feat(onboarding/parity): 接入包改写托管块前检测块外未提交改动,给出混合文件预警(P10)
All checks were successful
Push & PR Check / check (push) Successful in 1m2s
Push & PR Check / check (pull_request) Successful in 59s

2026-10-01 enterprise 的 git-workflow.md 在有项目未提交改写时被升级了托管块,
同一文件混有炼境与项目两种来源的改动,按「禁止字符串手术」无法拆分提交,只能交人定案。
管家应在制造混合文件时明确告知,而不是事后由人发现。

- TemplateResult 新增可选 warning(序列化时缺省省略,前端类型同步)
- apply_onboarding_pack 在模板分发与 buff 蓝图规则同步前后夹一层检测:写入前对
  AGENTS.md、docs/ai-context/git-workflow.md、CLAUDE.md 跑 git status,有改动再取
  HEAD:./<文件>(不在 HEAD 中按空内容算),两边去掉 ONBOARDING_MANAGED 与
  BLUEPRINT_MANAGED 块后比较(忽略行尾与空行);写入后只对本次确被改写的文件预警,
  文案含文件名与「块外有未提交改动,升级后将成为混合文件」
- 只提示不阻断,块外内容照旧不动;git 不可用、非仓库或命令失败一律静默;
  git status 带 --no-optional-locks,不抢 index.lock
- CLAUDE.md 只由蓝图规则同步改写,不在模板结果里,预警时补一条结果
- BLUEPRINT_MANAGED 标记提为 blueprint.rs 的 pub(crate) 常量,检测与 upsert 共用
- MCP 单项目输出在该文件行下加预警行,批量输出追加混合文件清单;工具描述同步;
  炼境 UI 接入包结果行下显示预警
- 契约文档 project-context.yaml 同步;蓝图 P10 标 ✅ 并记录验证与已知限制

验证:cargo test --locked lib 270 passed / 5 ignored;新增 3 个测试(纯函数、临时 git
仓库三种情形、非 git 目录),变异自检(探测强制返回 true 时两个场景测试均失败);
pnpm typecheck 与 pnpm test 通过。

Rules-Applied: R01,R06,R12
Feature-Confirmed: true

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lanrtop 2026-10-03 03:58:28 +09:00
parent dd2d7ca369
commit 13aa3a1a8c
8 changed files with 452 additions and 93 deletions

View File

@ -511,9 +511,9 @@ modules:
name: 三端客户端对等 name: 三端客户端对等
area: infra area: infra
status: in_progress status: in_progress
progress: 50 progress: 55
updated: 2026-10-02 updated: 2026-10-03
note: "P1/P2/P3/P3b/P9 完成:三端(Claude Code、Codex 含 Desktop、DSH 含桌面 GUI)在 Enterprise 经 %USERPROFILE%\\.lian-jing 稳定入口连项目专用地址,人终验通过。Claude 条目经 claude CLI 写入、以 claude mcp get 为准;DSH 由托管块全局行(headless)+ 会话级插件(GUI/web/acp 按会话工作目录)路由;接入写入幂等。待做 P4 探针固化(依赖 architecture-evolution A1)、P5–P8、P10。" note: "P1/P2/P3/P3b/P9/P10 完成:三端(Claude Code、Codex 含 Desktop、DSH 含桌面 GUI)在 Enterprise 经 %USERPROFILE%\\.lian-jing 稳定入口连项目专用地址,人终验通过。Claude 条目经 claude CLI 写入、以 claude mcp get 为准;DSH 由托管块全局行(headless)+ 会话级插件(GUI/web/acp 按会话工作目录)路由;接入写入幂等;接入包改写块外带未提交改动的托管块文件时给出混合文件预警。待做 P4 探针固化(依赖 architecture-evolution A1)、P5–P8。"
position: [3500, 950] position: [3500, 950]
- id: architecture-evolution - id: architecture-evolution

View File

