From 3df7550337d494862d6c1c703b3cecafc8c3a1d7 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 4 Jul 2026 12:02:49 +0900 Subject: [PATCH 01/14] =?UTF-8?q?fix(onboarding):=20lefthook=20commit-msg?= =?UTF-8?q?=20=E5=8D=95=E8=A1=8C=E9=81=BF=E5=85=8D=20Windows=20shell=20{-b?= =?UTF-8?q?lock=20=E8=A7=A3=E6=9E=90=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/resources/onboarding/lefthook.yml.tmpl | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src-tauri/resources/onboarding/lefthook.yml.tmpl b/src-tauri/resources/onboarding/lefthook.yml.tmpl index b30ed28..276bbad 100644 --- a/src-tauri/resources/onboarding/lefthook.yml.tmpl +++ b/src-tauri/resources/onboarding/lefthook.yml.tmpl @@ -13,10 +13,4 @@ pre-commit: commit-msg: commands: conventional: - run: | - head -1 "{1}" | grep -qE '^(feat|fix|docs|chore|refactor|ci|perf|style|test|build|revert)(\(.+\))?!?: .+' || { - echo "✗ commit message 必须符合 Conventional Commits:(): <描述>" - echo " type: feat|fix|docs|chore|refactor|ci|perf|style|test|build|revert" - echo " 例:feat(auth/login): 新增手机号登录" - exit 1 - } + run: "head -1 \"{1}\" | grep -qE '^(feat|fix|docs|chore|refactor|ci|perf|style|test|build|revert)(\\(.+\\))?!?: .+' || exit 1" From 95310890a3a751b0ab60ab8e12de5274f2a892b4 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 4 Jul 2026 12:12:29 +0900 Subject: [PATCH 02/14] =?UTF-8?q?docs(agent-button):=20=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E8=AF=B4=E8=B0=8E=E7=9A=84=20pnpm=20test=20=E6=8C=89=E9=92=AE?= =?UTF-8?q?=EF=BC=8CCONVENTIONS=20=E5=89=8D=E7=AB=AF=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E8=A7=84=E8=8C=83=E5=8A=A0=20Tauri=20=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E9=99=90=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .blueprint/CONVENTIONS.md | 37 +++++++++++-------- AGENTS.md | 3 +- src-tauri/resources/onboarding/AGENTS.md.tmpl | 2 +- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/.blueprint/CONVENTIONS.md b/.blueprint/CONVENTIONS.md index 641daf9..bcb6030 100644 --- a/.blueprint/CONVENTIONS.md +++ b/.blueprint/CONVENTIONS.md @@ -131,7 +131,7 @@ acceptance 是 agent 自验的唯一判断依据,必须是**可执行断言** ✅ "pnpm typecheck 无报错" ✅ "cargo test 通过(src-tauri/)" ✅ "MCP tool list_projects 返回新增项目名" -✅ "pnpm test <测试文件> 全绿" +✅ "pnpm vitest run <测试文件> 全绿"(仅适用于有纯逻辑层的项目,见前端可测试性规范) ``` 最小结构:`[载体] 调用/运行什么(参数) → 断言什么`。 @@ -696,39 +696,44 @@ mod tests { ## 前端代码可测试性规范 -> 本规范适用于**新增** TypeScript/React 代码,存量代码不强制回填。 +> **适用范围**:有独立纯逻辑层的项目(Web 服务、Node CLI 等)。 +> Tauri 桌面应用的主测试路径是 `cargo test`(Rust 层); +> 前端大量代码是 Tauri IPC 薄包装(`invoke()` 调用),无法在 Node 环境运行,不适合 vitest。 +> 炼境自身属于 Tauri 项目,**前端暂不配置 vitest**,以下规范待引入真实纯逻辑模块时生效。 + +### 何时引入 vitest + +满足以下条件之一时才引入: + +1. 项目有可独立运行(不依赖 Tauri 运行时)的纯逻辑模块,且该模块代码量 ≥ 200 行 +2. 某个 bug 在纯逻辑层反复出现,需要回归测试固化 + +引入时配置 `vitest.config.ts`,使用 `pnpm add -D vitest`。 ### 测试文件位置 前端测试文件与源文件同目录,命名约定:`.test.ts(x)`。 ``` -src/components/MyComponent.tsx -src/components/MyComponent.test.tsx ← 同目录、同名 + .test 后缀 - src/lib/utils.ts -src/lib/utils.test.ts ← vitest 自动发现 +src/lib/utils.test.ts ← vitest 自动发现(仅纯逻辑文件) ``` -### 测试框架 - -使用 vitest + `describe`/`it`/`expect`,配置见项目根 `vitest.config.ts`。 - -### 测试要求 +### 测试要求(适用时) | 代码类型 | 测试要求 | |---------|---------| | 纯逻辑函数(数据处理、格式化、解析、校验) | M 级任务卡强制附带测试,覆盖主路径 + 空/边界 | -| React 组件(含状态管理、数据流) | M 级任务卡建议附带测试,至少覆盖关键交互路径 | -| 纯样式组件 / Tauri IPC 薄包装层 | 无需测试 | +| Tauri IPC 薄包装层(invoke 调用) | 无需测试(主路径走 cargo test) | +| React 组件 | 无需测试(UI 由人工终验) | -### 任务卡 acceptance 模板补充 +### 任务卡 acceptance 模板(适用时) -含前端纯逻辑的 M 级任务卡,acceptance 须包含: +含前端纯逻辑的 M 级任务卡: ``` - [ ] 附带 vitest 测试,覆盖主路径 + 空数据边界 -- [ ] npx vitest run 全绿 +- [ ] pnpm vitest run 全绿 ``` ### 测试写法模板 diff --git a/AGENTS.md b/AGENTS.md index 8ebfe26..881014d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -207,7 +207,7 @@ docs/templates/ 炼境接入包模板草案(设计层) | UI 呈现层 | Playwright 驱动真界面(最后手段,仅关键路径) | 3. **完成的定义**:功能实现后,由 agent 沿投影路径真实走通一次(起服务 → 调接口 → 断言行为),再交人做 UI 终验——不许绕过功能手改产物冒充验证 -4. **侦察 → 驻军**:agent 走通的关键路径,顺手固化为一条测试(vitest / cargo test); +4. **侦察 → 驻军**:agent 走通的关键路径,顺手固化为一条测试(炼境主路径为 cargo test;vitest 仅在引入独立纯逻辑模块后按需添加); 一次性验收不固化(少而稳 > 多而脆) 5. **acceptance 可执行化**:`/splitter` 生成任务卡时,`acceptance` 字段必须是可执行验证路径, 格式:`[载体] 调用/运行什么(参数) → 断言什么`。 @@ -220,7 +220,6 @@ docs/templates/ 炼境接入包模板草案(设计层) | 能力 | 调用方式 | 通过标准 | |------|---------|---------| | TypeScript 类型检查 | `pnpm typecheck` | 无输出,exit 0 | -| 前端单元测试 | `pnpm test` | exit 0,无 FAILED | | Rust 单元测试 | `cargo test`(src-tauri/ 目录) | 无 FAILED | | MCP 工具调用 | 见 `src-tauri/src/mcp_tools.rs` 工具列表 | 返回 JSON,无 error 字段 | | 蓝图状态读取 | 读 `.blueprint/manifest.yaml` | 文件可解析,字段完整 | diff --git a/src-tauri/resources/onboarding/AGENTS.md.tmpl b/src-tauri/resources/onboarding/AGENTS.md.tmpl index b2bfa73..a89b2a2 100644 --- a/src-tauri/resources/onboarding/AGENTS.md.tmpl +++ b/src-tauri/resources/onboarding/AGENTS.md.tmpl @@ -51,7 +51,7 @@ | UI 呈现层 | Playwright 驱动真界面(最后手段,仅关键路径) | 3. **完成的定义**:功能实现后,由 agent 沿投影路径真实走通一次(起服务 → 调接口 → 断言行为),再交人做 UI 终验——不许绕过功能手改产物冒充验证 -4. **侦察 → 驻军**:agent 走通的关键路径,顺手固化为一条测试(vitest / cargo test); +4. **侦察 → 驻军**:agent 走通的关键路径,顺手固化为一条测试(桌面应用用 cargo test;Web 项目纯逻辑层用 vitest); 一次性验收不固化(少而稳 > 多而脆) 5. **acceptance 可执行化**:`/splitter` 生成任务卡时,`acceptance` 字段必须是可执行验证路径, 格式:`[载体] 调用/运行什么(参数) → 断言什么`。 From ccbc26acf33c8461ad254988b3068c4928671b02 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 4 Jul 2026 12:19:12 +0900 Subject: [PATCH 03/14] =?UTF-8?q?feat(mcp):=20=E6=96=B0=E5=A2=9E=20scan=5F?= =?UTF-8?q?unregistered=5Fprojects=20=E5=B7=A5=E5=85=B7=EF=BC=8C=E6=89=AB?= =?UTF-8?q?=E6=8F=8F=E6=96=87=E4=BB=B6=E7=B3=BB=E7=BB=9F=E6=89=BE=E5=87=BA?= =?UTF-8?q?=E6=9C=AA=E7=99=BB=E8=AE=B0=E7=82=BC=E5=A2=83=E7=9A=84=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/mcp_tools.rs | 121 +++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index 2d1e2be..1c5c1a0 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -240,6 +240,11 @@ pub fn tools_list_result() -> Value { }, "required": ["project_id"] } + }, + { + "name": "scan_unregistered_projects", + "description": "扫描已登记项目的父目录,找出存在于文件系统但尚未在炼境注册的项目目录。返回每个未注册目录的路径、是否有 .git、技术栈特征文件(package.json / Cargo.toml / go.mod)、是否有 AGENTS.md。用于日常发现漏网项目并决定是否接入炼境。", + "inputSchema": { "type": "object", "properties": {}, "required": [] } } ] }) @@ -456,6 +461,7 @@ pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value { crate::commands::cicd::distribute_workflow_for_project(pid, wt) } "list_all_projects" => list_all_projects(), + "scan_unregistered_projects" => scan_unregistered_projects(), "apply_onboarding_pack" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); resolve_project_root(&gid, pid).and_then(|root| { @@ -897,6 +903,117 @@ pub fn list_all_projects() -> Result { Ok(out) } +pub fn scan_unregistered_projects() -> Result { + let conn = db::pool().get().map_err(|e| e.to_string())?; + + // 取所有已登记的 win_path + let mut stmt = conn + .prepare("SELECT win_path FROM project_workspaces WHERE win_path IS NOT NULL") + .map_err(|e| e.to_string())?; + let registered_paths: std::collections::HashSet = stmt + .query_map([], |r| r.get::<_, String>(0)) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .map(|p| p.trim_end_matches(['/', '\\']).to_string()) + .collect(); + + // 推导父目录(去重) + let mut parent_dirs: std::collections::HashSet = + std::collections::HashSet::new(); + for p in ®istered_paths { + if let Some(parent) = std::path::Path::new(p).parent() { + parent_dirs.insert(parent.to_path_buf()); + } + } + + if parent_dirs.is_empty() { + return Ok("炼境暂无登记项目,无法推导扫描目录。".to_string()); + } + + let mut unregistered: Vec<(String, bool, Vec, bool)> = Vec::new(); + + for parent in &parent_dirs { + let entries = std::fs::read_dir(parent).map_err(|e| e.to_string())?; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let path_str = path.to_string_lossy().trim_end_matches(['/', '\\']).to_string(); + if registered_paths.contains(&path_str) { + continue; + } + // 隐藏目录跳过 + if path + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n.starts_with('.')) + .unwrap_or(false) + { + continue; + } + + let has_git = path.join(".git").exists(); + let mut stack: Vec = Vec::new(); + if path.join("package.json").exists() { + stack.push("JS/TS".into()); + } + if path.join("Cargo.toml").exists() { + stack.push("Rust".into()); + } + if path.join("go.mod").exists() { + stack.push("Go".into()); + } + if path.join("pom.xml").exists() { + stack.push("Java".into()); + } + let has_agents = path.join("AGENTS.md").exists(); + + unregistered.push((path_str, has_git, stack, has_agents)); + } + } + + if unregistered.is_empty() { + return Ok(format!( + "扫描了 {} 个父目录,所有子目录均已在炼境注册。", + parent_dirs.len() + )); + } + + unregistered.sort_by(|a, b| a.0.cmp(&b.0)); + + let mut out = format!( + "## 未注册目录(共 {} 个)\n\n扫描范围:{}\n\n", + unregistered.len(), + parent_dirs + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect::>() + .join("、") + ); + + for (path, has_git, stack, has_agents) in &unregistered { + let git_icon = if *has_git { "🗂 git" } else { "❌ 无git" }; + let agents_icon = if *has_agents { "📄 AGENTS" } else { "—" }; + let stack_str = if stack.is_empty() { + "未知".to_string() + } else { + stack.join("/") + }; + let name = std::path::Path::new(path) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| path.clone()); + out.push_str(&format!( + "- **{}** | {} | {} | {}\n `{}`\n\n", + name, git_icon, stack_str, agents_icon, path + )); + } + + out.push_str("---\n在炼境 UI 添加项目后,调用 `apply_onboarding_pack(project_id)` 分发接入包。"); + Ok(out) +} + // ── 测试 ───────────────────────────────────────────────────────────────────── #[cfg(test)] @@ -915,6 +1032,10 @@ mod tests { assert!(names.contains(&"apply_onboarding_pack"), "缺少 apply_onboarding_pack"); assert!(names.contains(&"verify_onboarding"), "缺少 verify_onboarding"); assert!(names.contains(&"list_all_projects"), "缺少 list_all_projects"); + assert!( + names.contains(&"scan_unregistered_projects"), + "缺少 scan_unregistered_projects" + ); assert!(names.contains(&"inject_mcp"), "缺少 inject_mcp"); assert!(names.contains(&"create_pull_request"), "缺少 create_pull_request"); assert!(names.contains(&"poll_now"), "缺少 poll_now"); From 2116e54e2986aa73f7e87a865deaf67339f47a64 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 4 Jul 2026 13:29:40 +0900 Subject: [PATCH 04/14] =?UTF-8?q?feat(mcp-inject):=20=E6=B3=A8=E5=85=A5?= =?UTF-8?q?=E6=97=B6=E5=90=8C=E6=AD=A5=E6=B3=A8=E5=86=8C=E5=85=A8=E5=B1=80?= =?UTF-8?q?=20~/.claude.json=20+=20liangjing.json=20=E8=A1=A5=20group=5Fid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - inject_project_mcp 末尾新增 try_register_globally 调用,通过 find_claude_bin 自动定位 claude CLI 并执行 claude mcp add -s user, 让新接入项目的产品组 MCP 立即对 Claude Code 可用 - write_liangjing_context 增加 group_id 参数,子项目读取后可直接 构造 lian-jing-{group_id} ToolSearch 查询,无需额外推断 - try_register_globally 为 best-effort:CLI 未找到或调用失败时 仅写日志,不影响 settings.json 注入结果 --- src-tauri/src/mcp_inject.rs | 114 ++++++++++++++++++++++++++++++++++-- 1 file changed, 109 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index 0e0ad56..7662910 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -47,19 +47,120 @@ fn liangjing_path(project_id: &str) -> Result { Ok(project_root(project_id)?.join(".claude").join("liangjing.json")) } -/// 写炼境上下文文件,供子项目 Claude 会话直接读取 project_id(不通过遍历 list_group_projects 匹配) -fn write_liangjing_context(project_id: &str, port: u16) -> Result<(), String> { +/// 写炼境上下文文件,供子项目 Claude 会话直接读取 project_id / group_id +fn write_liangjing_context(project_id: &str, group_id: &str, port: u16) -> Result<(), String> { let path = liangjing_path(project_id)?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .map_err(|e| format!("创建 .claude 目录失败: {}", e))?; } - let ctx = json!({ "project_id": project_id, "mcp_port": port }); + let ctx = json!({ + "project_id": project_id, + "group_id": group_id, + "mcp_port": port + }); let content = serde_json::to_string_pretty(&ctx).map_err(|e| e.to_string())?; std::fs::write(&path, content) .map_err(|e| format!("写入 liangjing.json 失败: {}", e)) } +/// 查找 claude CLI 路径(优先环境变量 CLAUDE_BIN,然后常用安装目录,最后 where 命令) +fn find_claude_bin() -> Option { + if let Ok(path) = std::env::var("CLAUDE_BIN") { + if std::path::Path::new(&path).exists() { + return Some(path); + } + } + + let home = std::env::var("USERPROFILE") + .or_else(|_| std::env::var("HOME")) + .unwrap_or_default(); + + let candidates = [ + format!(r"{}\.local\bin\claude.exe", home), + format!(r"{}\AppData\Local\AnthropicClaude\claude.exe", home), + r"C:\Program Files\AnthropicClaude\claude.exe".to_string(), + ]; + + for c in &candidates { + if std::path::Path::new(c).exists() { + return Some(c.clone()); + } + } + + // 最后尝试 where 命令(GUI 进程 PATH 可能不含用户 bin,but worth trying) + if let Ok(out) = std::process::Command::new("where").arg("claude.exe").output() { + if out.status.success() { + if let Ok(stdout) = String::from_utf8(out.stdout) { + if let Some(line) = stdout.lines().next() { + return Some(line.trim().to_string()); + } + } + } + } + + None +} + +/// 将产品组 MCP 注册到 Claude 全局配置(~/.claude.json),best-effort,失败仅记录日志 +fn try_register_globally(group_id: &str, port: u16) { + let server_name = format!("lian-jing-{}", group_id); + let server_url = format!("http://127.0.0.1:{}/mcp/group/{}", port, group_id); + + let Some(claude_bin) = find_claude_bin() else { + db::log_event( + "mcp_inject", + "warn", + "claude CLI 未找到,跳过全局 Mcp 注册", + None, + ); + return; + }; + + let git_bash = std::env::var("CLAUDE_CODE_GIT_BASH_PATH").unwrap_or_else(|_| { + r"C:\Program Files\Git\bin\bash.exe".to_string() + }); + + match std::process::Command::new(&claude_bin) + .args([ + "mcp", + "add", + "--transport", + "sse", + "-s", + "user", + &server_name, + &server_url, + ]) + .env("CLAUDE_CODE_GIT_BASH_PATH", &git_bash) + .output() + { + Ok(out) if out.status.success() => { + db::log_event( + "mcp_inject", + "info", + "全局 MCP 注册成功", + Some(&server_name), + ); + } + Ok(out) => { + let msg = format!( + "claude mcp add 非零退出: {}", + String::from_utf8_lossy(&out.stderr) + ); + db::log_event("mcp_inject", "warn", &msg, Some(&server_name)); + } + Err(e) => { + db::log_event( + "mcp_inject", + "warn", + &format!("执行 claude mcp add 失败: {}", e), + Some(&server_name), + ); + } + } +} + // ── JSON 读写 ───────────────────────────────────────────────────────────────── fn read_json(path: &PathBuf) -> Result { @@ -118,8 +219,11 @@ pub fn inject_project_mcp(project_id: &str, group_id: &str) -> Result<(), String log_event("mcp_inject", "info", "MCP 配置注入成功", Some(&ctx)); })?; - // 写炼境上下文,让子项目 Claude 会话无需遍历即可知道自己的 project_id - let _ = write_liangjing_context(project_id, port); + // 写炼境上下文(含 group_id),让子项目 Claude 会话无需遍历即可知道 project_id + group_id + let _ = write_liangjing_context(project_id, group_id, port); + + // 同步注册到 ~/.claude.json,让 Claude Code 能加载 MCP 工具(best-effort) + try_register_globally(group_id, port); Ok(()) } From 05d76739901f11c78e85aa4e0ad1751417cb4d3b Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 4 Jul 2026 13:44:02 +0900 Subject: [PATCH 05/14] =?UTF-8?q?feat(onboarding):=20T2+B3=20=E5=A4=8D?= =?UTF-8?q?=E6=9D=82=E5=BA=A6=E6=84=9F=E7=9F=A5=E5=88=86=E6=94=AF=E7=AD=96?= =?UTF-8?q?=E7=95=A5=20+=20agent=20=E6=8C=89=E9=92=AE=E8=BD=BD=E4=BD=93?= =?UTF-8?q?=E5=A3=B0=E6=98=8E=E4=BD=93=E7=B3=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T2 git-workflow 模板升 v1.3.0: - 分支策略从强制 dev→main 改为复杂度感知 (简单改动直推 main/master,功能性改动开 feat/ 分支+PR) - PR 创建 MCP 示例 head 从 "dev" 改为 "feat/" B3 agent 按钮载体声明闭环: - CONVENTIONS.md manifest.yaml 规范新增 agent_button_carrier 字段 (tauri/http/cli/playwright,新建蓝图时必填) - splitter SKILL.md 三级优先载体继承:manifest字段 > architect声明 > 就近推断 - 炼境自身 manifest.yaml 补 agent_button_carrier: tauri - AGENTS.md.tmpl 项目概况 TODO 提示新增载体字段填写指引 Rules-Applied: R04,R13 --- .blueprint/CONVENTIONS.md | 14 ++++++++++++- .blueprint/manifest.yaml | 3 ++- .blueprint/modules/onboarding-pack.md | 12 ++++++----- .claude/skills/splitter/SKILL.md | 5 ++++- src-tauri/resources/onboarding/AGENTS.md.tmpl | 2 ++ .../resources/onboarding/git-workflow.md.tmpl | 21 +++++++++++-------- 6 files changed, 40 insertions(+), 17 deletions(-) diff --git a/.blueprint/CONVENTIONS.md b/.blueprint/CONVENTIONS.md index bcb6030..57d3da2 100644 --- a/.blueprint/CONVENTIONS.md +++ b/.blueprint/CONVENTIONS.md @@ -161,10 +161,22 @@ concept 卡是想法 inbox,**不要求 files / acceptance / complexity**,随 ```yaml version: 1 name: 项目名称 -iteration: 1 # 当前迭代号(默认 1,重构已完成模块时手动递增) +iteration: 1 # 当前迭代号(默认 1,重构已完成模块时手动递增) updated: YYYY-MM-DD +agent_button_carrier: tauri # 项目主验证载体(见下方说明),新建蓝图时必填 ``` +**`agent_button_carrier` 取值**(就近原则,与 AGENTS.md 载体谱系对应): + +| 值 | 适用场景 | 对应验证方式 | +|----|---------|-------------| +| `tauri` | Tauri 桌面应用 | `cargo test` / Tauri command 函数 | +| `http` | Web 服务 / REST API | `curl` / HTTP 端点 | +| `cli` | CLI 工具 / npm scripts | 命令行调用 | +| `playwright` | 纯 UI 呈现层(最后手段) | Playwright 真界面驱动 | + +> 新建蓝图时 AI 必须根据技术栈自动推断并填入此字段。`splitter` 生成任务卡时直接从此字段继承载体,无需每次重新推断。 + ### 模块字段 ```yaml diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index b6b0eaa..8493f46 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -7,6 +7,7 @@ description: > 形成可自我进化的开发智能闭环。 iteration: 1 updated: 2026-07-04 +agent_button_carrier: tauri @@ -370,7 +371,7 @@ modules: area: backend status: in_progress progress: 99 - note: "体系分发主线:T1/B1/B2/B3/V1 ✅(V1 2026-07-04:lefthook 分发即激活 + verify_onboarding 六项检查链);剩 B4 健康诊断遵守度(后置)" + note: "体系分发主线:T1/T2/B1/B2/B3/V1 ✅(T2 2026-07-04:git-workflow 复杂度感知分支策略;B3 2026-07-04:manifest agent_button_carrier + splitter 三级载体继承);剩 B4 健康诊断遵守度(后置)" position: [3500, 0] - id: gitea-integration diff --git a/.blueprint/modules/onboarding-pack.md b/.blueprint/modules/onboarding-pack.md index ebdf0c0..40e58f2 100644 --- a/.blueprint/modules/onboarding-pack.md +++ b/.blueprint/modules/onboarding-pack.md @@ -117,16 +117,18 @@ - acceptance: [Tauri command] apply_onboarding_pack(project_id) 分发 lefthook.yml 后检测并激活 hook——找到 lefthook 可执行(node_modules/.bin/ 或 PATH)→ 执行 lefthook install,OnboardingReport 新增 hook_activated 三态(activated / missing_binary / failed),missing_binary 时附安装提示;[MCP tool] verify_onboarding(project_id) → 返回检查链 JSON(模板管理块版本到位 / .git/hooks/pre-commit 含 lefthook / origin 指向 Gitea / CI workflow 文件存在 / commit_metrics 近 30 天有该项目数据),每项 pass|fail + detail,无 error 字段;[cargo test] 检查链判定逻辑纯函数化,覆盖 hook 已挂/未挂、origin 匹配/不匹配(R06/R07) - notes: 背景(2026-07-04 侦察确认)——接入包写 lefthook.yml 后从不激活,文件在 hook 死 → conventional 不强制 → 飞轮数据脏;炼境自身未踩纯因手动装过。本卡补两件事:① 分发即激活;② "消费者角色"机器化验证(分发成功 ≠ 功能生效,对应 agent-button-system.md 消费者驱动验证)。产出直接供 B4 健康诊断遵守度消费。同日发现的姊妹雷:cicd.rs PR CI 模板 pnpm lint --if-present 必挂语法已修(dc501f7,回归断言驻军) -### 🔵 T2. git-workflow 模板:分支策略升级为复杂度感知 +### ✅ T2. git-workflow 模板:分支策略升级为复杂度感知 - complexity: M -- status: in_progress +- status: done +- done_note: 2026-07-04 完成。模板 ONBOARDING_MANAGED 升至 v1.3.0,分支策略改为复杂度感知(简单改动直推 main/master,功能性改动开 feat/ 分支+PR),PR MCP 示例 head 从 "dev" 改为 "feat/" - files: src-tauri/resources/onboarding/git-workflow.md.tmpl - acceptance: [Tauri command] 对已接入项目重跑接入包 → git-workflow.md ONBOARDING_MANAGED 块升至 v1.3.0,内容含「简单改动直推 main/master,功能性改动开 feat/ 分支 + PR」策略;PR 创建 MCP 示例 head 字段从 "dev" 改为 "feat/";docs/templates/agents-meta-rules.md 不含分支策略无需同步 - notes: 当前模板强制 dev→main 双分支,假设了任意项目都维护常设 dev 分支,实为过度约束。复杂度感知策略(小改直推 main/master,功能性改动开 feat/* + PR)与炼境自身策略一致、与飞轮目标对齐(PR body 是意图层数据),且对单人小项目更友好——不必维护无意义的常设 dev 分支。「简单/功能性」由 agent 根据改动文件数/性质自判,无需外部蓝图标签 -### 🔵 agent 按钮体系分发(B1/B2 已完成,B3/B4 待做) -- status: in_progress -- progress: 2026-07-03 B1 done——CONVENTIONS 1.10.0 加 R12(边界单点实现)+ R13(M/L 验收路径须真实走通,含载体就近选择表);B2 done——AGENTS.md.tmpl v1.1.0 管理块含「agent 按钮与验证路径」章节(双投影/边界单点/载体表/完成的定义/侦察→驻军)。剩 B3(提示词升级,M,单独一轮)、B4(健康诊断遵守度,后置) +### ✅ agent 按钮体系分发(B1/B2/B3 已完成,B4 后置) +- status: done +- done_note: 2026-07-04 B3 完成——CONVENTIONS.md manifest.yaml 规范新增 agent_button_carrier 字段(tauri/http/cli/playwright 四值)+ 必填规则;splitter SKILL.md 优先从 manifest 读取载体(三级优先:manifest字段>architect声明>就近推断);炼境自身 manifest.yaml 补上 `agent_button_carrier: tauri`;AGENTS.md.tmpl 项目概况 TODO 提示新增该字段填写指引。B4 健康诊断遵守度后置。 +- progress: 2026-07-03 B1 done——CONVENTIONS 1.10.0 加 R12(边界单点实现)+ R13(M/L 验收路径须真实走通,含载体就近选择表);B2 done——AGENTS.md.tmpl v1.1.0 管理块含「agent 按钮与验证路径」章节(双投影/边界单点/载体表/完成的定义/侦察→驻军)。B3 done 2026-07-04(见 done_note) - 版本化闭环要求(2026-07-03 用户澄清,体系分发的灵魂): 分发不是"发了就不管"——每类资产带版本分发 → 子项目按该版本实践产生经验(成功/失败)→ 经验自吸反哺炼境(trailer/failure_cases/复盘/返工率)→ 按版本分组对比效果 → 修订出新版 → 再分发。CONVENTIONS 是模范生(version+队列对比+梦核+同步,1.8→1.9 已跑通一整圈);B 系列每类资产按此标准建闭环。**前置依赖提升**:「接入包模板演进分发(MANAGED 块化)」concept 卡——模板无版本机制是当前最大缺口,没有它体系 v2 到不了存量项目 - notes: 2026-07-03 概念探讨收束后的分发需求(体系详见 memory concept_dual_projection 与 mcp-server 模块 concept 卡)。用户全 agent 开发形态下,"给开发者立规范"="给 agent 注入规范",可完全机器化。四条现成管道分发四类资产:① B1(S) CONVENTIONS 1.10.0 加 R12 边界单点实现 + R13 验收须真实走通验证路径 + 载体选择原则;② B2(S) 接入包 AGENTS.md 模板加 agent 按钮章节(三要素/载体谱系/侦察驻军);③ B3(M) init/sync 提示词升级——建蓝图时声明项目按钮载体、acceptance 生成为可执行验证路径格式;④ B4(S,后置) 健康诊断加体系遵守度检查。移植体验=零新增动作,接入流程不变。 diff --git a/.claude/skills/splitter/SKILL.md b/.claude/skills/splitter/SKILL.md index 83bbff6..aaa49db 100644 --- a/.claude/skills/splitter/SKILL.md +++ b/.claude/skills/splitter/SKILL.md @@ -32,7 +32,10 @@ allowed-tools: Read, Edit, Write, Glob **可派发标准(每张卡必须满足)** - `files`:明确列出涉及文件的相对路径 - `acceptance`:**可执行验证路径**,格式:`[载体] 调用/运行什么(参数) → 断言什么` - - 载体从 architect 的「验证路径」声明继承,就近选择:Tauri command / HTTP API / CLI / Playwright + - **载体来源(优先级)**: + 1. 先读 `.blueprint/manifest.yaml` 的 `agent_button_carrier` 字段 + 2. 无此字段时,从当前对话中 `/architect` 的「验证路径」声明继承 + 3. 两者都没有时,就近推断:Tauri 桌面应用 → `cargo test`,Web 服务 → HTTP API,CLI → 命令行 - 断言要具体(检查字段值、返回码、文件存在、数量 > 0),不写模糊的"功能正常" - 示例:`[Tauri command] apply_onboarding_pack(project_id) → 返回 written≥1,磁盘上文件存在` - 示例:`[HTTP API] POST /api/projects → 201,body.id 非空` diff --git a/src-tauri/resources/onboarding/AGENTS.md.tmpl b/src-tauri/resources/onboarding/AGENTS.md.tmpl index a89b2a2..bbd881b 100644 --- a/src-tauri/resources/onboarding/AGENTS.md.tmpl +++ b/src-tauri/resources/onboarding/AGENTS.md.tmpl @@ -7,6 +7,8 @@ ## 项目概况 + ## 架构与模块 diff --git a/src-tauri/resources/onboarding/git-workflow.md.tmpl b/src-tauri/resources/onboarding/git-workflow.md.tmpl index 56b421c..13754ef 100644 --- a/src-tauri/resources/onboarding/git-workflow.md.tmpl +++ b/src-tauri/resources/onboarding/git-workflow.md.tmpl @@ -3,19 +3,22 @@ > 把此文件粘贴给任何 AI Agent,让它了解本项目的 git 工作方式。由炼境接入包分发。 > `ONBOARDING_MANAGED` 标记块由炼境版本化管理(重跑接入包可原位升级),块外可自由补充项目特有约定。 - + -## 分支策略 +## 分支策略(复杂度感知) ``` -dev ← 日常开发在这里 commit/push - ↓ -main ← 稳定分支,通过 PR 合入(合入时触发 CI code review) +main/master ← 主分支,保持稳定 + ↑ + ├── 简单改动 → 直接 push main/master + └── 功能性改动 → feat/ 分支 → PR → main/master ``` -- 小改动可直接 push `dev` -- 功能完整后开 PR(dev → main) -- 不要直接 push `main`,除非紧急热修复 +**简单改动(直接推主分支)**:bug fix、chore、docs、style、单文件小改,行为对外无变化。 + +**功能性改动(开 feat/* 分支 + PR)**:新功能、重构、涉及多模块的 refactor。由 agent 根据改动文件数和性质自判,无需蓝图标签。 + +分支命名:`feat/<功能简述>`,如 `feat/login-sms`、`feat/order-pagination`。 ## Commit 规范(lefthook 强制) @@ -81,7 +84,7 @@ PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否 ``` 1. Read .claude/liangjing.json → 取出 project_id 2. 调用 MCP 工具 create_pull_request: - { "project_id": "", "head": "dev", "base": "main", + { "project_id": "", "head": "feat/", "base": "main", "title": "", "body": "<变更摘要>" } 3. 炼境内部用存储的 Gitea 凭证调 API,返回 pr_url ``` From 08e8d6d7191b96f5c6d9af3977f85133ec4a1f12 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 4 Jul 2026 14:04:20 +0900 Subject: [PATCH 06/14] =?UTF-8?q?fix(flywheel):=20=E4=BF=AE=E5=A4=8D=20Rul?= =?UTF-8?q?es-Applied=20trailer=20=E6=AD=BB=E9=93=BE=EF=BC=8Cgit=20log=20?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=E6=8F=90=E5=8F=96=20+=20UPSERT=20=E5=9B=9E?= =?UTF-8?q?=E5=A1=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1 管道自建成以来是死链:buff 与 ingest 两处 git log 都用 %s 只取 subject,trailer 在 body 里永远读不到(炼境自身 11 条带 trailer 的 commit,DB rules_applied 全空);单测喂全文所以全绿。 - GIT_LOG_FMT 加 %(trailers:key=Rules-Applied,valueonly,separator=%x2C), git 原生 trailer 解析,保持单行一条记录 - read_git_log 升为增量/全量统一取数入口(GitLogRange),buff 的 get_new_commits 委托之,消灭两处重复 format 定义 - record_commit_entry + upsert_metrics:ON CONFLICT 仅回填 rules_applied IS NULL 的存量行,绝不覆盖已有值,回填幂等 - ingest_full_git_history 改走 record_commit_entry,消灭第三处重复 INSERT - get_new_commits 的 WSL 分支随 2026-07-04 裁决顺手清理 - 测试:真 git 仓库 e2e trailer 提取回归 + UPSERT 四态语义,82 全绿 Rules-Applied: R06,R07,R13 --- .blueprint/manifest.yaml | 2 +- .blueprint/modules/flywheel-intelligence.md | 50 +++- src-tauri/src/commands/buff.rs | 71 +---- src-tauri/src/commands/commit_metrics.rs | 287 ++++++++++++++++---- 4 files changed, 282 insertions(+), 128 deletions(-) diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index 8493f46..016d2bf 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -306,7 +306,7 @@ modules: area: backend status: in_progress progress: 92 - note: "L0+F1+F2+R1+R2 完成(2026-07-02,rule_effectiveness 死链已接通);阶段三待数据覆盖 ≥10 项目" + note: "阶段三已过 architect+splitter(2026-07-04):P3-A~E 五卡待执行;P0 发现 R1 trailer 管道死链(%s 只取 subject),P3-A 修复" position: [1750, 650] - id: cloud-products diff --git a/.blueprint/modules/flywheel-intelligence.md b/.blueprint/modules/flywheel-intelligence.md index 1ffe8cb..6e3fa98 100644 --- a/.blueprint/modules/flywheel-intelligence.md +++ b/.blueprint/modules/flywheel-intelligence.md @@ -144,20 +144,48 @@ > 背景:"失败定边界、经验细化为变体"是经验飞轮的因果自洽核心,当前规则库只有正向 scope,无反例结构。 > 完成记录(2026-07-02):CONVENTIONS 升 v1.9.0,新增 boundaries/failure_cases 字段说明 + R11 + abandoned 模块状态定义;向其他项目的分发随下次发版生效(CONVENTIONS 编译进二进制)。 -### 阶段三:执行质量预测层 +### 阶段三:执行质量预测层(已过 /architect + /splitter,2026-07-04) + +> 触发条件已达标:commit_metrics 覆盖 17 项目 / 735 commit(459 conventional)。 +> 设计裁决:规则置信度闭环 + 风险预警本期做;时长分布(无任务时间戳)与模式库推荐(笔记进料枯竭)缓做。 +> 预测消费端 = MCP 工具 `predict_task_risk`(agent 开工前查询),梦核数据段为辅——预测给干活的 agent 用,不是给看板前的人看。 +> **P0 侦察发现**:R1 trailer 管道是死链——git log 用 `%s` 只取 subject,Rules-Applied trailer 在 body 里永远读不到(炼境自身 11 条带 trailer 的 commit,DB rules_applied 全空);单测喂全文所以全绿。P3-A 修复。 + +### ✅ P3-A. trailer 链路修复 + UPSERT 回填 +- status: done +- done_note: 2026-07-04 完成。GIT_LOG_FMT 加 %(trailers:key=Rules-Applied,valueonly)(git 原生 trailer 解析,保持单行记录);record_commit_entry + upsert_metrics(ON CONFLICT 仅回填 NULL,绝不覆盖);buff.rs get_new_commits 委托 commit_metrics::read_git_log(消灭重复 FMT + 顺手清理该函数 WSL 分支);ingest_full_git_history 改走 record_commit_entry(消灭第三处重复 INSERT)。测试:真 git 仓库 e2e trailer 提取回归 + UPSERT 四态语义,82 测试全绿。存量回填待新 build 重启后跑全量导入终验 +- complexity: M +- files: src-tauri/src/commands/commit_metrics.rs, src-tauri/src/commands/buff.rs +- acceptance: "[cargo test] read_git_log 用 %(trailers:key=Rules-Applied,valueonly) 提取 trailer,record_commit 改 UPSERT(仅补 rules_applied IS NULL 的行,不覆盖已有值)单测覆盖;[Tauri command] 对炼境自身重跑 ingest_full_git_history → commit_metrics 中 rules_applied 非空行 ≥10,rule_hit_stats 返回非空" +- notes: buff.rs 与 commit_metrics.rs 两处重复的 FMT 常量统一为 commit_metrics::read_git_log 单一来源;git 原生 trailer 解析保持单行一条记录,避免 %B 多行解析 + +### 📋 P3-B. commit_files 采集(增量 + 全量) - status: todo -- complexity: L -- depends: flywheel-intelligence(阶段二完成且数据积累达标后启动) -- files: TBD(需 /architect 后确定) -- acceptance: TBD +- complexity: M +- depends: flywheel-intelligence(卡 P3-A,共用 read_git_log 改造) +- files: src-tauri/src/commands/commit_metrics.rs, src-tauri/src/commands/buff.rs, src-tauri/src/db.rs +- acceptance: "[cargo test] 新表 commit_files(sha,path 联合主键) migration 到位(R05),git log --name-only 解析纯函数单测覆盖多 commit 多文件;[Tauri command] ingest_full_git_history 对炼境自身重跑 → commit_files 行数 >0 且重跑幂等(行数不变)" -阶段三方向(待 /architect 设计): -- 任务完成时长分布(为 S/M/L 定义时间基准) -- 高风险任务预警(特定文件组合 → 历史高返工率) -- 跨项目模式库推荐 -- **自动推荐规则置信度更新**:梦核读取复盘笔记中的 `auto_applied_rules` + `rule_effectiveness` 字段,输出 CONVENTIONS 规则库的 confidence/evidence_count 更新建议;新增规则提案(从高频踩坑模式自动归纳) +### 📋 P3-C. risk.rs 风险画像 + predict_task_risk MCP 工具 +- status: todo +- complexity: M +- depends: flywheel-intelligence(卡 P3-B) +- files: src-tauri/src/commands/risk.rs, src-tauri/src/commands/mod.rs, src-tauri/src/mcp_tools.rs +- acceptance: "[MCP tool] predict_task_risk(project_id, scope 或 files) → JSON 含 risk_level/rework_rate/sample_size/confidence/hot_files,无 error 字段;sample_size<5 时 confidence=low;[cargo test] 风险聚合纯函数(conn 注入 + in-memory DB)覆盖高返工 scope、小样本、脏历史(conventional 率低标不可信)三种输入;tools_list_result 含 predict_task_risk 断言" -> ⚠️ 阶段三为 L 级复杂度,到时须先走 /architect → /splitter 流程,禁止直接写代码。 +### 📋 P3-D. 梦核 prompt 高风险 scope 数据段 +- status: todo +- complexity: S +- depends: flywheel-intelligence(卡 P3-C) +- files: src-tauri/src/commands/blueprint.rs +- acceptance: "[cargo test] 梦核 prompt 构建函数单测断言输出含「跨项目高风险 scope」数据段(返工率 top scope + 项目分布);规则置信度段在 P3-A 数据流入后自然激活(注入点 blueprint.rs rule_hit_stats 已存在)" + +### 📋 P3-E. FlywheelPanel 风险展示 + commands.ts 同步(可选收尾) +- status: todo +- complexity: S +- depends: flywheel-intelligence(卡 P3-C) +- files: src/lib/commands.ts, src/components/dashboard/BlueprintModal.tsx +- acceptance: "[pnpm typecheck] exit 0;FlywheelPanel「Git 执行质量」卡片新增各项目返工率 top scope 列(get_scope_risk_profile 封装同步,R01)" ## 决策记录 diff --git a/src-tauri/src/commands/buff.rs b/src-tauri/src/commands/buff.rs index 524638c..cd998d0 100644 --- a/src-tauri/src/commands/buff.rs +++ b/src-tauri/src/commands/buff.rs @@ -160,14 +160,8 @@ fn check_project(app: &AppHandle, project_path: &str) -> Result<(), String> { } // 批量写入用事务包裹(首次可达数十条,避免逐条 fsync) let tx = conn.unchecked_transaction(); - for (sha, subject, date) in &commits { - let _ = crate::commands::commit_metrics::record_commit( - &conn, - &project_id, - sha, - subject, - Some(date.as_str()), - ); + for entry in &commits { + let _ = crate::commands::commit_metrics::record_commit_entry(&conn, &project_id, entry); } if let Ok(tx) = tx { let _ = tx.commit(); @@ -292,57 +286,20 @@ fn get_changed_files(root: &Path, last_commit: Option<&str>) -> Result) -> Vec<(String, String, String)> { - // sha \x1f subject \x1f committer-date(ISO),每条 commit 一行 - const FMT: &str = "--format=%H%x1f%s%x1f%cI"; - let path_str = root.to_string_lossy(); - - let out = if is_wsl_path(&path_str) { - let linux = match unc_to_linux(&path_str) { - Some(l) => l, - None => return vec![], - }; - let range = match last_commit { - Some(prev) => format!("{prev}..HEAD"), - None => format!("-n {FIRST_TIME_LIMIT}"), - }; - let cmd = format!("git -C {linux} log {range} {FMT}"); - crate::silent_cmd("wsl").args(["bash", "-c", &cmd]).output() - } else { - let mut cmd = crate::silent_cmd("git"); - cmd.arg("log"); - match last_commit { - Some(prev) => { - cmd.arg(format!("{prev}..HEAD")); - } - None => { - cmd.arg("-n").arg(FIRST_TIME_LIMIT.to_string()); - } - } - cmd.arg(FMT); - cmd.current_dir(root).output() +/// 取数统一走 commit_metrics::read_git_log(单一 format 来源,trailer 不再丢失)。 +/// WSL 路径分支已随 2026-07-04「放弃 WSL 路径项目」裁决清理。 +fn get_new_commits( + root: &Path, + last_commit: Option<&str>, +) -> Vec { + use crate::commands::commit_metrics::{read_git_log, GitLogRange}; + let range = match last_commit { + Some(prev) => GitLogRange::Since(prev), + None => GitLogRange::Limit(FIRST_TIME_LIMIT), }; - - let out = match out { - Ok(o) if o.status.success() => o, - _ => return vec![], - }; - - String::from_utf8_lossy(&out.stdout) - .lines() - .filter_map(|line| { - let mut parts = line.split('\u{1f}'); - let sha = parts.next()?.trim().to_string(); - if sha.is_empty() { - return None; - } - let subject = parts.next().unwrap_or("").to_string(); - let date = parts.next().unwrap_or("").to_string(); - Some((sha, subject, date)) - }) - .collect() + read_git_log(root, range).unwrap_or_default() } // ── 辅助:任务状态更新 ──────────────────────────────────────────────────────── diff --git a/src-tauri/src/commands/commit_metrics.rs b/src-tauri/src/commands/commit_metrics.rs index fe8a993..4b4595e 100644 --- a/src-tauri/src/commands/commit_metrics.rs +++ b/src-tauri/src/commands/commit_metrics.rs @@ -75,6 +75,21 @@ fn non_conventional(is_ci_auto: bool) -> ParsedCommit { } } +/// 把 trailer 原始值(如 "R01, r06 R01")归一化为大写去重逗号串。 +/// 只接受 R+数字 形式的 token;无有效 token 返回 None。 +pub fn normalize_rule_tokens(raw: &str) -> Option { + let mut ids: Vec = raw + .split([',', ' ', ',']) + .map(|t| t.trim().to_uppercase()) + .filter(|t| { + t.len() >= 2 && t.starts_with('R') && t[1..].chars().all(|c| c.is_ascii_digit()) + }) + .collect(); + ids.sort(); + ids.dedup(); + if ids.is_empty() { None } else { Some(ids.join(",")) } +} + /// 解析 commit message 中的 `Rules-Applied: R01,R06` trailer(飞轮 R1 规则有效性数据源)。 /// 只接受 R+数字 形式的 token,规范化为大写去重逗号串;无 trailer 或无有效 token 返回 None。 pub fn parse_rules_applied(message: &str) -> Option { @@ -87,24 +102,47 @@ pub fn parse_rules_applied(message: &str) -> Option { else { continue; }; - let rest = rest.trim(); - let mut ids: Vec = rest - .split([',', ' ', ',']) - .map(|t| t.trim().to_uppercase()) - .filter(|t| { - t.len() >= 2 - && t.starts_with('R') - && t[1..].chars().all(|c| c.is_ascii_digit()) - }) - .collect(); - ids.sort(); - ids.dedup(); - return if ids.is_empty() { None } else { Some(ids.join(",")) }; + return normalize_rule_tokens(rest); } None } -/// 解析并写入一条 commit 记录(sha 为主键,重复写入忽略)。 +/// UPSERT 写入一条 commit 记录:新 sha 插入;已存在且 rules_applied 为空时仅回填 +/// rules_applied(绝不覆盖已有值——回填幂等的关键约束)。返回是否产生了写入。 +fn upsert_metrics( + conn: &Connection, + project_id: &str, + sha: &str, + parsed: &ParsedCommit, + rules_applied: Option<&str>, + committed_at: Option<&str>, +) -> Result { + let n = conn + .execute( + "INSERT INTO commit_metrics + (sha, project_id, commit_type, scope, is_rework, is_ci_auto, committed_at, rules_applied) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT(sha) DO UPDATE SET rules_applied = excluded.rules_applied + WHERE commit_metrics.rules_applied IS NULL + AND excluded.rules_applied IS NOT NULL", + rusqlite::params![ + sha, + project_id, + parsed.commit_type, + parsed.scope, + parsed.is_rework as i32, + parsed.is_ci_auto as i32, + committed_at, + rules_applied, + ], + ) + .map_err(|e| e.to_string())?; + Ok(n > 0) +} + +/// 解析并写入一条 commit 记录(从完整 message 提取 conventional + Rules-Applied trailer)。 +/// 生产链路的 git log 只给 subject,trailer 走 `record_commit_entry`;本函数保留给 +/// 持有完整 message 的调用方与测试。 pub fn record_commit( conn: &Connection, project_id: &str, @@ -114,25 +152,26 @@ pub fn record_commit( ) -> Result<(), String> { let p = parse_conventional(message); let rules_applied = parse_rules_applied(message); - conn.execute( - "INSERT OR IGNORE INTO commit_metrics - (sha, project_id, commit_type, scope, is_rework, is_ci_auto, committed_at, rules_applied) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - rusqlite::params![ - sha, - project_id, - p.commit_type, - p.scope, - p.is_rework as i32, - p.is_ci_auto as i32, - committed_at, - rules_applied, - ], - ) - .map_err(|e| e.to_string())?; + upsert_metrics(conn, project_id, sha, &p, rules_applied.as_deref(), committed_at)?; Ok(()) } +/// 写入一条来自 `read_git_log` 的记录(subject 与 trailer 分列,trailer 由 git 原生解析)。 +pub fn record_commit_entry( + conn: &Connection, + project_id: &str, + entry: &GitLogEntry, +) -> Result { + let p = parse_conventional(&entry.subject); + let rules = normalize_rule_tokens(&entry.rules_trailer); + let date = if entry.committed_at.is_empty() { + None + } else { + Some(entry.committed_at.as_str()) + }; + upsert_metrics(conn, project_id, &entry.sha, &p, rules.as_deref(), date) +} + /// 单条规则的命中统计(跨项目聚合) #[derive(Debug, Serialize, Deserialize, Clone)] pub struct RuleHitStat { @@ -248,17 +287,63 @@ const FULL_HISTORY_LIMIT: usize = 500; /// 全量历史补录结果。 #[derive(Debug, Serialize)] pub struct IngestResult { - /// 本次新写入 commit 数 + /// 本次产生写入的 commit 数(新插入 + rules_applied 回填) pub imported: u32, - /// 已存在(INSERT OR IGNORE 跳过) + /// 已存在且无需回填(跳过) pub skipped: u32, } -/// 读取 git log 最近 limit 条:返回 (sha, subject, committed_at_iso)。 -fn read_git_log(root: &std::path::Path, limit: usize) -> Result, String> { - const FMT: &str = "--format=%H%x1f%s%x1f%cI"; - let out = crate::silent_cmd("git") - .args(["log", "-n", &limit.to_string(), FMT]) +/// git log 单条记录。`rules_trailer` 是 git 原生解析出的 Rules-Applied trailer 值 +/// (如 "R01,R06"),无 trailer 时为空串——trailer 在 commit body 里,`%s` 取不到, +/// 必须靠 `%(trailers:...)` 提取(曾因只用 %s 导致 R1 管道死链)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitLogEntry { + pub sha: String, + pub subject: String, + pub committed_at: String, + pub rules_trailer: String, +} + +/// git log 取数范围:最近 N 条(全量/首次),或 last..HEAD(增量 watcher)。 +pub enum GitLogRange<'a> { + Limit(usize), + Since(&'a str), +} + +/// sha \x1f subject \x1f committer-date(ISO) \x1f Rules-Applied值(逗号分隔),每条一行。 +const GIT_LOG_FMT: &str = + "--format=%H%x1f%s%x1f%cI%x1f%(trailers:key=Rules-Applied,valueonly,separator=%x2C)"; + +/// 解析 GIT_LOG_FMT 输出的一行。纯函数,可独立单测。 +pub fn parse_git_log_line(line: &str) -> Option { + let mut parts = line.split('\u{1f}'); + let sha = parts.next()?.trim().to_string(); + if sha.is_empty() { + return None; + } + Some(GitLogEntry { + sha, + subject: parts.next().unwrap_or("").to_string(), + committed_at: parts.next().unwrap_or("").to_string(), + rules_trailer: parts.next().unwrap_or("").trim().to_string(), + }) +} + +/// 读取 git log(含 Rules-Applied trailer)。增量与全量两条链路的单一取数入口, +/// buff watcher 与 ingest_full_git_history 共用,禁止再各自定义 format。 +pub fn read_git_log(root: &std::path::Path, range: GitLogRange) -> Result, String> { + let mut cmd = crate::silent_cmd("git"); + cmd.arg("log"); + match range { + GitLogRange::Limit(n) => { + cmd.args(["-n", &n.to_string()]); + } + GitLogRange::Since(prev) => { + cmd.arg(format!("{prev}..HEAD")); + } + } + let out = cmd + .arg(GIT_LOG_FMT) .current_dir(root) .output() .map_err(|e| format!("git log 失败: {e}"))?; @@ -267,14 +352,7 @@ fn read_git_log(root: &std::path::Path, limit: usize) -> Result GitLogEntry { + GitLogEntry { + sha: sha.into(), + subject: subject.into(), + committed_at: "2026-07-04T00:00:00+09:00".into(), + rules_trailer: trailer.into(), + } + } + + fn rules_of(conn: &rusqlite::Connection, sha: &str) -> Option { + conn.query_row( + "SELECT rules_applied FROM commit_metrics WHERE sha = ?1", + [sha], + |r| r.get(0), + ) + .unwrap() + } + + #[test] + fn parse_git_log_line_with_and_without_trailer() { + let e = parse_git_log_line("abc\u{1f}feat(x): y\u{1f}2026-07-04T00:00:00+09:00\u{1f}R01,R06") + .unwrap(); + assert_eq!(e.sha, "abc"); + assert_eq!(e.subject, "feat(x): y"); + assert_eq!(e.rules_trailer, "R01,R06"); + + let e2 = parse_git_log_line("def\u{1f}fix: z\u{1f}2026-07-04T00:00:00+09:00\u{1f}").unwrap(); + assert_eq!(e2.rules_trailer, "", "无 trailer 时为空串"); + + assert!(parse_git_log_line("").is_none()); + } + + #[test] + fn normalize_rule_tokens_filters_and_dedups() { + assert_eq!(normalize_rule_tokens("R01, r06 R01").as_deref(), Some("R01,R06")); + assert_eq!(normalize_rule_tokens("无效, abc"), None); + assert_eq!(normalize_rule_tokens(""), None); + } + + #[test] + fn upsert_backfills_null_rules_only() { + let conn = crate::db::conn_with_schema(); + // 模拟存量行:%s 时代写入,rules_applied 为 NULL + record_commit(&conn, "proj", "old", "feat(a): x", None).unwrap(); + assert_eq!(rules_of(&conn, "old"), None); + + // 回填:同 sha 带 trailer 重写 → 补上 + let changed = record_commit_entry(&conn, "proj", &entry("old", "feat(a): x", "R01,R06")).unwrap(); + assert!(changed, "回填应计为写入"); + assert_eq!(rules_of(&conn, "old").as_deref(), Some("R01,R06")); + + // 不覆盖:已有值的行,不同 trailer 重写 → 保持原值 + let changed = record_commit_entry(&conn, "proj", &entry("old", "feat(a): x", "R99")).unwrap(); + assert!(!changed, "已有值不应被覆盖"); + assert_eq!(rules_of(&conn, "old").as_deref(), Some("R01,R06")); + + // 无 trailer 重写已有 NULL 行 → 不产生无意义 UPDATE + record_commit(&conn, "proj", "old2", "chore: y", None).unwrap(); + let changed = record_commit_entry(&conn, "proj", &entry("old2", "chore: y", "")).unwrap(); + assert!(!changed); + + // 新行带 trailer 直接写入 + assert!(record_commit_entry(&conn, "proj", &entry("new", "fix(b): z", "R05")).unwrap()); + assert_eq!(rules_of(&conn, "new").as_deref(), Some("R05")); + } + + /// 端到端:真 git 仓库验证 %(trailers) 提取——这是 R1 死链(%s 只取 subject)的回归测试。 + #[test] + fn read_git_log_extracts_trailer_from_real_repo() { + let dir = std::env::temp_dir().join(format!("lj-trailer-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + let git = |args: &[&str]| { + let out = std::process::Command::new("git") + .args(args) + .current_dir(&dir) + .output() + .expect("git 可执行"); + assert!(out.status.success(), "git {:?} 失败: {}", args, String::from_utf8_lossy(&out.stderr)); + }; + git(&["init", "-q"]); + git(&["-c", "user.name=t", "-c", "user.email=t@t", "commit", "--allow-empty", "-q", + "-m", "feat(a): no trailer"]); + git(&["-c", "user.name=t", "-c", "user.email=t@t", "commit", "--allow-empty", "-q", + "-m", "fix(b): with trailer", "-m", "Rules-Applied: R01,R06"]); + + let entries = read_git_log(&dir, GitLogRange::Limit(10)).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + + assert_eq!(entries.len(), 2); + // git log 逆序:最新在前 + assert_eq!(entries[0].subject, "fix(b): with trailer"); + assert_eq!(entries[0].rules_trailer, "R01,R06", "trailer 必须被提取(%s 死链回归)"); + assert_eq!(entries[1].rules_trailer, ""); + } + #[test] fn conventional_stats_counts_ratio() { let conn = crate::db::conn_with_schema(); From 0ac809970f15910b703d2eec8d2f1fb4fcfee4f4 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 4 Jul 2026 14:07:57 +0900 Subject: [PATCH 07/14] =?UTF-8?q?feat(flywheel):=20commit=5Ffiles=20?= =?UTF-8?q?=E9=87=87=E9=9B=86=E2=80=94=E2=80=94=E6=96=87=E4=BB=B6=E7=BA=A7?= =?UTF-8?q?=E9=A3=8E=E9=99=A9=E7=94=BB=E5=83=8F=E6=95=B0=E6=8D=AE=E5=9C=B0?= =?UTF-8?q?=E5=9F=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新表 commit_files(sha,path 联合主键 + path 索引),migration 入 migrate() - read_git_log 加 --name-only:单次 git 调用同时取 conventional 指标、 Rules-Applied trailer 与触碰文件,零额外进程开销 - parse_git_log_output 纯函数解析 header/文件行混排输出(含 \x1f 的行 是 header,其后非空行归属该 commit) - record_commit_entry 落库文件清单(INSERT OR IGNORE 幂等),buff 增量 与 ingest 全量两条链路自动覆盖,无需各自改动 Rules-Applied: R05,R06,R07 --- .blueprint/modules/flywheel-intelligence.md | 5 +- src-tauri/src/commands/commit_metrics.rs | 119 ++++++++++++++------ src-tauri/src/db.rs | 10 ++ 3 files changed, 100 insertions(+), 34 deletions(-) diff --git a/.blueprint/modules/flywheel-intelligence.md b/.blueprint/modules/flywheel-intelligence.md index 6e3fa98..5489d5b 100644 --- a/.blueprint/modules/flywheel-intelligence.md +++ b/.blueprint/modules/flywheel-intelligence.md @@ -159,8 +159,9 @@ - acceptance: "[cargo test] read_git_log 用 %(trailers:key=Rules-Applied,valueonly) 提取 trailer,record_commit 改 UPSERT(仅补 rules_applied IS NULL 的行,不覆盖已有值)单测覆盖;[Tauri command] 对炼境自身重跑 ingest_full_git_history → commit_metrics 中 rules_applied 非空行 ≥10,rule_hit_stats 返回非空" - notes: buff.rs 与 commit_metrics.rs 两处重复的 FMT 常量统一为 commit_metrics::read_git_log 单一来源;git 原生 trailer 解析保持单行一条记录,避免 %B 多行解析 -### 📋 P3-B. commit_files 采集(增量 + 全量) -- status: todo +### ✅ P3-B. commit_files 采集(增量 + 全量) +- status: done +- done_note: 2026-07-04 完成。commit_files(sha,path 联合主键 + path 索引) migration 到位;read_git_log 加 --name-only,单次 git 调用同时取指标与文件(parse_git_log_output 纯函数解析 header/文件行混排);record_commit_entry 落库文件(INSERT OR IGNORE 幂等),buff 增量与 ingest 全量两条链路自动覆盖。83 测试全绿(真仓库 e2e 含文件断言) - complexity: M - depends: flywheel-intelligence(卡 P3-A,共用 read_git_log 改造) - files: src-tauri/src/commands/commit_metrics.rs, src-tauri/src/commands/buff.rs, src-tauri/src/db.rs diff --git a/src-tauri/src/commands/commit_metrics.rs b/src-tauri/src/commands/commit_metrics.rs index 4b4595e..46f37c7 100644 --- a/src-tauri/src/commands/commit_metrics.rs +++ b/src-tauri/src/commands/commit_metrics.rs @@ -157,6 +157,7 @@ pub fn record_commit( } /// 写入一条来自 `read_git_log` 的记录(subject 与 trailer 分列,trailer 由 git 原生解析)。 +/// 同时把触碰文件写入 commit_files(INSERT OR IGNORE 幂等,供文件级风险画像)。 pub fn record_commit_entry( conn: &Connection, project_id: &str, @@ -169,7 +170,15 @@ pub fn record_commit_entry( } else { Some(entry.committed_at.as_str()) }; - upsert_metrics(conn, project_id, &entry.sha, &p, rules.as_deref(), date) + let changed = upsert_metrics(conn, project_id, &entry.sha, &p, rules.as_deref(), date)?; + for path in &entry.files { + conn.execute( + "INSERT OR IGNORE INTO commit_files (sha, path) VALUES (?1, ?2)", + rusqlite::params![entry.sha, path], + ) + .map_err(|e| e.to_string())?; + } + Ok(changed) } /// 单条规则的命中统计(跨项目聚合) @@ -296,12 +305,14 @@ pub struct IngestResult { /// git log 单条记录。`rules_trailer` 是 git 原生解析出的 Rules-Applied trailer 值 /// (如 "R01,R06"),无 trailer 时为空串——trailer 在 commit body 里,`%s` 取不到, /// 必须靠 `%(trailers:...)` 提取(曾因只用 %s 导致 R1 管道死链)。 +/// `files` 来自 --name-only(P3-B 文件级风险画像数据源),merge commit 为空。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct GitLogEntry { pub sha: String, pub subject: String, pub committed_at: String, pub rules_trailer: String, + pub files: Vec, } /// git log 取数范围:最近 N 条(全量/首次),或 last..HEAD(增量 watcher)。 @@ -310,27 +321,43 @@ pub enum GitLogRange<'a> { Since(&'a str), } -/// sha \x1f subject \x1f committer-date(ISO) \x1f Rules-Applied值(逗号分隔),每条一行。 +/// sha \x1f subject \x1f committer-date(ISO) \x1f Rules-Applied值(逗号分隔)。 +/// 配合 --name-only:header 行后跟随该 commit 触碰的文件路径行。 const GIT_LOG_FMT: &str = "--format=%H%x1f%s%x1f%cI%x1f%(trailers:key=Rules-Applied,valueonly,separator=%x2C)"; -/// 解析 GIT_LOG_FMT 输出的一行。纯函数,可独立单测。 -pub fn parse_git_log_line(line: &str) -> Option { - let mut parts = line.split('\u{1f}'); - let sha = parts.next()?.trim().to_string(); - if sha.is_empty() { - return None; +/// 解析 GIT_LOG_FMT + --name-only 的完整输出。纯函数,可独立单测。 +/// 规则:含 \x1f 的行是 commit header;其后的非空行是该 commit 的文件路径。 +pub fn parse_git_log_output(output: &str) -> Vec { + let mut entries: Vec = Vec::new(); + for line in output.lines() { + if line.contains('\u{1f}') { + let mut parts = line.split('\u{1f}'); + let sha = parts.next().unwrap_or("").trim().to_string(); + if sha.is_empty() { + continue; + } + entries.push(GitLogEntry { + sha, + subject: parts.next().unwrap_or("").to_string(), + committed_at: parts.next().unwrap_or("").to_string(), + rules_trailer: parts.next().unwrap_or("").trim().to_string(), + files: Vec::new(), + }); + } else { + let path = line.trim(); + if !path.is_empty() { + if let Some(cur) = entries.last_mut() { + cur.files.push(path.to_string()); + } + } + } } - Some(GitLogEntry { - sha, - subject: parts.next().unwrap_or("").to_string(), - committed_at: parts.next().unwrap_or("").to_string(), - rules_trailer: parts.next().unwrap_or("").trim().to_string(), - }) + entries } -/// 读取 git log(含 Rules-Applied trailer)。增量与全量两条链路的单一取数入口, -/// buff watcher 与 ingest_full_git_history 共用,禁止再各自定义 format。 +/// 读取 git log(含 Rules-Applied trailer 与触碰文件)。增量与全量两条链路的 +/// 单一取数入口,buff watcher 与 ingest_full_git_history 共用,禁止再各自定义 format。 pub fn read_git_log(root: &std::path::Path, range: GitLogRange) -> Result, String> { let mut cmd = crate::silent_cmd("git"); cmd.arg("log"); @@ -343,17 +370,14 @@ pub fn read_git_log(root: &std::path::Path, range: GitLogRange) -> Result Date: Sat, 4 Jul 2026 14:12:35 +0900 Subject: [PATCH 08/14] =?UTF-8?q?feat(flywheel):=20predict=5Ftask=5Frisk?= =?UTF-8?q?=20=E9=A3=8E=E9=99=A9=E7=94=BB=E5=83=8F=20MCP=20=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E2=80=94=E2=80=94=E9=A2=84=E6=B5=8B=E5=B1=82=E8=90=BD?= =?UTF-8?q?=E5=9C=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 commands/risk.rs:scope 级(前缀匹配子 scope)+ 文件级 (commit_files JOIN commit_metrics)历史返工率聚合 - classify_risk 分级:返工率 ≥0.35 high、≥0.15 medium、其余 low; 有效返工率取 scope 与文件(样本 ≥3)的较高者 - 可信度防线:样本 <5 或 conventional 率 <0.5(脏历史)→ confidence=low 并附原因 notes,不让脏数据装可信 - CI 自修 commit(is_ci_auto)不计返工,与 L0 口径一致 - MCP 注册 predict_task_risk:agent 开工前按任务卡 scope/files 查询风险,工具清单测试断言驻军 - 7 个单测(in-memory DB + conn 注入),90 全绿 Rules-Applied: R01,R06,R07,R13 --- .blueprint/modules/flywheel-intelligence.md | 5 +- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/risk.rs | 297 ++++++++++++++++++++ src-tauri/src/mcp_tools.rs | 30 ++ 4 files changed, 331 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/commands/risk.rs diff --git a/.blueprint/modules/flywheel-intelligence.md b/.blueprint/modules/flywheel-intelligence.md index 5489d5b..f08bc3a 100644 --- a/.blueprint/modules/flywheel-intelligence.md +++ b/.blueprint/modules/flywheel-intelligence.md @@ -167,8 +167,9 @@ - files: src-tauri/src/commands/commit_metrics.rs, src-tauri/src/commands/buff.rs, src-tauri/src/db.rs - acceptance: "[cargo test] 新表 commit_files(sha,path 联合主键) migration 到位(R05),git log --name-only 解析纯函数单测覆盖多 commit 多文件;[Tauri command] ingest_full_git_history 对炼境自身重跑 → commit_files 行数 >0 且重跑幂等(行数不变)" -### 📋 P3-C. risk.rs 风险画像 + predict_task_risk MCP 工具 -- status: todo +### ✅ P3-C. risk.rs 风险画像 + predict_task_risk MCP 工具 +- status: done +- done_note: 2026-07-04 完成。commands/risk.rs:scope 级(前缀匹配子 scope)+ 文件级(commit_files JOIN)返工率聚合,classify_risk 阈值 ≥0.35 high / ≥0.15 medium;样本 <5 或 conventional 率 <0.5(脏历史)→ confidence=low 并附 notes;CI 自修剔除。MCP 工具 schema+dispatch+清单断言。7 个单测(高返工 scope/小样本/脏历史/热点文件/CI 剔除/入参校验/阈值),90 全绿。MCP 实调待新 build 重启后终验 - complexity: M - depends: flywheel-intelligence(卡 P3-B) - files: src-tauri/src/commands/risk.rs, src-tauri/src/commands/mod.rs, src-tauri/src/mcp_tools.rs diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 2466c08..86f05d9 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -18,6 +18,7 @@ pub mod cicd; pub mod blueprint; pub mod buff; pub mod commit_metrics; +pub mod risk; pub mod onboarding; pub mod canvas; pub mod claude_config; diff --git a/src-tauri/src/commands/risk.rs b/src-tauri/src/commands/risk.rs new file mode 100644 index 0000000..5859970 --- /dev/null +++ b/src-tauri/src/commands/risk.rs @@ -0,0 +1,297 @@ +//! 飞轮阶段三:执行质量风险画像(预测层)。 +//! +//! 从 commit_metrics(scope 级)+ commit_files(文件级)聚合历史返工率, +//! 供 agent 开工前通过 MCP 工具 `predict_task_risk` 评估任务风险。 +//! 全部查询走 conn 注入(R06),聚合与分级逻辑可用 in-memory DB 独立单测。 + +use rusqlite::Connection; +use serde::Serialize; + +/// scope 级返工画像。 +#[derive(Debug, Serialize)] +pub struct ScopeRisk { + pub scope: String, + pub commits: u32, + pub rework: u32, + pub rework_rate: f64, +} + +/// 单文件返工画像。 +#[derive(Debug, Serialize)] +pub struct FileRisk { + pub path: String, + pub commits: u32, + pub rework: u32, + pub rework_rate: f64, +} + +/// predict_task_risk 的完整返回。 +#[derive(Debug, Serialize)] +pub struct TaskRiskPrediction { + pub project_id: String, + pub scope_stats: Option, + pub file_stats: Vec, + /// 结论依据的样本量(scope 命中 commit 数;无 scope 时取文件最大命中数) + pub sample_size: u32, + /// low = 样本 <5 或历史数据脏(conventional 率 <0.5),结论仅供参考 + pub confidence: String, + /// low / medium / high,取 scope 与文件返工率的较高者分级 + pub risk_level: String, + pub notes: Vec, +} + +/// 返工率分级阈值:≥0.35 high、≥0.15 medium、其余 low。纯函数。 +pub fn classify_risk(rate: f64) -> &'static str { + if rate >= 0.35 { + "high" + } else if rate >= 0.15 { + "medium" + } else { + "low" + } +} + +fn rate(rework: u32, commits: u32) -> f64 { + if commits == 0 { + 0.0 + } else { + (rework as f64 / commits as f64 * 1000.0).round() / 1000.0 + } +} + +/// scope 级聚合:精确匹配或前缀匹配("auth" 命中 "auth" 与 "auth/login")。 +fn scope_risk(conn: &Connection, project_id: &str, scope: &str) -> Result, String> { + let (commits, rework): (u32, u32) = conn + .query_row( + "SELECT COUNT(*), + COALESCE(SUM(CASE WHEN is_rework = 1 AND is_ci_auto = 0 THEN 1 ELSE 0 END), 0) + FROM commit_metrics + WHERE project_id = ?1 AND (scope = ?2 OR scope LIKE ?2 || '/%')", + rusqlite::params![project_id, scope], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .map_err(|e| e.to_string())?; + if commits == 0 { + return Ok(None); + } + Ok(Some(ScopeRisk { + scope: scope.to_string(), + rework_rate: rate(rework, commits), + commits, + rework, + })) +} + +/// 文件级聚合:每个文件在该项目历史中被多少 commit 触碰、其中多少是返工。 +fn file_risks(conn: &Connection, project_id: &str, files: &[String]) -> Result, String> { + let mut result = Vec::new(); + for path in files { + let (commits, rework): (u32, u32) = conn + .query_row( + "SELECT COUNT(*), + COALESCE(SUM(CASE WHEN cm.is_rework = 1 AND cm.is_ci_auto = 0 THEN 1 ELSE 0 END), 0) + FROM commit_files cf + JOIN commit_metrics cm ON cm.sha = cf.sha + WHERE cm.project_id = ?1 AND cf.path = ?2", + rusqlite::params![project_id, path], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .map_err(|e| e.to_string())?; + if commits > 0 { + result.push(FileRisk { + path: path.clone(), + rework_rate: rate(rework, commits), + commits, + rework, + }); + } + } + // 返工次数降序,agent 一眼看到最烫的文件 + result.sort_by(|a, b| b.rework.cmp(&a.rework)); + Ok(result) +} + +/// 核心入口:按 scope 和/或文件清单预测任务风险。 +pub fn predict_task_risk( + conn: &Connection, + project_id: &str, + scope: Option<&str>, + files: &[String], +) -> Result { + if scope.is_none() && files.is_empty() { + return Err("scope 与 files 至少提供一个".to_string()); + } + + let scope_stats = match scope { + Some(s) if !s.trim().is_empty() => scope_risk(conn, project_id, s.trim())?, + _ => None, + }; + let file_stats = file_risks(conn, project_id, files)?; + + let mut notes = Vec::new(); + + // 脏历史检测:conventional 率 <0.5 时结论不可信(数据可信度提示,与 onboarding 卡 D 同源) + let (total, conv): (u32, u32) = conn + .query_row( + "SELECT COUNT(*), + COALESCE(SUM(CASE WHEN commit_type IS NOT NULL THEN 1 ELSE 0 END), 0) + FROM commit_metrics WHERE project_id = ?1", + [project_id], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .map_err(|e| e.to_string())?; + let dirty_history = total > 0 && (conv as f64 / total as f64) < 0.5; + if dirty_history { + notes.push(format!( + "历史数据脏:conventional 率 {:.0}%({}/{}),建议种 lefthook 后以新数据为准", + conv as f64 / total as f64 * 100.0, + conv, + total + )); + } + + let sample_size = scope_stats + .as_ref() + .map(|s| s.commits) + .unwrap_or_else(|| file_stats.iter().map(|f| f.commits).max().unwrap_or(0)); + + // 有效返工率取 scope 与文件中的较高者(文件样本 ≥3 才参与,避免单次 fix 拉爆) + let scope_rate = scope_stats.as_ref().map(|s| s.rework_rate).unwrap_or(0.0); + let file_rate = file_stats + .iter() + .filter(|f| f.commits >= 3) + .map(|f| f.rework_rate) + .fold(0.0_f64, f64::max); + let effective_rate = scope_rate.max(file_rate); + + let confidence = if sample_size < 5 || dirty_history { + if sample_size < 5 { + notes.push(format!("样本量 {sample_size} <5,结论仅供参考")); + } + "low" + } else { + "normal" + }; + + Ok(TaskRiskPrediction { + project_id: project_id.to_string(), + scope_stats, + file_stats, + sample_size, + confidence: confidence.to_string(), + risk_level: classify_risk(effective_rate).to_string(), + notes, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::commit_metrics::{record_commit_entry, GitLogEntry}; + + fn seed(conn: &Connection, pid: &str, sha: &str, subject: &str, files: &[&str]) { + let entry = GitLogEntry { + sha: sha.into(), + subject: subject.into(), + committed_at: "2026-07-04T00:00:00+09:00".into(), + rules_trailer: String::new(), + files: files.iter().map(|s| s.to_string()).collect(), + }; + record_commit_entry(conn, pid, &entry).unwrap(); + } + + #[test] + fn classify_thresholds() { + assert_eq!(classify_risk(0.4), "high"); + assert_eq!(classify_risk(0.35), "high"); + assert_eq!(classify_risk(0.2), "medium"); + assert_eq!(classify_risk(0.1), "low"); + } + + #[test] + fn high_rework_scope_flagged_high() { + let conn = crate::db::conn_with_schema(); + // onboarding scope:6 commits 里 3 条 fix(含一个子 scope),返工率 0.5 + for (i, subj) in [ + "feat(onboarding): a", + "fix(onboarding): b", + "fix(onboarding/pack): c", + "feat(onboarding): d", + "fix(onboarding): e", + "chore(onboarding): f", + ] + .iter() + .enumerate() + { + seed(&conn, "p1", &format!("s{i}"), subj, &[]); + } + let r = predict_task_risk(&conn, "p1", Some("onboarding"), &[]).unwrap(); + let s = r.scope_stats.unwrap(); + assert_eq!(s.commits, 6, "前缀匹配应包含子 scope"); + assert_eq!(s.rework, 3); + assert_eq!(r.risk_level, "high"); + assert_eq!(r.confidence, "normal"); + } + + #[test] + fn small_sample_low_confidence() { + let conn = crate::db::conn_with_schema(); + seed(&conn, "p1", "s1", "fix(auth): x", &[]); + seed(&conn, "p1", "s2", "feat(auth): y", &[]); + let r = predict_task_risk(&conn, "p1", Some("auth"), &[]).unwrap(); + assert_eq!(r.confidence, "low"); + assert!(r.notes.iter().any(|n| n.contains("样本量")), "应有小样本提示"); + } + + #[test] + fn dirty_history_noted_and_low_confidence() { + let conn = crate::db::conn_with_schema(); + // 10 条里仅 3 条 conventional → 脏历史 + for i in 0..7 { + seed(&conn, "p1", &format!("junk{i}"), "update stuff", &[]); + } + seed(&conn, "p1", "c1", "feat(api): a", &[]); + seed(&conn, "p1", "c2", "feat(api): b", &[]); + seed(&conn, "p1", "c3", "fix(api): c", &[]); + // api scope 样本 5 条以下也行,这里重点断言脏历史标记 + let r = predict_task_risk(&conn, "p1", Some("api"), &[]).unwrap(); + assert_eq!(r.confidence, "low"); + assert!(r.notes.iter().any(|n| n.contains("历史数据脏"))); + } + + #[test] + fn file_level_rework_surfaces_hot_file() { + let conn = crate::db::conn_with_schema(); + // hot.rs 被 4 个 commit 触碰,其中 2 个 fix;cold.rs 只有 feat + seed(&conn, "p1", "s1", "feat(a): x", &["src/hot.rs", "src/cold.rs"]); + seed(&conn, "p1", "s2", "fix(a): y", &["src/hot.rs"]); + seed(&conn, "p1", "s3", "fix(a): z", &["src/hot.rs"]); + seed(&conn, "p1", "s4", "feat(a): w", &["src/hot.rs"]); + let r = predict_task_risk( + &conn, + "p1", + None, + &["src/hot.rs".into(), "src/cold.rs".into(), "src/ghost.rs".into()], + ) + .unwrap(); + assert_eq!(r.file_stats.len(), 2, "无历史的文件不进结果"); + assert_eq!(r.file_stats[0].path, "src/hot.rs", "返工多的文件排前"); + assert_eq!(r.file_stats[0].rework, 2); + assert_eq!(r.risk_level, "high", "hot.rs 返工率 0.5 应分级 high"); + } + + #[test] + fn requires_scope_or_files() { + let conn = crate::db::conn_with_schema(); + assert!(predict_task_risk(&conn, "p1", None, &[]).is_err()); + } + + #[test] + fn ci_auto_fix_excluded_from_rework() { + let conn = crate::db::conn_with_schema(); + seed(&conn, "p1", "s1", "fix(ci): auto-fix [DeepSeek-V3]", &[]); + seed(&conn, "p1", "s2", "feat(ci): x", &[]); + let r = predict_task_risk(&conn, "p1", Some("ci"), &[]).unwrap(); + assert_eq!(r.scope_stats.unwrap().rework, 0, "CI 自修不计返工"); + } +} diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index 1c5c1a0..04345b2 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -245,6 +245,19 @@ pub fn tools_list_result() -> Value { "name": "scan_unregistered_projects", "description": "扫描已登记项目的父目录,找出存在于文件系统但尚未在炼境注册的项目目录。返回每个未注册目录的路径、是否有 .git、技术栈特征文件(package.json / Cargo.toml / go.mod)、是否有 AGENTS.md。用于日常发现漏网项目并决定是否接入炼境。", "inputSchema": { "type": "object", "properties": {}, "required": [] } + }, + { + "name": "predict_task_risk", + "description": "任务风险预测(飞轮阶段三):按 scope 和/或文件清单查询该项目历史返工率,返回 risk_level(low/medium/high)、样本量与置信度、热点文件排行。agent 开工前调用——任务卡涉及高返工 scope/文件时提高警惕(多写测试、小步提交)。样本 <5 或 commit 历史不规范时 confidence=low,结论仅供参考。", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" }, + "scope": { "type": "string", "description": "conventional commit scope(如 onboarding、auth/login),前缀匹配子 scope" }, + "files": { "type": "array", "items": { "type": "string" }, "description": "任务卡涉及的文件相对路径清单(与 git log --name-only 路径格式一致)" } + }, + "required": ["project_id"] + } } ] }) @@ -462,6 +475,22 @@ pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value { } "list_all_projects" => list_all_projects(), "scan_unregistered_projects" => scan_unregistered_projects(), + "predict_task_risk" => { + let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); + let scope = args.get("scope").and_then(|v| v.as_str()); + let files: Vec = args + .get("files") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|f| f.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + let conn = db::pool().get().map_err(|e| e.to_string())?; + crate::commands::risk::predict_task_risk(&conn, pid, scope, &files) + .and_then(|r| serde_json::to_string_pretty(&r).map_err(|e| e.to_string())) + } "apply_onboarding_pack" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); resolve_project_root(&gid, pid).and_then(|root| { @@ -1039,6 +1068,7 @@ mod tests { assert!(names.contains(&"inject_mcp"), "缺少 inject_mcp"); assert!(names.contains(&"create_pull_request"), "缺少 create_pull_request"); assert!(names.contains(&"poll_now"), "缺少 poll_now"); + assert!(names.contains(&"predict_task_risk"), "缺少 predict_task_risk"); // 每个工具都有 description 和 inputSchema for tool in tools { let name = tool["name"].as_str().unwrap_or("?"); From 66dc6209a85494c350abf7d85ccdb3ece70826cc Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 4 Jul 2026 14:18:04 +0900 Subject: [PATCH 09/14] =?UTF-8?q?feat(flywheel):=20=E6=A2=A6=E6=A0=B8=20pr?= =?UTF-8?q?ompt=20=E6=B3=A8=E5=85=A5=E8=B7=A8=E9=A1=B9=E7=9B=AE=E9=AB=98?= =?UTF-8?q?=E9=A3=8E=E9=99=A9=20scope=20=E6=95=B0=E6=8D=AE=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - risk.rs 新增 high_risk_scopes:样本 ≥5 且有非 CI 返工的 scope 按返工率降序 top 10,项目名 LEFT JOIN project_profiles(缺失回退 id) - generate_muhe_prompt 新增「跨项目高风险 scope」表格段,附分析指引 (反复返工 scope = 结构性脆弱点 → 固化环节输出预防性规范) - rule_hits 段 pool() 改 try_pool():原实现测试环境必 panic, generate_muhe_prompt 因此不可测;改后两段在无 DB 时走占位文案 - prompt 构建测试断言两个数据段存在 + 聚合排序/最小样本过滤测试,92 全绿 Rules-Applied: R06,R07 --- .blueprint/modules/flywheel-intelligence.md | 5 +- src-tauri/src/commands/blueprint.rs | 62 ++++++++++++++++- src-tauri/src/commands/risk.rs | 77 +++++++++++++++++++++ 3 files changed, 139 insertions(+), 5 deletions(-) diff --git a/.blueprint/modules/flywheel-intelligence.md b/.blueprint/modules/flywheel-intelligence.md index f08bc3a..8f3f8fc 100644 --- a/.blueprint/modules/flywheel-intelligence.md +++ b/.blueprint/modules/flywheel-intelligence.md @@ -175,8 +175,9 @@ - files: src-tauri/src/commands/risk.rs, src-tauri/src/commands/mod.rs, src-tauri/src/mcp_tools.rs - acceptance: "[MCP tool] predict_task_risk(project_id, scope 或 files) → JSON 含 risk_level/rework_rate/sample_size/confidence/hot_files,无 error 字段;sample_size<5 时 confidence=low;[cargo test] 风险聚合纯函数(conn 注入 + in-memory DB)覆盖高返工 scope、小样本、脏历史(conventional 率低标不可信)三种输入;tools_list_result 含 predict_task_risk 断言" -### 📋 P3-D. 梦核 prompt 高风险 scope 数据段 -- status: todo +### ✅ P3-D. 梦核 prompt 高风险 scope 数据段 +- status: done +- done_note: 2026-07-04 完成。risk.rs 新增 high_risk_scopes(样本 ≥5 且有非 CI 返工,返工率降序,项目名从 project_profiles 解析缺失回退 id);generate_muhe_prompt 注入「跨项目高风险 scope」表格段(含分析指引:反复返工 scope=结构性脆弱点→固化环节输出预防性规范);顺手把 rule_hits 段的 pool() 改 try_pool()(原实现测试环境必 panic,generate_muhe_prompt 因此不可测);prompt 构建测试 + 聚合排序过滤测试,92 全绿 - complexity: S - depends: flywheel-intelligence(卡 P3-C) - files: src-tauri/src/commands/blueprint.rs diff --git a/src-tauri/src/commands/blueprint.rs b/src-tauri/src/commands/blueprint.rs index 64a98c3..0ca50cf 100644 --- a/src-tauri/src/commands/blueprint.rs +++ b/src-tauri/src/commands/blueprint.rs @@ -1964,9 +1964,9 @@ pub fn generate_muhe_prompt(stats: FlywheelStats) -> String { let notes_total = stats.project_notes.len(); // ── 规则命中 × 返工交叉(Rules-Applied trailer,飞轮 R1)───────────────── - let rule_hits_summary = crate::db::pool() - .get() - .ok() + // try_pool:测试环境 pool 未初始化时走占位文案,不 panic + let rule_hits_summary = crate::db::try_pool() + .and_then(|p| p.get().ok()) .and_then(|conn| crate::commands::commit_metrics::rule_hit_stats(&conn).ok()) .filter(|v| !v.is_empty()) .map(|v| { @@ -1980,6 +1980,22 @@ pub fn generate_muhe_prompt(stats: FlywheelStats) -> String { }) .unwrap_or_else(|| "(暂无数据——commit message 附带 Rules-Applied trailer 后自动积累)".to_string()); + // ── 跨项目高风险 scope(飞轮阶段三预测层,样本 ≥5 且有返工)───────────── + let high_risk_summary = crate::db::try_pool() + .and_then(|p| p.get().ok()) + .and_then(|conn| crate::commands::risk::high_risk_scopes(&conn, 5, 10).ok()) + .filter(|v| !v.is_empty()) + .map(|v| { + let header = "| 项目 | scope | commit 数 | 返工数 | 返工率 |"; + let sep = "|------|-------|-----------|--------|--------|"; + let rows: Vec = v.iter().map(|s| { + format!("| {} | {} | {} | {} | {:.0}% |", + s.project_name, s.scope, s.commits, s.rework, s.rework_rate * 100.0) + }).collect(); + format!("{header}\n{sep}\n{}", rows.join("\n")) + }) + .unwrap_or_else(|| "(暂无数据——需 scope 样本 ≥5 且存在非 CI 返工)".to_string()); + format!(r#"你是炼境(Dev Manager)的蓝图架构师,负责对飞轮运转数据进行记忆巩固分析。 ## 飞轮运转数据(来自炼境跨项目聚合) @@ -2027,6 +2043,13 @@ pub fn generate_muhe_prompt(stats: FlywheelStats) -> String { {rule_hits_summary} +### 跨项目高风险 scope(飞轮阶段三预测层) + +> 历史返工率最高的模块热区。反复返工的 scope = 结构性脆弱点, +> 是「固化」环节输出预防性规范、或提示该模块需要重构/补测试的信号。 + +{high_risk_summary} + --- ## 当前 CONVENTIONS.md(v{conventions_version})规则摘要 @@ -2731,5 +2754,38 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + + // ── generate_muhe_prompt(P3-D 梦核数据段)─────────────────────────────── + + #[test] + fn muhe_prompt_contains_high_risk_scope_section() { + let stats = FlywheelStats { + cohorts: vec![], + top_blocked_reasons: vec![], + stalled_projects: vec![], + ai_doc_stale_projects: vec![], + iteration_jumps: vec![], + stalled_modules: vec![], + total_projects_scanned: 0, + projects_with_data: 0, + project_notes: vec![], + execution_quality: ExecutionQuality { + complexity_dist: Default::default(), + total_rework_count: 0, + avg_rounds_m: None, + }, + }; + let prompt = generate_muhe_prompt(stats); + assert!( + prompt.contains("### 跨项目高风险 scope"), + "梦核 prompt 应含高风险 scope 数据段" + ); + assert!( + prompt.contains("### 规则命中 × 返工交叉"), + "规则置信度数据段应保留" + ); + // 测试环境 pool 未初始化 → 两段都应走占位文案而非 panic + assert!(prompt.contains("暂无数据")); + } } diff --git a/src-tauri/src/commands/risk.rs b/src-tauri/src/commands/risk.rs index 5859970..384ffd4 100644 --- a/src-tauri/src/commands/risk.rs +++ b/src-tauri/src/commands/risk.rs @@ -111,6 +111,62 @@ fn file_risks(conn: &Connection, project_id: &str, files: &[String]) -> Result Result, String> { + let mut stmt = conn + .prepare( + "SELECT COALESCE(pp.name, cm.project_id) AS pname, cm.scope, + COUNT(*) AS c, + COALESCE(SUM(CASE WHEN cm.is_rework = 1 AND cm.is_ci_auto = 0 THEN 1 ELSE 0 END), 0) AS rw + FROM commit_metrics cm + LEFT JOIN project_workspaces pw ON pw.id = cm.project_id + LEFT JOIN project_profiles pp ON pp.id = pw.profile_id + WHERE cm.scope IS NOT NULL + GROUP BY cm.project_id, cm.scope + HAVING c >= ?1 AND rw > 0 + ORDER BY CAST(rw AS REAL) / c DESC, c DESC + LIMIT ?2", + ) + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map(rusqlite::params![min_commits, limit as i64], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, u32>(2)?, + r.get::<_, u32>(3)?, + )) + }) + .map_err(|e| e.to_string())?; + let mut result = Vec::new(); + for row in rows { + let (project_name, scope, commits, rework) = row.map_err(|e| e.to_string())?; + result.push(HighRiskScope { + project_name, + scope, + rework_rate: rate(rework, commits), + commits, + rework, + }); + } + Ok(result) +} + /// 核心入口:按 scope 和/或文件清单预测任务风险。 pub fn predict_task_risk( conn: &Connection, @@ -280,6 +336,27 @@ mod tests { assert_eq!(r.risk_level, "high", "hot.rs 返工率 0.5 应分级 high"); } + #[test] + fn high_risk_scopes_orders_by_rate_with_min_sample() { + let conn = crate::db::conn_with_schema(); + // proj-a/hot:4 commits 2 fix(rate 0.5);proj-b/warm:5 commits 1 fix(rate 0.2) + // proj-a/tiny:2 commits 1 fix——低于 min_commits=3 应被过滤 + for (sha, subj) in [ + ("a1", "fix(hot): x"), ("a2", "fix(hot): y"), ("a3", "feat(hot): z"), ("a4", "chore(hot): w"), + ("b1", "fix(warm): x"), ("b2", "feat(warm): a"), ("b3", "feat(warm): b"), + ("b4", "feat(warm): c"), ("b5", "feat(warm): d"), + ("t1", "fix(tiny): x"), ("t2", "feat(tiny): y"), + ] { + let pid = if sha.starts_with('a') || sha.starts_with('t') { "proj-a" } else { "proj-b" }; + seed(&conn, pid, sha, subj, &[]); + } + let top = high_risk_scopes(&conn, 3, 10).unwrap(); + assert_eq!(top.len(), 2, "tiny 样本不足应被过滤"); + assert_eq!(top[0].scope, "hot", "返工率高的排前"); + assert_eq!(top[0].project_name, "proj-a", "无登记项目回退 project_id"); + assert_eq!(top[1].scope, "warm"); + } + #[test] fn requires_scope_or_files() { let conn = crate::db::conn_with_schema(); From a5cb30734d29e05c4efaf1091376ad90aaa9bbca Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 4 Jul 2026 14:21:44 +0900 Subject: [PATCH 10/14] =?UTF-8?q?feat(flywheel):=20=E8=BF=94=E5=B7=A5?= =?UTF-8?q?=E7=83=AD=E5=8C=BA=20UI=20+=20get=5Fhigh=5Frisk=5Fscopes=20?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=EF=BC=8C=E9=98=B6=E6=AE=B5=E4=B8=89=E6=94=B6?= =?UTF-8?q?=E5=AE=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - risk.rs 新增 Tauri command get_high_risk_scopes(薄包装)+ lib.rs 注册 - commands.ts 封装 HighRiskScope 类型与调用(R01) - FlywheelPanel 新增「返工热区」块:跨项目返工率降序, 分级配色与 classify_risk 阈值一致(≥35% 红 / ≥15% 琥珀) - 蓝图:P3-A~E 五卡全部 done,附 L 级复盘笔记,模块 progress 98 Rules-Applied: R01,R13 --- .blueprint/manifest.yaml | 4 +- .blueprint/modules/flywheel-intelligence.md | 22 +++++++++-- .gitignore | 3 ++ docs/ai-context/git-workflow.md | 18 ++++++++- src-tauri/src/commands/risk.rs | 10 +++++ src-tauri/src/lib.rs | 1 + src/components/insight/FlywheelPanel.tsx | 43 +++++++++++++++++++++ src/lib/commands.ts | 12 ++++++ 8 files changed, 106 insertions(+), 7 deletions(-) diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index 016d2bf..675fc41 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -305,8 +305,8 @@ modules: name: 飞轮智能成长 area: backend status: in_progress - progress: 92 - note: "阶段三已过 architect+splitter(2026-07-04):P3-A~E 五卡待执行;P0 发现 R1 trailer 管道死链(%s 只取 subject),P3-A 修复" + progress: 98 + note: "阶段三 P3-A~E 全部完成(2026-07-04):trailer 死链修复+回填、commit_files 采集、predict_task_risk MCP、梦核高风险 scope 段、返工热区 UI;剩存量回填与 MCP 实调终验(待新 build 重启)" position: [1750, 650] - id: cloud-products diff --git a/.blueprint/modules/flywheel-intelligence.md b/.blueprint/modules/flywheel-intelligence.md index 8f3f8fc..ac7d216 100644 --- a/.blueprint/modules/flywheel-intelligence.md +++ b/.blueprint/modules/flywheel-intelligence.md @@ -183,12 +183,26 @@ - files: src-tauri/src/commands/blueprint.rs - acceptance: "[cargo test] 梦核 prompt 构建函数单测断言输出含「跨项目高风险 scope」数据段(返工率 top scope + 项目分布);规则置信度段在 P3-A 数据流入后自然激活(注入点 blueprint.rs rule_hit_stats 已存在)" -### 📋 P3-E. FlywheelPanel 风险展示 + commands.ts 同步(可选收尾) -- status: todo +### ✅ P3-E. FlywheelPanel 风险展示 + commands.ts 同步(可选收尾) +- status: done +- done_note: 2026-07-04 完成。实现形态微调:Git 执行质量表已有 TOP scope 列(F2 遗产),故新增独立「返工热区」块展示 get_high_risk_scopes(跨项目返工率降序,分级配色与 classify_risk 阈值一致 ≥35% 红 / ≥15% 琥珀);Tauri command get_high_risk_scopes 薄包装 + lib.rs 注册 + commands.ts 封装(R01)。pnpm typecheck exit 0,cargo test 92 全绿 - complexity: S - depends: flywheel-intelligence(卡 P3-C) -- files: src/lib/commands.ts, src/components/dashboard/BlueprintModal.tsx -- acceptance: "[pnpm typecheck] exit 0;FlywheelPanel「Git 执行质量」卡片新增各项目返工率 top scope 列(get_scope_risk_profile 封装同步,R01)" +- files: src/lib/commands.ts, src/components/insight/FlywheelPanel.tsx, src-tauri/src/commands/risk.rs, src-tauri/src/lib.rs +- acceptance: "[pnpm typecheck] exit 0;FlywheelPanel 新增跨项目返工热区展示(get_high_risk_scopes 封装同步,R01)" + +## 复盘笔记 + +【复盘】阶段三执行质量预测层 P3-A~E(2026-07-04) +任务类型: 后端数据管道 + MCP工具 + 前端展示 +复杂度: L(architect+splitter 拆为 2M+1M+1S+1S,单会话连续执行) +实际轮数: 1轮(预估 1 轮) +是否返工: 否 +有效策略: architect 阶段先查数据库实况而非只读设计文档——由此发现 R1 trailer 管道死链(%s 只取 subject,单测喂全文全绿而生产喂 subject 永空),这个 P0 若不修,阶段三核心闭环(规则置信度)建在死数据上;「单元绿、集成死」用真 git 仓库 e2e 测试驻军;UPSERT 只回填 NULL 不覆盖,让回填天然幂等;三处重复的 git log 取数统一为单一入口(read_git_log),--name-only 让指标与文件单次进程同取 +顺手修的旧债: generate_muhe_prompt 用 pool() 导致测试环境必 panic(不可测),改 try_pool() 走占位文案 +遗留: 存量数据回填与 predict_task_risk MCP 实调,待新 build 发布重启后终验(跑一次「全量导入」即回填 trailer + 文件) +auto_applied_rules: R01, R05, R06, R07, R13 +rule_effectiveness: R06=有效(conn 注入使 risk.rs 7 测试全部 in-memory 可行), R07=有效(e2e git 仓库回归测试抓住的正是集成层缺口), R13=有效(acceptance 可执行格式让五张卡零歧义), R01=有效(commands.ts 同步时确认 HighRiskScope 类型单一来源), R05=有效(commit_files migration 随手写进 migrate()) ## 决策记录 diff --git a/.gitignore b/.gitignore index 2cfa71a..687347c 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ src-tauri/dev_manager.db # 防止签名密钥被误提交(Windows ~ 路径展开问题) ~/ + +# 测试运行产物(根目录杂散 DB) +/dev-manager-tauri.db diff --git a/docs/ai-context/git-workflow.md b/docs/ai-context/git-workflow.md index 0289e3b..56b421c 100644 --- a/docs/ai-context/git-workflow.md +++ b/docs/ai-context/git-workflow.md @@ -3,7 +3,7 @@ > 把此文件粘贴给任何 AI Agent,让它了解本项目的 git 工作方式。由炼境接入包分发。 > `ONBOARDING_MANAGED` 标记块由炼境版本化管理(重跑接入包可原位升级),块外可自由补充项目特有约定。 - + ## 分支策略 @@ -72,6 +72,22 @@ git commit -m "feat(模块): 一件事" PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否则 CI code review 易超时失效。 「所有检测通过」≠ 代码被审查过——大 PR 即使 CI 全绿,review 可能已静默失效。 +## PR 创建(炼境托管项目) + +本项目由炼境管理,MCP 已注入且 `.claude/liangjing.json` 含 `project_id`。 + +**推送分支后,通过炼境 MCP 创建 PR,禁止用 curl + git credential fill 提取 token:** + +``` +1. Read .claude/liangjing.json → 取出 project_id +2. 调用 MCP 工具 create_pull_request: + { "project_id": "", "head": "dev", "base": "main", + "title": "", "body": "<变更摘要>" } +3. 炼境内部用存储的 Gitea 凭证调 API,返回 pr_url +``` + +若炼境未运行(MCP 不可用),降级方案:去 Gitea 网页手动创建 PR。 + ## 常用命令 ```bash diff --git a/src-tauri/src/commands/risk.rs b/src-tauri/src/commands/risk.rs index 384ffd4..33b88d5 100644 --- a/src-tauri/src/commands/risk.rs +++ b/src-tauri/src/commands/risk.rs @@ -167,6 +167,16 @@ pub fn high_risk_scopes( Ok(result) } +/// Tauri command:跨项目返工热区(FlywheelPanel「返工热区」块)。薄包装,逻辑在 high_risk_scopes。 +#[tauri::command] +pub fn get_high_risk_scopes( + min_commits: Option, + limit: Option, +) -> Result, String> { + let conn = crate::db::pool().get().map_err(|e| e.to_string())?; + high_risk_scopes(&conn, min_commits.unwrap_or(5), limit.unwrap_or(10) as usize) +} + /// 核心入口:按 scope 和/或文件清单预测任务风险。 pub fn predict_task_risk( conn: &Connection, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2391354..ad6150b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -278,6 +278,7 @@ pub fn run() { commands::commit_metrics::ingest_full_git_history, commands::commit_metrics::get_project_commit_health, commands::commit_metrics::get_all_commit_health, + commands::risk::get_high_risk_scopes, // mentor get_mentor_context, get_project_notes, diff --git a/src/components/insight/FlywheelPanel.tsx b/src/components/insight/FlywheelPanel.tsx index c717577..c50ddf7 100644 --- a/src/components/insight/FlywheelPanel.tsx +++ b/src/components/insight/FlywheelPanel.tsx @@ -9,10 +9,12 @@ import { archiveProjectDocs, ingestFullGitHistory, getAllCommitHealth, + getHighRiskScopes, type Project, type FlywheelStats, type StalledProject, type CommitHealthWithName, + type HighRiskScope, } from "../../lib/commands"; function StalledProjectRow({ p, projectsMap, onArchive }: { @@ -84,6 +86,11 @@ export function FlywheelPanel() { queryFn: () => getAllCommitHealth(), }); + const { data: riskScopes } = useQuery({ + queryKey: ["high-risk-scopes"], + queryFn: () => getHighRiskScopes(), + }); + const [ingesting, setIngesting] = useState(false); const [ingestSummary, setIngestSummary] = useState(null); @@ -375,6 +382,42 @@ export function FlywheelPanel() { )} + {/* 返工热区(飞轮阶段三预测层) */} + {riskScopes && riskScopes.length > 0 && ( +
+

+ 返工热区 + 历史返工率最高的模块,开工前提高警惕 +

+
+ + + + + + + + + + + {riskScopes.map((s) => ( + + + + + + + ))} + +
项目scopecommits返工率
+ {s.project_name} + {s.scope}{s.commits}= 0.35 ? "text-red-500" : s.rework_rate >= 0.15 ? "text-amber-500" : "text-gray-500"}`}> + {Math.round(s.rework_rate * 100)}% +
+
+
+ )} + {/* 梦核分析 */}

梦核分析

diff --git a/src/lib/commands.ts b/src/lib/commands.ts index c105975..f1707e2 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -1611,6 +1611,18 @@ export interface CommitHealthWithName extends ProjectCommitHealth { export const getAllCommitHealth = () => invoke("get_all_commit_health"); +// 飞轮阶段三:跨项目返工热区(样本 ≥minCommits 且有非 CI 返工的 scope,按返工率降序) +export interface HighRiskScope { + project_name: string; + scope: string; + commits: number; + rework: number; + rework_rate: number; +} + +export const getHighRiskScopes = (minCommits?: number, limit?: number) => + invoke("get_high_risk_scopes", { minCommits, limit }); + // ── Gitea Integration ──────────────────────────────────────────────────────── export interface GiteaInstance { From a77cb4ec96ada608bfb4fcde0b1617b749069338 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 4 Jul 2026 14:36:28 +0900 Subject: [PATCH 11/14] =?UTF-8?q?feat(flywheel):=20ingest=5Fgit=5Fhistory?= =?UTF-8?q?=20agent=20=E6=8C=89=E9=92=AE=E2=80=94=E2=80=94=E5=85=A8?= =?UTF-8?q?=E9=87=8F=E5=AF=BC=E5=85=A5=E5=8F=8C=E6=8A=95=E5=BD=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 消费者验证暴露的死角:「全量导入」只有 UI 投影,agent 够不到, 存量 trailer 回填只能靠人点按钮。按双投影原则补 MCP 投影: - 不传 project_id 遍历所有 win_path 项目跑 ingest_full_git_history, 传则单项目;返回逐项目 imported/skipped 汇总,单项目失败不中断 - 能力本体(ingest_full_git_history)不动,UI 与 MCP 调同一函数 - tools_list_result 断言驻军,92 测试全绿 Rules-Applied: R12,R13 --- .blueprint/modules/flywheel-intelligence.md | 7 +++ src-tauri/src/mcp_tools.rs | 58 +++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/.blueprint/modules/flywheel-intelligence.md b/.blueprint/modules/flywheel-intelligence.md index ac7d216..ebb68c9 100644 --- a/.blueprint/modules/flywheel-intelligence.md +++ b/.blueprint/modules/flywheel-intelligence.md @@ -191,6 +191,13 @@ - files: src/lib/commands.ts, src/components/insight/FlywheelPanel.tsx, src-tauri/src/commands/risk.rs, src-tauri/src/lib.rs - acceptance: "[pnpm typecheck] exit 0;FlywheelPanel 新增跨项目返工热区展示(get_high_risk_scopes 封装同步,R01)" +### 🔵 P3-F. ingest_git_history agent 按钮(全量导入双投影) +- status: in_progress +- complexity: S +- files: src-tauri/src/mcp_tools.rs +- acceptance: "[MCP tool] ingest_git_history() 不传参对所有 win_path 项目执行 ingest_full_git_history,返回逐项目 imported/skipped 汇总;传 project_id 只跑单项目;tools_list_result 含断言;[消费者验证] 实调后 rules_applied 非空行 ≥12(存量 trailer 回填生效)" +- notes: 消费者验证暴露的死角——「全量导入」只有 UI 投影,agent 够不到;按双投影原则补 MCP 投影,能力本体(ingest_full_git_history)不动 + ## 复盘笔记 【复盘】阶段三执行质量预测层 P3-A~E(2026-07-04) diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index 04345b2..ee16c45 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -246,6 +246,17 @@ pub fn tools_list_result() -> Value { "description": "扫描已登记项目的父目录,找出存在于文件系统但尚未在炼境注册的项目目录。返回每个未注册目录的路径、是否有 .git、技术栈特征文件(package.json / Cargo.toml / go.mod)、是否有 AGENTS.md。用于日常发现漏网项目并决定是否接入炼境。", "inputSchema": { "type": "object", "properties": {}, "required": [] } }, + { + "name": "ingest_git_history", + "description": "全量导入 git 历史到飞轮(等价 UI「全量导入」按钮的 agent 投影):解析最近 500 条 commit 的 conventional 指标、Rules-Applied trailer 与触碰文件,UPSERT 幂等(存量行自动回填 trailer)。不传 project_id 时对所有已登记 Windows 路径项目执行。适用:trailer 存量回填、新项目接入后初始化飞轮数据。", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "可选。项目 workspace ID;省略则遍历所有已登记项目" } + }, + "required": [] + } + }, { "name": "predict_task_risk", "description": "任务风险预测(飞轮阶段三):按 scope 和/或文件清单查询该项目历史返工率,返回 risk_level(low/medium/high)、样本量与置信度、热点文件排行。agent 开工前调用——任务卡涉及高返工 scope/文件时提高警惕(多写测试、小步提交)。样本 <5 或 commit 历史不规范时 confidence=low,结论仅供参考。", @@ -475,6 +486,52 @@ pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value { } "list_all_projects" => list_all_projects(), "scan_unregistered_projects" => scan_unregistered_projects(), + "ingest_git_history" => { + let pid = args.get("project_id").and_then(|v| v.as_str()); + let conn = db::pool().get().map_err(|e| e.to_string())?; + let targets: Vec<(String, String)> = { + let mut stmt = conn + .prepare( + "SELECT id, win_path FROM project_workspaces + WHERE win_path IS NOT NULL AND (?1 IS NULL OR id = ?1)", + ) + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map(rusqlite::params![pid], |r| Ok((r.get(0)?, r.get(1)?))) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .collect(); + rows + }; + drop(conn); + if targets.is_empty() { + Err(format!("未找到可导入的项目(project_id={:?})", pid)) + } else { + let mut lines = Vec::new(); + let (mut ok, mut failed) = (0u32, 0u32); + for (id, path) in &targets { + match crate::commands::commit_metrics::ingest_full_git_history( + id.clone(), + path.clone(), + ) { + Ok(r) => { + ok += 1; + lines.push(format!("- {}: 写入 {} 条,跳过 {} 条", id, r.imported, r.skipped)); + } + Err(e) => { + failed += 1; + lines.push(format!("- {}: ❌ {}", id, e)); + } + } + } + Ok(format!( + "全量导入完成:{} 个项目成功,{} 个失败\n\n{}", + ok, + failed, + lines.join("\n") + )) + } + } "predict_task_risk" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); let scope = args.get("scope").and_then(|v| v.as_str()); @@ -1069,6 +1126,7 @@ mod tests { assert!(names.contains(&"create_pull_request"), "缺少 create_pull_request"); assert!(names.contains(&"poll_now"), "缺少 poll_now"); assert!(names.contains(&"predict_task_risk"), "缺少 predict_task_risk"); + assert!(names.contains(&"ingest_git_history"), "缺少 ingest_git_history"); // 每个工具都有 description 和 inputSchema for tool in tools { let name = tool["name"].as_str().unwrap_or("?"); From 94d8be6eb6c3908ca26e903d9aea9e6ffac83666 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 4 Jul 2026 14:41:29 +0900 Subject: [PATCH 12/14] =?UTF-8?q?docs(blueprint):=20=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=E4=B8=89=E6=B6=88=E8=B4=B9=E8=80=85=E7=BB=88=E9=AA=8C=E9=80=9A?= =?UTF-8?q?=E8=BF=87=EF=BC=8Cflywheel-intelligence=20=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E6=94=B6=E5=AE=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rules-Applied: R13 --- .blueprint/manifest.yaml | 7 ++++--- .blueprint/modules/flywheel-intelligence.md | 8 +++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index 675fc41..54201cc 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -304,9 +304,10 @@ modules: - id: flywheel-intelligence name: 飞轮智能成长 area: backend - status: in_progress - progress: 98 - note: "阶段三 P3-A~E 全部完成(2026-07-04):trailer 死链修复+回填、commit_files 采集、predict_task_risk MCP、梦核高风险 scope 段、返工热区 UI;剩存量回填与 MCP 实调终验(待新 build 重启)" + status: done + progress: 100 + updated: 2026-07-04 + note: "阶段三 P3-A~F 全部完成并消费者终验(2026-07-04):trailer 死链修复、commit_files 采集、predict_task_risk、梦核高风险 scope 段、返工热区 UI、ingest_git_history agent 按钮;实测 rules_applied 6→27、commit_files 15688,规则置信度闭环有真数据" position: [1750, 650] - id: cloud-products diff --git a/.blueprint/modules/flywheel-intelligence.md b/.blueprint/modules/flywheel-intelligence.md index ebb68c9..e78bc71 100644 --- a/.blueprint/modules/flywheel-intelligence.md +++ b/.blueprint/modules/flywheel-intelligence.md @@ -191,8 +191,9 @@ - files: src/lib/commands.ts, src/components/insight/FlywheelPanel.tsx, src-tauri/src/commands/risk.rs, src-tauri/src/lib.rs - acceptance: "[pnpm typecheck] exit 0;FlywheelPanel 新增跨项目返工热区展示(get_high_risk_scopes 封装同步,R01)" -### 🔵 P3-F. ingest_git_history agent 按钮(全量导入双投影) -- status: in_progress +### ✅ P3-F. ingest_git_history agent 按钮(全量导入双投影) +- status: done +- done_note: 2026-07-04 完成并消费者终验。MCP 实调(无参全量):19 项目成功 / 7 失败(全是非 git 目录死登记,诚实报错);rules_applied 6→27(存量 21 条回填,远超 ≥12 验收线,含 R11 failure 记录与 R08/R10 fix 命中);commit_files 26→15688;规则置信度闭环从此有真实数据。UPSERT 语义实证:有 trailer 才计写入,旧 NULL 行不产生无意义 UPDATE - complexity: S - files: src-tauri/src/mcp_tools.rs - acceptance: "[MCP tool] ingest_git_history() 不传参对所有 win_path 项目执行 ingest_full_git_history,返回逐项目 imported/skipped 汇总;传 project_id 只跑单项目;tools_list_result 含断言;[消费者验证] 实调后 rules_applied 非空行 ≥12(存量 trailer 回填生效)" @@ -207,7 +208,8 @@ 是否返工: 否 有效策略: architect 阶段先查数据库实况而非只读设计文档——由此发现 R1 trailer 管道死链(%s 只取 subject,单测喂全文全绿而生产喂 subject 永空),这个 P0 若不修,阶段三核心闭环(规则置信度)建在死数据上;「单元绿、集成死」用真 git 仓库 e2e 测试驻军;UPSERT 只回填 NULL 不覆盖,让回填天然幂等;三处重复的 git log 取数统一为单一入口(read_git_log),--name-only 让指标与文件单次进程同取 顺手修的旧债: generate_muhe_prompt 用 pool() 导致测试环境必 panic(不可测),改 try_pool() 走占位文案 -遗留: 存量数据回填与 predict_task_risk MCP 实调,待新 build 发布重启后终验(跑一次「全量导入」即回填 trailer + 文件) +遗留: ~~存量数据回填与 predict_task_risk MCP 实调~~(已销项:2026-07-04 同日消费者终验全过——predict_task_risk 实调返回 medium/normal;ingest_git_history agent 按钮补上后 rules_applied 6→27、commit_files 26→15688) +追记(P3-F 消费者验证): 验证过程暴露「全量导入只有 UI 投影」死角 → 当场按双投影原则补 MCP 投影(P3-F 卡),agent 从此可自主触发回填;7 个非 git 目录死登记被诚实报错暴露,可作后续清理线索 auto_applied_rules: R01, R05, R06, R07, R13 rule_effectiveness: R06=有效(conn 注入使 risk.rs 7 测试全部 in-memory 可行), R07=有效(e2e git 仓库回归测试抓住的正是集成层缺口), R13=有效(acceptance 可执行格式让五张卡零歧义), R01=有效(commands.ts 同步时确认 HighRiskScope 类型单一来源), R05=有效(commit_files migration 随手写进 migrate()) From f03e4178fc47a267d0130037e9389aedd7f3a999 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 4 Jul 2026 14:49:05 +0900 Subject: [PATCH 13/14] =?UTF-8?q?feat(mcp):=20unregister=5Fproject=20agent?= =?UTF-8?q?=20=E6=8C=89=E9=92=AE=E2=80=94=E2=80=94=E7=99=BB=E8=AE=B0?= =?UTF-8?q?=E5=8D=AB=E7=94=9F=E5=B7=A5=E5=85=B7=E5=AF=B9=E8=A1=A5=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scan_unregistered_projects 发现漏网,unregister_project 清理死账。 薄包装调 delete_project 本体(UI 删除按钮同源),delete_local 硬编码 false——MCP 侧永不暴露删本地文件能力(安全边界)。 工具描述内置约束:删除不可逆,调用前须向用户列清单确认。 Rules-Applied: R12,R13 --- src-tauri/src/mcp_tools.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index ee16c45..5f21718 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -246,6 +246,17 @@ pub fn tools_list_result() -> Value { "description": "扫描已登记项目的父目录,找出存在于文件系统但尚未在炼境注册的项目目录。返回每个未注册目录的路径、是否有 .git、技术栈特征文件(package.json / Cargo.toml / go.mod)、是否有 AGENTS.md。用于日常发现漏网项目并决定是否接入炼境。", "inputSchema": { "type": "object", "properties": {}, "required": [] } }, + { + "name": "unregister_project", + "description": "从炼境删除一条项目登记(等价 UI 删除项目按钮的 agent 投影):清理产品组 MCP 配置、关联表、workspace 与孤儿 profile。绝不删除本地文件(该能力不对 agent 开放)。适用:scan_unregistered_projects 的反向操作——清理目录已消失或已无 .git 的死登记。删除不可逆,调用前先向用户列出目标清单确认。", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "要删除登记的项目 workspace ID" } + }, + "required": ["project_id"] + } + }, { "name": "ingest_git_history", "description": "全量导入 git 历史到飞轮(等价 UI「全量导入」按钮的 agent 投影):解析最近 500 条 commit 的 conventional 指标、Rules-Applied trailer 与触碰文件,UPSERT 幂等(存量行自动回填 trailer)。不传 project_id 时对所有已登记 Windows 路径项目执行。适用:trailer 存量回填、新项目接入后初始化飞轮数据。", @@ -486,6 +497,16 @@ pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value { } "list_all_projects" => list_all_projects(), "scan_unregistered_projects" => scan_unregistered_projects(), + "unregister_project" => { + let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); + if pid.is_empty() { + Err("project_id 不能为空".to_string()) + } else { + // delete_local 硬编码 false:MCP 侧永不删除本地文件 + crate::commands::projects::delete_project(pid.to_string(), Some(false)) + .map(|_| format!("已删除登记:{}(本地文件未动)", pid)) + } + } "ingest_git_history" => { let pid = args.get("project_id").and_then(|v| v.as_str()); let conn = db::pool().get().map_err(|e| e.to_string())?; @@ -1127,6 +1148,7 @@ mod tests { assert!(names.contains(&"poll_now"), "缺少 poll_now"); assert!(names.contains(&"predict_task_risk"), "缺少 predict_task_risk"); assert!(names.contains(&"ingest_git_history"), "缺少 ingest_git_history"); + assert!(names.contains(&"unregister_project"), "缺少 unregister_project"); // 每个工具都有 description 和 inputSchema for tool in tools { let name = tool["name"].as_str().unwrap_or("?"); From a914c8c2199b6b0ba8d3b865acb964b8e677e1b8 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 4 Jul 2026 15:04:29 +0900 Subject: [PATCH 14/14] =?UTF-8?q?docs(blueprint):=20unregister=5Fproject?= =?UTF-8?q?=20=E7=99=BB=E8=AE=B0=E5=8D=AB=E7=94=9F=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E5=AF=B9=E8=A1=A5=E5=8D=A1=EF=BC=88=E4=BA=8B=E5=90=8E=E7=99=BB?= =?UTF-8?q?=E8=AE=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rules-Applied: R13 --- .blueprint/modules/mcp-server.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.blueprint/modules/mcp-server.md b/.blueprint/modules/mcp-server.md index b382a06..6822953 100644 --- a/.blueprint/modules/mcp-server.md +++ b/.blueprint/modules/mcp-server.md @@ -11,6 +11,13 @@ ## 任务卡 +### ✅ unregister_project agent 按钮(登记卫生工具对) +- status: done +- complexity: S +- files: src-tauri/src/mcp_tools.rs +- acceptance: "[MCP tool] unregister_project(project_id) → 调 delete_project 本体(delete_local 硬编码 false,MCP 侧永不删本地文件),返回确认文案;tools_list_result 断言" +- done_note: 2026-07-04 完成并消费者终验:实调删除 4 个死登记(cursor-firewall-tray / dev-manager / mindforge-os / pdf-to-xlsx,全为目录无 .git 的飞轮死重),DB 验证 workspace + group_map 零残留,2 个空路径概念占位(银龙集团应用 / agent编排界面)按用户意图保留。与 scan_unregistered_projects 构成登记卫生工具对(发现漏网 + 清理死账) + ### ✅ 启动时绑定 HTTP 端口 - status: done - complexity: M