diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index 440701f..b895320 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -6,7 +6,7 @@ description: > 成为每个项目的贴身导师。炼境本身亦被纳入其中, 形成可自我进化的开发智能闭环。 iteration: 1 -updated: 2026-04-11 +updated: 2026-04-13 @@ -276,7 +276,7 @@ modules: name: 飞轮智能成长 area: backend status: in_progress - progress: 33 + progress: 86 position: [1750, 650] edges: diff --git a/.blueprint/modules/flywheel-intelligence.md b/.blueprint/modules/flywheel-intelligence.md index e61f57f..f2028c0 100644 --- a/.blueprint/modules/flywheel-intelligence.md +++ b/.blueprint/modules/flywheel-intelligence.md @@ -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 - depends: flywheel-intelligence(阶段二启动前完成,保障数据关键路径) - files: src-tauri/src/commands/blueprint.rs @@ -47,11 +63,21 @@ --- -### 阶段二:usage.json 扩展采集维度 -- status: todo +### ✅ MCP Server 支持 SSE 传输 +- 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 - 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 预估"对比 阶段二详细设计: diff --git a/.blueprint/usage.json b/.blueprint/usage.json index 0699fa2..8f0b0b8 100644 --- a/.blueprint/usage.json +++ b/.blueprint/usage.json @@ -903,6 +903,192 @@ "todo": 0, "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 · 后端聚合 command(MentorContext)", + "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 + } } ] } \ No newline at end of file diff --git a/.claude/skills/retro-catchup/SKILL.md b/.claude/skills/retro-catchup/SKILL.md new file mode 100644 index 0000000..871c5f9 --- /dev/null +++ b/.claude/skills/retro-catchup/SKILL.md @@ -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 -- ` 查看相关文件的提交历史 +- 若提交数 > 1,执行 `git diff ^.. -- ` 了解变更量 + +**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 的情况) + +## 原则 + +- 笔记头部必须标注 `【复盘·重建】` 与 `备注: 此笔记为事后重建`,与实时笔记区分 +- 宁可跳过,不要捏造。证据不足时注明"信息不足,无法推断" +- 每张卡独立处理,写入失败不影响后续 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 39539d8..cced25b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -885,6 +885,7 @@ dependencies = [ "axum", "base64 0.22.1", "chrono", + "futures-core", "git2", "once_cell", "open", @@ -904,6 +905,7 @@ dependencies = [ "tauri-plugin-single-instance", "tauri-plugin-updater", "tokio", + "tokio-stream", "ureq", "urlencoding", "uuid", @@ -5188,6 +5190,17 @@ dependencies = [ "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]] name = "tokio-util" version = "0.7.18" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b4d62ce..bcefa57 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -37,6 +37,8 @@ tauri-plugin-process = "2" tauri-plugin-single-instance = "2" axum = "0.7" tokio = { version = "1", features = ["net", "time"] } +tokio-stream = "0.1" +futures-core = "0.3" ureq = { version = "2", features = ["json"] } open = "5" base64 = "0.22" diff --git a/src-tauri/src/commands/blueprint.rs b/src-tauri/src/commands/blueprint.rs index bfa4352..219f0cb 100644 --- a/src-tauri/src/commands/blueprint.rs +++ b/src-tauri/src/commands/blueprint.rs @@ -1388,6 +1388,18 @@ pub struct FlywheelStats { pub projects_with_data: u32, /// 跨项目复盘笔记(最近 50 条,按时间倒序) pub project_notes: Vec, + /// 跨项目执行质量指标(从各项目 usage.json 最新快照聚合) + pub execution_quality: ExecutionQuality, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ExecutionQuality { + /// 跨项目任务复杂度分布 + pub complexity_dist: std::collections::HashMap, + /// 跨项目总返工次数 + pub total_rework_count: u32, + /// M 级任务平均实际轮数(所有项目加权平均) + pub avg_rounds_m: Option, } /// 内部:单项目最新快照的指标摘要 @@ -1401,6 +1413,12 @@ struct ProjectSnap { #[tauri::command] pub fn get_flywheel_stats(project_paths: Vec) -> Result { + // 去重:同一项目可能出现在多个产品组中,路径重复会导致指标重复计数 + let project_paths: Vec = { + let mut seen = std::collections::HashSet::new(); + project_paths.into_iter().filter(|p| seen.insert(p.clone())).collect() + }; + let now = chrono::Utc::now(); let thirty_days_ago = now - chrono::Duration::days(30); let sixty_days_ago = now - chrono::Duration::days(60); @@ -1417,6 +1435,11 @@ pub fn get_flywheel_stats(project_paths: Vec) -> Result = Vec::new(); let mut iteration_jumps: Vec = Vec::new(); let mut stalled_modules: Vec = Vec::new(); + // 执行质量聚合 + let mut agg_complexity: std::collections::HashMap = 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 { total_scanned += 1; @@ -1534,6 +1557,22 @@ pub fn get_flywheel_stats(project_paths: Vec) -> Result) -> Result 0 { + Some(((agg_m_rounds_sum / agg_m_rounds_count as f64) * 10.0).round() / 10.0) + } else { + None + }; + Ok(FlywheelStats { cohorts, top_blocked_reasons, @@ -1599,6 +1644,11 @@ pub fn get_flywheel_stats(project_paths: Vec) -> Result) -> Result Vec { - 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, 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 { let mut notes: Vec = Vec::new(); for path_str in project_paths { @@ -1783,6 +1841,24 @@ pub fn generate_muhe_prompt(stats: FlywheelStats) -> String { .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() { "(暂无复盘笔记——Claude 尚未在执行 M/L 任务后写入笔记)".to_string() @@ -1834,6 +1910,13 @@ pub fn generate_muhe_prompt(stats: FlywheelStats) -> String { ### 停滞模块(in_progress 但无可推进任务,格式:项目名/模块id) {stalled_modules_summary} +### 执行质量指标(跨项目聚合,来自 usage.json 阶段二扩展) +- 任务复杂度分布:{complexity_summary} +- M 级任务平均实际轮数:{rounds_summary} +- 跨项目总返工次数:{rework_summary} + +> 上述指标从各项目复盘笔记解析并写入 usage.json,用于量化执行效率变化趋势。 + ### 复盘笔记(跨项目 Claude 执行记录,共 {notes_total} 条) > 笔记由 Claude 在完成 M/L 任务后通过 MCP 写入,结构化格式为【复盘】开头。 @@ -1864,6 +1947,7 @@ pub fn generate_muhe_prompt(stats: FlywheelStats) -> String { 2. 相同踩坑在多个项目重复出现了吗?根因是什么? 3. 有哪些有效策略已经在多个任务中奏效,可以固化为规范? 4. 有哪些遗留风险被多次提及,说明系统存在尚未解决的脆弱点? +5. M 级任务的实际轮数 vs 预估轮数偏差如何?是否存在系统性低估? 输出格式: ``` @@ -1907,6 +1991,9 @@ pub fn generate_muhe_prompt(stats: FlywheelStats) -> String { ai_stale_summary = ai_stale_summary, iteration_summary = iteration_summary, stalled_modules_summary = stalled_modules_summary, + complexity_summary = complexity_summary, + rounds_summary = rounds_summary, + rework_summary = rework_summary, notes_total = notes_total, notes_summary = notes_summary, conventions_version = effective_conventions_version(), @@ -1916,6 +2003,119 @@ pub fn generate_muhe_prompt(stats: FlywheelStats) -> String { // ── 飞轮数据层:usage.json 快照 ────────────────────────────────────────────── +/// 从项目的 project_notes 解析返工次数和 M 级任务平均实际轮数。 +/// 返回 (rework_count, avg_rounds_m),avg_rounds_m 为 None 表示无 M 级复盘数据。 +fn parse_notes_metrics(bp_dir: &Path) -> (u32, Option) { + 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 = 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 = 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::() + .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 追加一条每日飞轮快照。 /// 节流:同一 UTC 日期只追加一次;写入失败静默忽略(不影响主流程)。 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_todo = 0u32; let mut blocked_reasons: Vec = Vec::new(); + let mut complexity_s = 0u32; + let mut complexity_m = 0u32; + let mut complexity_l = 0u32; for module in &manifest.modules { 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() { "in_progress" => tasks_in_progress += 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 可推进) let stalled_modules: Vec = manifest.modules.iter() .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, "blocked": stats.tasks_blocked, "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, // 当前任务状态快照,供下次 delta 计算使用 @@ -2158,3 +2376,181 @@ pub fn archive_project_docs(project_path: String) -> Result 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); + } +} diff --git a/src-tauri/src/commands/buff.rs b/src-tauri/src/commands/buff.rs index a384f63..c829e73 100644 --- a/src-tauri/src/commands/buff.rs +++ b/src-tauri/src/commands/buff.rs @@ -28,6 +28,12 @@ pub fn apply_blueprint_buff( crate::commands::blueprint::sync_blueprint_rules(path.clone()) .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 let conn = crate::db::pool().get().map_err(|e| e.to_string())?; conn.execute( diff --git a/src-tauri/src/commands/groups.rs b/src-tauri/src/commands/groups.rs index 3e6f60c..cba7a0e 100644 --- a/src-tauri/src/commands/groups.rs +++ b/src-tauri/src/commands/groups.rs @@ -109,6 +109,58 @@ pub fn get_group_maps() -> Result, String> { 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) = 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] pub fn add_group_member(project_id: String, group_id: String, role: Option) -> Result, String> { let conn = pool().get().map_err(|e| e.to_string())?; diff --git a/src-tauri/src/commands/mentor.rs b/src-tauri/src/commands/mentor.rs index 3e925fc..c0421b4 100644 --- a/src-tauri/src/commands/mentor.rs +++ b/src-tauri/src/commands/mentor.rs @@ -47,6 +47,14 @@ pub struct ActiveTaskCard { pub blocked_reason: Option, } +#[derive(Debug, Serialize)] +pub struct ReviewCoverage { + /// 已完成的 M/L 复杂度任务数 + pub completed_ml_tasks: u32, + /// 该项目已有的复盘笔记数 + pub note_count: u32, +} + #[derive(Debug, Serialize)] pub struct MentorContext { pub project_id: String, @@ -55,6 +63,8 @@ pub struct MentorContext { pub blueprint_summary: Option, pub active_tasks: Vec, pub recent_errors: Vec, + /// 复盘笔记覆盖情况 + pub review_coverage: Option, /// 格式化好的 Markdown,可直接复制给 Claude pub mentor_markdown: String, } @@ -169,6 +179,30 @@ pub fn get_mentor_context(project_id: String) -> Result { // 5b. AI 笔记(最近 5 条,作为跨会话记忆) 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 let mentor_markdown = build_mentor_markdown( &project_id, @@ -178,6 +212,7 @@ pub fn get_mentor_context(project_id: String) -> Result { &active_tasks, &recent_errors, &ai_notes, + review_coverage.as_ref(), ); Ok(MentorContext { @@ -187,6 +222,7 @@ pub fn get_mentor_context(project_id: String) -> Result { blueprint_summary, active_tasks, recent_errors, + review_coverage, mentor_markdown, }) } @@ -406,6 +442,23 @@ fn parse_blueprint_summary(root: &Path) -> Option { } } +/// 统计蓝图中已完成的 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 { match crate::commands::blueprint::get_blueprint(root.to_string_lossy().to_string()) { @@ -491,6 +544,7 @@ fn build_mentor_markdown( active_tasks: &[ActiveTaskCard], errors: &[RuntimeLogEntry], ai_notes: &[ProjectNote], + review_coverage: Option<&ReviewCoverage>, ) -> String { 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 笔记(跨会话记忆)──────────────────────────────────────────────────── if !ai_notes.is_empty() { md.push_str("\n## AI 笔记(历史观察)\n\n"); diff --git a/src-tauri/src/commands/templates.rs b/src-tauri/src/commands/templates.rs index 5b422f1..0992859 100644 --- a/src-tauri/src/commands/templates.rs +++ b/src-tauri/src/commands/templates.rs @@ -29,6 +29,9 @@ pub struct ProjectTemplate { pub created_at: Option, #[serde(default)] pub files: Vec, + /// JSON 数组,格式:[{label, description, initCommands}] + /// 存在时前端显示下拉让用户选择初始化方式 + pub variants: Option, } #[derive(Deserialize)] @@ -64,7 +67,7 @@ pub fn list_templates() -> Result, String> { let conn = db::pool().get().map_err(|e| e.to_string())?; let mut stmt = conn .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", ) .map_err(|e| e.to_string())?; @@ -81,6 +84,7 @@ pub fn list_templates() -> Result, String> { is_builtin: row.get::<_, i64>(6)? == 1, created_at: row.get(7)?, files: vec![], + variants: row.get(8)?, }) }) .map_err(|e| e.to_string())? @@ -96,7 +100,7 @@ pub fn get_template(id: String) -> Result { let mut tmpl = conn .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", params![id], |row| { @@ -110,6 +114,7 @@ pub fn get_template(id: String) -> Result { is_builtin: row.get::<_, i64>(6)? == 1, created_at: row.get(7)?, files: vec![], + variants: row.get(8)?, }) }, ) @@ -194,10 +199,15 @@ pub fn create_project_from_template( project_name: String, platform: String, space_id: Option, + variant_init_commands: Option, ) -> Result { 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 = Vec::new(); // 辅助:同时 emit 事件 + 记录到 logs @@ -386,6 +396,8 @@ pub fn seed_builtin_templates() { tech_stack: &'static str, init_commands: &'static str, files: Vec<(&'static str, &'static str)>, + /// JSON 字符串,存在时前端显示下拉选择初始化方式 + variants: Option<&'static str>, } let templates = vec![ @@ -396,6 +408,7 @@ pub fn seed_builtin_templates() { platform: "both", tech_stack: "Node,pnpm", init_commands: "pnpm install", + variants: None, files: vec![ ("package.json", r#"{ "name": "{{project_name_kebab}}", @@ -428,6 +441,7 @@ pub fn seed_builtin_templates() { platform: "both", tech_stack: "Node,pnpm", init_commands: "pnpm install", + variants: None, files: vec![ ("package.json", r#"{ "name": "{{project_name_kebab}}", @@ -451,6 +465,7 @@ pub fn seed_builtin_templates() { platform: "both", tech_stack: "Rust", init_commands: "cargo build", + variants: None, files: vec![ ("Cargo.toml", r#"[package] name = "{{project_name_kebab}}" @@ -470,6 +485,7 @@ edition = "2021" platform: "both", tech_stack: "", init_commands: "", + variants: None, files: vec![], }, Tpl { @@ -479,6 +495,7 @@ edition = "2021" platform: "both", tech_stack: "Rust,Node,pnpm,React,TypeScript,Tauri", init_commands: "pnpm install", + variants: None, files: vec![ ("package.json", r#"{ "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"), ], }, + Tpl { + id: "builtin-expo", + name: "Expo", + description: "React Native 移动端 App(Android / 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 { let _ = conn.execute( "INSERT OR IGNORE INTO project_templates - (id, name, description, platform, tech_stack, init_commands, is_builtin) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1)", + (id, name, description, platform, tech_stack, init_commands, is_builtin, variants) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7)", params![ t.id, t.name, @@ -672,8 +704,14 @@ pub fn run() { t.platform, if t.tech_stack.is_empty() { None } else { Some(t.tech_stack) }, if t.init_commands.is_empty() { None } else { Some(t.init_commands) }, + t.variants, ], ); + // 已存在的模板也补充 variants(INSERT 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 { let file_id = format!("{}-{}", t.id, path.replace('/', "-")); diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index f410239..9bd5f75 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -27,6 +27,11 @@ pub fn pool() -> &'static DbPool { POOL.get().expect("db not initialized") } +/// pool 未初始化时返回 None,测试环境中安全调用。 +pub(crate) fn try_pool() -> Option<&'static DbPool> { + POOL.get() +} + fn migrate(conn: &rusqlite::Connection) { conn.execute_batch( " @@ -527,6 +532,11 @@ fn migrate(conn: &rusqlite::Connection) { ); ").expect("create global_tools table"); + // 增量迁移:模板 variants 列(多初始化方式下拉选项) + let _ = conn.execute_batch( + "ALTER TABLE project_templates ADD COLUMN variants TEXT;" + ); + // 增量迁移:项目历史快照(每日一条,供 Claude 做趋势分析) conn.execute_batch(" CREATE TABLE IF NOT EXISTS project_snapshots ( diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index 497f0cd..de2745a 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -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); servers[&key] = json!({ - "type": "http", - "url": format!("http://localhost:{}/mcp/group/{}", port, group_id) + "type": "sse", + "url": format!("http://127.0.0.1:{}/mcp/group/{}", port, group_id) }); write_json(&path, &settings).inspect(|_| { diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 9f4f4f6..c29a29b 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -1,16 +1,24 @@ use axum::{ - extract::Path, + extract::{Path, Query, State}, http::StatusCode, - response::{IntoResponse, Response}, + response::{ + sse::{Event, KeepAlive, Sse}, + IntoResponse, Response, + }, routing::{get, post}, Json, Router, }; use serde::Deserialize; 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}; +/// SSE 会话表:session_id → 发送端((event_name, data)) +type Sessions = Arc>>>; + /// 从 settings 表读取 MCP 端口,默认 27190 pub fn read_port() -> u16 { db::pool() @@ -28,16 +36,22 @@ pub fn read_port() -> u16 { .unwrap_or(27190) } -/// 启动 MCP HTTP 服务(Streamable HTTP transport) +/// 启动 MCP HTTP 服务(同时支持 Streamable HTTP 和 SSE 两种传输) pub async fn start(port: u16) { + let sessions: Sessions = Arc::new(Mutex::new(HashMap::new())); + let app = Router::new() .route("/health", get(health)) - .route("/mcp/group/:group_id", post(handle_mcp)); + // Streamable HTTP(POST)— 保持向后兼容,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 { 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 { log_event("mcp_server", "error", &format!("Server 异常退出: {e}"), None); } @@ -62,36 +76,108 @@ struct RpcRequest { params: Option, } -async fn handle_mcp(Path(group_id): Path, Json(req): Json) -> Response { - // MCP 通知(无 id)→ 202 Accepted,无需响应体 - if req.id.is_none() { - return StatusCode::ACCEPTED.into_response(); - } +#[derive(Deserialize)] +struct SessionQuery { + session_id: String, +} - let id = req.id.clone(); +// ── 共享 RPC 处理逻辑 ───────────────────────────────────────────────────────── - let result = match req.method.as_str() { - "initialize" => json!({ +async fn process_rpc(group_id: &str, req: &RpcRequest) -> Result { + match req.method.as_str() { + "initialize" => Ok(json!({ "protocolVersion": "2024-11-05", "capabilities": { "tools": {} }, "serverInfo": { "name": "炼境", "version": env!("CARGO_PKG_VERSION") } + })), + "tools/list" => Ok(tools_list_result()), + "tools/call" => Ok(tools_call(group_id, req.params.as_ref()).await), + _ => Err(format!("Method not found: {}", req.method)), + } +} + +// ── Streamable HTTP(POST)— 向后兼容,Python 直调走此路径 ───────────────────── + +async fn handle_mcp(Path(group_id): Path, Json(req): Json) -> 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, + Path(group_id): Path, +) -> Sse>> { + 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::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, + Path(group_id): Path, + Query(query): Query, + Json(req): Json, +) -> 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 } }), - "tools/list" => tools_list_result(), - "tools/call" => tools_call(&group_id, req.params.as_ref()).await, - _ => { - return Json(json!({ - "jsonrpc": "2.0", - "id": id, - "error": { "code": -32601, "message": "Method not found" } - })) - .into_response(); - } }; - 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() } // ── 工具定义 ────────────────────────────────────────────────────────────────── diff --git a/src/components/manage/NewProjectModal.tsx b/src/components/manage/NewProjectModal.tsx index b8bde50..95e012e 100644 --- a/src/components/manage/NewProjectModal.tsx +++ b/src/components/manage/NewProjectModal.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { listen } from "@tauri-apps/api/event"; import { - listTemplates, createProjectFromTemplate, + listTemplates, createProjectFromTemplate, TemplateVariant, } from "../../lib/commands"; import { useUIStore } from "../../store/ui"; @@ -36,6 +36,7 @@ export function NewProjectModal({ spaceId, onClose }: Props) { // ── Step 2 ──────────────────────────────────────────────────────────────── const [selectedTemplateId, setSelectedTemplateId] = useState(null); + const [selectedVariantIdx, setSelectedVariantIdx] = useState(0); // ── Step 3/4 ────────────────────────────────────────────────────────────── const [logs, setLogs] = useState([]); @@ -78,6 +79,12 @@ export function NewProjectModal({ spaceId, onClose }: Props) { (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 ? `${parentPath.replace(/[/\\]+$/, "")}\\${projectName}` : ""; @@ -117,12 +124,16 @@ export function NewProjectModal({ spaceId, onClose }: Props) { }); try { + const variantCmd = selectedVariants.length > 0 + ? selectedVariants[selectedVariantIdx]?.initCommands + : undefined; await createProjectFromTemplate( selectedTemplateId, parentPath, projectName.trim(), platform, spaceId, + variantCmd, ); qc.invalidateQueries({ queryKey: ["projects"] }); setStep("done"); @@ -243,46 +254,85 @@ export function NewProjectModal({ spaceId, onClose }: Props) { {filteredTemplates.length === 0 && (