@ -172,10 +172,13 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。
- verification: "2026-10-02:新增 lib.rs write_if_content_changed(忽略行尾比较,内容不变不写);sync_blueprint_rules 的 CONVENTIONS 与托管块、context.json、.claude/settings.json、Codex 托管块统一改用它,托管块未变时返回 unchanged。cargo test 246 passed。实测 enterprise:注入后 .codex/config.toml 末尾空行 16→0;再次注入四个文件修改时间均不变。已存在的伪改动(context.json、CONVENTIONS.md)需在子项目里由人刷新一次索引。 - verification: "2026-10-02:新增 lib.rs write_if_content_changed(忽略行尾比较,内容不变不写);sync_blueprint_rules 的 CONVENTIONS 与托管块、context.json、.claude/settings.json、Codex 托管块统一改用它,托管块未变时返回 unchanged。cargo test 246 passed。实测 enterprise:注入后 .codex/config.toml 末尾空行 16→0;再次注入四个文件修改时间均不变。已存在的伪改动(context.json、CONVENTIONS.md)需在子项目里由人刷新一次索引。
对抗审查(9 个 agent,2 条成立、5 条驳回)后补:JSON 写入(settings.json、context.json)改为按语义比较——Claude Code 改写后键序与末尾换行不同,按文本比较仍会整文件重排;CI workflow 的 plan 改为忽略行尾判 noop(原按字节比较,CRLF 检出的同内容文件会被判 update 并请用户确认一次无变化写入),apply 与 generate_workflow 落盘同样只在内容变化时写;同类遗漏 agent_infra 生成文件与 notes-archive 回填也统一改用 write_if_content_changed。cargo test 245 passed(新增 JSON 语义与 CI CRLF 回归断言)。" 对抗审查(9 个 agent,2 条成立、5 条驳回)后补:JSON 写入(settings.json、context.json)改为按语义比较——Claude Code 改写后键序与末尾换行不同,按文本比较仍会整文件重排;CI workflow 的 plan 改为忽略行尾判 noop(原按字节比较,CRLF 检出的同内容文件会被判 update 并请用户确认一次无变化写入),apply 与 generate_workflow 落盘同样只在内容变化时写;同类遗漏 agent_infra 生成文件与 notes-archive 回填也统一改用 write_if_content_changed。cargo test 245 passed(新增 JSON 语义与 CI CRLF 回归断言)。"
### 📋 P10 — 接入包升级前检测目标文件的块外未提交改动 ### ✅ P10 — 接入包升级前检测目标文件的块外未提交改动
- status: todo - status: done
- complexity: S - complexity: S
- files: src-tauri/src/commands/onboarding.rs(apply 预览 / 结果) - files: src-tauri/src/commands/onboarding.rs(apply 预览 / 结果), src-tauri/src/commands/blueprint.rs(BLUEPRINT_MANAGED 标记常量), src-tauri/src/mcp_tools.rs(MCP 输出), src/lib/commands.ts, src/components/dashboard/BlueprintModal.tsx, docs/ai-context/project-context.yaml
- depends: P8 - depends: P8
- acceptance: "agent_verify: [cargo test] 目标文件在 git 中有托管块之外的未提交改动时,apply_onboarding_pack 的预览与结果对该文件给出 warning(含文件名与「块外有未提交改动,升级后将成为混合文件」提示),默认仍只改托管块、不改块外内容;无未提交改动或非 git 仓库时无此提示。" - acceptance: "agent_verify: [cargo test] 目标文件在 git 中有托管块之外的未提交改动时,apply_onboarding_pack 的预览与结果对该文件给出 warning(含文件名与「块外有未提交改动,升级后将成为混合文件」提示),默认仍只改托管块、不改块外内容;无未提交改动或非 git 仓库时无此提示。"
- 为什么:2026-10-01 enterprise 的 docs/ai-context/git-workflow.md 在有项目未提交改写的情况下被升级了托管块(1.6.0→1.7.0),产生「同一文件混有炼境与项目两种来源改动」的混合文件。按「禁止字符串手术」铁律它无法拆分提交,只能交人定案,verify_onboarding 也因此持续报版本不符。管家应在制造混合文件之前提示,而不是事后由人收拾。 - 为什么:2026-10-01 enterprise 的 docs/ai-context/git-workflow.md 在有项目未提交改写的情况下被升级了托管块(1.6.0→1.7.0),产生「同一文件混有炼境与项目两种来源改动」的混合文件。按「禁止字符串手术」铁律它无法拆分提交,只能交人定案,verify_onboarding 也因此持续报版本不符。管家应在制造混合文件之前提示,而不是事后由人收拾。
- 实现(2026-10-03,分支 feat/agent-client-parity-p10):TemplateResult 新增可选 warning。apply_onboarding_pack 在模板分发与 buff 蓝图规则同步前后夹一层检测:写入前对 AGENTS.md、docs/ai-context/git-workflow.md、CLAUDE.md 跑 `git --no-optional-locks status --porcelain -uall -- <文件>`,有改动再取 `git show HEAD:./<文件>`(不在 HEAD 中按空内容算,即未跟踪/仅暂存/尚无提交),两边去掉 ONBOARDING_MANAGED 与 BLUEPRINT_MANAGED 块后比较(忽略行尾、行末空白与空行);写入后只对本次确被改写的文件给出 warning(含文件名与「块外有未提交改动,升级后将成为混合文件」),CLAUDE.md 不在模板结果里,预警时补一条结果。git 不可用、非仓库或命令失败一律静默。只提示不阻断,块外内容照旧不动。MCP 单项目输出在该文件行下加 ⚠️ 行,批量输出追加「混合文件」清单;炼境 UI 接入包结果行下显示琥珀色预警。
- verification: "2026-10-03:新增 3 个测试——outside_managed_content_strips_blocks_and_ignores_eol_and_blank_lines(纯函数:剔除两种块、忽略 CRLF 与空行、不在 HEAD 时有块外内容才算、残缺块按块外处理);mixed_file_warning_only_for_uncommitted_changes_outside_managed_blocks(临时 git 仓库,走与 apply 相同的检测夹层 + 真实 sync_blueprint_rules:git-workflow.md 块外未提交改写 → updated 且带预警、块外改写原样保留、托管块升到模板版本;未跟踪 CLAUDE.md 含项目内容 → 补 appended 结果并预警;与 HEAD 一致的 AGENTS.md 照常升级无预警;全部提交后只改托管块版本并整体转 CRLF → 升级且全部无预警、不补 CLAUDE.md 行);mixed_file_warning_is_silent_outside_git(非 git 目录升级无预警,--nocapture 确认未走跳过分支)。变异自检:把探测函数强制返回 true,后两个测试均失败,已还原。cargo test --locked:lib 270 passed / 5 ignored,0 failed;pnpm typecheck 通过;pnpm test 16 passed。"
- known_limits: "仓库里没有 apply_onboarding_pack 的预览命令(acceptance 写的「预览」无处可挂),预警随结果返回:检测在写入前完成,但升级照常进行,调用方拿到结果时混合文件已经产生——真正「先提示再决定」要等 P8 的只报告 diff 模式复用本检测。从未提交过的炼境新建文件(块外整段是模板 TODO)在模板升版后重跑也会预警(不在 HEAD 中、无法区分块外内容来源),属安全方向的误报。buff 之外单独调用 sync_blueprint_rules(UI 同步蓝图规则、批量同步)不经此检测。仅比较 HEAD 与工作区:暂存区与工作区不同的中间状态不单独判断。"

View File

