1use std::fs;
28use std::io::{self, Write};
29use std::path::{Path, PathBuf};
30
31use anyhow::Context as _;
32use async_trait::async_trait;
33use oxi_sdk::{AgentTool, AgentToolResult, ToolContext};
34use serde::{Deserialize, Serialize};
35use serde_json::{Value, json};
36
37use crate::kernel_handle::KernelHandle;
38use crate::skill::SkillManager;
39use crate::skill::frontmatter::parse_skill;
40
41pub struct SkillForgeTool {
47 skill_manager: std::sync::Arc<SkillManager>,
48}
49
50impl SkillForgeTool {
51 pub fn from_kernel(kernel: &KernelHandle) -> Self {
53 Self {
54 skill_manager: kernel.extensions.skill_manager().clone(),
55 }
56 }
57}
58
59impl std::fmt::Debug for SkillForgeTool {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 f.debug_struct("SkillForgeTool").finish()
62 }
63}
64
65#[async_trait]
66impl AgentTool for SkillForgeTool {
67 fn name(&self) -> &str {
68 "skill_forge"
69 }
70
71 fn label(&self) -> &str {
72 "Skill Forge"
73 }
74
75 fn description(&self) -> &'static str {
76 "Create, validate, package, import, and manage skills. \
77 A skill is a folder with a SKILL.md file: YAML frontmatter with `name` and `description` \
78 (the description is the PRIMARY trigger — write it to fire when the user wants this \
79 capability, and make it a little pushy), followed by markdown instructions. \
80 Skills use progressive disclosure: (1) name+description always in context, (2) the SKILL.md \
81 body loaded when the skill triggers (keep under ~500 lines), (3) optional bundled \
82 resources — `scripts/`, `references/`, `assets/` — read on demand via their absolute path. \
83 Use `create` to scaffold a new skill, `write` to author rich SKILL.md content, `validate` \
84 to check structure before shipping, and `package` to export a distributable `.skill` zip. \
85 Use this tool whenever the user wants to build, edit, test, package, or ship a skill."
86 }
87
88 fn parameters_schema(&self) -> Value {
89 json!({
90 "type": "object",
91 "properties": {
92 "action": {
93 "type": "string",
94 "enum": ["list", "get", "create", "write", "validate", "package",
95 "import", "delete", "enable", "disable", "benchmark", "view"],
96 "description": "Skill operation. Authoring: create/write/validate/package/import/list/get/delete/enable/disable. Eval (deterministic post-processing): `benchmark` aggregates grading.json files in an eval workspace iteration into benchmark.json+benchmark.md (mean±stddev, delta); `view` generates a static self-contained HTML viewer of an eval iteration."
97 },
98 "name": {
99 "type": "string",
100 "description": "Skill name (lowercase, hyphens). Required for get/create/write/validate/package/delete/enable/disable. For `import`, an optional hint when frontmatter has no name."
101 },
102 "description": {
103 "type": "string",
104 "description": "One-line description of when the skill should trigger (create action)"
105 },
106 "content": {
107 "type": "string",
108 "description": "create: the markdown body (frontmatter synthesized for you). write/import: the FULL raw SKILL.md including frontmatter. validate: full raw SKILL.md when `name` is omitted."
109 },
110 "workspace": {
111 "type": "string",
112 "description": "benchmark/view: absolute path to an eval workspace ITERATION directory (contains per-eval subdirs each with with_skill/ and a baseline run holding grading.json + timing.json)."
113 },
114 "skill_name": {
115 "type": "string",
116 "description": "benchmark/view: skill name for labeling the report (optional)."
117 },
118 "output": {
119 "type": "string",
120 "description": "view: where to write the static HTML file (optional; defaults to <workspace>/review.html)."
121 }
122 },
123 "required": ["action"]
124 })
125 }
126
127 async fn execute(
128 &self,
129 _tool_call_id: &str,
130 params: Value,
131 _signal: Option<tokio::sync::oneshot::Receiver<()>>,
132 _ctx: &ToolContext,
133 ) -> Result<AgentToolResult, oxi_sdk::ToolError> {
134 let action = params
135 .get("action")
136 .and_then(|v| v.as_str())
137 .ok_or_else(|| "Missing required parameter: action".to_string())?;
138 let name = params.get("name").and_then(|v| v.as_str());
139 let description = params.get("description").and_then(|v| v.as_str());
140 let content = params.get("content").and_then(|v| v.as_str());
141 let workspace = params.get("workspace").and_then(|v| v.as_str());
142 let skill_name = params.get("skill_name").and_then(|v| v.as_str());
143 let output = params.get("output").and_then(|v| v.as_str());
144
145 match action {
146 "list" => self.act_list().await,
147 "get" => {
148 let name = name.ok_or("'get' requires 'name'")?;
149 self.act_get(name).await
150 }
151 "create" => {
152 let name = name.ok_or("'create' requires 'name'")?;
153 let description = description.unwrap_or("");
154 let content = content.unwrap_or("");
155 self.act_create(name, description, content).await
156 }
157 "write" => {
158 let name = name.ok_or("'write' requires 'name'")?;
159 let content = content.ok_or("'write' requires 'content' (full raw SKILL.md)")?;
160 self.act_write(name, content).await
161 }
162 "validate" => self.act_validate(name, content).await,
163 "package" => {
164 let name = name.ok_or("'package' requires 'name'")?;
165 self.act_package(name).await
166 }
167 "import" => {
168 let content = content.ok_or("'import' requires 'content' (raw SKILL.md text)")?;
169 self.act_import(content, name).await
170 }
171 "delete" => {
172 let name = name.ok_or("'delete' requires 'name'")?;
173 self.act_delete(name).await
174 }
175 "enable" => {
176 let name = name.ok_or("'enable' requires 'name'")?;
177 self.act_set_enabled(name, true).await
178 }
179 "disable" => {
180 let name = name.ok_or("'disable' requires 'name'")?;
181 self.act_set_enabled(name, false).await
182 }
183 "benchmark" => {
184 let workspace =
185 workspace.ok_or("'benchmark' requires 'workspace' (iteration dir)")?;
186 self.act_benchmark(workspace, skill_name).await
187 }
188 "view" => {
189 let workspace = workspace.ok_or("'view' requires 'workspace' (iteration dir)")?;
190 self.act_view(workspace, skill_name, output).await
191 }
192 other => Ok(AgentToolResult::error(format!("Unknown action: {other}"))),
193 }
194 }
195}
196
197impl SkillForgeTool {
198 async fn act_list(&self) -> Result<AgentToolResult, oxi_sdk::ToolError> {
199 let entries = self.skill_manager.list_skills().await;
200 let rows: Vec<Value> = entries
201 .iter()
202 .map(|e| {
203 json!({
204 "name": e.skill.name,
205 "description": e.skill.description,
206 "status": e.status.to_string(),
207 "format": e.format.to_string(),
208 "bundled": e.bundled,
209 })
210 })
211 .collect();
212 Ok(AgentToolResult::success(
213 serde_json::to_string_pretty(&json!({ "skills": rows, "count": rows.len() }))
214 .unwrap_or_default(),
215 ))
216 }
217
218 async fn act_get(&self, name: &str) -> Result<AgentToolResult, oxi_sdk::ToolError> {
219 match self.skill_manager.get_skill(name).await {
220 Some(e) => {
221 let meta = e.metadata.as_ref();
222 Ok(AgentToolResult::success(
223 serde_json::to_string_pretty(&json!({
224 "name": e.skill.name,
225 "description": e.skill.description,
226 "status": e.status.to_string(),
227 "format": e.format.to_string(),
228 "bundled": e.bundled,
229 "skill_dir": e.skill.path.parent().map(|p| p.display().to_string()),
230 "file_path": e.skill.file_path.display().to_string(),
231 "author": meta.and_then(|m| m.author.clone()),
232 "version": meta.and_then(|m| m.version.clone()),
233 "content": e.skill.content,
234 }))
235 .unwrap_or_default(),
236 ))
237 }
238 None => Ok(AgentToolResult::error(format!("Skill not found: {name}"))),
239 }
240 }
241
242 async fn act_create(
243 &self,
244 name: &str,
245 description: &str,
246 content: &str,
247 ) -> Result<AgentToolResult, oxi_sdk::ToolError> {
248 let synthetic = format!("---\nname: {name}\ndescription: {description}\n---\n\n{content}");
250 let report = validate_skill_content(&synthetic, Some(name));
251 if report.has_errors() {
252 return Ok(AgentToolResult::error(format!(
253 "Refusing to create skill with structural errors:\n{}",
254 report.render()
255 )));
256 }
257 match self
258 .skill_manager
259 .create_skill(name, description, content)
260 .await
261 {
262 Ok(()) => Ok(AgentToolResult::success(
263 serde_json::to_string_pretty(&json!({
264 "ok": true,
265 "name": name,
266 "path": self.skill_dir_of(name).await,
267 "warnings": report.warnings(),
268 }))
269 .unwrap_or_default(),
270 )),
271 Err(e) => Ok(AgentToolResult::error(format!("Create failed: {e}"))),
272 }
273 }
274
275 async fn act_write(
276 &self,
277 name: &str,
278 content: &str,
279 ) -> Result<AgentToolResult, oxi_sdk::ToolError> {
280 let report = validate_skill_content(content, Some(name));
281 if report.has_errors() {
282 return Ok(AgentToolResult::error(format!(
283 "Refusing to write skill with structural errors:\n{}",
284 report.render()
285 )));
286 }
287 match self.skill_manager.write_skill_raw(name, content).await {
288 Ok(entry) => Ok(AgentToolResult::success(
289 serde_json::to_string_pretty(&json!({
290 "ok": true,
291 "name": entry.skill.name,
292 "description": entry.skill.description,
293 "path": entry.skill.file_path.display().to_string(),
294 "warnings": report.warnings(),
295 }))
296 .unwrap_or_default(),
297 )),
298 Err(e) => Ok(AgentToolResult::error(format!("Write failed: {e}"))),
299 }
300 }
301
302 async fn act_validate(
303 &self,
304 name: Option<&str>,
305 content: Option<&str>,
306 ) -> Result<AgentToolResult, oxi_sdk::ToolError> {
307 let report = match (name, content) {
308 (Some(n), Some(c)) => {
309 match self.skill_manager.get_skill(n).await {
311 Some(e) => {
312 let raw = read_skill_md(&e.skill.file_path);
313 validate_skill_content(&raw, Some(n))
314 }
315 None => validate_skill_content(c, Some(n)),
316 }
317 }
318 (Some(n), None) => {
319 let e = self.skill_manager.get_skill(n).await.ok_or_else(|| {
320 format!("Skill not found: {n} (pass `content` to validate raw text)")
321 })?;
322 let raw = read_skill_md(&e.skill.file_path);
323 validate_skill_content(&raw, Some(n))
324 }
325 (None, Some(c)) => validate_skill_content(c, None),
326 (None, None) => {
327 return Ok(AgentToolResult::error(
328 "validate requires `name` (of an installed skill) or `content` (raw SKILL.md)",
329 ));
330 }
331 };
332 Ok(AgentToolResult::success(
333 serde_json::to_string_pretty(&report.to_json()).unwrap_or_default(),
334 ))
335 }
336
337 async fn act_package(&self, name: &str) -> Result<AgentToolResult, oxi_sdk::ToolError> {
338 let entry = self
339 .skill_manager
340 .get_skill(name)
341 .await
342 .ok_or_else(|| format!("Skill not found: {name}"))?;
343 let skill_dir = entry
344 .skill
345 .path
346 .parent()
347 .ok_or("skill has no directory")?
348 .to_path_buf();
349 let out = self.skill_manager.path().join(format!("{name}.skill"));
350 match package_skill_dir(&skill_dir, name, &out) {
351 Ok(path) => Ok(AgentToolResult::success(
352 serde_json::to_string_pretty(&json!({
353 "ok": true,
354 "name": name,
355 "archive": path.display().to_string(),
356 }))
357 .unwrap_or_default(),
358 )),
359 Err(e) => Ok(AgentToolResult::error(format!("Package failed: {e}"))),
360 }
361 }
362
363 async fn act_import(
364 &self,
365 content: &str,
366 name_hint: Option<&str>,
367 ) -> Result<AgentToolResult, oxi_sdk::ToolError> {
368 match self
369 .skill_manager
370 .import_skill_text(content, name_hint)
371 .await
372 {
373 Ok(entry) => Ok(AgentToolResult::success(
374 serde_json::to_string_pretty(&json!({
375 "ok": true,
376 "name": entry.skill.name,
377 "description": entry.skill.description,
378 "path": entry.skill.file_path.display().to_string(),
379 }))
380 .unwrap_or_default(),
381 )),
382 Err(e) => Ok(AgentToolResult::error(format!("Import failed: {e}"))),
383 }
384 }
385
386 async fn act_delete(&self, name: &str) -> Result<AgentToolResult, oxi_sdk::ToolError> {
387 match self.skill_manager.delete_skill(name).await {
388 Ok(()) => Ok(AgentToolResult::success(
389 serde_json::to_string_pretty(&json!({ "ok": true, "deleted": name }))
390 .unwrap_or_default(),
391 )),
392 Err(e) => Ok(AgentToolResult::error(format!("Delete failed: {e}"))),
393 }
394 }
395
396 async fn act_set_enabled(
397 &self,
398 name: &str,
399 enabled: bool,
400 ) -> Result<AgentToolResult, oxi_sdk::ToolError> {
401 let op = if enabled { "enable" } else { "disable" };
402 match self.skill_manager.set_enabled(name, enabled).await {
403 Ok(()) => Ok(AgentToolResult::success(
404 serde_json::to_string_pretty(&json!({
405 "ok": true, "name": name, "enabled": enabled
406 }))
407 .unwrap_or_default(),
408 )),
409 Err(e) => Ok(AgentToolResult::error(format!("{op} failed: {e}"))),
410 }
411 }
412
413 async fn skill_dir_of(&self, name: &str) -> String {
414 self.skill_manager
415 .get_skill(name)
416 .await
417 .and_then(|e| e.skill.path.parent().map(|p| p.display().to_string()))
418 .unwrap_or_default()
419 }
420 async fn act_benchmark(
421 &self,
422 workspace: &str,
423 skill_name: Option<&str>,
424 ) -> Result<AgentToolResult, oxi_sdk::ToolError> {
425 let name = skill_name.unwrap_or("skill");
426 match aggregate_benchmark(Path::new(workspace), name) {
427 Ok(bench) => {
428 let json_path = Path::new(workspace).join("benchmark.json");
429 let md_path = Path::new(workspace).join("benchmark.md");
430 let json_err = std::fs::write(&json_path, bench.to_json_string()).err();
431 let md_err = std::fs::write(&md_path, bench.to_markdown()).err();
432 if let Some(e) = json_err.or(md_err) {
433 return Ok(AgentToolResult::error(format!(
434 "Wrote partial benchmark but failed to persist: {e}"
435 )));
436 }
437 Ok(AgentToolResult::success(
438 serde_json::to_string_pretty(&json!({
439 "ok": true,
440 "benchmark_json": json_path.display().to_string(),
441 "benchmark_md": md_path.display().to_string(),
442 "configs": bench.config_names(),
443 "evals": bench.metadata.eval_count,
444 "delta_pass_rate": bench.delta.pass_rate,
445 }))
446 .unwrap_or_default(),
447 ))
448 }
449 Err(e) => Ok(AgentToolResult::error(format!("Benchmark failed: {e}"))),
450 }
451 }
452
453 async fn act_view(
454 &self,
455 workspace: &str,
456 skill_name: Option<&str>,
457 output: Option<&str>,
458 ) -> Result<AgentToolResult, oxi_sdk::ToolError> {
459 let name = skill_name.unwrap_or("skill");
460 let out = output
461 .map(PathBuf::from)
462 .unwrap_or_else(|| Path::new(workspace).join("review.html"));
463 match generate_review_html(Path::new(workspace), name, &out) {
464 Ok(path) => Ok(AgentToolResult::success(
465 serde_json::to_string_pretty(&json!({
466 "ok": true,
467 "html": path.display().to_string(),
468 "note": "Open this file in a browser. Use the Outputs tab to review each eval and leave feedback; the Benchmark tab shows the quantitative comparison."
469 }))
470 .unwrap_or_default(),
471 )),
472 Err(e) => Ok(AgentToolResult::error(format!("View failed: {e}"))),
473 }
474 }
475}
476
477fn read_skill_md(path: &Path) -> String {
478 fs::read_to_string(path).unwrap_or_default()
479}
480
481#[derive(Debug, Clone)]
485pub struct Finding {
486 pub severity: Severity,
487 pub message: String,
488}
489
490#[derive(Debug, Clone, Copy, PartialEq, Eq)]
491pub enum Severity {
492 Error,
494 Warning,
496}
497
498impl Finding {
499 fn warn(msg: impl Into<String>) -> Self {
500 Self {
501 severity: Severity::Warning,
502 message: msg.into(),
503 }
504 }
505 fn error(msg: impl Into<String>) -> Self {
506 Self {
507 severity: Severity::Error,
508 message: msg.into(),
509 }
510 }
511}
512
513#[derive(Debug, Clone, Default)]
515pub struct ValidationReport {
516 pub findings: Vec<Finding>,
517 pub name: String,
518 pub description: String,
519 pub body_lines: usize,
520 pub format: String,
521}
522
523impl ValidationReport {
524 pub fn has_errors(&self) -> bool {
525 self.findings.iter().any(|f| f.severity == Severity::Error)
526 }
527
528 pub fn warnings(&self) -> Vec<String> {
529 self.findings
530 .iter()
531 .filter(|f| f.severity == Severity::Warning)
532 .map(|f| f.message.clone())
533 .collect()
534 }
535
536 pub fn render(&self) -> String {
537 self.findings
538 .iter()
539 .map(|f| {
540 let lvl = match f.severity {
541 Severity::Error => "ERROR",
542 Severity::Warning => "WARN",
543 };
544 format!(" [{lvl}] {}", f.message)
545 })
546 .collect::<Vec<_>>()
547 .join("\n")
548 }
549
550 pub fn to_json(&self) -> Value {
551 json!({
552 "valid": !self.has_errors(),
553 "name": self.name,
554 "description_chars": self.description.chars().count(),
555 "body_lines": self.body_lines,
556 "format": self.format,
557 "findings": self.findings.iter().map(|f| {
558 let lvl = match f.severity {
559 Severity::Error => "error",
560 Severity::Warning => "warning",
561 };
562 json!({ "severity": lvl, "message": f.message })
563 }).collect::<Vec<_>>(),
564 })
565 }
566}
567
568pub fn validate_skill_content(content: &str, dir_name: Option<&str>) -> ValidationReport {
574 let mut findings = Vec::new();
575
576 let trimmed = content.trim_start();
578 if !trimmed.starts_with("---") {
579 findings.push(Finding::error(
580 "Missing YAML frontmatter (must start with `---`).",
581 ));
582 return ValidationReport {
583 findings,
584 ..Default::default()
585 };
586 }
587
588 let (parsed, body) = match parse_skill(content, &PathBuf::new()) {
590 Ok(v) => v,
591 Err(e) => {
592 findings.push(Finding::error(format!("Frontmatter does not parse: {e}")));
593 return ValidationReport {
594 findings,
595 ..Default::default()
596 };
597 }
598 };
599
600 let name = parsed.name.trim().to_string();
601 let description = parsed.description.trim().to_string();
602 let body_lines = body.lines().count();
603
604 if name.is_empty() {
606 findings.push(Finding::error("Frontmatter is missing `name`."));
607 } else {
608 if !is_valid_skill_name(&name) {
609 findings.push(Finding::error(format!(
610 "Skill name `{name}` is invalid — use lowercase ascii letters, digits, and hyphens only."
611 )));
612 }
613 if let Some(dir) = dir_name
614 && dir != name
615 {
616 findings.push(Finding::error(format!(
617 "Frontmatter name `{name}` does not match the skill directory `{dir}`. They must be identical for a packaged skill."
618 )));
619 }
620 }
621
622 if description.is_empty() {
624 findings.push(Finding::error(
625 "Frontmatter is missing `description` — this is the PRIMARY trigger and must state when to use the skill.",
626 ));
627 } else if description.chars().count() < 15 {
628 findings.push(Finding::warn(
629 "Description is very short. It is the primary trigger — describe both what the skill does AND when to use it. A little pushy is good.",
630 ));
631 }
632
633 if body.trim().is_empty() {
635 findings.push(Finding::error(
636 "SKILL.md body is empty — the skill has no instructions.",
637 ));
638 } else if body_lines > 500 {
639 findings.push(Finding::warn(format!(
640 "Body is {body_lines} lines. Progressive disclosure aims for <500; consider moving detail into `references/` and pointing to it."
641 )));
642 }
643
644 ValidationReport {
645 findings,
646 name,
647 description,
648 body_lines,
649 format: parsed.format.to_string(),
650 }
651}
652
653pub fn is_valid_skill_name(name: &str) -> bool {
656 !name.is_empty()
657 && !name.starts_with('-')
658 && !name.ends_with('-')
659 && name
660 .chars()
661 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
662}
663
664const EXCLUDE_DIRS: &[&str] = &["__pycache__", "node_modules", ".git"];
668const EXCLUDE_FILES: &[&str] = &[".DS_Store"];
669const ROOT_EXCLUDE_DIRS: &[&str] = &["evals"];
671
672pub fn package_skill_dir(
679 skill_dir: &Path,
680 skill_name: &str,
681 out: &Path,
682) -> anyhow::Result<PathBuf> {
683 use zip::ZipWriter;
684 use zip::write::SimpleFileOptions;
685
686 anyhow::ensure!(
687 skill_dir.is_dir(),
688 "skill directory not found: {}",
689 skill_dir.display()
690 );
691 anyhow::ensure!(
692 skill_dir.join("SKILL.md").exists(),
693 "SKILL.md not found in {}",
694 skill_dir.display()
695 );
696
697 if let Some(parent) = out.parent() {
698 fs::create_dir_all(parent)?;
699 }
700 let file = fs::File::create(out)?;
701 let mut zip = ZipWriter::new(file);
702 let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
703
704 let mut stack: Vec<(PathBuf, String)> = vec![(skill_dir.to_path_buf(), skill_name.to_string())];
705 while let Some((dir, prefix)) = stack.pop() {
706 let entries = match fs::read_dir(&dir) {
707 Ok(e) => e,
708 Err(e) => {
709 tracing::warn!(dir = %dir.display(), error = %e, "skipping unreadable dir");
710 continue;
711 }
712 };
713 for entry in entries.flatten() {
714 let path = entry.path();
715 let file_name = match path.file_name().and_then(|n| n.to_str()) {
716 Some(n) => n.to_string(),
717 None => continue,
718 };
719 let file_type = match fs::symlink_metadata(&path) {
723 Ok(m) => m.file_type(),
724 Err(e) => {
725 tracing::warn!(path = %path.display(), error = %e, "skipping unreadable entry");
726 continue;
727 }
728 };
729 if file_type.is_symlink() {
730 tracing::warn!(path = %path.display(), "skipping symlink in skill tree");
731 continue;
732 }
733
734 let is_root = prefix == skill_name;
736 if file_type.is_dir() {
737 if EXCLUDE_DIRS.contains(&file_name.as_str()) {
738 continue;
739 }
740 if is_root && ROOT_EXCLUDE_DIRS.contains(&file_name.as_str()) {
741 continue;
742 }
743 stack.push((path, format!("{prefix}/{file_name}")));
744 } else if file_type.is_file() {
745 if EXCLUDE_FILES.contains(&file_name.as_str()) {
746 continue;
747 }
748 if file_name.ends_with(".pyc") {
749 continue;
750 }
751 let arcname = format!("{prefix}/{file_name}");
752 if let Err(e) = add_file_to_zip(&mut zip, &path, &arcname, options) {
753 tracing::warn!(file = %arcname, error = %e, "skipping file");
754 }
755 }
756 }
757 }
758
759 zip.finish()?;
760 Ok(out.to_path_buf())
761}
762
763fn add_file_to_zip(
764 zip: &mut zip::ZipWriter<fs::File>,
765 path: &Path,
766 arcname: &str,
767 options: zip::write::SimpleFileOptions,
768) -> io::Result<()> {
769 let mut f = fs::File::open(path)?;
770 zip.start_file(arcname, options)?;
771 io::copy(&mut f, zip)?;
772 Ok(())
773}
774
775#[allow(dead_code)]
777fn _ensure_write_in_scope() -> Option<Box<dyn Write>> {
778 None
779}
780pub fn aggregate_benchmark(
797 iteration_dir: &Path,
798 skill_name: &str,
799) -> anyhow::Result<BenchmarkData> {
800 use std::collections::HashMap;
801
802 let mut runs: Vec<BenchmarkRun> = Vec::new();
803 let mut buckets: HashMap<String, MetricVec> = HashMap::new();
805
806 let eval_dirs = list_eval_dirs(iteration_dir)?;
807 for (eval_name, eval_dir) in &eval_dirs {
808 for cfg in ["with_skill", "without_skill", "old_skill"] {
809 let run_dir = eval_dir.join(cfg);
810 if !run_dir.join("grading.json").exists() {
811 continue;
812 }
813 let grading = read_grading(&run_dir.join("grading.json"));
814 let timing = read_timing(&run_dir.join("timing.json"));
815 let pass_rate = grading
816 .as_ref()
817 .and_then(|g| g.summary.as_ref())
818 .map(|s| s.pass_rate)
819 .unwrap_or(0.0);
820 let time_seconds = timing.duration_seconds();
821 let tokens = timing.total_tokens.unwrap_or(0.0);
822
823 let label = if cfg == "old_skill" {
825 "without_skill"
826 } else {
827 cfg
828 };
829 let mv = buckets.entry(label.to_string()).or_default();
830 mv.pass_rate.push(pass_rate);
831 mv.time_seconds.push(time_seconds);
832 mv.tokens.push(tokens);
833
834 let expectations = grading
835 .as_ref()
836 .map(|g| g.expectations.clone())
837 .unwrap_or_default();
838 runs.push(BenchmarkRun {
839 eval_name: eval_name.clone(),
840 configuration: label.to_string(),
841 pass_rate,
842 time_seconds,
843 tokens,
844 expectations,
845 });
846 }
847 }
848
849 if runs.is_empty() {
850 anyhow::bail!(
851 "no grading.json files found under {}. Run the eval cases (with_skill + baseline) first; \
852 each run dir must contain a grading.json (see references/schemas.md).",
853 iteration_dir.display()
854 );
855 }
856
857 let mut run_summary: HashMap<String, ConfigSummary> = HashMap::new();
858 for (label, mv) in &buckets {
859 run_summary.insert(
860 label.clone(),
861 ConfigSummary {
862 pass_rate: stats(&mv.pass_rate),
863 time_seconds: stats(&mv.time_seconds),
864 tokens: stats(&mv.tokens),
865 },
866 );
867 }
868
869 let delta = {
870 let with = run_summary.get("with_skill");
871 let without = run_summary.get("without_skill");
872 DeltaSummary {
873 pass_rate: diff(
874 with.map(|c| c.pass_rate.mean),
875 without.map(|c| c.pass_rate.mean),
876 ),
877 time_seconds: diff(
878 with.map(|c| c.time_seconds.mean),
879 without.map(|c| c.time_seconds.mean),
880 ),
881 tokens: diff(with.map(|c| c.tokens.mean), without.map(|c| c.tokens.mean)),
882 }
883 };
884
885 let mut notes = Vec::new();
886 analyze_results(&runs, &mut notes);
888
889 Ok(BenchmarkData {
890 metadata: BenchmarkMeta {
891 skill_name: skill_name.to_string(),
892 timestamp: chrono::Utc::now().to_rfc3339(),
893 eval_count: eval_dirs.len(),
894 configs: buckets.keys().cloned().collect(),
895 },
896 runs,
897 run_summary,
898 delta,
899 notes,
900 })
901}
902
903#[derive(Default)]
904struct MetricVec {
905 pass_rate: Vec<f64>,
906 time_seconds: Vec<f64>,
907 tokens: Vec<f64>,
908}
909
910fn stats(xs: &[f64]) -> ConfigStats {
912 if xs.is_empty() {
913 return ConfigStats {
914 mean: 0.0,
915 stddev: 0.0,
916 min: 0.0,
917 max: 0.0,
918 };
919 }
920 let mean = xs.iter().sum::<f64>() / xs.len() as f64;
921 let variance = if xs.len() > 1 {
922 xs.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (xs.len() - 1) as f64
923 } else {
924 0.0
925 };
926 ConfigStats {
927 mean,
928 stddev: variance.sqrt(),
929 min: xs.iter().cloned().fold(f64::INFINITY, f64::min),
930 max: xs.iter().cloned().fold(f64::NEG_INFINITY, f64::max),
931 }
932}
933
934fn diff(a: Option<f64>, b: Option<f64>) -> String {
936 match (a, b) {
937 (Some(a), Some(b)) => format!("{:+.2}", a - b),
938 _ => "n/a".to_string(),
939 }
940}
941
942fn analyze_results(runs: &[BenchmarkRun], notes: &mut Vec<String>) {
944 use std::collections::HashMap;
945 let mut by_eval: HashMap<String, Vec<(&str, f64)>> = HashMap::new();
947 for r in runs {
948 by_eval
949 .entry(r.eval_name.clone())
950 .or_default()
951 .push((r.configuration.as_str(), r.pass_rate));
952 }
953 for (eval, cfgs) in &by_eval {
954 let all_full = cfgs.iter().all(|(_, p)| (*p - 1.0).abs() < 1e-9);
955 let has_multiple = cfgs.len() > 1;
956 if all_full && has_multiple {
957 notes.push(format!(
958 "Eval '{eval}' passes 100% in every configuration — the assertions may not discriminate skill value."
959 ));
960 }
961 if has_multiple {
962 let vals: Vec<f64> = cfgs.iter().map(|(_, p)| *p).collect();
963 let lo = vals.iter().cloned().fold(f64::INFINITY, f64::min);
964 let hi = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
965 let spread = hi - lo;
966 if spread > 0.4 {
967 notes.push(format!(
968 "Eval '{eval}' shows large pass-rate spread ({spread:.2}) across configurations — inspect for flakiness."
969 ));
970 }
971 }
972 }
973}
974
975fn list_eval_dirs(iteration_dir: &Path) -> anyhow::Result<Vec<(String, PathBuf)>> {
976 let mut out = Vec::new();
977 let entries = fs::read_dir(iteration_dir)
978 .with_context(|| format!("reading iteration dir {}", iteration_dir.display()))?;
979 for entry in entries.flatten() {
980 let path = entry.path();
981 if !path.is_dir() {
982 continue;
983 }
984 let is_eval = ["with_skill", "without_skill", "old_skill"]
986 .iter()
987 .any(|c| path.join(c).join("grading.json").exists());
988 if is_eval {
989 let name = path
990 .file_name()
991 .and_then(|n| n.to_str())
992 .unwrap_or("eval")
993 .to_string();
994 out.push((name, path));
995 }
996 }
997 out.sort_by(|a, b| a.0.cmp(&b.0));
999 Ok(out)
1000}
1001
1002fn read_grading(path: &Path) -> Option<GradingFile> {
1003 let raw = fs::read_to_string(path).ok()?;
1004 serde_json::from_str(&raw).ok()
1005}
1006
1007fn read_timing(path: &Path) -> TimingFile {
1008 fs::read_to_string(path)
1009 .ok()
1010 .and_then(|raw| serde_json::from_str(&raw).ok())
1011 .unwrap_or_default()
1012}
1013
1014#[derive(Debug, Clone, Deserialize)]
1017struct GradingSummary {
1018 #[serde(default)]
1019 pass_rate: f64,
1020}
1021
1022#[derive(Debug, Clone, Default, Deserialize)]
1023struct GradingFile {
1024 #[serde(default)]
1025 summary: Option<GradingSummary>,
1026 #[serde(default)]
1027 expectations: Vec<ExpectationRow>,
1028}
1029
1030#[derive(Debug, Clone, Serialize, Deserialize)]
1031pub struct ExpectationRow {
1032 #[serde(default)]
1033 text: String,
1034 #[serde(default)]
1035 passed: bool,
1036}
1037
1038#[derive(Debug, Clone, Default, Deserialize)]
1039struct TimingFile {
1040 #[serde(default)]
1041 total_tokens: Option<f64>,
1042 #[serde(default)]
1043 total_duration_seconds: Option<f64>,
1044 #[serde(default)]
1045 duration_ms: Option<f64>,
1046}
1047
1048impl TimingFile {
1049 fn duration_seconds(&self) -> f64 {
1050 self.total_duration_seconds
1051 .or_else(|| self.duration_ms.map(|ms| ms / 1000.0))
1052 .unwrap_or(0.0)
1053 }
1054}
1055
1056#[derive(Debug, Clone, Serialize)]
1057pub struct BenchmarkRun {
1058 pub eval_name: String,
1059 pub configuration: String,
1060 pub pass_rate: f64,
1061 pub time_seconds: f64,
1062 pub tokens: f64,
1063 pub expectations: Vec<ExpectationRow>,
1064}
1065
1066#[derive(Debug, Clone, Serialize)]
1067pub struct ConfigStats {
1068 pub mean: f64,
1069 pub stddev: f64,
1070 pub min: f64,
1071 pub max: f64,
1072}
1073
1074#[derive(Debug, Clone, Serialize)]
1076pub struct ConfigSummary {
1077 pub pass_rate: ConfigStats,
1078 pub time_seconds: ConfigStats,
1079 pub tokens: ConfigStats,
1080}
1081
1082#[derive(Debug, Clone, Serialize)]
1083pub struct DeltaSummary {
1084 pub pass_rate: String,
1085 pub time_seconds: String,
1086 pub tokens: String,
1087}
1088
1089#[derive(Debug, Clone, Serialize)]
1090pub struct BenchmarkMeta {
1091 pub skill_name: String,
1092 pub timestamp: String,
1093 pub eval_count: usize,
1094 pub configs: Vec<String>,
1095}
1096
1097#[derive(Debug, Clone, Serialize)]
1098pub struct BenchmarkData {
1099 pub metadata: BenchmarkMeta,
1100 pub runs: Vec<BenchmarkRun>,
1101 pub run_summary: std::collections::HashMap<String, ConfigSummary>,
1102 pub delta: DeltaSummary,
1103 pub notes: Vec<String>,
1104}
1105
1106impl BenchmarkData {
1107 pub fn config_names(&self) -> Vec<String> {
1108 self.metadata.configs.clone()
1109 }
1110
1111 pub fn to_json_string(&self) -> String {
1112 serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".to_string())
1113 }
1114
1115 pub fn to_markdown(&self) -> String {
1116 let mut md = String::new();
1117 md.push_str(&format!("# Benchmark — {}\n\n", self.metadata.skill_name));
1118 md.push_str(&format!(
1119 "_{} evals · {} · configs: {}_\n\n",
1120 self.metadata.eval_count,
1121 self.metadata.timestamp,
1122 self.metadata.configs.join(", ")
1123 ));
1124 md.push_str("| config | pass_rate (mean ± σ) | time_s (mean ± σ) | tokens (mean ± σ) |\n");
1125 md.push_str("|---|---|---|---|\n");
1126 for cfg in &self.metadata.configs {
1127 if let Some(c) = self.run_summary.get(cfg) {
1128 md.push_str(&format!(
1129 "| {} | {:.2} ± {:.2} | {:.1} ± {:.1} | {:.0} ± {:.0} |\n",
1130 cfg,
1131 c.pass_rate.mean,
1132 c.pass_rate.stddev,
1133 c.time_seconds.mean,
1134 c.time_seconds.stddev,
1135 c.tokens.mean,
1136 c.tokens.stddev
1137 ));
1138 }
1139 }
1140 md.push_str(&format!(
1141 "\n**Δ (with_skill − baseline):** pass_rate {}, time {}s, tokens {}\n",
1142 self.delta.pass_rate, self.delta.time_seconds, self.delta.tokens
1143 ));
1144 if !self.notes.is_empty() {
1145 md.push_str("\n## Notes\n\n");
1146 for n in &self.notes {
1147 md.push_str(&format!("- {n}\n"));
1148 }
1149 }
1150 md
1151 }
1152}
1153
1154pub fn generate_review_html(
1163 iteration_dir: &Path,
1164 skill_name: &str,
1165 out: &Path,
1166) -> anyhow::Result<PathBuf> {
1167 let eval_dirs = list_eval_dirs(iteration_dir)?;
1168 let bench = aggregate_benchmark(iteration_dir, skill_name).ok();
1169
1170 let mut eval_cards = String::new();
1171 for (eval_name, eval_dir) in &eval_dirs {
1172 let prompt = fs::read_to_string(eval_dir.join("eval_metadata.json"))
1173 .ok()
1174 .and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
1175 .and_then(|v| v.get("prompt").and_then(|p| p.as_str()).map(str::to_string))
1176 .unwrap_or_default();
1177 eval_cards.push_str(&render_eval_card(eval_name, &prompt, eval_dir));
1178 }
1179
1180 let benchmark_html = match &bench {
1181 Some(b) => render_benchmark_section(b),
1182 None => "<p><em>No benchmark data yet. Run <code>skill_forge</code> action <code>benchmark</code> after grading.</em></p>".to_string(),
1183 };
1184 let html = HTML_TEMPLATE
1185 .replace("__SKILL_NAME__", &html_escape(skill_name))
1186 .replace("__EVAL_CARDS__", &eval_cards)
1187 .replace("__BENCHMARK__", &benchmark_html);
1188
1189 if let Some(parent) = out.parent() {
1190 fs::create_dir_all(parent).ok();
1191 }
1192 fs::write(out, html)?;
1193 Ok(out.to_path_buf())
1194}
1195
1196fn render_eval_card(eval_name: &str, prompt: &str, eval_dir: &Path) -> String {
1197 let mut card = String::new();
1198 card.push_str(&format!(
1199 "<section class=\"eval\" data-eval=\"{name}\">\n",
1200 name = html_escape(eval_name)
1201 ));
1202 card.push_str(&format!("<h2>{}</h2>\n", html_escape(eval_name)));
1203 if !prompt.is_empty() {
1204 card.push_str(&format!(
1205 "<details open><summary>Prompt</summary><pre>{}</pre></details>\n",
1206 html_escape(prompt)
1207 ));
1208 }
1209 for cfg in ["with_skill", "without_skill", "old_skill"] {
1210 let run_dir = eval_dir.join(cfg);
1211 if !run_dir.exists() {
1212 continue;
1213 }
1214 let label = if cfg == "old_skill" {
1215 "baseline (old_skill)"
1216 } else {
1217 cfg
1218 };
1219 card.push_str(&format!(
1220 "<details><summary><b>{}</b></summary>\n",
1221 html_escape(label)
1222 ));
1223 let outputs_dir = run_dir.join("outputs");
1225 if outputs_dir.is_dir() {
1226 card.push_str("<div class=\"outputs\">");
1227 if let Ok(entries) = fs::read_dir(&outputs_dir) {
1228 for e in entries.flatten() {
1229 if let Some(text) = read_output_text(&e.path()) {
1230 card.push_str(&format!(
1231 "<h4>{}</h4><pre>{}</pre>\n",
1232 html_escape(&e.file_name().to_string_lossy()),
1233 html_escape(&text)
1234 ));
1235 }
1236 }
1237 }
1238 card.push_str("</div>");
1239 }
1240 if let Some(g) = read_grading(&run_dir.join("grading.json")) {
1242 card.push_str("<details><summary>Formal grades</summary><table><tr><th>expectation</th><th>verdict</th></tr>");
1243 for row in &g.expectations {
1244 let verdict = if row.passed { "✅ pass" } else { "❌ fail" };
1245 card.push_str(&format!(
1246 "<tr><td>{}</td><td>{verdict}</td></tr>",
1247 html_escape(&row.text)
1248 ));
1249 }
1250 card.push_str("</table></details>");
1251 }
1252 card.push_str(&format!(
1253 "<textarea data-run=\"{cfg}\" placeholder=\"Leave feedback for {label}…\"></textarea>\n"
1254 ));
1255 card.push_str("</details>\n");
1256 }
1257 card.push_str("</section>\n");
1258 card
1259}
1260
1261fn render_benchmark_section(b: &BenchmarkData) -> String {
1262 let mut s = String::new();
1263 s.push_str("<table><tr><th>config</th><th>pass_rate</th><th>time (s)</th><th>tokens</th></tr>");
1264 for cfg in &b.metadata.configs {
1265 if let Some(c) = b.run_summary.get(cfg) {
1266 s.push_str(&format!(
1267 "<tr><td>{}</td><td>{:.2} ± {:.2}</td><td>{:.1} ± {:.1}</td><td>{:.0} ± {:.0}</td></tr>",
1268 html_escape(cfg), c.pass_rate.mean, c.pass_rate.stddev,
1269 c.time_seconds.mean, c.time_seconds.stddev, c.tokens.mean, c.tokens.stddev
1270 ));
1271 }
1272 }
1273 s.push_str("</table>");
1274 s.push_str(&format!(
1275 "<p><b>Δ (with_skill − baseline):</b> pass_rate {}, time {}s, tokens {}</p>",
1276 html_escape(&b.delta.pass_rate),
1277 html_escape(&b.delta.time_seconds),
1278 html_escape(&b.delta.tokens)
1279 ));
1280 if !b.notes.is_empty() {
1281 s.push_str("<ul>");
1282 for n in &b.notes {
1283 s.push_str(&format!("<li>{}</li>", html_escape(n)));
1284 }
1285 s.push_str("</ul>");
1286 }
1287 s
1288}
1289
1290fn read_output_text(path: &Path) -> Option<String> {
1291 if path.metadata().ok()?.len() > 200_000 {
1293 return Some(format!("[binary or large file: {}]", path.display()));
1294 }
1295 let bytes = fs::read(path).ok()?;
1296 let text = String::from_utf8_lossy(&bytes);
1297 if text.chars().any(|c| c == '\u{0}') {
1298 return Some(format!("[binary file: {}]", path.display()));
1299 }
1300 Some(text.into_owned())
1301}
1302
1303fn html_escape(s: &str) -> String {
1304 s.replace('&', "&")
1305 .replace('<', "<")
1306 .replace('>', ">")
1307 .replace('"', """)
1308}
1309
1310const HTML_TEMPLATE: &str = r#"<!DOCTYPE html>
1311<html lang="en">
1312<head>
1313<meta charset="utf-8">
1314<meta name="viewport" content="width=device-width, initial-scale=1">
1315<title>Skill review — __SKILL_NAME__</title>
1316<style>
1317 :root { color-scheme: light dark; --bg:#fff; --fg:#111; --muted:#666; --line:#ddd; }
1318 @media (prefers-color-scheme: dark){ :root{ --bg:#0d1117; --fg:#e6edf3; --muted:#8b949e; --line:#30363d; } }
1319 body{ font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif; background:var(--bg); color:var(--fg); margin:0 auto; max-width:960px; padding:24px; }
1320 h1,h2{ line-height:1.25; } details{ margin:.5em 0; padding:.5em .75em; border:1px solid var(--line); border-radius:6px; }
1321 summary{ cursor:pointer; font-weight:600; } pre{ background:rgba(127,127,127,.1); padding:.75em; border-radius:6px; overflow:auto; white-space:pre-wrap; word-break:break-word; }
1322 table{ border-collapse:collapse; width:100%; margin:.5em 0; } th,td{ border:1px solid var(--line); padding:.35em .5em; text-align:left; }
1323 textarea{ width:100%; min-height:60px; margin:.5em 0; font:inherit; box-sizing:border-box; background:transparent; color:var(--fg); border:1px solid var(--line); border-radius:6px; padding:.5em; }
1324 nav button{ font:inherit; padding:.4em .9em; margin-right:.5em; border:1px solid var(--line); border-radius:6px; background:transparent; color:var(--fg); cursor:pointer; }
1325 nav button.active{ background:var(--fg); color:var(--bg); } .hidden{ display:none; }
1326</style>
1327</head>
1328<body>
1329<h1>Skill review — __SKILL_NAME__</h1>
1330<nav><button id="tab-outputs" class="active" onclick="show('outputs')">Outputs</button><button id="tab-bench" onclick="show('benchmark')">Benchmark</button><button onclick="download()">Download feedback</button></nav>
1331<div id="outputs">__EVAL_CARDS__</div>
1332<div id="benchmark" class="hidden">__BENCHMARK__</div>
1333<script>
1334function show(id){ document.getElementById('outputs').classList.toggle('hidden',id!=='outputs'); document.getElementById('benchmark').classList.toggle('hidden',id!=='benchmark'); document.getElementById('tab-outputs').classList.toggle('active',id==='outputs'); document.getElementById('tab-bench').classList.toggle('active',id==='benchmark'); }
1335function download(){ const reviews=[]; document.querySelectorAll('section.eval').forEach(s=>{ s.querySelectorAll('textarea').forEach(t=>{ if(t.value && t.value.trim()){ reviews.push({run_id:s.getAttribute('data-eval')+'-'+t.getAttribute('data-run'), feedback:t.value, timestamp:new Date().toISOString()}); } }); }); const blob=new Blob([JSON.stringify({reviews,status:'complete'},null,2)],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='feedback.json'; a.click(); }
1336</script>
1337</body>
1338</html>"#;
1339
1340#[cfg(test)]
1341mod tests {
1342 use super::*;
1343
1344 const GOOD: &str = "---\nname: my-skill\ndescription: Does X when the user wants Y. Fires on mentions of Y or Z.\n---\n\n# My Skill\n\nInstructions here.\n";
1345
1346 #[test]
1347 fn validate_accepts_well_formed_skill() {
1348 let report = validate_skill_content(GOOD, Some("my-skill"));
1349 assert!(!report.has_errors(), "{}", report.render());
1350 assert_eq!(report.name, "my-skill");
1351 }
1352
1353 #[test]
1354 fn validate_flags_missing_frontmatter() {
1355 let report = validate_skill_content("# just a body\n", None);
1356 assert!(report.has_errors());
1357 assert!(report.render().contains("frontmatter"));
1358 }
1359
1360 #[test]
1361 fn validate_flags_missing_name() {
1362 let bad = "---\ndescription: A skill.\n---\n\nbody\n";
1363 let report = validate_skill_content(bad, None);
1364 assert!(report.has_errors());
1365 assert!(report.render().contains("name"));
1366 }
1367
1368 #[test]
1369 fn validate_flags_name_dir_mismatch() {
1370 let report = validate_skill_content(GOOD, Some("other-name"));
1371 assert!(report.has_errors());
1372 assert!(report.render().contains("does not match"));
1373 }
1374
1375 #[test]
1376 fn validate_flags_invalid_name() {
1377 let bad = "---\nname: My Skill!\ndescription: A skill that does the thing when needed.\n---\n\nbody\n";
1378 let report = validate_skill_content(bad, None);
1379 assert!(report.has_errors());
1380 }
1381
1382 #[test]
1383 fn validate_warns_on_short_description() {
1384 let bad = "---\nname: x\ndescription: short\n---\n\nbody\n";
1385 let report = validate_skill_content(bad, None);
1386 assert!(!report.has_errors());
1387 assert!(!report.warnings().is_empty());
1388 }
1389
1390 #[test]
1391 fn validate_warns_on_long_body() {
1392 let long_body: String = "line\n".repeat(600);
1393 let bad = format!(
1394 "---\nname: big\ndescription: A skill that does the thing when the user asks.\n---\n\n{long_body}"
1395 );
1396 let report = validate_skill_content(&bad, None);
1397 assert!(!report.has_errors());
1398 assert!(report.warnings().iter().any(|w| w.contains("500")));
1399 }
1400
1401 #[test]
1402 fn validate_flags_empty_body() {
1403 let bad = "---\nname: empty\ndescription: A skill that does the thing when the user asks.\n---\n\n";
1404 let report = validate_skill_content(bad, None);
1405 assert!(report.has_errors());
1406 }
1407
1408 #[test]
1409 fn is_valid_skill_name_rules() {
1410 assert!(is_valid_skill_name("my-skill"));
1411 assert!(is_valid_skill_name("a"));
1412 assert!(is_valid_skill_name("skill-2"));
1413 assert!(!is_valid_skill_name(""));
1414 assert!(!is_valid_skill_name("-leading"));
1415 assert!(!is_valid_skill_name("trailing-"));
1416 assert!(!is_valid_skill_name("UPPER"));
1417 assert!(!is_valid_skill_name("with space"));
1418 assert!(!is_valid_skill_name("under_score"));
1419 }
1420
1421 #[test]
1422 fn package_round_trip() {
1423 let tmp = tempfile::tempdir().unwrap();
1424 let skill_dir = tmp.path().join("demo-skill");
1425 fs::create_dir_all(&skill_dir).unwrap();
1426 fs::write(
1427 skill_dir.join("SKILL.md"),
1428 "---\nname: demo-skill\ndescription: A demo skill.\n---\n\n# Demo\n",
1429 )
1430 .unwrap();
1431 fs::create_dir_all(skill_dir.join("references")).unwrap();
1432 fs::write(skill_dir.join("references/api.md"), "# API\n").unwrap();
1433 fs::create_dir_all(skill_dir.join("evals")).unwrap();
1435 fs::write(skill_dir.join("evals/evals.json"), "{}").unwrap();
1436 fs::create_dir_all(skill_dir.join("__pycache__")).unwrap();
1438 fs::write(skill_dir.join("__pycache__/x.pyc"), "x").unwrap();
1439 fs::write(skill_dir.join(".DS_Store"), "x").unwrap();
1440
1441 let out = tmp.path().join("demo-skill.skill");
1442 let path = package_skill_dir(&skill_dir, "demo-skill", &out).unwrap();
1443 assert!(path.exists());
1444
1445 let f = fs::File::open(&path).unwrap();
1447 let mut archive = zip::ZipArchive::new(f).unwrap();
1448 let names: Vec<String> = (0..archive.len())
1449 .map(|i| archive.by_index(i).unwrap().name().to_string())
1450 .collect();
1451 assert!(names.iter().any(|n| n == "demo-skill/SKILL.md"));
1452 assert!(names.iter().any(|n| n == "demo-skill/references/api.md"));
1453 assert!(names.iter().all(|n| !n.contains("evals/")));
1455 assert!(names.iter().all(|n| !n.contains("__pycache__")));
1457 assert!(names.iter().all(|n| !n.contains(".DS_Store")));
1458 assert!(names.iter().all(|n| !n.contains(".pyc")));
1459 }
1460 #[cfg(unix)]
1461 #[test]
1462 fn package_skill_skips_symlinks() {
1463 use std::os::unix::fs::symlink;
1464 let tmp = tempfile::tempdir().unwrap();
1465 let secret = tmp.path().join("secret.txt");
1467 fs::write(&secret, "PRIVATE_KEY_CONTENTS").unwrap();
1468
1469 let skill_dir = tmp.path().join("leak-skill");
1470 fs::create_dir_all(&skill_dir).unwrap();
1471 fs::write(
1472 skill_dir.join("SKILL.md"),
1473 "---\nname: leak-skill\ndescription: A skill that tries to smuggle a symlink.\n---\n\n# x\n",
1474 )
1475 .unwrap();
1476 symlink(&secret, skill_dir.join("id_rsa")).unwrap();
1478
1479 let out = tmp.path().join("leak-skill.skill");
1480 package_skill_dir(&skill_dir, "leak-skill", &out).unwrap();
1481
1482 let f = fs::File::open(&out).unwrap();
1483 let mut archive = zip::ZipArchive::new(f).unwrap();
1484 for i in 0..archive.len() {
1485 let name = archive.by_index(i).unwrap().name().to_string();
1486 assert!(
1487 !name.contains("id_rsa"),
1488 "symlink leaked into archive: {name}"
1489 );
1490 }
1491 let bytes = fs::read(&out).unwrap();
1493 assert!(
1494 !String::from_utf8_lossy(&bytes).contains("PRIVATE_KEY_CONTENTS"),
1495 "symlink target contents leaked into .skill archive"
1496 );
1497 }
1498 #[test]
1499 fn aggregate_benchmark_computes_stats_and_delta() {
1500 let tmp = tempfile::tempdir().unwrap();
1501 let iter = tmp.path().join("iteration-1");
1502 let mk_run = |eval: &str, cfg: &str, pass_rate: f64, tokens: f64| {
1503 let dir = iter.join(eval).join(cfg);
1504 fs::create_dir_all(dir.join("outputs")).unwrap();
1505 fs::write(
1506 dir.join("grading.json"),
1507 serde_json::json!({
1508 "summary": {"passed": 2, "failed": 0, "total": 2, "pass_rate": pass_rate},
1509 "expectations": [
1510 {"text": "has X", "passed": true},
1511 {"text": "has Y", "passed": pass_rate > 0.5}
1512 ]
1513 })
1514 .to_string(),
1515 )
1516 .unwrap();
1517 fs::write(
1518 dir.join("timing.json"),
1519 serde_json::json!({"total_tokens": tokens, "total_duration_seconds": 10.0})
1520 .to_string(),
1521 )
1522 .unwrap();
1523 };
1524 mk_run("eval-0", "with_skill", 1.0, 3800.0);
1526 mk_run("eval-0", "without_skill", 0.5, 2100.0);
1527 mk_run("eval-1", "with_skill", 0.9, 4000.0);
1528 mk_run("eval-1", "without_skill", 0.3, 1900.0);
1529
1530 let bench = aggregate_benchmark(&iter, "demo").unwrap();
1531 assert_eq!(bench.metadata.eval_count, 2);
1532 assert!(bench.metadata.configs.contains(&"with_skill".to_string()));
1533 assert!(
1534 bench
1535 .metadata
1536 .configs
1537 .contains(&"without_skill".to_string())
1538 );
1539 let with = bench.run_summary.get("with_skill").unwrap();
1541 assert!((with.pass_rate.mean - 0.95).abs() < 1e-9);
1542 assert!(
1544 bench.delta.pass_rate.starts_with('+'),
1545 "delta={}",
1546 bench.delta.pass_rate
1547 );
1548
1549 let md = bench.to_markdown();
1551 assert!(md.contains("with_skill"));
1552 assert!(md.contains("without_skill"));
1553 }
1554
1555 #[test]
1556 fn aggregate_benchmark_errors_on_empty() {
1557 let tmp = tempfile::tempdir().unwrap();
1558 let iter = tmp.path().join("iteration-1");
1559 fs::create_dir_all(&iter).unwrap();
1560 assert!(aggregate_benchmark(&iter, "demo").is_err());
1561 }
1562
1563 #[test]
1564 fn generate_review_html_writes_self_contained_file() {
1565 let tmp = tempfile::tempdir().unwrap();
1566 let iter = tmp.path().join("iteration-1");
1567 let dir = iter.join("eval-0").join("with_skill");
1568 fs::create_dir_all(dir.join("outputs")).unwrap();
1569 fs::write(dir.join("outputs/result.txt"), "hello world").unwrap();
1570 fs::write(
1571 dir.join("grading.json"),
1572 serde_json::json!({
1573 "summary": {"pass_rate": 1.0},
1574 "expectations": [{"text": "says hello", "passed": true}]
1575 })
1576 .to_string(),
1577 )
1578 .unwrap();
1579 fs::write(
1580 dir.join("timing.json"),
1581 serde_json::json!({"total_tokens": 100.0, "total_duration_seconds": 5.0}).to_string(),
1582 )
1583 .unwrap();
1584 let bdir = iter.join("eval-0").join("without_skill");
1586 fs::create_dir_all(bdir.join("outputs")).unwrap();
1587 fs::write(
1588 bdir.join("grading.json"),
1589 serde_json::json!({"summary": {"pass_rate": 0.0}, "expectations": []}).to_string(),
1590 )
1591 .unwrap();
1592 fs::write(
1593 bdir.join("timing.json"),
1594 serde_json::json!({"total_tokens": 80.0, "total_duration_seconds": 4.0}).to_string(),
1595 )
1596 .unwrap();
1597
1598 let out = tmp.path().join("review.html");
1599 let path = generate_review_html(&iter, "demo", &out).unwrap();
1600 assert!(path.exists());
1601 let html = fs::read_to_string(&path).unwrap();
1602 assert!(html.contains("hello world"));
1603 assert!(html.contains("says hello"));
1604 assert!(html.contains("with_skill"));
1605 assert!(html.contains("download()"));
1607 }
1608}