//! Skills: markdown packages with frontmatter, discovered from //! `.vak/skills//SKILL.md` (project) and //! `/skills//SKILL.md` (user). Discovery metadata enters the //! capability packet; the brokered `skill` loader returns full content. use async_trait::async_trait; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; #[derive(Debug, Clone, PartialEq)] pub struct Skill { pub name: String, pub description: String, pub path: PathBuf, pub provenance: Option, pub shadowed: bool, /// Optional `serves:` frontmatter — what this skill is for, in its own /// words (`serves: documents, live-data`). `None` means undeclared, /// which is never narrowed away by the per-turn capability slice. See /// `crate::capability::domain`. pub serves: Option>, } /// A skill candidate that was found on disk but failed to parse. /// Returned by [`discover_with_diagnostics`] so inspection surfaces /// can explain *why* a skill is missing rather than leaving the /// operator to guess. #[derive(Debug, Clone, PartialEq)] pub struct SkillDiagnostic { /// Absolute path to the SKILL.md that failed. pub path: PathBuf, /// Human-readable reason the parse failed. pub reason: String, /// Provenance label if the root was a plugin. pub provenance: Option, } impl Skill { pub fn digest(&self) -> Result { let bytes = std::fs::read(&self.path)?; Ok(format!("{:x}", Sha256::digest(bytes))) } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct FrozenSkill { pub name: String, pub description: String, pub path: PathBuf, pub digest: String, pub provenance: Option, } impl FrozenSkill { pub fn load(&self) -> Result { let bytes = std::fs::read(&self.path).map_err(|error| { format!( r#"{{"type":"capability_unavailable","kind":"skill","name":{},"message":{}}}"#, json_string(&self.name), json_string(&error.to_string()) ) })?; let actual = format!("{:x}", Sha256::digest(&bytes)); if actual != self.digest { return Err(format!( r#"{{"type":"capability_stale","kind":"skill","name":{},"message":"skill changed after session admission; start a new session"}}"#, json_string(&self.name) )); } let content = String::from_utf8(bytes).map_err(|error| { format!( r#"{{"type":"capability_invalid","kind":"skill","name":{},"message":{}}}"#, json_string(&self.name), json_string(&error.to_string()) ) })?; let body = strip_frontmatter(&content).trim(); let base = self.path.parent().unwrap_or(Path::new(".")); let provenance = self.provenance.as_deref().unwrap_or("workspace-or-user"); Ok(format!( "\nReferences are relative to {}.\n\n{}\n", self.name, self.path.display(), provenance, base.display(), body )) } } pub fn frozen_from_capabilities( capabilities: &[vak_session::types::CapabilityDescriptor], ) -> Vec { capabilities .iter() .filter(|capability| capability.kind == vak_session::types::CapabilityKind::Skill) .filter_map(|capability| { Some(FrozenSkill { name: capability.name.clone(), description: capability.description.clone(), path: capability.source.clone()?, digest: capability.digest.clone()?, provenance: capability.provenance.clone(), }) }) .collect() } pub fn expand_invocation(input: &str, skills: &[FrozenSkill]) -> Result, String> { let trimmed = input.trim_start(); let Some(rest) = trimmed.strip_prefix("/skill:") else { return Ok(None); }; let mut parts = rest.splitn(2, char::is_whitespace); let name = parts.next().unwrap_or_default(); let Some(skill) = skills.iter().find(|skill| skill.name == name) else { return Err(format!( r#"{{"type":"capability_not_admitted","kind":"skill","name":{}}}"#, json_string(name) )); }; let block = skill.load()?; let args = parts.next().unwrap_or_default().trim(); Ok(Some(if args.is_empty() { block } else { format!("{block}\n\n{args}") })) } #[derive(Debug, Clone)] pub struct SkillTool { skills: BTreeMap, description: String, } impl SkillTool { /// Declared as a constant so capability declarations can read it /// without the turn's admitted skill set. pub const SERVES: &'static [&'static str] = &["documents", "orchestration"]; /// The skill catalogue itself is in the prompt (`prompt_section_from_capabilities`), /// once; the tool carries only the admitted names, as its schema enum. pub fn new(skills: impl IntoIterator) -> Self { let skills = skills .into_iter() .map(|skill| (skill.name.clone(), skill)) .collect::>(); Self { skills, description: "Load one skill document by its exact name from the skills listed \ in your instructions. Skills are instructions, not executable functions." .into(), } } } #[async_trait] impl vak_tools::Tool for SkillTool { fn name(&self) -> &str { "skill" } fn serves(&self) -> &'static [&'static str] { Self::SERVES } fn always_loaded(&self) -> bool { true } fn description(&self) -> &str { &self.description } fn schema(&self) -> Value { json!({ "type": "object", "properties": { "name": { "type": "string", "enum": self.skills.keys().collect::>(), "description": "Exact admitted skill name" } }, "required": ["name"], "additionalProperties": false }) } async fn execute(&self, args: &Value, _ctx: &vak_tools::ToolContext) -> vak_tools::ToolOutput { let Some(name) = args.get("name").and_then(Value::as_str) else { return vak_tools::ToolOutput::error( r#"{"type":"invalid_arguments","capability":"skill","message":"missing required string 'name'"}"#, ); }; let skill = self .skills .get(name) .or_else(|| self.skills.get(&name.replace('_', "-"))) .or_else(|| self.skills.get(&name.replace('-', "_"))); let Some(skill) = skill else { return vak_tools::ToolOutput::error(format!( r#"{{"type":"capability_not_admitted","kind":"skill","name":{}}}"#, json_string(name) )); }; match skill.load() { Ok(content) => vak_tools::ToolOutput::ok(content), Err(error) => vak_tools::ToolOutput::error(error), } } fn claims(&self, _args: &Value) -> vak_tools::ResourceClaims { vak_tools::ResourceClaims { read_only: true, ..Default::default() } } } fn json_string(value: &str) -> String { serde_json::to_string(value).unwrap_or_else(|_| "\"invalid\"".into()) } fn strip_frontmatter(content: &str) -> &str { let Some(rest) = content.strip_prefix("---") else { return content; }; rest.split_once("\n---") .map_or(content, |(_, body)| body.trim_start_matches(['\r', '\n'])) } pub fn discover(cwd: &Path, home: &Path) -> Vec { discover_with_plugins(cwd, home, &[]) } pub fn discover_with_plugins(cwd: &Path, home: &Path, plugins: &[(PathBuf, String)]) -> Vec { discover_all_with_plugins(cwd, home, plugins) .into_iter() .filter(|skill| !skill.shadowed) .collect() } /// Like [`discover_with_plugins`] but also returns diagnostics for every /// skill candidate that was found on disk but failed validation. pub fn discover_with_diagnostics( cwd: &Path, home: &Path, plugins: &[(PathBuf, String)], ) -> (Vec, Vec) { let mut roots = vec![(cwd.join(".vak/skills"), None), (home.join("skills"), None)]; let agents_dir = home.join("agents"); if let Ok(entries) = std::fs::read_dir(&agents_dir) { for entry in entries.flatten() { let p = entry.path(); if p.is_dir() { roots.push((p.join("skills"), None)); } } } roots.extend( plugins .iter() .map(|(root, provenance)| (root.join("skills"), Some(provenance.clone()))), ); roots.dedup_by(|a, b| a.0 == b.0); let mut skills = Vec::new(); let mut diagnostics = Vec::new(); for (root, provenance) in roots { let Ok(entries) = std::fs::read_dir(&root) else { continue; }; for entry in entries.flatten() { let skill_path = entry.path().join("SKILL.md"); if !skill_path.is_file() { continue; } match validate(&skill_path) { Ok((mut skill, _warnings)) => { skill.provenance = provenance.clone(); skills.push(skill); } Err(reason) => { diagnostics.push(SkillDiagnostic { path: skill_path, reason, provenance: provenance.clone(), }); } } } } skills.sort_by(|a, b| a.name.cmp(&b.name)); let mut seen = std::collections::HashSet::new(); for skill in &mut skills { skill.shadowed = !seen.insert(skill.name.clone()); } skills.retain(|skill| !skill.shadowed); (skills, diagnostics) } /// Discovers every valid skill, retaining lower-precedence entries so /// inspection surfaces can explain why a skill is not active. pub fn discover_all_with_plugins( cwd: &Path, home: &Path, plugins: &[(PathBuf, String)], ) -> Vec { let mut roots = vec![(cwd.join(".vak/skills"), None), (home.join("skills"), None)]; let agents_dir = home.join("agents"); if let Ok(entries) = std::fs::read_dir(&agents_dir) { for entry in entries.flatten() { let p = entry.path(); if p.is_dir() { roots.push((p.join("skills"), None)); } } } roots.extend( plugins .iter() .map(|(root, provenance)| (root.join("skills"), Some(provenance.clone()))), ); roots.dedup_by(|a, b| a.0 == b.0); let mut out = Vec::new(); for (root, provenance) in roots { let Ok(entries) = std::fs::read_dir(&root) else { continue; }; for entry in entries.flatten() { let skill_path = entry.path().join("SKILL.md"); if !skill_path.is_file() { continue; } if let Some(mut skill) = parse(&skill_path) { skill.provenance = provenance.clone(); out.push(skill); } } } out.sort_by(|a, b| a.name.cmp(&b.name)); let mut seen = std::collections::HashSet::new(); for skill in &mut out { skill.shadowed = !seen.insert(skill.name.clone()); } out } pub fn parse(path: &Path) -> Option { validate(path).ok().map(|(skill, _)| skill) } pub fn validate(path: &Path) -> Result<(Skill, Vec), String> { let text = std::fs::read_to_string(path).map_err(|error| error.to_string())?; let rest = text .strip_prefix("---") .ok_or_else(|| "file must begin with YAML frontmatter delimiter ---".to_string())?; let (frontmatter, _) = rest .split_once("\n---") .ok_or_else(|| "frontmatter is missing its closing --- delimiter".to_string())?; let mut name = None; let mut description = None; let mut compatibility = None; let mut serves = None; let mut warnings = Vec::new(); for line in frontmatter.lines() { let line = line.trim(); if let Some(v) = line.strip_prefix("name:") { name = Some(v.trim().trim_matches('"').to_string()); } else if let Some(v) = line.strip_prefix("description:") { description = Some(v.trim().trim_matches('"').to_string()); } else if let Some(v) = line.strip_prefix("compatibility:") { compatibility = Some(v.trim().trim_matches('"').to_string()); } else if let Some(v) = line.strip_prefix("serves:") { // Accept `a, b` and `[a, b]`; an empty value is "declared // nothing", which is different from not declaring at all only // in that it is a mistake worth not silently honouring — so an // empty list stays `None` (undeclared) rather than becoming a // slice that matches nothing. let raw = v.trim().trim_matches(['[', ']'].as_slice()); let parsed: Vec = raw .split(',') .map(|part| part.trim().trim_matches('"').to_string()) .filter(|part| !part.is_empty()) .collect(); if !parsed.is_empty() { serves = Some(parsed); } } else if line.starts_with("allowed-tools:") { warnings.push("allowed-tools is advisory and never grants authorization".into()); } } let name = name.ok_or_else(|| "frontmatter requires name".to_string())?; if !valid_name(&name) { return Err(format!( "name '{name}' is not valid lowercase kebab-case (1-64 chars)" )); } let description = description.ok_or_else(|| "frontmatter requires description".to_string())?; if description.trim().is_empty() { return Err("description must not be empty".into()); } if description.chars().count() > 1024 { return Err("description must be at most 1024 characters".into()); } if compatibility.as_deref().is_some_and(str::is_empty) { return Err("compatibility must not be empty when provided".into()); } // Reject skills whose descriptions reference retired tool names // (e.g. `python_eval`, `react_preview`). A skill that instructs the // model to call a tool that no longer exists produces // `unknown_capability` errors and model hallucinations of tool output. // This is a hard rejection — the skill must not enter the capability // contract sent to the model (AGNS invariant 9: model catalogues are // discovered, never hardcoded). let lower = text.to_lowercase(); for tool in vak_tools::retired::RETIRED_TOOLS { let pattern = format!("`{}`", tool.name.to_lowercase()); if lower.contains(&pattern) { return Err(format!( "skill '{}' references retired tool '{}' — run `vak setup seed` \ to remove the containing plugin, or edit this SKILL.md to use \ the replacement: {}", name, tool.name, tool.replacement )); } } Ok(( Skill { name, description, path: path.to_path_buf(), provenance: None, shadowed: false, serves, }, warnings, )) } fn valid_name(name: &str) -> bool { !name.is_empty() && name.len() <= 64 && !name.starts_with('-') && !name.ends_with('-') && !name.contains("--") && name .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') } /// The one place skills are listed to the model: name and description, /// one line each. Bodies are loaded on demand through the `skill` tool, /// which checks the admitted digest. pub fn prompt_section_from_capabilities( capabilities: &[vak_session::types::CapabilityDescriptor], ) -> String { let skills = capabilities .iter() .filter(|capability| capability.kind == vak_session::types::CapabilityKind::Skill) .collect::>(); if skills.is_empty() { return String::new(); } let mut out = String::from( "\nSkills (load one with `skill({\"name\": \"...\"})` before relying on it; a skill name is never a tool name):\n", ); for skill in skills { let description = skill .description .split_whitespace() .collect::>() .join(" "); out.push_str(&format!("- `{}`: {}\n", skill.name, description)); } out } #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { use super::*; #[test] fn parser_requires_standard_name_and_description() -> Result<(), Box> { let dir = tempfile::tempdir()?; let path = dir.path().join("SKILL.md"); std::fs::write(&path, "---\nname: Good_Name\ndescription: bad\n---\nbody")?; assert!(parse(&path).is_none()); std::fs::write( &path, "---\nname: good-name\ndescription: useful\n---\nbody", )?; let skill = parse(&path).ok_or_else(|| std::io::Error::other("valid skill should parse"))?; assert_eq!(skill.name, "good-name"); Ok(()) } #[test] fn discovery_marks_lower_precedence_duplicates_shadowed() -> Result<(), Box> { let dir = tempfile::tempdir()?; let project = dir.path().join(".vak/skills/demo"); let home = dir.path().join("home/skills/demo"); std::fs::create_dir_all(&project)?; std::fs::create_dir_all(&home)?; let body = "---\nname: demo\ndescription: demo skill\n---\nbody"; std::fs::write(project.join("SKILL.md"), body)?; std::fs::write(home.join("SKILL.md"), body)?; let all = discover_all_with_plugins(dir.path(), &dir.path().join("home"), &[]); assert_eq!(all.len(), 2); assert!(!all[0].shadowed); assert!(all[1].shadowed); assert_eq!(discover(dir.path(), &dir.path().join("home")).len(), 1); Ok(()) } #[test] fn validation_reports_advisory_fields_and_rejects_long_descriptions() -> Result<(), Box> { let dir = tempfile::tempdir()?; let path = dir.path().join("SKILL.md"); std::fs::write( &path, "---\nname: safe-skill\ndescription: useful\nallowed-tools: Bash\n---\nbody", )?; let (_, warnings) = validate(&path).map_err(std::io::Error::other)?; assert_eq!( warnings, vec!["allowed-tools is advisory and never grants authorization"] ); std::fs::write( &path, format!( "---\nname: safe-skill\ndescription: {}\n---\nbody", "x".repeat(1025) ), )?; assert!(validate(&path).is_err()); Ok(()) } #[test] fn prompt_lists_each_skill_once_without_leaking_paths_or_bodies() { let skill = vak_session::types::CapabilityDescriptor { name: "code-task".into(), kind: vak_session::types::CapabilityKind::Skill, invocation: vak_session::types::CapabilityInvocation::ModelTool, description: "focused\n implementation".into(), source: Some("/workspace/.vak/skills/code-task/SKILL.md".into()), digest: Some("sha".into()), provenance: None, configuration: serde_json::Value::Null, }; let prompt = prompt_section_from_capabilities(&[skill]); assert!(prompt.contains("`skill({\"name\": \"...\"})`")); assert!(prompt.contains("- `code-task`: focused implementation\n")); assert!(!prompt.contains("/workspace/.vak/skills")); let tool = SkillTool::new([FrozenSkill { name: "code-task".into(), description: "focused implementation".into(), path: "/workspace/.vak/skills/code-task/SKILL.md".into(), digest: "sha".into(), provenance: None, }]); use vak_tools::Tool; assert!( !tool.description().contains("focused implementation"), "the catalogue is listed once, in the prompt" ); assert_eq!(tool.schema()["properties"]["name"]["enum"][0], "code-task"); } #[tokio::test] async fn skill_tool_loads_only_the_frozen_digest() -> Result<(), Box> { use vak_tools::Tool; let dir = tempfile::tempdir()?; let path = dir.path().join("SKILL.md"); std::fs::write( &path, "---\nname: code-task\ndescription: focused implementation\n---\nUse table-driven tests.", )?; let skill = parse(&path).ok_or("skill should parse")?; let digest = skill.digest()?; let tool = SkillTool::new([FrozenSkill { name: skill.name, description: skill.description, path: path.clone(), digest, provenance: None, }]); let ctx = vak_tools::ToolContext { cwd: dir.path().to_path_buf(), cancel: tokio_util::sync::CancellationToken::new(), sandbox: None, sandbox_sink: None, agent_id: None, new_documents: Vec::new(), }; let loaded = tool .execute(&serde_json::json!({"name": "code-task"}), &ctx) .await; assert!(!loaded.is_error); assert!(loaded.content.contains("Use table-driven tests.")); assert!(loaded.content.contains("References are relative to")); std::fs::write(&path, "changed after admission")?; let stale = tool .execute(&serde_json::json!({"name": "code-task"}), &ctx) .await; assert!(stale.is_error); assert!(stale.content.contains("capability_stale")); Ok(()) } #[test] fn discover_with_diagnostics_reports_parse_failures() -> Result<(), Box> { let dir = tempfile::tempdir()?; let good = dir.path().join(".vak/skills/good-skill"); let bad = dir.path().join(".vak/skills/Bad_Skill"); std::fs::create_dir_all(&good)?; std::fs::create_dir_all(&bad)?; std::fs::write( good.join("SKILL.md"), "---\nname: good-skill\ndescription: useful\n---\nbody", )?; std::fs::write( bad.join("SKILL.md"), "---\nname: Bad_Skill\ndescription: bad\n---\nbody", )?; let home = dir.path().join("home"); std::fs::create_dir_all(&home)?; let (skills, diagnostics) = discover_with_diagnostics(dir.path(), &home, &[]); assert_eq!(skills.len(), 1); assert_eq!(skills[0].name, "good-skill"); assert_eq!(diagnostics.len(), 1); assert!( diagnostics[0] .reason .contains("not valid lowercase kebab-case") ); Ok(()) } #[test] fn explicit_skill_command_expands_before_model_dispatch() -> Result<(), Box> { let dir = tempfile::tempdir()?; let path = dir.path().join("SKILL.md"); std::fs::write( &path, "---\nname: code-task\ndescription: focused implementation\n---\nFollow the workflow.", )?; let skill = parse(&path).ok_or("skill should parse")?; let digest = skill.digest()?; let frozen = FrozenSkill { name: skill.name, description: skill.description, path, digest, provenance: None, }; let expanded = expand_invocation("/skill:code-task fix parser", &[frozen])? .ok_or("command should expand")?; assert!(expanded.contains("