Skip to main content

skill_harness/
compose.rs

1//! Composition-plan validation for reusable agent skill systems.
2
3use anyhow::{Context, Result};
4use std::collections::BTreeSet;
5use std::path::Path;
6
7pub const REQUIRED_SECTIONS: &[&str] = &[
8    "Decision Boundary",
9    "Proposed Skills",
10    "Resource Inventory",
11    "Invocation Policy",
12    "Validation Plan",
13    "Recommendation",
14];
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct CompositionPlanReport {
18    pub missing_sections: Vec<&'static str>,
19    pub candidate_names: BTreeSet<String>,
20    pub missing_guidance: Vec<&'static str>,
21}
22
23impl CompositionPlanReport {
24    pub fn is_valid(&self) -> bool {
25        self.missing_sections.is_empty()
26            && !self.candidate_names.is_empty()
27            && self.missing_guidance.is_empty()
28    }
29
30    pub fn error_messages(&self) -> Vec<String> {
31        let mut messages = Vec::new();
32        if !self.missing_sections.is_empty() {
33            messages.push(format!(
34                "missing required section(s): {}",
35                self.missing_sections.join(", ")
36            ));
37        }
38        if self.candidate_names.is_empty() {
39            messages.push(
40                "no candidate skill entries found; include at least one `name: skill-name` entry"
41                    .to_string(),
42            );
43        }
44        messages.extend(
45            self.missing_guidance
46                .iter()
47                .map(|message| message.to_string()),
48        );
49        messages
50    }
51}
52
53pub fn validate_composition_plan_path(path: &Path) -> Result<CompositionPlanReport> {
54    let content = std::fs::read_to_string(path)
55        .with_context(|| format!("failed to read {}", path.display()))?;
56    Ok(validate_composition_plan(&content))
57}
58
59pub fn validate_composition_plan(content: &str) -> CompositionPlanReport {
60    let headings: BTreeSet<String> = content.lines().filter_map(markdown_heading).collect();
61    let missing_sections = REQUIRED_SECTIONS
62        .iter()
63        .copied()
64        .filter(|section| !headings.contains(*section))
65        .collect();
66    let candidate_names = content.lines().filter_map(candidate_skill_name).collect();
67    let missing_guidance = missing_guidance(content);
68
69    CompositionPlanReport {
70        missing_sections,
71        candidate_names,
72        missing_guidance,
73    }
74}
75
76fn markdown_heading(line: &str) -> Option<String> {
77    let trimmed = line.trim();
78    if !trimmed.starts_with('#') {
79        return None;
80    }
81    let level = trimmed.chars().take_while(|c| *c == '#').count();
82    if level < 2 {
83        return None;
84    }
85    let title = trimmed[level..].trim();
86    if title.is_empty() {
87        return None;
88    }
89    Some(title.to_string())
90}
91
92fn candidate_skill_name(line: &str) -> Option<String> {
93    let trimmed = line.trim_start();
94    let is_candidate_line = trimmed.starts_with('-') || trimmed.starts_with('#');
95    if !is_candidate_line {
96        return None;
97    }
98
99    let lower = trimmed.to_ascii_lowercase();
100    let start = lower.find("name:")? + "name:".len();
101    let after_name = trimmed[start..].trim_start().trim_start_matches('`');
102    let name: String = after_name
103        .chars()
104        .take_while(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '-')
105        .collect();
106
107    if is_hyphen_case_skill_name(&name) {
108        Some(name)
109    } else {
110        None
111    }
112}
113
114fn is_hyphen_case_skill_name(name: &str) -> bool {
115    let parts: Vec<&str> = name.split('-').collect();
116    parts.len() >= 2
117        && parts.iter().all(|part| {
118            !part.is_empty()
119                && part
120                    .chars()
121                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
122        })
123}
124
125fn missing_guidance(content: &str) -> Vec<&'static str> {
126    let mut messages = Vec::new();
127    if !decision_boundary_has_one_vs_many_rationale(content) {
128        messages.push(
129            "decision boundary must explain the one-skill-vs-many rationale with explicit keep/split criteria",
130        );
131    }
132    if mentions_skill_creation(content) && !has_skill_creator_handoff_boundary(content) {
133        messages.push(
134            "skill-creator handoff boundary missing; name when compose-skills stops and skill-creator begins",
135        );
136    }
137    messages
138}
139
140fn decision_boundary_has_one_vs_many_rationale(content: &str) -> bool {
141    let Some(body) = section_body(content, "Decision Boundary") else {
142        return true;
143    };
144    let lower = body.to_ascii_lowercase();
145    let one_skill_language = lower.contains("one skill")
146        || lower.contains("single skill")
147        || lower.contains("keep it as one")
148        || lower.contains("keep one skill");
149    let split_language = lower.contains("split")
150        || lower.contains("many")
151        || lower.contains("several")
152        || lower.contains("larger than one")
153        || lower.contains("separate");
154    one_skill_language && split_language
155}
156
157fn mentions_skill_creation(content: &str) -> bool {
158    let lower = content.to_ascii_lowercase();
159    lower.contains("skill-creator") || lower.contains("skill creation")
160}
161
162fn has_skill_creator_handoff_boundary(content: &str) -> bool {
163    let lower = content.to_ascii_lowercase();
164    lower.contains("skill-creator") && (lower.contains("handoff") || lower.contains("hand off"))
165}
166
167fn section_body<'a>(content: &'a str, title: &str) -> Option<&'a str> {
168    let mut in_section = false;
169    let mut start = 0;
170    let mut end = content.len();
171
172    for line in content.lines() {
173        let line_start = line.as_ptr() as usize - content.as_ptr() as usize;
174        if let Some(heading) = markdown_heading(line) {
175            if in_section {
176                end = line_start;
177                break;
178            }
179            if heading == title {
180                in_section = true;
181                start = line_start + line.len();
182            }
183        }
184    }
185
186    in_section.then(|| content[start..end].trim())
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    fn valid_plan() -> &'static str {
194        r#"
195## Decision Boundary
196Keep one skill until reuse or safety needs a split.
197
198## Proposed Skills
199- name: compose-skills
200  purpose: Design small skill systems.
201
202## Resource Inventory
203Use references and scripts where they remove repeated reasoning.
204
205## Invocation Policy
206The planning skill is implicit.
207
208## Validation Plan
209Run positive, chained, and negative trigger checks.
210
211## Recommendation
212Create the standalone planning skill.
213"#
214    }
215
216    #[test]
217    fn accepts_valid_composition_plan() {
218        let report = validate_composition_plan(valid_plan());
219        assert!(report.is_valid());
220        assert_eq!(
221            report.candidate_names,
222            BTreeSet::from(["compose-skills".to_string()])
223        );
224    }
225
226    #[test]
227    fn reports_missing_sections_and_missing_candidates() {
228        let report = validate_composition_plan("## Decision Boundary\n\nNo candidates.\n");
229        assert!(!report.is_valid());
230        assert_eq!(
231            report.missing_sections,
232            vec![
233                "Proposed Skills",
234                "Resource Inventory",
235                "Invocation Policy",
236                "Validation Plan",
237                "Recommendation"
238            ]
239        );
240        assert!(report.candidate_names.is_empty());
241    }
242
243    #[test]
244    fn reports_golden_diagnostics_for_malformed_fixture() {
245        let report = validate_composition_plan(include_str!(
246            "../skills/compose-skills/references/fixtures/malformed-plan.md"
247        ));
248        assert_eq!(
249            report.error_messages(),
250            vec![
251                "missing required section(s): Resource Inventory, Invocation Policy, Validation Plan, Recommendation",
252                "no candidate skill entries found; include at least one `name: skill-name` entry",
253                "decision boundary must explain the one-skill-vs-many rationale with explicit keep/split criteria",
254            ]
255        );
256    }
257
258    #[test]
259    fn reports_missing_one_skill_vs_many_rationale() {
260        let report = validate_composition_plan(
261            r#"
262## Decision Boundary
263Create this skill because it sounds useful.
264
265## Proposed Skills
266- name: useful-skill
267
268## Resource Inventory
269Use SKILL.md.
270
271## Invocation Policy
272Implicit.
273
274## Validation Plan
275Forward-test trigger prompts.
276
277## Recommendation
278Proceed.
279"#,
280        );
281        assert_eq!(
282            report.error_messages(),
283            vec![
284                "decision boundary must explain the one-skill-vs-many rationale with explicit keep/split criteria",
285            ]
286        );
287    }
288
289    #[test]
290    fn reports_missing_skill_creator_handoff_boundary() {
291        let report = validate_composition_plan(
292            r#"
293## Decision Boundary
294Keep one skill until there is enough reuse to split into several skills.
295
296## Proposed Skills
297- name: compose-skills
298  purpose: Plan skills before implementation.
299- name: skill-creator
300  purpose: Implement the approved skill.
301
302## Resource Inventory
303Use SKILL.md and templates.
304
305## Invocation Policy
306Implicit.
307
308## Validation Plan
309Forward-test chained prompts.
310
311## Recommendation
312Proceed with both skills.
313"#,
314        );
315        assert_eq!(
316            report.error_messages(),
317            vec![
318                "skill-creator handoff boundary missing; name when compose-skills stops and skill-creator begins",
319            ]
320        );
321    }
322
323    #[test]
324    fn requires_hyphen_case_candidate_names() {
325        let report = validate_composition_plan(
326            "## Proposed Skills\n- name: skill\n- name: ComposeSkills\n- name: compose-skills\n",
327        );
328        assert_eq!(
329            report.candidate_names,
330            BTreeSet::from(["compose-skills".to_string()])
331        );
332    }
333
334    #[test]
335    fn fixture_rejects_malformed_plan() {
336        let report = validate_composition_plan(include_str!(
337            "../skills/compose-skills/references/fixtures/malformed-plan.md"
338        ));
339        assert!(!report.is_valid());
340        assert!(report.candidate_names.is_empty());
341        assert!(report.missing_sections.contains(&"Resource Inventory"));
342    }
343
344    #[test]
345    fn fixture_accepts_ambiguous_one_vs_many_split() {
346        let report = validate_composition_plan(include_str!(
347            "../skills/compose-skills/references/fixtures/ambiguous-one-vs-many.md"
348        ));
349        assert!(report.is_valid());
350        assert_eq!(
351            report.candidate_names,
352            BTreeSet::from(["documentation-cleanup".to_string()])
353        );
354    }
355
356    #[test]
357    fn fixture_accepts_agent_doc_workflow_decomposition() {
358        let report = validate_composition_plan(include_str!(
359            "../skills/compose-skills/references/fixtures/agent-doc-decomposition.md"
360        ));
361        assert!(report.is_valid());
362        assert_eq!(
363            report.candidate_names,
364            BTreeSet::from([
365                "agent-doc-route-diagnostics".to_string(),
366                "agent-doc-session".to_string()
367            ])
368        );
369    }
370
371    #[test]
372    fn fixture_accepts_oversized_workflow_handoff_example() {
373        let report = validate_composition_plan(include_str!(
374            "../skills/compose-skills/references/fixtures/oversized-workflow-handoff.md"
375        ));
376        assert!(report.is_valid());
377        assert_eq!(
378            report.candidate_names,
379            BTreeSet::from([
380                "customer-research".to_string(),
381                "launch-copy".to_string(),
382                "skill-creator".to_string()
383            ])
384        );
385    }
386}