Skip to main content

sparrow/capabilities/
mod.rs

1use serde::{Deserialize, Serialize};
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use crate::memory::Memory;
6
7pub mod mcp;
8pub mod plugin;
9
10// ─── Skill ──────────────────────────────────────────────────────────────────────
11
12/// A reusable capability: a SKILL.md file loaded into agent context when relevant.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Skill {
15    pub name: String,
16    pub description: String,
17    /// Keywords used to match this skill against a task context
18    pub trigger: Vec<String>,
19    /// The body/content loaded into the agent's system prompt or context
20    pub body: String,
21    /// Source file path (relative to skills dir)
22    #[serde(default)]
23    pub source_file: String,
24    /// How many times this skill was used/loaded
25    #[serde(default)]
26    pub usage_count: u32,
27    /// Creation timestamp
28    #[serde(default)]
29    pub created_at: String,
30    /// Quality score (0.0–1.0), used by curator for pruning
31    #[serde(default = "default_score")]
32    pub score: f64,
33    /// Whether this skill was auto-generated (by Curator) or user-created
34    #[serde(default)]
35    pub auto_generated: bool,
36    /// Optional references loaded only when the skill is explicitly invoked.
37    #[serde(default)]
38    pub references: Vec<String>,
39    /// Optional templates loaded only when explicitly requested.
40    #[serde(default)]
41    pub templates: Vec<String>,
42    /// Optional scripts advertised by the skill.
43    #[serde(default)]
44    pub scripts: Vec<String>,
45    /// Optional assets advertised by the skill.
46    #[serde(default)]
47    pub assets: Vec<String>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct SkillInvocation {
52    pub skill: Skill,
53    pub loaded_references: Vec<(String, String)>,
54    pub loaded_templates: Vec<(String, String)>,
55    pub loaded_scripts: Vec<(String, String)>,
56    pub loaded_assets: Vec<(String, String)>,
57}
58
59fn default_score() -> f64 {
60    0.5
61}
62
63impl Skill {
64    /// Parse a SKILL.md file into a Skill struct.
65    /// Format:
66    /// ```markdown
67    /// # Skill: <name>
68    ///
69    /// **Trigger:** comma, separated, keywords
70    ///
71    /// ## Body
72    /// <content>
73    /// ```
74    pub fn from_markdown(content: &str, source_file: &str) -> Option<Self> {
75        let mut name = String::new();
76        let mut description = String::new();
77        let mut trigger = Vec::new();
78        let mut body = String::new();
79        let mut references = Vec::new();
80        let mut templates = Vec::new();
81        let mut scripts = Vec::new();
82        let mut assets = Vec::new();
83        let mut in_body = false;
84
85        for line in content.lines() {
86            let trimmed = line.trim();
87
88            if trimmed.starts_with("# Skill:") || trimmed.starts_with("# ") {
89                name = trimmed
90                    .trim_start_matches("# Skill:")
91                    .trim_start_matches("# ")
92                    .trim()
93                    .to_string();
94                continue;
95            }
96
97            if trimmed.starts_with("**Trigger:**") || trimmed.starts_with("**Triggers:**") {
98                let trig_str = trimmed
99                    .trim_start_matches("**Trigger:**")
100                    .trim_start_matches("**Triggers:**")
101                    .trim();
102                trigger = trig_str
103                    .split(',')
104                    .map(|s| s.trim().to_lowercase())
105                    .filter(|s| !s.is_empty())
106                    .collect();
107                continue;
108            }
109
110            if trimmed.starts_with("**Description:**") {
111                description = trimmed
112                    .trim_start_matches("**Description:**")
113                    .trim()
114                    .to_string();
115                continue;
116            }
117            if trimmed.starts_with("**References:**") {
118                references = parse_csv_field(trimmed.trim_start_matches("**References:**"));
119                continue;
120            }
121            if trimmed.starts_with("**Templates:**") {
122                templates = parse_csv_field(trimmed.trim_start_matches("**Templates:**"));
123                continue;
124            }
125            if trimmed.starts_with("**Scripts:**") {
126                scripts = parse_csv_field(trimmed.trim_start_matches("**Scripts:**"));
127                continue;
128            }
129            if trimmed.starts_with("**Assets:**") {
130                assets = parse_csv_field(trimmed.trim_start_matches("**Assets:**"));
131                continue;
132            }
133
134            if trimmed == "## Body" || trimmed == "### Body" {
135                in_body = true;
136                continue;
137            }
138
139            if in_body {
140                body.push_str(line);
141                body.push('\n');
142            }
143        }
144
145        if name.is_empty() {
146            return None;
147        }
148
149        if body.is_empty() && !in_body {
150            // If no ## Body header, take everything after the header as body
151            body = content
152                .lines()
153                .skip_while(|l| !l.starts_with("**Trigger"))
154                .skip(1)
155                .collect::<Vec<_>>()
156                .join("\n");
157        }
158
159        Some(Skill {
160            name,
161            description: if description.is_empty() {
162                trigger.join(", ")
163            } else {
164                description
165            },
166            trigger,
167            body: body.trim().to_string(),
168            source_file: source_file.to_string(),
169            usage_count: 0,
170            created_at: chrono::Utc::now().format("%Y-%m-%d").to_string(),
171            score: 0.5,
172            auto_generated: false,
173            references,
174            templates,
175            scripts,
176            assets,
177        })
178    }
179
180    /// Convert a Skill back to SKILL.md markdown format
181    pub fn to_markdown(&self) -> String {
182        format!(
183            "# Skill: {name}\n\n\
184             **Trigger:** {trigger}\n\n\
185             **Description:** {desc}\n\n\
186             **References:** {references}\n\n\
187             **Templates:** {templates}\n\n\
188             **Scripts:** {scripts}\n\n\
189             **Assets:** {assets}\n\n\
190             ## Body\n\
191             {body}\n",
192            name = self.name,
193            trigger = self.trigger.join(", "),
194            desc = self.description,
195            references = self.references.join(", "),
196            templates = self.templates.join(", "),
197            scripts = self.scripts.join(", "),
198            assets = self.assets.join(", "),
199            body = self.body,
200        )
201    }
202
203    /// Check if this skill is relevant to a given context string.
204    /// Returns a relevance score 0.0–1.0.
205    pub fn relevance(&self, ctx: &str) -> f64 {
206        let lower = ctx.to_lowercase();
207        if self.trigger.is_empty() {
208            return 0.0;
209        }
210        let matches: usize = self
211            .trigger
212            .iter()
213            .filter(|kw| lower.contains(kw.as_str()))
214            .count();
215        if matches == 0 {
216            return 0.0;
217        }
218        matches as f64 / self.trigger.len() as f64
219    }
220}
221
222fn parse_csv_field(value: &str) -> Vec<String> {
223    value
224        .trim()
225        .split(',')
226        .map(|s| s.trim().to_string())
227        .filter(|s| !s.is_empty())
228        .collect()
229}
230
231// ─── THE SKILL LIBRARY TRAIT ────────────────────────────────────────────────────
232
233pub trait SkillLibrary: Send + Sync {
234    fn relevant(&self, ctx: &str, limit: usize) -> Vec<Skill>;
235    fn add(&self, skill: Skill) -> anyhow::Result<()>;
236    fn all(&self) -> Vec<Skill>;
237    fn curate(&self) -> anyhow::Result<()>;
238    fn prune(&self, min_score: f64) -> anyhow::Result<usize>;
239    fn get(&self, name: &str) -> Option<Skill>;
240    fn invoke(&self, name: &str) -> anyhow::Result<Option<SkillInvocation>>;
241    /// Remove a skill by name (any kind). Returns true if it existed.
242    fn remove(&self, name: &str) -> anyhow::Result<bool>;
243}
244
245// ─── Filesystem-backed skill library ────────────────────────────────────────────
246
247pub struct FsSkillLibrary {
248    skills_dir: PathBuf,
249    memory: Option<Arc<dyn Memory>>,
250}
251
252impl FsSkillLibrary {
253    pub fn new(skills_dir: PathBuf) -> Self {
254        std::fs::create_dir_all(&skills_dir).ok();
255        Self {
256            skills_dir,
257            memory: None,
258        }
259    }
260
261    pub fn with_memory(mut self, memory: Arc<dyn Memory>) -> Self {
262        self.memory = Some(memory);
263        self
264    }
265
266    /// Scan the skills directory and load all SKILL.md files
267    fn scan(&self) -> Vec<Skill> {
268        let mut skills = Vec::new();
269        if let Ok(entries) = std::fs::read_dir(&self.skills_dir) {
270            for entry in entries.flatten() {
271                let path = entry.path();
272                if path.is_dir() {
273                    // Look for SKILL.md inside subdirectories
274                    let skill_file = path.join("SKILL.md");
275                    if skill_file.exists() {
276                        if let Ok(content) = std::fs::read_to_string(&skill_file) {
277                            let rel = path
278                                .file_name()
279                                .map(|n| n.to_string_lossy().to_string())
280                                .unwrap_or_default();
281                            if let Some(skill) = Skill::from_markdown(&content, &rel) {
282                                skills.push(skill);
283                            }
284                        }
285                    }
286                } else if path
287                    .file_name()
288                    .map(|n| n.to_string_lossy().to_lowercase().ends_with(".skill.md"))
289                    .unwrap_or(false)
290                {
291                    if let Ok(content) = std::fs::read_to_string(&path) {
292                        let rel = path
293                            .file_name()
294                            .map(|n| n.to_string_lossy().to_string())
295                            .unwrap_or_default();
296                        if let Some(skill) = Skill::from_markdown(&content, &rel) {
297                            skills.push(skill);
298                        }
299                    }
300                }
301            }
302        }
303        skills
304    }
305}
306
307impl SkillLibrary for FsSkillLibrary {
308    fn relevant(&self, ctx: &str, limit: usize) -> Vec<Skill> {
309        let mut scored: Vec<(f64, Skill)> = self
310            .scan()
311            .into_iter()
312            .map(|s| {
313                let r = s.relevance(ctx);
314                (r, s)
315            })
316            .filter(|(r, _)| *r > 0.0)
317            .collect();
318
319        scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
320
321        scored.into_iter().take(limit).map(|(_, s)| s).collect()
322    }
323
324    fn add(&self, skill: Skill) -> anyhow::Result<()> {
325        // Create a directory for the skill
326        let skill_dir = self.skills_dir.join(safe_skill_dir_name(&skill.name)?);
327        std::fs::create_dir_all(&skill_dir)?;
328
329        let skill_file = skill_dir.join("SKILL.md");
330        let content = skill.to_markdown();
331        std::fs::write(&skill_file, content)?;
332
333        // Also store in memory if available
334        if let Some(mem) = &self.memory {
335            let _ = mem.upsert_doc(crate::memory::WorkingDoc {
336                id: format!("skill-{}", skill.name),
337                title: format!("Skill: {}", skill.name),
338                content: skill.body.clone(),
339                updated_at: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(),
340            });
341        }
342
343        Ok(())
344    }
345
346    fn all(&self) -> Vec<Skill> {
347        self.scan()
348    }
349
350    fn curate(&self) -> anyhow::Result<()> {
351        let curator = Curator::new();
352        curator.curate(&self.skills_dir)
353    }
354
355    fn prune(&self, min_score: f64) -> anyhow::Result<usize> {
356        let skills = self.scan();
357        let mut removed = 0;
358
359        for skill in &skills {
360            if skill.score < min_score && skill.auto_generated {
361                let skill_dir = self.skills_dir.join(safe_skill_dir_name(&skill.name)?);
362                if skill_dir.exists() {
363                    std::fs::remove_dir_all(&skill_dir)?;
364                    removed += 1;
365                }
366            }
367        }
368
369        Ok(removed)
370    }
371
372    fn get(&self, name: &str) -> Option<Skill> {
373        self.scan().into_iter().find(|s| s.name == name)
374    }
375
376    fn invoke(&self, name: &str) -> anyhow::Result<Option<SkillInvocation>> {
377        let Some(skill) = self.get(name) else {
378            return Ok(None);
379        };
380        let base = if skill.source_file.ends_with(".skill.md") {
381            self.skills_dir.clone()
382        } else {
383            self.skills_dir.join(&skill.source_file)
384        };
385        let load_files = |files: &[String]| -> Vec<(String, String)> {
386            let mut loaded = Vec::new();
387            for f in files {
388                let Ok(candidate) = safe_relative_path(&base, f) else {
389                    continue;
390                };
391                if let Ok(content) = std::fs::read_to_string(&candidate) {
392                    loaded.push((f.clone(), content));
393                }
394            }
395            loaded
396        };
397        Ok(Some(SkillInvocation {
398            loaded_references: load_files(&skill.references),
399            loaded_templates: load_files(&skill.templates),
400            loaded_scripts: load_files(&skill.scripts),
401            loaded_assets: load_files(&skill.assets),
402            skill,
403        }))
404    }
405
406    fn remove(&self, name: &str) -> anyhow::Result<bool> {
407        // Skills live under a directory equal to their name. Remove it.
408        let skill_dir = self.skills_dir.join(safe_skill_dir_name(name)?);
409        let existed = skill_dir.exists();
410        if existed {
411            std::fs::remove_dir_all(&skill_dir)?;
412        }
413        Ok(existed)
414    }
415}
416
417fn safe_skill_dir_name(name: &str) -> anyhow::Result<String> {
418    let trimmed = name.trim();
419    if trimmed.is_empty()
420        || trimmed.contains("..")
421        || trimmed.contains('/')
422        || trimmed.contains('\\')
423        || trimmed.contains(':')
424    {
425        anyhow::bail!("invalid skill name '{}'", name);
426    }
427    Ok(trimmed.to_string())
428}
429
430fn safe_relative_path(base: &Path, relative: &str) -> anyhow::Result<PathBuf> {
431    let rel = Path::new(relative);
432    if rel.is_absolute()
433        || rel
434            .components()
435            .any(|c| matches!(c, std::path::Component::ParentDir))
436    {
437        anyhow::bail!("skill reference escapes skill directory: {}", relative);
438    }
439    let candidate = base.join(rel);
440    let canonical_base = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
441    let canonical_candidate = candidate
442        .canonicalize()
443        .unwrap_or_else(|_| candidate.to_path_buf());
444    if !canonical_candidate.starts_with(&canonical_base) || !canonical_candidate.exists() {
445        anyhow::bail!("skill reference outside base or missing: {}", relative);
446    }
447    Ok(canonical_candidate)
448}
449
450// ─── Curator: self-improvement loop ─────────────────────────────────────────────
451
452pub struct Curator {
453    min_score: f64,
454    max_skills: usize,
455}
456
457impl Curator {
458    pub fn new() -> Self {
459        Self {
460            min_score: 0.2,
461            max_skills: 100,
462        }
463    }
464
465    /// Grade, consolidate, dedupe, and prune the skill library.
466    /// This is the closed learning loop — skills are created from experience
467    /// and improved during use.
468    pub fn curate(&self, skills_dir: &Path) -> anyhow::Result<()> {
469        let library = FsSkillLibrary::new(skills_dir.to_path_buf());
470        let mut skills = library.all();
471
472        if skills.is_empty() {
473            return Ok(());
474        }
475
476        // 1. Grade: score each skill based on usage and content quality
477        for skill in &mut skills {
478            // More usage = higher score
479            skill.score += skill.usage_count as f64 * 0.05;
480            // Longer body = more detailed = slightly higher score
481            skill.score += (skill.body.len() as f64 / 5000.0).min(0.1);
482            // Cap at 1.0
483            skill.score = skill.score.min(1.0);
484        }
485
486        // 2. Dedupe: merge skills with similar names or overlapping triggers
487        let mut merged = Vec::new();
488        let mut merged_indices = std::collections::HashSet::new();
489
490        for i in 0..skills.len() {
491            if merged_indices.contains(&i) {
492                continue;
493            }
494            let mut current = skills[i].clone();
495
496            for j in (i + 1)..skills.len() {
497                if merged_indices.contains(&j) {
498                    continue;
499                }
500                // Check similarity: same first 3 chars of name, or >50% trigger overlap
501                let name_overlap = current.name
502                    [..current.name.len().min(3).min(skills[j].name.len())]
503                    == skills[j].name[..skills[j].name.len().min(3).min(current.name.len())];
504
505                let trigger_overlap = {
506                    let a: std::collections::HashSet<_> = current.trigger.iter().cloned().collect();
507                    let b: std::collections::HashSet<_> =
508                        skills[j].trigger.iter().cloned().collect();
509                    let intersection = a.intersection(&b).count();
510                    let union = a.union(&b).count();
511                    if union == 0 {
512                        false
513                    } else {
514                        intersection as f64 / union as f64 > 0.5
515                    }
516                };
517
518                if name_overlap || trigger_overlap {
519                    // Merge: combine bodies, take higher score
520                    current.body = format!("{}\n\n---\n\n{}", current.body, skills[j].body);
521                    current.score = current.score.max(skills[j].score);
522                    current.trigger.extend(skills[j].trigger.clone());
523                    current.trigger.sort();
524                    current.trigger.dedup();
525                    merged_indices.insert(j);
526                }
527            }
528            merged.push(current);
529        }
530
531        // 3. Prune: remove low-score auto-generated skills, keep total under max
532        merged.retain(|s| !s.auto_generated || s.score >= self.min_score);
533        merged.sort_by(|a, b| {
534            b.score
535                .partial_cmp(&a.score)
536                .unwrap_or(std::cmp::Ordering::Equal)
537        });
538        if merged.len() > self.max_skills {
539            merged.truncate(self.max_skills);
540        }
541
542        // 4. Write back updated SKILL.md files without deleting references,
543        // templates, scripts or assets stored beside them.
544        for skill in &merged {
545            let skill_dir = skills_dir.join(safe_skill_dir_name(&skill.name)?);
546            std::fs::create_dir_all(&skill_dir)?;
547            std::fs::write(skill_dir.join("SKILL.md"), skill.to_markdown())?;
548        }
549
550        tracing::info!(
551            "Curator: {} skills before → {} after (deduped {}, pruned {})",
552            skills.len(),
553            merged.len(),
554            skills.len() - merged_indices.len(),
555            skills.len() + merged_indices.len() - merged.len() - skills.len().min(merged.len()),
556        );
557
558        Ok(())
559    }
560
561    /// Generate a skill candidate from a successful run trajectory.
562    /// Called by the engine after a `RunFinished` event.
563    pub fn propose_skill(run_description: &str, outcome: &str) -> Option<Skill> {
564        let words: Vec<&str> = run_description.split_whitespace().collect();
565        let lower = run_description.to_lowercase();
566        let outcome_lower = outcome.to_lowercase();
567
568        if words.len() < 5 || outcome_lower.contains("error") {
569            return None;
570        }
571
572        let specificity_markers = [
573            "github.com",
574            "http",
575            "https",
576            "this ",
577            "that ",
578            "the file",
579            "my ",
580            "your ",
581            "2024",
582            "2025",
583            "2026",
584        ];
585        if specificity_markers
586            .iter()
587            .any(|marker| lower.contains(marker))
588        {
589            return None;
590        }
591        if words.iter().any(|word| {
592            let cleaned = word.trim_matches(|c: char| !c.is_alphanumeric());
593            // Only bail on long proper-noun-looking tokens (> 12 chars, starts uppercase).
594            // Shorter capitalized words (structs, types) are normal in coding tasks.
595            cleaned
596                .chars()
597                .next()
598                .map(|c| c.is_uppercase())
599                .unwrap_or(false)
600                && cleaned.chars().count() > 12
601        }) {
602            return None;
603        }
604
605        let has_concrete_output = [
606            "diff", "fn ", "struct ", "impl ", "test", "fixed", "refactor", "added", "updated",
607            "created", "modified", "patch", "write", "edit", "return", "async", "pub ", "let ",
608            "const ", "mod ",
609        ]
610        .iter()
611        .any(|needle| outcome_lower.contains(needle));
612        if !has_concrete_output {
613            return None;
614        }
615
616        let name = skill_name_from_pattern(run_description)?.to_string();
617        let triggers = skill_triggers_for_pattern(&name);
618
619        Some(Skill {
620            name,
621            description: format!("Reusable pattern learned from: {}", run_description),
622            trigger: triggers,
623            body: format!(
624                "## Context\nTask: {}\n\n## Approach\n{}",
625                run_description, outcome
626            ),
627            source_file: String::new(),
628            usage_count: 0,
629            created_at: chrono::Utc::now().format("%Y-%m-%d").to_string(),
630            score: 0.3,
631            auto_generated: true,
632            references: Vec::new(),
633            templates: Vec::new(),
634            scripts: Vec::new(),
635            assets: Vec::new(),
636        })
637    }
638
639    pub fn propose_skill_if_missing(
640        run_description: &str,
641        outcome: &str,
642        library: &dyn SkillLibrary,
643    ) -> Option<Skill> {
644        let candidate = Self::propose_skill(run_description, outcome)?;
645        if library.get(&candidate.name).is_some() {
646            None
647        } else {
648            Some(candidate)
649        }
650    }
651}
652
653pub fn skill_name_from_pattern(description: &str) -> Option<&'static str> {
654    let d = description.to_lowercase();
655    if d.contains("test") && (d.contains("add") || d.contains("write") || d.contains("fix")) {
656        return Some("write-and-fix-tests");
657    }
658    if d.contains("refactor") || d.contains("rename") || d.contains("extract") {
659        return Some("refactor-safely");
660    }
661    if d.contains("debug") || d.contains("error") || d.contains("panic") || d.contains("crash") {
662        return Some("debug-systematically");
663    }
664    if d.contains("document")
665        || d.contains("comment")
666        || d.contains("readme")
667        || d.contains("docstring")
668    {
669        return Some("write-docs");
670    }
671    if d.contains("secur") || d.contains("vulnerab") || d.contains("audit") {
672        return Some("security-audit");
673    }
674    if d.contains("performance") || d.contains("slow") || d.contains("optim") || d.contains("bench")
675    {
676        return Some("performance-profile");
677    }
678    if d.contains("upgrade") || d.contains("bump") || d.contains("depend") || d.contains("package")
679    {
680        return Some("upgrade-dependencies");
681    }
682    if d.contains("review") || d.contains("pr") || d.contains("pull request") || d.contains("diff")
683    {
684        return Some("code-review");
685    }
686    if d.contains("git") || d.contains("commit") || d.contains("branch") || d.contains("merge") {
687        return Some("git-workflow");
688    }
689    None
690}
691
692fn skill_triggers_for_pattern(name: &str) -> Vec<String> {
693    match name {
694        "write-and-fix-tests" => vec!["test", "unit", "fix", "assert"],
695        "refactor-safely" => vec!["refactor", "rename", "extract", "safe"],
696        "debug-systematically" => vec!["debug", "error", "panic", "crash"],
697        "write-docs" => vec!["document", "readme", "comment", "docstring"],
698        "security-audit" => vec!["security", "audit", "vulnerability", "safe"],
699        "performance-profile" => vec!["performance", "slow", "optimize", "bench"],
700        "upgrade-dependencies" => vec!["upgrade", "bump", "dependency", "package"],
701        "code-review" => vec!["review", "pr", "diff", "pull-request"],
702        "git-workflow" => vec!["git", "commit", "branch", "merge"],
703        _ => vec!["skill"],
704    }
705    .into_iter()
706    .map(String::from)
707    .collect()
708}
709
710impl Default for Curator {
711    fn default() -> Self {
712        Self::new()
713    }
714}
715
716#[cfg(test)]
717mod tests {
718    use super::*;
719
720    fn temp_dir(name: &str) -> PathBuf {
721        std::env::temp_dir().join(format!(
722            "sparrow-tier2-{name}-{}",
723            std::time::SystemTime::now()
724                .duration_since(std::time::UNIX_EPOCH)
725                .unwrap()
726                .as_nanos()
727        ))
728    }
729
730    #[test]
731    fn skill_invocation_rejects_parent_dir_references() {
732        let root = temp_dir("skill-ref-escape");
733        std::fs::create_dir_all(root.join("review").join("references")).unwrap();
734        std::fs::write(
735            root.join("review").join("SKILL.md"),
736            "# Skill: review\n\n**Trigger:** review\n\n**References:** ../secret.txt, references/checklist.md\n\n## Body\nReview carefully.",
737        )
738        .unwrap();
739        std::fs::write(
740            root.join("review").join("references").join("checklist.md"),
741            "ok",
742        )
743        .unwrap();
744        std::fs::write(root.join("secret.txt"), "nope").unwrap();
745
746        let lib = FsSkillLibrary::new(root.clone());
747        let invocation = lib.invoke("review").unwrap().expect("skill should exist");
748
749        assert_eq!(invocation.loaded_references.len(), 1);
750        assert_eq!(invocation.loaded_references[0].0, "references/checklist.md");
751
752        let _ = std::fs::remove_dir_all(root);
753    }
754
755    #[test]
756    fn curator_preserves_skill_assets_and_updates_skill_md_only() {
757        let root = temp_dir("curator-assets");
758        let skill_dir = root.join("refactor-safely");
759        std::fs::create_dir_all(skill_dir.join("references")).unwrap();
760        std::fs::write(skill_dir.join("references").join("checklist.md"), "keep me").unwrap();
761        std::fs::write(
762            skill_dir.join("SKILL.md"),
763            "# Skill: refactor-safely\n\n**Trigger:** refactor, rename\n\n**References:** references/checklist.md\n\n## Body\nMove in small steps.",
764        )
765        .unwrap();
766
767        Curator::new().curate(&root).unwrap();
768
769        assert!(
770            skill_dir.join("references").join("checklist.md").exists(),
771            "curator must not delete progressive-disclosure assets"
772        );
773        let lib = FsSkillLibrary::new(root.clone());
774        let invocation = lib
775            .invoke("refactor-safely")
776            .unwrap()
777            .expect("skill should remain");
778        assert_eq!(invocation.loaded_references[0].1, "keep me");
779
780        let _ = std::fs::remove_dir_all(root);
781    }
782
783    #[test]
784    fn skill_names_cannot_escape_skill_root() {
785        let root = temp_dir("skill-name-escape");
786        let lib = FsSkillLibrary::new(root.clone());
787        let skill = Skill {
788            name: "../outside".into(),
789            description: "bad".into(),
790            trigger: vec!["bad".into()],
791            body: "bad".into(),
792            source_file: String::new(),
793            usage_count: 0,
794            created_at: String::new(),
795            score: 0.5,
796            auto_generated: false,
797            references: Vec::new(),
798            templates: Vec::new(),
799            scripts: Vec::new(),
800            assets: Vec::new(),
801        };
802
803        assert!(lib.add(skill).is_err());
804        assert!(!root.join("..").join("outside").exists());
805
806        let _ = std::fs::remove_dir_all(root);
807    }
808}