Claude Code に「テストが通るまで直して」と頼むと、人が操作しなくても、テストの実行と修正を繰り返してくれます。この繰り返しをループと呼びます。

便利な仕組みですが、止まり方を知らずに自動化すると、テストが落ちたまま「成功」として次の工程へ進むことがあります。この記事では、ループの仕組みを図で説明してから、39回の実行で分かった止まり方と、誤判定を防ぐ方法を紹介します。

Claude Code のループとは#

普通のチャットは、質問に1回答えて終わりです。Claude Code は、答える途中で自分でファイルを読み、コマンドを実行できます。この機能をツールと呼びます。

たとえば「テストが失敗しているので直して」と頼むと、次のように進みます。

  1. テストを実行して、どこが失敗しているか確かめる
  2. 関係するファイルを読む
  3. ファイルを直す
  4. もう一度テストを実行する
  5. 通ったので「直しました」と答える

1〜4 はツールを使う作業で、結果を見て次の作業を決めています。ツールを使わずに答えると、そこで作業は終わります。これがループの基本です。

Stop hook や /goal を設定すると、Claude が応答を終えるときに「本当に終わってよいか」を判定し、未達成なら作業を続けさせられます。この記事では、これをやり直しと呼びます。最初に戻るわけではなく、それまでの会話もファイルの変更も残ったまま続きます。

全体は図1のようになります。

Claude Code のループの全体図。①指示を受けた Claude は、②考えて③ツールを実行する内側のループを繰り返す。ツールを呼ばずに応答すると、Stop hook や /goal を設定していれば④で判定する。未達成なら②に戻り、達成・不可能・打ち切りのどれかで⑤ exit 0 で終了する。設定がなければ直接⑤へ進む。--max-turns などの実行の上限に達すると exit 1 で止まる

図1 Claude Code のループの全体図

止まり方で大事なのは、次の2つを区別することです。

  • 実行の上限: --max-turns(応答の回数)と --max-budget-usd(推定費用)で、自分で決めます。達すると exit 1 で止まります
  • 打ち切り: Claude がツールを呼ばないまま、やり直しが8回続くと、Claude Code は9回目の判定を無視して終わらせます。自動の仕組みで、exit 0 です

この記事で使う用語#

言葉何をするものかいつ働くか・指定しないと
claude -p依頼を渡して最後まで実行し、結果を出力して終わるコマンドCI やスクリプトから呼ぶとき。-p なしの claude は対話になる
ターン(応答)Claude が応答を1回返し、ツールを呼んでいれば実行して結果を受け取るまでのひと回り。ツールを呼ばない最後の回答も1回と数える実行中ずっと(図1の②③)。テストの修正なら4ターンだった
hook決まったタイミングで、登録したコマンドを Claude Code が自動で実行する機能対話でも claude -p でも。登録しなければ何も実行されない
Stop hookClaude が応答を終えるたびに動く hook。「やり直し」の結果を返すと、終わらせずに作業を続けさせる図1の④。ユーザーが中断したときは動かない。登録しなければ、応答を終えるとそのまま終わる
/goal終わりの条件を文章で渡すコマンド。別のモデルが達成か不可能と判断するまで作業を続けさせる対話でも claude -p でも。そのセッションだけ有効。使わなければ Claude が自分の判断で終わる
--max-turns1回の実行で Claude が応答してよい回数の上限claude -p だけ。指定しなければ上限なし
--max-budget-usd1回の実行の推定費用の上限claude -p だけ。指定しなければ上限なし
exit コードコマンドが終わるときに返す数字。claude -p は成功で 0、実行の失敗で 0 以外CI はこの値で次へ進むかを決める

Stop hook と /goal は、どちらも図1の④で動きます。Stop hook は判定を自分のスクリプトで書き、/goal は文章で書くだけで済みます。

何に困るのか#

たとえば、夜間の CI で Claude Code にテストを直させて、exit コードが 0 なら次の工程へ進む仕組みを作ったとします。

39回の検証では、打ち切りで終わった実行は10本(Stop hook 6本、/goal 4本)あり、すべて exit 0 でした。 テストが落ちたままでも、CI からは成功に見えるのです。

