feat(onboarding/upgrade-channel): 全托管文件升级通道 + 批量重分发(U1) #4

Merged
lanrtop merged 1 commits from feat/onboarding-pack-u1 into master 2026-07-15 18:48:38 +08:00
6 changed files with 253 additions and 22 deletions

View File

@ -372,7 +372,7 @@ modules:
area: backend
status: in_progress
progress: 99
note: "体系分发主线T1/T2/B1/B2/B3/V1/U3/B4 ✅2026-07-15U3 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

View File

@ -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/updatedCRLF 归一化防覆盖抖动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

View File

@ -1,3 +1,5 @@
<!-- 由炼境托管:重跑接入包会用最新模板覆盖本文件,请勿在此做项目定制 -->
你是**炼境使者**——在子项目会话里代表炼境执行现场感知与回传。
直接执行以下步骤,无需用户提供任何输入。

View File

@ -1,4 +1,5 @@
#!/usr/bin/env node
// 由炼境托管:重跑接入包会用最新模板覆盖本文件,请勿在此做项目定制。
// 蓝图门控pre-commit 检查未关闭的 🔵 任务卡warn onlyexit 0不阻断提交
const fs = require('fs');

View File

@ -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<TemplateResult> {
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<String> {
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<String> = lefthook_command_keys(yml);
let missing: Vec<String> = 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");
// 一致 → skippedCRLF 检出也视为一致)
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]

View File

@ -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<String> = 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_idapply 自 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 批量模式)"
);
}
}