サブエージェントに定型作業を任せると、9割はきれいに仕上がるのに、1割だけ約束したルールを外した成果物が混じる——これが一番厄介でした。個人開発で複数のサイト(Dolice Labs)を回している私の場合は、記事生成の下請けを別エージェントに切り出していますが、「禁止語が入る」「見出しの数が足りない」といった違反が、忙しい日ほど見逃されてマージされてしまいます。
手で全部レビューすれば防げますが、それでは委譲した意味がありません。そこで、サブエージェントが仕事を終えた瞬間に機械が採点し、基準を満たさなければ本人にやり直させる仕組みを SubagentStop フックで組みました。
SubagentStop はサブエージェントの「提出ボタン」に挟まる
Claude Code のフックには複数のイベントがありますが、サブエージェント専用の出口が SubagentStop です。親エージェントの停止に反応する Stop とは別物で、サブエージェントが応答を返し終えた直後にだけ発火します。ここがちょうど「提出物を受け取る前の検品ライン」になります。
重要なのは、このフックが終了コードと JSON 出力で挙動を制御できる点です。{"decision": "block", "reason": "..."} を標準出力に返すと、Claude Code はサブエージェントを止めずに、reason を指示として渡したまま処理を続行させます。つまり「ここがダメだから直して」とフィードバックを注入できます。採点結果をそのまま差し戻し文に使えるのが、この設計の肝です。
採点基準は散文ではなく JSON ルーブリックに固定する
「良い記事を書いて」のような曖昧な基準は機械採点に向きません。違反の有無を一意に判定できる形まで分解します。私が下請けの記事生成に使っているルーブリックはこんな形です。
{
"min_h2": 4,
"max_chars": 12000,
"min_chars": 2500,
"banned_words": ["崩壊", "最強", "爆速", "神", "完全ガイド", "決定版"],
"forbidden_openers": ["この記事では", "本記事では", "いかがでしたか"],
"require_code_block": true
}ポイントは、各項目が「数えられる」か「文字列一致で判定できる」ことです。主観が入る評価(面白さ・読みやすさ)はここに入れません。あくまで「機械で確実に弾けるルール違反」だけを置き、創造的な品質は人間と本体プロンプト側に任せます。境界を引くことで、フックが誤検知でループに陥るのを防げます。
フック本体:標準入力の JSON からトランスクリプトを開く
SubagentStop フックは、標準入力に session 情報を JSON で受け取ります。その中の transcript_path がサブエージェントの会話ログ(JSONL)です。最後のアシスタント発言が提出物なので、そこを取り出して採点器に渡します。
#!/usr/bin/env bash
# .claude/hooks/grade-subagent.sh
set -euo pipefail
INPUT="$(cat)" # フックは stdin で JSON を受け取る
TRANSCRIPT="$(printf '%s' "$INPUT" | node -e \
'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{
console.log(JSON.parse(d).transcript_path || "")})')"
if [ -z "$TRANSCRIPT" ] || [ ! -f "$TRANSCRIPT" ]; then
exit 0 # 採点対象がなければ素通り
fi
node "$(dirname "$0")/grade.mjs" "$TRANSCRIPT"set -euo pipefail を付けているのは、採点器がエラーで落ちたときにフックが無言で成功扱いになるのを防ぐためです。検品ラインが壊れたまま全部合格になるのが、品質ゲートで一番怖い失敗です。
採点器:トランスクリプト末尾を読んでルーブリックと突き合わせる
採点本体は決定的(同じ入力なら同じ結果)に保ちます。外部 API を呼ばないので速く、無料で、ネットワーク障害でも止まりません。
// .claude/hooks/grade.mjs
import { readFileSync } from "node:fs";
const RUBRIC = JSON.parse(
readFileSync(new URL("./rubric.json", import.meta.url), "utf8")
);
const transcriptPath = process.argv[2];
const lines = readFileSync(transcriptPath, "utf8").trim().split("\n");
// JSONL を末尾からたどり、最後のアシスタントのテキストを取り出す
let text = "";
for (let i = lines.length - 1; i >= 0; i--) {
const ev = JSON.parse(lines[i]);
if (ev.message?.role !== "assistant") continue;
const blocks = ev.message.content ?? [];
text = blocks.filter(b => b.type === "text").map(b => b.text).join("\n");
if (text) break;
}
const fail = [];
const h2 = (text.match(/^##\s+/gm) ?? []).length;
if (h2 < RUBRIC.min_h2) fail.push(`H2 が ${h2} 個(最低 ${RUBRIC.min_h2} 個)`);
const chars = [...text].length;
if (chars < RUBRIC.min_chars) fail.push(`本文 ${chars} 字(最低 ${RUBRIC.min_chars} 字)`);
if (chars > RUBRIC.max_chars) fail.push(`本文 ${chars} 字(上限 ${RUBRIC.max_chars} 字)`);
for (const w of RUBRIC.banned_words)
if (text.includes(w)) fail.push(`禁止語「${w}」を含む`);
for (const o of RUBRIC.forbidden_openers)
if (text.includes(o)) fail.push(`定型表現「${o}」を含む`);
if (RUBRIC.require_code_block && !text.includes("```"))
fail.push("コードブロックが無い");
if (fail.length === 0) process.exit(0); // 合格:何も出力せず終了
// 不合格:block 判定を JSON で返し、理由を差し戻し指示にする
console.log(JSON.stringify({
decision: "block",
reason:
"提出物が品質ルーブリックを満たしていません。次の点を直して再提出してください:\n" +
fail.map(f => `- ${f}`).join("\n"),
}));
process.exit(0);exit 0 のまま decision: block を返しているのがコツです。終了コード 2 でもブロックできますが、JSON で返すと reason をそのままサブエージェントへの指示として渡せるため、「何を直すか」が本人に伝わります。差し戻し文を箇条書きにしておくと、再生成の精度が目に見えて上がりました。
無限ループを防ぐガードを必ず入れる
この仕組みで最初にやらかしたのが、直しきれない違反でサブエージェントが延々と再試行する事態でした。たとえばルーブリックが厳しすぎて構造的に満たせないと、ブロックと再生成が止まりません。
対策として、トランスクリプトに含まれる過去の差し戻し回数を数え、上限を超えたら block をやめて人間にエスカレーションします。
const blockCount = lines.filter(l => l.includes('"decision":"block"')).length;
if (fail.length && blockCount >= 2) {
// 2回直しても通らなければ、止めて記録する(人間が見る)
console.error("[grade] 再試行上限。手動レビューへ: " + fail.join(" / "));
process.exit(0); // ここでは block しない=ループを止める
}採点ゲートは「自動で弾く」ことより「弾き続けない」ことのほうが運用では大事でした。止まらないゲートは、止まらないアラートと同じで、いずれ全員が無視するようになります。
運用して見えた3つの実務的な勘所
ひとつ目は、ルーブリックをコードに埋め込まず JSON ファイルに切り出したことです。基準は運用の中で必ず変わります。新しい禁止語が増えるたびにスクリプトを触っていては続きません。
ふたつ目は、採点器を決定的に保ったことです。モデルに採点させる誘惑は強いのですが、同じ提出物で合否が揺れると、サブエージェントは「運が悪かっただけ」と学習できません。機械的に弾ける違反はコードで、主観的な品質は本体プロンプトと人間で、と層を分けるのが安定しました。
みっつ目は、差し戻し理由を必ず具体的にしたことです。「品質が不十分」ではなく「H2 が 3 個(最低 4 個)」と数字で返すと、再生成が一発で通る確率が上がります。フィードバックの粒度が、自動修正ループの収束速度を直接決めます。
次の一歩として、まずは banned_words と min_h2 だけの最小ルーブリックで SubagentStop フックを一本通してみてください。検品ラインが一度動き出すと、基準を足していくのはあとからいくらでもできます。同じように委譲の品質に悩んでいる方の参考になれば幸いです。