//! Load builtin skills from `skills/**/*.md` into `skills` + //! `skill_versions` at server boot. Slice 3.5c of the missions //! consolidation. //! //! Frontmatter shape (YAML between `---` fences): //! name: //! description: //! when_to_use: //! tags: [foundation, rust, ...] //! //! The body is the rest of the file. Both are upserted idempotently: //! `skills_catalog::upsert_builtin` bumps the version + appends to //! `skill_versions` ONLY when the body actually changes. use serde::Deserialize; use sha2::{Digest, Sha256}; use sqlx::PgPool; use std::path::PathBuf; use cm_db::repo::skills_catalog::{upsert_builtin, UpsertBuiltinSkill}; #[derive(Debug, Deserialize)] struct Frontmatter { name: String, description: String, #[serde(default)] when_to_use: Option, #[serde(default)] tags: Vec, } fn skills_dir() -> PathBuf { if let Ok(d) = std::env::var("CLAWMATES_SKILLS_DIR") { return PathBuf::from(d); } let container = PathBuf::from("/etc/clawmates/skills"); if container.exists() { return container; } PathBuf::from("skills") } /// Deterministic id per builtin skill name — sha256 of a stable /// namespace + the name. Matches the pattern used by the team-template /// loader so ids are reproducible across boots. fn builtin_id(name: &str) -> uuid::Uuid { let mut h = Sha256::new(); h.update(b"clawmates.builtin.skill\x00"); h.update(name.as_bytes()); let d = h.finalize(); let mut bytes = [0u8; 16]; bytes.copy_from_slice(&d[..16]); bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; uuid::Uuid::from_bytes(bytes) } /// Walk the skills tree + upsert every `*.md`. Returns the count of /// successful upserts. Failures are logged and skipped so a single /// broken skill can't block boot. pub async fn load_builtins(pool: &PgPool) -> usize { let dir = skills_dir(); let files = match walk_md(&dir) { Ok(v) => v, Err(e) => { eprintln!( "skills_loader: dir {} not readable: {e} — skipping builtin skills seed", dir.display() ); return 0; } }; let mut loaded = 0usize; for path in files { match load_one(pool, &path).await { Ok(name) => { loaded += 1; eprintln!("skills_loader: upserted builtin skill {name}"); } Err(e) => { eprintln!("skills_loader: failed to load {}: {e}", path.display()); } } } loaded } fn walk_md(root: &std::path::Path) -> std::io::Result> { let mut out = Vec::new(); fn recurse(p: &std::path::Path, out: &mut Vec) -> std::io::Result<()> { for entry in std::fs::read_dir(p)? { let entry = entry?; let path = entry.path(); if entry.file_type()?.is_dir() { recurse(&path, out)?; } else if path.extension().and_then(|s| s.to_str()) == Some("md") { out.push(path); } } Ok(()) } recurse(root, &mut out)?; out.sort(); Ok(out) } /// Parse the frontmatter + body out of one file and upsert. async fn load_one(pool: &PgPool, path: &std::path::Path) -> Result { let text = std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?; let (frontmatter, body) = split_frontmatter(&text) .ok_or_else(|| format!("no --- frontmatter block in {}", path.display()))?; let fm: Frontmatter = serde_yaml::from_str(frontmatter) .map_err(|e| format!("parse frontmatter of {}: {e}", path.display()))?; let skill = UpsertBuiltinSkill { id: builtin_id(&fm.name), name: &fm.name, description: &fm.description, when_to_use: fm.when_to_use.as_deref(), tags: fm.tags.clone(), body, }; upsert_builtin(pool, skill) .await .map_err(|e| format!("upsert {}: {e}", fm.name))?; Ok(fm.name) } /// Extract the `---\n\n---\n` shape. Returns `(yaml, body)` /// or None if the file doesn't start with a frontmatter fence. fn split_frontmatter(text: &str) -> Option<(&str, &str)> { let mut rest = text.strip_prefix("---\n")?; // Some editors add a BOM; strip a leading whitespace/newline pair. if rest.starts_with('\r') { rest = rest.strip_prefix('\r').unwrap_or(rest); } let end = rest.find("\n---\n")?; let yaml = &rest[..end]; let body = &rest[end + "\n---\n".len()..]; Some((yaml, body.trim_start())) } #[cfg(test)] mod tests { use super::*; #[test] fn splits_frontmatter() { let src = "---\nname: foo\ndescription: bar\n---\n# body\n"; let (fm, body) = split_frontmatter(src).unwrap(); assert!(fm.contains("name: foo")); assert_eq!(body, "# body\n"); } #[test] fn no_frontmatter_returns_none() { assert!(split_frontmatter("# plain md\n").is_none()); } #[test] fn builtin_id_stable() { assert_eq!( builtin_id("workspace-repo-commit-protocol"), builtin_id("workspace-repo-commit-protocol") ); assert_ne!( builtin_id("workspace-repo-commit-protocol"), builtin_id("other-skill") ); } } #[cfg(test)] mod contradiction_tests { use std::path::PathBuf; fn skills_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../skills") .canonicalize() .expect("skills dir") } fn all_skills() -> Vec<(String, String)> { fn walk(dir: &std::path::Path, out: &mut Vec<(String, String)>) { for e in std::fs::read_dir(dir).expect("read skills dir") { let p = e.expect("entry").path(); if p.is_dir() { walk(&p, out); } else if p.extension().and_then(|x| x.to_str()) == Some("md") { out.push(( p.file_name().unwrap().to_string_lossy().to_string(), std::fs::read_to_string(&p).expect("read skill"), )); } } } let mut out = Vec::new(); walk(&skills_root(), &mut out); out } /// No skill may teach a workspace path the platform does not use. /// /// `workspace-repo-commit-protocol` told agents that `/workspace/repo` was /// "the ONLY path where source-modifying edits belong". The platform mounts /// and advertises `/mission/repo` — in 26 places — and `/workspace/repo` /// appears nowhere in the code. The skill is pinned on 29 role bindings and /// was delivered twice in a single measured run, so agents received the /// platform's real path and a skill contradicting it in the SAME prompt. #[test] fn no_skill_teaches_a_repo_path_the_platform_does_not_mount() { let mut offenders = Vec::new(); for (name, body) in all_skills() { if body.contains("/workspace/repo") { offenders.push(name); } } assert!( offenders.is_empty(), "{} skill(s) name /workspace/repo; the mission checkout is \ /mission/repo, so an agent following them writes somewhere that is \ never delivered: {}", offenders.len(), offenders.join(", ") ); } /// No skill may instruct an agent to call a tool it does not have. /// /// Every mission turn ends in `claude -p`, so the tools are Claude Code's /// (`Read`/`Edit`/`Write`/`Bash`/`Glob`/`Grep`). `phase_task_text` used to /// advertise ZeroClaw's names and was fixed after five agents spent 7.4k /// tokens on one mission describing the mismatch instead of working — and /// the same wrong names survived inside a pinned skill. /// /// Matched as a backticked instruction, not as bare words: a skill may /// legitimately DISCUSS these names, as this one now does when warning /// against them. #[test] fn no_skill_instructs_an_agent_to_call_a_zeroclaw_tool() { const ZEROCLAW_TOOLS: &[&str] = &[ "`file_read`", "`file_write`", "`file_edit`", "`content_search`", "`glob_search`", ]; let mut offenders = Vec::new(); for (name, body) in all_skills() { // The line has to READ as an instruction. "Do not reach for // `file_read`" is the correction, not the defect. for line in body.lines() { let l = line.to_ascii_lowercase(); if l.contains("do not") || l.contains("never") || l.contains("instead of") || l.contains("not what") { continue; } if ZEROCLAW_TOOLS.iter().any(|t| line.contains(t)) { offenders.push(format!("{name}: {}", line.trim())); } } } assert!( offenders.is_empty(), "{} skill line(s) tell an agent to use a tool its subprocess does \ not expose:\n {}", offenders.len(), offenders.join("\n ") ); } /// No skill may show a marker the real parser rejects. /// /// Checked by running `task_card_parser::parse` itself, never a copy of its /// rules — a second implementation of the contract drifts, and then the /// test passes while the mission loop stalls. /// /// This is the third instance of one class: the skills were written /// alongside the platform and then never compared to it again. The first /// was a repo path the platform does not mount; the second a tool the agent /// does not have; this one is `PLAN_COMPLETE: INT-01..05` in /// `decompose-int-items`, which a live planner emitted verbatim. Ids are /// strictly `INT-`, so the range form parses to nothing — the plan /// pass records no completion at all while every item stays open. /// /// Scoped to fenced code blocks, which is where a skill puts the text it /// tells an agent to EMIT. A marker named in a sentence is prose. #[test] fn no_skill_shows_a_marker_the_parser_would_reject() { // The templates. `INT-NN` is a placeholder an agent substitutes, not a // literal it emits, so it is not a contradiction. const PLACEHOLDERS: &[&str] = &["INT-NN", "INT-XX", "INT-N", "INT-nn"]; let mut offenders = Vec::new(); for (name, body) in all_skills() { let mut fenced = false; for line in body.lines() { if line.trim_start().starts_with("```") { fenced = !fenced; continue; } let t = line.trim(); if !fenced || !t.contains("INT-") || !t.contains(':') { continue; } let Some((kind, _)) = t.split_once(':') else { continue; }; if !MARKER_KINDS.contains(&kind.trim()) { continue; } if PLACEHOLDERS.iter().any(|p| t.contains(p)) { continue; } if crate::task_card_parser::parse(t).is_empty() { offenders.push(format!("{name}: {t}")); } } } assert!( offenders.is_empty(), "{} skill line(s) show a marker the parser rejects — an agent that \ follows them exactly is silently ignored:\n {}", offenders.len(), offenders.join("\n ") ); } /// The marker kinds, as the parser spells them. const MARKER_KINDS: &[&str] = &[ "TASK", "PLAN_COMPLETE", "WORK", "HANDOFF", "TEST_PASS", "TEST_FAIL", "REVIEW_APPROVE", "REVIEW_BLOCK", "COMPLETED", ]; }