Merge pull request 'feat(agent-context): add client capability packs' (#8) from feat/agent-client-capability-packs into master
All checks were successful
Push & PR Check / check (push) Successful in 1m3s

Reviewed-on: #8
This commit is contained in:
lanrtop 2026-09-21 20:21:23 +08:00
commit 1f38aebbac
16 changed files with 770 additions and 165 deletions

View File

@ -1,9 +1,9 @@
version: 1 version: 1
name: 炼境 name: 炼境
description: > description: >
Git 多空间开发管理器。以蓝图为神经系统,以 Claude 为智能层, Git 多空间开发管理器与项目 Agent 化的本地控制面。以蓝图为神经系统,
持续感知所管理项目的功能运转、质量状态与成长方向, 为 Codex、Claude 等可替换客户端 adapter 分发基线、发现状态并采集交付信号;
成为每个项目的贴身导师。炼境本身亦被纳入其中, 项目自身保留业务规则、测试、部署声明与交付决策。炼境本身亦被纳入其中,
形成可自我进化的开发智能闭环。 形成可自我进化的开发智能闭环。
iteration: 1 iteration: 1
updated: 2026-09-21 updated: 2026-09-21
@ -459,6 +459,15 @@ modules:
progress: 14 progress: 14
note: "P1 已完成:只读交付信号聚合(Git / PR / CI / review / 部署 unknown),不改变 Enterprise 既有 production 自动部署;后续受控执行按需推进。" note: "P1 已完成:只读交付信号聚合(Git / PR / CI / review / 部署 unknown),不改变 Enterprise 既有 production 自动部署;后续受控执行按需推进。"
position: [2000, 800] position: [2000, 800]
- id: agent-client-capability-packs
name: Agent 客户端能力包
area: infra
status: done
progress: 100
note: "P2 完成:工具无关 canonical context、版本化能力包自检与 Codex Skill-only 投影已闭环;不复制 MCP、凭证或部署能力。"
updated: 2026-09-21
position: [2250, 800]
edges: edges:
# 前端依赖 # 前端依赖
- from: workspace-mgmt - from: workspace-mgmt
@ -750,3 +759,9 @@ edges:
- from: project-capability-control-plane - from: project-capability-control-plane
to: runtime-diagnostics to: runtime-diagnostics
type: dependency 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,58 @@
---
id: agent-client-capability-packs
name: Agent 客户端能力包
status: done
progress: 100
---
## 概述
炼境为项目提供稳定的本地控制面与信号;客户端能力包只负责将同一项目规则、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: 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: 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: 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 边界。"

View File

@ -4,15 +4,15 @@
## 项目定位 ## 项目定位
Git 多空间开发管理器,同时也是**项目 Agent 化改造引擎**。 Git 多空间开发管理器,同时也是**项目 Agent 化的本地控制面**。
以蓝图为神经系统,以 Claude 为智能层,感知所管理项目的功能运转、质量状态与成长方向。 以蓝图为神经系统,为 Codex、Claude 等可替换客户端 adapter 分发基线、发现状态并采集交付信号;项目自身保留业务规则、测试、部署声明与交付决策。
炼境本身亦被纳入其中,形成可自我进化的开发智能闭环。 炼境本身亦被纳入其中,形成可自我进化的开发智能闭环。
## 技术栈 ## 技术栈
- 前端:React 19 + TypeScript + TailwindCSS + React Query + Zustand + React Flow - 前端:React 19 + TypeScript + TailwindCSS + React Query + Zustand + React Flow
- 后端:Rust + Tauri v2 + SQLite(rusqlite + r2d2) - 后端:Rust + Tauri v2 + SQLite(rusqlite + r2d2)
- MCP Server:内嵌于 Tauri 进程(HTTP),供 Claude Code 调用炼境数据 - MCP Server:内嵌于 Tauri 进程(HTTP),供 Codex、Claude 等客户端 adapter 调用炼境数据
- 包管理:pnpm(禁止 npm/yarn/npx/bun) - 包管理: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_server.rs MCP Server 路由层 + SSE 协议(agent 调用炼境的入口)
src-tauri/src/mcp_tools.rs MCP 工具 schema + dispatch + 实现(工具清单以 tools_list_result 为准) 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_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) src-tauri/resources/onboarding/ 接入包分发模板(AGENTS.md/lefthook/git-workflow)
.blueprint/ 项目蓝图(manifest + 模块详情 + 任务卡) .blueprint/ 项目蓝图(manifest + 模块详情 + 任务卡)
docs/ai-context/ AI 上下文文档(速查卡、契约映射) docs/ai-context/ AI 上下文文档(速查卡、契约映射)

View File

@ -3,7 +3,7 @@
> 把此文件粘贴给任何 AI Agent,让它了解本项目的 git 工作方式。由炼境接入包分发。 > 把此文件粘贴给任何 AI Agent,让它了解本项目的 git 工作方式。由炼境接入包分发。
> `ONBOARDING_MANAGED` 标记块由炼境版本化管理(重跑接入包可原位升级),块外可自由补充项目特有约定。 > `ONBOARDING_MANAGED` 标记块由炼境版本化管理(重跑接入包可原位升级),块外可自由补充项目特有约定。
<!-- ONBOARDING_MANAGED_START version="1.5.2" --> <!-- ONBOARDING_MANAGED_START version="1.6.0" -->
> ⚠️ 本块由炼境版本化托管,升级时**整块原位覆盖**——项目定制请写到块外,块内手改必丢。 > ⚠️ 本块由炼境版本化托管,升级时**整块原位覆盖**——项目定制请写到块外,块内手改必丢。
## 分支策略(复杂度感知) ## 分支策略(复杂度感知)
@ -78,13 +78,13 @@ PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否
## PR 创建(炼境托管项目) ## 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 是单一全局服务器 `lian-jing`,所有工具显式传 `project_id` 寻址,无需 group_id。
**推送分支后,通过炼境 MCP 创建 PR,禁止用 curl + git credential fill 提取 token:** **推送分支后,通过炼境 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: 2. 调用 MCP 工具 create_pull_request:
{ "project_id": "<id>", "head": "feat/<name>", "base": "main", { "project_id": "<id>", "head": "feat/<name>", "base": "main",
"title": "<Conventional Commits 格式>", "body": "<变更摘要>" } "title": "<Conventional Commits 格式>", "body": "<变更摘要>" }
@ -93,6 +93,8 @@ PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否
若炼境未运行(MCP 不可用),降级方案:去 Gitea 网页手动创建 PR。 若炼境未运行(MCP 不可用),降级方案:去 Gitea 网页手动创建 PR。
> 若只存在 `.claude/liangjing.json`,先调用 `inject_mcp(project_id)` 迁移到 canonical context;禁止将 Codex 用户级插件、客户端项目 UI 或全局配置当作项目身份来源。
## 交付状态(炼境托管项目) ## 交付状态(炼境托管项目)
在准备开 PR、合并 PR 或排查部署问题时,先调用 MCP 工具: 在准备开 PR、合并 PR 或排查部署问题时,先调用 MCP 工具:

