Claude Opus 5.5 の返事が英語になりがちだったのでClaude Code のフック機能でこの問題を解決してみた

Claude Opus 5.5 の返事が英語になりがちだったのでClaude Code のフック機能でこの問題を解決してみた

Claude Code で英語の返事が止まらない問題に直面して、CLAUDE.md への記述では対応できないことに気付きました。そこで、フックの仕組みを使って「お願い」ではなく「仕組み」で英語を止める方法を試してみたので、その手法と作ったプラグインを紹介します。
2026.10.20

リテールアプリ共創部のるおんです。

最近、Claude Code を Opus 5.5 で使っていると、 返事がいつのまにか英語になる ことが増えました。ツールの出力や英語のドキュメント、サブエージェントの報告を読んだ直後に、それにつられて英語で返してくるようです。

CLAUDE.md やメモリーに「返事は必ず日本語で」と書いても、しばらくするとまた英語に戻ります。何度も「日本語で」と言い直すのが地味にストレスだったので、 お願いではなく仕組みで防ぐ ことにしました。

この記事では、Claude Code の フック(hooks) の仕組みと使い方を公式ドキュメントで整理したうえで、フックで英語の返事を止めて日本語で書き直させる方法を紹介します。作ったフックは、誰でも入れられるようにプラグインとして公開しました。

先に結論

  • CLAUDE.md やメモリーに書く方法は、モデルへの お願い なので毎回は守られません。フックは決まったタイミングで 必ず 動くので、「英語なら止める」を確実に挟めます
  • 英語の返事は、次の3段で防げました
段 フックのイベント すること
① 予防 UserPromptSubmit 毎回のプロンプトに「返事は日本語で」の一言を足す
② 最後の返事 Stop 最後の返事が英語なら、終わらせずに日本語で書き直させる
③ 途中の一言 MessageDisplay + PreToolUse ツールを呼ぶ前の一言が英語なら、次のツールの実行を止めて言い直させる
  • 作ったフックはプラグインにしたので、次の2行で入ります
claude plugin marketplace add takagakiryuheiCM/claude-code-japanese-reply
claude plugin install japanese-reply@japanese-reply-hooks

困っていたこと

例えば、こんなやり取りです(実際によくあったやり取りを、題材だけ一般的なものに置き換えています)。

よくあったやり取り
自分 : テストが落ちているので、原因を調べて直してください

Claude: I checked the failing test. The root cause is that formatDate() returns
        the date in UTC, while the test expects JST. I updated the function to
        use the Asia/Tokyo timezone, and all tests now pass.

自分 : 日本語で返信して

Claude: すみません、英語で返信してしまいました。日本語で返信します。
        落ちていたテストを確認しました。原因は、formatDate() が日付を UTC で
        返しているのに、テストは日本時間(JST)を期待していたことです。
        関数を Asia/Tokyo のタイムゾーンで返すように直し、テストはすべて
        通るようになりました。

ついさっきまで日本語で会話していたのに、テストの実行結果(英語)を読んだ直後から、急に英語で返してきます。中身は合っているので作業は進むのですが、英語を読み解く手間がかかりますし、「日本語で」と言い直すたびに1往復増えます。これが1日に何度も起きていました。

英語になりやすいのは、だいたい次のような場面でした。

  • コマンドの実行結果やエラーメッセージ(英語)を読んだ直後の説明
  • 英語の公式ドキュメントを調べた直後のまとめ
  • ツールを呼ぶ前にはさむ「これから〇〇を確認します」のような一言

最初は CLAUDE.md とメモリーに「返事は必ず日本語で」と書いていました。書いた直後は守られるのですが、長い作業の途中で英語の出力が続くと、また英語に戻ってしまいます。CLAUDE.md もメモリーもモデルに渡す 文脈 なので、それより強い文脈(大量の英語の出力)があると負けてしまうのだと思います。

そこで、モデルの判断に任せず、決まったタイミングで必ず確かめる フック を使うことにしました。

Claude Code のフックとは

公式ドキュメントでは、フックは「Claude Code のライフサイクルの決まったタイミングで自動的に実行される、ユーザー定義のシェルコマンド・HTTP エンドポイント・MCP ツール・LLM のプロンプト・サブエージェント」と説明されています。

https://code.claude.com/docs/en/hooks

ガイドのページでは、フックの価値を「 deterministic control 」(決定的な制御)と表現しています。LLM が実行するかどうかを選ぶのではなく、決めた処理が必ず動く、ということです。CLAUDE.md が「お願い」なら、フックは「仕組み」です。

