日本語UIの設定を終えたのは、6月の初めのことでした。言語パックを入れ、AI 応答言語も日本語に指定して、画面はすっかり日本語になりました。しばらくは満足していました。
気づいたのは、その2週間後です。無人で回しているエージェントの成果を確認しようと git log --oneline -30 を叩いたとき、画面に流れてきたのは英語と日本語がまだらに並んだコミットログでした。Add: 記事更新パイプラインの冪等化 の3行下に fix: resolve race condition in the scheduler。同じエージェントが、同じ設定で、同じ週に書いたものです。
設定は何も間違っていませんでした。UI は日本語です。応答も日本語で返ってきます。それでも、リポジトリに残るものだけが揺れていました。
UI の言語と、成果物の言語は別々に決まっています
この現象を理解するのに、しばらくかかりました。整理してみると、言語を決めている場所は3つあり、それぞれ独立しています。
| 層 | 何が決めるか | 読む相手 | 無人運用で直る? |
| UI 言語 | 言語パック・エディタ設定 | 自分(画面の前にいる人) | —(設定した時点で固定) |
| 応答言語 | AI 応答言語設定・その場のプロンプト | 自分(会話の中) | その場で気づいて直せる |
| 成果物の言語 | モデルがその瞬間に置かれた文脈 | 将来の自分・共同作業者・利用者 | 誰も見ていないので直らない |
1層目と2層目は設定で決まります。既に整理された手順があり、私もAntigravity 2.0 を日本語UI で快適に使うための設定に沿って進めました。会話の中で言語が混ざったときも、その場で「日本語でお願いします」と一言添えれば戻ります。
問題は3層目です。ここは設定項目ではありません。エージェントがコミットメッセージを書く瞬間、その入力にあるのは差分と直前の会話だけです。差分の中身が英語のコード(function、return、変数名)で埋まっていれば、モデルは英語の文脈にいると判断します。日本語のドキュメントを直した差分なら、日本語で書きます。つまり成果物の言語は、設定ではなく差分の中身に引きずられます。
人が画面の前にいれば、この揺れは目に入った瞬間に直せます。無人運用ではそうはいきません。誰も読まないまま次のランが走り、混在したまま積み上がっていきます。私が2週間気づかなかったのは、まさにそのためでした。
6週間、何がどれくらい混ざっていたか
気づいた時点から、直す前にまず測ることにしました。無人ランが触れた成果物を種類別に走査し、日本語(CJK 文字)を含むものと含まないものを数えています。対象は Antigravity のスケジュール実行が6週間で残した成果物です。
| 成果物の種類 | 総数 | 日本語 | 英語 | 1つの中で混在 | 混在・非統一率 |
| コミットメッセージ | 312 件 | 198 | 114 | 7 | 36.5% |
| コードコメント(新規追加行) | 1,046 行 | 402 | 644 | — | 38.4% |
| 生成ドキュメント(.md) | 44 件 | 39 | 2 | 3 | 11.4% |
| ログ出力の文字列 | 188 行 | 51 | 137 | — | 27.1% |
数字を見て、いくつか腑に落ちたことがありました。
生成ドキュメントの混在率が低いのは、記事本文という「日本語で書くことが差分から自明な文脈」だからです。逆にコードコメントが最も揺れているのは、周囲の識別子がすべて英語だからでしょう。混在率は、その成果物の周囲にどれだけ英語のトークンが並んでいるかにほぼ比例していました。
もう一つ、「1つの中で混在」の7件が厄介でした。Fix: スケジューラの競合状態を解消 and update tests のように、1行の中で言語が切り替わっているものです。これは種類別の集計では見つけにくく、後述するゲートで初めて拾えました。
そして、この6週間で成果物の言語について私が手で直した回数は 0回 です。気づいていなかったのだから当然ですが、無人運用の性質を端的に示している数字だと思っています。
「出力言語契約」を、読み手ごとに決める
直そうと決めたとき、最初に浮かんだのは「全部日本語に揃える」でした。UI を日本語にしたのだから、と。
しかし少し考えて、やめました。私が運営しているサイトは日英2言語で公開しています。英語版の記事に添えるコードコメントまで日本語にしてしまうと、英語圏の読者がコードをそのまま持ち帰ったとき、コメントだけ読めないものが残ります。それは親切ではありません。
そこで、言語は「誰が読むか」で決めるという原則に立ち返りました。書き出したものが、次の表です。
| 成果物 | 読み手 | 決めた言語 | 理由 |
| コミットメッセージ(prefix) | ツール・grep・自分 | 英語固定(Add: Fix: Update:) | 機械が読む部分。揺れると検索できません |
| コミットメッセージ(本文) | 将来の自分 | 日本語 | 意図を最も正確に書ける言語で |
| コードコメント(公開コード) | 記事の読者(日英両方) | 英語 | 英語版記事に載っても読めること |
| コードコメント(非公開の運用スクリプト) | 自分だけ | 日本語 | 読み手が自分しかいないなら迷う必要はありません |
| ログ出力の文字列 | 自分・監視ツール | 英語 | grep・集計・将来の外部ツール連携を想定 |
| 生成ドキュメント(.md) | そのファイルの配置先による | 配置ディレクトリに従う | ja/ 配下は日本語、en/ 配下は英語 |
この表を作ってみて、自分でも意外だったのは、「全部日本語」でも「全部英語」でもない結論になったことです。日本語化とは画面を日本語にすることだと思い込んでいましたが、実際に必要だったのは「どこを日本語にして、どこを英語のままにするか」を自分で決めることでした。UI の言語設定は、その決定の一部でしかありません。
私はこの表を「出力言語契約」と呼んでいます。契約と呼ぶのは、エージェントに守らせる約束であると同時に、破られたときに機械で検出できる形で書いておく、という意味を込めているからです。
AGENTS.md に契約を落とし込む
契約は、エージェントが読む場所に置かなければ意味がありません。Antigravity では AGENTS.md がその場所になります。
私は最初、こう書きました。
## 出力言語
- コミットメッセージは日本語で書いてください
- コードコメントは英語で書いてください
これは、あまり効きませんでした。6週間の続きとして2週間動かした結果、コードコメントの英語率は 61.6% から 78.4% にしか上がりませんでした。改善はしていますが、5回に1回は破られている計算です。
効いたのは、判断の余地を消した書き方でした。
## 出力言語の契約(例外なく適用)
言語は「そのテキストを誰が読むか」で決まります。UI 設定や会話の言語とは無関係です。
### コミットメッセージ
- prefix は英語固定: `Add:` `Fix:` `Update:` `Refactor:` `Remove:` のいずれか
- prefix 以降の本文は日本語
- 1つのメッセージ内で日本語と英語を混ぜない(prefix を除く)
- 例(従う): `Fix: スケジューラの競合状態を解消`
- 例(従わない): `Fix: resolve race condition in the scheduler`
- 例(従わない): `Fix: スケジューラの競合状態を解消 and update tests`
### コードコメント
- `src/` `content/` 配下 = 公開されるコード → 英語のみ
- `scripts/` `_ops/` 配下 = 自分専用の運用スクリプト → 日本語のみ
- 迷ったら英語(公開される可能性を優先)
### ログ出力の文字列
- `print` `console.log` `logger.*` の第一引数は英語のみ
- 変数展開の中身(ファイル名・記事タイトル等)はこの限りではありません
書き換えて分かったのは、次の3点です。
- 「〜してください」より「〜のいずれか」の方が守られます。 選択肢を列挙すると、モデルは列挙の中から選ぶ挙動になります。自由記述の余地を残すと、そのときの文脈に引きずられます。
- 従わない例を書くと、効き方が変わります。 従う例だけを書いた版と比べて、混在の残存が目に見えて減りました。特に「1行内で混ざる」型は、従わない例を書くまで一度も減りませんでした。
- パスで条件分岐させると、判断が安定します。 「公開されるコードは英語」は人間には自明でも、モデルにとっては毎回の判断です。
src/ 配下、と書けば判断が不要になります。
なお AGENTS.md を入れ子にしている場合は、優先順位の解決が別の論点になります。私は入れ子になった AGENTS.md の優先順位で整理した方針に従い、出力言語契約はリポジトリ直下の1枚だけに置いて、子側では上書きしないことにしました。言語のような横断的な規約は、分散させると必ず食い違います。
規約は破られる前提で、機械的に検出する
AGENTS.md を整えても、5回に1回は破られていました。これは規約の書き方の問題というより、自然言語の指示は確率的にしか守られないという性質だと受け止めています。だから、規約とは別に検出の層を置きました。
lang_gate.py は、ステージされた差分とコミットメッセージを走査して、契約違反を報告します。全体で 120 行ほどの小さなものです。
#!/usr/bin/env python3
"""出力言語契約のゲート。ステージ済み差分とコミットメッセージを検査する。"""
import re
import subprocess
import sys
from pathlib import Path
# CJK 統合漢字・ひらがな・カタカナ。半角記号や全角英数は含めない
CJK = re.compile(r"[-ゟ゠-ヿ一-鿿]")
COMMIT_PREFIX = re.compile(r"^(Add|Fix|Update|Refactor|Remove|Docs|Test):\s")
# 英語コメント中に現れても違反としない固有名詞・技術語
ALLOWLIST = {"Antigravity", "Gemini", "Dolice", "Cloudflare", "Stripe"}
# パス → 期待する言語
RULES = [
(re.compile(r"^(src|content)/"), "en"),
(re.compile(r"^(scripts|_ops)/"), "ja"),
]
COMMENT_PATTERNS = [
re.compile(r"^\+\s*//\s?(.*)$"), # ts/js/go
re.compile(r"^\+\s*#\s?(.*)$"), # python/bash
re.compile(r"^\+\s*\*\s?(.*)$"), # jsdoc 継続行
]
def has_cjk(text: str) -> bool:
return bool(CJK.search(text))
def strip_allowlist(text: str) -> str:
for word in ALLOWLIST:
text = text.replace(word, "")
return text
def expected_lang(path: str) -> str | None:
for pattern, lang in RULES:
if pattern.match(path):
return lang
return None
def check_commit_message(msg: str) -> list[str]:
"""prefix は英語固定、本文は日本語、1行内の混在は不可。"""
violations = []
first = msg.strip().splitlines()[0] if msg.strip() else ""
if not COMMIT_PREFIX.match(first):
violations.append(f"commit: prefix が契約外です -> {first[:60]!r}")
return violations
body = COMMIT_PREFIX.sub("", first)
if not has_cjk(body):
violations.append(f"commit: 本文が日本語ではありません -> {body[:60]!r}")
else:
# 1行内の混在: CJK を含みつつ、3文字以上の英単語が2つ以上並ぶ
words = re.findall(r"[A-Za-z]{3,}", strip_allowlist(body))
if len(words) >= 2:
violations.append(f"commit: 1行内で言語が混在しています -> {body[:60]!r}")
return violations
def iter_added_comments(path: str) -> list[str]:
"""そのファイルの差分から、追加されたコメント行の中身だけを取り出す。"""
diff = subprocess.run(
["git", "diff", "--cached", "-U0", "--", path],
capture_output=True, text=True, check=True,
).stdout
out = []
in_code_block = False
for line in diff.splitlines():
# .md の差分ではコードブロック内を対象外にする
if path.endswith(".md") and line.lstrip("+-").strip().startswith("```"):
in_code_block = not in_code_block
continue
if in_code_block or not line.startswith("+"):
continue
for pattern in COMMENT_PATTERNS:
m = pattern.match(line)
if m and m.group(1).strip():
out.append(m.group(1).strip())
break
return out
def main() -> int:
violations: list[str] = []
msg_path = Path(sys.argv[1]) if len(sys.argv) > 1 else None
if msg_path and msg_path.exists():
violations += check_commit_message(msg_path.read_text(encoding="utf-8"))
staged = subprocess.run(
["git", "diff", "--cached", "--name-only", "-z"],
capture_output=True, text=True, check=True,
).stdout
# -z を付けないと、日本語ファイル名が八進エスケープされて検査対象を取り違えます
for path in filter(None, staged.split("\0")):
want = expected_lang(path)
if want is None:
continue
for comment in iter_added_comments(path):
cjk = has_cjk(strip_allowlist(comment))
if want == "en" and cjk:
violations.append(f"{path}: 公開コードのコメントに日本語 -> {comment[:50]!r}")
elif want == "ja" and not cjk and len(comment) > 12:
violations.append(f"{path}: 運用スクリプトのコメントに英語 -> {comment[:50]!r}")
if violations:
print("🛑 出力言語契約の違反:")
for v in violations:
print(f" - {v}")
return 1
print("✅ 出力言語契約 OK")
return 0
if __name__ == "__main__":
sys.exit(main())
実装していて、いくつか判断を要した箇所があります。
CJK の範囲を狭く取ったこと。 最初は全角記号(、『』`)も CJK 扱いにしていましたが、英語コメントの中に全角括弧が紛れ込んだだけで違反になり、うるさすぎました。ひらがな・カタカナ・漢字だけに絞ると、誤検知が目に見えて減りました。
git diff --name-only に -z を付けたこと。 これを付けないと、日本語を含むファイル名が八進エスケープされて返り、検査対象を取りこぼします。以前ファイル名の正規化で似た罠を踏んでいたので(日本語ファイル名が macOS と Linux で別物になるとき)、今回は最初から -z を付けました。過去の失敗が効いた数少ない場面です。
日本語期待側の判定に文字数の下限を置いたこと。 scripts/ 配下で # TODO や # noqa のような短い英語コメントまで違反にすると、実務が回りません。12 文字を超えるものだけを対象にしています。この閾値には理論的根拠はなく、手元の差分を見ながら決めた値です。
コミットフックと CI、どちらに置くか
置き場所は迷いました。結論から言えば、両方に置いて、役割を分けています。
| commit-msg フック | CI(push 後) |
| 止められるもの | 手元のコミット | すべてのコミット |
| 無人ランで効くか | 効きます(同じマシンで走るため) | 効きますが、混入後になります |
| 誤検知したとき | ランが止まります(痛い) | 通知が飛ぶだけ(痛くない) |
| 私の設定 | コミットメッセージのみ検査 | コメント・ログも含めて全部 |
分けた理由は、誤検知のコストが非対称だからです。コミットメッセージの検査はルールが単純で、6週間で誤検知はゼロでした。だからフックで止めても安全です。一方コメントの判定は、コード中の文字列やドキュメント断片を拾ってしまうことがあり、ここで無人ランを止めると、直す人がいない時間帯に処理が丸ごと落ちます。
これは前に一度やってしまった失敗でもあります。厳しいゲートを無人ランの経路に置いた結果、深夜に誤検知で止まり、朝まで何も進んでいなかったことがありました。無人運用のゲートは、止める権限の重さを誤検知率と釣り合わせるというのが、そのとき学んだことです。
フックの設定は3行で済みます。
# .git/hooks/commit-msg (実行権限を付けること: chmod +x)
#!/bin/sh
python3 scripts/lang_gate.py "$1" || exit 1
うまくいかなかったこと
正直に書いておきたい取りこぼしが3つあります。
1つ目は、既存のコードコメントには一切触れていないことです。 ゲートが見るのは差分の追加行だけなので、6週間で積み上がった 644 行の英語コメントのうち、scripts/ 配下にあるものは今も英語のまま残っています。一括変換も考えましたが、機械置換で文意が崩れた過去があり、手を出していません。触った箇所から少しずつ、というのが現状の方針です。
2つ目は、ログ出力の検査を諦めたことです。 logger.info(f"記事 {slug} を公開しました") のような f-string を正しく解析するには、正規表現では足りず AST を読む必要があります。作りかけて、費用対効果が合わないと判断して止めました。混在率 27.1% は、いまも 27.1% のままです。
3つ目は、モデル側の揺れを消しきれていないことです。 契約と例を整えた後でも、コメントの英語率は 94.2% で頭打ちになりました。残りの 5.8% はゲートが拾って落としています。つまり 規約だけでは 94%、ゲートを足して 100% という構図で、規約が完璧になる日は来ないという前提でゲートを維持しています。
6週間後の数字
契約とゲートを入れてから、次の6週間の実測です。
| 指標 | 導入前 | 導入後 |
| コミットメッセージの契約違反率 | 36.5% | 0%(フックで到達不能) |
| コードコメントの契約適合率 | 61.6% | 100%(規約 94.2% + ゲート 5.8%) |
| ゲートの誤検知 | — | コミット 0 件 / コメント 4 件(走査 1,203 回中 0.33%) |
| 1回あたりの走査時間 | — | 約 90〜140 ms(差分 30 ファイル程度) |
| ログ出力の混在率 | 27.1% | 27.1%(未対応) |
数字よりも大きかったのは、認識が変わったことだと感じています。日本語化とは、設定画面で言語を選ぶことだと思っていました。実際にやってみると、どこを日本語にして、どこを英語のままにするかを自分で決めて、それを機械が読める形で書き残す作業でした。UI の言語設定はその入り口にすぎず、無人でエージェントを走らせるほど、決めていない部分が黙って揺れていきます。
もし今、日本語UIの設定を終えたところだという方がいらっしゃったら、git log --oneline -30 を一度だけ叩いてみることをお勧めします。設定画面には現れない層が、そこに見えるはずです。私はそこから始めました。
出力言語の契約は、私自身まだ手探りで運用しています。ログ出力の 27.1% にどう向き合うかも決めきれていません。個人開発の小さなリポジトリでの話ですが、同じところで立ち止まっている方の手がかりになれば幸いです。