2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

モノレポの `frontend/CLAUDE.md`、本当に読まれてる? Claude Code のネストしたメモリ読み込みを検証してみた

2
Posted at

背景

もともとは Claude Code を使っていたので、CLAUDE.md に直接指示を書いていました。その後 Claude Code 以外のコーディングエージェントも併用したくなり、指示の実体は AGENTS.md に置き、CLAUDE.md はそこへの import だけを書く構成に移行しました。

この移行作業で全ディレクトリの CLAUDE.md を書き換えているときに、ふと気になったのがこの記事のテーマです。

リポジトリ構成は下記のようにしています。

root/
├── CLAUDE.md          # @AGENTS.md のみ
├── AGENTS.md          # ルート向け本体
├── frontend/
│   ├── CLAUDE.md      # @AGENTS.md のみ
│   └── AGENTS.md      # frontend 向け本体
└── backend/
    ├── CLAUDE.md      # @AGENTS.md のみ
    └── AGENTS.md      # backend 向け本体

この「CLAUDE.md から AGENTS.md を import する」パターンは、公式ドキュメントにも記載のある推奨構成です。

Claude Code reads CLAUDE.md, not AGENTS.md. If your repository already uses AGENTS.md for other coding agents, create a CLAUDE.md that imports it so both tools read the same instructions without duplicating them.

出典:How Claude remembers your project

ところがこの構成で /context を叩くと、Memory files セクションにはルート直下のファイルしか出てきません。サブディレクトリの CLAUDE.md は影も形もないです。

frontend を触っているときに frontend/AGENTS.md のルールは効いているのか?
それとも書いたつもりで一度も読まれていないのか?

モノレポ構成で frontend/AGENTS.mdbackend/AGENTS.md に別々の規約を書いているので、実際に検証してみることにしました。

検証

まずは Claude に実際にファイルを読ませて、コンテキストに何が入るかを直接見ていきます。読ませるファイルは CLAUDE.mdAGENTS.md とは無関係な、各ディレクトリに元からある普通のファイルです。今回は backend が Python 製なので pyproject.tomlfrontend が Node.js 製なので package.json を使いました。

なお以下はすべてリポジトリルートで claude を起動したセッションでの結果です。

検証1 サブディレクトリへの初回アクセス

backend/pyproject.toml を Read させると、ツール結果の下に以下の行がぶら下がりました。

  Read 1 file (ctrl+o to expand)
  ⎿  Loaded backend/CLAUDE.md
  ⎿  Loaded backend/AGENTS.md

サブディレクトリの CLAUDE.md が読み込まれていそうです。

しかも @AGENTS.md の import 先まで展開されています。ファイルを1つ Read しただけで、そのディレクトリのメモリファイルが会話に入ってきています。

frontend/package.json でも同じでした。frontend/CLAUDE.mdfrontend/AGENTS.md の全文が入っていました。ディレクトリごとに同じことが起きるようです。

検証2 /context の Memory files は変化するか

検証1の前後で /context を実行しました。Memory files はルートの3件、CLAUDE.mdAGENTS.mdMEMORY.md のままで、変化なしです。

Loaded backend/CLAUDE.md はTUIに表示されているのを見たので、「読まれていないから出ない」ではないようです。読まれてはいるが、/context の Memory files には計上されていないということになります。

では注入された分はどこにいるのか。

ドキュメントに記載がありました。Explore the context window に以下のような記載があります。

nested CLAUDE.md files load into message history when their trigger file is read

ネストしたメモリが積まれるのはメッセージ履歴(Messages)側で、/context の Memory files に出るのは起動時にロードされた分だけ、ということでした。

検証3 同じディレクトリに再度アクセスしたら

一度読んだ backend/pyproject.toml をもう一度 Read させてみます。TUI 上は1回目とまったく同じ Read 1 file としか出ないのですが、Claude 側に返っていたのはファイルの内容ではなく次の1行でした。

Wasted call — file unchanged since your last Read. Refer to that earlier tool_result instead.

