1use crate::skills::parse_skill_md;
23use chrono::{DateTime, Utc};
24use serde::Serialize;
25use std::collections::HashSet;
26use std::path::Path;
27
28pub const DEFAULT_MAX_AGENT_SKILLS: usize = 20;
31
32#[derive(Debug, Clone)]
34pub struct AuditConfig {
35 pub similarity: f64,
37 pub stale_days: i64,
40 pub min_body_chars: usize,
43 pub max_agent_skills: usize,
45}
46
47impl Default for AuditConfig {
48 fn default() -> Self {
49 Self {
50 similarity: 0.5,
51 stale_days: 30,
52 min_body_chars: 80,
53 max_agent_skills: DEFAULT_MAX_AGENT_SKILLS,
54 }
55 }
56}
57
58#[derive(Debug, Serialize)]
59pub struct DuplicatePair {
60 pub a: String,
61 pub b: String,
62 pub similarity: f64,
63 pub retire_candidates: Vec<String>,
65}
66
67#[derive(Debug, Serialize)]
68pub struct StaleSkill {
69 pub name: String,
70 pub age_days: Option<i64>,
72}
73
74#[derive(Debug, Serialize)]
75pub struct ThinSkill {
76 pub name: String,
77 pub body_chars: usize,
78}
79
80#[derive(Debug, Serialize)]
81pub struct CapStatus {
82 pub agent_created: usize,
83 pub max: usize,
84 pub at_capacity: bool,
86}
87
88#[derive(Debug, Serialize)]
89pub struct AuditReport {
90 pub skills_dir: String,
91 pub total_skills: usize,
92 pub agent_created: usize,
93 pub near_duplicates: Vec<DuplicatePair>,
94 pub stale: Vec<StaleSkill>,
95 pub thin: Vec<ThinSkill>,
96 pub cap: CapStatus,
97 pub clean: bool,
99}
100
101impl AuditReport {
102 pub fn has_findings(&self) -> bool {
105 !self.near_duplicates.is_empty()
106 || !self.stale.is_empty()
107 || !self.thin.is_empty()
108 || self.cap.at_capacity
109 }
110}
111
112struct ParsedSkill {
114 name: String,
115 source: String,
116 updated_at: Option<String>,
117 tokens: HashSet<String>,
119 body_chars: usize,
120}
121
122impl ParsedSkill {
123 fn is_agent_created(&self) -> bool {
124 self.source == "agent-created"
125 }
126}
127
128fn tokenize(text: &str) -> HashSet<String> {
130 text.split(|c: char| !c.is_alphanumeric())
131 .filter(|t| t.len() >= 3)
132 .map(|t| t.to_lowercase())
133 .collect()
134}
135
136fn jaccard(a: &HashSet<String>, b: &HashSet<String>) -> f64 {
138 if a.is_empty() && b.is_empty() {
139 return 0.0;
140 }
141 let inter = a.intersection(b).count() as f64;
142 let union = a.union(b).count() as f64;
143 if union == 0.0 {
144 0.0
145 } else {
146 inter / union
147 }
148}
149
150fn age_days(updated_at: &str, now: DateTime<Utc>) -> Option<i64> {
152 let parsed = DateTime::parse_from_rfc3339(updated_at).ok()?;
153 Some((now - parsed.with_timezone(&Utc)).num_days())
154}
155
156fn load_skills(skills_dir: &Path) -> Vec<ParsedSkill> {
158 let mut out = Vec::new();
159 let Ok(entries) = std::fs::read_dir(skills_dir) else {
160 return out;
161 };
162 for entry in entries.flatten() {
163 let dir = entry.path();
164 if !dir.is_dir() {
165 continue;
166 }
167 let skill_md = dir.join("SKILL.md");
168 let Ok(content) = std::fs::read_to_string(&skill_md) else {
169 continue;
170 };
171 let Some((meta, body)) = parse_skill_md(&content, &dir) else {
172 continue;
173 };
174 let mut tokens = tokenize(&meta.name);
175 tokens.extend(tokenize(&meta.description));
176 out.push(ParsedSkill {
177 name: meta.name,
178 source: meta.source,
179 updated_at: meta.updated_at,
180 tokens,
181 body_chars: body.trim().chars().count(),
182 });
183 }
184 out.sort_by(|a, b| a.name.cmp(&b.name));
185 out
186}
187
188fn audit_at(
190 skills_dir: &str,
191 skills: &[ParsedSkill],
192 cfg: &AuditConfig,
193 now: DateTime<Utc>,
194) -> AuditReport {
195 let mut near_duplicates = Vec::new();
197 for i in 0..skills.len() {
198 for j in (i + 1)..skills.len() {
199 let sim = jaccard(&skills[i].tokens, &skills[j].tokens);
200 if sim >= cfg.similarity {
201 let mut retire_candidates = Vec::new();
202 if skills[i].is_agent_created() {
203 retire_candidates.push(skills[i].name.clone());
204 }
205 if skills[j].is_agent_created() {
206 retire_candidates.push(skills[j].name.clone());
207 }
208 near_duplicates.push(DuplicatePair {
209 a: skills[i].name.clone(),
210 b: skills[j].name.clone(),
211 similarity: (sim * 1000.0).round() / 1000.0,
212 retire_candidates,
213 });
214 }
215 }
216 }
217
218 let mut stale = Vec::new();
220 let mut thin = Vec::new();
221 let mut agent_created = 0usize;
222 for s in skills {
223 if !s.is_agent_created() {
224 continue;
225 }
226 agent_created += 1;
227
228 let age = s.updated_at.as_deref().and_then(|ts| age_days(ts, now));
229 let is_stale = match age {
230 Some(d) => d >= cfg.stale_days,
231 None => true, };
233 if is_stale {
234 stale.push(StaleSkill {
235 name: s.name.clone(),
236 age_days: age,
237 });
238 }
239
240 if s.body_chars < cfg.min_body_chars {
241 thin.push(ThinSkill {
242 name: s.name.clone(),
243 body_chars: s.body_chars,
244 });
245 }
246 }
247
248 let cap = CapStatus {
249 agent_created,
250 max: cfg.max_agent_skills,
251 at_capacity: agent_created >= cfg.max_agent_skills,
252 };
253
254 let mut report = AuditReport {
255 skills_dir: skills_dir.to_string(),
256 total_skills: skills.len(),
257 agent_created,
258 near_duplicates,
259 stale,
260 thin,
261 cap,
262 clean: false,
263 };
264 report.clean = !report.has_findings();
265 report
266}
267
268pub fn audit_skills(skills_dir: &str, cfg: &AuditConfig) -> AuditReport {
270 let skills = load_skills(Path::new(skills_dir));
271 audit_at(skills_dir, &skills, cfg, Utc::now())
272}
273
274pub fn run_audit(skills_dir: &str, cfg: &AuditConfig, json: bool, strict: bool) -> i32 {
277 let report = audit_skills(skills_dir, cfg);
278
279 if json {
280 match serde_json::to_string_pretty(&report) {
281 Ok(s) => println!("{s}"),
282 Err(e) => eprintln!("failed to serialize report: {e}"),
283 }
284 } else {
285 print_text(&report);
286 }
287
288 if strict && report.has_findings() {
289 1
290 } else {
291 0
292 }
293}
294
295fn print_text(r: &AuditReport) {
296 println!(
297 "Skill audit: {} ({} skills, {} agent-created)",
298 r.skills_dir, r.total_skills, r.agent_created
299 );
300
301 if !r.near_duplicates.is_empty() {
302 println!("\nNear-duplicate skills (merge candidates):");
303 for d in &r.near_duplicates {
304 let retire = if d.retire_candidates.is_empty() {
305 "both built-in/curated".to_string()
306 } else {
307 format!("retire candidate(s): {}", d.retire_candidates.join(", "))
308 };
309 println!(
310 " - {} ~ {} (similarity {:.3}; {})",
311 d.a, d.b, d.similarity, retire
312 );
313 }
314 }
315
316 if !r.stale.is_empty() {
317 println!("\nStale agent-created skills (retire candidates):");
318 for s in &r.stale {
319 match s.age_days {
320 Some(d) => println!(" - {} ({d} days since last update)", s.name),
321 None => println!(" - {} (no/invalid updated_at)", s.name),
322 }
323 }
324 }
325
326 if !r.thin.is_empty() {
327 println!("\nThin agent-created skills (flesh out or retire):");
328 for t in &r.thin {
329 println!(" - {} ({} body chars)", t.name, t.body_chars);
330 }
331 }
332
333 if r.cap.at_capacity {
334 println!(
335 "\nAgent-created skill cap reached: {}/{} — retire some before new ones can be distilled.",
336 r.cap.agent_created, r.cap.max
337 );
338 } else {
339 println!(
340 "\nAgent-created cap: {}/{} ({} slot(s) free).",
341 r.cap.agent_created,
342 r.cap.max,
343 r.cap.max.saturating_sub(r.cap.agent_created)
344 );
345 }
346
347 if r.clean {
348 println!("\nNo curation actions needed.");
349 }
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355
356 fn skill(
357 name: &str,
358 source: &str,
359 desc: &str,
360 updated_at: Option<&str>,
361 body_chars: usize,
362 ) -> ParsedSkill {
363 let mut tokens = tokenize(name);
364 tokens.extend(tokenize(desc));
365 ParsedSkill {
366 name: name.into(),
367 source: source.into(),
368 updated_at: updated_at.map(|s| s.into()),
369 tokens,
370 body_chars,
371 }
372 }
373
374 fn now() -> DateTime<Utc> {
375 DateTime::parse_from_rfc3339("2026-06-04T00:00:00Z")
376 .unwrap()
377 .with_timezone(&Utc)
378 }
379
380 #[test]
381 fn jaccard_basics() {
382 let a = tokenize("deploy the web server quickly");
383 let b = tokenize("deploy the web server quickly");
384 assert!((jaccard(&a, &b) - 1.0).abs() < 1e-9);
385 let c = tokenize("bake a chocolate cake");
386 assert!(jaccard(&a, &c) < 0.2);
387 }
388
389 #[test]
390 fn flags_near_duplicate_and_marks_agent_side() {
391 let skills = vec![
392 skill(
393 "deploy-web",
394 "builtin",
395 "deploy the web server to production",
396 None,
397 500,
398 ),
399 skill(
400 "ship-web",
401 "agent-created",
402 "deploy the web server to production",
403 Some("2026-06-01T00:00:00Z"),
404 500,
405 ),
406 ];
407 let r = audit_at("d", &skills, &AuditConfig::default(), now());
408 assert_eq!(r.near_duplicates.len(), 1);
409 assert_eq!(
411 r.near_duplicates[0].retire_candidates,
412 vec!["ship-web".to_string()]
413 );
414 assert!(r.has_findings());
415 }
416
417 #[test]
418 fn distinct_skills_are_clean() {
419 let skills = vec![
420 skill(
421 "bake-cake",
422 "builtin",
423 "bake a chocolate cake in the oven",
424 None,
425 500,
426 ),
427 skill(
428 "file-taxes",
429 "builtin",
430 "compute and submit annual income taxes",
431 None,
432 500,
433 ),
434 ];
435 let r = audit_at("d", &skills, &AuditConfig::default(), now());
436 assert!(r.near_duplicates.is_empty());
437 assert!(r.clean, "report: {r:?}");
438 }
439
440 #[test]
441 fn stale_only_applies_to_agent_created() {
442 let skills = vec![
443 skill(
445 "old-builtin",
446 "builtin",
447 "alpha beta gamma",
448 Some("2000-01-01T00:00:00Z"),
449 500,
450 ),
451 skill(
453 "old-agent",
454 "agent-created",
455 "delta epsilon zeta",
456 Some("2026-01-01T00:00:00Z"),
457 500,
458 ),
459 skill(
461 "fresh-agent",
462 "agent-created",
463 "eta theta iota",
464 Some("2026-06-03T00:00:00Z"),
465 500,
466 ),
467 ];
468 let r = audit_at("d", &skills, &AuditConfig::default(), now());
469 let names: Vec<&str> = r.stale.iter().map(|s| s.name.as_str()).collect();
470 assert_eq!(names, vec!["old-agent"]);
471 }
472
473 #[test]
474 fn missing_timestamp_is_stale() {
475 let skills = vec![skill(
476 "no-date",
477 "agent-created",
478 "kappa lambda mu",
479 None,
480 500,
481 )];
482 let r = audit_at("d", &skills, &AuditConfig::default(), now());
483 assert_eq!(r.stale.len(), 1);
484 assert_eq!(r.stale[0].age_days, None);
485 }
486
487 #[test]
488 fn thin_body_flagged() {
489 let skills = vec![
490 skill(
491 "thin",
492 "agent-created",
493 "nu xi omicron",
494 Some("2026-06-03T00:00:00Z"),
495 10,
496 ),
497 skill(
498 "rich",
499 "agent-created",
500 "pi rho sigma",
501 Some("2026-06-03T00:00:00Z"),
502 500,
503 ),
504 ];
505 let r = audit_at("d", &skills, &AuditConfig::default(), now());
506 let names: Vec<&str> = r.thin.iter().map(|t| t.name.as_str()).collect();
507 assert_eq!(names, vec!["thin"]);
508 }
509
510 #[test]
511 fn cap_at_capacity() {
512 let cfg = AuditConfig {
513 max_agent_skills: 2,
514 ..AuditConfig::default()
515 };
516 let skills = vec![
517 skill(
518 "a",
519 "agent-created",
520 "aaa bbb ccc",
521 Some("2026-06-03T00:00:00Z"),
522 500,
523 ),
524 skill(
525 "b",
526 "agent-created",
527 "ddd eee fff",
528 Some("2026-06-03T00:00:00Z"),
529 500,
530 ),
531 ];
532 let r = audit_at("d", &skills, &cfg, now());
533 assert!(r.cap.at_capacity);
534 assert!(r.has_findings());
535 }
536}