tawasuyu

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 1346Shell 97Rust 63Markdown 571650 Definiciones

primero se elige, después se salta — y sólo si hace falta (SDD 32)

El usuario probó el instalador, dijo que sí, «se voló todo a negro y se quedó así hasta que
apagué con el botón». El instalador estaba VIVO del otro lado: el orden era «saltá primero,
elegí después», así que toda la conversación —censo, elección, confirmación— pasaba DESPUÉS
del kexec, en una máquina cuyo panel puede no volver a encenderse. Un kexec no reinicia el
hardware de vídeo. Había un instalador pidiendo una tecla que nadie podía ver que pedía.

(Hay un segundo sospechoso, medido y ya corregido el mismo día: el cmdline del salto tenía las
consolas al revés y /dev/console quedaba en el puerto serie, que un portátil no tiene. No se
puede decidir cuál de los dos fue sin repetirlo, y arreglar uno solo es cómo vuelve.)

El orden nuevo:

  censo → elegir destino → decidir si el salto hace falta
     ├─ el destino NO es la raíz viva ⇒ se instala DESDE ACÁ, sin saltar
     └─ el destino ES la raíz viva    ⇒ se explica qué implica, se confirma, y lo de
                                        después va con la respuesta PUESTA: ya no necesita
                                        pantalla. Un panel muerto pasa de ser un cuelgue a
                                        ser un detalle estético.

Piezas: `--pick` en el censo (muestra el terreno, deja elegir y NO TOCA NADA, dejando la
respuesta en un fichero — a un fichero y no a stdout, porque stdout es donde sale el menú);
`es_raiz_viva` leyendo el censo y equivocándose del lado que no destruye; `instalar_aqui`, que
desempaqueta el árbol con el busybox del propio paquete (el host puede no tener cpio — en
nuestras tres máquinas no lo hay) y corre el instalador sin matar la sesión; `--jump` para
forzarlo, que es lo que usan los arneses. Y un aviso: si hay sesión gráfica, el salto es mucho
más sano desde una consola de texto (Ctrl+Alt+F2).

Y la disciplina que pide el cifrado de negación plausible, que el usuario pidió recordar:
«NO RECONOZCO NADA» DEJA DE SER «ESTÁ VACÍO». Un pozo de qullqa es por diseño un dispositivo
lleno de aleatorio y sin firma, o sea exactamente lo que un instalador ingenuo llama «partición
vacía». Ahora eso es el veredicto CRUDA: se puede usar, pero la confirmación no es un «sí» —hay
que ESCRIBIR el nombre del dispositivo, porque un sí se contesta por reflejo—. Y el mensaje es
el mismo para un pozo, un disco recién borrado y un contenedor cifrado ajeno: decir «esto parece
un volumen oculto» delataría lo que el diseño existe para no delatar. Es la misma contención que
el editor de discos de tawasuyu ya había tenido que implementar (su T16).

⚠ Y la línea «opciones:» salía VACÍA en un disco cuyo único destino era una partición sin
identificar: ofrecía la opción 4 y a la vez decía que no había ninguna. Lo cazó el arnés del USB.

SDD 32 escribe el resto de lo que pidió y separa lo hecho de lo decidido: las dos caras (GUI si
hay sesión gráfica, TUI si no), el plan de pasos que se enseña antes de ejecutar, los perfiles
con mirada por defecto y los grupos (herramientas, compiladores), y qué falta del lado de qullqa
(nunca swap en disco —zram—, hibernación prohibida, ofrecer dejar sitio, no rellenar de ceros).

Tres arneses en verde: banco 25, salto 13, USB 13 (con la confirmación fuerte ejercitada).

36152da9284ad15039abe44e3cb91ef42aa43bb7 · por Sergio · (hace 28 d) · padre 0e99cfdd2 · ver el árbol en este commit

Delta semántico · 16

crates/takana-cli/src/metal.rs

6 archivos · +552 −36

diff --git a/crates/takana-cli/src/metal.rs b/crates/takana-cli/src/metal.rs
index dd98c886..1a95ccaf 100644
--- a/crates/takana-cli/src/metal.rs
+++ b/crates/takana-cli/src/metal.rs
@@ -41,6 +41,11 @@ use crate::kernel_ab;
 /// Nombres con los que viaja el payload. Se buscan en ese orden dentro del directorio del paquete.
 const INITRD: &[&str] = &["takana-instalador.cpio.gz", "instalador.cpio.gz", "initramfs.cpio.gz"];
 const KERNEL: &[&str] = &["kernel/boot/bzImage", "bzImage", "boot/bzImage", "kernel"];
+const GUION: &[&str] = &[
+    "scripts/takana-install-partition.sh",
+    "takana-install-partition.sh",
+    "instalador/takana-install-partition.sh",
+];
 /// La consola por defecto pide las DOS: `ttyS0` es la única forma de ver por qué murió cuando no
 /// llega a pintar nada, y `tty0` es la pantalla del que instala.
 ///
@@ -90,6 +95,10 @@ pub enum MetalCmd {
         /// Cargar el instalador en memoria y NO saltar. Ensayo: no se pierde la sesión.
         #[arg(long)]
         load_only: bool,
+        /// Saltar aunque no haga falta. Normalmente el salto sólo ocurre si el destino es la raíz
+        /// viva; esto lo fuerza (arneses, y para probar el salto a propósito).
+        #[arg(long)]
+        jump: bool,
         /// Consolas para el kernel del instalador.
         #[arg(long, default_value = CONSOLA)]
         console: String,
@@ -101,9 +110,9 @@ pub fn run(cmd: MetalCmd) -> Result<()> {
         MetalCmd::Check => check(),
         MetalCmd::Survey { json, payload } => survey(json, payload.as_deref()),
         MetalCmd::Install {
-            payload, root, esp, free, disk, yes, unattended, load_only, console,
+            payload, root, esp, free, disk, yes, unattended, load_only, jump, console,
         } => install(Instalacion {
-            payload, root, esp, free, disk, yes, unattended, load_only, console,
+            payload, root, esp, free, disk, yes, unattended, load_only, jump, console,
         }),
     }
 }
@@ -117,6 +126,7 @@ struct Instalacion {
     yes: bool,
     unattended: bool,
     load_only: bool,
+    jump: bool,
     console: String,
 }
 
