From 167fcf9880a978e7e2d62328f04184856058e427 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Mon, 21 Sep 2026 18:16:32 +0900 Subject: [PATCH] feat(agent-context): add canonical project context Feature-Confirmed: true --- .blueprint/manifest.yaml | 14 ++ .../modules/agent-client-capability-packs.md | 55 ++++++ docs/ai-context/project-brief.md | 3 +- docs/ai-context/project-context.yaml | 7 + src-tauri/src/mcp_inject.rs | 185 ++++++++++++++---- 5 files changed, 222 insertions(+), 42 deletions(-) create mode 100644 .blueprint/modules/agent-client-capability-packs.md diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index 0895d28..6a50064 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -459,6 +459,14 @@ modules: progress: 14 note: "P1 已完成:只读交付信号聚合(Git / PR / CI / review / 部署 unknown),不改变 Enterprise 既有 production 自动部署;后续受控执行按需推进。" position: [2000, 800] + + - id: agent-client-capability-packs + name: Agent 客户端能力包 + area: infra + status: in_progress + progress: 0 + note: "P2:以工具无关项目上下文为锚点,收敛 Codex / Claude 客户端差异;不复制 MCP、凭证或部署能力。" + position: [2250, 800] edges: # 前端依赖 - from: workspace-mgmt @@ -750,3 +758,9 @@ edges: - from: project-capability-control-plane to: runtime-diagnostics type: dependency + - from: onboarding-pack + to: agent-client-capability-packs + type: dependency + - from: project-capability-control-plane + to: agent-client-capability-packs + type: related diff --git a/.blueprint/modules/agent-client-capability-packs.md b/.blueprint/modules/agent-client-capability-packs.md new file mode 100644 index 0000000..222d72c --- /dev/null +++ b/.blueprint/modules/agent-client-capability-packs.md @@ -0,0 +1,55 @@ +--- +id: agent-client-capability-packs +name: Agent 客户端能力包 +status: planned +progress: 0 +--- + +## 概述 + +炼境为项目提供稳定的本地控制面与信号;客户端能力包只负责将同一项目规则、MCP 与工作流投影给 Codex、Claude 等 Agent。项目根目录与 canonical context 是锚点,不能把任一客户端的项目 UI、插件安装状态或全局配置当作项目身份。 + +## 已确认边界 + +``` +项目根目录 +├── .lian-jing/context.json ← canonical project_id 与兼容版本 +├── .codex/config.toml ← 项目级 Codex MCP 投影(P0 已有) +├── .claude/liangjing.json ← 历史兼容投影 +└── .agents/skills/lian-jing/ ← 项目内工作流说明 + +Codex 用户级插件 +└── Skill-only:指导读取项目状态并调用已存在的 MCP;不存 project_id、MCP URL、凭证或部署命令 +``` + +不在本模块实现 IDE UI、客户端项目同步、staging、云部署或生产发布。手工定义的同名 MCP server 必须继续拒绝覆盖。 + +## 任务卡 + +### ✅ P2-A. 工具无关的 canonical 项目上下文与兼容迁移 +- status: done +- complexity: M +- files: src-tauri/src/mcp_inject.rs, src-tauri/src/commands/onboarding.rs, src-tauri/src/mcp_tools.rs, docs/ai-context/project-context.yaml +- acceptance: "[cargo test] 对仅含 `.claude/liangjing.json` 的临时项目执行接入/验证 → 写入 `.lian-jing/context.json` 且 project_id 一致;已有 context 的未知字段保留;手工 `[mcp_servers.lian-jing]` 配置仍拒绝覆盖。" +- done_note: "2026-09-21:inject_mcp 先写 `.lian-jing/context.json`(schema_version=1、project_id),再写 Codex/Claude 投影;canonical 文件保留未知字段,旧 Claude context 继续保留运行时端口。" + +### 📋 P2-B. 能力包描述符与 onboarding 自检报告 +- status: todo +- complexity: M +- depends: P2-A. 工具无关的 canonical 项目上下文与兼容迁移 +- files: src-tauri/src/commands/onboarding.rs, src-tauri/src/mcp_tools.rs, src-tauri/resources/onboarding/, docs/ai-context/project-context.yaml +- acceptance: "[cargo test] verify_onboarding(project_id) → 对 canonical 缺失、legacy-only、pack 版本不符、Codex MCP 不可用分别返回可区分的 ready/missing/legacy/version_mismatch/unavailable 信号,且不将任一状态伪报为 ready。" + +### 📋 P2-C. Codex Skill-only 可移植能力包 +- status: todo +- complexity: M +- depends: P2-B. 能力包描述符与 onboarding 自检报告 +- files: plugins/lian-jing-codex/plugin.json, plugins/lian-jing-codex/.codex-plugin/plugin.json, plugins/lian-jing-codex/skills/lian-jing/SKILL.md, plugins/README.md +- acceptance: "[插件结构检查] manifest 与 skill 路径均在插件根目录内,skill 不含 project_id、127.0.0.1 MCP URL、凭证或部署命令;[新 Codex 会话] 已接入项目可按 skill 调用既有 MCP,未接入项目输出接入诊断而非虚构状态。" + +### 📋 P2-D. 接入包版本、升级说明与架构防腐同步 +- status: todo +- complexity: M +- depends: P2-B. 能力包描述符与 onboarding 自检报告, P2-C. Codex Skill-only 可移植能力包 +- files: AGENTS.md, docs/ai-context/project-brief.md, docs/ai-context/project-context.yaml, docs/templates/agents-meta-rules.md, resources/onboarding/ +- acceptance: "[cargo test] 接入包升级检查 → 旧 context/旧能力包给出明确 upgrade_hint 且不自动覆盖用户配置;[rg] 常驻架构文档将炼境描述为项目控制面、客户端描述为 adapter,且不把客户端插件表述为凭证或部署所有者。" diff --git a/docs/ai-context/project-brief.md b/docs/ai-context/project-brief.md index 800170c..5bc993c 100644 --- a/docs/ai-context/project-brief.md +++ b/docs/ai-context/project-brief.md @@ -9,7 +9,7 @@ | 前端 | React 19 + TypeScript + TailwindCSS + React Query + Zustand + React Flow | | 后端 | Rust + Tauri v2 | | 数据 | SQLite(rusqlite + r2d2) | -| MCP | 内嵌 HTTP Server(供 Claude Code 调用) | +| MCP | 内嵌 HTTP Server(供 Claude Code、Codex 等客户端调用) | | 包管理 | pnpm | ## 关键约定(改代码前必看) @@ -18,6 +18,7 @@ 2. **DB 变更必须有 migration**:在 `src-tauri/src/db.rs` 的 `migrate()` 里补语句,否则旧版本升级报错 3. **Rust 业务函数依赖注入**:禁止内部 `pool().get()`,连接由调用方传入 4. **接入包模板 = 分发给被管理项目的规则**:改 `resources/onboarding/` 务必同步 `docs/templates/` +5. **项目身份上下文**:`.lian-jing/context.json` 是工具无关的 `project_id` 单一事实来源;`.claude/liangjing.json` 仅保留为 Claude 兼容投影 ## 目录速查 diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index 4badfe1..a3482ff 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -15,6 +15,13 @@ contracts: - "src-tauri/src/commands/gitea.rs" doc: "工具 schema、dispatch 与调用说明必须同步;外部来源不可用时返回显式降级状态,不得伪造成功" + - label: 被管理项目身份上下文 + paths: + - "src-tauri/src/mcp_inject.rs" + - ".lian-jing/context.json" + - ".claude/liangjing.json" + doc: "`.lian-jing/context.json` 是 project_id 的工具无关单一事实来源;`.claude/liangjing.json` 仅为兼容投影。迁移必须保留未知字段,且不得以客户端全局配置或项目 UI 身份替代项目根目录上下文。" + - label: Tauri command 接口(前后端契约) paths: - "src-tauri/src/commands/**/*.rs" diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index 1f87d5d..552021e 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -8,6 +8,7 @@ const MCP_KEY: &str = "lian-jing"; const LEGACY_KEY_PREFIX: &str = "lian-jing:group-"; const CODEX_MCP_MANAGED_START: &str = "# LIANJING_MCP_MANAGED_START"; const CODEX_MCP_MANAGED_END: &str = "# LIANJING_MCP_MANAGED_END"; +const CONTEXT_SCHEMA_VERSION: u8 = 1; // ── 路径解析 ────────────────────────────────────────────────────────────────── @@ -44,8 +45,7 @@ pub(crate) fn project_root_with_conn( |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), ); - let (win_path, wsl_path, platform) = - row.map_err(|_| format!("项目 {} 不存在", project_id))?; + let (win_path, wsl_path, platform) = row.map_err(|_| format!("项目 {} 不存在", project_id))?; if platform.as_deref() == Some("wsl") { let wsl = wsl_path.ok_or_else(|| "WSL 项目未配置 wsl_path".to_string())?; @@ -71,38 +71,75 @@ pub(crate) fn project_root_with_conn( } fn settings_path(project_id: &str) -> Result { - Ok(project_root(project_id)?.join(".claude").join("settings.json")) + Ok(project_root(project_id)? + .join(".claude") + .join("settings.json")) } fn liangjing_path(project_id: &str) -> Result { - Ok(project_root(project_id)?.join(".claude").join("liangjing.json")) + Ok(project_root(project_id)? + .join(".claude") + .join("liangjing.json")) } + fn codex_config_path(project_id: &str) -> Result { Ok(project_root(project_id)?.join(".codex").join("config.toml")) } -/// 写炼境上下文文件,供子项目 Claude 会话直接读取 project_id。 -/// 合并写入:保留文件中已有的其他字段;主动删除 group_id(单一服务器寻址后已无用,留着会过期误导) -fn write_liangjing_context(project_id: &str, port: u16) -> Result<(), String> { - let path = liangjing_path(project_id)?; +fn read_context_object(path: &PathBuf, label: &str) -> Result { + match std::fs::read_to_string(path) { + Ok(content) if content.trim().is_empty() => Ok(json!({})), + Ok(content) => serde_json::from_str::(&content) + .map_err(|error| format!("读取 {label} 失败({}): {error}", path.display())) + .and_then(|value| { + if value.is_object() { + Ok(value) + } else { + Err(format!( + "{label} 顶层必须是 JSON 对象({})", + path.display() + )) + } + }), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(json!({})), + Err(error) => Err(format!("读取 {label} 失败({}): {error}", path.display())), + } +} + +fn write_context_object(path: &PathBuf, value: &Value, label: &str) -> Result<(), String> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) - .map_err(|e| format!("创建 .claude 目录失败: {}", e))?; + .map_err(|error| format!("创建 {label} 目录失败: {error}"))?; } - let mut ctx = match std::fs::read_to_string(&path) { - Ok(s) => serde_json::from_str::(&s) - .ok() - .filter(|v| v.is_object()) - .unwrap_or_else(|| json!({})), - Err(_) => json!({}), - }; + 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}")) +} + +/// 写工具无关的项目身份锚点。项目根目录是边界,客户端项目 UI 或用户级配置均不是身份来源。 +/// 合并写入:保留未来字段与项目定制字段,只有炼境拥有的身份字段会被刷新。 +fn write_canonical_context_at(root: &std::path::Path, project_id: &str) -> Result<(), String> { + let path = root.join(".lian-jing").join("context.json"); + let mut ctx = read_context_object(&path, "canonical 项目上下文")?; + let obj = ctx.as_object_mut().expect("ctx 必为 JSON 对象"); + obj.insert("schema_version".into(), json!(CONTEXT_SCHEMA_VERSION)); + obj.insert("project_id".into(), json!(project_id)); + write_context_object(&path, &ctx, "canonical 项目上下文") +} + +fn write_canonical_context(project_id: &str) -> Result<(), String> { + let root = project_root(project_id)?; + write_canonical_context_at(&root, project_id) +} + +/// 写旧 Claude 上下文投影。它不是项目身份的单一事实来源,保留是为了兼容已接入项目。 +fn write_liangjing_context(project_id: &str, port: u16) -> Result<(), String> { + let path = liangjing_path(project_id)?; + let mut ctx = read_context_object(&path, "Claude 兼容上下文")?; let obj = ctx.as_object_mut().expect("ctx 必为 JSON 对象"); obj.insert("project_id".into(), json!(project_id)); obj.insert("mcp_port".into(), json!(port)); obj.remove("group_id"); - let content = serde_json::to_string_pretty(&ctx).map_err(|e| e.to_string())?; - std::fs::write(&path, content) - .map_err(|e| format!("写入 liangjing.json 失败: {}", e)) + write_context_object(&path, &ctx, "Claude 兼容上下文") } /// 查找 claude CLI 路径(优先环境变量 CLAUDE_BIN,然后常用安装目录,最后 where 命令) @@ -130,7 +167,10 @@ fn find_claude_bin() -> Option { } // 最后尝试 where 命令(GUI 进程 PATH 可能不含用户 bin,but worth trying) - if let Ok(out) = std::process::Command::new("where").arg("claude.exe").output() { + if let Ok(out) = std::process::Command::new("where") + .arg("claude.exe") + .output() + { if out.status.success() { if let Ok(stdout) = String::from_utf8(out.stdout) { if let Some(line) = stdout.lines().next() { @@ -257,11 +297,21 @@ fn try_register_globally(port: u16) -> bool { /// 幂等 best-effort:不存在的名字 remove 失败仅忽略。返回成功移除的名字列表。 pub fn cleanup_legacy_global_registrations() -> Vec { let Some(claude_bin) = find_claude_bin() else { - db::log_event("mcp_inject", "warn", "claude CLI 未找到,跳过旧注册清理", None); + db::log_event( + "mcp_inject", + "warn", + "claude CLI 未找到,跳过旧注册清理", + None, + ); return Vec::new(); }; let Some(git_bash) = find_git_bash() else { - db::log_event("mcp_inject", "warn", "git-bash 未找到,跳过旧注册清理", None); + db::log_event( + "mcp_inject", + "warn", + "git-bash 未找到,跳过旧注册清理", + None, + ); return Vec::new(); }; let Ok(conn) = db::pool().get() else { @@ -303,8 +353,8 @@ fn read_json(path: &PathBuf) -> Result { if !path.exists() { return Ok(json!({})); } - let content = std::fs::read_to_string(path) - .map_err(|e| format!("读取 settings.json 失败: {}", e))?; + let content = + std::fs::read_to_string(path).map_err(|e| format!("读取 settings.json 失败: {}", e))?; if content.trim().is_empty() { return Ok(json!({})); } @@ -319,8 +369,7 @@ fn read_json(path: &PathBuf) -> Result { fn write_json(path: &PathBuf, value: &Value) -> Result<(), String> { if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| format!("创建 .claude 目录失败: {}", e))?; + 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)) @@ -346,10 +395,12 @@ fn upsert_codex_mcp_config(path: &PathBuf, server_url: &str) -> Result<(), Strin block, ¤t[end + CODEX_MCP_MANAGED_END.len()..] ), - (Some(_), _) | (_, Some(_)) => return Err(format!( - "{} 的炼境 Codex 管理块不完整或顺序错误,请修复标记后重试", - path.display() - )), + (Some(_), _) | (_, Some(_)) => { + return Err(format!( + "{} 的炼境 Codex 管理块不完整或顺序错误,请修复标记后重试", + path.display() + )) + } (None, None) if current .lines() @@ -394,7 +445,11 @@ fn remove_codex_mcp_config(path: &PathBuf) -> Result<(), String> { ) .trim_matches(['\r', '\n']) .to_string(); - let output = if next.is_empty() { String::new() } else { format!("{next}\n") }; + let output = if next.is_empty() { + String::new() + } else { + format!("{next}\n") + }; std::fs::write(path, output).map_err(|error| format!("移除 Codex MCP 配置失败: {error}")) } @@ -405,6 +460,7 @@ fn remove_codex_mcp_config(path: &PathBuf) -> Result<(), String> { pub fn inject_project_mcp(project_id: &str) -> Result<(), String> { let port = crate::mcp_server::read_port(); let server_url = format!("http://127.0.0.1:{}/mcp", port); + write_canonical_context(project_id)?; upsert_codex_mcp_config(&codex_config_path(project_id)?, &server_url)?; let path = settings_path(project_id)?; let mut settings = read_json(&path)?; @@ -432,8 +488,8 @@ pub fn inject_project_mcp(project_id: &str) -> Result<(), String> { log_event("mcp_inject", "info", "MCP 配置注入成功", Some(&ctx)); })?; - // 写炼境上下文,让子项目 Claude 会话无需遍历即可知道 project_id - let _ = write_liangjing_context(project_id, port); + // 保留 Claude 历史上下文作为兼容投影;项目身份以 .lian-jing/context.json 为准。 + write_liangjing_context(project_id, port)?; // 同步注册到 ~/.claude.json,让 Claude Code 能加载 MCP 工具(best-effort) try_register_globally(port); @@ -492,7 +548,11 @@ pub fn migrate_to_single_server() { format!(";失败详情: {}", errors.join("; ")) } ); - let level = if errors.is_empty() && register_ok { "info" } else { "warn" }; + let level = if errors.is_empty() && register_ok { + "info" + } else { + "warn" + }; db::log_event("mcp_inject", level, &summary, None); // 全局注册失败说明 claude CLI 链路不通(CLI 缺失/git-bash 缺失/命令报错), @@ -544,8 +604,14 @@ mod tests { /// 炼境进程 cwd(src-tauri/,2026-07-15 实锤,apply 链路同日已拦本处漏拦)。 #[test] fn registered_path_rejects_empty_and_relative() { - assert!(validate_registered_path("", "win_path").is_err(), "空串应拒绝"); - assert!(validate_registered_path(" ", "win_path").is_err(), "空白应拒绝"); + assert!( + validate_registered_path("", "win_path").is_err(), + "空串应拒绝" + ); + assert!( + validate_registered_path(" ", "win_path").is_err(), + "空白应拒绝" + ); assert!( validate_registered_path("src-tauri", "win_path").is_err(), "相对路径应拒绝(相对炼境 cwd 解析)" @@ -554,10 +620,8 @@ mod tests { #[test] fn codex_mcp_config_preserves_user_content_and_rejects_handwritten_conflict() { - let base = std::env::temp_dir().join(format!( - "lianjing_codex_mcp_test_{}", - std::process::id() - )); + let base = + std::env::temp_dir().join(format!("lianjing_codex_mcp_test_{}", 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"); @@ -570,7 +634,10 @@ mod tests { assert!(configured.contains("url = \"http://127.0.0.1:27190/mcp\"")); remove_codex_mcp_config(&path).unwrap(); - assert_eq!(std::fs::read_to_string(&path).unwrap(), "model = \"gpt-6\"\n"); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + "model = \"gpt-6\"\n" + ); let manual = "[mcp_servers.lian-jing]\nurl = \"http://manual/mcp\"\n"; std::fs::write(&path, manual).unwrap(); @@ -579,6 +646,42 @@ mod tests { 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!( + "lianjing_canonical_context_test_{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(base.join(".claude")).unwrap(); + std::fs::write( + base.join(".claude/liangjing.json"), + r#"{"project_id":"legacy-project","mcp_port":27190}"#, + ) + .unwrap(); + std::fs::create_dir_all(base.join(".lian-jing")).unwrap(); + std::fs::write( + base.join(".lian-jing/context.json"), + r#"{"project_setting":"preserve-me"}"#, + ) + .unwrap(); + + write_canonical_context_at(&base, "legacy-project").unwrap(); + let context: Value = serde_json::from_str( + &std::fs::read_to_string(base.join(".lian-jing/context.json")).unwrap(), + ) + .unwrap(); + assert_eq!(context["schema_version"], CONTEXT_SCHEMA_VERSION); + assert_eq!(context["project_id"], "legacy-project"); + assert_eq!(context["project_setting"], "preserve-me"); + assert!( + !context.as_object().unwrap().contains_key("mcp_port"), + "运行时端口不应进入工具无关的 canonical context" + ); + + let _ = std::fs::remove_dir_all(&base); + } + #[test] fn registered_path_accepts_absolute_and_unc() { assert!(validate_registered_path("E:\\code-project\\demo", "win_path").is_ok());