Skip to main content

retro_core/projection/
skill.rs

1use crate::analysis::backend::AnalysisBackend;
2use crate::errors::CoreError;
3use crate::models::{
4    KnowledgeNode, NodeType, Pattern, PatternStatus, PatternType, SkillDraft, SkillValidation,
5    SuggestedTarget,
6};
7use crate::util;
8
9const MAX_RETRIES: usize = 2;
10
11/// Convert a v2 KnowledgeNode to a v1 Pattern for skill generation.
12pub fn node_to_pattern(node: &KnowledgeNode) -> Pattern {
13    let pattern_type = match node.node_type {
14        NodeType::Skill => PatternType::WorkflowPattern,
15        NodeType::Rule | NodeType::Directive => PatternType::RepetitiveInstruction,
16        NodeType::Pattern => PatternType::RecurringMistake,
17        NodeType::Preference | NodeType::Memory => PatternType::WorkflowPattern,
18    };
19    let suggested_target = match node.node_type {
20        NodeType::Skill => SuggestedTarget::Skill,
21        NodeType::Rule
22        | NodeType::Directive
23        | NodeType::Pattern
24        | NodeType::Preference
25        | NodeType::Memory => SuggestedTarget::ClaudeMd,
26    };
27
28    Pattern {
29        id: node.id.clone(),
30        pattern_type,
31        description: node.content.clone(),
32        confidence: node.confidence,
33        times_seen: 1,
34        first_seen: node.created_at,
35        last_seen: node.updated_at,
36        last_projected: None,
37        status: PatternStatus::Active,
38        source_sessions: vec![],
39        related_files: vec![],
40        suggested_content: node.content.clone(),
41        suggested_target,
42        project: node.project_id.clone(),
43        generation_failed: false,
44    }
45}
46
47/// JSON schema for constrained decoding of skill validation responses.
48const SKILL_VALIDATION_SCHEMA: &str = r#"{"type":"object","properties":{"valid":{"type":"boolean"},"feedback":{"type":"string"}},"required":["valid","feedback"],"additionalProperties":false}"#;
49
50/// Generate a skill with retry logic. Returns Err if all attempts fail.
51pub fn generate_with_retry(
52    backend: &dyn AnalysisBackend,
53    pattern: &Pattern,
54    max_retries: usize,
55) -> Result<SkillDraft, CoreError> {
56    let mut feedback = String::new();
57    let retries = max_retries.min(MAX_RETRIES);
58
59    for attempt in 0..=retries {
60        let prompt = build_generation_prompt(pattern, if attempt > 0 { Some(&feedback) } else { None });
61        let response = backend.execute(&prompt, None)?;
62        let content = util::strip_code_fences(&response.text);
63
64        let name = match parse_skill_name(&content) {
65            Some(n) => n,
66            None => {
67                feedback = "The skill must have valid YAML frontmatter with a 'name' field.".to_string();
68                continue;
69            }
70        };
71
72        let draft = SkillDraft {
73            name,
74            content: content.clone(),
75            pattern_id: pattern.id.clone(),
76        };
77
78        // Validate
79        let validation_prompt = build_validation_prompt(&content, pattern);
80        match backend.execute(&validation_prompt, Some(SKILL_VALIDATION_SCHEMA)) {
81            Ok(val_response) => {
82                match parse_validation(&val_response.text) {
83                    Some(v) if v.valid => return Ok(draft),
84                    Some(v) => {
85                        feedback = v.feedback;
86                    }
87                    None => {
88                        // Validation parse failed — accept the draft if it has valid structure
89                        if has_valid_frontmatter(&content) {
90                            return Ok(draft);
91                        }
92                        feedback = "Skill validation response was unparseable.".to_string();
93                    }
94                }
95            }
96            Err(_) => {
97                // Validation call failed — accept draft if structurally valid
98                if has_valid_frontmatter(&content) {
99                    return Ok(draft);
100                }
101                feedback = "Skill validation call failed.".to_string();
102            }
103        }
104    }
105
106    Err(CoreError::Analysis(format!(
107        "skill generation failed after {} retries for pattern {}",
108        retries, pattern.id
109    )))
110}
111
112fn build_generation_prompt(pattern: &Pattern, feedback: Option<&str>) -> String {
113    let feedback_section = match feedback {
114        Some(fb) => format!(
115            "\n\n## Previous Attempt Feedback\n\nYour previous attempt was rejected: {fb}\nPlease address this feedback in your new attempt.\n"
116        ),
117        None => String::new(),
118    };
119
120    let related = if pattern.related_files.is_empty() {
121        "None".to_string()
122    } else {
123        pattern.related_files.join(", ")
124    };
125
126    format!(
127        r#"You are an expert at writing Claude Code skills. A skill is a reusable instruction file that Claude Code discovers and applies automatically.
128
129Generate a skill for the following discovered pattern:
130
131**Pattern Type:** {pattern_type}
132**Description:** {description}
133**Suggested Content:** {suggested_content}
134**Related Files:** {related}
135**Times Seen:** {times_seen}
136{feedback_section}
137## Skill Format
138
139The skill MUST follow this exact format:
140
141```
142---
143name: lowercase-letters-numbers-hyphens-only
144description: Use when [specific triggering conditions]. Include keywords like error messages, tool names, symptoms.
145---
146
147[Skill body: Clear, actionable instructions with specific commands and file paths.]
148```
149
150## Examples
151
152Example 1:
153```
154---
155name: run-tests-after-rust-changes
156description: Use when modifying .rs files in src/, when making code changes that could break functionality, or when the user mentions testing.
157---
158
159After modifying any Rust source file (.rs), always run the test suite:
160
1611. Run `cargo test` in the workspace root
1622. If tests fail, fix the failing tests before proceeding
1633. Run `cargo clippy` to check for warnings
164```
165
166Example 2:
167```
168---
169name: python-uv-package-management
170description: Use when installing Python packages, setting up virtual environments, seeing pip-related errors, or when pyproject.toml is present.
171---
172
173Always use `uv` for Python package management instead of `pip`:
174
1751. Install packages: `uv pip install <package>`
1762. Create virtual environments: `uv venv`
1773. Sync from requirements: `uv pip sync requirements.txt`
1784. Never use bare `pip install`
179```
180
181## Requirements
182
183- **name**: lowercase letters, numbers, and hyphens only. Descriptive of the skill's purpose.
184- **description**: MUST start with "Use when...". Describe TRIGGERING CONDITIONS, not what the skill does. Include relevant keywords (error messages, tool names, file types). Total YAML frontmatter must be under 1024 characters.
185- **body**: Actionable, specific instructions. Use numbered steps for procedures. Reference concrete commands and paths.
186
187Return ONLY the skill content (YAML frontmatter + body), no explanation or wrapping."#,
188        pattern_type = pattern.pattern_type,
189        description = pattern.description,
190        suggested_content = pattern.suggested_content,
191        related = related,
192        times_seen = pattern.times_seen,
193    )
194}
195
196fn build_validation_prompt(skill_content: &str, pattern: &Pattern) -> String {
197    format!(
198        r#"You are a quality reviewer for Claude Code skills. Review the following skill and determine if it meets quality standards.
199
200## Skill Content
201
202```
203{skill_content}
204```
205
206## Original Pattern
207
208**Description:** {description}
209**Suggested Content:** {suggested_content}
210
211## Quality Criteria
212
2131. **name** field: lowercase letters, numbers, and hyphens only
2142. **description**: Starts with "Use when..."
2153. **description**: Describes triggering conditions, NOT what the skill does
2164. **Total YAML frontmatter**: Under 1024 characters
2175. **Body**: Actionable and specific instructions
2186. **Relevance**: Skill actually addresses the original pattern
219
220Return ONLY a JSON object (no markdown wrapping):
221{{"valid": true, "feedback": ""}}
222or
223{{"valid": false, "feedback": "explanation of what needs to be fixed"}}"#,
224        skill_content = skill_content,
225        description = pattern.description,
226        suggested_content = pattern.suggested_content,
227    )
228}
229
230/// Parse the skill name from YAML frontmatter.
231pub fn parse_skill_name(content: &str) -> Option<String> {
232    let lines: Vec<&str> = content.lines().collect();
233    if lines.is_empty() || lines[0].trim() != "---" {
234        return None;
235    }
236
237    for line in &lines[1..] {
238        let trimmed = line.trim();
239        if trimmed == "---" {
240            break;
241        }
242        if let Some(rest) = trimmed.strip_prefix("name:") {
243            let name = rest.trim().trim_matches('"').trim_matches('\'').to_string();
244            if !name.is_empty() && name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
245                return Some(name);
246            }
247        }
248    }
249    None
250}
251
252/// Check if the content has valid frontmatter structure.
253fn has_valid_frontmatter(content: &str) -> bool {
254    let lines: Vec<&str> = content.lines().collect();
255    if lines.is_empty() || lines[0].trim() != "---" {
256        return false;
257    }
258    // Find closing ---
259    lines[1..].iter().any(|line| line.trim() == "---")
260}
261
262/// Parse the validation response JSON.
263/// With `--json-schema` constrained decoding, the response is guaranteed valid JSON.
264fn parse_validation(text: &str) -> Option<SkillValidation> {
265    serde_json::from_str(text.trim()).ok()
266}
267
268/// Determine the skill file path: {project}/.claude/skills/{name}/SKILL.md
269pub fn skill_path(project_root: &str, name: &str) -> String {
270    format!("{project_root}/.claude/skills/{name}/SKILL.md")
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn test_parse_skill_name_valid() {
279        let content = "---\nname: run-tests-after-changes\ndescription: Use when modifying files\n---\n\nBody here.";
280        assert_eq!(parse_skill_name(content), Some("run-tests-after-changes".to_string()));
281    }
282
283    #[test]
284    fn test_parse_skill_name_quoted() {
285        let content = "---\nname: \"my-skill\"\ndescription: Use when stuff\n---\n\nBody.";
286        assert_eq!(parse_skill_name(content), Some("my-skill".to_string()));
287    }
288
289    #[test]
290    fn test_parse_skill_name_invalid_chars() {
291        let content = "---\nname: My Skill Name\ndescription: test\n---\n";
292        assert_eq!(parse_skill_name(content), None);
293    }
294
295    #[test]
296    fn test_parse_skill_name_no_frontmatter() {
297        let content = "Just some text";
298        assert_eq!(parse_skill_name(content), None);
299    }
300
301    #[test]
302    fn test_has_valid_frontmatter() {
303        assert!(has_valid_frontmatter("---\nname: test\n---\nbody"));
304        assert!(!has_valid_frontmatter("no frontmatter"));
305        assert!(!has_valid_frontmatter("---\nno closing delimiter"));
306    }
307
308    #[test]
309    fn test_parse_validation_valid() {
310        let text = r#"{"valid": true, "feedback": ""}"#;
311        let v = parse_validation(text).unwrap();
312        assert!(v.valid);
313        assert!(v.feedback.is_empty());
314    }
315
316    #[test]
317    fn test_parse_validation_invalid() {
318        let text = r#"{"valid": false, "feedback": "description doesn't start with Use when"}"#;
319        let v = parse_validation(text).unwrap();
320        assert!(!v.valid);
321        assert!(v.feedback.contains("Use when"));
322    }
323
324    #[test]
325    fn test_skill_validation_schema_is_valid_json() {
326        let value: serde_json::Value = serde_json::from_str(SKILL_VALIDATION_SCHEMA)
327            .expect("SKILL_VALIDATION_SCHEMA must be valid JSON");
328        assert_eq!(value["type"], "object");
329        assert!(value["properties"]["valid"].is_object());
330        assert!(value["properties"]["feedback"].is_object());
331    }
332
333    #[test]
334    fn test_skill_path() {
335        assert_eq!(
336            skill_path("/home/user/project", "run-tests"),
337            "/home/user/project/.claude/skills/run-tests/SKILL.md"
338        );
339    }
340
341    #[test]
342    fn test_node_to_pattern() {
343        use crate::models::*;
344        use chrono::Utc;
345        let node = KnowledgeNode {
346            id: "node-1".to_string(),
347            node_type: NodeType::Skill,
348            scope: NodeScope::Global,
349            project_id: None,
350            content: "Pre-PR checklist: run tests, lint, format, commit".to_string(),
351            confidence: 0.78,
352            status: NodeStatus::Active,
353            created_at: Utc::now(),
354            updated_at: Utc::now(),
355            projected_at: None,
356            pr_url: None,
357        };
358
359        let pattern = node_to_pattern(&node);
360        assert_eq!(pattern.id, "node-1");
361        assert_eq!(pattern.description, node.content);
362        assert_eq!(pattern.suggested_content, node.content);
363        assert_eq!(pattern.confidence, 0.78);
364        assert_eq!(pattern.suggested_target, SuggestedTarget::Skill);
365    }
366}