TL;DR
- Issue Triage Agent の安全性は判定の精度ではなく、Judge に許した操作が戻せるかで決まる——このリポジトリではそう置いて設計しました。
closeは片道、holdは往復です。以下はその判断の根拠にした実測です。 - このリポジトリの全 issue 39 件・close イベント 36 件を実際にリプレイしました。「PR がリンクされている」という最も強い証拠で閉じた 21 件のうち、2 件が後から reopen されています(
社内データ)。証拠は正しく、判定が間違っていました。 - その 2 件は、未チェックのチェックボックスが最も多い 2 件でもありました(#425 が 80 個、#408 が 10 個。ただし #408 は 3 位の 7 個と僅差です)。この事実は Researcher が機械的に取れますが、close イベントだけを見る Judge には永久に届きません。
- ただしその信号だけで止めると 6 件を hold して 4 件が空振り(precision 33%)でした。精度は低いままで構いません。
holdは間違えても取り消せるからです。 - 分離は宣言だけでは効きません。このリポジトリのエージェント定義 24 本すべてが
Bashを持ち、ツール層で読み取り専用のものは 0 本でした。「Checker は本文を直接編集しない」は散文で書かれているだけです(社内データ)。 - 第 6 節のスクリプトをそのまま流すと、同じリプレイが手元のリポジトリでできます。必要なのは
ghとjqだけです。ただしこの記事の初稿のスクリプトは、他人のリポジトリに向けると壊れました(101 件目以降を黙って捨てる等、4 箇所)。直した経緯も第 6 節に残しています。 - 未チェック箱という信号そのものは移植できません。
cli/cli(6,399 issue)に向けると precision も recall も 0 でした。移植できるのは、Judge の操作をholdに限るという設計のほうです。
はじめに:閉じるのは片道で、開けておくのは往復
AI に issue のバックログ(backlog)をトリアージ(triage)させたい、という要望はだいたい同じ形をしています。「古い issue が数百件たまっていて誰も読んでいない。ラベルを付けて、重複をまとめて、終わっているものは閉じてほしい」。
このうち最後のひとつだけが質的に違います。誤クローズ(誤 close)だけは、間違えたことに気づく経路がないからです。ラベル付けも重複リンクも間違えたら剥がせばよく、剥がしたことは通知に残ります。close は違います。閉じた issue は検索から落ち、通知が止まり、誰も見ていないので間違いに気づく経路がありません。
この非対称性は、Amazon の株主宛書簡が Type 1 / Type 2 の意思決定として整理したものと同じ形です。戻れないドアと、通り抜けてから戻れるドアを同じ手続きで扱ってはいけない、という話でした。Issue triage における close は前者に近く、hold(判断を保留してラベルだけ付ける)は後者です。
そこで本記事は、「AI に何を判断させるか」ではなく「AI に何を実行させるか」を設計単位に置きます。具体的には、証拠を集める役(Researcher)と、証拠だけを読んで判定する役(Judge)を分けます。
そして、この分離が本当に効いているかを測ります。というのは、このリポジトリはすでに同型の分離を契約として書いているからです。成果物を作る側を Maker、それを独立に評価する側を Checker と呼び、Checker は指摘を返すだけで成果物を書き換えない、と spec/article_role_contracts.md に定めています。本記事の Researcher / Judge でいえば、Judge にあたるのが Checker です。それが実行層でどれだけ守られているかを後半で監査したところ、思っていたのと違う結果が出ました。
これは提言ではなく、1 リポジトリの記録です。適用範囲の話(どういう条件なら割に合わなかったか)はしますが、組織や体制をどう作るべきかには踏み込みません。
先に語の範囲を決めておきます。本記事の「トリアージ」は、障害アラートの集約(オンコール疲弊を防ぐ運用設計の手順)ではなく、issue バックログの close 判断を指します。
なお、権限をどう配るかというマトリクスの話は 権限マトリクスとガードレール設計 が、宣言したガバナンスと実際に効いているガバナンスのズレは AIガバナンスが効かない4類型と検証手順 が扱っています。本記事はそのうち issue triage という 1 つの操作に絞って、このリポジトリの過去の判断を全部リプレイしたものです。
1. issue トリアージの過去 close 判断を全部リプレイしたら、2 件外していた
まず「このリポジトリのトリアージは実際どうだったのか」を測ります。人間がやってきた判断を過去にさかのぼって並べ直すと、AI に任せたときに何が起きるかの下限が見えます。
対象は s977043/notionnext-blog の全 issue です。2026-09-07 時点で 39 件(OPEN 5 / CLOSED 34)でした。ここで数え方を 2 つに分けておきます。close イベントは 36 件、一度でも閉じられた issue は 35 件です。両者がずれるのは、2 件が一度閉じてから reopen され、うち 1 件が閉じ直されているためです。以降、割合の分母がどちらなのかを毎回書きます。
まず close の根拠を種類ごとに数えます。GitHub の ClosedEvent は closer フィールドを持っていて、その issue を閉じた PR やコミットを返します。
printf '%s' 'query { repository(owner:"s977043", name:"notionnext-blog") {
issues(first:100, states:[CLOSED,OPEN]) { nodes { number
timelineItems(first:50, itemTypes:[CLOSED_EVENT,REOPENED_EVENT]) {
nodes { __typename ... on ClosedEvent { closer { __typename } } } } } } } }' > /tmp/q.graphql
gh api graphql -F query=@/tmp/q.graphql > /tmp/issues.json
jq -r '[.data.repository.issues.nodes[].timelineItems.nodes[]
| if .__typename=="ClosedEvent"
then (if .closer==null then "NO-LINK" else .closer.__typename end)
else "REOPENED" end]
| group_by(.) | map({k:.[0],n:length}) | .[] | "\(.k)\t\(.n)"' /tmp/issues.json
出力は次のとおりです(社内データ、2026-09-07 実行)。
| close の根拠 | 件数 | 意味 |
|---|---|---|
PullRequest | 21 | closing keyword を含む PR がマージされて自動で閉じた |
Commit | 1 | コミットメッセージ経由で閉じた |
NO-LINK | 14 | 人が手で閉じた。機械可読な根拠は残っていない |
| 合計 | 36 | close イベント数 |
これとは別に、REOPENED イベントが 2 件あります(close の根拠ではないので上の表には入れていません)。reopen された 2 件は、どちらも PullRequest による close の直後に開き直されていました。 逆に、手動 close の後に reopen が発生したケースは 0 件です。
close と reopen が起きた順番まで見ると、これが確かめられます。ClosedEvent と ReopenedEvent を時系列のまま並べます。
jq -r '.data.repository.issues.nodes[]
| select([.timelineItems.nodes[].__typename] | index("ReopenedEvent"))
| "#\(.number) " + ([.timelineItems.nodes[]
| (if .__typename=="ClosedEvent"
then "closed:" + (.closer.__typename // "MANUAL")
else "REOPENED" end)] | join(" -> "))' /tmp/issues.json
# #408 closed:PullRequest -> REOPENED -> closed:MANUAL
# #425 closed:PullRequest -> REOPENED
reopen は 2 件とも closed:PullRequest の直後に来ています。そして #408 は最後に手動で閉じ直されたきり、開き直されていません。closed:MANUAL の後ろに REOPENED が並ぶ行は 1 つもありません。
つまり、最も証拠が強い経路が、最も外していたわけです。
この結果を「手で閉じるほうが安全」と読むのは誤りです。手動 close の 14 件は誰も検算していないので、間違っていても reopen として表面化しないだけかもしれません。監査されている信号のほうが成績が悪く見えるのは、監査されていない信号に成績が付いていないからです。ここを取り違えると、機械可読な根拠を残すことをやめる方向に倒れます。
2. 証拠は正しかった。判定が間違っていた
#408 と #425 が何だったかを見ると、外し方の構造が分かります。
- #408「content: 公開会議 申し送り(出典誤り / description過長 / GEO公開順・series設計 / リンク規約)」— 対応項目 10 個、うち未チェック 10 個(チェック済み 0)
- #425「feat(article): AI編集部型ワークフローへ再設計する」— 対応項目 90 個、うち未チェック 80 個(チェック済み 10)
どちらも「1 つの PR で終わる話」ではありませんでした。PR がマージされたのは事実で、その PR がこの issue を参照していたのも事実です。証拠に嘘はありません。証拠が答えているのは「この issue を参照した PR がマージされたか」であって、「この issue の要求がすべて満たされたか」ではない、というだけです。
そして「対応項目がいくつあるか」は、本文をパースすれば機械的に取れます。
gh issue list --repo s977043/notionnext-blog --state all --limit 500 \
--json number,body \
--jq '.[] | [.number, ([(.body // "") | split("\n")[]
| select(test("^\\s*[-*] \\[ \\]"))] | length)] | @tsv' \
| awk '$2 > 0' | sort -k2,2nr -k1,1nr
# 425 80
# 408 10
# 416 7
# 415 6
# 151 6
# 158 5
# 441 4
# 155 4
# 245 3
未チェックのチェックボックスが多い上位 2 件が、そのまま reopen された 2 件です(社内データ)。
ただし、この並びを「きれいに分かれている」と言うと誇張になります。突出しているのは #425(80)だけで、#408 の 10 は 3 位の #416(7)と僅差です。閾値を「10 以上」に置けば 2 件だけが残りますが、それは結果を見てから引いた線なので使いません。本節の後半で測るのは、閾値をひとつも引かない素朴な線(未チェック箱が 1 個でもあれば hold)の成績です。
この事実は Researcher が 1 コマンドで取れます。しかし、close イベントだけを入力にした Judge には絶対に届きません。Judge が何を間違えるかは、Judge の賢さではなく、Researcher が何を集めたかで決まっていました。
ただしこの信号の精度は 33% しかない
ここで話を終えると危険なので、同じ信号を判定に使ったときの成績も出します。
bash triage-replay.sh s977043/notionnext-blog
(スクリプト本体は第 6 節に載せます。実行結果は次のとおり、社内データ)
| 方針 | 動作 | 結果 |
|---|---|---|
| A: PR リンクがあれば close | 21 件を close(分母は一度でも閉じられた issue 35 件) | うち 2 件が reopen(誤り率 9.5%) |
| B: 未チェック箱が残る間は hold | 6 件を hold(分母は同じく 35 件) | 空振り 4 件、見逃し 0 件 |
方針 B を分類器として見ると、hold した 6 件のうち当たりは 2 件なので precision(適合率)は 33%、reopen された 2 件を両方とも捕まえているので recall(再現率)は 100% です。
先ほどの awk は未チェック箱を持つ issue を 9 件返しましたが、hold は 6 件です。差の 3 件(#416 / #415 / #441)は一度も閉じられたことがない issue で、スクリプトの select(.ever_closed) で落ちています。リプレイの対象は「過去に close 判断が下された issue」だけです。残る 6 件の内訳は、当たりが #408 / #425、空振りが #151 / #155 / #158 / #245 です。
方針 B は分類器としては出来が悪いです。閉じてよかった 4 件まで止めています。実際、チェックボックスは「作業が終わっていない」ことの証拠ではなく、「箱にチェックが入っていない」ことの証拠でしかありません。人は箱を消し忘れます。
それでも B を採るのは、B の唯一の操作が hold だからです。空振り 4 件のコストは「人が 4 件を目視する」であり、次に人が触れば解消します。A の誤り 2 件のコストは「閉じた issue が誰にも読まれずに残る」です。なお、この 2 件が reopen されるまでの時間は #425 が 33 秒、#408 が 5 分 1 秒でした。後から読み返して気づいたのではなく、その場に人がいたから即座に取り消せたというだけです。
判定精度で設計しないでください、という話ではありません。 このリポジトリでは、精度を上げる努力と、外したときの戻しやすさを、別々に測るようにした、という記録です。
3. Researcher と Judge に何を渡すか
以上を踏まえて、2 つの役に何を許すかを表にします。ここがこの記事の設計部分です。
| 観点 | Researcher | Judge |
|---|---|---|
| 入力 | GitHub API / リポジトリ / ログ | Researcher の出力だけ |
| 出力 | 事実のレコード(数値・真偽値・ID) | close / hold / unknown の 3 値と根拠フィールド名 |
| 禁止 | 「閉じてよい」「重複だ」等の評価語を出さない | API を自分で叩かない。本文を読まない |
| 副作用 | なし(読み取りのみ) | ラベル付与とコメントのみ。close は人間の承認待ちキューへ積む |

最後の行が、この表でいちばん効いています。NIST の AI RMF も human oversight(人による監督)を「どこに置くか定義し、評価し、文書化する」対象として扱っていて、監督を置くかどうかではなくどの操作の手前に置くかを問題にします。ここでは close の手前に置いています。
3 つだけ補足します。
1. Researcher が評価語を出さないこと。 「この issue は解決済みと思われる」と書いた瞬間、Judge の入力は事実ではなく他人の判定になります。Judge がそれを追認すれば分離は消えます。このリポジトリでは、Researcher の出力を open_boxes: 10 のような、後から検算できる形だけに絞りました。
2. Judge の出力を 3 値にすること。 close / keep の 2 値にすると、判断できないものが必ずどちらかへ丸められます。このリポジトリでは同じ問題を別のゲートで踏んでいて、spec/guard_self_check.md の R-6「未検査を PASS に丸めない」として規約化しました。記事の主張ごとに検証状態を持つ台帳(claim ledger)を検査する validate_claim_ledger.py は、pending / uncertain という「まだ確かめていない」状態を PASS ではなく BLOCK へ写像します。Triage でも同じ形にしています。unknown を用意しなかったときに unknown がどちらへ倒れるかは、close 側でした。
3. Judge が API を叩かないこと。 これは能力の制限ではなく、再現性の確保です。Anthropic のエージェント設計ガイドも、自律性を上げるほど人間のチェックポイントとサンドボックスでの検証が要ると書いており、設計を単純に保つことを勧めています。ここでの単純さは、Judge の入力経路を 1 本に減らすことにあたります。Judge が自分でデータを取りに行けるなら、後から同じ判定を再現できません。Researcher の出力をファイルに固定しておけば、判定ロジックだけを差し替えて第 2 節のようなリプレイができます。
似た構造を multi-agent 側で整理したものとして Codex subagents設計原則 と Codex通信トポロジー設計 があります。本記事はそのうち「判断を担う 1 体をどう縛るか」に絞っています。
4. 役割分離は宣言では効かない:エージェント定義 24 本の権限を監査した
ここからが、この記事を書いた本当の理由です。
このリポジトリは記事制作について、すでに Researcher / Judge に相当する分離を契約として持っています。spec/article_role_contracts.md にはこう書かれています。
Checker は 公開成果物を直接修正しない。指摘は review artifact として返し、修正は Maker に委譲する。
article-reviewer のエージェント定義にも「CHECKER READ-ONLY 契約」という節があり、「typo・表記ゆれを含め、本文は直接修正しない」と明記されています。
では、実行層ではどうなっているか。エージェント定義の tools: 行を数えました。
# 母数を揃えるため、両方とも「tools: 行を持つ定義」を数える
grep -h "^tools:" .agents/agents/*.md | wc -l # => 24
grep -h "^tools:" .agents/agents/*.md | grep -c Bash # => 24
# tools: 行が無いのは README だけ(=上の 24 が全数であることの確認)
grep -L "^tools:" .agents/agents/*.md # => .agents/agents/README.md
# 書き込み系ツールを持たないエージェント
# (tools: 行だけを判定に使う。ファイル名まで grep に渡すと
# article-writer.md / documentation-writer.md が名前の "writer" で誤除外される)
for f in .agents/agents/*.md; do
line=$(awk '/^tools:/{print; exit}' "$f")
[ -n "$line" ] || continue
echo "$line" | grep -qiE "write|edit" || echo "$f: $line"
done
# .agents/agents/explorer-agent.md: tools: Read, Grep, Glob, Bash, ViewCodeItem, FindByName
# .agents/agents/project-planner.md: tools: Read, Grep, Glob, Bash
結果は次のとおりです(社内データ、2026-09-07 時点)。
| 観測 | 値 |
|---|---|
| エージェント定義(README 除く) | 24 本 |
Write または Edit を持つ | 22 本 |
Bash を持つ | 24 本(全数) |
| ツール層で読み取り専用のもの | 0 本 |
article-reviewer は tools: Read, Grep, Glob, Bash, Write, WebSearch, WebFetch を宣言しています。Write があるのは正当で、review-draft.md や claim-ledger.json を書くために要ります。問題は Write の許可がパスで絞られていないことです。「レビュー成果物にだけ使う」は散文で書かれているだけで、content/posts/<slug>/index.md を上書きすることを妨げるものは何もありません。Bash があるので、仮に Write を外しても同じです。
対照的に、このリポジトリは取り消せない git 操作は機械的に止めています。
jq -r '.permissions.deny[]' .claude/settings.json | grep git
# Bash(git push origin main)
# Bash(git push origin main:*)
# Bash(git push -f:*)
deny は 19 ルールあり(Bash 系 7 / Read 系 9 / git push 系 3)——なお、この記事の初稿ではここを目視で数えて「20 ルール」と書いていました。jq '.permissions.deny | length' で数え直して 19 です。社内データ を付けた数値ほど、目で数えずコマンドで数え直したほうが安全でした。19 ルールは、rm / sudo / curl / main への直接 push / 秘密ファイルの読み取りを塞いでいます。「戻せない操作」は機械で塞ぎ、「役割の境界」は散文に置いているという状態です。前者が正しく設計されているぶん、後者の空白がはっきり見えます。
これが第 1 節の話と地続きなのは、close がまさに「戻せない側」だからです。このリポジトリは git push -f を deny の棚に置き、Checker の read-only を散文の棚に置いています。Issue Triage Agent の close 権限をどちらの棚に置くかは、まだ決めていません。記事制作の Checker について現状どちらに置けているかは、上の表のとおりです。
CI が緑でもガードが効いていないという同型の話は 効いていないガードを見つける7つの型 にまとめてあります。本節はその「宣言と実行層のズレ」を、権限グラントの側から数えたものです。なお、ここで「機械で塞げている」と書いた deny 19 件そのものを別の角度から検分すると、19 件は全部コマンドの綴りで書かれていて、被害範囲で書かれたものは 0 件でした(AIエージェントの権限は影響範囲で設計する)。
5. Judge が自分の採点をしている場所
もうひとつ実測した箇所を出します。このリポジトリには、記事のリスクに応じて人間を呼ぶかどうかを決める Gate があります(spec/article_risk_gate.md)。企画と構成を確定させる段階に置いてあるので Gate-1 と呼んでいて、risk_level を AI が単独で下げてはならないという escalation-only rule が書かれています。判断は content/posts/<slug>/gate.md に記録されます。
ls content/posts/*/gate.md | wc -l # => 4
grep -h "^- risk_level:" content/posts/*/gate.md | sort | uniq -c
# 1 - risk_level: high
# 3 - risk_level: medium
この 4 件目はこの記事自身の gate.md です。書き始めた時点では 3 件で、本文にもそう書いていました。技術レビューが実行し直して 4 件を返し、記事の数値と現物がずれていることを指摘しています。測定対象に自分が入っているときは、計測した時刻を書かないと再現しません。
記事は 200 本以上あるのに 4 件しかないのは、この記録を義務づけたのが最近で、新規追加された記事だけが検査対象だからです。機械検証もあります。scripts/check_article_working_artifacts.py が、新規追加された記事の gate.md に risk_level / risk_reason / gate1_decision / gate1_reason / thesis_gate が揃っているか、値が列挙の範囲内かを確かめます。
ただし、この guard が検証しているのは申告の形式です。「trigger 表を正しく評価した結果その値になったか」は検査していません。risk_level: low と書けば、それが正しいかどうかに関係なく enum チェックは通ります。関連する scripts/check_risk_gate_drift.py も、契約文が複数ファイルに転記されているときの不一致を見るもので、個々の判定の当否は対象外です。
つまり Judge(Gate-1 の判定者)が自分の答案を出し、機械は答案の書式を採点している構図です。これは欠陥というより、いま置いてある場所の記録です。第 3 節の表に照らすと、Researcher(trigger の該当事実を集める)と Judge(level を決める)が同じ主体になっている状態にあたります。Issue triage で同じ形にすると、「重複だと判断した根拠」を Judge 自身が書くことになり、検算する側は根拠の書式しか見られません。
エスカレーション経路そのものの設計は 人間へのエスカレーション設計、AI が出す成果物の検証しづらさは AI生成テストの信頼性を上げる で扱っています。
6. 手元で回すための最小構成
第 2 節で使ったリプレイスクリプトです。gh と jq があれば動きます。GitHub への書き込みは行いません(読み取り専用のクエリしか投げません)。
初稿はもっと短かったのですが、レビューで他人のリポジトリに向けると壊れることが分かり、4 箇所直しました。直した理由は節の最後にまとめます。
#!/usr/bin/env bash
# triage-replay.sh — 過去の close 判断を 2 方針でリプレイして reopen 実績と突き合わせる
# exit 0 = 測定できた / exit 2 = 測定できなかった(設定ミス・0 件・取りこぼし)
set -euo pipefail
REPO="${1:?usage: triage-replay.sh <owner>/<repo>}"
case "$REPO" in
*/*/*|*://*) echo "REPO は owner/repo 形式で指定してください: $REPO" >&2; exit 2 ;;
*/*) : ;;
*) echo "REPO は owner/repo 形式で指定してください: $REPO" >&2; exit 2 ;;
esac
OWNER="${REPO%%/*}"; NAME="${REPO##*/}"
WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT
EV="$WORK/evidence.ndjson"
cat > "$WORK/q.graphql" <<'GQL'
query($owner:String!, $name:String!, $cursor:String) {
repository(owner:$owner, name:$name) {
issues(first:100, after:$cursor, states:[OPEN,CLOSED]) {
pageInfo { hasNextPage endCursor }
nodes {
number
body
lastEditedAt
timelineItems(first:100, itemTypes:[CLOSED_EVENT,REOPENED_EVENT]) {
totalCount
nodes {
__typename
... on ClosedEvent { createdAt closer { __typename } }
... on ReopenedEvent { createdAt }
}
}
}
}
}
}
GQL
# --- Researcher: 事実だけを集める。評価語を出さない。全ページを取り切る ---
cursor=""; pages=0
while : ; do
if [ -z "$cursor" ]; then
gh api graphql -F query=@"$WORK/q.graphql" -f owner="$OWNER" -f name="$NAME" > "$WORK/page.json"
else
gh api graphql -F query=@"$WORK/q.graphql" -f owner="$OWNER" -f name="$NAME" \
-f cursor="$cursor" > "$WORK/page.json"
fi
jq -c '.data.repository.issues.nodes[] | {
number,
open_boxes: ([(.body // "") | split("\n")[]
| select(test("^[ \t]*[-*+] \\[ \\]"))] | length),
closed_by_pr: ([.timelineItems.nodes[] | select(.__typename=="ClosedEvent")
| .closer.__typename // "none"] | index("PullRequest") != null),
ever_closed: ([.timelineItems.nodes[].__typename] | index("ClosedEvent") != null),
reopened: ([.timelineItems.nodes[].__typename] | index("ReopenedEvent") != null),
last_closed_at: ([.timelineItems.nodes[]
| select(.__typename=="ClosedEvent") | .createdAt] | max),
body_last_edited_at: .lastEditedAt,
timeline_truncated: (.timelineItems.totalCount > 100)
}' "$WORK/page.json" >> "$EV"
pages=$((pages + 1))
[ "$(jq -r '.data.repository.issues.pageInfo.hasNextPage' "$WORK/page.json")" = "true" ] || break
cursor=$(jq -r '.data.repository.issues.pageInfo.endCursor' "$WORK/page.json")
done
# --- 測定できたかを先に確かめる。「0 件」を「満点」と区別する ---
collected=$(wc -l < "$EV" | tr -d ' ')
[ "$collected" -gt 0 ] || {
echo "issue を 1 件も収集できませんでした(repo 名の誤りか、issue が 0 件)" >&2; exit 2; }
echo "collected=$collected issues in $pages page(s)" >&2
truncated=$(jq -s '[.[] | select(.timeline_truncated)] | length' "$EV")
[ "$truncated" -eq 0 ] || {
echo "timeline が 100 件を超える issue が $truncated 件あり、取りこぼしています" >&2; exit 2; }
# close 後に body が編集された issue は、close 当時の入力を再現できていない
stale=$(jq -s '[.[] | select(.ever_closed and .body_last_edited_at != null
and .body_last_edited_at > .last_closed_at)] | length' "$EV")
[ "$stale" -eq 0 ] || echo "警告: close 後に body が編集された issue が $stale 件あります" >&2
# --- Judge: evidence だけを読む。API も本文も見ない ---
echo "policy_A(PR リンクがあれば close):"
jq -s '[.[] | select(.ever_closed)] | {
scope: length,
closed: [.[] | select(.closed_by_pr)] | length,
wrong: [.[] | select(.closed_by_pr and .reopened)] | length }' "$EV"
echo "policy_B(未チェック箱が残る間は hold):"
jq -s '[.[] | select(.ever_closed)] | {
scope: length,
held: [.[] | select(.open_boxes > 0)] | length,
held_wrong: [.[] | select(.open_boxes > 0 and (.reopened | not))] | length,
missed: [.[] | select(.open_boxes == 0 and .reopened)] | length }' "$EV"
echo "ground truth (実際に reopen された issue):"
jq -r 'select(.reopened) | " #\(.number) open_boxes=\(.open_boxes)"' "$EV"
このリポジトリでの実行結果はこうなりました(社内データ)。
collected=39 issues in 1 page(s)
警告: close 後に body が編集された issue が 1 件あります
policy_A(PR リンクがあれば close):
{
"scope": 35,
"closed": 21,
"wrong": 2
}
policy_B(未チェック箱が残る間は hold):
{
"scope": 35,
"held": 6,
"held_wrong": 4,
"missed": 0
}
ground truth (実際に reopen された issue):
#408 open_boxes=10
#425 open_boxes=80
初稿のスクリプトが壊れていた 4 箇所
レビューで、このリポジトリでだけ正しく動くスクリプトだったことが分かりました。直した内容がそのまま、Researcher を作るときの注意点になっています。
| # | 初稿の書き方 | 何が起きるか |
|---|---|---|
| 1 | issues(first:100) で pageInfo を見ない | 101 件目以降を黙って捨てる。cli/cli(6,399 issue)に向けると最古の 100 件だけを測り、それを全体の成績として exit 0 で出す |
| 2 | 0 件でも wrong:0 / missed:0 を exit 0 で返す | 「何も測っていない」と「完璧だった」が同じ出力になる |
| 3 | open_boxes を実行時点の body から数える | close 後に本文が編集されたリポジトリでは、close 当時の入力を再現していない |
| 4 | gh api -F name="$NAME" | -F は値を型変換するので、全数字のリポジトリ名(gabrielecirulli/2048 など)で Could not coerce value 2048 to String になり落ちる。-f なら通る |
1 番が最も重いです。この記事は第 7 節で「バックログが数百件あるとき」を分離が効く条件だと書いているので、分離が必要な規模でだけ結果が壊れます。 手元の 39 件では 1 ページに収まるため、最後まで気づきませんでした。
2 番は、この記事が第 3 節で「unknown を用意しないと unknown が close になる」と書いたのと同じ型です。測れなかったことを、測って問題なかったことと同じ出口から返していました。 修正版は測定不能を exit 2 に分け、scope(分母)を出力に含めています。
3 番は open_boxes の値そのものに関わります。実際 #425 は、open=90 / checked=0 の状態で close され、チェックが 10 個入ったのは reopen と同じ時刻でした。記事に載せた open_boxes=80 は close 後の値です。 このリポジトリでは該当が 1 件だけで、順位も held / held_wrong / missed も変わらなかったため集計値に影響はありませんでしたが、他のリポジトリで同じ保証はありません。修正版は該当件数を stderr に出します。
直したうえで、他のリポジトリに向けてみた
修正版を cli/cli に向けると、6,399 件を 64 ページで取り切ったうえで、このリポジトリとは正反対の結果が出ました。
collected=6399 issues in 64 page(s)
警告: close 後に body が編集された issue が 54 件あります
policy_B(未チェック箱が残る間は hold):
{
"scope": 5367,
"held": 39,
"held_wrong": 39,
"missed": 145
}
hold した 39 件はすべて空振り、reopen された 145 件は 1 件も捕まえていません。 precision も recall も 0 です。未チェックのチェックボックスという信号は、このリポジトリの issue の書き方にたまたま乗っていただけでした。
この結果は本記事の主張を壊しません。壊れたのは信号であって、Judge の操作を hold に限るという設計のほうではないからです。cli/cli で policy_B を動かしても、余計に 39 件が人の目に回るだけで、issue は 1 件も閉じられません。逆に、同じ精度で policy_A のように close まで許していたら、この 39 件は誰にも見られずに閉じていました。信号の当たり外れがそのまま被害にならないことが、hold に限る理由です。
ここで大事なのは、Researcher の出力がファイルとして残ることです。判定ロジックを変えたら、API を叩き直さずに同じ入力で結果を比べられます。第 2 節の表はそうやって作りました。LLM を Judge に据える場合も同じで、渡すのはこの ndjson だけにします。
ただし上のスクリプトは mktemp -d で作った一時ディレクトリを trap で消すので、実行が終わると evidence も消えます。判定を differential に比べたいときは EV を固定パスに変えてください。その場合は、前回の実行の残骸を読んでいないかを毎回確かめることが要ります。3 つのリポジトリを続けて流したら evidence が上書きされていた、というのがレビューで実際に起きました。
gh issue list と gh api は .claude/settings.json の allow に入っており、gh issue close は入っていません。
ここで、この記事の技術レビューが筆者の書きかけの主張を 1 つ潰しています。当初この段落には「Judge をこの許可集合に閉じておけば close は構造的に実行できなくなります」と書いていました。成立しません。 同じ allow に Bash(gh api:*) があり、gh api は GraphQL の mutation も REST の PATCH も送れるからです。
# gh issue close は allow に無い。しかしこれは通る
# (このコマンドは issue を閉じます。上の triage-replay.sh とは違い読み取り専用ではありません)
gh api -X PATCH repos/s977043/notionnext-blog/issues/N -f state=closed
しかも第 1 節と第 6 節のスクリプトが gh api graphql に依存しているので、この許可を外して塞ぐこともできません。正確には「gh issue close サブコマンドだけが塞がっていて、gh api 経由の close は塞げていない」です。
これは本節の主題そのものの実例になりました。許可リストを見て「close はできないはずだ」と読むのは、tools: に Write が無いのを見て「書き込めないはずだ」と読むのと同じ間違いで、Bash という広い口が開いていることを勘定に入れていません。Judge の action を本当に縛るなら、許可コマンドの列挙ではなく、Judge に GitHub の書き込みトークンを渡さない(読み取り専用トークンで動かす)ほうが確実です。
7. コストと、やらないほうがいい場合
導入コストと限界を書いておきます。
コスト。 Researcher / Judge を分けると往復が 1 回増えます。Researcher の出力スキーマを決める作業も要ります。本記事のスクリプトは 4 フィールドですが、実運用では「最終コメントからの経過日数」「リンクされた PR の状態」「参照されたファイルの実在」あたりを足すことになり、フィールドが増えるほど Judge のプロンプトも長くなります。
このリポジトリでやっていないこと。 上の設計を issue triage に常時適用する自動化は、まだ動かしていません。本記事で示したのは過去のリプレイと権限の監査までです。第 4 節で見つけた「Checker の Write がパスで絞られていない」も、この記事では直していません。測ったことと直したことは分けて書いています。
このリポジトリでは割に合っていない条件。 全 issue は 39 件で、目視できる量です。この規模で Researcher / Judge を常時動かすのは、正直なところ釣り合っていません。分離が効いてくるのは、バックログが数百件あって全件は読めず、なおかつ誤クローズの発見コストが高いときです。逆に、バックログが使い捨てで誤クローズの実害がほぼないなら、可逆性という前提そのものが成り立ちません。戻せなさのコストがゼロなら、ここまでの話は当てはまりません。
測定の限界。 4 つあります。
- 本記事の数値は 1 リポジトリ・39 issue・reopen 2 件の観測です。reopen を ground truth に使っていますが、reopen は誰かが気づいたときにしか発生しません。しかも今回の 2 件は 33 秒後と 5 分後の即時取り消しで、その場に人がいた分だけです。誰も見ていなかった誤 close は数えられていません。 したがって「誤り率 9.5%」は下限であり、法則ではありません。
open_boxesは実行時点の body から数えた値で、close 当時の入力ではありません。このリポジトリで該当するのは #425 の 1 件(close 時点は 90 個すべて未チェック、チェックが入ったのは reopen と同時刻)で、順位も集計値も変わりませんでした。他のリポジトリでは同じ保証がないので、修正版のスクリプトは該当件数を警告として出します。- 未チェックのチェックボックスという信号は、このリポジトリの書き方に依存しています。 第 6 節のとおり
cli/cliに向けると precision も recall も 0 になりました。信号は移植できません。移植できるのは、Judge の操作をholdに限るという設計のほうです。 - 第 6 節に書いたとおり、初稿のスクリプトは他人のリポジトリで壊れていました。このリポジトリで動いたことは、他所で動くことの証拠になりません。
FAQ
Q1. Researcher と Judge を、同じ 1 体のエージェントに 2 回プロンプトを分けて実行させてはだめですか
分けたことにはなりますが、弱い分離です。同じセッションだと Researcher フェーズの内部推論が Judge フェーズに残り、「集めたときにすでに閉じてよいと思っていた」判断が引き継がれます。このリポジトリの Maker / Checker 契約(spec/article_role_contracts.md)も同じ規定を持っています。契約の条文では Maker / Checker を役割ごとに Writer / Reviewer と呼び分けていて、Reviewer の禁止事項に rely on Writer's private/internal chain of reasoning、Writer の禁止事項に consume hidden/internal Reviewer reasoning as input があります。相手の頭の中を入力にしない、という同じ規定です。プロセスを分けて、受け渡しをファイルに限定するのが確実です。
Q2. LLM に判定させると毎回違う答えが出ます。どうすればいいですか
このリポジトリでは、Judge の入力を Researcher の出力ファイルだけに固定しました。入力が固定されていれば、揺らぎは判定ロジックの問題として切り分けられます。API 呼び出しを Judge に許すと、入力自体が毎回変わるので揺らぎの原因を特定できません。本記事のスクリプトが ndjson を中間ファイルに落としているのはこのためです。
Q3. close を人間の承認待ちにすると、結局人間がボトルネックになりませんか
なります。ただしボトルネックになるのは close 候補だけです。本記事のリプレイでは、一度でも閉じられた issue 35 件のうち hold されたのは 6 件でした。残りは自動で流れます。人間が読む量を減らすこと自体は AI駆動開発で人を律速にしない で扱っています。
Q4. チェックボックスを使わないリポジトリでも、この方法は使えますか
そのままでは使えません。この信号はこのリポジトリの書き方に依存しています。汎用的に効くのは信号そのものではなく、**「issue の要求単位数を数え、close の根拠が全単位をカバーしているかを別のフィールドとして持つ」**という形のほうです。requirement を箇条書きで書くリポジトリなら行数、外部の課題管理と紐づくならサブタスク数が代わりになります。
Q5. precision 33% の Judge を本番に置いてよいのですか
hold しかしないなら置けます。判断すべきは精度ではなく、外したときに誰が何コストで戻すかです。今回の空振り 4 件は、人が 4 件を目視すれば解消します。同じ精度の Judge に close を許すと、外した分は誰も戻しません。
まとめ
- このリポジトリの close 判断 36 件をリプレイしたところ、PR リンクという最強の証拠で閉じた 21 件のうち 2 件が reopen されていました。証拠は正しく、判定が間違っていました。
- その 2 件は未チェック項目が最も多い 2 件でした。Researcher なら 1 コマンドで取れる事実で、close イベントだけを見る Judge には届きません。
- その信号を判定に使うと precision は 33% でした。それでも使えるのは、Judge の操作を
holdに限っているからです。安全性を決めているのは精度ではなく操作の可逆性——これがこのリポジトリの出した結論です。 - 分離を宣言しても実行層に降りていなければ効きません。このリポジトリはエージェント定義 24 本すべてが
Bashを持ち、ツール層で読み取り専用は 0 本でした。git push -fはdenyで塞いでいるのに、Checker の書き込み境界は散文にあります。 - このリポジトリで最初にやったのは、
triage-replay.shを流して過去に何件外していたかを見ることでした。ここで誤り率が 0 だったなら、以降の設計には進んでいません。
References
- Linking a pull request to an issue — GitHub Docs
- GraphQL API objects: ClosedEvent — GitHub Docs / union Closer
- GraphQL API mutations: reopenIssue — GitHub Docs
- 2015 Letter to Shareholders — Amazon(Type 1 / Type 2 の意思決定、PDF)
- Building effective agents — Anthropic Engineering
- AI Risk Management Framework (AI RMF 1.0) — NIST
