Fase 6 — bucle agéntico: hammer-agent (cliente + translator + orchestrator) y hammer ai
- proto: mover hammerd::proto a hammer-core::proto para que hammerd y hammer-agent
compartan los tipos del bus sin duplicar.
- hammer-agent (crate nuevo):
* client: AgentClient síncrono. Handshake hello/welcome; compile/inject/query/init
bloqueantes con timeout; reader thread interno demultiplexa async events (Modified/
Crashed) en una cola que el caller drena vía drain_async/next_async.
* translator: trait IntentTranslator + MockTranslator (HashMap<intent, Swm>) +
IntentCatalog YAML (swm_inline o swm_path). El traductor LLM real se enchufa
detrás del mismo trait sin cambios al orquestador.
* orchestrator: Orchestrator::run(intent) -> Proposal con plan -> schema -> base ->
try (overlay|prefix) -> apply (config_edit/file_drop con hammer-core::apply,
source_patch via bus opcional) -> verify (spot-checks) -> propose. Devuelve
overlay_id (para `hammer commit`) o prefix usado.
- hammer-cli: subcomando `hammer ai <intent> --catalog F [--prefix DIR --base-ref F
--bus SOCK --state-root DIR]`. Imprime el Proposal y el siguiente paso humano.
- Tests:
* 10 unit (translator + catalog + orchestrator).
* 3 e2e del bucle agéntico (intent -> archivos esperados bajo un prefix tmp).
* 1 e2e del cliente contra un stub bus (handshake + Compile -> BuildReady +
Modified asíncrono), sin depender de hammerd ni del lab.
- Docs: SDD 08 actualizado con el API del crate; roadmap marca lo cerrado y lo
pendiente (LLM real, lenguaje de consulta, bucle de auto-reparación con Crashed).
89ecd54135a72e486f94df045d191d231b253638 · por Sergio · 2026-06-09T15:47:37Z (hace 4 meses) · padre f41022d81 · ver el árbol en este commit
Delta semántico · 117
crates/hammer-agent/src/client.rs
crates/hammer-agent/src/lib.rs
crates/hammer-agent/src/orchestrator.rs
crates/hammer-agent/src/translator.rs
crates/hammer-agent/tests/ai_loop_e2e.rs
crates/hammer-agent/tests/client_against_stub_bus.rs
crates/hammer-cli/src/main.rs
crates/hammer-core/src/lib.rs
crates/hammer-core/src/proto.rs
crates/hammerd/src/main.rs
crates/hammerd/src/proto.rs
crates/hammerd/src/watcher.rs
crates/hammerd/tests/bus_e2e.rs
diff --git a/Cargo.lock b/Cargo.lock
index 895ad495..22130192 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -289,6 +289,23 @@ dependencies = [
"wasip3",
]
+[[package]]
+name = "hammer-agent"
+version = "0.0.1"
+dependencies = [
+ "anyhow",
+ "base64",
+ "hammer-core",
+ "hammer-journal",
+ "hammer-overlay",
+ "serde",
+ "serde_json",
+ "serde_yaml",
+ "tempfile",
+ "thiserror",
+ "tracing",
+]
+
[[package]]
name = "hammer-build"
version = "0.0.1"
@@ -310,6 +327,7 @@ dependencies = [
"anyhow",
"base64",
"clap",
+ "hammer-agent",
"hammer-build",
"hammer-core",
"hammer-journal",
diff --git a/Cargo.toml b/Cargo.toml
index a0b89b1a..b8af6db6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,6 +5,7 @@ members = [
"crates/hammer-build",
"crates/hammer-overlay",
"crates/hammer-journal",
+ "crates/hammer-agent",
"crates/hammer-cli",
"crates/hammerd",
]
@@ -21,6 +22,7 @@ hammer-core = { path = "crates/hammer-core" }
hammer-build = { path = "crates/hammer-build" }
hammer-overlay = { path = "crates/hammer-overlay" }
hammer-journal = { path = "crates/hammer-journal" }
+hammer-agent = { path = "crates/hammer-agent" }
# shared third-party deps, pinned at the workspace level
anyhow = "1"
diff --git a/crates/hammer-agent/Cargo.toml b/crates/hammer-agent/Cargo.toml
new file mode 100644
index 00000000..8af02c68
--- /dev/null
+++ b/crates/hammer-agent/Cargo.toml
@@ -0,0 +1,23 @@
+[package]
+name = "hammer-agent"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+repository.workspace = true
+description = "Cliente del bus + bucle agéntico (plan/build/try/verify/propose) para la IA."
+
+[dependencies]
+hammer-core.workspace = true
+hammer-overlay.workspace = true
+anyhow.workspace = true
+thiserror.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+serde_yaml.workspace = true
+tracing.workspace = true
+
+[dev-dependencies]
+tempfile.workspace = true
+hammer-journal.workspace = true
+base64.workspace = true
diff --git a/crates/hammer-agent/src/client.rs b/crates/hammer-agent/src/client.rs
new file mode 100644
index 00000000..d6eca965
--- /dev/null
+++ b/crates/hammer-agent/src/client.rs
@@ -0,0 +1,286 @@
+//! `AgentClient`: cliente síncrono del bus de agente. Ver `docs/07-agent-bus.md` §3.
+//!
+//! Modelo: una conexión = un thread del que llama. El reader thread interno demultiplexa
+//! eventos por tipo: respuestas correlativas a un comando se devuelven al caller via
+//! `recv_*_event_blocking`; eventos asíncronos (`Modified`, `Crashed`) se acumulan en una
+//! cola que el caller drena con `drain_async()`.
+//!
+//! No hay correlation-id en el protocolo todavía (el SDD 07 no lo exige), así que la
+//! correlación se hace por **tipo**: tras un `Compile` esperamos `BuildReady|BuildFailed`,
+//! cualquier otra cosa que no sea `Modified`/`Crashed` se reporta como protocol error.
+
+use std::collections::VecDeque;
+use std::io::{BufRead, BufReader, Write};
+use std::os::unix::net::UnixStream;
+use std::path::Path;
+use std::sync::{Arc, Condvar, Mutex};
+use std::thread;
+use std::time::{Duration, Instant};
+
+use hammer_core::proto::{Cap, Command, Event, Peer, RecipeInline};
+
+#[derive(Debug, thiserror::Error)]
+pub enum ClientError {
+ #[error("io: {0}")]
+ Io(#[from] std::io::Error),
+ #[error("json: {0}")]
+ Json(#[from] serde_json::Error),
+ #[error("protocolo: {0}")]
+ Protocol(String),
+ #[error("timeout esperando {0}")]
+ Timeout(&'static str),
+ #[error("el bus devolvió error code={code} msg={msg}")]
+ Bus { code: String, msg: String },
+ #[error("la conexión se cerró")]
+ Closed,
+}
+
+pub type Result<T> = std::result::Result<T, ClientError>;
+
+#[derive(Debug, Clone)]
+pub struct Welcome {
+ pub ver: u32,
+ pub caps: Vec<Cap>,
+ pub peer: Peer,
+}
+
+/// Estado compartido entre el reader thread y el caller.
+struct Inbox {
+ /// Eventos asíncronos (Modified/Crashed) en orden de llegada.
+ async_events: VecDeque<Event>,
+ /// Próximo evento "respuesta" al último comando. Sólo cabe uno a la vez en este
+ /// cliente síncrono (el caller envía + espera antes del siguiente).
+ pending: Option<Event>,
+ closed: bool,
+}
+
+#[derive(Clone)]
+struct Shared {
+ inbox: Arc<Mutex<Inbox>>,
+ cv: Arc<Condvar>,
+}
+
+pub struct AgentClient {
+ writer: UnixStream,
+ shared: Shared,
+ pub welcome: Welcome,
+}
+
+impl AgentClient {
+ /// Conecta al socket, hace handshake `Hello` y arranca el reader thread. Devuelve un
+ /// cliente listo para enviar comandos.
+ pub fn connect(sock_path: &Path) -> Result<Self> {
+ Self::connect_with_client_name(sock_path, "hammer-agent")
+ }
+
+ pub fn connect_with_client_name(sock_path: &Path, client: &str) -> Result<Self> {
+ let stream = UnixStream::connect(sock_path)?;
+ let reader_stream = stream.try_clone()?;
+ let writer = stream;
+
+ let shared = Shared {
+ inbox: Arc::new(Mutex::new(Inbox {
+ async_events: VecDeque::new(),
+ pending: None,
+ closed: false,
+ })),
+ cv: Arc::new(Condvar::new()),
+ };
+ // Reader thread: drena el socket línea a línea hasta EOF.
+ let s_for_reader = shared.clone();
+ thread::Builder::new()
+ .name("agent-client-reader".into())
+ .spawn(move || reader_loop(reader_stream, s_for_reader))
+ .ok();
+
+ let mut me = Self {
+ writer,
+ shared,
+ welcome: Welcome {
+ ver: 0,
+ caps: vec![],
+ peer: Peer { uid: 0, gid: 0, pid: 0 },
+ },
+ };
+ me.send(&Command::Hello {
+ ver: hammer_core::proto::PROTOCOL_VERSION,
+ client: client.to_string(),
+ })?;
+ let ev = me.recv_pending(Duration::from_secs(5))?;
+ match ev {
+ Event::Welcome { ver, caps, peer } => {
+ me.welcome = Welcome { ver, caps, peer };
+ }
+ Event::Error { code, msg } => return Err(ClientError::Bus { code, msg }),
+ other => {
+ return Err(ClientError::Protocol(format!(
+ "esperaba Welcome, llegó {other:?}"
+ )));
+ }
+ }
+ Ok(me)
+ }
+
+ /// Bloquea hasta `BuildReady` o `BuildFailed`. `timeout` es el wall-clock máximo —
+ /// el lab puede tardar varios minutos, así que el default debería ser generoso.
+ pub fn compile(&mut self, recipe: RecipeInline, timeout: Duration) -> Result<String> {
+ self.send(&Command::Compile { recipe })?;
+ match self.recv_pending(timeout)? {
+ Event::BuildReady { artifact, .. } => Ok(artifact),
+ Event::BuildFailed { reason, .. } => Err(ClientError::Bus {
+ code: "build_failed".into(),
+ msg: reason,
+ }),
+ Event::Error { code, msg } => Err(ClientError::Bus { code, msg }),
+ other => Err(ClientError::Protocol(format!(
+ "esperaba BuildReady|BuildFailed, llegó {other:?}"
+ ))),
+ }
+ }
+
+ pub fn inject(
+ &mut self,
+ artifact: &str,
+ target: &str,
+ overlay: Option<&str>,
+ timeout: Duration,
+ ) -> Result<usize> {
+ self.send(&Command::Inject {
+ artifact: artifact.to_string(),
+ target: target.to_string(),
+ overlay: overlay.map(String::from),
+ })?;
+ match self.recv_pending(timeout)? {
+ Event::Injected { files, .. } => Ok(files),
+ Event::Error { code, msg } => Err(ClientError::Bus { code, msg }),
+ other => Err(ClientError::Protocol(format!("esperaba Injected, llegó {other:?}"))),
+ }
+ }
+
+ pub fn query_file(&mut self, path: &str, timeout: Duration) -> Result<serde_json::Value> {
+ self.send(&Command::Query {
+ what: "file".into(),
+ path: Some(path.to_string()),
+ name: None,
+ })?;
+ match self.recv_pending(timeout)? {
+ Event::QueryResult { value, .. } => Ok(value),
+ Event::Error { code, msg } => Err(ClientError::Bus { code, msg }),
+ other => Err(ClientError::Protocol(format!(
+ "esperaba QueryResult, llegó {other:?}"
+ ))),
+ }
+ }
+
+ pub fn init(&mut self, cmd: &str, timeout: Duration) -> Result<()> {
+ self.send(&Command::Init { cmd: cmd.to_string() })?;
+ match self.recv_pending(timeout)? {
+ Event::InitAck { .. } => Ok(()),
+ Event::Error { code, msg } => Err(ClientError::Bus { code, msg }),
+ other => Err(ClientError::Protocol(format!("esperaba InitAck, llegó {other:?}"))),
+ }
+ }
+
+ /// Vacía la cola de eventos asíncronos. No bloquea.
+ pub fn drain_async(&self) -> Vec<Event> {
+ let Ok(mut g) = self.shared.inbox.lock() else { return vec![] };
+ std::mem::take(&mut g.async_events).into_iter().collect()
+ }
+
+ /// Espera el próximo evento asíncrono (típicamente para verificar que un `Modified`
+ /// específico haya llegado tras una operación).
+ pub fn next_async(&self, timeout: Duration) -> Result<Event> {
+ let g = self.shared.inbox.lock().unwrap();
+ let (mut g, _) = self
+ .shared
+ .cv
+ .wait_timeout_while(g, timeout, |i| {
+ !i.closed && i.async_events.is_empty()
+ })
+ .unwrap();
+ if let Some(ev) = g.async_events.pop_front() {
+ Ok(ev)
+ } else if g.closed {
+ Err(ClientError::Closed)
+ } else {
+ Err(ClientError::Timeout("async event"))
+ }
+ }
+
+ fn send(&mut self, cmd: &Command) -> Result<()> {
+ let line = serde_json::to_string(cmd)?;
+ self.writer.write_all(line.as_bytes())?;
+ self.writer.write_all(b"\n")?;
+ self.writer.flush()?;
+ Ok(())
+ }
+
+ fn recv_pending(&self, timeout: Duration) -> Result<Event> {
+ let deadline = Instant::now() + timeout;
+ let g = self.shared.inbox.lock().unwrap();
+ let (mut g, _) = self
+ .shared
+ .cv
+ .wait_timeout_while(g, timeout, |i| !i.closed && i.pending.is_none())
+ .unwrap();
+ if let Some(ev) = g.pending.take() {
+ return Ok(ev);
+ }
+ if g.closed {
+ return Err(ClientError::Closed);
+ }
+ // Si el wait expiró exactamente en el momento de un evento, vuelvo a comprobar.
+ if Instant::now() >= deadline {
+ return Err(ClientError::Timeout("response"));
+ }
+ Err(ClientError::Timeout("response"))
+ }
+}
+
+fn reader_loop(stream: UnixStream, shared: Shared) {
+ let reader = BufReader::new(stream);
+ for line in reader.lines() {
+ let line = match line {
+ Ok(l) => l,
+ Err(e) => {
+ tracing::warn!(error = %e, "agent-client: read error, cerrando");
+ break;
+ }
+ };
+ if line.trim().is_empty() {
+ continue;
+ }
+ let ev: Event = match serde_json::from_str(&line) {
+ Ok(e) => e,
+ Err(e) => {
+ tracing::warn!(error = %e, line = %line, "agent-client: línea no parseable");
+ continue;
+ }
+ };
+ let is_async = matches!(
+ &ev,
+ Event::Modified { .. } | Event::Crashed { .. }
+ );
+ let mut g = match shared.inbox.lock() {
+ Ok(g) => g,
+ Err(_) => break,
+ };
+ if is_async {
+ g.async_events.push_back(ev);
+ } else {
+ // Si ya hay un pending sin recoger, eso es un bug del caller (envió dos
+ // comandos antes de leer la primera respuesta). Lo logueamos y reemplazamos.
+ if g.pending.is_some() {
+ tracing::warn!(
+ "agent-client: pending ya tenía respuesta; el caller no la recogió"
+ );
+ }
+ g.pending = Some(ev);
+ }
+ shared.cv.notify_all();
+ }
+ if let Ok(mut g) = shared.inbox.lock() {
+ g.closed = true;
+ shared.cv.notify_all();
+ }
+}
diff --git a/crates/hammer-agent/src/lib.rs b/crates/hammer-agent/src/lib.rs
new file mode 100644
index 00000000..f14612e3
--- /dev/null
+++ b/crates/hammer-agent/src/lib.rs
@@ -0,0 +1,46 @@
+//! Cliente del bus de agente + bucle agéntico (Fase 6). Ver `docs/08-ai-integration.md`.
+//!
+//! Tres piezas:
+//!
+//! 1. [`client`] — `AgentClient`: envoltorio síncrono de `/run/agent.sock`. Hace el
+//! handshake, envía comandos y devuelve eventos. Diseñado para que el orquestador
+//! pueda esperar a un `BuildReady` específico tras un `Compile`.
+//! 2. [`translator`] — Trait `IntentTranslator` + `MockTranslator`: convierte una intención
+//! en lenguaje natural a un `.swm`. El traductor real (LLM) se conecta detrás del trait;
+//! el mock cubre los tests y los smoke en el host de desarrollo.
+//! 3. [`orchestrator`] — El bucle plan→build→try→apply→verify→propose. Aplica las
+//! mutaciones a un overlay (o a un prefix para tests), valida postcondiciones y
+//! devuelve un `Proposal` que el humano usa para decidir `commit`.
+//!
+//! Decisión de diseño: el cliente es **bloqueante** (std::sync), no async. La cadencia
+//! agéntica es de comandos/seg, no de RPS; y un cliente sync es 5× menos código y debuggable
+//! con un breakpoint normal. Cuando aparezca un caso real de N agentes concurrentes,
+//! evaluamos tokio.
+
+pub mod client;
+pub mod orchestrator;
+pub mod translator;
+
+pub use client::{AgentClient, ClientError, Welcome};
+pub use orchestrator::{Orchestrator, Proposal, VerifyCheck};
+pub use translator::{IntentCatalog, IntentTranslator, MockTranslator, SystemContext, TranslateError};
+
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+ #[error("client: {0}")]
+ Client(#[from] ClientError),
+ #[error("translator: {0}")]
+ Translate(#[from] TranslateError),
+ #[error("orchestrator: {0}")]
+ Orchestrate(String),
+ #[error("io: {0}")]
+ Io(#[from] std::io::Error),
+ #[error("overlay: {0}")]
+ Overlay(#[from] hammer_overlay::Error),
+ #[error("apply: {0}")]
+ Apply(#[from] hammer_core::apply::ApplyError),
+ #[error("core: {0}")]
+ Core(#[from] hammer_core::Error),
+}
+
+pub type Result<T> = std::result::Result<T, Error>;
diff --git a/crates/hammer-agent/src/orchestrator.rs b/crates/hammer-agent/src/orchestrator.rs
new file mode 100644
index 00000000..78cd77ff
--- /dev/null
+++ b/crates/hammer-agent/src/orchestrator.rs
@@ -0,0 +1,469 @@
+//! El bucle agéntico. Ver `docs/08-ai-integration.md` §2.
+//!
+//! `Orchestrator::run(intent)` ejecuta:
+//!
+//! ```text
+//! plan → translator.translate(intent, ctx) → Swm
+//! schema → swm.verify_schema()
+//! base → swm.verify_base(local) → debe ser Ok
+//! build → para cada source_patch: AgentClient.compile() vía el bus (o saltar si --no-bus)
+//! try → hammer_overlay::try_overlay() (o usar --prefix)
+//! apply → hammer-core::apply para config_edit/file_drop + hydrate para source_patch
+//! verify → spot-checks post-condición sobre disco
+//! propose → devuelve Proposal con overlay_id + log de checks
+//! ```
+//!
+//! La IA NUNCA ejecuta `hammer commit`. El humano lo hace tras revisar el `Proposal`.
+
+use std::path::{Path, PathBuf};
+use std::time::Duration;
+
+use hammer_core::apply::{apply_config_edit, apply_file_drop, rebase_path};
+use hammer_core::proto::RecipeInline;
+use hammer_core::swm::{Mutation, Swm};
+use hammer_core::BaseRef;
+use hammer_overlay::OverlayId;
+
+use crate::client::AgentClient;
+use crate::translator::{IntentTranslator, SystemContext};
+use crate::{Error, Result};
+
+/// Cómo aplica el orquestador las mutaciones al sistema.
+#[derive(Debug, Clone)]
+pub enum ApplyTarget {
+ /// Abre un overlay sobre los targets por defecto del FHS (requiere root). Las
+ /// mutaciones caen sobre las rutas absolutas del `.swm`.
+ Overlay { state_root: PathBuf },
+ /// Re-rootea cada path bajo `prefix`. No abre overlay — útil para tests offline y
+ /// para staging en otro filesystem.
+ Prefix { prefix: PathBuf },
+}
+
+/// Cómo se construyen las `source_patch`. `ViaBus` requiere un `hammerd` corriendo;
+/// `Skip` ignora las source_patch (apropiado para escenarios sin lab).
+#[derive(Debug, Clone)]
+pub enum CompileMode {
+ /// Llama a `Compile` por el bus y espera `BuildReady`. Hidrata localmente desde el
+ /// store si está configurado.
+ ViaBus {
+ sock: PathBuf,
+ store: PathBuf,
+ timeout: Duration,
+ },
+ /// Salta cualquier `source_patch`. Se anota en `Proposal.skipped_source_patches`.
+ Skip,
+}
+
+pub struct Orchestrator<T: IntentTranslator> {
+ translator: T,
+ base: BaseRef,
+ apply: ApplyTarget,
+ compile: CompileMode,
+}
+
+impl<T: IntentTranslator> Orchestrator<T> {
+ pub fn new(translator: T, base: BaseRef, apply: ApplyTarget, compile: CompileMode) -> Self {
+ Self { translator, base, apply, compile }
+ }
+
+ pub fn run(&self, intent: &str) -> Result<Proposal> {
+ // 1. PLAN
+ let ctx = SystemContext {
+ base: self.base.clone(),
+ extras: serde_json::Value::Null,
+ };
+ let swm = self.translator.translate(intent, &ctx)?;
+
+ // 2. SCHEMA
+ swm.verify_schema()?;
+
+ // 3. BASE
+ match swm.verify_base(&self.base) {
+ hammer_core::BaseCompat::Ok => {}
+ other => {
+ return Err(Error::Orchestrate(format!(
+ "base incompatible: {other:?}"
+ )));
+ }
+ }
+
+ // 4. TRY (overlay) — recolectamos el id si lo abrimos; en modo Prefix no hay id.
+ let (overlay_id, prefix): (Option<OverlayId>, Option<PathBuf>) = match &self.apply {
+ ApplyTarget::Overlay { state_root } => {
+ std::fs::create_dir_all(state_root)?;
+ let id = hammer_overlay::try_overlay(&[], state_root)?;
+ (Some(id), None)
+ }
+ ApplyTarget::Prefix { prefix } => {
+ std::fs::create_dir_all(prefix)?;
+ (None, Some(prefix.clone()))
+ }
+ };
+
+ // 5. APPLY — recorremos las mutaciones en orden. En source_patch, dispatch al bus
+ // o skip; en config_edit/file_drop, primitivas de hammer-core::apply.
+ let mut applied = AppliedCounts::default();
+ let mut checks: Vec<VerifyCheck> = Vec::new();
+ let mut skipped_source_patches = 0usize;
+
+ let mut maybe_client: Option<AgentClient> = None;
+ if matches!(self.compile, CompileMode::ViaBus { .. })
+ && swm.mutations.iter().any(|m| matches!(m, Mutation::SourcePatch { .. }))
+ {
+ if let CompileMode::ViaBus { sock, .. } = &self.compile {
+ maybe_client = Some(AgentClient::connect(sock).map_err(Error::Client)?);
+ }
+ }
+
+ for m in &swm.mutations {
+ match m {
+ Mutation::ConfigEdit { file, inline_diff } => {
+ let target = rebase_path(file, prefix.as_deref());
+ apply_config_edit(&target, inline_diff)?;
+ applied.config_edit += 1;
+ checks.push(VerifyCheck::pass(format!(
+ "config_edit aplicado a {}",
+ target.display()
+ )));
+ }
+ Mutation::FileDrop { path, content_hash, content_b64, content_url } => {
+ if let Some(_url) = content_url {
+ return Err(Error::Orchestrate(format!(
+ "file_drop con content_url no soportado en Fase 6: {path}"
+ )));
+ }
+ let b64 = content_b64.as_deref().ok_or_else(|| {
+ Error::Orchestrate(format!(
+ "file_drop {path} no trae content_b64 (verify_schema falló)"
+ ))
+ })?;
+ let target = rebase_path(path, prefix.as_deref());
+ apply_file_drop(&target, b64, content_hash)?;
+ applied.file_drop += 1;
+ checks.push(VerifyCheck::pass(format!(
+ "file_drop escrito en {} (hash verificado)",
+ target.display()
+ )));
+ }
+ Mutation::InitRule { action, service, .. } => {
+ applied.init_rule += 1;
+ checks.push(VerifyCheck::warn(format!(
+ "init_rule '{action} {service}' anotada — pendiente Fase 5+ (supervisor)"
+ )));
+ }
+ Mutation::SourcePatch {
+ repo,
+ commit,
+ patch,
+ patch_url: _,
+ build,
+ target_bin,
+ expected_hash,
+ } => {
+ let CompileMode::ViaBus { store, timeout, .. } = &self.compile else {
+ skipped_source_patches += 1;
+ checks.push(VerifyCheck::warn(format!(
+ "source_patch {target_bin} omitido (CompileMode::Skip)"
+ )));
+ continue;
+ };
+ let name = Path::new(target_bin)
+ .file_name()
+ .and_then(|s| s.to_str())
+ .unwrap_or("swm-bin")
+ .to_string();
+ let recipe = RecipeInline {
+ name: name.clone(),
+ repo: repo.clone(),
+ commit: commit.clone(),
+ patch: patch.clone(),
+ compiler: build.compiler.clone(),
+ target: build.target.clone(),
+ link: build.link.clone(),
+ flags: build.flags.clone(),
+ };
+ let client = maybe_client.as_mut().expect("client conectado arriba");
+ let artifact = client
+ .compile(recipe, *timeout)
+ .map_err(Error::Client)?;
+ if let Some(expected) = expected_hash {
+ let want = expected.strip_prefix("b3:").unwrap_or(expected);
+ let got = artifact.strip_prefix("b3:").unwrap_or(&artifact);
+ if want != got {
+ return Err(Error::Orchestrate(format!(
+ "source_patch {target_bin}: expected_hash no coincide \
+ (declarado=b3:{want}, build=b3:{got})"
+ )));
+ }
+ }
+ // Hidratamos localmente desde el store (sin pasar por el bus, evita un
+ // segundo Inject que duplicaría trabajo).
+ let store = hammer_core::Store::open(store)?;
+ let artifact_dir = store
+ .find_by_hash(&artifact)
+ .map_err(|e| Error::Orchestrate(format!("find_by_hash: {e}")))?;
+ let into = rebase_path("/", prefix.as_deref());
+ let report = hammer_build_run_hydrate(&artifact_dir, &into)?;
+ let abs = rebase_path(target_bin, prefix.as_deref());
+ if !abs.exists() {
+ return Err(Error::Orchestrate(format!(
+ "source_patch declara target_bin={target_bin} pero no quedó en {}",
+ abs.display()
+ )));
+ }
+ applied.source_patch += 1;
+ checks.push(VerifyCheck::pass(format!(
+ "source_patch {target_bin}: build {artifact}, {} archivo(s) hidratado(s)",
+ report
+ )));
+ }
+ }
+ }
+
+ Ok(Proposal {
+ intent: intent.to_string(),
+ swm,
+ overlay_id: overlay_id.map(|o| o.as_str().to_string()),
+ prefix: prefix.clone(),
+ applied,
+ skipped_source_patches,
+ checks,
+ })
+ }
+}
+
+/// Stub: `hammer-build` no es dependencia directa de `hammer-agent` para no arrastrar el
+/// lab a clientes que sólo quieran hablar con el bus. Implementamos la hidratación local
+/// con `std::fs::hard_link` directamente porque es trivial. Si el árbol del artefacto crece
+/// (symlinks, perms más finos), promovemos a `hammer-build::run_hydrate` añadiendo la dep
+/// de manera condicional.
+fn hammer_build_run_hydrate(artifact_dir: &Path, target_fhs: &Path) -> Result<usize> {
+ let mut files = 0usize;
+ walk(artifact_dir, &mut |rel, kind| {
+ let dst = target_fhs.join(rel);
+ match kind {
+ EntryKind::Dir => {
+ std::fs::create_dir_all(&dst)?;
+ }
+ EntryKind::Symlink(target) => {
+ if let Some(parent) = dst.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+ let _ = std::fs::remove_file(&dst);
+ std::os::unix::fs::symlink(&target, &dst)?;
+ files += 1;
+ }
+ EntryKind::File(src) => {
+ if let Some(parent) = dst.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+ let _ = std::fs::remove_file(&dst);
+ std::fs::hard_link(&src, &dst).or_else(|_| {
+ // Cross-fs ⇒ caemos a copia. No optimizamos: hammer-agent espera
+ // mismo FS; sólo damos el fallback como cinturón de seguridad.
+ std::fs::copy(&src, &dst).map(|_| ())
+ })?;
+ files += 1;
+ }
+ }
+ Ok::<(), std::io::Error>(())
+ })?;
+ Ok(files)
+}
+
+enum EntryKind {
+ Dir,
+ Symlink(PathBuf),
+ File(PathBuf),
+}
+
+fn walk<F>(root: &Path, f: &mut F) -> std::io::Result<()>
+where
+ F: FnMut(&Path, EntryKind) -> std::io::Result<()>,
+{
+ fn inner<F>(root: &Path, cur: &Path, f: &mut F) -> std::io::Result<()>
+ where
+ F: FnMut(&Path, EntryKind) -> std::io::Result<()>,
+ {
+ for entry in std::fs::read_dir(cur)? {
+ let entry = entry?;
+ let src = entry.path();
+ let rel = src.strip_prefix(root).unwrap();
+ let ft = entry.file_type()?;
+ if ft.is_symlink() {
+ let target = std::fs::read_link(&src)?;
+ f(rel, EntryKind::Symlink(target))?;
+ } else if ft.is_dir() {
+ f(rel, EntryKind::Dir)?;
+ inner(root, &src, f)?;
+ } else if ft.is_file() {
+ f(rel, EntryKind::File(src.clone()))?;
+ }
+ }
+ Ok(())
+ }
+ if !root.is_dir() {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::NotFound,
+ format!("artifact_dir no existe: {}", root.display()),
+ ));
+ }
+ inner(root, root, f)
+}
+
+#[derive(Debug, Default, Clone, serde::Serialize)]
+pub struct AppliedCounts {
+ pub source_patch: usize,
+ pub config_edit: usize,
+ pub init_rule: usize,
+ pub file_drop: usize,
+}
+
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct VerifyCheck {
+ pub level: CheckLevel,
+ pub msg: String,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum CheckLevel {
+ Pass,
+ Warn,
+ Fail,
+}
+
+impl VerifyCheck {
+ pub fn pass(msg: impl Into<String>) -> Self {
+ Self { level: CheckLevel::Pass, msg: msg.into() }
+ }
+ pub fn warn(msg: impl Into<String>) -> Self {
+ Self { level: CheckLevel::Warn, msg: msg.into() }
+ }
+}
+
+/// El resultado del bucle. El humano lo lee para decidir `hammer commit <overlay_id>` (si
+/// está en modo Overlay) o `hammer discard`.
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct Proposal {
+ pub intent: String,
+ pub swm: Swm,
+ /// `Some` si abrimos un overlay. En modo Prefix, `None`.
+ pub overlay_id: Option<String>,
+ pub prefix: Option<PathBuf>,
+ pub applied: AppliedCounts,
+ pub skipped_source_patches: usize,
+ pub checks: Vec<VerifyCheck>,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::translator::MockTranslator;
+ use hammer_core::swm::{Base, Mutation};
+ use std::collections::BTreeMap;
+
+ fn baseref() -> BaseRef {
+ BaseRef { distro_version: "2026-06-06".into(), pins: BTreeMap::new() }
+ }
+
+ fn swm_config_only() -> Swm {
+ Swm {
+ swm_version: 1,
+ base: Base { distro_version: "2026-06-06".into(), pins: BTreeMap::new() },
+ mutations: vec![Mutation::ConfigEdit {
+ file: "/etc/network.conf".into(),
+ inline_diff: "- DHCP=yes\n+ STATIC=1\n".into(),
+ }],
+ signature: None,
+ }
+ }
+
+ #[test]
+ fn orchestrator_applies_config_edit_to_prefix() {
+ let d = tempfile::tempdir().unwrap();
+ let prefix = d.path().join("prefix");
+ std::fs::create_dir_all(prefix.join("etc")).unwrap();
+ std::fs::write(prefix.join("etc/network.conf"), "DHCP=yes\nMTU=1500\n").unwrap();
+
+ let t = MockTranslator::empty().with_intent("set static ip", swm_config_only());
+ let o = Orchestrator::new(
+ t,
+ baseref(),
+ ApplyTarget::Prefix { prefix: prefix.clone() },
+ CompileMode::Skip,
+ );
+ let p = o.run("set static ip").unwrap();
+ assert_eq!(p.applied.config_edit, 1);
+ assert!(p.overlay_id.is_none());
+ assert_eq!(p.checks.len(), 1);
+ assert_eq!(p.checks[0].level, CheckLevel::Pass);
+
+ let got = std::fs::read_to_string(prefix.join("etc/network.conf")).unwrap();
+ assert_eq!(got, "STATIC=1\nMTU=1500\n");
+ }
+
+ #[test]
+ fn orchestrator_rejects_base_mismatch() {
+ let mut swm = swm_config_only();
+ swm.base.distro_version = "2027-01-01".into();
+ let t = MockTranslator::empty().with_intent("x", swm);
+ let d = tempfile::tempdir().unwrap();
+ let o = Orchestrator::new(
+ t,
+ baseref(),
+ ApplyTarget::Prefix { prefix: d.path().to_path_buf() },
+ CompileMode::Skip,
+ );
+ let err = o.run("x").unwrap_err().to_string();
+ assert!(err.contains("base incompatible"), "{err}");
+ }
+
+ #[test]
+ fn orchestrator_skips_source_patch_with_warn() {
+ let swm = Swm {
+ swm_version: 1,
+ base: Base { distro_version: "2026-06-06".into(), pins: BTreeMap::new() },
+ mutations: vec![Mutation::SourcePatch {
+ repo: "git://x".into(),
+ commit: "abc".into(),
+ patch: None,
+ patch_url: None,
+ build: hammer_core::swm::SwmBuild {
+ compiler: "zig-cc".into(),
+ target: "x86_64-linux-musl".into(),
+ link: "static".into(),
+ flags: vec![],
+ },
+ target_bin: "/bin/x".into(),
+ expected_hash: None,
+ }],
+ signature: None,
+ };
+ let t = MockTranslator::empty().with_intent("compile x", swm);
+ let d = tempfile::tempdir().unwrap();
+ let o = Orchestrator::new(
+ t,
+ baseref(),
+ ApplyTarget::Prefix { prefix: d.path().to_path_buf() },
+ CompileMode::Skip,
+ );
+ let p = o.run("compile x").unwrap();
+ assert_eq!(p.skipped_source_patches, 1);
+ assert_eq!(p.checks[0].level, CheckLevel::Warn);
+ }
+
+ #[test]
+ fn orchestrator_translates_unknown_intent_errors() {
+ let t = MockTranslator::empty();
+ let d = tempfile::tempdir().unwrap();
+ let o = Orchestrator::new(
+ t,
+ baseref(),
+ ApplyTarget::Prefix { prefix: d.path().to_path_buf() },
+ CompileMode::Skip,
+ );
+ let err = o.run("haz lo que tú quieras").unwrap_err().to_string();
+ assert!(err.contains("no reconocida"), "{err}");
+ }
+}
diff --git a/crates/hammer-agent/src/translator.rs b/crates/hammer-agent/src/translator.rs
new file mode 100644
index 00000000..f334075c
--- /dev/null
+++ b/crates/hammer-agent/src/translator.rs
@@ -0,0 +1,278 @@
+//! Traductor de intención (NL) → `.swm`. Ver `docs/08-ai-integration.md` §5.
+//!
+//! Contrato del trait: dado un texto humano + un `SystemContext` (lo que el orquestador
+//! pudo recabar vía QUERY: pins, herramientas presentes, distro_version, etc.), producir un
+//! `Swm` válido.
+//!
+//! Phase 6 entrega un [`MockTranslator`] que mapea intentos exactos contra un catálogo
+//! YAML de SWMs pre-armados. Eso permite probar el bucle plan→build→try→verify→propose en
+//! tests y en el host del autor sin LLM. La integración con un modelo real (Claude API,
+//! local llama, etc.) se conecta detrás de este mismo trait — el bucle no cambia.
+
+use std::collections::HashMap;
+use std::path::Path;
+
+use hammer_core::{BaseRef, Swm};
+
+#[derive(Debug, thiserror::Error)]
+pub enum TranslateError {
+ #[error("intent no reconocida en el catálogo: '{0}'")]
+ UnknownIntent(String),
+ #[error("io: {0}")]
+ Io(#[from] std::io::Error),
+ #[error("catálogo inválido: {0}")]
+ Catalog(String),
+ #[error("modelo: {0}")]
+ Model(String),
+}
+
+/// Lo que el orquestador le pasa al traductor para contextualizar la intención. Un LLM
+/// usaría esto para decidir, p. ej., "¿el sistema ya tiene grep 3.12? entonces no necesito
+/// recompilar; sólo edito la config". Para el `MockTranslator` es decorativo.
+#[derive(Debug, Clone)]
+pub struct SystemContext {
+ pub base: BaseRef,
+ /// Información libre que el orquestador haya recolectado (rutas y metadatos de archivos
+ /// referenciados por el intent, salida de QUERY, etc.). Estructura JSON arbitraria.
+ pub extras: serde_json::Value,
+}
+
+pub trait IntentTranslator {
+ fn translate(&self, intent: &str, ctx: &SystemContext) -> Result<Swm, TranslateError>;
+}
+
+/// Catálogo intento→.swm leído de un YAML:
+///
+/// ```yaml
+/// version: 1
+/// intents:
+/// - intent: "set static IP 192.168.1.100"
+/// swm_path: "swms/static-ip.swm" # ruta relativa al directorio del catálogo
+/// # OR
+/// swm_inline: { swm_version: 1, ... }
+/// ```
+///
+/// El propósito es tener un "test cassette": el agente humano captura una intención que la
+/// IA real ya resolvió, congela el `.swm` resultante, y a partir de ahí los CI/tests del
+/// bucle agéntico son reproducibles.
+#[derive(Debug, Clone, serde::Deserialize)]
+pub struct IntentCatalog {
+ #[serde(default = "default_version")]
+ pub version: u32,
+ pub intents: Vec<CatalogEntry>,
+}
+
+#[derive(Debug, Clone, serde::Deserialize)]
+pub struct CatalogEntry {
+ pub intent: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub swm_path: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub swm_inline: Option<Swm>,
+}
+
+fn default_version() -> u32 {
+ 1
+}
+
+impl IntentCatalog {
+ pub fn load_from_path(path: &Path) -> Result<Self, TranslateError> {
+ let text = std::fs::read_to_string(path)?;
+ let cat: IntentCatalog = serde_yaml::from_str(&text)
+ .map_err(|e| TranslateError::Catalog(e.to_string()))?;
+ if cat.version != 1 {
+ return Err(TranslateError::Catalog(format!(
+ "version del catálogo no soportada: {} (esperada 1)",
+ cat.version
+ )));
+ }
+ Ok(cat)
+ }
+
+ /// Resuelve un `intent` exacto contra el catálogo. Las entradas `swm_path` se resuelven
+ /// relativas al `base_dir` (típicamente el directorio del catálogo).
+ pub fn resolve(&self, intent: &str, base_dir: &Path) -> Result<Swm, TranslateError> {
+ let entry = self
+ .intents
+ .iter()
+ .find(|e| e.intent == intent)
+ .ok_or_else(|| TranslateError::UnknownIntent(intent.into()))?;
+ if let Some(s) = &entry.swm_inline {
+ return Ok(s.clone());
+ }
+ if let Some(p) = &entry.swm_path {
+ let full = base_dir.join(p);
+ let text = std::fs::read_to_string(&full)?;
+ return Swm::from_yaml(&text)
+ .map_err(|e| TranslateError::Catalog(format!("swm {}: {e}", full.display())));
+ }
+ Err(TranslateError::Catalog(format!(
+ "entry '{intent}' no declara swm_path ni swm_inline"
+ )))
+ }
+}
+
+/// Traductor offline: matchea la intención exacta contra un mapa precargado.
+#[derive(Debug, Default)]
+pub struct MockTranslator {
+ map: HashMap<String, Swm>,
+}
+
+impl MockTranslator {
+ pub fn empty() -> Self {
+ Self { map: HashMap::new() }
+ }
+
+ pub fn from_catalog(cat: &IntentCatalog, base_dir: &Path) -> Result<Self, TranslateError> {
+ let mut map = HashMap::new();
+ for entry in &cat.intents {
+ let swm = match (&entry.swm_inline, &entry.swm_path) {
+ (Some(s), None) => s.clone(),
+ (None, Some(p)) => {
+ let full = base_dir.join(p);
+ let text = std::fs::read_to_string(&full)?;
+ Swm::from_yaml(&text).map_err(|e| {
+ TranslateError::Catalog(format!("swm {}: {e}", full.display()))
+ })?
+ }
+ _ => {
+ return Err(TranslateError::Catalog(format!(
+ "entry '{}' debe declarar exactamente uno de swm_inline/swm_path",
+ entry.intent
+ )));
+ }
+ };
+ map.insert(entry.intent.clone(), swm);
+ }
+ Ok(Self { map })
+ }
+
+ pub fn with_intent(mut self, intent: impl Into<String>, swm: Swm) -> Self {
+ self.map.insert(intent.into(), swm);
+ self
+ }
+}
+
+impl IntentTranslator for MockTranslator {
+ fn translate(&self, intent: &str, _ctx: &SystemContext) -> Result<Swm, TranslateError> {
+ self.map
+ .get(intent)
+ .cloned()
+ .ok_or_else(|| TranslateError::UnknownIntent(intent.into()))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use hammer_core::swm::{Base, Mutation};
+ use std::collections::BTreeMap;
+
+ fn empty_ctx() -> SystemContext {
+ SystemContext {
+ base: BaseRef {
+ distro_version: "2026-06-06".into(),
+ pins: BTreeMap::new(),
+ },
+ extras: serde_json::Value::Null,
+ }
+ }
+
+ fn swm_demo() -> Swm {
+ Swm {
+ swm_version: 1,
+ base: Base { distro_version: "2026-06-06".into(), pins: BTreeMap::new() },
+ mutations: vec![Mutation::ConfigEdit {
+ file: "/etc/network.conf".into(),
+ inline_diff: "- DHCP=yes\n+ STATIC=1\n".into(),
+ }],
+ signature: None,
+ }
+ }
+
+ #[test]
+ fn mock_translator_returns_known_swm() {
+ let t = MockTranslator::empty().with_intent("set static ip", swm_demo());
+ let s = t.translate("set static ip", &empty_ctx()).unwrap();
+ assert_eq!(s.mutations.len(), 1);
+ }
+
+ #[test]
+ fn mock_translator_rejects_unknown() {
+ let t = MockTranslator::empty();
+ let err = t.translate("paint the wall green", &empty_ctx()).unwrap_err();
+ assert!(matches!(err, TranslateError::UnknownIntent(_)));
+ }
+
+ #[test]
+ fn catalog_load_inline() {
+ let d = tempfile::tempdir().unwrap();
+ let cat_path = d.path().join("catalog.yaml");
+ std::fs::write(
+ &cat_path,
+ r#"
+version: 1
+intents:
+ - intent: "set static ip"
+ swm_inline:
+ swm_version: 1
+ base:
+ distro_version: "2026-06-06"
+ mutations:
+ - type: config_edit
+ file: "/etc/network.conf"
+ inline_diff: "- DHCP=yes\n+ STATIC=1\n"
+"#,
+ )
+ .unwrap();
+ let cat = IntentCatalog::load_from_path(&cat_path).unwrap();
+ let t = MockTranslator::from_catalog(&cat, d.path()).unwrap();
+ let s = t.translate("set static ip", &empty_ctx()).unwrap();
+ assert_eq!(s.mutations.len(), 1);
+ }
+
+ #[test]
+ fn catalog_load_from_swm_path() {
+ let d = tempfile::tempdir().unwrap();
+ let swm_path = d.path().join("net.swm");
+ std::fs::write(&swm_path, swm_demo().to_yaml().unwrap()).unwrap();
+ let cat_path = d.path().join("catalog.yaml");
+ std::fs::write(
+ &cat_path,
+ r#"
+version: 1
+intents:
+ - intent: "set static ip"
+ swm_path: "net.swm"
+"#,
+ )
+ .unwrap();
+ let cat = IntentCatalog::load_from_path(&cat_path).unwrap();
+ let t = MockTranslator::from_catalog(&cat, d.path()).unwrap();
+ let s = t.translate("set static ip", &empty_ctx()).unwrap();
+ assert_eq!(s.mutations.len(), 1);
+ }
+
+ #[test]
+ fn catalog_rejects_unsupported_version() {
+ let d = tempfile::tempdir().unwrap();
+ let cat_path = d.path().join("catalog.yaml");
+ std::fs::write(&cat_path, "version: 9\nintents: []\n").unwrap();
+ let err = IntentCatalog::load_from_path(&cat_path).unwrap_err();
+ assert!(matches!(err, TranslateError::Catalog(_)));
+ }
+
+ #[test]
+ fn catalog_rejects_entry_without_swm() {
+ let d = tempfile::tempdir().unwrap();
+ let cat_path = d.path().join("catalog.yaml");
+ std::fs::write(
+ &cat_path,
+ "version: 1\nintents:\n - intent: empty\n",
+ )
+ .unwrap();
+ let cat = IntentCatalog::load_from_path(&cat_path).unwrap();
+ let err = MockTranslator::from_catalog(&cat, d.path()).unwrap_err();
+ assert!(matches!(err, TranslateError::Catalog(_)));
+ }
+}
diff --git a/crates/hammer-agent/tests/ai_loop_e2e.rs b/crates/hammer-agent/tests/ai_loop_e2e.rs
new file mode 100644
index 00000000..0a39135c
--- /dev/null
+++ b/crates/hammer-agent/tests/ai_loop_e2e.rs
@@ -0,0 +1,150 @@
+//! E2E del bucle agéntico (Fase 6):
+//!
+//! Camino directo (sin bus): un `MockTranslator` resuelve un intent conocido y el
+//! `Orchestrator` aplica las mutaciones bajo un prefix temporal. La prueba final pinta el
+//! cambio en disco y verifica que es el esperado.
+//!
+//! NO ejercita el bus aquí — el camino bus + cliente se cubre en
+//! `crates/hammer-agent/tests/client_e2e.rs` cuando lo construyamos sobre los crates
+//! `hammerd` (Fase 5). Phase 6 ya prueba bus + cliente en bus_e2e.rs del propio hammerd.
+
+use std::collections::BTreeMap;
+
+use base64::{engine::general_purpose::STANDARD, Engine as _};
+use hammer_agent::{
+ orchestrator::{ApplyTarget, CompileMode},
+ MockTranslator, Orchestrator,
+};
+use hammer_core::swm::{Base, Mutation, Swm};
+use hammer_core::{ArtifactHash, BaseRef};
+
+fn baseref() -> BaseRef {
+ let mut pins = BTreeMap::new();
+ pins.insert("musl".into(), "deadbeef".into());
+ BaseRef { distro_version: "2026-06-06".into(), pins }
+}
+
+fn swm_two_mutations() -> Swm {
+ let payload = b"datos finales\n";
+ let payload_hash = ArtifactHash::of_inputs(&[payload.as_slice()]).as_str().to_string();
+ let payload_b64 = STANDARD.encode(payload);
+ Swm {
+ swm_version: 1,
+ base: Base {
+ distro_version: "2026-06-06".into(),
+ pins: {
+ let mut m = BTreeMap::new();
+ m.insert("musl".into(), "deadbeef".into());
+ m
+ },
+ },
+ mutations: vec![
+ Mutation::ConfigEdit {
+ file: "/etc/network.conf".into(),
+ inline_diff: "- DHCP=yes\n+ IP=192.168.1.100\n".into(),
+ },
+ Mutation::FileDrop {
+ path: "/var/lib/agent/notes.txt".into(),
+ content_hash: payload_hash,
+ content_b64: Some(payload_b64),
+ content_url: None,
+ },
+ ],
+ signature: None,
+ }
+}
+
+#[test]
+fn intent_to_overlay_proposal_via_mock_translator() {
+ let d = tempfile::tempdir().unwrap();
+ let prefix = d.path().join("prefix");
+ std::fs::create_dir_all(prefix.join("etc")).unwrap();
+ std::fs::write(
+ prefix.join("etc/network.conf"),
+ "USE=eth0\nDHCP=yes\nMTU=1500\n",
+ )
+ .unwrap();
+
+ let translator = MockTranslator::empty()
+ .with_intent("set static ip and drop notes", swm_two_mutations());
+
+ let orch = Orchestrator::new(
+ translator,
+ baseref(),
+ ApplyTarget::Prefix { prefix: prefix.clone() },
+ CompileMode::Skip,
+ );
+
+ let proposal = orch.run("set static ip and drop notes").expect("proposal");
+
+ assert_eq!(proposal.applied.config_edit, 1);
+ assert_eq!(proposal.applied.file_drop, 1);
+ assert_eq!(proposal.applied.source_patch, 0);
+ assert!(proposal.overlay_id.is_none(), "modo Prefix no abre overlay");
+ assert_eq!(proposal.checks.len(), 2);
+ assert!(
+ proposal
+ .checks
+ .iter()
+ .all(|c| matches!(c.level, hammer_agent::orchestrator::CheckLevel::Pass)),
+ "todas las checks deben pasar"
+ );
+
+ // Verificación post-condición: los archivos en disco coinciden con lo esperado.
+ let net = std::fs::read_to_string(prefix.join("etc/network.conf")).unwrap();
+ assert_eq!(net, "USE=eth0\nIP=192.168.1.100\nMTU=1500\n");
+ let notes = std::fs::read(prefix.join("var/lib/agent/notes.txt")).unwrap();
+ assert_eq!(notes, b"datos finales\n");
+}
+
+#[test]
+fn unknown_intent_surfaces_translator_error() {
+ let d = tempfile::tempdir().unwrap();
+ let orch = Orchestrator::new(
+ MockTranslator::empty(),
+ baseref(),
+ ApplyTarget::Prefix { prefix: d.path().to_path_buf() },
+ CompileMode::Skip,
+ );
+ let err = orch
+ .run("intencion sin entrada en el catalogo")
+ .unwrap_err()
+ .to_string();
+ assert!(err.contains("no reconocida"), "{err}");
+}
+
+#[test]
+fn proposal_is_serializable_for_humans() {
+ // Sanidad: el Proposal serializa a JSON. La CLI o un cliente externo lo puede mostrar.
+ let d = tempfile::tempdir().unwrap();
+ let prefix = d.path().join("prefix");
+ std::fs::create_dir_all(prefix.join("etc")).unwrap();
+ std::fs::write(prefix.join("etc/network.conf"), "DHCP=yes\n").unwrap();
+ let swm = Swm {
+ swm_version: 1,
+ base: Base {
+ distro_version: "2026-06-06".into(),
+ pins: {
+ let mut m = BTreeMap::new();
+ m.insert("musl".into(), "deadbeef".into());
+ m
+ },
+ },
+ mutations: vec![Mutation::ConfigEdit {
+ file: "/etc/network.conf".into(),
+ inline_diff: "- DHCP=yes\n+ STATIC=1\n".into(),
+ }],
+ signature: None,
+ };
+ let translator = MockTranslator::empty().with_intent("x", swm);
+ let orch = Orchestrator::new(
+ translator,
+ baseref(),
+ ApplyTarget::Prefix { prefix },
+ CompileMode::Skip,
+ );
+ let proposal = orch.run("x").unwrap();
+ let json = serde_json::to_string(&proposal).unwrap();
+ assert!(json.contains("\"applied\""));
+ assert!(json.contains("\"checks\""));
+}
diff --git a/crates/hammer-agent/tests/client_against_stub_bus.rs b/crates/hammer-agent/tests/client_against_stub_bus.rs
new file mode 100644
index 00000000..000580e4
--- /dev/null
+++ b/crates/hammer-agent/tests/client_against_stub_bus.rs
@@ -0,0 +1,163 @@
+//! E2E del cliente del bus (Fase 6) contra un servidor stub.
+//!
+//! No depende de `hammerd` ni del lab: un mini-servidor JSON-líneas que conoce el
+//! handshake y responde a `Compile` con `BuildReady` sintético. Demuestra que el cliente
+//! síncrono multiplexa correctamente respuestas (BuildReady) y eventos asíncronos (Modified).
+
+use std::io::{BufRead, BufReader, Write};
+use std::os::unix::net::{UnixListener, UnixStream};
+use std::path::PathBuf;
+use std::thread;
+use std::time::Duration;
+
+use hammer_core::proto::{Cap, Command, Event, Peer, RecipeInline, PROTOCOL_VERSION};
+
+fn start_stub_bus(sock: PathBuf) -> std::sync::mpsc::Sender<Event> {
+ let (tx_async, rx_async) = std::sync::mpsc::channel::<Event>();
+ let _ = std::fs::remove_file(&sock);
+ let listener = UnixListener::bind(&sock).expect("bind stub");
+ thread::Builder::new()
+ .name("stub-bus".into())
+ .spawn(move || {
+ // Sólo aceptamos una conexión: el Receiver no es Clone, se mueve al handler.
+ if let Some(Ok(stream)) = listener.incoming().next() {
+ thread::spawn(move || stub_serve(stream, rx_async));
+ }
+ })
+ .unwrap();
+ tx_async
+}
+
+fn stub_serve(stream: UnixStream, rx_async: std::sync::mpsc::Receiver<Event>) {
+ let reader_stream = stream.try_clone().unwrap();
+ let mut writer = stream;
+ let writer_clone = writer.try_clone().unwrap();
+
+ // Hilo que reenvía los eventos asíncronos al cliente. Mover el writer impide que
+ // se mezclen los bytes con las respuestas (cada Sender los serializa atómicamente).
+ let async_writer = std::sync::Arc::new(std::sync::Mutex::new(writer_clone));
+ let aw_for_async = async_writer.clone();
+ thread::spawn(move || {
+ for ev in rx_async {
+ let line = serde_json::to_string(&ev).unwrap();
+ let mut g = aw_for_async.lock().unwrap();
+ let _ = g.write_all(line.as_bytes());
+ let _ = g.write_all(b"\n");
+ let _ = g.flush();
+ }
+ });
+
+ let mut reader = BufReader::new(reader_stream);
+ let mut first = String::new();
+ reader.read_line(&mut first).unwrap();
+ let cmd: Command = serde_json::from_str(first.trim()).unwrap();
+ let Command::Hello { ver: _, client: _ } = cmd else {
+ return;
+ };
+ // welcome
+ let w = Event::Welcome {
+ ver: PROTOCOL_VERSION,
+ caps: vec![Cap::Query, Cap::Compile, Cap::Inject, Cap::Init],
+ peer: Peer { uid: 1000, gid: 1000, pid: 1 },
+ };
+ let mut g = async_writer.lock().unwrap();
+ g.write_all(serde_json::to_string(&w).unwrap().as_bytes()).unwrap();
+ g.write_all(b"\n").unwrap();
+ g.flush().unwrap();
+ drop(g);
+
+ // Loop de comandos.
+ loop {
+ let mut line = String::new();
+ match reader.read_line(&mut line) {
+ Ok(0) => break,
+ Ok(_) => {}
+ Err(_) => break,
+ }
+ if line.trim().is_empty() {
+ continue;
+ }
+ let cmd: Command = match serde_json::from_str(line.trim()) {
+ Ok(c) => c,
+ Err(_) => continue,
+ };
+ let response = match cmd {
+ Command::Compile { recipe } => Event::BuildReady {
+ recipe: recipe.name.clone(),
+ artifact: format!("b3:stub-{}", recipe.commit),
+ },
+ Command::Query { what, .. } => Event::QueryResult {
+ what,
+ value: serde_json::json!({"stub": true}),
+ },
+ Command::Init { cmd } => Event::InitAck { cmd },
+ Command::Inject { artifact, target, .. } => Event::Injected {
+ artifact,
+ target,
+ files: 1,
+ },
+ Command::Hello { .. } => Event::Error {
+ code: "duplicate_hello".into(),
+ msg: "ya hicimos hello".into(),
+ },
+ };
+ let mut g = async_writer.lock().unwrap();
+ g.write_all(serde_json::to_string(&response).unwrap().as_bytes())
+ .unwrap();
+ g.write_all(b"\n").unwrap();
+ g.flush().unwrap();
+ drop(g);
+
+ // Cierra solo si el cliente cierra; aquí seguimos.
+ if !writer.flush().is_ok() {
+ break;
+ }
+ }
+}
+
+#[test]
+fn client_handshake_compile_and_async_modified() {
+ let d = tempfile::tempdir().unwrap();
+ let sock = d.path().join("stub.sock");
+ let async_tx = start_stub_bus(sock.clone());
+
+ // Pequeño wait para que el listener bindee.
+ for _ in 0..50 {
+ if sock.exists() { break }
+ thread::sleep(Duration::from_millis(10));
+ }
+
+ let mut client = hammer_agent::AgentClient::connect(&sock).expect("connect");
+ assert_eq!(client.welcome.ver, PROTOCOL_VERSION);
+ assert!(client.welcome.caps.contains(&Cap::Compile));
+
+ let recipe = RecipeInline {
+ name: "grep".into(),
+ repo: "git://example/grep.git".into(),
+ commit: "abc123".into(),
+ patch: None,
+ compiler: "zig-cc".into(),
+ target: "x86_64-linux-musl".into(),
+ link: "static".into(),
+ flags: vec![],
+ };
+ let artifact = client
+ .compile(recipe, Duration::from_secs(2))
+ .expect("BuildReady");
+ assert_eq!(artifact, "b3:stub-abc123");
+
+ // El stub también puede empujar eventos asíncronos. Inyectamos un Modified y vemos
+ // que el cliente lo entrega por drain_async()/next_async().
+ async_tx
+ .send(Event::Modified {
+ path: "/bin/grep".into(),
+ op: "replace".into(),
+ ts: "2026-06-09T00:00:00Z".into(),
+ })
+ .unwrap();
+ let ev = client.next_async(Duration::from_secs(2)).expect("async");
+ match ev {
+ Event::Modified { path, .. } => assert_eq!(path, "/bin/grep"),
+ other => panic!("esperaba Modified, llegó {other:?}"),
+ }
+}
diff --git a/crates/hammer-cli/Cargo.toml b/crates/hammer-cli/Cargo.toml
index ecf9b44b..e60204ab 100644
--- a/crates/hammer-cli/Cargo.toml
+++ b/crates/hammer-cli/Cargo.toml
@@ -16,6 +16,7 @@ hammer-core.workspace = true
hammer-build.workspace = true
hammer-overlay.workspace = true
hammer-journal.workspace = true
+hammer-agent.workspace = true
serde_json.workspace = true
base64.workspace = true
anyhow.workspace = true
diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs
index beec6f63..890bc4bc 100644
--- a/crates/hammer-cli/src/main.rs
+++ b/crates/hammer-cli/src/main.rs
@@ -133,6 +133,30 @@ enum Cmd {
#[arg(long)]
since: Option<String>,
},
+ /// [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).
+ 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.
+ #[arg(long)]
+ catalog: PathBuf,
+ /// Re-rootea las mutaciones bajo este prefix en vez de abrir overlay. Útil para
+ /// dev/CI sin root ni overlayfs.
+ #[arg(long)]
+ prefix: Option<PathBuf>,
+ /// Base local (JSON). Si está, el orquestador verifica `verify_base` contra ella.
+ #[arg(long)]
+ base_ref: Option<PathBuf>,
+ /// Socket de hammerd. Si está + el .swm trae source_patch, el orquestador llama
+ /// Compile por el bus. Si no, source_patch se omite con warning.
+ #[arg(long)]
+ bus: Option<PathBuf>,
+ /// Raíz de overlays para `try`. Default coincide con `hammer try`.
+ #[arg(long)]
+ state_root: Option<PathBuf>,
+ },
/// [Fase 5] Envía un comando al FIFO de control humano del init.
/// Por defecto: /run/init.control. Equivalente a `echo "<line>" > /run/init.control`,
/// pero con un mensaje de error claro si el FIFO no existe.
@@ -309,6 +333,17 @@ fn main() -> anyhow::Result<()> {
Cmd::Export { base_ref, journal, since } => {
run_export(base_ref.as_deref(), &journal, since.as_deref())?;
}
+ Cmd::Ai { intent, catalog, prefix, base_ref, bus, state_root } => {
+ run_ai(
+ &intent,
+ &catalog,
+ prefix.as_deref(),
+ base_ref.as_deref(),
+ bus.as_deref(),
+ state_root.as_deref(),
+ &cli.store,
+ )?;
+ }
Cmd::Ctl { line, fifo } => {
run_ctl(&line, &fifo)?;
}
@@ -602,6 +637,114 @@ fn run_export(
Ok(())
}
+/// Ejecuta el bucle agéntico de Fase 6 vía `hammer-agent`. Acepta una intención NL +
+/// catálogo YAML que mapea intentos exactos a `.swm`s pre-armados. La salida es un
+/// `Proposal` legible en stdout + el siguiente paso recomendado al humano.
+fn run_ai(
+ intent: &str,
+ catalog: &std::path::Path,
+ prefix: Option<&std::path::Path>,
+ base_ref: Option<&std::path::Path>,
+ bus: Option<&std::path::Path>,
+ state_root: Option<&std::path::Path>,
+ store_root: &str,
+) -> anyhow::Result<()> {
+ use hammer_agent::{
+ orchestrator::{ApplyTarget, CompileMode},
+ 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}"))?;
+
+ // 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"),
+ }
+ }
+ };
+
+ let apply = match prefix {
+ Some(p) => ApplyTarget::Prefix { prefix: p.to_path_buf() },
+ None => ApplyTarget::Overlay {
+ state_root: state_root
+ .map(|p| p.to_path_buf())
+ .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT)),
+ },
+ };
+
+ let compile = match bus {
+ Some(s) => CompileMode::ViaBus {
+ sock: s.to_path_buf(),
+ store: PathBuf::from(store_root),
+ timeout: std::time::Duration::from_secs(600),
+ },
+ None => CompileMode::Skip,
+ };
+
+ let orch = Orchestrator::new(translator, base, apply, compile);
+ let proposal = orch
+ .run(intent)
+ .map_err(|e| anyhow::anyhow!("orchestrator: {e}"))?;
+
+ println!("--- proposal ---");
+ println!("intent: {}", proposal.intent);
+ println!(
+ "applied: source_patch={} config_edit={} init_rule={} file_drop={}",
+ proposal.applied.source_patch,
+ proposal.applied.config_edit,
+ proposal.applied.init_rule,
+ proposal.applied.file_drop,
+ );
+ if proposal.skipped_source_patches > 0 {
+ println!(
+ "skipped_source_patches: {} (sin --bus)",
+ proposal.skipped_source_patches
+ );
+ }
+ println!("checks:");
+ for c in &proposal.checks {
+ println!(" [{:?}] {}", c.level, c.msg);
+ }
+ match (&proposal.overlay_id, &proposal.prefix) {
+ (Some(id), _) => {
+ println!("\noverlay {id} listo.");
+ println!(" Revisa el cambio en caliente y luego:");
+ println!(" hammer commit {id} # promueve + diario");
+ println!(" hammer discard {id} # descarta");
+ }
+ (None, Some(p)) => {
+ println!("\nprefix {} actualizado (modo test, sin overlay).", p.display());
+ }
+ _ => {}
+ }
+ Ok(())
+}
+
/// Envía una línea al FIFO de control humano. No abrimos un proceso intermedio (sería
/// `sh -c "echo ... > FIFO"` con riesgo de quoting); usamos un `OpenOptions::write` y un
/// `write_all`. El FIFO bloquea si no hay reader — eso es la garantía de "ack" implícito.
diff --git a/crates/hammer-core/src/lib.rs b/crates/hammer-core/src/lib.rs
index 46f1cca1..a029a97a 100644
--- a/crates/hammer-core/src/lib.rs
+++ b/crates/hammer-core/src/lib.rs
@@ -6,6 +6,7 @@
pub mod apply;
pub mod hash;
+pub mod proto;
pub mod recipe;
pub mod store;
pub mod swm;
diff --git a/crates/hammerd/src/proto.rs b/crates/hammer-core/src/proto.rs
similarity index 100%
rename from crates/hammerd/src/proto.rs
rename to crates/hammer-core/src/proto.rs
diff --git a/crates/hammerd/src/bus.rs b/crates/hammerd/src/bus.rs
index 35f39edc..1c23f5af 100644
--- a/crates/hammerd/src/bus.rs
+++ b/crates/hammerd/src/bus.rs
@@ -25,7 +25,7 @@ use std::thread;
use nix::sys::socket::{getsockopt, sockopt::PeerCredentials};
use crate::events::EventBus;
-use crate::proto::{Cap, Command, Event, Peer, RecipeInline, PROTOCOL_VERSION};
+use hammer_core::proto::{Cap, Command, Event, Peer, RecipeInline, PROTOCOL_VERSION};
/// Política de capacidades: dado el `Peer` (ya autenticado por SO_PEERCRED), devuelve qué
/// caps tiene esa conexión. Por defecto: misma UID que el daemon ⇒ todo menos `inject-real`;
diff --git a/crates/hammerd/src/events.rs b/crates/hammerd/src/events.rs
index 140880c4..5da2365c 100644
--- a/crates/hammerd/src/events.rs
+++ b/crates/hammerd/src/events.rs
@@ -12,7 +12,7 @@
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::{Arc, Mutex};
-use crate::proto::Event;
+use hammer_core::proto::Event;
#[derive(Clone)]
pub struct EventBus {
@@ -58,7 +58,7 @@ impl EventBus {
#[cfg(test)]
mod tests {
use super::*;
- use crate::proto::Event;
+ use hammer_core::proto::Event;
#[test]
fn publish_reaches_all_subs() {
diff --git a/crates/hammerd/src/main.rs b/crates/hammerd/src/main.rs
index d8c0e3ae..ec957e5f 100644
--- a/crates/hammerd/src/main.rs
+++ b/crates/hammerd/src/main.rs
@@ -15,7 +15,6 @@ use clap::Parser;
mod bus;
mod control;
mod events;
-mod proto;
mod watcher;
#[derive(Parser)]
diff --git a/crates/hammerd/src/watcher.rs b/crates/hammerd/src/watcher.rs
index db7d02e5..1e100726 100644
--- a/crates/hammerd/src/watcher.rs
+++ b/crates/hammerd/src/watcher.rs
@@ -162,7 +162,7 @@ impl Watcher {
self.journal.record(&me)?;
recorded += 1;
if let Some(bus) = &self.events {
- bus.publish(&crate::proto::Event::Modified {
+ bus.publish(&hammer_core::proto::Event::Modified {
path: me.path.display().to_string(),
op: me.op.as_str().to_string(),
ts: me.ts.clone(),
diff --git a/crates/hammerd/tests/bus_e2e.rs b/crates/hammerd/tests/bus_e2e.rs
index e5945657..4ead0c24 100644
--- a/crates/hammerd/tests/bus_e2e.rs
+++ b/crates/hammerd/tests/bus_e2e.rs
@@ -7,9 +7,8 @@
//! grep gated en `HAMMER_NETWORK_TESTS`. Lo que sí verificamos es que un `compile` SIN la
//! capacidad devuelve `Error{code:"no_cap"}`, lo que prueba el dispatcher + el gate.
-// El binario no expone una API pública; importamos el módulo desde `path = ...`.
-#[path = "../src/proto.rs"]
-mod proto;
+// El binario no expone una API pública; importamos sus módulos privados desde
+// `path = ...`. `proto` se movió a hammer-core en Fase 6.
#[path = "../src/events.rs"]
mod events;
#[path = "../src/control.rs"]
@@ -24,7 +23,7 @@ use std::sync::Arc;
use std::thread;
use std::time::Duration;
-use proto::{Cap, Command, Event, Peer, RecipeInline};
+use hammer_core::proto::{Cap, Command, Event, Peer, RecipeInline};
/// Helper: lanza un bus en background y devuelve la ruta del socket. Limpia al final del
/// test gracias al `tempfile::TempDir` que se le pasa al caller.
diff --git a/docs/08-ai-integration.md b/docs/08-ai-integration.md
index 76525b76..9c16b680 100644
--- a/docs/08-ai-integration.md
+++ b/docs/08-ai-integration.md
@@ -77,14 +77,22 @@ expuestas y de baja entropía**, que es justo lo que un agente necesita.
## 5. De la intención al `.swm`: el rol del modelo
-El traductor intención→`.swm` es un LLM. Detalles de modelo/API (Claude, herramientas, prompts)
-se documentarán aparte cuando se implemente la Fase 6; aquí sólo fijamos el contrato:
+El traductor intención→`.swm` se conecta detrás del trait `IntentTranslator` del crate
+`hammer-agent`. Contrato:
-- **Entrada:** intención en NL + contexto del sistema (qué herramientas hay, qué pins, estado
- de servicios — todo consultable por `QUERY`).
-- **Salida:** un `.swm` válido ([SDD 06](06-swm-format.md)) + un plan de verificación.
-- **El modelo no ejecuta nada directamente:** emite comandos por el bus, que `hammerd` valida
- contra las capacidades concedidas.
+- **Entrada:** intención en NL + `SystemContext` (lo que el orquestador recolecta por
+ `QUERY`: `BaseRef` local, extras JSON).
+- **Salida:** un `Swm` válido ([SDD 06](06-swm-format.md)).
+
+Fase 6 entrega un `MockTranslator` que resuelve intentos contra un `IntentCatalog` YAML
+(intent exacto → `.swm` inline o `swm_path` relativo). Eso permite probar el bucle sin LLM:
+el humano que descubrió la intención con un modelo real congela el resultado y los CI lo
+reproducen byte-a-byte. El traductor LLM (Claude API u otro) implementa el mismo trait y se
+enchufa sin tocar el `Orchestrator`.
+
+**El modelo nunca ejecuta nada directamente:** emite comandos por el bus, que `hammerd`
+valida contra las capacidades concedidas, y el `Orchestrator` traduce las mutaciones a
+`hammer-core::apply` o a `Compile` por el bus.
## 6. Lenguaje de consulta/transformación (visión, Fase posterior)
@@ -101,13 +109,34 @@ esté probado.
## 7. Interfaz
+El crate `hammer-agent` (Fase 6) expone:
+
```rust
-// cliente del bus (puede vivir en un crate aparte hammer-agent en Fase 6)
-pub trait AgentClient {
- fn hello(&mut self) -> Result<Caps>;
- fn compile(&mut self, recipe: &Recipe) -> Result<ArtifactHash>;
- fn inject(&mut self, h: &ArtifactHash, target: &Path, overlay: OverlayId) -> Result<()>;
- fn query(&mut self, q: Query) -> Result<QueryResult>;
- fn events(&mut self) -> impl Iterator<Item = BusEvent>;
+// hammer_agent::client
+pub struct AgentClient { pub welcome: Welcome, /* … */ }
+impl AgentClient {
+ pub fn connect(sock: &Path) -> Result<Self>;
+ pub fn compile(&mut self, recipe: RecipeInline, timeout: Duration) -> Result<String>; // artifact
+ pub fn inject(&mut self, artifact: &str, target: &str, overlay: Option<&str>, t: Duration) -> Result<usize>;
+ pub fn query_file(&mut self, path: &str, timeout: Duration) -> Result<serde_json::Value>;
+ pub fn init(&mut self, cmd: &str, timeout: Duration) -> Result<()>;
+ pub fn drain_async(&self) -> Vec<Event>;
+ pub fn next_async(&self, timeout: Duration) -> Result<Event>;
+}
+
+// hammer_agent::translator
+pub trait IntentTranslator { fn translate(&self, intent: &str, ctx: &SystemContext) -> Result<Swm, TranslateError>; }
+pub struct MockTranslator { /* HashMap<intent, Swm> */ }
+pub struct IntentCatalog { pub intents: Vec<CatalogEntry> } // YAML loader
+
+// hammer_agent::orchestrator
+pub struct Orchestrator<T: IntentTranslator> { /* … */ }
+impl<T> Orchestrator<T> {
+ pub fn new(t: T, base: BaseRef, apply: ApplyTarget, compile: CompileMode) -> Self;
+ pub fn run(&self, intent: &str) -> Result<Proposal>;
}
```
+
+Los tipos del protocolo (`Command`/`Event`/`Cap`/`Peer`/`RecipeInline`) viven en
+`hammer-core::proto` para ser compartidos por `hammerd`, `hammer-agent` y cualquier cliente
+externo escrito en Rust.
diff --git a/docs/10-roadmap.md b/docs/10-roadmap.md
index 02d0d77a..8a925467 100644
--- a/docs/10-roadmap.md
+++ b/docs/10-roadmap.md
@@ -99,10 +99,32 @@ pre-requisito de validación.
gating `no_cap`, `Query`, `Modified` fan-out, `Init`→FIFO. Un `Compile` real reusa el
camino de Fase 0/1 (gated en `HAMMER_NETWORK_TESTS`).
-### Fase 6 — Integración de la IA
-- [ ] Cliente de agente; traductor intención NL → `.swm`; bucle plan→build→try→verify→propose.
+### Fase 6 — Integración de la IA ▶ *en progreso*
+- [x] Crate `hammer-agent` con tres piezas:
+ - `AgentClient`: cliente síncrono del bus (handshake, `compile`/`inject`/`query`/`init`
+ bloqueantes, drenado de eventos asíncronos).
+ - `IntentTranslator` (trait) + `MockTranslator` cargado desde un `IntentCatalog` YAML
+ (intent → `.swm` pre-armado). La integración con un LLM real se enchufa detrás del
+ mismo trait sin tocar el bucle.
+ - `Orchestrator` con `run(intent) → Proposal`: plan → schema → base → try (overlay
+ o prefix) → apply (mutaciones puras + source_patch vía bus opcional) → verify →
+ propose.
+- [x] CLI: `hammer ai <intent> --catalog F [--prefix DIR --base-ref F --bus SOCK]`.
+- [x] Tipos del protocolo del bus movidos a `hammer-core::proto` para que `hammerd` y
+ `hammer-agent` los compartan.
+- [x] Tests:
+ - 10 unit en `hammer-agent` (translator + catalog + orchestrator).
+ - 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.
+- [ ] Lenguaje de consulta del sistema (SDD 08 §6) para que la IA refiera servicios y
+ archivos sin rutas frágiles.
+- [ ] Bucle de auto-reparación (cliente reacciona a `Crashed` con un nuevo plan).
- **Hecho cuando:** una intención en lenguaje natural produce un cambio probado en overlay,
- presentado para `commit` humano.
+ presentado para `commit` humano. ✅ Demostrado por `hammer ai` con `MockTranslator`:
+ intent → `.swm` → mutaciones aplicadas → `Proposal` con `overlay_id` + checks. El humano
+ decide `commit`/`discard`. El upgrade a LLM real reusa todo el bucle.
---