use std::collections::HashSet;
use std::path::{Path, PathBuf};
use crate::core::agent_manifest::checksum;
use crate::core::skill_manifest::SkillManifest;
pub const FORMER_TRUSTY_MPM_SKILLS: &[&str] = &[
"mpm-bug-reporting",
"mpm-circuit-breaker-enforcement",
"mpm-delegation-patterns",
"mpm-git-file-tracking",
"mpm-pr-workflow",
"mpm-session-management",
"mpm-session-pause",
"mpm-session-resume",
"mpm-ticketing-integration",
"mpm-tool-usage-guide",
"mpm-verification-protocols",
];
const FORMER_TRUSTY_MPM_SKILL_CONTENT_SHA256: &[(&str, &str)] = &[
(
"mpm-bug-reporting",
"ae4217996f43b7d32c109675bb2e87a7f03a017b1d5d080e404f69d1130693da", ),
(
"mpm-circuit-breaker-enforcement",
"9201be68031dac92209ef262d07a87b295040c540b130f706b495db77fb0bab5", ),
(
"mpm-delegation-patterns",
"a0206ac2bc81264b8b14e854260179e1f1050fad9c2949813c6c8c447759cb4f", ),
(
"mpm-git-file-tracking",
"8450fa909f729bd8095db3a3705bcb44c91c48f069fcd23f212e144b90612aaf", ),
(
"mpm-pr-workflow",
"9dd922ecd082cc37525c618bf01ecc8ce6b94e80386672459f92aa9d872c895d", ),
(
"mpm-session-management",
"ce576c484f307967bccf3f01fbff6c143f0c2cd88ae220e73522b7024312bbcf", ),
(
"mpm-session-pause",
"3222569435343d98f757099e1cb628a961ff7a192e14e09cfcabcf9a7dedc5e4", ),
(
"mpm-session-resume",
"8b12545b4db016b0da7b860d613792e86e4888ec21afd74c0a2e3bb9c9ca5495", ),
(
"mpm-ticketing-integration",
"0b3347fdfaed66616541bf1c6d8e049df693fa7581a41a269bc9e53f384565d7", ),
(
"mpm-tool-usage-guide",
"23ea465fea861077adbcf6429ee733a231009eed4b5fbb1df21dfa7196efca8b", ),
(
"mpm-verification-protocols",
"35faabef27f1bc21f4b509dd60d5c229cfc4f0f12517064025c722a1a9d28f5d", ),
];
fn frozen_content_checksum(name: &str) -> Option<&'static str> {
FORMER_TRUSTY_MPM_SKILL_CONTENT_SHA256
.iter()
.find(|(stem, _)| *stem == name)
.map(|(_, sha)| *sha)
}
fn is_confirmed_trusty_mpm_origin(name: &str, skill_dir: &Path, manifest: &SkillManifest) -> bool {
let Ok(content) = std::fs::read_to_string(skill_dir.join("SKILL.md")) else {
return false;
};
if manifest.is_managed(name) {
return manifest.checksum_matches(name, &content);
}
frozen_content_checksum(name).is_some_and(|expected| expected == checksum(&content))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StaleSkill {
pub name: String,
pub path: PathBuf,
}
pub fn find_stale_mpm_skills(claude_skills_dir: &Path) -> Vec<StaleSkill> {
let Ok(entries) = std::fs::read_dir(claude_skills_dir) else {
return Vec::new();
};
let tm_rename_landed = entries.flatten().any(|entry| {
entry
.file_name()
.to_str()
.map(|name| name.starts_with("tm-"))
.unwrap_or(false)
});
if !tm_rename_landed {
return Vec::new();
}
let manifest = match SkillManifest::load(claude_skills_dir) {
Ok(m) => m,
Err(e) => {
tracing::error!(
dir = %claude_skills_dir.display(),
error = %e,
"skill ownership ledger unreadable — nominating no stale skills for removal"
);
return Vec::new();
}
};
FORMER_TRUSTY_MPM_SKILLS
.iter()
.filter_map(|&name| {
let path = claude_skills_dir.join(name);
if !path.is_dir() || !is_confirmed_trusty_mpm_origin(name, &path, &manifest) {
return None;
}
Some(StaleSkill {
name: name.to_string(),
path,
})
})
.collect()
}
pub fn remove_stale_mpm_skills(stale: &[StaleSkill]) -> std::io::Result<usize> {
let mut removed = 0;
for skill in stale {
std::fs::remove_dir_all(&skill.path)?;
removed += 1;
}
Ok(removed)
}
const STALE_MPM_SKILLS_MIGRATION_ID: &str = "1905-stale-mpm-skills-prune";
fn migrations_marker_path(root: &Path) -> PathBuf {
root.join("migrations.json")
}
fn completed_migrations(root: &Path) -> HashSet<String> {
std::fs::read_to_string(migrations_marker_path(root))
.ok()
.and_then(|raw| serde_json::from_str(&raw).ok())
.unwrap_or_default()
}
fn mark_migration_complete(root: &Path, id: &str) -> std::io::Result<()> {
let mut completed = completed_migrations(root);
completed.insert(id.to_string());
let json = serde_json::to_string_pretty(&completed).unwrap_or_else(|_| "[]".to_string());
std::fs::create_dir_all(root)?;
let path = migrations_marker_path(root);
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, path)
}
pub fn run_stale_mpm_skills_migration_once(root: &Path, claude_skills_dir: &Path) {
if completed_migrations(root).contains(STALE_MPM_SKILLS_MIGRATION_ID) {
return;
}
let stale = find_stale_mpm_skills(claude_skills_dir);
if !stale.is_empty() {
match remove_stale_mpm_skills(&stale) {
Ok(count) => {
tracing::info!(
count,
"removed stale pre-rename mpm-* skill(s) (one-time #1905 migration)"
);
}
Err(error) => {
tracing::debug!(
%error,
"stale mpm-* skill migration failed — will retry on next launch"
);
return;
}
}
} else {
tracing::debug!("no stale pre-rename mpm-* skills found (#1905 migration check)");
}
if let Err(error) = mark_migration_complete(root, STALE_MPM_SKILLS_MIGRATION_ID) {
tracing::debug!(%error, "failed to record #1905 migration marker");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::skill_manifest::SkillManifestEntry;
use tempfile::TempDir;
const FORMER_GIT_FILE_TRACKING_CONTENT: &str =
include_str!("test_fixtures/former-mpm-git-file-tracking.md");
fn write_skill_with_content(dir: &Path, name: &str, content: &str) {
let skill_dir = dir.join(name);
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(skill_dir.join("SKILL.md"), content).unwrap();
}
fn write_skill(dir: &Path, name: &str) {
write_skill_with_content(dir, name, &format!("---\nname: {name}\n---\n\n# {name}\n"));
}
fn write_confirmed_trusty_mpm_skill(claude_skills_dir: &Path, name: &str, content: &str) {
write_skill_with_content(claude_skills_dir, name, content);
let mut manifest = SkillManifest::load(claude_skills_dir).unwrap();
manifest.managed.insert(
name.to_string(),
SkillManifestEntry {
checksum: checksum(content),
deployed_at: "2026-01-01T00:00:00Z".to_string(),
},
);
manifest.save(claude_skills_dir).unwrap();
}
#[test]
fn find_stale_mpm_skills_reports_known_former_skill() {
let tmp = TempDir::new().unwrap();
write_confirmed_trusty_mpm_skill(
tmp.path(),
"mpm-bug-reporting",
"trusty-mpm's own old bundled content\n",
);
write_skill(tmp.path(), "tm-bug-reporting");
let stale = find_stale_mpm_skills(tmp.path());
assert_eq!(stale.len(), 1);
assert_eq!(stale[0].name, "mpm-bug-reporting");
assert_eq!(stale[0].path, tmp.path().join("mpm-bug-reporting"));
}
#[test]
fn find_stale_mpm_skills_ignores_unrelated_mpm_prefixed_dir() {
let tmp = TempDir::new().unwrap();
write_skill(tmp.path(), "mpm-doctor");
write_skill(tmp.path(), "tm-doctor");
let stale = find_stale_mpm_skills(tmp.path());
assert!(stale.is_empty());
}
#[test]
fn find_stale_mpm_skills_ignores_before_tm_rename_lands() {
let tmp = TempDir::new().unwrap();
write_confirmed_trusty_mpm_skill(tmp.path(), "mpm-bug-reporting", "old content\n");
let stale = find_stale_mpm_skills(tmp.path());
assert!(stale.is_empty());
}
#[test]
fn find_stale_mpm_skills_missing_dir_is_empty() {
let tmp = TempDir::new().unwrap();
let missing = tmp.path().join("does-not-exist");
assert!(find_stale_mpm_skills(&missing).is_empty());
}
#[test]
fn remove_stale_mpm_skills_deletes_directories() {
let tmp = TempDir::new().unwrap();
write_confirmed_trusty_mpm_skill(tmp.path(), "mpm-bug-reporting", "old content a\n");
write_confirmed_trusty_mpm_skill(tmp.path(), "mpm-pr-workflow", "old content b\n");
write_skill(tmp.path(), "tm-bug-reporting");
let stale = find_stale_mpm_skills(tmp.path());
assert_eq!(stale.len(), 2);
let removed = remove_stale_mpm_skills(&stale).unwrap();
assert_eq!(removed, 2);
assert!(!tmp.path().join("mpm-bug-reporting").exists());
assert!(!tmp.path().join("mpm-pr-workflow").exists());
assert!(tmp.path().join("tm-bug-reporting").exists());
}
#[test]
fn find_stale_mpm_skills_only_matches_allowlisted_stems() {
let tmp = TempDir::new().unwrap();
for name in FORMER_TRUSTY_MPM_SKILLS {
write_confirmed_trusty_mpm_skill(
tmp.path(),
name,
&format!("old content for {name}\n"),
);
}
for unrelated in ["mpm-doctor", "mpm-workflow", "mpm-config", "mpm-help"] {
write_skill(tmp.path(), unrelated);
}
write_skill(tmp.path(), "tm-doctor");
let stale = find_stale_mpm_skills(tmp.path());
assert_eq!(stale.len(), FORMER_TRUSTY_MPM_SKILLS.len());
for skill in &stale {
assert!(
FORMER_TRUSTY_MPM_SKILLS.contains(&skill.name.as_str()),
"{} matched via something other than the frozen allowlist",
skill.name
);
}
}
#[test]
fn find_stale_mpm_skills_ignores_differently_authored_same_name_skill() {
let tmp = TempDir::new().unwrap();
write_skill_with_content(
tmp.path(),
"mpm-session-management",
"# mpm-session-management\n\nThis is claude-mpm's own harness-agnostic \
skill content — trusty-mpm's deployer never wrote this file.\n",
);
write_skill(tmp.path(), "tm-session-management");
let stale = find_stale_mpm_skills(tmp.path());
assert!(
stale.is_empty(),
"a same-named but differently-authored skill must never be flagged as stale: {stale:?}"
);
assert!(tmp.path().join("mpm-session-management").is_dir());
}
#[test]
fn find_stale_mpm_skills_ignores_manifest_entry_with_mismatched_checksum() {
let tmp = TempDir::new().unwrap();
write_confirmed_trusty_mpm_skill(tmp.path(), "mpm-bug-reporting", "original content\n");
std::fs::write(
tmp.path().join("mpm-bug-reporting").join("SKILL.md"),
"replaced content from elsewhere\n",
)
.unwrap();
write_skill(tmp.path(), "tm-bug-reporting");
let stale = find_stale_mpm_skills(tmp.path());
assert!(stale.is_empty());
}
#[test]
fn find_stale_mpm_skills_confirms_origin_via_frozen_checksum_fallback() {
let tmp = TempDir::new().unwrap();
write_skill_with_content(
tmp.path(),
"mpm-git-file-tracking",
FORMER_GIT_FILE_TRACKING_CONTENT,
);
write_skill(tmp.path(), "tm-git-file-tracking");
let stale = find_stale_mpm_skills(tmp.path());
assert_eq!(stale.len(), 1);
assert_eq!(stale[0].name, "mpm-git-file-tracking");
}
#[test]
fn find_stale_mpm_skills_never_touches_catalog_sync_directory() {
let tmp = TempDir::new().unwrap();
let claude_skills = tmp.path().join(".claude").join("skills");
write_skill(&claude_skills, "tm-doctor");
let catalog_repo_skills = tmp
.path()
.join(".trusty-mpm")
.join("catalog")
.join("repo")
.join("skills");
write_skill(&catalog_repo_skills, "mpm-bug-reporting");
assert!(catalog_repo_skills.join("mpm-bug-reporting").is_dir());
let stale = find_stale_mpm_skills(&claude_skills);
assert!(stale.is_empty());
assert!(catalog_repo_skills.join("mpm-bug-reporting").is_dir());
}
#[test]
fn migration_runs_once_then_marks_complete() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join(".trusty-mpm");
let skills = tmp.path().join(".claude").join("skills");
write_confirmed_trusty_mpm_skill(&skills, "mpm-bug-reporting", "old content\n");
write_skill(&skills, "tm-bug-reporting");
run_stale_mpm_skills_migration_once(&root, &skills);
assert!(!skills.join("mpm-bug-reporting").exists());
assert!(skills.join("tm-bug-reporting").exists());
assert!(
completed_migrations(&root).contains(STALE_MPM_SKILLS_MIGRATION_ID),
"migration must record itself as complete"
);
}
#[test]
fn migration_is_noop_on_second_run() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join(".trusty-mpm");
let skills = tmp.path().join(".claude").join("skills");
write_skill(&skills, "tm-bug-reporting");
run_stale_mpm_skills_migration_once(&root, &skills);
assert!(completed_migrations(&root).contains(STALE_MPM_SKILLS_MIGRATION_ID));
write_confirmed_trusty_mpm_skill(&skills, "mpm-bug-reporting", "old content\n");
run_stale_mpm_skills_migration_once(&root, &skills);
assert!(skills.join("mpm-bug-reporting").exists());
}
}