@ -26,7 +26,7 @@ contracts:
- ".lian-jing/context.json" - ".lian-jing/context.json"
- ".lian-jing/capability-packs.json" - ".lian-jing/capability-packs.json"
- ".claude/liangjing.json" - ".claude/liangjing.json"
doc: "`.lian-jing/context.json` 是 project_id 的工具无关单一事实来源;`.claude/liangjing.json` 仅为兼容投影;`.lian-jing/capability-packs.json` 是炼境分发基线的版本描述。lian-jing skill 以同一模板双投影:`.agents/skills/lian-jing/`(Codex、DSH 读取)与 `.claude/skills/lian-jing/`(Claude Code 不读 .agents/skills),两份内容必须一致。context.json 可选字段 mcp_scope=global 声明控制面项目(如炼境自身):Claude 不写 local 条目、DSH 不入白名单,保留全局地址与跨项目工具。迁移必须保留未知字段;验证必须返回显式状态与 upgrade_hint;升级仅覆盖炼境托管文件,绝不覆盖项目定制或手工 MCP 配置,且不得以客户端全局配置或项目 UI 身份替代项目根目录上下文。" doc: "`.lian-jing/context.json` 是 project_id 的工具无关单一事实来源;`.claude/liangjing.json` 仅为兼容投影;`.lian-jing/capability-packs.json` 是炼境分发基线的版本描述。lian-jing skill 以同一模板双投影:`.agents/skills/lian-jing/`(Codex、DSH 读取)与 `.claude/skills/lian-jing/`(Claude Code 不读 .agents/skills),两份内容必须一致。context.json 可选字段 mcp_scope=global 声明控制面项目(如炼境自身):Claude 不写 local 条目、DSH 不入白名单,保留全局地址与跨项目工具。迁移必须保留未知字段;验证必须返回显式状态与 upgrade_hint;升级仅覆盖炼境托管文件,绝不覆盖项目定制或手工 MCP 配置,且不得以客户端全局配置或项目 UI 身份替代项目根目录上下文。apply_onboarding_pack 的模板结果 TemplateResult 可带 warning(混合文件预警):写入前 AGENTS.md、docs/ai-context/git-workflow.md 或 CLAUDE.md 在 git 中已有托管块(ONBOARDING_MANAGED / BLUEPRINT_MANAGED)之外的未提交改动(未跟踪文件带块外内容也算;比较忽略行尾与空行),且本次确实改写了该文件时给出;只提示不阻断、块外内容不动,非 git 仓库或 git 不可用时静默。CLAUDE.md 只由蓝图规则同步改写,仅在预警时补一条结果。"
- label: Tauri command 接口(前后端契约) - label: Tauri command 接口(前后端契约)
paths: paths:

View File