View File

@ -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 | | 前端 | React 19 + TypeScript + TailwindCSS + React Query + Zustand + React Flow |
| 后端 | Rust + Tauri v2 | | 后端 | Rust + Tauri v2 |
| 数据 | SQLite(rusqlite + r2d2) | | 数据 | SQLite(rusqlite + r2d2) |
| MCP | 内嵌 HTTP Server(供 Claude Code 调用) | | MCP | 内嵌 HTTP Server(供 Codex、Claude 等客户端 adapter 调用) |
| 包管理 | pnpm | | 包管理 | pnpm |
## 关键约定(改代码前必看) ## 关键约定(改代码前必看)
@ -18,6 +18,7 @@
2. **DB 变更必须有 migration**:在 `src-tauri/src/db.rs` 的 `migrate()` 里补语句,否则旧版本升级报错 2. **DB 变更必须有 migration**:在 `src-tauri/src/db.rs` 的 `migrate()` 里补语句,否则旧版本升级报错
3. **Rust 业务函数依赖注入**:禁止内部 `pool().get()`,连接由调用方传入 3. **Rust 业务函数依赖注入**:禁止内部 `pool().get()`,连接由调用方传入
4. **接入包模板 = 分发给被管理项目的规则**:改 `resources/onboarding/` 务必同步 `docs/templates/` 4. **接入包模板 = 分发给被管理项目的规则**:改 `resources/onboarding/` 务必同步 `docs/templates/`
5. **项目身份上下文**:`.lian-jing/context.json` 是工具无关的 `project_id` 单一事实来源;`.claude/liangjing.json` 仅保留为 Claude 兼容投影;`.lian-jing/capability-packs.json` 记录托管基线版本,版本不符必须通过自检报告升级提示
## 目录速查 ## 目录速查

View File

@ -15,6 +15,15 @@ contracts:
- "src-tauri/src/commands/gitea.rs" - "src-tauri/src/commands/gitea.rs"
doc: "工具 schema、dispatch 与调用说明必须同步;外部来源不可用时返回显式降级状态,不得伪造成功" doc: "工具 schema、dispatch 与调用说明必须同步;外部来源不可用时返回显式降级状态,不得伪造成功"
- 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` 仅为兼容投影;`.lian-jing/capability-packs.json` 是炼境分发基线的版本描述。迁移必须保留未知字段;验证必须返回显式状态与 upgrade_hint;升级仅覆盖炼境托管文件,绝不覆盖项目定制或手工 MCP 配置,且不得以客户端全局配置或项目 UI 身份替代项目根目录上下文。"
- label: Tauri command 接口(前后端契约) - label: Tauri command 接口(前后端契约)
paths: paths:
- "src-tauri/src/commands/**/*.rs" - "src-tauri/src/commands/**/*.rs"

View File

@ -1,6 +1,6 @@
--- ---
version: 1.1.0 version: 1.2.0
updated: 2026-07-03 updated: 2026-09-21
usage: 炼境接入包分发时写入被管理项目 AGENTS.md 的元规则标准内容 usage: 炼境接入包分发时写入被管理项目 AGENTS.md 的元规则标准内容
--- ---
@ -9,6 +9,12 @@ usage: 炼境接入包分发时写入被管理项目 AGENTS.md 的元规则标
> 本节由炼境接入包分发,规则技术栈无关,适用于所有项目和所有 AI 工具。 > 本节由炼境接入包分发,规则技术栈无关,适用于所有项目和所有 AI 工具。
> 项目特定的路径映射见 `docs/ai-context/project-context.yaml`。 > 项目特定的路径映射见 `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 友好工程四支柱 ## Agent 友好工程四支柱

5
plugins/README.md Normal file
View File

@ -0,0 +1,5 @@
# 炼境 Codex 插件源
`lian-jing-codex` 是 Skill-only 插件源码。它只指导 Codex 使用项目内已存在的炼境上下文与 MCP,不打包 MCP server、凭证、项目 ID 或部署能力。
项目身份始终来自 `.lian-jing/context.json`;Codex 的项目 UI、用户级插件安装和客户端全局配置都不是项目身份来源。

View File

@ -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 获取交付信号。"
}
}

View File

@ -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 映射;这些都属于项目接入与炼境控制面。

View File

@ -0,0 +1,17 @@
{
"schema_version": 1,
"packs": [
{
"pack_id": "base-onboarding",
"version": "1.1.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) 创建或迁移。该升级仅覆盖炼境托管的接入包文件,不覆盖项目定制或手工 MCP 配置。"
}
]
}

View File

