feat(onboarding): V1 接入生效验收——lefthook 分发即激活 + verify_onboarding 六项检查链
All checks were successful
Push Check / check (push) Successful in 45s
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:
parent
2da50890ea
commit
ccf87ae09a
@ -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
|
||||
|
||||
@ -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=有效(集成测试即真实走通的验收路径)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -148,22 +148,245 @@ pub fn dispatch_templates(project_path: &str) -> Vec<TemplateResult> {
|
||||
]
|
||||
}
|
||||
|
||||
/// 接入包应用报告:模板分发结果 + 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<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: "未找到 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<TemplateResult>,
|
||||
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<OnboardingReport, String> {
|
||||
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<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)]
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<string | null>(null);
|
||||
const [buffInitCopied, setBuffInitCopied] = useState(false);
|
||||
const [packTemplates, setPackTemplates] = useState<TemplateResult[] | null>(null);
|
||||
const [packHook, setPackHook] = useState<HookActivation | null>(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({
|
||||
</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>
|
||||
)}
|
||||
{buffStatus?.status === "active" ? (
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user