@ -1262,6 +1262,11 @@ pub struct SyncResult {
pub message: String, pub message: String,
} }
/// BLUEPRINT_MANAGED 块的标记(开始标记后紧跟 ` version="x.y.z" -->`)。
/// 接入包的混合文件预警(onboarding.rs)比较块外内容时也按它剔除本块。
pub(crate) const BLUEPRINT_MANAGED_START: &str = "<!-- BLUEPRINT_MANAGED_START";
pub(crate) const BLUEPRINT_MANAGED_END: &str = "<!-- BLUEPRINT_MANAGED_END -->";
/// 在指定文件中写入/替换 BLUEPRINT_MANAGED 块: /// 在指定文件中写入/替换 BLUEPRINT_MANAGED 块:
/// 文件不存在 → 用 header + 模板创建;已有 MANAGED 区 → 原位替换(内容不变则不写);否则追加。 /// 文件不存在 → 用 header + 模板创建;已有 MANAGED 区 → 原位替换(内容不变则不写);否则追加。
/// 返回动作:created | updated | unchanged | appended /// 返回动作:created | updated | unchanged | appended
@ -1277,7 +1282,7 @@ fn upsert_managed_block(
return Ok("created".to_string()); return Ok("created".to_string());
} }
let content = std::fs::read_to_string(path).map_err(|e| format!("读取 {label} 失败: {e}"))?; let content = std::fs::read_to_string(path).map_err(|e| format!("读取 {label} 失败: {e}"))?;
if content.contains("<!-- BLUEPRINT_MANAGED_START") { if content.contains(BLUEPRINT_MANAGED_START) {
let new_content = replace_managed_section(&content, template); let new_content = replace_managed_section(&content, template);
let written = crate::write_if_content_changed(path, &new_content) let written = crate::write_if_content_changed(path, &new_content)
.map_err(|e| format!("更新 {label} 失败: {e}"))?; .map_err(|e| format!("更新 {label} 失败: {e}"))?;
@ -1561,8 +1566,8 @@ fn unix_days_to_date(days: u64) -> String {
} }
fn replace_managed_section(content: &str, new_section: &str) -> String { fn replace_managed_section(content: &str, new_section: &str) -> String {
let start_marker = "<!-- BLUEPRINT_MANAGED_START"; let start_marker = BLUEPRINT_MANAGED_START;
let end_marker = "<!-- BLUEPRINT_MANAGED_END -->"; let end_marker = BLUEPRINT_MANAGED_END;
if let (Some(start), Some(end_pos)) = (content.find(start_marker), content.find(end_marker)) { if let (Some(start), Some(end_pos)) = (content.find(start_marker), content.find(end_marker)) {
let end = end_pos + end_marker.len(); let end = end_pos + end_marker.len();

View File

@ -34,41 +34,40 @@ const TOOLCHAIN_PACK_VERSION: &str = "1.0.0";
pub struct TemplateResult { pub struct TemplateResult {
/// 相对项目根的目标路径 /// 相对项目根的目标路径
pub name: String, pub name: String,
/// "written" | "skipped" | "failed" /// "written" | "updated" | "appended" | "skipped" | "failed"
pub action: String, pub action: String,
pub detail: String, pub detail: String,
/// 混合文件预警(P10):写入前该文件在 git 中已有托管块之外的未提交改动,
/// 本次又改写了其中的托管块。只提示不阻断,块外内容仍不触碰。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub warning: Option<String>,
}
impl TemplateResult {
fn new(name: &str, action: &str, detail: impl Into<String>) -> Self {
Self {
name: name.to_string(),
action: action.to_string(),
detail: detail.into(),
warning: None,
}
}
} }
/// 分发单个模板:存在则跳过(绝不覆盖),不存在则写入。 /// 分发单个模板:存在则跳过(绝不覆盖),不存在则写入。
fn dispatch_one(root: &Path, relative: &str, content: &str) -> TemplateResult { fn dispatch_one(root: &Path, relative: &str, content: &str) -> TemplateResult {
let target = root.join(relative); let target = root.join(relative);
if target.exists() { if target.exists() {
return TemplateResult { return TemplateResult::new(relative, "skipped", "已存在,未覆盖");
name: relative.to_string(),
action: "skipped".into(),
detail: "已存在,未覆盖".into(),
};
} }
if let Some(parent) = target.parent() { if let Some(parent) = target.parent() {
if let Err(e) = std::fs::create_dir_all(parent) { if let Err(e) = std::fs::create_dir_all(parent) {
return TemplateResult { return TemplateResult::new(relative, "failed", format!("建目录失败: {e}"));
name: relative.to_string(),
action: "failed".into(),
detail: format!("建目录失败: {e}"),
};
} }
} }
match std::fs::write(&target, content) { match std::fs::write(&target, content) {
Ok(_) => TemplateResult { Ok(_) => TemplateResult::new(relative, "written", "已写入"),
name: relative.to_string(), Err(e) => TemplateResult::new(relative, "failed", format!("写入失败: {e}")),
action: "written".into(),
detail: "已写入".into(),
},
Err(e) => TemplateResult {
name: relative.to_string(),
action: "failed".into(),
detail: format!("写入失败: {e}"),
},
} }
} }
@ -83,24 +82,12 @@ fn dispatch_fully_managed(root: &Path, relative: &str, content: &str) -> Templat
let normalize = |s: &str| s.replace("\r\n", "\n"); let normalize = |s: &str| s.replace("\r\n", "\n");
if let Ok(existing) = std::fs::read_to_string(&target) { if let Ok(existing) = std::fs::read_to_string(&target) {
if normalize(&existing) == normalize(content) { if normalize(&existing) == normalize(content) {
return TemplateResult { return TemplateResult::new(relative, "skipped", "内容已是最新模板");
name: relative.to_string(),
action: "skipped".into(),
detail: "内容已是最新模板".into(),
};
} }
} }
match std::fs::write(&target, content) { match std::fs::write(&target, content) {
Ok(_) => TemplateResult { Ok(_) => TemplateResult::new(relative, "updated", "全托管文件已覆盖为最新模板"),
name: relative.to_string(), Err(e) => TemplateResult::new(relative, "failed", format!("覆盖失败: {e}")),
action: "updated".into(),
detail: "全托管文件已覆盖为最新模板".into(),
},
Err(e) => TemplateResult {
name: relative.to_string(),
action: "failed".into(),
detail: format!("覆盖失败: {e}"),
},
} }
} }
@ -122,11 +109,11 @@ fn node_pnpm_contract_present(root: &Path) -> bool {
fn dispatch_toolchain_resolver(root: &Path) -> TemplateResult { fn dispatch_toolchain_resolver(root: &Path) -> TemplateResult {
if !node_pnpm_contract_present(root) { if !node_pnpm_contract_present(root) {
return TemplateResult { return TemplateResult::new(
name: "mise.toml".to_string(), "mise.toml",
action: "skipped".to_string(), "skipped",
detail: "非 .nvmrc + pnpm packageManager 项目,不适用 Node/pnpm 工具链包".to_string(), "非 .nvmrc + pnpm packageManager 项目,不适用 Node/pnpm 工具链包",
}; );
} }
dispatch_one(root, "mise.toml", TMPL_MISE) dispatch_one(root, "mise.toml", TMPL_MISE)
} }
@ -161,24 +148,14 @@ fn dispatch_versioned(root: &Path, relative: &str, template: &str) -> TemplateRe
} }
let content = match std::fs::read_to_string(&target) { let content = match std::fs::read_to_string(&target) {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => return TemplateResult::new(relative, "failed", format!("读取失败: {e}")),
return TemplateResult {
name: relative.to_string(),
action: "failed".into(),
detail: format!("读取失败: {e}"),
}
}
}; };
let (new_content, action, detail) = let (new_content, action, detail) =
if let (Some(s), Some(e)) = (content.find(MANAGED_START), content.find(MANAGED_END)) { if let (Some(s), Some(e)) = (content.find(MANAGED_START), content.find(MANAGED_END)) {
let old_ver = block_version(&content).unwrap_or("?").to_string(); let old_ver = block_version(&content).unwrap_or("?").to_string();
if old_ver == tpl_ver { if old_ver == tpl_ver {
return TemplateResult { return TemplateResult::new(relative, "skipped", format!("管理块已是 v{tpl_ver}"));
name: relative.to_string(),
action: "skipped".into(),
detail: format!("管理块已是 v{tpl_ver}"),
};
} }
let mut nc = String::new(); let mut nc = String::new();
nc.push_str(&content[..s]); nc.push_str(&content[..s]);
@ -198,16 +175,8 @@ fn dispatch_versioned(root: &Path, relative: &str, template: &str) -> TemplateRe
}; };
match std::fs::write(&target, new_content) { match std::fs::write(&target, new_content) {
Ok(_) => TemplateResult { Ok(_) => TemplateResult::new(relative, action, detail),
name: relative.to_string(), Err(e) => TemplateResult::new(relative, "failed", format!("写入失败: {e}")),
action: action.into(),
detail,
},
Err(e) => TemplateResult {
name: relative.to_string(),
action: "failed".into(),
detail: format!("写入失败: {e}"),
},
} }
} }
@ -245,6 +214,155 @@ pub fn dispatch_templates(project_path: &str) -> Vec<TemplateResult> {
] ]
} }
// ── P10:混合文件预警(升级托管块前检测块外的未提交改动)────────────────────
//
// 为什么:2026-10-01 enterprise 的 git-workflow.md 在有项目未提交改写时被升级了托管块,
// 同一文件混有炼境与项目两种来源的改动;按「禁止字符串手术」无法拆分提交,只能交人定案。
// 默认仍照常升级(只改托管块),但必须让调用方知道哪个文件因此成了混合文件。
/// 接入包会原位改写托管块的文件:AGENTS.md、git-workflow.md 由模板分发写 ONBOARDING_MANAGED,
/// AGENTS.md、CLAUDE.md 另由 buff 的蓝图规则同步写 BLUEPRINT_MANAGED。
const MANAGED_BLOCK_FILES: [&str; 3] =
["AGENTS.md", "docs/ai-context/git-workflow.md", "CLAUDE.md"];
/// 比较块外内容时整体剔除的托管块(开始标记前缀,结束标记)。
const STRIPPED_MANAGED_BLOCKS: [(&str, &str); 2] = [
(MANAGED_START, MANAGED_END),
(
crate::commands::blueprint::BLUEPRINT_MANAGED_START,
crate::commands::blueprint::BLUEPRINT_MANAGED_END,
),
];
/// 去掉全部托管块(含标记)后的块外内容。忽略行尾、行末空白与空行差异——
/// 炼境追加托管块时自己插入的空行不应被当成项目改动。
fn outside_managed_content(text: &str) -> String {
let mut rest = text.replace("\r\n", "\n");
for (start, end) in STRIPPED_MANAGED_BLOCKS {
while let Some(s) = rest.find(start) {
let Some(len) = rest[s..].find(end) else {
break; // 残缺块(无结束标记):余下内容按块外处理
};
rest.replace_range(s..s + len + end.len(), "");
}
}
rest.lines()
.map(str::trim_end)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join("\n")
}
/// HEAD 版本(None = 不在 HEAD 中:未跟踪、仅暂存或仓库尚无提交)与工作区版本的块外内容是否不同。
fn outside_managed_differs(head: Option<&str>, worktree: &str) -> bool {
outside_managed_content(head.unwrap_or("")) != outside_managed_content(worktree)
}
/// 目标文件在 git 中是否有托管块之外的未提交改动(未跟踪文件带块外内容也算)。
/// 非 git 仓库、git 不可用或命令失败一律返回 false:只做提示,探测失败不打扰调用方。
fn has_uncommitted_outside_managed(root: &Path, relative: &str, worktree: &str) -> bool {
// --no-optional-locks:后台探测不抢 index.lock,避免与 agent 同时进行的提交冲突
let status = crate::silent_cmd("git")
.args([
"--no-optional-locks",
"status",
"--porcelain",
"--untracked-files=all",
"--",
relative,
])
.current_dir(root)
.output();
match status {
Ok(out) if out.status.success() => {
if out.stdout.iter().all(u8::is_ascii_whitespace) {
return false; // 与 HEAD 一致(或被 .gitignore 忽略),没有未提交改动
}
}
_ => return false,
}
// `HEAD:./<path>` 按 cwd 解析,项目根是仓库子目录时同样正确
let head = crate::silent_cmd("git")
.args(["show", &format!("HEAD:./{relative}")])
.current_dir(root)
.output()
.ok()
.filter(|out| out.status.success())
.map(|out| String::from_utf8_lossy(&out.stdout).into_owned());
outside_managed_differs(head.as_deref(), worktree)
}
fn mixed_file_warning(relative: &str) -> String {
format!(
"{relative}:块外有未提交改动,升级后将成为混合文件——本次只改写了其中的炼境托管块,块外内容未动;\
提交前先确认块外改动的归属(不得用字符串手术拆分暂存),无法整体提交时交人定案。"
)
}
/// 写入前快照:块外已有未提交改动的托管块文件,及其写入前内容。
struct MixedFileGuard {
dirty: Vec<(&'static str, String)>,
}
impl MixedFileGuard {
fn snapshot(root: &Path) -> Self {
let dirty = MANAGED_BLOCK_FILES
.into_iter()
.filter_map(|relative| {
let before = std::fs::read_to_string(root.join(relative)).ok()?;
has_uncommitted_outside_managed(root, relative, &before)
.then_some((relative, before))
})
.collect();
Self { dirty }
}
/// 写入后:快照里本次确被改写的文件标注 warning;不在模板结果里的
/// (CLAUDE.md 只由蓝图规则同步改写)补一条结果,免得预警无处可见。
fn annotate(self, root: &Path, templates: &mut Vec<TemplateResult>) {
for (relative, before) in self.dirty {
let Ok(after) = std::fs::read_to_string(root.join(relative)) else {
continue;
};
if crate::same_text_ignoring_eol(&before, &after) {
continue; // 本次没改写它,不会制造混合文件
}
let warning = Some(mixed_file_warning(relative));
match templates.iter_mut().find(|t| t.name == relative) {
Some(result) => result.warning = warning,
None => {
let action =
if before.contains(crate::commands::blueprint::BLUEPRINT_MANAGED_START) {
"updated"
} else {
"appended"
};
let mut result = TemplateResult::new(
relative,
action,
"蓝图规则托管块(BLUEPRINT_MANAGED)已同步,块外内容未动",
);
result.warning = warning;
templates.push(result);
}
}
}
}
}
/// 模板分发与其后的写入步骤(apply 时为 lefthook 激活与 buff 蓝图规则同步)前后夹混合文件检测:
/// 写入前快照块外未提交改动,写入后只对确被改写的文件给出预警。
fn dispatch_templates_with_mixed_file_check<T>(
root: &Path,
then: impl FnOnce() -> Result<T, String>,
) -> Result<(Vec<TemplateResult>, T), String> {
let guard = MixedFileGuard::snapshot(root);
let mut templates = dispatch_templates(&root.to_string_lossy());
let rest = then()?;
guard.annotate(root, &mut templates);
Ok((templates, rest))
}
// ── V1:lefthook 激活(分发 ≠ 生效,文件写入后必须 lefthook install 挂上 hook)── // ── V1:lefthook 激活(分发 ≠ 生效,文件写入后必须 lefthook install 挂上 hook)──
/// lefthook 激活结果。status: "activated" | "missing_binary" | "failed" /// lefthook 激活结果。status: "activated" | "missing_binary" | "failed"
@ -324,6 +442,7 @@ pub struct OnboardingReport {
} }
/// 应用接入包(基线档):分发标准模板 + 激活 lefthook + 挂 buff(炼境登记 + git 观测/飞轮采集)。 /// 应用接入包(基线档):分发标准模板 + 激活 lefthook + 挂 buff(炼境登记 + git 观测/飞轮采集)。
/// 改写了块外带未提交改动的托管块文件时,对应结果带 warning(混合文件预警,见 P10 段)。
#[tauri::command] #[tauri::command]
pub fn apply_onboarding_pack( pub fn apply_onboarding_pack(
project_path: String, project_path: String,
@ -335,9 +454,11 @@ pub fn apply_onboarding_pack(
if project_path.trim().is_empty() || !root.is_dir() { if project_path.trim().is_empty() || !root.is_dir() {
return Err(format!("项目路径为空或不存在,拒绝分发: {project_path:?}")); return Err(format!("项目路径为空或不存在,拒绝分发: {project_path:?}"));
} }
let templates = dispatch_templates(&project_path); let (templates, (hook, buff)) = dispatch_templates_with_mixed_file_check(root, || {
let hook = run_lefthook_install(Path::new(project_path.trim_end_matches(['/', '\\']))); let hook = run_lefthook_install(root);
let buff = crate::commands::buff::apply_blueprint_buff(project_path, project_id)?; let buff = crate::commands::buff::apply_blueprint_buff(project_path.clone(), project_id)?;
Ok((hook, buff))
})?;
Ok(OnboardingReport { Ok(OnboardingReport {
templates, templates,
hook, hook,
@ -1168,6 +1289,203 @@ mod tests {
let _ = std::fs::remove_dir_all(&base); let _ = std::fs::remove_dir_all(&base);
} }
// ── P10:混合文件预警 ────────────────────────────────────────────────
#[test]
fn outside_managed_content_strips_blocks_and_ignores_eol_and_blank_lines() {
let with_blocks = "# 标题\r\n\r\n项目内容\r\n\r\n<!-- ONBOARDING_MANAGED_START version=\"1.0.0\" -->\r\n炼境内容\r\n<!-- ONBOARDING_MANAGED_END -->\r\n\r\n<!-- BLUEPRINT_MANAGED_START version=\"2.0.0\" -->\n蓝图\n<!-- BLUEPRINT_MANAGED_END -->\n";
assert_eq!(outside_managed_content(with_blocks), "# 标题\n项目内容");
assert!(!outside_managed_differs(
Some("# 标题\n\n项目内容\n"),
with_blocks
));
assert!(outside_managed_differs(
Some("# 标题\n项目内容"),
&with_blocks.replace("项目内容", "改过的项目内容")
));
// 不在 HEAD 中:有块外内容即算改动,只有托管块则不算
assert!(outside_managed_differs(None, with_blocks));
assert!(!outside_managed_differs(
None,
"<!-- ONBOARDING_MANAGED_START version=\"1\" -->\nx\n<!-- ONBOARDING_MANAGED_END -->\n"
));
// 残缺块(无结束标记)不剔除,余下内容按块外处理
assert_eq!(
outside_managed_content("a\n<!-- ONBOARDING_MANAGED_START version=\"1\" -->\nb"),
"a\n<!-- ONBOARDING_MANAGED_START version=\"1\" -->\nb"
);
}
fn git_in(dir: &Path, args: &[&str]) -> bool {
std::process::Command::new("git")
.args([
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"-c",
"commit.gpgsign=false",
"-c",
"core.autocrlf=false",
])
.args(args)
.current_dir(dir)
.output()
.expect("git 可执行")
.status
.success()
}
/// 托管块版本改旧(分发时必然升级),块外带项目自己的内容。
fn outdated_versioned(template: &str, project_line: &str) -> String {
let block = extract_managed_block(template).expect("模板必须带管理块");
let tpl_ver = block_version(block).expect("模板必须带版本标记");
let old_block = block.replace(&format!("version=\"{tpl_ver}\""), "version=\"0.0.1\"");
format!("# 项目文档\n\n{project_line}\n\n{old_block}\n")
}
/// 走与 apply_onboarding_pack 相同的检测夹层;后续写入只做 buff 里的蓝图规则同步
/// (buff 其余部分要连炼境 DB,lefthook 激活不碰托管块文件,均不在此调用)。
fn apply_like(root: &Path) -> Vec<TemplateResult> {
dispatch_templates_with_mixed_file_check(root, || {
crate::commands::blueprint::sync_blueprint_rules(root.to_string_lossy().into_owned())
.map(|_| ())
})
.expect("分发不应失败")
.0
}
fn result_of<'a>(results: &'a [TemplateResult], name: &str) -> &'a TemplateResult {
results
.iter()
.find(|t| t.name == name)
.unwrap_or_else(|| panic!("缺少 {name} 的分发结果"))
}
#[test]
fn mixed_file_warning_only_for_uncommitted_changes_outside_managed_blocks() {
let base =
std::env::temp_dir().join(format!("lianjing_onb_mixed_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("docs/ai-context")).unwrap();
let workflow_path = base.join("docs/ai-context/git-workflow.md");
let workflow = outdated_versioned(TMPL_GIT_WORKFLOW, "项目分支约定");
std::fs::write(
base.join("AGENTS.md"),
outdated_versioned(TMPL_AGENTS, "项目说明"),
)
.unwrap();
std::fs::write(&workflow_path, &workflow).unwrap();
assert!(git_in(&base, &["init", "-q"]));
assert!(git_in(&base, &["add", "-A"]));
assert!(git_in(
&base,
&["commit", "-q", "--no-verify", "-m", "init"]
));
// 情形一:git-workflow.md 块外有未提交改写、CLAUDE.md 未跟踪且含项目内容 → 两者预警;
// AGENTS.md 与 HEAD 一致 → 照常升级、无预警
std::fs::write(
&workflow_path,
workflow.replace("项目分支约定", "项目分支约定(未提交改写)"),
)
.unwrap();
std::fs::write(base.join("CLAUDE.md"), "# 项目自己的 CLAUDE.md\n").unwrap();
let r = apply_like(&base);
let wf = result_of(&r, "docs/ai-context/git-workflow.md");
assert_eq!(wf.action, "updated", "默认仍升级托管块");
let warning = wf.warning.as_deref().expect("块外有未提交改动应预警");
assert!(
warning.contains("docs/ai-context/git-workflow.md")
&& warning.contains("块外有未提交改动,升级后将成为混合文件"),
"预警须含文件名与提示: {warning}"
);
let upgraded = std::fs::read_to_string(&workflow_path).unwrap();
let tpl_ver = extract_managed_block(TMPL_GIT_WORKFLOW)
.and_then(block_version)
.unwrap();
assert!(
upgraded.contains(&format!("version=\"{tpl_ver}\"")),
"托管块应升级"
);
assert!(
upgraded.contains("项目分支约定(未提交改写)") && upgraded.starts_with("# 项目文档"),
"块外内容不得改动"
);
let claude = result_of(&r, "CLAUDE.md");
assert_eq!(claude.action, "appended");
assert!(
claude
.warning
.as_deref()
.is_some_and(|w| w.contains("CLAUDE.md")),
"未跟踪文件含块外内容也算:{claude:?}"
);
let claude_md = std::fs::read_to_string(base.join("CLAUDE.md")).unwrap();
assert!(claude_md.starts_with("# 项目自己的 CLAUDE.md"));
let agents = result_of(&r, "AGENTS.md");
assert_eq!(agents.action, "updated");
assert_eq!(agents.warning, None, "块外无未提交改动的文件不应预警");
// 情形二:全部提交后只改托管块(版本退回)并整体转成 CRLF → 照常升级、无预警
assert!(git_in(&base, &["add", "-A"]));
assert!(git_in(
&base,
&["commit", "-q", "--no-verify", "-m", "onboard"]
));
let agents_path = base.join("AGENTS.md");
let committed = std::fs::read_to_string(&agents_path).unwrap();
let tpl_ver = extract_managed_block(TMPL_AGENTS)
.and_then(block_version)
.unwrap();
let block_only = committed
.replace(&format!("version=\"{tpl_ver}\""), "version=\"0.0.1\"")
.replace('\n', "\r\n");
std::fs::write(&agents_path, block_only).unwrap();
let r = apply_like(&base);
assert_eq!(result_of(&r, "AGENTS.md").action, "updated");
assert!(
r.iter().all(|t| t.warning.is_none()),
"只改托管块(含行尾差异)不应预警: {r:?}"
);
assert!(
!r.iter().any(|t| t.name == "CLAUDE.md"),
"无预警时不补 CLAUDE.md 结果行"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn mixed_file_warning_is_silent_outside_git() {
let base =
std::env::temp_dir().join(format!("lianjing_onb_nogit_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
if git_in(&base, &["rev-parse", "--git-dir"]) {
// 临时目录位于某个 git 仓库内,构造不出「非 git 仓库」前置条件
eprintln!("跳过:{} 位于 git 仓库内", base.display());
let _ = std::fs::remove_dir_all(&base);
return;
}
std::fs::write(
base.join("AGENTS.md"),
outdated_versioned(TMPL_AGENTS, "项目说明"),
)
.unwrap();
std::fs::write(base.join("CLAUDE.md"), "# 项目自己的 CLAUDE.md\n").unwrap();
let r = apply_like(&base);
assert_eq!(result_of(&r, "AGENTS.md").action, "updated");
assert!(
r.iter().all(|t| t.warning.is_none()),
"非 git 仓库不应预警: {r:?}"
);
let _ = std::fs::remove_dir_all(&base);
}
// ── V1:接入生效验收纯函数 ──────────────────────────────────────────── // ── V1:接入生效验收纯函数 ────────────────────────────────────────────
#[test] #[test]
@ -1574,20 +1892,23 @@ mod tests {
#[test] #[test]
fn skill_is_projected_to_codex_and_claude_from_one_source() { fn skill_is_projected_to_codex_and_claude_from_one_source() {
let base = std::env::temp_dir().join(format!( let base =
"lianjing_skill_projection_{}", std::env::temp_dir().join(format!("lianjing_skill_projection_{}", std::process::id()));
std::process::id()
));
let _ = std::fs::remove_dir_all(&base); let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap(); std::fs::create_dir_all(&base).unwrap();
dispatch_templates(base.to_str().unwrap()); dispatch_templates(base.to_str().unwrap());
let codex = std::fs::read_to_string(base.join(".agents/skills/lian-jing/SKILL.md")).unwrap(); let codex =
let claude = std::fs::read_to_string(base.join(".claude/skills/lian-jing/SKILL.md")).unwrap(); std::fs::read_to_string(base.join(".agents/skills/lian-jing/SKILL.md")).unwrap();
let claude =
std::fs::read_to_string(base.join(".claude/skills/lian-jing/SKILL.md")).unwrap();
assert_eq!(codex, claude, "两端 skill 必须来自同一模板"); assert_eq!(codex, claude, "两端 skill 必须来自同一模板");
assert!(check_skill(Some(&claude)).0); assert!(check_skill(Some(&claude)).0);
let packs: serde_json::Value = serde_json::from_str(TMPL_CAPABILITY_PACKS).unwrap(); let packs: serde_json::Value = serde_json::from_str(TMPL_CAPABILITY_PACKS).unwrap();
let artifacts = packs["packs"][0]["project_artifacts"].to_string(); let artifacts = packs["packs"][0]["project_artifacts"].to_string();
assert!(artifacts.contains(".claude/skills/lian-jing/SKILL.md"), "能力包清单须登记 Claude 投影"); assert!(
artifacts.contains(".claude/skills/lian-jing/SKILL.md"),
"能力包清单须登记 Claude 投影"
);
let _ = std::fs::remove_dir_all(&base); let _ = std::fs::remove_dir_all(&base);
} }
@ -1679,7 +2000,10 @@ mod tests {
let stable = format!( let stable = format!(
"[mcp_servers.lian-jing]\n{url}http_headers_helper = \"\\\"%USERPROFILE%\\\\.lian-jing\\\\bin\\\\lianjing-mcp-header.cmd\\\" d1\"\n" "[mcp_servers.lian-jing]\n{url}http_headers_helper = \"\\\"%USERPROFILE%\\\\.lian-jing\\\\bin\\\\lianjing-mcp-header.cmd\\\" d1\"\n"
); );
assert_eq!(check_codex_mcp(Some(&stable), "p1", 27190).state, VerifyState::Ready); assert_eq!(
check_codex_mcp(Some(&stable), "p1", 27190).state,
VerifyState::Ready
);
} }
/// 回归:空路径登记曾把接入包写进炼境进程 cwd(src-tauri/,2026-07-15 批量实调实锤)。 /// 回归:空路径登记曾把接入包写进炼境进程 cwd(src-tauri/,2026-07-15 批量实调实锤)。

View File

@ -315,7 +315,7 @@ pub fn tools_list_result() -> Value {
}, },
{ {
"name": "apply_onboarding_pack", "name": "apply_onboarding_pack",
"description": "向项目分发标准接入包:写入 AGENTS.md / lefthook / git-workflow / envoy / blueprint-gate 模板(版本化块原位升级、全托管文件内容不一致即覆盖、lefthook 存在即跳过,块外用户内容绝不触碰),并挂载 buff 自动监听 git 提交。等价于炼境 UI「接入包」区域的「应用接入包」按钮。模板升级后对存量项目重新调用即可推送新版本;不传 project_id 则对所有已登记项目批量执行。", "description": "向项目分发标准接入包:写入 AGENTS.md / lefthook / git-workflow / envoy / blueprint-gate 模板(版本化块原位升级、全托管文件内容不一致即覆盖、lefthook 存在即跳过,块外用户内容绝不触碰;目标文件在 git 中块外已有未提交改动时仍只改托管块,但对该文件给出「混合文件」预警,提交前须先确认块外改动归属),并挂载 buff 自动监听 git 提交。等价于炼境 UI「接入包」区域的「应用接入包」按钮。模板升级后对存量项目重新调用即可推送新版本;不传 project_id 则对所有已登记项目批量执行。",
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -1240,11 +1240,26 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option<Str
format!("{short}={}", t.action) format!("{short}={}", t.action)
}) })
.collect(); .collect();
let mixed: Vec<&str> = report
.templates
.iter()
.filter(|t| t.warning.is_some())
.map(|t| t.name.as_str())
.collect();
let mixed_note = if mixed.is_empty() {
String::new()
} else {
format!(
" | ⚠️ 混合文件(块外有未提交改动): {}",
mixed.join(", ")
)
};
lines.push(format!( lines.push(format!(
"- {}: {} | hook={}", "- {}: {} | hook={}{}",
id, id,
summary.join(", "), summary.join(", "),
report.hook.status report.hook.status,
mixed_note
)); ));
} }
Err(e) => { Err(e) => {
@ -1278,7 +1293,12 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option<Str
"skipped" => "⏭ skipped", "skipped" => "⏭ skipped",
_ => "❌ failed", _ => "❌ failed",
}; };
format!(" {icon}: {}", t.name) match &t.warning {
Some(warning) => {
format!(" {icon}: {}\n ⚠️ {warning}", t.name)
}
None => format!(" {icon}: {}", t.name),
}
}) })
.collect(); .collect();
let hook_icon = match report.hook.status.as_str() { let hook_icon = match report.hook.status.as_str() {

View File

@ -1311,11 +1311,16 @@ function GovernancePanel({
const style = const style =
TEMPLATE_ACTION_STYLE[t.action] ?? TEMPLATE_ACTION_STYLE.failed; TEMPLATE_ACTION_STYLE[t.action] ?? TEMPLATE_ACTION_STYLE.failed;
return ( return (
<p key={t.name} className="text-[10px] font-mono flex items-center gap-1"> <div key={t.name}>
<p className="text-[10px] font-mono flex items-center gap-1">
<span className={style.color}>{style.icon}</span> <span className={style.color}>{style.icon}</span>
<span className="text-gray-600 truncate flex-1">{t.name}</span> <span className="text-gray-600 truncate flex-1">{t.name}</span>
<span className="text-gray-400">{style.label ?? t.detail}</span> <span className="text-gray-400">{style.label ?? t.detail}</span>
</p> </p>
{t.warning && (
<p className="text-[10px] text-amber-600 leading-relaxed pl-3">⚠ {t.warning}</p>
)}
</div>
); );
})} })}
{packHook && ( {packHook && (

View File

@ -1626,6 +1626,8 @@ export interface TemplateResult {
name: string; name: string;
action: "written" | "updated" | "appended" | "skipped" | "failed"; action: "written" | "updated" | "appended" | "skipped" | "failed";
detail: string; detail: string;
/** 混合文件预警:写入前该文件块外已有未提交改动,本次又改写了其中的托管块(只提示不阻断) */
warning?: string;
} }
export interface HookActivation { export interface HookActivation {