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 1182Shell 87Rust 63Markdown 541592 Definitions

mudanza: el tercer camino — mudar, RESPALDAR o abandonar, cosa por cosa y con su descripción

Pedido del usuario: «una lista de cada cosa con una corta descripción y la opción de yo elegir mudar,
mover a un directorio para respaldar ese directorio, o abandonarlo». Eran tres huecos distintos.

1. «CADA COSA». El censo medía RAÍCES: `/home` era UNA entrada de 25 G y UNA decisión, con repos,
   SDKs, 4,3 G de caché y trabajo irrepetible adentro. Ahora emite un renglón por cosa —169 en
   gioser contra 5—, un nivel hacia adentro de cada raíz y DOS en `/home`, porque su primer nivel
   son usuarios y la decisión no es por usuario. `--solo-totales` conserva la vista vieja, que es la
   que dimensiona la mudanza; las dos viajan en el censo (`datos` y `datos_raiz`).

   ⚠ El primer intento mandaba las ~110 sondas en UNA llamada, se pasaba del timeout y devolvía
   lista VACÍA: «no hay datos» en vez de «no pude medirlos». Va en tandas, y una tanda que falla se
   nombra.

2. «UNA CORTA DESCRIPCIÓN», y son hechos: lo sirve el servidor web · lo usa un proceso vivo · es
   repo git y a dónde apunta · tiene cambios sin commitear · adentro hay node_modules/target/venv ·
   lo más nuevo que hay dentro. «Sin señales» también es un hecho, y es el que dice dónde mirar.

