同じ注意書きを4つ目のファイルに書き写しているとき、手が止まりました。
4つの静的サイトのリポジトリを、それぞれ別の規約ファイルで扱っています。書き出しは違うのに、真ん中から先はほとんど同じことが書いてある。1つ直すたびに、残り3つを開いて同じ場所を探す作業が発生していました。個人開発で複数のプロジェクトを並行して抱えていると、この手の写し書きは静かに積み上がります。
Antigravity 2.11.0(8月26日)で AGENTS.md とカスタムルールファイルの中に @path/to/file を書けるようになり、外部ファイルを直接参照してインライン展開できるようになりました。この重複を畳めるということです。
分割すれば読み込み量も減るだろう、と何となく思っていました。手元のファイルで実際に測ってみたところ、そこは減りませんでした。減ったのは別のところです。
分割で減るのは保守量で、1セッションが読む量ではありません
先に結論から書きます。手元の4リポジトリの規約ファイルを、共通部と固有部に分けて @ で組み直したときの実測値です。
| 対象 | 分割前 | 分割後 | 変化 |
| 保守するファイルの総バイト数 | 340,824 | 129,229 | 62% 減 |
| 1セッションが読む展開後バイト数(1リポジトリ分) | 85,736 | 84,260 | 1.7% 減 |
保守対象は3分の1近くまで落ちました。一方、エージェントが実際に受け取る量はほとんど変わっていません。
理由は考えれば当たり前で、@ は取り込み先の中身をその場に展開するからです。共通規約を1ファイルにまとめても、参照している側を開けば結局その中身が全部そこに入ります。重複が消えるのはディスク上とレビュー時の話であって、展開後の入力にとっては何も起きていないということです。
もう少し細かい数字も出しておきます。4ファイルの空行を除いた行を数えると、延べ3,430行に対して、重複を除いたユニーク行は1,214行でした。4ファイル全部に同じ文字列で現れる行が695行(35,639文字)あります。
| 指標 | 行数 |
| 4ファイルの延べ行数(空行除く) | 3,430 |
| 重複を除いたユニーク行 | 1,214 |
| 4ファイル全部に出現する行 | 695 |
| 2ファイル以上に出現する行 | 780 |
固有情報は全体の35%しかありませんでした。残りは手で写した同じ文章です。この状態で1箇所を直し忘れると、リポジトリごとにエージェントの前提が食い違います。分割の動機は入力量の削減ではなく、この食い違いを構造的に起こらなくすることでした。
ここを取り違えたまま分割すると、期待していた効果が出ずに「分けた意味がなかった」という結論に行き着きます。何を買っているのかを先に決めておく必要があります。
同じファイル木が、解決基準の違いで二通りに壊れます
分割を始めてすぐ、判断が必要な箇所にぶつかりました。@rules/common.md と書いたとき、この rules/ はどこから見た rules/ なのか。
選択肢は2つです。
- ルート基準: プロジェクトのルートから見た相対パス。どのファイルに書いても同じ意味になる
- 元ファイル基準: その
@ を書いたファイルのあるディレクトリから見た相対パス。ファイルを移動すると意味が変わる
1段しか取り込まないうちは、どちらでも同じ結果になります。差が出るのは、取り込んだ先がさらに取り込むときです。
検証のために、循環する3ファイルを用意しました。a.md が rules/b.md を、b.md が rules/c.md を、c.md が a.md を取り込む構成です。
同じファイル木を、解決基準だけ変えて展開した結果がこちらです。
--- base=root ---
{"entry": "a.md", "ok": false,
"error": "循環参照: a.md -> rules/b.md -> rules/c.md -> a.md"}
--- base=file ---
{"entry": "a.md", "ok": true, "base": "file", "chars": 48,
"files": 3, "missing": ["rules/rules/c.md"], "duplicated": {}}
ルート基準では循環参照として止まりました。想定どおりです。
元ファイル基準では止まりません。rules/b.md の中の @rules/c.md が rules/rules/c.md として解決され、そんなファイルは無いので取り込みが不成立になります。循環が成立しないので、エラーにもなりません。展開は成功したことになり、規約が1ファイル分だけ静かに落ちます。
これが分割で踏みうる、いちばん質の悪い失敗だと考えています。壊れ方が例外ではなく欠落として現れるため、実行結果を見ても異常が分かりません。エージェントの振る舞いが少し変だ、という曖昧な形でしか表に出てきません。
規約ファイルは、書いてあることが守られているかどうかを毎回確認する種類のファイルではありません。だからこそ、欠落は長く残ります。
私自身、この落ち方に気づいたのは、展開器を書いて missing を出力させた後でした。それまでは、基準の違いは書き方の好みの問題だと思っていました。
参照先が無いときに空を返す実装が、いちばん危ない
上の話には対処があります。展開器の側で、参照先が見つからないときに空文字を返さないことです。
自分で書いた展開器では、見つからないパスをマーカーとして残すようにしました。
if not os.path.isfile(real):
stat["missing"].append(path)
return f"<!-- MISSING INCLUDE: {path} -->\n", stat
たった3行ですが、これがあるかないかで事故の見つかり方が変わります。展開後のテキストを目視したときにも、missing の配列を機械で検査したときにも、どちらでも引っかかります。
同じ考え方で、深さの上限も入れておきます。循環を検出できない基準を選んでしまった場合の保険です。
if depth > max_depth:
raise Cycle(f"{max_depth} 段を超えました: {os.path.relpath(real, root)}")
循環検出と深さ制限は役割が重なって見えますが、片方で拾えないケースをもう片方が拾います。両方入れておくのが安いと判断しました。
展開器の全体
エージェントに渡す前に自分で展開して、中身を確認するためのスクリプトです。手元ではこれを規約ファイルの変更時に走らせています。
#!/usr/bin/env python3
"""@path/to/file の取り込みを、エージェントへ渡す前に展開して監査する。
base="file" … 取り込み元ファイルからの相対(ネストすると基準が動く)
base="root" … プロジェクトルート固定(どこに書いても同じ意味になる)
"""
import sys, re, os, json
# 行全体が @path のときだけ取り込みとみなす。
# 本文中の「@mention」やメールアドレスを巻き込まないための制約。
INCLUDE = re.compile(r'^([ \t]*)@([A-Za-z0-9_./\-]+\.(?:md|txt|json))[ \t]*$')
class Cycle(Exception):
pass
def expand(path, root, base="root", stack=None, stat=None, depth=0, max_depth=16):
if stack is None:
stack = []
stat = {"count": {}, "missing": []}
real = os.path.realpath(os.path.join(root, path))
# realpath で正規化してから比較する。シンボリックリンクや ../ を挟んだ
# 別表記の同一ファイルを、別物として通してしまわないため。
if real in stack:
chain = [os.path.relpath(p, root) for p in stack[stack.index(real):]]
raise Cycle(" -> ".join(chain + [os.path.relpath(real, root)]))
if depth > max_depth:
raise Cycle(f"{max_depth} 段を超えました: {os.path.relpath(real, root)}")
stat["count"][real] = stat["count"].get(real, 0) + 1
# 見つからないときに空を返さない。規約が黙って消えるのがいちばん危ない。
if not os.path.isfile(real):
stat["missing"].append(path)
return f"<!-- MISSING INCLUDE: {path} -->\n", stat
out = []
for line in open(real, encoding="utf-8"):
m = INCLUDE.match(line)
if not m:
out.append(line)
continue
indent, target = m.group(1), m.group(2)
anchor = "" if base == "root" else os.path.dirname(os.path.relpath(real, root))
child, stat = expand(os.path.join(anchor, target), root, base,
stack + [real], stat, depth + 1, max_depth)
# 取り込み元のインデントを子側にも伝播させる。箇条書きの途中で
# 取り込んだときに、階層が崩れないようにするため。
out.extend(indent + l if l.strip() else l for l in child.splitlines(keepends=True))
return "".join(out), stat
def audit(entry, root, base="root"):
try:
text, stat = expand(entry, root, base)
except Cycle as e:
return {"entry": entry, "ok": False, "error": f"循環参照: {e}"}
return {
"entry": entry, "ok": True, "base": base,
"chars": len(text),
"files": len(stat["count"]),
"missing": stat["missing"],
"duplicated": {os.path.relpath(p, root): n
for p, n in stat["count"].items() if n > 1},
}
if __name__ == "__main__":
root, base = sys.argv[1], sys.argv[2]
for entry in sys.argv[3:]:
print(json.dumps(audit(entry, root, base), ensure_ascii=False))
使い方は次のとおりです。
python3 expand.py . root AGENTS.md
正規表現を行全体に限定しているのは意図的です。@ は文章中にもよく出てくる記号で、部分一致にすると本文の途中を取り込みとして解釈してしまいます。実際、最初は行頭一致だけで書いていて、箇条書きの中の説明文を巻き込みました。行全体・末尾の空白のみ許容、という形に落ち着いています。
共通規約を2経路から取り込むと、展開量は素直に二重になります
もうひとつ、分割後に増えるほうの落とし穴があります。
分割を進めていくと、共通ファイルを取り込む中間ファイルが複数できます。entry.md が x.md と y.md を取り込み、その両方が common.md を取り込む形です。木ではなくグラフになります。
3,200バイトの common.md を用意して測りました。
{"entry": "entry.md", "ok": true, "base": "root", "chars": 2416,
"files": 4, "missing": [], "duplicated": {"rules/common.md": 2}}
duplicated に2と出ています。展開後には common.md の中身がそのまま2回入っています。同じ規約が2回書いてある入力を、エージェントは素直に2回読みます。
分割の階層を深くするほど、この形は作りやすくなります。分割して整理したつもりが、展開後の量は分割前より増えている、ということが起こりえます。上の展開器で duplicated を出しているのはこのためです。
対処としては、共通ファイルの取り込みをエントリポイント1箇所に寄せて、中間ファイルからは取り込まない構成にしました。中間ファイルは共通規約が既に読まれている前提で書きます。この前提はファイル単体では確認できないので、共通規約を必要とするファイルの先頭に、どこから取り込まれる想定かをコメントで1行残しています。
分割してよい境界と、1枚のままにすべき境界
実際に組み直してみて、線を引く基準がいくつか見えてきました。
分割してよいもの
- 複数のリポジトリで文字通り同じ文面になっている規約。上の実測で言えば695行がここに該当します
- 参照するかどうかが作業内容によって変わる補足資料。読ませる必要がないセッションで読ませずに済みます
- 更新の主体が違うもの。片方だけを頻繁に触る場合、差分が読みやすくなります
1枚のままにすべきもの
- 順序に意味がある規約。取り込み位置がずれると優先順位の解釈が変わります
- 前後の文脈と一緒でないと誤読される条件つきの規則。「ただし〜の場合を除く」が別ファイルに離れると危険です
- 100行に満たない小さな塊。ファイルが増える手間のほうが上回ります
最後の点は実際にやり過ぎて戻しました。細かく分けるほど整理された気分になりますが、@ が並ぶだけのエントリファイルは、開いても何が書いてあるか分かりません。ファイルを開いて内容が読める状態を保つほうが、結局は速いという結論です。レビューする人間が自分ひとりしかいない個人開発では、この差がそのまま作業時間に出ます。
判断の順序としては、まず「同じ文面が複数箇所にあるか」を見ます。無ければ分割しません。あれば、その塊が単独で読んで意味が通るかを確認します。通らないなら、通るところまで範囲を広げてから切り出します。
配下ディレクトリの設定ファイルとの併用
2.11.0 では、プロジェクトの配下ディレクトリに置いた skills.json・agents.json・rules.json から、カスタムスキル・エージェント・ルールを発見できるようになりました。複数パッケージを持つ構成で、パッケージごとに別の規約を持たせられます。
@ 取り込みとは役割が違います。@ は「1つの規約ファイルの中身をどう構成するか」の道具で、配下ディレクトリの設定ファイルは「どの規約ファイルがいつ選ばれるか」の道具です。
手元では、次のように使い分けています。
| やりたいこと | 使うもの |
| 複数リポジトリで同じ文面の規約を1箇所に持つ | @path/to/file 取り込み |
| 作業しているディレクトリによって適用する規約を変える | 配下の rules.json |
| 特定のエージェントにだけ特定の規約を適用する | frontmatter の rules: キー |
3つ目の frontmatter の rules: キーは、カスタムの Markdown エージェントに特定のルールファイルを直接ひもづけるものです。エージェントごとに従う規約が違う場面で、その対応関係をファイルに書けます。
併用するときに注意しているのは、同じ規約が別経路から二重に入らないようにすることです。配下の rules.json で読まれる規約が、@ でも取り込まれていると、前節の二重展開と同じ状態になります。展開器で数えられるのは @ の経路だけなので、こちらは構成を決める段階で重ならないようにしておくしかありません。
手を入れる前に、一度測ってみてください
分割を検討しているなら、最初にやるべきは分割ではなく計測だと思います。手元のファイルで重複行を数えるだけなら、次の1行で済みます。
cat rules-a.md rules-b.md rules-c.md | grep -v '^\s*$' \
| sort | uniq -c | sort -rn | awk '$1>1' | wc -l
この数が小さければ、分割しても得るものはありません。大きければ、何が減るのかを理解したうえで進められます。私の場合は3,430行のうち2,216行が重複でしたが、それを知る前と後では、分割の設計がまったく違うものになりました。
分割そのものより、分割で何が変わらないのかを知っているほうが、後から効いてくると感じています。最後までお付き合いいただき、ありがとうございました。