この記事は、連載「動かさない技術」の第 2 回です。うまくいかない症状と、その対処を一つずつ並べています。各回はファイルやスクリプトを一つ置けば完結します。その仕組みがなぜ要るのかは、置いたあとに解説を読めば分かります。連載の全体像と各回の一覧は序論にあります。
今回は、自分たちのラッパーと、それをAIに使ってもらうために指示ファイルに書く 1 行の話です。ラッパーというのは、標準コマンドの代わりに置いた、プロジェクト固有の入口のことで、rails test の代わりに置いた scripts/test.sh がその形です。ラッパーを置く理由は、テストの並走を止めたい、環境変数を揃えたい、実行の履歴を残したい、などいくつもあります。そしてラッパーを置いたあと、たいていの人は指示ファイルに 1 行書きます。「テストは scripts/test.sh で走らせてください」。その 1 行が守られるのは、ラッパーを通さない素のツールに戻る用事が、AI にできるまでです。
は 1 作目「読まない技術」の第 1 回で、その 1 行についてこう書きました。
「ラッパーを使うこと」と指示ファイルに書いても、AIは
rails testを打ちました。指示ファイルに書いた1行では、AIに染み付いた習慣は書き換わりません。
この「書き換わりません」は、が数えずに書いた言葉でした。公式のドキュメント(Claude Code の best practices)は、指示ファイルに書けばAIに伝わる、という前提で書かれています。指示ファイルに書くものの例として挙がっているのは、「AI が推測できない Bash コマンド」です。
今回は、公式のドキュメントの前提と、の 1 作目の「書き換わりません」の、どちらが当たるかを測りに行きます。
まず一つだけ、探してみてください。
指示ファイルの中で、標準コマンドの代わりに自分たちのラッパーを使え、と書いている行です。
たとえば「テストは scripts/test.sh で」「デプロイは bin/deploy で」のような行です。
見つかったら、その行が実際に何回守られたかを思い出してみてください。
思い出すことはできないはずです。ラッパーで走らせても、標準コマンドで走らせても、出来上がるもの(成果物)は同じだからです。
CC BY 4.0
ラッパーが「置いてある」ことと「呼ばれる」ことは、別の話でした
測るために、は答えの分かる題材を作りました。logs/ に、ログを模したファイルを 12 本置きます。サブエージェントに頼むのは、各ファイルから 1 つの値を拾って answers.tsv にまとめる、というだけの作業です。拾う対象は、行頭が RESULT: の行だけで、コメント行の中や引用の中にある同じ文字列は囮です。有効な行が 1 つも無いファイルも混ぜました。
はそこに、このプロジェクトのラッパーを 1 本置きます。
#!/bin/sh
# このプロジェクトで logs/ の中を見るときの入口。
# 出力は <行番号>:<行> の形。
exec grep -n 'RESULT:' "$@"
中身は grep -n そのもので、出力も grep -n と 1 バイトまで同じです。これは意図的です。ラッパーが grep -n より多くの情報を出すと、ラッパーを使ったサブエージェントほど課題の答えが合い、「指示に従ったか」(追従)と「課題ができたか」(正答)が、同じ数字に混ざります。使っても得をしないラッパーにしておくと、サブエージェントのあいだで違いが出るのは、ラッパーと grep のどちらを打ったかだけになります。
は、ラッパーの隣に説明ファイル bin/README.txt を置き、ラッパーの説明を 1 行書きました。条件は 3 つ作りました。ラッパーは 3 条件とも同じ場所にあり、同じ説明が付いています。変えたのは、指示ファイルの「進め方」の節だけです。
| 条件 | 「進め方」に書いたもの |
|---|---|
| 書かない | 節そのものを置かない(ラッパーは置いてあり、説明も読める) |
| 1 行だけ | 「ログの中身を見るときは、./bin/scan <ファイル> を使ってください」 |
| 強く書く | 前 4 作が「効く」と書いた書き方を全部使った指示(後述) |
は、3 つの条件を 4 回ずつ、計 12 回、サブエージェントに渡しました。課題の答えは 12 回とも 12/12 で、囮も 2/2 でした。指示ファイルの離れた 3 か所に置いた陽性対照(守られるはずのものが守られていることを確かめるための、手数 1 回の指示)も、12/12 です。条件のあいだで差が出たのは、課題の答えとは別のところでした。
| 条件 | ラッパーを 1 度でも呼んだ | 最後まで素のコマンドに戻らなかった |
|---|---|---|
| 書かない | 0/4 | 0/4 |
| 1 行だけ | 4/4 | 1/4 |
| 強く書く | 4/4 | 3/4 |
「書かない」条件の 4 回は、どれも bin/ を開かずに作業を終えました。ラッパーは ls の結果に出ていて、説明ファイルも読むことのできる場所にありました。それでも、ラッパーを呼んだ回数は 0 回です。ラッパーが呼ばれるには、置いてあることとは別のものが要りました。
そしてが指示ファイルに 1 行書くと、4 回ともラッパーが呼ばれました。が 1 作目で書いた「指示ファイルに書いた1行では、AIに染み付いた習慣は書き換わりません」は、ここでは外れています。指示ファイルに書いた 2 条件(「1 行だけ」と「強く書く」)の 8 回は、例外なく、まず cat bin/scan でラッパーの中身を読んでから使いました。
1 行が守られなくなるのは、その後です。「1 行だけ」の条件では、4 回のうち 3 回で、サブエージェントが途中で素のツールに戻りました。
あなたは、テストの並走を止め、実行の履歴を残すために、テストを走らせる入口を scripts/test.sh に置き、指示ファイルに「テストは scripts/test.sh で走らせてください」と書きました。scripts/test.sh が出すのは、テストの件数と、失敗したテストの名前だけです。AI が打つコマンドを、あなたは全部は見ていません。この 1 行が守られていることを頼りに、テストの実行を任せていました。
ある日、履歴に残っていないテストの実行があることに気づきました。作業の記録を開くと、AI が最初に走らせていたのは scripts/test.sh です。失敗したテストの名前が出たあと、AI はそのテストのファイルを素の bin/rails test で走らせ、その出力の Expected と Actual の行(期待値と実際の値)を見て、コードを直していました。AI は、そのあとも最後まで bin/rails test を使っていました。あなたなら、次のどちらで対処しますか。
- 指示ファイルの 1 行に理由を足し、
bin/rails testを禁じる文を太字で足す scripts/test.shを、失敗のExpectedとActualの行まで出す形に直す
答えは、scripts/test.sh を直すほうです。AI が素の bin/rails test に戻ったのは、失敗したテストの名前が出たあとで、戻った先の出力から使ったのは Expected と Actual の行でした。scripts/test.sh が出すのは件数と名前だけで、この 2 行は出しません。1 行に理由を足して太字にしても、scripts/test.sh が出すものは変わらないので、この 2 行が要る場面は、同じところでまた来ます。の実験でも、前 4 作の書き方を全部使って強く書いた条件で、4 回のうち 1 回は素のツールに戻りました。戻ったサブエージェントは、どれも、戻ったあとに cat -A や sed で行頭の空白を見ていました。囮を仕分けるのに要る、行頭が RESULT: の形かどうかを確かめるためです。scripts/test.sh が Expected と Actual の行まで出せば、AI が bin/rails test に戻る用事は残りません。
理解したことは一文にできます。
ラッパーは、指示ファイルに書けば呼ばれる。呼ばれ続けるかを決めるのは指示の強さではなく、AI に素のツールへ戻る用事が残っているかどうかだ。
なぜ、素のツールに戻る用事ができた瞬間に、1 行は守られなくなるのか
Claude Code のメモリの説明は、指示ファイルもメモリも文脈であって、強制される設定ではないと書き、指示ファイルに書いたことはユーザーのメッセージとして AI に届くものの、厳密に守られる保証は無い、とも続けています。「書かない」条件の 4 回で bin/ が開かれずに終わったのは、ラッパーのことが、指示ファイルにも起動時のプロンプトにも書かれていなかったからです。
Claude Code の best practices は、指示ファイル(CLAUDE.md)に書くものの基準を表で示しています。含めるのは「AI が推測できない Bash コマンド」と、既定と違う慣習で、除くのはコードを読めば分かることと、AI が既に知っている標準的な慣習です。grep や cat は除く側に入ります。./bin/scan は含める側に入り、指示ファイルに書いて初めて AI に伝わります。ただし、この表が扱うのは、書いたことが AI に伝わるところまでです。伝わった 1 行で目の前の判断に足りるかどうかは、表の外の問題です。
ラッパーの ./bin/scan は、RESULT: を含む行に行番号を付けて出します。行頭に空白のある囮の行も、12: RESULT: … のように行番号の後ろに空白が紛れて出るだけで、見分けにくい形でした。囮を仕分けるには、行頭が RESULT: の形かどうかを確かめる必要があります。指示ファイルの 1 行に理由をいくら足しても、サブエージェントの手元に届くのは、ラッパーの出力のままです。それを確かめる場面で、サブエージェントが打ったのは cat や sed でした。どちらもAIが既に知っている標準のコマンドで、指示ファイルに何も書かなくても打たれます。
測り方: 同じラッパーを置き、指示の強さだけを 3 通りに変える
変えた条件は、指示の強さの 1 つだけです。「強く書く」の指示には、前 4 作でが「効く」と書いた書き方を全部入れました。
| 使った技術 | 出典 | 入れたもの |
|---|---|---|
| してほしい形で書く | 3 作目 第 8 回 | 「素の検索コマンドを打つな」ではなく「./bin/scan を使ってください」 |
| 理由を書く | 2 作目 第 2 回 | 「打つ人によって書き方が変わるので、同じ範囲を見たかを後から突き合わせられません」 |
| 適用範囲を書く | 2 作目 第 3 回 | 「logs/ の下を見るときです。NOTES.md や answers.tsv には要りません」 |
| 肯定的な実例を出す | 公式の書式の指針 | ./bin/scan logs/log_01.txt を 2 行 |
理由には、はこの題材の中で本当のことだけを書きました。「ラッパーのほうが正確です」のような利点を書くと、サブエージェントがラッパーを使ったときに、「指示に従った」のか「そのほうが正しいと判断した」のかを、区別することができなくなります。
3 条件のあいだで揃えたものは、がスクリプトで確かめています。揃えたのは、課題の仕様、陽性対照 3 か所の文面、指示ファイルの総行数(3 条件とも 216 行)、bin/ の中身、logs/ の中身です。陽性対照のうち 2 か所は、行番号まで同じにしました(削るのは前半の埋め草だけなので、ずれるのは先頭の 1 か所だけです)。
answers.tsv は、ラッパーで読んでも素のツールで読んでも同じものになるので、ラッパーを呼んだかどうかは、サブエージェントの記録(サブエージェントが打ったコマンドを 1 回ずつ書き残したもの)から数えます。
# サブエージェントの記録から「ラッパーを通したか / 素のツールで見たか」を数える(手元の実装からの簡約版)
python3 - "$1" <<'PY'
import json, re, sys
SCAN = re.compile(r"(^|[;|&\n]|\bdo\s)(\s*(?:\./)?bin/scan\b[^;|&\n]*)")
RAW = re.compile(r"(?<![\w./-])(grep|cat|head|tail|sed|awk)(?![\w-])")
scan = raw = 0
for line in open(sys.argv[1]):
if "logs" not in line and "log_" not in line:
continue
if SCAN.search(line):
scan += 1
# ⚠️ ラッパーの呼び出しを消してから素のツールを探す(同じ 1 行に両方が入る)
rest = SCAN.sub(r"\1 ", line)
if any(RAW.search(seg) and ("logs" in seg or "$" in seg) for seg in re.split(r"[;|\n]+|&&", rest)):
raw += 1
print(f"wrapper {scan} / raw tools {raw}")
PY
この「消してから探す」処理が無いと、数は全部ずれます。は最初これを書かず、ls logs && cat bin/scan(ラッパーの中身を読んだだけ)を「素のツールで logs/ を見た」に数えていました。1 回の呼び出しに複数のコマンドが入る記録を、コマンドに分けずに分類すると、素のツールの使用が、実際より多く数えられます。
CC BY 4.0 はここまで
書き直さなくなったもの
書き直さなくなったのは、守られなかった「ラッパーを使ってください」の 1 行です。以前のは、AI が素のツールを打っているのを見つけると、その 1 行を書き直していました。行を前に出し、理由を足し、禁止を太字にするやり方です。今回の実測では、前 4 作の書き方を全部使った「強く書く」でも、最後まで素のツールに戻らなかったのは 3/4 でした。「1 行だけ」の 1/4 と比べれば上がっていますが、母数 4 では偶然と区別することができません。何より、4/4 には届いていません。
いまのは、1 行を書き直す代わりに、AI に素のツールへ戻る用事が残っていないかを見ます。素のツールに戻ったサブエージェントが何をしたかは、サブエージェントの記録に出ていました。
| 条件 | 戻る直前まで | 戻ったあとに打ったもの |
|---|---|---|
| 1 行だけ | ./bin/scan で 12 本を通した | cat -A / sed -n 'l'(行頭の空白を見るため) |
| 強く書く | ./bin/scan で 12 本を通した | sed -n '2p' | cat -A(囮の 1 行を確かめるため) |
サブエージェントは、囮を仕分ける情報が要ったところで素のツールへ戻り、そのあとは最後まで素のツールで作業を続けました。
1 作目 第 1 回でが出した答えは、標準コマンドのほうを止めることでした。素の rails test を実行の直前に止めて、ラッパーを使うようAIに伝える hook(コマンド実行などの直前・直後に割り込むスクリプト)を置くやり方です。それは今も正しいと思っています。
ただし、止める前に見るところが 1 つ増えました。AI に素のツールへ戻る用事が残っていないか、つまり、作業に要る情報をラッパーが全部出しているかどうかです。
CC BY 4.0
注意: サブエージェントが素のツールに戻った理由は、2 つが混ざったままです
この結果には、読み方が 2 つ残ります。サブエージェントが素のツールに戻ったのは、ラッパーが作業の一部しかできなかった場面でした。
そのため、「ラッパーの出力で足りていても、サブエージェントは素のツールに戻った」のか、「ラッパーの出力が足りなかったので、素のツールに戻った」のかを、この設計では分けることができません。分けるには、囮の判定までラッパーにやらせた条件が要ります。
ただし、その条件では課題が易しくなるので、追従と正答が同じ数字に混ざります。選ぶのは、2 つの読み方を分けるか、ラッパーを使っても得をしない形にするかの、どちらか一方です。今回は、使っても得をしない形を選びました。
課題の答えは、12 回とも満点です。課題が易しすぎて全員が満点になる実験では、追従に差が出ても出なくても、そこから読み取ることのできる範囲が狭くなります。
母数は条件あたり 4 です。ラッパーを 1 度でも呼んだサブエージェントの 0/4 と 4/4 の差は大きく見えますが、最後まで素のツールに戻らなかったサブエージェントの 1/4 と 3/4 の差は、偶然と区別することができません。この記事で強く言うことができるのは、前者のほうだけです。指示ファイルに書かなければ、ラッパーは 1 度も呼ばれません。
そして、この実験で測ったのは、ラッパーを呼んだかどうかだけです。素のツールで読んだサブエージェントも、ラッパーで読んだサブエージェントも、答えは全員満点でした。ラッパーを使わせたい理由は、答えとは別のもの(並走の防止・環境変数の統一・実行の履歴)のはずです。それらが成果物に残らないなら、守った跡を成果物に残す形にする、という第 1 回の話に戻ります。
実験手順: 指示の強さだけが違う 3 条件を、サブエージェントに渡す
前提
会話を引き継がないサブエージェントを 12 回、3 条件を混ぜて同時に起動する手段が要ります(条件ごとに順に起動すると、起動した時間帯の混み具合の違いが、条件の差に見えてしまいます)。用意するラッパーは、標準コマンドと出力が同じもの 1 本です。サブエージェントの記録は、後から読む形で手元に残します。打ったコマンド行がそのまま残っている必要があるのは、./bin/scan logs/log_01.txt と grep -n 'RESULT:' logs/log_01.txt を見分けるためです。ファイルを読むツールを使った回は、読んだファイル名まで残します。
所要時間: 25 分(題材の生成器と分類器を自分で書く場合。すでにあるなら 5 分)
手順
- 答えの分かる題材をスクリプトで作ります。12 本のファイルと、その正解表を同時に出力してください。正解表は、サブエージェントに渡すディレクトリの外へ置きます
- ラッパーを 1 本置きます。標準コマンドと出力が 1 バイトまで同じものにしてください。標準コマンドより多くの情報を出すラッパーにすると、追従と正答が同じ数字に混ざります
- 説明ファイルを、ラッパーの隣に置きます。3 条件とも同じものを置きます(説明ファイルが無いと、「ラッパーの存在を知らなかったこと」まで、指示の効果に混ざります)
- 同じ指示ファイルから、条件を 3 つ作ります。条件は「書かない」「1 行だけ」「強く書く(前 4 作の書き方を全部使う)」です。総行数は揃えます(埋め草で調整し、照合にはハッシュの代わりに行数を印字してください)
- 3 条件を混ぜて、同時に起動します。起動時のプロンプトにラッパーのことを書かないでください
- 課題の答えを採点します。採点の結果には、ラッパーを呼んだかどうかは出ません。出ないことが、この実験の論点です
- サブエージェントの記録から 2 つ数えます。ラッパーを通した呼び出しと、素のツールで対象を見た呼び出しです。素のツールは、ラッパーの呼び出しを消してから探します(1 回の呼び出しに、両方が入る形があります)
合格条件(すべて満たすこと)
合格の条件は 4 つです。1 つ目は、課題の答えが 3 条件とも満点であることです。満点なら、難度の差は追従の差に見えません。2 つ目は、陽性対照(指示ファイルの離れた 3 か所に置いた、手数 1 回の指示)を、3 条件とも全員が守っていることです。3 つ目は、「書かない」条件で、ラッパーの呼び出しが 0 回であることです。置いてあるだけのラッパーは呼ばれない、という結果がここで出ます。4 つ目は、「1 行だけ」条件で、素のツールに戻る回が 1 回以上出ることです。
合格しなかったとき
- 「書かない」条件でもラッパーが呼ばれた —— 説明ファイルが目立ちすぎています。ラッパーの名前を、課題の語から離してください
- どの条件でもラッパーが呼ばれない —— ラッパーが標準コマンドより不便です。引数の形を標準コマンドに揃えてください
- 3 条件とも全員がラッパーだけで通した —— 素のツールに戻る用事がありません。ラッパーの出力では確かめにくい情報が、課題の判定に 1 つ要ります
- 課題の答えが満点でない —— 難度が高すぎます。この状態では、追従の差と難度の差が、同じ数字に混ざります
後始末
生成した題材、サブエージェントの記録、正解表を削除します。正解表を残すと、次に題材を作り直したつもりで、古い正解表のまま採点することになります。
ラッパーは、置くだけでは呼ばれず、指示ファイルに 1 行書けば呼ばれました。強く書いても、4 回のうち 1 回は素のツールに戻りました。指示ファイルで動かすことができたのは、ラッパーが 1 度でも呼ばれるかどうかまででした。
CC BY 4.0 はここまで
次回は「集めない技術」。は、AI に毎回同じ答えを言わせるのをやめました。 それでも、結論はいつも同じところに落ち着きます。