3. «ELEGIR ENTRE TRES»: DECISIONES pasa de (muda, muere) a (muda, RESPALDA, muere). Faltaba el
   camino que se usa de verdad —no lo quiero corriendo allá, tampoco lo quiero perder—; con dos
   opciones, todo lo dudoso se marcaba `muda` y la mudanza engordaba.

   La regla de fondo no cambió (qué datos VALEN no lo dice la máquina), pero cuatro hechos sí los
   sabe: servido/usado ⇒ muda · caché ⇒ muere · repo limpio con remoto ⇒ muere · repo sin remoto o
   sucio ⇒ respalda. Y el cruce que evita el peor error: un remoto que apunta a ESTA MISMA máquina
   no es un respaldo, es el mismo disco (medido: /mnt/vvv/humanoid → ssh://git@127.0.0.1:2345).

4. LAS DECISIONES SE GUARDAN. `--decide` las usaba sólo para el plan de esa corrida: cerrabas la
   terminal y se perdían las 169. Ahora reescribe el censo (temporal+fsync+rename, `.bak`, y releído
   antes de pisar nada; aborta si perdió alguna). Para eso hubo que hacer el emisor IDEMPOTENTE:
   escribía `decision = ""` fijo, así que re-emitir un censo decidido duplicaba la clave y el TOML
   dejaba de parsear — la herramienta no podía reescribir su propio fichero.

5. EL PASO `respaldo`: corral por defecto leído de respaldo-storagebox.sh (no una segunda dirección
   a mano), nombre aplanado, y NO se instala en el destino. Sin corral, aborta: un rsync a ninguna
   parte devuelve 0 y se lee como un respaldo. Probarlo con rotura a propósito Y con el control que
   tiene que pasar destapó dos bugs: `--mkpath` es obligatorio (el primer respaldo siempre estrena
   un nivel) y un FICHERO no lleva barra final — `rsync fichero/ dst/` es un error, y el paso de
   `datos` tenía el mismo defecto latente. La verificación es rsync en seco contra el destino real,
   no `du` ni `find`: el Storage Box no tiene find, no acepta tuberías, y su `du` comprime.

6. De paso: `aplicar.py --dry-run` decía «✅ todos los pasos ejecutados y verificados» sin haber
   ejecutado nada. Ahora dice EN SECO: N mostrados, NINGUNO ejecutado y NINGUNO verificado.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

5ec4e2b213414991d83690993727855d4e329895 · by Sergio · (1 month ago) · parent 628abdbb5 · browse the tree at this commit

Semantic delta · 14

scripts/mudanza/aplicar.py

  • body changed fn main

scripts/mudanza/censar.py

scripts/mudanza/planear.py

4 files · +554 −35

diff --git a/docs/29-mudanza.md b/docs/29-mudanza.md
index 68c4e329..e8b64254 100644
--- a/docs/29-mudanza.md
+++ b/docs/29-mudanza.md
@@ -79,6 +79,109 @@ El censo emite cada entrada con `decision = ""`. El plan es ese fichero con las
 3. **El plan es exportable y re-ejecutable** en otro proveedor. Es el artefacto que el usuario pidió.
 4. **Un TUI para llenarlo**, y también editable a mano — el fichero es la interfaz, el TUI es comodidad.
 
+### 3 bis · El tercer camino: **mudar · respaldar · abandonar** *(pedido del usuario, 2026-09-16)*
+
+> «la herramienta debería mostrarme una lista de cada cosa con una corta descripción y la opción de
+> yo elegir mudar, mover a un directorio para respaldar ese directorio, o abandonarlo»
+
+Las tres partes de esa frase eran tres huecos distintos, y los tres se cerraron.
+
+#### 1. «cada cosa» — el censo medía RAÍCES, no cosas
+
+`/home` era **una** entrada de 25 G y **una** decisión. Adentro hay cosas de dueños distintos: repos
+con remoto, SDKs que se re-bajan, 4,3 G de caché, y trabajo que no está en ningún otro lado. Con una
+sola decisión sólo se puede acertar a lo bruto — mudar 25 G para salvar 200 M, o abandonar 25 G y
+perder ese 200 M. **La granularidad de la lista es la granularidad de lo que se puede decidir.**
+
+`censar.py` ahora emite un renglón por cosa (169 en gioser contra 5), un nivel hacia adentro de cada
+raíz y **dos** en `/home`, porque su primer nivel son usuarios y la decisión no es por usuario.
+Queda `--solo-totales` para la vista vieja, que es la que dimensiona la mudanza y la que mira el
+preflight: las dos vistas viajan en el censo, `datos` y `datos_raiz`.
+
+⚠ Y una lección repetida: el primer intento mandaba las ~110 sondas en **una** llamada, se pasaba
+del timeout, y como el parser no encontraba ninguna línea devolvía **lista vacía** — «no hay datos»
+en vez de «no pude medirlos». Va en tandas de 12 y **una tanda que falla se nombra**.
+
+#### 2. «una corta descripción» — hechos, nunca un juicio
+
+De cada cosa se reporta lo que se puede comprobar, lo más decisivo primero:
+
+```
+/home/sergio/gioser-web     428M  LO SIRVE gioser.net, www.gioser.net · repo git → gitea@git.gioser.net:…
+/home/sergio/android-sdk   1019M  lo usa adb · lo más nuevo: 2026-06-12
+/home/sergio/accordion       37M  repo git → github.com/GotJimmy/accordion · CON CAMBIOS SIN COMMITEAR
+/home/sergio/.cache         4.3G  lo más nuevo: 2026-09-16
+/home/sergio/humanoid       200M  sin señales: ni git, ni servido, ni usado por nada vivo
+```
+
+Nada de «parece un proyecto viejo». Es repo o no lo es; lo sirve la config del servidor web o no; lo
+usa un proceso vivo o no; adentro hay `node_modules`/`target`/`venv` o no. **«sin señales» también
+es un hecho** — y es el que dice dónde hay que mirar a mano.
+
+#### 3. «yo elegir» entre tres — y qué recomienda la máquina
+
+`DECISIONES` pasa de `("muda","muere")` a `("muda","respalda","muere")`. El camino que faltaba es el
+que la gente usa de verdad: *esto no lo quiero corriendo allá, pero tampoco lo quiero perder*. Con
+dos opciones, todo lo dudoso se marcaba `muda` «por las dudas» y la mudanza engordaba con cosas que
+nadie iba a usar.
+
+La regla de fondo **no cambió**: qué datos VALEN no se deduce de la máquina, y la mayoría sale sin
+recomendación a propósito. Pero hay cuatro cosas que la máquina sí sabe, y callarlas obliga a mirar
+169 entradas a ojo:
+
+| hecho | recomienda | por qué |
+|---|---|---|
+| lo sirve el servidor web · lo usa un proceso vivo | `muda` | si no viaja, ese sitio o ese servicio deja de existir |
+| es caché o estado transitorio (`.cache`, `.gradle`, `node_modules`…) | `muere` | el destino lo vuelve a crear solo |
+| repo git **limpio** con remoto | `muere` | se vuelve a clonar |
+| repo **sin** remoto, o **con cambios sin commitear** | `respalda` | esa parte existe únicamente en este disco |
+
+⚠ **Y el cruce que evita el peor error**: un remoto que apunta a ESTA MISMA máquina no es un
+respaldo, es el mismo disco. Sin eso, «tiene remoto ⇒ se vuelve a clonar» mandaría a abandonar un
+repo cuyo único ejemplar se borra con el servidor. Medido en gioser: el remoto de
+`/mnt/vvv/humanoid` es `ssh://git@127.0.0.1:2345/…`, el gitea de la propia caja.
+
+#### 4. Y las decisiones ahora se GUARDAN
+
+`--decide` sólo las usaba para generar el plan de esa corrida: cerrabas la terminal y las 169
+elecciones se perdían. El censo **es** la interfaz (§3), así que ahora se vuelve a escribir con las
+decisiones puestas —temporal + `fsync` + `rename`, copia `.bak`, y **releído antes de pisar nada**—
+y se aborta si el fichero regenerado perdió alguna. Decidir 169 cosas siempre se hace a medias; que
+sobreviva a medias es parte del diseño, no una comodidad.
+
+Para que eso fuera posible hubo que hacer el emisor del censo **idempotente**: escribía
+`decision = ""` fijo al final de cada entrada, así que re-emitir un censo decidido producía la clave
+dos veces y el TOML dejaba de parsear. O sea que la herramienta no podía volver a escribir su propio
+fichero.
+
+#### 5. El paso `respaldo`, y los dos bugs que encontró el guardián de dos sentidos
+
+Lo marcado `respalda` va a un **corral** —por defecto el Storage Box, leído de
+`respaldo-storagebox.sh` para que no haya una segunda dirección escrita a mano— con el nombre
+aplanado (`/home/sergio/humanoid` → `home-sergio-humanoid`), y **no se instala en el destino**: la
+caja nueva no se entera de que existe. Sin corral configurado el plan **aborta**: un `rsync` a
+ninguna parte devuelve 0 y se lee igual que un respaldo.
+
+Probar el paso emitido con rotura a propósito **y** con el control que tiene que pasar destapó dos
+cosas que la lectura no veía:
+
+1. **`--mkpath` no es opcional**: sin él rsync muere con `mkdir … failed` en cuanto el corral tiene
+   un nivel que no existe — y el primer respaldo siempre lo tiene.
+2. **Un FICHERO no lleva barra final.** Con el censo detallado entran ficheros sueltos
+   (`mapas.tgz`, `gu.png`, un `.docx`), y `rsync fichero/ destino/` no es «copiá el fichero»: es un
+   error. El censo ya dice cuál es cuál; el paso de `datos` tenía el mismo defecto latente.
+
+La verificación no usa `du` ni `find`, sino **rsync en seco contra el destino real**: el corral
+puede ser un Storage Box, cuya shell restringida no tiene `find` ni acepta tuberías, y cuyo `du`
+además **comprime** (1,3 G subidos se ven como 576 M — un número que parece copia truncada y no lo
+es). Lo que se cuenta son los ficheros que a rsync todavía le faltarían.
+
+#### 6. De paso: el aplicador en seco decía «✅ todos los pasos ejecutados y verificados»
+
+Con `--dry-run` no se ejecuta nada, y el resumen de siempre contaba cero ejecutados igual que cero
+fallidos. Ahora dice **«EN SECO: N pasos mostrados, NINGUNO ejecutado y NINGUNO verificado»**. Es la
+misma regla que el resto del aplicador: *un paso que no se ejecutó no se marca como hecho*.
+
 ### ⚠ No todo lo que corre allá tiene sentido acá — y hay que DISCRIMINARLO con motivo
 
 Correr en el origen no significa que aplique en el destino. Un servidor en un datacenter no tiene
diff --git a/scripts/mudanza/aplicar.py b/scripts/mudanza/aplicar.py
index 8d3eca57..9d3435d9 100755
--- a/scripts/mudanza/aplicar.py
+++ b/scripts/mudanza/aplicar.py
@@ -96,7 +96,7 @@ def main():
     print(f"\n══ APLICANDO {plan.get('origen','?')} → {plan.get('destino','?')} ══")
     print(f"   {len(pasos)} paso(s) · estado en {est.path}\n")
 
-    hechos = fallidos = sospechosos = manuales = saltados = 0
+    hechos = fallidos = sospechosos = manuales = saltados = mostrados = 0
     for p in pasos:
         n, clase, titulo = p["n"], p["clase"], p["titulo"].strip()
         if a.only and clase != a.only:
@@ -120,6 +120,7 @@ def main():
             continue
 
         print(f"  ▶ {n:3} [{clase}] {titulo}")
+        mostrados += 1
         if a.dry_run:
             for l in cmd.splitlines():
                 print(f"       $ {l}")
@@ -163,6 +164,13 @@ def main():
             print("         y dejó 1367 artefactos vacíos. NO se marca como hecho.")
 
     print(f"\n── resumen ──")
+    if a.dry_run:
+        # ⚠ En seco NO SE EJECUTÓ NADA, así que el resumen de siempre —«✅ todos los pasos
+        # ejecutados y verificados»— sería exactamente la mentira que este aplicador existe para no
+        # decir: cero pasos corridos se contaba igual que cero pasos fallidos.
+        print(f"   EN SECO: {mostrados} paso(s) mostrados, NINGUNO ejecutado y NINGUNO verificado.")
+        print("   Quitá --dry-run para correrlo de verdad.")
+        return
     print(f"   ok {hechos} · ya estaban {saltados} · MANUALES pendientes {manuales} · "
           f"sospechosos {sospechosos} · fallidos {fallidos}")
     if manuales or sospechosos or fallidos:
diff --git a/scripts/mudanza/censar.py b/scripts/mudanza/censar.py
index a8e749d0..ddb5f84c 100755
--- a/scripts/mudanza/censar.py
+++ b/scripts/mudanza/censar.py
@@ -523,6 +523,168 @@ def datos(t, raices):
     return res
 
 
+def datos_detallados(t, raices, sitios=(), servicios=()):
+    """Un renglón POR COSA, con los hechos que hacen falta para decidir qué hacer con ella.
+
+    ── POR QUÉ NO ALCANZA CON LA RAÍZ (pedido del usuario, 2026-09-16) ─────────────────────────
+    `datos()` mide raíces: `/home` = 25 G, una línea, una decisión. Pero adentro hay 40 cosas de
+    dueños distintos —repos con remoto, SDKs que se re-bajan, caché, y trabajo que no está en
+    ningún lado— y con UNA decisión para todas sólo se puede acertar a lo bruto: mudar 25 G para
+    salvar 200 M, o abandonar 25 G y perder ese 200 M. La granularidad de la lista ES la
+    granularidad de lo que se puede decidir.
+
+    ── Y LA DESCRIPCIÓN SON HECHOS, NO UN JUICIO ──────────────────────────────────────────────
+    De cada entrada se reportan cosas COMPROBADAS: si es un repo git y si tiene remoto (⇒ se vuelve
+    a clonar), la fecha de lo más nuevo que hay dentro, si adentro hay directorios que se regeneran
+    (`node_modules`, `target`, `venv`), si la config del servidor web lo SIRVE y si un proceso vivo
+    lo usa. Nada de «parece un proyecto viejo». El que decide es el humano; esto le pone delante lo
+    que se puede medir, que es justo lo que no tiene a mano cuando mira una lista de nombres.
+
+    Todo en UNA llamada por raíz (un script generado), no una por directorio: son ~110 entradas.
+    """
+    # Un nivel; `/home` va a DOS porque su primer nivel son usuarios y la decisión no es por usuario.
+    objetivos = []
+    for r in raices:
+        out, ok = t.run(f"ls -A {shlex.quote(r)} 2>/dev/null")
+        if not ok or not out.strip():
+            continue
+        for n in out.split():
+            hijo = os.path.join(r, n)
+            if r == "/home":
+                sub, ok2 = t.run(f"ls -A {shlex.quote(hijo)} 2>/dev/null")
+                if ok2 and sub.strip():
+                    objetivos += [(os.path.join(hijo, x), r) for x in sub.split()]
+                    continue
+            objetivos.append((hijo, r))
+
+    # ── EN TANDAS, Y UN FALLO SE DICE ──────────────────────────────────────────────────────────
+    # `du -sh` sobre 110 directorios (25 G de `/home` entre ellos) no entra en el timeout de una
+    # sola llamada: la primera versión mandaba el programa entero, se pasaba de tiempo, y como el
+    # parser no encontraba ni una línea `P` devolvía **lista vacía** — o sea «no hay datos» en vez
+    # de «no pude medirlos». El mismo modo de fallo que el `-e` del §fuera_de_git, otra vez.
+    prog_de = {}
+    for ruta, _ in objetivos:
+        q = shlex.quote(ruta)
+        prog_de[ruta] = (
+            f'p={q}; '
+            'printf "P\t%s\n" "$p"; '
+            'sz=$(timeout 60 du -sh "$p" 2>/dev/null | cut -f1); printf "S\t%s\n" "${sz:-?}"; '
+            'szc=$(timeout 60 du -sh --count-links "$p" 2>/dev/null | cut -f1); '
+            'printf "C\t%s\n" "${szc:-?}"; '
+            '[ -d "$p" ] && printf "T\tdir\n" || printf "T\tfichero\n"; '
+            'if [ -d "$p/.git" ]; then '
+            '  printf "G\t%s\n" "$(git -C "$p" log -1 --format=%cs 2>/dev/null)"; '
+            '  printf "R\t%s\n" "$(git -C "$p" remote get-url origin 2>/dev/null)"; '
+            '  git -C "$p" status --porcelain 2>/dev/null | head -1 | '
+            '    while read -r _l; do printf "D\tsucio\n"; done; '
+            'fi; '
+            'for g in node_modules target venv .venv dist build .cache __pycache__; do '
+            '  [ -d "$p/$g" ] && printf "X\t%s\n" "$g"; done; '
+            'printf "F\t%s\n" "$(timeout 30 find "$p" -maxdepth 3 -type f -printf "%TY-%Tm-%Td\n" '
+            '  2>/dev/null | sort -r | head -1)"; '
+            'true'
+        )
+    TANDA = 12
+    rutas = [r for r, _ in objetivos]
+    salida, fallidas = [], []
+    for i in range(0, len(rutas), TANDA):
+        lote = rutas[i:i + TANDA]
+        out, ok = t.run("set -u\n" + "\n".join(prog_de[r] for r in lote))
+        if not ok and "P\t" not in out:
+            fallidas += lote
+            continue
+        salida.append(out)
+    if fallidas:
+        print(f"   ⚠ {len(fallidas)} ruta(s) no se pudieron MEDIR (la sonda falló o tardó de más): "
+              f"{', '.join(fallidas[:3])}{' …' if len(fallidas) > 3 else ''}", file=sys.stderr)
+        print("     No es lo mismo que «no hay nada ahí»: no entran al censo y hay que mirarlas.",
+              file=sys.stderr)
+
+    raiz_de = dict(objetivos)
+    por_ruta, actual = {}, None
+    for linea in "\n".join(salida).splitlines():
+        c = linea.split("\t")
+        if len(c) < 2:
+            continue
+        k, v = c[0], c[1]
+        if k == "P":
+            actual = {"path": v, "size": "?", "size_copy": "?", "tipo": "dir", "git_fecha": "",
+                      "git_remoto": "", "git_sucio": False, "regenerables": [], "reciente": "",
+                      "raiz": raiz_de.get(v, "")}
+            por_ruta[v] = actual
+        elif actual is None:
+            continue
+        elif k == "S": actual["size"] = v or "?"
+        elif k == "C": actual["size_copy"] = v or "?"
+        elif k == "T": actual["tipo"] = v
+        elif k == "G": actual["git_fecha"] = v
+        elif k == "R": actual["git_remoto"] = v
+        elif k == "D": actual["git_sucio"] = True
+        elif k == "X": actual["regenerables"].append(v)
+        elif k == "F": actual["reciente"] = v
+
+    # ── los cruces, en Python: son con lo que YA censamos, no con la máquina ────────────────────
+    raices_servidas = {}
+    for st in sitios:
+        if st.get("raiz"):
+            raices_servidas.setdefault(st["raiz"].rstrip("/"), []).append(st.get("nombres", ""))
+
+    res = []
+    for ruta, _ in objetivos:
+        e = por_ruta.get(ruta)
+        if not e:
+            continue
+        # ¿lo sirve la config del servidor web? (la ruta servida puede estar POR DEBAJO de ésta)
+        servido = []
+        for rs, doms in raices_servidas.items():
+            if rs == ruta or rs.startswith(ruta.rstrip("/") + "/"):
+                servido += " ".join(doms).split()
+        e["servido_por"] = sorted(set(servido))[:4]
+        # ¿lo usa un proceso VIVO? (su binario, su cwd o un argumento apuntan adentro)
+        usan = []
+        for s in servicios:
+            if s.get("class") == "declarado-muerto":
+                continue
+            texto = " ".join([s.get("cmdline", ""), s.get("cwd", ""), s.get("exec", "")])
+            if ruta in texto:
+                usan.append(s["name"])
+        e["usado_por"] = sorted(set(usan))[:4]
+        e["descripcion"] = _describir_dato(e)
+        res.append(e)
+    res.sort(key=lambda x: _bytes_aprox(x["size"]), reverse=True)
+    return res
+
+
+def _bytes_aprox(s):
+    """`du -sh` da «1.2G» / «288M». Para ORDENAR alcanza con una conversión aproximada; que no sea
+    exacta no cambia el orden de la lista, y pedir bytes exactos costaría otro `du` sobre 25 G."""
+    try:
+        u = s[-1]
+        n = float(s[:-1].replace(",", "."))
+        return n * {"K": 1024, "M": 1024**2, "G": 1024**3, "T": 1024**4}.get(u, 1)
+    except Exception:
+        return 0.0
+
+
+def _describir_dato(e):
+    """La línea corta que acompaña a cada cosa en la lista de decisiones. HECHOS separados por `·`,
+    el más decisivo primero. Sin adjetivos: «tiene remoto» se puede discutir, «parece obsoleto» no."""
+    t = []
+    if e["servido_por"]:
+        t.append("LO SIRVE " + ", ".join(e["servido_por"]))
+    if e["usado_por"]:
+        t.append("lo usa " + ", ".join(e["usado_por"]))
+    if e["git_remoto"]:
+        t.append(f"repo git → {e['git_remoto']}" + (" · CON CAMBIOS SIN COMMITEAR" if e["git_sucio"] else ""))
+    elif e["git_fecha"]:
+        t.append(f"repo git SIN remoto (último commit {e['git_fecha']})")
+    if e["regenerables"]:
+        t.append("adentro: " + "/".join(e["regenerables"]) + " (se regenera)")
+    if e["reciente"]:
+        t.append(f"lo más nuevo: {e['reciente']}")
+    return " · ".join(t) if t else "sin señales: ni git, ni servido, ni usado por nada vivo"
+
+
 def montajes(t):
     out, _ = t.run("df -h 2>/dev/null | grep -vE 'tmpfs|devtmpfs|^Filesystem|^Sist' ")
     res = []
@@ -803,6 +965,21 @@ def toml_escape(s):
     return '"' + str(s).replace("\\", "\\\\").replace('"', '\\"') + '"'
 
 
+def _campo_decision(e, con_destino=False):
+    """Las líneas `destino`/`decision` de una entrada, respetando lo que YA tenga.
+
+    ⚠ Sin esto, re-emitir un censo DECIDIDO escribe la clave dos veces y el TOML deja de parsear —
+    o sea que la herramienta no podía volver a escribir su propio fichero, que es justo lo que hace
+    falta para que las decisiones tomadas en `--decide` sobrevivan a la corrida."""
+    L = []
+    if con_destino:
+        d = e.get("destino", "")
+        L.append(f'destino = {toml_escape(d)}   # vacío = la MISMA ruta en el destino. Ej: "/work/home"')
+    dec = e.get("decision", "")
+    L.append(f'decision = {toml_escape(dec)}   # muda | respalda | muere | (vacío = sin decidir)')
+    return L
+
+
 def emitir_toml(c):
     L = []
     L.append("# censo de mudanza — generado por scripts/mudanza/censar.py. NO editar a mano salvo")
@@ -852,7 +1029,7 @@ def emitir_toml(c):
         if s.get('exec_motivo'):
             L.append(f"exec_motivo = {toml_escape(s['exec_motivo'])}")
         L.append(f"paquete = {toml_escape(s.get('paquete',''))}")
-        L.append('decision = ""   # muda | muere | (vacío = sin decidir)')
+        L += _campo_decision(s)
         L.append("")
     for x in c.get("repos", []):
         L.append("[[repo]]")
@@ -872,20 +1049,28 @@ def emitir_toml(c):
     for d in c["dominios"]:
         L.append("[[dominio]]")
         for k, v in d.items():
+            if k == "decision":
+                continue
             L.append(f"{k} = {toml_escape(v)}")
-        L.append('decision = ""   # muda | muere | (vacío = sin decidir)')
+        L += _campo_decision(d)
         L.append("")
     for d in c["datos"]:
         L.append("[[datos]]")
         for k, v in d.items():
-            L.append(f"{k} = {toml_escape(v)}")
+            if k in ("decision", "destino"):
+                continue
+            # `regenerables`/`servido_por`/`usado_por` son listas y `git_sucio` un booleano: pasarlos
+            # por `toml_escape` los dejaría como la cadena "[\'x\']", que parsea y MIENTE.
+            if isinstance(v, (list, bool)):
+                L.append(f"{k} = {json.dumps(v)}")
+            else:
+                L.append(f"{k} = {toml_escape(v)}")
         # ⚠ `destino` existe porque la ruta de origen NO tiene por qué caber en la misma ruta del
         # destino. Medido contra la caja takana: `/` tiene 3,5 G libres y los datos de gioser son
         # 33,6 G — el sitio está en `/work` (66 G) y en `/store` (19 G). Sin este campo el plan
         # copiaba ruta→misma ruta y la mudanza no entraba, con el detalle de que el fallo aparece a
         # mitad de un rsync de 22 G.
-        L.append('destino = ""   # vacío = la MISMA ruta en el destino. Ej: "/work/home"')
-        L.append('decision = ""   # muda | muere | (vacío = sin decidir)')
+        L += _campo_decision(d, con_destino=True)
         L.append("")
     for f in c["fuera_de_git"]:
         L.append("[[fuera_de_git]]")
@@ -897,8 +1082,7 @@ def emitir_toml(c):
         if f.get("porque"):
             L.append(f"porque = {toml_escape(f['porque'])}")
         L.append(f"entries = {json.dumps(f['entries'])}")
-        L.append('destino = ""   # vacío = la MISMA ruta en el destino')
-        L.append('decision = ""   # muda | muere | (vacío = sin decidir)')
+        L += _campo_decision(f, con_destino=True)
         L.append("")
     return "\n".join(L) + "\n"
 
@@ -943,11 +1127,20 @@ def resumen(c):
                   f"ausentes (corré el censo como su dueño o con privilegio):")
             for f in ciegos:
                 print(f"      {f['path']}")
