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