@@ -194,10 +204,7 @@ fn check() -> Result<()> {
 
 // ── survey ────────────────────────────────────────────────────────────────────────────────────
 fn survey(json: bool, payload: Option<&Path>) -> Result<()> {
-    let guion = buscar(
-        &candidatos(payload),
-        &["scripts/takana-install-partition.sh", "takana-install-partition.sh"],
-    )
+    let guion = buscar(&candidatos(payload), GUION)
     .ok_or_else(|| {
         anyhow::anyhow!(
             "no encuentro `takana-install-partition.sh` junto al binario ni en el directorio actual.\n  \
@@ -242,12 +249,69 @@ fn install(i: Instalacion) -> Result<()> {
             KERNEL.join(" / ")
         )
     })?;
+    let guion = buscar(&dirs, GUION);
+
+    // ── 1) PRIMERO SE ELIGE, Y SE ELIGE ACÁ ───────────────────────────────────────────────────
+    //
+    // ⚠ **Esto era al revés y costó un cuelgue en metal.** El orden viejo era «saltá primero,
+    // elegí después»: toda la conversación pasaba DESPUÉS del kexec, en una máquina cuyo panel
+    // puede no volver a encenderse tras el salto. El usuario dijo que sí, la pantalla se fue a
+    // negro y se quedó así hasta apagar con el botón — el instalador estaba vivo del otro lado,
+    // pidiendo una tecla que nadie podía ver que estaba pidiendo.
+    //
+    // Ahora se elige en el sistema que TODAVÍA FUNCIONA, y lo de después va con la respuesta
+    // puesta. Un panel muerto pasa de ser un cuelgue a ser un detalle estético.
+    let mut destino = i.root.clone();
+    let mut usar_hueco = i.free;
+    let mut disco = i.disk.clone();
+    if destino.is_none() && !usar_hueco {
+        let g = guion.as_ref().ok_or_else(|| {
+            anyhow::anyhow!(
+                "no encuentro el censo (`takana-install-partition.sh`) junto al paquete, así que no \
+                 puedo preguntarte acá.\n  Pasá --root PARTUUID=… o --payload <dir del paquete>."
+            )
+        })?;
+        let elec = elegir(g)?;
+        match elec {
+            None => {
+                println!("nada hecho.");
+                return Ok(());
+            }
+            Some(e) => {
+                destino = e.dest;
+                usar_hueco = e.free;
+                if e.disk.is_some() {
+                    disco = e.disk;
+                }
+            }
+        }
+    }
+
+    // ── 2) ¿HACE FALTA SALTAR? ────────────────────────────────────────────────────────────────
+    // Sólo si el destino es la raíz VIVA: es la única partición que no se puede formatear desde
+    // adentro. Para cualquier otra, el salto sería matar la sesión del usuario sin necesidad.
+    let censo = censo_de(guion.as_deref());
+    let sobre_la_raiz = destino
+        .as_deref()
+        .map(|d| es_raiz_viva(&censo, d))
+        .unwrap_or(false);
+
+    let inst = Instalacion { root: destino.clone(), free: usar_hueco, disk: disco.clone(), ..i };
+
+    if !sobre_la_raiz && !inst.load_only && !inst.jump {
+        // El destino está libre: se instala DESDE ACÁ y esta sesión no se muere.
+        if let Some(g) = guion.as_ref() {
+            return instalar_aqui(g, &dirs, &inst);
+        }
+        println!("⚠ el destino no es la raíz viva, así que no haría falta saltar — pero no encuentro");
+        println!("  el censo junto al paquete para instalar desde acá. Sigo por el salto.");
+    }
 
-    // El diagnóstico va ANTES de decir lo que va a pasar: si la máquina no puede saltar, prometer
-    // el viaje y fallar al final es peor que no prometerlo.
+    // ── 3) EL SALTO: sólo acá, y explicando qué implica ───────────────────────────────────────
     let (puede, motivos) = crate::kernel_cmd::kexec_probe();
     if !puede {
-        println!("✗ no se puede saltar al instalador desde esta máquina:");
+        println!("✗ el destino es la raíz que está corriendo AHORA, así que hay que soltarla antes");
+        println!("  de formatearla — y esta máquina no puede saltar:");
         for m in &motivos {
             println!("  · {m}");
         }
@@ -255,44 +319,216 @@ fn install(i: Instalacion) -> Result<()> {
         if !crate::kernel_cmd::soy_root() {
             println!("Repetilo con `sudo`: sin privilegios esto no es concluyente.");
         } else {
-            println!("Con la imagen USB se instala igual: el instalador es el mismo.");
+            println!("Quedan dos caminos, los dos sin tocar tu sistema actual:");
+            println!("  · instalar AL LADO, en espacio sin asignar:  takana metal install --free --disk /dev/…");
+            println!("  · la imagen USB, que hace exactamente lo mismo desde otro medio.");
         }
         std::process::exit(1);
     }
 
-    let cmdline = cmdline_de(&i);
-    println!("── instalar takana en esta máquina ────────────────────────────────────────────");
+    let cmdline = cmdline_de(&inst);
+    println!();
+    println!("── el salto ──────────────────────────────────────────────────────────────────");
+    if sobre_la_raiz {
+        println!("  El destino es la raíz que estás usando: no se puede formatear desde adentro.");
+        println!("  Para soltarla, esta máquina salta a un instalador que corre en RAM.");
+    } else {
+        println!("  El destino NO es la raíz viva, así que este salto no haría falta: lo pediste");
+        println!("  a mano con --jump. El instalador corre igual, en RAM.");
+    }
+    println!();
+    println!("  destino:    {}", inst.root.as_deref().unwrap_or("el espacio sin asignar"));
     println!("  instalador: {}  ({})", initrd.display(), tam(&initrd));
     println!("  su kernel:  {}  ({})", bz.display(), tam(&bz));
-    match (&i.root, i.free) {
-        (Some(r), _) => println!("  destino:    {r}"),
-        (None, true) => println!("  destino:    el espacio SIN ASIGNAR más grande (no se borra nada)"),
-        (None, false) => println!("  destino:    lo vas a elegir en la pantalla siguiente"),
-    }
     println!();
-    println!("  Lo que pasa al confirmar: esta sesión MUERE y arranca el instalador en RAM. Todavía");
-    println!("  no se escribe en ningún disco — el instalador muestra el terreno y pregunta antes.");
-    if i.unattended {
-        println!("  ⚠ --unattended: el instalador NO va a preguntar. Formatea el destino de arriba.");
+    println!("  Qué implica, en orden:");
+    println!("   1. TODO lo que está corriendo muere en el acto. Guardá antes.");
+    println!("   2. La pantalla puede quedarse en negro un rato, o no volver hasta el reinicio:");
+    println!("      un kexec no reinicia el hardware de vídeo. **No es un cuelgue** — ya no hay");
+    println!("      nada que preguntarte: la elección ya está hecha y el instalador la lleva puesta.");
+    println!("   3. Al terminar, la máquina reinicia sola.");
+    if en_sesion_grafica() {
+        println!();
+        println!("  ⚠ Esto parece una sesión GRÁFICA. El salto es mucho más sano desde una consola de");
+        println!("    texto: pasá a una con Ctrl+Alt+F2, entrá, y corré esto ahí.");
     }
-    println!("  Lo que NO vuelve: los programas abiertos de esta sesión. Guardá antes.");
     println!("──────────────────────────────────────────────────────────────────────────────");
 
-    if !i.yes && !i.load_only && !confirmar()? {
+    if !inst.yes && !inst.load_only && !confirmar()? {
         println!("nada hecho.");
         return Ok(());
     }
 
     kernel_ab::kexec_cargar(&bz, Some(&initrd), &cmdline)?;
     println!("✓ instalador cargado en memoria");
-    if i.load_only {
+    if inst.load_only {
         println!("  (--load-only: no se salta. Queda cargado hasta el próximo reinicio.)");
         return Ok(());
     }
-    println!("⚡ saltando al instalador — esto no vuelve.");
+    println!("⚡ saltando — a partir de acá no hace falta mirar nada.");
     kernel_ab::kexec_saltar()
 }
 
+/// Lo que devolvió el menú.
+struct Eleccion {
+    dest: Option<String>,
+    free: bool,
+    disk: Option<String>,
+}
+
+/// Corre el censo en modo `--pick`: muestra el terreno, deja elegir y **no toca nada**.
+fn elegir(guion: &Path) -> Result<Option<Eleccion>> {
+    let tmp = std::env::temp_dir().join(format!("takana-eleccion.{}", std::process::id()));
+    let st = std::process::Command::new("sh")
+        .arg(guion)
+        .arg("--pick")
+        .arg(&tmp)
+        .status()
+        .with_context(|| format!("no puedo ejecutar {}", guion.display()))?;
+    if !st.success() {
+        let _ = std::fs::remove_file(&tmp);
+        return Ok(None);
+    }
+    let txt = std::fs::read_to_string(&tmp).unwrap_or_default();
+    let _ = std::fs::remove_file(&tmp);
+    let mut e = Eleccion { dest: None, free: false, disk: None };
+    for l in txt.lines() {
+        match l.split_once('=') {
+            Some(("DEST", v)) => e.dest = Some(v.to_string()),
+            Some(("DISK", v)) => e.disk = Some(v.to_string()),
+            Some(("FREE", _)) => e.free = true,
+            _ => {}
+        }
+    }
+    if e.dest.is_none() && !e.free {
+        return Ok(None);
+    }
+    Ok(Some(e))
+}
+
+/// El censo en JSON, o vacío si no se pudo. **Vacío no es «no hay discos»**: acá sólo se usa para
+/// una pregunta concreta —¿el destino es la raíz viva?— y no saberlo se trata como «no».
+fn censo_de(guion: Option<&Path>) -> String {
+    let Some(g) = guion else { return String::new() };
+    std::process::Command::new("sh")
+        .arg(g)
+        .args(["--survey", "--json"])
+        .output()
+        .ok()
+        .filter(|o| o.status.success())
+        .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
+        .unwrap_or_default()
+}
+
+/// ¿El destino elegido es la raíz viva? Se lee del censo, que es quien lo decide.
+///
+/// Sin parsear JSON de verdad: se busca el bloque de esa partición y se mira si su veredicto es
+/// `RAIZ_VIVA`. Un formato de una línea por partición hace que esto sea exacto; si el censo
+/// cambiara de forma, esto deja de encontrar nada y el resultado es «no es la raíz» ⇒ se instala
+/// sin saltar y el instalador aborta con su propia guarda. Se equivoca del lado que no destruye.
+fn es_raiz_viva(censo_json: &str, destino: &str) -> bool {
+    let clave = destino.trim_start_matches("PARTUUID=").trim_start_matches("partuuid=");
+    for linea in censo_json.lines() {
+        let l = linea.trim();
+        if !l.starts_with('{') {
+            continue;
+        }
+        let casa = l.contains(&format!("\"partuuid\": \"{clave}\""))
+            || l.contains(&format!("\"dev\": \"{destino}\""));
+        if casa {
+            return l.contains("\"veredicto\": \"RAIZ_VIVA\"");
+        }
+    }
+    false
+}
+
+/// ¿Estamos dentro de una sesión gráfica? Sólo para AVISAR, nunca para decidir.
+///
+/// Un kexec no reinicia el hardware de vídeo, y saltar desde un compositor con la GPU tomada es la
+/// forma más fácil de quedarse con el panel apagado del otro lado. Desde una VT de texto el salto
+/// es mucho más sano. No se bloquea: se avisa, porque también hay quien sabe lo que hace.
+fn en_sesion_grafica() -> bool {
+    ["WAYLAND_DISPLAY", "DISPLAY", "XDG_SESSION_TYPE"]
+        .iter()
+        .any(|v| std::env::var(v).map(|x| !x.is_empty() && x != "tty").unwrap_or(false))
+}
+
+/// Instala **desde esta máquina**, sin saltar. Es el camino normal cuando el destino no es la raíz
+/// viva: no hay ninguna razón para matar la sesión del usuario.
+///
+/// El árbol que se copia vive comprimido dentro del initramfs del instalador, así que se
+/// desempaqueta primero — con el `busybox` que viaja en el paquete y no con el del host, que puede
+/// no existir (en las tres máquinas de este proyecto no hay `cpio`).
+fn instalar_aqui(guion: &Path, dirs: &[PathBuf], i: &Instalacion) -> Result<()> {
+    let bb = buscar(dirs, &["busybox", "bin/busybox"]).ok_or_else(|| {
+        anyhow::anyhow!(
+            "el paquete no trae `busybox`, que es con lo que se desempaqueta el árbol a instalar.\n  \
+             Rearmá el paquete con scripts/instalador-paquete.sh."
+        )
+    })?;
+    let initrd = buscar(dirs, INITRD).expect("ya se resolvió antes");
+
+    println!();
+    println!("── instalar desde acá, SIN saltar ────────────────────────────────────────────");
+    println!("  El destino no es la raíz que estás usando, así que no hace falta soltarla:");
+    println!("  esta sesión sigue viva y al terminar reiniciás cuando quieras.");
+    println!("  destino: {}", i.root.as_deref().unwrap_or("el espacio sin asignar"));
+    println!("──────────────────────────────────────────────────────────────────────────────");
+    if !i.yes && !confirmar()? {
+        println!("nada hecho.");
+        return Ok(());
+    }
+
+    let tmp = std::env::temp_dir().join(format!("takana-arbol.{}", std::process::id()));
+    std::fs::create_dir_all(&tmp)?;
+    println!("==> desempaquetando el árbol a instalar en {}", tmp.display());
+    let sh = format!(
+        "{bb} gzip -d -c {ird} | ( cd {d} && {bb} cpio -i -d --quiet )",
+        bb = bb.display(),
+        ird = initrd.display(),
+        d = tmp.display()
+    );
+    let st = std::process::Command::new("sh").arg("-c").arg(&sh).status()?;
+    if !st.success() {
+        bail!("no pude desempaquetar {}", initrd.display());
+    }
+
+    let mut c = std::process::Command::new("sh");
+    c.arg(guion);
+    if let Some(r) = &i.root {
+        c.arg("--root-part").arg(r);
+    }
+    if i.free {
+        c.arg("--use-free-space");
+    }
+    if let Some(d) = &i.disk {
+        c.arg("--disk").arg(d);
+    }
+    c.arg("--yes");
+    // Las herramientas y el árbol salen del paquete: acá no hay store del que resolverlas por hash.
+    for (v, rel) in [
+        ("TOOL_mke2fs", "bin/mke2fs"),
+        ("TOOL_sfdisk", "bin/sfdisk"),
+        ("TOOL_mmd", "bin/mmd"),
+        ("TOOL_mcopy", "bin/mcopy"),
+        ("TOOL_mdir", "bin/mdir"),
+    ] {
+        c.env(v, tmp.join(rel));
+    }
+    c.env("STAGE", tmp.join("payload/stage"))
+        .env("STAGE_LISTO", "1")
+        .env("KERNEL", tmp.join("payload/bzImage"))
+        .env("HAMMER", tmp.join("bin/takana"));
+    let st = c.status()?;
+    let _ = std::process::Command::new("rm").arg("-rf").arg(&tmp).status();
+    if !st.success() {
+        bail!("el instalador falló ({st})");
+    }
+    println!();
+    println!("✓ listo. Reiniciá cuando quieras y elegí takana en el firmware.");
+    Ok(())
+}
+
 /// La cmdline del instalador: las respuestas que ya se dieron viajan por acá, y las que no, no.
 /// Es el mismo canal que lee el `/init` del initramfs, así que **no hay nada horneado por máquina**.
 fn cmdline_de(i: &Instalacion) -> String {
@@ -344,7 +580,8 @@ mod tests {
     fn base() -> Instalacion {
         Instalacion {
             payload: None, root: None, esp: None, free: false, disk: None,
-            yes: false, unattended: false, load_only: false, console: CONSOLA.to_string(),
+            yes: false, unattended: false, load_only: false, jump: false,
+            console: CONSOLA.to_string(),
         }
     }
 
@@ -379,6 +616,35 @@ mod tests {
         assert!(!c.contains("takana.root="), "{c}");
     }
 
+    const CENSO: &str = r#"{
+  "version": 1,
+  "raiz_viva": "8:2",
+  "particiones": [
+    {"dev": "/dev/sda2", "partuuid": "AAAA", "mib": 6144, "veredicto": "RAIZ_VIVA", "usable": true},
+    {"dev": "/dev/sda5", "partuuid": "BBBB", "mib": 40960, "veredicto": "CANDIDATA", "usable": true}
+  ]
+}"#;
+
+    #[test]
+    fn el_salto_hace_falta_solo_para_la_raiz_viva() {
+        // Es la decisión que reordenó todo: saltar cuando no hace falta es matar la sesión del
+        // usuario por nada, y el cuelgue que lo destapó pasó justamente ahí.
+        assert!(es_raiz_viva(CENSO, "PARTUUID=AAAA"));
+        assert!(!es_raiz_viva(CENSO, "PARTUUID=BBBB"));
+        assert!(es_raiz_viva(CENSO, "/dev/sda2"));
+        assert!(!es_raiz_viva(CENSO, "/dev/sda5"));
+    }
+
+    #[test]
+    fn un_destino_que_el_censo_no_conoce_no_se_toma_por_la_raiz_viva() {
+        // Se equivoca del lado que NO destruye: si esto diera «sí», se saltaría sin necesidad; si
+        // da «no», se instala desde acá y la guarda del propio instalador aborta si el destino
+        // resulta ser la raíz. Un error de más contra un error que mata la sesión.
+        assert!(!es_raiz_viva(CENSO, "PARTUUID=ZZZZ"));
+        assert!(!es_raiz_viva("", "PARTUUID=AAAA"));
+        assert!(!es_raiz_viva("esto no es json", "PARTUUID=AAAA"));
+    }
+
     #[test]
     fn buscar_respeta_el_orden_de_los_nombres_y_de_los_directorios() {
         let d = std::env::temp_dir().join(format!("metal-test-{}", std::process::id()));
diff --git a/docs/32-instalador-de-usuario.md b/docs/32-instalador-de-usuario.md
new file mode 100644
index 00000000..229db68c
--- /dev/null
+++ b/docs/32-instalador-de-usuario.md
@@ -0,0 +1,173 @@
+# SDD 32 — El instalador de takana: la cara, los perfiles, y el orden de las preguntas
+
+> **Estado: PARCIAL.** Lo del §2 y §7 está **implementado y medido** (2026-09-23). Lo del §3 al §6
+> está **decidido y no construido**, y se dice cuál es cuál en cada sección.
+> **Nace de un fallo medido en metal**, no de una idea: el §1.
+
+## 1. Lo que pasó, que es de dónde sale todo esto
+
+El usuario bajó el instalador, corrió `takana metal install`, le dijo que sí, y:
+
+> *se voló todo a negro y se quedó así hasta que apagué con el botón de hardware.*
+
+El instalador **estaba vivo del otro lado**. Lo que había pasado es que el orden era *saltá primero,
+elegí después*: toda la conversación —el censo, la elección de partición, la confirmación— ocurría
+**después del `kexec`**, en una máquina cuyo panel puede no volver a encenderse. Un `kexec` no
+reinicia el hardware de vídeo: si el salto sale de una sesión gráfica con la GPU tomada, es normal
+que la pantalla no vuelva hasta el reinicio. Y del otro lado había un instalador pidiendo una tecla
+que nadie podía ver que estaba pidiendo.
+
+Hay además un segundo sospechoso, medido y ya corregido ese mismo día: **el cmdline del salto tenía
+las consolas al revés**. `/dev/console` es la ÚLTIMA `console=` del cmdline, y con `console=tty0`
+antes de `console=ttyS0` el instalador escribía y leía por el **puerto serie** — que un portátil no
+tiene. Los dos caminos dan el mismo síntoma: pantalla negra para siempre.
+
+**No se puede decidir cuál de los dos fue sin repetirlo**, y decirlo importa: arreglar sólo uno y
+declarar el caso cerrado es exactamente cómo vuelve.
+
+De ahí salen las dos conclusiones que ordenan todo el documento:
+
+> **(a) Ninguna pregunta se hace después del salto.**
+> **(b) El salto sólo ocurre si es imprescindible.**
+
+## 2. El orden de las preguntas — IMPLEMENTADO
+
+```
+   censo del terreno          ─┐
+   elegir destino              │  en el sistema que TODAVÍA funciona
+   elegir perfil y paquetes    │  (y con pantalla, teclado y la sesión viva)
+   ver el plan y confirmarlo  ─┘
+        │
+        ├── el destino NO es la raíz viva  ⇒  se instala DESDE ACÁ, sin saltar
+        │                                     (la sesión no se muere)
+        └── el destino ES la raíz viva     ⇒  se explica qué implica el salto,
+                                              se pide confirmación, y lo de después
+                                              va con la respuesta PUESTA: ya no
+                                              necesita pantalla
+                 └── y si esta máquina no puede saltar ⇒ espacio libre, o el USB
+```
+
+Hoy están hechos: el censo, la elección (`takana-install-partition.sh --pick`, que muestra el
+terreno, deja elegir y **no toca nada**), la decisión de si el salto hace falta (`es_raiz_viva`
+sobre el censo), la instalación **sin saltar** cuando el destino está libre, y el salto con la
+elección ya puesta en la cmdline. Falta lo del §3 al §6.
+
+⚠ **`--jump` fuerza el salto** aunque no haga falta. Existe para los arneses y para probarlo a
+propósito; no es el camino normal.
+
+Y el aviso que faltaba: si se detecta una sesión gráfica, el instalador **dice que el salto es
+mucho más sano desde una consola de texto** (Ctrl+Alt+F2). No lo bloquea —hay quien sabe lo que
+hace— pero no deja que sea una sorpresa.
+
+## 3. Las dos caras — DECIDIDO, NO CONSTRUIDO
+
+> *el script vea si tiene gui, abre churay llimphi, sino abre churay-tui.*
+
+| | |
+|---|---|
+| **hay sesión gráfica** | `churay` (la GUI llimphi). Su pestaña «Sistema en disco» ya existe y consume `takana metal survey --json` |
+| **no hay** | `churay-tui`, que **todavía no existe**. Hoy el hueco lo tapa el menú de texto del propio instalador, que funciona pero no es una cara |
+| **ninguna de las dos se puede abrir** | el menú de texto, como último recurso, que es lo que hay hoy |
+
+La detección es la misma que ya usa el aviso del salto (`WAYLAND_DISPLAY`/`DISPLAY`/
+`XDG_SESSION_TYPE`), más «¿existe el binario?».
+
+**El motor no cambia** ([ADR 0021](adr/0021-dueno-del-verbo-de-instalar.md)): las tres caras
+—GUI, TUI y el menú de texto— consumen el MISMO censo y el MISMO plan. El día que una de ellas
+clasifique particiones por su cuenta, empieza a ofrecer lo que las otras protegen.
+
+## 4. El plan de pasos — DECIDIDO, NO CONSTRUIDO
+
+> *el instalador arma la serie de pasos de qué formatear y cómo particionar.*
+
+Hoy el instalador **decide y ejecuta en el mismo movimiento**. Lo que falta es partirlo en dos:
+
+1. **Planificar**: del estado del disco + las elecciones sale una lista de operaciones ordenada
+   (`crear partición`, `formatear`, `copiar rootfs`, `poblar ESP`, `entrada NVRAM`), cada una con lo
+   que destruye y lo que no.
+2. **Enseñar el plan y pedir UN sí.** Es la pantalla que convierte «confío» en «entendí».
+3. **Ejecutar**, con el progreso por paso.
+
+Vive en `churay-core` (frontend-agnóstico, con tests) o en takana según dónde quede el motor; la
+decisión no está tomada y **no hace falta tomarla hasta escribir el primer paso**.
+
+## 5. Perfiles y grupos de paquetes — DECIDIDO, NO CONSTRUIDO
+
+> *se eligen los perfiles, por default mirada, pero que si kde, que si gnome, que si también qué
+> grupos de paquetes, que si tools, que si compiladores, que si cuando tengamos hacking.*
+
+Los perfiles **ya existen** y son `docs/state/targets.toml`: `escritorio-kde`, `escritorio-gnome`,
+`escritorio-cosmic`, `escritorio-sway`, `servidor`, `base`. Lo que no existe es elegirlos al
+instalar: hoy el instalador copia **un** rootfs ya armado.
+
+Dos caminos, y el usuario acepta los dos:
+
+| | |
+|---|---|
+| **A · el medio los trae** | el USB/`.run` lleva varias clausuras y se copia la elegida. Medio más grande, instalación sin red |
+| **B · ligera y después** | se instala una base chica y los perfiles se ponen después con `takana install <perfil>` ([SDD 27](27-perfiles-instalables-y-el-instalador.md)). Medio chico, pero pide red |
+
+**B es más barato y depende de que el SDD 27 esté hecho** (hoy `install` resuelve UN paquete, no un
+perfil). **A funciona hoy mismo** con el precio del tamaño. La decisión sensata es A para el USB
+—que existe justamente para máquinas sin red— y B para el `.run`.
+
+⚠ **`mirada` por defecto**, que es el escritorio de tawasuyu, no uno de los cuatro del catálogo.
+Eso es una decisión de producto y conviene que esté escrita: la distro se presenta con su propio
+escritorio, y KDE/GNOME/COSMIC/sway son alternativas, no el default.
+
+«Herramientas», «compiladores» y «hacking» son **grupos**, no perfiles: se suman a cualquiera. El
+corpus ya tiene 652 recetas selladas que ninguna imagen alcanza —361 Go, 214 Rust, 67 C, casi todas
+de línea de comandos— así que el grupo «herramientas» es **coste de build cero: sólo declararlo**.
+«hacking» no existe todavía y no se inventa acá.
+
+## 6. El formateo tiene que dejar vivir al cifrado de negación plausible
+
+`qullqa` (tawasuyu, `03_ukupacha/qullqa`) guarda datos vitales de modo que **una revisión del equipo
+no produzca sospecha de que existen**. Su pozo es, por diseño, **un dispositivo lleno de aleatorio y
+sin ninguna firma**. Para un instalador ingenuo eso es, palabra por palabra, «una partición vacía
+que se puede usar».
+
+### 6.1 Lo que YA está hecho — el veredicto `CRUDA`
+
+Una partición en la que no se reconoce nada **ya no se marca `✓ se puede usar`**: se marca
+`? sin identificar` y usarla pide una confirmación fuerte —**escribir el nombre del dispositivo**,
+no un «sí», porque un sí se contesta por reflejo—. La GUI la pinta distinto y con su propio aviso.
+
+⚠ Y **el mensaje es el mismo** para un pozo, un disco recién borrado y un contenedor cifrado ajeno.
+No es pereza: un aviso que dijera «esto parece un volumen oculto» delataría exactamente lo que el
+diseño existe para no delatar. Ni el censo ni la cara nombran ninguna hipótesis. Es la misma
+contención que el editor de discos de tawasuyu ya había tenido que implementar (su T16), y se copia
+a propósito en vez de reinventarse.
+
+### 6.2 Lo que falta, con su razón
+
+- **Nunca crear swap en disco.** Un `swapon` sin cifrar anula el documento entero de qullqa (su T9):
+  la llave y el claro terminan en el disco. El reemplazo es **zram** —comprimido en RAM, nunca toca
+  el disco— y `zswap` **apagado**, o se queda con las páginas antes de que lleguen a zram. Hoy el
+  instalador no crea swap; lo que falta es que eso sea una **decisión escrita y verificada**, no una
+  ausencia accidental.
+- **Hibernación prohibida** mientras haya un volumen abierto: escribe la RAM al disco.
+- **Ofrecer dejar sitio.** Que el plan pueda reservar un dispositivo o un hueco sin tabla, sin
+  llamarlo nada. Un instalador que ocupa el disco entero por defecto le quita al usuario la opción
+  de tener un pozo, y el que la necesita no va a pedirla en voz alta.
+- **No rellenar de ceros lo que no se usa.** Un área de ceros y un área de aleatorio se distinguen a
+  simple vista, y la primera dice «acá no hay nada» con demasiada seguridad.
+
+## 7. Lo que ya está, con su prueba
+
+| | prueba |
+|---|---|
+| censo del terreno, con veredicto por partición | `--survey`, `--json`; guardián `install-particion-test.sh` |
+| elegir sin tocar nada (`--pick`) | usado por `takana metal install` |
+| instalar **sin saltar** cuando no hace falta | implementado; el arnés del salto ahora pide `--jump` |
+| el salto, con la elección ya puesta | `metal-salto-test.sh`, 13 comprobaciones sobre el DISCO |
+| el USB, cuando el salto no se puede | `instalador-usb-test.sh --instalar`, 12, arrancando por firmware |
+| un fichero que se baja y hace todo | `takana-os.net/descargas/takana-instalador.run` |
+| la cara gráfica (pestaña, censo, elección) | `churay-core::metal`, 10 tests; `churay-llimphi` |
+
+## 8. Lo que este documento NO decide
+
+- **Dónde vive el planificador de pasos**, si en takana o en `churay-core`. Se decide al escribirlo.
+- **Cómo se eligen los perfiles en el medio USB** (A) contra el repo (B): depende del SDD 27.
+- **El particionado que ENCOGE.** Redimensionar pide desmontar, y eso es física; el instalador ya
+  corre desde fuera del disco destino en los dos caminos, así que es posible — pero no está escrito.
diff --git a/scripts/instalador-paquete.sh b/scripts/instalador-paquete.sh
index 5137c984..5864dcfe 100755
--- a/scripts/instalador-paquete.sh
+++ b/scripts/instalador-paquete.sh
@@ -58,6 +58,13 @@ poner "$TAKANA" "$SALIDA/takana" 0755
 poner "$KERNEL" "$SALIDA/kernel/boot/bzImage"
 poner scripts/takana-install-partition.sh "$SALIDA/takana-install-partition.sh" 0755
 poner scripts/lib/herramientas-desde-store.sh "$SALIDA/lib/herramientas-desde-store.sh"
+# `busybox` viaja suelto en el paquete —2 MiB— y no es redundante: es con lo que se desempaqueta el
+# árbol a instalar cuando NO hace falta saltar. El host puede no tener `cpio` ni `gzip` (en las tres
+# máquinas de este proyecto no hay `cpio`), así que depender de los del sistema ajeno es depender de
+# algo que ya sabemos que falta.
+_bb="${IRD_TREE:-work/.instalador-initramfs-tree}/bin/busybox"
+[ -r "$_bb" ] || { echo "no encuentro busybox en $_bb" >&2; exit 1; }
+poner "$_bb" "$SALIDA/busybox" 0755
 
 # ── 3) la comprobación que justifica este script ──────────────────────────────────────────────
 # El censo que el usuario corre ANTES de saltar (`takana metal survey`, el de afuera) y el que
diff --git a/scripts/instalador-usb-test.sh b/scripts/instalador-usb-test.sh
index d319f530..0ef451f0 100755
--- a/scripts/instalador-usb-test.sh
+++ b/scripts/instalador-usb-test.sh
@@ -76,7 +76,11 @@ LOG="$P/serie.log"
 # caracteres llegan antes de que el instalador abra la consola se pierden, y el menú se queda
 # esperando una tecla que ya pasó.
 if [ -n "$INSTALAR" ]; then
-    TECLAS="(sleep 40; printf '4\nsi\n'; sleep 300)"
+    # `/dev/vdb2`: el destino de este banco no tiene filesystem, así que el censo lo marca CRUDA y
+    # la confirmación NO es un «sí» — hay que escribir el nombre del dispositivo. Es la contención
+    # que protege un almacén de negación plausible de un instalador ingenuo, y este arnés la
+    # ejercita en vez de esquivarla.
+    TECLAS="(sleep 40; printf '4\n/dev/vdb2\nsi\n'; sleep 300)"
 else
     TECLAS="cat /dev/null"
 fi
@@ -124,9 +128,12 @@ grep -q 'NTFS\|NO TOCAR' "$P/serie.txt" && ok "hay particiones marcadas NO TOCAR
 # La opción que se va a teclear tiene que ser la que creemos, y se comprueba ANTES de dar por bueno
 # nada: si el layout cambió, «4» ya no es el destino y el test tiene que decirlo.
 if [ -n "$INSTALAR" ]; then
-    grep -qE '^ +4 +/dev/vdb2 .*se puede usar' "$P/serie.txt" \
+    grep -qE '^ +4 +/dev/vdb2 ' "$P/serie.txt" \
         && ok "la opción 4 del menú es /dev/vdb2 (la que se tecleó)" \
         || falla "la opción 4 no es /dev/vdb2: el layout cambió y se tecleó a ciegas"
+    grep -q 'Escribí el nombre completo (/dev/vdb2)' "$P/serie.txt" \
+        && ok "lo que no se identifica pidió confirmación FUERTE (escribir el nombre)" \
+        || falla "una partición sin identificar se aceptó con un sí"
     grep -q 'takana instalada en' "$P/serie.txt" && ok "el instalador dijo que instaló" \
         || falla "el instalador no llegó a instalar (¿las teclas no entraron por el serial?)"
     A="$P/destino-despues.img"
diff --git a/scripts/metal-salto-test.sh b/scripts/metal-salto-test.sh
index 74e6f323..289e434e 100755
--- a/scripts/metal-salto-test.sh
+++ b/scripts/metal-salto-test.sh
@@ -168,8 +168,10 @@ done
 if [ -x /takana-instalador.run ]; then
     # El camino del que BAJA UN FICHERO: la cabecera se desempaqueta sola y corre lo de adentro.
     log "SALTO-TEST: ───── takana-instalador.run (el fichero publicado) ─────"
+    # `--jump`: desde el 2026-09-23 el salto sólo ocurre si el destino es la raíz VIVA, y acá no
+    # lo es. Este arnés existe para probar el salto, así que lo pide explícitamente.
     TAKANA_TMP=/var/tmp /takana-instalador.run --root "$DEST" --esp "$ESPP" \
-        --unattended --yes > /dev/console 2>&1
+        --jump --unattended --yes > /dev/console 2>&1
 else
     log "SALTO-TEST: ───── takana metal check ─────"
     /bin/takana metal check > /dev/console 2>&1; log "SALTO-TEST: check rc=$?"
@@ -177,7 +179,7 @@ else
     /bin/takana metal survey --payload /paquete > /dev/console 2>&1; log "SALTO-TEST: survey rc=$?"
     log "SALTO-TEST: ───── takana metal install ─────"
     /bin/takana metal install --payload /paquete --root "$DEST" --esp "$ESPP" \
-        --unattended --yes > /dev/console 2>&1
+        --jump --unattended --yes > /dev/console 2>&1
 fi
 log "SALTO-TEST-FALLO: el salto NO ocurrió (install rc=$?) — el ORIGEN sigue vivo"
 sync; sleep 3; reboot -f
@@ -222,7 +224,7 @@ if [ -n "$VIA_RUN" ]; then
         || falla "el .run no verificó el sha256 de su payload"
     grep -q 'desempaquetando' "$LOG" && ok "el .run se desempaquetó solo" || falla "el .run no se desempaquetó"
 fi
-grep -q 'saltando al instalador' "$LOG" && ok "metal install: anunció el salto" || falla "no anunció el salto"
+grep -q '⚡ saltando' "$LOG" && ok "metal install: anunció el salto" || falla "no anunció el salto"
 grep -q 'SALTO-TEST-FALLO' "$LOG" && falla "EL SALTO NO OCURRIÓ: el origen siguió vivo" \
     || ok "EL SALTO OCURRIÓ: el origen no volvió del kexec"
 grep -q 'TAKANA-INSTALADOR: arrancado en RAM' "$LOG" && ok "el instalador arrancó DESPUÉS del salto" \
diff --git a/scripts/takana-install-partition.sh b/scripts/takana-install-partition.sh
index caba428b..55ee12e4 100755
--- a/scripts/takana-install-partition.sh
+++ b/scripts/takana-install-partition.sh
@@ -34,6 +34,12 @@
 #                              PARTUUID de cada partición y qué se puede usar. Empezá por acá.
 #   --json                     (con --survey) el mismo censo en JSON. Es el CONTRATO que consume
 #                              una cara gráfica: `takana metal survey --json` lo envuelve.
+#   --pick <fichero>           mostrar el censo, DEJAR ELEGIR y NO TOCAR NADA: deja la elección en
+#                              <fichero> (`DEST=…`, `FREE=1`, `DISK=…`) y sale. Es lo que permite
+#                              decidir ANTES del salto — así lo de después no necesita pantalla.
+#                              La respuesta va a un FICHERO y no a stdout porque stdout es donde
+#                              sale el menú: mezclarlos obliga a quien lo llama a adivinar dónde
+#                              termina la conversación y empieza el dato.
 #   (sin destino)              modo INTERACTIVO: muestra el censo y te deja elegir. Es lo que hace
 #                              que un mismo medio sirva en cualquier máquina.
 #   --use-free-space           no formatear nada existente: crear la partición en el hueco sin
@@ -50,7 +56,7 @@ set -eu
 ROOT="$(cd "$(dirname "$0")/.." && pwd)"; cd "$ROOT"
 
 DEST=""; ESP=""; DISK=""; ESP_PART=""; CLAIM_FALLBACK=""; NO_NVRAM=""; ASSUME_YES=""
-USE_FREE=""; ON_ACTIVE="${ON_ACTIVE:-auto}"; SOLO_CENSO=""; CENSO_JSON=""
+USE_FREE=""; ON_ACTIVE="${ON_ACTIVE:-auto}"; SOLO_CENSO=""; CENSO_JSON=""; SOLO_ELEGIR=""
 while [ $# -gt 0 ]; do
     case "$1" in
         --root-part) DEST="${2:?}"; shift 2 ;;
@@ -61,6 +67,7 @@ while [ $# -gt 0 ]; do
         --no-nvram)  NO_NVRAM=1; shift ;;
         --survey)    SOLO_CENSO=1; shift ;;
         --json)      CENSO_JSON=1; shift ;;
+        --pick)      SOLO_ELEGIR="${2:?--pick necesita el fichero donde dejar la respuesta}"; shift 2 ;;
         --use-free-space) USE_FREE=1; shift ;;
         --on-active-root) ON_ACTIVE="${2:?}"; shift 2 ;;
         --yes|-y)    ASSUME_YES=1; shift ;;
@@ -92,7 +99,7 @@ BLOCKDEV=$(herramienta util-linux sbin/blockdev blockdev 2>/dev/null || echo "")
 # encuentro el kernel EFI-stub» ANTES de imprimir una sola línea del terreno. Y el censo es
 # justamente lo primero que se corre en una máquina desconocida, donde nada de eso existe todavía.
 # El censo necesita `sfdisk` y `blkid`, nada más.
-if [ -z "$SOLO_CENSO" ]; then
+if [ -z "$SOLO_CENSO" ] && [ -z "$SOLO_ELEGIR" ]; then
     MKE2FS=$(herramienta e2fsprogs sbin/mke2fs mke2fs) || exit 1
     MMD=$(herramienta    mtools usr/bin/mmd   mmd)     || exit 1
     MCOPY=$(herramienta  mtools usr/bin/mcopy mcopy)   || exit 1
@@ -288,9 +295,24 @@ clasificar() {
     if [ -n "$_cl_mnt" ]; then
         echo "EN_USO|montada en $_cl_mnt"; return
     fi
+    # ⚠ **«No reconozco nada» NO es «está vacío», y confundirlos DESTRUYE.** Un almacén de
+    # negación plausible (`qullqa`, tawasuyu) es por diseño un dispositivo lleno de aleatorio y sin
+    # ninguna firma — o sea **exactamente** lo que un instalador ingenuo llama «partición vacía, se
+    # puede usar». Formatearla no filtra información: la borra, y el dueño se entera cuando ya no
+    # está. La contención que el editor de discos de tawasuyu ya implementó (su T16) es general y
+    # ésta la copia: **lo que no se pudo identificar nace CRUDO, no libre**, y sólo se usa con una
+    # confirmación fuerte.
+    #
+    # Y el mensaje es EL MISMO para un pozo, un disco recién borrado y un contenedor cifrado ajeno.
+    # Eso no es pereza: un aviso que dijera «esto parece un volumen oculto» delataría exactamente lo
+    # que el diseño existe para no delatar. Acá no se nombra nada de eso, a propósito.
     case "$_cl_fs" in
         ext2|ext3|ext4|xfs|btrfs|f2fs) _cl_q="Linux ($_cl_fs) sin montar" ;;
-        "")  _cl_q="sin filesystem reconocible" ;;
+        "")  _cl_min=$(mib_minimos)
+             if [ "${_cl_mib:-0}" -lt "$_cl_min" ] 2>/dev/null; then
+                 echo "NO_TOCAR|sin nada que se pueda identificar, y son ${_cl_mib} M (hacen falta ${_cl_min} M)"; return
+             fi
+             echo "CRUDA|no reconozco NADA acá: puede estar vacía o en uso por algo que este instalador no sabe leer"; return ;;
         *)   echo "NO_TOCAR|$_cl_fs (desconocido para este instalador)"; return ;;
     esac
     # ⚠ **Una candidata que no entra no es una candidata.** Sin esta puerta el censo de este mismo
