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).
Nuevo crate hammer-journal y watcher fanotify en hammerd. Cierra el contrato
del SDD 05 §3 ("toda mutación trazable/opaca registrada y consultable") y
liquida la deuda Fase 2 sobre el hook al diario en commit.
hammer-journal:
- MutationEvent (op, path, by, content_hash?, note?), MutationOp
Create/Replace/Edit/Delete, Source enum tagged HammerHydrate /
HammerCommit / External (distingue trazable de opaco para `export`).
- Journal: record append-only + sync_data, read_all/tail/follow (offset),
resilencia a líneas malformadas (kernel-panic mid-write se ignora).
- RFC 3339 UTC sin chrono (Hinnant civil_from_days, válido para todo i64).
- 7 unit tests cubren append/read/tail/follow/malformed/serde/timestamp.
Watcher (hammerd):
- nix::sys::fanotify con FAN_CLASS_NOTIF + FAN_CLOSE_WRITE +
FAN_EVENT_ON_CHILD sobre /bin /sbin /usr/bin /usr/sbin /lib /usr/lib /etc.
- Resuelve path por readlink(/proc/self/fd/<n>) — sin FAN_REPORT_DFID_NAME
para Fase 3 v1; el refinamiento Create/Edit con metadata viene después.
- Filtro: hammer_overlay::status(state_root) → ignora paths bajo un overlay
activo (el ruido del experimento no entra hasta el commit).
- Sin CAP_SYS_ADMIN, fanotify_init falla; hammerd lo reporta y sigue
durmiendo (preparado para que Fase 5 monte el bus encima).
- 3 unit tests sobre defaults/filtros/uid lookup.
CLI:
- hammer journal [--dir] [--tail N] [--follow] [--format pretty|json].
- hammer commit registra cada archivo promocionado/removido vía
commit_with_journal; --no-journal opt-out.
Hook overlay::commit:
- commit_with_journal(id, root, journal): para cada copied/removed emite un
MutationEvent con Source::HammerCommit { overlay }. Test directo del hook
sin overlayfs (lógica pura) más el e2e existente que ya cubre los mounts.
57 tests workspace, 0 fallos.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Nuevo crate hammer-overlay + integración al CLI. Diseño:
- API: try_overlay(targets, state_root) → OverlayId, status, discard, commit.
- Layout: <state>/<id>/{state.json, mounts/<slug>/{upper,work}}. El manifiesto
permite a `status` enumerar overlays sin estado en memoria.
- commit: desmonta primero, luego cp -a upper→lower por archivo, procesa
whiteouts (char dev 0/0) como remove en el lower. Sin diario aún (Fase 3).
- discard: umount LIFO + rm del state. Idempotente sobre `not mounted`.
- Privilegios: la librería no asume; emite `mount`/`umount` y deja que el
caller los tenga. CLI sin sudo falla con mensaje claro. El daemon de
Fase 5 los obtendrá desde contexto privilegiado.
Tests:
- 6 unit tests (slug, defaults, manifest, errores).
- E2E bajo bwrap+user-ns con CAP_SYS_ADMIN: ciclo try → escribir → commit →
verificar persiste; try → escribir → discard → verificar revertido;
remove en merged → commit → whiteout aplicado al lower. Gateado en
HAMMER_OVERLAY_TESTS=1: por defecto, los kernels deniegan el mount
overlayfs en user-ns no-privilegiado (sysctl/AppArmor); el dev habilita
cuando su entorno lo permite o corre con sudo.
CLI: hammer try [targets…] --state-root, commit <id>, discard <id>, status.
Refactor mínimo: las cmds que no tocan artefactos ya no abren (ni crean) el
store. `hammer status --state-root /tmp` no toca /store.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- `fetch_tarball.rs` (5 tests, sin red ni rootfs): genera tar.gz local, lo sirve
vía `file://` y ejercita el path completo: strip_components default vs 0,
sha256 mismatch que NO envenena caché ni deja .partial, reuso de caché en
segunda llamada con la fuente original eliminada, y rechazo de un archivo
cacheado con contenido distinto al sha esperado.
- `end_to_end_grep.rs` (1 test, gated): grep 3.12 desde GNU, build + hidratación
+ ejecución en bwrap+Alpine + cache-hit en segunda build. Doble skip:
HAMMER_NETWORK_TESTS!=1 o .dev-fs sin bootstrap. Comparte .dev-fs/cache con la
dev para no repagar musl.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Cierra el primer entregable del roadmap. Cambios:
- `Source`: ahora admite dos modos mutuamente excluyentes — `git` (repo+commit) o
`tarball` (url+sha256). El hash de entrada del artefacto usa el commit en modo
git y el sha256 en modo tarball; ambos son identificadores inmutables del
contenido fuente. Validación en `Source::kind()` con error claro.
- `fetch`: dispatch por modo. Tarball cacheado en `work/tarballs/<sha>.tar`,
descarga vía `curl -fL`, verificación sha256 con `sha2`, extracción con
`--strip-components` (default 1, GNU-style).
- `recipes/grep.toml`: GNU grep 3.12 desde el tarball release de gnu.org
(sha256 fijado, sin patches, `--disable-perl-regexp --disable-nls`).
- Bootstrap: añade `binutils` al rootfs Alpine — configure de autotools mira
ld/ar/ranlib incluso cuando el compilador real es zig cc.
- `.gitignore`: `/work/` (artefactos transitorios del build).
- `docs/10-roadmap.md`: Fase 0 y Fase 1 marcadas ✅; entregable cerrado.
Nota sobre v3.11 vs v3.12: probé primero v3.11 y el binario producido daba
"memory exhausted" en cualquier regex (bug conocido de grep+musl static al
inicializar DFA). v3.12 corrige y funciona limpio dentro del rootfs Alpine.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- `BuildConfig.cache_root` (override `HAMMER_CACHE`): bindea `/cache` al sandbox
y exporta `ZIG_GLOBAL_CACHE_DIR=/cache/zig`. Evita ~30s de recompilación de
musl en cada build.
- `scripts/bootstrap-devfs.sh`: idempotente, descarga+verifica alpine-minirootfs
3.23.4 y zig 0.16.0 con sha256 fijo, instala build tools en el rootfs vía apk
bajo bwrap.
- `make_tree_read_only`: solo archivos regulares pierden `w`; los directorios
conservan 0o755 para no estorbar GC ni `rm -rf` administrativo. La
inmutabilidad estricta del store se delega al mount RO de la distro propia.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
detect_build_system inspecciona el árbol del source y elige los comandos:
- configure.ac sin configure → autoreconf -fi && ./configure --prefix=/usr
- configure presente → ./configure --prefix=/usr
- CMakeLists.txt → cmake -B _build ... ; cmake --build ; cmake --install
- meson.build → meson setup _build ... ; meson compile ; meson install
- Makefile / GNUmakefile → make -j && make install DESTDIR=/out PREFIX=/usr
- nada → la receta debe traer [build.phases]
Overrides en la receta siguen ganando. recipe.build.flags se concatenan al
configure. Cuando link = "static", el orquestador inyecta LDFLAGS=-static
para que el link final autotools/cmake produzca el binario monolítico que
quiere nuestra hidratación primaria.
Tests:
- 7 unit tests de detección + emisión de comandos por sistema.
- 1 integration test end-to-end con autotools real (configure.ac + Makefile.am
+ hello.c → autoreconf+configure+make+install dentro del sandbox, sellado,
ejecutado bajo bwrap). Requiere el rootfs Alpine con `apk add make
autoconf automake m4 patch coreutils libtool pkgconf bash`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- hammer-core: Recipe::from_toml/load_from_path reales; Phases override;
hashing por *contenido* de patches; Store::seal con rename atómico + chmod
r/o recursivo; Store::find_by_hash por prefix.
- hammer-build: fetch (git mirror + archive al commit fijado), Sandbox::run
sobre bwrap con rootfs Alpine como tmp-overlay y zig cc inyectado,
orquestador build con caché por hash, hydrate con hardlinks atómicos
(link tmp + rename) que pisa el FHS sin tocar inodes del store.
- BuildConfig leído de HAMMER_{ROOTFS,ZIG,WORK} o defaults relativos al store.
- CLI: hammer build / hammer hydrate funcionales; logs a stderr para que
stdout sea pipeable (el hash y nada más).
- 20 unit + 1 integration test end-to-end (hello.c estático compilado bajo
bwrap, sellado, hidratado, ejecutado en sandbox limpio).
Cierra el primer entregable del roadmap salvo "X = grep" (falta heurística
autotools/cmake en resolve_phases).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Arranque del proyecto hammer (distro AI-nativa: laboratorio funcional en el
sótano, terminal mutable clásica arriba, integración de IA programadora).
- Workspace Rust (compila, tests verdes): hammer-core, hammer-build,
hammer-cli (bin `hammer`), hammerd.
- SDDs 00-10 + 6 ADRs en docs/ con toda la arquitectura.
- Esqueletos navegables mapeados a las fases del roadmap; Fase 0/1 listas
para implementar el sandbox real.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>