Skip to main content

retro_core/
curator.rs

1use crate::config::Config;
2use crate::db;
3use crate::errors::CoreError;
4use crate::models::{Pattern, PatternStatus, Projection};
5use crate::projection::claude_md;
6use crate::util::backup_file;
7use chrono::{Duration, Utc};
8use rusqlite::Connection;
9use std::path::Path;
10
11/// A stale item discovered by the curator.
12#[derive(Debug, Clone)]
13pub struct StaleItem {
14    pub pattern: Pattern,
15    pub projection: Projection,
16    pub reason: String,
17}
18
19/// Result of a clean operation.
20#[derive(Debug)]
21pub struct CleanResult {
22    pub archived_count: usize,
23    pub skills_removed: usize,
24    pub claude_md_rules_removed: usize,
25    pub agents_removed: usize,
26}
27
28/// Detect stale patterns that should be archived.
29///
30/// A pattern/projection is stale if ALL of these are true:
31/// - last_seen is older than staleness_days
32/// - The pattern was generated by retro (tracked via projections table)
33/// - Pattern is currently active
34pub fn detect_stale(
35    conn: &Connection,
36    config: &Config,
37) -> Result<Vec<StaleItem>, CoreError> {
38    let staleness_days = config.analysis.staleness_days as i64;
39    let cutoff = Utc::now() - Duration::days(staleness_days);
40
41    // Get active patterns that have projections (i.e., were generated by retro)
42    let active_patterns = db::get_patterns(conn, &["active"], None)?;
43    let projections = db::get_projections_for_active_patterns(conn)?;
44
45    let mut stale_items = Vec::new();
46
47    for pattern in &active_patterns {
48        if pattern.last_seen >= cutoff {
49            continue;
50        }
51
52        // Must have a projection (retro-generated)
53        if let Some(proj) = projections.iter().find(|p| p.pattern_id == pattern.id) {
54            let days_stale = (Utc::now() - pattern.last_seen).num_days();
55            stale_items.push(StaleItem {
56                pattern: pattern.clone(),
57                projection: proj.clone(),
58                reason: format!(
59                    "not seen in {} days (threshold: {} days)",
60                    days_stale, staleness_days
61                ),
62            });
63        }
64    }
65
66    Ok(stale_items)
67}
68
69/// Archive stale items: backup files, remove projections, update DB status.
70pub fn archive_stale_items(
71    conn: &Connection,
72    _config: &Config,
73    items: &[StaleItem],
74) -> Result<CleanResult, CoreError> {
75    let backup_dir = crate::config::retro_dir().join("backups");
76    std::fs::create_dir_all(&backup_dir)
77        .map_err(|e| CoreError::Io(format!("creating backup dir: {e}")))?;
78
79    let mut result = CleanResult {
80        archived_count: 0,
81        skills_removed: 0,
82        claude_md_rules_removed: 0,
83        agents_removed: 0,
84    };
85
86    // Collect CLAUDE.md items to batch-remove from managed section
87    let claude_md_items: Vec<&StaleItem> = items
88        .iter()
89        .filter(|i| i.projection.target_type == "claude_md")
90        .collect();
91
92    // Handle CLAUDE.md removals as a batch (one file write)
93    if !claude_md_items.is_empty() {
94        let target_path = &claude_md_items[0].projection.target_path;
95        if let Ok(content) = std::fs::read_to_string(target_path) {
96            // Read current rules
97            if let Some(current_rules) = claude_md::read_managed_section(&content) {
98                // Remove stale rules
99                let stale_contents: Vec<&str> = claude_md_items
100                    .iter()
101                    .map(|i| i.projection.content.as_str())
102                    .collect();
103
104                let remaining_rules: Vec<String> = current_rules
105                    .into_iter()
106                    .filter(|rule| !stale_contents.contains(&rule.as_str()))
107                    .collect();
108
109                // Backup before modification
110                backup_file(target_path, &backup_dir)?;
111
112                let updated = claude_md::update_claude_md_content(&content, &remaining_rules);
113                std::fs::write(target_path, &updated)
114                    .map_err(|e| CoreError::Io(format!("writing {target_path}: {e}")))?;
115
116                result.claude_md_rules_removed = claude_md_items.len();
117            }
118        }
119    }
120
121    // Handle skill and agent file removals individually
122    for item in items {
123        match item.projection.target_type.as_str() {
124            "skill" => {
125                let skill_path = Path::new(&item.projection.target_path);
126                if skill_path.exists() {
127                    backup_file(&item.projection.target_path, &backup_dir)?;
128
129                    // Remove the SKILL.md file
130                    let _ = std::fs::remove_file(skill_path);
131
132                    // Try to remove the parent directory if it's now empty
133                    if let Some(parent) = skill_path.parent() {
134                        let _ = std::fs::remove_dir(parent); // Only succeeds if empty
135                    }
136
137                    result.skills_removed += 1;
138                }
139            }
140            "global_agent" => {
141                let agent_path = Path::new(&item.projection.target_path);
142                if agent_path.exists() {
143                    backup_file(&item.projection.target_path, &backup_dir)?;
144                    let _ = std::fs::remove_file(agent_path);
145                    result.agents_removed += 1;
146                }
147            }
148            "claude_md" => {
149                // Already handled above in batch
150            }
151            _ => {}
152        }
153
154        // Update pattern status to archived
155        db::update_pattern_status(conn, &item.pattern.id, &PatternStatus::Archived)?;
156        result.archived_count += 1;
157    }
158
159    Ok(result)
160}
161
162/// JSON schema for constrained decoding of context audit responses.
163pub const AUDIT_RESPONSE_SCHEMA: &str = r#"{"type":"object","properties":{"findings":{"type":"array","items":{"type":"object","properties":{"finding_type":{"type":"string","enum":["redundant","contradictory","oversized","stale"]},"description":{"type":"string"},"affected_items":{"type":"array","items":{"type":"string"}},"suggestion":{"type":"string"}},"required":["finding_type","description","affected_items","suggestion"],"additionalProperties":false}}},"required":["findings"],"additionalProperties":false}"#;
164
165/// Context audit finding from AI analysis.
166#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
167pub struct AuditFinding {
168    pub finding_type: String,
169    pub description: String,
170    pub affected_items: Vec<String>,
171    pub suggestion: String,
172}
173
174/// Response from context audit AI call.
175#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
176pub struct AuditResponse {
177    pub findings: Vec<AuditFinding>,
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn test_audit_response_schema_is_valid_json() {
186        let value: serde_json::Value = serde_json::from_str(AUDIT_RESPONSE_SCHEMA)
187            .expect("AUDIT_RESPONSE_SCHEMA must be valid JSON");
188        assert_eq!(value["type"], "object");
189        assert!(value["properties"]["findings"].is_object());
190    }
191}