outbox: show reports instead of diffing and applying files
Assisted-by: Claude Opus 5.5 via Claude Code
diff --git a/AGENTS.md b/AGENTS.md
index 811ea61..64f6bd5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -11,7 +11,7 @@ Cargo.toml # Single-crate workspace
src/
main.rs # CLI: agent state, image builds, compose generation, outbox commands
config.rs # KDL parsing, config layers, profiles, mount resolution and merging
- outbox.rs # Pending config proposals: scan, map to host sources, apply/discard
+ outbox.rs # Pending config problem reports: scan, show, discard
build.rs # Sets RAMEKIN_VERSION from env or git rev
assets/
Dockerfile # Base image: both agents (+ claude managed settings, IS_SANDBOX)
@@ -51,7 +51,7 @@ just # All four
- Each workspace mounts at `/workspace/<slug>` (slug is `<dirname>-<hash>`, never a shared `/workspace`) so cwd-keyed agent state stays distinct per repo; compose's `working_dir` puts the agent there on start.
- Docker compose config is generated at runtime via `serde_yaml` over a typed `ComposeConfig` struct, not a static file. Volume mounts use the long-form bind syntax (`{type: bind, source, target, read_only}`), ordered lexicographically by target so parents precede children. Passthrough env vars render as bare names in the environment list.
- One base image (`ramekin-agent`) carries both agents; it has no ENTRYPOINT, and the generated compose config sets `entrypoint` to `pi` or `claude` per session. A project `.ramekin/Dockerfile` builds `FROM ramekin-agent` with a repo-specific tag. Image builds forward a host GitHub token (env vars or `gh auth token`) as a BuildKit secret for API calls.
-- The outbox (`src/outbox.rs`) is the only write path for shared config: each session mounts a fresh dir at `/root/.ramekin/outbox`; proposals map back to host sources via the agent allowlist plus an `.agent` sidecar written outside the mount; `ramekin outbox list|diff|apply|discard` reviews them.
+- The outbox (`src/outbox.rs`) is the agent's only channel back to shared config: each session mounts a fresh dir at `/root/.ramekin/outbox` where the agent writes Markdown problem reports (not fixes); an `.agent` sidecar written outside the mount records whose config they concern; `ramekin outbox list|show|discard` reviews them.
- `Ramekin::resolve` is side-effect free (so `ramekin config` never mutates state); materialization happens in `run` via `AgentState::prepare`/`prepare_session`.
- The `ramekin-prompt.md` template is rendered per session (`{{WORKSPACE_PATH}}` → the workspace target), mounted read-only at `/root/.ramekin/ramekin-prompt.md`, and passed via `--append-system-prompt` (pi) / `--append-system-prompt-file` (Claude — its plain flag takes a literal string).
- Version is set at build time via the `RAMEKIN_VERSION` env var (used by CI) or falls back to `dev+<short-sha>`.
diff --git a/README.md b/README.md
index d067b69..a7b6a47 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,7 @@ A Rust CLI orchestrates a Docker Compose stack. On each run it:
2. Builds the base image (`ramekin-agent`, carrying both agents), and a project-specific layer if one exists
3. Generates a compose config, renders the system prompt, and creates fresh agent dirs, all in a session-scoped cache directory
4. Starts the agent container with the workspace mounted at `/workspace/<slug>` (where `<slug>` is `<dirname>-<hash>`, so cwd-keyed agent state never collides across repos)
-5. Attaches interactively, then tears down on exit — logging any state the agent wrote to its session-scoped dirs before discarding it, and keeping any config proposals the agent left in its outbox
+5. Attaches interactively, then tears down on exit — logging any state the agent wrote to its session-scoped dirs before discarding it, and keeping any config problem reports the agent left in its outbox
Concurrent sessions don't interfere: everything a run touches is either read-only config, session-scoped plumbing under a random session id, or agent state the agent itself manages concurrently.
@@ -32,7 +32,7 @@ Concurrent sessions don't interfere: everything a run touches is either read-onl
`config` prints the active profile, resolved paths, volume mounts, and Dockerfile status without starting (or mutating) anything — useful for debugging mount issues.
-`outbox` reviews config changes proposed by agents — see [Outbox](#outbox).
+`outbox` reviews config problems reported by agents — see [Outbox](#outbox).
`completions <shell>` generates shell completions for bash, zsh, fish, elvish, or powershell. Pipe the output to a file sourced by your shell:
@@ -80,7 +80,7 @@ Agent config comes from the host's own dirs — the agents are also used locally
Ramekin keeps no parallel copy — edit the host files (or the dotfiles they symlink to) and the next session sees the changes. The rest of each host dir is runtime state (credentials, transcripts) and never enters the container. Project-level agent config (`.claude/`, `CLAUDE.md`, `AGENTS.md` in the repo) rides the workspace mount; the agents layer it themselves.
-Config is immutable from inside the container by design: in-container edits fail loudly, and the [outbox](#outbox) is the write path.
+Config is immutable from inside the container by design: in-container edits fail loudly, and the [outbox](#outbox) is where the agent reports problems with it.
### Persistence
@@ -185,16 +185,15 @@ env {
### Outbox
-Shared config is read-only in the container, so the outbox is the one reviewed path for changing it. Each session mounts a fresh, empty dir at `/root/.ramekin/outbox` (host: `$XDG_DATA_HOME/ramekin/repos/<slug>/outbox/<session-id>/`), and the system prompt tells the agent to write a Markdown file there describing each config problem it hits, rather than a fix. Empty outboxes vanish at teardown; anything left becomes a pending proposal.
+Shared config is read-only in the container, so the outbox is how an agent reports problems with it. Each session mounts a fresh, empty dir at `/root/.ramekin/outbox` (host: `$XDG_DATA_HOME/ramekin/repos/<slug>/outbox/<session-id>/`), and the system prompt tells the agent to write a Markdown file there describing each config problem it hits, rather than a fix. Empty outboxes vanish at teardown; anything left becomes a pending report.
```sh
-ramekin outbox list # pending proposals across sessions
-ramekin outbox diff [<slug>/<session>/<path>] # diff against the host source
-ramekin outbox apply <slug>/<session>/<path> # copy over the host source, after confirmation
-ramekin outbox discard <slug>/<session> # drop proposals
+ramekin outbox list # pending reports across sessions
+ramekin outbox show [<slug>/<session>/<path>] # print reports
+ramekin outbox discard <slug>/<session> # drop reports
```
-`apply` shows the diff, asks for confirmation, and writes through dotfiles symlinks so the change lands in the working copy. Proposals that don't map back to an allowlisted agent-config entry need an explicit `--to` destination.
+Fixing the config is up to you on the host, for example by handing a report to an agent running there.
### Container environment context
diff --git a/src/main.rs b/src/main.rs
index a2abf7a..f28ea7e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -72,7 +72,7 @@ enum Cmd {
},
/// Show resolved paths and mount configuration
Config,
- /// Review config changes proposed by agents
+ /// Review config problems reported by agents
Outbox {
#[command(subcommand)]
command: OutboxCmd,
@@ -86,26 +86,17 @@ enum Cmd {
#[derive(Subcommand)]
enum OutboxCmd {
- /// List pending proposals across all repos and sessions
+ /// List pending reports 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
+ /// Print reports
+ Show {
+ /// A single report (`<slug>/<session>/<path>`) or session
+ /// (`<slug>/<session>`); all reports 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
+ /// Drop reports once they're dealt with
Discard {
- /// A single proposal (`<slug>/<session>/<path>`) or a whole session
+ /// A single report (`<slug>/<session>/<path>`) or a whole session
/// (`<slug>/<session>`)
entry: String,
},
@@ -160,104 +151,37 @@ fn run_outbox(command: OutboxCmd) -> Result<()> {
match command {
OutboxCmd::List => {
- let proposals = outbox::scan(&data_home)?;
- if proposals.is_empty() {
- println!("no pending proposals");
+ let reports = outbox::scan(&data_home)?;
+ if reports.is_empty() {
+ println!("no pending reports");
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()),
- }
+ for r in reports {
+ println!("{} ({})", r.entry(), agent_label(&r));
}
}
- OutboxCmd::Diff { entry } => {
- let proposals = match entry {
+ OutboxCmd::Show { entry } => {
+ let reports = 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())?;
+ for r in reports {
+ let body = fs_err::read_to_string(&r.file).into_diagnostic()?;
+ println!("--- {} ({})\n{body}", r.entry(), agent_label(&r));
}
}
- 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());
+ for report in outbox::find(&data_home, &entry)? {
+ outbox::remove(&data_home, &report)?;
+ println!("discarded {}", report.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")
+fn agent_label(report: &outbox::Report) -> &'static str {
+ report.agent.map_or("unknown agent", |a| a.name())
}
// ---------------------------------------------------------------------------
@@ -976,11 +900,11 @@ impl Ramekin {
}
}
- // A non-empty outbox survives teardown as pending proposals.
+ // A non-empty outbox survives teardown as pending reports.
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`");
+ info!("{pending} config report(s) pending — review with `ramekin outbox list`");
}
Err(e) => error!("failed to finalize session outbox: {e}"),
}
diff --git a/src/outbox.rs b/src/outbox.rs
index 60bb3a9..ab5271c 100644
--- a/src/outbox.rs
+++ b/src/outbox.rs
@@ -1,14 +1,12 @@
-//! The outbox: the one reviewed path for stateful modification of shared
-//! config.
+//! The outbox: the one channel from an agent back to its host 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.
+//! Config is read-only inside the container by design. When an agent hits
+//! a config problem, it writes a Markdown report describing it into its
+//! session's outbox dir (mounted writable at [`OUTBOX_TARGET`]). Host side,
+//! `ramekin outbox` lists, shows, and discards pending reports; any fix to
+//! host config happens there, outside the container.
-use std::path::{Component, Path, PathBuf};
+use std::path::{Path, PathBuf};
use miette::{IntoDiagnostic, Result, bail};
@@ -19,8 +17,8 @@ use crate::config::Agent;
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).
+/// metadata file recording which agent the session ran, i.e. whose config
+/// the reports are about.
fn session_paths(data_home: &Path, slug: &str, session_id: &str) -> (PathBuf, PathBuf) {
let outbox = data_home.join(format!("repos/{slug}/outbox"));
(
@@ -31,7 +29,7 @@ fn session_paths(data_home: &Path, slug: &str, session_id: &str) -> (PathBuf, Pa
/// 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.
+/// a report can't misstate which agent filed it.
pub fn create_session(
data_home: &Path,
slug: &str,
@@ -57,51 +55,33 @@ pub fn finish_session(data_home: &Path, slug: &str, session_id: &str) -> Result<
Ok(files.len())
}
-/// One proposed file in some session's outbox.
+/// One report in some session's outbox.
#[derive(Debug)]
-pub struct Proposal {
+pub struct Report {
pub slug: String,
pub session: String,
- /// Path relative to the session outbox dir, mirroring the agent config
- /// layout.
+ /// Path relative to the session outbox dir.
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.
+ /// Absolute host path of the report file.
pub file: PathBuf,
}
-impl Proposal {
+impl Report {
/// 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();
+/// All pending reports across every repo and session, sorted by path.
+pub fn scan(data_home: &Path) -> Result<Vec<Report>> {
+ let mut reports = Vec::new();
let repos = data_home.join("repos");
if !repos.is_dir() {
- return Ok(proposals);
+ return Ok(reports);
}
for repo in sorted_dir(&repos)? {
let Some(slug) = dir_name(&repo) else {
@@ -124,7 +104,7 @@ pub fn scan(data_home: &Path) -> Result<Vec<Proposal>> {
let mut files = Vec::new();
collect_files(&session_dir, &session_dir, &mut files)?;
for rel in files {
- proposals.push(Proposal {
+ reports.push(Report {
slug: slug.clone(),
session: session.clone(),
file: session_dir.join(&rel),
@@ -134,13 +114,13 @@ pub fn scan(data_home: &Path) -> Result<Vec<Proposal>> {
}
}
}
- Ok(proposals)
+ Ok(reports)
}
-/// Proposals matching an entry: either one file (`<slug>/<session>/<rel>`)
+/// Reports 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)?
+pub fn find(data_home: &Path, entry: &str) -> Result<Vec<Report>> {
+ let matches: Vec<Report> = scan(data_home)?
.into_iter()
.filter(|p| {
let session_prefix = format!("{}/{}", p.slug, p.session);
@@ -153,20 +133,20 @@ pub fn find(data_home: &Path, entry: &str) -> Result<Vec<Proposal>> {
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()?;
+/// Remove a report file and prune its session outbox if now empty.
+pub fn remove(data_home: &Path, report: &Report) -> Result<()> {
+ fs_err::remove_file(&report.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);
+ let (session_dir, _) = session_paths(data_home, &report.slug, &report.session);
+ let mut dir = report.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)?;
+ finish_session(data_home, &report.slug, &report.session)?;
Ok(())
}
@@ -206,11 +186,11 @@ fn dir_name(path: &Path) -> Option<String> {
mod tests {
use super::*;
- fn write_proposal(data_home: &Path, slug: &str, session: &str, agent: &str, rel: &str) {
+ fn write_report(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(&file, "reported").unwrap();
fs_err::write(&meta, agent).unwrap();
}
@@ -230,7 +210,7 @@ mod tests {
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();
+ fs_err::write(dir.join("report.md"), "new").unwrap();
let pending = finish_session(data_home.path(), "repo-1", "abc").unwrap();
assert_eq!(pending, 1);
@@ -238,75 +218,38 @@ mod tests {
}
#[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() {
+ fn scan_finds_reports_with_agent() {
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()
- );
+ write_report(data_home.path(), "repo-1", "abc", "pi", "skill-gap.md");
+
+ let reports = scan(data_home.path()).unwrap();
+ assert_eq!(reports.len(), 1);
+ let r = &reports[0];
+ assert_eq!(r.slug, "repo-1");
+ assert_eq!(r.session, "abc");
+ assert_eq!(r.agent, Some(Agent::Pi));
+ assert_eq!(r.entry(), "repo-1/abc/skill-gap.md");
}
#[test]
- fn unallowlisted_proposal_has_no_host_target() {
- let data_home = tempfile::tempdir().unwrap();
- write_proposal(data_home.path(), "repo-1", "abc", "pi", "auth.json");
-
- let proposals = scan(data_home.path()).unwrap();
- // auth.json is pi runtime state, never config, so it's unallowlisted.
- assert_eq!(proposals[0].host_target(), None);
- }
-
- #[test]
- fn missing_sidecar_means_no_target() {
+ fn missing_sidecar_means_no_agent() {
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();
+ fs_err::write(dir.join("report.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);
+ let reports = scan(data_home.path()).unwrap();
+ assert_eq!(reports.len(), 1);
+ assert_eq!(reports[0].agent, 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");
+ write_report(data_home.path(), "repo-1", "abc", "pi", "a.md");
+ write_report(data_home.path(), "repo-1", "abc", "pi", "nested/b.md");
- let by_file = find(data_home.path(), "repo-1/abc/AGENTS.md").unwrap();
+ let by_file = find(data_home.path(), "repo-1/abc/a.md").unwrap();
assert_eq!(by_file.len(), 1);
let by_session = find(data_home.path(), "repo-1/abc").unwrap();
@@ -318,16 +261,10 @@ mod tests {
#[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();
+ write_report(data_home.path(), "repo-1", "abc", "pi", "nested/deep/a.md");
+
+ let reports = scan(data_home.path()).unwrap();
+ remove(data_home.path(), &reports[0]).unwrap();
let (dir, meta) = session_paths(data_home.path(), "repo-1", "abc");
assert!(!dir.exists());