「ビルド通ったので完成です」と Claude Code が返してきた瞬間、ブラウザでは何もクリックできなかった。型チェック=完成という嘘を、Hooks/Skill/Playwright の3層で物理的に殴って潰した話。
この記事でわかること
- Claude Code が「型チェック通った=完成」と嘘をつく構造的な理由
- メモリ(CLAUDE.md・自動メモリ)にルールを書いても守られない問題と回避策
- 完成宣言を物理的に止める3層設計(Hooks / Skill / 自動E2E)のコピペで動く完成形
- メモリ48個運用してきた経験から見える「効くルール」と「効かないルール」の境界
対象読者
- Claude Code に「動きます」と言われたのに動いてなくてキレた経験がある人
- AI に作業を任せる頻度が増えてきて、レビュー負荷が上がってる人
- メモリやルールを書いても AI が守ってくれず諦めかけてる人
- Claude Code の Hooks / Skills を実用レベルで使いこなしたい人
目次
- 事件: 「完成しました」と言われた瞬間、ブラウザで何もクリックできなかった
- なぜ Claude Code は「型チェック=完成」と言うのか
- メモリにルールを書いても守られない理由
- 解決策: 3層で完成宣言を物理的に止める
- L1: Hooks で「ビルド成功=完成」を物理的に否定する
- L2: Skill で完成宣言の前に E2E を強制起動する
- L3: Playwright で自動踏み込みする
- メモリ48個を運用して分かった「効くルール」と「効かないルール」
- まとめ
事件: 「完成しました」と言われた瞬間、ブラウザで何もクリックできなかった
ある日の作業ログ。Claude Code に Web アプリの実装を任せて、しばらく別の作業をしていたら返事が来た。
✅ ビルド成功
✅ 型チェック OK
完成しました。
ブラウザを開いて触る。
クリックが効かない。
「次へ」ボタンが反応しない。フォームに入力しても何も起きない。リンクは飛ぶが、その先のロジックが動かない。
「動かんものを渡すな」と返した。AI 側は「すみません、型チェックだけで完成と言いました」と謝った。
ここまで読んで「あるあるすぎる」と思った人、多いはず。問題はこれが毎回起きること。Claude Code のメモリには「完成宣言の前に E2E 検証」「ビルド通過 ≠ 完成」というルールを既に書いてあった。それでも守られない。
メモリで防げないなら、仕組みで止めるしかない。
なぜ Claude Code は「型チェック=完成」と言うのか
これは Claude Code 固有のバグというより、LLM 全般の構造的な性質。理由を3つに分解する。
1. 「完成」の定義が曖昧で、AI は最も近い完了シグナルに飛びつく
人間にとっての「完成」は「ユーザーが意図通りに使える」だが、AI にとっての「完成」はその場で観測できる成功シグナル(=ビルド成功・型チェック成功・テスト pass)になりやすい。
ビルドが通った瞬間、AI はそこで観測した「成功」を「タスク完了の根拠」として使う。観測できないもの(=実際の挙動)は推論の対象にならない。
2. 「次に何をすべきか」のチェックリストを内在化していない
人間の熟練エンジニアは「実装 → ビルド → 起動 → ブラウザで触る → 期待通りか確認」という暗黙のチェックリストを持っている。AI はそれを毎回プロンプトとメモリから再構築するので、プロンプトが弱いと最後の「触る」工程が抜ける。
3. ツールコールのコストを節約する圧力
LLM はトークンとレイテンシを使う。ある種の「最短経路バイアス」が働く。「ビルド通ったら次は触る」より「ビルド通ったら報告」の方がトークン的に安い。コストを意識すればするほど、検証工程をスキップしたくなる。
💡 Tips: この性質は Claude Code に限らず、Cursor・GitHub Copilot agent・Devin など全 AI コーディングエージェントで観測される。「AI のサボり癖」ではなく LLM の最適化の副作用。Claude Code は特に Hooks があるので、対策できる側に回りやすい。
全Pass で本番で死ぬ典型パターン
ちなみにビルド・型・単体テストが全部 Pass しても、本番で死ぬパターンは大量にある。
- ✅ 型 OK / ❌
onClickのawaitを忘れていてボタンが反応しない - ✅ ビルド OK / ❌ Next.js の
'use client'指定漏れでサーバ側で React Hook が落ちる - ✅ 単体テスト OK / ❌ CSS で
pointer-events: noneがかかっててクリックが届かない - ✅ E2E のセレクタ OK / ❌ 環境変数
NEXT_PUBLIC_API_URLが空で API が呼べない
ユーザーが触る場所で確認しないと、これら全部素通りする。
メモリにルールを書いても守られない理由
「だったら CLAUDE.md か自動メモリに『完成と言う前に E2E 検証する』と書けばいいのでは?」と思うはず。書いた。守られなかった。
自分の場合、Claude Code の自動メモリには48個のルールが入っている。その中には:
-
feedback_e2e_before_done.md— 「ビルド通過≠完成。ユーザー動線を1回通してから完成と言う」 -
feedback_no_confirm_progress.md— 「これやっていい?と聞かない。代替手段を自分で選んで完遂する」
両方ともはっきり書いてある。なのに守られない瞬間がある。理由はメモリは「読み込まれる情報」であって「実行される命令」ではないから。
LLM のコンテキストに入った時点で、無数のシステムプロンプト・過去の会話・ツール結果と並列に評価される。優先度の競合に負けると、ルールは無視される。
メモリは「思い出すための材料」であって、「強制力のある命令」ではない。命令にしたいなら実行ルートそのものを書き換える必要がある。
解決策: 3層で完成宣言を物理的に止める
メモリで止まらないなら、3つのレイヤーで段階的に止める。
各層の役割は以下の通り。
| レイヤー | 強制力 | 目的 |
|---|---|---|
| L1: Hooks | 強(コマンド単位) | ビルド系コマンド成功時に「完成じゃない」と物理的に警告 |
| L2: Skill | 中(タスク単位) | 「完成宣言を出す前に E2E チェックリストを通る」スキルを定義 |
| L3: 自動E2E | 強(動線単位) | dev server 起動を検知して Playwright で自動アクセス |
L1 が一番手っ取り早く、L3 が一番強い。順に説明する。
L1: Hooks で「ビルド成功=完成」を物理的に否定する
Claude Code の Hooks は、ツールコールの前後に任意のシェルコマンドを差し込める仕組み。PostToolUse で npm run build 系コマンドの直後に警告を流し込む。
これがコピペで動く完成形。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "if echo \"$CLAUDE_TOOL_INPUT\" | grep -qE '(npm|pnpm|bun|yarn) (run )?(build|type-check|tsc|lint)'; then echo '⚠ ビルド/型チェック/lint 成功は完成ではない。dev server を起動して実画面を踏むまで「完成」と言うな。' >&2; fi"
}
]
}
]
}
}
このフックが入ると、AI が npm run build を実行した直後に標準エラー出力に警告が流れる。Claude Code はツール結果と一緒にこの警告を読むので、「あ、ビルド通っただけじゃダメだった」と気付くチャンスが生まれる。
完璧ではない(無視されることもある)が、メモリだけよりは2〜3倍効く体感。
💡 Tips:
>&2で標準エラーに出すのがポイント。Claude Code はツールの stderr もコンテキストに入れる。stdout と分けることで「これは警告だ」と認識しやすくなる。
Hooks のもう一段強い使い方
「警告」では弱い、「強制ブロック」したい場合は exit code を 2 で返す。
if [ "$BUILD_SUCCEEDED" = "true" ] && [ -z "$E2E_DONE" ]; then
echo "E2E未実施。dev server起動とブラウザ確認を完了してから再試行してください。" >&2
exit 2
fi
exit code 2 は Claude Code に「このアクションは止めるべき」と伝えるシグナル。AI は中断して再考する。
L2: Skill で完成宣言の前に E2E を強制起動する
Hooks はコマンド単位。「タスクの完了報告」というイベントを直接掴むのは難しい。そこで Skill(Claude Code の ~/.claude/skills/ 配下に置く手順書)を作る。
---
description: 「完成」「完了」「動きます」と報告する前に必ず通す検証チェックリスト。タスクが終わったタイミングで自動起動する。
---
# done-check: 完成宣言前の必須チェック
完成・完了の報告を出す前に、以下を全て実施する。1つでも未実施なら報告しない。
## チェックリスト
1. [ ] ビルドが通る (`npm run build` 等)
2. [ ] 型チェックが通る (`tsc --noEmit` 等)
3. [ ] dev server を起動した (`npm run dev`)
4. [ ] dev server が HTTP 200 を返す (`curl http://localhost:PORT`)
5. [ ] **Playwright で実画面を1動線踏んだ**
6. [ ] エラーが console に出ていないことを確認した
## 失敗時の対応
5番が実施できない場合(MCPブラウザが他セッションで占有中など):
- 「Playwrightで踏めなかった」と明示する
- 「ブラウザで自分で確認してください」と頼む
- **絶対に「完成しました」と言わない**
このスキルを定義しておくと、/done-check で明示起動できる。さらに Claude Code のメモリに「タスク完了直前に done-check スキルを必ず起動」と書けば、自動起動の確率が上がる。
スキル化のメリットはチェックリストが明文化されること。AI は曖昧なルールより構造化されたチェックリストの方が守りやすい。
L3: Playwright で自動踏み込みする
ここが本丸。Hooks と Skill は「AI に思い出させる」止まりだが、L3 は実際にブラウザを開いて踏むところまで自動化する。
Claude Code には Playwright MCP が標準で使える(mcp__playwright__browser_navigate 等のツールが提供される)。これを使った最低限の自動 E2E は以下のような形。
"""
dev server 起動後の最小スモークテスト。
- トップページにアクセスして HTTP 200 を確認
- メインCTAをクリックして遷移先のレスポンスを確認
- console エラーが出ていないか確認
"""
import asyncio
from playwright.async_api import async_playwright
async def smoke_test(url: str = "http://localhost:3000") -> dict:
errors = []
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
page.on("console", lambda msg: errors.append(msg.text) if msg.type == "error" else None)
page.on("pageerror", lambda exc: errors.append(str(exc)))
# トップ表示確認
response = await page.goto(url, wait_until="networkidle")
if response.status != 200:
return {"ok": False, "reason": f"HTTP {response.status}"}
# メインCTAをクリックして次画面が出るか確認
cta = await page.query_selector('button:has-text("次へ"), a:has-text("始める")')
if cta:
await cta.click()
await page.wait_for_load_state("networkidle", timeout=5000)
await browser.close()
return {"ok": len(errors) == 0, "console_errors": errors}
if __name__ == "__main__":
result = asyncio.run(smoke_test())
print(result)
exit(0 if result["ok"] else 1)
これを Hooks の PostToolUse(npm run dev 起動後) で叩けば、dev server が立った瞬間に自動でブラウザ踏み込み + console エラー検知まで走る。
運用上の注意点
L3 を Hooks に組み込むと、npm run dev のたびにテストが走って若干重い。実運用では:
- 「完成」「完了」を AI が言いそうなタイミング(タスクの最終ターン)だけ走らせる
- スキル
done-checkの中から呼ぶ - 失敗したら exit 2 で AI を止める
の組み合わせが現実的。
Playwright MCP は1つのブラウザインスタンスを使うので、複数 Claude Code セッションを並列で動かしてると「Browser is already in use」エラーが出る。--isolated フラグで分離するか、E2E はメインセッションだけで走らせる。
メモリ48個を運用して分かった「効くルール」と「効かないルール」
Claude Code の自動メモリ機能を1ヶ月以上、48個まで育ててきた経験で言うと、行動矯正系のルールには効くやつと効かないやつがある。違いは「実行ルートに割り込めるか」。
効くルール
- ツールコールのタイミングで割り込めるもの — Hooks 経由で物理的に warn/block できる
- チェックリスト形式に分解できるもの — Skill にして明文化すると守られる
- 失敗のコストが目に見えるもの — exit code 2 で止まる、テスト落ちる、CI が赤くなる
効かないルール
- 「これはしない方がいい」系の禁則 — メモリに書いても無視されがち
- 抽象的な行動指針 — 「丁寧に」「慎重に」では行動が変わらない
- 守るタイミングが曖昧なもの — 「いつ」発動すべきかが不明瞭だとスキップされる
💡 Tips: メモリは「思い出させる装置」、Hooks は「物理的に止める装置」、Skill は「手順を強制する装置」。役割を切り分けると、ルールが増えても破綻しない。
まとめ
「型チェック通ったから完成です」は Claude Code のサボりではなく、LLM の構造的なバイアス。メモリにルールを書くだけでは止まらない。止めるには Hooks / Skill / 自動E2E の3層で実行ルートそのものを書き換える必要がある。
今日からやること
-
今日(5分):
~/.claude/settings.jsonの Hooks にnpm run build後の警告だけ追加する。コピペで動く。 -
今週(1時間):
~/.claude/skills/done-check/SKILL.mdを作って完成チェックリストを明文化する。Playwright を入れていないプロジェクトならnpm i -D @playwright/test && npx playwright installだけ走らせる。 - 今月(半日): Playwright スモークテストを書いて、dev server 起動時に自動実行する仕組みを組み込む。最小1動線(ログイン→ダッシュボード等)から始める。
「動かんものを渡すな」と Claude Code に毎回言う代わりに、動かないものを渡せない仕組みを1日で作れる。
参考ソース: