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    /// Delegate this step's sub-goal to a specialist MUR agent over A2A
264    /// (v3b, Channel mode). When set, the channel-aware executor dials this
265    /// agent via `message/send` instead of running `command`/`intent`, and
266    /// attributes the reply to `Agent{<canonical agent name>}` in the channel.
267    /// Ignored when the executor runs without a channel.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub delegate_to: Option<String>,
270
271    /// Risk tier for this step (v3c). When set on a command/delegate step run
272    /// over a channel, the executor gates it via `hitl::gate` per tier.
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub risk: Option<crate::hitl::RiskTier>,
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
278pub struct Trigger {
279    #[serde(rename = "type")]
280    pub kind: TriggerKind,
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub pattern: Option<String>,
283}
284
285impl Trigger {
286    /// Returns the keyword string for `Keyword` triggers, `None` otherwise.
287    pub fn exact_keyword(&self) -> Option<&str> {
288        if matches!(self.kind, TriggerKind::Keyword) {
289            self.pattern.as_deref()
290        } else {
291            None
292        }
293    }
294}
295
296#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
297pub struct Requirement {
298    pub name: String,
299    #[serde(default = "default_any_version")]
300    pub version: String,
301}
302
303fn default_any_version() -> String {
304    "*".to_string()
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn procedure_step_dag_fields_roundtrip() {
313        let yaml = r#"
314description: deploy the app
315command: "fly deploy --app {{app_name}}"
316id: deploy
317depends_on: [build, test]
318on_failure: retry
319retry:
320  max_retries: 2
321  backoff_secs: 5
322timeout_secs: 300
323needs_approval: true
324"#;
325        let step: ProcedureStep = serde_yaml_ng::from_str(yaml).unwrap();
326        assert_eq!(step.id.as_deref(), Some("deploy"));
327        assert_eq!(step.depends_on, vec!["build", "test"]);
328        assert_eq!(step.on_failure, FailureAction::Retry);
329        assert_eq!(step.retry.as_ref().unwrap().max_retries, 2);
330        assert_eq!(step.timeout_secs, Some(300));
331        assert!(step.needs_approval);
332
333        // Legacy step without any DAG fields parses with defaults.
334        let legacy: ProcedureStep =
335            serde_yaml_ng::from_str("description: run tests\ntool: Bash\n").unwrap();
336        assert!(legacy.id.is_none());
337        assert!(legacy.depends_on.is_empty());
338        assert_eq!(legacy.on_failure, FailureAction::Abort);
339        assert!(!legacy.needs_approval);
340    }
341
342    #[test]
343    fn procedure_step_parses_delegate_to() {
344        let yaml = "description: hand off to qa\ndelegate_to: qa\n";
345        let s: ProcedureStep = serde_yaml_ng::from_str(yaml).unwrap();
346        assert_eq!(s.delegate_to.as_deref(), Some("qa"));
347        // Absent → None (every existing skill.yaml still parses).
348        let s2: ProcedureStep = serde_yaml_ng::from_str("description: local step\n").unwrap();
349        assert_eq!(s2.delegate_to, None);
350    }
351
352    #[test]
353    fn variable_accepts_legacy_default_value_alias() {
354        // Legacy workflow YAML used `default_value`; the unified type aliases it.
355        let v: Variable = serde_yaml_ng::from_str(
356            "name: app\ntype: string\nrequired: true\ndefault_value: my-api\n",
357        )
358        .unwrap();
359        assert_eq!(v.default.as_deref(), Some("my-api"));
360        assert_eq!(v.var_type, VarType::String);
361
362        // Modern form `default:` parses too, and choices default empty.
363        let v2: Variable =
364            serde_yaml_ng::from_str("name: env\ntype: string\ndefault: prod\n").unwrap();
365        assert_eq!(v2.default.as_deref(), Some("prod"));
366        assert!(v2.choices.is_empty());
367    }
368
369    #[test]
370    fn variable_all_vartypes_parse() {
371        for t in ["string", "path", "url", "number", "bool", "array"] {
372            let v: Variable = serde_yaml_ng::from_str(&format!("name: x\ntype: {t}\n")).unwrap();
373            assert_eq!(v.var_type.to_string(), t);
374        }
375    }
376
377    #[test]
378    fn full_manifest_roundtrips() {
379        let yaml = r#"
380name: research-prices
381version: 1.0.0
382publisher: human:david
383description: Search product prices
384category: workflow
385hosts: [mur-agent]
386content:
387  abstract: Searches product prices.
388  procedure:
389    variables:
390      - name: product_name
391        type: string
392        required: true
393    steps:
394      - description: Navigate
395        tool: browser.navigate
396triggers:
397  - type: command
398    pattern: /research-prices
399priority: normal
400"#;
401        let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
402        assert_eq!(m.name, "research-prices");
403        assert_eq!(m.category, Category::Workflow);
404        assert_eq!(m.content.mode(), Some(ContentMode::Workflow));
405        let back = serde_yaml_ng::to_string(&m).unwrap();
406        let m2: SkillManifest = serde_yaml_ng::from_str(&back).unwrap();
407        assert_eq!(m2.name, m.name);
408    }
409
410    #[test]
411    fn context_mode_detected() {
412        let c = Content {
413            r#abstract: "a".into(),
414            context: Some("ctx".into()),
415            procedure: None,
416            command: None,
417            note: None,
418        };
419        assert_eq!(c.mode(), Some(ContentMode::Context));
420    }
421
422    #[test]
423    fn empty_content_returns_no_mode() {
424        let c = Content {
425            r#abstract: "a".into(),
426            context: None,
427            procedure: None,
428            command: None,
429            note: None,
430        };
431        assert_eq!(c.mode(), None);
432    }
433
434    #[test]
435    fn mode_returns_note_when_only_note_populated() {
436        let c = Content {
437            r#abstract: "a".into(),
438            context: None,
439            procedure: None,
440            command: None,
441            note: Some("# body".into()),
442        };
443        assert_eq!(c.mode(), Some(ContentMode::Note));
444    }
445
446    #[test]
447    fn mode_returns_none_when_note_and_context_both_populated() {
448        let c = Content {
449            r#abstract: "a".into(),
450            context: Some("ctx".into()),
451            procedure: None,
452            command: None,
453            note: Some("# body".into()),
454        };
455        assert_eq!(c.mode(), None);
456    }
457
458    #[test]
459    fn skill_without_evolution_log_defaults_to_empty() {
460        // YAML without evolution_log field must parse and default to vec![].
461        let yaml = r#"
462name: no-evol
463version: 0.1.0
464publisher: human:test
465description: test
466category: workflow
467content:
468  abstract: test
469"#;
470        let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
471        assert!(m.evolution_log.is_empty());
472    }
473
474    #[test]
475    fn skill_with_evolution_log_roundtrips() {
476        let yaml = r#"
477name: with-evol
478version: 0.1.0
479publisher: human:test
480description: test
481category: workflow
482content:
483  abstract: test
484evolution_log:
485  - version: "0.1.0"
486    generation: 0
487    source: "human:test"
488    changes: "Initial"
489    timestamp: "2026-01-01T00:00:00Z"
490"#;
491        let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
492        assert_eq!(m.evolution_log.len(), 1);
493        assert_eq!(m.evolution_log[0].version, "0.1.0");
494        // Round-trip.
495        let back = serde_yaml_ng::to_string(&m).unwrap();
496        let m2: SkillManifest = serde_yaml_ng::from_str(&back).unwrap();
497        assert_eq!(m2.evolution_log.len(), 1);
498        assert_eq!(m2.evolution_log[0].generation, 0);
499    }
500
501    #[test]
502    fn exact_keyword_returns_pattern_for_keyword_triggers() {
503        let t = Trigger {
504            kind: TriggerKind::Keyword,
505            pattern: Some("search".into()),
506        };
507        assert_eq!(t.exact_keyword(), Some("search"));
508    }
509
510    #[test]
511    fn exact_keyword_returns_none_for_non_keyword_triggers() {
512        let t = Trigger {
513            kind: TriggerKind::Command,
514            pattern: Some("run".into()),
515        };
516        assert_eq!(t.exact_keyword(), None);
517
518        let t = Trigger {
519            kind: TriggerKind::SessionStart,
520            pattern: None,
521        };
522        assert_eq!(t.exact_keyword(), None);
523
524        let t = Trigger {
525            kind: TriggerKind::Manual,
526            pattern: None,
527        };
528        assert_eq!(t.exact_keyword(), None);
529    }
530
531    #[test]
532    fn exact_keyword_returns_none_when_pattern_is_none() {
533        let t = Trigger {
534            kind: TriggerKind::Keyword,
535            pattern: None,
536        };
537        assert_eq!(t.exact_keyword(), None);
538    }
539}