Add the outbox: the reviewed write path for shared config
Config being read-only inside the container needs a counterpart: a
deliberate channel for agents to propose config changes instead of
silently losing them. Each session mounts a fresh, empty outbox dir
(host: $XDG_DATA_HOME/ramekin/repos/<slug>/outbox/<session-id>/,
container: /root/.ramekin/outbox) — the only agent-writable path
outside the workspace and agent state mounts — and the system prompt
tells the agent to write complete updated files there, mirroring the
agent config layout.

Host side, `ramekin outbox` reviews proposals across all repos and
sessions: `list`, `diff` (difftastic, falling back to diff -u),
`apply` (after showing the diff and confirming; writes through
dotfiles symlinks so the change lands in the working copy), and
`discard`. A proposal maps back to a host source via the agent's
config allowlist plus an agent sidecar recorded outside the mount, so
a confused or malicious proposal can't redirect where apply writes;
anything unmapped needs an explicit --to. Empty outboxes vanish at
teardown; non-empty ones survive with a pointer to `ramekin outbox
list`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayyqfpg98CeZPmtMPXQEFo
change
commit 6901c1460f04be1d787ce6e7fb230a98998e9c00
author Claude <noreply@anthropic.com>
date
parent e2a02fce
diff --git a/assets/ramekin-prompt.md b/assets/ramekin-prompt.md
index 8c8efde..0fd9986 100644
--- a/assets/ramekin-prompt.md
+++ b/assets/ramekin-prompt.md
@@ -10,7 +10,9 @@ The project workspace is bind-mounted at `{{WORKSPACE_PATH}}` (the container sta
 
 The container filesystem is ephemeral. Any files written outside `{{WORKSPACE_PATH}}` will be lost when the session ends. System packages installed with `apt-get` do not persist across sessions — use a custom `.ramekin/Dockerfile` to add permanent dependencies.
 
-Agent configuration (`AGENTS.md`, `skills/`, and the like) is mounted read-only by design; edits to it inside the container fail. If a config change seems worthwhile, tell the user about it instead.
+## Proposing configuration changes
+
+Agent configuration (memory files like `AGENTS.md`/`CLAUDE.md`, `skills/`, settings) is mounted read-only by design; editing it in place fails. To propose a change, write the complete updated file into `/root/.ramekin/outbox/`, mirroring its layout relative to your config directory (for example, a change to `skills/foo/SKILL.md` goes to `/root/.ramekin/outbox/skills/foo/SKILL.md`), and tell the user what you proposed and why. The user reviews and applies proposals on the host with `ramekin outbox`.
 
 ## Networking
 
diff --git a/src/main.rs b/src/main.rs
index ef2f679..ef32e2b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,4 +1,5 @@
 mod config;
+mod outbox;
 
 use std::collections::{BTreeMap, BTreeSet};
 use std::io::Write;
@@ -56,6 +57,11 @@ enum Cmd {
     },
     /// Show resolved paths and mount configuration
     Config,
+    /// Review config changes proposed by agents
+    Outbox {
+        #[command(subcommand)]
+        command: OutboxCmd,
+    },
     /// Generate shell completions
     Completions {
         /// Shell to generate completions for
@@ -63,6 +69,33 @@ enum Cmd {
     },
 }
 
+#[derive(Subcommand)]
+enum OutboxCmd {
+    /// List pending proposals across all repos and sessions
+    List,
+    /// Diff proposals against the host config they were mounted from
+    Diff {
+        /// A single proposal (`<slug>/<session>/<path>`) or session
+        /// (`<slug>/<session>`); all proposals when omitted
+        entry: Option<String>,
+    },
+    /// Copy a proposal over its host source, after confirmation
+    Apply {
+        /// The proposal to apply (`<slug>/<session>/<path>`)
+        entry: String,
+        /// Destination for proposals that don't map back to an allowlisted
+        /// agent-config entry
+        #[arg(long)]
+        to: Option<PathBuf>,
+    },
+    /// Drop proposals without applying them
+    Discard {
+        /// A single proposal (`<slug>/<session>/<path>`) or a whole session
+        /// (`<slug>/<session>`)
+        entry: String,
+    },
+}
+
 fn main() -> Result<()> {
     miette::set_hook(Box::new(|_| {
         Box::new(miette::MietteHandlerOpts::new().build())
@@ -86,15 +119,132 @@ fn main() -> Result<()> {
         return Ok(());
     }
 
+    // Outbox review is host-global: it spans repos and needs no workspace,
+    // profile, or agent resolution.
+    if let Cmd::Outbox { command } = command {
+        return run_outbox(command);
+    }
+
     let ramekin = Ramekin::resolve(cli.workspace, cli.profile.as_deref())?;
 
     match command {
         Cmd::Run { rebuild } => ramekin.run(rebuild, &cli.agent_args),
         Cmd::Config => ramekin.config(),
-        Cmd::Completions { .. } => unreachable!(),
+        Cmd::Completions { .. } | Cmd::Outbox { .. } => unreachable!(),
     }
 }
 
+// ---------------------------------------------------------------------------
+// Outbox commands
+// ---------------------------------------------------------------------------
+
+fn run_outbox(command: OutboxCmd) -> Result<()> {
+    let data_home = xdg::BaseDirectories::with_prefix("ramekin")
+        .get_data_home()
+        .ok_or_else(|| miette!("could not determine XDG data home"))?;
+
+    match command {
+        OutboxCmd::List => {
+            let proposals = outbox::scan(&data_home)?;
+            if proposals.is_empty() {
+                println!("no pending proposals");
+                return Ok(());
+            }
+            for p in proposals {
+                match p.host_target() {
+                    Some(target) => println!("{} → {}", p.entry(), target.display()),
+                    None => println!("{} (no mapped target; apply needs --to)", p.entry()),
+                }
+            }
+        }
+        OutboxCmd::Diff { entry } => {
+            let proposals = match entry {
+                Some(entry) => outbox::find(&data_home, &entry)?,
+                None => outbox::scan(&data_home)?,
+            };
+            for p in proposals {
+                diff_proposal(&p, p.host_target().as_deref())?;
+            }
+        }
+        OutboxCmd::Apply { entry, to } => {
+            let proposals = outbox::find(&data_home, &entry)?;
+            let [proposal] = proposals.as_slice() else {
+                bail!(
+                    "`{entry}` matches {} proposals; apply one file at a time",
+                    proposals.len()
+                );
+            };
+            let target = to.or_else(|| proposal.host_target()).ok_or_else(|| {
+                miette!(
+                    "`{entry}` doesn't map back to an allowlisted agent-config entry; \
+                     pass an explicit destination with --to"
+                )
+            })?;
+
+            diff_proposal(proposal, Some(&target))?;
+            if !confirm(&format!("apply to {}?", target.display()))? {
+                println!("not applied");
+                return Ok(());
+            }
+
+            // Write through a symlinked host source (dotfiles), so the
+            // change lands in the dotfiles working copy, not over the link.
+            let dest = if target.exists() {
+                target.canonicalize().into_diagnostic()?
+            } else {
+                if let Some(parent) = target.parent() {
+                    fs_err::create_dir_all(parent).into_diagnostic()?;
+                }
+                target
+            };
+            fs_err::copy(&proposal.file, &dest).into_diagnostic()?;
+            outbox::remove(&data_home, proposal)?;
+            println!("applied to {}", dest.display());
+        }
+        OutboxCmd::Discard { entry } => {
+            for proposal in outbox::find(&data_home, &entry)? {
+                outbox::remove(&data_home, &proposal)?;
+                println!("discarded {}", proposal.entry());
+            }
+        }
+    }
+    Ok(())
+}
+
+/// Show a proposal's diff against its host source (difftastic when
+/// available, `diff -u` otherwise). A missing host source diffs against
+/// /dev/null, i.e. shows the whole proposal as new.
+fn diff_proposal(proposal: &outbox::Proposal, target: Option<&Path>) -> Result<()> {
+    println!("--- {}", proposal.entry());
+    let host: &Path = match target {
+        Some(t) if t.exists() => t,
+        _ => Path::new("/dev/null"),
+    };
+    let difft = Command::new("difft").arg(host).arg(&proposal.file).status();
+    if difft.is_err() {
+        // difftastic not installed; plain diff. Exit code 1 just means the
+        // files differ.
+        Command::new("diff")
+            .arg("-u")
+            .arg(host)
+            .arg(&proposal.file)
+            .status()
+            .into_diagnostic()
+            .wrap_err("failed to run diff")?;
+    }
+    Ok(())
+}
+
+/// Ask the user to confirm on stdin. Anything but `y`/`yes` is a no.
+fn confirm(prompt: &str) -> Result<bool> {
+    print!("{prompt} [y/N] ");
+    std::io::stdout().flush().into_diagnostic()?;
+    let mut answer = String::new();
+    std::io::stdin().read_line(&mut answer).into_diagnostic()?;
+    let answer = answer.trim().to_ascii_lowercase();
+    Ok(answer == "y" || answer == "yes")
+}
+
 // ---------------------------------------------------------------------------
 // AgentState
 // ---------------------------------------------------------------------------
@@ -299,6 +449,7 @@ struct Ramekin {
     workspace_target: String,
     repo_slug: String,
     xdg: xdg::BaseDirectories,
+    data_home: PathBuf,
     cache_dir: PathBuf,
     custom_dockerfile: Option<PathBuf>,
     config: config::ScopedConfig,
@@ -343,6 +494,7 @@ impl Ramekin {
             workspace_target,
             repo_slug,
             xdg,
+            data_home,
             cache_dir,
             custom_dockerfile,
             config,
@@ -350,15 +502,20 @@ impl Ramekin {
         })
     }
 
-    /// Session plumbing mounts shared by both agents: the rendered prompt
-    /// and the workspace.
-    fn session_mounts(&self, session_dir: &Path) -> Vec<config::ResolvedMount> {
+    /// 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> {
         let mut mounts = self.agent_state.mounts(session_dir);
         mounts.push(config::ResolvedMount {
             source: session_dir.join("ramekin-prompt.md"),
             target: PROMPT_TARGET.into(),
             writable: false,
         });
+        mounts.push(config::ResolvedMount {
+            source: outbox_dir.to_path_buf(),
+            target: outbox::OUTBOX_TARGET.into(),
+            writable: true,
+        });
         mounts.push(config::ResolvedMount {
             source: self.workspace.clone(),
             target: self.workspace_target.clone(),
@@ -453,9 +610,12 @@ impl Ramekin {
 
         // Session mounts (sources materialize per run; shown with a placeholder)
         let placeholder = self.cache_dir.join("sessions/<session>");
+        let outbox_placeholder = self
+            .data_home
+            .join(format!("repos/{}/outbox/<session>", self.repo_slug));
         println!();
         println!("Session mounts");
-        for mount in self.session_mounts(&placeholder) {
+        for mount in self.session_mounts(&placeholder, &outbox_placeholder) {
             println!(
                 "    {} → {}",
                 mount.source.display(),
@@ -587,7 +747,11 @@ impl Ramekin {
         let prompt = RAMEKIN_PROMPT.replace("{{WORKSPACE_PATH}}", &self.workspace_target);
         fs_err::write(session_dir.join("ramekin-prompt.md"), prompt).into_diagnostic()?;
 
-        let session_mounts = self.session_mounts(&session_dir);
+        let outbox_dir =
+            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);
         let env_vars = self.config.merged_env();
         let compose = generate_compose(ComposeParams {
@@ -689,6 +853,15 @@ impl Ramekin {
             }
         }
 
+        // A non-empty outbox survives teardown as pending proposals.
+        match outbox::finish_session(&self.data_home, &self.repo_slug, &session_id) {
+            Ok(0) => {}
+            Ok(pending) => {
+                info!("{pending} config proposal(s) pending — review with `ramekin outbox list`");
+            }
+            Err(e) => error!("failed to finalize session outbox: {e}"),
+        }
+
         if let Err(e) = fs_err::remove_dir_all(&session_dir) {
             error!("failed to clean up session dir: {e}");
         }
diff --git a/src/outbox.rs b/src/outbox.rs
new file mode 100644
index 0000000..cbac932
--- /dev/null
+++ b/src/outbox.rs
@@ -0,0 +1,336 @@
+//! The outbox: the one reviewed path for stateful modification of shared
+//! config.
+//!
+//! Config is read-only inside the container by design. When an agent wants
+//! a config change, it writes the changed file into its session's outbox
+//! dir (mounted writable at [`OUTBOX_TARGET`]), mirroring the agent config
+//! layout. Host side, `ramekin outbox` lists pending proposals, diffs them
+//! against the host source each entry was mounted from, and applies or
+//! discards them. Nothing reaches host config without an explicit apply.
+
+use std::path::{Component, Path, PathBuf};
+
+use miette::{IntoDiagnostic, Result, bail};
+
+use crate::config::Agent;
+
+/// Container path of the session's writable outbox dir — the only
+/// agent-writable path outside the workspace and the agent state mounts.
+pub const OUTBOX_TARGET: &str = "/root/.ramekin/outbox";
+
+/// Host paths for one session's outbox: the mounted dir and its sidecar
+/// metadata file recording which agent the session ran (the same relative
+/// path maps to different host config dirs per agent).
+fn session_paths(data_home: &Path, slug: &str, session_id: &str) -> (PathBuf, PathBuf) {
+    let outbox = data_home.join(format!("repos/{slug}/outbox"));
+    (
+        outbox.join(session_id),
+        outbox.join(format!("{session_id}.agent")),
+    )
+}
+
+/// Create a fresh, empty outbox dir for a session, plus its agent sidecar.
+/// The sidecar sits *beside* the mounted dir, out of the agent's reach, so
+/// a confused or malicious proposal can't redirect where apply maps it.
+pub fn create_session(
+    data_home: &Path,
+    slug: &str,
+    session_id: &str,
+    agent: Agent,
+) -> Result<PathBuf> {
+    let (dir, meta) = session_paths(data_home, slug, session_id);
+    fs_err::create_dir_all(&dir).into_diagnostic()?;
+    fs_err::write(&meta, agent.name()).into_diagnostic()?;
+    Ok(dir)
+}
+
+/// Session teardown: drop the outbox if the agent left nothing in it,
+/// keep it (returning the pending count) otherwise.
+pub fn finish_session(data_home: &Path, slug: &str, session_id: &str) -> Result<usize> {
+    let (dir, meta) = session_paths(data_home, slug, session_id);
+    let mut files = Vec::new();
+    collect_files(&dir, &dir, &mut files)?;
+    if files.is_empty() {
+        fs_err::remove_dir_all(&dir).into_diagnostic()?;
+        fs_err::remove_file(&meta).into_diagnostic()?;
+    }
+    Ok(files.len())
+}
+
+/// One proposed file in some session's outbox.
+#[derive(Debug)]
+pub struct Proposal {
+    pub slug: String,
+    pub session: String,
+    /// Path relative to the session outbox dir, mirroring the agent config
+    /// layout.
+    pub rel: PathBuf,
+    /// The agent the session ran, from the sidecar. `None` if the sidecar
+    /// is missing or unparseable.
+    pub agent: Option<Agent>,
+    /// Absolute host path of the proposal file.
+    pub file: PathBuf,
+}
+
+impl Proposal {
+    /// The address `ramekin outbox` commands take: `<slug>/<session>/<rel>`.
+    pub fn entry(&self) -> String {
+        format!("{}/{}/{}", self.slug, self.session, self.rel.display())
+    }
+
+    /// The host config file this proposal maps back to: the agent's host
+    /// config dir plus the relative path — but only when the path's first
+    /// component is an allowlisted entry, i.e. something that was actually
+    /// mounted. Anything else needs an explicit destination to apply.
+    pub fn host_target(&self) -> Option<PathBuf> {
+        let agent = self.agent?;
+        let first = match self.rel.components().next()? {
+            Component::Normal(name) => name.to_str()?.to_string(),
+            _ => return None,
+        };
+        if !agent.config_allowlist().contains(&first.as_str()) {
+            return None;
+        }
+        let base = PathBuf::from(shellexpand::tilde(agent.host_config_dir()).as_ref());
+        Some(base.join(&self.rel))
+    }
+}
+
+/// All pending proposals across every repo and session, oldest path first.
+pub fn scan(data_home: &Path) -> Result<Vec<Proposal>> {
+    let mut proposals = Vec::new();
+    let repos = data_home.join("repos");
+    if !repos.is_dir() {
+        return Ok(proposals);
+    }
+    for repo in sorted_dir(&repos)? {
+        let Some(slug) = dir_name(&repo) else {
+            continue;
+        };
+        let outbox = repo.join("outbox");
+        if !outbox.is_dir() {
+            continue;
+        }
+        for session_dir in sorted_dir(&outbox)? {
+            if !session_dir.is_dir() {
+                continue; // .agent sidecars
+            }
+            let Some(session) = dir_name(&session_dir) else {
+                continue;
+            };
+            let agent = fs_err::read_to_string(outbox.join(format!("{session}.agent")))
+                .ok()
+                .and_then(|s| Agent::parse(s.trim()).ok());
+            let mut files = Vec::new();
+            collect_files(&session_dir, &session_dir, &mut files)?;
+            for rel in files {
+                proposals.push(Proposal {
+                    slug: slug.clone(),
+                    session: session.clone(),
+                    file: session_dir.join(&rel),
+                    rel,
+                    agent,
+                });
+            }
+        }
+    }
+    Ok(proposals)
+}
+
+/// Proposals matching an entry: either one file (`<slug>/<session>/<rel>`)
+/// or a whole session (`<slug>/<session>`).
+pub fn find(data_home: &Path, entry: &str) -> Result<Vec<Proposal>> {
+    let matches: Vec<Proposal> = scan(data_home)?
+        .into_iter()
+        .filter(|p| {
+            let session_prefix = format!("{}/{}", p.slug, p.session);
+            p.entry() == entry || session_prefix == entry
+        })
+        .collect();
+    if matches.is_empty() {
+        bail!("no outbox entry matches `{entry}` (see `ramekin outbox list`)");
+    }
+    Ok(matches)
+}
+
+/// Remove a proposal file and prune its session outbox if now empty.
+pub fn remove(data_home: &Path, proposal: &Proposal) -> Result<()> {
+    fs_err::remove_file(&proposal.file).into_diagnostic()?;
+    // Prune now-empty parent dirs up to (and including, via finish) the
+    // session dir.
+    let (session_dir, _) = session_paths(data_home, &proposal.slug, &proposal.session);
+    let mut dir = proposal.file.parent().map(Path::to_path_buf);
+    while let Some(d) = dir {
+        if d == session_dir || fs_err::remove_dir(&d).is_err() {
+            break;
+        }
+        dir = d.parent().map(Path::to_path_buf);
+    }
+    finish_session(data_home, &proposal.slug, &proposal.session)?;
+    Ok(())
+}
+
+/// Recursively collect files under `dir` as paths relative to `root`.
+fn collect_files(dir: &Path, root: &Path, found: &mut Vec<PathBuf>) -> Result<()> {
+    for entry in fs_err::read_dir(dir).into_diagnostic()? {
+        let entry = entry.into_diagnostic()?;
+        let path = entry.path();
+        if entry.file_type().into_diagnostic()?.is_dir() {
+            collect_files(&path, root, found)?;
+        } else {
+            found.push(
+                path.strip_prefix(root)
+                    .expect("walk stays under root")
+                    .to_path_buf(),
+            );
+        }
+    }
+    found.sort();
+    Ok(())
+}
+
+fn sorted_dir(dir: &Path) -> Result<Vec<PathBuf>> {
+    let mut entries: Vec<PathBuf> = fs_err::read_dir(dir)
+        .into_diagnostic()?
+        .filter_map(|e| e.ok().map(|e| e.path()))
+        .collect();
+    entries.sort();
+    Ok(entries)
+}
+
+fn dir_name(path: &Path) -> Option<String> {
+    path.file_name().map(|n| n.to_string_lossy().into_owned())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn write_proposal(data_home: &Path, slug: &str, session: &str, agent: &str, rel: &str) {
+        let (dir, meta) = session_paths(data_home, slug, session);
+        let file = dir.join(rel);
+        fs_err::create_dir_all(file.parent().unwrap()).unwrap();
+        fs_err::write(&file, "proposed").unwrap();
+        fs_err::write(&meta, agent).unwrap();
+    }
+
+    #[test]
+    fn create_then_finish_empty_session_leaves_nothing() {
+        let data_home = tempfile::tempdir().unwrap();
+        let dir = create_session(data_home.path(), "repo-1", "abc", Agent::Pi).unwrap();
+        assert!(dir.is_dir());
+
+        let pending = finish_session(data_home.path(), "repo-1", "abc").unwrap();
+        assert_eq!(pending, 0);
+        assert!(!dir.exists());
+        assert!(scan(data_home.path()).unwrap().is_empty());
+    }
+
+    #[test]
+    fn finish_keeps_nonempty_session() {
+        let data_home = tempfile::tempdir().unwrap();
+        let dir = create_session(data_home.path(), "repo-1", "abc", Agent::Pi).unwrap();
+        fs_err::write(dir.join("AGENTS.md"), "new").unwrap();
+
+        let pending = finish_session(data_home.path(), "repo-1", "abc").unwrap();
+        assert_eq!(pending, 1);
+        assert!(dir.exists());
+    }
+
+    #[test]
+    fn scan_finds_proposals_with_agent() {
+        let data_home = tempfile::tempdir().unwrap();
+        write_proposal(
+            data_home.path(),
+            "repo-1",
+            "abc",
+            "pi",
+            "skills/foo/SKILL.md",
+        );
+
+        let proposals = scan(data_home.path()).unwrap();
+        assert_eq!(proposals.len(), 1);
+        let p = &proposals[0];
+        assert_eq!(p.slug, "repo-1");
+        assert_eq!(p.session, "abc");
+        assert_eq!(p.agent, Some(Agent::Pi));
+        assert_eq!(p.entry(), "repo-1/abc/skills/foo/SKILL.md");
+        let target = p.host_target().unwrap();
+        assert!(
+            target.ends_with(".pi/agent/skills/foo/SKILL.md"),
+            "got {}",
+            target.display()
+        );
+    }
+
+    #[test]
+    fn claude_proposal_maps_to_claude_dir() {
+        let data_home = tempfile::tempdir().unwrap();
+        write_proposal(data_home.path(), "repo-1", "abc", "claude", "CLAUDE.md");
+
+        let proposals = scan(data_home.path()).unwrap();
+        let target = proposals[0].host_target().unwrap();
+        assert!(
+            target.ends_with(".claude/CLAUDE.md"),
+            "got {}",
+            target.display()
+        );
+    }
+
+    #[test]
+    fn unallowlisted_proposal_has_no_host_target() {
+        let data_home = tempfile::tempdir().unwrap();
+        write_proposal(data_home.path(), "repo-1", "abc", "pi", "settings.json");
+
+        let proposals = scan(data_home.path()).unwrap();
+        // settings.json is claude-shaped, not in pi's allowlist.
+        assert_eq!(proposals[0].host_target(), None);
+    }
+
+    #[test]
+    fn missing_sidecar_means_no_target() {
+        let data_home = tempfile::tempdir().unwrap();
+        let (dir, _) = session_paths(data_home.path(), "repo-1", "abc");
+        fs_err::create_dir_all(&dir).unwrap();
+        fs_err::write(dir.join("AGENTS.md"), "x").unwrap();
+
+        let proposals = scan(data_home.path()).unwrap();
+        assert_eq!(proposals.len(), 1);
+        assert_eq!(proposals[0].agent, None);
+        assert_eq!(proposals[0].host_target(), None);
+    }
+
+    #[test]
+    fn find_matches_file_and_session() {
+        let data_home = tempfile::tempdir().unwrap();
+        write_proposal(data_home.path(), "repo-1", "abc", "pi", "AGENTS.md");
+        write_proposal(data_home.path(), "repo-1", "abc", "pi", "skills/x.md");
+
+        let by_file = find(data_home.path(), "repo-1/abc/AGENTS.md").unwrap();
+        assert_eq!(by_file.len(), 1);
+
+        let by_session = find(data_home.path(), "repo-1/abc").unwrap();
+        assert_eq!(by_session.len(), 2);
+
+        assert!(find(data_home.path(), "repo-1/nope").is_err());
+    }
+
+    #[test]
+    fn remove_prunes_empty_dirs_and_session() {
+        let data_home = tempfile::tempdir().unwrap();
+        write_proposal(
+            data_home.path(),
+            "repo-1",
+            "abc",
+            "pi",
+            "skills/foo/SKILL.md",
+        );
+
+        let proposals = scan(data_home.path()).unwrap();
+        remove(data_home.path(), &proposals[0]).unwrap();
+
+        let (dir, meta) = session_paths(data_home.path(), "repo-1", "abc");
+        assert!(!dir.exists());
+        assert!(!meta.exists());
+    }
+}