exit 0 になる止まり方の例。通常の完了はテストが成功、Stop hook の打ち切りはテストが失敗、/goal の打ち切りは成功も失敗もあった。3つとも exit 0 に矢印が向かい、CI には同じ成功に見える

図2 止まり方が違っても exit コードは同じ0になる

ここからは、次の3つを検証します。

  1. --max-turns は何を数え、いくつにすればよいか
  2. Stop hook と /goal は、どう動いてどう止まるか
  3. 自動実行で完了を正しく判定するにはどうするか

検証の準備#

失敗するテストを3件持つ Python のプロジェクトを使いました。calc.py の add、multiply、average に、1か所ずつバグを入れてあります。

def add(a, b):
    return a - b


def multiply(a, b):
    return a + b


def average(values):
    return sum(values) / (len(values) + 1)

テストファイル test_calc.py です。

import unittest
from calc import add, multiply, average


class TestCalc(unittest.TestCase):
    def test_add(self):
        self.assertEqual(add(2, 3), 5)

    def test_multiply(self):
        self.assertEqual(multiply(2, 3), 6)

    def test_average(self):
        self.assertEqual(average([2, 4, 6]), 4)


if __name__ == "__main__":
    unittest.main()

Claude がファイルを直すので、試すときは先に Git で記録し、実行のたびに git checkout calc.py で元に戻してください。

git init && git add calc.py test_calc.py && git commit -m "start"

次のコマンドで実行しました。以降の検証は、ことわりがなければこのオプションです。

claude -p "test_calc.py のテストが失敗しています。原因を直して、python3 -m unittest で全部通ることを確かめてください。" \
  --setting-sources project --strict-mcp-config --model haiku \
  --output-format stream-json --verbose --no-session-persistence \
  --permission-mode dontAsk --tools "Bash,Read,Edit" \
  --allowedTools "Bash(python3 *)" "Read" "Edit" \
  --max-budget-usd 1 < /dev/null > run.jsonl
echo "exit=$?"

主なオプションの意味です。

  • --setting-sources project: 個人の設定(~/.claude/settings.json)と .claude/settings.local.json を読まない。自分で置く設定ファイルのうち、読まれるのはプロジェクトの .claude/settings.json だけになる。あとで作る Stop hook はここに置く
  • --strict-mcp-config: --mcp-config で渡した MCP サーバー(外部ツールの接続)だけを使う。今回は渡していないので、MCP は使わない
  • --output-format stream-json --verbose: 実行の途中経過を、1行に1つの JSON で出す。run.jsonl に保存して、あとで読み取る
  • --tools: Claude が使えるツールを決める。ここにないツールは呼べない
  • --allowedTools: 使えるツールのうち、確認なしで実行してよいものを決める。Bash(python3 *) は python3 で始まるコマンドだけを許可する
  • --permission-mode dontAsk: 確認が必要な操作を、確認せずに拒否する。人がいない自動実行で使う。ただし、作業ディレクトリ内のファイルの読み取りと、ls のような読み取り専用のコマンドは実行される
  • --no-session-persistence: セッションを保存しない。あとから再開しない使い捨ての実行で使う
  • < /dev/null: 標準入力を待たないようにする

実行の結果は、run.jsonl の最後の type が result の行にまとまっています。この記事では JSON を扱うコマンド jq で値を取り出します。主に出てくる項目は次の3つです。

項目意味
subtypeループがどう終わったかの種類。success はテストの合格を意味しない。実行の上限に達すると error_max_turns や error_max_budget_usd になる
resultClaude の最後の回答
num_turns名前はターン数だが、今回の実行では応答の回数と一致しなかった(応答4回で5〜7)。計算方法は公式に書かれていない

モデルは主に Haiku を使い、挙動が変わりそうな検証は Sonnet でも確かめました。39回で推定費用は合計約 0.3 ドルです。金額はすべて Claude Code が報告する推定値で、実際の請求額とは異なる場合があります。

--max-turns は何を数えるのか#

