feat(agent-context): add canonical project context

Feature-Confirmed: true
This commit is contained in:
lanrtop 2026-09-21 18:16:32 +09:00
parent 2294dbf5b5
commit 167fcf9880
5 changed files with 222 additions and 42 deletions

View File

@ -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

View File

@ -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,且不把客户端插件表述为凭证或部署所有者。"

View File

@ -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 兼容投影
## 目录速查

View File

@ -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"

View File

@ -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<PathBuf, String> {
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<PathBuf, String> {
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<PathBuf, String> {
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<Value, String> {
match std::fs::read_to_string(path) {
Ok(content) if content.trim().is_empty() => Ok(json!({})),
Ok(content) => serde_json::from_str::<Value>(&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::<Value>(&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<String> {
}
// 最后尝试 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<String> {
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<Value, String> {
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<Value, String> {
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,
&current[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());