{"slug": "make-codex-prove-it-a-three-file-design-that-leaves-evidence-on-disk", "title": "Make Codex Prove It: A Three-File Design That Leaves Evidence on Disk", "summary": "A developer has created a three-file design to verify AI agent work, addressing the problem of agents like Codex and Claude Code claiming completion without actually making changes. The system uses separate task, handoff, and status files, with the status file recording state transitions and the handoff file containing real git output, so a human can cross-check results in a shell. The approach emphasizes separation of concerns and error handling with set -euo pipefail.", "body_md": "An AI agent telling you \"done\" is not evidence. When I started delegating work to Codex, I took those reports at face value — until I checked the code and found the change missing, the wrong file edited, or no commit at all. So I stopped trusting language and started making the shell write the facts to disk.\n\nWhen you hand a task to Codex, it comes back with \"Completed.\" At first that satisfied me. But when I actually checked the code, the critical change wasn't there, or a different file had been touched, or `git commit`\n\nhad never run. The output \"I did it\" and the fact \"it was actually done\" are two different things.\n\nThis is true of Claude Code too. Whether tool results were read correctly, whether errors were swallowed — even with code I wrote myself, running a self-audit right after declaring completion turns up something every single time. Delegating implementation to an AI amplifies that problem by one more notch.\n\nThe fix is simple: **make it write state to a file, not to language.**\n\nEven if the AI says \"completed,\" it isn't complete unless `State: completed`\n\nexists in the status file. If the handoff file doesn't contain the real output of `git status --short`\n\n, you don't know what changed. If the four sections you specified in the task file (Summary, Files Changed, Validation, Remaining Risks) aren't there, you can't verify it.\n\nFiles don't lie. An AI under pressure will insist \"I did it,\" but the output of `cat status-file`\n\ncan't be forged. Pushing state management down into the filesystem is what makes it possible for **a human to cross-check it in a shell**. That's the essence of this design.\n\nThe other important piece is **separation of concerns**. orchestrate-codex-worker.sh takes three arguments up front.\n\n```\nbash scripts/orchestrate-codex-worker.sh <task-file> <handoff-file> <status-file>\n```\n\nEach of these three files has a clear role.\n\n`State: running`\n\n→ `State: completed`\n\n/ `State: failed`\n\n.What happens without this separation? When instructions and execution results live in the same place, \"is this an instruction or post-execution output?\" becomes ambiguous. When you run large numbers of tasks in parallel, that ambiguity is fatal. Multiple Codex workers can run in the same directory without interfering as long as each has its own independent task/handoff/status files.\n\nOn top of that, the script starts with `set -euo pipefail`\n\n. That's a declaration that \"the entire script stops the moment any command fails.\" Without it, Bash ignores errors and moves to the next line. With `set -euo pipefail`\n\n, the behavior becomes: if git rev-parse fails, stop; if mkdir fails, stop. A design that doesn't swallow errors matters especially in a script built on the premise of AI delegation.\n\nFollowing the script's behavior in order looks like this.\n\n```\n呼び出し元（Claude Code / cronジョブ等）\n    │\n    ├─ task-file を渡す（作業指示）\n    ├─ handoff-file のパスを渡す（引き継ぎ先）\n    └─ status-file のパスを渡す（状態管理先）\n         │\n         ▼\norchestrate-codex-worker.sh\n    │\n    ├─ [起動直後] write_status \"running\"\n    │        └→ status-file: State: running / Branch / Worktree / timestamp\n    │\n    ├─ task-file の読み込み確認\n    │   ├─ [失敗] write_status \"failed\" + handoff-fileにエラー書き出し → exit 1\n    │   └─ [成功] 処理継続\n    │\n    ├─ mktemp で prompt_file / output_file を作成\n    │   └─ trap cleanup EXIT（終了時に自動削除）\n    │\n    ├─ prompt_file を組み立て（Codexへの指示 + task-fileの内容）\n    │\n    ├─ codex exec -p yolo -m gpt-5.4 -C $(pwd) -o output_file < prompt_file\n    │   │\n    │   ├─ [成功]\n    │   │     handoff-file に書き出し:\n    │   │       - Completed: timestamp\n    │   │       - Branch: git rev-parse --abbrev-ref HEAD\n    │   │       - Worktree: pwd\n    │   │       - output_file の内容（Summary/Files Changed/Validation/Remaining Risks）\n    │   │       - git status --short\n    │   │     write_status \"completed\"\n    │   │\n    │   └─ [失敗]\n    │         handoff-file に書き出し:\n    │           - Failed: timestamp / Branch / Worktree\n    │           - \"The Codex worker exited with a non-zero status.\"\n    │         write_status \"failed\" → exit 1\n    │\n    └─ 完了\n```\n\nLooking at the `write_status`\n\nfunction — the core of the actual code — shows what's being recorded.\n\n```\nwrite_status() {\n  local state=\"$1\"\n  local details=\"$2\"\n\n  cat > \"$status_file\" <<EOF\n# Status\n\n- State: $state\n- Updated: $(timestamp)\n- Branch: $(git rev-parse --abbrev-ref HEAD)\n- Worktree: `$(pwd)`\n\n$details\nEOF\n}\n```\n\n`git rev-parse --abbrev-ref HEAD`\n\nreturns the branch name at that moment. `$(pwd)`\n\nis the absolute path of the worktree. The timestamp comes out as UTC ISO 8601 via `date -u +\"%Y-%m-%dT%H:%M:%SZ\"`\n\n.\n\nIn other words, the status file tells you line by line **when, on which branch, in which worktree, and in what state**. When running in parallel across multiple worktrees, just looking at the Worktree field in the status file identifies which is which.\n\nThe prompt handed to Codex is also assembled directly inside the script.\n\n```\ncat > \"$prompt_file\" <<EOF\nYou are one worker in an ECC tmux/worktree swarm.\n\nRules:\n- Work only in the current git worktree.\n- Do not touch sibling worktrees or the parent repo checkout.\n- Complete the task from the task file below.\n- Do not spawn subagents or external agents for this task.\n- Report progress and final results in stdout only.\n- Do not write handoff or status files yourself; the launcher manages those artifacts.\n- If you change code or docs, keep the scope narrow and defensible.\n- In your final response, include exactly these sections:\n  1. Summary\n  2. Files Changed\n  3. Validation\n  4. Remaining Risks\n\nTask file: $task_file\n\n$(cat \"$task_file\")\nEOF\n```\n\nTwo things stand out.\n\n**\"Do not write handoff or status files yourself\"** — an explicit prohibition. Who writes the handoff/status files is a key design fork. If you let Codex write them, Codex may output something that merely *looks* right. Writing them from the script side means you get the actual output of `git status --short`\n\n, the actual branch name from `git rev-parse --abbrev-ref HEAD`\n\n, and the actual time from `timestamp`\n\n. Those can't be falsified.\n\n**Forcing four sections.** Requiring \"exactly these sections\" in Codex's output fixes the positions that downstream processing and review will reference. Open the handoff file, read the \"Files Changed\" section, and you have the list of changed files; read the \"Validation\" section and you have the verification commands Codex actually ran and their results. With free-form output, when the next session loads the handoff file you no longer know where to look.\n\nThe codex invocation itself is one line.\n\n```\ncodex exec -p yolo -m gpt-5.4 --color never -C \"$(pwd)\" -o \"$output_file\" - < \"$prompt_file\"\n```\n\n`-p yolo`\n\nmeans no confirmation prompts, `-m gpt-5.4`\n\nspecifies the model, `-C \"$(pwd)\"`\n\nsets the working directory, `-o \"$output_file\"`\n\nsets the output destination, and `- < \"$prompt_file\"`\n\ntells it to read the prompt from stdin. `--color never`\n\nkeeps ANSI escape sequences from contaminating the handoff file, so no junk characters get in the way when you grep or parse it later.\n\nWhether this call succeeds (exit 0) or fails (non-zero) decides the branch that follows. Because the script has `set -euo pipefail`\n\n, a failing codex command doesn't exit outright — the `if codex exec ...; then ... else ... fi`\n\nstructure routes it into the error branch. Even on failure, the handoff file and status file are always written. Never producing a state of \"no file = it was never even run\" is what the later cross-check verification requires.\n\n`trap cleanup EXIT`\n\nprotects\nThere's a mechanism I didn't cover in the first half that you should read first.\n\n```\nprompt_file=\"$(mktemp)\"\noutput_file=\"$(mktemp)\"\ncleanup() {\n  rm -f \"$prompt_file\" \"$output_file\"\n}\ntrap cleanup EXIT\n```\n\n`mktemp`\n\ncreates a temp file like `/tmp/tmp.XXXXXX`\n\n. `trap cleanup EXIT`\n\ndeclares \"when the script exits — whether exit 0 or exit 1 — run the `cleanup`\n\nfunction.\"\n\nWhy is this needed? `codex exec`\n\nreads `prompt_file`\n\nand writes to `output_file`\n\n, but Codex sometimes exits non-zero. When Codex fails in a `set -euo pipefail`\n\nenvironment, the script enters the else block and ends with exit 1. Without `trap`\n\n, `/tmp/tmp.XXXXXX`\n\nwould linger. Once is fine, but run 30 workers in parallel and `/tmp`\n\nbloats. With `trap cleanup EXIT`\n\n, the temp files disappear no matter which path exits.\n\nOne more point: note that ** prompt_file and output_file are global variables**. At definition time, the\n\n`cleanup`\n\nfunction doesn't know the contents of `$prompt_file`\n\n/ `$output_file`\n\n. It reads the variable values at exit, when the function runs. That's exactly why the order is: assign the variables right after `mktemp`\n\n, then set the trap. Reverse the order and cleanup tries to delete empty paths and errors out.Reading the script, the task-file existence check comes before `write_status \"running\"`\n\n.\n\n```\nmkdir -p \"$(dirname \"$handoff_file\")\" \"$(dirname \"$status_file\")\"\n\nif [[ ! -r \"$task_file\" ]]; then\n  write_status \"failed\" \"- Error: task file is missing or unreadable (\\`$task_file\\`)\"\n  {\n    echo \"# Handoff\"\n    echo\n    echo \"- Failed: $(timestamp)\"\n    echo \"- Branch: \\`$(git rev-parse --abbrev-ref HEAD)\\`\"\n    echo \"- Worktree: \\`$(pwd)\\`\"\n    echo\n    echo \"Task file is missing or unreadable: \\`$task_file\\`\"\n  } > \"$handoff_file\"\n  exit 1\nfi\n\nwrite_status \"running\" \"- Task file: \\`$task_file\\`\"\n```\n\n`write_status \"running\"`\n\nmeans \"submission to Codex has begun.\" If the task file can't be read, the premise for submitting to Codex has collapsed, so it isn't entitled to be \"running.\" It writes `\"failed\"`\n\ndirectly and exits 1.\n\nThe reason `mkdir -p \"$(dirname \"$handoff_file\")\" \"$(dirname \"$status_file\")\"`\n\ncomes first is the same. When the task file can't be read and you try to write failed, if the directories for the handoff file or status file don't exist, that write itself fails. Make sure the parent directories exist before any write to the handoff/status files. That's why `mkdir -p dirname ...`\n\nsits at the top.\n\nWhen Codex's exit code is 0, the following gets written to the handoff file.\n\n```\nif codex exec -p yolo -m gpt-5.4 --color never -C \"$(pwd)\" -o \"$output_file\" - < \"$prompt_file\"; then\n  {\n    echo \"# Handoff\"\n    echo\n    echo \"- Completed: $(timestamp)\"\n    echo \"- Branch: \\`$(git rev-parse --abbrev-ref HEAD)\\`\"\n    echo \"- Worktree: \\`$(pwd)\\`\"\n    echo\n    cat \"$output_file\"\n    echo\n    echo \"## Git Status\"\n    echo\n    git status --short\n  } > \"$handoff_file\"\n  write_status \"completed\" \"- Handoff file: \\`$handoff_file\\`\"\n```\n\n`cat \"$output_file\"`\n\npulls in Codex's entire output. Immediately after, `echo \"## Git Status\"`\n\nand `git status --short`\n\nfollow.\n\nThat `git status --short`\n\nis the linchpin of verification. Suppose Codex wrote \"Files Changed: src/api/index.ts, tests/api.test.ts\" in its Summary — if `git status --short`\n\nshows nothing, that means git doesn't recognize any change to those files.\n\nThe cross-check commands I actually use are these.\n\n```\n# handoff-fileの\"## Git Status\"以降を確認\ngrep -A 20 \"## Git Status\" /path/to/handoff-file\n\n# 実際のgit diffと比較\ngit diff --stat HEAD\n```\n\nIf the handoff file's `## Git Status`\n\nand `git diff --stat`\n\nagree, what Codex said matches git's actual state. If they don't, it's one of two things: \"Codex thought it made changes but actually didn't,\" or \"it went all the way through commit, so nothing showed in `git status --short`\n\n(= clean).\" For the latter, I check the `Branch`\n\nin the status file and trace it with `git log`\n\n.\n\n**The reason for passing --color never to Codex** lies here. If ANSI escape sequences (control characters like\n\n`\\e[32m`\n\nor `\\033[0m`\n\n) get into output_file, they're transcribed verbatim into the handoff file. When you run `grep -A 20 \"## Git Status\" handoff-file`\n\n, invisible control characters break the pattern match. `--color never`\n\nis the instruction \"don't include ANSI codes in output,\" and without it, mechanical post-processing gets contaminated.The handoff file on failure is minimal.\n\n```\nelse\n  {\n    echo \"# Handoff\"\n    echo\n    echo \"- Failed: $(timestamp)\"\n    echo \"- Branch: \\`$(git rev-parse --abbrev-ref HEAD)\\`\"\n    echo \"- Worktree: \\`$(pwd)\\`\"\n    echo\n    echo \"The Codex worker exited with a non-zero status.\"\n  } > \"$handoff_file\"\n  write_status \"failed\" \"- Handoff file: \\`$handoff_file\\`\"\n  exit 1\nfi\n```\n\nJust the single line `The Codex worker exited with a non-zero status.`\n\nCodex's output is partially written to `output_file`\n\n, but `cat \"$output_file\"`\n\nis not executed here. Why?\n\nThe `output_file`\n\non failure is incomplete output. The four sections may not all be there. An API error may have hit partway through, leaving everything from Summary onward unwritten. Mixing incomplete output into the handoff file means that when the next session reads it, you can't tell \"is this completed output or partial output?\" **Record failure as failure, with zero content** — that's the design intent.\n\nTo investigate what was happening after a failure, you need to capture the stderr of the running `codex exec`\n\nseparately, not `output_file`\n\n. This script doesn't go that far, so on failure I enable Codex-side logging with an environment variable like `CODEX_DEBUG=1`\n\nand check separately.\n\n`set -euo pipefail`\n\nWhen I first wrote this script, I didn't have `set -euo pipefail`\n\nat the top. Bash's default behavior is to ignore errors and move to the next line.\n\nWhat happened? The Codex invocation failed, but the script didn't go into the next `if ... then`\n\nbranch (back then it was a direct call, not an if statement) and `write_status \"completed\"`\n\nran. The status file said `State: completed`\n\n. The handoff file had `Completed: 2026-05-14T08:23:11Z`\n\n. But `git diff --stat`\n\nshowed nothing.\n\nThe symptom is \"the status file says completed but the code hasn't changed.\" At first I thought \"did Codex commit everything?\" and looked at `git log`\n\n— nothing there either.\n\nPinning down the cause took 30 minutes. I ran `codex exec`\n\nmanually and checked the exit code: it was 1. `echo $?`\n\nreturned 1. But inside the script, that 1 was ignored and it moved to the next line.\n\nThe fix was just adding `set -euo pipefail`\n\nat the top and wrapping the Codex call in `if ... then ... else ... fi`\n\n. A two-line change. But before I noticed, I'd believed a \"falsely completed\" status file and stacked further work on top of it, which meant redoing all of it later.\n\nWhen you write a script on the premise of delegating work to an AI, ** set -euo pipefail is not an option, it's a requirement**. Bash's error-ignoring is tolerable when a human is debugging a script by hand, but with AI delegation, \"silently continuing after a failure\" becomes fatal.\n\n`--color never`\n\nWhen I tried to grep the handoff file, I got output like this.\n\n```\n## Git Status\n\n?? src/^[[0mapi^[[0m/^[[32mindex.ts^[[0m\n```\n\n`^[[0m`\n\nis the ANSI reset code and `^[[32m`\n\nspecifies green. Dumping Codex's terminal output straight to a file lets ANSI escape sequences in.\n\nIn that state, running `grep \"index.ts\" handoff-file`\n\ndoesn't match, because the pattern is `index.ts`\n\nbut in the file it's split up as `index^[[0m.ts`\n\n. It's readable to the eye, but it falls apart when you try to process it with a script.\n\nAt first I tried stripping the ANSI codes in post-processing with `sed 's/\\x1b\\[[0-9;]*m//g'`\n\n. That works, but a sed pattern covering every terminal escape sequence is complex, and it breaks if Codex starts using different escapes in the future.\n\nThe fundamental fix is passing `--color never`\n\nto `codex exec`\n\n. Tell Codex not to emit ANSI codes in the first place. Controlling it at the entrance is simpler and more robust than post-processing with sed.\n\nThere was a period when I put \"when you finish the work, write to the status file\" in the prompt. The idea was, \"if Codex itself can record completion, the script doesn't need if/else.\"\n\nWhat actually happened: Codex wrote `State: completed`\n\n. But the content wasn't real.\n\n```\n# Status\n\n- State: completed\n- Updated: 2026-05-20T14:33:00Z\n- Branch: main\n- Worktree: `/path/to/project`\n```\n\nBranch says `main`\n\n. But the actual worktree was on the `feature/api-refactor`\n\nbranch. That's not `git rev-parse --abbrev-ref HEAD`\n\n— it's a string Codex guessed as \"probably main.\" The timestamp wasn't the actual completion time either; it was a plausible-looking time Codex inferred from its training data.\n\nWorse was the case where the Codex invocation failed partway through. Codex sometimes tries to write the status file \"just in case\" right before exiting with an error. The result was a state where `codex exec`\n\nexited non-zero, yet the status file said `State: completed`\n\n.\n\nThe current prompt has an explicit prohibition.\n\n```\n- Do not write handoff or status files yourself; the launcher manages those artifacts.\n```\n\nThe reason for that one line is that \"if you let Codex write it, the values become guesses instead of measurements.\" `$(git rev-parse --abbrev-ref HEAD)`\n\nis actually executed by the shell. The `main`\n\nCodex writes is guessed by the model. That difference decisively changes verification accuracy. Files that record state should be written by the shell.\n\nI once passed the task file as a relative path like `./tasks/refactor-api.md`\n\nwhen calling the script. At that point the behavior of `mkdir -p \"$(dirname \"$handoff_file\")\"`\n\nwent wrong.\n\nBecause the handoff file was also passed as a relative path like `./handoffs/refactor-api-handoff.md`\n\n, when the script changed the working directory with `cd`\n\n(the version at the time did `cd`\n\ninto the worktree), the path `dirname`\n\ncomputed ended up somewhere other than intended.\n\nThe symptom was a `Permission denied`\n\nor `No such file or directory`\n\nerror meaning \"can't write to the handoff file.\" When I debugged it and echoed the path `dirname`\n\nreturned, it was `/handoffs`\n\ninstead of `/worktree/subdir/handoffs`\n\n.\n\nThe fix is to convert to absolute paths on the caller side before passing them.\n\n```\nbash scripts/orchestrate-codex-worker.sh \\\n  \"$(realpath ./tasks/refactor-api.md)\" \\\n  \"$(realpath -m ./handoffs/refactor-api-handoff.md)\" \\\n  \"$(realpath -m ./status/refactor-api.status.md)\"\n```\n\n`realpath`\n\nreturns the absolute path of an existing file. `realpath -m`\n\ncomputes and returns an absolute path even if the file doesn't exist (`-m`\n\n= `--no-require-file`\n\n). The handoff and status files are created by the script, so they don't exist at call time. Using `realpath -m`\n\nlets you fix the absolute path of a nonexistent file in advance.\n\nInside the script, `$(dirname \"$handoff_file\")`\n\nthen always computes the dirname of an absolute path, so it's safe to call the script from any directory.\n\nBased on these failures, the verification flow I use now is this.\n\n```\n# 1. status-fileでState確認\ngrep \"State:\" /path/to/status-file\n\n# 2. 実際のgit diffと突き合わせ\ngit diff --stat HEAD\n\n# 3. handoff-fileのGit Statusセクションと比較\ngrep -A 10 \"## Git Status\" /path/to/handoff-file\n```\n\nOnly when all three agree can I confirm \"what Codex said was actually done.\"\n\nStatus file says `State: completed`\n\n→ `git diff --stat`\n\nshows changes → the handoff file's `## Git Status`\n\nlists the same files. That's the three-piece set.\n\nConversely, the patterns where it breaks down are fixed.\n\n`State: completed`\n\nbut `git diff --stat`\n\nis empty → Codex said \"completed\" without making changes`git diff --stat`\n\nshows changes but they're not in the handoff file's `## Git Status`\n\n→ there's a bug in the script's write order (I did this once)`State: failed`\n\nbut `git diff --stat`\n\nshows changes → Codex made partial changes and exited 1 (the dangerous pattern)The last pattern needs the most caution. When Codex crashes after partial changes, `git diff --stat`\n\nshows changes, but whether those changes are \"partway toward a correct change\" or \"a broken state\" isn't clear until you read the code. In that case I read the full diff with `git diff HEAD`\n\n, then decide whether to fully revert or continue. Often I shelve it with `git stash`\n\nbefore running the next Codex worker.\n\nWhen the handoff file contains only `The Codex worker exited with a non-zero status.`\n\n, the cause of Codex's error only exists in stderr. This script currently doesn't capture stderr, so the only option is to reproduce it. When I submit the same task next time, I add one line to the task file: \"the previous run exited non-zero; identify the cause of the error before starting.\" I count on the root cause of the error being written in the \"Remaining Risks\" of the four sections.\n\nSince I started using this \"leave state in files\" design, the state of \"I don't know what changed\" after delegating to Codex has nearly disappeared. `cat`\n\nthe status file, `grep`\n\nthe handoff file, look at `git diff --stat`\n\n— with those three commands I can verify any worker's completion state in under 30 seconds. Completion that's \"only said\" doesn't get engraved into the filesystem.\n\nI once handed over a task file saying \"please improve the API's error handling.\" Codex came back in the four-section format. `State: completed`\n\nwas there too. The `## Git Status`\n\nsection existed. But the Git Status field was empty.\n\nReading the handoff file's Validation section, it said \"after investigation, there are no problems with the current implementation.\" Because the instruction was vague, \"a judgment that no improvement is needed\" got processed as \"task complete.\" Doing nothing while adhering to the four-section format functions as a loophole in the design.\n\nAfter I standardized the task file into the following format, this pattern nearly vanished.\n\n```\n対象: ~/dev/myapp/src/api/client.ts\nやること: fetchUser関数のcatch節でエラーをconsole.errorに出力し、呼び出し元へrethrowする\n完了条件: catch節にconsole.error + throw eが入っていること\n検証: grep -n \"console.error\" ~/dev/myapp/src/api/client.ts && grep -n \"throw e\" ~/dev/myapp/src/api/client.ts\n```\n\nWhen the completion criteria and verification command are written as a pair, Codex actually runs that command in the Validation section and pastes the stdout. The escape hatch of \"investigated, no problems found\" is closed off.\n\norchestrate-codex-worker.sh passes the current worktree to Codex via `-C \"$(pwd)\"`\n\n. What happens if you run two workers simultaneously in the same worktree?\n\nWhile Codex A is rewriting `src/api/client.ts`\n\n, Codex B reads the same file and makes a different change. B finishes first and lands a git commit. A, unaware of the state after B's commit, stages the file it wrote out and commits on top. The result is a commit where A overwrote lines it never intended to.\n\n`git diff --stat`\n\nhad A's changes and B's changes mixed together, and I could no longer trace which worker wrote which lines. Looking at `git log --oneline`\n\n, the ordering between commits was a mess too.\n\nWhen running parallel workers, always separate worktrees with `git worktree add`\n\n.\n\n```\ngit worktree add ~/dev/myapp-worker-a feature/api-fix-a\ngit worktree add ~/dev/myapp-worker-b feature/cache-fix-b\n```\n\nIf you move into each worktree's directory before launching the script, the status file's `Worktree`\n\nfield splits into `~/dev/myapp-worker-a`\n\nand `~/dev/myapp-worker-b`\n\n, and it's obvious at a glance which worker's result you're looking at.\n\nThere were times when `codex exec`\n\nexited 0, yet there was nothing before `## Git Status`\n\nin the handoff file. Because `cat \"$output_file\"`\n\ntranscribed an empty file as is, Codex's four-section output was missing entirely.\n\nThe cause was a timeout on the Codex API side. When processing drags on and the API cuts the session, `codex exec`\n\ncan exit 0 (behavior varies by Codex version). The output_file is created but is 0 bytes.\n\nI added two countermeasures.\n\n`codex exec`\n\nfrom the outside as `timeout 600 codex exec ...`\n\n. It gets killed at 10 minutes and returns exit 124, so in a `set -euo pipefail`\n\nenvironment it enters the else block and failed is recorded. Dangling workers disappear.`wc -c \"$handoff_file\"`\n\nand, if under 200 bytes, log \"the output may have been empty.\" The judgment is made by a human after looking at the status file.As I accumulated Codex's four-section outputs, I noticed `None`\n\nlining up in the Remaining Risks section. Yet multiple cases came up where a later worker reported \"there was a bug in the code the previous worker implemented.\" Adhering to the format while skewing the content positive — that's a common Codex tendency.\n\nAfter adding the following line to the task file, concrete risks started appearing.\n\n```\n- Remaining Risksには必ず1件以上の懸念事項を書くこと。「None」は禁止。\n```\n\nYou start getting things like \"the type definitions are provisional,\" \"test coverage is low,\" \"behavior against production data is unverified.\" By keeping Remaining Risks at one or more items at all times, you close off one form of false completion — completion by concealing problems.\n\nDuring a period when I had a folder with a Japanese name directly under my macOS home directory, I passed a handoff file path containing that path. `\"$(dirname \"$handoff_file\")\"`\n\nis wrapped in double quotes so it's safe against spaces, but because the calling script omitted quotes, `dirname`\n\nreceived a path split at the space.\n\nThe symptom is `No such file or directory`\n\nfrom `mkdir -p`\n\n. The handoff file's directory can't be created, so the task fails before it even starts.\n\nI solved it with both thorough quoting on the caller side and realpath conversion. When passing paths containing spaces to a shell script, always wrap them in double quotes, including on the right-hand side of variable assignments.\n\nThe handoff file on failure has only the single line `The Codex worker exited with a non-zero status.`\n\nCodex's error messages go to stderr, but this script doesn't redirect stderr, so they vanish the moment they hit the terminal.\n\nThe only option is to reproduce and identify the cause. I append \"the previous run exited non-zero; identify the cause of the error before starting\" to the next task file and hand it to Codex. I resubmit hoping the root cause of the error gets written in the \"Remaining Risks\" of the four sections.\n\nFundamentally fixing this requires a change that routes stderr to a separate file with `codex exec ... 2>\"$error_file\"`\n\nand runs `cat \"$error_file\"`\n\ninto the handoff file on failure. It needs to be understood as an unaddressed area of the current script.\n\nConvert the task file with `realpath`\n\n(errors if the file doesn't exist) and the handoff/status files with `realpath -m`\n\n(computes an absolute path even if they don't exist). Fixing relative paths into absolute paths at call time means the `dirname`\n\ninside the script always computes the correct path, no matter which directory it's called from.\n\n```\nTASK=\"$(realpath ./tasks/my-task.md)\"\nHANDOFF=\"$(realpath -m ./handoffs/my-task-handoff.md)\"\nSTATUS=\"$(realpath -m ./status/my-task.status.md)\"\nbash scripts/orchestrate-codex-worker.sh \"$TASK\" \"$HANDOFF\" \"$STATUS\"\n```\n\nA task file that ends with \"please improve X\" leaves the interpretation to Codex. Write the completion criteria and an executable verification command as a pair, and Codex will actually run that command in the validation section and paste the result. The escape hatch of \"investigated, no problems\" gets structurally closed off.\n\n`codex exec`\n\nhas no CLI-level timeout argument. Putting `timeout 600 codex exec ...`\n\naround it kills it at 10 minutes and returns exit 124. In a `set -euo pipefail`\n\nenvironment it's recorded as failed. It's the minimum defense against creating workers that hang for a long time.\n\nDon't run multiple workers in the same worktree. Separate worktrees with `git worktree add <path> <branch>`\n\nand launch the script from each worktree. Because the status file's Worktree field is recorded per worktree, you can trace afterward \"which worker wrote what to which branch.\"\n\nWhen the state is `State: failed`\n\nand `git diff --stat`\n\nshows changes, Codex made partial changes and failed. Submitting the next worker as is means overwriting on top of the previous half-broken changes. Shelve it with `git stash -u`\n\n, then append to the task file \"the previous run exited non-zero. The previous changes have been shelved. Start by identifying the cause,\" and resubmit.\n\nPut `Remaining Risksには必ず1件以上の懸念事項を書くこと。Noneは禁止。`\n\nin the task file. A handoff file with consecutive Nones is a sign that Codex is omitting risk descriptions. With concerns continually written down, later workers and reviewers inherit the points that deserve attention.\n\nNaming like `handoff-20260824-api-fix.md`\n\nlets you trace multiple worker results chronologically. You can check the latest completion with `ls -lt handoffs/`\n\n, and combining it with `grep -rl \"State: failed\" status/`\n\ngets you a list of failed tasks. Just making filenames meaningful makes post-processing scripts far easier to write.\n\n```\nwhile true; do\n  if grep -q \"State: completed\\|State: failed\" \"$STATUS_FILE\"; then\n    state=$(grep \"State:\" \"$STATUS_FILE\" | awk '{print $2}')\n    curl -s -X POST \"$SLACK_WEBHOOK\" \\\n      -H \"Content-Type: application/json\" \\\n      -d \"{\\\"text\\\": \\\"Worker ${state}: $(basename $STATUS_FILE)\\\"}\"\n    break\n  fi\n  sleep 15\ndone\n```\n\nRun Codex workers on a late-night cron and receive completion or failure over Slack. When you wake up, just reading the handoff file tells you the results of the overnight batch. With this setup I've actually received \"3 tasks completed while I slept, 1 failed\" two to three times a week.\n\nThis separation is condensed into the prompt's single line, `Do not write handoff or status files yourself; the launcher manages those artifacts.`\n\nBreak the boundary and Codex's guesses (\"probably the main branch\") get mixed with the shell's measurements (the result of `git rev-parse --abbrev-ref HEAD`\n\n), and cross-check verification falls apart.\n\nThe `cat > \"$prompt_file\" <<EOF ... EOF`\n\ninside the script is embedded in the script body. Every time you change a prompt rule, a diff shows up in `git diff scripts/orchestrate-codex-worker.sh`\n\n. That's actually an advantage: `git log scripts/orchestrate-codex-worker.sh`\n\nlets you trace the prompt's change history. Write \"why I added the rule banning None in Remaining Risks\" in the commit message, and months later when you read the script, the intent is clear.\n\nThe current script doesn't capture stderr. The cause of a failure vanishes the instant it hits the terminal. It's solved just by routing stderr to a separate file with `codex exec ... 2>\"$error_file\"`\n\nand adding a line that runs `cat \"$error_file\"`\n\ninto the handoff file on failure. Making this change after the script has stabilized dramatically shortens failure-investigation time.\n\nIf you repeat the same type of work (e.g. \"add validation to a new API endpoint\"), templatize the task file. Create `tasks/templates/add-validation.md`\n\nand swap in just the target filename with `sed`\n\nbefore the call. Because the instructions to Codex stay consistent, the handoff file's structure stays stable too, and `grep`\n\nand post-processing scripts are less likely to break.\n\nThe reasons delegation to Codex falls apart are almost always fixed. You believe the output \"completed\" and skip the cross-check that follows — that's it. What orchestrate-codex-worker.sh does in 108 lines is a design that structurally refuses to allow that omission.\n\nDon't hide errors, with `set -euo pipefail`\n\n. Separate the three roles of task/handoff/status files to divide the concerns. Branch name, worktree path, git status — values that need to be measured are written by the shell, not by Codex. Force four sections to demand a format from Codex's output. Prevent ANSI code contamination at the entrance with `--color never`\n\n. Reclaim temp files no matter which path exits, with `trap cleanup EXIT`\n\n.\n\nEach of these design decisions is unremarkable on its own. Combined, they form a loop where you can confirm \"whether what Codex said was actually done\" in 30 seconds.\n\n*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.\n\nFollow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*", "url": "https://wpnews.pro/news/make-codex-prove-it-a-three-file-design-that-leaves-evidence-on-disk", "canonical_source": "https://dev.to/bokuwalily/make-codex-prove-it-a-three-file-design-that-leaves-evidence-on-disk-1pln", "published_at": "2026-08-30 00:00:46+00:00", "updated_at": "2026-08-30 00:19:25.842069+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools"], "entities": ["Codex", "Claude Code", "gpt-5.4"], "alternates": {"html": "https://wpnews.pro/news/make-codex-prove-it-a-three-file-design-that-leaves-evidence-on-disk", "markdown": "https://wpnews.pro/news/make-codex-prove-it-a-three-file-design-that-leaves-evidence-on-disk.md", "text": "https://wpnews.pro/news/make-codex-prove-it-a-three-file-design-that-leaves-evidence-on-disk.txt", "jsonld": "https://wpnews.pro/news/make-codex-prove-it-a-three-file-design-that-leaves-evidence-on-disk.jsonld"}}