claudectl 0.49.3

Mission control for Claude Code — supervise, orchestrate, and connect coding agents with a local LLM brain and hive mind
Documentation
#![allow(dead_code)]

//! Proactive context pre-seeding (#198).
//!
//! Every new Claude Code session starts cold. The brain already accumulates
//! per-project preferences, anti-patterns, insights, and recent decision logs
//! — but none of that reaches the session at launch. This module composes a
//! compact, markdown-formatted briefing intended for injection at session
//! start (via plugin SessionStart hook or manual paste).

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};

// ────────────────────────────────────────────────────────────────────────────
// Tunables — keep the briefing short. SessionStart context is precious.
// ────────────────────────────────────────────────────────────────────────────

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;

// ────────────────────────────────────────────────────────────────────────────
// Public API
// ────────────────────────────────────────────────────────────────────────────

#[derive(Debug, Clone, Default)]
pub struct BriefingOptions {
    pub project: Option<String>,
    pub max_decisions: Option<usize>,
    pub include_claude_md_check: bool,
}

/// Build a markdown briefing for the given project. Caller decides what to do
/// with it (print, inject, save).
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 {
        // Only the header and disclaimer — no data to brief on.
        sections
            .push("_No accumulated brain data yet for this project. Briefing is empty._".into());
    }

    sections.join("\n\n")
}

// ────────────────────────────────────────────────────────────────────────────
// Filtering helpers
// ────────────────────────────────────────────────────────────────────────────

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
}

/// Keep only anti-patterns whose tools actually appeared in this project's
/// recent decisions. Without this filter the global library would pollute
/// every project's briefing.
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()
}

// ────────────────────────────────────────────────────────────────────────────
// Section rendering
// ────────────────────────────────────────────────────────────────────────────

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
    }
}

// ────────────────────────────────────────────────────────────────────────────
// Tests
// ────────────────────────────────────────────────────────────────────────────

#[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() {
        // Override HOME so we read from a clean tmp dir instead of the dev
        // machine's real ~/.claudectl (which may have decisions/preferences).
        let tmp = tempfile::tempdir().unwrap();
        let original_home = std::env::var("HOME").ok();
        // SAFETY: cargo test in this crate runs sequentially for env mutation.
        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"));
    }
}