-    if c["datos"]:
-        print("\n── datos ──")
-        for d in c["datos"]:
+    if c.get("datos_raiz"):
+        print("\n── datos, por raíz ──")
+        for d in c["datos_raiz"]:
             extra = f"  (copiar sin -H: {d['size_copy']})" if d["size_copy"] != d["size"] else ""
             print(f"  {d['path']:28} {d['size']:>7}{extra}")
+    if c["datos"] and c["datos"] is not c.get("datos_raiz"):
+        # ⚠ Se imprimen las 15 más grandes y SE DICE cuántas quedaron fuera. Cortar en silencio es
+        # lo que hace que una lista parezca completa: el fichero las trae todas, y quien decide
+        # tiene que saber que la pantalla no es el censo.
+        print(f"\n── datos, cosa por cosa ({len(c['datos'])} entradas; el TOML las trae todas) ──")
+        for d in c["datos"][:15]:
+            print(f"  {d['path']:44} {d['size']:>6}  {d.get('descripcion','')[:64]}")
+        if len(c["datos"]) > 15:
+            print(f"  … y {len(c['datos']) - 15} más en el fichero")
     print()
 
 
@@ -964,6 +1157,9 @@ def main():
                     help="sondear SÓLO el DNS (sin HTTP): no toca al origen, así que es seguro "
                          "contra uno mismo. Dice qué dominios son fósiles y cuáles apuntan acá")
     ap.add_argument("--no-probe", action="store_true", help="no sondear dominios")
