3行で
- 手元の git リポジトリ 96 本を走査した。エージェント向けの規約(
CLAUDE.md/AGENTS.mdなど)を持っているのは 33 本、34%。総量 323,833 字。 - そのうち実行可能なコマンドの形になっているのは 93 個。さらに、違反を止められる場所(CI・pre-commit・husky)でも実行されているのは 13 個、14.0%。
- そして
.claude/の hooks を使っているリポジトリは 33 本中 0 本。エージェントが動いている最中に効く唯一の仕組みが、まったく使われていなかった。
前提
前に「拘束力の階層」という話を書いた。同じ内容を書く場所によって、守られるかどうかが変わる。
| 書く場所 | 拘束力 |
|---|---|
| コミットメッセージ | 無し |
| 生成スクリプトの定数 | そのコードを通る書き込みだけ |
| README | 読まれれば |
CLAUDE.md / AGENTS.md
|
読まれれば |
| 終了コード 1 で落ちる検査 | 有り |
あのときは1つのリポジトリの事故から書いた。今回は手元の全部を数えて、この階層が実際にどう分布しているかを見る。
走査結果
git リポジトリ 96 本 / 規約ファイルあり 33 本 = 34%
規約の総量 323,833 字(中央値 3,290 / 最大 66,160)
断定表現(必ず/禁止/MUST/NEVER)130 箇所
条件付き表現(必要に応じて/推奨/if needed)18 箇所
止める仕組みを持っているか
GitHub Actions 22/33 = 67%
husky 9/33 = 27%
pre-commit 3/33 = 9%
.claude の hooks 0/33 = 0%
規約に書かれた実行可能なコマンド 93 個
違反を止められる場所でも実行されている 13 個 = 14.0%
どこからも実行されない 80 個
規約に書かれたファイルパス 207 個
同名ファイルすら存在しない 24 個 = 11.6%
32万字と、93行
まず量。323,833 字。中央値 3,290 字なので、大半は数千字の常識的な文書で、突出して長いものが1本(66,160 字)ある。
このうち、機械が実行できる形になっているのは 93 個のコマンドだけだ。残りは全部、散文で書かれた指示になる。「必ず」「禁止」「MUST」「NEVER」といった断定表現が 130 箇所あるが、断定していることと強制できることは別だ。
そして 93 個のうち、CI・pre-commit・husky のいずれかでも実行されているのは 13 個(14.0%)。80 個は、どこからも実行されない。
リポジトリ別に見ると、一様に低い。
リポジトリA 18 / 1
リポジトリB 14 / 2
リポジトリC 12 / 6
リポジトリD 11 / 3
リポジトリA(別パスの同名) 11 / 1
リポジトリE 10 / 0
リポジトリF 9 / 0
(リポジトリ名は伏せた。数字はそのまま。5行目が1行目と同名なのは、
同じリポジトリが複数のパスに置かれているため。)
最も良いもので 12 個中 6 個。18 個書いて 1 個というものもある。「規約はよく書けているが強制が甘い1本」ではなく、全部が同じ形をしている。
一度、数え間違えた
最初の集計では 29.0% と出ていた。倍以上ある。
原因は、コマンドを「先頭2語」で識別していたことだった。npm run dev も npm run check も npm run migrate も、鍵は全部 npm run になる。CI に npm run check が1行あるだけで、**規約に書かれた npm run 系のコマンド全部が「強制されている」**と数えられていた。
サブコマンドを取るランナーは1語余分に見る必要がある。
def cmd_key(cmd):
t = cmd.split()
if len(t) >= 3 and t[1] in ("run", "exec", "run-script"):
return " ".join(t[:3])
return " ".join(t[:2]) if len(t) >= 2 else ""
直したら 29.0% → 14.0%。
この間違いの方向が、記事の主題と同じなのが少し面白い。粗い一致は「守られている」側に倒れる。 検査を甘くすると、違反は検出されずに「合格」として出てくる。
.claude/hooks が 0 本
一番はっきりしたのがこれだった。
GitHub Actions 22/33 = 67%
.claude の hooks 0/33 = 0%
CI は 3 分の 2 のリポジトリにある。使い方も分かっている。にもかかわらず、エージェント向けの hooks を設定しているリポジトリは1本も無い。
この差が意味するのは、タイミングだ。
エージェントがファイルを書く
→ コミットする
→ プッシュする
→ CI が動く ← ここで初めて止まる
CI が止めるのはプッシュの後で、そのときエージェントのセッションはとっくに次へ進んでいる。あるいは終わっている。人間が結果を見て、次のセッションで直すことになる。
.claude/hooks はツール呼び出しの直前・直後に走る。ファイルが書かれた瞬間に検査して、落とせる。エージェントのループの中にある唯一の門がここなのに、誰も置いていない。
私自身も置いていなかった。CI は当たり前に書くのに、こちらは思いつかなかった。「エージェントに読ませる文書」は書くが、「エージェントを止める仕組み」は書いていないという非対称が、33 本すべてに出ている。
外れた仮説: 規約は腐っていなかった
走査する前、私は「規約は書かれた時点で止まっていて、コードだけが動いているから、参照先が壊れているはずだ」と考えていた。それを測るつもりだった。
規約に書かれたファイルパス 207 個
同名ファイルすら存在しない 24 個 = 11.6%
11.6%。しかも残った 24 個を1つずつ見たら、大半は壊れていなかった。
-
tools/your_tool.pysrc/sites/your-site.ts— 雛形の説明で、実在しなくて正しい。 -
data/news_YYYY-MM-DD.json— 日付の入るファイル名。 -
~/.hermes/config.yaml— リポジトリの外にあるパス。
これを除くと、本当に壊れているものはほとんど残らない。
**規約は腐っていない。**参照先は生きている。書いてあることは正しい。ただ、そのうち 86% は実行されない。
問題は鮮度ではなく形式だった。「正しいことが書いてある文書」と「違反を止める仕組み」は別のもので、前者をいくら丁寧に保っても後者にはならない。
それで何をするか
1. 規約の各項目に「これはどこで落ちるか」を書く。 落ちる場所が無い項目は、良くて助言、悪くて願望になる。私は自分の CLAUDE.md にこう書いた。
## 記事を書いたら必ず検証を通す
python3 scripts/check_article.py src/content/blog/<新しい記事>.md
**「必要に応じて」ではなく、毎回実行する。** 条件付きにすると実行されない。
書いた直後は満足したが、今回の走査でこのリポジトリは 1 個中 0 個だった。この記述自体は何も止めていない。 scripts/check_article.py は存在し、正しく動き、終了コード 1 で落ちる。だが呼ばれなければ落ちようがない。
2. 条件付きの表現を消す。 「必要に応じて」「可能であれば」「推奨」は、実行されないことを最初から許可している。私のコーパスでは 18 箇所しかなかったので、ここは全体としては問題になっていない。断定表現のほうが 130 箇所と圧倒的に多い。問題は言い回しの弱さではなく、強い言い回しが強制に繋がっていないことだった。
3. .claude/hooks を1つでも置く。 CI で書いている検査のうち、ファイル1本を見れば判定できるものは、そのまま hooks に移せる。書き込みの直後に落ちれば、エージェントはその場で直す。次のセッションを待たなくていい。
33 本中 0 本ということは、ここが一番伸びしろがある。
自分の環境で走査する
#!/usr/bin/env python3
"""ローカルの git リポジトリを走査し、エージェント向け規約の「拘束力」を測る。
python3 rules_enforcement.py [走査の起点 ...] [--exclude 正規表現]
規約に書かれていることが、違反を止められる場所でも実行されているかを見る。
--exclude は業務リポジトリを外すために使う(パスに対する正規表現)。
"""
import sys, os, re, glob, statistics, collections
RULE_FILES = ["CLAUDE.md", "AGENTS.md", ".cursorrules",
".github/copilot-instructions.md"]
# 規約の本文に現れる、実行可能なコマンド行
CMD = re.compile(r"(?m)^\s*(?:\$\s*)?((?:npm|pnpm|yarn|npx|python3?|pytest|make|uv"
r"|go|cargo|ruff|eslint|tsc|bash|sh|black|mypy|flake8|prettier"
r"|vitest|jest)\s+[^\n`]{2,80})")
# 違反を止められる場所。package.json の scripts は「定義」であって門ではない。
BLOCKING = [".github/workflows/*.y*ml", ".pre-commit-config.yaml",
".husky/*", ".claude/settings.json", ".claude/hooks/*"]
MUST = re.compile(r"必ず|してください|するな|禁止|MUST|NEVER|ALWAYS")
COND = re.compile(r"必要に応じて|可能であれば|なるべく|できるだけ|推奨"
r"|as needed|if needed|when appropriate|where possible")
INLINE = re.compile(r"`([^`\n]{5,90})`")
EXT = re.compile(r"\.(py|js|ts|tsx|jsx|md|json|ya?ml|toml|sh|php|rb|go|rs|sql|vue|html|css)$")
def cmd_key(cmd):
"""コマンドを識別する最小の語数。
`npm run` までしか見ないと `npm run dev` も `npm run check` も
同じ鍵になり、片方が CI にあるだけで全部「強制されている」ことになる。
サブコマンドを取るランナーは1語余分に見る。
"""
t = cmd.split()
if len(t) >= 3 and t[1] in ("run", "exec", "run-script"):
return " ".join(t[:3])
return " ".join(t[:2]) if len(t) >= 2 else ""
def read_all(*patterns):
out = ""
for pat in patterns:
for p in glob.glob(pat, recursive=True):
if os.path.isfile(p):
out += open(p, encoding="utf-8", errors="replace").read() + "\n"
return out
SKIP = {"node_modules", ".venv", "venv", "vendor", "dist", "build",
"Library", "Applications", ".Trash", ".npm", ".cache", "site-packages"}
def find_repos(roots, exclude=None, depth=5):
""".git を探す。見つけたらその下は降りない(サブモジュールは追わない)。"""
seen = []
for root in roots:
base = os.path.expanduser(root)
for dp, dn, _ in os.walk(base):
if dp[len(base):].count(os.sep) >= depth:
dn[:] = []
continue
if ".git" in dn:
dn[:] = []
if not (exclude and exclude.search(dp)):
seen.append(dp)
continue
dn[:] = [d for d in dn if d not in SKIP and not d.startswith("Library")]
return sorted(set(seen))
def basenames(repo):
"""リポジトリ内の全ファイル名。パスが移動しただけかを見分けるのに使う。"""
names = set()
for dp, dn, fn in os.walk(repo):
dn[:] = [d for d in dn if d not in
(".git", "node_modules", ".venv", "vendor", "dist", "build")]
names.update(fn)
return names
def main():
argv = sys.argv[1:]
exclude = None
if "--exclude" in argv:
i = argv.index("--exclude")
exclude = re.compile(argv[i + 1], re.I)
argv = argv[:i] + argv[i + 2:]
roots = argv or ["~"]
repos = find_repos(roots, exclude)
if not repos:
print("git リポジトリが見つからない。")
return 1
withrules, sizes = [], []
must = cond = 0
cmd_total = cmd_blocked = 0
path_total = path_missing = 0
mech = collections.Counter()
rows = []
for repo in repos:
text = read_all(*[os.path.join(repo, f) for f in RULE_FILES])
if not text.strip():
continue
withrules.append(repo)
sizes.append(len(text))
must += len(MUST.findall(text))
cond += len(COND.findall(text))
blocking = read_all(*[os.path.join(repo, p) for p in BLOCKING])
for name, pat in [("GitHub Actions", ".github/workflows/*.y*ml"),
("pre-commit", ".pre-commit-config.yaml"),
("husky", ".husky/*"),
(".claude の hooks", ".claude/settings.json")]:
if glob.glob(os.path.join(repo, pat)):
if name != ".claude の hooks":
mech[name] += 1
else:
s = read_all(os.path.join(repo, pat))
if "hooks" in s:
mech[name] += 1
cmds = {" ".join(m.split())[:60] for m in CMD.findall(text)}
hit = sum(1 for c in cmds if cmd_key(c) and cmd_key(c) in blocking)
cmd_total += len(cmds)
cmd_blocked += hit
names = basenames(repo)
paths = {s for s in INLINE.findall(text)
if "/" in s and EXT.search(s) and " " not in s
and not s.startswith(("http", "@")) and "*" not in s and "{" not in s}
missing = [p for p in paths
if not os.path.exists(os.path.join(repo, p.lstrip("./")))
and os.path.basename(p) not in names]
path_total += len(paths)
path_missing += len(missing)
if cmds or paths:
rows.append((os.path.basename(repo), len(cmds), hit,
len(paths), len(missing)))
n = len(repos)
m = len(withrules)
print(f"git リポジトリ {n} 本 / 規約ファイルあり {m} 本 = {m / n * 100:.0f}%")
if not m:
return 0
print(f" 規約の総量 {sum(sizes):,} 字"
f"(中央値 {statistics.median(sizes):,.0f} / 最大 {max(sizes):,})")
print(f" 断定表現(必ず/禁止/MUST/NEVER){must} 箇所")
print(f" 条件付き表現(必要に応じて/推奨/if needed){cond} 箇所")
print("\n止める仕組みを持っているか")
for name in ("GitHub Actions", "husky", "pre-commit", ".claude の hooks"):
print(f" {name:<18}{mech[name]:>3}/{m} = {mech[name] / m * 100:>4.0f}%")
if cmd_total:
print(f"\n規約に書かれた実行可能なコマンド {cmd_total} 個")
print(f" 違反を止められる場所でも実行されている {cmd_blocked} 個"
f" = {cmd_blocked / cmd_total * 100:.1f}%")
print(f" どこからも実行されない {cmd_total - cmd_blocked} 個")
if path_total:
print(f"\n規約に書かれたファイルパス {path_total} 個")
print(f" 同名ファイルすら存在しない {path_missing} 個"
f" = {path_missing / path_total * 100:.1f}%")
if rows:
print("\nリポジトリ別(コマンド数 / 強制されている数 / パス数 / 壊れている数)")
for name, c, h, p, mp in sorted(rows, key=lambda x: -x[1])[:15]:
print(f" {name:<32}{c:>4} /{h:>3} {p:>4} /{mp:>3}")
return 0
if __name__ == "__main__":
sys.exit(main())
--exclude は業務用リポジトリを外すためのもの。走査は .git を見つけたらその下に降りないので、サブモジュールは数えない。
この記事の限界
- 私1人の環境である。96 本のうち多くは実験用や使い捨てで、本番運用しているものは一部。運用中のリポジトリだけに絞れば強制率は上がるはずだが、そこは分けていない。
-
「違反を止められる場所」の判定が粗い。 CI に
npm run checkという行があることと、それが必須のステータスチェックになっていることは別だ。ローカルからは required status の設定は見えないので、14.0% ですら上限の可能性がある。 - コマンドの抽出は正規表現で、規約に散文で書かれた指示(「テストを通してから出す」)は数に入らない。実行可能な形になっているものだけを数えている。
-
package.jsonの scripts を「止める場所」に含めていない。定義は門ではないと判断したが、prepublishのようなフックとして機能しているものは取りこぼす。
まとめ
- 96 本中 33 本(34%)が規約を持ち、総量は 323,833 字。
- そのうち実行可能なコマンドは 93 個。違反を止められる場所でも実行されるのは 13 個(14.0%)。
- **
.claude/hooksは 33 本中 0 本。**エージェントのループの中で効く唯一の門が空いている。 - 規約は腐っていなかった。参照先は生きていた。問題は鮮度ではなく、86% が実行されない形で書かれていること。
- 検査を粗くすると「守られている」側に倒れる。私の最初の集計は、それで倍の数字を出した。
書いた文書が守られているかを知りたいなら、その文書に書いてあるコマンドが、どこかで実行されているかを数える。数えられないなら、それは強制されていない。
関連
「86% が実行されない形で書かれている」の続きです。では実行される形とは何か、どの層に何を置けば止まるのかを、構成パターン別に書いた本があります。
→ AIコンテキスト設計ガイド ── 規約を配ったのに守られない理由(2,000円)
第3章「規約が効かなくなる4つのパターン」まで無料で読めます。
/compact の後もルートの CLAUDE.md は再注入されるが、サブディレクトリと paths: 付きは再注入されない ── 長いセッションで静かに落ちるのはどちらか、という章です。