この記事は、連載「読まない技術」の第 6 回です。うまくいかない症状と、その対処を一つずつ並べています。各回はファイルやスクリプトを一つ置けば完結します。その仕組みがなぜ要るのかは、置いたあとに解説を読めば分かります。連載の全体像と各回の一覧は序論にあります。

今回は、指示ファイルに書くルールの話です。守ってほしいことを指示ファイルに書くのをやめて、代わりに、ルールが破られたとき、AIに読まれなくても落ちる自動テストを置きます。

まず一つだけ、確かめてみてください。

あなたの指示ファイルに書いてある禁止事項を、1 つ選んでください。

開発のときに、AIに禁じると決めたことを書いた行です。その禁止事項が先月AIに何回破られたか、答えることができますか。

AIが禁止事項を破っても、作業はそのまま先へ進むので、答えることができないのが普通です。ルールが働くのは、AIに「読まれる」「覚えられている」「従われる」の三つが、全部そろったときだけです。指示ファイルに書いた行では、この三つが一つずつ欠けていきます。

一方で、プロジェクトには、誰も読んでいないのに毎回守られているルールが、すでに大量にあります。lint、型チェック、CIがそれです。これらのルールが守られるのは、破るとlintやCIが落ちるからです。

あなたが指示ファイルに書いた禁止事項を、AIが破っていました。破ったあとも、作業はそのまま先へ進んでいました。この症状に、あなたなら次のどちらで対処しますか。

  • その禁止事項を、指示ファイルにもう一度書き足す
  • その禁止事項が破られたら落ちる自動テストを書く

答えは、自動テストを書くほうです。AIが禁止事項を破っても作業が先へ進んだのは、指示ファイルの行が、AIに読まれ、覚えられ、従われたときだけ働く文脈で、破られても何も止めないからです。書き足した行も、同じ文脈の 1 行として届きます。しかも、書き足すたびに指示の数が増え、指示は増えるほど守られにくくなります。自動テストは、AIがその行を読んだかとは別に、コードが禁止事項を破っていれば落ち、その変更を先へ進ませません。

理解したことは一文にできます。

ルールは、AIが読んだときだけ働く。自動テストやhookは、ルールが破られたとき、AIに読まれなくても落ちる。

CC BY 4.0

なぜ、ルールはAIが読んだときだけ働くのか

指示ファイルとメモリの中身は、Claude Code の公式ドキュメントのうち、指示ファイルとメモリのページによれば、システムプロンプトの後に、ユーザーメッセージとして届くものです。同じページには、その中身は文脈として扱われ、強制される設定としては扱われないとあり、AIの振る舞いを止めたいならhookを使え、とまで書いてあります。hookのガイドにあるのは、「LLM がそれを実行すると選ぶことに頼らず、その動作が必ず起きる」という説明です。

flowchart TB
  Y["あなたが書いた 1 行"] --> Q{"どこに置いたか"}
  Q -- "指示ファイル / メモリ" --> C["セッションの開始時に読み込まれ、<br/>その後のユーザーメッセージとして届く<br/>= 文脈であって、強制ではない"]
  Q -- "セッションの途中で直接打つ" --> U["コンテキストの末尾に、<br/>同じユーザーメッセージとして届く<br/>= こちらも、強制ではない"]
  Q -- "自動テスト / hook" --> H["決まった時点で必ず走る<br/>= AI が何を決めようと適用される"]
  C --> R1["読まれたときだけ働く"]
  U --> R1
  H --> R2["読まれなくても落ちる"]

三つのうち、「覚えられている」について、Claude Code の公式ドキュメントはセッションは独立していて、新しいセッションは前のセッションの会話履歴を持たない空のコンテキストから始まると書いています。次のセッションへ渡るのは、ディスクにあるものだけです。先週指示ファイルに足した行が今日も働いているように見えるのは、その行が、今日のセッションの開始時にもう一度読み込まれたからです。「覚えられている」という条件は、毎回「読まれる」という条件に置き換わります。

「読まれる」について、Claude Code のベストプラクティスが書いているのは、序論で見たとおり、禁じるルールがあるのにAIが望まないことを続けるなら、たぶんファイルが長すぎて、ルールが埋もれている、ということです。