+    ap.add_argument("--solo-totales", action="store_true",
+                    help="medir los datos POR RAÍZ y no cosa por cosa (más rápido, pero deja "
+                         "25 G de `/home` en una sola decisión)")
     ap.add_argument("--data-root", action="append", default=[],
                     help="raíz de datos a medir (repetible). Default: las habituales")
     a = ap.parse_args()
@@ -1031,14 +1227,18 @@ def main():
                 time.sleep(0.5)   # no parecer un escaneo ante el propio fail2ban del objetivo
             doms.append(sondear_dominio(d, set(ident["ips"]), http_ok))
     raices = a.data_root or ["/var/www", "/var/lib", "/srv", "/opt", "/home", "/store"]
-    dat = datos(t, raices)
+    # Dos vistas y no una: el TOTAL por raíz es lo que dimensiona la mudanza (y lo que mira el
+    # preflight), y el DETALLE por cosa es lo que se puede decidir. Dar sólo el total obliga a
+    # decidir 25 G de una; dar sólo el detalle pierde el número que dice si entra en el destino.
+    dat_raiz = datos(t, raices)
+    dat = datos_detallados(t, raices, sitios, svc) if not a.solo_totales else dat_raiz
     # Después de `raices`, que es de dónde salen los repos a mirar. (Estaba antes y `raices` todavía
     # no existía: habría reventado con NameError en la primera corrida con datos.)
     repos = repos_git(t, raices + ["/mnt/vvv"], ident.get("ips", []), nombres)
     censo = {
         "fecha": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
         "identidad": ident, "servicios": svc, "huerfanos": huerfanos, "puertos": pts,
-        "dominios": doms, "datos": dat, "contenido": cont, "sitios": sitios, "repos": repos, "montajes": montajes(t),
+        "dominios": doms, "datos": dat, "datos_raiz": dat_raiz, "contenido": cont, "sitios": sitios, "repos": repos, "montajes": montajes(t),
         "fuera_de_git": fuera_de_git(t),
     }
     resumen(censo)