https://code.claude.com/docs/en/hooks-guide

いつ動くか(主なイベント)

フックは、どのタイミングで動くか(イベント)を指定して設定します。今回使ったものを中心に並べると、次のとおりです。

頻度 イベント いつ動くか
セッションごと SessionStart / SessionEnd セッションの開始・終了
ターンごと UserPromptSubmit ユーザーがプロンプトを送ったとき(Claude が処理する前)
ターンごと Stop Claude が返事を終えようとしたとき
ツールごと PreToolUse / PostToolUse ツールを呼ぶ直前・直後
表示ごと MessageDisplay Claude の文が画面に表示されるとき

どこに書くか

フックは設定ファイルの hooks に書きます。置き場所で効く範囲が変わります。

ファイル 効く範囲
~/.claude/settings.json 自分の全プロジェクト
.claude/settings.json そのプロジェクト(リポジトリにコミットしてチームで共有できる)
.claude/settings.local.json そのプロジェクトの自分だけ
プラグインの hooks/hooks.json そのプラグインを有効にしている間ずっと

書き方は「イベント → matcher(どのツールに効かせるか)→ 実行する処理」の3段の入れ子です。

~/.claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "python3 ~/.claude/hooks/check.py" }
        ]
      }
    ]
  }
}

何を受け取り、どう返すか

command のフックは、 標準入力で JSON を受け取ります 。全イベントに共通で session_id・transcript_path・cwd・hook_event_name などが入り、イベントごとに固有の項目が足されます。今回使った項目は次のとおりです。

イベント 使った項目
Stop last_assistant_message(そのターンで Claude が最後に書いた文)、stop_hook_active(すでに Stop フックで続けさせている番か)
MessageDisplay message_id・delta(表示された文の差分)・final(その文の最後か)
PreToolUse tool_name・tool_input

返し方は、 終了コード と 標準出力の JSON の2通りです。

返し方 意味
終了コード 0 成功。標準出力の JSON があれば読まれる(UserPromptSubmit では、標準出力の文字がそのまま文脈に足される)
終了コード 2 ブロック。標準エラーの文が Claude に渡される(PreToolUse ならそのツールの実行が止まる)
それ以外 ブロックしないエラー
{"decision": "block", "reason": "..."} Stop で使うと、Claude は返事を終えずに reason を読んで作業を続ける

Stop で止め続けると無限に回ってしまうので、入力の stop_hook_active が true のとき(すでに一度止めて続けさせている番)は止めない、というのが定石です。

やってみた

1. プロンプトのたびに一言足す(UserPromptSubmit)

まずは予防です。UserPromptSubmit で標準出力に書いた文字は、そのまま Claude の文脈に足されます。毎回のプロンプトに「返事は日本語で」を添えるだけなので、 echo 1行で書けます。

~/.claude/settings.json(抜粋)
"UserPromptSubmit": [
  { "hooks": [{ "type": "command", "command": "echo '(返事は日本語で書いてください)'" }] }
]

CLAUDE.md より新しい位置に毎回入るので効きは良くなりますが、これもお願いには変わりないので、これだけでは英語になることがありました。

2. 最後の返事を確かめる(Stop)

本命は Stop です。Claude が返事を終えようとしたときに、最後の返事が英語なら {"decision": "block"} を返して、日本語で書き直させます。

英語かどうかは、文字数の比率で見ています。

  • コードの塊・インラインコード・URL・ファイルのパスは数えない(日本語の返事でも英字が多くなるため)
  • 英字が120字以上あり、かな・漢字の割合が25%未満なら英語とみなす
  • 短い返事(「OK」など)は見ない

最後の返事の本文は、入力の last_assistant_message から取ります。最初は transcript_path(会話の記録ファイル)を読んでいましたが、公式ドキュメントに 記録ファイルは書き込みが遅れることがあるので、そのターンの最後の文が要るなら last_assistant_message を使う という趣旨の注意があり、こちらに切り替えました。

3. 途中の一言を確かめる(MessageDisplay + PreToolUse)

Stop だけでは、 ツールを呼ぶ前にはさむ一言 が英語になっても素通りします。Stop が見るのは最後の返事だけだからです。

途中の一言を受け取れるのは、表示のたびに動く MessageDisplay です。ただし MessageDisplay は表示用のフックで、表示する文を差し替えることはできても、止めることはできません。そこで、次のように役割を分けました。

