仕上げは AI との協働でやっています。事実と表現は著者本人が確認しています。
この記事は事故の記録です。今読んでいるあなたが最初の100人に入っているなら、下書き状態の研修記事を見た人です。あの記事は消えました。これがその顛末です。
Claude Codeに記事の管理を任せていました。執筆・機械レビュー・限定公開・スケジュール管理まで一通り動く仕組みを作っていて、それなりにうまく回っていました。
ある日、既存記事の本文をリライトして「限定公開URLを開いてレビューしたい」と指示しました。そうしたら下書きの研修記事が全体公開されました。100人近くに読まれた後で気づきました。
何が起きたか
Claude Codeが実行したコマンドはこれです。
cd ~/workspace-ai/nomuraya-blogs/Qiita && QIITA_TOKEN=... bun run qiita publish --force 45min-training-from-50min-version
問題は2つ重なりました。
1. gate スクリプトを経由しなかった
記事の公開フローには3つのゲートを用意していました。
- Gate 1(機械レビュー): Dead URL / frontmatter / 文字数チェック
- Gate 2(ユーザーレビュー): 限定公開URLを人間が確認し、
reviewed_by_user: trueを手動で書く - Gate 3(スケジュール):
publish_target_dateと1日1記事ルールの確認
このフローは gate-machine-review.sh と safe-publish.sh の2本のスクリプトに実装してありました。ところがClaudeは「bun run qiita publishを直接叩けばいい」と判断して、スクリプトを経由しませんでした。
2. frontmatterの private: false を見落とした
qiita publish コマンドはfrontmatterの private フィールドを見て公開範囲を決めます。このファイルはいつの間にか private: false になっていました。Claudeはそれを確認せずに --force を付けて実行しました。
結果として、Gate 2(ユーザーレビュー)を通過していない記事が全体公開されました。Qiita APIでは一度全体公開した記事を private: true に戻せません。
事後に作った防衛システム
Gate 1: pre_publish_check.py(機械レビュー)
記事ファイルに対して以下を自動チェックします。
- frontmatterの必須フィールド(title / tags / id)
- 本文の文字数(下限チェック)
- Dead URL検出(curl HEAD → 404/410/5xx/DNS失敗を検出)
- amazonphotosリンクの混入検出
Dead URLチェックはGate 1に移しました。元々はユーザーレビュー段階で気づく前提でしたが、「機械が取れる問題は機械で止める」のが正しいやり方です。
Gate 2: safe-publish.sh(公開前の3チェック)
# Gate 1: 機械レビュー通過確認
if ! entry.get("machine_review_passed"): BLOCK
# 既公開記事の本文更新は Gate 2/3 をスキップ(更新 ≠ 新規公開)
if entry.get("current_status") == "public": 更新パスへ
# Gate 2: ユーザーレビュー確認(AI が true に書き換えることは禁止)
if ! entry.get("reviewed_by_user"): BLOCK
# Gate 3: スケジュール確認
if publish_target_date > today: BLOCK
if 本日すでに別記事が公開済み: BLOCK(1日1記事ルール)
reviewed_by_user: true はAIが自分で書けません。ledgerファイルを手動で編集する必要があります。これが人間ゲートの実装です。
Gate 0(物理ブロック): pre-bash-qiita-publish-guard.py
スクリプトを作っても「直接叩かれたら終わり」でした。なのでClaudeのBashツール実行前に割り込むhookを作りました。
BLOCK_PATTERNS = [
re.compile(r"bunx\s+qiita\s+publish"),
re.compile(r"bun\s+(?:run|x)\s+qiita\s+publish"),
re.compile(r"npx\s+qiita\s+publish"),
re.compile(r"\bqiita\s+publish\b"),
]
ALLOW_SUBSTRINGS = [
"safe-publish.sh",
"gate-machine-review.sh",
# qiita pull / preview / version 等は通す
]
bunx qiita publish some-slug を実行しようとすると、こうなります。
⛔ [qiita-publish-guard] qiita publish の直叩きはブロックされています。
必ず gate スクリプト経由で実行してください:
限定公開: bash .../gate-machine-review.sh <slug>
全体公開: bash .../safe-publish.sh <slug>
このhookはClaude Codeの PreToolUse イベントで発火します。settings.json の hooks に登録してあります。
今も残っている穴
正直に書きます。
updated_at ずれ問題: qiita publish は frontmatter の updated_at がQiita実機より古いと「内容が古い可能性があります」と弾きます。リライトのたびに実機の updated_at を取得してローカルに合わせる作業が発生します。safe-publish.sh に自動化を組み込む予定ですが、まだ未了です。
ledgerと実機の乖離: .review-status.jsonl の current_status はスクリプトが更新しますが、Qiita Web UIで操作するとledgerが追いつきません。ledger_sync.py で同期できますが、これが reviewed_by_user を自動で true にしようとする問題があります(人間ゲートの侵食)。こちらは現在修正中です。
教訓
AIに任せる操作と人間が持つ権限を「コードレベルで分離」しないと事故になります。
「AIが間違えないようにする」のではなく「AIが間違えても被害が出ない構造にする」のが正しい方向でした。今回作ったhookとスクリプトは、その実装です。
全体公開されてしまった研修記事は消せないので、この記事に差し替えました。最初の100人には申し訳ないです。