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 604Rust 56Shell 36Markdown 281227 Definitions

⎇ 0093c1f19cbb2a56bc17bc093c1e3f027eb4437a docs/plan-catalogo-objetivo.md Markdown · 224 lines · 13.1 KB History raw

catálogo objetivo P3: sembrador de aristas desde nixpkgs, calibrado contra la verdad 0093c1f19 · sergio ·

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óndequé esforma
scripts/product-userland-from-repo.sh:26-28el userland real del producto2 strings de shell
scripts/mirada-usb.sh:38la imagen de escritorio mirada1 string de shell
tandas/*.txt55 ficheros de campañas pasadaslistas planas, sin aristas
recipes/incoming-kde/206 recetas del escritoriouna 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:

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():

  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:

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.

modorecall (de la verdad)precisión (aristas sembradas que son reales)
directas39% *59% (269 de 455)
transitivas84%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:

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)

pasoquériesgoesfuerzo
P1targets.toml + lift-and-shift de los 3 strings; gate = lista expandida idénticabajo~1h
P2wanted + perfiles + by_profile en build-state.py; regenerar y commitear el diff del JSONbajo~1-2h
P3seed-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 generalizarmedio~medio día
P4enchufar el drenaje a la cola de la granjamediotras 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:

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