Claude Code に「この調査をまとめて」と頼むと、設計書や調査メモの .md をリポジトリの中に作ります。数が増えると git status が汚れるうえ、プロジェクトをまたいだ検索もできません。
自分は、こうしたドキュメントを Obsidian のボールトに寄せる 運用にしています。CLAUDE.md にルールを書いたうえで、PreToolUse フックで「リポジトリへの新規 .md の作成」を止め、代わりに書くべきパスを Claude Code に返すようにしました。
仕事と個人開発で18のリポジトリを触っていて、ボールトにはプロジェクトごとのノートが166本あります(2026-11-07 時点。うち34本は、リポジトリで git 管理しているファイルへのシンボリックリンク)。これがリポジトリごとに散らばっていたら、どこに何を書いたか追えません。
この記事は、その設定を最初から作る手順です。使ったファイルは3つだけです。
| ファイル | 役割 |
|---|---|
~/.claude/CLAUDE.md |
お願い(置き場所のルールを書く) |
~/.claude/scripts/obsidian-doc-guard.sh |
強制(新規 .md を deny して正しいパスを返す) |
~/.claude/settings.json |
フックの登録 |
動作確認した環境
- Claude Code 2.1.317(2026-11-07 に実機確認)
- macOS / bash /
jq/git(Windows・Linux では確かめていません) -
jqが無ければbrew install jqで入れます(後述のとおり、無いと何も止まりません) - ボールトは
~/obsidian/my-vault(以降この名前で書きます。自分の環境に読み替えてください)
手順1: CLAUDE.md に置き場所のルールを書く
~/.claude/CLAUDE.md(全プロジェクト共通)に、置き場所を書きます。自分が実際に置いている文面から、要点だけを抜粋し、パスを差し替えて載せます。
# ドキュメントの置き場所
設計書・調査レポート・手順書など、Claude Code が生成する読み物系のドキュメントは
リポジトリではなく Obsidian ボールトに書く。プロジェクトを問わず、例外なく全てに適用する。
- ボールト: `~/obsidian/my-vault`
- 置き場所: `~/obsidian/my-vault/Projects/<プロジェクト名>/`
(`<プロジェクト名>` は作業中のリポジトリのディレクトリ名)
## 強制(フック)
`PreToolUse(Write)` フックが、ボールト外への新規 `.md` 書き込みをブロックし、
正しいボールトのパスを返す。ブロックされたら、案内されたパスに書き直すこと。
これは「お願い」です。守られる保証はないので、次のフックで止めます。
手順2: ガードスクリプトを置く
~/.claude/scripts/obsidian-doc-guard.sh を作ります。フックの入力は標準入力の JSON で来ます。ファイルのパスは tool_input.file_path、作業ディレクトリは cwd です。止めるときは、終了コード 0 で permissionDecision: "deny" の JSON を返します。
実際に使っているスクリプトから、例外リストと git 管理外の分岐を削った縮約版です。
#!/usr/bin/env bash
# PreToolUse(Write): リポジトリへの新規 .md を止めて、ボールト側のパスを案内する
VAULT="${OBSIDIAN_VAULT:-$HOME/obsidian/my-vault}"
payload="$(cat)"
path="$(printf '%s' "$payload" | jq -r '.tool_input.file_path // ""')"
cwd="$(printf '%s' "$payload" | jq -r '.cwd // ""')"
[ "$(printf '%s' "$payload" | jq -r '.tool_name // ""')" = Write ] || exit 0
[ -n "$path" ] || exit 0
deny() {
jq -nc --arg r "$1" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}'
exit 0
}
# 相対パスは cwd で絶対化
case "$path" in /*) ;; *) path="$cwd/$path" ;; esac
base="$(basename "$path")"
# 通すもの: .md 以外 / ボールト配下 / 既存ファイルの上書き
case "$base" in *.md) ;; *) exit 0 ;; esac
case "$path" in "$VAULT"/*) exit 0 ;; esac
[ -e "$path" ] && exit 0
# 通すもの: リポジトリの構成物と、記事・設定ディレクトリ
case "$base" in README.md|CLAUDE.md|AGENTS.md|LICENSE.md|CHANGELOG.md) exit 0 ;; esac
case "$path" in */content/*|*/posts/*|*/skills/*|*/.claude/*|*/.github/*|*/node_modules/*) exit 0 ;; esac
# プロジェクト名 = git ルートのディレクトリ名。未作成ディレクトリは実在する祖先まで遡る
probe="$(dirname "$path")"
while [ ! -d "$probe" ] && [ "$probe" != / ]; do probe="$(dirname "$probe")"; done
root="$(git -C "$probe" rev-parse --show-toplevel 2>/dev/null)" || exit 0
proj="$(basename "$root")"
rel="${path#"$root"/}"
deny "ドキュメントは Obsidian ボールトで管理する運用です。
このファイルは代わりに次のパスに作成してください:
$VAULT/Projects/$proj/$rel"
実行権限をつけます。
chmod +x ~/.claude/scripts/obsidian-doc-guard.sh
ポイントは3つです。
- 既存ファイルの上書きは通します。 新規作成のときだけ「置き場所の決定」を迫る作りです
-
README.mdやCLAUDE.mdなどは通します。 リポジトリに置くべきファイルまで止めると、作業が進みません -
content/posts/skills/は通します。 これらは記事の本体やエージェントへの指示で、読み物のドキュメントではないからです。プロジェクトに合わせて足してください
手順3: settings.json にフックを登録する
~/.claude/settings.json(全プロジェクト共通)に書きます。command は絶対パスにします。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "/Users/<ユーザー名>/.claude/scripts/obsidian-doc-guard.sh",
"timeout": 10,
"statusMessage": "ドキュメントの置き場所を確認中..."
}
]
}
]
}
}
matcher は Write だけです。Claude Code が新規ファイルを作る通常の手段は Write ツールだからです(後述の穴もここから来ます)。
特定のプロジェクトだけで使うなら、同じ内容をそのプロジェクトの .claude/settings.json に書いても動きます。公式ドキュメントによると、設定ファイルの変更はファイルウォッチャーで自動的に読み込まれます。登録できたかどうかは、Claude Code で /hooks を打つと確認できます。
手順4: 止まるところを自分の目で見る
「設定した」と「止まる」は別です。2段階で確認します。
4-1. 疑似の入力 JSON を流す
テスト用の git リポジトリと空のボールトを作り、スクリプトに JSON を直接流します。
S=$HOME/hooktest
mkdir -p "$S/myapp/.github" "$S/vault" && git -C "$S/myapp" init -q && touch "$S/myapp/exists.md"
jq -n --arg p "$S/myapp/docs/design.md" --arg c "$S/myapp" \
'{tool_name:"Write",tool_input:{file_path:$p,content:"x"},cwd:$c}' \
| OBSIDIAN_VAULT="$S/vault" ~/.claude/scripts/obsidian-doc-guard.sh
deny の JSON が返り、案内パスが $S/vault/Projects/myapp/docs/design.md になれば成功です。これを11通りで回した結果が次の表です。
| 入力 | 結果 |
|---|---|
新規 docs/design.md(絶対パス) |
deny(案内: …/Projects/myapp/docs/design.md) |
新規 design.md(相対パス) |
deny(cwd で絶対化される) |
まだ無いディレクトリ a/b/c/new.md
|
deny(実在する祖先まで遡って git ルートを特定) |
既存 exists.md の上書き |
通す |
README.md |
通す |
content/posts/a.md |
通す |
.github/pr.md |
通す |
notes.txt(.md 以外) |
通す |
ボールト配下の .md
|
通す |
git 管理外のディレクトリの .md
|
通す(縮約版は git ルートが分からないので素通し) |
tool_name が Edit
|
対象外(通す) |
4-2. 実機で Claude Code に書かせる
ヘッドレスモード(claude -p)で、わざと違反させます。
cd ~/hooktest/myapp
claude -p "Write ツールで docs/design.md に「# test」と1行書いて。拒否されたら別パスに書き直さず、拒否メッセージを全文報告して終了" \
--allowedTools "Write" --disallowedTools "Bash"
返ってきたのはこうでした(縮約版を指すプロジェクト設定で実行。グローバル側の本番版は OBSIDIAN_DOC_GUARD=off で止めて、縮約版だけが効く状態にしています。両方が効いていると、拒否の文面が少し違って見えます)。
PreToolUse:Write hook error: ドキュメントは Obsidian ボールトで管理する運用です。
このファイルは代わりに次のパスに作成してください:
/Users/<ユーザー名>/obsidian/my-vault/Projects/myapp/docs/design.md
docs/design.md は作られず、案内パスが Claude Code に渡りました。ここまで見て初めて「効いている」と言えます。
詰まりどころ
Bash のリダイレクトはすり抜ける
これが最大の穴です。同じリポジトリで、Bash ツールだけを許可して docs/bash-test.md を作らせたところ、拒否されずに作られました。
claude -p "Bash ツールだけを使い、シェルのリダイレクトで docs/bash-test.md に「# bash」と書いて作成して。Write ツールは使わない" \
--allowedTools "Bash" --disallowedTools "Write,Edit"
フックの matcher が Write だけだからです。Claude Code は通常 Write ツールでファイルを作るので、普段の運用では効きますが、「Write 以外で作れば止まらない」ことは分かっておいてください。塞ぐなら PreToolUse(Bash) にもフックを足し、コマンド文字列から > や tee の書き込み先を取り出す必要があります。自分はまだ入れていません。
追記(2026-11-08):事後なら、文字列を解析せずに検知できる
コメントで、bashEditDiffEnabled を使う方法を教えていただきました。手元(Claude Code 2.1.317 / macOS)で確かめた結果です。
ユーザー設定(~/.claude/settings.json)か --settings で bashEditDiffEnabled を true にすると、Bash の実行中にリポジトリで変わったファイルの一覧が、PostToolUse(Bash) フックの tool_response.bashEditDiff.changedFiles に絶対パスで届きます(v2.1.269 以降)。リポジトリの .claude/settings.json に true と書いても有効になりません(公式の settings リファレンスより)。
{
"bashEditDiffEnabled": true,
"hooks": {
"PostToolUse": [
{ "matcher": "Bash",
"hooks": [{ "type": "command", "command": "/Users/<ユーザー名>/.claude/scripts/obsidian-doc-detect.sh", "timeout": 10 }] }
]
}
}
#!/usr/bin/env bash
# PostToolUse(Bash): Bash で作られた git 未追跡の .md を見つけたら、置き場所を案内する(事後の検知)
payload="$(cat)"
cwd="$(printf '%s' "$payload" | jq -r '.cwd // ""')"
hits=""
while IFS= read -r f; do
case "$f" in *.md) ;; *) continue ;; esac
rel="${f#"$cwd"/}"
git -C "$cwd" ls-files --error-unmatch -- "$rel" >/dev/null 2>&1 && continue
hits="$hits $rel"
done < <(printf '%s' "$payload" | jq -r '.tool_response.bashEditDiff.changedFiles[]?')
[ -z "$hits" ] && exit 0
echo "Bash で新しい .md が作られました:$hits 。ドキュメントはボールトに置いてください。" >&2
exit 2
使い捨てのリポジトリで echo hello > notes.md を実行させると、notes.md は作られたうえで、Claude にこう返りました。
PostToolUse:Bash hook blocking error from command: "…/post-bash.sh": … Bash で新しい .md が作られました: notes.md 。…
bashEditDiffEnabled を外して同じことをすると、tool_response に bashEditDiff が無く、何も検知されませんでした。
注意点は3つです。
- 作成そのものは止まりません。 事後の検知です。Write の deny を主にして、こちらは取りこぼしの検知に使う二段構えにします
- Git が無視するファイルと、サブモジュールの中のファイルは一覧に載りません。逆に、同じ時間に別のプログラムが変えたファイルが混ざることもあります(公式)
- 試したのは
--permission-mode acceptEditsのclaude -pです。権限の設定によっては、リダイレクトでの書き込みにそもそも承認が求められます
jq が無いと、何も止めずに素通しする
スクリプトは入力の解釈に jq を使っています。jq が見つからない環境で動かすと、jq: command not found(日本語ロケールでは「コマンドが見つかりません」)と標準エラーに出るだけで、終了コードは 0 のまま、ツール呼び出しは通ります。止める処理が黙って無効になるので、which jq で入っていることを先に確かめてください。
終了コードの意味は公式ドキュメントのとおりで、0 は成功(JSON があれば適用)、2 はブロッキングエラー、それ以外(1 や 127 など)はブロックしないエラーです。この「エラーなのに通る」性質が、静かに壊れる原因になります。
「止まっている」と思い込まない
自分は以前、別のフックで「止めているはず」と1か月思い込んで、実は何も止めていなかったことがありました。経緯は こちらの記事 に書いています。手順4のように、違反を1回起こして止まるのを見るまでは、「設定済み・未検証」として扱うのが安全です。
縮約版と、実際に使っているものとの違い
実際に使っているスクリプトは、縮約版に次を足しています。
- 例外リストが長い(
SKILL.md、CODE_OF_CONDUCT.mdなど。Zenn / Qiita の記事リポジトリの記事置き場も通す) - git 管理外でも、作業場所(
~/workspace/<名前>)の名前からプロジェクト名を決める - 環境変数
OBSIDIAN_DOC_GUARD=offで一時的に無効にできる - 拒否メッセージに「どうしてもリポジトリに必要なファイルは、ユーザーに確認してから除外リストに足す」と添える
例外は「止めて困ったら足す」運用です。最初から網羅しようとせず、縮約版から始めて、通したいものが出たら case に1行足していくのが現実的です。
まとめ
-
CLAUDE.mdはお願い、PreToolUse(Write)フックは強制。両方置く - 入力は標準入力の JSON(
tool_input.file_path/cwd)。止めるときはpermissionDecision: "deny"を返す - 実機の
claude -pで、止まるところを1回見る - Write 専用なので、Bash のリダイレクトは止まらない。
jqが無いと素通しする
関連: Claude Code の CLAUDE.md や Hooks を、大きなコードベースでどう使うかは自分のブログにも書いています。