半年前に自分のリポジトリで git blame を実行し、手が止まりました。
修正しようとしていた 3 行の担当者は、私自身の名前になっています。日付は 1 月。コミットメッセージは「Refactor cache layer and update tests」。変更されたファイルは 14 個、差分は 800 行を超えていました。
その 3 行は、エージェントが書いたものでした。当時の私は計画を承認し、diff をざっと眺め、まとめて 1 コミットにして push しています。半年後の私に残されたのは、800 行の海に浮かぶ 3 行と、何も語らない一文だけでした。
なぜこの条件分岐が必要だったのか。どのエラーを踏んで、この順序に落ち着いたのか。手がかりは、すでにどこにもありません。
コミットは「作業の単位」ではなく「意図の単位」
人間が手で書いていた頃、コミットの粒度は自然に制御されていました。1 時間かけて 1 つのことを直し、それをコミットする。作業時間そのものが粒度の上限として働いていたわけです。
エージェントに委ねると、この上限が消えます。1 回のセッションで計画が 6 ステップ進み、14 ファイルが書き換わる。それを 1 コミットにまとめてしまうのは、単に「セッションの区切り」がコミットの区切りに見えるからです。
しかし後から履歴を読む人間にとって、セッションの区切りには何の意味もありません。意味を持つのは意図の区切りです。
| コミットの割り方 | 半年後の git blame が返すもの | 差分の平均行数 |
| 1 セッション = 1 コミット | 「Refactor cache layer and update tests」 | 400〜900 行 |
| 1 計画ステップ = 1 コミット | 「Add TTL guard to cache read path」 | 30〜120 行 |
| 1 ファイル = 1 コミット | ファイル名の再掲。意図は失われたまま | 10〜60 行 |
3 行目に注意していただきたいのです。粒度を細かくすれば良いという話ではありません。ファイル単位で割っても、なぜその変更が必要だったのかは記録されません。
基準は行数ではなく、その変更を単独で説明できるかです。Antigravity のエージェントは計画(plan)を明示的に持ちます。計画の 1 ステップは、定義上「単独で説明できる変更」に対応しています。ここをコミット境界にするのが、個人開発で 4 サイトの記事生成を自動化するうちに落ち着いた形です。
レビュー担当が私自身しかいない環境では、履歴の読みやすさがそのまま将来の修正速度になります。
エージェントに計画ステップ単位でコミットさせる
実装は素朴で構いません。エージェントに「全部終わったらコミットして」と伝えるのをやめ、ステップの完了ごとにコミットさせます。
# .agent/commit-step.sh
# 使い方: commit-step.sh <step-id> <要約>
set -euo pipefail
STEP_ID="$1"; shift
SUMMARY="$*"
# ステージされた変更がなければ何もしない(空コミットを作らない)
if git diff --cached --quiet; then
echo "step ${STEP_ID}: staged changes なし。コミットをスキップします。"
exit 0
fi
FILES=$(git diff --cached --name-only | wc -l | tr -d ' ')
LINES=$(git diff --cached --numstat | awk '{ add += $1; del += $2 } END { print add + del }')
# 意図の単位を逸脱しつつある兆候を、その場で警告する
if [ "${FILES}" -gt 8 ] || [ "${LINES}" -gt 300 ]; then
echo "⚠️ step ${STEP_ID}: ${FILES} ファイル / ${LINES} 行。計画ステップが大きすぎる可能性があります。" >&2
fi
git commit -m "${SUMMARY}" \
-m "Agent-Run-Id: ${AGENT_RUN_ID}" \
-m "Agent-Step: ${STEP_ID}" \
-m "Agent-Model: ${AGENT_MODEL}" \
-m "Agent-Plan-Sha: ${AGENT_PLAN_SHA}"
git commit に -m を複数渡すと、2 番目以降が本文の段落として積まれます。末尾の段落が Key: Value の形だけで構成されていれば、Git はそれをトレーラ(trailer)として扱います。つまり git interpret-trailers --parse や git log --format='%(trailers:key=Agent-Run-Id,valueonly)' で機械的に取り出せる、ということです。
コミットメッセージに散文で「エージェントが生成」と書くのとは、まったく意味が違います。散文は読めますが、検索も集計もできません。
AGENT_PLAN_SHA は、承認した計画テキストのハッシュです。計画そのものはセッションに紐づいて消えますが、ハッシュがあれば「この 3 コミットは同じ計画から生まれた」ことは後から確定できます。
アーティファクトは消える、という前提から始める
Antigravity のエージェントは、実行の過程でウォークスルーや検証記録といったアーティファクトを生成します。ブラウザを起動して自己検証した結果のスクリーンショットも、そこに含まれます。
私は当初、コミットのトレーラにアーティファクトの URL を書いていました。数か月後、その URL の多くが開けなくなっていることに気づきます。ローカルの実行履歴を消したり、ワークスペースを作り直したりすれば、当然そうなります。
外部への参照は、参照先が生き続ける保証がある場合にしか意味を持ちません。ここは諦めて、要旨だけをリポジトリ内に凍結する方針に切り替えました。
# .agent/freeze_provenance.py
"""計画ステップの検証記録から、後から読む価値のある要旨だけを抜き出して凍結する。
スクリーンショットや全文ログは保存しない。半年後に必要なのは
「何を確かめたか」「何が根拠だったか」であって、画素ではないため。
"""
from __future__ import annotations
import hashlib
import json
import pathlib
import subprocess
import sys
PROVENANCE_DIR = pathlib.Path(".agent-provenance")
MAX_NOTE_CHARS = 1200
def freeze(run_id: str, step_id: str, record: dict) -> pathlib.Path:
"""1 ステップ分の来歴を JSON として保存し、そのパスを返す。"""
PROVENANCE_DIR.mkdir(exist_ok=True)
payload = {
"run_id": run_id,
"step_id": step_id,
"model": record["model"],
# なぜこう実装したか(人間が読む本文)
"intent": record["intent"][:MAX_NOTE_CHARS],
# 何をもって正しいと判断したか(検証の要旨のみ)
"verification": [v[:240] for v in record.get("verification", [])][:6],
# 検討して捨てた選択肢。これが半年後に一番効く
"rejected": [r[:240] for r in record.get("rejected", [])][:4],
"plan_sha": record["plan_sha"],
}
body = json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)
digest = hashlib.sha256(body.encode("utf-8")).hexdigest()[:12]
path = PROVENANCE_DIR / f"{run_id}-{step_id}-{digest}.json"
path.write_text(body + "\n", encoding="utf-8")
# 凍結ファイル自体を同じコミットに含める
subprocess.run(["git", "add", str(path)], check=True)
return path
if __name__ == "__main__":
record = json.load(sys.stdin)
out = freeze(sys.argv[1], sys.argv[2], record)
print(out)
rejected(検討して捨てた選択肢)を残すことに、私は最も価値を感じています。
半年後の自分が同じコードに戻ってくるとき、頭に浮かぶのはたいてい「なぜもっと素直に書かなかったのか」という疑問です。その素直な書き方をエージェントが検討し、なぜ捨てたかが 2 行残っていれば、私は同じ道を二度歩まずに済みます。
1 ステップあたりの凍結ファイルは、上限を効かせて 1〜3 KB に収まります。年間 1,500 ステップ動かしても数 MB です。リポジトリを圧迫する規模ではありません。
blame から設計判断まで逆引きする
ここまでの仕込みが効くのは、逆引きが 1 コマンドで済むときだけです。手順が 3 つに分かれた瞬間、人は誰も辿らなくなります。
#!/usr/bin/env python3
# .agent/why.py — 使い方: why.py <file> <line>
"""指定行を書いたコミットの来歴を辿り、意図・検証・棄却案を表示する。"""
from __future__ import annotations
import json
import pathlib
import subprocess
import sys
def _git(*args: str) -> str:
return subprocess.run(
["git", *args], check=True, capture_output=True, text=True
).stdout.strip()
def blame_commit(path: str, line: int) -> str:
# -L <line>,<line> で該当行だけを blame し、先頭のコミット SHA を得る
out = _git("blame", "-L", f"{line},{line}", "--porcelain", "--", path)
return out.split("\n", 1)[0].split(" ", 1)[0]
def trailer(sha: str, key: str) -> str:
return _git("log", "-1", f"--format=%(trailers:key={key},valueonly)", sha).strip()
def main() -> int:
path, line = sys.argv[1], int(sys.argv[2])
sha = blame_commit(path, line)
subject = _git("log", "-1", "--format=%s", sha)
run_id = trailer(sha, "Agent-Run-Id")
step_id = trailer(sha, "Agent-Step")
print(f"{sha[:12]} {subject}")
if not run_id:
print(" → 人間が直接書いた行です(来歴トレーラなし)。")
return 0
print(f" model: {trailer(sha, 'Agent-Model')} run: {run_id} step: {step_id}")
# 凍結済みの来歴を探す。ファイル名は run-step-digest 形式
matches = sorted(pathlib.Path(".agent-provenance").glob(f"{run_id}-{step_id}-*.json"))
if not matches:
print(" → 来歴ファイルが見つかりません(凍結前のコミットの可能性)。")
return 1
rec = json.loads(matches[-1].read_text(encoding="utf-8"))
print(f"\n 意図: {rec['intent']}")
if rec["verification"]:
print(" 検証:")
for v in rec["verification"]:
print(f" - {v}")
if rec["rejected"]:
print(" 棄却した案:")
for r in rec["rejected"]:
print(f" - {r}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
python .agent/why.py src/cache.py 87 と打てば、その行を書いたのは誰(どのモデル)で、何を確かめ、何を捨てたのかが 1 画面に出ます。
冒頭で私が立ち往生した 3 行に、この仕組みが入っていたなら。おそらく「TTL 期限切れとキー欠落を同一分岐で扱うと、キャッシュ再構築が二重に走る」という一文が、そこに残っていたはずです。
強制しなければ、仕組みは半年もたない
来歴トレーラは、書き忘れた瞬間に価値を失います。1 つでも欠けた行があれば、逆引きは「見つかりません」を返し、人はその仕組みを信用しなくなります。
commit-msg フックで機械的に塞ぎます。
#!/usr/bin/env bash
# .git/hooks/commit-msg
set -euo pipefail
MSG_FILE="$1"
# エージェント実行中でなければ何も要求しない(手作業のコミットは自由)
[ "${AGENT_RUN_ID:-}" = "" ] && exit 0
for KEY in Agent-Run-Id Agent-Step Agent-Model Agent-Plan-Sha; do
if ! git interpret-trailers --parse < "${MSG_FILE}" | grep -q "^${KEY}:"; then
echo "❌ トレーラ ${KEY} がありません。commit-step.sh 経由でコミットしてください。" >&2
exit 1
fi
done
# 来歴ファイルの同梱を確認する
if ! git diff --cached --name-only | grep -q '^\.agent-provenance/'; then
echo "❌ .agent-provenance/ の凍結ファイルが同梱されていません。" >&2
exit 1
fi
環境変数 AGENT_RUN_ID の有無で、人間の手作業とエージェント実行を分けています。人間が慌てて直すときにまで来歴を要求すると、フックは真っ先に --no-verify で回避され、そして二度と戻ってきません。
守らせたい相手を、守れる範囲に絞る。エージェントの権限設計と同じ考え方です。この線引きについては、Antigravity Agent に書き込み権限を安全に渡す — Permission Boundary の本番設計 でも触れています。
導入して変わったこと、変わらなかったこと
4 サイトの自動運用に組み込んで 2 か月ほどが経ちました。
コミット数は約 2.4 倍に増えました。1 セッションが平均 5〜7 コミットに割れたためです。履歴は確実に長くなり、git log --oneline を眺めるだけでは全体像がつかみにくくなりました。ここは代償として受け入れています。
一方で、ある行の意図を思い出すまでの時間は、体感で数分から十数秒になりました。why.py が答えを返すか、「人間が直接書いた行です」と告げるか、どちらかしか起こらないためです。分からないという状態がなくなったこと自体が、私にとっては大きな変化でした。
変わらなかったこともあります。エージェントが書いた intent は、しばしば実装の言い換えに留まります。「TTL チェックを追加した」と書かれていても、なぜ必要だったかは書かれていない。ここは計画を承認する時点で、私が一言足すしかありません。
来歴管理は、記録の仕組みを作れば自動で意味が湧いてくるものではないのだと、繰り返し思い知らされています。
どこから始めるか
すべてを一度に入れる必要はありません。効果の順に並べるなら、こうなります。
まず commit-step.sh だけを入れて、計画ステップごとにコミットを割ってください。トレーラも凍結ファイルもまだ要りません。コミットメッセージが具体的になるだけで、git blame の情報量は大きく変わります。
次にトレーラを足します。集計できるようになると、「今月のコミットの何割がエージェント由来か」が数字で見えるようになり、レビューの重点配分が変わります。
凍結ファイルと why.py は最後で構いません。これが効くのは、半年後にその行へ戻ってきたときだからです。
並行して複数のエージェントを走らせている場合は、コミット境界の前に作業領域そのものの分離が要ります。そちらは 複数エージェントを同じリポジトリで並走させると壊れる — worktree で作業領域を分離する設計 にまとめました。
半年後の自分は、他人です。その他人に向けて、いま何を残しておくか。エージェントに任せる範囲が広がるほど、この問いの重みは増していくのだと感じています。
次に git blame で手が止まったとき、この記事のどこかが手がかりになっていれば嬉しく思います。