diff --git a/src-tauri/src/commands/mentor.rs b/src-tauri/src/commands/mentor.rs index b39914c..3e925fc 100644 --- a/src-tauri/src/commands/mentor.rs +++ b/src-tauri/src/commands/mentor.rs @@ -11,6 +11,8 @@ pub struct ProjectHealth { pub has_git: bool, /// git log --oneline -1 输出 pub last_commit: Option, + /// 最近 10 条提交(hash + 日期 + message) + pub recent_commits: Vec, /// 项目记录的技术栈 pub tech_stack: Option, } @@ -35,12 +37,23 @@ pub struct RuntimeLogEntry { pub message: String, } +#[derive(Debug, Serialize)] +pub struct ActiveTaskCard { + pub module_name: String, + pub title: String, + pub status: String, + pub files: Option, + pub acceptance: Option, + pub blocked_reason: Option, +} + #[derive(Debug, Serialize)] pub struct MentorContext { pub project_id: String, pub project_name: String, pub health: ProjectHealth, pub blueprint_summary: Option, + pub active_tasks: Vec, pub recent_errors: Vec, /// 格式化好的 Markdown,可直接复制给 Claude pub mentor_markdown: String, @@ -96,22 +109,24 @@ pub fn get_mentor_context(project_id: String) -> Result { }; // 3. 本地健康检查 - let (path_valid, has_git, last_commit) = match (is_wsl, &wsl_linux_path, &wsl_distro) { + let (path_valid, has_git, last_commit, recent_commits) = match (is_wsl, &wsl_linux_path, &wsl_distro) { (true, Some(linux_path), Some(distro)) => { let pv = wsl_test_dir(distro, linux_path); let hg = pv && wsl_test_dir(distro, &format!("{}/.git", linux_path)); let lc = if hg { wsl_git_log(distro, linux_path) } else { None }; - (pv, hg, lc) + let rc = if hg { wsl_git_log_recent(distro, linux_path) } else { vec![] }; + (pv, hg, lc, rc) } - (true, _, _) => (false, false, None), + (true, _, _) => (false, false, None, vec![]), (false, _, _) => match win_path.as_deref().filter(|p| !p.is_empty()).map(Path::new) { Some(root) => { let pv = root.exists(); let hg = root.join(".git").exists(); let lc = if hg { win_git_log(root) } else { None }; - (pv, hg, lc) + let rc = if hg { win_git_log_recent(root) } else { vec![] }; + (pv, hg, lc, rc) } - None => (false, false, None), + None => (false, false, None, vec![]), }, }; @@ -119,6 +134,7 @@ pub fn get_mentor_context(project_id: String) -> Result { path_valid, has_git, last_commit, + recent_commits, tech_stack, }; @@ -133,16 +149,35 @@ pub fn get_mentor_context(project_id: String) -> Result { } }; + // 4b. 进行中 / 阻塞任务卡详情 + let active_tasks = match (is_wsl, &wsl_linux_path, &wsl_distro) { + (true, Some(linux_path), Some(distro)) => { + let unc_root = linux_to_unc(linux_path, distro); + read_active_task_cards(&unc_root) + } + (true, _, _) => vec![], + (false, _, _) => { + win_path.as_deref().filter(|p| !p.is_empty()).map(Path::new) + .map(read_active_task_cards) + .unwrap_or_default() + } + }; + // 5. 近期错误日志 let recent_errors = read_recent_errors(&conn, &project_id); + // 5b. AI 笔记(最近 5 条,作为跨会话记忆) + let ai_notes = read_ai_notes(&conn, &project_id); + // 6. 生成 mentor_markdown let mentor_markdown = build_mentor_markdown( &project_id, &project_name, &health, &blueprint_summary, + &active_tasks, &recent_errors, + &ai_notes, ); Ok(MentorContext { @@ -150,6 +185,7 @@ pub fn get_mentor_context(project_id: String) -> Result { project_name, health, blueprint_summary, + active_tasks, recent_errors, mentor_markdown, }) @@ -277,6 +313,41 @@ fn win_git_log(root: &Path) -> Option { .filter(|s| !s.is_empty()) } +/// 最近 10 条提交,格式:`abc1234 2026-04-08 fix: something` +fn win_git_log_recent(root: &Path) -> Vec { + let output = crate::silent_cmd("git") + .args(["log", "--pretty=format:%h %ad %s", "--date=short", "-10"]) + .current_dir(root) + .output(); + match output { + Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout) + .lines() + .filter(|l| !l.is_empty()) + .map(|l| l.to_string()) + .collect(), + _ => vec![], + } +} + +/// WSL 版最近 10 条提交 +fn wsl_git_log_recent(distro: &str, linux_path: &str) -> Vec { + let output = crate::silent_cmd("wsl") + .args([ + "-d", distro, "--", "git", "-C", linux_path, + "log", "--pretty=format:%h %ad %s", "--date=short", "-10", + ]) + .stderr(std::process::Stdio::null()) + .output(); + match output { + Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout) + .lines() + .filter(|l| !l.is_empty()) + .map(|l| l.to_string()) + .collect(), + _ => vec![], + } +} + // ── 蓝图摘要 ────────────────────────────────────────────────────────────────── /// WSL 项目:用 wsl 命令判断 manifest 存在性,再通过 UNC 路径读取内容 @@ -335,6 +406,31 @@ fn parse_blueprint_summary(root: &Path) -> Option { } } +/// 从项目根路径读取蓝图,返回所有进行中/阻塞的任务卡 +fn read_active_task_cards(root: &Path) -> Vec { + match crate::commands::blueprint::get_blueprint(root.to_string_lossy().to_string()) { + Ok(Some(data)) => data + .manifest + .modules + .into_iter() + .flat_map(|m| { + let module_name = m.name.clone(); + m.tasks.into_iter() + .filter(|t| t.status == "in_progress" || t.status == "blocked") + .map(move |t| ActiveTaskCard { + module_name: module_name.clone(), + title: t.title, + status: t.status, + files: t.files, + acceptance: t.acceptance, + blocked_reason: t.blocked_reason, + }) + }) + .collect(), + _ => vec![], + } +} + // ── 错误日志 ────────────────────────────────────────────────────────────────── fn read_recent_errors( @@ -362,6 +458,29 @@ fn read_recent_errors( result.unwrap_or_default() } +fn read_ai_notes(conn: &rusqlite::Connection, project_id: &str) -> Vec { + let mut stmt = match conn.prepare( + "SELECT id, project_id, source, content, created_at + FROM project_notes + WHERE project_id = ?1 + ORDER BY id DESC LIMIT 5", + ) { + Ok(s) => s, + Err(_) => return vec![], + }; + stmt.query_map(params![project_id], |row| { + Ok(ProjectNote { + id: row.get(0)?, + project_id: row.get(1)?, + source: row.get(2)?, + content: row.get(3)?, + created_at: row.get(4)?, + }) + }) + .map(|iter| iter.filter_map(|r| r.ok()).collect()) + .unwrap_or_default() +} + // ── Markdown 生成 ───────────────────────────────────────────────────────────── fn build_mentor_markdown( @@ -369,7 +488,9 @@ fn build_mentor_markdown( project_name: &str, health: &ProjectHealth, blueprint: &Option, + active_tasks: &[ActiveTaskCard], errors: &[RuntimeLogEntry], + ai_notes: &[ProjectNote], ) -> String { let mut md = String::new(); @@ -389,6 +510,16 @@ fn build_mentor_markdown( md.push_str(&format!("- 最近提交:`{}`\n", commit)); } + // ── 近期提交历史 ────────────────────────────────────────────────────────── + md.push_str("\n## 近期提交(最近 10 条)\n\n"); + if health.recent_commits.is_empty() { + md.push_str("暂无提交记录。\n"); + } else { + for c in &health.recent_commits { + md.push_str(&format!("- `{}`\n", c)); + } + } + // ── 蓝图状态 ────────────────────────────────────────────────────────────── md.push_str("\n## 蓝图状态\n\n"); if let Some(ref bp) = blueprint { @@ -409,6 +540,30 @@ fn build_mentor_markdown( md.push_str("该项目尚未接入蓝图(无 .blueprint/manifest.yaml)。\n"); } + // ── 进行中 / 阻塞任务卡 ─────────────────────────────────────────────────── + if !active_tasks.is_empty() { + let in_progress: Vec<_> = active_tasks.iter().filter(|t| t.status == "in_progress").collect(); + let blocked: Vec<_> = active_tasks.iter().filter(|t| t.status == "blocked").collect(); + + if !in_progress.is_empty() { + md.push_str("\n## 进行中任务\n\n"); + for t in &in_progress { + md.push_str(&format!("### [{}] {}\n", t.module_name, t.title)); + if let Some(ref f) = t.files { md.push_str(&format!("- files: `{}`\n", f)); } + if let Some(ref a) = t.acceptance { md.push_str(&format!("- acceptance: {}\n", a)); } + } + } + + if !blocked.is_empty() { + md.push_str("\n## 阻塞任务\n\n"); + for t in &blocked { + md.push_str(&format!("### [{}] {}\n", t.module_name, t.title)); + if let Some(ref r) = t.blocked_reason { md.push_str(&format!("- blocked_reason: {}\n", r)); } + if let Some(ref f) = t.files { md.push_str(&format!("- files: `{}`\n", f)); } + } + } + } + // ── 近期错误日志 ────────────────────────────────────────────────────────── md.push_str("\n## 近期错误日志(最多 20 条)\n\n"); if errors.is_empty() { @@ -419,7 +574,15 @@ fn build_mentor_markdown( } } - md.push_str("\n---\n\n请根据以上信息,对该项目的当前状态、质量和下一步方向给出你的判断与建议。\n"); + // ── AI 笔记(跨会话记忆)──────────────────────────────────────────────────── + if !ai_notes.is_empty() { + md.push_str("\n## AI 笔记(历史观察)\n\n"); + for note in ai_notes { + md.push_str(&format!("- `{}` {}\n", note.created_at, note.content)); + } + } + + md.push_str("\n---\n\n请根据以上信息,对该项目的当前状态、质量和下一步方向给出你的判断与建议。如需历史趋势数据,可调用 `get_project_snapshots` 工具。\n"); md } @@ -525,7 +688,8 @@ pub fn run_startup_health_scan() { for (project_id, project_name, win_path, wsl_path, platform) in rows { // 1. 检查 git 活跃度(超 14 天无提交) // 直接获取距今天数,None 表示路径不存在或无 git(跳过,不是"停滞") - if let Some(days) = get_days_since_last_commit(platform.as_deref(), win_path.as_deref(), wsl_path.as_deref(), &conn) { + let days_opt = get_days_since_last_commit(platform.as_deref(), win_path.as_deref(), wsl_path.as_deref(), &conn); + if let Some(days) = days_opt { if days > 14 { // \x1f (ASCII Unit Separator) 作分隔符,避免项目名含 | 导致解析错位 crate::db::log_event( @@ -561,17 +725,45 @@ pub fn run_startup_health_scan() { win_path.as_deref().map(|p| std::path::Path::new(p).join(".blueprint")) }; - if let Some(bp_dir) = blueprint_path_opt { - let blocked_count = count_blocked_tasks(&bp_dir); - if blocked_count > 0 { - crate::db::log_event( - "startup_alert", - "warn", - &format!("{}\x1fblocked_tasks\x1f{} 个任务阻塞", project_name, blocked_count), - Some(&format!(r#"{{"project_id":"{}"}}"#, project_id)), - ); - } + let blocked_count = blueprint_path_opt + .as_ref() + .map(|bp_dir| count_blocked_tasks(bp_dir)) + .unwrap_or(0); + + let (bp_done_rate, bp_in_progress) = blueprint_path_opt + .as_ref() + .and_then(|bp_dir| bp_dir.parent()) + .and_then(parse_blueprint_summary) + .map(|s| { + let rate = if s.module_count > 0 { + Some(s.done_count as f64 / s.module_count as f64) + } else { + None + }; + (rate, s.in_progress) + }) + .unwrap_or((None, 0)); + + if blocked_count > 0 { + crate::db::log_event( + "startup_alert", + "warn", + &format!("{}\x1fblocked_tasks\x1f{} 个任务阻塞", project_name, blocked_count), + Some(&format!(r#"{{"project_id":"{}"}}"#, project_id)), + ); } + + // 写入每日快照(INSERT OR IGNORE 保证每天只存一条) + let has_errors = has_recent_errors(&conn, &project_id); + save_project_snapshot( + &conn, + &project_id, + days_opt, + bp_done_rate, + bp_in_progress, + blocked_count, + has_errors, + ); } } @@ -632,6 +824,45 @@ fn count_blocked_tasks(blueprint_dir: &std::path::Path) -> usize { count } +/// 检查该项目在最近 7 天内是否有 error 级别的日志 +fn has_recent_errors(conn: &rusqlite::Connection, project_id: &str) -> bool { + conn.query_row( + "SELECT COUNT(*) FROM runtime_logs + WHERE level = 'error' + AND (json_extract(context, '$.project_id') = ?1 OR context IS NULL) + AND ts >= datetime('now', '-7 days')", + params![project_id], + |r| r.get::<_, i64>(0), + ) + .unwrap_or(0) > 0 +} + +/// 写入项目每日快照,INSERT OR IGNORE 保证每天只存一条 +fn save_project_snapshot( + conn: &rusqlite::Connection, + project_id: &str, + days_since_commit: Option, + blueprint_done_rate: Option, + blueprint_in_progress: u32, + blueprint_blocked: usize, + has_errors: bool, +) { + let _ = conn.execute( + "INSERT OR IGNORE INTO project_snapshots + (project_id, snapshot_date, days_since_commit, blueprint_done_rate, + blueprint_in_progress, blueprint_blocked, has_errors) + VALUES (?1, date('now', 'localtime'), ?2, ?3, ?4, ?5, ?6)", + params![ + project_id, + days_since_commit.map(|d| d as i64), + blueprint_done_rate, + blueprint_in_progress, + blueprint_blocked as i64, + if has_errors { 1i64 } else { 0i64 }, + ], + ); +} + #[tauri::command] pub fn get_startup_alerts() -> Result, String> { let conn = pool().get().map_err(|e| e.to_string())?; diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index f76bd89..d95a20e 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -514,6 +514,22 @@ fn migrate(conn: &rusqlite::Connection) { sort_order INTEGER DEFAULT 0 ); ").expect("create global_tools table"); + + // 增量迁移:项目历史快照(每日一条,供 Claude 做趋势分析) + conn.execute_batch(" + CREATE TABLE IF NOT EXISTS project_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id TEXT NOT NULL, + snapshot_date TEXT NOT NULL, + days_since_commit INTEGER, + blueprint_done_rate REAL, + blueprint_in_progress INTEGER DEFAULT 0, + blueprint_blocked INTEGER DEFAULT 0, + has_errors INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')), + UNIQUE(project_id, snapshot_date) + ); + ").expect("create project_snapshots table"); } #[cfg(test)] diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 7b00e5e..fcaacb6 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -180,6 +180,24 @@ fn tools_list_result() -> Value { }, "required": ["project_id", "content"] } + }, + { + "name": "get_project_snapshots", + "description": "获取项目的历史健康快照(每日一条),包含 git 提交间隔、蓝图完成率、阻塞任务数、错误情况。用于分析趋势、识别高价值改进变量。", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" + }, + "days": { + "type": "integer", + "description": "查询最近 N 天的快照,默认 30" + } + }, + "required": ["project_id"] + } } ] }) @@ -223,6 +241,11 @@ async fn tools_call(group_id: &str, params: Option<&Value>) -> Value { let content = args.get("content").and_then(|v| v.as_str()).unwrap_or(""); append_project_note(&gid, pid, content) } + "get_project_snapshots" => { + let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); + let days = args.get("days").and_then(|v| v.as_u64()).unwrap_or(30) as u32; + get_project_snapshots(&gid, pid, days) + } _ => Err(format!("未知工具: {}", tool_name)), }) .await; @@ -502,3 +525,74 @@ fn append_project_note(group_id: &str, project_id: &str, content: &str) -> Resul .map_err(|e| e.to_string())?; Ok("笔记已保存 ✅".to_string()) } + +fn get_project_snapshots(group_id: &str, project_id: &str, days: u32) -> Result { + let conn = db::pool().get().map_err(|e| e.to_string())?; + + // 校验项目属于该产品组 + let exists: bool = conn + .query_row( + "SELECT COUNT(*) FROM project_group_map WHERE group_id = ?1 AND project_id = ?2", + [group_id, project_id], + |r| r.get::<_, i64>(0), + ) + .map(|n| n > 0) + .unwrap_or(false); + if !exists { + return Err(format!("项目 {} 不属于产品组 {},或项目不存在", project_id, group_id)); + } + + let mut stmt = conn + .prepare( + "SELECT snapshot_date, days_since_commit, blueprint_done_rate, + blueprint_in_progress, blueprint_blocked, has_errors + FROM project_snapshots + WHERE project_id = ?1 + AND snapshot_date >= date('now', 'localtime', ?2) + ORDER BY snapshot_date ASC", + ) + .map_err(|e| e.to_string())?; + + let offset = format!("-{} days", days); + let rows: Vec<(String, Option, Option, i64, i64, i64)> = stmt + .query_map(rusqlite::params![project_id, offset], |r| { + Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)) + }) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .collect(); + + if rows.is_empty() { + return Ok(format!( + "项目 `{}` 暂无历史快照(快照在每次应用启动时写入,最多保留 {} 天)。", + project_id, days + )); + } + + let mut md = format!( + "# 项目历史快照:`{}`\n\n最近 {} 天,共 {} 条记录\n\n", + project_id, + days, + rows.len() + ); + md.push_str("| 日期 | 距上次提交(天) | 蓝图完成率 | 进行中模块 | 阻塞任务 | 有错误 |\n"); + md.push_str("|------|--------------|-----------|-----------|---------|------|\n"); + + for (date, days_commit, done_rate, in_prog, blocked, has_err) in &rows { + let commit_col = days_commit + .map(|d| d.to_string()) + .unwrap_or_else(|| "—".to_string()); + let rate_col = done_rate + .map(|r| format!("{:.0}%", r * 100.0)) + .unwrap_or_else(|| "—".to_string()); + let err_col = if *has_err == 1 { "⚠️" } else { "✅" }; + md.push_str(&format!( + "| {} | {} | {} | {} | {} | {} |\n", + date, commit_col, rate_col, in_prog, blocked, err_col + )); + } + + md.push_str("\n---\n\n请根据以上趋势数据,分析哪些变量与项目停滞或健康度下降最相关,并给出改进建议。\n"); + + Ok(md) +}