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#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Skill {
15 pub name: String,
16 pub description: String,
17 pub trigger: Vec<String>,
19 pub body: String,
21 #[serde(default)]
23 pub source_file: String,
24 #[serde(default)]
26 pub usage_count: u32,
27 #[serde(default)]
29 pub created_at: String,
30 #[serde(default = "default_score")]
32 pub score: f64,
33 #[serde(default)]
35 pub auto_generated: bool,
36 #[serde(default)]
38 pub references: Vec<String>,
39 #[serde(default)]
41 pub templates: Vec<String>,
42 #[serde(default)]
44 pub scripts: Vec<String>,
45 #[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 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 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 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 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
231pub 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 fn remove(&self, name: &str) -> anyhow::Result<bool>;
243 fn skills_root(&self) -> Option<std::path::PathBuf> {
246 None
247 }
248}
249
250pub 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 pub fn skills_dir(&self) -> &std::path::Path {
275 &self.skills_dir
276 }
277
278 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 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 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 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 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
466pub 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 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 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 for skill in &mut skills {
512 skill.score += skill.usage_count as f64 * 0.05;
514 skill.score += (skill.body.len() as f64 / 5000.0).min(0.1);
516 skill.score = skill.score.min(1.0);
518 }
519
520 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 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 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 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 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 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 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 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
696pub fn is_unfit_for_skill(text: &str) -> bool {
701 let lower = text.to_lowercase();
702 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 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 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 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 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}