use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use trusty_agents_common::agents::tier_audit::{MisplacedAgent, audit_agent_tier};
use crate::core::bundled_roster::bundled_roster;
use crate::core::doctor::{CheckStatus, DoctorCheck};
use crate::core::paths::FrameworkPaths;
const CHECK_NAME: &str = "asset_tier";
const MAX_NAMED: usize = 5;
struct TierScan {
label: &'static str,
dir: PathBuf,
found: Vec<MisplacedAgent>,
}
pub(super) fn check_asset_tier(
paths: &FrameworkPaths,
project_dir: Option<&Path>,
home: &Path,
) -> DoctorCheck {
let roster = bundled_roster(paths);
let canonical = paths.agent_deploy_dir();
let mut scans: Vec<TierScan> = Vec::new();
let mut seen: BTreeSet<PathBuf> = BTreeSet::new();
seen.insert(canonical.clone());
for (label, dir) in [
("project", project_dir.map(agent_tier_of)),
("operator home", Some(agent_tier_of(home))),
] {
let Some(dir) = dir else { continue };
if !seen.insert(dir.clone()) {
continue;
}
let found = audit_agent_tier(&dir, &roster);
scans.push(TierScan { label, dir, found });
}
verdict(&scans, &canonical)
}
fn agent_tier_of(base: &Path) -> PathBuf {
base.join(".claude").join("agents")
}
fn verdict(scans: &[TierScan], canonical: &Path) -> DoctorCheck {
let hits: Vec<&TierScan> = scans.iter().filter(|s| !s.found.is_empty()).collect();
if hits.is_empty() {
let scanned: Vec<String> = scans.iter().map(|s| s.dir.display().to_string()).collect();
return DoctorCheck::new(
CHECK_NAME,
CheckStatus::Ok,
format!(
"no tm-owned agent files outside the canonical tier {} (scanned: {})",
canonical.display(),
if scanned.is_empty() {
"none".to_owned()
} else {
scanned.join(", ")
}
),
);
}
let shadowing = hits.iter().any(|s| s.label == "project");
let detail: Vec<String> = hits
.iter()
.map(|s| {
format!(
"{} tier {} — {}",
s.label,
s.dir.display(),
name_list(&s.found)
)
})
.collect();
if shadowing {
return DoctorCheck::new(
CHECK_NAME,
CheckStatus::Fail,
format!(
"tm-owned agent files live outside the canonical tier {}: {}. Claude Code \
resolves the PROJECT tier first, so these copies win over the deployed \
roster and the real agent never loads — a stale or stub definition runs \
under the right name and nothing reports it (issue #4408). Remove them: \
`tm install --reset-agents --reset-agents-workspaces` retracts the \
manifest-tracked ones; an untracked copy must be deleted by hand.",
canonical.display(),
detail.join("; ")
),
);
}
DoctorCheck::new(
CHECK_NAME,
CheckStatus::Warn,
format!(
"tm-owned agent files linger in a legacy tier: {}. A managed session relocates \
CLAUDE_CONFIG_DIR, so these do NOT shadow the canonical tier {} — but no deploy \
refreshes them either, and a plain `claude` run outside tm reads them. Delete \
them once.",
detail.join("; "),
canonical.display()
),
)
}
fn name_list(found: &[MisplacedAgent]) -> String {
let named: Vec<&str> = found
.iter()
.take(MAX_NAMED)
.map(|f| f.name.as_str())
.collect();
let rest = found.len().saturating_sub(named.len());
if rest == 0 {
return named.join(", ");
}
format!("{} (+{rest} more)", named.join(", "))
}
#[cfg(test)]
#[path = "doctor_asset_tier_tests.rs"]
mod tests;