From 167fcf9880a978e7e2d62328f04184856058e427 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Mon, 21 Sep 2026 18:16:32 +0900 Subject: [PATCH 1/5] 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()); -- 2.45.2 From 122864f340dd0e0a8b89645d739d01bcdfb32671 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Mon, 21 Sep 2026 18:33:12 +0900 Subject: [PATCH 2/5] feat(onboarding): add capability pack status Feature-Confirmed: true --- .../modules/agent-client-capability-packs.md | 5 +- docs/ai-context/project-context.yaml | 4 +- .../.lian-jing/capability-packs.json | 17 + src-tauri/src/commands/onboarding.rs | 542 ++++++++++++++---- src-tauri/src/mcp_tools.rs | 2 +- 5 files changed, 461 insertions(+), 109 deletions(-) create mode 100644 src-tauri/resources/onboarding/.lian-jing/capability-packs.json diff --git a/.blueprint/modules/agent-client-capability-packs.md b/.blueprint/modules/agent-client-capability-packs.md index 222d72c..1bbc52e 100644 --- a/.blueprint/modules/agent-client-capability-packs.md +++ b/.blueprint/modules/agent-client-capability-packs.md @@ -33,12 +33,13 @@ Codex 用户级插件 - 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 +### ✅ P2-B. 能力包描述符与 onboarding 自检报告 +- status: done - 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。" +- done_note: "2026-09-21:新增 `.lian-jing/capability-packs.json` 基线描述符;verify_onboarding 对项目上下文、能力包、Codex skill/MCP 和既有基线逐项返回结构化状态与可执行升级提示。" ### 📋 P2-C. Codex Skill-only 可移植能力包 - status: todo diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index a3482ff..febf87a 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -18,9 +18,11 @@ contracts: - label: 被管理项目身份上下文 paths: - "src-tauri/src/mcp_inject.rs" + - "src-tauri/src/commands/onboarding.rs" - ".lian-jing/context.json" + - ".lian-jing/capability-packs.json" - ".claude/liangjing.json" - doc: "`.lian-jing/context.json` 是 project_id 的工具无关单一事实来源;`.claude/liangjing.json` 仅为兼容投影。迁移必须保留未知字段,且不得以客户端全局配置或项目 UI 身份替代项目根目录上下文。" + doc: "`.lian-jing/context.json` 是 project_id 的工具无关单一事实来源;`.claude/liangjing.json` 仅为兼容投影;`.lian-jing/capability-packs.json` 是炼境分发基线的版本描述。迁移必须保留未知字段,验证必须返回显式状态,且不得以客户端全局配置或项目 UI 身份替代项目根目录上下文。" - label: Tauri command 接口(前后端契约) paths: diff --git a/src-tauri/resources/onboarding/.lian-jing/capability-packs.json b/src-tauri/resources/onboarding/.lian-jing/capability-packs.json new file mode 100644 index 0000000..27d4bb1 --- /dev/null +++ b/src-tauri/resources/onboarding/.lian-jing/capability-packs.json @@ -0,0 +1,17 @@ +{ + "schema_version": 1, + "packs": [ + { + "pack_id": "base-onboarding", + "version": "1.0.0", + "supported_clients": ["codex", "claude"], + "project_artifacts": [ + "AGENTS.md", + ".agents/skills/lian-jing/SKILL.md", + ".lian-jing/context.json" + ], + "self_check": "verify_onboarding", + "upgrade_hint": "调用 apply_onboarding_pack(project_id) 更新基线;项目身份上下文由 inject_mcp(project_id) 创建或迁移。" + } + ] +} diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index fae4797..5f02f9a 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -17,8 +17,15 @@ const TMPL_AGENTS: &str = include_str!("../../resources/onboarding/AGENTS.md.tmp const TMPL_LEFTHOOK: &str = include_str!("../../resources/onboarding/lefthook.yml.tmpl"); const TMPL_GIT_WORKFLOW: &str = include_str!("../../resources/onboarding/git-workflow.md.tmpl"); const TMPL_ENVOY: &str = include_str!("../../resources/onboarding/.claude/commands/envoy.md"); -const TMPL_BLUEPRINT_GATE: &str = include_str!("../../resources/onboarding/.claude/hooks/blueprint-gate.cjs"); -const TMPL_LIANJING_SKILL: &str = include_str!("../../resources/onboarding/.agents/skills/lian-jing/SKILL.md"); +const TMPL_BLUEPRINT_GATE: &str = + include_str!("../../resources/onboarding/.claude/hooks/blueprint-gate.cjs"); +const TMPL_LIANJING_SKILL: &str = + include_str!("../../resources/onboarding/.agents/skills/lian-jing/SKILL.md"); +const TMPL_CAPABILITY_PACKS: &str = + include_str!("../../resources/onboarding/.lian-jing/capability-packs.json"); +const PROJECT_CONTEXT_SCHEMA_VERSION: u64 = 1; +const CAPABILITY_PACK_SCHEMA_VERSION: u64 = 1; +const BASE_ONBOARDING_PACK_VERSION: &str = "1.0.0"; /// 单个模板的分发结果。 #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] @@ -134,29 +141,32 @@ fn dispatch_versioned(root: &Path, relative: &str, template: &str) -> TemplateRe } }; - let (new_content, action, detail) = if let (Some(s), Some(e)) = - (content.find(MANAGED_START), content.find(MANAGED_END)) - { - let old_ver = block_version(&content).unwrap_or("?").to_string(); - if old_ver == tpl_ver { - return TemplateResult { - name: relative.to_string(), - action: "skipped".into(), - detail: format!("管理块已是 v{tpl_ver}"), - }; - } - let mut nc = String::new(); - nc.push_str(&content[..s]); - nc.push_str(tpl_block); - nc.push_str(&content[e + MANAGED_END.len()..]); - (nc, "updated", format!("管理块 v{old_ver} → v{tpl_ver},块外内容未动")) - } else { - ( - format!("{}\n\n{}\n", content.trim_end(), tpl_block), - "appended", - format!("追加管理块 v{tpl_ver}(存量文件无标记,原内容保留在上方)"), - ) - }; + let (new_content, action, detail) = + if let (Some(s), Some(e)) = (content.find(MANAGED_START), content.find(MANAGED_END)) { + let old_ver = block_version(&content).unwrap_or("?").to_string(); + if old_ver == tpl_ver { + return TemplateResult { + name: relative.to_string(), + action: "skipped".into(), + detail: format!("管理块已是 v{tpl_ver}"), + }; + } + let mut nc = String::new(); + nc.push_str(&content[..s]); + nc.push_str(tpl_block); + nc.push_str(&content[e + MANAGED_END.len()..]); + ( + nc, + "updated", + format!("管理块 v{old_ver} → v{tpl_ver},块外内容未动"), + ) + } else { + ( + format!("{}\n\n{}\n", content.trim_end(), tpl_block), + "appended", + format!("追加管理块 v{tpl_ver}(存量文件无标记,原内容保留在上方)"), + ) + }; match std::fs::write(&target, new_content) { Ok(_) => TemplateResult { @@ -181,8 +191,21 @@ pub fn dispatch_templates(project_path: &str) -> Vec { dispatch_one(root, "lefthook.yml", TMPL_LEFTHOOK), dispatch_versioned(root, "docs/ai-context/git-workflow.md", TMPL_GIT_WORKFLOW), dispatch_fully_managed(root, ".claude/commands/envoy.md", TMPL_ENVOY), - dispatch_fully_managed(root, ".claude/hooks/blueprint-gate.cjs", TMPL_BLUEPRINT_GATE), - dispatch_fully_managed(root, ".agents/skills/lian-jing/SKILL.md", TMPL_LIANJING_SKILL), + dispatch_fully_managed( + root, + ".claude/hooks/blueprint-gate.cjs", + TMPL_BLUEPRINT_GATE, + ), + dispatch_fully_managed( + root, + ".agents/skills/lian-jing/SKILL.md", + TMPL_LIANJING_SKILL, + ), + dispatch_fully_managed( + root, + ".lian-jing/capability-packs.json", + TMPL_CAPABILITY_PACKS, + ), ] } @@ -220,7 +243,11 @@ fn run_lefthook_install(root: &Path) -> HookActivation { } for (program, source) in &attempts { - match crate::silent_cmd(program).arg("install").current_dir(root).output() { + match crate::silent_cmd(program) + .arg("install") + .current_dir(root) + .output() + { Ok(out) if out.status.success() => { return HookActivation { status: "activated".into(), @@ -275,7 +302,11 @@ pub fn apply_onboarding_pack( let templates = dispatch_templates(&project_path); let hook = run_lefthook_install(Path::new(project_path.trim_end_matches(['/', '\\']))); let buff = crate::commands::buff::apply_blueprint_buff(project_path, project_id)?; - Ok(OnboardingReport { templates, hook, buff }) + Ok(OnboardingReport { + templates, + hook, + buff, + }) } // ── V1:接入生效验收(verify_onboarding 检查链,判定逻辑纯函数化可单测)────── @@ -285,9 +316,170 @@ pub fn apply_onboarding_pack( pub struct VerifyCheck { pub name: String, pub pass: bool, + pub state: VerifyState, pub detail: String, } +/// 接入检查的可行动状态。只有 ready 才表示检查项通过;其余状态必须显式说明原因。 +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum VerifyState { + Ready, + Missing, + Legacy, + VersionMismatch, + Unavailable, +} + +impl VerifyCheck { + fn with_state(name: impl Into, state: VerifyState, detail: impl Into) -> Self { + let pass = state == VerifyState::Ready; + Self { + name: name.into(), + pass, + state, + detail: detail.into(), + } + } + + fn from_pass(name: impl Into, pass: bool, detail: impl Into) -> Self { + Self::with_state( + name, + if pass { + VerifyState::Ready + } else { + VerifyState::Missing + }, + detail, + ) + } +} + +fn check_project_context( + canonical: Option<&str>, + legacy: Option<&str>, + expected_project_id: &str, +) -> VerifyCheck { + let parse_object = |content: &str| serde_json::from_str::(content).ok(); + if canonical.is_some() && canonical.and_then(parse_object).is_none() { + return VerifyCheck::with_state( + "项目身份上下文", + VerifyState::Unavailable, + ".lian-jing/context.json 不是有效 JSON——请修复后重试 inject_mcp", + ); + } + match canonical.and_then(parse_object) { + Some(context) if !context.is_object() => VerifyCheck::with_state( + "项目身份上下文", + VerifyState::Unavailable, + ".lian-jing/context.json 顶层不是 JSON 对象——请修复后重试 inject_mcp", + ), + Some(context) => { + let version = context + .get("schema_version") + .and_then(|value| value.as_u64()); + let project_id = context.get("project_id").and_then(|value| value.as_str()); + if version != Some(PROJECT_CONTEXT_SCHEMA_VERSION) { + VerifyCheck::with_state( + "项目身份上下文", + VerifyState::VersionMismatch, + format!( + "context schema_version={:?},当前为 {}——调用 inject_mcp 可升级", + version, PROJECT_CONTEXT_SCHEMA_VERSION + ), + ) + } else if project_id == Some(expected_project_id) { + VerifyCheck::with_state( + "项目身份上下文", + VerifyState::Ready, + "`.lian-jing/context.json` 与已登记 project_id 一致", + ) + } else { + VerifyCheck::with_state( + "项目身份上下文", + VerifyState::Unavailable, + "`.lian-jing/context.json` 的 project_id 与炼境登记不一致——停止使用并重新 inject_mcp", + ) + } + } + None if legacy + .and_then(parse_object) + .and_then(|value| { + value + .get("project_id") + .and_then(|value| value.as_str()) + .map(str::to_owned) + }) + .as_deref() + == Some(expected_project_id) => + { + VerifyCheck::with_state( + "项目身份上下文", + VerifyState::Legacy, + "仅发现 `.claude/liangjing.json`——调用 inject_mcp 迁移到 `.lian-jing/context.json`", + ) + } + None if legacy.is_some() => VerifyCheck::with_state( + "项目身份上下文", + VerifyState::Unavailable, + "`.claude/liangjing.json` 无有效 project_id——请重新 inject_mcp", + ), + None => VerifyCheck::with_state( + "项目身份上下文", + VerifyState::Missing, + "缺少 `.lian-jing/context.json`——调用 inject_mcp 创建项目身份上下文", + ), + } +} + +fn check_capability_packs(content: Option<&str>) -> VerifyCheck { + let Some(content) = content else { + return VerifyCheck::with_state( + "基础能力包描述符", + VerifyState::Missing, + "缺少 `.lian-jing/capability-packs.json`——重跑接入包可写入", + ); + }; + let Ok(descriptor) = serde_json::from_str::(content) else { + return VerifyCheck::with_state( + "基础能力包描述符", + VerifyState::Unavailable, + "能力包描述符不是有效 JSON——请修复后重跑接入包", + ); + }; + let schema_version = descriptor + .get("schema_version") + .and_then(|value| value.as_u64()); + let version = descriptor + .get("packs") + .and_then(|value| value.as_array()) + .and_then(|packs| { + packs.iter().find(|pack| { + pack.get("pack_id").and_then(|value| value.as_str()) == Some("base-onboarding") + }) + }) + .and_then(|pack| pack.get("version")) + .and_then(|value| value.as_str()); + if schema_version == Some(CAPABILITY_PACK_SCHEMA_VERSION) + && version == Some(BASE_ONBOARDING_PACK_VERSION) + { + VerifyCheck::with_state( + "基础能力包描述符", + VerifyState::Ready, + format!("base-onboarding v{BASE_ONBOARDING_PACK_VERSION}(最新)"), + ) + } else { + VerifyCheck::with_state( + "基础能力包描述符", + VerifyState::VersionMismatch, + format!( + "能力包 schema={schema_version:?}、base-onboarding={version:?};当前应为 schema={}、v{}——重跑接入包可升级", + CAPABILITY_PACK_SCHEMA_VERSION, BASE_ONBOARDING_PACK_VERSION + ), + ) + } +} + /// 纯函数:pre-commit hook 内容是否由 lefthook 接管。 fn hook_is_lefthook(content: &str) -> bool { content.contains("lefthook") @@ -299,7 +491,9 @@ fn parse_origin_url(git_config: &str) -> Option { for line in git_config.lines() { let t = line.trim(); if t.starts_with('[') { - in_origin = t.replace(' ', "").eq_ignore_ascii_case("[remote\"origin\"]"); + in_origin = t + .replace(' ', "") + .eq_ignore_ascii_case("[remote\"origin\"]"); continue; } if in_origin { @@ -340,15 +534,24 @@ fn check_codex_mcp(content: Option<&str>) -> (bool, String) { { (true, "lian-jing 指向本地 MCP 服务".into()) } - Some(_) => (false, "未找到炼境的 Codex MCP 配置——调用 inject_mcp 修复".into()), - None => (false, ".codex/config.toml 缺失——调用 inject_mcp 后生成".into()), + Some(_) => ( + false, + "未找到炼境的 Codex MCP 配置——调用 inject_mcp 修复".into(), + ), + None => ( + false, + ".codex/config.toml 缺失——调用 inject_mcp 后生成".into(), + ), } } fn check_skill(content: Option<&str>) -> (bool, String) { match content { Some(content) if content.contains("name: lian-jing") => (true, "炼境项目技能已分发".into()), - _ => (false, "缺少 .agents/skills/lian-jing/SKILL.md——重跑接入包".into()), + _ => ( + false, + "缺少 .agents/skills/lian-jing/SKILL.md——重跑接入包".into(), + ), } } @@ -356,7 +559,10 @@ fn check_skill(content: Option<&str>) -> (bool, String) { fn check_managed_version(content: Option<&str>, expected: &str) -> (bool, String) { match content.and_then(block_version) { Some(v) if v == expected => (true, format!("管理块 v{v}(最新)")), - Some(v) => (false, format!("管理块 v{v},最新模板 v{expected},重跑接入包可升级")), + Some(v) => ( + false, + format!("管理块 v{v},最新模板 v{expected},重跑接入包可升级"), + ), None => (false, "文件缺失或无管理块标记".into()), } } @@ -373,7 +579,10 @@ fn check_origin(origin_host: Option<&str>, instance_hosts: &[String]) -> (bool, } Some(h) => ( false, - format!("origin={h},不在已登记 Gitea 实例({})中", instance_hosts.join(", ")), + format!( + "origin={h},不在已登记 Gitea 实例({})中", + instance_hosts.join(", ") + ), ), } } @@ -430,7 +639,7 @@ fn check_lefthook_keys(project_yml: Option<&str>, template_yml: &str) -> (bool, } } -/// 接入生效验收:七项检查链。分发成功 ≠ 功能生效,本函数验证"真的用起来了"。 +/// 接入生效验收:检查链覆盖项目身份、能力包和既有接入基线。分发成功 ≠ 功能生效。 /// conn 由调用方注入(R06)。 pub fn verify_onboarding( conn: &rusqlite::Connection, @@ -449,27 +658,44 @@ pub fn verify_onboarding( "git-workflow 管理块版本(含交付状态调用说明)", ), ] { - let expected = extract_managed_block(tmpl).and_then(block_version).unwrap_or("?"); + let expected = extract_managed_block(tmpl) + .and_then(block_version) + .unwrap_or("?"); let (pass, detail) = check_managed_version(read(rel).as_deref(), expected); - checks.push(VerifyCheck { name: label.into(), pass, detail }); + checks.push(VerifyCheck::from_pass(label, pass, detail)); } + checks.push(check_project_context( + read(".lian-jing/context.json").as_deref(), + read(".claude/liangjing.json").as_deref(), + project_id, + )); + checks.push(check_capability_packs( + read(".lian-jing/capability-packs.json").as_deref(), + )); + let (pass, detail) = check_skill(read(".agents/skills/lian-jing/SKILL.md").as_deref()); - checks.push(VerifyCheck { name: "Codex 炼境技能已分发".into(), pass, detail }); + checks.push(VerifyCheck::from_pass("Codex 炼境技能已分发", pass, detail)); let (pass, detail) = check_codex_mcp(read(".codex/config.toml").as_deref()); - checks.push(VerifyCheck { name: "Codex MCP 已配置".into(), pass, detail }); + checks.push(VerifyCheck::from_pass("Codex MCP 已配置", pass, detail)); // 3 lefthook hook 真挂在 .git/hooks let (pass, detail) = match read(".git/hooks/pre-commit") { Some(c) if hook_is_lefthook(&c) => (true, "pre-commit 由 lefthook 接管".to_string()), - Some(_) => (false, "pre-commit 存在但非 lefthook(被其他工具占用?)".to_string()), - None => (false, "无 pre-commit hook——lefthook install 未执行".to_string()), + Some(_) => ( + false, + "pre-commit 存在但非 lefthook(被其他工具占用?)".to_string(), + ), + None => ( + false, + "无 pre-commit hook——lefthook install 未执行".to_string(), + ), }; - checks.push(VerifyCheck { name: "lefthook hook 挂载".into(), pass, detail }); + checks.push(VerifyCheck::from_pass("lefthook hook 挂载", pass, detail)); // 4 lefthook 检查段齐全(期望键从内嵌模板自省,U1) let (pass, detail) = check_lefthook_keys(read("lefthook.yml").as_deref(), TMPL_LEFTHOOK); - checks.push(VerifyCheck { name: "lefthook 检查段齐全".into(), pass, detail }); + checks.push(VerifyCheck::from_pass("lefthook 检查段齐全", pass, detail)); // 5 origin 指向已登记 Gitea 实例 let origin_host = read(".git/config") @@ -488,7 +714,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 }); + checks.push(VerifyCheck::from_pass("origin 指向 Gitea", pass, detail)); // 6 CI workflow 存在 let yml_count = std::fs::read_dir(root.join(".gitea/workflows")) @@ -503,15 +729,15 @@ pub fn verify_onboarding( .count() }) .unwrap_or(0); - checks.push(VerifyCheck { - name: "CI workflow 存在".into(), - pass: yml_count > 0, - detail: if yml_count > 0 { + checks.push(VerifyCheck::from_pass( + "CI workflow 存在", + yml_count > 0, + if yml_count > 0 { format!(".gitea/workflows/ 有 {yml_count} 个 workflow") } else { "无 .gitea/workflows/*.yml——可调 distribute_cicd 分发".into() }, - }); + )); // 7 飞轮数据在流入(近 30 天 commit_metrics) let recent: i64 = conn @@ -521,15 +747,15 @@ pub fn verify_onboarding( |r| r.get(0), ) .unwrap_or(0); - checks.push(VerifyCheck { - name: "飞轮 commit 数据流入".into(), - pass: recent > 0, - detail: if recent > 0 { + checks.push(VerifyCheck::from_pass( + "飞轮 commit 数据流入", + recent > 0, + if recent > 0 { format!("近 30 天 {recent} 条 commit 已采集") } else { "近 30 天无数据——确认 buff 已挂且有 commit 活动(或调 poll_now)".into() }, - }); + )); checks } @@ -577,8 +803,8 @@ mod tests { #[test] fn writes_then_skips_never_overwrites() { - let base = std::env::temp_dir() - .join(format!("lianjing_onboarding_test_{}", std::process::id())); + let base = + std::env::temp_dir().join(format!("lianjing_onboarding_test_{}", std::process::id())); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&base).unwrap(); let pp = base.to_str().unwrap(); @@ -608,8 +834,8 @@ mod tests { #[test] fn versioned_dispatch_three_states() { - let base = std::env::temp_dir() - .join(format!("lianjing_onb_ver_test_{}", std::process::id())); + let base = + std::env::temp_dir().join(format!("lianjing_onb_ver_test_{}", std::process::id())); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&base).unwrap(); let pp = base.to_str().unwrap(); @@ -631,13 +857,13 @@ mod tests { .and_then(block_version) .expect("模板必须带版本标记"); let old = agents + .replace(&format!("version=\"{tpl_ver}\""), "version=\"1.0.0\"") .replace( - &format!("version=\"{tpl_ver}\""), - "version=\"1.0.0\"", - ) - .replace("## 项目概况", "## 项目概况 + "## 项目概况", + "## 项目概况 -我的项目说明") +我的项目说明", + ) + " ## 用户自加的章节 内容 @@ -651,14 +877,24 @@ mod tests { upgraded.contains(&format!("version=\"{tpl_ver}\"")), "块应升级到模板版本" ); - assert!(upgraded.contains("我的项目说明"), "块外用户内容(前)须保留"); - assert!(upgraded.contains("用户自加的章节"), "块外用户内容(后)须保留"); + assert!( + upgraded.contains("我的项目说明"), + "块外用户内容(前)须保留" + ); + assert!( + upgraded.contains("用户自加的章节"), + "块外用户内容(后)须保留" + ); assert_eq!(upgraded.matches(MANAGED_START).count(), 1, "只有一个管理块"); // 态三:存量文件无标记 → appended,原内容保留在上方 - std::fs::write(base.join("AGENTS.md"), "# 手写的 AGENTS + std::fs::write( + base.join("AGENTS.md"), + "# 手写的 AGENTS -自定义内容").unwrap(); +自定义内容", + ) + .unwrap(); let r4 = dispatch_templates(pp); let agents_r = r4.iter().find(|t| t.name == "AGENTS.md").unwrap(); assert_eq!(agents_r.action, "appended"); @@ -677,8 +913,14 @@ mod tests { #[test] fn hook_detection_covers_mounted_and_foreign() { - assert!(hook_is_lefthook("#!/bin/sh\ncall lefthook run pre-commit"), "lefthook 接管应判 true"); - assert!(!hook_is_lefthook("#!/bin/sh\nnpx husky run"), "非 lefthook hook 应判 false"); + assert!( + hook_is_lefthook("#!/bin/sh\ncall lefthook run pre-commit"), + "lefthook 接管应判 true" + ); + assert!( + !hook_is_lefthook("#!/bin/sh\nnpx husky run"), + "非 lefthook hook 应判 false" + ); } #[test] @@ -689,41 +931,109 @@ mod tests { Some("https://git.example.com/o/r.git"), "应取 origin 而非其他 remote" ); - assert_eq!(parse_origin_url("[core]\n\tbare = false"), None, "无 origin 应返回 None"); + assert_eq!( + parse_origin_url("[core]\n\tbare = false"), + None, + "无 origin 应返回 None" + ); - assert_eq!(url_host("https://git.example.com/o/r.git").as_deref(), Some("git.example.com")); - assert_eq!(url_host("https://user@git.example.com:3000/o/r.git").as_deref(), Some("git.example.com"), "应忽略用户名与端口"); - assert_eq!(url_host("git@git.example.com:o/r.git").as_deref(), Some("git.example.com"), "ssh 形式"); + assert_eq!( + url_host("https://git.example.com/o/r.git").as_deref(), + Some("git.example.com") + ); + assert_eq!( + url_host("https://user@git.example.com:3000/o/r.git").as_deref(), + Some("git.example.com"), + "应忽略用户名与端口" + ); + assert_eq!( + url_host("git@git.example.com:o/r.git").as_deref(), + Some("git.example.com"), + "ssh 形式" + ); } #[test] fn origin_check_match_and_mismatch() { let hosts = vec!["git.example.com".to_string()]; - assert!(check_origin(Some("git.example.com"), &hosts).0, "命中实例应 pass"); + assert!( + check_origin(Some("git.example.com"), &hosts).0, + "命中实例应 pass" + ); assert!(!check_origin(Some("github.com"), &hosts).0, "未命中应 fail"); assert!(!check_origin(None, &hosts).0, "无 origin 应 fail"); - assert!(!check_origin(Some("git.example.com"), &[]).0, "无登记实例应 fail"); + assert!( + !check_origin(Some("git.example.com"), &[]).0, + "无登记实例应 fail" + ); + } + + #[test] + fn project_context_and_pack_checks_report_actionable_states() { + let ready = check_project_context( + Some(r#"{"schema_version":1,"project_id":"p1"}"#), + None, + "p1", + ); + assert_eq!(ready.state, VerifyState::Ready); + assert!(ready.pass); + + let legacy = check_project_context(None, Some(r#"{"project_id":"p1"}"#), "p1"); + assert_eq!(legacy.state, VerifyState::Legacy); + assert!(!legacy.pass); + + let mismatch = check_project_context( + Some(r#"{"schema_version":0,"project_id":"p1"}"#), + None, + "p1", + ); + assert_eq!(mismatch.state, VerifyState::VersionMismatch); + + let missing = check_project_context(None, None, "p1"); + assert_eq!(missing.state, VerifyState::Missing); + + let unavailable = check_project_context(Some("not-json"), None, "p1"); + assert_eq!(unavailable.state, VerifyState::Unavailable); + + let pack_ready = check_capability_packs(Some(TMPL_CAPABILITY_PACKS)); + assert_eq!(pack_ready.state, VerifyState::Ready); + let pack_old = check_capability_packs(Some(r#"{"schema_version":0,"packs":[]}"#)); + assert_eq!(pack_old.state, VerifyState::VersionMismatch); } #[test] fn verify_onboarding_full_chain() { let conn = crate::db::conn_with_schema(); - let base = std::env::temp_dir() - .join(format!("lianjing_verify_test_{}", std::process::id())); + let base = + std::env::temp_dir().join(format!("lianjing_verify_test_{}", std::process::id())); 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(), 9, "检查链应为九项"); + assert_eq!(checks.len(), 11, "检查链应为十一项"); assert!(checks.iter().all(|c| !c.pass), "空项目应全部不通过"); - // 铺齐全部生效条件后:七项全通过 + // 铺齐全部生效条件后:所有检查全通过 dispatch_templates(base.to_str().unwrap()); + std::fs::create_dir_all(base.join(".lian-jing")).unwrap(); + std::fs::write( + base.join(".lian-jing/context.json"), + r#"{"schema_version":1,"project_id":"p1"}"#, + ) + .unwrap(); std::fs::create_dir_all(base.join(".codex")).unwrap(); - std::fs::write(base.join(".codex/config.toml"), "[mcp_servers.lian-jing]\nurl = \"http://127.0.0.1:27190/mcp\"\n").unwrap(); + std::fs::write( + base.join(".codex/config.toml"), + "[mcp_servers.lian-jing]\nurl = \"http://127.0.0.1:27190/mcp\"\n", + ) + .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(); + std::fs::write( + base.join(".git/hooks/pre-commit"), + "call lefthook run pre-commit", + ) + .unwrap(); std::fs::write( base.join(".git/config"), "[remote \"origin\"]\n\turl = https://git.example.com/o/r.git\n", @@ -753,19 +1063,26 @@ mod tests { #[test] fn managed_version_check_states() { - let expected = extract_managed_block(TMPL_AGENTS).and_then(block_version).unwrap(); - assert!(check_managed_version(Some(TMPL_AGENTS), expected).0, "同版本应 pass"); - - let old = TMPL_AGENTS.replacen( - &format!("version=\"{expected}\""), - "version=\"0.0.1\"", - 1, + let expected = extract_managed_block(TMPL_AGENTS) + .and_then(block_version) + .unwrap(); + assert!( + check_managed_version(Some(TMPL_AGENTS), expected).0, + "同版本应 pass" ); + + let old = TMPL_AGENTS.replacen(&format!("version=\"{expected}\""), "version=\"0.0.1\"", 1); let (pass, detail) = check_managed_version(Some(&old), expected); - assert!(!pass && detail.contains("0.0.1"), "旧版本应 fail 且报出版本号"); + assert!( + !pass && detail.contains("0.0.1"), + "旧版本应 fail 且报出版本号" + ); assert!(!check_managed_version(None, expected).0, "文件缺失应 fail"); - assert!(!check_managed_version(Some("手写内容,无标记"), expected).0, "无标记应 fail"); + assert!( + !check_managed_version(Some("手写内容,无标记"), expected).0, + "无标记应 fail" + ); } /// 回归:空路径登记曾把接入包写进炼境进程 cwd(src-tauri/,2026-07-15 批量实调实锤)。 @@ -775,8 +1092,7 @@ mod tests { assert!(err.contains("拒绝分发"), "空路径应拒绝: {err}"); let err = apply_onboarding_pack(" ".into(), "p".into()).unwrap_err(); assert!(err.contains("拒绝分发"), "空白路径应拒绝: {err}"); - let err = - apply_onboarding_pack("E:/不存在的目录/xyz-404".into(), "p".into()).unwrap_err(); + let err = apply_onboarding_pack("E:/不存在的目录/xyz-404".into(), "p".into()).unwrap_err(); assert!(err.contains("拒绝分发"), "不存在路径应拒绝: {err}"); } @@ -784,8 +1100,7 @@ mod tests { #[test] fn fully_managed_dispatch_three_states() { - let base = std::env::temp_dir() - .join(format!("lianjing_fm_test_{}", std::process::id())); + 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(); @@ -804,15 +1119,18 @@ mod tests { 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 内容"); + 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 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(); @@ -821,9 +1139,15 @@ mod tests { // 模拟旧版本 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(); + 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(); + 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); @@ -833,10 +1157,16 @@ mod tests { 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.contains(&"typecheck".to_string()), + "模板应含 typecheck: {tpl_keys:?}" + ); assert!(tpl_keys.len() >= 3, "模板检查段应不止一个: {tpl_keys:?}"); // 注释掉的示例段不提取 - assert!(!tpl_keys.contains(&"api-typecheck".to_string()), "注释行不算键"); + assert!( + !tpl_keys.contains(&"api-typecheck".to_string()), + "注释行不算键" + ); // 项目文件与模板一致 → pass let (pass, _) = check_lefthook_keys(Some(TMPL_LEFTHOOK), TMPL_LEFTHOOK); @@ -847,7 +1177,10 @@ mod tests { 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}"); + assert!( + !detail.contains("typecheck,") && detail.contains("blueprint-gate"), + "已有键不在缺失列表: {detail}" + ); // 文件缺失 → fail assert!(!check_lefthook_keys(None, TMPL_LEFTHOOK).0); @@ -910,4 +1243,3 @@ mod tests { assert_eq!(silent.len(), 2, "pw-b 的回传过期后也应列为沉默"); } } - diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index 6adc304..f1d1705 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -262,7 +262,7 @@ pub fn tools_list_result() -> Value { }, { "name": "verify_onboarding", - "description": "接入生效验收:检查接入包在项目里是否真正生效(含 get_delivery_status 调用说明的模板管理块版本、lefthook hook 真挂在 .git/hooks、检查段对齐模板、origin 指向已登记 Gitea、CI workflow 存在、近 30 天飞轮 commit 数据流入)。分发成功 ≠ 功能生效,apply_onboarding_pack 之后应调用本工具做消费者角色验收。", + "description": "接入生效验收:检查接入包在项目里是否真正生效(含工具无关项目上下文、基础能力包版本、Codex MCP/skill、模板管理块、lefthook、origin、CI 与飞轮数据)。每项返回 ready / missing / legacy / version_mismatch / unavailable 的真实状态和升级提示;分发成功 ≠ 功能生效,apply_onboarding_pack 或 inject_mcp 之后应调用本工具验收。", "inputSchema": { "type": "object", "properties": { -- 2.45.2 From 250adfba24264d506bf241a4b7762fe2c74b9e65 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Mon, 21 Sep 2026 18:37:53 +0900 Subject: [PATCH 3/5] feat(codex-plugin): add lian-jing skill Feature-Confirmed: true --- .../modules/agent-client-capability-packs.md | 4 ++-- plugins/README.md | 5 +++++ .../lian-jing-codex/.codex-plugin/plugin.json | 18 ++++++++++++++++++ .../lian-jing-codex/skills/lian-jing/SKILL.md | 16 ++++++++++++++++ 4 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 plugins/README.md create mode 100644 plugins/lian-jing-codex/.codex-plugin/plugin.json create mode 100644 plugins/lian-jing-codex/skills/lian-jing/SKILL.md diff --git a/.blueprint/modules/agent-client-capability-packs.md b/.blueprint/modules/agent-client-capability-packs.md index 1bbc52e..b543e76 100644 --- a/.blueprint/modules/agent-client-capability-packs.md +++ b/.blueprint/modules/agent-client-capability-packs.md @@ -41,8 +41,8 @@ Codex 用户级插件 - acceptance: "[cargo test] verify_onboarding(project_id) → 对 canonical 缺失、legacy-only、pack 版本不符、Codex MCP 不可用分别返回可区分的 ready/missing/legacy/version_mismatch/unavailable 信号,且不将任一状态伪报为 ready。" - done_note: "2026-09-21:新增 `.lian-jing/capability-packs.json` 基线描述符;verify_onboarding 对项目上下文、能力包、Codex skill/MCP 和既有基线逐项返回结构化状态与可执行升级提示。" -### 📋 P2-C. Codex Skill-only 可移植能力包 -- status: todo +### 🔵 P2-C. Codex Skill-only 可移植能力包 +- status: in_progress - 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 diff --git a/plugins/README.md b/plugins/README.md new file mode 100644 index 0000000..5e41a1e --- /dev/null +++ b/plugins/README.md @@ -0,0 +1,5 @@ +# 炼境 Codex 插件源 + +`lian-jing-codex` 是 Skill-only 插件源码。它只指导 Codex 使用项目内已存在的炼境上下文与 MCP,不打包 MCP server、凭证、项目 ID 或部署能力。 + +项目身份始终来自 `.lian-jing/context.json`;Codex 的项目 UI、用户级插件安装和客户端全局配置都不是项目身份来源。 diff --git a/plugins/lian-jing-codex/.codex-plugin/plugin.json b/plugins/lian-jing-codex/.codex-plugin/plugin.json new file mode 100644 index 0000000..edac06d --- /dev/null +++ b/plugins/lian-jing-codex/.codex-plugin/plugin.json @@ -0,0 +1,18 @@ +{ + "name": "lian-jing-codex", + "version": "0.1.0", + "description": "Guide Codex through Lian Jing project context and existing MCP workflows.", + "author": { + "name": "Lian Jing" + }, + "skills": "./skills/", + "interface": { + "displayName": "炼境 · Codex", + "shortDescription": "在已接入项目中使用炼境工作流。", + "longDescription": "为 Codex 提供炼境项目上下文与既有 MCP 工作流指引;不携带凭证、项目身份或 MCP 配置。", + "developerName": "Lian Jing", + "category": "Productivity", + "capabilities": [], + "defaultPrompt": "检查当前项目的炼境接入状态,并在可用时使用已有 MCP 获取交付信号。" + } +} diff --git a/plugins/lian-jing-codex/skills/lian-jing/SKILL.md b/plugins/lian-jing-codex/skills/lian-jing/SKILL.md new file mode 100644 index 0000000..8347115 --- /dev/null +++ b/plugins/lian-jing-codex/skills/lian-jing/SKILL.md @@ -0,0 +1,16 @@ +--- +name: lian-jing-project +description: Inspect an already onboarded Lian Jing project, use its existing MCP tools for status, and preserve project-owned development decisions. Use when a Codex task needs Lian Jing onboarding or delivery context; do not use to configure credentials, deploy, or infer an unregistered project identity. +--- + +# 炼境项目上下文 + +在已接入炼境的项目中,需要读取接入状态、交付信号或提交分组建议时使用本 Skill。 + +1. 先读取项目根目录的 `AGENTS.md`、`.blueprint/` 规则和 `.lian-jing/context.json`。 +2. 仅将 `.lian-jing/context.json` 中的 `project_id` 用作炼境 MCP 调用参数。`.claude/liangjing.json` 只用于识别旧项目并提示迁移,不能作为长期身份来源。 +3. 当项目已有 `lian-jing` MCP 时,可调用 `get_diagnostics`、`verify_onboarding` 和 `get_delivery_status` 获取可观察信号;所有调用显式传入 `project_id`。 +4. 若缺少 canonical context、MCP 或项目登记,不得猜测 project_id、MCP URL、凭证或状态。说明缺失项,并建议通过炼境接入或 `inject_mcp` 修复。 +5. 炼境提供状态、凭证托管和受控入口;项目 Agent 仍负责代码判断、测试选择、提交分组与业务交付决策。不要把 CI 成功或部署信号表述为业务验收。 + +本插件不包含 MCP server、MCP URL、项目 ID、凭证、部署命令或客户端项目 UI 映射;这些都属于项目接入与炼境控制面。 -- 2.45.2 From f5b66aba4a0307344cf4a29eaf633a79cdbee72b Mon Sep 17 00:00:00 2001 From: lanrtop Date: Mon, 21 Sep 2026 19:23:54 +0900 Subject: [PATCH 4/5] feat(onboarding): version capability packs Feature-Confirmed: true --- .blueprint/manifest.yaml | 13 +++++++------ .../modules/agent-client-capability-packs.md | 14 ++++++++------ AGENTS.md | 8 ++++---- docs/ai-context/git-workflow.md | 8 +++++--- docs/ai-context/project-brief.md | 6 +++--- docs/ai-context/project-context.yaml | 2 +- docs/templates/agents-meta-rules.md | 10 ++++++++-- .../onboarding/.lian-jing/capability-packs.json | 4 ++-- src-tauri/resources/onboarding/AGENTS.md.tmpl | 9 ++++++++- .../resources/onboarding/git-workflow.md.tmpl | 8 +++++--- src-tauri/src/commands/onboarding.rs | 8 +++++++- 11 files changed, 58 insertions(+), 32 deletions(-) diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index 6a50064..a5148af 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -1,9 +1,9 @@ version: 1 name: 炼境 description: > - Git 多空间开发管理器。以蓝图为神经系统,以 Claude 为智能层, - 持续感知所管理项目的功能运转、质量状态与成长方向, - 成为每个项目的贴身导师。炼境本身亦被纳入其中, + Git 多空间开发管理器与项目 Agent 化的本地控制面。以蓝图为神经系统, + 为 Codex、Claude 等可替换客户端 adapter 分发基线、发现状态并采集交付信号; + 项目自身保留业务规则、测试、部署声明与交付决策。炼境本身亦被纳入其中, 形成可自我进化的开发智能闭环。 iteration: 1 updated: 2026-09-21 @@ -463,9 +463,10 @@ modules: - id: agent-client-capability-packs name: Agent 客户端能力包 area: infra - status: in_progress - progress: 0 - note: "P2:以工具无关项目上下文为锚点,收敛 Codex / Claude 客户端差异;不复制 MCP、凭证或部署能力。" + status: done + progress: 100 + note: "P2 完成:工具无关 canonical context、版本化能力包自检与 Codex Skill-only 投影已闭环;不复制 MCP、凭证或部署能力。" + updated: 2026-09-21 position: [2250, 800] edges: # 前端依赖 diff --git a/.blueprint/modules/agent-client-capability-packs.md b/.blueprint/modules/agent-client-capability-packs.md index b543e76..90cdd7c 100644 --- a/.blueprint/modules/agent-client-capability-packs.md +++ b/.blueprint/modules/agent-client-capability-packs.md @@ -1,8 +1,8 @@ --- id: agent-client-capability-packs name: Agent 客户端能力包 -status: planned -progress: 0 +status: done +progress: 100 --- ## 概述 @@ -41,16 +41,18 @@ Codex 用户级插件 - acceptance: "[cargo test] verify_onboarding(project_id) → 对 canonical 缺失、legacy-only、pack 版本不符、Codex MCP 不可用分别返回可区分的 ready/missing/legacy/version_mismatch/unavailable 信号,且不将任一状态伪报为 ready。" - done_note: "2026-09-21:新增 `.lian-jing/capability-packs.json` 基线描述符;verify_onboarding 对项目上下文、能力包、Codex skill/MCP 和既有基线逐项返回结构化状态与可执行升级提示。" -### 🔵 P2-C. Codex Skill-only 可移植能力包 -- status: in_progress +### ✅ P2-C. Codex Skill-only 可移植能力包 +- status: done - 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,未接入项目输出接入诊断而非虚构状态。" +- done_note: "2026-09-21:个人本地 marketplace 安装后,新 Codex 会话确认 Skill 已加载;未接入项目如实报告 canonical context 与 MCP 缺失,未猜测 project_id、凭证或交付状态。" -### 📋 P2-D. 接入包版本、升级说明与架构防腐同步 -- status: todo +### ✅ P2-D. 接入包版本、升级说明与架构防腐同步 +- status: done - 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,且不把客户端插件表述为凭证或部署所有者。" +- done_note: "2026-09-21:base-onboarding 升至 v1.1.0;旧能力包会显式返回 version_mismatch 与升级提示。托管模板、Git 工作流与常驻架构文档统一为 canonical context / 控制面 / 客户端 adapter 边界。" diff --git a/AGENTS.md b/AGENTS.md index 9b9b171..0506224 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,15 +4,15 @@ ## 项目定位 -Git 多空间开发管理器,同时也是**项目 Agent 化改造引擎**。 -以蓝图为神经系统,以 Claude 为智能层,感知所管理项目的功能运转、质量状态与成长方向。 +Git 多空间开发管理器,同时也是**项目 Agent 化的本地控制面**。 +以蓝图为神经系统,为 Codex、Claude 等可替换客户端 adapter 分发基线、发现状态并采集交付信号;项目自身保留业务规则、测试、部署声明与交付决策。 炼境本身亦被纳入其中,形成可自我进化的开发智能闭环。 ## 技术栈 - 前端:React 19 + TypeScript + TailwindCSS + React Query + Zustand + React Flow - 后端:Rust + Tauri v2 + SQLite(rusqlite + r2d2) -- MCP Server:内嵌于 Tauri 进程(HTTP),供 Claude Code 调用炼境数据 +- MCP Server:内嵌于 Tauri 进程(HTTP),供 Codex、Claude 等客户端 adapter 调用炼境数据 - 包管理:pnpm(禁止 npm/yarn/npx/bun) ## 炼境特有开发约束 @@ -30,7 +30,7 @@ src-tauri/src/db.rs SQLite 建表 + migration 逻辑 src-tauri/src/mcp_server.rs MCP Server 路由层 + SSE 协议(agent 调用炼境的入口) src-tauri/src/mcp_tools.rs MCP 工具 schema + dispatch + 实现(工具清单以 tools_list_result 为准) src-tauri/src/mcp_webhook.rs Gitea webhook 处理(push/PR 事件写入飞轮) -src-tauri/src/mcp_inject.rs MCP 配置注入(写入被管理项目的 .codex/config.toml 与 Claude 兼容配置) +src-tauri/src/mcp_inject.rs 接入注入(写入 canonical `.lian-jing/context.json`、`.codex/config.toml` 与 Claude 兼容配置) src-tauri/resources/onboarding/ 接入包分发模板(AGENTS.md/lefthook/git-workflow) .blueprint/ 项目蓝图(manifest + 模块详情 + 任务卡) docs/ai-context/ AI 上下文文档(速查卡、契约映射) diff --git a/docs/ai-context/git-workflow.md b/docs/ai-context/git-workflow.md index d22abce..307a774 100644 --- a/docs/ai-context/git-workflow.md +++ b/docs/ai-context/git-workflow.md @@ -3,7 +3,7 @@ > 把此文件粘贴给任何 AI Agent,让它了解本项目的 git 工作方式。由炼境接入包分发。 > `ONBOARDING_MANAGED` 标记块由炼境版本化管理(重跑接入包可原位升级),块外可自由补充项目特有约定。 - + > ⚠️ 本块由炼境版本化托管,升级时**整块原位覆盖**——项目定制请写到块外,块内手改必丢。 ## 分支策略(复杂度感知) @@ -78,13 +78,13 @@ PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否 ## PR 创建(炼境托管项目) -本项目由炼境管理,MCP 已注入且 `.claude/liangjing.json` 含 `project_id`。 +本项目由炼境管理;`.lian-jing/context.json` 是 `project_id` 的唯一来源。Codex 使用项目级 `.codex/config.toml` 发现 MCP(项目须受信任);`.claude/liangjing.json` 仅是旧项目的 Claude 兼容投影。 炼境 MCP 是单一全局服务器 `lian-jing`,所有工具显式传 `project_id` 寻址,无需 group_id。 **推送分支后,通过炼境 MCP 创建 PR,禁止用 curl + git credential fill 提取 token:** ``` -1. Read .claude/liangjing.json → 取出 project_id +1. Read .lian-jing/context.json → 取出 project_id 2. 调用 MCP 工具 create_pull_request: { "project_id": "", "head": "feat/", "base": "main", "title": "", "body": "<变更摘要>" } @@ -93,6 +93,8 @@ PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否 若炼境未运行(MCP 不可用),降级方案:去 Gitea 网页手动创建 PR。 +> 若只存在 `.claude/liangjing.json`,先调用 `inject_mcp(project_id)` 迁移到 canonical context;禁止将 Codex 用户级插件、客户端项目 UI 或全局配置当作项目身份来源。 + ## 交付状态(炼境托管项目) 在准备开 PR、合并 PR 或排查部署问题时,先调用 MCP 工具: diff --git a/docs/ai-context/project-brief.md b/docs/ai-context/project-brief.md index 5bc993c..a3f6709 100644 --- a/docs/ai-context/project-brief.md +++ b/docs/ai-context/project-brief.md @@ -1,6 +1,6 @@ # 炼境 — 速查卡 -**定位**:Git 多空间开发管理器 + 项目 Agent 化改造引擎。Tauri v2 桌面 App,管理多个开发项目,为每个项目注入 Agent 友好基础设施,形成智能成长飞轮。 +**定位**:项目 Agent 化的本地控制面。炼境管理多个 Git 工作区,分发版本化基线、发现状态并汇总交付信号;项目本身仍拥有业务规则、测试、部署声明与交付决策。 ## 技术栈 @@ -9,7 +9,7 @@ | 前端 | React 19 + TypeScript + TailwindCSS + React Query + Zustand + React Flow | | 后端 | Rust + Tauri v2 | | 数据 | SQLite(rusqlite + r2d2) | -| MCP | 内嵌 HTTP Server(供 Claude Code、Codex 等客户端调用) | +| MCP | 内嵌 HTTP Server(供 Codex、Claude 等客户端 adapter 调用) | | 包管理 | pnpm | ## 关键约定(改代码前必看) @@ -18,7 +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 兼容投影 +5. **项目身份上下文**:`.lian-jing/context.json` 是工具无关的 `project_id` 单一事实来源;`.claude/liangjing.json` 仅保留为 Claude 兼容投影;`.lian-jing/capability-packs.json` 记录托管基线版本,版本不符必须通过自检报告升级提示 ## 目录速查 diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index febf87a..94d9926 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -22,7 +22,7 @@ contracts: - ".lian-jing/context.json" - ".lian-jing/capability-packs.json" - ".claude/liangjing.json" - doc: "`.lian-jing/context.json` 是 project_id 的工具无关单一事实来源;`.claude/liangjing.json` 仅为兼容投影;`.lian-jing/capability-packs.json` 是炼境分发基线的版本描述。迁移必须保留未知字段,验证必须返回显式状态,且不得以客户端全局配置或项目 UI 身份替代项目根目录上下文。" + doc: "`.lian-jing/context.json` 是 project_id 的工具无关单一事实来源;`.claude/liangjing.json` 仅为兼容投影;`.lian-jing/capability-packs.json` 是炼境分发基线的版本描述。迁移必须保留未知字段;验证必须返回显式状态与 upgrade_hint;升级仅覆盖炼境托管文件,绝不覆盖项目定制或手工 MCP 配置,且不得以客户端全局配置或项目 UI 身份替代项目根目录上下文。" - label: Tauri command 接口(前后端契约) paths: diff --git a/docs/templates/agents-meta-rules.md b/docs/templates/agents-meta-rules.md index 98ff688..dafbbbb 100644 --- a/docs/templates/agents-meta-rules.md +++ b/docs/templates/agents-meta-rules.md @@ -1,6 +1,6 @@ --- -version: 1.1.0 -updated: 2026-07-03 +version: 1.2.0 +updated: 2026-09-21 usage: 炼境接入包分发时写入被管理项目 AGENTS.md 的元规则标准内容 --- @@ -9,6 +9,12 @@ usage: 炼境接入包分发时写入被管理项目 AGENTS.md 的元规则标 > 本节由炼境接入包分发,规则技术栈无关,适用于所有项目和所有 AI 工具。 > 项目特定的路径映射见 `docs/ai-context/project-context.yaml`。 +## 炼境项目上下文与能力包 + +- 项目根目录 `.lian-jing/context.json` 是工具无关的 `project_id` 单一事实来源;客户端项目 UI、用户级插件和全局配置都不能替代它。 +- `.claude/liangjing.json` 仅用于兼容已接入的旧 Claude 项目;发现 legacy-only 状态时,应通过炼境迁移,不应复制或猜测 ID。 +- `.lian-jing/capability-packs.json` 仅描述炼境托管基线的版本与自检。升级必须显式报告版本不符,并且不能覆盖项目自己的业务规则、测试、部署声明或手工 MCP 配置。 + --- ## Agent 友好工程四支柱 diff --git a/src-tauri/resources/onboarding/.lian-jing/capability-packs.json b/src-tauri/resources/onboarding/.lian-jing/capability-packs.json index 27d4bb1..eb3d3bf 100644 --- a/src-tauri/resources/onboarding/.lian-jing/capability-packs.json +++ b/src-tauri/resources/onboarding/.lian-jing/capability-packs.json @@ -3,7 +3,7 @@ "packs": [ { "pack_id": "base-onboarding", - "version": "1.0.0", + "version": "1.1.0", "supported_clients": ["codex", "claude"], "project_artifacts": [ "AGENTS.md", @@ -11,7 +11,7 @@ ".lian-jing/context.json" ], "self_check": "verify_onboarding", - "upgrade_hint": "调用 apply_onboarding_pack(project_id) 更新基线;项目身份上下文由 inject_mcp(project_id) 创建或迁移。" + "upgrade_hint": "调用 apply_onboarding_pack(project_id) 更新版本化基线;项目身份上下文由 inject_mcp(project_id) 创建或迁移。该升级仅覆盖炼境托管的接入包文件,不覆盖项目定制或手工 MCP 配置。" } ] } diff --git a/src-tauri/resources/onboarding/AGENTS.md.tmpl b/src-tauri/resources/onboarding/AGENTS.md.tmpl index 922b72c..4b7a263 100644 --- a/src-tauri/resources/onboarding/AGENTS.md.tmpl +++ b/src-tauri/resources/onboarding/AGENTS.md.tmpl @@ -18,7 +18,7 @@ - + > ⚠️ 本块由炼境版本化托管,升级时**整块原位覆盖**——项目定制请写到块外,块内手改必丢(2026-07-15 炼境自身实锤丢失 5 段定制后新增本警示)。 ## 核心开发约束 @@ -66,6 +66,13 @@ 见 `docs/ai-context/git-workflow.md`(分支策略 + Conventional Commits + Rules-Applied trailer + PR 规范)。 **commit 必须符合 Conventional Commits**(lefthook 强制),炼境据此采集飞轮数据。 +## 炼境项目上下文与能力包 + +- `.lian-jing/context.json` 是项目 `project_id` 的工具无关单一事实来源;不要从客户端项目 UI、用户级插件或全局配置推断项目身份。 +- `.claude/liangjing.json` 只是旧项目的 Claude 兼容投影。只发现它时,调用 `inject_mcp(project_id)` 迁移,而不是手工复制或猜测 ID。 +- `.lian-jing/capability-packs.json` 记录炼境托管基线的版本。`verify_onboarding(project_id)` 报告 `legacy`、`version_mismatch` 等状态时,按其 `detail` 中的升级提示处理。 +- `apply_onboarding_pack(project_id)` 只更新炼境明确托管的接入包文件;项目自己的业务规则、测试、部署声明和手工定义的同名 MCP server 仍归项目所有,不能被升级覆盖。 + ## agent 按钮与验证路径 功能的本体是代码层的能力;UI 按钮是它给人的投影,**agent 按钮**是给 agent 的投影 diff --git a/src-tauri/resources/onboarding/git-workflow.md.tmpl b/src-tauri/resources/onboarding/git-workflow.md.tmpl index 8bc5627..307a774 100644 --- a/src-tauri/resources/onboarding/git-workflow.md.tmpl +++ b/src-tauri/resources/onboarding/git-workflow.md.tmpl @@ -3,7 +3,7 @@ > 把此文件粘贴给任何 AI Agent,让它了解本项目的 git 工作方式。由炼境接入包分发。 > `ONBOARDING_MANAGED` 标记块由炼境版本化管理(重跑接入包可原位升级),块外可自由补充项目特有约定。 - + > ⚠️ 本块由炼境版本化托管,升级时**整块原位覆盖**——项目定制请写到块外,块内手改必丢。 ## 分支策略(复杂度感知) @@ -78,13 +78,13 @@ PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否 ## PR 创建(炼境托管项目) -本项目由炼境管理;Codex 使用项目级 `.codex/config.toml` 发现 MCP(项目须受信任),`.claude/liangjing.json` 含 `project_id`。 +本项目由炼境管理;`.lian-jing/context.json` 是 `project_id` 的唯一来源。Codex 使用项目级 `.codex/config.toml` 发现 MCP(项目须受信任);`.claude/liangjing.json` 仅是旧项目的 Claude 兼容投影。 炼境 MCP 是单一全局服务器 `lian-jing`,所有工具显式传 `project_id` 寻址,无需 group_id。 **推送分支后,通过炼境 MCP 创建 PR,禁止用 curl + git credential fill 提取 token:** ``` -1. Read .claude/liangjing.json → 取出 project_id +1. Read .lian-jing/context.json → 取出 project_id 2. 调用 MCP 工具 create_pull_request: { "project_id": "", "head": "feat/", "base": "main", "title": "", "body": "<变更摘要>" } @@ -93,6 +93,8 @@ PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否 若炼境未运行(MCP 不可用),降级方案:去 Gitea 网页手动创建 PR。 +> 若只存在 `.claude/liangjing.json`,先调用 `inject_mcp(project_id)` 迁移到 canonical context;禁止将 Codex 用户级插件、客户端项目 UI 或全局配置当作项目身份来源。 + ## 交付状态(炼境托管项目) 在准备开 PR、合并 PR 或排查部署问题时,先调用 MCP 工具: diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index 5f02f9a..4a6704a 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -25,7 +25,7 @@ const TMPL_CAPABILITY_PACKS: &str = include_str!("../../resources/onboarding/.lian-jing/capability-packs.json"); const PROJECT_CONTEXT_SCHEMA_VERSION: u64 = 1; const CAPABILITY_PACK_SCHEMA_VERSION: u64 = 1; -const BASE_ONBOARDING_PACK_VERSION: &str = "1.0.0"; +const BASE_ONBOARDING_PACK_VERSION: &str = "1.1.0"; /// 单个模板的分发结果。 #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] @@ -999,6 +999,12 @@ mod tests { assert_eq!(pack_ready.state, VerifyState::Ready); let pack_old = check_capability_packs(Some(r#"{"schema_version":0,"packs":[]}"#)); assert_eq!(pack_old.state, VerifyState::VersionMismatch); + + let outdated_pack = check_capability_packs(Some( + r#"{"schema_version":1,"packs":[{"pack_id":"base-onboarding","version":"1.0.0"}]}"#, + )); + assert_eq!(outdated_pack.state, VerifyState::VersionMismatch); + assert!(outdated_pack.detail.contains("重跑接入包可升级")); } #[test] -- 2.45.2 From f04b80f2013c3ac36994f777baa74bd51c71407f Mon Sep 17 00:00:00 2001 From: lanrtop Date: Mon, 21 Sep 2026 19:28:27 +0900 Subject: [PATCH 5/5] fix(onboarding): preserve identity mismatch signal --- src-tauri/src/commands/onboarding.rs | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index 4a6704a..fb36662 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -379,7 +379,13 @@ fn check_project_context( .get("schema_version") .and_then(|value| value.as_u64()); let project_id = context.get("project_id").and_then(|value| value.as_str()); - if version != Some(PROJECT_CONTEXT_SCHEMA_VERSION) { + if project_id != Some(expected_project_id) { + VerifyCheck::with_state( + "项目身份上下文", + VerifyState::Unavailable, + "`.lian-jing/context.json` 的 project_id 与炼境登记不一致——停止使用并重新 inject_mcp", + ) + } else if version != Some(PROJECT_CONTEXT_SCHEMA_VERSION) { VerifyCheck::with_state( "项目身份上下文", VerifyState::VersionMismatch, @@ -388,18 +394,12 @@ fn check_project_context( version, PROJECT_CONTEXT_SCHEMA_VERSION ), ) - } else if project_id == Some(expected_project_id) { + } else { VerifyCheck::with_state( "项目身份上下文", VerifyState::Ready, "`.lian-jing/context.json` 与已登记 project_id 一致", ) - } else { - VerifyCheck::with_state( - "项目身份上下文", - VerifyState::Unavailable, - "`.lian-jing/context.json` 的 project_id 与炼境登记不一致——停止使用并重新 inject_mcp", - ) } } None if legacy @@ -989,6 +989,14 @@ mod tests { ); assert_eq!(mismatch.state, VerifyState::VersionMismatch); + let identity_mismatch = check_project_context( + Some(r#"{"schema_version":0,"project_id":"another-project"}"#), + None, + "p1", + ); + assert_eq!(identity_mismatch.state, VerifyState::Unavailable); + assert!(identity_mismatch.detail.contains("停止使用")); + let missing = check_project_context(None, None, "p1"); assert_eq!(missing.state, VerifyState::Missing); -- 2.45.2