tawasuyu

tawasuyu/shuma

Interactive shell with zsh/fish parity, native multiplexing + remote sessions, on Llimphi.

Rust 509Markdown 156TOML 81YAML 312114 Definiciones

feat: shuma standalone — shell interactiva sobre Llimphi (front-door, git-dep al monorepo)

Shell con paridad zsh/fish, multiplexado y sesiones remotas nativas, en chasis
Llimphi de 4 slots. Front-door limpio: solo crates shuma-*/matilda-*; Llimphi y
lo fundacional (chasqui/minga discovery, pata-host, arje, hojas shared) por
git-dep del monorepo gioser.git — cero vendoring. shuma-discern se trae del
monorepo (lo necesita chasqui, evita doble-fuente). cargo check pasa (0 errores).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

1e5d3ed2397d6efbb71aa34380f170471508cfd7 · por Sergio · (hace 4 meses) · ver el árbol en este commit

Delta semántico · 1683

02_ruway/shuma/baremetal/matilda-app/src/main.rs

02_ruway/shuma/baremetal/matilda-apply/src/lib.rs

02_ruway/shuma/baremetal/matilda-config/src/docker.rs

02_ruway/shuma/baremetal/matilda-config/src/lib.rs

02_ruway/shuma/baremetal/matilda-config/src/nginx.rs

02_ruway/shuma/baremetal/matilda-core/src/container.rs

02_ruway/shuma/baremetal/matilda-core/src/host.rs

02_ruway/shuma/baremetal/matilda-core/src/inventory.rs

02_ruway/shuma/baremetal/matilda-core/src/lib.rs

02_ruway/shuma/baremetal/matilda-core/src/vhost.rs

02_ruway/shuma/baremetal/matilda-ghost/src/lib.rs

02_ruway/shuma/baremetal/matilda-linker/src/lib.rs

02_ruway/shuma/baremetal/matilda-plan/src/lib.rs

02_ruway/shuma/sandbox/shuma-card/src/lib.rs

02_ruway/shuma/sandbox/shuma-config/src/lib.rs

02_ruway/shuma/sandbox/shuma-core/src/flow_channel.rs

02_ruway/shuma/sandbox/shuma-core/src/lib.rs

02_ruway/shuma/sandbox/shuma-core/src/logbuf.rs

02_ruway/shuma/sandbox/shuma-core/src/persist.rs

02_ruway/shuma/sandbox/shuma-core/src/pipeline.rs

02_ruway/shuma/sandbox/shuma-core/src/pipelines.rs

02_ruway/shuma/sandbox/shuma-core/src/runtime.rs

02_ruway/shuma/sandbox/shuma-core/src/stats.rs

02_ruway/shuma/sandbox/shuma-core/src/workspaces.rs

02_ruway/shuma/sandbox/shuma-exec/src/lib.rs

02_ruway/shuma/sandbox/shuma-history/src/lib.rs

02_ruway/shuma/sandbox/shuma-intent/src/graph.rs

02_ruway/shuma/sandbox/shuma-intent/src/lib.rs

02_ruway/shuma/sandbox/shuma-intent/src/macros.rs

02_ruway/shuma/sandbox/shuma-intent/src/parse.rs

02_ruway/shuma/sandbox/shuma-line/src/dialect.rs

02_ruway/shuma/sandbox/shuma-line/src/editor.rs

02_ruway/shuma/sandbox/shuma-line/src/ghost.rs

02_ruway/shuma/sandbox/shuma-line/src/icon.rs

02_ruway/shuma/sandbox/shuma-line/src/lib.rs

02_ruway/shuma/sandbox/shuma-line/src/pipeline.rs

02_ruway/shuma/sandbox/shuma-line/src/token.rs

02_ruway/shuma/sandbox/shuma-link/src/channel.rs

02_ruway/shuma/sandbox/shuma-link/src/handshake.rs

02_ruway/shuma/sandbox/shuma-link/src/identity.rs

02_ruway/shuma/sandbox/shuma-link/src/lib.rs

02_ruway/shuma/sandbox/shuma-link/src/peers.rs

02_ruway/shuma/sandbox/shuma-module-canvas/src/lib.rs

02_ruway/shuma/sandbox/shuma-module-commandbar/src/lib.rs

02_ruway/shuma/sandbox/shuma-module-launcher/src/lib.rs

02_ruway/shuma/sandbox/shuma-module-matilda/src/lib.rs

02_ruway/shuma/sandbox/shuma-module-minga/src/lib.rs

02_ruway/shuma/sandbox/shuma-module-shell/examples/dump_ls.rs

02_ruway/shuma/sandbox/shuma-module-shell/examples/dump_shell.rs

02_ruway/shuma/sandbox/shuma-module-shell/src/lib.rs

02_ruway/shuma/sandbox/shuma-module-shell/src/update.rs

02_ruway/shuma/sandbox/shuma-module-shell/src/view.rs

02_ruway/shuma/sandbox/shuma-module/src/lib.rs

02_ruway/shuma/sandbox/shuma-protocol/src/lib.rs

02_ruway/shuma/sandbox/shuma-remote-exec/src/lib.rs

02_ruway/shuma/sandbox/shuma-session/src/lib.rs

02_ruway/shuma/sandbox/shuma-shell-render/src/lib.rs

02_ruway/shuma/sandbox/shuma-sysmon/src/lib.rs

02_ruway/shuma/shuma-cli/src/main.rs

02_ruway/shuma/shuma-gateway/src/main.rs

02_ruway/shuma/shuma-shell-llimphi/src/config.rs

02_ruway/shuma/shuma-shell-llimphi/src/main.rs

02_ruway/shuma/shuma-shell-llimphi/src/menu.rs

02_ruway/shuma/shuma-shell-llimphi/src/view.rs

182 archivos · +42292 −0

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b7141ea
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+/target
+**/*.rs.bk
+*.pdb
diff --git a/02_ruway/shuma/LEEME.md b/02_ruway/shuma/LEEME.md
new file mode 100644
index 0000000..fed95fe
--- /dev/null
+++ b/02_ruway/shuma/LEEME.md
@@ -0,0 +1,72 @@
+# shuma
+
+> Shell interactivo con paridad zsh/fish, sobre chasis Llimphi.
+
+`shuma` reemplaza zsh + tmux + mosh con una sola pieza: shell con history/completion/job-control, multiplexing nativo (no `tmux`), sesiones remotas (no `mosh`), todo dentro de un chasis Llimphi de 4 slots (TopBar, Main, BottomBar, DrawerTab + drawer Quake). Roadmap de 8 bloques (target 2026-05-25). `matilda` es la herramienta hermana para configuración declarativa multi-host.
+
+## Instalación
+
+```sh
+# shell desktop
+cargo run --release -p shuma-shell-llimphi
+
+# CLI puro
+cargo run --release -p shuma-cli
+
+# daemon (multi-sesión persistente)
+cargo run --release -p shuma-daemon
+```
+
+## Compatibilidad
+
+- **Linux / macOS / Windows** — shell + UI Llimphi.
+- **Wawa** — corre adentro del kernel.
+- Protocolo `shuma-protocol` permite cliente local + server remoto sin SSH.
+
+## Crates: shuma
+
+| Crate | Rol |
+|---|---|
+| [`shuma-core`](shuma-core/README.md) | Tipos: Session, Command, Output. |
+| [`shuma-cli`](shuma-cli/README.md) | CLI (no Llimphi). |
+| [`shuma-daemon`](shuma-daemon/README.md) | Daemon de sesiones. |
+| [`shuma-shell-llimphi`](shuma-shell-llimphi/README.md) | Shell con UI Llimphi. |
+| [`shuma-shell-render`](shuma-shell-render/README.md) | Renderer de output (ANSI, imágenes, links). |
+| [`shuma-protocol`](shuma-protocol/README.md) | Protocolo wire (reemplazo de SSH/mosh). |
+| [`shuma-gateway`](shuma-gateway/README.md) | Gateway de sesiones remotas. |
+| [`shuma-remote-exec`](shuma-remote-exec/README.md) | Exec remoto vía gateway. |
+| [`shuma-session`](shuma-session/README.md) | Sesión persistente. |
+| [`shuma-history`](shuma-history/README.md) | History con búsqueda fuzzy. |
+| [`shuma-exec`](shuma-exec/README.md) | Ejecutor de comandos. |
+| [`shuma-line`](shuma-line/README.md) | Readline (edición · completion · highlight). |
+| [`shuma-config`](shuma-config/README.md) | Config del shell. |
+| [`shuma-intent`](shuma-intent/README.md) | Intent → comando (predictor). |
+| [`shuma-infer`](shuma-infer/README.md) | Inferencia para `intent`. |
+| [`shuma-discern`](shuma-discern/README.md) | Discriminador comando-vs-texto. |
+| [`shuma-link`](shuma-link/README.md) | Links clickables en output. |
+| [`shuma-sysmon`](shuma-sysmon/README.md) | Monitor de sistema embebido. |
+| [`shuma-card`](shuma-card/README.md) | Card escritorio. |
+| [`shuma-module`](shuma-module/README.md) | Trait módulo del chasis. |
+| [`shuma-module-shell`](shuma-module-shell/README.md) | Módulo shell (Main slot). |
+| [`shuma-module-commandbar`](shuma-module-commandbar/README.md) | Módulo command bar (TopBar). |
+| [`shuma-module-launcher`](shuma-module-launcher/README.md) | Módulo launcher (DrawerTab). |
+| [`shuma-module-matilda`](shuma-module-matilda/README.md) | Módulo matilda integrado. |
+
+## Crates: matilda (declarative host config)
+
+| Crate | Rol |
+|---|---|
+| [`matilda-core`](matilda/matilda-core/README.md) | Modelo de config declarativa. |
+| [`matilda-config`](matilda/matilda-config/README.md) | Loader de archivos. |
+| [`matilda-plan`](matilda/matilda-plan/README.md) | Planificador de diff (estado actual → deseado). |
+| [`matilda-apply`](matilda/matilda-apply/README.md) | Ejecutor del plan. |
+| [`matilda-discover`](matilda/matilda-discover/README.md) | Descubrimiento de estado actual. |
+| [`matilda-linker`](matilda/matilda-linker/README.md) | Enlaza dotfiles. |
+| [`matilda-ghost`](matilda/matilda-ghost/README.md) | Modo dry-run. |
+| [`matilda-app`](matilda/matilda-app/README.md) | CLI/UI. |
+
+## Consideraciones
+
+- **Reemplazo, no añadido.** Si usás shuma, podés desinstalar zsh/tmux/mosh; todo el comportamiento está cubierto.
+- **`intent → comando`** es opcional; sin LLM corre el shell tradicional sin diferencia.
+- Sesiones remotas usan **`shuma-protocol`** sobre TCP/TLS — no requiere demonio SSH.
diff --git a/02_ruway/shuma/README.md b/02_ruway/shuma/README.md
new file mode 100644
index 0000000..4f1b379
--- /dev/null
+++ b/02_ruway/shuma/README.md
@@ -0,0 +1,27 @@
+# shuma
+
+> Interactive shell with zsh/fish parity, on a Llimphi chassis.
+
+`shuma` replaces zsh + tmux + mosh with a single piece: shell with history/completion/job-control, native multiplexing (no `tmux`), remote sessions (no `mosh`), all inside a Llimphi 4-slot chassis (TopBar, Main, BottomBar, DrawerTab + Quake drawer). 8-block roadmap (target 2026-05-25). `matilda` is the sibling tool for declarative multi-host configuration.
+
+## Install
+
+```sh
+cargo run --release -p shuma-shell-llimphi
+cargo run --release -p shuma-cli
+cargo run --release -p shuma-daemon
+```
+
+## Compatibility
+
+- **Linux / macOS / Windows** — shell + Llimphi UI.
+- **Wawa** — runs inside the kernel.
+- `shuma-protocol` enables local-client + remote-server without SSH.
+
+Crates listed in [README.md](README.md) (shuma + matilda).
+
+## Considerations
+
+- **Replacement, not addition.** If you use shuma, you can uninstall zsh/tmux/mosh; behavior fully covered.
+- **`intent → command`** is optional; without LLM the traditional shell runs unchanged.
+- Remote sessions use **`shuma-protocol`** over TCP/TLS — no SSH daemon required.
diff --git a/02_ruway/shuma/README.qu.md b/02_ruway/shuma/README.qu.md
new file mode 100644
index 0000000..75bcfef
--- /dev/null
+++ b/02_ruway/shuma/README.qu.md
@@ -0,0 +1,29 @@
+<!-- Quechua (Cusco/Collao). Revisión bienvenida. -->
+
+# shuma
+
+> Shell interactivo zsh/fish kasqaqlla, Llimphi chasis patanpi.
+
+`shuma` zsh + tmux + mosh suyaspa, sapan p'aki hina: shell history/completion/job-control, natural multiplexing (manan `tmux`), karu sesiones (manan `mosh`), Llimphi 4-slot chasispi (TopBar, Main, BottomBar, DrawerTab + Quake drawer). 8-bloque roadmap (suyay 2026-05-25). `matilda` huk multi-host declarativo herramienta.
+
+## Churay
+
+```sh
+cargo run --release -p shuma-shell-llimphi
+cargo run --release -p shuma-cli
+cargo run --release -p shuma-daemon
+```
+
+## Tinkuy
+
+- **Linux / macOS / Windows** — shell + Llimphi UI.
+- **Wawa** — kernel ukhupi.
+- `shuma-protocol` lokal-cliente + karu-server, mana SSHwan.
+
+Crateskuna [README.md](README.md)-pi.
+
+## Yuyaykunaq
+
+- **Suyay, mana aymachay.** Shuma usaspa, zsh/tmux/mosh wikch'ay atinki.
+- **`intent → comando`** opcional; mana LLM tradicional shell hina.
+- Karu sesiones **`shuma-protocol`** TCP/TLS patanpi — manan SSH daemon munana.
diff --git a/02_ruway/shuma/REPORTE.md b/02_ruway/shuma/REPORTE.md
new file mode 100644
index 0000000..ea2696b
--- /dev/null
+++ b/02_ruway/shuma/REPORTE.md
@@ -0,0 +1,367 @@
+# shuma — reporte técnico para IA
+
+> Estado: **2026-05-31** · rama `main` · compila limpio (`cargo build -p shuma-shell-llimphi -p shuma-daemon -p shuma-cli -p shuma-gateway`).
+> Audiencia: sesión de Claude futura u otra IA que retome el shell+plugins. Idioma del proyecto: español.
+
+## Estado (2026-05-31)
+
+### Hecho
+
+- **Chasis Llimphi completo** (`shuma-shell-llimphi`): slots TopBar/Main/BottomBar +
+  tabs, monitores (CPU/MEM) con splitter, i18n vía `rimay_localize`, theme/locale
+  vivos desde el bus `wawa-config`.
+- **Bloques A–F del roadmap cerrados (2026-05-28/29)**: REPL usable (streaming no
+  bloqueante, decoración clickeable del output, `LineState` con completion+ghost,
+  historial JSONL+fuzzy, PTY+vt100 con resize dinámico, paste con bracketed paste,
+  33/33 tests); daemon como ejecutor (local/daemon/`DaemonTcp` Noise_XK) + sidecar
+  al broker; launcher y commandbar reales (Cmd-P con nucleo_matcher); integración
+  wawa (watcher + theme/lang live); limpieza (SO_PEERCRED, parser de bindings, lienzo).
+- **Lienzo de intenciones shell↔canvas** (2026-05-29): cada `start_run` aparece como
+  `%cN` en el `SessionGraph`; nodo verde/rojo según exit; canvas clickeable que
+  inserta `%cN`/`%pN` en el cursor del shell.
+- **Adiós al Quake-drawer** (2026-05-29): el chasis es app standalone normal (tabs
+  siempre visibles); el overlay launcher vive en `pata` (antes en el retirado
+  `mirada-launcher-llimphi`).
+- **vim como card themeable** (2026-05): PTY con skin app-aware, drag-to-select +
+  copia al clipboard, paste con click derecho/medio, iconitos por tipo en paths.
+- **Menús** (lote 4): menú principal + menús contextuales en el chasis.
+- **Stack matilda** (`baremetal/`): config declarativa multi-host (core/plan/discover/
+  config/apply/ghost/linker/app) + `shuma-module-matilda` (tab con SSH real).
+- **Refactors regla #1**: split de `shuma-module-shell` (3028 LOC), `shuma-core`
+  (1517) y `shuma-shell-llimphi` main (1522) en módulos.
+
+### Pendiente
+
+- **E2 — hover trigger del drawer**: bloqueado por dispatching de pointer enter/leave
+  en `llimphi-ui`.
+- **Mouse en el PTY**: vt100 ya parsea los eventos; falta cablear el mouse de Llimphi.
+- **Tooltip "what would clicking this do?"** en decoraciones (espera al hover de llimphi-ui).
+- **Cablear `shuma-line::decorate`** completo desde más consumidores (ya hace mucho,
+  poco consumido).
+- **Daemon**: lockfile + check de PID vivo (hoy ignora bind si el socket existe).
+- **Placeholders residuales**: aunque launcher/commandbar ya tienen impl real, varios
+  crates sandbox del listado siguen sin app que los consuma directamente.
+
+---
+
+## 1. Mapa del subárbol `02_ruway/shuma/`
+
+```
+shuma/
+├── shuma-cli/              ← CLI admin del daemon (postcard sobre Unix socket)
+├── shuma-daemon/           ← runtime: dueño de Workspaces, admin socket, reaper
+├── shuma-gateway/          ← bridge HTTP/JSON ↔ postcard (1 endpoint: POST /rpc)
+├── shuma-shell-llimphi/    ← CHASIS gráfico (Llimphi) — host de los módulos
+├── baremetal/              ← stack de "matilda" (admin server declarativa)
+│   ├── matilda-core, -plan, -discover, -apply, -ghost, -linker, -config, -app
+└── sandbox/                ← crates de soporte del shell (sync, agnósticos de UI)
+    ├── shuma-card          ← Workspace/Pipeline/CommandRef → card_core::Card
+    ├── shuma-core          ← runtime in-memory (Mutex<HashMap>), reap, persist
+    ├── shuma-protocol      ← wire postcard u32-BE-prefix (daemon ↔ cli/gui)
+    ├── shuma-discern       ← discerners (magic-bytes, JSON, TOML, UTF8, Card)
+    ├── shuma-exec          ← ejecución sync: Direct / Shell / Pty; eventos mpsc
+    ├── shuma-link          ← Noise_XK + identity X25519 + FramedChannel
+    ├── shuma-remote-exec   ← cliente sync del ExecStream del daemon
+    ├── shuma-line          ← lex/parse/decorate/complete del input (sin frontend)
+    ├── shuma-history       ← JSONL append-only + fuzzy (nucleo_matcher)
+    ├── shuma-session       ← WorkSession (cwd, runs, grupos)
+    ├── shuma-intent        ← grafo de intenciones %cN/%pN
+    ├── shuma-shell-render  ← CanvasPlan (lienzo de contexto agnóstico)
+    ├── shuma-sysmon        ← /proc/stat + /proc/meminfo + historial
+    ├── shuma-module        ← contrato estructural de módulos (sin trait dyn)
+    ├── shuma-module-shell      ← MVP REPL (sh -c, sync, builtins)
+    ├── shuma-module-matilda    ← admin declarativa como tab del shell
+    ├── shuma-module-launcher   ← PLACEHOLDER
+    └── shuma-module-commandbar ← PLACEHOLDER
+```
+
+---
+
+## 2. Arquitectura en una pantalla
+
+```
+                              ┌────────────────────────────┐
+   shuma-cli ─postcard──┐     │  shuma-shell-llimphi       │
+   shuma-gateway ─json──┤     │  (chasis Llimphi)          │
+                        ▼     │   ┌──────────────────────┐ │
+                ┌─────────────┴┐  │  Slots:              │ │
+                │ shuma-daemon │  │   TopBar  (launcher) │ │
+                │  (admin sock)│  │   Main    (matilda…) │ │
+                │  + reaper    │  │   Drawer  [shell|…]  │ │
+                │  + Workspace │  │   BottomBar (cmdbar) │ │
+                │    Manager   │  └──────────────────────┘ │
+                └──────┬───────┘             │             │
+                       │                     ▼             │
+                       │           ┌─────────────────────┐ │
+                       │           │ shuma-module-shell  │ │
+                       │           │  ↓ (cuando se cablee)│ │
+                       │           │  shuma-exec / -line │ │
+                       │           │  -history / -session│ │
+                       │           └─────────────────────┘ │
+                       │           ┌─────────────────────┐ │
+                       │           │ shuma-module-matilda│ │
+                       │           │  → baremetal/matilda│ │
+                       │           └─────────────────────┘ │
+                       └────────────────────────────────────┘
+                       (vía shuma-protocol — hoy NO cableado
+                        desde el shell; el shell ejecuta local
+                        con sh -c, no via daemon)
+```
+
+**Puntos clave**:
+- El chasis es **static-dispatch**: enum `Kind { Launcher, CommandBar, Shell, Matilda }`. Agregar un módulo = variante + ramas en `update`/`view`. Sortea que `llimphi-ui` no tenga `View::map`.
+- Cada módulo expone `pub fn make(host) -> ...`; el binario `shuma-shell-llimphi` enlaza estáticamente y mapea `ModuleMsg → ShellMsg` con un cierre (`lift`).
+- El daemon, la CLI y el gateway son una **familia paralela** al chasis. El módulo `shuma-module-shell` ejecuta hoy directo con `sh -c` (no habla con el daemon).
+
+---
+
+## 3. Qué está hecho
+
+### 3.1 Chasis gráfico (`shuma-shell-llimphi`, 1 588 LOC)
+- **Layout completo**: TopBar, Main, BottomBar, Drawer-Quake (40 % altura por defecto), monitor stack con stat-cards + curvas (CPU, MEM + monitores aportados por módulos).
+- **Slots configurables** vía `shumarc-modules.toml` (`src/config.rs`): cualquier `id` no compilado se ignora con warning — el shumarc no rompe el arranque.
+- **Drawer**: toggle por F12, cerrar por Esc, click en command-bar abre. *Hover trigger pendiente* (`main.rs:40`: faltan enter/leave events en llimphi-ui).
+- **Toolbar de shortcuts** alimentada por `ModuleContributions` (declarativo).
+- **Resize del panel de monitores** con drag (splitter).
+- **i18n**: `rimay_localize::init()` en `main` — todas las cadenas vía `t("shuma-…")`.
+
+### 3.2 Daemon stack (`shuma-daemon` + `shuma-cli` + `shuma-gateway`)
+- **Protocolo** (`shuma-protocol`, 589 LOC): postcard sobre Unix socket; `Request`/`Response` con Workspace CRUD, Run one-shot, Pipeline, ExecStream, Discern, Health, Caps.
+- **Daemon** (1 279 LOC): `WorkspaceManager` (Mutex<HashMap>), reap cada 500 ms, drena pipelines en restart, persist a disco, sidecar pool opcional al broker `card_sidecar`.
+- **CLI** (`shuma`, 740 LOC): subcomandos `ping`, `health`, `caps`, `workspace {create|list|stop}`, `run`, `commands`, `discern`, `pipeline …`.
+- **Gateway HTTP** (168 LOC): `POST /rpc` con body JSON → postcard → daemon. Bind por env `SHIPOTE_GATEWAY_LISTEN`, default `127.0.0.1:7378`. Sin axum/hyper — parser ad-hoc.
+- **Noise_XK** (`shuma-link`, ~860 LOC): handshake, `KnownPeers` (allowlist tipo `authorized_keys`), `Keypair` X25519 en `~/.config/shuma/keys/identity.x25519`, `FramedChannel` (length-prefix + chacha20-poly1305). Listo para reemplazar Unix socket por TCP autenticado.
+- **Discern** (`shuma-discern`): pipeline configurable (MagicBytes → CardProbe → JsonProbe → TomlProbe → Utf8Probe).
+
+### 3.3 Stack matilda (`baremetal/`)
+- **`matilda-core`**: modelo declarativo (Host, Container, VHost, Inventory).
+- **`matilda-plan`**: diff inventario actual vs deseado → `Vec<Action>` ordenado.
+- **`matilda-discover`**: lee estado real (v1: por nombre — detecta creates y orphans, no cambios de config de un recurso existente).
+- **`matilda-config`**: `Container → docker run`, `VHost → server { … }` de nginx. Funciones puras.
+- **`matilda-apply`**: `Action → ApplyStep` (archivos + comandos), agnóstico de transporte.
+- **`matilda-ghost`**: ejecutor local (`set -e`), reporta `ApplyReport`.
+- **`matilda-linker`**: ejecutor SSH (sobre `brahman-ssh-multiplex`), mismo `ApplyReport`.
+- **`matilda-app`** (CLI standalone): `matilda example | plan | script | apply | dry-run` local y remoto.
+- **`shuma-module-matilda`** (1 120 LOC, **el módulo más completo**): tab del shell con inventario + plan + log + monitor de "pasos pendientes" + 3 shortcuts (Discover/Plan/Dry-run). Soporta `Source::Local` y `Source::Remote { host, user }` con SSH real. Recarga inventario desde el shumarc.
+
+### 3.4 Línea + ejecución sync (sandbox)
+Cinco crates listos pero **NO enchufados** al `shuma-module-shell` actual:
+- **`shuma-exec`** (PTY incluido): `Exec::{Direct, Shell, Pty}`, eventos por mpsc (`Stdout`/`Stderr`/`Bytes`/`Truncated`/`Spilled`/`Done`), capture-limit + spill a disco, splice(2) zero-copy.
+- **`shuma-line`**: tokenize + clasificación, `split_pipeline`, `complete` (con `flag_hints`), `ghost_suggestion`, `decorate_line` (paths clickeables, URLs, grep refs, SHA, `#NN`), `needs_continuation`, parser ANSI completo.
+- **`shuma-history`**: JSONL append-only, fuzzy con nucleo_matcher, dedup configurable.
+- **`shuma-session`**: WorkSession con cwd, `CommandRun` (estado + salida acotada), grupos guardados.
+- **`shuma-shell-render`**: CanvasPlan (lienzo de contexto del grafo de intenciones, agnóstico de UI).
+- **`shuma-remote-exec`**: cliente sync del subprotocolo `ExecStream` del daemon — API espejo de `shuma-exec::RunHandle`. Listo para reemplazar `sh -c` por *ejecución contra el daemon*.
+
+### 3.5 Estado actual del REPL (`shuma-module-shell`, ~2000 LOC)
+
+**Bloque A completo (2026-05-28).** El REPL ya es una pieza usable.
+
+- **A1** ejecución no bloqueante: streaming via `shuma-exec`, drenado por `Msg::ShellTick` a 100 ms. Cola si hay run vivo. Cancel = SIGKILL al grupo (`process_group(0)` + `killpg`).
+- **A2** decoración del output: `shuma_line::decorate_line` por línea; paths/URLs/grep-refs/issue/box-draw → `theme.accent`; git SHAs → `theme.fg_muted`.
+- **A3** input inteligente: `LineState` con tokens coloreados, cursor visible, ghost suggestion del historial. Tab completion (binarios en `$PATH` + paths bajo cwd + flag hints + prefijo común con N candidatos). ArrowRight al final acepta ghost. Ctrl+Arrow palabra, Home/End.
+- **A4** historial durable: JSONL en `$XDG_DATA_HOME/shuma/history.jsonl`. Up/Down navegan; Ctrl-R abre overlay `fuzzy_search`.
+- **A5** PTY + vt100: allowlist + prefijo `:tui` → `Exec::Pty`. `vt100::Parser` alimentado por bytes; render del panel = grid de celdas con `paint_with`. Teclas → xterm bytes.
+- **A6** resize dinámico del PTY: `shuma_exec::RunHandle::resize(rows, cols)` expuesto vía `MasterPty` en `Arc<Mutex<>>`; `tui_panel` painter publica el `PaintRect` en `state.last_tui_rect`; cada `drain_run` mira si cambió y manda `MasterPty::resize` + reescala el screen del `vt100::Parser`. vim/htop reciben SIGWINCH y reflowean.
+- **A7** click handlers en decoraciones: `Msg::OpenDecoration(DecorationKind)`. Path-dir → cd (más recálculo del `ShellSource`); Path-executable → llena el input con el path; Path-archivo / URL → `xdg-open` detached; GrepRef → `$EDITOR +line file`; GitSha → llena el input con `git show <sha>`. Render del output ahora es `FlexDirection::Row` con un nodo por span (los actionables llevan `on_click`).
+- **A8** paste + bracketed paste: Ctrl-V y Shift+Insert leen el clipboard (vía `arboard`). Sin TUI → `LineState::insert`. Con TUI → `RunHandle::write_input`; si el child habilitó bracketed paste (DECSET 2004, leído de `screen.bracketed_paste()`), la secuencia se envuelve en `\x1b[200~…\x1b[201~` para que vim/emacs distingan tipeo de pegado.
+- Builtins: `cd`, `pwd`, `clear`, `exit`. Tope 500 líneas en el buffer.
+- Tests: **33/33 verde** (timing del ejecutor, navegación de historial, tab/ghost/clicks/paste, build_spec routing, key→PTY bytes, palette ansi, partition_line, decoration handlers, PTY resize end-to-end con `stty size`).
+
+---
+
+## 4. Wawa — qué hay y qué falta
+
+`wawa-config` (en `shared/wawa-config`) es el **bus de configuración del SO wawa**: archivo JSON canónico (system: `/etc/wawa/config.json`, user: `$XDG_CONFIG_HOME/wawa/config.json`), watcher `notify` sobre ambos paths, atomic save (`tmp + rename`). Sin daemon pub-sub: las apps leen el archivo y se suscriben a cambios. Esto sobrevive a la transición Linux → arje (cuando wawa sea su propio SO, `system_path()` cambia, el resto no).
+
+Forma actual de la config:
+```json
+{
+  "theme_variant": "dark", "accent": "default",
+  "lang": "es-PE", "timefmt_24h": true,
+  "modules": { "mirada": true, "shuma": true, "chasqui": true,
+               "akasha": true, "minga": true, "agora": true }
+}
+```
+
+**Estado de la integración shuma ↔ wawa (2026-05-28): activa.**
+
+- `shuma-shell-llimphi::init` carga `WawaConfig::load()` + `theme_from_wawa(&wawa, &Theme::dark())` + `rimay_localize::set_locale(&wawa.lang)` antes de armar las instancias, así el primer render ya sale con el theme y locale correctos.
+- Un `wawa_config::ConfigWatcher` corre en background; cada cambio dispara `Msg::WawaConfigChanged(Box<WawaConfig>)` vía `Handle::dispatch`.
+- El handler re-arma `m.theme` con el nuevo variant/accent (fallback al theme actual si el variant es desconocido) y reinvoca `set_locale` — sin reiniciar el chasis, sin re-cargar las instancias. Los próximos `view()` ya pintan con la paleta nueva; los strings que viajan por `t(...)` también se rehidratan al cambiar.
+
+**Contrato dividido (D3):**
+
+- **`shumarc-modules.toml`** (TOML, project-local): topología de la UI del shell — qué módulo se monta en qué slot (TopBar/Main/BottomBar/Drawer), labels custom, Source (Local/Daemon/DaemonTcp/Remote). Esto es estructura de la app y vive con la app.
+- **`$XDG_CONFIG_HOME/wawa/config.json`** (JSON, perfil del usuario): preferencias visuales (`theme_variant`, `accent`), locale (`lang`), formato del reloj (`timefmt_24h`), bitmask de qué apps están on (`modules.{shuma, mirada, pluma, …}`). Esto es preferencia del usuario y es compartida por **todas** las apps Llimphi de gioser (pluma, dominium, cosmos, nada, nakui, shuma…).
+
+El toggle `modules.shuma = false` en el JSON wawa no apaga el binario corriendo (el chasis no se suicida); el efecto es que los launchers no listan a shuma como app activa. La supervisión del binario en sí es decisión del SO (wawa-init en el futuro arje, o systemd/manual hoy).
+
+---
+
+## 5. Plan propuesto (priorizado)
+
+### Bloque A — desbloquear el REPL  ✅ **completo (2026-05-28)**
+Ver §3.5 para el detalle del estado actual. Resumen:
+
+- A1 ✅ ejecución no bloqueante + cola + cancel SIGKILL al grupo
+- A2 ✅ decoración del output (paths/URLs/SHAs/grep-refs/issue/box-draw)
+- A3 ✅ LineState + tokens coloreados + Tab completion + ghost
+- A4 ✅ historial durable JSONL + Up/Down + Ctrl-R fuzzy overlay
+- A5 ✅ PTY + emulador vt100 (vía `vt100` crate) + render de grid
+- A6 ✅ resize dinámico del PTY (`RunHandle::resize` + tracking del PaintRect del panel)
+- A7 ✅ click handlers sobre decoraciones (Path/Url/GrepRef/GitSha)
+- A8 ✅ paste con bracketed paste (`arboard` + DECSET 2004)
+
+Pendientes opcionales (no bloquean nada):
+- Mouse en el PTY (vt100 ya parsea los eventos; falta cablear el mouse de Llimphi).
+- Tooltip "what would clicking this do?" en decoraciones (espera al hover en llimphi-ui).
+
+### Bloque B — integrar el daemon como ejecutor ✅ **completo (2026-05-28)**
+
+B1 ✅ **Runner enum local/daemon** en `shuma-module-shell`. `BackendHandle` envuelve `shuma_exec::RunHandle` y `shuma_remote_exec::RemoteRunHandle` con la misma API (`try_events`, `is_finished`, `kill`, `write_input`, `resize` — write/resize son no-op en remoto). `Source` extendido con variantes `Daemon { socket: Option<PathBuf>, label }` y `DaemonTcp { addr, server_pub_hex, label }`; `start_run` rutea según la variante. PTY siempre cae a local con notice (daemon no soporta PTY remoto).
+
+B2 ✅ **Source remoto via Noise XK**. `Source::DaemonTcp` consume `shuma_remote_exec::run_tcp`. Identidad X25519 del shell persiste vía `shuma_link::Keypair::load_or_generate(Keypair::default_path())` — primer arranque genera, después se reusa. `server_pub_hex` parseado con `PublicKey::from_hex`. Errores (no hay daemon, pubkey errónea) salen como notice en el output sin tumbar el shell.
+
+B3 ✅ **Sidecar broker en daemon**. `WorkspaceCreate` ahora llama a `pool.spawn(build_workspace_card(label, id))` cuando hay pool — cada workspace se publica al broker como `Card { kind: Ente, lifecycle: Daemon, flow: ["commands"] }` (paralelo a la `shuma.daemon` card que ya existía). `announce_edges_to_broker` para edges de pipeline ya estaba.
+
+### Bloque C — módulos placeholder ✅ **completo (2026-05-28)**
+
+C1 ✅ **launcher real con manifests**. `shuma-module-launcher` ahora lee `$XDG_CONFIG_HOME/shuma/apps/*.toml` (orden alfabético) en `State::from_apps_dir()`. Cada manifest es `{label, exec?, action_id?}`; si tiene `exec`, click → spawn detached (`process_group(0)`); si no, emite `Msg::EntryClicked(action_id)` al chasis. Si el dir no existe o no hay manifests válidos, cae a `State::demo()` para que el chasis siga exploratorio. Chasis llama a `from_apps_dir()` en lugar de `demo()`.
+
+C2 ✅ **commandbar real Cmd-P**. `shuma-module-commandbar` ahora trae catálogo de `CommandEntry { label, category, kind: FocusTab|Exec|Action }` provisionable vía `State::set_catalog`. Tipear filtra con `nucleo_matcher::Pattern::score`; Up/Down navegan; Enter activa (`activation_for(&state, &ev)` retorna `CommandKind`); Escape limpia; click en row → `ActivateAt(idx)`. Modo `Launcher` usa el catálogo, modo `Shell` ejecuta la línea tal cual (`CommandKind::Exec(text)`). Dropdown se muestra encima de la barra con hasta 8 matches.
+
+### Bloque D — wawa integration
+D1. Suscribir watcher `wawa-config` en `shuma-shell-llimphi::main`.
+D2. Reaccionar a cambios de `theme_variant`/`accent`/`lang` sin reiniciar (`Theme::for_variant` + `rimay_localize::set_lang`).
+D3. Documentar el contrato: el shumarc topología (qué módulos en qué slots) sigue siendo TOML aparte; el JSON wawa es para preferencias visuales y toggle de apps.
+
+### Bloque E — limpieza pendiente
+E1 ✅ `audit_request(peer: &str, req)` — Unix socket pasa `uid:1000` desde `SO_PEERCRED`; TCP autenticado pasa `pubkey:<16 hex>` (primeros 16 chars de la X25519 del peer).
+E2 ⏳ Hover trigger del drawer Quake — bloqueado por dispatching de `on_pointer_enter/leave` en `llimphi-ui` (WIP del usuario en curso; los campos y métodos públicos existen pero el runtime no los emite todavía).
+E3 ✅ Parser real de teclas en shumarc — `parse_binding` acepta `Ctrl+Shift+Space`, `Super+grave`, `Alt+F1`, etc. Modifiers: `Ctrl/Alt/Shift/Super` (con alias `Meta/Cmd/Win`). Named keys: F1..F24, Escape, Enter, Space, Tab, Backspace, Delete, Home, End, PageUp/Down, Arrows, Insert, grave. Tests cubren combos.
+E4 ✅ `shuma-module-canvas` consume el `SessionGraph` directo (layout in-tree para no arrastrar `pineal-render` al chasis).
+
+### Bloque F — features grandes (post-A/B)
+F1. Lienzo de contexto: panel adicional que renderice `shuma-intent::SessionGraph` con `shuma-shell-render::CanvasPlan`. El grafo `%cN`/`%pN` ya existe en `shuma-intent`; falta la UI y el parser de intents en la commandbar.
+F2. Job control en el módulo shell: `:jobs`, `:term`, `:stop`, `:cont`, sufijo `&` (`shuma-exec` ya soporta multi-run + kill).
+F3. Editor multi-línea: `shuma-line::continuation::needs_continuation` ya está; falta cablear al input.
+
+---
+
+## 6. Decisiones de diseño que conviene preservar
+
+1. **Static dispatch sobre trait objects**: `Kind` enum + `ModuleState` enum. Coste: una rama por módulo en `update`/`view`. Beneficio: cada módulo declara su `Msg` propio sin pelearse con `Box<dyn Any>` y sin downcast.
+2. **Sync por dentro, async sólo en bordes**: `shuma-exec`/`shuma-remote-exec` son sync (threads + mpsc); el daemon es tokio. El shell es sync — drena eventos en cada `Tick`. No tirar este patrón "porque tokio es lo moderno": Llimphi es sync.
+3. **El módulo no depende de `llimphi-ui` desde `shuma-module`**: sólo desde su crate concreto. Esto deja `shuma-module` (el contrato) testeable sin display.
+4. **El daemon ignora errores de bind si existe el socket** (`main.rs:30`): asume restart limpio. *Pendiente*: lockfile + check de PID vivo.
+5. **El gateway no usa axum**: parser HTTP ad-hoc en ~120 LOC. No agregar axum sólo para "ser idiomático" — un POST único no lo justifica.
+6. **Notación de slots** del shumarc: `[topbar]`, `[main]`, `[bottombar]`, `[[drawer.tabs]]`. Mantener — está documentada en `config.rs:1-44`.
+7. **`shuma-protocol::DEFAULT_SOCK_NAME = "shuma.sock"`** en `$XDG_RUNTIME_DIR`. No mover.
+
+---
+
+## 7. Trampas conocidas
+
+- **El binario `shuma-shell` GPUI (3.7k LOC) ya no existe** — se borró en `b92b643`. Cualquier referencia a "shuma-shell" en docs viejas es a esa versión. Las features grandes (completion, decoración, historial) viven en sandbox/* sueltas, no en un shell ensamblado.
+- **`russh v0.54.5`** dispara warning de future-incompat — no bloquea, llega vía `matilda-linker`.
+- **`gpui extinto en gioser** (memoria del proyecto): nada nuevo sobre GPUI. Todo gráfico es Llimphi.
+- **El módulo matilda en remoto SÍ ejecuta SSH real** (vía `matilda-linker`/`brahman-ssh-multiplex`); las pruebas reales necesitan un servidor con sshd alcanzable.
+- **`shuma-line::decorate` ya hace mucho** (paths clickeables, URLs, SHAs, grep refs) pero ningún consumidor lo usa hoy — fácil ganancia al cablearlo a `shuma-module-shell`.
+
+---
+
+## 8. Ranking de prioridad
+
+| # | Tarea | Ganancia | Costo |
+|---|-------|----------|-------|
+| ✅ | A1..A8 — bloque REPL extendido | shell completo | hecho 2026-05-28 |
+| ✅ | B1..B3 — daemon ejecutor + broker | shell remoto + observable | hecho 2026-05-28 |
+| ✅ | C1..C2 — launcher + commandbar reales | palette Cmd-P + apps | hecho 2026-05-28 |
+| ✅ | D1..D3 — wawa watcher + theme/lang live | preferencias unificadas | hecho 2026-05-28 |
+| ✅ | E1 E3 E4 — limpieza | SO_PEERCRED, parser bindings, lienzo | hecho 2026-05-28 |
+| ✅ | F2 F3 F1 — features grandes | jobs, multi-línea, lienzo | hecho 2026-05-28 |
+| ✅ | shell↔canvas live | runs del shell aparecen como `%cN` en el lienzo | hecho 2026-05-29 |
+| ⏳ | E2 — hover trigger drawer | requiere WIP llimphi-ui (pointer events) | bloqueado |
+
+**Integración shell↔canvas (2026-05-29).** `shuma-module-shell` mantiene
+su propio `SessionGraph` (campo `intent_graph` en `State`) y registra
+cada `start_run` como `%cN`. `drain_run` acumula bytes de
+stdout/stderr/raw y al cerrar el run llama `complete(id, ok, bytes)` —
+nodo verde si `exit 0`, rojo en cualquier otro caso (incluidos errores
+de spawn del backend remoto). Builtins (`cd`/`pwd`/`clear`/`exit` y los
+`:jobs/:term/:stop/:cont`) no entran al grafo. El chasis añadió
+`Kind::Canvas` con tab nuevo "Lienzo" en el drawer por defecto; cada
+`SHELL_TICK` (~100 ms) `sync_canvas_from_primary_shell` empuja el grafo
+del primer shell encontrado a todas las instancias canvas con
+`Msg::SyncGraph(graph)`. El lienzo refleja al instante el flujo de la
+sesión (3 tests nuevos en `shell` + 1 en `canvas`).
+
+**Adiós al Quake-drawer (2026-05-29, tercer bloque).** El chasis dejó
+de ser una imitación del launcher overlay y volvió a ser app standalone
+normal: tabs siempre visibles, sin F12, sin Esc-cierra-drawer, sin
+overlay absoluto sobre el escritorio. Eso vive en `pata` (antes en el
+retirado `mirada-launcher-llimphi`). Cambios concretos:
+
+- `Model`: `drawer_tabs` → `tabs`, `active_drawer_tab` → `active_tab`,
+  fuera `drawer_open` y `drawer_trigger`.
+- `Msg`: fuera `ToggleDrawer`, `CloseDrawer`, `SelectDrawerTab` →
+  queda `SelectTab(usize)`.
+- `Slot::DrawerTab(usize)` → `Slot::Tab(usize)`.
+- `on_key` ya no atrapa F12 ni Esc; `forward_key_to_focused_shell`
+  prioriza `Slot::Main` y cae al `tabs[active_tab]`.
+- `render_main_area` se simplificó: si el shumarc declara `[main]`,
+  ocupa todo el área (sin tabs ni monitores). Si no, tabs + splitter
+  con monitores a la derecha. No hay más `Position::Absolute` ni
+  capas overlay.
+- shumarc TOML: `[[drawer.tabs]]` → `[[tabs]]`, fuera `[drawer.trigger]`.
+- `parse_binding` + `matches_key` + `tests_bindings` (todo para
+  reconocer el shortcut de toggle drawer) borrados.
+- i18n: `shuma-empty-no-drawer-tabs`/`-compat` renombrados a
+  `shuma-empty-no-tabs`/`-compat`; `shuma-empty-no-main` y el hint que
+  mencionaba "F12 abre el drawer" eliminados.
+
+**Canvas clickeable (2026-05-29, segundo bloque).** Las cajas del
+lienzo responden al click vía `on_click_at` + `hit_test_box`: el
+primer click enfoca el `%cN` (borde 3.5 px en lugar de 2.0), el
+segundo desenfoca, y un click en vacío también desenfoca. Cuando hay
+un nodo enfocado aparece una tira inferior con la intención completa,
+status y bytes; al lado, dos botones "Insertar %cN" / "Insertar %pN"
+emiten `Msg::InsertRef(text)`. El chasis intercepta esta variante
+(`apply_module_msg` antes de routear al canvas), busca el primer
+`Shell` con `first_shell_slot`, abre+enfoca el drawer si está en una
+tab, y le manda `Msg::InsertAtCursor(text)` al shell. El shell inserta
+en la posición actual del cursor del `LineState`, cierra el overlay
+Ctrl-R si estaba abierto y deja el cursor justo después del texto.
+`SyncGraph` ahora limpia `focused` si el nodo desapareció del snapshot
+nuevo (evita detalle stale). 6 tests nuevos en canvas + 1 en shell.
+
+Pendientes opcionales restantes:
+- Mouse en el PTY (vt100 ya parsea; falta cablear el mouse de Llimphi).
+- Tooltip "what would clicking this do?" en decoraciones (espera al hover de llimphi-ui).
+
+---
+
+## 9. Comandos útiles para retomar
+
+```bash
+# Compilar todo el subárbol shuma
+cargo build -p shuma-shell-llimphi -p shuma-daemon -p shuma-cli -p shuma-gateway
+
+# Probar el chasis (necesita servidor gráfico Llimphi)
+cargo run -p shuma-shell-llimphi
+
+# Daemon + CLI rápida
+cargo run -p shuma-daemon &
+cargo run -p shuma-cli -- health
+
+# Gateway HTTP
+SHIPOTE_GATEWAY_LISTEN=127.0.0.1:7378 cargo run -p shuma-gateway
+
+# Estado de los crates sandbox (sin app que los consuma)
+wc -l 02_ruway/shuma/sandbox/*/src/*.rs
+```
+
+---
+
+*Generado por Claude (Opus 4.7) — `2026-05-27`. Si el plan cambia, actualizá la tabla de la §8 antes de tocar la §3.*
diff --git a/02_ruway/shuma/baremetal/matilda-app/Cargo.toml b/02_ruway/shuma/baremetal/matilda-app/Cargo.toml
new file mode 100644
index 0000000..8fffe1a
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-app/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "matilda"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "matilda — CLI de administración de servidores: carga un inventario, muestra el plan, emite el script y lo aplica (local, remoto por SSH, o en seco)."
+
+[[bin]]
+name = "matilda"
+path = "src/main.rs"
+
+[dependencies]
+matilda-core = { path = "../matilda-core" }
+matilda-config = { path = "../matilda-config" }
+matilda-plan = { path = "../matilda-plan" }
+matilda-apply = { path = "../matilda-apply" }
+matilda-ghost = { path = "../matilda-ghost" }
+matilda-linker = { path = "../matilda-linker" }
+matilda-discover = { path = "../matilda-discover" }
+clap = { workspace = true }
+serde_json = { workspace = true }
+tokio = { workspace = true }
diff --git a/02_ruway/shuma/baremetal/matilda-app/LEEME.md b/02_ruway/shuma/baremetal/matilda-app/LEEME.md
new file mode 100644
index 0000000..2051f13
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-app/LEEME.md
@@ -0,0 +1,16 @@
+# matilda-app
+
+> CLI/UI de [matilda](../../README.md).
+
+Comandos: `matilda discover`, `matilda plan`, `matilda apply`, `matilda ghost`, `matilda link`. UI Llimphi opcional para review del plan antes de aplicar.
+
+## Uso
+
+```sh
+cargo run --release -p matilda-app -- apply
+```
+
+## Deps
+
+- Todos los `matilda-*`
+- `clap`, opcional [`llimphi-ui`](../../../llimphi/)
diff --git a/02_ruway/shuma/baremetal/matilda-app/README.md b/02_ruway/shuma/baremetal/matilda-app/README.md
new file mode 100644
index 0000000..397bd56
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-app/README.md
@@ -0,0 +1,16 @@
+# matilda-app
+
+> CLI/UI of [matilda](../../README.md).
+
+Commands: `matilda discover`, `matilda plan`, `matilda apply`, `matilda ghost`, `matilda link`. Optional Llimphi UI for plan review before applying.
+
+## Usage
+
+```sh
+cargo run --release -p matilda-app -- apply
+```
+
+## Deps
+
+- All `matilda-*`
+- `clap`, optional [`llimphi-ui`](../../../llimphi/)
diff --git a/02_ruway/shuma/baremetal/matilda-app/src/main.rs b/02_ruway/shuma/baremetal/matilda-app/src/main.rs
new file mode 100644
index 0000000..80cd944
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-app/src/main.rs
@@ -0,0 +1,238 @@
+//! `matilda` — CLI de administración de servidores.
+//!
+//! Carga un inventario declarativo (JSON), lo reconcilia contra el
+//! estado actual y aplica los cambios — localmente, en seco, o en un
+//! servidor remoto por SSH:
+//!
+//! ```text
+//!   matilda example                 imprime un inventario de ejemplo
+//!   matilda plan    inv.json        muestra el plan de reconciliación
+//!   matilda script  inv.json        emite el script de aplicación
+//!   matilda apply   inv.json        aplica localmente
+//!   matilda apply   inv.json --dry-run            simula
+//!   matilda apply   inv.json --host deploy@srv    aplica por SSH
+//! ```
+
+use std::path::PathBuf;
+use std::process::ExitCode;
+
+use clap::{Parser, Subcommand};
+use matilda_apply::{plan_to_steps, steps_to_script, ApplyStep};
+use matilda_core::{Container, Host, Inventory, RestartPolicy, VHost};
+use matilda_ghost::ApplyReport;
+use matilda_linker::{Linker, SshAuth, SshConfig};
+use matilda_plan::{plan, Op};
+
+#[derive(Parser)]
+#[command(name = "matilda", about = "Administración declarativa de servidores")]
+struct Cli {
+    #[command(subcommand)]
+    cmd: Cmd,
+}
+
+#[derive(Subcommand)]
+enum Cmd {
+    /// Imprime un inventario de ejemplo para editar.
+    Example,
+    /// Muestra el plan de reconciliación del inventario.
+    Plan {
+        inventory: PathBuf,
+        /// Estado actual del servidor (por defecto: vacío).
+        #[arg(long)]
+        current: Option<PathBuf>,
+        /// Descubre el estado actual de esta máquina (docker + nginx).
+        #[arg(long)]
+        discover: bool,
+    },
+    /// Emite el script de shell que aplicaría el plan.
+    Script {
+        inventory: PathBuf,
+        #[arg(long)]
+        current: Option<PathBuf>,
+        #[arg(long)]
+        discover: bool,
+    },
+    /// Aplica el plan: local, en seco, o remoto por SSH.
+    Apply {
+        inventory: PathBuf,
+        #[arg(long)]
+        current: Option<PathBuf>,
+        /// Descubre el estado actual de esta máquina antes de reconciliar.
+        #[arg(long)]
+        discover: bool,
+        /// Simula sin tocar nada.
+        #[arg(long)]
+        dry_run: bool,
+        /// Aplica en un host remoto, `usuario@host`.
+        #[arg(long)]
+        host: Option<String>,
+        /// Contraseña SSH (si no se da, se usa la clave por defecto).
+        #[arg(long)]
+        password: Option<String>,
+    },
+}
+
+/// Carga un inventario JSON desde un archivo.
+fn load(path: &PathBuf) -> Result<Inventory, String> {
+    let text = std::fs::read_to_string(path)
+        .map_err(|e| format!("no se pudo leer {}: {e}", path.display()))?;
+    serde_json::from_str(&text).map_err(|e| format!("JSON inválido en {}: {e}", path.display()))
+}
+
+/// Resuelve el inventario "actual" contra el que reconciliar:
+/// `--discover` observa esta máquina; `--current` lee un archivo; si no,
+/// se parte de un inventario vacío (todo es creación).
+fn current_inventory(
+    discover: bool,
+    current: &Option<PathBuf>,
+    desired: &Inventory,
+) -> Result<Inventory, String> {
+    if discover {
+        // Descubrimiento detallado: `docker inspect` detecta el drift.
+        Ok(matilda_discover::discover_inventory(desired))
+    } else {
+        match current {
+            Some(p) => load(p),
+            None => Ok(Inventory::new()),
+        }
+    }
+}
+
+/// Construye un inventario de ejemplo.
+fn example_inventory() -> Inventory {
+    let mut inv = Inventory::new();
+    inv.add_host(Host::new("edge-1", "10.0.0.1").with_tag("prod"));
+    inv.add_container(
+        Container::new("web", "nginx:1.27")
+            .with_port(8080, 80)
+            .with_volume("/srv/site", "/usr/share/nginx/html")
+            .with_restart(RestartPolicy::Always),
+    );
+    inv.add_container(
+        Container::new("api", "ghcr.io/ejemplo/api:1.0")
+            .with_port(9000, 9000)
+            .with_env("DATABASE_URL", "postgres://db/app")
+            .with_restart(RestartPolicy::UnlessStopped),
+    );
+    inv.add_vhost(
+        VHost::to_container("sitio.com", "web", 80)
+            .with_alias("www.sitio.com")
+            .with_tls(),
+    );
+    inv
+}
+
+/// Imprime un `ApplyReport` legible.
+fn print_report(report: &ApplyReport) {
+    for r in &report.results {
+        println!("\n{} {}", if r.ok { "✔" } else { "✘" }, r.describe);
+        for l in &r.log {
+            println!("   {l}");
+        }
+    }
+    println!(
+        "\n{} de {} pasos aplicados.",
+        report.applied(),
+        report.results.len()
+    );
+    if !report.all_ok() {
+        println!("✘ se detuvo en el primer error.");
+    }
+}
+
+/// Aplica los pasos en un host remoto por SSH.
+async fn apply_remote(
+    target: &str,
+    password: Option<String>,
+    steps: &[ApplyStep],
+) -> Result<ApplyReport, String> {
+    let (user, host) = target
+        .split_once('@')
+        .ok_or_else(|| format!("host inválido (esperaba usuario@host): {target}"))?;
+    let auth = match password {
+        Some(pw) => SshAuth::Password(pw),
+        None => {
+            let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
+            SshAuth::Key {
+                path: PathBuf::from(format!("{home}/.ssh/id_ed25519")),
+                passphrase: None,
+            }
+        }
+    };
+    let config = SshConfig::new(host, user, auth);
+    let linker = Linker::connect(&config)
+        .await
+        .map_err(|e| format!("conexión SSH: {e}"))?;
+    Ok(linker.apply(steps).await)
+}
+
+fn run() -> Result<(), String> {
+    match Cli::parse().cmd {
+        Cmd::Example => {
+            let json = serde_json::to_string_pretty(&example_inventory())
+                .map_err(|e| e.to_string())?;
+            println!("{json}");
+        }
+
+        Cmd::Plan { inventory, current, discover } => {
+            let desired = load(&inventory)?;
+            let p = plan(&current_inventory(discover, &current, &desired)?, &desired);
+            if p.is_empty() {
+                println!("Sin cambios: el servidor ya está al día.");
+            } else {
+                for (i, action) in p.actions.iter().enumerate() {
+                    println!("{:>2}. {}", i + 1, action.describe());
+                }
+                println!(
+                    "\n{} acciones — {} crear, {} actualizar, {} eliminar.",
+                    p.len(),
+                    p.count(Op::Create),
+                    p.count(Op::Update),
+                    p.count(Op::Remove),
+                );
+            }
+        }
+
+        Cmd::Script { inventory, current, discover } => {
+            let desired = load(&inventory)?;
+            let p = plan(&current_inventory(discover, &current, &desired)?, &desired);
+            print!("{}", steps_to_script(&plan_to_steps(&p, &desired)));
+        }
+
+        Cmd::Apply { inventory, current, discover, dry_run, host, password } => {
+            let desired = load(&inventory)?;
+            let p = plan(&current_inventory(discover, &current, &desired)?, &desired);
+            let steps = plan_to_steps(&p, &desired);
+            if steps.is_empty() {
+                println!("Sin cambios: nada que aplicar.");
+                return Ok(());
+            }
+            let report = if dry_run {
+                println!("— simulación (no se toca nada) —");
+                matilda_ghost::dry_run(&steps)
+            } else if let Some(target) = host {
+                println!("— aplicando en {target} por SSH —");
+                let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?;
+                rt.block_on(apply_remote(&target, password, &steps))?
+            } else {
+                println!("— aplicando localmente —");
+                matilda_ghost::apply(&steps)
+            };
+            print_report(&report);
+            if !report.all_ok() {
+                return Err("la aplicación falló".into());
+            }
+        }
+    }
+    Ok(())
+}
+
+fn main() -> ExitCode {
+    match run() {
+        Ok(()) => ExitCode::SUCCESS,
+        Err(e) => {
+            eprintln!("error: {e}");
+            ExitCode::FAILURE
+        }
+    }
+}
diff --git a/02_ruway/shuma/baremetal/matilda-apply/Cargo.toml b/02_ruway/shuma/baremetal/matilda-apply/Cargo.toml
new file mode 100644
index 0000000..b1f903d
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-apply/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "matilda-apply"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "matilda — puente plan→ejecución: traduce un Plan de reconciliación a pasos concretos (archivos a escribir + comandos a correr) listos para aplicar en el servidor."
+
+[dependencies]
+matilda-core = { path = "../matilda-core" }
+matilda-plan = { path = "../matilda-plan" }
+matilda-config = { path = "../matilda-config" }
+serde = { workspace = true }
diff --git a/02_ruway/shuma/baremetal/matilda-apply/LEEME.md b/02_ruway/shuma/baremetal/matilda-apply/LEEME.md
new file mode 100644
index 0000000..0429010
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-apply/LEEME.md
@@ -0,0 +1,10 @@
+# matilda-apply
+
+> Ejecutor del plan de [matilda](../../README.md).
+
+Aplica `Vec<Action>` de [`matilda-plan`](../matilda-plan/README.md). Cada action loguea + revertible. Para checks duros pide confirmación interactiva.
+
+## Deps
+
+- [`matilda-plan`](../matilda-plan/README.md)
+- `tokio`
diff --git a/02_ruway/shuma/baremetal/matilda-apply/README.md b/02_ruway/shuma/baremetal/matilda-apply/README.md
new file mode 100644
index 0000000..d163512
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-apply/README.md
@@ -0,0 +1,10 @@
+# matilda-apply
+
+> Plan executor of [matilda](../../README.md).
+
+Applies `Vec<Action>` from [`matilda-plan`](../matilda-plan/README.md). Each action is logged + revertible. For hard checks, asks for interactive confirmation.
+
+## Deps
+
+- [`matilda-plan`](../matilda-plan/README.md)
+- `tokio`
diff --git a/02_ruway/shuma/baremetal/matilda-apply/src/lib.rs b/02_ruway/shuma/baremetal/matilda-apply/src/lib.rs
new file mode 100644
index 0000000..353c08f
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-apply/src/lib.rs
@@ -0,0 +1,208 @@
+//! `matilda-apply` — el puente entre el plan y la ejecución real.
+//!
+//! `matilda-plan` dice *qué* cambiar (una lista ordenada de `Action`s).
+//! Este crate dice *cómo*: traduce cada acción a un [`ApplyStep`]
+//! concreto — los archivos a escribir en el servidor y los comandos a
+//! correr, en orden.
+//!
+//! Sigue siendo **agnóstico de transporte**: no abre conexiones ni
+//! ejecuta nada. Aplicar los pasos —localmente, por SSH o vía el agente
+//! `matilda-ghost`— es trabajo de la capa de I/O. Aquí todo es una
+//! función pura y testeable.
+
+#![forbid(unsafe_code)]
+
+use matilda_config::{docker_run_command, nginx_server_block};
+use matilda_core::Inventory;
+use matilda_plan::{Op, Plan, Resource};
+use serde::{Deserialize, Serialize};
+
+/// Directorio donde matilda deja los `server` de nginx.
+const NGINX_SITES: &str = "/etc/nginx/sites-enabled";
+
+/// Un archivo a escribir en el servidor.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct FileWrite {
+    pub path: String,
+    pub content: String,
+}
+
+/// Un paso de aplicación: la traducción concreta de una acción del plan.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ApplyStep {
+    /// Descripción legible de la acción de origen.
+    pub describe: String,
+    /// Archivos a escribir en el servidor (antes de los comandos).
+    pub files: Vec<FileWrite>,
+    /// Comandos de shell a ejecutar, en orden.
+    pub commands: Vec<String>,
+}
+
+/// Ruta del archivo `server` de un dominio.
+fn vhost_path(domain: &str) -> String {
+    format!("{NGINX_SITES}/{domain}.conf")
+}
+
+/// Traduce un plan a pasos concretos de aplicación.
+///
+/// Necesita el inventario **deseado** para conocer los detalles de cada
+/// recurso (imagen del contenedor, upstream del vhost). Las acciones
+/// sobre *hosts* no producen pasos: un host es a qué servidor conectarse,
+/// no algo que se "aplique" en él.
+pub fn plan_to_steps(plan: &Plan, desired: &Inventory) -> Vec<ApplyStep> {
+    let mut steps = Vec::new();
+    for action in &plan.actions {
+        let describe = action.describe();
+        let step = match (action.op, action.resource) {
+            // --- Contenedores ---
+            (Op::Create, Resource::Container) => desired
+                .container(&action.name)
+                .map(|c| ApplyStep {
+                    describe,
+                    files: Vec::new(),
+                    commands: vec![docker_run_command(c)],
+                }),
+            (Op::Update, Resource::Container) => desired.container(&action.name).map(|c| {
+                ApplyStep {
+                    describe,
+                    files: Vec::new(),
+                    // Recrear: quitar el viejo, lanzar el nuevo.
+                    commands: vec![
+                        format!("docker rm -f {}", action.name),
+                        docker_run_command(c),
+                    ],
+                }
+            }),
+            (Op::Remove, Resource::Container) => Some(ApplyStep {
+                describe,
+                files: Vec::new(),
+                commands: vec![format!("docker rm -f {}", action.name)],
+            }),
+
+            // --- VHosts ---
+            (Op::Create | Op::Update, Resource::VHost) => {
+                desired.vhost(&action.name).map(|v| ApplyStep {
+                    describe,
+                    files: vec![FileWrite {
+                        path: vhost_path(&action.name),
+                        content: nginx_server_block(v),
+                    }],
+                    commands: vec!["nginx -t && nginx -s reload".to_string()],
+                })
+            }
+            (Op::Remove, Resource::VHost) => Some(ApplyStep {
+                describe,
+                files: Vec::new(),
+                commands: vec![
+                    format!("rm -f {}", vhost_path(&action.name)),
+                    "nginx -t && nginx -s reload".to_string(),
+                ],
+            }),
+
+            // --- Hosts: no se "aplican" (son destino de conexión) ---
+            (_, Resource::Host) => None,
+        };
+        if let Some(step) = step {
+            steps.push(step);
+        }
+    }
+    steps
+}
+
+/// Vuelca los pasos a un script de shell único — útil para revisarlo, o
+/// para ejecutarlo de un tirón en el servidor. Los archivos se emiten
+/// como heredocs.
+pub fn steps_to_script(steps: &[ApplyStep]) -> String {
+    let mut out = String::from("#!/usr/bin/env bash\nset -euo pipefail\n");
+    for step in steps {
+        out.push_str(&format!("\n# {}\n", step.describe));
+        for f in &step.files {
+            out.push_str(&format!("cat > {} <<'MATILDA_EOF'\n", f.path));
+            out.push_str(&f.content);
+            if !f.content.ends_with('\n') {
+                out.push('\n');
+            }
+            out.push_str("MATILDA_EOF\n");
+        }
+        for cmd in &step.commands {
+            out.push_str(cmd);
+            out.push('\n');
+        }
+    }
+    out
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use matilda_core::{Container, VHost};
+
+    fn desired() -> Inventory {
+        let mut inv = Inventory::new();
+        inv.add_container(Container::new("web", "nginx:1.27").with_port(8080, 80));
+        inv.add_vhost(VHost::to_container("site.com", "web", 8080));
+        inv
+    }
+
+    #[test]
+    fn fresh_inventory_produces_create_steps() {
+        let steps = plan_to_steps(&matilda_plan::plan(&Inventory::new(), &desired()), &desired());
+        assert_eq!(steps.len(), 2); // un contenedor + un vhost
+        // El contenedor se crea con `docker run`.
+        assert!(steps[0].commands[0].starts_with("docker run -d --name web"));
+        // El vhost escribe su archivo y recarga nginx.
+        assert_eq!(steps[1].files.len(), 1);
+        assert!(steps[1].files[0].path.ends_with("site.com.conf"));
+        assert!(steps[1].commands[0].contains("nginx -s reload"));
+    }
+
+    #[test]
+    fn update_recreates_the_container() {
+        let mut current = Inventory::new();
+        current.add_container(Container::new("web", "nginx:1.25"));
+        let steps = plan_to_steps(&matilda_plan::plan(&current, &desired()), &desired());
+        let cont = steps.iter().find(|s| s.describe.contains("contenedor")).unwrap();
+        assert_eq!(cont.commands[0], "docker rm -f web");
+        assert!(cont.commands[1].starts_with("docker run"));
+    }
+
+    #[test]
+    fn removal_steps_clean_up() {
+        let mut current = Inventory::new();
+        current.add_container(Container::new("viejo", "img"));
+        current.add_vhost(VHost::to_address("viejo.com", "1.2.3.4:80"));
+        let steps = plan_to_steps(&matilda_plan::plan(&current, &Inventory::new()), &Inventory::new());
+        let cmds: Vec<&str> = steps
+            .iter()
+            .flat_map(|s| s.commands.iter())
+            .map(|s| s.as_str())
+            .collect();
+        assert!(cmds.iter().any(|c| c.contains("docker rm -f viejo")));
+        assert!(cmds.iter().any(|c| c.contains("rm -f") && c.contains("viejo.com")));
+    }
+
+    #[test]
+    fn host_actions_produce_no_steps() {
+        let mut desired = Inventory::new();
+        desired.add_host(matilda_core::Host::new("edge", "10.0.0.1"));
+        let steps = plan_to_steps(&matilda_plan::plan(&Inventory::new(), &desired), &desired);
+        assert!(steps.is_empty());
+    }
+
+    #[test]
+    fn empty_plan_yields_no_steps() {
+        let inv = desired();
+        let steps = plan_to_steps(&matilda_plan::plan(&inv, &inv.clone()), &inv);
+        assert!(steps.is_empty());
+    }
+
+    #[test]
+    fn script_emits_heredocs_and_commands() {
+        let steps = plan_to_steps(&matilda_plan::plan(&Inventory::new(), &desired()), &desired());
+        let script = steps_to_script(&steps);
+        assert!(script.starts_with("#!/usr/bin/env bash"));
+        assert!(script.contains("docker run -d --name web"));
+        assert!(script.contains("cat > /etc/nginx/sites-enabled/site.com.conf <<'MATILDA_EOF'"));
+        assert!(script.contains("MATILDA_EOF"));
+    }
+}
diff --git a/02_ruway/shuma/baremetal/matilda-config/Cargo.toml b/02_ruway/shuma/baremetal/matilda-config/Cargo.toml
new file mode 100644
index 0000000..70f5434
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-config/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "matilda-config"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "matilda — renderizado de configuración: del modelo declarativo a comandos docker run, servicios docker-compose y bloques server de nginx."
+
+[dependencies]
+matilda-core = { path = "../matilda-core" }
diff --git a/02_ruway/shuma/baremetal/matilda-config/LEEME.md b/02_ruway/shuma/baremetal/matilda-config/LEEME.md
new file mode 100644
index 0000000..72fb835
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-config/LEEME.md
@@ -0,0 +1,9 @@
+# matilda-config
+
+> Loader de archivos de [matilda](../../README.md).
+
+Lee `matilda.toml` + includes, resuelve variables, valida contra el schema de [`matilda-core`](../matilda-core/README.md).
+
+## Deps
+
+- [`matilda-core`](../matilda-core/README.md), `toml`
diff --git a/02_ruway/shuma/baremetal/matilda-config/README.md b/02_ruway/shuma/baremetal/matilda-config/README.md
new file mode 100644
index 0000000..7da49c1
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-config/README.md
@@ -0,0 +1,9 @@
+# matilda-config
+
+> File loader of [matilda](../../README.md).
+
+Reads `matilda.toml` + includes, resolves variables, validates against [`matilda-core`](../matilda-core/README.md)'s schema.
+
+## Deps
+
+- [`matilda-core`](../matilda-core/README.md), `toml`
diff --git a/02_ruway/shuma/baremetal/matilda-config/src/docker.rs b/02_ruway/shuma/baremetal/matilda-config/src/docker.rs
new file mode 100644
index 0000000..0259ef7
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-config/src/docker.rs
@@ -0,0 +1,105 @@
+//! Renderizado de un [`Container`] a Docker — `docker run` y compose.
+
+use matilda_core::Container;
+
+/// Comando `docker run` de un contenedor, en una sola línea. El orden de
+/// los flags es fijo (determinista): `-d --name --restart -p -e -v img`.
+pub fn docker_run_command(c: &Container) -> String {
+    let mut parts: Vec<String> = vec![
+        "docker".into(),
+        "run".into(),
+        "-d".into(),
+        "--name".into(),
+        c.name.clone(),
+        "--restart".into(),
+        c.restart.docker_flag().into(),
+    ];
+    for p in &c.ports {
+        parts.push("-p".into());
+        parts.push(format!("{}:{}", p.host, p.container));
+    }
+    for (k, v) in &c.env {
+        parts.push("-e".into());
+        parts.push(format!("{k}={v}"));
+    }
+    for (host, container) in &c.volumes {
+        parts.push("-v".into());
+        parts.push(format!("{host}:{container}"));
+    }
+    parts.push(c.image.clone());
+    parts.join(" ")
+}
+
+/// Bloque de servicio para un `docker-compose.yml`. Viene indentado para
+/// colocarse tal cual bajo la clave `services:`.
+pub fn compose_service(c: &Container) -> String {
+    let mut out = String::new();
+    out.push_str(&format!("  {}:\n", c.name));
+    out.push_str(&format!("    image: {}\n", c.image));
+    out.push_str(&format!("    restart: {}\n", c.restart.docker_flag()));
+    if !c.ports.is_empty() {
+        out.push_str("    ports:\n");
+        for p in &c.ports {
+            out.push_str(&format!("      - \"{}:{}\"\n", p.host, p.container));
+        }
+    }
+    if !c.env.is_empty() {
+        out.push_str("    environment:\n");
+        for (k, v) in &c.env {
+            out.push_str(&format!("      - {k}={v}\n"));
+        }
+    }
+    if !c.volumes.is_empty() {
+        out.push_str("    volumes:\n");
+        for (host, container) in &c.volumes {
+            out.push_str(&format!("      - {host}:{container}\n"));
+        }
+    }
+    out
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use matilda_core::RestartPolicy;
+
+    fn sample() -> Container {
+        Container::new("web", "nginx:1.27")
+            .with_port(8080, 80)
+            .with_env("TZ", "America/Caracas")
+            .with_volume("/srv/web", "/usr/share/nginx/html")
+            .with_restart(RestartPolicy::Always)
+    }
+
+    #[test]
+    fn run_command_has_all_flags() {
+        let cmd = docker_run_command(&sample());
+        assert!(cmd.starts_with("docker run -d --name web --restart always"));
+        assert!(cmd.contains("-p 8080:80"));
+        assert!(cmd.contains("-e TZ=America/Caracas"));
+        assert!(cmd.contains("-v /srv/web:/usr/share/nginx/html"));
+        assert!(cmd.ends_with("nginx:1.27"));
+    }
+
+    #[test]
+    fn run_command_is_deterministic() {
+        assert_eq!(docker_run_command(&sample()), docker_run_command(&sample()));
+    }
+
+    #[test]
+    fn compose_service_indents_under_services() {
+        let yaml = compose_service(&sample());
+        assert!(yaml.contains("  web:\n"));
+        assert!(yaml.contains("    image: nginx:1.27\n"));
+        assert!(yaml.contains("    restart: always\n"));
+        assert!(yaml.contains("      - \"8080:80\"\n"));
+    }
+
+    #[test]
+    fn minimal_container_omits_empty_sections() {
+        let yaml = compose_service(&Container::new("bare", "alpine"));
+        assert!(!yaml.contains("ports:"));
+        assert!(!yaml.contains("environment:"));
+        assert!(!yaml.contains("volumes:"));
+    }
+}
diff --git a/02_ruway/shuma/baremetal/matilda-config/src/lib.rs b/02_ruway/shuma/baremetal/matilda-config/src/lib.rs
new file mode 100644
index 0000000..6682e08
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-config/src/lib.rs
@@ -0,0 +1,63 @@
+//! `matilda-config` — del modelo declarativo a archivos de configuración.
+//!
+//! Funciones puras: toman un tipo de `matilda-core` y devuelven el texto
+//! de configuración listo para escribir en el servidor. No tocan disco
+//! ni Docker — sólo construyen strings, así que cada salida es testeable
+//! y determinista.
+//!
+//! - [`docker`] — `Container` → `docker run` / servicio docker-compose.
+//! - [`nginx`] — `VHost` → bloque `server` de nginx.
+
+#![forbid(unsafe_code)]
+
+pub mod docker;
+pub mod nginx;
+
+pub use docker::{compose_service, docker_run_command};
+pub use nginx::nginx_server_block;
+
+use matilda_core::Inventory;
+
+/// Renderiza el `docker-compose.yml` completo de un inventario.
+pub fn compose_file(inv: &Inventory) -> String {
+    let mut out = String::from("services:\n");
+    for c in inv.containers() {
+        out.push_str(&compose_service(c));
+    }
+    out
+}
+
+/// Renderiza el archivo de sites de nginx — un bloque `server` por
+/// vhost, separados por una línea en blanco.
+pub fn nginx_sites(inv: &Inventory) -> String {
+    inv.vhosts()
+        .map(nginx_server_block)
+        .collect::<Vec<_>>()
+        .join("\n")
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use matilda_core::{Container, VHost};
+
+    #[test]
+    fn compose_file_lists_every_container() {
+        let mut inv = Inventory::new();
+        inv.add_container(Container::new("web", "nginx"));
+        inv.add_container(Container::new("db", "postgres:16"));
+        let yaml = compose_file(&inv);
+        assert!(yaml.starts_with("services:\n"));
+        assert!(yaml.contains("  web:\n") && yaml.contains("  db:\n"));
+    }
+
+    #[test]
+    fn nginx_sites_renders_every_vhost() {
+        let mut inv = Inventory::new();
+        inv.add_vhost(VHost::to_container("a.com", "web", 80));
+        inv.add_vhost(VHost::to_container("b.com", "web", 80));
+        let conf = nginx_sites(&inv);
+        assert!(conf.contains("server_name a.com;"));
+        assert!(conf.contains("server_name b.com;"));
+    }
+}
diff --git a/02_ruway/shuma/baremetal/matilda-config/src/nginx.rs b/02_ruway/shuma/baremetal/matilda-config/src/nginx.rs
new file mode 100644
index 0000000..e2a1425
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-config/src/nginx.rs
@@ -0,0 +1,94 @@
+//! Renderizado de un [`VHost`] a un bloque `server` de nginx.
+
+use matilda_core::{Upstream, VHost};
+
+/// URL de `proxy_pass` para un upstream. Un contenedor se referencia por
+/// su nombre, que la red de Docker resuelve a su IP interna.
+fn proxy_target(upstream: &Upstream) -> String {
+    match upstream {
+        Upstream::Address(addr) => format!("http://{addr}"),
+        Upstream::Container { name, port } => format!("http://{name}:{port}"),
+    }
+}
+
+/// Renderiza el `server` de nginx de un vhost. Con TLS emite dos
+/// bloques: el `:443 ssl` y un `:80` que redirige a HTTPS.
+pub fn nginx_server_block(v: &VHost) -> String {
+    let names: Vec<&str> = std::iter::once(v.domain.as_str())
+        .chain(v.aliases.iter().map(|s| s.as_str()))
+        .collect();
+    let server_name = names.join(" ");
+    let target = proxy_target(&v.upstream);
+
+    let mut out = String::new();
+    if v.tls {
+        // Redirección :80 → :443.
+        out.push_str("server {\n");
+        out.push_str("    listen 80;\n");
+        out.push_str(&format!("    server_name {server_name};\n"));
+        out.push_str("    return 301 https://$host$request_uri;\n");
+        out.push_str("}\n\n");
+
+        out.push_str("server {\n");
+        out.push_str("    listen 443 ssl;\n");
+        out.push_str(&format!("    server_name {server_name};\n"));
+        out.push_str(&format!(
+            "    ssl_certificate /etc/letsencrypt/live/{}/fullchain.pem;\n",
+            v.domain
+        ));
+        out.push_str(&format!(
+            "    ssl_certificate_key /etc/letsencrypt/live/{}/privkey.pem;\n",
+            v.domain
+        ));
+    } else {
+        out.push_str("server {\n");
+        out.push_str("    listen 80;\n");
+        out.push_str(&format!("    server_name {server_name};\n"));
+    }
+
+    out.push_str("    location / {\n");
+    out.push_str(&format!("        proxy_pass {target};\n"));
+    out.push_str("        proxy_set_header Host $host;\n");
+    out.push_str("        proxy_set_header X-Real-IP $remote_addr;\n");
+    out.push_str("    }\n");
+    out.push_str("}\n");
+    out
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn plain_vhost_listens_on_80() {
+        let block = nginx_server_block(&VHost::to_container("app.com", "web", 8080));
+        assert!(block.contains("listen 80;"));
+        assert!(!block.contains("listen 443"));
+        assert!(block.contains("server_name app.com;"));
+        assert!(block.contains("proxy_pass http://web:8080;"));
+    }
+
+    #[test]
+    fn tls_vhost_adds_443_and_redirect() {
+        let block = nginx_server_block(&VHost::to_address("secure.com", "10.0.0.5:80").with_tls());
+        assert!(block.contains("listen 443 ssl;"));
+        assert!(block.contains("return 301 https://$host$request_uri;"));
+        assert!(block.contains("/etc/letsencrypt/live/secure.com/fullchain.pem"));
+        assert!(block.contains("proxy_pass http://10.0.0.5:80;"));
+    }
+
+    #[test]
+    fn aliases_join_the_server_name() {
+        let v = VHost::to_address("main.com", "1.2.3.4:80")
+            .with_alias("www.main.com")
+            .with_alias("alt.com");
+        let block = nginx_server_block(&v);
+        assert!(block.contains("server_name main.com www.main.com alt.com;"));
+    }
+
+    #[test]
+    fn render_is_deterministic() {
+        let v = VHost::to_container("x.com", "c", 80).with_tls();
+        assert_eq!(nginx_server_block(&v), nginx_server_block(&v));
+    }
+}
diff --git a/02_ruway/shuma/baremetal/matilda-core/Cargo.toml b/02_ruway/shuma/baremetal/matilda-core/Cargo.toml
new file mode 100644
index 0000000..2bd92c9
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-core/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "matilda-core"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "matilda — modelo de dominio de administración de servidores: Host, Container, VHost y el Inventory que los agrupa. Agnóstico de transporte y de Docker."
+
+[dependencies]
+serde = { workspace = true }
diff --git a/02_ruway/shuma/baremetal/matilda-core/LEEME.md b/02_ruway/shuma/baremetal/matilda-core/LEEME.md
new file mode 100644
index 0000000..deb5ec4
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-core/LEEME.md
@@ -0,0 +1,9 @@
+# matilda-core
+
+> Modelo de config declarativa de [shuma/matilda](../../README.md).
+
+`HostConfig { packages, files, services, dotfiles, ... }` serializable a TOML. La verdad de "cómo debería estar el host" se escribe acá.
+
+## Deps
+
+- `serde`, `toml`
diff --git a/02_ruway/shuma/baremetal/matilda-core/README.md b/02_ruway/shuma/baremetal/matilda-core/README.md
new file mode 100644
index 0000000..1bfa2ed
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-core/README.md
@@ -0,0 +1,9 @@
+# matilda-core
+
+> Declarative config model of [shuma/matilda](../../README.md).
+
+`HostConfig { packages, files, services, dotfiles, ... }` serializable to TOML. The truth of "how the host should be" is written here.
+
+## Deps
+
+- `serde`, `toml`
diff --git a/02_ruway/shuma/baremetal/matilda-core/src/container.rs b/02_ruway/shuma/baremetal/matilda-core/src/container.rs
new file mode 100644
index 0000000..a731a25
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-core/src/container.rs
@@ -0,0 +1,132 @@
+//! `Container` — la especificación declarativa de un contenedor Docker.
+//!
+//! Es sólo el *deseo*: qué imagen, qué puertos, qué entorno. Ejecutar
+//! Docker es trabajo de capas superiores; aquí el contenedor es un dato
+//! comparable (`PartialEq`) para que el plan detecte cambios.
+
+use serde::{Deserialize, Serialize};
+
+/// Política de reinicio del contenedor.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum RestartPolicy {
+    /// Nunca reiniciar.
+    #[default]
+    No,
+    /// Reiniciar sólo si salió con error.
+    OnFailure,
+    /// Reiniciar siempre.
+    Always,
+    /// Reiniciar salvo que se haya detenido a mano.
+    UnlessStopped,
+}
+
+impl RestartPolicy {
+    /// Valor tal como lo espera el flag `--restart` de Docker.
+    pub fn docker_flag(self) -> &'static str {
+        match self {
+            RestartPolicy::No => "no",
+            RestartPolicy::OnFailure => "on-failure",
+            RestartPolicy::Always => "always",
+            RestartPolicy::UnlessStopped => "unless-stopped",
+        }
+    }
+}
+
+/// Un mapeo de puerto `host → contenedor`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PortMap {
+    pub host: u16,
+    pub container: u16,
+}
+
+impl PortMap {
+    pub fn new(host: u16, container: u16) -> Self {
+        Self { host, container }
+    }
+}
+
+/// La especificación declarativa de un contenedor. Clave única: `name`.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Container {
+    pub name: String,
+    /// Imagen con etiqueta — `"nginx:1.27"`, `"postgres:16"`.
+    pub image: String,
+    pub ports: Vec<PortMap>,
+    /// Variables de entorno, ordenadas por clave para comparación estable.
+    pub env: Vec<(String, String)>,
+    /// Volúmenes `ruta_host → ruta_contenedor`.
+    pub volumes: Vec<(String, String)>,
+    pub restart: RestartPolicy,
+}
+
+impl Container {
+    /// Contenedor mínimo: nombre + imagen.
+    pub fn new(name: impl Into<String>, image: impl Into<String>) -> Self {
+        Self {
+            name: name.into(),
+            image: image.into(),
+            ports: Vec::new(),
+            env: Vec::new(),
+            volumes: Vec::new(),
+            restart: RestartPolicy::default(),
+        }
+    }
+
+    /// Publica un puerto (encadenable).
+    pub fn with_port(mut self, host: u16, container: u16) -> Self {
+        self.ports.push(PortMap::new(host, container));
+        self
+    }
+
+    /// Define una variable de entorno (encadenable). El vector se
+    /// mantiene ordenado por clave para que dos contenedores con el
+    /// mismo entorno comparen iguales sin importar el orden de llamada.
+    pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
+        let key = key.into();
+        self.env.retain(|(k, _)| k != &key);
+        self.env.push((key, value.into()));
+        self.env.sort_by(|a, b| a.0.cmp(&b.0));
+        self
+    }
+
+    /// Monta un volumen (encadenable).
+    pub fn with_volume(
+        mut self,
+        host_path: impl Into<String>,
+        container_path: impl Into<String>,
+    ) -> Self {
+        self.volumes.push((host_path.into(), container_path.into()));
+        self
+    }
+
+    /// Fija la política de reinicio (encadenable).
+    pub fn with_restart(mut self, restart: RestartPolicy) -> Self {
+        self.restart = restart;
+        self
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn env_order_does_not_affect_equality() {
+        let a = Container::new("c", "img").with_env("B", "2").with_env("A", "1");
+        let b = Container::new("c", "img").with_env("A", "1").with_env("B", "2");
+        assert_eq!(a, b);
+    }
+
+    #[test]
+    fn with_env_overwrites_same_key() {
+        let c = Container::new("c", "img").with_env("K", "old").with_env("K", "new");
+        assert_eq!(c.env, vec![("K".to_string(), "new".to_string())]);
+    }
+
+    #[test]
+    fn restart_flags_match_docker() {
+        assert_eq!(RestartPolicy::UnlessStopped.docker_flag(), "unless-stopped");
+        assert_eq!(RestartPolicy::default(), RestartPolicy::No);
+    }
+}
diff --git a/02_ruway/shuma/baremetal/matilda-core/src/host.rs b/02_ruway/shuma/baremetal/matilda-core/src/host.rs
new file mode 100644
index 0000000..75bc095
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-core/src/host.rs
@@ -0,0 +1,46 @@
+//! `Host` — un servidor administrado.
+
+use serde::{Deserialize, Serialize};
+
+/// Un servidor bajo administración. La clave única es `name`.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Host {
+    /// Nombre lógico — clave de inventario, no necesariamente el hostname.
+    pub name: String,
+    /// IP o nombre DNS por el que se alcanza.
+    pub address: String,
+    /// Etiquetas libres — `"prod"`, `"db"`, `"edge"`.
+    pub tags: Vec<String>,
+}
+
+impl Host {
+    pub fn new(name: impl Into<String>, address: impl Into<String>) -> Self {
+        Self { name: name.into(), address: address.into(), tags: Vec::new() }
+    }
+
+    /// Añade una etiqueta (encadenable). No duplica.
+    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
+        let tag = tag.into();
+        if !self.tags.contains(&tag) {
+            self.tags.push(tag);
+        }
+        self
+    }
+
+    /// `true` si el host lleva la etiqueta `tag`.
+    pub fn has_tag(&self, tag: &str) -> bool {
+        self.tags.iter().any(|t| t == tag)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn with_tag_dedups() {
+        let h = Host::new("edge-1", "10.0.0.1").with_tag("prod").with_tag("prod");
+        assert_eq!(h.tags.len(), 1);
+        assert!(h.has_tag("prod"));
+    }
+}
diff --git a/02_ruway/shuma/baremetal/matilda-core/src/inventory.rs b/02_ruway/shuma/baremetal/matilda-core/src/inventory.rs
new file mode 100644
index 0000000..01259a3
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-core/src/inventory.rs
@@ -0,0 +1,131 @@
+//! `Inventory` — el estado declarado de la infraestructura.
+//!
+//! Reúne hosts, contenedores y vhosts. Cada colección es un `BTreeMap`
+//! por nombre: toda iteración es determinista y el `diff` de
+//! `matilda-plan` produce siempre el mismo orden de acciones.
+
+use std::collections::BTreeMap;
+
+use serde::{Deserialize, Serialize};
+
+use crate::container::Container;
+use crate::host::Host;
+use crate::vhost::VHost;
+
+/// El inventario completo — la fuente de verdad declarativa.
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+pub struct Inventory {
+    hosts: BTreeMap<String, Host>,
+    containers: BTreeMap<String, Container>,
+    vhosts: BTreeMap<String, VHost>,
+}
+
+impl Inventory {
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    // --- Hosts ---
+
+    pub fn add_host(&mut self, host: Host) {
+        self.hosts.insert(host.name.clone(), host);
+    }
+
+    pub fn host(&self, name: &str) -> Option<&Host> {
+        self.hosts.get(name)
+    }
+
+    pub fn hosts(&self) -> impl Iterator<Item = &Host> {
+        self.hosts.values()
+    }
+
+    // --- Contenedores ---
+
+    pub fn add_container(&mut self, container: Container) {
+        self.containers.insert(container.name.clone(), container);
+    }
+
+    pub fn container(&self, name: &str) -> Option<&Container> {
+        self.containers.get(name)
+    }
+
+    pub fn containers(&self) -> impl Iterator<Item = &Container> {
+        self.containers.values()
+    }
+
+    // --- VHosts ---
+
+    pub fn add_vhost(&mut self, vhost: VHost) {
+        self.vhosts.insert(vhost.domain.clone(), vhost);
+    }
+
+    pub fn vhost(&self, domain: &str) -> Option<&VHost> {
+        self.vhosts.get(domain)
+    }
+
+    pub fn vhosts(&self) -> impl Iterator<Item = &VHost> {
+        self.vhosts.values()
+    }
+
+    // --- Consultas transversales ---
+
+    /// `true` si el inventario no tiene nada declarado.
+    pub fn is_empty(&self) -> bool {
+        self.hosts.is_empty() && self.containers.is_empty() && self.vhosts.is_empty()
+    }
+
+    /// VHosts cuyo upstream apunta a un contenedor inexistente — la
+    /// inconsistencia más común de un inventario.
+    pub fn broken_vhosts(&self) -> Vec<&VHost> {
+        self.vhosts
+            .values()
+            .filter(|v| {
+                v.depends_on_container()
+                    .is_some_and(|c| !self.containers.contains_key(c))
+            })
+            .collect()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn add_and_query_each_kind() {
+        let mut inv = Inventory::new();
+        inv.add_host(Host::new("edge", "10.0.0.1"));
+        inv.add_container(Container::new("web", "nginx:1.27"));
+        inv.add_vhost(VHost::to_container("site.com", "web", 80));
+        assert!(inv.host("edge").is_some());
+        assert!(inv.container("web").is_some());
+        assert!(inv.vhost("site.com").is_some());
+        assert!(!inv.is_empty());
+    }
+
+    #[test]
+    fn broken_vhosts_point_to_missing_containers() {
+        let mut inv = Inventory::new();
+        inv.add_vhost(VHost::to_container("site.com", "fantasma", 80));
+        inv.add_vhost(VHost::to_address("static.com", "1.2.3.4:80"));
+        let broken: Vec<_> = inv.broken_vhosts().iter().map(|v| v.domain.clone()).collect();
+        assert_eq!(broken, vec!["site.com"]);
+    }
+
+    #[test]
+    fn vhost_with_present_container_is_not_broken() {
+        let mut inv = Inventory::new();
+        inv.add_container(Container::new("web", "nginx:1.27"));
+        inv.add_vhost(VHost::to_container("site.com", "web", 80));
+        assert!(inv.broken_vhosts().is_empty());
+    }
+
+    #[test]
+    fn iteration_is_ordered_by_name() {
+        let mut inv = Inventory::new();
+        inv.add_container(Container::new("zeta", "img"));
+        inv.add_container(Container::new("alfa", "img"));
+        let names: Vec<_> = inv.containers().map(|c| c.name.as_str()).collect();
+        assert_eq!(names, vec!["alfa", "zeta"]);
+    }
+}
diff --git a/02_ruway/shuma/baremetal/matilda-core/src/lib.rs b/02_ruway/shuma/baremetal/matilda-core/src/lib.rs
new file mode 100644
index 0000000..5135abb
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-core/src/lib.rs
@@ -0,0 +1,26 @@
+//! `matilda-core` — el modelo de dominio de administración de servidores.
+//!
+//! matilda administra servidores, sus contenedores Docker y los hosts
+//! virtuales de proxy inverso. Este crate es la parte declarativa y
+//! pura: describe *qué* debe existir, sin tocar Docker, SSH ni archivos.
+//!
+//! - [`host`] — [`Host`], un servidor administrado.
+//! - [`container`] — [`Container`], la spec declarativa de un contenedor.
+//! - [`vhost`] — [`VHost`], un host virtual de proxy inverso.
+//! - [`inventory`] — [`Inventory`], el estado declarado completo.
+//!
+//! El renderizado de configuración vive en `matilda-config`; la
+//! reconciliación deseado-vs-actual, en `matilda-plan`; el transporte
+//! (SSH «Linker», agente «Ghost»), en capas superiores.
+
+#![forbid(unsafe_code)]
+
+pub mod container;
+pub mod host;
+pub mod inventory;
+pub mod vhost;
+
+pub use container::{Container, PortMap, RestartPolicy};
+pub use host::Host;
+pub use inventory::Inventory;
+pub use vhost::{Upstream, VHost};
diff --git a/02_ruway/shuma/baremetal/matilda-core/src/vhost.rs b/02_ruway/shuma/baremetal/matilda-core/src/vhost.rs
new file mode 100644
index 0000000..4c48fb9
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-core/src/vhost.rs
@@ -0,0 +1,88 @@
+//! `VHost` — un host virtual de proxy inverso.
+
+use serde::{Deserialize, Serialize};
+
+/// El destino al que un `VHost` reenvía el tráfico.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum Upstream {
+    /// Una dirección `host:puerto` literal.
+    Address(String),
+    /// Un contenedor del inventario, por nombre y puerto interno.
+    Container { name: String, port: u16 },
+}
+
+/// Un host virtual: un dominio que se reenvía a un upstream. Clave
+/// única: `domain`.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct VHost {
+    pub domain: String,
+    pub upstream: Upstream,
+    /// Si se sirve sobre HTTPS.
+    pub tls: bool,
+    /// Dominios alternativos que resuelven al mismo upstream.
+    pub aliases: Vec<String>,
+}
+
+impl VHost {
+    /// VHost que apunta a una dirección literal.
+    pub fn to_address(domain: impl Into<String>, address: impl Into<String>) -> Self {
+        Self {
+            domain: domain.into(),
+            upstream: Upstream::Address(address.into()),
+            tls: false,
+            aliases: Vec::new(),
+        }
+    }
+
+    /// VHost que apunta a un contenedor del inventario.
+    pub fn to_container(
+        domain: impl Into<String>,
+        container: impl Into<String>,
+        port: u16,
+    ) -> Self {
+        Self {
+            domain: domain.into(),
+            upstream: Upstream::Container { name: container.into(), port },
+            tls: false,
+            aliases: Vec::new(),
+        }
+    }
+
+    /// Activa TLS (encadenable).
+    pub fn with_tls(mut self) -> Self {
+        self.tls = true;
+        self
+    }
+
+    /// Añade un alias de dominio (encadenable).
+    pub fn with_alias(mut self, alias: impl Into<String>) -> Self {
+        self.aliases.push(alias.into());
+        self
+    }
+
+    /// Nombre del contenedor del que depende, si el upstream es uno.
+    pub fn depends_on_container(&self) -> Option<&str> {
+        match &self.upstream {
+            Upstream::Container { name, .. } => Some(name),
+            Upstream::Address(_) => None,
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn container_upstream_reports_its_dependency() {
+        let v = VHost::to_container("app.example.com", "web", 8080).with_tls();
+        assert_eq!(v.depends_on_container(), Some("web"));
+        assert!(v.tls);
+    }
+
+    #[test]
+    fn address_upstream_has_no_container_dependency() {
+        let v = VHost::to_address("static.example.com", "10.0.0.9:80");
+        assert_eq!(v.depends_on_container(), None);
+    }
+}
diff --git a/02_ruway/shuma/baremetal/matilda-discover/Cargo.toml b/02_ruway/shuma/baremetal/matilda-discover/Cargo.toml
new file mode 100644
index 0000000..f4e5297
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-discover/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "matilda-discover"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "matilda — descubrimiento del estado actual de un servidor: qué contenedores y vhosts existen, para reconciliar contra el inventario deseado."
+
+[dependencies]
+matilda-core = { path = "../matilda-core" }
+serde = { workspace = true }
+serde_json = { workspace = true }
+
+[dev-dependencies]
+matilda-plan = { path = "../matilda-plan" }
diff --git a/02_ruway/shuma/baremetal/matilda-discover/LEEME.md b/02_ruway/shuma/baremetal/matilda-discover/LEEME.md
new file mode 100644
index 0000000..a97dd47
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-discover/LEEME.md
@@ -0,0 +1,10 @@
+# matilda-discover
+
+> Descubrimiento de estado actual de [matilda](../../README.md).
+
+Lee el sistema (paquetes instalados, archivos en `/etc`, servicios systemd) y produce un `HostConfig` "actual". Comparable con el deseado para calcular el diff.
+
+## Deps
+
+- [`matilda-core`](../matilda-core/README.md)
+- `dbus` (systemd), `walkdir`
diff --git a/02_ruway/shuma/baremetal/matilda-discover/README.md b/02_ruway/shuma/baremetal/matilda-discover/README.md
new file mode 100644
index 0000000..48da119
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-discover/README.md
@@ -0,0 +1,10 @@
+# matilda-discover
+
+> Current-state discovery of [matilda](../../README.md).
+
+Reads the system (installed packages, files in `/etc`, systemd services) and produces the "actual" `HostConfig`. Comparable with the desired state to compute the diff.
+
+## Deps
+
+- [`matilda-core`](../matilda-core/README.md)
+- `dbus` (systemd), `walkdir`
diff --git a/02_ruway/shuma/baremetal/matilda-discover/src/lib.rs b/02_ruway/shuma/baremetal/matilda-discover/src/lib.rs
new file mode 100644
index 0000000..4feb9d0
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-discover/src/lib.rs
@@ -0,0 +1,349 @@
+//! `matilda-discover` — qué hay realmente en el servidor.
+//!
+//! Para reconciliar de verdad hace falta saber el estado *actual*: qué
+//! contenedores y vhosts existen. Este crate lo observa y lo reconstruye
+//! como un [`Inventory`] que `matilda-plan` puede diferenciar contra el
+//! deseado.
+//!
+//! Alcance v1: descubre por **nombre**. Detecta correctamente lo que hay
+//! que **crear** y lo que hay que **eliminar** (huérfanos). No detecta
+//! cambios de configuración de un recurso existente — eso necesita
+//! inspección detallada (`docker inspect`), aún no implementada; un
+//! recurso presente y deseado se asume sin cambios.
+//!
+//! El parseo es puro y testeable; sólo [`discover_local`] toca el sistema.
+
+#![forbid(unsafe_code)]
+
+use matilda_core::{Container, Inventory, VHost};
+use serde::{Deserialize, Serialize};
+
+/// El estado observado de un servidor — los nombres de lo que existe.
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ServerState {
+    /// Nombres de los contenedores presentes.
+    pub containers: Vec<String>,
+    /// Dominios de los vhosts presentes.
+    pub vhosts: Vec<String>,
+}
+
+/// Parsea la salida de `docker ps -a --format '{{.Names}}'` — un nombre
+/// por línea.
+pub fn parse_docker_names(text: &str) -> Vec<String> {
+    text.lines()
+        .map(str::trim)
+        .filter(|l| !l.is_empty())
+        .map(str::to_string)
+        .collect()
+}
+
+/// Parsea un listado de `/etc/nginx/sites-enabled` — un archivo por
+/// línea; el sufijo `.conf` se quita para quedarse con el dominio.
+pub fn parse_nginx_sites(text: &str) -> Vec<String> {
+    text.lines()
+        .map(str::trim)
+        .filter(|l| !l.is_empty())
+        .map(|l| l.strip_suffix(".conf").unwrap_or(l).to_string())
+        .collect()
+}
+
+/// Reconstruye el inventario "actual" a partir de los nombres observados.
+///
+/// Un recurso presente que también está en `desired` se copia de ahí —
+/// así el `plan` no marca cambios espurios (la detección real de drift
+/// necesita inspección detallada). Un recurso presente que **no** está
+/// en `desired` entra como un marcador, y el `plan` lo verá como un
+/// `Remove`.
+pub fn observed_inventory(state: &ServerState, desired: &Inventory) -> Inventory {
+    let mut inv = Inventory::new();
+    for name in &state.containers {
+        match desired.container(name) {
+            Some(c) => inv.add_container(c.clone()),
+            None => inv.add_container(Container::new(name, "(desconocido)")),
+        }
+    }
+    for domain in &state.vhosts {
+        match desired.vhost(domain) {
+            Some(v) => inv.add_vhost(v.clone()),
+            None => inv.add_vhost(VHost::to_address(domain, "(desconocido)")),
+        }
+    }
+    inv
+}
+
+/// Ejecuta un comando local y devuelve su stdout, o `None` si falla.
+fn run_local(program: &str, args: &[&str]) -> Option<String> {
+    let out = std::process::Command::new(program).args(args).output().ok()?;
+    out.status
+        .success()
+        .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
+}
+
+// --- Detección de drift por `docker inspect` ----------------------------
+
+/// Subconjunto de la salida de `docker inspect` que importa para el drift.
+#[derive(Debug, Deserialize)]
+struct DockerInspect {
+    #[serde(rename = "Config")]
+    config: DockerConfig,
+    #[serde(rename = "HostConfig")]
+    host_config: DockerHostConfig,
+}
+
+#[derive(Debug, Deserialize)]
+struct DockerConfig {
+    #[serde(rename = "Image")]
+    image: String,
+    #[serde(default, rename = "Env")]
+    env: Vec<String>,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct DockerHostConfig {
+    #[serde(default, rename = "Binds")]
+    binds: Option<Vec<String>>,
+    #[serde(default, rename = "PortBindings")]
+    port_bindings: std::collections::HashMap<String, Option<Vec<PortBinding>>>,
+    #[serde(default, rename = "RestartPolicy")]
+    restart_policy: DockerRestart,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct DockerRestart {
+    #[serde(default, rename = "Name")]
+    name: String,
+}
+
+#[derive(Debug, Deserialize)]
+struct PortBinding {
+    #[serde(rename = "HostPort")]
+    host_port: String,
+}
+
+/// `true` si el contenedor que está corriendo **se desvió** de lo que
+/// declara `desired` — distinta imagen, puerto, env o volumen.
+///
+/// La comparación es por *satisfacción*: lo que el spec declara debe
+/// estar; lo extra que traiga la imagen (su `PATH`, etc.) se ignora.
+/// Si el JSON no se puede leer, se asume que no hay drift (no se marca
+/// un cambio espurio).
+pub fn container_drift(desired: &Container, inspect_json: &str) -> bool {
+    let parsed: Vec<DockerInspect> = match serde_json::from_str(inspect_json) {
+        Ok(v) => v,
+        Err(_) => return false,
+    };
+    let Some(d) = parsed.first() else {
+        return false;
+    };
+
+    // Imagen.
+    if d.config.image != desired.image {
+        return true;
+    }
+    // Política de reinicio (docker reporta "" cuando no hay → "no").
+    let actual = if d.host_config.restart_policy.name.is_empty() {
+        "no"
+    } else {
+        d.host_config.restart_policy.name.as_str()
+    };
+    if actual != desired.restart.docker_flag() {
+        return true;
+    }
+    // Cada puerto declarado debe estar publicado al host correcto.
+    for p in &desired.ports {
+        let key = format!("{}/tcp", p.container);
+        let published = d
+            .host_config
+            .port_bindings
+            .get(&key)
+            .and_then(|b| b.as_ref())
+            .map(|bs| bs.iter().any(|b| b.host_port == p.host.to_string()))
+            .unwrap_or(false);
+        if !published {
+            return true;
+        }
+    }
+    // Cada variable de entorno declarada debe estar presente.
+    for (k, v) in &desired.env {
+        let want = format!("{k}={v}");
+        if !d.config.env.iter().any(|e| e == &want) {
+            return true;
+        }
+    }
+    // Cada volumen declarado debe estar montado.
+    for (h, c) in &desired.volumes {
+        let want = format!("{h}:{c}");
+        if !d.host_config.binds.iter().flatten().any(|b| b.starts_with(&want)) {
+            return true;
+        }
+    }
+    false
+}
+
+/// Descubre el inventario actual **con detección de drift**: corre
+/// `docker inspect` en cada contenedor y, si se desvió del spec deseado,
+/// lo marca para que el `plan` emita un `Update`. Los contenedores al
+/// día se copian del deseado (sin cambio); los huérfanos quedan marcados
+/// para `Remove`. Los vhosts se descubren por nombre.
+pub fn discover_inventory(desired: &Inventory) -> Inventory {
+    let mut inv = Inventory::new();
+    let names = run_local("docker", &["ps", "-a", "--format", "{{.Names}}"])
+        .map(|t| parse_docker_names(&t))
+        .unwrap_or_default();
+    for name in names {
+        match desired.container(&name) {
+            Some(d) => {
+                let drifted = run_local("docker", &["inspect", &name])
+                    .map(|json| container_drift(d, &json))
+                    .unwrap_or(false);
+                if drifted {
+                    // Marcador distinto del deseado → el plan verá `Update`.
+                    inv.add_container(Container::new(&name, "(desviado)"));
+                } else {
+                    inv.add_container(d.clone());
+                }
+            }
+            None => inv.add_container(Container::new(&name, "(huérfano)")),
+        }
+    }
+    for domain in run_local("ls", &["-1", "/etc/nginx/sites-enabled"])
+        .map(|t| parse_nginx_sites(&t))
+        .unwrap_or_default()
+    {
+        match desired.vhost(&domain) {
+            Some(v) => inv.add_vhost(v.clone()),
+            None => inv.add_vhost(VHost::to_address(&domain, "(huérfano)")),
+        }
+    }
+    inv
+}
+
+/// Observa el estado de *esta* máquina: `docker ps` + los sitios de
+/// nginx. Si docker no está o el directorio no existe, esa parte queda
+/// vacía (no es un error — quizá el servidor aún no tiene nada).
+pub fn discover_local() -> ServerState {
+    let containers = run_local("docker", &["ps", "-a", "--format", "{{.Names}}"])
+        .map(|t| parse_docker_names(&t))
+        .unwrap_or_default();
+    let vhosts = run_local("ls", &["-1", "/etc/nginx/sites-enabled"])
+        .map(|t| parse_nginx_sites(&t))
+        .unwrap_or_default();
+    ServerState { containers, vhosts }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use matilda_plan::{plan, Op};
+
+    #[test]
+    fn parses_docker_names() {
+        let names = parse_docker_names("web\napi\n\n  db  \n");
+        assert_eq!(names, vec!["web", "api", "db"]);
+    }
+
+    #[test]
+    fn parses_nginx_sites_stripping_conf() {
+        let sites = parse_nginx_sites("sitio.com.conf\napi.sitio.com.conf\n");
+        assert_eq!(sites, vec!["sitio.com", "api.sitio.com"]);
+    }
+
+    #[test]
+    fn observed_present_and_desired_diffs_clean() {
+        // Un contenedor presente que también se desea → sin cambios.
+        let mut desired = Inventory::new();
+        desired.add_container(Container::new("web", "nginx:1.27"));
+        let state = ServerState { containers: vec!["web".into()], vhosts: vec![] };
+        let current = observed_inventory(&state, &desired);
+        let p = plan(&current, &desired);
+        assert!(p.is_empty(), "presente y deseado → sin acciones");
+    }
+
+    #[test]
+    fn observed_orphan_becomes_a_removal() {
+        // Un contenedor presente que NO se desea → se elimina.
+        let desired = Inventory::new();
+        let state = ServerState { containers: vec!["viejo".into()], vhosts: vec![] };
+        let current = observed_inventory(&state, &desired);
+        let p = plan(&current, &desired);
+        assert_eq!(p.count(Op::Remove), 1);
+        assert_eq!(p.actions[0].name, "viejo");
+    }
+
+    #[test]
+    fn missing_desired_resource_becomes_a_creation() {
+        let mut desired = Inventory::new();
+        desired.add_container(Container::new("nuevo", "img:1"));
+        // El servidor no tiene nada.
+        let current = observed_inventory(&ServerState::default(), &desired);
+        let p = plan(&current, &desired);
+        assert_eq!(p.count(Op::Create), 1);
+    }
+
+    #[test]
+    fn create_and_remove_together() {
+        let mut desired = Inventory::new();
+        desired.add_container(Container::new("nuevo", "img:1"));
+        let state = ServerState { containers: vec!["viejo".into()], vhosts: vec![] };
+        let p = plan(&observed_inventory(&state, &desired), &desired);
+        assert_eq!(p.count(Op::Create), 1);
+        assert_eq!(p.count(Op::Remove), 1);
+    }
+
+    /// `docker inspect` de un `web` con nginx:1.27, 8080→80, un volumen,
+    /// la env TZ y reinicio `always`.
+    const INSPECT_WEB: &str = r#"[{
+        "Config": {
+            "Image": "nginx:1.27",
+            "Env": ["PATH=/usr/local/sbin", "TZ=UTC"]
+        },
+        "HostConfig": {
+            "Binds": ["/srv/web:/usr/share/nginx/html"],
+            "PortBindings": {"80/tcp": [{"HostPort": "8080"}]},
+            "RestartPolicy": {"Name": "always"}
+        }
+    }]"#;
+
+    fn web_spec() -> matilda_core::Container {
+        Container::new("web", "nginx:1.27")
+            .with_port(8080, 80)
+            .with_volume("/srv/web", "/usr/share/nginx/html")
+            .with_env("TZ", "UTC")
+            .with_restart(matilda_core::RestartPolicy::Always)
+    }
+
+    #[test]
+    fn no_drift_when_running_matches_the_spec() {
+        assert!(!container_drift(&web_spec(), INSPECT_WEB));
+    }
+
+    #[test]
+    fn drift_when_image_changed() {
+        let mut spec = web_spec();
+        spec.image = "nginx:1.25".into();
+        assert!(container_drift(&spec, INSPECT_WEB));
+    }
+
+    #[test]
+    fn drift_when_a_declared_port_is_missing() {
+        let spec = web_spec().with_port(9000, 9000);
+        assert!(container_drift(&spec, INSPECT_WEB));
+    }
+
+    #[test]
+    fn drift_when_a_declared_env_is_missing() {
+        let spec = web_spec().with_env("DEBUG", "1");
+        assert!(container_drift(&spec, INSPECT_WEB));
+    }
+
+    #[test]
+    fn drift_when_restart_policy_differs() {
+        let spec = web_spec().with_restart(matilda_core::RestartPolicy::No);
+        assert!(container_drift(&spec, INSPECT_WEB));
+    }
+
+    #[test]
+    fn unreadable_json_is_not_treated_as_drift() {
+        assert!(!container_drift(&web_spec(), "no es json"));
+    }
+}
diff --git a/02_ruway/shuma/baremetal/matilda-ghost/Cargo.toml b/02_ruway/shuma/baremetal/matilda-ghost/Cargo.toml
new file mode 100644
index 0000000..64fe4fa
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-ghost/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "matilda-ghost"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "matilda — el agente que ejecuta los ApplySteps en la máquina destino: escribe los archivos, corre los comandos y reporta el resultado paso a paso."
+
+[dependencies]
+matilda-apply = { path = "../matilda-apply" }
+serde = { workspace = true }
diff --git a/02_ruway/shuma/baremetal/matilda-ghost/LEEME.md b/02_ruway/shuma/baremetal/matilda-ghost/LEEME.md
new file mode 100644
index 0000000..e1be2ca
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-ghost/LEEME.md
@@ -0,0 +1,9 @@
+# matilda-ghost
+
+> Modo dry-run de [matilda](../../README.md).
+
+Wrapper de [`matilda-apply`](../matilda-apply/README.md) que NO ejecuta — sólo imprime. Útil antes de un apply real.
+
+## Deps
+
+- [`matilda-apply`](../matilda-apply/README.md)
diff --git a/02_ruway/shuma/baremetal/matilda-ghost/README.md b/02_ruway/shuma/baremetal/matilda-ghost/README.md
new file mode 100644
index 0000000..c988fa4
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-ghost/README.md
@@ -0,0 +1,9 @@
+# matilda-ghost
+
+> Dry-run mode of [matilda](../../README.md).
+
+Wrapper of [`matilda-apply`](../matilda-apply/README.md) that does NOT execute — only prints what would. Useful before a real apply.
+
+## Deps
+
+- [`matilda-apply`](../matilda-apply/README.md)
diff --git a/02_ruway/shuma/baremetal/matilda-ghost/src/lib.rs b/02_ruway/shuma/baremetal/matilda-ghost/src/lib.rs
new file mode 100644
index 0000000..bc4d9db
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-ghost/src/lib.rs
@@ -0,0 +1,222 @@
+//! `matilda-ghost` — el agente que aplica los pasos en la máquina destino.
+//!
+//! El «Ghost» es quien realmente ejecuta: recibe los [`ApplyStep`]s que
+//! tradujo `matilda-apply` y, en orden, escribe los archivos y corre los
+//! comandos en *esta* máquina (la del servidor). Reporta paso a paso en
+//! un [`ApplyReport`].
+//!
+//! Semántica `set -e`: si un paso falla, se detiene — no se aplican los
+//! siguientes. [`dry_run`] muestra lo que haría sin tocar nada.
+//!
+//! La aplicación *remota* (por SSH) la hace `matilda-linker`, que produce
+//! el mismo [`ApplyReport`] reusando estos tipos.
+
+#![forbid(unsafe_code)]
+
+use matilda_apply::ApplyStep;
+use serde::{Deserialize, Serialize};
+
+/// Resultado de un paso de aplicación.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct StepResult {
+    /// Descripción de la acción aplicada.
+    pub describe: String,
+    /// `true` si el paso completó sin errores.
+    pub ok: bool,
+    /// Bitácora legible: archivos escritos, comandos y su salida.
+    pub log: Vec<String>,
+}
+
+/// El reporte de aplicar un plan: un resultado por paso ejecutado.
+#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
+pub struct ApplyReport {
+    pub results: Vec<StepResult>,
+}
+
+impl ApplyReport {
+    /// `true` si todos los pasos ejecutados salieron bien.
+    pub fn all_ok(&self) -> bool {
+        self.results.iter().all(|r| r.ok)
+    }
+
+    /// Cantidad de pasos que salieron bien.
+    pub fn applied(&self) -> usize {
+        self.results.iter().filter(|r| r.ok).count()
+    }
+
+    /// El primer paso que falló, si lo hubo.
+    pub fn failed(&self) -> Option<&StepResult> {
+        self.results.iter().find(|r| !r.ok)
+    }
+}
+
+/// Corre un comando de shell, juntando su salida (stdout + stderr).
+/// Los comandos de matilda llevan `&&`, redirecciones… → van por `sh -c`.
+fn run_command(cmd: &str) -> std::io::Result<(i32, Vec<String>)> {
+    let out = std::process::Command::new("sh").arg("-c").arg(cmd).output()?;
+    let mut lines = Vec::new();
+    for chunk in [&out.stdout, &out.stderr] {
+        for l in String::from_utf8_lossy(chunk).lines() {
+            lines.push(l.to_string());
+        }
+    }
+    Ok((out.status.code().unwrap_or(-1), lines))
+}
+
+/// Aplica un paso en esta máquina: escribe sus archivos y corre sus
+/// comandos. Devuelve el resultado; se detiene en el primer error.
+fn apply_step(step: &ApplyStep) -> StepResult {
+    let mut log = Vec::new();
+    let mut ok = true;
+
+    for f in &step.files {
+        match std::fs::write(&f.path, &f.content) {
+            Ok(()) => log.push(format!("✔ escrito {}", f.path)),
+            Err(e) => {
+                log.push(format!("✘ no se pudo escribir {}: {e}", f.path));
+                ok = false;
+                break;
+            }
+        }
+    }
+
+    if ok {
+        for cmd in &step.commands {
+            log.push(format!("$ {cmd}"));
+            match run_command(cmd) {
+                Ok((0, out)) => {
+                    log.extend(out.into_iter().map(|l| format!("  {l}")));
+                }
+                Ok((code, out)) => {
+                    log.extend(out.into_iter().map(|l| format!("  {l}")));
+                    log.push(format!("✘ el comando salió con código {code}"));
+                    ok = false;
+                    break;
+                }
+                Err(e) => {
+                    log.push(format!("✘ no se pudo ejecutar: {e}"));
+                    ok = false;
+                    break;
+                }
+            }
+        }
+    }
+
+    StepResult { describe: step.describe.clone(), ok, log }
+}
+
+/// Aplica los pasos en orden. Se detiene en el primero que falle
+/// (semántica `set -e`): los posteriores no se ejecutan.
+pub fn apply(steps: &[ApplyStep]) -> ApplyReport {
+    let mut results = Vec::new();
+    for step in steps {
+        let result = apply_step(step);
+        let failed = !result.ok;
+        results.push(result);
+        if failed {
+            break;
+        }
+    }
+    ApplyReport { results }
+}
+
+/// Simula la aplicación: reporta qué archivos y comandos se ejecutarían,
+/// sin tocar nada. Seguro para previsualizar.
+pub fn dry_run(steps: &[ApplyStep]) -> ApplyReport {
+    let results = steps
+        .iter()
+        .map(|s| {
+            let mut log = Vec::new();
+            for f in &s.files {
+                log.push(format!("escribiría {} ({} bytes)", f.path, f.content.len()));
+            }
+            for c in &s.commands {
+                log.push(format!("$ {c}"));
+            }
+            StepResult { describe: s.describe.clone(), ok: true, log }
+        })
+        .collect();
+    ApplyReport { results }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use matilda_apply::FileWrite;
+
+    /// Paso que escribe un archivo temporal y corre un comando.
+    fn step(describe: &str, file: Option<FileWrite>, cmds: &[&str]) -> ApplyStep {
+        ApplyStep {
+            describe: describe.into(),
+            files: file.into_iter().collect(),
+            commands: cmds.iter().map(|s| s.to_string()).collect(),
+        }
+    }
+
+    fn temp(name: &str) -> String {
+        std::env::temp_dir()
+            .join(format!("matilda-ghost-{}-{name}", std::process::id()))
+            .to_string_lossy()
+            .into_owned()
+    }
+
+    #[test]
+    fn dry_run_touches_nothing() {
+        let path = temp("dry");
+        let _ = std::fs::remove_file(&path);
+        let steps = vec![step(
+            "crear x",
+            Some(FileWrite { path: path.clone(), content: "hola".into() }),
+            &["echo hecho"],
+        )];
+        let report = dry_run(&steps);
+        assert!(report.all_ok());
+        assert_eq!(report.results.len(), 1);
+        // dry_run no escribió el archivo.
+        assert!(!std::path::Path::new(&path).exists());
+    }
+
+    #[test]
+    fn apply_writes_files_and_runs_commands() {
+        let path = temp("apply");
+        let _ = std::fs::remove_file(&path);
+        let steps = vec![step(
+            "crear config",
+            Some(FileWrite { path: path.clone(), content: "contenido".into() }),
+            &["echo aplicado"],
+        )];
+        let report = apply(&steps);
+        assert!(report.all_ok());
+        assert_eq!(std::fs::read_to_string(&path).unwrap(), "contenido");
+        assert!(report.results[0].log.iter().any(|l| l.contains("aplicado")));
+        let _ = std::fs::remove_file(&path);
+    }
+
+    #[test]
+    fn apply_stops_at_the_first_failure() {
+        let steps = vec![
+            step("ok", None, &["true"]),
+            step("falla", None, &["exit 7"]),
+            step("nunca", None, &["echo no-deberia-correr"]),
+        ];
+        let report = apply(&steps);
+        // El tercer paso no se ejecutó.
+        assert_eq!(report.results.len(), 2);
+        assert!(!report.all_ok());
+        assert_eq!(report.applied(), 1);
+        assert!(report.failed().unwrap().describe.contains("falla"));
+    }
+
+    #[test]
+    fn nonzero_exit_marks_the_step_failed() {
+        let report = apply(&[step("test", None, &["false"])]);
+        assert!(!report.results[0].ok);
+    }
+
+    #[test]
+    fn empty_plan_applies_cleanly() {
+        let report = apply(&[]);
+        assert!(report.all_ok());
+        assert_eq!(report.applied(), 0);
+    }
+}
diff --git a/02_ruway/shuma/baremetal/matilda-linker/Cargo.toml b/02_ruway/shuma/baremetal/matilda-linker/Cargo.toml
new file mode 100644
index 0000000..4ea4830
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-linker/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "matilda-linker"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "matilda — el enlace SSH: conecta a un servidor y aplica los ApplySteps remotamente, escribiendo archivos y corriendo comandos sobre la conexión multiplexada."
+
+[dependencies]
+matilda-apply = { path = "../matilda-apply" }
+matilda-ghost = { path = "../matilda-ghost" }
+ssh = { workspace = true }
diff --git a/02_ruway/shuma/baremetal/matilda-linker/LEEME.md b/02_ruway/shuma/baremetal/matilda-linker/LEEME.md
new file mode 100644
index 0000000..1b265a3
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-linker/LEEME.md
@@ -0,0 +1,9 @@
+# matilda-linker
+
+> Enlaza dotfiles de [matilda](../../README.md).
+
+Maneja symlinks de dotfiles desde el repo del usuario a `$HOME`. Detecta conflictos; backup automático antes de pisar.
+
+## Deps
+
+- [`matilda-core`](../matilda-core/README.md)
diff --git a/02_ruway/shuma/baremetal/matilda-linker/README.md b/02_ruway/shuma/baremetal/matilda-linker/README.md
new file mode 100644
index 0000000..80f6878
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-linker/README.md
@@ -0,0 +1,9 @@
+# matilda-linker
+
+> Dotfile linker of [matilda](../../README.md).
+
+Manages symlinks from the user's repo to `$HOME`. Detects conflicts; automatic backup before overwriting.
+
+## Deps
+
+- [`matilda-core`](../matilda-core/README.md)
diff --git a/02_ruway/shuma/baremetal/matilda-linker/src/lib.rs b/02_ruway/shuma/baremetal/matilda-linker/src/lib.rs
new file mode 100644
index 0000000..e1efc8a
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-linker/src/lib.rs
@@ -0,0 +1,154 @@
+//! `matilda-linker` — el enlace SSH que aplica un plan en un servidor.
+//!
+//! El [`Linker`] conecta a un host vía `brahman-ssh-multiplex` y aplica
+//! los [`ApplyStep`]s **remotamente**: escribe los archivos (con un
+//! heredoc) y corre los comandos, cada uno sobre la conexión SSH
+//! multiplexada. Produce el mismo [`ApplyReport`] que `matilda-ghost`,
+//! así el consumidor no distingue aplicación local de remota.
+//!
+//! La prueba real necesita un servidor SSH — se hace fuera del unit
+//! test. Lo puro y testeable es la construcción del comando de escritura.
+
+#![forbid(unsafe_code)]
+
+use matilda_apply::{ApplyStep, FileWrite};
+use matilda_ghost::{ApplyReport, StepResult};
+
+pub use ssh::{SshAuth, SshConfig, SshError};
+use ssh::SshSession;
+
+/// Marcador de heredoc para escribir archivos remotos.
+const HEREDOC: &str = "MATILDA_LINKER_EOF";
+
+/// Comando de shell que escribe `f.content` en `f.path` del host remoto.
+fn file_write_command(f: &FileWrite) -> String {
+    format!(
+        "cat > '{}' <<'{HEREDOC}'\n{}\n{HEREDOC}",
+        f.path, f.content
+    )
+}
+
+/// Enlace activo a un servidor: una sesión SSH multiplexada.
+pub struct Linker {
+    session: SshSession,
+}
+
+impl Linker {
+    /// Conecta y autentica contra el host descrito por `config`.
+    pub async fn connect(config: &SshConfig) -> Result<Linker, SshError> {
+        Ok(Linker { session: SshSession::connect(config).await? })
+    }
+
+    /// Aplica un paso en el host remoto: escribe sus archivos, corre sus
+    /// comandos. Se detiene en el primer error.
+    async fn apply_step(&self, step: &ApplyStep) -> StepResult {
+        let mut log = Vec::new();
+        let mut ok = true;
+
+        for f in &step.files {
+            match self.session.exec(&file_write_command(f)).await {
+                Ok(out) if out.exit_code == 0 => log.push(format!("✔ escrito {}", f.path)),
+                Ok(out) => {
+                    log.push(format!(
+                        "✘ escribir {}: {}",
+                        f.path,
+                        String::from_utf8_lossy(&out.stderr).trim()
+                    ));
+                    ok = false;
+                    break;
+                }
+                Err(e) => {
+                    log.push(format!("✘ {e}"));
+                    ok = false;
+                    break;
+                }
+            }
+        }
+
+        if ok {
+            for cmd in &step.commands {
+                log.push(format!("$ {cmd}"));
+                match self.session.exec(cmd).await {
+                    Ok(out) => {
+                        for l in String::from_utf8_lossy(&out.stdout).lines() {
+                            log.push(format!("  {l}"));
+                        }
+                        for l in String::from_utf8_lossy(&out.stderr).lines() {
+                            log.push(format!("  {l}"));
+                        }
+                        if out.exit_code != 0 {
+                            log.push(format!("✘ el comando salió con código {}", out.exit_code));
+                            ok = false;
+                            break;
+                        }
+                    }
+                    Err(e) => {
+                        log.push(format!("✘ no se pudo ejecutar: {e}"));
+                        ok = false;
+                        break;
+                    }
+                }
+            }
+        }
+
+        StepResult { describe: step.describe.clone(), ok, log }
+    }
+
+    /// Ejecuta un comando arbitrario en el host remoto y devuelve su
+    /// stdout. Útil para discover (p. ej. `docker ps -a --format
+    /// '{{.Names}}'`) sin abrir un `apply` completo. Si el comando
+    /// sale con código distinto de 0, retorna el stderr como `Err`.
+    pub async fn exec(&self, cmd: &str) -> Result<String, SshError> {
+        let out = self.session.exec(cmd).await?;
+        if out.exit_code != 0 {
+            return Err(SshError::Channel(format!(
+                "exit {}: {}",
+                out.exit_code,
+                String::from_utf8_lossy(&out.stderr).trim()
+            )));
+        }
+        Ok(String::from_utf8_lossy(&out.stdout).into_owned())
+    }
+
+    /// Aplica los pasos en orden sobre el host remoto. Se detiene en el
+    /// primero que falle (semántica `set -e`).
+    pub async fn apply(&self, steps: &[ApplyStep]) -> ApplyReport {
+        let mut results = Vec::new();
+        for step in steps {
+            let result = self.apply_step(step).await;
+            let failed = !result.ok;
+            results.push(result);
+            if failed {
+                break;
+            }
+        }
+        ApplyReport { results }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn file_write_command_uses_a_heredoc() {
+        let f = FileWrite {
+            path: "/etc/nginx/sites-enabled/site.conf".into(),
+            content: "server { listen 80; }".into(),
+        };
+        let cmd = file_write_command(&f);
+        assert!(cmd.starts_with("cat > '/etc/nginx/sites-enabled/site.conf' <<'"));
+        assert!(cmd.contains("server { listen 80; }"));
+        assert!(cmd.ends_with(HEREDOC));
+    }
+
+    #[test]
+    fn ssh_config_is_re_exported() {
+        // El consumidor arma la conexión sin depender de ssh-multiplex.
+        let c = SshConfig::new("srv.example", "deploy", SshAuth::Password("x".into()));
+        assert_eq!(c.host, "srv.example");
+    }
+
+    // La aplicación remota real (`Linker::connect` + `apply`) necesita un
+    // servidor SSH — se prueba fuera del unit test.
+}
diff --git a/02_ruway/shuma/baremetal/matilda-plan/Cargo.toml b/02_ruway/shuma/baremetal/matilda-plan/Cargo.toml
new file mode 100644
index 0000000..983b530
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-plan/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "matilda-plan"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "matilda — reconciliación de estado: compara el inventario deseado con el actual y produce una lista ordenada de acciones que respeta las dependencias."
+
+[dependencies]
+matilda-core = { path = "../matilda-core" }
+serde = { workspace = true }
diff --git a/02_ruway/shuma/baremetal/matilda-plan/LEEME.md b/02_ruway/shuma/baremetal/matilda-plan/LEEME.md
new file mode 100644
index 0000000..87c865f
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-plan/LEEME.md
@@ -0,0 +1,9 @@
+# matilda-plan
+
+> Planificador de diff (actual → deseado) de [matilda](../../README.md).
+
+Toma actual + deseado, produce `Vec<Action>` ordenada por dependencia. Cada `Action` es atómica y reversible.
+
+## Deps
+
+- [`matilda-core`](../matilda-core/README.md), [`matilda-discover`](../matilda-discover/README.md)
diff --git a/02_ruway/shuma/baremetal/matilda-plan/README.md b/02_ruway/shuma/baremetal/matilda-plan/README.md
new file mode 100644
index 0000000..5540c04
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-plan/README.md
@@ -0,0 +1,9 @@
+# matilda-plan
+
+> Diff planner (actual → desired) of [matilda](../../README.md).
+
+Takes actual + desired, produces a dependency-ordered `Vec<Action>`. Each `Action` is atomic and reversible.
+
+## Deps
+
+- [`matilda-core`](../matilda-core/README.md), [`matilda-discover`](../matilda-discover/README.md)
diff --git a/02_ruway/shuma/baremetal/matilda-plan/src/lib.rs b/02_ruway/shuma/baremetal/matilda-plan/src/lib.rs
new file mode 100644
index 0000000..09de1ea
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-plan/src/lib.rs
@@ -0,0 +1,268 @@
+//! `matilda-plan` — reconciliación de estado deseado vs actual.
+//!
+//! Dado el inventario *actual* de un servidor y el inventario *deseado*,
+//! produce la lista de [`Action`]s que lo lleva de uno al otro. El orden
+//! respeta las dependencias:
+//!
+//! 1. crear/actualizar hosts;
+//! 2. crear/actualizar contenedores (los vhosts dependen de ellos);
+//! 3. crear/actualizar vhosts;
+//! 4. eliminar vhosts (antes que sus contenedores);
+//! 5. eliminar contenedores;
+//! 6. eliminar hosts.
+//!
+//! Es una función pura y determinista — el mismo par de inventarios da
+//! siempre el mismo plan. Aplicarlo (Docker, nginx, SSH) es trabajo de
+//! capas superiores.
+
+#![forbid(unsafe_code)]
+
+use matilda_core::Inventory;
+use serde::{Deserialize, Serialize};
+
+/// El tipo de recurso sobre el que opera una acción.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Resource {
+    Host,
+    Container,
+    VHost,
+}
+
+impl Resource {
+    fn label(self) -> &'static str {
+        match self {
+            Resource::Host => "host",
+            Resource::Container => "contenedor",
+            Resource::VHost => "vhost",
+        }
+    }
+}
+
+/// La operación de una acción.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Op {
+    Create,
+    Update,
+    Remove,
+}
+
+impl Op {
+    fn verb(self) -> &'static str {
+        match self {
+            Op::Create => "crear",
+            Op::Update => "actualizar",
+            Op::Remove => "eliminar",
+        }
+    }
+}
+
+/// Una acción del plan: operar sobre un recurso identificado por nombre.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Action {
+    pub op: Op,
+    pub resource: Resource,
+    /// Nombre del recurso — `name` del host/contenedor, `domain` del vhost.
+    pub name: String,
+}
+
+impl Action {
+    fn new(op: Op, resource: Resource, name: impl Into<String>) -> Self {
+        Self { op, resource, name: name.into() }
+    }
+
+    /// Descripción legible — `"crear contenedor «web»"`.
+    pub fn describe(&self) -> String {
+        format!("{} {} «{}»", self.op.verb(), self.resource.label(), self.name)
+    }
+}
+
+/// El plan de reconciliación: acciones en orden de aplicación.
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Plan {
+    pub actions: Vec<Action>,
+}
+
+impl Plan {
+    /// `true` si no hay nada que cambiar — los inventarios ya coinciden.
+    pub fn is_empty(&self) -> bool {
+        self.actions.is_empty()
+    }
+
+    /// Cantidad de acciones.
+    pub fn len(&self) -> usize {
+        self.actions.len()
+    }
+
+    /// Cuenta las acciones de una operación dada.
+    pub fn count(&self, op: Op) -> usize {
+        self.actions.iter().filter(|a| a.op == op).count()
+    }
+}
+
+/// Calcula el plan que lleva de `current` a `desired`.
+pub fn plan(current: &Inventory, desired: &Inventory) -> Plan {
+    let mut actions: Vec<Action> = Vec::new();
+
+    // --- Fase 1: hosts a crear/actualizar ---
+    for h in desired.hosts() {
+        match current.host(&h.name) {
+            None => actions.push(Action::new(Op::Create, Resource::Host, &h.name)),
+            Some(cur) if cur != h => {
+                actions.push(Action::new(Op::Update, Resource::Host, &h.name))
+            }
+            Some(_) => {}
+        }
+    }
+
+    // --- Fase 2: contenedores a crear/actualizar ---
+    for c in desired.containers() {
+        match current.container(&c.name) {
+            None => actions.push(Action::new(Op::Create, Resource::Container, &c.name)),
+            Some(cur) if cur != c => {
+                actions.push(Action::new(Op::Update, Resource::Container, &c.name))
+            }
+            Some(_) => {}
+        }
+    }
+
+    // --- Fase 3: vhosts a crear/actualizar ---
+    for v in desired.vhosts() {
+        match current.vhost(&v.domain) {
+            None => actions.push(Action::new(Op::Create, Resource::VHost, &v.domain)),
+            Some(cur) if cur != v => {
+                actions.push(Action::new(Op::Update, Resource::VHost, &v.domain))
+            }
+            Some(_) => {}
+        }
+    }
+
+    // --- Fase 4: vhosts a eliminar (antes que sus contenedores) ---
+    for v in current.vhosts() {
+        if desired.vhost(&v.domain).is_none() {
+            actions.push(Action::new(Op::Remove, Resource::VHost, &v.domain));
+        }
+    }
+
+    // --- Fase 5: contenedores a eliminar ---
+    for c in current.containers() {
+        if desired.container(&c.name).is_none() {
+            actions.push(Action::new(Op::Remove, Resource::Container, &c.name));
+        }
+    }
+
+    // --- Fase 6: hosts a eliminar ---
+    for h in current.hosts() {
+        if desired.host(&h.name).is_none() {
+            actions.push(Action::new(Op::Remove, Resource::Host, &h.name));
+        }
+    }
+
+    Plan { actions }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use matilda_core::{Container, Host, VHost};
+
+    #[test]
+    fn empty_to_empty_is_a_noop() {
+        let p = plan(&Inventory::new(), &Inventory::new());
+        assert!(p.is_empty());
+    }
+
+    #[test]
+    fn fresh_inventory_is_all_creates() {
+        let mut desired = Inventory::new();
+        desired.add_host(Host::new("edge", "10.0.0.1"));
+        desired.add_container(Container::new("web", "nginx"));
+        desired.add_vhost(VHost::to_container("site.com", "web", 80));
+        let p = plan(&Inventory::new(), &desired);
+        assert_eq!(p.count(Op::Create), 3);
+        assert_eq!(p.count(Op::Remove), 0);
+    }
+
+    #[test]
+    fn unchanged_inventory_yields_no_actions() {
+        let mut inv = Inventory::new();
+        inv.add_container(Container::new("web", "nginx:1.27"));
+        let p = plan(&inv, &inv.clone());
+        assert!(p.is_empty());
+    }
+
+    #[test]
+    fn changed_image_is_an_update() {
+        let mut current = Inventory::new();
+        current.add_container(Container::new("web", "nginx:1.26"));
+        let mut desired = Inventory::new();
+        desired.add_container(Container::new("web", "nginx:1.27"));
+        let p = plan(&current, &desired);
+        assert_eq!(p.actions, vec![Action::new(Op::Update, Resource::Container, "web")]);
+    }
+
+    #[test]
+    fn dropped_resources_become_removes() {
+        let mut current = Inventory::new();
+        current.add_container(Container::new("old", "img"));
+        current.add_vhost(VHost::to_container("old.com", "old", 80));
+        let p = plan(&current, &Inventory::new());
+        assert_eq!(p.count(Op::Remove), 2);
+    }
+
+    #[test]
+    fn vhost_removal_precedes_container_removal() {
+        // Un vhost debe eliminarse antes que el contenedor que lo sirve.
+        let mut current = Inventory::new();
+        current.add_container(Container::new("web", "nginx"));
+        current.add_vhost(VHost::to_container("site.com", "web", 80));
+        let p = plan(&current, &Inventory::new());
+        let vhost_pos = p
+            .actions
+            .iter()
+            .position(|a| a.resource == Resource::VHost)
+            .unwrap();
+        let cont_pos = p
+            .actions
+            .iter()
+            .position(|a| a.resource == Resource::Container)
+            .unwrap();
+        assert!(vhost_pos < cont_pos);
+    }
+
+    #[test]
+    fn container_creation_precedes_vhost_creation() {
+        let mut desired = Inventory::new();
+        desired.add_container(Container::new("web", "nginx"));
+        desired.add_vhost(VHost::to_container("site.com", "web", 80));
+        let p = plan(&Inventory::new(), &desired);
+        let cont_pos = p
+            .actions
+            .iter()
+            .position(|a| a.resource == Resource::Container)
+            .unwrap();
+        let vhost_pos = p
+            .actions
+            .iter()
+            .position(|a| a.resource == Resource::VHost)
+            .unwrap();
+        assert!(cont_pos < vhost_pos);
+    }
+
+    #[test]
+    fn plan_is_deterministic() {
+        let mut current = Inventory::new();
+        current.add_container(Container::new("a", "img:1"));
+        let mut desired = Inventory::new();
+        desired.add_container(Container::new("a", "img:2"));
+        desired.add_container(Container::new("b", "img:1"));
+        assert_eq!(plan(&current, &desired), plan(&current, &desired));
+    }
+
+    #[test]
+    fn describe_is_human_readable() {
+        let a = Action::new(Op::Create, Resource::Container, "web");
+        assert_eq!(a.describe(), "crear contenedor «web»");
+    }
+}
diff --git a/02_ruway/shuma/sandbox/shuma-card/Cargo.toml b/02_ruway/shuma/sandbox/shuma-card/Cargo.toml
new file mode 100644
index 0000000..810de74
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-card/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "shuma-card"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "Tipos de shipote: WorkspaceSpec, PipelineSpec, CommandRef, FlowEdge. Compilan a Cards de card-core."
+
+[dependencies]
+card-core = { workspace = true }
+# Orquestador único de la suite (Linux): shuma compila sus specs a `Intent`
+# de sandokan en vez de manejar su propio ciclo de vida. Ver SDD de sandokan.
+sandokan-core = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+toml = { workspace = true }
+thiserror = { workspace = true }
+ulid = { workspace = true }
+
+[dev-dependencies]
+# Prueba end-to-end del puente: spec de shuma → Intent → proceso aislado real.
+sandokan-local = { workspace = true }
+tokio = { workspace = true }
diff --git a/02_ruway/shuma/sandbox/shuma-card/LEEME.md b/02_ruway/shuma/sandbox/shuma-card/LEEME.md
new file mode 100644
index 0000000..5f77c7a
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-card/LEEME.md
@@ -0,0 +1,9 @@
+# shuma-card
+
+> Card escritorio de [shuma](../../README.md).
+
+Stat-card con sesiones activas + última actividad. Click abre el shell.
+
+## Deps
+
+- [`shuma-core`](../shuma-core/README.md), [`llimphi-widget-stat-card`](../../../llimphi/widgets/stat-card/README.md)
diff --git a/02_ruway/shuma/sandbox/shuma-card/README.md b/02_ruway/shuma/sandbox/shuma-card/README.md
new file mode 100644
index 0000000..29de46e
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-card/README.md
@@ -0,0 +1,9 @@
+# shuma-card
+
+> Desktop card of [shuma](../../README.md).
+
+Stat-card with active sessions + last activity. Click opens the shell.
+
+## Deps
+
+- [`shuma-core`](../shuma-core/README.md), [`llimphi-widget-stat-card`](../../../llimphi/widgets/stat-card/README.md)
diff --git a/02_ruway/shuma/sandbox/shuma-card/src/lib.rs b/02_ruway/shuma/sandbox/shuma-card/src/lib.rs
new file mode 100644
index 0000000..1972c63
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-card/src/lib.rs
@@ -0,0 +1,774 @@
+//! `shuma-card` — tipos del runtime shuma.
+//!
+//! Tres entidades nuevas encima del `brahman-card::Card`:
+//!
+//! - [`WorkspaceSpec`] — espacio aislado raíz con su propio `SomaSpec`.
+//! - [`CommandRef`] — un comando dentro de un workspace.
+//! - [`PipelineSpec`] — DAG de `CommandRef` conectados por `FlowEdge`.
+//!
+//! Cada `WorkspaceSpec`/`CommandRef` se **compila** a una o varias
+//! [`card_core::Card`] que el daemon entrega al [`Incarnator`] de
+//! `ente-incarnate`. Esto preserva el contrato canónico del fractal.
+
+#![forbid(unsafe_code)]
+
+use card_core::{Card, Payload, Permissions, SomaSpec, Supervision};
+use sandokan_core::{Intent, IsolationLevel};
+use serde::{Deserialize, Serialize};
+use std::time::Duration;
+use thiserror::Error;
+use ulid::Ulid;
+
+// =====================================================================
+// Identidades
+// =====================================================================
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub struct WorkspaceId(pub Ulid);
+
+impl WorkspaceId {
+    pub fn new() -> Self {
+        Self(Ulid::new())
+    }
+}
+
+impl Default for WorkspaceId {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl std::fmt::Display for WorkspaceId {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        self.0.fmt(f)
+    }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub struct PipelineId(pub Ulid);
+
+impl PipelineId {
+    pub fn new() -> Self {
+        Self(Ulid::new())
+    }
+}
+
+impl Default for PipelineId {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl std::fmt::Display for PipelineId {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        self.0.fmt(f)
+    }
+}
+
+// =====================================================================
+// Workspace
+// =====================================================================
+
+/// Espacio aislado de shuma. Es la raíz de aislamiento — cualquier comando
+/// que corre dentro hereda restricciones y no puede aflojarlas.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct WorkspaceSpec {
+    pub label: String,
+
+    /// Aislamiento del workspace mismo (cuando se materializa como Card raíz).
+    #[serde(default)]
+    pub soma: SomaSpec,
+
+    /// Permisos máximos para hijas. Hijas pueden bajar pero no subir.
+    #[serde(default)]
+    pub permissions: Permissions,
+
+    /// `None` = vive hasta `stop`. `Some(d)` = el daemon lo termina tras d.
+    #[serde(default, with = "opt_duration_millis")]
+    pub ttl: Option<Duration>,
+
+    /// Slots de flow pre-declarados. Limitan qué consumidores externos al
+    /// workspace pueden empatar contra los productores internos.
+    #[serde(default)]
+    pub flow_dirs: Vec<FlowSlot>,
+
+    /// Política al terminar el workspace.
+    #[serde(default)]
+    pub on_exit: ExitPolicy,
+
+    /// Política de enforcement automático cuando un recurso excede su
+    /// rlimit declarado en `soma.rlimits`. Default = sólo accounting
+    /// (None) — el quota report sigue funcionando, pero no hay kill.
+    #[serde(default)]
+    pub quota_enforce: QuotaEnforcement,
+}
+
+/// Acción cuando un recurso excede su límite. Aplica por recurso (mem,
+/// nproc, ...).
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum QuotaAction {
+    /// Sólo accounting: la breach aparece en `workspace_quota`.
+    #[default]
+    None,
+    /// Loguear la breach (info-level del daemon).
+    Log,
+    /// Matar todos los comandos vivos del workspace (SIGKILL, sin grace).
+    Kill,
+}
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct QuotaEnforcement {
+    #[serde(default)]
+    pub mem: QuotaAction,
+    #[serde(default)]
+    pub nproc: QuotaAction,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct FlowSlot {
+    pub name: String,
+    pub direction: FlowDirection,
+    /// Si `Workspace`, sólo otros nodos del mismo workspace pueden empatar.
+    /// Si `Public`, el broker global puede emparejar.
+    #[serde(default)]
+    pub scope: FlowScope,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum FlowDirection {
+    Input,
+    Output,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum FlowScope {
+    #[default]
+    Workspace,
+    Public,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum ExitPolicy {
+    /// Reapear procesos hijos y descartar estado.
+    #[default]
+    Reap,
+    /// Mantener el workspace en `stopped` para inspección.
+    Keep,
+    /// Tomar snapshot del estado (para restart posterior).
+    Snapshot,
+}
+
+mod opt_duration_millis {
+    use serde::{Deserialize, Deserializer, Serialize, Serializer};
+    use std::time::Duration;
+
+    pub fn serialize<S: Serializer>(d: &Option<Duration>, s: S) -> Result<S::Ok, S::Error> {
+        d.map(|x| x.as_millis() as u64).serialize(s)
+    }
+
+    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Duration>, D::Error> {
+        let v: Option<u64> = Option::deserialize(d)?;
+        Ok(v.map(Duration::from_millis))
+    }
+}
+
+// =====================================================================
+// CommandRef
+// =====================================================================
+
+/// Un comando que vive dentro de un workspace. Se compila a una `Card` con
+/// `pin_to` apuntando al workspace padre (label) y su `SomaSpec`
+/// intersectado con el del workspace.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct CommandRef {
+    pub label: String,
+    pub payload: Payload,
+
+    /// SomaSpec del comando. El compilador lo intersecta con el del workspace.
+    #[serde(default)]
+    pub soma: SomaSpec,
+
+    /// Inputs/outputs tipados (mismos `Flow` de brahman-card).
+    #[serde(default)]
+    pub flows: card_core::Flows,
+
+    /// Política de supervisión. Default `OneShot` (un comando se ejecuta y muere).
+    #[serde(default = "default_oneshot")]
+    pub supervision: Supervision,
+}
+
+fn default_oneshot() -> Supervision {
+    Supervision::OneShot
+}
+
+// =====================================================================
+// Pipeline
+// =====================================================================
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PipelineSpec {
+    pub label: String,
+    pub workspace: WorkspaceId,
+    pub nodes: Vec<CommandRef>,
+    #[serde(default)]
+    pub edges: Vec<FlowEdge>,
+    #[serde(default)]
+    pub discern: DiscernPolicy,
+    /// Si `true` y cualquier comando del pipeline termina con exit!=0,
+    /// el daemon relaunch el pipeline ENTERO (stop + nuevo run_pipeline).
+    /// Útil para pipelines de procesamiento continuo.
+    #[serde(default)]
+    pub restart_on_failure: bool,
+    /// Backoff inicial entre restarts (ms). Crece exponencialmente
+    /// hasta `restart_max_backoff_ms`. Default 200ms = ~5 restarts/s
+    /// inicial, escalando rápido.
+    #[serde(default = "default_restart_backoff")]
+    pub restart_backoff_ms: u64,
+    /// Backoff máximo (ms). Default 30s. El backoff no crece más allá.
+    #[serde(default = "default_restart_max_backoff")]
+    pub restart_max_backoff_ms: u64,
+    /// Máximo de restarts antes de dar up. `0` = infinito. Default 0.
+    /// Útil para fail-loud cuando un pipeline siempre falla.
+    #[serde(default)]
+    pub restart_max: u32,
+}
+
+fn default_restart_backoff() -> u64 {
+    200
+}
+fn default_restart_max_backoff() -> u64 {
+    30_000
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct FlowEdge {
+    /// Índice en `PipelineSpec.nodes` del productor.
+    pub from: usize,
+    /// Nombre del Flow output del productor.
+    pub from_output: String,
+    /// Índice en `PipelineSpec.nodes` del consumidor.
+    pub to: usize,
+    /// Nombre del Flow input del consumidor.
+    pub to_input: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct DiscernPolicy {
+    /// Bytes a samplear por flow para el discernidor. Default 4 KiB.
+    #[serde(default = "default_sample_bytes")]
+    pub sample_bytes: usize,
+    /// Si `true`, enriquece la Card del producer con el TypeRef detectado.
+    #[serde(default = "default_true")]
+    pub enrich_producer: bool,
+    /// Chunks que el FlowChannel guarda en replay buffer para subscribers
+    /// tarde. Default 32. Subir si los productores escriben en ráfagas y
+    /// querés que los consumidores tardíos vean toda la salida.
+    #[serde(default = "default_replay_chunks")]
+    pub replay_chunks: usize,
+    /// Tope adicional por **bytes** acumulados en el replay buffer. Lo
+    /// que se exceda primero (chunks o bytes) drop-ea el chunk más viejo.
+    /// `0` = sin tope por bytes (sólo aplica `replay_chunks`). Útil para
+    /// productores con chunks de tamaño variable.
+    #[serde(default)]
+    pub replay_bytes: usize,
+    /// Rate-limit del flow channel (bytes/s). `0` = sin límite. Si está
+    /// definido, el splitter sleeps proporcional al tamaño del chunk
+    /// antes de re-broadcastear. Protege subscribers lentos.
+    #[serde(default)]
+    pub max_bytes_per_sec: u64,
+}
+
+impl Default for DiscernPolicy {
+    fn default() -> Self {
+        Self {
+            sample_bytes: default_sample_bytes(),
+            enrich_producer: default_true(),
+            replay_chunks: default_replay_chunks(),
+            replay_bytes: 0,
+            max_bytes_per_sec: 0,
+        }
+    }
+}
+
+fn default_sample_bytes() -> usize {
+    4096
+}
+fn default_true() -> bool {
+    true
+}
+fn default_replay_chunks() -> usize {
+    32
+}
+
+// =====================================================================
+// Compilación a Card
+// =====================================================================
+
+#[derive(Debug, Error)]
+pub enum CompileError {
+    #[error("workspace label vacío")]
+    EmptyWorkspaceLabel,
+    #[error("comando con label vacío en posición {0}")]
+    EmptyCommandLabel(usize),
+    #[error("edge fuera de rango: from={from}, to={to}, nodes={nodes}")]
+    EdgeOutOfBounds { from: usize, to: usize, nodes: usize },
+}
+
+impl WorkspaceSpec {
+    /// Compila el WorkspaceSpec a una Card raíz que el Incarnator puede
+    /// encarnar. Usa `Payload::Virtual` (el workspace no es un proceso por
+    /// sí solo; sólo aloja hijos).
+    pub fn to_card(&self, id: WorkspaceId) -> Result<Card, CompileError> {
+        if self.label.trim().is_empty() {
+            return Err(CompileError::EmptyWorkspaceLabel);
+        }
+        let mut c = Card::new(format!("shuma.workspace.{}", self.label));
+        c.id = id.0;
+        c.soma = self.soma.clone();
+        c.permissions = self.permissions.clone();
+        c.payload = Payload::Virtual;
+        c.supervision = Supervision::OneShot;
+        Ok(c)
+    }
+
+    /// Puente al orquestador único (sandokan): compila el workspace a una
+    /// `Intent` que cualquier `sandokan_core::Engine` (LocalEngine en este
+    /// host, DaemonEngine o RemoteEngine) puede encarnar. Es la Card
+    /// `Virtual` contenedora — aloja comandos hijos que se le unen.
+    pub fn to_intent(
+        &self,
+        id: WorkspaceId,
+        isolation: IsolationLevel,
+    ) -> Result<Intent, CompileError> {
+        Ok(Intent::new(self.to_card(id)?).with_isolation(isolation))
+    }
+
+    /// Puente "ambiente = un shell aislado" (slice 1: *solo aislarlo*).
+    /// A diferencia de [`Self::to_card`] (Card `Virtual` contenedora), esto
+    /// produce una Card **Native** que ejecuta `exec`/`argv` tomando el
+    /// `soma` del workspace como su propio aislamiento — el shell vive
+    /// directamente dentro de los namespaces del ambiente, sin un paso de
+    /// "unirse" a un contenedor aparte. La devuelve como `Intent` lista
+    /// para `Engine::run`.
+    pub fn shell_intent(
+        &self,
+        id: WorkspaceId,
+        exec: impl Into<String>,
+        argv: Vec<String>,
+        isolation: IsolationLevel,
+    ) -> Result<Intent, CompileError> {
+        if self.label.trim().is_empty() {
+            return Err(CompileError::EmptyWorkspaceLabel);
+        }
+        let mut c = Card::new(format!("shuma.workspace.{}", self.label));
+        c.id = id.0;
+        c.soma = self.soma.clone();
+        c.permissions = self.permissions.clone();
+        c.payload = Payload::Native {
+            exec: exec.into(),
+            argv,
+            envp: vec![],
+        };
+        c.supervision = Supervision::OneShot;
+        Ok(Intent::new(c).with_isolation(isolation))
+    }
+}
+
+impl CommandRef {
+    /// Compila un CommandRef a Card hija de un workspace. La Card resultante
+    /// referencia al workspace por label en `pin_to` de cada Flow.
+    pub fn to_card(&self, idx: usize, workspace_label: &str) -> Result<Card, CompileError> {
+        if self.label.trim().is_empty() {
+            return Err(CompileError::EmptyCommandLabel(idx));
+        }
+        let mut c = Card::new(format!("shuma.cmd.{}.{}", workspace_label, self.label));
+        c.payload = self.payload.clone();
+        c.soma = intersect_soma(&self.soma, /*workspace*/ &SomaSpec::default());
+        c.supervision = self.supervision.clone();
+        c.flow = self.flows.clone();
+        // pin_to del workspace en cada Flow input/output → el broker prefiere
+        // resolver dentro del mismo workspace cuando hay candidatos múltiples.
+        let pin = format!("shuma.workspace.{}", workspace_label);
+        for f in c.flow.input.iter_mut().chain(c.flow.output.iter_mut()) {
+            if f.pin_to.is_none() {
+                f.pin_to = Some(pin.clone());
+            }
+        }
+        Ok(c)
+    }
+}
+
+/// Intersección conservadora: si el workspace pidió aislamiento, la hija
+/// también lo tiene (no puede aflojar). Si la hija pidió aislamiento extra,
+/// se respeta.
+fn intersect_soma(child: &SomaSpec, ws: &SomaSpec) -> SomaSpec {
+    let mut out = child.clone();
+    out.namespaces.mount  |= ws.namespaces.mount;
+    out.namespaces.pid    |= ws.namespaces.pid;
+    out.namespaces.net    |= ws.namespaces.net;
+    out.namespaces.uts    |= ws.namespaces.uts;
+    out.namespaces.ipc    |= ws.namespaces.ipc;
+    out.namespaces.user   |= ws.namespaces.user;
+    out.namespaces.cgroup |= ws.namespaces.cgroup;
+    // rlimits: el menor (más restrictivo) gana.
+    out.rlimits.mem_bytes = min_opt(out.rlimits.mem_bytes, ws.rlimits.mem_bytes);
+    out.rlimits.nproc = min_opt(out.rlimits.nproc, ws.rlimits.nproc);
+    out.rlimits.nofile = min_opt(out.rlimits.nofile, ws.rlimits.nofile);
+    out
+}
+
+fn min_opt<T: Ord + Copy>(a: Option<T>, b: Option<T>) -> Option<T> {
+    match (a, b) {
+        (Some(x), Some(y)) => Some(x.min(y)),
+        (Some(x), None) | (None, Some(x)) => Some(x),
+        (None, None) => None,
+    }
+}
+
+impl PipelineSpec {
+    pub fn validate(&self) -> Result<(), CompileError> {
+        let n = self.nodes.len();
+        for (i, c) in self.nodes.iter().enumerate() {
+            if c.label.trim().is_empty() {
+                return Err(CompileError::EmptyCommandLabel(i));
+            }
+        }
+        for e in &self.edges {
+            if e.from >= n || e.to >= n {
+                return Err(CompileError::EdgeOutOfBounds {
+                    from: e.from,
+                    to: e.to,
+                    nodes: n,
+                });
+            }
+        }
+        Ok(())
+    }
+}
+
+// =====================================================================
+// I/O conveniencia (TOML + JSON)
+// =====================================================================
+
+#[derive(Debug, Error)]
+pub enum LoadError {
+    #[error("io: {0}")]
+    Io(#[from] std::io::Error),
+    #[error("toml: {0}")]
+    Toml(#[from] toml::de::Error),
+    #[error("json: {0}")]
+    Json(#[from] serde_json::Error),
+    #[error("format desconocido (esperado .toml o .json)")]
+    UnknownFormat,
+}
+
+pub fn load_workspace_spec(path: &std::path::Path) -> Result<WorkspaceSpec, LoadError> {
+    let raw = std::fs::read_to_string(path)?;
+    match path.extension().and_then(|s| s.to_str()) {
+        Some("toml") => Ok(toml::from_str(&raw)?),
+        Some("json") => Ok(serde_json::from_str(&raw)?),
+        _ => Err(LoadError::UnknownFormat),
+    }
+}
+
+pub fn load_pipeline_spec(path: &std::path::Path) -> Result<PipelineSpec, LoadError> {
+    let raw = std::fs::read_to_string(path)?;
+    match path.extension().and_then(|s| s.to_str()) {
+        Some("toml") => Ok(toml::from_str(&raw)?),
+        Some("json") => Ok(serde_json::from_str(&raw)?),
+        _ => Err(LoadError::UnknownFormat),
+    }
+}
+
+/// Sustituye `${KEY}` en todos los strings del spec por el valor de
+/// `vars["KEY"]`. Variables sin match quedan intactas (no se borra el
+/// placeholder — útil para detectar olvidos).
+///
+/// Walk recursivo sobre la representación JSON intermedia para cubrir
+/// labels, argv, envp, paths y cualquier String del schema.
+pub fn substitute_vars(
+    spec: &PipelineSpec,
+    vars: &std::collections::HashMap<String, String>,
+) -> Result<PipelineSpec, serde_json::Error> {
+    if vars.is_empty() {
+        return Ok(spec.clone());
+    }
+    let mut v = serde_json::to_value(spec)?;
+    walk_subst(&mut v, vars);
+    serde_json::from_value(v)
+}
+
+fn walk_subst(v: &mut serde_json::Value, vars: &std::collections::HashMap<String, String>) {
+    match v {
+        serde_json::Value::String(s) => {
+            *s = subst_str(s, vars);
+        }
+        serde_json::Value::Array(arr) => {
+            for item in arr {
+                walk_subst(item, vars);
+            }
+        }
+        serde_json::Value::Object(obj) => {
+            for (_, val) in obj.iter_mut() {
+                walk_subst(val, vars);
+            }
+        }
+        _ => {}
+    }
+}
+
+fn subst_str(s: &str, vars: &std::collections::HashMap<String, String>) -> String {
+    let mut out = String::with_capacity(s.len());
+    let bytes = s.as_bytes();
+    let mut i = 0;
+    while i < bytes.len() {
+        if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'{' {
+            // Buscar el cierre `}`.
+            if let Some(close) = bytes[i + 2..].iter().position(|&b| b == b'}') {
+                let key = std::str::from_utf8(&bytes[i + 2..i + 2 + close]).unwrap_or("");
+                if let Some(val) = vars.get(key) {
+                    out.push_str(val);
+                    i += 2 + close + 1;
+                    continue;
+                }
+            }
+        }
+        out.push(bytes[i] as char);
+        i += 1;
+    }
+    out
+}
+
+#[cfg(test)]
+mod subst_tests {
+    use super::*;
+    use std::collections::HashMap;
+
+    #[test]
+    fn substitute_in_argv_and_label() {
+        let mut vars = HashMap::new();
+        vars.insert("MSG".into(), "hola-mundo".into());
+        vars.insert("LABEL".into(), "renamed".into());
+        let spec = PipelineSpec {
+            label: "p-${LABEL}".into(),
+            workspace: WorkspaceId::new(),
+            nodes: vec![CommandRef {
+                label: "node-${LABEL}".into(),
+                payload: Payload::Native {
+                    exec: "/bin/echo".into(),
+                    argv: vec!["${MSG}".into()],
+                    envp: vec![],
+                },
+                soma: Default::default(),
+                flows: Default::default(),
+                supervision: Supervision::OneShot,
+            }],
+            edges: vec![],
+            discern: DiscernPolicy::default(),
+            restart_on_failure: false,
+            restart_backoff_ms: 200,
+            restart_max_backoff_ms: 30_000,
+            restart_max: 0,
+        };
+        let out = substitute_vars(&spec, &vars).unwrap();
+        assert_eq!(out.label, "p-renamed");
+        assert_eq!(out.nodes[0].label, "node-renamed");
+        match &out.nodes[0].payload {
+            Payload::Native { argv, .. } => assert_eq!(argv[0], "hola-mundo"),
+            _ => panic!("wrong payload"),
+        }
+    }
+
+    #[test]
+    fn unknown_var_left_intact() {
+        let vars = HashMap::new();
+        let spec = PipelineSpec {
+            label: "p-${UNDEFINED}".into(),
+            workspace: WorkspaceId::new(),
+            nodes: vec![],
+            edges: vec![],
+            discern: DiscernPolicy::default(),
+            restart_on_failure: false,
+            restart_backoff_ms: 200,
+            restart_max_backoff_ms: 30_000,
+            restart_max: 0,
+        };
+        let out = substitute_vars(&spec, &vars).unwrap();
+        assert_eq!(out.label, "p-${UNDEFINED}");
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn sample_workspace() -> WorkspaceSpec {
+        WorkspaceSpec {
+            label: "demo".into(),
+            soma: SomaSpec::default(),
+            permissions: Permissions::default(),
+            ttl: Some(Duration::from_secs(60)),
+            flow_dirs: vec![FlowSlot {
+                name: "out".into(),
+                direction: FlowDirection::Output,
+                scope: FlowScope::Public,
+            }],
+            on_exit: ExitPolicy::Reap,
+            quota_enforce: Default::default(),
+        }
+    }
+
+    #[test]
+    fn workspace_toml_roundtrip() {
+        let ws = sample_workspace();
+        let s = toml::to_string(&ws).unwrap();
+        let back: WorkspaceSpec = toml::from_str(&s).unwrap();
+        assert_eq!(back.label, ws.label);
+        assert_eq!(back.ttl, ws.ttl);
+        assert_eq!(back.flow_dirs.len(), 1);
+    }
+
+    #[test]
+    fn workspace_json_roundtrip() {
+        let ws = sample_workspace();
+        let s = serde_json::to_string(&ws).unwrap();
+        let back: WorkspaceSpec = serde_json::from_str(&s).unwrap();
+        assert_eq!(back.label, ws.label);
+    }
+
+    #[test]
+    fn workspace_compiles_to_card() {
+        let ws = sample_workspace();
+        let id = WorkspaceId::new();
+        let c = ws.to_card(id).unwrap();
+        assert_eq!(c.id, id.0);
+        assert!(c.label.starts_with("shuma.workspace."));
+        assert!(matches!(c.payload, Payload::Virtual));
+    }
+
+    #[test]
+    fn empty_label_rejected() {
+        let mut ws = sample_workspace();
+        ws.label = String::new();
+        assert!(ws.to_card(WorkspaceId::new()).is_err());
+    }
+
+    #[test]
+    fn workspace_compiles_to_intent_virtual() {
+        let ws = sample_workspace();
+        let intent = ws
+            .to_intent(WorkspaceId::new(), IsolationLevel::Standard)
+            .unwrap();
+        assert!(matches!(intent.card.payload, Payload::Virtual));
+        assert_eq!(intent.context.isolation, Some(IsolationLevel::Standard));
+    }
+
+    #[test]
+    fn shell_intent_is_native_and_inherits_workspace_soma() {
+        let mut ws = sample_workspace();
+        ws.soma.namespaces.user = true;
+        ws.soma.namespaces.pid = true;
+        let intent = ws
+            .shell_intent(
+                WorkspaceId::new(),
+                "/bin/sh",
+                vec!["-c".into(), "true".into()],
+                IsolationLevel::Standard,
+            )
+            .unwrap();
+        match &intent.card.payload {
+            Payload::Native { exec, .. } => assert_eq!(exec, "/bin/sh"),
+            other => panic!("esperaba Native, fue {other:?}"),
+        }
+        assert!(
+            intent.card.soma.namespaces.pid,
+            "el shell del ambiente hereda el soma del workspace"
+        );
+    }
+
+    /// Unificación del orquestador: un `WorkspaceSpec` de shuma se materializa
+    /// como un proceso aislado real **a través del `Engine` de sandokan**
+    /// (LocalEngine), sin que shuma maneje su propio ciclo de vida. El shell
+    /// sale 0 sólo si vio PID 1, probando que el aislamiento del ambiente se
+    /// aplicó end-to-end por el camino unificado.
+    #[tokio::test]
+    async fn workspace_shell_runs_isolated_via_sandokan_engine() {
+        use card_core::NamespaceSet;
+        use sandokan_core::Engine;
+        use sandokan_local::LocalEngine;
+
+        let mut ws = sample_workspace();
+        ws.soma.namespaces = NamespaceSet {
+            user: true,
+            pid: true,
+            mount: true,
+            uts: true,
+            ipc: true,
+            net: false,
+            cgroup: false,
+        };
+
+        let intent = ws
+            .shell_intent(
+                WorkspaceId::new(),
+                "/bin/sh",
+                vec!["-c".into(), "test $$ -eq 1".into()],
+                IsolationLevel::Standard,
+            )
+            .unwrap();
+        let id = intent.card_id();
+
+        let engine = LocalEngine::new();
+        engine.run(intent).await.expect("run vía sandokan");
+        tokio::time::sleep(Duration::from_millis(300)).await;
+        let st = engine.status(id).await.expect("status");
+        assert!(st.is_terminal(), "el shell debió terminar, fue {st:?}");
+        assert!(
+            !st.is_failure(),
+            "exit 0 ⇒ el shell vio PID 1 (ambiente aislado). Estado: {st:?}"
+        );
+    }
+
+    #[test]
+    fn pipeline_validates_edges() {
+        let p = PipelineSpec {
+            label: "p".into(),
+            workspace: WorkspaceId::new(),
+            nodes: vec![CommandRef {
+                label: "a".into(),
+                payload: Payload::Virtual,
+                soma: SomaSpec::default(),
+                flows: card_core::Flows::default(),
+                supervision: Supervision::OneShot,
+            }],
+            edges: vec![FlowEdge {
+                from: 0,
+                from_output: "x".into(),
+                to: 5,
+                to_input: "y".into(),
+            }],
+            discern: DiscernPolicy::default(),
+            restart_on_failure: false,
+            restart_backoff_ms: 200,
+            restart_max_backoff_ms: 30_000,
+            restart_max: 0,
+        };
+        assert!(p.validate().is_err());
+    }
+
+    #[test]
+    fn intersect_soma_takes_more_restrictive() {
+        let mut child = SomaSpec::default();
+        child.rlimits.mem_bytes = Some(1_000_000);
+        let mut ws = SomaSpec::default();
+        ws.rlimits.mem_bytes = Some(500_000);
+        ws.namespaces.user = true;
+        let r = intersect_soma(&child, &ws);
+        assert_eq!(r.rlimits.mem_bytes, Some(500_000));
+        assert!(r.namespaces.user);
+    }
+}
diff --git a/02_ruway/shuma/sandbox/shuma-config/Cargo.toml b/02_ruway/shuma/sandbox/shuma-config/Cargo.toml
new file mode 100644
index 0000000..816b07d
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-config/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "shuma-config"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "shuma — fichero de configuración (.shumarc.toml): aliases, prompt, env, dedup/captura por sesión."
+
+[dependencies]
+serde = { workspace = true }
+toml = { workspace = true }
+directories = { workspace = true }
+
+[dev-dependencies]
+tempfile = { workspace = true }
diff --git a/02_ruway/shuma/sandbox/shuma-config/LEEME.md b/02_ruway/shuma/sandbox/shuma-config/LEEME.md
new file mode 100644
index 0000000..20c68d8
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-config/LEEME.md
@@ -0,0 +1,9 @@
+# shuma-config
+
+> Config del shell de [shuma](../../README.md).
+
+Aliases, env, prompt template, atajos, plugins. TOML serializable. Recarga en runtime sin reiniciar la sesión.
+
+## Deps
+
+- [`shuma-core`](../shuma-core/README.md), `serde`, `toml`
diff --git a/02_ruway/shuma/sandbox/shuma-config/README.md b/02_ruway/shuma/sandbox/shuma-config/README.md
new file mode 100644
index 0000000..06c7cd9
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-config/README.md
@@ -0,0 +1,9 @@
+# shuma-config
+
+> Shell config of [shuma](../../README.md).
+
+Aliases, env, prompt template, shortcuts, plugins. TOML serializable. Runtime reload without restarting the session.
+
+## Deps
+
+- [`shuma-core`](../shuma-core/README.md), `serde`, `toml`
diff --git a/02_ruway/shuma/sandbox/shuma-config/completions.example/cargo.toml b/02_ruway/shuma/sandbox/shuma-config/completions.example/cargo.toml
new file mode 100644
index 0000000..eef0d1b
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-config/completions.example/cargo.toml
@@ -0,0 +1,29 @@
+# ============================================================
+# cargo.toml — completions extra para `cargo` en shuma-shell.
+#
+# Copiá este fichero a `~/.config/shuma/completions/cargo.toml`
+# (creando el directorio si no existe). Los flags listados se SUMAN al
+# catálogo built-in de shuma-line; no lo reemplazan. Ideal para flags
+# personalizados o nuevos que aún no estén en el catálogo.
+#
+# Convenciones:
+# - Un flag por entrada; el motor de completion los filtra por prefijo.
+# - Si el flag espera valor, terminá en `=` (p.ej. `--manifest-path=`):
+#   tras `=` shuma-shell pasa a completar paths.
+# - El array `flags` es lo único soportado hoy; en el futuro se
+#   sumarán `subcommands` y `args` con tipo (path/host/etc.).
+# ============================================================
+
+flags = [
+    "--target-dir=",
+    "--manifest-path=",
+    "--profile=",
+    "--config=",
+    "--build-plan",
+    "--message-format=human",
+    "--message-format=json",
+    "--message-format=short",
+    "--timings",
+    "--unit-graph",
+    "-Z",
+]
diff --git a/02_ruway/shuma/sandbox/shuma-config/shumarc.example.toml b/02_ruway/shuma/sandbox/shuma-config/shumarc.example.toml
new file mode 100644
index 0000000..38388f8
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-config/shumarc.example.toml
@@ -0,0 +1,62 @@
+# ============================================================
+# shumarc.toml — configuración personal de `shuma-shell`.
+#
+# Copiá este fichero a `~/.config/shuma/shumarc.toml` (o el
+# equivalente XDG que use tu SO) y editá lo que quieras. Cualquier
+# sección omitida cae a los valores por defecto.
+# ============================================================
+
+# ---- Aliases ----
+# La primera palabra de la línea de comando se reemplaza por el
+# cuerpo si coincide con un alias. No hay parámetros posicionales
+# ni recursión: lo que devuelve el alias es lo que se ejecuta.
+[aliases]
+ll  = "ls -la --color=auto"
+la  = "ls -A --color=auto"
+gs  = "git status --short"
+gl  = "git log --oneline --graph --decorate"
+gd  = "git diff"
+cb  = "cargo build"
+ct  = "cargo test"
+cr  = "cargo run"
+
+# ---- Variables de entorno ----
+# Se aplican al proceso del shell al arrancar; los hijos las heredan.
+[env]
+EDITOR  = "hx"
+PAGER   = "less -R"
+
+# ---- Prompt ----
+# Segmentos en orden. Tokens soportados:
+#   "cwd"   — directorio actual (con `~` corto si es $HOME).
+#   "git"   — rama actual, si estamos en un repo.
+#   "exit"  — código de salida del último comando, si fue ≠ 0.
+#   "time"  — HH:MM:SS local.
+#   <otro>  — literal, se muestra tal cual (útil como separador).
+[prompt]
+segments = ["cwd", "git", "exit"]
+
+# ---- Historial durable ----
+# Política de dedup al persistir en ~/.local/share/shuma/history.jsonl:
+#   "none"               — guarda todo.
+#   "ignore_consecutive" — descarta repeticiones inmediatas (default).
+#   "erase_dups"         — al ver un duplicado, borra las copias previas.
+[history]
+dedup = "ignore_consecutive"
+
+# ---- Captura de salida por sesión ----
+# `limit_mb = 0` desactiva el tope; `spill = true` vuelca al disco la
+# salida que excede el tope (útil para builds gigantes).
+[capture]
+limit_mb = 8
+spill = false
+
+# ---- Completion de flags ----
+# El catálogo built-in de shuma-line cubre ~40 comandos típicos. Para
+# ampliarlo, dejá un archivo por comando en
+# `$XDG_CONFIG_HOME/shuma/completions/<cmd>.toml` con la forma:
+#
+#   flags = ["--mi-flag", "--otro=", "-x"]
+#
+# Los flags terminados en `=` activan completion de path tras el `=`.
+# Ver `completions.example/cargo.toml` en el repo para un caso real.
diff --git a/02_ruway/shuma/sandbox/shuma-config/src/lib.rs b/02_ruway/shuma/sandbox/shuma-config/src/lib.rs
new file mode 100644
index 0000000..9524ec0
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-config/src/lib.rs
@@ -0,0 +1,474 @@
+//! `shuma-config` — el fichero de configuración personal del shell.
+//!
+//! Se carga al arrancar desde `$XDG_CONFIG_HOME/shuma/shumarc.toml`
+//! (típicamente `~/.config/shuma/shumarc.toml` en Linux). Si no existe
+//! o no se pudo parsear, el shell arranca con [`Config::default`] —
+//! aquí no hay nada crítico, sólo preferencias del usuario.
+//!
+//! Esquema mínimo:
+//!
+//! ```toml
+//! # ---- Aliases ----
+//! # Se expanden ANTES del tokenizer: la primera palabra de la línea,
+//! # si coincide, se reemplaza por el cuerpo.
+//! [aliases]
+//! ll = "ls -la"
+//! gs = "git status --short"
+//!
+//! # ---- Variables de entorno ----
+//! # Se exportan al proceso del shell al cargar; los procesos hijos las
+//! # heredan.
+//! [env]
+//! EDITOR = "hx"
+//!
+//! # ---- Prompt ----
+//! # Segmentos en orden. Tokens soportados:
+//! #   "cwd", "git", "exit", "time", o cualquier literal.
+//! [prompt]
+//! segments = ["cwd", "git", "exit"]
+//!
+//! # ---- Historial durable ----
+//! [history]
+//! dedup = "ignore_consecutive"  # none | ignore_consecutive | erase_dups
+//!
+//! # ---- Captura de salida ----
+//! [capture]
+//! limit_mb = 8
+//! spill = false
+//! ```
+
+#![forbid(unsafe_code)]
+
+use std::collections::HashMap;
+use std::path::{Path, PathBuf};
+
+use serde::{Deserialize, Serialize};
+
+/// Política de deduplicación, paralela a la de `shuma-history` pero
+/// codificada como string en el fichero TOML para que el rc sea legible.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum DedupPolicy {
+    None,
+    #[default]
+    IgnoreConsecutive,
+    EraseDups,
+}
+
+/// Configuración del historial durable.
+#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
+pub struct HistoryConfig {
+    #[serde(default)]
+    pub dedup: DedupPolicy,
+}
+
+/// Configuración de la política de captura de salida por sesión.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct CaptureConfig {
+    /// Tope en MiB; `0` = sin tope.
+    #[serde(default = "default_limit_mb")]
+    pub limit_mb: usize,
+    /// Si la salida que excede el tope se vuelca a disco.
+    #[serde(default)]
+    pub spill: bool,
+}
+
+fn default_limit_mb() -> usize {
+    8
+}
+
+impl Default for CaptureConfig {
+    fn default() -> Self {
+        Self { limit_mb: 8, spill: false }
+    }
+}
+
+/// Configuración del prompt — segmentos en orden.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PromptConfig {
+    #[serde(default = "default_segments")]
+    pub segments: Vec<String>,
+}
+
+fn default_segments() -> Vec<String> {
+    vec!["cwd".into()]
+}
+
+impl Default for PromptConfig {
+    fn default() -> Self {
+        Self { segments: default_segments() }
+    }
+}
+
+/// Configuración completa cargada del `.shumarc.toml`.
+#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
+pub struct Config {
+    /// Aliases: la primera palabra de una línea se reemplaza por el cuerpo.
+    #[serde(default)]
+    pub aliases: HashMap<String, String>,
+    /// Variables de entorno a exportar al proceso del shell al cargar.
+    #[serde(default)]
+    pub env: HashMap<String, String>,
+    #[serde(default)]
+    pub prompt: PromptConfig,
+    #[serde(default)]
+    pub history: HistoryConfig,
+    #[serde(default)]
+    pub capture: CaptureConfig,
+}
+
+impl Config {
+    /// Ruta por defecto: `$XDG_CONFIG_HOME/shuma/shumarc.toml`. `None` si
+    /// el SO no expone un directorio de configuración.
+    pub fn default_path() -> Option<PathBuf> {
+        directories::ProjectDirs::from("", "", "shuma")
+            .map(|d| d.config_dir().join("shumarc.toml"))
+    }
+
+    /// Directorio donde el shell busca completions extendidas:
+    /// `$XDG_CONFIG_HOME/shuma/completions/`. Cada archivo `<cmd>.toml`
+    /// declara las flags de un comando — el shell las suma a la tabla
+    /// estática de [`shuma_line::flag_hints`].
+    pub fn completions_dir() -> Option<PathBuf> {
+        directories::ProjectDirs::from("", "", "shuma")
+            .map(|d| d.config_dir().join("completions"))
+    }
+
+    /// Carga la configuración del path indicado. Si el fichero no existe
+    /// devuelve [`Config::default`] sin error (caso normal en arranque
+    /// limpio).
+    pub fn load(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
+        let path = path.as_ref();
+        if !path.exists() {
+            return Ok(Self::default());
+        }
+        let text = std::fs::read_to_string(path)
+            .map_err(|e| ConfigError::Io(path.to_path_buf(), e))?;
+        toml::from_str(&text).map_err(|e| ConfigError::Parse(path.to_path_buf(), e))
+    }
+
+    /// Carga la configuración del path por defecto. Errores blandos
+    /// (parse, IO) se devuelven; ausencia del fichero da `default`.
+    pub fn load_default() -> Result<Self, ConfigError> {
+        match Self::default_path() {
+            Some(p) => Self::load(p),
+            None => Ok(Self::default()),
+        }
+    }
+
+    /// Aplica las variables de entorno declaradas al proceso actual.
+    /// Pensado para llamarse una vez al arrancar el shell; los procesos
+    /// hijos heredan el entorno y verán los valores.
+    pub fn apply_env(&self) {
+        for (k, v) in &self.env {
+            // SAFETY: setenv no es seguro en presencia de hilos concurrentes
+            // que lean getenv. El shell la llama una vez en el hilo principal,
+            // antes de spawnear ningún subproceso, así que es válido.
+            // SAFETY (Rust 2024): `set_var` es unsafe sólo bajo
+            // edición 2024; en 2021 sigue siendo seguro.
+            std::env::set_var(k, v);
+        }
+    }
+
+    /// Expande aliases en una línea: si la **primera palabra** coincide
+    /// con un alias, se reemplaza por su cuerpo. El resto de la línea
+    /// queda intacto.
+    ///
+    /// Convención simple — sin parámetros posicionales, sin recursión
+    /// (un alias se expande una vez, no se persigue el resultado).
+    pub fn expand_aliases<'a>(&self, line: &'a str) -> std::borrow::Cow<'a, str> {
+        let trimmed = line.trim_start();
+        let leading_ws = line.len() - trimmed.len();
+        let (head, rest) = match trimmed.find(char::is_whitespace) {
+            Some(i) => (&trimmed[..i], &trimmed[i..]),
+            None => (trimmed, ""),
+        };
+        if let Some(body) = self.aliases.get(head) {
+            let mut out = String::with_capacity(line.len() + body.len());
+            out.push_str(&line[..leading_ws]);
+            out.push_str(body);
+            out.push_str(rest);
+            std::borrow::Cow::Owned(out)
+        } else {
+            std::borrow::Cow::Borrowed(line)
+        }
+    }
+}
+
+impl From<DedupPolicy> for &'static str {
+    fn from(p: DedupPolicy) -> Self {
+        match p {
+            DedupPolicy::None => "none",
+            DedupPolicy::IgnoreConsecutive => "ignore_consecutive",
+            DedupPolicy::EraseDups => "erase_dups",
+        }
+    }
+}
+
+/// Expande `$VAR` y `${VAR}` en un texto contra `getenv`. Si la variable
+/// no existe, se sustituye por cadena vacía — convención bash. Las
+/// barras `\$` escapan el signo.
+pub fn expand_env(s: &str) -> String {
+    let mut out = String::with_capacity(s.len());
+    let bytes = s.as_bytes();
+    let mut i = 0;
+    while i < bytes.len() {
+        let c = bytes[i];
+        if c == b'\\' && i + 1 < bytes.len() && bytes[i + 1] == b'$' {
+            out.push('$');
+            i += 2;
+            continue;
+        }
+        if c != b'$' {
+            out.push(bytes[i] as char);
+            i += 1;
+            continue;
+        }
+        // `$VAR` o `${VAR}`.
+        let (name_end, with_braces) = if i + 1 < bytes.len() && bytes[i + 1] == b'{' {
+            // `${VAR}` — buscar la `}` que cierra.
+            match s[i + 2..].find('}') {
+                Some(off) => (i + 2 + off, true),
+                None => {
+                    out.push('$');
+                    i += 1;
+                    continue;
+                }
+            }
+        } else {
+            let start = i + 1;
+            let mut end = start;
+            while end < bytes.len()
+                && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_')
+            {
+                end += 1;
+            }
+            if end == start {
+                // `$` solo: literal.
+                out.push('$');
+                i += 1;
+                continue;
+            }
+            (end, false)
+        };
+        let name_start = if with_braces { i + 2 } else { i + 1 };
+        let name = &s[name_start..name_end];
+        if let Ok(val) = std::env::var(name) {
+            out.push_str(&val);
+        }
+        i = name_end + if with_braces { 1 } else { 0 };
+    }
+    out
+}
+
+/// Completion declarada por el usuario para un comando concreto.
+/// Esquema mínimo en `<cmd>.toml`:
+///
+/// ```toml
+/// flags = ["--foo", "--bar=", "-x"]
+/// ```
+#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
+pub struct CommandCompletion {
+    #[serde(default)]
+    pub flags: Vec<String>,
+}
+
+impl CommandCompletion {
+    /// Carga `<dir>/<cmd>.toml` si existe, o devuelve `None`. Si el
+    /// archivo está roto, también `None` — completions son nice-to-have,
+    /// no deben caer el shell.
+    pub fn load(dir: &Path, command: &str) -> Option<Self> {
+        let path = dir.join(format!("{command}.toml"));
+        let text = std::fs::read_to_string(path).ok()?;
+        toml::from_str(&text).ok()
+    }
+
+    /// Carga *todas* las completions de un directorio en un HashMap.
+    /// Útil para precargar al arrancar el shell (un read_dir + N lecturas
+    /// pequeñas; barato comparado con el coste de un fork).
+    pub fn load_all(dir: &Path) -> HashMap<String, Self> {
+        let mut out = HashMap::new();
+        let Ok(entries) = std::fs::read_dir(dir) else {
+            return out;
+        };
+        for e in entries.flatten() {
+            let path = e.path();
+            let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
+                continue;
+            };
+            if path.extension().and_then(|e| e.to_str()) != Some("toml") {
+                continue;
+            }
+            if let Ok(text) = std::fs::read_to_string(&path) {
+                if let Ok(c) = toml::from_str::<CommandCompletion>(&text) {
+                    out.insert(stem.to_string(), c);
+                }
+            }
+        }
+        out
+    }
+}
+
+/// Errores al cargar la configuración.
+#[derive(Debug)]
+pub enum ConfigError {
+    Io(PathBuf, std::io::Error),
+    Parse(PathBuf, toml::de::Error),
+}
+
+impl std::fmt::Display for ConfigError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            ConfigError::Io(p, e) => write!(f, "lectura de {}: {}", p.display(), e),
+            ConfigError::Parse(p, e) => write!(f, "parseo de {}: {}", p.display(), e),
+        }
+    }
+}
+
+impl std::error::Error for ConfigError {}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use tempfile::tempdir;
+
+    #[test]
+    fn missing_file_yields_default() {
+        let d = tempdir().unwrap();
+        let c = Config::load(d.path().join("nope.toml")).unwrap();
+        assert_eq!(c, Config::default());
+    }
+
+    #[test]
+    fn parses_a_full_example() {
+        let d = tempdir().unwrap();
+        let path = d.path().join("shumarc.toml");
+        std::fs::write(
+            &path,
+            r#"
+[aliases]
+ll = "ls -la"
+gs = "git status"
+
+[env]
+EDITOR = "hx"
+
+[prompt]
+segments = ["cwd", "git", "exit"]
+
+[history]
+dedup = "erase_dups"
+
+[capture]
+limit_mb = 16
+spill = true
+"#,
+        )
+        .unwrap();
+        let c = Config::load(&path).unwrap();
+        assert_eq!(c.aliases.get("ll").map(|s| s.as_str()), Some("ls -la"));
+        assert_eq!(c.env.get("EDITOR").map(|s| s.as_str()), Some("hx"));
+        assert_eq!(c.prompt.segments, vec!["cwd", "git", "exit"]);
+        assert_eq!(c.history.dedup, DedupPolicy::EraseDups);
+        assert_eq!(c.capture.limit_mb, 16);
+        assert!(c.capture.spill);
+    }
+
+    #[test]
+    fn partial_toml_falls_back_to_defaults() {
+        // Sólo aliases — el resto debe defaultear, no fallar.
+        let d = tempdir().unwrap();
+        let path = d.path().join("shumarc.toml");
+        std::fs::write(&path, "[aliases]\nll = \"ls -la\"\n").unwrap();
+        let c = Config::load(&path).unwrap();
+        assert_eq!(c.aliases.len(), 1);
+        assert_eq!(c.prompt, PromptConfig::default());
+        assert_eq!(c.capture, CaptureConfig::default());
+    }
+
+    #[test]
+    fn alias_expansion_replaces_first_word_only() {
+        let mut c = Config::default();
+        c.aliases.insert("ll".into(), "ls -la".into());
+        assert_eq!(c.expand_aliases("ll"), "ls -la");
+        assert_eq!(c.expand_aliases("ll src/"), "ls -la src/");
+        // `ll` en el medio no es un alias.
+        assert_eq!(c.expand_aliases("echo ll"), "echo ll");
+    }
+
+    #[test]
+    fn alias_preserves_leading_whitespace() {
+        let mut c = Config::default();
+        c.aliases.insert("ll".into(), "ls -la".into());
+        // Un comando indentado mantiene su indentación tras expandir.
+        assert_eq!(c.expand_aliases("  ll src/"), "  ls -la src/");
+    }
+
+    #[test]
+    fn alias_does_not_recurse() {
+        // No queremos que un alias expandido se vuelva a expandir —
+        // evita bucles infinitos triviales (ll=ls, ls=ll).
+        let mut c = Config::default();
+        c.aliases.insert("a".into(), "b".into());
+        c.aliases.insert("b".into(), "c".into());
+        assert_eq!(c.expand_aliases("a"), "b");
+    }
+
+    #[test]
+    fn expand_env_substitutes_vars() {
+        // Usamos una var artificial para no colisionar con el entorno real.
+        // SAFETY: ver `Config::apply_env`; en tests de un solo hilo es OK.
+        std::env::set_var("SHUMA_TEST_VAR", "valor");
+        assert_eq!(expand_env("$SHUMA_TEST_VAR"), "valor");
+        assert_eq!(expand_env("${SHUMA_TEST_VAR}/bin"), "valor/bin");
+        // Variable inexistente → cadena vacía.
+        std::env::remove_var("SHUMA_TEST_NOPE");
+        assert_eq!(expand_env("x=$SHUMA_TEST_NOPE!"), "x=!");
+        // `\$` se escapa.
+        assert_eq!(expand_env("precio \\$5"), "precio $5");
+    }
+
+    #[test]
+    fn expand_env_keeps_dollar_alone() {
+        std::env::remove_var("SHUMA_TEST_FOO");
+        assert_eq!(expand_env("$ "), "$ ");
+        assert_eq!(expand_env("$"), "$");
+    }
+
+    #[test]
+    fn completion_loads_per_command_file() {
+        let d = tempdir().unwrap();
+        std::fs::write(
+            d.path().join("mytool.toml"),
+            "flags = [\"--foo\", \"--bar=\"]\n",
+        )
+        .unwrap();
+        let c = CommandCompletion::load(d.path(), "mytool").unwrap();
+        assert_eq!(c.flags, vec!["--foo", "--bar="]);
+        // Comando inexistente → None.
+        assert!(CommandCompletion::load(d.path(), "nope").is_none());
+    }
+
+    #[test]
+    fn completion_loads_all_in_dir() {
+        let d = tempdir().unwrap();
+        std::fs::write(d.path().join("alfa.toml"), "flags = [\"--a\"]\n").unwrap();
+        std::fs::write(d.path().join("beta.toml"), "flags = [\"--b\"]\n").unwrap();
+        std::fs::write(d.path().join("ignored.txt"), "no soy toml").unwrap();
+        let all = CommandCompletion::load_all(d.path());
+        assert_eq!(all.len(), 2);
+        assert!(all.contains_key("alfa"));
+        assert!(all.contains_key("beta"));
+        assert!(!all.contains_key("ignored"));
+    }
+
+    #[test]
+    fn corrupt_completion_file_is_skipped() {
+        let d = tempdir().unwrap();
+        std::fs::write(d.path().join("bad.toml"), "not = valid = toml").unwrap();
+        std::fs::write(d.path().join("good.toml"), "flags = [\"--ok\"]\n").unwrap();
+        let all = CommandCompletion::load_all(d.path());
+        assert!(all.contains_key("good"));
+        assert!(!all.contains_key("bad"));
+    }
+}
diff --git a/02_ruway/shuma/sandbox/shuma-core/Cargo.toml b/02_ruway/shuma/sandbox/shuma-core/Cargo.toml
new file mode 100644
index 0000000..5f22db8
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-core/Cargo.toml
@@ -0,0 +1,27 @@
+[package]
+name = "shuma-core"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "Runtime de shuma: WorkspaceManager sobre arje-incarnate. Estado in-memory, lifecycle, reaping."
+
+[dependencies]
+shuma-card = { path = "../shuma-card" }
+shuma-discern = { workspace = true }
+card-core = { workspace = true }
+arje-incarnate = { workspace = true }
+nix = { workspace = true }
+libc = { workspace = true }
+anyhow = { workspace = true }
+thiserror = { workspace = true }
+tokio = { workspace = true }
+tracing = { workspace = true }
+ulid = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+
+[dev-dependencies]
+tempfile = { workspace = true }
diff --git a/02_ruway/shuma/sandbox/shuma-core/LEEME.md b/02_ruway/shuma/sandbox/shuma-core/LEEME.md
new file mode 100644
index 0000000..4332090
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-core/LEEME.md
@@ -0,0 +1,9 @@
+# shuma-core
+
+> Tipos de [shuma](../../README.md): `Session`, `Command`, `Output`.
+
+Núcleo sin transporte ni UI. `Session` lleva history, env, cwd. `Command` representa lo tipeado. `Output` puede ser texto, imagen, link.
+
+## Deps
+
+- `serde`, `uuid`
diff --git a/02_ruway/shuma/sandbox/shuma-core/README.md b/02_ruway/shuma/sandbox/shuma-core/README.md
new file mode 100644
index 0000000..19a39c4
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-core/README.md
@@ -0,0 +1,9 @@
+# shuma-core
+
+> Types of [shuma](../../README.md): `Session`, `Command`, `Output`.
+
+Core without transport or UI. `Session` holds history, env, cwd. `Command` represents what the user typed. `Output` can be text, image, link.
+
+## Deps
+
+- `serde`, `uuid`
diff --git a/02_ruway/shuma/sandbox/shuma-core/src/flow_channel.rs b/02_ruway/shuma/sandbox/shuma-core/src/flow_channel.rs
new file mode 100644
index 0000000..a56797f
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-core/src/flow_channel.rs
@@ -0,0 +1,478 @@
+//! Flow channels: data plane sobre Unix socket por edge enriquecido.
+//!
+//! Cuando un splitter detecta el TypeRef de un edge, además de replicar a
+//! los consumers internos del pipeline, se levanta un FlowChannel que
+//! expone los bytes a subscribers externos (otros módulos del fractal).
+//!
+//! ## Diseño
+//!
+//! - `tokio::sync::broadcast::channel` para fan-out lock-less entre el
+//!   splitter (sender) y los N subscribers conectados.
+//! - `UnixListener` accept-loop: por cada cliente nuevo, spawn una task
+//!   que drena el receiver y escribe al socket.
+//! - Subscribers lentos pueden perder mensajes (broadcast::Receiver::Lagged)
+//!   — se loguea warn y se sigue. Esto es deliberado para no bloquear el
+//!   splitter en consumers lentos.
+//!
+//! ## Lifetime
+//!
+//! `FlowChannel` se construye con `new(path)`. Cuando se drop:
+//! - El `accept_task` se cancela (vía drop del `tokio::task::JoinHandle`
+//!   que tenemos abort-on-drop).
+//! - El socket file se borra del FS (`Drop` impl).
+//!
+//! Sender clones son baratos; los subscribers conectados se enteran del
+//! cierre cuando todos los senders se dropean.
+
+use std::collections::VecDeque;
+use std::path::{Path, PathBuf};
+use std::sync::{Arc, Mutex};
+use tokio::io::AsyncWriteExt;
+use tokio::net::UnixListener;
+use tokio::sync::broadcast;
+use tokio::task::AbortHandle;
+use tracing::{debug, warn};
+
+/// Capacidad del broadcast channel. Si un subscriber está más de N chunks
+/// atrasado, queda `Lagged` y empieza a perder mensajes.
+const BROADCAST_CAP: usize = 64;
+
+/// Chunks default del replay buffer. Cuando un cliente nuevo se conecta,
+/// recibe hasta estos N chunks antes de iniciar el broadcast live.
+/// Override via `FlowChannel::with_replay_cap`.
+pub const DEFAULT_REPLAY_CHUNKS: usize = 32;
+
+pub struct FlowChannel {
+    sender: broadcast::Sender<Arc<Vec<u8>>>,
+    replay: Arc<Mutex<VecDeque<Arc<Vec<u8>>>>>,
+    replay_caps: ReplayCaps,
+    socket_path: PathBuf,
+    meter: Arc<FlowMeter>,
+    _accept_handle: AbortOnDrop,
+}
+
+/// Contador de bytes y rate (bytes/s ventana 1s).
+#[derive(Debug)]
+pub struct FlowMeter {
+    /// Bytes acumulados desde la creación del FlowChannel.
+    total_bytes: std::sync::atomic::AtomicU64,
+    /// Ring buffer de (timestamp_ms, bytes_acumulados) para calcular
+    /// el rate sobre los últimos N samples.
+    rate_window: Mutex<VecDeque<(u64, u64)>>,
+}
+
+const RATE_WINDOW_SAMPLES: usize = 32;
+
+impl FlowMeter {
+    fn new() -> Self {
+        Self {
+            total_bytes: std::sync::atomic::AtomicU64::new(0),
+            rate_window: Mutex::new(VecDeque::with_capacity(RATE_WINDOW_SAMPLES)),
+        }
+    }
+
+    fn record(&self, delta: u64) {
+        let now = self.total_bytes
+            .fetch_add(delta, std::sync::atomic::Ordering::Relaxed)
+            + delta;
+        let ts = std::time::SystemTime::now()
+            .duration_since(std::time::UNIX_EPOCH)
+            .map(|d| d.as_millis() as u64)
+            .unwrap_or(0);
+        if let Ok(mut w) = self.rate_window.lock() {
+            if w.len() >= RATE_WINDOW_SAMPLES {
+                w.pop_front();
+            }
+            w.push_back((ts, now));
+        }
+    }
+
+    /// Bytes totales acumulados desde la creación.
+    pub fn total_bytes(&self) -> u64 {
+        self.total_bytes.load(std::sync::atomic::Ordering::Relaxed)
+    }
+
+    /// Bytes por segundo (rolling sobre la ventana). 0 si no hay
+    /// historia suficiente o si el último sample es muy viejo (>5s).
+    pub fn bytes_per_sec(&self) -> f64 {
+        let now_ms = std::time::SystemTime::now()
+            .duration_since(std::time::UNIX_EPOCH)
+            .map(|d| d.as_millis() as u64)
+            .unwrap_or(0);
+        let w = match self.rate_window.lock() {
+            Ok(w) => w,
+            Err(_) => return 0.0,
+        };
+        if w.len() < 2 {
+            return 0.0;
+        }
+        let last = w.back().copied().unwrap();
+        // Si el último sample tiene >5s, asumimos idle.
+        if now_ms.saturating_sub(last.0) > 5000 {
+            return 0.0;
+        }
+        let first = w.front().copied().unwrap();
+        let dt_ms = last.0.saturating_sub(first.0).max(1);
+        let d_bytes = last.1.saturating_sub(first.1);
+        (d_bytes as f64 * 1000.0) / dt_ms as f64
+    }
+}
+
+#[derive(Debug, Clone, Copy)]
+pub struct ReplayCaps {
+    /// Máximo de chunks retenidos.
+    pub chunks: usize,
+    /// Máximo de bytes (sumando len de chunks). `0` = sin tope.
+    pub bytes: usize,
+}
+
+impl ReplayCaps {
+    pub fn chunks_only(chunks: usize) -> Self {
+        Self { chunks: chunks.max(1), bytes: 0 }
+    }
+    pub fn new(chunks: usize, bytes: usize) -> Self {
+        Self { chunks: chunks.max(1), bytes }
+    }
+}
+
+#[derive(Clone)]
+pub struct FlowSender {
+    sender: broadcast::Sender<Arc<Vec<u8>>>,
+    replay: Arc<Mutex<VecDeque<Arc<Vec<u8>>>>>,
+    replay_caps: ReplayCaps,
+    meter: Arc<FlowMeter>,
+}
+
+impl FlowSender {
+    /// Pushea al broadcast y al replay buffer. Si no hay subscribers,
+    /// el broadcast::send retorna Err pero igual guardamos en replay
+    /// (subscribers tarde verán los chunks pasados).
+    pub fn send(&self, data: Arc<Vec<u8>>) {
+        let incoming = data.len();
+        let caps = self.replay_caps;
+        if let Ok(mut g) = self.replay.lock() {
+            evict_for_incoming(&mut g, caps, incoming);
+            g.push_back(data.clone());
+        }
+        self.meter.record(incoming as u64);
+        let _ = self.sender.send(data);
+    }
+}
+
+/// Evict los chunks más viejos para hacer espacio a un chunk entrante de
+/// `incoming` bytes — el buffer post-push queda dentro de los caps.
+fn evict_for_incoming(buf: &mut VecDeque<Arc<Vec<u8>>>, caps: ReplayCaps, incoming: usize) {
+    // 1) chunks: dejar lugar para 1 más.
+    while buf.len() + 1 > caps.chunks {
+        if buf.pop_front().is_none() {
+            break;
+        }
+    }
+    // 2) bytes (si está activado).
+    if caps.bytes > 0 {
+        let mut current: usize = buf.iter().map(|a| a.len()).sum();
+        while current + incoming > caps.bytes {
+            match buf.pop_front() {
+                Some(c) => current = current.saturating_sub(c.len()),
+                None => break,
+            }
+        }
+    }
+}
+
+impl std::fmt::Debug for FlowChannel {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("FlowChannel")
+            .field("socket_path", &self.socket_path)
+            .field("subscribers", &self.sender.receiver_count())
+            .finish()
+    }
+}
+
+impl FlowChannel {
+    /// Crea un FlowChannel atado al path `socket_path`. Si el path ya
+    /// existe, lo borra antes de bind (asume restart limpio).
+    pub fn new(socket_path: PathBuf) -> std::io::Result<Self> {
+        Self::with_replay_caps(socket_path, ReplayCaps::chunks_only(DEFAULT_REPLAY_CHUNKS))
+    }
+
+    pub fn with_replay_cap(socket_path: PathBuf, chunks: usize) -> std::io::Result<Self> {
+        Self::with_replay_caps(socket_path, ReplayCaps::chunks_only(chunks))
+    }
+
+    pub fn with_replay_caps(socket_path: PathBuf, caps: ReplayCaps) -> std::io::Result<Self> {
+        if socket_path.exists() {
+            let _ = std::fs::remove_file(&socket_path);
+        }
+        if let Some(parent) = socket_path.parent() {
+            let _ = std::fs::create_dir_all(parent);
+        }
+        let listener = UnixListener::bind(&socket_path)?;
+        let (tx, _rx_unused) = broadcast::channel::<Arc<Vec<u8>>>(BROADCAST_CAP);
+        let replay: Arc<Mutex<VecDeque<Arc<Vec<u8>>>>> =
+            Arc::new(Mutex::new(VecDeque::with_capacity(caps.chunks)));
+        let tx_for_accept = tx.clone();
+        let replay_for_accept = replay.clone();
+        let path_for_log = socket_path.clone();
+
+        let join = tokio::spawn(async move {
+            debug!(path = %path_for_log.display(), "flow channel listening");
+            loop {
+                let (mut stream, _addr) = match listener.accept().await {
+                    Ok(p) => p,
+                    Err(e) => {
+                        warn!(?e, "flow channel accept failed");
+                        return;
+                    }
+                };
+                // Snapshot del replay buffer Y subscribe al broadcast.
+                // El orden es crítico: subscribe ANTES de drenar el replay
+                // para no perder chunks que llegan justo en el medio.
+                let mut rx = tx_for_accept.subscribe();
+                let snapshot: Vec<Arc<Vec<u8>>> = {
+                    let g = replay_for_accept.lock().expect("replay lock");
+                    g.iter().cloned().collect()
+                };
+                tokio::spawn(async move {
+                    // Fase 1: drenar replay snapshot al subscriber.
+                    for chunk in &snapshot {
+                        if let Err(e) = stream.write_all(chunk).await {
+                            debug!(?e, "flow subscriber dropped during replay");
+                            return;
+                        }
+                    }
+                    // Fase 2: live broadcast.
+                    loop {
+                        match rx.recv().await {
+                            Ok(chunk) => {
+                                if let Err(e) = stream.write_all(&chunk).await {
+                                    debug!(?e, "flow subscriber dropped");
+                                    return;
+                                }
+                            }
+                            Err(broadcast::error::RecvError::Closed) => return,
+                            Err(broadcast::error::RecvError::Lagged(n)) => {
+                                warn!(skipped = n, "flow subscriber lagged");
+                            }
+                        }
+                    }
+                });
+            }
+        });
+
+        Ok(Self {
+            sender: tx,
+            replay,
+            replay_caps: caps,
+            socket_path,
+            meter: Arc::new(FlowMeter::new()),
+            _accept_handle: AbortOnDrop(join.abort_handle()),
+        })
+    }
+
+    pub fn meter(&self) -> &FlowMeter {
+        &self.meter
+    }
+
+    /// Push un chunk al channel. Si no hay subscribers, drop silencioso.
+    /// Siempre se guarda en el replay buffer (con cap rotation por chunks
+    /// y opcionalmente por bytes).
+    pub fn send(&self, data: Vec<u8>) {
+        let incoming = data.len();
+        let arc = Arc::new(data);
+        let caps = self.replay_caps;
+        if let Ok(mut g) = self.replay.lock() {
+            evict_for_incoming(&mut g, caps, incoming);
+            g.push_back(arc.clone());
+        }
+        self.meter.record(incoming as u64);
+        let _ = self.sender.send(arc);
+    }
+
+    pub fn socket_path(&self) -> &Path {
+        &self.socket_path
+    }
+
+    /// Handle clone-able para que tasks externas (splitter) pushen al
+    /// channel sin tener ownership del FlowChannel. Cada push se guarda
+    /// también en el replay buffer y se contabiliza en el meter.
+    pub fn sender_handle(&self) -> FlowSender {
+        FlowSender {
+            sender: self.sender.clone(),
+            replay: self.replay.clone(),
+            replay_caps: self.replay_caps,
+            meter: self.meter.clone(),
+        }
+    }
+
+    pub fn subscriber_count(&self) -> usize {
+        self.sender.receiver_count()
+    }
+}
+
+impl Drop for FlowChannel {
+    fn drop(&mut self) {
+        // El AbortOnDrop cancela el accept loop; sólo nos queda limpiar el
+        // socket file.
+        let _ = std::fs::remove_file(&self.socket_path);
+    }
+}
+
+struct AbortOnDrop(AbortHandle);
+impl Drop for AbortOnDrop {
+    fn drop(&mut self) {
+        self.0.abort();
+    }
+}
+
+/// Path canónico para un flow channel: `$XDG_RUNTIME_DIR/shuma-flow-<id>.sock`.
+pub fn default_flow_socket_path(id: &str) -> PathBuf {
+    let base = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| {
+        let uid = nix::unistd::getuid().as_raw();
+        let p = format!("/run/user/{uid}");
+        if std::path::Path::new(&p).exists() {
+            p
+        } else {
+            "/tmp".into()
+        }
+    });
+    PathBuf::from(base).join(format!("shuma-flow-{id}.sock"))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use tokio::io::AsyncReadExt;
+    use tokio::net::UnixStream;
+
+    #[tokio::test]
+    async fn channel_delivers_to_subscriber() {
+        let tmp = tempfile::tempdir().unwrap();
+        let path = tmp.path().join("flow.sock");
+        let ch = FlowChannel::new(path.clone()).unwrap();
+
+        // Subscriber se conecta.
+        let path_clone = path.clone();
+        let task = tokio::spawn(async move {
+            let mut stream = UnixStream::connect(&path_clone).await.unwrap();
+            let mut buf = vec![0u8; 64];
+            let n = stream.read(&mut buf).await.unwrap();
+            buf.truncate(n);
+            buf
+        });
+
+        // Damos tiempo al accept.
+        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
+        // Hasta que haya 1 receiver_count, el send no llega.
+        for _ in 0..50 {
+            if ch.subscriber_count() >= 1 {
+                break;
+            }
+            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
+        }
+        ch.send(b"hello-flow".to_vec());
+
+        let received = tokio::time::timeout(std::time::Duration::from_secs(2), task)
+            .await
+            .expect("timeout")
+            .unwrap();
+        assert_eq!(received, b"hello-flow");
+    }
+
+    #[tokio::test]
+    async fn replay_buffer_serves_late_subscriber() {
+        let tmp = tempfile::tempdir().unwrap();
+        let path = tmp.path().join("flow.sock");
+        let ch = FlowChannel::new(path.clone()).unwrap();
+
+        // Pushes ANTES de cualquier subscriber: van solo al replay.
+        ch.send(b"chunk-1".to_vec());
+        ch.send(b"chunk-2".to_vec());
+        ch.send(b"chunk-3".to_vec());
+
+        // Subscriber LATE — debe recibir los 3 chunks del replay.
+        let path_clone = path.clone();
+        let task = tokio::spawn(async move {
+            let mut stream = UnixStream::connect(&path_clone).await.unwrap();
+            let mut buf = vec![0u8; 256];
+            // Leemos hasta recibir los 3 chunks (21 bytes esperados).
+            let mut total = Vec::new();
+            for _ in 0..20 {
+                let n = stream.read(&mut buf).await.unwrap();
+                if n == 0 {
+                    break;
+                }
+                total.extend_from_slice(&buf[..n]);
+                if total.len() >= 21 {
+                    break;
+                }
+            }
+            total
+        });
+
+        let received = tokio::time::timeout(std::time::Duration::from_secs(2), task)
+            .await
+            .expect("timeout")
+            .unwrap();
+        let s = String::from_utf8_lossy(&received);
+        assert!(s.contains("chunk-1"), "got: {s:?}");
+        assert!(s.contains("chunk-2"), "got: {s:?}");
+        assert!(s.contains("chunk-3"), "got: {s:?}");
+    }
+
+    #[tokio::test]
+    async fn replay_evicts_by_bytes_cap() {
+        let tmp = tempfile::tempdir().unwrap();
+        let path = tmp.path().join("flow.sock");
+        // chunks=100 (no limita), bytes=20: deberíamos retener sólo los
+        // últimos chunks cuyos bytes sumen ≤ 20.
+        let ch = FlowChannel::with_replay_caps(path.clone(), ReplayCaps::new(100, 20)).unwrap();
+        ch.send(b"AAAAAAAA".to_vec()); // 8 bytes
+        ch.send(b"BBBBBBBB".to_vec()); // 8 → total 16
+        ch.send(b"CCCCCCCC".to_vec()); // 8 → total 24 > 20, evict A → 16
+        ch.send(b"DDDDDDDD".to_vec()); // 8 → total 24 > 20, evict B → 16
+
+        let path_clone = path.clone();
+        let task = tokio::spawn(async move {
+            let mut stream = UnixStream::connect(&path_clone).await.unwrap();
+            let mut buf = vec![0u8; 64];
+            let mut total = Vec::new();
+            for _ in 0..20 {
+                let n = stream.read(&mut buf).await.unwrap();
+                if n == 0 {
+                    break;
+                }
+                total.extend_from_slice(&buf[..n]);
+                if total.len() >= 16 {
+                    break;
+                }
+            }
+            total
+        });
+        let got = tokio::time::timeout(std::time::Duration::from_secs(2), task)
+            .await
+            .expect("timeout")
+            .unwrap();
+        let s = String::from_utf8_lossy(&got);
+        // Sólo C y D (los más viejos A y B fueron evicted).
+        assert!(!s.contains("AAAA"), "should have evicted A: {s:?}");
+        assert!(!s.contains("BBBB"), "should have evicted B: {s:?}");
+        assert!(s.contains("CCCC"), "should keep C: {s:?}");
+        assert!(s.contains("DDDD"), "should keep D: {s:?}");
+    }
+
+    #[tokio::test]
+    async fn drop_removes_socket() {
+        let tmp = tempfile::tempdir().unwrap();
+        let path = tmp.path().join("flow.sock");
+        {
+            let _ch = FlowChannel::new(path.clone()).unwrap();
+            assert!(path.exists());
+        }
+        // Después del drop, el socket file no debe quedar.
+        // Damos un pelín de tiempo al runtime para que el drop corra
+        // mientras estamos en task.
+        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
+        assert!(!path.exists());
+    }
+}
diff --git a/02_ruway/shuma/sandbox/shuma-core/src/lib.rs b/02_ruway/shuma/sandbox/shuma-core/src/lib.rs
new file mode 100644
index 0000000..a97d7ba
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-core/src/lib.rs
@@ -0,0 +1,261 @@
+//! `shuma-core` — runtime in-memory de Workspaces y comandos.
+//!
+//! Mantiene un estado tokio-friendly (Mutex sobre HashMap) con:
+//! - Workspaces vivos (id → state).
+//! - PIDs de comandos lanzados, indexados por workspace.
+//! - Reaping cooperativo: `reap_dead()` cosecha hijos terminados.
+
+// `pipeline` necesita `unsafe` puntual para `libc::close` y construir
+// `OwnedFd` desde fds que armamos con `pipe2(2)`. El resto del crate
+// permanece safe — el cargo lint `unsafe_code` queda permitido sólo en
+// el módulo concreto.
+#![deny(unsafe_op_in_unsafe_fn)]
+
+pub mod flow_channel;
+pub mod logbuf;
+pub mod persist;
+pub mod pipeline;
+pub mod stats;
+
+use card_core::{Card, Payload, Supervision};
+use arje_incarnate::{Incarnator, IncarnatorConfig};
+use nix::sys::signal::{kill, Signal};
+use nix::sys::wait::{waitpid, WaitPidFlag, WaitStatus};
+use nix::unistd::Pid;
+use shuma_card::{CommandRef, PipelineSpec, WorkspaceId, WorkspaceSpec};
+use std::collections::HashMap;
+use std::sync::Arc;
+use std::time::Instant;
+use thiserror::Error;
+use tokio::sync::Mutex;
+use tracing::{info, warn};
+use ulid::Ulid;
+
+#[derive(Debug, Error)]
+pub enum CoreError {
+    #[error("workspace {0} not found")]
+    WorkspaceNotFound(WorkspaceId),
+    #[error("compile: {0}")]
+    Compile(#[from] shuma_card::CompileError),
+    #[error("incarnate: {0}")]
+    Incarnate(#[from] arje_incarnate::IncarnateError),
+}
+
+#[derive(Debug)]
+pub struct WorkspaceState {
+    pub id: WorkspaceId,
+    pub spec: WorkspaceSpec,
+    pub root_card: Card,
+    pub commands: HashMap<Ulid, CommandState>,
+    pub started: Instant,
+    /// Última muestra de `(wall_instant, cpu_usec)` usada para calcular
+    /// `cpu_percent` en la próxima medición. None hasta el primer measure.
+    pub last_cpu_sample: Option<(Instant, u64)>,
+    /// Ring buffer de samples recientes para sparklines. Se popula cada
+    /// vez que `workspace_stats` se llama (típicamente desde el shell).
+    /// Cap 64 samples = ~2 minutos a 2s/sample.
+    pub stats_history: std::collections::VecDeque<stats::WorkspaceStats>,
+}
+
+const STATS_HISTORY_CAP: usize = 64;
+
+#[derive(Debug, Clone)]
+pub struct CommandState {
+    pub id: Ulid,
+    pub label: String,
+    pub pid: Pid,
+    pub alive: bool,
+    pub exit_status: Option<i32>,
+    /// Ring buffer del stdout. `None` para comandos sin captura.
+    pub stdout: Option<logbuf::LogBuf>,
+    /// Ring buffer del stderr. Separado de `stdout` para que el CLI
+    /// pueda filtrarlos. `None` para comandos sin captura.
+    pub stderr: Option<logbuf::LogBuf>,
+    /// Si el comando fue lanzado como parte de un Pipeline, su ULID.
+    pub pipeline_id: Option<Ulid>,
+}
+
+/// Stream a leer en `get_command_logs`. `Both` concatena stderr-después-stdout
+/// para una vista combinada (orden temporal aproximado).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum LogStream {
+    Stdout,
+    Stderr,
+    Both,
+}
+
+pub struct WorkspaceManager {
+    inner: Arc<Mutex<Inner>>,
+    incarnator: Arc<Incarnator>,
+    /// True si hubo alguna mutación desde el último `save_snapshot`.
+    /// `save_snapshot` skip si false (snapshot incremental — evita
+    /// re-serialize cuando nada cambió, ej. SIGTERM tras un período idle).
+    dirty: std::sync::atomic::AtomicBool,
+}
+
+struct Inner {
+    workspaces: HashMap<WorkspaceId, WorkspaceState>,
+    /// Definiciones nombradas de pipelines persistidas. NO es lo mismo
+    /// que "pipelines vivos" — son specs guardados para reusar con
+    /// `run-saved`. Sobreviven restart vía snapshot.
+    saved_pipelines: HashMap<String, PipelineSpec>,
+    /// Flow channels vivos por pipeline. Se retienen hasta que el
+    /// pipeline termine — cuando todos los hijos del pipeline murieron,
+    /// el reaper los borra (futuro). v1: viven hasta `stop_pipeline_flows`
+    /// explícito o hasta shutdown.
+    pipeline_flows: HashMap<Ulid, Vec<crate::flow_channel::FlowChannel>>,
+    /// Specs de comandos `run()` con `restart_on_failure=true`. Indexed
+    /// por command_id. Cuando `reap_dead` detecta exit!=0, se relauncha
+    /// con la misma spec (nuevo pid y nuevo command_id se asigna por
+    /// el nuevo state pero el restart_spec sigue ligado al original).
+    restart_specs: HashMap<Ulid, RestartSpec>,
+    /// Supervisores de pipelines con `restart_on_failure`. Indexed por
+    /// pipeline_id. Cuando `reap_dead` detecta que el pipeline tuvo
+    /// algún command failure, agrega un entry a `pending_pipeline_restarts`.
+    pipeline_supervisors: HashMap<Ulid, PipelineSupervisor>,
+    /// Cola de pipelines pendientes de restart. El daemon la drena en
+    /// cada loop del reaper, hace stop + run_pipeline.
+    pending_pipeline_restarts: Vec<Ulid>,
+}
+
+#[derive(Debug, Clone)]
+pub struct PipelineSupervisor {
+    pub workspace: WorkspaceId,
+    pub spec: PipelineSpec,
+    pub tap: bool,
+    pub restart_count: u32,
+    /// Backoff actual (ms) — escala exponencialmente con cada restart.
+    pub current_backoff_ms: u64,
+}
+
+#[derive(Debug, Clone)]
+struct RestartSpec {
+    workspace: WorkspaceId,
+    exec: String,
+    argv: Vec<String>,
+    envp: Vec<(String, String)>,
+    /// Backoff inicial (ms). Crece exponencialmente hasta max_backoff_ms.
+    backoff_ms: u64,
+    max_backoff_ms: u64,
+    /// Cantidad de restarts ya ejecutados (para tracking).
+    restart_count: u32,
+}
+
+#[derive(Debug, Clone)]
+pub struct CommandSummary {
+    pub id: Ulid,
+    pub label: String,
+    pub pid: i32,
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct HealthCounts {
+    pub alive_workspaces: u32,
+    pub alive_commands: u32,
+    pub alive_pipelines: u32,
+    pub active_flows: u32,
+}
+
+#[derive(Debug, Clone)]
+pub struct CommandInfo {
+    pub id: Ulid,
+    pub label: String,
+    pub pid: i32,
+    pub alive: bool,
+    pub exit_status: Option<i32>,
+    pub log_bytes: u64,
+}
+
+/// Lee VmRSS (bytes) de `/proc/<pid>/status`. Helper local para
+/// reap_dead que no necesita el full stats. Devuelve 0 si el proc no
+/// existe o el campo no aparece.
+fn read_proc_rss(pid: i32) -> Option<u64> {
+    let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
+    status
+        .lines()
+        .find_map(|l| l.strip_prefix("VmRSS:").map(str::trim))
+        .and_then(|s| s.split_whitespace().next())
+        .and_then(|s| s.parse::<u64>().ok())
+        .map(|kb| kb * 1024)
+}
+
+fn spawn_log_drainer(read_fd: std::os::fd::RawFd, logs: logbuf::LogBuf) {
+    // Marcar non-blocking + envolver en AsyncFd; igual patrón que el tap.
+    // SAFETY: F_SETFL sobre fd válido.
+    unsafe {
+        let flags = libc::fcntl(read_fd, libc::F_GETFL, 0);
+        if flags >= 0 {
+            libc::fcntl(read_fd, libc::F_SETFL, flags | libc::O_NONBLOCK);
+        }
+    }
+    tokio::spawn(async move {
+        // SAFETY: ownership del fd transferido al drainer task.
+        let owned = unsafe { std::os::fd::OwnedFd::from_raw_fd_compat(read_fd) };
+        let afd = match tokio::io::unix::AsyncFd::with_interest(owned, tokio::io::Interest::READABLE) {
+            Ok(a) => a,
+            Err(e) => {
+                tracing::warn!(?e, "log drainer AsyncFd failed");
+                return;
+            }
+        };
+        let mut buf = [0u8; 4096];
+        loop {
+            let mut guard = match afd.readable().await {
+                Ok(g) => g,
+                Err(_) => break,
+            };
+            use std::os::fd::AsRawFd;
+            let fd = afd.as_raw_fd();
+            // SAFETY: read sobre fd válido.
+            let r = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut _, buf.len()) };
+            if r > 0 {
+                logs.append(&buf[..r as usize]);
+                continue;
+            }
+            if r == 0 {
+                break; // EOF
+            }
+            let err = std::io::Error::last_os_error();
+            if err.kind() == std::io::ErrorKind::WouldBlock {
+                guard.clear_ready();
+                continue;
+            }
+            tracing::warn!(?err, "log drainer read err");
+            break;
+        }
+    });
+}
+
+trait OwnedFdFromRawCompat: Sized {
+    unsafe fn from_raw_fd_compat(fd: std::os::fd::RawFd) -> Self;
+}
+
+impl OwnedFdFromRawCompat for std::os::fd::OwnedFd {
+    unsafe fn from_raw_fd_compat(fd: std::os::fd::RawFd) -> Self {
+        use std::os::fd::FromRawFd;
+        // SAFETY: el caller transfiere ownership de fd a OwnedFd.
+        unsafe { std::os::fd::OwnedFd::from_raw_fd(fd) }
+    }
+}
+
+
+#[derive(Debug, Clone)]
+pub struct WorkspaceSnapshot {
+    pub id: WorkspaceId,
+    pub label: String,
+    pub commands: u32,
+    pub uptime_ms: u64,
+}
+
+fn short_ulid(u: &Ulid) -> String {
+    let s = u.to_string();
+    s[s.len() - 6..].to_string()
+}
+
+// `impl WorkspaceManager` partido por dominio (regla dura #1, 1517 LOC):
+mod pipelines;
+mod runtime;
+mod workspaces;
+
+#[cfg(test)]
+mod tests;
diff --git a/02_ruway/shuma/sandbox/shuma-core/src/logbuf.rs b/02_ruway/shuma/sandbox/shuma-core/src/logbuf.rs
new file mode 100644
index 0000000..a5452c0
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-core/src/logbuf.rs
@@ -0,0 +1,122 @@
+//! Ring buffer en memoria para capturar stdout/stderr de comandos.
+//!
+//! Tamaño fijo por comando (config: `MAX_LOG_BYTES`). Cuando se llena,
+//! descarta los bytes más viejos. Pensado para diagnostico rápido, no
+//! para retención histórica — eso es trabajo de un journald-like aparte.
+
+use std::sync::{Arc, Mutex};
+
+/// Bytes máximos retenidos por comando. 64 KiB cubre logs típicos sin
+/// abusar de memoria si el daemon tiene cientos de comandos vivos.
+pub const MAX_LOG_BYTES: usize = 64 * 1024;
+
+#[derive(Debug, Clone)]
+pub struct LogBuf {
+    inner: Arc<Mutex<Inner>>,
+}
+
+#[derive(Debug)]
+struct Inner {
+    /// Bytes raw. Cuando se acerca al cap, descartamos head para mantener
+    /// el tail.
+    buf: Vec<u8>,
+    cap: usize,
+    /// Total escrito alguna vez (no decrementado al recortar).
+    written_total: u64,
+}
+
+impl LogBuf {
+    pub fn new() -> Self {
+        Self::with_cap(MAX_LOG_BYTES)
+    }
+
+    pub fn with_cap(cap: usize) -> Self {
+        Self {
+            inner: Arc::new(Mutex::new(Inner {
+                buf: Vec::with_capacity(cap.min(4096)),
+                cap,
+                written_total: 0,
+            })),
+        }
+    }
+
+    pub fn append(&self, data: &[u8]) {
+        let Ok(mut g) = self.inner.lock() else { return };
+        g.written_total += data.len() as u64;
+        g.buf.extend_from_slice(data);
+        // Recorte cuando excede cap (con un pequeño slack para evitar
+        // shift en cada append). El usuario ve sólo el tail.
+        if g.buf.len() > g.cap + 1024 {
+            let drop = g.buf.len() - g.cap;
+            g.buf.drain(..drop);
+        }
+    }
+
+    /// Devuelve el tail de hasta `n` bytes (o todo si `n=0`).
+    pub fn tail(&self, n: usize) -> Vec<u8> {
+        let g = match self.inner.lock() {
+            Ok(g) => g,
+            Err(_) => return Vec::new(),
+        };
+        if n == 0 || n >= g.buf.len() {
+            return g.buf.clone();
+        }
+        g.buf[g.buf.len() - n..].to_vec()
+    }
+
+    /// Cuántos bytes hay actualmente en el buffer.
+    pub fn len(&self) -> usize {
+        self.inner.lock().map(|g| g.buf.len()).unwrap_or(0)
+    }
+
+    pub fn is_empty(&self) -> bool {
+        self.len() == 0
+    }
+
+    pub fn written_total(&self) -> u64 {
+        self.inner.lock().map(|g| g.written_total).unwrap_or(0)
+    }
+}
+
+impl Default for LogBuf {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn append_and_tail_basic() {
+        let lb = LogBuf::with_cap(100);
+        lb.append(b"hello ");
+        lb.append(b"world\n");
+        let t = lb.tail(0);
+        assert_eq!(t, b"hello world\n");
+    }
+
+    #[test]
+    fn cap_drops_oldest() {
+        let lb = LogBuf::with_cap(10);
+        lb.append(&[b'a'; 8]);
+        lb.append(&[b'b'; 8]);
+        // Después del recorte, debe quedar ~10 bytes pero el slack
+        // permite hasta 10+1024. Como pasamos slack, no se recorta aún
+        // en este caso (16 bytes < 10+1024). Forzamos un append grande.
+        lb.append(&[b'c'; 2048]);
+        assert!(lb.len() <= 10 + 1024);
+        let t = lb.tail(0);
+        // El tail debe contener 'c's (los más recientes).
+        assert!(t.iter().filter(|&&b| b == b'c').count() > 0);
+    }
+
+    #[test]
+    fn written_total_tracks_all() {
+        let lb = LogBuf::with_cap(10);
+        lb.append(b"abcdef");
+        lb.append(b"ghijkl");
+        assert_eq!(lb.written_total(), 12);
+    }
+}
diff --git a/02_ruway/shuma/sandbox/shuma-core/src/persist.rs b/02_ruway/shuma/sandbox/shuma-core/src/persist.rs
new file mode 100644
index 0000000..2d34109
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-core/src/persist.rs
@@ -0,0 +1,383 @@
+//! Persistencia del estado del WorkspaceManager.
+//!
+//! v1: sólo `WorkspaceSpec`s vivos. Los comandos (PIDs) NO se persisten —
+//! el kernel los mata al cerrar el daemon. Sólo la *intención declarada*
+//! (Workspaces creados con su spec) sobrevive a un reboot del daemon.
+
+use crate::WorkspaceManager;
+use serde::{Deserialize, Serialize};
+use shuma_card::{PipelineSpec, WorkspaceId, WorkspaceSpec};
+use std::path::{Path, PathBuf};
+use tracing::{info, warn};
+
+/// v2 agregó `saved_pipelines`. v3 agrega `live_pipelines`. v4 agrega
+/// `stats_history` por workspace (sparkline survives daemon restart).
+/// Versiones inferiores leen campos ausentes como vacío.
+pub const SNAPSHOT_VERSION: u16 = 4;
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ShipoteSnapshot {
+    pub version: u16,
+    pub timestamp_ms: u64,
+    pub workspaces: Vec<WorkspaceEntry>,
+    #[serde(default)]
+    pub saved_pipelines: Vec<PipelineEntry>,
+    /// Pipelines vivos con supervisor (`restart_on_failure=true`) al
+    /// momento del snapshot. El daemon los relanza al restore.
+    #[serde(default)]
+    pub live_pipelines: Vec<LivePipelineEntry>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct WorkspaceEntry {
+    pub id: WorkspaceId,
+    pub spec: WorkspaceSpec,
+    /// Stats history persistida — cap reasonable para no inflar el JSON.
+    /// Sólo se guardan campos serializables (no Instant).
+    #[serde(default)]
+    pub stats_history: Vec<PersistedStats>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PersistedStats {
+    pub commands_alive: u32,
+    pub commands_total: u32,
+    pub rss_bytes: Option<u64>,
+    pub rss_peak_bytes: Option<u64>,
+    pub cpu_usec: Option<u64>,
+    pub cpu_percent: Option<f32>,
+    pub cpu_cores: u32,
+    pub uptime_ms: u64,
+}
+
+impl From<&crate::stats::WorkspaceStats> for PersistedStats {
+    fn from(s: &crate::stats::WorkspaceStats) -> Self {
+        Self {
+            commands_alive: s.commands_alive,
+            commands_total: s.commands_total,
+            rss_bytes: s.rss_bytes,
+            rss_peak_bytes: s.rss_peak_bytes,
+            cpu_usec: s.cpu_usec,
+            cpu_percent: s.cpu_percent,
+            cpu_cores: s.cpu_cores,
+            uptime_ms: s.uptime_ms,
+        }
+    }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PipelineEntry {
+    pub name: String,
+    pub spec: PipelineSpec,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct LivePipelineEntry {
+    pub workspace: WorkspaceId,
+    pub spec: PipelineSpec,
+    pub tap: bool,
+}
+
+impl ShipoteSnapshot {
+    pub fn write(&self, path: &Path) -> anyhow::Result<()> {
+        let bytes = serde_json::to_vec_pretty(self)?;
+        if let Some(parent) = path.parent() {
+            std::fs::create_dir_all(parent).ok();
+        }
+        let tmp = path.with_extension("tmp");
+        std::fs::write(&tmp, &bytes)?;
+        std::fs::rename(&tmp, path)?;
+        Ok(())
+    }
+
+    pub fn read(path: &Path) -> anyhow::Result<Self> {
+        let bytes = std::fs::read(path)?;
+        let snap: ShipoteSnapshot = serde_json::from_slice(&bytes)?;
+        // v1 y v2 son compatibles forward (v1 sin saved_pipelines lee como vec vacío).
+        if snap.version > SNAPSHOT_VERSION {
+            anyhow::bail!(
+                "snapshot version {} no soportada (esperada ≤ {})",
+                snap.version,
+                SNAPSHOT_VERSION
+            );
+        }
+        Ok(snap)
+    }
+}
+
+/// Path canónico del snapshot: `$XDG_STATE_HOME/shuma/state.json`,
+/// fallback `$HOME/.local/state/shuma/state.json`,
+/// fallback `/tmp/shuma-state-$UID.json`.
+pub fn default_snapshot_path() -> PathBuf {
+    if let Ok(state) = std::env::var("XDG_STATE_HOME") {
+        return PathBuf::from(state).join("shuma/state.json");
+    }
+    if let Ok(home) = std::env::var("HOME") {
+        return PathBuf::from(home).join(".local/state/shuma/state.json");
+    }
+    let uid = nix::unistd::getuid().as_raw();
+    PathBuf::from(format!("/tmp/shuma-state-{uid}.json"))
+}
+
+fn now_ms() -> u64 {
+    std::time::SystemTime::now()
+        .duration_since(std::time::UNIX_EPOCH)
+        .map(|d| d.as_millis() as u64)
+        .unwrap_or(0)
+}
+
+impl WorkspaceManager {
+    /// Toma snapshot del estado actual.
+    pub async fn snapshot(&self) -> ShipoteSnapshot {
+        const PERSIST_STATS_CAP: usize = 16;
+        let g = self.inner.lock().await;
+        let workspaces = g
+            .workspaces
+            .iter()
+            .map(|(id, ws)| {
+                // Persist sólo los últimos N samples — el resto crece
+                // y el JSON se infla.
+                let take = ws.stats_history.len().min(PERSIST_STATS_CAP);
+                let skip = ws.stats_history.len() - take;
+                let stats_history: Vec<PersistedStats> = ws
+                    .stats_history
+                    .iter()
+                    .skip(skip)
+                    .map(PersistedStats::from)
+                    .collect();
+                WorkspaceEntry {
+                    id: *id,
+                    spec: ws.spec.clone(),
+                    stats_history,
+                }
+            })
+            .collect();
+        let saved_pipelines = g
+            .saved_pipelines
+            .iter()
+            .map(|(name, spec)| PipelineEntry {
+                name: name.clone(),
+                spec: spec.clone(),
+            })
+            .collect();
+        // Pipelines vivos con supervisor — preserva la intención. Los
+        // pids/sockets/discernments son ephemeral y se regeneran al
+        // restore (relaunch desde cero).
+        let live_pipelines = g
+            .pipeline_supervisors
+            .values()
+            .map(|sup| LivePipelineEntry {
+                workspace: sup.workspace,
+                spec: sup.spec.clone(),
+                tap: sup.tap,
+            })
+            .collect();
+        ShipoteSnapshot {
+            version: SNAPSHOT_VERSION,
+            timestamp_ms: now_ms(),
+            workspaces,
+            saved_pipelines,
+            live_pipelines,
+        }
+    }
+
+    /// Escribe snapshot a disco. Si `is_dirty()` es false **y** el path
+    /// existe (snapshot previo válido), skip la escritura.
+    pub async fn save_snapshot(&self, path: &Path) -> anyhow::Result<()> {
+        if !self.is_dirty() && path.exists() {
+            info!(path = %path.display(), "snapshot SKIPPED (clean)");
+            return Ok(());
+        }
+        let snap = self.snapshot().await;
+        snap.write(path)?;
+        // Clear dirty: lo que está en disco es el current state.
+        self.dirty
+            .store(false, std::sync::atomic::Ordering::Relaxed);
+        info!(path = %path.display(), workspaces = snap.workspaces.len(), "snapshot saved");
+        Ok(())
+    }
+
+    /// Carga snapshot desde disco y restaura los Workspaces + saved
+    /// pipelines. Devuelve los `live_pipelines` para que el caller
+    /// (daemon) los relance — no podemos relanzarlos desde acá porque
+    /// `run_pipeline` necesita `Incarnator` + `DiscernPipeline`.
+    /// Errores no-fatales (workspaces inválidos) se loguean y se saltan.
+    pub async fn restore_snapshot(
+        self: &std::sync::Arc<Self>,
+        path: &Path,
+    ) -> anyhow::Result<RestoreOutcome> {
+        let snap = match ShipoteSnapshot::read(path) {
+            Ok(s) => s,
+            Err(e) => {
+                warn!(?e, path = %path.display(), "no snapshot — start fresh");
+                return Ok(RestoreOutcome::default());
+            }
+        };
+        let mut out = RestoreOutcome::default();
+        for entry in snap.workspaces {
+            // v2+: reusamos el id original así clients que tracking
+            // workspace_id no se rompen al restart.
+            let label = entry.spec.label.clone();
+            let id = entry.id;
+            let history = entry.stats_history;
+            match self.create_with_id(id, entry.spec).await {
+                Ok(_) => {
+                    out.workspaces_restored += 1;
+                    // Hidratar history persistida. Convertimos
+                    // PersistedStats → WorkspaceStats (perdemos
+                    // los campos no serializables como `source`).
+                    if !history.is_empty() {
+                        let mut g = self.inner.lock().await;
+                        if let Some(ws) = g.workspaces.get_mut(&id) {
+                            for ps in history {
+                                ws.stats_history.push_back(crate::stats::WorkspaceStats {
+                                    commands_alive: ps.commands_alive,
+                                    commands_total: ps.commands_total,
+                                    rss_bytes: ps.rss_bytes,
+                                    rss_peak_bytes: ps.rss_peak_bytes,
+                                    cpu_usec: ps.cpu_usec,
+                                    cpu_percent: ps.cpu_percent,
+                                    cpu_cores: ps.cpu_cores,
+                                    source: "persisted".into(),
+                                    uptime_ms: ps.uptime_ms,
+                                });
+                            }
+                        }
+                    }
+                }
+                Err(e) => warn!(?e, %label, "skipped workspace en restore"),
+            }
+        }
+        for entry in snap.saved_pipelines {
+            self.save_pipeline(entry.name, entry.spec).await;
+            out.saved_pipelines_restored += 1;
+        }
+        out.live_pipelines = snap.live_pipelines;
+        // Restore no cuenta como mutación — lo que está en disco es lo
+        // que acabamos de cargar. Sin esto, el próximo SIGTERM siempre
+        // re-escribiría aunque no hubiese cambios reales.
+        self.dirty
+            .store(false, std::sync::atomic::Ordering::Relaxed);
+        info!(
+            workspaces = out.workspaces_restored,
+            saved_pipelines = out.saved_pipelines_restored,
+            live_pipelines = out.live_pipelines.len(),
+            "snapshot restored"
+        );
+        Ok(out)
+    }
+}
+
+/// Lo que el caller del restore obtiene. Las `live_pipelines` requieren
+/// `Incarnator + DiscernPipeline` para relanzarlas → el caller las
+/// procesa (típicamente el daemon).
+#[derive(Debug, Default)]
+pub struct RestoreOutcome {
+    pub workspaces_restored: usize,
+    pub saved_pipelines_restored: usize,
+    pub live_pipelines: Vec<LivePipelineEntry>,
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::WorkspaceManager;
+    use arje_incarnate::IncarnatorConfig;
+    use shuma_card::{ExitPolicy, WorkspaceSpec};
+    use std::sync::Arc;
+
+    fn sample_ws(label: &str) -> WorkspaceSpec {
+        WorkspaceSpec {
+            label: label.into(),
+            soma: Default::default(),
+            permissions: Default::default(),
+            ttl: None,
+            flow_dirs: vec![],
+            on_exit: ExitPolicy::Reap,
+            quota_enforce: Default::default(),
+        }
+    }
+
+    #[tokio::test]
+    async fn roundtrip_snapshot_preserves_ulids() {
+        let tmp = tempfile::tempdir().unwrap();
+        let path = tmp.path().join("state.json");
+
+        let mgr1 = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+        let (id1, _) = mgr1.create(sample_ws("a")).await.unwrap();
+        let (id2, _) = mgr1.create(sample_ws("b")).await.unwrap();
+        mgr1.save_snapshot(&path).await.unwrap();
+
+        let mgr2 = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+        let out = mgr2.restore_snapshot(&path).await.unwrap();
+        assert_eq!(out.workspaces_restored, 2);
+        let listed = mgr2.list().await;
+        let restored_ids: std::collections::HashSet<_> = listed.iter().map(|s| s.id).collect();
+        assert!(restored_ids.contains(&id1));
+        assert!(restored_ids.contains(&id2));
+    }
+
+    #[tokio::test]
+    async fn save_snapshot_skips_when_clean() {
+        let tmp = tempfile::tempdir().unwrap();
+        let path = tmp.path().join("state.json");
+        let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+        let _ = mgr.create(sample_ws("dirty-test")).await.unwrap();
+        assert!(mgr.is_dirty(), "create debería marcar dirty");
+        mgr.save_snapshot(&path).await.unwrap();
+        assert!(!mgr.is_dirty(), "save_snapshot debería limpiar dirty");
+        let mtime1 = std::fs::metadata(&path).unwrap().modified().unwrap();
+        // Esperamos un pelín para que mtime cambie si fuera re-escrito.
+        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
+        // Segundo save sin mutación → skip.
+        mgr.save_snapshot(&path).await.unwrap();
+        let mtime2 = std::fs::metadata(&path).unwrap().modified().unwrap();
+        assert_eq!(mtime1, mtime2, "skip cuando clean — mtime no cambia");
+    }
+
+    #[tokio::test]
+    async fn snapshot_includes_saved_pipelines() {
+        use shuma_card::{CommandRef, DiscernPolicy, PipelineSpec};
+        let tmp = tempfile::tempdir().unwrap();
+        let path = tmp.path().join("state.json");
+
+        let mgr1 = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+        let (ws_id, _) = mgr1.create(sample_ws("ws")).await.unwrap();
+        let spec = PipelineSpec {
+            label: "echo-cat".into(),
+            workspace: ws_id,
+            nodes: vec![CommandRef {
+                label: "n1".into(),
+                payload: card_core::Payload::Native {
+                    exec: "/bin/echo".into(),
+                    argv: vec!["hi".into()],
+                    envp: vec![],
+                },
+                soma: Default::default(),
+                flows: Default::default(),
+                supervision: card_core::Supervision::OneShot,
+            }],
+            edges: vec![],
+            discern: DiscernPolicy::default(),
+            restart_on_failure: false,
+            restart_backoff_ms: 200,
+            restart_max_backoff_ms: 30_000,
+            restart_max: 0,
+        };
+        mgr1.save_pipeline("daily".into(), spec).await;
+        mgr1.save_snapshot(&path).await.unwrap();
+
+        let mgr2 = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+        mgr2.restore_snapshot(&path).await.unwrap();
+        let saved = mgr2.list_saved_pipelines().await;
+        assert_eq!(saved, vec!["daily".to_string()]);
+        let got = mgr2.get_saved_pipeline("daily").await.expect("saved");
+        assert_eq!(got.label, "echo-cat");
+    }
+
+    #[test]
+    fn default_path_ends_with_state_json() {
+        let p = default_snapshot_path();
+        assert!(p.to_string_lossy().ends_with("state.json"));
+    }
+}
diff --git a/02_ruway/shuma/sandbox/shuma-core/src/pipeline.rs b/02_ruway/shuma/sandbox/shuma-core/src/pipeline.rs
new file mode 100644
index 0000000..6f016f6
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-core/src/pipeline.rs
@@ -0,0 +1,808 @@
+//! Pipeline runtime: encadena nodos con pipes y opcionalmente intercepta
+//! cada flow para discernir su contenido.
+//!
+//! Cada nodo se encarna via [`arje_incarnate::Incarnator`] — eso significa
+//! que **cada comando puede tener su propio SomaSpec** (namespaces, cgroup,
+//! rlimits) heredado del workspace. La conexión stdin↔stdout se hace con
+//! `pipe2(2)` + `ChildStdio` declarativo: el callback de clone(2) hace los
+//! `dup2` pre-execve sin romper la regla async-signal-safe.
+
+use crate::CoreError;
+use card_core::Payload;
+use arje_incarnate::{ChildStdio, Incarnator};
+use nix::fcntl::OFlag;
+use nix::unistd::pipe2;
+use shuma_card::PipelineSpec;
+use shuma_discern::{DiscernPipeline, Discernment, Hint};
+use std::os::fd::{AsRawFd, IntoRawFd, RawFd};
+use std::sync::Arc;
+use tokio::io::unix::AsyncFd;
+use tokio::io::Interest;
+use tracing::{debug, info, warn};
+use ulid::Ulid;
+
+/// Resultado de lanzar un pipeline.
+#[derive(Debug)]
+pub struct PipelineLaunch {
+    pub pipeline: Ulid,
+    pub command_pids: Vec<(String, i32)>,
+    /// Discernments por edge, en el mismo orden que `spec.edges`.
+    pub edge_discernments: Vec<EdgeDiscernment>,
+}
+
+#[derive(Debug, Clone)]
+pub struct EdgeDiscernment {
+    pub from_label: String,
+    pub from_output: String,
+    pub to_label: String,
+    pub to_input: String,
+    pub discernment: Option<Discernment>,
+    /// Path del Unix socket donde otros módulos pueden suscribirse al
+    /// stream replicado por este edge. `None` cuando tap=false (no hay
+    /// data plane porque no hay sampling).
+    pub flow_socket: Option<std::path::PathBuf>,
+}
+
+/// Lanza un pipeline conectando nodos por stdin/stdout. Cada nodo se
+/// encarna via `Incarnator` (con o sin namespacing según su SomaSpec).
+///
+/// Soporta:
+/// - Pipeline lineal (1 producer → 1 consumer).
+/// - **Fan-out** (1 producer → N consumers): shuma interpone un
+///   splitter que duplica bytes a cada destino. Cuando `tap=true`, el
+///   splitter además samplea para discernir.
+/// - Múltiples predecessors por nodo NO se soporta aún (fan-in): sólo se
+///   honra el primer edge entrante.
+pub async fn run_pipeline(
+    spec: &PipelineSpec,
+    workspace_label: &str,
+    tap: bool,
+    discerner: Arc<DiscernPipeline>,
+    incarnator: Arc<Incarnator>,
+    manager: Option<Arc<crate::WorkspaceManager>>,
+) -> Result<PipelineLaunch, CoreError> {
+    spec.validate()?;
+    let n = spec.nodes.len();
+    info!(
+        nodes = n,
+        edges = spec.edges.len(),
+        tap,
+        "launching pipeline (incarnated)"
+    );
+
+    // Pre-compute grafo:
+    // - `consumers[i]` = índices de edges salientes de `i`.
+    // - `predecessors[j]` = índices de edges entrantes a `j`.
+    let mut consumers: Vec<Vec<usize>> = vec![Vec::new(); n];
+    let mut predecessors: Vec<Vec<usize>> = vec![Vec::new(); n];
+    for (idx, e) in spec.edges.iter().enumerate() {
+        consumers[e.from].push(idx);
+        predecessors[e.to].push(idx);
+    }
+
+    // Por cada edge: par (r_to_consumer, w_from_producer_side).
+    // El consumer recibe r_to_consumer; el producer escribe a w_from_producer_side
+    // (directa o vía splitter).
+    let mut edge_r: Vec<RawFd> = vec![-1; spec.edges.len()];
+    let mut edge_w: Vec<RawFd> = vec![-1; spec.edges.len()];
+    for i in 0..spec.edges.len() {
+        let (r, w) = pipe2(OFlag::O_CLOEXEC).map_err(|e| {
+            CoreError::Incarnate(arje_incarnate::IncarnateError::Pipe(e))
+        })?;
+        edge_r[i] = r.into_raw_fd();
+        edge_w[i] = w.into_raw_fd();
+    }
+
+    let mut consumer_stdin_fd: Vec<Option<RawFd>> = vec![None; n];
+    let mut producer_stdout_fd: Vec<Option<RawFd>> = vec![None; n];
+    let mut splitter_specs: Vec<SplitterSpec> = Vec::new();
+    let mut merger_specs: Vec<MergerSpec> = Vec::new();
+
+    // Stdout del producer: directo a edge_w[único] si tiene 1 consumer y NO tap;
+    // sino, pipe propio que va al splitter task.
+    for i in 0..n {
+        if consumers[i].is_empty() {
+            continue;
+        }
+        if consumers[i].len() == 1 && !tap {
+            producer_stdout_fd[i] = Some(edge_w[consumers[i][0]]);
+            continue;
+        }
+        // Splitter: pipe propio para el productor → splitter lee y replica a edge_w[*].
+        let (prod_r, prod_w) = pipe2(OFlag::O_CLOEXEC).map_err(|e| {
+            CoreError::Incarnate(arje_incarnate::IncarnateError::Pipe(e))
+        })?;
+        producer_stdout_fd[i] = Some(prod_w.into_raw_fd());
+        let prod_r_fd = prod_r.into_raw_fd();
+        let mut consumer_writes: Vec<RawFd> = Vec::with_capacity(consumers[i].len());
+        let mut edge_meta: Vec<EdgeMeta> = Vec::with_capacity(consumers[i].len());
+        for edge_idx in &consumers[i] {
+            let edge = &spec.edges[*edge_idx];
+            consumer_writes.push(edge_w[*edge_idx]);
+            edge_meta.push(EdgeMeta {
+                from_label: spec.nodes[edge.from].label.clone(),
+                from_output: edge.from_output.clone(),
+                to_label: spec.nodes[edge.to].label.clone(),
+                to_input: edge.to_input.clone(),
+            });
+        }
+        splitter_specs.push(SplitterSpec {
+            producer_r_fd: prod_r_fd,
+            consumer_w_fds: consumer_writes,
+            edges: edge_meta,
+            tap,
+            sample_bytes: spec.discern.sample_bytes,
+            max_bytes_per_sec: spec.discern.max_bytes_per_sec,
+        });
+    }
+
+    // Stdin del consumer: edge_r[único] si tiene 1 predecessor; sino, merger.
+    for j in 0..n {
+        match predecessors[j].len() {
+            0 => {}
+            1 => {
+                consumer_stdin_fd[j] = Some(edge_r[predecessors[j][0]]);
+            }
+            _ => {
+                // Merger: lee de N edge_r y escribe a un nuevo pipe cuyo
+                // read end es el stdin del consumer.
+                let (cons_r, cons_w) = pipe2(OFlag::O_CLOEXEC).map_err(|e| {
+                    CoreError::Incarnate(arje_incarnate::IncarnateError::Pipe(e))
+                })?;
+                consumer_stdin_fd[j] = Some(cons_r.into_raw_fd());
+                let inputs: Vec<RawFd> = predecessors[j]
+                    .iter()
+                    .map(|eidx| edge_r[*eidx])
+                    .collect();
+                merger_specs.push(MergerSpec {
+                    producer_r_fds: inputs,
+                    consumer_w_fd: cons_w.into_raw_fd(),
+                });
+            }
+        }
+    }
+
+    // Encarnamos cada nodo con su stdin/stdout fd asignado.
+    let mut pids = Vec::with_capacity(n);
+    for (i, node) in spec.nodes.iter().enumerate() {
+        match &node.payload {
+            Payload::Native { .. } | Payload::Legacy { .. } => {}
+            _ => {
+                return Err(CoreError::Incarnate(
+                    arje_incarnate::IncarnateError::NonExecutablePayload,
+                ))
+            }
+        }
+        let card = node.to_card(i, workspace_label)?;
+        let stdio = ChildStdio {
+            stdin_fd: consumer_stdin_fd[i],
+            stdout_fd: producer_stdout_fd[i],
+            stderr_fd: None,
+        };
+        let outcome = incarnator
+            .incarnate_with(&card, stdio)
+            .map_err(CoreError::Incarnate)?;
+        let pid = outcome.pid;
+        pids.push((node.label.clone(), pid.as_raw()));
+        debug!(label = %node.label, pid = pid.as_raw(), "node incarnated");
+    }
+
+    let pipeline_id_for_flows = Ulid::new();
+    // Si tap=true, creamos un FlowChannel por edge para el data plane.
+    // Cada splitter pushea al sender del channel correspondiente.
+    let pipeline_id = pipeline_id_for_flows;
+    let mut flow_channels: Vec<crate::flow_channel::FlowChannel> = Vec::new();
+    let mut splitter_channels: Vec<Vec<Option<crate::flow_channel::FlowSender>>> =
+        Vec::with_capacity(splitter_specs.len());
+    let mut edge_socket_for_splitter: Vec<Vec<Option<std::path::PathBuf>>> = Vec::new();
+    for s in &splitter_specs {
+        let mut senders_per_edge = Vec::with_capacity(s.edges.len());
+        let mut paths_per_edge = Vec::with_capacity(s.edges.len());
+        for (i, _em) in s.edges.iter().enumerate() {
+            if !s.tap {
+                senders_per_edge.push(None);
+                paths_per_edge.push(None);
+                continue;
+            }
+            // Socket name = pipeline_id full (26 chars ULID) + edge_idx.
+            // ULID es único globalmente → cero colisiones entre runs.
+            // Edge_idx desambigua múltiples sockets del mismo pipeline.
+            // No incluimos from_label en el name (puede tener chars que
+            // no van en paths Unix — los hints van en `EdgeDiscernment`).
+            let id = format!("{}-{}", pipeline_id, i);
+            let mut socket = crate::flow_channel::default_flow_socket_path(&id);
+            // Fallback: si el path existe (raro — daemon crashed sin
+            // cleanup), agregar suffix numérico hasta encontrar libre.
+            let mut suffix = 1u32;
+            while socket.exists() {
+                let alt = format!("{id}-{suffix}");
+                socket = crate::flow_channel::default_flow_socket_path(&alt);
+                suffix += 1;
+                if suffix > 1000 {
+                    warn!(orig = id, "flow socket collision: 1000 retries — using as-is");
+                    break;
+                }
+            }
+            match crate::flow_channel::FlowChannel::with_replay_caps(
+                socket.clone(),
+                crate::flow_channel::ReplayCaps::new(spec.discern.replay_chunks, spec.discern.replay_bytes),
+            ) {
+                Ok(fc) => {
+                    senders_per_edge.push(Some(fc.sender_handle()));
+                    paths_per_edge.push(Some(socket));
+                    flow_channels.push(fc);
+                }
+                Err(e) => {
+                    warn!(?e, "flow channel new failed");
+                    senders_per_edge.push(None);
+                    paths_per_edge.push(None);
+                }
+            }
+        }
+        splitter_channels.push(senders_per_edge);
+        edge_socket_for_splitter.push(paths_per_edge);
+    }
+
+    // Registramos los flow_channels en el manager AHORA, antes de await
+    // las tasks. Esto permite que clientes externos hagan `flow list` y
+    // se suscriban mientras el pipeline aún produce data.
+    if let Some(mgr) = &manager {
+        if !flow_channels.is_empty() {
+            let drained: Vec<crate::flow_channel::FlowChannel> = flow_channels.drain(..).collect();
+            mgr.retain_pipeline_flows(pipeline_id, drained).await;
+        }
+    }
+
+    // Spawn mergers + splitters después del incarnate. Cada task posee
+    // sus fds y los cierra al terminar (via Drop de OwnedFd).
+    let mut merger_handles: Vec<tokio::task::JoinHandle<()>> = Vec::new();
+    for m in merger_specs {
+        merger_handles.push(spawn_merger(m));
+    }
+    let mut tap_handles: Vec<SplitterHandle> = Vec::new();
+    for (s, senders) in splitter_specs.into_iter().zip(splitter_channels.into_iter()) {
+        tap_handles.push(spawn_splitter(s, discerner.clone(), senders));
+    }
+
+    let mut edge_discernments = Vec::new();
+    for (h, paths) in tap_handles.into_iter().zip(edge_socket_for_splitter.into_iter()) {
+        match h.handle.await {
+            Ok(eds) => {
+                for (mut ed, path) in eds.into_iter().zip(paths.into_iter()) {
+                    ed.flow_socket = path;
+                    edge_discernments.push(ed);
+                }
+            }
+            Err(e) => warn!(?e, "splitter handle joined with error"),
+        }
+    }
+    for h in merger_handles {
+        if let Err(e) = h.await {
+            warn!(?e, "merger handle joined with error");
+        }
+    }
+
+    Ok(PipelineLaunch {
+        pipeline: pipeline_id,
+        command_pids: pids,
+        edge_discernments,
+    })
+}
+
+#[allow(dead_code)]
+fn short_ulid(u: &Ulid) -> String {
+    let s = u.to_string();
+    s[s.len() - 6..].to_string()
+}
+
+#[derive(Debug, Clone)]
+struct EdgeMeta {
+    from_label: String,
+    from_output: String,
+    to_label: String,
+    to_input: String,
+}
+
+struct SplitterSpec {
+    producer_r_fd: RawFd,
+    consumer_w_fds: Vec<RawFd>,
+    edges: Vec<EdgeMeta>,
+    tap: bool,
+    sample_bytes: usize,
+    /// Rate-limit en bytes/s (0 = sin limit). Tras cada chunk de `n`
+    /// bytes, splitter sleeps `n / max_bytes_per_sec` segundos.
+    max_bytes_per_sec: u64,
+}
+
+struct SplitterHandle {
+    handle: tokio::task::JoinHandle<Vec<EdgeDiscernment>>,
+}
+
+struct MergerSpec {
+    producer_r_fds: Vec<RawFd>,
+    consumer_w_fd: RawFd,
+}
+
+fn spawn_merger(spec: MergerSpec) -> tokio::task::JoinHandle<()> {
+    for fd in &spec.producer_r_fds {
+        set_nonblocking(*fd);
+    }
+    set_nonblocking(spec.consumer_w_fd);
+    // Patrón: una task lectora por cada producer reenvía bytes a un mpsc.
+    // El merger principal consume del mpsc y escribe al consumer.
+    // Esto evita el "block en reader idle" del enfoque round-robin sobre
+    // AsyncFd::ready() (los readers idle nunca dejan turno).
+    tokio::spawn(async move {
+        let (tx, mut rx) = tokio::sync::mpsc::channel::<Vec<u8>>(32);
+        let nr = spec.producer_r_fds.len();
+        for fd in spec.producer_r_fds {
+            let tx = tx.clone();
+            tokio::spawn(async move {
+                // SAFETY: ownership transferida.
+                let owned = unsafe { std::os::fd::OwnedFd::from_raw_fd_compat(fd) };
+                let r = match AsyncFd::with_interest(owned, Interest::READABLE) {
+                    Ok(a) => a,
+                    Err(e) => {
+                        warn!(?e, "merger reader AsyncFd");
+                        return;
+                    }
+                };
+                let mut buf = [0u8; 4096];
+                loop {
+                    match async_read(&r, &mut buf).await {
+                        Ok(0) => break,
+                        Ok(n) => {
+                            if tx.send(buf[..n].to_vec()).await.is_err() {
+                                break;
+                            }
+                        }
+                        Err(_) => break,
+                    }
+                }
+                // Drop de tx → cuando todos los readers cerraron, el rx
+                // recibe None y el merger termina.
+            });
+        }
+        drop(tx); // sólo los reader tasks tienen sus clones ahora.
+
+        // SAFETY: ownership transferida al task.
+        let w_owned = unsafe { std::os::fd::OwnedFd::from_raw_fd_compat(spec.consumer_w_fd) };
+        let w = match AsyncFd::with_interest(w_owned, Interest::WRITABLE) {
+            Ok(a) => a,
+            Err(e) => {
+                warn!(?e, "merger AsyncFd w");
+                return;
+            }
+        };
+
+        let mut total: u64 = 0;
+        while let Some(chunk) = rx.recv().await {
+            if async_write_all(&w, &chunk).await.is_err() {
+                return;
+            }
+            total += chunk.len() as u64;
+        }
+        debug!(bytes = total, readers = nr, "merger finished");
+    })
+}
+
+fn spawn_splitter(
+    spec: SplitterSpec,
+    discerner: Arc<DiscernPipeline>,
+    edge_senders: Vec<Option<crate::flow_channel::FlowSender>>,
+) -> SplitterHandle {
+    set_nonblocking(spec.producer_r_fd);
+    for fd in &spec.consumer_w_fds {
+        set_nonblocking(*fd);
+    }
+
+    let handle = tokio::spawn(async move {
+        // SAFETY: ownership transferida al task.
+        let r_owned = unsafe { std::os::fd::OwnedFd::from_raw_fd_compat(spec.producer_r_fd) };
+        let r = match AsyncFd::with_interest(r_owned, Interest::READABLE) {
+            Ok(a) => a,
+            Err(e) => {
+                warn!(?e, "splitter AsyncFd r");
+                return Vec::new();
+            }
+        };
+        let mut writers: Vec<AsyncFd<std::os::fd::OwnedFd>> = Vec::with_capacity(spec.consumer_w_fds.len());
+        for fd in spec.consumer_w_fds {
+            let owned = unsafe { std::os::fd::OwnedFd::from_raw_fd_compat(fd) };
+            match AsyncFd::with_interest(owned, Interest::WRITABLE) {
+                Ok(a) => writers.push(a),
+                Err(e) => warn!(?e, "splitter AsyncFd w"),
+            }
+        }
+
+        let mut sample: Vec<u8> = Vec::with_capacity(spec.sample_bytes);
+        let mut buf = [0u8; 4096];
+        let mut total: u64 = 0;
+        let mut eof = false;
+        let mut bucket = if spec.max_bytes_per_sec > 0 {
+            Some(TokenBucket::new(spec.max_bytes_per_sec))
+        } else {
+            None
+        };
+
+        // Fase 1: sampling (sólo si tap=true) + replicación.
+        while !eof && (spec.tap && sample.len() < spec.sample_bytes) {
+            let n = match async_read(&r, &mut buf).await {
+                Ok(0) => { eof = true; 0 }
+                Ok(n) => n,
+                Err(e) => { warn!(?e, "splitter read"); break; }
+            };
+            if n == 0 { break; }
+            if spec.tap {
+                let take = n.min(spec.sample_bytes - sample.len());
+                sample.extend_from_slice(&buf[..take]);
+            }
+            // Token bucket: reserva ANTES de broadcast — si hay debt,
+            // sleep antes de mandar al subscriber.
+            if let Some(b) = bucket.as_mut() {
+                let wait = b.reserve(n as u64);
+                if !wait.is_zero() {
+                    tokio::time::sleep(wait).await;
+                }
+            }
+            broadcast_chunk(&writers, &edge_senders, &buf[..n]).await;
+            total += n as u64;
+        }
+
+        let d = if spec.tap {
+            discerner.discern(&sample, &Hint { path: None, size_total: None })
+        } else {
+            None
+        };
+
+        // Fase 2: replicación pura.
+        while !eof {
+            let n = match async_read(&r, &mut buf).await {
+                Ok(0) => { eof = true; 0 }
+                Ok(n) => n,
+                Err(_) => break,
+            };
+            if n == 0 { break; }
+            if let Some(b) = bucket.as_mut() {
+                let wait = b.reserve(n as u64);
+                if !wait.is_zero() {
+                    tokio::time::sleep(wait).await;
+                }
+            }
+            broadcast_chunk(&writers, &edge_senders, &buf[..n]).await;
+            total += n as u64;
+        }
+        debug!(bytes = total, consumers = writers.len(), "splitter finished");
+
+        // Mismo discernment para todos los edges del splitter (es el mismo
+        // stream replicado). Devolvemos N entries (una por edge) para que
+        // la UI/CLI los liste todos. flow_socket lo rellena el caller.
+        spec.edges
+            .into_iter()
+            .map(|em| EdgeDiscernment {
+                from_label: em.from_label,
+                from_output: em.from_output,
+                to_label: em.to_label,
+                to_input: em.to_input,
+                discernment: d.clone(),
+                flow_socket: None,
+            })
+            .collect()
+    });
+    SplitterHandle { handle }
+}
+
+/// Token-bucket real con capacidad de burst.
+/// - `rate_bps`: tokens (bytes) por segundo de refill.
+/// - `capacity`: máx tokens acumulables. Default = 1 segundo de rate.
+/// - `tokens`: tokens disponibles (puede negativos para "debt").
+/// - `last_refill`: para calcular cuántos refill desde la última call.
+struct TokenBucket {
+    rate_bps: u64,
+    capacity: u64,
+    tokens: f64,
+    last_refill: std::time::Instant,
+}
+
+impl TokenBucket {
+    fn new(rate_bps: u64) -> Self {
+        Self {
+            rate_bps,
+            capacity: rate_bps, // 1 second worth of burst.
+            tokens: rate_bps as f64,
+            last_refill: std::time::Instant::now(),
+        }
+    }
+
+    /// Refill desde la última call según wall time. Reserva `cost`
+    /// tokens; si no alcanza, retorna el sleep necesario.
+    fn reserve(&mut self, cost: u64) -> std::time::Duration {
+        let now = std::time::Instant::now();
+        let elapsed_secs = now.duration_since(self.last_refill).as_secs_f64();
+        self.tokens = (self.tokens + elapsed_secs * self.rate_bps as f64)
+            .min(self.capacity as f64);
+        self.last_refill = now;
+
+        self.tokens -= cost as f64;
+        if self.tokens >= 0.0 {
+            std::time::Duration::ZERO
+        } else {
+            // Debt: tiempo para recuperar a 0 tokens.
+            let secs_needed = -self.tokens / self.rate_bps as f64;
+            std::time::Duration::from_secs_f64(secs_needed)
+        }
+    }
+}
+
+async fn broadcast_chunk(
+    writers: &[AsyncFd<std::os::fd::OwnedFd>],
+    edge_senders: &[Option<crate::flow_channel::FlowSender>],
+    data: &[u8],
+) {
+    // Internal pipes a los consumers del pipeline.
+    for w in writers {
+        let _ = async_write_all(w, data).await;
+    }
+    // Externos: broadcast a subscribers vía FlowChannel.
+    // Cada edge tiene su propio sender (mismo data — el sample/discernment
+    // viaja por broadcast separados para que un subscriber por edge vea su
+    // stream específico).
+    if edge_senders.iter().any(|s| s.is_some()) {
+        let shared = std::sync::Arc::new(data.to_vec());
+        for s in edge_senders {
+            if let Some(s) = s {
+                let _ = s.send(shared.clone());
+            }
+        }
+    }
+}
+
+async fn async_read(
+    afd: &AsyncFd<std::os::fd::OwnedFd>,
+    buf: &mut [u8],
+) -> std::io::Result<usize> {
+    loop {
+        let mut guard = afd.readable().await?;
+        let fd = afd.as_raw_fd();
+        // SAFETY: lectura sobre fd válido propiedad del AsyncFd.
+        let r = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut _, buf.len()) };
+        if r >= 0 {
+            return Ok(r as usize);
+        }
+        let err = std::io::Error::last_os_error();
+        if err.kind() == std::io::ErrorKind::WouldBlock {
+            guard.clear_ready();
+            continue;
+        }
+        return Err(err);
+    }
+}
+
+async fn async_write_all(
+    afd: &AsyncFd<std::os::fd::OwnedFd>,
+    mut buf: &[u8],
+) -> std::io::Result<()> {
+    while !buf.is_empty() {
+        let mut guard = afd.writable().await?;
+        let fd = afd.as_raw_fd();
+        // SAFETY: escritura sobre fd válido propiedad del AsyncFd.
+        let r = unsafe { libc::write(fd, buf.as_ptr() as *const _, buf.len()) };
+        if r > 0 {
+            buf = &buf[r as usize..];
+            continue;
+        }
+        if r == 0 {
+            return Err(std::io::Error::new(
+                std::io::ErrorKind::WriteZero,
+                "write 0",
+            ));
+        }
+        let err = std::io::Error::last_os_error();
+        if err.kind() == std::io::ErrorKind::WouldBlock {
+            guard.clear_ready();
+            continue;
+        }
+        return Err(err);
+    }
+    Ok(())
+}
+
+fn set_nonblocking(fd: RawFd) {
+    // SAFETY: fcntl con F_SETFL es seguro para fds válidos.
+    unsafe {
+        let flags = libc::fcntl(fd, libc::F_GETFL, 0);
+        if flags >= 0 {
+            libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK);
+        }
+    }
+}
+
+// Extension trait para abstraer la API de OwnedFd entre versiones (compat).
+trait OwnedFdFromRawCompat: Sized {
+    unsafe fn from_raw_fd_compat(fd: RawFd) -> Self;
+}
+
+impl OwnedFdFromRawCompat for std::os::fd::OwnedFd {
+    unsafe fn from_raw_fd_compat(fd: RawFd) -> Self {
+        use std::os::fd::FromRawFd;
+        // SAFETY: el caller transfiere ownership de `fd` a la `OwnedFd`.
+        unsafe { std::os::fd::OwnedFd::from_raw_fd(fd) }
+    }
+}
+
+// Re-export para que el unused warning del AsRawFd se calle si no se usa.
+#[allow(dead_code)]
+fn _keep_raw(_: &dyn AsRawFd) {}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use card_core::Payload;
+    use arje_incarnate::IncarnatorConfig;
+    use shuma_card::{CommandRef, DiscernPolicy, FlowEdge, PipelineSpec, WorkspaceId};
+
+    fn cmd(label: &str, exec: &str, argv: &[&str]) -> CommandRef {
+        CommandRef {
+            label: label.into(),
+            payload: Payload::Native {
+                exec: exec.into(),
+                argv: argv.iter().map(|s| s.to_string()).collect(),
+                envp: vec![],
+            },
+            soma: Default::default(),
+            flows: Default::default(),
+            supervision: card_core::Supervision::OneShot,
+        }
+    }
+
+    #[tokio::test]
+    async fn pipeline_isolated_echo_to_cat_runs() {
+        let spec = PipelineSpec {
+            label: "echo-cat".into(),
+            workspace: WorkspaceId::new(),
+            nodes: vec![
+                cmd("p1", "/bin/echo", &["hola pipeline aislado"]),
+                cmd("p2", "/bin/cat", &[]),
+            ],
+            edges: vec![FlowEdge {
+                from: 0,
+                from_output: "stdout".into(),
+                to: 1,
+                to_input: "stdin".into(),
+            }],
+            discern: DiscernPolicy::default(),
+            restart_on_failure: false,
+            restart_backoff_ms: 200,
+            restart_max_backoff_ms: 30_000,
+            restart_max: 0,
+        };
+        let disc = Arc::new(DiscernPipeline::default_pipeline());
+        let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
+        let launch = run_pipeline(&spec, "ws", false, disc, inc, None).await.unwrap();
+        assert_eq!(launch.command_pids.len(), 2);
+        // Cosecha.
+        for (_, pid) in &launch.command_pids {
+            let _ = nix::sys::wait::waitpid(nix::unistd::Pid::from_raw(*pid), None);
+        }
+    }
+
+    #[tokio::test]
+    async fn pipeline_fanin_two_to_one() {
+        // 2 productores → 1 consumer (cat). El merger multiplexa.
+        let spec = PipelineSpec {
+            label: "fanin".into(),
+            workspace: WorkspaceId::new(),
+            nodes: vec![
+                cmd("p1", "/bin/echo", &["from-p1"]),
+                cmd("p2", "/bin/echo", &["from-p2"]),
+                cmd("c", "/bin/cat", &[]),
+            ],
+            edges: vec![
+                FlowEdge {
+                    from: 0,
+                    from_output: "stdout".into(),
+                    to: 2,
+                    to_input: "stdin".into(),
+                },
+                FlowEdge {
+                    from: 1,
+                    from_output: "stdout".into(),
+                    to: 2,
+                    to_input: "stdin".into(),
+                },
+            ],
+            discern: DiscernPolicy::default(),
+            restart_on_failure: false,
+            restart_backoff_ms: 200,
+            restart_max_backoff_ms: 30_000,
+            restart_max: 0,
+        };
+        let disc = Arc::new(DiscernPipeline::default_pipeline());
+        let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
+        let launch = run_pipeline(&spec, "ws", false, disc, inc, None).await.unwrap();
+        assert_eq!(launch.command_pids.len(), 3);
+        for (_, pid) in &launch.command_pids {
+            let _ = nix::sys::wait::waitpid(nix::unistd::Pid::from_raw(*pid), None);
+        }
+    }
+
+    #[tokio::test]
+    async fn pipeline_fanout_one_to_two() {
+        // 1 productor (echo) → 2 consumers (wc -c). Splitter replica.
+        let spec = PipelineSpec {
+            label: "fanout".into(),
+            workspace: WorkspaceId::new(),
+            nodes: vec![
+                cmd("p", "/bin/echo", &["fanout-test"]),
+                cmd("c1", "/bin/cat", &[]),
+                cmd("c2", "/bin/cat", &[]),
+            ],
+            edges: vec![
+                FlowEdge {
+                    from: 0,
+                    from_output: "stdout".into(),
+                    to: 1,
+                    to_input: "stdin".into(),
+                },
+                FlowEdge {
+                    from: 0,
+                    from_output: "stdout".into(),
+                    to: 2,
+                    to_input: "stdin".into(),
+                },
+            ],
+            discern: DiscernPolicy::default(),
+            restart_on_failure: false,
+            restart_backoff_ms: 200,
+            restart_max_backoff_ms: 30_000,
+            restart_max: 0,
+        };
+        let disc = Arc::new(DiscernPipeline::default_pipeline());
+        let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
+        let launch = run_pipeline(&spec, "ws", false, disc, inc, None).await.unwrap();
+        assert_eq!(launch.command_pids.len(), 3);
+        for (_, pid) in &launch.command_pids {
+            let _ = nix::sys::wait::waitpid(nix::unistd::Pid::from_raw(*pid), None);
+        }
+    }
+
+    #[tokio::test]
+    async fn pipeline_isolated_with_tap_captures_discernment() {
+        let spec = PipelineSpec {
+            label: "json-cat".into(),
+            workspace: WorkspaceId::new(),
+            nodes: vec![
+                cmd("p1", "/bin/echo", &["{\"hello\": 1}"]),
+                cmd("p2", "/bin/cat", &[]),
+            ],
+            edges: vec![FlowEdge {
+                from: 0,
+                from_output: "stdout".into(),
+                to: 1,
+                to_input: "stdin".into(),
+            }],
+            discern: DiscernPolicy {
+                sample_bytes: 4096,
+                enrich_producer: true,
+                replay_chunks: 32,
+                replay_bytes: 0,
+                max_bytes_per_sec: 0,
+            },
+            restart_on_failure: false,
+            restart_backoff_ms: 200,
+            restart_max_backoff_ms: 30_000,
+            restart_max: 0,
+        };
+        let disc = Arc::new(DiscernPipeline::default_pipeline());
+        let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
+        let launch = run_pipeline(&spec, "ws", true, disc, inc, None).await.unwrap();
+        assert_eq!(launch.edge_discernments.len(), 1);
+        let d = &launch.edge_discernments[0];
+        let dis = d.discernment.as_ref().expect("discernment present");
+        assert_eq!(dis.mime.as_deref(), Some("application/json"));
+        // Cosecha.
+        for (_, pid) in &launch.command_pids {
+            let _ = nix::sys::wait::waitpid(nix::unistd::Pid::from_raw(*pid), None);
+        }
+    }
+}
diff --git a/02_ruway/shuma/sandbox/shuma-core/src/pipelines.rs b/02_ruway/shuma/sandbox/shuma-core/src/pipelines.rs
new file mode 100644
index 0000000..09910df
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-core/src/pipelines.rs
@@ -0,0 +1,318 @@
+//! Pipelines, supervisores, flows y pipelines guardados.
+
+use super::*;
+
+impl WorkspaceManager {
+    pub fn new(cfg: IncarnatorConfig) -> Self {
+        Self {
+            inner: Arc::new(Mutex::new(Inner {
+                workspaces: HashMap::new(),
+                saved_pipelines: HashMap::new(),
+                pipeline_flows: HashMap::new(),
+                restart_specs: HashMap::new(),
+                pipeline_supervisors: HashMap::new(),
+                pending_pipeline_restarts: Vec::new(),
+            })),
+            incarnator: Arc::new(Incarnator::new(cfg)),
+            dirty: std::sync::atomic::AtomicBool::new(false),
+        }
+    }
+
+    /// Marca el manager como dirty. Cualquier mutación que afecta al
+    /// snapshot debería llamar esto.
+    #[inline]
+    pub(crate) fn mark_dirty(&self) {
+        self.dirty.store(true, std::sync::atomic::Ordering::Relaxed);
+    }
+
+    /// True si hubo cambios desde el último `save_snapshot`. Útil para
+    /// chequeos cooperativos (ej. monitoring que pollea cada N).
+    pub fn is_dirty(&self) -> bool {
+        self.dirty.load(std::sync::atomic::Ordering::Relaxed)
+    }
+
+    /// Registra un supervisor para un pipeline con `restart_on_failure=true`.
+    /// El daemon llama esto tras `run_pipeline` para que `reap_dead` agregue
+    /// el pipeline a la cola de restart cuando algún command falle.
+    pub async fn register_pipeline_supervisor(
+        &self,
+        pipeline_id: Ulid,
+        workspace: WorkspaceId,
+        spec: PipelineSpec,
+        tap: bool,
+    ) {
+        if !spec.restart_on_failure {
+            return;
+        }
+        tracing::debug!(%pipeline_id, label = %spec.label, "pipeline supervisor registered");
+        let mut g = self.inner.lock().await;
+        let initial_backoff = spec.restart_backoff_ms.max(50);
+        g.pipeline_supervisors.insert(
+            pipeline_id,
+            PipelineSupervisor {
+                workspace,
+                spec,
+                tap,
+                restart_count: 0,
+                current_backoff_ms: initial_backoff,
+            },
+        );
+        drop(g);
+        self.mark_dirty();
+    }
+
+    /// Variante que preserva backoff/count del supervisor anterior (para
+    /// re-registrar tras un restart sin perder el throttle acumulado).
+    pub async fn register_pipeline_supervisor_with_state(
+        &self,
+        pipeline_id: Ulid,
+        workspace: WorkspaceId,
+        spec: PipelineSpec,
+        tap: bool,
+        restart_count: u32,
+        current_backoff_ms: u64,
+    ) {
+        if !spec.restart_on_failure {
+            return;
+        }
+        let mut g = self.inner.lock().await;
+        g.pipeline_supervisors.insert(
+            pipeline_id,
+            PipelineSupervisor {
+                workspace,
+                spec,
+                tap,
+                restart_count,
+                current_backoff_ms,
+            },
+        );
+    }
+
+    /// Drena la cola de pipelines pendientes de restart y retorna las
+    /// specs a relaunch. El daemon lo llama tras cada `reap_dead`.
+    ///
+    /// Aplica `restart_max`: si el supervisor ya pasó el límite, no se
+    /// retorna y el supervisor se elimina (give-up). El backoff
+    /// preserva el valor actual; el daemon decide cuándo aplicar el
+    /// sleep antes del relaunch.
+    pub async fn take_pending_restarts(&self) -> Vec<PipelineSupervisor> {
+        let mut g = self.inner.lock().await;
+        let pending = std::mem::take(&mut g.pending_pipeline_restarts);
+        let mut out = Vec::with_capacity(pending.len());
+        for old_id in pending {
+            if let Some(mut sup) = g.pipeline_supervisors.remove(&old_id) {
+                if sup.spec.restart_max > 0 && sup.restart_count >= sup.spec.restart_max {
+                    tracing::warn!(
+                        label = %sup.spec.label,
+                        restart_count = sup.restart_count,
+                        max = sup.spec.restart_max,
+                        "pipeline restart_max reached — giving up"
+                    );
+                    continue; // no relaunch, supervisor discarded.
+                }
+                sup.restart_count += 1;
+                out.push(sup);
+            }
+        }
+        out
+    }
+
+    /// Registra los comandos lanzados por un pipeline en el workspace.
+    /// Esto permite `pipeline_stop` (matar selectivamente sólo los pids
+    /// de un pipeline). `pipeline_id` se setea en cada CommandState.
+    pub async fn register_pipeline_commands(
+        &self,
+        workspace: WorkspaceId,
+        pipeline_id: Ulid,
+        commands: Vec<(String, i32)>,
+    ) {
+        let mut g = self.inner.lock().await;
+        let Some(ws) = g.workspaces.get_mut(&workspace) else { return };
+        for (label, pid) in commands {
+            let cmd_id = Ulid::new();
+            ws.commands.insert(
+                cmd_id,
+                CommandState {
+                    id: cmd_id,
+                    label,
+                    pid: Pid::from_raw(pid),
+                    alive: true,
+                    exit_status: None,
+                    stdout: None,
+                    stderr: None,
+                    pipeline_id: Some(pipeline_id),
+                },
+            );
+        }
+    }
+
+    /// Detiene selectivamente los comandos de un pipeline. SIGTERM →
+    /// `grace` → SIGKILL. Devuelve cantidad reapeada. Si no hay comandos
+    /// del pipeline en ningún workspace, retorna 0.
+    pub async fn stop_pipeline(
+        &self,
+        pipeline_id: Ulid,
+        grace: std::time::Duration,
+    ) -> u32 {
+        // 1) Recolectamos pids de ese pipeline en todos los workspaces.
+        let mut targets: Vec<Pid> = Vec::new();
+        {
+            let g = self.inner.lock().await;
+            for ws in g.workspaces.values() {
+                for cmd in ws.commands.values() {
+                    if cmd.alive && cmd.pipeline_id == Some(pipeline_id) {
+                        targets.push(cmd.pid);
+                    }
+                }
+            }
+        }
+        if targets.is_empty() {
+            return 0;
+        }
+        let initial = if grace.is_zero() { Signal::SIGKILL } else { Signal::SIGTERM };
+        for pid in &targets {
+            let _ = kill(*pid, initial);
+        }
+        let mut reaped = 0u32;
+        let mut still = targets.clone();
+        let deadline = std::time::Instant::now() + grace;
+        let poll = std::time::Duration::from_millis(20);
+        while !still.is_empty() && std::time::Instant::now() < deadline {
+            still.retain(|pid| match waitpid(*pid, Some(WaitPidFlag::WNOHANG)) {
+                Ok(WaitStatus::StillAlive) => true,
+                Ok(_) => {
+                    reaped += 1;
+                    false
+                }
+                Err(_) => false,
+            });
+            if !still.is_empty() {
+                tokio::time::sleep(poll).await;
+            }
+        }
+        for pid in &still {
+            let _ = kill(*pid, Signal::SIGKILL);
+            let _ = waitpid(*pid, None);
+            reaped += 1;
+        }
+        // Marcar como dead en estado in-memory.
+        let mut g = self.inner.lock().await;
+        for ws in g.workspaces.values_mut() {
+            for cmd in ws.commands.values_mut() {
+                if cmd.pipeline_id == Some(pipeline_id) && cmd.alive {
+                    cmd.alive = false;
+                }
+            }
+        }
+        // Drop flows del pipeline.
+        g.pipeline_flows.remove(&pipeline_id);
+        info!(%pipeline_id, reaped, "pipeline stopped");
+        reaped
+    }
+
+    /// Retiene los FlowChannels de un pipeline para que sobrevivan al
+    /// fin del request. Drop = cierre del data plane.
+    pub async fn retain_pipeline_flows(
+        &self,
+        pipeline: Ulid,
+        flows: Vec<crate::flow_channel::FlowChannel>,
+    ) {
+        self.inner.lock().await.pipeline_flows.insert(pipeline, flows);
+    }
+
+    /// Snapshot de counts agregados para health endpoint.
+    pub async fn health_counts(&self) -> HealthCounts {
+        let g = self.inner.lock().await;
+        let alive_workspaces = g.workspaces.len() as u32;
+        let alive_commands: u32 = g
+            .workspaces
+            .values()
+            .map(|ws| ws.commands.values().filter(|c| c.alive).count() as u32)
+            .sum();
+        let alive_pipelines = g.pipeline_supervisors.len() as u32;
+        let active_flows: u32 = g.pipeline_flows.values().map(|v| v.len() as u32).sum();
+        HealthCounts {
+            alive_workspaces,
+            alive_commands,
+            alive_pipelines,
+            active_flows,
+        }
+    }
+
+    /// Lista pipelines vivos con sus sockets activos.
+    pub async fn list_flow_pipelines(&self) -> Vec<(Ulid, Vec<std::path::PathBuf>)> {
+        let g = self.inner.lock().await;
+        g.pipeline_flows
+            .iter()
+            .map(|(id, flows)| {
+                (
+                    *id,
+                    flows.iter().map(|f| f.socket_path().to_path_buf()).collect(),
+                )
+            })
+            .collect()
+    }
+
+    /// Throughput per-socket: bytes_total + bytes_per_sec por flow socket.
+    pub async fn flow_throughput(&self) -> Vec<(std::path::PathBuf, u64, f64)> {
+        let g = self.inner.lock().await;
+        let mut out = Vec::new();
+        for flows in g.pipeline_flows.values() {
+            for fc in flows {
+                out.push((
+                    fc.socket_path().to_path_buf(),
+                    fc.meter().total_bytes(),
+                    fc.meter().bytes_per_sec(),
+                ));
+            }
+        }
+        out
+    }
+
+    /// Cierra el data plane de un pipeline (drop = remove_file de sockets).
+    pub async fn drop_pipeline_flows(&self, pipeline: Ulid) -> bool {
+        self.inner.lock().await.pipeline_flows.remove(&pipeline).is_some()
+    }
+
+    pub fn incarnator(&self) -> &Incarnator {
+        &self.incarnator
+    }
+
+    /// Handle Arc-clonable del Incarnator, para que el pipeline lo pueda
+    /// usar fuera del manager.
+    pub fn incarnator_handle(&self) -> Arc<Incarnator> {
+        self.incarnator.clone()
+    }
+
+    // -----------------------------------------------------------------
+    // Saved pipelines (definiciones nombradas, no runs)
+    // -----------------------------------------------------------------
+
+    /// Guarda (o reemplaza) un PipelineSpec bajo `name`.
+    pub async fn save_pipeline(&self, name: String, spec: PipelineSpec) {
+        self.inner.lock().await.saved_pipelines.insert(name, spec);
+        self.mark_dirty();
+    }
+
+    /// Devuelve los nombres de los pipelines guardados.
+    pub async fn list_saved_pipelines(&self) -> Vec<String> {
+        let g = self.inner.lock().await;
+        let mut v: Vec<String> = g.saved_pipelines.keys().cloned().collect();
+        v.sort();
+        v
+    }
+
+    /// Recupera el PipelineSpec guardado bajo `name`.
+    pub async fn get_saved_pipeline(&self, name: &str) -> Option<PipelineSpec> {
+        self.inner.lock().await.saved_pipelines.get(name).cloned()
+    }
+
+    /// Elimina un saved pipeline.
+    pub async fn drop_saved_pipeline(&self, name: &str) -> bool {
+        let existed = self.inner.lock().await.saved_pipelines.remove(name).is_some();
+        if existed {
+            self.mark_dirty();
+        }
+        existed
+    }
+}
diff --git a/02_ruway/shuma/sandbox/shuma-core/src/runtime.rs b/02_ruway/shuma/sandbox/shuma-core/src/runtime.rs
new file mode 100644
index 0000000..adce389
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-core/src/runtime.rs
@@ -0,0 +1,236 @@
+//! Lanzamiento de pipelines y reaping cooperativo de hijos muertos.
+
+use super::*;
+
+impl WorkspaceManager {
+    /// Lanza todas las Cards de un Pipeline. Devuelve (label, pid) por nodo.
+    /// La conexión via flows queda librada al broker (cuando haya integración
+    /// completa con sidecar; v1 sólo lanza).
+    pub async fn run_pipeline(
+        &self,
+        spec: &PipelineSpec,
+    ) -> Result<Vec<(String, Pid)>, CoreError> {
+        spec.validate()?;
+        let workspace_label = {
+            let g = self.inner.lock().await;
+            let ws = g
+                .workspaces
+                .get(&spec.workspace)
+                .ok_or(CoreError::WorkspaceNotFound(spec.workspace))?;
+            ws.spec.label.clone()
+        };
+        let mut launched = Vec::new();
+        for (i, node) in spec.nodes.iter().enumerate() {
+            let card = node.to_card(i, &workspace_label)?;
+            let out = self.incarnator.incarnate(&card)?;
+            let mut g = self.inner.lock().await;
+            if let Some(ws) = g.workspaces.get_mut(&spec.workspace) {
+                ws.commands.insert(
+                    card.id,
+                    CommandState {
+                        id: card.id,
+                        label: node.label.clone(),
+                        pid: out.pid,
+                        alive: true,
+                        exit_status: None,
+                        stdout: None, // run_pipeline NO captura (conecta por pipes).
+                        stderr: None,
+                        pipeline_id: None,
+                    },
+                );
+            }
+            launched.push((node.label.clone(), out.pid));
+        }
+        Ok(launched)
+    }
+
+    /// Cosecha hijos terminados (no-bloqueante). Llamar periódicamente desde
+    /// el daemon o ante SIGCHLD. Marca `alive=false` y guarda exit_status.
+    pub async fn reap_dead(self: &Arc<Self>) {
+        let mut to_restart: Vec<RestartSpec> = Vec::new();
+        let mut to_enforce_kill: Vec<WorkspaceId> = Vec::new();
+        {
+            let mut g = self.inner.lock().await;
+            for ws in g.workspaces.values_mut() {
+                for cmd in ws.commands.values_mut() {
+                    if !cmd.alive {
+                        continue;
+                    }
+                    match waitpid(cmd.pid, Some(WaitPidFlag::WNOHANG)) {
+                        Ok(WaitStatus::Exited(_, code)) => {
+                            cmd.alive = false;
+                            cmd.exit_status = Some(code);
+                        }
+                        Ok(WaitStatus::Signaled(_, sig, _)) => {
+                            cmd.alive = false;
+                            cmd.exit_status = Some(128 + (sig as i32));
+                        }
+                        _ => {}
+                    }
+                }
+            }
+            // Quota enforcement: chequear breach por workspace y aplicar policy.
+            // Lo hacemos dentro del mismo lock para tener una lectura
+            // consistente; el kill real va fuera del lock.
+            for (ws_id, ws) in g.workspaces.iter() {
+                let rl = &ws.spec.soma.rlimits;
+                let qe = &ws.spec.quota_enforce;
+                // Sólo aplicamos si hay al menos una action != None.
+                if qe.mem == shuma_card::QuotaAction::None
+                    && qe.nproc == shuma_card::QuotaAction::None
+                {
+                    continue;
+                }
+                // Medir RSS y nproc vivos sin pasar por workspace_stats
+                // (que tomaría el lock recursivo). Hacemos un read directo.
+                let alive: Vec<i32> = ws
+                    .commands
+                    .values()
+                    .filter(|c| c.alive)
+                    .map(|c| c.pid.as_raw())
+                    .collect();
+                let nproc_alive = alive.len() as u32;
+                let mem_used: u64 = alive
+                    .iter()
+                    .filter_map(|pid| read_proc_rss(*pid))
+                    .sum();
+
+                let mem_breach = matches!(rl.mem_bytes, Some(limit) if mem_used > limit);
+                let nproc_breach = matches!(rl.nproc, Some(limit) if nproc_alive > limit);
+
+                let mut kill_needed = false;
+                if mem_breach {
+                    match qe.mem {
+                        shuma_card::QuotaAction::Log => {
+                            warn!(%ws_id, used = mem_used, limit = ?rl.mem_bytes, "quota breach: memory");
+                        }
+                        shuma_card::QuotaAction::Kill => {
+                            warn!(%ws_id, used = mem_used, limit = ?rl.mem_bytes, "quota breach: KILLING");
+                            kill_needed = true;
+                        }
+                        _ => {}
+                    }
+                }
+                if nproc_breach {
+                    match qe.nproc {
+                        shuma_card::QuotaAction::Log => {
+                            warn!(%ws_id, alive = nproc_alive, limit = ?rl.nproc, "quota breach: nproc");
+                        }
+                        shuma_card::QuotaAction::Kill => {
+                            warn!(%ws_id, alive = nproc_alive, limit = ?rl.nproc, "quota breach: KILLING");
+                            kill_needed = true;
+                        }
+                        _ => {}
+                    }
+                }
+                if kill_needed {
+                    to_enforce_kill.push(*ws_id);
+                }
+            }
+            // Pipeline supervisor: detectar pipelines cuyos comandos tienen
+            // failure. Marca para restart si tiene supervisor.
+            // Esto se hace cuando TODOS los comandos del pipeline están
+            // dead Y al menos uno tiene exit!=0 (sino podría disparar
+            // restart mientras otros comandos aún corren — incorrecto).
+            let supervisor_ids: Vec<Ulid> = g.pipeline_supervisors.keys().copied().collect();
+            for pipe_id in supervisor_ids {
+                // ¿Hay algún comando vivo de este pipeline?
+                let mut all_dead = true;
+                let mut any_failed = false;
+                for ws in g.workspaces.values() {
+                    for cmd in ws.commands.values() {
+                        if cmd.pipeline_id != Some(pipe_id) {
+                            continue;
+                        }
+                        if cmd.alive {
+                            all_dead = false;
+                        } else if cmd.exit_status.map_or(false, |s| s != 0) {
+                            any_failed = true;
+                        }
+                    }
+                }
+                if all_dead && any_failed {
+                    // Push a queue si no estaba ya.
+                    if !g.pending_pipeline_restarts.contains(&pipe_id) {
+                        g.pending_pipeline_restarts.push(pipe_id);
+                    }
+                }
+            }
+            // Detectar restart_specs cuyo command_id ya está dead con exit!=0.
+            let mut to_remove: Vec<Ulid> = Vec::new();
+            for (cmd_id, spec) in g.restart_specs.iter() {
+                let mut should_restart = false;
+                let mut should_drop = false;
+                'outer: for ws in g.workspaces.values() {
+                    if let Some(cmd) = ws.commands.get(cmd_id) {
+                        if !cmd.alive {
+                            match cmd.exit_status {
+                                Some(0) => should_drop = true,
+                                Some(_) => should_restart = true,
+                                None => {}
+                            }
+                            break 'outer;
+                        }
+                    }
+                }
+                if should_drop {
+                    to_remove.push(*cmd_id);
+                } else if should_restart {
+                    to_restart.push(spec.clone());
+                    to_remove.push(*cmd_id);
+                }
+            }
+            for id in to_remove {
+                g.restart_specs.remove(&id);
+            }
+        }
+        // Quota enforcement: kill workspaces fuera del lock.
+        for ws_id in to_enforce_kill {
+            let _ = self.stop_with_grace(ws_id, std::time::Duration::ZERO).await;
+        }
+        // Schedule restart fuera del lock.
+        for mut spec in to_restart {
+            let mgr = self.clone();
+            let backoff = std::time::Duration::from_millis(spec.backoff_ms);
+            // Subir el backoff para la PRÓXIMA falla, no esta.
+            spec.backoff_ms = (spec.backoff_ms * 2).min(spec.max_backoff_ms);
+            spec.restart_count += 1;
+            let restart_n = spec.restart_count;
+            tokio::spawn(async move {
+                tokio::time::sleep(backoff).await;
+                info!(
+                    backoff_ms = backoff.as_millis() as u64,
+                    restart = restart_n,
+                    "restarting failed command"
+                );
+                let workspace = spec.workspace;
+                if let Err(e) = mgr
+                    .run_with_options(workspace, spec.exec.clone(), spec.argv.clone(), spec.envp.clone(), true)
+                    .await
+                {
+                    warn!(?e, "restart failed to launch");
+                    return;
+                }
+                // Preservar backoff acumulado: localizar el nuevo command_id
+                // (el más reciente vivo en el workspace) y sobreescribir.
+                let new_cmd_id = {
+                    let g = mgr.inner.lock().await;
+                    g.workspaces.get(&workspace).and_then(|ws| {
+                        ws.commands
+                            .values()
+                            .filter(|c| c.alive)
+                            .max_by_key(|c| c.id)
+                            .map(|c| c.id)
+                    })
+                };
+                if let Some(new_id) = new_cmd_id {
+                    let mut g = mgr.inner.lock().await;
+                    if let Some(existing) = g.restart_specs.get_mut(&new_id) {
+                        existing.backoff_ms = spec.backoff_ms;
+                        existing.restart_count = spec.restart_count;
+                    }
+                }
+            });
+        }
+    }
+}
diff --git a/02_ruway/shuma/sandbox/shuma-core/src/stats.rs b/02_ruway/shuma/sandbox/shuma-core/src/stats.rs
new file mode 100644
index 0000000..33ba9ff
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-core/src/stats.rs
@@ -0,0 +1,210 @@
+//! Resource accounting por workspace.
+//!
+//! Dos fuentes:
+//! - **Per-proc** (`/proc/<pid>/status` + `stat`): suma RSS y CPU ticks de
+//!   los comandos vivos del workspace. Siempre disponible. Costo: O(N pids).
+//! - **Cgroup v2** (`memory.current`, `cpu.stat`): un read por workspace si
+//!   `SomaSpec.cgroup.path` está y es leíble. Más preciso (incluye descendants).
+//!
+//! Si ambos están disponibles, devolvemos el cgroup (más preciso) y dejamos
+//! el per-proc como `sample_via_proc`.
+
+use std::path::Path;
+use std::time::Instant;
+
+#[derive(Debug, Clone, Default)]
+pub struct WorkspaceStats {
+    pub commands_alive: u32,
+    pub commands_total: u32,
+    /// RSS sumado en bytes. `None` si no se pudo medir.
+    pub rss_bytes: Option<u64>,
+    /// High-water mark de RSS (peak alguna vez observado). Cgroup v2:
+    /// `memory.peak` (≥6.5). Per-proc: suma de `VmHWM` de cada pid.
+    pub rss_peak_bytes: Option<u64>,
+    /// Tiempo CPU acumulado en microsegundos. `None` si no se pudo medir.
+    pub cpu_usec: Option<u64>,
+    /// %CPU instantáneo derivado entre dos samples consecutivos. `None`
+    /// en el primer sample (no hay baseline). `100.0` = 1 core saturado.
+    /// `400.0` con 4 cores activos = la máquina al 100%.
+    pub cpu_percent: Option<f32>,
+    /// Cores online detectados (sysconf `_SC_NPROCESSORS_ONLN`). Útil
+    /// para normalizar `cpu_percent / cpu_cores` → 0..100 absoluto.
+    pub cpu_cores: u32,
+    /// Fuente del dato: "proc" | "cgroup" | "mixed".
+    pub source: String,
+    /// Wall-clock uptime del workspace en milisegundos.
+    pub uptime_ms: u64,
+}
+
+impl WorkspaceStats {
+    /// CPU% normalizado al 100% total de la máquina (no por core).
+    /// Útil para comparar workspaces independiente del paralelismo.
+    pub fn cpu_percent_total(&self) -> Option<f32> {
+        self.cpu_percent
+            .map(|p| if self.cpu_cores == 0 { p } else { p / self.cpu_cores as f32 })
+    }
+}
+
+/// Reporte de quotas: comparación entre el accounting real y los
+/// `rlimits` declarados en `SomaSpec`. NO hace enforcement automático
+/// en v1 — sólo accounting + reporting. El caller decide qué hacer.
+#[derive(Debug, Clone, Default)]
+pub struct QuotaReport {
+    /// Límite de memoria declarado (bytes). None = sin límite.
+    pub mem_limit: Option<u64>,
+    /// Límite de procesos declarado.
+    pub nproc_limit: Option<u32>,
+    /// Lista de violaciones detectadas (strings humano-legibles).
+    /// Empty = todo dentro de quota.
+    pub breaches: Vec<String>,
+}
+
+/// Detecta cores online runtime. Cacheado vía OnceLock — el valor no
+/// cambia salvo hotplug, que es raro y aceptamos sample stale.
+fn online_cores() -> u32 {
+    static CACHED: std::sync::OnceLock<u32> = std::sync::OnceLock::new();
+    *CACHED.get_or_init(|| {
+        let n = unsafe { libc::sysconf(libc::_SC_NPROCESSORS_ONLN) };
+        if n > 0 { n as u32 } else { 1 }
+    })
+}
+
+/// Mide stats para un set de PIDs vivos + un path de cgroup opcional.
+pub fn measure(
+    alive_pids: &[i32],
+    cgroup_path: Option<&Path>,
+    workspace_started: Instant,
+) -> WorkspaceStats {
+    let mut rss_proc: u64 = 0;
+    let mut rss_peak_proc: u64 = 0;
+    let mut cpu_proc: u64 = 0;
+    let mut proc_ok = false;
+    for &pid in alive_pids {
+        if let Some((rss, peak, cpu)) = read_proc_pid(pid) {
+            rss_proc += rss;
+            rss_peak_proc += peak;
+            cpu_proc += cpu;
+            proc_ok = true;
+        }
+    }
+
+    let cgroup = cgroup_path.and_then(read_cgroup_stats);
+
+    let (rss, rss_peak, cpu, source) = match (cgroup, proc_ok) {
+        (Some(cg), _) => (Some(cg.rss), cg.rss_peak, Some(cg.cpu_usec), "cgroup".to_string()),
+        (None, true) => (
+            Some(rss_proc),
+            Some(rss_peak_proc),
+            Some(cpu_proc),
+            "proc".to_string(),
+        ),
+        (None, false) => (None, None, None, "none".to_string()),
+    };
+
+    WorkspaceStats {
+        commands_alive: alive_pids.len() as u32,
+        commands_total: 0,
+        rss_bytes: rss,
+        rss_peak_bytes: rss_peak,
+        cpu_usec: cpu,
+        cpu_percent: None, // El caller lo rellena con el diff vs prev sample.
+        cpu_cores: online_cores(),
+        source,
+        uptime_ms: workspace_started.elapsed().as_millis() as u64,
+    }
+}
+
+struct CgroupStats {
+    rss: u64,
+    rss_peak: Option<u64>,
+    cpu_usec: u64,
+}
+
+/// Lee `(rss_bytes, rss_peak_bytes, cpu_usec)` de `/proc/<pid>/`. None si el proc desapareció.
+fn read_proc_pid(pid: i32) -> Option<(u64, u64, u64)> {
+    let (rss_kb, hwm_kb) = {
+        let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
+        let mut rss = 0u64;
+        let mut hwm = 0u64;
+        for l in status.lines() {
+            if let Some(rest) = l.strip_prefix("VmRSS:") {
+                rss = rest
+                    .trim()
+                    .split_whitespace()
+                    .next()
+                    .and_then(|s| s.parse().ok())
+                    .unwrap_or(0);
+            } else if let Some(rest) = l.strip_prefix("VmHWM:") {
+                hwm = rest
+                    .trim()
+                    .split_whitespace()
+                    .next()
+                    .and_then(|s| s.parse().ok())
+                    .unwrap_or(0);
+            }
+        }
+        (rss, hwm)
+    };
+    let cpu_usec = {
+        let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
+        // format: pid (comm) state ppid pgrp ... utime stime cutime cstime
+        // Cuidado: comm puede tener espacios y paréntesis. Buscamos la última `)`.
+        let end_comm = stat.rfind(')')?;
+        let after = &stat[end_comm + 1..];
+        let fields: Vec<&str> = after.split_whitespace().collect();
+        // Tras `)`, índice 0 = state, índice 11 = utime, 12 = stime.
+        let utime = fields.get(11).and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
+        let stime = fields.get(12).and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
+        let ticks = utime + stime;
+        // Convertimos ticks → microsegundos. SC_CLK_TCK típicamente 100.
+        let clk_tck = unsafe { libc::sysconf(libc::_SC_CLK_TCK) }.max(1) as u64;
+        ticks * 1_000_000 / clk_tck
+    };
+    Some((rss_kb * 1024, hwm_kb * 1024, cpu_usec))
+}
+
+/// Lee `CgroupStats` del cgroup. None si no existe o no es leíble.
+/// `memory.peak` requiere kernel ≥6.5; si falta, `rss_peak` queda None.
+fn read_cgroup_stats(cgroup_path: &Path) -> Option<CgroupStats> {
+    let mem = std::fs::read_to_string(cgroup_path.join("memory.current"))
+        .ok()
+        .and_then(|s| s.trim().parse::<u64>().ok())?;
+    let cpu_stat = std::fs::read_to_string(cgroup_path.join("cpu.stat")).ok()?;
+    let cpu_usec = cpu_stat
+        .lines()
+        .find_map(|l| l.strip_prefix("usage_usec"))
+        .and_then(|s| s.split_whitespace().next())
+        .and_then(|s| s.parse::<u64>().ok())
+        .unwrap_or(0);
+    let peak = std::fs::read_to_string(cgroup_path.join("memory.peak"))
+        .ok()
+        .and_then(|s| s.trim().parse::<u64>().ok());
+    Some(CgroupStats {
+        rss: mem,
+        rss_peak: peak,
+        cpu_usec,
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn measure_with_no_pids_returns_zero() {
+        let stats = measure(&[], None, Instant::now());
+        assert_eq!(stats.commands_alive, 0);
+        assert_eq!(stats.rss_bytes, None);
+        assert_eq!(stats.source, "none");
+    }
+
+    #[test]
+    fn measure_self_pid_returns_data() {
+        let me = std::process::id() as i32;
+        let stats = measure(&[me], None, Instant::now());
+        assert_eq!(stats.commands_alive, 1);
+        // Nuestro propio RSS debería ser > 0.
+        assert!(stats.rss_bytes.unwrap_or(0) > 0);
+        assert_eq!(stats.source, "proc");
+    }
+}
diff --git a/02_ruway/shuma/sandbox/shuma-core/src/tests.rs b/02_ruway/shuma/sandbox/shuma-core/src/tests.rs
new file mode 100644
index 0000000..7b25dba
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-core/src/tests.rs
@@ -0,0 +1,308 @@
+use super::*;
+
+#[tokio::test]
+async fn ttl_auto_stops_workspace() {
+    let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+    let spec = WorkspaceSpec {
+        label: "ttl-test".into(),
+        soma: Default::default(),
+        permissions: Default::default(),
+        ttl: Some(std::time::Duration::from_millis(120)),
+        flow_dirs: vec![],
+        on_exit: shuma_card::ExitPolicy::Reap,
+        quota_enforce: Default::default(),
+    };
+    let (id, _) = mgr.create(spec).await.unwrap();
+    assert_eq!(mgr.list().await.len(), 1);
+    tokio::time::sleep(std::time::Duration::from_millis(250)).await;
+    assert_eq!(
+        mgr.list().await.len(),
+        0,
+        "TTL expirado: workspace debe haber sido removido"
+    );
+    let _ = id;
+}
+
+#[tokio::test]
+async fn create_and_list_workspace() {
+    let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+    let spec = WorkspaceSpec {
+        label: "test".into(),
+        soma: Default::default(),
+        permissions: Default::default(),
+        ttl: None,
+        flow_dirs: vec![],
+        on_exit: shuma_card::ExitPolicy::Reap,
+        quota_enforce: Default::default(),
+    };
+    let (id, _w) = mgr.create(spec).await.unwrap();
+    let list = mgr.list().await;
+    assert_eq!(list.len(), 1);
+    assert_eq!(list[0].id, id);
+}
+
+#[tokio::test]
+async fn run_captures_stdout_to_log() {
+    let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+    let spec = WorkspaceSpec {
+        label: "logs".into(),
+        soma: Default::default(),
+        permissions: Default::default(),
+        ttl: None,
+        flow_dirs: vec![],
+        on_exit: shuma_card::ExitPolicy::Reap,
+        quota_enforce: Default::default(),
+    };
+    let (id, _) = mgr.create(spec).await.unwrap();
+    let summary = mgr
+        .run(id, "/bin/echo".into(), vec!["captured-output".into()], vec![])
+        .await
+        .unwrap();
+    for _ in 0..50 {
+        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
+        mgr.reap_dead().await;
+        let logs = mgr
+            .get_command_logs(id, summary.id, 0, LogStream::Stdout)
+            .await
+            .unwrap_or_default();
+        if !logs.is_empty() {
+            let s = String::from_utf8_lossy(&logs);
+            assert!(s.contains("captured-output"), "got: {s:?}");
+            return;
+        }
+    }
+    panic!("logs never captured");
+}
+
+#[tokio::test]
+async fn run_captures_stderr_separately() {
+    let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+    let spec = WorkspaceSpec {
+        label: "stderr".into(),
+        soma: Default::default(),
+        permissions: Default::default(),
+        ttl: None,
+        flow_dirs: vec![],
+        on_exit: shuma_card::ExitPolicy::Reap,
+        quota_enforce: Default::default(),
+    };
+    let (id, _) = mgr.create(spec).await.unwrap();
+    // sh -c "echo OUT; echo ERR >&2"
+    let summary = mgr
+        .run(
+            id,
+            "/bin/sh".into(),
+            vec!["-c".into(), "echo OUT; echo ERR >&2".into()],
+            vec![],
+        )
+        .await
+        .unwrap();
+    for _ in 0..50 {
+        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
+        mgr.reap_dead().await;
+        let so = mgr
+            .get_command_logs(id, summary.id, 0, LogStream::Stdout)
+            .await
+            .unwrap_or_default();
+        let se = mgr
+            .get_command_logs(id, summary.id, 0, LogStream::Stderr)
+            .await
+            .unwrap_or_default();
+        if !so.is_empty() && !se.is_empty() {
+            let so_s = String::from_utf8_lossy(&so);
+            let se_s = String::from_utf8_lossy(&se);
+            assert!(so_s.contains("OUT"), "stdout: {so_s:?}");
+            assert!(se_s.contains("ERR"), "stderr: {se_s:?}");
+            assert!(!so_s.contains("ERR"), "stdout no debería tener ERR");
+            assert!(!se_s.contains("OUT"), "stderr no debería tener OUT");
+            return;
+        }
+    }
+    panic!("logs never captured on both streams");
+}
+
+#[tokio::test]
+async fn restart_on_failure_relaunches_failing_command() {
+    let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+    let spec = WorkspaceSpec {
+        label: "restart".into(),
+        soma: Default::default(),
+        permissions: Default::default(),
+        ttl: None,
+        flow_dirs: vec![],
+        on_exit: shuma_card::ExitPolicy::Reap,
+        quota_enforce: Default::default(),
+    };
+    let (id, _) = mgr.create(spec).await.unwrap();
+    // /bin/false sale con exit=1. Con restart_on_failure=true debería
+    // relanzarse al menos 1 vez (tras el backoff inicial de 200ms).
+    let summary = mgr
+        .run_with_options(id, "/bin/false".into(), vec![], vec![], true)
+        .await
+        .unwrap();
+    let original_id = summary.id;
+    // Esperamos ~500ms para que termine + reap + restart corra.
+    for _ in 0..30 {
+        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
+        mgr.reap_dead().await;
+        let g = mgr.inner.lock().await;
+        if let Some(ws) = g.workspaces.get(&id) {
+            let new_cmds: Vec<_> = ws.commands.keys().filter(|k| **k != original_id).collect();
+            if !new_cmds.is_empty() {
+                // Hay un nuevo command_id → restart funcionó.
+                return;
+            }
+        }
+    }
+    panic!("restart never launched a new command");
+}
+
+#[tokio::test]
+async fn pipeline_supervisor_queues_restart_on_failure() {
+    use shuma_card::{CommandRef, DiscernPolicy, PipelineSpec};
+    let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+    let (ws_id, _) = mgr.create(WorkspaceSpec {
+        label: "psup".into(),
+        soma: Default::default(),
+        permissions: Default::default(),
+        ttl: None,
+        flow_dirs: vec![],
+        on_exit: shuma_card::ExitPolicy::Reap,
+        quota_enforce: Default::default(),
+    }).await.unwrap();
+    let spec = PipelineSpec {
+        label: "fail-pipeline".into(),
+        workspace: ws_id,
+        nodes: vec![CommandRef {
+            label: "boom".into(),
+            payload: card_core::Payload::Native {
+                exec: "/bin/false".into(),
+                argv: vec![],
+                envp: vec![],
+            },
+            soma: Default::default(),
+            flows: Default::default(),
+            supervision: card_core::Supervision::OneShot,
+        }],
+        edges: vec![],
+        discern: DiscernPolicy::default(),
+        restart_on_failure: true,
+        restart_backoff_ms: 200,
+        restart_max_backoff_ms: 30_000,
+        restart_max: 0,
+    };
+    let pipeline_id = ulid::Ulid::new();
+    // Simulamos lo que haría el daemon: registramos un comando como
+    // si fuera de pipeline. Usamos `register_pipeline_commands` con
+    // un pid fake — pero como reaper hace waitpid, mejor lanzar de verdad.
+    // Hack: usar /bin/false via run() y manualmente marcar pipeline_id.
+    let summary = mgr.run(ws_id, "/bin/false".into(), vec![], vec![]).await.unwrap();
+    // Marcar el comando con pipeline_id manualmente.
+    {
+        let mut g = mgr.inner.lock().await;
+        if let Some(ws) = g.workspaces.get_mut(&ws_id) {
+            if let Some(cmd) = ws.commands.get_mut(&summary.id) {
+                cmd.pipeline_id = Some(pipeline_id);
+            }
+        }
+    }
+    mgr.register_pipeline_supervisor(pipeline_id, ws_id, spec, true).await;
+    // Esperamos que reap detecte la falla y push a pending.
+    for _ in 0..40 {
+        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
+        mgr.reap_dead().await;
+        let pending = mgr.take_pending_restarts().await;
+        if !pending.is_empty() {
+            assert_eq!(pending[0].spec.label, "fail-pipeline");
+            return;
+        }
+    }
+    panic!("supervisor never queued a restart");
+}
+
+#[tokio::test]
+async fn quota_enforce_nproc_kill_terminates_commands() {
+    let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+    let mut spec = WorkspaceSpec {
+        label: "qenforce".into(),
+        soma: Default::default(),
+        permissions: Default::default(),
+        ttl: None,
+        flow_dirs: vec![],
+        on_exit: shuma_card::ExitPolicy::Reap,
+        quota_enforce: shuma_card::QuotaEnforcement {
+            mem: shuma_card::QuotaAction::None,
+            nproc: shuma_card::QuotaAction::Kill,
+        },
+    };
+    spec.soma.rlimits.nproc = Some(1);
+    let (id, _) = mgr.create(spec).await.unwrap();
+    // Lanzo 2 procesos (cada uno sleep). nproc_limit=1 → breach inmediato.
+    let _ = mgr.run(id, "/bin/sleep".into(), vec!["5".into()], vec![]).await.unwrap();
+    let _ = mgr.run(id, "/bin/sleep".into(), vec!["5".into()], vec![]).await.unwrap();
+    // Reaper detecta breach y mata workspace.
+    for _ in 0..30 {
+        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
+        mgr.reap_dead().await;
+        let alive = mgr.list().await;
+        if alive.is_empty() {
+            return; // workspace removido por stop()
+        }
+    }
+    panic!("quota enforce kill never triggered");
+}
+
+#[tokio::test]
+async fn workspace_stats_history_accumulates() {
+    let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+    let spec = WorkspaceSpec {
+        label: "history".into(),
+        soma: Default::default(),
+        permissions: Default::default(),
+        ttl: None,
+        flow_dirs: vec![],
+        on_exit: shuma_card::ExitPolicy::Reap,
+        quota_enforce: Default::default(),
+    };
+    let (id, _) = mgr.create(spec).await.unwrap();
+    // Necesitamos al menos un comando vivo para que `measure` no
+    // retorne source=none (que igual se appendea, pero con stats vacíos).
+    let _ = mgr
+        .run(id, "/bin/sleep".into(), vec!["5".into()], vec![])
+        .await
+        .unwrap();
+    // Llamar stats 5 veces.
+    for _ in 0..5 {
+        let _ = mgr.workspace_stats(id).await;
+    }
+    let history = mgr.workspace_stats_history(id, 0).await.unwrap();
+    assert_eq!(history.len(), 5, "history debería tener 5 samples");
+    // tail=3 retorna los últimos 3.
+    let tail3 = mgr.workspace_stats_history(id, 3).await.unwrap();
+    assert_eq!(tail3.len(), 3);
+    // Cleanup.
+    let _ = mgr.stop_with_grace(id, std::time::Duration::ZERO).await;
+}
+
+#[tokio::test]
+async fn run_true_in_workspace() {
+    let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+    let spec = WorkspaceSpec {
+        label: "exec".into(),
+        soma: Default::default(),
+        permissions: Default::default(),
+        ttl: None,
+        flow_dirs: vec![],
+        on_exit: shuma_card::ExitPolicy::Reap,
+        quota_enforce: Default::default(),
+    };
+    let (id, _) = mgr.create(spec).await.unwrap();
+    let summary = mgr
+        .run(id, "/bin/true".into(), vec![], vec![])
+        .await
+        .unwrap();
+    assert!(summary.pid > 0);
+    // Cosecha.
+    std::thread::sleep(std::time::Duration::from_millis(100));
+    mgr.reap_dead().await;
+}
diff --git a/02_ruway/shuma/sandbox/shuma-core/src/workspaces.rs b/02_ruway/shuma/sandbox/shuma-core/src/workspaces.rs
new file mode 100644
index 0000000..7832b0f
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-core/src/workspaces.rs
@@ -0,0 +1,414 @@
+//! Workspaces: alta/baja, stats/quota, listado, comandos.
+
+use super::*;
+
+impl WorkspaceManager {
+
+    /// Label del workspace, si existe.
+    pub async fn workspace_label(&self, id: WorkspaceId) -> Option<String> {
+        self.inner
+            .lock()
+            .await
+            .workspaces
+            .get(&id)
+            .map(|w| w.spec.label.clone())
+    }
+
+    /// Compara accounting real (RSS, commands_alive) contra los rlimits
+    /// declarados en `SomaSpec`. Devuelve violaciones humanizadas. NO
+    /// hace enforcement automático.
+    pub async fn workspace_quota(&self, id: WorkspaceId) -> Option<stats::QuotaReport> {
+        let stats_now = self.workspace_stats(id).await?;
+        let g = self.inner.lock().await;
+        let ws = g.workspaces.get(&id)?;
+        let rl = &ws.spec.soma.rlimits;
+        let mut report = stats::QuotaReport {
+            mem_limit: rl.mem_bytes,
+            nproc_limit: rl.nproc,
+            breaches: Vec::new(),
+        };
+        if let (Some(limit), Some(used)) = (rl.mem_bytes, stats_now.rss_bytes) {
+            if used > limit {
+                report.breaches.push(format!(
+                    "memory: {:.2} MiB > {:.2} MiB limit",
+                    used as f64 / 1024.0 / 1024.0,
+                    limit as f64 / 1024.0 / 1024.0,
+                ));
+            }
+        }
+        if let Some(limit) = rl.nproc {
+            if stats_now.commands_alive > limit {
+                report.breaches.push(format!(
+                    "nproc: {} alive > {} limit",
+                    stats_now.commands_alive, limit
+                ));
+            }
+        }
+        Some(report)
+    }
+
+    /// Estadísticas de recursos del workspace: RSS + CPU agregado de sus
+    /// comandos vivos. Lee `/proc/<pid>/` directamente; si el spec declara
+    /// `soma.cgroup.path`, también intenta el cgroup (más preciso, incluye
+    /// descendants).
+    ///
+    /// `cpu_percent` se calcula entre samples consecutivos. Necesita ≥2
+    /// llamadas para tener un valor (la primera siempre retorna `None`).
+    pub async fn workspace_stats(&self, id: WorkspaceId) -> Option<stats::WorkspaceStats> {
+        let mut g = self.inner.lock().await;
+        let ws = g.workspaces.get_mut(&id)?;
+        let alive: Vec<i32> = ws
+            .commands
+            .values()
+            .filter(|c| c.alive)
+            .map(|c| c.pid.as_raw())
+            .collect();
+        let total = ws.commands.len() as u32;
+        let cgroup_path = if ws.spec.soma.cgroup.path.is_empty() {
+            None
+        } else {
+            Some(std::path::PathBuf::from(format!(
+                "/sys/fs/cgroup{}",
+                ws.spec.soma.cgroup.path
+            )))
+        };
+        let mut s = stats::measure(&alive, cgroup_path.as_deref(), ws.started);
+        s.commands_total = total;
+
+        // CPU%: diff entre el sample actual y el previo, dividido por
+        // wall time. 100% = 1 core saturado. >100% = varios cores.
+        let now = Instant::now();
+        if let Some(cpu_now) = s.cpu_usec {
+            if let Some((prev_t, prev_cpu)) = ws.last_cpu_sample {
+                let dt_us = now.duration_since(prev_t).as_micros() as u64;
+                let d_cpu = cpu_now.saturating_sub(prev_cpu);
+                if dt_us > 0 {
+                    s.cpu_percent = Some(100.0 * d_cpu as f32 / dt_us as f32);
+                }
+            }
+            ws.last_cpu_sample = Some((now, cpu_now));
+        }
+        // Append a history (ring buffer cap).
+        if ws.stats_history.len() >= STATS_HISTORY_CAP {
+            ws.stats_history.pop_front();
+        }
+        ws.stats_history.push_back(s.clone());
+        Some(s)
+    }
+
+    /// Retorna las últimas N samples de stats (servidas desde el ring
+    /// buffer interno). Sobrevive restart del shell.
+    pub async fn workspace_stats_history(
+        &self,
+        id: WorkspaceId,
+        tail: usize,
+    ) -> Option<Vec<stats::WorkspaceStats>> {
+        let g = self.inner.lock().await;
+        let ws = g.workspaces.get(&id)?;
+        let take = if tail == 0 { ws.stats_history.len() } else { tail };
+        let skip = ws.stats_history.len().saturating_sub(take);
+        Some(ws.stats_history.iter().skip(skip).cloned().collect())
+    }
+
+    pub async fn create(
+        self: &Arc<Self>,
+        spec: WorkspaceSpec,
+    ) -> Result<(WorkspaceId, Vec<String>), CoreError> {
+        self.create_with_id(WorkspaceId::new(), spec).await
+    }
+
+    /// Variante que acepta el ID. Útil para restore_snapshot: preserva
+    /// ULIDs entre restarts, así clients que tracking workspace_id no se
+    /// rompen.
+    pub async fn create_with_id(
+        self: &Arc<Self>,
+        id: WorkspaceId,
+        spec: WorkspaceSpec,
+    ) -> Result<(WorkspaceId, Vec<String>), CoreError> {
+        let card = spec.to_card(id)?;
+        let mut warnings = self.incarnator.dry_run(&card).warnings;
+        let ttl = spec.ttl;
+
+        // Si el workspace declara cgroup path Y rlimits, intentamos
+        // crear el cgroup y escribir memory.max/pids.max. El kernel
+        // hace OOM kill al exceder memory.max — enforcement automático
+        // sin policy adicional. Falla silenciosa si no hay delegation.
+        if !spec.soma.cgroup.path.is_empty() {
+            if let Ok(abs) = arje_incarnate::cgroup::ensure_cgroup(&spec.soma.cgroup) {
+                let applied =
+                    arje_incarnate::cgroup::apply_rlimits_to_cgroup(&abs, &spec.soma.rlimits);
+                if !applied.is_empty() {
+                    warnings.push(format!("cgroup limits applied: {}", applied.join(", ")));
+                }
+            }
+        }
+        let state = WorkspaceState {
+            id,
+            spec,
+            root_card: card,
+            commands: HashMap::new(),
+            started: Instant::now(),
+            last_cpu_sample: None,
+            stats_history: std::collections::VecDeque::with_capacity(STATS_HISTORY_CAP),
+        };
+        self.inner.lock().await.workspaces.insert(id, state);
+        self.mark_dirty();
+        info!(%id, ?ttl, "workspace created");
+
+        // Si tiene TTL, programar auto-stop. El task captura un weak ref
+        // al manager para no impedir que se dropée si el daemon termina.
+        if let Some(duration) = ttl {
+            let mgr_weak = Arc::downgrade(self);
+            tokio::spawn(async move {
+                tokio::time::sleep(duration).await;
+                if let Some(mgr) = mgr_weak.upgrade() {
+                    let exists = mgr.inner.lock().await.workspaces.contains_key(&id);
+                    if exists {
+                        info!(%id, "workspace TTL expired — auto-stop");
+                        let _ = mgr.stop(id).await;
+                    }
+                }
+            });
+        }
+
+        Ok((id, warnings))
+    }
+
+    pub async fn list(&self) -> Vec<WorkspaceSnapshot> {
+        let g = self.inner.lock().await;
+        g.workspaces
+            .values()
+            .map(|w| WorkspaceSnapshot {
+                id: w.id,
+                label: w.spec.label.clone(),
+                commands: w.commands.len() as u32,
+                uptime_ms: w.started.elapsed().as_millis() as u64,
+            })
+            .collect()
+    }
+
+    pub async fn stop(&self, id: WorkspaceId) -> Result<u32, CoreError> {
+        self.stop_with_grace(id, std::time::Duration::from_millis(1000)).await
+    }
+
+    /// Variante con tiempo de gracia configurable. SIGTERM → espera `grace`
+    /// → SIGKILL si quedan vivos. `grace=0` = SIGKILL inmediato.
+    pub async fn stop_with_grace(
+        &self,
+        id: WorkspaceId,
+        grace: std::time::Duration,
+    ) -> Result<u32, CoreError> {
+        let mut g = self.inner.lock().await;
+        let ws = g.workspaces.remove(&id).ok_or(CoreError::WorkspaceNotFound(id))?;
+        // También limpiamos flow_channels del workspace si los hubiera —
+        // por workspace lo retenemos por pipeline, no por workspace.
+        drop(g);
+        self.mark_dirty();
+
+        // 1) SIGTERM (o SIGKILL si grace=0) a todos vivos.
+        let initial_signal = if grace.is_zero() { Signal::SIGKILL } else { Signal::SIGTERM };
+        let alive_pids: Vec<Pid> = ws
+            .commands
+            .values()
+            .filter(|c| c.alive)
+            .map(|c| c.pid)
+            .collect();
+        for pid in &alive_pids {
+            let _ = kill(*pid, initial_signal);
+        }
+
+        // 2) Esperar hasta `grace` haciendo polling WNOHANG.
+        let mut reaped = 0u32;
+        let mut still_alive: Vec<Pid> = alive_pids.clone();
+        let deadline = std::time::Instant::now() + grace;
+        let poll_interval = std::time::Duration::from_millis(20);
+        while !still_alive.is_empty() && std::time::Instant::now() < deadline {
+            still_alive.retain(|pid| match waitpid(*pid, Some(WaitPidFlag::WNOHANG)) {
+                Ok(WaitStatus::StillAlive) => true,
+                Ok(_) => {
+                    reaped += 1;
+                    false
+                }
+                Err(_) => false,
+            });
+            if !still_alive.is_empty() {
+                tokio::time::sleep(poll_interval).await;
+            }
+        }
+
+        // 3) SIGKILL forzoso a los que quedan, y wait blocking.
+        for pid in &still_alive {
+            let _ = kill(*pid, Signal::SIGKILL);
+            let _ = waitpid(*pid, None);
+            reaped += 1;
+        }
+        info!(
+            %id,
+            reaped,
+            grace_ms = grace.as_millis() as u64,
+            sigkilled = still_alive.len(),
+            "workspace stopped"
+        );
+        Ok(reaped)
+    }
+
+    /// Ejecuta un comando one-shot dentro de un workspace existente.
+    /// Captura stdout+stderr en un ring buffer accesible vía
+    /// [`get_command_logs`](Self::get_command_logs).
+    pub async fn run(
+        &self,
+        id: WorkspaceId,
+        exec: String,
+        argv: Vec<String>,
+        envp: Vec<(String, String)>,
+    ) -> Result<CommandSummary, CoreError> {
+        self.run_with_options(id, exec, argv, envp, false).await
+    }
+
+    /// Variante con `restart_on_failure`: si el comando muere con
+    /// exit_status != 0, el reaper lo relauncha con backoff exponencial
+    /// (200ms → 400 → 800 → … cap 30s).
+    pub async fn run_with_options(
+        &self,
+        id: WorkspaceId,
+        exec: String,
+        argv: Vec<String>,
+        envp: Vec<(String, String)>,
+        restart_on_failure: bool,
+    ) -> Result<CommandSummary, CoreError> {
+        let workspace_label = {
+            let g = self.inner.lock().await;
+            let ws = g.workspaces.get(&id).ok_or(CoreError::WorkspaceNotFound(id))?;
+            ws.spec.label.clone()
+        };
+        let cmd_ref = CommandRef {
+            label: format!("run-{}", short_ulid(&Ulid::new())),
+            payload: Payload::Native { exec, argv, envp },
+            soma: Default::default(),
+            flows: Default::default(),
+            supervision: Supervision::OneShot,
+        };
+        let card = cmd_ref.to_card(0, &workspace_label)?;
+
+        // Dos pipes O_CLOEXEC: uno para stdout, otro para stderr.
+        use std::os::fd::IntoRawFd;
+        let (sout_r, sout_w) =
+            nix::unistd::pipe2(nix::fcntl::OFlag::O_CLOEXEC).map_err(|e| {
+                CoreError::Incarnate(arje_incarnate::IncarnateError::Pipe(e))
+            })?;
+        let (serr_r, serr_w) =
+            nix::unistd::pipe2(nix::fcntl::OFlag::O_CLOEXEC).map_err(|e| {
+                CoreError::Incarnate(arje_incarnate::IncarnateError::Pipe(e))
+            })?;
+        let sout_r_fd = sout_r.into_raw_fd();
+        let sout_w_fd = sout_w.into_raw_fd();
+        let serr_r_fd = serr_r.into_raw_fd();
+        let serr_w_fd = serr_w.into_raw_fd();
+
+        let stdout_buf = logbuf::LogBuf::new();
+        let stderr_buf = logbuf::LogBuf::new();
+
+        let stdio = arje_incarnate::ChildStdio {
+            stdin_fd: None,
+            stdout_fd: Some(sout_w_fd),
+            stderr_fd: Some(serr_w_fd),
+        };
+        let out = self.incarnator.incarnate_with(&card, stdio)?;
+        let cmd_id = card.id;
+        let cmd_label = cmd_ref.label.clone();
+        let pid = out.pid;
+
+        spawn_log_drainer(sout_r_fd, stdout_buf.clone());
+        spawn_log_drainer(serr_r_fd, stderr_buf.clone());
+
+        let mut g = self.inner.lock().await;
+        if let Some(ws) = g.workspaces.get_mut(&id) {
+            ws.commands.insert(
+                cmd_id,
+                CommandState {
+                    id: cmd_id,
+                    label: cmd_label.clone(),
+                    pid,
+                    alive: true,
+                    exit_status: None,
+                    stdout: Some(stdout_buf),
+                    stderr: Some(stderr_buf),
+                    pipeline_id: None,
+                },
+            );
+        }
+        if restart_on_failure {
+            // Reextract exec/argv/envp del payload del CommandRef.
+            if let Payload::Native { exec, argv, envp } = &cmd_ref.payload {
+                g.restart_specs.insert(
+                    cmd_id,
+                    RestartSpec {
+                        workspace: id,
+                        exec: exec.clone(),
+                        argv: argv.clone(),
+                        envp: envp.clone(),
+                        backoff_ms: 200,
+                        max_backoff_ms: 30_000,
+                        restart_count: 0,
+                    },
+                );
+            }
+        }
+        for d in &out.degradations {
+            warn!(?d, %id, "command incarnation degradation");
+        }
+        Ok(CommandSummary {
+            id: cmd_id,
+            label: cmd_label,
+            pid: pid.as_raw(),
+        })
+    }
+
+    /// Devuelve el tail del log capturado para `(workspace, command)`.
+    /// `stream` selecciona stdout/stderr/both.
+    pub async fn get_command_logs(
+        &self,
+        workspace: WorkspaceId,
+        command: Ulid,
+        tail_bytes: usize,
+        stream: LogStream,
+    ) -> Option<Vec<u8>> {
+        let g = self.inner.lock().await;
+        let ws = g.workspaces.get(&workspace)?;
+        let cmd = ws.commands.get(&command)?;
+        match stream {
+            LogStream::Stdout => cmd.stdout.as_ref().map(|lb| lb.tail(tail_bytes)),
+            LogStream::Stderr => cmd.stderr.as_ref().map(|lb| lb.tail(tail_bytes)),
+            LogStream::Both => {
+                let so = cmd.stdout.as_ref().map(|lb| lb.tail(tail_bytes)).unwrap_or_default();
+                let se = cmd.stderr.as_ref().map(|lb| lb.tail(tail_bytes)).unwrap_or_default();
+                let mut out = so;
+                out.extend_from_slice(&se);
+                Some(out)
+            }
+        }
+    }
+
+    /// Lista comandos de un workspace.
+    pub async fn list_commands(&self, workspace: WorkspaceId) -> Vec<CommandInfo> {
+        let g = self.inner.lock().await;
+        let Some(ws) = g.workspaces.get(&workspace) else { return Vec::new() };
+        let mut out: Vec<CommandInfo> = ws
+            .commands
+            .values()
+            .map(|c| CommandInfo {
+                id: c.id,
+                label: c.label.clone(),
+                pid: c.pid.as_raw(),
+                alive: c.alive,
+                exit_status: c.exit_status,
+                log_bytes: c.stdout.as_ref().map(|l| l.written_total()).unwrap_or(0)
+                    + c.stderr.as_ref().map(|l| l.written_total()).unwrap_or(0),
+            })
+            .collect();
+        // Orden estable por ULID (temporal).
+        out.sort_by_key(|c| c.id);
+        out
+    }
+
+}
diff --git a/02_ruway/shuma/sandbox/shuma-exec/Cargo.toml b/02_ruway/shuma/sandbox/shuma-exec/Cargo.toml
new file mode 100644
index 0000000..7e3e0f7
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-exec/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "shuma-exec"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "shuma — ejecutor de comandos del shell con salida en streaming: lanza un proceso y entrega stdout/stderr línea a línea por un canal. Agnóstico de UI."
+
+[dependencies]
+# `zerocopy` → `splice` (volcado pipe→archivo sin copia). `OFlag` (para los
+# pipes de tee por etapa con `O_CLOEXEC` vía `pipe2`) vive bajo la feature
+# `fs`, que el workspace ya habilita — `unistd::pipe2` no necesita feature.
+nix = { workspace = true, features = ["zerocopy"] }
+# PTY allocation cross-platform — usado por `Exec::Pty` para los
+# comandos TUI (vim, htop, less, claude-code, etc.).
+portable-pty = { workspace = true }
diff --git a/02_ruway/shuma/sandbox/shuma-exec/LEEME.md b/02_ruway/shuma/sandbox/shuma-exec/LEEME.md
new file mode 100644
index 0000000..5c99811
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-exec/LEEME.md
@@ -0,0 +1,9 @@
+# shuma-exec
+
+> Ejecutor de comandos de [shuma](../../README.md).
+
+Envuelve `std::process::Command` con job-control, signal handling, env del session. Pipes, redirects, &&/||.
+
+## Deps
+
+- [`shuma-core`](../shuma-core/README.md), `nix`
diff --git a/02_ruway/shuma/sandbox/shuma-exec/README.md b/02_ruway/shuma/sandbox/shuma-exec/README.md
new file mode 100644
index 0000000..447abc1
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-exec/README.md
@@ -0,0 +1,9 @@
+# shuma-exec
+
+> Command executor of [shuma](../../README.md).
+
+Wraps `std::process::Command` with job-control, signal handling, session env. Pipes, redirects, &&/||.
+
+## Deps
+
+- [`shuma-core`](../shuma-core/README.md), `nix`
diff --git a/02_ruway/shuma/sandbox/shuma-exec/src/lib.rs b/02_ruway/shuma/sandbox/shuma-exec/src/lib.rs
new file mode 100644
index 0000000..4e667c3
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-exec/src/lib.rs
@@ -0,0 +1,1269 @@
+//! `shuma-exec` — ejecución de comandos del shell con salida en streaming.
+//!
+//! Dos modos de ejecución, un mismo contrato de eventos:
+//!
+//! - [`Exec::Direct`] — brahman lanza y **conecta los procesos él mismo**:
+//!   un `Command` por etapa del pipe, los pipes cableados con descriptores
+//!   reales. Control total del árbol de procesos (matar todo el pipe de
+//!   un golpe). Es el modo preferido.
+//! - [`Exec::Shell`] — delega a un shell externo (`bash -c "<line>"`).
+//!   Reservado para sintaxis que el modo directo aún no absorbe (globs,
+//!   `$VAR`, redirecciones, `&&`). bash es **sólo un parser de sintaxis**,
+//!   no el ejecutor por defecto.
+//!
+//! **Captura acotada.** [`CommandSpec::capture_limit`] topa los bytes en
+//! RAM; pasado el tope, o se **descarta** ([`RunEvent::Truncated`]) o se
+//! **vuelca a un archivo** si hay [`CommandSpec::spill_path`]
+//! ([`RunEvent::Spilled`]). En ambos casos el pipe se sigue drenando, así
+//! el proceso no se bloquea.
+//!
+//! **Reproceso.** [`CommandSpec::stdin_data`] alimenta un texto por la
+//! entrada estándar: reprocesa la salida capturada de un comando previo
+//! sin volver a correr el original.
+
+#![forbid(unsafe_code)]
+
+use std::fs::File;
+use std::io::{BufRead, BufReader, Read, Write};
+use std::os::fd::AsFd;
+use std::os::unix::process::CommandExt;
+use std::path::{Path, PathBuf};
+use std::process::{Child, Command, Stdio};
+use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
+use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
+use std::sync::{Arc, Mutex};
+use std::thread::JoinHandle;
+
+use nix::fcntl::{splice, SpliceFFlags};
+use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize};
+
+/// Una etapa del pipe en ejecución directa: un binario y sus argumentos
+/// ya resueltos (sin comillas, sin metacaracteres).
+#[derive(Debug, Clone)]
+pub struct StageSpec {
+    pub program: String,
+    pub args: Vec<String>,
+}
+
+/// Cómo ejecutar.
+#[derive(Debug, Clone)]
+pub enum Exec {
+    /// Vía un shell externo — `program -c "<line>"`.
+    Shell { line: String, program: String },
+    /// Directo — brahman lanza y conecta cada etapa.
+    Direct { stages: Vec<StageSpec> },
+    /// Bajo un PTY (cross-platform vía `portable-pty`). Pensado para
+    /// comandos **TUI fullscreen** (vim, htop, less, claude code) que
+    /// detectan `isatty()` y rehúsan funcionar con pipes. Emite
+    /// [`RunEvent::Bytes`] crudos en vez de `Stdout(String)` para que
+    /// el frontend pueda alimentar un emulador vt100 propio.
+    Pty {
+        program: String,
+        args: Vec<String>,
+        cols: u16,
+        rows: u16,
+    },
+}
+
+/// Qué ejecutar y con qué política de captura.
+#[derive(Debug, Clone)]
+pub struct CommandSpec {
+    pub exec: Exec,
+    pub cwd: String,
+    /// Tope de captura en bytes; `0` = sin límite.
+    pub capture_limit: usize,
+    /// Si está, la salida que excede el tope se vuelca a este archivo.
+    pub spill_path: Option<PathBuf>,
+    /// Texto a alimentar por stdin — para reprocesar una salida previa.
+    pub stdin_data: Option<String>,
+    /// Si `true`, en un pipe `Direct` se intercepta el stdout de **cada
+    /// etapa intermedia** (tee): además de alimentar a la siguiente, cada
+    /// línea se emite como [`RunEvent::StageStdout`]. Permite ver el stream
+    /// de cada etapa **en vivo**, sin re-ejecutar. Default `false` (sólo se
+    /// captura la salida de la última etapa, como siempre).
+    pub capture_stages: bool,
+}
+
+impl CommandSpec {
+    /// Ejecución vía `bash -c "<line>"`.
+    pub fn shell(line: impl Into<String>, cwd: impl Into<String>) -> Self {
+        Self {
+            exec: Exec::Shell { line: line.into(), program: "bash".into() },
+            cwd: cwd.into(),
+            capture_limit: 0,
+            spill_path: None,
+            stdin_data: None,
+            capture_stages: false,
+        }
+    }
+
+    /// Ejecución directa de un pipe de etapas.
+    pub fn direct(stages: Vec<StageSpec>, cwd: impl Into<String>) -> Self {
+        Self {
+            exec: Exec::Direct { stages },
+            cwd: cwd.into(),
+            capture_limit: 0,
+            spill_path: None,
+            stdin_data: None,
+            capture_stages: false,
+        }
+    }
+
+    /// Activa la captura por etapa (tee) en pipes directos (encadenable).
+    pub fn with_stage_capture(mut self) -> Self {
+        self.capture_stages = true;
+        self
+    }
+
+    /// Fija el tope de captura en bytes (encadenable).
+    pub fn with_limit(mut self, bytes: usize) -> Self {
+        self.capture_limit = bytes;
+        self
+    }
+
+    /// Vuelca la salida excedente a `path` en vez de descartarla.
+    pub fn with_spill(mut self, path: PathBuf) -> Self {
+        self.spill_path = Some(path);
+        self
+    }
+
+    /// Alimenta `data` por la entrada estándar del proceso (encadenable).
+    pub fn with_stdin(mut self, data: impl Into<String>) -> Self {
+        self.stdin_data = Some(data.into());
+        self
+    }
+}
+
+/// Un evento de la ejecución de un comando.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum RunEvent {
+    /// Una línea de salida estándar (de la última etapa del pipe).
+    Stdout(String),
+    /// Una línea de stdout de una etapa **intermedia** del pipe (tee). Sólo
+    /// se emite con `CommandSpec::capture_stages`. `stage` = índice 0-based
+    /// de la etapa que la produjo. El front la muestra en el desplegable de
+    /// esa etapa, sin re-ejecutar nada.
+    StageStdout { stage: usize, line: String },
+    /// Una línea de salida de error.
+    Stderr(String),
+    /// Un chunk de bytes crudos del PTY (sólo bajo [`Exec::Pty`]). El
+    /// frontend debe alimentarlo a un emulador vt100 para renderizarlo;
+    /// puede traer secuencias de cursor movement, erase, OSC, etc.
+    Bytes(Vec<u8>),
+    /// La captura alcanzó su tope; lo que sigue se descarta.
+    Truncated,
+    /// La captura alcanzó su tope; el resto se vuelca al archivo dado.
+    Spilled(String),
+    /// El proceso terminó con este código de salida.
+    Exited(i32),
+    /// El proceso no pudo siquiera lanzarse.
+    Failed(String),
+}
+
+impl RunEvent {
+    /// `true` si el evento cierra la ejecución (`Exited` o `Failed`).
+    pub fn is_terminal(&self) -> bool {
+        matches!(self, RunEvent::Exited(_) | RunEvent::Failed(_))
+    }
+}
+
+/// Asa de un comando en ejecución. El consumidor la conserva y drena sus
+/// eventos cuando le conviene.
+pub struct RunHandle {
+    rx: Receiver<RunEvent>,
+    finished: bool,
+    /// Los procesos, compartidos con el hilo coordinador para poder
+    /// matarlos — todas las etapas de un pipe directo. Vacío para
+    /// runs PTY.
+    children: Arc<Mutex<Vec<Child>>>,
+    /// PIDs vistos de runs PTY (los gestiona `portable-pty`; no son
+    /// `std::process::Child`). Se usan para enviarles señales con
+    /// `nix::sys::signal::kill`.
+    pty_pids: Arc<Mutex<Vec<u32>>>,
+    /// Canal opcional para escribir bytes en el stdin del proceso.
+    /// Sólo está cableado en modo PTY; en los otros modos los sends
+    /// no llegan a nadie (el receptor se cae al instante).
+    stdin_tx: Sender<Vec<u8>>,
+    /// PTY master vivo — sólo en modo `Exec::Pty`. Permite resize en
+    /// caliente cuando el panel cambia de tamaño. El coordinador del
+    /// PTY lo rellena tras crear el `pair` y lo conserva hasta el exit
+    /// del child (drop al final).
+    pty_master: Arc<Mutex<Option<Box<dyn MasterPty + Send>>>>,
+}
+
+/// Asa "fría" de un `RunHandle` que **sólo** sirve para matar el comando.
+/// No comparte el lock principal de eventos, así que se puede usar desde
+/// otro hilo/task aún cuando el dueño del `RunHandle` esté bloqueado en
+/// `next_event()`. Cloneable.
+#[derive(Clone)]
+pub struct Killer {
+    children: Arc<Mutex<Vec<Child>>>,
+    pty_pids: Arc<Mutex<Vec<u32>>>,
+}
+
+impl Killer {
+    /// Manda SIGKILL a todas las etapas vivas del comando. No hace nada
+    /// si ya terminaron. La señal va a todo el **grupo de procesos** de
+    /// cada etapa — con `bash -c "sleep 30"`, esto mata bash *y* el
+    /// sleep hijo (mismo pgid; `spawn_shell` arma cada child con
+    /// `process_group(0)`).
+    pub fn kill(&self) {
+        self.signal(nix::sys::signal::Signal::SIGKILL);
+        // Fallback: además de la señal, llamamos a `kill()` del Child
+        // para que `wait()` cosechée el exit status sin colgarse (el
+        // `kill` de std manda SIGKILL al PID directo y no falla si el
+        // proceso ya murió).
+        if let Ok(mut guard) = self.children.lock() {
+            for c in guard.iter_mut() {
+                let _ = c.kill();
+            }
+        }
+    }
+
+    /// PIDs de las etapas que aún consider vivas el coordinador. Puede
+    /// estar vacío durante una micro-ventana entre `run()` y el spawn
+    /// real — no es un bug, sólo refleja la realidad del scheduling.
+    pub fn pids(&self) -> Vec<u32> {
+        let mut out = Vec::new();
+        if let Ok(g) = self.children.lock() {
+            out.extend(g.iter().map(|c| c.id()));
+        }
+        if let Ok(g) = self.pty_pids.lock() {
+            out.extend(g.iter().copied());
+        }
+        out
+    }
+
+    /// SIGTERM — el "kill educado" (Ctrl-C estándar). El proceso suele
+    /// limpiar antes de morir. Devuelve `true` si llegó a al menos una
+    /// etapa viva.
+    pub fn term(&self) -> bool {
+        self.signal(nix::sys::signal::Signal::SIGTERM)
+    }
+
+    /// SIGSTOP — el proceso pasa a estado "stopped"; no consume CPU y
+    /// no produce salida hasta recibir SIGCONT. Útil para "pausar" un
+    /// `tail -f` o un build ruidoso sin perderlo.
+    pub fn stop(&self) -> bool {
+        self.signal(nix::sys::signal::Signal::SIGSTOP)
+    }
+
+    /// SIGCONT — reanuda un proceso parado con [`Killer::stop`].
+    pub fn cont(&self) -> bool {
+        self.signal(nix::sys::signal::Signal::SIGCONT)
+    }
+
+    fn signal(&self, sig: nix::sys::signal::Signal) -> bool {
+        let pids = self.pids();
+        let mut delivered = false;
+        for pid in pids {
+            let target = nix::unistd::Pid::from_raw(pid as i32);
+            // `killpg` busca el grupo cuyo pgid coincide con `pid`:
+            // como cada child se lanzó con `process_group(0)`, el child
+            // es líder del grupo y `pgid == pid`. Matar el grupo abarca
+            // cualquier proceso que el child hubiese forkado.
+            if nix::sys::signal::killpg(target, sig).is_ok() {
+                delivered = true;
+            } else if nix::sys::signal::kill(target, sig).is_ok() {
+                // Fallback: si el child no es líder de grupo (PTY usa
+                // `portable-pty`, que no garantiza `process_group(0)`),
+                // mandamos al PID directo.
+                delivered = true;
+            }
+        }
+        delivered
+    }
+}
+
+impl RunHandle {
+    /// Mata todos los procesos del comando. No hace nada si ya terminaron.
+    pub fn kill(&self) {
+        if let Ok(mut guard) = self.children.lock() {
+            for c in guard.iter_mut() {
+                let _ = c.kill();
+            }
+        }
+    }
+
+    /// Asa cloneable que sólo permite matar el comando — útil para usar
+    /// `kill()` desde otra tarea sin tocar el lock que tiene el reader.
+    pub fn killer(&self) -> Killer {
+        Killer {
+            children: Arc::clone(&self.children),
+            pty_pids: Arc::clone(&self.pty_pids),
+        }
+    }
+
+    /// Escribe bytes en el stdin del proceso. Sólo tiene efecto bajo
+    /// [`Exec::Pty`] — el modo TUI cablea un writer thread que recibe
+    /// estos bytes y los reenvía al PTY master. En los otros modos, el
+    /// send se descarta (no hay listener) y devuelve `false`.
+    pub fn write_input(&self, bytes: Vec<u8>) -> bool {
+        self.stdin_tx.send(bytes).is_ok()
+    }
+
+    /// Reescala el PTY. Sólo aplica bajo [`Exec::Pty`]: lockea el
+    /// master vivo y llama a `MasterPty::resize`. Devuelve `false`
+    /// silenciosamente si no hay PTY (modos Shell/Direct), el master
+    /// no se publicó todavía, o el SO devuelve error.
+    pub fn resize(&self, rows: u16, cols: u16) -> bool {
+        let Ok(mut guard) = self.pty_master.lock() else {
+            return false;
+        };
+        let Some(master) = guard.as_mut() else {
+            return false;
+        };
+        master
+            .resize(PtySize {
+                rows,
+                cols,
+                pixel_width: 0,
+                pixel_height: 0,
+            })
+            .is_ok()
+    }
+
+    /// Próximo evento, bloqueando hasta que llegue. `None` cuando el
+    /// proceso terminó (ya se emitió `Exited`/`Failed`) o el canal se
+    /// cerró. Pensado para puentes sync→async (el daemon lo usa para
+    /// re-emitir cada evento como un frame del protocolo).
+    pub fn next_event(&mut self) -> Option<RunEvent> {
+        if self.finished {
+            return None;
+        }
+        match self.rx.recv() {
+            Ok(ev) => {
+                if ev.is_terminal() {
+                    self.finished = true;
+                }
+                Some(ev)
+            }
+            Err(_) => {
+                self.finished = true;
+                None
+            }
+        }
+    }
+
+    /// Drena todos los eventos disponibles ahora mismo, sin bloquear.
+    pub fn try_events(&mut self) -> Vec<RunEvent> {
+        let mut out = Vec::new();
+        loop {
+            match self.rx.try_recv() {
+                Ok(ev) => {
+                    if ev.is_terminal() {
+                        self.finished = true;
+                    }
+                    out.push(ev);
+                }
+                Err(TryRecvError::Empty) => break,
+                Err(TryRecvError::Disconnected) => {
+                    self.finished = true;
+                    break;
+                }
+            }
+        }
+        out
+    }
+
+    /// Bloquea hasta que el proceso termine y devuelve todos sus eventos.
+    pub fn wait_all(&mut self) -> Vec<RunEvent> {
+        let mut out = Vec::new();
+        while let Ok(ev) = self.rx.recv() {
+            let terminal = ev.is_terminal();
+            out.push(ev);
+            if terminal {
+                self.finished = true;
+            }
+        }
+        self.finished = true;
+        out
+    }
+
+    /// `true` si ya se observó el evento terminal.
+    pub fn is_finished(&self) -> bool {
+        self.finished
+    }
+
+    /// Asa "fría" para controlar el PTY (stdin + resize) desde otra
+    /// tarea/hilo sin tocar el lock de eventos — espejo de [`Killer`]
+    /// para el lado de entrada. La usa el daemon: el `RunHandle` se mueve
+    /// a un hilo-puente que bloquea en `next_event()`, mientras la tarea
+    /// async conserva este `PtyControl` para reenviar las teclas y los
+    /// resize que llegan del cliente remoto.
+    pub fn pty_control(&self) -> PtyControl {
+        PtyControl {
+            stdin_tx: self.stdin_tx.clone(),
+            pty_master: Arc::clone(&self.pty_master),
+        }
+    }
+}
+
+/// Asa cloneable de **control de entrada** de un run PTY: escribe stdin y
+/// reescala, sin compartir el lock de eventos del [`RunHandle`]. Igual que
+/// con [`RunHandle::write_input`]/[`RunHandle::resize`], las operaciones
+/// son no-op fuera de modo [`Exec::Pty`].
+#[derive(Clone)]
+pub struct PtyControl {
+    stdin_tx: Sender<Vec<u8>>,
+    pty_master: Arc<Mutex<Option<Box<dyn MasterPty + Send>>>>,
+}
+
+impl PtyControl {
+    /// Escribe bytes en el stdin del PTY. Ver [`RunHandle::write_input`].
+    pub fn write_input(&self, bytes: Vec<u8>) -> bool {
+        self.stdin_tx.send(bytes).is_ok()
+    }
+
+    /// Reescala el PTY. Ver [`RunHandle::resize`].
+    pub fn resize(&self, rows: u16, cols: u16) -> bool {
+        let Ok(mut guard) = self.pty_master.lock() else {
+            return false;
+        };
+        let Some(master) = guard.as_mut() else {
+            return false;
+        };
+        master
+            .resize(PtySize {
+                rows,
+                cols,
+                pixel_width: 0,
+                pixel_height: 0,
+            })
+            .is_ok()
+    }
+}
+
+/// Vuelca el resto de un pipe a un archivo con **copia cero** (`splice`):
+/// los bytes van de pipe a archivo sin pasar por espacio de usuario.
+fn spill_rest<R: Read + AsFd>(reader: &mut BufReader<R>, path: &Path, first_line: &str) {
+    let Ok(file) = File::create(path) else {
+        return;
+    };
+    let mut file = file;
+    // La línea que cruzó el tope y lo ya bufereado van primero…
+    let _ = file.write_all(first_line.as_bytes());
+    let buffered: Vec<u8> = reader.buffer().to_vec();
+    let _ = file.write_all(&buffered);
+    reader.consume(buffered.len());
+    // …y el resto del pipe se mueve con `splice`, kernel a kernel.
+    loop {
+        match splice(reader.get_ref(), None, &file, None, 1 << 20, SpliceFFlags::empty()) {
+            Ok(0) | Err(_) => break,
+            Ok(_) => {}
+        }
+    }
+}
+
+/// Lanza un hilo lector de un flujo, con captura acotada. Pasado el tope:
+/// si hay `spill`, el resto se vuelca al archivo con `splice` (copia
+/// cero); si no, se descarta. En ambos casos el pipe se **sigue
+/// drenando** — el proceso nunca se bloquea.
+#[allow(clippy::too_many_arguments)]
+fn spawn_reader<R: Read + AsFd + Send + 'static>(
+    stream: R,
+    tx: Sender<RunEvent>,
+    make: fn(String) -> RunEvent,
+    limit: usize,
+    counter: Arc<AtomicUsize>,
+    announced: Arc<AtomicBool>,
+    spill: Option<PathBuf>,
+) -> JoinHandle<()> {
+    std::thread::spawn(move || {
+        let mut reader = BufReader::new(stream);
+        let mut buf = String::new();
+        loop {
+            buf.clear();
+            let n = match reader.read_line(&mut buf) {
+                Ok(0) => break, // EOF
+                Ok(n) => n,
+                Err(_) => break,
+            };
+            let total = counter.fetch_add(n, Ordering::Relaxed) + n;
+            if limit != 0 && total > limit {
+                let first = !announced.swap(true, Ordering::Relaxed);
+                match &spill {
+                    Some(path) => {
+                        if first {
+                            let _ = tx.send(RunEvent::Spilled(path.display().to_string()));
+                        }
+                        spill_rest(&mut reader, path, &buf);
+                        break; // splice se llevó el resto
+                    }
+                    None => {
+                        if first {
+                            let _ = tx.send(RunEvent::Truncated);
+                        }
+                        continue; // descarta, pero sigue drenando
+                    }
+                }
+            }
+            let line = buf.trim_end_matches(['\n', '\r']).to_string();
+            if tx.send(make(line)).is_err() {
+                break;
+            }
+        }
+    })
+}
+
+/// Resultado de lanzar los procesos: lo que el coordinador necesita.
+struct Spawned {
+    children: Vec<Child>,
+    stdin: Option<std::process::ChildStdin>,
+    stdout: Option<std::process::ChildStdout>,
+    stderrs: Vec<std::process::ChildStderr>,
+    /// Etapas intermedias a interceptar (solo con `capture_stages`). Vacío
+    /// en el caso normal.
+    stage_tees: Vec<StageTee>,
+}
+
+/// Una etapa intermedia cuyo stdout interceptamos: el coordinador lee
+/// `stdout`, reenvía los bytes a `sink` (el stdin de la etapa siguiente) y
+/// emite cada línea como [`RunEvent::StageStdout`].
+struct StageTee {
+    stage: usize,
+    stdout: std::process::ChildStdout,
+    sink: std::fs::File,
+}
+
+/// Lanza un único proceso shell (`program -c "<line>"`).
+fn spawn_shell(line: &str, program: &str, cwd: &str, want_stdin: bool) -> std::io::Result<Spawned> {
+    let mut child = Command::new(program)
+        .arg("-c")
+        .arg(line)
+        .current_dir(cwd)
+        .stdin(if want_stdin { Stdio::piped() } else { Stdio::null() })
+        .stdout(Stdio::piped())
+        .stderr(Stdio::piped())
+        // Nuevo grupo de procesos: con `bash -c "sleep 30"` el bash se
+        // forka a un sleep hijo; matar al bash sólo no alcanza al sleep.
+        // Con el grupo, `killpg(pid, SIG)` derriba a todo el subárbol.
+        .process_group(0)
+        .spawn()?;
+    let stdin = child.stdin.take();
+    let stdout = child.stdout.take();
+    let stderrs = child.stderr.take().into_iter().collect();
+    Ok(Spawned { children: vec![child], stdin, stdout, stderrs, stage_tees: vec![] })
+}
+
+/// Lanza un pipe de etapas conectándolas con descriptores reales.
+fn spawn_direct(
+    stages: &[StageSpec],
+    cwd: &str,
+    want_stdin: bool,
+    capture_stages: bool,
+) -> std::io::Result<Spawned> {
+    if stages.is_empty() {
+        return Err(std::io::Error::new(
+            std::io::ErrorKind::InvalidInput,
+            "pipe vacío",
+        ));
+    }
+    let n = stages.len();
+    let mut children: Vec<Child> = Vec::with_capacity(n);
+    let mut stage_tees: Vec<StageTee> = Vec::new();
+    // Qué alimenta el stdin de la etapa actual (i>0): el stdout de la
+    // anterior (directo) o el read-end de un pipe de tee (capturado).
+    let mut next_stdin: Option<Stdio> = None;
+
+    for (i, st) in stages.iter().enumerate() {
+        let mut cmd = Command::new(&st.program);
+        cmd.args(&st.args)
+            .current_dir(cwd)
+            .stdout(Stdio::piped())
+            .stderr(Stdio::piped());
+        if i == 0 {
+            cmd.stdin(if want_stdin { Stdio::piped() } else { Stdio::null() });
+            // Primera etapa abre su propio grupo de procesos; las demás
+            // se enganchan en el mismo (process_group(0) hereda el pgid
+            // del padre — pero el padre somos nosotros, no la etapa 0).
+            // En la práctica `setpgid` del kernel respeta sólo lo que
+            // pedimos: las etapas 1.. quedan en pgid de la 0 si las
+            // forkamos con CLONE_PARENT_SETTID; con Command no tenemos
+            // ese control fino, así que cada stage queda en su propio
+            // pgid. El Killer cubre todas las etapas igual porque
+            // mantiene los PIDs/Childs por separado.
+            cmd.process_group(0);
+        } else {
+            // La etapa anterior alimenta a ésta (stdout directo o tee).
+            cmd.stdin(next_stdin.take().expect("stdin de etapa previa"));
+            cmd.process_group(0);
+        }
+        match cmd.spawn() {
+            Ok(mut child) => {
+                if i + 1 < n {
+                    let stdout = child.stdout.take().expect("stdout de etapa intermedia");
+                    if capture_stages {
+                        // Tee: pipe propio. La etapa siguiente lee del read-end;
+                        // un hilo del coordinador reenvía stage[i].stdout al
+                        // write-end y captura cada línea como StageStdout.
+                        // `O_CLOEXEC`: estos fds NO deben heredarse a las etapas
+                        // que spawneamos después. Si una etapa heredara el
+                        // write-end del pipe de tee, su lado lector nunca vería
+                        // EOF y se colgaría esperando más entrada (deadlock). El
+                        // dup2 a fd 0 de la etapa siguiente lo hace std (limpia
+                        // CLOEXEC en el fd 0 resultante), así que su stdin queda bien.
+                        let (rd, wr) = nix::unistd::pipe2(nix::fcntl::OFlag::O_CLOEXEC)
+                            .map_err(|e| std::io::Error::other(format!("pipe tee: {e}")))?;
+                        next_stdin = Some(Stdio::from(std::fs::File::from(rd)));
+                        stage_tees.push(StageTee {
+                            stage: i,
+                            stdout,
+                            sink: std::fs::File::from(wr),
+                        });
+                    } else {
+                        next_stdin = Some(Stdio::from(stdout));
+                    }
+                }
+                children.push(child);
+            }
+            Err(e) => {
+                // Si una etapa no arranca, se matan las ya lanzadas.
+                for mut c in children {
+                    let _ = c.kill();
+                }
+                return Err(std::io::Error::new(
+                    e.kind(),
+                    format!("{}: {e}", st.program),
+                ));
+            }
+        }
+    }
+
+    let stdin = children.first_mut().and_then(|c| c.stdin.take());
+    let stdout = children.last_mut().and_then(|c| c.stdout.take());
+    let stderrs = children.iter_mut().filter_map(|c| c.stderr.take()).collect();
+    Ok(Spawned { children, stdin, stdout, stderrs, stage_tees })
+}
+
+/// Hilo de tee de una etapa intermedia: lee su stdout, lo reenvía a `sink`
+/// (stdin de la etapa siguiente) y emite cada línea como `StageStdout`. Al
+/// EOF cierra `sink` (drop) para que la etapa siguiente vea fin de entrada.
+fn tee_pump(
+    stage: usize,
+    mut stdout: std::process::ChildStdout,
+    mut sink: std::fs::File,
+    tx: Sender<RunEvent>,
+) {
+    let mut buf = [0u8; 8192];
+    let mut line: Vec<u8> = Vec::new();
+    loop {
+        let n = match stdout.read(&mut buf) {
+            Ok(0) | Err(_) => break,
+            Ok(n) => n,
+        };
+        let chunk = &buf[..n];
+        // Reenviar a la etapa siguiente (si murió, igual seguimos drenando
+        // para no bloquear a la etapa actual).
+        let _ = sink.write_all(chunk);
+        // Capturar por línea para el desplegable de la etapa.
+        for &b in chunk {
+            if b == b'\n' {
+                let s = String::from_utf8_lossy(&line).into_owned();
+                let _ = tx.send(RunEvent::StageStdout { stage, line: s });
+                line.clear();
+            } else {
+                line.push(b);
+            }
+        }
+    }
+    if !line.is_empty() {
+        let s = String::from_utf8_lossy(&line).into_owned();
+        let _ = tx.send(RunEvent::StageStdout { stage, line: s });
+    }
+}
+
+/// Lanza `spec` y devuelve un [`RunHandle`] desde el que drenar la
+/// salida. La función vuelve de inmediato: el proceso corre en hilos.
+pub fn run(spec: &CommandSpec) -> RunHandle {
+    let (tx, rx) = mpsc::channel();
+    let (stdin_tx, stdin_rx) = mpsc::channel::<Vec<u8>>();
+    let spec = spec.clone();
+    let cell: Arc<Mutex<Vec<Child>>> = Arc::new(Mutex::new(Vec::new()));
+    let pty_pids: Arc<Mutex<Vec<u32>>> = Arc::new(Mutex::new(Vec::new()));
+    let pty_master: Arc<Mutex<Option<Box<dyn MasterPty + Send>>>> =
+        Arc::new(Mutex::new(None));
+    let cell_thread = Arc::clone(&cell);
+    let pty_pids_thread = Arc::clone(&pty_pids);
+    let pty_master_thread = Arc::clone(&pty_master);
+
+    // Modo PTY: ruta separada — el proceso corre bajo un pseudo-terminal
+    // (cross-platform via `portable-pty`), los bytes crudos se emiten
+    // como [`RunEvent::Bytes`] para que el frontend los pase por su
+    // emulador vt100, y el frontend escribe en stdin con
+    // [`RunHandle::write_input`].
+    if let Exec::Pty { program, args, cols, rows } = &spec.exec {
+        let program = program.clone();
+        let args = args.clone();
+        let cols = *cols;
+        let rows = *rows;
+        let cwd = spec.cwd.clone();
+        std::thread::spawn(move || {
+            spawn_pty_thread(
+                &program,
+                &args,
+                &cwd,
+                cols,
+                rows,
+                tx,
+                stdin_rx,
+                pty_pids_thread,
+                pty_master_thread,
+            );
+        });
+        return RunHandle {
+            rx,
+            finished: false,
+            children: cell,
+            pty_pids,
+            stdin_tx,
+            pty_master,
+        };
+    }
+
+    std::thread::spawn(move || {
+        let want_stdin = spec.stdin_data.is_some();
+        let spawned = match &spec.exec {
+            Exec::Shell { line, program } => {
+                spawn_shell(line, program, &spec.cwd, want_stdin)
+            }
+            Exec::Direct { stages } => {
+                spawn_direct(stages, &spec.cwd, want_stdin, spec.capture_stages)
+            }
+            Exec::Pty { .. } => unreachable!("Pty se maneja antes"),
+        };
+        let Spawned { children, stdin, stdout, stderrs, stage_tees } = match spawned {
+            Ok(s) => s,
+            Err(e) => {
+                let _ = tx.send(RunEvent::Failed(e.to_string()));
+                return;
+            }
+        };
+
+        // Alimenta stdin (reproceso) en su propio hilo.
+        if let (Some(data), Some(mut sink)) = (spec.stdin_data.clone(), stdin) {
+            std::thread::spawn(move || {
+                let _ = sink.write_all(data.as_bytes());
+            });
+        }
+
+        // Comparte los procesos para que `kill` los alcance.
+        if let Ok(mut g) = cell_thread.lock() {
+            *g = children;
+        }
+
+        // Tee de etapas intermedias (solo con capture_stages): un hilo por
+        // etapa que reenvía su stdout a la siguiente y emite StageStdout.
+        // Guardamos los handles para joinearlos antes de `Exited` (si no, un
+        // StageStdout tardío se perdería tras cerrar el run).
+        let mut tee_handles: Vec<JoinHandle<()>> = Vec::new();
+        for tee in stage_tees {
+            let txc = tx.clone();
+            tee_handles
+                .push(std::thread::spawn(move || tee_pump(tee.stage, tee.stdout, tee.sink, txc)));
+        }
+
+        // Captura acotada: contador y aviso compartidos por todos los
+        // lectores. El volcado a archivo se aplica sólo a stdout (el
+        // contenido principal); stderr excedente se descarta.
+        let counter = Arc::new(AtomicUsize::new(0));
+        let announced = Arc::new(AtomicBool::new(false));
+        let limit = spec.capture_limit;
+
+        let mut readers: Vec<JoinHandle<()>> = Vec::new();
+        if let Some(s) = stdout {
+            readers.push(spawn_reader(
+                s,
+                tx.clone(),
+                RunEvent::Stdout,
+                limit,
+                Arc::clone(&counter),
+                Arc::clone(&announced),
+                spec.spill_path.clone(),
+            ));
+        }
+        for s in stderrs {
+            readers.push(spawn_reader(
+                s,
+                tx.clone(),
+                RunEvent::Stderr,
+                limit,
+                Arc::clone(&counter),
+                Arc::clone(&announced),
+                None,
+            ));
+        }
+        for h in readers {
+            let _ = h.join();
+        }
+        // Joinear los tees garantiza que todos los StageStdout salgan antes
+        // del Exited (drenaje completo de las etapas intermedias).
+        for h in tee_handles {
+            let _ = h.join();
+        }
+
+        // Cosecha todas las etapas; el código de salida es el de la última.
+        let code = {
+            let mut g = cell_thread.lock().expect("children lock");
+            let mut last = -1;
+            for c in g.iter_mut() {
+                last = c.wait().ok().and_then(|s| s.code()).unwrap_or(-1);
+            }
+            last
+        };
+        let _ = tx.send(RunEvent::Exited(code));
+    });
+
+    RunHandle {
+        rx,
+        finished: false,
+        children: cell,
+        pty_pids,
+        stdin_tx,
+        pty_master,
+    }
+}
+
+/// Coordinador del modo PTY: aloja un PTY de tamaño `cols`×`rows`,
+/// lanza el comando bajo él, y mantiene tres flujos:
+///
+/// - Reader thread: lee chunks de hasta 4 KiB del master del PTY y los
+///   emite como `RunEvent::Bytes`. Termina cuando el child cierra el
+///   slave (EOF).
+/// - Writer thread: bloquea en `stdin_rx` y reenvía bytes al master
+///   del PTY (lo que el frontend manda como input crudo).
+/// - Esta función espera al child y emite `RunEvent::Exited(code)`.
+fn spawn_pty_thread(
+    program: &str,
+    args: &[String],
+    cwd: &str,
+    cols: u16,
+    rows: u16,
+    tx: Sender<RunEvent>,
+    stdin_rx: Receiver<Vec<u8>>,
+    pty_pids: Arc<Mutex<Vec<u32>>>,
+    pty_master_slot: Arc<Mutex<Option<Box<dyn MasterPty + Send>>>>,
+) {
+    let pty_system = native_pty_system();
+    let pair = match pty_system.openpty(PtySize {
+        rows,
+        cols,
+        pixel_width: 0,
+        pixel_height: 0,
+    }) {
+        Ok(p) => p,
+        Err(e) => {
+            let _ = tx.send(RunEvent::Failed(format!("openpty: {e}")));
+            return;
+        }
+    };
+    let mut cmd = CommandBuilder::new(program);
+    for a in args {
+        cmd.arg(a);
+    }
+    cmd.cwd(cwd);
+    // Heurística estándar: TUIs leen `TERM` para decidir capacidad de
+    // colores y movimiento. xterm-256color es el lcm más amplio.
+    cmd.env("TERM", "xterm-256color");
+    let mut child = match pair.slave.spawn_command(cmd) {
+        Ok(c) => c,
+        Err(e) => {
+            let _ = tx.send(RunEvent::Failed(format!("spawn: {e}")));
+            return;
+        }
+    };
+    // El slave fd no se necesita más en el padre; cerrarlo aquí evita
+    // que el child quede sin EOF cuando cierra su lado.
+    drop(pair.slave);
+
+    if let Some(pid) = child.process_id() {
+        if let Ok(mut g) = pty_pids.lock() {
+            g.push(pid);
+        }
+    }
+
+    let mut reader = match pair.master.try_clone_reader() {
+        Ok(r) => r,
+        Err(e) => {
+            let _ = tx.send(RunEvent::Failed(format!("try_clone_reader: {e}")));
+            return;
+        }
+    };
+    let mut writer = match pair.master.take_writer() {
+        Ok(w) => w,
+        Err(e) => {
+            let _ = tx.send(RunEvent::Failed(format!("take_writer: {e}")));
+            return;
+        }
+    };
+
+    // Publica el master vivo: el `RunHandle` lo lockea para resize en
+    // caliente. `take_writer`/`try_clone_reader` ya retornaron handles
+    // independientes; el master se mantiene como dueño del PTY.
+    if let Ok(mut slot) = pty_master_slot.lock() {
+        *slot = Some(pair.master);
+    }
+
+    let tx_reader = tx.clone();
+    let reader_thread = std::thread::spawn(move || {
+        let mut buf = [0u8; 4096];
+        loop {
+            match reader.read(&mut buf) {
+                Ok(0) => break, // child cerró el slave
+                Ok(n) => {
+                    if tx_reader.send(RunEvent::Bytes(buf[..n].to_vec())).is_err() {
+                        break;
+                    }
+                }
+                Err(_) => break,
+            }
+        }
+    });
+
+    let writer_thread = std::thread::spawn(move || {
+        while let Ok(bytes) = stdin_rx.recv() {
+            if writer.write_all(&bytes).is_err() {
+                break;
+            }
+            let _ = writer.flush();
+        }
+    });
+
+    let code = match child.wait() {
+        Ok(s) => s.exit_code() as i32,
+        Err(_) => -1,
+    };
+    // El master se dropea ahora → reader ve EOF y termina. Esperamos al
+    // reader para que no se pierdan bytes finales.
+    if let Ok(mut slot) = pty_master_slot.lock() {
+        *slot = None;
+    }
+    let _ = reader_thread.join();
+    // El writer thread sale solo cuando el stdin_tx se dropee del lado
+    // del frontend; no lo joineamos sincrónicamente.
+    drop(writer_thread);
+    let _ = tx.send(RunEvent::Exited(code));
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// Ejecución directa de un único programa.
+    fn direct(program: &str, args: &[&str]) -> CommandSpec {
+        CommandSpec::direct(
+            vec![StageSpec {
+                program: program.into(),
+                args: args.iter().map(|s| s.to_string()).collect(),
+            }],
+            ".",
+        )
+    }
+
+    /// Pipe directo de varias etapas.
+    fn pipe(stages: &[(&str, &[&str])]) -> CommandSpec {
+        CommandSpec::direct(
+            stages
+                .iter()
+                .map(|(p, a)| StageSpec {
+                    program: p.to_string(),
+                    args: a.iter().map(|s| s.to_string()).collect(),
+                })
+                .collect(),
+            ".",
+        )
+    }
+
+    fn stdout_of(events: Vec<RunEvent>) -> Vec<String> {
+        events
+            .into_iter()
+            .filter_map(|e| match e {
+                RunEvent::Stdout(l) => Some(l),
+                _ => None,
+            })
+            .collect()
+    }
+
+    #[test]
+    fn direct_runs_a_single_program() {
+        let mut h = run(&direct("echo", &["hola", "mundo"]));
+        let events = h.wait_all();
+        assert!(events.contains(&RunEvent::Stdout("hola mundo".into())));
+        assert!(events.contains(&RunEvent::Exited(0)));
+    }
+
+    #[test]
+    fn direct_wires_a_pipeline_itself() {
+        // printf … | sort — brahman conecta los procesos, sin shell.
+        let mut h = run(&pipe(&[
+            ("printf", &["b\\na\\nc\\n"]),
+            ("sort", &[]),
+        ]));
+        assert_eq!(stdout_of(h.wait_all()), vec!["a", "b", "c"]);
+    }
+
+    #[test]
+    fn direct_three_stage_pipeline() {
+        let mut h = run(&pipe(&[
+            ("printf", &["3\\n1\\n2\\n1\\n"]),
+            ("sort", &[]),
+            ("uniq", &[]),
+        ]));
+        assert_eq!(stdout_of(h.wait_all()), vec!["1", "2", "3"]);
+    }
+
+    fn stage_lines(events: &[RunEvent], stage: usize) -> Vec<String> {
+        events
+            .iter()
+            .filter_map(|e| match e {
+                RunEvent::StageStdout { stage: s, line } if *s == stage => Some(line.clone()),
+                _ => None,
+            })
+            .collect()
+    }
+
+    // El supuesto "deadlock por write-end huérfano" era en realidad el error de
+    // build: se pidió la feature `fcntl` de nix (inexistente en 0.29 — `OFlag`
+    // vive bajo `fs`, ya habilitada), así que el crate ni compilaba y el fallo
+    // se confundió con un cuelgue. Con pipe2(O_CLOEXEC) ningún hijo hereda el
+    // write-end del tee y la etapa siguiente ve EOF en cuanto la anterior cierra
+    // su stdout; verificado estable (5/5).
+    #[test]
+    fn capture_stages_intercepts_each_stage_stdout() {
+        // printf "hello" | tr a-z A-Z | rev  →  etapa0="hello", etapa1="HELLO",
+        // salida final (rev) = "OLLEH". El tee captura las intermedias EN VIVO
+        // sin re-ejecutar, que es lo que el desplegable necesita por etapa.
+        let spec = pipe(&[
+            ("printf", &["hello\\n"]),
+            ("tr", &["a-z", "A-Z"]),
+            ("rev", &[]),
+        ])
+        .with_stage_capture();
+        let events = run(&spec).wait_all();
+
+        assert_eq!(stage_lines(&events, 0), vec!["hello"], "etapa 0 (printf)");
+        assert_eq!(stage_lines(&events, 1), vec!["HELLO"], "etapa 1 (tr)");
+        // La última etapa sigue saliendo por Stdout normal.
+        assert_eq!(stdout_of(events), vec!["OLLEH"]);
+    }
+
+    #[test]
+    fn without_capture_stages_there_are_no_stage_events() {
+        // El comportamiento por defecto no cambia: sólo la salida final.
+        let events = run(&pipe(&[("printf", &["x\\n"]), ("cat", &[])])).wait_all();
+        assert!(
+            !events
+                .iter()
+                .any(|e| matches!(e, RunEvent::StageStdout { .. })),
+            "sin with_stage_capture no debe haber StageStdout"
+        );
+        assert_eq!(stdout_of(events), vec!["x"]);
+    }
+
+    #[test]
+    fn direct_nonzero_exit_is_the_last_stage() {
+        let mut h = run(&direct("false", &[]));
+        assert!(h.wait_all().contains(&RunEvent::Exited(1)));
+    }
+
+    #[test]
+    fn direct_missing_program_fails_gracefully() {
+        let mut h = run(&direct("no-existe-binario-xyz", &[]));
+        let events = h.wait_all();
+        assert!(matches!(events.first(), Some(RunEvent::Failed(_))));
+    }
+
+    #[test]
+    fn shell_mode_still_works_for_complex_syntax() {
+        let mut h = run(&CommandSpec {
+            exec: Exec::Shell { line: "echo $((2 + 3))".into(), program: "sh".into() },
+            ..CommandSpec::shell("", ".")
+        });
+        assert!(h.wait_all().contains(&RunEvent::Stdout("5".into())));
+    }
+
+    #[test]
+    fn capture_limit_truncates_but_process_finishes() {
+        let mut h = run(&direct("seq", &["1", "20000"]).with_limit(400));
+        let events = h.wait_all();
+        assert!(events.contains(&RunEvent::Truncated));
+        assert!(events.contains(&RunEvent::Exited(0)));
+        assert!(stdout_of(events).len() < 20000);
+    }
+
+    #[test]
+    fn spill_writes_overflow_to_a_file() {
+        let path = std::env::temp_dir()
+            .join(format!("shuma-exec-spill-{}.log", std::process::id()));
+        let _ = std::fs::remove_file(&path);
+        let mut h = run(&direct("seq", &["1", "5000"])
+            .with_limit(200)
+            .with_spill(path.clone()));
+        let events = h.wait_all();
+        assert!(events.iter().any(|e| matches!(e, RunEvent::Spilled(_))));
+        assert!(events.contains(&RunEvent::Exited(0)));
+        // El archivo de volcado existe y tiene contenido.
+        let spilled = std::fs::read_to_string(&path).unwrap_or_default();
+        assert!(spilled.contains("5000"), "la cola se volcó al archivo");
+        let _ = std::fs::remove_file(&path);
+    }
+
+    #[test]
+    fn stdin_data_reprocessed_by_a_filter() {
+        let mut h = run(&direct("grep", &["beta"]).with_stdin("alfa\nbeta\nbetabel\ngamma"));
+        assert_eq!(stdout_of(h.wait_all()), vec!["beta", "betabel"]);
+    }
+
+    #[test]
+    fn kill_stops_a_long_running_pipeline() {
+        let mut h = run(&pipe(&[("sleep", &["30"]), ("cat", &[])]));
+        std::thread::sleep(std::time::Duration::from_millis(250));
+        h.kill();
+        let events = h.wait_all();
+        assert!(events.last().map(|e| e.is_terminal()).unwrap_or(false));
+    }
+
+    #[test]
+    fn terminal_event_detection() {
+        assert!(RunEvent::Exited(0).is_terminal());
+        assert!(!RunEvent::Truncated.is_terminal());
+        assert!(!RunEvent::Spilled("x".into()).is_terminal());
+    }
+
+    #[test]
+    fn pty_run_emits_bytes_for_a_tty_aware_command() {
+        // `tty` imprime el path del terminal cuando se le da uno, o
+        // "not a tty" cuando se le pipea. Bajo PTY debe imprimir un
+        // path con `/dev/pts/` o `/dev/ptmx` (depende del SO).
+        let spec = CommandSpec {
+            exec: Exec::Pty {
+                program: "tty".into(),
+                args: vec![],
+                cols: 80,
+                rows: 24,
+            },
+            cwd: ".".into(),
+            capture_limit: 0,
+            spill_path: None,
+            stdin_data: None,
+            capture_stages: false,
+        };
+        let mut h = run(&spec);
+        let mut bytes_seen = Vec::<u8>::new();
+        let mut exit = -999;
+        while let Some(ev) = h.next_event() {
+            match ev {
+                RunEvent::Bytes(b) => bytes_seen.extend(b),
+                RunEvent::Exited(c) => exit = c,
+                _ => {}
+            }
+        }
+        assert_eq!(exit, 0, "tty exit 0 bajo PTY (bytes='{}')",
+                   String::from_utf8_lossy(&bytes_seen));
+        let out = String::from_utf8_lossy(&bytes_seen);
+        // `tty` emite el path del slave + \r\n (los PTY añaden \r).
+        assert!(
+            out.contains("/dev/pts/") || out.contains("/dev/ptmx") || out.contains("/dev/tty"),
+            "tty output inesperado: {out:?}"
+        );
+    }
+
+    #[test]
+    fn pty_write_input_reaches_the_child_stdin() {
+        // `cat` bajo PTY refleja lo que le escribamos por stdin (en
+        // modo PTY, cada caracter va con echo encendido por defecto).
+        // Le mandamos "hola\n" y esperamos verlo en los bytes de
+        // salida. Después cerramos el stdin (drop del Sender) y
+        // mandamos Ctrl-D (0x04) para que cat termine.
+        let spec = CommandSpec {
+            exec: Exec::Pty {
+                program: "cat".into(),
+                args: vec![],
+                cols: 80,
+                rows: 24,
+            },
+            cwd: ".".into(),
+            capture_limit: 0,
+            spill_path: None,
+            stdin_data: None,
+            capture_stages: false,
+        };
+        let mut h = run(&spec);
+        // Cat necesita un instante para que su slave arranque y abra stdin.
+        std::thread::sleep(std::time::Duration::from_millis(150));
+        assert!(h.write_input(b"hola\n".to_vec()));
+        // Ctrl-D para cerrar EOF en modo cooked.
+        std::thread::sleep(std::time::Duration::from_millis(100));
+        assert!(h.write_input(b"\x04".to_vec()));
+        let mut bytes_seen = Vec::<u8>::new();
+        let started = std::time::Instant::now();
+        while let Some(ev) = h.next_event() {
+            if started.elapsed() > std::time::Duration::from_secs(5) {
+                panic!("cat no terminó en 5s tras Ctrl-D");
+            }
+            if let RunEvent::Bytes(b) = ev {
+                bytes_seen.extend(b);
+            }
+        }
+        let out = String::from_utf8_lossy(&bytes_seen);
+        assert!(out.contains("hola"), "cat no echó 'hola': {out:?}");
+    }
+
+    #[test]
+    fn pty_resize_changes_dimensions_in_running_child() {
+        // Lanzamos `bash` bajo PTY a 80×24, esperamos que el slave
+        // tenga las dims iniciales, hacemos resize, y verificamos que
+        // el child ve el nuevo tamaño tras la señal SIGWINCH.
+        let spec = CommandSpec {
+            exec: Exec::Pty {
+                program: "bash".into(),
+                args: vec!["-c".into(), "stty size; sleep 0.3; stty size".into()],
+                cols: 80,
+                rows: 24,
+            },
+            cwd: ".".into(),
+            capture_limit: 0,
+            spill_path: None,
+            stdin_data: None,
+            capture_stages: false,
+        };
+        let mut h = run(&spec);
+        // Esperar a que el master se publique (race con el coordinador).
+        std::thread::sleep(std::time::Duration::from_millis(100));
+        assert!(h.resize(40, 132), "resize debería aplicarse");
+        let mut bytes = Vec::<u8>::new();
+        while let Some(ev) = h.next_event() {
+            if let RunEvent::Bytes(b) = ev {
+                bytes.extend(b);
+            }
+        }
+        let out = String::from_utf8_lossy(&bytes);
+        // El primer `stty size` muestra 24 80; el segundo debe mostrar
+        // las dimensiones nuevas tras el resize (40 132).
+        assert!(
+            out.contains("40 132"),
+            "stty size tras resize no muestra 40 132: {out:?}"
+        );
+    }
+
+    #[test]
+    fn killer_can_stop_and_continue_a_process() {
+        // `sleep 30` se para con SIGSTOP, se reanuda con SIGCONT y
+        // luego se mata con SIGTERM. El test acaba en <1s aunque el
+        // sleep nominal sea de 30s.
+        let h = run(&direct("sleep", &["30"]));
+        let killer = h.killer();
+        // Esperar a que aparezca el PID (el coordinador rellena el Vec
+        // tras el spawn — micro-delay).
+        let mut tries = 0;
+        while killer.pids().is_empty() && tries < 100 {
+            std::thread::sleep(std::time::Duration::from_millis(10));
+            tries += 1;
+        }
+        assert!(!killer.pids().is_empty(), "pid no apareció");
+        assert!(killer.stop(), "SIGSTOP no llegó");
+        assert!(killer.cont(), "SIGCONT no llegó");
+        assert!(killer.term(), "SIGTERM no llegó");
+        // El test no se cuelga gracias al term().
+    }
+}
diff --git a/02_ruway/shuma/sandbox/shuma-history/Cargo.toml b/02_ruway/shuma/sandbox/shuma-history/Cargo.toml
new file mode 100644
index 0000000..d4dc8e8
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-history/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "shuma-history"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "shuma — historial durable de comandos (JSONL append-only) con navegación y búsqueda fuzzy."
+
+[dependencies]
+serde = { workspace = true }
+serde_json = { workspace = true }
+directories = { workspace = true }
+nucleo-matcher = { workspace = true }
+
+[dev-dependencies]
+tempfile = { workspace = true }
diff --git a/02_ruway/shuma/sandbox/shuma-history/LEEME.md b/02_ruway/shuma/sandbox/shuma-history/LEEME.md
new file mode 100644
index 0000000..6c4df34
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-history/LEEME.md
@@ -0,0 +1,9 @@
+# shuma-history
+
+> History con búsqueda fuzzy de [shuma](../../README.md).
+
+`fzf`-style por defecto + per-directory history opcional. Privacy mode (`shopt -s nohist` equivalente).
+
+## Deps
+
+- [`shuma-core`](../shuma-core/README.md)
diff --git a/02_ruway/shuma/sandbox/shuma-history/README.md b/02_ruway/shuma/sandbox/shuma-history/README.md
new file mode 100644
index 0000000..7d876f1
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-history/README.md
@@ -0,0 +1,9 @@
+# shuma-history
+
+> Fuzzy-search history of [shuma](../../README.md).
+
+`fzf`-style by default + optional per-directory history. Privacy mode (`shopt -s nohist` equivalent).
+
+## Deps
+
+- [`shuma-core`](../shuma-core/README.md)
diff --git a/02_ruway/shuma/sandbox/shuma-history/src/lib.rs b/02_ruway/shuma/sandbox/shuma-history/src/lib.rs
new file mode 100644
index 0000000..90e5b7f
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-history/src/lib.rs
@@ -0,0 +1,430 @@
+//! `shuma-history` — historial **durable** de comandos.
+//!
+//! Independiente del historial vivo de [`shuma_session::WorkSession`]
+//! (que guarda salida completa para la vista en curso): aquí sólo se
+//! persisten *líneas* con su contexto mínimo, en un fichero JSONL
+//! append‑only fácil de leer, rotar y compartir entre sesiones.
+//!
+//! Diseño:
+//!
+//! - **JSONL** (`{"line":...,"cwd":...,"exit":...,"started":...,"duration_ms":...}`).
+//!   Una entrada por línea, append‑only — robusto frente a kills/crashes.
+//! - **Sin lock global**: las escrituras usan `OpenOptions::append`, que
+//!   en Linux son atómicas hasta `PIPE_BUF` (4096 B). Las líneas largas
+//!   no se entrelazan en la práctica con los tamaños típicos de un
+//!   comando.
+//! - **Búsqueda fuzzy** con [`nucleo_matcher`] — mismo matcher que
+//!   helix‑editor: rápido, Unicode‑correct, ranking estable.
+//! - **Dedup**: política configurable; por defecto se ignora el
+//!   duplicado *consecutivo* (estilo bash `HISTCONTROL=ignoredups`).
+
+#![forbid(unsafe_code)]
+
+use std::fs::{File, OpenOptions};
+use std::io::{self, BufRead, BufReader, Write};
+use std::path::{Path, PathBuf};
+
+use serde::{Deserialize, Serialize};
+
+/// Una entrada del historial durable — la línea y su contexto mínimo.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Entry {
+    /// La línea de comandos tal como se ejecutó.
+    pub line: String,
+    /// Directorio en que se lanzó.
+    pub cwd: String,
+    /// Código de salida (`None` si nunca terminó —p. ej. crash del shell).
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub exit: Option<i32>,
+    /// Segundo Unix en que arrancó.
+    pub started: u64,
+    /// Duración en milisegundos (`None` si no terminó).
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub duration_ms: Option<u64>,
+}
+
+impl Entry {
+    /// Construye una entrada nueva con la línea y el cwd; resto a vacío.
+    pub fn new(line: impl Into<String>, cwd: impl Into<String>, started: u64) -> Self {
+        Self {
+            line: line.into(),
+            cwd: cwd.into(),
+            exit: None,
+            started,
+            duration_ms: None,
+        }
+    }
+}
+
+/// Política de deduplicación al añadir entradas nuevas.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum DedupPolicy {
+    /// Guardar todas las entradas sin deduplicar.
+    None,
+    /// Saltar el comando si es idéntico al último guardado (`ignoredups`).
+    IgnoreConsecutive,
+    /// Borrar duplicados previos cuando se vuelve a ver el mismo comando.
+    EraseDups,
+}
+
+impl Default for DedupPolicy {
+    fn default() -> Self {
+        Self::IgnoreConsecutive
+    }
+}
+
+/// Dirección de navegación por el historial.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Nav {
+    /// Hacia atrás en el tiempo (flecha arriba).
+    Older,
+    /// Hacia adelante en el tiempo (flecha abajo).
+    Newer,
+}
+
+/// Historial durable cargado en memoria, con su fichero de respaldo.
+pub struct History {
+    path: PathBuf,
+    entries: Vec<Entry>,
+    dedup: DedupPolicy,
+    /// Cuántas líneas inválidas se descartaron al cargar.
+    skipped: usize,
+}
+
+impl History {
+    /// Ruta por defecto: `$XDG_DATA_HOME/shuma/history.jsonl` (o el
+    /// equivalente Linux/macOS/Windows según [`directories`]). `None` si
+    /// el SO no expone un directorio de datos para el usuario.
+    pub fn default_path() -> Option<PathBuf> {
+        directories::ProjectDirs::from("", "", "shuma")
+            .map(|d| d.data_dir().join("history.jsonl"))
+    }
+
+    /// Abre (o crea) el historial en `path`. Carga todas las entradas
+    /// existentes. Las líneas inválidas se cuentan en `skipped` pero no
+    /// abortan la apertura — el shell debe poder arrancar incluso con
+    /// historial parcialmente corrupto.
+    pub fn open(path: impl Into<PathBuf>) -> io::Result<Self> {
+        let path = path.into();
+        if let Some(parent) = path.parent() {
+            std::fs::create_dir_all(parent)?;
+        }
+        let mut entries = Vec::new();
+        let mut skipped = 0usize;
+        if path.exists() {
+            let f = File::open(&path)?;
+            for line in BufReader::new(f).lines() {
+                let line = line?;
+                if line.trim().is_empty() {
+                    continue;
+                }
+                match serde_json::from_str::<Entry>(&line) {
+                    Ok(e) => entries.push(e),
+                    Err(_) => skipped += 1,
+                }
+            }
+        }
+        Ok(Self { path, entries, dedup: DedupPolicy::default(), skipped })
+    }
+
+    /// Política de deduplicación activa.
+    pub fn dedup(&self) -> DedupPolicy {
+        self.dedup
+    }
+
+    /// Cambia la política de deduplicación.
+    pub fn set_dedup(&mut self, policy: DedupPolicy) {
+        self.dedup = policy;
+    }
+
+    /// Líneas inválidas descartadas en la última apertura.
+    pub fn skipped_on_load(&self) -> usize {
+        self.skipped
+    }
+
+    /// Cantidad de entradas en memoria.
+    pub fn len(&self) -> usize {
+        self.entries.len()
+    }
+
+    /// `true` si no hay entradas.
+    pub fn is_empty(&self) -> bool {
+        self.entries.is_empty()
+    }
+
+    /// Ruta del fichero de respaldo.
+    pub fn path(&self) -> &Path {
+        &self.path
+    }
+
+    /// Entradas en orden cronológico (más antigua primero).
+    pub fn entries(&self) -> &[Entry] {
+        &self.entries
+    }
+
+    /// Última entrada.
+    pub fn last(&self) -> Option<&Entry> {
+        self.entries.last()
+    }
+
+    /// Añade una entrada — aplica la política de dedup y persiste a
+    /// disco. Devuelve `true` si efectivamente se añadió (no era un
+    /// duplicado descartable). Las entradas con `line` vacía se ignoran.
+    pub fn append(&mut self, entry: Entry) -> io::Result<bool> {
+        if entry.line.trim().is_empty() {
+            return Ok(false);
+        }
+        match self.dedup {
+            DedupPolicy::None => {}
+            DedupPolicy::IgnoreConsecutive => {
+                if self.entries.last().is_some_and(|e| e.line == entry.line) {
+                    return Ok(false);
+                }
+            }
+            DedupPolicy::EraseDups => {
+                self.entries.retain(|e| e.line != entry.line);
+                self.rewrite_file()?;
+            }
+        }
+        self.write_one(&entry)?;
+        self.entries.push(entry);
+        Ok(true)
+    }
+
+    /// Actualiza la última entrada con el código de salida y la duración
+    /// cuando el comando termina. Persiste reescribiendo el fichero.
+    pub fn finalize_last(&mut self, exit: i32, duration_ms: u64) -> io::Result<()> {
+        if let Some(last) = self.entries.last_mut() {
+            last.exit = Some(exit);
+            last.duration_ms = Some(duration_ms);
+            self.rewrite_file()?;
+        }
+        Ok(())
+    }
+
+    /// Navegación por el historial — devuelve el `(index, entry)`
+    /// correspondiente a moverse `dir` desde el cursor actual. El cursor
+    /// `None` parte "del final" (por debajo de la última entrada).
+    /// Convención: el índice 0 es la **entrada más reciente**, y avanza
+    /// hacia el pasado al subir el cursor.
+    pub fn navigate(&self, cursor: Option<usize>, dir: Nav) -> Option<(usize, &Entry)> {
+        if self.entries.is_empty() {
+            return None;
+        }
+        let next = match (cursor, dir) {
+            (None, Nav::Older) => 0,
+            (None, Nav::Newer) => return None,
+            (Some(i), Nav::Older) => i + 1,
+            (Some(0), Nav::Newer) => return None,
+            (Some(i), Nav::Newer) => i - 1,
+        };
+        if next >= self.entries.len() {
+            return None;
+        }
+        let entry = &self.entries[self.entries.len() - 1 - next];
+        Some((next, entry))
+    }
+
+    /// Búsqueda fuzzy sobre el campo `line`. Devuelve hasta `limit`
+    /// resultados ordenados por score descendente. Una `query` vacía
+    /// devuelve las entradas más recientes.
+    pub fn fuzzy_search(&self, query: &str, limit: usize) -> Vec<&Entry> {
+        if limit == 0 || self.entries.is_empty() {
+            return Vec::new();
+        }
+        if query.trim().is_empty() {
+            return self.entries.iter().rev().take(limit).collect();
+        }
+        use nucleo_matcher::{
+            pattern::{CaseMatching, Normalization, Pattern},
+            Config, Matcher,
+        };
+        let mut matcher = Matcher::new(Config::DEFAULT);
+        let pat = Pattern::parse(query, CaseMatching::Smart, Normalization::Smart);
+        let mut scored: Vec<(u32, usize)> = Vec::new();
+        let mut buf = Vec::new();
+        for (idx, e) in self.entries.iter().enumerate() {
+            buf.clear();
+            let hay = nucleo_matcher::Utf32Str::new(&e.line, &mut buf);
+            if let Some(score) = pat.score(hay, &mut matcher) {
+                scored.push((score, idx));
+            }
+        }
+        // Score desc, y a igualdad de score, el más reciente primero.
+        scored.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
+        scored
+            .into_iter()
+            .take(limit)
+            .map(|(_, i)| &self.entries[i])
+            .collect()
+    }
+
+    // --- I/O ---
+
+    fn write_one(&self, entry: &Entry) -> io::Result<()> {
+        let mut f = OpenOptions::new().create(true).append(true).open(&self.path)?;
+        let mut s = serde_json::to_string(entry).map_err(io::Error::other)?;
+        s.push('\n');
+        f.write_all(s.as_bytes())?;
+        f.flush()
+    }
+
+    fn rewrite_file(&self) -> io::Result<()> {
+        // Escritura atómica vía rename — nunca dejamos un historial a medias.
+        let tmp = self.path.with_extension("jsonl.tmp");
+        {
+            let mut f = OpenOptions::new()
+                .create(true)
+                .truncate(true)
+                .write(true)
+                .open(&tmp)?;
+            for e in &self.entries {
+                let mut s = serde_json::to_string(e).map_err(io::Error::other)?;
+                s.push('\n');
+                f.write_all(s.as_bytes())?;
+            }
+            f.flush()?;
+        }
+        std::fs::rename(tmp, &self.path)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use tempfile::tempdir;
+
+    fn h(dir: &Path) -> History {
+        History::open(dir.join("history.jsonl")).unwrap()
+    }
+
+    #[test]
+    fn empty_history_round_trip() {
+        let d = tempdir().unwrap();
+        let h1 = h(d.path());
+        assert!(h1.is_empty());
+        let h2 = h(d.path());
+        assert!(h2.is_empty());
+    }
+
+    #[test]
+    fn append_persists_across_reopen() {
+        let d = tempdir().unwrap();
+        {
+            let mut h = h(d.path());
+            h.append(Entry::new("ls", "/tmp", 1000)).unwrap();
+            h.append(Entry::new("pwd", "/tmp", 1001)).unwrap();
+        }
+        let h = h(d.path());
+        assert_eq!(h.len(), 2);
+        assert_eq!(h.entries()[0].line, "ls");
+        assert_eq!(h.entries()[1].line, "pwd");
+    }
+
+    #[test]
+    fn ignore_consecutive_dedup_skips_repeats() {
+        let d = tempdir().unwrap();
+        let mut h = h(d.path());
+        assert!(h.append(Entry::new("ls", "/tmp", 1)).unwrap());
+        assert!(!h.append(Entry::new("ls", "/tmp", 2)).unwrap());
+        assert!(h.append(Entry::new("pwd", "/tmp", 3)).unwrap());
+        assert!(h.append(Entry::new("ls", "/tmp", 4)).unwrap());
+        assert_eq!(h.len(), 3);
+    }
+
+    #[test]
+    fn erase_dups_purges_prior_copies() {
+        let d = tempdir().unwrap();
+        let mut h = h(d.path());
+        h.set_dedup(DedupPolicy::EraseDups);
+        h.append(Entry::new("ls", "/tmp", 1)).unwrap();
+        h.append(Entry::new("pwd", "/tmp", 2)).unwrap();
+        h.append(Entry::new("ls", "/tmp", 3)).unwrap();
+        assert_eq!(h.len(), 2);
+        assert_eq!(h.entries()[0].line, "pwd");
+        assert_eq!(h.entries()[1].line, "ls");
+    }
+
+    #[test]
+    fn empty_line_is_ignored() {
+        let d = tempdir().unwrap();
+        let mut h = h(d.path());
+        assert!(!h.append(Entry::new("", "/tmp", 1)).unwrap());
+        assert!(!h.append(Entry::new("   ", "/tmp", 2)).unwrap());
+        assert!(h.is_empty());
+    }
+
+    #[test]
+    fn finalize_writes_exit_and_duration() {
+        let d = tempdir().unwrap();
+        {
+            let mut h = h(d.path());
+            h.append(Entry::new("sleep 1", "/tmp", 0)).unwrap();
+            h.finalize_last(0, 1000).unwrap();
+        }
+        let h = h(d.path());
+        assert_eq!(h.last().unwrap().exit, Some(0));
+        assert_eq!(h.last().unwrap().duration_ms, Some(1000));
+    }
+
+    #[test]
+    fn navigate_walks_from_newest_to_oldest() {
+        let d = tempdir().unwrap();
+        let mut h = h(d.path());
+        for (i, l) in ["a", "b", "c"].iter().enumerate() {
+            h.append(Entry::new(*l, "/tmp", i as u64)).unwrap();
+        }
+        // Empezando sin cursor, Older da la más reciente.
+        let (i0, e0) = h.navigate(None, Nav::Older).unwrap();
+        assert_eq!((i0, e0.line.as_str()), (0, "c"));
+        let (i1, e1) = h.navigate(Some(i0), Nav::Older).unwrap();
+        assert_eq!((i1, e1.line.as_str()), (1, "b"));
+        let (i2, e2) = h.navigate(Some(i1), Nav::Older).unwrap();
+        assert_eq!((i2, e2.line.as_str()), (2, "a"));
+        // En el extremo no hay más viejas.
+        assert!(h.navigate(Some(i2), Nav::Older).is_none());
+        // Volvemos hacia las nuevas.
+        let (i3, e3) = h.navigate(Some(i2), Nav::Newer).unwrap();
+        assert_eq!((i3, e3.line.as_str()), (1, "b"));
+    }
+
+    #[test]
+    fn fuzzy_search_ranks_matches() {
+        let d = tempdir().unwrap();
+        let mut h = h(d.path());
+        for l in ["cargo build --release", "cargo test", "git status", "cargo run"] {
+            h.append(Entry::new(l, "/tmp", 0)).unwrap();
+        }
+        let hits = h.fuzzy_search("cgo", 10);
+        // Las 3 entradas con "cargo" matchean; "git status" no.
+        assert_eq!(hits.len(), 3);
+        assert!(hits.iter().all(|e| e.line.contains("cargo")));
+    }
+
+    #[test]
+    fn empty_query_returns_most_recent_first() {
+        let d = tempdir().unwrap();
+        let mut h = h(d.path());
+        for l in ["a", "b", "c", "d"] {
+            h.append(Entry::new(l, "/tmp", 0)).unwrap();
+        }
+        let hits = h.fuzzy_search("", 2);
+        assert_eq!(hits.len(), 2);
+        assert_eq!(hits[0].line, "d");
+        assert_eq!(hits[1].line, "c");
+    }
+
+    #[test]
+    fn corrupt_lines_are_skipped_not_fatal() {
+        let d = tempdir().unwrap();
+        let path = d.path().join("history.jsonl");
+        std::fs::write(
+            &path,
+            "{\"line\":\"ok\",\"cwd\":\"/tmp\",\"started\":1}\ngarbage\n{\"line\":\"ok2\",\"cwd\":\"/tmp\",\"started\":2}\n",
+        )
+        .unwrap();
+        let h = History::open(&path).unwrap();
+        assert_eq!(h.len(), 2);
+        assert_eq!(h.skipped_on_load(), 1);
+    }
+}
diff --git a/02_ruway/shuma/sandbox/shuma-infer/Cargo.toml b/02_ruway/shuma/sandbox/shuma-infer/Cargo.toml
new file mode 100644
index 0000000..2e0939c
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-infer/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "shuma-infer"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "shuma — motor de inferencia de intenciones secuenciales: detecta patrones de comandos repetidos en el historial y abstrae sus argumentos variables."
+
+[dependencies]
+serde = { workspace = true }
diff --git a/02_ruway/shuma/sandbox/shuma-infer/LEEME.md b/02_ruway/shuma/sandbox/shuma-infer/LEEME.md
new file mode 100644
index 0000000..fd25c86
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-infer/LEEME.md
@@ -0,0 +1,9 @@
+# shuma-infer
+
+> Inferencia para [`intent`](../shuma-intent/README.md) de [shuma](../../README.md).
+
+Prompts + heurísticas que rodean al LLM. Mantiene el modelo "confinado" al lenguaje shell.
+
+## Deps
+
+- [`shuma-intent`](../shuma-intent/README.md)
diff --git a/02_ruway/shuma/sandbox/shuma-infer/README.md b/02_ruway/shuma/sandbox/shuma-infer/README.md
new file mode 100644
index 0000000..f5de26a
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-infer/README.md
@@ -0,0 +1,9 @@
+# shuma-infer
+
+> Inference for [`intent`](../shuma-intent/README.md) of [shuma](../../README.md).
+
+Prompts + heuristics around the LLM. Keeps the model "confined" to shell command language.
+
+## Deps
+
+- [`shuma-intent`](../shuma-intent/README.md)
diff --git a/02_ruway/shuma/sandbox/shuma-infer/src/lib.rs b/02_ruway/shuma/sandbox/shuma-infer/src/lib.rs
new file mode 100644
index 0000000..076e08b
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-infer/src/lib.rs
@@ -0,0 +1,460 @@
+//! `shuma-infer` — el motor de inferencia de intenciones secuenciales.
+//!
+//! El shell observa cómo trabajas. Cuando una *coreografía* de comandos
+//! se repite —`cd` a un proyecto, `git pull`, `cargo build`— este motor
+//! la detecta, la abstrae (los argumentos que cambian se vuelven
+//! variables) y la ofrece como un patrón reutilizable. Automatización
+//! que nace de la repetición orgánica, no de escribir scripts.
+//!
+//! Es agnóstico y determinista: recibe el historial reducido a
+//! [`CommandRecord`]s y devuelve [`EmergingPattern`]s. No toca disco, ni
+//! la red, ni ningún frontend — el shell se encarga de eso.
+//!
+//! ```text
+//!   historial ──► detect_patterns ──► [EmergingPattern]
+//!     · firma de binarios (ventana deslizante)
+//!     · sólo ventanas 100% exitosas
+//!     · abstracción: args que varían → Varies
+//!     · se quedan los patrones maximales
+//! ```
+
+#![forbid(unsafe_code)]
+
+use std::collections::BTreeMap;
+
+use serde::{Deserialize, Serialize};
+
+/// Un comando ejecutado, reducido a lo que importa para inferir.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct CommandRecord {
+    /// El binario invocado — la primera palabra de la línea.
+    pub binary: String,
+    /// Los argumentos, en orden.
+    pub args: Vec<String>,
+    /// Directorio en que se ejecutó.
+    pub cwd: String,
+    /// Si terminó con éxito (código 0).
+    pub success: bool,
+}
+
+impl CommandRecord {
+    /// Reduce una línea de comando a un registro. La división es simple
+    /// (`split_whitespace`) — suficiente para comparar firmas.
+    pub fn parse(line: &str, cwd: impl Into<String>, success: bool) -> Self {
+        let mut words = line.split_whitespace().map(str::to_string);
+        let binary = words.next().unwrap_or_default();
+        Self { binary, args: words.collect(), cwd: cwd.into(), success }
+    }
+}
+
+/// Ajustes del detector.
+#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
+pub struct InferConfig {
+    /// Largo mínimo de una secuencia para considerarla patrón.
+    pub min_len: usize,
+    /// Largo máximo de ventana a buscar.
+    pub max_len: usize,
+    /// Cuántas veces debe repetirse una firma para emerger.
+    pub min_occurrences: usize,
+}
+
+impl Default for InferConfig {
+    fn default() -> Self {
+        Self { min_len: 2, max_len: 5, min_occurrences: 2 }
+    }
+}
+
+/// Los argumentos de un paso del patrón, tras la abstracción.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum StepArgs {
+    /// Los argumentos son idénticos en todas las ocurrencias.
+    Fixed(Vec<String>),
+    /// Los argumentos cambian entre ocurrencias — son una variable.
+    Varies,
+}
+
+/// Un paso abstracto del patrón: el binario + sus argumentos.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PatternStep {
+    pub binary: String,
+    pub args: StepArgs,
+}
+
+impl PatternStep {
+    /// Renderiza el paso para mostrarlo — `"git pull"`, `"cd <…>"`.
+    pub fn render(&self) -> String {
+        match &self.args {
+            StepArgs::Fixed(a) if a.is_empty() => self.binary.clone(),
+            StepArgs::Fixed(a) => format!("{} {}", self.binary, a.join(" ")),
+            StepArgs::Varies => format!("{} <…>", self.binary),
+        }
+    }
+}
+
+/// Un patrón de comandos que emergió del historial.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct EmergingPattern {
+    /// Firma: la secuencia de binarios.
+    pub signature: Vec<String>,
+    /// Pasos abstractos — para mostrar al usuario.
+    pub steps: Vec<PatternStep>,
+    /// Las líneas reales de la ocurrencia más reciente — ejecutables.
+    pub example: Vec<String>,
+    /// Cuántas veces apareció el patrón.
+    pub occurrences: usize,
+    /// Directorios donde arrancó el patrón, sin repetir.
+    pub directories: Vec<String>,
+}
+
+impl EmergingPattern {
+    /// Puntaje de interés: más largo y más frecuente, más arriba.
+    pub fn score(&self) -> usize {
+        self.occurrences * self.signature.len()
+    }
+
+    /// Nombre sugerido para el patrón — los binarios significativos
+    /// (sin el `cd` inicial) unidos por `+`.
+    pub fn suggested_name(&self) -> String {
+        let significant: Vec<&str> = self
+            .signature
+            .iter()
+            .filter(|b| b.as_str() != "cd")
+            .map(String::as_str)
+            .collect();
+        if significant.is_empty() {
+            self.signature.join("+")
+        } else {
+            significant.join("+")
+        }
+    }
+}
+
+/// `true` si `needle` aparece como sub-secuencia contigua de `haystack`.
+fn contains_subslice(haystack: &[String], needle: &[String]) -> bool {
+    needle.len() <= haystack.len() && haystack.windows(needle.len()).any(|w| w == needle)
+}
+
+/// Construye el patrón abstracto a partir de su firma y las posiciones
+/// donde ocurrió.
+fn build_pattern(
+    history: &[CommandRecord],
+    signature: &[String],
+    starts: &[usize],
+) -> EmergingPattern {
+    let len = signature.len();
+    let mut steps = Vec::with_capacity(len);
+    for i in 0..len {
+        // Argumentos de este paso a lo largo de todas las ocurrencias.
+        let first = &history[starts[0] + i].args;
+        let all_same = starts.iter().all(|&s| &history[s + i].args == first);
+        let args = if all_same {
+            StepArgs::Fixed(first.clone())
+        } else {
+            StepArgs::Varies
+        };
+        steps.push(PatternStep { binary: signature[i].clone(), args });
+    }
+
+    // La ocurrencia más reciente da las líneas reales, ejecutables.
+    let last = *starts.iter().max().expect("hay ocurrencias");
+    let example: Vec<String> = (0..len)
+        .map(|i| {
+            let c = &history[last + i];
+            if c.args.is_empty() {
+                c.binary.clone()
+            } else {
+                format!("{} {}", c.binary, c.args.join(" "))
+            }
+        })
+        .collect();
+
+    // El directorio "de trabajo" de una ocurrencia: el cwd de su último
+    // comando — para entonces todos los `cd` ya se hicieron.
+    let mut directories: Vec<String> = Vec::new();
+    for &s in starts {
+        let d = &history[s + len - 1].cwd;
+        if !directories.contains(d) {
+            directories.push(d.clone());
+        }
+    }
+
+    EmergingPattern {
+        signature: signature.to_vec(),
+        steps,
+        example,
+        occurrences: starts.len(),
+        directories,
+    }
+}
+
+/// Detecta los patrones de comandos repetidos en `history`.
+///
+/// Sólo cuentan las ventanas cuyos comandos terminaron todos con éxito.
+/// Se devuelven los patrones *maximales* (uno contenido en otro más
+/// largo no se reporta), ordenados por puntaje descendente.
+pub fn detect_patterns(history: &[CommandRecord], cfg: &InferConfig) -> Vec<EmergingPattern> {
+    // firma → posiciones de inicio de las ventanas que la producen.
+    let mut windows: BTreeMap<Vec<String>, Vec<usize>> = BTreeMap::new();
+    for len in cfg.min_len..=cfg.max_len {
+        if history.len() < len {
+            break;
+        }
+        for start in 0..=history.len() - len {
+            let win = &history[start..start + len];
+            if !win.iter().all(|c| c.success) {
+                continue; // una ventana con un fallo no es un patrón
+            }
+            let signature: Vec<String> = win.iter().map(|c| c.binary.clone()).collect();
+            windows.entry(signature).or_default().push(start);
+        }
+    }
+
+    // Firmas que se repiten lo suficiente.
+    let qualifying: Vec<(Vec<String>, Vec<usize>)> = windows
+        .into_iter()
+        .filter(|(_, starts)| starts.len() >= cfg.min_occurrences)
+        .collect();
+
+    // Sólo las maximales: una firma contenida en otra más larga que
+    // también califica se descarta (la larga la subsume).
+    let mut patterns: Vec<EmergingPattern> = qualifying
+        .iter()
+        .filter(|(sig, _)| {
+            !qualifying
+                .iter()
+                .any(|(other, _)| other.len() > sig.len() && contains_subslice(other, sig))
+        })
+        .map(|(sig, starts)| build_pattern(history, sig, starts))
+        .collect();
+
+    patterns.sort_by(|a, b| {
+        b.score()
+            .cmp(&a.score())
+            .then(a.signature.cmp(&b.signature))
+    });
+    patterns
+}
+
+/// Predice la continuación de un patrón en curso.
+///
+/// Mira el final del historial `recent`: si sus últimos comandos
+/// coinciden con el prefijo de la firma de algún patrón, devuelve las
+/// líneas que faltan para completarlo —tomadas de la ocurrencia más
+/// reciente, así son ejecutables—. Ante varios, gana el patrón cuyo
+/// prefijo coincidente sea más largo. Es lo que alimenta el "ghosting".
+/// Devuelve `(índice del patrón en `patterns`, líneas de continuación)`.
+pub fn predict_next(
+    recent: &[CommandRecord],
+    patterns: &[EmergingPattern],
+) -> Option<(usize, Vec<String>)> {
+    let bins: Vec<&str> = recent.iter().map(|r| r.binary.as_str()).collect();
+    // best = (longitud del prefijo coincidente, índice del patrón).
+    let mut best: Option<(usize, usize)> = None;
+    for (pi, p) in patterns.iter().enumerate() {
+        // Tiene que quedar al menos un paso por predecir.
+        let max_k = p.signature.len().saturating_sub(1).min(bins.len());
+        for k in (1..=max_k).rev() {
+            let tail = &bins[bins.len() - k..];
+            let prefix_matches = p
+                .signature
+                .iter()
+                .take(k)
+                .map(String::as_str)
+                .eq(tail.iter().copied());
+            if prefix_matches {
+                if best.map(|(bk, _)| k > bk).unwrap_or(true) {
+                    best = Some((k, pi));
+                }
+                break;
+            }
+        }
+    }
+    best.map(|(k, pi)| (pi, patterns[pi].example[k..].to_vec()))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// Atajo: un `CommandRecord` exitoso.
+    fn ok(line: &str, cwd: &str) -> CommandRecord {
+        CommandRecord::parse(line, cwd, true)
+    }
+
+    #[test]
+    fn parse_splits_binary_and_args() {
+        let r = CommandRecord::parse("git commit -m mensaje", "/p", true);
+        assert_eq!(r.binary, "git");
+        assert_eq!(r.args, vec!["commit", "-m", "mensaje"]);
+    }
+
+    #[test]
+    fn detects_a_repeated_sequence() {
+        // cd → git pull → cargo build, dos veces, en dos directorios.
+        let history = vec![
+            ok("cd /proj/a", "/home"),
+            ok("git pull", "/proj/a"),
+            ok("cargo build", "/proj/a"),
+            ok("cd /proj/b", "/home"),
+            ok("git pull", "/proj/b"),
+            ok("cargo build", "/proj/b"),
+        ];
+        let patterns = detect_patterns(&history, &InferConfig::default());
+        assert_eq!(patterns.len(), 1);
+        let p = &patterns[0];
+        assert_eq!(p.signature, vec!["cd", "git", "cargo"]);
+        assert_eq!(p.occurrences, 2);
+    }
+
+    #[test]
+    fn abstracts_varying_arguments() {
+        let history = vec![
+            ok("cd /proj/a", "/home"),
+            ok("git pull", "/proj/a"),
+            ok("cd /proj/b", "/home"),
+            ok("git pull", "/proj/b"),
+        ];
+        let patterns = detect_patterns(&history, &InferConfig::default());
+        let p = &patterns[0];
+        // El `cd` cambia de argumento → Varies; `git pull` es constante.
+        assert_eq!(p.steps[0].args, StepArgs::Varies);
+        assert_eq!(p.steps[1].args, StepArgs::Fixed(vec!["pull".into()]));
+        assert_eq!(p.steps[0].render(), "cd <…>");
+        assert_eq!(p.steps[1].render(), "git pull");
+    }
+
+    #[test]
+    fn example_is_the_most_recent_occurrence() {
+        let history = vec![
+            ok("cd /proj/a", "/home"),
+            ok("git pull", "/proj/a"),
+            ok("cd /proj/b", "/home"),
+            ok("git pull", "/proj/b"),
+        ];
+        let p = &detect_patterns(&history, &InferConfig::default())[0];
+        // Las líneas reales y ejecutables de la última ocurrencia.
+        assert_eq!(p.example, vec!["cd /proj/b", "git pull"]);
+    }
+
+    #[test]
+    fn a_failed_command_breaks_the_pattern() {
+        let history = vec![
+            ok("cd /proj/a", "/home"),
+            ok("git pull", "/proj/a"),
+            ok("cd /proj/b", "/home"),
+            CommandRecord::parse("git pull", "/proj/b", false), // falló
+        ];
+        // Sólo una ventana [cd, git] exitosa → no se repite → sin patrón.
+        assert!(detect_patterns(&history, &InferConfig::default()).is_empty());
+    }
+
+    #[test]
+    fn no_repetition_yields_no_patterns() {
+        let history = vec![
+            ok("ls", "/a"),
+            ok("pwd", "/a"),
+            ok("date", "/a"),
+        ];
+        assert!(detect_patterns(&history, &InferConfig::default()).is_empty());
+    }
+
+    #[test]
+    fn longer_pattern_subsumes_its_subsequences() {
+        // [cd, git, cargo] repetido → no se reporta también [cd, git].
+        let history = vec![
+            ok("cd /a", "/h"),
+            ok("git pull", "/a"),
+            ok("cargo build", "/a"),
+            ok("cd /b", "/h"),
+            ok("git pull", "/b"),
+            ok("cargo build", "/b"),
+        ];
+        let patterns = detect_patterns(&history, &InferConfig::default());
+        assert_eq!(patterns.len(), 1);
+        assert_eq!(patterns[0].signature.len(), 3);
+    }
+
+    #[test]
+    fn directories_are_collected() {
+        let history = vec![
+            ok("cd /a", "/home"),
+            ok("git pull", "/a"),
+            ok("cd /b", "/work"),
+            ok("git pull", "/b"),
+        ];
+        let p = &detect_patterns(&history, &InferConfig::default())[0];
+        // El directorio de cada ocurrencia es el de su último comando.
+        assert_eq!(p.directories, vec!["/a", "/b"]);
+    }
+
+    #[test]
+    fn suggested_name_drops_the_cd() {
+        let history = vec![
+            ok("cd /a", "/h"),
+            ok("git pull", "/a"),
+            ok("cargo build", "/a"),
+            ok("cd /b", "/h"),
+            ok("git pull", "/b"),
+            ok("cargo build", "/b"),
+        ];
+        let p = &detect_patterns(&history, &InferConfig::default())[0];
+        assert_eq!(p.suggested_name(), "git+cargo");
+    }
+
+    /// Mundo de prueba: el patrón cd → git pull → cargo build, visto dos
+    /// veces, y la lista de patrones que produce.
+    fn pattern_world() -> Vec<EmergingPattern> {
+        let history = vec![
+            ok("cd /a", "/h"),
+            ok("git pull", "/a"),
+            ok("cargo build", "/a"),
+            ok("cd /b", "/h"),
+            ok("git pull", "/b"),
+            ok("cargo build", "/b"),
+        ];
+        detect_patterns(&history, &InferConfig::default())
+    }
+
+    #[test]
+    fn predicts_the_rest_after_a_cd() {
+        let patterns = pattern_world();
+        // El usuario acaba de hacer `cd` → se predicen los pasos que faltan.
+        let recent = vec![ok("cd /nuevo", "/h")];
+        let (_, next) = predict_next(&recent, &patterns).unwrap();
+        assert_eq!(next, vec!["git pull", "cargo build"]);
+    }
+
+    #[test]
+    fn prediction_shrinks_as_the_pattern_advances() {
+        let patterns = pattern_world();
+        let recent = vec![ok("cd /nuevo", "/h"), ok("git pull", "/nuevo")];
+        let (_, next) = predict_next(&recent, &patterns).unwrap();
+        assert_eq!(next, vec!["cargo build"]);
+    }
+
+    #[test]
+    fn no_prediction_when_nothing_matches() {
+        let patterns = pattern_world();
+        let recent = vec![ok("ls", "/h"), ok("pwd", "/h")];
+        assert!(predict_next(&recent, &patterns).is_none());
+    }
+
+    #[test]
+    fn score_ranks_longer_and_more_frequent_higher() {
+        let short = EmergingPattern {
+            signature: vec!["a".into(), "b".into()],
+            steps: vec![],
+            example: vec![],
+            occurrences: 2,
+            directories: vec![],
+        };
+        let long = EmergingPattern {
+            signature: vec!["a".into(), "b".into(), "c".into()],
+            steps: vec![],
+            example: vec![],
+            occurrences: 3,
+            directories: vec![],
+        };
+        assert!(long.score() > short.score());
+    }
+}
diff --git a/02_ruway/shuma/sandbox/shuma-intent/Cargo.toml b/02_ruway/shuma/sandbox/shuma-intent/Cargo.toml
new file mode 100644
index 0000000..ce32375
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-intent/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "shuma-intent"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "shuma — núcleo agnóstico del shell: parser de intenciones (tokens %cN/%pN) + grafo de contexto de la sesión."
+
+[dependencies]
+serde = { workspace = true }
diff --git a/02_ruway/shuma/sandbox/shuma-intent/LEEME.md b/02_ruway/shuma/sandbox/shuma-intent/LEEME.md
new file mode 100644
index 0000000..75ed321
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-intent/LEEME.md
@@ -0,0 +1,9 @@
+# shuma-intent
+
+> Intent → comando (predictor) de [shuma](../../README.md).
+
+Toma texto natural y predice el comando shell. Usa [`pluma-llm`](../../../../00_unanchay/pluma/pluma-llm/README.md). Sugiere; nunca ejecuta sin confirmación.
+
+## Deps
+
+- [`pluma-llm-core`](../../../../00_unanchay/pluma/pluma-llm-core/README.md)
diff --git a/02_ruway/shuma/sandbox/shuma-intent/README.md b/02_ruway/shuma/sandbox/shuma-intent/README.md
new file mode 100644
index 0000000..1a91a08
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-intent/README.md
@@ -0,0 +1,9 @@
+# shuma-intent
+
+> Intent → command (predictor) of [shuma](../../README.md).
+
+Takes natural-language text and predicts the matching shell command. Uses [`pluma-llm`](../../../../00_unanchay/pluma/pluma-llm/README.md). Suggests; never executes without confirmation.
+
+## Deps
+
+- [`pluma-llm-core`](../../../../00_unanchay/pluma/pluma-llm-core/README.md)
diff --git a/02_ruway/shuma/sandbox/shuma-intent/src/graph.rs b/02_ruway/shuma/sandbox/shuma-intent/src/graph.rs
new file mode 100644
index 0000000..c858dfb
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-intent/src/graph.rs
@@ -0,0 +1,175 @@
+//! Grafo de contexto de una sesión de shuma.
+//!
+//! Registra cada intención ejecutada como un nodo `%cN`; al terminar, el
+//! nodo expone su buffer de salida `%pN`. El grafo permite resolver las
+//! referencias del prompt, validar intenciones nuevas antes de ejecutar,
+//! y colapsar nodos exitosos para la quietud visual.
+
+use crate::parse::{Intention, Ref};
+use serde::{Deserialize, Serialize};
+
+/// Estado de un nodo del grafo de contexto.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum NodeStatus {
+    Running,
+    Ok,
+    Failed,
+}
+
+/// Un comando registrado en la sesión.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct CommandNode {
+    /// Identificador `%cN`.
+    pub id: u32,
+    /// Texto de la intención original.
+    pub intention: String,
+    /// Buffer `%pN` producido como salida, si el comando ya terminó.
+    pub output_buffer: Option<u32>,
+    pub status: NodeStatus,
+    /// Colapsado en la UI (nodo exitoso retraído por quietud visual).
+    pub collapsed: bool,
+    /// Bytes del buffer de salida (para dimensionar el grafo visual).
+    pub output_bytes: u64,
+}
+
+/// Grafo de intenciones y flujos de una sesión de shell.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct SessionGraph {
+    commands: Vec<CommandNode>,
+    next_command: u32,
+    next_buffer: u32,
+}
+
+impl Default for SessionGraph {
+    fn default() -> Self {
+        Self { commands: Vec::new(), next_command: 1, next_buffer: 1 }
+    }
+}
+
+impl SessionGraph {
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    pub fn len(&self) -> usize {
+        self.commands.len()
+    }
+
+    pub fn is_empty(&self) -> bool {
+        self.commands.is_empty()
+    }
+
+    pub fn commands(&self) -> &[CommandNode] {
+        &self.commands
+    }
+
+    /// Registra una intención nueva en estado `Running`. Devuelve su `%cN`.
+    pub fn record(&mut self, intention: impl Into<String>) -> u32 {
+        let id = self.next_command;
+        self.next_command += 1;
+        self.commands.push(CommandNode {
+            id,
+            intention: intention.into(),
+            output_buffer: None,
+            status: NodeStatus::Running,
+            collapsed: false,
+            output_bytes: 0,
+        });
+        id
+    }
+
+    /// Marca un comando como terminado y le asigna un buffer de salida.
+    /// Devuelve el `%pN` asignado, o `None` si el `%cN` no existe.
+    pub fn complete(&mut self, command_id: u32, ok: bool, output_bytes: u64) -> Option<u32> {
+        let buffer = self.next_buffer;
+        let node = self.commands.iter_mut().find(|c| c.id == command_id)?;
+        node.status = if ok { NodeStatus::Ok } else { NodeStatus::Failed };
+        node.output_bytes = output_bytes;
+        node.output_buffer = Some(buffer);
+        self.next_buffer += 1;
+        Some(buffer)
+    }
+
+    /// Resuelve una referencia a su nodo de comando.
+    pub fn resolve(&self, r: Ref) -> Option<&CommandNode> {
+        match r {
+            Ref::Command(n) => self.commands.iter().find(|c| c.id == n),
+            Ref::Buffer(n) => self.commands.iter().find(|c| c.output_buffer == Some(n)),
+        }
+    }
+
+    /// Referencias de la intención que NO se pueden resolver en esta
+    /// sesión. Vacío = la intención es ejecutable (validación previa
+    /// del prompt).
+    pub fn dangling_refs(&self, intention: &Intention) -> Vec<Ref> {
+        intention
+            .refs()
+            .into_iter()
+            .filter(|r| self.resolve(*r).is_none())
+            .collect()
+    }
+
+    /// Colapsa los nodos exitosos (quietud visual: los flujos que ya
+    /// funcionaron se retraen).
+    pub fn collapse_succeeded(&mut self) {
+        for c in &mut self.commands {
+            if c.status == NodeStatus::Ok {
+                c.collapsed = true;
+            }
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn record_assigns_increasing_command_ids() {
+        let mut g = SessionGraph::new();
+        assert_eq!(g.record("cat a"), 1);
+        assert_eq!(g.record("cat b"), 2);
+        assert_eq!(g.len(), 2);
+    }
+
+    #[test]
+    fn complete_assigns_buffer_and_status() {
+        let mut g = SessionG

El parche sigue, pero se cortó acá: pedí el commit por git para verlo entero.