@ -18,7 +18,7 @@
<!-- TODO: 列出 README / ARCHITECTURE.md / 其他关键文档及用途。 --> <!-- TODO: 列出 README / ARCHITECTURE.md / 其他关键文档及用途。 -->
<!-- ONBOARDING_MANAGED_START version="1.7.0" --> <!-- ONBOARDING_MANAGED_START version="1.8.0" -->
> ⚠️ 本块由炼境版本化托管,升级时**整块原位覆盖**——项目定制请写到块外,块内手改必丢(2026-07-15 炼境自身实锤丢失 5 段定制后新增本警示)。 > ⚠️ 本块由炼境版本化托管,升级时**整块原位覆盖**——项目定制请写到块外,块内手改必丢(2026-07-15 炼境自身实锤丢失 5 段定制后新增本警示)。
## 核心开发约束 ## 核心开发约束
@ -66,6 +66,13 @@
见 `docs/ai-context/git-workflow.md`(分支策略 + Conventional Commits + Rules-Applied trailer + PR 规范)。 见 `docs/ai-context/git-workflow.md`(分支策略 + Conventional Commits + Rules-Applied trailer + PR 规范)。
**commit 必须符合 Conventional Commits**(lefthook 强制),炼境据此采集飞轮数据。 **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 按钮与验证路径 ## agent 按钮与验证路径
功能的本体是代码层的能力;UI 按钮是它给人的投影,**agent 按钮**是给 agent 的投影 功能的本体是代码层的能力;UI 按钮是它给人的投影,**agent 按钮**是给 agent 的投影

View File

@ -3,7 +3,7 @@
> 把此文件粘贴给任何 AI Agent,让它了解本项目的 git 工作方式。由炼境接入包分发。 > 把此文件粘贴给任何 AI Agent,让它了解本项目的 git 工作方式。由炼境接入包分发。
> `ONBOARDING_MANAGED` 标记块由炼境版本化管理(重跑接入包可原位升级),块外可自由补充项目特有约定。 > `ONBOARDING_MANAGED` 标记块由炼境版本化管理(重跑接入包可原位升级),块外可自由补充项目特有约定。
<!-- ONBOARDING_MANAGED_START version="1.5.2" --> <!-- ONBOARDING_MANAGED_START version="1.6.0" -->
> ⚠️ 本块由炼境版本化托管,升级时**整块原位覆盖**——项目定制请写到块外,块内手改必丢。 > ⚠️ 本块由炼境版本化托管,升级时**整块原位覆盖**——项目定制请写到块外,块内手改必丢。
## 分支策略(复杂度感知) ## 分支策略(复杂度感知)
@ -78,13 +78,13 @@ PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否
## PR 创建(炼境托管项目) ## 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 是单一全局服务器 `lian-jing`,所有工具显式传 `project_id` 寻址,无需 group_id。
**推送分支后,通过炼境 MCP 创建 PR,禁止用 curl + git credential fill 提取 token:** **推送分支后,通过炼境 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: 2. 调用 MCP 工具 create_pull_request:
{ "project_id": "<id>", "head": "feat/<name>", "base": "main", { "project_id": "<id>", "head": "feat/<name>", "base": "main",
"title": "<Conventional Commits 格式>", "body": "<变更摘要>" } "title": "<Conventional Commits 格式>", "body": "<变更摘要>" }
@ -93,6 +93,8 @@ PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否
若炼境未运行(MCP 不可用),降级方案:去 Gitea 网页手动创建 PR。 若炼境未运行(MCP 不可用),降级方案:去 Gitea 网页手动创建 PR。
> 若只存在 `.claude/liangjing.json`,先调用 `inject_mcp(project_id)` 迁移到 canonical context;禁止将 Codex 用户级插件、客户端项目 UI 或全局配置当作项目身份来源。
## 交付状态(炼境托管项目) ## 交付状态(炼境托管项目)
在准备开 PR、合并 PR 或排查部署问题时,先调用 MCP 工具: 在准备开 PR、合并 PR 或排查部署问题时,先调用 MCP 工具:

View File

