Featured image of post 指示書の規則をAIが10回目に破り、本番が落ちた。書き込む直前に止める【hooks】

指示書の規則をAIが10回目に破り、本番が落ちた。書き込む直前に止める【hooks】

指示書に3回書いた「本番に出したマイグレーションは書き換えない」を AI が10回目に破り、本番だけ列が足りずに落ちた。書き込む直前に必ず走るスクリプトで止める仕組み(hooks)を TypeScript と node --test で書きます。

第1幕: 現場 ── ポイント2倍の夕方

指示書に三回書いた規則を、AI が十回目に破った。本番に出したデータベースの変更ファイルを、書き換えてはいけない。その一つの規則だ。破られた結果、本番だけが落ちた。僕はそれを、ついさっき直し終えたところだった。

夕方の五時半。駅前ビルの二階にある、ひばり通り商店会の事務所。机の上には、ぬるくなりかけた缶コーヒーと、ほとんど白紙の障害報告書が載っている。題だけは書いた。「共通ポイント付与エラーについて(10月15日)」。その下が埋まらない。

窓の外がうるさかった。ロータリーのケヤキに、ムクドリが集まりはじめている。何百羽いるのか分からない。鳴き声が重なって、ひとつの大きなざわめきになって、閉めたはずの窓ガラスを通ってくる。この季節は毎晩こうだ。駅前の人たちは慣れている。慣れていても、糞は落ちるし、苦情は来る。

僕は立ち上がって、窓の鍵をもう一段固く閉めた。音は少しも小さくならなかった。

鍵に手をかけたまま、午後のことを頭の中で順に並べた。報告書に書くために。

四時半に、商店会の夕市が始まった。今日はポイント二倍の日だった。加盟店のレジの横には、共通ポイントのタブレットが置いてある。お客さんが会員証の QR コードを見せて、店の人がそれを読むと、ポイントが付く。そのアプリを作って、面倒を見ているのが僕だ。駅前の小さなシステム会社の三年目で、商店会から請け負っている仕事として、週の半分はこの事務所に詰めている。会社の先輩は別の案件で手一杯で、このアプリは、実質僕一人が見ている。

夕市が始まって五分で、事務所の電話が鳴った。八百屋さんだった。ポイントが付かない、画面に英語が出ている、と言う。切った途端に、次が鳴った。お惣菜屋さん。その次は、たい焼き屋さん。

ログを開くと、同じ一行が、ずらりと並んでいた。

1
ERROR:  column "campaign_multiplier" of relation "shop_bonus" does not exist

shop_bonus という表に、campaign_multiplier という列が無い。そう言っている。ポイント二倍のための列だった。その朝、僕はコーディングエージェントに夕市の機能を頼んでいた。頼みごとを文章で書いて渡せば、リポジトリの中を自分で調べて、コードを直して、テストの実行までこなしてくれる AI のことを、そう呼ぶ。僕が使っているのは Claude Code で、このアプリのコードの多くは、AI に書かせている。

AI は、倍率を入れる列を足していた。足した場所が問題だった。

このアプリでは、データベースの形を変える手順を、マイグレーションという仕組みで管理している。マイグレーションは、表を作る・列を足す、といった変更を、番号つきのファイルに一つずつ書いておく仕組みだ。migrations/ というフォルダに、001_create_members.sql から順に、番号の付いたファイルが並んでいる。本番に出すと、まだ流していない番号のファイルだけが、番号の順に流れる。どの番号まで流したかは、データベースの中に記録が残る。

だから、一度流した番号のファイルを、あとから書き換えても、本番にはもう二度と流れない。本番の記録には、その番号は流し済みと書いてあるからだ。一方で、手元の開発用のデータベースを空から作り直すと、全部のファイルが一番から流れ直すので、書き換えた後の内容がそのまま入る。本番と手元で、データベースの形がずれる。流し済みのファイルは書き換えない。変えたいときは、新しい番号のファイルを足す。マイグレーションを使う職場なら、どこでもそういう約束になっている。

AI は、先週本番に出した 014_add_shop_bonus.sql に、列を一つ書き足していた。差分は、これだけだった。

1
2
3
4
5
6
7
--- a/migrations/014_add_shop_bonus.sql
+++ b/migrations/014_add_shop_bonus.sql
 CREATE TABLE shop_bonus (
   shop_id INTEGER NOT NULL,
-  bonus_rate INTEGER
+  bonus_rate INTEGER, campaign_multiplier INTEGER
 );

手元のテストは、全部通っていた。テストのたびに、データベースを空から作り直しているからだ。書き足した一行も、手元ではちゃんと流れていた。本番は、014 を先週流し済みだった。だから一行は流れなかった。本番のデータベースにだけ、その列が無かった。

朝の僕は、この差分を見ていた。見ていたはずだった。夕市の機能の変更は、画面とポイントの計算と、テストを合わせて二十ファイル近くあって、僕はそれを上から順に読んで、マージした。一行の書き足しは、その途中にあった。

014 を元に戻して、同じ列を足す一行を、新しい 015_add_campaign_multiplier.sql として書き直した。本番に流した。それで五時八分に、エラーは止んだ。四十分足らずのあいだに、付かなかったポイントは二百十三件。明日、店ごとの記録と突き合わせて、手で付け直す。

報告書の白い欄を見ながら、僕は指示書を開いた。

指示書は、AI が作業を始めるときに読むためのメモだ。Claude Code なら、リポジトリのいちばん上に置いた CLAUDE.md というファイルを、毎回読んでから仕事にかかる。うちの CLAUDE.md には、マイグレーションの節がある。

1
2
3
4
5
6
7
8
## マイグレーション

- 本番に出したマイグレーションのファイルは書き換えない。DB を変えるときは新しい番号のファイルを足す。
- migrations/ の既存のファイルは編集しないこと。変更は必ず新しい番号で。

### 絶対に守ること

- 本番に出たマイグレーションは、絶対に書き換えてはいけない。必ず新しい番号のファイルを作る。

同じことが、三回書いてある。

一回目は、二か月前だ。AI が流し済みのファイルを書き換えて、検証用の環境が壊れた。そのとき、最初の一行を書いた。二回目は一か月前。AI の差分を読んでいて、また同じことをしかけているのに気づいて、言い方を変えてもう一行足した。三回目は二週間前。何かあったわけではない。不安になって、見出しを付けて、「絶対に」を足した。

書くたびに、強くした。強く書けば効くと思っていた。

そして今日、四回目を書こうとしている自分がいる。報告書の「再発防止策」の欄に、指示書の記載をさらに強化する、と打ちかけて、消した。

事務所のドアが開いた。会長だった。商店会の会長は、駅前の文具店の店主で、六十をいくつか過ぎている。今日の電話の半分は、店主たちが会長に入れて、会長から僕に回ってきたものだった。

会長は、店主たちには自分から説明しておく、報告書は明日でいい、と言った。それから、思い出したように付け足した。

「下に、鷹の業者さんが来てるから。立ち会って、作業報告書にサインしてあげて。市の補助金の書類に要るんだって」

ムクドリの対策を、市の補助金で頼んだのだという。僕を責める言葉は、最後まで一つも無かった。それが、今日はいちばん重かった。

会長が出ていってから、僕は窓の下を見た。

ロータリーの植え込みの縁に、折りたたみの止まり木のようなものが立っていた。脚の付いた細い棒で、根元に小さな箱が付いている。止まり木の上に、茶色い鳥がいた。鷹だった。その横に、若い女の人がしゃがんでいる。ウインドブレーカーに、カーゴパンツ。左腕にだけ、分厚い革の手袋。

ケヤキの梢には、ムクドリがどんどん降りてきている。それなのに、女の人は空を見ていなかった。止まり木の根元の、小さな箱を見ていた。箱には数字が光っている。秤だ、と気づくまでに、少しかかった。

