複数のリポジトリでエージェントを無人で走らせていると、朝にまとめて Pull Request の山をレビューすることになります。私自身、個人開発のアプリ(AdMob で収益化しています)と運用中のブログを合わせて、数本のリポジトリを並行で回しているので、この「朝の PR レビュー」が日課になっています。
困るのは、説明欄がほとんど役に立たないことでした。「Update files」「Fix issues」「Apply changes」。エージェントが自動で付けた説明は、たいていこの三語のどれかに収束します。
説明が空っぽだと、結局は差分を毎回ゼロから読むしかありません。数本ならまだしも、本数が増えると読み切れなくなり、ある朝「まあ大丈夫だろう」と中身をよく見ずにマージしてしまう。事故は、たいていこの瞬間に起きます。
問題は差分そのものではなく、「人間が短時間で安全に判断できる形に要約されていない」ことでした。そこで、エージェントに構造化された要約を書かせ、空疎な説明は機械的に弾くゲートを挟むようにしました。その設計と動くコードを追っていきます。
なぜエージェントの PR 説明は空疎になりがちなのか
エージェントは、与えられたタスクを「完了」させることに最適化されています。コードが書けてテストが通れば、その時点で目的は達成です。PR の説明は、エージェントにとっては副産物でしかありません。
しかも説明文には、コードのように「通る・落ちる」の判定がありません。「Update files」でも構文エラーにはならないので、誰も困らないまま素通りしてしまいます。
もう一つの理由は、エージェントが「自分が何をしたか」は出力できても、「人間が何を確認すべきか」までは自発的に書かないことです。両者はまったく別の情報です。前者は変更の記録、後者はレビューの設計図です。レビューに必要なのは後者なのに、放っておくと前者すら省略されます。
つまり、説明の質はプロンプトとゲートで明示的に要求しない限り、構造的に底へ落ちていきます。
レビューできる説明に必要な5つの要素
何本もの PR を読み返して、人間が短時間で安全に判断できた説明には共通する型がありました。次の5つです。
| 要素 | 問い | 空疎な例 → 機能する例 |
| What(何を) | どのファイル群に何の変更をしたか | 「Update files」→「広告表示の頻度制御を AdFrequencyController に切り出し」 |
| Why(なぜ) | どの課題を解くための変更か | (記載なし)→「特定条件でインタースティシャルが二重表示される不具合の修正」 |
| Risk(リスク) | 壊れたら影響が大きいのはどこか | (記載なし)→「課金導線には触れていないが、広告頻度の既定値を変更したため収益指標に影響しうる」 |
| Test(根拠) | 正しさをどう確かめたか | 「Tested」→「二重表示の再現テストを追加し、頻度上限の境界値3ケースを検証」 |
| Review focus(注視点) | 人間に特に見てほしい箇所はどこか | (記載なし)→「shouldShowAd() の境界条件と、既定値の変更が妥当か」 |
このうち、レビューを軽くするのに最も効くのは Risk と Review focus です。What と Why は差分を読めば(時間をかければ)復元できますが、Risk と Review focus は変更した本人にしか分からない情報で、ここを書かせることがレビューの負荷を最も下げます。
逆に言えば、この2つが空欄の PR は「読み手に判断を丸投げしている」状態です。ゲートで重点的に守るべきはここだと考えています。
エージェントに構造化要約を書かせるプロンプト設計
まず、エージェントに自由記述ではなく決まった見出しを埋めさせます。Antigravity のエージェントに渡すタスク指示の末尾に、次のテンプレートを必須として添えています。
PR の説明は、必ず以下の見出しをすべて埋めて Markdown で出力してください。
各見出しには最低1文を書き、該当しない場合も「該当なし」と理由を添えてください。
## What
変更したファイル群と、加えた変更の要点を箇条書きで。
## Why
この変更が解決する課題。関連 issue があれば番号を添える。
## Risk
壊れたときに影響が大きい箇所。課金・認証・データ移行・設定値の
いずれかに触れた場合は、必ずその旨と影響範囲を明記する。
## Test
正しさを確かめた方法。追加・変更したテストと、確認した境界条件。
## Review focus
レビュアーに特に確認してほしい箇所を1〜3点。
このテンプレートの肝は、Risk の項に「課金・認証・データ移行・設定値に触れたら必ず書け」という具体的なトリガーを埋め込んでいる点です。抽象的に「リスクを書け」と言うと、エージェントは「特になし」と返しがちです。何に触れたら書くべきかを名指しすると、出力が安定します。
ただし、プロンプトだけでは守られません。エージェントは指示を無視することがありますし、無人運用ではその場で叱る人もいません。だから、出力を機械的に検証するゲートを必ず後段に置きます。
空疎な説明を弾く検証ゲート
次のスクリプトは、PR の説明文(標準入力)と変更ファイルの一覧(git から取得)を受け取り、要件を満たさなければ終了コード 1 で落ちます。Antigravity の CLI フックや CI に挟む前提の、依存ライブラリなしの Node.js です。
何を解決するコードかというと、「必須見出しがすべて埋まっているか」「Why が What の言い換えになっていないか」「高リスクなパスに触れたのに Risk が実質空欄でないか」を自動で確かめるためのものです。
#!/usr/bin/env node
// validate-pr-description.mjs
// 使い方:
// git diff --name-only origin/main...HEAD > /tmp/changed.txt
// cat pr-body.md | node validate-pr-description.mjs --changed /tmp/changed.txt
import { readFileSync } from "node:fs";
// --- 1. 入力の取得 -------------------------------------------------
function readStdin() {
try {
return readFileSync(0, "utf8");
} catch {
return "";
}
}
function getChangedFiles() {
const idx = process.argv.indexOf("--changed");
if (idx === -1) return [];
const path = process.argv[idx + 1];
return readFileSync(path, "utf8")
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
}
// --- 2. 必須見出しの定義 -------------------------------------------
const REQUIRED_SECTIONS = ["What", "Why", "Risk", "Test", "Review focus"];
// 「実質空欄」とみなす定型句。これだけしか書かれていなければ不合格。
const VACUOUS = [
/^update files?\.?$/i,
/^fix(es|ed)? issues?\.?$/i,
/^apply changes\.?$/i,
/^n\/?a\.?$/i,
/^なし\.?$/,
/^特になし\.?$/,
/^該当なし\.?$/, // Risk/Test では理由なしの「該当なし」も不可(後述)
];
// --- 3. 高リスクなパスのパターン -----------------------------------
const HIGH_RISK = [
{ label: "課金", re: /(billing|payment|checkout|stripe|pricing|subscription)/i },
{ label: "認証", re: /(auth|login|session|token|oauth|credential)/i },
{ label: "データ移行", re: /(migration|migrate|schema|\.sql$)/i },
{ label: "設定値", re: /(config|\.env|settings|\.ya?ml$|\.toml$)/i },
{ label: "削除", re: null }, // 削除は別途、行数から判定
];
// --- 4. 説明文を見出しごとに分解 -----------------------------------
function parseSections(body) {
const map = {};
const re = /^##\s+(.+?)\s*$/gm;
const heads = [...body.matchAll(re)];
for (let i = 0; i < heads.length; i++) {
const name = heads[i][1].trim();
const start = heads[i].index + heads[i][0].length;
const end = i + 1 < heads.length ? heads[i + 1].index : body.length;
map[name.toLowerCase()] = body.slice(start, end).trim();
}
return map;
}
function isVacuous(text) {
if (!text) return true;
const firstLine = text.split("\n")[0].trim();
return VACUOUS.some((re) => re.test(firstLine));
}
// 単語の重なりで「Why が What の言い換え」を検出する素朴な指標
function jaccard(a, b) {
const norm = (s) =>
new Set(
s
.toLowerCase()
.replace(/[^\p{L}\p{N}\s]/gu, " ")
.split(/\s+/)
.filter((w) => w.length > 1)
);
const sa = norm(a);
const sb = norm(b);
if (sa.size === 0 || sb.size === 0) return 0;
let inter = 0;
for (const w of sa) if (sb.has(w)) inter++;
return inter / (sa.size + sb.size - inter);
}
// --- 5. 検証本体 ---------------------------------------------------
function validate(body, changed) {
const errors = [];
const sections = parseSections(body);
// 5-1. 必須見出しの存在と非空
for (const name of REQUIRED_SECTIONS) {
const text = sections[name.toLowerCase()];
if (text === undefined) {
errors.push(`見出し「## ${name}」がありません`);
} else if (isVacuous(text)) {
errors.push(`「## ${name}」が実質空欄です: "${text.split("\n")[0]}"`);
}
}
// 5-2. Why が What の言い換えになっていないか
const what = sections["what"] ?? "";
const why = sections["why"] ?? "";
if (what && why && jaccard(what, why) > 0.8) {
errors.push("Why が What とほぼ同一です。動機(なぜ)を書いてください");
}
// 5-3. 高リスクなパスに触れたら Risk への言及を必須にする
const hitLabels = new Set();
for (const f of changed) {
for (const { label, re } of HIGH_RISK) {
if (re && re.test(f)) hitLabels.add(label);
}
}
// 高リスク領域に触れたのに Risk が実質空欄なら不合格。
// ラベル名そのものの言及までは求めない(過剰検出を避け、非空であることだけ保証する)。
if (hitLabels.size > 0 && isVacuous(sections["risk"] ?? "")) {
const labels = [...hitLabels].join("・");
errors.push(`${labels}に関わるファイルを変更していますが、Risk が空欄です`);
}
return errors;
}
// --- 6. 実行 -------------------------------------------------------
const body = readStdin();
const changed = getChangedFiles();
if (!body.trim()) {
console.error("PR 説明が空です。テンプレートを埋めてください。");
process.exit(1);
}
const errors = validate(body, changed);
if (errors.length > 0) {
console.error("PR 説明がレビュー要件を満たしていません:\n");
for (const e of errors) console.error(" - " + e);
console.error("\nテンプレートの見出しをすべて埋め、Risk と Review focus を具体的に書いてください。");
process.exit(1);
}
console.log("PR 説明はレビュー要件を満たしています。");
なぜこの形にしているか、一点補足します。jaccard による「Why が What の言い換え」検出は、わざと素朴な単語重なりに留めています。厳密な意味判定をしようとすると誤検出が増え、エージェントが説明を書き直すループに陥りがちだからです。閾値 0.8 は、私の運用では「明らかにコピペしただけ」を捕まえる程度に効き、正当な説明はほとんど通します。
差分からリスクを自動で見積もって人間に渡す
ゲートで弾くだけでなく、レビュアー(人間)の判断を助けるために、差分から「注視すべき度合い」を見積もって PR に追記すると効果的でした。次の小さなスクリプトは、変更行数と高リスクなパスへの接触から、ざっくりしたリスクスコアを出します。
// risk-score.mjs — git diff の数値とパスからリスクの目安を出す
import { execSync } from "node:child_process";
const base = process.argv[2] ?? "origin/main";
const stat = execSync(`git diff --numstat ${base}...HEAD`, { encoding: "utf8" })
.split("\n")
.filter(Boolean)
.map((l) => {
const [add, del, file] = l.split("\t");
return { add: Number(add) || 0, del: Number(del) || 0, file };
});
const PATTERNS = [
{ label: "課金", re: /(billing|payment|checkout|stripe|pricing)/i, weight: 5 },
{ label: "認証", re: /(auth|session|token|credential)/i, weight: 4 },
{ label: "データ移行", re: /(migration|schema|\.sql$)/i, weight: 5 },
{ label: "設定値", re: /(config|\.env|\.ya?ml$|\.toml$)/i, weight: 3 },
];
let score = 0;
const reasons = [];
let totalDel = 0;
for (const { add, del, file } of stat) {
totalDel += del;
for (const { label, re, weight } of PATTERNS) {
if (re.test(file)) {
score += weight;
reasons.push(`${label}: ${file} (+${add}/-${del})`);
}
}
}
// 大量削除は単独で加点(消すことは足すより壊しやすい)
if (totalDel > 200) {
score += 3;
reasons.push(`大量削除: 計 -${totalDel} 行`);
}
const level = score >= 8 ? "高" : score >= 4 ? "中" : "低";
console.log(`リスク目安: ${level} (score=${score})`);
for (const r of reasons) console.log(" - " + r);
このスコアはあくまで「目安」であって、合否判定には使いません。私が大切にしているのは、機械が判断を肩代わりしないことです。スコアは「ここは特に見てね」という注意喚起にとどめ、マージするかどうかは必ず人間が決めます。エージェントの自動化を進めるほど、最後の一線を人間が握っている感覚が安心につながります。
ゲートをエージェントのループに組み込む
検証は、エージェントが PR を開く直前に走らせます。Antigravity の CLI を無人実行している場合は、PR 作成コマンドの前段に挟むのが素直です。
#!/usr/bin/env bash
# open-pr.sh — 検証を通った PR だけを開く
set -euo pipefail
BASE="origin/main"
BODY_FILE="pr-body.md" # エージェントが書いた説明
git diff --name-only "${BASE}...HEAD" > /tmp/changed.txt
# 1. 説明の検証ゲート(落ちたらここで停止)
if ! cat "${BODY_FILE}" | node validate-pr-description.mjs --changed /tmp/changed.txt; then
echo "説明がレビュー要件未達のため PR を開きません。エージェントに書き直させます。"
exit 1
fi
# 2. リスク目安を説明の末尾に追記(人間向けの注意喚起)
{
echo ""
echo "---"
echo "### 自動リスク見積もり"
echo '```'
node risk-score.mjs "${BASE}"
echo '```'
} >> "${BODY_FILE}"
# 3. ここで初めて PR を開く
gh pr create --base main --title "$(git log -1 --pretty=%s)" --body-file "${BODY_FILE}"
検証が落ちたときは、エラーメッセージをそのままエージェントに返して説明を書き直させます。ここで重要なのは、検証ゲートと PR 作成を一つの流れにしつつも、ゲートが落ちたら確実に止まる(set -e と exit 1)ようにしておくことです。空疎な説明のまま PR が開いてしまっては、ゲートを置いた意味がありません。
運用して分かったこと
最初は5要素すべてを厳しく必須にしていましたが、些末な変更(タイポ修正やコメント追加)にまで Risk と Test を書かせると、説明の方が変更より長くなって本末転倒でした。
今は、変更行数が小さく高リスクなパスに触れていない PR には逃げ道を用意しています。具体的には、ゲートの先頭で「変更が10行未満かつ高リスクパスに非接触なら、What だけで通す」という分岐を入れました。守りたいのは「重い変更が空疎なまま素通りすること」であって、すべての PR を重装備にすることではありません。
もう一つの学びは、Risk 欄を書かせるようになってから、エージェント自身の変更が少し慎重になったことです。リスクを言語化させる工程が、結果的に「触れすぎない」方向へ働いているように感じます。ゲートは弾くためだけのものではなく、書き手の振る舞いを静かに変える装置でもあるのだと考えています。
導入の順序として、私は次の三段階をお勧めします。
- まずは一つのリポジトリで、Risk 欄だけを必須にする。
- 誤検出の感触をつかんでから、Review focus を必須に加える。
- 最後に Test を必須化し、対象リポジトリを段階的に広げる。
最初から完璧な型を求めず、レビューが実際に軽くなった手応えを確かめながら育てていくことを推奨します。
同じように無人のエージェント運用でレビューの形骸化に悩んでいる方の、何かの手がかりになれば幸いです。