Files
1jehuang--jcode/crates/jcode-base/src/goal.rs
T
wehub-resource-sync a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:34 +08:00

715 lines
22 KiB
Rust

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<Self> {
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<String>,
pub title: String,
pub scope: GoalScope,
pub description: Option<String>,
pub why: Option<String>,
pub success_criteria: Vec<String>,
pub milestones: Vec<GoalMilestone>,
pub next_steps: Vec<String>,
pub blockers: Vec<String>,
pub current_milestone_id: Option<String>,
pub progress_percent: Option<u8>,
}
#[derive(Debug, Clone, Default)]
pub struct GoalUpdateInput {
pub title: Option<String>,
pub description: Option<String>,
pub why: Option<String>,
pub status: Option<GoalStatus>,
pub success_criteria: Option<Vec<String>>,
pub milestones: Option<Vec<GoalMilestone>>,
pub next_steps: Option<Vec<String>>,
pub blockers: Option<Vec<String>>,
pub current_milestone_id: Option<Option<String>>,
pub progress_percent: Option<Option<u8>>,
pub checkpoint_summary: Option<String>,
}
#[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<String>,
title: String,
attached_at: DateTime<Utc>,
}
#[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<Goal> {
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<GoalScope>,
working_dir: Option<&Path>,
update: GoalUpdateInput,
) -> Result<Option<Goal>> {
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<GoalScope>,
working_dir: Option<&Path>,
) -> Result<Option<Goal>> {
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<Vec<Goal>> {
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<Option<Goal>> {
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<Option<Goal>> {
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<crate::side_panel::SidePanelSnapshot> {
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<Option<crate::side_panel::SidePanelSnapshot>> {
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<Option<GoalDisplayResult>> {
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<Option<GoalDisplayResult>> {
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<crate::side_panel::SidePanelSnapshot> {
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<String> {
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 <id>` 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<bool> {
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<PathBuf> {
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<PathBuf> {
Ok(crate::storage::jcode_dir()?.join("goals").join("global"))
}
fn project_goals_dir(working_dir: Option<&Path>) -> Result<Option<PathBuf>> {
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<Vec<Goal>> {
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<PathBuf> {
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<String> {
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<String>) -> Vec<String> {
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<String> {
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<String> {
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;