エラーメッセージではなく、Read の tool_result そのものが差し替わっています。前回の Read 以降そのファイルが更新されていなければ、同じ内容を二度コンテキストに積まないよう Claude Code 側が制御しているのかもしれません。

画面に出ないので、セッションのトランスクリプト(~/.claude/projects/<project名>/<session_id>.jsonl)で裏を取りました。握り潰された Read だけ toolUseResult.typefile_unchanged になっています。

そこで同じセッションの中で、次の3パターンを試しました。

  1. backend/pyproject.toml を Read(初回)
  2. backend/README.md を Read。backend/ 直下の、まだ読んでいない別ファイルです
  3. backend/pyproject.toml を更新してから再度 Read

結果は、2 でも 3 でも Loaded backend/CLAUDE.md の行は出ませんでした。後述のフックログでも、2 と 3 では1行も記録が増えていません。

注入は同じディレクトリにつき1回だけのようです。

一度入ればメッセージ履歴に残るので、再注入がいらないのは分かります。ただ、履歴が積み重なったときに前のほうに入った frontend/AGENTS.md がそのまま効き続けるかは別の話です。そこまでは試していないので推測ですが、長いセッションではネストした規約は無視されることがあるというのはこのためかと思います。

公式ドキュメントとの突き合わせ

How Claude remembers your project に該当の記述がありました。ロードのされ方は2系統です。

作業ディレクトリとその上位階層の CLAUDE.md は、セッション開始時に一括ロードされます。ファイルシステムのルート側から作業ディレクトリに向かって順に連結されるため、起動場所に近い指示ほど後ろ、つまりあとに読まれる位置に来ます。/context の Memory files に出るのはこちらです。

サブディレクトリのほうは起動時ロードではない、と How CLAUDE.md files load の章にはっきり書いてあります。they are included when Claude reads files in those subdirectories の一文です。

検証1で見たとおりの挙動が、そのまま仕様として書かれていました。引き金は files in those subdirectories、そのディレクトリ配下のファイルを読むことです。特定のファイルが引き金になるわけではありません。

import についても、@path で参照したファイルは元の CLAUDE.md と一緒に展開されてコンテキストに載る、と書いてあります。

ここで地味に重要なのが、相対パスの解決基準です。backend/CLAUDE.md にも frontend/CLAUDE.md にも、中身は同じ @AGENTS.md の1行しか書いていません。それでも検証1では backend/ を読んだときは backend/AGENTS.mdfrontend/ を読んだときは frontend/AGENTS.md が入っていました。

Relative paths resolve relative to the file containing the import, not the working directory.

出典:How Claude remembers your project

import の相対パスは、起動時の作業ディレクトリではなく import を書いたファイルの位置から解決されます。同じ1行を全ディレクトリにコピーしておけば、それぞれ自分の隣の AGENTS.md を指してくれます。パッケージごとに @backend/AGENTS.md のようなパスを書き分ける必要はなさそうです。

一方、検証3で見た「同じディレクトリには重複注入しない」ほうは、ドキュメントに裏付けが見つかりませんでした。公式の表現は reloads them as Claude reads files in that subdirectory で、これだと読むたびに再ロードされるように取れます。初回限定とも重複排除するとも書かれていません。実測では1回しか注入されていないので、ここはドキュメントの書き方と検証にずれがあるようでした。

InstructionsLoaded フックでログを取る