最後のツールのあとに出た英語は PreToolUse が来ないので、 Stop でも同じ印を拾います。

最初は MessageDisplay で印を残し、次の PreToolUse で拾うだけの素直な形にしましたが、英語の一言が素通りしたり、止まるのがツール1回ぶん遅れたりしました。日本語の前置きと英語の文が1つの段落に混ざると全体の比率では日本語扱いになること、 MessageDisplay の判定が終わる前に次の PreToolUse が動くことがあるのが原因だったので、段落ごとに判定し、判定中の印(.busy ファイル)があれば最大0.5秒だけ待つように直しました。最終的には、次のスクリプトになっています。

japanese_reply.py(4つのイベントを1本で受けるスクリプト)
scripts/japanese_reply.py
#!/usr/bin/env python3
"""Claude Code の返事を日本語に保つフック(1 本のスクリプトで 4 つのイベントを受ける)"""
import json
import os
import re
import sys
import tempfile
import time

def env_num(name, default, cast):
    try:
        return cast(os.environ.get(name, default))
    except ValueError:
        return default

MIN_LATIN = env_num("JAPANESE_REPLY_MIN_LATIN", 120, int)
MIN_LATIN_MID = env_num("JAPANESE_REPLY_MIN_LATIN_MID", 60, int)
MAX_JA_RATIO = env_num("JAPANESE_REPLY_MAX_JA_RATIO", 0.25, float)

REMINDER = "(返事は日本語で書いてください。ツールの出力やドキュメントが英語でも、説明やツールを呼ぶ前の一言も日本語で)"

JA_RE = re.compile(r"[\u3040-\u30ff\u3400-\u9fff\uff66-\uff9f]")  # ひらがな・カタカナ・漢字・半角カナ
LATIN_RE = re.compile(r"[A-Za-z]")

def strip_noise(text):
    text = re.sub(r"```.*?```", " ", text, flags=re.S)
    text = re.sub(r"`[^`]*`", " ", text)
    text = re.sub(r"https?://\S+", " ", text)
    text = re.sub(r"[~/.\w-]*/[\w./-]+", " ", text)
    return text

def is_english(text, min_latin):
    for para in re.split(r"\n\s*\n", strip_noise(text or "")):
        para = "\n".join(l for l in para.splitlines() if not l.lstrip().startswith(">"))
        ja = len(JA_RE.findall(para))
        latin = len(LATIN_RE.findall(para))
        if latin >= min_latin and ja / (ja + latin) < MAX_JA_RATIO:
            return True
    return False

def state_dir(session_id):
    base = os.environ.get("CLAUDE_PLUGIN_DATA") or os.path.join(tempfile.gettempdir(), "claude-japanese-reply")
    d = os.path.join(base, "sessions", re.sub(r"[^\w-]", "_", session_id or "unknown"))
    os.makedirs(d, exist_ok=True)
    return d

def on_message_display(data):
    # delta を message_id ごとに貯め、final のときにまとめて判定する
    d = state_dir(data.get("session_id"))
    mid = re.sub(r"[^\w-]", "_", str(data.get("message_id") or "msg"))
    buf = os.path.join(d, f"{mid}.txt")
    busy = os.path.join(d, f"{mid}.busy")
    open(busy, "w").close()
    try:
        with open(buf, "a", encoding="utf-8") as f:
            f.write(data.get("delta") or "")
        if data.get("final"):
            with open(buf, encoding="utf-8") as f:
                text = f.read()
            os.remove(buf)
            if is_english(text, MIN_LATIN_MID):
                with open(os.path.join(d, "english.flag"), "w", encoding="utf-8") as f:
                    f.write(text[:300])
    finally:
        os.remove(busy)
    return 0

def take_flag(d, wait_s=0.5):
    # MessageDisplay の判定が終わる前に PreToolUse が来ることがあるので、少しだけ待つ(古い .busy は無視)
    deadline = time.time() + wait_s
    while time.time() < deadline:
        busy = [n for n in os.listdir(d) if n.endswith(".busy")
                and time.time() - os.path.getmtime(os.path.join(d, n)) < 5]
        if not busy:
            break
        time.sleep(0.05)
    flag = os.path.join(d, "english.flag")
    if not os.path.exists(flag):
        return None
    with open(flag, encoding="utf-8") as f:
        text = f.read()
    os.remove(flag)
    return text.strip()[:120]

