CLAUDE.md に「変更後は必ずtestする」と追記した。それでもcoding agentが検証を飛ばす。次は「test結果を確認してから完了報告する」と強く書く。しばらくすると、別の経路で同じ事故が起き、文書の語気だけが強くなる。
CLAUDE.md が役に立たないわけではない。package managerや設計方針を伝えるには便利だ。ただし、agentが読むcontextと、実行を止める仕組みは別にしたほうがいい。副作用はtool実行前のhook / permissionで止め、lintやtestは実行後のgateで落とす。
ここで扱うのはhookの設定リファレンスではない。既存のCLAUDE.mdにあるMUSTやNEVERを棚卸しして、ルールを置くlayerを決め直す。
長いinstructionは、強制設定ではない
長い指示書に従う能力を測るHANDBOOK.md benchmarkでは、65のtaskと824の機械判定基準が使われた。handbookは20〜124ページあり、strict pass@1の最高値は36.2%だった。
この数字はbenchmark条件での結果であり、手元のrepositoryにおけるClaude Codeの成功率ではない。それでも、「書いておけば毎回守られる」を前提に設計するのは危ない、と考える材料にはなる。
Claude Codeの公式ドキュメントでも、CLAUDE.mdはproject memoryとしてcontextに読み込まれる。enforced configurationではない。実行前に止めたい操作にはPreToolUse hookやpermissionという別の境界が用意されている。
CLAUDE.mdの短さを競う話ではない。文書に強制まで背負わせず、担当範囲をcontextへ戻す。
まずルールを3つのlayerに分ける
棚卸しでは、ルールの文章を直す前に置き場所を決める。
| layer | 役割 | 例 | 失敗したとき |
|---|---|---|---|
| context | 判断材料を渡す | pnpmを使う、directoryの責務、設計上の理由 | agentが選択時に参照する |
| pre-tool gate | 戻しにくい副作用を実行前に止める | repository外へのwrite、未承認publish | tool callをblockする |
| post-run gate | 結果を機械判定する | lint、typecheck、test、artifact確認 | commandをnon-zeroで終える |
迷ったら、この程度の分類関数に落としてみると整理しやすい。
type RuleLayer = 'context' | 'pre-tool' | 'post-run'
type Rule = {
mustStopBeforeSideEffect: boolean
canCheckAfterRun: boolean
}
function chooseLayer(rule: Rule): RuleLayer {
if (rule.mustStopBeforeSideEffect) return 'pre-tool'
if (rule.canCheckAfterRun) return 'post-run'
return 'context'
}
たとえば「pnpmを使う」は判断時に必要なのでcontextに残す。「repository外へ書き込まない」は、書き込んだあとに検査しても遅いためpre-tool gateへ移す。「type errorを残さない」は実行結果を判定できるのでpost-run gateへ移す。
「このUIで迷わないか」のような条件はCIだけでは判定しにくい。そこは人が見る。何でも自動gateへ押し込むと、通過条件だけを満たした使いにくい実装ができる。
CLAUDE.mdには判断材料を残す
CLAUDE.mdから禁止事項を全部消す必要はない。小さなTypeScript repositoryなら、たとえば次の情報を残す。
# Project context
- Package managerはpnpmを使う
- 実装はsrc/、testはtests/に置く
- 変更後の共通確認commandはpnpm verify:agent
- dist/はbuild outputなので直接編集しない
- Architectureの背景はdocs/architecture.mdを参照する
ここには、agentが実装方法を選ぶための情報がある。長い設計説明は別ドキュメントへ分け、入口だけを置けばいい。
逆に、次のような文が増えていたら移動候補だ。
- NEVER write outside this repository
- MUST run lint, typecheck, test and build
- NEVER report completion when build output is missing
1行目はpre-tool gate、残りはpost-run gateで扱える。強調語をさらに足すより、失敗時に処理が止まる場所を作る。
副作用はtool callの前で止める
実行後に戻しにくい操作は、agentが指示を思い出すかどうかに任せない。hook / permission側でtool inputを見て、許可か拒否かを返す。
以下はClaude Code固有のhook schemaではなく、hookから呼ぶpolicy関数の概念例だ。
import path from 'node:path'
type ToolAction =
| { kind: 'write'; target: string }
| { kind: 'publish'; target: string }
| { kind: 'read'; target: string }
type Decision =
| { allow: true }
| { allow: false; reason: string }
function isOutsideRepository(repoRoot: string, target: string): boolean {
const absoluteTarget = path.isAbsolute(target)
? target
: path.resolve(repoRoot, target)
const relative = path.relative(repoRoot, absoluteTarget)
return relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)
}
export function decide(action: ToolAction, repoRoot: string): Decision {
if (action.kind === 'write' && isOutsideRepository(repoRoot, action.target)) {
return { allow: false, reason: 'repository外へのwriteは許可されていません' }
}
if (action.kind === 'publish') {
return { allow: false, reason: 'publishには人間の承認が必要です' }
}
return { allow: true }
}
本番ではshell文字列を雑な正規表現で判定するより、tool名と正規化済みinputを使う。巨大なdeny listから始める必要もない。事故履歴を見て、戻すコストが高い操作を一つ選ぶほうが試しやすい。
hookの設定形式は更新される可能性があるため、matcherや返却形式は利用中のClaude Code公式ドキュメントに合わせる。ここで固定したいのはschemaではなく、「副作用より前で判定する」という責務だ。
完了条件は1本のcommandへ寄せる
小さなTypeScript repositoryを例に、local agentとCIが同じcommandを呼ぶようにする。
{
"scripts": {
"verify:artifacts": "node scripts/verify-artifacts.mjs",
"verify:agent": "pnpm lint && pnpm typecheck && pnpm test && pnpm build && pnpm verify:artifacts && git diff --check"
}
}
dist/index.jsが必要なprojectなら、artifact checkも小さなscriptにできる。
// scripts/verify-artifacts.mjs
import { stat } from 'node:fs/promises'
const artifact = new URL('../dist/index.js', import.meta.url)
const result = await stat(artifact).catch(() => null)
if (!result || !result.isFile() || result.size === 0) {
throw new Error('dist/index.js is missing or empty')
}
CI側で別の検証手順を再実装しない。
- name: Verify agent changes
run: pnpm verify:agent
agentが「lintもtestも通りました」と書いても、それだけでは完了条件にしない。runnerのexit status、変更file、必要なartifactを確認する。summaryは証拠へ移動する入口として使う。
agent_result:
command: pnpm verify:agent
exit_code: 0
changed_files:
- src/index.ts
- tests/index.test.ts
artifacts:
- dist/index.js
もちろん、このYAMLをagent自身が書いただけでは証明にならない。CI logや実fileから生成・照合できる項目だけをreceiptとして残す。
正常系だけで終わらせない
gateを追加したら、正常系が通るだけで満足せず、狙った場所で落ちることも確認する。
sample repositoryでは次の順で試せる。
- 変更前のbranchで
pnpm verify:agentが通ることを確認する。 - smoke test用fileへ意図的なtype errorを入れ、commandがnon-zeroになることを確認する。
- type errorを戻し、同じcommandが通ることを確認する。
- policy関数へrepository外の架空pathを渡し、実際にはwriteせずにdenyになることをunit testする。
- CIでも
pnpm verify:agentを実行し、localと同じ結果になるかを見る。
pre-tool gateのtestで危険なcommandを本当に実行する必要はない。判定関数へ無害なinputを渡せば、tool実行前に止まる経路を確認できる。
import path from 'node:path'
import { expect, test } from 'vitest'
import { decide } from '../scripts/tool-policy'
test('repository外へのwriteを拒否する', () => {
const repoRoot = process.cwd()
const outside = path.join(repoRoot, '..', 'blocked-smoke-test.txt')
expect(decide({ kind: 'write', target: outside }, repoRoot)).toEqual({
allow: false,
reason: 'repository外へのwriteは許可されていません',
})
})
こうしておけば、deny条件を変更したときもreviewしやすい。permission設定を目視しただけで終わらせず、block path自体をrepositoryのtest対象にできる。
最初は一つの事故だけ移す
CLAUDE.mdを一気に全面改稿すると、どの変更が効いたか分からなくなる。
最近起きた事故を一つ選び、そのルールをどこへ置くか決める。gateを追加したら、正常系と意図的な失敗を一度ずつ通す。最初の変更はそれで十分だ。
coding agentの指示違反に対して毎回文章を足しても、事故を止める場所は増えない。CLAUDE.mdには判断材料を残し、止めたい副作用はhook / permissionへ、機械で確認できる完了条件はCIとartifact checkへ移す。次に同じ事故が起きたとき、追記する文言ではなく、止めるlayerを選べるようになる。