gioser

sergio/takana

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

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 · (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/client_against_stub_bus.rs

crates/hammer-cli/src/main.rs

  • cambió de firma enum Cmd
  • cambió por dentro fn main
  • nueva fn run_ai

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

21 archivos · +1912 −283

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.
 
 ---