feat: 健康中心诊断优化 + CONVENTIONS v1.4.0

健康中心:
- check_editor 修正为读 editors/active_editors 新字段结构,兼容旧 active_editor
- check_github_token 区分 401/403(Token 失效)与网络超时(warn 而非 error)

蓝图规则:
- CONVENTIONS v1.4.0:新增「交互质量复盘笔记」章节,建立结构化复盘数据集
- blueprint.rs 版本常量同步至 1.4.0,CLAUDE.md 注入模板版本同步
- 项目导师模块标记为 done(progress 100)
This commit is contained in:
lanrtop 2026-04-04 23:20:31 +09:00
parent af88408669
commit 157df716c7
7 changed files with 157 additions and 32 deletions

View File

@ -1,6 +1,6 @@
--- ---
version: 1.3.0 version: 1.4.0
updated: 2026-04-03 updated: 2026-04-04
source: dev-manager-tauri source: dev-manager-tauri
--- ---
@ -266,6 +266,50 @@ Claude 应关注这些信号来决定是否更新蓝图:
- 执行模型遇到无法解决的问题 → 标记 🔴 blocked + blocked_reason - 执行模型遇到无法解决的问题 → 标记 🔴 blocked + blocked_reason
- Opus 看到 blocked 任务 → 分析原因,拆/补/调后产出新任务卡 - Opus 看到 blocked 任务 → 分析原因,拆/补/调后产出新任务卡
## 交互质量复盘笔记
> 本章节仅适用于**开发执行阶段**(实现代码时),初始化蓝图或同步蓝图时无需执行。
每完成一张 **M 或 L 复杂度**的任务卡后Claude 应通过 `append_project_note`MCP 工具写入一条结构化复盘笔记。这是飞轮外环的原始数据来源——积累足够多条后Claude 读一遍即可识别高频坑点和高效模式。
### 笔记格式
```
【复盘】<任务标题>
任务类型: 后端命令 | 前端组件 | 数据层 | MCP工具 | 架构调整
复杂度: S | M | L
实际轮数: N轮预估 M 轮)
是否返工: 是/否
返工原因: <若返工一句话说明根因"WSL路径格式未预料">
有效策略: <本次奏效的做法一句话>
遗留风险: <若有一句话>
```
### 触发时机
| 情况 | 操作 |
|------|------|
| M/L 任务卡标记为 ✅ done | 写一条复盘笔记source: mcp |
| 遇到非预期的系统边界或平台差异 | 额外写一条"陷阱记录",遗留风险填具体复现条件 |
| 同类问题第二次出现 | 在新笔记中引用旧笔记时间戳,说明"同类问题复发" |
### 示例
```
【复盘】WSL项目路径检测
任务类型: 后端命令
复杂度: M
实际轮数: 6轮预估 2 轮)
是否返工: 是
返工原因: wsl_path 字段存的是 Windows UNC 格式(\\wsl.localhost\Ubuntu-22.04\...),而非 Linux 路径,导致 wsl 子命令收到错误参数
有效策略: 在函数入口统一用 unc_to_distro_and_linux() 转换,一次解析,全程复用
遗留风险: Path::exists() 对 \\wsl.localhost\ 路径无效,所有 WSL 路径存在性检查必须走 wsl -d distro -- test -d/f
```
> 这些笔记不替代决策记录(决策记录记架构 WHY复盘笔记记执行 HOW 和踩坑)。两者都写。
---
## 飞轮外环(跨项目数据反哺) ## 飞轮外环(跨项目数据反哺)
内环描述的是单项目的执行流程。外环负责**把多个项目的执行数据聚合起来,反哺 CONVENTIONS 本身**。 内环描述的是单项目的执行流程。外环负责**把多个项目的执行数据聚合起来,反哺 CONVENTIONS 本身**。

View File

@ -246,8 +246,8 @@ modules:
- id: project-mentor - id: project-mentor
name: 项目导师 name: 项目导师
area: frontend area: frontend
status: in_progress status: done
progress: 75 progress: 100
position: [2000, 0] position: [2000, 0]
edges: edges:

View File

