1# Plan — catálogo objetivo: del caos al contador que baja
2
3**Fecha:** 2026-07-22 · **Estado:** propuesto, listo para ejecutar · **Toca:** `scripts/build-state.py`,
4`docs/state/`, `scripts/product-userland-from-repo.sh`, `scripts/mirada-usb.sh`
5
6## 1. El diagnóstico, medido (no intuido)
7
8La pregunta que lo dispara: *«¿se puede barrer primero TODOS los paquetes que va a tener la distro y
9construir el árbol de dependencias de una, en vez de descubrir a los golpes?»*
10
11Media respuesta ya está construida. `docs/state/build-state.json` **es** ese árbol: 768 nodos,
12aristas = deps de build, `topo_ok: true`, **0 deps huérfanas**, 750 selladas / 16 deuda / 2 nunca. Y
13cada nodo trae `blocked_by` (quién me traba) y `unblocks` (a cuántos destrabo) ⇒ el orden de
14masticado ya está calculado (`scripts/build-state.py:147-157`).
15
16Lo que **no** existe es el grafo de lo que la distro *debe* tener. Ese conocimiento vive disperso:
17
18| dónde | qué es | forma |
19|---|---|---|
20| `scripts/product-userland-from-repo.sh:26-28` | el userland real del producto | 2 strings de shell |
21| `scripts/mirada-usb.sh:38` | la imagen de escritorio mirada | 1 string de shell |
22| `tandas/*.txt` | 55 ficheros de campañas pasadas | listas planas, sin aristas |
23| `recipes/incoming-kde/` | 206 recetas del escritorio | una cola, no un objetivo |
24
25De ahí la sensación de caos: **el mapa cierra perfecto, pero sólo cubre el territorio ya
26conquistado.** «¿Cuánto falta?» no tiene respuesta porque no hay artefacto que enumere el destino.
27Y cada frente nuevo re-descubre su propia frontera desde cero.
28
29## 2. Qué se puede saber de antemano y qué no
30
31Honestidad sobre el límite, porque define el alcance del plan:
32
33- **Se puede saber el esqueleto.** nixpkgs/Alpine ya tienen el grafo de deps de casi todo. Se
34 extrae sin construir nada (`scripts/nix-import.sh:24-40` ya hace exactamente ese `nix eval`
35 sacando `buildInputs` + `nativeBuildInputs`).
36- **No se puede saber la verdad.** Las deps de un paquete no son propiedad del paquete: son
37 propiedad de *tu configuración de build*. mesa con qué drivers gallium, qué plataformas, qué
38 opciones meson — cambiás una flag y cambia el grafo. Lo importado es el grafo de **otra** distro
39 con **otras** decisiones. Aproximación buena, verdad no.
40- **El tramo final sólo se sabe ejecutando el build** — pero cuesta **1** construcción, no N.
41 «Compilar mesa cinco veces» era el método viejo (probar → fallar → adivinar qué faltó). harkaq ya
42 convirtió eso de *búsqueda* en *medición*: construís una vez, el veredicto por fase dice qué paths
43 tocó de verdad, los declarás. Es el bucle ya cerrado con zlib (`Impuro` → `Hermetico` en 3 fases)
44 y el que la campaña desatendida corre en la granja (49 recetas con deps declaradas por el kernel).
45
46**Conclusión:** el caos no es inevitable, pero no se elimina por adelantado — se convierte. El
47barrido previo compra el **esqueleto y el orden**; harkaq compra **una medición en vez de cinco
48adivinanzas**. Lo irreducible que queda son las deps que dependen de *tus* opciones de compilación,
49y ésas son decisiones, no descubrimientos: van en la receta, no en un bucle de prueba y error.
50
51## 3. El invariante nuevo
52
53> Hoy `orphan_deps == 0` porque el grafo sólo contiene lo que existe. El plan lo abre a propósito:
54> **la frontera deja de ser invisible y pasa a ser un número.**
55
56Ojo con el gate de CI (`build-state.py --check`, `scripts/build-state.py:201`): hoy sale 1 si hay
57huérfanas o `unhashable`. El diseño lo preserva — un nodo *deseado* está **declarado**, así que una
58arista que apunta a él **resuelve** y `orphan_deps` sigue significando lo mismo (arista colgando que
59nadie declaró ni quiere). La frontera se cuenta con un estado nuevo, `wanted`, que **no** es un
60fallo. Y `wanted` ≠ `unhashable`: el primero es «no hay receta todavía», el segundo «hay receta y
61está rota».
62
63## 4. Las piezas
64
65### P1 — `docs/state/targets.toml`: el manifiesto de objetivo
66
67Un solo fichero declarativo. Un `perfil` = una imagen enviable.
68
69```toml
70schema = "hammer-targets/1"
71
72[perfil.base]
73descripcion = "userland foundational: la distro arranca y se usa"
74paquetes = ["bash", "git", "sudo", "doas", "util-linux", "..."]
75
76[perfil.cli]
77hereda = ["base"]
78paquetes = ["bat", "fd", "ripgrep", "..."]
79
80[perfil.escritorio-kde]
81hereda = ["base"]
82paquetes = ["plasma-desktop", "..."]
83```
84
85**La inversión clave:** `paquetes` lista sólo las **raíces** — lo que un usuario pide por nombre. La
86clausura sale del grafo, no de la lista. Hoy es al revés: `tandas/base-system-3-libs.txt` enumera a
87mano los miembros de la clausura («libs»). Eso es trabajo que el grafo puede hacer solo.
88
89Migración: los strings de `product-userland-from-repo.sh:26-28` pasan a `perfil.base` +
90`perfil.cli` **verbatim**, y el de `mirada-usb.sh:38` a `perfil.escritorio-mirada`. Es un
91lift-and-shift, no un rediseño. **Gate:** el `PKGS_LIST` resultante debe ser idéntico al de hoy —
92verificable con un diff de la lista expandida. Ninguna imagen que hoy funciona puede cambiar.
93
94### P2 — estado `wanted` + membresía de perfil en `build-state.py`
95
96Cambio quirúrgico (~40 líneas) sobre `load_recipes()` / `main()`:
97
981. Cargar `targets.toml`, expandir `hereda`, obtener el set de raíces por perfil.
992. Toda raíz —y toda dep de un nodo— que no tenga receta se crea como nodo
100 `{state: "wanted", deps: [], perfiles: [...]}`. Deja de ser huérfana y pasa a ser frontera.
1013. **Campo `perfiles` en TODOS los nodos**, propagado por alcanzabilidad desde las raíces. Esto
102 solo ya paga el trabajo: responde *«si rompo zlib, qué imágenes se caen»* y —más incómodo—
103 *«cuáles de las 768 recetas no las necesita ninguna imagen»* (sospecha: buena parte de los 362
104 CLIs Go son catálogo, no distro; conviene saberlo explícitamente en vez de suponerlo).
1054. `totals` gana `wanted`; bloque nuevo `by_profile`: por perfil, tamaño de clausura / sellado /
106 deuda / wanted. **Ése es el «cuánto falta», por imagen.**
107
108**Límite honesto que hay que escribir en el header del script:** la clausura por perfil necesita las
109deps de los nodos `wanted`, que no se conocen hasta P3. En la primera corrida `by_profile` es una
110**cota inferior** que crece a medida que se siembra. Mejor un número que converge que ninguno.
111
112### P3 — `scripts/seed-graph.py`: sembrar aristas desde upstream
113
114Para cada nodo `wanted` sin aristas, sacar la lista de deps de la metadata upstream **sin construir**:
115
116- **nix** — reusar tal cual el `nix eval --apply` de `nix-import.sh:24-40`, que ya devuelve
117 `build_inputs` + `native_build_inputs`. No emitir receta: sólo las aristas.
118- **alpine** — `makedepends` del APKBUILD, vía `alpine-import.sh`.
119
120Salida a un fichero **aparte**, `docs/state/seed-edges.json`, con procedencia por arista
121(`{dep, fuente: "nix"|"alpine", fecha}`). Nunca a `targets.toml`, nunca a `recipes/`.
122
123**Disciplina, en negrita porque es donde esto se puede pudrir: la semilla es una HIPÓTESIS, no la
124verdad.** Los nombres son de upstream (`pkg-config` no existe en hammer, que usa zig-cc; `stdenv` y
125los hooks de autoreconf no son nada acá). Hace falta una tabla de mapeo de nombres y una lista de
126descarte de nix-ismos. Va a haber ruido. Su único trabajo es hacer la frontera **contable y
127ordenable**, no correcta. Nota de ADR: leer la *metadata* de nixpkgs no viola el ADR 0004 — lo que
128prohíbe es **construir** con nix, y acá no se construye nada.
129
130#### P3 — RESULTADO MEDIDO (2026-07-22)
131
132`scripts/seed-graph.py`, con `--calibrar` que mide la semilla contra la verdad que ya tenemos: las
133recetas escritas a mano. Tres rondas de calibración, cada corrección salida de un dato y no de una
134intuición. Muestra final: 60 recetas, 686 deps declaradas.
135
136| modo | recall (de la verdad) | precisión (aristas sembradas que son reales) |
137|---|---|---|
138| **directas** | 39% * | **59%** (269 de 455) |
139| transitivas | **84%** | 6% (390 de 6760) |
140
141\* contra la verdad *aplanada*. La comparación es injusta y el modo transitivo lo prueba: la
142información **sí está** (84%), sólo hace falta aplanarla.
143
144**Decisión: se siembran aristas DIRECTAS; la transitividad la hace el grafo.** Las dos distros ponen
145la frontera en lugares distintos — hammer APLANA la clausura en `[deps].build` (cada dep es una capa
146`--overlay-src` del sandbox: si no está declarada, no está en el árbol), nixpkgs declara sólo las
147directas y propaga. Reproducir el aplanado con el cierre transitivo de nixpkgs arrastra su universo
148de bootstrap: **4629 nodos fantasma** (`glibc-locales`, `autoconf`, `texinfo`, `help2man`). Como
149`build-state.py` ya calcula clausura desde las aristas, aplanar en el sembrador es trabajo duplicado
150que además envenena el grafo.
151
152Lo que la calibración enseñó, y que ninguna intuición habría dado:
153
154- **Cuatro clases de discrepancia, no una.** No todo desacuerdo es ruido: `make`/`binutils`/
155 `linux-headers` son **convención de hammer** (nix las da por implícitas en el stdenv); `zlib` vs
156 `zlib-shared` es una **decisión nuestra** de enlazado que nix no puede conocer; `cargo`/`rustc` los
157 **provee el lab**, no una receta (las recetas Rust declaran `deps = []`); y recién lo que queda es
158 ruido de verdad. Contarlas juntas hacía parecer irreducible lo que era clasificable.
159- **Los nombres desalinean de formas aburridas y arreglables**: mayúsculas (`libx11` vs `libX11`),
160 implementación (`gettext`→`gettext-tiny`, `ninja`→`samurai`), versión pegada al pname
161 (`glibc-iconv-2.42`), hooks del stdenv (`cargo-build-hook.sh`). Filtrarlos subió la precisión de
162 45% a 59% y bajó los "sin attr en nixpkgs" de 22/40 a 6/60.
163- **El escritorio KDE no vive en el top-level de nixpkgs** sino bajo `kdePackages.`. Sin ese
164 fallback, justo el perfil objetivo era insembrable.
165
166**Y un hallazgo de secuencia: hoy hay 0 nodos `wanted`**, porque `targets.toml` se pobló por
167lift-and-shift de lo que ya existe. Un sembrador de nodos inexistentes no tiene a quién sembrar. De
168ahí el modo que sí da valor ya: `--frontera <perfil>` siembra las recetas que YA existen en la
169clausura de un perfil y reporta lo que nixpkgs pide y hammer no tiene receta para construir —
170descubre la frontera desde el corpus actual en vez de esperar a que alguien la declare. Sale a
171`docs/state/seed-frontera.json`, versionado, para que el `git diff` muestre qué huecos aparecen.
172Primer barrido: **137 candidatos en `escritorio-kde`** (`kdoctools` ×6, `milou`, `polkit-qt-1`,
173`libkscreen`, `qqc2-breeze-style`, y `mesa-libgbm`/`libglvnd`/`spirv-tools` — que son exactamente el
174muro de GBM/EGL ya documentado), **51 en `cli`**, 46 en `escritorio-mirada`.
175
176Cada candidato es una **hipótesis a clasificar por un humano**: (a) dep opcional que hammer no
177habilitó a propósito, (b) nix-ismo que falta filtrar, o (c) hueco real. El sembrador no decide cuál;
178los ordena por cuántas recetas los piden. **Nada se promueve a receta automáticamente.**
179
180### P4 — el bucle de drenaje, y dónde harkaq cierra el lazo
181
182Regla de fuente única de verdad:
183
184> **`seed-edges.json` sólo se consulta para nodos SIN receta. En cuanto hay receta, manda la receta.**
185
186Con eso la semilla es **auto-borrable**: cada paquete construido reemplaza su arista hipotética por
187la medida (la receta, ya corregida por el veredicto de harkaq — que es literalmente lo que hace la
188campaña desatendida hoy). Al final del frente, `seed-edges.json` queda vacío. Si no se vacía, eso
189mismo es la lista de lo que falta.
190
191Orden de ataque por perfil: topológico, priorizado por `unblocks` — ya se calcula en
192`build-state.py:151-157`, sólo hay que incluir los nodos `wanted`.
193
194### P5 — jubilar las tandas (degradarlas, no borrarlas)
195
196`tandas/` pasa a ser **archivo de campañas pasadas** (55 ficheros con historia, no se tiran). El
197trabajo nuevo sale de una vista derivada del grafo: los próximos N nodos `wanted` **construibles ya**
198(sin `blocked_by`) en orden topológico. El lazo de la granja (`vps-nunca-idle`, la cosecha cron) se
199alimenta de ahí en vez de listas escritas a mano.
200
201**Eso es lo que de verdad mata la sensación de caos: la cola del worker deja de ser autoría y pasa a
202ser derivada.**
203
204## 5. Secuencia (commits chicos, valor en el primero)
205
206| paso | qué | riesgo | esfuerzo |
207|---|---|---|---|
208| **P1** | `targets.toml` + lift-and-shift de los 3 strings; gate = lista expandida idéntica | bajo | ~1h |
209| **P2** | `wanted` + `perfiles` + `by_profile` en `build-state.py`; regenerar y commitear el diff del JSON | bajo | ~1-2h |
210| **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 |
211| **P4** | enchufar el drenaje a la cola de la granja | medio | tras P3 |
212
213P1+P2 es el corte mínimo viable y ya entrega el primer número de «cuánto falta por imagen». P3 no se
214generaliza hasta conocer su ruido.
215
216**Gates:**
217- P2 mantiene `--check` en verde y `topo_ok: true`.
218- P3 **no escribe** en `recipes/` ni en `targets.toml`.
219- Ninguna imagen que hoy bootea cambia de contenido por P1.
220
221## 6. Coordinación
222
223Toca ficheros compartidos (`scripts/build-state.py`, `docs/state/`). Hay otro agente trabajando en
224el repo ⇒ acordar antes de P1/P2, y **nunca `git add -A`** (memoria `etapa-g-frente-go`).
plan-catalogo-objetivo.md
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.
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():
- Cargar
targets.toml, expandir hereda, obtener el set de raíces por perfil.
- 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.
- 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).
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.
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).