feat(onboarding): V1 接入生效验收——lefthook 分发即激活 + verify_onboarding 六项检查链
All checks were successful
Push Check / check (push) Successful in 45s

分发成功 ≠ 功能生效:接入包此前只写 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
This commit is contained in:
lanrtop 2026-07-04 08:27:39 +09:00
parent 2da50890ea
commit ccf87ae09a
7 changed files with 410 additions and 14 deletions

View File

@ -369,8 +369,8 @@ modules:
name: 接入包 name: 接入包
area: backend area: backend
status: in_progress status: in_progress
progress: 98 progress: 99
note: "体系分发主线T1/B1/B2/B3 ✅;剩 B4 健康诊断遵守度(后置)+ V1 接入生效验收2026-07-04 新立lefthook 分发后从不激活的洞 + verify_onboarding 消费者角色验证" note: "体系分发主线T1/B1/B2/B3/V1 ✅V1 2026-07-04lefthook 分发即激活 + verify_onboarding 六项检查链);剩 B4 健康诊断遵守度(后置"
position: [3500, 0] position: [3500, 0]
- id: gitea-integration - id: gitea-integration

View File

@ -107,8 +107,9 @@
> 背景模板演进无法触达存量项目是版本化闭环的最大缺口trailer 章节曾手工补)。 > 背景模板演进无法触达存量项目是版本化闭环的最大缺口trailer 章节曾手工补)。
### 📋 V1. 接入生效验收lefthook 激活 + verify_onboarding 按钮 ### ✅ V1. 接入生效验收lefthook 激活 + verify_onboarding 按钮
- status: planned - 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 - complexity: M
- depends: onboarding-pack卡 B、卡 T1 - depends: onboarding-pack卡 B、卡 T1
- files: src-tauri/src/commands/onboarding.rs, src-tauri/src/mcp_tools.rs, src/lib/commands.ts - 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 到不了存量项目 - 版本化闭环要求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,后置) 健康诊断加体系遵守度检查。移植体验=零新增动作,接入流程不变。 - 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 工具实调需炼境升级重启后终验(运行实例是旧 buildWSL UNC 路径下 lefthook install 的 current_dir 行为未实测,失败会如实进 failed 态不会误报
auto_applied_rules: R01, R06, R07, R13
rule_effectiveness: R01=有效commands.ts 同步时暴露类型层旧债), R06=有效conn 注入使全链集成测试可行), R07=有效6 条新测试), R13=有效(集成测试即真实走通的验收路径)

View File

@ -30,7 +30,7 @@ src/lib/commands.ts 所有 Tauri command 的前端封装(唯一
src-tauri/src/commands/ Rust 后端实现(每功能一个 .rs 文件) src-tauri/src/commands/ Rust 后端实现(每功能一个 .rs 文件)
src-tauri/src/db.rs SQLite 建表 + migration 逻辑 src-tauri/src/db.rs SQLite 建表 + migration 逻辑
src-tauri/src/mcp_server.rs MCP Server 路由层 + SSE 协议agent 调用炼境的入口) 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_webhook.rs Gitea webhook 处理push/PR 事件写入飞轮)
src-tauri/src/mcp_inject.rs MCP 配置注入(写入被管理项目的 .claude/settings.json src-tauri/src/mcp_inject.rs MCP 配置注入(写入被管理项目的 .claude/settings.json
src-tauri/resources/onboarding/ 接入包分发模板AGENTS.md/lefthook/git-workflow src-tauri/resources/onboarding/ 接入包分发模板AGENTS.md/lefthook/git-workflow

View File

@ -148,22 +148,245 @@ pub fn dispatch_templates(project_path: &str) -> Vec<TemplateResult> {
] ]
} }
/// 接入包应用报告:模板分发结果 + buff 状态。 // ── V1lefthook 激活(分发 ≠ 生效,文件写入后必须 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<std::path::PathBuf> = 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: "未找到 lefthooknode_modules/.bin 与 PATH 均无。安装pnpm add -D lefthook 后重跑接入包".into(),
},
Err(e) => HookActivation {
status: "failed".into(),
detail: format!("执行失败: {e}"),
},
}
}
/// 接入包应用报告:模板分发结果 + hook 激活 + buff 状态。
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct OnboardingReport { pub struct OnboardingReport {
pub templates: Vec<TemplateResult>, pub templates: Vec<TemplateResult>,
pub hook: HookActivation,
pub buff: crate::commands::buff::BuffStatus, pub buff: crate::commands::buff::BuffStatus,
} }
/// 应用接入包(基线档):分发标准模板 + 挂 buff炼境登记 + git 观测/飞轮采集)。 /// 应用接入包(基线档):分发标准模板 + 激活 lefthook + 挂 buff炼境登记 + git 观测/飞轮采集)。
#[tauri::command] #[tauri::command]
pub fn apply_onboarding_pack( pub fn apply_onboarding_pack(
project_path: String, project_path: String,
project_id: String, project_id: String,
) -> Result<OnboardingReport, String> { ) -> Result<OnboardingReport, String> {
let templates = dispatch_templates(&project_path); 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)?; 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<String> {
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<String> {
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<VerifyCheck> {
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<String> = 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)] #[cfg(test)]
@ -257,5 +480,98 @@ mod tests {
let _ = std::fs::remove_dir_all(&base); 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");
}
} }

View File