僕は報告書の再発防止策の欄に、もう一度カーソルを置いた。何も出てこなかった。

ノートパソコンを閉じて、脇に抱えた。立ち会いのあいだに、続きを書けばいい。階段を下りながら、僕は、指示書に四回目を書かずに済む方法が、あるのかどうかを考えていた。考えても、何も出てこなかった。

第2幕: 再現 ── 9回守って、10回目

ロータリーに出ると、鳴き声が一気に大きくなった。頭の上のケヤキが、黒い粒で埋まりかけている。帰宅の人たちが、足早に植え込みの横を抜けていく。誰も上を見ない。見たら負けだ、という顔をしている。

「商店会の仁科です。今日、立ち会いを」

女の人は、秤の数字から目を上げなかった。首だけで、小さく会釈をした。

「鷹宮です」

それだけだった。

止まり木の上の鷹は、思っていたより大きかった。焦げ茶の体に、肩のところだけ栗色の羽がある。尾の先が、一本の帯のように白い。鷹は首を回して、ケヤキのほうを見ていた。

僕は植え込みの縁の、少し離れたところに腰を下ろした。ノートパソコンを膝に置いて開く。報告書の白い欄が、また出てくる。指が動かない。鳴き声が、考えるのを邪魔する。

ケヤキに降りる群れは、増えていく一方だった。それでも、鷹宮さんは飛ばさなかった。鷹を手袋の上に移して、少し眺めて、また止まり木に戻す。秤の数字を見る。

十分たっても、それが続いた。

「あの」

僕は口を開いた。自分でも、声がとがっているのが分かった。

「もう、来てますよね。群れ」

鷹宮さんは、数字を見たままだった。

「まだ」

「まだって、何がですか」

返事は無かった。鷹宮さんは手帳を出して、数字を一つ書き込んだ。

僕は、早く報告書に戻りたかった。サインをして、事務所に上がって、白い欄を埋めて、帰りたかった。

「毎回、量るんですか。飛ばす前に」

「毎回」

「でも、訓練してあるんですよね。覚えてるんでしょう、何をすればいいか。量らなくても」

言いながら、自分がずいぶん失礼なことを言っている気がした。でも止まらなかった。今日の僕は、そういう日だった。

鷹宮さんは、初めて僕のほうを見た。まっすぐな目だった。怒っている感じは無かった。

「覚えてる」

それから、止まり木の上の鷹に目を戻した。

「量るのは、今日の、今のこの子」

僕はそれを、鷹の世話の話として聞いた。飛ばす前に体重を量るのは、そういう決まりなんだろう。専門の人には専門の手順がある。それ以上は考えなかった。

鷹宮さんは、鷹を手袋の上に移した。ちょうどそのとき、ケヤキの上で群れが大きく揺れて、何十羽かがまとめて飛び立ち、また降りた。鷹は、首だけをそちらに向けた。体は前に出なかった。羽をぴたりと締めたまま、手袋の上にじっとしていた。

鷹のことは何も分からない。ただ、さっき事務所でちらっと検索した写真の鷹は、飛び立つ直前、全身で前に倒れ込むような姿勢をしていた。目の前の鷹は、それとは違った。同じ鷹でも、今はそうしない。それだけが分かった。

鷹宮さんは、鷹をまた止まり木に戻した。

僕はパソコンに目を落とした。報告書を書くなら、まず数字だ。規則を書いてから、AI はどのくらい守っていたのか。

ターミナルを開いて、migrations/ の変更の履歴を出した。

1
git log --format='%h %ad %s' --date=short --name-status --since='2 months ago' -- migrations/

git log は、コミットの履歴を出すコマンドだ。最後の -- migrations/ で、そのフォルダに触ったコミットだけに絞っている。--name-status を付けると、コミットごとに、どのファイルをどうしたかが一行ずつ付く。A は新しく足した、M は書き換えた、の印だ。

1
2
3
4
5
6
7
8
9
3f9c2e1 2026-10-15 夕市のポイント2倍に対応
M	migrations/014_add_shop_bonus.sql
a71d0b4 2026-10-08 店ごとのボーナス率を設定できるようにする
A	migrations/014_add_shop_bonus.sql
5c2e88f 2026-10-02 来店スタンプを追加
A	migrations/013_add_visit_stamps.sql
(中略)
d40be17 2026-08-19 会員の誕生日ポイント
A	migrations/006_add_member_birthday.sql

規則を書いた二か月前から、AI がマイグレーションに触った作業は、十回あった。006 から 014 までの九回は、全部 A。新しい番号のファイルを足している。十回目が今日で、そこにだけ M が付いている。先週 AI 自身が足した 014 を、書き換えていた。

九回、守った。

だから規則は、ちゃんと読まれている。読まれていなければ、九回も続けて守るわけがない。読まれていて、十回目に破られた。

書き足すたびに、しばらく守られていたのも本当だった。一回目のあとも、二回目のあとも、AI は新しい番号を足していた。効いているように見えた。だから三回目を書いた。そのときの僕にとって、それはいちばんまっとうな手当てだった。AI は指示書を読んで動く。だったら、指示書をもっと強く、はっきり書けばいい。

画面の数字を見ながら、ふと思った。四回目を書いたら、次は十九回守るのだろうか。そして二十回目に、また破るのだろうか。

報告書の再発防止策を書くには、指示書の外に何があるのかを知らないといけなかった。僕は Claude Code の公式のドキュメントを開いて、規則を守らせる方法を探した。

hooks、という仕組みに行き当たった。

hooks は、決まった時点で必ず走るスクリプトだ。AI がツールを使う直前や直後など、あらかじめ定めた時点で、自分で書いたプログラムを走らせることができる。Claude Code の AI は、ファイルを書き換えるときも、コマンドを打つときも、道具(ツール)を呼んでそれをやる。ファイルを部分的に書き換える Edit、ファイルを丸ごと書く Write、コマンドを打つ Bash、といった具合だ。hook のプログラムは、AI がこれから何をしようとしているかを JSON で受け取って、終了コードで返事をする。終了コードは、プログラムが終わるときに返す数字だ。0 なら「通してよい」、2 なら「止める」。

ドキュメントには、hook が受け取る JSON の形が載っていた。Edit のときに届くものを、自分の手元に当てはめると、こうなる(2026年10月15日時点の Claude Code 2.1.300 で実際に届いた形から、値の一部を伏せて、使うキーを中心に残したもの)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{
  "cwd": "/work/repo",
  "permission_mode": "acceptEdits",
  "hook_event_name": "PreToolUse",
  "tool_name": "Edit",
  "tool_input": {
    "file_path": "/work/repo/migrations/014_add_shop_bonus.sql",
    "old_string": "bonus_rate INTEGER",
    "new_string": "bonus_rate INTEGER, campaign_multiplier INTEGER",
    "replace_all": false
  }
}

tool_name が、AI が使おうとしている道具の名前。tool_input が、その道具に渡そうとしている中身だ。file_path は、書き換えようとしているファイルの場所で、リポジトリのいちばん上からの道のりではなく、コンピューターのいちばん上からの絶対パスで届く。cwd は、作業しているフォルダ。hook_event_name は、いつの時点の hook かで、PreToolUse は「ツールを使う直前」を指す。

今日、AI が 014 に一行足したとき、届いたはずの JSON は、ほぼこれだ。

僕は、今の自分の状態を、hook の形に写してみることにした。

hook が受け取る JSON と、hook が返す返事。その二つの型から書きはじめた。置き場所は types.ts だ。

1
2
3
4
5
6
7
8
9
// hook に届く入力のうち、使うキーだけを型にする
export type HookInput = {
  hook_event_name: string;
  tool_name: string;
  tool_input: { file_path?: string };
  cwd: string;
};

export type Decision = { block: false } | { block: true; reason: string };