--max-turns は、claude -p の1回の実行で、Claude が応答してよい回数の上限です。何かを繰り返させるオプションではありません。 Claude が図1の②で応答するたびに1と数え、上限の回数に達すると exit 1 で止まります。

ツールを呼んだ応答、ツールを呼ばない最後の回答、やり直しのあとの応答のどれも数えます。指定しなければ上限はないので、終わらない実行を防ぐために付けます。

たとえば --max-turns 30 なら、「応答は30回まで。それでも終わらなければ止める」という意味です。

応答の回数を測る#

先ほどのバグ修正で、応答の回数を測りました。5回実行して、すべて4回でした。

応答呼んだツール
1Bash
2Read × 2
3Edit、Bash
4なし(最後の回答)

冒頭の手順は5つですが、3回目の応答で Edit と Bash を一度に呼んだので、作業の数と応答の数は一致しません。

--max-turns を変えて実行した結果です。

--max-turns応答の回数exitテスト
221失敗
331成功
440成功

表のとおり、応答の回数は --max-turns の値で止まりました。--max-turns 3 の行に注目してください。3回目の応答で修正は終わり、テストも通っています。それでも最後の回答を返す前に上限に達したので、exit 1 です。

いくつにすればよいか#

公式の考え方では、必要なのはツールを呼ぶ応答の数に、最後の回答の1を足した数です。今回の修正なら4で、これより少ないと error_max_turns で止まります。Stop hook や /goal を使うなら、やり直しの分も数えるので増やしてください(この点は公式の式にはなく、今回の実測からの補足です)。今回の検証で応答が最も多かった実行は22回でした。後のスクリプトでは、公式の例と同じ 30 にしています。

応答の回数を知りたいときは、次のコマンドでメッセージ ID を数えます。

jq -s '[.[] | select(.type == "assistant" and .message.model != "<synthetic>") | .message.id] | unique | length' run.jsonl

<synthetic> は、/goal を使うと入る Goal set: という内部のメッセージを除くための指定です。

--max-budget-usd は余裕を持たせる#

--max-budget-usd は、claude -p の1回の実行で、Claude Code が推定した費用の上限です。達すると exit 1 で止まります。指定しなければ上限はありません。

読むファイルの大きさで、費用は大きく変わります。バグ修正は推定 0.002 ドルでしたが、1,500行、約 132KB のログを読ませると、応答2回で 0.015 ドルと約7倍になりました。今回の検証で最も高かった実行は 0.073 ドルです。後のスクリプトでは、これより十分大きい 2 ドルにしています。

上限は少し超えることがあります。0.001 ドルに設定したときは、0.00126 ドルで止まりました。応答が返ってから確認し、上限に達した応答も最後まで処理するからです。

Stop hook はどう動くか#

Stop hook は図1の④で動きます。タスクの完了時だけでなく、Claude が応答を終えるたびに、登録したスクリプトを実行します。スクリプトが exit 0 で次の JSON を標準出力に出すと、Claude は終わらずに作業を続けます。reason の文章が次の指示になります。

{"decision": "block", "reason": "やり直す理由"}

テストが落ちている間は終わらせない hook を作りました。.claude/settings.json に登録します。

{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"python3 .claude/stop.py"}]}]}}

.claude/stop.py の中身です。テストを実行し、失敗していれば上の JSON を出します。hook.log は、呼ばれた回数を数えるために書き出しています。追記されるので、試し直すときは消してください。

import json,sys,subprocess
d=json.load(sys.stdin); r=subprocess.run(['python3','-m','unittest'],capture_output=True,text=True)
open('hook.log','a').write(json.dumps({'active':d.get('stop_hook_active'),'rc':r.returncode})+'\n')
if r.returncode: print(json.dumps({"decision":"block","reason":"python3 -m unittest が失敗しています:\n"+r.stderr[-400:]}))

ここで「add だけ直してください。ほかの関数は今は触らなくて構いません」と依頼します。指示を守る限りテストは通らないので、hook とぶつかります。

Claude は指示を守ってほかの関数を直さず、やり直しが続きました。最後は次の警告で打ち切られました。

