From ec672bb0277c84810bf24ae8e6f374ec07c22361 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Tue, 6 Oct 2026 07:46:48 +0900 Subject: [PATCH] =?UTF-8?q?fix(coexist):=20=E6=96=B0=E7=82=BC=E5=A2=83?= =?UTF-8?q?=E6=8E=A5=E7=AE=A1=E9=A1=B9=E7=9B=AE=E6=96=87=E4=BB=B6=E5=90=8E?= =?UTF-8?q?=EF=BC=8C=E6=97=A7=E7=82=BC=E5=A2=83=E7=9A=84=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E5=88=86=E5=8F=91=E3=80=81=E8=93=9D=E5=9B=BE=E8=A7=84=E5=88=99?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E4=B8=8E=20buff=20=E8=BD=AE=E8=AF=A2?= =?UTF-8?q?=E8=B7=B3=E8=BF=87=E8=AF=A5=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 共存期两边会来回覆盖同一批托管文件(AGENTS.md、CLAUDE.md、lian-jing 技能、CONVENTIONS、任务卡)。 新炼境(lian-jing-next)接管某项目时在 .lian-jing/context.json 写入 files_managed_by="lian-jing-next"; 旧炼境据此跳过:dispatch_templates_with、sync_blueprint_rules_with、buff check_project。 身份合并写(write_canonical_context_with)本就保留未知字段,测试锁定接管标记不会被自己抹掉。 Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/commands/blueprint.rs | 25 ++++++++++++++++++++ src-tauri/src/commands/buff.rs | 4 ++++ src-tauri/src/commands/onboarding.rs | 23 +++++++++++++++++++ src-tauri/src/mcp_inject.rs | 34 ++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+) diff --git a/src-tauri/src/commands/blueprint.rs b/src-tauri/src/commands/blueprint.rs index 3db65d8..9e6bb6f 100644 --- a/src-tauri/src/commands/blueprint.rs +++ b/src-tauri/src/commands/blueprint.rs @@ -1323,6 +1323,15 @@ pub(crate) fn sync_blueprint_rules_with( root: &Path, sink: &dyn crate::WriteSink, ) -> Result { + if crate::mcp_inject::files_managed_by_next(root) { + return Ok(SyncResult { + success: true, + conventions_written: false, + claude_md_action: "skipped".into(), + agents_md_action: "skipped".into(), + message: crate::mcp_inject::FILES_MANAGED_BY_NEXT_SKIP.into(), + }); + } let bp_dir = root.join(".blueprint"); let project_name = root .file_name() @@ -2876,6 +2885,22 @@ pub fn archive_project_docs(project_path: String) -> Result Result<(), String> { let root = Path::new(project_path); + // 共存期防回写:项目文件已由新炼境接管,不再改写其任务卡 + if crate::mcp_inject::files_managed_by_next(root) { + return Ok(()); + } // 获取当前 HEAD commit let current_commit = match get_head_commit(root) { diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index 19d2d04..09f234d 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -199,6 +199,14 @@ pub(crate) fn dispatch_templates_with( root: &Path, sink: &dyn crate::WriteSink, ) -> Vec { + if crate::mcp_inject::files_managed_by_next(root) { + return vec![TemplateResult { + name: ".lian-jing/context.json".into(), + action: "skipped".into(), + detail: crate::mcp_inject::FILES_MANAGED_BY_NEXT_SKIP.into(), + warning: None, + }]; + } vec![ dispatch_versioned(root, "AGENTS.md", TMPL_AGENTS, sink), dispatch_one(root, "lefthook.yml", TMPL_LEFTHOOK, sink), @@ -1243,6 +1251,21 @@ pub fn silent_projects(conn: &rusqlite::Connection) -> Result #[cfg(test)] mod tests { + // 共存期防回写:项目文件已由新炼境接管时,接入包模板分发零写入 + #[test] + fn dispatch_skips_projects_managed_by_next() { + let base = std::env::temp_dir().join(format!("lianjing_dispatch_guard_test_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(base.join(".lian-jing")).unwrap(); + std::fs::create_dir_all(base.join(".blueprint")).unwrap(); + std::fs::write(base.join(".lian-jing/context.json"), r#"{"project_id":"p1","files_managed_by":"lian-jing-next"}"#).unwrap(); + let results = super::dispatch_templates(base.to_str().unwrap()); + assert_eq!(results.len(), 1); + assert_eq!(results[0].action, "skipped"); + assert!(!base.join("AGENTS.md").exists() && !base.join(".claude").exists() && !base.join("lefthook.yml").exists()); + let _ = std::fs::remove_dir_all(&base); + } + use super::*; #[test] diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index a6c1bd7..b87a655 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -235,6 +235,20 @@ fn write_json_with( .map_err(|error| format!("写入 {label} 失败: {error}")) } +/// 新炼境(lian-jing-next)接管项目文件后写入 `.lian-jing/context.json` 的 `files_managed_by` 取值。 +pub(crate) const FILES_MANAGED_BY_NEXT: &str = "lian-jing-next"; + +/// 共存期防回写守卫:项目文件已由新炼境接管时,旧炼境的模板分发、蓝图规则同步与 buff 轮询一律跳过, +/// 避免两边来回覆盖同一批托管文件。只读判断,文件缺失或不合法时视为未接管。 +pub(crate) fn files_managed_by_next(root: &std::path::Path) -> bool { + std::fs::read_to_string(root.join(".lian-jing").join("context.json")) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()) + .is_some_and(|ctx| ctx.get("files_managed_by").and_then(Value::as_str) == Some(FILES_MANAGED_BY_NEXT)) +} + +pub(crate) const FILES_MANAGED_BY_NEXT_SKIP: &str = "项目文件已由新炼境接管(.lian-jing/context.json 的 files_managed_by),旧炼境不再分发"; + /// 写工具无关的项目身份锚点。项目根目录是边界,客户端项目 UI 或用户级配置均不是身份来源。 /// 合并写入:保留未来字段与项目定制字段,只有炼境拥有的身份字段会被刷新。 #[cfg(test)] @@ -3623,6 +3637,26 @@ trust_level = "trusted" ); } + #[test] + fn files_managed_by_next_guard_reads_context_and_survives_identity_rewrite() { + let base = std::env::temp_dir().join(format!("lianjing_files_guard_test_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(base.join(".lian-jing")).unwrap(); + assert!(!files_managed_by_next(&base), "没有 context.json 视为未接管"); + std::fs::write( + base.join(".lian-jing/context.json"), + r#"{"project_id":"p1","schema_version":1,"files_managed_by":"lian-jing-next"}"#, + ) + .unwrap(); + assert!(files_managed_by_next(&base)); + // 旧炼境刷新身份时必须保留接管标记,否则守卫会被自己抹掉 + write_canonical_context_at(&base, "p1").unwrap(); + assert!(files_managed_by_next(&base), "身份合并写后接管标记仍在"); + std::fs::write(base.join(".lian-jing/context.json"), r#"{"project_id":"p1","files_managed_by":"other"}"#).unwrap(); + assert!(!files_managed_by_next(&base)); + let _ = std::fs::remove_dir_all(&base); + } + #[test] fn canonical_context_migrates_legacy_project_id_and_preserves_unknown_fields() { let base = std::env::temp_dir().join(format!(