#![allow(dead_code)]
use std::path::Path;
use super::decisions::{DecisionRecord, read_all_decisions};
use super::garden::find_claude_md;
use super::preferences::{
DistilledPreferences, PreferencePattern, format_preference_summary,
load_preferences_for_project,
};
use super::sequences::{AntiPattern, load_library};
const MAX_DECISIONS_IN_BRIEFING: usize = 8;
const MAX_ANTIPATTERNS_IN_BRIEFING: usize = 3;
const MAX_PREFERENCE_LINES: usize = 10;
const RECENT_WINDOW_SECS: u64 = 7 * 24 * 3600;
#[derive(Debug, Clone, Default)]
pub struct BriefingOptions {
pub project: Option<String>,
pub max_decisions: Option<usize>,
pub include_claude_md_check: bool,
}
pub fn build_briefing(opts: &BriefingOptions, cwd: &Path) -> String {
let project = opts.project.as_deref().unwrap_or("(global)");
let all = read_all_decisions();
let project_filter = opts.project.as_deref();
let recent: Vec<&DecisionRecord> = filter_recent_for_project(&all, project_filter);
let prefs = project_filter
.and_then(load_preferences_for_project)
.or_else(super::preferences::load_preferences);
let library = load_library();
let project_antipatterns = filter_antipatterns_for_project(&library, &recent);
let mut sections: Vec<String> = Vec::new();
sections.push(format!("# Session briefing — {project}"));
sections.push(
"_Auto-generated by `claudectl --brain-briefing`. Reflects state of the brain at session start._"
.to_string(),
);
if let Some(prefs) = prefs.as_ref() {
if !prefs.patterns.is_empty() {
sections.push(render_preferences_section(prefs));
}
}
if !project_antipatterns.is_empty() {
sections.push(render_antipatterns_section(&project_antipatterns));
}
let max_decisions = opts.max_decisions.unwrap_or(MAX_DECISIONS_IN_BRIEFING);
if !recent.is_empty() {
sections.push(render_recent_decisions_section(&recent, max_decisions));
}
if opts.include_claude_md_check {
if let Some(path) = find_claude_md(cwd) {
sections.push(format!(
"## Project guidance\n\n- `CLAUDE.md` is loaded automatically by Claude Code: `{}`",
path.display()
));
}
}
if sections.len() == 2 {
sections
.push("_No accumulated brain data yet for this project. Briefing is empty._".into());
}
sections.join("\n\n")
}
fn filter_recent_for_project<'a>(
all: &'a [DecisionRecord],
project: Option<&str>,
) -> Vec<&'a DecisionRecord> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let mut filtered: Vec<&DecisionRecord> = all
.iter()
.filter(|d| match project {
Some(p) => d.project.eq_ignore_ascii_case(p),
None => true,
})
.filter(|d| {
d.resolved_at
.map(|ts| now.saturating_sub(ts) <= RECENT_WINDOW_SECS)
.unwrap_or(true)
})
.collect();
filtered.sort_by_key(|d| std::cmp::Reverse(d.resolved_at.unwrap_or(0)));
filtered
}
fn filter_antipatterns_for_project<'a>(
library: &'a [AntiPattern],
recent: &[&DecisionRecord],
) -> Vec<&'a AntiPattern> {
if recent.is_empty() {
return Vec::new();
}
let project_tools: std::collections::HashSet<String> =
recent.iter().filter_map(|d| d.tool.clone()).collect();
library
.iter()
.filter(|ap| ap.steps.iter().any(|s| project_tools.contains(&s.tool)))
.take(MAX_ANTIPATTERNS_IN_BRIEFING)
.collect()
}
fn render_preferences_section(prefs: &DistilledPreferences) -> String {
let mut out = String::from("## Learned preferences\n\n");
let top: Vec<&PreferencePattern> = prefs
.patterns
.iter()
.filter(|p| p.sample_count >= 5 && p.confidence >= 0.7)
.take(MAX_PREFERENCE_LINES)
.collect();
if top.is_empty() {
out.push_str(&format_preference_summary(prefs));
return out;
}
for p in top {
let cmd_part = p
.command_pattern
.as_deref()
.map(|c| format!(" `{c}`"))
.unwrap_or_default();
let strength = if p.accept_rate >= 0.9 {
"almost always"
} else if p.accept_rate >= 0.7 {
"usually"
} else if p.accept_rate <= 0.1 {
"almost never"
} else if p.accept_rate <= 0.3 {
"rarely"
} else {
"sometimes"
};
out.push_str(&format!(
"- {strength} {} `{}`{cmd_part} _(n={}, conf={:.0}%)_\n",
p.preferred_action,
p.tool,
p.sample_count,
p.confidence * 100.0,
));
}
out
}
fn render_antipatterns_section(library: &[&AntiPattern]) -> String {
let mut out = String::from("## Anti-patterns to avoid\n\n");
for ap in library {
out.push_str(&format!(
"- {} — bad outcome {}/{} ({:.0}%)\n",
ap.display(),
ap.bad_terminals,
ap.total_occurrences,
ap.bad_rate() * 100.0,
));
}
out
}
fn render_recent_decisions_section(recent: &[&DecisionRecord], limit: usize) -> String {
let mut out = String::from("## Recent decisions\n\n");
for d in recent.iter().take(limit) {
let tool = d.tool.as_deref().unwrap_or("?");
let cmd = d
.command
.as_deref()
.map(|c| truncate(c, 60))
.unwrap_or_default();
let verdict = if d.is_positive() {
"approved"
} else if d.is_negative() {
"rejected"
} else {
"observed"
};
out.push_str(&format!("- [{tool}] `{cmd}` — {verdict}\n"));
}
out
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let mut out: String = s.chars().take(max - 1).collect();
out.push('…');
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::brain::decisions::DecisionType;
use crate::brain::sequences::SeqStep;
fn dec(project: &str, tool: &str, cmd: &str, action: &str, ts: u64) -> DecisionRecord {
DecisionRecord {
timestamp: ts.to_string(),
pid: 1,
project: project.into(),
tool: Some(tool.into()),
command: Some(cmd.into()),
brain_action: "approve".into(),
brain_confidence: 0.9,
brain_reasoning: String::new(),
user_action: action.into(),
context: None,
outcome: None,
decision_type: DecisionType::Session,
suggested_at: Some(ts),
resolved_at: Some(ts),
override_reason: None,
decision_id: None,
}
}
#[test]
fn filter_recent_by_project_case_insensitive() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let all = vec![
dec("Claudectl", "Bash", "cargo test", "accept", now),
dec("other", "Bash", "rm -rf", "reject", now),
];
let kept = filter_recent_for_project(&all, Some("claudectl"));
assert_eq!(kept.len(), 1);
assert_eq!(kept[0].project, "Claudectl");
}
#[test]
fn filter_recent_drops_stale() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let stale_ts = now - (RECENT_WINDOW_SECS + 60);
let all = vec![
dec("p", "Bash", "fresh", "accept", now),
dec("p", "Bash", "stale", "accept", stale_ts),
];
let kept = filter_recent_for_project(&all, Some("p"));
assert_eq!(kept.len(), 1);
assert_eq!(kept[0].command.as_deref().unwrap(), "fresh");
}
#[test]
fn antipatterns_filtered_to_project_tools() {
let owned = [dec("p", "Edit", "src/lib.rs", "accept", 1000)];
let recent_refs: Vec<&DecisionRecord> = owned.iter().collect();
let library = vec![
AntiPattern {
steps: vec![SeqStep {
tool: "Edit".into(),
cmd: None,
had_error: false,
}],
total_occurrences: 5,
bad_terminals: 4,
last_seen: 1000,
avg_downstream_cost: 0.0,
},
AntiPattern {
steps: vec![SeqStep {
tool: "TaskCompletely".into(),
cmd: None,
had_error: false,
}],
total_occurrences: 5,
bad_terminals: 4,
last_seen: 1000,
avg_downstream_cost: 0.0,
},
];
let kept = filter_antipatterns_for_project(&library, &recent_refs);
assert_eq!(kept.len(), 1);
assert_eq!(kept[0].steps[0].tool, "Edit");
}
#[test]
fn briefing_is_self_explanatory_when_empty() {
let tmp = tempfile::tempdir().unwrap();
let original_home = std::env::var("HOME").ok();
unsafe { std::env::set_var("HOME", tmp.path()) };
let opts = BriefingOptions {
project: Some("nonexistent-project-name".into()),
..Default::default()
};
let briefing = build_briefing(&opts, tmp.path());
if let Some(h) = original_home {
unsafe { std::env::set_var("HOME", h) };
} else {
unsafe { std::env::remove_var("HOME") };
}
assert!(briefing.contains("Session briefing"));
assert!(briefing.contains("No accumulated brain data"));
}
}