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…

TOML 1572Shell 203Rust 109Markdown 993728 Definitions

⎇ 89ecd54135a72e486f94df045d191d231b253638 crates/hammer-cli/src/main.rs Rust · 769 lines · 30.3 KB History Who touched what raw

Fase 6 — bucle agéntico: hammer-agent (cliente + translator + orchestrator) y hammer ai 89ecd5413 · Sergio ·

1//! `hammer` — el punto de entrada de todo flujo manual. Ver `docs/`.
2//!
3//! Los subcomandos están mapeados a las fases del roadmap (`docs/10-roadmap.md`). Los que aún
4//! no están implementados devuelven un error claro indicando su fase, para que el esqueleto
5//! sea navegable desde el día uno.
6
7use std::path::PathBuf;
8
9use clap::{Parser, Subcommand};
10
11const DEFAULT_STORE: &str = "/store";
12
13#[derive(Parser)]
14#[command(
15    name = "hammer",
16    version,
17    about = "Laboratorio funcional en el sótano, terminal mutable arriba.",
18    long_about = "hammer: distro AI-nativa. Ver docs/ para el diseño completo."
19)]
20struct Cli {
21    /// Ruta del content-addressed store.
22    #[arg(long, default_value = DEFAULT_STORE, global = true)]
23    store: String,
24
25    #[command(subcommand)]
26    cmd: Cmd,
27}
28
29#[derive(Subcommand)]
30enum Cmd {
31    /// [Fase 0] Compila una receta y la sella en el store.
32    Build {
33        /// Ruta a la receta TOML.
34        recipe: String,
35    },
36    /// [Fase 1] Proyecta un artefacto del store al FHS (real o de overlay).
37    Hydrate {
38        /// Hash del artefacto (acepta prefijos cortos; con o sin `b3:`).
39        hash: String,
40        #[arg(long, default_value = "/")]
41        into: String,
42        /// Modo de enlazado: `static` (hardlink directo) | `dynamic` (requiere patchelf).
43        #[arg(long, default_value = "static")]
44        link: String,
45    },
46    /// [Fase 2] Monta un overlay de experimentación sobre los directorios del sistema.
47    /// Sin targets → cubre el FHS clásico (/usr/bin, /bin, /usr/lib, /lib, /etc, …).
48    Try {
49        /// Directorios a poner bajo overlay. Si vacío, usa la lista por defecto.
50        targets: Vec<PathBuf>,
51        /// Raíz donde viven los manifiestos y los upper/work. Default `/var/lib/hammer/overlays`.
52        #[arg(long)]
53        state_root: Option<PathBuf>,
54    },
55    /// [Fase 2+3] Fusiona un overlay al FHS real y desmonta. Por defecto registra cada
56    /// archivo promocionado en el diario; usa `--no-journal` para saltarse el registro.
57    Commit {
58        id: String,
59        #[arg(long)]
60        state_root: Option<PathBuf>,
61        /// Directorio del diario donde anotar la promoción.
62        #[arg(long, default_value = "/var/lib/hammer/journal")]
63        journal: PathBuf,
64        /// Saltarse el registro en el diario (útil para experimentos efímeros).
65        #[arg(long)]
66        no_journal: bool,
67    },
68    /// [Fase 2] Desmonta un overlay y borra su upper. El sistema vuelve al estado base.
69    Discard {
70        id: String,
71        #[arg(long)]
72        state_root: Option<PathBuf>,
73    },
74    /// [Fase 2] Lista los overlays activos según los manifiestos persistidos.
75    Status {
76        #[arg(long)]
77        state_root: Option<PathBuf>,
78    },
79    /// [Fase 3] Ver/seguir el diario de mutaciones.
80    Journal {
81        /// Directorio del diario (mismo que `hammerd --journal`).
82        #[arg(long, default_value = "/var/lib/hammer/journal")]
83        dir: PathBuf,
84        /// Muestra los últimos N eventos. Default: 20.
85        #[arg(long, short, default_value = "20")]
86        tail: usize,
87        /// Sigue el archivo: imprime nuevos eventos según aparecen.
88        #[arg(long, short)]
89        follow: bool,
90        /// Formato. `pretty` (humano, default) o `json` (raw passthrough).
91        #[arg(long, default_value = "pretty")]
92        format: String,
93    },
94    /// [Fase 4] Aplica un manifiesto .swm. Por defecto abre un overlay sobre el FHS y aplica
95    /// las mutaciones ahí; con `--prefix DIR` opera directamente en `DIR` sin overlay
96    /// (útil para tests offline o staging en otro filesystem).
97    Apply {
98        file: String,
99        /// Si está, re-rootea todas las rutas absolutas del .swm bajo este prefix y NO abre
100        /// overlay. `--prefix /mnt/foo` ⇒ `/etc/network.conf` → `/mnt/foo/etc/network.conf`.
101        #[arg(long)]
102        prefix: Option<PathBuf>,
103        /// Si está, salta source_patch silenciosamente. Útil para probar el flujo de
104        /// config_edit/file_drop sin lab disponible.
105        #[arg(long)]
106        skip_source_patch: bool,
107        /// Tras aplicar, intenta verificar pin contra base local de `--base-ref`.
108        #[arg(long)]
109        base_ref: Option<PathBuf>,
110        /// Raíz del overlay (sólo en modo overlay).
111        #[arg(long)]
112        state_root: Option<PathBuf>,
113    },
114    /// [Fase 4] Verifica un .swm: schema, base (si se da `--base-ref`), y opcionalmente el
115    /// hash de cada `file_drop` inline.
116    SwmVerify {
117        file: String,
118        #[arg(long)]
119        base_ref: Option<PathBuf>,
120    },
121    /// [Fase 4] Exporta el diario actual como manifiesto .swm a stdout.
122    /// Mutaciones trazables ⇒ file_drop con content_b64 y hash. Mutaciones opacas ⇒
123    /// mismas pero con `note` indicando el origen (warning legible para el receptor).
124    Export {
125        /// Archivo JSON con la base local; se inserta como `base` en el .swm. Si no se da,
126        /// se emite una base con `distro_version: "unknown"` y se imprime un warning.
127        #[arg(long)]
128        base_ref: Option<PathBuf>,
129        /// Directorio del diario.
130        #[arg(long, default_value = "/var/lib/hammer/journal")]
131        journal: PathBuf,
132        /// Sólo eventos a partir de este timestamp (RFC 3339). Si no se da, exporta todo.
133        #[arg(long)]
134        since: Option<String>,
135    },
136    /// [Fase 6] Ejecuta el bucle agéntico: traduce una intención NL a un .swm vía catálogo
137    /// mock y la aplica a un overlay (o prefix) sin promover al FHS. Imprime el Proposal.
138    Ai {
139        /// La intención en lenguaje natural (string exacta, indexada en el catálogo).
140        intent: String,
141        /// Catálogo YAML de intent → swm. Cuando exista un traductor LLM real, este flag
142        /// será opcional y se documentará el path por defecto.
143        #[arg(long)]
144        catalog: PathBuf,
145        /// Re-rootea las mutaciones bajo este prefix en vez de abrir overlay. Útil para
146        /// dev/CI sin root ni overlayfs.
147        #[arg(long)]
148        prefix: Option<PathBuf>,
149        /// Base local (JSON). Si está, el orquestador verifica `verify_base` contra ella.
150        #[arg(long)]
151        base_ref: Option<PathBuf>,
152        /// Socket de hammerd. Si está + el .swm trae source_patch, el orquestador llama
153        /// Compile por el bus. Si no, source_patch se omite con warning.
154        #[arg(long)]
155        bus: Option<PathBuf>,
156        /// Raíz de overlays para `try`. Default coincide con `hammer try`.
157        #[arg(long)]
158        state_root: Option<PathBuf>,
159    },
160    /// [Fase 5] Envía un comando al FIFO de control humano del init.
161    /// Por defecto: /run/init.control. Equivalente a `echo "<line>" > /run/init.control`,
162    /// pero con un mensaje de error claro si el FIFO no existe.
163    Ctl {
164        /// La línea a enviar (p. ej. "start web", "restart network").
165        line: String,
166        #[arg(long, default_value = "/run/init.control")]
167        fifo: PathBuf,
168    },
169}
170
171fn print_event(ev: &hammer_journal::MutationEvent, format: &str) {
172    match format {
173        "json" => {
174            // Re-serializa. No imprimimos la línea original (no la tenemos a mano), pero el
175            // formato es estable porque MutationEvent es la fuente de verdad del schema.
176            match serde_json::to_string(ev) {
177                Ok(s) => println!("{s}"),
178                Err(e) => eprintln!("journal: error serializando: {e}"),
179            }
180        }
181        _ => {
182            let actor = match &ev.by.source {
183                hammer_journal::Source::HammerHydrate { artifact } => format!("hydrate {artifact}"),
184                hammer_journal::Source::HammerCommit { overlay, artifact } => match artifact {
185                    Some(a) => format!("commit {overlay} ({a})"),
186                    None => format!("commit {overlay}"),
187                },
188                hammer_journal::Source::External => {
189                    let pid = ev.by.pid.map(|p| format!("pid={p}")).unwrap_or_default();
190                    let uid = ev.by.uid.map(|u| format!("uid={u}")).unwrap_or_default();
191                    format!("external {pid} {uid}").trim().to_string()
192                }
193            };
194            println!(
195                "{ts} {op:<7} {path}  ←  {actor}",
196                ts = ev.ts,
197                op = ev.op.as_str(),
198                path = ev.path.display(),
199            );
200        }
201    }
202}
203
204fn main() -> anyhow::Result<()> {
205    tracing_subscriber::fmt()
206        .with_writer(std::io::stderr)
207        .with_env_filter(
208            tracing_subscriber::EnvFilter::try_from_default_env()
209                .unwrap_or_else(|_| "info".into()),
210        )
211        .init();
212
213    let cli = Cli::parse();
214
215    // Sólo las cmds que de verdad tocan artefactos abren (y posiblemente crean) el store.
216    // Las cmds de overlay/diario/apply pueden correr en sistemas sin store local todavía.
217    match cli.cmd {
218        Cmd::Build { recipe } => {
219            let store = hammer_core::Store::open(&cli.store)?;
220            let recipe = hammer_core::Recipe::load_from_path(&recipe)?;
221            let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
222            let hash = hammer_build::build(&recipe, &cfg, &store)?;
223            println!("{hash}");
224        }
225        Cmd::Hydrate { hash, into, link } => {
226            let store = hammer_core::Store::open(&cli.store)?;
227            let mode = match link.as_str() {
228                "static" => hammer_core::LinkMode::Static,
229                "dynamic" => hammer_core::LinkMode::Dynamic,
230                other => anyhow::bail!("--link debe ser 'static' o 'dynamic', no '{other}'"),
231            };
232            let artifact_dir = store.find_by_hash(&hash)?;
233            let report = hammer_build::run_hydrate(
234                &artifact_dir,
235                std::path::Path::new(&into),
236                mode,
237            )?;
238            println!(
239                "hydrated {} archivo(s) ({} dir(s), {} symlink(s)) → {}",
240                report.files.len(),
241                report.dirs_created,
242                report.symlinks,
243                into
244            );
245            for f in &report.files {
246                println!("  {}", f.dst.display());
247            }
248        }
249        Cmd::Try { targets, state_root } => {
250            let root = state_root
251                .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
252            std::fs::create_dir_all(&root)?;
253            let id = hammer_overlay::try_overlay(&targets, &root)?;
254            println!("{id}");
255        }
256        Cmd::Commit { id, state_root, journal, no_journal } => {
257            let root = state_root
258                .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
259            let id = hammer_overlay::OverlayId(id);
260            let report = if no_journal {
261                hammer_overlay::commit(&id, &root)?
262            } else {
263                let j = hammer_journal::Journal::open(&journal)?;
264                hammer_overlay::commit_with_journal(&id, &root, &j)?
265            };
266            println!(
267                "commit OK — {} archivo(s) promocionados, {} eliminado(s){}",
268                report.copied.len(),
269                report.removed.len(),
270                if no_journal { "" } else { " (anotado en el diario)" }
271            );
272        }
273        Cmd::Discard { id, state_root } => {
274            let root = state_root
275                .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
276            hammer_overlay::discard(&hammer_overlay::OverlayId(id), &root)?;
277            println!("discard OK");
278        }
279        Cmd::Status { state_root } => {
280            let root = state_root
281                .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
282            let overlays = hammer_overlay::status(&root)?;
283            if overlays.is_empty() {
284                println!("(sin overlays activos)");
285            } else {
286                for o in overlays {
287                    println!(
288                        "{}  created={}  mounts={}",
289                        o.id,
290                        o.created_at,
291                        o.mounts.len()
292                    );
293                    for m in &o.mounts {
294                        println!("  └─ {}  upper={}", m.target.display(), m.upper.display());
295                    }
296                }
297            }
298        }
299        Cmd::Journal { dir, tail, follow, format } => {
300            let j = hammer_journal::Journal::open(&dir)?;
301            let initial = j.tail(tail)?;
302            for ev in &initial {
303                print_event(ev, &format);
304            }
305            if follow {
306                use std::io::Write;
307                let _ = std::io::stdout().flush();
308                let mut cursor = j.end_offset()?;
309                loop {
310                    std::thread::sleep(std::time::Duration::from_millis(500));
311                    let (news, new_cursor) = j.read_from_offset(cursor)?;
312                    cursor = new_cursor;
313                    for ev in &news {
314                        print_event(ev, &format);
315                    }
316                    let _ = std::io::stdout().flush();
317                }
318            }
319        }
320        Cmd::Apply { file, prefix, skip_source_patch, base_ref, state_root } => {
321            run_apply(
322                &cli.store,
323                &file,
324                prefix.as_deref(),
325                skip_source_patch,
326                base_ref.as_deref(),
327                state_root.as_deref(),
328            )?;
329        }
330        Cmd::SwmVerify { file, base_ref } => {
331            run_swm_verify(&file, base_ref.as_deref())?;
332        }
333        Cmd::Export { base_ref, journal, since } => {
334            run_export(base_ref.as_deref(), &journal, since.as_deref())?;
335        }
336        Cmd::Ai { intent, catalog, prefix, base_ref, bus, state_root } => {
337            run_ai(
338                &intent,
339                &catalog,
340                prefix.as_deref(),
341                base_ref.as_deref(),
342                bus.as_deref(),
343                state_root.as_deref(),
344                &cli.store,
345            )?;
346        }
347        Cmd::Ctl { line, fifo } => {
348            run_ctl(&line, &fifo)?;
349        }
350    }
351    Ok(())
352}
353
354/// Lee y deserializa un `.swm` desde disco; falla con mensaje útil si no parsea.
355fn load_swm(file: &str) -> anyhow::Result<hammer_core::Swm> {
356    let yaml = std::fs::read_to_string(file)
357        .map_err(|e| anyhow::anyhow!("no pude leer {file}: {e}"))?;
358    hammer_core::Swm::from_yaml(&yaml)
359        .map_err(|e| anyhow::anyhow!("no pude parsear {file}: {e}"))
360}
361
362/// Lee una `BaseRef` local desde JSON; usada por `apply` y `swm verify` para verificar la
363/// compatibilidad de pins. Si la ruta no existe, devuelve `Ok(None)` para que el caller
364/// pueda decidir entre fallar o seguir con un warning.
365fn load_local_base(path: Option<&std::path::Path>) -> anyhow::Result<Option<hammer_core::BaseRef>> {
366    let Some(p) = path else { return Ok(None) };
367    if !p.is_file() {
368        return Err(anyhow::anyhow!("base-ref {} no existe", p.display()));
369    }
370    let bytes = std::fs::read(p)?;
371    let base: hammer_core::BaseRef =
372        serde_json::from_slice(&bytes).map_err(|e| anyhow::anyhow!("base-ref inválido: {e}"))?;
373    Ok(Some(base))
374}
375
376fn print_base_compat(c: &hammer_core::BaseCompat) {
377    use hammer_core::BaseCompat::*;
378    match c {
379        Ok => eprintln!("verify_base: OK"),
380        DistroMismatch { local, swm } => eprintln!(
381            "verify_base: distro_version no coincide — local={local} swm={swm}"
382        ),
383        PinMismatch(diffs) => {
384            eprintln!("verify_base: {} pin(s) divergente(s):", diffs.len());
385            for d in diffs {
386                let local = d.local.as_deref().unwrap_or("(ausente)");
387                eprintln!("  {}: local={local} swm={}", d.name, d.swm);
388            }
389        }
390    }
391}
392
393fn run_swm_verify(file: &str, base_ref: Option<&std::path::Path>) -> anyhow::Result<()> {
394    let swm = load_swm(file)?;
395    swm.verify_schema()
396        .map_err(|e| anyhow::anyhow!("schema inválido: {e}"))?;
397    println!("schema: OK  ({} mutación(es))", swm.mutations.len());
398    // Verificación de hash de los file_drop inline (no toca disco).
399    for (i, m) in swm.mutations.iter().enumerate() {
400        if let hammer_core::Mutation::FileDrop { content_b64: Some(b64), content_hash, path, .. } = m {
401            hammer_core::apply::verify_content_hash(b64, content_hash).map_err(|e| {
402                anyhow::anyhow!("file_drop #{} en {path}: {e}", i + 1)
403            })?;
404        }
405    }
406    println!("file_drop inline: OK");
407    if let Some(local) = load_local_base(base_ref)? {
408        print_base_compat(&swm.verify_base(&local));
409    } else {
410        eprintln!("verify_base: omitido (sin --base-ref)");
411    }
412    Ok(())
413}
414
415/// Aplica un `.swm`. Dos modos:
416/// - **prefix** (`--prefix DIR`): re-rootea cada path al DIR; no abre overlay. Para tests y
417///   staging.
418/// - **overlay** (default): abre un overlay sobre los targets por defecto y aplica las
419///   mutaciones sobre las rutas reales (que el kernel redirige al upper del overlay).
420fn run_apply(
421    store_path: &str,
422    file: &str,
423    prefix: Option<&std::path::Path>,
424    skip_source_patch: bool,
425    base_ref: Option<&std::path::Path>,
426    state_root: Option<&std::path::Path>,
427) -> anyhow::Result<()> {
428    let swm = load_swm(file)?;
429    swm.verify_schema()?;
430
431    if let Some(local) = load_local_base(base_ref)? {
432        match swm.verify_base(&local) {
433            hammer_core::BaseCompat::Ok => {}
434            other => {
435                print_base_compat(&other);
436                anyhow::bail!("base local no compatible con la base del .swm");
437            }
438        }
439    }
440
441    let mut overlay_id: Option<hammer_overlay::OverlayId> = None;
442    if prefix.is_none() {
443        let root = state_root
444            .map(|p| p.to_path_buf())
445            .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
446        std::fs::create_dir_all(&root)?;
447        let id = hammer_overlay::try_overlay(&[], &root)?;
448        eprintln!("overlay {id} abierto");
449        overlay_id = Some(id);
450    }
451
452    let mut counts = (0usize, 0usize, 0usize, 0usize); // src_patch, cfg, init, drop
453    for (i, m) in swm.mutations.iter().enumerate() {
454        match m {
455            hammer_core::Mutation::SourcePatch { target_bin, .. } => {
456                counts.0 += 1;
457                if skip_source_patch {
458                    eprintln!("  [skip] source_patch #{} → {target_bin}", i + 1);
459                    continue;
460                }
461                let store = hammer_core::Store::open(store_path)?;
462                let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
463                let hash = hammer_build::build_source_patch(m, &cfg, &store, None)?;
464                let artifact = store.find_by_hash(hash.as_str())?;
465                let into = hammer_core::apply::rebase_path("/", prefix);
466                let report = hammer_build::run_hydrate(
467                    &artifact,
468                    &into,
469                    hammer_core::LinkMode::Static,
470                )?;
471                eprintln!(
472                    "  source_patch #{}: {} → hidratados {} archivo(s) en {}",
473                    i + 1,
474                    hash,
475                    report.files.len(),
476                    into.display()
477                );
478                // Sanity: el target_bin declarado debe existir tras la hidratación.
479                let abs = hammer_core::apply::rebase_path(target_bin, prefix);
480                if !abs.exists() {
481                    anyhow::bail!(
482                        "source_patch declara target_bin={target_bin} pero no quedó en {}",
483                        abs.display()
484                    );
485                }
486            }
487            hammer_core::Mutation::ConfigEdit { file: f, inline_diff } => {
488                counts.1 += 1;
489                let target = hammer_core::apply::rebase_path(f, prefix);
490                hammer_core::apply::apply_config_edit(&target, inline_diff)?;
491                eprintln!("  config_edit #{} → {}", i + 1, target.display());
492            }
493            hammer_core::Mutation::InitRule { action, service, .. } => {
494                counts.2 += 1;
495                eprintln!(
496                    "  [pendiente fase 5] init_rule #{}: {action} {service} — ignorando",
497                    i + 1
498                );
499            }
500            hammer_core::Mutation::FileDrop { path, content_hash, content_b64, content_url } => {
501                counts.3 += 1;
502                let target = hammer_core::apply::rebase_path(path, prefix);
503                match (content_b64, content_url) {
504                    (Some(b64), None) => {
505                        hammer_core::apply::apply_file_drop(&target, b64, content_hash)?;
506                        eprintln!("  file_drop #{} → {}", i + 1, target.display());
507                    }
508                    (None, Some(url)) => {
509                        anyhow::bail!(
510                            "file_drop #{} usa content_url={url}: fetch remoto pendiente",
511                            i + 1
512                        );
513                    }
514                    _ => unreachable!("verify_schema lo descarta"),
515                }
516            }
517        }
518    }
519    eprintln!(
520        "apply OK — source_patch={} config_edit={} init_rule={} file_drop={}",
521        counts.0, counts.1, counts.2, counts.3
522    );
523    if let Some(id) = &overlay_id {
524        println!("{id}");
525        eprintln!("overlay listo: usa `hammer commit {id}` para promocionar al FHS real.");
526    }
527    Ok(())
528}
529
530/// Exporta el diario actual como `.swm`. Estrategia honesta para Fase 4:
531///
532/// - Cada evento se traduce a un `file_drop` con `content_b64` (lo que hay en disco *ahora*)
533///   y `content_hash`. Esto garantiza reproducción byte-a-byte sin depender de tener el
534///   recipe original. La pérdida es que perdemos provenance — el receptor obtiene el binario
535///   pero no la receta que lo produjo. Para `source_patch` real necesitaremos un mapa
536///   artefacto→receta que la Fase 4 no construye todavía.
537/// - Mutaciones opacas (`Source::External`) llevan un `note` indicando "origen externo"
538///   para que el receptor sepa que no hay garantía de provenance.
539/// - Mutaciones con `op = Delete` se omiten: un `.swm` describe el estado final por
540///   adición/edición, no por borrado. Es un compromiso consciente (un futuro `FileRemove`
541///   resolvería esto).
542fn run_export(
543    base_ref_path: Option<&std::path::Path>,
544    journal_dir: &std::path::Path,
545    since: Option<&str>,
546) -> anyhow::Result<()> {
547    use base64::{engine::general_purpose::STANDARD, Engine as _};
548    use std::collections::BTreeMap;
549
550    let j = hammer_journal::Journal::open(journal_dir)?;
551    let events = j.read_all()?;
552    let filtered: Vec<_> = match since {
553        Some(t) => events.into_iter().filter(|e| e.ts.as_str() > t).collect(),
554        None => events,
555    };
556
557    let base = match load_local_base(base_ref_path)? {
558        Some(local) => hammer_core::Base {
559            distro_version: local.distro_version,
560            pins: local.pins,
561        },
562        None => {
563            eprintln!("warning: sin --base-ref; emitiendo base con distro_version='unknown'");
564            hammer_core::Base {
565                distro_version: "unknown".into(),
566                pins: BTreeMap::new(),
567            }
568        }
569    };
570
571    // Dedup por path: dentro de un export queremos un único estado final por archivo.
572    // Procesamos en orden y nos quedamos con la última ocurrencia.
573    let mut last_by_path: std::collections::BTreeMap<PathBuf, &hammer_journal::MutationEvent> =
574        std::collections::BTreeMap::new();
575    for ev in &filtered {
576        if matches!(ev.op, hammer_journal::MutationOp::Delete) {
577            last_by_path.remove(&ev.path); // borrado anula cualquier estado previo
578            continue;
579        }
580        last_by_path.insert(ev.path.clone(), ev);
581    }
582
583    let mut mutations = Vec::new();
584    let mut warnings = 0usize;
585    for (path, ev) in &last_by_path {
586        let bytes = match std::fs::read(path) {
587            Ok(b) => b,
588            Err(e) => {
589                warnings += 1;
590                eprintln!(
591                    "warning: no pude leer {} para exportar (ev ts={}): {e}",
592                    path.display(),
593                    ev.ts
594                );
595                continue;
596            }
597        };
598        let h = hammer_core::ArtifactHash::of_inputs(&[bytes.as_slice()]);
599        let b64 = STANDARD.encode(&bytes);
600        let note = match &ev.by.source {
601            hammer_journal::Source::External => Some(format!(
602                "origen externo (pid={:?}, uid={:?}) — sin provenance",
603                ev.by.pid, ev.by.uid
604            )),
605            hammer_journal::Source::HammerHydrate { artifact } => {
606                Some(format!("hydrate {artifact}"))
607            }
608            hammer_journal::Source::HammerCommit { overlay, artifact } => match artifact {
609                Some(a) => Some(format!("commit {overlay} ({a})")),
610                None => Some(format!("commit {overlay}")),
611            },
612        };
613        // El note vive en el journal, no en el schema del file_drop — lo omitimos en el
614        // YAML emitido por ahora; está en stderr (warnings) si era external.
615        let _ = note;
616        mutations.push(hammer_core::Mutation::FileDrop {
617            path: path.display().to_string(),
618            content_hash: h.as_str().to_string(),
619            content_b64: Some(b64),
620            content_url: None,
621        });
622    }
623
624    let swm = hammer_core::Swm {
625        swm_version: 1,
626        base,
627        mutations,
628        signature: None,
629    };
630    let yaml = swm.to_yaml()?;
631    print!("{yaml}");
632    eprintln!(
633        "export: {} mutación(es), {} warning(s)",
634        swm.mutations.len(),
635        warnings
636    );
637    Ok(())
638}
639
640/// Ejecuta el bucle agéntico de Fase 6 vía `hammer-agent`. Acepta una intención NL +
641/// catálogo YAML que mapea intentos exactos a `.swm`s pre-armados. La salida es un
642/// `Proposal` legible en stdout + el siguiente paso recomendado al humano.
643fn run_ai(
644    intent: &str,
645    catalog: &std::path::Path,
646    prefix: Option<&std::path::Path>,
647    base_ref: Option<&std::path::Path>,
648    bus: Option<&std::path::Path>,
649    state_root: Option<&std::path::Path>,
650    store_root: &str,
651) -> anyhow::Result<()> {
652    use hammer_agent::{
653        orchestrator::{ApplyTarget, CompileMode},
654        IntentCatalog, MockTranslator, Orchestrator,
655    };
656
657    let cat = IntentCatalog::load_from_path(catalog)
658        .map_err(|e| anyhow::anyhow!("catálogo {}: {e}", catalog.display()))?;
659    let base_dir = catalog.parent().unwrap_or_else(|| std::path::Path::new("."));
660    let translator = MockTranslator::from_catalog(&cat, base_dir)
661        .map_err(|e| anyhow::anyhow!("catalog: {e}"))?;
662
663    // BaseRef: si se da, leerlo; si no, fabricar uno permisivo desde la base del catálogo
664    // (la usaremos sólo para verify_base, que tolera pins extra en local).
665    let base = match load_local_base(base_ref)? {
666        Some(b) => b,
667        None => {
668            // Tomamos la base del swm del intent (si existe) como referencia local.
669            let ctx_swm = cat
670                .intents
671                .iter()
672                .find(|e| e.intent == intent)
673                .ok_or_else(|| anyhow::anyhow!("intent '{intent}' no está en el catálogo"))?
674                .swm_inline
675                .clone()
676                .or_else(|| {
677                    cat.intents
678                        .iter()
679                        .find(|e| e.intent == intent)
680                        .and_then(|e| e.swm_path.as_ref())
681                        .and_then(|p| std::fs::read_to_string(base_dir.join(p)).ok())
682                        .and_then(|t| hammer_core::Swm::from_yaml(&t).ok())
683                });
684            match ctx_swm {
685                Some(s) => hammer_core::BaseRef::from(&s.base),
686                None => anyhow::bail!("no pude inferir BaseRef sin --base-ref"),
687            }
688        }
689    };
690
691    let apply = match prefix {
692        Some(p) => ApplyTarget::Prefix { prefix: p.to_path_buf() },
693        None => ApplyTarget::Overlay {
694            state_root: state_root
695                .map(|p| p.to_path_buf())
696                .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT)),
697        },
698    };
699
700    let compile = match bus {
701        Some(s) => CompileMode::ViaBus {
702            sock: s.to_path_buf(),
703            store: PathBuf::from(store_root),
704            timeout: std::time::Duration::from_secs(600),
705        },
706        None => CompileMode::Skip,
707    };
708
709    let orch = Orchestrator::new(translator, base, apply, compile);
710    let proposal = orch
711        .run(intent)
712        .map_err(|e| anyhow::anyhow!("orchestrator: {e}"))?;
713
714    println!("--- proposal ---");
715    println!("intent: {}", proposal.intent);
716    println!(
717        "applied: source_patch={} config_edit={} init_rule={} file_drop={}",
718        proposal.applied.source_patch,
719        proposal.applied.config_edit,
720        proposal.applied.init_rule,
721        proposal.applied.file_drop,
722    );
723    if proposal.skipped_source_patches > 0 {
724        println!(
725            "skipped_source_patches: {} (sin --bus)",
726            proposal.skipped_source_patches
727        );
728    }
729    println!("checks:");
730    for c in &proposal.checks {
731        println!("  [{:?}] {}", c.level, c.msg);
732    }
733    match (&proposal.overlay_id, &proposal.prefix) {
734        (Some(id), _) => {
735            println!("\noverlay {id} listo.");
736            println!("  Revisa el cambio en caliente y luego:");
737            println!("    hammer commit {id}     # promueve + diario");
738            println!("    hammer discard {id}    # descarta");
739        }
740        (None, Some(p)) => {
741            println!("\nprefix {} actualizado (modo test, sin overlay).", p.display());
742        }
743        _ => {}
744    }
745    Ok(())
746}
747
748/// Envía una línea al FIFO de control humano. No abrimos un proceso intermedio (sería
749/// `sh -c "echo ... > FIFO"` con riesgo de quoting); usamos un `OpenOptions::write` y un
750/// `write_all`. El FIFO bloquea si no hay reader — eso es la garantía de "ack" implícito.
751fn run_ctl(line: &str, fifo: &std::path::Path) -> anyhow::Result<()> {
752    use std::io::Write;
753    use std::os::unix::fs::FileTypeExt;
754    let meta = std::fs::symlink_metadata(fifo).map_err(|e| {
755        anyhow::anyhow!(
756            "no encuentro el FIFO {} ({e}); ¿está hammerd arrancado?",
757            fifo.display()
758        )
759    })?;
760    if !meta.file_type().is_fifo() {
761        anyhow::bail!("{} existe pero no es un FIFO", fifo.display());
762    }
763    let mut f = std::fs::OpenOptions::new().write(true).open(fifo)?;
764    f.write_all(line.as_bytes())?;
765    if !line.ends_with('\n') {
766        f.write_all(b"\n")?;
767    }
768    Ok(())
769}