gioser

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 1572Shell 203Rust 109Markdown 993728 Definiciones

feat(swm): source_patch modela tarballs además de git (Fase 4)

Mutation::SourcePatch y RecipeInline ganan campos repo/commit XOR tarball/sha256
(Option, serde-default ⇒ compat con .swm git existentes), resueltos por
swm::swm_source_kind con la misma regla que recipe::Source::kind. swm_bridge
sintetiza la receta según el modo; hammer export reconstruye fuentes tarball
como source_patch en vez de caer a file_drop (provenance fina recuperada).
Prompt del traductor y roadmap actualizados. Tests nuevos para tarball + XOR.

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

0f42c38106e4e3202ae1005b89f8bfc7415b7f69 · por Sergio · (hace 4 meses) · padre 7c099afe4 · ver el árbol en este commit

Delta semántico · 24

crates/hammer-agent/src/orchestrator.rs

crates/hammer-agent/src/translator_claude.rs

crates/hammer-agent/tests/client_against_stub_bus.rs

crates/hammer-build/src/swm_bridge.rs

crates/hammer-cli/src/main.rs

crates/hammer-core/src/proto.rs

crates/hammer-core/src/swm.rs

crates/hammerd/src/bus.rs

crates/hammerd/tests/bus_e2e.rs

10 archivos · +280 −83

