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    /// On-disk root for the library, if any. In-memory implementations
244    /// return None; `sparrow skills update` reads SKILL.md from here.
245    fn skills_root(&self) -> Option<std::path::PathBuf> {
246        None
247    }
248}
249
250// ─── Filesystem-backed skill library ────────────────────────────────────────────
251
252pub struct FsSkillLibrary {
253    skills_dir: PathBuf,
254    memory: Option<Arc<dyn Memory>>,
255}
256
257impl FsSkillLibrary {
258    pub fn new(skills_dir: PathBuf) -> Self {
259        std::fs::create_dir_all(&skills_dir).ok();
260        Self {
261            skills_dir,
262            memory: None,
263        }
264    }
265
266    pub fn with_memory(mut self, memory: Arc<dyn Memory>) -> Self {
267        self.memory = Some(memory);
268        self
269    }
270
271    /// Path to the skills root. Exposed so commands like
272    /// `sparrow skills update` can re-read a skill from disk without going
273    /// through the cached library view.
274    pub fn skills_dir(&self) -> &std::path::Path {
275        &self.skills_dir
276    }
277
278    /// Scan the skills directory and load all SKILL.md files
279    pub fn scan(&self) -> Vec<Skill> {
280        let mut skills = Vec::new();
281        if let Ok(entries) = std::fs::read_dir(&self.skills_dir) {
282            for entry in entries.flatten() {
283                let path = entry.path();
284                if path.is_dir() {
285                    // Look for SKILL.md inside subdirectories
286                    let skill_file = path.join("SKILL.md");
287                    if skill_file.exists() {
288                        if let Ok(content) = std::fs::read_to_string(&skill_file) {
289                            let rel = path
290                                .file_name()
291                                .map(|n| n.to_string_lossy().to_string())
292                                .unwrap_or_default();
293                            if let Some(skill) = Skill::from_markdown(&content, &rel) {
294                                skills.push(skill);
295                            }
296                        }
297                    }
298                } else if path
299                    .file_name()
300                    .map(|n| n.to_string_lossy().to_lowercase().ends_with(".skill.md"))
301                    .unwrap_or(false)
302                {
303                    if let Ok(content) = std::fs::read_to_string(&path) {
304                        let rel = path
305                            .file_name()
306                            .map(|n| n.to_string_lossy().to_string())
307                            .unwrap_or_default();
308                        if let Some(skill) = Skill::from_markdown(&content, &rel) {
309                            skills.push(skill);
310                        }
311                    }
312                }
313            }
314        }
315        skills
316    }
317}
318
319impl SkillLibrary for FsSkillLibrary {
320    fn skills_root(&self) -> Option<std::path::PathBuf> {
321        Some(self.skills_dir.clone())
322    }
323
324    fn relevant(&self, ctx: &str, limit: usize) -> Vec<Skill> {
325        let mut scored: Vec<(f64, Skill)> = self
326            .scan()
327            .into_iter()
328            .map(|s| {
329                let r = s.relevance(ctx);
330                (r, s)
331            })
332            .filter(|(r, _)| *r > 0.0)
333            .collect();
334
335        scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
336
337        scored.into_iter().take(limit).map(|(_, s)| s).collect()
338    }
339
340    fn add(&self, skill: Skill) -> anyhow::Result<()> {
341        // Create a directory for the skill
342        let skill_dir = self.skills_dir.join(safe_skill_dir_name(&skill.name)?);
343        std::fs::create_dir_all(&skill_dir)?;
344
345        let skill_file = skill_dir.join("SKILL.md");
346        let content = skill.to_markdown();
347        std::fs::write(&skill_file, content)?;
348
349        // Also store in memory if available
350        if let Some(mem) = &self.memory {
351            let _ = mem.upsert_doc(crate::memory::WorkingDoc {
352                id: format!("skill-{}", skill.name),
353                title: format!("Skill: {}", skill.name),
354                content: skill.body.clone(),
355                updated_at: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(),
356            });
357        }
358
359        Ok(())
360    }
361
362    fn all(&self) -> Vec<Skill> {
363        self.scan()
364    }
365
366    fn curate(&self) -> anyhow::Result<()> {
367        let curator = Curator::new();
368        curator.curate(&self.skills_dir)
369    }
370
371    fn prune(&self, min_score: f64) -> anyhow::Result<usize> {
372        let skills = self.scan();
373        let mut removed = 0;
374
375        for skill in &skills {
376            if skill.score < min_score && skill.auto_generated {
377                let skill_dir = self.skills_dir.join(safe_skill_dir_name(&skill.name)?);
378                if skill_dir.exists() {
379                    std::fs::remove_dir_all(&skill_dir)?;
380                    removed += 1;
381                }
382            }
383        }
384
385        Ok(removed)
386    }
387
388    fn get(&self, name: &str) -> Option<Skill> {
389        self.scan().into_iter().find(|s| s.name == name)
390    }
391
392    fn invoke(&self, name: &str) -> anyhow::Result<Option<SkillInvocation>> {
393        let Some(skill) = self.get(name) else {
394            return Ok(None);
395        };
396        let base = if skill.source_file.ends_with(".skill.md") {
397            self.skills_dir.clone()
398        } else {
399            self.skills_dir.join(&skill.source_file)
400        };
401        let load_files = |files: &[String]| -> Vec<(String, String)> {
402            let mut loaded = Vec::new();
403            for f in files {
404                let Ok(candidate) = safe_relative_path(&base, f) else {
405                    continue;
406                };
407                if let Ok(content) = std::fs::read_to_string(&candidate) {
408                    loaded.push((f.clone(), content));
409                }
410            }
411            loaded
412        };
413        Ok(Some(SkillInvocation {
414            loaded_references: load_files(&skill.references),
415            loaded_templates: load_files(&skill.templates),
416            loaded_scripts: load_files(&skill.scripts),
417            loaded_assets: load_files(&skill.assets),
418            skill,
419        }))
420    }
421
422    fn remove(&self, name: &str) -> anyhow::Result<bool> {
423        // Skills live under a directory equal to their name. Remove it.
424        let skill_dir = self.skills_dir.join(safe_skill_dir_name(name)?);
425        let existed = skill_dir.exists();
426        if existed {
427            std::fs::remove_dir_all(&skill_dir)?;
428        }
429        Ok(existed)
430    }
431}
432
433fn safe_skill_dir_name(name: &str) -> anyhow::Result<String> {
434    let trimmed = name.trim();
435    if trimmed.is_empty()
436        || trimmed.contains("..")
437        || trimmed.contains('/')
438        || trimmed.contains('\\')
439        || trimmed.contains(':')
440    {
441        anyhow::bail!("invalid skill name '{}'", name);
442    }
443    Ok(trimmed.to_string())
444}
445
446fn safe_relative_path(base: &Path, relative: &str) -> anyhow::Result<PathBuf> {
447    let rel = Path::new(relative);
448    if rel.is_absolute()
449        || rel
450            .components()
451            .any(|c| matches!(c, std::path::Component::ParentDir))
452    {
453        anyhow::bail!("skill reference escapes skill directory: {}", relative);
454    }
455    let candidate = base.join(rel);
456    let canonical_base = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
457    let canonical_candidate = candidate
458        .canonicalize()
459        .unwrap_or_else(|_| candidate.to_path_buf());
460    if !canonical_candidate.starts_with(&canonical_base) || !canonical_candidate.exists() {
461        anyhow::bail!("skill reference outside base or missing: {}", relative);
462    }
463    Ok(canonical_candidate)
464}
465
466// ─── Curator: self-improvement loop ─────────────────────────────────────────────
467
468pub struct Curator {
469    min_score: f64,
470    max_skills: usize,
471}
472
473impl Curator {
474    pub fn new() -> Self {
475        Self {
476            min_score: 0.2,
477            max_skills: 100,
478        }
479    }
480
481    /// Grade, consolidate, dedupe, and prune the skill library.
482    /// This is the closed learning loop — skills are created from experience
483    /// and improved during use.
484    pub fn curate(&self, skills_dir: &Path) -> anyhow::Result<()> {
485        let library = FsSkillLibrary::new(skills_dir.to_path_buf());
486        let mut skills = library.all();
487
488        if skills.is_empty() {
489            return Ok(());
490        }
491
492        // 0. Purge poisoned skills (G1): any skill whose description or body
493        //    carries UI-status leakage or a user complaint is deleted from
494        //    disk — it must never be re-injected into a system prompt.
495        skills.retain(|s| {
496            let poisoned = is_unfit_for_skill(&s.description) || is_unfit_for_skill(&s.body);
497            if poisoned {
498                if let Ok(dir_name) = safe_skill_dir_name(&s.name) {
499                    let _ = std::fs::remove_dir_all(skills_dir.join(dir_name));
500                }
501                tracing::warn!("Curator: purged poisoned skill `{}`", s.name);
502            }
503            !poisoned
504        });
505
506        if skills.is_empty() {
507            return Ok(());
508        }
509
510        // 1. Grade: score each skill based on usage and content quality
511        for skill in &mut skills {
512            // More usage = higher score
513            skill.score += skill.usage_count as f64 * 0.05;
514            // Longer body = more detailed = slightly higher score
515            skill.score += (skill.body.len() as f64 / 5000.0).min(0.1);
516            // Cap at 1.0
517            skill.score = skill.score.min(1.0);
518        }
519
520        // 2. Dedupe: merge skills with similar names or overlapping triggers
521        let mut merged = Vec::new();
522        let mut merged_indices = std::collections::HashSet::new();
523
524        for i in 0..skills.len() {
525            if merged_indices.contains(&i) {
526                continue;
527            }
528            let mut current = skills[i].clone();
529
530            for j in (i + 1)..skills.len() {
531                if merged_indices.contains(&j) {
532                    continue;
533                }
534                // Check similarity: same first 3 chars of name, or >50% trigger overlap
535                let name_overlap = current.name
536                    [..current.name.len().min(3).min(skills[j].name.len())]
537                    == skills[j].name[..skills[j].name.len().min(3).min(current.name.len())];
538
539                let trigger_overlap = {
540                    let a: std::collections::HashSet<_> = current.trigger.iter().cloned().collect();
541                    let b: std::collections::HashSet<_> =
542                        skills[j].trigger.iter().cloned().collect();
543                    let intersection = a.intersection(&b).count();
544                    let union = a.union(&b).count();
545                    if union == 0 {
546                        false
547                    } else {
548                        intersection as f64 / union as f64 > 0.5
549                    }
550                };
551
552                if name_overlap || trigger_overlap {
553                    // Merge: combine bodies, take higher score
554                    current.body = format!("{}\n\n---\n\n{}", current.body, skills[j].body);
555                    current.score = current.score.max(skills[j].score);
556                    current.trigger.extend(skills[j].trigger.clone());
557                    current.trigger.sort();
558                    current.trigger.dedup();
559                    merged_indices.insert(j);
560                }
561            }
562            merged.push(current);
563        }
564
565        // 3. Prune: remove low-score auto-generated skills, keep total under max
566        merged.retain(|s| !s.auto_generated || s.score >= self.min_score);
567        merged.sort_by(|a, b| {
568            b.score
569                .partial_cmp(&a.score)
570                .unwrap_or(std::cmp::Ordering::Equal)
571        });
572        if merged.len() > self.max_skills {
573            merged.truncate(self.max_skills);
574        }
575
576        // 4. Write back updated SKILL.md files without deleting references,
577        // templates, scripts or assets stored beside them.
578        for skill in &merged {
579            let skill_dir = skills_dir.join(safe_skill_dir_name(&skill.name)?);
580            std::fs::create_dir_all(&skill_dir)?;
581            std::fs::write(skill_dir.join("SKILL.md"), skill.to_markdown())?;
582        }
583
584        tracing::info!(
585            "Curator: {} skills before → {} after (deduped {}, pruned {})",
586            skills.len(),
587            merged.len(),
588            skills.len() - merged_indices.len(),
589            skills.len() + merged_indices.len() - merged.len() - skills.len().min(merged.len()),
590        );
591
592        Ok(())
593    }
594
595    /// Generate a skill candidate from a successful run trajectory.
596    /// Called by the engine after a `RunFinished` event.
597    pub fn propose_skill(run_description: &str, outcome: &str) -> Option<Skill> {
598        let words: Vec<&str> = run_description.split_whitespace().collect();
599        let lower = run_description.to_lowercase();
600        let outcome_lower = outcome.to_lowercase();
601
602        if words.len() < 5 || outcome_lower.contains("error") {
603            return None;
604        }
605
606        // G1: never learn from UI-status leakage or a user complaint. The
607        // poisoned `code-review` skill on disk was born from a task description
608        // polluted with cockpit status text ("coder ◌ consulting … parsing
609        // request…") and an outcome carrying "✓ coder completed · 4487↑ 150↓
610        // tok" plus a frustrated correction. None of that is a reusable skill.
611        if is_unfit_for_skill(run_description) || is_unfit_for_skill(outcome) {
612            return None;
613        }
614
615        let specificity_markers = [
616            "github.com",
617            "http",
618            "https",
619            "this ",
620            "that ",
621            "the file",
622            "my ",
623            "your ",
624            "2024",
625            "2025",
626            "2026",
627        ];
628        if specificity_markers
629            .iter()
630            .any(|marker| lower.contains(marker))
631        {
632            return None;
633        }
634        if words.iter().any(|word| {
635            let cleaned = word.trim_matches(|c: char| !c.is_alphanumeric());
636            // Only bail on long proper-noun-looking tokens (> 12 chars, starts uppercase).
637            // Shorter capitalized words (structs, types) are normal in coding tasks.
638            cleaned
639                .chars()
640                .next()
641                .map(|c| c.is_uppercase())
642                .unwrap_or(false)
643                && cleaned.chars().count() > 12
644        }) {
645            return None;
646        }
647
648        let has_concrete_output = [
649            "diff", "fn ", "struct ", "impl ", "test", "fixed", "refactor", "added", "updated",
650            "created", "modified", "patch", "write", "edit", "return", "async", "pub ", "let ",
651            "const ", "mod ",
652        ]
653        .iter()
654        .any(|needle| outcome_lower.contains(needle));
655        if !has_concrete_output {
656            return None;
657        }
658
659        let name = skill_name_from_pattern(run_description)?.to_string();
660        let triggers = skill_triggers_for_pattern(&name);
661
662        Some(Skill {
663            name,
664            description: format!("Reusable pattern learned from: {}", run_description),
665            trigger: triggers,
666            body: format!(
667                "## Context\nTask: {}\n\n## Approach\n{}",
668                run_description, outcome
669            ),
670            source_file: String::new(),
671            usage_count: 0,
672            created_at: chrono::Utc::now().format("%Y-%m-%d").to_string(),
673            score: 0.3,
674            auto_generated: true,
675            references: Vec::new(),
676            templates: Vec::new(),
677            scripts: Vec::new(),
678            assets: Vec::new(),
679        })
680    }
681
682    pub fn propose_skill_if_missing(
683        run_description: &str,
684        outcome: &str,
685        library: &dyn SkillLibrary,
686    ) -> Option<Skill> {
687        let candidate = Self::propose_skill(run_description, outcome)?;
688        if library.get(&candidate.name).is_some() {
689            None
690        } else {
691            Some(candidate)
692        }
693    }
694}
695
696/// G1: signatures of text that must NEVER become a skill — cockpit/UI status
697/// leakage and user complaints/corrections. Used to reject both the task
698/// description and the outcome before a skill is proposed, and to recognise
699/// already-poisoned skills on disk for purging.
700pub fn is_unfit_for_skill(text: &str) -> bool {
701    let lower = text.to_lowercase();
702    // UI / cockpit status artifacts that should never reach the curator.
703    const UI_ARTIFACTS: &[&str] = &[
704        "◌",
705        "consulting ",
706        "parsing request",
707        "↑",
708        "↓",
709        "completed ·",
710        "route set",
711        "reusable pattern learned from",
712        "metrics captured",
713    ];
714    if UI_ARTIFACTS.iter().any(|m| lower.contains(m)) {
715        return true;
716    }
717    // Complaint / correction markers — the user telling Sparrow it failed.
718    const COMPLAINT_MARKERS: &[&str] = &[
719        "tu as vraiment un problème",
720        "regarde ce que tu m'as",
721        "n'importe quoi",
722        "ça marche pas",
723        "ne marche pas",
724        "you have a problem",
725        "this is broken",
726        "that's wrong",
727    ];
728    if COMPLAINT_MARKERS.iter().any(|m| lower.contains(m)) {
729        return true;
730    }
731    false
732}
733
734pub fn skill_name_from_pattern(description: &str) -> Option<&'static str> {
735    let d = description.to_lowercase();
736    if d.contains("test") && (d.contains("add") || d.contains("write") || d.contains("fix")) {
737        return Some("write-and-fix-tests");
738    }
739    if d.contains("refactor") || d.contains("rename") || d.contains("extract") {
740        return Some("refactor-safely");
741    }
742    if d.contains("debug") || d.contains("error") || d.contains("panic") || d.contains("crash") {
743        return Some("debug-systematically");
744    }
745    if d.contains("document")
746        || d.contains("comment")
747        || d.contains("readme")
748        || d.contains("docstring")
749    {
750        return Some("write-docs");
751    }
752    if d.contains("secur") || d.contains("vulnerab") || d.contains("audit") {
753        return Some("security-audit");
754    }
755    if d.contains("performance") || d.contains("slow") || d.contains("optim") || d.contains("bench")
756    {
757        return Some("performance-profile");
758    }
759    if d.contains("upgrade") || d.contains("bump") || d.contains("depend") || d.contains("package")
760    {
761        return Some("upgrade-dependencies");
762    }
763    if d.contains("review") || d.contains("pr") || d.contains("pull request") || d.contains("diff")
764    {
765        return Some("code-review");
766    }
767    if d.contains("git") || d.contains("commit") || d.contains("branch") || d.contains("merge") {
768        return Some("git-workflow");
769    }
770    None
771}
772
773fn skill_triggers_for_pattern(name: &str) -> Vec<String> {
774    match name {
775        "write-and-fix-tests" => vec!["test", "unit", "fix", "assert"],
776        "refactor-safely" => vec!["refactor", "rename", "extract", "safe"],
777        "debug-systematically" => vec!["debug", "error", "panic", "crash"],
778        "write-docs" => vec!["document", "readme", "comment", "docstring"],
779        "security-audit" => vec!["security", "audit", "vulnerability", "safe"],
780        "performance-profile" => vec!["performance", "slow", "optimize", "bench"],
781        "upgrade-dependencies" => vec!["upgrade", "bump", "dependency", "package"],
782        "code-review" => vec!["review", "pr", "diff", "pull-request"],
783        "git-workflow" => vec!["git", "commit", "branch", "merge"],
784        _ => vec!["skill"],
785    }
786    .into_iter()
787    .map(String::from)
788    .collect()
789}
790
791impl Default for Curator {
792    fn default() -> Self {
793        Self::new()
794    }
795}
796
797#[cfg(test)]
798mod tests {
799    use super::*;
800
801    fn temp_dir(name: &str) -> PathBuf {
802        std::env::temp_dir().join(format!(
803            "sparrow-tier2-{name}-{}",
804            std::time::SystemTime::now()
805                .duration_since(std::time::UNIX_EPOCH)
806                .unwrap()
807                .as_nanos()
808        ))
809    }
810
811    #[test]
812    fn skill_invocation_rejects_parent_dir_references() {
813        let root = temp_dir("skill-ref-escape");
814        std::fs::create_dir_all(root.join("review").join("references")).unwrap();
815        std::fs::write(
816            root.join("review").join("SKILL.md"),
817            "# Skill: review\n\n**Trigger:** review\n\n**References:** ../secret.txt, references/checklist.md\n\n## Body\nReview carefully.",
818        )
819        .unwrap();
820        std::fs::write(
821            root.join("review").join("references").join("checklist.md"),
822            "ok",
823        )
824        .unwrap();
825        std::fs::write(root.join("secret.txt"), "nope").unwrap();
826
827        let lib = FsSkillLibrary::new(root.clone());
828        let invocation = lib.invoke("review").unwrap().expect("skill should exist");
829
830        assert_eq!(invocation.loaded_references.len(), 1);
831        assert_eq!(invocation.loaded_references[0].0, "references/checklist.md");
832
833        let _ = std::fs::remove_dir_all(root);
834    }
835
836    #[test]
837    fn curator_preserves_skill_assets_and_updates_skill_md_only() {
838        let root = temp_dir("curator-assets");
839        let skill_dir = root.join("refactor-safely");
840        std::fs::create_dir_all(skill_dir.join("references")).unwrap();
841        std::fs::write(skill_dir.join("references").join("checklist.md"), "keep me").unwrap();
842        std::fs::write(
843            skill_dir.join("SKILL.md"),
844            "# Skill: refactor-safely\n\n**Trigger:** refactor, rename\n\n**References:** references/checklist.md\n\n## Body\nMove in small steps.",
845        )
846        .unwrap();
847
848        Curator::new().curate(&root).unwrap();
849
850        assert!(
851            skill_dir.join("references").join("checklist.md").exists(),
852            "curator must not delete progressive-disclosure assets"
853        );
854        let lib = FsSkillLibrary::new(root.clone());
855        let invocation = lib
856            .invoke("refactor-safely")
857            .unwrap()
858            .expect("skill should remain");
859        assert_eq!(invocation.loaded_references[0].1, "keep me");
860
861        let _ = std::fs::remove_dir_all(root);
862    }
863
864    #[test]
865    fn curator_purges_poisoned_skill_from_disk() {
866        // G1: a skill whose description is a complaint laced with UI status
867        // text must be deleted on the next curate pass.
868        let root = temp_dir("curator-purge");
869        let toxic = root.join("code-review");
870        std::fs::create_dir_all(&toxic).unwrap();
871        std::fs::write(
872            toxic.join("SKILL.md"),
873            "# Skill: code-review\n\n**Trigger:** review, pr, diff\n\n**Description:** Reusable pattern learned from: non tu as vraiment un problème regarde ce que tu m'as écris : coder ◌ consulting deepseek-v4-pro\n\n## Body\n✓ coder completed · 4487↑ 150↓ tok",
874        )
875        .unwrap();
876        // A legitimate skill must survive.
877        let good = root.join("refactor-safely");
878        std::fs::create_dir_all(&good).unwrap();
879        std::fs::write(
880            good.join("SKILL.md"),
881            "# Skill: refactor-safely\n\n**Trigger:** refactor\n\n**Description:** Move code in small verified steps.\n\n## Body\nExtract, compile, test, repeat.",
882        )
883        .unwrap();
884
885        Curator::new().curate(&root).unwrap();
886
887        assert!(!toxic.exists(), "poisoned skill dir must be removed");
888        assert!(good.exists(), "legitimate skill must survive");
889
890        let _ = std::fs::remove_dir_all(root);
891    }
892
893    #[test]
894    fn propose_skill_rejects_ui_status_and_complaints() {
895        // The exact polluted inputs that produced the poisoned skill.
896        assert!(
897            Curator::propose_skill(
898                "non tu as vraiment un problème regarde ce que tu m'as écris coder",
899                "✓ coder completed · 4487↑ 150↓ tok",
900            )
901            .is_none()
902        );
903        assert!(is_unfit_for_skill(
904            "coder ◌ consulting deepseek-v4-pro · parsing request…"
905        ));
906        assert!(!is_unfit_for_skill(
907            "Refactor the auth module by extracting the token parser into its own function."
908        ));
909    }
910
911    #[test]
912    fn skill_names_cannot_escape_skill_root() {
913        let root = temp_dir("skill-name-escape");
914        let lib = FsSkillLibrary::new(root.clone());
915        let skill = Skill {
916            name: "../outside".into(),
917            description: "bad".into(),
918            trigger: vec!["bad".into()],
919            body: "bad".into(),
920            source_file: String::new(),
921            usage_count: 0,
922            created_at: String::new(),
923            score: 0.5,
924            auto_generated: false,
925            references: Vec::new(),
926            templates: Vec::new(),
927            scripts: Vec::new(),
928            assets: Vec::new(),
929        };
930
931        assert!(lib.add(skill).is_err());
932        assert!(!root.join("..").join("outside").exists());
933
934        let _ = std::fs::remove_dir_all(root);
935    }
936}