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 · por Sergio · 2026-06-10T16:29:27Z (hace 4 meses) · padre f769d9173 · ver el árbol en este commit
Delta semántico · 36
crates/hammer-agent/src/lib.rs
crates/hammer-agent/src/translator_claude.rs
crates/hammer-cli/src/main.rs
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