diff --git a/crates/hammer-agent/src/orchestrator.rs b/crates/hammer-agent/src/orchestrator.rs
index 2d7cba8b..de4be4ce 100644
--- a/crates/hammer-agent/src/orchestrator.rs
+++ b/crates/hammer-agent/src/orchestrator.rs
@@ -154,6 +154,8 @@ impl<T: IntentTranslator> Orchestrator<T> {
                 Mutation::SourcePatch {
                     repo,
                     commit,
+                    tarball,
+                    sha256,
                     patch,
                     patch_url: _,
                     build,
@@ -176,6 +178,8 @@ impl<T: IntentTranslator> Orchestrator<T> {
                         name: name.clone(),
                         repo: repo.clone(),
                         commit: commit.clone(),
+                        tarball: tarball.clone(),
+                        sha256: sha256.clone(),
                         patch: patch.clone(),
                         compiler: build.compiler.clone(),
                         target: build.target.clone(),
@@ -567,8 +571,10 @@ mod tests {
             swm_version: 1,
             base: Base { distro_version: "2026-06-06".into(), pins: BTreeMap::new() },
             mutations: vec![Mutation::SourcePatch {
-                repo: "git://x".into(),
-                commit: "abc".into(),
+                repo: Some("git://x".into()),
+                commit: Some("abc".into()),
+                tarball: None,
+                sha256: None,
                 patch: None,
                 patch_url: None,
                 build: hammer_core::swm::SwmBuild {
diff --git a/crates/hammer-agent/src/translator_claude.rs b/crates/hammer-agent/src/translator_claude.rs
index 0f146d2f..7a033ff6 100644
--- a/crates/hammer-agent/src/translator_claude.rs
+++ b/crates/hammer-agent/src/translator_claude.rs
@@ -146,6 +146,8 @@ const SYSTEM_PROMPT: &str = r#"Eres un traductor que convierte intenciones human
 
 3. source_patch — compila desde fuente y la inyecta.
    {"type":"source_patch","repo":"git://...","commit":"<sha>","build":{"compiler":"zig-cc","target":"x86_64-linux-musl","link":"static","flags":[]},"target_bin":"/usr/bin/foo"}
+   - Origen git (repo+commit) O tarball (tarball+sha256), exactamente uno; p. ej.
+     {"type":"source_patch","tarball":"https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz","sha256":"<hex>","build":{...},"target_bin":"/usr/bin/grep"}
    - `target_bin` ruta absoluta del binario producido.
 
 4. init_rule — registra un servicio.
diff --git a/crates/hammer-agent/tests/client_against_stub_bus.rs b/crates/hammer-agent/tests/client_against_stub_bus.rs
index 000580e4..009530be 100644
--- a/crates/hammer-agent/tests/client_against_stub_bus.rs
+++ b/crates/hammer-agent/tests/client_against_stub_bus.rs
@@ -84,7 +84,10 @@ fn stub_serve(stream: UnixStream, rx_async: std::sync::mpsc::Receiver<Event>) {
         let response = match cmd {
             Command::Compile { recipe } => Event::BuildReady {
                 recipe: recipe.name.clone(),
-                artifact: format!("b3:stub-{}", recipe.commit),
+                artifact: format!(
+                    "b3:stub-{}",
+                    recipe.commit.as_deref().or(recipe.sha256.as_deref()).unwrap_or("nosrc")
+                ),
             },
             Command::Query { what, .. } => Event::QueryResult {
                 what,
@@ -133,8 +136,10 @@ fn client_handshake_compile_and_async_modified() {
 
     let recipe = RecipeInline {
         name: "grep".into(),
-        repo: "git://example/grep.git".into(),
-        commit: "abc123".into(),
+        repo: Some("git://example/grep.git".into()),
+        commit: Some("abc123".into()),
+        tarball: None,
+        sha256: None,
         patch: None,
         compiler: "zig-cc".into(),
         target: "x86_64-linux-musl".into(),
diff --git a/crates/hammer-build/src/swm_bridge.rs b/crates/hammer-build/src/swm_bridge.rs
index f88cf643..511dce87 100644
--- a/crates/hammer-build/src/swm_bridge.rs
+++ b/crates/hammer-build/src/swm_bridge.rs
@@ -27,33 +27,54 @@ pub fn build_source_patch(
     store: &Store,
     scratch_root: Option<&Path>,
 ) -> hammer_core::Result<ArtifactHash> {
-    let (repo, commit, patch, patch_url, build_cfg, target_bin, expected) = match mutation {
-        Mutation::SourcePatch {
-            repo,
-            commit,
-            patch,
-            patch_url,
-            build,
-            target_bin,
-            expected_hash,
-        } => (repo, commit, patch, patch_url, build, target_bin, expected_hash),
-        _ => {
-            return Err(hammer_core::Error::Recipe(
-                "build_source_patch: la mutación no es 'source_patch'".into(),
-            ));
-        }
-    };
+    let (repo, commit, tarball, sha256, patch, patch_url, build_cfg, target_bin, expected) =
+        match mutation {
+            Mutation::SourcePatch {
+                repo,
+                commit,
+                tarball,
+                sha256,
+                patch,
+                patch_url,
+                build,
+                target_bin,
+                expected_hash,
+            } => (
+                repo, commit, tarball, sha256, patch, patch_url, build, target_bin, expected_hash,
+            ),
+            _ => {
+                return Err(hammer_core::Error::Recipe(
+                    "build_source_patch: la mutación no es 'source_patch'".into(),
+                ));
+            }
+        };
+
+    // git xor tarball — misma regla que el schema (`swm_source_kind`).
+    let source_kind = hammer_core::swm::swm_source_kind(
+        repo.as_deref(),
+        commit.as_deref(),
+        tarball.as_deref(),
+        sha256.as_deref(),
+    )
+    .map_err(hammer_core::Error::Recipe)?;
 
     let scratch = scratch_root.unwrap_or(&cfg.work_root).to_path_buf();
     let recipe_dir = scratch.join("swm-recipes");
     std::fs::create_dir_all(&recipe_dir)?;
 
-    // Materializamos el patch a disco bajo un nombre derivado del commit (estable: si el .swm
-    // se reaplica, reusamos el mismo archivo y el hash de la receta no depende de
+    // Clave estable del origen para nombrar artefactos a disco: el commit (git) o el
+    // sha256 (tarball). Idéntica reaplicación ⇒ mismo nombre ⇒ hash de receta determinista.
+    let source_key: &str = match &source_kind {
+        hammer_core::SourceKind::Git { commit, .. } => commit,
+        hammer_core::SourceKind::Tarball { sha256, .. } => sha256,
+    };
+
+    // Materializamos el patch a disco bajo un nombre derivado de la clave de origen (estable:
+    // si el .swm se reaplica, reusamos el mismo archivo y el hash de la receta no depende de
     // aleatoriedad). Origen: inline (`patch`) o remoto (`patch_url`); el schema garantiza que
     // no vengan ambos.
     let mut patches: Vec<String> = Vec::new();
-    let patch_path = recipe_dir.join(format!("{commit}.patch"));
+    let patch_path = recipe_dir.join(format!("{source_key}.patch"));
     if let Some(text) = patch {
         std::fs::write(&patch_path, text.as_bytes())?;
         patches.push(patch_path.to_string_lossy().into_owned());
@@ -64,14 +85,7 @@ pub fn build_source_patch(
     }
 
     let name = derive_name(target_bin);
-    let recipe = synthesize_recipe(
-        &name,
-        commit,
-        repo,
-        build_cfg,
-        patches,
-        &recipe_dir,
-    )?;
+    let recipe = synthesize_recipe(&name, &source_kind, build_cfg, patches, &recipe_dir)?;
 
     let hash = build(&recipe, cfg, store)?;
     if let Some(want) = expected {
@@ -102,24 +116,33 @@ fn derive_name(target_bin: &str) -> String {
 
 fn synthesize_recipe(
     name: &str,
-    commit: &str,
-    repo: &str,
+    source: &hammer_core::SourceKind<'_>,
     build_cfg: &SwmBuild,
     patches: Vec<String>,
     base_dir: &Path,
 ) -> hammer_core::Result<Recipe> {
     let compiler = parse_compiler(&build_cfg.compiler)?;
     let link = parse_link(&build_cfg.link)?;
+    // Sección `[source]` y sufijo de versión según el modo de origen. El validador de
+    // `Source::kind` ya garantiza git xor tarball; acá sólo emitimos el TOML correspondiente.
+    let (version_key, source_block) = match source {
+        hammer_core::SourceKind::Git { repo, commit } => (
+            *commit,
+            format!("repo   = \"{repo}\"\ncommit = \"{commit}\"\n"),
+        ),
+        hammer_core::SourceKind::Tarball { url, sha256 } => (
+            *sha256,
+            format!("tarball = \"{url}\"\nsha256  = \"{sha256}\"\n"),
+        ),
+    };
     // Construimos la receta vía TOML para reusar las defaults y el validador de `Source`.
     let toml_text = format!(
         r#"
 name = "{name}"
-version = "swm-{commit_short}"
+version = "swm-{version_short}"
 
 [source]
-repo   = "{repo}"
-commit = "{commit}"
-
+{source_block}
 [build]
 compiler = "{compiler}"
 target   = "{target}"
@@ -127,9 +150,8 @@ link     = "{link}"
 flags    = []
 "#,
         name = name,
-        commit_short = &commit[..commit.len().min(12)],
-        repo = repo,
-        commit = commit,
+        version_short = &version_key[..version_key.len().min(12)],
+        source_block = source_block,
         compiler = compiler.as_str(),
         target = build_cfg.target,
         link = link.as_str(),
@@ -199,8 +221,10 @@ mod tests {
         let d = tempfile::tempdir().unwrap();
         let r = synthesize_recipe(
             "grep",
-            "a1b2c3d4e5f6a7b8c9",
-            "git://example/grep.git",
+            &hammer_core::SourceKind::Git {
+                repo: "git://example/grep.git",
+                commit: "a1b2c3d4e5f6a7b8c9",
+            },
             &fake_swm_build(),
             vec![],
             d.path(),
@@ -218,6 +242,31 @@ mod tests {
         }
     }
 
+    #[test]
+    fn synthesize_recipe_tarball() {
+        let d = tempfile::tempdir().unwrap();
+        let r = synthesize_recipe(
+            "grep",
+            &hammer_core::SourceKind::Tarball {
+                url: "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz",
+                sha256: "abcdef0123456789",
+            },
+            &fake_swm_build(),
+            vec![],
+            d.path(),
+        )
+        .unwrap();
+        assert_eq!(r.name, "grep");
+        assert!(r.version.starts_with("swm-abcdef012345"));
+        match r.source.kind().unwrap() {
+            hammer_core::SourceKind::Tarball { url, sha256 } => {
+                assert_eq!(url, "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz");
+                assert_eq!(sha256, "abcdef0123456789");
+            }
+            _ => panic!("modo tarball esperado"),
+        }
+    }
+
     #[test]
     fn parse_compiler_and_link() {
         assert_eq!(parse_compiler("zig-cc").unwrap(), Compiler::ZigCc);
@@ -249,8 +298,10 @@ mod tests {
         std::fs::write(&patch_src, b"--- a\n+++ b\n").unwrap();
         let commit = "deadbeefcafe";
         let m = Mutation::SourcePatch {
-            repo: "git://x".into(),
-            commit: commit.into(),
+            repo: Some("git://x".into()),
+            commit: Some(commit.into()),
+            tarball: None,
+            sha256: 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 7f69d9fb..3a208779 100644
--- a/crates/hammer-cli/src/main.rs
+++ b/crates/hammer-cli/src/main.rs
@@ -1054,25 +1054,14 @@ fn build_export_mutations(
             .map(|p| p.display().to_string())
             .unwrap_or_else(|| "/".into());
 
-        // Convertimos la Recipe a los campos que SourcePatch necesita.
-        let (repo, commit) = match recipe.source.kind() {
+        // Convertimos la Recipe a los campos de origen que SourcePatch necesita
+        // (git xor tarball — ambos se modelan ya, sin caer a file_drop).
+        let (repo, commit, tarball, sha256) = match recipe.source.kind() {
             Ok(hammer_core::SourceKind::Git { repo, commit }) => {
-                (repo.to_string(), commit.to_string())
+                (Some(repo.to_string()), Some(commit.to_string()), None, None)
             }
             Ok(hammer_core::SourceKind::Tarball { url, sha256 }) => {
-                // SourcePatch sólo modela el modo git. Para tarballs caemos al
-                // fallback file_drop por evento — no perdemos completeness, sólo
-                // provenance fina. El humano lo ve por stderr.
-                eprintln!(
-                    "warning: artefacto {art_hash} proviene de tarball ({url} sha256={sha256}); \
-                     `source_patch` aún no modela tarballs, hago file_drop"
-                );
-                for p in &paths {
-                    if let Some(ev) = last_by_path.get(p) {
-                        fallback.push((p.clone(), *ev));
-                    }
-                }
-                continue;
+                (None, None, Some(url.to_string()), Some(sha256.to_string()))
             }
             Err(_) => {
                 stats.warnings += 1;
@@ -1105,6 +1094,8 @@ fn build_export_mutations(
         mutations.push(hammer_core::Mutation::SourcePatch {
             repo,
             commit,
+            tarball,
+            sha256,
             patch: patch_inline,
             patch_url: None,
             build: hammer_core::SwmBuild {
@@ -1613,8 +1604,8 @@ flags    = ["--enable-foo"]
                 expected_hash,
                 ..
             } => {
-                assert_eq!(repo, "git://example/grep.git");
-                assert_eq!(commit, "deadbeef");
+                assert_eq!(repo.as_deref(), Some("git://example/grep.git"));
+                assert_eq!(commit.as_deref(), Some("deadbeef"));
                 assert_eq!(build.compiler, "zig-cc");
                 assert_eq!(build.link, "static");
                 assert_eq!(build.flags, vec!["--enable-foo".to_string()]);
diff --git a/crates/hammer-core/src/proto.rs b/crates/hammer-core/src/proto.rs
index 7afa8afa..d6733efd 100644
--- a/crates/hammer-core/src/proto.rs
+++ b/crates/hammer-core/src/proto.rs
@@ -63,8 +63,17 @@ pub enum Command {
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 pub struct RecipeInline {
     pub name: String,
-    pub repo: String,
-    pub commit: String,
+    // Origen git (modo histórico) **o** tarball — exactamente uno, igual que
+    // `swm::Mutation::SourcePatch`. `Option` con `serde(default)` para compat con clientes
+    // que sólo mandan `repo`+`commit`.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub repo: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub commit: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub tarball: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub sha256: Option<String>,
     #[serde(default, skip_serializing_if = "Option::is_none")]
     pub patch: Option<String>,
     #[serde(default = "default_compiler")]
@@ -208,8 +217,10 @@ mod tests {
         let c = Command::Compile {
             recipe: RecipeInline {
                 name: "grep".into(),
-                repo: "git://x/grep.git".into(),
-                commit: "abc".into(),
+                repo: Some("git://x/grep.git".into()),
+                commit: Some("abc".into()),
+                tarball: None,
+                sha256: None,
                 patch: None,
                 compiler: "zig-cc".into(),
                 target: "x86_64-linux-musl".into(),
diff --git a/crates/hammer-core/src/swm.rs b/crates/hammer-core/src/swm.rs
index da39ef41..3cd9a439 100644
--- a/crates/hammer-core/src/swm.rs
+++ b/crates/hammer-core/src/swm.rs
@@ -70,8 +70,19 @@ pub struct PinDiff {
 #[serde(tag = "type", rename_all = "snake_case")]
 pub enum Mutation {
     SourcePatch {
-        repo: String,
-        commit: String,
+        // Origen git (modo histórico) **o** tarball — exactamente uno, igual que
+        // [`crate::recipe::Source`]. Antes sólo se modelaba git y los tarballs caían a
+        // `file_drop`; ahora `hammer export` los reconstruye como source_patch. Los campos
+        // son `Option` (con `serde(default)`) para que los `.swm` git existentes —que sólo
+        // traen `repo`+`commit`— sigan parseando sin cambios.
+        #[serde(default, skip_serializing_if = "Option::is_none")]
+        repo: Option<String>,
+        #[serde(default, skip_serializing_if = "Option::is_none")]
+        commit: Option<String>,
+        #[serde(default, skip_serializing_if = "Option::is_none")]
+        tarball: Option<String>,
+        #[serde(default, skip_serializing_if = "Option::is_none")]
+        sha256: Option<String>,
         #[serde(default, skip_serializing_if = "Option::is_none")]
         patch: Option<String>,
         #[serde(default, skip_serializing_if = "Option::is_none")]
@@ -200,18 +211,58 @@ impl Swm {
     }
 }
 
+/// Resuelve el modo de origen de un `source_patch` (git **xor** tarball) desde sus campos
+/// opcionales, con la misma regla que [`crate::recipe::Source::kind`]. Centraliza la
+/// validación para que `verify_schema`, el lab y el export compartan una sola fuente de verdad.
+pub fn swm_source_kind<'a>(
+    repo: Option<&'a str>,
+    commit: Option<&'a str>,
+    tarball: Option<&'a str>,
+    sha256: Option<&'a str>,
+) -> Result<crate::recipe::SourceKind<'a>, String> {
+    use crate::recipe::SourceKind;
+    match (repo, commit, tarball, sha256) {
+        (Some(repo), Some(commit), None, None) => Ok(SourceKind::Git { repo, commit }),
+        (None, None, Some(url), Some(sha256)) => Ok(SourceKind::Tarball { url, sha256 }),
+        (Some(_), Some(_), Some(_), _) | (Some(_), Some(_), _, Some(_)) => {
+            Err("source_patch: usa repo+commit O tarball+sha256, no ambos".into())
+        }
+        _ => Err("source_patch: faltan campos; necesito (repo+commit) o (tarball+sha256)".into()),
+    }
+}
+
 impl Mutation {
     /// Sanity por mutación. Cada `type` tiene precondiciones distintas; la validación cruzada
     /// (p. ej. "el `target_bin` apunta a un dir gestionado") la decide quien aplica, no el
     /// schema.
     pub fn verify_schema(&self) -> Result<(), String> {
+        use crate::recipe::SourceKind;
         match self {
-            Mutation::SourcePatch { repo, commit, patch, patch_url, target_bin, .. } => {
-                if repo.is_empty() {
-                    return Err("source_patch: 'repo' vacío".into());
-                }
-                if commit.is_empty() {
-                    return Err("source_patch: 'commit' vacío".into());
+            Mutation::SourcePatch {
+                repo, commit, tarball, sha256, patch, patch_url, target_bin, ..
+            } => {
+                match swm_source_kind(
+                    repo.as_deref(),
+                    commit.as_deref(),
+                    tarball.as_deref(),
+                    sha256.as_deref(),
+                )? {
+                    SourceKind::Git { repo, commit } => {
+                        if repo.is_empty() {
+                            return Err("source_patch: 'repo' vacío".into());
+                        }
+                        if commit.is_empty() {
+                            return Err("source_patch: 'commit' vacío".into());
+                        }
+                    }
+                    SourceKind::Tarball { url, sha256 } => {
+                        if url.is_empty() {
+                            return Err("source_patch: 'tarball' vacío".into());
+                        }
+                        if sha256.is_empty() {
+                            return Err("source_patch: 'sha256' vacío".into());
+                        }
+                    }
                 }
                 if patch.is_some() && patch_url.is_some() {
                     return Err(
@@ -422,8 +473,10 @@ mutations:
             swm_version: 1,
             base: base_v("x", &[]),
             mutations: vec![Mutation::SourcePatch {
-                repo: "git://x".into(),
-                commit: "abc".into(),
+                repo: Some("git://x".into()),
+                commit: Some("abc".into()),
+                tarball: None,
+                sha256: None,
                 patch: None,
                 patch_url: None,
                 build: SwmBuild {
@@ -439,4 +492,74 @@ mutations:
         };
         swm.verify_schema().unwrap();
     }
+
+    #[test]
+    fn verify_schema_source_patch_tarball() {
+        let swm = Swm {
+            swm_version: 1,
+            base: base_v("x", &[]),
+            mutations: vec![Mutation::SourcePatch {
+                repo: None,
+                commit: None,
+                tarball: Some("https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz".into()),
+                sha256: Some("deadbeef".into()),
+                patch: None,
+                patch_url: None,
+                build: SwmBuild {
+                    compiler: "zig-cc".into(),
+                    target: "x86_64-linux-musl".into(),
+                    link: "static".into(),
+                    flags: vec![],
+                },
+                target_bin: "/bin/grep".into(),
+                expected_hash: None,
+            }],
+            signature: None,
+        };
+        swm.verify_schema().unwrap();
+    }
+
+    #[test]
+    fn verify_schema_source_patch_rechaza_git_y_tarball_mezclados() {
+        let m = Mutation::SourcePatch {
+            repo: Some("git://x".into()),
+            commit: Some("abc".into()),
+            tarball: Some("https://x/a.tar.gz".into()),
+            sha256: Some("deadbeef".into()),
+            patch: None,
+            patch_url: None,
+            build: SwmBuild {
+                compiler: "zig-cc".into(),
+                target: "x86_64-linux-musl".into(),
+                link: "static".into(),
+                flags: vec![],
+            },
+            target_bin: "/bin/x".into(),
+            expected_hash: None,
+        };
+        let err = m.verify_schema().unwrap_err();
+        assert!(err.contains("no ambos"), "mensaje inesperado: {err}");
+    }
+
+    #[test]
+    fn verify_schema_source_patch_rechaza_sin_origen() {
+        let m = Mutation::SourcePatch {
+            repo: None,
+            commit: None,
+            tarball: None,
+            sha256: None,
+            patch: None,
+            patch_url: None,
+            build: SwmBuild {
+                compiler: "zig-cc".into(),
+                target: "x86_64-linux-musl".into(),
+                link: "static".into(),
+                flags: vec![],
+            },
+            target_bin: "/bin/x".into(),
+            expected_hash: None,
+        };
+        let err = m.verify_schema().unwrap_err();
+        assert!(err.contains("faltan campos"), "mensaje inesperado: {err}");
+    }
 }
diff --git a/crates/hammerd/src/bus.rs b/crates/hammerd/src/bus.rs
index 7a538f81..0f2e98f6 100644
--- a/crates/hammerd/src/bus.rs
+++ b/crates/hammerd/src/bus.rs
@@ -362,6 +362,8 @@ fn run_compile(recipe: RecipeInline, store_root: PathBuf, tx: Sender<Event>) {
     let mutation = hammer_core::swm::Mutation::SourcePatch {
         repo: recipe.repo,
         commit: recipe.commit,
+        tarball: recipe.tarball,
+        sha256: recipe.sha256,
         patch: recipe.patch,
         patch_url: None,
         build: hammer_core::swm::SwmBuild {
@@ -493,8 +495,10 @@ caps = ["query", "compile", "inject", "inject-real", "init"]
             t_of(&Command::Compile {
                 recipe: RecipeInline {
                     name: "x".into(),
-                    repo: "g".into(),
-                    commit: "c".into(),
+                    repo: Some("g".into()),
+                    commit: Some("c".into()),
+                    tarball: None,
+                    sha256: None,
                     patch: None,
                     compiler: "zig-cc".into(),
                     target: "t".into(),
diff --git a/crates/hammerd/tests/bus_e2e.rs b/crates/hammerd/tests/bus_e2e.rs
index 6a80c8b0..12799879 100644
--- a/crates/hammerd/tests/bus_e2e.rs
+++ b/crates/hammerd/tests/bus_e2e.rs
@@ -138,8 +138,10 @@ fn missing_cap_returns_error_no_cap() {
         &Command::Compile {
             recipe: RecipeInline {
                 name: "x".into(),
-                repo: "git://x".into(),
-                commit: "abc".into(),
+                repo: Some("git://x".into()),
+                commit: Some("abc".into()),
+                tarball: None,
+                sha256: None,
                 patch: None,
                 compiler: "zig-cc".into(),
                 target: "x86_64-linux-musl".into(),
diff --git a/docs/10-roadmap.md b/docs/10-roadmap.md
index 4cd1f8f5..af9c1bc9 100644
--- a/docs/10-roadmap.md
+++ b/docs/10-roadmap.md
@@ -94,8 +94,10 @@ pre-requisito de validación.
 - [x] Provenance en `export`: mapa artefacto→receta vía sidecar `.hammer/recipe.toml` que
       `hammer-build::build` escribe dentro del artefacto antes de sellar. `hammer export`
       agrupa eventos por `artifact_hash` y emite UN `source_patch` por grupo cuya receta
-      sea recuperable; lo demás cae al fallback `file_drop`. Source `git` modelado;
-      `tarball` cae a file_drop con warning (pendiente extender SourcePatch).
+      sea recuperable; lo demás cae al fallback `file_drop`. Source `git` **y `tarball`**
+      modelados: `Mutation::SourcePatch` (y `RecipeInline`) llevan campos `repo`/`commit`
+      **xor** `tarball`/`sha256` (resueltos por `swm::swm_source_kind`, misma regla que
+      `recipe::Source::kind`); `hammer export` reconstruye ambos sin caer a `file_drop`.
 - [x] Firma `signature` (ed25519) y `TrustStore` local. `hammer_core::sign`: `KeyPair`
       (genera/carga/escribe claves), `TrustStore::load(dir)` (lee `*.ed25519.pub`),
       `Swm::verify_signature(&trust) → SigStatus` (`trusted`/`unknown-key`/`bad-sig`/