{"repo":"NousResearch/hermes-agent","public_id":"23943","display_label":"Missing locales/ directory in PyPI package distribution","summary":"Most members concern a recurring packaging bug where the locales/ i18n catalog directory is omitted from PyPI wheels and sdists, causing slash commands to display raw translation keys. A few adjacent items address related packaging fixes and one /model command behavior change.","subsystem":"environments","counts":{"members":17,"open_issues":1,"open_issues_distinct":1,"open_prs":1,"closed_issues":5,"merged_prs":1,"attempt_prs":10},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":38383,"merged_at":"2026-06-03T19:00:27+00:00"},"resolution":{"state":"partially_resolved","by":"pr-38383"},"freshness":{"state":"verified","last_verified_at":"2026-06-03T19:00:27+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.1388235294117647},"fix_edges":[{"issue_number":23943,"pr_number":38383,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-03T19:00:27+00:00"},{"issue_number":27632,"pr_number":38383,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-03T19:00:27+00:00"},{"issue_number":35374,"pr_number":38383,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-03T19:00:27+00:00"},{"issue_number":35595,"pr_number":38383,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-03T19:00:27+00:00"},{"issue_number":39105,"pr_number":38383,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-03T19:00:27+00:00"}],"members":[{"kind":"issue","number":23943,"title":"[Bug]: Nix package omits locales, causing raw i18n keys in gateway command replies","url":null,"state":"closed"},{"kind":"issue","number":27632,"title":"v0.13.0: locales/ directory missing from pip package — slash commands show raw i18n keys","url":null,"state":"closed"},{"kind":"issue","number":35374,"title":"PyPI wheel omits `locales/` catalogs, causing gateway slash commands to show raw i18n keys","url":null,"state":"closed"},{"kind":"issue","number":35595,"title":"v0.15: /model command returns structured field list instead of human-readable message","url":null,"state":"closed"},{"kind":"issue","number":38840,"title":"[bug] WeChat /usage returns i18n keys instead of rendered values","url":null,"state":"open"},{"kind":"issue","number":39105,"title":"locales/ directory missing from PyPI package — i18n returns raw keys","url":null,"state":"closed"},{"kind":"pr","number":27716,"title":"fix(pip): include locales in pip package via MANIFEST.in + HERMES_BUNDLED_LOCALES env var","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#38383","number":38383}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":30261,"title":"fix: include locales/ in pip package distribution","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#38383","number":38383}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":33841,"title":"fix(packaging): include locales/ in pip distribution","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#38383","number":38383}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":35429,"title":"fix(packaging): include locales/ i18n catalogs in wheel and sdist","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#38383","number":38383}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":35494,"title":"fix: include locales/ i18n catalogs in pip package distribution","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#38383","number":38383}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":35735,"title":"fix(i18n): include locales in pip package distribution and add fallback path (#35595)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#38383","number":38383}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":38383,"title":"fix(packaging): ship locales/ i18n catalogs in wheel, sdist, and Nix","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":[".github/workflows/tests.yml","MANIFEST.in","agent/i18n.py","nix/checks.nix","nix/hermes-agent.nix","pyproject.toml","tests/agent/test_i18n.py","tests/test_packaging_metadata.py","tests/test_wheel_locales_e2e.py"],"fix_hunks":[{"file":".github/workflows/tests.yml","hunks":["@@ -171,6 +171,11 @@ jobs:\n           source .venv/bin/activate\n           uv pip install -e \".[all,dev]\"\n \n+      - name: Packaged-wheel i18n smoke test\n+        run: |\n+          source .venv/bin/activate\n+          python -m pytest -m integration tests/test_wheel_locales_e2e.py -v\n+\n       - name: Run e2e tests\n         run: |\n           source .venv/bin/activate"]},{"file":"MANIFEST.in","hunks":["@@ -1,5 +1,6 @@\n graft skills\n graft optional-skills\n+graft locales\n # Bundled plugin manifests (plugin.yaml / plugin.yml). Without these the\n # PluginManager scan (hermes_cli/plugins.py) finds zero plugins on installs\n # built from the sdist (e.g. Homebrew, downstream packagers). package-data"]},{"file":"agent/i18n.py","hunks":["@@ -32,6 +32,7 @@\n \n import logging\n import os\n+import sysconfig\n import threading\n from functools import lru_cache\n from pathlib import Path","@@ -87,11 +88,54 @@\n def _locales_dir() -> Path:\n     \"\"\"Return the directory containing locale YAML files.\n \n-    Lives next to the repo root so both the bundled install and editable\n-    checkouts find it without PYTHONPATH gymnastics.\n+    Resolution order, first existing wins:\n+\n+    1. ``HERMES_BUNDLED_LOCALES`` env var -- set by the Nix wrapper (or any\n+       sealed-packaging system) to point at the installed catalog directory.\n+    2. ``<repo-root>/locales`` -- source checkouts and ``pip install -e .``,\n+       where the working tree sits next to ``agent/``.\n+    3. ``<sysconfig data|purelib|platlib>/locales`` -- pip wheel installs.\n+       setuptools ``data-files`` extracts ``locales/*.yaml`` under the\n+       interpreter's ``data`` scheme; the other schemes are checked as a\n+       safety net for nonstandard layouts.\n+\n+    Falling through to the source-style path (even when missing) keeps\n+    ``_load_catalog`` error messages informative -- it logs the path it\n+    looked at -- rather than raising.\n     \"\"\"\n-    # agent/i18n.py -> agent/ -> repo root\n-    return Path(__file__).resolve().parent.parent / \"locales\"\n+    override = os.getenv(\"HERMES_BUNDLED_LOCALES\", \"\").strip()\n+    if override:\n+        candidate = Path(override)\n+        if candidate.is_dir():\n+            return candidate\n+        logger.warning(\n+            \"HERMES_BUNDLED_LOCALES points to a non-directory path (%s); \"\n+            \"falling back to bundled/source locale resolution\",\n+            override,\n+        )\n+\n+    # agent/i18n.py -> agent/ -> repo root (source checkout, editable install)\n+    source_dir = Path(__file__).resolve().parent.parent / \"locales\"\n+    if source_dir.is_dir():\n+        return source_dir\n+\n+    # pip wheel install: data-files lands under the interpreter data scheme.\n+    # ``data`` (== sys.prefix in a venv) is where setuptools data-files extract\n+    # and is checked first. ``purelib``/``platlib`` (site-packages) are a safety\n+    # net for nonstandard layouts. NOTE: this does NOT cover ``pip install\n+    # --user`` (user scheme, ~/.local/locales) or ``pip install --target`` --\n+    # both are out of scope; see the plan header.\n+    for scheme in (\"data\", \"purelib\", \"platlib\"):\n+        raw = sysconfig.get_path(scheme)\n+        if not raw:\n+            continue\n+        candidate = Path(raw) / \"locales\"\n+        if candidate.is_dir():\n+            return candidate\n+\n+    # Last resort: return the source-style path so _load_catalog's catalog-missing\n+    # log (logger.debug \"i18n catalog missing for %s at %s\") stays informative.\n+    return source_dir\n \n \n def _normalize_lang(value: Any) -> str:"]},{"file":"nix/checks.nix","hunks":["@@ -160,6 +160,53 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)\n           echo \"ok\" > $out/result\n         '';\n \n+        # Verify bundled i18n locale catalogs are present and resolvable.\n+        # Regression for #23943 / #27632 / #35374 — sealed Nix venvs dropped\n+        # locales/, surfacing raw i18n keys like gateway.reset.header_default.\n+        bundled-locales = pkgs.runCommand \"hermes-bundled-locales\" { } ''\n+          set -e\n+          echo \"=== Checking bundled locales ===\"\n+          test -d ${hermes-agent}/share/hermes-agent/locales || (echo \"FAIL: locales directory missing\"; exit 1)\n+          echo \"PASS: locales directory exists\"\n+\n+          LOC_COUNT=$(find ${hermes-agent}/share/hermes-agent/locales -name \"*.yaml\" | wc -l)\n+          test \"$LOC_COUNT\" -ge 16 || (echo \"FAIL: expected >=16 catalogs, found $LOC_COUNT\"; exit 1)\n+          echo \"PASS: $LOC_COUNT locale catalogs found\"\n+\n+          test -f ${hermes-agent}/share/hermes-agent/locales/en.yaml || (echo \"FAIL: en.yaml missing\"; exit 1)\n+          echo \"PASS: en.yaml present\"\n+\n+          grep -q \"HERMES_BUNDLED_LOCALES\" ${hermes-agent}/bin/hermes || \\\n+            (echo \"FAIL: HERMES_BUNDLED_LOCALES not in wrapper\"; exit 1)\n+          echo \"PASS: HERMES_BUNDLED_LOCALES set in wrapper\"\n+\n+          echo \"=== Rendering via the wrapper override (HERMES_BUNDLED_LOCALES) ===\"\n+          export HOME=$(mktemp -d)\n+          RENDERED=$(cd \"$HOME\" && HERMES_BUNDLED_LOCALES=${hermes-agent}/share/hermes-agent/locales \\\n+            ${hermesVenv}/bin/python3 -c \"from agent import i18n; print(i18n.t('gateway.reset.header_default', lang='en'))\")\n+          echo \"rendered: $RENDERED\"\n+          test \"$RENDERED\" != \"gateway.reset.header_default\" || (echo \"FAIL: i18n returned the raw key with HERMES_BUNDLED_LOCALES set\"; exit 1)\n+          echo \"PASS: i18n renders a human string via the wrapper override\"\n+\n+          # Defense-in-depth check: the sealed venv must ALSO resolve catalogs\n+          # with NO env var, via the wheel's setuptools data-files materialized\n+          # into the venv data scheme. If a future uv2nix bump drops data-files,\n+          # the wrapper override above would mask the regression at runtime while\n+          # `pip install`/other sealed paths silently break — this catches it.\n+          echo \"=== Rendering WITHOUT the env var (data-files materialization) ===\"\n+          BARE_DIR=$(cd \"$HOME\" && ${hermesVenv}/bin/python3 -c \"from agent import i18n; print(i18n._locales_dir())\")\n+          BARE=$(cd \"$HOME\" && ${hermesVenv}/bin/python3 -c \"from agent import i18n; print(i18n.t('gateway.reset.header_default', lang='en'))\")\n+          echo \"resolved dir (no env var): $BARE_DIR\"\n+          echo \"rendered: $BARE\"\n+          test \"$BARE\" != \"gateway.reset.header_default\" || \\\n+            (echo \"FAIL: sealed venv could not resolve locales without HERMES_BUNDLED_LOCALES — data-files materialization regressed\"; exit 1)\n+          echo \"PASS: sealed venv resolves locales via data-files without the env var\"\n+\n+          echo \"=== All bundled locales checks passed ===\"\n+          mkdir -p $out\n+          echo \"ok\" > $out/result\n+        '';\n+\n         # Verify bundled TUI is present and compiled\n         bundled-tui = pkgs.runCommand \"hermes-bundled-tui\" { } ''\n           set -e"]},{"file":"nix/hermes-agent.nix","hunks":["@@ -67,6 +67,21 @@ let\n     filter = path: _type: !(lib.hasInfix \"/__pycache__/\" path);\n   };\n \n+  # i18n locale catalogs (locales/*.yaml). Shipped into the store and pointed\n+  # at by HERMES_BUNDLED_LOCALES so the wrapped binary always resolves human\n+  # strings instead of raw i18n keys (#23943 / #27632 / #35374).\n+  #\n+  # Defense-in-depth, not load-bearing: the wheel already declares locales/ as\n+  # setuptools data-files, so uv2nix materializes them into the venv's data\n+  # scheme and agent/i18n.py resolves them with no env var. The wrapper override\n+  # pins the store path so a future uv2nix change that drops data-files can't\n+  # silently ship raw keys via `nix build` (checks don't run on a plain build).\n+  # The bundled-locales flake check verifies BOTH paths independently.\n+  #\n+  # Plain cleanSource (no __pycache__ filter): locales/ is bare *.yaml, never\n+  # compiled, so it never carries a __pycache__ dir to exclude.\n+  bundledLocales = lib.cleanSource ../locales;\n+\n   runtimeDeps = [\n     nodejs\n     ripgrep","@@ -151,6 +166,7 @@ stdenv.mkDerivation (finalAttrs: {\n     mkdir -p $out/share/hermes-agent $out/bin\n     cp -r ${bundledSkills} $out/share/hermes-agent/skills\n     cp -r ${bundledPlugins} $out/share/hermes-agent/plugins\n+    cp -r ${bundledLocales} $out/share/hermes-agent/locales\n     cp -r ${hermesWeb} $out/share/hermes-agent/web_dist\n \n     mkdir -p $out/ui-tui","@@ -162,6 +178,7 @@ stdenv.mkDerivation (finalAttrs: {\n           --suffix PATH : \"${runtimePath}\" \\\n           --set HERMES_BUNDLED_SKILLS $out/share/hermes-agent/skills \\\n           --set HERMES_BUNDLED_PLUGINS $out/share/hermes-agent/plugins \\\n+          --set HERMES_BUNDLED_LOCALES $out/share/hermes-agent/locales \\\n           --set HERMES_WEB_DIST $out/share/hermes-agent/web_dist \\\n           --set HERMES_TUI_DIR $out/ui-tui \\\n           --set HERMES_PYTHON ${hermesVenv}/bin/python3 \\"]},{"file":"pyproject.toml","hunks":["@@ -230,6 +230,15 @@ hermes-acp = \"acp_adapter.entry:main\"\n [tool.setuptools]\n py-modules = [\"run_agent\", \"model_tools\", \"toolsets\", \"batch_runner\", \"trajectory_compressor\", \"toolset_distributions\", \"cli\", \"hermes_bootstrap\", \"hermes_constants\", \"hermes_state\", \"hermes_time\", \"hermes_logging\", \"utils\", \"mcp_serve\"]\n \n+[tool.setuptools.data-files]\n+# i18n catalogs. locales/ is a bare data directory (no __init__.py), so it is\n+# neither a package (packages.find) nor package-data (which attaches to a\n+# package). data-files ships it in the wheel; MANIFEST.in `graft locales`\n+# ships it in the sdist. Without this, sealed installs (pip wheel, Nix store\n+# venv) drop the catalogs and gateway/CLI commands surface raw i18n keys like\n+# `gateway.reset.header_default` (#27632, #35374, #23943).\n+locales = [\"locales/*.yaml\"]\n+\n [tool.setuptools.package-data]\n hermes_cli = [\"web_dist/**/*\", \"tui_dist/**/*\", \"scripts/install.sh\", \"scripts/install.ps1\"]\n gateway = [\"assets/**/*\"]"]},{"file":"tests/agent/test_i18n.py","hunks":["@@ -167,3 +167,63 @@ def test_t_missing_key_in_non_english_falls_back_to_english(tmp_path, monkeypatc\n def test_t_unknown_language_uses_english():\n     \"\"\"Unknown lang codes normalize to English, not to a key-path fallback.\"\"\"\n     assert i18n.t(\"approval.denied\", lang=\"klingon\") == i18n.t(\"approval.denied\", lang=\"en\")\n+\n+\n+# ---------------------------------------------------------------------------\n+# _locales_dir resolution ladder -- regression for #23943 / #27632 / #35374.\n+# Sealed installs (Nix store venv, pip wheel) have no source tree next to\n+# agent/, so _locales_dir must resolve via env override or the data scheme.\n+# ---------------------------------------------------------------------------\n+\n+def test_locales_dir_env_override_used_when_dir_exists(tmp_path, monkeypatch):\n+    \"\"\"HERMES_BUNDLED_LOCALES wins when it points at a real directory.\"\"\"\n+    bundled = tmp_path / \"bundled-locales\"\n+    bundled.mkdir()\n+    monkeypatch.setenv(\"HERMES_BUNDLED_LOCALES\", str(bundled))\n+    assert i18n._locales_dir() == bundled\n+\n+\n+def test_locales_dir_env_override_ignored_when_missing(tmp_path, monkeypatch):\n+    \"\"\"A bogus HERMES_BUNDLED_LOCALES falls through to source/wheel resolution\n+    instead of returning a path that doesn't exist.\"\"\"\n+    monkeypatch.setenv(\"HERMES_BUNDLED_LOCALES\", str(tmp_path / \"does-not-exist\"))\n+    result = i18n._locales_dir()\n+    assert result != tmp_path / \"does-not-exist\"\n+    # In a source checkout this is the repo-root locales dir.\n+    assert result.name == \"locales\"\n+\n+\n+def test_locales_dir_falls_back_to_data_scheme(tmp_path, monkeypatch):\n+    \"\"\"When neither the env override nor a source-adjacent locales/ exists,\n+    _locales_dir uses sysconfig's data scheme (the pip-wheel layout).\"\"\"\n+    import sysconfig\n+\n+    # No env override.\n+    monkeypatch.delenv(\"HERMES_BUNDLED_LOCALES\", raising=False)\n+\n+    # Force the source-adjacent path to a location with no locales/ dir.\n+    fake_pkg = tmp_path / \"site-packages\" / \"agent\"\n+    fake_pkg.mkdir(parents=True)\n+    monkeypatch.setattr(i18n, \"__file__\", str(fake_pkg / \"i18n.py\"))\n+\n+    # Stand up a fake data scheme containing locales/.\n+    data_root = tmp_path / \"data-scheme\"\n+    (data_root / \"locales\").mkdir(parents=True)\n+    real_get_path = sysconfig.get_path\n+\n+    def fake_get_path(name, *args, **kwargs):\n+        if name == \"data\":\n+            return str(data_root)\n+        return real_get_path(name, *args, **kwargs)\n+\n+    monkeypatch.setattr(i18n.sysconfig, \"get_path\", fake_get_path)\n+\n+    assert i18n._locales_dir() == data_root / \"locales\"\n+\n+\n+def test_t_resolves_real_string_in_source_checkout():\n+    \"\"\"Sanity: in the test environment (a source checkout) t() must return a\n+    human string, never the bare key path. Guards against catalog-load\n+    regressions independent of packaging.\"\"\"\n+    assert i18n.t(\"gateway.reset.header_default\", lang=\"en\") != \"gateway.reset.header_default\"\n+    assert i18n.t(\"gateway.status.header\", lang=\"en\") != \"gateway.status.header\""]},{"file":"tests/test_packaging_metadata.py","hunks":["@@ -200,3 +200,29 @@ def test_locked_starlette_is_not_vulnerable_to_cve_2026_48710():\n             f\"floor {'.'.join(map(str, _STARLETTE_CVE_FLOOR))} — regenerate the \"\n             f\"lockfile after bumping the pin\"\n         )\n+\n+\n+def test_locale_catalogs_ship_in_both_wheel_and_sdist():\n+    \"\"\"Regression test for #27632 / #35374 / #23943.\n+\n+    locales/ is a bare data directory (no __init__.py), so it is invisible to\n+    packages.find and to package-data (which attaches to a package). It must be\n+    declared as setuptools data-files (wheel) AND grafted in MANIFEST.in\n+    (sdist). Without both, sealed installs drop the catalogs and gateway/CLI\n+    commands surface raw i18n keys like `gateway.reset.header_default`.\n+    \"\"\"\n+    data = tomllib.loads((REPO_ROOT / \"pyproject.toml\").read_text(encoding=\"utf-8\"))\n+    data_files = data[\"tool\"][\"setuptools\"].get(\"data-files\", {})\n+    assert data_files.get(\"locales\") == [\"locales/*.yaml\"], (\n+        \"pyproject [tool.setuptools.data-files] must declare \"\n+        'locales = [\"locales/*.yaml\"] so the wheel ships i18n catalogs'\n+    )\n+\n+    manifest = (REPO_ROOT / \"MANIFEST.in\").read_text(encoding=\"utf-8\")\n+    assert \"graft locales\" in manifest, (\n+        \"MANIFEST.in must `graft locales` so the sdist ships i18n catalogs\"\n+    )\n+\n+    # Every on-disk catalog has the .yaml extension the globs above match.\n+    on_disk = list((REPO_ROOT / \"locales\").glob(\"*.yaml\"))\n+    assert on_disk, \"expected locales/*.yaml catalogs on disk\""]},{"file":"tests/test_wheel_locales_e2e.py","hunks":["@@ -0,0 +1,137 @@\n+\"\"\"End-to-end: a built wheel, installed without a source tree, must resolve\n+i18n catalogs and render human strings — not raw key paths.\n+\n+This is the test that would have caught #27632 / #35374 / #23943. Metadata\n+unit tests (test_packaging_metadata.py) prove the glob is declared; this proves\n+the runtime actually finds the catalogs after a real pip install.\n+\n+This lives in tests/ (NOT tests/e2e/) so it is collected by the dedicated CI\n+step in Task 9, not by the existing `python -m pytest tests/e2e/` runner.\n+\n+Assumption: `from agent import i18n` must import with only stdlib + pyyaml\n+available (the test installs the wheel --no-deps + pyyaml). agent/__init__.py's\n+jiter preload swallows ImportError, and i18n.py imports yaml lazily inside\n+_load_catalog, so this holds today. If i18n.py ever gains a top-level non-stdlib\n+import, add it to the pip install line below.\n+\n+Marked `integration` because it shells out to `uv build` + `venv` + `pip` and\n+takes ~15-30s. Run with: pytest -m integration tests/test_wheel_locales_e2e.py\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import glob\n+import os\n+import subprocess\n+import sys\n+import tarfile\n+import venv\n+from pathlib import Path\n+\n+import pytest\n+\n+REPO_ROOT = Path(__file__).resolve().parents[1]\n+\n+\n[redacted:email]\n[redacted:email](300)  # overrides the global --timeout=30; cold-CI wheel build + venv + pip can exceed it\n+def test_installed_wheel_renders_i18n_strings(tmp_path):\n+    # 1. Build the wheel from the current tree.\n+    wheel_dir = tmp_path / \"wheel\"\n+    build = subprocess.run(\n+        [\"uv\", \"build\", \"--wheel\", \"--out-dir\", str(wheel_dir), \".\"],\n+        cwd=REPO_ROOT,\n+        capture_output=True,\n+        text=True,\n+        timeout=600,\n+    )\n+    assert build.returncode == 0, f\"uv build failed:\\n{build.stderr}\"\n+    wheels = glob.glob(str(wheel_dir / \"*.whl\"))\n+    assert wheels, \"no wheel produced\"\n+    wheel = wheels[0]\n+\n+    # 2. Fresh venv, install the wheel WITHOUT deps (we only exercise i18n,\n+    #    which needs pyyaml). --force-reinstall guards against pip's\n+    #    same-version no-op.\n+    venv_dir = tmp_path / \"venv\"\n+    venv.create(venv_dir, with_pip=True)\n+    vpy = venv_dir / \"bin\" / \"python\"\n+    subprocess.run([str(vpy), \"-m\", \"pip\", \"install\", \"-q\", \"pyyaml\"], check=True, timeout=300)\n+    subprocess.run(\n+        [str(vpy), \"-m\", \"pip\", \"install\", \"-q\", \"--no-deps\", \"--force-reinstall\", wheel],\n+        check=True,\n+        timeout=300,\n+    )\n+\n+    # 3. Run from a directory that is NOT the source tree, with a clean env\n+    #    (no PYTHONPATH leaking the repo, no HERMES_BUNDLED_LOCALES).\n+    probe = (\n+        \"from agent import i18n;\"\n+        \"import sys;\"\n+        \"r = i18n.t('gateway.reset.header_default', lang='en');\"\n+        \"s = i18n.t('gateway.status.header', lang='en');\"\n+        \"print(repr(r)); print(repr(s));\"\n+        \"sys.exit(0 if (r != 'gateway.reset.header_default' \"\n+        \"and s != 'gateway.status.header') else 1)\"\n+    )\n+    env = {k: v for k, v in os.environ.items() if k not in (\"PYTHONPATH\", \"HERMES_BUNDLED_LOCALES\")}\n+    env[\"PATH\"] = f\"{venv_dir / 'bin'}:{env['PATH']}\"\n+    env[\"VIRTUAL_ENV\"] = str(venv_dir)\n+    run = subprocess.run(\n+        [str(vpy), \"-c\", probe],\n+        cwd=str(tmp_path),  # NOT the repo root\n+        capture_output=True,\n+        text=True,\n+        env=env,\n+        timeout=120,\n+    )\n+    assert run.returncode == 0, (\n+        \"installed wheel returned raw i18n keys instead of human strings:\\n\"\n+        f\"stdout: {run.stdout}\\nstderr: {run.stderr}\"\n+    )\n+\n+\n[redacted:email]\n[redacted:email](300)  # overrides the global --timeout=30; cold-CI sdist build can exceed it\n+def test_built_sdist_ships_locale_catalogs(tmp_path):\n+    \"\"\"The sdist must carry locales/ too.\n+\n+    The wheel is covered above; the sdist is a separately shipped artifact\n+    (PyPI, and the form distro/Homebrew packagers build from). MANIFEST.in\n+    `graft locales` is what puts the catalogs in the tarball — a stale graft or\n+    a setuptools change would pass the metadata unit test (which only inspects\n+    the declaration) while the actual artifact regresses. This inspects the\n+    real tarball so that path can't rot silently. Closes the sdist half of\n+    #27632 / #35374 / #23943.\n+    \"\"\"\n+    sdist_dir = tmp_path / \"sdist\"\n+    build = subprocess.run(\n+        [\"uv\", \"build\", \"--sdist\", \"--out-dir\", str(sdist_dir), \".\"],\n+        cwd=REPO_ROOT,\n+        capture_output=True,\n+        text=True,\n+        timeout=600,\n+    )\n+    assert build.returncode == 0, f\"uv build --sdist failed:\\n{build.stderr}\"\n+    tarballs = glob.glob(str(sdist_dir / \"*.tar.gz\"))\n+    assert tarballs, \"no sdist produced\"\n+\n+    with tarfile.open(tarballs[0]) as tf:\n+        # Members are prefixed with the sdist root dir, e.g.\n+        # hermes_agent-0.15.1/locales/en.yaml — match on the suffix.\n+        catalogs = [m for m in tf.getnames() if \"/locales/\" in m and m.endswith(\".yaml\")]\n+\n+    # Compare against the canonical language list rather than a hardcoded floor\n+    # so adding/removing a catalog updates the guard automatically and a dropped\n+    # catalog (not just a fully-empty graft) trips it.\n+    from agent.i18n import SUPPORTED_LANGUAGES\n+\n+    expected = len(SUPPORTED_LANGUAGES)\n+    assert len(catalogs) == expected, (\n+        f\"sdist shipped {len(catalogs)} locale catalogs, expected {expected} \"\n+        f\"({len(SUPPORTED_LANGUAGES)} supported languages) — check `graft \"\n+        \"locales` in MANIFEST.in\"\n+    )\n+    assert any(m.endswith(\"/locales/en.yaml\") for m in catalogs), (\n+        f\"sdist missing locales/en.yaml; shipped: {catalogs[:5]}\"\n+    )\n"]}]},{"kind":"pr","number":38947,"title":"fix(i18n): keep /usage readable without locale catalog","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":39151,"title":"fix(i18n): include locales in PyPI package and add fallback paths","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#38383","number":38383}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":40253,"title":"fix(security): replace sys.path.insert with importlib.util for tts_normalize","url":null,"state":"closed","close_reason":{"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":49696,"title":"fix(packaging): ship optional-mcps/unreal-engine manifest in the wheel","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}