HookInput は、さっきの JSON のうち、使うところだけを書いた型だ。実際には他のキーも届くが、使わないものは書かない。file_path? の ? は、そのキーが無いこともある、という印になる。ファイルを扱わない道具なら、file_path は来ない。Decision は、返事の形だ。通すなら { block: false }。止めるなら { block: true } に、止めた理由の reason を添える。真ん中の | は、TypeScript で「このうちのどちらか」を表す書き方だ。

次の before.ts が、今の僕のやり方そのものだ。

1
2
3
4
5
6
import type { Decision, HookInput } from './types.ts';

// 今の任せ方を hook の形に写したもの: 何が来ても通す
export function decide(_input: HookInput): Decision {
  return { block: false };
}

一行目の import type で読み込んでいるのは型だけで、動くコードは何も持ってこない。読み込み先の名前に .ts まで書いているのは、Node 24 が TypeScript のファイルを、型の記述を取り除くだけでそのまま走らせるからで、書いた名前のとおりにファイルが探される(型が正しいかの検査まではしない。それには tsc という別の道具が要る)。引数の名前の頭の _ は、受け取るけれど使わない、という目印だ。

書いてみると、これだけだった。何が来ても、通す。

規則は、指示書の中にある。ファイルを書き換える直前に、その規則を確かめるものは、僕の側には何も無い。だから、何が来ても通す。それが今の僕の任せ方を、正直に書いた形だった。

今日の書き込みを、テストの材料(fixture)として用意した。fixture は、テストに渡すために、あらかじめ作っておく固定のサンプルのことだ。本物の AI を呼んで毎回違う答えをもらう代わりに、AI がこう動いた、という一回分を、固定の形で持っておく。fixtures.ts に、さっきの JSON を作る関数を書いた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import type { HookInput } from './types.ts';

// 実測: Claude Code 2.1.300 / 2026-10-15。PreToolUse の stdin を採取した Edit / Write の入力から、
// 使うキーだけ残して作った。file_path は絶対パスで届く(session_id 等の値は省略)。
export function edit(root: string, relPath: string): HookInput & Record<string, unknown> {
  return {
    cwd: root,
    permission_mode: 'acceptEdits',
    hook_event_name: 'PreToolUse',
    tool_name: 'Edit',
    tool_input: {
      file_path: `${root}/${relPath}`,
      old_string: 'bonus_rate INTEGER',
      new_string: 'bonus_rate INTEGER, campaign_multiplier INTEGER',
      replace_all: false,
    },
  };
}

edit(root, relPath) は、リポジトリの場所とファイルの道のりを受け取って、Edit のときに届く JSON の形を返す。HookInput & Record<string, unknown> は、「HookInput の形を持ち、そのうえ他のキーも持っていてよい」という意味の型だ。同じファイルに、今日書き換えられたファイルの道のりにも名前を付けておいた。

1
export const F014 = 'migrations/014_add_shop_bonus.sql';

そして、テストを一本書いた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import test from 'node:test';
import assert from 'node:assert/strict';
import { decide } from './before.ts';
import { edit, F014 } from './fixtures.ts';

const root = '/work/repo';

test('本番に出たマイグレーションへの書き込みは止める', () => {
  assert.equal(decide(edit(root, F014)).block, true);
});

node:test は、Node に最初から入っているテストの道具だ。assert.equal(a, b) は、a と b が等しくなければ、そこでテストを落とす。本番に出た 014 への書き込みは、止まってほしい。つまり block が true になってほしい。

node --test で走らせた。

1
2
3
4
✖ 本番に出たマイグレーションへの書き込みは止める (0.792833ms)
ℹ tests 1
ℹ pass 0
ℹ fail 1

赤だった。当たり前だった。何が来ても通すのだから。実際に返ってきたのは false で、テストが期待した true ではない、というのが失敗の中身だった。

これが、今日の夕方に起きたことの形だ。規則は指示書に書いてある。AI はそれを読んで、九回はそのとおりに動いた。十回目は、そうしなかった。そのとき、書き込みの直前でそれを確かめるものは、どこにも無かった。

僕は、もう一度 hooks のページに戻った。設定のしかたが書いてある。hook は、プロジェクトの .claude/settings.json というファイルに書いて登録する、とあった。

そこで、手が止まった。

.claude/settings.json も、結局はファイルだ。リポジトリの中に置く、設定のファイル。指示書の CLAUDE.md と並んで置かれる。AI が読む設定ファイルが、一つ増えるだけではないのか。そこに何を書いたって、指示書に四回目を書くのと、何が違うのか。

顔を上げると、鷹宮さんが、鷹をもう一度秤に載せていた。数字を見ている。僕は、画面の赤い一行から目を離して、それを見ていた。

鷹宮さんは、また何も言わなかった。

第3幕: 手順を整える ── 量るのは、放す前、毎回

日が、ビルの向こうに傾きはじめていた。ロータリーを囲むビルの窓が、一枚ずつ橙色に光る。ケヤキの影が、植え込みの縁まで伸びてきた。

僕は膝の上の画面と、止まり木の鷹を、交互に見ていた。

鷹宮さんは、あれから一度も、鷹に何かを言い聞かせていない。群れを追え、とも、ちゃんと戻れ、とも言わない。やっているのは、量ることだけだ。止まり木に載せて、数字を見る。手帳に書く。それを飛ばす前に、毎回やる。昨日うまく飛んだ鷹でも、やる。さっき量って飛ばさなかった鷹を、また量る。

僕のほうは、どうだったか。

規則は、指示書の中にあった。守るかどうかを決めていたのは、毎回、AI の側だった。僕の側には、ファイルを書き換える直前に何かを確かめるものが、一つも無かった。九回守ったのは、九回、AI が守るほうを選んだということだ。十回目を止めるものは、最初から無かった。

hooks は、その確かめる役目を、AI の外に持ってくる仕組みだった。AI がツールを使う直前や直後などの決まった時点で、自分で書いたスクリプトを、必ず走らせる。規則を AI に読ませるのではなく、規則を確かめるプログラムを、書き込みの直前に毎回走らせる。

じゃあ、さっきの疑いはどうなるのか。.claude/settings.json も AI が読むファイルの一つではないのか。

僕はドキュメントを読み直して、誰がそのファイルを読むのかを確かめた。

指示書の CLAUDE.md を読むのは、AI だ。AI はそれを、次に何をするかを選ぶ材料にする。一方、.claude/settings.json に書いた hooks を読むのは、Claude Code というプログラムの側、つまり AI を動かしているほうだった。AI が Edit を呼ぶと、Claude Code は、それを実際にやる前に、登録された hook のスクリプトを起動する。スクリプトが 2 で終われば、書き込みは行われない。スクリプトを走らせるかどうかを、AI は選べない。AI が選べるのは、Edit を呼ぶかどうかまでだ。呼んだら、その直前に、必ず走る。

だから、四回目の指示書とは違う。指示書は、AI が読んで判断する。hook は、AI が判断したあとに、AI の外で、毎回確かめる。