@ -74,6 +74,48 @@
- depends: T1 - depends: T1
- acceptance: 炼境自身dev-manager-tauri作为项目注册后`get_mentor_context` 能正常返回其蓝图摘要和健康数据,不产生递归调用 - acceptance: 炼境自身dev-manager-tauri作为项目注册后`get_mentor_context` 能正常返回其蓝图摘要和健康数据,不产生递归调用
### ✅ T5 · project_notes 数据层 + 后端命令
- status: done
- complexity: S
- files: src-tauri/src/db.rs, src-tauri/src/commands/mentor.rs, src-tauri/src/lib.rs
- depends: T1
- acceptance: DB 增量迁移新增 `project_notes` 表;`get_project_notes(project_id)` 和 `add_project_note(project_id, content, source)` 两个 Tauri command 可通过 invoke 正常调用
### ✅ T6 · MCP append_project_note 工具
- status: done
- complexity: S
- files: src-tauri/src/mcp_server.rs
- depends: T5
- acceptance: MCP tools/list 新增 `append_project_note` 工具Claude 通过 MCP 调用后,笔记写入 DB返回成功确认包含 group 归属校验project_id 必须属于请求的 group
### ✅ T7 · MentorPanel 展示 AI 笔记
- status: done
- complexity: S
- files: src/components/mentor/MentorPanel.tsx, src/lib/commands.ts
- depends: T5
- acceptance: MentorPanel 底部新增"AI 笔记"区域,展示该项目最近 10 条笔记(时间 + 内容),无笔记时显示占位文案
### ✅ T8 · 后端全组巡检命令
- status: done
- complexity: S
- files: src-tauri/src/commands/mentor.rs, src-tauri/src/lib.rs
- depends: T1
- acceptance: `get_group_mentor_report(group_id)` 命令遍历组内所有项目,聚合每个项目的 mentor_markdown返回一份完整的 Markdown 字符串,包含各项目小标题分隔
### ✅ T9 · 前端全组巡检按钮
- status: done
- complexity: S
- files: src/components/manage/ManagePage.tsx, src/lib/commands.ts
- depends: T8
- acceptance: ManagePage 产品组 Tab 的每个组卡片右上角出现"巡检全组"按钮;点击后加载中显示 spinner完成后将聚合报告复制到剪贴板并提示"已复制,可粘贴给 Claude"
### ✅ T10 · 启动健康扫描 + 前端 Alert Banner
- status: done
- complexity: M
- files: src-tauri/src/commands/mentor.rs, src-tauri/src/lib.rs, src/lib/commands.ts, src/App.tsx
- depends: T1
- acceptance: 应用启动时后台扫描所有项目,超 14 天无 git 提交或蓝图存在 🔴 blocked 任务的项目写入告警category=startup_alert前端 App.tsx 加载完成后调用 `get_startup_alerts`,有结果则在页面顶部显示可关闭的黄色 Banner列出需关注的项目名
## 决策记录 ## 决策记录
- 项目导师是洞察与引导层health-center 是数据基础层,两者是上下游关系,不合并为一个模块 - 项目导师是洞察与引导层health-center 是数据基础层,两者是上下游关系,不合并为一个模块

View File

@ -30,19 +30,19 @@
"modules": { "modules": {
"blocked": 0, "blocked": 0,
"concept": 0, "concept": 0,
"done": 29, "done": 32,
"in_progress": 0, "in_progress": 0,
"planned": 0, "planned": 0,
"stalled": [], "stalled": [],
"total": 29 "total": 32
}, },
"tasks": { "tasks": {
"blocked": 0, "blocked": 0,
"dispatched": 0, "dispatched": 1,
"done": 106, "done": 115,
"in_progress": 0, "in_progress": 0,
"todo": 0, "todo": 1,
"total": 110 "total": 116
} }
} }
] ]

View File

