feat: 飞轮智能阶段二完成——MCP SSE 传输 + 复盘覆盖率 + usage.json 扩展采集

- MCP Server 新增 GET/SSE 端点,Claude Code 原生 MCP 打通
- 项目导师面板显示复盘笔记覆盖率统计与缺口提醒
- usage.json 快照新增 complexity_dist、rework_count 等扩展字段
- 独立项目自动创建产品组并注入 MCP
- 新增 retro-catchup 技能用于批量补写历史复盘笔记
- 补全飞轮存量函数单元测试
- NewProjectModal 支持更灵活的模板参数配置
This commit is contained in:
lanrtop 2026-04-13 01:49:56 +09:00
parent 6162533c77
commit 8c06aa5e44
17 changed files with 1211 additions and 83 deletions

View File

@ -6,7 +6,7 @@ description: >
成为每个项目的贴身导师。炼境本身亦被纳入其中, 成为每个项目的贴身导师。炼境本身亦被纳入其中,
形成可自我进化的开发智能闭环。 形成可自我进化的开发智能闭环。
iteration: 1 iteration: 1
updated: 2026-04-11 updated: 2026-04-13
@ -276,7 +276,7 @@ modules:
name: 飞轮智能成长 name: 飞轮智能成长
area: backend area: backend
status: in_progress status: in_progress
progress: 33 progress: 86
position: [1750, 650] position: [1750, 650]
edges: edges:

View File

@ -36,8 +36,24 @@
--- ---
### 📋 补测试:飞轮存量函数安全网 ### ✅ 独立项目自动创建产品组
- status: todo - status: done
- complexity: S
- files: src-tauri/src/commands/groups.rs, src-tauri/src/commands/buff.rs
- acceptance: 对未加入任何产品组的蓝图项目apply_blueprint_buff 时自动创建 "{name} (独立项目)" 产品组并注入 MCP使 append_project_note 在该项目的 Claude 会话中可用
---
### ✅ 复盘笔记覆盖率可见性
- status: done
- complexity: M
- files: src-tauri/src/commands/mentor.rs, src/lib/commands.ts, src/components/mentor/MentorPanel.tsx
- acceptance: 项目导师面板显示"已完成 M/L 任务数 / 复盘笔记数 / 缺口"三格统计;缺口 > 0 时显示琥珀色警告提示mentor_markdown 同步写入覆盖率表格和补写提醒,让 Claude 每次打开报告时自动感知
---
### ✅ 补测试:飞轮存量函数安全网
- status: done
- complexity: M - complexity: M
- depends: flywheel-intelligence阶段二启动前完成保障数据关键路径 - depends: flywheel-intelligence阶段二启动前完成保障数据关键路径
- files: src-tauri/src/commands/blueprint.rs - files: src-tauri/src/commands/blueprint.rs
@ -47,11 +63,21 @@
--- ---
### 阶段二usage.json 扩展采集维度 ### ✅ MCP Server 支持 SSE 传输
- status: todo - status: done
- complexity: M
- files: src-tauri/src/mcp_server.rs
- acceptance: `GET /mcp/group/:group_id` 返回 `text/event-stream`,按 MCP SSE 传输规范完成握手Claude Code 会话启动时 `append_project_note` 等工具可在工具列表中原生出现Windows 本地测试通过curl -N GET 端点可见 SSE 事件流)
> 背景Claude Code MCP 客户端用 GET 发起 SSE 握手,炼境目前只接受 POST导致 405 → MCP 永久失效。加 GET/SSE 端点后,所有项目原生 MCP 打通,复盘笔记可直接通过 `append_project_note` 写入,飞轮数据链完整,无需任何绕路脚本。
---
### ✅ 阶段二usage.json 扩展采集维度
- status: done
- complexity: M - complexity: M
- depends: flywheel-intelligence阶段一完成后启动 - depends: flywheel-intelligence阶段一完成后启动
- files: src-tauri/src/commands/blueprint_flywheel.rs - files: src-tauri/src/commands/blueprint.rs
- acceptance: usage.json 快照新增 complexity_dist、rework_count、avg_rounds_M 字段;字段值从复盘笔记解析填充;梦核分析可输出"M 级任务实际轮数 vs 预估"对比 - acceptance: usage.json 快照新增 complexity_dist、rework_count、avg_rounds_M 字段;字段值从复盘笔记解析填充;梦核分析可输出"M 级任务实际轮数 vs 预估"对比
阶段二详细设计: 阶段二详细设计:

View File

@ -903,6 +903,192 @@
"todo": 0, "todo": 0,
"total": 140 "total": 140
} }
},
{
"at": "2026-04-12T00:16:09Z",
"blocked_reasons": [],
"conventions_version": "1.4.4",
"delta": {
"newly_blocked": [],
"newly_done": [
"flywheel-intelligence/MCP Server 支持 SSE 传输",
"flywheel-intelligence/复盘笔记覆盖率可见性",
"flywheel-intelligence/独立项目自动创建产品组",
"flywheel-intelligence/补测试:飞轮存量函数安全网",
"flywheel-intelligence/阶段一:复盘笔记接入梦核飞轮",
"user-project-todos/前端:备忘清单 UI 组件",
"user-project-todos/数据层user_project_todos 表 + Rust commands"
],
"unblocked": []
},
"iteration": 1,
"modules": {
"blocked": 0,
"concept": 0,
"done": 35,
"in_progress": 1,
"planned": 0,
"stalled": [
"flywheel-intelligence"
],
"total": 36
},
"task_ids": {
"blocked": [],
"done": [
"auto-update/Capabilities 权限配置",
"auto-update/下载 & 安装",
"auto-update/更新检测",
"auto-update/自动检查更新",
"auto-update/错误提示",
"blueprint-buff/BlueprintView 跨项目切换",
"blueprint-buff/Buff 生命周期 Rust 命令",
"blueprint-buff/blueprint_buffs 数据表",
"blueprint-buff/git watcher + 任务匹配写回 + 飞轮快照",
"blueprint-buff/前端 Buff 管理 UI",
"blueprint-buff/前端实时刷新",
"blueprint-feedback/归档 command",
"blueprint-feedback/归档候选展示",
"blueprint-feedback/梦核分析入口(蓝图弹窗)",
"blueprint-feedback/梦核提示词增加停滞模块数据",
"blueprint-flywheel/get_flywheel_stats command",
"blueprint-flywheel/get_flywheel_stats 支持迭代感知",
"blueprint-flywheel/usage.json 埋点写入",
"blueprint-flywheel/梦核提示词生成 command",
"blueprint-flywheel/飞轮健康面板",
"blueprint-governance/CLAUDE.md 模板定义",
"blueprint-governance/CONVENTIONS.md 版本化",
"blueprint-governance/GovernancePanel 规则编辑器 UI",
"blueprint-governance/get_blueprint_status command",
"blueprint-governance/sync/梦核/summary 切换为 effective 内容",
"blueprint-governance/sync_blueprint_rules command",
"blueprint-governance/批量同步所有项目规则",
"blueprint-governance/用户版 CONVENTIONS 存储层初始化",
"blueprint-governance/用户版 CONVENTIONS 读写 command",
"blueprint-onboarding/generate_blueprint_prompt command",
"blueprint-onboarding/蓝图操作弹窗",
"blueprint-onboarding/项目列表蓝图状态徽章",
"blueprint-reader/合并返回完整蓝图数据",
"blueprint-reader/读取 manifest.yaml",
"blueprint-reader/读取 modules/*.md",
"blueprint-view/.blueprint/ 数据结构设计",
"blueprint-view/React Flow 节点图渲染",
"blueprint-view/blocked 状态支持",
"blueprint-view/下一步汇总面板",
"blueprint-view/任务卡复制派发",
"blueprint-view/批量执行提示词入口",
"blueprint-view/智能连线路由",
"blueprint-view/模块详情面板",
"blueprint-view/统计汇总条",
"blueprint-view/蓝图治理面板",
"blueprint-view/蓝图读取 Command",
"blueprint-view/边高亮与模块聚焦",
"blueprint-view/顶部状态行扩展",
"blueprint-view/项目卡片蓝图入口",
"cicd-workflow/Actions 运行历史",
"cicd-workflow/CI/CD 配置 CRUD",
"cicd-workflow/Tauri OSS 分发模板",
"cicd-workflow/Web SSH 部署模板",
"cicd-workflow/Workflow 文件生成",
"devtools-scan/WSL 工具扫描",
"devtools-scan/Windows 工具扫描",
"devtools-scan/工具信息持久化",
"flywheel-intelligence/MCP Server 支持 SSE 传输",
"flywheel-intelligence/复盘笔记覆盖率可见性",
"flywheel-intelligence/独立项目自动创建产品组",
"flywheel-intelligence/补测试:飞轮存量函数安全网",
"flywheel-intelligence/阶段一:复盘笔记接入梦核飞轮",
"git-ops/上游状态检测",
"git-ops/分支管理",
"git-ops/基础操作",
"github-auth/OAuth Device Flow",
"github-auth/Token 管理",
"github-publish/.gitignore 生成",
"github-publish/Git 初始化 & 首次推送",
"github-publish/创建 GitHub 仓库",
"github-publish/扫描本地 Git 状态",
"health-center/T1 · Backend 健康检查聚合命令",
"health-center/T2 · 质量指标聚合",
"health-center/T3 · 前端健康中心页面",
"health-center/T4 · 扩展 MCP get_diagnostics",
"mcp-config-inject/加入产品组时注入配置",
"mcp-config-inject/端口变更时重新注入所有配置",
"mcp-config-inject/退出产品组时清除配置",
"mcp-server/MCP 端口可配置Rust 侧)",
"mcp-server/启动时绑定 HTTP 端口",
"mcp-server/实现 MCP JSON-RPC 基础协议",
"mcp-server/实现产品组上下文工具",
"product-group-mcp/产品组详情显示 MCP 注入状态",
"product-group-mcp/设置页添加 MCP 端口配置",
"project-dashboard/分组画布",
"project-dashboard/项目卡片列表",
"project-dashboard/项目发现面板",
"project-dashboard/项目快捷操作",
"project-env-scan/环境信息持久化",
"project-env-scan/项目环境检测",
"project-mentor/T1 · 后端聚合 commandMentorContext",
"project-mentor/T10 · 启动健康扫描 + 前端 Alert Banner",
"project-mentor/T11 · 项目历史快照",
"project-mentor/T2 · MCP 项目导师查询接口",
"project-mentor/T3 · 前端导师面板",
"project-mentor/T4 · 炼境自指接入",
"project-mentor/T5 · project_notes 数据层 + 后端命令",
"project-mentor/T6 · MCP append_project_note 工具",
"project-mentor/T7 · MentorPanel 展示 AI 笔记",
"project-mentor/T8 · 后端全组巡检命令",
"project-mentor/T9 · 前端全组巡检按钮",
"project-mgmt/子文件夹扫描",
"project-mgmt/批量导入项目",
"project-mgmt/项目 CRUD",
"project-mgmt/项目档案Profile",
"project-templates/从模板创建项目",
"project-templates/内置模板种植",
"project-templates/模板 CRUD",
"project-tools/工具 CRUD",
"project-tools/工具启动",
"repo-registry/仓库导入",
"repo-registry/仓库挂载",
"repo-registry/仓库注册表管理",
"runtime-diagnostics/T1 · SQLite runtime_logs 表",
"runtime-diagnostics/T2 · 替换 eprintln! 为结构化日志",
"runtime-diagnostics/T3 · MCP Server 新增 get_diagnostics 工具",
"runtime-diagnostics/T4 · 注入失败结果返回前端",
"runtime-diagnostics/T5 · 设置页 MCP 真实健康检查",
"settings-page/WSL 发行版选择",
"settings-page/关于 & 更新",
"settings-page/终端配置",
"settings-page/编辑器检测",
"shell-launch/打开终端",
"shell-launch/打开编辑器",
"sqlite-db/数据库迁移",
"sqlite-db/连接池初始化",
"tags-groups/分组管理",
"tags-groups/标签管理",
"tray-service/关闭窗口最小化到托盘",
"tray-service/系统托盘图标与菜单",
"tray-service/集成 tauri-plugin-single-instance",
"user-project-todos/前端:备忘清单 UI 组件",
"user-project-todos/数据层user_project_todos 表 + Rust commands",
"v2rayn-import/当前节点读取",
"v2rayn-import/数据库路径检测",
"v2rayn-import/节点列表扫描",
"workspace-mgmt/空间 CRUD",
"workspace-mgmt/空间级设置",
"workspace-mgmt/部署命令管理",
"wsl-proxy/代理一致性测试",
"wsl-proxy/代理同步",
"wsl-proxy/代理清除",
"wsl-proxy/代理状态检测"
]
},
"tasks": {
"blocked": 0,
"dispatched": 0,
"done": 143,
"in_progress": 0,
"todo": 2,
"total": 149
}
} }
] ]
} }