def on_pre_tool_use(data):
    text = take_flag(state_dir(data.get("session_id")))
    if text is None:
        return 0
    sys.stderr.write(
        f"直前に画面へ出した一言が英語でした(「{text}」)。"
        "同じ内容を日本語で一言書き直してから、いま止めた操作をもう一度呼んでください。\n"
    )
    return 2

def on_stop(data):
    if data.get("stop_hook_active"):
        return 0
    # 最後の返事も MessageDisplay を通って印を残すので、印は先に取って消しておく
    text = take_flag(state_dir(data.get("session_id")))
    if is_english(data.get("last_assistant_message"), MIN_LATIN):
        reason = "最後の返事が英語になっています。同じ内容を日本語で書き直して返してください。"
    elif text is not None:
        reason = f"途中で画面に出した一言が英語でした(「{text}」)。その内容も含めて、日本語で書き直して返してください。"
    else:
        return 0
    print(json.dumps({"decision": "block", "reason": reason}, ensure_ascii=False))
    return 0

def main():
    try:
        data = json.load(sys.stdin)
    except ValueError:
        return 0
    event = data.get("hook_event_name")
    if event == "UserPromptSubmit":
        print(REMINDER)
        return 0
    if event == "MessageDisplay":
        return on_message_display(data)
    if event == "PreToolUse":
        return on_pre_tool_use(data)
    if event == "Stop":
        return on_stop(data)
    return 0

if __name__ == "__main__":
    sys.exit(main())

設定ファイルに書くなら、4つのイベントから同じスクリプトを呼びます(プラグインで入れる場合は、この設定は不要です)。

~/.claude/settings.json(抜粋)
"hooks": {
  "UserPromptSubmit": [
    { "hooks": [{ "type": "command", "command": "python3 ~/.claude/hooks/japanese_reply.py" }] }
  ],
  "MessageDisplay": [
    { "hooks": [{ "type": "command", "command": "python3 ~/.claude/hooks/japanese_reply.py" }] }
  ],
  "PreToolUse": [
    { "matcher": ".*", "hooks": [{ "type": "command", "command": "python3 ~/.claude/hooks/japanese_reply.py" }] }
  ],
  "Stop": [
    { "hooks": [{ "type": "command", "command": "python3 ~/.claude/hooks/japanese_reply.py" }] }
  ]
}

Claude Code の起動中に設定ファイルを直接書き換えても、通常は変更が自動で読み込まれます。設定されているフックは /hooks で一覧できます。

動作確認

公開したプラグインを --plugin-dir で読み込み、実際の Claude Code で確かめました。手元の設定(~/.claude/settings.json)のフックと二重に動かないように、 --setting-sources project でユーザーの設定を外しています。わざと「英語で答えて」と頼み、フックの動きを --include-hook-events で出力させました(出力を読みやすく整えています)。

「英語だけで答えて」と頼んだとき
HOOK: UserPromptSubmit (返事は日本語で書いてください。…)
TEXT: A hook in Claude Code is a user-configured shell command that automatically executes ...
HOOK: Stop {"decision": "block", "reason": "最後の返事が英語になっています。同じ内容を日本語で書き直して返してください。"}
TEXT: Claude Codeにおけるフックとは、セッション中の特定のイベントに応じて自動的に実行される、ユーザーが設定したシェルコマンドのことです。…
HOOK: Stop (止めない)
「ツールを呼ぶ前に英語で一言書いて」と頼んだとき
TEXT: I will now run the macOS `date` command via Bash to display the current system date and time, ...
TOOL_USE: Bash {"command": "date"}
HOOK: PreToolUse 直前に画面へ出した一言が英語でした(「I will now run ...」)。同じ内容を日本語で一言書き直してから… exit=2
TEXT: これからBashツールでmacOSの`date`コマンドを実行し、現在のシステム日時を表示して、その結果を簡潔に報告します。
TOOL_USE: Bash {"command": "date"}
HOOK: PreToolUse (止めない) exit=0
TOOL_RESULT: Tue Oct 20 08:01:38 JST 2026
TEXT: 結果: 2026年10月20日(火) 08:01:38 JST

英語の一言のあとのツールは止められ、日本語で言い直してから同じツールを呼び直しています。

ちなみに、この記事を書いている最中にも、私(Claude Code)が英語で一言書いた直後のコマンドが止められました。英語になる問題は、思った以上に普段から起きていたようです。

