use std::collections::BTreeSet;
use std::path::Path;
use crate::memory::{entries as memory_entries, promote as memory_promote};
use crate::state::session as session_state;
use super::behavioral_drift::{collect_session_commits, collect_session_touched_paths};
use super::{
active_session_start_count, CandidateAction, CandidateActionApply, CandidateUpdate,
CandidateUpdates, HandoffState, MemoryState,
};
pub(super) fn build_candidate_updates(
repo_root: &Path,
profile: &str,
handoff: &HandoffState,
effective_memory: &MemoryState,
tracked_session: Option<&session_state::SessionStateFile>,
branch_memory_available: bool,
handoff_candidate_refresh_enabled: bool,
) -> CandidateUpdates {
CandidateUpdates {
handoff: build_handoff_refresh_candidates(
repo_root,
handoff,
tracked_session,
handoff_candidate_refresh_enabled,
),
memory: build_memory_candidates(
repo_root,
profile,
effective_memory,
tracked_session,
branch_memory_available,
),
}
}
pub(super) fn build_handoff_refresh_candidates(
repo_root: &Path,
handoff: &HandoffState,
tracked_session: Option<&session_state::SessionStateFile>,
enabled: bool,
) -> Vec<CandidateUpdate> {
if !enabled {
return Vec::new();
}
let session_start = match tracked_session {
Some(session) if session.started_at_epoch_s > 0 => session.started_at_epoch_s,
_ => return Vec::new(),
};
if handoff.immediate_actions.is_empty() && handoff.key_files.is_empty() {
return Vec::new();
}
let commits = match collect_session_commits(repo_root, session_start) {
Ok(commits) => commits,
Err(_) => return Vec::new(),
};
if commits.is_empty() {
return Vec::new();
}
let mut out = Vec::new();
for (index, action) in handoff.immediate_actions.iter().enumerate() {
let tokens = distinctive_tokens(action);
if tokens.is_empty() {
continue;
}
let mut hits = Vec::new();
for commit in &commits {
let subject_lower = commit.subject.to_ascii_lowercase();
if tokens.iter().any(|token| subject_lower.contains(token)) {
hits.push(format!("{} ({})", commit.short_hash, commit.subject));
}
}
if !hits.is_empty() {
out.push(CandidateUpdate {
kind: "handoff_refresh",
summary: format!(
"Immediate action #{} looks completed by {} commit(s) in `session_start..HEAD`: {}",
index + 1,
hits.len(),
action,
),
section: Some("immediate_actions"),
replacement_lines: Some(hits),
entry_id: None,
source_scope: None,
suggested_destination: None,
action: None,
});
}
}
if !handoff.key_files.is_empty() {
if let Ok(touched) = collect_session_touched_paths(repo_root, session_start) {
if !touched.is_empty() {
let matched: Vec<String> = handoff
.key_files
.iter()
.filter(|key_file| key_file_matches_touched(key_file, &touched))
.cloned()
.collect();
if !matched.is_empty() {
out.push(CandidateUpdate {
kind: "handoff_refresh",
summary: format!(
"{} key-file entry(ies) intersect paths touched in `session_start..HEAD`; review whether their role has shifted.",
matched.len(),
),
section: Some("key_files"),
replacement_lines: Some(matched),
entry_id: None,
source_scope: None,
suggested_destination: None,
action: None,
});
}
}
}
}
out
}
fn distinctive_tokens(action: &str) -> Vec<String> {
const STOP: &[&str] = &[
"the",
"and",
"for",
"with",
"from",
"into",
"onto",
"that",
"this",
"when",
"then",
"open",
"run",
"add",
"update",
"implement",
"ensure",
"cover",
"branch",
"commit",
"pull",
"push",
"merge",
"review",
"write",
"trace",
"build",
"refresh",
"session",
"handoff",
"candidate",
"config",
"flag",
"radar",
"ccd",
"main",
"origin",
"pr",
];
let lower = action.to_ascii_lowercase();
let mut tokens: Vec<String> = lower
.split(|c: char| {
!(c.is_ascii_alphanumeric() || c == '#' || c == '_' || c == '/' || c == '.' || c == '-')
})
.filter(|token| !token.is_empty())
.filter(|token| {
if token.starts_with('#')
|| token.contains('#')
|| token.contains('/')
|| token.contains('.')
{
return true;
}
if token.len() < 5 {
return false;
}
!STOP.contains(&token.as_ref())
})
.map(str::to_owned)
.collect();
tokens.sort();
tokens.dedup();
tokens
}
fn key_file_matches_touched(key_file: &str, touched: &BTreeSet<String>) -> bool {
let trimmed = key_file.trim();
if trimmed.is_empty() {
return false;
}
if touched.contains(trimmed) {
return true;
}
touched
.iter()
.any(|path| path == trimmed || trimmed.contains(path.as_str()))
}
#[derive(Clone, Copy)]
enum MemoryCandidateScope {
Clone,
Branch,
}
impl MemoryCandidateScope {
fn label(self) -> &'static str {
match self {
Self::Clone => "workspace",
Self::Branch => "work_stream",
}
}
fn source_scope(self) -> &'static str {
match self {
Self::Clone => "workspace-memory",
Self::Branch => "work-stream-memory",
}
}
fn suggested_destination(self, branch_memory_available: bool) -> &'static str {
match self {
Self::Clone if branch_memory_available => "work-stream-memory",
Self::Clone | Self::Branch => "project-memory",
}
}
fn promotion_review_summary(self, entry: &memory_entries::StructuredMemoryEntry) -> String {
match self {
Self::Clone => format!(
"{} memory entry `{}` was touched this session; review whether it should stay workspace-local or be promoted to a higher memory scope.",
self.label(),
entry.id
),
Self::Branch => format!(
"{} memory entry `{}` was touched this session; review whether it should move into project memory.",
self.label(),
entry.id
),
}
}
}
fn build_memory_candidates(
repo_root: &Path,
profile: &str,
effective_memory: &MemoryState,
tracked_session: Option<&session_state::SessionStateFile>,
branch_memory_available: bool,
) -> Vec<CandidateUpdate> {
let Some(current_session_start_count) = active_session_start_count(tracked_session) else {
return Vec::new();
};
let branch_entry_ids = effective_memory
.structured
.branch_entries
.iter()
.map(|entry| entry.id.clone())
.collect::<BTreeSet<_>>();
let repo_entry_ids = effective_memory
.structured
.repo_entries
.iter()
.map(|entry| entry.id.clone())
.collect::<BTreeSet<_>>();
let context = MemoryCandidateContext {
repo_root,
profile,
branch_entry_ids: &branch_entry_ids,
repo_entry_ids: &repo_entry_ids,
current_session_start_count,
branch_memory_available,
};
let mut memory = Vec::new();
extend_memory_candidates(
&mut memory,
&effective_memory.structured.clone_entries,
MemoryCandidateScope::Clone,
&context,
);
extend_memory_candidates(
&mut memory,
&effective_memory.structured.branch_entries,
MemoryCandidateScope::Branch,
&context,
);
memory
}
struct MemoryCandidateContext<'a> {
repo_root: &'a Path,
profile: &'a str,
branch_entry_ids: &'a BTreeSet<String>,
repo_entry_ids: &'a BTreeSet<String>,
current_session_start_count: u64,
branch_memory_available: bool,
}
fn extend_memory_candidates(
candidates: &mut Vec<CandidateUpdate>,
entries: &[memory_entries::StructuredMemoryEntry],
scope: MemoryCandidateScope,
context: &MemoryCandidateContext<'_>,
) {
candidates.extend(
entries
.iter()
.filter_map(|entry| memory_candidate_from_entry(entry, scope, context)),
);
}
fn memory_candidate_from_entry(
entry: &memory_entries::StructuredMemoryEntry,
scope: MemoryCandidateScope,
context: &MemoryCandidateContext<'_>,
) -> Option<CandidateUpdate> {
if entry.state != "active"
|| entry.last_touched_session != context.current_session_start_count
|| entry.source_ref.is_some()
|| !memory_promote::is_promotable_entry_type(&entry.entry_type)
{
return None;
}
let suggested_destination = scope.suggested_destination(context.branch_memory_available);
if candidate_already_exists(
&entry.id,
suggested_destination,
context.branch_entry_ids,
context.repo_entry_ids,
) {
return None;
}
Some(CandidateUpdate {
kind: "promotion_review",
summary: scope.promotion_review_summary(entry),
section: None,
replacement_lines: None,
entry_id: Some(entry.id.clone()),
source_scope: Some(scope.source_scope()),
suggested_destination: Some(suggested_destination),
action: Some(memory_candidate_admit_preview_action(
context.repo_root,
context.profile,
entry.id.as_str(),
scope,
suggested_destination,
)),
})
}
fn candidate_already_exists(
entry_id: &str,
suggested_destination: &'static str,
branch_entry_ids: &BTreeSet<String>,
repo_entry_ids: &BTreeSet<String>,
) -> bool {
match suggested_destination {
"work-stream-memory" => {
branch_entry_ids.contains(entry_id) || repo_entry_ids.contains(entry_id)
}
"project-memory" => repo_entry_ids.contains(entry_id),
_ => false,
}
}
fn memory_candidate_admit_preview_action(
repo_root: &Path,
profile: &str,
entry_id: &str,
scope: MemoryCandidateScope,
suggested_destination: &'static str,
) -> CandidateAction {
CandidateAction {
kind: "memory_candidate_admit_preview",
argv: vec![
"ccd".to_owned(),
"memory".to_owned(),
"candidate".to_owned(),
"admit".to_owned(),
"--path".to_owned(),
repo_root.display().to_string(),
"--profile".to_owned(),
profile.to_owned(),
"--entry".to_owned(),
entry_id.to_owned(),
"--source-scope".to_owned(),
scope.source_scope().to_owned(),
"--destination".to_owned(),
suggested_destination.to_owned(),
],
note: "Preview only. Add `--write` to stage this reviewed candidate as a higher-scope `promotion_candidate` entry.",
apply: Some(memory_candidate_admit_apply_action(
repo_root,
profile,
entry_id,
scope,
suggested_destination,
)),
}
}
fn memory_candidate_admit_apply_action(
repo_root: &Path,
profile: &str,
entry_id: &str,
scope: MemoryCandidateScope,
suggested_destination: &'static str,
) -> CandidateActionApply {
CandidateActionApply {
argv: vec![
"ccd".to_owned(),
"memory".to_owned(),
"candidate".to_owned(),
"admit".to_owned(),
"--path".to_owned(),
repo_root.display().to_string(),
"--profile".to_owned(),
profile.to_owned(),
"--entry".to_owned(),
entry_id.to_owned(),
"--source-scope".to_owned(),
scope.source_scope().to_owned(),
"--destination".to_owned(),
suggested_destination.to_owned(),
"--write".to_owned(),
],
requirements: Vec::new(),
note: "Mutates memory surfaces. Execute this apply command only after reviewing the staged candidate.",
}
}