エージェントに頼むな、フックで強制しろ — oh-my-openagentのコードを読んで自分のハーネスに持ち帰ったもの
失敗ひとつにフックひとつを付けて57個まで積んだハーネスと、フック同士がぶつかって結局入口をひとつにまとめた話
先に結論。エージェントが壊れる場所が見えたら、プロンプトに一行足すのではなく、その場所にフック(決まった瞬間ごとに走るコード)を掛ける。この記事を読めば、OpenCodeプラグインのoh-my-openagent(OmO)が実際にどんなフックを掛けているかがわかり、同じ考え方をClaude Codeでどう使うかを例で確かめられる。フックが増えすぎたときに起きる問題も書いた。
OmOは自分を「バッテリー同梱のエージェントハーネス」と紹介している。ハーネスはモデルを包んで、ツール・ルール・リトライを付ける外側の殻だ。言葉はいいが、コードで何をしているのか気になってリポジトリを落として読んだ。基準は2026-11-05のdevブランチ(コミット48ed2357d、バージョン5.1.60)。パッケージ名はまだoh-my-opencodeのままだ。
まず規模 — 1か月でリリース44回
git ls-filesとwc -lで自分で数えた。
workspaces(プラットフォーム別バイナリ含む) 68
TypeScript(テスト除く) 約49万行
Rust 約8.6万行
テストファイル 4,139
設定スキーマに名前があるフック 57
リリースタグ v5.1.17 (10-05) → v5.1.60 (11-04)、44回
OpenCodeをフォークしない。OpenCodeが開けているフック地点(設定読み込み・メッセージ受信・ツール実行の前後・セッションイベント・圧縮)に割り込んで動きを変える。だからOpenCodeが上がっても追いかけやすい。代わりに、フック地点がない場所には手が出せない。
失敗ひとつにフックひとつ
フックの一覧を読んでいくと、名前がそのまま「エージェントがこう壊れた」という記録になっている。いくつか選んだ。
| 壊れ方 | フック | やること |
|---|---|---|
| 古い行の上に編集 | hashline-read-enhancer + hashline-core |
Readの結果の行ごとにハッシュを付け、違えば編集を拒否 |
| TODOを残して止まる | todo-continuation-enforcer |
アイドルになった瞬間にTODOが残っていれば「続けろ」を入れる |
| プランナーがコードを直す | prometheus-md-only |
計画担当のエージェントは.mdだけ編集できる |
| 既存ファイルを丸ごと上書き | write-existing-file-guard |
Writeでの既存ファイル上書きを止める |
| フォルダのルールを知らない | directory-agents-injector・rules-injector |
そのパスのAGENTS.md・ルールを自動で入れる |
| コンテキストがあふれる | preemptive-compaction・compaction-todo-preserver |
上限の前に圧縮し、TODOは残す |
Hashline — 行番号に2文字のハッシュを付ける
エージェントはファイルを読み、何ターンか後にその記憶で編集する。その間にファイルが変わっていたら、違う行を直してしまう。OmOはReadの結果の行ごとに行番号#ハッシュ|を付ける。ハッシュは行の中身のxxHash32を256で割った余りを、ZPMQVRWSNKTXJBYHの16文字から2文字に置き換えたものだ。リポジトリの実装をbunで実際に動かした。
1#KW|def total(items)
2#KQ| items.sum(&:price)
3#PT|end
# 2行目だけ変えると
1#KW|def total(items)
2#YS| items.sum(&:amount)
3#PT|end
変わった行だけハッシュが変わる。エージェントが2#KQを直そうとすると、今のファイルの2行目はYSなのでHashlineMismatchErrorで拒否され、エラーに新しいハッシュが載って読み直すことになる。2文字だと256通りしかないので衝突はする。だから必ず行番号とペアで使う。
Claude CodeのEditは、old_stringがファイルと一字一句合ったときだけ直す。行番号ではなく原文で位置を決めているので、同じ問題を別のやり方で防いでいることになる。行番号で編集するツールを自作するなら、Hashlineのほうが参考になる。
TODO継続の強制 — 「止めない」より「止めるべきときを知る」コード
todo-continuation-enforcerは、セッションがアイドルになった瞬間にTODOが残っていれば、2秒のカウントダウンの後に「Incomplete tasks remain in your todo list」で始まるメッセージを入れる。終わったと言い張ると「システムは君の完了の主張を疑っている、確認し直せ」という一文まで入っている。
面白いのは、押すコードより押さないコードのほうがずっと多いことだ。アイドル処理のファイルにはreturnで抜ける箇所が25か所ある。ユーザーが中断した直後(3秒)・バックグラウンド作業が動いている・ユーザーに質問を投げている・トークン上限に当たった・圧縮した直後・5秒以内にもう押した・5回連続で失敗した・同じ状態で3回止まった、なら押さない。最初は「続けろと入れたら終わり」だと思っていたが、実際は無限ループとユーザー無視を防ぐ側が本体だった。
どんでん返し — フックが増えるとフック同士がぶつかる
2026-05に上がったissue #4012 は「応答の吹き出しが2つ、同時に同じ内容を流す」だった。原因はフックひとつのミスではない。OpenCodeのsession.promptAsyncは、メッセージが確実に受け付けられる前に戻ってくる。だから同じアイドルを見たフック2つ(たとえばTODO継続の強制とバックグラウンド作業の完了通知)が、それぞれ「今入れていい」と判断して両方入れた。
直し方がこの記事でいちばん持ち帰る価値がある。リポジトリの設計記録(ADR)によると、メッセージを入れる場所が13か所を超えていて、それぞれが気をつけるだけでは防げないと結論した。そこでsession.prompt・promptAsyncを呼ぶコードはprompt-async-gate.tsの1ファイルだけに置き、残りは全部dispatchInternalPrompt()を通すようにした。セッションIDごとに予約を取り、先に取ったほうだけが送る。このルールを破ると、ソースファイルを読んで検査するテスト(prompt-async-route-audit.test.ts)が落ちる。
フックで強制するのも結局ルールで、ルールが増えるとルールを強制する仕組みがまた要る。エージェントに頼まないのと同じように、フックを書く人にも「気をつけてください」ではなくテストで止めた。
ほかに読んでいて引っかかったもの:エージェントの並び順を守るために、グローバルのArray.prototype.sort・toSortedを差し替えている(agent-sort-shim.ts)。自分で「1ファイル200行」を推奨しているのに超えるファイルが629個、バックグラウンド作業の管理manager.tsは3,416行ある。匿名テレメトリはドキュメント上デフォルトでオンで、DO_NOT_TRACK=1で切る(インストールして自分では確かめていない)。
Claude Codeで真似する — Stopフックひとつ
OmOを丸ごと入れなくても、考え方は持ち帰れる。Claude CodeのStopフックは、エージェントが応答を終えようとした瞬間に走る。JSONでdecision: "block"と理由を出すと、止まらずにその理由を受け取って作業を続ける。
#!/usr/bin/env bash
# .claude/hooks/todo-gate.sh — TODO.md に [ ] が残っていればもう一度押す
input=$(cat)
# すでにこのフックで続けている最中なら通す(無限ループ防止)
[ "$(printf '%s' "$input" | jq -r .stop_hook_active)" = true ] && exit 0
left=$(grep -c '^- \[ \]' TODO.md 2>/dev/null || true)
if [ "${left:-0}" -gt 0 ]; then
jq -n --arg n "$left" '{decision:"block", reason:("TODO.md の未完了: " + $n + "件。次の項目を続けろ")}'
fi
.claude/settings.jsonのhooks.Stopにこのスクリプトをcommandとして登録すればいい。入力を真似て動かすと、残りが2件のときはblockが出て、stop_hook_activeがtrueか全部終わっていれば何も出さない。公式ドキュメントにも、stop_hook_activeを見て解決しない条件で止め続けるなと書いてあり、連続の継続は8回で切れる。
このスクリプトは一回しか押さない。OmOのように「同じ状態で3回なら諦める」をやるなら、残り件数をファイルに書いておいて減ったかどうか比べればいい。どこまで押すかが、このフックの設計のすべてだ。
このブログも実は同じ道をたどった。記事をコントローラーに入れるとき__END__の後に入れるとWebに出ないのだが、ルール文書に「注意」を何度書いてもたまに間違えた。今はEdit・Writeの後に走るPostToolUseフックが文法と位置を検査して、エージェントにすぐ返す。それ以来、このミスを人がレビューで拾うことはなくなった。
いつプロンプトで、いつフックで
| プロンプトに書く | フックで強制する | |
|---|---|---|
| 合うルール | 好み・文体・判断が要るもの | 機械で真偽を測れるもの(ファイルの状態・パス・残りのTODO) |
| 破られたら | 人がレビューで拾う | その場で拒否され、理由が返る |
| コスト | 一行 | スクリプト + 無限ループ・誤検知の処理 |
| 増えると | プロンプトが長くなって守られにくい | フック同士がぶつかる → 入口をひとつにまとめる |
ハーネスエンジニアリングとプロンプトエンジニアリングの違いを概念で整理した記事もある。この記事は、その違いを実際のコードで見た側だ。
OmOはそのまま入れるより、教科書として読むのがいい。フック名の一覧を眺めるだけでも、エージェントがどこで壊れるかの地図になる。社内のエージェント運用で同じミスが繰り返されていて、どこをフックで止めるか決めかねているなら、negabaroに聞いてもらってかまわない。今使っているルール文書を一緒に見て、コードに移す行から選ぶ。
キーコンセプト
機械で真偽を測れるルール(ファイルの状態・パス・残りのTODO)から、プロンプトをフックに移す
押し続けるフックは、押さない条件(中断直後・質問待ち・同じ状態の繰り返し)を先に書く
Claude CodeのStopフックは、stop_hook_activeを見て解決しない条件で止め続けない
メッセージを入れるフックが複数あるなら入口を一か所にまとめ、迂回はテストで止める