use std::path::PathBuf;
use std::time::UNIX_EPOCH;
use crate::extras::dirge_paths::ProjectPaths;
const STALE_AFTER_DAYS: u64 = 30;
const ARCHIVE_AFTER_STALE_DAYS: u64 = 90;
const INTERVAL_HOURS: u64 = 168;
#[allow(dead_code)]
const IDLE_HOURS: u64 = 2;
pub struct Curator {
paths: ProjectPaths,
clock: crate::extras::curator_clock::CuratorClock,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub enum SkillLifecycle {
Active,
Stale,
Archived,
}
impl Curator {
pub fn new(paths: &ProjectPaths) -> Result<Self, String> {
let clock = crate::extras::curator_clock::CuratorClock::new(
paths,
paths.skills_dir().join(".curator_state"),
INTERVAL_HOURS,
crate::extras::curator_clock::DEFAULT_MIN_SESSIONS_FIRST_RUN,
)?;
Ok(Curator {
paths: paths.clone(),
clock,
})
}
pub fn should_run_now(&mut self) -> bool {
self.clock.should_run_now()
}
pub fn apply_automatic_transitions(&mut self) -> Result<Vec<String>, String> {
let now = now_secs();
let skills_dir = self.paths.skills_dir();
if !skills_dir.is_dir() {
self.clock.mark_ran()?;
return Ok(Vec::new());
}
let mut usage = crate::extras::skills::usage::UsageStore::load(&self.paths).ok();
let mut stale_names: Vec<String> = Vec::new();
let mut reactivated: Vec<String> = Vec::new();
for entry in std::fs::read_dir(&skills_dir)
.map_err(|e| format!("Failed to read skills directory: {e}"))?
{
let entry = entry.map_err(|e| format!("Failed to read skill entry: {e}"))?;
let path = entry.path();
if !path.is_dir() || !path.join("SKILL.md").is_file() {
continue;
}
let file_name = path.file_name().and_then(|n| n.to_str());
if file_name == Some(".archive") {
continue;
}
let name = match file_name {
Some(n) => n.to_string(),
None => continue,
};
if let Some(ref usage) = usage {
if usage.get(&name).map(|u| u.pinned).unwrap_or(false) {
continue;
}
if !usage.is_agent_created(&name) {
continue;
}
}
let age_seconds = if let Some(ref usage) = usage {
usage.activity_age_seconds(&name).unwrap_or_else(|| {
file_mod_age(&path.join("SKILL.md"), now)
})
} else {
file_mod_age(&path.join("SKILL.md"), now)
};
let age_days = age_seconds / 86400;
if age_days >= ARCHIVE_AFTER_STALE_DAYS {
self.archive_skill(&name)?;
if let Some(ref mut u) = usage {
let _ = u.set_state(&name, crate::extras::skills::usage::SkillState::Archived);
}
} else if age_days >= STALE_AFTER_DAYS {
stale_names.push(name.clone());
if let Some(ref mut u) = usage {
let _ = u.set_state(&name, crate::extras::skills::usage::SkillState::Stale);
}
} else {
let needs_reactivate = match usage.as_ref() {
Some(u) => u
.get(&name)
.map(|r| matches!(r.state, crate::extras::skills::usage::SkillState::Stale))
.unwrap_or(false),
None => false,
};
if needs_reactivate && let Some(ref mut u) = usage {
let _ = u.set_state(&name, crate::extras::skills::usage::SkillState::Active);
reactivated.push(name);
}
}
}
if !reactivated.is_empty() {
tracing::info!(
target: "dirge::curator",
count = %reactivated.len(),
"Reactivated {} stale skills with recent activity",
reactivated.len()
);
}
self.clock.mark_ran()?;
Ok(stale_names)
}
pub(crate) fn archive_skill(&self, name: &str) -> Result<(), String> {
let src = self.paths.skills_dir().join(name);
if !src.is_dir() {
return Ok(());
}
let archive_dir = self.paths.skills_dir().join(".archive");
std::fs::create_dir_all(&archive_dir)
.map_err(|e| format!("Failed to create archive directory: {e}"))?;
let dest = archive_dir.join(name);
if dest.exists() {
return Ok(());
}
std::fs::rename(&src, &dest)
.map_err(|e| format!("Failed to archive skill '{}': {}", name, e))?;
Ok(())
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn record_run(&mut self) -> Result<(), String> {
self.clock.mark_ran()
}
}
pub const CURATOR_PROMPT: &str = "You are running as dirge's background skill CURATOR. \
This is an UMBRELLA-BUILDING consolidation pass, not a passive audit and not a \
duplicate-finder.\n\n\
The goal of the skill collection is a LIBRARY OF CLASS-LEVEL INSTRUCTIONS AND \
EXPERIENTIAL KNOWLEDGE. A collection of hundreds of narrow skills where each one \
captures one session's specific bug is a FAILURE of the library — not a feature. \
An agent searching skills matches on descriptions, not on exact names; one broad \
umbrella skill with labeled subsections beats five narrow siblings for \
discoverability, not the other way around.\n\n\
The right target shape is CLASS-LEVEL skills with rich SKILL.md bodies — not \
one-session-one-skill micro-entries.\n\n\
Hard rules — do not violate:\n\
1. DO NOT touch bundled or hub-installed skills. The candidate list below is \
already filtered to agent-created skills only.\n\
2. DO NOT call `skill(action='delete', ...)` unless you've ALREADY absorbed the \
skill's content into an umbrella via `skill(action='patch', ...)`. Deletion \
moves the directory to `.dirge/skills/.archive/`; archives are recoverable but \
the content is gone from the live library.\n\
3. DO NOT touch skills shown as pinned=yes. Skip them entirely.\n\
4. DO NOT use usage counters as a reason to skip consolidation. The counters are \
new and often mostly zero. Judge overlap on CONTENT, not on use_count. 'use=0' \
is not evidence a skill is valuable; it's absence of evidence either way.\n\
5. DO NOT reject consolidation on the grounds that 'each skill has a distinct \
trigger'. Pairwise distinctness is the wrong bar. The right bar is: 'would a \
human maintainer write this as N separate skills, or as one skill with N \
labeled subsections?' When the answer is the latter, merge.\n\n\
How to work — not optional:\n\
1. Scan the full candidate list. Identify PREFIX CLUSTERS (skills sharing a \
first word or domain keyword).\n\
2. For each cluster with 2+ members, do NOT ask 'are these pairs overlapping?' — \
ask 'what is the UMBRELLA CLASS these skills all serve? Would a maintainer name \
that class and write one skill for it?' If yes, pick (or create) the umbrella \
and absorb the siblings into it.\n\
3. Three ways to consolidate — use the right one per cluster:\n\
\u{0020} a. MERGE INTO EXISTING UMBRELLA — one skill in the cluster is already \
broad enough. Use `skill(action='load', name=<umbrella>)` to read it, then \
`skill(action='patch', name=<umbrella>, old_string=..., new_string=...)` to \
add a labeled section for each sibling's unique insight, then \
`skill(action='delete', name=<sibling>)` to archive the siblings.\n\
\u{0020} b. CREATE A NEW UMBRELLA SKILL — no existing member is broad enough. \
Use `skill(action='create', name=<umbrella>, content=...)` to write a new \
class-level skill whose SKILL.md covers the shared workflow with short \
labeled subsections. Archive the now-absorbed narrow siblings.\n\
\u{0020} c. KEEP NARROW — only if the skill is already a class-level umbrella \
and none of the proposed merges would improve discoverability.\n\
4. Also flag skills whose NAME is too narrow (contains a PR number, a feature \
codename, a specific error string). These almost always belong as a subsection \
under a class-level umbrella.\n\
5. Iterate. After one consolidation round, scan the remaining set and look for \
the NEXT umbrella opportunity. Don't stop after 3 merges.\n\n\
Your toolset (only the `skill` tool is available):\n\
\u{0020} - `skill(action='list')` — re-list current skills\n\
\u{0020} - `skill(action='load', name=...)` — read a skill's SKILL.md\n\
\u{0020} - `skill(action='patch', name=..., old_string=..., new_string=...)` — \
add sections to an umbrella\n\
\u{0020} - `skill(action='create', name=..., content=...)` — create a new \
umbrella SKILL.md\n\
\u{0020} - `skill(action='delete', name=...)` — archive a sibling \
(after absorbing its content elsewhere)\n\n\
'keep' is a legitimate decision ONLY when the skill is already class-level and \
none of the proposed merges would improve discoverability. 'This is narrow but \
distinct from its siblings' is NOT a reason to keep — it's a reason to move it \
under an umbrella as a subsection.\n\n\
Candidate list follows. Process it. When done, write a brief summary of what \
you consolidated and what you left alone.";
pub fn render_candidate_list(usage: &crate::extras::skills::usage::UsageStore) -> String {
use std::fmt::Write as _;
let mut rows: Vec<(&String, &crate::extras::skills::usage::SkillUsage)> = usage
.skill_names()
.filter(|name| usage.is_agent_created(name))
.filter_map(|name| usage.get(name).map(|u| (name, u)))
.collect();
if rows.is_empty() {
return String::from("No agent-created skills — curator pass is a no-op.");
}
rows.sort_by(|a, b| {
let key_a = a.1.last_used_at.as_deref().unwrap_or("");
let key_b = b.1.last_used_at.as_deref().unwrap_or("");
key_b.cmp(key_a).then_with(|| a.0.cmp(b.0))
});
let mut out = String::from("Candidate skills (agent-created, sorted by last activity):\n");
for (name, u) in rows {
let activity = u
.last_used_at
.as_deref()
.or(u.last_patched_at.as_deref())
.or(u.last_viewed_at.as_deref())
.unwrap_or("never");
let state = match u.state {
crate::extras::skills::usage::SkillState::Active => "active",
crate::extras::skills::usage::SkillState::Stale => "stale",
crate::extras::skills::usage::SkillState::Archived => "archived",
};
let _ = writeln!(
out,
" - {name} state={state} pinned={} use={} view={} patches={} last_activity={activity}",
if u.pinned { "yes" } else { "no" },
u.use_count,
u.view_count,
u.patch_count,
);
}
out
}
#[derive(Debug, Clone)]
pub struct CuratorReport {
pub started_at_rfc3339: String,
pub elapsed_secs: f64,
pub before_candidates: String,
pub after_candidates: String,
pub tool_actions: Vec<String>,
pub error: Option<String>,
}
impl CuratorReport {
pub fn to_markdown(&self) -> String {
use std::collections::BTreeMap;
use std::fmt::Write as _;
let mut out = String::new();
let _ = writeln!(out, "# Curator run report\n");
let _ = writeln!(out, "- Started: {}", self.started_at_rfc3339);
let _ = writeln!(out, "- Elapsed: {:.2}s", self.elapsed_secs);
let _ = writeln!(
out,
"- Outcome: {}",
if self.error.is_some() {
"error"
} else if self.tool_actions.is_empty() {
"no-op"
} else {
"modified skills"
}
);
if let Some(err) = &self.error {
let _ = writeln!(out, "- Error: `{}`", err);
}
let mut histogram: BTreeMap<&str, usize> = BTreeMap::new();
for action in &self.tool_actions {
*histogram.entry(action.as_str()).or_insert(0) += 1;
}
if !histogram.is_empty() {
let _ = writeln!(out, "\n## Tool calls\n");
for (name, count) in &histogram {
let _ = writeln!(out, "- `{}` × {}", name, count);
}
}
let before_names = parse_candidate_names(&self.before_candidates);
let after_names = parse_candidate_names(&self.after_candidates);
let removed: Vec<&String> = before_names.difference(&after_names).collect();
let added: Vec<&String> = after_names.difference(&before_names).collect();
if !removed.is_empty() || !added.is_empty() {
let _ = writeln!(out, "\n## Skill set delta\n");
if !removed.is_empty() {
let _ = writeln!(out, "Archived ({}):", removed.len());
let mut sorted = removed.clone();
sorted.sort();
for name in sorted {
let _ = writeln!(out, "- ~~`{}`~~", name);
}
}
if !added.is_empty() {
let _ = writeln!(out, "\nAdded ({}):", added.len());
let mut sorted = added.clone();
sorted.sort();
for name in sorted {
let _ = writeln!(out, "- **`{}`**", name);
}
}
}
let _ = writeln!(out, "\n## Candidate list — before\n\n```");
out.push_str(&self.before_candidates);
if !self.before_candidates.ends_with('\n') {
out.push('\n');
}
let _ = writeln!(out, "```\n");
let _ = writeln!(out, "## Candidate list — after\n\n```");
out.push_str(&self.after_candidates);
if !self.after_candidates.ends_with('\n') {
out.push('\n');
}
let _ = writeln!(out, "```");
out
}
}
fn parse_candidate_names(rendered: &str) -> std::collections::BTreeSet<String> {
let mut out = std::collections::BTreeSet::new();
for line in rendered.lines() {
let trimmed = line.trim_start();
if let Some(rest) = trimmed.strip_prefix("- ")
&& let Some(name) = rest.split_whitespace().next()
{
out.insert(name.to_string());
}
}
out
}
pub fn write_curator_report(
paths: &ProjectPaths,
report: &CuratorReport,
) -> Result<PathBuf, String> {
let root = paths.skills_dir().join(".curator_reports");
std::fs::create_dir_all(&root)
.map_err(|e| format!("Failed to create curator reports dir: {e}"))?;
let stamp = chrono::Utc::now().format("%Y%m%d-%H%M%S").to_string();
let mut run_dir = root.join(&stamp);
let mut suffix = 1;
while run_dir.exists() {
suffix += 1;
run_dir = root.join(format!("{}-{}", stamp, suffix));
}
std::fs::create_dir_all(&run_dir)
.map_err(|e| format!("Failed to create curator run dir: {e}"))?;
let md = report.to_markdown();
let report_path = run_dir.join("REPORT.md");
std::fs::write(&report_path, md.as_bytes())
.map_err(|e| format!("Failed to write REPORT.md: {e}"))?;
Ok(run_dir)
}
fn now_secs() -> u64 {
crate::time_util::now_unix_secs()
}
fn file_mod_age(path: &std::path::Path, now: u64) -> u64 {
std::fs::metadata(path)
.ok()
.and_then(|meta| meta.modified().ok())
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| now.saturating_sub(d.as_secs()))
.unwrap_or(now)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
static TEST_COUNTER: AtomicU32 = AtomicU32::new(0);
fn temp_project() -> (ProjectPaths, std::path::PathBuf) {
let n = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
let dir =
std::env::temp_dir().join(format!("dirge-curator-test-{}-{}", std::process::id(), n));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join(".git")).unwrap();
let paths = ProjectPaths::new(&dir);
(paths, dir)
}
fn create_skill_dir(paths: &ProjectPaths, name: &str) {
let dir = paths.skills_dir().join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("SKILL.md"), "---\nname: test\n---\n\nbody\n").unwrap();
}
fn write_state(paths: &ProjectPaths, last_run: u64) {
std::fs::create_dir_all(paths.skills_dir()).unwrap();
std::fs::write(
paths.skills_dir().join(".curator_state"),
format!(r#"{{"last_run": {last_run}, "first_check": {last_run}}}"#),
)
.unwrap();
}
#[test]
fn first_run_gated_on_session_count() {
let (paths, _dir) = temp_project();
std::fs::create_dir_all(paths.sessions_dir()).unwrap();
let db = crate::extras::session_db::SessionDb::open(&paths.session_db_path()).unwrap();
db.insert_session("s1", "cli", "gpt-5", "openai", "2026-05-01T10:00:00Z")
.unwrap();
drop(db);
let mut curator = Curator::new(&paths).unwrap();
assert!(!curator.should_run_now(), "1 session — deferred");
let db = crate::extras::session_db::SessionDb::open(&paths.session_db_path()).unwrap();
for i in 2..=12 {
db.insert_session(
&format!("s{i}"),
"cli",
"gpt-5",
"openai",
"2026-05-01T10:00:00Z",
)
.unwrap();
}
drop(db);
let mut curator = Curator::new(&paths).unwrap();
assert!(
curator.should_run_now(),
"enough sessions — first run fires without a 7-day wait"
);
}
#[test]
fn runs_after_interval_elapses() {
let (paths, _dir) = temp_project();
write_state(&paths, now_secs() - INTERVAL_HOURS * 3600 - 1);
let mut curator = Curator::new(&paths).unwrap();
assert!(curator.should_run_now());
}
#[test]
fn does_not_run_within_interval() {
let (paths, _dir) = temp_project();
write_state(&paths, now_secs() - 3600);
let mut curator = Curator::new(&paths).unwrap();
assert!(!curator.should_run_now());
}
#[test]
fn archive_moves_skill_to_archive_dir() {
let (paths, _dir) = temp_project();
create_skill_dir(&paths, "old-skill");
let curator = Curator::new(&paths).unwrap();
curator.archive_skill("old-skill").unwrap();
assert!(!paths.skills_dir().join("old-skill").is_dir());
assert!(
paths
.skills_dir()
.join(".archive")
.join("old-skill")
.join("SKILL.md")
.is_file()
);
}
#[test]
fn empty_skills_dir_is_no_op() {
let (paths, _dir) = temp_project();
std::fs::create_dir_all(paths.skills_dir()).unwrap();
let mut curator = Curator::new(&paths).unwrap();
let stale = curator.apply_automatic_transitions().unwrap();
assert!(stale.is_empty());
}
#[test]
fn missing_skills_dir_is_no_op() {
let (paths, _dir) = temp_project();
let mut curator = Curator::new(&paths).unwrap();
let stale = curator.apply_automatic_transitions().unwrap();
assert!(stale.is_empty());
}
#[test]
fn record_run_updates_timestamp() {
let (paths, _dir) = temp_project();
let mut curator = Curator::new(&paths).unwrap();
let before = curator.clock.last_run();
curator.record_run().unwrap();
let curator2 = Curator::new(&paths).unwrap();
assert!(
curator2.clock.last_run() > before,
"recording a run should update last_run"
);
}
#[test]
fn curator_prompt_names_real_skill_actions() {
let p = CURATOR_PROMPT;
for required in &[
"action='patch'",
"action='create'",
"action='delete'",
"action='load'",
"action='list'",
] {
assert!(p.contains(required), "prompt missing {}", required);
}
assert!(!p.contains("skill_manage"), "leaked hermes alias");
assert!(!p.contains("skill_view"), "leaked hermes alias");
assert!(p.contains("UMBRELLA"), "missing umbrella framing");
assert!(p.contains("agent-created"), "missing filter constraint");
assert!(p.contains("pinned"), "missing pinned-skip rule");
}
#[test]
fn render_candidate_list_empty_when_no_agent_skills() {
let (paths, _dir) = temp_project();
let store = crate::extras::skills::usage::UsageStore::load(&paths).unwrap();
let text = render_candidate_list(&store);
assert!(
text.contains("No agent-created skills"),
"expected no-op message: {text}"
);
}
#[test]
fn render_candidate_list_lists_agent_created_only() {
let (paths, _dir) = temp_project();
let mut store = crate::extras::skills::usage::UsageStore::load(&paths).unwrap();
store.record_create("agent-a", "agent");
store.record_create("agent-b", "agent");
store.record_view("bundled-x");
let text = render_candidate_list(&store);
assert!(text.contains("agent-a"), "agent-a should appear: {text}");
assert!(text.contains("agent-b"), "agent-b should appear");
assert!(
!text.contains("bundled-x"),
"bundled-x must NOT appear (not agent-created): {text}"
);
assert!(text.contains("use="), "missing use_count column");
assert!(text.contains("patches="), "missing patch_count column");
assert!(
text.contains("last_activity="),
"missing last_activity column"
);
}
fn sample_report() -> CuratorReport {
CuratorReport {
started_at_rfc3339: "2026-05-28T09:00:00Z".into(),
elapsed_secs: 12.5,
before_candidates: "Candidate skills (agent-created, sorted by last activity):\n \
- alpha state=active pinned=no use=1 view=2 patches=0 last_activity=never\n \
- beta-narrow state=stale pinned=no use=0 view=0 patches=0 last_activity=never\n"
.into(),
after_candidates: "Candidate skills (agent-created, sorted by last activity):\n \
- alpha state=active pinned=no use=1 view=2 patches=1 last_activity=never\n \
- alpha-umbrella state=active pinned=no use=0 view=0 patches=0 last_activity=never\n"
.into(),
tool_actions: vec![
"skill".into(),
"skill".into(),
"skill".into(),
],
error: None,
}
}
#[test]
fn curator_report_markdown_includes_all_sections() {
let md = sample_report().to_markdown();
assert!(md.contains("# Curator run report"), "missing title");
assert!(md.contains("2026-05-28T09:00:00Z"), "missing start time");
assert!(md.contains("12.50s"), "missing elapsed seconds");
assert!(md.contains("modified skills"), "missing outcome line");
assert!(md.contains("## Tool calls"), "missing tool section");
assert!(md.contains("`skill` × 3"), "missing histogram entry");
assert!(md.contains("## Skill set delta"), "missing delta section");
assert!(md.contains("Archived (1):"), "missing archived header");
assert!(md.contains("~~`beta-narrow`~~"), "missing archived entry");
assert!(md.contains("Added (1):"), "missing added header");
assert!(md.contains("**`alpha-umbrella`**"), "missing added entry");
assert!(
md.contains("## Candidate list — before"),
"missing before dump"
);
assert!(
md.contains("## Candidate list — after"),
"missing after dump"
);
}
#[test]
fn curator_report_renders_no_op_when_no_tool_calls() {
let mut r = sample_report();
r.tool_actions.clear();
r.after_candidates = r.before_candidates.clone();
let md = r.to_markdown();
assert!(md.contains("no-op"), "outcome must show no-op");
assert!(
!md.contains("## Tool calls"),
"no-op runs must omit the tool section"
);
assert!(
!md.contains("## Skill set delta"),
"no-op runs must omit the delta section"
);
}
#[test]
fn curator_report_renders_error_outcome() {
let mut r = sample_report();
r.error = Some("provider returned 503".into());
let md = r.to_markdown();
assert!(md.contains("Outcome: error"), "outcome must show error");
assert!(md.contains("`provider returned 503`"), "error must appear");
}
#[test]
fn write_curator_report_creates_timestamped_dir() {
let (paths, dir) = temp_project();
let report = sample_report();
let run_dir = write_curator_report(&paths, &report).expect("write");
assert!(run_dir.exists(), "run dir must exist");
assert!(
run_dir.starts_with(paths.skills_dir().join(".curator_reports")),
"report must live under .dirge/skills/.curator_reports: {}",
run_dir.display()
);
let report_md = run_dir.join("REPORT.md");
assert!(report_md.exists(), "REPORT.md must be written");
let body = std::fs::read_to_string(&report_md).expect("read");
assert!(body.contains("# Curator run report"));
assert!(body.contains("alpha-umbrella"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn write_curator_report_handles_same_second_reruns() {
let (paths, dir) = temp_project();
let report = sample_report();
let first = write_curator_report(&paths, &report).expect("first write");
let second = write_curator_report(&paths, &report).expect("second write");
assert_ne!(
first, second,
"back-to-back writes must land in distinct dirs"
);
assert!(first.exists() && second.exists());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn render_candidate_list_flags_pinned() {
let (paths, _dir) = temp_project();
let mut store = crate::extras::skills::usage::UsageStore::load(&paths).unwrap();
store.record_create("pinned-skill", "agent");
store.set_pinned("pinned-skill", true).unwrap();
let text = render_candidate_list(&store);
assert!(
text.contains("pinned=yes"),
"pinned skills must be flagged so the LLM can skip them: {text}"
);
}
}