View File

@ -0,0 +1,129 @@
---
name: retro-catchup
description: 为项目中所有已完成但缺少复盘笔记的 M/L 任务卡补写历史复盘笔记,并通过 MCP append_project_note 写入炼境。Use when a project has completed M/L tasks but zero or few retrospective notes.
allowed-tools: Read, Glob, Grep, Bash(git log *), Bash(git diff *), Bash(python *), Bash(python3 *), mcp__lian*
---
# 复盘笔记补写Retro Catch-Up
为已完成的 M/L 任务卡补写事后重建版复盘笔记,推动飞轮数据积累。
> ⚠️ 这些笔记是**事后重建**,基于任务卡内容 + git 历史推断,比实时记录精度低。但对识别高频模式仍然有价值。
## 写入通道(三级降级)
每次调用 `append_project_note` 时,按以下顺序尝试,成功即停止:
**① 原生 MCP**:若 `append_project_note` 工具可用,直接调用。
**② Python HTTP 直调**:若原生 MCP 不可用Claude Code 与炼境握手失败的已知问题),读取项目 `.claude/settings.json`,提取 group_id用 Python 直接 POST 炼境 API
```python
import urllib.request, json, sys
# 从 .claude/settings.json 提取 group_id
with open('.claude/settings.json') as f:
cfg = json.load(f)
group_id = None
for key in cfg.get('mcpServers', {}):
if key.startswith('lian-jing:group-'):
group_id = key.split('lian-jing:group-')[1]
break
# project_id 通过先调用 list_group_projects 获得
url = f'http://localhost:27190/mcp/group/{group_id}'
def mcp_call(method, params={}):
payload = json.dumps({'jsonrpc':'2.0','id':1,'method':method,'params':params}).encode()
req = urllib.request.Request(url, data=payload, method='POST')
req.add_header('Content-Type', 'application/json')
req.add_header('Accept', 'application/json')
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read().decode('utf-8', errors='replace'))
# 调用示例
result = mcp_call('tools/call', {
'name': 'append_project_note',
'arguments': {'project_id': PROJECT_ID, 'content': NOTE_CONTENT}
})
```
**③ 本地文件兜底**:若炼境未运行(连接超时),将所有笔记追加写入 `.blueprint/retro-notes.md`,格式与正式笔记相同,头部加注 `<!-- 待炼境恢复后手动同步 -->`
## 步骤
### 1. 确认写入通道 + 项目 ID
按以下顺序确定:
1. 检测 `append_project_note` 是否在可用工具中 → 是则用**通道①**
2. 否则检测 `localhost:27190` 是否可达(`curl -s --max-time 2 http://localhost:27190` 或 Python urlopen→ 可达则用**通道②**,同时读取 `.claude/settings.json` 提取 `group_id`,再调用 `list_group_projects` 获取当前项目 `project_id`
3. 均不可达 → 用**通道③**,无需 project_id
告知用户当前使用哪个通道,例如:`使用通道②Python HTTP 直调)`。
### 2. 扫描所有已完成 M/L 任务卡
遍历 `.blueprint/modules/*.md`,收集满足以下条件的任务卡:
- 前缀为 ✅status: done
- complexity 为 `M``L`
输出扫描结果:共找到 N 张,列出标题 + 所属模块。
### 3. 逐卡重建复盘笔记
对每张任务卡按顺序处理:
**3a. 读取证据**
- 读取任务卡的 `files` 字段列出的所有文件(当前代码状态)
- 执行 `git log --oneline -- <file>` 查看相关文件的提交历史
- 若提交数 > 1执行 `git diff <first_commit>^..<last_commit> -- <file>` 了解变更量
**3b. 推断执行情况**
根据证据推断:
- **实际轮数**:相关文件的提交次数(粗估)
- **是否返工**:若同一文件有多次修改且 message 含 fix/修复/重新/又 等关键词
- **返工原因**:从 commit message 或代码变更中推断
- **有效策略**:从最终实现方式中提取关键设计决策
**3c. 写入笔记**
按步骤 1 确定的通道调用 `append_project_note`,内容格式:
```
【复盘·重建】<任务标题>
任务类型: 后端命令 | 前端组件 | 数据层 | MCP工具 | 架构调整
复杂度: M | L
实际轮数: N轮事后推断基于 git 提交数)
是否返工: 是/否/不确定
返工原因: <若返工 commit message 推断>
有效策略: <从最终实现中提取的关键做法>
遗留风险: <若代码中有明显 TODO 或边界情况未处理>
备注: 此笔记为事后重建,非实时记录
```
每写完一张,告知用户"✅ 已写入:<任务标题>"。
### 4. 输出汇总
```
## 复盘补写完成
共处理 N 张 M/L 任务卡:
- ✅ 成功写入X 张
- ⏭️ 跳过无足够证据Y 张
飞轮状态:累计复盘笔记 Z 条
```
## 跳过条件
以下情况跳过该任务卡,不强行写入:
- `files` 字段为空,且 git 中找不到相关提交
- 任务卡内容过于简单S 级错标为 M 的情况)
## 原则
- 笔记头部必须标注 `【复盘·重建】``备注: 此笔记为事后重建`,与实时笔记区分
- 宁可跳过,不要捏造。证据不足时注明"信息不足,无法推断"
- 每张卡独立处理,写入失败不影响后续

13
src-tauri/Cargo.lock generated
View File

