1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
//! `tm doctor` legacy-instruction-source probe (issue #2876).
//!
//! Why: leftover GLOBAL instruction sources — bundled skill copies in
//! `~/.claude/skills` from the pre-project-local deploy model — shadow the
//! current framework and keep serving stale guidance, one of the two silent
//! drift modes behind the PR #2825 attribution bypass. The narrower
//! agents/skills/deployment probes do not catch them.
//!
//! **`~/.trusty-mpm/claude-config` is NOT one of them (#7797).** This probe
//! also warned whenever that directory merely existed, calling it superseded
//! and safe to delete. It is the standalone driver's LIVE `CLAUDE_CONFIG_DIR`:
//! `ensure_global_config_dir` creates and populates it on every `tm run`,
//! `tm load` and `tm update`, and `tm reinstall`'s `standalone` target
//! (`core::reinstall::reinstall_targets`) redeploys into it. So the sub-check
//! warned on every environment that had ever launched a standalone session,
//! and its remediation was undone by the next launch. Nothing distinguishes a
//! pre-migration leftover there from the live tier — the driver deploys the
//! same agents and skills into it today — so the sub-check is gone rather than
//! narrowed. The tm-owned `~/.trusty-tools/trusty-mpm/claude-config` that
//! DAEMON-managed sessions use is a different directory and is unaffected.
//!
//! The OTHER drift mode — deployed skill content diverging from the installed
//! binary's assets — used to live here too. Issue #4604 moved it to
//! [`super::doctor_skill_drift`] when its reference point changed from the
//! `~/.trusty-mpm/framework/skills` extraction cache (which could itself lag
//! the binary, making every skill it covered report clean) to the binary's own
//! embedded assets.
//!
//! What: [`check_legacy_instruction_sources`] `Warn`s when bundled skill
//! copies are left in `~/.claude/skills` — advisory-only, since a leftover
//! copy shadows rather than silently drops a convention. It never blocks
//! session start and never writes.
//! Test: the `tests` module below covers the present/absent branches.
use std::path::Path;
use crate::core::doctor::{CheckStatus, DoctorCheck};
/// Probe for legacy GLOBAL skill copies that can serve stale guidance.
///
/// Why: before managed sessions went project-local, skills deployed into the
/// global `~/.claude/skills/`. That tier is superseded (project-local
/// `.claude/skills/` and the tm-managed `CLAUDE_CONFIG_DIR`), but leftover
/// copies keep being read by Claude Code and can serve outdated conventions —
/// e.g. an old attribution footer — to any session (issue #2876). This probe
/// makes those leftovers visible.
/// What: `Warn` (advisory — never `Fail`) when `~/.claude/skills` holds bundled
/// skill copies, naming the count and the one-line remediation; `Ok` when it
/// holds none. `home` is the base to resolve that path under (the real home in
/// production, a temp dir in tests).
///
/// #7102: the remediation it prints must be one that actually clears it. It
/// used to offer `tm install`, whose skill step wrote this same directory, so
/// following the advice reproduced the warning; the installer now deploys
/// bundled skills to the managed tier only
/// ([`crate::core::skill_install_tiers::deploy_install_skill_tiers`]) and the
/// text says to delete the copies. #7783 closed the last writer that refilled
/// `~/.claude/skills` ([`crate::core::reinstall`]) and widened the count past
/// the `tm-` prefix — see [`count_legacy_bundled_skills`].
///
/// #7797: `~/.trusty-mpm/claude-config` is no longer a finding here. See the
/// module doc — it is the standalone driver's live config home, not a
/// leftover, so its mere existence warned on every environment.
/// Test: `legacy_sources_ok_when_absent`, `legacy_sources_warns_on_tm_skills`,
/// `legacy_sources_counts_an_unprefixed_bundled_skill`,
/// `legacy_sources_ok_when_the_standalone_config_dir_exists`,
/// `legacy_sources_ok_after_the_install_deploy`.
pub(super) fn check_legacy_instruction_sources(home: &Path) -> DoctorCheck {
let mut findings: Vec<String> = Vec::new();
let bundled_copies = count_legacy_bundled_skills(&home.join(".claude").join("skills"));
if bundled_copies > 0 {
// #7102: the old text said "remove them or run `tm install` to refresh",
// and `tm install` wrote this very directory — so the remediation
// reproduced the finding. Removal is now the whole fix.
// #7783: the count covers every bundled stem, not just the `tm-*` ones,
// so the wording may no longer promise a prefix.
findings.push(format!(
"{bundled_copies} bundled skill copy(ies) in ~/.claude/skills (legacy global \
deploy — delete them; bundled skills deploy only to the tm-managed \
CLAUDE_CONFIG_DIR since #6586, and neither `tm install` nor `tm reinstall` \
writes them here)"
));
}
// #7797: no `~/.trusty-mpm/claude-config` sub-check — that directory is the
// standalone driver's live CLAUDE_CONFIG_DIR, recreated by the next
// `tm run`/`tm load`/`tm update` and redeployed into by `tm reinstall`.
if findings.is_empty() {
DoctorCheck::new(
"legacy_sources",
CheckStatus::Ok,
"no legacy global instruction sources found",
)
} else {
DoctorCheck::new(
"legacy_sources",
CheckStatus::Warn,
format!(
"legacy instruction source(s) may serve stale guidance: {}",
findings.join("; ")
),
)
}
}
/// Count leftover BUNDLED skill copies directly under a `~/.claude/skills`.
///
/// Why (#7783): this counted entries whose name starts with `tm-`, which is
/// only the prefixed third of what tm ships — a live `tm doctor` reported 23
/// copies beside ~33 unprefixed ones (`documentation-style`,
/// `writing-plans`, …) it could not see. The roster this binary ships is the
/// only test that stays correct as skills are added and renamed, and it is
/// also the exact set the deployers write, so the check and the deploy agree
/// by construction.
/// What: the number of entries whose name resolves to a stem in
/// [`crate::core::manifest::framework::declared_skill_stems`] — the catalog the
/// framework manifest declares and the test gate validates the content
/// against, so no second derivation of "what tm ships" enters the codebase. Both on-disk forms
/// count: the `<stem>/` directory and the flat `<stem>.md`. A missing or
/// unreadable directory is `0`. An operator's OWN skill that happens to share
/// a bundled stem is counted too; the finding is advisory and the remediation
/// (delete the copy — the managed tier already serves it) is the same either
/// way.
/// Test: `legacy_sources_warns_on_tm_skills`,
/// `legacy_sources_counts_an_unprefixed_bundled_skill`,
/// `legacy_sources_ok_when_absent`.
fn count_legacy_bundled_skills(skills_dir: &Path) -> usize {
// #9012: the binary's declared roster; the content is not needed.
let bundled = crate::core::manifest::framework::declared_skill_stems().unwrap_or_default();
let Ok(entries) = std::fs::read_dir(skills_dir) else {
return 0;
};
entries
.flatten()
.filter(|entry| {
entry
.file_name()
.to_str()
.is_some_and(|name| bundled.contains(name.strip_suffix(".md").unwrap_or(name)))
})
.count()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn legacy_sources_ok_when_absent() {
// A clean home with no legacy global sources is Ok.
let tmp = tempfile::tempdir().unwrap();
let check = check_legacy_instruction_sources(tmp.path());
assert_eq!(check.status, CheckStatus::Ok);
}
#[test]
fn legacy_sources_warns_on_tm_skills() {
// A leftover `~/.claude/skills/tm-*` copy must be flagged.
let tmp = tempfile::tempdir().unwrap();
let skills = tmp.path().join(".claude").join("skills");
let skill_dir = skills.join("tm-workflow");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(skill_dir.join("SKILL.md"), "old attribution").unwrap();
// A non-tm user skill must NOT trip the probe.
std::fs::create_dir_all(skills.join("my-skill")).unwrap();
let check = check_legacy_instruction_sources(tmp.path());
assert_eq!(check.status, CheckStatus::Warn);
assert!(check.message.contains(".claude/skills"));
assert!(check.message.contains('1'));
}
#[test]
fn legacy_sources_counts_an_unprefixed_bundled_skill() {
// #7783: the count filtered on a `tm-` prefix, so the bundled skills
// that carry none — `documentation-style` here — were invisible to it
// and a live `tm doctor` under-reported the leftovers by ~33 copies.
let tmp = tempfile::tempdir().unwrap();
let skills = tmp.path().join(".claude").join("skills");
let bundled = skills.join("documentation-style");
std::fs::create_dir_all(&bundled).unwrap();
std::fs::write(bundled.join("SKILL.md"), "stale copy").unwrap();
// An operator skill matching no bundled stem is still never counted.
std::fs::create_dir_all(skills.join("my-own-skill")).unwrap();
let check = check_legacy_instruction_sources(tmp.path());
assert_eq!(check.status, CheckStatus::Warn, "{}", check.message);
assert!(
check.message.contains("1 bundled skill copy(ies)"),
"{}",
check.message
);
}
#[test]
fn legacy_sources_ok_when_the_standalone_config_dir_exists() {
// #7797: `~/.trusty-mpm/claude-config` used to `Warn` on mere
// existence. It is the standalone driver's live CLAUDE_CONFIG_DIR —
// `ensure_global_config_dir` creates it on every `tm run`/`tm load`/
// `tm update` and `tm reinstall`'s `standalone` target deploys agents
// and skills into it — so the warning fired on every environment that
// had ever launched one, and advised deleting a directory tm recreates.
// Populate it the way the driver does, not as a bare directory: the
// verdict must be Ok either way.
let tmp = tempfile::tempdir().unwrap();
let cfg = tmp.path().join(".trusty-mpm").join("claude-config");
std::fs::create_dir_all(cfg.join("agents")).unwrap();
std::fs::create_dir_all(cfg.join("skills").join("documentation-style")).unwrap();
std::fs::write(
cfg.join("skills")
.join("documentation-style")
.join("SKILL.md"),
"deployed by the standalone driver",
)
.unwrap();
let check = check_legacy_instruction_sources(tmp.path());
assert_eq!(check.status, CheckStatus::Ok, "{}", check.message);
assert!(
!check.message.contains("claude-config"),
"{}",
check.message
);
}
#[test]
fn legacy_sources_ok_after_the_install_deploy() {
// #7102: `tm install`'s skill step wrote every bundled `tm-*` skill into
// `~/.claude/skills`, the directory this check flags — so the check's
// own remediation ("run `tm install`") put the flagged copies back and
// the warning never cleared. Drive the installer's real deploy against a
// temp home, then read this check's verdict on that same home: the two
// must agree.
let tmp = tempfile::tempdir().unwrap();
let paths = crate::core::paths::FrameworkPaths::under(tmp.path());
let source = paths.skill_source_dir();
std::fs::create_dir_all(&source).unwrap();
std::fs::write(source.join("tm-workflow.md"), "bundled body").unwrap();
crate::core::skill_install_tiers::deploy_install_skill_tiers(&paths).unwrap();
let check = check_legacy_instruction_sources(tmp.path());
assert_eq!(
check.status,
CheckStatus::Ok,
"the installer's own deploy target must not trip legacy_sources: {}",
check.message
);
// …and the deploy must actually have happened, or the assertion above
// passes vacuously on a no-op.
assert!(
paths
.skill_deploy_dir()
.join("tm-workflow")
.join("SKILL.md")
.is_file(),
"the bundled skill must be on disk in the managed tier"
);
}
}