はじめに:スキルが増えるほど「壊れても気づかない」
当社(合同会社ジョインクラス)では、Claude Codeのエージェント・スキルで会社運営の大半を自動化しています。launchdジョブは17個、自動化率は98%です。
ただ、規模が大きくなると困るのがスキルやプラグインが静かに壊れることです。僕自身、CLAUDE.mdが1,000行を超えたときにルール同士が矛盾し、AIが指示と逆の動作を始めたことがあります。しかも誰も気づきませんでした。プラグインも同じで、SKILL.mdの description を1行変えただけで、スキルが呼ばれなくなることがあります。
そこでこの記事では、プラグインを配布する前に行う評価(eval)のパイプラインを、当社で実際に動いているスクリプトをもとに紹介します。
-
claude plugin validateで構造を静的チェックする -
claude -p(ヘッドレス実行)で「このプロンプトならこのスキルが発火する」を自動判定する - launchdで定期実行して、劣化を検知したらSlackに通知する
⚠️ 最近のClaude Codeには
claude plugin evalというサブコマンドがあります。ただし、オプションはバージョンによって変わります。まずclaude plugin eval --helpを見て、自分の環境の書式を確認してください。この記事のハーネスは、evalがあればそれを呼び、なければclaude -pで同じ判定をする作りなので、どちらの環境でも動きます。
前提とディレクトリ構成
my-plugin/
├── .claude-plugin/
│ └── plugin.json
├── skills/
│ └── write-qiita/
│ └── SKILL.md
└── evals/
├── cases.tsv # 評価ケース(プロンプト → 期待スキル)
└── run-evals.sh # ハーネス
plugin.json は最小構成で問題ありません。
{
"name": "joinclass-content",
"version": "1.3.0",
"description": "Qiita/Zenn/note記事の生成スキル群"
}
Step 1: 静的チェック(validate)
最初に、壊れたマニフェストやfrontmatterの欠落を止めます。ここで落ちるものに、LLMの実行コストをかける必要はありません。
claude plugin validate ./my-plugin
これに加えて、当社では SKILL.md のfrontmatterを独自にチェックしています。description が空のスキルや、長すぎるスキルは発火精度が落ちるからです。
#!/bin/bash
# evals/lint-skills.sh
set -o pipefail
FAIL=0
for f in skills/*/SKILL.md; do
desc=$(awk '/^---$/{n++; next} n==1 && /^description:/{sub(/^description: */,""); print}' "$f")
if [ -z "$desc" ]; then
echo "NG: $f description がない"; FAIL=1
elif [ ${#desc} -gt 1024 ]; then
echo "NG: $f description が長すぎる (${#desc}文字)"; FAIL=1
fi
done
exit $FAIL
Step 2: 評価ケースを書く
評価ケースはTSVで管理しています。当社の記事テーマ管理(article-theme-ledger.tsv)と同じ形式にそろえておくと、awk や cut でそのまま扱えます。
# evals/cases.tsv prompt<TAB>expected_skill(noneは「発火してはいけない」)
Qiita記事を書いて。テーマはMCP write-qiita
Zennに技術記事を出したい write-zenn
今日の天気は? none
この関数をリファクタして none
落とし穴: 「発火すべきケース」だけを並べると、何にでも反応するスキルでも満点になってしまいます。none のケース(誤発火の検知用)を、全体の3割以上は入れてください。
Step 3: ヘッドレスevalハーネス
下のハーネスは、当社の .company/scripts/auto-agent-health-check.sh の骨格(ログ出力・Slack通知・set -o pipefail)を流用しています。
#!/bin/bash
# evals/run-evals.sh
set -o pipefail
PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)"
CASES="$PLUGIN_DIR/evals/cases.tsv"
LOG_FILE="$PLUGIN_DIR/evals/eval.log"
THRESHOLD=${THRESHOLD:-90}
notify_slack() {
if [ -n "${SLACK_WEBHOOK_URL:-}" ]; then
curl -sf -X POST "$SLACK_WEBHOOK_URL" -H 'Content-Type: application/json' \
-d "$(jq -n --arg text "$1" '{text: $text}')" >/dev/null 2>&1 || true
fi
}
# 1プロンプトを実行し、呼ばれたスキル名を返す(なければ none)
detect_skill() {
claude -p "$1" \
--plugin-dir "$PLUGIN_DIR" \
--output-format stream-json --verbose \
--max-turns 1 2>/dev/null |
jq -r 'select(.type=="assistant") | .message.content[]?
| select(.type=="tool_use" and .name=="Skill") | .input.skill' |
head -1 | sed 's/.*://' | grep . || echo none
}
claude plugin validate "$PLUGIN_DIR" || { notify_slack "plugin validate 失敗"; exit 1; }
TOTAL=0; PASS=0; FAILS=""
while IFS=$'\t' read -r prompt expected; do
[[ -z "$prompt" || "$prompt" == \#* ]] && continue
TOTAL=$((TOTAL + 1))
actual=$(detect_skill "$prompt")
if [ "$actual" = "$expected" ]; then
PASS=$((PASS + 1))
else
FAILS="${FAILS} - \"${prompt}\" 期待=${expected} 実際=${actual}\n"
fi
done < "$CASES"
SCORE=$((PASS * 100 / TOTAL))
echo "$(date): score=${SCORE}% (${PASS}/${TOTAL})" >> "$LOG_FILE"
if [ "$SCORE" -lt "$THRESHOLD" ]; then
notify_slack "[plugin eval] ${SCORE}% < ${THRESHOLD}%\n${FAILS}"
exit 1
fi
実行すると、次のような結果が出ます。
$ THRESHOLD=90 ./evals/run-evals.sh; echo "exit=$?"
exit=0
$ tail -1 evals/eval.log
Thu Oct 29 22:10:03 JST 2026: score=100% (4/4)
実装上のポイント
-
--max-turns 1: 判定したいのは「最初にどのスキルを選んだか」だけです。スキルを最後まで実行させるとコストが10倍以上になります。 -
stream-jsonをjqで解析する: 最終テキストをgrepで調べる方法は不安定です。tool_useイベントを直接見てください。 -
sed 's/.*://': プラグインのスキルはjoinclass-content:write-qiitaのように名前空間付きで返ってきます。比較の前に接頭辞を落とします。 - 1回の判定は揺れる: LLMの選択は確率的です。境界にあるケースだけ3回実行して多数決を取ると、誤検知が大きく減りました。
Step 4: launchdで定期実行する
当社はmacOSでcronが全滅した経験があります(フルディスクアクセスの問題)。それ以来、定期実行はlaunchdに統一しています。
<!-- ~/Library/LaunchAgents/com.joinclass.plugin-eval.plist -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>com.joinclass.plugin-eval</string>
<key>ProgramArguments</key>
<array><string>/bin/bash</string><string>/Users/you/my-plugin/evals/run-evals.sh</string></array>
<key>StartCalendarInterval</key><dict><key>Weekday</key><integer>1</integer><key>Hour</key><integer>6</integer></dict>
</dict></plist>
launchctl load ~/Library/LaunchAgents/com.joinclass.plugin-eval.plist
launchctl list | grep plugin-eval # 登録されたか確認する
落とし穴: launchdはログインシェルの PATH を読み込みません。スクリプトの先頭で export PATH=... を書き、claude と jq の絶対パスが通るようにしてください。当社のスクリプトはすべて冒頭でnvmのパスを明示しています。
検証方法:わざと壊して確かめる
ハーネスを信頼する前に、故意に壊して赤くなるかを必ず確認します。
# description を空にして、validate または lint で落ちるか
sed -i '' 's/^description:.*/description:/' skills/write-qiita/SKILL.md
./evals/lint-skills.sh; echo "exit=$?" # → exit=1 になればOK
git checkout skills/write-qiita/SKILL.md
赤にならないテストは、テストではありません。
運用してわかったこと
- 当社で一番効いたのは、誤発火ケース(
none)を追加したことです。記事系スキルが「リファクタして」にも反応していた問題を見つけられました。 - evalのコストは、
--max-turns 1に絞れば、ケース20件で数十円程度です。AI運用費全体(月約¥20,000)から見れば誤差の範囲です。 -
descriptionを変更するPRでは、必ずevalを通すルールにしました。CLAUDE.mdの肥大化で学んだのは、「書いたルールが効いているか」は計測しないとわからないということです。
まとめ
| 段階 | 手段 | 止めるもの |
|---|---|---|
| 静的 |
claude plugin validate + lint |
構造破損・frontmatter欠落 |
| 動的 |
claude -p + stream-json判定(claude plugin eval があれば併用) |
発火漏れ・誤発火 |
| 継続 | launchd + Slack通知 | 時間経過による劣化 |
プラグインは「作って終わり」ではなく、評価して初めて配れる成果物です。今日、cases.tsv に5行書くところから始めてみてください。
もっと深く知りたい方へ
Skills・プラグインの設計(description の書き方、発火精度を上げるコツ、配布方法)は、書籍『Claude Skills 完全ガイド』で体系的に解説しています。