diff --git a/scripts/mudanza/planear.py b/scripts/mudanza/planear.py
index fbfac008..85e410fc 100755
--- a/scripts/mudanza/planear.py
+++ b/scripts/mudanza/planear.py
@@ -36,6 +36,7 @@ import argparse, os, sys, shlex, time, tomllib
 
 sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
 import formatos                                            # noqa: E402
+import censar                                              # noqa: E402
 from censar import candidatos_de as candidatos             # noqa: E402
 
 _REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -66,7 +67,16 @@ def humano(mib):
 
 # ── decisión ────────────────────────────────────────────────────────────────────────────────────
 
-DECISIONES = ("muda", "muere")
+# ── TRES CAMINOS, NO DOS (pedido del usuario, 2026-09-16) ───────────────────────────────────────
+# Con `muda`/`muere` una cosa que no va a la caja nueva sólo podía MORIR, así que todo lo dudoso se
+# marcaba `muda` «por las dudas» y la mudanza engordaba con cosas que nadie iba a usar. El tercer
+# camino es el que la gente usa de verdad: *esto no lo quiero corriendo allá, pero tampoco lo quiero
+# perder*. `respalda` lo copia al corral de respaldo y NO lo instala en el destino.
+#
+# ⚠ `respalda` cuenta como «no se muda» para la recomendación derivada de los servicios: un binario
+# que queda archivado no puede arrancar en la caja nueva. Confundirlos daría un servicio marcado
+# `muda` cuyo ejecutable no va a existir allá — y eso se descubre al arrancarlo, no al decidir.
+DECISIONES = ("muda", "respalda", "muere")
 
 
 def entradas(censo):
@@ -352,7 +362,7 @@ def origen_binario(e, repo=None):
     return ("desconocido", motivo or "no se pudo leer su binario: hace falta averiguarlo a mano")
 
 
