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