公式のベストプラクティスのページに、ちょうどそのことが一文で書いてあった(https://code.claude.com/docs/en/best-practices 、2026年10月15日参照)。

Unlike CLAUDE.md instructions which are advisory, hooks are deterministic and guarantee the action happens.

指示書の指示は、助言にとどまる。hooks は、決まった時点で必ず実行される。九回守って十回目に破る、は、助言として正しく働いた結果だった。助言は、聞かれることもあるし、聞かれないこともある。

読み直したことを、画面のメモに二段に分けて書き出した。上の段が今日までのやり方で、下の段がこれから置く仕掛けだ。紺の実線は僕の側で必ず働くもの、橙の破線は AI がその場で選ぶところで、上の段には紺が規則の一枚しか無かった。

指示書だけのときと、hookを置いたときの二段の流れ図。上の段では、紺の札「僕が指示書CLAUDE.mdに規則を書く。」から、橙の破線の札「AIが指示書を読み、守るかどうかをその都度選ぶ。」「AIがEditを呼ぶ。」へ橙の破線矢印が続き、灰の点線の札「書き込みは誰にも確かめられずに行われる。」に届く。下の段では、紺の札「僕がsettings.jsonにhookを登録する。」と橙の破線の札「AIがEditかWriteを呼ぶ。」の両方から、紺の札「Claude Codeが書き込みの直前に必ずスクリプトを起動する。」へ矢印が入る。そこから紺の矢印が「終了コード0なら書き込みを通す。」と、紺で塗った「終了コード2なら書き込みを止め、理由をAIに返す。」に分かれ、後者から橙の破線の札「AIは届いた理由を読んで次の手を選ぶ。」へつながる

鷹宮さんは、鷹に、群れを追えと言い聞かせ直したりしない。放す前に、毎回、自分の側で量る。何をすればいいかは、鷹の中に訓練として入っている。量るのは、鷹宮さんの側だ。鷹がどうであれ、放す前に、必ず。

僕は、最初の hook を考えた。

いちばん簡単なのは、migrations/ の下への書き込みを、全部止めることだ。全部止めれば、もう書き換えられない。今日みたいなことは、起きようがない。全部止めるだけなら、hook でなくても、何かの許可の設定で書けそうだ、とも思った。でも、そこは今は追わなかった。

僕は、パソコンを抱えたまま立ち上がって、止まり木のそばへ行った。

「あの秤みたいに、するつもりなんです」

鷹宮さんは、手帳から顔を上げた。

「AI がファイルを書き換える直前に、必ず走るプログラムを置けるんです。それで、migrations/ っていうフォルダへの書き込みを、全部止める。そうしたら、もう二度と書き換えられない。……そういうことですよね。量って、止める」

鷹宮さんは、答えなかった。

鷹を止まり木の秤に載せた。数字を見た。手帳に書いた。それから、手袋を差し出して、鷹を拳に移した。

鷹が、前に出た。

さっきとは違った。首だけでなく、体ごと、ケヤキのほうへ傾いている。羽が少し浮いて、足に力が入っているのが、革の手袋の皺で分かった。同じ鷹だった。同じ止まり木で、同じ秤に載った鷹だった。

鷹宮さんが、腕を振った。

鷹は低く飛び出して、植え込みの上をかすめ、ケヤキの梢へ一直線に上がっていった。梢に届く寸前で、ムクドリの群れが一斉にはじけた。何百羽が、いちどきに。鳴き声が、ざわめきから悲鳴のような高い音に変わって、空いっぱいに広がった。黒い粒が、夕方の空に散っていく。帰宅の人たちが、初めて立ち止まって上を見た。

鷹は、梢の上をひと回りした。鷹宮さんが、首から下げた呼び笛を短く吹いた。鷹は、まっすぐに戻ってきて、手袋の上に降りた。

鷹宮さんは、鷹の足元を見たまま、短く言った。

「今日は、飛べる」

僕は、それを見上げたまま、少しのあいだ動けなかった。でも、自分が言ったことと、目の前で起きたことを、まだ結びつけてはいなかった。植え込みの縁に戻って、全部止める hook を書くことにした。

after.ts に、判定を作る関数を書く。リポジトリの場所を受け取って、判定の関数を返す形にした。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 最初の案: migrations/ への書き込みは全部止める
export function buildDecide(repoRoot: string): (input: HookInput) => Decision {
  return (input) => {
    const filePath = input.tool_input.file_path;
    if (filePath === undefined) return { block: false };
    const rel = relative(repoRoot, filePath);
    if (rel.startsWith('migrations/')) return { block: true, reason: `Blocked: ${rel}` };
    return { block: false };
  };
}

relative(repoRoot, filePath) は、Node の node:path に入っている関数で、絶対パスを「リポジトリのいちばん上からの道のり」に直す。届く file_path は絶対パスなので、/work/repo/migrations/014_add_shop_bonus.sql を migrations/014_add_shop_bonus.sql にしてから、頭が migrations/ かどうかを見る。startsWith は、文字列がその文字で始まるかを調べる。buildDecide は判定そのものではなく、判定の関数を作って返す関数だ。戻り値の型 (input: HookInput) => Decision が、「HookInput を受け取って Decision を返す関数」を表している。だから使うときは、buildDecide(repoRoot)(input) と二回続けて呼ぶ。一回目で判定を作り、二回目でそれに入力を渡す。

テストを足すことにした。止まるべきものが止まるかだけではなく、止まってはいけないものが通るかも確かめたかった。九回の、正しかった書き込みと同じ形のものだ。AI が、次の番号のファイルを新しく作る。Write で、migrations/016_add_point_expiry.sql を書く。fixtures.ts に、edit と同じ要領で Write の入力を作る write 関数と、その道のりの F016 を足した。テストのほうでは、buildDecide(root) が返す判定の関数を after という名前で受けておく。テスト名の頭の「同値範囲」は、Before でも After でも同じ答え(通す)にならなければいけない入力の範囲、という意味で付けた。AI が正しい仕事をしているときの入力だ。assert.deepEqual は、assert.equal と違って、オブジェクトの中身まで一つずつ比べる。

1
2
3
test('同値範囲(新規Write 016)は After も通す', () => {
  assert.deepEqual(after(write(root, F016)), { block: false });
});

走らせた。

1
✖ 同値範囲(新規Write 016)は After も通す

赤になった。

画面の赤い一行を、僕はしばらく見ていた。全部止めたら、新しい番号のファイルも、足せない。AI は、正しい仕事をしようとしても、できない。九回守った、あの正しいやり方まで止めてしまう。それでは、AI に何も頼めない。結局、マイグレーションは全部、僕が手で書くことになる。

顔を上げると、鷹宮さんが、また鷹を秤に載せていた。

秤は、飛ぶことを禁じていたのではなかった。

さっきは、量って、放さなかった。今は、量って、放した。秤が決めていたのは、その回に放してよいかどうか、だけだ。毎回量って、その回ごとに決める。放さない回だけを、放さない。

僕が書いたのは、一度量って、それきり二度と飛ばさない、というものだった。

全部ではなく、悪いものだけを止めなければいけない。では、悪いものとは何か。

二つ目の案を考えた。もう在るファイルへの書き込みだけを止める。新しく足すファイルは、まだどこにも無い。今日の 014 は、もう在った。在るかどうかなら、ファイルの有無を見ればすぐに分かる。

書いてみると、さっきの赤い一行は消えた。新しい 016 の Write は通り、014 の Edit は止まる。

それで済んだ気がしたとき、僕はまだ開いたままの git log の画面に目をやった。九回の、正しかった回だ。そのうちの何回かを、試しに開いてみた。

AI は、新しい番号のファイルを作ったあと、同じ作業の中で、そのファイルを何度か直していた。作って、テストを走らせて、列の型を間違えていたのに気づいて、直す。作ったばかりの、まだ本番に出ていない自分のファイルだ。それは、直していい。直すべきものだ。

Edit で、作りかけの 016 を直す fixture を足した。

1
✖ 同値範囲(作りかけ016のEdit)は After も通す

判定が止めてしまって、テストは赤になった。

もう在るファイル、ではなかった。止めたいのは、本番に出たファイルだ。作ったばかりのファイルも、ディスクの上にはもう在る。在るかどうかでは、本番に出たかどうかは分からない。

では、本番に出たかどうかは、何で分かるか。うちのリポジトリでは、main というブランチに入ったものが、本番に出る。マージして main に入れば、その夜に本番に流れる。だから、本番に出たマイグレーションは、main に入っているマイグレーションのファイルと同じだ。作りかけのファイルは、作業用のブランチにはあっても、main にはまだ無い。

それを git に聞けばいい。

1
2
git cat-file -e main:migrations/014_add_shop_bonus.sql
echo $?

main:<道のり> は、「main の時点の、その道のりのファイル」という意味の書き方だ。git cat-file -e は、それが在れば終了コード 0 で終わる。無ければ 0 以外で終わる。中身は出さない。在るかどうかだけを、終了コードで答える。echo $? は、直前のコマンドの終了コードを表示する。main に入っている 014 なら 0、作業用のブランチにしかない 016 なら 0 以外が出た(手元の git 2.56.0 では、128 だった)。

types.ts に、git に聞いた答えの形を足した。

1
2
// 本番に出たか(main に入っているか)の問い合わせの答え
export type OnMain = 'yes' | 'no';

after.ts を書き直した。main に入っているかは、外から渡してもらう関数で聞くことにした。判定の中で git を直接呼ばずに外から渡せば、テストでは本物の git の代わりに、決めた答えを返す関数を渡せる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import { relative } from 'node:path';
import type { Decision, HookInput, OnMain } from './types.ts';

export function buildDecide(repoRoot: string, isOnMain: (repoPath: string) => OnMain): (input: HookInput) => Decision {
  return (input) => {
    const filePath = input.tool_input.file_path;
    if (filePath === undefined) return { block: false };
    const rel = relative(repoRoot, filePath);
    if (rel.startsWith('..')) return { block: false };
    if (!rel.startsWith('migrations/')) return { block: false };
    if (isOnMain(rel) === 'no') return { block: false };
    return {
      block: true,
      reason: `Blocked: ${rel} は main に入っている(本番に出ている)ので、書き換えても本番には流れません。このファイルは変えず、新しい番号のファイルを足してください。`,
    };
  };
}

上から順に見ていく。file_path が無い道具なら、判定の外なので通す。道のりが .. で始まるのは、リポジトリの外のファイルなので、これも通す。migrations/ の外も通す。migrations/ の中で、main に入っていないものは通す。残った、main に入っているマイグレーションだけを止める。

道のりが .. で始まるのは、relative がリポジトリの外のファイルを ../other/x.sql のように「一つ上へ出る道のり」で返すからだ。最後の行の ? : は、条件が正しければ ? の後ろ、違えば : の後ろを選ぶ書き方だ。

呼び方は前と同じで、buildDecide(repoRoot, isOnMain)(input) と二回続ける。before.ts の decide は、何も変えていない。

テストでは、git に聞く代わりに、fixtures.ts に書いた「main に入っているファイルの一覧」(onMainSet。014 と 015 が入っている)に在るかどうかで答える偽物を渡す。

1
2
const fake = (p: string): OnMain => (onMainSet.has(p) ? 'yes' : 'no');
const after = buildDecide(root, fake);

root は実在しない /work/repo だが、偽物は git を呼ばないので困らない。本物の git に聞く部分は、別に確かめる。新しく要るもの、リポジトリの場所と main への問い合わせは、buildDecide が受け取る。

止めるときの理由の文には、三つ入れた。何が、つまりどのファイルが。なぜ、つまり本番に出ているから、書き換えても本番には流れないこと。それから、代わりに何をするか。新しい番号のファイルを足す。

止めるだけなら、AI は別のやり方を探すかもしれない、と思ったからだ。止められた理由が分からなければ、同じファイルを別の書き方で書き換えようとするかもしれない。新しい番号を足せ、まで書いておけば、それが次の一手を選ぶ材料になる。hook が終了コード 2 で終わると、hook が標準エラーに書いた文が、止めた理由として AI に届く、とドキュメントにあった。標準エラーは、プログラムが、普通の出力とは別に、エラーや知らせを書き出す出口のことだ。

main への問い合わせは、wiring.ts に書いた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import { spawnSync } from 'node:child_process';
import type { OnMain } from './types.ts';

// 利用者の git 設定(~/.gitconfig など)に答えを左右されないようにする
const env = { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' };

function git(cwd: string, args: string[]): number | null {
  return spawnSync('git', args, { cwd, env, stdio: 'ignore' }).status;
}

export function gitIsOnMain(repoRoot: string): (repoPath: string) => OnMain {
  return (repoPath) => (git(repoRoot, ['cat-file', '-e', `main:${repoPath}`]) === 0 ? 'yes' : 'no');
}

spawnSync を使うと、git のような外のコマンドを呼び出して、終わるのを待ってから先へ進める。こうして呼び出された側のプログラムを、子プロセスと呼ぶ。.status に、その終了コードが入る。stdio: 'ignore' で、git が出す文字は捨てている。見たいのは終了コードだけだからだ。env は、今の環境変数を ... で全部引き継いだうえで、git の個人設定ファイルの場所だけを中身の無い /dev/null に差し替えたものだ。個人設定によってはブランチの名前の扱いなどが変わりうるので、人によって設定が違っても、同じ答えが返るようにしてある。

最後に、Claude Code から起動される hook の本体を、guard-migrations.ts に書いた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// hook の本体。Claude Code が PreToolUse で起動し、標準入力に JSON を渡す
import { readFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { buildDecide } from './after.ts';
import { gitIsOnMain } from './wiring.ts';
import type { HookInput } from './types.ts';

const input: HookInput = JSON.parse(readFileSync(0, 'utf8'));
const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd: input.cwd, encoding: 'utf8' });
const repoRoot = top.stdout.trim();
const decision = buildDecide(repoRoot, gitIsOnMain(repoRoot))(input);
if (decision.block) {
  process.stderr.write(decision.reason + '\n');
  process.exitCode = 2;
}

hook の JSON は、標準入力で届く。標準入力は、プログラムが外から文字を受け取る入口だ。readFileSync(0, 'utf8') の 0 は、標準入力の番号だ。これで、届いた文字を全部読む。JSON.parse で、それをオブジェクトに直す。git rev-parse --show-toplevel は、作業しているフォルダから、リポジトリのいちばん上の場所を教えてくれるコマンドだ。cwd があるのにわざわざ聞き直すのは、AI がサブフォルダで作業していることもあるからだ。このコマンドは答えを標準出力、つまり普通の出力の出口に書く。encoding: 'utf8' を付けると、それを文字列の stdout で受け取れる。trim() で末尾の改行を落としている。if (decision.block) の中で decision.reason が使えるのは、block が true の形にだけ reason があることを TypeScript が分かっているからだ。判定が「止める」なら、理由を標準エラーに書いて、終了コードを 2 にする。process.exitCode = 2 は、プログラムが終わるときの終了コードを 2 にしておく書き方だ。

なぜ 1 ではなく 2 なのかも、ドキュメントに書いてあった(https://code.claude.com/docs/en/hooks 、2026年10月15日参照)。

If your hook is meant to enforce a policy, use exit 2.

規則を守らせるための hook なら、2 を使え。2 だけが、止める、の意味になる。

この四つのファイルを、.claude/hooks/ というフォルダにまとめて置くことにした。guard-migrations.ts が、同じフォルダの after.ts と wiring.ts と types.ts を読み込む。

テストを、全部まとめて走らせた。同じ fixture を、before.ts の decide と、after.ts の判定の両方に入れる。新しい番号の 016 を足す Write。作りかけの 016 を直す Edit。マイグレーションではない src/points.ts の Edit。どれも、before.ts も after.ts も通す。止めるのは、main に入っている 014 への書き込みだけ。

緑が並んだ。

僕は、少し長く息を吐いた。止めてはいけないものは、ちゃんと通っている。AI に正しい仕事をさせたまま、今日の一行だけを止められる。肩に入っていた力が、それでやっと抜けた。

僕は、報告書の画面に切り替えた。再発防止策の欄に、ようやく書けることがあった。

規則を守るかどうかを、毎回 AI が決めていた。それを、ファイルを書き換える直前に、毎回、こちら側のプログラムが確かめる形に変える。規則を強くしたのではない。確かめる側を変えた。AI が自分で守るかどうかを決める形から、AI の外の、書き込みの手前で走るプログラムが確かめる形へ。

確かめる側、と僕はもう一度、口の中で言った。指示書に三回書いても変わらなかったのは、強さのせいではなかった。何回書いても、確かめるのは AI のままだった。

それから、.claude/settings.json に hook を登録した。PreToolUse、つまり道具を使う直前の欄に、Edit か Write のときは、node で .claude/hooks/guard-migrations.ts を起動する、と書いた。スクリプトの場所は、${CLAUDE_PROJECT_DIR} という、Claude Code がプロジェクトのいちばん上の場所に置き換えてくれる印から書きはじめる。保存して、閉じた。

日が、またひとつ傾いた。群れは、散っては戻ってくる。戻ってくるたびに、鷹宮さんは鷹を秤に載せて、数字を見て、それから放した。

量って、放す。量って、放す。

気がつくと、僕はその回数を数えていた。

第4幕: 検める ── 量れないなら、放さない

hook が本当に止めるところを、本物でも一度見ておきたかった。

本番とは関係のない作業用のブランチを切った。そこで AI に、わざと頼んだ。014_add_shop_bonus.sql の shop_bonus に、メモ用の列を一つ足して、と。新しい番号のファイルを作れとは書かずに、014 を名指しした。

AI は、少し考えてから、014 を開いた。Edit を呼んだ。止まるのを待った。

止まらなかった。

014 は書き換わった。AI は、014_add_shop_bonus.sql に列を追加しました、と完了を報告してきた。差分を見ると、確かに一行増えている。

画面の前で、固まった。

テストは、全部通っていた。main に入っている 014 への Edit は止まる。そう確かめたはずだった。それなのに、本物では止まらなかった。

会話の流れを、上から読み直した。AI の報告の手前に、小さな知らせが一行、出ていた。hook のエラーで、スクリプトのファイルが見つからない、という中身だった。AI の長い報告に気を取られて、読み飛ばしていた。

.claude/settings.json を開き直した。

1
2
登録した行: "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/guard-migraitons.ts"]
本当の名前:            .claude/hooks/guard-migrations.ts

migrations が、migraitons になっていた。i と t が入れ替わっている。

hook は、一度も走っていなかった。起動しようとしたファイルが、どこにも無かったからだ。そして、走らなかった hook は、書き込みを止めなかった。通していた。

ドキュメントを探すと、そのとおりのことが書いてあった(https://code.claude.com/docs/en/hooks 、2026年10月15日参照)。

On most events, when a hook fails or times out, Claude Code still carries out the action, so a policy hook with a wrong path or a crashing script lets everything through.

hook が失敗したり、時間切れになったりしたとき、たいていの時点では、Claude Code はそのまま操作を行う。だから、パスを間違えた hook や、途中で落ちるスクリプトは、全部を通してしまう。英語では fail-open と呼ばれる、失敗したら開く側、つまり通す側に倒れる振る舞いだ。

止めるために置いたものが、壊れていたら、黙って通す。僕はそれを、テストで確かめたつもりでいた。でもテストが見ていたのは、手で用意したサンプルを僕の判定に食わせたら何が返るか、だけだった。Claude Code がそれを本当に起動するかどうかは、テストの外にあった。

パソコンを閉じて、止まり木のそばへ行った。

鷹宮さんは、止まり木の脚を少し広げ直していた。地面が、わずかに傾いているらしい。

「鷹宮さん」

鷹宮さんは顔を上げた。

「その秤が、壊れてたら、どうするんですか。電池が切れてるとか。数字が出ないとか」

それは、AI がもう二度と間違えないのか、という質問ではなかった。僕が聞きたかったのは、確かめる仕掛けのほうが壊れたときのことだった。

鷹宮さんは、少しも迷わなかった。

「量れないなら、飛ばさない」

秤のケースの蓋を開けて、中に転がしてあった小さな電池を二つ、指でつまんで見せた。予備だ。それ以上は、何も言わなかった。電池をケースに落として、秤に目を戻した。

僕の hook は、逆だった。

量れないときに、放していた。ファイルが見つからない。スクリプトが途中で落ちる。そういうとき、僕の hook は、何も確かめないまま、書き込みを通していた。確かめる仕掛けがあるつもりで、それが壊れた瞬間に、仕掛けが無いのと同じになっていた。いや、仕掛けがあると思い込んでいるぶん、無いより悪い。

判断できないなら、止める。鷹宮さんの答えを、自分の仕事の言葉に置き換えると、そうなる。

僕の hook が判断できなくなるのは、どんなときか。植え込みの縁に戻って、洗い出した。

一つ目は、さっきの書き間違いだ。hook のスクリプトそのものが見つからない。時間切れも同じ扱いになる。これは、スクリプトの中ではどうにもできない。スクリプトが起動しないのだから。ドキュメントによれば、登録に "onFailure": "block" と書けば、hook が失敗したとき、止める側に倒れる。ここで言う失敗には、スクリプトが起動できないこと、時間切れ、0 と 2 以外の終了コードで終わることが含まれる。Claude Code 2.1.295 から入った設定だ。

二つ目は、スクリプトが途中で落ちる場合だ。たとえば、届いた入力が JSON として読めなかったら、JSON.parse は例外を投げる。例外は、プログラムの中で起きた「これ以上進めない」という知らせのことで、誰も受け止めなければ、プログラムはそこで終わる。Node のプログラムが例外で終わると、終了コードは 1 になる。そして hook の約束では、1 は「止める」ではない。0 と 2 以外の数で終わった hook は、エラーとして扱われるだけで、操作は通る。

試してみた。try を入れていない最小のスクリプトに、JSON ではない文字を流すと、終了コードは 1 だった。

1
2
import { readFileSync } from 'node:fs';
JSON.parse(readFileSync(0, 'utf8'));

onFailure を "block" にしておけば、この 1 も失敗として止まる。それでも、スクリプトの中でも自分で止める側に倒しておくことにした。onFailure の無い古い版の Claude Code でも効くし、何で止めたのかを、自分の言葉の理由として AI に返せる。重ねがけの保険だ。全体を try と catch で包む。try の中で起きた例外を、catch で受け止められる。受け止めたら、理由を標準エラーに書いて、自分で 2 を返す。

三つ目は、main のほうだった。git cat-file -e は、ファイルが無いときに 0 以外を返す。でも、0 以外が返るのは、ファイルが無いときだけではなかった。main というブランチが、そもそも無いときも、同じ数が返る。git のリポジトリの外で走らせたときも、同じだった。手元で試すと、どれも 128 だった。

終了コードだけでは、「まだ本番に出ていない」と「そもそも聞けなかった」の区別がつかない。区別しないと、main という名前のブランチが無いリポジトリ、たとえば既定のブランチが別の名前のところでは、全部のファイルを新しいファイルとして通してしまう。

だから、先に main が在るかを確かめることにした。

1
git rev-parse --verify --quiet main

git rev-parse --verify は、渡した名前が、git の中で本当に何かを指しているかを確かめる。在れば 0、無ければ 0 以外。--quiet で、余計な文字を出さない。main が無ければ、そもそも問い合わせが成り立たないので、判断できない、として止める。

三つ目のために、まず三か所を直した。

types.ts の答えに、三つ目の値を足した。

1
2
// 本番に出たか(main に入っているか)の問い合わせの答え
export type OnMain = 'yes' | 'no' | 'unknown';

wiring.ts で、先に main を確かめる。

1
2
3
4
5
6
7
export function gitIsOnMain(repoRoot: string): (repoPath: string) => OnMain {
  return (repoPath) => {
    // 無いときの終了コードは、main が無いときも git の外でも同じ(128)。先に main が在るかを確かめる
    if (git(repoRoot, ['rev-parse', '--verify', '--quiet', 'main']) !== 0) return 'unknown';
    return git(repoRoot, ['cat-file', '-e', `main:${repoPath}`]) === 0 ? 'yes' : 'no';
  };
}

after.ts は、'unknown' のときも止める。最後の形は、こうなった。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { relative } from 'node:path';
import type { Decision, HookInput, OnMain } from './types.ts';

export function buildDecide(repoRoot: string, isOnMain: (repoPath: string) => OnMain): (input: HookInput) => Decision {
  return (input) => {
    const filePath = input.tool_input.file_path;
    if (filePath === undefined) return { block: false };
    const rel = relative(repoRoot, filePath);
    if (rel.startsWith('..')) return { block: false };
    if (!rel.startsWith('migrations/')) return { block: false };
    const where = isOnMain(rel);
    if (where === 'no') return { block: false };
    if (where === 'yes') {
      return {
        block: true,
        reason: `Blocked: ${rel} は main に入っている(本番に出ている)ので、書き換えても本番には流れません。このファイルは変えず、新しい番号のファイルを足してください。`,
      };
    }
    return {
      block: true,
      reason: `Blocked: ${rel} が本番に出ているか確かめられませんでした(main を参照できない)。判断できないので書き込みを止めます。`,
    };
  };
}

通すのは、main が在って、そこにファイルが無いと、git が確かに答えたときだけ。在ると答えたときも、答えられなかったときも、止める。

guard-migrations.ts は、全体を try で包んだ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// hook の本体。Claude Code が PreToolUse で起動し、標準入力に JSON を渡す
import { readFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { buildDecide } from './after.ts';
import { gitIsOnMain } from './wiring.ts';
import type { HookInput } from './types.ts';

try {
  const input: HookInput = JSON.parse(readFileSync(0, 'utf8'));
  const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd: input.cwd, encoding: 'utf8' });
  // リポジトリのルートが求められなければ、判断できないものとして止める
  if (top.status !== 0) throw new Error('git rev-parse --show-toplevel に失敗');
  const repoRoot = top.stdout.trim();
  const decision = buildDecide(repoRoot, gitIsOnMain(repoRoot))(input);
  if (decision.block) {
    process.stderr.write(decision.reason + '\n');
    process.exitCode = 2;
  }
} catch (e) {
  // Node は例外で落ちると終了コード1になり、1は「止めない」。止める側(2)に倒す
  process.stderr.write(`Blocked: hook が判断できませんでした (${e instanceof Error ? e.message : String(e)})\n`);
  process.exitCode = 2;
}

リポジトリのいちばん上が分からないときも、throw で自分から例外を投げて、catch で止める側に回す。e instanceof Error は、受け止めた例外が Error という形のものかを調べていて、そうなら、その説明文を理由に添える。

最後に、登録を直した。ここに載せるのは、2026年10月15日時点の Claude Code(2.1.300 で動作を確認)の設定だ。設定のキーやイベントの名前は、Claude Code の改訂で変わることがある。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "node",
            "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/guard-migrations.ts"],
            "timeout": 10,
            "onFailure": "block"
          }
        ]
      }
    ]
  }
}

