Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .backlog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<ctld version>-<commit hash>`. | `feature/dev-build-channel` |
| [`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` |
Expand Down
124 changes: 124 additions & 0 deletions .backlog/TOOLING-I18N-CLAUDE-CODE-TRANSLATE/PRD.md
Original file line number Diff line number Diff line change
@@ -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 "<same JSON-in/JSON-out prompt already sent to the API>"
--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).
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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

- [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).
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.
- [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

None - can start immediately
Loading