# Localization

Acolyte keeps user-facing copy translatable while protocol methods, payload keys, tool ids, and machine-readable values remain stable.

## Translatable surfaces

- CLI/TUI help and guidance text
- human-readable status/error copy shown to users
- narrative assistant framing that is not part of machine contracts

## Language-neutral surfaces

- RPC method names and protocol message type identifiers
- structured payload keys and machine-readable enums
- tool ids, tool row labels, and machine markers in raw tool output
- file/line/path metadata emitted for automation and parsing
- prompt text sent to the model, which stays English so model behavior does not vary by locale
- key chords and the glyphs that stand for them, which name what the keyboard prints

A new protocol or tool contract takes an identifier-style name rather than a natural-language label, so translating a surface never changes a contract.

## Reply language

The prompt is authored and tuned in English and stays that way; the interface language reaches the model only as the language it writes its prose in. The output contract names that language whenever the interface is not English, so a reply follows `locale` without any instruction being translated.

What the model writes into the repository — code, identifiers, comments, commit messages, file contents — stays English at every locale.

## Authoring

Messages are authored in ARB (`src/i18n/<locale>.arb`) and compiled into TypeScript by `bun run messages`. Nothing reads ARB at runtime.

Each message carries an `@<key>` entry describing where it appears and what its placeholders hold. Write that description as if for a translator who cannot see the code: name the surface, say whether a placeholder arrives already translated, and call out any literal that must survive translation, such as a command name, flag, or environment variable.

The compiler rejects a catalog that omits a key, adds an unknown one, uses a placeholder the English message does not, or leaves a description unwritten. It also rejects a key no source file references, so a message cannot be translated into every language after the code that showed it is gone.

A key held in a field rather than written at the call site is typed as a `PlainTranslationKey`, so a name no catalog carries is a compile error instead of a blank surface. Prose belongs in the message, never in the literal beside it: a chord rendered as `ctrl + c twice` keeps that “twice” in English in every language, while `ctrl + c` plus a translated “twice to exit” reads everywhere.

Call `t()` inside the function that renders, never at module scope. `setLocale` runs at the entrypoints (`cli.ts`, `server.ts`) and again on the daemon for each turn, so a string built while a module evaluates keeps the locale that happened to be active at import. The reply language is not read from that global: it travels with the turn, so a prompt cannot pick up another turn’s language. A table of labels or help text is a thunk or a table of keys, resolved on read. Nothing enforces this — a frozen string is a half-translated screen, not an error — so a table that must hold rendered text is covered by a test that switches locale after import and asserts the new language.

## Selecting a language

`acolyte config set locale <id>` chooses the interface language. It is user-scoped, so the choice follows the person across projects and a repository cannot set it. The value is validated against the bundled locales, and a wrong one is answered with the full list. The language applies to the next command, and a running daemon adopts it on its next turn.

## Message syntax

A message is literal text, `{name}` placeholders, and the ICU plural form `{count, plural, one {# file} other {# files}}`, where `#` is the count. Plural categories come from `Intl.PluralRules`, so a locale gets the arms its language actually needs; `other` is required and is used when a category has no arm.

A run of choices decided at runtime arrives as one placeholder, already joined by `Intl.ListFormat` in the active language, so the message never spells the conjunction itself. A fixed set small enough to write out belongs in the message text.

Other ICU forms — `select`, `selectordinal`, number and date skeletons — are not supported and fail the build. ICU quoting is not implemented either: an apostrophe touching `{`, `}`, or `#` is rejected, because a conforming ICU parser would read it as an escape and stop substituting.

## Adding a locale

1. Copy `src/i18n/en.arb` to `src/i18n/<locale>.arb` and translate the message values, leaving keys and placeholders as they are.
2. Run `bun run messages` and commit both the ARB file and the generated output.

The locale becomes selectable with no further wiring: the compiler discovers `*.arb`, and the generated catalog supplies both the `TRANSLATIONS` map and the locale enum the configuration validates against.

## Key naming

- put chat content under `chat.*` keys (slash-command responses, chat status rows, chat progress/error text)
- keep CLI-only command text under `cli.*` keys
- reserve `tui.*` for terminal UI chrome only (panel labels, key hints, picker/status-line framing), not chat content
- name a key for the surface it renders on, not the module that calls it
- prefer one stable key per message intent; two surfaces that show the same words keep separate keys when their wording may diverge