tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 188Rust 55Shell 30Markdown 281211 Definiciones

Etapa F paquetería #1: `hammer pack` — receta del corpus → paquete .swm (source_patch)

Cierra la dirección forward que faltaba (SDD 06 §6 la marcaba "para más adelante"):
una receta que el sistema ya sabe construir se vuelve un paquete distribuible y
reproducible-desde-fuente, inversa de `hammer apply`.

- hammer-core: `Swm::from_recipe(recipe, target_bin, patch_text, expected, distro)`
  (constructor puro: el caller lee los patches). `SwmBuild` gana `phases`+`zig_version`
  y `SourcePatch` gana `strip_components` (Option/skip ⇒ .swm viejos parsean igual) para
  reproducir con fidelidad el corpus real (22/34 recetas usan phases, 8 usan zig 0.13).
- hammer-build/swm_bridge: la dirección inversa (source_patch→Recipe→build) ahora traslada
  phases/zig_version/strip_components a la receta efímera ⇒ apply rehace idéntico.
- hammer-cli: `hammer pack <recipe> [--target-bin] [--out] [--expected|--build] [--sign]`.
  Concatena los patches inline; avisa si la receta declara deps (el source_patch aún no
  las modela = pieza posterior). `export` también enriquece su source_patch.
- Validado en host: ripgrep (git+patch+install custom), openssl (tarball+zig 0.13+phases),
  coreutils (multicall), findutils firmado → swm-verify "trusted". Tests core+bridge verde.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

339a7b07ef8d72e101fecfbd722c2f8feb8a7d1c · por sergio · (hace 4 meses) · padre be881ad09 · ver el árbol en este commit

Delta semántico · 23

crates/hammer-agent/src/orchestrator.rs

crates/hammer-build/src/swm_bridge.rs

crates/hammer-cli/src/main.rs

crates/hammer-core/src/recipe.rs

crates/hammer-core/src/swm.rs

crates/hammerd/src/bus.rs

7 archivos · +379 −5

