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
//! Agent-bundled skill co-deployment and observability (DOC-42, issue #2889).
//!
//! Why: `docs/specs/agent-bundled-skills.md` §SPEC-AGENTSKILLS-02 requires
//! that when an agent declaring `skills:` is deployed, its declared skills
//! are also deployed to the same target — and §SPEC-AGENTSKILLS-05 requires
//! logging both the co-deploy set and any declared skill that fails to
//! resolve through the 3-tier precedence. This logic is identical whether the
//! caller is the ordinary session-launch path (`core::session_launch`) or the
//! standalone `tm install` deploy — both call
//! [`agent_deployer::deploy_agents_filtered`] and then need to (a) fold every
//! deployed agent's declared skills into the skill-deploy `select` predicate
//! and (b) log the resolution outcome. Centralising it here keeps the two
//! call sites from re-implementing (and silently diverging on) the same
//! union + resolve + log sequence.
//! What: [`co_deploy_skill_set`] unions every agent's declared skills into
//! one stem set (for the skill deployer's `select` predicate);
//! [`log_declared_skills`] emits the per-agent `info` summary line and a
//! `warn`/`debug` line per declared skill depending on whether it resolves.
//! Test: `co_deploy_skill_set_unions_all_agents`,
//! `co_deploy_skill_set_empty_when_no_agent_declares_skills`, plus the
//! `log_declared_skills` tests in this file (log content is asserted via the
//! returned dangling-skill list, since `tracing` output itself is not
//! directly assertable in a unit test).
use std::collections::{BTreeSet, HashMap};
use crate::core::skill_tiers::resolve_skill_tier;
/// Union every declared-skills list into one stem set.
///
/// Why: the skill deployer's bundled-tier `select` predicate needs the FULL
/// set of skill names any deployed agent depends on, so a declared skill the
/// harness manifest would otherwise exclude still deploys (§SPEC-AGENTSKILLS-02
/// co-deploy guarantee).
/// What: returns the union of every `Vec<String>` in `declared`, de-duplicated
/// via a `BTreeSet` (also gives deterministic iteration order for logging).
/// Test: `co_deploy_skill_set_unions_all_agents`,
/// `co_deploy_skill_set_empty_when_no_agent_declares_skills`.
pub fn co_deploy_skill_set(declared: &HashMap<String, Vec<String>>) -> BTreeSet<String> {
declared.values().flatten().cloned().collect()
}
/// Log the co-deploy summary and per-skill resolution for every declared skill.
///
/// Why: an operator debugging a "skill not found" error needs to see, in the
/// logs, which skills each deployed agent declared and whether each one
/// resolved — this is the §SPEC-AGENTSKILLS-05 observability contract.
/// What: for each agent with a non-empty `skills` list, emits one `info` line
/// naming the agent and its declared skills, then for each declared skill
/// either a `debug` line naming the resolved tier or a `warn` line when the
/// skill is absent from all three tiers (`project`/`user`/`bundled`, per
/// [`resolve_skill_tier`]). Returns the list of dangling `(agent, skill)`
/// pairs so callers can additionally surface them beyond the log stream (e.g.
/// a deploy summary count) without re-deriving resolution.
/// Test: `log_declared_skills_resolves_all_tiers`,
/// `log_declared_skills_flags_dangling_reference`,
/// `log_declared_skills_skips_agents_with_no_skills`.
pub fn log_declared_skills(
declared: &HashMap<String, Vec<String>>,
project: &BTreeSet<String>,
user: &BTreeSet<String>,
bundled: &BTreeSet<String>,
) -> Vec<(String, String)> {
let mut dangling = Vec::new();
// Sort agent names for deterministic log ordering across runs.
let mut agents: Vec<&String> = declared.keys().collect();
agents.sort();
for agent in agents {
let skills = &declared[agent];
if skills.is_empty() {
continue;
}
tracing::info!(
agent = %agent,
skills = %skills.join(", "),
"deploying agent with declared skills"
);
for skill in skills {
match resolve_skill_tier(skill, project, user, bundled) {
Some(tier) => tracing::debug!(
agent = %agent,
skill = %skill,
tier = tier.label(),
"co-deployed skill resolved"
),
None => {
tracing::warn!(
agent = %agent,
skill = %skill,
"agent declares skill '{skill}', but it is not found in any tier \
(project/user/bundled)"
);
dangling.push((agent.clone(), skill.clone()));
}
}
}
}
dangling
}
#[cfg(test)]
mod tests {
use super::*;
fn set(items: &[&str]) -> BTreeSet<String> {
items.iter().map(|s| s.to_string()).collect()
}
fn declared(pairs: &[(&str, &[&str])]) -> HashMap<String, Vec<String>> {
pairs
.iter()
.map(|(agent, skills)| {
(
agent.to_string(),
skills.iter().map(|s| s.to_string()).collect(),
)
})
.collect()
}
#[test]
fn co_deploy_skill_set_unions_all_agents() {
let map = declared(&[
(
"code-critic",
&["code-review-standards", "systematic-debugging"],
),
(
"rust-engineer",
&["toolchains-rust-core", "systematic-debugging"],
),
]);
let union = co_deploy_skill_set(&map);
assert_eq!(
union,
set(&[
"code-review-standards",
"systematic-debugging",
"toolchains-rust-core"
])
);
}
#[test]
fn co_deploy_skill_set_empty_when_no_agent_declares_skills() {
let map = declared(&[("plain", &[])]);
assert!(co_deploy_skill_set(&map).is_empty());
}
#[test]
fn log_declared_skills_resolves_all_tiers() {
let map = declared(&[(
"code-critic",
&["project-skill", "user-skill", "bundled-skill"],
)]);
let dangling = log_declared_skills(
&map,
&set(&["project-skill"]),
&set(&["user-skill"]),
&set(&["bundled-skill"]),
);
assert!(dangling.is_empty());
}
#[test]
fn log_declared_skills_flags_dangling_reference() {
let map = declared(&[("code-critic", &["missing-skill"])]);
let dangling =
log_declared_skills(&map, &BTreeSet::new(), &BTreeSet::new(), &BTreeSet::new());
assert_eq!(
dangling,
vec![("code-critic".to_string(), "missing-skill".to_string())]
);
}
#[test]
fn log_declared_skills_skips_agents_with_no_skills() {
let map = declared(&[("plain", &[])]);
let dangling =
log_declared_skills(&map, &BTreeSet::new(), &BTreeSet::new(), &BTreeSet::new());
assert!(dangling.is_empty());
}
#[test]
fn co_deploy_resolves_sibling_2890_code_critic_fixture() {
// Cross-PR fixture (issue #2890, PR #2900): `code-critic` declares
// `[code-review-standards, contract-driven-testing]`. Once both land
// on main these resolve from the Bundled tier; here we pin the
// resolution behavior with a synthetic bundled set standing in for
// the real asset (not yet merged) so #2889's co-deploy logic is
// proven compatible independent of #2890's landing order.
let map = declared(&[(
"code-critic",
&["code-review-standards", "contract-driven-testing"],
)]);
let bundled = set(&["code-review-standards", "contract-driven-testing"]);
let dangling = log_declared_skills(&map, &BTreeSet::new(), &BTreeSet::new(), &bundled);
assert!(
dangling.is_empty(),
"both skills must resolve from bundled: {dangling:?}"
);
assert_eq!(co_deploy_skill_set(&map), bundled);
}
#[test]
fn log_declared_skills_partial_resolution_flags_only_missing() {
let map = declared(&[("agent", &["ok-skill", "bad-skill"])]);
let dangling = log_declared_skills(
&map,
&BTreeSet::new(),
&BTreeSet::new(),
&set(&["ok-skill"]),
);
assert_eq!(
dangling,
vec![("agent".to_string(), "bad-skill".to_string())]
);
}
}