Setting your editor's display language does nothing for the language your agents write into the repository. Here is what six weeks of unattended runs actually produced, how to pin output language per audience, and a gate that catches the drift mechanically.
I finished the Japanese UI setup in early June. Language pack installed, AI response language set, the whole interface translated. I was pleased with myself for about two weeks.
Then I ran git log --oneline -30 to review what my unattended agents had been up to, and the screen filled with a striped mess of two languages. A Japanese commit message, then three lines down, fix: resolve race condition in the scheduler. Same agent. Same settings. Same week.
Nothing was misconfigured. The UI was Japanese. Responses came back in Japanese. Only the things left behind in the repository were drifting.
The UI language and the artifact language are decided in different places
It took me a while to see why. There are three layers, and they are independent of one another.
Layer
What decides it
Who reads it
Self-correcting?
UI language
Language pack, editor settings
You, at the screen
Fixed once configured
Response language
Response language setting, the prompt at hand
You, in the conversation
Yes — you notice and correct it
Artifact language
Whatever context the model is in at that instant
Future you, collaborators, users
No — nobody is watching
The first two are settings. There is an established procedure for them, and I followed the one in setting up Antigravity 2.0 with a Japanese UI. When the conversation itself drifts, a single correction in the chat pulls it back.
The third layer is not a setting at all. When an agent writes a commit message, its input is the diff and the recent conversation — nothing else. If the diff is full of English tokens (function, return, variable names), the model concludes it is operating in an English context. If the diff touched Japanese prose, it writes Japanese. The artifact language follows the diff, not your configuration.
With a human at the keyboard, that drift gets fixed the moment it appears on screen. Unattended, it doesn't. The next run starts, nobody reads the output, and the mixture accumulates. That is exactly why I went two weeks without noticing.
Six weeks of measurements, before changing anything
Once I noticed, I resisted the urge to fix it and measured first. I scanned everything the scheduled runs had left behind over six weeks, bucketed by artifact type, and counted what contained Japanese characters and what didn't.
Artifact type
Total
Japanese
English
Mixed within one
Non-conforming
Commit messages
312
198
114
7
36.5%
Code comments (added lines)
1,046
402
644
—
38.4%
Generated docs (.md)
44
39
2
3
11.4%
Log output strings
188
51
137
—
27.1%
A few things clicked into place.
Generated docs drift least because "this is Japanese prose" is obvious from the diff itself. Code comments drift most because every identifier around them is English. The mixing rate tracked almost linearly with how many English tokens surrounded the artifact.
The seven "mixed within one" cases were the nastiest. Things like Fix: スケジューラの競合状態を解消 and update tests — the language switches mid-line. Bucket counts can't see those. Only the gate below caught them.
And the number of times I manually corrected an artifact's language during those six weeks: zero. Obvious, since I didn't know it was happening — but it captures the nature of unattended work rather precisely.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦UI language, response language, and artifact language are three independent layers. In unattended runs, only the third one goes unread and unfixed
✦How to pin commit messages, code comments, log strings, and generated docs by audience rather than by preference, and how to encode that in AGENTS.md
✦A 120-line lang_gate.py that catches violations mechanically, plus six weeks of measurements: violation rates, false-positive rate, and three things it still misses
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
My first instinct was to force everything to Japanese. The UI is Japanese, so why not.
I talked myself out of it. As an indie developer at Dolice I publish in both Japanese and English, and if code comments in an English article are written in Japanese, an English-speaking reader who copies that snippet gets a block they can't read. That isn't hospitable.
So I went back to a simpler principle: language is decided by who reads the text. Writing that out produced this.
Artifact
Audience
Language
Reason
Commit message (prefix)
Tools, grep, me
English, fixed set (Add:Fix:Update:)
Machines read this. Drift breaks search
Commit message (body)
Future me
Japanese
Intent belongs in the language I write it most precisely in
Code comments (published code)
Readers of both language editions
English
Must stay readable when it lands in an English article
Code comments (private ops scripts)
Only me
Japanese
When you're the only reader, there is nothing to debate
Log output strings
Me, monitoring tools
English
grep, aggregation, future tooling
Generated docs (.md)
Depends where the file lives
Follows the directory
ja/ is Japanese, en/ is English
What surprised me was that the answer came out neither "all Japanese" nor "all English." I had assumed localizing meant translating the interface. What it actually required was deciding which parts stay in which language, and then writing that decision down somewhere a machine can read it. The UI setting is a small piece of that.
I call this table an output language contract. "Contract" because it is both a promise the agent is asked to keep and something written in a shape that lets me detect a broken promise.
Encoding the contract in AGENTS.md
A contract nowhere near the agent is decoration. In Antigravity, AGENTS.md is where it goes.
My first attempt:
## Output language- Write commit messages in Japanese- Write code comments in English
This barely worked. Two more weeks of runs moved the English rate for comments from 61.6% to 78.4%. Better, but one in five still broke it.
What worked was removing the room to decide.
## Output language contract (applies without exception)Language is decided by who reads the text. It is unrelated to the UI settingor the language of the conversation.### Commit messages- Prefix is English, from this fixed set: `Add:` `Fix:` `Update:` `Refactor:` `Remove:`- Everything after the prefix is Japanese- Never mix languages within one message (the prefix aside)- Follow this: `Fix: スケジューラの競合状態を解消`- Not this: `Fix: resolve race condition in the scheduler`- Not this: `Fix: スケジューラの競合状態を解消 and update tests`### Code comments- Under `src/` and `content/` = published code -> English only- Under `scripts/` and `_ops/` = private ops scripts -> Japanese only- When unsure, choose English (assume it will be published)### Log output strings- The first argument to `print`, `console.log`, `logger.*` is English only- Interpolated values (filenames, article titles) are exempt
Three things came out of that rewrite.
"One of the following" beats "please do X." Enumerate the options and the model picks from the enumeration. Leave free-form space and it follows whatever context it happens to be in.
Counter-examples change the behavior. Compared to a version with only positive examples, residual mixing dropped visibly. The mid-line mixing case in particular never improved until I wrote a counter-example for it.
Branching on paths stabilizes the judgment. "Published code is English" is obvious to a human but is a fresh decision for the model on every run. Write under src/ and there is no decision left to make.
If you nest AGENTS.md files, precedence becomes its own problem. Following the approach I worked out in precedence for nested AGENTS.md files, I keep the language contract in the single root file and never override it in children. Cross-cutting rules like language will always diverge if you distribute them.
Assume the rules get broken, and detect it mechanically
Even with the tightened contract, one in twenty comments still broke it. I've come to read that less as a writing failure than as a property: natural-language instructions are only ever probabilistically obeyed. So the detection layer sits separately from the rules.
lang_gate.py scans the staged diff and the commit message and reports violations. It's about 120 lines.
#!/usr/bin/env python3"""Gate for the output language contract: checks staged diffs and commit messages."""import reimport subprocessimport sysfrom pathlib import Path# CJK ideographs, hiragana, katakana. Deliberately excludes full-width punctuationCJK = re.compile(r"[-ゟ゠-ヿ一-鿿]")COMMIT_PREFIX = re.compile(r"^(Add|Fix|Update|Refactor|Remove|Docs|Test):\s")# Proper nouns that may appear inside English comments without counting as violationsALLOWLIST = {"Antigravity", "Gemini", "Dolice", "Cloudflare", "Stripe"}# path -> expected languageRULES = [ (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 continuation]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 textdef expected_lang(path: str) -> str | None: for pattern, lang in RULES: if pattern.match(path): return lang return Nonedef check_commit_message(msg: str) -> list[str]: """English prefix, Japanese body, no mixing within a line.""" violations = [] first = msg.strip().splitlines()[0] if msg.strip() else "" if not COMMIT_PREFIX.match(first): violations.append(f"commit: prefix outside the contract -> {first[:60]!r}") return violations body = COMMIT_PREFIX.sub("", first) if not has_cjk(body): violations.append(f"commit: body is not Japanese -> {body[:60]!r}") else: # Mixed within a line: contains CJK and two or more English words of 3+ chars words = re.findall(r"[A-Za-z]{3,}", strip_allowlist(body)) if len(words) >= 2: violations.append(f"commit: languages mixed within one line -> {body[:60]!r}") return violationsdef iter_added_comments(path: str) -> list[str]: """Pull just the added comment bodies out of this file's diff.""" 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(): # In .md diffs, skip fenced code blocks 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 outdef 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 # Without -z, non-ASCII filenames come back octal-escaped and you scan the wrong files 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}: Japanese in a published-code comment -> {comment[:50]!r}") elif want == "ja" and not cjk and len(comment) > 12: violations.append(f"{path}: English in an ops-script comment -> {comment[:50]!r}") if violations: print("Output language contract violations:") for v in violations: print(f" - {v}") return 1 print("Output language contract OK") return 0if __name__ == "__main__": sys.exit(main())
A few judgment calls are worth explaining.
The CJK range is deliberately narrow. My first version counted full-width punctuation as CJK, which meant a single full-width bracket inside an English comment tripped the gate. Far too noisy. Restricting it to hiragana, katakana, and ideographs cut false positives sharply.
--name-only gets -z. Without it, filenames containing non-ASCII come back octal-escaped and the scan silently skips them. I'd been bitten by a close cousin of this before, in Unicode normalization drift in filenames, so this time the flag went in from the start. One of the rare occasions a past mistake paid off.
The Japanese-expected side has a length floor. Flagging # TODO and # noqa under scripts/ as violations makes the gate unusable. Only comments longer than 12 characters are checked. That threshold has no theory behind it; I picked it while staring at real diffs.
Hook or CI?
I went back and forth on placement. The answer turned out to be both, with split responsibilities.
commit-msg hook
CI (post-push)
Catches
Local commits
Every commit
Works unattended?
Yes, same machine as the run
Yes, but after the fact
Cost of a false positive
The run dies (expensive)
A notification (cheap)
What I check there
Commit messages only
Comments and logs too
The split exists because false-positive cost is asymmetric. Commit-message checking is simple enough that six weeks produced zero false positives, so blocking on it is safe. Comment checking occasionally grabs a string literal or a doc fragment, and killing an unattended run on that means the job dies at an hour when nobody is around to restart it.
I've made that mistake before: a strict gate on the unattended path fired a false positive at 2am and nothing had moved by morning. In unattended systems, the authority to block should be proportional to how rarely the check is wrong. That's what I took away.
Existing comments are untouched. The gate only sees added lines, so of the 644 English comments accumulated over six weeks, the ones under scripts/ are still English. I considered a bulk rewrite and backed off — mechanical replacement has mangled meaning for me before. The current policy is to fix them as I happen to touch them.
I gave up on log strings. Parsing logger.info(f"published {slug}") correctly needs an AST walk, not a regex. I started, decided the effort wasn't worth it, and stopped. That 27.1% is still 27.1%.
The model still drifts. Even with the contract and counter-examples, the English rate for comments plateaued at 94.2%. The gate catches the remaining 5.8%. So: rules get you to 94, the gate covers the rest. I keep the gate because the rules are never going to reach 100.
Six weeks later
Metric
Before
After
Commit message violation rate
36.5%
0% (unreachable via the hook)
Code comment conformance
61.6%
100% (94.2% rules + 5.8% gate)
Gate false positives
—
0 commits / 4 comments (0.33% of 1,203 scans)
Scan time per run
—
~90–140 ms for a ~30-file diff
Log string mixing
27.1%
27.1% (unaddressed)
The shift in how I think about it mattered more than the numbers. I'd assumed localization meant picking a language in a settings pane. What it actually meant was deciding which parts stay in which language, and recording that decision somewhere a machine can enforce it. The UI setting is the doorway. The more work you hand to unattended agents, the more the parts you never decided will quietly drift.
If you've just finished localizing your editor, run git log --oneline -30 once. There's a layer there that the settings pane never shows you. That's where I started.
I'm still working this out — I haven't decided what to do about that 27.1% in the logs. This is one small repository run by one person, but if you're stuck at the same place, I hope some of it is useful.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.