A hook blocked the turn from ending 9 consecutive times — overriding and ending turn.

問題は結果です。subtype、num_turns、result の文字数を取り出します。

$ jq -r 'select(.type == "result") | [.subtype, .num_turns, (.result | length)] | @tsv' run.jsonl
success	23	0

exit 0、subtype は success で、回答は空でした。 このときテストは2件失敗したままです。Sonnet でも同じで、もう一度実行しても同じでした。

公式ドキュメントには、Claude Code は成功で 0、実行が失敗すると 0 以外を返すとあります。ただ、自動化する側から見れば、完了していないのに成功に見えます。

打ち切りでは、終わらない実行を防げない#

同じ検証で、hook は18回呼ばれ、応答は22回でした。9回で終わらなかったのは、途中で Claude が Bash を呼んだからです。公式の説明どおり、やり直しの連続回数はツールを呼ぶと0に戻ります。

今回は Claude が途中でツールを呼ばなくなって終わりました。ただ、公式の説明どおりなら、ツールを呼び続ける限り打ち切りは起きません。終わらない実行を止めるのは --max-turns の役割です。

stop_hook_active で繰り返しを減らす#

hook に渡される stop_hook_active は、やり直しの途中で呼ばれたときに true になる値です。公式の hooks ガイドは、これが true なら hook が何も出力せずに終了し、Claude を終わらせる書き方を紹介しています。上のスクリプトなら、hook.log に書き込む行の次に、この1行を足します。

if d.get('stop_hook_active'): sys.exit(0)

有無を比べました。全部のバグを直す依頼に、指示を守る限り通らない次のテストを、作業ディレクトリの test_never.py として足しています。環境変数を変えず、test_never.py も編集しないよう指示しました。比べた hook は、どちらも reason に「全部通るまで続けてください」を加えたものです。

import os, unittest
class T(unittest.TestCase):
    def test_env(self):
        self.assertEqual(os.environ.get("LAB_TOKEN"), "not-set-anywhere")
stop_hook_active の確認hook の呼び出し応答最後の回答exitテスト
なし10回15回空0失敗
あり2回6回あり0失敗

ありのほうが無駄な繰り返しは減り、Claude は通らない理由を説明して終わりました。ただし、hook がやり直させるのは最初の1回だけです。繰り返しを減らしたいなら入れる、何度もやり直させたいなら入れない、と使い分けます。どちらでも exit 0 なので、完了の判定には使えません。

試したあとは、test_never.py と足した1行を消してください。残っていると、このあとの手順の結果が変わります。

/goal はどう動くか#

/goal も図1の④で動きますが、判定を自分で書く必要はありません。Claude がツールを呼ばずに応答を終えるたびに、小さなモデルが条件と会話を読み、「未達成」「達成」「不可能」のどれかを返します。以下ではこのモデルを「判定役」と呼びます。未達成ならやり直しになり、達成か不可能なら /goal は解除されます。

公式の説明では、内部でそのセッションだけ有効な Stop hook を使っています。条件がそのまま指示になるので、依頼文は別に要りません。Stop hook が .claude/settings.json に残っていると一緒に動くので、試すときは消してください。

claude -p "/goal python3 -m unittest が exit 0 で終わること。テストファイルは変更しない。" \
  --setting-sources project --strict-mcp-config --model haiku \
  --output-format stream-json --verbose --no-session-persistence \
  --permission-mode dontAsk --tools "Bash,Read,Edit" \
  --allowedTools "Bash" "Read" "Edit" \
  --max-budget-usd 1 < /dev/null > run.jsonl

Bash 全体を許可したこの形なら、Haiku も Sonnet も応答4回で終わり、テストも通りました。Sonnet の実行では、判定に使われたとみられる Haiku の費用が 0.0006 ドルで、本体の 0.0255 ドルの約2.5%でした。公式も、判定の費用は通常、本体に比べてごくわずかとしています。

条件には確認方法を書く#

公式の説明によると、判定役はコマンドを実行せず、会話に出た内容だけで判断します。だから、条件には「python3 -m unittest が exit 0」のように、会話に結果が出る確認方法を書きます。 検証でも、判定の理由は次のように残りました。

