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/3772f6a42b9ae4ac6a3280c064f767eb to your computer and use it in GitHub Desktop.

Select an option

Save mobiRic/3772f6a42b9ae4ac6a3280c064f767eb to your computer and use it in GitHub Desktop.
Universal Claude→Codex commit finalization system: two-agent quality-gated commit workflow

The Finalize-with-Codex System

A two-agent workflow that separates authoring (Claude) from quality gating (Codex). Claude drafts the commit message and hands off to Codex; Codex runs the checks, makes only mechanical repairs, and commits using Claude's message verbatim.

See also: cleanup-with-codex — a heavier, occasional sibling that runs a two-stage reuse/dead-code cleanup pass in an isolated worktree, then hands off to this system's finalize-commit for the actual commit.


Files in this gist

File Purpose
finalize-with-codex-SKILL.md Claude skill — orchestrates the handoff
finalize-commit-SKILL.md Codex skill — runs quality gates and commits
run-finalizer.sh Shell launcher — background bridge between Claude and Codex
response-schema.json JSON schema enforcing Codex's output shape

How the system works

1. ~/.claude/CLAUDE.md — Universal commit policy

The top-level behavioral rule injected into every Claude session. Tells Claude:

  • When to use the skill: any change that could affect runtime behavior (source, tests, build config, schemas, CI, deps).
  • When to skip it: purely presentational changes (docs, comments, assets, .gitignore). When ambiguous, skip — it's cheaper to run the skill afterwards than to undo an unnecessary run.
  • How to hand off: run git status + git diff + git log, draft the commit message, print it, then invoke finalize-with-codex immediately. No confirmation step.
  • While locked: don't edit or stage files in the target checkout. Other checkouts are fine.
  • On blocked result: surface the blocker; don't bypass the skill.
  • Never push via Codex. Claude's existing confirm-before-push behavior is unchanged.

2. finalize-with-codex-SKILL.md — Claude skill spec

Installed at ~/.claude/skills/finalize-with-codex/SKILL.md. The model-invocable skill Claude follows step-by-step when it needs to hand off:

  1. Lock check (advisory): Look for $GIT_DIR/codex-finalize.lock early and warn if found — but the shell script's atomic acquisition is authoritative, so this is just an early warning.
  2. Write request file: Use the Write tool to put {"commit_message": "...", "task_summary": "...", "files": [...]} in the scratchpad. files is an optional array of repo-relative paths — when present, it restricts the commit to exactly those, leaving any other working-tree change untouched; omit it to infer scope from the full working-tree diff. No dynamic text crosses the shell boundary — only the checkout path and file path are passed as arguments.
  3. Launch the helper: Run run-finalizer.sh via the Bash tool with run_in_background=true. Claude is auto-notified on completion; no polling.
  4. Parse the result: Look for FINALIZE_DONE $RUN_DIR in the notification output (fall back to FINALIZE_STARTED if Codex crashed), then read $RUN_DIR/result.json and report based on status (committed, blocked, failed, inspect).

3. run-finalizer.sh — Shell launcher