@@ -361,6 +383,7 @@ mostrar_censo() {
         _mc_n=$(( _mc_n + 1 ))
         case "$_ver" in
             CANDIDATA) _mc_marca="✓ se puede usar (SE BORRA lo que tenga)" ;;
+            CRUDA)     _mc_marca="? sin identificar — se puede usar, con confirmación" ;;
             LIBRE)     _mc_marca="✓ instalar AL LADO sin borrar nada" ;;
             RAIZ_VIVA) _mc_marca="⚡ sólo con el salto (kexec)" ;;
             SOLO_ESP)  _mc_marca="· se reusa como ESP, no se formatea" ;;
@@ -408,7 +431,7 @@ censo_json() {
     _cj_pri=1
     { if [ -n "$_cj_f" ]; then cat "$_cj_f"; else censo; fi; } | while IFS='|' read -r _d _u _m _fs _lb _mn _ver _exp; do
         [ -n "$_d" ] || continue
-        case "$_ver" in CANDIDATA|LIBRE|RAIZ_VIVA) _cj_us=true ;; *) _cj_us=false ;; esac
+        case "$_ver" in CANDIDATA|LIBRE|RAIZ_VIVA|CRUDA) _cj_us=true ;; *) _cj_us=false ;; esac
         [ "$_cj_pri" = 1 ] || printf ',\n'
         _cj_pri=0
         printf '    {"dev": %s, "partuuid": %s, "mib": %s, "fs": %s, "etiqueta": %s, "montaje": %s, "veredicto": %s, "usable": %s, "explicacion": %s}' \
