use serde_json::{json, Value}; use crate::db; // ── 工具 Schema 定义 ────────────────────────────────────────────────────────── pub fn tools_list_result() -> Value { json!({ "tools": [ { "name": "get_diagnostics", "description": "查询炼境运行时诊断信息:MCP Server 启动状态、各项目 MCP 配置注入结果、最近错误日志。排查功能是否正常时首先调用此工具。", "inputSchema": { "type": "object", "properties": {}, "required": [] } }, { "name": "list_group_projects", "description": "列出产品组内所有成员项目,包含名称、路径、平台、技术栈、角色等信息(group_id 可从 list_all_projects 获取)", "inputSchema": { "type": "object", "properties": { "group_id": { "type": "string", "description": "产品组 ID" } }, "required": ["group_id"] } }, { "name": "get_project_blueprint", "description": "读取产品组内某个项目的蓝图文件(.blueprint/manifest.yaml),了解该项目的模块与任务状态", "inputSchema": { "type": "object", "properties": { "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" } }, "required": ["project_id"] } }, { "name": "get_project_file", "description": "读取产品组内某个项目的任意文件内容,如 openapi.yaml、README.md、src/types.ts 等", "inputSchema": { "type": "object", "properties": { "project_id": { "type": "string", "description": "项目 workspace ID" }, "relative_path": { "type": "string", "description": "相对于项目根目录的文件路径,如 openapi.yaml 或 src/api/routes.ts" } }, "required": ["project_id", "relative_path"] } }, { "name": "get_project_mentor_context", "description": "获取产品组内某个项目的完整导师上下文——包含健康状态、蓝图进度摘要、近期错误日志,以及格式化好的 Markdown 报告(可直接用于质量分析和成长建议)", "inputSchema": { "type": "object", "properties": { "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" } }, "required": ["project_id"] } }, { "name": "list_envoy_returns", "description": "列出所有子项目通过「炼境使者」发回炼境的待处理洞察,按项目分组展示。炼境会话启动时调用,用于检查是否有需要处置的治理反馈(模板 bug、验证有效的实践、体系改进假设)。无需任何参数。", "inputSchema": { "type": "object", "properties": {} } }, { "name": "append_project_note", "description": "将 Claude 的分析结论或复盘记录写入项目笔记,供未来会话和用户在炼境中查阅。完成 M/L 复杂度任务后必须调用,写入结构化复盘笔记。", "inputSchema": { "type": "object", "properties": { "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" }, "content": { "type": "string", "description": "笔记内容。完成 M/L 任务后使用以下结构化格式:\n【复盘】<任务标题>\n任务类型: 后端命令|前端组件|数据层|MCP工具|架构调整\n复杂度: S|M|L\n实际轮数: N轮(预估M轮)\n是否返工: 是/否\n返工原因: <若返工填写,否则省略>\n有效策略: <本次奏效的做法>\n遗留风险: <若有填写,否则省略>\n\n其他场景(分析结论、建议、洞察)可自由格式。" } }, "required": ["project_id", "content"] } }, { "name": "get_blueprint_status", "description": "检测项目蓝图治理状态:CONVENTIONS 规则版本是否最新、蓝图内容是否可能落后于代码(synced/rules_outdated/content_stale/none)。等价于炼境 UI 项目卡片上的蓝图状态徽章。", "inputSchema": { "type": "object", "properties": { "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" } }, "required": ["project_id"] } }, { "name": "generate_blueprint_prompt", "description": "生成蓝图初始化/同步提示词(含 CONVENTIONS 规范与 manifest 严格格式约束)。等价于炼境 UI 治理面板的「初始化蓝图/更新蓝图描绘」按钮。拿到提示词后在该项目目录下执行它(或交给该项目的 AI 会话),完成后可用 get_blueprint_status / get_project_blueprint 验证结果。", "inputSchema": { "type": "object", "properties": { "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" }, "mode": { "type": "string", "enum": ["init", "sync"], "description": "init=项目尚无蓝图时从零初始化;sync=已有蓝图,对照代码现状同步更新" } }, "required": ["project_id", "mode"] } }, { "name": "sync_blueprint_rules", "description": "将最新 CONVENTIONS 规则同步到项目(写入 .blueprint/CONVENTIONS.md + 更新 AGENTS.md/CLAUDE.md 的蓝图管理区块)。等价于炼境 UI 治理面板的「同步规则」按钮。仅当 get_blueprint_status 返回 rules_outdated 时需要调用。", "inputSchema": { "type": "object", "properties": { "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" } }, "required": ["project_id"] } }, { "name": "create_pull_request", "description": "通过炼境存储的 Gitea 凭证为项目创建合并请求(PR)。炼境内部调用 Gitea API,无需 Claude 自行提取 token。/ship 交付流程在推送分支后应调用此工具完成 PR 创建。", "inputSchema": { "type": "object", "properties": { "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" }, "head": { "type": "string", "description": "源分支名,如 dev" }, "base": { "type": "string", "description": "目标分支名,如 main" }, "title": { "type": "string", "description": "PR 标题" }, "body": { "type": "string", "description": "PR 描述(Markdown),可为空字符串" } }, "required": ["project_id", "head", "base", "title"] } }, { "name": "get_project_snapshots", "description": "获取项目的历史健康快照(每日一条),包含 git 提交间隔、蓝图完成率、阻塞任务数、错误情况。用于分析趋势、识别高价值改进变量。", "inputSchema": { "type": "object", "properties": { "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" }, "days": { "type": "integer", "description": "查询最近 N 天的快照,默认 30" } }, "required": ["project_id"] } }, { "name": "poll_now", "description": "立即触发一次 Gitea 轮询:拉取所有绑定仓库的最新 commit 和 PR 状态写入飞轮。等价于炼境 UI「立即拉取」按钮。适用于公网 Gitea + 内网炼境(webhook 不可达)场景。", "inputSchema": { "type": "object", "properties": {}, "required": [] } }, { "name": "setup_webhook", "description": "为项目重新注册 Gitea webhook(订阅 push + pull_request 事件)。等价于炼境 UI「重设」按钮。导入新项目后、或需要更新事件订阅时调用。", "inputSchema": { "type": "object", "properties": { "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" } }, "required": ["project_id"] } }, { "name": "inject_mcp", "description": "向项目的 .claude/settings.json 注入炼境 MCP 配置,同时写入 bypassPermissions 和 liangjing.json。等价于炼境 UI「注入 MCP」按钮。导入新项目后或炼境重启后调用。", "inputSchema": { "type": "object", "properties": { "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" } }, "required": ["project_id"] } }, { "name": "get_ci_status", "description": "查询指定 commit 的 CI 检查结果(pass/fail/pending)。/ship 流程推送分支并开 PR 后,用此工具确认 CI 是否通过再决定是否合并。", "inputSchema": { "type": "object", "properties": { "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" }, "commit_sha": { "type": "string", "description": "要查询的 commit SHA(完整 40 位或前 8 位均可)" } }, "required": ["project_id", "commit_sha"] } }, { "name": "merge_pull_request", "description": "合并项目的指定 PR。建议在 get_ci_status 确认通过后再调用。等价于 Gitea UI「合并 PR」按钮。", "inputSchema": { "type": "object", "properties": { "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" }, "pr_number": { "type": "integer", "description": "PR 编号(来自 create_pull_request 的返回结果或 Gitea UI)" } }, "required": ["project_id", "pr_number"] } }, { "name": "get_pr_events", "description": "查询项目的 PR 生命周期历史(opened/merged/closed)。数据来自炼境飞轮,本地 git 命令无法获取。", "inputSchema": { "type": "object", "properties": { "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" }, "limit": { "type": "integer", "description": "返回最近 N 条记录,默认 20" } }, "required": ["project_id"] } }, { "name": "get_commit_metrics", "description": "查询项目的提交飞轮数据:提交类型分布(feat/fix/chore...)、是否返工、近期频率。数据来自炼境 DB,本地 git log 无法直接得到解析后的分类。", "inputSchema": { "type": "object", "properties": { "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" }, "days": { "type": "integer", "description": "查询最近 N 天,默认 30" } }, "required": ["project_id"] } }, { "name": "distribute_cicd", "description": "向项目写入 CI 模板文件(.gitea/workflows/)。当项目缺少 CI 配置时调用,自动解析项目路径写入。支持 pr_ci(PR 质量检查)和 deepseek_review(AI 代码审查)两种类型。", "inputSchema": { "type": "object", "properties": { "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" }, "workflow_type": { "type": "string", "enum": ["pr_ci", "deepseek_review"], "description": "pr_ci=PR 合并前质量检查;deepseek_review=AI 代码审查并评论到 PR" } }, "required": ["project_id", "workflow_type"] } }, { "name": "list_all_projects", "description": "列出炼境中所有已登记项目(不限产品组),包含 project_id、名称、路径、所属产品组、MCP 注入状态。用于发现尚未注入的项目,再调 inject_mcp 完成接入。", "inputSchema": { "type": "object", "properties": {}, "required": [] } }, { "name": "apply_onboarding_pack", "description": "向项目分发标准接入包:写入 AGENTS.md / lefthook / git-workflow 模板(已存在则原位升级 ONBOARDING_MANAGED 块,绝不覆盖块外用户内容),并挂载 buff 自动监听 git 提交。等价于炼境 UI「接入包」区域的「应用接入包」按钮。模板版本升级后对存量项目重新调用即可推送新版本。", "inputSchema": { "type": "object", "properties": { "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" } }, "required": ["project_id"] } }, { "name": "verify_onboarding", "description": "接入生效验收:检查接入包在项目里是否真正生效(模板管理块版本到位 / lefthook hook 真挂在 .git/hooks / origin 指向已登记 Gitea / CI workflow 存在 / 近 30 天飞轮 commit 数据流入)。分发成功 ≠ 功能生效,apply_onboarding_pack 之后应调用本工具做消费者角色验收。", "inputSchema": { "type": "object", "properties": { "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" } }, "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"] } }, { "name": "setup_gitea_repo", "description": "为项目在默认 Gitea 实例上创建远程仓库,并将本地 git remote origin 指向新仓库(幂等:仓库已存在则直接绑定,remote 已有则 set-url 更新)。创建后炼境自动记录绑定关系,后续可调用 setup_webhook 注册 webhook。需先在炼境 UI 配置一个默认 Gitea 实例。", "inputSchema": { "type": "object", "properties": { "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" }, "repo_name": { "type": "string", "description": "Gitea 仓库名;省略则用项目名自动 slug 化(非 ASCII 字符过滤掉)" }, "private": { "type": "boolean", "description": "是否创建私有仓库,默认 false(公开)" } }, "required": ["project_id"] } } ] }) } // ── 工具分发 ────────────────────────────────────────────────────────────────── pub async fn tools_call(params: Option<&Value>) -> Value { let tool_name = params .and_then(|p| p.get("name")) .and_then(|n| n.as_str()) .unwrap_or("") .to_string(); let args = params .and_then(|p| p.get("arguments")) .cloned() .unwrap_or(json!({})); let outcome = tokio::task::spawn_blocking(move || match tool_name.as_str() { "get_diagnostics" => get_diagnostics(), "list_group_projects" => { let group_id = args .get("group_id") .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()); match group_id { Some(g) => list_group_projects(g), None => Err("需显式传 group_id 参数(组 ID 可从 list_all_projects 获取)".to_string()), } } "get_project_blueprint" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); get_project_blueprint(pid) } "get_blueprint_status" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); resolve_project_root(pid).and_then(|root| { crate::commands::blueprint::get_blueprint_status(root.to_string_lossy().to_string()) .and_then(|s| serde_json::to_string_pretty(&s).map_err(|e| e.to_string())) }) } "generate_blueprint_prompt" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); let mode = args.get("mode").and_then(|v| v.as_str()).unwrap_or("sync"); if mode != "init" && mode != "sync" { Err(format!("mode 只允许 init 或 sync,收到: {mode}")) } else { resolve_project_root(pid).and_then(|root| { crate::commands::blueprint::generate_blueprint_prompt( root.to_string_lossy().to_string(), mode.to_string(), ) }) } } "sync_blueprint_rules" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); resolve_project_root(pid).and_then(|root| { crate::commands::blueprint::sync_blueprint_rules(root.to_string_lossy().to_string()) .and_then(|r| serde_json::to_string_pretty(&r).map_err(|e| e.to_string())) }) } "get_project_file" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); let rel = args.get("relative_path").and_then(|v| v.as_str()).unwrap_or(""); get_project_file(pid, rel) } "get_project_mentor_context" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); get_project_mentor_context(pid) } "list_envoy_returns" => { list_envoy_returns() } "append_project_note" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); let content = args.get("content").and_then(|v| v.as_str()).unwrap_or(""); append_project_note(pid, content) } "get_project_snapshots" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); let days = args.get("days").and_then(|v| v.as_u64()).unwrap_or(30) as u32; get_project_snapshots(pid, days) } "create_pull_request" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); let head = args.get("head").and_then(|v| v.as_str()).unwrap_or(""); let base = args.get("base").and_then(|v| v.as_str()).unwrap_or("main"); let title = args.get("title").and_then(|v| v.as_str()).unwrap_or(""); let body = args.get("body").and_then(|v| v.as_str()).unwrap_or(""); crate::commands::gitea::gitea_api_create_pull_request(pid, head, base, title, body) } "poll_now" => crate::commands::gitea::poll_all_repos() .map(|(polled, ingested)| format!("轮询完成:{polled} 个仓库,新 commit {ingested} 条,PR 事件已同步")), "setup_webhook" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); let port = crate::mcp_server::read_port(); let callback_base = format!("http://127.0.0.1:{}", port); crate::commands::gitea::gitea_setup_webhook(pid.to_string(), callback_base).map(|repo| { format!( "webhook 注册成功:{}/{} hook_id={}", repo.gitea_owner, repo.gitea_repo_name, repo.webhook_id.map(|id| id.to_string()).unwrap_or_default() ) }) } "inject_mcp" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); crate::mcp_inject::inject_project_mcp(pid) .map(|_| format!("MCP 注入成功:project_id={}(单一 lian-jing 服务器,无需 group_id)", pid)) } "get_ci_status" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); let sha = args.get("commit_sha").and_then(|v| v.as_str()).unwrap_or(""); if sha.is_empty() { return Err("commit_sha 不能为空".to_string()); } crate::commands::gitea::gitea_api_get_ci_status(pid, sha) } "merge_pull_request" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); let pr_num = args.get("pr_number").and_then(|v| v.as_i64()).unwrap_or(0); if pr_num <= 0 { return Err("pr_number 必须为正整数".to_string()); } crate::commands::gitea::gitea_api_merge_pull_request(pid, pr_num) } "get_pr_events" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize; let conn = db::pool().get().map_err(|e| e.to_string())?; let mut stmt = conn .prepare( "SELECT pr_number, title, action, head_branch, base_branch, author, event_at FROM pr_events WHERE project_id = ?1 ORDER BY event_at DESC LIMIT ?2", ) .map_err(|e| e.to_string())?; let rows: Vec = stmt .query_map(rusqlite::params![pid, limit as i64], |r| { Ok(( r.get::<_, i64>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?, r.get::<_, String>(3)?, r.get::<_, String>(4)?, r.get::<_, String>(5)?, r.get::<_, String>(6)?, )) }) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .map(|(num, title, action, head, base, author, at)| { let icon = match action.as_str() { "merged" => "✅", "closed" => "🚫", _ => "🔵", }; format!("{icon} PR #{num} [{action}] {title}\n {head} → {base} by {author} @ {at}") }) .collect(); if rows.is_empty() { Ok(format!("项目 {} 暂无 PR 事件记录", pid)) } else { Ok(format!("PR 事件(最近 {} 条):\n\n{}", rows.len(), rows.join("\n\n"))) } } "get_commit_metrics" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); let days = args.get("days").and_then(|v| v.as_u64()).unwrap_or(30); let conn = db::pool().get().map_err(|e| e.to_string())?; let offset = format!("-{} days", days); let total: i64 = conn .query_row( "SELECT COUNT(*) FROM commit_metrics WHERE project_id = ?1 AND committed_at >= datetime('now', ?2)", rusqlite::params![pid, offset], |r| r.get(0), ) .unwrap_or(0); let mut type_stmt = conn .prepare( "SELECT COALESCE(commit_type,'unknown') as t, COUNT(*) as n, SUM(is_rework) as rework FROM commit_metrics WHERE project_id = ?1 AND committed_at >= datetime('now', ?2) GROUP BY t ORDER BY n DESC", ) .map_err(|e| e.to_string())?; let type_rows: Vec = type_stmt .query_map(rusqlite::params![pid, offset], |r| { Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?)) }) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .map(|(t, n, rework)| format!(" - {t}: {n} 条(返工 {rework} 条)")) .collect(); let rework_total: i64 = conn .query_row( "SELECT SUM(is_rework) FROM commit_metrics WHERE project_id = ?1 AND committed_at >= datetime('now', ?2)", rusqlite::params![pid, offset], |r| r.get(0), ) .unwrap_or(0); let mut out = format!("## 提交飞轮数据(最近 {} 天)\n\n", days); out.push_str(&format!("- **总提交数**:{}\n", total)); out.push_str(&format!( "- **返工提交**:{} 条({:.0}%)\n", rework_total, if total > 0 { rework_total as f64 / total as f64 * 100.0 } else { 0.0 } )); out.push_str("\n**按类型分布**:\n"); out.push_str(&type_rows.join("\n")); Ok(out) } "distribute_cicd" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); let wt = args.get("workflow_type").and_then(|v| v.as_str()).unwrap_or(""); 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 = 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())) } "setup_gitea_repo" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); let repo_name = args.get("repo_name").and_then(|v| v.as_str()); let private = args.get("private").and_then(|v| v.as_bool()).unwrap_or(false); crate::commands::gitea::gitea_api_setup_repo(pid, repo_name, private) } "apply_onboarding_pack" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); resolve_project_root(pid).and_then(|root| { let path = root.to_string_lossy().to_string(); crate::commands::onboarding::apply_onboarding_pack(path, pid.to_string()).map( |report| { let template_lines: Vec = report .templates .iter() .map(|t| { let icon = match t.action.as_str() { "written" => "✅ written", "updated" => "🔄 updated", "appended" => "➕ appended", "skipped" => "⏭ skipped", _ => "❌ failed", }; format!(" {icon}: {}", t.name) }) .collect(); let hook_icon = match report.hook.status.as_str() { "activated" => "✅", "missing_binary" => "⚠️", _ => "❌", }; format!( "接入包分发完成(project_id={})\n\n模板:\n{}\n\nlefthook hook:{} {}({})\nbuff:{}\n\n建议调用 verify_onboarding 做生效验收。", pid, template_lines.join("\n"), hook_icon, report.hook.status, report.hook.detail, report.buff.status ) }, ) }) } "verify_onboarding" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); let root = resolve_project_root(pid)?; let conn = db::pool().get().map_err(|e| e.to_string())?; let checks = crate::commands::onboarding::verify_onboarding(&conn, pid, &root); let passed = checks.iter().filter(|c| c.pass).count(); serde_json::to_string_pretty(&json!({ "project_id": pid, "pass": passed == checks.len(), "summary": format!("{passed}/{} 项通过", checks.len()), "checks": checks, })) .map_err(|e| e.to_string()) } _ => Err(format!("未知工具: {}", tool_name)), }) .await; match outcome { Ok(Ok(text)) => json!({ "content": [{ "type": "text", "text": text }] }), Ok(Err(e)) => json!({ "content": [{ "type": "text", "text": e }], "isError": true }), Err(e) => json!({ "content": [{ "type": "text", "text": e.to_string() }], "isError": true }), } } // ── 工具实现 ────────────────────────────────────────────────────────────────── fn get_diagnostics() -> Result { let conn = db::pool().get().map_err(|e| e.to_string())?; let port = crate::mcp_server::read_port(); let start_time: Option = conn .query_row( "SELECT ts FROM runtime_logs WHERE category = 'mcp_server' AND level = 'info' ORDER BY id ASC LIMIT 1", [], |r| r.get(0), ) .ok(); let mut out = String::new(); out.push_str("## 炼境 MCP 诊断报告\n\n"); out.push_str(&format!("- **服务端口**: {}\n", port)); out.push_str(&format!( "- **启动时间**: {}\n\n", start_time.as_deref().unwrap_or("未知(可能在本次日志之前启动)") )); out.push_str("### MCP 注入状态(每个项目最后一次结果)\n\n"); let mut stmt = conn .prepare( "SELECT context, level, message, ts FROM runtime_logs WHERE category = 'mcp_inject' AND id IN ( SELECT MAX(id) FROM runtime_logs WHERE category = 'mcp_inject' GROUP BY json_extract(context, '$.project_id') ) ORDER BY ts DESC LIMIT 50", ) .map_err(|e| e.to_string())?; let inject_rows: Vec<(Option, String, String, String)> = stmt .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); if inject_rows.is_empty() { out.push_str("_暂无注入记录(成员加入产品组时会产生)_\n\n"); } else { for (ctx, level, msg, ts) in &inject_rows { let project_id = ctx .as_deref() .and_then(|s| serde_json::from_str::(s).ok()) .and_then(|v| v.get("project_id").and_then(|x| x.as_str()).map(|s| s.to_string())) .unwrap_or_else(|| "unknown".to_string()); let icon = if level == "info" { "✅" } else { "⚠️" }; out.push_str(&format!("- {} `{}` — {} _({})\n", icon, project_id, msg, ts)); } out.push('\n'); } out.push_str("### 最近错误日志(最多 20 条)\n\n"); let mut stmt2 = conn .prepare( "SELECT ts, category, level, message FROM runtime_logs WHERE level IN ('error', 'warn') ORDER BY id DESC LIMIT 20", ) .map_err(|e| e.to_string())?; let error_rows: Vec<(String, String, String, String)> = stmt2 .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); if error_rows.is_empty() { out.push_str("_无错误记录_ ✅\n"); } else { for (ts, cat, level, msg) in &error_rows { out.push_str(&format!("- `[{}]` **{}** `{}` — {}\n", ts, level, cat, msg)); } } out.push_str("\n### 系统健康\n\n"); let report = crate::commands::settings::collect_health_report(); out.push_str(&format!("检查时间:{}\n\n", report.checked_at)); out.push_str("| 检查项 | 状态 | 详情 |\n|--------|------|------|\n"); for item in &report.items { let icon = match item.status.as_str() { "ok" => "✅", "warn" => "⚠️", "error" => "❌", _ => "—", }; out.push_str(&format!( "| {} | {} {} | {} |\n", item.name, icon, item.status, item.detail.as_deref().unwrap_or("-") )); } out.push_str("\n### 质量指标\n\n"); let q = &report.quality; out.push_str(&format!("- **休眠项目**(>30天未 push):{} 个\n", q.dormant_projects.len())); for p in &q.dormant_projects { out.push_str(&format!(" - {} ({} 天)\n", p.name, p.days_since_push)); } out.push_str(&format!("- **孤立项目**(无分组 & 无 repo):{} 个\n", q.orphaned_projects.len())); for p in &q.orphaned_projects { out.push_str(&format!(" - {}\n", p.name)); } out.push_str(&format!( "- **MCP 注入异常产品组**(近 7 天):{} 个\n", q.groups_with_inject_warn.len() )); for gid in &q.groups_with_inject_warn { out.push_str(&format!(" - `{}`\n", gid)); } out.push_str(&format!("- **无本地路径项目**:{} 个\n", q.projects_without_path)); Ok(out) } fn list_group_projects(group_id: &str) -> Result { let conn = db::pool().get().map_err(|e| e.to_string())?; let mut stmt = conn .prepare( "SELECT pw.id, pp.name, pw.platform, pw.win_path, pw.wsl_path, pp.tech_stack, pgm.role FROM project_group_map pgm JOIN project_workspaces pw ON pw.id = pgm.project_id JOIN project_profiles pp ON pp.id = pw.profile_id WHERE pgm.group_id = ?1", ) .map_err(|e| e.to_string())?; let rows: Vec = stmt .query_map([group_id], |row| { let id: String = row.get(0)?; let name: String = row.get(1)?; let platform: Option = row.get(2)?; let win_path: Option = row.get(3)?; let wsl_path: Option = row.get(4)?; let tech_stack: Option = row.get(5)?; let role: Option = row.get(6)?; Ok(format!( "- **{}** (id: `{}`)\n 平台: {} | 技术栈: {}{}\n Windows 路径: {}\n WSL 路径: {}", name, id, platform.as_deref().unwrap_or("unknown"), tech_stack.as_deref().unwrap_or("未配置"), role.map(|r| format!(" | 角色: {}", r)).unwrap_or_default(), win_path.as_deref().unwrap_or("未配置"), wsl_path.as_deref().unwrap_or("未配置"), )) }) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); if rows.is_empty() { return Ok("该产品组暂无成员项目。".to_string()); } Ok(format!("产品组成员(共 {} 个项目):\n\n{}", rows.len(), rows.join("\n\n"))) } fn get_project_blueprint(project_id: &str) -> Result { let root = resolve_project_root(project_id)?; let path = root.join(".blueprint").join("manifest.yaml"); std::fs::read_to_string(&path) .map_err(|e| format!("读取蓝图失败({}): {}", path.display(), e)) } fn get_project_file(project_id: &str, relative_path: &str) -> Result { if relative_path.contains("..") { return Err("路径不允许包含 \"..\"".to_string()); } let root = resolve_project_root(project_id)?; let path = root.join(relative_path); std::fs::read_to_string(&path) .map_err(|e| format!("读取文件失败({}): {}", path.display(), e)) } /// 按 project_id 直查项目根目录(单一服务器寻址,不做组归属过滤) pub fn resolve_project_root(project_id: &str) -> Result { let conn = db::pool().get().map_err(|e| e.to_string())?; let row: rusqlite::Result<(Option, Option, Option)> = conn.query_row( "SELECT win_path, wsl_path, platform FROM project_workspaces WHERE id = ?1", [project_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), ); let (win_path, wsl_path, platform) = row .map_err(|_| format!("项目 {} 不存在", project_id))?; if platform.as_deref() == Some("wsl") { let wsl = wsl_path.ok_or_else(|| "WSL 项目未配置 wsl_path".to_string())?; let distro: String = conn .query_row("SELECT value FROM settings WHERE key = 'wsl_distro'", [], |r| r.get(0)) .unwrap_or_else(|_| "Ubuntu".to_string()); let inner = wsl.trim_start_matches('/').replace('/', "\\"); Ok(std::path::PathBuf::from(format!("\\\\wsl.localhost\\{}\\{}", distro, inner))) } else { let win = win_path.ok_or_else(|| "Windows 项目未配置 win_path".to_string())?; Ok(std::path::PathBuf::from(win)) } } /// 项目存在性检查(替代旧的组归属校验——单一服务器寻址后组不再是访问边界) fn ensure_project_exists(conn: &rusqlite::Connection, project_id: &str) -> Result<(), String> { let exists: bool = conn .query_row( "SELECT COUNT(*) FROM project_workspaces WHERE id = ?1", [project_id], |r| r.get::<_, i64>(0), ) .map(|n| n > 0) .unwrap_or(false); if !exists { return Err(format!("项目 {} 不存在", project_id)); } Ok(()) } fn get_project_mentor_context(project_id: &str) -> Result { let conn = db::pool().get().map_err(|e| e.to_string())?; ensure_project_exists(&conn, project_id)?; let ctx = crate::commands::mentor::get_mentor_context(project_id.to_string()) .map_err(|e| format!("获取导师上下文失败: {e}"))?; Ok(ctx.mentor_markdown) } fn list_envoy_returns() -> Result { let conn = db::pool().get().map_err(|e| e.to_string())?; let mut stmt = conn .prepare( "SELECT pn.id, pp.name, pn.content, pn.created_at FROM project_notes pn JOIN project_workspaces pw ON pn.project_id = pw.id JOIN project_profiles pp ON pw.profile_id = pp.id WHERE pn.content LIKE '【炼境使者回传】%' ORDER BY pn.created_at DESC LIMIT 50", ) .map_err(|e| e.to_string())?; let rows: Vec<(i64, String, String, String)> = stmt .query_map([], |row| { Ok(( row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, )) }) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); if rows.is_empty() { return Ok("📭 暂无待处理的炼境使者回传".to_string()); } let mut out = format!("📬 炼境使者回传(共 {} 条)\n\n", rows.len()); for (_id, project_name, content, created_at) in &rows { let date = &created_at[..created_at.len().min(10)]; out.push_str(&format!("---\n**来源**: {} | **时间**: {}\n{}\n\n", project_name, date, content)); } out.push_str("---\n💡 处置参考:fix_template → 改接入包模板文件;update_convention → 改 CONVENTIONS.md;log_for_review → 记录待观察,暂不动模板"); Ok(out) } fn append_project_note(project_id: &str, content: &str) -> Result { if content.trim().is_empty() { return Err("笔记内容不能为空".to_string()); } let conn = db::pool().get().map_err(|e| e.to_string())?; ensure_project_exists(&conn, project_id)?; conn.execute( "INSERT INTO project_notes (project_id, source, content) VALUES (?1, 'mcp', ?2)", rusqlite::params![project_id, content], ) .map_err(|e| e.to_string())?; Ok("笔记已保存 ✅".to_string()) } fn get_project_snapshots(project_id: &str, days: u32) -> Result { let conn = db::pool().get().map_err(|e| e.to_string())?; ensure_project_exists(&conn, project_id)?; let mut stmt = conn .prepare( "SELECT snapshot_date, days_since_commit, blueprint_done_rate, blueprint_in_progress, blueprint_blocked, has_errors FROM project_snapshots WHERE project_id = ?1 AND snapshot_date >= date('now', 'localtime', ?2) ORDER BY snapshot_date ASC", ) .map_err(|e| e.to_string())?; let offset = format!("-{} days", days); let rows: Vec<(String, Option, Option, i64, i64, i64)> = stmt .query_map(rusqlite::params![project_id, offset], |r| { Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)) }) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); if rows.is_empty() { return Ok(format!( "项目 `{}` 暂无历史快照(快照在每次应用启动时写入,最多保留 {} 天)。", project_id, days )); } let mut md = format!( "# 项目历史快照:`{}`\n\n最近 {} 天,共 {} 条记录\n\n", project_id, days, rows.len() ); md.push_str("| 日期 | 距上次提交(天) | 蓝图完成率 | 进行中模块 | 阻塞任务 | 有错误 |\n"); md.push_str("|------|--------------|-----------|-----------|---------|------|\n"); for (date, days_commit, done_rate, in_prog, blocked, has_err) in &rows { let commit_col = days_commit.map(|d| d.to_string()).unwrap_or_else(|| "—".to_string()); let rate_col = done_rate .map(|r| format!("{:.0}%", r * 100.0)) .unwrap_or_else(|| "—".to_string()); let err_col = if *has_err == 1 { "⚠️" } else { "✅" }; md.push_str(&format!( "| {} | {} | {} | {} | {} | {} |\n", date, commit_col, rate_col, in_prog, blocked, err_col )); } md.push_str("\n---\n\n请根据以上趋势数据,分析哪些变量与项目停滞或健康度下降最相关,并给出改进建议。\n"); Ok(md) } pub fn list_all_projects() -> Result { let conn = db::pool().get().map_err(|e| e.to_string())?; let mut stmt = conn .prepare( "SELECT pw.id, pp.name, pw.platform, pw.win_path, pw.wsl_path, pgm.group_id FROM project_workspaces pw JOIN project_profiles pp ON pp.id = pw.profile_id LEFT JOIN project_group_map pgm ON pgm.project_id = pw.id ORDER BY pp.name", ) .map_err(|e| e.to_string())?; let rows: Vec<(String, String, Option, Option, Option, Option)> = stmt.query_map([], |r| { Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)) }) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); if rows.is_empty() { return Ok("炼境暂无登记项目。".to_string()); } let mut out = format!("## 炼境全量项目(共 {} 个)\n\n", rows.len()); for (id, name, platform, win_path, wsl_path, group_id) in &rows { let project_path = if platform.as_deref() == Some("wsl") { wsl_path.as_ref().map(|p| { let conn2 = db::pool().get().ok(); let distro = conn2 .and_then(|c| { c.query_row( "SELECT value FROM settings WHERE key = 'wsl_distro'", [], |r| r.get::<_, String>(0), ) .ok() }) .unwrap_or_else(|| "Ubuntu".to_string()); let inner = p.trim_start_matches('/').replace('/', "\\"); format!("\\\\wsl.localhost\\{}\\{}", distro, inner) }) } else { win_path.clone() }; let injected = project_path .as_ref() .map(|p| std::path::Path::new(p).join(".claude").join("liangjing.json").exists()) .unwrap_or(false); let inject_icon = if injected { "✅ 已注入" } else { "⚠️ 未注入" }; let group_str = group_id.as_deref().unwrap_or("无产品组"); let path_str = project_path.as_deref().unwrap_or("未配置路径"); out.push_str(&format!( "- **{}** `{}`\n {} | 组: `{}` | {}\n\n", name, id, inject_icon, group_str, path_str )); } out.push_str("---\n对未注入项目调用 `inject_mcp(project_id)` 完成接入。"); 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)] mod tests { use super::*; #[test] fn tools_list_contains_expected_tools() { let result = tools_list_result(); let tools = result["tools"].as_array().expect("tools 应为数组"); let names: Vec<&str> = tools .iter() .filter_map(|t| t["name"].as_str()) .collect(); // 核心工具必须存在 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"); assert!(names.contains(&"setup_gitea_repo"), "缺少 setup_gitea_repo"); // 每个工具都有 description 和 inputSchema for tool in tools { let name = tool["name"].as_str().unwrap_or("?"); assert!(tool["description"].is_string(), "工具 {name} 缺少 description"); assert!(tool["inputSchema"].is_object(), "工具 {name} 缺少 inputSchema"); } } #[test] fn apply_onboarding_pack_schema_requires_project_id() { let result = tools_list_result(); let tools = result["tools"].as_array().unwrap(); for name in ["apply_onboarding_pack", "verify_onboarding"] { let tool = tools.iter().find(|t| t["name"] == name).unwrap(); let required = tool["inputSchema"]["required"].as_array().unwrap(); let fields: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect(); assert!(fields.contains(&"project_id"), "{name} 应必填 project_id"); } } }