Adding a Language Developer Guide - edmozley/freeitsm GitHub Wiki

Adding a language, and finishing one β€” developer guide

How FreeITSM gets a language from nothing, or from half-done, to 100%. Companion to Internationalisation for how t() and the fallback chain work, and i18n Audit for the checks.

⚠️ This page was rewritten on 2026-09-22 and the old version was dangerous

It described a workflow where each agent wrote a whole lang/<code>/<module>.php file with the Write tool. That destroys a partial locale. A locale file holding 358 of 2,049 keys is partial, not absent, and writing it wholesale discards what is there β€” including anything a native speaker has reviewed. French had 4,443 existing strings when this was noticed.

Everything below describes the pipeline that replaced it, in which no agent ever writes to lang/.


1. The shape of the job

One language is 24 files and roughly 12,550 keys. Too much for one agent, so it is fanned out β€” but the unit of work is a chunk (one screen), not a file, and the orchestrator does every destructive step.

i18n_chunk.php          β†’ what is missing, split into chunks an agent can do
   ↓  agents translate, writing TSV only, never touching lang/
i18n_repair_chunk.php   β†’ fix the one malformation that recurs
i18n_verify_chunk.php   β†’ the gate. Exit 0 = safe to merge
i18n_merge.php          β†’ the ONLY script that writes to lang/

i18n_gated_merge.php runs the last three together and cannot be invoked in a way that merges an unverified chunk β€” see Β§6.

πŸ”‘ The split of responsibility is the whole design. Translating agents read English and write a TSV. A bad agent therefore cannot damage anything. Every destructive step stays under one pair of hands.


2. Running it

# 1. What is missing, chunked, with per-locale byte budgets
php scripts/i18n_chunk.php fr --out C:/tmp/work

# 2. Fan out agents. Each reads scripts/i18n_BRIEF.md + a glossary + its chunks.
#    See Β§4 for the prompt, Β§5 for sizing.

# 3. Verify and merge β€” one command, cannot be got wrong
php scripts/i18n_gated_merge.php fr C:/tmp/work/chunks