diff --git a/crates/hammer-agent/src/orchestrator.rs b/crates/hammer-agent/src/orchestrator.rs
index 7ce8ec3f..ac329573 100644
--- a/crates/hammer-agent/src/orchestrator.rs
+++ b/crates/hammer-agent/src/orchestrator.rs
@@ -163,6 +163,7 @@ impl<T: IntentTranslator> Orchestrator<T> {
                     commit,
                     tarball,
                     sha256,
+                    strip_components: _,
                     patch,
                     patch_url: _,
                     build,
@@ -582,6 +583,7 @@ mod tests {
                 commit: Some("abc".into()),
                 tarball: None,
                 sha256: None,
+                strip_components: None,
                 patch: None,
                 patch_url: None,
                 build: hammer_core::swm::SwmBuild {
@@ -589,6 +591,8 @@ mod tests {
                     target: "x86_64-linux-musl".into(),
                     link: "static".into(),
                     flags: vec![],
+                    phases: Default::default(),
+                    zig_version: None,
                 },
                 target_bin: "/bin/x".into(),
                 expected_hash: None,
diff --git a/crates/hammer-build/src/swm_bridge.rs b/crates/hammer-build/src/swm_bridge.rs
index 511dce87..8274f05d 100644
--- a/crates/hammer-build/src/swm_bridge.rs
+++ b/crates/hammer-build/src/swm_bridge.rs
@@ -27,20 +27,22 @@ pub fn build_source_patch(
     store: &Store,
     scratch_root: Option<&Path>,
 ) -> hammer_core::Result<ArtifactHash> {
-    let (repo, commit, tarball, sha256, patch, patch_url, build_cfg, target_bin, expected) =
+    let (repo, commit, tarball, sha256, strip_components, patch, patch_url, build_cfg, target_bin, expected) =
         match mutation {
             Mutation::SourcePatch {
                 repo,
                 commit,
                 tarball,
                 sha256,
+                strip_components,
                 patch,
                 patch_url,
                 build,
                 target_bin,
                 expected_hash,
             } => (
-                repo, commit, tarball, sha256, patch, patch_url, build, target_bin, expected_hash,
+                repo, commit, tarball, sha256, strip_components, patch, patch_url, build, target_bin,
+                expected_hash,
             ),
             _ => {
                 return Err(hammer_core::Error::Recipe(
@@ -85,7 +87,14 @@ pub fn build_source_patch(
     }
 
     let name = derive_name(target_bin);
-    let recipe = synthesize_recipe(&name, &source_kind, build_cfg, patches, &recipe_dir)?;
+    let recipe = synthesize_recipe(
+        &name,
+        &source_kind,
+        build_cfg,
+        *strip_components,
+        patches,
+        &recipe_dir,
+    )?;
 
     let hash = build(&recipe, cfg, store)?;
     if let Some(want) = expected {
@@ -118,6 +127,7 @@ fn synthesize_recipe(
     name: &str,
     source: &hammer_core::SourceKind<'_>,
     build_cfg: &SwmBuild,
+    strip_components: Option<usize>,
     patches: Vec<String>,
     base_dir: &Path,
 ) -> hammer_core::Result<Recipe> {
@@ -158,6 +168,13 @@ flags    = []
     );
     let mut recipe = Recipe::from_toml(&toml_text)?;
     recipe.build.flags = build_cfg.flags.clone();
+    // Fidelidad de reconstrucción: fases custom, zig por-paquete y strip del tarball viajan en
+    // el .swm (si vienen) y se reinyectan en la receta efímera tal cual los tenía el original.
+    recipe.build.phases = build_cfg.phases.clone();
+    recipe.build.zig_version = build_cfg.zig_version.clone();
+    if let Some(n) = strip_components {
+        recipe.source.strip_components = n;
+    }
     // Resolución de patches relativos al recipe_dir donde acabamos de escribir los inline.
     // Aceptamos rutas absolutas tal cual: las hemos generado nosotros.
     recipe.source.patches = patches
@@ -206,6 +223,8 @@ mod tests {
             target: "x86_64-linux-musl".into(),
             link: "static".into(),
             flags: vec!["--enable-foo".into()],
+            phases: Default::default(),
+            zig_version: None,
         }
     }
 
@@ -226,6 +245,7 @@ mod tests {
                 commit: "a1b2c3d4e5f6a7b8c9",
             },
             &fake_swm_build(),
+            None,
             vec![],
             d.path(),
         )
@@ -252,6 +272,7 @@ mod tests {
                 sha256: "abcdef0123456789",
             },
             &fake_swm_build(),
+            None,
             vec![],
             d.path(),
         )
@@ -302,6 +323,7 @@ mod tests {
             commit: Some(commit.into()),
             tarball: None,
             sha256: None,
+            strip_components: None,
             patch: None,
             patch_url: Some(format!("file://{}", patch_src.display())),
             build: fake_swm_build(),
diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs
index d93032de..b26d7485 100644
--- a/crates/hammer-cli/src/main.rs
+++ b/crates/hammer-cli/src/main.rs
@@ -167,6 +167,36 @@ enum Cmd {
         #[arg(long)]
         since: Option<String>,
     },
+    /// [Etapa F] Empaqueta una receta del corpus (`recipes/foo.toml`) como un `.swm` de un
+    /// único `source_patch`: la dirección **forward** que vuelve una receta que el sistema ya
+    /// sabe construir un paquete distribuible y reproducible-desde-fuente. Inversa de `apply`.
+    Pack {
+        /// Ruta a la receta a empaquetar (p. ej. `recipes/ripgrep.toml`).
+        recipe: PathBuf,
+        /// Ancla de sanity: ruta absoluta del binario que debe existir tras hidratar el
+        /// artefacto. Default `/usr/bin/<name>`. (apply hidrata el artefacto ENTERO; esto sólo
+        /// verifica que algo esperado quedó en su sitio.)
+        #[arg(long)]
+        target_bin: Option<String>,
+        /// Archivo `.swm` de salida. Sin esto, se imprime a stdout.
+        #[arg(long, short)]
+        out: Option<PathBuf>,
+        /// `distro_version` de la base del paquete (la base local del receptor debe coincidir).
+        #[arg(long, default_value = "dev")]
+        distro_version: String,
+        /// `expected_hash` declarado (`b3:…`) — el ancla de "verificar, no confiar". Si se omite
+        /// y no se pasa `--build`, el paquete sale sin ancla (el receptor reproduce igual, pero
+        /// no hay hash autor contra el que comparar).
+        #[arg(long)]
+        expected: Option<String>,
+        /// Construir la receta AHORA en el lab para sellar el `expected_hash` real (necesita
+        /// store + sandbox). Mutuamente informativo con `--expected` (este último gana si ambos).
+        #[arg(long)]
+        build: bool,
+        /// Firmar el `.swm` resultante con esta clave privada Ed25519 (como `swm-sign`).
+        #[arg(long)]
+        sign: Option<PathBuf>,
+    },
     /// [Fase 6] Ejecuta el bucle agéntico: traduce una intención NL a un .swm vía catálogo
     /// mock y la aplica a un overlay (o prefix) sin promover al FHS. Imprime el Proposal.
     Ai {
@@ -647,6 +677,26 @@ fn main() -> anyhow::Result<()> {
         Cmd::Export { base_ref, journal, since } => {
             run_export(base_ref.as_deref(), &journal, since.as_deref(), &cli.store)?;
         }
+        Cmd::Pack {
+            recipe,
+            target_bin,
+            out,
+            distro_version,
+            expected,
+            build,
+            sign,
+        } => {
+            run_pack(
+                &recipe,
+                target_bin.as_deref(),
+                out.as_deref(),
+                &distro_version,
+                expected.as_deref(),
+                build,
+                sign.as_deref(),
+                &cli.store,
+            )?;
+        }
         Cmd::Ai {
             intent,
             catalog,
@@ -1038,6 +1088,114 @@ fn run_keygen(name: &str, out: Option<&std::path::Path>) -> anyhow::Result<()> {
     Ok(())
 }
 
+/// Empaqueta una receta como un `.swm` de un único `source_patch` (Etapa F). Lee la receta,
+/// materializa el texto de sus patches (resueltos contra `base_dir`, concatenados), opcionalmente
+/// construye para sellar el `expected_hash` real, firma si se pide, y emite el manifiesto.
+#[allow(clippy::too_many_arguments)]
+fn run_pack(
+    recipe_path: &std::path::Path,
+    target_bin: Option<&str>,
+    out: Option<&std::path::Path>,
+    distro_version: &str,
+    expected: Option<&str>,
+    do_build: bool,
+    sign: Option<&std::path::Path>,
+    store_path: &str,
+) -> anyhow::Result<()> {
+    let recipe = hammer_core::Recipe::load_from_path(recipe_path)
+        .map_err(|e| anyhow::anyhow!("no pude cargar la receta {}: {e}", recipe_path.display()))?;
+
+    // El `source_patch` aún NO modela `deps` (resolución de dependencias entre paquetes = pieza
+    // posterior de la paquetería). Una receta con build-deps se empaqueta igual, pero el receptor
+    // necesitará esas deps ya presentes en su store para reproducir. Avisamos, no perdemos en silencio.
+    if !recipe.deps.build.is_empty() || !recipe.deps.runtime.is_empty() {
+        eprintln!(
+            "⚠ la receta declara deps (build={:?} runtime={:?}) que el source_patch no transporta; \
+             el receptor debe tenerlas en su store para reproducir (deps entre paquetes: pieza futura)",
+            recipe.deps.build, recipe.deps.runtime
+        );
+    }
+
+    // Texto de los patches: cada uno se resuelve relativo al directorio de la receta (igual que
+    // el lab) y se concatena. El `source_patch` del .swm modela un único `patch` inline; un diff
+    // unificado multi-fichero concatenado se aplica hunk-a-hunk igual (git apply / patch -p1).
+    let patch_text = if recipe.source.patches.is_empty() {
+        None
+    } else {
+        let mut buf = String::new();
+        for p in &recipe.source.patches {
+            let path = std::path::Path::new(p);
+            let abs = if path.is_absolute() {
+                path.to_path_buf()
+            } else {
+                recipe.base_dir.join(path)
+            };
+            let txt = std::fs::read_to_string(&abs)
+                .map_err(|e| anyhow::anyhow!("leyendo patch {}: {e}", abs.display()))?;
+            if !buf.is_empty() && !buf.ends_with('\n') {
+                buf.push('\n');
+            }
+            buf.push_str(&txt);
+        }
+        Some(buf)
+    };
+
+    let target_bin = target_bin
+        .map(|s| s.to_string())
+        .unwrap_or_else(|| format!("/usr/bin/{}", recipe.name));
+
+    // expected_hash: `--expected` manda; si no, `--build` lo sella construyendo de verdad en el
+    // lab; si ninguno, el paquete sale sin ancla de verificación (válido, sólo más débil).
+    let expected_hash: Option<String> = if let Some(h) = expected {
+        Some(h.to_string())
+    } else if do_build {
+        let store = hammer_core::Store::open(store_path)?;
+        let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
+        let hash = hammer_build::build(&recipe, &cfg, &store)
+            .map_err(|e| anyhow::anyhow!("construyendo la receta para sellar expected_hash: {e}"))?;
+        eprintln!("construida: {hash}");
+        Some(hash.as_str().to_string())
+    } else {
+        None
+    };
+
+    let mut swm = hammer_core::Swm::from_recipe(
+        &recipe,
+        target_bin,
+        patch_text,
+        expected_hash,
+        distro_version,
+    )?;
+
+    if let Some(key) = sign {
+        let priv_b64 = std::fs::read_to_string(key)
+            .map_err(|e| anyhow::anyhow!("no pude leer la clave {}: {e}", key.display()))?;
+        let kp = hammer_core::KeyPair::from_private_b64(&priv_b64)?;
+        let by = key
+            .file_name()
+            .map(|f| f.to_string_lossy().trim_end_matches(".ed25519").to_string())
+            .unwrap_or_else(|| "anon".into());
+        swm.signature = Some(kp.sign(&swm, &by));
+        eprintln!("firmado por {by}");
+    }
+
+    let yaml = swm.to_yaml()?;
+    match out {
+        Some(dest) => {
+            std::fs::write(dest, &yaml)?;
+            eprintln!(
+                "empaquetada {} → {} ({} mutación source_patch{})",
+                recipe.name,
+                dest.display(),
+                swm.mutations.len(),
+                if swm.signature.is_some() { ", firmado" } else { "" },
+            );
+        }
+        None => print!("{yaml}"),
+    }
+    Ok(())
+}
+
 fn run_swm_sign(
     file: &str,
     key: &std::path::Path,
@@ -1409,6 +1567,8 @@ fn build_export_mutations(
             commit,
             tarball,
             sha256,
+            strip_components: (recipe.source.strip_components != 1)
+                .then_some(recipe.source.strip_components),
             patch: patch_inline,
             patch_url: None,
             build: hammer_core::SwmBuild {
@@ -1416,6 +1576,8 @@ fn build_export_mutations(
                 target: recipe.build.target.clone(),
                 link: recipe.build.link.as_str().to_string(),
                 flags: recipe.build.flags.clone(),
+                phases: recipe.build.phases.clone(),
+                zig_version: recipe.build.zig_version.clone(),
             },
             target_bin,
             expected_hash: Some(art_hash),
diff --git a/crates/hammer-core/src/recipe.rs b/crates/hammer-core/src/recipe.rs
index 698a4664..5093b137 100644
--- a/crates/hammer-core/src/recipe.rs
+++ b/crates/hammer-core/src/recipe.rs
@@ -121,6 +121,14 @@ pub struct Phases {
     pub install: Option<String>,
 }
 
+impl Phases {
+    /// `true` si ninguna fase está sobreescrita (todo se decide por heurística). Sirve a
+    /// serde (`skip_serializing_if`) para que un `.swm` sin fases custom no las emita.
+    pub fn is_empty(&self) -> bool {
+        self.configure.is_none() && self.compile.is_none() && self.install.is_none()
+    }
+}
+
 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
 pub struct Deps {
     #[serde(default)]
diff --git a/crates/hammer-core/src/swm.rs b/crates/hammer-core/src/swm.rs
index 3cd9a439..37bd2df4 100644
--- a/crates/hammer-core/src/swm.rs
+++ b/crates/hammer-core/src/swm.rs
@@ -83,6 +83,10 @@ pub enum Mutation {
         tarball: Option<String>,
         #[serde(default, skip_serializing_if = "Option::is_none")]
         sha256: Option<String>,
+        /// Componentes a recortar al extraer un tarball (modo tarball). `None` ⇒ el default
+        /// del lab (1, un único top-level `proyecto-version/`). Sólo se emite si difiere.
+        #[serde(default, skip_serializing_if = "Option::is_none")]
+        strip_components: Option<usize>,
         #[serde(default, skip_serializing_if = "Option::is_none")]
         patch: Option<String>,
         #[serde(default, skip_serializing_if = "Option::is_none")]
@@ -126,6 +130,16 @@ pub struct SwmBuild {
     pub link: String,
     #[serde(default)]
     pub flags: Vec<String>,
+    /// Overrides explícitos de las fases del build (configure/compile/install). Sin esto un
+    /// `source_patch` sólo reproduciría builds heurísticos; con esto un paquete que viene de
+    /// una `Recipe` con install custom (p. ej. los multicall coreutils/findutils) se rehace
+    /// fiel. Vacío ⇒ heurística del lab, idéntico a una receta sin `[build.phases]`.
+    #[serde(default, skip_serializing_if = "crate::recipe::Phases::is_empty")]
+    pub phases: crate::recipe::Phases,
+    /// Versión de zig por-paquete (escotilla a la regresión de zig 0.14; ver `Build::zig_version`).
+    /// `None` ⇒ el zig por defecto del receptor.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub zig_version: Option<String>,
 }
 
 #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -209,6 +223,72 @@ impl Swm {
             BaseCompat::PinMismatch(diffs)
         }
     }
+
+    /// Construye un `.swm` de un único `source_patch` a partir de una [`crate::Recipe`] del
+    /// corpus — la dirección **forward** que cierra la paquetería (Etapa F): una receta que el
+    /// sistema YA sabe construir se vuelve un paquete distribuible y reproducible-desde-fuente.
+    /// Es la inversa de `hammer_build::swm_bridge` (que va `source_patch` → `Recipe` → build).
+    ///
+    /// `hammer-core` no toca disco: el caller resuelve y **lee** los patches de la receta
+    /// (relativos a `recipe.base_dir`) y pasa su texto ya concatenado en `patch_text`. El
+    /// `expected_hash`, si se da, ancla "verificar, no confiar" (el receptor rehace y compara).
+    pub fn from_recipe(
+        recipe: &crate::Recipe,
+        target_bin: impl Into<String>,
+        patch_text: Option<String>,
+        expected_hash: Option<String>,
+        distro_version: impl Into<String>,
+    ) -> crate::Result<Swm> {
+        use crate::recipe::SourceKind;
+
+        // strip_components sólo viaja si difiere del default del lab (1) y es modo tarball.
+        let strip_components = match recipe.source.kind()? {
+            SourceKind::Tarball { .. } if recipe.source.strip_components != 1 => {
+                Some(recipe.source.strip_components)
+            }
+            _ => None,
+        };
+        let (repo, commit, tarball, sha256) = match recipe.source.kind()? {
+            SourceKind::Git { repo, commit } => {
+                (Some(repo.to_string()), Some(commit.to_string()), None, None)
+            }
+            SourceKind::Tarball { url, sha256 } => {
+                (None, None, Some(url.to_string()), Some(sha256.to_string()))
+            }
+        };
+
+        let build = SwmBuild {
+            compiler: recipe.build.compiler.as_str().to_string(),
+            target: recipe.build.target.clone(),
+            link: recipe.build.link.as_str().to_string(),
+            flags: recipe.build.flags.clone(),
+            phases: recipe.build.phases.clone(),
+            zig_version: recipe.build.zig_version.clone(),
+        };
+
+        let swm = Swm {
+            swm_version: 1,
+            base: Base {
+                distro_version: distro_version.into(),
+                pins: BTreeMap::new(),
+            },
+            mutations: vec![Mutation::SourcePatch {
+                repo,
+                commit,
+                tarball,
+                sha256,
+                strip_components,
+                patch: patch_text,
+                patch_url: None,
+                build,
+                target_bin: target_bin.into(),
+                expected_hash,
+            }],
+            signature: None,
+        };
+        swm.verify_schema()?;
+        Ok(swm)
+    }
 }
 
 /// Resuelve el modo de origen de un `source_patch` (git **xor** tarball) desde sus campos
@@ -477,6 +557,7 @@ mutations:
                 commit: Some("abc".into()),
                 tarball: None,
                 sha256: None,
+                strip_components: None,
                 patch: None,
                 patch_url: None,
                 build: SwmBuild {
@@ -484,6 +565,8 @@ mutations:
                     target: "x86_64-linux-musl".into(),
                     link: "static".into(),
                     flags: vec![],
+                    phases: Default::default(),
+                    zig_version: None,
                 },
                 target_bin: "/bin/x".into(),
                 expected_hash: None,
@@ -503,6 +586,7 @@ mutations:
                 commit: None,
                 tarball: Some("https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz".into()),
                 sha256: Some("deadbeef".into()),
+                strip_components: None,
                 patch: None,
                 patch_url: None,
                 build: SwmBuild {
@@ -510,6 +594,8 @@ mutations:
                     target: "x86_64-linux-musl".into(),
                     link: "static".into(),
                     flags: vec![],
+                    phases: Default::default(),
+                    zig_version: None,
                 },
                 target_bin: "/bin/grep".into(),
                 expected_hash: None,
@@ -526,6 +612,7 @@ mutations:
             commit: Some("abc".into()),
             tarball: Some("https://x/a.tar.gz".into()),
             sha256: Some("deadbeef".into()),
+            strip_components: None,
             patch: None,
             patch_url: None,
             build: SwmBuild {
@@ -533,6 +620,8 @@ mutations:
                 target: "x86_64-linux-musl".into(),
                 link: "static".into(),
                 flags: vec![],
+                phases: Default::default(),
+                zig_version: None,
             },
             target_bin: "/bin/x".into(),
             expected_hash: None,
@@ -541,6 +630,80 @@ mutations:
         assert!(err.contains("no ambos"), "mensaje inesperado: {err}");
     }
 
+    #[test]
+    fn from_recipe_git_preserva_build_phases_y_patch() {
+        // Receta git con install custom, flags, zig_version → el paquete debe reproducir todo.
+        let toml = r#"
+name = "ripgrep"
+version = "14.1.1"
+[source]
+repo   = "https://github.com/BurntSushi/ripgrep"
+commit = "4649aa9700619f94cf9c66876e9549d83420e16c"
+[build]
+compiler = "zig-cc"
+target   = "x86_64-linux-musl"
+link     = "static"
+flags    = ["--bin", "rg"]
+[build.phases]
+install = "cp target/release/rg /out/usr/bin/rg"
+"#;
+        let recipe = crate::Recipe::from_toml(toml).unwrap();
+        let swm = Swm::from_recipe(
+            &recipe,
+            "/usr/bin/rg",
+            Some("PATCH-TEXT".into()),
+            Some("b3:cafe".into()),
+            "2026-06-21",
+        )
+        .unwrap();
+        assert_eq!(swm.base.distro_version, "2026-06-21");
+        match &swm.mutations[0] {
+            Mutation::SourcePatch {
+                repo, commit, tarball, patch, build, target_bin, expected_hash, strip_components, ..
+            } => {
+                assert_eq!(repo.as_deref(), Some("https://github.com/BurntSushi/ripgrep"));
+                assert_eq!(commit.as_deref(), Some("4649aa9700619f94cf9c66876e9549d83420e16c"));
+                assert!(tarball.is_none());
+                assert_eq!(patch.as_deref(), Some("PATCH-TEXT"));
+                assert_eq!(target_bin, "/usr/bin/rg");
+                assert_eq!(expected_hash.as_deref(), Some("b3:cafe"));
+                assert!(strip_components.is_none(), "git no lleva strip_components");
+                assert_eq!(build.compiler, "zig-cc");
+                assert_eq!(build.link, "static");
+                assert_eq!(build.flags, vec!["--bin".to_string(), "rg".to_string()]);
+                assert_eq!(build.phases.install.as_deref(), Some("cp target/release/rg /out/usr/bin/rg"));
+            }
+            other => panic!("esperaba SourcePatch, obtuve {other:?}"),
+        }
+    }
+
+    #[test]
+    fn from_recipe_tarball_lleva_zig_version_y_strip() {
+        let toml = r#"
+name = "openssl"
+version = "3.5.4"
+[source]
+tarball = "https://example/openssl-3.5.4.tar.gz"
+sha256  = "deadbeef"
+strip_components = 2
+[build]
+compiler = "zig-cc"
+zig_version = "0.13.0"
+"#;
+        let recipe = crate::Recipe::from_toml(toml).unwrap();
+        let swm = Swm::from_recipe(&recipe, "/usr/bin/openssl", None, None, "dev").unwrap();
+        match &swm.mutations[0] {
+            Mutation::SourcePatch { tarball, sha256, strip_components, build, expected_hash, .. } => {
+                assert_eq!(tarball.as_deref(), Some("https://example/openssl-3.5.4.tar.gz"));
+                assert_eq!(sha256.as_deref(), Some("deadbeef"));
+                assert_eq!(*strip_components, Some(2), "strip != 1 debe viajar");
+                assert_eq!(build.zig_version.as_deref(), Some("0.13.0"));
+                assert!(expected_hash.is_none());
+            }
+            other => panic!("esperaba SourcePatch, obtuve {other:?}"),
+        }
+    }
+
     #[test]
     fn verify_schema_source_patch_rechaza_sin_origen() {
         let m = Mutation::SourcePatch {
@@ -548,6 +711,7 @@ mutations:
             commit: None,
             tarball: None,
             sha256: None,
+            strip_components: None,
             patch: None,
             patch_url: None,
             build: SwmBuild {
@@ -555,6 +719,8 @@ mutations:
                 target: "x86_64-linux-musl".into(),
                 link: "static".into(),
                 flags: vec![],
+                phases: Default::default(),
+                zig_version: None,
             },
             target_bin: "/bin/x".into(),
             expected_hash: None,
diff --git a/crates/hammerd/src/bus.rs b/crates/hammerd/src/bus.rs
index b93ea063..93ab6089 100644
--- a/crates/hammerd/src/bus.rs
+++ b/crates/hammerd/src/bus.rs
@@ -365,6 +365,7 @@ fn run_compile(recipe: RecipeInline, store_root: PathBuf, tx: Sender<Event>) {
         commit: recipe.commit,
         tarball: recipe.tarball,
         sha256: recipe.sha256,
+        strip_components: None,
         patch: recipe.patch,
         patch_url: None,
         build: hammer_core::swm::SwmBuild {
@@ -372,6 +373,8 @@ fn run_compile(recipe: RecipeInline, store_root: PathBuf, tx: Sender<Event>) {
             target: recipe.target,
             link: recipe.link,
             flags: recipe.flags,
+            phases: Default::default(),
+            zig_version: None,
         },
         target_bin: format!("/usr/bin/{name}"),
         expected_hash: None,
diff --git a/docs/06-swm-format.md b/docs/06-swm-format.md
index aaa19129..45948037 100644
--- a/docs/06-swm-format.md
+++ b/docs/06-swm-format.md
@@ -126,5 +126,14 @@ CLI:
   con `--base-ref`, la compatibilidad de base.
 - `hammer export --journal DIR > out.swm` — lee el diario y emite un `.swm` con un
   `file_drop` por archivo modificado (estado final actual; los `Delete` se omiten). El
-  receptor reproduce byte-a-byte; la provenance vía `source_patch` queda para más adelante
-  (necesita un mapa artefacto→receta).
+  receptor reproduce byte-a-byte. (Cuando el diario tiene provenance de receta, `export`
+  emite `source_patch` en vez de `file_drop` — el mapa artefacto→receta ya existe.)
+- `hammer pack <recipe.toml>` — **dirección forward (Etapa F):** empaqueta una receta del
+  corpus como un `.swm` de un único `source_patch`. Vuelve una receta que el sistema ya sabe
+  construir un paquete distribuible y reproducible-desde-fuente; es la inversa de `apply`.
+  `--target-bin` fija el ancla de sanity (default `/usr/bin/<name>`); `--expected b3:…` o
+  `--build` sellan el `expected_hash` (ancla "verificar, no confiar"); `--sign KEY` lo firma.
+  El `source_patch` traslada con fidelidad `build.{phases,zig_version,flags}` y, en tarball,
+  `strip_components`; los patches de la receta viajan inline (concatenados). **Aún no modela
+  `deps`** (resolución entre paquetes = pieza posterior): si la receta declara build/runtime
+  deps, `pack` lo advierte y el receptor debe tenerlas ya en su store para reproducir.