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