> 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…
H4d: `hammer compat <repo>` — la búsqueda que particiona un repo contra lo instalado (SDD 15 §H4)
Responde la pregunta que arrancó §H4 ("cuando busco, ¿cuáles puedo adoptar?"): es el `filtrar`
del prototipo wawa-memo, ahora sobre el repo real y READ-ONLY (no construye ni toca nada).
Evalúa cada paquete contra el estado instalado y lo parte en {compatibles, requieren-elección,
incompatibles}, combinando la vía declarada (slots, H4b) con la observada (paths, H4c):
incompatible domina, colisión (de slot o fichero) -> elección, si no compatible.
Verificado e2e real (tests/compat_gate.rs): un repo de dos paquetes se parte correctamente
(uno choca de fichero con lo instalado -> elección; otro limpio -> compatible).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs
index be2fe239..11e6fb16 100644
--- a/crates/hammer-cli/src/main.rs
+++ b/crates/hammer-cli/src/main.rs
@@ -261,6 +261,17 @@ enum Cmd {
#[arg(long, default_value = hammer_core::installed::DEFAULT_DB)]
db: PathBuf,
},
+ /// [H4d] BÚSQUEDA de compatibilidad: particiona un repo entero contra el estado instalado en
+ /// {compatibles, requieren-elección, incompatibles} — sin construir ni tocar nada. Responde
+ /// "cuando busco, ¿cuáles puedo adoptar?" (SDD 15 §H4).
+ Compat {
+ /// Repositorio: directorio local (con `index.json`) o URL HTTP(S).
+ #[arg(long, default_value = DEFAULT_REPO)]
+ repo: String,
+ /// Base de datos de instalados contra la que evaluar.
+ #[arg(long, default_value = hammer_core::installed::DEFAULT_DB)]
+ db: PathBuf,
+ },
/// [Etapa G] Importa una receta DESDE nixpkgs: toma el JSON normalizado que produce
/// `scripts/nix-import.sh` (vía `nix eval`) y emite una receta hammer (`.toml`). Trae la
/// RECETA (source+hash+deps+flags), nunca el binario del cache de nix — hammer reconstruye.
@@ -875,6 +886,7 @@ fn main() -> anyhow::Result<()> {
}
Cmd::Uninstall { name, db } => run_uninstall(&name, &db)?,
Cmd::Installed { db } => run_installed_list(&db)?,
+ Cmd::Compat { repo, db } => run_compat(&repo, &db)?,
Cmd::ImportNix { file, out } => run_import_nix(&file, out.as_deref())?,
Cmd::ImportAlpine { file, out } => run_import_alpine(&file, out.as_deref())?,
Cmd::Pin { recipe, out } => run_pin(&recipe, out.as_deref())?,
@@ -1961,6 +1973,77 @@ fn run_installed_list(db_path: &std::path::Path) -> anyhow::Result<()> {
Ok(())
}
+/// [H4d] La BÚSQUEDA de compatibilidad: evalúa cada paquete del repo contra el estado instalado
+/// y lo clasifica. Es `compat::filtrar` del prototipo `wawa-memo`, ahora sobre el repo real —
+/// read-only, no construye ni toca nada. Combina la vía DECLARADA (slots, H4b) con la OBSERVADA
+/// (paths que el paquete escribe, H4c): incompatible (requisito sin resolver) domina; si no, una
+/// colisión (de slot o de fichero) ⇒ requiere elección; si no, compatible.
+fn run_compat(repo: &str, db_path: &std::path::Path) -> anyhow::Result<()> {
+ let source = RepoSource::parse(repo);
+ let index = source.read_index()?;
+ let idb = hammer_core::InstalledDb::load(db_path)?;
+ let estado = idb.system_state();
+
+ // Materializamos TODOS los `.swm` (para HTTP los baja; para local es el propio dir).
+ let all_files: Vec<&str> = index.packages.iter().map(|p| p.file.as_str()).collect();
+ let mut _tmp: Option<tempfile::TempDir> = None;
+ let repo_dir = source.materialize(&index, &all_files, &mut _tmp)?;
+
+ let (mut compatibles, mut elegibles, mut incompatibles) = (Vec::new(), Vec::new(), Vec::new());
+ for entry in &index.packages {
+ let swm_path = hammer_core::RepoIndex::swm_path(&repo_dir, entry);
+ let swm = match swm_path.to_str().map(load_swm) {
+ Some(Ok(s)) => s,
+ _ => {
+ eprintln!("aviso: no pude leer {} — omito", entry.name);
+ continue;
+ }
+ };
+ // 1) Vía DECLARADA (slots): una incompatibilidad domina todo.
+ let slots = source_patch_slots(&swm);
+ let veredicto = hammer_core::compat::evaluar(&estado, &slots);
+ if let hammer_core::CompatVeredicto::Incompatible { slot, requerido, presente } = &veredicto {
+ incompatibles.push(format!(
+ "{:<16} requiere '{slot}' @ {requerido}, hay {}",
+ entry.name,
+ presente.as_deref().unwrap_or("(ausente)")
+ ));
+ continue;
+ }
+ // 2) Colisión: de slot declarado (H4b) o de fichero observado (H4c) ⇒ requiere elección.
+ let paths = hammer_core::compat::output_paths(&swm);
+ let pcols = hammer_core::compat::path_collisions(&idb, &entry.name, &paths);
+ if let hammer_core::CompatVeredicto::Colision { slot, entrante, .. } = &veredicto {
+ elegibles.push(format!("{:<16} colisión de slot '{slot}' (quiere {entrante})", entry.name));
+ } else if !pcols.is_empty() {
+ let d = pcols.iter().map(|c| format!("{} (de '{}')", c.path, c.propietario)).collect::<Vec<_>>().join(", ");
+ elegibles.push(format!("{:<16} colisión de fichero: {d}", entry.name));
+ } else {
+ compatibles.push(entry.name.clone());
+ }
+ }
+
+ println!(
+ "compat: {} paquete(s) en {} contra {} superficie(s) instalada(s)",
+ index.packages.len(),
+ source.label(),
+ estado.len()
+ );
+ println!("\ncompatibles ({}):", compatibles.len());
+ for n in &compatibles {
+ println!(" ✓ {n}");
+ }
+ println!("\nrequieren elección ({}) — pasá `install --force-slots`:", elegibles.len());
+ for n in &elegibles {
+ println!(" ~ {n}");
+ }
+ println!("\nincompatibles ({}) — descartar:", incompatibles.len());
+ for n in &incompatibles {
+ println!(" ✗ {n}");
+ }
+ Ok(())
+}
+
/// Desinstala un paquete: borra los ficheros que registró (salvo los que otro paquete instalado
/// también aporta — refcount) y quita su entrada de la DB. NO deshace `config_edit` (modifican
/// ficheros ajenos, no creados por el paquete).
diff --git a/crates/hammer-cli/tests/compat_gate.rs b/crates/hammer-cli/tests/compat_gate.rs
index 47bac76a..7bf025d0 100644
--- a/crates/hammer-cli/tests/compat_gate.rs
+++ b/crates/hammer-cli/tests/compat_gate.rs
@@ -75,3 +75,50 @@ fn install_aborta_ante_colision_de_fichero_y_force_slots_la_supera() {
"el fichero debía escribirse con --force-slots. stderr: {err}"
);
}
+
+/// H4d e2e: `hammer compat <repo>` particiona un repo contra el estado instalado. La búsqueda
+/// del prototipo `wawa-memo` (`filtrar`), ahora sobre el repo real y read-only.
+#[test]
+fn compat_particiona_el_repo_contra_lo_instalado() {
+ let tmp = tempfile::tempdir().unwrap();
+ let repo = tmp.path().join("repo");
+ std::fs::create_dir_all(&repo).unwrap();
+ let db = tmp.path().join("installed.json");
+
+ // Instalado: 'tema-claro' posee /share/logo.png.
+ std::fs::write(
+ &db,
+ r#"{"packages":[{"name":"tema-claro","version":"1","files":["/share/logo.png"]}]}"#,
+ )
+ .unwrap();
+
+ // Dos paquetes en el repo: uno choca (mismo path que tema-claro), otro es limpio (otro path).
+ for (name, path) in [("tema-oscuro", "/share/logo.png"), ("fondo-montaña", "/share/fondo.png")] {
+ let content = format!("bytes-{name}");
+ let hash = ArtifactHash::of_inputs(&[content.as_bytes()]);
+ let b64 = STANDARD.encode(content.as_bytes());
+ let swm = format!(
+ "swm_version: 1\nbase:\n distro_version: dev\n pins: {{}}\nmutations:\n\
+ - type: file_drop\n path: {path}\n content_hash: {}\n content_b64: {}\n",
+ hash.as_str(), b64
+ );
+ std::fs::write(repo.join(format!("{name}-1.swm")), swm).unwrap();
+ }
+ let index = r#"{"packages":[
+ {"name":"tema-oscuro","version":"1","file":"tema-oscuro-1.swm","distro_version":"dev","deps":[]},
+ {"name":"fondo-montaña","version":"1","file":"fondo-montaña-1.swm","distro_version":"dev","deps":[]}
+ ]}"#;
+ std::fs::write(repo.join("index.json"), index).unwrap();
+
+ let out = Command::new(env!("CARGO_BIN_EXE_hammer"))
+ .args(["compat", "--repo", repo.to_str().unwrap(), "--db", db.to_str().unwrap()])
+ .output()
+ .unwrap();
+ assert!(out.status.success());
+ let stdout = String::from_utf8_lossy(&out.stdout);
+ // fondo-montaña es compatible; tema-oscuro requiere elección (colisión de fichero).
+ assert!(stdout.contains("compatibles (1)"), "stdout: {stdout}");
+ assert!(stdout.contains("✓ fondo-montaña"), "stdout: {stdout}");
+ assert!(stdout.contains("requieren elección (1)"), "stdout: {stdout}");
+ assert!(stdout.contains("tema-oscuro"), "stdout: {stdout}");
+}
diff --git a/docs/15-frontier-ai-native.md b/docs/15-frontier-ai-native.md
index 93de023a..9f7afa2f 100644
--- a/docs/15-frontier-ai-native.md
+++ b/docs/15-frontier-ai-native.md
@@ -336,9 +336,17 @@ La definición dura de compatibilidad de una config `C` contra mi estado `E`:
escriben `/share/logo.png`; el segundo aborta con "COLISIÓN de fichero" **y no escribe nada**;
con `--force-slots` la elección se respeta y el fichero se escribe.
Con H4c, un paquete **sin declarar slots** ya participa del gate por lo que de verdad toca.
- Lo que queda abierto es **derivar la otra mitad** (los `requires` observados: de qué depende
- el paquete) y subir el grano del path-slot a superficies más ricas (un módulo wasm de wawa,
- un componente), además del **colapso** con el lado proceso.
+- **H4d** ✅ — **La BÚSQUEDA: `hammer compat <repo>`.** El `filtrar` del prototipo `wawa-memo`,
+ ahora sobre el repo real y **read-only** (no construye ni toca nada). Responde la pregunta que
+ arrancó §H4 — *"cuando busco, ¿cuáles puedo adoptar?"*: evalúa **cada** paquete del repo contra
+ el estado instalado y lo particiona en **{compatibles, requieren-elección, incompatibles}**,
+ combinando la vía declarada (slots, H4b) con la observada (paths, H4c) — incompatible domina,
+ colisión (de slot o de fichero) ⇒ elección, si no compatible. **Verificado e2e real**
+ (`tests/compat_gate.rs`): un repo de dos paquetes se parte correctamente (uno choca de fichero
+ con lo instalado ⇒ elección, otro limpio ⇒ compatible).
+ Lo que queda abierto: **derivar la otra mitad** (los `requires` observados: de qué depende el
+ paquete, no sólo qué escribe), subir el grano del path-slot a superficies más ricas (un módulo
+ wasm de wawa, un componente), y el **colapso** con el lado proceso.
**Frontera honesta.** H4a demuestra el *álgebra* sobre hashes abstractos; lo que **no**
resuelve es la **granularidad de los slots** — dos configs pueden no colisionar en el slot
@@ -400,7 +408,8 @@ H3a (design-doc) ──► registrar la visión, barato
└► H4a ✅ (config = conjunto de slots por hash: compatible/completa/segura)
└► H4b ✅ (slots en la receta/.swm real + gate en `hammer install`)
└► H4c ✅ (superficies OBSERVADAS: colisión de fichero, sin declarar slots)
- └► [proceso] replay del MonotonicLog ──► plan OS-CRDT (otro agente)
+ └► H4d ✅ (`hammer compat <repo>`: la búsqueda que particiona un repo)
+ └► [proceso] replay del MonotonicLog ──► plan OS-CRDT (otro agente)
```
Recomendación: **H1 primero** (empuja la frontera que ya tenemos, sin apuestas). **H2a** en