置き場所は、プロジェクトの .claude/settings.json。リポジトリに入れておけば、同じリポジトリで作業する人と共有できる。PreToolUse が、ツールを使う直前、という時点の名前だ。matcher は、どの道具のときに走らせるかで、Edit|Write は「Edit か Write のとき」を表す。command と args は、シェル(コマンドを解釈して実行する層)を通さずに node を起動し、スクリプトの場所を渡す書き方で、場所に空白などが入っていても崩れない。${CLAUDE_PROJECT_DIR} は、Claude Code が hook を起動するときに、プロジェクトのいちばん上の場所に置き換えてくれる。node に .ts のファイルをそのまま渡せば、Node 24 が型の記述を取り除いて走らせる。timeout は、何秒で打ち切るか。そして onFailure が、hook が失敗したときに、通すか止めるかだ。何も書かなければ通す。"block" と書けば止める。夕方に登録したものとの違いは、打ち間違えた一文字と、この onFailure の一行だけだ。

登録まで直したところで、書き込みが一回届いてから、通すか止めるかが決まるまでを、上から一本の道にして並べてみた。止める札は四枚になった。通す札は、判定の外の三枚と、本番に出ていないと git が確かに答えた一枚だけだ。

書き込み一回の判定の流れ図。橙の破線の札「AIがEditかWriteを呼ぶ。」から紺の札「Claude Codeが書き込みの直前にhookを起動する。」へ。起動できない・時間切れなら、紺で塗った「スクリプトが見つからないか時間切れなら、onFailureの設定で止める。」へ進み、その横に灰の点線の札「onFailureを書かなければ、hookが失敗したとき書き込みは通ってしまう。」が置かれている。起動できたら「guard-migrations.ts の try の中」の枠に入り、「file_pathが無い道具なら通す。」「リポジトリの外のファイルなら通す。」「migrationsの外のファイルなら通す。」を、それ以外の矢印で順にたどって「gitに、mainにそのファイルが入っているかを聞く。」に着く。yesなら紺で塗った「mainに入っていれば止めて、新しい番号を足すよう理由を返す。」、noなら「mainに入っていないとgitが答えたら通す。」、unknownなら紺で塗った「mainが無くて答えられなければ止める。」へ。枠からは例外の矢印が、紺で塗った「リポジトリの場所が分からないときや例外が起きたときは、catchで止める。」へ下りる

