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;
}