先に結論です。本編が「置いたもの」のうち、Claude Code の外で動いているものは 2 種類しかありません。git の hook と、ファイルシステムのロックです。 どちらも Claude Code の hook(付録 C)より 1 つ内側にいて、Claude Code を通らない操作(人の端末・IDE・gh・サブエージェント・別の clone)にも効きます。その代わり、どちらも git と OS の契約の上に立っていて、その契約を知らないと「動いているように見えて動いていない」形で壊れます。第 1 部 第 4 回の 39 コミット、テストの並走で出た 53 errors、計画 192 の「ロックが 1 秒で消えていた」は、全部この種類の壊れ方です。
もう 1 つの結論は、git は「同じパス」しか衝突として扱わないということです。並列実行する作業場所が別々のファイル名で同じ番号を取ると、merge は黙って通ります。手元の計画書 196 本のうち、番号が重複しているものは 25 番あります。本編が「前例が次の計画を呼ぶ」と書いた機構(第 3 部 第 4 回)は、採番の側では並列実行に弱く、それを止める機構は手元にまだありません。
この章は git 側(G.1〜G.6)とプロセス側(G.7〜G.10)に分かれます。付録 C が Claude Code の hook の契約を、付録 F が「機構の裏打ち」型(依頼 ↔ 機構の裏打ち)を書きました。この章はその外側、git hook が実際に走る条件と、ロックが実際に排他になる条件を埋めます。
G.1 git hook の契約 —— いつ走り、どこにあり、何で飛ばせるか
git の hook は、githooks(5) が定める 4 つの条件で動きます(2026-09-05 確認。手元は git 2.39.5、公式ページの記述は 2.54.0)。
| 条件 | 公式の記述(要約) | 手元で効いている形 |
|---|---|---|
| 置き場所 | 既定は $GIT_DIR/hooks。core.hooksPath で変えられる | .git/hooks/pre-commit と .git/hooks/commit-msg。core.hooksPath は未設定 |
| 実行ビット | 実行ビットの無い hook は無視される | 設置スクリプトが chmod +x を打つ。存在検査の hook は -f ではなく -x を見る |
| 失敗の扱い | pre-commit が非ゼロで終わると、commit は作られる前に中止 | HAS_ERROR=1 を集めて最後に exit 1 |
| 迂回 | --no-verify は pre-commit と commit-msg を両方飛ばす | Claude Code 側の hook も --no-verify を含むコマンドは通す(G.5) |
pre-commit は引数を取らず、「提案されたコミットメッセージを取得する前」に呼ばれます。だからメッセージの規約は pre-commit では見られず、手元の [2/8] は「commit-msg で見る」と言って飛ばしています。commit-msg は 1 引数(メッセージファイルのパス)を受け、そのファイルを書き換えてもよい契約です。手元は書き換えず、先頭行が ^(feat|fix|refactor|test|docs|style|perf|chore|db|WIP): に合うかだけを見て、合わなければ非ゼロで返します。
もう 1 つ、契約の外にあるのに本編が前提にしていたことがあります。hook は版管理の対象ではありません。 githooks(5) にその旨の明文はなく、単に $GIT_DIR/hooks がリポジトリの内容(objects・refs)の外にあるだけです。git clone は objects と refs を運び、$GIT_DIR/hooks には .sample を並べます。手元の .git-hooks/*.sample がリポジトリに入っているのは、この外側にある実体を運ぶための写しの正です。第 1 部 第 4 回が「手順どおりに設置すると .git/hooks/pre-commit が生まれる」と書いた設置は、この写しを cp することです。
G.2 hook を配る 3 つの手段 —— 写す・指す・道具に任せる
hook が clone に付いてこない以上、配る手段を決めなければなりません。3 つあります。
| 手段 | 仕組み | 利点 | 手元で採らなかった / 採った理由 |
|---|---|---|---|
写す(cp + chmod +x) | .git-hooks/*.sample を .git/hooks/ へ複製 | 設置後は git の既定どおり動く。ブランチを切り替えても hook は変わらない | ✅ 採用。⚠️ 写しなので正と実体がずれる(G.4) |
指す(core.hooksPath) | git config core.hooksPath .git-hooks で置き場所そのものを変える | 設置が 1 行。checkout に追随する(ブランチごとの hook がそのまま効く) | 未採用。.sample の拡張子を外して実行ビットを付ける必要があり、config は clone ごとなので「clone を作ったら 1 行打つ」は残る |
| 道具に任せる(husky・lefthook・pre-commit framework) | npm install や bundle install の後処理で hook を書き込む | 依存を入れた瞬間に設置される | 未採用。手元の Ruby / Node はコンテナの中で動き、hook はホストで走る(付録 D の実行環境の線引き) |
core.hooksPath の効き方は、config を書かずに 1 回だけ試せます。
$ git -c core.hooksPath=.git-hooks rev-parse --git-path hooks
.git-hooks
-c はそのコマンド 1 回だけ有効な設定です。rev-parse --git-path hooks は「git がいま hook を探しに行く場所」を返すので、設置検査の hook(G.4)もこの 1 行で置き場所を引いています。.git/hooks を直書きしないのは、G.3 の worktree のためです。
「写す」を採った結果、手元には写しの鮮度という問題が残ります。.git-hooks/pre-commit.sample は 2026-02-10 の初版から 15 回変わり、うち直近の 5 回は 2026-08-28 から 08-31 に集中しています。設置版は cp した時点で止まります。今日の clone2 では両者は一致していますが(diff が空)、それは 8 月 31 日に配り直したからで、機構ではありません。設置検査の hook が中身の一致を見ない理由は G.4 に書きます。
G.3 worktree と clone —— 何が共有され、何が共有されないか
第 1 部 第 4 回の簡約版に git rev-parse --git-path hooks 2>/dev/null || echo .git/hooks とあり、コメントは「worktree でも正しい場所を見る」でした。手元で worktree を 1 つ作って引くと、こうなります。
$ git worktree add /tmp/wt_probe HEAD
$ cd /tmp/wt_probe
$ git rev-parse --git-dir
/private/var/works/private/multi_clones/typingtube_backend_2/.git/worktrees/wt_probe
$ git rev-parse --git-common-dir
/private/var/works/private/multi_clones/typingtube_backend_2/.git
$ git rev-parse --git-path hooks
/private/var/works/private/multi_clones/typingtube_backend_2/.git/hooks
linked worktree では .git はディレクトリではなくファイルで、gitdir: の 1 行で $GIT_DIR(worktree ごとの private な場所)を指し、そこから $GIT_COMMON_DIR(本体の .git)へ戻ります。git-worktree の DETAILS はこう書いています(2026-09-05 確認)。
Path resolution via
git rev-parse --git-pathuses either$GIT_DIRor$GIT_COMMON_DIRdepending on the path.
HEAD と index は $GIT_DIR(worktree ごと)、refs と objects と hooks は $GIT_COMMON_DIR(共有)です。だから worktree を足しても hook は設置し直さなくてよく、逆に clone を足すと hook は無いところから始まります。付録 D.4 が書いた auto memory と同じ線引きです。
| 項目 | linked worktree | 別 clone |
|---|---|---|
| objects / refs | 共有 | 別(fetch / push で同期) |
| HEAD / index / 作業ツリー | 別 | 別 |
hooks($GIT_COMMON_DIR/hooks) | 共有 | 別(設置が要る) |
config(core.hooksPath を含む) | 共有(extensions.worktreeConfig を有効にしない限り) | 別 |
| Claude Code の auto memory(付録 D.4) | 共有 | 別 |
tmp/(ロック・実行記録) | 別(作業ツリーの中) | 別 |
| Docker のコンテナ | 別(compose の project 名が変わる) | 別 |
⚠️ この表の 6 行目が、G.8 のロックに効きます。ロックは作業ツリーの tmp/ にあり、作業ツリーごとです。worktree 2 つで同じ DB コンテナを共有していたら、ロックは互いを見ません。 worktree 運用にするなら、ロックの置き場所を $GIT_COMMON_DIR の下か、DB コンテナの側へ移す設計が先に要ります。
G.4 コミットを見る 3 つ —— Claude Code の hook・git の hook・自動テスト
手元では、コミット 1 回に 3 つの機構が順に当たります。
flowchart LR
subgraph CC["Claude Code のセッション"]
B["Bash ツール<br/>git commit -m ..."] --> H3["PreToolUse hook<br/>require_git_hooks_installed.sh<br/>hooks の実在と -x を見る"]
end
H3 -- "exit 2(未設置)" --> STOP1["コマンドは走らない<br/>案内: bash scripts/setup-git-hooks.sh"]
H3 -- "exit 0" --> G["git commit"]
T["人の端末 / IDE / gh"] --> G
G --> PC["pre-commit(チェック 8 つ)<br/>ステージした差分だけを見る"]
PC -- "exit 1" --> STOP2["commit は作られない"]
PC -- "exit 0" --> CM["commit-msg<br/>先頭行の接頭辞"]
CM -- "exit 1" --> STOP2
CM -- "exit 0" --> OBJ["commit object"]
G -. "--no-verify" .-> OBJ
T7["型 7 の自動テスト(付録 F.8)<br/>CLAUDE.md の依頼 ↔ pre-commit.sample"] -. "正を見る(実体は見ない)" .-> PC
3 つは見ているものが違います。
| 何が | 見るもの | 見ないもの | 効く経路 |
|---|---|---|---|
| Claude Code の hook(付録 C.6) | git commit を含むコマンド / hooks の実在と実行ビット | hook の中身 / 人の端末 | Claude Code の Bash ツールだけ |
| git の pre-commit / commit-msg | ステージした差分とメッセージ | 未ステージ・未追跡(G.6)/ --no-verify | 人も AI も。gh や IDE のコミットも通る |
| 「機構の裏打ち」型の自動テスト(付録 F.8) | CLAUDE.md の「必ず実行」↔ .git-hooks/pre-commit.sample | .git/hooks/ の実体 | テストを走らせたとき |
Claude Code の hook が中身の一致を見ない理由は、clone ごとにブランチが違うからです。2026-08-28 の実測では、本体の設置版が新版で、checkout 済みの sample が旧版でした。それは正しい状態で、一致を要求すると正しい状態を落とします。だから hook は -x の有無だけを見て、鮮度は人が diff .git-hooks/pre-commit.sample .git/hooks/pre-commit で見ます。
8 つのチェックは何を見ているか
pre-commit の 340 行を、チェックごとに「入力」で並べます。⚠️ この表は 2026-09-05 の実物で、[N/8] の番号は本編(第 3 部 第 1 回・付録 F)が引いたものと同じです。
| # | 見るもの | 入力 | 既定で走るか | 飛ばされる条件 |
|---|---|---|---|---|
[1/8] 機密 | .env / master.key のステージ、password=... の形 | git diff --cached | 走る | test/ spec/ docs/ .git-hooks/ は除外 |
[2/8] メッセージ | (commit-msg に委譲) | — | 飛ぶ | — |
[3/8] Rubocop | ステージした .rb | --cached --name-only --diff-filter=ACMR | 走る(Docker の中で) | .rb が無ければ飛ぶ |
[4/8] テスト | 全量 | — | 走らない | RUN_TESTS_ON_COMMIT=true のときだけ |
[5/8] design | ラチェット 8 項目(付録 F.3) | ステージに .css / .erb があれば全量走査 | 走る | 生成した文書の鮮度も見る |
[6/8] locale | YAML の構文 + 全言語そろい | ステージした locale だけ(baseline を触ったら全量) | 走る | — |
[7/8] E2E | 直した E2E テストを走らせたか | ステージした E2E_AREA 持ちの mtime ↔ tmp/e2e_run_history | 走る | — |
[8/8] 実行記録 | 直したテストを走らせたか。加えて、hook やガード(ラッパー / L1 / この pre-commit)を触ったらその自己検査をその場で走らせる | ステージしたテストの mtime ↔ tmp/test_run_history。自己検査は scripts/hook_selftests.sh --staged が「そのパスを名指しする検査」を引く | 走る | scripts/check_test_runs.py が無いブランチでは飛ぶ(自己検査の runner が無いブランチでも同じく飛ぶ) |
3 つ、本編が言っていないことがあります。
1 つ目、pre-commit はテストを走らせません。 [4/8] は既定で飛びます。代わりに [7/8] と [8/8] が「走らせた記録」を見ます。記録は L1(G.8)が tmp/test_run_history へ追記し、名前フィルタ(-n)付きの実行は「走らせた」に数えません。つまり手元のコミットが保証するのは「テストが合格」ではなく、「直したテストファイルの更新時刻より後に、そのファイルを含む実行が 1 度あった」です。合格かどうかは記録に無く、tmp/test_last.log にだけあります。
2 つ目、--diff-filter=ACMR の R は後から足しました。 2026-08-28 まで ACM で、git mv して中身も直したファイルは類似度の高いリネーム(R095 など)と判定されて一覧から消え、Rubocop・design・locale・E2E のチェックが全部素通りしていました。git の rename 検出は「削除 + 追加」を 1 つの R にまとめるので、フィルタに R が無いと、そのファイルは差分に存在しないことになります。
3 つ目、配る側が 2 か所で壊れていました。 [8/8] は scripts/check_test_runs.py の存在を確かめずに呼んでいたので、そのスクリプトをまだ持たないブランチへ sample を配ると、その clone の全コミットが落ちる形でした。設置スクリプトは read -p を使っていて、非対話(Claude Code のセッション)では標準入力が EOF になり、set -e と合わさって exit 1 で止まっていました。既存 hook が無い clone では read に届かないので通り、配り直しの経路だけが壊れていました。両方 2026-08-28 に直り、後者は --force と [ -t 0 ](標準入力が端末か)の分岐になっています。
G.5 逃げ道 —— --no-verify と環境変数は、AI も付けられる
第 1 部 第 4 回の簡約版は 2 行目でこう書いていました。
case "$command" in *--no-verify*|*SKIP_HOOK_CHECK=*) exit 0 ;; esac # 明示の逃げ道は通す
実物も同じで、git commit を拾う正規表現より前に、この case があります。理由は付録 C.8 に書いたとおりで、案内した逃げ道は必ず通さなければ、ガードレールが邪魔になった誰かがガードレールごと壊します。ここで 2 つ、本編が言っていないことを書きます。
1 つ目、逃げ道は部分文字列で判定されていて、逃げ道は --no-verify だけではありません。 *--no-verify* は、git commit -m "fix: --no-verify の説明を直した" も通します(偽陰性)。もう 1 つ、git -c core.hooksPath=/dev/null commit は --no-verify を含まないので hook の判定に入りますが、hook 自身が打つ git rev-parse --git-path hooks には -c が効かないので .git/hooks が返り、そこに pre-commit はあるので通ります。そして git 本体は /dev/null を hooks の場所として読むので、pre-commit も commit-msg も走りません。手元で打つと、見出しが 1 行も出ないまま commit が作られます(2026-09-05 実測。--allow-empty で作って reset --hard で消した)。設置検査の hook は、この迂回を見ていません。 判定を厳密にするなら、コマンドを shlex で分解して git の引数列(-c core.hooksPath= を含む)を見る形になりますが、bash -c "..." の入れ子や && の連結まで正しく分解するのは、それ自体が 1 本のパーサです。手元はここを厳密にしていません。
2 つ目、逃げ道を付けるのは人とは限りません。 第 2 部 第 3 回が書いたとおり、AI は止められた案内文を読み、案内された逃げ道を自分で付けられます。--no-verify は git の公式の迂回で、Claude Code の hook もそれを通し、pre-commit も commit-msg も走りません。3 つすべてが同じ 1 語で外れます。 止める機構は手元にありません。代わりにあるのは記録で、git log には --no-verify の痕跡が残りません(commit object にはその情報が無い)。付録 C.7 の状態ファイルの形で「逃げ道が使われた回数」を数えるなら、hook が exit 0 する前に 1 行追記します。手元の撮影ガード(require_visual_verification_declaration.sh)は判定ごとに tmp/visual_guard_log.tsv へ書いていますが、git hook の設置検査は書いていません。
⚠️ --no-verify は pre-commit と commit-msg を飛ばしますが、prepare-commit-msg と post-commit は飛ばしません(git-commit(1) の記述は「Bypass the pre-commit and commit-msg hooks」の 2 つだけ)。逃げ道の後にも走る hook を置けるということで、post-commit で「pre-commit の記録が無いコミット」を数える形は作れます。手元には無く、G.13 に形だけ書きます。
G.6 git diff --stat に見えないもの
第 1 部 第 9 回の 2 行のうち、1 行目は git diff --stat HEAD | tail -1 でした。本編を読み直して、この行が未追跡の新規ファイルを数えないことが分かりました。手元で 3 つの状態を作って測ります。
| 状態 | git diff --stat | git diff --stat HEAD | git status --porcelain |
|---|---|---|---|
| 新規ファイルを作っただけ(未追跡) | (空) | (空) | ?? zz_probe.txt |
git add した(ステージ) | (空) | 1 file changed, 0 insertions(+), 0 deletions(-)(空ファイルなので 0 行) | A zz_probe.txt |
| 既存ファイルを編集(未ステージ) | 1 file changed, ... | 1 file changed, ... | M path |
git diff は既定で「作業ツリー ↔ index」、HEAD を付けると「作業ツリー ↔ HEAD」を比べます。どちらも index か HEAD に無いファイルは比較の対象になりません。「新しく作った 5 ファイル」は、git add するまで 0 files changed です。第 1 部 第 9 回が数字で拾おうとした「小さな修正です」の隣の 14 files は、新規ファイルの分だけ小さく出ます。
git status --porcelain は、未追跡を ?? で出し、書式が git のバージョンと設定に依らないことを公式が保証しています(--porcelain の説明。2026-09-05 確認)。-u を付けないときの既定は normal(未追跡を表示)で、status.showUntrackedFiles で変えられます。手元は未設定(既定)です。数える側の 2 行は、こうなります。
# handoff_numbers.sh —— 第 1 部 第 9 回の 2 行に、未追跡を 1 行足した形
git diff --stat HEAD | tail -1 # 追跡済みの変更(ステージ・未ステージ)
git status --porcelain | grep -c '^??' # 未追跡の新規ファイル数(0 なら 0 と出る)
grep " runs, " tmp/test_last.log | tail -1 # 件数の行
⚠️ 3 行目は本編が「観測点は 2〜3 個」と言った上限のぎりぎりです。足すなら、一度も食い違いを拾わなかった行を外します。
G.7 採番の競合 —— git は「同じパス」しか衝突にしない
第 3 部 第 4 回は、AI が次の番号を取って計画書を書く、と書きました。この「番号を取る」ことを、手元では採番と呼んでいます(計画書の冒頭に毎回ある語です)。番号の取り方は「docs/plan/ の最大番号 + 1」で、これは読んでから書く操作です。並列実行する作業場所が同じ時刻に読めば、同じ番号を取ります。git はこれを衝突として扱いません。merge の衝突は同じパスの同じ行にしか起きず、191_a.md と 191_b.md は別のパスだからです。
手元の 196 本では 25 番が重複していて、そのうち 1 番は同じ日に別の clone で取られたものでした。計画書だけでなく migration でも、timestamp を手で書いて衝突した例があります。
計画書の番号は重複しても動きます(リンクが 2 本を指す、という読みにくさだけ)。migration の番号は動きません。 Rails は db/migrate/<timestamp>_<name>.rb の timestamp を schema_migrations に記録し、同じ timestamp が 2 本あれば ActiveRecord::DuplicateMigrationVersionError で止まります。手元の衝突は、timestamp を手で選んだ 2 本が同じ分を指したものでした。timestamp は「同じ秒に 2 人が生成しない」ことを前提にした採番で、手で書けば前提が消えます。
なぜ手元に機構が無いのか。計画 212 以降の計画書は冒頭に「起票前に git fetch して、全クローンの採番を見ること」と書いています。これは指示で、付録 F の分類では「機械が真偽を決められるのに、指示ファイルに残っている」側です。決められます。「走査 + 下限」型(走査 + 下限)で 1 本書けます。
# test/reference/plan_number_uniqueness_test.rb —— 置くならこの形(手元には無い)
class PlanNumberUniquenessTest < ActiveSupport::TestCase
BASELINE = 25 # 2026-09-05 の重複番号の数。型 2 のラチェット(下げていく)
test "docs/plan の番号は重複しない(既存の重複は基準値まで)" do
numbers = Dir.glob(Rails.root.join("docs/plan/*.md")).filter_map { |p| File.basename(p)[/\A(\d+)_/, 1] }
assert_operator numbers.size, :>=, 150, "計画書の読み先が壊れている(#{numbers.size} 本)"
dups = numbers.tally.select { |_n, c| c > 1 }
assert_operator dups.size, :<=, BASELINE,
"計画書の番号が新しく重複した(#{dups.size} 番 > 基準 #{BASELINE}): #{dups.keys.sort.inspect}。" \
"fetch して他の作業場所の最大番号を見てから採番すること"
end
end
⚠️ このテストは自分の clone しか見ません。別の clone がまだ push していない 215 を、こちらの 215 と突き合わせることはできません。fetch した後の merge で初めて 2 本が並び、そこでこのテストが失敗になります。つまり止められるのは「衝突したまま main に残る」ことで、「衝突する」ことではありません。衝突そのものを止めるには、番号を中央(origin の 1 ファイル)で取るか、番号を捨てて衝突しない識別子(日付 + 短い slug)にするかです。手元はどちらも採っていません。計画書のリンクが 200 本ぶん番号で書かれているからです。
もう 1 つ、本編の「前例が次の計画を呼ぶ」の側の波及です。前例の複製は、採番の癖も複製します。 AI が ls docs/plan | tail で最大番号を見る形は、200 本の前例から学んだ手順で、「fetch してから」は前例に写っていません。計画 212〜215 の冒頭の注意は、次の計画書がこの 4 本を前例として読んだときにだけ効きます。
G.8 mkdir ロック —— 原子性・PID・heartbeat・残骸
テストの入口で mkdir によるロックを 1 つ取る形があります。ここでは、そのロックがなぜ排他になり、いつ排他でなくなるかを書きます。
先に、なぜ並列実行で壊れるか
並走したときの壊れ方は「fixtures のロードがデッドロックし、共有しているキャッシュや Redis が混線して」でした。手元の構成で、壊れる場所を名指しします。test/test_helper.rb は fixtures :all と parallelize(workers: 1) で、テスト DB は clone ごとに 1 つ(MySQL の typingtube_test。compose project が別なので clone 同士は共有しない)です。Rails の fixture の読み込みは、各プロセスが最初のテストの前に、fixture のある全テーブルを DELETE してから INSERT する操作です。この書き込みはテストごとのトランザクション(use_transactional_tests)の外で commit されるので、同じ DB に向かう 2 つのプロセスが同時にこれをすると、InnoDB の行ロックを互いに待ち、片方がデッドロックの犠牲(Deadlock found)か Lock wait timeout で落ちます。どんなに小さい部分実行でも fixtures :all は全テーブルを触るので、全量 vs 部分でも、部分 vs 部分でも同じです(計画 192 §2.3)。もう 1 つの共有物は Valkey で、テストの cache_store は :null_store なので Rails のキャッシュは混線しませんが、対戦(AnyCable)の状態は同じ Valkey に置かれ、そこを触る E2E が 2 本走ると互いのキーを踏みます。ロックが止めているのは、この 2 つの共有物への同時書き込みです。
なぜ mkdir か
POSIX の mkdir() は、名前が既に存在すれば EEXIST で失敗し、「-1 が返ったときはディレクトリを作らない」と定めています(2026-09-05 確認)。1 回の system call で「無ければ作る、あれば失敗」が決まるので、2 つのプロセスが同時に呼んでも片方だけが成功します。これが排他の根拠です。手元で 2 回打つと、こうなります。
$ mkdir /tmp/x/lock && echo first-ok
first-ok
$ mkdir /tmp/x/lock
mkdir: /tmp/x/lock: File exists
$ echo $?
1
flock を使わない理由は 2 つで、macOS に flock コマンドが無いこと(which flock が空。計画 192 §2.2)と、ロックの読み手(ホストのラッパー)と書き手(コンテナの Rails)が別の OS にいて、advisory lock がボリュームマウント越しに通る保証が無いことです。ディレクトリの存在は、どちらの側からも同じに見えます。
PID の生存確認と、その限界
ロックを取れなかった側は、「本当に走っているのか、残骸か」を決めなければなりません。ロックの簡約版は kill -0 "$owner" でした。POSIX の kill() は、sig が 0 のとき「エラー検査だけを行い、シグナルは送らない。null signal は pid の有効性の検査に使える」と定め、失敗は ESRCH(該当プロセス無し)か EPERM(権限無し)です。手元の L1(test/support/test_execution_guard.rb)は EPERM を「別ユーザーのプロセス。生きてはいる」と読みます。
kill -0 には 2 つの抜け穴があります。PID の再利用(死んだプロセスの番号を、無関係な新しいプロセスが受け取る)と、PID 名前空間(G.9。別の名前空間の同じ番号を見る)です。前者は確率の問題で、後者は Docker で必ず起きます。手元の L1 が生存確認を主役にしないのは、後者のためです。
手元の判定 —— heartbeat が主役、PID は脇、TTL は保険
L1 のロックは 2 ファイルを持つディレクトリです。
tmp/rails_test.lock/
info pid= / started_at=(epoch)/ started_at_human= / argv= / full_run=
heartbeat epoch の数字だけ。15 秒ごとに書き直す
取れなかったときの判定を、状態機械で書きます。
stateDiagram-v2 [*] --> try: Dir.mkdir(LOCK_DIR) try --> held: 成功 → info と heartbeat を書き、15 秒ごとの thread を起動 try --> inspect: EEXIST inspect --> running: heartbeat が 90 秒以内 inspect --> dead: heartbeat が古く、PID が居ない inspect --> expired: heartbeat が古く、PID は居るが、開始から 10 分超 inspect --> hung: heartbeat が古く、PID は居て、10 分以内 inspect --> unreadable: info が読めない running --> [*]: abort(--last / --wait / ALLOW_CONCURRENT を案内) hung --> [*]: abort(TTL を待つな。ps / tail / kill / rm -rf を案内) unreadable --> [*]: abort(rm -rf を案内) dead --> reclaim: rm -rf して取り直す expired --> reclaim reclaim --> try held --> released: Minitest.after_run(通常)/ at_exit(Minitest が走らない経路)
判定の順が本体です。heartbeat が新鮮なら、PID が見えなくても running で止めます(安全側)。PID を見るのは heartbeat が止まってからで、そこで dead(回収)と hung(人が調べる)を分けます。TTL(10 分)は「heartbeat も止まり、PID も見える」ときにだけ効く最後の保険で、待ち時間ではありません。
heartbeat の鮮度はファイルの mtime ではなく中身の epochで測ります。2026-08-24 に、macOS の Docker のファイル共有で mtime の反映が遅れ、コンテナ内で touch -d した過去の mtime が直後の読み取りでは「今」に見えて、固まったロックを「動いている」と誤判定しました。書いた内容は mtime より一貫していた、というのが実測の結論です。
残骸が生まれる 3 つの経路
| 経路 | 何が起きるか | 手元の対処 |
|---|---|---|
| SIGKILL / 電源断 | trap EXIT も at_exit も走らない。ディレクトリが残る | heartbeat が止まる → 90 秒後に dead か hung で判定。dead なら次の実行が回収 |
| fork した子が先に終わる | 子の at_exit が親のロックを消す | @owner_pid == Process.pid のときだけ解放 |
at_exit の順序 | minitest/autorun の at_exit より後に登録した解放が、先に走る | 通常の解放は Minitest.after_run、at_exit は「Minitest がこれから走らない経路」だけ |
3 つ目は 2026-08-31 に見つかりました。Ruby の at_exit は「複数登録されたハンドラは登録の逆順に実行される」と定めています(Kernel#at_exit。2026-09-05 確認)。test_helper.rb は minitest/autorun を先に require し、その中で Minitest が「テストを走らせる at_exit」を登録します。その後にガードが at_exit { release! } を登録すると、実行順は逆で、ガードの解放がテスト本体より先に走ります。実測ではロックの寿命が 1 秒で、その後 4 分以上テストが走り続け、並走ガードは丸ごと無効でした。「ロックを取る」検査 4 項目は合格でした。enforce! の直後しか見ておらず、テストが走っている時点で持っているかを見ていなかったからです。
直した形は、通常経路の解放を Minitest.after_run に任せ、at_exit の中では「このあと Minitest が走るか」を Minitest 自身と同じ条件($! が nil か、成功の SystemExit か)で判定して、走らないときだけ解放する、です。⚠️ ロックの簡約版(bash の trap 'rm -rf "$lock"' EXIT)はシェルスクリプトの中の話で、この順序の問題は起きません。起きるのは、ロックを取る側がテストランナーと同じプロセスの中にいるときです。
G.9 Docker の PID 名前空間 —— ロックの書き手を 1 つにする
ロックの簡約版は、ラッパー(scripts/test.sh)がロックを取り、echo $$ > "$lock/pid" を書いていました。読み直したときに、ここに「PID 空間」の注意を付けました。手元の構成で測ります。
$ docker compose exec -T web sh -c 'echo "container: \$\$ = $$"; ps -o pid,comm | head -3'
container: $$ = 7829
PID COMMAND
1 ruby
7808 ruby
$ docker inspect --format '{{.State.Pid}} {{.HostConfig.PidMode}}' $(docker compose ps -q web)
9351
$ ps -o pid,comm -p 9351 # macOS のホスト
PID COMM # (空 = 存在しない)
コンテナの中では Puma が PID 1 です。pid_namespaces(7) は「PID 名前空間はプロセス ID の番号空間を隔離し、異なる名前空間のプロセスは同じ PID を持ちうる」「名前空間で最初に作られたプロセスが PID 1 で、その名前空間の init になる」と定めています(2026-09-05 確認)。Docker は既定で全コンテナに PID 名前空間を有効にし、--pid=host を付けたときだけホストと共有します(docker container run のリファレンス。手元の PidMode は空 = 既定)。
macOS ではもう 1 つあります。docker inspect が返す 9351 は Docker Desktop の Linux VM の中の PID で、macOS の ps には居ません。つまり手元には PID 空間が 3 つあります。
flowchart TB
subgraph MAC["macOS(ホスト)—— ラッパー scripts/rails_test.sh が動く"]
W["bash $$ = macOS の番号"]
end
subgraph VM["Docker Desktop の Linux VM"]
D["containerd-shim … PID 9351 = コンテナの init"]
subgraph CT["web コンテナ(PID 名前空間)—— L1 が動く"]
P1["PID 1 ruby(puma)"]
P2["PID 7808 puma worker(テスト実行なら bin/rails test の番号)"]
end
end
W -. "kill -0 7808 → macOS の 7808 を見る(無関係)" .-> P2
P2 -. "Process.kill(0, macOS の番号) → コンテナに居ない = dead と誤判定" .-> W
W --- LOCK["tmp/rails_test.lock/(bind mount で両方から見える)"]
P2 --- LOCK
ロックのファイルは bind mount で両側から見えますが、そこに書いた PID の番号は書いた側の名前空間でしか意味を持ちません。ラッパーがホストの PID を書き、L1 がコンテナの中で Process.kill(0, pid) を呼べば、居ない番号を「居ない = 残骸」と読んで生きているロックを回収します。逆も同じです。計画 192 §2.2 が「ロックの書き手は L1 だけにする」と決めたのはこのためで、L1〜L3 の役割はこうなります。
| どこで | 動く場所 | ロックへの関わり | 判定材料 |
|---|---|---|---|
L1 test_execution_guard.rb | コンテナ | 唯一の書き手。取得・heartbeat・解放・回収 | heartbeat の中身 → PID → TTL |
L2 scripts/rails_test.sh | macOS | 読むだけ。作らない・消さない | heartbeat の中身の経過秒だけ(PID は見ない) |
| L3 Claude Code の hook | macOS | 触らない | コマンド文字列だけ |
L2 が誤って回収しないことのほうが、L2 が正確であることより大事です。見逃しても L1 が止めます。
同じ構造の事故が、もう 1 か所にあります。tmp/pids/server.pid です。Puma はコンテナの中で自分の PID をここへ書き、手元の中身は 1 です(コンテナの init なので)。コンテナが SIGKILL で止まると bind mount のファイルは残り、次に起動した Puma は「PID 1 が居るか」を kill 0 で見ます。どのコンテナでも PID 1 は必ず居るので、残骸は永遠に「生きている」と判定され、A server is already running で終了します。macOS のホストで PID 1 は launchd で、こちらも必ず居ます。手元のラッパーが「web コンテナが動いていない」の案内に rm -f tmp/pids/server.pid を書いているのは、この残骸のためです。PID による生存確認が名前空間をまたぐと、「居ない番号を居ないと読む」(ロックの誤回収)と「必ず居る番号を居ると読む」(起動不能)の両方向に壊れます。
⚠️ このロックの簡約版は、ラッパーとテストが同じ OS で動く構成では正しく動きます。Docker を挟むなら、ロックの書き手をコンテナ側(テストランナーと同じ名前空間)に 1 つに決め、ホスト側は読むだけにします。
G.10 bash の文字数とバイト数
手元で最初に踏んだのは、${#var} が「見たい」を 9 と数えた件でした。bash のマニュアルは ${#parameter} を「parameter の値の文字数(length in characters)」と定めています(2026-09-05 確認)。文字数をどう数えるかはロケールで決まり、マニュアルにはロケールの注記がありません。手元で 4 通り測ります。
| 環境 | bash | LANG / LC_ALL | ${#v}(v = 見たい) |
|---|---|---|---|
| Claude Code の Bash ツール(macOS) | 3.2.57 | どちらも未設定 | 9 |
同上で LC_ALL=C | 3.2.57 | C | 9 |
同上で LC_ALL=C.UTF-8 | 3.2.57 | C.UTF-8 | 3 |
| web コンテナ | 5.2.15 | LANG=C.UTF-8 | 3 |
1 行目が本体です。Claude Code の hook は、Claude Code のプロセスの環境変数で走ります。 この VS Code 拡張のセッションでは LANG も LC_* も環境に無く(env | grep -E '^(LANG|LC_)' が空)、bash は C ロケールで動いて、UTF-8 の 3 バイト文字を 3 文字と数えます。人の端末には通常 LANG=ja_JP.UTF-8 があるので、同じ hook を端末で試すと通り、Claude Code から呼ばれると壊れます。宣言 hook の「8 文字未満は空欄扱い」を 3 文字の宣言が素通りしたのは、この差です。
直し方は 2 つで、手元は後者です。
# 1. ロケールを hook の中で固定する(bash 3.2 でも UTF-8 なら効く)
export LC_ALL=C.UTF-8
# 2. 数える処理をロケールに依らない言語へ渡す(手元の実物 .claude/hooks/require_visual_verification_declaration.sh 82 行目)
qlen() { printf '%s' "$1" | python3 -c 'import sys; print(len(sys.stdin.buffer.read().decode("utf-8", "replace")))'; }
sys.stdin.buffer でバイト列として受け、decode("utf-8") で文字にしてから len を取るので、呼び出し側のロケールに依りません。⚠️ ${#var} だけでなく、${var:0:8}(部分文字列)、[[ $var =~ ^.{8} ]](正規表現の .)、cut -c、wc -m も同じ理由でロケールに依ります。hook の中で日本語の長さや位置を扱う行は、全部この分岐にあります。禁句の判定(grep -qE '念のため|とりあえず')は固定文字列の一致なので、バイト列のまま一致し、影響を受けません。
G.11 本編との対応
- 第 1 部 第 4 回(スキルを使わない)—— 存在検査の hook が見る「
.git/hooks/pre-commitの実在」は G.1 の契約(置き場所・実行ビット)の上にあり、worktree で場所が変わる理由は G.3、39 コミットが素通りした理由(hook は clone に付いてこない)は G.2。第 1 部 第 9 回(引き継ぎ)のgit diff --stat HEADが未追跡を数えない件は G.6。 - 本書の外の話(次の連載で扱う仕組み)ですが、テストを 1 本しか通さないロックが
mkdirで排他になる根拠と、PID の生存確認が Docker で意味を失う理由は G.8・G.9。bash の${#var}がバイト数を返す件は G.10。 - 第 3 部 第 1 回(検証を指示しない)の「機構の裏打ち」型が
.git-hooks/pre-commit.sample(正)を読み、実体を読まない理由は G.4。第 3 部 第 4 回(計画を書けと言わない)の採番の競合は G.7。第 2 部 第 3 回の「逃げ道は AI 自身が付けられる」は G.5。
G.12 本編が言っていない注意
- hook の鮮度を見る機構がありません。 設置検査は
-xだけを見ます。中身の一致を要求できない理由は G.4 のとおりですが、「sample のほうが新しいのに設置版が古い」は[ sample -nt 設置版 ]で判定でき、案内は「diff を見ろ」で足ります。 - 逃げ道は部分文字列で、記録も残らず、
-c core.hooksPath=は見ていません(G.5)。--no-verifyを含むコミットメッセージで hook が黙り、--no-verifyで飛ばしたコミットはgit logから区別できません。git -c core.hooksPath=/dev/null commitは 3 つのどれにも止まらず、pre-commit も走りません(実測)。数えるならpost-commitで「pre-commit が書いた目印が無いコミット」を数えます(G.13 の 9)。⚠️post-commitもcore.hooksPath=/dev/nullでは走らないので、この迂回だけはgit logの後追い(git log --format=%Hと目印の突合)でしか数えられません。 - 採番に機構がありません(G.7)。「走査 + 下限」型のテストは「衝突したまま残る」ことしか止めず、「衝突する」ことは番号の取り方を変えないと止まりません。
- pre-commit はテストを走らせず、「走らせた記録」を見ています(G.4)。記録は合格かどうかを持ちません。
tmp/test_last.logは>で毎回上書きされるので、記録の時刻に対応するログは最後の 1 本しか残りません。 git diff --stat HEADは未追跡を数えません(G.6)。第 1 部 第 9 回の 2 行にgit status --porcelain | grep -c '^??'を足すまで、新規ファイルだけの作業は 0 files に見えます。- worktree 運用ではロックが互いを見ません(G.3)。ロックは作業ツリーの
tmp/にあり、DB コンテナを共有する worktree 同士は別のロックを持ちます。worktree に切り替えるならロックの置き場所を先に決めます。 - PID の再利用は塞いでいません(G.8)。heartbeat が止まり、書かれた PID を無関係なプロセスが受け取っていれば、
hungと判定して人に調べさせます。回収はしないので安全側ですが、案内のkill <pid>を打つ前にpsで名前を見る 1 行が要ります。案内文にはps aux | grep '[r]ails test'があり、順序もそうなっています。 trap EXITもat_exitも SIGKILL では走りません(G.8)。Claude Code の Bash ツールがタイムアウトで子プロセスをどう止めるか(SIGTERM か SIGKILL か)は手元で確かめていません。SIGKILL なら残骸が残り、heartbeat 経由で 90 秒後に回収されます。- hook のロケールは Claude Code の環境で決まります(G.10)。端末で通った hook が Claude Code から壊れる差はここにあり、
envを hook の中で 1 度>&2に出せば分かります。手元の hook は 1 本(撮影ガード)だけを python3 に逃がしていて、他の hook は日本語の長さを扱っていません。
G.13 検証手順
止まるべきものが止まり、通るべきものが通ることを、機構ごとに一度ずつ。⚠️ 壊す操作は写しか一時変更で行い、終わったら git status --porcelain が空に戻ることを見ます。
1. 実行ビットを外して、hook が無視されることを見る。 chmod -x .git/hooks/pre-commit して、Claude Code の外の端末で空コミット(git commit --allow-empty -m "chore: probe")を打ちます。typingtube pre-commit checks の見出しが出ないまま commit が作られることを見ます(githooks(5) の「実行ビットの無い hook は無視される」の実物)。git reset --soft HEAD~1 で戻し、chmod +x で戻します。次に Claude Code から同じコミットを打たせ、設置検査の hook が 未設置: pre-commit で止めることを見ます(-x を見ている証拠)。
2. --no-verify が 3 つすべてを外すことを見る。 手順 1 の状態(実行ビットあり)で、Claude Code から git commit --allow-empty --no-verify -m "probe"(接頭辞なし)を打たせます。設置検査の hook は黙り、pre-commit も commit-msg も走らず、接頭辞の無いメッセージで commit が作られることを見ます。git reset --soft HEAD~1 で戻します。次に git commit --allow-empty -m "fix: --no-verify の説明" を打たせ、hook が黙る(部分文字列の偽陰性)ことも見ます。こちらは pre-commit と commit-msg が走るので、実害は「設置検査を飛ばす」だけです。
3. worktree の hooks の場所。 git worktree add /tmp/wt_probe HEAD して、その中で git rev-parse --git-path hooks と --git-dir と --git-common-dir を打ち、hooks だけが本体の .git/hooks を指すことを見ます。git worktree remove --force /tmp/wt_probe で消します。
4. core.hooksPath の効き方と、見えない迂回。 git -c core.hooksPath=.git-hooks rev-parse --git-path hooks が .git-hooks を返すことを見ます。次に git -c core.hooksPath=/dev/null commit --allow-empty -m "chore: probe" を Claude Code から打たせ、設置検査の hook が黙り、pre-commit の見出しも出ず、commit が作られることを見ます(G.5 の 2 つ目の迂回)。git reset --hard HEAD~1 で消します。⚠️ これは「止まるべきものが止まらない」実物で、手元では塞いでいません。
5. rename がチェックから漏れる形。 git mv docs/articles/book/README.md docs/articles/book/README_probe.md してステージし、git diff --cached --name-only --diff-filter=ACM が空、--diff-filter=ACMR が README_probe.md を出すことを見ます(2026-09-05 実測)。git mv で戻します。中身を 1 行直しても、類似度が高ければ R のままです。
6. 未追跡と diff --stat。 touch zz_probe.txt して git diff --stat HEAD | tail -1 が空、git status --porcelain が ?? zz_probe.txt を出すことを見ます。git add して diff --stat HEAD に 1 file changed が出ることを見ます。git reset -q zz_probe.txt && rm zz_probe.txt。
7. mkdir の排他と、ロックの状態。 mkdir tmp/rails_test.lock を 2 回打ち、2 回目が File exists で 1 を返すことを見ます。tmp/rails_test.lock/ の中に info も heartbeat も無い状態で scripts/rails_test.sh test/models/song_test.rb を打ち、L2 が「ロックがありますが、動いていません」(heartbeat 無し = 99999 秒)で止めることを見ます。次に echo $(date +%s) > tmp/rails_test.lock/heartbeat を書いて同じコマンドを打ち、L2 が「既に実行中です」で止めること(heartbeat が主役)を見ます。rm -rf tmp/rails_test.lock で戻し、普通に走ることを見ます。
8. ロックがテスト中も生きていること(at_exit の順序の再発防止)。 全量ではなく 1 ファイルを scripts/rails_test.sh test/models/song_test.rb で走らせ、走っている間に別の端末で cat tmp/rails_test.lock/info と cat tmp/rails_test.lock/heartbeat を 1 秒おきに数回打ちます。ロックが最後まで消えないことと、heartbeat の数字が 15 秒ごとに進むことを見ます。終わったら消えることを見ます。at_exit の順序が逆だった形なら、1 秒で消えます。ガード自身の自動テストにも「テストが走っている最中に持っているか」の項目を 1 つ置きます。
9. post-commit で逃げ道を数える形。 手元に無い hook を、置くならこの形です。pre-commit の最後(exit 0 の直前)に date +%s > "$(git rev-parse --git-path pre-commit-passed)" を書き(worktree でも同じ場所を指すように --git-path で引く)、post-commit でその目印が「このコミットより新しいか」を見ます。
#!/bin/bash
# .git/hooks/post-commit —— pre-commit の印が無ければ「--no-verify で通った」と記録する(手元には無い)
mark=$(git rev-parse --git-path pre-commit-passed)
commit_at=$(git log -1 --format=%ct)
if [ ! -f "$mark" ] || [ "$(cat "$mark")" -lt $((commit_at - 60)) ]; then
printf '%s\t%s\tno-verify?\n' "$(date '+%Y-%m-%d %H:%M')" "$(git rev-parse --short HEAD)" >> tmp/commit_guard_log.tsv
fi
rm -f "$mark"
--no-verify で 1 回コミットして 1 行増えること、普通のコミットで増えないことを見ます。⚠️ post-commit は --no-verify で飛ばないので、ここに置く意味があります。
10. bash の文字数。 Claude Code の Bash ツールから v="見たい"; echo "${#v} LANG=$LANG" を打ち、9 LANG= が出ることを見ます。同じコマンドを人の端末で打ち、3 LANG=ja_JP.UTF-8(など)が出ることを見ます。差が hook の壊れ方の再現です。次に手元の qlen に同じ文字列を渡し、どちらの環境でも 3 が返ることを見ます。
G.14 出典(2026-09-05 確認)
- githooks(5) —— 既定の置き場所
$GIT_DIR/hooksとcore.hooksPath、実行ビットの無い hook は無視される、pre-commit は--no-verifyで飛ばせて非ゼロで commit を中止、commit-msg は 1 引数(メッセージファイル)で書き換えてよい。手元のman githooks(git 2.39.5)でも同文を確認 - git-commit(1) ——
--no-verifyは「pre-commit と commit-msg の hook を飛ばす」の 2 つだけ - git-config(1)
core.hooksPath—— 絶対でも相対でもよく、相対は hook が走るディレクトリ基準。手元のman git-configで確認 - git-worktree(1) DETAILS —— linked worktree の
.gitはファイルで$GIT_DIRと$GIT_COMMON_DIRを持ち、git rev-parse --git-pathはパスによってどちらかを使う。「直接アクセスするときはどちらに属するか仮定せず--git-pathを使え」 - git-status(1) ——
--porcelainは版と設定に依らない書式、??は未追跡、-u無指定の既定はnormal、status.showUntrackedFilesで変更可 - POSIX
mkdir()——[EEXIST] The named file exists./ 「-1 が返ったときはディレクトリを作らない」。⚠️ 原子性の明文は無く、「1 回の呼び出しで作るか失敗するかが決まる」ことから排他に使える - POSIX
kill()——sigが 0 のとき「エラー検査だけを行い、シグナルは送らない。null signal は pid の有効性の検査に使える」/ESRCH/EPERM - pid_namespaces(7) —— 「PID 名前空間はプロセス ID の番号空間を隔離し、異なる名前空間のプロセスは同じ PID を持ちうる」「最初のプロセスが PID 1 で、その名前空間の init」
- docker container run
--pid—— 「既定で全コンテナは PID 名前空間が有効」、--pid=hostでホストと共有、--pid=container:<name>で別コンテナと共有 - Bash Reference Manual 3.5.3 Shell Parameter Expansion ——
${#parameter}は「値の文字数(length in characters)」。⚠️ ロケールの注記は無く、手元の実測(C ロケールで 9 / UTF-8 で 3)で補う - Ruby 3.3
Kernel#at_exit—— 「複数登録されたハンドラは登録の逆順に実行される」 - 手元の実測 —— git 2.39.5(Apple Git-154)/ bash 3.2.57(macOS)と 5.2.15(コンテナ)の
${#v}/ Docker Desktop の 3 つの PID 空間(docker inspectの PID が macOS に居ない・tmp/pids/server.pid=1)/ worktree の--git-path hooksとgit -c core.hooksPath=/dev/null commitの素通り /docs/plan/の重複 25 番(2026-09-05)