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    /// Optional manifest version for permission-aware skills.
49    #[serde(default)]
50    pub manifest_version: Option<String>,
51    /// Optional allow-list of tools while this skill is active.
52    #[serde(default)]
53    pub allowed_tools: Vec<String>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct SkillInvocation {
58    pub skill: Skill,
59    pub loaded_references: Vec<(String, String)>,
60    pub loaded_templates: Vec<(String, String)>,
61    pub loaded_scripts: Vec<(String, String)>,
62    pub loaded_assets: Vec<(String, String)>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize, Default)]
66pub struct SkillManifest {
67    #[serde(default)]
68    pub version: Option<String>,
69    #[serde(default)]
70    pub allowed_tools: Vec<String>,
71}
72
73fn default_score() -> f64 {
74    0.5
75}
76
77impl Skill {
78    /// Parse a SKILL.md file into a Skill struct.
79    /// Format:
80    /// ```markdown
81    /// # Skill: <name>
82    ///
83    /// **Trigger:** comma, separated, keywords
84    ///
85    /// ## Body
86    /// <content>
87    /// ```
88    pub fn from_markdown(content: &str, source_file: &str) -> Option<Self> {
89        let mut name = String::new();
90        let mut description = String::new();
91        let mut trigger = Vec::new();
92        let mut body = String::new();
93        let mut references = Vec::new();
94        let mut templates = Vec::new();
95        let mut scripts = Vec::new();
96        let mut assets = Vec::new();
97        let mut allowed_tools = Vec::new();
98        let mut in_body = false;
99
100        for line in content.lines() {
101            let trimmed = line.trim();
102
103            if trimmed.starts_with("# Skill:") || trimmed.starts_with("# ") {
104                name = trimmed
105                    .trim_start_matches("# Skill:")
106                    .trim_start_matches("# ")
107                    .trim()
108                    .to_string();
109                continue;
110            }
111
112            if trimmed.starts_with("**Trigger:**") || trimmed.starts_with("**Triggers:**") {
113                let trig_str = trimmed
114                    .trim_start_matches("**Trigger:**")
115                    .trim_start_matches("**Triggers:**")
116                    .trim();
117                trigger = trig_str
118                    .split(',')
119                    .map(|s| s.trim().to_lowercase())
120                    .filter(|s| !s.is_empty())
121                    .collect();
122                continue;
123            }
124
125            if trimmed.starts_with("**Description:**") {
126                description = trimmed
127                    .trim_start_matches("**Description:**")
128                    .trim()
129                    .to_string();
130                continue;
131            }
132            if trimmed.starts_with("**References:**") {
133                references = parse_csv_field(trimmed.trim_start_matches("**References:**"));
134                continue;
135            }
136            if trimmed.starts_with("**Templates:**") {
137                templates = parse_csv_field(trimmed.trim_start_matches("**Templates:**"));
138                continue;
139            }
140            if trimmed.starts_with("**Scripts:**") {
141                scripts = parse_csv_field(trimmed.trim_start_matches("**Scripts:**"));
142                continue;
143            }
144            if trimmed.starts_with("**Assets:**") {
145                assets = parse_csv_field(trimmed.trim_start_matches("**Assets:**"));
146                continue;
147            }
148            if trimmed.starts_with("**Allowed Tools:**") {
149                allowed_tools = parse_csv_field(trimmed.trim_start_matches("**Allowed Tools:**"));
150                continue;
151            }
152
153            if trimmed == "## Body" || trimmed == "### Body" {
154                in_body = true;
155                continue;
156            }
157
158            if in_body {
159                body.push_str(line);
160                body.push('\n');
161            }
162        }
163
164        if name.is_empty() {
165            return None;
166        }
167
168        if body.is_empty() && !in_body {
169            // If no ## Body header, take everything after the header as body
170            body = content
171                .lines()
172                .skip_while(|l| !l.starts_with("**Trigger"))
173                .skip(1)
174                .collect::<Vec<_>>()
175                .join("\n");
176        }
177
178        Some(Skill {
179            name,
180            description: if description.is_empty() {
181                trigger.join(", ")
182            } else {
183                description
184            },
185            trigger,
186            body: body.trim().to_string(),
187            source_file: source_file.to_string(),
188            usage_count: 0,
189            created_at: chrono::Utc::now().format("%Y-%m-%d").to_string(),
190            score: 0.5,
191            auto_generated: false,
192            references,
193            templates,
194            scripts,
195            assets,
196            manifest_version: None,
197            allowed_tools,
198        })
199    }
200
201    /// Convert a Skill back to SKILL.md markdown format
202    pub fn to_markdown(&self) -> String {
203        format!(
204            "# Skill: {name}\n\n\
205             **Trigger:** {trigger}\n\n\
206             **Description:** {desc}\n\n\
207             **References:** {references}\n\n\
208             **Templates:** {templates}\n\n\
209             **Scripts:** {scripts}\n\n\
210             **Assets:** {assets}\n\n\
211             **Allowed Tools:** {allowed_tools}\n\n\
212             ## Body\n\
213             {body}\n",
214            name = self.name,
215            trigger = self.trigger.join(", "),
216            desc = self.description,
217            references = self.references.join(", "),
218            templates = self.templates.join(", "),
219            scripts = self.scripts.join(", "),
220            assets = self.assets.join(", "),
221            allowed_tools = self.allowed_tools.join(", "),
222            body = self.body,
223        )
224    }
225
226    /// Check if this skill is relevant to a given context string.
227    /// Returns a relevance score 0.0–1.0.
228    pub fn relevance(&self, ctx: &str) -> f64 {
229        let lower = ctx.to_lowercase();
230        if self.trigger.is_empty() {
231            return 0.0;
232        }
233        let matches: usize = self
234            .trigger
235            .iter()
236            .filter(|kw| lower.contains(kw.as_str()))
237            .count();
238        if matches == 0 {
239            return 0.0;
240        }
241        matches as f64 / self.trigger.len() as f64
242    }
243}
244
245fn parse_csv_field(value: &str) -> Vec<String> {
246    value
247        .trim()
248        .split(',')
249        .map(|s| s.trim().to_string())
250        .filter(|s| !s.is_empty())
251        .collect()
252}
253
254fn apply_skill_manifest(skill: &mut Skill, dir: &Path) {
255    if let Some(manifest) = load_skill_manifest(dir) {
256        if manifest.version.is_some() {
257            skill.manifest_version = manifest.version;
258        }
259        if !manifest.allowed_tools.is_empty() {
260            skill.allowed_tools = manifest.allowed_tools;
261        }
262    }
263}
264
265fn load_skill_manifest(dir: &Path) -> Option<SkillManifest> {
266    let toml_path = dir.join("manifest.toml");
267    if toml_path.exists() {
268        return std::fs::read_to_string(toml_path)
269            .ok()
270            .and_then(|s| toml::from_str(&s).ok());
271    }
272    let json_path = dir.join("manifest.json");
273    if json_path.exists() {
274        return std::fs::read_to_string(json_path)
275            .ok()
276            .and_then(|s| serde_json::from_str(&s).ok());
277    }
278    None
279}
280
281// ─── THE SKILL LIBRARY TRAIT ────────────────────────────────────────────────────
282
283pub trait SkillLibrary: Send + Sync {
284    fn relevant(&self, ctx: &str, limit: usize) -> Vec<Skill>;
285    fn add(&self, skill: Skill) -> anyhow::Result<()>;
286    fn all(&self) -> Vec<Skill>;
287    fn curate(&self) -> anyhow::Result<()>;
288    fn prune(&self, min_score: f64) -> anyhow::Result<usize>;
289    fn get(&self, name: &str) -> Option<Skill>;
290    fn invoke(&self, name: &str) -> anyhow::Result<Option<SkillInvocation>>;
291    /// Remove a skill by name (any kind). Returns true if it existed.
292    fn remove(&self, name: &str) -> anyhow::Result<bool>;
293    /// On-disk root for the library, if any. In-memory implementations
294    /// return None; `sparrow skills update` reads SKILL.md from here.
295    fn skills_root(&self) -> Option<std::path::PathBuf> {
296        None
297    }
298}
299
300// ─── Filesystem-backed skill library ────────────────────────────────────────────
301
302pub struct FsSkillLibrary {
303    skills_dir: PathBuf,
304    memory: Option<Arc<dyn Memory>>,
305}
306
307impl FsSkillLibrary {
308    pub fn new(skills_dir: PathBuf) -> Self {
309        std::fs::create_dir_all(&skills_dir).ok();
310        Self {
311            skills_dir,
312            memory: None,
313        }
314    }
315
316    pub fn with_memory(mut self, memory: Arc<dyn Memory>) -> Self {
317        self.memory = Some(memory);
318        self
319    }
320
321    /// Path to the skills root. Exposed so commands like
322    /// `sparrow skills update` can re-read a skill from disk without going
323    /// through the cached library view.
324    pub fn skills_dir(&self) -> &std::path::Path {
325        &self.skills_dir
326    }
327
328    /// Scan the skills directory and load all SKILL.md files
329    pub fn scan(&self) -> Vec<Skill> {
330        let mut skills = Vec::new();
331        if let Ok(entries) = std::fs::read_dir(&self.skills_dir) {
332            for entry in entries.flatten() {
333                let path = entry.path();
334                if path.is_dir() {
335                    // Look for SKILL.md inside subdirectories
336                    let skill_file = path.join("SKILL.md");
337                    if skill_file.exists() {
338                        if let Ok(content) = std::fs::read_to_string(&skill_file) {
339                            let rel = path
340                                .file_name()
341                                .map(|n| n.to_string_lossy().to_string())
342                                .unwrap_or_default();
343                            if let Some(mut skill) = Skill::from_markdown(&content, &rel) {
344                                apply_skill_manifest(&mut skill, &path);
345                                skills.push(skill);
346                            }
347                        }
348                    }
349                } else if path
350                    .file_name()
351                    .map(|n| n.to_string_lossy().to_lowercase().ends_with(".skill.md"))
352                    .unwrap_or(false)
353                {
354                    if let Ok(content) = std::fs::read_to_string(&path) {
355                        let rel = path
356                            .file_name()
357                            .map(|n| n.to_string_lossy().to_string())
358                            .unwrap_or_default();
359                        if let Some(mut skill) = Skill::from_markdown(&content, &rel) {
360                            apply_skill_manifest(
361                                &mut skill,
362                                path.parent().unwrap_or(&self.skills_dir),
363                            );
364                            skills.push(skill);
365                        }
366                    }
367                }
368            }
369        }
370        skills
371    }
372}
373
374impl SkillLibrary for FsSkillLibrary {
375    fn skills_root(&self) -> Option<std::path::PathBuf> {
376        Some(self.skills_dir.clone())
377    }
378
379    fn relevant(&self, ctx: &str, limit: usize) -> Vec<Skill> {
380        let mut scored: Vec<(f64, Skill)> = self
381            .scan()
382            .into_iter()
383            .map(|s| {
384                let r = s.relevance(ctx);
385                (r, s)
386            })
387            .filter(|(r, _)| *r > 0.0)
388            .collect();
389
390        scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
391
392        scored.into_iter().take(limit).map(|(_, s)| s).collect()
393    }
394
395    fn add(&self, skill: Skill) -> anyhow::Result<()> {
396        // Create a directory for the skill
397        let skill_dir = self.skills_dir.join(safe_skill_dir_name(&skill.name)?);
398        std::fs::create_dir_all(&skill_dir)?;
399
400        let skill_file = skill_dir.join("SKILL.md");
401        let content = skill.to_markdown();
402        std::fs::write(&skill_file, content)?;
403
404        // Also store in memory if available
405        if let Some(mem) = &self.memory {
406            let _ = mem.upsert_doc(crate::memory::WorkingDoc {
407                id: format!("skill-{}", skill.name),
408                title: format!("Skill: {}", skill.name),
409                content: skill.body.clone(),
410                updated_at: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(),
411            });
412        }
413
414        Ok(())
415    }
416
417    fn all(&self) -> Vec<Skill> {
418        self.scan()
419    }
420
421    fn curate(&self) -> anyhow::Result<()> {
422        let curator = Curator::new();
423        curator.curate(&self.skills_dir)
424    }
425
426    fn prune(&self, min_score: f64) -> anyhow::Result<usize> {
427        let skills = self.scan();
428        let mut removed = 0;
429
430        for skill in &skills {
431            if skill.score < min_score && skill.auto_generated {
432                let skill_dir = self.skills_dir.join(safe_skill_dir_name(&skill.name)?);
433                if skill_dir.exists() {
434                    std::fs::remove_dir_all(&skill_dir)?;
435                    removed += 1;
436                }
437            }
438        }
439
440        Ok(removed)
441    }
442
443    fn get(&self, name: &str) -> Option<Skill> {
444        self.scan().into_iter().find(|s| s.name == name)
445    }
446
447    fn invoke(&self, name: &str) -> anyhow::Result<Option<SkillInvocation>> {
448        let Some(skill) = self.get(name) else {
449            return Ok(None);
450        };
451        let base = if skill.source_file.ends_with(".skill.md") {
452            self.skills_dir.clone()
453        } else {
454            self.skills_dir.join(&skill.source_file)
455        };
456        let load_files = |files: &[String]| -> Vec<(String, String)> {
457            let mut loaded = Vec::new();
458            for f in files {
459                let Ok(candidate) = safe_relative_path(&base, f) else {
460                    continue;
461                };
462                if let Ok(content) = std::fs::read_to_string(&candidate) {
463                    loaded.push((f.clone(), content));
464                }
465            }
466            loaded
467        };
468        Ok(Some(SkillInvocation {
469            loaded_references: load_files(&skill.references),
470            loaded_templates: load_files(&skill.templates),
471            loaded_scripts: load_files(&skill.scripts),
472            loaded_assets: load_files(&skill.assets),
473            skill,
474        }))
475    }
476
477    fn remove(&self, name: &str) -> anyhow::Result<bool> {
478        // Skills live under a directory equal to their name. Remove it.
479        let skill_dir = self.skills_dir.join(safe_skill_dir_name(name)?);
480        let existed = skill_dir.exists();
481        if existed {
482            std::fs::remove_dir_all(&skill_dir)?;
483        }
484        Ok(existed)
485    }
486}
487
488fn safe_skill_dir_name(name: &str) -> anyhow::Result<String> {
489    let trimmed = name.trim();
490    if trimmed.is_empty()
491        || trimmed.contains("..")
492        || trimmed.contains('/')
493        || trimmed.contains('\\')
494        || trimmed.contains(':')
495    {
496        anyhow::bail!("invalid skill name '{}'", name);
497    }
498    Ok(trimmed.to_string())
499}
500
501fn safe_relative_path(base: &Path, relative: &str) -> anyhow::Result<PathBuf> {
502    let rel = Path::new(relative);
503    if rel.is_absolute()
504        || rel
505            .components()
506            .any(|c| matches!(c, std::path::Component::ParentDir))
507    {
508        anyhow::bail!("skill reference escapes skill directory: {}", relative);
509    }
510    let candidate = base.join(rel);
511    let canonical_base = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
512    let canonical_candidate = candidate
513        .canonicalize()
514        .unwrap_or_else(|_| candidate.to_path_buf());
515    if !canonical_candidate.starts_with(&canonical_base) || !canonical_candidate.exists() {
516        anyhow::bail!("skill reference outside base or missing: {}", relative);
517    }
518    Ok(canonical_candidate)
519}
520
521// ─── Curator: self-improvement loop ─────────────────────────────────────────────
522
523pub struct Curator {
524    min_score: f64,
525    max_skills: usize,
526}
527
528impl Curator {
529    pub fn new() -> Self {
530        Self {
531            min_score: 0.2,
532            max_skills: 100,
533        }
534    }
535
536    /// Grade, consolidate, dedupe, and prune the skill library.
537    /// This is the closed learning loop — skills are created from experience
538    /// and improved during use.
539    pub fn curate(&self, skills_dir: &Path) -> anyhow::Result<()> {
540        let library = FsSkillLibrary::new(skills_dir.to_path_buf());
541        let mut skills = library.all();
542
543        if skills.is_empty() {
544            return Ok(());
545        }
546
547        // 0. Purge poisoned skills (G1): any skill whose description or body
548        //    carries UI-status leakage or a user complaint is deleted from
549        //    disk — it must never be re-injected into a system prompt.
550        skills.retain(|s| {
551            let poisoned = is_unfit_for_skill(&s.description) || is_unfit_for_skill(&s.body);
552            if poisoned {
553                if let Ok(dir_name) = safe_skill_dir_name(&s.name) {
554                    let _ = std::fs::remove_dir_all(skills_dir.join(dir_name));
555                }
556                tracing::warn!("Curator: purged poisoned skill `{}`", s.name);
557            }
558            !poisoned
559        });
560
561        if skills.is_empty() {
562            return Ok(());
563        }
564
565        // 1. Grade: score each skill based on usage and content quality
566        for skill in &mut skills {
567            // More usage = higher score
568            skill.score += skill.usage_count as f64 * 0.05;
569            // Longer body = more detailed = slightly higher score
570            skill.score += (skill.body.len() as f64 / 5000.0).min(0.1);
571            // Cap at 1.0
572            skill.score = skill.score.min(1.0);
573        }
574
575        // 2. Dedupe: merge skills with similar names or overlapping triggers
576        let mut merged = Vec::new();
577        let mut merged_indices = std::collections::HashSet::new();
578
579        for i in 0..skills.len() {
580            if merged_indices.contains(&i) {
581                continue;
582            }
583            let mut current = skills[i].clone();
584
585            for j in (i + 1)..skills.len() {
586                if merged_indices.contains(&j) {
587                    continue;
588                }
589                // Check similarity: same first 3 chars of name, or >50% trigger overlap
590                let name_overlap = current.name
591                    [..current.name.len().min(3).min(skills[j].name.len())]
592                    == skills[j].name[..skills[j].name.len().min(3).min(current.name.len())];
593
594                let trigger_overlap = {
595                    let a: std::collections::HashSet<_> = current.trigger.iter().cloned().collect();
596                    let b: std::collections::HashSet<_> =
597                        skills[j].trigger.iter().cloned().collect();
598                    let intersection = a.intersection(&b).count();
599                    let union = a.union(&b).count();
600                    if union == 0 {
601                        false
602                    } else {
603                        intersection as f64 / union as f64 > 0.5
604                    }
605                };
606
607                if name_overlap || trigger_overlap {
608                    // Merge: combine bodies, take higher score
609                    current.body = format!("{}\n\n---\n\n{}", current.body, skills[j].body);
610                    current.score = current.score.max(skills[j].score);
611                    current.trigger.extend(skills[j].trigger.clone());
612                    current.trigger.sort();
613                    current.trigger.dedup();
614                    merged_indices.insert(j);
615                }
616            }
617            merged.push(current);
618        }
619
620        // 3. Prune: remove low-score auto-generated skills, keep total under max
621        merged.retain(|s| !s.auto_generated || s.score >= self.min_score);
622        merged.sort_by(|a, b| {
623            b.score
624                .partial_cmp(&a.score)
625                .unwrap_or(std::cmp::Ordering::Equal)
626        });
627        if merged.len() > self.max_skills {
628            merged.truncate(self.max_skills);
629        }
630
631        // 4. Write back updated SKILL.md files without deleting references,
632        // templates, scripts or assets stored beside them.
633        for skill in &merged {
634            let skill_dir = skills_dir.join(safe_skill_dir_name(&skill.name)?);
635            std::fs::create_dir_all(&skill_dir)?;
636            std::fs::write(skill_dir.join("SKILL.md"), skill.to_markdown())?;
637        }
638
639        tracing::info!(
640            "Curator: {} skills before → {} after (deduped {}, pruned {})",
641            skills.len(),
642            merged.len(),
643            skills.len() - merged_indices.len(),
644            skills.len() + merged_indices.len() - merged.len() - skills.len().min(merged.len()),
645        );
646
647        Ok(())
648    }
649
650    /// Generate a skill candidate from a successful run trajectory.
651    /// Called by the engine after a `RunFinished` event.
652    pub fn propose_skill(run_description: &str, outcome: &str) -> Option<Skill> {
653        let words: Vec<&str> = run_description.split_whitespace().collect();
654        let lower = run_description.to_lowercase();
655        let outcome_lower = outcome.to_lowercase();
656
657        if words.len() < 5 || outcome_lower.contains("error") {
658            return None;
659        }
660
661        // G1: never learn from UI-status leakage or a user complaint. The
662        // poisoned `code-review` skill on disk was born from a task description
663        // polluted with cockpit status text ("coder ◌ consulting … parsing
664        // request…") and an outcome carrying "✓ coder completed · 4487↑ 150↓
665        // tok" plus a frustrated correction. None of that is a reusable skill.
666        if is_unfit_for_skill(run_description) || is_unfit_for_skill(outcome) {
667            return None;
668        }
669
670        let specificity_markers = [
671            "github.com",
672            "http",
673            "https",
674            "this ",
675            "that ",
676            "the file",
677            "my ",
678            "your ",
679            "2024",
680            "2025",
681            "2026",
682        ];
683        if specificity_markers
684            .iter()
685            .any(|marker| lower.contains(marker))
686        {
687            return None;
688        }
689        if words.iter().any(|word| {
690            let cleaned = word.trim_matches(|c: char| !c.is_alphanumeric());
691            // Only bail on long proper-noun-looking tokens (> 12 chars, starts uppercase).
692            // Shorter capitalized words (structs, types) are normal in coding tasks.
693            cleaned
694                .chars()
695                .next()
696                .map(|c| c.is_uppercase())
697                .unwrap_or(false)
698                && cleaned.chars().count() > 12
699        }) {
700            return None;
701        }
702
703        let has_concrete_output = [
704            "diff", "fn ", "struct ", "impl ", "test", "fixed", "refactor", "added", "updated",
705            "created", "modified", "patch", "write", "edit", "return", "async", "pub ", "let ",
706            "const ", "mod ",
707        ]
708        .iter()
709        .any(|needle| outcome_lower.contains(needle));
710        if !has_concrete_output {
711            return None;
712        }
713
714        let name = skill_name_from_pattern(run_description)?.to_string();
715        let triggers = skill_triggers_for_pattern(&name);
716
717        Some(Skill {
718            name,
719            description: format!("Reusable pattern learned from: {}", run_description),
720            trigger: triggers,
721            body: format!(
722                "## Context\nTask: {}\n\n## Approach\n{}",
723                run_description, outcome
724            ),
725            source_file: String::new(),
726            usage_count: 0,
727            created_at: chrono::Utc::now().format("%Y-%m-%d").to_string(),
728            score: 0.3,
729            auto_generated: true,
730            references: Vec::new(),
731            templates: Vec::new(),
732            scripts: Vec::new(),
733            assets: Vec::new(),
734            manifest_version: None,
735            allowed_tools: Vec::new(),
736        })
737    }
738
739    pub fn propose_skill_if_missing(
740        run_description: &str,
741        outcome: &str,
742        library: &dyn SkillLibrary,
743    ) -> Option<Skill> {
744        let candidate = Self::propose_skill(run_description, outcome)?;
745        if library.get(&candidate.name).is_some() {
746            None
747        } else {
748            Some(candidate)
749        }
750    }
751}
752
753/// G1: signatures of text that must NEVER become a skill — cockpit/UI status
754/// leakage and user complaints/corrections. Used to reject both the task
755/// description and the outcome before a skill is proposed, and to recognise
756/// already-poisoned skills on disk for purging.
757pub fn is_unfit_for_skill(text: &str) -> bool {
758    let lower = text.to_lowercase();
759    // UI / cockpit status artifacts that should never reach the curator.
760    const UI_ARTIFACTS: &[&str] = &[
761        "◌",
762        "consulting ",
763        "parsing request",
764        "↑",
765        "↓",
766        "completed ·",
767        "route set",
768        "reusable pattern learned from",
769        "metrics captured",
770    ];
771    if UI_ARTIFACTS.iter().any(|m| lower.contains(m)) {
772        return true;
773    }
774    // Complaint / correction markers — the user telling Sparrow it failed.
775    const COMPLAINT_MARKERS: &[&str] = &[
776        "tu as vraiment un problème",
777        "regarde ce que tu m'as",
778        "n'importe quoi",
779        "ça marche pas",
780        "ne marche pas",
781        "you have a problem",
782        "this is broken",
783        "that's wrong",
784    ];
785    if COMPLAINT_MARKERS.iter().any(|m| lower.contains(m)) {
786        return true;
787    }
788    false
789}
790
791pub fn skill_name_from_pattern(description: &str) -> Option<&'static str> {
792    let d = description.to_lowercase();
793    if d.contains("test") && (d.contains("add") || d.contains("write") || d.contains("fix")) {
794        return Some("write-and-fix-tests");
795    }
796    if d.contains("refactor") || d.contains("rename") || d.contains("extract") {
797        return Some("refactor-safely");
798    }
799    if d.contains("debug") || d.contains("error") || d.contains("panic") || d.contains("crash") {
800        return Some("debug-systematically");
801    }
802    if d.contains("document")
803        || d.contains("comment")
804        || d.contains("readme")
805        || d.contains("docstring")
806    {
807        return Some("write-docs");
808    }
809    if d.contains("secur") || d.contains("vulnerab") || d.contains("audit") {
810        return Some("security-audit");
811    }
812    if d.contains("performance") || d.contains("slow") || d.contains("optim") || d.contains("bench")
813    {
814        return Some("performance-profile");
815    }
816    if d.contains("upgrade") || d.contains("bump") || d.contains("depend") || d.contains("package")
817    {
818        return Some("upgrade-dependencies");
819    }
820    if d.contains("review") || d.contains("pr") || d.contains("pull request") || d.contains("diff")
821    {
822        return Some("code-review");
823    }
824    if d.contains("git") || d.contains("commit") || d.contains("branch") || d.contains("merge") {
825        return Some("git-workflow");
826    }
827    None
828}
829
830fn skill_triggers_for_pattern(name: &str) -> Vec<String> {
831    match name {
832        "write-and-fix-tests" => vec!["test", "unit", "fix", "assert"],
833        "refactor-safely" => vec!["refactor", "rename", "extract", "safe"],
834        "debug-systematically" => vec!["debug", "error", "panic", "crash"],
835        "write-docs" => vec!["document", "readme", "comment", "docstring"],
836        "security-audit" => vec!["security", "audit", "vulnerability", "safe"],
837        "performance-profile" => vec!["performance", "slow", "optimize", "bench"],
838        "upgrade-dependencies" => vec!["upgrade", "bump", "dependency", "package"],
839        "code-review" => vec!["review", "pr", "diff", "pull-request"],
840        "git-workflow" => vec!["git", "commit", "branch", "merge"],
841        _ => vec!["skill"],
842    }
843    .into_iter()
844    .map(String::from)
845    .collect()
846}
847
848impl Default for Curator {
849    fn default() -> Self {
850        Self::new()
851    }
852}
853
854#[cfg(test)]
855mod tests {
856    use super::*;
857
858    fn temp_dir(name: &str) -> PathBuf {
859        std::env::temp_dir().join(format!(
860            "sparrow-tier2-{name}-{}",
861            std::time::SystemTime::now()
862                .duration_since(std::time::UNIX_EPOCH)
863                .unwrap()
864                .as_nanos()
865        ))
866    }
867
868    #[test]
869    fn skill_invocation_rejects_parent_dir_references() {
870        let root = temp_dir("skill-ref-escape");
871        std::fs::create_dir_all(root.join("review").join("references")).unwrap();
872        std::fs::write(
873            root.join("review").join("SKILL.md"),
874            "# Skill: review\n\n**Trigger:** review\n\n**References:** ../secret.txt, references/checklist.md\n\n## Body\nReview carefully.",
875        )
876        .unwrap();
877        std::fs::write(
878            root.join("review").join("references").join("checklist.md"),
879            "ok",
880        )
881        .unwrap();
882        std::fs::write(root.join("secret.txt"), "nope").unwrap();
883
884        let lib = FsSkillLibrary::new(root.clone());
885        let invocation = lib.invoke("review").unwrap().expect("skill should exist");
886
887        assert_eq!(invocation.loaded_references.len(), 1);
888        assert_eq!(invocation.loaded_references[0].0, "references/checklist.md");
889
890        let _ = std::fs::remove_dir_all(root);
891    }
892
893    #[test]
894    fn curator_preserves_skill_assets_and_updates_skill_md_only() {
895        let root = temp_dir("curator-assets");
896        let skill_dir = root.join("refactor-safely");
897        std::fs::create_dir_all(skill_dir.join("references")).unwrap();
898        std::fs::write(skill_dir.join("references").join("checklist.md"), "keep me").unwrap();
899        std::fs::write(
900            skill_dir.join("SKILL.md"),
901            "# Skill: refactor-safely\n\n**Trigger:** refactor, rename\n\n**References:** references/checklist.md\n\n## Body\nMove in small steps.",
902        )
903        .unwrap();
904
905        Curator::new().curate(&root).unwrap();
906
907        assert!(
908            skill_dir.join("references").join("checklist.md").exists(),
909            "curator must not delete progressive-disclosure assets"
910        );
911        let lib = FsSkillLibrary::new(root.clone());
912        let invocation = lib
913            .invoke("refactor-safely")
914            .unwrap()
915            .expect("skill should remain");
916        assert_eq!(invocation.loaded_references[0].1, "keep me");
917
918        let _ = std::fs::remove_dir_all(root);
919    }
920
921    #[test]
922    fn curator_purges_poisoned_skill_from_disk() {
923        // G1: a skill whose description is a complaint laced with UI status
924        // text must be deleted on the next curate pass.
925        let root = temp_dir("curator-purge");
926        let toxic = root.join("code-review");
927        std::fs::create_dir_all(&toxic).unwrap();
928        std::fs::write(
929            toxic.join("SKILL.md"),
930            "# 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",
931        )
932        .unwrap();
933        // A legitimate skill must survive.
934        let good = root.join("refactor-safely");
935        std::fs::create_dir_all(&good).unwrap();
936        std::fs::write(
937            good.join("SKILL.md"),
938            "# Skill: refactor-safely\n\n**Trigger:** refactor\n\n**Description:** Move code in small verified steps.\n\n## Body\nExtract, compile, test, repeat.",
939        )
940        .unwrap();
941
942        Curator::new().curate(&root).unwrap();
943
944        assert!(!toxic.exists(), "poisoned skill dir must be removed");
945        assert!(good.exists(), "legitimate skill must survive");
946
947        let _ = std::fs::remove_dir_all(root);
948    }
949
950    #[test]
951    fn propose_skill_rejects_ui_status_and_complaints() {
952        // The exact polluted inputs that produced the poisoned skill.
953        assert!(
954            Curator::propose_skill(
955                "non tu as vraiment un problème regarde ce que tu m'as écris coder",
956                "✓ coder completed · 4487↑ 150↓ tok",
957            )
958            .is_none()
959        );
960        assert!(is_unfit_for_skill(
961            "coder ◌ consulting deepseek-v4-pro · parsing request…"
962        ));
963        assert!(!is_unfit_for_skill(
964            "Refactor the auth module by extracting the token parser into its own function."
965        ));
966    }
967
968    #[test]
969    fn skill_names_cannot_escape_skill_root() {
970        let root = temp_dir("skill-name-escape");
971        let lib = FsSkillLibrary::new(root.clone());
972        let skill = Skill {
973            name: "../outside".into(),
974            description: "bad".into(),
975            trigger: vec!["bad".into()],
976            body: "bad".into(),
977            source_file: String::new(),
978            usage_count: 0,
979            created_at: String::new(),
980            score: 0.5,
981            auto_generated: false,
982            references: Vec::new(),
983            templates: Vec::new(),
984            scripts: Vec::new(),
985            assets: Vec::new(),
986            manifest_version: None,
987            allowed_tools: Vec::new(),
988        };
989
990        assert!(lib.add(skill).is_err());
991        assert!(!root.join("..").join("outside").exists());
992
993        let _ = std::fs::remove_dir_all(root);
994    }
995
996    #[test]
997    fn skill_manifest_restricts_tool_specs() {
998        let root = temp_dir("skill-manifest-tools");
999        let skill_dir = root.join("read-only-review");
1000        std::fs::create_dir_all(&skill_dir).unwrap();
1001        std::fs::write(
1002            skill_dir.join("SKILL.md"),
1003            "# Skill: read-only-review\n\n**Trigger:** review\n\n## Body\nInspect only.",
1004        )
1005        .unwrap();
1006        std::fs::write(
1007            skill_dir.join("manifest.toml"),
1008            "version = \"2\"\nallowed_tools = [\"fs_read\"]\n",
1009        )
1010        .unwrap();
1011
1012        let lib = FsSkillLibrary::new(root.clone());
1013        let invocation = lib
1014            .invoke("read-only-review")
1015            .unwrap()
1016            .expect("skill should load");
1017        assert_eq!(invocation.skill.manifest_version.as_deref(), Some("2"));
1018        assert_eq!(invocation.skill.allowed_tools, vec!["fs_read"]);
1019
1020        let mut registry = crate::tools::ToolRegistry::new();
1021        registry.register(std::sync::Arc::new(crate::tools::fs::FsRead));
1022        registry.register(std::sync::Arc::new(crate::tools::fs::FsWrite));
1023        let specs = registry.to_specs_for_skill(&invocation.skill);
1024        let names: Vec<_> = specs.into_iter().map(|s| s.name).collect();
1025        assert_eq!(names, vec!["fs_read"]);
1026
1027        let _ = std::fs::remove_dir_all(root);
1028    }
1029}