use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tracing::{debug, info, warn};
use clawft_types::skill::SkillDefinition;
use crate::pipeline::learner::TrajectoryLearner;
use crate::pipeline::mutation::{TrajectoryHint, auto_select_strategy, mutate_prompt};
use crate::security::intersect_path_allowlists;
pub const AUTOGEN_WORKSPACE_PATH: &str = ".";
pub const FILESYSTEM_FRONTMATTER_KEY: &str = "filesystem";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillFilesystemAllowlist {
pub readable_paths: Vec<String>,
pub writable_paths: Vec<String>,
pub allow_create: bool,
pub allow_delete: bool,
}
impl Default for SkillFilesystemAllowlist {
fn default() -> Self {
Self::minimal_autogen()
}
}
impl SkillFilesystemAllowlist {
pub fn minimal_autogen() -> Self {
Self {
readable_paths: vec![AUTOGEN_WORKSPACE_PATH.into()],
writable_paths: vec![AUTOGEN_WORKSPACE_PATH.into()],
allow_create: false,
allow_delete: false,
}
}
pub fn to_yaml_frontmatter(&self) -> String {
let mut out = String::from("filesystem:\n");
out.push_str(" readable-paths:\n");
if self.readable_paths.is_empty() {
out.push_str(" []\n");
} else {
for p in &self.readable_paths {
out.push_str(&format!(" - {p}\n"));
}
}
out.push_str(" writable-paths:\n");
if self.writable_paths.is_empty() {
out.push_str(" []\n");
} else {
for p in &self.writable_paths {
out.push_str(&format!(" - {p}\n"));
}
}
out.push_str(&format!(" allow-create: {}\n", self.allow_create));
out.push_str(&format!(" allow-delete: {}\n", self.allow_delete));
out
}
pub fn from_json(value: &serde_json::Value) -> Option<Self> {
let obj = value.as_object()?;
let readable = string_list_from_map(obj, "readable-paths")
.or_else(|| string_list_from_map(obj, "readable_paths"))
.unwrap_or_default();
let writable = string_list_from_map(obj, "writable-paths")
.or_else(|| string_list_from_map(obj, "writable_paths"))
.unwrap_or_default();
let allow_create = bool_from_map(obj, "allow-create")
.or_else(|| bool_from_map(obj, "allow_create"))
.unwrap_or(false);
let allow_delete = bool_from_map(obj, "allow-delete")
.or_else(|| bool_from_map(obj, "allow_delete"))
.unwrap_or(false);
Some(Self {
readable_paths: readable,
writable_paths: writable,
allow_create,
allow_delete,
})
}
pub fn from_skill(skill: &SkillDefinition) -> Option<Self> {
if let Some(value) = skill.metadata.get(FILESYSTEM_FRONTMATTER_KEY) {
return Self::from_json(value);
}
if is_autogenerated_skill(skill) {
return Some(Self::minimal_autogen());
}
None
}
pub fn is_subset_of(&self, other: &Self) -> bool {
let read_ok = other.readable_paths.is_empty()
|| self
.readable_paths
.iter()
.all(|p| other.readable_paths.iter().any(|h| paths_equal(p, h)));
let write_ok = other.writable_paths.is_empty()
|| self
.writable_paths
.iter()
.all(|p| other.writable_paths.iter().any(|h| paths_equal(p, h)));
let create_ok = !self.allow_create || other.allow_create;
let delete_ok = !self.allow_delete || other.allow_delete;
read_ok && write_ok && create_ok && delete_ok
}
}
fn string_list_from_map(
obj: &serde_json::Map<String, serde_json::Value>,
key: &str,
) -> Option<Vec<String>> {
let arr = obj.get(key)?.as_array()?;
Some(
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect(),
)
}
fn bool_from_map(obj: &serde_json::Map<String, serde_json::Value>, key: &str) -> Option<bool> {
obj.get(key)?.as_bool()
}
fn paths_equal(a: &str, b: &str) -> bool {
let na = if a == "/" {
a.to_string()
} else {
a.trim_end_matches('/').to_string()
};
let nb = if b == "/" {
b.to_string()
} else {
b.trim_end_matches('/').to_string()
};
na == nb
}
pub fn is_autogenerated_skill(skill: &SkillDefinition) -> bool {
skill
.metadata
.get("autogenerated")
.and_then(|v| v.as_bool())
.unwrap_or(false)
}
pub fn intersect_filesystem_allowlist(
skill: &SkillFilesystemAllowlist,
host: &SkillFilesystemAllowlist,
) -> SkillFilesystemAllowlist {
SkillFilesystemAllowlist {
readable_paths: intersect_path_allowlists(&skill.readable_paths, &host.readable_paths),
writable_paths: intersect_path_allowlists(&skill.writable_paths, &host.writable_paths),
allow_create: skill.allow_create && host.allow_create,
allow_delete: skill.allow_delete && host.allow_delete,
}
}
pub fn effective_filesystem_on_load(
skill: &SkillDefinition,
host: &SkillFilesystemAllowlist,
) -> SkillFilesystemAllowlist {
match SkillFilesystemAllowlist::from_skill(skill) {
Some(skill_fs) => intersect_filesystem_allowlist(&skill_fs, host),
None => host.clone(),
}
}
#[derive(Clone)]
pub struct AutogenConfig {
pub enabled: bool,
pub threshold: usize,
pub max_pending: usize,
pub install_dir: Option<PathBuf>,
pub learner: Option<Arc<TrajectoryLearner>>,
}
impl std::fmt::Debug for AutogenConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AutogenConfig")
.field("enabled", &self.enabled)
.field("threshold", &self.threshold)
.field("max_pending", &self.max_pending)
.field("install_dir", &self.install_dir)
.field(
"learner",
&self
.learner
.as_ref()
.map(|l| format!("TrajectoryLearner(n={})", l.trajectory_count())),
)
.finish()
}
}
impl Default for AutogenConfig {
fn default() -> Self {
Self {
enabled: false,
threshold: 3,
max_pending: 10,
install_dir: None,
learner: None,
}
}
}
impl AutogenConfig {
pub fn install_dir(&self) -> PathBuf {
self.install_dir.clone().unwrap_or_else(|| {
#[cfg(feature = "native")]
{
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".clawft")
.join("skills")
}
#[cfg(not(feature = "native"))]
{
PathBuf::from(".clawft").join("skills")
}
})
}
pub fn with_learner(mut self, learner: Arc<TrajectoryLearner>) -> Self {
self.learner = Some(learner);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ToolCallPattern {
pub tool_names: Vec<String>,
}
impl ToolCallPattern {
pub fn new(tool_names: Vec<String>) -> Self {
Self { tool_names }
}
pub fn suggested_name(&self) -> String {
if self.tool_names.is_empty() {
return "unnamed-skill".to_string();
}
let parts: Vec<&str> = self.tool_names.iter().map(|s| s.as_str()).collect();
let name = parts.join("-then-");
if name.len() > 60 {
format!("{}-auto", &name[..56])
} else {
format!("{name}-auto")
}
}
pub fn len(&self) -> usize {
self.tool_names.len()
}
pub fn is_empty(&self) -> bool {
self.tool_names.is_empty()
}
}
pub struct PatternDetector {
config: AutogenConfig,
pattern_counts: HashMap<ToolCallPattern, usize>,
recent_calls: Vec<String>,
max_window: usize,
reported: HashMap<ToolCallPattern, bool>,
}
impl PatternDetector {
pub fn new(config: AutogenConfig) -> Self {
Self {
config,
pattern_counts: HashMap::new(),
recent_calls: Vec::new(),
max_window: 10,
reported: HashMap::new(),
}
}
pub fn record_tool_call(&mut self, tool_name: &str) {
if !self.config.enabled {
return;
}
self.recent_calls.push(tool_name.to_string());
if self.recent_calls.len() > self.max_window * 3 {
self.recent_calls.drain(..self.max_window);
}
let n = self.recent_calls.len();
for len in 2..=self.max_window.min(n) {
let pattern = ToolCallPattern::new(self.recent_calls[n - len..n].to_vec());
*self.pattern_counts.entry(pattern).or_insert(0) += 1;
}
}
pub fn detect_candidates(&mut self) -> Vec<ToolCallPattern> {
if !self.config.enabled {
return vec![];
}
let threshold = self.config.threshold;
let mut candidates = Vec::new();
for (pattern, count) in &self.pattern_counts {
if *count >= threshold && !self.reported.contains_key(pattern) {
candidates.push(pattern.clone());
}
}
for candidate in &candidates {
self.reported.insert(candidate.clone(), true);
}
candidates
}
pub fn pattern_count(&self, pattern: &ToolCallPattern) -> usize {
self.pattern_counts.get(pattern).copied().unwrap_or(0)
}
pub fn reset(&mut self) {
self.pattern_counts.clear();
self.recent_calls.clear();
self.reported.clear();
}
pub fn is_enabled(&self) -> bool {
self.config.enabled
}
pub fn config(&self) -> &AutogenConfig {
&self.config
}
pub fn install_dir(&self) -> PathBuf {
self.config.install_dir()
}
pub fn learner(&self) -> Option<&TrajectoryLearner> {
self.config.learner.as_deref()
}
pub fn learner_arc(&self) -> Option<Arc<TrajectoryLearner>> {
self.config.learner.clone()
}
pub fn set_learner(&mut self, learner: Option<Arc<TrajectoryLearner>>) {
self.config.learner = learner;
}
}
#[derive(Debug, Clone)]
pub struct SkillCandidate {
pub name: String,
pub description: String,
pub pattern: ToolCallPattern,
pub skill_md: String,
pub state: CandidateState,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CandidateState {
Pending,
Approved,
Rejected,
}
pub fn generate_skill_md(pattern: &ToolCallPattern) -> SkillCandidate {
let name = pattern.suggested_name();
let tools_list = pattern
.tool_names
.iter()
.map(|t| format!(" - {t}"))
.collect::<Vec<_>>()
.join("\n");
let description = format!(
"Auto-generated skill: executes {} in sequence",
pattern
.tool_names
.iter()
.map(|t| format!("`{t}`"))
.collect::<Vec<_>>()
.join(" -> ")
);
let instructions = format!(
"Execute the following tools in order:\n{}",
pattern
.tool_names
.iter()
.enumerate()
.map(|(i, t)| format!("{}. Use the `{t}` tool", i + 1))
.collect::<Vec<_>>()
.join("\n")
);
let filesystem_yaml = SkillFilesystemAllowlist::minimal_autogen().to_yaml_frontmatter();
let skill_md = format!(
"---\n\
name: {name}\n\
description: \"{description}\"\n\
version: 0.1.0\n\
allowed-tools:\n\
{tools_list}\n\
user-invocable: false\n\
autogenerated: true\n\
{filesystem_yaml}\
---\n\n\
{instructions}\n"
);
SkillCandidate {
name,
description,
pattern: pattern.clone(),
skill_md,
state: CandidateState::Pending,
}
}
pub fn install_pending_skill(
candidate: &SkillCandidate,
install_dir: &Path,
) -> Result<PathBuf, String> {
let skill_dir = install_dir.join(&candidate.name);
std::fs::create_dir_all(&skill_dir).map_err(|e| format!("create skill dir: {e}"))?;
let skill_path = skill_dir.join("SKILL.md");
std::fs::write(&skill_path, &candidate.skill_md).map_err(|e| format!("write SKILL.md: {e}"))?;
let marker_path = skill_dir.join(PENDING_MARKER);
std::fs::write(&marker_path, "awaiting user approval")
.map_err(|e| format!("write .pending marker: {e}"))?;
info!(
skill = %candidate.name,
path = %skill_dir.display(),
"installed pending skill"
);
Ok(skill_dir)
}
pub fn approve_skill(skill_dir: &Path) -> Result<(), String> {
let marker = skill_dir.join(PENDING_MARKER);
if marker.exists() {
std::fs::remove_file(&marker).map_err(|e| format!("remove .pending: {e}"))?;
info!(skill_dir = %skill_dir.display(), "approved pending skill");
Ok(())
} else {
Err("skill is not in pending state".into())
}
}
pub fn reject_skill(skill_dir: &Path) -> Result<(), String> {
if skill_dir.exists() {
std::fs::remove_dir_all(skill_dir).map_err(|e| format!("remove skill dir: {e}"))?;
info!(skill_dir = %skill_dir.display(), "rejected and removed pending skill");
Ok(())
} else {
Err("skill directory does not exist".into())
}
}
pub const PENDING_MARKER: &str = ".pending";
pub fn is_pending(skill_dir: &Path) -> bool {
skill_dir.join(PENDING_MARKER).exists()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PendingReviewTiming {
CliOnly,
#[default]
CliWithStartNotice,
}
impl PendingReviewTiming {
pub const DEFAULT_POLICY: Self = Self::CliWithStartNotice;
pub fn emits_start_notice(self) -> bool {
matches!(self, Self::CliWithStartNotice)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingSkillInfo {
pub name: String,
pub path: PathBuf,
}
pub fn list_pending_skills(install_dir: &Path) -> Result<Vec<PendingSkillInfo>, String> {
if !install_dir.exists() {
return Ok(Vec::new());
}
let mut out = Vec::new();
let entries =
std::fs::read_dir(install_dir).map_err(|e| format!("read skills dir: {e}"))?;
for entry in entries {
let entry = entry.map_err(|e| format!("read dir entry: {e}"))?;
let path = entry.path();
if !path.is_dir() {
continue;
}
if !is_pending(&path) {
continue;
}
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
out.push(PendingSkillInfo { name, path });
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
pub fn format_pending_review_notice(
pending: &[PendingSkillInfo],
timing: PendingReviewTiming,
) -> Option<String> {
if !timing.emits_start_notice() || pending.is_empty() {
return None;
}
let count = pending.len();
let names: Vec<&str> = pending.iter().map(|p| p.name.as_str()).collect();
let preview = match names.len() {
0 => return None,
1 => names[0].to_string(),
2 => format!("{}, {}", names[0], names[1]),
3 => format!("{}, {}, {}", names[0], names[1], names[2]),
n => format!("{}, {} (+{} more)", names[0], names[1], n - 2),
};
Some(format!(
"Pending skills ({count}): {preview}. \
Review: weft skills pending | approve <name> | reject <name>"
))
}
pub fn default_user_skills_dir() -> Option<PathBuf> {
#[cfg(feature = "native")]
{
dirs::home_dir().map(|h| h.join(".clawft").join("skills"))
}
#[cfg(not(feature = "native"))]
{
Some(PathBuf::from(".clawft").join("skills"))
}
}
pub fn pending_review_start_notice(
install_dir: Option<&Path>,
timing: PendingReviewTiming,
) -> Option<String> {
if !timing.emits_start_notice() {
return None;
}
let owned;
let dir = match install_dir {
Some(p) => p,
None => {
owned = default_user_skills_dir()?;
&owned
}
};
let pending = list_pending_skills(dir).ok()?;
format_pending_review_notice(&pending, timing)
}
pub fn improve_skill_instructions(instructions: &str, learner: &TrajectoryLearner) -> String {
let best: Vec<_> = learner
.get_best_trajectories(10)
.into_iter()
.filter(|t| t.trajectory.quality.overall >= 0.8)
.collect();
let poor = learner.get_poor_trajectories(10);
if best.is_empty() && poor.is_empty() {
return instructions.to_string();
}
let hints: Vec<TrajectoryHint> = best
.iter()
.chain(poor.iter())
.map(|st| TrajectoryHint {
request_content: st
.trajectory
.request
.messages
.first()
.map(|m| m.content.clone())
.unwrap_or_default(),
quality_score: st.trajectory.quality.overall,
feedback: st.feedback.clone(),
})
.collect();
let strategy = auto_select_strategy(&hints);
debug!(
?strategy,
hints_count = hints.len(),
"mutating skill instructions"
);
mutate_prompt(instructions, &hints, strategy)
}
pub fn generate_skill_md_with_learning(
pattern: &ToolCallPattern,
learner: Option<&TrajectoryLearner>,
) -> SkillCandidate {
let mut candidate = generate_skill_md(pattern);
if let Some(learner) = learner {
let patterns = learner.extract_successful_patterns();
if !patterns.is_empty() {
debug!(
pattern_count = patterns.len(),
"improving skill via trajectory learning"
);
if let Some(idx) = candidate.skill_md.find("\n---\n") {
let (front, rest) = candidate.skill_md.split_at(idx + "\n---\n".len());
let body = rest.trim_start_matches('\n');
let improved = improve_skill_instructions(body, learner);
candidate.skill_md = format!("{front}\n{improved}");
if !candidate.skill_md.ends_with('\n') {
candidate.skill_md.push('\n');
}
} else {
candidate.skill_md = improve_skill_instructions(&candidate.skill_md, learner);
}
}
}
candidate
}
pub const DEFAULT_PROMOTION_THRESHOLD: usize = 10;
pub const CLAUDE_SKILLS_MARKER: &str = ".claude/skills";
pub const PROMOTION_AUDIT_FILENAME: &str = ".promotion-audit.jsonl";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillPromotionConfig {
pub enabled: bool,
pub threshold: usize,
pub target_dir: Option<PathBuf>,
pub audit_log_path: Option<PathBuf>,
}
impl Default for SkillPromotionConfig {
fn default() -> Self {
Self {
enabled: true,
threshold: DEFAULT_PROMOTION_THRESHOLD,
target_dir: None,
audit_log_path: None,
}
}
}
impl SkillPromotionConfig {
pub fn from_types(cfg: &clawft_types::config::SkillPromotionConfig) -> Self {
Self {
enabled: cfg.enabled,
threshold: cfg.threshold.max(1),
target_dir: None,
audit_log_path: None,
}
}
pub fn target_dir(&self) -> PathBuf {
self.target_dir
.clone()
.or_else(default_user_skills_dir)
.unwrap_or_else(|| PathBuf::from(".clawft").join("skills"))
}
pub fn audit_log_path(&self) -> PathBuf {
self.audit_log_path
.clone()
.unwrap_or_else(|| self.target_dir().join(PROMOTION_AUDIT_FILENAME))
}
pub fn with_threshold(mut self, threshold: usize) -> Self {
self.threshold = threshold.max(1);
self
}
pub fn with_target_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.target_dir = Some(dir.into());
self
}
pub fn with_audit_log_path(mut self, path: impl Into<PathBuf>) -> Self {
self.audit_log_path = Some(path.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct SkillPromotionAuditEntry {
pub skill_name: String,
pub source_path: PathBuf,
pub target_path: PathBuf,
pub successful_invocations: usize,
pub threshold: usize,
pub timestamp_unix_ms: u64,
pub event: String,
}
impl SkillPromotionAuditEntry {
pub const EVENT_TYPE: &'static str = "skill.promoted";
fn new(
skill_name: impl Into<String>,
source_path: PathBuf,
target_path: PathBuf,
successful_invocations: usize,
threshold: usize,
) -> Self {
Self {
skill_name: skill_name.into(),
source_path,
target_path,
successful_invocations,
threshold,
timestamp_unix_ms: unix_ms_now(),
event: Self::EVENT_TYPE.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PromotionOutcome {
Disabled,
NotEligible,
Counted {
success_count: usize,
threshold: usize,
},
AlreadyPromoted {
target: PathBuf,
},
Promoted {
entry: SkillPromotionAuditEntry,
},
Error(String),
}
pub struct SkillPromoter {
config: SkillPromotionConfig,
success_counts: HashMap<String, usize>,
promoted: HashMap<String, PathBuf>,
audit_log: Vec<SkillPromotionAuditEntry>,
max_audit_entries: usize,
}
impl SkillPromoter {
pub fn new(config: SkillPromotionConfig) -> Self {
Self {
config,
success_counts: HashMap::new(),
promoted: HashMap::new(),
audit_log: Vec::new(),
max_audit_entries: 10_000,
}
}
pub fn with_defaults() -> Self {
Self::new(SkillPromotionConfig::default())
}
pub fn config(&self) -> &SkillPromotionConfig {
&self.config
}
pub fn is_enabled(&self) -> bool {
self.config.enabled
}
pub fn success_count(&self, skill_name: &str) -> usize {
self.success_counts.get(skill_name).copied().unwrap_or(0)
}
pub fn is_promoted(&self, skill_name: &str) -> bool {
self.promoted.contains_key(skill_name)
}
pub fn audit_log(&self) -> &[SkillPromotionAuditEntry] {
&self.audit_log
}
pub fn reset(&mut self) {
self.success_counts.clear();
self.promoted.clear();
self.audit_log.clear();
}
pub fn record_success(
&mut self,
skill_name: &str,
source_path: &Path,
) -> PromotionOutcome {
if !self.config.enabled {
return PromotionOutcome::Disabled;
}
if skill_name.is_empty() {
return PromotionOutcome::Error("skill name is empty".into());
}
let skill_dir = resolve_skill_dir(source_path);
if !is_claude_skills_path(&skill_dir) {
debug!(
skill = %skill_name,
path = %skill_dir.display(),
"skill promotion: not a .claude/skills source, skipping"
);
return PromotionOutcome::NotEligible;
}
if let Some(target) = self.promoted.get(skill_name) {
return PromotionOutcome::AlreadyPromoted {
target: target.clone(),
};
}
let target_root = self.config.target_dir();
let expected_target = target_root.join(skill_name);
if expected_target.exists() && expected_target.join("SKILL.md").exists() {
self.promoted
.insert(skill_name.to_string(), expected_target.clone());
return PromotionOutcome::AlreadyPromoted {
target: expected_target,
};
}
let count = {
let entry = self
.success_counts
.entry(skill_name.to_string())
.or_insert(0);
*entry = entry.saturating_add(1);
*entry
};
let threshold = self.config.threshold.max(1);
if count < threshold {
debug!(
skill = %skill_name,
count,
threshold,
"skill promotion: counted successful invocation"
);
return PromotionOutcome::Counted {
success_count: count,
threshold,
};
}
match promote_skill_directory(&skill_dir, &target_root, skill_name) {
Ok(target_path) => {
let entry = SkillPromotionAuditEntry::new(
skill_name,
skill_dir,
target_path.clone(),
count,
threshold,
);
self.push_audit(entry.clone());
if let Err(e) = append_audit_log(&self.config.audit_log_path(), &entry) {
warn!(
error = %e,
skill = %skill_name,
"skill promotion: failed to write audit log file"
);
}
self.promoted
.insert(skill_name.to_string(), target_path);
info!(
skill = %skill_name,
count,
threshold,
"skill promotion: promoted .claude/skills → .clawft/skills"
);
PromotionOutcome::Promoted { entry }
}
Err(e) => {
warn!(error = %e, skill = %skill_name, "skill promotion: copy failed");
PromotionOutcome::Error(e)
}
}
}
fn push_audit(&mut self, entry: SkillPromotionAuditEntry) {
self.audit_log.push(entry);
if self.audit_log.len() > self.max_audit_entries {
let overflow = self.audit_log.len() - self.max_audit_entries;
self.audit_log.drain(..overflow);
}
}
}
pub fn is_claude_skills_path(path: &Path) -> bool {
let components: Vec<String> = path
.components()
.filter_map(|c| match c {
std::path::Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
_ => None,
})
.collect();
components
.windows(2)
.any(|w| w[0] == ".claude" && w[1] == "skills")
}
fn resolve_skill_dir(path: &Path) -> PathBuf {
if path.is_file()
|| path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.eq_ignore_ascii_case("SKILL.md") || n == "skill.json")
{
path.parent().unwrap_or(path).to_path_buf()
} else {
path.to_path_buf()
}
}
pub fn promote_skill_directory(
source_dir: &Path,
target_root: &Path,
name: &str,
) -> Result<PathBuf, String> {
if !source_dir.exists() {
return Err(format!(
"source skill directory does not exist: {}",
source_dir.display()
));
}
if !source_dir.is_dir() {
return Err(format!(
"source is not a directory: {}",
source_dir.display()
));
}
if name.is_empty()
|| name.contains('/')
|| name.contains('\\')
|| name == "."
|| name == ".."
|| name.contains("..")
{
return Err(format!("invalid skill name for promotion: {name:?}"));
}
let dest = target_root.join(name);
if dest.exists() {
return Err(format!(
"destination already exists: {}",
dest.display()
));
}
std::fs::create_dir_all(target_root)
.map_err(|e| format!("create target root {}: {e}", target_root.display()))?;
copy_dir_recursive(source_dir, &dest)?;
let pending = dest.join(PENDING_MARKER);
if pending.exists() {
let _ = std::fs::remove_file(&pending);
}
let stamp = dest.join(".promoted-from-claude");
let stamp_body = format!(
"source={}\npromoted_at_ms={}\n",
source_dir.display(),
unix_ms_now()
);
let _ = std::fs::write(&stamp, stamp_body);
Ok(dest)
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), String> {
std::fs::create_dir_all(dst).map_err(|e| format!("create {}: {e}", dst.display()))?;
let entries = std::fs::read_dir(src).map_err(|e| format!("read {}: {e}", src.display()))?;
for entry in entries {
let entry = entry.map_err(|e| format!("dir entry: {e}"))?;
let file_type = entry
.file_type()
.map_err(|e| format!("file type: {e}"))?;
let from = entry.path();
let to = dst.join(entry.file_name());
if file_type.is_dir() {
copy_dir_recursive(&from, &to)?;
} else if file_type.is_file() {
std::fs::copy(&from, &to)
.map_err(|e| format!("copy {} → {}: {e}", from.display(), to.display()))?;
}
}
Ok(())
}
fn append_audit_log(path: &Path, entry: &SkillPromotionAuditEntry) -> Result<(), String> {
use std::io::Write;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("create audit parent {}: {e}", parent.display()))?;
}
let line = serde_json::to_string(entry).map_err(|e| format!("serialize audit: {e}"))?;
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map_err(|e| format!("open audit log {}: {e}", path.display()))?;
writeln!(file, "{line}").map_err(|e| format!("write audit log: {e}"))?;
Ok(())
}
fn unix_ms_now() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn enabled_config() -> AutogenConfig {
AutogenConfig {
enabled: true,
threshold: 3,
max_pending: 10,
install_dir: None,
learner: None,
}
}
fn disabled_config() -> AutogenConfig {
AutogenConfig::default()
}
#[test]
fn config_default_is_disabled() {
let config = AutogenConfig::default();
assert!(!config.enabled);
assert_eq!(config.threshold, 3);
assert_eq!(config.max_pending, 10);
assert!(config.learner.is_none());
}
#[test]
fn config_with_learner_attaches_arc() {
let learner = Arc::new(TrajectoryLearner::new(
crate::pipeline::learner::TrajectoryLearnerConfig::default(),
));
let config = AutogenConfig::default().with_learner(Arc::clone(&learner));
assert!(config.learner.is_some());
assert_eq!(
Arc::as_ptr(config.learner.as_ref().unwrap()),
Arc::as_ptr(&learner)
);
}
#[test]
fn config_install_dir_default() {
let config = AutogenConfig::default();
let dir = config.install_dir();
assert!(dir.to_string_lossy().contains(".clawft"));
assert!(dir.to_string_lossy().contains("skills"));
}
#[test]
fn pattern_suggested_name() {
let pattern = ToolCallPattern::new(vec!["read_file".into(), "edit_file".into()]);
let name = pattern.suggested_name();
assert!(name.contains("read_file"));
assert!(name.contains("edit_file"));
assert!(name.ends_with("-auto"));
}
#[test]
fn pattern_suggested_name_long_truncated() {
let tools: Vec<String> = (0..20).map(|i| format!("tool_{i}")).collect();
let pattern = ToolCallPattern::new(tools);
let name = pattern.suggested_name();
assert!(name.len() <= 65, "name too long: {}", name.len());
}
#[test]
fn pattern_empty() {
let pattern = ToolCallPattern::new(vec![]);
assert!(pattern.is_empty());
assert_eq!(pattern.len(), 0);
assert_eq!(pattern.suggested_name(), "unnamed-skill");
}
#[test]
fn detector_disabled_records_nothing() {
let mut detector = PatternDetector::new(disabled_config());
detector.record_tool_call("read_file");
detector.record_tool_call("edit_file");
let candidates = detector.detect_candidates();
assert!(candidates.is_empty());
}
#[test]
fn detector_does_not_fire_below_threshold() {
let mut detector = PatternDetector::new(enabled_config());
for _ in 0..2 {
detector.record_tool_call("read_file");
detector.record_tool_call("edit_file");
}
let candidates = detector.detect_candidates();
assert!(candidates.is_empty());
}
#[test]
fn detector_fires_at_threshold() {
let mut detector = PatternDetector::new(enabled_config());
for _ in 0..3 {
detector.record_tool_call("read_file");
detector.record_tool_call("edit_file");
}
let candidates = detector.detect_candidates();
assert!(!candidates.is_empty(), "should detect at least one pattern");
}
#[test]
fn detector_does_not_re_report() {
let mut detector = PatternDetector::new(enabled_config());
for _ in 0..5 {
detector.record_tool_call("read_file");
detector.record_tool_call("edit_file");
}
let first = detector.detect_candidates();
assert!(!first.is_empty());
let second = detector.detect_candidates();
assert!(second.is_empty(), "should not re-report same pattern");
}
#[test]
fn detector_reset_clears_state() {
let mut detector = PatternDetector::new(enabled_config());
for _ in 0..3 {
detector.record_tool_call("a");
detector.record_tool_call("b");
}
let _ = detector.detect_candidates();
detector.reset();
for _ in 0..3 {
detector.record_tool_call("a");
detector.record_tool_call("b");
}
let candidates = detector.detect_candidates();
assert!(!candidates.is_empty());
}
#[test]
fn detector_configurable_threshold() {
let config = AutogenConfig {
enabled: true,
threshold: 5,
..Default::default()
};
let mut detector = PatternDetector::new(config);
for _ in 0..4 {
detector.record_tool_call("x");
detector.record_tool_call("y");
}
assert!(detector.detect_candidates().is_empty(), "below threshold");
detector.record_tool_call("x");
detector.record_tool_call("y");
assert!(!detector.detect_candidates().is_empty(), "at threshold");
}
#[test]
fn generate_skill_md_valid() {
let pattern = ToolCallPattern::new(vec![
"read_file".into(),
"edit_file".into(),
"write_file".into(),
]);
let candidate = generate_skill_md(&pattern);
assert!(candidate.name.contains("auto"));
assert_eq!(candidate.state, CandidateState::Pending);
assert!(candidate.skill_md.contains("---"));
assert!(candidate.skill_md.contains("read_file"));
assert!(candidate.skill_md.contains("edit_file"));
assert!(candidate.skill_md.contains("write_file"));
assert!(candidate.skill_md.contains("autogenerated: true"));
assert!(candidate.skill_md.contains("user-invocable: false"));
}
#[test]
fn generate_skill_md_description_contains_tools() {
let pattern = ToolCallPattern::new(vec!["a".into(), "b".into()]);
let candidate = generate_skill_md(&pattern);
assert!(candidate.description.contains("`a`"));
assert!(candidate.description.contains("`b`"));
}
#[test]
fn install_pending_skill_creates_files() {
let dir = std::env::temp_dir().join("clawft_autogen_install_test");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::create_dir_all(&dir);
let pattern = ToolCallPattern::new(vec!["read_file".into(), "edit_file".into()]);
let candidate = generate_skill_md(&pattern);
let result = install_pending_skill(&candidate, &dir);
assert!(result.is_ok());
let skill_dir = result.unwrap();
assert!(skill_dir.join("SKILL.md").exists());
assert!(skill_dir.join(".pending").exists());
assert!(is_pending(&skill_dir));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn approve_skill_removes_marker() {
let dir = std::env::temp_dir().join("clawft_autogen_approve_test");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::create_dir_all(&dir);
let pattern = ToolCallPattern::new(vec!["a".into(), "b".into()]);
let candidate = generate_skill_md(&pattern);
let skill_dir = install_pending_skill(&candidate, &dir).unwrap();
assert!(is_pending(&skill_dir));
assert!(approve_skill(&skill_dir).is_ok());
assert!(!is_pending(&skill_dir));
assert!(skill_dir.join("SKILL.md").exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn reject_skill_removes_directory() {
let dir = std::env::temp_dir().join("clawft_autogen_reject_test");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::create_dir_all(&dir);
let pattern = ToolCallPattern::new(vec!["x".into(), "y".into()]);
let candidate = generate_skill_md(&pattern);
let skill_dir = install_pending_skill(&candidate, &dir).unwrap();
assert!(skill_dir.exists());
assert!(reject_skill(&skill_dir).is_ok());
assert!(!skill_dir.exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn approve_non_pending_fails() {
let dir = std::env::temp_dir().join("clawft_autogen_approve_nopend");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::create_dir_all(&dir);
let result = approve_skill(&dir);
assert!(result.is_err());
assert!(result.unwrap_err().contains("not in pending state"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn pending_review_timing_default_is_cli_with_start_notice() {
assert_eq!(
PendingReviewTiming::default(),
PendingReviewTiming::CliWithStartNotice
);
assert_eq!(
PendingReviewTiming::DEFAULT_POLICY,
PendingReviewTiming::CliWithStartNotice
);
assert!(PendingReviewTiming::CliWithStartNotice.emits_start_notice());
assert!(!PendingReviewTiming::CliOnly.emits_start_notice());
}
#[test]
fn list_pending_skills_empty_when_dir_missing() {
let dir = std::env::temp_dir().join("clawft_autogen_list_missing_weft74");
let _ = std::fs::remove_dir_all(&dir);
let pending = list_pending_skills(&dir).unwrap();
assert!(pending.is_empty());
}
#[test]
fn list_pending_skills_empty_one_many() {
let dir = std::env::temp_dir().join("clawft_autogen_list_pending_weft74");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::create_dir_all(&dir);
assert!(list_pending_skills(&dir).unwrap().is_empty());
let approved = dir.join("approved-skill");
std::fs::create_dir_all(&approved).unwrap();
std::fs::write(approved.join("SKILL.md"), "---\nname: approved\n---\n").unwrap();
assert!(list_pending_skills(&dir).unwrap().is_empty());
let p1 = ToolCallPattern::new(vec!["read_file".into(), "edit_file".into()]);
let c1 = generate_skill_md(&p1);
install_pending_skill(&c1, &dir).unwrap();
let one = list_pending_skills(&dir).unwrap();
assert_eq!(one.len(), 1);
assert_eq!(one[0].name, c1.name);
let p2 = ToolCallPattern::new(vec!["a".into(), "b".into()]);
let mut c2 = generate_skill_md(&p2);
c2.name = "aaa-pending".into();
install_pending_skill(&c2, &dir).unwrap();
let many = list_pending_skills(&dir).unwrap();
assert_eq!(many.len(), 2);
assert_eq!(many[0].name, "aaa-pending");
assert!(many.iter().any(|p| p.name == c1.name));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn format_pending_review_notice_respects_timing_and_count() {
let empty: Vec<PendingSkillInfo> = Vec::new();
assert!(format_pending_review_notice(
&empty,
PendingReviewTiming::CliWithStartNotice
)
.is_none());
let pending = vec![
PendingSkillInfo {
name: "alpha-auto".into(),
path: PathBuf::from("/tmp/alpha-auto"),
},
PendingSkillInfo {
name: "beta-auto".into(),
path: PathBuf::from("/tmp/beta-auto"),
},
PendingSkillInfo {
name: "gamma-auto".into(),
path: PathBuf::from("/tmp/gamma-auto"),
},
PendingSkillInfo {
name: "delta-auto".into(),
path: PathBuf::from("/tmp/delta-auto"),
},
];
assert!(
format_pending_review_notice(&pending, PendingReviewTiming::CliOnly).is_none()
);
let notice =
format_pending_review_notice(&pending, PendingReviewTiming::CliWithStartNotice)
.expect("notice when pending + CliWithStartNotice");
assert!(notice.contains("Pending skills (4)"));
assert!(notice.contains("alpha-auto"));
assert!(notice.contains("+2 more"));
assert!(notice.contains("weft skills pending"));
assert!(notice.contains("approve"));
assert!(notice.contains("reject"));
let one = format_pending_review_notice(
&pending[..1],
PendingReviewTiming::CliWithStartNotice,
)
.unwrap();
assert!(one.contains("Pending skills (1): alpha-auto"));
assert!(!one.contains("more"));
}
#[test]
fn pending_review_start_notice_best_effort() {
let dir = std::env::temp_dir().join("clawft_autogen_start_notice_weft74");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::create_dir_all(&dir);
assert!(
pending_review_start_notice(Some(&dir), PendingReviewTiming::CliWithStartNotice)
.is_none()
);
let pattern = ToolCallPattern::new(vec!["x".into(), "y".into()]);
let candidate = generate_skill_md(&pattern);
install_pending_skill(&candidate, &dir).unwrap();
let notice =
pending_review_start_notice(Some(&dir), PendingReviewTiming::CliWithStartNotice)
.expect("pending skill should produce start notice");
assert!(notice.contains(&candidate.name));
assert!(notice.contains("weft skills pending"));
assert!(
pending_review_start_notice(Some(&dir), PendingReviewTiming::CliOnly).is_none()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn generated_skill_has_minimal_permissions() {
let pattern = ToolCallPattern::new(vec!["read_file".into()]);
let candidate = generate_skill_md(&pattern);
assert!(!candidate.skill_md.contains("shell: true"));
assert!(!candidate.skill_md.contains("network:"));
assert!(candidate.skill_md.contains("user-invocable: false"));
assert!(candidate.skill_md.contains("filesystem:"));
assert!(candidate.skill_md.contains("readable-paths:"));
assert!(candidate.skill_md.contains("writable-paths:"));
assert!(candidate.skill_md.contains("allow-create: false"));
assert!(candidate.skill_md.contains("allow-delete: false"));
assert!(candidate.skill_md.contains(&format!("- {AUTOGEN_WORKSPACE_PATH}")));
}
#[test]
fn generate_skill_md_emits_explicit_minimal_allowlist() {
let pattern = ToolCallPattern::new(vec!["read_file".into(), "edit_file".into()]);
let candidate = generate_skill_md(&pattern);
let minimal = SkillFilesystemAllowlist::minimal_autogen();
let skill = crate::agent::skills_v2::parse_skill_md(&candidate.skill_md, None)
.expect("generated SKILL.md must parse");
assert!(is_autogenerated_skill(&skill));
let fs = SkillFilesystemAllowlist::from_skill(&skill)
.expect("autogen skill must carry filesystem allowlist");
assert_eq!(fs, minimal);
assert_eq!(fs.readable_paths, vec![AUTOGEN_WORKSPACE_PATH]);
assert_eq!(fs.writable_paths, vec![AUTOGEN_WORKSPACE_PATH]);
assert!(!fs.allow_create);
assert!(!fs.allow_delete);
}
#[test]
fn broader_host_effective_allowlist_is_minimum() {
let pattern = ToolCallPattern::new(vec!["read_file".into()]);
let candidate = generate_skill_md(&pattern);
let skill = crate::agent::skills_v2::parse_skill_md(&candidate.skill_md, None).unwrap();
let host = SkillFilesystemAllowlist {
readable_paths: vec![
".".into(),
"/home".into(),
"/tmp".into(),
"/var/log".into(),
],
writable_paths: vec![".".into(), "/tmp".into(), "/home/out".into()],
allow_create: true,
allow_delete: true,
};
let effective = effective_filesystem_on_load(&skill, &host);
let minimal = SkillFilesystemAllowlist::minimal_autogen();
assert_eq!(
effective, minimal,
"broader host must not widen autogen skill FS beyond minimal"
);
assert!(
effective.is_subset_of(&host),
"effective must never exceed host"
);
assert!(
effective.is_subset_of(&minimal),
"effective must not exceed skill-declared minimum"
);
assert!(!effective.allow_create);
assert!(!effective.allow_delete);
}
#[test]
fn narrower_host_further_restricts_autogen_skill() {
let pattern = ToolCallPattern::new(vec!["read_file".into()]);
let candidate = generate_skill_md(&pattern);
let skill = crate::agent::skills_v2::parse_skill_md(&candidate.skill_md, None).unwrap();
let host = SkillFilesystemAllowlist {
readable_paths: vec!["/tmp".into()],
writable_paths: vec!["/tmp".into()],
allow_create: false,
allow_delete: false,
};
let effective = effective_filesystem_on_load(&skill, &host);
assert!(effective.readable_paths.is_empty());
assert!(effective.writable_paths.is_empty());
assert!(effective.is_subset_of(&host));
}
#[test]
fn intersect_filesystem_allowlist_ands_boolean_flags() {
let skill = SkillFilesystemAllowlist {
readable_paths: vec![".".into()],
writable_paths: vec![".".into()],
allow_create: true,
allow_delete: false,
};
let host = SkillFilesystemAllowlist {
readable_paths: vec![".".into()],
writable_paths: vec![".".into()],
allow_create: false,
allow_delete: true,
};
let effective = intersect_filesystem_allowlist(&skill, &host);
assert!(!effective.allow_create);
assert!(!effective.allow_delete);
}
#[test]
fn non_autogen_skill_without_fs_defers_to_host() {
let skill = SkillDefinition::new("manual", "hand-written skill");
let host = SkillFilesystemAllowlist {
readable_paths: vec!["/workspace".into()],
writable_paths: vec!["/workspace".into()],
allow_create: true,
allow_delete: false,
};
let effective = effective_filesystem_on_load(&skill, &host);
assert_eq!(effective, host);
}
#[test]
fn legacy_autogen_without_fs_block_uses_minimal_defaults() {
let mut skill = SkillDefinition::new("legacy-auto", "old autogen skill");
skill
.metadata
.insert("autogenerated".into(), serde_json::json!(true));
let host = SkillFilesystemAllowlist {
readable_paths: vec![".".into(), "/extra".into()],
writable_paths: vec![".".into(), "/extra".into()],
allow_create: true,
allow_delete: true,
};
let effective = effective_filesystem_on_load(&skill, &host);
assert_eq!(effective, SkillFilesystemAllowlist::minimal_autogen());
}
#[test]
fn is_enabled_reflects_config() {
let enabled = PatternDetector::new(enabled_config());
assert!(enabled.is_enabled());
let disabled = PatternDetector::new(disabled_config());
assert!(!disabled.is_enabled());
}
fn make_test_learner() -> TrajectoryLearner {
use crate::pipeline::learner::TrajectoryLearnerConfig;
use crate::pipeline::traits::{
ChatRequest, LlmMessage, QualityScore, RoutingDecision, Trajectory,
};
use clawft_types::provider::{ContentBlock, LlmResponse, StopReason, Usage};
let learner = TrajectoryLearner::new(TrajectoryLearnerConfig::default());
let make_traj = |overall: f32, content: &str| Trajectory {
request: ChatRequest {
messages: vec![LlmMessage {
role: "user".into(),
content: content.into(),
tool_call_id: None,
tool_calls: None,
}],
tools: vec![],
model: None,
max_tokens: None,
temperature: None,
auth_context: None,
complexity_boost: 0.0,
tool_choice: None,
},
routing: RoutingDecision::default(),
response: LlmResponse {
id: "r".into(),
content: vec![ContentBlock::Text { text: "ok".into() }],
stop_reason: StopReason::EndTurn,
usage: Usage {
input_tokens: 5,
output_tokens: 2,
total_tokens: 0,
},
metadata: std::collections::HashMap::new(),
},
quality: QualityScore {
overall,
relevance: overall,
coherence: overall,
},
};
use crate::pipeline::traits::LearningBackend;
learner.record(&make_traj(0.95, "Write a function to sort a list"));
learner.record(&make_traj(0.9, "Explain recursion with examples"));
learner.record(&make_traj(0.3, "Fix the bug"));
learner.record(&make_traj(0.4, "Summarize article"));
learner
}
#[test]
fn improve_skill_instructions_with_patterns() {
let learner = make_test_learner();
let original = "Use the read_file tool\nCheck the output";
let improved = improve_skill_instructions(original, &learner);
assert!(!improved.is_empty());
}
#[test]
fn improve_skill_instructions_empty_learner_returns_original() {
use crate::pipeline::learner::TrajectoryLearnerConfig;
let learner = TrajectoryLearner::new(TrajectoryLearnerConfig::default());
let original = "Use the read_file tool";
let result = improve_skill_instructions(original, &learner);
assert_eq!(result, original);
}
#[test]
fn generate_skill_md_with_learning_no_learner() {
let pattern = ToolCallPattern::new(vec!["a".into(), "b".into()]);
let candidate = generate_skill_md_with_learning(&pattern, None);
assert!(candidate.skill_md.contains("autogenerated: true"));
let baseline = generate_skill_md(&pattern);
assert_eq!(candidate.skill_md, baseline.skill_md);
}
#[test]
fn generate_skill_md_with_learning_uses_learner() {
let learner = make_test_learner();
let pattern = ToolCallPattern::new(vec!["read_file".into(), "edit_file".into()]);
let baseline = generate_skill_md(&pattern);
let candidate = generate_skill_md_with_learning(&pattern, Some(&learner));
assert!(candidate.skill_md.contains("read_file"));
assert!(candidate.skill_md.contains("autogenerated: true"));
assert!(candidate.skill_md.contains("filesystem:"));
assert!(!candidate.skill_md.is_empty());
assert_ne!(
candidate.skill_md, baseline.skill_md,
"trajectory learning must change SKILL.md body"
);
assert!(
candidate.skill_md.contains("Successful Examples")
|| candidate.skill_md.contains("Write a function")
|| candidate.skill_md.contains("Explain recursion")
|| candidate.skill_md.contains("Step "),
"expected example injection or rephrase markers in improved SKILL.md: {}",
candidate.skill_md
);
}
#[test]
fn e2e_autogen_config_learner_improves_skill_md() {
let learner = Arc::new(make_test_learner());
let config = AutogenConfig {
enabled: true,
threshold: 2,
max_pending: 10,
install_dir: None,
learner: Some(Arc::clone(&learner)),
};
assert!(config.learner.is_some());
let mut detector = PatternDetector::new(config);
for _ in 0..2 {
detector.record_tool_call("read_file");
detector.record_tool_call("edit_file");
}
let candidates = detector.detect_candidates();
assert!(
!candidates.is_empty(),
"pattern detector should emit at least one candidate"
);
let pattern = &candidates[0];
let baseline = generate_skill_md(pattern);
let improved = generate_skill_md_with_learning(pattern, detector.learner());
assert_ne!(improved.skill_md, baseline.skill_md);
assert!(improved.skill_md.contains("read_file"));
assert!(improved.skill_md.contains("autogenerated: true"));
assert!(
improved.skill_md.contains("Successful Examples")
|| improved.skill_md.contains("Write a function")
|| improved.skill_md.contains("Explain recursion"),
"improved body should reflect synthetic high-quality trajectories: {}",
improved.skill_md
);
}
fn write_claude_skill(root: &Path, name: &str, body: &str) -> PathBuf {
let skill_dir = root
.join(".claude")
.join("skills")
.join(name);
std::fs::create_dir_all(&skill_dir).unwrap();
let md = format!(
"---\nname: {name}\ndescription: \"test skill {name}\"\nversion: 0.1.0\n---\n\n{body}\n"
);
std::fs::write(skill_dir.join("SKILL.md"), md).unwrap();
skill_dir
}
fn promotion_fixture() -> (tempfile::TempDir, PathBuf, PathBuf) {
let tmp = tempfile::tempdir().expect("tempdir");
let claude_skill = write_claude_skill(tmp.path(), "plane-workflow", "Do plane things.");
let target = tmp.path().join(".clawft").join("skills");
std::fs::create_dir_all(&target).unwrap();
(tmp, claude_skill, target)
}
#[test]
fn is_claude_skills_path_detects_marker() {
assert!(is_claude_skills_path(Path::new(
"/home/u/proj/.claude/skills/foo"
)));
assert!(is_claude_skills_path(Path::new(
".claude/skills/foo/SKILL.md"
)));
assert!(!is_claude_skills_path(Path::new(
"/home/u/proj/.clawft/skills/foo"
)));
assert!(!is_claude_skills_path(Path::new("skills/foo")));
assert!(!is_claude_skills_path(Path::new(
"/home/u/.claude/not-skills/foo"
)));
}
#[test]
fn promotion_config_defaults() {
let cfg = SkillPromotionConfig::default();
assert!(cfg.enabled);
assert_eq!(cfg.threshold, DEFAULT_PROMOTION_THRESHOLD);
assert_eq!(DEFAULT_PROMOTION_THRESHOLD, 10);
}
#[test]
fn promotion_config_from_types() {
let types_cfg = clawft_types::config::SkillPromotionConfig {
enabled: false,
threshold: 25,
};
let cfg = SkillPromotionConfig::from_types(&types_cfg);
assert!(!cfg.enabled);
assert_eq!(cfg.threshold, 25);
}
#[test]
fn promotion_disabled_is_noop() {
let (tmp, source, target) = promotion_fixture();
let mut promoter = SkillPromoter::new(
SkillPromotionConfig::default()
.with_threshold(2)
.with_target_dir(&target),
);
let mut cfg = promoter.config().clone();
cfg.enabled = false;
promoter = SkillPromoter::new(cfg.with_target_dir(&target).with_threshold(2));
let outcome = promoter.record_success("plane-workflow", &source);
assert_eq!(outcome, PromotionOutcome::Disabled);
assert_eq!(promoter.success_count("plane-workflow"), 0);
assert!(!target.join("plane-workflow").exists());
drop(tmp);
}
#[test]
fn promotion_not_eligible_for_clawft_source() {
let tmp = tempfile::tempdir().unwrap();
let clawft_skill = tmp.path().join(".clawft").join("skills").join("local");
std::fs::create_dir_all(&clawft_skill).unwrap();
std::fs::write(clawft_skill.join("SKILL.md"), "---\nname: local\n---\n").unwrap();
let target = tmp.path().join("out");
let mut promoter = SkillPromoter::new(
SkillPromotionConfig::default()
.with_threshold(1)
.with_target_dir(&target),
);
let outcome = promoter.record_success("local", &clawft_skill);
assert_eq!(outcome, PromotionOutcome::NotEligible);
assert_eq!(promoter.success_count("local"), 0);
}
#[test]
fn promotion_counts_below_threshold() {
let (tmp, source, target) = promotion_fixture();
let mut promoter = SkillPromoter::new(
SkillPromotionConfig::default()
.with_threshold(5)
.with_target_dir(&target),
);
for i in 1..=4 {
match promoter.record_success("plane-workflow", &source) {
PromotionOutcome::Counted {
success_count,
threshold,
} => {
assert_eq!(success_count, i);
assert_eq!(threshold, 5);
}
other => panic!("expected Counted, got {other:?}"),
}
}
assert!(!target.join("plane-workflow").exists());
assert!(promoter.audit_log().is_empty());
drop(tmp);
}
#[test]
fn promotion_auto_promotes_at_threshold_and_audits() {
let (tmp, source, target) = promotion_fixture();
let audit = tmp.path().join("audit.jsonl");
let mut promoter = SkillPromoter::new(
SkillPromotionConfig::default()
.with_threshold(3)
.with_target_dir(&target)
.with_audit_log_path(&audit),
);
for _ in 0..2 {
assert!(matches!(
promoter.record_success("plane-workflow", &source),
PromotionOutcome::Counted { .. }
));
}
let outcome = promoter.record_success("plane-workflow", &source);
match outcome {
PromotionOutcome::Promoted { entry } => {
assert_eq!(entry.skill_name, "plane-workflow");
assert_eq!(entry.successful_invocations, 3);
assert_eq!(entry.threshold, 3);
assert_eq!(entry.event, SkillPromotionAuditEntry::EVENT_TYPE);
assert!(entry.target_path.ends_with("plane-workflow"));
assert!(entry.target_path.join("SKILL.md").exists());
assert!(entry.target_path.join(".promoted-from-claude").exists());
}
other => panic!("expected Promoted, got {other:?}"),
}
assert_eq!(promoter.audit_log().len(), 1);
let disk = std::fs::read_to_string(&audit).expect("audit file");
assert!(disk.contains("skill.promoted"));
assert!(disk.contains("plane-workflow"));
let promoted_md = std::fs::read_to_string(target.join("plane-workflow").join("SKILL.md"))
.expect("promoted SKILL.md");
assert!(promoted_md.contains("plane-workflow"));
assert!(promoted_md.contains("Do plane things."));
drop(tmp);
}
#[test]
fn promotion_is_idempotent_after_first() {
let (tmp, source, target) = promotion_fixture();
let mut promoter = SkillPromoter::new(
SkillPromotionConfig::default()
.with_threshold(2)
.with_target_dir(&target),
);
assert!(matches!(
promoter.record_success("plane-workflow", &source),
PromotionOutcome::Counted { .. }
));
assert!(matches!(
promoter.record_success("plane-workflow", &source),
PromotionOutcome::Promoted { .. }
));
match promoter.record_success("plane-workflow", &source) {
PromotionOutcome::AlreadyPromoted { target: t } => {
assert!(t.ends_with("plane-workflow"));
}
other => panic!("expected AlreadyPromoted, got {other:?}"),
}
assert_eq!(promoter.audit_log().len(), 1);
drop(tmp);
}
#[test]
fn promotion_detects_preexisting_target_as_already_promoted() {
let (tmp, source, target) = promotion_fixture();
let dest = target.join("plane-workflow");
std::fs::create_dir_all(&dest).unwrap();
std::fs::write(dest.join("SKILL.md"), "---\nname: plane-workflow\n---\n").unwrap();
let mut promoter = SkillPromoter::new(
SkillPromotionConfig::default()
.with_threshold(1)
.with_target_dir(&target),
);
match promoter.record_success("plane-workflow", &source) {
PromotionOutcome::AlreadyPromoted { target: t } => {
assert_eq!(t, dest);
}
other => panic!("expected AlreadyPromoted, got {other:?}"),
}
assert_eq!(promoter.success_count("plane-workflow"), 0);
drop(tmp);
}
#[test]
fn promotion_accepts_skill_md_path_as_source() {
let (tmp, source, target) = promotion_fixture();
let skill_md = source.join("SKILL.md");
let mut promoter = SkillPromoter::new(
SkillPromotionConfig::default()
.with_threshold(1)
.with_target_dir(&target),
);
match promoter.record_success("plane-workflow", &skill_md) {
PromotionOutcome::Promoted { entry } => {
assert!(entry.target_path.join("SKILL.md").exists());
}
other => panic!("expected Promoted, got {other:?}"),
}
drop(tmp);
}
#[test]
fn promote_skill_directory_rejects_traversal_names() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src");
std::fs::create_dir_all(&src).unwrap();
let dest_root = tmp.path().join("dest");
let err = promote_skill_directory(&src, &dest_root, "../escape").unwrap_err();
assert!(err.contains("invalid skill name"), "{err}");
}
#[test]
fn configurable_threshold_example_ten() {
let (tmp, source, target) = promotion_fixture();
let mut promoter = SkillPromoter::new(
SkillPromotionConfig::default()
.with_threshold(10)
.with_target_dir(&target),
);
for _ in 0..9 {
assert!(matches!(
promoter.record_success("plane-workflow", &source),
PromotionOutcome::Counted { .. }
));
}
assert!(!target.join("plane-workflow").exists());
assert!(matches!(
promoter.record_success("plane-workflow", &source),
PromotionOutcome::Promoted { .. }
));
assert!(target.join("plane-workflow").join("SKILL.md").exists());
drop(tmp);
}
}