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