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…

⎇ 534014c3ec69daea789a1d0523e1d00a69c6ed65 scripts/yupana.py Python · 859 siq'ikuna · 43.4 KB Ñawpa willay Pi imata llamkarqan ch'uya

paso 8 bis: pigz sobre zlib-rs, sellado — el swap en un consumidor real no costó una línea de su fuente 8836ce706 · Sergio ·

1#!/usr/bin/env python3
2# yupana.py — la YUPANA del build: el ábaco que RECKONA sobre el estado de la distro.
3#
4# DOS OBJETOS ANDINOS, DOS ROLES. En los Andes el khipu GUARDABA (cuerdas anudadas que registran
5# cantidades y relaciones) y la yupana CALCULABA sobre ese registro (el ábaco inca). Acá es igual y
6# la distinción es la arquitectura, no adorno:
7#     · `docs/state/build-state.json` es el KHIPU — el registro firme y versionado: los nudos
8#       (recetas) y las cuerdas (deps), la única fuente de verdad de QUÉ hay y QUÉ falta.
9#     · este módulo es la YUPANA — el motor que reckona sobre el khipu: a QUIÉN rompo si toco algo
10#       (radio), qué imágenes dependen de un nudo (perfiles), en qué orden masticar (ondas).
11# Es la BASE metodológica y la PUERTA ÚNICA; el resto de los scripts de estado son sus órganos.
12# Antes este saber vivía disperso (en la cabeza, en `grep`, en docs que envejecen) y cada medición
13# a mano mentía distinto.
14#
15# POR QUÉ EXISTE ESTE FICHERO Y NO SÓLO build-state.py. El 2026-07-22 un cambio a `libdrm`
16# (default_library=both, para gtk4) invalidó 118 recetas KDE. El radio se midió contra
17# `recipes/*.toml` y NO contra `recipes/incoming-kde/` ⇒ dio 8 en vez de 118. `build-state.py` sin
18# `--kde` cometía EXACTAMENTE el mismo error: cargaba sólo el corpus, y su campo `perfiles` llegaba
19# a AFIRMAR que tocar `libdrm` sólo afectaba a `escritorio-mirada` — una mentira con confianza. La
20# causa es estructural: las dependencias INVERSAS son un hecho del disco entero, no de la vista que
21# uno cargó. Este módulo las calcula SIEMPRE sobre TODAS las colas, y es de donde build-state saca
22# `perfiles`/`dependientes`. La regla, en una línea:
23#
24#     QUÉ NODOS PUNTÚO es una decisión de vista. QUIÉN ME CONSUME es un hecho — nunca depende de la vista.
25#
26# RESOLUCIÓN SIBLING-FIRST. Una dep `libdrm` desde una receta de `incoming-kde` resuelve a
27# `incoming-kde/libdrm` si existe, y si no cae al `corpus/libdrm` (igual que el sandbox al montar las
28# capas). Por eso los nodos se identifican por PAR `(cola, nombre)`, no por nombre: `corpus/fontconfig`
29# y `incoming-kde/fontconfig` son nudos distintos y romper uno no rompe a los consumidores del otro.
30#
31# yupana es la PUERTA ÚNICA de la metodología. Sus verbos son los órganos del ciclo, en el orden en
32# que se usan (el mismo que la cadena del plan docs/plan-catalogo-objetivo.md):
33#
34#   radio <receta>    ¿a QUIÉN rompo si toco esto? (nativo; cruza TODAS las colas) ← ANTES de cambiar nada
35#   perfiles <receta> ¿qué imágenes dependen de esto? (nativo)
36#   duplicados        invariante (c): nudos que son el mismo paquete bajo dos nombres (por sha) (nativo)
37#   keystones         nudos en deuda que gatean el máximo trabajo bloqueado (cierre inverso, AND) (nativo)
38#   preflight <r>     modelo EXTRA-GRAFO: ¿choca con un límite del entorno que el grafo no ve? (nativo)
39#   estado            regenera el grafo firme          → build-state.py
40#   objetivo          qué debe tener cada imagen        → targets.py --lista
41#   frontera <perfil> qué pide upstream y no tenemos    → seed-graph.py --frontera
42#   triaje            clasificar la frontera            → triaje.py
43#   drenar [--perfil] el orden de masticado, en ondas   → drenar.py
44#
45# La ayuda operativa vive en el DOCSTRING de abajo y no en estos comentarios, por una razón medida:
46# `main()` hace `print(__doc__)` para `--help` y para un verbo desconocido, y mientras todo el
47# encabezado fue `#` eso imprimía literalmente `None`. La puerta única de la metodología no sabía
48# explicarse, y el error de verbo desconocido tampoco decía cuáles son los verbos. El diseño y el
49# porqué se quedan arriba como comentario; lo que un humano necesita para USARLO va en el docstring.
50"""yupana — el ábaco del build: reckona sobre el khipu (docs/state/build-state.json).
51
52Verbos, en el orden del ciclo:
53
54  radio <receta> [--json]   ¿a QUIÉN rompo si toco esto? cruza TODAS las colas ← ANTES de cambiar nada
55  perfiles <receta>         ¿qué imágenes dependen de esto?
56  duplicados [--json]       el mismo paquete bajo dos nombres (por sha)
57  keystones [--json]        nudos en deuda que gatean el máximo trabajo bloqueado
58  preflight <receta>        ¿choca con un límite del entorno que el grafo no ve?
59  estado [--kde|--gnome|--cosmic|--wlr]   regenera el grafo firme
60  objetivo                  qué debe tener cada imagen
61  frontera <perfil>         qué pide upstream y no tenemos
62  triaje                    clasificar la frontera
63  sembrar <perfil>          sembrar las recetas que la frontera reveló
64  drenar [--todos|--perfil <p>]           el orden de masticado, en ondas
65
66Uso:  scripts/yupana.py radio <receta> [--json]
67      scripts/yupana.py estado [--kde] | drenar [--todos] | triaje | frontera <perfil> | objetivo
68Como módulo:  from yupana import cargar_colas, membresia, dependientes, resolver
69"""
70import glob, json, os, re, subprocess, sys, tomllib
71from pathlib import Path
72
73ROOT = Path(__file__).resolve().parent.parent
74sys.path.insert(0, str(ROOT / "scripts"))
75import targets as targets_mod
76
77
78def cargar_colas(solo_build=False):
79    """{cola: {nombre: [deps]}} sobre TODAS las colas del disco. `corpus` = recipes/;
80    `incoming-<x>` = recipes/incoming-<x>/. Es el grafo completo, sin recortar por vista.
81
82    `solo_build=True` devuelve **únicamente las aristas de build**. La distinción no es cosmética y
83    la pide `radio`: `deps.runtime` **NO entra en el ArtifactHash**, así que un cambio en una receta
84    NO re-hashea a quien la consume sólo en runtime. Con la unión, `radio gcc-libs` contestaba
85    «9 sellados caen a deuda» —firefox con PGO dos veces, horas de granja— y la respuesta correcta
86    era CERO: los cinco la declaran en `runtime`. Medido el 2026-09-18."""
87    lee = _deps_build if solo_build else _deps
88    colas = {}
89    for f in sorted(glob.glob(str(ROOT / "recipes/*.toml"))):
90        n = os.path.basename(f)[:-5]
91        colas.setdefault("corpus", {})[n] = lee(f)
92    for f in sorted(glob.glob(str(ROOT / "recipes/incoming-*/*.toml"))):
93        q = os.path.basename(os.path.dirname(f))
94        colas.setdefault(q, {})[os.path.basename(f)[:-5]] = lee(f)
95    return colas
96
97
98def _deps(f):
99    try:
100        d = tomllib.load(open(f, "rb")).get("deps", {})
101        # ⚠ BUILD **+ RUNTIME** (2026-09-06). Leía sólo `build`, y por eso una dep de EJECUCIÓN no
102        # existía para NINGUNA herramienta que use yupana —el vigía de sonames, la membresía de
103        # perfiles, el grafo de estado—. Medido: `firefox` declaraba `runtime = ["gcc-libs"]` y el
104        # cierre de las cuatro imágenes seguía sin `libstdc++.so.6`, así que el navegador no
105        # arrancaba y el vigía lo seguía reportando como hueco después de haberlo arreglado.
106        # `deps.runtime` está en el esquema de takana desde siempre; lo que faltaba era que ALGUIEN
107        # lo leyera. Un campo que nadie lee es un campo que miente.
108        # La unión es la definición de cierre: para CORRER hacen falta las dos.
109        return sorted(set(d.get("build", []) or []) | set(d.get("runtime", []) or []))
110    except Exception:
111        return []
112
113
114def _deps_build(f):
115    """Sólo las aristas de BUILD: las únicas que propagan un re-hash (`runtime` está fuera de
116    `hash_inputs`, igual que `license`). Para el cierre de una imagen hace falta la unión —eso es
117    `_deps`—; para «¿a quién obligo a reconstruir?» hace falta esto."""
118    try:
119        return sorted(set(tomllib.load(open(f, "rb")).get("deps", {}).get("build", []) or []))
120    except Exception:
121        return []
122
123
124# --- INVARIANTE (c): CANÓNICO — sin duplicados accidentales entre orígenes ------------------------
125# Sufijos que en takana marcan una VARIANTE deliberada del mismo paquete (misma fuente, otra decisión
126# de build: estático/shared, kernel por perfil, mesa por backend). Compartir sha con estos NO es un
127# duplicado a fundir — es la diversificación coherente [[etapa-g-gui-chain-boundary]]. `-hello`/`-hola`
128# son demos que vendorean la fuente de otro a propósito.
129SUFIJOS_VARIANTE = ("-shared", "-static", "-libs", "-tiny", "-dev", "-headers", "-minimal",
130                    "-generic", "-metal-dual", "-metal", "-swrast", "-llvmpipe", "-hello", "-hola",
131                    # `-zlibrs` (2026-09-22): el MISMO paquete construido contra `zlib-rs` en vez de
132                    # `zlib`. Es la prueba de consumidor del paso 8; sin este sufijo el invariante (c)
133                    # lo reportaría como «colisión-fuente ACCIONABLE», que es un falso positivo — y un
134                    # guardián con falsos positivos se empieza a ignorar.
135                    "-zlibrs")
136
137
138def _base(n):
139    """Reduce un nombre a su base de familia quitando sufijos de variante, repetido: `zlib-shared`→
140    `zlib`, `mesa-llvmpipe`→`mesa`, `linux-generic`→`linux`. NO toca el `lib` inicial a propósito
141    (`libpng`→`png` colapsaría paquetes sin relación). Para decidir si dos nudos que comparten fuente
142    son la MISMA familia (variante ok) o paquetes SIN relación (colisión real)."""
143    prev = None
144    while prev != n:
145        prev = n
146        for s in SUFIJOS_VARIANTE:
147            if n.endswith(s) and len(n) > len(s):
148                n = n[: -len(s)]
149    return n
150
151
152# --- KEYSTONES: los nudos que gatean el trabajo -------------------------------------------------
153# ADVERTENCIA MATEMÁTICA (medida, no supuesta). El árbol de dominadores INGENUO NO sirve acá: asume
154# alcanzabilidad OR (basta un camino), pero construir es AND (hacen falta TODAS las deps). `kcoreaddons`
155# depende de `dbus`/`libdrm`/`mesa` además de `qtbase` ⇒ en el grafo de desbloqueo hay un camino que
156# evita qtbase, y el dominador-OR lo pierde. La métrica correcta del "cuánto gateo" bajo AND es el
157# CIERRE INVERSO TRANSITIVO. Pero el crudo lo gana el toolchain (make=313, inútil): el keystone
158# ACCIONABLE es el cierre inverso restringido a la DEUDA — cuánto trabajo BLOQUEADO libero. El
159# dominador SÍ tiene un uso legítimo, otro: la RETENCIÓN (lo exclusivamente mío), abajo.
160
161# --- MODELO EXTRA-GRAFO: patrones de hueco de ENTORNO -------------------------------------------
162# El grafo modela deps (R1: Receta×Receta). El entorno es OTRA capa: R2 (Receta×Capacidad, qué exige
163# un build) y R3 (Host×Capacidad, qué ofrece la máquina). Construible = clausura(R1) ∧ R2⊆R3. Estos
164# huecos viven FUERA del grafo (infra del sandbox, toolchain, layout de fs) y una matemática de grafos
165# sola no los ve — pero SÍ se aprenden, como patrones con predictor, calibrados contra fallos REALES.
166# Cada patrón: (nombre, deps) → veredicto|None. `preflight` los corre ANTES de construir.
167#
168# HOY: 1 patrón (overlay-lowerdir), la cicatriz de kio (2026-07-22). Futuros: capacidad-de-host
169# (rootfs laptop≠worker, techo MSRV — acumular observaciones (receta,host,lo-que-faltó)); skew de
170# toolchain (zig-skew — fingerprint del toolchain por artefacto). harkaq ya MIDE R2 (evidencia
171# negativa: qué path del entorno tocó un build); esto lo vuelve consultable + pre-vuela.
172#
173# CALIBRADO, no adivinado: el estimador daba corto hasta usar el prefijo que bwrap VE (`/oldroot/...`,
174# la ruta runtime del worker, no la del hub). Con eso: kio estimado 5346B vs real 5315B (31B de error).
175# El primer predictor "obvio" (prefijo del hub) mentía con confianza — la lección de siempre.
176PAGINA_OVERLAY = 4096  # tope del string de opciones de un mount overlayfs = PAGE_SIZE
177# Prefijo que bwrap ve en runtime (bajo /oldroot, ruta del worker). Ajustable por host.
178_PREF_SANDBOX = os.environ.get("PREF_STORE_SANDBOX", "/oldroot/opt/takana/store/")
179_OVERHEAD_OVERLAY = len("upperdir=/tmp-overlay-upper-0,workdir=/tmp-overlay-work-0,") \
180    + len("/oldroot/opt/takana/.dev-fs/alpine") + len(",userxattr")
181
182
183def _lowerdir_estimado(deps):
184    """Bytes del string `lowerdir=` que bwrap arma apilando `deps` como capas overlay directas. Es la
185    cantidad de GRAFO (nº y nombre de deps) que cruza el umbral de ENTORNO (una página)."""
186    return _OVERHEAD_OVERLAY + sum(len(_PREF_SANDBOX) + 64 + 1 + len(n) + 1 for n in deps)
187
188
189def patron_overlay(nombre, deps):
190    """¿El apilado directo de las deps desborda el mount de overlay? Si sí, la receta SÓLO monta
191    gracias al merge de deps (cp -al a store/.dmerge, 1 sola capa). El pre-flight hace VISIBLE esa
192    dependencia de una mitigación —y su precondición— que antes era invisible hasta que fallaba."""
193    est = _lowerdir_estimado(deps)
194    if est <= PAGINA_OVERLAY:
195        return None  # entra directo, sin riesgo
196    return dict(
197        patron="overlay-lowerdir",
198        detalle=f"{len(deps)} deps ⇒ lowerdir ~{est}B > {PAGINA_OVERLAY}B (una página)",
199        riesgo="depende-del-merge",
200        nota=("apilado directo DESBORDA ⇒ sólo monta vía el merge de deps (cp -al a store/.dmerge, "
201              "1 capa). Precondición: .dmerge mismo-fs que el store — OK por construcción (vive DENTRO "
202              "del store, fix 6a0dc40). Si el store cambiara de layout y el merge fallara, esta receta "
203              "NO montaría, silenciosamente."))
204
205
206PATRONES_ENTORNO = [patron_overlay]
207
208
209def preflight(objetivo):
210    """Corre los patrones de entorno sobre una receta o (con --perfil) sobre todas las de una imagen,
211    ANTES de construir. Responde la pregunta que kio contestó tarde y cara: ¿este build va a chocar
212    con un límite del entorno que el grafo no ve?"""
213    colas = cargar_colas()
214    idx = {q: set(m) for q, m in colas.items()}
215    if objetivo.startswith("perfil:"):
216        pn = objetivo.split(":", 1)[1]
217        p = targets_mod.load().get(pn)
218        if not p:
219            print(f"perfil desconocido: {pn}", file=sys.stderr); return 1
220        cola = p.get("cola", "corpus")
221        nombres = [(cola if n in idx.get(cola, ()) else "corpus", n)
222                   for n in colas.get(cola, {})] if cola != "corpus" else \
223                  [("corpus", n) for n in colas["corpus"]]
224        # sólo los del ámbito: todas las recetas de la cola del perfil
225        objetivos = [(q, n) for q in ({cola} | {"corpus"}) for n in colas.get(q, {})]
226    else:
227        ubic = [(q, objetivo) for q in colas if objetivo in colas[q]]
228        if not ubic:
229            print(f"no hay receta `{objetivo}` en ninguna cola", file=sys.stderr); return 1
230        objetivos = ubic
231
232    hits = []
233    for (q, n) in objetivos:
234        deps = colas[q][n]
235        for patron in PATRONES_ENTORNO:
236            v = patron(n, deps)
237            if v:
238                hits.append((f"{q}/{n}", v))
239    hits.sort(key=lambda h: -_lowerdir_estimado(colas[h[0].split("/")[0]][h[0].split("/", 1)[1]]))
240
241    if not objetivo.startswith("perfil:"):
242        q, n = objetivos[0]
243        print(f"== preflight de {q}/{n} ({len(colas[q][n])} deps)")
244        if not hits:
245            print("   ✓ sin riesgo de entorno conocido (entra en el mount overlay directo).")
246        for _, v in hits:
247            print(f"   ⚠ {v['patron']}: {v['detalle']}")
248            print(f"      {v['nota']}")
249        return 0
250    print(f"== preflight del perfil (patrones: {', '.join(p.__name__ for p in PATRONES_ENTORNO)})")
251    print(f"   {len(hits)} recetas dependen del merge para montar (apilado directo desbordaría):")
252    for nombre, v in hits[:25]:
253        print(f"   ⚠ {nombre:28} {v['detalle']}")
254    print(f"\n   son construibles HOY (el merge compacta), pero su montaje CUELGA de que store/.dmerge")
255    print(f"   sea mismo-fs que el store. Si un host rompe eso, fallan silenciosas — pre-vuela antes.")
256    return 0
257
258
259def _tiempos():
260    """nombre → mediana de segundos de build, desde `store/.times/*.json` (sidecar no determinista,
261    lo escribe `scripts/farm/build-timed.sh` en el worker y llega con el rsync de cosecha). Vacío
262    hasta que corran builds instrumentados; los consumidores degradan a peso=1."""
263    from statistics import median
264    muestras = {}
265    for f in glob.glob(str(ROOT / "store/.times/*.json")):
266        try:
267            d = json.loads(open(f).read())
268        except Exception:
269            continue
270        if d.get("name") and isinstance(d.get("seconds"), (int, float)):
271            muestras.setdefault(d["name"], []).append(d["seconds"])
272    return {n: median(v) for n, v in muestras.items()}
273
274
275def _camino_critico(nodos, deps_debt, peso):
276    """Longest weighted path en el subgrafo de DEUDA (DP sobre topológico). El peso de un nudo = sus
277    segundos de build (1 si no hay dato). La suma sobre la cadena más larga = ETA con paralelismo
278    infinito: hay que construir esa cadena en SERIE. Devuelve (segundos, secuencia)."""
279    memo, camino = {}, {}
280    def cp(n):
281        if n in memo:
282            return memo[n]
283        mejor, via = 0, None
284        for d in deps_debt.get(n, ()):
285            c = cp(d)
286            if c > mejor:
287                mejor, via = c, d
288        memo[n] = peso(n) + mejor
289        camino[n] = via
290        return memo[n]
291    for n in nodos:
292        cp(n)
293    if not memo:
294        return 0, []
295    fin = max(memo, key=memo.get)
296    seq = []
297    x = fin
298    while x is not None:
299        seq.append(x); x = camino.get(x)
300    return memo[fin], list(reversed(seq))
301
302
303def _cierre_inverso(objetivo, rev):
304    """Nodos que dependen de `objetivo`, transitivamente (semántica AND: si X me tiene en su clausura,
305    X no se construye sin mí)."""
306    vis, pila = set(), [objetivo]
307    while pila:
308        x = pila.pop()
309        for c in rev.get(x, ()):
310            if c not in vis:
311                vis.add(c); pila.append(c)
312    return vis
313
314
315def _dominador_retencion(colas, perfiles):
316    """RETENCIÓN estilo GC: para cada nudo, cuántos otros son EXCLUSIVAMENTE suyos — sólo alcanzables
317    (como dependencia, desde las raíces de las imágenes) a través de él. Este SÍ es el árbol de
318    dominadores, en su uso correcto: 'si suelto este nudo, cuánto más puedo soltar'. Grafo
319    consumidor→dep, raíz virtual sobre todas las raíces de perfil; retención = tamaño del subárbol de
320    dominadores. Iterativo (Cooper-Harvey-Kennedy), suficiente para ~1000 nudos."""
321    idx = {q: set(m) for q, m in colas.items()}
322    R = ("__raiz__", "")
323    succ = {R: set()}
324    for pn, p in perfiles.items():
325        for raiz in p["raices"]:
326            nodo = resolver(raiz, p.get("cola", "corpus"), idx)
327            if nodo:
328                succ[R].add(nodo)
329    # aristas consumidor→dep
330    for q, miembros in colas.items():
331        for n in miembros:
332            nodo = (q, n)
333            succ.setdefault(nodo, set())
334            for d in miembros[n]:
335                r = resolver(d, q, idx)
336                if r:
337                    succ[nodo].add(r)
338    # sólo lo alcanzable desde R
339    orden, vis = [], set()
340    def dfs(u):
341        vis.add(u)
342        for v in succ.get(u, ()):
343            if v not in vis:
344                dfs(v)
345        orden.append(u)
346    sys.setrecursionlimit(10000)
347    dfs(R)
348    rpo = list(reversed(orden))
349    pos = {n: i for i, n in enumerate(rpo)}
350    preds = {}
351    for u in succ:
352        for v in succ[u]:
353            preds.setdefault(v, set()).add(u)
354    idom = {R: R}
355    def inter(a, b):
356        while a != b:
357            while pos[a] > pos[b]:
358                a = idom[a]
359            while pos[b] > pos[a]:
360                b = idom[b]
361        return a
362    cambio = True
363    while cambio:
364        cambio = False
365        for u in rpo:
366            if u == R:
367                continue
368            ps = [p for p in preds.get(u, ()) if p in idom]
369            if not ps:
370                continue
371            nuevo = ps[0]
372            for p in ps[1:]:
373                nuevo = inter(nuevo, p)
374            if idom.get(u) != nuevo:
375                idom[u] = nuevo; cambio = True
376    # tamaño de subárbol (retención) = cuántos nodos domina cada uno
377    hijos = {}
378    for n, d in idom.items():
379        if n != d:
380            hijos.setdefault(d, []).append(n)
381    ret = {}
382    def subtam(u):
383        t = 0
384        for h in hijos.get(u, ()):
385            t += 1 + subtam(h)
386        ret[u] = t
387        return t
388    subtam(R)
389    return ret
390
391
392def _khipus():
393    """TODOS los grafos de estado del disco, descubiertos y no escritos a mano.
394
395    La lista estaba codificada como `(kde, gnome, corpus)` en dos sitios y **omitía cosmic y wlr**,
396    que son dos de los cuatro escritorios. Es la misma forma del fallo que motivó este fichero (ver
397    la cabecera): medir contra un subconjunto de las colas y que el número salga chico sin que nada
398    avise. Un grafo nuevo entra solo; ninguno se puede olvidar.
399    """
400    return sorted(glob.glob(str(ROOT / "docs/state/build-state*.json")))
401
402
403def _estados_por_cola():
404    """`{(cola, nombre): estado}` sobre todos los khipus.
405
406    Va por (COLA, nombre) y no por nombre: el mismo nombre vive en varias colas con recetas y hashes
407    DISTINTOS —`gdk-pixbuf` está en corpus, en incoming-kde y en incoming-wlr— así que un índice por
408    nombre devuelve el estado de otra receta con toda confianza. Eso es peor que no saber.
409    """
410    estados = {}
411    for f in _khipus():
412        try:
413            nodos = json.loads(Path(f).read_text())["nodes"]
414        except Exception:
415            continue
416        for n, r in nodos.items():
417            q = r.get("queue")
418            if q:
419                estados.setdefault((q, n), r.get("state", "?"))
420    return estados
421
422
423def keystones(como_json=False):
424    """Los nudos que GATEAN más trabajo. Métrica accionable: cierre inverso transitivo restringido a
425    la DEUDA (cuánto bloqueado libero al sellar esto). Segunda columna: RETENCIÓN por dominadores (lo
426    exclusivamente mío). El grafo elegido es el que más deuda tiene (KDE si existe)."""
427    from collections import Counter
428    colas = cargar_colas()
429    rev = dependientes(colas)
430    perfiles = targets_mod.load()
431    # estados desde TODOS los khipus, indexados por (cola, nombre)
432    estados = _estados_por_cola()
433    DEUDA = ("debt", "never", "unhashable", "wanted")
434    ret = _dominador_retencion(colas, perfiles)
435
436    filas = []
437    for nodo in list(rev):
438        cerr = _cierre_inverso(nodo, rev)
439        en_deuda = sum(1 for nd in cerr if estados.get(nd) in DEUDA)
440        q, n = nodo
441        yo_deuda = estados.get(nodo) in DEUDA
442        filas.append(dict(nudo=f"{q}/{n}", gateo_total=len(cerr), gateo_en_deuda=en_deuda,
443                          exclusivo=ret.get(nodo, 0), en_deuda=yo_deuda))
444    # KEYSTONE ACCIONABLE: un nodo SELLADO ya está disponible ⇒ construirlo no libera nada (make gatea
445    # 65 en deuda pero está hecho). El que importa está EN DEUDA y gatea el máximo trabajo bloqueado.
446    # Orden: primero los en-deuda, por gateo-en-deuda desc. Los sellados quedan como contexto (su
447    # retención `exclusivo` sí es info válida, pero no son trabajo).
448    filas.sort(key=lambda r: (not r["en_deuda"], -r["gateo_en_deuda"], -r["gateo_total"]))
449    accionables = [r for r in filas if r["en_deuda"]]
450
451    # CAMINO CRÍTICO PESADO: subgrafo de deuda, aristas dep entre nodos en deuda, peso = segundos de
452    # build. La cadena más larga = ETA con paralelismo infinito. Sin datos de tiempo, peso=1 y el
453    # camino crítico = nº de pasos (lo que ya daban las ondas). Con datos, es la ETA real en segundos.
454    tiempos = _tiempos()
455    idx = {q: set(m) for q, m in colas.items()}
456    deuda_set = {(q, n) for q in colas for n in colas[q] if estados.get((q, n)) in DEUDA}
457    deps_debt = {}
458    for (q, n) in deuda_set:
459        dd = set()
460        for d in colas.get(q, {}).get(n, []):
461            r = resolver(d, q, idx)
462            if r in deuda_set:
463                dd.add(r)
464        deps_debt[(q, n)] = dd
465    def peso(nodo):
466        return tiempos.get(nodo[1], 1 if not tiempos else _mediana_o_uno(tiempos))
467    seg, seq = _camino_critico(deuda_set, deps_debt, peso)
468    cubiertos = sum(1 for nodo in deuda_set if nodo[1] in tiempos)
469    critico = dict(con_datos=bool(tiempos), segundos=seg if tiempos else None,
470                   pasos=len(seq), cobertura=f"{cubiertos}/{len(deuda_set)}",
471                   secuencia=[f"{q}/{n}" for q, n in seq])
472
473    doc = dict(schema="hammer-keystones/3", nudos=filas[:80], en_deuda=len(accionables),
474               camino_critico=critico)
475    (ROOT / "docs/state/keystones.json").write_text(json.dumps(doc, indent=1, ensure_ascii=False) + "\n")
476
477    if como_json:
478        print(json.dumps(doc, indent=1, ensure_ascii=False)); return 0
479    print("== KEYSTONES — nudos EN DEUDA que gatean el máximo trabajo bloqueado (semántica AND)")
480    print("   'gateo en deuda' = otro trabajo bloqueado que se libera al sellar ESTE. La métrica accionable:")
481    print("   sellá de arriba hacia abajo y la cascada cae lo antes posible.\n")
482    print(f"   {'nudo (en deuda)':30} {'gateo-deuda':>11} {'gateo-total':>11} {'exclusivo':>9}")
483    if not accionables:
484        print("   (ningún nodo en deuda — todas las imágenes en ámbito están completas)")
485    for r in accionables[:20]:
486        print(f"   {r['nudo']:30} {r['gateo_en_deuda']:11} {r['gateo_total']:11} {r['exclusivo']:9}")
487    print(f"\n   {len(accionables)} nudos en deuda en total")
488    print(f"\n== CAMINO CRÍTICO (la cadena más larga; hay que construirla en SERIE aunque haya ∞ workers)")
489    if critico["con_datos"]:
490        m = critico["segundos"] // 60
491        print(f"   ETA (paralelismo infinito): {m} min sobre {critico['pasos']} pasos  "
492              f"· cobertura de tiempos {critico['cobertura']}")
493    else:
494        print(f"   {critico['pasos']} pasos (SIN datos de tiempo aún ⇒ peso=1; instrumentá builds en")
495        print(f"   el worker con campana-deuda.sh y el rsync de cosecha trae store/.times).")
496    if seq:
497        muestra = " → ".join(n.split("/")[-1] for n in critico["secuencia"][:8])
498        print(f"   {muestra}" + (" → …" if len(seq) > 8 else ""))
499    print(f"\n   → docs/state/keystones.json")
500    print("   nota: dominador-OR ingenuo NO sirve (build es AND); 'exclusivo' SÍ es dominador (retención).")
501    return 0
502
503
504def _mediana_o_uno(tiempos):
505    from statistics import median
506    return median(tiempos.values()) if tiempos else 1
507
508
509def _fuente(f):
510    """(sha, url, version, build_fp) de una receta. Los tres primeros = identidad de lo que BAJA. El
511    cuarto = HUELLA DE BUILD (link, compiler, fases) — porque dos recetas con la MISMA fuente pero
512    build distinto NO son redundantes: son variantes deliberadas (corpus/dbus estático vs
513    incoming-kde/dbus dinámico para Qt6DBus). Ignorar esto hacía que el detector marcara `dbus` como
514    sombra redundante y casi borra una receta load-bearing (2026-07-22)."""
515    try:
516        d = tomllib.load(open(f, "rb"))
517    except Exception:
518        return (None, "", None, None, False)
519    s = d.get("source", {})
520    b = d.get("build", {})
521    ph = b.get("phases", {}) or {}
522    build_fp = (b.get("link"), b.get("compiler"),
523                tuple(sorted((k, str(v)) for k, v in ph.items())))
524    # ¿construye la FUENTE descargada, o es un CARRIER (stub/demo que genera todo inline y sólo presta
525    # el tarball)? Señal: invoca un sistema de build. itstool/-hello no ⇒ carrier; su colisión de sha
526    # es esperada, no un error de copiado. (2026-07-22: sin esto el detector marcaba 4 falsos bugs.)
527    blob = "\n".join(str(ph.get(k, "")) for k in ("configure", "compile", "install"))
528    # Strip comentarios shell (`# …` a fin de línea) ANTES de buscar: si no, la prosa de un stub
529    # ("# parsea la invocación de meson") matchea y marca al carrier como builder (falso positivo
530    # que el guardián cazó, 2026-07-22).
531    sin_comentarios = re.sub(r"#.*", "", blob)
532    construye = any(re.search(rx, sin_comentarios) for rx in
533                    (r"\bmake\b", r"\bmeson\b", r"\bcmake\b", r"\bninja\b", r"\bcargo\b",
534                     r"\./configure", r"\bautogen", r"\bautoreconf"))
535    return (s.get("sha256") or s.get("output_hash"),
536            s.get("tarball") or s.get("url") or s.get("git") or "",
537            d.get("version"), build_fp, construye)
538
539
540def _recetas_con_fuente():
541    """{(cola, nombre): {sha, url, version, build_fp, construye_fuente, deps}} sobre todo el disco."""
542    out = {}
543    for pat, es_incoming in (("recipes/*.toml", False), ("recipes/incoming-*/*.toml", True)):
544        for f in sorted(glob.glob(str(ROOT / pat))):
545            n = os.path.basename(f)[:-5]
546            q = os.path.basename(os.path.dirname(f)) if es_incoming else "corpus"
547            sha, url, ver, build_fp, construye = _fuente(f)
548            out[(q, n)] = dict(sha=sha, url=url, version=ver, build_fp=build_fp,
549                               construye_fuente=construye, deps=_deps(f))
550    return out
551
552
553def duplicados(como_json=False):
554    """INVARIANTE (c): detecta nudos que son el MISMO paquete bajo nombres/colas distintos, por
555    EVIDENCIA de fuente (sha/url), no por nombre. Clasifica cada grupo:
556      colisión-fuente — mismo sha, nombres de familias DISTINTAS ⇒ casi seguro un sha copiado por
557                        error (itstool tenía el tarball de gettext-tiny). ACCIONABLE.
558      variante        — mismo sha, misma familia (`zlib`/`zlib-shared`, `mesa-*`) ⇒ deliberado, OK.
559      sombra          — mismo NOMBRE en >1 cola. Redundante si versión+sha+deps coinciden; si no,
560                        es una variante justificada de la cola.
561    NO funde nada solo: reporta y deja el juicio al humano (como el triaje). El radio de cada nudo
562    ayuda a elegir el canónico."""
563    recs = _recetas_con_fuente()
564    from collections import defaultdict
565    rev = dependientes()
566
567    # 1) grupos por sha (identidad de fuente)
568    por_sha = defaultdict(list)
569    for k, r in recs.items():
570        if r["sha"]:
571            por_sha[r["sha"]].append(k)
572    colisiones, variantes, carriers = [], [], []
573    for sha, ks in por_sha.items():
574        nombres = {n for _, n in ks}
575        if len(nombres) < 2:
576            continue
577        grupo = dict(sha=sha[:16], nudos=sorted(f"{q}/{n}" for q, n in ks),
578                     radio={f"{q}/{n}": len(rev.get((q, n), ())) for q, n in ks})
579        builders = [k for k in ks if recs[k]["construye_fuente"]]
580        familias = {_base(n) for _, n in ks}
581        # Clasificación por evidencia, de más benigno a más sospechoso:
582        if len(builders) <= 1:
583            # ≤1 receta construye la fuente ⇒ el resto son CARRIERS que le prestan el tarball. Benigno.
584            grupo["carriers"] = sorted(f"{q}/{n}" for (q, n) in ks
585                                       if not recs[(q, n)]["construye_fuente"])
586            carriers.append(grupo)
587        elif len(familias) == 1:
588            variantes.append(grupo)          # misma familia (zlib/zlib-shared): variante deliberada
589        elif len({recs[k]["build_fp"] for k in builders}) > 1:
590            variantes.append(grupo)          # familias distintas pero BUILD distinto: variante cross-nombre
591        else:                                # ≥2 builders, MISMO build, familias distintas ⇒ sospechoso
592            colisiones.append(grupo)
593
594    # 2) sombras: mismo nombre en >1 cola
595    por_nombre = defaultdict(list)
596    for (q, n) in recs:
597        por_nombre[n].append(q)
598    sombras = []
599    for n, qs in por_nombre.items():
600        if len(qs) < 2:
601            continue
602        # Redundante SÓLO si coinciden fuente Y BUILD: mismo tarball no basta, dos builds distintos
603        # del mismo source (estático vs dinámico) son variantes deliberadas, no redundancia.
604        firmas = {(recs[(q, n)]["version"], recs[(q, n)]["sha"], recs[(q, n)]["build_fp"],
605                   tuple(sorted(recs[(q, n)]["deps"]))) for q in qs}
606        sombras.append(dict(nombre=n, colas=sorted(qs),
607                            redundante=len(firmas) == 1,  # idénticas en fuente+build ⇒ una sobra
608                            radio={f"{q}/{n}": len(rev.get((q, n), ())) for q in qs}))
609
610    doc = dict(schema="hammer-duplicados/2",
611               colisiones_fuente=sorted(colisiones, key=lambda g: -max(g["radio"].values() or [0])),
612               variantes=sorted(variantes, key=lambda g: g["nudos"]),
613               carriers=sorted(carriers, key=lambda g: g["nudos"]),
614               sombras=sorted(sombras, key=lambda s: (not s["redundante"], s["nombre"])))
615    (ROOT / "docs/state/duplicados.json").write_text(
616        json.dumps(doc, indent=1, ensure_ascii=False) + "\n")
617
618    if como_json:
619        print(json.dumps(doc, indent=1, ensure_ascii=False))
620        return 0
621    red = [s for s in sombras if s["redundante"]]
622    print(f"== INVARIANTE (c) CANÓNICO — duplicados por evidencia de fuente\n")
623    print(f"⚠ COLISIONES DE FUENTE REALES: {len(colisiones)}  (≥2 recetas construyen el MISMO source,")
624    print(f"   mismo build, familias distintas — casi seguro un sha copiado por error)")
625    for g in doc["colisiones_fuente"]:
626        print(f"   sha {g['sha']}…  {g['nudos']}")
627        print(f"        radio: " + "  ".join(f"{k.split('/')[-1]}={v}" for k, v in g["radio"].items()))
628    if not colisiones:
629        print("   (ninguna — el invariante (c) se cumple)")
630    print(f"\n⚠ SOMBRAS REDUNDANTES: {len(red)}  (mismo nombre+fuente+BUILD en 2 colas — una sobra)")
631    for s in red:
632        print(f"   {s['nombre']:24} {s['colas']}")
633    if not red:
634        print("   (ninguna)")
635    print(f"\n✓ BENIGNOS (no son problema):")
636    print(f"   {len(carriers)} grupos CARRIER (stub/demo que presta el tarball: itstool, *-hello)")
637    print(f"   {len(variantes)} variantes deliberadas (misma fuente, build/familia distintos: zlib-shared, prison-scanner)")
638    print(f"   {len(sombras)-len(red)} sombras justificadas (mismo nombre, build distinto: dbus estático/dinámico)")
639    print(f"\n   → docs/state/duplicados.json")
640    return 0
641
642
643def resolver(dep, desde_cola, indice):
644    """Sibling-first: (cola, nombre) del nudo que provee `dep` visto desde `desde_cola`, o None si
645    ninguna cola lo tiene (dep externa / nodo `wanted`). `indice` = {cola: set(nombres)}."""
646    if dep in indice.get(desde_cola, ()):
647        return (desde_cola, dep)
648    if dep in indice.get("corpus", ()):
649        return ("corpus", dep)
650    return None
651
652
653def dependientes(colas=None):
654    """{(cola,nombre): set((cola,nombre) que lo consumen)} — el mapa de dependencias INVERSAS sobre
655    el grafo entero. Este es el cálculo que, hecho sobre una sola cola, produjo el bug de libdrm."""
656    colas = colas or cargar_colas()
657    indice = {q: set(m) for q, m in colas.items()}
658    rev = {}
659    for q, miembros in colas.items():
660        for nombre, deps in miembros.items():
661            for d in deps:
662                objetivo = resolver(d, q, indice)
663                if objetivo:
664                    rev.setdefault(objetivo, set()).add((q, nombre))
665    return rev
666
667
668def membresia(colas=None, perfiles=None, podar=None):
669    """{(cola,nombre): set(perfiles que lo alcanzan)} sobre el grafo entero, respetando sibling-first.
670    Un nudo pertenece a una imagen si la clausura de sus raíces —resolviendo cada cuerda desde la
671    cola del consumidor— llega hasta él. Esto es lo que arregla la mentira de `perfiles`.
672
673    `podar` (conjunto de NOMBRES de receta) recorta la exploración: un nudo podado no entra a la
674    membresía **y no se sigue explorando desde él**, salvo que sea RAÍZ DECLARADA del perfil, donde la
675    declaración gana. Es la vista de IMAGEN (`membresia_imagen`); el default —sin poda— sigue siendo
676    la de BUILD, que es la que necesita el grafo de estado («qué hay que construir»)."""
677    colas = colas or cargar_colas()
678    perfiles = perfiles if perfiles is not None else targets_mod.load()
679    podar = set(podar or ())
680    indice = {q: set(m) for q, m in colas.items()}
681    memb = {}
682    for pn, p in perfiles.items():
683        cola_p = p.get("cola", "corpus")
684        # La válvula. Se compara contra las raíces EXPANDIDAS (`hereda` incluido): una herramienta
685        # declarada en `cli` tiene que seguir viajando en `servidor`, que hereda de `cli`.
686        raices_p = set(p["raices"])
687        vistos, pila = set(), []
688        for raiz in p["raices"]:
689            nodo = resolver(raiz, cola_p, indice)
690            pila.append(nodo if nodo else ("wanted", raiz))  # raíz sin receta = nodo wanted
691        while pila:
692            nodo = pila.pop()
693            if nodo in vistos:
694                continue
695            vistos.add(nodo)
696            memb.setdefault(nodo, set()).add(pn)
697            q, nombre = nodo
698            if q == "wanted":
699                continue
700            for d in colas.get(q, {}).get(nombre, []):
701                sig = resolver(d, q, indice)
702                sig = sig if sig else ("wanted", d)
703                if sig[1] in podar and sig[1] not in raices_p:
704                    continue
705                pila.append(sig)
706    return memb
707
708
709def membresia_imagen(colas=None, perfiles=None):
710    """La membresía de la IMAGEN: `membresia()` menos las herramientas de `[solo-build]`.
711
712    ══ POR QUÉ EXISTEN DOS VISTAS Y NO UNA ════════════════════════════════════════════════════════
713    El cierre de un perfil se calcula sobre `build ∪ runtime` porque para CONSTRUIR hacen falta las
714    dos (y porque leer sólo `build` dejó a firefox sin `libstdc++.so.6`, ver `_deps`). Pero la imagen
715    no se construye a sí misma: medido el 2026-09-22, las 8 imágenes llevaban adentro `llvm21`
716    (1.527 MiB), `go`, `python3`, `binutils`, `cmake`, `perl`… — el 44 % de los bytes de `servidor`.
717    Es la misma forma que el caso busybox, que llegaba a los 7 perfiles por 24 `[deps] build`.
718
719    **La poda es por NODO, no por tipo de ARISTA**, y eso no es un detalle: podar las aristas de
720    build es lo que rompió firefox. Así, una librería que llega por la arista de build de una app que
721    sí viaja (`qtbase`, `glib`, `harfbuzz`) SE QUEDA — conservador a propósito.
722
723    Quién la usa: `hydrate-profile.py` (arma el rootfs), `vigia-sonames.py --imagen` y
724    `vigia-interpretes.py`. Quién NO: `build-state.py` para su `clausura` (es «qué construir») ni
725    `repo-perfil.sh` (un repositorio no es una imagen: instalar `cmake` desde el repo es correcto)."""
726    return membresia(colas, perfiles, podar=targets_mod.solo_build())
727
728
729def _estado(nodo):
730    """Estado del nudo desde el grafo firme, si está regenerado. Best-effort: el radio funciona sin él."""
731    return _estados_por_cola().get(nodo, "?")
732
733
734def radio(nombre, como_json=False):
735    """¿A QUIÉN rompo si re-hasheo `recipes/**/<nombre>.toml`? La pregunta que, mal medida, costó 118
736    recetas. Cruza TODAS las colas por construcción: es imposible que 'olvide' una cola."""
737    colas = cargar_colas()
738    indice = {q: set(m) for q, m in colas.items()}
739    # ¿En qué cola vive la receta que se va a tocar? Puede estar en varias (variante sombreada).
740    ubic = [(q, nombre) for q in colas if nombre in colas[q]]
741    if not ubic:
742        print(f"no hay receta `{nombre}` en ninguna cola", file=sys.stderr)
743        return 1
744    # ⚠ DOS GRAFOS, Y LA DIFERENCIA ES EL NÚMERO QUE LA GENTE USA PARA DECIDIR (2026-09-18).
745    # El de BUILD es el que propaga un re-hash; el de la unión (build+runtime) es el del cierre de
746    # una imagen. `radio` mezclaba los dos y contestaba «9 sellados caen a deuda» para `gcc-libs`,
747    # cuando la respuesta correcta es CERO: sus cinco consumidores la declaran en `runtime`, que no
748    # entra en `hash_inputs`. Ese número de más casi cuesta reconstruir firefox con PGO dos veces.
749    rev_b = dependientes(cargar_colas(solo_build=True))     # cascada de rebuild
750    rev_u = dependientes(colas)                             # cierre / imágenes
751    rev = rev_b
752    memb = membresia(colas, targets_mod.load())
753
754    resultado = {}
755    for nodo in ubic:
756        directos = sorted(rev.get(nodo, set()))
757        # Cierre inverso: quién depende, transitivamente (el radio real de un re-hash en cascada).
758        vistos, pila = set(), list(rev.get(nodo, set()))
759        while pila:
760            n = pila.pop()
761            if n in vistos:
762                continue
763            vistos.add(n)
764            pila.extend(rev.get(n, set()))
765        # Las imágenes salen del grafo de la UNIÓN: para que una imagen ARRANQUE hacen falta también
766        # las deps de runtime, aunque no fuercen rebuild.
767        vistos_u, pila_u = set(), list(rev_u.get(nodo, set()))
768        while pila_u:
769            n = pila_u.pop()
770            if n in vistos_u:
771                continue
772            vistos_u.add(n)
773            pila_u.extend(rev_u.get(n, set()))
774        imgs = set()
775        for n in vistos_u | {nodo}:
776            imgs |= memb.get(n, set())
777        solo_runtime = sorted(vistos_u - vistos)
778        # Cuántos de los transitivos YA están sellados: ésos son los que HOY están al día y el
779        # re-hash MANDA A DEUDA (los que ya están en deuda igual habría que reconstruirlos). El
780        # radio verdadero de rebuild forzado por el cambio.
781        sellados = sum(1 for n in vistos if _estado(n) == "sealed")
782        resultado[f"{nodo[0]}/{nodo[1]}"] = dict(
783            directos=len(directos), transitivos=len(vistos),
784            imagenes=sorted(imgs), sellados_que_caen=sellados,
785            solo_runtime=len(solo_runtime),
786            solo_runtime_nombres=[f"{q}/{n}" for q, n in solo_runtime][:12],
787            dependientes_por_cola=_por_cola(vistos))
788
789    if como_json:
790        print(json.dumps(resultado, indent=1, ensure_ascii=False))
791        return 0
792    for nodo, r in resultado.items():
793        print(f"== radio de tocar  {nodo}")
794        print(f"   dependientes directos     {r['directos']}   (por BUILD: los que un re-hash arrastra)")
795        print(f"   dependientes transitivos  {r['transitivos']}   (el radio real de un re-hash en cascada)")
796        if r["solo_runtime"]:
797            print(f"   sólo por RUNTIME          {r['solo_runtime']}   NO se reconstruyen "
798                  f"(`runtime` está fuera de hash_inputs): " + ", ".join(r["solo_runtime_nombres"]))
799        print(f"   por cola                  " +
800              "  ".join(f"{q}={n}" for q, n in sorted(r["dependientes_por_cola"].items())))
801        print(f"   IMÁGENES afectadas        {', '.join(r['imagenes']) or '(ninguna declarada)'}")
802        print(f"   sellados que CAEN a deuda {r['sellados_que_caen']}   (hoy al día; el re-hash los fuerza a rebuild)")
803        if len(resultado) > 1:
804            print()
805    print("\n   regla: esto cruza TODAS las colas. NO midas radio con `grep recipes/*.toml`.")
806    return 0
807
808
809def _por_cola(nodos):
810    from collections import Counter
811    return dict(Counter(q for q, _ in nodos))
812
813
814# Delegación a los órganos: yupana es la puerta, cada script sigue siendo el músculo. Se delega por
815# subproceso (no import) para no acoplar sus ciclos de vida ni sus flags — cada uno evoluciona solo.
816ORGANOS = {
817    "estado":   ["build-state.py"],
818    "drenar":   ["drenar.py"],
819    "triaje":   ["triaje.py"],
820    "frontera": ["seed-graph.py", "--frontera"],
821    "objetivo": ["targets.py", "--lista"],
822    "sembrar":  ["seed-graph.py", "--perfil"],
823}
824
825
826def delegar(cmd, extra):
827    return subprocess.call([sys.executable, str(ROOT / "scripts" / ORGANOS[cmd][0])]
828                           + ORGANOS[cmd][1:] + extra)
829
830
831def main():
832    args = sys.argv[1:]
833    if not args or args[0] in ("-h", "--help"):
834        print(__doc__)
835        return 0
836    cmd, extra = args[0], args[1:]
837    if cmd == "radio" and extra:
838        return radio(extra[0], como_json="--json" in extra)
839    if cmd == "duplicados":
840        return duplicados(como_json="--json" in extra)
841    if cmd == "keystones":
842        return keystones(como_json="--json" in extra)
843    if cmd == "preflight" and extra:
844        obj = f"perfil:{extra[extra.index('--perfil') + 1]}" if "--perfil" in extra else extra[0]
845        return preflight(obj)
846    if cmd == "perfiles" and extra:
847        memb = membresia()
848        hits = {f"{q}/{n}": sorted(ps) for (q, n), ps in memb.items() if n == extra[0]}
849        print(json.dumps(hits, indent=1, ensure_ascii=False) if hits
850              else f"`{extra[0]}` no lo alcanza ninguna imagen declarada")
851        return 0
852    if cmd in ORGANOS:
853        return delegar(cmd, extra)
854    print(f"verbo desconocido: {cmd}\n{__doc__}", file=sys.stderr)
855    return 1
856
857
858if __name__ == "__main__":
859    sys.exit(main())