返回 AiToEarn
live-commit-manual-edits.mjs
根目录 / project / aitoearn-web / .agents / skills / impeccable / scripts / live-commit-manual-edits.mjs
1 #!/usr/bin/env node
2 /**
3 * CLI helper: apply pending live copy edits as one AI-owned batch.
4 *
5 * The browser Save path stages copy edits in .impeccable/live. This script is
6 * called by /manual-edit-commit when the user clicks Apply copy edits. It gives
7 * the local AI runner the full staged batch plus evidence, validates the files
8 * the runner reports touching, and clears only entries reported as applied.
9 *
10 * Usage:
11 * node live-commit-manual-edits.mjs
12 * node live-commit-manual-edits.mjs --page-url=/
13 *
14 * Output JSON:
15 * { applied, failed, files, cleared, count, pageUrl }
16 */
17
18 import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
19 import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live-manual-edits-buffer.mjs';
20 import { isGeneratedFile } from './is-generated.mjs';
21 import {
22 runCopyEditBatchAgent,
23 runCopyEditPostApplyChecks,
24 } from './live-copy-edit-agent.mjs';
25 import fs from 'node:fs';
26 import path from 'node:path';
27
28 const ROLLBACK_EXTENSIONS = new Set([
29 '.astro',
30 '.cjs',
31 '.css',
32 '.htm',
33 '.html',
34 '.js',
35 '.json',
36 '.jsx',
37 '.md',
38 '.mdx',
39 '.mjs',
40 '.scss',
41 '.svelte',
42 '.svg',
43 '.ts',
44 '.tsx',
45 '.txt',
46 '.vue',
47 '.yaml',
48 '.yml',
49 ]);
50 const ROLLBACK_SKIP_DIRS = new Set([
51 '.astro',
52 '.git',
53 '.impeccable',
54 '.next',
55 '.nuxt',
56 '.svelte-kit',
57 'build',
58 'coverage',
59 'dist',
60 'node_modules',
61 'out',
62 ]);
63 const DEFAULT_REPAIR_ATTEMPTS = 3;
64
65 function argVal(args, name) {
66 const prefix = name + '=';
67 for (const arg of args) {
68 if (arg === name) return true;
69 if (arg.startsWith(prefix)) return arg.slice(prefix.length);
70 }
71 return null;
72 }
73
74 function countOps(entries) {
75 let count = 0;
76 for (const entry of entries || []) count += Array.isArray(entry.ops) ? entry.ops.length : 0;
77 return count;
78 }
79
80 function summarizeAppliedEntries(entries, appliedEntryIds) {
81 const ids = new Set(appliedEntryIds);
82 const out = [];
83 for (const entry of entries || []) {
84 if (!ids.has(entry.id)) continue;
85 for (const op of entry.ops || []) {
86 out.push({
87 id: entry.id,
88 ref: op.ref,
89 originalText: op.originalText,
90 newText: op.newText,
91 });
92 }
93 }
94 return out;
95 }
96
97 function normalizeFailedEntries(batch, result, fallbackReason) {
98 const failed = [];
99 const failedByEntryId = new Map();
100 for (const item of result?.failed || []) {
101 const entryId = item.entryId || item.id || null;
102 if (!entryId) continue;
103 failedByEntryId.set(entryId, item);
104 }
105
106 for (const entry of batch.entries || []) {
107 const item = failedByEntryId.get(entry.id);
108 if (!item) continue;
109 failed.push({
110 id: entry.id,
111 reason: item.reason || item.message || fallbackReason || 'failed',
112 candidates: Array.isArray(item.candidates) && item.candidates.length > 0
113 ? item.candidates
114 : candidatesForEntry(batch, entry.id),
115 });
116 }
117 return failed;
118 }
119
120 function mergeFailedEntries(...groups) {
121 const out = [];
122 const indexById = new Map();
123 for (const item of groups.flatMap((group) => Array.isArray(group) ? group : [])) {
124 if (!item || typeof item !== 'object') continue;
125 const id = typeof item.id === 'string' && item.id ? item.id : null;
126 if (!id) {
127 out.push(item);
128 continue;
129 }
130 const existingIndex = indexById.get(id);
131 if (existingIndex === undefined) {
132 indexById.set(id, out.length);
133 out.push(item);
134 continue;
135 }
136 out[existingIndex] = {
137 ...out[existingIndex],
138 ...item,
139 candidates: item.candidates || out[existingIndex].candidates,
140 checks: item.checks || out[existingIndex].checks,
141 };
142 }
143 return out;
144 }
145
146 function candidatesForEntry(batch, entryId) {
147 return (batch.candidates || [])
148 .filter((candidate) => candidate.entryId === entryId)
149 .flatMap((candidate) => [
150 ...(candidate.sourceHint ? [candidate.sourceHint] : []),
151 ...(candidate.textMatches || []),
152 ...(candidate.objectKeyMatches || []),
153 ...(candidate.locatorMatches || []),
154 ...(candidate.contextTextMatches || []),
155 ])
156 .slice(0, 12);
157 }
158
159 function uniqueStrings(values) {
160 return [...new Set(values.filter((value) => typeof value === 'string' && value.trim()))];
161 }
162
163 function allEntryIds(batch) {
164 return (batch?.entries || []).map((entry) => entry.id).filter(Boolean);
165 }
166
167 function mergeUniqueStrings(...groups) {
168 return uniqueStrings(groups.flatMap((group) => Array.isArray(group) ? group : []));
169 }
170
171 function repairAttemptLimit(env = process.env) {
172 const value = Number(env.IMPECCABLE_LIVE_MANUAL_EDIT_REPAIR_ATTEMPTS || DEFAULT_REPAIR_ATTEMPTS);
173 if (!Number.isFinite(value)) return DEFAULT_REPAIR_ATTEMPTS;
174 return Math.max(1, Math.min(10, Math.trunc(value)));
175 }
176
177 function summarizeRepairFailures(failures = []) {
178 return failures.map((failure) => {
179 const out = {
180 reason: failure.reason || failure.detail || 'validation_failed',
181 };
182 if (failure.id || failure.entryId) out.entryId = failure.id || failure.entryId;
183 if (failure.ref) out.ref = failure.ref;
184 if (failure.detail) out.detail = failure.detail;
185 if (failure.file) out.file = failure.file;
186 if (failure.message) out.message = failure.message;
187 if (failure.marker) out.marker = failure.marker;
188 if (Array.isArray(failure.files)) out.files = failure.files.slice(0, 8);
189 if (Array.isArray(failure.candidates)) {
190 out.candidates = failure.candidates.slice(0, 8).map((candidate) => ({
191 file: candidate.file,
192 line: candidate.line,
193 kind: candidate.kind,
194 reason: candidate.reason,
195 }));
196 }
197 if (Array.isArray(failure.failures)) {
198 out.failures = failure.failures.slice(0, 8).map((item) => ({
199 ref: item.ref,
200 reason: item.reason || item.detail,
201 detail: item.detail,
202 candidates: Array.isArray(item.candidates)
203 ? item.candidates.slice(0, 6).map((candidate) => ({
204 file: candidate.file,
205 line: candidate.line,
206 kind: candidate.kind,
207 reason: candidate.reason,
208 }))
209 : undefined,
210 }));
211 }
212 if (failure.checks) out.checks = failure.checks;
213 return out;
214 }).slice(0, 20);
215 }
216
217 function buildRepairBatch(batch, repair) {
218 return {
219 ...batch,
220 repair,
221 };
222 }
223
224 function normalizeProjectSourcePath(cwd, file, opts = {}) {
225 if (!file || typeof file !== 'string') return null;
226 const absolute = path.isAbsolute(file) ? file : path.resolve(cwd, file);
227 const relative = path.relative(cwd, absolute);
228 if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null;
229 if (opts.requireExists && !fs.existsSync(absolute)) return null;
230 if (isGeneratedFile(absolute, { cwd })) return null;
231 return relative;
232 }
233
234 function normalizeRelativeFile(cwd, file) {
235 return normalizeProjectSourcePath(cwd, file, { requireExists: true });
236 }
237
238 function sourceHintWindowFailure(cwd, op) {
239 const hint = op?.sourceHint;
240 if (!hint?.file || !hint.line) return null;
241 const relative = normalizeRelativeFile(cwd, hint.file);
242 if (!relative) return null;
243 const absolute = path.resolve(cwd, relative);
244 let content;
245 try { content = fs.readFileSync(absolute, 'utf-8'); } catch { return null; }
246 const lines = content.split('\n');
247 const line = Math.max(1, Number(hint.line) || 1);
248 const lineText = lines[line - 1] || '';
249 const start = Math.max(0, line - 5);
250 const end = Math.min(lines.length, line + 4);
251 if (
252 typeof op.originalText === 'string'
253 && op.originalText
254 && lineText.includes(op.originalText)
255 && !lineShowsAppliedOp(lineText, op)
256 ) {
257 return {
258 file: relative,
259 line,
260 reason: 'source_hint_still_contains_original_text',
261 };
262 }
263 if (lines.slice(start, end).some((candidateLine) => lineShowsAppliedOp(candidateLine, op))) return null;
264 return null;
265 }
266
267 function verificationTargetsForOp(batch, op, reportedFiles, cwd) {
268 const candidate = (batch.candidates || []).find((item) => item.entryId === op.entryId && item.ref === op.ref);
269 const out = [];
270 const reportedFileSet = new Set(reportedFiles || []);
271 const add = (file, line, kind) => {
272 const relativeFile = normalizeRelativeFile(cwd, file);
273 const lineNumber = Number(line);
274 if (!relativeFile || !Number.isFinite(lineNumber) || lineNumber < 1) return;
275 out.push({ file: relativeFile, line: lineNumber, kind, reported: reportedFileSet.has(relativeFile) });
276 };
277
278 add(op.sourceHint?.file, op.sourceHint?.line, 'source_hint');
279 add(candidate?.sourceHint?.relativeFile || candidate?.sourceHint?.file, candidate?.sourceHint?.line, 'candidate_source_hint');
280 for (const item of candidate?.textMatches || []) add(item.file, item.line, 'text_match');
281 for (const item of candidate?.objectKeyMatches || []) add(item.file, item.line, 'object_key_match');
282 for (const item of candidate?.locatorMatches || []) add(item.file, item.line, 'locator_match');
283 for (const item of candidate?.contextTextMatches || []) add(item.file, item.line, 'context_text_match');
284
285 // Manual copy edits often stage coupled leaves from the same UI object, e.g.
286 // a card label plus its count. Dynamic source stores both on the label/key
287 // line, so the count op may need the sibling label's data candidates.
288 for (const siblingCandidate of siblingCandidatesForEntry(batch, op)) {
289 add(siblingCandidate.sourceHint?.relativeFile || siblingCandidate.sourceHint?.file, siblingCandidate.sourceHint?.line, 'entry_source_hint');
290 for (const item of siblingCandidate.textMatches || []) add(item.file, item.line, 'entry_text_match');
291 for (const item of siblingCandidate.objectKeyMatches || []) add(item.file, item.line, 'entry_object_key_match');
292 for (const item of siblingCandidate.contextTextMatches || []) add(item.file, item.line, 'entry_context_text_match');
293 }
294
295 for (const relativeFile of reportedFiles || []) {
296 for (const target of locatorTargetsInFile(cwd, relativeFile, op)) {
297 out.push(target);
298 }
299 }
300
301 const seen = new Set();
302 return out.filter((target) => {
303 const key = target.file + ':' + target.line + ':' + target.kind;
304 if (seen.has(key)) return false;
305 seen.add(key);
306 return true;
307 });
308 }
309
310 function objectKeyCandidatesForOp(batch, op) {
311 const candidates = (batch.candidates || [])
312 .filter((item) => item.entryId === op.entryId && item.ref === op.ref);
313 return candidates.flatMap((candidate) => candidate.objectKeyMatches || []);
314 }
315
316 function lineHasObjectKey(line, text) {
317 if (typeof text !== 'string' || text.length === 0) return false;
318 const quotedKey = new RegExp('(^|[\\s,{])([\'"`])' + escapeRegExp(text) + '\\2\\s*:');
319 if (quotedKey.test(line)) return true;
320 const identifierSafe = /^[A-Za-z_$][\w$]*$/.test(text);
321 if (!identifierSafe) return false;
322 const bareKey = new RegExp('(^|[\\s,{])' + escapeRegExp(text) + '\\s*:');
323 return bareKey.test(line);
324 }
325
326 function objectKeyMatchStillUsesOriginal(cwd, match, op) {
327 const relative = normalizeRelativeFile(cwd, match?.file);
328 const lineNumber = Number(match?.line);
329 if (!relative || !Number.isFinite(lineNumber) || lineNumber < 1) return false;
330 let lines;
331 try { lines = fs.readFileSync(path.resolve(cwd, relative), 'utf-8').split('\n'); } catch { return false; }
332 const start = Math.max(0, lineNumber - 4);
333 const end = Math.min(lines.length, lineNumber + 3);
334 const windowLines = lines.slice(start, end);
335 if (windowLines.some((line) => lineHasObjectKey(line, op.newText))) return false;
336 return windowLines.some((line) => lineHasObjectKey(line, op.originalText));
337 }
338
339 function coupledObjectKeyFailuresForOp(batch, op, cwd) {
340 if (
341 typeof op?.originalText !== 'string'
342 || typeof op?.newText !== 'string'
343 || op.originalText === op.newText
344 ) return [];
345 return objectKeyCandidatesForOp(batch, op)
346 .filter((match) => objectKeyMatchStillUsesOriginal(cwd, match, op))
347 .map((match) => ({
348 ref: op.ref,
349 reason: 'source_verification_failed',
350 detail: 'edited_text_source_key_dependency_not_updated',
351 candidates: [{
352 file: normalizeRelativeFile(cwd, match.file) || match.file,
353 line: match.line,
354 kind: 'object_key_match',
355 reason: 'edited text is also a source key; update the coupled key to newText or fail the entry',
356 }],
357 }));
358 }
359
360 function siblingCandidatesForEntry(batch, op) {
361 if (!op?.entryId) return [];
362 return (batch.candidates || []).filter((item) => item.entryId === op.entryId && item.ref !== op.ref);
363 }
364
365 function locatorTargetsInFile(cwd, relativeFile, op) {
366 if (!opHasLocator(op)) return [];
367 const absolute = path.resolve(cwd, relativeFile);
368 let lines;
369 try { lines = fs.readFileSync(absolute, 'utf-8').split('\n'); } catch { return []; }
370 const out = [];
371 for (let index = 0; index < lines.length; index += 1) {
372 if (!lineMatchesManualEditLocator(lines[index], op)) continue;
373 out.push({ file: relativeFile, line: index + 1, kind: 'reported_locator_match' });
374 if (out.length >= 20) break;
375 }
376 return out;
377 }
378
379 function verificationTargetPasses(cwd, target, op) {
380 let lines;
381 try { lines = fs.readFileSync(path.resolve(cwd, target.file), 'utf-8').split('\n'); } catch { return false; }
382 return verificationTargetPassesLines(lines, target, op);
383 }
384
385 function verificationTargetPassesLines(lines, target, op) {
386 const line = lines[target.line - 1] || '';
387 if (lineShowsAppliedOp(line, op)) return true;
388 const originalText = typeof op?.originalText === 'string' ? op.originalText : '';
389 if (originalText && line.includes(originalText)) return false;
390 const kind = String(target.kind || '');
391 const canSearchWindow = target.reported
392 || kind.includes('context_text_match')
393 || kind.includes('object_key_match')
394 || kind.includes('text_match');
395 if (!canSearchWindow) return false;
396 const radius = kind.includes('context_text_match') ? 20 : 4;
397 const start = Math.max(0, target.line - radius - 1);
398 const end = Math.min(lines.length, target.line + radius);
399 const windowLines = lines.slice(start, end);
400 if (windowLines.some((candidateLine) => lineShowsAppliedOp(candidateLine, op))) return true;
401 if (windowShowsAppliedOp(windowLines, op)) return true;
402 return false;
403 }
404
405 function windowShowsAppliedOp(lines, op) {
406 const newText = typeof op?.newText === 'string' ? op.newText : '';
407 if (!newText) return false;
408 const originalText = typeof op?.originalText === 'string' ? op.originalText : '';
409 const normalizedNew = normalizeVerificationText(newText);
410 const normalizedOriginal = normalizeVerificationText(originalText);
411 const normalizedWindow = normalizeVerificationText(lines.join('\n'));
412 if (!normalizedNew || !normalizedWindow.includes(normalizedNew)) return false;
413 if (normalizedOriginal && !normalizedNew.includes(normalizedOriginal) && normalizedWindow.includes(normalizedOriginal)) return false;
414 return true;
415 }
416
417 function normalizeVerificationText(text) {
418 return String(text || '').replace(/\s+/g, ' ').trim();
419 }
420
421 function lineShowsAppliedOp(line, op) {
422 const originalText = typeof op?.originalText === 'string' ? op.originalText : '';
423 const newText = typeof op?.newText === 'string' ? op.newText : '';
424 const deletion = op?.deleted === true || newText.length === 0;
425 if (deletion) return !!originalText && !line.includes(originalText);
426 if (!line.includes(newText)) return false;
427 if (originalText && !newText.includes(originalText) && line.includes(originalText)) return false;
428 return true;
429 }
430
431 function opHasLocator(op) {
432 return !!(
433 op?.tag
434 || op?.elementId
435 || (Array.isArray(op?.classes) && op.classes.filter(Boolean).length > 0)
436 );
437 }
438
439 function lineMatchesManualEditLocator(line, op) {
440 if (op.tag) {
441 const tagRe = new RegExp('<\\s*' + escapeRegExp(op.tag) + '(?=[\\s>/]|$)', 'i');
442 if (!tagRe.test(line)) return false;
443 }
444
445 if (op.elementId) {
446 const idRe = new RegExp('\\bid\\s*=\\s*["\']' + escapeRegExp(op.elementId) + '["\']');
447 if (!idRe.test(line)) return false;
448 }
449
450 const classes = Array.isArray(op.classes) ? op.classes.filter(Boolean) : [];
451 for (const className of classes) {
452 if (!line.includes(className)) return false;
453 }
454
455 return true;
456 }
457
458 function verifyAppliedEntry({ batch, entry, reportedFiles, cwd }) {
459 const failures = [];
460 for (const rawOp of entry.ops || []) {
461 const op = { ...rawOp, entryId: entry.id };
462 if (op.deleted === true && typeof op.newText !== 'string') op.newText = '';
463 if (typeof op.newText !== 'string') {
464 failures.push({
465 ref: op.ref,
466 reason: 'source_verification_failed',
467 detail: 'missing_newText',
468 candidates: candidatesForEntry(batch, entry.id).slice(0, 12),
469 });
470 continue;
471 }
472 const targets = verificationTargetsForOp(batch, op, reportedFiles, cwd);
473 const coupledObjectKeyFailures = coupledObjectKeyFailuresForOp(batch, op, cwd);
474 if (
475 coupledObjectKeyFailures.length === 0
476 && targets.some((target) => verificationTargetPasses(cwd, target, op))
477 ) continue;
478
479 if (coupledObjectKeyFailures.length > 0) {
480 failures.push(...coupledObjectKeyFailures.map((failure) => ({
481 ...failure,
482 candidates: [
483 ...(failure.candidates || []),
484 ...targets.map((target) => ({ file: target.file, line: target.line, kind: target.kind })),
485 ...candidatesForEntry(batch, entry.id),
486 ].slice(0, 12),
487 })));
488 continue;
489 }
490
491 const hintedOldText = sourceHintWindowFailure(cwd, op);
492 if (hintedOldText) {
493 failures.push({
494 ref: op.ref,
495 reason: 'source_verification_failed',
496 detail: hintedOldText.reason,
497 candidates: [hintedOldText, ...targets.map((target) => ({ file: target.file, line: target.line, kind: target.kind })), ...candidatesForEntry(batch, entry.id)].slice(0, 12),
498 });
499 continue;
500 }
501
502 failures.push({
503 ref: op.ref,
504 reason: 'source_verification_failed',
505 detail: op.newText.length === 0 ? 'originalText_still_present_in_plausible_source_location' : 'newText_not_found_in_plausible_source_location',
506 candidates: targets.map((target) => ({ file: target.file, line: target.line, kind: target.kind })).concat(candidatesForEntry(batch, entry.id)).slice(0, 12),
507 });
508 }
509 return failures;
510 }
511
512 function snapshotTargetPasses(snapshot, target, op) {
513 const before = snapshot.get(target.file)?.content;
514 if (typeof before !== 'string') return false;
515 return verificationTargetPassesLines(before.split('\n'), target, op);
516 }
517
518 function findUnappliedEntrySourceChanges({ batch, entries, reportedFiles, cwd, rollbackSnapshot }) {
519 const failures = [];
520 for (const entry of entries || []) {
521 for (const rawOp of entry.ops || []) {
522 const op = { ...rawOp, entryId: entry.id };
523 if (typeof op.newText !== 'string' || op.newText.length === 0) continue;
524 const targets = verificationTargetsForOp(batch, op, reportedFiles, cwd);
525 const leakedTargets = targets.filter((target) =>
526 verificationTargetPasses(cwd, target, op)
527 && !snapshotTargetPasses(rollbackSnapshot, target, op)
528 );
529 if (leakedTargets.length === 0) continue;
530 failures.push({
531 id: entry.id,
532 reason: 'failed_entry_source_changed',
533 ref: op.ref,
534 newText: op.newText,
535 candidates: leakedTargets
536 .map((target) => ({ file: target.file, line: target.line, kind: target.kind }))
537 .concat(candidatesForEntry(batch, entry.id))
538 .slice(0, 12),
539 });
540 break;
541 }
542 }
543 return failures;
544 }
545
546 function verificationFailuresForEntries(batch, entries, reason, extra = {}) {
547 return entries.map((entry) => ({
548 id: entry.id,
549 reason,
550 candidates: candidatesForEntry(batch, entry.id),
551 ...extra,
552 }));
553 }
554
555 function clearAppliedEntries(cwd, appliedEntryIds) {
556 const ids = new Set(appliedEntryIds);
557 if (ids.size === 0) return 0;
558 const buffer = readBuffer(cwd);
559 let cleared = 0;
560 const kept = [];
561 for (const entry of buffer.entries || []) {
562 if (ids.has(entry.id)) {
563 cleared += Array.isArray(entry.ops) ? entry.ops.length : 0;
564 } else {
565 kept.push(entry);
566 }
567 }
568 writeBuffer(cwd, { version: buffer.version || 1, entries: kept });
569 return cleared;
570 }
571
572 function snapshotRollbackFiles(cwd, files = null) {
573 const snapshot = new Map();
574 const rollbackFiles = Array.isArray(files) && files.length > 0
575 ? uniqueStrings(files).map((file) => normalizeRollbackPath(cwd, file)).filter(Boolean)
576 : collectRollbackFiles(cwd);
577 for (const relativeFile of rollbackFiles) {
578 const absolute = path.resolve(cwd, relativeFile);
579 try {
580 snapshot.set(relativeFile, {
581 existed: true,
582 content: fs.readFileSync(absolute, 'utf-8'),
583 });
584 } catch (err) {
585 if (err?.code === 'ENOENT') {
586 snapshot.set(relativeFile, { existed: false });
587 }
588 // Other read failures are not safe to roll back.
589 }
590 }
591 return snapshot;
592 }
593
594 function collectRollbackFiles(cwd) {
595 const out = [];
596 const seenDirs = new Set();
597 const seenFiles = new Set();
598 scanRollbackDir(cwd, cwd, out, seenDirs, seenFiles, 0);
599 return out;
600 }
601
602 function scanRollbackDir(dir, cwd, out, seenDirs, seenFiles, depth) {
603 if (depth > 10) return;
604 let realDir;
605 try { realDir = fs.realpathSync(dir); } catch { return; }
606 if (seenDirs.has(realDir)) return;
607 seenDirs.add(realDir);
608
609 let entries;
610 try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
611 for (const entry of entries) {
612 if (entry.isDirectory()) {
613 if (ROLLBACK_SKIP_DIRS.has(entry.name)) continue;
614 scanRollbackDir(path.join(dir, entry.name), cwd, out, seenDirs, seenFiles, depth + 1);
615 continue;
616 }
617 if (!entry.isFile()) continue;
618 if (!ROLLBACK_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) continue;
619 const absolute = path.join(dir, entry.name);
620 if (isGeneratedFile(absolute, { cwd })) continue;
621 let realFile;
622 try { realFile = fs.realpathSync(absolute); } catch { continue; }
623 if (seenFiles.has(realFile)) continue;
624 seenFiles.add(realFile);
625 const relative = path.relative(cwd, absolute);
626 if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) continue;
627 out.push(relative);
628 }
629 }
630
631 function changedFilesSinceSnapshot(cwd, snapshot, scopeFiles = null) {
632 const changed = new Map();
633 const scopedFiles = Array.isArray(scopeFiles) && scopeFiles.length > 0
634 ? scopeFiles.map((file) => normalizeRollbackPath(cwd, file)).filter(Boolean)
635 : null;
636 const currentFiles = new Set(scopedFiles || collectRollbackFiles(cwd));
637 for (const [relativeFile, before] of snapshot.entries()) {
638 if (scopedFiles && !currentFiles.has(relativeFile)) continue;
639 const absolute = path.resolve(cwd, relativeFile);
640 if (before?.existed === false) {
641 if (fs.existsSync(absolute)) changed.set(relativeFile, { file: relativeFile, kind: 'added' });
642 continue;
643 }
644 if (!fs.existsSync(absolute)) {
645 changed.set(relativeFile, { file: relativeFile, kind: 'deleted' });
646 continue;
647 }
648 let content;
649 try { content = fs.readFileSync(absolute, 'utf-8'); } catch { continue; }
650 if (content !== before.content) {
651 changed.set(relativeFile, { file: relativeFile, kind: 'modified' });
652 }
653 }
654 for (const relativeFile of currentFiles) {
655 if (!snapshot.has(relativeFile)) {
656 changed.set(relativeFile, { file: relativeFile, kind: 'unknown' });
657 }
658 }
659 return [...changed.values()];
660 }
661
662 function rollbackChangedFiles(cwd, snapshot, extraFiles = [], scopeFiles = []) {
663 const scope = new Set(
664 [...(scopeFiles || []), ...(extraFiles || [])]
665 .map((file) => normalizeRollbackPath(cwd, file))
666 .filter(Boolean),
667 );
668 const changed = changedFilesSinceSnapshot(cwd, snapshot, [...scope]);
669 const byFile = new Map(changed.map((item) => [item.file, item]));
670 for (const file of extraFiles || []) {
671 const relative = normalizeRollbackPath(cwd, file);
672 if (relative && !byFile.has(relative)) {
673 byFile.set(relative, { file: relative, kind: snapshot.has(relative) ? 'reported' : 'unknown' });
674 }
675 }
676
677 const rolledBackFiles = [];
678 const rollbackFailures = [];
679 for (const item of byFile.values()) {
680 if (!scope.has(item.file)) continue;
681 const absolute = path.resolve(cwd, item.file);
682 const before = snapshot.get(item.file);
683 try {
684 if (before?.existed !== false && typeof before?.content === 'string') {
685 fs.mkdirSync(path.dirname(absolute), { recursive: true });
686 fs.writeFileSync(absolute, before.content, 'utf-8');
687 } else if (before?.existed === false && item.kind === 'added' && fs.existsSync(absolute)) {
688 fs.rmSync(absolute);
689 } else {
690 rollbackFailures.push({ file: item.file, reason: 'no_snapshot' });
691 continue;
692 }
693 rolledBackFiles.push(item.file);
694 } catch (err) {
695 rollbackFailures.push({ file: item.file, reason: 'restore_failed', message: err.message || String(err) });
696 }
697 }
698 return { rolledBackFiles, rollbackFailures };
699 }
700
701 function collectApplyOwnedFiles(batch, cwd, extraFiles = []) {
702 const files = [];
703 for (const entry of batch?.entries || []) {
704 for (const op of entry.ops || []) files.push(op.sourceHint?.file);
705 }
706 for (const candidate of batch?.candidates || []) {
707 files.push(candidate.sourceHint?.relativeFile, candidate.sourceHint?.file);
708 for (const item of candidate.textMatches || []) files.push(item.file);
709 for (const item of candidate.objectKeyMatches || []) files.push(item.file);
710 for (const item of candidate.locatorMatches || []) files.push(item.file);
711 for (const item of candidate.contextTextMatches || []) files.push(item.file);
712 }
713 files.push(...(extraFiles || []));
714 return uniqueStrings(files)
715 .map((file) => normalizeRollbackPath(cwd, file))
716 .filter(Boolean);
717 }
718
719 function unreportedChangedFiles(cwd, snapshot, reportedFiles, scopeFiles = []) {
720 const reported = new Set(
721 (reportedFiles || [])
722 .map((file) => normalizeRollbackPath(cwd, file))
723 .filter(Boolean),
724 );
725 const scope = new Set(
726 (scopeFiles || [])
727 .map((file) => normalizeRollbackPath(cwd, file))
728 .filter(Boolean),
729 );
730 return changedFilesSinceSnapshot(cwd, snapshot, [...scope])
731 .map((item) => item.file)
732 .filter((file) => scope.has(file))
733 .filter((file) => !reported.has(file));
734 }
735
736 function normalizeRollbackPath(cwd, file) {
737 return normalizeProjectSourcePath(cwd, file);
738 }
739
740 function verifyEntriesAfterRepair({ batch, appliedEntryIds, files, cwd }) {
741 const reportedFiles = uniqueStrings(files || [])
742 .map((file) => normalizeRelativeFile(cwd, file))
743 .filter(Boolean);
744 const entries = (batch.entries || []).filter((entry) => appliedEntryIds.includes(entry.id));
745 const verifiedIds = [];
746 const failed = [];
747 for (const entry of entries) {
748 const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
749 if (failures.length === 0) {
750 verifiedIds.push(entry.id);
751 } else {
752 failed.push({
753 id: entry.id,
754 reason: 'source_verification_failed',
755 failures,
756 candidates: candidatesForEntry(batch, entry.id),
757 });
758 }
759 }
760 return { verifiedIds, failed, reportedFiles };
761 }
762
763 async function repairPostApplyValidation({
764 batch,
765 cwd,
766 pageUrl,
767 count,
768 provider,
769 env,
770 timeoutMs,
771 applyBatchToSource,
772 chatAvailable,
773 transactionId,
774 appliedEntryIds,
775 files,
776 failed,
777 notes,
778 warnings,
779 postChecks,
780 repairReason = 'post_apply_validation_failed',
781 repairFailures = null,
782 }) {
783 const maxAttempts = repairAttemptLimit(env);
784 let currentFiles = mergeUniqueStrings(files || []);
785 let currentAppliedIds = mergeUniqueStrings(appliedEntryIds || []);
786 let currentFailed = Array.isArray(failed) ? failed : [];
787 let currentNotes = Array.isArray(notes) ? notes : [];
788 let currentWarnings = Array.isArray(warnings) ? warnings : [];
789 let currentFailures = Array.isArray(repairFailures) ? repairFailures : (postChecks?.failures || []);
790
791 for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
792 const repair = {
793 attempt,
794 maxAttempts,
795 transactionId: transactionId || null,
796 reason: repairReason,
797 failures: summarizeRepairFailures(currentFailures),
798 files: currentFiles,
799 pageUrl,
800 };
801 let repairResult;
802 try {
803 repairResult = await runCopyEditBatchAgent(buildRepairBatch(batch, repair), {
804 cwd,
805 provider,
806 env,
807 timeoutMs,
808 applyBatchToSource,
809 chatAvailable,
810 });
811 } catch (err) {
812 currentFailures = [{
813 reason: 'repair_agent_failed',
814 message: err.message || String(err),
815 }];
816 continue;
817 }
818
819 currentFiles = mergeUniqueStrings(currentFiles, repairResult.files || []);
820 currentNotes = [...currentNotes, ...(repairResult.notes || [])];
821 currentWarnings = [...currentWarnings, ...(repairResult.warnings || [])];
822 currentAppliedIds = mergeUniqueStrings(currentAppliedIds, repairResult.appliedEntryIds || []);
823 currentFailed = mergeFailedEntries(
824 currentFailed,
825 normalizeFailedEntries(batch, repairResult, 'repair_failed'),
826 );
827
828 const verified = verifyEntriesAfterRepair({
829 batch,
830 appliedEntryIds: currentAppliedIds,
831 files: currentFiles,
832 cwd,
833 });
834 if (verified.failed.length > 0) {
835 currentFailures = verified.failed;
836 continue;
837 }
838
839 const repairedChecks = runCopyEditPostApplyChecks({ cwd, files: currentFiles });
840 currentWarnings = [...currentWarnings, ...(repairedChecks.warnings || [])];
841 if (!repairedChecks.ok) {
842 currentFailures = repairedChecks.failures || [];
843 continue;
844 }
845
846 const cleared = clearAppliedEntries(cwd, verified.verifiedIds);
847 const counts = countByPage(cwd);
848 const verifiedIdSet = new Set(verified.verifiedIds);
849 return {
850 applied: summarizeAppliedEntries(batch.entries, verified.verifiedIds),
851 failed: mergeFailedEntries(currentFailed).filter((item) => !verifiedIdSet.has(item.id)),
852 files: currentFiles,
853 cleared,
854 count,
855 pageUrl,
856 warnings: currentWarnings,
857 notes: currentNotes,
858 repair: {
859 status: 'repaired',
860 attempts: attempt,
861 maxAttempts,
862 transactionId: transactionId || null,
863 },
864 ...counts,
865 };
866 }
867
868 const decisionFailedEntries = currentAppliedIds.length > 0
869 ? (batch.entries || [])
870 .filter((entry) => currentAppliedIds.includes(entry.id))
871 .map((entry) => ({
872 id: entry.id,
873 reason: repairReason,
874 checks: currentFailures,
875 candidates: candidatesForEntry(batch, entry.id),
876 }))
877 : verificationFailuresForEntries(batch, batch.entries || [], repairReason, { checks: currentFailures });
878 return {
879 applied: [],
880 failed: mergeFailedEntries(decisionFailedEntries, currentFailed),
881 files: currentFiles,
882 cleared: 0,
883 count,
884 pageUrl,
885 warnings: currentWarnings,
886 notes: currentNotes,
887 reason: 'manual_edit_repair_needs_decision',
888 needsManualDecision: true,
889 repair: {
890 status: 'needs_decision',
891 attempts: maxAttempts,
892 maxAttempts,
893 transactionId: transactionId || null,
894 failures: summarizeRepairFailures(currentFailures),
895 files: currentFiles,
896 },
897 ...countByPage(cwd),
898 };
899 }
900
901 export async function commitManualEdits({
902 cwd = process.cwd(),
903 pageUrl = null,
904 provider = undefined,
905 env = process.env,
906 timeoutMs = undefined,
907 applyBatchToSource = undefined,
908 chatAvailable = undefined,
909 repairOnly = false,
910 transactionId = null,
911 batch: providedBatch = null,
912 } = {}) {
913 try {
914 readBufferStrict(cwd);
915 } catch (err) {
916 return {
917 applied: [],
918 failed: [],
919 files: [],
920 cleared: 0,
921 count: 0,
922 pageUrl,
923 reason: 'manual_edit_buffer_invalid',
924 message: err.message || String(err),
925 ...countByPage(cwd),
926 };
927 }
928
929 const batch = providedBatch || buildManualEditEvidence({ cwd, pageUrl });
930 const count = countOps(batch.entries);
931 if (count === 0) {
932 return {
933 applied: [],
934 failed: [],
935 files: [],
936 cleared: 0,
937 count: 0,
938 pageUrl,
939 reason: 'no_pending_edits',
940 ...countByPage(cwd),
941 };
942 }
943
944 const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
945 const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
946 let result;
947 try {
948 result = repairOnly
949 ? {
950 status: 'done',
951 appliedEntryIds: allEntryIds(batch),
952 failed: [],
953 files: collectApplyOwnedFiles(batch, cwd),
954 notes: ['repair-only validation pass'],
955 }
956 : await runCopyEditBatchAgent(batch, {
957 cwd,
958 provider,
959 env,
960 timeoutMs,
961 applyBatchToSource,
962 chatAvailable,
963 });
964 } catch (err) {
965 const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
966 return {
967 applied: [],
968 failed: batch.entries.map((entry) => ({
969 id: entry.id,
970 reason: err.message || String(err),
971 candidates: candidatesForEntry(batch, entry.id),
972 })),
973 files: [],
974 cleared: 0,
975 count,
976 pageUrl,
977 rolledBackFiles: rollback.rolledBackFiles,
978 rollbackFailures: rollback.rollbackFailures,
979 ...countByPage(cwd),
980 };
981 }
982
983 if (result.status === 'error') {
984 const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
985 const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
986 const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
987 return {
988 applied: [],
989 failed: failed.length > 0
990 ? failed
991 : verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
992 files: result.files || [],
993 cleared: 0,
994 count,
995 pageUrl,
996 notes: result.notes || [],
997 rolledBackFiles: rollback.rolledBackFiles,
998 rollbackFailures: rollback.rollbackFailures,
999 ...countByPage(cwd),
1000 };
1001 }
1002
1003 const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
1004 const reportedFiles = uniqueStrings(result.files || [])
1005 .map((file) => normalizeRelativeFile(cwd, file))
1006 .filter(Boolean);
1007 const aiFailed = normalizeFailedEntries(batch, result, 'AI copy edit failed');
1008 const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
1009 const failedIds = new Set(aiFailed.map((item) => item.id).filter(Boolean));
1010 const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
1011
1012 if (conflictingAppliedIds.length > 0) {
1013 const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
1014 const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
1015 return {
1016 applied: [],
1017 failed: [
1018 ...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
1019 ...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
1020 ],
1021 files: result.files || [],
1022 cleared: 0,
1023 count,
1024 pageUrl,
1025 notes: result.notes || [],
1026 rolledBackFiles: rollback.rolledBackFiles,
1027 rollbackFailures: rollback.rollbackFailures,
1028 ...countByPage(cwd),
1029 };
1030 }
1031
1032 const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
1033 if (unreportedFiles.length > 0) {
1034 const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
1035 return {
1036 applied: [],
1037 failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
1038 files: result.files || [],
1039 unreportedFiles,
1040 cleared: 0,
1041 count,
1042 pageUrl,
1043 notes: result.notes || [],
1044 rolledBackFiles: rollback.rolledBackFiles,
1045 rollbackFailures: rollback.rollbackFailures,
1046 ...countByPage(cwd),
1047 };
1048 }
1049
1050 if (result.status === 'done' && reportedAppliedIds.length === 0) {
1051 const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
1052 return {
1053 applied: [],
1054 failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
1055 files: result.files || [],
1056 cleared: 0,
1057 count,
1058 pageUrl,
1059 notes: result.notes || [],
1060 rolledBackFiles: rollback.rolledBackFiles,
1061 rollbackFailures: rollback.rollbackFailures,
1062 ...countByPage(cwd),
1063 };
1064 }
1065
1066 const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
1067 if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
1068 return repairPostApplyValidation({
1069 batch,
1070 cwd,
1071 pageUrl,
1072 count,
1073 provider,
1074 env,
1075 timeoutMs,
1076 applyBatchToSource,
1077 chatAvailable,
1078 transactionId,
1079 appliedEntryIds: reportedAppliedIds,
1080 files: result.files || [],
1081 failed: aiFailed,
1082 notes: result.notes || [],
1083 warnings: result.warnings || [],
1084 repairReason: 'missing_touched_files',
1085 repairFailures: verificationFailuresForEntries(batch, reportedAppliedEntries, 'missing_touched_files'),
1086 });
1087 }
1088
1089 const verifiedAppliedIds = [];
1090 const verificationFailed = [];
1091 for (const entry of reportedAppliedEntries) {
1092 const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
1093 if (failures.length === 0) {
1094 verifiedAppliedIds.push(entry.id);
1095 } else {
1096 verificationFailed.push({
1097 id: entry.id,
1098 reason: 'source_verification_failed',
1099 failures,
1100 candidates: candidatesForEntry(batch, entry.id),
1101 });
1102 }
1103 }
1104 const unreportedEntries = result.status === 'done' || result.status === 'partial'
1105 ? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
1106 : [];
1107 const nonRepairFailed = [
1108 ...verificationFailuresForEntries(batch, unreportedEntries, 'not_reported_applied'),
1109 ...aiFailed,
1110 ];
1111 const failed = [
1112 ...verificationFailed,
1113 ...nonRepairFailed,
1114 ];
1115
1116 const unappliedEntries = batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id));
1117 const leakedUnapplied = findUnappliedEntrySourceChanges({
1118 batch,
1119 entries: unappliedEntries,
1120 reportedFiles,
1121 cwd,
1122 rollbackSnapshot,
1123 });
1124 if (leakedUnapplied.length > 0) {
1125 const leakedIds = new Set(leakedUnapplied.map((item) => item.id).filter(Boolean));
1126 const rolledBackVerified = reportedAppliedEntries
1127 .filter((entry) => verifiedAppliedIds.includes(entry.id))
1128 .map((entry) => ({
1129 id: entry.id,
1130 reason: 'rolled_back_due_to_failed_entry_source_changed',
1131 candidates: candidatesForEntry(batch, entry.id),
1132 }));
1133 const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
1134 return {
1135 applied: [],
1136 failed: [
1137 ...leakedUnapplied,
1138 ...failed.filter((item) => !leakedIds.has(item.id)),
1139 ...rolledBackVerified,
1140 ],
1141 files: result.files || [],
1142 cleared: 0,
1143 count,
1144 pageUrl,
1145 rolledBackFiles: rollback.rolledBackFiles,
1146 rollbackFailures: rollback.rollbackFailures,
1147 notes: result.notes || [],
1148 ...countByPage(cwd),
1149 };
1150 }
1151
1152 if (verificationFailed.length > 0) {
1153 return repairPostApplyValidation({
1154 batch,
1155 cwd,
1156 pageUrl,
1157 count,
1158 provider,
1159 env,
1160 timeoutMs,
1161 applyBatchToSource,
1162 chatAvailable,
1163 transactionId,
1164 appliedEntryIds: reportedAppliedIds,
1165 files: result.files || [],
1166 failed: nonRepairFailed,
1167 notes: result.notes || [],
1168 warnings: result.warnings || [],
1169 repairReason: 'source_verification_failed',
1170 repairFailures: verificationFailed,
1171 });
1172 }
1173
1174 const postChecks = runCopyEditPostApplyChecks({ cwd, files: result.files || [] });
1175 if (!postChecks.ok) {
1176 const postCheckEntries = verifiedAppliedIds.length > 0
1177 ? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
1178 : batch.entries;
1179 return repairPostApplyValidation({
1180 batch,
1181 cwd,
1182 pageUrl,
1183 count,
1184 provider,
1185 env,
1186 timeoutMs,
1187 applyBatchToSource,
1188 chatAvailable,
1189 transactionId,
1190 appliedEntryIds: verifiedAppliedIds.length > 0
1191 ? verifiedAppliedIds
1192 : postCheckEntries.map((entry) => entry.id).filter(Boolean),
1193 files: result.files || [],
1194 failed,
1195 notes: result.notes || [],
1196 warnings: [...(result.warnings || []), ...(postChecks.warnings || [])],
1197 postChecks,
1198 });
1199 }
1200
1201 const cleared = clearAppliedEntries(cwd, verifiedAppliedIds);
1202 const counts = countByPage(cwd);
1203 return {
1204 applied: summarizeAppliedEntries(batch.entries, verifiedAppliedIds),
1205 failed,
1206 files: result.files || [],
1207 cleared,
1208 count,
1209 pageUrl,
1210 warnings: [...(result.warnings || []), ...(postChecks.warnings || [])],
1211 notes: result.notes || [],
1212 ...counts,
1213 };
1214 }
1215
1216 async function main() {
1217 const args = process.argv.slice(2);
1218 if (args.includes('--help') || args.includes('-h')) {
1219 console.log('Usage: node live-commit-manual-edits.mjs [--page-url=<url>] [--provider=auto|codex|claude|mock]');
1220 process.exit(0);
1221 }
1222
1223 const result = await commitManualEdits({
1224 cwd: process.cwd(),
1225 pageUrl: argVal(args, '--page-url'),
1226 provider: argVal(args, '--provider') || undefined,
1227 timeoutMs: Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000),
1228 });
1229 console.log(JSON.stringify(result));
1230 }
1231
1232 if (process.argv[1]?.endsWith('live-commit-manual-edits.mjs')) {
1233 main().catch((err) => {
1234 console.error(JSON.stringify({ error: 'commit_failed', message: err.message || String(err) }));
1235 process.exit(1);
1236 });
1237 }
1238
1239 function escapeRegExp(value) {
1240 return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1241 }
1242
1242 lines Plain Text