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
All checks were successful
Push & PR Check / check (push) Successful in 1m3s
Reviewed-on: #8
This commit is contained in:
commit
1f38aebbac
@ -1,9 +1,9 @@
|
||||
version: 1
|
||||
name: 炼境
|
||||
description: >
|
||||
Git 多空间开发管理器。以蓝图为神经系统,以 Claude 为智能层,
|
||||
持续感知所管理项目的功能运转、质量状态与成长方向,
|
||||
成为每个项目的贴身导师。炼境本身亦被纳入其中,
|
||||
Git 多空间开发管理器与项目 Agent 化的本地控制面。以蓝图为神经系统,
|
||||
为 Codex、Claude 等可替换客户端 adapter 分发基线、发现状态并采集交付信号;
|
||||
项目自身保留业务规则、测试、部署声明与交付决策。炼境本身亦被纳入其中,
|
||||
形成可自我进化的开发智能闭环。
|
||||
iteration: 1
|
||||
updated: 2026-09-21
|
||||
@ -459,6 +459,15 @@ 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: done
|
||||
progress: 100
|
||||
note: "P2 完成:工具无关 canonical context、版本化能力包自检与 Codex Skill-only 投影已闭环;不复制 MCP、凭证或部署能力。"
|
||||
updated: 2026-09-21
|
||||
position: [2250, 800]
|
||||
edges:
|
||||
# 前端依赖
|
||||
- from: workspace-mgmt
|
||||
@ -750,3 +759,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
|
||||
|
||||
58
.blueprint/modules/agent-client-capability-packs.md
Normal file
58
.blueprint/modules/agent-client-capability-packs.md
Normal 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 边界。"
|
||||
@ -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 上下文文档(速查卡、契约映射)
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
> 把此文件粘贴给任何 AI Agent,让它了解本项目的 git 工作方式。由炼境接入包分发。
|
||||
> `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 创建(炼境托管项目)
|
||||
|
||||
本项目由炼境管理,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": "<id>", "head": "feat/<name>", "base": "main",
|
||||
"title": "<Conventional Commits 格式>", "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 工具:
|
||||
|
||||
@ -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 调用) |
|
||||
| MCP | 内嵌 HTTP Server(供 Codex、Claude 等客户端 adapter 调用) |
|
||||
| 包管理 | 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 兼容投影;`.lian-jing/capability-packs.json` 记录托管基线版本,版本不符必须通过自检报告升级提示
|
||||
|
||||
## 目录速查
|
||||
|
||||
|
||||
@ -15,6 +15,15 @@ contracts:
|
||||
- "src-tauri/src/commands/gitea.rs"
|
||||
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 接口(前后端契约)
|
||||
paths:
|
||||
- "src-tauri/src/commands/**/*.rs"
|
||||
|
||||
10
docs/templates/agents-meta-rules.md
vendored
10
docs/templates/agents-meta-rules.md
vendored
@ -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 友好工程四支柱
|
||||
|
||||
5
plugins/README.md
Normal file
5
plugins/README.md
Normal file
@ -0,0 +1,5 @@
|
||||
# 炼境 Codex 插件源
|
||||
|
||||
`lian-jing-codex` 是 Skill-only 插件源码。它只指导 Codex 使用项目内已存在的炼境上下文与 MCP,不打包 MCP server、凭证、项目 ID 或部署能力。
|
||||
|
||||
项目身份始终来自 `.lian-jing/context.json`;Codex 的项目 UI、用户级插件安装和客户端全局配置都不是项目身份来源。
|
||||
18
plugins/lian-jing-codex/.codex-plugin/plugin.json
Normal file
18
plugins/lian-jing-codex/.codex-plugin/plugin.json
Normal 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 获取交付信号。"
|
||||
}
|
||||
}
|
||||
16
plugins/lian-jing-codex/skills/lian-jing/SKILL.md
Normal file
16
plugins/lian-jing-codex/skills/lian-jing/SKILL.md
Normal 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 映射;这些都属于项目接入与炼境控制面。
|
||||
@ -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 配置。"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -18,7 +18,7 @@
|
||||
|
||||
<!-- TODO: 列出 README / ARCHITECTURE.md / 其他关键文档及用途。 -->
|
||||
|
||||
<!-- ONBOARDING_MANAGED_START version="1.7.0" -->
|
||||
<!-- ONBOARDING_MANAGED_START version="1.8.0" -->
|
||||
> ⚠️ 本块由炼境版本化托管,升级时**整块原位覆盖**——项目定制请写到块外,块内手改必丢(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 的投影
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
> 把此文件粘贴给任何 AI Agent,让它了解本项目的 git 工作方式。由炼境接入包分发。
|
||||
> `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 创建(炼境托管项目)
|
||||
|
||||
本项目由炼境管理;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": "<id>", "head": "feat/<name>", "base": "main",
|
||||
"title": "<Conventional Commits 格式>", "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 工具:
|
||||
|
||||
@ -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.1.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<TemplateResult> {
|
||||
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<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 接管。
|
||||
fn hook_is_lefthook(content: &str) -> bool {
|
||||
content.contains("lefthook")
|
||||
@ -299,7 +491,9 @@ fn parse_origin_url(git_config: &str) -> Option<String> {
|
||||
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,123 @@ 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 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]
|
||||
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 +1077,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 +1106,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 +1114,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 +1133,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 +1153,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 +1171,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 +1191,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 +1257,3 @@ mod tests {
|
||||
assert_eq!(silent.len(), 2, "pw-b 的回传过期后也应列为沉默");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
¤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());
|
||||
|
||||
@ -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": {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user