//! `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, wake: Arc) { 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 { 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 { 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 ); } }