use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::core::agent_skill_codeploy::log_declared_skills;
use crate::core::doctor_repair::{RepairMode, StepStatus};
use crate::core::manifest::HarnessPlan;
use crate::core::paths::FrameworkPaths;
use crate::core::project_tier_strays::remove_project_tier_strays;
use crate::core::skill_deployer::DeployStats;
use crate::core::skill_tiers::{
deploy_all_skill_tiers, list_project_custom_stems, list_source_stems,
};
fn stray_backup_root(fw_root: &Path, now: chrono::DateTime<chrono::Utc>) -> PathBuf {
fw_root.join(format!(
"backup-session-stray-sweep-{}",
now.format("%Y%m%d-%H%M%S")
))
}
pub(super) fn sweep_project_tier_strays(fw: &FrameworkPaths, project_dir: &Path) -> usize {
let operator = FrameworkPaths::from_root(&fw.root);
let backup_root = stray_backup_root(&fw.root, chrono::Utc::now());
let steps = remove_project_tier_strays(
&operator,
Some(project_dir),
&backup_root,
RepairMode::Apply,
);
let mut removed = 0usize;
for step in &steps {
match &step.status {
StepStatus::Applied { .. } => removed += 1,
StepStatus::Failed(why) => tracing::warn!(
path = %step.path.display(),
"could not remove a stray project-tier skill copy: {why} — the session \
starts with the copy still in place"
),
StepStatus::Refused(why) => tracing::debug!(
path = %step.path.display(),
"left a project-tier skill copy alone: {why}"
),
StepStatus::Planned => {}
}
}
if removed > 0 {
let noun = if removed == 1 { "copy" } else { "copies" };
tracing::warn!(
project_dir = %project_dir.display(),
removed,
backup = %backup_root.display(),
"removed {removed} stray project-tier {noun} of bundled skills (#6754) — \
backed up first; `tm doctor` audits this tier"
);
}
removed
}
pub(super) fn deploy_session_skills(
fw: &FrameworkPaths,
plan: &HarnessPlan,
project_dir: &Path,
declared_skills: &HashMap<String, Vec<String>>,
roster_errors: &mut Vec<String>,
) -> DeployStats {
sweep_project_tier_strays(fw, project_dir);
match crate::core::skill_staleness::stale_skills(&plan.skill_source, &fw.claude_skills_dir()) {
Ok(stale) if !stale.is_empty() => tracing::warn!(
project_dir = %project_dir.display(),
stale_skills = %stale.join(", "),
"deployed skills were stale relative to bundled assets — refreshing now \
(run `tm doctor` to audit; long-lived worktrees drift until re-provisioned)"
),
Ok(_) => {}
Err(e) => roster_errors.push(format!(
"skill staleness undetermined — the ownership ledger at {} could not be read: {e}",
fw.claude_skills_dir().display()
)),
}
let bundled_stems = list_source_stems(&plan.skill_source).unwrap_or_default();
let user_stems = list_source_stems(&fw.user_skill_source_dir()).unwrap_or_default();
let project_stems = list_project_custom_stems(&fw.claude_skills_dir()).unwrap_or_default();
log_declared_skills(declared_skills, &project_stems, &user_stems, &bundled_stems);
crate::core::provisioning_stage::emit(
crate::core::provisioning_stage::ProvisioningStage::DeployingSkills,
);
let managed = deploy_all_skill_tiers(
&plan.skill_source,
&fw.user_skill_source_dir(),
&fw.skill_deploy_dir(),
|_| true,
);
let project = deploy_all_skill_tiers(
&plan.skill_source,
&fw.user_skill_source_dir(),
&fw.claude_skills_dir(),
crate::core::project_skill_tier::bundled_excluded_from_project_tier,
);
let stats = match (managed, project) {
(Ok(managed), Ok(project)) => {
let mut stats = managed.stats;
stats.deployed.extend(project.stats.deployed);
stats.skipped.extend(project.stats.skipped);
stats.unchanged.extend(project.stats.unchanged);
stats
}
(Err(err), _) | (Ok(_), Err(err)) => {
tracing::error!(
project_dir = %project_dir.display(),
"skill deploy FAILED — session will launch WITHOUT the tm/mpm skill \
set: {err}. Identity/output-style provisioning continues regardless."
);
roster_errors.push(format!("skill deploy failed: {err}"));
DeployStats::default()
}
};
crate::core::skill_retire::retire_orphaned_skills(fw, Some(project_dir));
stats
}
#[cfg(test)]
#[path = "skills_tests.rs"]
mod tests;