ccd-cli 1.0.0-beta.3

Bootstrap and validate Continuous Context Development repositories
use anyhow::Result;

use crate::paths::state::StateLayout;
use crate::state::runtime as runtime_state;
use crate::state::session as session_state;
use crate::state::session_gates;
use crate::state::work_stream_decay;
use crate::telemetry::{cost as telemetry_cost, host as host_telemetry};

use super::{ContextHealth, ContextSignals};

#[allow(clippy::too_many_arguments)]
pub(super) fn build_context_health(
    layout: &StateLayout,
    locality_id: &str,
    tracked_session: Option<&session_state::SessionStateFile>,
    active_session_id: Option<&str>,
    host_snapshot: Option<&host_telemetry::HostContextSnapshot>,
    handoff: &runtime_state::RuntimeHandoffState,
    attention_anchor: Option<&session_gates::ExecutionGateAnchor>,
    work_stream_decay: &work_stream_decay::WorkStreamDecayView,
) -> Result<ContextHealth> {
    let now_epoch_s = session_state::now_epoch_s()?;
    let context_used_pct = host_snapshot
        .and_then(|snapshot| snapshot.context_used_pct)
        .or_else(|| optional_env_u8("CCD_CONTEXT_USED_PCT"));
    let signals = ContextSignals {
        compacted: host_snapshot
            .and_then(|snapshot| snapshot.compacted)
            .or_else(|| optional_env_bool("CCD_CONTEXT_COMPACTED")),
        session_minutes: tracked_session
            .map(|state| session_state::session_minutes(state, now_epoch_s)),
        ccd_start_cycles_in_conversation: tracked_session.map(|state| state.start_count),
        host_total_tokens: host_snapshot.and_then(|snapshot| snapshot.total_tokens),
        host_context_window_tokens: host_snapshot
            .and_then(|snapshot| snapshot.model_context_window),
        tool_output_kb: optional_env_u64("CCD_TOOL_OUTPUT_KB"),
        operator_symptoms: optional_env_list("CCD_OPERATOR_SYMPTOMS"),
    };
    let has_host_signal = host_snapshot.is_some();
    let has_env_signal = optional_env_u8("CCD_CONTEXT_USED_PCT").is_some()
        || signals.compacted.is_some()
        || signals.tool_output_kb.is_some()
        || !signals.operator_symptoms.is_empty();
    let has_session_signal =
        signals.session_minutes.is_some() || signals.ccd_start_cycles_in_conversation.is_some();
    let source = match (has_host_signal, has_env_signal, has_session_signal) {
        (true, _, true) => "mixed",
        (true, _, false) => "host",
        (false, true, true) => "mixed",
        (false, true, false) => "env",
        (false, false, true) => "heuristic",
        (false, false, false) => "none",
    };

    let mut risk = 0u8;
    let mut scored_signals = 0usize;

    if let Some(minutes) = signals.session_minutes {
        scored_signals += 1;
        risk = risk.saturating_add(match minutes {
            0..=29 => 0,
            30..=59 => 10,
            60..=89 => 25,
            _ => 40,
        });
    }

    if let Some(cycles) = signals.ccd_start_cycles_in_conversation {
        scored_signals += 1;
        risk = risk.saturating_add(match cycles {
            0..=1 => 0,
            2..=3 => 10,
            _ => 25,
        });
    }

    if let Some(used_pct) = context_used_pct {
        scored_signals += 1;
        risk = risk.saturating_add(match used_pct {
            0..=69 => 0,
            70..=84 => 10,
            85..=94 => 20,
            _ => 30,
        });
    }

    // ccd#529: when context_used_pct is unavailable (no window source),
    // fall back to raw host_total_tokens as a coarse degradation proxy.
    // The thresholds are deliberately conservative — without a window we
    // cannot compute utilization, so we score absolute token volume on a
    // gentle curve. This prevents the "0% at 400k tokens" false-low
    // that the issue reported.
    if context_used_pct.is_none() {
        if let Some(total_tokens) = signals.host_total_tokens {
            scored_signals += 1;
            risk = risk.saturating_add(match total_tokens {
                0..=99_999 => 0,
                100_000..=199_999 => 5,
                200_000..=399_999 => 15,
                400_000..=699_999 => 25,
                _ => 35,
            });
        }
    }

    if let Some(compacted) = signals.compacted {
        scored_signals += 1;
        if compacted {
            risk = risk.saturating_add(35);
        }
    }

    if let Some(tool_output_kb) = signals.tool_output_kb {
        scored_signals += 1;
        risk = risk.saturating_add(match tool_output_kb {
            0..=99 => 0,
            100..=249 => 10,
            250..=499 => 20,
            _ => 30,
        });
    }

    if !signals.operator_symptoms.is_empty() {
        scored_signals += 1;
        let symptom_score: u8 = signals.operator_symptoms.iter().take(3).map(|_| 15u8).sum();
        risk = risk.saturating_add(symptom_score);
    }

    if work_stream_decay.consecutive_no_progress > 0 {
        scored_signals += 1;
        risk = risk.saturating_add(match work_stream_decay.consecutive_no_progress {
            0..=1 => 5,
            2 => 20,
            _ => 35,
        });
    }

    let degradation_risk_pct = if scored_signals == 0 {
        None
    } else {
        Some(risk.min(95))
    };

    let band = match degradation_risk_pct {
        None => "unknown",
        Some(0..=34) => "low",
        Some(35..=59) => "moderate",
        Some(60..=79) => "risky",
        Some(_) => "critical",
    };

    // ccd#529: when the host adapter reports cumulative tokens but the
    // window is unknown (no native source and no env-var override),
    // `context_used_pct` can't be computed and the degradation score
    // is only driven by session-scoped signals that reset on every
    // `session-state clear`. Flag this partial-signal state explicitly
    // rather than fabricate a band — users should set
    // `CCD_CONTEXT_WINDOW_TOKENS` (or a runtime-specific override) to
    // unlock pct-based scoring.
    let host_window_unknown = signals.host_total_tokens.is_some()
        && context_used_pct.is_none()
        && signals.host_context_window_tokens.is_none();

    let confidence = if host_window_unknown {
        "low"
    } else if has_host_signal && (signals.compacted.is_some() || has_session_signal) {
        "high"
    } else if has_host_signal || has_session_signal || has_env_signal {
        "medium"
    } else {
        "low"
    };

    let recommendation = match degradation_risk_pct {
        Some(80..=100) => "wrap_up_and_clear",
        Some(60..=79) => "wrap_up_soon",
        Some(35..=59) => "keep_focus",
        Some(_) if host_window_unknown => "capture_session_state",
        Some(_) => "continue",
        None if host_window_unknown => "capture_session_state",
        None if has_host_signal || has_env_signal || has_session_signal => "continue",
        None => "capture_session_state",
    };
    let recommendation = match work_stream_decay.status {
        "critical" => "wrap_up_and_clear",
        "warning" if recommendation == "continue" => "wrap_up_soon",
        _ => recommendation,
    };
    let continuity_actions = handoff
        .immediate_actions
        .iter()
        .filter(|item| item.lifecycle.is_active())
        .map(|item| item.text.clone())
        .collect::<Vec<_>>();
    let focus =
        telemetry_cost::continuity_target(attention_anchor, &handoff.title, &continuity_actions);
    let cost = telemetry_cost::build_cost_view_for_focus(
        layout,
        locality_id,
        active_session_id,
        host_snapshot,
        focus.as_ref(),
    )?;

    Ok(ContextHealth {
        source,
        context_used_pct,
        degradation_risk_pct,
        band,
        confidence,
        signals,
        cost,
        recommendation,
    })
}

fn optional_env_bool(name: &str) -> Option<bool> {
    let value = std::env::var(name).ok()?;
    match value.trim().to_ascii_lowercase().as_str() {
        "1" | "true" | "yes" => Some(true),
        "0" | "false" | "no" => Some(false),
        _ => None,
    }
}

fn optional_env_u8(name: &str) -> Option<u8> {
    std::env::var(name)
        .ok()
        .and_then(|value| value.trim().parse::<u8>().ok())
}

fn optional_env_u64(name: &str) -> Option<u64> {
    std::env::var(name)
        .ok()
        .and_then(|value| value.trim().parse::<u64>().ok())
}

fn optional_env_list(name: &str) -> Vec<String> {
    std::env::var(name)
        .ok()
        .map(|value| {
            value
                .split(',')
                .map(str::trim)
                .filter(|entry| !entry.is_empty())
                .map(str::to_owned)
                .collect()
        })
        .unwrap_or_default()
}