Skip to content

Instantly share code, notes, and snippets.

@laiso
Last active August 14, 2026 06:04
Show Gist options
  • Select an option

  • Save laiso/236be6cee65c887f7c86b8d01f175911 to your computer and use it in GitHub Desktop.

Select an option

Save laiso/236be6cee65c887f7c86b8d01f175911 to your computer and use it in GitHub Desktop.
/red-pen-loop: Fix until the red pen runs dry.
// 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