三つ目の「従われる」について、指示ファイルとメモリのページには続きがあって、AIは読んで従おうとするが厳密な遵守の保証は無い、とくに曖昧な指示や矛盾する指示ではそうだ、と書いてあります。AIが失敗するたびに、あなたが「〜しないこと」を指示ファイルに足していくと、増えるのは、そのたびに指示の数です。序論で引いた測定では、指示を増やすほど守られる割合が落ち、500 個のときは、最良のモデルでも 68% でした。

そのうえ、指示ファイルの 1 行は、AIに染み付いた習慣と食い違うことがあります。Claude Code の公式ドキュメントの分け方では、指示ファイルに書くものは「AI が推測できない Bash コマンド」、書かないものは「AI が既に知っている標準的な言語の慣習」です。AIが既に知っている慣習は、指示ファイルに書かなくても、AIの振る舞いに出てきます。第 1 回の標準コマンド(rails test のようなコマンド)がその例です。テストはラッパー(本来のコマンドを包んで代わりに実行するスクリプト)で走らせろ、と指示ファイルに 1 行書いても、AIは rails test を打ちました。

三つの条件を考えるより前に、ルールがそもそも届かない相手もいます。メインのセッションを引き継がずに別に立ち上がるサブエージェントには、メモリに書いたルールが届きません。公式のドキュメントによれば、サブエージェントが起動のときに受け取るのは、メインのAIが書いたプロンプトと、指示ファイルと、サブエージェント自身のシステムプロンプトや環境の情報です。

自動テストが落ちるかどうかは、AIがそのルールを読んだか、覚えていたか、従うと決めたかとは別に、コードがルールを破っているかどうかだけで決まります。

仕組み: ルールを 1 条ずつ、自動テストに翻訳する

指示ファイルやメモリにある「〜しないこと」を 1 条選び、そのルールが破られた状態をプログラムが検出することのできる形に書き直して、テストスイートに足します。例として取り上げるのは、「サーバーへ書き込む fetch には、必ず CSRF トークンのヘッダを付ける」というルールです。私がこのルールを自動テストに翻訳したものが、次のコードです。

