Add a per-repo cache directive
Build directories can't ride in on `mounts`: a missing mount source is
skipped silently, which for a cache reads as nothing but slow builds.
Ramekin owns the host path and creates it instead.

Assisted-by: Claude Opus 5 via Claude Code
change voqzttpvznumozmwuwqtnkozkspoqnkr
commit f8d095a161a9a3303835c18229a3cf3a501feb12
author Alpha Chen <alpha@kejadlen.dev>
date
parent nwzknyzx
diff --git a/README.md b/README.md
index a2958bb..8cc96d4 100644
--- a/README.md
+++ b/README.md
@@ -137,6 +137,30 @@ mounts {
 
 Session mounts (the workspace, agent state, the rendered prompt, the outbox) are forced and cannot be overridden from config.
 
+### Caches
+
+A `cache` is a writable directory ramekin creates per repo and keeps across sessions. Build tools are the reason it exists: without one, every session pays a cold build, and pointing the tool at the workspace instead means the container and the host fight over one build directory.
+
+```kdl
+cache {
+    // Cargo's build directory, shadowing the host's target/ inside the
+    // workspace, so plain `cargo build` finds it
+    target
+    // uv's cache, at an explicit container path
+    uv "~/.cache/uv"
+}
+```
+
+Each child node names a directory under `$XDG_DATA_HOME/ramekin/repos/<slug>/caches/`; its argument is the container path, resolved like a mount target (`~` is the container home, a relative path resolves against the workspace mount). Omit the argument and the name serves as the path — a bare `target` is the whole declaration for a build directory the tool already looks for in the workspace. Caches merge by name across layers, so a higher layer can retarget one, and two names claiming the same container path is an error.
+
+Unlike `mounts`, the host side isn't configurable and the directory is created rather than skipped when missing — a cache that silently failed to mount would look like nothing but slow builds. Caches are per repo because build directories can't be shared: tools take an exclusive lock on them for the duration of a build, and a toolchain change invalidates their contents wholesale. Download caches have the opposite shape — keyed by name and version, safe to share — so those belong in `mounts`:
+
+```kdl
+mounts {
+    "~/.cache/ramekin-rust/registry" target="/root/.cargo/registry" writable=#true
+}
+```
+
 ### Environment variables
 
 `env` has exactly one syntax: a block with one child node per variable. The single argument is the value; omit it to pass the host's value through at run time (the value never lands in config or the generated compose file). `env` merges per variable across layers, overlaying the active profile's env.
diff --git a/skills/ramekin/SKILL.md b/skills/ramekin/SKILL.md
index 1662ea4..5273aa5 100644
--- a/skills/ramekin/SKILL.md
+++ b/skills/ramekin/SKILL.md
@@ -112,3 +112,16 @@ while `df` inside the container shows plenty free on the overlay.
 Symptom: writes to the workspace fail mysteriously. Keep build
 artifacts on the container side — e.g.
 `CARGO_TARGET_DIR=/tmp/<something>` — which also speeds up builds.
+
+Better still, declare a `cache` in `.ramekin/config.kdl` and let ramekin
+keep the directory across sessions:
+
+```kdl
+cache {
+    target    # shadows the workspace's own target/
+}
+```
+
+A bare node uses its name as the container path, and relative paths
+resolve inside the workspace, so the build tool needs no configuring and
+the host's build directory stays untouched.
diff --git a/src/config.rs b/src/config.rs
index 7df9ac4..c90972d 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -164,6 +164,66 @@ impl ResolvedMount {
     }
 }
 
