From ccf87ae09aac6200b338b1215222be76615e7a56 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 4 Jul 2026 08:27:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(onboarding):=20V1=20=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E7=94=9F=E6=95=88=E9=AA=8C=E6=94=B6=E2=80=94=E2=80=94lefthook?= =?UTF-8?q?=20=E5=88=86=E5=8F=91=E5=8D=B3=E6=BF=80=E6=B4=BB=20+=20verify?= =?UTF-8?q?=5Fonboarding=20=E5=85=AD=E9=A1=B9=E6=A3=80=E6=9F=A5=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 分发成功 ≠ 功能生效:接入包此前只写 lefthook.yml 从不激活, 文件在 hook 死 → conventional 不强制 → 飞轮数据脏。 - apply_onboarding_pack 分发后自动 lefthook install(本地 .bin 优先/ PATH 兜底/静默),OnboardingReport 新增 hook 三态 - 新 MCP 工具 verify_onboarding:管理块版本/hook 真挂/origin 命中 Gitea 实例/CI workflow 存在/近 30 天飞轮数据 六项检查链 JSON - 判定纯函数化 5 条单测 + in-memory DB 全链集成测试 - commands.ts 补 TemplateResult 缺失的 updated/appended 态(T1 旧债) - AGENTS.md 去掉写死的工具计数(防腐:以 tools_list_result 自省为准) Rules-Applied: R01,R06,R07,R13 --- .blueprint/manifest.yaml | 4 +- .blueprint/modules/onboarding-pack.md | 18 +- AGENTS.md | 2 +- src-tauri/src/commands/onboarding.rs | 322 +++++++++++++++++++- src-tauri/src/mcp_tools.rs | 46 ++- src/components/dashboard/BlueprintModal.tsx | 24 ++ src/lib/commands.ts | 8 +- 7 files changed, 410 insertions(+), 14 deletions(-) diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index 045e082..751ec50 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -369,8 +369,8 @@ modules: name: 接入包 area: backend status: in_progress - progress: 98 - note: "体系分发主线:T1/B1/B2/B3 ✅;剩 B4 健康诊断遵守度(后置)+ V1 接入生效验收(2026-07-04 新立,lefthook 分发后从不激活的洞 + verify_onboarding 消费者角色验证)" + progress: 99 + note: "体系分发主线:T1/B1/B2/B3/V1 ✅(V1 2026-07-04:lefthook 分发即激活 + verify_onboarding 六项检查链);剩 B4 健康诊断遵守度(后置)" position: [3500, 0] - id: gitea-integration diff --git a/.blueprint/modules/onboarding-pack.md b/.blueprint/modules/onboarding-pack.md index 4b89010..a9f8a37 100644 --- a/.blueprint/modules/onboarding-pack.md +++ b/.blueprint/modules/onboarding-pack.md @@ -107,8 +107,9 @@ > 背景:模板演进无法触达存量项目是版本化闭环的最大缺口(trailer 章节曾手工补)。 -### 📋 V1. 接入生效验收:lefthook 激活 + verify_onboarding 按钮 -- status: planned +### ✅ V1. 接入生效验收:lefthook 激活 + verify_onboarding 按钮 +- status: done +- done_note: 2026-07-04 完成。apply_onboarding_pack 分发后自动 lefthook install(本地 node_modules/.bin 优先、PATH 兜底、CREATE_NO_WINDOW 静默),报告新增 hook 三态;verify_onboarding MCP 工具六项检查链(AGENTS/git-workflow 管理块版本自省对比、pre-commit 真挂 lefthook、origin host 命中已登记 Gitea 实例、.gitea/workflows 存在、近 30 天 commit_metrics 流入);判定逻辑纯函数化 5 条单测 + in-memory DB 全链集成测试(空项目六项全 fail → 铺齐后六项全 pass);前端 commands.ts 类型同步(顺修 TemplateResult 缺 updated/appended 旧债)+ BlueprintModal 显示 hook 激活行。MCP 实调待炼境升级重启后终验(当前运行实例是旧 build) - complexity: M - depends: onboarding-pack(卡 B、卡 T1) - files: src-tauri/src/commands/onboarding.rs, src-tauri/src/mcp_tools.rs, src/lib/commands.ts @@ -121,3 +122,16 @@ - 版本化闭环要求(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,后置) 健康诊断加体系遵守度检查。移植体验=零新增动作,接入流程不变。 +## 复盘笔记 + +【复盘】V1 接入生效验收(2026-07-04) +任务类型: 后端命令 + MCP工具 + 前端类型同步 +复杂度: M +实际轮数: 1轮(预估 1 轮) +是否返工: 否 +有效策略: 立卡前先侦察实锤(grep 确认 lefthook install 全仓库零调用、cicd.rs 模板同款 lint 语法雷),acceptance 直接写成可执行验证路径使实现无歧义;检查链判定与 IO/DB 分离(纯函数 + conn 注入),in-memory DB 全链集成测试把"空项目全 fail → 铺齐全 pass"变成回归资产;模板期望版本从内嵌模板自省(extract_managed_block)而非硬编码,模板升版验收逻辑零维护 +顺手修的旧债: 前端 TemplateResult 类型缺 updated/appended 两态(T1 时只修了组件层样式映射,类型层漏了);AGENTS.md/agent-button-system.md 写死"21 个工具"计数(防腐铁律违例,改为指向 tools_list_result 自省) +遗留: MCP 工具实调需炼境升级重启后终验(运行实例是旧 build);WSL UNC 路径下 lefthook install 的 current_dir 行为未实测,失败会如实进 failed 态不会误报 +auto_applied_rules: R01, R06, R07, R13 +rule_effectiveness: R01=有效(commands.ts 同步时暴露类型层旧债), R06=有效(conn 注入使全链集成测试可行), R07=有效(6 条新测试), R13=有效(集成测试即真实走通的验收路径) + diff --git a/AGENTS.md b/AGENTS.md index ca17f58..cd03478 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ src/lib/commands.ts 所有 Tauri command 的前端封装(唯一 src-tauri/src/commands/ Rust 后端实现(每功能一个 .rs 文件) src-tauri/src/db.rs SQLite 建表 + migration 逻辑 src-tauri/src/mcp_server.rs MCP Server 路由层 + SSE 协议(agent 调用炼境的入口) -src-tauri/src/mcp_tools.rs MCP 工具 schema + dispatch + 实现(21 个工具) +src-tauri/src/mcp_tools.rs MCP 工具 schema + dispatch + 实现(工具清单以 tools_list_result 为准) src-tauri/src/mcp_webhook.rs Gitea webhook 处理(push/PR 事件写入飞轮) src-tauri/src/mcp_inject.rs MCP 配置注入(写入被管理项目的 .claude/settings.json) src-tauri/resources/onboarding/ 接入包分发模板(AGENTS.md/lefthook/git-workflow) diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index 1ceb0af..dbf109f 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -148,22 +148,245 @@ pub fn dispatch_templates(project_path: &str) -> Vec { ] } -/// 接入包应用报告:模板分发结果 + buff 状态。 +// ── V1:lefthook 激活(分发 ≠ 生效,文件写入后必须 lefthook install 挂上 hook)── + +/// lefthook 激活结果。status: "activated" | "missing_binary" | "failed" +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct HookActivation { + pub status: String, + pub detail: String, +} + +/// 执行 lefthook install:优先项目本地 node_modules/.bin,回退全局 PATH。 +fn run_lefthook_install(root: &Path) -> HookActivation { + let mut candidates: Vec = Vec::new(); + #[cfg(target_os = "windows")] + { + candidates.push(root.join("node_modules/.bin/lefthook.exe")); + candidates.push(root.join("node_modules/.bin/lefthook.cmd")); + } + #[cfg(not(target_os = "windows"))] + candidates.push(root.join("node_modules/.bin/lefthook")); + + let local = candidates.into_iter().find(|p| p.exists()); + let program = local + .as_ref() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "lefthook".to_string()); + let source = if local.is_some() { "项目本地" } else { "全局 PATH" }; + + match crate::silent_cmd(&program).arg("install").current_dir(root).output() { + Ok(out) if out.status.success() => HookActivation { + status: "activated".into(), + detail: format!("lefthook install 完成({source})"), + }, + Ok(out) => HookActivation { + status: "failed".into(), + detail: format!( + "lefthook install 退出码 {:?}:{}", + out.status.code(), + String::from_utf8_lossy(&out.stderr).trim() + ), + }, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => HookActivation { + status: "missing_binary".into(), + detail: "未找到 lefthook(node_modules/.bin 与 PATH 均无)。安装:pnpm add -D lefthook 后重跑接入包".into(), + }, + Err(e) => HookActivation { + status: "failed".into(), + detail: format!("执行失败: {e}"), + }, + } +} + +/// 接入包应用报告:模板分发结果 + hook 激活 + buff 状态。 #[derive(Debug, Serialize, Deserialize, Clone)] pub struct OnboardingReport { pub templates: Vec, + pub hook: HookActivation, pub buff: crate::commands::buff::BuffStatus, } -/// 应用接入包(基线档):分发标准模板 + 挂 buff(炼境登记 + git 观测/飞轮采集)。 +/// 应用接入包(基线档):分发标准模板 + 激活 lefthook + 挂 buff(炼境登记 + git 观测/飞轮采集)。 #[tauri::command] pub fn apply_onboarding_pack( project_path: String, project_id: String, ) -> Result { let templates = dispatch_templates(&project_path); + let hook = run_lefthook_install(Path::new(project_path.trim_end_matches(['/', '\\']))); let buff = crate::commands::buff::apply_blueprint_buff(project_path, project_id)?; - Ok(OnboardingReport { templates, buff }) + Ok(OnboardingReport { templates, hook, buff }) +} + +// ── V1:接入生效验收(verify_onboarding 检查链,判定逻辑纯函数化可单测)────── + +/// 单项验收结果。 +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct VerifyCheck { + pub name: String, + pub pass: bool, + pub detail: String, +} + +/// 纯函数:pre-commit hook 内容是否由 lefthook 接管。 +fn hook_is_lefthook(content: &str) -> bool { + content.contains("lefthook") +} + +/// 纯函数:从 .git/config 文本提取 origin remote 的 url。 +fn parse_origin_url(git_config: &str) -> Option { + let mut in_origin = false; + for line in git_config.lines() { + let t = line.trim(); + if t.starts_with('[') { + in_origin = t.replace(' ', "").eq_ignore_ascii_case("[remote\"origin\"]"); + continue; + } + if in_origin { + if let Some(rest) = t.strip_prefix("url") { + if let Some(v) = rest.trim_start().strip_prefix('=') { + return Some(v.trim().to_string()); + } + } + } + } + None +} + +/// 纯函数:提取 URL 的 host(支持 https:// 与 git@host: 两种形式,忽略端口与用户名)。 +fn url_host(url: &str) -> Option { + let u = url.trim(); + if let Some(rest) = u.split("://").nth(1) { + let authority = rest.split('/').next()?; + let host = authority.rsplit('@').next()?.split(':').next()?; + return Some(host.to_lowercase()); + } + // ssh 形式 git@host:owner/repo.git + let after_at = u.rsplit('@').next()?; + let host = after_at.split([':', '/']).next()?; + if host.contains('.') { + Some(host.to_lowercase()) + } else { + None + } +} + +/// 纯函数:管理块版本是否与最新模板一致。 +fn check_managed_version(content: Option<&str>, expected: &str) -> (bool, String) { + match content.and_then(block_version) { + Some(v) if v == expected => (true, format!("管理块 v{v}(最新)")), + Some(v) => (false, format!("管理块 v{v},最新模板 v{expected},重跑接入包可升级")), + None => (false, "文件缺失或无管理块标记".into()), + } +} + +/// 纯函数:origin host 是否命中已登记的 Gitea 实例。 +fn check_origin(origin_host: Option<&str>, instance_hosts: &[String]) -> (bool, String) { + match origin_host { + None => (false, "未配置 origin remote(或无 .git)".into()), + Some(h) if instance_hosts.is_empty() => { + (false, format!("origin={h},但炼境未登记任何 Gitea 实例")) + } + Some(h) if instance_hosts.iter().any(|i| i == h) => { + (true, format!("origin → {h}(已登记 Gitea 实例)")) + } + Some(h) => ( + false, + format!("origin={h},不在已登记 Gitea 实例({})中", instance_hosts.join(", ")), + ), + } +} + +/// 接入生效验收:五项检查链。分发成功 ≠ 功能生效,本函数验证"真的用起来了"。 +/// conn 由调用方注入(R06)。 +pub fn verify_onboarding( + conn: &rusqlite::Connection, + project_id: &str, + root: &Path, +) -> Vec { + let read = |rel: &str| std::fs::read_to_string(root.join(rel)).ok(); + let mut checks = Vec::new(); + + // 1/2 模板管理块版本到位(期望版本从内嵌模板自省,不硬编码) + for (rel, tmpl, label) in [ + ("AGENTS.md", TMPL_AGENTS, "AGENTS.md 管理块版本"), + ("docs/ai-context/git-workflow.md", TMPL_GIT_WORKFLOW, "git-workflow 管理块版本"), + ] { + let expected = extract_managed_block(tmpl).and_then(block_version).unwrap_or("?"); + let (pass, detail) = check_managed_version(read(rel).as_deref(), expected); + checks.push(VerifyCheck { name: label.into(), pass, detail }); + } + + // 3 lefthook hook 真挂在 .git/hooks + let (pass, detail) = match read(".git/hooks/pre-commit") { + Some(c) if hook_is_lefthook(&c) => (true, "pre-commit 由 lefthook 接管".to_string()), + Some(_) => (false, "pre-commit 存在但非 lefthook(被其他工具占用?)".to_string()), + None => (false, "无 pre-commit hook——lefthook install 未执行".to_string()), + }; + checks.push(VerifyCheck { name: "lefthook hook 挂载".into(), pass, detail }); + + // 4 origin 指向已登记 Gitea 实例 + let origin_host = read(".git/config") + .as_deref() + .and_then(parse_origin_url) + .as_deref() + .and_then(url_host); + let mut instance_hosts: Vec = Vec::new(); + if let Ok(mut stmt) = conn.prepare("SELECT url FROM gitea_instances") { + if let Ok(rows) = stmt.query_map([], |r| r.get::<_, String>(0)) { + for u in rows.flatten() { + if let Some(h) = url_host(&u) { + instance_hosts.push(h); + } + } + } + } + let (pass, detail) = check_origin(origin_host.as_deref(), &instance_hosts); + checks.push(VerifyCheck { name: "origin 指向 Gitea".into(), pass, detail }); + + // 5 CI workflow 存在 + let yml_count = std::fs::read_dir(root.join(".gitea/workflows")) + .map(|rd| { + rd.flatten() + .filter(|e| { + e.path() + .extension() + .map(|x| x == "yml" || x == "yaml") + .unwrap_or(false) + }) + .count() + }) + .unwrap_or(0); + checks.push(VerifyCheck { + name: "CI workflow 存在".into(), + pass: yml_count > 0, + detail: if yml_count > 0 { + format!(".gitea/workflows/ 有 {yml_count} 个 workflow") + } else { + "无 .gitea/workflows/*.yml——可调 distribute_cicd 分发".into() + }, + }); + + // 6 飞轮数据在流入(近 30 天 commit_metrics) + let recent: i64 = conn + .query_row( + "SELECT COUNT(*) FROM commit_metrics WHERE project_id = ?1 AND committed_at >= datetime('now','-30 days')", + rusqlite::params![project_id], + |r| r.get(0), + ) + .unwrap_or(0); + checks.push(VerifyCheck { + name: "飞轮 commit 数据流入".into(), + pass: recent > 0, + detail: if recent > 0 { + format!("近 30 天 {recent} 条 commit 已采集") + } else { + "近 30 天无数据——确认 buff 已挂且有 commit 活动(或调 poll_now)".into() + }, + }); + + checks } #[cfg(test)] @@ -257,5 +480,98 @@ mod tests { let _ = std::fs::remove_dir_all(&base); } + + // ── V1:接入生效验收纯函数 ──────────────────────────────────────────── + + #[test] + fn hook_detection_covers_mounted_and_foreign() { + assert!(hook_is_lefthook("#!/bin/sh\ncall lefthook run pre-commit"), "lefthook 接管应判 true"); + assert!(!hook_is_lefthook("#!/bin/sh\nnpx husky run"), "非 lefthook hook 应判 false"); + } + + #[test] + fn origin_parsing_and_host_extraction() { + let cfg = "[core]\n\trepositoryformatversion = 0\n[remote \"origin\"]\n\turl = https://git.example.com/o/r.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n[remote \"mirror\"]\n\turl = https://gitee.com/x/y.git\n"; + assert_eq!( + parse_origin_url(cfg).as_deref(), + Some("https://git.example.com/o/r.git"), + "应取 origin 而非其他 remote" + ); + assert_eq!(parse_origin_url("[core]\n\tbare = false"), None, "无 origin 应返回 None"); + + assert_eq!(url_host("https://git.example.com/o/r.git").as_deref(), Some("git.example.com")); + assert_eq!(url_host("https://user@git.example.com:3000/o/r.git").as_deref(), Some("git.example.com"), "应忽略用户名与端口"); + assert_eq!(url_host("git@git.example.com:o/r.git").as_deref(), Some("git.example.com"), "ssh 形式"); + } + + #[test] + fn origin_check_match_and_mismatch() { + let hosts = vec!["git.example.com".to_string()]; + assert!(check_origin(Some("git.example.com"), &hosts).0, "命中实例应 pass"); + assert!(!check_origin(Some("github.com"), &hosts).0, "未命中应 fail"); + assert!(!check_origin(None, &hosts).0, "无 origin 应 fail"); + assert!(!check_origin(Some("git.example.com"), &[]).0, "无登记实例应 fail"); + } + + #[test] + fn verify_onboarding_full_chain() { + let conn = crate::db::conn_with_schema(); + let base = std::env::temp_dir() + .join(format!("lianjing_verify_test_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + + // 空目录:六项全不通过(真实反映"什么都没接") + let checks = verify_onboarding(&conn, "p1", &base); + assert_eq!(checks.len(), 6, "检查链应为六项"); + assert!(checks.iter().all(|c| !c.pass), "空项目应全部不通过"); + + // 铺齐全部生效条件后:六项全通过 + dispatch_templates(base.to_str().unwrap()); + std::fs::create_dir_all(base.join(".git/hooks")).unwrap(); + std::fs::write(base.join(".git/hooks/pre-commit"), "call lefthook run pre-commit").unwrap(); + std::fs::write( + base.join(".git/config"), + "[remote \"origin\"]\n\turl = https://git.example.com/o/r.git\n", + ) + .unwrap(); + std::fs::create_dir_all(base.join(".gitea/workflows")).unwrap(); + std::fs::write(base.join(".gitea/workflows/pr-check.yml"), "name: x").unwrap(); + conn.execute( + "INSERT INTO gitea_instances (id, name, url, access_token) VALUES ('i1','t','https://git.example.com','tok')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO commit_metrics (sha, project_id, commit_type, is_rework, is_ci_auto, committed_at, parsed_at) + VALUES ('abc','p1','feat',0,0,datetime('now'),datetime('now'))", + [], + ) + .unwrap(); + + let checks = verify_onboarding(&conn, "p1", &base); + for c in &checks { + assert!(c.pass, "「{}」应通过,实际: {}", c.name, c.detail); + } + + let _ = std::fs::remove_dir_all(&base); + } + + #[test] + fn managed_version_check_states() { + let expected = extract_managed_block(TMPL_AGENTS).and_then(block_version).unwrap(); + assert!(check_managed_version(Some(TMPL_AGENTS), expected).0, "同版本应 pass"); + + let old = TMPL_AGENTS.replacen( + &format!("version=\"{expected}\""), + "version=\"0.0.1\"", + 1, + ); + let (pass, detail) = check_managed_version(Some(&old), expected); + assert!(!pass && detail.contains("0.0.1"), "旧版本应 fail 且报出版本号"); + + assert!(!check_managed_version(None, expected).0, "文件缺失应 fail"); + assert!(!check_managed_version(Some("手写内容,无标记"), expected).0, "无标记应 fail"); + } } diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index 59d93bb..2d1e2be 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -229,6 +229,17 @@ pub fn tools_list_result() -> Value { }, "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"] + } } ] }) @@ -465,16 +476,38 @@ pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value { format!(" {icon}: {}", t.name) }) .collect(); + let hook_icon = match report.hook.status.as_str() { + "activated" => "✅", + "missing_binary" => "⚠️", + _ => "❌", + }; format!( - "接入包分发完成(project_id={})\n\n模板:\n{}\n\nbuff:{}", + "接入包分发完成(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(&gid, 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; @@ -880,6 +913,7 @@ mod tests { .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(&"inject_mcp"), "缺少 inject_mcp"); assert!(names.contains(&"create_pull_request"), "缺少 create_pull_request"); @@ -896,9 +930,11 @@ mod tests { fn apply_onboarding_pack_schema_requires_project_id() { let result = tools_list_result(); let tools = result["tools"].as_array().unwrap(); - let tool = tools.iter().find(|t| t["name"] == "apply_onboarding_pack").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")); + 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"); + } } } diff --git a/src/components/dashboard/BlueprintModal.tsx b/src/components/dashboard/BlueprintModal.tsx index cff2e72..a435a32 100644 --- a/src/components/dashboard/BlueprintModal.tsx +++ b/src/components/dashboard/BlueprintModal.tsx @@ -32,6 +32,7 @@ import { type SyncResult, type BuffStatus, type TemplateResult, + type HookActivation, type CommitStats, } from "../../lib/commands"; @@ -1058,6 +1059,7 @@ function GovernancePanel({ const [buffInitPrompt, setBuffInitPrompt] = useState(null); const [buffInitCopied, setBuffInitCopied] = useState(false); const [packTemplates, setPackTemplates] = useState(null); + const [packHook, setPackHook] = useState(null); const [promptMode, setPromptMode] = useState<"init" | "sync" | null>(null); const [prompt, setPrompt] = useState(""); @@ -1071,9 +1073,11 @@ function GovernancePanel({ setBuffError(null); setBuffInitPrompt(null); setPackTemplates(null); + setPackHook(null); try { const report = await applyOnboardingPack(projectPath, projectId); setPackTemplates(report.templates); + setPackHook(report.hook); refetchBuff(); // 只有尚无蓝图时才展示 init prompt;已有蓝图则 Buff 直接激活无需初始化 if (bpStatus?.status === "none") { @@ -1095,6 +1099,7 @@ function GovernancePanel({ refetchBuff(); setBuffInitPrompt(null); setPackTemplates(null); + setPackHook(null); } catch (e) { setBuffError(String(e)); } finally { @@ -1313,6 +1318,25 @@ function GovernancePanel({

); })} + {packHook && ( +

+ + {packHook.status === "activated" ? "✓" : packHook.status === "missing_binary" ? "⚠" : "✗"} + + lefthook hook + + {packHook.detail} + +

+ )} )} {buffStatus?.status === "active" ? ( diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 3fa4369..c105975 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -1471,12 +1471,18 @@ export const listBuffedProjects = () => export interface TemplateResult { name: string; - action: "written" | "skipped" | "failed"; + action: "written" | "updated" | "appended" | "skipped" | "failed"; + detail: string; +} + +export interface HookActivation { + status: "activated" | "missing_binary" | "failed"; detail: string; } export interface OnboardingReport { templates: TemplateResult[]; + hook: HookActivation; buff: BuffStatus; }