@ -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_LEFTHOOK: &str = include_str!("../../resources/onboarding/lefthook.yml.tmpl");
const TMPL_GIT_WORKFLOW: &str = include_str!("../../resources/onboarding/git-workflow.md.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_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_BLUEPRINT_GATE: &str =
const TMPL_LIANJING_SKILL: &str = include_str!("../../resources/onboarding/.agents/skills/lian-jing/SKILL.md"); 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.1.0";
/// 单个模板的分发结果。 /// 单个模板的分发结果。
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
@ -134,9 +141,8 @@ fn dispatch_versioned(root: &Path, relative: &str, template: &str) -> TemplateRe
} }
}; };
let (new_content, action, detail) = if let (Some(s), Some(e)) = let (new_content, action, detail) =
(content.find(MANAGED_START), content.find(MANAGED_END)) if let (Some(s), Some(e)) = (content.find(MANAGED_START), content.find(MANAGED_END)) {
{
let old_ver = block_version(&content).unwrap_or("?").to_string(); let old_ver = block_version(&content).unwrap_or("?").to_string();
if old_ver == tpl_ver { if old_ver == tpl_ver {
return TemplateResult { return TemplateResult {
@ -149,7 +155,11 @@ fn dispatch_versioned(root: &Path, relative: &str, template: &str) -> TemplateRe
nc.push_str(&content[..s]); nc.push_str(&content[..s]);
nc.push_str(tpl_block); nc.push_str(tpl_block);
nc.push_str(&content[e + MANAGED_END.len()..]); nc.push_str(&content[e + MANAGED_END.len()..]);
(nc, "updated", format!("管理块 v{old_ver} → v{tpl_ver},块外内容未动")) (
nc,
"updated",
format!("管理块 v{old_ver} → v{tpl_ver},块外内容未动"),
)
} else { } else {
( (
format!("{}\n\n{}\n", content.trim_end(), tpl_block), format!("{}\n\n{}\n", content.trim_end(), tpl_block),
@ -181,8 +191,21 @@ pub fn dispatch_templates(project_path: &str) -> Vec<TemplateResult> {
dispatch_one(root, "lefthook.yml", TMPL_LEFTHOOK), dispatch_one(root, "lefthook.yml", TMPL_LEFTHOOK),
dispatch_versioned(root, "docs/ai-context/git-workflow.md", TMPL_GIT_WORKFLOW), 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/commands/envoy.md", TMPL_ENVOY),
dispatch_fully_managed(root, ".claude/hooks/blueprint-gate.cjs", TMPL_BLUEPRINT_GATE), dispatch_fully_managed(
dispatch_fully_managed(root, ".agents/skills/lian-jing/SKILL.md", TMPL_LIANJING_SKILL), 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 { 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() => { Ok(out) if out.status.success() => {
return HookActivation { return HookActivation {
status: "activated".into(), status: "activated".into(),
@ -275,7 +302,11 @@ pub fn apply_onboarding_pack(
let templates = dispatch_templates(&project_path); let templates = dispatch_templates(&project_path);
let hook = run_lefthook_install(Path::new(project_path.trim_end_matches(['/', '\\']))); let hook = run_lefthook_install(Path::new(project_path.trim_end_matches(['/', '\\'])));
let buff = crate::commands::buff::apply_blueprint_buff(project_path, project_id)?; 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 检查链,判定逻辑纯函数化可单测)────── // ── V1:接入生效验收(verify_onboarding 检查链,判定逻辑纯函数化可单测)──────
@ -285,9 +316,170 @@ pub fn apply_onboarding_pack(
pub struct VerifyCheck { pub struct VerifyCheck {
pub name: String, pub name: String,
pub pass: bool, pub pass: bool,
pub state: VerifyState,
pub detail: String, 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<String>, state: VerifyState, detail: impl Into<String>) -> Self {
let pass = state == VerifyState::Ready;
Self {
name: name.into(),
pass,
state,
detail: detail.into(),
}
}
fn from_pass(name: impl Into<String>, pass: bool, detail: impl Into<String>) -> 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::<serde_json::Value>(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 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,
format!(
"context schema_version={:?},当前为 {}——调用 inject_mcp 可升级",
version, PROJECT_CONTEXT_SCHEMA_VERSION
),
)
} else {
VerifyCheck::with_state(
"项目身份上下文",
VerifyState::Ready,
"`.lian-jing/context.json` 与已登记 project_id 一致",
)
}
}
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::<serde_json::Value>(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 接管。 /// 纯函数:pre-commit hook 内容是否由 lefthook 接管。
fn hook_is_lefthook(content: &str) -> bool { fn hook_is_lefthook(content: &str) -> bool {
content.contains("lefthook") content.contains("lefthook")
@ -299,7 +491,9 @@ fn parse_origin_url(git_config: &str) -> Option<String> {
for line in git_config.lines() { for line in git_config.lines() {
let t = line.trim(); let t = line.trim();
if t.starts_with('[') { 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; continue;
} }
if in_origin { if in_origin {
@ -340,15 +534,24 @@ fn check_codex_mcp(content: Option<&str>) -> (bool, String) {
{ {
(true, "lian-jing 指向本地 MCP 服务".into()) (true, "lian-jing 指向本地 MCP 服务".into())
} }
Some(_) => (false, "未找到炼境的 Codex MCP 配置——调用 inject_mcp 修复".into()), Some(_) => (
None => (false, ".codex/config.toml 缺失——调用 inject_mcp 后生成".into()), false,
"未找到炼境的 Codex MCP 配置——调用 inject_mcp 修复".into(),
),
None => (
false,
".codex/config.toml 缺失——调用 inject_mcp 后生成".into(),
),
} }
} }
fn check_skill(content: Option<&str>) -> (bool, String) { fn check_skill(content: Option<&str>) -> (bool, String) {
match content { match content {
Some(content) if content.contains("name: lian-jing") => (true, "炼境项目技能已分发".into()), 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) { fn check_managed_version(content: Option<&str>, expected: &str) -> (bool, String) {
match content.and_then(block_version) { match content.and_then(block_version) {
Some(v) if v == expected => (true, format!("管理块 v{v}(最新)")), 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()), None => (false, "文件缺失或无管理块标记".into()),
} }
} }
@ -373,7 +579,10 @@ fn check_origin(origin_host: Option<&str>, instance_hosts: &[String]) -> (bool,
} }
Some(h) => ( Some(h) => (
false, 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)。 /// conn 由调用方注入(R06)。
pub fn verify_onboarding( pub fn verify_onboarding(
conn: &rusqlite::Connection, conn: &rusqlite::Connection,
@ -449,27 +658,44 @@ pub fn verify_onboarding(
"git-workflow 管理块版本(含交付状态调用说明)", "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); 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()); 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()); 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 // 3 lefthook hook 真挂在 .git/hooks
let (pass, detail) = match read(".git/hooks/pre-commit") { let (pass, detail) = match read(".git/hooks/pre-commit") {
Some(c) if hook_is_lefthook(&c) => (true, "pre-commit 由 lefthook 接管".to_string()), Some(c) if hook_is_lefthook(&c) => (true, "pre-commit 由 lefthook 接管".to_string()),
Some(_) => (false, "pre-commit 存在但非 lefthook(被其他工具占用?)".to_string()), Some(_) => (
None => (false, "无 pre-commit hook——lefthook install 未执行".to_string()), 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) // 4 lefthook 检查段齐全(期望键从内嵌模板自省,U1)
let (pass, detail) = check_lefthook_keys(read("lefthook.yml").as_deref(), TMPL_LEFTHOOK); 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 实例 // 5 origin 指向已登记 Gitea 实例
let origin_host = read(".git/config") 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); 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 存在 // 6 CI workflow 存在
let yml_count = std::fs::read_dir(root.join(".gitea/workflows")) let yml_count = std::fs::read_dir(root.join(".gitea/workflows"))
@ -503,15 +729,15 @@ pub fn verify_onboarding(
.count() .count()
}) })
.unwrap_or(0); .unwrap_or(0);
checks.push(VerifyCheck { checks.push(VerifyCheck::from_pass(
name: "CI workflow 存在".into(), "CI workflow 存在",
pass: yml_count > 0, yml_count > 0,
detail: if yml_count > 0 { if yml_count > 0 {
format!(".gitea/workflows/ 有 {yml_count} 个 workflow") format!(".gitea/workflows/ 有 {yml_count} 个 workflow")
} else { } else {
"无 .gitea/workflows/*.yml——可调 distribute_cicd 分发".into() "无 .gitea/workflows/*.yml——可调 distribute_cicd 分发".into()
}, },
}); ));
// 7 飞轮数据在流入(近 30 天 commit_metrics) // 7 飞轮数据在流入(近 30 天 commit_metrics)
let recent: i64 = conn let recent: i64 = conn
@ -521,15 +747,15 @@ pub fn verify_onboarding(
|r| r.get(0), |r| r.get(0),
) )
.unwrap_or(0); .unwrap_or(0);
checks.push(VerifyCheck { checks.push(VerifyCheck::from_pass(
name: "飞轮 commit 数据流入".into(), "飞轮 commit 数据流入",
pass: recent > 0, recent > 0,
detail: if recent > 0 { if recent > 0 {
format!("近 30 天 {recent} 条 commit 已采集") format!("近 30 天 {recent} 条 commit 已采集")
} else { } else {
"近 30 天无数据——确认 buff 已挂且有 commit 活动(或调 poll_now)".into() "近 30 天无数据——确认 buff 已挂且有 commit 活动(或调 poll_now)".into()
}, },
}); ));
checks checks
} }
@ -577,8 +803,8 @@ mod tests {
#[test] #[test]
fn writes_then_skips_never_overwrites() { fn writes_then_skips_never_overwrites() {
let base = std::env::temp_dir() let base =
.join(format!("lianjing_onboarding_test_{}", std::process::id())); std::env::temp_dir().join(format!("lianjing_onboarding_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base); let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap(); std::fs::create_dir_all(&base).unwrap();
let pp = base.to_str().unwrap(); let pp = base.to_str().unwrap();
@ -608,8 +834,8 @@ mod tests {
#[test] #[test]
fn versioned_dispatch_three_states() { fn versioned_dispatch_three_states() {
let base = std::env::temp_dir() let base =
.join(format!("lianjing_onb_ver_test_{}", std::process::id())); std::env::temp_dir().join(format!("lianjing_onb_ver_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base); let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap(); std::fs::create_dir_all(&base).unwrap();
let pp = base.to_str().unwrap(); let pp = base.to_str().unwrap();
@ -631,13 +857,13 @@ mod tests {
.and_then(block_version) .and_then(block_version)
.expect("模板必须带版本标记"); .expect("模板必须带版本标记");
let old = agents let old = agents
.replace(&format!("version=\"{tpl_ver}\""), "version=\"1.0.0\"")
.replace( .replace(
&format!("version=\"{tpl_ver}\""), "## 项目概况",
"version=\"1.0.0\"", "## 项目概况
)
.replace("## 项目概况", "## 项目概况
我的项目说明") 我的项目说明",
)
+ " + "
## 用户自加的章节 ## 用户自加的章节
内容 内容
@ -651,14 +877,24 @@ mod tests {
upgraded.contains(&format!("version=\"{tpl_ver}\"")), upgraded.contains(&format!("version=\"{tpl_ver}\"")),
"块应升级到模板版本" "块应升级到模板版本"
); );
assert!(upgraded.contains("我的项目说明"), "块外用户内容(前)须保留"); assert!(
assert!(upgraded.contains("用户自加的章节"), "块外用户内容(后)须保留"); upgraded.contains("我的项目说明"),
"块外用户内容(前)须保留"
);
assert!(
upgraded.contains("用户自加的章节"),
"块外用户内容(后)须保留"
);
assert_eq!(upgraded.matches(MANAGED_START).count(), 1, "只有一个管理块"); assert_eq!(upgraded.matches(MANAGED_START).count(), 1, "只有一个管理块");
// 态三:存量文件无标记 → appended,原内容保留在上方 // 态三:存量文件无标记 → 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 r4 = dispatch_templates(pp);
let agents_r = r4.iter().find(|t| t.name == "AGENTS.md").unwrap(); let agents_r = r4.iter().find(|t| t.name == "AGENTS.md").unwrap();
assert_eq!(agents_r.action, "appended"); assert_eq!(agents_r.action, "appended");
@ -677,8 +913,14 @@ mod tests {
#[test] #[test]
fn hook_detection_covers_mounted_and_foreign() { fn hook_detection_covers_mounted_and_foreign() {
assert!(hook_is_lefthook("#!/bin/sh\ncall lefthook run pre-commit"), "lefthook 接管应判 true"); assert!(
assert!(!hook_is_lefthook("#!/bin/sh\nnpx husky run"), "非 lefthook hook 应判 false"); 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] #[test]
@ -689,41 +931,123 @@ mod tests {
Some("https://git.example.com/o/r.git"), Some("https://git.example.com/o/r.git"),
"应取 origin 而非其他 remote" "应取 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!(
assert_eq!(url_host("https://user@git.example.com:3000/o/r.git").as_deref(), Some("git.example.com"), "应忽略用户名与端口"); url_host("https://git.example.com/o/r.git").as_deref(),
assert_eq!(url_host("git@git.example.com:o/r.git").as_deref(), Some("git.example.com"), "ssh 形式"); 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] #[test]
fn origin_check_match_and_mismatch() { fn origin_check_match_and_mismatch() {
let hosts = vec!["git.example.com".to_string()]; 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(Some("github.com"), &hosts).0, "未命中应 fail");
assert!(!check_origin(None, &hosts).0, "无 origin 应 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 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);
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);
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] #[test]
fn verify_onboarding_full_chain() { fn verify_onboarding_full_chain() {
let conn = crate::db::conn_with_schema(); let conn = crate::db::conn_with_schema();
let base = std::env::temp_dir() let base =
.join(format!("lianjing_verify_test_{}", std::process::id())); std::env::temp_dir().join(format!("lianjing_verify_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base); let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap(); std::fs::create_dir_all(&base).unwrap();
// 空目录:七项全不通过(真实反映"什么都没接") // 空目录:所有检查均不通过(真实反映"什么都没接")
let checks = verify_onboarding(&conn, "p1", &base); let checks = verify_onboarding(&conn, "p1", &base);
assert_eq!(checks.len(), 9, "检查链应为九项"); assert_eq!(checks.len(), 11, "检查链应为十一项");
assert!(checks.iter().all(|c| !c.pass), "空项目应全部不通过"); assert!(checks.iter().all(|c| !c.pass), "空项目应全部不通过");
// 铺齐全部生效条件后:七项全通过 // 铺齐全部生效条件后:所有检查全通过
dispatch_templates(base.to_str().unwrap()); 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::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::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( std::fs::write(
base.join(".git/config"), base.join(".git/config"),
"[remote \"origin\"]\n\turl = https://git.example.com/o/r.git\n", "[remote \"origin\"]\n\turl = https://git.example.com/o/r.git\n",
@ -753,19 +1077,26 @@ mod tests {
#[test] #[test]
fn managed_version_check_states() { fn managed_version_check_states() {
let expected = extract_managed_block(TMPL_AGENTS).and_then(block_version).unwrap(); let expected = extract_managed_block(TMPL_AGENTS)
assert!(check_managed_version(Some(TMPL_AGENTS), expected).0, "同版本应 pass"); .and_then(block_version)
.unwrap();
let old = TMPL_AGENTS.replacen( assert!(
&format!("version=\"{expected}\""), check_managed_version(Some(TMPL_AGENTS), expected).0,
"version=\"0.0.1\"", "同版本应 pass"
1,
); );
let old = TMPL_AGENTS.replacen(&format!("version=\"{expected}\""), "version=\"0.0.1\"", 1);
let (pass, detail) = check_managed_version(Some(&old), expected); 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(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 批量实调实锤)。 /// 回归:空路径登记曾把接入包写进炼境进程 cwd(src-tauri/,2026-07-15 批量实调实锤)。
@ -775,8 +1106,7 @@ mod tests {
assert!(err.contains("拒绝分发"), "空路径应拒绝: {err}"); assert!(err.contains("拒绝分发"), "空路径应拒绝: {err}");
let err = apply_onboarding_pack(" ".into(), "p".into()).unwrap_err(); let err = apply_onboarding_pack(" ".into(), "p".into()).unwrap_err();
assert!(err.contains("拒绝分发"), "空白路径应拒绝: {err}"); assert!(err.contains("拒绝分发"), "空白路径应拒绝: {err}");
let err = let err = apply_onboarding_pack("E:/不存在的目录/xyz-404".into(), "p".into()).unwrap_err();
apply_onboarding_pack("E:/不存在的目录/xyz-404".into(), "p".into()).unwrap_err();
assert!(err.contains("拒绝分发"), "不存在路径应拒绝: {err}"); assert!(err.contains("拒绝分发"), "不存在路径应拒绝: {err}");
} }
@ -784,8 +1114,7 @@ mod tests {
#[test] #[test]
fn fully_managed_dispatch_three_states() { fn fully_managed_dispatch_three_states() {
let base = std::env::temp_dir() let base = std::env::temp_dir().join(format!("lianjing_fm_test_{}", std::process::id()));
.join(format!("lianjing_fm_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base); let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap(); std::fs::create_dir_all(&base).unwrap();
@ -804,15 +1133,18 @@ mod tests {
std::fs::write(base.join("envoy.md"), "v1 旧内容").unwrap(); std::fs::write(base.join("envoy.md"), "v1 旧内容").unwrap();
let r = dispatch_fully_managed(&base, "envoy.md", "v2 内容"); let r = dispatch_fully_managed(&base, "envoy.md", "v2 内容");
assert_eq!(r.action, "updated"); 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); let _ = std::fs::remove_dir_all(&base);
} }
#[test] #[test]
fn dispatch_templates_upgrades_fully_managed_files() { fn dispatch_templates_upgrades_fully_managed_files() {
let base = std::env::temp_dir() let base =
.join(format!("lianjing_fm_e2e_test_{}", std::process::id())); std::env::temp_dir().join(format!("lianjing_fm_e2e_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base); let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap(); std::fs::create_dir_all(&base).unwrap();
let pp = base.to_str().unwrap(); let pp = base.to_str().unwrap();
@ -821,9 +1153,15 @@ mod tests {
// 模拟旧版本 envoy:内容与当前模板不同 → 重跑接入包应覆盖为最新 // 模拟旧版本 envoy:内容与当前模板不同 → 重跑接入包应覆盖为最新
std::fs::write(base.join(".claude/commands/envoy.md"), "旧版使者指令").unwrap(); std::fs::write(base.join(".claude/commands/envoy.md"), "旧版使者指令").unwrap();
let r2 = dispatch_templates(pp); 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", "全托管文件应被升级,而非跳过"); 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"); assert_eq!(gate.action, "skipped", "未动过的全托管文件重跑应 skipped");
let _ = std::fs::remove_dir_all(&base); let _ = std::fs::remove_dir_all(&base);
@ -833,10 +1171,16 @@ mod tests {
fn lefthook_keys_extraction_and_check() { fn lefthook_keys_extraction_and_check() {
// 从内嵌模板自省:至少含 typecheck 与 commit-msg 侧检查段 // 从内嵌模板自省:至少含 typecheck 与 commit-msg 侧检查段
let tpl_keys = lefthook_command_keys(TMPL_LEFTHOOK); 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.len() >= 3, "模板检查段应不止一个: {tpl_keys:?}");
// 注释掉的示例段不提取 // 注释掉的示例段不提取
assert!(!tpl_keys.contains(&"api-typecheck".to_string()), "注释行不算键"); assert!(
!tpl_keys.contains(&"api-typecheck".to_string()),
"注释行不算键"
);
// 项目文件与模板一致 → pass // 项目文件与模板一致 → pass
let (pass, _) = check_lefthook_keys(Some(TMPL_LEFTHOOK), TMPL_LEFTHOOK); let (pass, _) = check_lefthook_keys(Some(TMPL_LEFTHOOK), TMPL_LEFTHOOK);
@ -847,7 +1191,10 @@ mod tests {
let (pass, detail) = check_lefthook_keys(Some(partial), TMPL_LEFTHOOK); let (pass, detail) = check_lefthook_keys(Some(partial), TMPL_LEFTHOOK);
assert!(!pass); assert!(!pass);
assert!(detail.contains("缺少检查段"), "应列出缺失键: {detail}"); assert!(detail.contains("缺少检查段"), "应列出缺失键: {detail}");
assert!(!detail.contains("typecheck,") && detail.contains("blueprint-gate"), "已有键不在缺失列表: {detail}"); assert!(
!detail.contains("typecheck,") && detail.contains("blueprint-gate"),
"已有键不在缺失列表: {detail}"
);
// 文件缺失 → fail // 文件缺失 → fail
assert!(!check_lefthook_keys(None, TMPL_LEFTHOOK).0); assert!(!check_lefthook_keys(None, TMPL_LEFTHOOK).0);
@ -910,4 +1257,3 @@ mod tests {
assert_eq!(silent.len(), 2, "pw-b 的回传过期后也应列为沉默"); assert_eq!(silent.len(), 2, "pw-b 的回传过期后也应列为沉默");
} }
} }

View File

@ -8,6 +8,7 @@ const MCP_KEY: &str = "lian-jing";
const LEGACY_KEY_PREFIX: &str = "lian-jing:group-"; const LEGACY_KEY_PREFIX: &str = "lian-jing:group-";
const CODEX_MCP_MANAGED_START: &str = "# LIANJING_MCP_MANAGED_START"; const CODEX_MCP_MANAGED_START: &str = "# LIANJING_MCP_MANAGED_START";
const CODEX_MCP_MANAGED_END: &str = "# LIANJING_MCP_MANAGED_END"; 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)?)), |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
); );
let (win_path, wsl_path, platform) = let (win_path, wsl_path, platform) = row.map_err(|_| format!("项目 {} 不存在", project_id))?;
row.map_err(|_| format!("项目 {} 不存在", project_id))?;
if platform.as_deref() == Some("wsl") { if platform.as_deref() == Some("wsl") {
let wsl = wsl_path.ok_or_else(|| "WSL 项目未配置 wsl_path".to_string())?; 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> { 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> { 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> { fn codex_config_path(project_id: &str) -> Result<PathBuf, String> {
Ok(project_root(project_id)?.join(".codex").join("config.toml")) Ok(project_root(project_id)?.join(".codex").join("config.toml"))
} }
/// 写炼境上下文文件,供子项目 Claude 会话直接读取 project_id。 fn read_context_object(path: &PathBuf, label: &str) -> Result<Value, String> {
/// 合并写入:保留文件中已有的其他字段;主动删除 group_id(单一服务器寻址后已无用,留着会过期误导) match std::fs::read_to_string(path) {
fn write_liangjing_context(project_id: &str, port: u16) -> Result<(), String> { Ok(content) if content.trim().is_empty() => Ok(json!({})),
let path = liangjing_path(project_id)?; 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() { if let Some(parent) = path.parent() {
std::fs::create_dir_all(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) { let content = serde_json::to_string_pretty(value).map_err(|error| error.to_string())?;
Ok(s) => serde_json::from_str::<Value>(&s) std::fs::write(path, content).map_err(|error| format!("写入 {label} 失败: {error}"))
.ok() }
.filter(|v| v.is_object())
.unwrap_or_else(|| json!({})), /// 写工具无关的项目身份锚点。项目根目录是边界,客户端项目 UI 或用户级配置均不是身份来源。
Err(_) => json!({}), /// 合并写入:保留未来字段与项目定制字段,只有炼境拥有的身份字段会被刷新。
}; 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 对象"); let obj = ctx.as_object_mut().expect("ctx 必为 JSON 对象");
obj.insert("project_id".into(), json!(project_id)); obj.insert("project_id".into(), json!(project_id));
obj.insert("mcp_port".into(), json!(port)); obj.insert("mcp_port".into(), json!(port));
obj.remove("group_id"); obj.remove("group_id");
let content = serde_json::to_string_pretty(&ctx).map_err(|e| e.to_string())?; write_context_object(&path, &ctx, "Claude 兼容上下文")
std::fs::write(&path, content)
.map_err(|e| format!("写入 liangjing.json 失败: {}", e))
} }
/// 查找 claude CLI 路径(优先环境变量 CLAUDE_BIN,然后常用安装目录,最后 where 命令) /// 查找 claude CLI 路径(优先环境变量 CLAUDE_BIN,然后常用安装目录,最后 where 命令)
@ -130,7 +167,10 @@ fn find_claude_bin() -> Option<String> {
} }
// 最后尝试 where 命令(GUI 进程 PATH 可能不含用户 bin,but worth trying) // 最后尝试 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 out.status.success() {
if let Ok(stdout) = String::from_utf8(out.stdout) { if let Ok(stdout) = String::from_utf8(out.stdout) {
if let Some(line) = stdout.lines().next() { if let Some(line) = stdout.lines().next() {
@ -257,11 +297,21 @@ fn try_register_globally(port: u16) -> bool {
/// 幂等 best-effort:不存在的名字 remove 失败仅忽略。返回成功移除的名字列表。 /// 幂等 best-effort:不存在的名字 remove 失败仅忽略。返回成功移除的名字列表。
pub fn cleanup_legacy_global_registrations() -> Vec<String> { pub fn cleanup_legacy_global_registrations() -> Vec<String> {
let Some(claude_bin) = find_claude_bin() else { 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(); return Vec::new();
}; };
let Some(git_bash) = find_git_bash() else { 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(); return Vec::new();
}; };
let Ok(conn) = db::pool().get() else { let Ok(conn) = db::pool().get() else {
@ -303,8 +353,8 @@ fn read_json(path: &PathBuf) -> Result<Value, String> {
if !path.exists() { if !path.exists() {
return Ok(json!({})); return Ok(json!({}));
} }
let content = std::fs::read_to_string(path) let content =
.map_err(|e| format!("读取 settings.json 失败: {}", e))?; std::fs::read_to_string(path).map_err(|e| format!("读取 settings.json 失败: {}", e))?;
if content.trim().is_empty() { if content.trim().is_empty() {
return Ok(json!({})); return Ok(json!({}));
} }
@ -319,8 +369,7 @@ fn read_json(path: &PathBuf) -> Result<Value, String> {
fn write_json(path: &PathBuf, value: &Value) -> Result<(), String> { fn write_json(path: &PathBuf, value: &Value) -> Result<(), String> {
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent) std::fs::create_dir_all(parent).map_err(|e| format!("创建 .claude 目录失败: {}", e))?;
.map_err(|e| format!("创建 .claude 目录失败: {}", e))?;
} }
let content = serde_json::to_string_pretty(value).map_err(|e| e.to_string())?; 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)) 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, block,
&current[end + CODEX_MCP_MANAGED_END.len()..] &current[end + CODEX_MCP_MANAGED_END.len()..]
), ),
(Some(_), _) | (_, Some(_)) => return Err(format!( (Some(_), _) | (_, Some(_)) => {
return Err(format!(
"{} 的炼境 Codex 管理块不完整或顺序错误,请修复标记后重试", "{} 的炼境 Codex 管理块不完整或顺序错误,请修复标记后重试",
path.display() path.display()
)), ))
}
(None, None) (None, None)
if current if current
.lines() .lines()
@ -394,7 +445,11 @@ fn remove_codex_mcp_config(path: &PathBuf) -> Result<(), String> {
) )
.trim_matches(['\r', '\n']) .trim_matches(['\r', '\n'])
.to_string(); .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}")) 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> { pub fn inject_project_mcp(project_id: &str) -> Result<(), String> {
let port = crate::mcp_server::read_port(); let port = crate::mcp_server::read_port();
let server_url = format!("http://127.0.0.1:{}/mcp", 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)?; upsert_codex_mcp_config(&codex_config_path(project_id)?, &server_url)?;
let path = settings_path(project_id)?; let path = settings_path(project_id)?;
let mut settings = read_json(&path)?; 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)); log_event("mcp_inject", "info", "MCP 配置注入成功", Some(&ctx));
})?; })?;
// 写炼境上下文,让子项目 Claude 会话无需遍历即可知道 project_id // 保留 Claude 历史上下文作为兼容投影;项目身份以 .lian-jing/context.json 为准。
let _ = write_liangjing_context(project_id, port); write_liangjing_context(project_id, port)?;
// 同步注册到 ~/.claude.json,让 Claude Code 能加载 MCP 工具(best-effort) // 同步注册到 ~/.claude.json,让 Claude Code 能加载 MCP 工具(best-effort)
try_register_globally(port); try_register_globally(port);
@ -492,7 +548,11 @@ pub fn migrate_to_single_server() {
format!(";失败详情: {}", errors.join("; ")) 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); db::log_event("mcp_inject", level, &summary, None);
// 全局注册失败说明 claude CLI 链路不通(CLI 缺失/git-bash 缺失/命令报错), // 全局注册失败说明 claude CLI 链路不通(CLI 缺失/git-bash 缺失/命令报错),
@ -544,8 +604,14 @@ mod tests {
/// 炼境进程 cwd(src-tauri/,2026-07-15 实锤,apply 链路同日已拦本处漏拦)。 /// 炼境进程 cwd(src-tauri/,2026-07-15 实锤,apply 链路同日已拦本处漏拦)。
#[test] #[test]
fn registered_path_rejects_empty_and_relative() { fn registered_path_rejects_empty_and_relative() {
assert!(validate_registered_path("", "win_path").is_err(), "空串应拒绝"); assert!(
assert!(validate_registered_path(" ", "win_path").is_err(), "空白应拒绝"); validate_registered_path("", "win_path").is_err(),
"空串应拒绝"
);
assert!(
validate_registered_path(" ", "win_path").is_err(),
"空白应拒绝"
);
assert!( assert!(
validate_registered_path("src-tauri", "win_path").is_err(), validate_registered_path("src-tauri", "win_path").is_err(),
"相对路径应拒绝(相对炼境 cwd 解析)" "相对路径应拒绝(相对炼境 cwd 解析)"
@ -554,10 +620,8 @@ mod tests {
#[test] #[test]
fn codex_mcp_config_preserves_user_content_and_rejects_handwritten_conflict() { fn codex_mcp_config_preserves_user_content_and_rejects_handwritten_conflict() {
let base = std::env::temp_dir().join(format!( let base =
"lianjing_codex_mcp_test_{}", std::env::temp_dir().join(format!("lianjing_codex_mcp_test_{}", std::process::id()));
std::process::id()
));
let _ = std::fs::remove_dir_all(&base); let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join(".codex")).unwrap(); std::fs::create_dir_all(base.join(".codex")).unwrap();
let path = base.join(".codex/config.toml"); let path = base.join(".codex/config.toml");
@ -570,7 +634,10 @@ mod tests {
assert!(configured.contains("url = \"http://127.0.0.1:27190/mcp\"")); assert!(configured.contains("url = \"http://127.0.0.1:27190/mcp\""));
remove_codex_mcp_config(&path).unwrap(); 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"; let manual = "[mcp_servers.lian-jing]\nurl = \"http://manual/mcp\"\n";
std::fs::write(&path, manual).unwrap(); std::fs::write(&path, manual).unwrap();
@ -579,6 +646,42 @@ mod tests {
let _ = std::fs::remove_dir_all(&base); let _ = std::fs::remove_dir_all(&base);
} }
#[test]
fn canonical_context_migrates_legacy_project_id_and_preserves_unknown_fields() {
let base = std::env::temp_dir().join(format!(
"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] #[test]
fn registered_path_accepts_absolute_and_unc() { fn registered_path_accepts_absolute_and_unc() {
assert!(validate_registered_path("E:\\code-project\\demo", "win_path").is_ok()); assert!(validate_registered_path("E:\\code-project\\demo", "win_path").is_ok());

View File

@ -262,7 +262,7 @@ pub fn tools_list_result() -> Value {
}, },
{ {
"name": "verify_onboarding", "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": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "properties": {