@ -885,6 +885,7 @@ dependencies = [
"axum", "axum",
"base64 0.22.1", "base64 0.22.1",
"chrono", "chrono",
"futures-core",
"git2", "git2",
"once_cell", "once_cell",
"open", "open",
@ -904,6 +905,7 @@ dependencies = [
"tauri-plugin-single-instance", "tauri-plugin-single-instance",
"tauri-plugin-updater", "tauri-plugin-updater",
"tokio", "tokio",
"tokio-stream",
"ureq", "ureq",
"urlencoding", "urlencoding",
"uuid", "uuid",
@ -5188,6 +5190,17 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tokio-stream"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
]
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.18" version = "0.7.18"

View File

@ -37,6 +37,8 @@ tauri-plugin-process = "2"
tauri-plugin-single-instance = "2" tauri-plugin-single-instance = "2"
axum = "0.7" axum = "0.7"
tokio = { version = "1", features = ["net", "time"] } tokio = { version = "1", features = ["net", "time"] }
tokio-stream = "0.1"
futures-core = "0.3"
ureq = { version = "2", features = ["json"] } ureq = { version = "2", features = ["json"] }
open = "5" open = "5"
base64 = "0.22" base64 = "0.22"

View File

@ -1388,6 +1388,18 @@ pub struct FlywheelStats {
pub projects_with_data: u32, pub projects_with_data: u32,
/// 跨项目复盘笔记(最近 50 条,按时间倒序) /// 跨项目复盘笔记(最近 50 条,按时间倒序)
pub project_notes: Vec<ProjectNoteEntry>, pub project_notes: Vec<ProjectNoteEntry>,
/// 跨项目执行质量指标(从各项目 usage.json 最新快照聚合)
pub execution_quality: ExecutionQuality,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ExecutionQuality {
/// 跨项目任务复杂度分布
pub complexity_dist: std::collections::HashMap<String, u32>,
/// 跨项目总返工次数
pub total_rework_count: u32,
/// M 级任务平均实际轮数(所有项目加权平均)
pub avg_rounds_m: Option<f64>,
} }
/// 内部:单项目最新快照的指标摘要 /// 内部:单项目最新快照的指标摘要
@ -1401,6 +1413,12 @@ struct ProjectSnap {
#[tauri::command] #[tauri::command]
pub fn get_flywheel_stats(project_paths: Vec<String>) -> Result<FlywheelStats, String> { pub fn get_flywheel_stats(project_paths: Vec<String>) -> Result<FlywheelStats, String> {
// 去重:同一项目可能出现在多个产品组中,路径重复会导致指标重复计数
let project_paths: Vec<String> = {
let mut seen = std::collections::HashSet::new();
project_paths.into_iter().filter(|p| seen.insert(p.clone())).collect()
};
let now = chrono::Utc::now(); let now = chrono::Utc::now();
let thirty_days_ago = now - chrono::Duration::days(30); let thirty_days_ago = now - chrono::Duration::days(30);
let sixty_days_ago = now - chrono::Duration::days(60); let sixty_days_ago = now - chrono::Duration::days(60);
@ -1417,6 +1435,11 @@ pub fn get_flywheel_stats(project_paths: Vec<String>) -> Result<FlywheelStats, S
let mut ai_doc_stale_projects: Vec<StalledProject> = Vec::new(); let mut ai_doc_stale_projects: Vec<StalledProject> = Vec::new();
let mut iteration_jumps: Vec<IterationJump> = Vec::new(); let mut iteration_jumps: Vec<IterationJump> = Vec::new();
let mut stalled_modules: Vec<String> = Vec::new(); let mut stalled_modules: Vec<String> = Vec::new();
// 执行质量聚合
let mut agg_complexity: std::collections::HashMap<String, u32> = Default::default();
let mut agg_rework = 0u32;
let mut agg_m_rounds_sum = 0.0f64;
let mut agg_m_rounds_count = 0u32;
for path_str in &project_paths { for path_str in &project_paths {
total_scanned += 1; total_scanned += 1;
@ -1534,6 +1557,22 @@ pub fn get_flywheel_stats(project_paths: Vec<String>) -> Result<FlywheelStats, S
} }
} }
} }
// ── 执行质量指标聚合(阶段二新增字段) ──────────────────────────────────
if let Some(dist) = latest["tasks"]["complexity_dist"].as_object() {
for (k, v) in dist {
if let Some(n) = v.as_u64() {
*agg_complexity.entry(k.clone()).or_insert(0) += n as u32;
}
}
}
if let Some(rc) = latest["tasks"]["rework_count"].as_u64() {
agg_rework += rc as u32;
}
if let Some(ar) = latest["tasks"]["avg_rounds_M"].as_f64() {
agg_m_rounds_sum += ar;
agg_m_rounds_count += 1;
}
} }
// 构建 cohorts // 构建 cohorts
@ -1589,6 +1628,12 @@ pub fn get_flywheel_stats(project_paths: Vec<String>) -> Result<FlywheelStats, S
let project_notes = collect_flywheel_notes(&project_paths); let project_notes = collect_flywheel_notes(&project_paths);
let avg_rounds_m_agg = if agg_m_rounds_count > 0 {
Some(((agg_m_rounds_sum / agg_m_rounds_count as f64) * 10.0).round() / 10.0)
} else {
None
};
Ok(FlywheelStats { Ok(FlywheelStats {
cohorts, cohorts,
top_blocked_reasons, top_blocked_reasons,
@ -1599,6 +1644,11 @@ pub fn get_flywheel_stats(project_paths: Vec<String>) -> Result<FlywheelStats, S
total_projects_scanned: total_scanned, total_projects_scanned: total_scanned,
projects_with_data, projects_with_data,
project_notes, project_notes,
execution_quality: ExecutionQuality {
complexity_dist: agg_complexity,
total_rework_count: agg_rework,
avg_rounds_m: avg_rounds_m_agg,
},
}) })
} }
@ -1607,11 +1657,19 @@ pub fn get_flywheel_stats(project_paths: Vec<String>) -> Result<FlywheelStats, S
/// 再关联 project_notesproject_id = workspace_id取最近 50 条。 /// 再关联 project_notesproject_id = workspace_id取最近 50 条。
/// DB 访问失败时静默返回空列表,不影响主流程。 /// DB 访问失败时静默返回空列表,不影响主流程。
fn collect_flywheel_notes(project_paths: &[String]) -> Vec<ProjectNoteEntry> { fn collect_flywheel_notes(project_paths: &[String]) -> Vec<ProjectNoteEntry> {
let conn = match crate::db::pool().get() { let pool = match crate::db::try_pool() {
Some(p) => p,
None => return vec![],
};
let conn = match pool.get() {
Ok(c) => c, Ok(c) => c,
Err(_) => return vec![], Err(_) => return vec![],
}; };
collect_flywheel_notes_inner(&conn, project_paths)
}
/// 可测试的内层实现,接受注入的连接(支持 in-memory DB
fn collect_flywheel_notes_inner(conn: &rusqlite::Connection, project_paths: &[String]) -> Vec<ProjectNoteEntry> {
let mut notes: Vec<ProjectNoteEntry> = Vec::new(); let mut notes: Vec<ProjectNoteEntry> = Vec::new();
for path_str in project_paths { for path_str in project_paths {
@ -1783,6 +1841,24 @@ pub fn generate_muhe_prompt(stats: FlywheelStats) -> String {
.join("\n") .join("\n")
}; };
// ── 执行质量指标 ──────────────────────────────────────────────────────────
let eq = &stats.execution_quality;
let complexity_summary = {
let s = eq.complexity_dist.get("S").copied().unwrap_or(0);
let m = eq.complexity_dist.get("M").copied().unwrap_or(0);
let l = eq.complexity_dist.get("L").copied().unwrap_or(0);
if s + m + l == 0 {
"(暂无复杂度标记数据)".to_string()
} else {
format!("S: {} 个 | M: {} 个 | L: {}", s, m, l)
}
};
let rounds_summary = match eq.avg_rounds_m {
Some(v) => format!("{:.1}", v),
None => "(暂无 M 级复盘数据)".to_string(),
};
let rework_summary = format!("{}", eq.total_rework_count);
// ── 复盘笔记 ────────────────────────────────────────────────────────────── // ── 复盘笔记 ──────────────────────────────────────────────────────────────
let notes_summary = if stats.project_notes.is_empty() { let notes_summary = if stats.project_notes.is_empty() {
"暂无复盘笔记——Claude 尚未在执行 M/L 任务后写入笔记)".to_string() "暂无复盘笔记——Claude 尚未在执行 M/L 任务后写入笔记)".to_string()
@ -1834,6 +1910,13 @@ pub fn generate_muhe_prompt(stats: FlywheelStats) -> String {
### in_progress /id ### in_progress /id
{stalled_modules_summary} {stalled_modules_summary}
### usage.json
- {complexity_summary}
- M {rounds_summary}
- {rework_summary}
> usage.json
### Claude {notes_total} ### Claude {notes_total}
> Claude M/L MCP > Claude M/L MCP
@ -1864,6 +1947,7 @@ pub fn generate_muhe_prompt(stats: FlywheelStats) -> String {
2. 2.
3. 3.
4. 4.
5. M vs
``` ```
@ -1907,6 +1991,9 @@ pub fn generate_muhe_prompt(stats: FlywheelStats) -> String {
ai_stale_summary = ai_stale_summary, ai_stale_summary = ai_stale_summary,
iteration_summary = iteration_summary, iteration_summary = iteration_summary,
stalled_modules_summary = stalled_modules_summary, stalled_modules_summary = stalled_modules_summary,
complexity_summary = complexity_summary,
rounds_summary = rounds_summary,
rework_summary = rework_summary,
notes_total = notes_total, notes_total = notes_total,
notes_summary = notes_summary, notes_summary = notes_summary,
conventions_version = effective_conventions_version(), conventions_version = effective_conventions_version(),
@ -1916,6 +2003,119 @@ pub fn generate_muhe_prompt(stats: FlywheelStats) -> String {
// ── 飞轮数据层usage.json 快照 ────────────────────────────────────────────── // ── 飞轮数据层usage.json 快照 ──────────────────────────────────────────────
/// 从项目的 project_notes 解析返工次数和 M 级任务平均实际轮数。
/// 返回 (rework_count, avg_rounds_m)avg_rounds_m 为 None 表示无 M 级复盘数据。
fn parse_notes_metrics(bp_dir: &Path) -> (u32, Option<f64>) {
let project_path = match bp_dir.parent().and_then(|p| p.to_str()) {
Some(p) => p.to_string(),
None => return (0, None),
};
let pool = match crate::db::try_pool() {
Some(p) => p,
None => return (0, None),
};
let conn = match pool.get() {
Ok(c) => c,
Err(_) => return (0, None),
};
// 查找此项目的 workspace_id
let workspace_id: String = match conn.query_row(
"SELECT id FROM project_workspaces WHERE win_path = ?1 OR wsl_path = ?1 LIMIT 1",
rusqlite::params![project_path],
|row| row.get(0),
) {
Ok(id) => id,
Err(_) => return (0, None),
};
// 读取所有复盘笔记
let mut stmt = match conn.prepare(
"SELECT content FROM project_notes WHERE project_id = ?1",
) {
Ok(s) => s,
Err(_) => return (0, None),
};
let rows = match stmt.query_map(rusqlite::params![workspace_id], |row| {
row.get::<_, String>(0)
}) {
Ok(r) => r,
Err(_) => return (0, None),
};
let mut rework_count = 0u32;
let mut m_rounds: Vec<f64> = Vec::new();
for row in rows.filter_map(|r| r.ok()) {
let content = row;
// 只解析【复盘】格式的笔记
if !content.contains("【复盘】") {
continue;
}
// 每条笔记只取首次出现的字段,避免格式异常导致重复计数
let mut note_rework = false;
let mut note_rounds: Option<f64> = None;
for line in content.lines() {
let trimmed = line.trim();
// 解析"是否返工: 是"(仅取首次)
if !note_rework
&& (trimmed.starts_with("是否返工:") || trimmed.starts_with("是否返工:"))
{
let val = trimmed.splitn(2, |c| c == ':' || c == '').nth(1).unwrap_or("").trim();
if val == "" {
note_rework = true;
}
}
// 解析"实际轮数: N轮"(仅取首次)
if note_rounds.is_none()
&& (trimmed.starts_with("实际轮数:") || trimmed.starts_with("实际轮数:"))
{
let val = trimmed.splitn(2, |c| c == ':' || c == '').nth(1).unwrap_or("").trim();
// 提取数字部分,如 "3轮预估2轮" → 3.0
note_rounds = val.chars()
.take_while(|c| c.is_ascii_digit() || *c == '.')
.collect::<String>()
.parse()
.ok();
}
}
if note_rework {
rework_count += 1;
}
// 仅对 M 级任务聚合轮数
if let Some(r) = note_rounds {
let is_m = content.lines().any(|l| {
let t = l.trim();
(t.starts_with("复杂度:") || t.starts_with("复杂度:"))
&& t.splitn(2, |c| c == ':' || c == '')
.nth(1)
.map(|v| v.trim() == "M")
.unwrap_or(false)
});
if is_m {
m_rounds.push(r);
}
}
}
let avg = if m_rounds.is_empty() {
None
} else {
let sum: f64 = m_rounds.iter().sum();
Some((sum / m_rounds.len() as f64 * 10.0).round() / 10.0) // 保留一位小数
};
(rework_count, avg)
}
/// 向项目 .blueprint/usage.json 追加一条每日飞轮快照。 /// 向项目 .blueprint/usage.json 追加一条每日飞轮快照。
/// 节流:同一 UTC 日期只追加一次;写入失败静默忽略(不影响主流程)。 /// 节流:同一 UTC 日期只追加一次;写入失败静默忽略(不影响主流程)。
fn append_usage_snapshot(bp_dir: &Path, manifest: &BlueprintManifest, stats: &BlueprintStats) { fn append_usage_snapshot(bp_dir: &Path, manifest: &BlueprintManifest, stats: &BlueprintStats) {
@ -1966,8 +2166,20 @@ fn append_usage_snapshot(bp_dir: &Path, manifest: &BlueprintManifest, stats: &Bl
let mut tasks_in_progress = 0u32; let mut tasks_in_progress = 0u32;
let mut tasks_todo = 0u32; let mut tasks_todo = 0u32;
let mut blocked_reasons: Vec<String> = Vec::new(); let mut blocked_reasons: Vec<String> = Vec::new();
let mut complexity_s = 0u32;
let mut complexity_m = 0u32;
let mut complexity_l = 0u32;
for module in &manifest.modules { for module in &manifest.modules {
for task in &module.tasks { for task in &module.tasks {
// 复杂度分布统计
if let Some(ref c) = task.complexity {
match c.to_uppercase().as_str() {
"S" => complexity_s += 1,
"M" => complexity_m += 1,
"L" => complexity_l += 1,
_ => {}
}
}
match task.status.as_str() { match task.status.as_str() {
"in_progress" => tasks_in_progress += 1, "in_progress" => tasks_in_progress += 1,
"todo" => tasks_todo += 1, "todo" => tasks_todo += 1,
@ -1984,6 +2196,9 @@ fn append_usage_snapshot(bp_dir: &Path, manifest: &BlueprintManifest, stats: &Bl
} }
} }
// 从 project_notes 解析返工次数和 M 级任务平均轮数
let (rework_count, avg_rounds_m) = parse_notes_metrics(bp_dir);
// 停滞模块in_progress 且任务全为 in_progress/blocked没有 todo 可推进) // 停滞模块in_progress 且任务全为 in_progress/blocked没有 todo 可推进)
let stalled_modules: Vec<String> = manifest.modules.iter() let stalled_modules: Vec<String> = manifest.modules.iter()
.filter(|m| m.status == "in_progress" && !m.tasks.is_empty()) .filter(|m| m.status == "in_progress" && !m.tasks.is_empty())
@ -2054,7 +2269,10 @@ fn append_usage_snapshot(bp_dir: &Path, manifest: &BlueprintManifest, stats: &Bl
"in_progress": tasks_in_progress, "in_progress": tasks_in_progress,
"blocked": stats.tasks_blocked, "blocked": stats.tasks_blocked,
"todo": tasks_todo, "todo": tasks_todo,
"dispatched": stats.dispatchable "dispatched": stats.dispatchable,
"complexity_dist": { "S": complexity_s, "M": complexity_m, "L": complexity_l },
"rework_count": rework_count,
"avg_rounds_M": avg_rounds_m
}, },
"blocked_reasons": blocked_reasons, "blocked_reasons": blocked_reasons,
// 当前任务状态快照,供下次 delta 计算使用 // 当前任务状态快照,供下次 delta 计算使用
@ -2158,3 +2376,181 @@ pub fn archive_project_docs(project_path: String) -> Result<ArchiveResult, Strin
archive_dir: archive_dir.to_string_lossy().to_string(), archive_dir: archive_dir.to_string_lossy().to_string(),
}) })
} }
// ── 测试 ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
use std::fs;
/// 建立最小 schema 的 in-memory DB仅包含 collect_flywheel_notes_inner 所需表。
fn setup_db() -> Connection {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE project_profiles (
id TEXT PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE project_workspaces (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL,
win_path TEXT,
wsl_path TEXT
);
CREATE TABLE project_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL
);",
)
.unwrap();
conn
}
// ── collect_flywheel_notes_inner ─────────────────────────────────────────
#[test]
fn test_collect_notes_win_path_match() {
let conn = setup_db();
conn.execute("INSERT INTO project_profiles VALUES ('p1', 'MyProject')", []).unwrap();
conn.execute(
"INSERT INTO project_workspaces VALUES ('ws1', 'p1', 'C:/repos/my-project', NULL)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO project_notes VALUES (NULL, 'ws1', '【复盘】win path test', '2026-01-01T00:00:00Z')",
[],
)
.unwrap();
let notes = collect_flywheel_notes_inner(&conn, &["C:/repos/my-project".to_string()]);
assert_eq!(notes.len(), 1);
assert_eq!(notes[0].project_name, "MyProject");
assert!(notes[0].content.contains("win path test"));
}
#[test]
fn test_collect_notes_wsl_path_match() {
let conn = setup_db();
conn.execute("INSERT INTO project_profiles VALUES ('p2', 'WslProject')", []).unwrap();
conn.execute(
"INSERT INTO project_workspaces VALUES ('ws2', 'p2', NULL, '/home/user/wsl-project')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO project_notes VALUES (NULL, 'ws2', '【复盘】wsl path test', '2026-01-02T00:00:00Z')",
[],
)
.unwrap();
let notes = collect_flywheel_notes_inner(&conn, &["/home/user/wsl-project".to_string()]);
assert_eq!(notes.len(), 1);
assert_eq!(notes[0].project_name, "WslProject");
assert!(notes[0].content.contains("wsl path test"));
}
#[test]
fn test_collect_notes_project_not_found() {
let conn = setup_db();
// 空库,路径不存在
let notes = collect_flywheel_notes_inner(&conn, &["/nonexistent/path".to_string()]);
assert!(notes.is_empty());
}
// ── get_flywheel_stats ───────────────────────────────────────────────────
fn write_usage(dir: &std::path::Path, json: serde_json::Value) {
let bp = dir.join(".blueprint");
fs::create_dir_all(&bp).unwrap();
fs::write(bp.join("usage.json"), serde_json::to_string_pretty(&json).unwrap()).unwrap();
}
#[test]
fn test_flywheel_stats_empty_paths() {
let stats = get_flywheel_stats(vec![]).unwrap();
assert_eq!(stats.total_projects_scanned, 0);
assert_eq!(stats.projects_with_data, 0);
assert!(stats.cohorts.is_empty());
assert!(stats.iteration_jumps.is_empty());
}
#[test]
fn test_flywheel_stats_normal_snapshot() {
let tmp = std::env::temp_dir().join("blueprint_test_normal_snapshot");
let _ = fs::remove_dir_all(&tmp);
write_usage(
&tmp,
serde_json::json!({
"project_name": "TestProject",
"snapshots": [{
"at": "2026-01-01T00:00:00Z",
"conventions_version": "1.2.0",
"iteration": 1,
"tasks": { "total": 10, "done": 6, "dispatched": 8, "blocked": 1 },
"blocked_reasons": ["依赖未就绪"],
"modules": { "stalled": [] }
}]
}),
);
let stats = get_flywheel_stats(vec![tmp.to_string_lossy().to_string()]).unwrap();
assert_eq!(stats.total_projects_scanned, 1);
assert_eq!(stats.projects_with_data, 1);
assert_eq!(stats.cohorts.len(), 1);
assert_eq!(stats.cohorts[0].conventions_version, "1.2.0");
assert_eq!(stats.cohorts[0].project_count, 1);
// completion_rate = 6/10 = 0.6,四舍五入到 3 位小数
assert!((stats.cohorts[0].completion_rate - 0.6).abs() < 1e-6);
assert_eq!(stats.top_blocked_reasons.len(), 1);
assert_eq!(stats.top_blocked_reasons[0].reason, "依赖未就绪");
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn test_flywheel_stats_iteration_jump() {
let tmp = std::env::temp_dir().join("blueprint_test_iteration_jump");
let _ = fs::remove_dir_all(&tmp);
write_usage(
&tmp,
serde_json::json!({
"project_name": "JumpProject",
"snapshots": [
{
"at": "2026-01-01T00:00:00Z",
"conventions_version": "1.0.0",
"iteration": 1,
"tasks": { "total": 10, "done": 8, "dispatched": 9, "blocked": 0 },
"blocked_reasons": [],
"modules": { "stalled": [] }
},
{
"at": "2026-02-01T00:00:00Z",
"conventions_version": "1.0.0",
"iteration": 2,
"tasks": { "total": 15, "done": 3, "dispatched": 10, "blocked": 0 },
"blocked_reasons": [],
"modules": { "stalled": [] }
}
]
}),
);
let stats = get_flywheel_stats(vec![tmp.to_string_lossy().to_string()]).unwrap();
assert_eq!(stats.iteration_jumps.len(), 1);
let jump = &stats.iteration_jumps[0];
assert_eq!(jump.project, "JumpProject");
assert_eq!(jump.from_iteration, 1);
assert_eq!(jump.to_iteration, 2);
// rate_before = 8/10 = 0.8, rate_after = 3/15 = 0.2
assert!((jump.rate_before - 0.8).abs() < 1e-6);
assert!((jump.rate_after - 0.2).abs() < 1e-6);
let _ = fs::remove_dir_all(&tmp);
}
}

View File

@ -28,6 +28,12 @@ pub fn apply_blueprint_buff(
crate::commands::blueprint::sync_blueprint_rules(path.clone()) crate::commands::blueprint::sync_blueprint_rules(path.clone())
.map_err(|e| format!("注入蓝图规则失败: {e}"))?; .map_err(|e| format!("注入蓝图规则失败: {e}"))?;
// 独立项目自动创建产品组(含 MCP 注入)
if let Err(e) = crate::commands::groups::ensure_standalone_group(&project_id) {
// 非致命,不阻断 Buff 应用
crate::db::log_event("buff", "warn", &format!("独立产品组创建失败: {e}"), None);
}
// 写入 DB // 写入 DB
let conn = crate::db::pool().get().map_err(|e| e.to_string())?; let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
conn.execute( conn.execute(

View File

@ -109,6 +109,58 @@ pub fn get_group_maps() -> Result<Vec<ProjectGroupMap>, String> {
result result
} }
/// 确保独立项目有默认产品组。
/// 若项目已属于某产品组,直接返回;否则创建 "{project_name} (独立项目)" 组,加入成员并注入 MCP。
pub fn ensure_standalone_group(project_id: &str) -> Result<(), String> {
let conn = pool().get().map_err(|e| e.to_string())?;
// 已在某个组内,跳过
let already_in_group: bool = conn
.query_row(
"SELECT COUNT(*) FROM project_group_map WHERE project_id = ?1",
params![project_id],
|row| row.get::<_, i64>(0),
)
.unwrap_or(0)
> 0;
if already_in_group {
return Ok(());
}
// 查项目显示名和所属空间
let (project_name, space_id): (String, Option<String>) = conn
.query_row(
"SELECT pp.name, pw.space_id FROM project_workspaces pw
JOIN project_profiles pp ON pp.id = pw.profile_id
WHERE pw.id = ?1",
params![project_id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.map_err(|_| format!("项目 {project_id} 不存在或未关联 profile"))?;
let group_id = uuid::Uuid::new_v4().to_string();
let group_name = format!("{project_name} (独立项目)");
conn.execute(
"INSERT INTO product_groups (id, name, space_id) VALUES (?1, ?2, ?3)",
params![group_id, group_name, space_id],
)
.map_err(|e| e.to_string())?;
conn.execute(
"INSERT OR REPLACE INTO project_group_map (project_id, group_id) VALUES (?1, ?2)",
params![project_id, group_id],
)
.map_err(|e| e.to_string())?;
if let Err(e) = mcp_inject::inject_project_mcp(project_id, &group_id) {
let ctx = serde_json::json!({"project_id": project_id, "group_id": &group_id}).to_string();
log_event("mcp_inject", "warn", &format!("独立项目 MCP 注入失败: {e}"), Some(&ctx));
}
Ok(())
}
#[tauri::command] #[tauri::command]
pub fn add_group_member(project_id: String, group_id: String, role: Option<String>) -> Result<Option<String>, String> { pub fn add_group_member(project_id: String, group_id: String, role: Option<String>) -> Result<Option<String>, String> {
let conn = pool().get().map_err(|e| e.to_string())?; let conn = pool().get().map_err(|e| e.to_string())?;

View File

@ -47,6 +47,14 @@ pub struct ActiveTaskCard {
pub blocked_reason: Option<String>, pub blocked_reason: Option<String>,
} }
#[derive(Debug, Serialize)]
pub struct ReviewCoverage {
/// 已完成的 M/L 复杂度任务数
pub completed_ml_tasks: u32,
/// 该项目已有的复盘笔记数
pub note_count: u32,
}
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct MentorContext { pub struct MentorContext {
pub project_id: String, pub project_id: String,
@ -55,6 +63,8 @@ pub struct MentorContext {
pub blueprint_summary: Option<BlueprintSummary>, pub blueprint_summary: Option<BlueprintSummary>,
pub active_tasks: Vec<ActiveTaskCard>, pub active_tasks: Vec<ActiveTaskCard>,
pub recent_errors: Vec<RuntimeLogEntry>, pub recent_errors: Vec<RuntimeLogEntry>,
/// 复盘笔记覆盖情况
pub review_coverage: Option<ReviewCoverage>,
/// 格式化好的 Markdown可直接复制给 Claude /// 格式化好的 Markdown可直接复制给 Claude
pub mentor_markdown: String, pub mentor_markdown: String,
} }
@ -169,6 +179,30 @@ pub fn get_mentor_context(project_id: String) -> Result<MentorContext, String> {
// 5b. AI 笔记(最近 5 条,作为跨会话记忆) // 5b. AI 笔记(最近 5 条,作为跨会话记忆)
let ai_notes = read_ai_notes(&conn, &project_id); let ai_notes = read_ai_notes(&conn, &project_id);
// 5c. 复盘笔记覆盖率
let review_coverage = {
let completed_ml_tasks = match (is_wsl, &wsl_linux_path, &wsl_distro) {
(true, Some(linux_path), Some(distro)) => {
let unc_root = linux_to_unc(linux_path, distro);
count_done_ml_tasks(&unc_root)
}
(true, _, _) => 0,
(false, _, _) => {
win_path.as_deref().filter(|p| !p.is_empty()).map(Path::new)
.map(count_done_ml_tasks)
.unwrap_or(0)
}
};
let note_count = conn
.query_row(
"SELECT COUNT(*) FROM project_notes WHERE project_id = ?1",
params![&project_id],
|r| r.get::<_, u32>(0),
)
.unwrap_or(0);
Some(ReviewCoverage { completed_ml_tasks, note_count })
};
// 6. 生成 mentor_markdown // 6. 生成 mentor_markdown
let mentor_markdown = build_mentor_markdown( let mentor_markdown = build_mentor_markdown(
&project_id, &project_id,
@ -178,6 +212,7 @@ pub fn get_mentor_context(project_id: String) -> Result<MentorContext, String> {
&active_tasks, &active_tasks,
&recent_errors, &recent_errors,
&ai_notes, &ai_notes,
review_coverage.as_ref(),
); );
Ok(MentorContext { Ok(MentorContext {
@ -187,6 +222,7 @@ pub fn get_mentor_context(project_id: String) -> Result<MentorContext, String> {
blueprint_summary, blueprint_summary,
active_tasks, active_tasks,
recent_errors, recent_errors,
review_coverage,
mentor_markdown, mentor_markdown,
}) })
} }
@ -406,6 +442,23 @@ fn parse_blueprint_summary(root: &Path) -> Option<BlueprintSummary> {
} }
} }
/// 统计蓝图中已完成的 M/L 复杂度任务数
fn count_done_ml_tasks(root: &Path) -> u32 {
match crate::commands::blueprint::get_blueprint(root.to_string_lossy().to_string()) {
Ok(Some(data)) => data
.manifest
.modules
.iter()
.flat_map(|m| m.tasks.iter())
.filter(|t| {
t.status == "done"
&& matches!(t.complexity.as_deref(), Some("M") | Some("L"))
})
.count() as u32,
_ => 0,
}
}
/// 从项目根路径读取蓝图,返回所有进行中/阻塞的任务卡 /// 从项目根路径读取蓝图,返回所有进行中/阻塞的任务卡
fn read_active_task_cards(root: &Path) -> Vec<ActiveTaskCard> { fn read_active_task_cards(root: &Path) -> Vec<ActiveTaskCard> {
match crate::commands::blueprint::get_blueprint(root.to_string_lossy().to_string()) { match crate::commands::blueprint::get_blueprint(root.to_string_lossy().to_string()) {
@ -491,6 +544,7 @@ fn build_mentor_markdown(
active_tasks: &[ActiveTaskCard], active_tasks: &[ActiveTaskCard],
errors: &[RuntimeLogEntry], errors: &[RuntimeLogEntry],
ai_notes: &[ProjectNote], ai_notes: &[ProjectNote],
review_coverage: Option<&ReviewCoverage>,
) -> String { ) -> String {
let mut md = String::new(); let mut md = String::new();
@ -574,6 +628,24 @@ fn build_mentor_markdown(
} }
} }
// ── 复盘笔记覆盖率 ────────────────────────────────────────────────────────
if let Some(cov) = review_coverage {
md.push_str("\n## 复盘笔记覆盖率\n\n");
let gap = cov.completed_ml_tasks.saturating_sub(cov.note_count);
md.push_str(&format!(
"| 已完成 M/L 任务 | 复盘笔记数 | 缺口 |\n|------|------|------|\n| {} | {} | {} |\n",
cov.completed_ml_tasks, cov.note_count, gap
));
if gap > 0 {
md.push_str(&format!(
"\n> ⚠️ 有 {} 个 M/L 任务尚无复盘笔记。完成本次任务后请通过 `append_project_note` 补写,格式见 CONVENTIONS.md §交互质量复盘笔记。\n",
gap
));
} else if cov.completed_ml_tasks > 0 {
md.push_str("\n> ✅ 所有已完成 M/L 任务均有复盘笔记。\n");
}
}
// ── AI 笔记(跨会话记忆)──────────────────────────────────────────────────── // ── AI 笔记(跨会话记忆)────────────────────────────────────────────────────
if !ai_notes.is_empty() { if !ai_notes.is_empty() {
md.push_str("\n## AI 笔记(历史观察)\n\n"); md.push_str("\n## AI 笔记(历史观察)\n\n");

View File

@ -29,6 +29,9 @@ pub struct ProjectTemplate {
pub created_at: Option<String>, pub created_at: Option<String>,
#[serde(default)] #[serde(default)]
pub files: Vec<TemplateFile>, pub files: Vec<TemplateFile>,
/// JSON 数组,格式:[{label, description, initCommands}]
/// 存在时前端显示下拉让用户选择初始化方式
pub variants: Option<String>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -64,7 +67,7 @@ pub fn list_templates() -> Result<Vec<ProjectTemplate>, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?; let conn = db::pool().get().map_err(|e| e.to_string())?;
let mut stmt = conn let mut stmt = conn
.prepare( .prepare(
"SELECT id, name, description, platform, tech_stack, init_commands, is_builtin, created_at "SELECT id, name, description, platform, tech_stack, init_commands, is_builtin, created_at, variants
FROM project_templates ORDER BY is_builtin DESC, name", FROM project_templates ORDER BY is_builtin DESC, name",
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@ -81,6 +84,7 @@ pub fn list_templates() -> Result<Vec<ProjectTemplate>, String> {
is_builtin: row.get::<_, i64>(6)? == 1, is_builtin: row.get::<_, i64>(6)? == 1,
created_at: row.get(7)?, created_at: row.get(7)?,
files: vec![], files: vec![],
variants: row.get(8)?,
}) })
}) })
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
@ -96,7 +100,7 @@ pub fn get_template(id: String) -> Result<ProjectTemplate, String> {
let mut tmpl = conn let mut tmpl = conn
.query_row( .query_row(
"SELECT id, name, description, platform, tech_stack, init_commands, is_builtin, created_at "SELECT id, name, description, platform, tech_stack, init_commands, is_builtin, created_at, variants
FROM project_templates WHERE id = ?1", FROM project_templates WHERE id = ?1",
params![id], params![id],
|row| { |row| {
@ -110,6 +114,7 @@ pub fn get_template(id: String) -> Result<ProjectTemplate, String> {
is_builtin: row.get::<_, i64>(6)? == 1, is_builtin: row.get::<_, i64>(6)? == 1,
created_at: row.get(7)?, created_at: row.get(7)?,
files: vec![], files: vec![],
variants: row.get(8)?,
}) })
}, },
) )
@ -194,10 +199,15 @@ pub fn create_project_from_template(
project_name: String, project_name: String,
platform: String, platform: String,
space_id: Option<String>, space_id: Option<String>,
variant_init_commands: Option<String>,
) -> Result<CreateProjectResult, String> { ) -> Result<CreateProjectResult, String> {
use tauri::Emitter; use tauri::Emitter;
let tmpl = get_template(template_id)?; let mut tmpl = get_template(template_id)?;
// 如果用户选择了 variant用其 initCommands 覆盖模板默认值
if let Some(v) = variant_init_commands {
tmpl.init_commands = Some(v);
}
let mut logs: Vec<String> = Vec::new(); let mut logs: Vec<String> = Vec::new();
// 辅助:同时 emit 事件 + 记录到 logs // 辅助:同时 emit 事件 + 记录到 logs
@ -386,6 +396,8 @@ pub fn seed_builtin_templates() {
tech_stack: &'static str, tech_stack: &'static str,
init_commands: &'static str, init_commands: &'static str,
files: Vec<(&'static str, &'static str)>, files: Vec<(&'static str, &'static str)>,
/// JSON 字符串,存在时前端显示下拉选择初始化方式
variants: Option<&'static str>,
} }
let templates = vec![ let templates = vec![
@ -396,6 +408,7 @@ pub fn seed_builtin_templates() {
platform: "both", platform: "both",
tech_stack: "Node,pnpm", tech_stack: "Node,pnpm",
init_commands: "pnpm install", init_commands: "pnpm install",
variants: None,
files: vec![ files: vec![
("package.json", r#"{ ("package.json", r#"{
"name": "{{project_name_kebab}}", "name": "{{project_name_kebab}}",
@ -428,6 +441,7 @@ pub fn seed_builtin_templates() {
platform: "both", platform: "both",
tech_stack: "Node,pnpm", tech_stack: "Node,pnpm",
init_commands: "pnpm install", init_commands: "pnpm install",
variants: None,
files: vec![ files: vec![
("package.json", r#"{ ("package.json", r#"{
"name": "{{project_name_kebab}}", "name": "{{project_name_kebab}}",
@ -451,6 +465,7 @@ pub fn seed_builtin_templates() {
platform: "both", platform: "both",
tech_stack: "Rust", tech_stack: "Rust",
init_commands: "cargo build", init_commands: "cargo build",
variants: None,
files: vec![ files: vec![
("Cargo.toml", r#"[package] ("Cargo.toml", r#"[package]
name = "{{project_name_kebab}}" name = "{{project_name_kebab}}"
@ -470,6 +485,7 @@ edition = "2021"
platform: "both", platform: "both",
tech_stack: "", tech_stack: "",
init_commands: "", init_commands: "",
variants: None,
files: vec![], files: vec![],
}, },
Tpl { Tpl {
@ -479,6 +495,7 @@ edition = "2021"
platform: "both", platform: "both",
tech_stack: "Rust,Node,pnpm,React,TypeScript,Tauri", tech_stack: "Rust,Node,pnpm,React,TypeScript,Tauri",
init_commands: "pnpm install", init_commands: "pnpm install",
variants: None,
files: vec![ files: vec![
("package.json", r#"{ ("package.json", r#"{
"name": "{{project_name_kebab}}", "name": "{{project_name_kebab}}",
@ -658,13 +675,28 @@ pub fn run() {
(".gitignore", "node_modules/\ndist/\n.env\n.env.*\n!.env.example\nsrc-tauri/target/\nsrc-tauri/gen/\n"), (".gitignore", "node_modules/\ndist/\n.env\n.env.*\n!.env.example\nsrc-tauri/target/\nsrc-tauri/gen/\n"),
], ],
}, },
Tpl {
id: "builtin-expo",
name: "Expo",
description: "React Native 移动端 AppAndroid / iOS。TypeScript + Expo SDK适合游戏、工具、内容类手机应用。选择下方模板变体来确定初始化方式。",
platform: "both",
tech_stack: "React Native,TypeScript,Expo",
init_commands: "pnpm create expo-app@latest . --template blank-typescript",
variants: Some(r#"[
{"label":"blank-typescript推荐","description":"最小化 TypeScript 起步,适合游戏类、工具类、独立 App","initCommands":"pnpm create expo-app@latest . --template blank-typescript"},
{"label":"tabs底部标签导航","description":"内置底部 Tab 栏 + Expo Router适合多页面内容型 App","initCommands":"pnpm create expo-app@latest . --template tabs"},
{"label":"bare裸原生配置","description":"完整原生 Expo 配置,可深度定制原生模块,适合进阶项目","initCommands":"pnpm create expo-app@latest . --template bare-minimum"},
{"label":"默认(向导模式)","description":"运行官方交互式向导,在终端中选择模板类型","initCommands":"pnpm create expo-app@latest ."}
]"#),
files: vec![],
},
]; ];
for t in &templates { for t in &templates {
let _ = conn.execute( let _ = conn.execute(
"INSERT OR IGNORE INTO project_templates "INSERT OR IGNORE INTO project_templates
(id, name, description, platform, tech_stack, init_commands, is_builtin) (id, name, description, platform, tech_stack, init_commands, is_builtin, variants)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7)",
params![ params![
t.id, t.id,
t.name, t.name,
@ -672,8 +704,14 @@ pub fn run() {
t.platform, t.platform,
if t.tech_stack.is_empty() { None } else { Some(t.tech_stack) }, if t.tech_stack.is_empty() { None } else { Some(t.tech_stack) },
if t.init_commands.is_empty() { None } else { Some(t.init_commands) }, if t.init_commands.is_empty() { None } else { Some(t.init_commands) },
t.variants,
], ],
); );
// 已存在的模板也补充 variantsINSERT OR IGNORE 不更新已有行)
let _ = conn.execute(
"UPDATE project_templates SET variants = ?1 WHERE id = ?2 AND is_builtin = 1",
params![t.variants, t.id],
);
for (path, content) in &t.files { for (path, content) in &t.files {
let file_id = format!("{}-{}", t.id, path.replace('/', "-")); let file_id = format!("{}-{}", t.id, path.replace('/', "-"));

View File

@ -27,6 +27,11 @@ pub fn pool() -> &'static DbPool {
POOL.get().expect("db not initialized") POOL.get().expect("db not initialized")
} }
/// pool 未初始化时返回 None测试环境中安全调用。
pub(crate) fn try_pool() -> Option<&'static DbPool> {
POOL.get()
}
fn migrate(conn: &rusqlite::Connection) { fn migrate(conn: &rusqlite::Connection) {
conn.execute_batch( conn.execute_batch(
" "
@ -527,6 +532,11 @@ fn migrate(conn: &rusqlite::Connection) {
); );
").expect("create global_tools table"); ").expect("create global_tools table");
// 增量迁移:模板 variants 列(多初始化方式下拉选项)
let _ = conn.execute_batch(
"ALTER TABLE project_templates ADD COLUMN variants TEXT;"
);
// 增量迁移:项目历史快照(每日一条,供 Claude 做趋势分析) // 增量迁移:项目历史快照(每日一条,供 Claude 做趋势分析)
conn.execute_batch(" conn.execute_batch("
CREATE TABLE IF NOT EXISTS project_snapshots ( CREATE TABLE IF NOT EXISTS project_snapshots (

View File

@ -88,8 +88,8 @@ pub fn inject_project_mcp(project_id: &str, group_id: &str) -> Result<(), String
let key = format!("{}{}", MCP_KEY_PREFIX, group_id); let key = format!("{}{}", MCP_KEY_PREFIX, group_id);
servers[&key] = json!({ servers[&key] = json!({
"type": "http", "type": "sse",
"url": format!("http://localhost:{}/mcp/group/{}", port, group_id) "url": format!("http://127.0.0.1:{}/mcp/group/{}", port, group_id)
}); });
write_json(&path, &settings).inspect(|_| { write_json(&path, &settings).inspect(|_| {

View File

@ -1,16 +1,24 @@
use axum::{ use axum::{
extract::Path, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
response::{IntoResponse, Response}, response::{
sse::{Event, KeepAlive, Sse},
IntoResponse, Response,
},
routing::{get, post}, routing::{get, post},
Json, Router, Json, Router,
}; };
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::net::SocketAddr; use std::{collections::HashMap, convert::Infallible, net::SocketAddr, sync::Arc};
use tokio::sync::{mpsc, Mutex};
use tokio_stream::{wrappers::ReceiverStream, StreamExt as _};
use crate::{db, db::log_event}; use crate::{db, db::log_event};
/// SSE 会话表session_id → 发送端((event_name, data)
type Sessions = Arc<Mutex<HashMap<String, mpsc::Sender<(String, String)>>>>;
/// 从 settings 表读取 MCP 端口,默认 27190 /// 从 settings 表读取 MCP 端口,默认 27190
pub fn read_port() -> u16 { pub fn read_port() -> u16 {
db::pool() db::pool()
@ -28,16 +36,22 @@ pub fn read_port() -> u16 {
.unwrap_or(27190) .unwrap_or(27190)
} }
/// 启动 MCP HTTP 服务(Streamable HTTP transport /// 启动 MCP HTTP 服务(同时支持 Streamable HTTP 和 SSE 两种传输
pub async fn start(port: u16) { pub async fn start(port: u16) {
let sessions: Sessions = Arc::new(Mutex::new(HashMap::new()));
let app = Router::new() let app = Router::new()
.route("/health", get(health)) .route("/health", get(health))
.route("/mcp/group/:group_id", post(handle_mcp)); // Streamable HTTPPOST— 保持向后兼容Python 直调仍可用
.route("/mcp/group/:group_id", post(handle_mcp).get(handle_sse_connect))
// SSE 消息端点:客户端通过此 URL 发送 JSON-RPC响应经 SSE 推回
.route("/mcp/group/:group_id/messages", post(handle_sse_message))
.with_state(sessions);
let addr = SocketAddr::from(([127, 0, 0, 1], port)); let addr = SocketAddr::from(([0, 0, 0, 0], port));
match tokio::net::TcpListener::bind(addr).await { match tokio::net::TcpListener::bind(addr).await {
Ok(listener) => { Ok(listener) => {
log_event("mcp_server", "info", &format!("MCP Server 已启动,端口 {port}"), None); log_event("mcp_server", "info", &format!("MCP Server 已启动,端口 {port}HTTP + SSE"), None);
if let Err(e) = axum::serve(listener, app).await { if let Err(e) = axum::serve(listener, app).await {
log_event("mcp_server", "error", &format!("Server 异常退出: {e}"), None); log_event("mcp_server", "error", &format!("Server 异常退出: {e}"), None);
} }
@ -62,36 +76,108 @@ struct RpcRequest {
params: Option<Value>, params: Option<Value>,
} }
async fn handle_mcp(Path(group_id): Path<String>, Json(req): Json<RpcRequest>) -> Response { #[derive(Deserialize)]
// MCP 通知(无 id→ 202 Accepted无需响应体 struct SessionQuery {
if req.id.is_none() { session_id: String,
return StatusCode::ACCEPTED.into_response();
} }
let id = req.id.clone(); // ── 共享 RPC 处理逻辑 ─────────────────────────────────────────────────────────
let result = match req.method.as_str() { async fn process_rpc(group_id: &str, req: &RpcRequest) -> Result<Value, String> {
"initialize" => json!({ match req.method.as_str() {
"initialize" => Ok(json!({
"protocolVersion": "2024-11-05", "protocolVersion": "2024-11-05",
"capabilities": { "tools": {} }, "capabilities": { "tools": {} },
"serverInfo": { "serverInfo": {
"name": "炼境", "name": "炼境",
"version": env!("CARGO_PKG_VERSION") "version": env!("CARGO_PKG_VERSION")
} }
}), })),
"tools/list" => tools_list_result(), "tools/list" => Ok(tools_list_result()),
"tools/call" => tools_call(&group_id, req.params.as_ref()).await, "tools/call" => Ok(tools_call(group_id, req.params.as_ref()).await),
_ => { _ => Err(format!("Method not found: {}", req.method)),
return Json(json!({
"jsonrpc": "2.0",
"id": id,
"error": { "code": -32601, "message": "Method not found" }
}))
.into_response();
} }
}
// ── Streamable HTTPPOST— 向后兼容Python 直调走此路径 ─────────────────────
async fn handle_mcp(Path(group_id): Path<String>, Json(req): Json<RpcRequest>) -> Response {
// MCP 通知(无 id→ 202 Accepted
if req.id.is_none() {
return StatusCode::ACCEPTED.into_response();
}
let id = req.id.clone();
match process_rpc(&group_id, &req).await {
Ok(result) => Json(json!({ "jsonrpc": "2.0", "id": id, "result": result })).into_response(),
Err(msg) => Json(json!({
"jsonrpc": "2.0", "id": id,
"error": { "code": -32601, "message": msg }
}))
.into_response(),
}
}
// ── SSE 传输GET + POST /messages─────────────────────────────────────────
/// GET /mcp/group/:group_id
/// Claude Code 发起 SSE 握手,建立长连接,首条事件告知后续 POST 的 URL。
async fn handle_sse_connect(
State(sessions): State<Sessions>,
Path(group_id): Path<String>,
) -> Sse<impl futures_core::Stream<Item = Result<Event, Infallible>>> {
let session_id = uuid::Uuid::new_v4().to_string();
let (tx, rx) = mpsc::channel::<(String, String)>(32);
// 第一条事件:告知客户端后续 POST 的 endpoint URL
let port = read_port();
let endpoint_uri = format!(
"http://127.0.0.1:{}/mcp/group/{}/messages?session_id={}",
port, group_id, session_id
);
let _ = tx.send(("endpoint".to_string(), endpoint_uri)).await;
sessions.lock().await.insert(session_id, tx);
let stream = ReceiverStream::new(rx)
.map(|(evt_name, data)| Ok::<Event, Infallible>(Event::default().event(evt_name).data(data)));
Sse::new(stream).keep_alive(KeepAlive::default())
}
/// POST /mcp/group/:group_id/messages?session_id=xxx
/// 客户端通过此 URL 发送 JSON-RPC 请求,响应经 SSE channel 推回。
async fn handle_sse_message(
State(sessions): State<Sessions>,
Path(group_id): Path<String>,
Query(query): Query<SessionQuery>,
Json(req): Json<RpcRequest>,
) -> Response {
if req.id.is_none() {
return StatusCode::ACCEPTED.into_response();
}
let id = req.id.clone();
let response_json = match process_rpc(&group_id, &req).await {
Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
Err(msg) => json!({
"jsonrpc": "2.0", "id": id,
"error": { "code": -32601, "message": msg }
}),
}; };
Json(json!({ "jsonrpc": "2.0", "id": id, "result": result })).into_response() let mut sessions_guard = sessions.lock().await;
if let Some(tx) = sessions_guard.get(&query.session_id) {
if tx
.send(("message".to_string(), response_json.to_string()))
.await
.is_err()
{
// 连接已关闭,惰性清理
sessions_guard.remove(&query.session_id);
}
}
StatusCode::ACCEPTED.into_response()
} }
// ── 工具定义 ────────────────────────────────────────────────────────────────── // ── 工具定义 ──────────────────────────────────────────────────────────────────

View File

@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { import {
listTemplates, createProjectFromTemplate, listTemplates, createProjectFromTemplate, TemplateVariant,
} from "../../lib/commands"; } from "../../lib/commands";
import { useUIStore } from "../../store/ui"; import { useUIStore } from "../../store/ui";
@ -36,6 +36,7 @@ export function NewProjectModal({ spaceId, onClose }: Props) {
// ── Step 2 ──────────────────────────────────────────────────────────────── // ── Step 2 ────────────────────────────────────────────────────────────────
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null); const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
const [selectedVariantIdx, setSelectedVariantIdx] = useState<number>(0);
// ── Step 3/4 ────────────────────────────────────────────────────────────── // ── Step 3/4 ──────────────────────────────────────────────────────────────
const [logs, setLogs] = useState<string[]>([]); const [logs, setLogs] = useState<string[]>([]);
@ -78,6 +79,12 @@ export function NewProjectModal({ spaceId, onClose }: Props) {
(t) => t.platform === "both" || t.platform === platform (t) => t.platform === "both" || t.platform === platform
); );
const selectedTemplate = filteredTemplates.find((t) => t.id === selectedTemplateId) ?? null;
const selectedVariants: TemplateVariant[] = (() => {
try { return selectedTemplate?.variants ? JSON.parse(selectedTemplate.variants) : []; }
catch { return []; }
})();
const projectFullPath = parentPath && projectName const projectFullPath = parentPath && projectName
? `${parentPath.replace(/[/\\]+$/, "")}\\${projectName}` ? `${parentPath.replace(/[/\\]+$/, "")}\\${projectName}`
: ""; : "";
@ -117,12 +124,16 @@ export function NewProjectModal({ spaceId, onClose }: Props) {
}); });
try { try {
const variantCmd = selectedVariants.length > 0
? selectedVariants[selectedVariantIdx]?.initCommands
: undefined;
await createProjectFromTemplate( await createProjectFromTemplate(
selectedTemplateId, selectedTemplateId,
parentPath, parentPath,
projectName.trim(), projectName.trim(),
platform, platform,
spaceId, spaceId,
variantCmd,
); );
qc.invalidateQueries({ queryKey: ["projects"] }); qc.invalidateQueries({ queryKey: ["projects"] });
setStep("done"); setStep("done");
@ -243,12 +254,18 @@ export function NewProjectModal({ spaceId, onClose }: Props) {
{filteredTemplates.length === 0 && ( {filteredTemplates.length === 0 && (
<p className="text-sm text-gray-400 text-center py-8"></p> <p className="text-sm text-gray-400 text-center py-8"></p>
)} )}
{filteredTemplates.map((t) => ( {filteredTemplates.map((t) => {
const isSelected = selectedTemplateId === t.id;
const variants: TemplateVariant[] = (() => {
try { return t.variants ? JSON.parse(t.variants) : []; }
catch { return []; }
})();
return (
<div key={t.id} className="space-y-2">
<button <button
key={t.id} onClick={() => { setSelectedTemplateId(t.id); setSelectedVariantIdx(0); }}
onClick={() => setSelectedTemplateId(t.id)}
className={`w-full text-left px-4 py-3 rounded-xl border transition-all ${ className={`w-full text-left px-4 py-3 rounded-xl border transition-all ${
selectedTemplateId === t.id isSelected
? "border-blue-500 bg-blue-50" ? "border-blue-500 bg-blue-50"
: "border-gray-200 hover:border-blue-200 hover:bg-gray-50" : "border-gray-200 hover:border-blue-200 hover:bg-gray-50"
}`} }`}
@ -278,13 +295,46 @@ export function NewProjectModal({ spaceId, onClose }: Props) {
))} ))}
</div> </div>
)} )}
{t.initCommands && ( {variants.length === 0 && t.initCommands && (
<p className="text-[11px] text-gray-300 mt-1 font-mono">$ {t.initCommands.split("\n")[0]}</p> <p className="text-[11px] text-gray-300 mt-1 font-mono">$ {t.initCommands.split("\n")[0]}</p>
)} )}
</button> </button>
{/* variant 下拉:仅在选中且有 variants 时展开 */}
{isSelected && variants.length > 0 && (
<div className="ml-3 border-l-2 border-blue-200 pl-3 space-y-1">
<p className="text-[10px] text-gray-400 font-medium mb-1.5"></p>
{variants.map((v, idx) => (
<button
key={idx}
onClick={(e) => { e.stopPropagation(); setSelectedVariantIdx(idx); }}
className={`w-full text-left px-3 py-2 rounded-lg border transition-all ${
selectedVariantIdx === idx
? "border-blue-400 bg-blue-50"
: "border-gray-100 hover:border-blue-200 hover:bg-gray-50"
}`}
>
<div className="flex items-center gap-1.5">
<span className={`w-3.5 h-3.5 rounded-full border flex-shrink-0 ${
selectedVariantIdx === idx
? "border-blue-500 bg-blue-500"
: "border-gray-300"
}`} />
<span className="text-xs font-medium text-gray-700">{v.label}</span>
</div>
{v.description && (
<p className="text-[11px] text-gray-400 mt-0.5 ml-5">{v.description}</p>
)}
<p className="text-[10px] text-gray-300 mt-0.5 ml-5 font-mono">$ {v.initCommands}</p>
</button>
))} ))}
</div> </div>
)} )}
</div>
);
})}
</div>
)}
{/* ── Step 3/4执行进度 ── */} {/* ── Step 3/4执行进度 ── */}
{(step === "executing" || step === "done") && ( {(step === "executing" || step === "done") && (

View File

@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { getMentorContext, getProjectNotes, type MentorContext, type ProjectNote } from "../../lib/commands"; import { getMentorContext, getProjectNotes, type MentorContext, type ProjectNote, type ReviewCoverage } from "../../lib/commands";
interface Props { interface Props {
projectId: string; projectId: string;
@ -151,6 +151,11 @@ export function MentorPanel({ projectId, projectName, onClose }: Props) {
)} )}
</section> </section>
{/* 复盘笔记覆盖率 */}
{ctx.review_coverage && (
<ReviewCoverageSection coverage={ctx.review_coverage} />
)}
{/* AI 笔记 */} {/* AI 笔记 */}
<section> <section>
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">AI </h3> <h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">AI </h3>
@ -210,6 +215,39 @@ export function MentorPanel({ projectId, projectName, onClose }: Props) {
// ── 辅助组件 ────────────────────────────────────────────────────────────────── // ── 辅助组件 ──────────────────────────────────────────────────────────────────
function ReviewCoverageSection({ coverage }: { coverage: ReviewCoverage }) {
const gap = coverage.completed_ml_tasks - coverage.note_count;
const hasGap = gap > 0;
return (
<section>
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2"></h3>
<div className="flex gap-2 mb-2">
<div className="flex-1 bg-gray-50 rounded-lg px-3 py-2 flex flex-col gap-0.5">
<span className="text-[11px] text-gray-400"> M/L </span>
<span className="text-sm font-semibold text-gray-800">{coverage.completed_ml_tasks}</span>
</div>
<div className="flex-1 bg-gray-50 rounded-lg px-3 py-2 flex flex-col gap-0.5">
<span className="text-[11px] text-gray-400"></span>
<span className="text-sm font-semibold text-gray-800">{coverage.note_count}</span>
</div>
<div className={`flex-1 rounded-lg px-3 py-2 flex flex-col gap-0.5 ${hasGap ? "bg-amber-50" : "bg-green-50"}`}>
<span className="text-[11px] text-gray-400"></span>
<span className={`text-sm font-semibold ${hasGap ? "text-amber-600" : "text-green-600"}`}>
{hasGap ? `${gap}` : "✓ 0"}
</span>
</div>
</div>
{hasGap && (
<div className="flex items-start gap-2 px-3 py-2 rounded-lg bg-amber-50 border border-amber-200 text-xs text-amber-700">
<span className="shrink-0 mt-0.5"></span>
<span> {gap} M/L MCP </span>
</div>
)}
</section>
);
}
function HealthRow({ function HealthRow({
ok, ok,
label, label,

View File

@ -679,6 +679,12 @@ export interface TemplateFile {
content: string; content: string;
} }
export interface TemplateVariant {
label: string;
description: string;
initCommands: string;
}
export interface ProjectTemplate { export interface ProjectTemplate {
id: string; id: string;
name: string; name: string;
@ -689,6 +695,8 @@ export interface ProjectTemplate {
isBuiltin: boolean; isBuiltin: boolean;
createdAt?: string; createdAt?: string;
files: TemplateFile[]; files: TemplateFile[];
/** JSON 字符串,解析后为 TemplateVariant[],存在时显示下拉选择 */
variants?: string;
} }
export interface SaveTemplateInput { export interface SaveTemplateInput {
@ -724,6 +732,7 @@ export const createProjectFromTemplate = (
projectName: string, projectName: string,
platform: string, platform: string,
spaceId?: string, spaceId?: string,
variantInitCommands?: string,
) => ) =>
invoke<CreateProjectResult>("create_project_from_template", { invoke<CreateProjectResult>("create_project_from_template", {
templateId, templateId,
@ -731,6 +740,7 @@ export const createProjectFromTemplate = (
projectName, projectName,
platform, platform,
spaceId, spaceId,
variantInitCommands,
}); });
export const registerAndLinkRepo = ( export const registerAndLinkRepo = (
@ -1063,6 +1073,12 @@ export interface ProjectNoteEntry {
created_at: string; created_at: string;
} }
export interface ExecutionQuality {
complexity_dist: Record<string, number>;
total_rework_count: number;
avg_rounds_m: number | null;
}
export interface FlywheelStats { export interface FlywheelStats {
cohorts: FlywheelCohort[]; cohorts: FlywheelCohort[];
top_blocked_reasons: BlockedReasonCount[]; top_blocked_reasons: BlockedReasonCount[];
@ -1078,6 +1094,8 @@ export interface FlywheelStats {
projects_with_data: number; projects_with_data: number;
/** 跨项目复盘笔记(最近 50 条) */ /** 跨项目复盘笔记(最近 50 条) */
project_notes: ProjectNoteEntry[]; project_notes: ProjectNoteEntry[];
/** 跨项目执行质量指标(阶段二扩展) */
execution_quality: ExecutionQuality;
} }
export const getFlywheelStats = (projectPaths: string[]) => export const getFlywheelStats = (projectPaths: string[]) =>
@ -1127,12 +1145,18 @@ export interface RuntimeLogEntry {
message: string; message: string;
} }
export interface ReviewCoverage {
completed_ml_tasks: number;
note_count: number;
}
export interface MentorContext { export interface MentorContext {
project_id: string; project_id: string;
project_name: string; project_name: string;
health: ProjectHealth; health: ProjectHealth;
blueprint_summary?: BlueprintSummary; blueprint_summary?: BlueprintSummary;
recent_errors: RuntimeLogEntry[]; recent_errors: RuntimeLogEntry[];
review_coverage?: ReviewCoverage;
mentor_markdown: string; mentor_markdown: string;
} }