注意点・Tips

  • 英語が画面に一度出ることは防げません 。MessageDisplay は表示用のフックで、表示を止めることはできないためです。出たあとに止めて書き直させる仕組みなので、英語と日本語の両方が画面に残ります
  • 判定はシンプルな比率です 。英語の引用やログを貼るだけの返事が英語と判定されないように、コードの塊・インラインコード・URL・パス・> で始まる引用は数えないようにしています。英語の文をそのまま見せたいときは、コードの塊か引用で囲むと止められません
  • 英語で答えてほしいときも止まります 。英訳を頼むときなどは、一時的にフックを外してください
  • stop_hook_active を必ず見る 。見ないと、書き直しの返事にも Stop フックが動いて、止め続けてしまう可能性があります
  • ~/.claude/settings.json に書くと全プロジェクトに効きます 。チームで揃えたいなら、プロジェクトの .claude/settings.json に書くか、プラグインで配るのが良いと思います
  • 最後の返事は last_assistant_message から取る 。会話の記録ファイルは書き込みが遅れることがあります
  • MessageDisplay は、手元では途中から動いていないように見える場面がありました。原因は確かめられていません。その場合でも、最後の返事は Stop で確かめられます

プラグインで配布する

フックは設定ファイルに直接書いても使えますが、ほかの人にも使ってもらえるように、 プラグイン にして GitHub で公開しました。

https://github.com/takagakiryuheiCM/claude-code-japanese-reply

プラグインにするとき、スキルで配るか迷いましたが、スキルに書いたフックが動くのは、そのスキルが呼ばれたあとのセッションの間だけです。今回は最初から常に動いてほしいので、有効にしている間ずっとフックが動くプラグインにしました。

入れ方

claude plugin marketplace add takagakiryuheiCM/claude-code-japanese-reply
claude plugin install japanese-reply@japanese-reply-hooks

Claude Code のセッションの中からなら、 /plugin marketplace add takagakiryuheiCM/claude-code-japanese-reply と /plugin install japanese-reply@japanese-reply-hooks でも入ります。外すときは claude plugin uninstall japanese-reply@japanese-reply-hooks です。

python3 が必要です(標準ライブラリだけを使っています)。

プラグインの中身

プラグインの中身は、上のスクリプトと、それを4つのイベントにつなぐ hooks/hooks.json だけです。

claude-code-japanese-reply/
├── .claude-plugin/
│   └── marketplace.json        # マーケットプレイスの定義
└── plugins/japanese-reply/
    ├── .claude-plugin/
    │   └── plugin.json         # プラグインの定義
    ├── hooks/
    │   └── hooks.json          # フックの設定
    └── scripts/
        └── japanese_reply.py   # 上のスクリプト

hooks/hooks.json は設定ファイルの hooks と同じ形で書けます。プラグインの置き場所は ${CLAUDE_PLUGIN_ROOT} で参照します。

plugins/japanese-reply/hooks/hooks.json(抜粋)
{
  "hooks": {
    "Stop": [
      { "hooks": [{ "type": "command", "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/japanese_reply.py\"" }] }
    ]
  }
}

しきい値は環境変数(JAPANESE_REPLY_MIN_LATIN・JAPANESE_REPLY_MIN_LATIN_MID・JAPANESE_REPLY_MAX_JA_RATIO)で変えられます。

おわりに

CLAUDE.md やメモリーに書くことは、あくまでモデルへのお願いです。何度言っても直らない振る舞いは、フックで 必ず通る関所 を作ってしまうのが確実だと感じました。

今回は「日本語で返す」でしたが、「コミット前に必ずテストを通す」「特定のファイルを触らせない」など、 毎回必ず守ってほしいこと はフックと相性が良いと思います。CLAUDE.md に同じお願いを何度も書き足している方は、フックにできないか考えてみてください。

以上、どなたかの参考になれば幸いです。

参考

https://code.claude.com/docs/en/hooks

https://code.claude.com/docs/en/hooks-guide

https://code.claude.com/docs/en/plugins

https://code.claude.com/docs/en/plugins-reference

https://code.claude.com/docs/en/plugin-marketplaces


Claudeならクラスメソッドにお任せください

クラスメソッドは、Anthropic社とリセラー契約を締結しています。各種製品ガイドから、業種別の活用法、フェーズごとのお悩み解決などサービス支援ページにまとめております。まずはご覧いただき、お気軽にご相談ください。

サービス詳細を見る

この記事をシェアする

AI白書

関連記事