tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1433Shell 145Markdown 87Rust 852973 Definitions

Fase 6 — traductor LLM real (Claude API) detrás de feature llm-claude

`ClaudeTranslator` implementa el trait `IntentTranslator` igual que el
`MockTranslator`, así que el `Orchestrator` no cambia: se traduce una
intención NL a un `.swm` válido vía `/v1/messages`.

Diseño:
- Síncrono (ureq + rustls), consistente con `AgentClient`. Tokio
  entraría sólo si el resto del crate lo pidiera.
- Trait `HttpClient` inyectable ⇒ tests sin red contra una fake que
  captura el request y devuelve un body pre-armado.
- Modelo por defecto: `claude-opus-4-8` (Opus 4.8, el más capaz al día
  de hoy). Adaptive thinking + `effort=high` por defecto. Override por
  env (`HAMMER_LLM_MODEL`, `HAMMER_LLM_EFFORT`, `HAMMER_LLM_BASE_URL`).
- System prompt documenta el shape exacto del `.swm` (4 variantes de
  mutación) y obliga JSON puro. Parseamos con `serde_json::from_str::
  <Swm>` + `verify_schema()` como gate adicional. Manejamos `refusal`,
  error envelopes de la API y code-fence markdown.

Activación:
- Sin feature: el módulo declara los tipos pero `ClaudeTranslator::new`
  está bajo cfg. El binario compila sin red.
- Con `--features llm-claude`: trae `ureq` con rustls, y la CLI activa
  `hammer ai --llm`.

CLI:
- `hammer ai` gana `--llm` (mutuamente excluyente con `--catalog`).
  Refactor de `run_ai` en helpers `run_with_mock_translator` /
  `run_with_llm_translator` para mantener legible el dispatch.
- Sin la feature, `--llm` falla con un mensaje claro pidiendo
  recompilar.

Tests (7 unit): happy path con verificación de URL/headers/body,
unwrap de markdown, `refusal`, error envelope, SWM mal formado,
verify_schema, helpers de strip_code_fence.

59862005396c138e8f45908397c62e2845776f57 · by Sergio · (4 months ago) · parent f769d9173 · browse the tree at this commit

Semantic delta · 36

crates/hammer-agent/src/lib.rs

crates/hammer-cli/src/main.rs

7 files · +1110 −63

