Skip to main content

a3s_code_core/skills/
registry.rs

1//! Skill Registry
2//!
3//! Manages skill registration, loading, and lookup.
4//! Integrates with `SkillValidator` (safety gate) and `SkillScorer` (feedback loop).
5
6use super::feedback::SkillScorer;
7use super::validator::SkillValidator;
8use super::Skill;
9use anyhow::Context;
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::sync::{Arc, RwLock};
13
14/// Skill registry for managing available skills
15///
16/// Provides skill registration, loading from directories, and lookup by name.
17/// Optionally validates skills before registration and filters disabled skills
18/// from the system prompt based on feedback scores.
19pub struct SkillRegistry {
20    skills: Arc<RwLock<HashMap<String, Arc<Skill>>>>,
21    validator: Arc<RwLock<Option<Arc<dyn SkillValidator>>>>,
22    scorer: Arc<RwLock<Option<Arc<dyn SkillScorer>>>>,
23}
24
25impl SkillRegistry {
26    /// Create a new empty skill registry
27    pub fn new() -> Self {
28        Self {
29            skills: Arc::new(RwLock::new(HashMap::new())),
30            validator: Arc::new(RwLock::new(None)),
31            scorer: Arc::new(RwLock::new(None)),
32        }
33    }
34
35    /// Create a registry with built-in skills
36    pub fn with_builtins() -> Self {
37        let registry = Self::new();
38        for skill in super::builtin::builtin_skills() {
39            // Built-in skills bypass validation
40            registry.register_unchecked(skill);
41        }
42        registry
43    }
44
45    /// Fork this registry into an independent copy.
46    ///
47    /// The fork shares no state with the original — skills added to the fork
48    /// do not affect the source registry. Validator and scorer are preserved so
49    /// that session/subagent registries keep the same safety and scoring policy
50    /// as the source registry.
51    pub fn fork(&self) -> Self {
52        let skills = self.skills.read().unwrap().clone();
53        Self {
54            skills: Arc::new(RwLock::new(skills)),
55            validator: Arc::new(RwLock::new(self.validator.read().unwrap().clone())),
56            scorer: Arc::new(RwLock::new(self.scorer.read().unwrap().clone())),
57        }
58    }
59
60    /// Set the skill validator (safety gate)
61    pub fn set_validator(&self, validator: Arc<dyn SkillValidator>) {
62        *self.validator.write().unwrap() = Some(validator);
63    }
64
65    /// Set the skill scorer (feedback loop)
66    pub fn set_scorer(&self, scorer: Arc<dyn SkillScorer>) {
67        *self.scorer.write().unwrap() = Some(scorer);
68    }
69
70    /// Get the scorer (for external use, e.g., ManageSkillTool)
71    pub fn scorer(&self) -> Option<Arc<dyn SkillScorer>> {
72        self.scorer.read().unwrap().clone()
73    }
74
75    /// Register a skill with validation
76    ///
77    /// If a validator is set, the skill must pass validation before registration.
78    /// Returns an error if validation fails.
79    pub fn register(
80        &self,
81        skill: Arc<Skill>,
82    ) -> Result<(), super::validator::SkillValidationError> {
83        // Run validator if set
84        if let Some(ref validator) = *self.validator.read().unwrap() {
85            validator.validate(&skill)?;
86        }
87        self.register_unchecked(skill);
88        Ok(())
89    }
90
91    /// Register a skill without validation (for built-in skills)
92    pub fn register_unchecked(&self, skill: Arc<Skill>) {
93        let mut skills = self.skills.write().unwrap();
94        skills.insert(skill.name.clone(), skill);
95    }
96
97    /// Get a skill by name
98    pub fn get(&self, name: &str) -> Option<Arc<Skill>> {
99        let skills = self.skills.read().unwrap();
100        skills.get(name).cloned()
101    }
102
103    /// List all registered skill names
104    pub fn list(&self) -> Vec<String> {
105        let skills = self.skills.read().unwrap();
106        skills.keys().cloned().collect()
107    }
108
109    /// Get all registered skills
110    pub fn all(&self) -> Vec<Arc<Skill>> {
111        let skills = self.skills.read().unwrap();
112        skills.values().cloned().collect()
113    }
114
115    /// Load skills from a directory
116    ///
117    /// Recursively scans the directory for skill files and attempts to parse them.
118    ///
119    /// Supported layouts:
120    /// - `path/to/skill.md`
121    /// - `path/to/skill/SKILL.md`
122    ///
123    /// Candidate files are processed in deterministic sorted order. Files that
124    /// fail to parse are skipped with debug logging; validation failures are
125    /// logged as warnings.
126    pub fn load_from_dir(&self, dir: impl AsRef<Path>) -> anyhow::Result<usize> {
127        let dir = dir.as_ref();
128
129        if !dir.exists() {
130            return Ok(0);
131        }
132
133        if !dir.is_dir() {
134            anyhow::bail!("Path is not a directory: {}", dir.display());
135        }
136
137        let mut loaded = 0;
138        for candidate in Self::collect_skill_candidates(dir)? {
139            match Skill::from_file(&candidate) {
140                Ok(skill) => {
141                    let name = skill.name.clone();
142                    let skill = Arc::new(skill);
143                    if self.get(&name).is_some() {
144                        tracing::warn!(
145                            skill = %name,
146                            path = %candidate.display(),
147                            "Duplicate skill name encountered during directory load — overriding previous definition"
148                        );
149                    }
150                    match self.register(skill) {
151                        Ok(()) => loaded += 1,
152                        Err(e) => {
153                            tracing::warn!(
154                                "Skill validation failed for {}: {}",
155                                candidate.display(),
156                                e
157                            );
158                        }
159                    }
160                }
161                Err(e) => {
162                    tracing::debug!("Skipped {}: {}", candidate.display(), e);
163                }
164            }
165        }
166
167        Ok(loaded)
168    }
169
170    fn collect_skill_candidates(dir: &Path) -> anyhow::Result<Vec<PathBuf>> {
171        fn visit(dir: &Path, out: &mut Vec<PathBuf>) -> anyhow::Result<()> {
172            let mut entries = std::fs::read_dir(dir)
173                .with_context(|| format!("Failed to read directory: {}", dir.display()))?
174                .collect::<Result<Vec<_>, std::io::Error>>()?;
175            entries.sort_by_key(|entry| entry.path());
176
177            for entry in entries {
178                let path = entry.path();
179                if path.is_dir() {
180                    let skill_md = path.join("SKILL.md");
181                    if skill_md.is_file() {
182                        out.push(skill_md);
183                    }
184                    visit(&path, out)?;
185                } else if path.extension().and_then(|s| s.to_str()) == Some("md") {
186                    out.push(path);
187                }
188            }
189            Ok(())
190        }
191
192        let mut out = Vec::new();
193        visit(dir, &mut out)?;
194        out.sort();
195        out.dedup();
196        Ok(out)
197    }
198
199    /// Load a single skill from a file
200    pub fn load_from_file(&self, path: impl AsRef<Path>) -> anyhow::Result<Arc<Skill>> {
201        let skill = Skill::from_file(path)?;
202        let skill = Arc::new(skill);
203        self.register(skill.clone())
204            .map_err(|e| anyhow::anyhow!("Skill validation failed: {}", e))?;
205        Ok(skill)
206    }
207
208    /// Remove a skill by name
209    pub fn remove(&self, name: &str) -> Option<Arc<Skill>> {
210        let mut skills = self.skills.write().unwrap();
211        skills.remove(name)
212    }
213
214    /// Clear all skills
215    pub fn clear(&self) {
216        let mut skills = self.skills.write().unwrap();
217        skills.clear();
218    }
219
220    /// Get the number of registered skills
221    pub fn len(&self) -> usize {
222        let skills = self.skills.read().unwrap();
223        skills.len()
224    }
225
226    /// Check if the registry is empty
227    pub fn is_empty(&self) -> bool {
228        self.len() == 0
229    }
230
231    /// Get all skills of a specific kind
232    pub fn by_kind(&self, kind: super::SkillKind) -> Vec<Arc<Skill>> {
233        let skills = self.skills.read().unwrap();
234        skills
235            .values()
236            .filter(|s| s.kind == kind)
237            .cloned()
238            .collect()
239    }
240
241    /// Get all skills with a specific tag
242    pub fn by_tag(&self, tag: &str) -> Vec<Arc<Skill>> {
243        let skills = self.skills.read().unwrap();
244        skills
245            .values()
246            .filter(|s| s.tags.iter().any(|t| t == tag))
247            .cloned()
248            .collect()
249    }
250
251    /// Get all persona-kind skills
252    ///
253    /// Personas are session-level system prompts bound at session creation.
254    /// They are NOT injected into the global system prompt via `to_system_prompt()`.
255    pub fn personas(&self) -> Vec<Arc<Skill>> {
256        self.by_kind(super::SkillKind::Persona)
257    }
258
259    /// Search discoverable instruction/tool skills by name, tag, description, or content.
260    pub fn search(&self, query: &str, limit: usize) -> Vec<Arc<Skill>> {
261        let skills = self.skills.read().unwrap();
262        let scorer = self.scorer.read().unwrap();
263        let query_lower = query.to_lowercase();
264        let query_tokens: Vec<&str> = query_lower
265            .split_whitespace()
266            .map(|w| w.trim_matches(|c: char| !c.is_alphanumeric()))
267            .filter(|w| w.len() >= 2)
268            .collect();
269
270        let mut scored: Vec<(u32, String, Arc<Skill>)> = skills
271            .values()
272            .filter(|s| Self::is_discoverable_skill(s))
273            .filter(|s| match scorer.as_ref() {
274                Some(sc) => !sc.should_disable(&s.name),
275                None => true,
276            })
277            .filter_map(|skill| {
278                let score = Self::skill_search_score(skill, &query_lower, &query_tokens);
279                if score == 0 {
280                    None
281                } else {
282                    Some((score, skill.name.clone(), Arc::clone(skill)))
283                }
284            })
285            .collect();
286
287        scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
288        scored
289            .into_iter()
290            .take(limit.max(1))
291            .map(|(_, _, skill)| skill)
292            .collect()
293    }
294
295    fn is_discoverable_skill(skill: &Skill) -> bool {
296        skill.kind == super::SkillKind::Instruction || skill.kind == super::SkillKind::Tool
297    }
298
299    fn skill_search_score(skill: &Skill, query_lower: &str, query_tokens: &[&str]) -> u32 {
300        if query_lower.trim().is_empty() {
301            return 1;
302        }
303
304        let name = skill.name.to_lowercase();
305        let description = skill.description.to_lowercase();
306        let tags: Vec<String> = skill.tags.iter().map(|t| t.to_lowercase()).collect();
307        let content = skill.content.to_lowercase();
308        let mut score = 0;
309
310        if query_lower.contains(&name) {
311            score += 100;
312        }
313        if tags.iter().any(|tag| query_lower.contains(tag)) {
314            score += 80;
315        }
316
317        for token in query_tokens {
318            if name.contains(token) {
319                score += 20;
320            }
321            if tags.iter().any(|tag| tag.contains(token)) {
322                score += 15;
323            }
324            if description.contains(token) {
325                score += 8;
326            }
327            if content.contains(token) {
328                score += 2;
329            }
330        }
331
332        score
333    }
334
335    /// Generate system prompt content from all instruction skills
336    ///
337    /// Concatenates the content of all instruction-type skills for injection
338    /// into the system prompt. Skills disabled by the scorer are excluded.
339    /// Persona-kind skills are excluded — they are bound per-session, not globally.
340    /// Generate the system prompt fragment for this registry.
341    ///
342    /// Only emits a skill directory (name + description) — NOT the full skill content.
343    /// Full content is injected on-demand via `match_skills` when a user request matches.
344    pub fn to_system_prompt(&self) -> String {
345        let skills = self.skills.read().unwrap();
346        let scorer = self.scorer.read().unwrap();
347
348        let has_discoverable_skill = skills.values().any(|s| {
349            Self::is_discoverable_skill(s)
350                && match scorer.as_ref() {
351                    Some(sc) => !sc.should_disable(&s.name),
352                    None => true,
353                }
354        });
355
356        if !has_discoverable_skill {
357            return String::new();
358        }
359
360        String::from(crate::prompts::SKILLS_CATALOG_HEADER)
361    }
362
363    /// Return the full content of skills relevant to the given user input.
364    ///
365    /// Matches by checking if any skill name or tag appears in the input (case-insensitive).
366    /// Returns an empty string if no skills match — caller should not inject anything.
367    pub fn match_skills(&self, user_input: &str) -> String {
368        let matched = self.search(user_input, 3);
369
370        if matched.is_empty() {
371            return String::new();
372        }
373
374        let mut out = String::from("# Skill Instructions\n\n");
375        for skill in matched {
376            out.push_str(&skill.to_system_prompt());
377            out.push_str("\n\n---\n\n");
378        }
379        out
380    }
381}
382
383impl Default for SkillRegistry {
384    fn default() -> Self {
385        Self::new()
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use crate::skills::feedback::{DefaultSkillScorer, SkillFeedback, SkillOutcome};
393    use crate::skills::SkillKind;
394    use std::io::Write;
395    use tempfile::TempDir;
396
397    #[test]
398    fn test_new_registry() {
399        let registry = SkillRegistry::new();
400        assert_eq!(registry.len(), 0);
401        assert!(registry.is_empty());
402    }
403
404    #[test]
405    fn test_with_builtins() {
406        let registry = SkillRegistry::with_builtins();
407        assert_eq!(registry.len(), 9, "Expected 9 built-in skills");
408        assert!(!registry.is_empty());
409
410        // Code assistance skills
411        assert!(registry.get("agentic-search").is_some());
412        assert!(registry.get("agentic-parse").is_some());
413        assert!(registry.get("code-search").is_some());
414        assert!(registry.get("code-review").is_some());
415        assert!(registry.get("explain-code").is_some());
416        assert!(registry.get("find-bugs").is_some());
417
418        // Tool documentation skills
419        assert!(registry.get("builtin-tools").is_some());
420        assert!(registry.get("delegate-task").is_some());
421        assert!(registry.get("find-skills").is_some());
422    }
423
424    #[test]
425    fn test_register_and_get() {
426        let registry = SkillRegistry::new();
427
428        let skill = Arc::new(Skill {
429            name: "test-skill".to_string(),
430            description: "A test skill".to_string(),
431            allowed_tools: None,
432            disable_model_invocation: false,
433            kind: SkillKind::Instruction,
434            content: "Test content".to_string(),
435            tags: vec![],
436            version: None,
437        });
438
439        registry.register(skill.clone()).unwrap();
440
441        assert_eq!(registry.len(), 1);
442        let retrieved = registry.get("test-skill").unwrap();
443        assert_eq!(retrieved.name, "test-skill");
444    }
445
446    #[test]
447    fn test_list() {
448        let registry = SkillRegistry::with_builtins();
449        let names = registry.list();
450
451        assert_eq!(names.len(), 9, "Expected 9 built-in skills");
452        assert!(names.contains(&"code-search".to_string()));
453        assert!(names.contains(&"code-review".to_string()));
454        assert!(names.contains(&"builtin-tools".to_string()));
455        assert!(names.contains(&"delegate-task".to_string()));
456        assert!(names.contains(&"find-skills".to_string()));
457    }
458
459    #[test]
460    fn test_remove() {
461        let registry = SkillRegistry::with_builtins();
462        assert_eq!(registry.len(), 9);
463
464        let removed = registry.remove("code-search");
465        assert!(removed.is_some());
466        assert_eq!(registry.len(), 8);
467        assert!(registry.get("code-search").is_none());
468    }
469
470    #[test]
471    fn test_clear() {
472        let registry = SkillRegistry::with_builtins();
473        assert_eq!(registry.len(), 9);
474
475        registry.clear();
476        assert_eq!(registry.len(), 0);
477        assert!(registry.is_empty());
478    }
479
480    #[test]
481    fn test_by_kind() {
482        let registry = SkillRegistry::with_builtins();
483        let instruction_skills = registry.by_kind(SkillKind::Instruction);
484
485        assert_eq!(
486            instruction_skills.len(),
487            9,
488            "Expected 9 instruction skills (6 code assistance + 3 tool documentation)"
489        );
490
491        let persona_skills = registry.by_kind(SkillKind::Persona);
492        assert_eq!(persona_skills.len(), 0);
493    }
494
495    #[test]
496    fn test_by_tag() {
497        let registry = SkillRegistry::with_builtins();
498        let search_skills = registry.by_tag("search");
499
500        assert_eq!(search_skills.len(), 2); // code-search and agentic-search
501        let names: Vec<&str> = search_skills.iter().map(|s| s.name.as_str()).collect();
502        assert!(names.contains(&"code-search"));
503        assert!(names.contains(&"agentic-search"));
504
505        let security_skills = registry.by_tag("security");
506        assert_eq!(security_skills.len(), 1);
507        assert_eq!(security_skills[0].name, "find-bugs");
508    }
509
510    #[test]
511    fn test_load_from_dir() -> anyhow::Result<()> {
512        let temp_dir = TempDir::new()?;
513
514        // Create a valid skill file
515        let skill_path = temp_dir.path().join("test-skill.md");
516        let mut file = std::fs::File::create(&skill_path)?;
517        writeln!(file, "---")?;
518        writeln!(file, "name: test-skill")?;
519        writeln!(file, "description: A test skill")?;
520        writeln!(file, "kind: instruction")?;
521        writeln!(file, "---")?;
522        writeln!(file, "# Test Skill")?;
523        writeln!(file, "This is a test skill.")?;
524        drop(file);
525
526        // Create a non-skill .md file (should be skipped)
527        let readme_path = temp_dir.path().join("README.md");
528        std::fs::write(&readme_path, "# README\nNot a skill")?;
529
530        // Create a non-.md file (should be skipped)
531        let txt_path = temp_dir.path().join("notes.txt");
532        std::fs::write(&txt_path, "Some notes")?;
533
534        let registry = SkillRegistry::new();
535        let loaded = registry.load_from_dir(temp_dir.path())?;
536
537        assert_eq!(loaded, 1);
538        assert_eq!(registry.len(), 1);
539        assert!(registry.get("test-skill").is_some());
540
541        Ok(())
542    }
543
544    #[test]
545    fn test_load_from_dir_recurses_into_nested_skill_dirs() -> anyhow::Result<()> {
546        let temp_dir = TempDir::new()?;
547        let nested = temp_dir.path().join("nested").join("code-review-helper");
548        std::fs::create_dir_all(&nested)?;
549
550        let skill_path = nested.join("SKILL.md");
551        let mut file = std::fs::File::create(&skill_path)?;
552        writeln!(file, "---")?;
553        writeln!(file, "name: nested-skill")?;
554        writeln!(file, "description: A nested skill")?;
555        writeln!(file, "kind: instruction")?;
556        writeln!(file, "---")?;
557        writeln!(file, "# Nested Skill")?;
558        writeln!(file, "This skill lives in a nested SKILL.md.")?;
559        drop(file);
560
561        let registry = SkillRegistry::new();
562        let loaded = registry.load_from_dir(temp_dir.path())?;
563
564        assert_eq!(loaded, 1);
565        assert!(registry.get("nested-skill").is_some());
566        Ok(())
567    }
568
569    #[test]
570    fn test_load_from_file() -> anyhow::Result<()> {
571        let temp_dir = TempDir::new()?;
572        let skill_path = temp_dir.path().join("my-skill.md");
573
574        let mut file = std::fs::File::create(&skill_path)?;
575        writeln!(file, "---")?;
576        writeln!(file, "name: my-skill")?;
577        writeln!(file, "description: My custom skill")?;
578        writeln!(file, "---")?;
579        writeln!(file, "# My Skill")?;
580        drop(file);
581
582        let registry = SkillRegistry::new();
583        let skill = registry.load_from_file(&skill_path)?;
584
585        assert_eq!(skill.name, "my-skill");
586        assert_eq!(registry.len(), 1);
587
588        Ok(())
589    }
590
591    #[test]
592    fn test_to_system_prompt() {
593        let registry = SkillRegistry::with_builtins();
594        let prompt = registry.to_system_prompt();
595
596        assert!(prompt.contains("# Skills"));
597        assert!(prompt.contains("search_skills"));
598        assert!(prompt.contains("Skill"));
599        assert!(!prompt.contains("code-search"));
600        assert!(!prompt.contains("code-review"));
601    }
602
603    #[test]
604    fn test_load_from_nonexistent_dir() {
605        let registry = SkillRegistry::new();
606        let result = registry.load_from_dir("/nonexistent/path");
607
608        assert!(result.is_ok());
609        assert_eq!(result.unwrap(), 0);
610    }
611
612    #[test]
613    fn test_load_from_dir_rejects_file_path() -> anyhow::Result<()> {
614        let temp_dir = TempDir::new()?;
615        let path = temp_dir.path().join("not-a-directory.md");
616        std::fs::write(&path, "# not a directory")?;
617
618        let registry = SkillRegistry::new();
619        let err = registry.load_from_dir(&path).unwrap_err();
620        assert!(err.to_string().contains("Path is not a directory"));
621        Ok(())
622    }
623
624    #[test]
625    fn test_load_from_dir_duplicate_name_overrides_previous_definition() -> anyhow::Result<()> {
626        let temp_dir = TempDir::new()?;
627
628        let first = temp_dir.path().join("first.md");
629        std::fs::write(
630            &first,
631            "---\nname: duplicate-skill\ndescription: First copy\n---\n# First\nalpha\n",
632        )?;
633
634        let nested = temp_dir.path().join("nested");
635        std::fs::create_dir_all(&nested)?;
636        let second = nested.join("SKILL.md");
637        std::fs::write(
638            &second,
639            "---\nname: duplicate-skill\ndescription: Second copy\n---\n# Second\nbeta\n",
640        )?;
641
642        let registry = SkillRegistry::new();
643        let loaded = registry.load_from_dir(temp_dir.path())?;
644
645        assert_eq!(loaded, 2);
646        assert_eq!(registry.len(), 1);
647        assert_eq!(
648            registry.get("duplicate-skill").unwrap().description,
649            "Second copy"
650        );
651        Ok(())
652    }
653
654    // --- Validator integration ---
655
656    #[test]
657    fn test_register_with_validator_rejects_reserved() {
658        use crate::skills::validator::DefaultSkillValidator;
659
660        let registry = SkillRegistry::new();
661        registry.set_validator(Arc::new(DefaultSkillValidator::default()));
662
663        let skill = Arc::new(Skill {
664            name: "code-search".to_string(), // reserved
665            description: "Override builtin".to_string(),
666            allowed_tools: None,
667            disable_model_invocation: false,
668            kind: SkillKind::Instruction,
669            content: "Malicious override".to_string(),
670            tags: vec![],
671            version: None,
672        });
673
674        let result = registry.register(skill);
675        assert!(result.is_err());
676        assert_eq!(registry.len(), 0);
677    }
678
679    #[test]
680    fn test_register_with_validator_accepts_valid() {
681        use crate::skills::validator::DefaultSkillValidator;
682
683        let registry = SkillRegistry::new();
684        registry.set_validator(Arc::new(DefaultSkillValidator::default()));
685
686        let skill = Arc::new(Skill {
687            name: "my-custom-skill".to_string(),
688            description: "A valid skill".to_string(),
689            allowed_tools: Some("read(*), grep(*)".to_string()),
690            disable_model_invocation: false,
691            kind: SkillKind::Instruction,
692            content: "Help with code review.".to_string(),
693            tags: vec![],
694            version: None,
695        });
696
697        assert!(registry.register(skill).is_ok());
698        assert_eq!(registry.len(), 1);
699    }
700
701    #[test]
702    fn test_register_without_validator_accepts_anything() {
703        let registry = SkillRegistry::new();
704        // No validator set
705
706        let skill = Arc::new(Skill {
707            name: "code-search".to_string(), // reserved name, but no validator
708            description: "test".to_string(),
709            allowed_tools: None,
710            disable_model_invocation: false,
711            kind: SkillKind::Instruction,
712            content: "test".to_string(),
713            tags: vec![],
714            version: None,
715        });
716
717        assert!(registry.register(skill).is_ok());
718    }
719
720    #[test]
721    fn test_all_personas_and_scorer_accessor() {
722        let registry = SkillRegistry::new();
723        let scorer = Arc::new(DefaultSkillScorer::default());
724        registry.set_scorer(scorer.clone());
725
726        registry.register_unchecked(Arc::new(Skill {
727            name: "persona-skill".to_string(),
728            description: "Persona".to_string(),
729            allowed_tools: None,
730            disable_model_invocation: false,
731            kind: SkillKind::Persona,
732            content: "Persona content".to_string(),
733            tags: vec!["voice".to_string()],
734            version: None,
735        }));
736        registry.register_unchecked(Arc::new(Skill {
737            name: "instruction-skill".to_string(),
738            description: "Instruction".to_string(),
739            allowed_tools: None,
740            disable_model_invocation: false,
741            kind: SkillKind::Instruction,
742            content: "Instruction content".to_string(),
743            tags: vec!["workflow".to_string()],
744            version: None,
745        }));
746
747        assert_eq!(registry.all().len(), 2);
748        assert_eq!(registry.personas().len(), 1);
749        assert_eq!(registry.personas()[0].name, "persona-skill");
750        assert!(registry.scorer().is_some());
751    }
752
753    #[test]
754    fn test_load_from_file_with_validator_rejects() {
755        use crate::skills::validator::DefaultSkillValidator;
756
757        let temp_dir = TempDir::new().unwrap();
758        let skill_path = temp_dir.path().join("code-search.md");
759
760        let mut file = std::fs::File::create(&skill_path).unwrap();
761        writeln!(file, "---").unwrap();
762        writeln!(file, "name: code-search").unwrap(); // reserved
763        writeln!(file, "description: Override").unwrap();
764        writeln!(file, "---").unwrap();
765        writeln!(file, "# Override").unwrap();
766        drop(file);
767
768        let registry = SkillRegistry::new();
769        registry.set_validator(Arc::new(DefaultSkillValidator::default()));
770
771        let result = registry.load_from_file(&skill_path);
772        assert!(result.is_err());
773        assert_eq!(registry.len(), 0);
774    }
775
776    // --- Scorer integration ---
777
778    #[test]
779    fn test_to_system_prompt_skips_disabled_skills() {
780        let registry = SkillRegistry::new();
781        let scorer = Arc::new(DefaultSkillScorer::default());
782        registry.set_scorer(scorer.clone());
783
784        // Register two skills (unchecked to bypass validator)
785        registry.register_unchecked(Arc::new(Skill {
786            name: "good-skill".to_string(),
787            description: "Good".to_string(),
788            allowed_tools: None,
789            disable_model_invocation: false,
790            kind: SkillKind::Instruction,
791            content: "Good instructions".to_string(),
792            tags: vec![],
793            version: None,
794        }));
795        registry.register_unchecked(Arc::new(Skill {
796            name: "bad-skill".to_string(),
797            description: "Bad".to_string(),
798            allowed_tools: None,
799            disable_model_invocation: false,
800            kind: SkillKind::Instruction,
801            content: "Bad instructions".to_string(),
802            tags: vec![],
803            version: None,
804        }));
805
806        // Give bad-skill enough negative feedback to disable it
807        for _ in 0..5 {
808            scorer.record(SkillFeedback {
809                skill_name: "bad-skill".to_string(),
810                outcome: SkillOutcome::Failure,
811                score_delta: -1.0,
812                reason: "Did not help".to_string(),
813                timestamp: 0,
814            });
815        }
816
817        let prompt = registry.to_system_prompt();
818        assert!(prompt.contains("search_skills"));
819        assert!(!prompt.contains("bad-skill"));
820    }
821
822    #[test]
823    fn test_fork_is_independent() {
824        let original = SkillRegistry::with_builtins();
825        let fork = original.fork();
826
827        // Fork has same skills as original
828        assert_eq!(fork.len(), original.len());
829
830        // Adding to fork does not affect original
831        fork.register_unchecked(Arc::new(Skill {
832            name: "session-only".to_string(),
833            description: "Only in fork".to_string(),
834            allowed_tools: None,
835            disable_model_invocation: false,
836            kind: SkillKind::Instruction,
837            content: "content".to_string(),
838            tags: vec![],
839            version: None,
840        }));
841
842        assert_eq!(fork.len(), original.len() + 1);
843        assert!(fork.get("session-only").is_some());
844        assert!(original.get("session-only").is_none());
845    }
846
847    #[test]
848    fn test_fork_inherits_builtins() {
849        let fork = SkillRegistry::with_builtins().fork();
850        assert!(fork.get("code-search").is_some());
851        assert!(fork.get("code-review").is_some());
852        assert!(fork.get("find-bugs").is_some());
853    }
854
855    #[test]
856    fn test_fork_preserves_validator() {
857        use crate::skills::validator::DefaultSkillValidator;
858
859        let original = SkillRegistry::new();
860        original.set_validator(Arc::new(DefaultSkillValidator::default()));
861
862        let fork = original.fork();
863        let invalid = Arc::new(Skill {
864            name: "BadName".to_string(),
865            description: "invalid".to_string(),
866            allowed_tools: None,
867            disable_model_invocation: false,
868            kind: SkillKind::Instruction,
869            content: "content".to_string(),
870            tags: vec![],
871            version: None,
872        });
873
874        assert!(fork.register(invalid).is_err());
875    }
876
877    #[test]
878    fn test_fork_preserves_scorer() {
879        let original = SkillRegistry::new();
880        let scorer = Arc::new(DefaultSkillScorer::default());
881        original.set_scorer(scorer.clone());
882        original.register_unchecked(Arc::new(Skill {
883            name: "disabled-skill".to_string(),
884            description: "disabled".to_string(),
885            allowed_tools: None,
886            disable_model_invocation: false,
887            kind: SkillKind::Instruction,
888            content: "content".to_string(),
889            tags: vec![],
890            version: None,
891        }));
892
893        for _ in 0..5 {
894            scorer.record(SkillFeedback {
895                skill_name: "disabled-skill".to_string(),
896                outcome: SkillOutcome::Failure,
897                score_delta: -1.0,
898                reason: "bad".to_string(),
899                timestamp: 0,
900            });
901        }
902
903        let fork = original.fork();
904        let prompt = fork.to_system_prompt();
905        assert!(!prompt.contains("disabled-skill"));
906    }
907
908    #[test]
909    fn test_search_skills_ranks_matches_and_skips_disabled() {
910        let registry = SkillRegistry::new();
911        let scorer = Arc::new(DefaultSkillScorer::default());
912        registry.set_scorer(scorer.clone());
913
914        registry.register_unchecked(Arc::new(Skill {
915            name: "build-planner".to_string(),
916            description: "Plan complex builds".to_string(),
917            allowed_tools: None,
918            disable_model_invocation: false,
919            kind: SkillKind::Instruction,
920            content: "Planner instructions".to_string(),
921            tags: vec!["architecture".to_string()],
922            version: None,
923        }));
924        registry.register_unchecked(Arc::new(Skill {
925            name: "silent-helper".to_string(),
926            description: "Troubleshoot quietly".to_string(),
927            allowed_tools: None,
928            disable_model_invocation: false,
929            kind: SkillKind::Instruction,
930            content: "Hidden instructions".to_string(),
931            tags: vec!["debug".to_string()],
932            version: None,
933        }));
934
935        for _ in 0..5 {
936            scorer.record(SkillFeedback {
937                skill_name: "silent-helper".to_string(),
938                outcome: SkillOutcome::Failure,
939                score_delta: -1.0,
940                reason: "disabled".to_string(),
941                timestamp: 0,
942            });
943        }
944
945        let matches = registry.search("architecture plan", 5);
946        assert_eq!(matches.len(), 1);
947        assert_eq!(matches[0].name, "build-planner");
948
949        let disabled = registry.search("debug silent-helper", 5);
950        assert!(disabled.is_empty());
951    }
952
953    #[test]
954    fn test_match_skills_matches_name_tag_and_description_and_skips_disabled() {
955        let registry = SkillRegistry::new();
956        let scorer = Arc::new(DefaultSkillScorer::default());
957        registry.set_scorer(scorer.clone());
958
959        registry.register_unchecked(Arc::new(Skill {
960            name: "build-planner".to_string(),
961            description: "Plan complex builds".to_string(),
962            allowed_tools: None,
963            disable_model_invocation: false,
964            kind: SkillKind::Instruction,
965            content: "Planner instructions".to_string(),
966            tags: vec!["architecture".to_string()],
967            version: None,
968        }));
969        registry.register_unchecked(Arc::new(Skill {
970            name: "silent-helper".to_string(),
971            description: "Troubleshoot quietly".to_string(),
972            allowed_tools: None,
973            disable_model_invocation: false,
974            kind: SkillKind::Instruction,
975            content: "Hidden instructions".to_string(),
976            tags: vec!["debug".to_string()],
977            version: None,
978        }));
979
980        for _ in 0..5 {
981            scorer.record(SkillFeedback {
982                skill_name: "silent-helper".to_string(),
983                outcome: SkillOutcome::Failure,
984                score_delta: -1.0,
985                reason: "disabled".to_string(),
986                timestamp: 0,
987            });
988        }
989
990        let by_name = registry.match_skills("please use build-planner for this task");
991        assert!(by_name.contains("Planner instructions"));
992
993        let by_tag = registry.match_skills("need architecture guidance");
994        assert!(by_tag.contains("Planner instructions"));
995
996        let by_description = registry.match_skills("help me plan the release");
997        assert!(by_description.contains("Planner instructions"));
998
999        let disabled = registry.match_skills("need debug help from silent-helper");
1000        assert!(!disabled.contains("Hidden instructions"));
1001
1002        assert!(registry
1003            .match_skills("totally unrelated request")
1004            .is_empty());
1005    }
1006}