自作ルール1本をOpus 5のときだけ注入したら、いい感じになった
by yasuna
8 min read
この記事はAIエージェントと一緒に執筆しています
はじめに
端的に言うと、AI エージェント向けの自作ルールを 1 本だけ書いて、Opus 5 のときだけ注入するようにしたら、かなり具合が良くなった話です。
作ったものは、.claude/rules/ に置いたルール 1 ファイルと、それを注入するシェルスクリプト 1 本だけです。UserPromptSubmit フックから毎回スクリプトが走り、いま応答しているモデルが Opus 5 系のときだけルールを標準出力に吐きます。標準出力はそのままセッションのコンテキストに入るので、ルールが載る。Opus 5 以外のときは何も出力しないので、何も起きません。ルールの中身は、自分のセッションログを調べて起こした 6 パターンです。
エージェントにルールを足すのは簡単で、失敗するたびに一行足せばいい。でもそうやって育てた設定って、だいたい膨らむだけ膨らんで、どれが効いているのか分からなくなります。今回は逆で、ルールは 1 本だけにしました。そのぶん、その 1 本は実測から書いています。
きっかけ:どの層に置くかで効き方が変わる
きっかけはこの記事でした。Yuichi Uemura さんの「Opus5が思考が浅いように感じる問題への対策」です。
https://zenn.dev/u1/articles/claude5-rules-collapse-and-fix
同じ rules のまま Opus 5 に切り替えたら、区分のないフラットな長文になる、原因の説明が 1 層で止まる、複数案に評価軸が付かない、といった症状が出た。その原因を rules 側ではなく本体 system prompt 側の変化に求めているのがこの記事の芯です。Claude 5 世代では本体 system prompt が大きく削減されていて、応答の書き方を規定する文が無くなっている。だから「書き方が濃く規定されていた旧 prompt」を前提に書かれた rules が、空白を埋める形になっていない、と。
自分がいちばん持って帰ったのは、指示をどの層で届けるかという話でした。記事では 4 経路(本体 system prompt / output style / UserPromptSubmit hook / 常時 rules)の効き方が実測で比較されていて、こうまとめられています。
「変えたい行動の直前に、短く、毎回届ける」指示がいちばん効く
実際、記事の著者は「返答せず作業に入る」癖が output style 単独では直らず、毎発話注入を入れて止まったと書いています。コストは約 50 token/発話。
この「フックで毎発話届ける」という仕掛けを、そのまま借りることにしました。中身は自分で用意する、という方針で。
中身は自分のログから起こす
借りたのは器のほうで、載せるルールは自分で書きました。理由は単純で、自分の環境で何が起きているのかは、自分のログにしか書いていないからです。
手元のセッションログ(個人開発 3 リポジトリ、約 800MB)を実際に調べました。「こういうズレがあるはず」と先に決めず、訂正が入っている箇所を数えるやり方です。結果、繰り返し出てきたのは次の 6 パターンでした。
- 位置づけラベルのズレ … 章とコラムを取り違えたまま実装完了まで走る
- 区別の平坦化 … 採用案と没案、キャラクターと演者を一語にまとめてしまう
- 定番パターンへの吸着 … 指定した描写を「よくある表現」に置き換える
- 古い仕様の用語で現状を語る … 変更前の仕様のまま説明を続ける
- 主体・対象の勝手な推定 … 「別のエージェントが編集中」と断定(実際は自分)
- 要約で構成と根拠が消える … 「4 分冊」の前提が圧縮後に「1 冊」になる
自分がいちばん痛かったのは 3 番で、ギャグ描写を定番の「鼻血」に置き換えられたことがあります。実際に指定したのは「牛乳をブーッとふく」でした。学習データ上の最頻パターンって、その場の正解とは限らないんですよね。
この 6 つを 1 ファイルにまとめたものが、注入するルールの全部です。「こうあるべき」から書いた規範はひとつも入れていません。全部、実際に訂正が入った箇所から起こしています。
ちなみに元記事のチェックリストには「rule 文だけで意味が閉じているか」「発火条件が観測できる事実か」「守られたかどうかを後から判定できるか」といった項目があって、これは書きながら何度も見返しました。とくに最後の 1 つは、後述する確かめ方に直結しています。
Opus 5 のときだけ注入したい
ルールをコンテキストに載せるとトークンを食います。全モデルで常時載せるほどの確信はなかったので、特定のモデルのときだけ注入することにしました。
ところが Claude Code には、モデル別に設定を出し分ける公式機能がありません。そこでフック側でゲートすることにしました。
最初の実装は一度も発火していなかった
最初は SessionStart フックの stdin に入っている model フィールドで判定するつもりでした。設計としては素直です。
でも実測したら、ペイロードに model が存在しませんでした。
{"session_id":"...","transcript_path":"...","cwd":"...","hook_event_name":"SessionStart","source":"startup"}
判定は常に空振りで、ゲートは一度も成立していませんでした。動かして確かめるまで気づけないやつです。ドキュメントを読んで「あるはず」と思い込んでいた側の問題ですね。
そこで UserPromptSubmit に変えました。元記事が推している経路がそもそもこれなので、結果的に本来の形に戻ったことになります。こちらは transcript_path をくれるので、実際に応答しているモデル ID をトランスクリプトから読めます。しかも毎プロンプト発火するので、セッション途中の /model 切り替えにも追従できる。
登録はこれだけ。
{
"hooks": {
"UserPromptSubmit": [
{ "hooks": [ { "type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/inject-semantic-rules.sh\"" } ] }
]
}
}
モデル判定は確度の高い順に 4 段構えにしました。環境変数 ANTHROPIC_MODEL → トランスクリプトの最後の assistant メッセージが名乗るモデル ID → 起動コマンドの --model(/proc の祖先プロセスから取得)→ settings の既定モデル。特定できないときは安全側に倒して注入しません。
opus[1m] という表記ゆれ
ここで踏んだのが表記の問題です。
公式ドキュメントに出てくる API のモデル ID は claude-opus-5 です。でも Claude Code 側で 1M コンテキストを選んでいると、settings に保存される既定モデルは opus[1m] という表記になります。角括弧付きです。
claude-opus-5* だけを見ていると、ここで素通りします。
case "$model" in
claude-opus-5*|opus|opus\[*) is_opus5=1 ;;
esac
ドキュメントのモデル ID と、ツールが名乗るモデル ID は別物。当たり前なんですが、実際に両方で試すまで気づきませんでした。
元記事の毎発話注入は約 50 token でしたが、こちらは 6 パターン + 実例なのでもっと重いです。なので注入は (session_id, model) ごとに 1 回だけにしました。毎プロンプト再注入はせず、モデルを切り替えたときだけ再注入されます。状態ファイルは $TMPDIR/claude-semgen-guard/ に置きました。これが後で効いてきます。
効いているかを痕跡で確かめる
入れたはいいものの、本当に効いているのかは別の問題です。
モデルに「ルールに従いましたか」と聞いても意味がありません。それらしい答えが返ってくるだけなので。なので痕跡を見ました。
まず、注入されたかどうか。これは状態ファイルで分かります。
$ ls "${TMPDIR:-/tmp}/claude-semgen-guard/"
<セッションIDのファイルが1つ>
$ cat "${TMPDIR:-/tmp}/claude-semgen-guard/<セッションID>"
claude-opus-5
セッション ID とモデルが記録されているので、このセッションに注入が走ったことは確定します。ゲート自体は、モデルを変えて叩けば確かめられます。claude-opus-5 と opus[1m] では注入され、claude-sonnet-5 では無出力。設計どおりでした。
次に、行動に出たかどうか。ちょうど注入済みの状態でブログ記事を 1 本書かせていたので、その作業を条ごとに突き合わせました。
| 実際にやったこと | 対応する条 |
|---|---|
| 着手前に「これは下書きとして作ります」と 1 行宣言した | §1 位置づけラベルを勝手に決めない |
公式ドキュメントの記述と自分の感想を --- で分離した |
§2 区別を平坦化しない |
| 「MR は PR と受け取って進めます」と推定を明記した | §5 推定は推定と明記 |
| 数値・パラメータ名を原文の表記のまま引用した | §6 引用・構成を落とさない |
とくに §1 は、ルールの文面が「これは○○として作ります」と 1 行で宣言せよ、なので、ほぼそのまま出ています。以前は長い成果物を作りきってから位置づけを訂正する、というのを何度もやっていたので、ここが変わったのがいちばん体感に効きました。確認 1 行のコストは、書き直しのコストより常に安いんですよね。
元記事のチェックリストにある「守られたかどうかを後から判定できるか」は、書くときより後で棚卸しするときに効いてきます。「よく考えろ」「丁寧に扱え」みたいな内面的な指示で書いてしまうと、効いたかどうかを外から見る方法がありません。守ったときに外から見える痕跡が残る形にしておくと、あとで確かめられます。
まとめ
- 器は借りて、中身は自分で書く。UserPromptSubmit で毎発話届けるのが効くという知見は元記事のもの、載せるルールは自分のログから
- ルールは実際に起きた訂正から書く。思いついた規範を並べても発火しない
- 載せるのは1 本だけ。そのぶん実例を具体的に書く。実例のない禁則は守られない
- 効いているかはモデルに聞かない。痕跡で見る。状態ファイルで注入の有無、行動と条の突き合わせで遵守の有無
- ドキュメントの
claude-opus-5と、ツールが名乗るopus[1m]は別物。ゲートを書くなら両方見る
1 本でここまで変わるとは思っていませんでした。設定を膨らませる前に、まず自分のログを見に行くのがいちばん早いかもしれません。
きっかけをくれた記事に感謝します。