没有适用于当前平台的模板

)} - {filteredTemplates.map((t) => ( - - ))} + + {t.description && ( +

{t.description}

+ )} + {t.techStack && ( +
+ {t.techStack.split(",").map((tag) => ( + + {tag.trim()} + + ))} +
+ )} + {variants.length === 0 && t.initCommands && ( +

$ {t.initCommands.split("\n")[0]}

+ )} + + + {/* variant 下拉:仅在选中且有 variants 时展开 */} + {isSelected && variants.length > 0 && ( +
+

选择初始化模板

+ {variants.map((v, idx) => ( + + ))} +
+ )} + + ); + })} )} diff --git a/src/components/mentor/MentorPanel.tsx b/src/components/mentor/MentorPanel.tsx index 552bcd0..9146f91 100644 --- a/src/components/mentor/MentorPanel.tsx +++ b/src/components/mentor/MentorPanel.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; 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 { projectId: string; @@ -151,6 +151,11 @@ export function MentorPanel({ projectId, projectName, onClose }: Props) { )} + {/* 复盘笔记覆盖率 */} + {ctx.review_coverage && ( + + )} + {/* AI 笔记 */}

AI 笔记

@@ -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 ( +
+

复盘笔记覆盖

+
+
+ 已完成 M/L 任务 + {coverage.completed_ml_tasks} +
+
+ 复盘笔记数 + {coverage.note_count} +
+
+ 缺口 + + {hasGap ? `−${gap}` : "✓ 0"} + +
+
+ {hasGap && ( +
+ ⚠️ + 有 {gap} 个 M/L 任务缺少复盘笔记,完成下次任务后请通过 MCP 补写。 +
+ )} +
+ ); +} + function HealthRow({ ok, label, diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 975d7a9..9ca3b94 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -679,6 +679,12 @@ export interface TemplateFile { content: string; } +export interface TemplateVariant { + label: string; + description: string; + initCommands: string; +} + export interface ProjectTemplate { id: string; name: string; @@ -689,6 +695,8 @@ export interface ProjectTemplate { isBuiltin: boolean; createdAt?: string; files: TemplateFile[]; + /** JSON 字符串,解析后为 TemplateVariant[],存在时显示下拉选择 */ + variants?: string; } export interface SaveTemplateInput { @@ -724,6 +732,7 @@ export const createProjectFromTemplate = ( projectName: string, platform: string, spaceId?: string, + variantInitCommands?: string, ) => invoke("create_project_from_template", { templateId, @@ -731,6 +740,7 @@ export const createProjectFromTemplate = ( projectName, platform, spaceId, + variantInitCommands, }); export const registerAndLinkRepo = ( @@ -1063,6 +1073,12 @@ export interface ProjectNoteEntry { created_at: string; } +export interface ExecutionQuality { + complexity_dist: Record; + total_rework_count: number; + avg_rounds_m: number | null; +} + export interface FlywheelStats { cohorts: FlywheelCohort[]; top_blocked_reasons: BlockedReasonCount[]; @@ -1078,6 +1094,8 @@ export interface FlywheelStats { projects_with_data: number; /** 跨项目复盘笔记(最近 50 条) */ project_notes: ProjectNoteEntry[]; + /** 跨项目执行质量指标(阶段二扩展) */ + execution_quality: ExecutionQuality; } export const getFlywheelStats = (projectPaths: string[]) => @@ -1127,12 +1145,18 @@ export interface RuntimeLogEntry { message: string; } +export interface ReviewCoverage { + completed_ml_tasks: number; + note_count: number; +} + export interface MentorContext { project_id: string; project_name: string; health: ProjectHealth; blueprint_summary?: BlueprintSummary; recent_errors: RuntimeLogEntry[]; + review_coverage?: ReviewCoverage; mentor_markdown: string; }