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…
⎇ 89ecd54135a72e486f94df045d191d231b253638
crates/hammer-cli/src/main.rs
History
Who touched what
raw
Fase 6 — bucle agéntico: hammer-agent (cliente + translator + orchestrator) y hammer ai
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}