0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Code の「完了しました」を信用しない。判定専用AI「Jev」をフック3本に挟み、4日間1,519判定・約5.6円で止めた話

0
Last updated at Posted at 2026-10-02

はじめに

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件を止めました。筆者はノンズ株式会社でこの運用をしています。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?