@@ -463,7 +486,11 @@ elegir_destino() {
     printf "  opciones: "
     while IFS='|' read -r _d _u _m _fs _lb _mn _ver _exp; do
         _ed_n=$(( _ed_n + 1 ))
-        case "$_ver" in CANDIDATA|LIBRE|RAIZ_VIVA) printf "%s " "$_ed_n" ;; esac
+        # ⚠ `CRUDA` tiene que estar acá. Sin ella la línea salía VACÍA en un disco cuyo único
+        # destino posible era una partición sin identificar: el menú ofrecía la opción 4 y a la vez
+        # decía «opciones: (ninguna)». Medido en el arnés del USB el 2026-09-23. Una lista de
+        # opciones que no coincide con lo que la tabla marca es peor que no tenerla.
+        case "$_ver" in CANDIDATA|LIBRE|RAIZ_VIVA|CRUDA) printf "%s " "$_ed_n" ;; esac
     done < "$_ed_f"
     echo
     printf "  nº (o 'no' para salir sin tocar nada): "
@@ -480,6 +507,16 @@ elegir_destino() {
     case "$_ed_ver" in
         LIBRE)     USE_FREE=1; DISK="$_ed_dev"; DEST=""
                    echo "  ⇒ instalar AL LADO, en el espacio libre de $_ed_dev" ;;
