diff --git a/.blueprint/modules/agent-client-capability-packs.md b/.blueprint/modules/agent-client-capability-packs.md index 222d72c..1bbc52e 100644 --- a/.blueprint/modules/agent-client-capability-packs.md +++ b/.blueprint/modules/agent-client-capability-packs.md @@ -33,12 +33,13 @@ Codex 用户级插件 - acceptance: "[cargo test] 对仅含 `.claude/liangjing.json` 的临时项目执行接入/验证 → 写入 `.lian-jing/context.json` 且 project_id 一致;已有 context 的未知字段保留;手工 `[mcp_servers.lian-jing]` 配置仍拒绝覆盖。" - done_note: "2026-09-21:inject_mcp 先写 `.lian-jing/context.json`(schema_version=1、project_id),再写 Codex/Claude 投影;canonical 文件保留未知字段,旧 Claude context 继续保留运行时端口。" -### 📋 P2-B. 能力包描述符与 onboarding 自检报告 -- status: todo +### ✅ P2-B. 能力包描述符与 onboarding 自检报告 +- status: done - complexity: M - depends: P2-A. 工具无关的 canonical 项目上下文与兼容迁移 - files: src-tauri/src/commands/onboarding.rs, src-tauri/src/mcp_tools.rs, src-tauri/resources/onboarding/, docs/ai-context/project-context.yaml - acceptance: "[cargo test] verify_onboarding(project_id) → 对 canonical 缺失、legacy-only、pack 版本不符、Codex MCP 不可用分别返回可区分的 ready/missing/legacy/version_mismatch/unavailable 信号,且不将任一状态伪报为 ready。" +- done_note: "2026-09-21:新增 `.lian-jing/capability-packs.json` 基线描述符;verify_onboarding 对项目上下文、能力包、Codex skill/MCP 和既有基线逐项返回结构化状态与可执行升级提示。" ### 📋 P2-C. Codex Skill-only 可移植能力包 - status: todo diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index a3482ff..febf87a 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -18,9 +18,11 @@ contracts: - label: 被管理项目身份上下文 paths: - "src-tauri/src/mcp_inject.rs" + - "src-tauri/src/commands/onboarding.rs" - ".lian-jing/context.json" + - ".lian-jing/capability-packs.json" - ".claude/liangjing.json" - doc: "`.lian-jing/context.json` 是 project_id 的工具无关单一事实来源;`.claude/liangjing.json` 仅为兼容投影。迁移必须保留未知字段,且不得以客户端全局配置或项目 UI 身份替代项目根目录上下文。" + doc: "`.lian-jing/context.json` 是 project_id 的工具无关单一事实来源;`.claude/liangjing.json` 仅为兼容投影;`.lian-jing/capability-packs.json` 是炼境分发基线的版本描述。迁移必须保留未知字段,验证必须返回显式状态,且不得以客户端全局配置或项目 UI 身份替代项目根目录上下文。" - label: Tauri command 接口(前后端契约) paths: diff --git a/src-tauri/resources/onboarding/.lian-jing/capability-packs.json b/src-tauri/resources/onboarding/.lian-jing/capability-packs.json new file mode 100644 index 0000000..27d4bb1 --- /dev/null +++ b/src-tauri/resources/onboarding/.lian-jing/capability-packs.json @@ -0,0 +1,17 @@ +{ + "schema_version": 1, + "packs": [ + { + "pack_id": "base-onboarding", + "version": "1.0.0", + "supported_clients": ["codex", "claude"], + "project_artifacts": [ + "AGENTS.md", + ".agents/skills/lian-jing/SKILL.md", + ".lian-jing/context.json" + ], + "self_check": "verify_onboarding", + "upgrade_hint": "调用 apply_onboarding_pack(project_id) 更新基线;项目身份上下文由 inject_mcp(project_id) 创建或迁移。" + } + ] +} diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index fae4797..5f02f9a 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -17,8 +17,15 @@ const TMPL_AGENTS: &str = include_str!("../../resources/onboarding/AGENTS.md.tmp const TMPL_LEFTHOOK: &str = include_str!("../../resources/onboarding/lefthook.yml.tmpl"); const TMPL_GIT_WORKFLOW: &str = include_str!("../../resources/onboarding/git-workflow.md.tmpl"); const TMPL_ENVOY: &str = include_str!("../../resources/onboarding/.claude/commands/envoy.md"); -const TMPL_BLUEPRINT_GATE: &str = include_str!("../../resources/onboarding/.claude/hooks/blueprint-gate.cjs"); -const TMPL_LIANJING_SKILL: &str = include_str!("../../resources/onboarding/.agents/skills/lian-jing/SKILL.md"); +const TMPL_BLUEPRINT_GATE: &str = + include_str!("../../resources/onboarding/.claude/hooks/blueprint-gate.cjs"); +const TMPL_LIANJING_SKILL: &str = + include_str!("../../resources/onboarding/.agents/skills/lian-jing/SKILL.md"); +const TMPL_CAPABILITY_PACKS: &str = + include_str!("../../resources/onboarding/.lian-jing/capability-packs.json"); +const PROJECT_CONTEXT_SCHEMA_VERSION: u64 = 1; +const CAPABILITY_PACK_SCHEMA_VERSION: u64 = 1; +const BASE_ONBOARDING_PACK_VERSION: &str = "1.0.0"; /// 单个模板的分发结果。 #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] @@ -134,29 +141,32 @@ fn dispatch_versioned(root: &Path, relative: &str, template: &str) -> TemplateRe } }; - let (new_content, action, detail) = if let (Some(s), Some(e)) = - (content.find(MANAGED_START), content.find(MANAGED_END)) - { - let old_ver = block_version(&content).unwrap_or("?").to_string(); - if old_ver == tpl_ver { - return TemplateResult { - name: relative.to_string(), - action: "skipped".into(), - detail: format!("管理块已是 v{tpl_ver}"), - }; - } - let mut nc = String::new(); - nc.push_str(&content[..s]); - nc.push_str(tpl_block); - nc.push_str(&content[e + MANAGED_END.len()..]); - (nc, "updated", format!("管理块 v{old_ver} → v{tpl_ver},块外内容未动")) - } else { - ( - format!("{}\n\n{}\n", content.trim_end(), tpl_block), - "appended", - format!("追加管理块 v{tpl_ver}(存量文件无标记,原内容保留在上方)"), - ) - }; + let (new_content, action, detail) = + if let (Some(s), Some(e)) = (content.find(MANAGED_START), content.find(MANAGED_END)) { + let old_ver = block_version(&content).unwrap_or("?").to_string(); + if old_ver == tpl_ver { + return TemplateResult { + name: relative.to_string(), + action: "skipped".into(), + detail: format!("管理块已是 v{tpl_ver}"), + }; + } + let mut nc = String::new(); + nc.push_str(&content[..s]); + nc.push_str(tpl_block); + nc.push_str(&content[e + MANAGED_END.len()..]); + ( + nc, + "updated", + format!("管理块 v{old_ver} → v{tpl_ver},块外内容未动"), + ) + } else { + ( + format!("{}\n\n{}\n", content.trim_end(), tpl_block), + "appended", + format!("追加管理块 v{tpl_ver}(存量文件无标记,原内容保留在上方)"), + ) + }; match std::fs::write(&target, new_content) { Ok(_) => TemplateResult { @@ -181,8 +191,21 @@ pub fn dispatch_templates(project_path: &str) -> Vec { dispatch_one(root, "lefthook.yml", TMPL_LEFTHOOK), dispatch_versioned(root, "docs/ai-context/git-workflow.md", TMPL_GIT_WORKFLOW), dispatch_fully_managed(root, ".claude/commands/envoy.md", TMPL_ENVOY), - dispatch_fully_managed(root, ".claude/hooks/blueprint-gate.cjs", TMPL_BLUEPRINT_GATE), - dispatch_fully_managed(root, ".agents/skills/lian-jing/SKILL.md", TMPL_LIANJING_SKILL), + dispatch_fully_managed( + root, + ".claude/hooks/blueprint-gate.cjs", + TMPL_BLUEPRINT_GATE, + ), + dispatch_fully_managed( + root, + ".agents/skills/lian-jing/SKILL.md", + TMPL_LIANJING_SKILL, + ), + dispatch_fully_managed( + root, + ".lian-jing/capability-packs.json", + TMPL_CAPABILITY_PACKS, + ), ] } @@ -220,7 +243,11 @@ fn run_lefthook_install(root: &Path) -> HookActivation { } for (program, source) in &attempts { - match crate::silent_cmd(program).arg("install").current_dir(root).output() { + match crate::silent_cmd(program) + .arg("install") + .current_dir(root) + .output() + { Ok(out) if out.status.success() => { return HookActivation { status: "activated".into(), @@ -275,7 +302,11 @@ pub fn apply_onboarding_pack( 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, hook, buff }) + Ok(OnboardingReport { + templates, + hook, + buff, + }) } // ── V1:接入生效验收(verify_onboarding 检查链,判定逻辑纯函数化可单测)────── @@ -285,9 +316,170 @@ pub fn apply_onboarding_pack( pub struct VerifyCheck { pub name: String, pub pass: bool, + pub state: VerifyState, pub detail: String, } +/// 接入检查的可行动状态。只有 ready 才表示检查项通过;其余状态必须显式说明原因。 +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum VerifyState { + Ready, + Missing, + Legacy, + VersionMismatch, + Unavailable, +} + +impl VerifyCheck { + fn with_state(name: impl Into, state: VerifyState, detail: impl Into) -> Self { + let pass = state == VerifyState::Ready; + Self { + name: name.into(), + pass, + state, + detail: detail.into(), + } + } + + fn from_pass(name: impl Into, pass: bool, detail: impl Into) -> Self { + Self::with_state( + name, + if pass { + VerifyState::Ready + } else { + VerifyState::Missing + }, + detail, + ) + } +} + +fn check_project_context( + canonical: Option<&str>, + legacy: Option<&str>, + expected_project_id: &str, +) -> VerifyCheck { + let parse_object = |content: &str| serde_json::from_str::(content).ok(); + if canonical.is_some() && canonical.and_then(parse_object).is_none() { + return VerifyCheck::with_state( + "项目身份上下文", + VerifyState::Unavailable, + ".lian-jing/context.json 不是有效 JSON——请修复后重试 inject_mcp", + ); + } + match canonical.and_then(parse_object) { + Some(context) if !context.is_object() => VerifyCheck::with_state( + "项目身份上下文", + VerifyState::Unavailable, + ".lian-jing/context.json 顶层不是 JSON 对象——请修复后重试 inject_mcp", + ), + Some(context) => { + let version = context + .get("schema_version") + .and_then(|value| value.as_u64()); + let project_id = context.get("project_id").and_then(|value| value.as_str()); + if version != Some(PROJECT_CONTEXT_SCHEMA_VERSION) { + VerifyCheck::with_state( + "项目身份上下文", + VerifyState::VersionMismatch, + format!( + "context schema_version={:?},当前为 {}——调用 inject_mcp 可升级", + version, PROJECT_CONTEXT_SCHEMA_VERSION + ), + ) + } else if project_id == Some(expected_project_id) { + VerifyCheck::with_state( + "项目身份上下文", + VerifyState::Ready, + "`.lian-jing/context.json` 与已登记 project_id 一致", + ) + } else { + VerifyCheck::with_state( + "项目身份上下文", + VerifyState::Unavailable, + "`.lian-jing/context.json` 的 project_id 与炼境登记不一致——停止使用并重新 inject_mcp", + ) + } + } + None if legacy + .and_then(parse_object) + .and_then(|value| { + value + .get("project_id") + .and_then(|value| value.as_str()) + .map(str::to_owned) + }) + .as_deref() + == Some(expected_project_id) => + { + VerifyCheck::with_state( + "项目身份上下文", + VerifyState::Legacy, + "仅发现 `.claude/liangjing.json`——调用 inject_mcp 迁移到 `.lian-jing/context.json`", + ) + } + None if legacy.is_some() => VerifyCheck::with_state( + "项目身份上下文", + VerifyState::Unavailable, + "`.claude/liangjing.json` 无有效 project_id——请重新 inject_mcp", + ), + None => VerifyCheck::with_state( + "项目身份上下文", + VerifyState::Missing, + "缺少 `.lian-jing/context.json`——调用 inject_mcp 创建项目身份上下文", + ), + } +} + +fn check_capability_packs(content: Option<&str>) -> VerifyCheck { + let Some(content) = content else { + return VerifyCheck::with_state( + "基础能力包描述符", + VerifyState::Missing, + "缺少 `.lian-jing/capability-packs.json`——重跑接入包可写入", + ); + }; + let Ok(descriptor) = serde_json::from_str::(content) else { + return VerifyCheck::with_state( + "基础能力包描述符", + VerifyState::Unavailable, + "能力包描述符不是有效 JSON——请修复后重跑接入包", + ); + }; + let schema_version = descriptor + .get("schema_version") + .and_then(|value| value.as_u64()); + let version = descriptor + .get("packs") + .and_then(|value| value.as_array()) + .and_then(|packs| { + packs.iter().find(|pack| { + pack.get("pack_id").and_then(|value| value.as_str()) == Some("base-onboarding") + }) + }) + .and_then(|pack| pack.get("version")) + .and_then(|value| value.as_str()); + if schema_version == Some(CAPABILITY_PACK_SCHEMA_VERSION) + && version == Some(BASE_ONBOARDING_PACK_VERSION) + { + VerifyCheck::with_state( + "基础能力包描述符", + VerifyState::Ready, + format!("base-onboarding v{BASE_ONBOARDING_PACK_VERSION}(最新)"), + ) + } else { + VerifyCheck::with_state( + "基础能力包描述符", + VerifyState::VersionMismatch, + format!( + "能力包 schema={schema_version:?}、base-onboarding={version:?};当前应为 schema={}、v{}——重跑接入包可升级", + CAPABILITY_PACK_SCHEMA_VERSION, BASE_ONBOARDING_PACK_VERSION + ), + ) + } +} + /// 纯函数:pre-commit hook 内容是否由 lefthook 接管。 fn hook_is_lefthook(content: &str) -> bool { content.contains("lefthook") @@ -299,7 +491,9 @@ fn parse_origin_url(git_config: &str) -> Option { for line in git_config.lines() { let t = line.trim(); if t.starts_with('[') { - in_origin = t.replace(' ', "").eq_ignore_ascii_case("[remote\"origin\"]"); + in_origin = t + .replace(' ', "") + .eq_ignore_ascii_case("[remote\"origin\"]"); continue; } if in_origin { @@ -340,15 +534,24 @@ fn check_codex_mcp(content: Option<&str>) -> (bool, String) { { (true, "lian-jing 指向本地 MCP 服务".into()) } - Some(_) => (false, "未找到炼境的 Codex MCP 配置——调用 inject_mcp 修复".into()), - None => (false, ".codex/config.toml 缺失——调用 inject_mcp 后生成".into()), + Some(_) => ( + false, + "未找到炼境的 Codex MCP 配置——调用 inject_mcp 修复".into(), + ), + None => ( + false, + ".codex/config.toml 缺失——调用 inject_mcp 后生成".into(), + ), } } fn check_skill(content: Option<&str>) -> (bool, String) { match content { Some(content) if content.contains("name: lian-jing") => (true, "炼境项目技能已分发".into()), - _ => (false, "缺少 .agents/skills/lian-jing/SKILL.md——重跑接入包".into()), + _ => ( + false, + "缺少 .agents/skills/lian-jing/SKILL.md——重跑接入包".into(), + ), } } @@ -356,7 +559,10 @@ fn check_skill(content: Option<&str>) -> (bool, String) { 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},重跑接入包可升级")), + Some(v) => ( + false, + format!("管理块 v{v},最新模板 v{expected},重跑接入包可升级"), + ), None => (false, "文件缺失或无管理块标记".into()), } } @@ -373,7 +579,10 @@ fn check_origin(origin_host: Option<&str>, instance_hosts: &[String]) -> (bool, } Some(h) => ( false, - format!("origin={h},不在已登记 Gitea 实例({})中", instance_hosts.join(", ")), + format!( + "origin={h},不在已登记 Gitea 实例({})中", + instance_hosts.join(", ") + ), ), } } @@ -430,7 +639,7 @@ fn check_lefthook_keys(project_yml: Option<&str>, template_yml: &str) -> (bool, } } -/// 接入生效验收:七项检查链。分发成功 ≠ 功能生效,本函数验证"真的用起来了"。 +/// 接入生效验收:检查链覆盖项目身份、能力包和既有接入基线。分发成功 ≠ 功能生效。 /// conn 由调用方注入(R06)。 pub fn verify_onboarding( conn: &rusqlite::Connection, @@ -449,27 +658,44 @@ pub fn verify_onboarding( "git-workflow 管理块版本(含交付状态调用说明)", ), ] { - let expected = extract_managed_block(tmpl).and_then(block_version).unwrap_or("?"); + 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 }); + checks.push(VerifyCheck::from_pass(label, pass, detail)); } + checks.push(check_project_context( + read(".lian-jing/context.json").as_deref(), + read(".claude/liangjing.json").as_deref(), + project_id, + )); + checks.push(check_capability_packs( + read(".lian-jing/capability-packs.json").as_deref(), + )); + let (pass, detail) = check_skill(read(".agents/skills/lian-jing/SKILL.md").as_deref()); - checks.push(VerifyCheck { name: "Codex 炼境技能已分发".into(), pass, detail }); + checks.push(VerifyCheck::from_pass("Codex 炼境技能已分发", pass, detail)); let (pass, detail) = check_codex_mcp(read(".codex/config.toml").as_deref()); - checks.push(VerifyCheck { name: "Codex MCP 已配置".into(), pass, detail }); + checks.push(VerifyCheck::from_pass("Codex MCP 已配置", 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()), + 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 }); + checks.push(VerifyCheck::from_pass("lefthook hook 挂载", pass, detail)); // 4 lefthook 检查段齐全(期望键从内嵌模板自省,U1) let (pass, detail) = check_lefthook_keys(read("lefthook.yml").as_deref(), TMPL_LEFTHOOK); - checks.push(VerifyCheck { name: "lefthook 检查段齐全".into(), pass, detail }); + checks.push(VerifyCheck::from_pass("lefthook 检查段齐全", pass, detail)); // 5 origin 指向已登记 Gitea 实例 let origin_host = read(".git/config") @@ -488,7 +714,7 @@ pub fn verify_onboarding( } } let (pass, detail) = check_origin(origin_host.as_deref(), &instance_hosts); - checks.push(VerifyCheck { name: "origin 指向 Gitea".into(), pass, detail }); + checks.push(VerifyCheck::from_pass("origin 指向 Gitea", pass, detail)); // 6 CI workflow 存在 let yml_count = std::fs::read_dir(root.join(".gitea/workflows")) @@ -503,15 +729,15 @@ pub fn verify_onboarding( .count() }) .unwrap_or(0); - checks.push(VerifyCheck { - name: "CI workflow 存在".into(), - pass: yml_count > 0, - detail: if yml_count > 0 { + checks.push(VerifyCheck::from_pass( + "CI workflow 存在", + yml_count > 0, + if yml_count > 0 { format!(".gitea/workflows/ 有 {yml_count} 个 workflow") } else { "无 .gitea/workflows/*.yml——可调 distribute_cicd 分发".into() }, - }); + )); // 7 飞轮数据在流入(近 30 天 commit_metrics) let recent: i64 = conn @@ -521,15 +747,15 @@ pub fn verify_onboarding( |r| r.get(0), ) .unwrap_or(0); - checks.push(VerifyCheck { - name: "飞轮 commit 数据流入".into(), - pass: recent > 0, - detail: if recent > 0 { + checks.push(VerifyCheck::from_pass( + "飞轮 commit 数据流入", + recent > 0, + if recent > 0 { format!("近 30 天 {recent} 条 commit 已采集") } else { "近 30 天无数据——确认 buff 已挂且有 commit 活动(或调 poll_now)".into() }, - }); + )); checks } @@ -577,8 +803,8 @@ mod tests { #[test] fn writes_then_skips_never_overwrites() { - let base = std::env::temp_dir() - .join(format!("lianjing_onboarding_test_{}", std::process::id())); + let base = + std::env::temp_dir().join(format!("lianjing_onboarding_test_{}", std::process::id())); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&base).unwrap(); let pp = base.to_str().unwrap(); @@ -608,8 +834,8 @@ mod tests { #[test] fn versioned_dispatch_three_states() { - let base = std::env::temp_dir() - .join(format!("lianjing_onb_ver_test_{}", std::process::id())); + let base = + std::env::temp_dir().join(format!("lianjing_onb_ver_test_{}", std::process::id())); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&base).unwrap(); let pp = base.to_str().unwrap(); @@ -631,13 +857,13 @@ mod tests { .and_then(block_version) .expect("模板必须带版本标记"); let old = agents + .replace(&format!("version=\"{tpl_ver}\""), "version=\"1.0.0\"") .replace( - &format!("version=\"{tpl_ver}\""), - "version=\"1.0.0\"", - ) - .replace("## 项目概况", "## 项目概况 + "## 项目概况", + "## 项目概况 -我的项目说明") +我的项目说明", + ) + " ## 用户自加的章节 内容 @@ -651,14 +877,24 @@ mod tests { upgraded.contains(&format!("version=\"{tpl_ver}\"")), "块应升级到模板版本" ); - assert!(upgraded.contains("我的项目说明"), "块外用户内容(前)须保留"); - assert!(upgraded.contains("用户自加的章节"), "块外用户内容(后)须保留"); + assert!( + upgraded.contains("我的项目说明"), + "块外用户内容(前)须保留" + ); + assert!( + upgraded.contains("用户自加的章节"), + "块外用户内容(后)须保留" + ); assert_eq!(upgraded.matches(MANAGED_START).count(), 1, "只有一个管理块"); // 态三:存量文件无标记 → appended,原内容保留在上方 - std::fs::write(base.join("AGENTS.md"), "# 手写的 AGENTS + std::fs::write( + base.join("AGENTS.md"), + "# 手写的 AGENTS -自定义内容").unwrap(); +自定义内容", + ) + .unwrap(); let r4 = dispatch_templates(pp); let agents_r = r4.iter().find(|t| t.name == "AGENTS.md").unwrap(); assert_eq!(agents_r.action, "appended"); @@ -677,8 +913,14 @@ mod tests { #[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"); + 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] @@ -689,41 +931,109 @@ mod tests { Some("https://git.example.com/o/r.git"), "应取 origin 而非其他 remote" ); - assert_eq!(parse_origin_url("[core]\n\tbare = false"), None, "无 origin 应返回 None"); + 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 形式"); + 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("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"); + assert!( + !check_origin(Some("git.example.com"), &[]).0, + "无登记实例应 fail" + ); + } + + #[test] + fn project_context_and_pack_checks_report_actionable_states() { + let ready = check_project_context( + Some(r#"{"schema_version":1,"project_id":"p1"}"#), + None, + "p1", + ); + assert_eq!(ready.state, VerifyState::Ready); + assert!(ready.pass); + + let legacy = check_project_context(None, Some(r#"{"project_id":"p1"}"#), "p1"); + assert_eq!(legacy.state, VerifyState::Legacy); + assert!(!legacy.pass); + + let mismatch = check_project_context( + Some(r#"{"schema_version":0,"project_id":"p1"}"#), + None, + "p1", + ); + assert_eq!(mismatch.state, VerifyState::VersionMismatch); + + let missing = check_project_context(None, None, "p1"); + assert_eq!(missing.state, VerifyState::Missing); + + let unavailable = check_project_context(Some("not-json"), None, "p1"); + assert_eq!(unavailable.state, VerifyState::Unavailable); + + let pack_ready = check_capability_packs(Some(TMPL_CAPABILITY_PACKS)); + assert_eq!(pack_ready.state, VerifyState::Ready); + let pack_old = check_capability_packs(Some(r#"{"schema_version":0,"packs":[]}"#)); + assert_eq!(pack_old.state, VerifyState::VersionMismatch); } #[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 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(), 9, "检查链应为九项"); + assert_eq!(checks.len(), 11, "检查链应为十一项"); assert!(checks.iter().all(|c| !c.pass), "空项目应全部不通过"); - // 铺齐全部生效条件后:七项全通过 + // 铺齐全部生效条件后:所有检查全通过 dispatch_templates(base.to_str().unwrap()); + std::fs::create_dir_all(base.join(".lian-jing")).unwrap(); + std::fs::write( + base.join(".lian-jing/context.json"), + r#"{"schema_version":1,"project_id":"p1"}"#, + ) + .unwrap(); std::fs::create_dir_all(base.join(".codex")).unwrap(); - std::fs::write(base.join(".codex/config.toml"), "[mcp_servers.lian-jing]\nurl = \"http://127.0.0.1:27190/mcp\"\n").unwrap(); + std::fs::write( + base.join(".codex/config.toml"), + "[mcp_servers.lian-jing]\nurl = \"http://127.0.0.1:27190/mcp\"\n", + ) + .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/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", @@ -753,19 +1063,26 @@ mod tests { #[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 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!( + !pass && detail.contains("0.0.1"), + "旧版本应 fail 且报出版本号" + ); assert!(!check_managed_version(None, expected).0, "文件缺失应 fail"); - assert!(!check_managed_version(Some("手写内容,无标记"), expected).0, "无标记应 fail"); + assert!( + !check_managed_version(Some("手写内容,无标记"), expected).0, + "无标记应 fail" + ); } /// 回归:空路径登记曾把接入包写进炼境进程 cwd(src-tauri/,2026-07-15 批量实调实锤)。 @@ -775,8 +1092,7 @@ mod tests { assert!(err.contains("拒绝分发"), "空路径应拒绝: {err}"); let err = apply_onboarding_pack(" ".into(), "p".into()).unwrap_err(); assert!(err.contains("拒绝分发"), "空白路径应拒绝: {err}"); - let err = - apply_onboarding_pack("E:/不存在的目录/xyz-404".into(), "p".into()).unwrap_err(); + let err = apply_onboarding_pack("E:/不存在的目录/xyz-404".into(), "p".into()).unwrap_err(); assert!(err.contains("拒绝分发"), "不存在路径应拒绝: {err}"); } @@ -784,8 +1100,7 @@ mod tests { #[test] fn fully_managed_dispatch_three_states() { - let base = std::env::temp_dir() - .join(format!("lianjing_fm_test_{}", std::process::id())); + let base = std::env::temp_dir().join(format!("lianjing_fm_test_{}", std::process::id())); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&base).unwrap(); @@ -804,15 +1119,18 @@ mod tests { std::fs::write(base.join("envoy.md"), "v1 旧内容").unwrap(); let r = dispatch_fully_managed(&base, "envoy.md", "v2 内容"); assert_eq!(r.action, "updated"); - assert_eq!(std::fs::read_to_string(base.join("envoy.md")).unwrap(), "v2 内容"); + assert_eq!( + std::fs::read_to_string(base.join("envoy.md")).unwrap(), + "v2 内容" + ); let _ = std::fs::remove_dir_all(&base); } #[test] fn dispatch_templates_upgrades_fully_managed_files() { - let base = std::env::temp_dir() - .join(format!("lianjing_fm_e2e_test_{}", std::process::id())); + let base = + std::env::temp_dir().join(format!("lianjing_fm_e2e_test_{}", std::process::id())); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&base).unwrap(); let pp = base.to_str().unwrap(); @@ -821,9 +1139,15 @@ mod tests { // 模拟旧版本 envoy:内容与当前模板不同 → 重跑接入包应覆盖为最新 std::fs::write(base.join(".claude/commands/envoy.md"), "旧版使者指令").unwrap(); let r2 = dispatch_templates(pp); - let envoy = r2.iter().find(|t| t.name == ".claude/commands/envoy.md").unwrap(); + let envoy = r2 + .iter() + .find(|t| t.name == ".claude/commands/envoy.md") + .unwrap(); assert_eq!(envoy.action, "updated", "全托管文件应被升级,而非跳过"); - let gate = r2.iter().find(|t| t.name == ".claude/hooks/blueprint-gate.cjs").unwrap(); + let gate = r2 + .iter() + .find(|t| t.name == ".claude/hooks/blueprint-gate.cjs") + .unwrap(); assert_eq!(gate.action, "skipped", "未动过的全托管文件重跑应 skipped"); let _ = std::fs::remove_dir_all(&base); @@ -833,10 +1157,16 @@ mod tests { fn lefthook_keys_extraction_and_check() { // 从内嵌模板自省:至少含 typecheck 与 commit-msg 侧检查段 let tpl_keys = lefthook_command_keys(TMPL_LEFTHOOK); - assert!(tpl_keys.contains(&"typecheck".to_string()), "模板应含 typecheck: {tpl_keys:?}"); + assert!( + tpl_keys.contains(&"typecheck".to_string()), + "模板应含 typecheck: {tpl_keys:?}" + ); assert!(tpl_keys.len() >= 3, "模板检查段应不止一个: {tpl_keys:?}"); // 注释掉的示例段不提取 - assert!(!tpl_keys.contains(&"api-typecheck".to_string()), "注释行不算键"); + assert!( + !tpl_keys.contains(&"api-typecheck".to_string()), + "注释行不算键" + ); // 项目文件与模板一致 → pass let (pass, _) = check_lefthook_keys(Some(TMPL_LEFTHOOK), TMPL_LEFTHOOK); @@ -847,7 +1177,10 @@ mod tests { let (pass, detail) = check_lefthook_keys(Some(partial), TMPL_LEFTHOOK); assert!(!pass); assert!(detail.contains("缺少检查段"), "应列出缺失键: {detail}"); - assert!(!detail.contains("typecheck,") && detail.contains("blueprint-gate"), "已有键不在缺失列表: {detail}"); + assert!( + !detail.contains("typecheck,") && detail.contains("blueprint-gate"), + "已有键不在缺失列表: {detail}" + ); // 文件缺失 → fail assert!(!check_lefthook_keys(None, TMPL_LEFTHOOK).0); @@ -910,4 +1243,3 @@ mod tests { assert_eq!(silent.len(), 2, "pw-b 的回传过期后也应列为沉默"); } } - diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index 6adc304..f1d1705 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -262,7 +262,7 @@ pub fn tools_list_result() -> Value { }, { "name": "verify_onboarding", - "description": "接入生效验收:检查接入包在项目里是否真正生效(含 get_delivery_status 调用说明的模板管理块版本、lefthook hook 真挂在 .git/hooks、检查段对齐模板、origin 指向已登记 Gitea、CI workflow 存在、近 30 天飞轮 commit 数据流入)。分发成功 ≠ 功能生效,apply_onboarding_pack 之后应调用本工具做消费者角色验收。", + "description": "接入生效验收:检查接入包在项目里是否真正生效(含工具无关项目上下文、基础能力包版本、Codex MCP/skill、模板管理块、lefthook、origin、CI 与飞轮数据)。每项返回 ready / missing / legacy / version_mismatch / unavailable 的真实状态和升级提示;分发成功 ≠ 功能生效,apply_onboarding_pack 或 inject_mcp 之后应调用本工具验收。", "inputSchema": { "type": "object", "properties": {