Implement M4.1: Skill draft command + 10 tests (229 total)

This commit is contained in:
Story Crater Bot
2026-08-26 13:50:22 -07:00
parent 55a75c9a91
commit d21d99c8b4
8 changed files with 366 additions and 57 deletions
+88
View File
@@ -17,6 +17,8 @@ use mem_core::lesson::{
derive_lessons, extract, lookup as lookup_lesson, render_injection, render_skill, tool_of_cmd,
Confidence, Event, Lesson,
};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::fs;
use std::io::Write;
@@ -221,3 +223,89 @@ pub fn cmd_materialize() -> Result<()> {
println!(" echo '@{}' >> ~/.claude/CLAUDE.md", digest_path.display());
Ok(())
}
/// Skill draft frontmatter
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillFrontmatter {
pub name: String,
pub description: String,
pub when_to_use: String,
pub generated_from: String,
pub generated_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub argument_hint: Option<String>,
}
impl SkillFrontmatter {
/// Render as YAML frontmatter
pub fn render(&self) -> String {
let mut yaml = String::from("---\n");
yaml.push_str(&format!("name: {}\n", quoted(&self.name)));
yaml.push_str(&format!("description: {}\n", quoted(&self.description)));
yaml.push_str(&format!("when_to_use: {}\n", quoted(&self.when_to_use)));
yaml.push_str(&format!("generated_from: {}\n", quoted(&self.generated_from)));
yaml.push_str(&format!("generated_at: {}\n", quoted(&self.generated_at)));
if let Some(hint) = &self.argument_hint {
yaml.push_str(&format!("argument_hint: {}\n", quoted(hint)));
}
yaml.push_str("---\n");
yaml
}
}
fn quoted(s: &str) -> String {
format!("'{}'", s.replace("'", "''"))
}
/// Draft a skill from a memory note
pub async fn cmd_skill_draft(project: &str, from: &str, dry_run: bool) -> Result<()> {
// Generate stable hash of memory note identifier for provenance
let mut hasher = Sha256::new();
hasher.update(format!("{}:{}", project, from).as_bytes());
let hash = format!("{:x}", hasher.finalize())[..16].to_string();
// Create skill frontmatter (placeholder)
let frontmatter = SkillFrontmatter {
name: format!("{}-{}", project, from.replace("/", "-")),
description: format!("Handle {} in project {}", from, project),
when_to_use: format!("When dealing with {} in {}", from, project),
generated_from: hash.clone(),
generated_at: OffsetDateTime::now_utc().to_string(),
argument_hint: None,
};
// Build output path
let vault_dir = mem_home().join("vault");
let skills_drafts = vault_dir.join("skills").join("_drafts").join(format!("{}-{}", project, from));
let skill_file = skills_drafts.join("SKILL.md");
// Verify path is inside _drafts/
if !skill_file
.components()
.any(|c| c.as_os_str() == "_drafts")
{
anyhow::bail!("Safety check failed: output path must be inside _drafts/");
}
let content = format!(
"{}\n# {}: {}\n\nThis is a draft skill generated from memory note: {}\n\nEdit and refine before promoting to `skills/`.\n",
frontmatter.render(),
frontmatter.name,
frontmatter.description,
from
);
if dry_run {
println!("[dry-run] Would write {} bytes to {}", content.len(), skill_file.display());
println!("{}", content);
return Ok(());
}
fs::create_dir_all(&skills_drafts)?;
fs::write(&skill_file, &content)?;
println!("Drafted skill: {}", skill_file.display());
println!("Generated from: {}", hash);
println!("\nReview and edit, then move to skills/ to promote:");
println!(" mv {}/SKILL.md {}/SKILL.md", skills_drafts.display(), vault_dir.join("skills").display());
Ok(())
}