3行で
- 自分が運用しているサイトで、記事の 1,770 本がビルドから一度も読まれないディレクトリに書かれていた。
- 正しい書き込み先は 2026-02-15 に一度発見され、修正までされていた。生成スクリプト5本も全部正しい値を持っていた。それでも3ヶ月後に反転し、以後6ヶ月間ずっと間違った側に書かれ続けた。
- 気づかなかったのは誰も見ていなかったからではない。毎日 Slack に届いていて、それが成功として通っていたから。
- 差を作ったのは知識でも指示でもコードでもない。拒否する側が居たかどうかだけだった。
これは他人の話ではなく自分の運用サイトの事故です。数字はすべて公開リポジトリの git log から再導出できます。
何が起きていたか
Astro の content collection は src/content/ 配下しか読みません。設定はこれだけです。
// src/content/config.ts
const blogCollection = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string(),
publishedAt: z.string(),
author: z.string(),
category: z.string(),
tags: z.array(z.string()),
lang: z.enum(['en', 'ja']).default('en'),
}),
});
export const collections = { blog: blogCollection };
collections に登録された blog は src/content/blog/ に対応します。
リポジトリのルートにある content/blog/(src/ が付かない方)は、
astro.config からも src/pages からも一度も参照されていません。
$ grep -rn "content/blog" astro.config.* src/content/config.ts src/pages \
| grep -v "src/content/blog"
$ # 何も出ない
そこに書いてもエラーは出ません。ビルドは通り、コミットも成功し、
GitHub 上にファイルは実在し、リポジトリを見れば記事はちゃんとそこにあります。
サイトにだけ出ません。
正解は一度、発見されていた
git の履歴に、3つ並んだコミットがあります。
| 日付 | コミット | 内容 |
|---|---|---|
| 2026-02-14 | 6357272 |
10本を content/blog/ に追加 |
| 2026-02-15 | 6dcef83 |
Add frontmatter to 10 articles for proper display in blog index |
| 2026-02-15 | 236ca06 |
Move Clawdbot Skill articles to correct location (src/content/blog/) |
真ん中が誤診です。「ブログ一覧に表示されない」という症状に対して、
「frontmatter が無いからだ」と診断して frontmatter を足した。
足しても表示されなかった。読まれないディレクトリなので当然です。
そして最後のコミットで正解に到達している。--name-status はこうなっています。
R100 content/blog/2026-02-14-bluebubbles-skill.md
→ src/content/blog/2026-02-14-bluebubbles-skill.md
R100 は内容が 100% 一致する純粋なリネーム。
中身は最初から正しく、置き場所だけが間違っていたことが記録されています。
つまり 2026-02-15 の時点で、この不変条件は発見され、言語化され、修正されている。
コミットメッセージには "correct location (src/content/blog/)" とはっきり書いてある。
その3ヶ月後に反転した
git log --diff-filter=A で、月ごとにどちらへ何本追加されたかを数えます。
| 月 |
src/content/blog(読まれる) |
content/blog(読まれない) |
|---|---|---|
| 2026-02 | 227 | 13 |
| 2026-03 | 70 | 0 |
| 2026-04 | 12 | 0 |
| 2026-05 | 0 | 396 |
| 2026-06 | 6 | 535 |
| 2026-07 | 3 | 550 |
| 2026-08 | 0 | 289 |
| 合計 | 318 | 1,783 |
2026-02〜04 は正しく動いています。2026-05 で完全に反転し、二度と戻っていません。
2026-05 以降だけを取ると、読まれる側 9本 に対して読まれない側 1,770本。
この記事を書いてリポジトリに push した当日にも、
8d30490(2026-08-18)が content/blog/2026-08-17-rsi-simulator-...md に1本追加していました。
6ヶ月間、失敗信号は一度も出ていません。
生成スクリプトは、5本とも正しかった
ここで予想が外れました。「生成器の定数が間違っているのだろう」と思って見に行くと、逆でした。
リポジトリにコミットされている恒久的な生成スクリプトは、全部が正しい方を指しています。
scripts/generate-new-articles.js:13 const BLOG_DIR = join(process.cwd(), 'src/content/blog');
scripts/generate-blog-articles.js:13 const BLOG_DIR = join(process.cwd(), 'src/content/blog');
scripts/check-latest-ai-news.js:26 const BLOG_DIR = join(process.cwd(), 'src/content/blog');
scripts/generate-release-article.mjs:26 const BLOG_DIR = './src/content/blog';
fix-frontmatter.js:131 const blogDir = path.join(__dirname, 'src/content/blog');
不変条件はコードの中に、5箇所、正しく書かれていた。それでも効かなかった。
実際に使われた書き込み経路が、このコードを一度も通らなかったからです。
エージェントにファイルを書かせるとき、生成スクリプトは経由されません。
ファイル書き込みツールで直接 .md を作る。そのとき BLOG_DIR は存在しないのと同じです。
定数に埋め込まれた不変条件は、その定数を読む経路を通った書き込みしか拘束しません。
本当の下流は Slack だった
では6ヶ月間、誰も何も見ていなかったのか。逆です。見ていました。
リポジトリのルートに、使い捨ての Python が 52 本散らばっています。うち 29 本が send_slack_*.py。
中身は全部同じ形で、その日の記事のパスを1本だけ直書きしています。
repo_root = '/Users/<user>/git/<repo>'
article_path = os.path.join(repo_root, 'content/blog/2026-07-04-ai-news-weekly-....md')
body = open(article_path).read()
# Strip markdown for Slack
body = body.replace('**', '*').replace('## ', '')
message = "*AI開発ニュース 2026年7月4日——...* #ai-news\n\n" + body[:3000] + "..."
**これは毎日成功していました。**記事は毎日 Slack に流れていた。
つまり下流の消費者は実在し、フィードバックループも回っていて、しかもずっと緑だった。
誰も見ていなかったから気づかなかったのではありません。
見ていた。ただし見ていたのはサイトではなく Slack だった。
Slack に届いた時点で「今日の記事は出た」という確認が毎日成立してしまう。
そして send_slack_*.py は content/blog から読むので、
書き手と読み手のペアとしては完全に整合している。
このペアの中に、サイトに出ているかを問う要素が一つもありません。
同じ書き手、違う結果
原因が「拒否する側の有無」であることは、同じリポジトリの中で対照が取れます。
どちらのディレクトリも同じ人間が、同じモデルで、同じ目的で書いています。
frontmatter の必須項目(title / description / publishedAt / author / category / tags)が
全部そろっているファイルの割合を数えると:
| ディレクトリ | 充足 | 率 |
|---|---|---|
src/content/blog(ビルドが読む) |
314 / 315 | 99.7% |
content/blog(ビルドは読まない) |
3 / 1,773 | 0.2% |
500倍の差があります。読まれる側だけ書き手が丁寧だったわけではありません。
読まれる側はスキーマ違反でビルドが落ちるから、落ちなくなるまで直されただけです。
その痕跡も履歴に残っています。読まれる側にだけ、修復コミットが繰り返し現れます。
6d62748 fix: add required publishedAt field to MiniMax-M2.5 articles9c5bb6c fix: correct frontmatter format for all new blog articles069b9a9 fix: Fix publishedAt format and remove invalid CLAUDE.md
最後の一つが分かりやすい例です。src/content/blog/ の中に CLAUDE.md が置かれていました。
中身はツールが自動生成したメモの雛形で、frontmatter はありません。
<claude-mem-context>
# Recent Activity
*No recent activity*
</claude-mem-context>
これがコレクションの一エントリとして読まれ、スキーマ違反でビルドを落とし、削除されました。
ゲートは、そのファイルが記事なのかツールの副産物なのかを見ません。
コレクションの中にあって形式が合わなければ、機械的に拒否します。
読まれない側には、この種のコミットが一つもありません。
唯一の例外が冒頭の 6dcef83(frontmatter を足した誤診)で、
それも表示が変わらなかったので繰り返されませんでした。
品質は書き手の努力ではなく、下流に拒否する者が居るかどうかで決まっていました。
なぜ6ヶ月も気づかないのか
ここが実装上いちばん重要な点です。
ビルドは失敗していません。成功していました。
content collection のローダーは、指定されたディレクトリを走査して見つかったものを読みます。
0件は「0件見つかった」という正常な結果です。エラーではありません。
src/content/blog/ には既存の 315 本があるので、そもそも 0 件にすらならない。
新規記事が1本も増えていないことは、どこにも現れません。
書き込み先を間違える → ビルド成功
frontmatter が壊れる → ビルド失敗
間違いが大きいほど静かになるという反転が起きています。
frontmatter の1フィールドの型が違えばビルドが落ちるのに、
ディレクトリごと外していると何も起きない。
検査は「読み込んだものが正しいか」しか見ておらず、
「読み込むべきものが来ているか」を誰も見ていないからです。
これは Astro 固有の話ではありません。
グロブでファイルを集める処理、ディレクトリを監視する処理、
プレフィックス指定で S3 を舐める処理——空集合を成功として返す実装すべてに同じ穴があります。
そのうえに、前述の Slack 通知が乗っています。
沈黙しているだけなら、いつか誰かが「そういえば最近サイトが更新されていない」と気づいたはずです。
そうならなかったのは、沈黙ではなく肯定が返ってきていたからです。
記事を書く → content/blog に置かれる → Slack に届く → 「今日も出た」
↘ サイトには出ない(誰も問い合わせない)
観測点が下流の一部にしか無いと、その一部が緑である限り全体が緑に見えます。
通知は「処理が走ったこと」の証拠であって、「目的が達成されたこと」の証拠ではありません。
この2つを同じものとして扱うと、失敗は成功の顔をして毎日届きます。
拘束力の階層
今回の履歴から読み取れるのは、記録した場所によって拘束力がまったく違うということです。
| 記録した場所 | 次の書き込みを止められるか | 実際にどうだったか |
|---|---|---|
| コミットメッセージ | ❌ | "correct location" と明記 → 6ヶ月後に1,770本が同じ間違いを反復 |
| 生成スクリプト内の定数 | ❌ | 5箇所すべて正しい値。その経路を通らない書き込みには無力 |
| README / ドキュメント | △ | 読まれるかどうかは書き手次第 |
CLAUDE.md / AGENTS.md
|
△ | エージェントは読むが、守らなくても何も起きない |
| 終了コード 1 を返す検査 | ✅ | 通らないと先に進めない |
上4つはすべて「お願い」です。従わなかったときのコストがゼロなので、
遅かれ早かれ従われなくなります。
この表の1行目と2行目が重要で、どちらも明文化されていたのに効かなかったという直接の反例です。
特に2行目は「コードに書けば守られる」という直感を裏切ります。
コード内の定数が拘束するのは、その定数を読む経路だけです。
エージェントが直接ファイルを書くようになった瞬間、その経路は使われなくなりました。
そして拘束力の無さは、コミットメッセージ自体にも現れています。
件名に日本語以外の文字体系が混入している率を月ごとに数えると:
| 月 | 混入 / 全体 | 率 |
|---|---|---|
| 2026-02 | 0 / 243 | 0.0% |
| 2026-03 | 0 / 48 | 0.0% |
| 2026-05 | 8 / 393 | 2.0% |
| 2026-06 | 11 / 571 | 1.9% |
| 2026-07 | 9 / 512 | 1.8% |
ディレクトリの反転と同じ 2026-05 に立ち上がっています。
コミットメッセージには検査が無いので、そのまま通ります。実例:
Add AI news: ServiceNow 4000万美元投资BusinessNext扩大银行AI
Add AI news article: Anthropic 15亿美元版权和解
Add AI news article: OpenAI GPT-Red безопасность, Google DeepMind ...
そして、この中に一つだけ性質の違うものがあります。
Fix: 減速の汉字置換(减→減)を修正
簡体字の置換を修正したコミットのメッセージが、簡体字で汚染されています。
汉字 は 漢字 です。本文には検査があったので直った。
件名には検査が無かったので、直しているその手で同じ欠陥を作っている。
混入そのものの測定は別記事に書きました。この記事の主題は、
なぜそれが半年放置されたかの方です。
自分のリポジトリで確かめる
最初に「ビルド設定からそのディレクトリが参照されているか grep する」という検査を書きました。
**これは動きません。**やってみると、正しい方の src/content/blog まで「参照なし」と出ます。
参照なし 1773本 content/blog
参照なし 315本 src/content/blog ← 正しい方まで引っかかる
Astro はコレクション名 blog で暗黙に束縛していて、
src/content/blog というパス文字列はソースのどこにも書かれていないからです。
規約による束縛なので、静的な grep では原理的に辿れません。
そしてこれが、この種の不具合が見えない理由でもあります。
「どこから読まれているか」を追おうとしても、追う先が存在しない。
唯一ごまかせない検査は、ビルド生成物との突き合わせです。
#!/usr/bin/env bash
# 各ディレクトリから1本ずつスラッグを取り、ビルド出力に現れるか確認する
npm run build >/dev/null 2>&1 || { echo "ビルド失敗"; exit 1; }
for dir in src/content/blog content/blog; do # ← 自分の候補ディレクトリに置き換える
slug=$(git ls-files "$dir" | grep '\.md$' | head -1 | xargs basename | sed 's/\.md$//')
[ -z "$slug" ] && continue
if grep -rqs "$slug" dist/; then
echo "OK 出力にある : $dir ($slug)"
else
echo "NG 出力に無い : $dir ($slug) ← 書いても公開されていない"
fi
done
実際の出力:
OK 出力にある : src/content/blog (2026-02-04-cursor-subagents-skills-ja)
NG 出力に無い : content/blog (05-13-ai-news-update-ai-news-update)
dist/ に出ているかは、フレームワークの規約にも設定にも依存しません。
出ていなければ公開されていない、それだけです。
月ごとの推移を見るには --diff-filter=A を使います。
ファイルの mtime は一括処理で簡単に壊れるので、当てになりません。
DIR=content/blog # ← 調べたいディレクトリ
git log --diff-filter=A --date=format:'%Y-%m' \
--pretty='C %ad' --name-only -- "$DIR" \
| awk '/^C /{m=$2} /\.md$/{c[m]++} END{for(k in c) print k, c[k]}' \
| sort
実際の出力(上の表の右列と一致します):
2026-02 13
2026-05 396
2026-06 535
2026-07 550
2026-08 289
直す
やったことは2つだけです。書き方を工夫する側ではなく、拒否する側を作りました。
1. 不変条件をエージェントが必ず読む場所に置く(CLAUDE.md)
## 記事の書き込み先
**記事は `src/content/blog/` にのみ書く。`content/blog/` には書かない。**
Astro が読むのは `src/content/` 配下だけ。`content/blog/` はビルドのどこからも
参照されていない。書いてもエラーは出ず、コミットも成功し、GitHub 上に
ファイルも実在するが、サイトには一生出ない。
commit message ではなくここに書くのは、次の書き込みの前に読まれる場所だからです。
ただしこれ単体では「お願い」のままで、拘束力はありません。次が本体です。
2. 通らないと落ちる検査を置く(scripts/check_article.py)
REQUIRED = ["title", "description", "publishedAt", "author", "category", "tags"]
CONSUMED_DIR = os.path.join("src", "content", "blog")
def check(path):
errors = []
# 1) 書き込み先がビルドに読まれる場所か
if CONSUMED_DIR not in os.path.normpath(os.path.abspath(path)):
errors.append(f"書き込み先が {CONSUMED_DIR}/ ではない: {path}")
text = open(path, encoding="utf-8").read()
# 2) frontmatter の必須項目
m = re.match(r"^---\r?\n(.*?)\r?\n---", text, re.S)
if not m:
errors.append("frontmatter が無い")
else:
keys = set(re.findall(r"^([A-Za-z_]+)\s*:", m.group(1), re.M))
missing = [k for k in REQUIRED if k not in keys]
if missing:
errors.append("必須項目が欠落: " + ", ".join(missing))
# 3) 文字体系の混入(別記事参照)
...
return errors
# 違反があれば終了コード 1
sys.exit(1 if any(check(p) for p in sys.argv[1:]) else 0)
チェック1が今回の本題です。書き込み先そのものを検査対象にする。
「書かれた内容が正しいか」だけを見ていると、書かれた場所は永久に検査されません。
そして CLAUDE.md にはこう書きました。
**「必要に応じて」ではなく、毎回実行する。** 条件付きにすると実行されない。
「必要に応じて検証する」は、必要かどうかの判断を書き手に委ねる書き方です。
書き手は自分が間違っていることに気づいていないので、必要だと判断しません。
条件を外して初めてゲートになります。
効いているかの確認
書いた検査が本当に落とすかは、既存の記事に対して実行して確かめました。
新しい記事で試すと、たまたま通ってしまったときに何も分かりません。
$ python3 scripts/check_article.py src/content/blog/2026-03-01-claude-code-context-mode-ja.md
L51: MCP Directory & Hubを 운영하는Köseoğlu氏の発案。同氏は「みんなツールを作る一方で…
L9: …以下のデータが каждыйツールコールごとにコンテキストに追加
NG src/content/blog/2026-03-01-claude-code-context-mode-ja.md
- ハングル が混入: 는영운하
- キリル が混入: аджйкы
[exit 1]
公開済みの記事から実際に検出されました(운영하는 = 運営する、каждый = それぞれの)。
検査を書いたら、既知の違反で一度落としてから運用に乗せるべきです。
落ちないゲートは無いのと同じで、しかも「検査してある」という誤った安心を配ります。
この記事の限界
-
1リポジトリの事例です。「拒否ゲートが品質を決める」という一般命題の証明ではありません。
99.7% vs 0.2% は強い対照ですが、n=1 の自然実験です。 - **2026-05 に何が変わったかは特定できていません。**書き込みが生成スクリプト経由から
直接のファイル書き込みに移ったことは状況証拠から言えますが、
その切り替えが起きた瞬間を示すコミットは見つかりませんでした。 -
Slack 通知が誤った安心を生んだ、というのは私の解釈です。
「毎日 Slack に届いていた」「サイトには出ていなかった」は事実として確認できますが、
それを見て安心したという内心の記録はありません。 - **修正の効果はまだ測れていません。**ゲートを置いたのは 2026-08-18 です。
実際に間違った書き込みが止まったかは、今後のgit logでしか検証できません。 - **既存 1,773 本の遡及移動はしていません。**うち frontmatter があるのは 3 本だけなので、
そのまま移すとビルドが落ちます。順序としては、まず止血、次に移送です。
まとめ
- 正しい不変条件は6ヶ月前に発見され、コミットメッセージに明記され、修正まで実施され、
さらに生成スクリプト5本に正しい定数として書かれていた。
それでも同じ間違いが 1,770 回繰り返された。知識の不足ではありません。 - **コードに書いても足りません。**定数が拘束するのは、その定数を読む経路を通った書き込みだけ。
エージェントが直接ファイルを書き始めた時点で、その経路は使われなくなりました。 - 同一リポジトリ内の対照で、frontmatter 充足率は 99.7% vs 0.2%。
差は書き手ではなく、下流に拒否する者が居たかどうか。 - 一番危険な失敗は、失敗として報告されない失敗。
空集合を成功として返す実装は、全部この穴を持っています。
さらに悪いのは、別の下流が成功を返してくること。通知は「走ったこと」の証拠にすぎません。 - ドキュメントは「お願い」、終了コード 1 は「制約」。
効かせたい規則は、守らなかったときに止まる場所に置く。
そして検査は、書かれた内容だけでなく書かれた場所も対象にする。
関連記事
- 日本語で生成した記事 2,089 本を測ったら6割に他言語が混ざっていた — 範囲判定では原理的に取れない
関連
「ドキュメントはお願い、終了コード 1 は制約」の、置き場所の側の話です。同じ内容でも、どの層に書くかで拘束力が変わります。
層の切り方と、効かなくなる4つのパターンの見分け方をまとめた本があります。
→ AIコンテキスト設計ガイド ── 規約を配ったのに守られない理由(2,000円)
第3章「規約が効かなくなる4つのパターン」まで無料で読めます。
/compact の後もルートの CLAUDE.md は再注入されるが、サブディレクトリと paths: 付きは再注入されない ── 長いセッションで静かに落ちるのはどちらか、という章です。