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
//! Which gitignored agent and skill files tm deployed into a worktree (#8534).
//!
//! Why: `.claude/agents/` and `.claude/skills/*` are gitignored by tm's
//! scaffold block, but they also hold a user's own agents and skills. Excusing
//! the directories by name let decommission delete a user-written
//! `.claude/skills/deploy-check/SKILL.md` (#8534 critic round 3).
//! What: a file is tm's only when the ledger tm keeps beside it records it —
//! the skills manifest in `.claude/skills/`, the agent manifest in
//! `.claude/agents/` with a framework origin — and its bytes still match the
//! recorded checksum. Everything else under those directories is kept output,
//! and so is every file there when its ledger cannot be read.
//! Test: `worktree_ignored_output_asset_tests`.
use std::path::Path;
use tracing::warn;
use crate::core::agent_manifest::{AgentManifest, ManifestLoad};
use crate::core::skill_manifest::SkillManifest;
/// The skills deploy directory, repo-relative.
const SKILLS_DIR: &str = ".claude/skills";
/// The agents deploy directory, repo-relative.
const AGENTS_DIR: &str = ".claude/agents";
/// Is `rel` one of the deploy directories or beneath one?
///
/// Why: those paths are decided by a ledger, so the static harness list must
/// not excuse them by name.
/// Test: `harness_paths_are_excused_and_look_alikes_are_not`.
pub(super) fn is_asset_path(rel: &str) -> bool {
let rel = rel.trim_end_matches('/');
[SKILLS_DIR, AGENTS_DIR].iter().any(|dir| {
rel.strip_prefix(dir)
.is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
})
}
/// The two ledgers of one worktree, each read at most once per check.
pub(super) struct DeployedAssets<'a> {
root: &'a Path,
skills: Option<Option<SkillManifest>>,
agents: Option<Option<AgentManifest>>,
}
impl<'a> DeployedAssets<'a> {
/// A classifier for the tree at `root`; nothing is read until asked.
pub(super) fn new(root: &'a Path) -> Self {
Self {
root,
skills: None,
agents: None,
}
}
/// Did tm deploy the file at `rel`, and is it unchanged since?
///
/// Why: only such a file is regenerable — tm writes it back on the next
/// deploy. A user's file, or tm's file after a hand edit, is work.
/// What: `.claude/skills/<skill>/SKILL.md` is keyed `<skill>` and any other
/// file in the skill `<skill>/<rest>`, as the skill deployer records them;
/// `.claude/agents/<file>` is keyed `<file>` and needs a framework origin.
/// An unreadable ledger or file, or any other path, answers `false`.
/// Test: `a_user_skill_keeps_the_tree`,
/// `a_manifest_named_skill_does_not_block_removal`,
/// `an_unreadable_skill_manifest_keeps_the_tree`,
/// `a_hand_edited_deployed_skill_keeps_the_tree`,
/// `only_framework_agents_in_the_ledger_are_excused`.
pub(super) fn is_tm_deployed(&mut self, rel: &str) -> bool {
if let Some(rest) = rel.strip_prefix(SKILLS_DIR).and_then(under) {
let Some((skill, file)) = rest.split_once('/') else {
return false; // loose files in the directory are not a skill's
};
let key = if file == "SKILL.md" {
skill.to_string()
} else {
format!("{skill}/{file}")
};
let root = self.root;
let Some(ledger) = self.skills.get_or_insert_with(|| load_skills(root)) else {
return false;
};
return read(root, rel).is_some_and(|body| ledger.checksum_matches(&key, &body));
}
if let Some(file) = rel.strip_prefix(AGENTS_DIR).and_then(under) {
if file.contains('/') {
return false; // the agent deployer writes flat files only
}
let root = self.root;
let Some(ledger) = self.agents.get_or_insert_with(|| load_agents(root)) else {
return false;
};
let framework = ledger
.managed
.get(file)
.is_some_and(|entry| entry.origin.is_framework_owned());
return framework
&& read(root, rel).is_some_and(|body| ledger.checksum_matches(file, &body));
}
false
}
}
/// The rest of a path after `/`, if the prefix stripped was a whole segment.
fn under(rest: &str) -> Option<&str> {
rest.strip_prefix('/').filter(|r| !r.is_empty())
}
/// `root/rel` as text, or `None` when it cannot be read or is not UTF-8.
fn read(root: &Path, rel: &str) -> Option<String> {
std::fs::read_to_string(root.join(rel)).ok()
}
/// The skills ledger, or `None` (logged) when it exists but cannot be read.
fn load_skills(root: &Path) -> Option<SkillManifest> {
SkillManifest::load(&root.join(SKILLS_DIR))
.inspect_err(|e| {
warn!(root = %root.display(), "skills ledger unreadable ({e}); keeping every skill file (#8534)");
})
.ok()
}
/// The agents ledger, or `None` (logged) when it exists but cannot be read.
fn load_agents(root: &Path) -> Option<AgentManifest> {
match AgentManifest::load_checked(&root.join(AGENTS_DIR)) {
ManifestLoad::Ok(ledger) => Some(ledger),
ManifestLoad::Corrupt(e) => {
warn!(root = %root.display(), "agents ledger unreadable ({e}); keeping every agent file (#8534)");
None
}
}
}