Skip to main content

microclaw_engine/
skill_audit.rs

1//! Deterministic skill curation audit.
2//!
3//! `skill_review` distills a reusable skill from a *single* completed session,
4//! and the reflector retires skills purely by inactivity age. Neither gives a
5//! cross-skill view of the on-disk corpus. This module fills that gap with a
6//! read-only, deterministic audit — no LLM, no DB, no mutation — that surfaces
7//! the signals a curator (human or, later, an LLM pass) needs:
8//!
9//! * **near-duplicate** skills (token-Jaccard over name + description) — merge
10//!   candidates, and a strong retire signal when an `agent-created` skill
11//!   shadows a built-in one;
12//! * **stale** `agent-created` skills (no recent `updated_at`) — retire
13//!   candidates;
14//! * **thin** `agent-created` skills (near-empty body) — flesh-out or retire;
15//! * **cap headroom** against the `agent-created` ceiling.
16//!
17//! Built-in / human-curated skills (`source` other than `agent-created`) are
18//! immutable, so they are never flagged as stale/thin retire candidates; they
19//! still participate in duplicate detection so an agent skill that merely
20//! restates a built-in is caught.
21
22use crate::skills::parse_skill_md;
23use chrono::{DateTime, Utc};
24use serde::Serialize;
25use std::collections::HashSet;
26use std::path::Path;
27
28/// The `agent-created` ceiling enforced by `skill_review`. Mirrored here so the
29/// audit can report headroom against the same number.
30pub const DEFAULT_MAX_AGENT_SKILLS: usize = 20;
31
32/// Tunable thresholds for the audit.
33#[derive(Debug, Clone)]
34pub struct AuditConfig {
35    /// Token-Jaccard at or above which two skills are "near-duplicate" (0.0-1.0).
36    pub similarity: f64,
37    /// An `agent-created` skill whose `updated_at` is older than this many days
38    /// (or absent) is a stale retire candidate.
39    pub stale_days: i64,
40    /// An `agent-created` skill whose body is shorter than this many characters
41    /// (after trimming) is a thin retire candidate.
42    pub min_body_chars: usize,
43    /// The `agent-created` ceiling, for headroom reporting.
44    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    /// Names that are `agent-created` (and thus the side(s) safe to retire).
64    pub retire_candidates: Vec<String>,
65}
66
67#[derive(Debug, Serialize)]
68pub struct StaleSkill {
69    pub name: String,
70    /// Age in whole days, or `None` when `updated_at` is missing/unparseable.
71    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    /// True once the ceiling is reached (no further agent skills can be created).
85    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    /// True when nothing actionable was found.
98    pub clean: bool,
99}
100
101impl AuditReport {
102    /// Whether the audit found anything a curator should act on. Drives the
103    /// `--strict` exit code.
104    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
112/// One parsed skill, reduced to the fields the audit reasons about.
113struct ParsedSkill {
114    name: String,
115    source: String,
116    updated_at: Option<String>,
117    /// Lowercased token set over name + description, for similarity.
118    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
128/// Split text into lowercased alphanumeric tokens of length >= 3.
129fn 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
136/// Jaccard index of two token sets: |A∩B| / |A∪B|. Empty/empty is 0.0.
137fn 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
150/// Whole-day age of an RFC3339 timestamp relative to `now`. `None` if unparseable.
151fn 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
156/// Read and parse every `<dir>/*/SKILL.md` into the reduced form.
157fn 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
188/// Run the audit against a parsed corpus at a fixed `now` (injected for tests).
189fn audit_at(
190    skills_dir: &str,
191    skills: &[ParsedSkill],
192    cfg: &AuditConfig,
193    now: DateTime<Utc>,
194) -> AuditReport {
195    // Near-duplicates: every unordered pair at or above the threshold.
196    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    // Stale / thin: agent-created skills only (others are immutable).
219    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, // missing/unparseable timestamp: can't prove fresh
232        };
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
268/// Audit the skills directory and return a structured report.
269pub 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
274/// CLI entry point. Prints the report (text or `--json`) and returns the
275/// process exit code: always 0 unless `strict` is set and there are findings.
276pub 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        // Only the agent-created side is a retire candidate.
410        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            // Old built-in: immutable, never flagged stale.
444            skill(
445                "old-builtin",
446                "builtin",
447                "alpha beta gamma",
448                Some("2000-01-01T00:00:00Z"),
449                500,
450            ),
451            // Old agent skill: stale.
452            skill(
453                "old-agent",
454                "agent-created",
455                "delta epsilon zeta",
456                Some("2026-01-01T00:00:00Z"),
457                500,
458            ),
459            // Fresh agent skill: not stale.
460            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}