claude plugin evalをCIの合否に使う — aggregate-result.jsonの読み方
claude plugin evalの終了コードに出ない失敗を、aggregate-result.jsonで拾う方法。partial、casesPassed、delta、aborted、skippedPaidGradersの読み方です。
claude plugin evalの合否は、終了コードだけでも判定できます。ただし、終了コードに出ない事情が結果の数字に混ざることがあります。コスト上限での打ち切り、judgeの省略、モックによる中断です。これらを区別するには、--jsonで書き出すaggregate-result.jsonを読みます。
本記事は、このJSONのどのフィールドで合否を決め、どのフィールドで結果をトレンドから外すかを扱います。コマンドの導入やケースの書き方はclaude plugin evalの使い方にまとめています。
終了コードで足りる範囲と足りない範囲
ドキュメントの終了コードは5種類です。
| 終了コード | 意味 |
|---|---|
| 0 | 意味全ケースが--threshold以上で、全ケースファイルが読み込めた |
| 1 | 意味しきい値未満のケース、読み込み失敗、ケースなし、信頼されていないプラグインディレクトリなど |
| 2 | 意味部分実行。--max-cost-usdの上限到達、または資格情報の拒否 |
| 130 | 意味中断(部分結果は書き出される) |
| 143 | 意味CIのタイムアウトなどによる終了 |
0と1だけを見れば、ビルドの成否は決まります。足りないのは次の3点です。
- with-minus-withoutのdeltaは報告されるだけで、終了コードを変えません。プラグインが効いていない(deltaがゼロ以下の)ケースでも、withアームのスコアが足りていればビルドは通ります
- 途中でusage-limitやrate-limitに達したときは、以降のrunがエラーで終わりスコアはほぼ0になります。スイートは
partial扱いにならず最後まで走るため、見かけ上は退行に見えます - 終了コード2になる打ち切りは、結果ファイルを見ないと理由が分かりません
こうした事情を結果から読み取るために、JSONの出番があります。
aggregate-result.jsonの基本
実行のたびに、評価ディレクトリのresults/<タイムスタンプ>/にaggregate-result.jsonとreport.htmlが書かれます。--json <path>を渡すと同じ結果ドキュメントを任意のパスにも書き出せます。--output-dirで出力先ディレクトリも変えられます。
ドキュメントはschemaVersion: 1で、フィールド名はすべてcamelCaseです。新しいフィールドは既存の名前を変えずに追加されるため、スクリプトは知らないフィールドを無視する書き方にします。
ゲートに使うことが多いのは、次のフィールドです。
| フィールド | 役割 |
|---|---|
partial / partialReason | 役割未完走の印。理由はcost_ceiling・interrupted・auth_failed |
aggregates.casesPassed / casesTotal | 役割しきい値以上のケース数と総数 |
aggregates.overallScore / meanDelta | 役割スイート全体の平均スコアと平均delta |
cases[].aggregates.score / delta | 役割ケースごとのwithアームの平均スコアと、with引くwithoutの差 |
cases[].arms.with[]のerror / aborted / skippedPaidGraders | 役割runごとの異常の印 |
JSONにはほかに、スイートの設定、graderの定義、runごとのgrader結果(説明と根拠つき)も入っています。costUsd・durationSeconds・claudeVersionも含まれ、costUsdはjudge呼び出しを含む定価ベースの推定額です。
合否はcasesPassedとoverallScoreで決める
ケース単位の合否は、そのケースのwithアームのスコアが--threshold以上かどうかで決まります。--thresholdの既定は1.0で、実行時に渡した値がそのままcasesPassedの計算に使われます。
そのため、実行時と別のしきい値で判定し直したいときは、casesPassedではなくcases[].aggregates.scoreから自分で数えます。逆に実行時のしきい値で足りるなら、casesPassed == casesTotalを見るのが最短です。
ゲートの条件は2つだけで足ります。完走していること、そして全ケースが通っていることです。
jq -e '
((.partial // false) | not)
and (.aggregates.casesPassed == .aggregates.casesTotal)
' results.jsonこれは公式の例ではなく、前述のフィールド定義から組んだ書き方です。jq -eは結果がfalseかnullのときに終了コード1を返すので、CIのステップとしてそのまま使えます。
partialの結果はトレンドから外す
partialがtrueのとき、partialReasonには理由が入ります。
cost_ceiling:--max-cost-usdに達し、未着手のrunが残ったauth_failed: 資格情報が最初のrunの前後で拒否されたinterrupted: 中断された
ドキュメントは、部分結果をトレンドのグラフに入れないよう求めています。終了コードは、コスト上限と資格情報の拒否が2、中断が130と分かれて書かれています。ただしJSONを読めば、コードを経由せずに理由を直接取れます。
完走した結果だけを時系列に積む処理は、次のように書けます。
jq -r 'select((.partial // false) | not)
| [.claudeVersion, .aggregates.overallScore,
(.aggregates.meanDelta // ""), .costUsd]
| @csv' results.json >> trend.csvmeanDeltaは二つのアームを比べた実行にだけ入るので、// ""で欠損を許しています。
比較できないrunを見分ける: skippedPaidGradersとaborted
partialではない完走結果にも、スコアが他と比べられない行が混じります。runごとの印が2つあります。
skippedPaidGradersは、コスト上限のせいでそのrunのjudge graderが省略されたときにtrueになります。judgeを使わないgraderだけで採点されたスコアなので、他のrunと同じ土俵には載せられません。
abortedは、モックがexpect:、abort_when、またはagentモックの呼び出し予算によってrunを止めたときに付きます。server・tool・reasonを持ちます。このrunはスコア0で、errorはnullのままです。
落とし穴
errorがnullでもスコアが0のrunがある
abortedで止まったrunは、スコア0なのにerrorがnullです。「errorが空のrunだけを正常とみなす」フィルタでは、このrunが正常側に残ります。逆にerrorが空でないrunも、出力できたものは採点されるので、スコア0とは限りません。runの異常を調べるときは、error・aborted・skippedPaidGradersの3つを別々に見ます。
異常runを数える例です。
jq '[.cases[].arms.with[]
| select(.skippedPaidGraders == true or has("aborted"))]
| length' results.json0より大きければ、そのスコアは素直な比較に使えません。ログに「比較不能run: N件」と出して、人が見られる状態にしておく運用が考えられます。
deltaを合否に足したいとき
deltaはwithアームのスコアからwithoutアームのスコアを引いた値です。cases[].aggregates.deltaは、ケースが片方のアームしか走らなかったとき、あるいは二つのアームが比べられないときには出力されません。--ablation noneを渡した実行では、全ケースでdeltaがなくなります。
終了コードはdeltaを見ないので、「プラグインが効いていること」までゲートにしたいなら自前の判定が要ります。たとえば次のように、deltaが付いているケースだけ、0以下を一覧にします。
jq -r '.cases[]
| select(.aggregates.delta != null and .aggregates.delta <= 0)
| "\(.name)\t\(.aggregates.delta)"' results.jsonこれをそのまま失敗条件にするかは、運用次第です。1.0のスコアがプラグインなしでも出るケースは、もともとdeltaが0になります。落とすのではなく、まずは警告として出して様子を見るほうが安全です。
加えて、二つのアームを比べる実行では、tool_used: Skillのような「プラグインがなければ通りようがない」graderはscored: falseになり、スコアから除外されます。run内のgraderがpassed: falseでもrunが1.0になる場合があるのはこのためで、JSONのscoredを見ると理由が分かります。
withoutアームのerrorは、そのまま失敗とは限らない
JSONには、withアームのcases[].arms.withのほかに、ベースライン側のcases[].arms.withoutも入っています。ここのerrorを機械的に集計すると、誤検知が出ます。
ベースラインはプラグインなしで走るので、Claudeがプラグインのエージェントを呼ぼうとすると、Agent type '<plugin>:<agent-name>' not foundというエラーでツール呼び出しが失敗します。deltaはプラグインのrunとこのベースラインの比較なので、これは想定どおりの挙動です。
異常runの集計はwithアームに限定し、withoutアームのerrorは失敗の根拠にしません。ベースラインのコストを抑えたいだけなら、--ablation noneで外せます。
CIジョブの組み立て
評価コマンドは終了コードで落とし、JSONで「落ちなかったが怪しい」を拾う二段構成にします。評価コマンドが失敗してもJSONを読めるよう、ジョブ側で|| status=$?のように終了コードを保存してから続けます。
status=0
claude plugin eval . \
--trust-plugin \
--json results.json \
--threshold 0.8 \
--model claude-sonnet-5 \
--judge-model claude-haiku-4-5 \
--no-publish \
--max-cost-usd 20 || status=$?
if jq -e '(.partial // false) | not' results.json >/dev/null; then
jq -r '.aggregates | "passed \(.casesPassed)/\(.casesTotal)"' results.json
else
echo "partial: $(jq -r .partialReason results.json)"
fi
exit "$status"--jsonの直後にターゲットを置くと、ターゲットが出力パスとして読まれて--json output path must end in .jsonのエラーになります。ターゲットは先に書くか、--jsonに.jsonで終わるパスを明示します。上の例は.を先頭に置いています。
CIランナー側には、Claude CodeのインストールとANTHROPIC_API_KEYなどの資格情報が必要です。--trust-pluginがないと、信頼されていないチェックアウトディレクトリは拒否されて終了コード1になります。Docker Desktop入りのMacでBash付きの実行が拒否される場合は、別記事の対処を参照してください。
終了コード1なのに結果が正常に見えるとき
出力された数字が良いのに終了コード1になるときの原因は、ドキュメントでは2つ挙げられています。
--thresholdの既定が1.0なので、1つでも満点でないケースがあると1になる- ケースファイルが読み込めなかった。このエラーは標準エラーに出るだけで、結果の表には出ない
前者はしきい値を実際に求める水準へ上げ下げすれば解消します。後者は、JSONが書かれていてもcasesTotalが期待より少ないことで気づけます。ケースの数を「期待値」としてCI側に持っておき、casesTotalと突き合わせる方法があります。
usage-limitの途中打ち切りは別物
課金上限はpartialの対象ですが、プランの使用上限やAPIのレート制限は違います。到達すると以降のrunはそのエラーで終わり、出力したものだけで採点されてほぼ0点になります。スイートはpartialにならないため、JSONだけでは完走した退行に見えます。
見分けるには、cases[].arms.with[].errorに制限のメッセージが入っていないかを確認します。runごとのerrorがまとまって同じ文面になっているなら、リセット後に--runs 1や--caseで絞って再実行します。
skill-creatorのevalはevals/evals.jsonという別形式のケースを使い、claude plugin evalのケースファイルとは互いに読み合いません。CIでプラグイン全体をゲートにするなら、この記事のJSONを前提にします。
まとめ
終了コードでビルドを落とし、JSONで結果の質を選別する分担にすると、運用が安定します。
- ゲートの条件は
partialでないこととcasesPassed == casesTotal - トレンドには
partial: true、skippedPaidGraders、abortedのあるrunを入れない - deltaは終了コードに効かない。必要なら自前で失敗条件にするか、警告に留める
- 実行時と別のしきい値で見たいときは、
casesPassedではなく各ケースのscoreから数え直す
コマンドの使い方はClaude Code v2.1.269のリリースノートに、ケースの書き方はclaude plugin evalの使い方にあります。