Implement M4.1: Skill draft command + 10 tests (229 total)
This commit is contained in:
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -90,6 +90,19 @@ enum Commands {
|
||||
/// Write lessons out as SKILL.md files and a CLAUDE.md digest
|
||||
Materialize,
|
||||
|
||||
/// Draft a skill from a memory note
|
||||
SkillDraft {
|
||||
/// Project name
|
||||
#[arg(long, value_name = "PROJECT")]
|
||||
project: String,
|
||||
/// Query ID or memory note identifier
|
||||
#[arg(long, value_name = "QUERY_ID")]
|
||||
from: String,
|
||||
/// Dry run - print without writing
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
},
|
||||
|
||||
/// Start HTTP server
|
||||
Serve {
|
||||
#[arg(long, default_value = "8080")]
|
||||
@@ -138,6 +151,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
floor,
|
||||
} => lessons_cmd::cmd_lookup(tool.as_deref(), cmd.as_deref(), file.as_deref(), floor)?,
|
||||
Commands::Materialize => lessons_cmd::cmd_materialize()?,
|
||||
Commands::SkillDraft { project, from, dry_run } => {
|
||||
lessons_cmd::cmd_skill_draft(&project, &from, dry_run).await?
|
||||
}
|
||||
Commands::Serve { port, api_key, database_url } => {
|
||||
let api_key = api_key.unwrap_or_else(|| std::env::var("MEM_API_KEY").unwrap_or_else(|_| "test-key".to_string()));
|
||||
let database_url = database_url.unwrap_or_else(|| std::env::var("DATABASE_URL").unwrap_or_else(|_| "postgresql://app:poimen@localhost:5432/memory".to_string()));
|
||||
|
||||
Reference in New Issue
Block a user