Last active
August 14, 2026 06:04
-
-
Save laiso/236be6cee65c887f7c86b8d01f175911 to your computer and use it in GitHub Desktop.
/red-pen-loop: Fix until the red pen runs dry.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // red-pen-loop — one model marks your work, another fixes it, until the marks are gone. | |
| // | |
| // Codex reviews, Claude fixes, repeat. Keeping the grader and the fixer separate is | |
| // the point: a model grading its own homework is the failure mode this avoids. | |
| // | |
| // Save as .claude/workflows/red-pen-loop.js (or ~/.claude/workflows/ for every project). | |
| // It runs as /red-pen-loop — the command name comes from meta.name, not the filename. | |
| // | |
| // Needs the `codex` CLI on PATH and `Bash(codex exec:*)` in your allowlist, or the | |
| // review agent stops to ask for permission on every round. | |
| // | |
| // /red-pen-loop codex [threshold] [model] [target] | |
| // | |
| // threshold 1|2|3, default 1 — fix everything at or below this priority | |
| // model passed to `codex -m`; omit or say "default" for codex's own | |
| // target default is uncommitted changes; base:<branch> or commit:<sha> | |
| // | |
| // A bare 1|2|3 is read as the threshold, so the model is skippable. Pass "default" | |
| // explicitly when you want a target but not a model. | |
| // | |
| // /red-pen-loop codex | |
| // /red-pen-loop codex 2 gpt-5.6-luna base:main | |
| // /red-pen-loop codex 1 default commit:abc1234 | |
| // | |
| // It stops when nothing is left at or below the threshold, when a round turns up only | |
| // findings it has already seen, or after MAX_ROUNDS. The returned `trajectory` shows | |
| // P1/P2/P3 counts per round — that is the part worth reading. | |
| // | |
| // Types are enforced at both ends, outside the model: codex validates its final response | |
| // against --output-schema, and agent(..., {schema}) validates what the agent returns. | |
| // That is why the loop condition below is a plain filter and not another prompt. | |
| // | |
| // WARNING: this edits files. Fix agents run with edits auto-approved, up to | |
| // MAX_FIX_AGENTS of them at once. Run it on a throwaway branch. | |
| export const meta = { | |
| name: 'red-pen-loop', | |
| description: 'One model marks your work, another fixes it, until the marks are gone', | |
| whenToUse: 'When you want an external reviewer (Codex) to grade a diff and have Claude fix everything above a severity threshold, looping until nothing is left. Args: "codex [threshold] [model] [target]" — threshold is 1|2|3, model defaults to "default", target defaults to uncommitted changes and also accepts base:<branch> or commit:<sha>.', | |
| phases: [ | |
| { title: 'Review', detail: 'Run codex and collect prioritized findings' }, | |
| { title: 'Fix', detail: 'Group findings under the threshold by file and fix in parallel' }, | |
| ], | |
| } | |
| const MAX_ROUNDS = 5 | |
| const MAX_FIX_AGENTS = 8 // fix agents per round; the rest carry over to the next round | |
| // Handed to codex via --output-schema. Note two requirements of OpenAI structured | |
| // outputs: every key in `properties` must appear in `required`, and optional fields | |
| // are expressed as a nullable type rather than by omission. The descriptions carry | |
| // the priority scale, so the semantics travel with the type. | |
| const CODEX_SCHEMA = { | |
| type: 'object', | |
| additionalProperties: false, | |
| required: ['findings'], | |
| properties: { | |
| findings: { | |
| type: 'array', | |
| description: "Every finding. Be strict about priority 1: 'just in case' and 'eventually' are 2 or 3.", | |
| items: { | |
| type: 'object', | |
| additionalProperties: false, | |
| required: ['priority', 'file', 'line', 'summary', 'detail'], | |
| properties: { | |
| priority: { | |
| type: 'integer', | |
| enum: [1, 2, 3], | |
| description: '1 = must not ship (bug, data loss, security, regression). 2 = should fix (readability, maintainability, missing tests). 3 = preference.', | |
| }, | |
| file: { type: 'string', description: 'Repository-relative path' }, | |
| line: { type: ['integer', 'null'], description: 'Line number, or null if not applicable' }, | |
| summary: { type: 'string', description: 'One sentence stating the defect' }, | |
| detail: { type: 'string', description: 'What is wrong and how to fix it' }, | |
| }, | |
| }, | |
| }, | |
| }, | |
| } | |
| // The Claude side wraps codex's shape and also asks what was actually run. | |
| const REVIEW_SCHEMA = { | |
| type: 'object', | |
| required: ['ranCommand', 'findings'], | |
| properties: { | |
| ranCommand: { type: 'string', description: 'The command actually executed, or why it failed' }, | |
| findings: CODEX_SCHEMA.properties.findings, | |
| }, | |
| } | |
| const FIX_SCHEMA = { | |
| type: 'object', | |
| required: ['changed', 'note'], | |
| properties: { | |
| changed: { type: 'boolean', description: 'Whether any file was actually edited' }, | |
| note: { type: 'string', description: 'What was changed, or why nothing was' }, | |
| }, | |
| } | |
| // ─── args: "codex [threshold] [model] [target]" ─── | |
| const TOK = (typeof args === 'string' ? args : '').trim().split(/\s+/).filter(Boolean) | |
| const REVIEWER = TOK[0] || 'codex' | |
| if (REVIEWER !== 'codex') { | |
| return { error: `Only "codex" is supported for now, got "${REVIEWER}". Usage: codex [threshold] [model] [target]` } | |
| } | |
| let i = 1 | |
| const THRESHOLD = /^[123]$/.test(TOK[i] || '') ? Number(TOK[i++]) : 1 | |
| const MODEL_TOK = TOK[i] !== undefined ? TOK[i++] : '' | |
| const MODEL = (MODEL_TOK === 'default' || MODEL_TOK === '-') ? '' : MODEL_TOK | |
| const TARGET = TOK.slice(i).join(' ') | |
| // Scope goes in the prompt, not in a flag. `codex exec review` is not used here: | |
| // --base and [PROMPT] are mutually exclusive, and --output-schema has no effect on it. | |
| let diffCmd = 'git diff HEAD' | |
| let scopeLabel = 'uncommitted changes' | |
| if (/^base:/.test(TARGET)) { | |
| diffCmd = `git diff ${TARGET.slice(5)}` | |
| scopeLabel = `diff against ${TARGET.slice(5)}` | |
| } else if (/^commit:/.test(TARGET)) { | |
| diffCmd = `git show ${TARGET.slice(7)}` | |
| scopeLabel = `commit ${TARGET.slice(7)}` | |
| } else if (TARGET) { | |
| scopeLabel = TARGET | |
| } | |
| const CMD = `codex exec${MODEL ? ` -m ${MODEL}` : ''}` | |
| log(`reviewer: ${CMD}`) | |
| log(`threshold: fixing until nothing at priority <= ${THRESHOLD} remains`) | |
| log(`scope: ${scopeLabel}`) | |
| const SCHEMA_JSON = JSON.stringify(CODEX_SCHEMA, null, 2) | |
| const REVIEW_PROMPT = (round, prevKeys) => | |
| `## Review (round ${round + 1} of ${MAX_ROUNDS})\n\n` + | |
| `Scope: ${scopeLabel}\n\n` + | |
| `**1. Write the schema to a file**\n\n` + | |
| `Save this as \`red-pen-schema.json\` (skip if it is already there).\n\n` + | |
| '```json\n' + SCHEMA_JSON + '\n```\n\n' + | |
| `**2. Run this command as written**\n\n` + | |
| '```bash\n' + | |
| `${CMD} \\\n` + | |
| ` --output-schema red-pen-schema.json \\\n` + | |
| ` -o red-pen-out.json \\\n` + | |
| ` "Review the diff produced by \\\`${diffCmd}\\\` in this repository. ` + | |
| `Report every finding using the required output shape." \\\n` + | |
| ` < /dev/null\n` + | |
| '```\n\n' + | |
| (TARGET && !/^(base|commit):/.test(TARGET) | |
| ? `Append a note limiting the review to \`${TARGET}\` to the prompt above.\n\n` : '') + | |
| `**3. Read the result and return it**\n\n` + | |
| `\`red-pen-out.json\` already conforms to the schema. Read it and pass it through.\n\n` + | |
| `## Rules\n` + | |
| `- **The command above is verified. Do not read \`--help\`, do not smoke-test it,\n` + | |
| ` do not look for a better invocation.** Run it as written. \`< /dev/null\` is\n` + | |
| ` required — without it codex blocks waiting on stdin.\n` + | |
| `- **Do not review the diff yourself as a substitute.** The whole point is that the\n` + | |
| ` judgment comes from a different model.\n` + | |
| `- If it fails, return an empty \`findings\` and put the command and the error verbatim\n` + | |
| ` in \`ranCommand\`. Do not try to repair the environment or find a workaround.\n` + | |
| `- Delete the temporary files when you are done.\n` + | |
| (prevKeys.length > 0 | |
| ? `\n## Findings from previous rounds\n` + | |
| `If any of these are still here, the fix did not take.\n` + | |
| prevKeys.map(k => `- ${k}`).join('\n') + '\n' | |
| : '') + | |
| `\nStructured output only.` | |
| const FIX_PROMPT = (file, items) => | |
| `## Fix\n\n**File:** ${file}\n\n` + | |
| `Fix only what the external reviewer marked at priority ${THRESHOLD} or below.\n\n` + | |
| items.map((f, n) => | |
| `### ${n + 1}. [P${f.priority}] ${f.summary}${f.line ? ` (${f.file}:${f.line})` : ''}\n${f.detail || ''}` | |
| ).join('\n\n') + | |
| `\n\n## Rules\n` + | |
| `- Fix the reported problems and nothing else. No drive-by refactoring.\n` + | |
| `- If a finding is wrong or you cannot fix it, leave the file alone and explain in \`note\`.\n` + | |
| `- Run the tests afterwards if the project has any.\n\nStructured output only.` | |
| // ─── Loop ─── | |
| const rounds = [] | |
| let seenKeys = [] | |
| let stalled = false | |
| for (let round = 0; round < MAX_ROUNDS; round++) { | |
| const review = await agent(REVIEW_PROMPT(round, seenKeys), { | |
| label: `review:r${round + 1}`, | |
| phase: 'Review', | |
| schema: REVIEW_SCHEMA, | |
| }) | |
| if (!review) { | |
| rounds.push({ round: round + 1, error: 'review agent returned nothing' }) | |
| break | |
| } | |
| const findings = review.findings || [] | |
| const counts = { | |
| p1: findings.filter(f => f.priority === 1).length, | |
| p2: findings.filter(f => f.priority === 2).length, | |
| p3: findings.filter(f => f.priority === 3).length, | |
| } | |
| const targets = findings.filter(f => f.priority <= THRESHOLD) // ← the only decision | |
| log(`round ${round + 1}: P1=${counts.p1} P2=${counts.p2} P3=${counts.p3} → ${targets.length} to fix`) | |
| if (targets.length === 0) { | |
| rounds.push({ round: round + 1, counts, fixes: [], ranCommand: review.ranCommand }) | |
| log(`nothing left at priority <= ${THRESHOLD} after ${round + 1} round(s)`) | |
| break | |
| } | |
| // Same findings as last round means the fixes are not landing | |
| const key = f => `${f.file}:${f.line ?? '?'}:${f.summary.slice(0, 60)}` | |
| const nowKeys = targets.map(key) | |
| if (round > 0 && nowKeys.every(k => seenKeys.includes(k))) { | |
| stalled = true | |
| rounds.push({ round: round + 1, counts, fixes: [], stalled: true, ranCommand: review.ranCommand }) | |
| log('only previously seen findings remain — stalling, stopping here') | |
| break | |
| } | |
| seenKeys = [...new Set([...seenKeys, ...nowKeys])] | |
| // Group by file so two agents never edit the same file at once | |
| const byFile = new Map() | |
| for (const f of targets) { | |
| if (!byFile.has(f.file)) byFile.set(f.file, []) | |
| byFile.get(f.file).push(f) | |
| } | |
| const groups = [...byFile.entries()] | |
| const running = groups.slice(0, MAX_FIX_AGENTS) | |
| const deferred = groups.slice(MAX_FIX_AGENTS) | |
| if (deferred.length > 0) { | |
| log(`${deferred.length} file(s) carried over to the next round (cap is ${MAX_FIX_AGENTS})`) | |
| } | |
| const fixes = await parallel( | |
| running.map(([file, items]) => () => | |
| agent(FIX_PROMPT(file, items), { | |
| label: `fix:${file.split('/').pop()}`, | |
| phase: 'Fix', | |
| schema: FIX_SCHEMA, | |
| }).then(r => ({ file, count: items.length, ...(r || { changed: false, note: 'fix agent returned nothing' }) })) | |
| ) | |
| ) | |
| rounds.push({ | |
| round: round + 1, | |
| counts, | |
| ranCommand: review.ranCommand, | |
| deferredFiles: deferred.length, | |
| fixes: fixes.filter(Boolean), | |
| }) | |
| } | |
| const last = rounds[rounds.length - 1] | |
| const converged = !!last && !!last.counts && | |
| last.counts.p1 + (THRESHOLD >= 2 ? last.counts.p2 : 0) + (THRESHOLD >= 3 ? last.counts.p3 : 0) === 0 | |
| return { | |
| command: CMD, | |
| threshold: THRESHOLD, | |
| scope: scopeLabel, | |
| converged, | |
| stalled, | |
| roundsRun: rounds.length, | |
| maxRounds: MAX_ROUNDS, | |
| trajectory: rounds.map(r => ({ | |
| round: r.round, | |
| p1: r.counts ? r.counts.p1 : null, | |
| p2: r.counts ? r.counts.p2 : null, | |
| p3: r.counts ? r.counts.p3 : null, | |
| filesFixed: r.fixes ? r.fixes.filter(f => f.changed).length : 0, | |
| filesDeferred: r.deferredFiles || 0, | |
| })), | |
| rounds, | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment