diff --git a/.blueprint/modules/agent-client-parity.md b/.blueprint/modules/agent-client-parity.md index 8d6c69e..ef2c53d 100644 --- a/.blueprint/modules/agent-client-parity.md +++ b/.blueprint/modules/agent-client-parity.md @@ -156,3 +156,20 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。 - acceptance: "agent_verify: [cargo test] 托管块同时含 mcp-lian-jing 与 lianjing-session-mcp 两行、插件路径为绝对路径、roots 与 normalize_dsh_root 一致;[node --test] 假 ctx 下按会话 cwd 命中项目才挂载、null 哨兵与未命中不挂、shim 失败/超时不抛错且无未处理 rejection;[DSH 0.2 与 0.1 运行时] 临时 DSH_HOME + stub MCP 服务,ACP session/new 两个不同 cwd 的会话只有项目会话产生 /mcp/projects/ 请求。human_verify: DSH GUI 在 enterprise 工作区新建会话能看到开发变量工具,在其他工作区看不到。" - 为什么:2026-10-01 人终验确认 DSH 桌面版会话只连全局地址。只读调研(两路独立 + 对抗复核)结论:DSH 桌面版只有一个 Host 进程,启动目录固定为 profile 目录,!!js 只在启动时求值、拿不到会话信息,mcp-client 的地址在实例化时固定,也不读任何项目级 MCP 配置,所以只改托管块做不到。可行路径是 DSH 自带的 Agent 作用域:每个 GUI 会话的 session.header.cwd 即工作区路径,会话创建时串行发出 agent/created;在 agent.ctx 上挂 dsh-mcp-client,工具只对该会话可见并覆盖同名全局工具(DSH 内置的 ACP 就这样按会话挂 MCP)。 - 风险(实施前需人确认是否值得):依赖 DSH 内部接口(agent/created、agent.ctx.plugin、ctx.loader.import、按 scope 分账),DSH 升级可能失效,须特性探测并降级为全局;Host 有 installFailLoud,插件任何未处理异常都会打崩桌面 Host;agent/created 是串行事件,挂载须加短超时,炼境离线时静默降级;Node 按 URL 缓存 ESM,插件升级须换文件名;子代理看不到父会话作用域的工具,是否给子代理单独挂载需人定;属于往 DSH 进程注入代码,信任边界与现有 !!js + execFileSync 相同,但需按「管家准入」论证。 +- 可行性实测(2026-10-02,用户确认「先实测再实现」):临时 DSH_HOME + 记录请求的桩 MCP 服务 + 原型插件(%TEMP%\lj-p9-spike),经 ACP 在同一进程内新建两个不同 cwd 的会话。DSH 0.2.0-rc.2(桌面版内置)与 0.1.5-rc.2(enterprise 本地)结果一致:插件按绝对路径从 home patch 加载;ctx.loader.import('@deepseek-ai/dsh-mcp-client') 成功;每个会话触发 agent/created 且带 session.header.cwd;项目内会话挂载后桩服务收到 /mcp/projects/ 的 initialize 与 tools/list(带认证头),另一会话无任何请求。0.1.x 没有 profileContext,事件里也没有 source 字段。未实测:挂载工具对该会话模型的可见性、桌面 GUI 热加载(只有代码阅读结论)。实现需在 PR #24 合并后从 master 开分支。 + +### ✅ P3b — 接入写入幂等:同内容不重写、Codex 托管块不累积空行 +- status: done +- complexity: S +- files: src-tauri/src/lib.rs, src-tauri/src/mcp_inject.rs, src-tauri/src/commands/blueprint.rs +- acceptance: "agent_verify: [cargo test] 连续 upsert Codex 托管块两次字节不变,已累积的空行被吸收,块外后续内容保留;CRLF 检出的同内容 AGENTS/CLAUDE 托管块返回 unchanged 且不被 LF 重写;[MCP inject_mcp ×2] enterprise 的 .codex/config.toml、.claude/settings.json、.lian-jing/context.json、.claude/liangjing.json 第二次注入修改时间不变。" +- 为什么:2026-10-01 enterprise 未提交改动归属梳理发现两处炼境侧缺陷。① Codex 托管块自带结尾换行,又原样保留 END 之后的换行,每注入一次多一个空行(enterprise 已累积 16 行)。② sync_blueprint_rules 与注入在内容不变时也用 LF 重写文件:子项目开 core.autocrlf,CRLF 检出的同内容文件因大小变化被 git status 误报为改动(CONVENTIONS.md、context.json),修改时间也被刷新,干扰了「这次改动是谁做的」的判断。 +- 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)需在子项目里由人刷新一次索引。" + +### 📋 P10 — 接入包升级前检测目标文件的块外未提交改动 +- status: todo +- complexity: S +- files: src-tauri/src/commands/onboarding.rs(apply 预览 / 结果) +- depends: P8 +- 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 也因此持续报版本不符。管家应在制造混合文件之前提示,而不是事后由人收拾。 diff --git a/src-tauri/src/commands/blueprint.rs b/src-tauri/src/commands/blueprint.rs index e932739..bea7f63 100644 --- a/src-tauri/src/commands/blueprint.rs +++ b/src-tauri/src/commands/blueprint.rs @@ -1256,14 +1256,14 @@ pub fn get_blueprint_status(project_path: String) -> Result Result // 确保 .blueprint/ 存在 std::fs::create_dir_all(&bp_dir).map_err(|e| format!("创建 .blueprint/ 失败: {e}"))?; - // 1. 写入 CONVENTIONS.md(使用当前生效版本:用户版优先,fallback 内置) - std::fs::write( - bp_dir.join("CONVENTIONS.md"), - effective_conventions_content(), + // 1. 写入 CONVENTIONS.md(使用当前生效版本:用户版优先,fallback 内置;内容不变则不写) + let conventions_written = crate::write_if_content_changed( + &bp_dir.join("CONVENTIONS.md"), + &effective_conventions_content(), ) .map_err(|e| format!("写入 CONVENTIONS.md 失败: {e}"))?; @@ -1323,7 +1324,7 @@ pub fn sync_blueprint_rules(project_path: String) -> Result Ok(SyncResult { success: true, - conventions_written: true, + conventions_written, claude_md_action: claude_action.clone(), agents_md_action: agents_action.clone(), message: format!( @@ -3051,6 +3052,14 @@ mod tests { assert!(c.contains("内容B") && !c.contains("内容A")); assert_eq!(c.matches("BLUEPRINT_MANAGED_START").count(), 1); + // 2b. 回归:内容不变(含 autocrlf 检出的 CRLF 副本)→ unchanged,不重写文件 + //(2026-10-01 enterprise:无条件 LF 重写让 git status 误报改动、修改时间失真) + std::fs::write(&f, c.replace('\n', "\r\n")).unwrap(); + let before = std::fs::read(&f).unwrap(); + let a = upsert_managed_block(&f, tpl_v2, "# 头部").unwrap(); + assert_eq!(a, "unchanged"); + assert_eq!(std::fs::read(&f).unwrap(), before, "CRLF 原样保留,未被 LF 重写"); + // 3. 存在但无 MANAGED 区 → appended(既有内容保留) let g = dir.join("CLAUDE.md"); std::fs::write( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index da49b8a..c480ece 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -18,6 +18,22 @@ pub fn mcp_client_auth_headers_json(project_digest: &str) -> Result std::io::Result { + let normalize = |text: &str| text.replace("\r\n", "\n"); + if let Ok(existing) = std::fs::read_to_string(path) { + if normalize(&existing) == normalize(content) { + return Ok(false); + } + } + std::fs::write(path, content).map(|()| true) +} + /// 创建不弹 CMD 窗口的子进程(Windows 专用 CREATE_NO_WINDOW 标志)。 /// 所有后台静默命令都应通过此函数创建,避免在 Windows 上频繁闪出黑框。 pub(crate) fn silent_cmd(program: &str) -> std::process::Command { diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index 5cd25b9..b32fc6f 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -200,7 +200,9 @@ fn write_context_object(path: &PathBuf, value: &Value, label: &str) -> Result<() .map_err(|error| format!("创建 {label} 目录失败: {error}"))?; } let content = serde_json::to_string_pretty(value).map_err(|error| error.to_string())?; - std::fs::write(path, content).map_err(|error| format!("写入 {label} 失败: {error}")) + crate::write_if_content_changed(path, &content) + .map(|_| ()) + .map_err(|error| format!("写入 {label} 失败: {error}")) } /// 写工具无关的项目身份锚点。项目根目录是边界,客户端项目 UI 或用户级配置均不是身份来源。 @@ -1042,7 +1044,9 @@ fn write_json(path: &PathBuf, value: &Value) -> Result<(), String> { std::fs::create_dir_all(parent).map_err(|e| format!("创建 .claude 目录失败: {}", e))?; } let content = serde_json::to_string_pretty(value).map_err(|e| e.to_string())?; - std::fs::write(path, content).map_err(|e| format!("写入 settings.json 失败: {}", e)) + crate::write_if_content_changed(path, &content) + .map(|_| ()) + .map_err(|e| format!("写入 settings.json 失败: {}", e)) } fn upsert_codex_mcp_config( @@ -1065,12 +1069,13 @@ fn upsert_codex_mcp_config( current.find(CODEX_MCP_MANAGED_END), ); let next = match (start, end) { - (Some(start), Some(end)) if end >= start => format!( - "{}{}{}", - ¤t[..start], - block, - ¤t[end + CODEX_MCP_MANAGED_END.len()..] - ), + (Some(start), Some(end)) if end >= start => { + // 块自带结尾换行:吸收 END 之后原有的空行再接上块外内容,否则每次注入都多一个空行 + let tail = + current[end + CODEX_MCP_MANAGED_END.len()..].trim_start_matches(['\r', '\n']); + let separator = if tail.is_empty() { "" } else { "\n" }; + format!("{}{block}{separator}{tail}", ¤t[..start]) + } (Some(_), _) | (_, Some(_)) => { return Err(format!( "{} 的炼境 Codex 管理块不完整或顺序错误,请修复标记后重试", @@ -1095,7 +1100,9 @@ fn upsert_codex_mcp_config( std::fs::create_dir_all(parent) .map_err(|error| format!("创建 .codex 目录失败: {error}"))?; } - std::fs::write(path, next).map_err(|error| format!("写入 Codex config.toml 失败: {error}")) + crate::write_if_content_changed(path, &next) + .map(|_| ()) + .map_err(|error| format!("写入 Codex config.toml 失败: {error}")) } fn remove_codex_mcp_config(path: &PathBuf) -> Result<(), String> { @@ -1407,6 +1414,53 @@ mod tests { ); } + /// 回归:托管块原先每次注入都在 END 之后多留一个空行(enterprise 已累积 15 行), + /// 且内容不变也会用 LF 重写 CRLF 检出的文件。 + #[test] + fn codex_mcp_upsert_is_idempotent_and_absorbs_accumulated_blank_lines() { + let base = std::env::temp_dir().join(format!("lianjing_codex_idem_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(base.join(".codex")).unwrap(); + let path = base.join(".codex/config.toml"); + let url = "http://127.0.0.1:27190/mcp/projects/p1"; + let helper = shim_helper_command("d1"); + + // 块在末尾、END 之后堆了 15 个空行(CRLF 检出) + let block = format!( + "{CODEX_MCP_MANAGED_START}\n[mcp_servers.{MCP_KEY}]\nurl = \"{url}\"\nhttp_headers_helper = {}\n{CODEX_MCP_MANAGED_END}", + serde_json::to_string(&helper).unwrap() + ); + let accumulated = + format!("model = \"gpt-6\"\n{block}{}", "\n".repeat(16)).replace('\n', "\r\n"); + std::fs::write(&path, &accumulated).unwrap(); + upsert_codex_mcp_config(&path, url, &helper).unwrap(); + let cleaned = std::fs::read_to_string(&path).unwrap(); + assert!( + cleaned.ends_with(&format!("{CODEX_MCP_MANAGED_END}\n")), + "END 之后的累积空行被吸收" + ); + + // 再注入一次:内容与字节都不变 + let before = std::fs::read(&path).unwrap(); + upsert_codex_mcp_config(&path, url, &helper).unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), before); + + // 块在中间:块外后续内容保留,并且重复注入稳定 + std::fs::write( + &path, + format!("{cleaned}\n\n\n[profiles.x]\nmodel = \"y\"\n"), + ) + .unwrap(); + upsert_codex_mcp_config(&path, url, &helper).unwrap(); + let middle = std::fs::read_to_string(&path).unwrap(); + assert!(middle.contains(&format!( + "{CODEX_MCP_MANAGED_END}\n\n[profiles.x]\nmodel = \"y\"\n" + ))); + upsert_codex_mcp_config(&path, url, &helper).unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), middle); + let _ = std::fs::remove_dir_all(&base); + } + #[test] fn codex_mcp_config_preserves_user_content_and_rejects_handwritten_conflict() { let base =