The background shell script that bridges Claude and Codex:

  1. Resolves the absolute git dir and git common dir (handles both normal repos and linked worktrees — Codex needs write access to both, or git add/git commit fail writing loose objects).
  2. Builds the sandbox's writable-dir list: the git dir(s), plus any paths declared in the target checkout's .claude/finalize-with-codex.extra-writable-dirs (one path per line, blank/#-comment lines skipped, ~/$VARS expanded via eval echo). This is how a project declares that some tool run during its quality gates needs to write outside the checkout — e.g. a build tool's cache dir under $HOME — without patching this global script. Optional and additive; a project without the file behaves exactly as before.
  3. Atomic lock acquisition via set -o noclobber — writes PID and start time. Aborts immediately if the lock already exists; never auto-removes stale locks (requires user action).
  4. Creates a unique run dir via mktemp -d, immediately prints FINALIZE_STARTED $RUN_DIR — so an early crash still tells Claude where to look.
  5. Records HEAD_BEFORE.
  6. Invokes codex exec with:
    • -C "$CHECKOUT_PATH" — target working directory
    • -s workspace-write — sandbox level
    • --ephemeral — no persisted session
    • --add-dir for each dir from step 2
    • --output-schema pointing to response-schema.json — enforces the response shape
    • -o $RUN_DIR/result.json — captures Codex's final message
    • Prompt via stdin: $finalize-commit + REQUEST_FILE: context line
  7. Records HEAD_AFTER. If Codex exits non-zero or produces invalid JSON, synthesizes a result: inspect if HEAD advanced (commit may have succeeded), failed otherwise.
  8. Prints FINALIZE_DONE $RUN_DIR.
  9. Lock released by trap on exit.

4. response-schema.json — Output schema

Passed to codex exec --output-schema to enforce what Codex returns. All fields are present but nullable, keeping the schema simple and compatible with Codex's output handling:

Field Type Notes
status enum committed, blocked, failed, inspect
commit_sha string|null set on committed and inspect
commit_message_used string|null set on committed
repairs_made array|null list of mechanical repairs applied
blocker string|null set on blocked
commands_run array|null all commands Codex ran
error string|null set on failed and inspect

Design note: An earlier iteration used a oneOf schema with per-status required fields. This was simplified to flat nullable fields — oneOf caused validation friction with codex exec, so all fields are always present but nullable.


5. finalize-commit-SKILL.md — Codex skill spec

Installed at ~/.codex/skills/finalize-commit/SKILL.md. The skill Codex runs when invoked via $finalize-commit. Its constraints are strict:

It does:

  • Read REQUEST_FILE and extract commit_message, task_summary, and the optional files scope
  • Read only build/test docs (not architecture docs) to learn how to run checks
  • Scope formatting to changed lines only — recomputed fresh from git diff -M -U0 against the pre-handoff HEAD before every formatting pass, including after every repair, never reused across an edit. Diffing runs against all touched paths in one command, never a single path at a time, because a lone new-side pathspec suppresses git's rename detection and makes a moved file look like one giant addition
  • Run formatter, autofix, lint, compile, tests
  • Repair: formatter violations, lint violations, compile errors, import/syntax/rename/type errors
  • Rerun all checks after repair
  • Stage only intended files (exactly the files list when given, via git add --, never git add -A/.); commit the message via a temp file and git commit -F <tempfile> — never -m "<inline>", so quotes/backticks/$ in the message can't be mangled crossing a shell boundary
  • Verify the landed commit: git log -1 --format=%B must match the intended message byte-for-byte (ignoring a trailing newline); a mismatch returns inspect, not a silent success

It never:

  • Repairs failing assertions, flaky tests, business logic, public APIs, schemas, deps, or CI
  • Rewrites or validates Claude's commit message
  • Blocks on documentation inconsistencies, architecture concerns, or instruction disagreements — those decisions were Claude's and the user's, not Codex's
  • Pushes, amends, bypasses hooks, disables tests, weakens assertions, or suppresses failures

End-to-end flow

User: "commit this"
  │
Claude: git status + git diff + git log
        → drafts commit message
        → writes request JSON to scratchpad
        → calls run-finalizer.sh in background
  │
run-finalizer.sh (background):
        → acquires per-worktree lock
        → prints FINALIZE_STARTED $RUN_DIR
        → invokes codex exec (skill via stdin)
        → validates/synthesizes result.json
        → prints FINALIZE_DONE $RUN_DIR
        → releases lock

Claude: may continue work in other checkouts
        auto-notified by run_in_background when done

Codex finalize-commit (concurrent):
        → reads REQUEST_FILE
        → format → lint → compile → test
        → repair (mechanical only) → rerun
        → commit with Claude's message (verbatim)
        → return JSON result
  │
Claude: reads result.json
        committed → report SHA + continue
        blocked   → surface blocker to user
        failed    → show error + log path
        inspect   → warn user to check git log before retrying

inspect now covers two distinct cases: Codex exiting abnormally after HEAD advanced (synthesized by run-finalizer.sh), or Codex committing normally but its own post-commit message verification finding a mismatch (returned by Codex itself). Both get the same treatment — don't retry without checking git log first.


Key design decisions

  • Request file, not shell args — commit messages can contain quotes, newlines, and special characters. Writing to a JSON file and passing only the path avoids all shell injection risk.
  • Atomic lock via noclobber — prevents two concurrent finalizers on the same checkout. Stale locks require explicit user removal; the script never auto-removes them.
  • FINALIZE_STARTED before codex exec — ensures Claude has the run dir path even if Codex crashes immediately.
  • HEAD_BEFORE/HEAD_AFTER comparison — if Codex exits non-zero but HEAD advanced, the commit likely happened; inspect status warns Claude to check before retrying rather than blindly re-committing.
  • --add-dir "$GIT_DIR" — needed to give Codex's sandbox access to the git directory, without which git commit inside the sandbox would fail.
  • Codex blocks only on check failures — architecture disagreements and doc inconsistencies are explicitly excluded as block reasons; Codex is a quality gate, not a reviewer.
  • files scope lives with the request, not as a global default — most handoffs cover the full working-tree diff; scoping to specific paths is opt-in per request, for the case where unrelated changes coexist and only one set should land now. If a single file mixes an in-scope and out-of-scope change such that the two can't be cleanly isolated, that's blocked, not a guess.
  • -F <tempfile>, never -m, for the commit message — an inline -m "$MESSAGE" interpolates the message through a shell command; apostrophes, quotes, backticks, and $ in the message can be silently mangled crossing that boundary. Writing the message to its own temp file and using -F avoids shell interpretation entirely. The post-commit git log -1 --format=%B verification exists because this exact bug shipped a truncated commit message once already — the fix needed to make that class of bug structurally impossible, not just fixable after the fact.
  • Extra-writable-dirs file lives in the project, not the global script — a tool run during quality gates sometimes needs to write outside the checkout (a cache dir under $HOME, say). That need is project-specific; hardcoding it into this global script means every future project with its own such need requires a hand-patch here. .claude/finalize-with-codex.extra-writable-dirs keeps the fix versioned with the project that needs it.
  • Changed-line ranges are always recomputed, never cached — formatting scope is derived fresh from git diff -M -U0 before every pass, including after every repair, rather than computed once and adjusted. Repairs shift line numbers; a cached range silently drifts out of sync with the file it describes. Recomputing is the only reliable way to guarantee a fix never lands on a line Claude didn't touch.
name finalize-commit
description Quality-gate and commit a set of staged changes handed off by Claude. Use when invoked by the finalize-with-codex Claude skill. Runs linter, compiler, and tests, and formats only the lines Claude changed (never a whole-file reformat); makes only mechanical repairs; then commits using the Claude-authored message verbatim. Never pushes. Returns a structured JSON result.

finalize-commit

Accepts a handoff from Claude, runs quality gates, optionally repairs mechanical errors, and creates a local commit using Claude's pre-authored message.

Inputs (from context)

Read the REQUEST_FILE path from context. Parse its JSON to extract:

  • commit_message — use verbatim; treat as authoritative, do not validate or rewrite it
  • task_summary — background context for understanding the change
  • files (optional array of repo-relative paths) — when present, this is the entire scope of the handoff: the only paths inspected, formatted, linted against, repaired, or staged. When absent, scope is the full working-tree diff, as before.

Return blocked immediately if REQUEST_FILE is absent, unreadable, or commit_message is empty.

Workflow

  1. Read REQUEST_FILE from context; parse JSON to extract commit_message, task_summary, and files. Return blocked immediately if the file is absent, unreadable, or commit_message is empty. Then inspect git status and the full diff to identify which build/test commands to run.

    If files is present, treat those exact paths as the handoff scope for every remaining step — do not inspect, format, lint-fix, repair, or stage anything outside them. Any other unstaged/untracked change in the tree is out of scope: leave it alone. If a single file mixes an in-scope change with an out-of-scope one such that the two can't be cleanly isolated, that's blocked — explain the conflict, don't guess.

  2. Read only the repository's build and test command documentation (e.g. CLAUDE.md sections on build commands, test commands, and lint). Read it to learn how to run the checks — not to evaluate whether the changes are architecturally correct. Do not use documentation to second-guess the changes.

  3. Scope formatting to changed lines only. Touched files are files from the request if present, otherwise every file with a working-tree diff against the pre-handoff HEAD. Before every formatting/lint pass — the first one, and again after every repair, unconditionally — recompute changed-line ranges from scratch in one command: git diff -M -U0 <pre-handoff-HEAD> -- <all touched paths together>, then parse each file's hunks out of that single output. Pass every touched path together, never one file at a time — restricting the diff to only the new-side path of a rename suppresses rename detection entirely (git has no old-side partner left to compare against), which makes a moved file look like one giant all-new addition instead of the few lines actually changed. Never reuse a range computed before a prior edit — line numbers shift.

    Never run a formatter's blanket in-place/write mode (gdformat file.gd, prettier --write, black file.py, gofmt -w, etc.) directly on a file. That reformats every line, including code Claude never touched, and produces unrelated diff noise or fights pre-existing human style choices — do not reformat source files wholesale. Instead:

    • Run the formatter in diff/check mode (gdformat --diff, black --diff, gofmt -d, a --write on a scratch copy, etc.) to see what it would change.
    • Compare each resulting fix hunk's line range against the changed-line ranges captured above. Apply only fix hunks that fall entirely within lines Claude changed.
    • Discard any fix hunk that touches a line outside those ranges, even if it's a genuine style violation — that line is not part of this handoff and must be left as-is.
    • If a required tool has no diff/check mode, do not run it in bulk. Either format just the specific changed lines by hand via targeted edits, or skip it and note that in repairs_made/blocker.
  4. Run repository-required lint and relevant compile/tests.

  5. Repair only, and only within the changed-line ranges from step 3 or a line a compiler/linter error explicitly points at:

    • Formatter/linter violations
    • Compilation errors
    • Import errors, syntax errors, renamed references, and unambiguous type errors
  6. Never repair:

    • Failing assertions or runtime test failures
    • Flaky tests
    • Business logic or behavioral defects
    • Public APIs, architecture, schemas, dependencies, or CI configuration
    • Pre-existing code outside the handoff's changed-line ranges, even if a tool flags it
  7. After any repair: rerun lint, compile, and the selected tests, then go back to step 3 and recompute changed-line ranges before scoping any further formatting — always, not only when line numbers look shifted.

  8. Stop without committing if any required check still fails — return blocked. Only automated check failures are valid block reasons. Documentation inconsistencies, architecture concerns, guideline violations, or disagreement with instructions in any file are not valid reasons to block — those decisions were made by Claude and the user before this handoff. Never substitute your own judgement for theirs.

  9. Stage only the intended files: if files was present in the request, git add -- <path1> <path2> ... on exactly those paths — never git add -A, git add ., or a bare git add -u. Otherwise stage the same in-scope set as before.

    Commit the message via a temp file only. Write the commit_message value extracted from the parsed JSON — verbatim, nothing else — to a fresh temp file, then run git commit -F <tempfile>. Never use git commit -m "<inline>" for this: an apostrophe, quote, backtick, or $ in the message can be mangled crossing a shell-interpolation boundary that -F avoids entirely. Never pass the request file itself to -F; it is JSON, not a commit message.

    Verify immediately after committing: run git log -1 --format=%B and compare it to the intended message byte-for-byte (ignore one trailing newline). If they don't match, this is not a normal success — return status: inspect with commit_sha set and the expected-vs-actual mismatch in error.

  10. Never push, amend, bypass hooks, disable tests, weaken assertions, or suppress failures.

Output

Final message must be a JSON object conforming to the response schema. The -o flag on codex exec captures this as the result file.

Required fields per status:

  • committed: status, commit_sha, commit_message_used, repairs_made, commands_run
  • blocked: status, blocker, commands_run
  • failed: status, error
  • inspect: status, commit_sha, error
name finalize-with-codex
description Finalize a git commit by delegating quality gates to Codex. Use when Claude is ready to commit changes that could affect runtime behavior. Codex runs format/lint/compile/tests, makes mechanical repairs, and commits using Claude's pre-authored message. Invoked by Claude automatically per the Universal Commit Policy in ~/.claude/CLAUDE.md.

finalize-with-codex

Orchestrates a quality-gated commit via Codex. Claude authors the commit message; Codex owns formatting, linting, compilation, testing, repairs, and the actual commit.

Inputs

  • checkout_path — absolute path to the target git checkout
  • commit_message — the full commit message Claude has drafted
  • task_summary — one sentence describing what was done
  • files (optional, array of repo-relative paths) — restricts the commit to exactly these paths, leaving any other working-tree change untouched. Use when unrelated changes coexist and only one set should land now. Omit to infer scope from the full working-tree diff (default).

Steps

  1. Lock check (advisory): Check for an existing codex-finalize.lock in the checkout's git dir and warn early if found. This check is advisory only — the shell helper's atomic lock acquisition is authoritative.

  2. Write request file: Use the Write tool to create a JSON file in the scratchpad:

    {"commit_message": "...", "task_summary": "...", "files": ["path/one.gd", "path/two.gd"]}

    files is optional — omit it entirely when the commit should cover the full working-tree diff. Never pass the message or summary as shell arguments — only the file path crosses the shell boundary.

  3. Launch helper: Run run-finalizer.sh using the Bash tool with run_in_background=true, passing only two arguments: the checkout path and the request file path.

    ~/.claude/skills/finalize-with-codex/run-finalizer.sh "$CHECKOUT_PATH" "$REQUEST_FILE"
    

    Claude is automatically re-invoked when the process completes — no polling required.

  4. Report result: When notified, parse $RUN_DIR from the FINALIZE_DONE line in the notification output (fall back to FINALIZE_STARTED if the process crashed before completion). Read $RUN_DIR/result.json and report:

    • committed — show SHA and message
    • blocked — show blocker details and any repairs attempted
    • failed — show error and log path ($RUN_DIR/codex.log)
    • inspect — HEAD advanced but something needs manual verification: either Codex exited abnormally, or Codex committed but its own post-commit message check found a mismatch against the intended message. Either way, warn the user not to retry without checking git log first.

Checking progress mid-run

If the user asks what the finalizer is doing while it's still running, don't wait for FINALIZE_DONE. Read the git dir's pointer file to find the live run directory, then tail its log:

RUN_DIR="$(cat "$GIT_DIR/codex-finalize-current-run")"
tail -c 2000 "$RUN_DIR/codex.log"

$GIT_DIR is the same absolute path resolved in run-finalizer.sh (git rev-parse --git-dir, made absolute). The pointer file is overwritten at the start of each run, so it always reflects the most recent invocation.

Extra sandbox-writable directories

Codex runs the quality gates in a sandbox that can only write inside the checkout (plus its git dirs). If a project's build/test/lint tooling writes somewhere else — a tool cache or settings file under $HOME, for example — declare it in .claude/finalize-with-codex.extra-writable-dirs at the checkout root: one path per line, blank lines and #-comments ignored, ~ and $VARS expanded. run-finalizer.sh reads this file if present and adds each path as an extra --add-dir. Optional and additive — a project without this file behaves exactly as before.

{
"type": "object",
"additionalProperties": false,
"required": ["status", "commit_sha", "commit_message_used", "repairs_made", "blocker", "commands_run", "error"],
"properties": {
"status": { "type": "string", "enum": ["committed", "blocked", "failed", "inspect"] },
"commit_sha": { "type": ["string", "null"] },
"commit_message_used": { "type": ["string", "null"] },
"repairs_made": { "type": ["array", "null"], "items": { "type": "string" } },
"blocker": { "type": ["string", "null"] },
"commands_run": { "type": ["array", "null"], "items": { "type": "string" } },
"error": { "type": ["string", "null"] }
}
}
#!/usr/bin/env bash
set -euo pipefail
SKILL_DIR="$(cd "$(dirname "$0")" && pwd)"
CHECKOUT_PATH="$1"
REQUEST_FILE="$2"
# Resolve absolute git dir
GIT_DIR_REL="$(git -C "$CHECKOUT_PATH" rev-parse --git-dir)"
if [[ "$GIT_DIR_REL" == /* ]]; then
GIT_DIR="$GIT_DIR_REL"
else
GIT_DIR="$(cd "$CHECKOUT_PATH/$GIT_DIR_REL" && pwd)"
fi
# Resolve absolute git *common* dir. In a linked worktree, --git-dir points at
# the private per-worktree dir (.git/worktrees/<name>), but the shared object
# database (objects/, refs/) lives in the common dir. Codex needs write access
# to both, or `git add`/`git commit` fail trying to write loose objects.
COMMON_DIR_REL="$(git -C "$CHECKOUT_PATH" rev-parse --git-common-dir)"
if [[ "$COMMON_DIR_REL" == /* ]]; then
COMMON_DIR="$COMMON_DIR_REL"
else
COMMON_DIR="$(cd "$CHECKOUT_PATH/$COMMON_DIR_REL" && pwd)"
fi
# Projects can declare extra sandbox-writable dirs (tool caches/config outside
# the checkout) in .claude/finalize-with-codex.extra-writable-dirs, one path
# per line, ~ and $VARS expanded. Keeps project-specific sandbox needs
# versioned with the project instead of hardcoded in this global script.
ADD_DIRS=("$GIT_DIR")
if [ "$COMMON_DIR" != "$GIT_DIR" ]; then
ADD_DIRS+=("$COMMON_DIR")
fi
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
LOCK_FILE="$GIT_DIR/codex-finalize.lock"
# Atomic lock acquisition via noclobber
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 finalization may be running. Remove it manually after confirming no finalizer is running." >&2
exit 1
fi
trap 'rm -f "$LOCK_FILE"' EXIT
# Create run directory and announce start immediately
RUN_DIR="$(mktemp -d)"
echo "FINALIZE_STARTED $RUN_DIR"
# Stable pointer so callers can find the live log/result without parsing this
# process's own stdout (which may be buried in a background task transcript).
echo "$RUN_DIR" > "$GIT_DIR/codex-finalize-current-run"
# Record HEAD before Codex runs
HEAD_BEFORE="$(git -C "$CHECKOUT_PATH" rev-parse HEAD)"
# Invoke Codex, capturing exit code without relying on set -e
set +e
printf '%s\n\nREQUEST_FILE: %s\n' '$finalize-commit' "$REQUEST_FILE" \
| codex exec \
-C "$CHECKOUT_PATH" \
-s workspace-write \
-c sandbox_workspace_write.network_access=true \
"${ADD_DIR_ARGS[@]}" \
--ephemeral \
--output-schema "$SKILL_DIR/response-schema.json" \
-o "$RUN_DIR/result.json" \
- \
2>"$RUN_DIR/codex.log"
CODEX_EXIT=$?
set -e
# Record HEAD after Codex runs
HEAD_AFTER="$(git -C "$CHECKOUT_PATH" rev-parse HEAD)"
# Validate result; synthesize if missing, invalid, or Codex exited non-zero
if [ "$CODEX_EXIT" -ne 0 ] || [ ! -f "$RUN_DIR/result.json" ] || ! jq empty "$RUN_DIR/result.json" 2>/dev/null; then
if [ "$HEAD_AFTER" != "$HEAD_BEFORE" ]; then
printf '{"status":"inspect","commit_sha":"%s","error":"codex exited abnormally but HEAD advanced — commit may have succeeded"}\n' \
"$HEAD_AFTER" > "$RUN_DIR/result.json"
else
printf '{"status":"failed","error":"codex exited without valid output"}\n' \
> "$RUN_DIR/result.json"
fi
fi
echo "FINALIZE_DONE $RUN_DIR"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment