Skip to content

Instantly share code, notes, and snippets.

@mobiRic
Last active August 28, 2026 14:41
Show Gist options
  • Select an option

  • Save mobiRic/e81f0799979f31c3637dfd772daf8c85 to your computer and use it in GitHub Desktop.

Select an option

Save mobiRic/e81f0799979f31c3637dfd772daf8c85 to your computer and use it in GitHub Desktop.
cleanup-with-codex: a deep reuse/dead-code cleanup pass via Codex, sibling to finalize-with-codex

The Cleanup-with-Codex System

A heavier, occasional sibling to finalize-with-codex: a deep reuse/dead-code/duplication/naming cleanup pass, run in an isolated git worktree, with a producer stage and an always-run, fresh-context adversarial critique stage before finalize-with-codex's existing commit gate lands the result. Claude stays the architect — the pipeline flags public-API ideas and out-of-scope findings rather than acting on them.

This system does not duplicate finalize-with-codex — it invokes it, unmodified, twice per run (see Relationship to finalize-with-codex below). Install both gists to use this one.


Files in this gist

File Purpose
cleanup-with-codex-SKILL.md Claude skill — orchestrates the handoff
run-cleanup.sh Shell launcher — worktree lifecycle, the three codex exec calls, rebase/ff-merge
cleanup-produce-SKILL.md Codex skill — the producer: finds and applies the cleanup
cleanup-critique-SKILL.md Codex skill — the critic: fresh-session adversarial review
cleanup-produce-schema.json JSON schema enforcing the producer's output shape
cleanup-critique-schema.json JSON schema enforcing the critic's output shape

Not included here: response-schema.json and finalize-commit-SKILL.md — this system reuses those from the finalize-with-codex gist as-is.


How the system works

1. cleanup-with-codex-SKILL.md — Claude skill spec

Installed at ~/.claude/skills/cleanup-with-codex/SKILL.md. Invoked explicitly — this is an occasional, heavier pass, not something to run per-commit:

  1. Ensure a clean checkout first. If git status --porcelain shows anything, that's Claude's own uncommitted work: draft a commit message for it and invoke finalize-with-codex to land it before doing anything else, passing an explicit files list (covers untracked paths finalize-commit's own diff-based default would miss). Capture HEAD before this step as pre_cleanup_head.
  2. Lock check (advisory) — codex-cleanup.lock in the checkout's git dir; the shell helper's own atomic lock is authoritative.
  3. Write request file — {"task_summary": "...", "files": [...], "pre_cleanup_head": "<sha>"} to the scratchpad.
  4. Launch run-cleanup.sh via Bash with run_in_background=true. Auto-notified on completion.
  5. Report the result — five terminal states: no_changes_needed, committed, blocked, failed, inspect. On committed or no_changes_needed, surface api_suggestions/findings_flagged if either is non-empty — that's the actual point of keeping cleanup an implementer rather than an architect. Any result carrying worktree_dir/cleanup_branch — blocked, inspect, or a failed past the preflight checks — leaves that worktree and branch fully intact for manual rescue. No automated resume.

2. run-cleanup.sh — Shell launcher

The background script that owns the whole pipeline:

  1. Validates args and request-file JSON; resolves the checkout's git dir; acquires its own codex-cleanup.lock (separate from finalize's — they don't contend, since cleanup never touches the checkout's working tree until the final fast-forward).
  2. Preflight, hard-fail rather than reason about it: an in-progress merge/rebase/cherry-pick, or the checkout still being dirty at this point (Step 0 above should have already guaranteed clean) — both return failed immediately.
  3. BASE = current HEAD. Creates a throwaway branch + worktree at BASE via git worktree add — since the checkout was clean, BASE already contains everything; no untracked-file copying needed.
  4. codex exec #1 — cleanup-produce. Checks abnormal exit and a self-reported status: "failed"; otherwise continues regardless of whether it made changes.
  5. codex exec #2 — cleanup-critique. Always runs, fresh --ephemeral session, no shared context with the producer.
  6. Gate: git status --porcelain in the worktree, checked only now — after both passes. Empty → no_changes_needed, worktree/branch discarded, api_suggestions/findings_flagged still threaded into the result. Non-empty → proceed.
  7. Selects commit_message: the critic's if non-null/non-empty, else the producer's; failed if neither is usable (guards against a literal "null" commit title). Computes the touched-files list via git diff --name-only -z "$BASE" + git ls-files --others --exclude-standard -z, deduped — rename-safe, unlike a naive git status --porcelain -z + fixed-offset substring strip.
  8. codex exec #3 — $finalize-commit, the existing skill, completely unmodified. The pipeline's one and only commit.
  9. git rebase --onto <current checkout tip> <BASE> <branch> — a pure concurrency guard against something else landing on the checkout mid-run (e.g. a parallel finalize-with-codex run), not WIP-netting. Conflict → blocked, blocked_stage: rebase, worktree/branch left intact.
  10. git merge --ff-only back into the checkout, retried up to 3 times against a moving tip. Still failing → blocked, blocked_stage: merge_back.
  11. Success → worktree/branch removed, CLEANUP_DONE $RUN_DIR.

3. cleanup-produce-SKILL.md — the producer

Installed at ~/.codex/skills/cleanup-produce/SKILL.md. Runs inside the isolated worktree, starting from a guaranteed-clean tree.

In scope: reuse, dead code, duplication, naming, over-engineering. Explicitly not bug-hunting.

Scope boundary: the diff/task implied by files/task_summary, plus adjacent code only when the in-scope cleanup requires it — concretely: the same file, the same module, or direct callers of a symbol its own change made dead. Never roam for unrelated cleanup elsewhere.

Exclusion list: reads CLAUDE.md/README first and extracts every explicit "deliberate, do not simplify" statement before touching anything.

Reuse-sweep protocol: for every non-trivial changed block, extracts a distinctive signature and greps the whole repo — not just adjacent modules — for near-duplicates. Extracts only when there are ≥2 real consumers.

Tests: adds/updates tests to match its changes; runs the repo's own documented test commands; reverts a specific change if it breaks a test rather than pushing forward.

Never: changes a public/exported API — records the idea in api_suggestions instead. Never commits; that happens exactly once, later, inside finalize-commit.

4. cleanup-critique-SKILL.md — the critic

Installed at ~/.codex/skills/cleanup-critique/SKILL.md. Runs in a fresh Codex session with no shared context with the producer — the reset is the actual mechanism, not a formality. A real prior finding validated it: a producer-style pass missed a 4-line duplicated Navigation-Compose incantation between two call sites; a separate pass over the resulting diff, unburdened by the producer's own reasoning, caught it.

Always runs, regardless of how much or little the producer changed — an earlier design gated the critic on the producer's own output size, which review found anti-correlated with the risk it was meant to catch: a producer that tunnel-visions and misses something tends to produce a small diff, exactly the wrong signal to skip a second look on.

What "the diff" means (never anything wider): the producer's resulting diff if it made changes; otherwise the diff from pre_cleanup_head to BASE (whatever Step 0 committed on the way in, if anything) — if that's also empty, there's genuinely nothing to review, and "nothing found" is reported as done, not a special case.

Apply vs. flag: applies only high-confidence, cosmetic, in-scope fixes itself, running tests after each and reverting on failure. Flags everything else — structural changes, anything touching outside the diff, anything public-API-shaped — into findings_flagged rather than applying it. Same ≥2-consumer extraction gate as the producer. Never touches a public API itself.

5. Response schemas

cleanup-produce-schema.json and cleanup-critique-schema.json follow the same flat-nullable-fields convention as finalize-with-codex's own response-schema.json — all fields always present but nullable, since a oneOf-per-status design caused real validation friction against codex exec --output-schema in the original finalize work. The top-level $RUN_DIR/result.json that run-cleanup.sh assembles is hand-built via jq -n on every path and passed through no schema of its own — same pattern as run-finalizer.sh's own failure-path synthesis.


Relationship to finalize-with-codex

