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    /// Generate system prompt content from all instruction skills
260    ///
261    /// Concatenates the content of all instruction-type skills for injection
262    /// into the system prompt. Skills disabled by the scorer are excluded.
263    /// Persona-kind skills are excluded — they are bound per-session, not globally.
264    /// Generate the system prompt fragment for this registry.
265    ///
266    /// Only emits a skill directory (name + description) — NOT the full skill content.
267    /// Full content is injected on-demand via `match_skills` when a user request matches.
268    pub fn to_system_prompt(&self) -> String {
269        let skills = self.skills.read().unwrap();
270        let scorer = self.scorer.read().unwrap();
271
272        let instruction_skills: Vec<_> = skills
273            .values()
274            .filter(|s| {
275                // Include both Instruction and Tool kinds in system prompt
276                s.kind == super::SkillKind::Instruction || s.kind == super::SkillKind::Tool
277            })
278            .filter(|s| match scorer.as_ref() {
279                Some(sc) => !sc.should_disable(&s.name),
280                None => true,
281            })
282            .collect();
283
284        if instruction_skills.is_empty() {
285            return String::new();
286        }
287
288        let mut prompt = String::from(crate::prompts::SKILLS_CATALOG_HEADER);
289        prompt.push_str("\n\n");
290        for skill in &instruction_skills {
291            prompt.push_str(&format!("- **{}**: {}\n", skill.name, skill.description));
292        }
293        prompt
294    }
295
296    /// Return the full content of skills relevant to the given user input.
297    ///
298    /// Matches by checking if any skill name or tag appears in the input (case-insensitive).
299    /// Returns an empty string if no skills match — caller should not inject anything.
300    pub fn match_skills(&self, user_input: &str) -> String {
301        let skills = self.skills.read().unwrap();
302        let scorer = self.scorer.read().unwrap();
303        let input_lower = user_input.to_lowercase();
304
305        let matched: Vec<_> = skills
306            .values()
307            .filter(|s| {
308                // Include both Instruction and Tool kinds in matching
309                s.kind == super::SkillKind::Instruction || s.kind == super::SkillKind::Tool
310            })
311            .filter(|s| match scorer.as_ref() {
312                Some(sc) => !sc.should_disable(&s.name),
313                None => true,
314            })
315            .filter(|s| {
316                // Match by skill name or any tag appearing in the user input
317                input_lower.contains(&s.name.to_lowercase())
318                    || s.tags
319                        .iter()
320                        .any(|t| input_lower.contains(&t.to_lowercase()))
321                    || input_lower.contains(
322                        s.description
323                            .to_lowercase()
324                            .split_whitespace()
325                            .next()
326                            .unwrap_or(""),
327                    )
328            })
329            .collect();
330
331        if matched.is_empty() {
332            return String::new();
333        }
334
335        let mut out = String::from("# Skill Instructions\n\n");
336        for skill in matched {
337            out.push_str(&skill.to_system_prompt());
338            out.push_str("\n\n---\n\n");
339        }
340        out
341    }
342}
343
344impl Default for SkillRegistry {
345    fn default() -> Self {
346        Self::new()
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use crate::skills::feedback::{DefaultSkillScorer, SkillFeedback, SkillOutcome};
354    use crate::skills::SkillKind;
355    use std::io::Write;
356    use tempfile::TempDir;
357
358    #[test]
359    fn test_new_registry() {
360        let registry = SkillRegistry::new();
361        assert_eq!(registry.len(), 0);
362        assert!(registry.is_empty());
363    }
364
365    #[test]
366    fn test_with_builtins() {
367        let registry = SkillRegistry::with_builtins();
368        assert_eq!(registry.len(), 8, "Expected 8 built-in skills");
369        assert!(!registry.is_empty());
370
371        // Code assistance skills
372        assert!(registry.get("agentic-search").is_some());
373        assert!(registry.get("code-search").is_some());
374        assert!(registry.get("code-review").is_some());
375        assert!(registry.get("explain-code").is_some());
376        assert!(registry.get("find-bugs").is_some());
377
378        // Tool documentation skills
379        assert!(registry.get("builtin-tools").is_some());
380        assert!(registry.get("delegate-task").is_some());
381        assert!(registry.get("find-skills").is_some());
382    }
383
384    #[test]
385    fn test_register_and_get() {
386        let registry = SkillRegistry::new();
387
388        let skill = Arc::new(Skill {
389            name: "test-skill".to_string(),
390            description: "A test skill".to_string(),
391            allowed_tools: None,
392            disable_model_invocation: false,
393            kind: SkillKind::Instruction,
394            content: "Test content".to_string(),
395            tags: vec![],
396            version: None,
397        });
398
399        registry.register(skill.clone()).unwrap();
400
401        assert_eq!(registry.len(), 1);
402        let retrieved = registry.get("test-skill").unwrap();
403        assert_eq!(retrieved.name, "test-skill");
404    }
405
406    #[test]
407    fn test_list() {
408        let registry = SkillRegistry::with_builtins();
409        let names = registry.list();
410
411        assert_eq!(names.len(), 8, "Expected 8 built-in skills");
412        assert!(names.contains(&"code-search".to_string()));
413        assert!(names.contains(&"code-review".to_string()));
414        assert!(names.contains(&"builtin-tools".to_string()));
415        assert!(names.contains(&"delegate-task".to_string()));
416        assert!(names.contains(&"find-skills".to_string()));
417    }
418
419    #[test]
420    fn test_remove() {
421        let registry = SkillRegistry::with_builtins();
422        assert_eq!(registry.len(), 8);
423
424        let removed = registry.remove("code-search");
425        assert!(removed.is_some());
426        assert_eq!(registry.len(), 7);
427        assert!(registry.get("code-search").is_none());
428    }
429
430    #[test]
431    fn test_clear() {
432        let registry = SkillRegistry::with_builtins();
433        assert_eq!(registry.len(), 8);
434
435        registry.clear();
436        assert_eq!(registry.len(), 0);
437        assert!(registry.is_empty());
438    }
439
440    #[test]
441    fn test_by_kind() {
442        let registry = SkillRegistry::with_builtins();
443        let instruction_skills = registry.by_kind(SkillKind::Instruction);
444
445        assert_eq!(
446            instruction_skills.len(),
447            8,
448            "Expected 8 instruction skills (5 code assistance + 3 tool documentation)"
449        );
450
451        let persona_skills = registry.by_kind(SkillKind::Persona);
452        assert_eq!(persona_skills.len(), 0);
453    }
454
455    #[test]
456    fn test_by_tag() {
457        let registry = SkillRegistry::with_builtins();
458        let search_skills = registry.by_tag("search");
459
460        assert_eq!(search_skills.len(), 2); // code-search and agentic-search
461        let names: Vec<&str> = search_skills.iter().map(|s| s.name.as_str()).collect();
462        assert!(names.contains(&"code-search"));
463        assert!(names.contains(&"agentic-search"));
464
465        let security_skills = registry.by_tag("security");
466        assert_eq!(security_skills.len(), 1);
467        assert_eq!(security_skills[0].name, "find-bugs");
468    }
469
470    #[test]
471    fn test_load_from_dir() -> anyhow::Result<()> {
472        let temp_dir = TempDir::new()?;
473
474        // Create a valid skill file
475        let skill_path = temp_dir.path().join("test-skill.md");
476        let mut file = std::fs::File::create(&skill_path)?;
477        writeln!(file, "---")?;
478        writeln!(file, "name: test-skill")?;
479        writeln!(file, "description: A test skill")?;
480        writeln!(file, "kind: instruction")?;
481        writeln!(file, "---")?;
482        writeln!(file, "# Test Skill")?;
483        writeln!(file, "This is a test skill.")?;
484        drop(file);
485
486        // Create a non-skill .md file (should be skipped)
487        let readme_path = temp_dir.path().join("README.md");
488        std::fs::write(&readme_path, "# README\nNot a skill")?;
489
490        // Create a non-.md file (should be skipped)
491        let txt_path = temp_dir.path().join("notes.txt");
492        std::fs::write(&txt_path, "Some notes")?;
493
494        let registry = SkillRegistry::new();
495        let loaded = registry.load_from_dir(temp_dir.path())?;
496
497        assert_eq!(loaded, 1);
498        assert_eq!(registry.len(), 1);
499        assert!(registry.get("test-skill").is_some());
500
501        Ok(())
502    }
503
504    #[test]
505    fn test_load_from_dir_recurses_into_nested_skill_dirs() -> anyhow::Result<()> {
506        let temp_dir = TempDir::new()?;
507        let nested = temp_dir.path().join("nested").join("code-review-helper");
508        std::fs::create_dir_all(&nested)?;
509
510        let skill_path = nested.join("SKILL.md");
511        let mut file = std::fs::File::create(&skill_path)?;
512        writeln!(file, "---")?;
513        writeln!(file, "name: nested-skill")?;
514        writeln!(file, "description: A nested skill")?;
515        writeln!(file, "kind: instruction")?;
516        writeln!(file, "---")?;
517        writeln!(file, "# Nested Skill")?;
518        writeln!(file, "This skill lives in a nested SKILL.md.")?;
519        drop(file);
520
521        let registry = SkillRegistry::new();
522        let loaded = registry.load_from_dir(temp_dir.path())?;
523
524        assert_eq!(loaded, 1);
525        assert!(registry.get("nested-skill").is_some());
526        Ok(())
527    }
528
529    #[test]
530    fn test_load_from_file() -> anyhow::Result<()> {
531        let temp_dir = TempDir::new()?;
532        let skill_path = temp_dir.path().join("my-skill.md");
533
534        let mut file = std::fs::File::create(&skill_path)?;
535        writeln!(file, "---")?;
536        writeln!(file, "name: my-skill")?;
537        writeln!(file, "description: My custom skill")?;
538        writeln!(file, "---")?;
539        writeln!(file, "# My Skill")?;
540        drop(file);
541
542        let registry = SkillRegistry::new();
543        let skill = registry.load_from_file(&skill_path)?;
544
545        assert_eq!(skill.name, "my-skill");
546        assert_eq!(registry.len(), 1);
547
548        Ok(())
549    }
550
551    #[test]
552    fn test_to_system_prompt() {
553        let registry = SkillRegistry::with_builtins();
554        let prompt = registry.to_system_prompt();
555
556        assert!(prompt.contains("# Available Skills"));
557        assert!(prompt.contains("code-search"));
558        assert!(prompt.contains("code-review"));
559        assert!(prompt.contains("explain-code"));
560        assert!(prompt.contains("find-bugs"));
561    }
562
563    #[test]
564    fn test_load_from_nonexistent_dir() {
565        let registry = SkillRegistry::new();
566        let result = registry.load_from_dir("/nonexistent/path");
567
568        assert!(result.is_ok());
569        assert_eq!(result.unwrap(), 0);
570    }
571
572    #[test]
573    fn test_load_from_dir_rejects_file_path() -> anyhow::Result<()> {
574        let temp_dir = TempDir::new()?;
575        let path = temp_dir.path().join("not-a-directory.md");
576        std::fs::write(&path, "# not a directory")?;
577
578        let registry = SkillRegistry::new();
579        let err = registry.load_from_dir(&path).unwrap_err();
580        assert!(err.to_string().contains("Path is not a directory"));
581        Ok(())
582    }
583
584    #[test]
585    fn test_load_from_dir_duplicate_name_overrides_previous_definition() -> anyhow::Result<()> {
586        let temp_dir = TempDir::new()?;
587
588        let first = temp_dir.path().join("first.md");
589        std::fs::write(
590            &first,
591            "---\nname: duplicate-skill\ndescription: First copy\n---\n# First\nalpha\n",
592        )?;
593
594        let nested = temp_dir.path().join("nested");
595        std::fs::create_dir_all(&nested)?;
596        let second = nested.join("SKILL.md");
597        std::fs::write(
598            &second,
599            "---\nname: duplicate-skill\ndescription: Second copy\n---\n# Second\nbeta\n",
600        )?;
601
602        let registry = SkillRegistry::new();
603        let loaded = registry.load_from_dir(temp_dir.path())?;
604
605        assert_eq!(loaded, 2);
606        assert_eq!(registry.len(), 1);
607        assert_eq!(
608            registry.get("duplicate-skill").unwrap().description,
609            "Second copy"
610        );
611        Ok(())
612    }
613
614    // --- Validator integration ---
615
616    #[test]
617    fn test_register_with_validator_rejects_reserved() {
618        use crate::skills::validator::DefaultSkillValidator;
619
620        let registry = SkillRegistry::new();
621        registry.set_validator(Arc::new(DefaultSkillValidator::default()));
622
623        let skill = Arc::new(Skill {
624            name: "code-search".to_string(), // reserved
625            description: "Override builtin".to_string(),
626            allowed_tools: None,
627            disable_model_invocation: false,
628            kind: SkillKind::Instruction,
629            content: "Malicious override".to_string(),
630            tags: vec![],
631            version: None,
632        });
633
634        let result = registry.register(skill);
635        assert!(result.is_err());
636        assert_eq!(registry.len(), 0);
637    }
638
639    #[test]
640    fn test_register_with_validator_accepts_valid() {
641        use crate::skills::validator::DefaultSkillValidator;
642
643        let registry = SkillRegistry::new();
644        registry.set_validator(Arc::new(DefaultSkillValidator::default()));
645
646        let skill = Arc::new(Skill {
647            name: "my-custom-skill".to_string(),
648            description: "A valid skill".to_string(),
649            allowed_tools: Some("read(*), grep(*)".to_string()),
650            disable_model_invocation: false,
651            kind: SkillKind::Instruction,
652            content: "Help with code review.".to_string(),
653            tags: vec![],
654            version: None,
655        });
656
657        assert!(registry.register(skill).is_ok());
658        assert_eq!(registry.len(), 1);
659    }
660
661    #[test]
662    fn test_register_without_validator_accepts_anything() {
663        let registry = SkillRegistry::new();
664        // No validator set
665
666        let skill = Arc::new(Skill {
667            name: "code-search".to_string(), // reserved name, but no validator
668            description: "test".to_string(),
669            allowed_tools: None,
670            disable_model_invocation: false,
671            kind: SkillKind::Instruction,
672            content: "test".to_string(),
673            tags: vec![],
674            version: None,
675        });
676
677        assert!(registry.register(skill).is_ok());
678    }
679
680    #[test]
681    fn test_all_personas_and_scorer_accessor() {
682        let registry = SkillRegistry::new();
683        let scorer = Arc::new(DefaultSkillScorer::default());
684        registry.set_scorer(scorer.clone());
685
686        registry.register_unchecked(Arc::new(Skill {
687            name: "persona-skill".to_string(),
688            description: "Persona".to_string(),
689            allowed_tools: None,
690            disable_model_invocation: false,
691            kind: SkillKind::Persona,
692            content: "Persona content".to_string(),
693            tags: vec!["voice".to_string()],
694            version: None,
695        }));
696        registry.register_unchecked(Arc::new(Skill {
697            name: "instruction-skill".to_string(),
698            description: "Instruction".to_string(),
699            allowed_tools: None,
700            disable_model_invocation: false,
701            kind: SkillKind::Instruction,
702            content: "Instruction content".to_string(),
703            tags: vec!["workflow".to_string()],
704            version: None,
705        }));
706
707        assert_eq!(registry.all().len(), 2);
708        assert_eq!(registry.personas().len(), 1);
709        assert_eq!(registry.personas()[0].name, "persona-skill");
710        assert!(registry.scorer().is_some());
711    }
712
713    #[test]
714    fn test_load_from_file_with_validator_rejects() {
715        use crate::skills::validator::DefaultSkillValidator;
716
717        let temp_dir = TempDir::new().unwrap();
718        let skill_path = temp_dir.path().join("code-search.md");
719
720        let mut file = std::fs::File::create(&skill_path).unwrap();
721        writeln!(file, "---").unwrap();
722        writeln!(file, "name: code-search").unwrap(); // reserved
723        writeln!(file, "description: Override").unwrap();
724        writeln!(file, "---").unwrap();
725        writeln!(file, "# Override").unwrap();
726        drop(file);
727
728        let registry = SkillRegistry::new();
729        registry.set_validator(Arc::new(DefaultSkillValidator::default()));
730
731        let result = registry.load_from_file(&skill_path);
732        assert!(result.is_err());
733        assert_eq!(registry.len(), 0);
734    }
735
736    // --- Scorer integration ---
737
738    #[test]
739    fn test_to_system_prompt_skips_disabled_skills() {
740        let registry = SkillRegistry::new();
741        let scorer = Arc::new(DefaultSkillScorer::default());
742        registry.set_scorer(scorer.clone());
743
744        // Register two skills (unchecked to bypass validator)
745        registry.register_unchecked(Arc::new(Skill {
746            name: "good-skill".to_string(),
747            description: "Good".to_string(),
748            allowed_tools: None,
749            disable_model_invocation: false,
750            kind: SkillKind::Instruction,
751            content: "Good instructions".to_string(),
752            tags: vec![],
753            version: None,
754        }));
755        registry.register_unchecked(Arc::new(Skill {
756            name: "bad-skill".to_string(),
757            description: "Bad".to_string(),
758            allowed_tools: None,
759            disable_model_invocation: false,
760            kind: SkillKind::Instruction,
761            content: "Bad instructions".to_string(),
762            tags: vec![],
763            version: None,
764        }));
765
766        // Give bad-skill enough negative feedback to disable it
767        for _ in 0..5 {
768            scorer.record(SkillFeedback {
769                skill_name: "bad-skill".to_string(),
770                outcome: SkillOutcome::Failure,
771                score_delta: -1.0,
772                reason: "Did not help".to_string(),
773                timestamp: 0,
774            });
775        }
776
777        let prompt = registry.to_system_prompt();
778        assert!(prompt.contains("good-skill"));
779        assert!(!prompt.contains("bad-skill"));
780    }
781
782    #[test]
783    fn test_fork_is_independent() {
784        let original = SkillRegistry::with_builtins();
785        let fork = original.fork();
786
787        // Fork has same skills as original
788        assert_eq!(fork.len(), original.len());
789
790        // Adding to fork does not affect original
791        fork.register_unchecked(Arc::new(Skill {
792            name: "session-only".to_string(),
793            description: "Only in fork".to_string(),
794            allowed_tools: None,
795            disable_model_invocation: false,
796            kind: SkillKind::Instruction,
797            content: "content".to_string(),
798            tags: vec![],
799            version: None,
800        }));
801
802        assert_eq!(fork.len(), original.len() + 1);
803        assert!(fork.get("session-only").is_some());
804        assert!(original.get("session-only").is_none());
805    }
806
807    #[test]
808    fn test_fork_inherits_builtins() {
809        let fork = SkillRegistry::with_builtins().fork();
810        assert!(fork.get("code-search").is_some());
811        assert!(fork.get("code-review").is_some());
812        assert!(fork.get("find-bugs").is_some());
813    }
814
815    #[test]
816    fn test_fork_preserves_validator() {
817        use crate::skills::validator::DefaultSkillValidator;
818
819        let original = SkillRegistry::new();
820        original.set_validator(Arc::new(DefaultSkillValidator::default()));
821
822        let fork = original.fork();
823        let invalid = Arc::new(Skill {
824            name: "BadName".to_string(),
825            description: "invalid".to_string(),
826            allowed_tools: None,
827            disable_model_invocation: false,
828            kind: SkillKind::Instruction,
829            content: "content".to_string(),
830            tags: vec![],
831            version: None,
832        });
833
834        assert!(fork.register(invalid).is_err());
835    }
836
837    #[test]
838    fn test_fork_preserves_scorer() {
839        let original = SkillRegistry::new();
840        let scorer = Arc::new(DefaultSkillScorer::default());
841        original.set_scorer(scorer.clone());
842        original.register_unchecked(Arc::new(Skill {
843            name: "disabled-skill".to_string(),
844            description: "disabled".to_string(),
845            allowed_tools: None,
846            disable_model_invocation: false,
847            kind: SkillKind::Instruction,
848            content: "content".to_string(),
849            tags: vec![],
850            version: None,
851        }));
852
853        for _ in 0..5 {
854            scorer.record(SkillFeedback {
855                skill_name: "disabled-skill".to_string(),
856                outcome: SkillOutcome::Failure,
857                score_delta: -1.0,
858                reason: "bad".to_string(),
859                timestamp: 0,
860            });
861        }
862
863        let fork = original.fork();
864        let prompt = fork.to_system_prompt();
865        assert!(!prompt.contains("disabled-skill"));
866    }
867
868    #[test]
869    fn test_match_skills_matches_name_tag_and_description_and_skips_disabled() {
870        let registry = SkillRegistry::new();
871        let scorer = Arc::new(DefaultSkillScorer::default());
872        registry.set_scorer(scorer.clone());
873
874        registry.register_unchecked(Arc::new(Skill {
875            name: "build-planner".to_string(),
876            description: "Plan complex builds".to_string(),
877            allowed_tools: None,
878            disable_model_invocation: false,
879            kind: SkillKind::Instruction,
880            content: "Planner instructions".to_string(),
881            tags: vec!["architecture".to_string()],
882            version: None,
883        }));
884        registry.register_unchecked(Arc::new(Skill {
885            name: "silent-helper".to_string(),
886            description: "Troubleshoot quietly".to_string(),
887            allowed_tools: None,
888            disable_model_invocation: false,
889            kind: SkillKind::Instruction,
890            content: "Hidden instructions".to_string(),
891            tags: vec!["debug".to_string()],
892            version: None,
893        }));
894
895        for _ in 0..5 {
896            scorer.record(SkillFeedback {
897                skill_name: "silent-helper".to_string(),
898                outcome: SkillOutcome::Failure,
899                score_delta: -1.0,
900                reason: "disabled".to_string(),
901                timestamp: 0,
902            });
903        }
904
905        let by_name = registry.match_skills("please use build-planner for this task");
906        assert!(by_name.contains("Planner instructions"));
907
908        let by_tag = registry.match_skills("need architecture guidance");
909        assert!(by_tag.contains("Planner instructions"));
910
911        let by_description = registry.match_skills("help me plan the release");
912        assert!(by_description.contains("Planner instructions"));
913
914        let disabled = registry.match_skills("need debug help from silent-helper");
915        assert!(!disabled.contains("Hidden instructions"));
916
917        assert!(registry
918            .match_skills("totally unrelated request")
919            .is_empty());
920    }
921}