背景
以前, Codexで作業をした際に, 進捗や試行錯誤, 実装の背景などを保持するために, memo.md を自動で更新するhookを作成した.
sota411.mehttps://sota411.me/articles/codex-memo-hooks.html
アイデア自体はよかったものの, しばらく使い続けると別の課題が積み上がってきた. 今回はその問題を潰すために, 仕組みを刷新した話である.
運用して出てきた3つの課題
- 私自身がプロンプトを送信するたびに,
UserPromptSubmitでテンプレを注入し, それをCodex自身が埋めるという仕組みだったため, main agentのコンテクストが圧迫されてしまっていた. - 実装についての簡単な質問や, 確認をしたいだけのシーンでも, hookが発火してしまい, 余計な要約が増えてしまっていた.
- 出力の最後に
memo.mdへの書き込みが起きる仕組みであったため, 送信したプロンプトへの回答を出力してほしいのに, 「memo.mdを更新した」ことが最終的な出力になってしまうことがあった.
誰が書くかを変える
浮かび上がってきた課題に共通している原因として, 作業しているagent自身に, 同じターンの中でメモを書かせていたことだと考えた.
そこで方針を変え, メインのagentはmemo.mdを一切触らず, Stop hookがバックグラウンドで別プロセスを起動し, 起動したagentで memo.md を書くという仕組みに変えた.
main agentからすると, memo.md は一切触れることがなくなり, コンテクストも圧迫しなければ, 最終的な回答が「memo.mdを更新した」ことにすり替わることもなくなる.
Stop hookでターンを分類する
StopはまずセッションのJSONLから, 今回のturnに対応する行を切り出す. task_startedからtask_completeかturn_abortedまでがそのturnの範囲になる.
pythondef extract_turn_slice(session_path: Path, turn_id: str) -> list[dict]:
records, in_turn = [], False
for line in session_path.open(encoding="utf-8"):
record = json.loads(line)
payload = record.get("payload")
if record["type"] == "event_msg" and isinstance(payload, dict):
event = payload.get("type")
if event == "task_started" and payload.get("turn_id") == turn_id:
in_turn, records = True, [record]
continue
if in_turn and event in {"task_complete", "turn_aborted"}:
records.append(record)
in_turn = False
continue
if in_turn:
records.append(record)
return records
このスライスを見て, ファイルを変えたターンかどうかを判定する. apply_patchやパッチ成功イベントがあれば即座に「変更あり」. コマンド実行はcatやrgのような読み取り専用コマンドの一覧と照合し, それ以外は変更ありとみなす.
pythonREAD_ONLY_COMMANDS = {"rg", "grep", "cat", "ls", "head", "tail", "wc", "find", "jq", "stat", "diff", ...}
ここで迷ったのは, 一覧にないコマンドをどちらに倒すかである. 記録漏れの方が, 余計な要約1回より困る. なので未知のコマンドは全部「変更あり」に倒した. 分類ミスが疑わしければ, あとでリストに足せばいい話にした.
質問だけで終わったターンはここでスキップされ, memo.mdには何も起きない.
バックグラウンドの要約プロセス
変更ありと判定されたターンだけ, subprocess.Popenで別プロセスを起動して即座にStopは終わる.
pythonsubprocess.Popen(
[sys.executable, __file__, "summarize", str(job_path)],
stdin=subprocess.DEVNULL,
stdout=log_file,
stderr=subprocess.STDOUT,
start_new_session=True,
)
起動された子プロセスは, ターンの抜粋を読み取り専用のcodex execに渡して要約させる.
bashcodex exec - -C "$cwd" -s read-only --ephemeral --skip-git-repo-check \
-c features.hooks=false -m gpt-5.5 -c 'model_reasoning_effort="low"' \
--color never -o out.md
features.hooks=falseを付けたのは, この子プロセス自身が自分のStop hookを再度呼んで無限に増殖するのを防ぐためである. --ephemeralでセッションファイルも残さないようにした.
#nomemoと#memoで例外を作る
自動判定だけに頼ると, 「記録したいこと」と「記録したくないこと」を区別するのが難しい.
そこで, ユーザープロンプトに#nomemoがあれば, ファイルを変えたターンでも記録をスキップする. #memoがあれば, 読み取りだけのターンでも強制的に記録する. 両方書かれていたら#nomemoを優先させた.
実際に動かして確認したこと
テスト用のフォルダで, DEFAULT_TIMEOUTを30から60に変えるという想定の抜粋をsummarizeに直接渡してみた.
markdown## 2026-07-08 06:45 - Codex turn e2e-turn
<!-- codex-memo:done session=e2e-session turn=e2e-turn -->
- 要件・完了条件: `config.py` の `DEFAULT_TIMEOUT` を `30` から `60` に変更し, 関連テストも修正すること.
- 調査ログ: `rg -n "DEFAULT_TIMEOUT" src tests` を実行し, `src/config.py:12` と `tests/test_config.py:8` の参照を確認した.
- 判断までの経緯: 検索結果で定数定義と期待値テストの2箇所だけが示されたため, 両方を同じ `60` に更新する方針を選んだ.
- 実装・変更内容: `src/config.py` の `DEFAULT_TIMEOUT = 30` を `60` に変更し, `tests/test_config.py` の期待値も `60` に変更した.
- 確認結果: `python3 -m pytest tests/test_config.py -q` を実行し, `1 passed in 0.02s` を確認した.
- 残課題・次回引き継ぎ: 抜粋内では追加の失敗テストや未対応ファイルは示されておらず, 指定範囲の変更と確認は完了している.
<!-- /codex-memo:done -->
セッションファイルが増えていないこと(--ephemeralが効いていること), 同じturnで2回Stopが呼ばれても要約プロセスが2度起動しないこと, pre-commit検証が新旧どちらのブロックにも通ることも, ユニットテスト23本と合わせて確認した.
やってみて分かったこと
前回の設計は「main agentに書かせて, 書いていなければturnを終了させない」だったが, 今回は「main agentに書かせずに後から別プロセスに書かせる」という設計に移行した.
残っている課題として, バックグラウンドでのmemo.mdの更新が失敗しても, main agentや私には通知が来ないため, ログを見に行かない限り, 記録漏れに気づけないという問題がある. そういった観点では, 前回の設計にも良さはあったと感じている. 今回はログを~/.cache/codex-memo-guard/logs/に残す形で妥協したが, ここは正直, もっとベストプラクティスがありそうだと感じている.