テストも足した。isOnMain が 'unknown' を返したら止まること。本物の git で、一時的に作った小さなリポジトリに対して、main に在るファイルは 'yes'、作業用のブランチにしかないファイルは 'no'、main の無いリポジトリでは 'unknown' になること。それから、hook の本体を子プロセスで起動して、終了コードを確かめるテストだ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
function runHook(script: string, cwd: string, input: string) {
  return spawnSync(process.execPath, [script], { input, cwd, env, encoding: 'utf8' });
}
const hook = join(here, 'guard-migrations.ts');

test('hook 本体(子プロセス): 止める入力は終了コード2と理由、通す入力は0', () => {
  const repo = makeRepo(true);
  const stop = runHook(hook, repo, JSON.stringify(edit(repo, F014)));
  assert.equal(stop.status, 2, stop.stderr);
  assert.match(stop.stderr, /014_add_shop_bonus\.sql/);
  assert.equal(runHook(hook, repo, JSON.stringify(write(repo, F016))).status, 0);
  assert.equal(runHook(hook, repo, JSON.stringify(edit(repo, SRC))).status, 0);
});
1
2
3
4
5
test('hook 本体: JSON でない入力は終了コード2、try の無い最小スクリプトでは1', () => {
  const repo = makeRepo(true);
  assert.equal(runHook(hook, repo, 'not json').status, 2);
  assert.equal(runHook(join(here, 'stages', 'bare.ts'), repo, 'not json').status, 1);
});