+        CRUDA)
+                   # Confirmación FUERTE, y distinta de un «sí»: se teclea el nombre del device.
+                   # Un `sí` se contesta por reflejo; escribir `/dev/nvme0n1p5` obliga a mirar.
+                   echo "  ⚠ En $_ed_dev no se reconoce NADA. Puede estar vacía — o puede tener algo"
+                   echo "    que este instalador no sabe leer, y formatearla lo borra sin aviso."
+                   printf "    Escribí el nombre completo (%s) para confirmar, o cualquier otra cosa para salir: " "$_ed_dev"
+                   read -r _ed_conf
+                   [ "$_ed_conf" = "$_ed_dev" ] || { echo "  cancelado: no se tocó nada."; return 1; }
+                   if [ -n "$_ed_uu" ] && [ "$_ed_uu" != "-" ]; then DEST="PARTUUID=$_ed_uu"; else DEST="$_ed_dev"; fi
+                   echo "  ⇒ destino: $_ed_dev ($DEST)" ;;
         CANDIDATA|RAIZ_VIVA)
                    # Por PARTUUID SIEMPRE que se pueda: el nombre de device puede cambiar entre
                    # este momento y el arranque siguiente (ver `resolver_dev`).
@@ -587,6 +624,30 @@ if [ -n "$SOLO_CENSO" ]; then
     exit 0
 fi
 
