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, schemars::JsonSchema)]
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, schemars::JsonSchema)]
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, schemars::JsonSchema)]
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/// Commander extension: retry configuration for a workflow step.
137#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
138pub struct RetryConfig {
139    pub max_retries: u32,
140    #[serde(default)]
141    pub backoff_secs: Option<u64>,
142}
143
144/// What to do when a workflow step fails.
145#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
146#[serde(rename_all = "lowercase")]
147pub enum FailureAction {
148    /// Skip this step and continue
149    Skip,
150    /// Abort the entire workflow
151    #[default]
152    Abort,
153    /// Retry the step
154    Retry,
155}
156
157#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
158pub struct Variable {
159    pub name: String,
160    #[serde(rename = "type", default)]
161    pub var_type: VarType,
162    #[serde(default)]
163    pub required: bool,
164    /// String-encoded default. `default_value` accepted for legacy workflow YAML.
165    /// Runtime coerces per `var_type` (Number/Bool parsed, Array decoded as
166    /// JSON or comma-separated).
167    #[serde(
168        default,
169        alias = "default_value",
170        skip_serializing_if = "Option::is_none"
171    )]
172    pub default: Option<String>,
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub description: Option<String>,
175    /// Allowed values (renders as a dropdown in the Hub DAG editor).
176    #[serde(default, skip_serializing_if = "Vec::is_empty")]
177    pub choices: Vec<String>,
178}
179
180/// Variable types for workflow/skill parameters (v2 resolved decision #3:
181/// ONE `Variable` type lives here; `workflow::Variable` re-exports it).
182#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
183#[serde(rename_all = "lowercase")]
184pub enum VarType {
185    #[default]
186    String,
187    Path,
188    Url,
189    Number,
190    Bool,
191    /// Array of strings (e.g., multiple URLs, multiple product names)
192    Array,
193}
194
195impl std::fmt::Display for VarType {
196    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197        match self {
198            VarType::String => write!(f, "string"),
199            VarType::Path => write!(f, "path"),
200            VarType::Url => write!(f, "url"),
201            VarType::Number => write!(f, "number"),
202            VarType::Bool => write!(f, "bool"),
203            VarType::Array => write!(f, "array"),
204        }
205    }
206}
207
208#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
209pub struct ProcedureStep {
210    pub description: String,
211
212    /// Literal tool name. Pre-M6b behaviour: hard binding. Post-M6b: treated
213    /// as a hint when `intent` is also set; otherwise still a hard binding.
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub tool: Option<String>,
216
217    /// What the step is trying to accomplish. Free-form string, no central
218    /// taxonomy. Resolved at inject time against the agent's MCP inventory.
219    /// When set, the resolver prefers a tool whose name matches a glob in
220    /// `mcp_requirements` over the literal `tool` field.
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub intent: Option<String>,
223
224    /// Preferred tool name pattern (glob). Used as a tiebreaker among
225    /// resolver candidates. Falls back to literal `tool`, then to any
226    /// `mcp_requirements` match for the intent.
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub tool_hint: Option<String>,
229
230    // ── Executable-DAG fields (workflow-engine v2 P2; all default so every
231    //    existing skill.yaml parses unchanged) ──
232    /// Stable step id for `depends_on` references. When omitted, executors
233    /// assign the zero-based step index as the id at load time (not serialized).
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub id: Option<String>,
236
237    /// Step ids this step depends on. Empty = root step. Step order derives
238    /// from the dependency topology, never from list position (v2 decision #1).
239    #[serde(default, skip_serializing_if = "Vec::is_empty")]
240    pub depends_on: Vec<String>,
241
242    /// Shell command (command-mode step), run via `sh -c` with exit-code
243    /// gating. Intent-mode steps leave this None — in pure CLI runs they are
244    /// printed as instructions and marked skipped in the ledger (decision #2).
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub command: Option<String>,
247
248    #[serde(default)]
249    pub on_failure: FailureAction,
250
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub retry: Option<RetryConfig>,
253
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub timeout_secs: Option<u64>,
256
257    /// Pause for human approval before running. TTY: prompt and wait.
258    /// Non-TTY: auto-skip and mark `skipped_approval` in the ledger; `--yes`
259    /// auto-approves (v2 decision #5). Wired by the P3 executor.
260    #[serde(default)]
261    pub needs_approval: bool,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
265pub struct Trigger {
266    #[serde(rename = "type")]
267    pub kind: TriggerKind,
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub pattern: Option<String>,
270}
271
272impl Trigger {
273    /// Returns the keyword string for `Keyword` triggers, `None` otherwise.
274    pub fn exact_keyword(&self) -> Option<&str> {
275        if matches!(self.kind, TriggerKind::Keyword) {
276            self.pattern.as_deref()
277        } else {
278            None
279        }
280    }
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
284pub struct Requirement {
285    pub name: String,
286    #[serde(default = "default_any_version")]
287    pub version: String,
288}
289
290fn default_any_version() -> String {
291    "*".to_string()
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn procedure_step_dag_fields_roundtrip() {
300        let yaml = r#"
301description: deploy the app
302command: "fly deploy --app {{app_name}}"
303id: deploy
304depends_on: [build, test]
305on_failure: retry
306retry:
307  max_retries: 2
308  backoff_secs: 5
309timeout_secs: 300
310needs_approval: true
311"#;
312        let step: ProcedureStep = serde_yaml_ng::from_str(yaml).unwrap();
313        assert_eq!(step.id.as_deref(), Some("deploy"));
314        assert_eq!(step.depends_on, vec!["build", "test"]);
315        assert_eq!(step.on_failure, FailureAction::Retry);
316        assert_eq!(step.retry.as_ref().unwrap().max_retries, 2);
317        assert_eq!(step.timeout_secs, Some(300));
318        assert!(step.needs_approval);
319
320        // Legacy step without any DAG fields parses with defaults.
321        let legacy: ProcedureStep =
322            serde_yaml_ng::from_str("description: run tests\ntool: Bash\n").unwrap();
323        assert!(legacy.id.is_none());
324        assert!(legacy.depends_on.is_empty());
325        assert_eq!(legacy.on_failure, FailureAction::Abort);
326        assert!(!legacy.needs_approval);
327    }
328
329    #[test]
330    fn variable_accepts_legacy_default_value_alias() {
331        // Legacy workflow YAML used `default_value`; the unified type aliases it.
332        let v: Variable = serde_yaml_ng::from_str(
333            "name: app\ntype: string\nrequired: true\ndefault_value: my-api\n",
334        )
335        .unwrap();
336        assert_eq!(v.default.as_deref(), Some("my-api"));
337        assert_eq!(v.var_type, VarType::String);
338
339        // Modern form `default:` parses too, and choices default empty.
340        let v2: Variable =
341            serde_yaml_ng::from_str("name: env\ntype: string\ndefault: prod\n").unwrap();
342        assert_eq!(v2.default.as_deref(), Some("prod"));
343        assert!(v2.choices.is_empty());
344    }
345
346    #[test]
347    fn variable_all_vartypes_parse() {
348        for t in ["string", "path", "url", "number", "bool", "array"] {
349            let v: Variable = serde_yaml_ng::from_str(&format!("name: x\ntype: {t}\n")).unwrap();
350            assert_eq!(v.var_type.to_string(), t);
351        }
352    }
353
354    #[test]
355    fn full_manifest_roundtrips() {
356        let yaml = r#"
357name: research-prices
358version: 1.0.0
359publisher: human:david
360description: Search product prices
361category: workflow
362hosts: [mur-agent]
363content:
364  abstract: Searches product prices.
365  procedure:
366    variables:
367      - name: product_name
368        type: string
369        required: true
370    steps:
371      - description: Navigate
372        tool: browser.navigate
373triggers:
374  - type: command
375    pattern: /research-prices
376priority: normal
377"#;
378        let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
379        assert_eq!(m.name, "research-prices");
380        assert_eq!(m.category, Category::Workflow);
381        assert_eq!(m.content.mode(), Some(ContentMode::Workflow));
382        let back = serde_yaml_ng::to_string(&m).unwrap();
383        let m2: SkillManifest = serde_yaml_ng::from_str(&back).unwrap();
384        assert_eq!(m2.name, m.name);
385    }
386
387    #[test]
388    fn context_mode_detected() {
389        let c = Content {
390            r#abstract: "a".into(),
391            context: Some("ctx".into()),
392            procedure: None,
393            command: None,
394            note: None,
395        };
396        assert_eq!(c.mode(), Some(ContentMode::Context));
397    }
398
399    #[test]
400    fn empty_content_returns_no_mode() {
401        let c = Content {
402            r#abstract: "a".into(),
403            context: None,
404            procedure: None,
405            command: None,
406            note: None,
407        };
408        assert_eq!(c.mode(), None);
409    }
410
411    #[test]
412    fn mode_returns_note_when_only_note_populated() {
413        let c = Content {
414            r#abstract: "a".into(),
415            context: None,
416            procedure: None,
417            command: None,
418            note: Some("# body".into()),
419        };
420        assert_eq!(c.mode(), Some(ContentMode::Note));
421    }
422
423    #[test]
424    fn mode_returns_none_when_note_and_context_both_populated() {
425        let c = Content {
426            r#abstract: "a".into(),
427            context: Some("ctx".into()),
428            procedure: None,
429            command: None,
430            note: Some("# body".into()),
431        };
432        assert_eq!(c.mode(), None);
433    }
434
435    #[test]
436    fn skill_without_evolution_log_defaults_to_empty() {
437        // YAML without evolution_log field must parse and default to vec![].
438        let yaml = r#"
439name: no-evol
440version: 0.1.0
441publisher: human:test
442description: test
443category: workflow
444content:
445  abstract: test
446"#;
447        let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
448        assert!(m.evolution_log.is_empty());
449    }
450
451    #[test]
452    fn skill_with_evolution_log_roundtrips() {
453        let yaml = r#"
454name: with-evol
455version: 0.1.0
456publisher: human:test
457description: test
458category: workflow
459content:
460  abstract: test
461evolution_log:
462  - version: "0.1.0"
463    generation: 0
464    source: "human:test"
465    changes: "Initial"
466    timestamp: "2026-01-01T00:00:00Z"
467"#;
468        let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
469        assert_eq!(m.evolution_log.len(), 1);
470        assert_eq!(m.evolution_log[0].version, "0.1.0");
471        // Round-trip.
472        let back = serde_yaml_ng::to_string(&m).unwrap();
473        let m2: SkillManifest = serde_yaml_ng::from_str(&back).unwrap();
474        assert_eq!(m2.evolution_log.len(), 1);
475        assert_eq!(m2.evolution_log[0].generation, 0);
476    }
477
478    #[test]
479    fn exact_keyword_returns_pattern_for_keyword_triggers() {
480        let t = Trigger {
481            kind: TriggerKind::Keyword,
482            pattern: Some("search".into()),
483        };
484        assert_eq!(t.exact_keyword(), Some("search"));
485    }
486
487    #[test]
488    fn exact_keyword_returns_none_for_non_keyword_triggers() {
489        let t = Trigger {
490            kind: TriggerKind::Command,
491            pattern: Some("run".into()),
492        };
493        assert_eq!(t.exact_keyword(), None);
494
495        let t = Trigger {
496            kind: TriggerKind::SessionStart,
497            pattern: None,
498        };
499        assert_eq!(t.exact_keyword(), None);
500
501        let t = Trigger {
502            kind: TriggerKind::Manual,
503            pattern: None,
504        };
505        assert_eq!(t.exact_keyword(), None);
506    }
507
508    #[test]
509    fn exact_keyword_returns_none_when_pattern_is_none() {
510        let t = Trigger {
511            kind: TriggerKind::Keyword,
512            pattern: None,
513        };
514        assert_eq!(t.exact_keyword(), None);
515    }
516}