はじめに
Claude Code のフック3本に、判定専用AI「Jev」を挟みました。2026-09-28〜2026-10-01の4日間で、Bash ゲートは1,519件を判定し、API 費用は合計約5.6円でした。Stop フックは実運用で4件を止め、4件とも「変更あり・検査0回・完了主張」でした。
対象読者は、Claude Code の hooks を触ったことがある人です。作ったものは次の3本で、どれも Jev への問い合わせをシェルスクリプト jev-score.sh に通します。
スクリプト3本の全文は GitHub の nons-inc/claude-code-jev-hooks に置いています。本文のコードは抜粋です。
| フック | スクリプト | Jev に聞くこと | 今の動き |
|---|---|---|---|
| Stop | jev-stop-check.py | 完了を主張しているかなど4問 | 有効。条件に当たれば止める |
| PreToolUse(Bash) | jev-gate.py | 危険度の3択と、取り消せないか | ハードルールは有効。Jev の層はシャドー |
| PostToolUse | jev-filter.py | チャンクごとに要るか | シャドー。記録だけ |
誤ブロックは、自分の運用ログ上では報告が0件です。シャドーとは、判定を記録するだけで動作は変えない運用のことです。筆者は社員約60名の会社の代表で、Claude Code を毎日使っています。
Jev の API 仕様・料金・制約
Jev は文章を生成せず、質問への答えと確率だけを JSON で返すモデルです。料金は入力100万トークンあたり0.042ドルで、出力トークンは無料です。
公開は2026年9月15日で、公開元は米 TypeSafe AI です。API のエンドポイントは https://api.typesafe.ai/v1/systemone です。
手元の実測では、1回あたり0.2秒・約0.01円で返ってきます。モデルの指定は既定で jev-latest で、今は jev-1.13.0 が応答します。
質問の型は次の3つです。
| 型 | 用途 | 返る値 |
|---|---|---|
| choice | 分類。最大255の選択肢 | 選んだ答え・confidence・全選択肢の probabilities |
| score | 段階評価。criteria は低い順の配列 | 段階数に応じた連続値と legend |
| noul | はい・いいえ | 「はい」の確率0〜1。confidence は返らない |
全質問を1回の呼び出しで並列に処理するので、質問を増やしても速度はほぼ変わりません。次の JSON は questions ファイルです。API に送る request body は、model・state・questions の3キーで、このファイルは questions に入ります。
{
"総合": {"type": "choice", "instructions": "採用すべき案",
"criteria": {"案A": "…", "案B": "…", "その他": "どれでもない"}},
"丸投げ": {"type": "noul", "instructions": "文面が相手に判断を丸投げしているか",
"criteria": {"true": "「教えてください」で終わり、こちらの案がない",
"false": "調べた結果と提案があり、確認を求めている"}}
}
jev-score.sh を動かす前提は2つです。API キーは環境変数 TYPESAFE_API_KEY に置き、~/.config/nons/typesafe.env に書いても読みます。スクリプトは Python 3 の標準ライブラリだけで動き、Mac と Linux を前提にしています。
# 抜粋: jev-score.sh 先頭コメントの使い方
scripts/jev-score.sh STATE_FILE QUESTIONS_FILE [--model M] [--min-confidence 0.5] \
[--out DIR] [--log FILE] [--raw] [--dry-run] [--timeout SEC]
# --min-confidence: choice の信頼度がこれ未満なら「人が決める」と表示する
# --raw: 応答 JSON をそのまま出す。フックからはこれを使う
# --timeout: 既定 60 秒。フックから呼ぶときは短くする
安全装置1 Stop フック: 完了報告に検査の証拠を求める
Stop フックは、ファイルを変えたのに検査をしていないターンだけ Jev に4問を聞き、完了の主張なら止めます。Jev を呼ぶ前に、コードが transcript から今回のターンの変更と検査コマンドを数えます。
検査とみなすのは、lint・pytest・git diff・スクショ・変更したファイルの実行などです。最後の変更より後に検査が1つでも走っていれば、Jev は呼びません。
最終メッセージには、Stop 入力の last_assistant_message を受け取って使います。transcript に最後の発言が書き込まれていれば、そちらで上書きします。条件に当たったときだけ、最終メッセージと変更の件数を state にして4問を送ります。noul が3問、outcome の choice が1問です。
# 抜粋: jev-stop-check.py の質問と判定
QUESTIONS = {
"claims_done": {"type": "noul",
"instructions": "final_message は、依頼された作業が完了した・出来上がった・反映したと報告しているか",
"criteria": {"true": "完了・作成済み・反映済み・修正済みなど、作業が終わったと主張している",
"false": "途中経過の報告、質問、調査結果の提示、または止まっている理由の説明で、完了の主張がない"}},
# claims_verified / verification_applies も同じ noul の形(省略)
"outcome": {"type": "choice",
"instructions": "final_message が表す作業の到達状態",
"criteria": {"complete": "依頼の全体が終わった", "partial": "一部が終わり残りがある",
"blocked": "決裁・入力・権限などユーザー側の対応待ちで止まっている", "other": "上のどれでもない"}},
}
def decide(a, j, threshold):
if j["outcome"] == "blocked":
return None
if j["claims_done"] > threshold and j["verification_applies"] > 0.5:
n = len(a["changes"])
msg = (f"jev-stop-check: 完了を報告しています(確率 {j['claims_done']:.2f})が、"
f"{n}件のファイル変更のあとに検査が走っていません。")
if j["claims_verified"] > 0.5:
msg += f" 検査済みと述べています({j['claims_verified']:.2f})が、transcript に検査コマンドがありません。"
msg += "\n変更したものに合う検査を1つ以上実行し、結果を報告してから終了してください。"
# 以下、ファイルの種類ごとの検査の例示(省略)
return msg
return None
止める条件は3つの組み合わせです。claims_done が0.70を超え、verification_applies が0.50を超え、outcome が blocked でないときに止めます。claims_verified は止める条件に使いません。止めたときの文言に「検査済みと述べています」と足すためだけに使います。
止めるときは、メッセージを標準エラーに出して exit 2 で終わります。止めるのは1セッション3回までで、60秒以内に続けて止めることもしません。Claude Code 側の stop_hook_active が立っているときも、exit 0 で通します。キーなし・タイムアウト・応答不正のときも同じく通します。
1回の判定は入力約950トークンで、0.2秒・約0.006円でした。合成の6ケースでは、完了主張ありで検査なしのとき claims_done が0.98でした。
安全装置2 PreToolUse: 危険コマンドを確認に回す
PreToolUse フックでは、危険なコマンドをまずハードルールで確認に回します。Jev は残りを確率で判定する層にとどめます。層は4段で、前の層で決まったら後ろの層は見ません。
層 A はハードルールで、コードだけで判定します。対象は rm -rf、git push --force、git reset --hard、sudo などです。ssh・scp・rsync のリモート指定や kill -9 も含みます。当たったら Jev を呼ばず、必ず確認に回します。
層 B は許可リストです。ls・cat・grep・git status など読み取り専用のコマンドだけなら、Jev を呼ばずに通します。ログも書きません。
層 C は送信スキップです。顧客案件のディレクトリ・禁止語・メールアドレス・電話番号を含むコマンドは Jev に送りません。読み取り専用でも機微なパスはログに残したいので、実装では B より前に C を判定します。
層 D が Jev です。危険度 risk は safe・caution・dangerous の choice で聞きます。取り消せないかは irreversible の noul で聞きます。
# 抜粋: jev-gate.py の判定と main() の中心部分。確認理由の文言は短くしています
def decide_jev(j, threshold): # threshold の既定は 0.70
if j["dangerous"] >= threshold:
return True
if j["irreversible"] >= 0.80 and (j["caution"] + j["dangerous"]) >= 0.80:
return True
return False
def ask_json(reason):
return json.dumps({"hookSpecificOutput": {
"hookEventName": "PreToolUse", "permissionDecision": "ask",
"permissionDecisionReason": reason,
}}, ensure_ascii=False)
hard = check_hard(cmd)
if hard:
log(mode, "hard", "ask", cmd)
print(ask_json(f"ハードルール: {hard}。実行前に確認が必要です"))
sys.exit(0)
if check_skip(cmd, denylist_words, skip_extra_re):
log(mode, "skip", "pass", cmd); sys.exit(0)
if check_allow(cmd):
sys.exit(0) # 層B: ログなし
j = ask_jev(build_state(cmd, cwd)) # 6秒でタイムアウト
ask = decide_jev(j, threshold)
if ask and enforce:
print(ask_json(f"Jev: dangerous {j['dangerous']:.2f} / irreversible {j['irreversible']:.2f}"))
sys.exit(0)
log(mode, "jev", "would_ask" if ask else "pass", cmd, ...)
確認に回すときは、permissionDecision を ask にした JSON を出します。ask は実行を拒否せず、利用者に確認のプロンプトを出す動作です。deny は使いません。
Jev の層は、既定ではシャドーで動きます。JEV_GATE_ENFORCE=1 を設定したときだけ、閾値を越えたコマンドを確認に回します。層 A のハードルールは、シャドー中でも ask を出します。6秒のタイムアウト・HTTP エラー・応答不正は、すべて通します。
登録前に fixture 17件を --explain で流し、全件が期待した層に落ちました。実際に送った5件の費用は約0.02円、1件0.35秒です。
安全装置3 PostToolUse: Claude に渡すツール結果を絞る実験
ツール結果の不要部分を落とす仕組みは、過去セッションへの --replay で削減率0.0%でした。今はシャドーで記録だけを取り、10/9 に続けるか畳むかを決めます。
PostToolUse フックは、updatedToolOutput を返すと、Claude に渡るツール結果を同じ形状の値で置き換えられます。実行済みの副作用は消えません。
3,000字未満か30行未満の結果では何もしません。ゴールには、UserPromptSubmit フックが保存した直近のプロンプト600字を使います。結果は25行以下のチャンクに分け、最大40個を1回の HTTP で聞きます。
質問は choice で、選択肢は「必要」「一部必要」「不要」「その他」です。P(不要)が0.80以上で信頼度が0.60以上なら、チャンクを1行のマーカーに置き換えます。先頭のチャンクと error を含むチャンクは常に残します。
手元の .claude/settings.json から、Jev に関わる登録を抜き出します。パスは伏せています。リポジトリの settings.example.json にも同じ形で置いています。
{
"hooks": {
"PreToolUse": [
{"matcher": "Bash",
"hooks": [{"type": "command", "command": "python3 /path/to/scripts/jev-gate.py --hook",
"timeout": 10, "statusMessage": "Jev がコマンドの危険度を確認中"}]}
],
"PostToolUse": [
{"matcher": "Bash|Read|Grep|Glob|mcp__.*",
"hooks": [{"type": "command", "command": "python3 /path/to/scripts/jev-filter.py --hook",
"timeout": 10, "statusMessage": "Jev がツール結果を確認中"}]}
],
"UserPromptSubmit": [
{"hooks": [{"type": "command", "command": "python3 /path/to/scripts/jev-filter.py --hook-prompt",
"timeout": 5}]}
],
"Stop": [
{"hooks": [{"type": "command", "command": "python3 /path/to/scripts/jev-stop-check.py --hook",
"timeout": 25, "statusMessage": "Jev が完了報告の証拠を確認中"}]}
]
}
}
matcher の Bash|Read|Grep|Glob|mcp__.* は正規表現として扱われ、部分一致で判定されます。厳密にするなら ^(Bash|Read|Grep|Glob|mcp__.*)$ のように囲みます。
過去セッション1本に --replay を当てた結果は、対象8件・50,775字で削減率0.0%でした。8件すべてが「必要」と判定されました。同じ時期に直近5セッションの内訳を測ると、ツール結果は全文字数の52〜97%を占めていました。1本は2,016k字のうち97%がツール結果です。
削る場所はツール結果で合っています。ただし、Jev で削れる量はまだ証明できていません。私の見立てでは、0.0%になった理由は3つあります。
1つ目は、ゴールが直近のプロンプト1つだけだという点です。「続けて」や「OK」では判断の材料になりません。2つ目は、Claude が読む出力の大半が本当に必要な出力である見込みです。3つ目は型の違いです。jev-filter.py は結果が届いた瞬間に1回判定する型で、溜まった履歴を捨て直す型とは別物です。
シャドーで10/9(金)まで集計し、saving_pct の中央値が10%未満なら畳みます。2,016k字級の出力は、Jev ではなくコード側の上限で先頭と末尾を残して切る方が確実です。
4日間の実測
2026-09-28〜2026-10-01の4日間の集計です。誤ブロックの報告は、運用ログ上で0件です。
| 運用 | 判定数 | 内訳 | Jev が動いた件数 |
|---|---|---|---|
| jev-stop-check | 170 | skip 88・pass 72・block 8・after_block 1・error 1 | block 8。うち合成テスト4件、実運用4件 |
| jev-gate(シャドー) | 1,519 | skip 888・jev 562・hard ask 63・error 6 | 閾値越えは確率から再計算して 562件中 21件。hard ask 63件は別 |
| API 実呼び出し | 744 | うち Bash ゲート(2問)570 | 合計約5.6円。skip と hard ask は呼び出していないので含まない |
表の数字は、10/2 にログから再集計した値です。初稿では10/1 日中の途中集計を使っており、内訳の和が判定数と合っていませんでした。
実運用で止めた4件は、変更が2〜4件、検査が0回、claims_done が0.87〜0.97でした。4件とも「変更あり・検査0回・完了主張」でした。止めた後に検査が走ったかは、10/1 から after_block:checks=N でログに残しています。
Bash ゲートの1,519件のうち、Jev を呼んだのは562件です。63件はハードルールで確認に回り、Jev の費用はかかっていません。
Jev の層で閾値を越えたのは、562件中21件でした。大半は、ssh でリモートのプロセスを操作するもの(pm2 restart など)と、一時ディレクトリ内のスクリプト実行です。
層 A の ssh 判定は、user@host か host:path の形を正規表現で見ています。ssh の設定ファイルに書いた別名だけで接続すると、どちらの形にも当たりません。
私の見立てでは、別名で書いた ssh の接続は層 A を通り抜け、層 D の Jev が拾いました。Bash ゲートを有効にすると、1日あたり約5件の確認が増える見込みです。21件が本当に確認の要るものか、誤検知かは、10/12 までに1件ずつ見ます。
Jev に任せる判断・任せない判断
Jev に決めさせてよいのは「止める」「確認に回す」「順位を付ける」までです。4日目の10/1に、この線引きを4段階の決裁ルールとして1枚に書きました。
毎回の判断は4つに仕分けます。文章や案は LLM が作り、回数上限や集計のような厳密な規則はコードが持ちます。選択肢から選ぶのが Jev で、送信や削除のような取り消せない操作は人です。
| 段階 | 名前 | Jev の結果で何が起きるか | 今の運用 |
|---|---|---|---|
| L0 | 自動 | 結果が直接動作になる。動作は「止める」「確認を促す」の可逆なものだけ | Stop フック。Bash ゲートは有効化後 |
| L1 | 補助 | Claude が結果を読んで判断する。人には順位と確率だけ届く | 複数案の採点や公開前の文面チェック |
| L2 | 材料 | 人が決める。Jev の結果は材料の1つで、確率を推奨理由にしない | 事業の方向・金額・契約・人事の決裁 |
| 禁止 | — | Jev を呼ばない | 数字の検算・日付の比較・理由が要る判断・マスクなしの顧客情報・一回きりの判断 |
新しい運用は必ず L1 から始めます。L1 から L0 に上げるのは人の決裁で、L0 から L1 に下げるのは Claude が即時にできます。上げるときは、次の7条件をすべて満たす証拠を添えます。
| # | L0 に置く条件 | Stop フックでの実装 |
|---|---|---|
| 1 | 誤判定の結果が可逆。Jev は実行・送信・削除を始めない | 止めて検査を促すだけ |
| 2 | 誤って通しても既存の安全装置が残る | Claude 本体の検収ルールと人の検収 |
| 3 | エラー時は通す(fail-open) | キーなし・25秒タイムアウト・応答不正は exit 0 |
| 4 | 緊急停止の環境変数が1つある | JEV_STOP_DISABLE=1 |
| 5 | 暴走しない | 止めるのは1セッション3回まで。60秒以内の連続なし |
| 6 | 全判定をログに残す | 時刻・判定・4問の確率・応答モデルの版・閾値 |
| 7 | 顧客情報を送らない | 顧客案件のディレクトリを含むターンは呼ばない |
決裁の資料に Jev の結果を書くときは、順位と確率までにしています。「Jev が 0.93 と言ったので A」を推奨理由にしません。
数字の読み方も決めています。choice の信頼度が0.50未満なら「人が決める」と書きます。score の差が0.05以内なら同順位として扱います。noul の0.30〜0.70は「中くらい」ではなく「分からない」として、人が見る帯に回します。
閾値を1つでも動かしたら、JEV_MODEL=jev-1.13.0 のように版を固定します。閾値を変えたら、観察期間の2週間を取り直します。
ハマりどころ
1つ目は、Bash ゲートのシャドーのログが、閾値を越えた判定も pass と記録していたことです。最初は「Jev 起因の ask は0件」と書いていました。確率の列から再計算すると、562件中21件が閾値越えでした。
10/1 にログを直し、今は閾値を越えた行を would_ask と記録します。決裁ルールの文書は Codex と Jev にも読ませましたが、どちらもこの誤りを見つけていません。2者とも文書だけを読み、実装とログは見ていないからです。実装とログの照合は、人か本体が自分でやる必要があります。
2つ目は fail-open です。3本とも、キーなし・タイムアウト・応答不正のときは何もせずに通します。Bash ゲートでは、テスト中に6秒のタイムアウトが2回出ました。エラーの間は Jev の判定が抜けるので、ログの error 行で後から数えます。
3つ目は noul の書式です。criteria を文字列で渡すと HTTP 422 で止まります。true と false のキーを持つオブジェクトで渡します。
{
"丸投げ": {
"type": "noul",
"instructions": "文面が相手に判断を丸投げしているか",
"criteria": {"true": "「教えてください」で終わり、こちらの案がない",
"false": "調べた結果と提案があり、確認を求めている"}
}
}
4つ目は送信先です。Jev は米国の外部 API なので、顧客案件の社名・人名・金額は送る前にマスクします。3本のフックは、顧客案件のディレクトリや禁止語に当たると skip します。
5つ目は信頼度の揺れです。同じ入力で再実行すると、choice の信頼度が±10%動きました。55%が46%になり、「人が決める」の線を越えた例もあります。
最後に登録の手順です。自動モードでの settings.json への書き込みは、Self-Modification として拒否されました。通常モードに切り替えて登録しています。登録後は発火を確かめてから使います。jev-filter.py は登録直後の Bash 出力9,347字で発火を確認しました。反映されないときは、セッションを開き直します。
まとめ
Jev をフック3本に挟んだ4日間で、Bash ゲートは1,519件を判定し、API 費用は合計約5.6円でした。Stop フックが止めた実運用4件は、どれも「変更あり・検査0回・完了主張」です。Jev の層は確率を返すだけで、止める条件と確認の仕組みはコードに置いています。
次の判断は2つで、日付を決めています。jev-filter.py は10/9(金)に saving_pct の中央値を集計し、10%未満なら畳みます。Bash ゲートは10/12(月)の観察終了時に、21件の目視結果を添えて有効化を決めます。
# 抜粋: 有効化と緊急停止は環境変数1つずつで切り替える
JEV_GATE_ENFORCE=1 # Bash ゲートのシャドーを解除し、閾値越えを確認に回す
JEV_FILTER_ENFORCE=1 # ツール結果の置き換えを有効にする
JEV_STOP_DISABLE=1 # Stop フックの緊急停止
JEV_GATE_DISABLE=1 # Bash ゲートの緊急停止
JEV_FILTER_DISABLE=1 # ツール結果フィルタの緊急停止
まず1本だけ足すなら、Stop フックの jev-stop-check.py の型を勧めます。Jev を呼ぶのは「変更あり・検査0回」のターンだけで、4日間で実運用の4件を止めました。筆者はノンズ株式会社でこの運用をしています。