+# ── `--pick`: ELEGIR sin tocar ────────────────────────────────────────────────────────────────
+# Existe por una razón que costó un susto: hasta hoy el orden era «saltá primero, elegí después»,
+# o sea que TODA la conversación con la persona pasaba DESPUÉS del kexec — en una máquina donde el
+# panel puede no volver a encenderse. El usuario dijo que sí al salto, la pantalla se fue a negro y
+# se quedó así: el instalador estaba vivo del otro lado esperando una tecla que nadie podía ver que
+# le estaba pidiendo.
+#
+# Con `--pick` la elección se hace ANTES, en el sistema que todavía funciona, y lo que pasa después
+# del salto ya no necesita pantalla: va con la respuesta puesta en la cmdline. Un panel muerto pasa
+# de ser un cuelgue a ser un detalle estético.
+#
+# No toca nada: no resuelve el device, no formatea, no escribe la tabla. Sólo imprime la elección.
+if [ -n "$SOLO_ELEGIR" ]; then
+    [ -t 0 ] || { echo "takana-install-partition: --pick necesita una terminal para preguntar" >&2; exit 2; }
+    elegir_destino || exit 1
+    # Se imprime en stdout y en el formato más tonto posible, para que lo lea un programa sin
+    # adivinar: una línea por respuesta, `CLAVE=valor`, nada más.
+    : > "$SOLO_ELEGIR"
+    [ -n "$DEST" ]     && echo "DEST=$DEST" >> "$SOLO_ELEGIR"
+    [ -n "$USE_FREE" ] && echo "FREE=1"     >> "$SOLO_ELEGIR"
+    [ -n "$DISK" ]     && echo "DISK=$DISK" >> "$SOLO_ELEGIR"
+    exit 0
+fi
+
 if [ -n "$NECESITA_ELEGIR" ]; then
     if [ -t 0 ]; then
         elegir_destino || exit 1