もう少し確実な証拠が欲しくなったので、InstructionsLoaded フックを使いました。CLAUDE.md.claude/rules/*.md がコンテキストにロードされたときに発火するイベントで、どのファイルがいつ何を引き金にロードされたかをそのまま記録できます。以下も v2.1.241 での結果です。

設定

.claude/settings.json に追記します。

{
  "hooks": {
    "InstructionsLoaded": [
      {
        "matcher": "*",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '[.load_reason, .file_path, (.trigger_file_path // \"-\"), (.parent_file_path // \"-\")] | @tsv' >> \"$HOME/.claude/instructions-loaded.log\""
          }
        ]
      }
    ]
  }
}

これで ~/.claude/instructions-loaded.log に4列の TSV が追記されていきます。列の意味はこうです。

フィールド 内容
1 load_reason なぜロードされたか
2 file_path ロードされた指示ファイル
3 trigger_file_path 引き金になったファイル(Claude が Read したファイル)
4 parent_file_path import 元のファイル(@path を書いていたほう)

3列目と4列目は該当するときだけ値が入るキーで、そうでない行にはキー自体がありません。

登録できたかどうかは /hooks で確認できます。

ログを読むうえで要になるのが1列目の load_reason で、実測できたのは以下の3つです。

load_reason いつ発火するか
session_start セッション開始時の一括ロード。作業ディレクトリとその上位階層の CLAUDE.md
nested_traversal サブディレクトリのファイルを Read したことによるオンデマンドロード
include @path の import が展開された

実際に取れたログ

フックを仕掛けた状態で、リポジトリルートから新しいセッションを開き、backend/pyproject.tomlbackend/README.mdfrontend/package.json の順に Read させた結果が以下です(パスはリポジトリルートからの相対に短縮しています)。

load_reason       file_path           trigger_file_path       parent_file_path
                  (ロードされた)      (引き金)                (import 元)
────────────────  ──────────────────  ──────────────────────  ──────────────────
include           AGENTS.md           -                       CLAUDE.md
session_start     CLAUDE.md           -                       -
include           backend/AGENTS.md   backend/pyproject.toml  backend/CLAUDE.md
nested_traversal  backend/CLAUDE.md   backend/pyproject.toml  -
include           frontend/AGENTS.md  frontend/package.json   frontend/CLAUDE.md
nested_traversal  frontend/CLAUDE.md  frontend/package.json   -

(見出しの3行は説明のために足したもので、実際のログには出ません)

なおフックはロードごとに別プロセスで起動するので、行の並び順からロード順は推論できません。ペアで見てください。

3ファイル読ませて6行、つまり backend/README.md の分が丸ごと無いのがポイントです。検証との対応はこうなります。

検証 ログ上の裏付け
検証1(サブは Read 時にオンデマンド) nested_traversal が backend / frontend に1件ずつ。trigger_file_path が引き金のファイルを指している
検証3(同じディレクトリには初回のみ) backend/ 直下の未読ファイル backend/README.md を読んでもログが1行も増えない。touch して同じファイルを読み直しても増えない
@import も展開される nested_traversal と対になる include があり、parent_file_path が当該 CLAUDE.md を指す
ルートは起動時ロード session_start はルートの CLAUDE.md のみ。サブディレクトリの分は1件もない

目視で見た Loaded ... の表示と、ログの中身は一致しました。

まとめ

以下はリポジトリルートから起動したセッションでの話です。

項目 結論
ネストした CLAUDE.md は読まれるか 読まれる(公式仕様)
いつ読まれるか そのディレクトリ配下のファイルに最初にアクセスしたとき
何度も読まれるか 同じディレクトリには初回のみ。以降は再注入されない(一度入った分は会話の履歴に残る)
/context の Memory files に載るか 載らない。起動時ロードされるものだけが対象
@import 先も展開されるか される(backend/CLAUDE.md@AGENTS.md で確認)

/context に出ないのは仕様で、不具合ではありません。ここを取り違えて「Memory files に出ていないから読まれていない」と早合点して、指示を全部ルートに集約するとルートの CLAUDE.md が無駄に膨らみます。

Monorepos and large repos には、ルートには全体に効く規約を、パッケージごとにはそのスタック固有の規約を、という2階層の分け方が書いてあります。この挙動を知ってから読むと、理由まで分かりますね。ルートは起動時に必ず載るから全体規約の置き場になり、ネストはそのディレクトリを触ったときにだけ載るから、frontend/ の作業中に backend/ の規約がコンテキストを消費しないのですね。

そのうえで私の結論はこうです。frontend/AGENTS.md に書いていいのは、frontend/ を触っているときにだけ効けばいい規約に限る。全体に効かせたいものは、面倒でもルートに上げる。ネストしたほうは確かに読まれますが、読まれるのはそのディレクトリに触った最初の1回だけで、あとはメッセージ履歴の中に埋もれていきます。

参考リンク

2
1
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?