@ -36,7 +36,7 @@ docs/ 部署和更新指南
- 代码重构/简化:/simplify → /code-review - 代码重构/简化:/simplify → /code-review
- 发现深层问题:/Dev-deep-think - 发现深层问题:/Dev-deep-think
<!-- BLUEPRINT_MANAGED_START version="1.3.0" --> <!-- BLUEPRINT_MANAGED_START version="1.4.0" -->
## 项目蓝图 ## 项目蓝图
本项目使用 `.blueprint/` 目录维护项目全景架构图,驱动需求讨论和任务管理。 本项目使用 `.blueprint/` 目录维护项目全景架构图,驱动需求讨论和任务管理。

View File

@ -4,10 +4,10 @@ use std::path::Path;
// ── 主节点master CONVENTIONS.md 内容和版本(编译时嵌入)──────────────────── // ── 主节点master CONVENTIONS.md 内容和版本(编译时嵌入)────────────────────
const MASTER_CONVENTIONS: &str = include_str!("../../../.blueprint/CONVENTIONS.md"); const MASTER_CONVENTIONS: &str = include_str!("../../../.blueprint/CONVENTIONS.md");
const MASTER_CONVENTIONS_VERSION: &str = "1.3.0"; const MASTER_CONVENTIONS_VERSION: &str = "1.4.0";
// CLAUDE.md 蓝图管理区模板(版本与 CONVENTIONS 保持一致) // CLAUDE.md 蓝图管理区模板(版本与 CONVENTIONS 保持一致)
const CLAUDE_MANAGED_TEMPLATE: &str = r#"<!-- BLUEPRINT_MANAGED_START version="1.3.0" --> const CLAUDE_MANAGED_TEMPLATE: &str = r#"<!-- BLUEPRINT_MANAGED_START version="1.4.0" -->
## ##
使 `.blueprint/` 使 `.blueprint/`

View File