+/// A cache's name, checked to be a single path component.
+///
+/// The name is `join`ed onto this repo's `caches/` directory, so an unchecked
+/// one would let config write outside it. Parsing is the only way to build
+/// one, which keeps that guarantee structural rather than a convention the
+/// call site has to know about.
+#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
+pub struct CacheName(String);
+
+impl CacheName {
+    pub fn parse(name: &str) -> Result<Self> {
+        ensure!(!name.is_empty(), "a cache name can't be empty");
+        ensure!(
+            !name.contains('/'),
+            "cache `{name}`: a name is one directory under this repo's caches, \
+             so it can't contain `/`"
+        );
+        ensure!(
+            !name.starts_with('.'),
+            "cache `{name}`: a name can't start with `.`, which would hide the \
+             directory or escape the one above it"
+        );
+        Ok(Self(name.to_string()))
+    }
+
+    pub fn as_str(&self) -> &str {
+        &self.0
+    }
+}
+
+impl fmt::Display for CacheName {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "{}", self.0)
+    }
+}
+
+impl AsRef<Path> for CacheName {
+    fn as_ref(&self) -> &Path {
+        Path::new(&self.0)
+    }
+}
+
+/// A per-repo cache: a writable directory ramekin creates and keeps across
+/// sessions, mounted at `target` in the container.
+///
+/// Unlike a `mounts` entry, the host side isn't configurable — it's
+/// `$XDG_DATA_HOME/ramekin/repos/<slug>/caches/<name>`, created eagerly like
+/// the sessions and outbox dirs. That keeps caches per repo (build
+/// directories can't be shared: tools lock them, and a differing toolchain
+/// invalidates their contents) without a host path in config, and without
+/// the silent skip that a missing mount source gets.
+#[derive(Debug, PartialEq, Clone)]
+pub struct Cache {
+    /// Directory name on the host, under this repo's `caches/`.
+    pub name: CacheName,
+    /// Container path, already resolved: `~` is the container home and a
+    /// relative path resolves against the workspace mount.
+    pub target: String,
+}
+
 /// An environment variable for the container. `value: None` means the
 /// variable passes through from the host environment at run time.
 #[derive(Debug, PartialEq, Clone)]
@@ -181,6 +241,7 @@ pub struct ConfigLayer {
     pub path: Option<PathBuf>,
     pub mounts: Vec<ResolvedMount>,
     pub env: Vec<EnvVar>,
+    pub caches: Vec<Cache>,
 }
 
 /// A value tagged with the config scope it came from.
@@ -342,6 +403,7 @@ impl ScopedConfig {
             path: None,
             mounts: binary_mounts(profile.agent),
             env: Vec::new(),
+            caches: Vec::new(),
         }];
         layers.push(ConfigLayer {
             scope: Scope::Profile,
@@ -352,6 +414,7 @@ impl ScopedConfig {
                 .filter_map(|m| m.resolve(workspace_target))
                 .collect(),
             env: profile.env.clone(),
+            caches: Vec::new(),
         });
         layers.extend(builders.into_iter().map(LayerBuilder::build));
 
@@ -396,6 +459,41 @@ impl ScopedConfig {
             .collect()
     }
 
+    /// Merge caches from all layers, de-duplicated by name, so a higher layer
+    /// can retarget a cache a lower one declared. Two names pointing at the
+    /// same container path is a config error rather than a silent
+    /// last-writer-wins, since one of the two host directories would then
+    /// hold artifacts nothing ever reads.
+    pub fn merged_caches(&self) -> Result<Vec<ScopedValue<&Cache>>> {
+        let mut by_name: BTreeMap<&str, ScopedValue<&Cache>> = BTreeMap::new();
+        for layer in &self.layers {
+            for cache in &layer.caches {
+                by_name.insert(
+                    cache.name.as_str(),
+                    ScopedValue {
+                        scope: layer.scope,
+                        value: cache,
+                    },
+                );
+            }
+        }
+
+        let mut by_target: BTreeMap<&str, &str> = BTreeMap::new();
+        for sv in by_name.values() {
+            if let Some(other) = by_target.insert(sv.value.target.as_str(), sv.value.name.as_str())
+            {
+                bail!(
+                    "caches `{other}` and `{}` both mount at {}; give them separate paths \
+                     or drop one",
+                    sv.value.name,
+                    sv.value.target,
+                );
+            }
+        }
+
+        Ok(by_name.into_values().collect())
+    }
+
     /// Merge environment variables from all layers, de-duplicated by name.
     ///
     /// Higher-precedence layers override variables with the same name.
@@ -433,6 +531,8 @@ struct LayerBuilder {
     env_names: BTreeSet<String>,
     profiles: Vec<Profile>,
     profile_names: BTreeSet<String>,
+    caches: Vec<Cache>,
+    cache_names: BTreeSet<CacheName>,
     selection: Option<String>,
 }
 
@@ -447,6 +547,8 @@ impl LayerBuilder {
             env_names: BTreeSet::new(),
             profiles: Vec::new(),
             profile_names: BTreeSet::new(),
+            caches: Vec::new(),
+            cache_names: BTreeSet::new(),
             selection: None,
         }
     }
