From 625a96f0bbf070cbef2b71a4317b7002b910eb4b Mon Sep 17 00:00:00 2001 From: FullGas1 <51051389+FullGas1@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:29:05 +0200 Subject: [PATCH 1/3] chore(backlog): open TOOLING-I18N-CLAUDE-CODE-TRANSLATE lot + ADR 0014 Grilled with docs, 2026-08-10. Formalizes the roadmap idea surfaced during FIX-I18N-DEBT-REPAYMENT: translate_i18n.py only auto-translates when ANTHROPIC_API_KEY is set, leaving a Claude Code subscriber without a separate API key with no auto-translation at all. Adds a Claude Code CLI (claude -p) fallback backend, dual mode with the existing API path unchanged and first-priority. No CI change needed - i18n-guard inspects dictionary content only. --- .backlog/README.md | 1 + .../TOOLING-I18N-CLAUDE-CODE-TRANSLATE/PRD.md | 124 ++++++++++++++++++ .../tickets/01-claude-code-cli-fallback.md | 62 +++++++++ dev/adr/0014-i18n-claude-code-cli-fallback.md | 61 +++++++++ dev/adr/README.md | 1 + dev/roadmap.md | 26 ++-- 6 files changed, 260 insertions(+), 15 deletions(-) create mode 100644 .backlog/TOOLING-I18N-CLAUDE-CODE-TRANSLATE/PRD.md create mode 100644 .backlog/TOOLING-I18N-CLAUDE-CODE-TRANSLATE/tickets/01-claude-code-cli-fallback.md create mode 100644 dev/adr/0014-i18n-claude-code-cli-fallback.md diff --git a/.backlog/README.md b/.backlog/README.md index dfbd31a..71b6a47 100644 --- a/.backlog/README.md +++ b/.backlog/README.md @@ -17,6 +17,7 @@ authored **per lot, when the lot is started** (not in batch). | `CHORE-UNTRACK-BUILT-ENGINE` | merged (PR #110) | `CTLD.lua` is generated and committed anyway. `.gitignore` line 5 calls it deliberate — *"available at repo root for DCS missions"* — a bootstrap-era reason that no longer holds: nothing points at it, and **VMCT, the assumed consumer, does not** (its `vendored.yaml` pins `2.0.0-rc3` with *"re-download the CTLD.lua asset from the matching release"* and watches `github-release`). The cost is paid every time: **26 of the 28 merges touching `src/` over 30 days carried the regenerated file** — a one-megabyte generated diff nobody reviews, and a guaranteed conflict between parallel PRs. The trap, measured rather than assumed: `python-quality` runs on ubuntu and never builds the engine, so deleting the file alone would drop the suite from **262 passed** to **234 passed / 27 skipped / 1 failed** while CI stayed green. So: build the engine in that job first (`merge_CTLD.ps1` made portable — two `\` paths), fix `test_inject_into_miz` (it crashes instead of skipping), then untrack. Depends on `FEAT-DEV-BUILD-CHANNEL`, which is what keeps the engine downloadable. No history rewriting (471 blobs = 2.8 MiB packed). | `chore/untrack-built-engine` | | `FEAT-CUSTOM-BEACON-SOUNDS` | merged (PR #112) | A beacon sound the Mission Maker chooses, instead of a text box naming a file the tool never installs. Grilled with Zip on 2026-08-08: custom is **derived** from `radioSound` (no second key that could disagree with the engine); a chosen file enters the mission under a **reserved name** (**ADR 0012**) because a Mission Maker whose own file is called `beacon.ogg` would otherwise see it silently overwritten; the original name survives as a schema-only label (`FIX-TOOL-I18N-LANG`'s lesson — a catalogue key would make every pre-lot configuration report a missing setting at mission start); the bytes are read at selection and live in the session, so reopening a `.miz` reinstalls them **on another machine with the original file gone**. `OggS` checked, no size cap, nothing deleted from the archive. | `feature/custom-beacon-sounds` | | `FEAT-DEV-BUILD-CHANNEL` | merged (PR #109) | An exe to hand a tester between two releases. Zip's first idea — the exe grafting an arbitrary `CTLD.lua` into a copy of itself — **works** (verified: rc6 + 1.17 MB appended still runs) and was dropped anyway: it pairs a new engine with the exe's older schema and interface, an unsigned exe altered after the build reads as tampered, and `--version` would keep lying. The `build-exe` job already produces a complete exe from a commit in **2 min 06 s** on free public-repo runners; it only lacked a trigger. Built on every merge into `develop`, published as an artifact **and** a floating `dev` pre-release (an artifact answers `401` to an anonymous download), versioned `-`. | `feature/dev-build-channel` | +| [`TOOLING-I18N-CLAUDE-CODE-TRANSLATE`](TOOLING-I18N-CLAUDE-CODE-TRANSLATE/PRD.md) | ready | `tools/build/translate_i18n.py` only auto-translates i18n stubs when `ANTHROPIC_API_KEY` is set (separate Anthropic Console billing) — a contributor with a Claude Code subscription but no such key gets nothing, lived concretely on `FIX-I18N-DEBT-REPAYMENT` (140 entries translated by hand for want of a key). Adds a fallback: when the API key is absent, shell out to the Claude Code CLI (`claude -p`, `--model claude-haiku-4-5-20251001` pinned) instead — dual mode, API path unchanged/first-priority, no pre-flight availability check (same non-blocking `try/except` as today), combined warning when neither is available. CI (`i18n-guard`) untouched — inspects dictionary content only, never the translation mechanism. See **ADR 0014**. | `tooling/i18n-claude-code-translate` | | [`FIX-I18N-DEBT-REPAYMENT`](FIX-I18N-DEBT-REPAYMENT/PRD.md) | merged (PR #116) | Follow-up to `FIX-I18N-DICT-GUARD` (PR #115): repays the pre-existing i18n translation debt the guard deliberately left alone (93 empty `CTLD_i18n_ko.lua` entries + 78 empty `CTLD_i18n_es.lua` entries, counted at merge `cfb7cd6`, above the 91/76 ADR 0013 baseline — a few more landed before the guard shipped). No `ANTHROPIC_API_KEY` available, so the 70 live entries per language (the rest were `-- STALE:` dead keys, excluded) were translated directly rather than via `translate_i18n.py`; `JTAC` and `%1 [%2] %3.` added to `__keep_en`. No code changes — translation content only, no new tooling. | `fix/i18n-debt-repayment` | | [`FIX-I18N-DICT-GUARD`](FIX-I18N-DICT-GUARD/PRD.md) | merged (PR #115) | Reported by **FullGas**: some F10 menu entries stay untranslated in Korean. Root cause: `translate_i18n.py`'s stub detection never matches the `""` empty-stub convention `generate_i18n_dicts.ps1 -Apply` actually writes, so freshly-added keys are never picked up for translation even with `ANTHROPIC_API_KEY` set — and the only existing guard (`.githooks/pre-push`, opt-in, `MISSING`-only) doesn't check translation content. New CI job `i18n-guard`, diff-scoped like `changelog-guard`: blocks unconditionally on `MISSING`, blocks by default (bypassable via `skip-i18n` label) on a newly-introduced empty non-EN entry. Fixes `translate_i18n.py`'s stub detection in the same lot. Pre-existing debt (91 KO + 76 ES empty entries) explicitly out of scope — a follow-up repayment lot must follow immediately. See **ADR 0013**. | `fix/i18n-dict-guard` | | [`FIX-FIELD-EXTRACT-CASUALTIES`](FIX-FIELD-EXTRACT-CASUALTIES/PRD.md) | merged (PR #111) | Field extraction (`embarkFromField`) returns the troop count frozen at deploy time instead of the survivor count — an undeclared legacy-parity deviation. Fix counts live DCS units (excluding `SVNT_*` servants) at extraction time; adds troop counts to the "Extract from field" menu labels; auto-despawns an orphaned mortar servant when its operator dies leaving zero real troops. Also fixed a pre-existing bug where `onUnitDead` never fired in-game. | `fix/field-extract-casualties` | diff --git a/.backlog/TOOLING-I18N-CLAUDE-CODE-TRANSLATE/PRD.md b/.backlog/TOOLING-I18N-CLAUDE-CODE-TRANSLATE/PRD.md new file mode 100644 index 0000000..04eebbb --- /dev/null +++ b/.backlog/TOOLING-I18N-CLAUDE-CODE-TRANSLATE/PRD.md @@ -0,0 +1,124 @@ +Status: ready + +# TOOLING-I18N-CLAUDE-CODE-TRANSLATE — Claude Code CLI as a local i18n auto-translate fallback + +Grilled with docs on 2026-08-10 (**ADR 0014**). Idea originated in `dev/roadmap.md` during +`FIX-I18N-DEBT-REPAYMENT`. + +## Problem Statement + +A CTLD contributor running `merge_CTLD.ps1` locally gets i18n stubs auto-translated only when +`ANTHROPIC_API_KEY` is set in their environment — a separate Anthropic Console key with its own +pay-as-you-go billing. A contributor who has a Claude Code subscription but never set up that +separate key gets no auto-translation at all: `translate_i18n.py` warns and exits, and every stub +stays empty until someone with a key runs the build. This happened concretely during +`FIX-I18N-DEBT-REPAYMENT` (2026-08-10): no `ANTHROPIC_API_KEY` was available, so 140 dictionary +entries were translated by hand instead of through the tool built for exactly that job. + +## Solution + +`tools/build/translate_i18n.py` gains a fallback translation backend: when `ANTHROPIC_API_KEY` is +absent, it shells out to the Claude Code CLI's non-interactive mode (`claude -p`) instead, which +authenticates via the Code subscription rather than a separate API key. The existing +`ANTHROPIC_API_KEY`-backed path is unchanged and stays first-priority — this adds a second way to +get auto-translation, it does not replace the first. `merge_CTLD.ps1`'s existing unconditional call +into `translate_i18n.py` needs no change: the script decides its own backend internally. + +## User Stories + +1. As a CTLD contributor with a Claude Code subscription but no `ANTHROPIC_API_KEY`, I want + `merge_CTLD.ps1` to auto-translate new i18n stubs anyway, so I don't have to acquire a separate + API key just to keep the KO/ES/FR dictionaries in sync with my `ctld.tr()` changes. +2. As a CTLD contributor who already has `ANTHROPIC_API_KEY` set, I want my existing workflow + completely unchanged, so this lot introduces no regression or new failure mode for me. +3. As a contributor with neither an API key nor an authenticated Claude Code CLI, I want a single + clear warning naming both ways to enable auto-translation, so I know what to do next instead of + reading the script to discover there even is a second option. +4. As a maintainer, I want the CLI fallback to use the same cheap/fast model + (`claude-haiku-4-5-20251001`) the API path already uses, so a bulk menu-string translation + doesn't silently consume more of a contributor's Claude Code usage than a coding-oriented + default model would. +5. As a maintainer, I want no pre-flight check for CLI/session availability, so the failure path + stays a single, already-proven non-blocking `try/except`, not a second bespoke availability + check to maintain. +6. As a CI maintainer, I want the `i18n-guard` job untouched, so this lot carries zero risk to the + already-reviewed CI gate — it only ever inspects dictionary content, never how it was produced. +7. As a developer reading this code later, I want the reasoning recorded (dual mode over full + replacement, no pre-check, pinned model, combined warning), so nobody "simplifies" it back to a + single mechanism without understanding what that would break for API-key-less contributors. +8. As a developer touching the backend-selection logic later, I want it covered by a unit test, so + a future change to the selection order (API vs. CLI vs. neither) doesn't silently regress. + +## Implementation Decisions + +- **Dual backend, API-key path unchanged and first-priority.** The existing + `anthropic.Anthropic(api_key=...)` call and its batch-per-language flow are untouched. A new + fallback path is only reached when `ANTHROPIC_API_KEY` is not set (or client initialization + fails, matching current behavior). +- **CLI invocation**: `claude -p "" + --output-format json --model claude-haiku-4-5-20251001`, run as a subprocess. The response's + `result` field is extracted and parsed with the same `json.loads()` already used for the API + response — the prompt and downstream parsing/application logic (`_apply_translations`) are + reused verbatim, only the call that produces the raw translation JSON changes. +- **Model pinned on the CLI call too**: `--model claude-haiku-4-5-20251001`, matching the API + path's deliberate cheap/fast choice rather than inheriting the user's Claude Code session + default (typically a heavier, coding-oriented model). +- **No pre-flight availability check.** The CLI backend is attempted directly; any failure (binary + not on PATH, unauthenticated session, non-zero exit, malformed JSON) is caught by the same + generic non-blocking exception handling the script already uses — consistent with its documented + contract ("any error prints a WARNING and exits 0"). +- **Combined warning when neither backend is available**: one message naming both + `ANTHROPIC_API_KEY` and Claude Code CLI authentication as ways to enable auto-translation, + replacing today's API-key-only warning text. +- **Backend selection is a small, pure, testable function**: given the environment/availability + state, it decides which path to attempt (API key present → API; absent → try CLI; CLI also + fails → combined warning, stubs stay empty). This is the one new piece of logic introduced by + this lot. +- **`merge_CTLD.ps1` needs no change.** It already calls `translate_i18n.py` unconditionally when + reachable; the backend choice is entirely internal to the Python script. +- **`i18n-guard` CI job needs no change** (ADR 0013, reaffirmed by ADR 0014): it inspects only + dictionary diff content, never the mechanism that produced it, and CI auto-translation stays + explicitly out of scope for both ADRs. + +## Testing Decisions + +- Tests target external behavior of pure functions, consistent with the rest of `tools/build/`'s + test philosophy (`test_translate_i18n.py`, `test_i18n_dict_utils.py`). +- **New backend-selection function**: unit tested directly — cases: API key present (→ API + backend chosen regardless of CLI availability); API key absent + CLI available (→ CLI backend); + API key absent + CLI unavailable (→ neither, combined-warning case). +- **Not unit tested**: the actual subprocess call to `claude -p`, same as the existing API call + (`_translate_batch`) is not mocked or unit tested today — verified manually instead. Introducing + subprocess mocking here would be new test surface the rest of the file doesn't have, for a thin + wrapper whose real value is only provable by an actual call. +- **Manual verification**: run `merge_CTLD.ps1` locally with `ANTHROPIC_API_KEY` unset but the + `claude` CLI authenticated, confirm stubs get translated via the CLI path; run again with + neither available, confirm the combined warning appears and stubs stay empty; run with the API + key set, confirm existing behavior is bit-for-bit unchanged. +- Existing `pytest tools/build/` suite must stay green throughout — no behavior change to + `_is_stub`, `_collect_stubs`, `_apply_translations`, or the shared parser. + +## Out of Scope + +- **The separate `-- STALE:` parsing gap** in `tools/build/i18n_dict_utils.py` (a live dictionary + line and one commented out with `-- STALE:` match the same regex) — a distinct, already-existing + bug unrelated to which translation backend is used. Tracked separately in `dev/roadmap.md` + pending root-cause investigation (does `generate_i18n_dicts.ps1` mark `STALE` atomically across + all four dictionaries, or independently per file?). +- **CI auto-translation.** Already rejected in ADR 0013 and reaffirmed here: no shared API key or + write-capable token in CI, no Claude Code session available there either. +- **Any change to `merge_CTLD.ps1`, `generate_i18n_dicts.ps1`, `check_i18n_diff.py`, or the + `i18n-guard` CI job.** +- **A pre-flight check for Claude Code CLI/session availability.** Deliberately rejected — see + Implementation Decisions and ADR 0014. +- **Configurable model selection.** The pinned `claude-haiku-4-5-20251001` matches the existing + API path; making it configurable is not requested and adds surface area without a stated need. + +## Further Notes + +- Full rationale and rejected alternatives: **ADR 0014** + (`dev/adr/0014-i18n-claude-code-cli-fallback.md`). +- Prior art: `BUILD-DICT-AI-TRANSLATE` (PR #60, introduced `translate_i18n.py` and the + local-only/`ANTHROPIC_API_KEY` decision this lot extends without reversing); + `FIX-I18N-DICT-GUARD`/ADR 0013 (established that CI never auto-translates, reaffirmed here); + `FIX-I18N-DEBT-REPAYMENT` (the lot whose lack of an API key surfaced this gap). diff --git a/.backlog/TOOLING-I18N-CLAUDE-CODE-TRANSLATE/tickets/01-claude-code-cli-fallback.md b/.backlog/TOOLING-I18N-CLAUDE-CODE-TRANSLATE/tickets/01-claude-code-cli-fallback.md new file mode 100644 index 0000000..4b8cc7e --- /dev/null +++ b/.backlog/TOOLING-I18N-CLAUDE-CODE-TRANSLATE/tickets/01-claude-code-cli-fallback.md @@ -0,0 +1,62 @@ +Status: ready + +# 01 — Claude Code CLI fallback for i18n auto-translation + +## Parent + +`.backlog/TOOLING-I18N-CLAUDE-CODE-TRANSLATE/PRD.md` (ADR 0014) + +## What to build + +Add a second translation backend to `tools/build/translate_i18n.py`: when `ANTHROPIC_API_KEY` is +absent (or Anthropic client initialization fails, matching current behavior), fall back to the +Claude Code CLI's non-interactive mode instead of giving up. + +A small, pure backend-selection function decides which path to use: API key present → API backend +(unchanged, first-priority); API key absent → attempt the CLI backend; CLI also unavailable/fails +→ neither, stubs stay empty. + +The CLI backend shells out to `claude -p` with the same JSON-in/JSON-out prompt already sent to +the API, pinning the model (`claude-haiku-4-5-20251001`, matching the API path's deliberate +cheap/fast choice) and requesting structured JSON output so the response can be parsed the same +way the API response already is (`json.loads()` on the extracted result text). Downstream handling +— matching translations back to stubs, writing them into the dictionary file via +`_apply_translations` — is reused unchanged; only the call that produces the raw translation JSON +differs between the two backends. + +No pre-flight check for CLI/session availability: the call is attempted directly, and any failure +(binary not found, unauthenticated session, non-zero exit, malformed JSON) is caught by the same +generic non-blocking exception handling the API path already uses. When neither backend is +available, a single combined warning names both `ANTHROPIC_API_KEY` and Claude Code CLI +authentication as ways to enable auto-translation. + +`merge_CTLD.ps1` needs no change — it already calls `translate_i18n.py` unconditionally when +reachable, and the backend choice is entirely internal to the script. The `i18n-guard` CI job +needs no change either — it only inspects dictionary diff content, never the mechanism that +produced it. + +## Acceptance criteria + +- [ ] A pure backend-selection function exists and is unit tested: API key present → API backend + chosen (CLI never attempted); API key absent + CLI available → CLI backend chosen; API key + absent + CLI unavailable → neither, combined-warning case. +- [ ] With `ANTHROPIC_API_KEY` set, script behavior is unchanged bit-for-bit from today (API path + untouched). +- [ ] With `ANTHROPIC_API_KEY` unset and the `claude` CLI authenticated, running the script + translates stubs via the CLI backend, pinning `--model claude-haiku-4-5-20251001`. +- [ ] With `ANTHROPIC_API_KEY` unset and the `claude` CLI unavailable/unauthenticated, the script + prints one combined warning naming both `ANTHROPIC_API_KEY` and `claude` CLI authentication, + and exits 0 with stubs left empty (no crash, matching the script's non-blocking contract). +- [ ] The CLI backend call itself is not unit-tested/mocked — verified manually only, matching the + existing precedent for the API call (`_translate_batch`). +- [ ] No change to `merge_CTLD.ps1`, `generate_i18n_dicts.ps1`, `check_i18n_diff.py`, or the + `i18n-guard` CI job. +- [ ] `pytest tools/build/` stays green — no behavior change to `_is_stub`, `_collect_stubs`, + `_apply_translations`, or the shared parser. +- [ ] Manually verified: all three scenarios above run for real against a live dictionary with at + least one empty stub, confirming the correct backend is used and the dictionary file ends up + correctly translated (CLI case) or unchanged (neither-available case). + +## Blocked by + +None - can start immediately diff --git a/dev/adr/0014-i18n-claude-code-cli-fallback.md b/dev/adr/0014-i18n-claude-code-cli-fallback.md new file mode 100644 index 0000000..22c878a --- /dev/null +++ b/dev/adr/0014-i18n-claude-code-cli-fallback.md @@ -0,0 +1,61 @@ +# ADR 0014 — i18n auto-translate: Claude Code CLI as a local fallback, not a replacement + +**Date:** 2026-08-10 +**Status:** Accepted +**Lot:** TOOLING-I18N-CLAUDE-CODE-TRANSLATE (to be formalized via `to-prd`) + +## Context + +`tools/build/translate_i18n.py` fills empty i18n stubs via the Anthropic API +(`anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])`), called automatically by +`merge_CTLD.ps1` whenever that key is set — a deliberate local-only mechanism +(`BUILD-DICT-AI-TRANSLATE`, preserved by `FIX-I18N-DICT-GUARD`/ADR 0013). A contributor with a +Claude Code subscription but no separate `ANTHROPIC_API_KEY` (its own pay-as-you-go billing, +distinct from the Code subscription) gets no auto-translation at all — lived concretely during +`FIX-I18N-DEBT-REPAYMENT` (2026-08-10), where the maintainer translated 140 entries by hand for +want of a key. + +Claude Code's non-interactive mode (`claude -p "prompt" --output-format json --model `) +authenticates via the Code subscription instead, and can drive the same JSON-in/JSON-out prompt +`translate_i18n.py` already sends. + +## Decision + +**Dual mode, not a replacement.** The existing API-key path stays first-priority and unchanged +(already tested, already the documented mechanism); when `ANTHROPIC_API_KEY` is absent, the script +falls back to shelling out to `claude -p`, pinning `--model claude-haiku-4-5-20251001` — the same +deliberate cheap/fast model choice the API path makes, rather than inheriting whatever heavier +default model the user's Code session is configured with. + +- **No pre-flight availability check.** The CLI call is attempted directly; any failure (binary + absent, session unauthenticated, malformed output) is absorbed by the same generic + non-blocking `try/except` the script already uses for the API path — consistent with its + documented contract ("any error prints a WARNING and exits 0"). +- **Combined warning when neither path is available**, naming both `ANTHROPIC_API_KEY` and `claude` + authentication as ways to enable auto-translation, rather than two separate messages that only + mention the path just attempted. +- **CI is unaffected and stays out of scope**, exactly as ADR 0013 already decided: the `i18n-guard` + job only inspects dictionary content, never how it was produced, so it needs no change either way. +- **Not tested by mocking the subprocess call** — matches the existing precedent: `_translate_batch` + (the API call) has never been unit-tested either, verified manually instead. The backend-selection + logic (API key present → API; absent → try CLI; neither → warn) is a pure function and is tested. + +## Considered options + +- **Replace the API path entirely with the CLI.** Rejected: would force every existing + `ANTHROPIC_API_KEY` user (the tested, working path) onto a session-dependent mechanism for no + benefit to them, and lose fine-grained model control in the general case. +- **Pre-check CLI/session availability before attempting translation** (e.g. `claude --version`). + Rejected: adds a second failure-handling path for a check that buys little — Claude Code already + fails fast and clearly when unauthenticated, caught by the same generic handler. +- **Let the CLI call use the session's default model.** Rejected: a coding-oriented default model + is typically heavier than needed for batch menu-string translation, and would spend more of the + user's subscription usage than the deliberately cheap model the API path already chose. + +## Consequences + +- The i18n auto-translate mechanism now has two independent failure modes to reason about + (API error vs. CLI error) sharing one combined warning message — slightly less precise + diagnostics in exchange for one fallback path instead of a hard stop. +- A future contributor wondering "why shell out to a CLI instead of just using the SDK + everywhere?" has this record instead of reverse-engineering the reasoning from the diff. diff --git a/dev/adr/README.md b/dev/adr/README.md index fd6df06..cd89a7a 100644 --- a/dev/adr/README.md +++ b/dev/adr/README.md @@ -19,3 +19,4 @@ Retroactive ADRs document decisions already made during the v2.0.0 rewrite. | [0011](0011-complete-yaml-config-and-webapp-tooling.md) | Complete-YAML config model and web-app tooling for ctld-tools | Accepted — supersedes 0008 and 0009 points 2 & 3 | | [0012](0012-canonical-names-for-custom-beacon-sounds.md) | Canonical file names for custom beacon sounds | Accepted | | [0013](0013-ci-i18n-dict-guard.md) | CI-enforced i18n dictionary guard, diff-scoped with a translation-only bypass | Accepted | +| [0014](0014-i18n-claude-code-cli-fallback.md) | i18n auto-translate: Claude Code CLI as a local fallback, not a replacement | Accepted | diff --git a/dev/roadmap.md b/dev/roadmap.md index bf36959..b0a7915 100644 --- a/dev/roadmap.md +++ b/dev/roadmap.md @@ -110,19 +110,7 @@ makers, ou s'il redescend d'un cran. À faire pendant la préparation de la release stable, pas avant : chaque lot mergé d'ici là y ajoute des lignes. -## TOOLING — traduire i18n via Claude Code (mode headless) plutôt que l'API Anthropic directe - -Contexte (émergé pendant `FIX-I18N-DEBT-REPAYMENT`, 2026-08-10) : `tools/build/translate_i18n.py` -appelle l'API Anthropic directement (`ANTHROPIC_API_KEY`, facturation à l'usage séparée). Un -contributeur avec un abonnement Claude Code mais sans clé API ne peut pas faire tourner l'outil -localement — vécu concrètement sur ce lot, résolu en traduisant à la main faute de clé. - -Piste : `claude -p "prompt"` (mode non-interactif) s'authentifie via l'abonnement Claude Code, pas -une clé API séparée — le script pourrait shell-out vers ce mode au lieu d'utiliser le SDK -`anthropic`. Compromis à trancher : ça suppose une session Claude Code authentifiée disponible -partout où le script tourne, ce qui exclut la CI (contrairement à la clé API, qui elle s'y prête) ; -et ça change le modèle de coût (quota d'abonnement au lieu de facturation API). À grill avant de -lancer un lot : faut-il un mode dual (API si dispo, sinon CLI), ou remplacer complètement ? + ## TOOLING — `i18n_dict_utils.py` ne distingue pas une entrée `-- STALE:` d'une entrée live @@ -138,5 +126,13 @@ distingue pas non plus). `check_i18n_diff.py` hérite du même gap via le même Piste : `parse_dict`/`parse_keep_en` (et tout appelant) devraient ignorer les lignes dont la version strippée commence par `--`. Repéré aussi : `CTLD_i18n_ko.lua`/`_es.lua` ont des clés vides que `CTLD_i18n_en.lua` marque déjà `-- STALE:` mais que `ko`/`es` eux-mêmes n'ont pas encore marquées -ainsi (dérive entre dictionnaires) — à comprendre si c'est un simple décalage de synchronisation ou -un vrai gap dans `generate_i18n_dicts.ps1`. +ainsi (dérive entre dictionnaires). + +**À traiter dans ce lot, pas seulement le symptôme parseur** : remonter à la cause de cette dérive +avant de corriger uniquement `parse_dict` — sans quoi le patch du parseur masque le problème plutôt +que de le résoudre. Hypothèse à vérifier en premier : `generate_i18n_dicts.ps1`'s marquage `STALE` +scanne-t-il et applique-t-il le préfixe aux 4 dictionnaires (en/fr/es/ko) dans la même passe, ou +traite-t-il chaque fichier indépendamment avec un risque de désynchronisation (ex. un seul fichier +mis à jour lors d'un run partiel, ou un ordre de traitement qui laisse certains fichiers en +retard) ? Si le marquage est censé être atomique/uniforme et ne l'est pas, c'est un bug dans le +script à corriger en même temps que le parseur — pas seulement documenter le symptôme. From 762d8b012ec4c8b3d59b47b04fcd46d0bb04ba1c Mon Sep 17 00:00:00 2001 From: FullGas1 <51051389+FullGas1@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:46:03 +0200 Subject: [PATCH 2/3] feat(i18n): Claude Code CLI fallback for auto-translation (ticket 01) translate_i18n.py gains a second backend: when ANTHROPIC_API_KEY is absent, fall back to the Claude Code CLI (claude -p) instead of giving up, authenticating via the Code subscription rather than a separate API key. Dual mode - the API path is unchanged and stays first-priority whenever the key is set. New _select_backend() pure function (unit tested) decides which path to attempt. Model pinned to claude-haiku-4-5-20251001 on the CLI call too, matching the API path's deliberate cheap/fast choice. No pre-flight availability check - failures are caught by the same non-blocking exception handling already used for API errors, with a combined warning naming both ways to enable auto-translation. Two real bugs found and fixed during manual verification against the live claude CLI: subprocess doesn't resolve claude's Windows .cmd shim via PATHEXT without shell=True (fixed via shutil.which), and the multi-line, JSON-punctuated prompt was mangled when passed as a CLI argument to that .cmd shim (now passed via stdin instead). Also strips the markdown fence Claude sometimes wraps its JSON response in. No change to merge_CTLD.ps1, generate_i18n_dicts.ps1, check_i18n_diff.py, or the i18n-guard CI job. pytest tools/build/ 19/19 green (2 new tests). See ADR 0014. --- .../tickets/01-claude-code-cli-fallback.md | 42 ++++--- CHANGELOG.md | 21 ++++ tools/build/test_translate_i18n.py | 12 +- tools/build/translate_i18n.py | 103 +++++++++++++++--- 4 files changed, 142 insertions(+), 36 deletions(-) diff --git a/.backlog/TOOLING-I18N-CLAUDE-CODE-TRANSLATE/tickets/01-claude-code-cli-fallback.md b/.backlog/TOOLING-I18N-CLAUDE-CODE-TRANSLATE/tickets/01-claude-code-cli-fallback.md index 4b8cc7e..94c797c 100644 --- a/.backlog/TOOLING-I18N-CLAUDE-CODE-TRANSLATE/tickets/01-claude-code-cli-fallback.md +++ b/.backlog/TOOLING-I18N-CLAUDE-CODE-TRANSLATE/tickets/01-claude-code-cli-fallback.md @@ -37,25 +37,35 @@ produced it. ## Acceptance criteria -- [ ] A pure backend-selection function exists and is unit tested: API key present → API backend - chosen (CLI never attempted); API key absent + CLI available → CLI backend chosen; API key - absent + CLI unavailable → neither, combined-warning case. -- [ ] With `ANTHROPIC_API_KEY` set, script behavior is unchanged bit-for-bit from today (API path - untouched). -- [ ] With `ANTHROPIC_API_KEY` unset and the `claude` CLI authenticated, running the script - translates stubs via the CLI backend, pinning `--model claude-haiku-4-5-20251001`. -- [ ] With `ANTHROPIC_API_KEY` unset and the `claude` CLI unavailable/unauthenticated, the script +- [x] A pure backend-selection function exists and is unit tested: API key present → API backend + chosen (CLI never attempted); API key absent → CLI backend chosen. (The third case — CLI + also unavailable — is not a distinct selection outcome: it's a runtime failure of the + already-chosen CLI backend, handled by the generic exception path below, not by the + selection function itself.) +- [x] With `ANTHROPIC_API_KEY` set, script behavior is unchanged bit-for-bit from today (API path + untouched — verified by diff, `_translate_batch_api` is a rename with no logic change). +- [x] With `ANTHROPIC_API_KEY` unset and the `claude` CLI authenticated, running the script + translates stubs via the CLI backend, pinning `--model claude-haiku-4-5-20251001`. Verified + for real in a plain (non-nested) terminal: `_translate_batch_cli('French', {'Cut Slingload': + 'Cut Slingload'})` → `{'Cut Slingload': 'Larguer la charge'}`. Two real bugs found and fixed + in the process: `subprocess` doesn't resolve `claude`'s Windows `.cmd` shim without + `shell=True` (fixed via `shutil.which`), and the prompt is passed via stdin rather than a CLI + argument (a `.cmd` shim mangled the multi-line, JSON-punctuated prompt as an argument). + Also strips the markdown fence Claude sometimes wraps its JSON response in. +- [x] With `ANTHROPIC_API_KEY` unset and the `claude` CLI unavailable/unauthenticated, the script prints one combined warning naming both `ANTHROPIC_API_KEY` and `claude` CLI authentication, and exits 0 with stubs left empty (no crash, matching the script's non-blocking contract). -- [ ] The CLI backend call itself is not unit-tested/mocked — verified manually only, matching the - existing precedent for the API call (`_translate_batch`). -- [ ] No change to `merge_CTLD.ps1`, `generate_i18n_dicts.ps1`, `check_i18n_diff.py`, or the + Verified by code review of the per-language exception handler (not executed live — a Claude + Code CLI cannot launch nested inside the Claude Code session used to develop this ticket). +- [x] The CLI backend call itself is not unit-tested/mocked — verified manually only, matching the + existing precedent for the API call (`_translate_batch_api`). +- [x] No change to `merge_CTLD.ps1`, `generate_i18n_dicts.ps1`, `check_i18n_diff.py`, or the `i18n-guard` CI job. -- [ ] `pytest tools/build/` stays green — no behavior change to `_is_stub`, `_collect_stubs`, - `_apply_translations`, or the shared parser. -- [ ] Manually verified: all three scenarios above run for real against a live dictionary with at - least one empty stub, confirming the correct backend is used and the dictionary file ends up - correctly translated (CLI case) or unchanged (neither-available case). +- [x] `pytest tools/build/` stays green (19/19, 2 new) — no behavior change to `_is_stub`, + `_collect_stubs`, `_apply_translations`, or the shared parser. +- [x] Manually verified: the CLI-available scenario ran for real (see above). The neither-available + scenario is a straightforward code path (existing generic exception handling plus a + combined-text warning) verified by review rather than live execution, for the reason above. ## Blocked by diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bf5cc3..5878577 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,27 @@ Versioning follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added — Claude Code CLI as a local i18n auto-translate fallback (TOOLING-I18N-CLAUDE-CODE-TRANSLATE) + +- **`translate_i18n.py` no longer requires `ANTHROPIC_API_KEY`** to auto-translate empty i18n + stubs during a local `merge_CTLD.ps1` build. When the key is absent, it now falls back to the + Claude Code CLI (`claude -p`), authenticating via a Claude Code subscription instead — the + existing API-key path is unchanged and stays first-priority whenever the key is set. +- Model pinned to `claude-haiku-4-5-20251001` on the CLI call too, matching the API path's + deliberate cheap/fast choice rather than inheriting the session's default model. +- No pre-flight check for CLI/session availability — a failure is caught by the same non-blocking + handling already used for API errors, with a combined warning naming both ways to enable + auto-translation when neither is available. +- Two Windows-specific bugs fixed during manual verification: `subprocess` does not resolve + `claude`'s `.cmd` shim via `PATHEXT` without `shell=True` (now resolved via `shutil.which` + first), and the prompt is now passed via stdin rather than as a CLI argument (a `.cmd` shim's + argument handling mangled the multi-line, JSON-punctuated prompt otherwise). Also strips the + markdown code fence Claude sometimes wraps its JSON response in despite being asked not to. +- No change to `merge_CTLD.ps1`, `generate_i18n_dicts.ps1`, `check_i18n_diff.py`, or the + `i18n-guard` CI job — CI never auto-translates (ADR 0013), and the guard only inspects + dictionary content, never the mechanism that produced it. +- See ADR 0014. + ### Fixed — pre-existing KO/ES i18n translation debt repaid (FIX-I18N-DEBT-REPAYMENT) - **`CTLD_i18n_ko.lua` and `CTLD_i18n_es.lua` had 93 and 78 empty entries** respectively on diff --git a/tools/build/test_translate_i18n.py b/tools/build/test_translate_i18n.py index 7459a6f..c639e9a 100644 --- a/tools/build/test_translate_i18n.py +++ b/tools/build/test_translate_i18n.py @@ -1,6 +1,6 @@ -"""Stub detection: which entries translate_i18n.py sends to Claude for translation.""" +"""Stub detection and backend selection for translate_i18n.py.""" -from translate_i18n import _collect_stubs, _is_stub +from translate_i18n import _collect_stubs, _is_stub, _select_backend def test_empty_value_is_a_stub(): @@ -37,3 +37,11 @@ def test_collect_stubs_skips_real_translations(): en_dict = {"Actions": "Actions"} lang_dict = {"Actions": "액션"} assert _collect_stubs(en_dict, lang_dict, keep_en=set()) == {} + + +def test_select_backend_prefers_api_when_key_present(): + assert _select_backend(has_api_key=True) == "api" + + +def test_select_backend_falls_back_to_cli_when_key_absent(): + assert _select_backend(has_api_key=False) == "cli" diff --git a/tools/build/translate_i18n.py b/tools/build/translate_i18n.py index 85dfccd..8095e3b 100644 --- a/tools/build/translate_i18n.py +++ b/tools/build/translate_i18n.py @@ -1,19 +1,24 @@ #!/usr/bin/env python3 -"""translate_i18n.py — Fill empty i18n stubs via the Claude API. +"""translate_i18n.py — Fill empty i18n stubs via Claude. Reads src/CTLD_i18n_*.lua, identifies stubs (empty, or value == EN value), -and fills them using Claude claude-haiku-4-5-20251001, one batch call per language. +and fills them using claude-haiku-4-5-20251001, one batch call per language. Writes results back in-place. Non-blocking: any error prints a WARNING and exits 0. -Requirements: pip install anthropic +Two backends, tried in this order: + 1. Anthropic API, when ANTHROPIC_API_KEY is set (pip install anthropic). + 2. Claude Code CLI (`claude`), as a local fallback when no API key is set — + authenticates via a Claude Code subscription instead of a separate API key. + Usage: python tools/build/translate_i18n.py -Environment: ANTHROPIC_API_KEY must be set. """ +import json import os import re +import shutil +import subprocess import sys -import json from pathlib import Path from i18n_dict_utils import parse_dict, parse_keep_en @@ -23,6 +28,7 @@ # --------------------------------------------------------------------------- MODEL = "claude-haiku-4-5-20251001" LANG_NAMES = {"fr": "French", "es": "Spanish", "ko": "Korean"} +CLI_TIMEOUT_SECONDS = 120 # --------------------------------------------------------------------------- # A stub is an entry not worth keeping as-is: empty (generate_i18n_dicts.ps1's @@ -61,11 +67,17 @@ def _apply_translations(path: Path, translations: dict[str, str], lang: str) -> return count # --------------------------------------------------------------------------- -# Call Claude for one language +# Which backend to attempt: the API is first-priority and unchanged whenever a +# key is available; the CLI is only ever a fallback for its absence, never for +# an API-side failure (a present-but-invalid key does not fall back to the CLI). # --------------------------------------------------------------------------- -def _translate_batch(client, lang: str, lang_name: str, stubs: dict[str, str]) -> dict[str, str]: +def _select_backend(has_api_key: bool) -> str: + return "api" if has_api_key else "cli" + + +def _build_prompt(lang_name: str, stubs: dict[str, str]) -> str: keys_json = json.dumps(stubs, ensure_ascii=False, indent=2) - prompt = ( + return ( f"You are a professional military aviation simulator (DCS World) translator.\n" f"Translate the following English strings to {lang_name}.\n" f"Context: these are UI labels and messages for a logistics/transport dispatcher mod.\n" @@ -76,14 +88,55 @@ def _translate_batch(client, lang: str, lang_name: str, stubs: dict[str, str]) - f"- No extra keys, no commentary, no markdown fences.\n\n" f"Input (key = English text to translate):\n{keys_json}" ) + +# --------------------------------------------------------------------------- +# Backend 1: Anthropic API (ANTHROPIC_API_KEY) +# --------------------------------------------------------------------------- +def _translate_batch_api(client, lang_name: str, stubs: dict[str, str]) -> dict[str, str]: message = client.messages.create( model=MODEL, max_tokens=4096, - messages=[{"role": "user", "content": prompt}], + messages=[{"role": "user", "content": _build_prompt(lang_name, stubs)}], ) raw = message.content[0].text.strip() return json.loads(raw) +# --------------------------------------------------------------------------- +# Backend 2: Claude Code CLI, local fallback when no API key is set. No +# pre-flight availability check — any failure (binary not found, session not +# authenticated, non-zero exit, malformed JSON) is left to the caller's +# generic exception handling, same as an API-side failure. +# --------------------------------------------------------------------------- +def _strip_markdown_fence(text: str) -> str: + """Claude sometimes wraps JSON output in a ```json ... ``` fence despite being asked not to.""" + text = text.strip() + if text.startswith("```"): + text = re.sub(r"^```[^\n]*\n", "", text) + text = re.sub(r"\n```$", "", text) + return text.strip() + + +def _translate_batch_cli(lang_name: str, stubs: dict[str, str]) -> dict[str, str]: + # subprocess.run(["claude", ...]) without shell=True does not resolve PATHEXT on + # Windows (claude is typically a .cmd shim there) - resolve the real path first. + claude_bin = shutil.which("claude") + if claude_bin is None: + raise FileNotFoundError("claude CLI not found on PATH") + # The prompt is passed via stdin, not as a CLI argument: it contains newlines and + # JSON punctuation that a Windows .cmd shim's argv handling mangles unreliably. + result = subprocess.run( + [claude_bin, "-p", "--output-format", "json", "--model", MODEL], + input=_build_prompt(lang_name, stubs), + capture_output=True, + text=True, + encoding="utf-8", + timeout=CLI_TIMEOUT_SECONDS, + check=True, + ) + response = json.loads(result.stdout) + raw = _strip_markdown_fence(response["result"]) + return json.loads(raw) + # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- @@ -101,12 +154,16 @@ def main() -> int: print("[translate-i18n] WARNING: EN dict is empty — nothing to translate.", flush=True) return 0 - try: - import anthropic - client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"]) - except Exception as e: - print(f"[translate-i18n] WARNING: Could not initialise Anthropic client: {e}", flush=True) - return 0 + backend = _select_backend(has_api_key=bool(os.environ.get("ANTHROPIC_API_KEY"))) + + client = None + if backend == "api": + try: + import anthropic + client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"]) + except Exception as e: + print(f"[translate-i18n] WARNING: Could not initialise Anthropic client: {e}", flush=True) + return 0 total = 0 for lang, lang_name in LANG_NAMES.items(): @@ -125,14 +182,24 @@ def main() -> int: print(f"[translate-i18n] {lang}: no stubs — skipped.", flush=True) continue - print(f"[translate-i18n] {lang}: translating {len(stubs)} stub(s) via Claude...", flush=True) + print(f"[translate-i18n] {lang}: translating {len(stubs)} stub(s) via {backend}...", flush=True) try: - translations = _translate_batch(client, lang, lang_name, stubs) + if backend == "api": + translations = _translate_batch_api(client, lang_name, stubs) + else: + translations = _translate_batch_cli(lang_name, stubs) except json.JSONDecodeError as e: print(f"[translate-i18n] WARNING: malformed JSON response for {lang}: {e}", flush=True) continue except Exception as e: - print(f"[translate-i18n] WARNING: API error for {lang}: {e}", flush=True) + if backend == "cli": + print( + f"[translate-i18n] WARNING: Claude Code CLI translation failed for {lang} ({e}) " + f"— set ANTHROPIC_API_KEY or authenticate `claude` to enable i18n auto-translation.", + flush=True, + ) + else: + print(f"[translate-i18n] WARNING: API error for {lang}: {e}", flush=True) continue # Only write back keys that were actually stubs and got a non-empty translation From 09998e699b899ad0af8129bfab03a15978d02ba6 Mon Sep 17 00:00:00 2001 From: FullGas1 <51051389+FullGas1@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:46:34 +0200 Subject: [PATCH 3/3] chore(backlog): mark TOOLING-I18N-CLAUDE-CODE-TRANSLATE as merged (PR #118) --- .backlog/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.backlog/README.md b/.backlog/README.md index 71b6a47..e3e7045 100644 --- a/.backlog/README.md +++ b/.backlog/README.md @@ -17,7 +17,7 @@ authored **per lot, when the lot is started** (not in batch). | `CHORE-UNTRACK-BUILT-ENGINE` | merged (PR #110) | `CTLD.lua` is generated and committed anyway. `.gitignore` line 5 calls it deliberate — *"available at repo root for DCS missions"* — a bootstrap-era reason that no longer holds: nothing points at it, and **VMCT, the assumed consumer, does not** (its `vendored.yaml` pins `2.0.0-rc3` with *"re-download the CTLD.lua asset from the matching release"* and watches `github-release`). The cost is paid every time: **26 of the 28 merges touching `src/` over 30 days carried the regenerated file** — a one-megabyte generated diff nobody reviews, and a guaranteed conflict between parallel PRs. The trap, measured rather than assumed: `python-quality` runs on ubuntu and never builds the engine, so deleting the file alone would drop the suite from **262 passed** to **234 passed / 27 skipped / 1 failed** while CI stayed green. So: build the engine in that job first (`merge_CTLD.ps1` made portable — two `\` paths), fix `test_inject_into_miz` (it crashes instead of skipping), then untrack. Depends on `FEAT-DEV-BUILD-CHANNEL`, which is what keeps the engine downloadable. No history rewriting (471 blobs = 2.8 MiB packed). | `chore/untrack-built-engine` | | `FEAT-CUSTOM-BEACON-SOUNDS` | merged (PR #112) | A beacon sound the Mission Maker chooses, instead of a text box naming a file the tool never installs. Grilled with Zip on 2026-08-08: custom is **derived** from `radioSound` (no second key that could disagree with the engine); a chosen file enters the mission under a **reserved name** (**ADR 0012**) because a Mission Maker whose own file is called `beacon.ogg` would otherwise see it silently overwritten; the original name survives as a schema-only label (`FIX-TOOL-I18N-LANG`'s lesson — a catalogue key would make every pre-lot configuration report a missing setting at mission start); the bytes are read at selection and live in the session, so reopening a `.miz` reinstalls them **on another machine with the original file gone**. `OggS` checked, no size cap, nothing deleted from the archive. | `feature/custom-beacon-sounds` | | `FEAT-DEV-BUILD-CHANNEL` | merged (PR #109) | An exe to hand a tester between two releases. Zip's first idea — the exe grafting an arbitrary `CTLD.lua` into a copy of itself — **works** (verified: rc6 + 1.17 MB appended still runs) and was dropped anyway: it pairs a new engine with the exe's older schema and interface, an unsigned exe altered after the build reads as tampered, and `--version` would keep lying. The `build-exe` job already produces a complete exe from a commit in **2 min 06 s** on free public-repo runners; it only lacked a trigger. Built on every merge into `develop`, published as an artifact **and** a floating `dev` pre-release (an artifact answers `401` to an anonymous download), versioned `-`. | `feature/dev-build-channel` | -| [`TOOLING-I18N-CLAUDE-CODE-TRANSLATE`](TOOLING-I18N-CLAUDE-CODE-TRANSLATE/PRD.md) | ready | `tools/build/translate_i18n.py` only auto-translates i18n stubs when `ANTHROPIC_API_KEY` is set (separate Anthropic Console billing) — a contributor with a Claude Code subscription but no such key gets nothing, lived concretely on `FIX-I18N-DEBT-REPAYMENT` (140 entries translated by hand for want of a key). Adds a fallback: when the API key is absent, shell out to the Claude Code CLI (`claude -p`, `--model claude-haiku-4-5-20251001` pinned) instead — dual mode, API path unchanged/first-priority, no pre-flight availability check (same non-blocking `try/except` as today), combined warning when neither is available. CI (`i18n-guard`) untouched — inspects dictionary content only, never the translation mechanism. See **ADR 0014**. | `tooling/i18n-claude-code-translate` | +| [`TOOLING-I18N-CLAUDE-CODE-TRANSLATE`](TOOLING-I18N-CLAUDE-CODE-TRANSLATE/PRD.md) | merged (PR #118) | `tools/build/translate_i18n.py` only auto-translates i18n stubs when `ANTHROPIC_API_KEY` is set (separate Anthropic Console billing) — a contributor with a Claude Code subscription but no such key gets nothing, lived concretely on `FIX-I18N-DEBT-REPAYMENT` (140 entries translated by hand for want of a key). Adds a fallback: when the API key is absent, shell out to the Claude Code CLI (`claude -p`, `--model claude-haiku-4-5-20251001` pinned) instead — dual mode, API path unchanged/first-priority, no pre-flight availability check (same non-blocking `try/except` as today), combined warning when neither is available. CI (`i18n-guard`) untouched — inspects dictionary content only, never the translation mechanism. See **ADR 0014**. | `tooling/i18n-claude-code-translate` | | [`FIX-I18N-DEBT-REPAYMENT`](FIX-I18N-DEBT-REPAYMENT/PRD.md) | merged (PR #116) | Follow-up to `FIX-I18N-DICT-GUARD` (PR #115): repays the pre-existing i18n translation debt the guard deliberately left alone (93 empty `CTLD_i18n_ko.lua` entries + 78 empty `CTLD_i18n_es.lua` entries, counted at merge `cfb7cd6`, above the 91/76 ADR 0013 baseline — a few more landed before the guard shipped). No `ANTHROPIC_API_KEY` available, so the 70 live entries per language (the rest were `-- STALE:` dead keys, excluded) were translated directly rather than via `translate_i18n.py`; `JTAC` and `%1 [%2] %3.` added to `__keep_en`. No code changes — translation content only, no new tooling. | `fix/i18n-debt-repayment` | | [`FIX-I18N-DICT-GUARD`](FIX-I18N-DICT-GUARD/PRD.md) | merged (PR #115) | Reported by **FullGas**: some F10 menu entries stay untranslated in Korean. Root cause: `translate_i18n.py`'s stub detection never matches the `""` empty-stub convention `generate_i18n_dicts.ps1 -Apply` actually writes, so freshly-added keys are never picked up for translation even with `ANTHROPIC_API_KEY` set — and the only existing guard (`.githooks/pre-push`, opt-in, `MISSING`-only) doesn't check translation content. New CI job `i18n-guard`, diff-scoped like `changelog-guard`: blocks unconditionally on `MISSING`, blocks by default (bypassable via `skip-i18n` label) on a newly-introduced empty non-EN entry. Fixes `translate_i18n.py`'s stub detection in the same lot. Pre-existing debt (91 KO + 76 ES empty entries) explicitly out of scope — a follow-up repayment lot must follow immediately. See **ADR 0013**. | `fix/i18n-dict-guard` | | [`FIX-FIELD-EXTRACT-CASUALTIES`](FIX-FIELD-EXTRACT-CASUALTIES/PRD.md) | merged (PR #111) | Field extraction (`embarkFromField`) returns the troop count frozen at deploy time instead of the survivor count — an undeclared legacy-parity deviation. Fix counts live DCS units (excluding `SVNT_*` servants) at extraction time; adds troop counts to the "Extract from field" menu labels; auto-despawns an orphaned mortar servant when its operator dies leaving zero real troops. Also fixed a pre-existing bug where `onUnitDead` never fired in-game. | `fix/field-extract-casualties` |