-def recomendar(tipo, e, datos_que_mueren=(), sitios=()):
+def recomendar(tipo, e, datos_que_mueren=(), sitios=(), locales=()):
     """Devuelve `(decision, motivo)`. **Recomendación, nunca decisión**: se imprime con su razón para
     que aceptarla sea barato y equivocarse sea caro de no notar.
 
@@ -411,12 +421,52 @@ def recomendar(tipo, e, datos_que_mueren=(), sitios=()):
         return "", ""
 
     if tipo == "datos":
-        # A propósito SIN recomendación: qué datos valen no se deduce de la máquina. `terapeuta.ec`
-        # eran 279 M sin DNS y el usuario decidió que mueran; nada en el sistema podía decidir eso.
+        # ── LO QUE SIGUE SIN RECOMENDARSE, Y LO QUE SÍ ──────────────────────────────────────────
+        # La regla de fondo no cambió: **qué datos VALEN no se deduce de la máquina** — `terapeuta.ec`
+        # eran 279 M sin DNS y el usuario decidió que murieran; nada en el sistema podía decidir eso.
+        # Pero hay tres hechos que sí decide la máquina, y callarlos obliga a mirar 169 entradas a
+        # ojo: que el servidor web lo SIRVA, que un proceso vivo lo USE, y que sea recuperable de
+        # otro lado (un repo limpio con remoto) o regenerable (una caché). Se recomienda SÓLO sobre
+        # esos hechos, con el hecho como motivo, y el resto sale en blanco a propósito.
+        if e.get("servido_por"):
+            return "muda", ("lo SIRVE " + ", ".join(e["servido_por"]) +
+                            ": si no viaja, ese sitio deja de existir")
+        if e.get("usado_por"):
+            return "muda", ("lo usa un proceso vivo (" + ", ".join(e["usado_por"]) +
+                            "): un servicio que se muda sin sus datos arranca vacío")
+        base = os.path.basename(e.get("path", ""))
+        if base in _CACHES or base in ("swap", "lost+found"):
+            return "muere", f"`{base}` es caché o estado transitorio: se regenera solo en el destino"
+        rem = e.get("git_remoto") or ""
+        # ⚠ UN REMOTO QUE APUNTA A ESTA MISMA MÁQUINA NO ES UN RESPALDO, ES EL MISMO DISCO.
+        # Sin esto, «tiene remoto ⇒ se vuelve a clonar» recomienda ABANDONAR un repo cuyo único
+        # ejemplar se borra con la máquina. Medido en gioser: el remoto de `/mnt/vvv/humanoid` es
+        # `ssh://git@127.0.0.1:2345/…` — el gitea de acá. El censo ya lo sabe (`[[repo]] solo_aqui`)
+        # y lo que faltaba era cruzarlo.
+        if rem and any(x and x in rem for x in locales):
+            return "respalda", (f"su remoto ({rem}) es ESTA MISMA MÁQUINA: no es un respaldo, es el "
+                                "mismo disco. Clonarlo después de borrarla no se puede")
+        if rem and not e.get("git_sucio"):
+            return "muere", (f"repo git LIMPIO con remoto ({rem}): se vuelve a clonar")
+        if rem and e.get("git_sucio"):
+            return "respalda", ("repo git con remoto pero CON CAMBIOS SIN COMMITEAR: lo commiteado "
+                                "se vuelve a clonar, lo de encima sólo está acá")
+        if e.get("git_fecha") and not rem:
+            return "respalda", (f"repo git SIN remoto (último commit {e['git_fecha']}): su historia "
+                                "existe únicamente en este disco")
         return "", "los datos no se recomiendan solos: sólo vos sabés si valen"
     return "", ""
 
 
+# Nombres cuyo contenido se REGENERA. No es una lista de «cosas grandes»: es de cosas que el destino
+# vuelve a crear solo. Borrar por tamaño sería adivinar; por regenerabilidad es un hecho.
+_CACHES = {
+    ".cache", ".npm", ".npm-global", ".gradle", ".pub-cache", ".dartServer", ".cargo", ".rustup",
+    ".m2", ".gem", ".go", "go", ".yarn", ".bun", ".deno", ".venvs", ".local-cache", "node_modules",
+    "target", "__pycache__", ".mozilla-cache", ".thumbnails", ".ccache", ".sccache",
+}
+
+
 def sugerir(tipo, e, datos_que_mueren=()):
     return recomendar(tipo, e, datos_que_mueren, sitios)[0]
 
@@ -443,6 +493,12 @@ def revisar(censo, interactivo=True):
     """
     todos = entradas(censo)
     datos = [(t, n, e) for t, n, e in todos if t == "datos"]
+    # Con qué se compara un remoto para saber si apunta a esta misma máquina. Sale del censo, no de
+    # una lista cableada: las IPs y el hostname los midió `identidad()`.
+    maq = censo.get("maquina", {})
+    locales = {"127.0.0.1", "localhost", "0.0.0.0"} | set(maq.get("ips", []) or [])
+    if maq.get("hostname"):
+        locales.add(maq["hostname"])
     resto = [(t, n, e) for t, n, e in todos if t != "datos"]
 
     # ── 1. datos ────────────────────────────────────────────────────────────────────────────────
@@ -452,28 +508,41 @@ def revisar(censo, interactivo=True):
         print("   la recomendación de los servicios, así que va primero.\n")
         for t, n, e in datos:
             marca = e.get("decision") or "—"
-            print(f"   {n:34} {_etiqueta(t,n,e):>22}   decisión actual: {marca}")
+            rec, motivo = recomendar("datos", e, locales=locales)
+            e["_rec"], e["_motivo"] = rec, motivo
+            print(f"   {n:46} {e.get('size','?'):>6}  {marca if marca != '—' else (rec or '—'):>8}"
+                  f"  {e.get('descripcion', '')[:70]}")
         if interactivo:
-            print()
+            print("\n   [m]uda = va a la caja nueva · [r]espalda = se copia al corral y NO se instala"
+                  " · [x] muere con la máquina\n")
             for t, n, e in datos:
                 if e.get("decision") in DECISIONES:
                     continue
-                r = input(f"   {n:34} [{_etiqueta(t,n,e)}]  [m]uda/[x]muere > ").strip().lower()
-                e["decision"] = {"m": "muda", "x": "muere"}.get(r, "")
-
-    mueren = [e["path"] for t, n, e in datos if e.get("decision") == "muere"]
+                rec = e.get("_rec", "")
+                print(f"   {n}   {e.get('size','?')}")
+                if e.get("descripcion"):
+                    print(f"      {e['descripcion'][:100]}")
+                if e.get("_motivo"):
+                    print(f"      ↳ sugerido: {rec} — {e['_motivo'][:90]}")
+                r = input(f"      [m]uda/[r]espalda/[x]muere"
+                          f"{'/Enter=' + rec if rec else ''} > ").strip().lower()
+                e["decision"] = {"m": "muda", "r": "respalda", "x": "muere", "": rec}.get(r, "")
+
+    # ⚠ `respalda` entra acá junto a `muere`: las dos significan «su ruta NO va a existir en el
+    # destino», que es lo único que la derivación de servicios necesita saber.
+    mueren = [e["path"] for t, n, e in datos if e.get("decision") in ("muere", "respalda")]
 
     # ── 2. lo demás, con recomendación y motivo ─────────────────────────────────────────────────
-    grupos = {"muda": [], "muere": [], "": []}
+    grupos = {"muda": [], "respalda": [], "muere": [], "": []}
     for t, n, e in resto:
         d, motivo = recomendar(t, e, mueren, censo.get("sitio", []))
         e["_rec"], e["_motivo"] = d, motivo
         grupos[d].append((t, n, e))
 
     print("\n══ 2/2 · SERVICIOS Y DOMINIOS ════════════════════════════════════════════════════")
-    titulos = {"muda": "se recomienda MUDAR", "muere": "se recomienda NO mudar",
-               "": "SIN recomendación — hay que decidir"}
-    for g in ("muda", "muere", ""):
+    titulos = {"muda": "se recomienda MUDAR", "respalda": "se recomienda RESPALDAR",
+               "muere": "se recomienda NO mudar", "": "SIN recomendación — hay que decidir"}
+    for g in ("muda", "respalda", "muere", ""):
         if not grupos[g]:
             continue
         print(f"\n── {titulos[g]} ({len(grupos[g])}) " + "─" * 30)
@@ -515,8 +584,8 @@ def revisar(censo, interactivo=True):
             continue
         sug = f"[{rec}]" if rec else "[sin recomendación]"
         resp = input(f"   {t:8} {n:30} {sug} {e.get('_motivo','')[:60]}\n"
-                     f"            [m]uda/[x]muere/Enter={rec or 'nada'} > ").strip().lower()
-        e["decision"] = {"m": "muda", "x": "muere", "": rec}.get(resp, "")
+                     f"            [m]uda/[r]espalda/[x]muere/Enter={rec or 'nada'} > ").strip().lower()
+        e["decision"] = {"m": "muda", "r": "respalda", "x": "muere", "": rec}.get(resp, "")
         if e["decision"] == "muda" and t == "servicio":
             lista, alt_rec, motivo = alternativas(n, _REPO)
             if lista and len([x for x in lista if x["sellado"]]) > 1:
@@ -532,6 +601,53 @@ def revisar(censo, interactivo=True):
     return censo
 
 
+def guardar_censo(censo, ruta, respaldar=True):
+    """Vuelve a escribir el censo con las decisiones puestas, usando el MISMO emisor que lo creó.
+
+    Dos detalles que no son de estilo: se descartan las claves internas (`_rec`, `_motivo`, que
+    `revisar()` cuelga de cada entrada y NO son parte del censo), y se escribe con temporal +
+    `rename` — si la herramienta muere a mitad, el fichero anterior sigue entero. Perder el censo a
+    medio decidir es perder el trabajo que más cuesta de toda la mudanza."""
+    limpio = {}
+    for k, v in censo.items():
+        if isinstance(v, list):
+            limpio[k] = [{kk: vv for kk, vv in e.items() if not kk.startswith("_")}
+                         if isinstance(e, dict) else e for e in v]
+        else:
+            limpio[k] = v
+    # El emisor de `censar` habla en plural; el TOML, en singular. El puente va acá y no allá para
+    # no tocar el censo, que es quien define el formato.
+    puente = {
+        "fecha": limpio.get("fecha", ""), "identidad": limpio.get("maquina", {}),
+        "servicios": limpio.get("servicio", []), "huerfanos": limpio.get("huerfano", []),
+        "puertos": limpio.get("puerto", []), "dominios": limpio.get("dominio", []),
+        "datos": limpio.get("datos", []), "datos_raiz": limpio.get("datos_raiz", []),
+        "contenido": limpio.get("contenido", []), "sitios": limpio.get("sitio", []),
+        "repos": limpio.get("repo", []), "montajes": limpio.get("montaje", []),
+        "fuera_de_git": limpio.get("fuera_de_git", []),
+    }
+    texto = censar.emitir_toml(puente)
+    # Y se RELEE antes de pisar nada: el mismo guardián que el plan. Un censo que no vuelve a
+    # parsear deja la mudanza sin su fichero central y sin forma de saber cuándo se rompió.
+    try:
+        vuelto = tomllib.loads(texto)
+    except tomllib.TOMLDecodeError as e:
+        sys.exit(f"✗ el censo regenerado NO es TOML válido: {e}\n  No se escribe nada.")
+    n_dec = sum(1 for _t, _n, x in entradas(vuelto) if x.get("decision"))
+    n_esp = sum(1 for _t, _n, x in entradas(censo) if x.get("decision"))
+    if n_dec != n_esp:
+        sys.exit(f"✗ el censo regenerado perdió decisiones ({n_esp} → {n_dec}). No se escribe nada.")
+    if respaldar and os.path.exists(ruta):
+        with open(ruta, encoding="utf-8") as fh:
+            open(ruta + ".bak", "w", encoding="utf-8").write(fh.read())
+    tmp = ruta + ".tmp"
+    with open(tmp, "w", encoding="utf-8") as fh:
+        fh.write(texto)
+        fh.flush()
+        os.fsync(fh.fileno())
+    os.replace(tmp, ruta)
+
+
 def validar(censo):
     """Regla 1: nada sin decidir se ejecuta."""
     faltan = [(t, n) for t, n, e in entradas(censo) if e.get("decision") not in DECISIONES]
@@ -618,7 +734,28 @@ def incoherencias(censo):
     return out
 
 
-def pasos(censo, target, key):
+def destino_respaldo_default():
+    """El corral de respaldo sale de `scripts/respaldo-storagebox.sh`, NO de una constante nueva acá.
+
+    Dos ficheros con la misma dirección escrita a mano divergen, y el que se queda viejo no falla:
+    respalda a un sitio que nadie mira. Se leen sus defaults (`SB_USER`/`SB_HOST`/`SB_PORT`), que es
+    donde el proyecto ya guarda lo que se guarda por si acaso."""
+    ruta = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "respaldo-storagebox.sh")
+    v = {}
+    try:
+        with open(ruta, encoding="utf-8") as fh:
+            for linea in fh:
+                for k in ("SB_USER", "SB_HOST", "SB_PORT"):
+                    if linea.startswith(f'{k}="${{{k}:-'):
+                        v[k] = linea.split(":-", 1)[1].split("}", 1)[0]
+    except OSError:
+        return "", ""
+    if not (v.get("SB_USER") and v.get("SB_HOST")):
+        return "", ""
+    return f'{v["SB_USER"]}@{v["SB_HOST"]}:respaldo-mudanza/', v.get("SB_PORT", "22")
+
+
+def pasos(censo, target, key, respaldo=None, respaldo_port=None):
     ssh_opts = "-o StrictHostKeyChecking=accept-new -o ConnectTimeout=20"
     if key:
         ssh_opts = f"-i {key} " + ssh_opts
@@ -633,6 +770,7 @@ def pasos(censo, target, key):
                   "verifica": verifica or "", "verifica_tipo": verifica_tipo, "nota": nota or ""})
 
     datos_muda = [d for d in censo.get("datos", []) if d.get("decision") == "muda"]
+    datos_resp = [d for d in censo.get("datos", []) if d.get("decision") == "respalda"]
     svc_muda = [s for s in censo.get("servicio", []) if s.get("decision") == "muda"]
     doms_muda = [d for d in censo.get("dominio", []) if d.get("decision") == "muda"]
     muere = [(t, n, e) for t, n, e in entradas(censo) if e.get("decision") == "muere"]
@@ -807,12 +945,21 @@ def pasos(censo, target, key):
     for d in datos_muda:
         p = d["path"]
         dst = (d.get("destino") or "").strip() or p
+        # Ver la nota del paso de respaldo: con el censo detallado hay entradas que son FICHEROS, y
+        # a un fichero no se le pone barra (y su verificación no es `find -type f | wc -l` en un
+        # directorio, sino comparar el fichero mismo).
+        es_dir = d.get("tipo", "dir") != "fichero"
+        org, dst_r = (p + "/", dst + "/") if es_dir else (p, dst)
+        ver = ((f"src=$(find {shlex.quote(p)} -type f | wc -l); "
+                f"dst=$({ssh} 'find {shlex.quote(dst)} -type f | wc -l'); "
+                f'echo "ficheros: origen=$src destino=$dst"; [ "$src" = "$dst" ]') if es_dir else
+               (f"src=$(stat -c%s {shlex.quote(p)}); "
+                f"dst=$({ssh} 'stat -c%s {shlex.quote(dst)}' 2>/dev/null || echo ausente); "
+                f'echo "bytes: origen=$src destino=$dst"; [ "$src" = "$dst" ]'))
         paso("datos", f"copiar {p} → {dst}  ({d.get('size','?')})",
              f"rsync -aH --partial --info=stats2 --exclude='.dmerge' "
-             f"-e {shlex.quote('ssh ' + ssh_opts)} {shlex.quote(p + '/')} {target}:{shlex.quote(dst + '/')}",
-             verifica=(f"src=$(find {shlex.quote(p)} -type f | wc -l); "
-                       f"dst=$({ssh} 'find {shlex.quote(dst)} -type f | wc -l'); "
-                       f'echo "ficheros: origen=$src destino=$dst"; [ "$src" = "$dst" ]'),
+             f"-e {shlex.quote('ssh ' + ssh_opts)} {shlex.quote(org)} {target}:{shlex.quote(dst_r)}",
+             verifica=ver,
              nota="`-H` obligatorio: sin él los hardlinks se expanden (60 G medidos → 85 G). "
                   "Y la verificación COMPARA, no informa: cuenta los ficheros de los dos lados y "
                   "FALLA si no coinciden. La versión anterior imprimía el número de directorios "
@@ -820,6 +967,51 @@ def pasos(censo, target, key):
                   "Hace falta porque un rsync que se corta deja el destino a medias y devuelve 0: "
                   "así se llenó un disco y quedaron 1367 artefactos vacíos.")
 