@@ -492,6 +594,19 @@ impl LayerBuilder {
             );
             self.profiles.push(profile);
         }
+        for cache in raw.caches {
+            ensure!(
+                self.cache_names.insert(cache.name.clone()),
+                "{}: cache `{}` is defined twice in the {} layer",
+                file.display(),
+                cache.name,
+                self.scope,
+            );
+            self.caches.push(Cache {
+                target: resolve_container_target(&cache.target, workspace_target),
+                ..cache
+            });
+        }
         for name in raw.selections {
             ensure!(
                 self.selection.is_none(),
@@ -510,6 +625,7 @@ impl LayerBuilder {
             path: self.path,
             mounts: self.mounts,
             env: self.env,
+            caches: self.caches,
         }
     }
 }
@@ -523,6 +639,7 @@ impl LayerBuilder {
 struct RawConfig {
     mounts: Vec<Mount>,
     env: Vec<EnvVar>,
+    caches: Vec<Cache>,
     profiles: Vec<Profile>,
     selections: Vec<String>,
 }
@@ -541,6 +658,7 @@ fn parse_config(content: &str) -> Result<RawConfig> {
         match node.name().value() {
             "mounts" => raw.mounts.extend(parse_mounts(node)?),
             "env" => raw.env.extend(parse_env(node)?),
+            "cache" => raw.caches.extend(parse_cache(node)?),
             // `profile "name" { ... }` defines; `profile "name"` selects.
             "profile" => match parse_profile(node)? {
                 ProfileNode::Definition(profile) => raw.profiles.push(profile),
@@ -729,6 +847,33 @@ fn parse_env(node: &KdlNode) -> Result<Vec<EnvVar>> {
         .collect()
 }
 
+/// Parse a `cache` block: one child node per cache, named by the host
+/// directory it gets under this repo's `caches/`, with the container path as
+/// its argument. A bare node takes the name as its path too, which for the
+/// common case (a build directory the tool looks for in the workspace) is
+/// the whole declaration: `cache { target }`.
+fn parse_cache(node: &KdlNode) -> Result<Vec<Cache>> {
+    ensure!(
+        node.entries().is_empty(),
+        "`cache` takes a block (cache {{ target }}), not inline values"
+    );
+
+    let Some(children) = node.children() else {
+        return Ok(Vec::new());
+    };
+    children
+        .nodes()
+        .iter()
+        .map(|child| {
+            let name = CacheName::parse(child.name().value())?;
+            Ok(Cache {
+                target: optional_string_arg(child)?.unwrap_or_else(|| name.to_string()),
+                name,
+            })
+        })
+        .collect()
+}
+
 /// The node's single string argument, required.
 fn single_string_arg(node: &KdlNode) -> Result<String> {
     optional_string_arg(node)?
@@ -1058,6 +1203,118 @@ mod tests {
         assert_eq!(resolved.target, "/tmp");
     }
 
+    #[test]
+    fn parse_cache_block() {
+        let raw = parse_config(
+            r#"
+            cache {
+                target "/root/target"
+                uv "~/.cache/uv"
+            }
+            "#,
+        )
+        .unwrap();
+        assert_eq!(
+            raw.caches,
+            vec![
+                Cache {
+                    name: cache_name("target"),
+                    target: "/root/target".into(),
+                },
+                Cache {
+                    name: cache_name("uv"),
+                    target: "~/.cache/uv".into(),
+                },
+            ]
+        );
+    }
+
+    /// Cache targets resolve like mount targets, so a relative one lands in
+    /// the workspace — shadowing the host's build dir with the container's.
+    #[test]
+    fn cache_target_resolves_against_the_workspace() {
+        let raw = parse_config(r#"cache { target "target" }"#).unwrap();
+        let mut builder = LayerBuilder::new(Scope::Project, None);
+        builder.add(raw, Path::new("c.kdl"), WS).unwrap();
+        let layer = builder.build();
+        assert_eq!(layer.caches[0].target, "/workspace/test-slug/target");
+    }
+
+    #[test]
+    fn cache_name_cannot_escape_its_directory() {
+        let err = parse_config(r#"cache { "../elsewhere" "/root/target" }"#).unwrap_err();
+        assert!(err.to_string().contains("can't contain `/`"), "{err}");
+
+        let err = parse_config(r#"cache { ".." "/root/target" }"#).unwrap_err();
+        assert!(err.to_string().contains("can't start with `.`"), "{err}");
+    }
+
+    #[test]
+    fn cache_name_cannot_be_empty() {
+        let err = CacheName::parse("").unwrap_err();
+        assert!(err.to_string().contains("can't be empty"), "{err}");
+    }
+
+    /// A bare node names a directory in the workspace, so `cache { target }`
+    /// covers a build dir the tool already looks for there.
+    #[test]
+    fn bare_cache_takes_its_name_as_the_path() {
+        let raw = parse_config(r#"cache { target }"#).unwrap();
+        assert_eq!(
+            raw.caches,
+            vec![Cache {
+                name: cache_name("target"),
+                target: "target".into(),
+            }]
+        );
+
+        let mut builder = LayerBuilder::new(Scope::Project, None);
+        builder.add(raw, Path::new("c.kdl"), WS).unwrap();
+        assert_eq!(
+            builder.build().caches[0].target,
+            "/workspace/test-slug/target"
+        );
+    }
+
+    #[test]
+    fn merged_caches_deduplicates_by_name() {
+        let cache = |name: &str, target: &str| Cache {
+            name: cache_name(name),
+            target: target.into(),
+        };
+        let user = ConfigLayer {
+            caches: vec![cache("target", "/root/target"), cache("uv", "/root/uv")],
+            ..layer(Scope::User, vec![])
+        };
+        let project = ConfigLayer {
+            caches: vec![cache("target", "/root/elsewhere")],
+            ..layer(Scope::Project, vec![])
+        };
+        let config = scoped(vec![user, project]);
+        let merged = config.merged_caches().unwrap();
+        assert_eq!(merged.len(), 2);
+        let target = merged
+            .iter()
+            .find(|sv| sv.value.name.as_str() == "target")
+            .unwrap();
+        assert_eq!(target.value.target, "/root/elsewhere");
+        assert_eq!(target.scope, Scope::Project);
+    }
+
+    #[test]
+    fn two_caches_at_one_target_is_an_error() {
+        let cache = |name: &str| Cache {
+            name: cache_name(name),
+            target: "/root/target".into(),
+        };
+        let user = ConfigLayer {
+            caches: vec![cache("a"), cache("b")],
+            ..layer(Scope::User, vec![])
+        };
+        let err = scoped(vec![user]).merged_caches().unwrap_err().to_string();
+        assert!(err.contains("both mount at /root/target"), "{err}");
+    }
+
     #[test]
     fn scope_display() {
         assert_eq!(Scope::Binary.to_string(), "binary");
@@ -1071,9 +1328,14 @@ mod tests {
             path: None,
             mounts,
             env: Vec::new(),
+            caches: Vec::new(),
         }
     }
 
+    fn cache_name(name: &str) -> CacheName {
+        CacheName::parse(name).unwrap()
+    }
+
     fn mount(source: &str, target: &str, writable: bool) -> ResolvedMount {
         ResolvedMount {
             source: PathBuf::from(source),
@@ -1262,6 +1524,7 @@ mod tests {
                     value: Some("user".into()),
                 },
             ],
+            caches: Vec::new(),
         };
         let project = ConfigLayer {
             scope: Scope::Project,
@@ -1271,6 +1534,7 @@ mod tests {
                 name: "FOO".into(),
                 value: Some("project".into()),
             }],
+            caches: Vec::new(),
         };
         let config = scoped(vec![user, project]);
         let merged = config.merged_env();
diff --git a/src/main.rs b/src/main.rs
index b80c258..b0c2004 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -527,6 +527,29 @@ impl Ramekin {
         })
     }
 
+    /// Host directory backing this repo's caches, one subdirectory per
+    /// configured `cache`.
+    fn repo_caches_dir(&self) -> PathBuf {
+        self.data_home
+            .join(format!("repos/{}/caches", self.repo_slug))
+    }
+
+    /// Mounts for the configured caches. Forced like the session mounts: the
+    /// container path comes from config, the host path never does.
+    fn cache_mounts(&self) -> Result<Vec<config::ResolvedMount>> {
+        let base = self.repo_caches_dir();
+        Ok(self
+            .config
+            .merged_caches()?
+            .into_iter()
+            .map(|sv| config::ResolvedMount {
+                source: base.join(&sv.value.name),
+                target: sv.value.target.clone(),
+                writable: true,
+            })
+            .collect())
+    }
+
     /// Session plumbing mounts shared by both agents: the rendered prompt,
     /// the outbox, and the workspace.
     fn session_mounts(&self, session_dir: &Path, outbox_dir: &Path) -> Vec<config::ResolvedMount> {
@@ -549,11 +572,12 @@ impl Ramekin {
         mounts
     }
 
-    /// Merge config mounts with the forced session mounts, ordered
-    /// lexicographically by target so parents precede children.
+    /// Merge config mounts with the forced ones (session plumbing and
+    /// caches), ordered lexicographically by target so parents precede
+    /// children.
     fn final_mounts<'a>(
         &'a self,
-        session_mounts: &'a [config::ResolvedMount],
+        forced: &'a [config::ResolvedMount],
     ) -> Vec<&'a config::ResolvedMount> {
         let mut by_target: BTreeMap<&str, &config::ResolvedMount> = self
             .config
@@ -561,7 +585,7 @@ impl Ramekin {
             .into_iter()
             .map(|sv| (sv.value.target.as_str(), sv.value))
             .collect();
-        for mount in session_mounts {
+        for mount in forced {
             by_target.insert(mount.target.as_str(), mount);
         }
         by_target.into_values().collect()
@@ -646,6 +670,25 @@ impl Ramekin {
             );
         }
 
+        // Caches
+        let caches = self.config.merged_caches()?;
+        if !caches.is_empty() {
+            println!();
+            println!("Caches");
+            let base = self.repo_caches_dir();
+            let scopes: BTreeSet<_> = caches.iter().map(|sv| sv.scope).collect();
+            for scope in scopes {
+                println!("  {}", scope_label(scope));
+                for sv in caches.iter().filter(|sv| sv.scope == scope) {
+                    println!(
+                        "    {} → {}",
+                        base.join(&sv.value.name).display(),
+                        sv.value.target
+                    );
+                }
+            }
+        }
+
         // Environment
         if !merged_env.is_empty() {
             println!();
@@ -766,8 +809,14 @@ impl Ramekin {
             outbox::create_session(&self.data_home, &self.repo_slug, &session_id, agent)
                 .wrap_err("failed to create session outbox")?;
 
-        let session_mounts = self.session_mounts(&session_dir, &outbox_dir);
-        let all_mounts = self.final_mounts(&session_mounts);
+        // Caches are created rather than skipped when absent: a cache that
+        // silently didn't mount would look like nothing but slow builds.
+        let mut forced_mounts = self.session_mounts(&session_dir, &outbox_dir);
+        for mount in self.cache_mounts()? {
+            fs_err::create_dir_all(&mount.source).into_diagnostic()?;
+            forced_mounts.push(mount);
+        }
+        let all_mounts = self.final_mounts(&forced_mounts);
         let env_vars = self.config.merged_env();
         let compose = generate_compose(ComposeParams {
             dockerfile: &dockerfile,