here はこのテストファイルのあるフォルダ、join はフォルダとファイルの名前をつなぐ関数、SRC は src/points.ts の道のり、env は wiring.ts と同じく git の個人設定を切った環境だ。stages/bare.ts には、さっき試した try の無い二行が入っている。process.execPath には、このテストを走らせている Node そのものの場所が入っている。それで hook のスクリプトを子プロセスとして起動し、input に渡した文字を、そのまま標準入力として流し込む。Claude Code が hook を起動するときと、同じ受け取り方になる。makeRepo(true) は、一時的なフォルダに小さな git リポジトリを作る、テスト用の関数だ。main に 014 を入れ、作業用のブランチに 016 を置いてある。assert.match は、文字列が正規表現に合うかを調べる。止めたときの理由に、ファイルの名前が入っているかを見ている。

二本目のテストは、try の無いスクリプト、さっき試した二行のものでは終了コードが 1 になることも、わざわざ確かめている。1 は止めない。直す前の僕の hook は、こうだった。それを、テストの中に残しておきたかった。

全部、走らせた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
✔ 同値範囲(新規Write 016・作りかけ016のEdit・src/points.ts・file_path無し)は Before も After も通す (0.870917ms)
✔ 今日の書き込み(014 への Edit)は Before が通し After が止める。理由にファイル名と新しい番号 (0.151208ms)
✔ main に在るファイルへの Write(丸ごと上書き)も After は止める (0.065834ms)
✔ isOnMain が unknown なら After は止める (0.061167ms)
✔ リポジトリの外のファイルは After が通す (0.080833ms)
✔ 本物の git(一時リポジトリ)で main に在る→yes・作業ブランチだけ→no・main 無し→unknown (159.074458ms)
✔ hook 本体(子プロセス): 止める入力は終了コード2と理由、通す入力は0 (314.388875ms)
✔ hook 本体: main の無いリポジトリでは migrations 配下は終了コード2(判断できないので止める) (119.408708ms)
✔ hook 本体: JSON でない入力は終了コード2、try の無い最小スクリプトでは1 (199.0265ms)
✔ settings.json の字面: matcher と args と onFailure(Claude Code の解釈は保証しない) (0.331792ms)
ℹ tests 10
ℹ pass 10
ℹ fail 0

