# 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.

#### P3 — RESULTADO MEDIDO (2026-07-22)

`scripts/seed-graph.py`, con `--calibrar` que mide la semilla contra la verdad que ya tenemos: las
recetas escritas a mano. Tres rondas de calibración, cada corrección salida de un dato y no de una
intuición. Muestra final: 60 recetas, 686 deps declaradas.

| modo | recall (de la verdad) | precisión (aristas sembradas que son reales) |
|---|---|---|
| **directas** | 39% * | **59%** (269 de 455) |
| transitivas | **84%** | 6% (390 de 6760) |

\* contra la verdad *aplanada*. La comparación es injusta y el modo transitivo lo prueba: la
información **sí está** (84%), sólo hace falta aplanarla.

**Decisión: se siembran aristas DIRECTAS; la transitividad la hace el grafo.** Las dos distros ponen
la frontera en lugares distintos — hammer APLANA la clausura en `[deps].build` (cada dep es una capa
`--overlay-src` del sandbox: si no está declarada, no está en el árbol), nixpkgs declara sólo las
directas y propaga. Reproducir el aplanado con el cierre transitivo de nixpkgs arrastra su universo
de bootstrap: **4629 nodos fantasma** (`glibc-locales`, `autoconf`, `texinfo`, `help2man`). Como
`build-state.py` ya calcula clausura desde las aristas, aplanar en el sembrador es trabajo duplicado
que además envenena el grafo.

Lo que la calibración enseñó, y que ninguna intuición habría dado:

- **Cuatro clases de discrepancia, no una.** No todo desacuerdo es ruido: `make`/`binutils`/
  `linux-headers` son **convención de hammer** (nix las da por implícitas en el stdenv); `zlib` vs
  `zlib-shared` es una **decisión nuestra** de enlazado que nix no puede conocer; `cargo`/`rustc` los
  **provee el lab**, no una receta (las recetas Rust declaran `deps = []`); y recién lo que queda es
  ruido de verdad. Contarlas juntas hacía parecer irreducible lo que era clasificable.
- **Los nombres desalinean de formas aburridas y arreglables**: mayúsculas (`libx11` vs `libX11`),
  implementación (`gettext`→`gettext-tiny`, `ninja`→`samurai`), versión pegada al pname
  (`glibc-iconv-2.42`), hooks del stdenv (`cargo-build-hook.sh`). Filtrarlos subió la precisión de
  45% a 59% y bajó los "sin attr en nixpkgs" de 22/40 a 6/60.
- **El escritorio KDE no vive en el top-level de nixpkgs** sino bajo `kdePackages.`. Sin ese
  fallback, justo el perfil objetivo era insembrable.

**Y un hallazgo de secuencia: hoy hay 0 nodos `wanted`**, porque `targets.toml` se pobló por
lift-and-shift de lo que ya existe. Un sembrador de nodos inexistentes no tiene a quién sembrar. De
ahí el modo que sí da valor ya: `--frontera <perfil>` siembra las recetas que YA existen en la
clausura de un perfil y reporta lo que nixpkgs pide y hammer no tiene receta para construir —
descubre la frontera desde el corpus actual en vez de esperar a que alguien la declare. Sale a
`docs/state/seed-frontera.json`, versionado, para que el `git diff` muestre qué huecos aparecen.
Primer barrido: **137 candidatos en `escritorio-kde`** (`kdoctools` ×6, `milou`, `polkit-qt-1`,
`libkscreen`, `qqc2-breeze-style`, y `mesa-libgbm`/`libglvnd`/`spirv-tools` — que son exactamente el
muro de GBM/EGL ya documentado), **51 en `cli`**, 46 en `escritorio-mirada`.

Cada candidato es una **hipótesis a clasificar por un humano**: (a) dep opcional que hammer no
habilitó a propósito, (b) nix-ismo que falta filtrar, o (c) hueco real. El sembrador no decide cuál;
los ordena por cuántas recetas los piden. **Nada se promueve a receta automáticamente.**

### 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`.

#### P4 — RESULTADO MEDIDO (2026-07-22)

`scripts/drenar.py`. No lista "lo que falta" en un montón: lo parte en **ondas topológicas** sobre el
subgrafo *en deuda* (una receta sólo espera por deps que también estén en deuda; las selladas ya
están en el store y no bloquean). Onda 1 = construible ya. Dentro de cada onda, orden por `unblocks`
desc: primero lo que libera más.

