Skip to main content

mur_common/skill/
manifest.rs

1//! Skill manifest — full serde representation of canonical `skill.yaml`.
2
3use super::evolution::EvolutionEvent;
4use super::mcp::McpRequirement;
5use super::types::{Category, ContentMode, HostId, Priority, Provenance, TriggerKind, TrustLevel};
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9/// Top-level skill — wraps the manifest with security metadata that lives
10/// alongside (but separate from) the publisher-authored fields.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct Skill {
13    #[serde(flatten)]
14    pub manifest: SkillManifest,
15
16    /// Computed at install time. Never serialized into the source YAML.
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    pub content_sha256: Option<String>,
19
20    /// Set by the trust store at install time, not by the publisher.
21    #[serde(default)]
22    pub trust_level: TrustLevel,
23
24    /// Capabilities the skill declares it needs.
25    #[serde(default, skip_serializing_if = "Vec::is_empty")]
26    pub capabilities_declared: Vec<String>,
27
28    /// DSSE envelope JSON (base64-encoded inside the envelope). `None` for
29    /// unsigned skills — they enter at Sandboxed and stay there.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub publisher_signature: Option<String>,
32}
33
34/// Publisher-authored fields. This is what gets signed and is the unit of
35/// content hashing.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct SkillManifest {
38    pub name: String,
39    pub version: String,
40    pub publisher: String,
41    pub description: String,
42    pub category: Category,
43
44    /// Origin of this skill. Defaults to `Human` so every existing manifest
45    /// (which has no `provenance:` key) parses as human-authored.
46    #[serde(default)]
47    pub provenance: Provenance,
48
49    #[serde(default, skip_serializing_if = "Vec::is_empty")]
50    pub hosts: Vec<HostId>,
51
52    pub content: Content,
53
54    #[serde(default, skip_serializing_if = "Vec::is_empty")]
55    pub requires: Vec<Requirement>,
56
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    pub tags: Vec<String>,
59
60    #[serde(default, skip_serializing_if = "Vec::is_empty")]
61    pub triggers: Vec<Trigger>,
62
63    #[serde(default)]
64    pub priority: Priority,
65
66    /// Evolution history — each entry records one generation.
67    #[serde(default, skip_serializing_if = "Vec::is_empty")]
68    pub evolution_log: Vec<EvolutionEvent>,
69
70    /// Peer transfer provenance — each entry is `agent://<name>`.
71    /// Last entry is the immediate source; first entry is the original publisher.
72    /// Empty for registry-installed and locally-authored skills.
73    #[serde(default, skip_serializing_if = "Vec::is_empty")]
74    pub transfer_chain: Vec<String>,
75
76    /// MCP tool capabilities this skill needs at runtime. Optional; absent
77    /// in M3-era v2.0 manifests. Added in schema v2.1.
78    ///
79    /// **Signature scope:** signed as part of the manifest. Changing
80    /// `mcp_requirements` invalidates an existing publisher signature.
81    #[serde(default, skip_serializing_if = "Vec::is_empty")]
82    pub mcp_requirements: Vec<McpRequirement>,
83
84    /// Timestamp of last modification (for fleet-sync LWW). Used by
85    /// `resolve_manifest_lww()` for conflict resolution. Defaults to the Unix epoch
86    /// on deserialization if absent (for backwards compat with unsigned skills).
87    #[serde(default)]
88    pub updated_at: DateTime<Utc>,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct Content {
93    /// Layer 2 — injected into the system prompt at session start.
94    pub r#abstract: String,
95
96    /// Exactly one of the following is `Some`. Enforced by schema validation.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub context: Option<String>,
99
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub procedure: Option<Procedure>,
102
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub command: Option<String>,
105
106    /// Note mode (category: note): free markdown body, stored inline in the
107    /// canonical skill.yaml per the 1a storage decision.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub note: Option<String>,
110}
111
112impl Content {
113    pub fn mode(&self) -> Option<ContentMode> {
114        match (
115            self.context.is_some(),
116            self.procedure.is_some(),
117            self.command.is_some(),
118            self.note.is_some(),
119        ) {
120            (true, false, false, false) => Some(ContentMode::Context),
121            (false, true, false, false) => Some(ContentMode::Workflow),
122            (false, false, true, false) => Some(ContentMode::Command),
123            (false, false, false, true) => Some(ContentMode::Note),
124            _ => None,
125        }
126    }
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct Procedure {
131    #[serde(default, skip_serializing_if = "Vec::is_empty")]
132    pub variables: Vec<Variable>,
133    pub steps: Vec<ProcedureStep>,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct Variable {
138    pub name: String,
139    #[serde(rename = "type")]
140    pub var_type: String,
141    #[serde(default)]
142    pub required: bool,
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub default: Option<serde_yaml_ng::Value>,
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub description: Option<String>,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct ProcedureStep {
151    pub description: String,
152
153    /// Literal tool name. Pre-M6b behaviour: hard binding. Post-M6b: treated
154    /// as a hint when `intent` is also set; otherwise still a hard binding.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub tool: Option<String>,
157
158    /// What the step is trying to accomplish. Free-form string, no central
159    /// taxonomy. Resolved at inject time against the agent's MCP inventory.
160    /// When set, the resolver prefers a tool whose name matches a glob in
161    /// `mcp_requirements` over the literal `tool` field.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub intent: Option<String>,
164
165    /// Preferred tool name pattern (glob). Used as a tiebreaker among
166    /// resolver candidates. Falls back to literal `tool`, then to any
167    /// `mcp_requirements` match for the intent.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub tool_hint: Option<String>,
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct Trigger {
174    #[serde(rename = "type")]
175    pub kind: TriggerKind,
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub pattern: Option<String>,
178}
179
180impl Trigger {
181    /// Returns the keyword string for `Keyword` triggers, `None` otherwise.
182    pub fn exact_keyword(&self) -> Option<&str> {
183        if matches!(self.kind, TriggerKind::Keyword) {
184            self.pattern.as_deref()
185        } else {
186            None
187        }
188    }
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct Requirement {
193    pub name: String,
194    #[serde(default = "default_any_version")]
195    pub version: String,
196}
197
198fn default_any_version() -> String {
199    "*".to_string()
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn full_manifest_roundtrips() {
208        let yaml = r#"
209name: research-prices
210version: 1.0.0
211publisher: human:david
212description: Search product prices
213category: workflow
214hosts: [mur-agent]
215content:
216  abstract: Searches product prices.
217  procedure:
218    variables:
219      - name: product_name
220        type: string
221        required: true
222    steps:
223      - description: Navigate
224        tool: browser.navigate
225triggers:
226  - type: command
227    pattern: /research-prices
228priority: normal
229"#;
230        let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
231        assert_eq!(m.name, "research-prices");
232        assert_eq!(m.category, Category::Workflow);
233        assert_eq!(m.content.mode(), Some(ContentMode::Workflow));
234        let back = serde_yaml_ng::to_string(&m).unwrap();
235        let m2: SkillManifest = serde_yaml_ng::from_str(&back).unwrap();
236        assert_eq!(m2.name, m.name);
237    }
238
239    #[test]
240    fn context_mode_detected() {
241        let c = Content {
242            r#abstract: "a".into(),
243            context: Some("ctx".into()),
244            procedure: None,
245            command: None,
246            note: None,
247        };
248        assert_eq!(c.mode(), Some(ContentMode::Context));
249    }
250
251    #[test]
252    fn empty_content_returns_no_mode() {
253        let c = Content {
254            r#abstract: "a".into(),
255            context: None,
256            procedure: None,
257            command: None,
258            note: None,
259        };
260        assert_eq!(c.mode(), None);
261    }
262
263    #[test]
264    fn mode_returns_note_when_only_note_populated() {
265        let c = Content {
266            r#abstract: "a".into(),
267            context: None,
268            procedure: None,
269            command: None,
270            note: Some("# body".into()),
271        };
272        assert_eq!(c.mode(), Some(ContentMode::Note));
273    }
274
275    #[test]
276    fn mode_returns_none_when_note_and_context_both_populated() {
277        let c = Content {
278            r#abstract: "a".into(),
279            context: Some("ctx".into()),
280            procedure: None,
281            command: None,
282            note: Some("# body".into()),
283        };
284        assert_eq!(c.mode(), None);
285    }
286
287    #[test]
288    fn skill_without_evolution_log_defaults_to_empty() {
289        // YAML without evolution_log field must parse and default to vec![].
290        let yaml = r#"
291name: no-evol
292version: 0.1.0
293publisher: human:test
294description: test
295category: workflow
296content:
297  abstract: test
298"#;
299        let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
300        assert!(m.evolution_log.is_empty());
301    }
302
303    #[test]
304    fn skill_with_evolution_log_roundtrips() {
305        let yaml = r#"
306name: with-evol
307version: 0.1.0
308publisher: human:test
309description: test
310category: workflow
311content:
312  abstract: test
313evolution_log:
314  - version: "0.1.0"
315    generation: 0
316    source: "human:test"
317    changes: "Initial"
318    timestamp: "2026-01-01T00:00:00Z"
319"#;
320        let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
321        assert_eq!(m.evolution_log.len(), 1);
322        assert_eq!(m.evolution_log[0].version, "0.1.0");
323        // Round-trip.
324        let back = serde_yaml_ng::to_string(&m).unwrap();
325        let m2: SkillManifest = serde_yaml_ng::from_str(&back).unwrap();
326        assert_eq!(m2.evolution_log.len(), 1);
327        assert_eq!(m2.evolution_log[0].generation, 0);
328    }
329
330    #[test]
331    fn exact_keyword_returns_pattern_for_keyword_triggers() {
332        let t = Trigger {
333            kind: TriggerKind::Keyword,
334            pattern: Some("search".into()),
335        };
336        assert_eq!(t.exact_keyword(), Some("search"));
337    }
338
339    #[test]
340    fn exact_keyword_returns_none_for_non_keyword_triggers() {
341        let t = Trigger {
342            kind: TriggerKind::Command,
343            pattern: Some("run".into()),
344        };
345        assert_eq!(t.exact_keyword(), None);
346
347        let t = Trigger {
348            kind: TriggerKind::SessionStart,
349            pattern: None,
350        };
351        assert_eq!(t.exact_keyword(), None);
352
353        let t = Trigger {
354            kind: TriggerKind::Manual,
355            pattern: None,
356        };
357        assert_eq!(t.exact_keyword(), None);
358    }
359
360    #[test]
361    fn exact_keyword_returns_none_when_pattern_is_none() {
362        let t = Trigger {
363            kind: TriggerKind::Keyword,
364            pattern: None,
365        };
366        assert_eq!(t.exact_keyword(), None);
367    }
368}