use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
struct Sandbox {
base: PathBuf,
source: PathBuf,
mind_home: PathBuf,
claude_home: PathBuf,
}
struct Run {
stdout: String,
stderr: String,
success: bool,
}
impl Sandbox {
fn new() -> Sandbox {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-lobes-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let source = base.join("agents");
let sb = Sandbox {
base: base.clone(),
source: source.clone(),
mind_home: base.join("mind"),
claude_home: base.join("claude"),
};
write(
&source.join("skills/review/SKILL.md"),
"---\nname: review\ndescription: Review the diff for bugs\n---\n# review skill\n",
);
write(
&source.join("rules/style.md"),
"---\ndescription: ASCII only\n---\n# style rule\n",
);
git(&source, &["-c", "init.defaultBranch=main", "init", "-q"]);
git(&source, &["config", "user.email", "t@t"]);
git(&source, &["config", "user.name", "t"]);
git(&source, &["add", "-A"]);
git(&source, &["commit", "-qm", "initial"]);
std::fs::create_dir_all(&sb.mind_home).unwrap();
sb
}
fn mind(&self, args: &[&str]) -> Run {
self.run(args, &[])
}
fn mind_env(&self, args: &[&str], envs: &[(&str, &str)]) -> Run {
self.run(args, envs)
}
fn mind_home_sandboxed(&self, args: &[&str]) -> Run {
let home_str = self.base.to_string_lossy().into_owned();
self.mind_env(args, &[("HOME", &home_str)])
}
fn mind_cwd_sandboxed(&self, args: &[&str]) -> Run {
self.run_cwd(args, &[], Some(&self.base))
}
fn run(&self, args: &[&str], envs: &[(&str, &str)]) -> Run {
self.run_cwd(args, envs, None)
}
fn run_cwd(&self, args: &[&str], envs: &[(&str, &str)], cwd: Option<&Path>) -> Run {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_mind"));
cmd.args(args)
.env("MIND_HOME", &self.mind_home)
.env("CLAUDE_HOME", &self.claude_home)
.env_remove("MIND_AGENT_HOMES")
.env_remove("MIND_POLICY_FILE")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for (k, v) in envs {
cmd.env(k, v);
}
if let Some(dir) = cwd {
cmd.current_dir(dir);
}
let out = cmd.output().expect("run mind");
Run {
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
success: out.status.success(),
}
}
fn source_spec(&self) -> String {
self.source.to_string_lossy().into_owned()
}
fn write_config(&self, body: &str) {
write(&self.mind_home.join("config.toml"), body);
}
}
impl Drop for Sandbox {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.base);
}
}
fn write(path: &Path, contents: &str) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, contents).unwrap();
}
fn git(repo: &Path, args: &[&str]) {
std::fs::create_dir_all(repo).unwrap();
let status = Command::new("git")
.args(args)
.current_dir(repo)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.expect("run git");
assert!(status.success(), "git {args:?} failed");
}
fn parse_json(stdout: &str) -> serde_json::Value {
serde_json::from_str(stdout.trim()).unwrap_or_else(|e| panic!("not JSON: {e}\n{stdout}"))
}
#[test]
fn preset_add_records_path_and_kinds() {
let sb = Sandbox::new();
let added = sb.mind_home_sandboxed(&["config", "lobes", "add", "--preset", "gemini"]);
assert!(added.success, "preset add failed: {}", added.stderr);
let listed = sb.mind(&["config", "lobes", "list", "--json"]);
let v = parse_json(&listed.stdout);
let lobes = v["lobes"].as_array().expect("lobes array");
let gemini = lobes
.iter()
.find(|l| {
l["path"]
.as_str()
.is_some_and(|p| p.ends_with(".gemini/config"))
})
.expect("a .gemini/config lobe entry");
let kinds: Vec<&str> = gemini["kinds"]
.as_array()
.expect("kinds array")
.iter()
.map(|k| k.as_str().unwrap())
.collect();
assert_eq!(kinds, vec!["skill"], "gemini is skill-only");
let bad = sb.mind(&["config", "lobes", "add", "--preset", "emacs"]);
assert!(!bad.success, "unknown preset must fail");
assert!(bad.stderr.contains("preset"), "{}", bad.stderr);
let ag = sb.mind(&["config", "lobes", "add", "--preset", "antigravity"]);
assert!(!ag.success, "removed antigravity preset must fail");
let agcli = sb.mind(&["config", "lobes", "add", "--preset", "antigravity-cli"]);
assert!(!agcli.success, "removed antigravity-cli preset must fail");
let both = sb.mind(&["config", "lobes", "add", "/tmp/x", "--preset", "gemini"]);
assert!(!both.success, "path + --preset must conflict");
}
#[test]
fn kinds_filter_excludes_rule_from_skill_only_lobe() {
let sb = Sandbox::new();
let skill_lobe = sb.base.join("gemini-lobe");
sb.write_config(&format!(
"lobes = [\"{claude}\", {{ path = \"{skill}\", kinds = [\"skill\"] }}]\n",
claude = sb.claude_home.display(),
skill = skill_lobe.display(),
));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
assert!(sb.mind(&["learn", "style"]).success, "learn rule");
assert!(
std::fs::symlink_metadata(sb.claude_home.join("skills/review")).is_ok(),
"skill must link into the Claude lobe"
);
assert!(
std::fs::symlink_metadata(skill_lobe.join("skills/review")).is_ok(),
"skill must link into the skill-only lobe"
);
assert!(
std::fs::symlink_metadata(sb.claude_home.join("rules/style")).is_ok()
|| std::fs::symlink_metadata(sb.claude_home.join("rules/style.md")).is_ok(),
"rule must link into the Claude lobe"
);
assert!(
std::fs::symlink_metadata(skill_lobe.join("rules/style.md")).is_err(),
"rule must NOT link into a skill-only lobe (HARN-3)"
);
let manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(sb.mind_home.join("manifest.json")).unwrap())
.unwrap();
let rule_links: Vec<&str> = manifest["items"]["rule:style"]["links"]
.as_array()
.expect("rule links array")
.iter()
.map(|l| l.as_str().unwrap())
.collect();
let skill_lobe_str = skill_lobe.display().to_string();
assert!(
!rule_links.iter().any(|l| l.starts_with(&skill_lobe_str)),
"rule manifest links must omit the skill-only lobe: {rule_links:?}"
);
assert!(
rule_links
.iter()
.any(|l| l.starts_with(&sb.claude_home.display().to_string())),
"rule manifest links must include the Claude lobe: {rule_links:?}"
);
let linked = std::fs::read_to_string(skill_lobe.join("skills/review/SKILL.md")).unwrap();
let original = std::fs::read_to_string(sb.source.join("skills/review/SKILL.md")).unwrap();
assert_eq!(
linked, original,
"mind links skill/agent files verbatim; no frontmatter rewrite (HARN-6)"
);
}
#[test]
fn list_and_show_display_kinds() {
let sb = Sandbox::new();
assert!(
sb.mind_home_sandboxed(&["config", "lobes", "add", "--preset", "gemini"])
.success
);
let list = sb.mind(&["config", "lobes", "list"]).stdout;
assert!(
list.contains(".gemini/config") && list.contains("skill"),
"list must show the kinds filter: {list}"
);
let show = sb.mind(&["config", "show"]).stdout;
assert!(
show.contains(".gemini/config") && show.contains("skill"),
"show must surface the kinds filter: {show}"
);
}
#[test]
fn detect_reports_then_yes_adds() {
let sb = Sandbox::new();
let detect_home = sb.base.join("detect");
std::fs::create_dir_all(detect_home.join(".gemini")).unwrap();
let detect_str = detect_home.to_string_lossy().into_owned();
let report = sb.mind_env(
&["config", "lobes", "detect"],
&[("MIND_DETECT_HOME", &detect_str)],
);
assert!(report.success, "detect failed: {}", report.stderr);
assert!(
report.stdout.contains("gemini"),
"detect must report gemini: {}",
report.stdout
);
let after_report = sb.mind(&["config", "lobes", "list", "--json"]);
let v = parse_json(&after_report.stdout);
assert!(
!v["lobes"].as_array().unwrap().iter().any(|l| l["path"]
.as_str()
.is_some_and(|p| p.ends_with(".gemini/config"))),
"report-only detect must NOT add the lobe: {}",
after_report.stdout
);
let json = sb.mind_env(
&["config", "lobes", "detect", "--json"],
&[("MIND_DETECT_HOME", &detect_str)],
);
let jv = parse_json(&json.stdout);
assert_eq!(jv["action"], "lobe-detect", "{}", json.stdout);
assert_eq!(
jv["added"], false,
"json report must not mutate: {}",
json.stdout
);
assert!(
jv["detected"]
.as_array()
.unwrap()
.iter()
.any(|d| d["preset"] == "gemini"),
"detected must list gemini: {}",
json.stdout
);
let added = sb.mind_env(
&["config", "lobes", "detect", "--yes"],
&[("MIND_DETECT_HOME", &detect_str)],
);
assert!(added.success, "detect --yes failed: {}", added.stderr);
let after_add = sb.mind(&["config", "lobes", "list", "--json"]);
let av = parse_json(&after_add.stdout);
let gemini = av["lobes"]
.as_array()
.unwrap()
.iter()
.find(|l| {
l["path"]
.as_str()
.is_some_and(|p| p.ends_with(".gemini/config"))
})
.expect("detect --yes must add the gemini/config lobe");
assert_eq!(
gemini["path"].as_str().unwrap(),
detect_home.join(".gemini/config").display().to_string(),
"added lobe path must be under the detection base"
);
}
#[test]
fn detect_yes_text_output_reports_and_persists() {
let sb = Sandbox::new();
let detect_home = sb.base.join("detect");
std::fs::create_dir_all(detect_home.join(".gemini")).unwrap();
let detect_str = detect_home.to_string_lossy().into_owned();
let added = sb.mind_env(
&["config", "lobes", "detect", "--yes"],
&[("MIND_DETECT_HOME", &detect_str)],
);
assert!(added.success, "detect --yes failed: {}", added.stderr);
assert!(
added.stdout.contains("gemini") && added.stdout.contains("added"),
"text --yes must report the added gemini lobe: {}",
added.stdout
);
let list = sb.mind(&["config", "lobes", "list"]).stdout;
assert!(
list.contains(".gemini/config"),
"detect --yes must persist the lobe: {list}"
);
}
#[test]
fn detect_no_homes_reports_nothing_and_mutates_nothing() {
let sb = Sandbox::new();
let detect_home = sb.base.join("empty-detect");
std::fs::create_dir_all(&detect_home).unwrap();
let detect_str = detect_home.to_string_lossy().into_owned();
let text = sb.mind_env(
&["config", "lobes", "detect", "--yes"],
&[("MIND_DETECT_HOME", &detect_str)],
);
assert!(text.success, "detect failed: {}", text.stderr);
assert!(
text.stdout.contains("no new agent homes (lobes) detected"),
"empty detection must report none found: {}",
text.stdout
);
let json = sb.mind_env(
&["config", "lobes", "detect", "--json", "--yes"],
&[("MIND_DETECT_HOME", &detect_str)],
);
let jv = parse_json(&json.stdout);
assert_eq!(jv["action"], "lobe-detect");
assert_eq!(
jv["added"], false,
"no candidates => added=false: {}",
json.stdout
);
assert!(
jv["detected"].as_array().unwrap().is_empty(),
"detected must be empty: {}",
json.stdout
);
let list = sb.mind(&["config", "lobes", "list", "--json"]);
let lv = parse_json(&list.stdout);
assert!(
!lv["lobes"].as_array().unwrap().iter().any(|l| l["path"]
.as_str()
.is_some_and(|p| p.ends_with(".gemini/config") || p.ends_with(".agents"))),
"empty detection must not have added any harness lobe: {}",
list.stdout
);
}
#[test]
fn detect_dedups_codex_and_universal_same_path() {
let sb = Sandbox::new();
let detect_home = sb.base.join("detect-dup");
std::fs::create_dir_all(detect_home.join(".codex")).unwrap();
std::fs::create_dir_all(detect_home.join(".agents")).unwrap();
let detect_str = detect_home.to_string_lossy().into_owned();
let json = sb.mind_env(
&["config", "lobes", "detect", "--json"],
&[("MIND_DETECT_HOME", &detect_str)],
);
let jv = parse_json(&json.stdout);
let agents_path = detect_home.join(".agents").display().to_string();
let agents_entries: Vec<&serde_json::Value> = jv["detected"]
.as_array()
.unwrap()
.iter()
.filter(|d| d["path"].as_str() == Some(agents_path.as_str()))
.collect();
assert_eq!(
agents_entries.len(),
1,
"codex+universal must collapse to ONE ~/.agents candidate: {}",
json.stdout
);
let added = sb.mind_env(
&["config", "lobes", "detect", "--yes"],
&[("MIND_DETECT_HOME", &detect_str)],
);
assert!(added.success, "{}", added.stderr);
let list = sb.mind(&["config", "lobes", "list", "--json"]);
let lv = parse_json(&list.stdout);
let agents_lobes: Vec<&serde_json::Value> = lv["lobes"]
.as_array()
.unwrap()
.iter()
.filter(|l| l["path"].as_str() == Some(agents_path.as_str()))
.collect();
assert_eq!(
agents_lobes.len(),
1,
"only one ~/.agents lobe must be persisted: {}",
list.stdout
);
}
#[test]
fn preset_add_codex() {
let sb = Sandbox::new();
let added = sb.mind_home_sandboxed(&["config", "lobes", "add", "--preset", "codex"]);
assert!(added.success, "codex add failed: {}", added.stderr);
let listed = sb.mind(&["config", "lobes", "list", "--json"]);
let v = parse_json(&listed.stdout);
let entry = v["lobes"]
.as_array()
.expect("lobes array")
.iter()
.find(|l| l["path"].as_str().is_some_and(|p| p.ends_with(".agents")))
.unwrap_or_else(|| panic!("a .agents lobe entry for codex: {}", listed.stdout));
let kinds: Vec<&str> = entry["kinds"]
.as_array()
.expect("kinds array")
.iter()
.map(|k| k.as_str().unwrap())
.collect();
assert_eq!(kinds, vec!["skill"], "codex kinds");
}
#[test]
fn preset_add_windsurf() {
let sb = Sandbox::new();
let added = sb.mind_cwd_sandboxed(&["config", "lobes", "add", "--preset", "windsurf"]);
assert!(added.success, "windsurf add failed: {}", added.stderr);
let listed = sb.mind(&["config", "lobes", "list", "--json"]);
let v = parse_json(&listed.stdout);
let entry = v["lobes"]
.as_array()
.expect("lobes array")
.iter()
.find(|l| l["path"].as_str().is_some_and(|p| p.ends_with(".windsurf")))
.unwrap_or_else(|| panic!("a .windsurf lobe entry for windsurf: {}", listed.stdout));
let kinds: Vec<&str> = entry["kinds"]
.as_array()
.expect("kinds array")
.iter()
.map(|k| k.as_str().unwrap())
.collect();
assert_eq!(kinds, vec!["skill"], "windsurf kinds");
}
#[test]
fn kinds_filtered_lobe_lifecycle_forget_and_introspect() {
let sb = Sandbox::new();
let skill_lobe = sb.base.join("skill-only-lobe");
sb.write_config(&format!(
"lobes = [\"{claude}\", {{ path = \"{skill}\", kinds = [\"skill\"] }}]\n",
claude = sb.claude_home.display(),
skill = skill_lobe.display(),
));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
assert!(sb.mind(&["learn", "style"]).success, "learn rule");
let intro = sb.mind(&["introspect", "--json"]);
assert!(intro.success, "introspect failed: {}", intro.stderr);
let iv = parse_json(&intro.stdout);
let issues = iv["issues"].as_array().expect("issues array");
assert!(
issues.is_empty(),
"a kinds-filtered lobe must produce no drift/missing-link issues: {}",
intro.stdout
);
let forget_rule = sb.mind(&["forget", "style", "--yes"]);
assert!(
forget_rule.success,
"forget of a rule in a kinds-filtered setup must succeed: {}",
forget_rule.stderr
);
assert!(
std::fs::symlink_metadata(sb.claude_home.join("rules/style.md")).is_err(),
"the recorded Claude rule link must be removed by forget"
);
assert!(
std::fs::symlink_metadata(sb.claude_home.join("skills/review")).is_ok()
&& std::fs::symlink_metadata(skill_lobe.join("skills/review")).is_ok(),
"the skill must remain linked in both lobes after forgetting the rule"
);
let forget_skill = sb.mind(&["forget", "review", "--yes"]);
assert!(forget_skill.success, "{}", forget_skill.stderr);
assert!(
std::fs::symlink_metadata(sb.claude_home.join("skills/review")).is_err()
&& std::fs::symlink_metadata(skill_lobe.join("skills/review")).is_err(),
"forget must remove the skill from every recorded lobe"
);
let intro2 = sb.mind(&["introspect", "--json"]);
let iv2 = parse_json(&intro2.stdout);
assert!(
iv2["issues"].as_array().unwrap().is_empty(),
"introspect must stay clean after forget: {}",
intro2.stdout
);
}
#[test]
fn upgrade_respects_kinds_filter() {
let sb = Sandbox::new();
let skill_lobe = sb.base.join("skill-only-lobe");
sb.write_config(&format!(
"lobes = [\"{claude}\", {{ path = \"{skill}\", kinds = [\"skill\"] }}]\n",
claude = sb.claude_home.display(),
skill = skill_lobe.display(),
));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "style"]).success, "learn rule");
write(
&sb.source.join("rules/style.md"),
"---\ndescription: ASCII only\n---\n# style rule v2\n",
);
git(&sb.source, &["commit", "-aqm", "bump rule"]);
assert!(sb.mind(&["sync"]).success, "sync failed");
let up = sb.mind(&["upgrade", "--yes"]);
assert!(
up.success,
"upgrade of a kinds-filtered rule must succeed: {}",
up.stderr
);
assert!(
std::fs::symlink_metadata(sb.claude_home.join("rules/style.md")).is_ok(),
"rule must remain linked in the Claude lobe after upgrade"
);
assert!(
std::fs::symlink_metadata(skill_lobe.join("rules/style.md")).is_err(),
"rule must NOT be linked into the skill-only lobe after upgrade (HARN-2)"
);
let manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(sb.mind_home.join("manifest.json")).unwrap())
.unwrap();
let rule_links: Vec<&str> = manifest["items"]["rule:style"]["links"]
.as_array()
.expect("rule links")
.iter()
.map(|l| l.as_str().unwrap())
.collect();
let skill_lobe_str = skill_lobe.display().to_string();
assert!(
!rule_links.iter().any(|l| l.starts_with(&skill_lobe_str)),
"rule links must still omit the skill-only lobe after upgrade: {rule_links:?}"
);
}
#[test]
fn lobes_add_without_path_or_preset_errors() {
let sb = Sandbox::new();
let run = sb.mind(&["config", "lobes", "add"]);
assert!(!run.success, "add with no target must fail");
assert!(
run.stderr.contains("path") && run.stderr.contains("--preset"),
"error must mention both a path and --preset: {}",
run.stderr
);
}
#[test]
fn preset_add_preserves_bare_entry_shape() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
let added = sb.mind_home_sandboxed(&["config", "lobes", "add", "--preset", "gemini"]);
assert!(added.success, "preset add failed: {}", added.stderr);
let raw = std::fs::read_to_string(sb.mind_home.join("config.toml")).unwrap();
let bare = format!("\"{}\"", sb.claude_home.display());
assert!(
raw.contains(&bare),
"the original bare lobe must remain a bare string after rewrite:\n{raw}"
);
assert!(
raw.contains("kinds") && raw.contains(".gemini/config"),
"the preset lobe must be a table with kinds:\n{raw}"
);
let listed = sb.mind(&["config", "lobes", "list", "--json"]);
let v = parse_json(&listed.stdout);
let lobes = v["lobes"].as_array().unwrap();
let claude_str = sb.claude_home.display().to_string();
assert!(
lobes
.iter()
.any(|l| l.as_str() == Some(claude_str.as_str())),
"a bare lobe must serialize as a plain JSON string (all-kinds): {}",
listed.stdout
);
assert!(
lobes.iter().any(|l| l.is_object()
&& l["path"]
.as_str()
.is_some_and(|p| p.ends_with(".gemini/config"))
&& l["kinds"].is_array()),
"the preset lobe must serialize as an object with a kinds array: {}",
listed.stdout
);
}
#[test]
fn remove_preset_added_detailed_lobe_by_path() {
let sb = Sandbox::new();
assert!(
sb.mind_home_sandboxed(&["config", "lobes", "add", "--preset", "gemini"])
.success
);
let listed = sb.mind(&["config", "lobes", "list", "--json"]);
let v = parse_json(&listed.stdout);
let gemini_path = v["lobes"]
.as_array()
.unwrap()
.iter()
.find_map(|l| {
l["path"]
.as_str()
.filter(|p| p.ends_with(".gemini/config"))
.map(str::to_string)
})
.expect("gemini lobe path");
let removed = sb.mind(&["config", "lobes", "remove", &gemini_path]);
assert!(
removed.success,
"removing a detailed preset lobe by path must succeed: {}",
removed.stderr
);
let after = sb.mind(&["config", "lobes", "list", "--json"]);
let av = parse_json(&after.stdout);
assert!(
!av["lobes"].as_array().unwrap().iter().any(|l| l["path"]
.as_str()
.is_some_and(|p| p.ends_with(".gemini/config"))),
"the gemini lobe must be gone after remove: {}",
after.stdout
);
}
#[test]
fn tool_with_explicit_link_respects_kinds_filter() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-tool-lobe-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let source = base.join("agents");
write(&source.join("toolkit/run.sh"), "#!/bin/sh\necho hi\n");
write(
&source.join("mind.toml"),
"[source]\ndescription = \"tool source\"\n\n[[items]]\nkind = \"tool\"\nname = \"toolkit\"\npath = \"toolkit\"\nlink = \"tools/toolkit\"\n",
);
git(&source, &["-c", "init.defaultBranch=main", "init", "-q"]);
git(&source, &["config", "user.email", "t@t"]);
git(&source, &["config", "user.name", "t"]);
git(&source, &["add", "-A"]);
git(&source, &["commit", "-qm", "initial"]);
let mind_home = base.join("mind");
let claude_home = base.join("claude");
std::fs::create_dir_all(&mind_home).unwrap();
let skill_lobe = base.join("skill-only-lobe");
let run = |args: &[&str]| -> Run {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_mind"));
cmd.args(args)
.env("MIND_HOME", &mind_home)
.env("CLAUDE_HOME", &claude_home)
.env_remove("MIND_AGENT_HOMES")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let out = cmd.output().expect("run mind");
Run {
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
success: out.status.success(),
}
};
write(
&mind_home.join("config.toml"),
&format!(
"lobes = [\"{claude}\", {{ path = \"{skill}\", kinds = [\"skill\"] }}]\n",
claude = claude_home.display(),
skill = skill_lobe.display(),
),
);
assert!(run(&["meld", &source.to_string_lossy()]).success);
let learned = run(&["learn", "toolkit"]);
assert!(learned.success, "learn tool failed: {}", learned.stderr);
assert!(
std::fs::symlink_metadata(claude_home.join("tools/toolkit")).is_ok(),
"a tool with an explicit link must link into a no-kinds lobe (TOOL-4)"
);
assert!(
std::fs::symlink_metadata(skill_lobe.join("tools/toolkit")).is_err(),
"a skill-only lobe must not receive a tool link (HARN-1)"
);
let manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(mind_home.join("manifest.json")).unwrap())
.unwrap();
let links: Vec<&str> = manifest["items"]["tool:toolkit"]["links"]
.as_array()
.expect("tool links")
.iter()
.map(|l| l.as_str().unwrap())
.collect();
let skill_lobe_str = skill_lobe.display().to_string();
assert!(
!links.iter().any(|l| l.starts_with(&skill_lobe_str)),
"tool links must omit the skill-only lobe: {links:?}"
);
assert!(
links
.iter()
.any(|l| l.starts_with(&claude_home.display().to_string())),
"tool links must include the no-kinds Claude lobe: {links:?}"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn detect_short_yes_flag_is_accepted() {
let sb = Sandbox::new();
let detect_home = sb.base.join("detect-empty");
std::fs::create_dir_all(&detect_home).unwrap();
let detect_str = detect_home.to_string_lossy().into_owned();
let run = sb.mind_env(
&["config", "lobes", "detect", "-y"],
&[("MIND_DETECT_HOME", &detect_str)],
);
assert!(
run.success,
"detect -y must succeed (not error on the flag): stderr={}",
run.stderr
);
assert!(
!run.stderr.contains("unexpected argument"),
"detect -y must not produce 'unexpected argument': {}",
run.stderr
);
}
#[test]
fn detect_global_yes_pre_verb_is_accepted() {
let sb = Sandbox::new();
let detect_home = sb.base.join("detect-empty2");
std::fs::create_dir_all(&detect_home).unwrap();
let detect_str = detect_home.to_string_lossy().into_owned();
let run = sb.mind_env(
&["-y", "config", "lobes", "detect"],
&[("MIND_DETECT_HOME", &detect_str)],
);
assert!(
run.success,
"mind -y config lobes detect must succeed: stderr={}",
run.stderr
);
assert!(
!run.stderr.contains("unexpected argument"),
"mind -y detect must not error on the flag: {}",
run.stderr
);
let detect_home2 = sb.base.join("detect-empty3");
std::fs::create_dir_all(&detect_home2).unwrap();
let detect_str2 = detect_home2.to_string_lossy().into_owned();
let run2 = sb.mind_env(
&["--yes", "config", "lobes", "detect"],
&[("MIND_DETECT_HOME", &detect_str2)],
);
assert!(
run2.success,
"mind --yes config lobes detect must succeed: stderr={}",
run2.stderr
);
}
#[test]
fn lobe_add_backfills_installed_items_with_yes() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
let new_lobe = sb.base.join("newlobe");
let new_lobe_str = new_lobe.to_string_lossy().into_owned();
let added = sb.mind(&["config", "lobes", "add", "--yes", &new_lobe_str]);
assert!(added.success, "lobe add --yes failed: {}", added.stderr);
assert!(
std::fs::symlink_metadata(new_lobe.join("skills/review")).is_ok(),
"the installed skill must be backfilled into the new lobe: {}",
added.stdout
);
}
#[test]
fn lobe_add_preset_backfills_with_yes() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
let home_str = sb.base.to_string_lossy().into_owned();
let added = sb.mind_env(
&["config", "lobes", "add", "--preset", "gemini", "--yes"],
&[("HOME", &home_str)],
);
assert!(added.success, "preset add --yes failed: {}", added.stderr);
assert!(
std::fs::symlink_metadata(sb.base.join(".gemini/config/skills/review")).is_ok(),
"the installed skill must be backfilled into the gemini preset lobe: {}",
added.stdout
);
}
#[test]
fn lobe_detect_backfills_with_yes() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
let detect_home = sb.base.join("detect");
std::fs::create_dir_all(detect_home.join(".gemini")).unwrap();
let detect_str = detect_home.to_string_lossy().into_owned();
let added = sb.mind_env(
&["config", "lobes", "detect", "--yes"],
&[("MIND_DETECT_HOME", &detect_str)],
);
assert!(added.success, "detect --yes failed: {}", added.stderr);
assert!(
std::fs::symlink_metadata(detect_home.join(".gemini/config/skills/review")).is_ok(),
"the installed skill must be backfilled into the detected gemini lobe: {}",
added.stdout
);
}
#[test]
fn lobe_add_no_tty_no_yes_backfills_immediately() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
let new_lobe = sb.base.join("newlobe");
let new_lobe_str = new_lobe.to_string_lossy().into_owned();
let added = sb.mind(&["config", "lobes", "add", &new_lobe_str]);
assert!(added.success, "lobe add failed: {}", added.stderr);
assert!(
!added.stdout.contains("mind introspect --fix"),
"HARN-17: the backfill is unconditional, so no introspect note is printed: {}",
added.stdout
);
assert!(
std::fs::symlink_metadata(new_lobe.join("skills/review")).is_ok(),
"HARN-17: without --yes, in a non-TTY, the skill must STILL be backfilled \
into the new lobe immediately: {}",
added.stdout
);
}
#[test]
fn introspect_reports_missing_lobe_links() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
let new_lobe = sb.base.join("newlobe");
sb.write_config(&format!(
"lobes = [\"{}\", \"{}\"]\n",
sb.claude_home.display(),
new_lobe.display()
));
let intro = sb.mind(&["introspect", "--json"]);
let iv = parse_json(&intro.stdout);
let issues = iv["issues"].as_array().expect("issues array");
assert!(
issues.iter().any(|i| i["kind"] == "missing-lobe-link"),
"introspect must report the uncovered lobe as missing-lobe-link: {}",
intro.stdout
);
}
#[test]
fn introspect_fix_creates_missing_lobe_links() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
let new_lobe = sb.base.join("newlobe");
sb.write_config(&format!(
"lobes = [\"{}\", \"{}\"]\n",
sb.claude_home.display(),
new_lobe.display()
));
let fixed = sb.mind(&["introspect", "--fix"]);
assert!(fixed.success, "introspect --fix failed: {}", fixed.stderr);
assert!(
std::fs::symlink_metadata(new_lobe.join("skills/review")).is_ok(),
"introspect --fix must create the missing lobe link: {}",
fixed.stdout
);
let intro = sb.mind(&["introspect", "--json"]);
let iv = parse_json(&intro.stdout);
let issues = iv["issues"].as_array().expect("issues array");
assert!(
!issues.iter().any(|i| i["kind"] == "missing-lobe-link"),
"after --fix no missing-lobe-link finding must remain: {}",
intro.stdout
);
}
#[test]
fn lobe_add_no_items_skips_backfill_silently() {
let sb = Sandbox::new();
let new_lobe = sb.base.join("newlobe");
let new_lobe_str = new_lobe.to_string_lossy().into_owned();
let added = sb.mind(&["config", "lobes", "add", &new_lobe_str]);
assert!(added.success, "lobe add failed: {}", added.stderr);
assert!(
added.stdout.contains("added lobe"),
"add must confirm the lobe: {}",
added.stdout
);
assert!(
!added.stdout.contains("mind introspect --fix"),
"with nothing installed there is no backfill note: {}",
added.stdout
);
}
#[test]
fn harn17_json_no_yes_backfills_silently() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
let new_lobe = sb.base.join("newlobe-json-noskip");
let new_lobe_str = new_lobe.to_string_lossy().into_owned();
let run = sb.mind(&["config", "lobes", "add", "--json", &new_lobe_str]);
assert!(run.success, "lobe add --json failed: {}", run.stderr);
let v = parse_json(&run.stdout);
assert_eq!(
v["action"], "lobe-add",
"JSON must include action: {}",
run.stdout
);
assert_eq!(v["outcome"], "added", "lobe must be added: {}", run.stdout);
assert!(
!run.stdout.contains("introspect"),
"JSON mode must not emit the introspect note: {}",
run.stdout
);
assert!(
std::fs::symlink_metadata(new_lobe.join("skills/review")).is_ok(),
"HARN-17: the backfill is unconditional, so the skill must be backfilled \
even without --yes in JSON mode: {}",
run.stdout
);
}
#[test]
fn harn7_json_yes_backfills_silently() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
let new_lobe = sb.base.join("newlobe-json-yes");
let new_lobe_str = new_lobe.to_string_lossy().into_owned();
let run = sb.mind(&["config", "lobes", "add", "--json", "--yes", &new_lobe_str]);
assert!(run.success, "lobe add --json --yes failed: {}", run.stderr);
let v = parse_json(&run.stdout);
assert_eq!(v["action"], "lobe-add", "{}", run.stdout);
assert_eq!(v["outcome"], "added", "{}", run.stdout);
assert!(
std::fs::symlink_metadata(new_lobe.join("skills/review")).is_ok(),
"with --yes the skill must be backfilled in JSON mode: {}",
run.stdout
);
}
#[test]
fn harn7_backfill_preset_codex_skill_only_excludes_rule() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
assert!(sb.mind(&["learn", "style"]).success, "learn rule");
let home_str = sb.base.to_string_lossy().into_owned();
let added = sb.mind_env(
&["config", "lobes", "add", "--preset", "codex", "--yes"],
&[("HOME", &home_str)],
);
assert!(added.success, "preset add --yes failed: {}", added.stderr);
assert!(
std::fs::symlink_metadata(sb.base.join(".agents/skills/review")).is_ok(),
"the skill must be backfilled into the codex (skill-only) lobe"
);
assert!(
std::fs::symlink_metadata(sb.base.join(".agents/rules/style.md")).is_err(),
"the rule must NOT be backfilled into a skill-only lobe (HARN-1/HARN-7)"
);
}
#[test]
fn harn7_no_op_lobe_add_does_not_backfill() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
let new_lobe = sb.base.join("existing-lobe");
let new_lobe_str = new_lobe.to_string_lossy().into_owned();
let first = sb.mind(&["config", "lobes", "add", "--yes", &new_lobe_str]);
assert!(first.success, "{}", first.stderr);
assert!(
std::fs::symlink_metadata(new_lobe.join("skills/review")).is_ok(),
"first add must backfill the skill"
);
std::fs::remove_file(new_lobe.join("skills/review")).unwrap();
let second = sb.mind(&["config", "lobes", "add", "--yes", &new_lobe_str]);
assert!(second.success, "{}", second.stderr);
assert!(
second.stdout.contains("already configured"),
"must report lobe already configured: {}",
second.stdout
);
assert!(
std::fs::symlink_metadata(new_lobe.join("skills/review")).is_err(),
"no-op add must not backfill items into an already-configured lobe"
);
}
#[test]
fn harn15_preset_add_creates_missing_lobe_dir_backfills_and_stays_clean() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
let home = sb.base.join("fresh-home");
std::fs::create_dir_all(&home).unwrap();
assert!(
std::fs::symlink_metadata(home.join(".gemini")).is_err(),
"sanity: ~/.gemini must not exist before the add"
);
let home_str = home.to_string_lossy().into_owned();
let added = sb.mind_env(
&["config", "lobes", "add", "--preset", "gemini"],
&[("HOME", &home_str)],
);
assert!(added.success, "preset add failed: {}", added.stderr);
let lobe_path = home.join(".gemini/config");
assert!(
lobe_path.is_dir(),
"HARN-15: the resolved lobe path must be created immediately: {}",
added.stdout
);
assert!(
std::fs::symlink_metadata(lobe_path.join("skills/review")).is_ok(),
"HARN-17: the installed skill must be backfilled into the gemini lobe: {}",
added.stdout
);
let intro = sb.mind(&["introspect", "--fix", "--json"]);
assert!(intro.success, "introspect --fix failed: {}", intro.stderr);
let iv = parse_json(&intro.stdout);
let issues = iv["issues"].as_array().expect("issues array");
assert!(
issues.is_empty(),
"introspect --fix must report no outstanding issue for a reachable, \
fully-backfilled preset lobe: {}",
intro.stdout
);
let listed = sb.mind(&["config", "lobes", "list", "--json"]);
let lv = parse_json(&listed.stdout);
let lobe_path_str = lobe_path.to_string_lossy().into_owned();
assert!(
lv["lobes"]
.as_array()
.unwrap()
.iter()
.any(|l| l["path"].as_str() == Some(lobe_path_str.as_str())),
"the gemini lobe entry must remain in config, not pruned as vanished: {}",
listed.stdout
);
}
#[test]
fn harn16_unreachable_lobe_prints_note_once_per_process() {
let sb = Sandbox::new();
let vanished_project = sb.base.join("vanished-project");
let unreachable_lobe = vanished_project.join(".windsurf");
sb.write_config(&format!(
"lobes = [\"{}\", \"{}\"]\n",
sb.claude_home.display(),
unreachable_lobe.display()
));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
let learn = sb.mind(&["learn", "*"]);
assert!(learn.success, "learn failed: {}", learn.stderr);
let occurrences = learn.stdout.matches("is unreachable").count();
assert_eq!(
occurrences, 1,
"HARN-16: the unreachable-lobe note must print exactly once per \
process, not once per skipped item: {}",
learn.stdout
);
assert!(
learn.stdout.contains("config lobes remove"),
"the note must name the config-remove remedy: {}",
learn.stdout
);
}
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
#[test]
fn harn16_unreachable_lobe_note_shell_quotes_a_metachar_path() {
let sb = Sandbox::new();
let vanished_project = sb.base.join("it's a $(vanished); project");
let unreachable_lobe = vanished_project.join(".windsurf");
sb.write_config(&format!(
"lobes = [\"{}\", \"{}\"]\n",
sb.claude_home.display(),
unreachable_lobe.display()
));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
let learn = sb.mind(&["learn", "*"]);
assert!(learn.success, "learn failed: {}", learn.stderr);
let path_str = unreachable_lobe.to_string_lossy().into_owned();
let quoted = shell_quote(&path_str);
assert!(
learn
.stdout
.contains(&format!("mind config lobes remove {quoted}")),
"the remedy must shell-quote the unreachable lobe's path: {}",
learn.stdout
);
assert!(
!learn
.stdout
.contains(&format!("mind config lobes remove {path_str}")),
"the raw path must never be spliced in unquoted: {}",
learn.stdout
);
}
#[test]
fn harn17_link_project_backfills_existing_items_immediately() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
let proj = sb.base.join("freshproj");
std::fs::create_dir_all(&proj).unwrap();
let proj_str = proj.to_string_lossy().into_owned();
let r = sb.mind(&["link-project", &proj_str]);
assert!(r.success, "link-project failed: {}", r.stderr);
assert!(
!r.stdout.contains("mind introspect --fix"),
"HARN-17: link-project's backfill is unconditional; no introspect \
note must be printed: {}",
r.stdout
);
assert!(
std::fs::symlink_metadata(proj.join(".windsurf/skills/review")).is_ok(),
"HARN-17: the already-installed skill must be linked immediately by \
link-project: {}",
r.stdout
);
}
#[test]
fn harn17_backfill_reports_foreign_target_without_clobbering() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
let new_lobe = sb.base.join("foreign-lobe");
let skills_dir = new_lobe.join("skills");
std::fs::create_dir_all(&skills_dir).unwrap();
let foreign_target = skills_dir.join("review");
let foreign_content = b"not managed by mind";
std::fs::write(&foreign_target, foreign_content).unwrap();
let new_lobe_str = new_lobe.to_string_lossy().into_owned();
let added = sb.mind(&["config", "lobes", "add", &new_lobe_str]);
assert!(added.success, "lobe add failed: {}", added.stderr);
assert!(
added.stdout.contains("--force"),
"a blocked backfill target must name the --force remedy: {}",
added.stdout
);
let meta = std::fs::symlink_metadata(&foreign_target).unwrap();
assert!(
!meta.file_type().is_symlink(),
"the foreign file must NOT be clobbered into a symlink"
);
assert_eq!(
std::fs::read(&foreign_target).unwrap(),
foreign_content,
"the foreign file's content must be untouched"
);
}
#[test]
fn harn8_introspect_fix_clobber_reports_finding_and_continues() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
let new_lobe = sb.base.join("blocked-lobe");
sb.write_config(&format!(
"lobes = [\"{}\", \"{}\"]\n",
sb.claude_home.display(),
new_lobe.display()
));
std::fs::create_dir_all(&new_lobe).unwrap();
std::fs::write(new_lobe.join("skills"), "blocking file").unwrap();
let run = sb.mind(&["introspect", "--fix", "--json"]);
assert!(
run.success,
"introspect --fix must not abort on a clobber conflict: {}",
run.stderr
);
let v = parse_json(&run.stdout);
let issues = v["issues"].as_array().expect("issues array");
assert!(
issues.iter().any(|i| i["kind"] == "missing-lobe-link"),
"a blocked link must produce a missing-lobe-link finding: {}",
run.stdout
);
assert!(
std::fs::symlink_metadata(new_lobe.join("skills/review")).is_err(),
"the blocked link must remain absent after --fix"
);
assert!(
std::fs::symlink_metadata(sb.claude_home.join("skills/review")).is_ok(),
"the original claude_home skill symlink must be intact after a blocked lobe error (HARN-8)"
);
}
#[test]
fn harn8_introspect_fix_reports_exactly_one_finding_per_blocked_link() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
let new_lobe = sb.base.join("blocked-lobe-count");
sb.write_config(&format!(
"lobes = [\"{}\", \"{}\"]\n",
sb.claude_home.display(),
new_lobe.display()
));
std::fs::create_dir_all(&new_lobe).unwrap();
std::fs::write(new_lobe.join("skills"), "blocking file").unwrap();
let run = sb.mind(&["introspect", "--fix", "--json"]);
assert!(
run.success,
"introspect --fix must not abort: {}",
run.stderr
);
let v = parse_json(&run.stdout);
let issues = v["issues"].as_array().expect("issues array");
let count = issues
.iter()
.filter(|i| i["kind"] == "missing-lobe-link")
.count();
assert_eq!(
count, 1,
"exactly one missing-lobe-link finding per blocked link, got {count}: {}",
run.stdout
);
}
#[test]
fn harn8_broken_recorded_link_fires_both_findings_without_fix() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
std::fs::remove_file(sb.claude_home.join("skills/review")).unwrap();
let intro = sb.mind(&["introspect", "--json"]);
let iv = parse_json(&intro.stdout);
let issues = iv["issues"].as_array().expect("issues array");
assert!(
issues.iter().any(|i| i["kind"] == "missing-link"),
"a broken recorded link must fire a missing-link finding: {}",
intro.stdout
);
assert!(
issues.iter().any(|i| i["kind"] == "missing-lobe-link"),
"a broken recorded link also fires missing-lobe-link (on-disk check, HARN-8): {}",
intro.stdout
);
let fixed = sb.mind(&["introspect", "--fix", "--json"]);
assert!(fixed.success, "{}", fixed.stderr);
let fv = parse_json(&fixed.stdout);
let fixed_issues = fv["issues"].as_array().expect("issues array");
assert!(
fixed_issues.is_empty(),
"after --fix no issues must remain for a broken recorded link: {}",
fixed.stdout
);
assert!(
std::fs::symlink_metadata(sb.claude_home.join("skills/review")).is_ok(),
"the symlink must be restored by --fix"
);
}
#[test]
fn introspect_fix_backfills_after_implicit_default_install() {
let sb = Sandbox::new();
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
assert!(
std::fs::symlink_metadata(sb.claude_home.join("skills/review")).is_ok(),
"skill must be linked into implicit claude_home"
);
let new_lobe = sb.base.join("newlobe");
sb.write_config(&format!(
"lobes = [\"{}\", \"{}\"]\n",
sb.claude_home.display(),
new_lobe.display()
));
let fixed = sb.mind(&["introspect", "--fix"]);
assert!(fixed.success, "introspect --fix failed: {}", fixed.stderr);
assert!(
std::fs::symlink_metadata(new_lobe.join("skills/review")).is_ok(),
"introspect --fix must create the missing lobe link: {}",
fixed.stdout
);
let intro = sb.mind(&["introspect", "--json"]);
let iv = parse_json(&intro.stdout);
let issues = iv["issues"].as_array().expect("issues array");
assert!(
!issues.iter().any(|i| i["kind"] == "missing-lobe-link"),
"after --fix no missing-lobe-link must remain: {}",
intro.stdout
);
}
#[test]
fn first_lobe_add_preserves_claude_home() {
let sb = Sandbox::new();
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
let new_lobe = sb.base.join("newlobe");
let new_lobe_str = new_lobe.to_string_lossy().into_owned();
assert!(
sb.mind(&["config", "lobes", "add", &new_lobe_str]).success,
"lobe add must succeed"
);
assert!(
sb.mind(&["learn", "review"]).success,
"learn after lobe-add failed"
);
assert!(
std::fs::symlink_metadata(sb.claude_home.join("skills/review")).is_ok(),
"new install must reach claude_home after first lobe-add"
);
assert!(
std::fs::symlink_metadata(new_lobe.join("skills/review")).is_ok(),
"new install must reach the explicitly-added lobe"
);
}
#[test]
fn lobe_add_claude_home_no_duplicate() {
let sb = Sandbox::new();
let ch = sb.claude_home.to_string_lossy().into_owned();
let added = sb.mind(&["config", "lobes", "add", &ch]);
assert!(
added.success,
"lobe add claude_home failed: {}",
added.stderr
);
let listed = sb.mind(&["config", "lobes", "list", "--json"]);
let lv = parse_json(&listed.stdout);
let lobes = lv["lobes"].as_array().expect("lobes array");
assert_eq!(
lobes.len(),
1,
"adding claude_home must not create a duplicate: {}",
listed.stdout
);
}
#[test]
fn preset_add_preserves_claude_home_on_empty_lobes_config() {
let sb = Sandbox::new();
sb.write_config("lobes = []\n");
let home_str = sb.base.to_string_lossy().into_owned();
let added = sb.mind_env(
&["config", "lobes", "add", "--preset", "gemini"],
&[("HOME", &home_str)],
);
assert!(added.success, "preset add failed: {}", added.stderr);
let listed = sb.mind(&["config", "lobes", "list", "--json"]);
let v = parse_json(&listed.stdout);
let lobes = v["lobes"].as_array().expect("lobes array");
assert_eq!(
lobes.len(),
2,
"claude_home must be auto-prepended before the gemini preset on empty config: {}",
listed.stdout
);
let ch = sb.claude_home.to_string_lossy().into_owned();
assert!(
lobes.iter().any(|l| {
l.as_str() == Some(ch.as_str()) || l["path"].as_str() == Some(ch.as_str())
}),
"claude_home must appear as first entry in the saved lobe list: {}",
listed.stdout
);
}
#[test]
fn detect_yes_preserves_claude_home_on_empty_lobes_config() {
let sb = Sandbox::new();
sb.write_config("lobes = []\n");
let detect_home = sb.base.join("detect");
std::fs::create_dir_all(detect_home.join(".gemini")).unwrap();
let detect_str = detect_home.to_string_lossy().into_owned();
let added = sb.mind_env(
&["config", "lobes", "detect", "--yes"],
&[("MIND_DETECT_HOME", &detect_str)],
);
assert!(added.success, "detect --yes failed: {}", added.stderr);
let listed = sb.mind(&["config", "lobes", "list", "--json"]);
let v = parse_json(&listed.stdout);
let lobes = v["lobes"].as_array().expect("lobes array");
assert_eq!(
lobes.len(),
2,
"claude_home must be auto-prepended by detect --yes on empty config: {}",
listed.stdout
);
let ch = sb.claude_home.to_string_lossy().into_owned();
assert!(
lobes.iter().any(|l| {
l.as_str() == Some(ch.as_str()) || l["path"].as_str() == Some(ch.as_str())
}),
"claude_home must appear in the saved lobe list after detect: {}",
listed.stdout
);
}
#[test]
fn link_project_adds_windsurf_lobe_and_fans_skill() {
let sb = Sandbox::new();
let proj = sb.base.join("myproject");
std::fs::create_dir_all(&proj).unwrap();
let proj_str = proj.to_string_lossy().into_owned();
let r = sb.mind(&["link-project", &proj_str]);
assert!(r.success, "link-project failed: {}", r.stderr);
let listed = sb.mind(&["config", "lobes", "list", "--json"]);
let v = parse_json(&listed.stdout);
let ws_path = proj.join(".windsurf").to_string_lossy().into_owned();
let ws_entry = v["lobes"]
.as_array()
.expect("lobes array")
.iter()
.find(|l| l["path"].as_str() == Some(ws_path.as_str()))
.unwrap_or_else(|| {
panic!(
"windsurf lobe at {ws_path} must be in config: {}",
listed.stdout
)
});
let kinds: Vec<&str> = ws_entry["kinds"]
.as_array()
.expect("kinds array")
.iter()
.map(|k| k.as_str().unwrap())
.collect();
assert_eq!(
kinds,
vec!["skill"],
"windsurf lobe must be skill-only (HARN-10)"
);
assert!(
r.stdout.contains("gitignore") || r.stdout.contains(".gitignore"),
"link-project must print gitignore guidance: {}",
r.stdout
);
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
assert!(sb.mind(&["learn", "style"]).success, "learn rule");
assert!(
std::fs::symlink_metadata(proj.join(".windsurf/skills/review")).is_ok(),
"skill must fan into the windsurf project lobe"
);
assert!(
std::fs::symlink_metadata(proj.join(".windsurf/rules")).is_err()
&& std::fs::symlink_metadata(proj.join(".windsurf/rules/style.md")).is_err(),
"rule must NOT land in the windsurf lobe (skill-only, HARN-10)"
);
}
#[test]
fn config_lobes_add_with_preset_and_subdir() {
let sb = Sandbox::new();
let proj = sb.base.join("proj2");
std::fs::create_dir_all(&proj).unwrap();
let proj_str = proj.to_string_lossy().into_owned();
let r = sb.mind(&["config", "lobes", "add", &proj_str, "--preset", "windsurf"]);
assert!(
r.success,
"config lobes add --preset windsurf failed: {}",
r.stderr
);
let ws_path = proj.join(".windsurf").to_string_lossy().into_owned();
let listed = sb.mind(&["config", "lobes", "list", "--json"]);
let v = parse_json(&listed.stdout);
assert!(
v["lobes"]
.as_array()
.unwrap()
.iter()
.any(|l| l["path"].as_str() == Some(ws_path.as_str())),
"config lobes add --preset windsurf must register the windsurf lobe: {}",
listed.stdout
);
let proj2 = sb.base.join("proj3");
std::fs::create_dir_all(&proj2).unwrap();
let proj2_str = proj2.to_string_lossy().into_owned();
let r2 = sb.mind(&["config", "lobes", "add", &proj2_str, "--subdir", ".cursor"]);
assert!(
r2.success,
"config lobes add --subdir failed: {}",
r2.stderr
);
let cursor_path = proj2.join(".cursor").to_string_lossy().into_owned();
let listed2 = sb.mind(&["config", "lobes", "list", "--json"]);
let v2 = parse_json(&listed2.stdout);
let cursor_entry = v2["lobes"]
.as_array()
.unwrap()
.iter()
.find(|l| l["path"].as_str() == Some(cursor_path.as_str()))
.unwrap_or_else(|| panic!("cursor lobe must be in config: {}", listed2.stdout));
assert_eq!(
cursor_entry["kinds"]
.as_array()
.unwrap()
.iter()
.map(|k| k.as_str().unwrap())
.collect::<Vec<_>>(),
vec!["skill"],
"subdir lobe must be skill-only"
);
let missing = sb.mind(&[
"config",
"lobes",
"add",
"/nonexistent/proj/xyz",
"--preset",
"windsurf",
]);
assert!(!missing.success, "missing base must fail");
assert!(
missing.stderr.contains("nonexistent")
|| missing.stderr.contains("not found")
|| missing.stderr.contains("does not exist")
|| missing.stderr.contains("missing"),
"error must mention the missing path: {}",
missing.stderr
);
}
#[test]
fn link_project_defaults_to_cwd() {
let sb = Sandbox::new();
let proj = sb.base.join("proj-cwd");
std::fs::create_dir_all(&proj).unwrap();
let proj = std::fs::canonicalize(&proj).unwrap();
let r = sb.run_cwd(&["link-project"], &[], Some(&proj));
assert!(r.success, "link-project (cwd) failed: {}", r.stderr);
let ws_path = proj.join(".windsurf").to_string_lossy().into_owned();
let listed = sb.mind(&["config", "lobes", "list", "--json"]);
let v = parse_json(&listed.stdout);
assert!(
v["lobes"]
.as_array()
.unwrap()
.iter()
.any(|l| l["path"].as_str() == Some(ws_path.as_str())),
"link-project with no dir must use cwd/.windsurf: {}",
listed.stdout
);
}
#[test]
fn snapshot_writes_real_files_no_lobe_registered() {
let sb = Sandbox::new();
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
let proj = sb.base.join("snapproj");
std::fs::create_dir_all(&proj).unwrap();
let proj_str = proj.to_string_lossy().into_owned();
let r = sb.mind(&["link-project", &proj_str, "--snapshot"]);
assert!(r.success, "link-project --snapshot failed: {}", r.stderr);
assert!(
r.stdout.contains("frozen") || r.stdout.contains("wrote"),
"snapshot must report frozen files: {}",
r.stdout
);
let skill_dir = proj.join(".windsurf/skills/review");
let md = std::fs::metadata(&skill_dir);
assert!(
md.is_ok(),
"skill dir must exist after snapshot: {}",
r.stdout
);
let sym_md = std::fs::symlink_metadata(&skill_dir).unwrap();
assert!(
!sym_md.file_type().is_symlink(),
"snapshot must write a real dir, not a symlink"
);
let listed = sb.mind(&["config", "lobes", "list", "--json"]);
let v = parse_json(&listed.stdout);
let ws_path = proj.join(".windsurf").to_string_lossy().into_owned();
assert!(
!v["lobes"]
.as_array()
.unwrap()
.iter()
.any(|l| l["path"].as_str() == Some(ws_path.as_str())),
"snapshot must NOT register a managed lobe: {}",
listed.stdout
);
let forget = sb.mind(&["forget", "review", "--yes"]);
assert!(forget.success, "forget failed: {}", forget.stderr);
assert!(
skill_dir.exists(),
"snapshot copy must survive forget (it is not a managed link)"
);
}
#[test]
fn lobe_remove_snapshot_converts_symlinks_to_real_files() {
let sb = Sandbox::new();
let proj = sb.base.join("rmsnap");
std::fs::create_dir_all(&proj).unwrap();
let proj_str = proj.to_string_lossy().into_owned();
assert!(sb.mind(&["link-project", &proj_str]).success);
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(
sb.mind(&["learn", "review", "--yes"]).success,
"learn skill"
);
let ws_path = proj.join(".windsurf").to_string_lossy().into_owned();
let skill_link = proj.join(".windsurf/skills/review");
assert!(
std::fs::symlink_metadata(&skill_link).is_ok(),
"skill symlink must exist in the windsurf lobe before remove"
);
let pre_sym_md = std::fs::symlink_metadata(&skill_link).unwrap();
assert!(
pre_sym_md.file_type().is_symlink(),
"pre-remove: the skill entry must be a symlink"
);
let r = sb.mind(&["config", "lobes", "remove", &ws_path, "--snapshot"]);
assert!(r.success, "lobe remove --snapshot failed: {}", r.stderr);
assert!(
r.stdout.contains("frozen") || r.stdout.contains("real"),
"remove --snapshot must report frozen links: {}",
r.stdout
);
let listed = sb.mind(&["config", "lobes", "list", "--json"]);
let v = parse_json(&listed.stdout);
assert!(
!v["lobes"]
.as_array()
.unwrap()
.iter()
.any(|l| l["path"].as_str() == Some(ws_path.as_str())),
"windsurf lobe must be gone from config after remove --snapshot: {}",
listed.stdout
);
assert!(
skill_link.exists(),
"skill dir must still exist after remove --snapshot"
);
let post_sym_md = std::fs::symlink_metadata(&skill_link).unwrap();
assert!(
!post_sym_md.file_type().is_symlink(),
"remove --snapshot must convert the symlink to a real file/dir"
);
}
#[test]
fn introspect_reports_vanished_lobe() {
let sb = Sandbox::new();
let proj = sb.base.join("vanished-proj");
std::fs::create_dir_all(&proj).unwrap();
let proj_str = proj.to_string_lossy().into_owned();
assert!(sb.mind(&["link-project", &proj_str]).success);
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(
sb.mind(&["learn", "review", "--yes"]).success,
"learn skill"
);
let ws_path = proj.join(".windsurf").to_string_lossy().into_owned();
let skill_link = proj.join(".windsurf/skills/review");
assert!(
std::fs::symlink_metadata(&skill_link).is_ok(),
"skill link must exist before vanishing the dir"
);
std::fs::remove_dir_all(&proj).unwrap();
let r = sb.mind(&["introspect", "--json"]);
assert!(
r.success,
"introspect must succeed even with a vanished lobe: {}",
r.stderr
);
let v = parse_json(&r.stdout);
let issues = v["issues"].as_array().expect("issues array");
assert!(
issues
.iter()
.any(|i| i["kind"].as_str() == Some("vanished-lobe")),
"introspect must report vanished-lobe finding: {}",
r.stdout
);
let vanished = issues
.iter()
.find(|i| i["kind"].as_str() == Some("vanished-lobe"))
.unwrap();
assert_eq!(
vanished["target"].as_str(),
Some(ws_path.as_str()),
"vanished-lobe target must be the windsurf lobe path"
);
let listed = sb.mind(&["config", "lobes", "list", "--json"]);
let lv = parse_json(&listed.stdout);
assert!(
lv["lobes"]
.as_array()
.unwrap()
.iter()
.any(|l| l["path"].as_str() == Some(ws_path.as_str())),
"config must retain the vanished lobe entry until --fix: {}",
listed.stdout
);
}
#[test]
fn introspect_fix_prunes_vanished_lobe() {
let sb = Sandbox::new();
let proj = sb.base.join("vanished-fix");
std::fs::create_dir_all(&proj).unwrap();
let proj_str = proj.to_string_lossy().into_owned();
assert!(sb.mind(&["link-project", &proj_str]).success);
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(
sb.mind(&["learn", "review", "--yes"]).success,
"learn skill"
);
let ws_path = proj.join(".windsurf").to_string_lossy().into_owned();
let manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(sb.mind_home.join("manifest.json")).unwrap())
.unwrap();
assert!(
manifest["items"]["skill:review"]["links"]
.as_array()
.unwrap()
.iter()
.any(|l| l.as_str().map(|s| s.starts_with(&ws_path)).unwrap_or(false)),
"manifest must record a link inside the windsurf lobe before fix"
);
std::fs::remove_dir_all(&proj).unwrap();
let r = sb.mind(&["introspect", "--fix", "--json"]);
assert!(r.success, "introspect --fix must succeed: {}", r.stderr);
let listed = sb.mind(&["config", "lobes", "list", "--json"]);
let lv = parse_json(&listed.stdout);
assert!(
!lv["lobes"]
.as_array()
.unwrap()
.iter()
.any(|l| l["path"].as_str() == Some(ws_path.as_str())),
"config must NOT contain the vanished lobe after --fix: {}",
listed.stdout
);
let manifest_after: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(sb.mind_home.join("manifest.json")).unwrap())
.unwrap();
let links_after = manifest_after["items"]["skill:review"]["links"]
.as_array()
.unwrap();
assert!(
!links_after
.iter()
.any(|l| l.as_str().map(|s| s.starts_with(&ws_path)).unwrap_or(false)),
"manifest must not retain links under the vanished lobe after --fix: {manifest_after:#?}"
);
}
#[test]
fn harn18_fixed_vanished_lobe_is_not_also_an_outstanding_issue() {
let sb = Sandbox::new();
let proj = sb.base.join("vanished-harn18");
std::fs::create_dir_all(&proj).unwrap();
let proj_str = proj.to_string_lossy().into_owned();
assert!(sb.mind(&["link-project", &proj_str]).success);
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(
sb.mind(&["learn", "review", "--yes"]).success,
"learn skill"
);
std::fs::remove_dir_all(&proj).unwrap();
let json_run = sb.mind(&["introspect", "--fix", "--json"]);
assert!(
json_run.success,
"introspect --fix failed: {}",
json_run.stderr
);
let v = parse_json(&json_run.stdout);
let issues = v["issues"].as_array().expect("issues array");
assert!(
issues.is_empty(),
"HARN-18: a vanished lobe pruned in this run must not remain as an \
outstanding issue: {}",
json_run.stdout
);
let sb2 = Sandbox::new();
let proj2 = sb2.base.join("vanished-harn18-text");
std::fs::create_dir_all(&proj2).unwrap();
let proj2_str = proj2.to_string_lossy().into_owned();
assert!(sb2.mind(&["link-project", &proj2_str]).success);
assert!(sb2.mind(&["meld", &sb2.source_spec()]).success);
assert!(
sb2.mind(&["learn", "review", "--yes"]).success,
"learn skill"
);
std::fs::remove_dir_all(&proj2).unwrap();
let text_run = sb2.mind(&["introspect", "--fix"]);
assert!(text_run.success, "{}", text_run.stderr);
assert!(
text_run
.stdout
.contains("pruned vanished lobe(s) from config"),
"the repair must still be reported: {}",
text_run.stdout
);
assert!(
!text_run.stdout.contains("issue(s) found"),
"HARN-18: a fully-repaired run must not report an outstanding issue \
count: {}",
text_run.stdout
);
}
#[test]
fn detect_project_scoped_preset_prints_guidance_not_add() {
let sb = Sandbox::new();
let detect_home = sb.base.join("detect-ws");
std::fs::create_dir_all(detect_home.join(".codeium/windsurf")).unwrap();
let detect_str = detect_home.to_string_lossy().into_owned();
let r = sb.mind_env(
&["config", "lobes", "detect"],
&[("MIND_DETECT_HOME", &detect_str)],
);
assert!(r.success, "detect failed: {}", r.stderr);
assert!(
r.stdout.contains("link-project"),
"detect of windsurf must print link-project guidance: {}",
r.stdout
);
let listed = sb.mind(&["config", "lobes", "list", "--json"]);
let v = parse_json(&listed.stdout);
let has_windsurf = v["lobes"]
.as_array()
.unwrap()
.iter()
.any(|l| l["path"].as_str().is_some_and(|p| p.ends_with(".windsurf")));
assert!(
!has_windsurf,
"detect must NOT add a project-scoped windsurf lobe: {}",
listed.stdout
);
let r_yes = sb.mind_env(
&["config", "lobes", "detect", "--yes"],
&[("MIND_DETECT_HOME", &detect_str)],
);
assert!(r_yes.success, "detect --yes failed: {}", r_yes.stderr);
let listed2 = sb.mind(&["config", "lobes", "list", "--json"]);
let v2 = parse_json(&listed2.stdout);
let has_windsurf2 = v2["lobes"]
.as_array()
.unwrap()
.iter()
.any(|l| l["path"].as_str().is_some_and(|p| p.ends_with(".windsurf")));
assert!(
!has_windsurf2,
"detect --yes must NOT auto-add a project-scoped preset: {}",
listed2.stdout
);
let r_json = sb.mind_env(
&["config", "lobes", "detect", "--json"],
&[("MIND_DETECT_HOME", &detect_str)],
);
assert!(r_json.success, "detect --json failed: {}", r_json.stderr);
let jv = parse_json(&r_json.stdout);
assert!(
jv["detected"]
.as_array()
.unwrap()
.iter()
.any(|d| d["preset"] == "windsurf" && d["scope"] == "project"),
"JSON detected must include windsurf with scope=project: {}",
r_json.stdout
);
assert!(
jv["guidance"]
.as_array()
.map(|a| !a.is_empty())
.unwrap_or(false),
"JSON must include a non-empty guidance array for project-scoped presets: {}",
r_json.stdout
);
}
#[test]
fn snapshot_force_overwrites_foreign_target() {
let sb = Sandbox::new();
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
let proj = sb.base.join("forcesnap");
std::fs::create_dir_all(&proj).unwrap();
let proj_str = proj.to_string_lossy().into_owned();
let target = proj.join(".windsurf/skills/review");
std::fs::create_dir_all(target.parent().unwrap()).unwrap();
std::fs::write(&target, "foreign content").unwrap();
let refused = sb.mind(&["link-project", &proj_str, "--snapshot"]);
assert!(
!refused.success,
"snapshot must refuse a foreign target without --force: stdout={} stderr={}",
refused.stdout, refused.stderr
);
assert_eq!(
std::fs::read_to_string(&target).unwrap(),
"foreign content",
"the foreign file must be left intact when the snapshot is refused"
);
let forced = sb.mind(&["link-project", &proj_str, "--snapshot", "--force"]);
assert!(
forced.success,
"snapshot --force must overwrite the foreign target: {}",
forced.stderr
);
assert!(
target.join("SKILL.md").is_file(),
"with --force the frozen skill dir must replace the foreign file: {}",
forced.stdout
);
let md = std::fs::symlink_metadata(&target).unwrap();
assert!(
!md.file_type().is_symlink() && md.file_type().is_dir(),
"the frozen skill must be a real directory after --force"
);
}
#[test]
fn lobe_re_add_json_reports_no_op() {
let sb = Sandbox::new();
let proj = sb.base.join("reproj");
std::fs::create_dir_all(&proj).unwrap();
let proj_str = proj.to_string_lossy().into_owned();
assert!(
sb.mind(&["link-project", &proj_str]).success,
"first link-project must succeed"
);
let second = sb.mind(&[
"config", "lobes", "add", &proj_str, "--preset", "windsurf", "--json",
]);
assert!(
second.success,
"re-adding a registered lobe must succeed: {}",
second.stderr
);
let v = parse_json(&second.stdout);
assert_eq!(v["action"], "lobe-add", "{}", second.stdout);
assert_eq!(
v["outcome"], "no-op",
"re-adding an already-registered lobe must be a no-op: {}",
second.stdout
);
let listed = sb.mind(&["config", "lobes", "list", "--json"]);
let lv = parse_json(&listed.stdout);
let ws_path = proj.join(".windsurf").to_string_lossy().into_owned();
let count = lv["lobes"]
.as_array()
.unwrap()
.iter()
.filter(|l| l["path"].as_str() == Some(ws_path.as_str()))
.count();
assert_eq!(
count, 1,
"no-op re-add must not duplicate the lobe: {}",
listed.stdout
);
}
#[test]
fn link_project_first_add_preserves_claude_home() {
let sb = Sandbox::new();
let proj = sb.base.join("firstlink");
std::fs::create_dir_all(&proj).unwrap();
let proj_str = proj.to_string_lossy().into_owned();
assert!(
sb.mind(&["link-project", &proj_str]).success,
"first link-project must succeed"
);
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
assert!(
std::fs::symlink_metadata(sb.claude_home.join("skills/review")).is_ok(),
"claude_home must be preserved as an explicit lobe after the first link-project"
);
assert!(
std::fs::symlink_metadata(proj.join(".windsurf/skills/review")).is_ok(),
"the skill must reach the project windsurf lobe"
);
}
#[test]
fn windsurf_skill_only_rejects_agent_and_tool() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-ws-kinds-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let source = base.join("agents");
write(
&source.join("skills/review/SKILL.md"),
"---\nname: review\ndescription: review\n---\n# review\n",
);
write(
&source.join("helper.md"),
"---\nname: helper\ndescription: an agent\n---\n# helper agent\n",
);
write(&source.join("toolkit/run.sh"), "#!/bin/sh\necho hi\n");
write(
&source.join("mind.toml"),
"[source]\ndescription = \"multi-kind source\"\n\n\
[[items]]\nkind = \"skill\"\nname = \"review\"\npath = \"skills/review\"\n\n\
[[items]]\nkind = \"agent\"\nname = \"helper\"\npath = \"helper.md\"\n\n\
[[items]]\nkind = \"tool\"\nname = \"toolkit\"\npath = \"toolkit\"\nlink = \"tools/toolkit\"\n",
);
git(&source, &["-c", "init.defaultBranch=main", "init", "-q"]);
git(&source, &["config", "user.email", "t@t"]);
git(&source, &["config", "user.name", "t"]);
git(&source, &["add", "-A"]);
git(&source, &["commit", "-qm", "initial"]);
let mind_home = base.join("mind");
let claude_home = base.join("claude");
std::fs::create_dir_all(&mind_home).unwrap();
let proj = base.join("proj");
std::fs::create_dir_all(&proj).unwrap();
let run = |args: &[&str]| -> Run {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_mind"));
cmd.args(args)
.env("MIND_HOME", &mind_home)
.env("CLAUDE_HOME", &claude_home)
.env_remove("MIND_AGENT_HOMES")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let out = cmd.output().expect("run mind");
Run {
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
success: out.status.success(),
}
};
assert!(run(&["link-project", proj.to_str().unwrap()]).success);
assert!(run(&["meld", source.to_str().unwrap()]).success);
assert!(run(&["learn", "review"]).success, "learn skill");
let la = run(&["learn", "helper"]);
assert!(la.success, "learn agent failed: {}", la.stderr);
let lt = run(&["learn", "toolkit"]);
assert!(lt.success, "learn tool failed: {}", lt.stderr);
assert!(
std::fs::symlink_metadata(proj.join(".windsurf/skills/review")).is_ok(),
"skill must land in the windsurf lobe"
);
assert!(
std::fs::symlink_metadata(proj.join(".windsurf/agents")).is_err()
&& std::fs::symlink_metadata(proj.join(".windsurf/agents/helper.md")).is_err(),
"an agent must NOT land in a skill-only windsurf lobe (HARN-10)"
);
assert!(
std::fs::symlink_metadata(proj.join(".windsurf/tools")).is_err()
&& std::fs::symlink_metadata(proj.join(".windsurf/tools/toolkit")).is_err(),
"a tool must NOT land in a skill-only windsurf lobe (HARN-10)"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn link_project_json_reports_mutation() {
let sb = Sandbox::new();
let proj = sb.base.join("jsonproj");
std::fs::create_dir_all(&proj).unwrap();
let proj_str = proj.to_string_lossy().into_owned();
let r = sb.mind(&["link-project", &proj_str, "--json"]);
assert!(r.success, "link-project --json failed: {}", r.stderr);
let v = parse_json(&r.stdout);
assert_eq!(v["action"], "lobe-add", "{}", r.stdout);
assert_eq!(v["outcome"], "added", "{}", r.stdout);
let ws_path = proj.join(".windsurf").to_string_lossy().into_owned();
assert_eq!(
v["target"].as_str(),
Some(ws_path.as_str()),
"target must be the windsurf lobe path: {}",
r.stdout
);
assert!(
!r.stdout.contains("gitignore"),
"JSON mode must not emit the prose gitignore note: {}",
r.stdout
);
}
#[test]
fn snapshot_json_reports_frozen_result() {
let sb = Sandbox::new();
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
let proj = sb.base.join("snapjson");
std::fs::create_dir_all(&proj).unwrap();
let proj_str = proj.to_string_lossy().into_owned();
let r = sb.mind(&["link-project", &proj_str, "--snapshot", "--json"]);
assert!(r.success, "snapshot --json failed: {}", r.stderr);
let v = parse_json(&r.stdout);
assert_eq!(v["schema"], 1, "schema version: {}", r.stdout);
assert_eq!(v["action"], "lobe-add", "action: {}", r.stdout);
assert_eq!(v["outcome"], "snapshot", "outcome: {}", r.stdout);
assert_eq!(v["count"], 1, "one item frozen: {}", r.stdout);
let installed: Vec<&str> = v["installed"]
.as_array()
.expect("installed array")
.iter()
.map(|k| k.as_str().unwrap())
.collect();
assert_eq!(
installed,
vec!["skill:review"],
"installed keys: {}",
r.stdout
);
let ws_path = proj.join(".windsurf").to_string_lossy().into_owned();
assert_eq!(
v["target"], ws_path,
"target is the lobe path: {}",
r.stdout
);
let skill_dir = proj.join(".windsurf/skills/review");
let md = std::fs::symlink_metadata(&skill_dir).expect("frozen skill dir must exist");
assert!(
!md.file_type().is_symlink() && md.file_type().is_dir(),
"snapshot must write a real dir under --json too"
);
let listed = sb.mind(&["config", "lobes", "list", "--json"]);
let lv = parse_json(&listed.stdout);
assert!(
!lv["lobes"]
.as_array()
.unwrap()
.iter()
.any(|l| l["path"].as_str() == Some(ws_path.as_str())),
"snapshot must not register a managed lobe even under --json: {}",
listed.stdout
);
}
#[test]
fn snapshot_json_no_items_reports_no_op() {
let sb = Sandbox::new();
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
let proj = sb.base.join("emptysnap");
std::fs::create_dir_all(&proj).unwrap();
let proj_str = proj.to_string_lossy().into_owned();
let r = sb.mind(&["link-project", &proj_str, "--snapshot", "--json"]);
assert!(r.success, "empty snapshot --json failed: {}", r.stderr);
let v = parse_json(&r.stdout);
assert_eq!(v["outcome"], "no-op", "outcome: {}", r.stdout);
assert_eq!(v["count"], 0, "count zero: {}", r.stdout);
assert!(
v["installed"].as_array().is_none_or(|a| a.is_empty()),
"installed empty: {}",
r.stdout
);
}
#[test]
fn remove_snapshot_json_reports_detached_count() {
let sb = Sandbox::new();
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
let proj = sb.base.join("detachjson");
std::fs::create_dir_all(&proj).unwrap();
let proj_str = proj.to_string_lossy().into_owned();
assert!(
sb.mind(&["link-project", &proj_str, "--yes"]).success,
"managed link-project"
);
let ws_path = proj.join(".windsurf").to_string_lossy().into_owned();
let r = sb.mind(&[
"config",
"lobes",
"remove",
&ws_path,
"--snapshot",
"--json",
]);
assert!(r.success, "remove --snapshot --json failed: {}", r.stderr);
let v = parse_json(&r.stdout);
assert_eq!(v["action"], "lobe-remove", "action: {}", r.stdout);
assert_eq!(v["outcome"], "detached", "outcome: {}", r.stdout);
assert_eq!(v["count"], 1, "one link frozen: {}", r.stdout);
}
#[test]
fn snapshot_no_items_is_noop_not_error() {
let sb = Sandbox::new();
let proj = sb.base.join("emptysnap");
std::fs::create_dir_all(&proj).unwrap();
let proj_str = proj.to_string_lossy().into_owned();
let r = sb.mind(&["link-project", &proj_str, "--snapshot"]);
assert!(
r.success,
"snapshot with nothing installed must succeed: {}",
r.stderr
);
assert!(
r.stdout.contains("no installed items"),
"empty snapshot must print the no-items note: {}",
r.stdout
);
assert!(
std::fs::symlink_metadata(proj.join(".windsurf/skills")).is_err(),
"no skills dir must be created when there is nothing to snapshot"
);
}
#[test]
fn link_project_twice_is_idempotent() {
let sb = Sandbox::new();
let proj = sb.base.join("twiceproj");
std::fs::create_dir_all(&proj).unwrap();
let proj_str = proj.to_string_lossy().into_owned();
assert!(
sb.mind(&["link-project", &proj_str]).success,
"first link-project must succeed"
);
let second = sb.mind(&["link-project", &proj_str]);
assert!(
second.success,
"second link-project (same dir) must succeed: {}",
second.stderr
);
assert!(
second.stdout.contains("already configured"),
"second link-project must report the lobe already configured: {}",
second.stdout
);
let listed = sb.mind(&["config", "lobes", "list", "--json"]);
let v = parse_json(&listed.stdout);
let ws_path = proj.join(".windsurf").to_string_lossy().into_owned();
let count = v["lobes"]
.as_array()
.unwrap()
.iter()
.filter(|l| l["path"].as_str() == Some(ws_path.as_str()))
.count();
assert_eq!(
count, 1,
"the windsurf lobe must appear exactly once after two link-projects: {}",
listed.stdout
);
}
#[test]
fn lobe_add_relative_base_resolves_absolute() {
let sb = Sandbox::new();
let proj = sb.base.join("relproj");
std::fs::create_dir_all(&proj).unwrap();
let r = sb.run_cwd(
&["config", "lobes", "add", "relproj", "--preset", "windsurf"],
&[],
Some(&sb.base),
);
assert!(r.success, "relative-base add failed: {}", r.stderr);
let listed = sb.mind(&["config", "lobes", "list", "--json"]);
let v = parse_json(&listed.stdout);
let has_abs = v["lobes"].as_array().unwrap().iter().any(|l| {
l["path"]
.as_str()
.map(|p| p.starts_with('/') && p.ends_with("relproj/.windsurf"))
.unwrap_or(false)
});
assert!(
has_abs,
"a relative base must resolve to an absolute windsurf lobe path (STO-16): {}",
listed.stdout
);
}
#[test]
fn lobe_remove_snapshot_multiple_items_keeps_store() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-rmsnap-multi-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let source = base.join("agents");
write(
&source.join("skills/one/SKILL.md"),
"---\nname: one\ndescription: skill one\n---\n# one\n",
);
write(
&source.join("skills/two/SKILL.md"),
"---\nname: two\ndescription: skill two\n---\n# two\n",
);
git(&source, &["-c", "init.defaultBranch=main", "init", "-q"]);
git(&source, &["config", "user.email", "t@t"]);
git(&source, &["config", "user.name", "t"]);
git(&source, &["add", "-A"]);
git(&source, &["commit", "-qm", "initial"]);
let mind_home = base.join("mind");
let claude_home = base.join("claude");
std::fs::create_dir_all(&mind_home).unwrap();
let proj = base.join("proj");
std::fs::create_dir_all(&proj).unwrap();
let run = |args: &[&str]| -> Run {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_mind"));
cmd.args(args)
.env("MIND_HOME", &mind_home)
.env("CLAUDE_HOME", &claude_home)
.env_remove("MIND_AGENT_HOMES")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let out = cmd.output().expect("run mind");
Run {
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
success: out.status.success(),
}
};
assert!(run(&["link-project", proj.to_str().unwrap()]).success);
assert!(run(&["meld", source.to_str().unwrap()]).success);
assert!(run(&["learn", "one"]).success, "learn one");
assert!(run(&["learn", "two"]).success, "learn two");
let ws_path = proj.join(".windsurf").to_string_lossy().into_owned();
let link_one = proj.join(".windsurf/skills/one");
let link_two = proj.join(".windsurf/skills/two");
assert!(
std::fs::symlink_metadata(&link_one)
.unwrap()
.file_type()
.is_symlink()
&& std::fs::symlink_metadata(&link_two)
.unwrap()
.file_type()
.is_symlink(),
"both skills must be symlinked into the windsurf lobe before remove"
);
let manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(mind_home.join("manifest.json")).unwrap())
.unwrap();
let store_one = manifest["items"]["skill:one"]["store"]
.as_str()
.unwrap()
.to_string();
let store_two = manifest["items"]["skill:two"]["store"]
.as_str()
.unwrap()
.to_string();
let r = run(&["config", "lobes", "remove", &ws_path, "--snapshot"]);
assert!(r.success, "remove --snapshot failed: {}", r.stderr);
for link in [&link_one, &link_two] {
let md = std::fs::symlink_metadata(link).unwrap();
assert!(
!md.file_type().is_symlink() && md.file_type().is_dir(),
"remove --snapshot must convert {link:?} to a real dir"
);
assert!(
link.join("SKILL.md").is_file(),
"the frozen copy at {link:?} must contain the skill file"
);
}
assert!(
mind_home.join(&store_one).join("SKILL.md").is_file(),
"store copy for skill:one must survive the freeze"
);
assert!(
mind_home.join(&store_two).join("SKILL.md").is_file(),
"store copy for skill:two must survive the freeze"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn harn15_lobe_add_creates_the_dir_with_nothing_installed() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
let new_lobe = sb.base.join("empty-manifest-lobe");
assert!(
std::fs::symlink_metadata(&new_lobe).is_err(),
"sanity: the lobe path must not exist before the add"
);
let new_lobe_str = new_lobe.to_string_lossy().into_owned();
let added = sb.mind(&["config", "lobes", "add", &new_lobe_str]);
assert!(added.success, "lobe add failed: {}", added.stderr);
assert!(
new_lobe.is_dir(),
"HARN-15: registering a lobe must create its directory even when there \
is nothing to backfill (nothing else can create it): {}",
added.stdout
);
let intro = sb.mind(&["introspect", "--fix", "--json"]);
assert!(intro.success, "introspect --fix failed: {}", intro.stderr);
let iv = parse_json(&intro.stdout);
assert!(
!iv["issues"]
.as_array()
.unwrap()
.iter()
.any(|i| i["kind"].as_str() == Some("vanished-lobe")),
"a lobe created at add time must not be reported vanished: {}",
intro.stdout
);
assert!(
new_lobe.is_dir(),
"the lobe dir must survive an `introspect --fix` pass"
);
}
#[test]
fn harn15_missing_explicit_base_is_refused_and_creates_nothing() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
let missing = sb.base.join("no-such-base");
let missing_str = missing.to_string_lossy().into_owned();
for args in [
vec![
"config",
"lobes",
"add",
&missing_str,
"--preset",
"windsurf",
],
vec![
"config",
"lobes",
"add",
&missing_str,
"--subdir",
".cursor",
],
vec!["link-project", &missing_str],
] {
let r = sb.mind(&args);
assert!(!r.success, "a missing explicit base must fail: {args:?}");
assert!(
std::fs::symlink_metadata(&missing).is_err(),
"HARN-15 must not create a mistyped BASE directory ({args:?}): {}",
r.stderr
);
}
let listed = sb.mind(&["config", "lobes", "list", "--json"]);
let v = parse_json(&listed.stdout);
assert!(
!v["lobes"].as_array().unwrap().iter().any(|l| l["path"]
.as_str()
.is_some_and(|p| p.contains("no-such-base"))),
"a refused add must leave no config entry behind: {}",
listed.stdout
);
}
#[test]
fn harn15_snapshot_add_creates_no_lobe_dir_and_no_config_entry() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
let snap = sb.base.join("snapshot-target");
let snap_str = snap.to_string_lossy().into_owned();
let r = sb.mind(&["config", "lobes", "add", &snap_str, "--snapshot"]);
assert!(r.success, "snapshot add failed: {}", r.stderr);
assert!(
std::fs::symlink_metadata(&snap).is_err(),
"HARN-15's mkdir is confined to the managed path; --snapshot must not \
create the target dir: {}",
r.stdout
);
let listed = sb.mind(&["config", "lobes", "list", "--json"]);
let v = parse_json(&listed.stdout);
assert!(
!v["lobes"].as_array().unwrap().iter().any(|l| l["path"]
.as_str()
.is_some_and(|p| p.contains("snapshot-target"))),
"--snapshot must register no config entry: {}",
listed.stdout
);
}
#[test]
fn harn16_note_is_per_lobe_and_names_both_remedies() {
let sb = Sandbox::new();
let gone_a = sb.base.join("gone-a").join(".windsurf");
let gone_b = sb.base.join("gone-b").join(".windsurf");
sb.write_config(&format!(
"lobes = [\"{}\", \"{}\", \"{}\"]\n",
sb.claude_home.display(),
gone_a.display(),
gone_b.display()
));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
let learn = sb.mind(&["learn", "*"]);
assert!(learn.success, "learn failed: {}", learn.stderr);
assert_eq!(
learn.stdout.matches("is unreachable").count(),
2,
"HARN-16: one note per unreachable LOBE (2 lobes x 2 items = 2 notes): {}",
learn.stdout
);
for lobe in [&gone_a, &gone_b] {
let s = lobe.to_string_lossy().into_owned();
assert_eq!(
learn
.stdout
.matches(&format!("lobe '{s}' is unreachable"))
.count(),
1,
"each unreachable lobe must be named exactly once: {}",
learn.stdout
);
}
assert!(
learn.stdout.contains("create the missing directory"),
"the note must name the create-the-directory remedy: {}",
learn.stdout
);
assert!(
learn.stdout.contains("config lobes remove"),
"the note must name the drop-the-lobe remedy: {}",
learn.stdout
);
let manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(sb.mind_home.join("manifest.json")).unwrap())
.unwrap();
let a = gone_a.to_string_lossy().into_owned();
assert!(
!manifest["items"]["skill:review"]["links"]
.as_array()
.unwrap()
.iter()
.any(|l| l.as_str().is_some_and(|s| s.starts_with(&a))),
"an unreachable lobe must contribute no manifest link: {manifest:#?}"
);
}
#[test]
fn harn16_no_note_for_an_unreachable_lobe_that_excludes_the_kind() {
let sb = Sandbox::new();
let gone = sb.base.join("gone-skillonly").join(".windsurf");
sb.write_config(&format!(
"lobes = [\"{}\", {{ path = \"{}\", kinds = [\"skill\"] }}]\n",
sb.claude_home.display(),
gone.display()
));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
let learn = sb.mind(&["learn", "style"]); assert!(learn.success, "learn rule failed: {}", learn.stderr);
assert!(
!learn.stdout.contains("is unreachable"),
"a skill-only lobe is excluded by the kinds filter before the \
reachability check, so installing a rule must not warn: {}",
learn.stdout
);
}
#[test]
fn harn17_force_overrides_the_foreign_target_guard() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
let new_lobe = sb.base.join("forced-lobe");
std::fs::create_dir_all(new_lobe.join("skills")).unwrap();
let foreign_target = new_lobe.join("skills/review");
std::fs::write(&foreign_target, b"not managed by mind").unwrap();
let new_lobe_str = new_lobe.to_string_lossy().into_owned();
let added = sb.mind(&["config", "lobes", "add", "--force", &new_lobe_str]);
assert!(added.success, "forced lobe add failed: {}", added.stderr);
assert!(
!added.stdout.contains("could not link"),
"--force must not report a blocked target: {}",
added.stdout
);
let md = std::fs::symlink_metadata(&foreign_target).unwrap();
assert!(
md.file_type().is_symlink(),
"--force must replace the foreign file with mind's symlink"
);
let target = std::fs::read_link(&foreign_target).unwrap();
assert!(
target.starts_with(sb.mind_home.join("store")),
"the forced link must point into the store: {target:?}"
);
let manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(sb.mind_home.join("manifest.json")).unwrap())
.unwrap();
let expected = foreign_target.to_string_lossy().into_owned();
assert!(
manifest["items"]["skill:review"]["links"]
.as_array()
.unwrap()
.iter()
.any(|l| l.as_str() == Some(expected.as_str())),
"a forced link must be recorded in the manifest: {manifest:#?}"
);
}
#[test]
fn harn17_blocked_backfill_keeps_manifest_honest_and_continues() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
assert!(sb.mind(&["learn", "style"]).success, "learn rule");
let new_lobe = sb.base.join("partial-lobe");
std::fs::create_dir_all(new_lobe.join("skills")).unwrap();
let foreign_target = new_lobe.join("skills/review");
let foreign_content = b"user content, not mind's";
std::fs::write(&foreign_target, foreign_content).unwrap();
let new_lobe_str = new_lobe.to_string_lossy().into_owned();
let added = sb.mind(&["config", "lobes", "add", &new_lobe_str]);
assert!(
added.success,
"a blocked target is a warning, not a failure: {}",
added.stderr
);
assert_eq!(
std::fs::read(&foreign_target).unwrap(),
foreign_content,
"the foreign file's content must be untouched"
);
let rule_link = new_lobe.join("rules/style.md");
assert!(
std::fs::symlink_metadata(&rule_link).is_ok(),
"a blocked target for one item must not stop the next item from linking: {}",
added.stdout
);
let manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(sb.mind_home.join("manifest.json")).unwrap())
.unwrap();
let blocked = foreign_target.to_string_lossy().into_owned();
let skill_links: Vec<&str> = manifest["items"]["skill:review"]["links"]
.as_array()
.unwrap()
.iter()
.map(|l| l.as_str().unwrap())
.collect();
assert!(
!skill_links.contains(&blocked.as_str()),
"a link that was NOT created must never be recorded: {skill_links:?}"
);
assert!(
skill_links
.iter()
.any(|l| l.starts_with(&sb.claude_home.to_string_lossy().into_owned())),
"the pre-existing claude_home link must survive the partial backfill: {skill_links:?}"
);
let rule_links: Vec<&str> = manifest["items"]["rule:style"]["links"]
.as_array()
.unwrap()
.iter()
.map(|l| l.as_str().unwrap())
.collect();
let rule_link_str = rule_link.to_string_lossy().into_owned();
assert!(
rule_links.contains(&rule_link_str.as_str()),
"the link that WAS created must be recorded: {rule_links:?}"
);
let intro = sb.mind(&["introspect", "--json"]);
let iv = parse_json(&intro.stdout);
assert!(
iv["issues"]
.as_array()
.unwrap()
.iter()
.any(|i| i["kind"].as_str() == Some("missing-lobe-link")
&& i["target"].as_str() == Some("skill:review")),
"the blocked link must surface as a missing-lobe-link finding: {}",
intro.stdout
);
let fixed = sb.mind(&["introspect", "--fix"]);
assert!(fixed.success, "introspect --fix failed: {}", fixed.stderr);
assert_eq!(
std::fs::read(&foreign_target).unwrap(),
foreign_content,
"`introspect --fix` has no --force, so it must not clobber the file either"
);
}
#[test]
fn harn17_foreign_symlink_at_target_is_not_followed_or_replaced() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
let elsewhere = sb.base.join("user-owned-review");
std::fs::create_dir_all(&elsewhere).unwrap();
let new_lobe = sb.base.join("symlinked-lobe");
std::fs::create_dir_all(new_lobe.join("skills")).unwrap();
let target = new_lobe.join("skills/review");
std::os::unix::fs::symlink(&elsewhere, &target).unwrap();
let new_lobe_str = new_lobe.to_string_lossy().into_owned();
let added = sb.mind(&["config", "lobes", "add", &new_lobe_str]);
assert!(added.success, "lobe add failed: {}", added.stderr);
assert_eq!(
std::fs::read_link(&target).unwrap(),
elsewhere,
"a symlink that does not point into the store is the user's and must \
be left pointing where it pointed: {}",
added.stdout
);
assert!(
added.stdout.contains("--force"),
"the blocked target must be reported with the --force remedy: {}",
added.stdout
);
}
#[test]
fn harn17_detect_backfill_does_not_clobber_a_foreign_target() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
let detect_home = sb.base.join("detect-foreign");
let lobe = detect_home.join(".gemini/config");
std::fs::create_dir_all(lobe.join("skills")).unwrap();
let foreign_target = lobe.join("skills/review");
let foreign_content = b"user content in the gemini home";
std::fs::write(&foreign_target, foreign_content).unwrap();
let detect_str = detect_home.to_string_lossy().into_owned();
let r = sb.mind_env(
&["config", "lobes", "detect", "--yes"],
&[("MIND_DETECT_HOME", &detect_str)],
);
assert!(r.success, "detect --yes failed: {}", r.stderr);
let md = std::fs::symlink_metadata(&foreign_target).unwrap();
assert!(
!md.file_type().is_symlink(),
"detect's backfill must not turn a foreign file into a symlink"
);
assert_eq!(
std::fs::read(&foreign_target).unwrap(),
foreign_content,
"detect's backfill must leave foreign content untouched"
);
assert!(
r.stdout.contains("--force"),
"the blocked target must be reported with the --force remedy: {}",
r.stdout
);
let manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(sb.mind_home.join("manifest.json")).unwrap())
.unwrap();
let blocked = foreign_target.to_string_lossy().into_owned();
assert!(
!manifest["items"]["skill:review"]["links"]
.as_array()
.unwrap()
.iter()
.any(|l| l.as_str() == Some(blocked.as_str())),
"a link that was not created must not be recorded: {manifest:#?}"
);
}
#[test]
fn harn15_lobe_path_occupied_by_a_file_fails_without_registering() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
let occupied = sb.base.join("a-file-not-a-lobe");
std::fs::write(&occupied, b"i am a file").unwrap();
let occupied_str = occupied.to_string_lossy().into_owned();
let r = sb.mind(&["config", "lobes", "add", &occupied_str]);
assert!(
!r.success,
"a lobe path occupied by a file must fail, not silently register: {}",
r.stdout
);
assert_eq!(
std::fs::read(&occupied).unwrap(),
b"i am a file",
"the user's file must be untouched"
);
let listed = sb.mind(&["config", "lobes", "list", "--json"]);
let v = parse_json(&listed.stdout);
assert!(
!v["lobes"]
.as_array()
.unwrap()
.iter()
.any(|l| l["path"].as_str() == Some(occupied_str.as_str())),
"a failed mkdir must leave no config entry: {}",
listed.stdout
);
}
#[test]
fn harn17_backfill_has_no_interactive_or_deferral_branch() {
let src = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/commands.rs"),
)
.expect("read src/commands.rs");
let start = src
.find("fn backfill_new_lobes(")
.expect("backfill_new_lobes must exist in src/commands.rs");
let body = &src[start..];
let end = body.find("\n}\n").expect("end of backfill_new_lobes");
let body = &body[..end];
assert!(
body.contains("link_into_new_lobes"),
"the sliced body must be backfill_new_lobes itself:\n{body}"
);
for forbidden in [
"is_tty",
"confirm_default_yes",
"confirm(",
"introspect --fix",
] {
assert!(
!body.contains(forbidden),
"HARN-17: the backfill must be unconditional in every mode, but its \
body mentions `{forbidden}`:\n{body}"
);
}
}
#[test]
fn harn18_repaired_finding_is_dropped_without_hiding_the_rest() {
let sb = Sandbox::new();
let proj = sb.base.join("harn18-mixed");
std::fs::create_dir_all(&proj).unwrap();
let proj_str = proj.to_string_lossy().into_owned();
assert!(sb.mind(&["link-project", &proj_str]).success);
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(
sb.mind(&["learn", "review", "--yes"]).success,
"learn skill"
);
let ws_path = proj.join(".windsurf").to_string_lossy().into_owned();
std::fs::remove_dir_all(&proj).unwrap();
std::fs::write(sb.source.join("mind.toml"), "this is not = = valid toml\n").unwrap();
std::fs::write(
sb.source.join("skills/review/SKILL.md"),
"---\nname: review\ndescription: Review the diff for bugs\n---\n# review skill (edited)\n",
)
.unwrap();
let r = sb.mind(&["introspect", "--fix", "--json"]);
assert!(r.success, "introspect --fix failed: {}", r.stderr);
let v = parse_json(&r.stdout);
let issues = v["issues"].as_array().expect("issues array");
assert!(
!issues
.iter()
.any(|i| i["kind"].as_str() == Some("vanished-lobe")
&& i["target"].as_str() == Some(ws_path.as_str())),
"HARN-18: the lobe pruned in this run must not also be reported: {}",
r.stdout
);
assert!(
issues
.iter()
.any(|i| i["kind"].as_str() == Some("source-scan-failed")),
"HARN-18 drops only the REPAIRED finding; a finding raised BEFORE the \
filter must survive it: {}",
r.stdout
);
let sb2 = Sandbox::new();
let proj2 = sb2.base.join("harn18-mixed-text");
std::fs::create_dir_all(&proj2).unwrap();
let proj2_str = proj2.to_string_lossy().into_owned();
assert!(sb2.mind(&["link-project", &proj2_str]).success);
assert!(sb2.mind(&["meld", &sb2.source_spec()]).success);
assert!(
sb2.mind(&["learn", "review", "--yes"]).success,
"learn skill"
);
std::fs::remove_dir_all(&proj2).unwrap();
std::fs::write(sb2.source.join("mind.toml"), "this is not = = valid toml\n").unwrap();
let text = sb2.mind(&["introspect", "--fix"]);
assert!(text.success, "{}", text.stderr);
assert!(
text.stdout.contains("pruned vanished lobe(s) from config"),
"the repair must still be reported: {}",
text.stdout
);
assert!(
text.stdout.contains("issue(s) found"),
"a run with a surviving issue must still report a count: {}",
text.stdout
);
assert!(
!text.stdout.contains("parent dir is gone"),
"the repaired vanished-lobe line must not be printed as outstanding: {}",
text.stdout
);
}
#[test]
fn harn18_without_fix_the_vanished_lobe_is_still_counted() {
let sb = Sandbox::new();
let proj = sb.base.join("harn18-nofix");
std::fs::create_dir_all(&proj).unwrap();
let proj_str = proj.to_string_lossy().into_owned();
assert!(sb.mind(&["link-project", &proj_str]).success);
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(
sb.mind(&["learn", "review", "--yes"]).success,
"learn skill"
);
std::fs::remove_dir_all(&proj).unwrap();
let r = sb.mind(&["introspect"]);
assert!(r.success, "introspect failed: {}", r.stderr);
assert!(
r.stdout.contains("parent dir is gone"),
"without --fix the vanished lobe must be reported: {}",
r.stdout
);
assert!(
r.stdout.contains("issue(s) found"),
"without --fix the vanished lobe must be COUNTED: {}",
r.stdout
);
}
#[test]
fn harn16_unreachable_note_keeps_json_stdout_parseable() {
let sb = Sandbox::new();
let gone = sb.base.join("gone-json").join(".windsurf");
sb.write_config(&format!(
"lobes = [\"{}\", \"{}\"]\n",
sb.claude_home.display(),
gone.display()
));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
let learn = sb.mind(&["learn", "review", "--json"]);
assert!(learn.success, "learn --json failed: {}", learn.stderr);
assert!(
serde_json::from_str::<serde_json::Value>(learn.stdout.trim()).is_ok(),
"stdout under --json must be a single JSON document; the HARN-16 note \
must be suppressed or routed to stderr: {}",
learn.stdout
);
assert!(
learn.stderr.contains("is unreachable"),
"the HARN-16 note must still be emitted, on stderr: {}",
learn.stderr
);
}
#[test]
fn harn17_blocked_backfill_keeps_json_stdout_parseable() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
let new_lobe = sb.base.join("json-blocked-lobe");
std::fs::create_dir_all(new_lobe.join("skills")).unwrap();
std::fs::write(new_lobe.join("skills/review"), b"user content").unwrap();
let new_lobe_str = new_lobe.to_string_lossy().into_owned();
let added = sb.mind(&["config", "lobes", "add", "--json", &new_lobe_str]);
assert!(added.success, "lobe add --json failed: {}", added.stderr);
assert!(
serde_json::from_str::<serde_json::Value>(added.stdout.trim()).is_ok(),
"stdout under --json must be a single JSON document; the blocked-target \
warning must be suppressed or routed to stderr: {}",
added.stdout
);
assert!(
added.stderr.contains("could not link"),
"the blocked-target warning must still be emitted, on stderr: {}",
added.stderr
);
}
#[test]
fn harn17_blocked_backfill_warning_is_marked_and_on_stdout_in_text_mode() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
let new_lobe = sb.base.join("text-blocked-lobe");
std::fs::create_dir_all(new_lobe.join("skills")).unwrap();
std::fs::write(new_lobe.join("skills/review"), b"user content").unwrap();
let new_lobe_str = new_lobe.to_string_lossy().into_owned();
let added = sb.mind(&["config", "lobes", "add", &new_lobe_str]);
assert!(added.success, "lobe add failed: {}", added.stderr);
assert!(
added.stdout.contains("! could not link"),
"in text mode the blocked-target warning belongs on STDOUT, carrying the \
warn marker: stdout={} stderr={}",
added.stdout,
added.stderr
);
assert!(
!added.stderr.contains("could not link"),
"text mode must not route the warning to stderr: {}",
added.stderr
);
}
fn setup_project_lobe(sb: &Sandbox) -> (PathBuf, PathBuf) {
let proj = sb.base.join("proj");
let local_lobe = proj.join("locallobe");
std::fs::create_dir_all(&proj).unwrap();
sb.write_config(&format!(
"lobes = [\"{claude}\", \"{local}\"]\n",
claude = sb.claude_home.display(),
local = local_lobe.display(),
));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
(proj, local_lobe)
}
#[test]
fn local_learn_links_only_into_the_project_lobe() {
let sb = Sandbox::new();
let (proj, local_lobe) = setup_project_lobe(&sb);
let learned = sb.run_cwd(&["learn", "review", "--local"], &[], Some(&proj));
assert!(
learned.success,
"learn --local inside a project lobe must succeed: {}",
learned.stderr
);
assert!(
std::fs::symlink_metadata(local_lobe.join("skills/review")).is_ok(),
"the skill must link into the project lobe"
);
assert!(
std::fs::symlink_metadata(sb.claude_home.join("skills/review")).is_err(),
"--local must NOT fan the skill into the global Claude home: stderr={}",
learned.stderr
);
}
#[test]
fn local_learn_outside_any_lobe_is_an_error() {
let sb = Sandbox::new();
let (_proj, local_lobe) = setup_project_lobe(&sb);
let elsewhere = sb.base.join("elsewhere");
std::fs::create_dir_all(&elsewhere).unwrap();
let learned = sb.run_cwd(&["learn", "review", "--local"], &[], Some(&elsewhere));
assert!(
!learned.success,
"--local outside a registered project lobe must fail: {}",
learned.stdout
);
assert!(
learned
.stderr
.contains("no registered project lobe lives under this directory"),
"the error must explain that the cwd is not inside a project lobe: {}",
learned.stderr
);
assert!(
std::fs::symlink_metadata(sb.claude_home.join("skills/review")).is_err()
&& std::fs::symlink_metadata(local_lobe.join("skills/review")).is_err(),
"a refused --local install must link nothing"
);
}
#[test]
fn global_learn_still_fans_out_to_all_lobes() {
let sb = Sandbox::new();
let (_proj, local_lobe) = setup_project_lobe(&sb);
let elsewhere = sb.base.join("elsewhere");
std::fs::create_dir_all(&elsewhere).unwrap();
let learned = sb.run_cwd(&["learn", "review"], &[], Some(&elsewhere));
assert!(learned.success, "global learn failed: {}", learned.stderr);
assert!(
std::fs::symlink_metadata(sb.claude_home.join("skills/review")).is_ok(),
"global learn must link into the Claude home"
);
assert!(
std::fs::symlink_metadata(local_lobe.join("skills/review")).is_ok(),
"global learn must also fan into the project lobe"
);
}
#[test]
fn local_learn_from_global_lobes_parent_dir_refuses() {
let sb = Sandbox::new();
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
let learned = sb.run_cwd(&["learn", "review", "--local"], &[], Some(&sb.base));
assert!(
!learned.success,
"--local from the global lobe's parent directory must be refused, not \
narrowed to the global home: stdout={} stderr={}",
learned.stdout, learned.stderr
);
assert!(
learned
.stderr
.contains("no registered project lobe lives under this directory"),
"the error must explain that the cwd is not inside a project lobe: {}",
learned.stderr
);
assert!(
std::fs::symlink_metadata(sb.claude_home.join("skills/review")).is_err(),
"a refused --local install must not link into the global home: {}",
learned.stderr
);
}
#[test]
fn local_learn_picks_the_deeper_of_two_qualifying_lobes() {
let sb = Sandbox::new();
let proj = sb.base.join("proj");
let shallow = proj.join("shallowlobe");
let deep = proj.join("shallowlobe").join("nested").join("deeplobe");
std::fs::create_dir_all(deep.parent().unwrap()).unwrap();
sb.write_config(&format!(
"lobes = [\"{claude}\", \"{shallow}\", \"{deep}\"]\n",
claude = sb.claude_home.display(),
shallow = shallow.display(),
deep = deep.display(),
));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
let learned = sb.run_cwd(&["learn", "review", "--local"], &[], Some(&proj));
assert!(learned.success, "{}", learned.stderr);
assert!(
learned
.stderr
.contains(&deep.to_string_lossy().into_owned()),
"the note must name the deeper (winning) lobe: {}",
learned.stderr
);
assert!(
std::fs::symlink_metadata(deep.join("skills/review")).is_ok(),
"the deeper lobe must receive the link"
);
assert!(
std::fs::symlink_metadata(shallow.join("skills/review")).is_err(),
"the shallower (losing) lobe must NOT receive the link"
);
assert!(
std::fs::symlink_metadata(sb.claude_home.join("skills/review")).is_err(),
"the global lobe must not receive the link either"
);
}
#[test]
fn local_learn_json_scopes_to_one_lobe_and_suppresses_the_note() {
let sb = Sandbox::new();
let (proj, local_lobe) = setup_project_lobe(&sb);
let learned = sb.run_cwd(&["--json", "learn", "review", "--local"], &[], Some(&proj));
assert!(learned.success, "{} {}", learned.stdout, learned.stderr);
let v = parse_json(&learned.stdout);
assert_eq!(v["schema"], 1, "{}", learned.stdout);
assert!(
std::fs::symlink_metadata(local_lobe.join("skills/review")).is_ok(),
"--local --json must still link into the project lobe"
);
assert!(
std::fs::symlink_metadata(sb.claude_home.join("skills/review")).is_err(),
"--local --json must NOT fan into the global Claude home"
);
assert!(
!learned.stderr.contains("--local"),
"the --local scoping note must be suppressed under --json: {}",
learned.stderr
);
}
#[test]
fn local_meld_json_links_only_into_the_project_lobe() {
let sb = Sandbox::new();
let proj = sb.base.join("proj");
let local_lobe = proj.join("locallobe");
std::fs::create_dir_all(&proj).unwrap();
sb.write_config(&format!(
"lobes = [\"{claude}\", \"{local}\"]\n",
claude = sb.claude_home.display(),
local = local_lobe.display(),
));
let spec = sb.source_spec();
let melded = sb.run_cwd(
&["--json", "--yes", "meld", &spec, "--local"],
&[],
Some(&proj),
);
assert!(melded.success, "{} {}", melded.stdout, melded.stderr);
assert!(
std::fs::symlink_metadata(local_lobe.join("skills/review")).is_ok(),
"meld --local must link into the project lobe: {}",
melded.stdout
);
assert!(
std::fs::symlink_metadata(sb.claude_home.join("skills/review")).is_err(),
"meld --local must NOT fan into the global Claude home"
);
}
#[test]
fn plain_learn_inside_a_project_lobe_prints_the_local_availability_note() {
let sb = Sandbox::new();
let (proj, local_lobe) = setup_project_lobe(&sb);
let learned = sb.run_cwd(&["learn", "review"], &[], Some(&proj));
assert!(learned.success, "{}", learned.stderr);
assert!(
learned.stderr.contains("--local"),
"a plain learn inside a registered project lobe must advertise \
--local on stderr: {}",
learned.stderr
);
assert!(std::fs::symlink_metadata(sb.claude_home.join("skills/review")).is_ok());
assert!(std::fs::symlink_metadata(local_lobe.join("skills/review")).is_ok());
let json_run = sb.run_cwd(&["--json", "learn", "review"], &[], Some(&proj));
assert!(json_run.success, "{}", json_run.stderr);
assert!(
!json_run.stderr.contains("--local"),
"the availability note must be suppressed under --json: {}",
json_run.stderr
);
}
#[test]
fn local_learn_from_home_rooted_preset_lobe_refuses() {
let sb = Sandbox::new();
let home_dir = sb.base.join("home");
let gemini_lobe = home_dir.join(".gemini").join("config");
std::fs::create_dir_all(&home_dir).unwrap();
sb.write_config(&format!(
"lobes = [\"{claude}\", \"{gemini}\"]\n",
claude = sb.claude_home.display(),
gemini = gemini_lobe.display(),
));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
let home_str = home_dir.to_string_lossy().into_owned();
let learned = sb.run_cwd(
&["learn", "review", "--local"],
&[("HOME", home_str.as_str())],
Some(&home_dir),
);
assert!(
!learned.success,
"--local from a home-rooted preset lobe's home directory must be \
refused, not narrowed to that preset lobe: stdout={} stderr={}",
learned.stdout, learned.stderr
);
assert!(
learned
.stderr
.contains("no registered project lobe lives under this directory"),
"the error must explain that the cwd is not inside a project lobe: {}",
learned.stderr
);
assert!(
std::fs::symlink_metadata(gemini_lobe.join("skills/review")).is_err(),
"a refused --local install must not link into the home-rooted preset \
lobe: {}",
learned.stderr
);
assert!(
std::fs::symlink_metadata(sb.claude_home.join("skills/review")).is_err(),
"a refused --local install must not link into the global home either"
);
}
#[test]
fn local_learn_project_lobe_under_home_dir_is_still_detected() {
let sb = Sandbox::new();
let home_dir = sb.base.join("home");
let proj = home_dir.join("dev").join("proj");
let local_lobe = proj.join("locallobe");
std::fs::create_dir_all(&proj).unwrap();
sb.write_config(&format!(
"lobes = [\"{claude}\", \"{local}\"]\n",
claude = sb.claude_home.display(),
local = local_lobe.display(),
));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
let home_str = home_dir.to_string_lossy().into_owned();
let learned = sb.run_cwd(
&["learn", "review", "--local"],
&[("HOME", home_str.as_str())],
Some(&proj),
);
assert!(
learned.success,
"a genuine project lobe under the home directory, with cwd below the \
home directory, must still be detected: {}",
learned.stderr
);
assert!(
std::fs::symlink_metadata(local_lobe.join("skills/review")).is_ok(),
"the project lobe under the home directory must receive the link"
);
assert!(
std::fs::symlink_metadata(sb.claude_home.join("skills/review")).is_err(),
"--local must NOT fan the skill into the global Claude home"
);
}
#[test]
fn local_learn_mind_default_lobe_as_global_home_is_excluded() {
let sb = Sandbox::new();
let default_lobe_dir = sb.base.join("customdefault");
std::fs::create_dir_all(&default_lobe_dir).unwrap();
let default_str = default_lobe_dir.to_string_lossy().into_owned();
assert!(
sb.mind_env(
&["meld", &sb.source_spec()],
&[("MIND_DEFAULT_LOBE", default_str.as_str())]
)
.success
);
let learned = sb.run_cwd(
&["learn", "review", "--local"],
&[("MIND_DEFAULT_LOBE", default_str.as_str())],
Some(&sb.base),
);
assert!(
!learned.success,
"--local from the MIND_DEFAULT_LOBE-resolved global home's parent \
directory must be refused, not narrowed to that home: stdout={} \
stderr={}",
learned.stdout, learned.stderr
);
assert!(
learned
.stderr
.contains("no registered project lobe lives under this directory"),
"the error must explain that the cwd is not inside a project lobe: {}",
learned.stderr
);
assert!(
std::fs::symlink_metadata(default_lobe_dir.join("skills/review")).is_err(),
"a refused --local install must not link into the MIND_DEFAULT_LOBE \
home: {}",
learned.stderr
);
assert!(
std::fs::symlink_metadata(sb.claude_home.join("skills/review")).is_err(),
"a refused --local install must not link into the (unused) CLAUDE_HOME \
either"
);
}
#[cfg(unix)]
#[test]
fn learn_into_a_lobe_whose_directory_is_a_dangling_symlink_names_the_broken_link() {
let sb = Sandbox::new();
let broken_lobe = sb.base.join("brokenlobe");
let missing_target = sb.base.join("gone");
std::os::unix::fs::symlink(&missing_target, &broken_lobe).unwrap();
sb.write_config(&format!("lobes = [\"{}\"]\n", broken_lobe.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
let learned = sb.mind(&["learn", "review"]);
assert!(
!learned.success,
"learn into a lobe whose directory is a dangling symlink must fail: \
stdout={} stderr={}",
learned.stdout, learned.stderr
);
assert!(
learned.stderr.contains("brokenlobe"),
"the error must name the broken link: {}",
learned.stderr
);
assert!(
learned.stderr.contains("gone"),
"the error must name the link's (missing) target: {}",
learned.stderr
);
assert!(
!learned.stderr.contains("os error 17") && !learned.stderr.contains("File exists"),
"must not surface the OS's bare File-exists errno as the reported cause: {}",
learned.stderr
);
let learned_json = sb.mind(&["learn", "review", "--json"]);
assert!(!learned_json.success);
let v = parse_json(&learned_json.stdout);
assert_eq!(
v["error"]["kind"], "broken-symlink-path",
"expected the broken-symlink-path error kind: {v}"
);
}
#[test]
fn wf13_project_lobe_with_no_kinds_filter_admits_a_workflow() {
let sb = Sandbox::new();
write(
&sb.source.join("workflows/deploy.js"),
"export const meta = {\n name: 'deploy',\n description: 'Deploy it',\n}\n",
);
git(&sb.source, &["add", "-A"]);
git(&sb.source, &["commit", "-qm", "add workflow"]);
let proj = sb.base.join("proj");
std::fs::create_dir_all(&proj).unwrap();
let lobe = proj.join(".claude");
let lobe_str = lobe.to_string_lossy().into_owned();
let added = sb.mind(&["config", "lobes", "add", &lobe_str]);
assert!(added.success, "config lobes add failed: {}", added.stderr);
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
let learned = sb.mind(&["learn", "workflow:deploy"]);
assert!(
learned.success,
"learn workflow: {}\n{}",
learned.stdout, learned.stderr
);
assert!(
std::fs::symlink_metadata(proj.join(".claude/workflows/deploy.js")).is_ok(),
"a project lobe with no kinds filter must link the workflow (WF-13): {}",
learned.stdout
);
}
#[test]
fn wf13_link_project_lobe_never_links_a_workflow() {
let sb = Sandbox::new();
write(
&sb.source.join("workflows/deploy.js"),
"export const meta = {\n name: 'deploy',\n description: 'Deploy it',\n}\n",
);
git(&sb.source, &["add", "-A"]);
git(&sb.source, &["commit", "-qm", "add workflow"]);
let ws_proj = sb.base.join("ws-proj");
std::fs::create_dir_all(&ws_proj).unwrap();
let ws_str = ws_proj.to_string_lossy().into_owned();
let r1 = sb.mind(&["link-project", &ws_str]);
assert!(r1.success, "link-project failed: {}", r1.stderr);
let sd_proj = sb.base.join("sd-proj");
std::fs::create_dir_all(&sd_proj).unwrap();
let sd_str = sd_proj.to_string_lossy().into_owned();
let r2 = sb.mind(&["link-project", &sd_str, "--subdir", ".claude"]);
assert!(r2.success, "link-project --subdir failed: {}", r2.stderr);
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
let learned = sb.mind(&["learn", "workflow:deploy"]);
assert!(
learned.success,
"learn workflow: {}\n{}",
learned.stdout, learned.stderr
);
let learned_skill = sb.mind(&["learn", "review"]);
assert!(
learned_skill.success,
"learn skill: {}\n{}",
learned_skill.stdout, learned_skill.stderr
);
assert!(
std::fs::symlink_metadata(ws_proj.join(".windsurf/skills/review")).is_ok(),
"the windsurf project lobe must receive the skill: {}",
learned_skill.stdout
);
assert!(
std::fs::symlink_metadata(sd_proj.join(".claude/skills/review")).is_ok(),
"the --subdir project lobe must receive the skill: {}",
learned_skill.stdout
);
assert!(
std::fs::symlink_metadata(ws_proj.join(".windsurf/workflows/deploy.js")).is_err(),
"a link-project (windsurf preset) lobe must not link a workflow"
);
assert!(
std::fs::symlink_metadata(ws_proj.join(".windsurf/workflows")).is_err(),
"a link-project (windsurf preset) lobe must not even create workflows/"
);
assert!(
std::fs::symlink_metadata(sd_proj.join(".claude/workflows/deploy.js")).is_err(),
"a link-project --subdir lobe must not link a workflow even when it is \
rooted at the harness's own project workflow directory"
);
assert!(
std::fs::symlink_metadata(sd_proj.join(".claude/workflows")).is_err(),
"a link-project --subdir lobe must not even create workflows/"
);
assert!(
std::fs::symlink_metadata(sb.claude_home.join("workflows/deploy.js")).is_ok(),
"the default (unfiltered) lobe must link the workflow: {}",
learned.stdout
);
}
#[test]
fn wf13_link_project_has_no_claude_preset() {
let sb = Sandbox::new();
let proj = sb.base.join("claude-proj");
std::fs::create_dir_all(&proj).unwrap();
let proj_str = proj.to_string_lossy().into_owned();
let r = sb.mind(&["--json", "link-project", &proj_str, "--preset", "claude"]);
assert!(
!r.success,
"`--preset claude` must fail, not resolve: stdout={} stderr={}",
r.stdout, r.stderr
);
let err = parse_json(&r.stdout);
assert_eq!(
err["error"]["kind"], "unknown-preset",
"`claude` must be an unknown preset, not a silently skill-only one: {err}"
);
let listed = sb.mind(&["config", "lobes", "list", "--json"]);
let v = parse_json(&listed.stdout);
assert!(
!v["lobes"].as_array().unwrap().iter().any(|l| l["path"]
.as_str()
.is_some_and(|p| p.contains("claude-proj"))),
"a failed preset add must register no lobe: {}",
listed.stdout
);
}
#[test]
fn unm9_recall_skips_an_unsafe_named_unmanaged_workflow() {
let sb = Sandbox::new();
let body = "export const meta = {\n name: 'deploy',\n description: 'Deploy it',\n}\n";
write(&sb.claude_home.join("workflows/...js"), body);
write(&sb.claude_home.join("workflows/deploy.js"), body);
let r = sb.mind(&["recall"]);
assert!(
r.success,
"recall must succeed despite the unsafe entry: {}\n{}",
r.stdout, r.stderr
);
assert!(
r.stderr.contains("skipping unmanaged workflow"),
"the unsafe entry must be reported as skipped: {}",
r.stderr
);
assert!(
r.stdout.contains("deploy"),
"the well-formed sibling must still be listed: {}",
r.stdout
);
}