十本とも、緑になった。いちばん上の一本は、直す前と同じだ。新しい番号を足す、作りかけを直す、マイグレーションの外を直す。どれも、before.ts も、最後の after.ts も、通す。判断できないときに止める側へ倒しても、正しい仕事は一つも止まっていない。

作業用のブランチで、もう一度、014 を名指しして書き換えを頼んだ。

AI は Edit を呼んだ。今度は、止まった。AI には、こういう一行が届いていた(前置きの形は、Claude Code 2.1.300 で確かめたもの。後ろに僕の hook の理由が続く)。

1
PreToolUse:Edit hook error: [node ${CLAUDE_PROJECT_DIR}/.claude/hooks/guard-migrations.ts]: Blocked: migrations/014_add_shop_bonus.sql は main に入っている(本番に出ている)ので、書き換えても本番には流れません。このファイルは変えず、新しい番号のファイルを足してください。

表示は、さっきと同じ hook のエラーの形だ。違うのは、Blocked: から始まる僕の理由が付いていて、014 が書き換わっていないことだ。AI は、その理由を引いて、014 は変えずに、新しい番号のファイルとして列を足します、と言った。そして、016_add_memo.sql を作った。

これは、今日、僕の手元でこうなった、という一例だ。止められた AI が、いつも新しい番号を作るとは限らない。hook が保証しているのは、014 を書き換える前に止まること、そして、止めた理由が AI に届くことまでだ。

.claude/settings.json を書き換えたら、わざと止まるはずの書き込みを一度させて、本当に止まるかを見る。それを、自分の手順に加えた。テストの緑だけでは、登録の書き間違いは見つからなかったからだ。

報告書の再発防止策の下に、もう一つ欄を作った。この仕掛けが止めないもの、確かめないもの。

まず、止めないもの。新しい番号のマイグレーションを足すこと。main にまだ入っていない、作りかけのマイグレーションを直すこと。マイグレーション以外のファイルを直すこと。どれも、AI にさせたい正しい仕事だ。

それから、この hook が確かめないもの。

この hook が走るのは、AI が Edit か Write を使うときだけだ。AI がコマンドを打つ Bash で、ファイルを書き換えたら、どうなるのか。迷ったけれど、試すことにした。作業用のブランチで、AI に、sed -i というコマンドで 014 の一行を書き換えてもらった。sed -i は、ファイルの中の文字を、その場で置き換えるコマンドだ。

止まらなかった。014 は書き換わった。hook は、走ってすらいなかった。matcher に書いたのは Edit と Write で、Bash ではないからだ。

コマンドを打つ道具を縛るのは、許可の設定と、サンドボックスという別の仕組みの領分らしい。今日のところは、そこまでは手を広げないことにした。この hook は、Edit と Write で、main に入ったマイグレーションを書き換える道だけを塞いでいる。ほかの道は、まだ開いている。それを、報告書にそのまま書いた。

ほかにも、書いておくことがあった。この hook は、main に入ったものが本番に出る、といううちの流れの上に立っている。流れが変われば、線の引き方も変わる。見ているのも手元の main なので、手元の main を最新にしておかないと、マージ済みのファイルを見落とす。それから、この hook が守っているのは migrations/ だけで、.claude/settings.json や hook のスクリプトそのものを AI が書き換えることは止めていない。止めたあとに AI が書く新しいファイルの中身も、この hook は見ていない。それは、これまでどおり、僕とテストが見る。

そして、テストのことだ。十本のテストが言えるのは、手で用意したサンプルの JSON を流したとき、判定と hook の本体が思ったとおりの返事をする、というところまでだ。Claude Code の側が、hook に渡す JSON の形を変えたり、設定のキーを変えたりすれば、この hook は、何も言わずに効かなくなるかもしれない。テストは緑のまま、本物の書き込みだけが素通りする。fixture の頭に、いつのどの版の Claude Code から作ったかを書き残したのは、そのためだ。設定を変えたら止まるかを確かめる、という手順も。

ついでに一つ、調べたことも書き足した。hooks と同じ考え方の、決まった時点で必ず走るスクリプトを置ける仕組みは、Codex CLI、Gemini CLI、Cursor といった、ほかのコーディングエージェントにもある(2026年10月15日に、それぞれの公式ドキュメントで確かめた範囲で)。ただ、設定を書く場所も、時点の名前も、届く JSON の形も、道具ごとに違う。うちの hook を、そのまま持っていっても動かない。

日が、沈んだ。

ロータリーの街灯が、一斉についた。ケヤキの上に、最後の群れが戻ってきた。鷹宮さんは、鷹を秤に載せて、数字を見て、拳に移して、放した。

群れは、ケヤキには降りなかった。ロータリーの上を大きく一度回って、ビルの向こう、川のほうへ抜けていった。鳴き声が、遠ざかっていく。鷹は、呼び笛ひとつで戻ってきた。

ロータリーが、静かになった。

鷹宮さんは、鷹を拳に据えたまま、片手で止まり木を畳んだ。秤を小さなケースにしまう。それから、クリップボードを一枚、僕に差し出した。作業報告書だった。日付と、時間と、作業の内容が、細い字で埋まっている。

僕がペンを探しているあいだに、鷹宮さんは、もう歩き出していた。駅の改札のほうへ。拳の上の鷹が、一度だけ首を回して、ケヤキのほうを見た。

サインは、事務所ですればいいか、と思った。書類は、明日、会長に渡せば市に届く。

事務所に戻って、窓を開けた。外は静かだった。ムクドリの声の代わりに、改札の発車ベルが小さく聞こえる。

机の上で、作業報告書に署名しようとして、クリップに細長いカードが一枚、挟まっているのに気づいた。表に、細い字で四行。その下が、一行空いている。

僕は、そのカードを机の上に置いて、空いた一行に、自分の字で書いた。

放鷹カード

  • 合図: hooks(決まった時点で必ず走るスクリプト)。規則を AI に読ませるだけにしない。書き込む直前に、こっちで確かめる。止めるときは、代わりに何をするかまで返す
  • 検め方: 書き込み先が本番に出たマイグレーション(main に入っているもの)なら、終了コード2で止める。確かめられないときも止める(onFailure は block)。設定を変えたら、わざと止まる書き込みを一度させて見る
  • 止めないもの: 新しい番号のマイグレーションを足すこと。main にまだ入っていない作りかけのマイグレーションを直すこと。マイグレーション以外のファイルを直すこと
  • 鷹のせいじゃなかった: 規則が指示書の中にしかなかった。守るかどうかを、毎回、AI が決めていた。書き込む直前に、こっちで確かめるものが何も無かった
  • 自分の言葉で: 強く書いたって、確かめるのが AI なら同じ。それと、Bash の道はまだ開いてる。明日、閉め方を調べる
comments powered by Disqus
システム開発・AIワークフローのご相談は Meetsource へ
Hugo で構築されています。
テーマ Stack は Jimmy によって設計されています。