@ -340,7 +340,7 @@ fn check_github_token() -> HealthItem {
_ => return Ok(skip_item("github_token", "GitHub Token", "未登录 GitHub 账户")), _ => return Ok(skip_item("github_token", "GitHub Token", "未登录 GitHub 账户")),
}; };
match ureq::builder().timeout(std::time::Duration::from_secs(5)).build() match ureq::builder().timeout(std::time::Duration::from_secs(8)).build()
.get("https://api.github.com/user") .get("https://api.github.com/user")
.set("Authorization", &format!("Bearer {}", token)) .set("Authorization", &format!("Bearer {}", token))
.set("User-Agent", "dev-manager-tauri") .set("User-Agent", "dev-manager-tauri")
@ -353,42 +353,81 @@ fn check_github_token() -> HealthItem {
Ok(HealthItem { id: "github_token".into(), name: "GitHub Token".into(), status: "ok".into(), Ok(HealthItem { id: "github_token".into(), name: "GitHub Token".into(), status: "ok".into(),
detail: Some(format!("Token 有效,账户: {}", username)) }) detail: Some(format!("Token 有效,账户: {}", username)) })
} }
Err(e) => Ok(HealthItem { id: "github_token".into(), name: "GitHub Token".into(), status: "error".into(), Err(ureq::Error::Status(401, _)) | Err(ureq::Error::Status(403, _)) =>
detail: Some(format!("Token 失效或网络不可达: {}", e)) }), Ok(HealthItem { id: "github_token".into(), name: "GitHub Token".into(), status: "error".into(),
detail: Some("Token 已失效或权限不足,请重新登录 GitHub 账户".into()) }),
Err(e) => Ok(HealthItem { id: "github_token".into(), name: "GitHub Token".into(), status: "warn".into(),
detail: Some(format!("Token 已存储但无法联通 GitHub API可能需要代理: {}", e)) }),
} }
})(); })();
result.unwrap_or_else(|e| err_item("github_token", "GitHub Token", &e)) result.unwrap_or_else(|e| err_item("github_token", "GitHub Token", &e))
} }
/// 检测活跃编辑器路径是否可用settings.active_editor JSON /// 检测活跃编辑器路径是否可用
/// 存储结构editors = [{id,name,path}]active_editors = ["id1","id2"]
fn check_editor() -> HealthItem { fn check_editor() -> HealthItem {
let result: Result<HealthItem, String> = (|| { let result: Result<HealthItem, String> = (|| {
let conn = pool().get().map_err(|e| e.to_string())?; let conn = pool().get().map_err(|e| e.to_string())?;
let raw: Option<String> = conn.query_row(
"SELECT value FROM settings WHERE key = 'active_editor'",
[], |row| row.get(0),
).ok();
let raw = match raw { let get_val = |key: &str| -> Option<String> {
Some(s) if !s.trim().is_empty() => s, conn.query_row(
_ => return Ok(skip_item("editor", "编辑器路径", "未配置活跃编辑器")), "SELECT value FROM settings WHERE key = ?1",
rusqlite::params![key], |row| row.get(0),
).ok()
}; };
let v: Value = serde_json::from_str(&raw) // 读取编辑器列表
.unwrap_or_else(|_| Value::Null); let editors_raw = match get_val("editors") {
let name = v["name"].as_str().unwrap_or("编辑器").to_string(); Some(s) if !s.trim().is_empty() => s,
let path = v["path"].as_str().unwrap_or("").to_string(); _ => return Ok(skip_item("editor", "编辑器路径", "未配置任何编辑器")),
};
let editors: Vec<Value> = serde_json::from_str(&editors_raw)
.unwrap_or_default();
if editors.is_empty() {
return Ok(skip_item("editor", "编辑器路径", "未配置任何编辑器"));
}
// 读取活跃编辑器 ID 列表(兼容旧单值字段)
let active_ids: Vec<String> = if let Some(raw) = get_val("active_editors") {
serde_json::from_str(&raw).unwrap_or_default()
} else if let Some(old_id) = get_val("active_editor") {
if old_id.is_empty() { vec![] } else { vec![old_id] }
} else {
vec![]
};
if active_ids.is_empty() {
return Ok(HealthItem { id: "editor".into(), name: "编辑器路径".into(), status: "warn".into(),
detail: Some("已添加编辑器但未勾选活跃编辑器,请前往设置页勾选至少一个".into()) });
}
// 取第一个活跃编辑器检测路径
let first_id = &active_ids[0];
let entry = editors.iter().find(|e| e["id"].as_str() == Some(first_id));
let (name, path) = match entry {
Some(e) => (
e["name"].as_str().unwrap_or("编辑器").to_string(),
e["path"].as_str().unwrap_or("").to_string(),
),
None => return Ok(HealthItem { id: "editor".into(), name: "编辑器路径".into(), status: "warn".into(),
detail: Some(format!("活跃编辑器 ID `{}` 在编辑器列表中未找到,请重新配置", first_id)) }),
};
if path.is_empty() { if path.is_empty() {
return Ok(HealthItem { id: "editor".into(), name: "编辑器路径".into(), status: "warn".into(), return Ok(HealthItem { id: "editor".into(), name: "编辑器路径".into(), status: "warn".into(),
detail: Some(format!("{} 未找到可执行路径", name)) }); detail: Some(format!("{} 路径为空,请在设置页编辑该编辑器条目补全路径", name)) });
} }
if std::path::Path::new(&path).exists() { if std::path::Path::new(&path).exists() {
let extra = if active_ids.len() > 1 {
format!("(共 {} 个活跃编辑器)", active_ids.len())
} else {
String::new()
};
Ok(HealthItem { id: "editor".into(), name: "编辑器路径".into(), status: "ok".into(), Ok(HealthItem { id: "editor".into(), name: "编辑器路径".into(), status: "ok".into(),
detail: Some(format!("{} 路径有效", name)) }) detail: Some(format!("{} 路径有效{}", name, extra)) })
} else { } else {
Ok(HealthItem { id: "editor".into(), name: "编辑器路径".into(), status: "error".into(), Ok(HealthItem { id: "editor".into(), name: "编辑器路径".into(), status: "error".into(),
detail: Some(format!("{} 可执行文件不存在: {}", name, path)) }) detail: Some(format!("{} 可执行文件不存在: {},请在设置页更新路径", name, path)) })
} }
})(); })();
result.unwrap_or_else(|e| err_item("editor", "编辑器路径", &e)) result.unwrap_or_else(|e| err_item("editor", "编辑器路径", &e))