Sota411 ← Back
← Back to Articles

2026-07-08

Codexのmemo.md強制をblockからバックグラウンド要約に作り直した

背景

以前, Codexで作業をした際に, 進捗や試行錯誤, 実装の背景などを保持するために, memo.md を自動で更新するhookを作成した.

sota411.mehttps://sota411.me/articles/codex-memo-hooks.html

アイデア自体はよかったものの, しばらく使い続けると別の課題が積み上がってきた. 今回はその問題を潰すために, 仕組みを刷新した話である.

運用して出てきた3つの課題

  1. 私自身がプロンプトを送信するたびに, UserPromptSubmitでテンプレを注入し, それをCodex自身が埋めるという仕組みだったため, main agentのコンテクストが圧迫されてしまっていた.
  2. 実装についての簡単な質問や, 確認をしたいだけのシーンでも, hookが発火してしまい, 余計な要約が増えてしまっていた.
  3. 出力の最後に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_completeturn_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やパッチ成功イベントがあれば即座に「変更あり」. コマンド実行はcatrgのような読み取り専用コマンドの一覧と照合し, それ以外は変更ありとみなす.

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/に残す形で妥協したが, ここは正直, もっとベストプラクティスがありそうだと感じている.

← トップページに戻る