use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; pub use jcode_task_types::{Goal, GoalMilestone, GoalScope, GoalStatus, GoalStep, GoalUpdate}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GoalDisplayMode { Auto, Focus, UpdateOnly, None, } impl GoalDisplayMode { pub fn parse(value: &str) -> Option { match value.trim().to_ascii_lowercase().as_str() { "auto" => Some(Self::Auto), "focus" => Some(Self::Focus), "update_only" => Some(Self::UpdateOnly), "none" => Some(Self::None), _ => None, } } } #[derive(Debug, Clone, Default)] pub struct GoalCreateInput { pub id: Option, pub title: String, pub scope: GoalScope, pub description: Option, pub why: Option, pub success_criteria: Vec, pub milestones: Vec, pub next_steps: Vec, pub blockers: Vec, pub current_milestone_id: Option, pub progress_percent: Option, } #[derive(Debug, Clone, Default)] pub struct GoalUpdateInput { pub title: Option, pub description: Option, pub why: Option, pub status: Option, pub success_criteria: Option>, pub milestones: Option>, pub next_steps: Option>, pub blockers: Option>, pub current_milestone_id: Option>, pub progress_percent: Option>, pub checkpoint_summary: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] struct GoalAttachment { goal_id: String, scope: GoalScope, #[serde(default, skip_serializing_if = "Option::is_none")] project_hash: Option, title: String, attached_at: DateTime, } #[derive(Debug, Clone)] pub struct GoalDisplayResult { pub goal: Goal, pub snapshot: crate::side_panel::SidePanelSnapshot, } pub fn create_goal(input: GoalCreateInput, working_dir: Option<&Path>) -> Result { if input.title.trim().is_empty() { anyhow::bail!("goal title cannot be empty"); } let mut goal = Goal::new(&input.title, input.scope); if let Some(id) = input.id.as_deref().map(str::trim).filter(|s| !s.is_empty()) { goal.id = jcode_task_types::sanitize_goal_id(id); } goal.id = next_available_goal_id(&goal.id, goal.scope, working_dir)?; goal.description = input.description.unwrap_or_default().trim().to_string(); goal.why = input.why.unwrap_or_default().trim().to_string(); goal.success_criteria = trim_vec(input.success_criteria); goal.milestones = input.milestones; goal.next_steps = trim_vec(input.next_steps); goal.blockers = trim_vec(input.blockers); goal.current_milestone_id = input.current_milestone_id; goal.progress_percent = input.progress_percent.map(|p| p.min(100)); goal.updated_at = Utc::now(); save_goal(&goal, working_dir)?; sync_goal_memory(&goal, working_dir)?; Ok(goal) } pub fn update_goal( id: &str, scope_hint: Option, working_dir: Option<&Path>, update: GoalUpdateInput, ) -> Result> { let Some(mut goal) = load_goal(id, scope_hint, working_dir)? else { return Ok(None); }; if let Some(title) = update .title .as_deref() .map(str::trim) .filter(|s| !s.is_empty()) { goal.title = title.to_string(); } if let Some(description) = update.description { goal.description = description.trim().to_string(); } if let Some(why) = update.why { goal.why = why.trim().to_string(); } if let Some(status) = update.status { goal.status = status; } if let Some(criteria) = update.success_criteria { goal.success_criteria = trim_vec(criteria); } if let Some(milestones) = update.milestones { goal.milestones = milestones; } if let Some(next_steps) = update.next_steps { goal.next_steps = trim_vec(next_steps); } if let Some(blockers) = update.blockers { goal.blockers = trim_vec(blockers); } if let Some(current_milestone_id) = update.current_milestone_id { goal.current_milestone_id = current_milestone_id.map(|s| s.trim().to_string()); } if let Some(progress_percent) = update.progress_percent { goal.progress_percent = progress_percent.map(|p| p.min(100)); } if let Some(summary) = update .checkpoint_summary .as_deref() .map(str::trim) .filter(|s| !s.is_empty()) { goal.updates.push(GoalUpdate { at: Utc::now(), summary: summary.to_string(), }); } goal.updated_at = Utc::now(); save_goal(&goal, working_dir)?; sync_goal_memory(&goal, working_dir)?; Ok(Some(goal)) } pub fn load_goal( id: &str, scope_hint: Option, working_dir: Option<&Path>, ) -> Result> { let id = jcode_task_types::sanitize_goal_id(id); let mut candidates = Vec::new(); match scope_hint { Some(GoalScope::Global) => candidates.push(goal_file_in_dir(&global_goals_dir()?, &id)), Some(GoalScope::Project) => { if let Some(dir) = project_goals_dir(working_dir)? { candidates.push(goal_file_in_dir(&dir, &id)); } } None => { if let Some(dir) = project_goals_dir(working_dir)? { candidates.push(goal_file_in_dir(&dir, &id)); } candidates.push(goal_file_in_dir(&global_goals_dir()?, &id)); } } for path in candidates { if path.exists() { let goal: Goal = crate::storage::read_json(&path) .with_context(|| format!("failed to read goal {}", path.display()))?; return Ok(Some(goal)); } } Ok(None) } pub fn list_relevant_goals(working_dir: Option<&Path>) -> Result> { let mut goals = load_goals_in_dir(&global_goals_dir()?)?; if let Some(project_dir) = project_goals_dir(working_dir)? { goals.extend(load_goals_in_dir(&project_dir)?); } sort_goals(&mut goals); Ok(goals) } pub fn resume_goal(session_id: &str, working_dir: Option<&Path>) -> Result> { if let Some(goal) = load_attached_goal(session_id, working_dir)? && goal.status.is_resumable() { return Ok(Some(goal)); } let mut goals = list_relevant_goals(working_dir)?; goals.retain(|goal| goal.status.is_resumable()); Ok(goals.into_iter().next()) } pub fn attach_goal_to_session( session_id: &str, goal: &Goal, working_dir: Option<&Path>, ) -> Result<()> { let attachment = GoalAttachment { goal_id: goal.id.clone(), scope: goal.scope, project_hash: if goal.scope == GoalScope::Project { Some(project_hash(working_dir.ok_or_else(|| { anyhow::anyhow!("working_dir required for project goal") })?)) } else { None }, title: goal.title.clone(), attached_at: Utc::now(), }; let path = session_attachment_path(session_id)?; crate::storage::write_json_fast(&path, &attachment) } pub fn load_attached_goal(session_id: &str, working_dir: Option<&Path>) -> Result> { let path = session_attachment_path(session_id)?; if !path.exists() { return Ok(None); } let attachment: GoalAttachment = crate::storage::read_json(&path)?; if attachment.scope == GoalScope::Project { let Some(dir) = working_dir else { return Ok(None); }; let current_hash = project_hash(dir); if attachment.project_hash.as_deref() != Some(current_hash.as_str()) { return Ok(None); } } load_goal(&attachment.goal_id, Some(attachment.scope), working_dir) } pub fn open_goals_overview_for_session( session_id: &str, working_dir: Option<&Path>, focus: bool, ) -> Result { let goals = list_relevant_goals(working_dir)?; crate::side_panel::write_markdown_page( session_id, "goals", Some("Goals"), &render_goals_overview(&goals), focus, ) } pub fn refresh_goals_overview_for_session( session_id: &str, working_dir: Option<&Path>, ) -> Result> { let snapshot = crate::side_panel::snapshot_for_session(session_id)?; if !snapshot.pages.iter().any(|page| page.id == "goals") { return Ok(None); } let focus = snapshot.focused_page_id.as_deref() == Some("goals"); Ok(Some(open_goals_overview_for_session( session_id, working_dir, focus, )?)) } pub fn open_goal_for_session( session_id: &str, working_dir: Option<&Path>, id: &str, explicit_focus: bool, ) -> Result> { let Some(goal) = load_goal(id, None, working_dir)? else { return Ok(None); }; let snapshot = write_goal_page( session_id, working_dir, &goal, if explicit_focus { GoalDisplayMode::Focus } else { GoalDisplayMode::Auto }, )?; Ok(Some(GoalDisplayResult { goal, snapshot })) } pub fn resume_goal_for_session( session_id: &str, working_dir: Option<&Path>, explicit_focus: bool, ) -> Result> { let Some(goal) = resume_goal(session_id, working_dir)? else { return Ok(None); }; let snapshot = write_goal_page( session_id, working_dir, &goal, if explicit_focus { GoalDisplayMode::Focus } else { GoalDisplayMode::Auto }, )?; Ok(Some(GoalDisplayResult { goal, snapshot })) } pub fn write_goal_page( session_id: &str, working_dir: Option<&Path>, goal: &Goal, display: GoalDisplayMode, ) -> Result { let page_id = goal_page_id(&goal.id); let page_title = format!("Goal: {}", goal.title); let focus = match display { GoalDisplayMode::None => false, GoalDisplayMode::Focus => true, GoalDisplayMode::UpdateOnly => false, GoalDisplayMode::Auto => should_focus_goal_page(session_id, &page_id)?, }; let snapshot = crate::side_panel::write_markdown_page( session_id, &page_id, Some(&page_title), &render_goal_detail(goal), focus, )?; attach_goal_to_session(session_id, goal, working_dir)?; Ok(snapshot) } pub fn goal_page_id(id: &str) -> String { format!("goal.{}", jcode_task_types::sanitize_goal_id(id)) } pub fn header_badge( working_dir: Option<&Path>, snapshot: &crate::side_panel::SidePanelSnapshot, ) -> Option { if let Some(page) = snapshot.focused_page() && page.id.starts_with("goal.") { return Some(format!("🎯 {}*", truncate_title(&page.title, 28))); } let goals = list_relevant_goals(working_dir).ok()?; let active: Vec<_> = goals .into_iter() .filter(|goal| { matches!( goal.status, GoalStatus::Active | GoalStatus::Blocked | GoalStatus::Draft ) }) .collect(); match active.as_slice() { [] => None, [goal] => Some(format!("🎯 {}", truncate_title(&goal.title, 28))), many => Some(format!("🎯 {} active", many.len())), } } pub fn render_goals_overview(goals: &[Goal]) -> String { let mut out = String::from("# Goals\n\n"); if goals.is_empty() { out.push_str( "No goals yet. Use the `goal` tool or `/goals show ` after creating one.\n", ); return out; } for goal in goals { out.push_str(&format!( "## {}\n- Status: {}\n- Scope: {}\n", goal.title, goal.status.as_str(), goal.scope.as_str() )); if let Some(progress) = goal.progress_percent { out.push_str(&format!("- Progress: {}%\n", progress)); } if let Some(milestone) = goal.current_milestone() { out.push_str(&format!("- Current milestone: {}\n", milestone.title)); } if let Some(next_step) = goal.next_steps.first() { out.push_str(&format!("- Next step: {}\n", next_step)); } out.push_str(&format!("- Id: `{}`\n\n", goal.id)); } out } pub fn render_goal_detail(goal: &Goal) -> String { let mut out = format!( "# Goal: {}\n\n**Status:** {} \n**Scope:** {} \n**Updated:** {} \n", goal.title, goal.status.as_str(), goal.scope.as_str(), goal.updated_at.format("%Y-%m-%d %H:%M") ); if let Some(progress) = goal.progress_percent { out.push_str(&format!("**Progress:** {}% \n", progress)); } out.push('\n'); if !goal.description.trim().is_empty() { out.push_str("## Description\n"); out.push_str(goal.description.trim()); out.push_str("\n\n"); } if !goal.why.trim().is_empty() { out.push_str("## Why\n"); out.push_str(goal.why.trim()); out.push_str("\n\n"); } if !goal.success_criteria.is_empty() { out.push_str("## Success criteria\n"); for item in &goal.success_criteria { out.push_str(&format!("- {}\n", item)); } out.push('\n'); } if let Some(milestone) = goal.current_milestone() { out.push_str(&format!("## Current milestone\n### {}\n", milestone.title)); if milestone.steps.is_empty() { out.push_str(&format!("- Status: {}\n\n", milestone.status)); } else { for step in &milestone.steps { let checked = if step.status == "completed" { "x" } else { " " }; out.push_str(&format!("- [{}] {}\n", checked, step.content)); } out.push('\n'); } } if !goal.milestones.is_empty() { out.push_str("## Milestones\n"); for milestone in &goal.milestones { let marker = if goal.current_milestone_id.as_deref() == Some(milestone.id.as_str()) { "→" } else { "-" }; out.push_str(&format!( "{} {} ({})\n", marker, milestone.title, milestone.status )); } out.push('\n'); } if !goal.next_steps.is_empty() { out.push_str("## Next steps\n"); for (idx, step) in goal.next_steps.iter().enumerate() { out.push_str(&format!("{}. {}\n", idx + 1, step)); } out.push('\n'); } if !goal.blockers.is_empty() { out.push_str("## Blockers\n"); for blocker in &goal.blockers { out.push_str(&format!("- {}\n", blocker)); } out.push('\n'); } if !goal.updates.is_empty() { out.push_str("## Recent updates\n"); for update in goal.updates.iter().rev().take(8) { out.push_str(&format!( "- {}: {}\n", update.at.format("%Y-%m-%d"), update.summary )); } } out } fn should_focus_goal_page(session_id: &str, page_id: &str) -> Result { let snapshot = crate::side_panel::snapshot_for_session(session_id)?; let has_goal_page = snapshot .pages .iter() .any(|page| page.id == "goals" || page.id.starts_with("goal.")); let focused = snapshot.focused_page_id.as_deref(); Ok(!has_goal_page || focused == Some(page_id) || focused == Some("goals")) } fn save_goal(goal: &Goal, working_dir: Option<&Path>) -> Result<()> { let path = goal_file(goal, working_dir)?; crate::storage::write_json_fast(&path, goal) } fn goal_file(goal: &Goal, working_dir: Option<&Path>) -> Result { let dir = match goal.scope { GoalScope::Global => global_goals_dir()?, GoalScope::Project => project_goals_dir(working_dir)? .ok_or_else(|| anyhow::anyhow!("working_dir required for project goal"))?, }; Ok(goal_file_in_dir(&dir, &goal.id)) } fn goal_file_in_dir(dir: &Path, id: &str) -> PathBuf { dir.join(format!("{}.json", jcode_task_types::sanitize_goal_id(id))) } fn global_goals_dir() -> Result { Ok(crate::storage::jcode_dir()?.join("goals").join("global")) } fn project_goals_dir(working_dir: Option<&Path>) -> Result> { let Some(dir) = working_dir else { return Ok(None); }; Ok(Some( crate::storage::jcode_dir()? .join("goals") .join("projects") .join(project_hash(dir)), )) } fn load_goals_in_dir(dir: &Path) -> Result> { if !dir.exists() { return Ok(Vec::new()); } let mut goals = Vec::new(); for entry in std::fs::read_dir(dir)? { let entry = entry?; let path = entry.path(); if path.extension().and_then(|ext| ext.to_str()) != Some("json") { continue; } let goal: Goal = crate::storage::read_json(&path) .with_context(|| format!("failed to read goal {}", path.display()))?; goals.push(goal); } sort_goals(&mut goals); Ok(goals) } fn sort_goals(goals: &mut [Goal]) { goals.sort_by(|a, b| { a.status .sort_rank() .cmp(&b.status.sort_rank()) .then_with(|| b.updated_at.cmp(&a.updated_at)) .then_with(|| a.title.cmp(&b.title)) }); } fn project_hash(path: &Path) -> String { use std::collections::hash_map::DefaultHasher; let mut hasher = DefaultHasher::new(); path.hash(&mut hasher); format!("{:016x}", hasher.finish()) } fn session_attachment_path(session_id: &str) -> Result { Ok(crate::storage::jcode_dir()? .join("goals") .join("sessions") .join(format!("{}.json", session_id))) } fn next_available_goal_id( base: &str, scope: GoalScope, working_dir: Option<&Path>, ) -> Result { let mut candidate = jcode_task_types::sanitize_goal_id(base); let mut idx = 2; while load_goal(&candidate, Some(scope), working_dir)?.is_some() { candidate = format!("{}-{}", jcode_task_types::sanitize_goal_id(base), idx); idx += 1; } Ok(candidate) } fn trim_vec(values: Vec) -> Vec { values .into_iter() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .collect() } fn truncate_title(title: &str, max_chars: usize) -> String { let raw = title.trim_start_matches("Goal: ").trim(); let char_count = raw.chars().count(); if char_count <= max_chars { raw.to_string() } else if max_chars <= 1 { "…".to_string() } else { let clipped: String = raw.chars().take(max_chars - 1).collect(); format!("{}…", clipped) } } fn sync_goal_memory(goal: &Goal, working_dir: Option<&Path>) -> Result { use crate::memory::{MemoryCategory, MemoryEntry, MemoryManager, TrustLevel}; let manager = match goal.scope { GoalScope::Project => { MemoryManager::new().with_project_dir(working_dir.ok_or_else(|| { anyhow::anyhow!("working_dir required for project goal memory sync") })?) } GoalScope::Global => MemoryManager::new(), }; let mut entry = MemoryEntry::new( MemoryCategory::Custom("goal".to_string()), goal_memory_content(goal), ) .with_source(format!("goal:{}", goal.id)) .with_trust(TrustLevel::High) .with_tags(goal_memory_tags(goal)); entry.id = goal_memory_id(goal); entry.updated_at = goal.updated_at; entry.created_at = goal.created_at; match goal.scope { GoalScope::Project => manager.upsert_project_memory(entry), GoalScope::Global => manager.upsert_global_memory(entry), } } fn goal_memory_id(goal: &Goal) -> String { format!("goal:{}", goal.id) } fn goal_memory_tags(goal: &Goal) -> Vec { let mut tags = vec![ "goal".to_string(), format!("goal:{}", goal.id), format!("goal_status:{}", goal.status.as_str()), format!("goal_scope:{}", goal.scope.as_str()), ]; if let Some(current) = goal.current_milestone_id.as_deref() { tags.push(format!("goal_milestone:{}", current)); } if !goal.title.trim().is_empty() { tags.extend( goal.title .split(|ch: char| !ch.is_ascii_alphanumeric()) .map(|part| part.trim().to_ascii_lowercase()) .filter(|part| part.len() >= 4) .take(4) .map(|part| format!("goal_term:{}", part)), ); } tags.sort(); tags.dedup(); tags } fn goal_memory_content(goal: &Goal) -> String { let mut out = format!( "Goal: {}\nStatus: {}\nScope: {}", goal.title, goal.status.as_str(), goal.scope.as_str() ); if let Some(progress) = goal.progress_percent { out.push_str(&format!("\nProgress: {}%", progress)); } if let Some(milestone) = goal.current_milestone() { out.push_str(&format!("\nCurrent milestone: {}", milestone.title)); } if !goal.description.trim().is_empty() { out.push_str(&format!("\nDescription: {}", goal.description.trim())); } if !goal.why.trim().is_empty() { out.push_str(&format!("\nWhy: {}", goal.why.trim())); } if !goal.next_steps.is_empty() { out.push_str("\nNext steps:"); for step in goal.next_steps.iter().take(3) { out.push_str(&format!("\n- {}", step)); } } if !goal.blockers.is_empty() { out.push_str("\nBlockers:"); for blocker in goal.blockers.iter().take(3) { out.push_str(&format!("\n- {}", blocker)); } } out } #[cfg(test)] #[path = "goal_tests.rs"] mod goal_tests;