# 4. Prove it
php scripts/i18n_audit.php --all
php scripts/i18n_drift.php nn nb      # only for close-language pairs
for f in lang/fr/*.php; do php -l "$f"; done

Re-running is safe at every step. i18n_merge.php never overwrites an existing value, and i18n_chunk.php recomputes the gap from scratch β€” so an interrupted run needs no bookkeeping. Just run it again.


3. πŸ”΄ Give every agent a glossary, not a rule about consistency

Extract the terms the locale already uses and hand the same file to every agent. This is what stops twelve agents inventing twelve words for "ticket", and it removes the reason an agent goes wandering through lang/<code>/ β€” which is what once made one read for twenty minutes and write nothing.

Take every short English value that already has a twin in the locale, keep only those used twice or more where one rendering holds at least 60% of uses. A 35% locale yields ~400 terms; a 75% locale yields ~650.

⚠️ The glossary fixes terms per ENGLISH STRING, not per concept. Malayalam's plural "analysts" mapped to a Sanskritised coinage while the far commoner singular mapped to a transliteration, so the filter accepted the minority form. Check the glossary for clashes with your own rules before launching.

πŸ”΄ Settle contested terms BEFORE a wave, never during. Pinning a correction into the instructions mid-run left Malayalam calling one role two things, because ten bundles were already in flight. The glossary is extracted first, so every clash in it is knowable up front.


4. The prompt

The hard rules live in scripts/i18n_BRIEF.md β€” the reusable artefact, which works for a human translator or any AI tool. Put per-locale material in an INSTRUCTIONS.<locale>.md that every agent for that locale reads, and keep each agent's own prompt to its file list plus what is specific to those chunks.

Forty lines of rules retyped per agent is forty chances for one to drift out of step. Generate the instruction files rather than hand-copying them.

Model: Sonnet. The failure modes that matter are instruction-following, and Β§6 catches every one mechanically. Use one model for a whole locale.

Two things every prompt needs, learned the hard way:

  • "Read the file for its key count β€” do not trust a number in your prompt." Three agents correctly flagged that stated counts did not match their files; the chunker was right and the orchestrator's numbers were stale. A count in a prompt is redundant with the file and can only mislead.
  • "Write each output file as soon as that chunk is done." A rate limit killed eleven agents mid-run; every file already written was recovered, every unwritten one was lost.

5. πŸ”΄ Sizing: four calibrations, each from a measured failure

The chunk size has been wrong four different ways. Every correction came from a real death, none from reasoning in advance.

5.1 Keys are not the unit β€” bytes are

A 597-key chunk of short labels completed in two minutes. A 358-key chunk of help prose killed its agent. Key count predicted neither.

5.2 The write-once rule is what actually buys the size

The fix for those deaths was not a smaller chunk. It was "write the file once, do not echo it back" β€” both deaths were agents producing the same content twice.

Proven deliberately: seven pt-BR bundles at ~45 KB, larger than a chunk that had died, with the rule. Six of six survived. That is why the rule lives in i18n_BRIEF.md, and why the chunker's comment says the limit assumes the brief is being followed. Translating without it? Halve the number.

5.3 ⚠️ The ceiling is on OUTPUT, and scripts expand differently

--maxbytes counts English bytes, but the 64,000-token limit is on what the agent writes. Measured output Γ· English bytes, over whole completed locales:

Locale Script Expansion
nb Latin Γ—1.04
ms Latin Γ—1.12
fr Latin Γ—1.18
uk Cyrillic Γ—1.64
gu Indic Γ—2.04
ml Indic Γ—2.42

A 45 KB English bundle is ~53 KB of French and ~109 KB of Malayalam. That is exactly how two Malayalam agents died at a size five other locales had completed comfortably. i18n_chunk.php now scales each locale's budget by its measured expansion β€” an Indic locale gets ~19,700 English bytes rather than 40,000.

πŸ”‘ Add a measured figure before running a new script family. An unknown locale gets the Latin default, which is only safe if it is Latin.

5.4 A section over budget is split at second-level key boundaries

tickets.settings is one section of 39 KB. settings.modals, settings.sla and settings.numbering are each a coherent screen. Cutting there keeps a dialogue whole; cutting at an arbitrary key count gives you half a screen in one register and half in another.


6. πŸ”΄ An agent's self-verification claim carries no weight

This is the single most important thing on the page. The translation work has been consistently good. It is specifically the self-checking that cannot be trusted, and that is why the gate is mechanical.

Five distinct classes of confident-but-wrong hand-back in one day:

The claim The reality
"blank-English key written with its tab, verified with awk" No tab. Twice, each naming the exact key and quoting the command it ran.
"147 lines written" 144. Three keys dropped outright.
"same keys, same order" Four keys reordered.
"all HTML tags preserved in the same order" Both <code> tags gone from one line.
"only forms.php uses tΓΊ" Eleven of twenty-five modules did. It had read five files.

⚠️ The two false blank-tab reports were indistinguishable from the two agents who genuinely had fixed it. You cannot tell from the report. Run the check.

πŸ”΄ i18n_gated_merge.php exists because verify; merge was typed instead of verify && merge and a failing chunk was merged. The harm was nil that time, by luck. A gate that depends on remembering a shell operator is not a gate.


7. πŸ”΄ Blank-English keys: the fault that is now impossible

Seven keys in the whole product have an empty English value. Eleven failures in one day were all on those seven.

The mechanism, found on the eleventh: the Write tool strips trailing whitespace, so a line whose correct content is key + TAB + nothing cannot survive being written. The agents were not careless β€” they verified content they had genuinely produced, and the tool discarded it on the way out. No prompt wording could ever have fixed it, which is what eleven attempts demonstrated.

So the pipeline stopped asking. i18n_chunk.php withholds those keys and i18n_merge.php fills them, because a blank English value has exactly one correct translation in every language. i18n_repair_chunk.php remains the net for chunks generated before that change, and also strips a leaked </content> tool-call fragment β€” the only tabless line it will ever delete, because a key never starts with <.


8. ⚠️ Close-language pairs drift invisibly

ms/id, nb/nn, and in principle any Portuguese, Spanish or Serbo-Croatian variants. The file parses, every key is present, every placeholder survives β€” and it is quietly the wrong language.

Use php scripts/i18n_drift.php nn nb, in both directions. And finish one side before starting the other: Malay completed with boleh 436 and bisa zero, so Indonesian now has a settled sibling to be warned against, rather than two half-done locales drifting toward each other.

πŸ”΄ Do not do this with grep. grep -E '\bfrΓ₯\b' finds 9 occurrences in lang/nn; the real number is 608. The word ends in a non-ASCII letter and grep does not treat Γ₯ as a word character, so the boundary never matches. A drift check that under-counts reports CLEAN on a drifting locale. i18n_drift.php matches with an explicit alphabet, reads values only so key names cannot produce false hits, and has a --self-test.

⚠️ Pick word pairs that are genuinely distinctive. Nynorsk's 7 bruker against 88 brukar looks like drift and is not β€” those are the verb, which nynorsk permits both ways. Listing it would flag correct Norwegian.


9. What 100% does NOT mean

Read i18n Audit before quoting a figure. In short: the audit compares a locale to lang/en, so a locale cannot be more complete than its source. A screen never wired to t() is missing from lang/en itself, and no locale comparison can ever reveal it.

Run php scripts/i18n_unwired.php as well. As of 2026-09-22 it reports 60 files, including the whole Checklists module UI and problem-management, which has no lang/en namespace at all.


10. The bits that are not translation

  • includes/i18n.php β€” SUPPORTED_LOCALES, the allow-list. Native name, not the English name: a Nynorsk reader offered only "Norwegian" cannot tell which of the two standards they are about to get. This is also what stops the locale parameter being used for path traversal.
  • Register the locale LAST on a live install. An absent file falls back cleanly; a registered locale with a half-written, unparseable file is a fatal on that module, because the per-key fallback only rescues a file that loads.
  • CHANGELOG.local.md, and the language count in README.md β€” a number in prose that nothing will ever tell you has gone stale.
  • Module names. FreeITSM keeps "Watchtower" and "War Room" in English in seven of eight Indian locales; Malayalam transliterates. Check the glossary before asserting a rule, and prefer what the locale already does.

The drift problem, which never goes away

100% is a snapshot, not a state. Every feature that adds lang/en keys un-does parity for every locale at once. scripts/i18n_gate.php reports it, and RELEASING.md step 3a asks before a tag is cut.

See also

⚠️ **GitHub.com Fallback** ⚠️