@ -229,6 +229,17 @@ pub fn tools_list_result() -> Value {
}, },
"required": ["project_id"] "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) format!(" {icon}: {}", t.name)
}) })
.collect(); .collect();
let hook_icon = match report.hook.status.as_str() {
"activated" => "",
"missing_binary" => "⚠️",
_ => "",
};
format!( format!(
"接入包分发完成project_id={}\n\n模板:\n{}\n\nbuff{}", "接入包分发完成project_id={}\n\n模板:\n{}\n\nlefthook hook{} {}{}\nbuff{}\n\n建议调用 verify_onboarding 做生效验收。",
pid, pid,
template_lines.join("\n"), template_lines.join("\n"),
hook_icon,
report.hook.status,
report.hook.detail,
report.buff.status 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)), _ => Err(format!("未知工具: {}", tool_name)),
}) })
.await; .await;
@ -880,6 +913,7 @@ mod tests {
.collect(); .collect();
// 核心工具必须存在 // 核心工具必须存在
assert!(names.contains(&"apply_onboarding_pack"), "缺少 apply_onboarding_pack"); 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(&"list_all_projects"), "缺少 list_all_projects");
assert!(names.contains(&"inject_mcp"), "缺少 inject_mcp"); assert!(names.contains(&"inject_mcp"), "缺少 inject_mcp");
assert!(names.contains(&"create_pull_request"), "缺少 create_pull_request"); assert!(names.contains(&"create_pull_request"), "缺少 create_pull_request");
@ -896,9 +930,11 @@ mod tests {
fn apply_onboarding_pack_schema_requires_project_id() { fn apply_onboarding_pack_schema_requires_project_id() {
let result = tools_list_result(); let result = tools_list_result();
let tools = result["tools"].as_array().unwrap(); let tools = result["tools"].as_array().unwrap();
let tool = tools.iter().find(|t| t["name"] == "apply_onboarding_pack").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 required = tool["inputSchema"]["required"].as_array().unwrap();
let fields: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect(); let fields: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
assert!(fields.contains(&"project_id")); assert!(fields.contains(&"project_id"), "{name} 应必填 project_id");
}
} }
} }

View File

@ -32,6 +32,7 @@ import {
type SyncResult, type SyncResult,
type BuffStatus, type BuffStatus,
type TemplateResult, type TemplateResult,
type HookActivation,
type CommitStats, type CommitStats,
} from "../../lib/commands"; } from "../../lib/commands";
@ -1058,6 +1059,7 @@ function GovernancePanel({
const [buffInitPrompt, setBuffInitPrompt] = useState<string | null>(null); const [buffInitPrompt, setBuffInitPrompt] = useState<string | null>(null);
const [buffInitCopied, setBuffInitCopied] = useState(false); const [buffInitCopied, setBuffInitCopied] = useState(false);
const [packTemplates, setPackTemplates] = useState<TemplateResult[] | null>(null); const [packTemplates, setPackTemplates] = useState<TemplateResult[] | null>(null);
const [packHook, setPackHook] = useState<HookActivation | null>(null);
const [promptMode, setPromptMode] = useState<"init" | "sync" | null>(null); const [promptMode, setPromptMode] = useState<"init" | "sync" | null>(null);
const [prompt, setPrompt] = useState(""); const [prompt, setPrompt] = useState("");
@ -1071,9 +1073,11 @@ function GovernancePanel({
setBuffError(null); setBuffError(null);
setBuffInitPrompt(null); setBuffInitPrompt(null);
setPackTemplates(null); setPackTemplates(null);
setPackHook(null);
try { try {
const report = await applyOnboardingPack(projectPath, projectId); const report = await applyOnboardingPack(projectPath, projectId);
setPackTemplates(report.templates); setPackTemplates(report.templates);
setPackHook(report.hook);
refetchBuff(); refetchBuff();
// 只有尚无蓝图时才展示 init prompt已有蓝图则 Buff 直接激活无需初始化 // 只有尚无蓝图时才展示 init prompt已有蓝图则 Buff 直接激活无需初始化
if (bpStatus?.status === "none") { if (bpStatus?.status === "none") {
@ -1095,6 +1099,7 @@ function GovernancePanel({
refetchBuff(); refetchBuff();
setBuffInitPrompt(null); setBuffInitPrompt(null);
setPackTemplates(null); setPackTemplates(null);
setPackHook(null);
} catch (e) { } catch (e) {
setBuffError(String(e)); setBuffError(String(e));
} finally { } finally {
@ -1313,6 +1318,25 @@ function GovernancePanel({
</p> </p>
); );
})} })}
{packHook && (
<p className="text-[10px] font-mono flex items-center gap-1">
<span
className={
packHook.status === "activated"
? "text-green-600"
: packHook.status === "missing_binary"
? "text-amber-500"
: "text-red-500"
}
>
{packHook.status === "activated" ? "✓" : packHook.status === "missing_binary" ? "⚠" : "✗"}
</span>
<span className="text-gray-600 truncate flex-1">lefthook hook</span>
<span className="text-gray-400 truncate" title={packHook.detail}>
{packHook.detail}
</span>
</p>
)}
</div> </div>
)} )}
{buffStatus?.status === "active" ? ( {buffStatus?.status === "active" ? (

View File

@ -1471,12 +1471,18 @@ export const listBuffedProjects = () =>
export interface TemplateResult { export interface TemplateResult {
name: string; name: string;
action: "written" | "skipped" | "failed"; action: "written" | "updated" | "appended" | "skipped" | "failed";
detail: string;
}
export interface HookActivation {
status: "activated" | "missing_binary" | "failed";
detail: string; detail: string;
} }
export interface OnboardingReport { export interface OnboardingReport {
templates: TemplateResult[]; templates: TemplateResult[];
hook: HookActivation;
buff: BuffStatus; buff: BuffStatus;
} }