feat(flywheel): 阶段三执行质量预测层(trailer 死链修复 + 风险画像) #2
@ -131,7 +131,7 @@ acceptance 是 agent 自验的唯一判断依据,必须是**可执行断言**
|
||||
✅ "pnpm typecheck 无报错"
|
||||
✅ "cargo test 通过(src-tauri/)"
|
||||
✅ "MCP tool list_projects 返回新增项目名"
|
||||
✅ "pnpm test <测试文件> 全绿"
|
||||
✅ "pnpm vitest run <测试文件> 全绿"(仅适用于有纯逻辑层的项目,见前端可测试性规范)
|
||||
```
|
||||
|
||||
最小结构:`[载体] 调用/运行什么(参数) → 断言什么`。
|
||||
@ -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
|
||||
@ -696,39 +708,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`。
|
||||
|
||||
### 测试文件位置
|
||||
|
||||
前端测试文件与源文件同目录,命名约定:`<filename>.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 全绿
|
||||
```
|
||||
|
||||
### 测试写法模板
|
||||
|
||||
@ -7,6 +7,7 @@ description: >
|
||||
形成可自我进化的开发智能闭环。
|
||||
iteration: 1
|
||||
updated: 2026-07-04
|
||||
agent_button_carrier: tauri
|
||||
|
||||
|
||||
|
||||
@ -303,9 +304,10 @@ modules:
|
||||
- id: flywheel-intelligence
|
||||
name: 飞轮智能成长
|
||||
area: backend
|
||||
status: in_progress
|
||||
progress: 92
|
||||
note: "L0+F1+F2+R1+R2 完成(2026-07-02,rule_effectiveness 死链已接通);阶段三待数据覆盖 ≥10 项目"
|
||||
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
|
||||
@ -370,7 +372,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
|
||||
|
||||
@ -144,20 +144,74 @@
|
||||
> 背景:"失败定边界、经验细化为变体"是经验飞轮的因果自洽核心,当前规则库只有正向 scope,无反例结构。
|
||||
> 完成记录(2026-07-02):CONVENTIONS 升 v1.9.0,新增 boundaries/failure_cases 字段说明 + R11 + abandoned 模块状态定义;向其他项目的分发随下次发版生效(CONVENTIONS 编译进二进制)。
|
||||
|
||||
### 阶段三:执行质量预测层
|
||||
- status: todo
|
||||
- complexity: L
|
||||
- depends: flywheel-intelligence(阶段二完成且数据积累达标后启动)
|
||||
- files: TBD(需 /architect 后确定)
|
||||
- acceptance: TBD
|
||||
### 阶段三:执行质量预测层(已过 /architect + /splitter,2026-07-04)
|
||||
|
||||
阶段三方向(待 /architect 设计):
|
||||
- 任务完成时长分布(为 S/M/L 定义时间基准)
|
||||
- 高风险任务预警(特定文件组合 → 历史高返工率)
|
||||
- 跨项目模式库推荐
|
||||
- **自动推荐规则置信度更新**:梦核读取复盘笔记中的 `auto_applied_rules` + `rule_effectiveness` 字段,输出 CONVENTIONS 规则库的 confidence/evidence_count 更新建议;新增规则提案(从高频踩坑模式自动归纳)
|
||||
> 触发条件已达标: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 修复。
|
||||
|
||||
> ⚠️ 阶段三为 L 级复杂度,到时须先走 /architect → /splitter 流程,禁止直接写代码。
|
||||
### ✅ 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: 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
|
||||
- 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: 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
|
||||
- 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: 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
|
||||
- acceptance: "[cargo test] 梦核 prompt 构建函数单测断言输出含「跨项目高风险 scope」数据段(返工率 top scope + 项目分布);规则置信度段在 P3-A 数据流入后自然激活(注入点 blueprint.rs rule_hit_stats 已存在)"
|
||||
|
||||
### ✅ 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/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: 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 回填生效)"
|
||||
- notes: 消费者验证暴露的死角——「全量导入」只有 UI 投影,agent 够不到;按双投影原则补 MCP 投影,能力本体(ingest_full_git_history)不动
|
||||
|
||||
## 复盘笔记
|
||||
|
||||
【复盘】阶段三执行质量预测层 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 实调~~(已销项: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())
|
||||
|
||||
## 决策记录
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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/<name> 分支+PR),PR MCP 示例 head 从 "dev" 改为 "feat/<name>"
|
||||
- files: src-tauri/resources/onboarding/git-workflow.md.tmpl
|
||||
- acceptance: [Tauri command] 对已接入项目重跑接入包 → git-workflow.md ONBOARDING_MANAGED 块升至 v1.3.0,内容含「简单改动直推 main/master,功能性改动开 feat/<name> 分支 + PR」策略;PR 创建 MCP 示例 head 字段从 "dev" 改为 "feat/<name>";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,后置) 健康诊断加体系遵守度检查。移植体验=零新增动作,接入流程不变。
|
||||
|
||||
|
||||
@ -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 非空`
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -31,3 +31,6 @@ src-tauri/dev_manager.db
|
||||
|
||||
# 防止签名密钥被误提交(Windows ~ 路径展开问题)
|
||||
~/
|
||||
|
||||
# 测试运行产物(根目录杂散 DB)
|
||||
/dev-manager-tauri.db
|
||||
|
||||
@ -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` | 文件可解析,字段完整 |
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
> 把此文件粘贴给任何 AI Agent,让它了解本项目的 git 工作方式。由炼境接入包分发。
|
||||
> `ONBOARDING_MANAGED` 标记块由炼境版本化管理(重跑接入包可原位升级),块外可自由补充项目特有约定。
|
||||
|
||||
<!-- ONBOARDING_MANAGED_START version="1.1.0" -->
|
||||
<!-- ONBOARDING_MANAGED_START version="1.2.0" -->
|
||||
|
||||
## 分支策略
|
||||
|
||||
@ -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": "<id>", "head": "dev", "base": "main",
|
||||
"title": "<Conventional Commits 格式>", "body": "<变更摘要>" }
|
||||
3. 炼境内部用存储的 Gitea 凭证调 API,返回 pr_url
|
||||
```
|
||||
|
||||
若炼境未运行(MCP 不可用),降级方案:去 Gitea 网页手动创建 PR。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
|
||||
@ -7,6 +7,8 @@
|
||||
## 项目概况
|
||||
|
||||
<!-- TODO: 一句话描述项目 + 技术栈。例:pnpm monorepo,React 19 + TypeScript 前端 + Hono API。 -->
|
||||
<!-- TODO: 在 .blueprint/manifest.yaml 顶层补 agent_button_carrier(tauri/http/cli/playwright),
|
||||
splitter 生成任务卡时直接从此字段继承验证载体。参考 .blueprint/CONVENTIONS.md §manifest.yaml规范。 -->
|
||||
|
||||
## 架构与模块
|
||||
|
||||
@ -51,7 +53,7 @@
|
||||
| UI 呈现层 | Playwright 驱动真界面(最后手段,仅关键路径) |
|
||||
3. **完成的定义**:功能实现后,由 agent 沿投影路径真实走通一次(起服务 → 调接口 →
|
||||
断言行为),再交人做 UI 终验——不许绕过功能手改产物冒充验证
|
||||
4. **侦察 → 驻军**:agent 走通的关键路径,顺手固化为一条测试(vitest / cargo test);
|
||||
4. **侦察 → 驻军**:agent 走通的关键路径,顺手固化为一条测试(桌面应用用 cargo test;Web 项目纯逻辑层用 vitest);
|
||||
一次性验收不固化(少而稳 > 多而脆)
|
||||
5. **acceptance 可执行化**:`/splitter` 生成任务卡时,`acceptance` 字段必须是可执行验证路径,
|
||||
格式:`[载体] 调用/运行什么(参数) → 断言什么`。
|
||||
|
||||
@ -3,19 +3,22 @@
|
||||
> 把此文件粘贴给任何 AI Agent,让它了解本项目的 git 工作方式。由炼境接入包分发。
|
||||
> `ONBOARDING_MANAGED` 标记块由炼境版本化管理(重跑接入包可原位升级),块外可自由补充项目特有约定。
|
||||
|
||||
<!-- ONBOARDING_MANAGED_START version="1.2.0" -->
|
||||
<!-- ONBOARDING_MANAGED_START version="1.3.0" -->
|
||||
|
||||
## 分支策略
|
||||
## 分支策略(复杂度感知)
|
||||
|
||||
```
|
||||
dev ← 日常开发在这里 commit/push
|
||||
↓
|
||||
main ← 稳定分支,通过 PR 合入(合入时触发 CI code review)
|
||||
main/master ← 主分支,保持稳定
|
||||
↑
|
||||
├── 简单改动 → 直接 push main/master
|
||||
└── 功能性改动 → feat/<name> 分支 → 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": "<id>", "head": "dev", "base": "main",
|
||||
{ "project_id": "<id>", "head": "feat/<name>", "base": "main",
|
||||
"title": "<Conventional Commits 格式>", "body": "<变更摘要>" }
|
||||
3. 炼境内部用存储的 Gitea 凭证调 API,返回 pr_url
|
||||
```
|
||||
|
||||
@ -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:<type>(<scope>): <描述>"
|
||||
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"
|
||||
|
||||
@ -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<String> = 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("暂无数据"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<Strin
|
||||
/// 首次接入时只解析最近这么多条 commit,避免一次性解析整个历史。
|
||||
const FIRST_TIME_LIMIT: usize = 50;
|
||||
|
||||
/// 获取 last_commit..HEAD 之间的新 commit(sha, subject, committed_at ISO)。
|
||||
/// 获取 last_commit..HEAD 之间的新 commit(含 Rules-Applied trailer)。
|
||||
/// 首次接入(last_commit 为 None)只取最近 FIRST_TIME_LIMIT 条。
|
||||
fn get_new_commits(root: &Path, last_commit: Option<&str>) -> 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<crate::commands::commit_metrics::GitLogEntry> {
|
||||
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()
|
||||
}
|
||||
|
||||
// ── 辅助:任务状态更新 ────────────────────────────────────────────────────────
|
||||
|
||||
@ -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<String> {
|
||||
let mut ids: Vec<String> = 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<String> {
|
||||
@ -87,24 +102,47 @@ pub fn parse_rules_applied(message: &str) -> Option<String> {
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let rest = rest.trim();
|
||||
let mut ids: Vec<String> = 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<bool, String> {
|
||||
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,35 @@ 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 原生解析)。
|
||||
/// 同时把触碰文件写入 commit_files(INSERT OR IGNORE 幂等,供文件级风险画像)。
|
||||
pub fn record_commit_entry(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
entry: &GitLogEntry,
|
||||
) -> Result<bool, String> {
|
||||
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())
|
||||
};
|
||||
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)
|
||||
}
|
||||
|
||||
/// 单条规则的命中统计(跨项目聚合)
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct RuleHitStat {
|
||||
@ -248,34 +296,88 @@ 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<Vec<(String, String, String)>, 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 管道死链)。
|
||||
/// `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<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值(逗号分隔)。
|
||||
/// 配合 --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 + --name-only 的完整输出。纯函数,可独立单测。
|
||||
/// 规则:含 \x1f 的行是 commit header;其后的非空行是该 commit 的文件路径。
|
||||
pub fn parse_git_log_output(output: &str) -> Vec<GitLogEntry> {
|
||||
let mut entries: Vec<GitLogEntry> = 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
/// 读取 git log(含 Rules-Applied trailer 与触碰文件)。增量与全量两条链路的
|
||||
/// 单一取数入口,buff watcher 与 ingest_full_git_history 共用,禁止再各自定义 format。
|
||||
pub fn read_git_log(root: &std::path::Path, range: GitLogRange) -> Result<Vec<GitLogEntry>, 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
|
||||
.args([GIT_LOG_FMT, "--name-only"])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.map_err(|e| format!("git log 失败: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!("git log 错误: {}", String::from_utf8_lossy(&out.stderr)));
|
||||
}
|
||||
Ok(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())
|
||||
Ok(parse_git_log_output(&String::from_utf8_lossy(&out.stdout)))
|
||||
}
|
||||
|
||||
/// 全量历史导入:扫描最近 500 条 commit,幂等写入 commit_metrics 表。
|
||||
@ -290,7 +392,7 @@ pub fn ingest_full_git_history(
|
||||
if !root.exists() {
|
||||
return Err(format!("目录不存在: {}", root.display()));
|
||||
}
|
||||
let commits = read_git_log(root, FULL_HISTORY_LIMIT)?;
|
||||
let commits = read_git_log(root, GitLogRange::Limit(FULL_HISTORY_LIMIT))?;
|
||||
let total = commits.len() as u32;
|
||||
|
||||
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
|
||||
@ -306,19 +408,11 @@ pub fn ingest_full_git_history(
|
||||
|
||||
let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
|
||||
let mut imported = 0u32;
|
||||
for (sha, subject, date) in &commits {
|
||||
let p = parse_conventional(subject);
|
||||
let n = conn.execute(
|
||||
"INSERT OR IGNORE INTO commit_metrics
|
||||
(sha, project_id, commit_type, scope, is_rework, is_ci_auto, committed_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
rusqlite::params![
|
||||
sha, effective_id, p.commit_type, p.scope,
|
||||
p.is_rework as i32, p.is_ci_auto as i32,
|
||||
if date.is_empty() { None } else { Some(date.as_str()) },
|
||||
],
|
||||
).map_err(|e| e.to_string())?;
|
||||
imported += n as u32;
|
||||
for entry in &commits {
|
||||
// 新 sha 插入;已存在的行 UPSERT 回填 rules_applied(trailer 死链修复后的存量补数路径)
|
||||
if record_commit_entry(&conn, &effective_id, entry)? {
|
||||
imported += 1;
|
||||
}
|
||||
}
|
||||
tx.commit().map_err(|e| e.to_string())?;
|
||||
Ok(IngestResult { imported, skipped: total - imported })
|
||||
@ -629,6 +723,136 @@ mod tests {
|
||||
assert_eq!(total, 1, "同 sha 重复写入应被忽略");
|
||||
}
|
||||
|
||||
// ── P3-A:trailer 链路修复 + UPSERT 回填 ─────────────────────────────────────
|
||||
|
||||
fn entry(sha: &str, subject: &str, trailer: &str) -> GitLogEntry {
|
||||
GitLogEntry {
|
||||
sha: sha.into(),
|
||||
subject: subject.into(),
|
||||
committed_at: "2026-07-04T00:00:00+09:00".into(),
|
||||
rules_trailer: trailer.into(),
|
||||
files: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn rules_of(conn: &rusqlite::Connection, sha: &str) -> Option<String> {
|
||||
conn.query_row(
|
||||
"SELECT rules_applied FROM commit_metrics WHERE sha = ?1",
|
||||
[sha],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_git_log_output_headers_trailers_and_files() {
|
||||
// 两条 commit:首条带 trailer + 2 个文件,次条无 trailer + 1 个文件(--name-only 真实形状)
|
||||
let output = "abc\u{1f}feat(x): y\u{1f}2026-07-04T00:00:00+09:00\u{1f}R01,R06\n\
|
||||
\n\
|
||||
src/a.rs\n\
|
||||
src/b.rs\n\
|
||||
def\u{1f}fix: z\u{1f}2026-07-04T00:00:00+09:00\u{1f}\n\
|
||||
\n\
|
||||
src/c.rs\n";
|
||||
let entries = parse_git_log_output(output);
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert_eq!(entries[0].sha, "abc");
|
||||
assert_eq!(entries[0].subject, "feat(x): y");
|
||||
assert_eq!(entries[0].rules_trailer, "R01,R06");
|
||||
assert_eq!(entries[0].files, vec!["src/a.rs", "src/b.rs"]);
|
||||
assert_eq!(entries[1].rules_trailer, "", "无 trailer 时为空串");
|
||||
assert_eq!(entries[1].files, vec!["src/c.rs"]);
|
||||
|
||||
assert!(parse_git_log_output("").is_empty());
|
||||
// 空 commit(merge / --allow-empty):header 后无文件行
|
||||
let no_files = parse_git_log_output("abc\u{1f}chore: m\u{1f}d\u{1f}\n");
|
||||
assert!(no_files[0].files.is_empty());
|
||||
}
|
||||
|
||||
#[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"]);
|
||||
std::fs::write(dir.join("x.rs"), "// x").unwrap();
|
||||
std::fs::write(dir.join("y.rs"), "// y").unwrap();
|
||||
git(&["add", "."]);
|
||||
git(&["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-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[0].files, vec!["x.rs", "y.rs"], "--name-only 文件必须被采集");
|
||||
assert_eq!(entries[1].rules_trailer, "");
|
||||
assert!(entries[1].files.is_empty(), "空 commit 无文件");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_files_written_and_idempotent() {
|
||||
let conn = crate::db::conn_with_schema();
|
||||
let mut e = entry("s1", "feat(a): x", "");
|
||||
e.files = vec!["src/a.rs".into(), "src/b.rs".into()];
|
||||
record_commit_entry(&conn, "proj", &e).unwrap();
|
||||
record_commit_entry(&conn, "proj", &e).unwrap(); // 重跑
|
||||
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM commit_files WHERE sha = 's1'", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 2, "文件写入且重跑幂等");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conventional_stats_counts_ratio() {
|
||||
let conn = crate::db::conn_with_schema();
|
||||
|
||||
@ -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;
|
||||
|
||||
384
src-tauri/src/commands/risk.rs
Normal file
384
src-tauri/src/commands/risk.rs
Normal file
@ -0,0 +1,384 @@
|
||||
//! 飞轮阶段三:执行质量风险画像(预测层)。
|
||||
//!
|
||||
//! 从 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<ScopeRisk>,
|
||||
pub file_stats: Vec<FileRisk>,
|
||||
/// 结论依据的样本量(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<String>,
|
||||
}
|
||||
|
||||
/// 返工率分级阈值:≥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<Option<ScopeRisk>, 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<Vec<FileRisk>, 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(梦核数据段)。
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct HighRiskScope {
|
||||
pub project_name: String,
|
||||
pub scope: String,
|
||||
pub commits: u32,
|
||||
pub rework: u32,
|
||||
pub rework_rate: f64,
|
||||
}
|
||||
|
||||
/// 跨项目聚合:样本 ≥ min_commits 且至少 1 次返工的 scope,按返工率降序取 top limit。
|
||||
/// 项目名从 project_profiles 解析,登记缺失时回退 project_id。
|
||||
pub fn high_risk_scopes(
|
||||
conn: &Connection,
|
||||
min_commits: u32,
|
||||
limit: usize,
|
||||
) -> Result<Vec<HighRiskScope>, 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)
|
||||
}
|
||||
|
||||
/// Tauri command:跨项目返工热区(FlywheelPanel「返工热区」块)。薄包装,逻辑在 high_risk_scopes。
|
||||
#[tauri::command]
|
||||
pub fn get_high_risk_scopes(
|
||||
min_commits: Option<u32>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<Vec<HighRiskScope>, 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,
|
||||
project_id: &str,
|
||||
scope: Option<&str>,
|
||||
files: &[String],
|
||||
) -> Result<TaskRiskPrediction, String> {
|
||||
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 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();
|
||||
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 自修不计返工");
|
||||
}
|
||||
}
|
||||
@ -538,6 +538,16 @@ fn migrate(conn: &rusqlite::Connection) {
|
||||
// 增量迁移(飞轮 R1):Rules-Applied trailer 规则命中列(已存在则忽略)
|
||||
let _ = conn.execute("ALTER TABLE commit_metrics ADD COLUMN rules_applied TEXT", []);
|
||||
|
||||
// 飞轮阶段三(P3-B):commit 触碰的文件清单,供文件级返工风险画像
|
||||
conn.execute_batch("
|
||||
CREATE TABLE IF NOT EXISTS commit_files (
|
||||
sha TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
PRIMARY KEY (sha, path)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_commit_files_path ON commit_files(path);
|
||||
").expect("create commit_files table");
|
||||
|
||||
// 飞轮 L1:PR 生命周期事件(opened / closed / merged)
|
||||
conn.execute_batch("
|
||||
CREATE TABLE IF NOT EXISTS pr_events (
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -47,19 +47,120 @@ fn liangjing_path(project_id: &str) -> Result<PathBuf, String> {
|
||||
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<String> {
|
||||
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<Value, String> {
|
||||
@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@ -240,6 +240,46 @@ 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": [] }
|
||||
},
|
||||
{
|
||||
"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 存量回填、新项目接入后初始化飞轮数据。",
|
||||
"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,结论仅供参考。",
|
||||
"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"]
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
@ -456,6 +496,79 @@ 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(),
|
||||
"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())?;
|
||||
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());
|
||||
let files: Vec<String> = 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| {
|
||||
@ -897,6 +1010,117 @@ pub fn list_all_projects() -> Result<String, String> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn scan_unregistered_projects() -> Result<String, String> {
|
||||
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<String> = 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::path::PathBuf> =
|
||||
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<String>, 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<String> = 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::<Vec<_>>()
|
||||
.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,9 +1139,16 @@ 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");
|
||||
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("?");
|
||||
|
||||
@ -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<HighRiskScope[]>({
|
||||
queryKey: ["high-risk-scopes"],
|
||||
queryFn: () => getHighRiskScopes(),
|
||||
});
|
||||
|
||||
const [ingesting, setIngesting] = useState(false);
|
||||
const [ingestSummary, setIngestSummary] = useState<string | null>(null);
|
||||
|
||||
@ -375,6 +382,42 @@ export function FlywheelPanel() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 返工热区(飞轮阶段三预测层) */}
|
||||
{riskScopes && riskScopes.length > 0 && (
|
||||
<div className="bg-white rounded-lg border border-gray-100 p-3 space-y-2">
|
||||
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">
|
||||
返工热区
|
||||
<span className="ml-1.5 normal-case font-normal">历史返工率最高的模块,开工前提高警惕</span>
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[10px]">
|
||||
<thead>
|
||||
<tr className="text-gray-400 border-b border-gray-100">
|
||||
<th className="text-left pb-1 font-medium">项目</th>
|
||||
<th className="text-left pb-1 font-medium">scope</th>
|
||||
<th className="text-right pb-1 font-medium">commits</th>
|
||||
<th className="text-right pb-1 font-medium">返工率</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{riskScopes.map((s) => (
|
||||
<tr key={`${s.project_name}-${s.scope}`} className="hover:bg-gray-50">
|
||||
<td className="py-1 text-gray-700 truncate max-w-[80px]" title={s.project_name}>
|
||||
{s.project_name}
|
||||
</td>
|
||||
<td className="py-1 text-gray-500 font-mono truncate max-w-[100px]">{s.scope}</td>
|
||||
<td className="py-1 text-right font-mono text-gray-500">{s.commits}</td>
|
||||
<td className={`py-1 text-right font-mono font-semibold ${s.rework_rate >= 0.35 ? "text-red-500" : s.rework_rate >= 0.15 ? "text-amber-500" : "text-gray-500"}`}>
|
||||
{Math.round(s.rework_rate * 100)}%
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 梦核分析 */}
|
||||
<div className="bg-white rounded-lg border border-gray-100 p-3 space-y-2">
|
||||
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">梦核分析</p>
|
||||
|
||||
@ -1611,6 +1611,18 @@ export interface CommitHealthWithName extends ProjectCommitHealth {
|
||||
export const getAllCommitHealth = () =>
|
||||
invoke<CommitHealthWithName[]>("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<HighRiskScope[]>("get_high_risk_scopes", { minCommits, limit });
|
||||
|
||||
// ── Gitea Integration ────────────────────────────────────────────────────────
|
||||
|
||||
export interface GiteaInstance {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user