Skip to main content

majordomus_cli/capability/builtin/
skills.rs

1//! The `skills` module: every skill as a proven capability, derived.
2//!
3//! The module is `skills` and not `skill` because `skill` is the declarative **kind** of the
4//! files under `.ai/repo/skills/`, whose resources are `majordomus://skill/<id>`, and the
5//! registry composes modules and kinds into one namespace.
6//!
7//! Every capability here reads [`crate::skill::Skills`], built on demand from the index and the
8//! evidence ledger. There is no second catalogue: the skills are the index's objects of kind
9//! `skill`, and what this module adds is what the repository can show about each — tested,
10//! documented, enforced, used — and the findings when it cannot. All three are read-only.
11//!
12//! ```
13//! use majordomus_cli::capability::builtin::skills;
14//! let m = skills::module();
15//! let ids: Vec<&str> = m.capabilities.iter().map(|e| e.capability.id.as_str()).collect();
16//! assert_eq!(ids, ["skills.status", "skills.explain", "skills.verify"]);
17//! ```
18
19use 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
32/// The URI under which every skill, derived, is read as an MCP resource.
33pub const SKILLS_URI: &str = "majordomus://skills";
34
35// ---------------------------------------------------------------- views
36
37/// Every skill with its derived standing, and how many stand where: what
38/// `majordomus skills status`, `GET /api/v1/skills` and the `majordomus_skills` tool answer.
39///
40/// ```
41/// use majordomus_cli::capability::builtin::skills::SkillStatusList;
42/// let empty = SkillStatusList {
43///     count: 0,
44///     standings: std::collections::BTreeMap::new(),
45///     skills: vec![],
46/// };
47/// assert_eq!(empty.count, 0);
48/// ```
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
50pub struct SkillStatusList {
51    /// How many skills.
52    pub count: usize,
53    /// How many in each standing, keyed by the standing word.
54    pub standings: std::collections::BTreeMap<String, usize>,
55    /// Every skill, in index order.
56    pub skills: Vec<SkillStatus>,
57}
58
59/// The verdict over every skill: valid when no finding refuses.
60///
61/// ```
62/// use majordomus_cli::capability::builtin::skills::SkillVerification;
63/// let v = SkillVerification { valid: true, skills: 3, failures: 0, warnings: 2, findings: vec![] };
64/// // warnings are debts, reported; they do not make the verdict false
65/// assert!(v.valid);
66/// ```
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
68pub struct SkillVerification {
69    /// True when no finding is a failure.
70    pub valid: bool,
71    /// How many skills were examined.
72    pub skills: usize,
73    /// How many findings refuse.
74    pub failures: usize,
75    /// How many findings are warnings.
76    pub warnings: usize,
77    /// Every finding.
78    pub findings: Vec<SkillFinding>,
79}
80
81// ---------------------------------------------------------------- inputs
82
83/// Which skill to explain, by its id. An id the repository does not hold is a refusal
84/// naming what was looked for, never an empty answer.
85///
86/// ```
87/// use majordomus_cli::capability::builtin::skills::SkillExplainInput;
88/// let input = SkillExplainInput { id: "implement".into() };
89/// assert_eq!(input.id, "implement");
90/// ```
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
92#[serde(deny_unknown_fields)]
93pub struct SkillExplainInput {
94    /// The skill's id, which is also its directory name.
95    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        // a repository without a skill still answers the route, with the refusal, so the
107        // required parameter keeps an example in the OpenAPI document
108        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
121// ---------------------------------------------------------------- handlers
122
123fn 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
163/// The module the registry composes: three read-only capabilities over one derivation, each
164/// declared once here and projected to the command line, HTTP, MCP and OpenAPI.
165///
166/// ```
167/// use majordomus_cli::capability::builtin::skills;
168/// let m = skills::module();
169/// assert_eq!(m.id.as_str(), "skills");
170/// assert!(m.capabilities.iter().all(|e| e.capability.id.as_str().starts_with("skills.")));
171/// ```
172pub 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    /// The declaration is the only place these names exist; a refactor that dropped an
239    /// exposure or renamed a route would still compile, and this would not pass.
240    #[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    /// An evidence ledger this executable cannot read is an internal error on every skills
283    /// question, never a derivation that reads it as empty and calls every skill untested.
284    #[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}