1use crate::skill::manifest::{Procedure, ProcedureStep, Requirement, SkillManifest, Trigger};
11use crate::skill::mcp::{McpRequirement, SkillCapability};
12use crate::skill::types::TriggerKind;
13use serde::{Deserialize, Serialize};
14use std::collections::{BTreeMap, BTreeSet};
15
16#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
17pub struct TriggerGene {
18 pub kind: TriggerKind,
19 pub pattern: Option<String>,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
23pub struct McpGene {
24 pub tool_pattern: String,
25 pub capability: SkillCapability,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct StepGene {
30 pub intent: Option<String>,
32 pub description: String,
33 pub tool: Option<String>,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct SkillGene {
38 pub triggers: BTreeSet<TriggerGene>,
39 pub steps: Vec<StepGene>,
40 pub requires: BTreeMap<String, String>,
43 pub mcp: BTreeSet<McpGene>,
44}
45
46#[derive(Debug, thiserror::Error)]
47pub enum GeneError {
48 #[error("skill is not procedure-mode (recombine requires procedure-mode skills)")]
49 NotProcedure,
50}
51
52impl SkillGene {
53 pub fn from_manifest(m: &SkillManifest) -> Result<Self, GeneError> {
54 let proc = m
55 .content
56 .procedure
57 .as_ref()
58 .ok_or(GeneError::NotProcedure)?;
59
60 let triggers = m
61 .triggers
62 .iter()
63 .map(|t| TriggerGene {
64 kind: t.kind,
65 pattern: t.pattern.clone(),
66 })
67 .collect();
68
69 let steps = proc
70 .steps
71 .iter()
72 .map(|s| StepGene {
73 intent: s.intent.clone(),
74 description: s.description.clone(),
75 tool: s.tool.clone(),
76 })
77 .collect();
78
79 let requires = m
80 .requires
81 .iter()
82 .map(|r| (r.name.clone(), r.version.clone()))
83 .collect();
84
85 let mcp = m
86 .mcp_requirements
87 .iter()
88 .map(|r| McpGene {
89 tool_pattern: r.tool_pattern.clone(),
90 capability: r.capability,
91 })
92 .collect();
93
94 Ok(SkillGene {
95 triggers,
96 steps,
97 requires,
98 mcp,
99 })
100 }
101
102 pub fn to_procedure(&self) -> Procedure {
106 Procedure {
107 variables: Vec::new(),
108 steps: self
109 .steps
110 .iter()
111 .map(|s| ProcedureStep {
112 description: s.description.clone(),
113 tool: s.tool.clone(),
114 intent: s.intent.clone(),
115 tool_hint: None,
116 ..Default::default()
117 })
118 .collect(),
119 }
120 }
121
122 pub fn to_triggers(&self) -> Vec<Trigger> {
123 self.triggers
124 .iter()
125 .map(|t| Trigger {
126 kind: t.kind,
127 pattern: t.pattern.clone(),
128 })
129 .collect()
130 }
131
132 pub fn to_requirements(&self) -> Vec<Requirement> {
133 self.requires
134 .iter()
135 .map(|(name, version)| Requirement {
136 name: name.clone(),
137 version: version.clone(),
138 })
139 .collect()
140 }
141
142 pub fn to_mcp_requirements(&self) -> Vec<McpRequirement> {
143 self.mcp
144 .iter()
145 .map(|g| McpRequirement {
146 tool_pattern: g.tool_pattern.clone(),
147 capability: g.capability,
148 fallback: String::new(),
149 })
150 .collect()
151 }
152}
153
154#[derive(Debug, Clone, Default, Serialize)]
155pub struct GeneDiff {
156 pub triggers_added: Vec<TriggerGene>,
157 pub triggers_removed: Vec<TriggerGene>,
158 pub steps_added: Vec<StepGene>,
159 pub steps_removed: Vec<StepGene>,
160 pub steps_changed: Vec<(StepGene, StepGene)>,
162 pub requires_changed: Vec<(String, String, String)>,
164 pub requires_added: Vec<(String, String)>,
165 pub requires_removed: Vec<(String, String)>,
166 pub mcp_added: Vec<McpGene>,
167 pub mcp_removed: Vec<McpGene>,
168}
169
170impl GeneDiff {
171 pub fn between(a: &SkillGene, b: &SkillGene) -> Self {
172 let mut d = GeneDiff {
173 triggers_added: b.triggers.difference(&a.triggers).cloned().collect(),
174 triggers_removed: a.triggers.difference(&b.triggers).cloned().collect(),
175 mcp_added: b.mcp.difference(&a.mcp).cloned().collect(),
176 mcp_removed: a.mcp.difference(&b.mcp).cloned().collect(),
177 ..Default::default()
178 };
179
180 for (name, a_ver) in &a.requires {
182 match b.requires.get(name) {
183 None => d.requires_removed.push((name.clone(), a_ver.clone())),
184 Some(b_ver) if b_ver != a_ver => {
185 d.requires_changed
186 .push((name.clone(), a_ver.clone(), b_ver.clone()));
187 }
188 _ => {}
189 }
190 }
191 for (name, b_ver) in &b.requires {
192 if !a.requires.contains_key(name) {
193 d.requires_added.push((name.clone(), b_ver.clone()));
194 }
195 }
196
197 let a_by_intent: BTreeMap<&str, &StepGene> = a
199 .steps
200 .iter()
201 .filter_map(|s| s.intent.as_deref().map(|i| (i, s)))
202 .collect();
203 let b_by_intent: BTreeMap<&str, &StepGene> = b
204 .steps
205 .iter()
206 .filter_map(|s| s.intent.as_deref().map(|i| (i, s)))
207 .collect();
208
209 for (intent, a_step) in &a_by_intent {
210 match b_by_intent.get(intent) {
211 None => d.steps_removed.push((*a_step).clone()),
212 Some(b_step) if a_step != b_step => {
213 d.steps_changed.push(((*a_step).clone(), (*b_step).clone()));
214 }
215 _ => {}
216 }
217 }
218 for (intent, b_step) in &b_by_intent {
219 if !a_by_intent.contains_key(intent) {
220 d.steps_added.push((*b_step).clone());
221 }
222 }
223
224 for s in a.steps.iter().filter(|s| s.intent.is_none()) {
227 d.steps_removed.push(s.clone());
228 }
229 for s in b.steps.iter().filter(|s| s.intent.is_none()) {
230 d.steps_added.push(s.clone());
231 }
232
233 d
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use crate::skill::manifest::{Content, Procedure, ProcedureStep, Trigger, Visibility};
241 use crate::skill::types::{Category, TriggerKind};
242
243 fn manifest_with_steps(steps: Vec<ProcedureStep>, triggers: Vec<Trigger>) -> SkillManifest {
244 SkillManifest {
245 name: "x".into(),
246 version: "0.1.0".into(),
247 publisher: "human:test".into(),
248 description: "t".into(),
249 category: Category::Workflow,
250 scope: Default::default(),
251 visibility: Visibility::default(),
252 fleet: None,
253 team: None,
254 governance: None,
255 project: None,
256 hosts: vec![],
257 content: Content {
258 r#abstract: "a".into(),
259 context: None,
260 procedure: Some(Procedure {
261 variables: vec![],
262 steps,
263 }),
264 command: None,
265 note: None,
266 },
267 requires: vec![],
268 tags: vec![],
269 triggers,
270 priority: Default::default(),
271 evolution_log: vec![],
272 transfer_chain: vec![],
273 mcp_requirements: vec![],
274 provenance: Default::default(),
275 updated_at: chrono::Utc::now(),
276 }
277 }
278
279 #[test]
280 fn from_manifest_extracts_gene_fields() {
281 let m = manifest_with_steps(
282 vec![ProcedureStep {
283 description: "navigate".into(),
284 tool: Some("browser.go".into()),
285 intent: Some("open_page".into()),
286 tool_hint: None,
287 ..Default::default()
288 }],
289 vec![Trigger {
290 kind: TriggerKind::Command,
291 pattern: Some("/x".into()),
292 }],
293 );
294 let g = SkillGene::from_manifest(&m).unwrap();
295 assert_eq!(g.steps.len(), 1);
296 assert_eq!(g.steps[0].intent.as_deref(), Some("open_page"));
297 assert_eq!(g.triggers.len(), 1);
298 }
299
300 #[test]
301 fn from_manifest_rejects_non_procedure() {
302 let mut m = manifest_with_steps(vec![], vec![]);
303 m.content.procedure = None;
304 m.content.context = Some("ctx".into());
305 assert!(matches!(
306 SkillGene::from_manifest(&m),
307 Err(GeneError::NotProcedure)
308 ));
309 }
310
311 #[test]
312 fn diff_detects_added_and_changed_steps() {
313 let a = manifest_with_steps(
314 vec![ProcedureStep {
315 description: "old".into(),
316 tool: None,
317 intent: Some("i1".into()),
318 tool_hint: None,
319 ..Default::default()
320 }],
321 vec![],
322 );
323 let b = manifest_with_steps(
324 vec![
325 ProcedureStep {
326 description: "new desc".into(),
327 tool: None,
328 intent: Some("i1".into()),
329 tool_hint: None,
330 ..Default::default()
331 },
332 ProcedureStep {
333 description: "added".into(),
334 tool: None,
335 intent: Some("i2".into()),
336 tool_hint: None,
337 ..Default::default()
338 },
339 ],
340 vec![],
341 );
342 let ga = SkillGene::from_manifest(&a).unwrap();
343 let gb = SkillGene::from_manifest(&b).unwrap();
344 let d = GeneDiff::between(&ga, &gb);
345 assert_eq!(d.steps_changed.len(), 1);
346 assert_eq!(d.steps_added.len(), 1);
347 assert_eq!(d.steps_removed.len(), 0);
348 }
349
350 #[test]
351 fn diff_treats_intentless_steps_as_unmatched() {
352 let a = manifest_with_steps(
353 vec![ProcedureStep {
354 description: "no-intent".into(),
355 tool: None,
356 intent: None,
357 tool_hint: None,
358 ..Default::default()
359 }],
360 vec![],
361 );
362 let b = manifest_with_steps(vec![], vec![]);
363 let ga = SkillGene::from_manifest(&a).unwrap();
364 let gb = SkillGene::from_manifest(&b).unwrap();
365 let d = GeneDiff::between(&ga, &gb);
366 assert_eq!(d.steps_removed.len(), 1);
367 }
368
369 #[test]
370 fn round_trip_to_procedure_preserves_intent_and_tool() {
371 let g = SkillGene {
372 triggers: BTreeSet::new(),
373 steps: vec![StepGene {
374 intent: Some("i".into()),
375 description: "d".into(),
376 tool: Some("t".into()),
377 }],
378 requires: BTreeMap::new(),
379 mcp: BTreeSet::new(),
380 };
381 let p = g.to_procedure();
382 assert_eq!(p.steps.len(), 1);
383 assert_eq!(p.steps[0].intent.as_deref(), Some("i"));
384 assert_eq!(p.steps[0].tool.as_deref(), Some("t"));
385 assert!(p.steps[0].tool_hint.is_none());
386 }
387}