Correr el rebuild EN la VM destapó dos faltantes reales del builder (justo lo que
la verificación de Stage 2 debe cazar):
1. El toolchain (variante a, desde Alpine) traía compiladores (cargo/make/zig)
pero NO las herramientas de orquestación del lab — bwrap (anida el sandbox),
git (git archive del mirror) y curl (tarballs). En el host las aporta el
sistema; en la VM el toolchain es el único userland capaz, así que deben vivir
ahí. bootstrap-devfs.sh las añade (paquete `bubblewrap`, no `bwrap`).
2. Esos binarios son Alpine *dinámicos* y no corren desde el userland Stage 1
*estático* (musl --disable-shared ⇒ no hay loader en /lib). rebuild-stage1
ahora instala un shim del loader musl (cp a /lib) + LD_LIBRARY_PATH/PATH al
toolchain antes de invocar hammer; el sandbox de build sigue anidando en
/toolchain. El hammer estático corre por ruta absoluta.
El builder boot end-to-end ya estaba probado; esto lo hace además *capaz de
reconstruirse*. 28 tests verdes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Smoke test real (qemu -cpu Broadwell, initramfs 410 MB gz): el builder rootfs
bootea hasta la shell. arje-zero como PID 1 carga+valida la seed card, instancia
hammerd (pid 60) + console-getty (pid 61), hammerd levanta watcher fanotify +
agent.sock, y la getty deja `~ #` en consola. Mismo boot que Stage 1 pero con el
toolchain adentro — listo para `rebuild-stage1`.
Actualiza la receta de §8c: script boot-builder-vm.sh, MEM=6144, kernel real,
nota de RAM (tmpfs + rebuild en RAM va justo sin KVM).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
scripts/boot-builder-vm.sh empaqueta el último eslabón operacional del SDD 11 §7:
bootea work/builder.cpio.gz (el builder rootfs como initramfs cpio+gzip) con
arje-zero como PID 1; en la consola se corre `rebuild-stage1` para reconstruir
stage1' con el toolchain de adentro y comparar con la referencia.
- Defaults: KERNEL=/boot/vmlinuz-linux, MEM=6144, CPU=Broadwell (AVX2 bajo TCG).
- KVM=1 usa -enable-kvm -cpu host si hay /dev/kvm (mucho más rápido).
- Documenta la tensión de RAM: el rootfs vive en tmpfs (~1.2 GB) + el rebuild Rust
necesita varios GB más, todo en RAM; con host ~7 GB y sin KVM va justo/lento.
El initramfs (410 MB gz, 26776 entradas) se verificó: sbin/init→arje-zero, el
hammer estático, rebuild-stage1, rebuild.env y el toolchain/cargo presentes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Al construir el hammer estático musl y armar el builder con --work-cache work,
dos problemas reales (cazados antes de llenar el disco):
1. Recursión. --work-cache work --out work/builder-rootfs deja el destino DENTRO
de la caché: copiar la caché se tragaba el propio builder a medio armar
(work/builder-rootfs/work/builder-rootfs/… sin fondo). Fix: link_or_copy_tree
toma un `skip` (path canónico a no descender), que corta el ciclo.
2. Bloat de sources. --work-cache copiaba TODO work/, incluidos los árboles ya
materializados de work/sources (4 GB, regenerables). Fix: sólo se embeben
work/repos (mirrors git) y work/tarballs (descargas verificadas); de ahí
`fetch` rematerializa los sources en la VM. El builder pasa de ~5 GB a ~750 MB.
+2 tests (recursión termina y no se auto-copia; sources/ no viaja). Runbook §8c
aclara la semántica de --work-cache. El hammer estático musl
(target/x86_64-unknown-linux-musl/release/hammer) sale static-pie linked, listo
para bootear como PID-algo en la VM.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
El único sub-ítem que le quedaba a Stage 2: el rebuild *dentro* del rootfs. El
Stage 1 que booteamos es runtime (musl+busybox+hammerd+arje-zero), sin compilador
— no puede reconstruirse. El builder rootfs es Stage 1 + el toolchain adentro.
Implementa `hammer_bootstrap::builder_rootfs` (variante a pragmática, SDD 11 §7.2):
sobre el Stage 1 rootfs hidratado monta /toolchain (rootfs Alpine = sandbox de
build), /store con la semilla replicada (el hammer de adentro resuelve zig por
hash), /usr/bin/hammer + /etc/hammer/recipes, y el driver /usr/bin/rebuild-stage1
que apunta HAMMER_ROOTFS=/toolchain, corre `bootstrap stage1` y compara stage1'
contra la referencia con `stage2 --verify` (lee SEED_HASH/SEED_KIND/REF_CONTENT
de /etc/hammer/rebuild.env).
- BuilderSpec/BuilderReport + builder_hash lógico (insumos: stage1+semilla+
recetas+binario+tag toolchain+driver) — reproducible y auditable sin hashear el
árbol Alpine; anota línea stage 2 en el manifiesto.
- link_or_copy_tree (hardlink-or-copy, sin chmod: no toca permisos del .dev-fs).
- El builder no se sella (toolchain Alpine no es content-addressed); se ensambla
en out_dir para empaquetar como initramfs.
- CLI `hammer bootstrap builder --stage1 H --seed-hash H [--hammer-bin] [--toolchain]
[--ref-content] [--work-cache] [--out]`. +4 tests (assemble, hash determinista,
sin-ref, stage1 no sellado).
Validado contra el store real: Stage 1 73d7a9be… + semilla 3ce721ec… ⇒ builder
81dad3d9… (1.2 GB con el toolchain). Lo que queda es operacional: bootear en la
VM y correr rebuild-stage1 — runbook §8c documenta la receta (hammer estático
musl, initramfs, qemu -cpu Broadwell, --work-cache para rebuild offline).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
La verificación de Stage 2 (of_tree + verify_against) está probada y los 4
componentes reconstruyen bit-idéntico. El rebuild *dentro* del rootfs exige un
builder rootfs: el Stage 1 mínimo es runtime, sin compilador.
Diseña: qué necesita el builder (hammer estático + recetas + seed zig + make/
autotools + cargo/rust + linux-headers + bwrap para el sandbox anidado); dos
niveles de pureza fasables — (a) pragmático (toolchain desde Alpine, demuestra el
mecanismo + cierra la reproducibilidad end-to-end) y (b) auto-alojamiento puro
(toolchain construido por hammer desde fuente, incremental); y el enganche con
stage2 (ya ancla of_tree(stage1); el builder produce stage1').
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cierra el ◑ de arje-zero sin un rebuild de horas: su único riesgo era el
Cargo.lock generado sin --locked (tawasuyu lo gitignora). Verificado barato —
dos `cargo generate-lockfile` del commit pinned dan un lock byte-idéntico
(resolución determinista) — y el camino de build Rust ya está probado
bit-reproducible vía hammerd.
Los 4 componentes de Stage 1 (musl, busybox, hammerd, arje-zero) están
verificados reproducibles. Lo único que le queda a Stage 2 es el rebuild dentro
de un builder rootfs (auto-alojamiento pleno) — hito aparte.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tercer componente probado byte-idéntico entre dos builds independientes: el
camino Rust (crt-static + cargo --locked + SOURCE_DATE_EPOCH + paths fijos /src)
reproduce. 3/4 componentes verificados reproducibles (musl, busybox, hammerd).
arje-zero queda ◑: mismo camino, pero vendorea sin --locked (tawasuyu no committea
Cargo.lock) ⇒ el lock generado podría variar entre corridas; committear el lock
en tawasuyu lo cerraría (plan C.2 #5).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
La verificación de reproducibilidad de Stage 2 (dos builds + diff de bytes) probó
que musl reconstruye bit-idéntico pero busybox NO, y cazó dos no-determinismos:
1. Config interactiva: tras editar .config, silentoldconfig prompea por opciones
NEW leyendo stdin → set de applets variable (build no-determinista y flaky).
Fix en busybox.toml: `yes '' | make oldconfig` (defaults, determinista;
1.36.1 no tiene olddefconfig).
2. Headers UAPI del kernel: el config determinista habilita console-tools, que
exige linux/kd.h (zig no la bundlea). Fix: linux-headers en bootstrap-devfs.sh
(zig cc nativo la halla en /usr/include).
Con ambos, musl y busybox reconstruyen bit-idéntico. Runbook §8b documenta la
verificación y deja pendiente: reproducibilidad de los componentes Rust y el
rebuild dentro de un builder rootfs (auto-alojamiento pleno).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Esqueleto de Stage 2 (SDD 11 §3): la verificación de auto-alojamiento.
- VerifyReport + Reproducibility {Reproducible|Divergent|RebuildPending}
- artifact_content_hash(store, hash, name): of_tree del artefacto sellado
- stage2(stage1, store): ancla el content-hash del rootfs (los BYTES reales, no
el hash input-addressed del store) como referencia y lo anota en el manifiesto
(línea stage 2); verdict = RebuildPending
- verify_against(report, rebuilt): compara stage1 vs stage1' → Reproducible/Divergent
- CLI: `hammer bootstrap stage2 --rootfs HASH [--verify CONTENT_HASH]`
Validado sobre el rootfs real: content-hash b3:d0d5669f… (≠ store hash 73d7a9be…,
confirma que of_tree hashea bytes); --verify igual ⇒ ✓ REPRODUCIBLE, distinto ⇒
✗ DIVERGENTE. +2 tests. El rebuild nativo DENTRO del rootfs (produce stage1')
corre en la VM destino — el único sub-ítem que queda de Stage 2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stage 2 compara el content-hash (of_tree) de stage1 vs stage1' rebuildeado; para
que coincidan, el build debe ser determinista. Las rutas ya lo son (el source se
bindea en /src, constante entre rebuilds); el knob que faltaba es el timestamp:
SOURCE_DATE_EPOCH=1 (+ TZ=UTC) fija los que tar/ar/gzip y __DATE__/__TIME__ embeben.
No cambia ningún ArtifactHash (input-addressed) ⇒ caché intacta; sólo normaliza
los bytes de salida de builds futuros. Plan C.2 #5.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stage 2 verifica bit-reproducibilidad comparando hash(stage1) vs hash(stage1').
Pero el ArtifactHash del store es INPUT-addressed (Merkle de inputs de receta:
fuente+flags+deps), así que esa comparación sería trivialmente true. Stage 2
necesita un hash de la SALIDA real (los bytes).
of_tree(root) hashea el contenido: rutas relativas ordenadas + tipo + bit de
ejecución + contenido / target de symlink, BLAKE3 length-prefijado. Determinista
e independiente de la ruta raíz y del orden del filesystem; no sigue symlinks.
+2 tests (determinismo entre dos árboles idénticos; detección de cambios de
contenido/exec/symlink-target).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Desde la consola del Stage 1 booteado: kill $(pidof hammerd) → arje-zero lo
supervisa de verdad:
Ente disuelto label=hammerd status=Killed(SIGTERM)
Restart programado label=hammerd delay_ms=200
Ente encarnado label=hammerd pid=Some(Pid(64))
Detecta la muerte con su ExitStatus, hace backoff y re-encarna hammerd (pid
59→64). Es la supervisión real del init propio (ADR 0007) que la Fase 5 había
diferido. Runbook §8 y roadmap actualizados. Pendiente: B.2 (exponerlo a la
capa de IA por agent.sock) y atestación A1/A2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
La corrida real cierra el lazo: con el build nativo crt-static (cargo rustc),
arje-zero y hammerd salen statically-linked (ET_EXEC, sin DT_NEEDED), el rootfs
se sella, y el boot en QEMU (-cpu Broadwell) levanta el sistema:
arje-zero (PID 1) → carga+valida /ente/seed.card.json → bucle primordial →
encarna hammerd (Pid 59) + console-getty (Pid 60) → shell en consola;
hammerd levanta su watcher fanotify y el agent.sock.
Runbook §8: bitácora completa (todos los fixes: gcc/HOSTCC, GNU-ld, rust en el
lab, triple nativo, wrapper saneador, vendoring condicional, AVX→Broadwell,
dinámico→crt-static vía cargo rustc). Roadmap: Stage 1 ◑→✅.
Pendiente: demo del CRASHED real (kill hammerd → restart con backoff; la
supervisión Restart ya corre), bus único (B.2), atestación (A1/A2).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tercera iteración del fix de la corrida real (runbook §8). +crt-static (y
relocation-model=static) en RUSTFLAGS rompen los proc-macros en build nativo:
RUSTFLAGS alcanza a las deps, y un proc-macro es un dylib PIC que no puede ser
estático ("cannot produce proc-macro for clap_derive ... crate types").
Fix: pasar los flags de codegen del binario por `cargo rustc --release … -- <flags>`,
que los aplica SÓLO a la crate top-level, dejando proc-macros/deps intactos. Así el
binario final es crt-static + relocation-model=static (AUTOCONTENIDO y ET_EXEC sin
interpreter, como busybox: sin libc.so, sin loader, sin soname). El linker (zig cc)
sigue por RUSTFLAGS. musl queda --disable-shared (no se necesita loader).
Validado por unit tests; la corrida real rebuildea hammerd/arje-zero crt-static.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cierra el último bloqueo del boot que la corrida en QEMU destapó (runbook §8):
arje-zero/hammerd buildeaban DINÁMICOS contra musl (el rust de Alpine es dinámico
por defecto) → DT_NEEDED libc.musl-x86_64.so.1, y nuestro libc.so (musl shared con
zig cc) no exporta memcpy/memset/… → el loader falla y el init muere.
Fix golden-path: para link=static, RUSTFLAGS ahora fuerza
-C target-feature=+crt-static -C relocation-model=static
⇒ binarios AUTOCONTENIDOS (musl dentro), ET_EXEC sin interpreter, como busybox:
sin libc.so, sin loader, sin el soname de Alpine. musl vuelve a --disable-shared
(no se necesita el loader). Boot en QEMU: usar -cpu Broadwell (el qemu64 default no
tiene el AVX que zig emite).
Validado por la corrida real: los 4 componentes buildan y el rootfs se sella; el
kernel arranca el initramfs y ejecuta arje-zero como PID 1. Falta rebuildear
hammerd/arje-zero con crt-static para cerrar el boot (cache-bust + rebuild).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Desbloquea recetas Cargo cuya fuente no committea Cargo.lock (p.ej. el monorepo
tawasuyu, que lo gitignora) sin tocar la política de ese repo.
vendor_cargo_deps: si la fuente trae Cargo.lock → `cargo vendor --locked`
(reproducible, deps pineadas por el lock del commit). Si no → vendoreo sin
--locked (genera el lock al vuelo) + warn! explícito de "build no pineado en el
tiempo". El end-state reproducible sigue siendo committear el lock (plan C.2 #5).
+1 test del camino sin-lock. Aplica a arje-zero (tawasuyu); hammerd (hammer,
que sí committea el lock) sigue por el camino --locked.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resuelve el bloqueo Rust de la primera corrida: el rust de Alpine es
x86_64-alpine-linux-musl y --target x86_64-unknown-linux-musl no tiene std.
Cuando recipe.build.target == SANDBOX_NATIVE_TARGET (x86_64-linux-musl, el del
sandbox), se construye NATIVO (sin --target): el std nativo del rust del sandbox
sirve. El wrapper .hammer-zig-cc ahora:
- es un único ejecutable (resuelve el `zig cc` de dos palabras que cargo y cc-rs
mal-parsean),
- SANEA el triple: cc-rs (build scripts, p.ej. blake3) detecta el host triple de
Alpine y pasa --target=x86_64-alpine-linux-musl, que zig rechaza
(UnknownOperatingSystem); lo reescribe a x86_64-linux-musl.
Se usa como CC (cc-rs) y como linker (RUSTFLAGS). El cross real conserva --target.
Validado en VM real: hammerd compila nativo con zig cc y se sella. +1 test (cross
mantiene el triple), detect_cargo actualizado. arje-zero queda bloqueado aparte:
tawasuyu no commitea Cargo.lock (vendor --locked falla) — ver runbook §8.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Primera ejecución del runbook en el host. El userland C builda end-to-end; el
camino Rust queda bloqueado por una decisión de toolchain. Hallazgos verificados:
recipes/busybox.toml — tres incompatibilidades GNU-toolchain ↔ zig, resueltas:
- gcc hardcodeado (CC/HOSTCC = gcc con `=` ignora el env): pasar CC='zig cc'
HOSTCC='zig cc' en línea de comando de make
- flags GNU-ld que zig cc valida y rechaza (--warn-common, -Map, --verbose):
quitarlos de scripts/trylink con sed
Con esto musl 1.2.5 y busybox 1.36.1 compilan, linkean y se sellan.
scripts/bootstrap-devfs.sh — añade rust+cargo al rootfs (las recetas Cargo
corren cargo en el sandbox), con CAVEAT: el rust de Alpine es
x86_64-alpine-linux-musl pero BuildSys::Cargo pide x86_64-unknown-linux-musl
(no instalado, sin rustup) → bloquea hammerd/arje-zero. Decisión de toolchain
abierta (plan C.2).
docs/runbooks/stage1-vm-boot.md §8 — bitácora con la tabla de etapas, los fixes
y las opciones para resolver el triple Rust.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Procedimiento operativo end-to-end para de-riesgar lo construido: stage0 (ingerir
zig) → stage1 (build + ensamblar el rootfs) → empaquetar initramfs (cpio newc +
gzip) → bootear en QEMU con el kernel del host → criterios de éxito.
Fundamentado en los comandos reales del repo (scripts/bootstrap-devfs.sh, la URL
y sha256 de zig 0.16.0, hammer bootstrap stage0/stage1). La prueba clave: matar
hammerd y ver a arje-zero reiniciarlo con backoff — el CRASHED real.
Honesto sobre su estado: es el procedimiento, no un transcript verificado; el
cross-compile y el boot reales aún no se corrieron (ese es el punto). §7
anticipa los ajustes probables (init, /dev/console, getty/tty, -lgcc_s).
Enlazado desde SDD 12 §I2 y el índice de docs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reemplaza el init provisional de busybox por arje-zero como PID 1 — el paso
"init real" que entrega el CRASHED real (Fase 5 diferida, ADR 0007).
- STAGE1_COMPONENTS += arje-zero (la receta puente ya lo construía)
- assemble_rootfs: genera /ente/seed.card.json (template autocontenido), crea
/sbin/init -> /usr/bin/arje-zero, y los mount points que arje monta (incl.
/ente, /var/lib/hammer, /sys/fs/cgroup, /dev/pts, /dev/shm). Se retira el inittab.
- la seed declara hammerd como Payload::Native Restart (su on_death = el CRASHED
real) + console-getty supervisada; ULIDs fijos ⇒ RootfsHash reproducible (v2)
- la seed se VERIFICÓ contra el tipo real card_core::Card (from_json + validate
pasan), no sólo como JSON — evidencia, no aserción
- +2 tests (ensamblado con arje init + seed válida); 21 verdes en el crate
Falta para cerrar Stage 1: bus único (B.2) y atestación (A1/A2). Boot real en VM.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diseña el último paso ◑ del Stage 1: reemplazar el init provisional de busybox
por arje-zero como PID 1, lo que entrega el CRASHED real (Fase 5 diferida).
Fundamentado en el código real de arje-zero (citas a tawasuyu):
- contrato de boot: arje monta los pseudo-FS él mismo (arje-kernel); lee
/ente/seed.card.json (Card Virtual + genesis); supervisa con Restart/Backoff
- qué debe proveer el rootfs (mount points, seed, /sbin/init→arje-zero, consola)
- la seed card de Stage 1: hammerd como Payload::Native Restart ⇒ on_death = el
CRASHED real; console-getty supervisada; ENTE_BUS_SOCK estable en /run
- generación de la seed: template JSON autocontenido v1 (no acoplar build a
tawasuyu), arje-packager al llegar la atestación A1
- fases I1(✅)→I2(PID 1)→I3(bus único B.2)→I4(overlay+atestación)
- relación agent.sock (API IA, encima) vs arje-bus (control del init)
Contraparte en hammer del plan tawasuyu PLAN-ATESTACION-Y-HAMMER §B.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Puente concreto hacia "arje es el init de hammer" (ADR 0007): recipes/arje-zero.toml
construye el init PID 1 de arje con el lab de hammer.
- Cargo recipe: fuente = monorepo tawasuyu a commit fijado (el de la migración A0
arje-cas → BLAKE3, así el CAS es coherente con hammer), -p arje-zero, estático
con zig cc; deps vendoreadas en el fetch (Lote 6) ⇒ build --offline
- prueba que el lab cruza al repo de tawasuyu y produce el binario del init; NO es
PID 1 todavía (eso es el paso "init real": seed card + arje-bus + hammerd Card)
- nuevo test que escanea recipes/ y valida que toda receta parsea + hashea offline
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cierra el userland mínimo salvo el init definitivo. hammerd entra como tercer
componente de stage1, construido por el lab vía BuildSys::Cargo + vendoring:
- recipes/hammerd.toml: source git del repo hammer a commit fijado (ADR 0006),
flags=["-p","hammerd"] para construir sólo ese binario del workspace, estático
con zig cc; las deps se vendorean en el fetch (Lote 6) ⇒ build --offline
- STAGE1_COMPONENTS += hammerd; el inittab provisional lo arranca como servicio
respawn (/usr/bin/hammerd) tras montar los pseudo-FS
- el test de recetas del repo ahora valida también hammerd.toml (parse + hash)
Falta sólo sustituir el init provisional de busybox por arje (ADR 0007) para el
CRASHED real. El cross-compile se valida en la VM.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completa BuildSys::Cargo: las recetas Rust necesitan sus deps de crates.io
disponibles para el `cargo build --offline` hermético del sandbox. fetch::
vendor_cargo_deps corre `cargo vendor --locked` (red OK fuera del sandbox) y
escribe .cargo/config.toml en el árbol del source; build() lo invoca cuando
detecta una receta Cargo, tras los patches.
- --locked fija por Cargo.lock (dentro del commit que hashea la receta) ⇒ sin
no-determinismo nuevo: mismo commit ⇒ mismas deps vendoreadas
- crate sin deps ⇒ config vacío, inocuo
- test gated (skip si no hay cargo) del camino spawn → stdout → config.toml
Habilita hammerd (y a futuro arje) como recetas Cargo construibles offline.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cross-compila musl + busybox con la semilla de Stage 0 (no el zig del host),
ensambla un rootfs FHS por hardlink y lo sella. CLI: `hammer bootstrap stage1`.
- seed_toolchain_dir(store, hash, kind): primitivo sin SeedSpec, para que Stage 1
resuelva el toolchain desde (seed_hash, kind) sin re-pasar url/sha256
- RootfsHash = hash de CONTENIDO (componentes ordenados + init), no del árbol en
disco ⇒ reproducible aunque difieran inodes/timestamps
- assemble_rootfs: esqueleto FHS + hydrate de cada componente + /etc/inittab
provisional (busybox init); descarta el sidecar .hammer del rootfs
- idempotente (no reensambla si el rootfs ya está sellado); anota línea stage 1
- hammerd (receta Cargo) y arje quedan como slots pendientes (plan M2 / ADR 0007)
+3 tests (rootfs_hash determinista/sensible, assemble_rootfs, recetas del repo
parsean y hashean). Workspace verde; el cross-compile real se valida en la VM.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Desbloquea integrar el workspace tawasuyu (y cualquier crate Rust) en el lab:
- detección por Cargo.toml (gana sobre Makefile huérfano, cede ante CMake de
un políglota C+Rust).
- traducción de triple zig→rustc (x86_64-linux-musl → x86_64-unknown-linux-musl).
- compile: cargo build --release --locked --offline --target <triple>; link por
wrapper zig cc (CARGO_TARGET_<T>_LINKER) que provee el libgcc_s que el link
dinámico musl exige (cierra el 'cannot find -lgcc_s' que destapó M2). link=dynamic
añade -C target-feature=-crt-static para dlopen (apps gráficas: Vulkan/Wayland).
- install: copia los ejecutables de target/<triple>/release a /out/usr/bin
(busybox-safe).
- doc 02-build-lab §Cargo (vendoreo de deps para build offline) + receta de ejemplo
recipes/llimphi-counter.toml (caso gráfico dinámico).
- 5 tests nuevos (34/34 verdes en hammer-build).
Coordina con tawasuyu/03_ukupacha/arje/PLAN-ATESTACION-Y-HAMMER §C (milestone:
falta BuildSys::Cargo en el lab).
Las dos piezas C del userland mínimo (SDD 11 §3), estáticas con zig cc cross
a x86_64-linux-musl, sha256 fijado (descargado y verificado, no inventado):
- musl 1.2.5 a9a118bbe84d... (libc del target: headers + libc.a)
- busybox 1.36.1 b8cc24c9574d... (coreutils + sh + init provisional vía inittab)
busybox: defconfig + CONFIG_STATIC=y, TC desactivado (gcc-ismos incompatibles
con musl); install por CONFIG_PREFIX crea los symlinks de applets para el rootfs.
hammerd queda fuera por ahora: con el soporte Cargo del lab ya es construible,
pero su sourcing (repo pinned + vendor para --offline) es parte del plan M2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SeedSpec::toolchain_dir resuelve el dir del compilador dentro de la semilla
sellada, listo para que el sandbox lo bindee como /opt/zig; seed_build_config
deriva una BuildConfig que cross-compila con el seed pinned, no con el zig del
host. Stage 0 sigue siendo ingesta pura: el layout se resuelve al usarlo
(zig en la raíz o en el hijo versionado), con error claro si falta o es ambiguo.
Decisiones del Lote 4 registradas en ADR 0008 / SDD 11:
- coreutils+shell = busybox (balsa desechable; GPLv2 no contamina el final)
- init arje diferido (Stage 1 con init mínimo provisional; CRASHED real luego)
- layout de semilla: ingesta pura + resolución al usar
+5 tests (layouts: raíz / hijo versionado / no-sellada / ambiguo / build_config).
Workspace verde.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lote 2 del track posterior. El hash de un artefacto ahora incluye el hash de
cada dep de build (Merkle sobre el subgrafo), no solo su fuente+flags.
- catálogo = base_dir de la receta: cada dep se resuelve a <base_dir>/<dep>.toml
(firma de artifact_hash/build sin cambios; cero ripple en call sites)
- detección de ciclos con la cadena en el mensaje (a -> b -> a)
- errores claros: dep .toml ausente, o deps sin base_dir (receta de TOML crudo)
- 6 tests nuevos; ninguna receta actual declara deps ⇒ sin impacto en e2e
Prepara Stage 1: seed_hash entrará al hash de toda receta del userland mínimo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lote 1 del track posterior. Implementa el embrión del log de transparencia
(SDD 11 §4): crate hammer-bootstrap::manifest con BootstrapManifest/StageEntry.
- append-only e idempotente (de-dup por (stage, artifact_hash))
- escritura atómica (tmp + rename); ts informativo, fuera de todo hash
- stage0() anota su línea en ambos caminos (sello nuevo e idempotente);
un sha erróneo no genera línea
- 9 tests nuevos (4 de manifest + 5 de stage0/manifest), workspace verde
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cablea el Stage 0 del bootstrap a la CLI: subcomando `bootstrap stage0
--url --sha256 --version [--seed zig|musl-cross-make]` que abre el store,
arma el SeedSpec y llama a hammer_bootstrap::stage0, imprimiendo el b3 del
artefacto sellado.
- stage0 ahora limpia el staging `.bootstrap-tmp` pase lo que pase (extraído
stage0_into): un sha256 erróneo ya no deja el tarball a medio descargar bajo
el store. Test reforzado para afirmarlo.
- e2e de CLL `bootstrap_cli.rs`: tarball falso vía file:// → sella + imprime
hash, idempotente, sha erróneo falla sin sellar. Sin red.
Docs SDD 11 §5 y roadmap marcan Stage 0 (lib+CLI) hecho; Stage 1/2 pendientes.
Workspace 219 verde.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Primer eslabón ejecutable del bootstrap from-scratch (SDD 11). Stage 0 ingiere un
toolchain semilla pinned al store sin compilarlo:
- SeedSpec{kind,version,url,sha256}. seed_hash() deriva el ArtifactHash de la
identidad pinned (clase+versión+sha256), no de rutas del host ni del url → el
mismo tarball desde otro espejo produce el mismo artefacto.
- stage0(seed, store): descarga (curl, file:// offline-ok), verifica sha256
ANTES de extraer, untar y seal en el store. Idempotente si ya está sellado;
un sha erróneo no sella nada.
- Reusa el lab: hammer_build::download (+ nuevo verify_sha256 público), Store::seal.
Staging bajo el root del store para que el rename de seal sea atómico.
ADR 0008 fija la decisión (3 stages, zig semilla primaria, musl-cross-make
escotilla, ingerir-no-compilar el Stage 0). Renumerado a 0008 porque el 0007 lo
tomó la decisión de adoptar arje como init; SDD 11 y refs alineados con arje
(init supervisado que entrega el CRASHED real, ADR 0007).
4 unit tests (hash por identidad, ingest+seal+readonly, idempotencia, rechazo de
sha) sin red (file://). Workspace 218 verde.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Coordina con tawasuyu/03_ukupacha/arje/PLAN-ATESTACION-Y-HAMMER.md:
- arje (init from-scratch ya existente, PID1 + supervisión real) cubre el
CRASHED diferido de la Fase 5 en vez de escribir init nuevo.
- frontera: PID1 fino, un solo bus (arje-bus + hammer-core::proto), CAS
unificado en BLAKE3, confianza en capas (procedencia hammer + atestación
arje; el expected_hash del .swm = el blake3 que arje atesta).
- caveat: la cage glibc es del mundo hammer/Linux, fuera de PID1.
Puntero añadido en docs/10-roadmap.md §Track posterior.
Diseña el corte del cordón umbilical con Alpine en tres etapas, reusando el lab
de Fase 0 sin mecanismo nuevo:
- Stage 0: toolchain semilla (zig primario, musl-cross-make como escotilla)
ingerido al store como fuente pinned por sha256 — el único insumo del host.
- Stage 1: userland mínimo cross-compilado (musl, busybox/toybox, init propio,
hammerd) ensamblado en un rootfs sellado. El init propio habilita el CRASHED
real diferido de Fase 5.
- Stage 2: rebuild nativo dentro del rootfs y diff de hashes stage1 vs stage1'
⇒ auto-alojamiento bit-reproducible.
Cada etapa anota (stage, recipe_hash, artifact_hash) en bootstrap.json, embrión
del log de transparencia (SDD 09 §4). Propone el crate hammer-bootstrap y la CLI
`hammer bootstrap stage0|stage1|stage2|--all`. Decisiones abiertas marcadas para
un futuro ADR 0007. Enlazado desde el índice de docs y el roadmap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sincroniza las cabeceras con la realidad: tras cerrar la op del watcher (Fase 3)
y la anidación LIFO (Fase 2), ningún ítem de las fases 0–6 queda abierto salvo
CRASHED real, explícitamente diferido al track posterior. Reescribe "Estado
actual" (seguía describiendo el día 1) con el bucle agéntico validado, 214 tests
verdes y el siguiente paso: bootstrap from-scratch del track posterior.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Un `try` sobre un target ya cubierto apila un overlay nuevo: el kernel toma el
merged view de la capa inferior como lowerdir. Para que esto sea seguro y no un
footgun:
- fresh_id añade un `seq` atómico por proceso (`<ts>-<pid>-<seq>` zero-padded),
eliminando la colisión de ids cuando un orquestador apila varios overlays en
el mismo segundo — el caso real de la anidación.
- stack_key define un orden de apilamiento total y determinista (created_at,
desempatado por id).
- blocking_overlays detecta capas más jóvenes que solapan targets (igualdad o
ancestro de path). commit/discard fallan con Error::Shadowed si existen,
exigiendo resolver LIFO de arriba hacia abajo — antes se desmontaba la capa
equivocada del target compartido en silencio.
Tests: 6 unit del guard (disjuntos / solape / ancestro / desempate / commit y
discard rechazados) sin privilegios; e2e overlay_nested_stack_inside_userns
prueba el stack real (capa 2 ve la 1, rechazo LIFO, fusión arriba→abajo),
gated en HAMMER_OVERLAY_TESTS. Docs 04 y roadmap actualizados.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CLOSE_WRITE ya no se materializa siempre como Replace. classify_op deriva:
- Delete: el fd resuelve a " (deleted)" (sufijo que ahora recortamos del path
en vez de colarlo al diario; content_hash queda None).
- Edit: el diario ya tenía un evento previo no-Delete para el path.
- Create: primera mutación observada, o resurrección tras un Delete.
Es la divergencia que el diario atestigua, no la verdad absoluta del FS. La
atribución plena vía FAN_REPORT_DFID_NAME queda diferida: nix 0.30 no parsea
los info-records de FID y el modo FID perdería el fd que hashea el contenido.
classify_op se extrae como función libre para probarla sin CAP_SYS_ADMIN; 4
tests unitarios nuevos. Roadmap Fase 3 actualizado.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
El campo content_hash (ya existía en MutationEvent) ahora se puebla y se
usa para no ensuciar el diario con reescrituras sin cambios.
hammer-journal:
- content_hash_of(bytes) / hash_file(path): blake3 plano (estilo b3sum,
"b3:<hex>"), distinto del of_inputs de artefactos — aquí la pregunta es
"¿cambió el archivo?".
- last_for_path(path): último evento de un path.
- record_dedup(ev): omite el evento si deja el archivo idéntico al último
estado de ese path (content_hash igual) o si es un Delete sobre algo ya
borrado. Devuelve si escribió o no. +7 tests.
hammerd watcher: hashea cada CLOSE_WRITE y usa record_dedup; una
reescritura idéntica ni registra ni emite Modified al bus.
22 binarios de test verdes, sin warnings nuevos.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cierra el último hueco de "sólo inline": ahora un .swm puede referenciar
el patch y el contenido de un file_drop por URL.
- hammer-build/src/download.rs: fetch_url_bytes / fetch_url_to_file (curl,
sólo fuera del sandbox; acepta file:// para tests offline). 3 tests.
- swm_bridge::build_source_patch: descarga patch_url a
swm-recipes/<commit>.patch antes de compilar (ya no lo rechaza). Test
reescrito con file://.
- CLI apply: file_drop con content_url descarga y verifica BLAKE3 ANTES de
escribir; hash erróneo ⇒ nada tocado (verificado e2e).
Integridad: content_hash (file_drop) y build reproducible + expected_hash
(patch). 22 binarios de test verdes; e2e por file:// (apply + caso de
hash inválido que no escribe).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cuando una fase de build falla, el lab ahora conserva las últimas 50
líneas del log y las entrega a la IA por el bus.
- sandbox.rs: BuildFailure { reason, log_tail } (impl Error), wrap en
Error::Other. Sandbox::run hace tee de stdout/stderr al padre (streaming
en vivo intacto) y a un ring buffer acotado; al fallar, devuelve la cola
en log_tail. spawn_pump + push_tail con tests.
- BuildFailure::from_error(&Error) recupera la struct por downcast.
- bus.rs run_compile: separa reason del log_tail real del compilador en
vez de mandar e.to_string() y log_tail=None.
Tests: push_tail (ring), roundtrip por downcast, none para errores planos.
Firmas de build/build_source_patch sin cambios. 22 binarios verdes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sustituye la política hardcodeada por una declarativa (SDD 07 §4).
hammer-core/src/caps.rs:
- AgentCapsConfig { default, rule[] }; CapRule { uid?, gid?, caps }.
- caps_for(uid, gid): primera regla que casa (todos los campos
declarados deben coincidir) o default. load(path) → Ok(None) si falta.
- 8 tests: orden de reglas, match uid+gid, fallback, regla vacía ignorada.
hammerd:
- bus::policy_from_config(cfg) construye la CapsPolicy desde la config.
- main: --agent-caps (default /etc/hammer/agent-caps.toml). Con fichero
usa la config; sin fichero o con fichero inválido cae a default_policy
(avisando). +1 test del policy_from_config.
examples/agent-caps.toml: plantilla comentada.
22 binarios de test verdes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cierra el item de firma del modelo de confianza (SDD 09 §3, §5).
hammer-core/src/sign.rs:
- KeyPair: genera (getrandom), carga/serializa claves base64, escribe
<name>.ed25519 (0600) + <name>.ed25519.pub, y firma un Swm.
- TrustStore::load(dir): lee *.ed25519.pub; dir ausente ⇒ store vacío.
- Swm::verify_signature(&trust) → SigStatus {Trusted|UnknownKey|BadSig|
Unsigned}. Bytes firmados = JSON canónico de (swm_version, base,
mutations) sin la firma; deterministas (pins en BTreeMap).
- 8 tests: sign/verify, tamper, wrong-key, roundtrip en disco, dir ausente.
CLI:
- hammer keygen <name> [--out DIR]
- hammer swm-sign <file> --key K [--by N] [-o OUT]
- hammer swm-verify <file> --trust DIR (reporta trusted/unknown-key/
bad-sig/unsigned; bad-sig aborta, lo demás informa).
La firma da autoría + integridad pero NO autoriza promover: apply sigue
reproduciendo y comparando. Verificado e2e por el CLI (keygen→sign→verify
en los 4 estados). 22 binarios de test verdes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hasta ahora `hammer export` emitía sólo `file_drop` con `content_b64`.
Eso reproducía byte-a-byte pero perdía la receta: el receptor obtenía
binarios opacos, sin manera de auditarlos ni de recompilarlos desde
fuente. Esta fase cierra el hueco para los artefactos producidos por
`hammer-build::build`.
Mecanismo:
- `Store` reserva `.hammer/recipe.toml` (`RECIPE_SIDECAR_REL`) dentro
de cada artefacto. `hammer-build::build` lo escribe antes de sellar,
así queda inmutable junto con el árbol.
- `Store::recipe_for_dir` / `recipe_for_hash` lo leen de vuelta. Si el
artefacto es viejo y no trae sidecar, devuelven `None` sin fallar.
- `Recipe::to_toml` (nuevo) hace el roundtrip.
Lógica del export (función pura `build_export_mutations`):
1. Aplica semánticas de Delete: invalida estados previos del mismo path.
2. Particiona los eventos sobrevivientes en (a) los que tienen
`artifact_hash` con receta sidecar y (b) el resto.
3. Por cada grupo de (a) emite UN `source_patch` con repo+commit+build
de la receta y `expected_hash = artifact_hash`. El `target_bin` es
el primer path alfabético del grupo; el receptor hidrata el árbol
completo al aplicar.
4. Patches de la receta se concatenan inline en el `source_patch.patch`.
5. Los eventos del bucket (b) caen al fallback `file_drop` con
`content_b64 + content_hash` (orden alfabético).
Limitaciones explícitas:
- `SourcePatch` sólo modela `Source::Git`. Recetas con `tarball` se
reportan por stderr y caen a file_drop. Extender el SWM para
tarballs es trabajo aparte.
- Si la receta tiene patches pero alguno no se puede leer, no se
inlina; `expected_hash` sigue siendo el gate de integridad para
detectar la divergencia.
CLI: el subcomando `Export` ahora recibe `--store` para resolver
artefactos. Defaults igual que antes (`/store`).
Tests (12 nuevos):
- hammer-core (5): `Recipe::to_toml` roundtrip; `Store::recipe_for_dir`
sin/con sidecar; `Store::recipe_for_hash` por prefijo + hash inexistente.
- hammer-cli (7): export sin store; semánticas de Delete; hydrate con
sidecar emite source_patch agrupando dos archivos; hydrate sin sidecar
cae a file_drop contando `missing_recipe`; mix trazable + external;
path ilegible sólo warning.
`ClaudeTranslator` implementa el trait `IntentTranslator` igual que el
`MockTranslator`, así que el `Orchestrator` no cambia: se traduce una
intención NL a un `.swm` válido vía `/v1/messages`.
Diseño:
- Síncrono (ureq + rustls), consistente con `AgentClient`. Tokio
entraría sólo si el resto del crate lo pidiera.
- Trait `HttpClient` inyectable ⇒ tests sin red contra una fake que
captura el request y devuelve un body pre-armado.
- Modelo por defecto: `claude-opus-4-8` (Opus 4.8, el más capaz al día
de hoy). Adaptive thinking + `effort=high` por defecto. Override por
env (`HAMMER_LLM_MODEL`, `HAMMER_LLM_EFFORT`, `HAMMER_LLM_BASE_URL`).
- System prompt documenta el shape exacto del `.swm` (4 variantes de
mutación) y obliga JSON puro. Parseamos con `serde_json::from_str::
<Swm>` + `verify_schema()` como gate adicional. Manejamos `refusal`,
error envelopes de la API y code-fence markdown.
Activación:
- Sin feature: el módulo declara los tipos pero `ClaudeTranslator::new`
está bajo cfg. El binario compila sin red.
- Con `--features llm-claude`: trae `ureq` con rustls, y la CLI activa
`hammer ai --llm`.
CLI:
- `hammer ai` gana `--llm` (mutuamente excluyente con `--catalog`).
Refactor de `run_ai` en helpers `run_with_mock_translator` /
`run_with_llm_translator` para mantener legible el dispatch.
- Sin la feature, `--llm` falla con un mensaje claro pidiendo
recompilar.
Tests (7 unit): happy path con verificación de URL/headers/body,
unwrap de markdown, `refusal`, error envelope, SWM mal formado,
verify_schema, helpers de strip_code_fence.
`Orchestrator::run_with_repair(intent, &policy)` corre el ciclo
plan→try→apply→wait_for_crash→re-plan hasta `max_attempts` o
estabilización (sin Crashed en `crash_window`). Cuando llega un Crashed
asíncrono por el bus, la política convierte `(service, code)` en una
nueva intención (`default_crash_intent` por defecto) y reentra.
Diseño:
- `RepairPolicy { max_attempts, crash_window, event_sock, format_intent }`
acota el blast-radius desde el caller; la IA no decide reintentar.
- `Proposal` gana `repair_chain: Vec<RepairAttempt>` con la cadena de
intentos (intent, overlay_id, triggered_crash). La IA NUNCA promueve
al FHS; el humano lee el chain y decide commit/discard.
- `wait_for_crash` abre un AgentClient sólo para drenar async events;
reusa toda la maquinaria síncrona del cliente.
CLI: `hammer ai --repair-max-attempts N --repair-window-ms M`. Sin el
flag, `run` corre una sola vez (compatible hacia atrás).
Tests (3): stub bus multi-conexión que pre-programa eventos por
conexión. Cubre reacción a Crashed, cap por max_attempts con crashes
persistentes, y modo sin socket (una sola corrida).
Forma `kind:value`: `bin`, `file`, `pin`, `service`, `depends`. El
evaluador acepta un EvalContext con BaseRef, fs_root y path_env para
re-rootear contra un overlay o prefix sin tocar el FHS real. `depends:`
implementa un parser ELF64 LE mínimo que recorre PT_DYNAMIC para extraer
DT_NEEDED, suficiente para los binarios estáticos+dinámicos del lab.
- hammer-core::query: parse(expr) + eval(term, ctx) + eval_str(expr, ctx).
- proto: Command::Query gana `expr: Option<String>`.
- AgentClient::query_expr: equivalente a query_file pero por el bus.
- hammerd: maneja `what="expr"` invocando query::eval_str.
- CLI: `hammer query <expr> [--base-ref] [--fs-root]` evalúa en proceso.
Tests: 18 unit en `hammer-core::query::tests` (parse, eval con fs_root,
ELF gated en `HAMMER_HOST_ELF_TESTS`), 1 e2e en `hammerd::bus_e2e`
(query_expr_evaluates_against_host_path).
- proto: mover hammerd::proto a hammer-core::proto para que hammerd y hammer-agent
compartan los tipos del bus sin duplicar.
- hammer-agent (crate nuevo):
* client: AgentClient síncrono. Handshake hello/welcome; compile/inject/query/init
bloqueantes con timeout; reader thread interno demultiplexa async events (Modified/
Crashed) en una cola que el caller drena vía drain_async/next_async.
* translator: trait IntentTranslator + MockTranslator (HashMap<intent, Swm>) +
IntentCatalog YAML (swm_inline o swm_path). El traductor LLM real se enchufa
detrás del mismo trait sin cambios al orquestador.
* orchestrator: Orchestrator::run(intent) -> Proposal con plan -> schema -> base ->
try (overlay|prefix) -> apply (config_edit/file_drop con hammer-core::apply,
source_patch via bus opcional) -> verify (spot-checks) -> propose. Devuelve
overlay_id (para `hammer commit`) o prefix usado.
- hammer-cli: subcomando `hammer ai <intent> --catalog F [--prefix DIR --base-ref F
--bus SOCK --state-root DIR]`. Imprime el Proposal y el siguiente paso humano.
- Tests:
* 10 unit (translator + catalog + orchestrator).
* 3 e2e del bucle agéntico (intent -> archivos esperados bajo un prefix tmp).
* 1 e2e del cliente contra un stub bus (handshake + Compile -> BuildReady +
Modified asíncrono), sin depender de hammerd ni del lab.
- Docs: SDD 08 actualizado con el API del crate; roadmap marca lo cerrado y lo
pendiente (LLM real, lenguaje de consulta, bucle de auto-reparación con Crashed).
- proto: tipos Command/Event con serde tag "t", Hello/Welcome handshake, RecipeInline
reducida para COMPILE, Cap enum (Query/Compile/Inject/InjectReal/Init) y
required_for() para gating por comando.
- bus: serve_agent_bus listens en UnixListener, autenticación SO_PEERCRED por
conexión via nix::sys::socket::PeerCredentials, una conexión = reader thread +
writer thread + bus-forwarder thread + worker spawn por COMPILE. Dispatch a
hammer-build (Compile), find_by_hash+run_hydrate (Inject), stat (Query/file),
store.find_by_hash (Query/artifact) y send_to_fifo (Init).
- events: EventBus in-process (Arc<Mutex<Vec<Sender>>>), suscripción por conexión
y purga perezosa de subs muertos en publish.
- control: ensure_fifo (mkfifo idempotente, rechaza non-FIFO), run_reader (relog
por línea, reabre al EOF), send_to_fifo (bloqueante si no hay reader).
- watcher: nuevo start_with_events que clona el EventBus; cada MutationEvent
registrado se re-emite como Event::Modified a las conexiones abiertas.
- hammerd::main: orquesta FIFO + watcher + bus en threads; --no-watcher para
dev sin CAP_SYS_ADMIN.
- hammer-cli: hammer ctl <line> [--fifo PATH] escribe al FIFO con error claro si
no existe; reemplaza el stub "[fase 5 pendiente]".
- Tests:
* 14 unit tests nuevos en hammerd (proto/bus/events/control).
* 5 e2e en crates/hammerd/tests/bus_e2e.rs: handshake con peer creds, gating
no_cap, Query/file, Modified fan-out vía bus, Init -> FIFO end-to-end.
- Docs: docs/10-roadmap.md actualizado con lo cerrado y lo pendiente (policy
declarativa, CRASHED real con supervisor, log_tail en BuildFailed).