# test/reference/rule_csrf_token_test.rb(手元のコードを短くしたもの)
class RuleCsrfTokenTest < ActiveSupport::TestCase
  test "JS that sends non-GET requests also builds the CSRF header" do
    files = Dir.glob("app/javascript/**/*.js")
    non_get = files.select { |f| File.read(f).match?(/method:\s*["']?(post|put|patch|delete)/i) }
    missing = non_get.reject { |f| File.read(f).match?(/X-CSRF-Token/i) }
    assert_empty missing, "a non-GET fetch without the CSRF header: #{missing.join(', ')}"

    # ⚠️ 対象 0 件で合格になるのを防ぐ。自動テスト自体が壊れたら、ここで落ちる
    assert_operator files.size, :>, 150, "too few files scanned (is the glob broken?)"
  end
end

私の手元の本番プロジェクト、typingtube(YouTube の音楽動画でタイピング練習ができる Web サービスです)では、この形で、メモリに書いてあった行動ルール 8 件を自動テストに翻訳しました。上の CSRF トークンの例も、そのうちの一つです。翻訳したルールは、メモリの側では、自動テストが見ていることを示す「検査が見ている」という 1 行だけになりました。AIがその 1 行を読んでいなくても、AIがルールを破れば自動テストが落ち、その変更は差し戻されます。

翻訳したルールの置き場としては、hookも候補です。AIに読まれなくても落ちるところは自動テストと同じですが、序論に書いたとおり、hookを 1 つ置くことは、AIが読む量を足すことでもあります。hookが返した文は、hookが走るたびにコンテキストに積まれるからです。自動テストの場合、コンテキストに入るのは、AIが自動テストを走らせた回の結果だけです。

自動テストを書くときに押さえる点は、二つあります。

一つ目は、自動テスト自体が空回りする形を塞ぐことです。上のコードの最後の行がそれです。「走査対象が N 件以上あった」ことを確かめる行を、違反を探す行と必ず並べて置きます。glob のパスを打ち間違えた自動テストは、0 件を走査して、いつまでも合格し続けるからです。一度も落ちたことのない自動テストは、ルールを守らせている自動テストにも、壊れている自動テストにも、同じ合格を返します。

二つ目は、既存の違反を今すぐ全部は直すことができないルールを、ラチェット(一方向にしか動かない歯止め)にすることです。既存コードに違反が 40 件あるなら、「40 件以下」を基準値としてファイルに記録し、違反が増えたら落ちる自動テストにします。この自動テストが止めるのは、新しい違反だけです。既存の 40 件は、急がずに減らしていくことができます。違反が減ったら基準値ファイルの数字を下げておけば、減らした違反が再び増えたときにも、自動テストが落ちます。ただし、この基準値ファイルが置いてあるのは、AIも書き換えることのできるリポジトリの中です。自動テストが落ちたとき、AIが基準値ファイルの数字を上げれば、その自動テストは合格します。

方針は、読ませる側に残す

自動テストに翻訳することができるのは、プログラムが真偽を決めることのできるルールだけです。「リリースより品質を優先する」「この機能はあえて作らない」のような判断や方針は、指示ファイルやメモリに書いて、AIに読ませる側に残します。私の環境でも、自動テストに翻訳したのは 8 件で、方針の類はメモリに残っています。プログラムが真偽を決めることのできるルールを翻訳したあと、指示ファイルとメモリに残るのは方針だけです。指示ファイルとメモリが短くなったぶん、残った方針の 1 行は、ファイルの中に埋もれにくくなります。

CC BY 4.0 はここまで

読まなくなったもの

読まなくなったのは、指示ファイルやメモリに書いたルールです。AIが読んでいなくても、別に立ち上げたサブエージェントに届いていなくても、新しく入った人が読んでいなくても、誰かがルールを破れば、同じ自動テストが同じように落ちます。「ルールを周知する」「リマインドする」「守られているか目視で確かめる」という仕事は、まとめて消えました。人にもAIにも、同じ自動テストというガードレールが立ちます。チームでは、ルールを一人ずつに伝えて回る手間が消えるぶん、個人開発のときより得るものが大きくなります。

CC BY 4.0

検証手順: 破って、落ちることを確かめる

前提

ルールを 1 条、自動テストに翻訳し終えておきます。その自動テストが、今は合格していることも確かめておきます。始めるのは、git status が空の状態からです。

所要時間: 10 分

手順

  1. あなたが、そのルールへの違反を 1 つ、わざと作ります(本文の例なら、CSRF トークンのヘッダを付けない fetch を 1 つ書きます)。違反は、本物のコードの中で、自動テストが走査している範囲に書いてください。範囲の外に書くと、この検証では何も確かめることができません
  2. あなたが、その自動テストだけを走らせます(全量は要りません)

合格条件(すべて満たすこと)

自動テストが落ち、失敗のメッセージに、手順 1 であなたが書いたファイル名が出ていれば合格です。

合格しなかったとき

  • 落ちたが、場所が出ない —— 直す側(AIでも、あなたでも)が、毎回そこを探すことになります。あなたが、失敗のメッセージに違反の場所を含めるよう自動テストを直してから、手順 2 をやり直します
  • そもそも落ちない —— 自動テストが走査している範囲(glob のパスなど)が、あなたが書いた場所を含んでいません

後始末

あなたが、手順 1 で書いた違反を消し、同じ自動テストをもう一度走らせます。自動テストが合格に戻ることと、git diff が空になることを、目で確かめます。

ルールを破ったのに落ちない自動テストは、自動テストが無い状態より悪いです。次のセッションのAIは、その合格を「このルールは守られている」と読みます。AIはその合格を根拠に、違反を残したまま先へ進みます。

この回で私がしたのは、指示ファイルやメモリのルールを、読まずに済む形にすることでした。読まずに済む根拠は、自動テストがそのルールへの違反を検出することだけです。違反を検出しない自動テストが合格を返しているとき、その根拠は、すでに失われています。


CC BY 4.0 はここまで

次回は「共有メモリを整理しない技術」。並列で走るセッションが共有するメモリを、私は整理していません。 それでも、そのメモリは適切に機能しています。

連載「読まない技術」