Stop hook feedback:
[python3 -m unittest が exit 0 で終わること。テストファイルは変更しない。]: insufficient evidence in transcript: ...

許可が狭いと、直っていても未達成で終わることがある#

一番気をつけたいのは、次の検証です。許可を Bash(python3 *) に絞って同じ /goal を実行しました。

  1. Claude が最初に cd "$(pwd)" && python3 -m unittest; echo "EXIT=$?"; ... というコマンドを実行し、拒否される
  2. そのあと ls は成功するが、Claude はシェルが使えないと判断して Read と Edit で修正する
  3. python3 -m unittest を単独で試さないまま、判定役に証拠がないと言われ続ける
  4. やり直しが続いて打ち切られ、exit 0、回答は空で終わる

あとで確認すると、テストは通っていました。 コードは直っているのに未達成のまま打ち切られ、それでも exit 0 です。

どんな形が拒否されるのか、同じ許可で切り分けました。dontAsk モードで1回ずつの結果です。

コマンド結果
python3 -c "print(1)"実行
cd . && python3 -c "print(1)"実行
ls && python3 -c "print(1)"実行
python3 -c "print(1)" | tail -1実行
python3 -c "print(1)"; echo done実行
cd "$(pwd)" && python3 -c "print(1)"拒否
python3 -c "print(1)"; echo "exit=$?"拒否
python3 -c "print(1)" && curl --version拒否

公式の説明どおり、複数をつないだコマンドは、各部分が許可済みか読み取り専用なら通ります。curl は許可していないので拒否されました。$(pwd) と $? を含む形も拒否されました。

対策として、検証用の使い捨て環境では Bash 全体を許可します。この形では、Haiku も Sonnet も応答4回で終わり、テストも通りました。

拒否された回数は、次のコマンドで確認できます。

jq -r 'select(.type == "result") | .permission_denials | length' run.jsonl

止まり方の一覧#

今回観測した止まり方をまとめます。

止まり方exitsubtype最後の回答テスト
通常の完了0successあり成功
stop_hook_active で hook が通した0successあり失敗
Stop hook の打ち切り0success空失敗
/goal の打ち切り0success空成功も失敗もあった
/goal が打ち切りなしで終了0successあり成功も失敗もあった
--max-turns に到達1error_max_turnsなし値が2では失敗、3では成功
--max-budget-usd に到達1error_max_budget_usdなし失敗

「空」は result が空文字で、「なし」は result の項目自体がないことです。

0 以外で終わるのは、自分で決めた実行の上限の2つだけでした。

「/goal が打ち切りなしで終了」のうちテストが失敗したのは、Bash を渡さない --tools "Read,Edit" の /goal を Sonnet で実行したときです。Haiku では打ち切られました。各1回なので、モデルの差とは言えません。

やり直しても --max-turns の回数は0に戻らない#

Stop hook や /goal がやり直しをさせると、--max-turns の回数が0に戻るのではと考えて確かめました。/goal は Bash を渡さない --tools "Read,Edit" の形で、Stop hook は先ほどの add だけ直す依頼で、それぞれ --max-turns 5 を付けました。

どちらも、やり直しを挟みながら応答5回で止まりました。error_max_turns、exit 1 です(各1回)。

公式の説明では、/goal の条件に or stop after 20 turns のように書くこともできます。ただ、これは判定役が会話から判断するものです。自動実行では --max-turns も付けてください。

CI で誤判定しない確認スクリプト#

ここまでの結果から、完了の判定は Claude の外で行います。次のスクリプトを check-claude.sh として保存します。

#!/bin/bash
# claude -p を実行し、終了コードとは別に、自分の検査で完了を判定する
set -u
command -v jq > /dev/null || { echo "NG: jq がありません"; exit 1; }
log=claude-run.jsonl
claude -p "$1" --output-format stream-json --verbose --max-turns 30 --max-budget-usd 2 "${@:2}" < /dev/null > "$log"
code=$?