run-cleanup.sh invokes the existing, completely unmodified finalize-commit skill as its one and only commit step (codex exec ... $finalize-commit, referencing finalize-with-codex's own response-schema.json by relative path), and cleanup-with-codex-SKILL.md invokes the full finalize-with-codex Claude skill a second time at its own Step 0, to land any pre-existing dirty work before the worktree pipeline starts. Neither invocation reimplements finalize's rules — this system depends on that gist being installed, not merged into it. The two systems have different risk profiles and update cadences (a fix to finalize-commit benefits both without cleanup's own files changing at all), which is also why they're published as separate gists rather than one.


End-to-end flow

User: "cleanup this"
  │
Claude: git status --porcelain in checkout_path
        → dirty? draft a message, invoke finalize-with-codex, wait
        → capture pre_cleanup_head
        → writes request JSON to scratchpad
        → calls run-cleanup.sh in background
  │
run-cleanup.sh (background):
        → validates args/request JSON
        → acquires its own lock
        → preflight: in-progress merge/rebase/cherry-pick? dirty tree? → failed
        → BASE = HEAD; git worktree add -b <branch> <dir> BASE
  │
        codex exec #1 — cleanup-produce (isolated worktree)
              → reads CLAUDE.md/README, builds exclusion list
              → finds cleanup in scope, repo-wide reuse-sweep before extracting
              → edits + adds/updates tests; reverts a change that breaks a test
              → never touches a public API — records api_suggestions instead
              → authors its own commit_message
  │
        codex exec #2 — cleanup-critique (ALWAYS runs, fresh session, no shared context)
              → reviews producer's diff, or pre_cleanup_head→BASE if producer did nothing
              → fresh repo-wide reuse-sweep
              → applies only high-confidence/cosmetic/in-scope fixes, tests after each
              → flags everything else into findings_flagged
  │
        gate: anything actually changed in the worktree?
              no  → no_changes_needed, worktree/branch discarded,
                    api_suggestions/findings_flagged still surfaced
              yes → select commit_message (critic's, else producer's; fail if neither)
  │
        codex exec #3 — $finalize-commit (existing, UNMODIFIED skill)
              → format/lint/compile/test → commit
  │
        git rebase --onto <checkout tip> BASE <branch>   (concurrency guard)
        git merge --ff-only <branch>  into the real checkout   (retried up to 3x)
        success → worktree/branch removed, CLEANUP_DONE $RUN_DIR
        conflict at either step → blocked, worktree/branch left intact
  │
Claude: reads result.json
        no_changes_needed → good outcome; surface any flagged findings
        committed         → SHA + message; surface api_suggestions/findings_flagged
        blocked           → surface blocked_stage + blocker; artifacts intact for rescue
        failed / inspect  → surface error/log; same inspect discipline as finalize-with-codex

Key design decisions

  • Worktree isolation, not a stash-and-net-out approach. An earlier design let cleanup run on a dirty checkout, using git stash create as BASE and relying on rebase --onto's exclusive lower bound to keep WIP out of the landed commit. Review found this left the post-run checkout state genuinely unstated, and — separately — that a stash never captures untracked files, so an unfiltered scope computation could sweep any untracked scratch file into the cleanup commit regardless of whether cleanup touched it. The fix: commit dirty WIP for real (via finalize-with-codex, Step 0) before the pipeline ever starts. BASE is then always just HEAD in a checkout guaranteed clean, and rebase --onto narrows to what it should always have been — a concurrency guard, not a WIP-netting mechanism.
  • The critic always runs. A gate keyed on the producer's own output size is anti-correlated with the risk it's meant to catch — see cleanup-critique-SKILL.md above. Deterministic, bash-computed gating happens exactly once, and only after both passes: did the worktree actually change at all.
  • Fresh session for the critic is the mechanism, not a formality. No shared context with the producer is what let a real prior run catch a cross-file duplication a diff-scoped producer pass structurally couldn't see.
  • ≥2 real consumers before any extraction, for both producer and critic — mirrors this system's own home repo's stance that "three similar lines is better than a premature abstraction."
  • Flag, don't touch, anything public-API-shaped. The main Claude session stays the architect; the Codex agents stay implementers. api_suggestions and findings_flagged are threaded all the way from the producer's/critic's own result files into the top-level result.json on both committed and no_changes_needed — a flagged finding must never be silently discarded once the worktree is torn down.
  • Block, never auto-resolve, on either a rebase or a merge-back conflict. Both leave the worktree and branch fully intact for manual rescue — no automated resume for v1.
  • Deterministic checks throughout, never Codex self-reporting. The touched-files list, the "did anything change" gate, and the commit-message null/empty guard are all computed in bash from git state — never inferred from how a model "feels" about its own output.
  • Rename-safe file-list computation. git status --porcelain -z corrupts a renamed file's new path — a rename emits two NUL-delimited records and only the first carries a status prefix, so a naive fixed-offset substring strip mangles both. git diff --name-only -z handles a rename as one clean path.
  • Two gists, not one, despite finalize-commit being a real runtime dependency of this system. The coupling is an invocation-by-name relationship (this system shells out to $finalize-commit and references its schema by relative path) rather than shared code — the same relationship a script has with git or gradlew. finalize-with-codex is the thing to run on every commit; cleanup-with-codex is a heavier, occasional pass most people won't want by default. Keeping them separate means a fix to one doesn't show up as noise in the other's version history, and someone can adopt just the commit gate without inheriting a worktree-manipulating pipeline they didn't ask for.

Future improvements (deferred)

Raised in review, not adopted for v1 — kept here in case cost/experience later justifies building them:

  • A formal, deterministic instruction-discovery and precedence algorithm across CLAUDE.md, AGENTS.md, nested instruction files, CONTRIBUTING.md, and build docs, rather than "read CLAUDE.md and the nearest module README(s)."
  • Mandating two independent, explicitly logged search strategies for the reuse-sweep (a symbol/call-site grep plus a separate structural/behavior-marker search), with generated-code exclusions and a record of every search performed.
  • Worked-example operational definitions for "cosmetic" vs. "structural," "high-confidence" vs. not, and "public/exported" vs. not.
  • A configurable policy layer for huge diffs, binary files, generated sources, vendored code, and submodules — default exclusions and size/time thresholds instead of an unbounded pass.
{
"type": "object",
"additionalProperties": false,
"required": ["status", "commit_message", "findings_applied", "findings_flagged", "commands_run", "error"],
"properties": {
"status": { "type": "string", "enum": ["done", "failed"] },
"commit_message": { "type": ["string", "null"] },
"findings_applied": { "type": ["array", "null"], "items": { "type": "string" } },
"findings_flagged": { "type": ["array", "null"], "items": { "type": "string" } },
"commands_run": { "type": ["array", "null"], "items": { "type": "string" } },
"error": { "type": ["string", "null"] }
}
}
name cleanup-critique
description Fresh-session adversarial review that always runs after cleanup-produce, focused on a repo-wide reuse sweep the producer's diff-scoped framing structurally cannot fully cover. Reviews the producer's diff if it made changes, otherwise the diff already present when this run started. Applies only high-confidence, cosmetic, in-scope fixes and runs tests after each; flags everything else. "Nothing found" is a valid, good outcome. Never touches public APIs.

cleanup-critique

Runs in a fresh Codex session with no shared context with cleanup-produce — that reset is the actual mechanism this skill exists for, not a formality. A prior real finding validated it: a producer-style pass missed a 4-line duplicated Navigation-Compose incantation between two call sites; a separate pass over the producer's output tree caught it, precisely because it wasn't continuing the producer's own reasoning.

This skill always runs, regardless of how much or how little the producer changed — a gate keyed on the producer's own output size was found to be anti-correlated with risk (a producer that misses something due to tunnel vision tends to produce a small or empty diff, which is exactly the wrong signal to skip a second look on).

Inputs (from context)

PRODUCER_RESULT_FILE, BASE_COMMIT, and PRE_CLEANUP_HEAD are given as context lines. Read PRODUCER_RESULT_FILE to see what the producer reports having done.

What to review — "the diff" means exactly one of these two, never anything wider:

  • If the producer made changes: review its resulting diff/tree — git diff "$BASE_COMMIT" in the current worktree — not its reasoning.
  • If the producer made no changes: review git diff "$PRE_CLEANUP_HEAD" "$BASE_COMMIT" instead — whatever was already committed when this run started (this may be empty, in which case you have nothing to review; report done with nothing found, same as any other "nothing found" outcome).

Mandate

Primary job: run the repo-wide reuse sweep, fresh, on whichever diff above applies. This is the one angle a single diff-scoped pass structurally cannot fully cover.

Secondary: if reviewing the producer's own diff, verify it didn't introduce new duplication or regress an angle it had already touched.

Same extraction gate as the producer: ≥2 real consumers before proposing or applying extraction.

Apply vs. flag: apply only high-confidence, cosmetic, in-scope fixes yourself. Flag everything else — structural changes, anything touching outside the diff, anything public-API-shaped — into findings_flagged rather than applying it.

Tests: run the same repo-documented test commands the producer uses (see its own mandate) after any fix you apply yourself, and revert that specific fix if it breaks one — the same discipline as the producer. Do not leave a build-breaking fix for finalize-commit to discover later.

Confidence floor: a finding below high confidence is discarded, not reported. "Nothing found" is an explicitly valid, good, expected outcome — never pad the report with low-value nitpicks to look thorough.

Not in scope: not an open-ended "what else could be improved" review. Narrowly scoped to what the producer's diff-scoped framing structurally couldn't see (or, in the no-producer-changes case, a genuine first look at what's already there).

Never: change a public/exported API (flag ideas instead); push, amend, bypass hooks, disable tests; commit anything.

Workflow

  1. Read PRODUCER_RESULT_FILE; determine which diff applies (see Inputs above) and inspect it.
  2. Run the repo-wide reuse sweep fresh over that diff.
  3. If reviewing the producer's own diff, check its touched areas for anything it may have regressed.
  4. Apply only high-confidence, cosmetic, in-scope fixes directly; run the test suite after each and revert on failure.
  5. Discard anything below high confidence; flag the rest (with file/line context) in findings_flagged.
  6. Produce commit_message — the producer's, unmodified, if you made no changes of your own; otherwise the producer's extended to also describe your changes; if the producer made no changes and you made some, author your own from scratch.

Output

JSON conforming to the response schema. status:

  • done — review completed (whether or not any fix was applied — "nothing found" is done, not a special case).
  • failed — could not complete the review; error populated.
{
"type": "object",
"additionalProperties": false,
"required": ["status", "commit_message", "changes_made", "tests_touched", "api_suggestions", "commands_run", "error"],
"properties": {
"status": { "type": "string", "enum": ["done", "no_changes_needed", "failed"] },
"commit_message": { "type": ["string", "null"] },
"changes_made": { "type": ["array", "null"], "items": { "type": "string" } },
"tests_touched": { "type": ["array", "null"], "items": { "type": "string" } },
"api_suggestions": { "type": ["array", "null"], "items": { "type": "string" } },
"commands_run": { "type": ["array", "null"], "items": { "type": "string" } },
"error": { "type": ["string", "null"] }
}
}
name cleanup-produce
description Deep reuse/dead-code/duplication/naming/over-engineering cleanup pass on the current worktree, handed off by cleanup-with-codex. Quality-only, not bug-hunting. Extracts explicit "do not simplify" statements from CLAUDE.md/README before starting, and never changes a public API. Adds/updates tests to match its own changes and reverts any change that breaks a test rather than pushing forward. Never commits.

cleanup-produce

Runs inside an isolated git worktree (never the real checkout), starting from a guaranteed-clean tree. Produces a real, substantive cleanup edit — not a mechanical formatting pass (that's finalize-commit's job, which runs after this).

Inputs (from context)

Read REQUEST_FILE from context. Return status: "failed" immediately if it's absent or unreadable. Otherwise parse its JSON for task_summary and optional files (a starting focus area, not a hard boundary).

Mandate

In scope: reuse, dead code, duplication, naming, over-engineering. Explicitly not correctness/bug-hunting — never fix a suspected bug, never touch behavior outside cleanup's own scope.

Scope boundary: operate within the diff/task implied by files/task_summary. You may follow into adjacent, currently-untouched code only when the cleanup already in scope requires it — concretely: the same file, the same module, or direct callers of a symbol your own change made dead. Never go looking for unrelated cleanup elsewhere in the repo, and never treat "I can grep the whole repo for duplicates" (the reuse-sweep, below) as license to edit whatever the sweep turns up outside this boundary — flag it instead if it's out of scope.

Exclusion list — read first: before making any change, read the repo's CLAUDE.md and the nearest module README(s), and extract every explicit "deliberate, do not simplify" statement into an exclusion list checked against every change. Never "simplify away" anything documented as deliberate — always re-derive this list from the actual repo you're working against, per-run.

Reuse-sweep protocol: for every non-trivial new or changed block, extract a distinctive signature (a symbol name, a structural pattern) and grep the whole repo — not just the owning and adjacent modules — for near-duplicates. Propose or apply an extraction only when there are at least 2 real consumers: the new/changed code plus at least one other existing real usage found by the sweep. Never extract for a single occurrence.

Tests: add or update tests to match your changes. Run the test suite the repo's own build/test documentation defines (the same discovery finalize-commit already does — read CLAUDE.md/README for the actual test commands, not every test category that might exist, e.g. skip instrumented/device tests this repo's own docs already mark as out of scope for a routine pass). If a specific change breaks a test, revert that specific change rather than pushing forward or weakening the test.

Never:

  • Change a public/exported API signature or contract as a goal. Record an improvement idea in api_suggestions instead — do not make it. The main Claude session stays the architect; you stay the implementer.
  • Push, amend, bypass hooks, disable tests, or weaken assertions.
  • Commit anything — that happens exactly once, later, inside finalize-commit.

Workflow

  1. Read REQUEST_FILE; read CLAUDE.md/README(s) and build the exclusion list.
  2. Identify the cleanup scope from files/task_summary; look for the categories above within that scope (plus any adjacent dead code your own edits create).
  3. Run the repo-wide reuse-sweep for each non-trivial block before applying it.
  4. Make the edits. Add/update tests.
  5. Run the test suite; revert any single change that breaks a test.
  6. Author commit_message yourself — authoritative, describing what you actually did, not a draft for someone else to rewrite. Required whenever status: "done".

Output

JSON conforming to the response schema. status:

  • done — changes made; commit_message (required, non-empty), changes_made, tests_touched populated.
  • no_changes_needed — genuinely nothing to clean up in scope. A good outcome — do not pad the report to look productive. commit_message may be null here.
  • failed — could not complete; error populated.
name cleanup-with-codex
description Run a deep reuse/dead-code/duplication cleanup pass via Codex, isolated in a throwaway git worktree. Use for occasional, heavier cleanup passes distinct from finalize-with-codex's routine commit gate — invoke explicitly when asked to do a cleanup pass, not automatically per the Universal Commit Policy.

cleanup-with-codex

Orchestrates a two-stage Codex cleanup (produce, then adversarial critique — the critic always runs) followed by finalize-with-codex's existing commit gate, all inside an ephemeral git worktree so the real checkout's working tree is never touched until a clean, conflict-free result is ready to fast-forward in.

Inputs

  • checkout_path — absolute path to the target git checkout
  • task_summary — one sentence describing the scope/intent of the cleanup pass
  • files (optional, array of repo-relative paths) — a starting focus area for cleanup-produce. Unlike finalize-commit's files, this is a soft hint, not a hard boundary — cleanup-produce may follow into adjacent untouched code when the in-scope cleanup requires it (see cleanup-produce's own SKILL.md for how "adjacent" is bounded).

Steps

  1. Ensure a clean checkout first. Run git status --porcelain in checkout_path. If it's dirty, this is your own uncommitted work — draft a commit message for it and invoke finalize-with-codex to land it, exactly as for an ordinary commit. Pass an explicit files list computed from git status --porcelain (covers untracked files — don't rely on finalize-commit's own no-files default, which is diff-based). Wait for it to complete before continuing; this is a prerequisite, not launched in parallel. Before this step, capture the checkout's current HEAD as pre_cleanup_head (if the tree was already clean and this step is a no-op, it equals the current HEAD).

  2. Lock check (advisory): look for codex-cleanup.lock in the checkout's git dir and warn early if found — the shell helper's own atomic lock is authoritative.

  3. Write request file: Use the Write tool to create a JSON file in the scratchpad: {"task_summary": "...", "files": ["path/one.kt"], "pre_cleanup_head": "<sha>"} (files optional).

  4. Launch helper: run run-cleanup.sh via Bash with run_in_background=true, passing only the checkout path and the request file path. Claude is auto-notified on completion — no polling.

  5. Report result: when notified, parse $RUN_DIR from CLEANUP_DONE (or CLEANUP_STARTED if it crashed). Read $RUN_DIR/result.json and report:

    • no_changes_needed — neither the producer nor the critic found anything worth changing; nothing landed, worktree discarded. This is a good, expected outcome, not a failure. Still report api_suggestions/findings_flagged if either is non-empty — a run can flag something worth your attention even without landing a commit.
    • committed — show SHA and message used, and report api_suggestions/ findings_flagged if either is non-empty. These are the whole reason cleanup stays an implementer rather than an architect: surface them, don't just log the SHA and move on.
    • blocked — show blocked_stage (rebase or merge_back), the blocker, and worktree_dir/cleanup_branch.
    • failed — show error and the relevant log (produce.log/critique.log/ finalize.log/worktree-add.log under $RUN_DIR). If the error mentions "checkout has uncommitted changes," Step 0 above didn't run or didn't complete before run-cleanup.sh launched — investigate rather than retrying blindly. If the error mentions an in-progress merge/rebase/cherry-pick, resolve that in the checkout first.
    • inspect — same treatment as finalize-with-codex's inspect: warn the user not to retry without checking git log first, and check finalize_result (a filename under $RUN_DIR) for detail when present.

    Artifact preservation: any result that carries worktree_dir/ cleanup_branch — blocked, inspect, and any failed that occurred after the worktree was created (i.e. everything past the arg/request-file/preflight checks) — leaves that worktree and branch fully intact. Do not delete them. Rescue by hand with ordinary git: inspect, resolve, and either finish the rebase/merge manually or abandon it and git worktree remove --force. No automated resume for v1. For a merge_back-stage block specifically, the checkout may also have unrelated concurrent work in progress — check before force-removing anything. A failed or blocked result with no worktree_dir field means nothing was ever created — there's nothing to clean up.

Checking progress mid-run

$RUN_DIR/phase holds the current stage name (preflight, worktree-setup, producing, critiquing, gating, finalizing, rebasing, merging). Tail the matching log under $RUN_DIR for detail. $GIT_DIR/codex-cleanup-current-run points at the live $RUN_DIR, same convention as finalize-with-codex.

Relationship to finalize-with-codex

This skill invokes the existing, unmodified finalize-with-codex skill twice: once at Step 0 (to land any pre-existing dirty work before cleanup starts, using a Claude-authored message, same as always), and once inside the pipeline itself (via run-cleanup.sh invoking $finalize-commit directly, using a message producer/critique authored). Neither invocation reimplements finalize's rules. Locking is independent otherwise: cleanup uses its own lock file for the worktree pipeline and does not contend with a concurrent finalize-with-codex run on the same checkout, since cleanup never touches the checkout's working tree directly until the final fast-forward merge.

#!/usr/bin/env bash
set -euo pipefail
SKILL_DIR="$(cd "$(dirname "$0")" && pwd)"
if [ "$#" -ne 2 ]; then
echo "ERROR: usage: run-cleanup.sh <checkout_path> <request_file>" >&2
exit 1
fi
CHECKOUT_PATH="$1"
REQUEST_FILE="$2"
if [ ! -f "$REQUEST_FILE" ] || ! jq empty "$REQUEST_FILE" 2>/dev/null; then
echo "ERROR: request file missing or invalid JSON: $REQUEST_FILE" >&2
exit 1
fi
FINALIZE_SCHEMA="$SKILL_DIR/../finalize-with-codex/response-schema.json"
PRODUCE_SCHEMA="$SKILL_DIR/cleanup-produce-schema.json"
CRITIQUE_SCHEMA="$SKILL_DIR/cleanup-critique-schema.json"
resolve_abs_git_dir() {
local rel abs
rel="$(git -C "$1" rev-parse --git-dir)"
if [[ "$rel" == /* ]]; then abs="$rel"; else abs="$(cd "$1/$rel" && pwd)"; fi
echo "$abs"
}
resolve_abs_common_dir() {
local rel abs
rel="$(git -C "$1" rev-parse --git-common-dir)"
if [[ "$rel" == /* ]]; then abs="$rel"; else abs="$(cd "$1/$rel" && pwd)"; fi
echo "$abs"
}
GIT_DIR="$(resolve_abs_git_dir "$CHECKOUT_PATH")"
LOCK_FILE="$GIT_DIR/codex-cleanup.lock"
if ! ( set -C; printf 'PID=%s\nSTARTED=%s\n' "$$" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$LOCK_FILE" ) 2>/dev/null; then
echo "ERROR: Lock file exists at $LOCK_FILE — another cleanup may be running. Remove it manually after confirming no cleanup is running." >&2
exit 1
fi
trap 'rm -f "$LOCK_FILE"' EXIT
RUN_DIR="$(mktemp -d)"
echo "CLEANUP_STARTED $RUN_DIR"
echo "$RUN_DIR" > "$GIT_DIR/codex-cleanup-current-run"
echo "starting" > "$RUN_DIR/phase"
write_result() { jq -n "$@" > "$RUN_DIR/result.json"; }
finish() { echo "CLEANUP_DONE $RUN_DIR"; exit 0; }
teardown_discard() { # $1=worktree $2=branch — used on no_changes_needed
git -C "$CHECKOUT_PATH" worktree remove --force "$1" || true
git -C "$CHECKOUT_PATH" branch -D "$2" || true
}
# --- Step 1: checkout must already be clean (cleanup-with-codex/SKILL.md's own
# Step 0 is responsible for committing dirty WIP via finalize-with-codex before
# this script is ever launched). Fail fast rather than reasoning about a dirty
# tree here. ---
echo "preflight" > "$RUN_DIR/phase"
if [ -e "$GIT_DIR/MERGE_HEAD" ] || [ -d "$GIT_DIR/rebase-merge" ] \
|| [ -d "$GIT_DIR/rebase-apply" ] || [ -e "$GIT_DIR/CHERRY_PICK_HEAD" ]; then
write_result --arg err "checkout has an in-progress merge/rebase/cherry-pick — resolve it manually before invoking cleanup" \
'{status:"failed", error:$err}'
finish
fi
if [ -n "$(git -C "$CHECKOUT_PATH" status --porcelain)" ]; then
write_result --arg err "checkout has uncommitted changes — cleanup-with-codex SKILL.md step 0 should have committed them via finalize-with-codex first" \
'{status:"failed", error:$err}'
finish
fi
PRE_CLEANUP_HEAD="$(jq -r '.pre_cleanup_head // empty' "$REQUEST_FILE")"
BASE="$(git -C "$CHECKOUT_PATH" rev-parse HEAD)"
[ -z "$PRE_CLEANUP_HEAD" ] && PRE_CLEANUP_HEAD="$BASE"
# --- Step 2: throwaway branch + worktree at BASE ---
echo "worktree-setup" > "$RUN_DIR/phase"
WORKTREE_DIR="$RUN_DIR/worktree"
BRANCH="codex-cleanup/$(basename "$RUN_DIR")"
if ! git -C "$CHECKOUT_PATH" worktree add -b "$BRANCH" "$WORKTREE_DIR" "$BASE" \
> "$RUN_DIR/worktree-add.log" 2>&1; then
write_result --arg err "git worktree add failed — see $RUN_DIR/worktree-add.log" \
'{status:"failed", error:$err}'
finish
fi
WT_GIT_DIR="$(resolve_abs_git_dir "$WORKTREE_DIR")"
WT_COMMON_DIR="$(resolve_abs_common_dir "$WORKTREE_DIR")"
ADD_DIRS=("$WT_GIT_DIR")
[ "$WT_COMMON_DIR" != "$WT_GIT_DIR" ] && ADD_DIRS+=("$WT_COMMON_DIR")
EXTRA_DIRS_FILE="$CHECKOUT_PATH/.claude/finalize-with-codex.extra-writable-dirs"
if [ -f "$EXTRA_DIRS_FILE" ]; then
while IFS= read -r LINE || [ -n "$LINE" ]; do
[[ -z "$LINE" || "$LINE" == \#* ]] && continue
ADD_DIRS+=("$(eval echo "$LINE")")
done < "$EXTRA_DIRS_FILE"
fi
ADD_DIR_ARGS=()
for DIR in "${ADD_DIRS[@]}"; do ADD_DIR_ARGS+=(--add-dir "$DIR"); done
CODEX_COMMON=( -s workspace-write -c sandbox_workspace_write.network_access=true
"${ADD_DIR_ARGS[@]}" --ephemeral )
# --- Step 3: cleanup-produce ---
echo "producing" > "$RUN_DIR/phase"
set +e
printf '%s\n\nREQUEST_FILE: %s\n' '$cleanup-produce' "$REQUEST_FILE" \
| codex exec -C "$WORKTREE_DIR" "${CODEX_COMMON[@]}" \
--output-schema "$PRODUCE_SCHEMA" -o "$RUN_DIR/produce-result.json" - \
2>"$RUN_DIR/produce.log"
PRODUCE_EXIT=$?
set -e
if [ "$PRODUCE_EXIT" -ne 0 ] || [ ! -f "$RUN_DIR/produce-result.json" ] \
|| ! jq empty "$RUN_DIR/produce-result.json" 2>/dev/null; then
write_result --arg wt "$WORKTREE_DIR" --arg br "$BRANCH" \
'{status:"failed", error:"cleanup-produce exited abnormally — see produce.log",
worktree_dir:$wt, cleanup_branch:$br}'
finish
fi
PSTATUS="$(jq -r '.status' "$RUN_DIR/produce-result.json")"
if [ "$PSTATUS" = "failed" ]; then
write_result --arg wt "$WORKTREE_DIR" --arg br "$BRANCH" \
--arg perr "$(jq -r '.error // "cleanup-produce reported failed"' "$RUN_DIR/produce-result.json")" \
'{status:"failed", error:$perr, worktree_dir:$wt, cleanup_branch:$br}'
finish
fi
# Note: no early exit on "no_changes_needed" here — the critic always runs
# regardless of what the producer did.
# --- Step 4: cleanup-critique — ALWAYS runs, fresh session ---
echo "critiquing" > "$RUN_DIR/phase"
set +e
printf '%s\n\nPRODUCER_RESULT_FILE: %s\nBASE_COMMIT: %s\nPRE_CLEANUP_HEAD: %s\n' \
'$cleanup-critique' "$RUN_DIR/produce-result.json" "$BASE" "$PRE_CLEANUP_HEAD" \
| codex exec -C "$WORKTREE_DIR" "${CODEX_COMMON[@]}" \
--output-schema "$CRITIQUE_SCHEMA" -o "$RUN_DIR/critique-result.json" - \
2>"$RUN_DIR/critique.log"
CRITIQUE_EXIT=$?
set -e
if [ "$CRITIQUE_EXIT" -ne 0 ] || [ ! -f "$RUN_DIR/critique-result.json" ] \
|| ! jq empty "$RUN_DIR/critique-result.json" 2>/dev/null; then
write_result --arg wt "$WORKTREE_DIR" --arg br "$BRANCH" \
'{status:"failed", error:"cleanup-critique exited abnormally — see critique.log",
worktree_dir:$wt, cleanup_branch:$br}'
finish
fi
CSTATUS="$(jq -r '.status' "$RUN_DIR/critique-result.json")"
if [ "$CSTATUS" = "failed" ]; then
write_result --arg wt "$WORKTREE_DIR" --arg br "$BRANCH" \
--arg cerr "$(jq -r '.error // "cleanup-critique reported failed"' "$RUN_DIR/critique-result.json")" \
'{status:"failed", error:$cerr, worktree_dir:$wt, cleanup_branch:$br}'
finish
fi
# --- Step 5: did anything actually change, across both passes? ---
echo "gating" > "$RUN_DIR/phase"
if [ -z "$(git -C "$WORKTREE_DIR" status --porcelain)" ]; then
write_result --arg wt "$WORKTREE_DIR" --arg br "$BRANCH" \
--argjson api "$(jq '.api_suggestions // []' "$RUN_DIR/produce-result.json")" \
--argjson flagged "$(jq '.findings_flagged // []' "$RUN_DIR/critique-result.json")" \
'{status:"no_changes_needed", worktree_dir:$wt, cleanup_branch:$br,
api_suggestions:$api, findings_flagged:$flagged}'
teardown_discard "$WORKTREE_DIR" "$BRANCH"
finish
fi
# --- Step 6: commit message selection, guarded against null/empty ---
COMMIT_MESSAGE="$(jq -r '.commit_message' "$RUN_DIR/critique-result.json")"
if [ "$COMMIT_MESSAGE" = "null" ] || [ -z "$COMMIT_MESSAGE" ]; then
COMMIT_MESSAGE="$(jq -r '.commit_message' "$RUN_DIR/produce-result.json")"
fi
if [ "$COMMIT_MESSAGE" = "null" ] || [ -z "$COMMIT_MESSAGE" ]; then
write_result --arg wt "$WORKTREE_DIR" --arg br "$BRANCH" \
'{status:"failed", error:"neither cleanup-produce nor cleanup-critique produced a usable commit_message",
worktree_dir:$wt, cleanup_branch:$br}'
finish
fi
TASK_SUMMARY="$(jq -r '.task_summary // "codex cleanup pass"' "$REQUEST_FILE")"
# NUL-safe and rename-safe: git status --porcelain -z corrupts a renamed file's
# new path (a rename emits two NUL-delimited records, only the first carrying a
# status prefix, so a naive substr(4) on every record chops the new path too).
# git diff --name-only handles renames as a single clean path; combined with
# ls-files for untracked new files, deduped.
FILES_JSON=$( { git -C "$WORKTREE_DIR" diff --name-only -z "$BASE" -- .; \
git -C "$WORKTREE_DIR" ls-files --others --exclude-standard -z; } |
tr '\0' '\n' | jq -R -s -c 'split("\n") | map(select(length>0)) | unique')
FINALIZE_REQUEST="$RUN_DIR/finalize-request.json"
jq -n --arg msg "$COMMIT_MESSAGE" --arg sum "$TASK_SUMMARY" --argjson files "$FILES_JSON" \
'{commit_message:$msg, task_summary:$sum, files:$files}' > "$FINALIZE_REQUEST"
# --- Step 7: finalize-commit — existing, UNMODIFIED skill, the pipeline's one commit ---
echo "finalizing" > "$RUN_DIR/phase"
set +e
printf '%s\n\nREQUEST_FILE: %s\n' '$finalize-commit' "$FINALIZE_REQUEST" \
| codex exec -C "$WORKTREE_DIR" "${CODEX_COMMON[@]}" \
--output-schema "$FINALIZE_SCHEMA" -o "$RUN_DIR/finalize-result.json" - \
2>"$RUN_DIR/finalize.log"
FINALIZE_EXIT=$?
set -e
if [ "$FINALIZE_EXIT" -ne 0 ] || [ ! -f "$RUN_DIR/finalize-result.json" ] \
|| ! jq empty "$RUN_DIR/finalize-result.json" 2>/dev/null; then
write_result --arg wt "$WORKTREE_DIR" --arg br "$BRANCH" \
'{status:"inspect", error:"finalize-commit exited abnormally — see finalize.log; worktree left intact",
worktree_dir:$wt, cleanup_branch:$br}'
finish
fi
FINALIZE_STATUS="$(jq -r '.status' "$RUN_DIR/finalize-result.json")"
if [ "$FINALIZE_STATUS" != "committed" ]; then
write_result --argjson fr "$(cat "$RUN_DIR/finalize-result.json")" \
--arg wt "$WORKTREE_DIR" --arg br "$BRANCH" \
'{status: (if $fr.status=="blocked" then "blocked" else $fr.status end),
blocker: $fr.blocker, error: $fr.error,
worktree_dir:$wt, cleanup_branch:$br, finalize_result:"finalize-result.json"}'
finish
fi
# --- Step 8: rebase --onto TIP — now purely a concurrent-modification guard ---
echo "rebasing" > "$RUN_DIR/phase"
TIP="$(git -C "$CHECKOUT_PATH" rev-parse HEAD)"
ATTEMPT=0
MERGED=0
CURRENT_UPSTREAM="$BASE"
while [ "$ATTEMPT" -lt 3 ]; do
ATTEMPT=$((ATTEMPT+1))
if ! git -C "$WORKTREE_DIR" rebase --onto "$TIP" "$CURRENT_UPSTREAM" "$BRANCH" \
> "$RUN_DIR/rebase.log" 2>&1; then
write_result --arg wt "$WORKTREE_DIR" --arg br "$BRANCH" \
'{status:"blocked", blocked_stage:"rebase",
blocker:"rebase conflict against current checkout tip — see rebase.log; worktree left intact for manual rescue",
worktree_dir:$wt, cleanup_branch:$br}'
finish
fi
CURRENT_UPSTREAM="$TIP"
echo "merging" > "$RUN_DIR/phase"
if git -C "$CHECKOUT_PATH" merge --ff-only "$BRANCH" > "$RUN_DIR/merge.log" 2>&1; then
MERGED=1
break
fi
NEW_TIP="$(git -C "$CHECKOUT_PATH" rev-parse HEAD)"
if [ "$NEW_TIP" = "$TIP" ]; then break; fi # real conflict, not a race — stop retrying
TIP="$NEW_TIP"
done
if [ "$MERGED" -ne 1 ]; then
write_result --arg wt "$WORKTREE_DIR" --arg br "$BRANCH" \
'{status:"blocked", blocked_stage:"merge_back",
blocker:"ff-only merge failed — see merge.log; worktree left intact for manual rescue",
worktree_dir:$wt, cleanup_branch:$br}'
finish
fi
# --- Success: teardown (safe — real checkout files were never touched) ---
CSHA="$(git -C "$CHECKOUT_PATH" rev-parse HEAD)"
git -C "$CHECKOUT_PATH" worktree remove --force "$WORKTREE_DIR" || true
git -C "$CHECKOUT_PATH" branch -d "$BRANCH" || true
write_result --arg sha "$CSHA" --arg msg "$COMMIT_MESSAGE" \
--argjson api "$(jq '.api_suggestions // []' "$RUN_DIR/produce-result.json")" \
--argjson flagged "$(jq '.findings_flagged // []' "$RUN_DIR/critique-result.json")" \
'{status:"committed", commit_sha:$sha, commit_message_used:$msg, critic_ran:true,
api_suggestions:$api, findings_flagged:$flagged}'
finish
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment