1use schemars::JsonSchema;
20use serde::{Deserialize, Serialize};
21
22use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
23use crate::capability::handler::{CapabilityError, Context};
24use crate::capability::model::{CachePolicy, Exposure, McpExposure, McpResource, Stability};
25use crate::capability::module::ModuleDescriptor;
26use crate::evidence::Ledger;
27use crate::skill::{SkillFinding, SkillStatus, Skills, SKILL};
28use crate::{capability, module};
29
30use super::{get, mcp, Empty};
31
32pub const SKILLS_URI: &str = "majordomus://skills";
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
50pub struct SkillStatusList {
51 pub count: usize,
53 pub standings: std::collections::BTreeMap<String, usize>,
55 pub skills: Vec<SkillStatus>,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
68pub struct SkillVerification {
69 pub valid: bool,
71 pub skills: usize,
73 pub failures: usize,
75 pub warnings: usize,
77 pub findings: Vec<SkillFinding>,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
92#[serde(deny_unknown_fields)]
93pub struct SkillExplainInput {
94 pub id: String,
96}
97
98impl BenchmarkCases for SkillExplainInput {
99 fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
100 let id = ctx
101 .index
102 .objects
103 .iter()
104 .find(|o| o.kind == SKILL)
105 .map(|o| o.identity.clone());
106 vec![NamedCase::new(
109 if id.is_some() {
110 "first-skill"
111 } else {
112 "absent"
113 },
114 SkillExplainInput {
115 id: id.unwrap_or_else(|| "absent".into()),
116 },
117 )]
118 }
119}
120
121fn derived(ctx: &Context) -> Result<Skills, CapabilityError> {
124 let root = std::path::PathBuf::from(&ctx.index.repository.root);
125 let ledger = Ledger::load(&root).map_err(|e| CapabilityError::Internal(e.to_string()))?;
126 Ok(Skills::build(&ctx.index, &ledger))
127}
128
129fn skills_status(ctx: &Context, _: Empty) -> Result<SkillStatusList, CapabilityError> {
130 let skills = derived(ctx)?;
131 let mut standings = std::collections::BTreeMap::new();
132 for s in &skills.skills {
133 *standings.entry(s.standing.label().to_string()).or_insert(0) += 1;
134 }
135 Ok(SkillStatusList {
136 count: skills.skills.len(),
137 standings,
138 skills: skills.skills,
139 })
140}
141
142fn skills_explain(ctx: &Context, input: SkillExplainInput) -> Result<SkillStatus, CapabilityError> {
143 let skills = derived(ctx)?;
144 skills.skill(&input.id).cloned().ok_or_else(|| {
145 CapabilityError::NotFound(format!(
146 "no skill '{}'; `skills.status` names every one this repository holds",
147 input.id
148 ))
149 })
150}
151
152fn skills_verify(ctx: &Context, _: Empty) -> Result<SkillVerification, CapabilityError> {
153 let skills = derived(ctx)?;
154 Ok(SkillVerification {
155 valid: skills.failures() == 0,
156 skills: skills.skills.len(),
157 failures: skills.failures(),
158 warnings: skills.warnings(),
159 findings: skills.findings,
160 })
161}
162
163pub fn module() -> ModuleDescriptor {
173 module! {
174 id: "skills",
175 title: "Skills",
176 description: "Every skill the layer defines as a proven capability: whether a test names it and the evidence ledger holds a current passing run of that test, whether the site projects its page, whether a doctrine and a CI gate hold it, and whether a workflow, prompt, profile, provider template, recipe or CI workflow invokes it. All four are derived on every read and none is authored; an active skill no test names or nothing invokes is an orphan.",
177 stability: Stability::BehaviorallyVerified,
178 capabilities: [
179 capability! {
180 id: "skills.status",
181 title: "Every skill, with its derived standing",
182 description: "Every skill the index holds, each with the tests that name it and the evidence state of their latest runs, its page projection, the doctrine and gates that hold it, every invocation that references it, and the standing those derive: proven, partial, orphan, invalid, or not_required for a draft or deprecated skill.",
183 input: Empty,
184 output: SkillStatusList,
185 stability: Stability::BehaviorallyVerified,
186 exposure: Exposure {
187 mcp: Some(McpExposure {
188 tool: Some("majordomus_skills".into()),
189 resource: Some(McpResource { uri: SKILLS_URI.into(), name: "skills".into() }),
190 }),
191 http: get("/api/v1/skills"),
192 cli: Some(crate::capability::CliExposure { path: vec!["skills".into(), "status".into()] }),
193 },
194 tags: ["skill", "evidence"],
195 cache: CachePolicy::Disabled,
196 handler: skills_status,
197 },
198 capability! {
199 id: "skills.explain",
200 title: "One skill, with everything derived about it",
201 description: "One skill in full: its authored identity, status and provenance marker, each test that names it with the state of its latest recorded run and the command that reproduces it, its page, its doctrine and gates, every invocation by path and line, its standing and the findings about it. The file itself stays at `majordomus://skill/<id>`.",
202 input: SkillExplainInput,
203 output: SkillStatus,
204 stability: Stability::BehaviorallyVerified,
205 exposure: Exposure {
206 mcp: mcp("majordomus_skill_explain"),
207 http: get("/api/v1/skills/explain"),
208 cli: Some(crate::capability::CliExposure { path: vec!["skills".into(), "explain".into()] }),
209 },
210 tags: ["skill", "evidence"],
211 cache: CachePolicy::Disabled,
212 handler: skills_explain,
213 },
214 capability! {
215 id: "skills.verify",
216 title: "What the skills owe and do not have",
217 description: "Every finding over the skills: a file that breaks the skill contract, an active skill no test names or nothing invokes, a test whose latest run failed, a test or an invocation naming a skill that does not exist — each a failure — and evidence that is not current, a missing page or a missing gate, each a warning.",
218 input: Empty,
219 output: SkillVerification,
220 stability: Stability::BehaviorallyVerified,
221 exposure: Exposure {
222 mcp: mcp("majordomus_skills_verify"),
223 http: get("/api/v1/skills/verify"),
224 cli: Some(crate::capability::CliExposure { path: vec!["skills".into(), "verify".into()] }),
225 },
226 tags: ["skill", "evidence", "validation"],
227 cache: CachePolicy::Disabled,
228 handler: skills_verify,
229 },
230 ],
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 #[test]
241 fn the_declaration_yields_the_projections_it_claims() {
242 let m = module();
243 let expected: &[(&str, &str, &str, &[&str])] = &[
244 (
245 "skills.status",
246 "majordomus_skills",
247 "/api/v1/skills",
248 &["skills", "status"],
249 ),
250 (
251 "skills.explain",
252 "majordomus_skill_explain",
253 "/api/v1/skills/explain",
254 &["skills", "explain"],
255 ),
256 (
257 "skills.verify",
258 "majordomus_skills_verify",
259 "/api/v1/skills/verify",
260 &["skills", "verify"],
261 ),
262 ];
263 assert_eq!(m.capabilities.len(), expected.len());
264 for (e, (id, tool, path, cli)) in m.capabilities.iter().zip(expected) {
265 let c = &e.capability;
266 assert_eq!(c.id.as_str(), *id);
267 let exp = &c.exposure;
268 assert_eq!(
269 exp.mcp.as_ref().and_then(|m| m.tool.as_deref()),
270 Some(*tool)
271 );
272 assert_eq!(exp.http.as_ref().map(|h| h.path.as_str()), Some(*path));
273 let words: Vec<&str> = exp
274 .cli
275 .as_ref()
276 .map(|c| c.path.iter().map(String::as_str).collect())
277 .unwrap_or_default();
278 assert_eq!(words, *cli);
279 }
280 }
281
282 #[test]
285 fn an_unreadable_evidence_ledger_refuses_every_question() {
286 let repo = crate::synthetic::SyntheticRepository::small().unwrap();
287 let ledger = repo.root().join(crate::evidence::ledger::LEDGER_PATH);
288 std::fs::create_dir_all(ledger.parent().unwrap()).unwrap();
289 std::fs::write(&ledger, "this is not a ledger").unwrap();
290 let ctx = repo.context().unwrap();
291 for (id, input) in [
292 ("skills.status", serde_json::json!({})),
293 ("skills.explain", serde_json::json!({ "id": "alpha" })),
294 ("skills.verify", serde_json::json!({})),
295 ] {
296 match ctx.execute(id, input) {
297 Err(CapabilityError::Internal(m)) => {
298 assert!(
299 m.contains("not a ledger this version can read"),
300 "{id}: {m}"
301 )
302 }
303 other => panic!("{id} answered over an unreadable ledger: {other:?}"),
304 }
305 }
306 }
307}