use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use tracing::debug;
use crate::commands::start::START_RENDER_SECTION_ORDER;
use crate::handoff::{self, GitState};
use crate::paths::state::StateLayout;
use crate::state::compiled as compiled_state;
use crate::state::consistency;
use crate::state::projection_metadata;
use crate::state::reference_paths::{collect_prose_path_candidates, extract_key_files_candidates};
use crate::state::runtime as runtime_state;
use crate::state::session as session_state;
use super::{
BehavioralDriftInputs, BehavioralDriftSignal, BehavioralDriftState, DriftAggregateStatus,
DriftSignalStatus, HandoffState,
};
pub(super) const CANONICAL_PROMPT_SURFACE_ORDER: &[&str] = &[
"effective_policy",
"effective_memory",
"execution_gates",
"handoff",
];
pub(super) const CANONICAL_COMPILED_SURFACE_ORDER: &[&str] = &[
"effective_memory",
"handoff",
"execution_gates",
"escalation",
"recovery",
"git_state",
"session_state",
];
pub(super) struct ProjectionObservationLoad {
pub(super) observations: Vec<projection_metadata::ProjectionObservation>,
pub(super) warnings: Vec<String>,
}
pub(super) fn build_behavioral_drift(
repo_root: &Path,
inputs: BehavioralDriftInputs<'_>,
) -> BehavioralDriftState {
let prefix_observations = projection_observations_for_session(
inputs.layout,
inputs.locality_id,
inputs.runtime,
inputs.tracked_session,
);
let signals = vec![
build_handoff_structure_signal(inputs.handoff_contents),
build_handoff_expectations_signal(inputs.tracked_session, inputs.git, inputs.handoff),
build_handoff_references_signal(repo_root, inputs.runtime_handoff),
build_handoff_issue_references_signal(
repo_root,
inputs.runtime_handoff,
inputs.tracked_session,
),
build_surface_order_signal(),
build_compiled_state_churn_signal(
inputs.layout,
inputs.tracked_session,
&prefix_observations,
),
build_tool_surface_signal(inputs.tracked_session, &prefix_observations),
]
.into_iter()
.chain(
inputs
.consistency_axes
.into_iter()
.map(map_consistency_axis),
)
.collect::<Vec<_>>();
let drifted = signals
.iter()
.filter(|signal| signal.status == DriftSignalStatus::Drift)
.collect::<Vec<_>>();
let no_signal_count = signals
.iter()
.filter(|signal| signal.status == DriftSignalStatus::NoSignal)
.count();
if !drifted.is_empty() {
let mut recommended_corrections = Vec::new();
for correction in drifted
.iter()
.filter_map(|signal| signal.recommended_correction.clone())
{
if !recommended_corrections.contains(&correction) {
recommended_corrections.push(correction);
}
}
return BehavioralDriftState {
status: DriftAggregateStatus::NeedsRecalibration,
summary: format!(
"{} behavioral drift signal(s) need explicit recalibration before wrap-up is treated as clean.",
drifted.len()
),
evidence: drifted
.iter()
.flat_map(|signal| signal.evidence.clone())
.collect(),
recommended_corrections,
signals,
};
}
if no_signal_count == signals.len() {
return BehavioralDriftState {
status: DriftAggregateStatus::NoSignal,
summary: "Radar has no deterministic behavioral-drift signal beyond the base evaluation surfaces."
.to_owned(),
evidence: Vec::new(),
recommended_corrections: Vec::new(),
signals,
};
}
BehavioralDriftState {
status: DriftAggregateStatus::Aligned,
summary: "No confirmed behavioral drift was detected from the available CCD-local signals."
.to_owned(),
evidence: Vec::new(),
recommended_corrections: Vec::new(),
signals,
}
}
fn map_consistency_axis(axis: consistency::ConsistencyAxis) -> BehavioralDriftSignal {
BehavioralDriftSignal {
id: axis.id,
status: match axis.status {
consistency::ConsistencyStatus::Aligned => DriftSignalStatus::Aligned,
consistency::ConsistencyStatus::Drift => DriftSignalStatus::Drift,
consistency::ConsistencyStatus::NoSignal => DriftSignalStatus::NoSignal,
},
summary: axis.summary,
evidence: axis.evidence,
recommended_correction: axis.recommended_correction,
}
}
#[derive(Clone, Debug)]
enum HandoffReferenceOutcome {
Resolved,
Missing,
OutsideRepo { reason: &'static str },
}
fn classify_handoff_reference(
repo_root: &Path,
canonical_root: &Path,
candidate: &str,
) -> HandoffReferenceOutcome {
let path = Path::new(candidate);
if path.is_absolute() {
return HandoffReferenceOutcome::OutsideRepo {
reason: "absolute path",
};
}
if path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return HandoffReferenceOutcome::OutsideRepo {
reason: "parent-directory traversal escapes the repo root",
};
}
let target = repo_root.join(path);
if !target.exists() {
return HandoffReferenceOutcome::Missing;
}
let canonical_target = match target.canonicalize() {
Ok(target) => target,
Err(_) => return HandoffReferenceOutcome::Missing,
};
if canonical_target.starts_with(canonical_root) {
HandoffReferenceOutcome::Resolved
} else {
HandoffReferenceOutcome::OutsideRepo {
reason: "symlink target escapes the repo root",
}
}
}
enum HandoffEntryOutcome {
Resolved,
Missing,
OutsideRepo(&'static str),
}
fn classify_handoff_entry(
repo_root: &Path,
canonical_root: &Path,
candidates: &[String],
) -> HandoffEntryOutcome {
let mut last_outside: Option<&'static str> = None;
for candidate in candidates {
match classify_handoff_reference(repo_root, canonical_root, candidate) {
HandoffReferenceOutcome::Resolved => return HandoffEntryOutcome::Resolved,
HandoffReferenceOutcome::Missing => {}
HandoffReferenceOutcome::OutsideRepo { reason } => last_outside = Some(reason),
}
}
match last_outside {
Some(reason) => HandoffEntryOutcome::OutsideRepo(reason),
None => HandoffEntryOutcome::Missing,
}
}
fn build_handoff_references_signal(
repo_root: &Path,
handoff: &runtime_state::RuntimeHandoffState,
) -> BehavioralDriftSignal {
let canonical_root = match repo_root.canonicalize() {
Ok(root) => root,
Err(_) => {
return drift(
"handoff_references_resolved",
"The current handoff still references paths that could not be validated against the repo root.",
vec![format!(
"Could not canonicalize repo root for handoff reference validation: {}",
repo_root.display()
)],
"Re-run radar-state from a valid repo checkout so CCD can validate handoff references against the repo root.",
)
}
};
let mut missing: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
let mut outside: std::collections::BTreeMap<String, &'static str> =
std::collections::BTreeMap::new();
for item in handoff.key_files.iter().filter(|i| i.lifecycle.is_active()) {
let candidates = extract_key_files_candidates(&item.text);
if candidates.is_empty() {
continue;
}
match classify_handoff_entry(repo_root, &canonical_root, &candidates) {
HandoffEntryOutcome::Resolved => {}
HandoffEntryOutcome::Missing => {
missing.insert(item.text.clone());
}
HandoffEntryOutcome::OutsideRepo(reason) => {
outside.insert(item.text.clone(), reason);
}
}
}
let mut prose_seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let prose_fields: [&[runtime_state::RuntimeHandoffItem]; 3] = [
&handoff.immediate_actions,
&handoff.operational_guardrails,
&handoff.definition_of_done,
];
for field in prose_fields {
for item in field.iter().filter(|i| i.lifecycle.is_active()) {
let mut tokens: Vec<String> = Vec::new();
collect_prose_path_candidates(&item.text, &mut tokens);
for token in tokens {
if !prose_seen.insert(token.clone()) {
continue;
}
match classify_handoff_reference(repo_root, &canonical_root, &token) {
HandoffReferenceOutcome::Resolved => {}
HandoffReferenceOutcome::Missing => {
missing.insert(token);
}
HandoffReferenceOutcome::OutsideRepo { reason } => {
outside.insert(token, reason);
}
}
}
}
}
if missing.is_empty() && outside.is_empty() {
return aligned(
"handoff_references_resolved",
"Every handoff path reference resolves to a repo-local path under the repo root.",
Vec::new(),
);
}
let mut evidence: Vec<String> = Vec::new();
if !missing.is_empty() {
evidence.push(format!(
"{} handoff path reference(s) no longer exist on disk:",
missing.len()
));
evidence.extend(
missing
.iter()
.map(|path| format!("- {}", markdown_list_literal(path))),
);
}
if !outside.is_empty() {
evidence.push(format!(
"{} handoff path reference(s) are not repo-local:",
outside.len()
));
evidence.extend(
outside
.iter()
.map(|(path, reason)| format!("- {} ({reason})", markdown_list_literal(path))),
);
}
drift(
"handoff_references_resolved",
"The current handoff still references paths that are missing or live outside the repo root.",
evidence,
"Rewrite the handoff so its key_files, immediate actions, guardrails, and definition of done only reference repo-local paths that still exist, then run `ccd handoff write` to persist the corrected handoff.",
)
}
pub(super) struct SessionCommitRef {
pub(super) short_hash: String,
pub(super) subject: String,
pub(super) issues: BTreeSet<u32>,
}
fn extract_commit_issue_refs(text: &str, out: &mut BTreeSet<u32>) {
const KEYWORDS: &[&str] = &[
"close", "closes", "closed", "fix", "fixes", "fixed", "resolve", "resolves", "resolved",
"ref", "refs",
];
let lower = text.to_ascii_lowercase();
let bytes = lower.as_bytes();
for keyword in KEYWORDS {
let kw_bytes = keyword.as_bytes();
let mut cursor = 0;
while cursor + kw_bytes.len() <= bytes.len() {
match lower[cursor..].find(keyword) {
Some(rel) => {
let start = cursor + rel;
let end = start + kw_bytes.len();
let before_ok = start == 0 || !bytes[start - 1].is_ascii_alphanumeric();
let after_ok = bytes.get(end).is_none_or(|c| !c.is_ascii_alphabetic());
if before_ok && after_ok {
if let Some(issue) = parse_issue_suffix(&lower[end..]) {
out.insert(issue);
}
}
cursor = start + 1;
}
None => break,
}
}
}
let mut cursor = 0;
while let Some(rel) = lower[cursor..].find("(#") {
let start = cursor + rel + 2;
let tail = &lower[start..];
let digit_end = tail
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(tail.len());
if digit_end > 0 && tail.as_bytes().get(digit_end) == Some(&b')') {
if let Ok(issue) = tail[..digit_end].parse::<u32>() {
out.insert(issue);
}
}
cursor = start;
}
}
fn parse_issue_suffix(tail: &str) -> Option<u32> {
let trimmed = tail.trim_start_matches(|c: char| c == ':' || c.is_ascii_whitespace());
let without_prefix = trimmed.strip_prefix("ccd").unwrap_or(trimmed);
let without_hash = without_prefix.strip_prefix('#')?;
let digit_end = without_hash
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(without_hash.len());
if digit_end == 0 {
return None;
}
without_hash[..digit_end].parse::<u32>().ok()
}
fn extract_handoff_issue_refs(handoff: &runtime_state::RuntimeHandoffState) -> BTreeSet<u32> {
let mut out = BTreeSet::new();
collect_handoff_issue_tokens(&handoff.title, &mut out);
for item in handoff
.immediate_actions
.iter()
.filter(|i| i.lifecycle.is_active())
{
collect_handoff_issue_tokens(&item.text, &mut out);
}
out
}
fn collect_handoff_issue_tokens(text: &str, out: &mut BTreeSet<u32>) {
let bytes = text.as_bytes();
let mut idx = 0;
while idx < bytes.len() {
if bytes[idx] == b'#' {
let digit_start = idx + 1;
let mut digit_end = digit_start;
while digit_end < bytes.len() && bytes[digit_end].is_ascii_digit() {
digit_end += 1;
}
if digit_end > digit_start {
if bytes
.get(digit_end)
.is_none_or(|c| !c.is_ascii_alphanumeric())
{
if let Ok(issue) = text[digit_start..digit_end].parse::<u32>() {
out.insert(issue);
}
}
idx = digit_end;
continue;
}
}
idx += 1;
}
}
pub(super) fn collect_session_commits(
repo_root: &Path,
session_start_epoch_s: u64,
) -> Result<Vec<SessionCommitRef>, String> {
use std::process::Command;
let since_arg = format!("--since=@{session_start_epoch_s}");
debug!(
args = ?["log", "HEAD", since_arg.as_str(), "--format"],
session_start_epoch_s,
dir = %repo_root.display(),
"spawning git log for handoff issue-reference drift"
);
let output = Command::new("git")
.args([
"log",
"HEAD",
&since_arg,
"--format=%ct%x1f%h%x1f%s%x1f%B%x1e",
])
.current_dir(repo_root)
.output()
.map_err(|err| format!("failed to spawn `git log`: {err}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let detail = stderr.trim();
if detail.is_empty() {
return Err("`git log` exited non-zero".to_owned());
}
return Err(format!("`git log` failed: {detail}"));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut commits = Vec::new();
for record in stdout.split('\x1e') {
let record = record.trim_matches(|c: char| c == '\n' || c == '\r');
if record.is_empty() {
continue;
}
let mut parts = record.splitn(4, '\x1f');
let committer_epoch = parts.next().unwrap_or("").trim().parse::<u64>().ok();
let hash = parts.next().unwrap_or("").trim().to_owned();
let subject = parts.next().unwrap_or("").trim().to_owned();
let body = parts.next().unwrap_or("");
let Some(ct) = committer_epoch else { continue };
if ct < session_start_epoch_s {
break;
}
if hash.is_empty() {
continue;
}
let mut issues = BTreeSet::new();
extract_commit_issue_refs(&subject, &mut issues);
extract_commit_issue_refs(body, &mut issues);
commits.push(SessionCommitRef {
short_hash: hash,
subject,
issues,
});
}
Ok(commits)
}
pub(super) fn build_handoff_issue_references_signal(
repo_root: &Path,
handoff: &runtime_state::RuntimeHandoffState,
tracked_session: Option<&session_state::SessionStateFile>,
) -> BehavioralDriftSignal {
let session_start = match tracked_session {
Some(session) if session.started_at_epoch_s > 0 => session.started_at_epoch_s,
_ => {
return no_signal(
"handoff_issue_references_fresh",
"No active CCD session boundary is available; cannot bound `session_start..HEAD` for handoff issue-reference drift.",
Vec::new(),
);
}
};
let handoff_issues = extract_handoff_issue_refs(handoff);
if handoff_issues.is_empty() {
return aligned(
"handoff_issue_references_fresh",
"The current handoff title and immediate actions do not reference any GitHub issue tokens, so session-bounded commits cannot stale them.",
Vec::new(),
);
}
let commits = match collect_session_commits(repo_root, session_start) {
Ok(commits) => commits,
Err(reason) => {
return drift(
"handoff_issue_references_fresh",
"Could not read the session-bounded commit history needed to validate handoff issue references; staleness cannot be ruled out.",
vec![
format!(
"{} handoff issue reference(s) remain unverified:",
handoff_issues.len()
),
handoff_issues
.iter()
.map(|issue| format!("#{issue}"))
.collect::<Vec<_>>()
.join(", "),
format!("git log read failure: {reason}"),
],
"Re-run `ccd radar-state` from a valid git checkout so CCD can walk `session_start..HEAD` and decide whether the handoff still names advanced issues; if the read keeps failing, manually reconcile the handoff's title and immediate_actions against the issues the session landed before closing out.",
);
}
};
let mut hits: BTreeMap<u32, Vec<&SessionCommitRef>> = BTreeMap::new();
for commit in &commits {
for issue in &commit.issues {
if handoff_issues.contains(issue) {
hits.entry(*issue).or_default().push(commit);
}
}
}
if hits.is_empty() {
return aligned(
"handoff_issue_references_fresh",
"No commits in `session_start..HEAD` close or reference GitHub issues named in the current handoff.",
Vec::new(),
);
}
let mut evidence: Vec<String> = Vec::with_capacity(hits.len() + 1);
evidence.push(format!(
"{} handoff issue reference(s) have been advanced by commits in `session_start..HEAD`:",
hits.len()
));
for (issue, commits) in &hits {
let joined = commits
.iter()
.map(|commit| format!("{} ({})", commit.short_hash, commit.subject))
.collect::<Vec<_>>()
.join("; ");
evidence.push(format!("- #{issue}: {joined}"));
}
drift(
"handoff_issue_references_fresh",
"The current handoff still references GitHub issues that commits landed during this session have closed or advanced.",
evidence,
"Rewrite the handoff so its title and immediate_actions no longer reference GitHub issues that were closed or materially advanced by commits in `session_start..HEAD`, then run `ccd handoff write` to persist the corrected handoff.",
)
}
fn markdown_list_literal(text: &str) -> String {
if text.contains('`') {
text.to_owned()
} else {
format!("`{text}`")
}
}
fn build_handoff_structure_signal(contents: &str) -> BehavioralDriftSignal {
let missing_sections = handoff::REQUIRED_SECTIONS
.iter()
.filter(|section| !handoff::has_section(contents, section))
.map(|section| section.trim_start_matches("## ").to_owned())
.collect::<Vec<_>>();
if missing_sections.is_empty() {
return aligned(
"handoff_structure",
"The workspace-local handoff contains the required CCD session sections.",
vec![layout_handoff_sections()],
);
}
drift(
"handoff_structure",
"The workspace-local handoff is structurally incomplete for reliable next-session guidance.",
vec![format!("Missing sections: {}", missing_sections.join(", "))],
"Rewrite the handoff so it includes the required CCD sections before closing the session.",
)
}
fn build_handoff_expectations_signal(
tracked_session: Option<&session_state::SessionStateFile>,
git: Option<&GitState>,
handoff: &HandoffState,
) -> BehavioralDriftSignal {
let Some(_session) = tracked_session else {
return no_signal(
"handoff_expectations",
"No active session telemetry is available, so handoff-expectation drift cannot be scoped to this conversation.",
Vec::new(),
);
};
let mut evidence = vec![format!("Handoff title: `{}`", handoff.title)];
if let Some(git) = git {
evidence.insert(0, format!("Branch: `{}`", git.branch));
}
if handoff.title != "No active session"
&& !handoff.immediate_actions.is_empty()
&& !handoff.definition_of_done.is_empty()
{
return aligned(
"handoff_expectations",
"The active session is anchored by a concrete handoff title, actions, and definition of done.",
evidence,
);
}
if handoff.title == "No active session" {
evidence.push("The handoff title still says `No active session`.".to_owned());
}
if handoff.immediate_actions.is_empty() {
evidence.push("`## Immediate Actions` is empty.".to_owned());
}
if handoff.definition_of_done.is_empty() {
evidence.push("`## Definition of Done` is empty.".to_owned());
}
drift(
"handoff_expectations",
"The active session is not anchored by a concrete next-session intent yet.",
evidence,
"Set a concrete next-session title and fill in Immediate Actions and Definition of Done before wrap-up.",
)
}
fn build_surface_order_signal() -> BehavioralDriftSignal {
let start_order = START_RENDER_SECTION_ORDER
.iter()
.map(|section| section.as_str())
.collect::<Vec<_>>();
let compiled_order = compiled_state::SESSION_RENDER_SURFACE_ORDER.to_vec();
let start_order_ok = start_order == CANONICAL_PROMPT_SURFACE_ORDER;
let compiled_order_ok = compiled_order == CANONICAL_COMPILED_SURFACE_ORDER;
let mut evidence = Vec::new();
evidence.push(format!("Kernel start order: {}", start_order.join(" -> ")));
evidence.push(format!(
"Kernel compiled order: {}",
compiled_order.join(" -> ")
));
if start_order_ok && compiled_order_ok {
return aligned(
"prefix_surface_order",
"Prompt surfaces remain ordered by stability, preserving cache-friendly prefix assembly.",
evidence,
);
}
if !start_order_ok {
evidence.push(format!(
"Expected kernel start order: {}",
CANONICAL_PROMPT_SURFACE_ORDER.join(" -> ")
));
}
if !compiled_order_ok {
evidence.push(format!(
"Expected kernel compiled order: {}",
CANONICAL_COMPILED_SURFACE_ORDER.join(" -> ")
));
}
drift(
"prefix_surface_order",
"Kernel prompt surface ordering drifted away from the canonical stability order.",
evidence,
"Keep kernel prompt surfaces ordered policy -> memory -> execution_gates -> handoff, and keep the kernel compiled session subset effective_memory -> handoff -> execution_gates -> escalation -> recovery -> git_state -> session_state.",
)
}
fn build_compiled_state_churn_signal(
layout: &StateLayout,
tracked_session: Option<&session_state::SessionStateFile>,
observation_load: &ProjectionObservationLoad,
) -> BehavioralDriftSignal {
let Some(_session) = tracked_session else {
return no_signal(
"compiled_state_churn",
"No active session telemetry is available, so derived runtime-view churn cannot be scoped to this conversation.",
Vec::new(),
);
};
let fingerprints = observation_load
.observations
.iter()
.map(|observation| observation.source_fingerprint.trim())
.filter(|fingerprint| !fingerprint.is_empty())
.collect::<Vec<_>>();
let distinct_fingerprints = distinct_count(&fingerprints);
if distinct_fingerprints == 0 {
return no_signal(
"compiled_state_churn",
"No derived runtime-view observations were recorded after this session started.",
observation_load.warnings.clone(),
);
}
let mut evidence = vec![
format!(
"{} distinct derived runtime-view fingerprint(s) observed in this session.",
distinct_fingerprints
),
format!(
"Observation log: {}",
layout.clone_projection_metadata_path().display()
),
];
evidence.extend(observation_load.warnings.iter().cloned());
if let Some(summary) = projection_surface_change_summary(&observation_load.observations) {
evidence.push(summary);
}
if distinct_fingerprints >= 3 {
return drift(
"compiled_state_churn",
"The derived session prefix changed repeatedly during one session, which breaks prompt-cache reuse.",
evidence,
"Batch memory, focus, and handoff edits when possible, or use lightweight delta updates instead of repeatedly rebuilding the full session prefix.",
);
}
aligned(
"compiled_state_churn",
"Compiled-state churn stayed within the expected session baseline.",
evidence,
)
}
fn build_tool_surface_signal(
tracked_session: Option<&session_state::SessionStateFile>,
observation_load: &ProjectionObservationLoad,
) -> BehavioralDriftSignal {
let Some(_session) = tracked_session else {
return no_signal(
"tool_surface_mutation",
"No active session telemetry is available, so tool-surface drift cannot be scoped to this conversation.",
Vec::new(),
);
};
let tool_fingerprints = observation_load
.observations
.iter()
.filter_map(|observation| observation.tool_surface_fingerprint.as_deref())
.map(str::trim)
.filter(|fingerprint| !fingerprint.is_empty())
.collect::<Vec<_>>();
let distinct_tool_fingerprints = distinct_count(&tool_fingerprints);
if distinct_tool_fingerprints == 0 {
return no_signal(
"tool_surface_mutation",
"No MCP tool-surface fingerprint was recorded for this session.",
observation_load.warnings.clone(),
);
}
let mut evidence = vec![format!(
"{} distinct tool-surface fingerprint(s) observed in this session.",
distinct_tool_fingerprints
)];
evidence.extend(observation_load.warnings.iter().cloned());
if distinct_tool_fingerprints >= 2 {
return drift(
"tool_surface_mutation",
"The available tool surface changed mid-session, which can invalidate cached prefixes.",
evidence,
"Register tool stubs up front and avoid adding or removing MCP tools mid-session when defer-loading can preserve prefix stability.",
);
}
aligned(
"tool_surface_mutation",
"The observed tool surface stayed stable during this session.",
evidence,
)
}
fn projection_observations_for_session(
layout: &StateLayout,
locality_id: &str,
runtime: &runtime_state::LoadedRuntimeState,
tracked_session: Option<&session_state::SessionStateFile>,
) -> ProjectionObservationLoad {
let Some(session) = tracked_session else {
return ProjectionObservationLoad {
observations: Vec::new(),
warnings: Vec::new(),
};
};
let mut warnings = Vec::new();
let mut observations = match projection_metadata::load_for_layout(layout) {
Ok(Some(metadata)) => metadata
.observations
.into_iter()
.filter(|observation| observation.observed_at_epoch_s >= session.started_at_epoch_s)
.collect::<Vec<_>>(),
Ok(None) => Vec::new(),
Err(error) => {
let warning = format!(
"Failed to load projection observations from {}: {error:#}",
layout.clone_projection_metadata_path().display()
);
eprintln!("Warning: {warning}");
warnings.push(warning);
Vec::new()
}
};
let current_store = match compiled_state::preview_for_target_with_cache(
layout,
runtime,
compiled_state::ProjectionTarget::Session,
) {
Ok(store) => Some(store.value),
Err(error) => {
let warning = format!(
"Failed to preview the current compiled session state for repo `{locality_id}`: {error:#}"
);
eprintln!("Warning: {warning}");
warnings.push(warning);
None
}
};
if let Some(store) = current_store {
let current = projection_metadata::ProjectionObservation {
observed_at_epoch_s: session_state::now_epoch_s()
.ok()
.unwrap_or(session.started_at_epoch_s),
source_fingerprint: store.source_fingerprint.clone(),
projection_digests: store
.projection_digests
.clone()
.or_else(|| Some(compiled_state::compute_projection_digests(&store))),
tool_surface_fingerprint: projection_metadata::current_tool_surface_fingerprint(),
session_id: None,
};
if observations.last().is_none_or(|last| {
last.source_fingerprint != current.source_fingerprint
|| last.tool_surface_fingerprint != current.tool_surface_fingerprint
}) {
observations.push(current);
}
}
ProjectionObservationLoad {
observations,
warnings,
}
}
fn projection_surface_change_summary(
observations: &[projection_metadata::ProjectionObservation],
) -> Option<String> {
let mut counts = BTreeMap::new();
for window in observations.windows(2) {
let [previous, current] = window else {
continue;
};
let (Some(previous), Some(current)) = (
previous.projection_digests.as_ref(),
current.projection_digests.as_ref(),
) else {
continue;
};
if previous.effective_memory != current.effective_memory {
*counts.entry("effective_memory").or_insert(0usize) += 1;
}
if previous.handoff != current.handoff {
*counts.entry("handoff").or_insert(0usize) += 1;
}
}
if counts.is_empty() {
return None;
}
Some(format!(
"Observed changed surfaces: {}",
counts
.into_iter()
.map(|(surface, count)| format!("{surface} x{count}"))
.collect::<Vec<_>>()
.join(", ")
))
}
fn distinct_count(values: &[&str]) -> usize {
values.iter().copied().collect::<BTreeSet<_>>().len()
}
fn aligned(
id: &'static str,
summary: impl Into<String>,
evidence: Vec<String>,
) -> BehavioralDriftSignal {
BehavioralDriftSignal {
id,
status: DriftSignalStatus::Aligned,
summary: summary.into(),
evidence,
recommended_correction: None,
}
}
fn no_signal(
id: &'static str,
summary: impl Into<String>,
evidence: Vec<String>,
) -> BehavioralDriftSignal {
BehavioralDriftSignal {
id,
status: DriftSignalStatus::NoSignal,
summary: summary.into(),
evidence,
recommended_correction: None,
}
}
fn drift(
id: &'static str,
summary: impl Into<String>,
evidence: Vec<String>,
recommended_correction: impl Into<String>,
) -> BehavioralDriftSignal {
BehavioralDriftSignal {
id,
status: DriftSignalStatus::Drift,
summary: summary.into(),
evidence,
recommended_correction: Some(recommended_correction.into()),
}
}
fn layout_handoff_sections() -> String {
format!(
"Required sections present: {}",
handoff::REQUIRED_SECTIONS
.iter()
.map(|section| section.trim_start_matches("## "))
.collect::<Vec<_>>()
.join(", ")
)
}