diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index 94f9748..8e7e445 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -372,7 +372,7 @@ modules: area: backend status: in_progress progress: 99 - note: "体系分发主线:T1/T2/B1/B2/B3/V1/U3/B4 ✅(2026-07-15:U3 Rules-Applied ingest 层校验+回溯清洗;B4 沉默项目回传覆盖率诊断);剩 📋 U1(全托管升级通道+批量重分发)" + note: "体系分发主线全卡完成:…/V1/U3/B4/U1 ✅(2026-07-15 择升环三断点修复收官:U3 规则校验 / B4 沉默项目信号 / U1 全托管升级+批量重分发)。U1 批量模式 MCP 实调待重建安装后终验,通过后模块收官 done" position: [3500, 0] updated: 2026-07-15 diff --git a/.blueprint/modules/onboarding-pack.md b/.blueprint/modules/onboarding-pack.md index 6d2382f..6fd014e 100644 --- a/.blueprint/modules/onboarding-pack.md +++ b/.blueprint/modules/onboarding-pack.md @@ -134,8 +134,9 @@ - 版本化闭环要求(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,后置) 健康诊断加体系遵守度检查。移植体验=零新增动作,接入流程不变。 -### 🔵 U1. 全托管文件升级通道 + 批量重分发(择升环断点修复 ①) -- status: planned +### ✅ U1. 全托管文件升级通道 + 批量重分发(择升环断点修复 ①) +- status: done +- done_note: 2026-07-15 完成(分支 feat/onboarding-pack-u1 → PR)。dispatch_fully_managed 三态(written/skipped/updated,CRLF 归一化防覆盖抖动),envoy.md/blueprint-gate.cjs 切换全托管并加「由炼境托管」声明头;verify_onboarding 新增第 7 项「lefthook 检查段齐全」——期望键集合从内嵌 TMPL_LEFTHOOK 自省提取(lefthook_command_keys 纯函数,注释行不算键),零硬编码;MCP apply_onboarding_pack 支持批量模式(project_id 字段缺失→全部已登记项目,空串仍按无效单项目报错),schema required 放宽为空并同步测试断言(契约放宽属验收要求,已声明)。cargo test 102 全过。**遗留待终验**:批量模式 MCP 实调需重建安装炼境后执行(模板在二进制资源里,同 V1 先例);batch_apply.py/scan_onboarded.py 暂保留(用户保留意见),实调终验通过后再清理 - complexity: M - depends: onboarding-pack(卡 T1) - files: src-tauri/src/commands/onboarding.rs, src-tauri/src/mcp_tools.rs, src-tauri/resources/onboarding/.claude/commands/envoy.md, src-tauri/resources/onboarding/.claude/hooks/blueprint-gate.cjs diff --git a/src-tauri/resources/onboarding/.claude/commands/envoy.md b/src-tauri/resources/onboarding/.claude/commands/envoy.md index b711334..1230a6f 100644 --- a/src-tauri/resources/onboarding/.claude/commands/envoy.md +++ b/src-tauri/resources/onboarding/.claude/commands/envoy.md @@ -1,3 +1,5 @@ + + 你是**炼境使者**——在子项目会话里代表炼境执行现场感知与回传。 直接执行以下步骤,无需用户提供任何输入。 diff --git a/src-tauri/resources/onboarding/.claude/hooks/blueprint-gate.cjs b/src-tauri/resources/onboarding/.claude/hooks/blueprint-gate.cjs index cf379f7..a31e24c 100644 --- a/src-tauri/resources/onboarding/.claude/hooks/blueprint-gate.cjs +++ b/src-tauri/resources/onboarding/.claude/hooks/blueprint-gate.cjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +// 由炼境托管:重跑接入包会用最新模板覆盖本文件,请勿在此做项目定制。 // 蓝图门控:pre-commit 检查未关闭的 🔵 任务卡,warn only(exit 0,不阻断提交)。 const fs = require('fs'); diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index e4221b7..db92a32 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -62,6 +62,38 @@ fn dispatch_one(root: &Path, relative: &str, content: &str) -> TemplateResult { } } +/// 全托管文件分发(三档升级语义之②,U1):炼境独占管理、用户不定制(文件头有托管声明)。 +/// 不存在→written;与内嵌模板一致→skipped;不一致→覆盖 updated。 +/// 比较前归一化行尾(子项目 git autocrlf 可能把 LF 检出为 CRLF,避免永久 updated 抖动)。 +fn dispatch_fully_managed(root: &Path, relative: &str, content: &str) -> TemplateResult { + let target = root.join(relative); + if !target.exists() { + return dispatch_one(root, relative, content); + } + let normalize = |s: &str| s.replace("\r\n", "\n"); + if let Ok(existing) = std::fs::read_to_string(&target) { + if normalize(&existing) == normalize(content) { + return TemplateResult { + name: relative.to_string(), + action: "skipped".into(), + detail: "内容已是最新模板".into(), + }; + } + } + match std::fs::write(&target, content) { + Ok(_) => TemplateResult { + name: relative.to_string(), + action: "updated".into(), + detail: "全托管文件已覆盖为最新模板".into(), + }, + Err(e) => TemplateResult { + name: relative.to_string(), + action: "failed".into(), + detail: format!("覆盖失败: {e}"), + }, + } +} + /// 从模板中提取 ONBOARDING_MANAGED 块(含首尾标记行)。 fn extract_managed_block(template: &str) -> Option<&str> { let start = template.find(MANAGED_START)?; @@ -147,8 +179,8 @@ pub fn dispatch_templates(project_path: &str) -> Vec { dispatch_versioned(root, "AGENTS.md", TMPL_AGENTS), dispatch_one(root, "lefthook.yml", TMPL_LEFTHOOK), dispatch_versioned(root, "docs/ai-context/git-workflow.md", TMPL_GIT_WORKFLOW), - dispatch_one(root, ".claude/commands/envoy.md", TMPL_ENVOY), - dispatch_one(root, ".claude/hooks/blueprint-gate.cjs", TMPL_BLUEPRINT_GATE), + dispatch_fully_managed(root, ".claude/commands/envoy.md", TMPL_ENVOY), + dispatch_fully_managed(root, ".claude/hooks/blueprint-gate.cjs", TMPL_BLUEPRINT_GATE), ] } @@ -317,7 +349,59 @@ fn check_origin(origin_host: Option<&str>, instance_hosts: &[String]) -> (bool, } } -/// 接入生效验收:五项检查链。分发成功 ≠ 功能生效,本函数验证"真的用起来了"。 +/// 从 lefthook.yml 内容提取 commands 键集合(` commands:` 下缩进 4 空格的键行)。 +/// 纯函数;注释与更深缩进(run: | 多行体等)忽略。用于 U1 检查项自省—— +/// 期望键集合取自内嵌模板,禁止硬编码具体检查项名(模板演进校验器零维护)。 +pub fn lefthook_command_keys(yml: &str) -> Vec { + let mut keys = Vec::new(); + let mut in_commands = false; + for line in yml.lines() { + let trimmed = line.trim_start(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + let indent = line.len() - trimmed.len(); + if indent == 2 && trimmed.trim_end() == "commands:" { + in_commands = true; + continue; + } + if indent < 4 { + in_commands = false; + continue; + } + if in_commands && indent == 4 { + if let Some(key) = trimmed.trim_end().strip_suffix(':') { + keys.push(key.to_string()); + } + } + } + keys +} + +/// U1 检查:项目 lefthook.yml 是否缺少模板要求的检查段(lefthook 不自动覆盖,只检测落后)。 +fn check_lefthook_keys(project_yml: Option<&str>, template_yml: &str) -> (bool, String) { + let Some(yml) = project_yml else { + return (false, "lefthook.yml 缺失——重跑接入包写入".to_string()); + }; + let have: Vec = lefthook_command_keys(yml); + let missing: Vec = lefthook_command_keys(template_yml) + .into_iter() + .filter(|k| !have.contains(k)) + .collect(); + if missing.is_empty() { + (true, "模板要求的检查段齐全".to_string()) + } else { + ( + false, + format!( + "缺少检查段: {}——lefthook.yml 不自动覆盖,请手动对照模板补齐", + missing.join(", ") + ), + ) + } +} + +/// 接入生效验收:七项检查链。分发成功 ≠ 功能生效,本函数验证"真的用起来了"。 /// conn 由调用方注入(R06)。 pub fn verify_onboarding( conn: &rusqlite::Connection, @@ -345,7 +429,11 @@ pub fn verify_onboarding( }; checks.push(VerifyCheck { name: "lefthook hook 挂载".into(), pass, detail }); - // 4 origin 指向已登记 Gitea 实例 + // 4 lefthook 检查段齐全(期望键从内嵌模板自省,U1) + let (pass, detail) = check_lefthook_keys(read("lefthook.yml").as_deref(), TMPL_LEFTHOOK); + checks.push(VerifyCheck { name: "lefthook 检查段齐全".into(), pass, detail }); + + // 5 origin 指向已登记 Gitea 实例 let origin_host = read(".git/config") .as_deref() .and_then(parse_origin_url) @@ -364,7 +452,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 }); - // 5 CI workflow 存在 + // 6 CI workflow 存在 let yml_count = std::fs::read_dir(root.join(".gitea/workflows")) .map(|rd| { rd.flatten() @@ -387,7 +475,7 @@ pub fn verify_onboarding( }, }); - // 6 飞轮数据在流入(近 30 天 commit_metrics) + // 7 飞轮数据在流入(近 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')", @@ -587,12 +675,12 @@ mod tests { 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_eq!(checks.len(), 7, "检查链应为七项"); 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(); @@ -640,6 +728,79 @@ mod tests { assert!(!check_managed_version(Some("手写内容,无标记"), expected).0, "无标记应 fail"); } + // ── U1:全托管文件三态 + lefthook 检查段自省 ───────────────────────────── + + #[test] + fn fully_managed_dispatch_three_states() { + 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(); + + // 不存在 → written + let r = dispatch_fully_managed(&base, "envoy.md", "v2 内容"); + assert_eq!(r.action, "written"); + + // 一致 → skipped(CRLF 检出也视为一致) + let r = dispatch_fully_managed(&base, "envoy.md", "v2 内容"); + assert_eq!(r.action, "skipped"); + std::fs::write(base.join("envoy.md"), "v2 内容".replace('\n', "\r\n")).unwrap(); + let r = dispatch_fully_managed(&base, "envoy.md", "v2 内容"); + assert_eq!(r.action, "skipped", "仅行尾差异不应触发覆盖"); + + // 不一致(旧版模板/被改动)→ 覆盖 updated + 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 内容"); + + 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 _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + let pp = base.to_str().unwrap(); + + dispatch_templates(pp); + // 模拟旧版本 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(); + assert_eq!(envoy.action, "updated", "全托管文件应被升级,而非跳过"); + 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); + } + + #[test] + 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.len() >= 3, "模板检查段应不止一个: {tpl_keys:?}"); + // 注释掉的示例段不提取 + assert!(!tpl_keys.contains(&"api-typecheck".to_string()), "注释行不算键"); + + // 项目文件与模板一致 → pass + let (pass, _) = check_lefthook_keys(Some(TMPL_LEFTHOOK), TMPL_LEFTHOOK); + assert!(pass); + + // 缺段 → fail 且列出缺失键 + let partial = "pre-commit:\n commands:\n typecheck:\n run: pnpm typecheck\n"; + 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}"); + + // 文件缺失 → fail + assert!(!check_lefthook_keys(None, TMPL_LEFTHOOK).0); + } + // ── B4:使者回传覆盖率(沉默项目信号)──────────────────────────────────── #[test] diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index 8f16191..a02ea9c 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -235,18 +235,18 @@ pub fn tools_list_result() -> Value { }, { "name": "apply_onboarding_pack", - "description": "向项目分发标准接入包:写入 AGENTS.md / lefthook / git-workflow 模板(已存在则原位升级 ONBOARDING_MANAGED 块,绝不覆盖块外用户内容),并挂载 buff 自动监听 git 提交。等价于炼境 UI「接入包」区域的「应用接入包」按钮。模板版本升级后对存量项目重新调用即可推送新版本。", + "description": "向项目分发标准接入包:写入 AGENTS.md / lefthook / git-workflow / envoy / blueprint-gate 模板(版本化块原位升级、全托管文件内容不一致即覆盖、lefthook 存在即跳过,块外用户内容绝不触碰),并挂载 buff 自动监听 git 提交。等价于炼境 UI「接入包」区域的「应用接入包」按钮。模板升级后对存量项目重新调用即可推送新版本;不传 project_id 则对所有已登记项目批量执行。", "inputSchema": { "type": "object", "properties": { - "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" } + "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects)。省略则批量分发到所有已登记项目" } }, - "required": ["project_id"] + "required": [] } }, { "name": "verify_onboarding", - "description": "接入生效验收:检查接入包在项目里是否真正生效(模板管理块版本到位 / lefthook hook 真挂在 .git/hooks / origin 指向已登记 Gitea / CI workflow 存在 / 近 30 天飞轮 commit 数据流入)。分发成功 ≠ 功能生效,apply_onboarding_pack 之后应调用本工具做消费者角色验收。", + "description": "接入生效验收:检查接入包在项目里是否真正生效(模板管理块版本到位 / lefthook hook 真挂在 .git/hooks / lefthook 检查段对齐模板 / origin 指向已登记 Gitea / CI workflow 存在 / 近 30 天飞轮 commit 数据流入)。分发成功 ≠ 功能生效,apply_onboarding_pack 之后应调用本工具做消费者角色验收。", "inputSchema": { "type": "object", "properties": { @@ -607,6 +607,63 @@ pub async fn tools_call(params: Option<&Value>) -> Value { let private = args.get("private").and_then(|v| v.as_bool()).unwrap_or(false); crate::commands::gitea::gitea_api_setup_repo(pid, repo_name, private) } + // U1 批量模式:project_id 字段缺失(而非空串——空串按无效单项目报错)→ 全部已登记项目 + "apply_onboarding_pack" if args.get("project_id").and_then(|v| v.as_str()).is_none() => { + // 复用 ingest_git_history 先例;替代曾经的 batch_apply.py 临时脚本 + let conn = db::pool().get().map_err(|e| e.to_string())?; + let targets: Vec<(String, String)> = { + let mut stmt = conn + .prepare("SELECT id, win_path FROM project_workspaces WHERE win_path IS NOT NULL") + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map([], |r| Ok((r.get(0)?, r.get(1)?))) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .collect(); + rows + }; + drop(conn); + if targets.is_empty() { + Err("无已登记的 Windows 路径项目可分发".to_string()) + } else { + let mut lines = Vec::new(); + let (mut ok, mut failed) = (0u32, 0u32); + for (id, path) in &targets { + match crate::commands::onboarding::apply_onboarding_pack( + path.clone(), + id.clone(), + ) { + Ok(report) => { + ok += 1; + let summary: Vec = report + .templates + .iter() + .map(|t| { + let short = t.name.rsplit('/').next().unwrap_or(&t.name); + format!("{short}={}", t.action) + }) + .collect(); + lines.push(format!( + "- {}: {} | hook={}", + id, + summary.join(", "), + report.hook.status + )); + } + Err(e) => { + failed += 1; + lines.push(format!("- {}: ❌ {}", id, e)); + } + } + } + Ok(format!( + "批量接入包分发完成:{} 成功,{} 失败\n\n{}\n\n建议对关键项目调用 verify_onboarding 抽查生效。", + ok, + failed, + lines.join("\n") + )) + } + } "apply_onboarding_pack" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); resolve_project_root(pid).and_then(|root| { @@ -1238,11 +1295,20 @@ mod tests { fn apply_onboarding_pack_schema_requires_project_id() { let result = tools_list_result(); let tools = result["tools"].as_array().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 fields: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect(); - assert!(fields.contains(&"project_id"), "{name} 应必填 project_id"); - } + // verify 必填 project_id;apply 自 U1 起可省略(批量模式) + let verify = tools.iter().find(|t| t["name"] == "verify_onboarding").unwrap(); + let fields: Vec<&str> = verify["inputSchema"]["required"] + .as_array() + .unwrap() + .iter() + .filter_map(|v| v.as_str()) + .collect(); + assert!(fields.contains(&"project_id"), "verify_onboarding 应必填 project_id"); + + let apply = tools.iter().find(|t| t["name"] == "apply_onboarding_pack").unwrap(); + assert!( + apply["inputSchema"]["required"].as_array().unwrap().is_empty(), + "apply_onboarding_pack 的 project_id 应可省略(U1 批量模式)" + ); } }