← back to Codex Yolo

scripts/apply-trivial-patches.cjs

126 lines

#!/usr/bin/env node
// apply-trivial-patches.cjs
// Reads a finished claude-codex debate dir → spawns Claude Code CLI with a
// strict prompt → asks for ONLY high-confidence trivial fixes as a JSON list
// of {file, search, replace, kind} → applies them.
// LOGIC changes get diff-printed to <project>/_yolo_patches/iter-N.diff for
// Steve's morning review — never auto-applied.
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');

const args = Object.fromEntries(
  process.argv.slice(2).reduce((acc, a, i, A) => {
    if (a.startsWith('--')) acc.push([a.slice(2), A[i+1]]);
    return acc;
  }, [])
);
const DEBATE   = args.debate;
const PROJECT  = args.project ? path.resolve(args.project) : args.project;
const ITER     = args.iter || '1';
const MAX_APP  = parseInt(args['max-applied'] || '5', 10);

if (!DEBATE || !PROJECT) {
  console.error('usage: apply-trivial-patches.cjs --debate DIR --project DIR [--iter N] [--max-applied N]');
  process.exit(2);
}

const finalReport = (() => {
  const fp = path.join(DEBATE, 'final_report.md');
  if (fs.existsSync(fp)) return fs.readFileSync(fp, 'utf8');
  // Fallback: concat round_*/*.txt
  const rounds = fs.readdirSync(DEBATE).filter(n => /^round_\d+$/.test(n));
  let buf = '';
  for (const r of rounds) {
    for (const f of fs.readdirSync(path.join(DEBATE, r))) {
      buf += `\n=== ${r}/${f} ===\n` + fs.readFileSync(path.join(DEBATE, r, f), 'utf8');
    }
  }
  return buf;
})();

if (!finalReport.trim()) {
  console.log('[apply] empty debate report, nothing to do');
  process.exit(0);
}

const patchDir = path.join(PROJECT, '_yolo_patches');
fs.mkdirSync(patchDir, { recursive: true });

// Strict prompt — asks Claude Code CLI for a JSON-only response of trivial patches
const prompt = `You are reviewing a claude-codex 8-way debate report for a code project.
Project root: ${PROJECT}

# Debate report (truncated)
${finalReport.slice(0, 18000)}

# Your job
Output ONLY a single JSON object on one line. No prose, no markdown fence.
The shape:
{ "trivial": [ {"file":"<rel path>","search":"<EXACT substring>","replace":"<new substring>","kind":"typo|null-guard|missing-await|regex-escape|off-by-one"} ], "logic": [ {"file":"<rel>","summary":"<1-line>","unified_diff":"<full diff>"} ] }

Rules:
- "trivial" entries MUST satisfy ALL: ≤1 file, ≤10 lines changed, ≤120-char search string, search appears exactly once in the file, replacement preserves identifiers used elsewhere.
- Skip anything that touches: dw_unified, Shopify, DNS, payment, auth, schema migration, deletes data.
- If unsure, demote to "logic" with a unified_diff for review.
- If the debate report is empty / inconclusive, output {"trivial":[],"logic":[]}.
- Cap "trivial" at ${MAX_APP}.
`;

let response;
try {
  response = execFileSync('/Users/macstudio3/.claude/local/claude', [
    '-p', prompt,
    '--output-format','text',
    '--allowed-tools','Read,Glob,Grep'
  ], { encoding: 'utf8', maxBuffer: 4*1024*1024, timeout: 6*60*1000 });
} catch {
  // Fallback path: many machines have `claude` on PATH instead
  try {
    response = execFileSync('claude', [
      '-p', prompt, '--output-format','text',
      '--allowed-tools','Read,Glob,Grep'
    ], { encoding:'utf8', maxBuffer: 4*1024*1024, timeout: 6*60*1000 });
  } catch (e2) {
    console.error('[apply] claude CLI failed:', e2.message);
    process.exit(0);
  }
}

// Pull JSON out of the response (may have trailing whitespace / a fence)
const m = response.match(/\{[\s\S]*\}/);
if (!m) { console.log('[apply] no JSON in claude response'); process.exit(0); }
let data;
try { data = JSON.parse(m[0]); }
catch (e) { console.log('[apply] JSON parse fail:', e.message); process.exit(0); }

const trivial = Array.isArray(data.trivial) ? data.trivial.slice(0, MAX_APP) : [];
const logic   = Array.isArray(data.logic) ? data.logic : [];

// Write logic-change diff for Steve's morning review (NEVER apply)
if (logic.length) {
  const diff = logic.map(l => `# ${l.file}\n# ${l.summary}\n${l.unified_diff || '(no diff supplied)'}`).join('\n\n');
  fs.writeFileSync(path.join(patchDir, `iter-${ITER}.diff`), diff);
  console.log(`[apply] wrote ${logic.length} proposed logic patches → _yolo_patches/iter-${ITER}.diff`);
}

// Apply trivial patches one by one with strict safety
let applied = 0;
for (const p of trivial) {
  try {
    const fp = path.resolve(PROJECT, p.file);
    if (fp !== PROJECT && !fp.startsWith(PROJECT + path.sep)) { console.log('[apply] path escape, skip:', p.file); continue; }
    if (!fs.existsSync(fp)) { console.log('[apply] missing:', p.file); continue; }
    const before = fs.readFileSync(fp, 'utf8');
    const occ = before.split(p.search).length - 1;
    if (occ !== 1) { console.log(`[apply] search not unique (${occ}) in ${p.file}, skip`); continue; }
    if (Math.abs(p.replace.length - p.search.length) > 600) { console.log('[apply] huge replace, skip'); continue; }
    fs.writeFileSync(fp, before.replace(p.search, p.replace));
    console.log(`[apply] ${p.kind}: ${p.file}`);
    applied++;
  } catch (e) {
    console.log('[apply] one patch failed:', e.message);
  }
}
console.log(`[apply] applied=${applied} proposed_logic=${logic.length}`);