jq -s -r 'map(select(.type == "result")) | last | "subtype=\(.subtype) num_turns=\(.num_turns) denials=\(.permission_denials | length)"' "$log"

if [ "$code" -ne 0 ]; then
  echo "NG: claude が異常終了 (exit=$code)"; exit 1
fi
if jq -s -e 'any(.[]; .type == "system" and .key == "stop-hook-block-cap")' "$log" > /dev/null; then
  echo "NG: やり直しが続いて打ち切られた"; exit 1
fi
if ! git diff --quiet HEAD -- test_calc.py; then
  echo "NG: テストファイルが変更された"; exit 1
fi
if ! python3 -m unittest > /dev/null 2>&1; then
  echo "NG: テストが落ちている"; exit 1
fi
echo "OK"

上から順に、実行の上限、打ち切り、テストファイルの書き換え、テストの結果を確かめています。外で測るテストを Claude が書き換えると判定の意味がなくなるので、git diff で確かめます。stop-hook-block-cap は、打ち切りのときにログに出る key という項目の値で、/goal の打ち切りでも同じ値が出ました。上限の 30 と 2 は、先ほどの値の決め方によるものなので、作業の規模に合わせて変えてください。test_calc.py とテストのコマンドも、自分のプロジェクトに合わせて変えてください。

Git で管理したプロジェクトのルートに置き、chmod +x check-claude.sh で実行権限を付けます。ログは claude-run.jsonl に残るので、NG のときはここで原因を確かめます。CI ではこのファイルを成果物として保存してください。CI で動かすには、claude のインストールと認証(API キーなど)、jq も別に用意します。

第1引数が依頼文で、第2引数以降はそのまま claude に渡します。--setting-sources project を付けているので、.claude/settings.json の hook も読み込まれます。

./check-claude.sh "test_calc.py のテストが失敗しています。calc.py を直してください。テストファイルは変更しないでください。" \
  --setting-sources project --strict-mcp-config --model haiku \
  --no-session-persistence --permission-mode dontAsk \
  --tools "Bash,Read,Edit" --allowedTools "Bash" "Read" "Edit"

この例は Bash 全体を許可しています。シークレットを持つ CI ではそのまま使わず、使い捨ての環境で動かしてください。許可を絞るときの注意は「許可が狭いと、直っていても未達成で終わることがある」のとおりです。

Haiku で動かした結果です(39回とは別に、記事の仕上げで再実行しました)。上は全部直す依頼、下は add だけ直す依頼で、下には先ほどの Stop hook を入れています。

subtype=success num_turns=5 denials=0
OK

subtype=success num_turns=18 denials=0
NG: やり直しが続いて打ち切られた

下の例は、subtype が success でも NG と判定できています。

打ち切られた実行は、テストが通っていても NG にしています。Claude が完了を確認できなかった実行なので、人が見るためです。stop-hook-block-cap は公式のドキュメントに載っていない値です。頼るべきなのは最後のテストの実行で、この確認は原因を早く知るための補助です。

どれを使うか#

  • 人が見ている対話セッション: /goal が手軽です。公式の説明では、ツールを呼ばないやり取りが続くと警告が出て操作が戻ります。目標は設定されたまま残ります
  • CI や夜間のバッチ: --max-turns と --max-budget-usd を必ず付け、完了は上のスクリプトのように Claude の外で確かめます
  • Claude が終わる前に必ず通したい検査: Stop hook が向いています。ただし、打ち切られることがあり、通らなくても exit コードには出ないので、外側の検査は別に必要です。.claude/settings.json をコミットするとチーム全員の対話セッションでも動くので、応答のたびに検査が走ってよいかも確かめてください
  • 終わりの条件を測れない作業: 公式は、テスト結果のように測れる終わりの状態を条件にするよう勧めています。「きれいにする」のような依頼は、測れる条件に言い換えてから使います

まとめ#

claude -p の exit 0 は、実行が失敗しなかったことしか表しません。打ち切りでも exit 0 で終わるので、自動化では --max-turns と --max-budget-usd で実行の上限を決め、完了はテストなど外部の検査で判定してください。