diff --git a/CHANGELOG.md b/CHANGELOG.md index b1e6d95a..d15e1d47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Cross-package release notes for relayburn. Package changelogs contain package-le ## [Unreleased] +- `burn overhead` discovers the default Claude Code, Codex, and OpenCode user/ancestor/project instruction chains with harness-accurate precedence, boundaries, deduplication, and scope labels. - Pricing recognizes Claude 5 and GPT-5.6 models, prefers first-party tariffs over reseller duplicates, and applies long-context price tiers. - `burn hotspots --findings` surfaces unknown model pricing explicitly and ranks unpriced sessions by token volume instead of treating them as $0.00. diff --git a/crates/relayburn-cli/src/commands/overhead.rs b/crates/relayburn-cli/src/commands/overhead.rs index 04e08b6a..a01bc4fe 100644 --- a/crates/relayburn-cli/src/commands/overhead.rs +++ b/crates/relayburn-cli/src/commands/overhead.rs @@ -46,6 +46,7 @@ fn run_report( since: since.clone(), kind: kind.map(Into::into), ledger_home: globals.ledger_path.clone(), + harness_home: None, }; let progress = TaskProgress::new(globals, "overhead"); progress.set_task("analyzing overhead files"); @@ -66,7 +67,7 @@ fn run_report( project_path.display() ), None => format!( - "no overhead files found at {} (looked for CLAUDE.md, .claude/CLAUDE.md, AGENTS.md)\n", + "no overhead files found at {} (looked for active CLAUDE.md, CLAUDE.local.md, and AGENTS.md instruction chains)\n", project_path.display() ), }; @@ -107,6 +108,7 @@ fn run_trim( ledger_home: globals.ledger_path.clone(), top, include_diff: None, + harness_home: None, }; let progress = TaskProgress::new(globals, "overhead"); progress.set_task("finding trim candidates"); @@ -127,7 +129,7 @@ fn run_trim( project_path.display() ), None => format!( - "no overhead files found at {} (looked for CLAUDE.md, .claude/CLAUDE.md, AGENTS.md)\n", + "no overhead files found at {} (looked for active CLAUDE.md, CLAUDE.local.md, and AGENTS.md instruction chains)\n", project_path.display() ), }; @@ -225,9 +227,10 @@ fn push_file_block( let display = format_file_display(&parsed.path); let applies_to = describe_applies_to(&parsed.applies_to); lines.push(format!( - "{display} — {} lines, ~{} tokens — applies to: {applies_to}", + "{display} — {} lines, ~{} tokens — scope: {} — applies to: {applies_to}", format_uint(parsed.total_lines), format_tokens(parsed.tokens), + parsed.scope.wire_str(), )); if parsed.tokens == 0 { lines.push(" (empty file — no attribution)".to_string()); diff --git a/crates/relayburn-sdk-node/src/lib.rs b/crates/relayburn-sdk-node/src/lib.rs index bbfe033f..1fe8fc10 100644 --- a/crates/relayburn-sdk-node/src/lib.rs +++ b/crates/relayburn-sdk-node/src/lib.rs @@ -910,6 +910,7 @@ pub struct OverheadOptions { pub since: Option, pub kind: Option, pub ledger_home: Option, + pub harness_home: Option, } /// Per-file + per-section overhead cost attribution. Powers `burn overhead`. @@ -925,12 +926,14 @@ pub fn overhead(opts: Option) -> Result, /// Include the unified-diff text per recommendation. Default true. pub include_diff: Option, + pub harness_home: Option, } /// Trim recommendations for high-cost overhead-file sections. Powers @@ -967,6 +971,7 @@ pub fn overhead_trim(opts: Option) -> Result) -> Result &'static str { + match self { + Self::User => "user", + Self::Ancestor => "ancestor", + Self::Project => "project", + } + } +} + #[derive(Debug, Clone, PartialEq)] pub(crate) struct OverheadFile { pub kind: OverheadFileKind, pub path: String, + pub scope: OverheadFileScope, /// Which agent sources read this file into their cached context. A turn's /// `source` must be in this list for the file to count toward that turn. pub applies_to: Vec, + /// Number of leading bytes the harness injects. This is normally the + /// complete file, but Codex caps the combined project instruction chain + /// at 32 KiB and can therefore inject only a prefix of the final file. + content_bytes: usize, } #[derive(Debug, Clone, PartialEq)] @@ -65,53 +88,386 @@ pub(crate) struct AttributeOverheadInput<'a> { pub pricing: &'a PricingTable, } -struct Candidate { - kind: OverheadFileKind, - parts: &'static [&'static str], - applies_to: &'static [SourceKind], +const DEFAULT_CODEX_PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; + +#[derive(Debug)] +struct DiscoveryRoots { + home: PathBuf, + codex_home: PathBuf, + opencode_config: PathBuf, + /// Test-only filesystem-root substitute. Production always uses `None` + /// and walks Claude ancestors to the real root. + claude_ancestor_stop: Option, } -const CANDIDATES: &[Candidate] = &[ - Candidate { - kind: OverheadFileKind::ClaudeMd, - parts: &["CLAUDE.md"], - applies_to: &[SourceKind::ClaudeCode], - }, - Candidate { - kind: OverheadFileKind::ClaudeMd, - parts: &[".claude", "CLAUDE.md"], - applies_to: &[SourceKind::ClaudeCode], - }, - Candidate { - kind: OverheadFileKind::AgentsMd, - parts: &["AGENTS.md"], - applies_to: &[SourceKind::Codex, SourceKind::Opencode], +impl DiscoveryRoots { + fn from_process() -> Self { + let home = crate::util::home_dir(); + let codex_home = std::env::var_os("CODEX_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".codex")); + let opencode_config = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".config")) + .join("opencode"); + Self { + home, + codex_home, + opencode_config, + claude_ancestor_stop: None, + } + } + + fn for_harness_home(home: &Path) -> Self { + Self { + home: home.to_path_buf(), + codex_home: home.join(".codex"), + opencode_config: home.join(".config").join("opencode"), + claude_ancestor_stop: None, + } + } + + #[cfg(test)] + fn for_home(home: &Path) -> Self { + Self { + claude_ancestor_stop: Some(home.to_path_buf()), + ..Self::for_harness_home(home) + } + } +} + +#[derive(Debug)] +struct DiscoveredFile { + identity: FileIdentity, + file: OverheadFile, +} + +#[derive(Debug, PartialEq, Eq)] +enum FileIdentity { + #[cfg(unix)] + Unix { + device: u64, + inode: u64, }, -]; + Canonical(PathBuf), +} + +fn file_identity(path: &Path) -> FileIdentity { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + + if let Ok(metadata) = fs::metadata(path) { + return FileIdentity::Unix { + device: metadata.dev(), + inode: metadata.ino(), + }; + } + } + FileIdentity::Canonical(fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())) +} + +fn is_file(path: &Path) -> bool { + matches!(fs::metadata(path), Ok(meta) if meta.is_file()) +} + +fn readable_nonempty_bytes(path: &Path) -> Option> { + if !is_file(path) { + return None; + } + let bytes = fs::read(path).ok()?; + (!String::from_utf8_lossy(&bytes).trim().is_empty()).then_some(bytes) +} + +fn add_file( + out: &mut Vec, + kind: OverheadFileKind, + path: &Path, + scope: OverheadFileScope, + source: SourceKind, + content_bytes: usize, +) { + let Some(bytes) = readable_nonempty_bytes(path) else { + return; + }; + let content_bytes = content_bytes.min(bytes.len()); + if content_bytes == 0 + || String::from_utf8_lossy(&bytes[..content_bytes]) + .trim() + .is_empty() + { + return; + } + + // Canonical identity collapses symlink aliases such as + // `.claude/CLAUDE.md -> ../CLAUDE.md`. Keep different filename kinds as + // separate rows: a root `CLAUDE.md -> AGENTS.md` represents disjoint + // harness conventions even though the bytes happen to share a target. + let identity = file_identity(path); + if let Some(existing) = out.iter_mut().find(|entry| { + entry.identity == identity + && entry.file.kind == kind + && entry.file.content_bytes == content_bytes + }) { + if !existing.file.applies_to.contains(&source) { + existing.file.applies_to.push(source); + } + return; + } + if let Some(existing) = out.iter_mut().find(|entry| { + entry.identity == identity + && entry.file.kind == kind + && entry.file.applies_to.contains(&source) + }) { + // The same harness can encounter a physical file through aliases. + // Charge it once, using the shortest prefix that harness injects. + existing.file.content_bytes = existing.file.content_bytes.min(content_bytes); + return; + } + + out.push(DiscoveredFile { + identity, + file: OverheadFile { + kind, + path: path.to_string_lossy().into_owned(), + scope, + applies_to: vec![source], + content_bytes, + }, + }); +} + +fn nearest_git_root(project_path: &Path) -> Option { + project_path + .ancestors() + .find(|dir| is_file(&dir.join(".git")) || dir.join(".git").is_dir()) + .map(Path::to_path_buf) +} + +fn bounded_project_chain(project_path: &Path, git_root: Option<&Path>) -> Vec { + let Some(root) = git_root else { + return vec![project_path.to_path_buf()]; + }; + let mut chain = Vec::new(); + for dir in project_path.ancestors() { + chain.push(dir.to_path_buf()); + if dir == root { + break; + } + } + chain.reverse(); + chain +} pub(crate) fn find_overhead_files(project_path: &Path) -> Vec { - let mut out = Vec::new(); - for c in CANDIDATES { - let mut abs = project_path.to_path_buf(); - for p in c.parts { - abs = abs.join(p); + find_overhead_files_with_roots(project_path, &DiscoveryRoots::from_process()) +} + +pub(crate) fn find_overhead_files_in_home(project_path: &Path, home: &Path) -> Vec { + find_overhead_files_with_roots(project_path, &DiscoveryRoots::for_harness_home(home)) +} + +/// Discover only instruction files that the default harness configuration +/// injects at session startup. +/// +/// Deliberately excluded: on-demand descendant instructions (Claude Code and +/// OpenCode), Claude managed policy / `.claude/rules` / imports / excludes, +/// Codex fallback filenames and non-default byte caps, and OpenCode +/// `CONTEXT.md`, custom `instructions`, and compatibility-disable flags. +/// These require session or harness configuration evidence that this pure +/// filesystem query does not have; undercounting them is safer than charging +/// every project turn for a merely-present file. +fn find_overhead_files_with_roots( + project_path: &Path, + roots: &DiscoveryRoots, +) -> Vec { + let git_root = nearest_git_root(project_path); + let project_scope_root = git_root.as_deref().unwrap_or(project_path); + let project_chain = bounded_project_chain(project_path, git_root.as_deref()); + let mut found = Vec::::new(); + + // User-global files are ordered before project files, matching all three + // harnesses' prompt construction. + add_file( + &mut found, + OverheadFileKind::ClaudeMd, + &roots.home.join(".claude").join("CLAUDE.md"), + OverheadFileScope::User, + SourceKind::ClaudeCode, + usize::MAX, + ); + + // Codex global precedence is first non-empty: override, then AGENTS.md. + for name in ["AGENTS.override.md", "AGENTS.md"] { + let path = roots.codex_home.join(name); + let Some(bytes) = readable_nonempty_bytes(&path) else { + continue; + }; + add_file( + &mut found, + OverheadFileKind::AgentsMd, + &path, + OverheadFileScope::User, + SourceKind::Codex, + bytes.len(), + ); + break; + } + + // OpenCode stops at the first existing global candidate. An empty or + // unreadable primary file blocks its Claude-compatible fallback but adds + // no prompt bytes. + for path in [ + roots.opencode_config.join("AGENTS.md"), + roots.home.join(".claude").join("CLAUDE.md"), + ] { + if !is_file(&path) { + continue; + } + if let Some(bytes) = readable_nonempty_bytes(&path) { + let kind = if path.file_name().and_then(|p| p.to_str()) == Some("AGENTS.md") { + OverheadFileKind::AgentsMd + } else { + OverheadFileKind::ClaudeMd + }; + add_file( + &mut found, + kind, + &path, + OverheadFileScope::User, + SourceKind::Opencode, + bytes.len(), + ); + } + break; + } + + // Claude Code loads CLAUDE.md + CLAUDE.local.md at every ancestor all + // the way to the filesystem root. Project-vs-ancestor scope is based on + // the git root solely for presentation; it does not truncate discovery. + let mut claude_chain = Vec::new(); + for dir in project_path.ancestors() { + claude_chain.push(dir.to_path_buf()); + if roots.claude_ancestor_stop.as_deref() == Some(dir) { + break; + } + } + claude_chain.reverse(); + for dir in claude_chain { + let scope = if dir.starts_with(project_scope_root) { + OverheadFileScope::Project + } else { + OverheadFileScope::Ancestor + }; + let claude_md = dir.join("CLAUDE.md"); + add_file( + &mut found, + OverheadFileKind::ClaudeMd, + &claude_md, + scope, + SourceKind::ClaudeCode, + usize::MAX, + ); + // Official docs name `.claude/CLAUDE.md` as a project-root + // alternative but do not say it is checked at every ancestor. A + // scratch session proves git-root discovery from a nested CWD, so we + // intentionally use the narrow git-root-only rule. Without a git + // marker, the requested CWD is the project-root fallback. + if dir == project_scope_root { + add_file( + &mut found, + OverheadFileKind::ClaudeMd, + &dir.join(".claude").join("CLAUDE.md"), + OverheadFileScope::Project, + SourceKind::ClaudeCode, + usize::MAX, + ); } - match fs::metadata(&abs) { - Ok(meta) if meta.is_file() => { - out.push(OverheadFile { - kind: c.kind, - path: abs.to_string_lossy().into_owned(), - applies_to: c.applies_to.to_vec(), - }); + add_file( + &mut found, + OverheadFileKind::ClaudeMd, + &dir.join("CLAUDE.local.md"), + scope, + SourceKind::ClaudeCode, + usize::MAX, + ); + } + + // Codex chooses at most one non-empty candidate per directory and applies + // one aggregate 32 KiB budget to the root -> CWD project chain. + let mut codex_remaining = DEFAULT_CODEX_PROJECT_DOC_MAX_BYTES; + for dir in &project_chain { + if codex_remaining == 0 { + break; + } + for name in ["AGENTS.override.md", "AGENTS.md"] { + let path = dir.join(name); + if !is_file(&path) { + continue; + } + if let Some(bytes) = readable_nonempty_bytes(&path) { + let injected = bytes.len().min(codex_remaining); + if !String::from_utf8_lossy(&bytes[..injected]) + .trim() + .is_empty() + { + add_file( + &mut found, + OverheadFileKind::AgentsMd, + &path, + OverheadFileScope::Project, + SourceKind::Codex, + injected, + ); + codex_remaining -= injected; + } } - _ => {} + // Codex chooses by metadata before it reads content: an empty or + // unreadable override blocks AGENTS.md in the same directory. + break; + } + } + + // OpenCode takes the first filename class with any existing match, then + // loads every match in that class from CWD through the worktree root. + for (name, kind) in [ + ("AGENTS.md", OverheadFileKind::AgentsMd), + ("CLAUDE.md", OverheadFileKind::ClaudeMd), + ] { + let matches: Vec = project_chain + .iter() + .map(|dir| dir.join(name)) + .filter(|path| is_file(path)) + .collect(); + if matches.is_empty() { + continue; + } + for path in matches { + add_file( + &mut found, + kind, + &path, + OverheadFileScope::Project, + SourceKind::Opencode, + usize::MAX, + ); } + break; } - out + + found.into_iter().map(|entry| entry.file).collect() } pub(crate) fn load_overhead_file(file: OverheadFile) -> std::io::Result { - let parsed = load_claude_md_file(Path::new(&file.path))?; + let path = Path::new(&file.path); + if fs::metadata(path)?.len() as usize <= file.content_bytes { + let parsed = load_claude_md_file(path)?; + return Ok(ParsedOverheadFile { file, parsed }); + } + let mut bytes = fs::read(path)?; + bytes.truncate(file.content_bytes); + let text = String::from_utf8_lossy(&bytes); + let parsed = crate::analyze::claude_md::parse_claude_md(&file.path, &text); Ok(ParsedOverheadFile { file, parsed }) } @@ -223,35 +579,270 @@ mod tests { } } + fn write_fixture(path: &Path, content: &str) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, content).unwrap(); + } + + fn mark_git_root(path: &Path) { + fs::create_dir_all(path.join(".git")).unwrap(); + } + + fn discover_fixture(home: &Path, project: &Path) -> Vec { + find_overhead_files_with_roots(project, &DiscoveryRoots::for_home(home)) + } + #[test] - fn find_overhead_files_discovers_all_three_with_correct_applies_to() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - fs::write(root.join("CLAUDE.md"), "# root").unwrap(); - fs::create_dir_all(root.join(".claude")).unwrap(); - fs::write(root.join(".claude").join("CLAUDE.md"), "# nested").unwrap(); - fs::write(root.join("AGENTS.md"), "# agents").unwrap(); + fn discovery_matches_harness_startup_chains_scopes_and_stable_order() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path(); + let root = home.join("workspace").join("repo"); + let cwd = root.join("packages").join("api"); + fs::create_dir_all(&cwd).unwrap(); + mark_git_root(&root); + + write_fixture(&home.join(".claude/CLAUDE.md"), "global claude"); + write_fixture(&home.join(".codex/AGENTS.md"), "global codex"); + write_fixture(&home.join(".config/opencode/AGENTS.md"), "global opencode"); + write_fixture(&home.join("CLAUDE.md"), "workspace ancestor"); + write_fixture(&root.join("CLAUDE.md"), "root claude"); + write_fixture(&root.join(".claude/CLAUDE.md"), "dot claude"); + write_fixture(&root.join("CLAUDE.local.md"), "root local"); + write_fixture(&cwd.join("CLAUDE.md"), "cwd claude"); + write_fixture(&cwd.join("CLAUDE.local.md"), "cwd local"); + write_fixture(&root.join("AGENTS.md"), "root agents"); + write_fixture(&cwd.join("AGENTS.md"), "cwd agents"); - let files = find_overhead_files(root); - assert_eq!(files.len(), 3); - let agents = files + let files = discover_fixture(home, &cwd); + let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect(); + assert_eq!( + paths, + vec![ + home.join(".claude/CLAUDE.md").to_str().unwrap(), + home.join(".codex/AGENTS.md").to_str().unwrap(), + home.join(".config/opencode/AGENTS.md").to_str().unwrap(), + home.join("CLAUDE.md").to_str().unwrap(), + root.join("CLAUDE.md").to_str().unwrap(), + root.join(".claude/CLAUDE.md").to_str().unwrap(), + root.join("CLAUDE.local.md").to_str().unwrap(), + cwd.join("CLAUDE.md").to_str().unwrap(), + cwd.join("CLAUDE.local.md").to_str().unwrap(), + root.join("AGENTS.md").to_str().unwrap(), + cwd.join("AGENTS.md").to_str().unwrap(), + ] + ); + + assert_eq!(files[0].scope, OverheadFileScope::User); + assert_eq!(files[0].applies_to, vec![SourceKind::ClaudeCode]); + assert_eq!(files[1].applies_to, vec![SourceKind::Codex]); + assert_eq!(files[2].applies_to, vec![SourceKind::Opencode]); + assert_eq!(files[3].scope, OverheadFileScope::Ancestor); + assert!(files[4..] .iter() - .find(|f| f.kind == OverheadFileKind::AgentsMd) - .unwrap(); + .all(|f| f.scope == OverheadFileScope::Project)); + for file in &files[9..] { + assert_eq!( + file.applies_to, + vec![SourceKind::Codex, SourceKind::Opencode] + ); + } + } + + #[test] + fn codex_and_opencode_stop_at_git_root_but_claude_walks_above_it() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path(); + let root = home.join("outer/repo"); + let cwd = root.join("sub"); + fs::create_dir_all(&cwd).unwrap(); + mark_git_root(&root); + write_fixture(&home.join("outer/AGENTS.md"), "outside agents"); + write_fixture(&home.join("outer/CLAUDE.md"), "outside claude"); + write_fixture(&root.join("AGENTS.md"), "inside agents"); + + let files = discover_fixture(home, &cwd); + let outside_agents = home.join("outer/AGENTS.md").to_string_lossy().into_owned(); + assert!(!files.iter().any(|f| f.path == outside_agents)); + let outside_claude = home.join("outer/CLAUDE.md").to_string_lossy().into_owned(); + let file = files.iter().find(|f| f.path == outside_claude).unwrap(); + assert_eq!(file.scope, OverheadFileScope::Ancestor); + assert_eq!(file.applies_to, vec![SourceKind::ClaudeCode]); + } + + #[test] + fn no_git_root_limits_codex_and_opencode_to_requested_directory() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path(); + let parent = home.join("plain"); + let cwd = parent.join("child"); + fs::create_dir_all(&cwd).unwrap(); + write_fixture(&parent.join("AGENTS.md"), "parent agents"); + write_fixture(&cwd.join("AGENTS.md"), "cwd agents"); + + let files = discover_fixture(home, &cwd); + let parent_path = parent.join("AGENTS.md").to_string_lossy().into_owned(); + assert!(!files.iter().any(|f| f.path == parent_path)); + let cwd_path = cwd.join("AGENTS.md").to_string_lossy().into_owned(); + let file = files.iter().find(|f| f.path == cwd_path).unwrap(); assert_eq!( - agents.applies_to, + file.applies_to, vec![SourceKind::Codex, SourceKind::Opencode] ); - let claude_count = files + } + + #[test] + fn codex_override_and_opencode_filename_class_precedence_stay_distinct() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path(); + let root = home.join("repo"); + fs::create_dir_all(&root).unwrap(); + mark_git_root(&root); + write_fixture(&root.join("AGENTS.override.md"), "codex override"); + write_fixture(&root.join("AGENTS.md"), "shared agents"); + write_fixture(&root.join("CLAUDE.md"), "claude fallback"); + + let files = discover_fixture(home, &root); + let override_path = root + .join("AGENTS.override.md") + .to_string_lossy() + .into_owned(); + assert_eq!( + files + .iter() + .find(|f| f.path == override_path) + .unwrap() + .applies_to, + vec![SourceKind::Codex] + ); + let agents_path = root.join("AGENTS.md").to_string_lossy().into_owned(); + assert_eq!( + files + .iter() + .find(|f| f.path == agents_path) + .unwrap() + .applies_to, + vec![SourceKind::Opencode] + ); + let claude_path = root.join("CLAUDE.md").to_string_lossy().into_owned(); + assert_eq!( + files + .iter() + .find(|f| f.path == claude_path) + .unwrap() + .applies_to, + vec![SourceKind::ClaudeCode] + ); + } + + #[test] + fn empty_opencode_global_blocks_claude_fallback_without_adding_a_row() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path(); + let root = home.join("repo"); + fs::create_dir_all(&root).unwrap(); + write_fixture(&home.join(".claude/CLAUDE.md"), "claude global"); + write_fixture(&home.join(".config/opencode/AGENTS.md"), " \n"); + + let files = discover_fixture(home, &root); + assert_eq!(files.len(), 1); + assert_eq!(files[0].applies_to, vec![SourceKind::ClaudeCode]); + assert_eq!(files[0].scope, OverheadFileScope::User); + } + + #[test] + fn empty_codex_project_override_blocks_same_directory_agents_file() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path(); + let root = home.join("repo"); + fs::create_dir_all(&root).unwrap(); + mark_git_root(&root); + write_fixture(&root.join("AGENTS.override.md"), " \n"); + write_fixture(&root.join("AGENTS.md"), "opencode still loads this"); + + let files = discover_fixture(home, &root); + assert_eq!(files.len(), 1); + assert_eq!(files[0].applies_to, vec![SourceKind::Opencode]); + } + + #[test] + fn inactive_descendants_are_excluded_but_become_active_when_used_as_cwd() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path(); + let root = home.join("repo"); + let nested = root.join("services/payments"); + fs::create_dir_all(&nested).unwrap(); + mark_git_root(&root); + write_fixture(&root.join("CLAUDE.md"), "root"); + write_fixture(&nested.join("CLAUDE.md"), "nested"); + + let from_root = discover_fixture(home, &root); + let nested_path = nested.join("CLAUDE.md").to_string_lossy().into_owned(); + assert!(!from_root.iter().any(|f| f.path == nested_path)); + + let from_nested = discover_fixture(home, &nested); + assert!(from_nested.iter().any(|f| f.path == nested_path)); + } + + #[test] + fn codex_project_chain_obeys_aggregate_32k_byte_budget() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path(); + let root = home.join("repo"); + let cwd = root.join("sub"); + fs::create_dir_all(&cwd).unwrap(); + mark_git_root(&root); + write_fixture( + &root.join("AGENTS.md"), + &"a".repeat(DEFAULT_CODEX_PROJECT_DOC_MAX_BYTES - 8), + ); + write_fixture(&cwd.join("AGENTS.md"), "12345678ignored-tail"); + + let files = discover_fixture(home, &cwd); + let cwd_path = cwd.join("AGENTS.md").to_string_lossy().into_owned(); + let cwd_files: Vec<_> = files.iter().filter(|f| f.path == cwd_path).collect(); + assert_eq!(cwd_files.len(), 2); + + let codex = cwd_files + .iter() + .find(|f| f.applies_to == vec![SourceKind::Codex]) + .unwrap(); + assert_eq!(codex.content_bytes, 8); + let parsed = load_overhead_file((*codex).clone()).unwrap(); + assert_eq!(parsed.parsed.bytes, 8); + + let opencode = cwd_files + .iter() + .find(|f| f.applies_to == vec![SourceKind::Opencode]) + .unwrap(); + assert_eq!(opencode.content_bytes, "12345678ignored-tail".len()); + let parsed = load_overhead_file((*opencode).clone()).unwrap(); + assert_eq!(parsed.parsed.bytes, "12345678ignored-tail".len() as u64); + } + + #[cfg(unix)] + #[test] + fn physical_identity_deduplicates_claude_symlink_and_hardlink_aliases() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let home = temp.path(); + let root = home.join("repo"); + fs::create_dir_all(root.join(".claude")).unwrap(); + mark_git_root(&root); + write_fixture(&root.join("CLAUDE.md"), "same physical file"); + symlink("../CLAUDE.md", root.join(".claude/CLAUDE.md")).unwrap(); + fs::hard_link(root.join("CLAUDE.md"), root.join("CLAUDE.local.md")).unwrap(); + + let files = discover_fixture(home, &root); + let claude_files: Vec<&OverheadFile> = files .iter() .filter(|f| f.kind == OverheadFileKind::ClaudeMd) - .count(); - assert_eq!(claude_count, 2); - for f in &files { - if f.kind == OverheadFileKind::ClaudeMd { - assert_eq!(f.applies_to, vec![SourceKind::ClaudeCode]); - } - } + .collect(); + assert_eq!(claude_files.len(), 1); + assert_eq!( + claude_files[0].applies_to, + vec![SourceKind::ClaudeCode, SourceKind::Opencode] + ); } #[test] @@ -267,7 +858,9 @@ mod tests { file: OverheadFile { kind: OverheadFileKind::ClaudeMd, path: "/p/CLAUDE.md".to_string(), + scope: OverheadFileScope::Project, applies_to: vec![SourceKind::ClaudeCode], + content_bytes: usize::MAX, }, parsed: claude_md.clone(), }, @@ -275,7 +868,9 @@ mod tests { file: OverheadFile { kind: OverheadFileKind::AgentsMd, path: "/p/AGENTS.md".to_string(), + scope: OverheadFileScope::Project, applies_to: vec![SourceKind::Codex, SourceKind::Opencode], + content_bytes: usize::MAX, }, parsed: agents_md.clone(), }, @@ -345,7 +940,9 @@ mod tests { file: OverheadFile { kind: OverheadFileKind::ClaudeMd, path: "/p/CLAUDE.md".to_string(), + scope: OverheadFileScope::Project, applies_to: vec![SourceKind::ClaudeCode], + content_bytes: usize::MAX, }, parsed: small.clone(), }, @@ -353,7 +950,9 @@ mod tests { file: OverheadFile { kind: OverheadFileKind::ClaudeMd, path: "/p/.claude/CLAUDE.md".to_string(), + scope: OverheadFileScope::Project, applies_to: vec![SourceKind::ClaudeCode], + content_bytes: usize::MAX, }, parsed: big.clone(), }, @@ -406,7 +1005,9 @@ mod tests { file: OverheadFile { kind: OverheadFileKind::ClaudeMd, path: "/p/CLAUDE.md".to_string(), + scope: OverheadFileScope::Project, applies_to: vec![SourceKind::ClaudeCode], + content_bytes: usize::MAX, }, parsed: claude_md, }, @@ -414,7 +1015,9 @@ mod tests { file: OverheadFile { kind: OverheadFileKind::AgentsMd, path: "/p/AGENTS.md".to_string(), + scope: OverheadFileScope::Project, applies_to: vec![SourceKind::Codex, SourceKind::Opencode], + content_bytes: usize::MAX, }, parsed: agents_md, }, @@ -437,8 +1040,10 @@ mod tests { #[test] fn load_overhead_file_round_trips_via_find() { let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("AGENTS.md"), "## Section\nbody").unwrap(); - let files = find_overhead_files(dir.path()); + let root = dir.path().join("repo"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("AGENTS.md"), "## Section\nbody").unwrap(); + let files = discover_fixture(dir.path(), &root); assert_eq!(files.len(), 1); let f = files.into_iter().next().unwrap(); let parsed = load_overhead_file(f).unwrap(); @@ -475,7 +1080,9 @@ mod tests { file: OverheadFile { kind: OverheadFileKind::ClaudeMd, path: "/p/CLAUDE.md".to_string(), + scope: OverheadFileScope::Project, applies_to: vec![SourceKind::ClaudeCode], + content_bytes: usize::MAX, }, parsed: claude_md.clone(), }, @@ -483,7 +1090,9 @@ mod tests { file: OverheadFile { kind: OverheadFileKind::AgentsMd, path: "/p/AGENTS.md".to_string(), + scope: OverheadFileScope::Project, applies_to: vec![SourceKind::Codex, SourceKind::Opencode], + content_bytes: usize::MAX, }, parsed: agents_md.clone(), }, diff --git a/crates/relayburn-sdk/src/lib.rs b/crates/relayburn-sdk/src/lib.rs index 445b8ea1..850a620f 100644 --- a/crates/relayburn-sdk/src/lib.rs +++ b/crates/relayburn-sdk/src/lib.rs @@ -103,10 +103,10 @@ pub use crate::analyze::{ cost_for_turn, describe_applies_to, summarize_fidelity, AttributionMethod, BashAggregation, BashVerbAggregation, CostBreakdown, CoverageField, FidelitySummary, FieldCoverage, FileAggregation, FindingPricingStatus, MarkdownSection, McpServerAggregation, ModelCost, - ModelCostTier, OneShotMetrics, OutcomeLabel, OverheadFileKind, QualityResult, ReasoningMode, - ReplacementSavingsSummary, RowCoverage, SessionClaudeMdCost, SessionOutcome, - SubagentAggregation, SubagentTreeNode, SubagentTypeStats, UsageCostAggregateRow, WasteFinding, - WasteSeverity, DEFAULT_MIN_SAMPLE, + ModelCostTier, OneShotMetrics, OutcomeLabel, OverheadFileKind, OverheadFileScope, + QualityResult, ReasoningMode, ReplacementSavingsSummary, RowCoverage, SessionClaudeMdCost, + SessionOutcome, SubagentAggregation, SubagentTreeNode, SubagentTypeStats, + UsageCostAggregateRow, WasteFinding, WasteSeverity, DEFAULT_MIN_SAMPLE, }; // Span tree primitives (issue #430). Re-exported at the SDK root so diff --git a/crates/relayburn-sdk/src/query_verbs/mod.rs b/crates/relayburn-sdk/src/query_verbs/mod.rs index 0a683f8a..339fe69b 100644 --- a/crates/relayburn-sdk/src/query_verbs/mod.rs +++ b/crates/relayburn-sdk/src/query_verbs/mod.rs @@ -22,21 +22,22 @@ use crate::analyze::{ attribute_hotspots, attribute_overhead, build_compare_table, build_ghost_surface_inputs, build_subagent_tree, build_trim_recommendations, cost_for_turn, deltas_for_session, detect_ghost_surface, detect_patterns, detect_tool_call_patterns, detect_tool_output_bloat, - find_overhead_files, findings_from_patterns, ghost_surface_to_finding, has_minimum_fidelity, - load_claude_settings, load_overhead_file, load_pricing, mark_findings_with_unpriced_sessions, - project_claude_settings_path, render_unified_diff_for_recommendation, sort_findings, sum_costs, - summarize_fidelity, summarize_fidelity_from_iter, summarize_replacement_savings, - tally_unpriced, tool_call_pattern_to_finding, tool_output_bloat_to_finding, - unpriced_usage_findings, user_claude_settings_path, AggregateByProviderOptions, - AttributeOverheadInput, AttributionMethod, BashAggregation, BashVerbAggregation, - BuildSubagentTreeOptions, CompareOptions as AnalyzeCompareOptions, CompareTable, ContextDelta, - ContextDeltaOpts, CostBreakdown, DetectPatternsOptions, DetectToolCallPatternsOptions, + find_overhead_files, find_overhead_files_in_home, findings_from_patterns, + ghost_surface_to_finding, has_minimum_fidelity, load_claude_settings, load_overhead_file, + load_pricing, mark_findings_with_unpriced_sessions, project_claude_settings_path, + render_unified_diff_for_recommendation, sort_findings, sum_costs, summarize_fidelity, + summarize_fidelity_from_iter, summarize_replacement_savings, tally_unpriced, + tool_call_pattern_to_finding, tool_output_bloat_to_finding, unpriced_usage_findings, + user_claude_settings_path, AggregateByProviderOptions, AttributeOverheadInput, + AttributionMethod, BashAggregation, BashVerbAggregation, BuildSubagentTreeOptions, + CompareOptions as AnalyzeCompareOptions, CompareTable, ContextDelta, ContextDeltaOpts, + CostBreakdown, DetectPatternsOptions, DetectToolCallPatternsOptions, DetectToolOutputBloatOptions, FidelitySummary, FileAggregation, GhostSurfaceFindingOptions, HotspotsOptions as AnalyzeHotspotsOptions, LoadedClaudeSettings, MarkdownSection, - McpServerAggregation, OverheadFile, OverheadFileKind, OwnerRail, ParsedOverheadFile, - PricingTable, ProviderFilter, QualityResult, ReplacementSavingsSummary, SessionClaudeMdCost, - SubagentAggregation, SubagentTreeNode, SubagentTypeStats, ToolSavingsAggregate, TurnSpanTree, - UsageCostAggregateRow, WasteFinding, + McpServerAggregation, OverheadFile, OverheadFileKind, OverheadFileScope, OwnerRail, + ParsedOverheadFile, PricingTable, ProviderFilter, QualityResult, ReplacementSavingsSummary, + SessionClaudeMdCost, SubagentAggregation, SubagentTreeNode, SubagentTypeStats, + ToolSavingsAggregate, TurnSpanTree, UsageCostAggregateRow, WasteFinding, }; use crate::ledger::{EnrichedTurn, Enrichment, Query}; use crate::reader::{ diff --git a/crates/relayburn-sdk/src/query_verbs/overhead.rs b/crates/relayburn-sdk/src/query_verbs/overhead.rs index c67ab98c..2a2849fb 100644 --- a/crates/relayburn-sdk/src/query_verbs/overhead.rs +++ b/crates/relayburn-sdk/src/query_verbs/overhead.rs @@ -11,6 +11,9 @@ pub struct OverheadOptions { pub since: Option, pub kind: Option, pub ledger_home: Option, + /// Override the harness configuration home (`~/.claude`, `~/.codex`, + /// `~/.config/opencode`). This does not bound Claude's ancestor walk. + pub harness_home: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -40,6 +43,7 @@ pub struct OverheadAttributionDetail { pub struct OverheadFileSummary { pub kind: OverheadFileKind, pub path: String, + pub scope: OverheadFileScope, pub applies_to: Vec, pub total_lines: u64, pub bytes: u64, @@ -53,6 +57,7 @@ pub struct OverheadFileSummary { pub struct OverheadPerFileEntry { pub path: String, pub kind: OverheadFileKind, + pub scope: OverheadFileScope, pub applies_to: Vec, pub attribution: OverheadAttributionDetail, } @@ -75,6 +80,8 @@ pub struct OverheadTrimOptions { pub ledger_home: Option, pub top: Option, pub include_diff: Option, + /// See [`OverheadOptions::harness_home`]. + pub harness_home: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -100,6 +107,7 @@ pub struct OverheadTrimProjectedSavings { pub struct OverheadTrimRecommendation { pub file: String, pub kind: OverheadFileKind, + pub scope: OverheadFileScope, pub applies_to: Vec, pub section: OverheadTrimSection, pub projected_savings: OverheadTrimProjectedSavings, @@ -137,13 +145,17 @@ fn gather_overhead( project: Option<&Path>, since: Option<&str>, kind: Option, + harness_home: Option<&Path>, ) -> Result { let project_path: PathBuf = match project { Some(p) => fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()), None => std::env::current_dir()?, }; - let mut found: Vec = find_overhead_files(&project_path); + let mut found: Vec = match harness_home { + Some(home) => find_overhead_files_in_home(&project_path, home), + None => find_overhead_files(&project_path), + }; if let Some(want) = kind { found.retain(|f| f.kind == want); } @@ -157,7 +169,18 @@ fn gather_overhead( let mut parsed_files: Vec = Vec::with_capacity(found.len()); for f in found { - parsed_files.push(load_overhead_file(f)?); + // Files can disappear or become unreadable between discovery and + // loading. One raced file must not suppress the rest of the report. + if let Ok(parsed) = load_overhead_file(f) { + parsed_files.push(parsed); + } + } + if parsed_files.is_empty() { + return Ok(GatheredOverhead { + project_path, + files: Vec::new(), + attribution: None, + }); } let resolved = resolve_project(&project_path.to_string_lossy()); @@ -187,6 +210,7 @@ impl LedgerHandle { opts.project.as_deref(), opts.since.as_deref(), opts.kind, + opts.harness_home.as_deref(), )?; let project_str = data.project_path.to_string_lossy().into_owned(); let Some(attribution) = data.attribution else { @@ -203,6 +227,7 @@ impl LedgerHandle { .map(|pf| OverheadFileSummary { kind: pf.file.kind, path: pf.file.path.clone(), + scope: pf.file.scope, applies_to: pf.file.applies_to.clone(), total_lines: pf.parsed.total_lines, bytes: pf.parsed.bytes, @@ -217,6 +242,7 @@ impl LedgerHandle { .map(|p| OverheadPerFileEntry { path: p.file.path.clone(), kind: p.file.kind, + scope: p.file.scope, applies_to: p.file.applies_to.clone(), attribution: OverheadAttributionDetail { total_tokens: p.attribution.total_tokens, @@ -255,6 +281,7 @@ impl LedgerHandle { opts.project.as_deref(), opts.since.as_deref(), opts.kind, + opts.harness_home.as_deref(), )?; let project_str = data.project_path.to_string_lossy().into_owned(); let top_n = parse_top_n(opts.top); @@ -310,6 +337,7 @@ impl LedgerHandle { recommendations.push(OverheadTrimRecommendation { file: to_project_relative(&fa.file.path, &data.project_path), kind: fa.file.kind, + scope: fa.file.scope, applies_to: fa.file.applies_to.clone(), section: OverheadTrimSection { heading: rec.section.heading.clone(), diff --git a/crates/relayburn-sdk/src/query_verbs/tests.rs b/crates/relayburn-sdk/src/query_verbs/tests.rs index 5f345ae6..ef26277d 100644 --- a/crates/relayburn-sdk/src/query_verbs/tests.rs +++ b/crates/relayburn-sdk/src/query_verbs/tests.rs @@ -905,11 +905,19 @@ fn overhead_returns_empty_when_no_files_present() { let r = handle .overhead(OverheadOptions { project: Some(project.path().to_path_buf()), + harness_home: Some(project.path().join("empty-home")), ..OverheadOptions::default() }) .unwrap(); - assert!(r.files.is_empty()); - assert!(r.per_file.is_empty()); + let project_root = std::fs::canonicalize(project.path()).unwrap(); + assert!(!r + .files + .iter() + .any(|file| Path::new(&file.path).starts_with(&project_root))); + assert!(!r + .per_file + .iter() + .any(|file| Path::new(&file.path).starts_with(&project_root))); assert_eq!(r.grand_total, 0.0); } @@ -922,12 +930,21 @@ fn overhead_attributes_when_claude_md_present() { let r = handle .overhead(OverheadOptions { project: Some(project.path().to_path_buf()), + harness_home: Some(project.path().join("empty-home")), ..OverheadOptions::default() }) .unwrap(); - assert_eq!(r.files.len(), 1); - assert_eq!(r.per_file.len(), 1); - assert_eq!(r.files[0].kind, OverheadFileKind::ClaudeMd); + let fixture_path = std::fs::canonicalize(project.path().join("CLAUDE.md")) + .unwrap() + .to_string_lossy() + .into_owned(); + let file = r + .files + .iter() + .find(|file| file.path == fixture_path) + .unwrap(); + assert_eq!(file.kind, OverheadFileKind::ClaudeMd); + assert!(r.per_file.iter().any(|file| file.path == fixture_path)); } #[test] @@ -943,7 +960,8 @@ fn overhead_trim_emits_summary_when_claude_md_present() { let r = handle .overhead_trim(OverheadTrimOptions { project: Some(project.path().to_path_buf()), - top: Some(1), + top: Some(100), + harness_home: Some(project.path().join("empty-home")), ..OverheadTrimOptions::default() }) .unwrap(); @@ -951,12 +969,19 @@ fn overhead_trim_emits_summary_when_claude_md_present() { // CLAUDE.md's token count — so attribution sees no rides and total // cost is 0. `build_trim_recommendations` still emits a top-N row // per non-preamble section, with projected savings = 0; that's the - // contract. With `top=1` and two H2 sections in the file, we get - // a single recommendation. - assert_eq!(r.summary.files_analyzed, 1); - assert_eq!(r.recommendations.len(), 1); - assert_eq!(r.recommendations[0].projected_savings.per_session_usd, 0.0); - assert!(r.recommendations[0].diff.is_some()); + // contract. Both fixture sections remain discoverable even if the host + // filesystem contributes unrelated ancestor instructions. + let fixture_recommendations: Vec<_> = r + .recommendations + .iter() + .filter(|recommendation| recommendation.file == "CLAUDE.md") + .collect(); + assert_eq!(fixture_recommendations.len(), 2); + assert_eq!( + fixture_recommendations[0].projected_savings.per_session_usd, + 0.0 + ); + assert!(fixture_recommendations[0].diff.is_some()); assert_eq!(r.since, "all time"); } diff --git a/crates/relayburn-sdk/tests/integration.rs b/crates/relayburn-sdk/tests/integration.rs index 40b1719c..5ab82d10 100644 --- a/crates/relayburn-sdk/tests/integration.rs +++ b/crates/relayburn-sdk/tests/integration.rs @@ -194,14 +194,20 @@ fn sdk_verbs_round_trip_against_a_fixture_ledger() { let oh = handle .overhead(OverheadOptions { project: Some(project.path().to_path_buf()), + harness_home: Some(project.path().join("empty-home")), ..Default::default() }) .expect("handle overhead"); assert_eq!(oh.grand_total, 0.0); - assert_eq!(oh.files.len(), 0); + let project_root = std::fs::canonicalize(project.path()).expect("canonical project tmp"); + assert!(!oh + .files + .iter() + .any(|file| Path::new(&file.path).starts_with(&project_root))); let _oh2 = overhead(OverheadOptions { project: Some(project.path().to_path_buf()), ledger_home: Some(home.path().to_path_buf()), + harness_home: Some(project.path().join("empty-home")), ..Default::default() }) .expect("free overhead"); @@ -211,14 +217,18 @@ fn sdk_verbs_round_trip_against_a_fixture_ledger() { let trim = handle .overhead_trim(OverheadTrimOptions { project: Some(project.path().to_path_buf()), + harness_home: Some(project.path().join("empty-home")), ..Default::default() }) .expect("handle overhead_trim"); - assert_eq!(trim.recommendations.len(), 0); - assert_eq!(trim.summary.total_recommendations, 0); + assert!(trim.recommendations.iter().all(|recommendation| { + let path = Path::new(&recommendation.file); + path.is_absolute() && !path.starts_with(&project_root) + })); let _trim2 = overhead_trim(OverheadTrimOptions { project: Some(project.path().to_path_buf()), ledger_home: Some(home.path().to_path_buf()), + harness_home: Some(project.path().join("empty-home")), ..Default::default() }) .expect("free overhead_trim"); diff --git a/packages/relayburn/CHANGELOG.md b/packages/relayburn/CHANGELOG.md index b26052e1..9f4e9a48 100644 --- a/packages/relayburn/CHANGELOG.md +++ b/packages/relayburn/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to `relayburn`. ## [Unreleased] +- `burn overhead` discovers active user, ancestor, and project instruction chains and labels each file with its scope. - Cost output recognizes Claude 5 and GPT-5.6 models, prefers first-party tariffs, and applies long-context price tiers. - `burn hotspots --findings` identifies unknown pricing and ranks unpriced sessions by token volume instead of $0.00. diff --git a/packages/sdk-node/CHANGELOG.md b/packages/sdk-node/CHANGELOG.md index 666de257..198ac84d 100644 --- a/packages/sdk-node/CHANGELOG.md +++ b/packages/sdk-node/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] +- `overhead()` and `overheadTrim()` discover active instruction chains with configurable harness homes, label files by scope, and carry scope into trim recommendations. - Cost calculations recognize Claude 5 and GPT-5.6 models, prefer first-party tariffs, and apply long-context price tiers. - `hotspots()` findings identify unknown pricing and rank unpriced sessions by token volume instead of $0.00. diff --git a/packages/sdk-node/src/index.d.ts b/packages/sdk-node/src/index.d.ts index c532fb83..76e03adc 100644 --- a/packages/sdk-node/src/index.d.ts +++ b/packages/sdk-node/src/index.d.ts @@ -158,6 +158,7 @@ export interface FingerprintResult { export declare function fingerprint(opts?: FingerprintOptions): Promise export type OverheadFileKind = 'claude-md' | 'agents-md'; +export type OverheadFileScope = 'user' | 'ancestor' | 'project'; export type OverheadHarness = 'claude-code' | 'codex' | 'opencode'; export interface OverheadOptions { @@ -165,6 +166,7 @@ export interface OverheadOptions { since?: string; kind?: OverheadFileKind; ledgerHome?: string; + harnessHome?: string; } export interface OverheadSection { @@ -193,6 +195,7 @@ export interface OverheadAttributionDetail { export interface OverheadFileSummary { kind: OverheadFileKind; path: string; + scope: OverheadFileScope; appliesTo: OverheadHarness[]; totalLines: number; bytes: number | bigint; @@ -204,6 +207,7 @@ export interface OverheadFileSummary { export interface OverheadPerFileEntry { path: string; kind: OverheadFileKind; + scope: OverheadFileScope; appliesTo: OverheadHarness[]; attribution: OverheadAttributionDetail; } @@ -226,6 +230,7 @@ export interface OverheadTrimOptions extends OverheadOptions { export interface OverheadTrimRecommendation { file: string; kind: OverheadFileKind; + scope: OverheadFileScope; appliesTo: OverheadHarness[]; section: { heading: string; startLine: number; endLine: number; tokens: number | bigint }; projectedSavings: {