diff --git a/Cargo.lock b/Cargo.lock
index 22130192..b844b643 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -47,7 +47,7 @@ version = "1.1.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
 dependencies = [
- "windows-sys",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -58,7 +58,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
 dependencies = [
  "anstyle",
  "once_cell_polyfill",
- "windows-sys",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -232,6 +232,17 @@ dependencies = [
  "crypto-common",
 ]
 
+[[package]]
+name = "displaydoc"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "equivalent"
 version = "1.0.2"
@@ -245,7 +256,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
 dependencies = [
  "libc",
- "windows-sys",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -266,6 +277,15 @@ version = "0.1.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
 
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
 [[package]]
 name = "generic-array"
 version = "0.14.7"
@@ -276,6 +296,17 @@ dependencies = [
  "version_check",
 ]
 
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
 [[package]]
 name = "getrandom"
 version = "0.4.2"
@@ -304,6 +335,7 @@ dependencies = [
  "tempfile",
  "thiserror",
  "tracing",
+ "ureq",
 ]
 
 [[package]]
@@ -429,12 +461,115 @@ version = "0.4.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
 
+[[package]]
+name = "icu_collections"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
 [[package]]
 name = "id-arena"
 version = "2.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
 
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
 [[package]]
 name = "indexmap"
 version = "2.14.0"
@@ -483,6 +618,12 @@ version = "0.12.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
 
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
 [[package]]
 name = "log"
 version = "0.4.32"
@@ -532,7 +673,7 @@ version = "0.50.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
 dependencies = [
- "windows-sys",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -547,12 +688,27 @@ version = "1.70.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
 
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
 [[package]]
 name = "pin-project-lite"
 version = "0.2.17"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
 
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
 [[package]]
 name = "prettyplease"
 version = "0.2.37"
@@ -604,6 +760,20 @@ version = "0.8.10"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
 
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
 [[package]]
 name = "rustix"
 version = "1.1.4"
@@ -614,7 +784,42 @@ dependencies = [
  "errno",
  "libc",
  "linux-raw-sys",
- "windows-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
+dependencies = [
+ "log",
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
+dependencies = [
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
 ]
 
 [[package]]
@@ -726,12 +931,24 @@ version = "1.15.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
 
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
 [[package]]
 name = "strsim"
 version = "0.11.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
 
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
 [[package]]
 name = "syn"
 version = "2.0.117"
@@ -743,6 +960,17 @@ dependencies = [
  "unicode-ident",
 ]
 
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "tempfile"
 version = "3.27.0"
@@ -750,10 +978,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
 dependencies = [
  "fastrand",
- "getrandom",
+ "getrandom 0.4.2",
  "once_cell",
  "rustix",
- "windows-sys",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -785,6 +1013,16 @@ dependencies = [
  "cfg-if",
 ]
 
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
 [[package]]
 name = "toml"
 version = "0.8.23"
@@ -911,6 +1149,47 @@ version = "0.2.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
 
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "ureq"
+version = "2.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
+dependencies = [
+ "base64",
+ "log",
+ "once_cell",
+ "rustls",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "url",
+ "webpki-roots 0.26.11",
+]
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
 [[package]]
 name = "utf8parse"
 version = "0.2.2"
@@ -929,6 +1208,12 @@ version = "0.9.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
 
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
 [[package]]
 name = "wasip2"
 version = "1.0.3+wasi-0.2.9"
@@ -981,12 +1266,39 @@ dependencies = [
  "semver",
 ]
 
+[[package]]
+name = "webpki-roots"
+version = "0.26.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
+dependencies = [
+ "webpki-roots 1.0.7",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
+dependencies = [
+ "rustls-pki-types",
+]
+
 [[package]]
 name = "windows-link"
 version = "0.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
 
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets",
+]
+
 [[package]]
 name = "windows-sys"
 version = "0.61.2"
@@ -996,6 +1308,70 @@ dependencies = [
  "windows-link",
 ]
 
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
 [[package]]
 name = "winnow"
 version = "0.7.15"
@@ -1099,6 +1475,95 @@ dependencies = [
  "wasmparser",
 ]
 
+[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "zmij"
 version = "1.0.21"
diff --git a/crates/hammer-agent/Cargo.toml b/crates/hammer-agent/Cargo.toml
index 8af02c68..e05c9ec7 100644
--- a/crates/hammer-agent/Cargo.toml
+++ b/crates/hammer-agent/Cargo.toml
@@ -7,6 +7,12 @@ authors.workspace = true
 repository.workspace = true
 description = "Cliente del bus + bucle agéntico (plan/build/try/verify/propose) para la IA."
 
+[features]
+default = []
+# Traductor LLM real contra Claude API (anthropic-version 2023-06-01).
+# Trae `ureq` con rustls; si no la habilitas, el crate sigue siendo dependency-free de red.
+llm-claude = ["dep:ureq"]
+
 [dependencies]
 hammer-core.workspace = true
 hammer-overlay.workspace = true
@@ -16,6 +22,7 @@ serde.workspace = true
 serde_json.workspace = true
 serde_yaml.workspace = true
 tracing.workspace = true
+ureq = { version = "2", optional = true, default-features = false, features = ["tls", "json"] }
 
 [dev-dependencies]
 tempfile.workspace = true
diff --git a/crates/hammer-agent/src/lib.rs b/crates/hammer-agent/src/lib.rs
index f14612e3..5d34ff9f 100644
--- a/crates/hammer-agent/src/lib.rs
+++ b/crates/hammer-agent/src/lib.rs
@@ -20,10 +20,12 @@
 pub mod client;
 pub mod orchestrator;
 pub mod translator;
+pub mod translator_claude;
 
 pub use client::{AgentClient, ClientError, Welcome};
 pub use orchestrator::{Orchestrator, Proposal, VerifyCheck};
 pub use translator::{IntentCatalog, IntentTranslator, MockTranslator, SystemContext, TranslateError};
+pub use translator_claude::{ClaudeConfig, ClaudeTranslator, HttpClient};
 
 #[derive(Debug, thiserror::Error)]
 pub enum Error {
diff --git a/crates/hammer-agent/src/translator_claude.rs b/crates/hammer-agent/src/translator_claude.rs
new file mode 100644
index 00000000..0f146d2f
--- /dev/null
+++ b/crates/hammer-agent/src/translator_claude.rs
@@ -0,0 +1,473 @@
+//! Traductor LLM real contra la Claude API (anthropic-version `2023-06-01`).
+//!
+//! Implementa [`IntentTranslator`] exactamente como el `MockTranslator`, así que el bucle
+//! del [`Orchestrator`](crate::Orchestrator) no cambia. Lo que ofrece de extra es que el
+//! contenido de la intención NL llega a un modelo real (Claude Opus 4.8 por defecto) que
+//! produce el `.swm` directamente — no se requiere catálogo pre-armado.
+//!
+//! Diseño:
+//!
+//! - **Síncrono** y mínimo: usamos `ureq` (gated detrás del feature `llm-claude`) porque
+//!   el cliente del bus también es síncrono. Tokio entraría sólo si el resto del crate lo
+//!   pidiera.
+//! - **HTTP inyectable**: el trait [`HttpClient`] permite tests sin red contra una fake
+//!   que devuelve respuestas pre-armadas y verifica el cuerpo enviado.
+//! - **Modelo por defecto**: `claude-opus-4-8`, con adaptive thinking activado y
+//!   `effort: "high"`. Cambiar el modelo es modificar el `ClaudeConfig`.
+//! - **Schema enforcement**: el modelo recibe un system prompt que documenta el shape
+//!   exacto del `Swm` con ejemplos; pedimos respuesta JSON pura. El parser local
+//!   (`serde_json::from_str::<Swm>`) actúa como gate adicional, y `verify_schema()`
+//!   se llama antes de devolver. Si el modelo divaga, el caller obtiene un
+//!   `TranslateError::Model` con el cuerpo bruto para debugging.
+//!
+//! El bucle agéntico (con o sin auto-reparación) reusa el mismo `Orchestrator`. La única
+//! diferencia es la construcción del traductor:
+//!
+//! ```ignore
+//! let cfg = ClaudeConfig::from_env()?;
+//! let t = ClaudeTranslator::new(cfg);
+//! let orch = Orchestrator::new(t, base, apply, compile);
+//! orch.run("instala grep con regex Perl por defecto")?;
+//! ```
+
+use crate::translator::{IntentTranslator, SystemContext, TranslateError};
+use hammer_core::Swm;
+
+/// Inyección HTTP. La implementación por defecto ([`UreqHttp`]) usa `ureq` síncrono;
+/// los tests pasan una fake.
+pub trait HttpClient: Send + Sync {
+    fn post_json(
+        &self,
+        url: &str,
+        headers: &[(&str, &str)],
+        body: &str,
+    ) -> Result<String, TranslateError>;
+}
+
+#[cfg(feature = "llm-claude")]
+pub struct UreqHttp;
+
+#[cfg(feature = "llm-claude")]
+impl HttpClient for UreqHttp {
+    fn post_json(
+        &self,
+        url: &str,
+        headers: &[(&str, &str)],
+        body: &str,
+    ) -> Result<String, TranslateError> {
+        let mut req = ureq::post(url);
+        for (k, v) in headers {
+            req = req.set(k, v);
+        }
+        let resp = req
+            .send_string(body)
+            .map_err(|e| TranslateError::Model(format!("http: {e}")))?;
+        resp.into_string()
+            .map_err(|e| TranslateError::Model(format!("body: {e}")))
+    }
+}
+
+#[derive(Debug, Clone)]
+pub struct ClaudeConfig {
+    pub api_key: String,
+    pub model: String,
+    pub max_tokens: u32,
+    pub base_url: String,
+    /// Nivel de esfuerzo del modelo (`low|medium|high|max`). Default `high`.
+    pub effort: String,
+}
+
+impl ClaudeConfig {
+    /// Construye desde env vars: `ANTHROPIC_API_KEY` (obligatoria), `HAMMER_LLM_MODEL`,
+    /// `HAMMER_LLM_BASE_URL`, `HAMMER_LLM_EFFORT`.
+    pub fn from_env() -> Result<Self, TranslateError> {
+        let api_key = std::env::var("ANTHROPIC_API_KEY").map_err(|_| {
+            TranslateError::Model(
+                "ANTHROPIC_API_KEY no está en el entorno; exporta tu key o usa MockTranslator"
+                    .into(),
+            )
+        })?;
+        Ok(Self {
+            api_key,
+            model: std::env::var("HAMMER_LLM_MODEL")
+                .unwrap_or_else(|_| "claude-opus-4-8".into()),
+            max_tokens: 8192,
+            base_url: std::env::var("HAMMER_LLM_BASE_URL")
+                .unwrap_or_else(|_| "https://api.anthropic.com".into()),
+            effort: std::env::var("HAMMER_LLM_EFFORT").unwrap_or_else(|_| "high".into()),
+        })
+    }
+}
+
+pub struct ClaudeTranslator {
+    cfg: ClaudeConfig,
+    http: Box<dyn HttpClient>,
+}
+
+impl ClaudeTranslator {
+    /// Constructor por defecto: usa [`UreqHttp`] (sólo disponible con feature `llm-claude`).
+    #[cfg(feature = "llm-claude")]
+    pub fn new(cfg: ClaudeConfig) -> Self {
+        Self { cfg, http: Box::new(UreqHttp) }
+    }
+
+    /// Constructor con HTTP inyectado. Útil para tests y para apuntar a un proxy/mock.
+    pub fn with_http(cfg: ClaudeConfig, http: Box<dyn HttpClient>) -> Self {
+        Self { cfg, http }
+    }
+}
+
+const SYSTEM_PROMPT: &str = r#"Eres un traductor que convierte intenciones humanas en lenguaje natural a manifiestos `.swm` (Software Mutación) del sistema hammer. El receptor parseará tu respuesta con serde_json (Rust) y rechazará cualquier output que no sea JSON puro válido.
+
+# Shape del Swm (versión 1)
+
+```json
+{
+  "swm_version": 1,
+  "base": {
+    "distro_version": "<copia el distro_version del SystemContext>",
+    "pins": {}
+  },
+  "mutations": [ ... ]
+}
+```
+
+# Variantes de mutación
+
+1. config_edit — edita un archivo de texto vía unified diff.
+   {"type":"config_edit","file":"/abs/path","inline_diff":"- old line\n+ new line\n"}
+   - `file` debe ser ruta absoluta.
+   - `inline_diff` usa el formato de diff unified clásico (líneas `-`, `+` y espacio).
+
+2. file_drop — coloca un archivo en el sistema.
+   {"type":"file_drop","path":"/abs/path","content_hash":"b3:<hex>","content_b64":"<base64>"}
+   - `content_hash` es BLAKE3 del contenido descodificado, con el prefijo `b3:`.
+   - SOLO emite `file_drop` si puedes calcular el hash correcto. Si no, salta esta variante.
+
+3. source_patch — compila desde fuente y la inyecta.
+   {"type":"source_patch","repo":"git://...","commit":"<sha>","build":{"compiler":"zig-cc","target":"x86_64-linux-musl","link":"static","flags":[]},"target_bin":"/usr/bin/foo"}
+   - `target_bin` ruta absoluta del binario producido.
+
+4. init_rule — registra un servicio.
+   {"type":"init_rule","action":"start","service":"nginx","command":"/usr/sbin/nginx -g 'daemon off;'"}
+
+# Reglas
+
+- TODAS las rutas son absolutas (comienzan con `/`).
+- Copia `distro_version` y `pins` desde el SystemContext que te pase el usuario.
+- Si la intención es ambigua o requiere información que no tienes (p. ej. el hash de un binario que no puedes computar), prefiere `config_edit` o `source_patch` sobre `file_drop`.
+- Responde EXCLUSIVAMENTE con el JSON del Swm. Sin texto antes, sin texto después, sin markdown, sin backticks. La primera línea debe ser `{` y la última `}`.
+"#;
+
+impl IntentTranslator for ClaudeTranslator {
+    fn translate(&self, intent: &str, ctx: &SystemContext) -> Result<Swm, TranslateError> {
+        let body = build_request_body(&self.cfg, intent, ctx)?;
+        let url = format!(
+            "{}/v1/messages",
+            self.cfg.base_url.trim_end_matches('/')
+        );
+        let auth = format!("{}", self.cfg.api_key);
+        let headers: [(&str, &str); 3] = [
+            ("x-api-key", auth.as_str()),
+            ("anthropic-version", "2023-06-01"),
+            ("content-type", "application/json"),
+        ];
+        let raw = self.http.post_json(&url, &headers, &body)?;
+        parse_response(&raw)
+    }
+}
+
+fn build_request_body(
+    cfg: &ClaudeConfig,
+    intent: &str,
+    ctx: &SystemContext,
+) -> Result<String, TranslateError> {
+    let ctx_json = serde_json::json!({
+        "distro_version": ctx.base.distro_version,
+        "pins": ctx.base.pins,
+        "extras": ctx.extras,
+    });
+    let user_msg = format!(
+        "SystemContext:\n{}\n\nIntención del humano:\n{}\n\nRecuerda: responde sólo con el JSON del Swm.",
+        serde_json::to_string_pretty(&ctx_json).unwrap_or_else(|_| "{}".into()),
+        intent,
+    );
+    let body = serde_json::json!({
+        "model": cfg.model,
+        "max_tokens": cfg.max_tokens,
+        "system": SYSTEM_PROMPT,
+        "thinking": { "type": "adaptive" },
+        "output_config": { "effort": cfg.effort },
+        "messages": [
+            { "role": "user", "content": user_msg }
+        ]
+    });
+    serde_json::to_string(&body).map_err(|e| TranslateError::Model(format!("request body: {e}")))
+}
+
+fn parse_response(raw: &str) -> Result<Swm, TranslateError> {
+    let v: serde_json::Value = serde_json::from_str(raw).map_err(|e| {
+        TranslateError::Model(format!("respuesta no es JSON ({e}): {raw}"))
+    })?;
+    // Error envelope de la API: { "type": "error", "error": { "type": "...", "message": "..." } }
+    if v.get("type").and_then(|t| t.as_str()) == Some("error") {
+        let err = v
+            .get("error")
+            .map(|e| e.to_string())
+            .unwrap_or_else(|| "error sin detalle".into());
+        return Err(TranslateError::Model(format!("API: {err}")));
+    }
+    // stop_reason "refusal" ⇒ el modelo rechazó. Lo reportamos como Model.
+    if v.get("stop_reason").and_then(|s| s.as_str()) == Some("refusal") {
+        let det = v
+            .get("stop_details")
+            .map(|d| d.to_string())
+            .unwrap_or_else(|| "sin stop_details".into());
+        return Err(TranslateError::Model(format!("refusal: {det}")));
+    }
+    let text = extract_first_text(&v)?;
+    let cleaned = strip_code_fence(&text);
+    let swm: Swm = serde_json::from_str(cleaned).map_err(|e| {
+        TranslateError::Model(format!(
+            "JSON del modelo no parsea como Swm ({e}). Texto:\n{text}"
+        ))
+    })?;
+    swm.verify_schema()
+        .map_err(|e| TranslateError::Model(format!("schema del Swm: {e}")))?;
+    Ok(swm)
+}
+
+fn extract_first_text(v: &serde_json::Value) -> Result<String, TranslateError> {
+    let content = v
+        .get("content")
+        .and_then(|c| c.as_array())
+        .ok_or_else(|| TranslateError::Model(format!("'content' ausente o no array: {v}")))?;
+    for block in content {
+        if block.get("type").and_then(|t| t.as_str()) == Some("text") {
+            if let Some(s) = block.get("text").and_then(|t| t.as_str()) {
+                return Ok(s.to_string());
+            }
+        }
+    }
+    Err(TranslateError::Model(format!(
+        "no encontré un bloque de texto en content: {v}"
+    )))
+}
+
+/// Limpia el típico envoltorio ```json ... ```. Best-effort: si no encuentra fence,
+/// devuelve el texto sin cambios. También recorta whitespace de los extremos.
+fn strip_code_fence(s: &str) -> &str {
+    let trimmed = s.trim();
+    let inside = trimmed
+        .strip_prefix("```json")
+        .or_else(|| trimmed.strip_prefix("```"))
+        .map(|rest| {
+            rest.trim_start_matches('\n')
+                .trim_end_matches("```")
+                .trim_end_matches('\n')
+                .trim_end()
+        })
+        .unwrap_or(trimmed);
+    inside.trim()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::collections::BTreeMap;
+    use std::sync::{Arc, Mutex};
+
+    use hammer_core::BaseRef;
+
+    /// Fake HTTP que captura el request y devuelve un body pre-armado.
+    struct FakeHttp {
+        captured: Arc<Mutex<Option<(String, Vec<(String, String)>, String)>>>,
+        response: String,
+    }
+
+    impl HttpClient for FakeHttp {
+        fn post_json(
+            &self,
+            url: &str,
+            headers: &[(&str, &str)],
+            body: &str,
+        ) -> Result<String, TranslateError> {
+            *self.captured.lock().unwrap() = Some((
+                url.to_string(),
+                headers.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
+                body.to_string(),
+            ));
+            Ok(self.response.clone())
+        }
+    }
+
+    fn cfg() -> ClaudeConfig {
+        ClaudeConfig {
+            api_key: "sk-test".into(),
+            model: "claude-opus-4-8".into(),
+            max_tokens: 4096,
+            base_url: "https://api.anthropic.com".into(),
+            effort: "high".into(),
+        }
+    }
+
+    fn empty_ctx() -> SystemContext {
+        SystemContext {
+            base: BaseRef {
+                distro_version: "2026-06-06".into(),
+                pins: BTreeMap::new(),
+            },
+            extras: serde_json::Value::Null,
+        }
+    }
+
+    fn ok_response(text: &str) -> String {
+        serde_json::json!({
+            "id": "msg_test",
+            "type": "message",
+            "role": "assistant",
+            "model": "claude-opus-4-8",
+            "content": [{"type": "text", "text": text}],
+            "stop_reason": "end_turn",
+            "usage": {"input_tokens": 1, "output_tokens": 1}
+        })
+        .to_string()
+    }
+
+    fn swm_text() -> String {
+        let s = serde_json::json!({
+            "swm_version": 1,
+            "base": {"distro_version": "2026-06-06", "pins": {}},
+            "mutations": [{
+                "type": "config_edit",
+                "file": "/etc/network.conf",
+                "inline_diff": "- DHCP=yes\n+ STATIC=1\n"
+            }]
+        });
+        serde_json::to_string(&s).unwrap()
+    }
+
+    #[test]
+    fn happy_path_emits_v1_messages_post_with_correct_headers() {
+        let captured = Arc::new(Mutex::new(None));
+        let http = FakeHttp {
+            captured: captured.clone(),
+            response: ok_response(&swm_text()),
+        };
+        let t = ClaudeTranslator::with_http(cfg(), Box::new(http));
+        let swm = t.translate("set static IP", &empty_ctx()).unwrap();
+        assert_eq!(swm.mutations.len(), 1);
+
+        let (url, headers, body) = captured.lock().unwrap().clone().unwrap();
+        assert_eq!(url, "https://api.anthropic.com/v1/messages");
+        assert!(headers.iter().any(|(k, v)| k == "x-api-key" && v == "sk-test"));
+        assert!(headers
+            .iter()
+            .any(|(k, v)| k == "anthropic-version" && v == "2023-06-01"));
+        assert!(headers
+            .iter()
+            .any(|(k, v)| k == "content-type" && v == "application/json"));
+        let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
+        assert_eq!(parsed["model"], "claude-opus-4-8");
+        assert_eq!(parsed["thinking"]["type"], "adaptive");
+        assert_eq!(parsed["output_config"]["effort"], "high");
+        let user_msg = parsed["messages"][0]["content"].as_str().unwrap();
+        assert!(user_msg.contains("set static IP"), "{user_msg}");
+        assert!(user_msg.contains("2026-06-06"), "{user_msg}");
+    }
+
+    #[test]
+    fn unwraps_markdown_code_fence() {
+        let fenced = format!("```json\n{}\n```", swm_text());
+        let http = FakeHttp {
+            captured: Arc::new(Mutex::new(None)),
+            response: ok_response(&fenced),
+        };
+        let t = ClaudeTranslator::with_http(cfg(), Box::new(http));
+        let swm = t.translate("x", &empty_ctx()).unwrap();
+        assert_eq!(swm.mutations.len(), 1);
+    }
+
+    #[test]
+    fn refusal_surfaces_as_model_error() {
+        let resp = serde_json::json!({
+            "id": "msg_x",
+            "type": "message",
+            "content": [],
+            "stop_reason": "refusal",
+            "stop_details": {"category": "cyber", "explanation": "no"},
+            "usage": {"input_tokens": 1, "output_tokens": 0}
+        });
+        let http = FakeHttp {
+            captured: Arc::new(Mutex::new(None)),
+            response: resp.to_string(),
+        };
+        let t = ClaudeTranslator::with_http(cfg(), Box::new(http));
+        let err = t.translate("x", &empty_ctx()).unwrap_err();
+        match err {
+            TranslateError::Model(m) => assert!(m.contains("refusal"), "{m}"),
+            other => panic!("esperaba Model, llegó {other:?}"),
+        }
+    }
+
+    #[test]
+    fn api_error_envelope_is_reported() {
+        let resp = serde_json::json!({
+            "type": "error",
+            "error": {"type": "invalid_request_error", "message": "bad model"}
+        });
+        let http = FakeHttp {
+            captured: Arc::new(Mutex::new(None)),
+            response: resp.to_string(),
+        };
+        let t = ClaudeTranslator::with_http(cfg(), Box::new(http));
+        let err = t.translate("x", &empty_ctx()).unwrap_err();
+        match err {
+            TranslateError::Model(m) => assert!(m.contains("invalid_request_error"), "{m}"),
+            other => panic!("esperaba Model, llegó {other:?}"),
+        }
+    }
+
+    #[test]
+    fn malformed_swm_surfaces_as_model_error() {
+        let resp = ok_response("{\"not\": \"a swm\"}");
+        let http = FakeHttp {
+            captured: Arc::new(Mutex::new(None)),
+            response: resp,
+        };
+        let t = ClaudeTranslator::with_http(cfg(), Box::new(http));
+        let err = t.translate("x", &empty_ctx()).unwrap_err();
+        match err {
+            TranslateError::Model(m) => assert!(m.contains("Swm"), "{m}"),
+            other => panic!("esperaba Model, llegó {other:?}"),
+        }
+    }
+
+    #[test]
+    fn schema_verify_catches_bad_swm() {
+        // swm_version inválida: el JSON parsea pero verify_schema rechaza.
+        let bad = serde_json::json!({
+            "swm_version": 9,
+            "base": {"distro_version": "x", "pins": {}},
+            "mutations": []
+        });
+        let http = FakeHttp {
+            captured: Arc::new(Mutex::new(None)),
+            response: ok_response(&bad.to_string()),
+        };
+        let t = ClaudeTranslator::with_http(cfg(), Box::new(http));
+        let err = t.translate("x", &empty_ctx()).unwrap_err();
+        match err {
+            TranslateError::Model(m) => assert!(m.contains("swm_version"), "{m}"),
+            other => panic!("esperaba Model, llegó {other:?}"),
+        }
+    }
+
+    #[test]
+    fn strip_code_fence_handles_variants() {
+        assert_eq!(strip_code_fence("```json\n{\"a\":1}\n```"), "{\"a\":1}");
+        assert_eq!(strip_code_fence("```\n{\"a\":1}\n```"), "{\"a\":1}");
+        assert_eq!(strip_code_fence("{\"a\":1}"), "{\"a\":1}");
+        assert_eq!(strip_code_fence("  {\"a\":1}\n\n"), "{\"a\":1}");
+    }
+}
diff --git a/crates/hammer-cli/Cargo.toml b/crates/hammer-cli/Cargo.toml
index e60204ab..820c2674 100644
--- a/crates/hammer-cli/Cargo.toml
+++ b/crates/hammer-cli/Cargo.toml
@@ -11,6 +11,12 @@ description = "El binario `hammer`: orquesta build, hydrate, try/commit, apply/e
 name = "hammer"
 path = "src/main.rs"
 
+[features]
+default = []
+# Activa el traductor LLM real (Claude API) detrás del flag `hammer ai --llm`.
+# Sin esta feature, `--llm` falla con un mensaje claro pidiendo recompilar.
+llm-claude = ["hammer-agent/llm-claude"]
+
 [dependencies]
 hammer-core.workspace = true
 hammer-build.workspace = true
diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs
index a80a898e..08cb216e 100644
--- a/crates/hammer-cli/src/main.rs
+++ b/crates/hammer-cli/src/main.rs
@@ -136,12 +136,17 @@ enum Cmd {
     /// [Fase 6] Ejecuta el bucle agéntico: traduce una intención NL a un .swm vía catálogo
     /// mock y la aplica a un overlay (o prefix) sin promover al FHS. Imprime el Proposal.
     Ai {
-        /// La intención en lenguaje natural (string exacta, indexada en el catálogo).
+        /// La intención en lenguaje natural. En modo mock (`--catalog`), debe coincidir
+        /// exactamente con una entrada del catálogo; en modo `--llm`, es texto libre.
         intent: String,
-        /// Catálogo YAML de intent → swm. Cuando exista un traductor LLM real, este flag
-        /// será opcional y se documentará el path por defecto.
+        /// Catálogo YAML de intent → swm (MockTranslator). Mutuamente excluyente con `--llm`.
         #[arg(long)]
-        catalog: PathBuf,
+        catalog: Option<PathBuf>,
+        /// Activa el traductor LLM real (Claude API). Requiere `ANTHROPIC_API_KEY` en el env
+        /// y un binario compilado con `--features llm-claude`. Modelo por defecto:
+        /// `claude-opus-4-8` (sobreescribible con `HAMMER_LLM_MODEL`).
+        #[arg(long)]
+        llm: bool,
         /// Re-rootea las mutaciones bajo este prefix en vez de abrir overlay. Útil para
         /// dev/CI sin root ni overlayfs.
         #[arg(long)]
@@ -364,6 +369,7 @@ fn main() -> anyhow::Result<()> {
         Cmd::Ai {
             intent,
             catalog,
+            llm,
             prefix,
             base_ref,
             bus,
@@ -373,7 +379,8 @@ fn main() -> anyhow::Result<()> {
         } => {
             run_ai(
                 &intent,
-                &catalog,
+                catalog.as_deref(),
+                llm,
                 prefix.as_deref(),
                 base_ref.as_deref(),
                 bus.as_deref(),
@@ -684,7 +691,8 @@ fn run_export(
 /// `Proposal` legible en stdout + el siguiente paso recomendado al humano.
 fn run_ai(
     intent: &str,
-    catalog: &std::path::Path,
+    catalog: Option<&std::path::Path>,
+    llm: bool,
     prefix: Option<&std::path::Path>,
     base_ref: Option<&std::path::Path>,
     bus: Option<&std::path::Path>,
@@ -693,43 +701,26 @@ fn run_ai(
     repair_max_attempts: Option<u32>,
     repair_window_ms: u64,
 ) -> anyhow::Result<()> {
-    use hammer_agent::{
-        orchestrator::{ApplyTarget, CompileMode, RepairPolicy},
-        IntentCatalog, MockTranslator, Orchestrator,
-    };
+    use hammer_agent::orchestrator::{ApplyTarget, CompileMode, RepairPolicy};
 
-    let cat = IntentCatalog::load_from_path(catalog)
-        .map_err(|e| anyhow::anyhow!("catálogo {}: {e}", catalog.display()))?;
-    let base_dir = catalog.parent().unwrap_or_else(|| std::path::Path::new("."));
-    let translator = MockTranslator::from_catalog(&cat, base_dir)
-        .map_err(|e| anyhow::anyhow!("catalog: {e}"))?;
+    if llm && catalog.is_some() {
+        anyhow::bail!("--llm y --catalog son mutuamente excluyentes");
+    }
+    if !llm && catalog.is_none() {
+        anyhow::bail!("hace falta --catalog (modo mock) o --llm (modo LLM)");
+    }
 
-    // BaseRef: si se da, leerlo; si no, fabricar uno permisivo desde la base del catálogo
-    // (la usaremos sólo para verify_base, que tolera pins extra en local).
-    let base = match load_local_base(base_ref)? {
-        Some(b) => b,
-        None => {
-            // Tomamos la base del swm del intent (si existe) como referencia local.
-            let ctx_swm = cat
-                .intents
-                .iter()
-                .find(|e| e.intent == intent)
-                .ok_or_else(|| anyhow::anyhow!("intent '{intent}' no está en el catálogo"))?
-                .swm_inline
-                .clone()
-                .or_else(|| {
-                    cat.intents
-                        .iter()
-                        .find(|e| e.intent == intent)
-                        .and_then(|e| e.swm_path.as_ref())
-                        .and_then(|p| std::fs::read_to_string(base_dir.join(p)).ok())
-                        .and_then(|t| hammer_core::Swm::from_yaml(&t).ok())
-                });
-            match ctx_swm {
-                Some(s) => hammer_core::BaseRef::from(&s.base),
-                None => anyhow::bail!("no pude inferir BaseRef sin --base-ref"),
-            }
+    // Calcular BaseRef. En modo catálogo se puede inferir del .swm; en modo LLM hace falta
+    // --base-ref explícito porque el modelo no ha producido nada aún.
+    let base = if llm {
+        match load_local_base(base_ref)? {
+            Some(b) => b,
+            None => anyhow::bail!(
+                "modo --llm requiere --base-ref para que el traductor reciba un SystemContext válido"
+            ),
         }
+    } else {
+        infer_base_for_catalog(intent, catalog.unwrap(), base_ref)?
     };
 
     let apply = match prefix {
@@ -750,21 +741,24 @@ fn run_ai(
         None => CompileMode::Skip,
     };
 
-    let orch = Orchestrator::new(translator, base, apply, compile);
-    let proposal = match repair_max_attempts {
-        Some(max) => {
-            let policy = RepairPolicy {
-                max_attempts: max,
-                crash_window: std::time::Duration::from_millis(repair_window_ms),
-                event_sock: bus.map(|p| p.to_path_buf()),
-                ..RepairPolicy::default()
-            };
-            orch.run_with_repair(intent, &policy)
-                .map_err(|e| anyhow::anyhow!("orchestrator (auto-repair): {e}"))?
-        }
-        None => orch
-            .run(intent)
-            .map_err(|e| anyhow::anyhow!("orchestrator: {e}"))?,
+    let policy_opt = repair_max_attempts.map(|max| RepairPolicy {
+        max_attempts: max,
+        crash_window: std::time::Duration::from_millis(repair_window_ms),
+        event_sock: bus.map(|p| p.to_path_buf()),
+        ..RepairPolicy::default()
+    });
+
+    let proposal = if llm {
+        run_with_llm_translator(intent, base, apply, compile, policy_opt.as_ref())?
+    } else {
+        run_with_mock_translator(
+            intent,
+            catalog.unwrap(),
+            base,
+            apply,
+            compile,
+            policy_opt.as_ref(),
+        )?
     };
 
     println!("--- proposal ---");
@@ -816,6 +810,102 @@ fn run_ai(
     Ok(())
 }
 
+/// En modo catálogo, intenta deducir un BaseRef si el humano no pasó `--base-ref`:
+/// usa la `base` del propio `.swm` mockeado. Esto facilita el smoke-test offline.
+fn infer_base_for_catalog(
+    intent: &str,
+    catalog: &std::path::Path,
+    base_ref: Option<&std::path::Path>,
+) -> anyhow::Result<hammer_core::BaseRef> {
+    if let Some(b) = load_local_base(base_ref)? {
+        return Ok(b);
+    }
+    let cat = hammer_agent::IntentCatalog::load_from_path(catalog)
+        .map_err(|e| anyhow::anyhow!("catálogo {}: {e}", catalog.display()))?;
+    let base_dir = catalog.parent().unwrap_or_else(|| std::path::Path::new("."));
+    let ctx_swm = cat
+        .intents
+        .iter()
+        .find(|e| e.intent == intent)
+        .ok_or_else(|| anyhow::anyhow!("intent '{intent}' no está en el catálogo"))?
+        .swm_inline
+        .clone()
+        .or_else(|| {
+            cat.intents
+                .iter()
+                .find(|e| e.intent == intent)
+                .and_then(|e| e.swm_path.as_ref())
+                .and_then(|p| std::fs::read_to_string(base_dir.join(p)).ok())
+                .and_then(|t| hammer_core::Swm::from_yaml(&t).ok())
+        });
+    match ctx_swm {
+        Some(s) => Ok(hammer_core::BaseRef::from(&s.base)),
+        None => anyhow::bail!("no pude inferir BaseRef sin --base-ref"),
+    }
+}
+
+fn run_with_mock_translator(
+    intent: &str,
+    catalog: &std::path::Path,
+    base: hammer_core::BaseRef,
+    apply: hammer_agent::orchestrator::ApplyTarget,
+    compile: hammer_agent::orchestrator::CompileMode,
+    policy: Option<&hammer_agent::orchestrator::RepairPolicy>,
+) -> anyhow::Result<hammer_agent::Proposal> {
+    use hammer_agent::{IntentCatalog, MockTranslator, Orchestrator};
+    let cat = IntentCatalog::load_from_path(catalog)
+        .map_err(|e| anyhow::anyhow!("catálogo {}: {e}", catalog.display()))?;
+    let base_dir = catalog.parent().unwrap_or_else(|| std::path::Path::new("."));
+    let translator = MockTranslator::from_catalog(&cat, base_dir)
+        .map_err(|e| anyhow::anyhow!("catalog: {e}"))?;
+    let orch = Orchestrator::new(translator, base, apply, compile);
+    match policy {
+        Some(p) => orch
+            .run_with_repair(intent, p)
+            .map_err(|e| anyhow::anyhow!("orchestrator (auto-repair): {e}")),
+        None => orch
+            .run(intent)
+            .map_err(|e| anyhow::anyhow!("orchestrator: {e}")),
+    }
+}
+
+#[cfg(feature = "llm-claude")]
+fn run_with_llm_translator(
+    intent: &str,
+    base: hammer_core::BaseRef,
+    apply: hammer_agent::orchestrator::ApplyTarget,
+    compile: hammer_agent::orchestrator::CompileMode,
+    policy: Option<&hammer_agent::orchestrator::RepairPolicy>,
+) -> anyhow::Result<hammer_agent::Proposal> {
+    use hammer_agent::{ClaudeConfig, ClaudeTranslator, Orchestrator};
+    let cfg = ClaudeConfig::from_env()
+        .map_err(|e| anyhow::anyhow!("ClaudeConfig::from_env: {e}"))?;
+    let translator = ClaudeTranslator::new(cfg);
+    let orch = Orchestrator::new(translator, base, apply, compile);
+    match policy {
+        Some(p) => orch
+            .run_with_repair(intent, p)
+            .map_err(|e| anyhow::anyhow!("orchestrator (auto-repair): {e}")),
+        None => orch
+            .run(intent)
+            .map_err(|e| anyhow::anyhow!("orchestrator: {e}")),
+    }
+}
+
+#[cfg(not(feature = "llm-claude"))]
+fn run_with_llm_translator(
+    _intent: &str,
+    _base: hammer_core::BaseRef,
+    _apply: hammer_agent::orchestrator::ApplyTarget,
+    _compile: hammer_agent::orchestrator::CompileMode,
+    _policy: Option<&hammer_agent::orchestrator::RepairPolicy>,
+) -> anyhow::Result<hammer_agent::Proposal> {
+    anyhow::bail!(
+        "este binario se compiló sin el feature `llm-claude`. Recompila: \
+         `cargo build -p hammer-cli --features llm-claude` (o desactiva --llm)."
+    )
+}
+
 /// Evalúa una expresión del mini-lenguaje localmente e imprime el JSON resultante.
 /// Espejo en proceso del path remoto vía bus (`AgentClient::query_expr`).
 fn run_query(
diff --git a/docs/10-roadmap.md b/docs/10-roadmap.md
index 04e67b64..fbeb5a9e 100644
--- a/docs/10-roadmap.md
+++ b/docs/10-roadmap.md
@@ -117,7 +117,11 @@ pre-requisito de validación.
       - 3 e2e del bucle agéntico (prefix tmp → archivos esperados en disco).
       - 1 e2e del cliente contra un *stub* del bus (handshake + Compile→BuildReady + Modified
         asíncrono).
-- [ ] Traductor LLM real (Claude API u otro), opcional vía feature flag o crate aparte.
+- [x] Traductor LLM real (Claude API u otro), opcional vía feature flag o crate aparte.
+      `ClaudeTranslator` detrás de la feature `llm-claude`, con `ureq + rustls`. Modelo
+      por defecto `claude-opus-4-8`, adaptive thinking + `effort=high`. Trait `HttpClient`
+      inyectable para tests sin red. CLI: `hammer ai --llm` (mutuamente excluyente con
+      `--catalog`). Sin la feature, el flag falla con mensaje claro.
 - [x] Lenguaje de consulta del sistema (SDD 08 §6) para que la IA refiera servicios y
       archivos sin rutas frágiles. Forma `kind:value` (`bin`, `file`, `pin`, `service`,
       `depends`), evaluable local (`hammer query <expr>`) y remoto vía bus