1use super::capability::{CapabilityInventory, capability_card, filter_tools_to_inventory};
4use super::store::write_pack;
5use super::types::{
6 GraphEdge, GraphNode, GraphSpec, HarnessPack, LoopSpec, VerifierKind, VerifierSpec,
7};
8use crate::skills::{SkillManifest, SkillWriteResult};
9use anyhow::Result;
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12
13#[derive(Debug, Clone, Default)]
15pub struct MaterializeOptions {
16 pub id_override: Option<String>,
18 pub default_max_turns: Option<u32>,
20 pub default_token_budget: Option<i64>,
22 pub required_skills: HashMap<String, SkillManifest>,
24}
25
26pub fn materialize_from_skill(
31 data_dir: &Path,
32 skill: &SkillManifest,
33 inventory: &CapabilityInventory,
34 options: MaterializeOptions,
35) -> Result<HarnessPack> {
36 let id = options
37 .id_override
38 .clone()
39 .filter(|s| !s.trim().is_empty())
40 .unwrap_or_else(|| skill.id.clone());
41
42 let max_turns = options.default_max_turns.unwrap_or(15);
43 let token_budget = options.default_token_budget;
44
45 let mut ordered: Vec<&SkillManifest> = Vec::new();
47 for req_id in &skill.requires {
48 if let Some(req) = options.required_skills.get(req_id) {
49 ordered.push(req);
50 }
51 }
52 ordered.push(skill);
53
54 let graph = if ordered.len() > 1 {
56 build_multi_skill_graph(ordered, inventory)
57 } else {
58 build_single_skill_graph(skill, inventory)
59 };
60
61 let mut verifiers = Vec::new();
62 if inventory.all_tools.iter().any(|t| t == "bash") {
63 verifiers.push(VerifierSpec {
64 id: "default_verify".into(),
65 kind: VerifierKind::Bash,
66 recipe: "echo 'run project tests via bash when appropriate'".into(),
67 });
68 }
69 if inventory.browser_available {
70 verifiers.push(VerifierSpec {
71 id: "browser_smoke".into(),
72 kind: VerifierKind::Browser,
73 recipe: "browser open/goto/screenshot against local preview when available".into(),
74 });
75 }
76
77 let loop_spec = LoopSpec {
78 id: id.clone(),
79 max_turns,
80 token_budget,
81 stop: vec![
82 "goal.complete".into(),
83 "goal.blocked".into(),
84 "budget".into(),
85 "max_turns".into(),
86 ],
87 };
88
89 let skill_md = format!(
90 "---\nname: {}\nid: {}\ndescription: {}\n---\n\n{}\n",
91 skill.name,
92 skill.id,
93 skill.description.as_deref().unwrap_or(""),
94 skill.instructions.trim()
95 );
96
97 let mut cap = capability_card(inventory);
98 cap.push_str("\n\n## Materialize notes\n");
99 cap.push_str(&format!(
100 "- source_skill: {}\n- graph_entry: {}\n- nodes: {}\n",
101 skill.id,
102 graph.entry,
103 graph
104 .nodes
105 .iter()
106 .map(|n| n.id.as_str())
107 .collect::<Vec<_>>()
108 .join(", ")
109 ));
110 let gaps: Vec<&str> = skill
111 .allow_tools
112 .iter()
113 .filter(|t| !inventory.all_tools.iter().any(|a| a == *t))
114 .map(|s| s.as_str())
115 .collect();
116 if !gaps.is_empty() {
117 cap.push_str(&format!(
118 "- filtered_unknown_tools: [{}]\n",
119 gaps.join(", ")
120 ));
121 }
122 if !skill.requires.is_empty() {
123 cap.push_str(&format!(
124 "- required_skills: [{}]\n",
125 skill.requires.join(", ")
126 ));
127 let missing: Vec<&str> = skill
128 .requires
129 .iter()
130 .filter(|r| !options.required_skills.contains_key(*r))
131 .map(|s| s.as_str())
132 .collect();
133 if !missing.is_empty() {
134 cap.push_str(&format!(
135 "- missing_required_skills: [{}]\n",
136 missing.join(", ")
137 ));
138 }
139 }
140
141 let pack = HarnessPack {
142 id: id.clone(),
143 root: PathBuf::new(),
144 loop_spec,
145 graph: Some(graph),
146 verifiers,
147 capability_md: Some(cap),
148 skill_md: Some(skill_md),
149 };
150
151 let root = write_pack(data_dir, &pack)?;
152 let mut out = pack;
153 out.root = root;
154 Ok(out)
155}
156
157pub fn materialize_after_save(
162 data_dir: &Path,
163 result: &SkillWriteResult,
164 required_skills: &[SkillManifest],
165) -> Result<Option<PathBuf>> {
166 if !result.skill.harness && result.skill.requires.is_empty() {
167 return Ok(None);
168 }
169
170 let inv = default_capability_inventory();
171 let mut opts = MaterializeOptions::default();
172 for req in required_skills {
173 opts.required_skills.insert(req.id.clone(), req.clone());
174 }
175 let pack = materialize_from_skill(data_dir, &result.skill, &inv, opts)?;
176 Ok(Some(pack.root))
177}
178
179fn default_capability_inventory() -> CapabilityInventory {
180 use crate::harness_pack::capability::inventory_from_tool_names;
181 inventory_from_tool_names(
182 [
183 "search",
184 "read_file",
185 "edit",
186 "write_file",
187 "bash",
188 "plan",
189 "question",
190 "tool_search",
191 "memory",
192 "set_session_title",
193 "get_goal",
194 "create_goal",
195 "update_goal",
196 "skill_save",
197 "skill_list",
198 "skill_get",
199 "load_skill",
200 ],
201 [
202 "browser",
203 "code",
204 "code_edit",
205 "code_exec",
206 "repo_explore",
207 "subagent",
208 "workflow",
209 "package_manager",
210 "ast_search",
211 "symbol_goto",
212 "view_image",
213 "apply_patch",
214 "sandbox",
215 "analyze_attachment",
216 ],
217 true,
218 true,
219 50,
220 Vec::<String>::new(),
221 Vec::<String>::new(),
222 Vec::<String>::new(),
223 )
224}
225
226fn build_multi_skill_graph(
227 ordered: Vec<&SkillManifest>,
228 inventory: &CapabilityInventory,
229) -> GraphSpec {
230 let mut nodes = Vec::new();
231 let mut edges = Vec::new();
232 let entry = ordered[0].id.clone();
233
234 for (i, skill) in ordered.iter().enumerate() {
235 let role = if i == ordered.len() - 1 {
236 "final".into()
237 } else if skill
238 .allow_tools
239 .iter()
240 .any(|t| t == "write_file" || t == "edit" || t == "bash")
241 {
242 "write".into()
243 } else {
244 "read_only".into()
245 };
246 let tools = if skill.allow_tools.is_empty() {
247 default_core_tools(inventory)
248 } else {
249 filter_tools_to_inventory(&skill.allow_tools, inventory)
250 };
251 nodes.push(GraphNode {
252 id: skill.id.clone(),
253 role,
254 allow_tools: tools,
255 verifiers: vec![],
256 });
257 if i > 0 {
258 edges.push(GraphEdge {
259 from: ordered[i - 1].id.clone(),
260 to: skill.id.clone(),
261 when: None,
262 });
263 }
264 }
265
266 GraphSpec {
267 entry,
268 nodes,
269 edges,
270 }
271}
272
273fn build_single_skill_graph(skill: &SkillManifest, inventory: &CapabilityInventory) -> GraphSpec {
274 let explore_tools = filter_tools_to_inventory(
275 &[
276 "search".into(),
277 "read_file".into(),
278 "tool_search".into(),
279 "browser".into(),
280 "repo_explore".into(),
281 "code".into(),
282 ],
283 inventory,
284 );
285 let write_tools = filter_tools_to_inventory(
286 &[
287 "search".into(),
288 "read_file".into(),
289 "edit".into(),
290 "write_file".into(),
291 "bash".into(),
292 "plan".into(),
293 "tool_search".into(),
294 ],
295 inventory,
296 );
297 let verify_tools = filter_tools_to_inventory(
298 &[
299 "bash".into(),
300 "browser".into(),
301 "read_file".into(),
302 "search".into(),
303 ],
304 inventory,
305 );
306
307 let mut nodes = Vec::new();
308 let mut edges = Vec::new();
309 let entry;
310
311 if !explore_tools.is_empty() && !write_tools.is_empty() {
312 entry = "explore".into();
313 nodes.push(GraphNode {
314 id: "explore".into(),
315 role: "read_only".into(),
316 allow_tools: explore_tools,
317 verifiers: vec![],
318 });
319 nodes.push(GraphNode {
320 id: "implement".into(),
321 role: "write".into(),
322 allow_tools: write_tools,
323 verifiers: vec![],
324 });
325 edges.push(GraphEdge {
326 from: "explore".into(),
327 to: "implement".into(),
328 when: None,
329 });
330 if !verify_tools.is_empty() {
331 nodes.push(GraphNode {
332 id: "verify".into(),
333 role: "verify".into(),
334 allow_tools: verify_tools.clone(),
335 verifiers: vec!["default_verify".into()],
336 });
337 edges.push(GraphEdge {
338 from: "implement".into(),
339 to: "verify".into(),
340 when: None,
341 });
342 edges.push(GraphEdge {
343 from: "verify".into(),
344 to: "implement".into(),
345 when: Some("verify.failed".into()),
346 });
347 }
348 } else {
349 entry = "main".into();
350 nodes.push(GraphNode {
351 id: "main".into(),
352 role: "default".into(),
353 allow_tools: if skill.allow_tools.is_empty() {
354 default_core_tools(inventory)
355 } else {
356 filter_tools_to_inventory(&skill.allow_tools, inventory)
357 },
358 verifiers: vec![],
359 });
360 }
361
362 GraphSpec {
363 entry,
364 nodes,
365 edges,
366 }
367}
368
369fn default_core_tools(inventory: &CapabilityInventory) -> Vec<String> {
370 filter_tools_to_inventory(
371 &[
372 "search".into(),
373 "read_file".into(),
374 "edit".into(),
375 "write_file".into(),
376 "bash".into(),
377 "plan".into(),
378 "question".into(),
379 "tool_search".into(),
380 "memory".into(),
381 ],
382 inventory,
383 )
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389 use crate::harness_pack::capability::inventory_from_tool_names;
390 use crate::harness_pack::store::load_pack;
391 use crate::skills::{SkillSource, SkillWriteScope};
392 use std::path::PathBuf;
393
394 fn fixture_skill(allow: &[&str]) -> SkillManifest {
395 SkillManifest {
396 id: "design-loop".into(),
397 name: "Design Loop".into(),
398 description: Some("Preview review harness".into()),
399 version: Some("1.0.0".into()),
400 author: None,
401 tags: vec!["harness".into()],
402 requires: vec![],
403 allow_tools: allow.iter().map(|s| (*s).to_string()).collect(),
404 deny_tools: vec![],
405 harness: true,
406 pool: None,
407 path: PathBuf::from("builtin:design-loop"),
408 instructions: "## Steps\n1. Explore\n2. Fix\n3. Verify preview\n".into(),
409 source: SkillSource::Builtin,
410 scope: SkillWriteScope::User,
411 }
412 }
413
414 #[test]
415 fn materialize_filters_unknown_tools_and_roundtrips() {
416 let dir = tempfile::tempdir().unwrap();
417 let inv = inventory_from_tool_names(
418 [
419 "search",
420 "read_file",
421 "edit",
422 "write_file",
423 "bash",
424 "plan",
425 "tool_search",
426 ],
427 ["browser", "subagent"],
428 true,
429 true,
430 50,
431 None::<String>,
432 None::<String>,
433 None::<String>,
434 );
435 let skill = fixture_skill(&["search", "read_file", "a11y_audit", "browser", "edit"]);
436 let pack = materialize_from_skill(dir.path(), &skill, &inv, MaterializeOptions::default())
437 .unwrap();
438 assert_eq!(pack.id, "design-loop");
439 assert!(pack.root.join("loop.toml").is_file());
440 assert!(pack.root.join("graph.toml").is_file());
441
442 let graph = pack.graph.as_ref().unwrap();
444 for node in &graph.nodes {
445 assert!(
446 !node.allow_tools.iter().any(|t| t == "a11y_audit"),
447 "invented tool in {:?}",
448 node.allow_tools
449 );
450 }
451 assert!(
452 graph
453 .nodes
454 .iter()
455 .any(|n| n.allow_tools.iter().any(|t| t == "browser"))
456 );
457
458 let reloaded = load_pack(dir.path(), "design-loop").unwrap().unwrap();
459 assert_eq!(reloaded.loop_spec.max_turns, 15);
460 assert_eq!(reloaded.graph.as_ref().unwrap().entry, "explore");
461 }
462
463 #[test]
464 fn materialize_multi_skill_graph_from_requires() {
465 let dir = tempfile::tempdir().unwrap();
466 let inv = inventory_from_tool_names(
467 ["search", "read_file", "write_file", "edit", "bash"],
468 ["browser"],
469 true,
470 true,
471 50,
472 None::<String>,
473 None::<String>,
474 None::<String>,
475 );
476 let node_a = SkillManifest {
477 id: "analyst".into(),
478 name: "Analyst".into(),
479 description: None,
480 version: None,
481 author: None,
482 tags: vec![],
483 requires: vec![],
484 allow_tools: vec!["search".into(), "read_file".into()],
485 deny_tools: vec![],
486 harness: false,
487 pool: None,
488 path: PathBuf::from("store:analyst"),
489 instructions: "Analyze.".into(),
490 source: SkillSource::Store,
491 scope: SkillWriteScope::User,
492 };
493 let node_b = SkillManifest {
494 id: "implementer".into(),
495 name: "Implementer".into(),
496 description: None,
497 version: None,
498 author: None,
499 tags: vec![],
500 requires: vec![],
501 allow_tools: vec!["write_file".into(), "edit".into()],
502 deny_tools: vec![],
503 harness: false,
504 pool: None,
505 path: PathBuf::from("store:implementer"),
506 instructions: "Implement.".into(),
507 source: SkillSource::Store,
508 scope: SkillWriteScope::User,
509 };
510 let main = SkillManifest {
511 id: "design-loop".into(),
512 name: "Design Loop".into(),
513 description: Some("Loop".into()),
514 version: None,
515 author: None,
516 tags: vec!["harness".into()],
517 requires: vec!["analyst".into(), "implementer".into()],
518 allow_tools: vec!["read_file".into()],
519 deny_tools: vec![],
520 harness: true,
521 pool: None,
522 path: PathBuf::from("store:design-loop"),
523 instructions: "Run design loop.".into(),
524 source: SkillSource::Store,
525 scope: SkillWriteScope::User,
526 };
527 let mut opts = MaterializeOptions::default();
528 opts.required_skills.insert("analyst".into(), node_a);
529 opts.required_skills.insert("implementer".into(), node_b);
530 let pack = materialize_from_skill(dir.path(), &main, &inv, opts).unwrap();
531 let graph = pack.graph.as_ref().unwrap();
532 assert_eq!(graph.entry, "analyst");
533 assert_eq!(graph.nodes.len(), 3);
534 assert!(graph.nodes.iter().any(|n| n.id == "analyst"));
535 assert!(graph.nodes.iter().any(|n| n.id == "implementer"));
536 assert!(graph.nodes.iter().any(|n| n.id == "design-loop"));
537 let analyst = graph.nodes.iter().find(|n| n.id == "analyst").unwrap();
538 assert!(analyst.allow_tools.contains(&"search".into()));
539 assert!(!analyst.allow_tools.contains(&"write_file".into()));
540 }
541}