Eso da tres cosas que una lista plana no da: el orden correcto, **la profundidad real de la cadena**
—cuántos pasos secuenciales faltan, que es lo que manda en el reloj de pared— y qué se puede
paralelizar sin pisarse.

| perfil | en deuda | ondas | onda 1 |
|---|---|---|---|
| base | 0 | — | imagen completa |
| cli | 0 | — | imagen completa |
| **escritorio-kde** | **77** | **12** | **1 receta: `qtbase`** |
| escritorio-mirada | 4 | 2 | 3 recetas |

**El hallazgo: el escritorio KDE está serializado detrás de una sola receta.** `qtbase` destraba 117
nodos y es lo único de la onda 1 — hasta que no esté sellada, ningún otro trabajo del escritorio
puede empezar, y por más workers que se enciendan no hay nada que paralelizar. Las 12 ondas son el
piso de pasos secuenciales. Eso cambia la pregunta de "¿cuántas recetas faltan?" (77, poco
informativo) a "¿cuál es el camino crítico?" (qtbase → qttools/qtdeclarative → los KF6 → Plasma).

Dos correcciones que salieron de mirar la salida:

- **El grafo se elige por la `cola` que el perfil declara, no por cuál tiene más nodos.** La cola
  `incoming-kde` SOMBREA recetas canónicas, así que medir `escritorio-mirada` contra el grafo KDE
  medía una imagen que nadie construye (daba 3 en deuda en vez de 4). Cada perfil se mide contra el
  árbol de recetas del que realmente sale. El mismo atajo estaba en `seed-graph.py --frontera`;
  corregido en los dos.
- **La regla de la fuente única, implementada**: `deps_de()` usa la receta si existe y las aristas
  sembradas sólo si el nodo es `wanted`, marcando la procedencia (`[semilla]`) en la salida. Hoy no
  se ejerce (0 nodos `wanted`), pero queda escrita donde se va a usar.

**Enchufado al latido**: `cosecha-cron.sh` regenera `docs/state/drenaje.json` junto al grafo y lo
commitea. La cola pasa a ser **derivada y versionada** — el `git diff` entre dos ciclos muestra qué
salió de la deuda y qué onda se vació. **No lanza ningún build**: construir cuesta € y sigue siendo
decisión explícita (`farm-up` + `campana-deuda.sh`), que es lo que el pie de la salida recuerda.

### 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.**

#### P5 — RESULTADO (2026-07-22)

`tandas/README.md` declara el directorio **archivo de campañas pasadas** (55 ficheros, no se borran:
su `git log` es la crónica de cada frente) y documenta de dónde sale el trabajo ahora. La vía de
escape (`import-batch.sh -f`) sigue viva; deja de ser el camino principal.

Lo que faltaba para que la cola fuera de verdad derivada era el paso humano, y estaba sin forma:
137 candidatos de frontera clasificables sólo en la cabeza de alguien. `scripts/triaje.py` le da
forma durable — `docs/state/frontera-triaje.toml`, **170 candidatos** unificados de los tres
perfiles. Cada veredicto CIERRA un lazo distinto, que es lo que lo hace algo más que una lista:

| veredicto | destino | efecto |
|---|---|---|
| `hueco` | raíz en `targets.toml` | nace un nodo `wanted` ⇒ el sembrador lo siembra y el drenaje lo ordena |
| `opcional` | queda registrado | deja de reaparecer como pregunta |
| `nix-ismo` | `nixismos-triaje.txt` | vuelve a `seed-graph.py` como descarte ⇒ **el sembrador aprende de su propio triaje** |

Sincronizar **nunca pisa un juicio humano**: agrega candidatos nuevos y marca `ausente = true` los
que dejaron de aparecer (que puede ser progreso —se escribió la receta— o un cambio de nixpkgs, y
conviene verlo en el `git diff`). El campo `sugerencia` propone con evidencia mecánica (cuántas
recetas lo piden, si el nombre tiene forma de env/wrapper de nix) pero `veredicto` arranca en
`pendiente`: la heurística **abarata** el juicio, no lo cierra.

Lazo verificado de punta a punta: marcar `python3-3.14.6-env` como `nix-ismo` → `--aplicar` lo
escribe al descarte → `seed-graph.normalizar()` lo devuelve clasificado y deja de contarlo como
hueco. (Probado y revertido; los 170 siguen en `pendiente` — clasificar es decisión del humano.)

**Estado de la cadena**: cerrada y corriendo. `targets.toml` → `build-state.json` → `drenaje.json` →
worker, regenerada por el latido cada 30 min. El único paso que requiere una persona es el triaje.

## 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`).
