use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use trusty_agents_common::agents::tier_audit::TierAuditError;
use trusty_agents_common::agents::tier_audit::{
MisplacedAgent, ProvenanceDisagreement, audit_agent_tier, audit_provenance,
};
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,
outcome: TierOutcome,
}
enum TierOutcome {
Scanned(Vec<MisplacedAgent>),
Unscannable(String),
}
impl TierScan {
fn found(&self) -> &[MisplacedAgent] {
match &self.outcome {
TierOutcome::Scanned(found) => found,
TierOutcome::Unscannable(_) => &[],
}
}
}
pub(super) fn check_asset_tier(
paths: &FrameworkPaths,
project_dir: Option<&Path>,
home: &Path,
) -> DoctorCheck {
let roster = match bundled_roster(paths) {
Ok(roster) => roster,
Err(err) => {
return DoctorCheck::new(
CHECK_NAME,
CheckStatus::Warn,
format!("cannot classify agent tiers: {err}"),
);
}
};
let canonical = paths.agent_deploy_dir();
let mut scans: Vec<TierScan> = Vec::new();
let mut disagreements: Vec<ProvenanceDisagreement> = 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 outcome = match audit_agent_tier(&dir, &roster) {
Ok(found) => TierOutcome::Scanned(found),
Err(e @ TierAuditError::Unscannable { .. }) => TierOutcome::Unscannable(e.to_string()),
};
disagreements.extend(audit_provenance(&dir).unwrap_or_default());
scans.push(TierScan {
label,
dir,
outcome,
});
}
disagreements.extend(audit_provenance(&canonical).unwrap_or_default());
disagreements.sort_by(|a, b| a.path.cmp(&b.path));
verdict(&scans, &canonical, &disagreements)
}
fn agent_tier_of(base: &Path) -> PathBuf {
base.join(".claude").join("agents")
}
fn verdict(
scans: &[TierScan],
canonical: &Path,
disagreements: &[ProvenanceDisagreement],
) -> DoctorCheck {
let unscannable: Vec<String> = scans
.iter()
.filter_map(|s| match &s.outcome {
TierOutcome::Unscannable(why) => Some(format!("{} tier — {why}", s.label)),
TierOutcome::Scanned(_) => None,
})
.collect();
let hits: Vec<&TierScan> = scans.iter().filter(|s| !s.found().is_empty()).collect();
if hits.is_empty() {
let scanned: Vec<String> = scans
.iter()
.filter(|s| matches!(s.outcome, TierOutcome::Scanned(_)))
.map(|s| s.dir.display().to_string())
.collect();
if !unscannable.is_empty() {
return DoctorCheck::new(
CHECK_NAME,
CheckStatus::Warn,
format!(
"a non-canonical agent tier could not be scanned, so whether tm-owned agent \
files shadow the canonical tier {} is UNDETERMINED here: {}. Scanned: {}. \
Make the directory readable and re-run, or delete it.",
canonical.display(),
unscannable.join("; "),
if scanned.is_empty() {
"none".to_owned()
} else {
scanned.join(", ")
}
),
);
}
if !disagreements.is_empty() {
return DoctorCheck::new(
CHECK_NAME,
CheckStatus::Warn,
format!(
"no tm-owned agent files outside the canonical tier {}, but {}. The \
deployed-agent manifest wins for ownership, so agent resolution is \
unaffected — but the file's frontmatter was changed by something other \
than a deploy. Re-run `tm install` to restore it, or delete the file if \
it is yours (issue #4698).",
canonical.display(),
disagreement_list(disagreements)
),
);
}
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 mut detail: Vec<String> = hits
.iter()
.map(|s| {
format!(
"{} tier {} — {}",
s.label,
s.dir.display(),
name_list(s.found())
)
})
.collect();
detail.extend(unscannable.iter().map(|u| format!("{u} (UNDETERMINED)")));
if !disagreements.is_empty() {
detail.push(disagreement_list(disagreements));
}
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 disagreement_list(disagreements: &[ProvenanceDisagreement]) -> String {
let named: Vec<&str> = disagreements
.iter()
.take(MAX_NAMED)
.map(|d| d.detail.as_str())
.collect();
let rest = disagreements.len().saturating_sub(named.len());
let body = named.join("; ");
let plural = if disagreements.len() == 1 { "" } else { "s" };
if rest == 0 {
return format!(
"{} agent file{plural} declare a `provenance:` that contradicts the deployed-agent manifest: {body}",
disagreements.len()
);
}
format!(
"{} agent file{plural} declare a `provenance:` that contradicts the deployed-agent manifest: {body} (+{rest} more)",
disagreements.len()
)
}
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;