hyperhive/hive-agent/src/claude_md_watch.rs

191 lines
7.5 KiB
Rust

//! `CLAUDE.md` size watch: raises a loose-ends-v2 todo when the agent's
//! own `CLAUDE.md` grows past 500 lines, and feeds the same line count
//! into an OTEL gauge every tick regardless of threshold. Per the
//! operator's own framing of the ask — "claude md is personality and
//! how you organize yourself, not a work log" — the prompt is asked to
//! suggest moving detail out (separate files, an archive) rather than
//! mandate deleting it, and not to forbid deletion either.
//!
//! Same shape as [`crate::disk_watch`] / [`crate::state_entry_watch`]
//! (periodic in-process probe, `Todos::upsert` with a *stable, bucketed*
//! summary) — a third instance of the same anti-nag pattern rather than
//! a new one.
//!
//! `CLAUDE.md` lives at the top of the agent's own state dir — the same
//! directory `claude --print` runs with as its cwd (`turn::session_cwd`),
//! which is how the CLI's own auto-load discovers it.
//!
//! Anti-nag: the todo is keyed (`claude_md`/[`TODO_KEY`]), and the
//! summary is bucketed by line count ([`BUCKET`]) so drifting inside one
//! bucket reports `changed == false` on `upsert` — 510 → 540 stays quiet,
//! crossing 550 speaks up again. Dropping back under [`WARN_LINES`]
//! (someone archived a chunk) clears the todo, same as the siblings.
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Notify;
use crate::todos::Todos;
/// Same cadence as `disk_watch` / `state_entry_watch` — no reason for
/// this to poll more often, and reading one small file is cheaper still.
const CHECK_INTERVAL: Duration = Duration::from_mins(15);
/// Line count past which the hint appears. Matches the issue's ask
/// exactly ("exceeds 500 lines").
const WARN_LINES: u64 = 500;
/// Line counts are reported in buckets this wide — the anti-nag knob,
/// same role as `disk_watch::BUCKET_PCT` / `state_entry_watch::BUCKET`.
const BUCKET: u64 = 50;
/// Producing subsystem marker for the todo row.
const SUBSYSTEM: &str = "claude_md";
/// Fixed dedup key — there is only ever one `CLAUDE.md`-size todo per
/// agent.
const TODO_KEY: &str = "size";
/// Background loop: periodically reconcile the `CLAUDE.md`-size todo and
/// feed the OTEL gauge. Detached task — runs for the harness's lifetime;
/// errors are logged, never fatal.
pub async fn run(todos: Arc<Todos>, wake: Arc<Notify>) {
loop {
tokio::time::sleep(CHECK_INTERVAL).await;
let lines = match tokio::task::spawn_blocking(|| line_count(&claude_md_path())).await {
Ok(l) => l,
Err(e) => {
tracing::warn!(error = %e, "claude.md watch probe panicked");
continue;
}
};
if let Some(lines) = lines {
crate::otel_turn_metrics::record_claude_md_lines(lines);
}
reconcile(&todos, &wake, lines.and_then(summary_for).as_deref());
}
}
/// Apply one probe result to the todo store: raise/refresh while over
/// threshold, clear once back under. Split from [`run`] so the store
/// interaction is testable without a timer.
fn reconcile(todos: &Todos, wake: &Notify, summary: Option<&str>) {
match summary {
Some(summary) => match todos.upsert(SUBSYSTEM, Some(TODO_KEY), summary, None, false) {
Ok((_, true)) => wake.notify_one(),
Ok((_, false)) => {}
Err(e) => tracing::warn!(error = ?e, "claude.md todo upsert failed"),
},
None => {
if let Err(e) = todos.clear(SUBSYSTEM, Some(TODO_KEY)) {
tracing::warn!(error = ?e, "claude.md todo clear failed");
}
}
}
}
/// `CLAUDE.md`'s path: the top of the agent's own state dir, matching
/// where `claude --print` runs (`turn::session_cwd`) and therefore where
/// its own auto-load discovers the file.
fn claude_md_path() -> std::path::PathBuf {
crate::paths::state_dir().join("CLAUDE.md")
}
/// Line count of the file at `path`. `None` when it can't be read — no
/// `CLAUDE.md` yet is not a warning, and a transient read failure isn't
/// either.
fn line_count(path: &Path) -> Option<u64> {
let contents = std::fs::read_to_string(path).ok()?;
// A trailing newline (the common case) must not count as one more
// blank line than an editor would show.
Some(contents.lines().count() as u64)
}
/// Pure rendering half of [`run`]: the todo text for a given line count.
/// Separated so the threshold and the *stability* of the summary are
/// unit-tested without a real filesystem.
fn summary_for(lines: u64) -> Option<String> {
if lines < WARN_LINES {
return None;
}
let bucket = lines / BUCKET * BUCKET;
Some(format!(
"your CLAUDE.md is {bucket}+ lines. CLAUDE.md is personality and how you organize \
yourself, not a work log — if it's grown past that, moving detail into separate files \
(linked from CLAUDE.md) or an archive is what actually shrinks this count. It \
auto-loads into every turn, so a smaller file is a fixed cost saved on every turn. \
Nothing here is a hard limit, and deleting stale content outright is fine too if \
that's genuinely what it is — no obligation either way. No need to act on this now — \
leave the todo open and come back to it whenever."
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn under_threshold_is_silent() {
assert!(summary_for(WARN_LINES - 1).is_none());
}
#[test]
fn at_threshold_speaks_up() {
let s = summary_for(WARN_LINES).expect("todo");
assert!(s.contains("500+ lines"));
assert!(s.contains("not a work log"));
assert!(s.contains("no obligation"));
}
/// The anti-nag property: a count drifting inside one bucket must
/// render byte-identically, so `Todos::upsert` reports
/// `changed == false` and nothing wakes the agent.
#[test]
fn drift_inside_a_bucket_is_identical() {
let a = summary_for(510).expect("todo");
let b = summary_for(549).expect("todo");
assert_eq!(a, b);
}
#[test]
fn crossing_a_bucket_changes_the_summary() {
let a = summary_for(549).expect("todo");
let b = summary_for(550).expect("todo");
assert_ne!(a, b);
assert!(b.contains("550+ lines"));
}
/// The store round-trip: first raise wakes, an identical re-probe does
/// not, and dropping back under threshold clears the row.
#[test]
fn reconcile_wakes_once_then_clears() {
let dir = tempfile::tempdir().expect("tempdir");
let todos = Todos::open(&dir.path().join("state.sqlite")).expect("open");
let wake = Notify::new();
let summary = summary_for(WARN_LINES).expect("todo");
reconcile(&todos, &wake, Some(&summary));
assert_eq!(todos.list(Some(SUBSYSTEM)).expect("list").len(), 1);
reconcile(&todos, &wake, Some(&summary));
assert_eq!(todos.list(Some(SUBSYSTEM)).expect("list").len(), 1);
reconcile(&todos, &wake, None);
assert!(todos.list(Some(SUBSYSTEM)).expect("list").is_empty());
}
#[test]
fn line_count_matches_editor_expectations() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("CLAUDE.md");
// Three lines, trailing newline — an editor shows 3 lines, not 4.
std::fs::write(&path, "one\ntwo\nthree\n").expect("write");
assert_eq!(line_count(&path), Some(3));
}
#[test]
fn missing_file_is_silent_not_an_error() {
assert_eq!(
line_count(Path::new("/definitely/does/not/exist/CLAUDE.md")),
None
);
}
}