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/// Visibility scope for a skill — determines which layers can see and use it.
10#[derive(
11    Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
12)]
13#[serde(rename_all = "lowercase")]
14pub enum SkillScope {
15    /// Visible to the current user only (default).
16    #[default]
17    User,
18    /// Visible across the current project (if active_project is set).
19    Project,
20    /// Visible across the current fleet (if active_fleet is set).
21    Fleet,
22    /// Visible across the current MUR Server team (if active_team is set).
23    Team,
24    /// Visible across the entire enterprise (always visible if scoping is enabled).
25    Enterprise,
26}
27
28impl SkillScope {
29    /// Returns `true` if this scope is `User`.
30    pub fn is_user(&self) -> bool {
31        matches!(self, SkillScope::User)
32    }
33}
34
35/// Progressive disclosure: whether a skill appears in the always-injected
36/// learning index or is loadable on demand only.
37#[derive(
38    Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
39)]
40#[serde(rename_all = "snake_case")]
41pub enum Visibility {
42    /// Listed in the session-start learning index (default).
43    #[default]
44    Indexed,
45    /// Excluded from the index and Layer-2 abstract injection; reachable via
46    /// `mur skill show`, search, and retrieval.
47    OnDemand,
48}
49
50impl Visibility {
51    /// Returns `true` if this is the default `Indexed` visibility.
52    pub fn is_indexed(&self) -> bool {
53        matches!(self, Visibility::Indexed)
54    }
55}
56
57/// Governance identification for Commander integration.
58/// Current code: serde-only seam, never read at runtime.
59/// Commander reads `org_id` + `constitution_hash` to load the applicable
60/// constitution and derive policy. Never stores policy here — policy belongs
61/// in the constitution, not the manifest.
62// ponytail: seam — ignored until Commander ships.
63#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
64#[serde(default)]
65pub struct GovernanceRef {
66    #[serde(default, skip_serializing_if = "String::is_empty")]
67    pub org_id: String,
68    #[serde(default, skip_serializing_if = "String::is_empty")]
69    pub constitution_hash: String,
70}
71
72/// Is a skill with this (scope, fleet, project, team) visible in the given active context?
73/// Layers combine: user/enterprise are always visible; fleet/project/team are visible
74/// only when their selector matches the active context. (specific wins; see spec §6)
75/// Wired into injection via `mur-core` `retrieve::skill_candidates::filter_by_scope`:
76/// project context is auto-derived from the cwd repo root; fleet context remains
77/// env-only until the fleet runtime supplies it; team is set from `Fleet.team_id`.
78pub fn scope_visible(
79    scope: SkillScope,
80    skill_fleet: Option<&str>,
81    skill_project: Option<&str>,
82    skill_team: Option<&str>, // required when scope == Team
83    active_fleet: Option<&str>,
84    active_project: Option<&str>,
85    active_team: Option<&str>, // from MUR_ACTIVE_TEAM env
86) -> bool {
87    match scope {
88        SkillScope::User => true,
89        SkillScope::Enterprise => true,
90        SkillScope::Project => skill_project.is_some() && active_project == skill_project,
91        SkillScope::Fleet => skill_fleet.is_some() && active_fleet == skill_fleet,
92        SkillScope::Team => skill_team.is_some() && active_team == skill_team,
93    }
94}
95
96/// Top-level skill — wraps the manifest with security metadata that lives
97/// alongside (but separate from) the publisher-authored fields.
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct Skill {
100    #[serde(flatten)]
101    pub manifest: SkillManifest,
102
103    /// Computed at install time. Never serialized into the source YAML.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub content_sha256: Option<String>,
106
107    /// Set by the trust store at install time, not by the publisher.
108    #[serde(default)]
109    pub trust_level: TrustLevel,
110
111    /// Capabilities the skill declares it needs.
112    #[serde(default, skip_serializing_if = "Vec::is_empty")]
113    pub capabilities_declared: Vec<String>,
114
115    /// DSSE envelope JSON (base64-encoded inside the envelope). `None` for
116    /// unsigned skills — they enter at Sandboxed and stay there.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub publisher_signature: Option<String>,
119}
120
121/// Publisher-authored fields. This is what gets signed and is the unit of
122/// content hashing.
123#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
124pub struct SkillManifest {
125    pub name: String,
126    pub version: String,
127    pub publisher: String,
128    pub description: String,
129    pub category: Category,
130
131    /// Visibility scope of this skill (user/project/fleet/enterprise).
132    /// Defaults to `User` for back-compat with unsigned skills.
133    #[serde(default, skip_serializing_if = "SkillScope::is_user")]
134    pub scope: SkillScope,
135
136    /// Progressive disclosure: `on_demand` skills never appear in the
137    /// session-start learning index or Layer-2 abstract injection.
138    #[serde(default, skip_serializing_if = "Visibility::is_indexed")]
139    pub visibility: Visibility,
140
141    /// Registry origin stamp: `registry:<publisher>/<name>`. Present on
142    /// built-in registry-installed skills; drives upgrade pipeline.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub origin: Option<String>,
145    /// Version installed from the registry at stamp time.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub origin_version: Option<String>,
148    /// `content_hash_for_origin` of the content as shipped; mismatch against
149    /// current content means the user modified the skill locally.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub origin_hash: Option<String>,
152
153    /// Fleet identifier (required if scope is Fleet).
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub fleet: Option<String>,
156
157    /// Team id this skill is scoped to; required when scope == Team.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub team: Option<String>,
160
161    /// Commander governance seam. Current runtime: ignored entirely.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub governance: Option<GovernanceRef>,
164
165    /// Project path (required if scope is Project).
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub project: Option<String>,
168
169    /// Origin of this skill. Defaults to `Human` so every existing manifest
170    /// (which has no `provenance:` key) parses as human-authored.
171    #[serde(default)]
172    pub provenance: Provenance,
173
174    #[serde(default, skip_serializing_if = "Vec::is_empty")]
175    pub hosts: Vec<HostId>,
176
177    pub content: Content,
178
179    #[serde(default, skip_serializing_if = "Vec::is_empty")]
180    pub requires: Vec<Requirement>,
181
182    #[serde(default, skip_serializing_if = "Vec::is_empty")]
183    pub tags: Vec<String>,
184
185    #[serde(default, skip_serializing_if = "Vec::is_empty")]
186    pub triggers: Vec<Trigger>,
187
188    #[serde(default)]
189    pub priority: Priority,
190
191    /// Evolution history — each entry records one generation.
192    #[serde(default, skip_serializing_if = "Vec::is_empty")]
193    pub evolution_log: Vec<EvolutionEvent>,
194
195    /// Peer transfer provenance — each entry is `agent://<name>`.
196    /// Last entry is the immediate source; first entry is the original publisher.
197    /// Empty for registry-installed and locally-authored skills.
198    #[serde(default, skip_serializing_if = "Vec::is_empty")]
199    pub transfer_chain: Vec<String>,
200
201    /// MCP tool capabilities this skill needs at runtime. Optional; absent
202    /// in M3-era v2.0 manifests. Added in schema v2.1.
203    ///
204    /// **Signature scope:** signed as part of the manifest. Changing
205    /// `mcp_requirements` invalidates an existing publisher signature.
206    #[serde(default, skip_serializing_if = "Vec::is_empty")]
207    pub mcp_requirements: Vec<McpRequirement>,
208
209    /// Timestamp of last modification (for fleet-sync LWW). Used by
210    /// `resolve_manifest_lww()` for conflict resolution. Defaults to the Unix epoch
211    /// on deserialization if absent (for backwards compat with unsigned skills).
212    #[serde(default)]
213    pub updated_at: DateTime<Utc>,
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
217pub struct Content {
218    /// Layer 2 — injected into the system prompt at session start.
219    pub r#abstract: String,
220
221    /// Exactly one of the following is `Some`. Enforced by schema validation.
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub context: Option<String>,
224
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub procedure: Option<Procedure>,
227
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub command: Option<String>,
230
231    /// Note mode (category: note): free markdown body, stored inline in the
232    /// canonical skill.yaml per the 1a storage decision.
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub note: Option<String>,
235}
236
237impl Content {
238    pub fn mode(&self) -> Option<ContentMode> {
239        match (
240            self.context.is_some(),
241            self.procedure.is_some(),
242            self.command.is_some(),
243            self.note.is_some(),
244        ) {
245            (true, false, false, false) => Some(ContentMode::Context),
246            (false, true, false, false) => Some(ContentMode::Workflow),
247            (false, false, true, false) => Some(ContentMode::Command),
248            (false, false, false, true) => Some(ContentMode::Note),
249            _ => None,
250        }
251    }
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
255pub struct Procedure {
256    #[serde(default, skip_serializing_if = "Vec::is_empty")]
257    pub variables: Vec<Variable>,
258    pub steps: Vec<ProcedureStep>,
259}
260
261/// Commander extension: retry configuration for a workflow step.
262#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
263pub struct RetryConfig {
264    pub max_retries: u32,
265    #[serde(default)]
266    pub backoff_secs: Option<u64>,
267}
268
269/// What to do when a workflow step fails.
270#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
271#[serde(rename_all = "lowercase")]
272pub enum FailureAction {
273    /// Skip this step and continue
274    Skip,
275    /// Abort the entire workflow
276    #[default]
277    Abort,
278    /// Retry the step
279    Retry,
280}
281
282#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
283pub struct Variable {
284    pub name: String,
285    #[serde(rename = "type", default)]
286    pub var_type: VarType,
287    #[serde(default)]
288    pub required: bool,
289    /// String-encoded default. `default_value` accepted for legacy workflow YAML.
290    /// Runtime coerces per `var_type` (Number/Bool parsed, Array decoded as
291    /// JSON or comma-separated).
292    #[serde(
293        default,
294        alias = "default_value",
295        skip_serializing_if = "Option::is_none"
296    )]
297    pub default: Option<String>,
298    #[serde(default, skip_serializing_if = "Option::is_none")]
299    pub description: Option<String>,
300    /// Allowed values (renders as a dropdown in the Hub DAG editor).
301    #[serde(default, skip_serializing_if = "Vec::is_empty")]
302    pub choices: Vec<String>,
303}
304
305/// Variable types for workflow/skill parameters (v2 resolved decision #3:
306/// ONE `Variable` type lives here; `workflow::Variable` re-exports it).
307#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
308#[serde(rename_all = "lowercase")]
309pub enum VarType {
310    #[default]
311    String,
312    Path,
313    Url,
314    Number,
315    Bool,
316    /// Array of strings (e.g., multiple URLs, multiple product names)
317    Array,
318}
319
320impl std::fmt::Display for VarType {
321    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
322        match self {
323            VarType::String => write!(f, "string"),
324            VarType::Path => write!(f, "path"),
325            VarType::Url => write!(f, "url"),
326            VarType::Number => write!(f, "number"),
327            VarType::Bool => write!(f, "bool"),
328            VarType::Array => write!(f, "array"),
329        }
330    }
331}
332
333#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
334pub struct ProcedureStep {
335    pub description: String,
336
337    /// Literal tool name. Pre-M6b behaviour: hard binding. Post-M6b: treated
338    /// as a hint when `intent` is also set; otherwise still a hard binding.
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub tool: Option<String>,
341
342    /// What the step is trying to accomplish. Free-form string, no central
343    /// taxonomy. Resolved at inject time against the agent's MCP inventory.
344    /// When set, the resolver prefers a tool whose name matches a glob in
345    /// `mcp_requirements` over the literal `tool` field.
346    #[serde(default, skip_serializing_if = "Option::is_none")]
347    pub intent: Option<String>,
348
349    /// Preferred tool name pattern (glob). Used as a tiebreaker among
350    /// resolver candidates. Falls back to literal `tool`, then to any
351    /// `mcp_requirements` match for the intent.
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub tool_hint: Option<String>,
354
355    // ── Executable-DAG fields (workflow-engine v2 P2; all default so every
356    //    existing skill.yaml parses unchanged) ──
357    /// Stable step id for `depends_on` references. When omitted, executors
358    /// assign the zero-based step index as the id at load time (not serialized).
359    #[serde(default, skip_serializing_if = "Option::is_none")]
360    pub id: Option<String>,
361
362    /// Step ids this step depends on. Empty = root step. Step order derives
363    /// from the dependency topology, never from list position (v2 decision #1).
364    #[serde(default, skip_serializing_if = "Vec::is_empty")]
365    pub depends_on: Vec<String>,
366
367    /// Shell command (command-mode step), run via `sh -c` with exit-code
368    /// gating. Intent-mode steps leave this None — in pure CLI runs they are
369    /// printed as instructions and marked skipped in the ledger (decision #2).
370    #[serde(default, skip_serializing_if = "Option::is_none")]
371    pub command: Option<String>,
372
373    #[serde(default)]
374    pub on_failure: FailureAction,
375
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub retry: Option<RetryConfig>,
378
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub timeout_secs: Option<u64>,
381
382    /// Pause for human approval before running. TTY: prompt and wait.
383    /// Non-TTY: auto-skip and mark `skipped_approval` in the ledger; `--yes`
384    /// auto-approves (v2 decision #5). Wired by the P3 executor.
385    #[serde(default)]
386    pub needs_approval: bool,
387
388    /// Delegate this step's sub-goal to a specialist MUR agent over A2A
389    /// (v3b, Channel mode). When set, the channel-aware executor dials this
390    /// agent via `message/send` instead of running `command`/`intent`, and
391    /// attributes the reply to `Agent{<canonical agent name>}` in the channel.
392    /// Ignored when the executor runs without a channel.
393    #[serde(default, skip_serializing_if = "Option::is_none")]
394    pub delegate_to: Option<String>,
395
396    /// Risk tier for this step (v3c). When set on a command/delegate step run
397    /// over a channel, the executor gates it via `hitl::gate` per tier.
398    #[serde(default, skip_serializing_if = "Option::is_none")]
399    pub risk: Option<crate::hitl::RiskTier>,
400}
401
402#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
403pub struct Trigger {
404    #[serde(rename = "type")]
405    pub kind: TriggerKind,
406    #[serde(default, skip_serializing_if = "Option::is_none")]
407    pub pattern: Option<String>,
408}
409
410impl Trigger {
411    /// Returns the keyword string for `Keyword` triggers, `None` otherwise.
412    pub fn exact_keyword(&self) -> Option<&str> {
413        if matches!(self.kind, TriggerKind::Keyword) {
414            self.pattern.as_deref()
415        } else {
416            None
417        }
418    }
419}
420
421#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
422pub struct Requirement {
423    pub name: String,
424    #[serde(default = "default_any_version")]
425    pub version: String,
426}
427
428fn default_any_version() -> String {
429    "*".to_string()
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    #[test]
437    fn visibility_defaults_to_indexed_and_parses_on_demand() {
438        let yaml = r#"
439name: vis-default
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_eq!(m.visibility, Visibility::Indexed);
449
450        let yaml2 = format!("{yaml}visibility: on_demand\n");
451        let m2: SkillManifest = serde_yaml_ng::from_str(&yaml2).unwrap();
452        assert_eq!(m2.visibility, Visibility::OnDemand);
453
454        // Default is omitted on serialize (keeps existing manifests signature-stable).
455        let out = serde_yaml_ng::to_string(&m).unwrap();
456        assert!(!out.contains("visibility"));
457        let out2 = serde_yaml_ng::to_string(&m2).unwrap();
458        assert!(out2.contains("visibility: on_demand"));
459    }
460
461    #[test]
462    fn procedure_step_dag_fields_roundtrip() {
463        let yaml = r#"
464description: deploy the app
465command: "fly deploy --app {{app_name}}"
466id: deploy
467depends_on: [build, test]
468on_failure: retry
469retry:
470  max_retries: 2
471  backoff_secs: 5
472timeout_secs: 300
473needs_approval: true
474"#;
475        let step: ProcedureStep = serde_yaml_ng::from_str(yaml).unwrap();
476        assert_eq!(step.id.as_deref(), Some("deploy"));
477        assert_eq!(step.depends_on, vec!["build", "test"]);
478        assert_eq!(step.on_failure, FailureAction::Retry);
479        assert_eq!(step.retry.as_ref().unwrap().max_retries, 2);
480        assert_eq!(step.timeout_secs, Some(300));
481        assert!(step.needs_approval);
482
483        // Legacy step without any DAG fields parses with defaults.
484        let legacy: ProcedureStep =
485            serde_yaml_ng::from_str("description: run tests\ntool: Bash\n").unwrap();
486        assert!(legacy.id.is_none());
487        assert!(legacy.depends_on.is_empty());
488        assert_eq!(legacy.on_failure, FailureAction::Abort);
489        assert!(!legacy.needs_approval);
490    }
491
492    #[test]
493    fn procedure_step_parses_delegate_to() {
494        let yaml = "description: hand off to qa\ndelegate_to: qa\n";
495        let s: ProcedureStep = serde_yaml_ng::from_str(yaml).unwrap();
496        assert_eq!(s.delegate_to.as_deref(), Some("qa"));
497        // Absent → None (every existing skill.yaml still parses).
498        let s2: ProcedureStep = serde_yaml_ng::from_str("description: local step\n").unwrap();
499        assert_eq!(s2.delegate_to, None);
500    }
501
502    #[test]
503    fn variable_accepts_legacy_default_value_alias() {
504        // Legacy workflow YAML used `default_value`; the unified type aliases it.
505        let v: Variable = serde_yaml_ng::from_str(
506            "name: app\ntype: string\nrequired: true\ndefault_value: my-api\n",
507        )
508        .unwrap();
509        assert_eq!(v.default.as_deref(), Some("my-api"));
510        assert_eq!(v.var_type, VarType::String);
511
512        // Modern form `default:` parses too, and choices default empty.
513        let v2: Variable =
514            serde_yaml_ng::from_str("name: env\ntype: string\ndefault: prod\n").unwrap();
515        assert_eq!(v2.default.as_deref(), Some("prod"));
516        assert!(v2.choices.is_empty());
517    }
518
519    #[test]
520    fn variable_all_vartypes_parse() {
521        for t in ["string", "path", "url", "number", "bool", "array"] {
522            let v: Variable = serde_yaml_ng::from_str(&format!("name: x\ntype: {t}\n")).unwrap();
523            assert_eq!(v.var_type.to_string(), t);
524        }
525    }
526
527    #[test]
528    fn full_manifest_roundtrips() {
529        let yaml = r#"
530name: research-prices
531version: 1.0.0
532publisher: human:david
533description: Search product prices
534category: workflow
535hosts: [mur-agent]
536content:
537  abstract: Searches product prices.
538  procedure:
539    variables:
540      - name: product_name
541        type: string
542        required: true
543    steps:
544      - description: Navigate
545        tool: browser.navigate
546triggers:
547  - type: command
548    pattern: /research-prices
549priority: normal
550"#;
551        let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
552        assert_eq!(m.name, "research-prices");
553        assert_eq!(m.category, Category::Workflow);
554        assert_eq!(m.content.mode(), Some(ContentMode::Workflow));
555        let back = serde_yaml_ng::to_string(&m).unwrap();
556        let m2: SkillManifest = serde_yaml_ng::from_str(&back).unwrap();
557        assert_eq!(m2.name, m.name);
558    }
559
560    #[test]
561    fn context_mode_detected() {
562        let c = Content {
563            r#abstract: "a".into(),
564            context: Some("ctx".into()),
565            procedure: None,
566            command: None,
567            note: None,
568        };
569        assert_eq!(c.mode(), Some(ContentMode::Context));
570    }
571
572    #[test]
573    fn empty_content_returns_no_mode() {
574        let c = Content {
575            r#abstract: "a".into(),
576            context: None,
577            procedure: None,
578            command: None,
579            note: None,
580        };
581        assert_eq!(c.mode(), None);
582    }
583
584    #[test]
585    fn mode_returns_note_when_only_note_populated() {
586        let c = Content {
587            r#abstract: "a".into(),
588            context: None,
589            procedure: None,
590            command: None,
591            note: Some("# body".into()),
592        };
593        assert_eq!(c.mode(), Some(ContentMode::Note));
594    }
595
596    #[test]
597    fn mode_returns_none_when_note_and_context_both_populated() {
598        let c = Content {
599            r#abstract: "a".into(),
600            context: Some("ctx".into()),
601            procedure: None,
602            command: None,
603            note: Some("# body".into()),
604        };
605        assert_eq!(c.mode(), None);
606    }
607
608    #[test]
609    fn skill_without_evolution_log_defaults_to_empty() {
610        // YAML without evolution_log field must parse and default to vec![].
611        let yaml = r#"
612name: no-evol
613version: 0.1.0
614publisher: human:test
615description: test
616category: workflow
617content:
618  abstract: test
619"#;
620        let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
621        assert!(m.evolution_log.is_empty());
622    }
623
624    #[test]
625    fn skill_with_evolution_log_roundtrips() {
626        let yaml = r#"
627name: with-evol
628version: 0.1.0
629publisher: human:test
630description: test
631category: workflow
632content:
633  abstract: test
634evolution_log:
635  - version: "0.1.0"
636    generation: 0
637    source: "human:test"
638    changes: "Initial"
639    timestamp: "2026-01-01T00:00:00Z"
640"#;
641        let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
642        assert_eq!(m.evolution_log.len(), 1);
643        assert_eq!(m.evolution_log[0].version, "0.1.0");
644        // Round-trip.
645        let back = serde_yaml_ng::to_string(&m).unwrap();
646        let m2: SkillManifest = serde_yaml_ng::from_str(&back).unwrap();
647        assert_eq!(m2.evolution_log.len(), 1);
648        assert_eq!(m2.evolution_log[0].generation, 0);
649    }
650
651    #[test]
652    fn exact_keyword_returns_pattern_for_keyword_triggers() {
653        let t = Trigger {
654            kind: TriggerKind::Keyword,
655            pattern: Some("search".into()),
656        };
657        assert_eq!(t.exact_keyword(), Some("search"));
658    }
659
660    #[test]
661    fn exact_keyword_returns_none_for_non_keyword_triggers() {
662        let t = Trigger {
663            kind: TriggerKind::Command,
664            pattern: Some("run".into()),
665        };
666        assert_eq!(t.exact_keyword(), None);
667
668        let t = Trigger {
669            kind: TriggerKind::SessionStart,
670            pattern: None,
671        };
672        assert_eq!(t.exact_keyword(), None);
673
674        let t = Trigger {
675            kind: TriggerKind::Manual,
676            pattern: None,
677        };
678        assert_eq!(t.exact_keyword(), None);
679    }
680
681    #[test]
682    fn exact_keyword_returns_none_when_pattern_is_none() {
683        let t = Trigger {
684            kind: TriggerKind::Keyword,
685            pattern: None,
686        };
687        assert_eq!(t.exact_keyword(), None);
688    }
689
690    #[test]
691    fn skill_scope_serde_and_default() {
692        // Default is User.
693        assert_eq!(SkillScope::default(), SkillScope::User);
694        assert!(SkillScope::User.is_user());
695        assert!(!SkillScope::Project.is_user());
696        assert!(!SkillScope::Fleet.is_user());
697
698        // Serde: lowercase in YAML.
699        let yaml = r#"
700name: scoped-skill
701version: 0.1.0
702publisher: human:test
703description: test
704category: workflow
705scope: fleet
706fleet: prod
707project: null
708content:
709  abstract: test
710"#;
711        let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
712        assert_eq!(m.scope, SkillScope::Fleet);
713        assert_eq!(m.fleet, Some("prod".into()));
714        assert_eq!(m.project, None);
715
716        // Round-trip preserves scope.
717        let back = serde_yaml_ng::to_string(&m).unwrap();
718        let m2: SkillManifest = serde_yaml_ng::from_str(&back).unwrap();
719        assert_eq!(m2.scope, SkillScope::Fleet);
720        assert_eq!(m2.fleet, Some("prod".into()));
721
722        // Missing scope defaults to User.
723        let yaml_no_scope = r#"
724name: default-scope
725version: 0.1.0
726publisher: human:test
727description: test
728category: workflow
729content:
730  abstract: test
731"#;
732        let m3: SkillManifest = serde_yaml_ng::from_str(yaml_no_scope).unwrap();
733        assert_eq!(m3.scope, SkillScope::User);
734        assert!(m3.fleet.is_none());
735        assert!(m3.project.is_none());
736    }
737
738    #[test]
739    fn scope_visible_matrix() {
740        // user + enterprise always visible
741        assert!(scope_visible(
742            SkillScope::User,
743            None,
744            None,
745            None,
746            None,
747            None,
748            None
749        ));
750        assert!(scope_visible(
751            SkillScope::Enterprise,
752            None,
753            None,
754            None,
755            None,
756            None,
757            None
758        ));
759        // fleet skill visible only when active fleet matches
760        assert!(scope_visible(
761            SkillScope::Fleet,
762            Some("dev"),
763            None,
764            None,
765            Some("dev"),
766            None,
767            None
768        ));
769        assert!(!scope_visible(
770            SkillScope::Fleet,
771            Some("dev"),
772            None,
773            None,
774            Some("ops"),
775            None,
776            None
777        ));
778        assert!(!scope_visible(
779            SkillScope::Fleet,
780            Some("dev"),
781            None,
782            None,
783            None,
784            None,
785            None
786        ));
787        // project skill visible only when active project matches
788        assert!(scope_visible(
789            SkillScope::Project,
790            None,
791            Some("/p"),
792            None,
793            None,
794            Some("/p"),
795            None
796        ));
797        assert!(!scope_visible(
798            SkillScope::Project,
799            None,
800            Some("/p"),
801            None,
802            None,
803            Some("/q"),
804            None
805        ));
806    }
807
808    #[test]
809    fn team_scope_visibility() {
810        // matches when active_team == skill_team
811        assert!(scope_visible(
812            SkillScope::Team,
813            None,
814            None,
815            Some("org-xyz"),
816            None,
817            None,
818            Some("org-xyz"),
819        ));
820        // mismatch → false
821        assert!(!scope_visible(
822            SkillScope::Team,
823            None,
824            None,
825            Some("org-abc"),
826            None,
827            None,
828            Some("org-xyz"),
829        ));
830        // no active_team → fail-closed
831        assert!(!scope_visible(
832            SkillScope::Team,
833            None,
834            None,
835            Some("org-xyz"),
836            None,
837            None,
838            None,
839        ));
840        // no skill_team selector → never injects (None == None guard)
841        assert!(!scope_visible(
842            SkillScope::Team,
843            None,
844            None,
845            None,
846            None,
847            None,
848            Some("org-xyz"),
849        ));
850    }
851
852    #[test]
853    fn governance_ref_roundtrip() {
854        let yaml = "name: t\nversion: 1.0.0\npublisher: human:test\ndescription: t\ncategory: workflow\ncontent:\n  abstract: t\ngovernance:\n  org_id: org-1\n  constitution_hash: abc\n";
855        let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
856        let g = m.governance.unwrap();
857        assert_eq!(g.org_id, "org-1");
858        assert_eq!(g.constitution_hash, "abc");
859    }
860
861    #[test]
862    fn governance_ref_absent_is_none() {
863        let m: SkillManifest = serde_yaml_ng::from_str("name: t\nversion: 1.0.0\npublisher: human:test\ndescription: t\ncategory: workflow\ncontent:\n  abstract: t\n").unwrap();
864        assert!(m.governance.is_none());
865    }
866
867    #[test]
868    fn team_field_roundtrip() {
869        let yaml = "name: t\nversion: 1.0.0\npublisher: human:test\ndescription: t\ncategory: workflow\ncontent:\n  abstract: t\nscope: team\nteam: org-1\n";
870        let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
871        assert_eq!(m.scope, SkillScope::Team);
872        assert_eq!(m.team.as_deref(), Some("org-1"));
873    }
874}