plan: catálogo objetivo — manifiesto de perfiles + estado `wanted` en el grafo
El grafo de lo que EXISTE cierra perfecto (768 nodos, 0 huérfanas, topo OK). El de lo que la distro DEBE tener no existe: vive en 2 strings de shell de product-userland-from-repo.sh, uno de mirada-usb.sh y 55 tandas planas. De ahí que "cuánto falta" no tenga respuesta. Plan en 5 piezas: targets.toml (perfiles = imágenes, listando RAÍCES y dejando que la clausura salga del grafo), estado `wanted` + membresía de perfil en build-state.py, siembra de aristas desde metadata upstream sin construir, y el drenaje topológico donde harkaq reemplaza la arista hipotética por la medida. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Delta semántico
Ninguna definición cambió: el cambio es de texto, documentación o datos.
1 archivos · +174 −0
- docs/plan-catalogo-objetivo.md +174 −0
diff --git a/docs/plan-catalogo-objetivo.md b/docs/plan-catalogo-objetivo.md
new file mode 100644
index 00000000..3b56c253
--- /dev/null
+++ b/docs/plan-catalogo-objetivo.md
@@ -0,0 +1,174 @@
+# Plan — catálogo objetivo: del caos al contador que baja
+
+**Fecha:** 2026-07-22 · **Estado:** propuesto, listo para ejecutar · **Toca:** `scripts/build-state.py`,
+`docs/state/`, `scripts/product-userland-from-repo.sh`, `scripts/mirada-usb.sh`
+
+## 1. El diagnóstico, medido (no intuido)
+
+La pregunta que lo dispara: *«¿se puede barrer primero TODOS los paquetes que va a tener la distro y
+construir el árbol de dependencias de una, en vez de descubrir a los golpes?»*
+
+Media respuesta ya está construida. `docs/state/build-state.json` **es** ese árbol: 768 nodos,
+aristas = deps de build, `topo_ok: true`, **0 deps huérfanas**, 750 selladas / 16 deuda / 2 nunca. Y
+cada nodo trae `blocked_by` (quién me traba) y `unblocks` (a cuántos destrabo) ⇒ el orden de
+masticado ya está calculado (`scripts/build-state.py:147-157`).
+
+Lo que **no** existe es el grafo de lo que la distro *debe* tener. Ese conocimiento vive disperso:
+
+| dónde | qué es | forma |
+|---|---|---|
+| `scripts/product-userland-from-repo.sh:26-28` | el userland real del producto | 2 strings de shell |
+| `scripts/mirada-usb.sh:38` | la imagen de escritorio mirada | 1 string de shell |
+| `tandas/*.txt` | 55 ficheros de campañas pasadas | listas planas, sin aristas |
+| `recipes/incoming-kde/` | 206 recetas del escritorio | una cola, no un objetivo |
+
+De ahí la sensación de caos: **el mapa cierra perfecto, pero sólo cubre el territorio ya
+conquistado.** «¿Cuánto falta?» no tiene respuesta porque no hay artefacto que enumere el destino.
+Y cada frente nuevo re-descubre su propia frontera desde cero.
+
+## 2. Qué se puede saber de antemano y qué no
+
+Honestidad sobre el límite, porque define el alcance del plan:
+
+- **Se puede saber el esqueleto.** nixpkgs/Alpine ya tienen el grafo de deps de casi todo. Se
+ extrae sin construir nada (`scripts/nix-import.sh:24-40` ya hace exactamente ese `nix eval`
+ sacando `buildInputs` + `nativeBuildInputs`).
+- **No se puede saber la verdad.** Las deps de un paquete no son propiedad del paquete: son
+ propiedad de *tu configuración de build*. mesa con qué drivers gallium, qué plataformas, qué
+ opciones meson — cambiás una flag y cambia el grafo. Lo importado es el grafo de **otra** distro
+ con **otras** decisiones. Aproximación buena, verdad no.
+- **El tramo final sólo se sabe ejecutando el build** — pero cuesta **1** construcción, no N.
+ «Compilar mesa cinco veces» era el método viejo (probar → fallar → adivinar qué faltó). harkaq ya
+ convirtió eso de *búsqueda* en *medición*: construís una vez, el veredicto por fase dice qué paths
+ tocó de verdad, los declarás. Es el bucle ya cerrado con zlib (`Impuro` → `Hermetico` en 3 fases)
+ y el que la campaña desatendida corre en la granja (49 recetas con deps declaradas por el kernel).
+
+**Conclusión:** el caos no es inevitable, pero no se elimina por adelantado — se convierte. El
+barrido previo compra el **esqueleto y el orden**; harkaq compra **una medición en vez de cinco
+adivinanzas**. Lo irreducible que queda son las deps que dependen de *tus* opciones de compilación,
+y ésas son decisiones, no descubrimientos: van en la receta, no en un bucle de prueba y error.
+
+## 3. El invariante nuevo
+
+> Hoy `orphan_deps == 0` porque el grafo sólo contiene lo que existe. El plan lo abre a propósito:
+> **la frontera deja de ser invisible y pasa a ser un número.**
+
+Ojo con el gate de CI (`build-state.py --check`, `scripts/build-state.py:201`): hoy sale 1 si hay
+huérfanas o `unhashable`. El diseño lo preserva — un nodo *deseado* está **declarado**, así que una
+arista que apunta a él **resuelve** y `orphan_deps` sigue significando lo mismo (arista colgando que
+nadie declaró ni quiere). La frontera se cuenta con un estado nuevo, `wanted`, que **no** es un
+fallo. Y `wanted` ≠ `unhashable`: el primero es «no hay receta todavía», el segundo «hay receta y
+está rota».
+
+## 4. Las piezas
+
+### P1 — `docs/state/targets.toml`: el manifiesto de objetivo
+
+Un solo fichero declarativo. Un `perfil` = una imagen enviable.
+
+```toml
+schema = "hammer-targets/1"
+
+[perfil.base]
+descripcion = "userland foundational: la distro arranca y se usa"
+paquetes = ["bash", "git", "sudo", "doas", "util-linux", "..."]
+
+[perfil.cli]
+hereda = ["base"]
+paquetes = ["bat", "fd", "ripgrep", "..."]
+
+[perfil.escritorio-kde]
+hereda = ["base"]
+paquetes = ["plasma-desktop", "..."]
+```
+
+**La inversión clave:** `paquetes` lista sólo las **raíces** — lo que un usuario pide por nombre. La
+clausura sale del grafo, no de la lista. Hoy es al revés: `tandas/base-system-3-libs.txt` enumera a
+mano los miembros de la clausura («libs»). Eso es trabajo que el grafo puede hacer solo.
+
+Migración: los strings de `product-userland-from-repo.sh:26-28` pasan a `perfil.base` +
+`perfil.cli` **verbatim**, y el de `mirada-usb.sh:38` a `perfil.escritorio-mirada`. Es un
+lift-and-shift, no un rediseño. **Gate:** el `PKGS_LIST` resultante debe ser idéntico al de hoy —
+verificable con un diff de la lista expandida. Ninguna imagen que hoy funciona puede cambiar.
+
+### P2 — estado `wanted` + membresía de perfil en `build-state.py`
+
+Cambio quirúrgico (~40 líneas) sobre `load_recipes()` / `main()`:
+
+1. Cargar `targets.toml`, expandir `hereda`, obtener el set de raíces por perfil.
+2. Toda raíz —y toda dep de un nodo— que no tenga receta se crea como nodo
+ `{state: "wanted", deps: [], perfiles: [...]}`. Deja de ser huérfana y pasa a ser frontera.
+3. **Campo `perfiles` en TODOS los nodos**, propagado por alcanzabilidad desde las raíces. Esto
+ solo ya paga el trabajo: responde *«si rompo zlib, qué imágenes se caen»* y —más incómodo—
+ *«cuáles de las 768 recetas no las necesita ninguna imagen»* (sospecha: buena parte de los 362
+ CLIs Go son catálogo, no distro; conviene saberlo explícitamente en vez de suponerlo).
+4. `totals` gana `wanted`; bloque nuevo `by_profile`: por perfil, tamaño de clausura / sellado /
+ deuda / wanted. **Ése es el «cuánto falta», por imagen.**
+
+**Límite honesto que hay que escribir en el header del script:** la clausura por perfil necesita las
+deps de los nodos `wanted`, que no se conocen hasta P3. En la primera corrida `by_profile` es una
+**cota inferior** que crece a medida que se siembra. Mejor un número que converge que ninguno.
+
+### P3 — `scripts/seed-graph.py`: sembrar aristas desde upstream
+
+Para cada nodo `wanted` sin aristas, sacar la lista de deps de la metadata upstream **sin construir**:
+
+- **nix** — reusar tal cual el `nix eval --apply` de `nix-import.sh:24-40`, que ya devuelve
+ `build_inputs` + `native_build_inputs`. No emitir receta: sólo las aristas.
+- **alpine** — `makedepends` del APKBUILD, vía `alpine-import.sh`.
+
+Salida a un fichero **aparte**, `docs/state/seed-edges.json`, con procedencia por arista
+(`{dep, fuente: "nix"|"alpine", fecha}`). Nunca a `targets.toml`, nunca a `recipes/`.
+
+**Disciplina, en negrita porque es donde esto se puede pudrir: la semilla es una HIPÓTESIS, no la
+verdad.** Los nombres son de upstream (`pkg-config` no existe en hammer, que usa zig-cc; `stdenv` y
+los hooks de autoreconf no son nada acá). Hace falta una tabla de mapeo de nombres y una lista de
+descarte de nix-ismos. Va a haber ruido. Su único trabajo es hacer la frontera **contable y
+ordenable**, no correcta. Nota de ADR: leer la *metadata* de nixpkgs no viola el ADR 0004 — lo que
+prohíbe es **construir** con nix, y acá no se construye nada.
+
+### P4 — el bucle de drenaje, y dónde harkaq cierra el lazo
+
+Regla de fuente única de verdad:
+
+> **`seed-edges.json` sólo se consulta para nodos SIN receta. En cuanto hay receta, manda la receta.**
+
+Con eso la semilla es **auto-borrable**: cada paquete construido reemplaza su arista hipotética por
+la medida (la receta, ya corregida por el veredicto de harkaq — que es literalmente lo que hace la
+campaña desatendida hoy). Al final del frente, `seed-edges.json` queda vacío. Si no se vacía, eso
+mismo es la lista de lo que falta.
+
+Orden de ataque por perfil: topológico, priorizado por `unblocks` — ya se calcula en
+`build-state.py:151-157`, sólo hay que incluir los nodos `wanted`.
+
+### P5 — jubilar las tandas (degradarlas, no borrarlas)
+
+`tandas/` pasa a ser **archivo de campañas pasadas** (55 ficheros con historia, no se tiran). El
+trabajo nuevo sale de una vista derivada del grafo: los próximos N nodos `wanted` **construibles ya**
+(sin `blocked_by`) en orden topológico. El lazo de la granja (`vps-nunca-idle`, la cosecha cron) se
+alimenta de ahí en vez de listas escritas a mano.
+
+**Eso es lo que de verdad mata la sensación de caos: la cola del worker deja de ser autoría y pasa a
+ser derivada.**
+
+## 5. Secuencia (commits chicos, valor en el primero)
+
+| paso | qué | riesgo | esfuerzo |
+|---|---|---|---|
+| **P1** | `targets.toml` + lift-and-shift de los 3 strings; gate = lista expandida idéntica | bajo | ~1h |
+| **P2** | `wanted` + `perfiles` + `by_profile` en `build-state.py`; regenerar y commitear el diff del JSON | bajo | ~1-2h |
+| **P3** | `seed-graph.py` contra **un solo perfil** (`escritorio-kde`: el más duro y el de mayor valor, 206 recetas con frontera desconocida). **Medir la tasa de ruido antes de generalizar** | medio | ~medio día |
+| **P4** | enchufar el drenaje a la cola de la granja | medio | tras P3 |
+
+P1+P2 es el corte mínimo viable y ya entrega el primer número de «cuánto falta por imagen». P3 no se
+generaliza hasta conocer su ruido.
+
+**Gates:**
+- P2 mantiene `--check` en verde y `topo_ok: true`.
+- P3 **no escribe** en `recipes/` ni en `targets.toml`.
+- Ninguna imagen que hoy bootea cambia de contenido por P1.
+
+## 6. Coordinación
+
+Toca ficheros compartidos (`scripts/build-state.py`, `docs/state/`). Hay otro agente trabajando en
+el repo ⇒ acordar antes de P1/P2, y **nunca `git add -A`** (memoria `etapa-g-frente-go`).