+    # ── 1 bis. lo que se RESPALDA ───────────────────────────────────────────────────────────────
+    # Ni se muda ni se pierde. Va al corral de respaldo y NO se instala en el destino: la caja nueva
+    # no se entera de que esto existe. Por eso es un paso propio y no un `datos` con otro destino —
+    # su verificación es distinta y su fallo significa otra cosa (no rompe el servidor nuevo, pero
+    # borra el original sin copia si nadie lo mira).
+    if datos_resp:
+        if not respaldo:
+            # Un corral vacío convertiría cada `respalda` en un `rsync` a ninguna parte que además
+            # devuelve 0. Abortar acá es la única forma de que no se lea como «respaldado».
+            sys.exit("✗ hay entradas marcadas `respalda` y no sé a dónde respaldarlas.\n"
+                     "  Pasá --respaldo <destino> (host:ruta o ruta local). No invento uno: un\n"
+                     "  respaldo a un sitio equivocado se ve igual que un respaldo.")
+        rsh = f"ssh -p {respaldo_port} {ssh_opts}" if respaldo_port else f"ssh {ssh_opts}"
+        remoto = ":" in respaldo.split("/", 1)[0]
+        for d in datos_resp:
+            ruta = d["path"]
+            # Aplanada: `/home/sergio/humanoid` → `home-sergio-humanoid`. El corral junta cosas de
+            # rutas distintas, y conservar el árbol haría que dos orígenes se pisaran en el mismo
+            # nombre. El nombre aplanado DICE de dónde salió, que es lo que hace falta para volver.
+            nombre = ruta.strip("/").replace("/", "-")
+            dst = respaldo.rstrip("/") + "/" + nombre
+            # ⚠ Un FICHERO no lleva barra final y su destino es el directorio que lo contiene.
+            # Con el censo detallado entran ficheros sueltos (`mapas.tgz`, `gu.png`, un `.docx`), y
+            # `rsync fichero/ destino/` no es «copiá el fichero»: es un error. El censo ya dice cuál
+            # es cuál (`tipo`), así que no hay que adivinarlo.
+            es_dir = d.get("tipo", "dir") != "fichero"
+            org = ruta + "/" if es_dir else ruta
+            dst_r = dst + "/" if es_dir else dst
+            # ⚠ `--mkpath` NO es opcional, y lo cazó el guardián de dos sentidos: sin él rsync
+            # muere con `mkdir "/home/respaldo-mudanza/prueba-guardian" failed` en cuanto el corral
+            # tiene un nivel que todavía no existe — y el primer respaldo SIEMPRE lo tiene.
+            base = (f"rsync -aH --mkpath --partial --partial-dir=.rsync-partial --info=stats2 "
+                    + (f"-e {shlex.quote(rsh)} " if remoto else "")
+                    + f"{shlex.quote(org)} {shlex.quote(dst_r)}")
+            # La verificación se le pregunta a RSYNC, no a `du` ni a `find`: el corral puede ser un
+            # Storage Box, cuya shell restringida no tiene `find` ni acepta tuberías (medido). Una
+            # corrida en seco compara tamaño y fecha CONTRA EL DESTINO REAL y dice qué falta.
+            ver = (f"pend=$({base.replace('rsync -aH ', 'rsync -aHn ').replace('--info=stats2', chr(39)+'--out-format=%n'+chr(39))}"
+                   f" | grep -v '/$' | wc -l); echo \"pendientes en el corral: $pend\"; [ \"$pend\" -eq 0 ]")
+            paso("respaldo", f"respaldar {ruta} → {dst}  ({d.get('size','?')})", base,
+                 verifica=ver,
+                 nota="NO se instala en el destino: esto es archivo, no mudanza. Y `du` en el "
+                      "corral puede dar MENOS que el origen sin que falte nada (un Storage Box "
+                      "comprime: 1,3 G subidos se veían como 576 M). Por eso verifica rsync.")
+
     # ── 2. servicios ────────────────────────────────────────────────────────────────────────────
     for s in svc_muda:
         nd = s.get("class") == "no-declarado"
@@ -957,6 +1149,11 @@ def main():
     ap.add_argument("--revisar", action="store_true",
                     help="sólo mostrar la revisión con recomendaciones, sin preguntar")
     ap.add_argument("--out", help="fichero del plan (default: stdout)")
+    ap.add_argument("--censo-out", help="dónde escribir el censo con las decisiones tomadas "
+                                        "(default: sobre el mismo censo, con copia `.bak`)")
+    ap.add_argument("--respaldo", help="corral donde va lo marcado `respalda` (host:ruta o ruta "
+                                       "local). Default: el Storage Box de respaldo-storagebox.sh")
+    ap.add_argument("--respaldo-port", help="puerto SSH del corral (el Storage Box usa 23)")
     a = ap.parse_args()
 
     with open(a.censo, "rb") as f:
@@ -966,9 +1163,20 @@ def main():
         return
     if a.decide:
         censo = revisar(censo, interactivo=True)
+        # ── LAS DECISIONES SE GUARDAN, O NO EXISTIERON ──────────────────────────────────────────
+        # Antes `--decide` sólo las usaba para generar el plan de esa corrida: cerrabas la terminal
+        # y las 169 elecciones se perdían. El censo ES la interfaz (SDD 29 §3), así que volver a
+        # escribirlo no es comodidad, es lo que hace que decidir a medias sea posible — y decidir
+        # 169 cosas SIEMPRE se hace a medias.
+        destino_censo = a.censo_out or a.censo
+        guardar_censo(censo, destino_censo, respaldar=(destino_censo == a.censo))
+        print(f"   decisiones guardadas → {destino_censo}")
     validar(censo)
 
-    P = pasos(censo, a.target, a.key)
+    resp_dst, resp_port = (a.respaldo, a.respaldo_port)
+    if not resp_dst:
+        resp_dst, resp_port = destino_respaldo_default()
+    P = pasos(censo, a.target, a.key, resp_dst, resp_port)
     por_clase = {}
     for p in P:
         por_clase[p["clase"]] = por_clase.get(p["clase"], 0) + 1