Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c12e8df0ae | ||
|
|
3d9336d908 | ||
|
|
1bc0629827 | ||
|
|
55ee5a483c | ||
|
|
cbc1d46c4e | ||
|
|
d15d3895d7 | ||
|
|
f0e5b70025 | ||
|
|
b01f1d1d81 | ||
|
|
0516de5304 | ||
|
|
5b0b068148 | ||
|
|
ade767894f | ||
|
|
f7ab6dbbb8 | ||
|
|
7cfbfb1b86 | ||
|
|
473cf7e92b | ||
| 21f1b60aea | |||
|
|
1672b071e4 | ||
|
|
2fe576b75a | ||
|
|
aaf3343133 | ||
|
|
f04eb6e766 | ||
|
|
be2f69578a | ||
|
|
8c746cf8d5 | ||
|
|
67bdacbf2e | ||
|
|
85d488e831 | ||
|
|
ce3ef6e14e | ||
|
|
92da0fda0d | ||
|
|
fd869ee161 | ||
|
|
1c44bfe30a | ||
|
|
46640783f9 | ||
|
|
86995f7472 | ||
|
|
4226372423 | ||
|
|
31f93adbb2 | ||
|
|
9355c07a6f | ||
|
|
a05fb49347 | ||
|
|
0d1427f4e9 | ||
| 01eb941ab9 | |||
|
|
df7d52a084 | ||
|
|
a41161f7d1 | ||
|
|
daed723438 | ||
|
|
138a06aa17 | ||
|
|
bce9a0a37c |
@ -1,6 +1,6 @@
|
||||
---
|
||||
version: 1.10.0
|
||||
updated: 2026-07-03
|
||||
version: 1.12.0
|
||||
updated: 2026-07-18
|
||||
source: dev-manager-tauri
|
||||
---
|
||||
|
||||
@ -136,6 +136,11 @@ acceptance 是 agent 自验的唯一判断依据,必须是**可执行断言**
|
||||
|
||||
最小结构:`[载体] 调用/运行什么(参数) → 断言什么`。
|
||||
|
||||
**含 UI 操作路径时必须分层**:
|
||||
- `agent_verify:` — 机器可断言(函数返回值、HTTP 状态码、数据库记录);stop hook / CI 只验此段
|
||||
- `human_verify:` — 需人眼判断(视觉效果、拖拽手势、动画);30 秒浏览器终验
|
||||
纯命令行 / API 任务可省略 `human_verify:`;UI 拖拽 / 视觉类必须拆,否则 stop hook 无法收敛。
|
||||
|
||||
含 Rust 业务逻辑的 M/L 卡,acceptance 还须包含(详见 Rust 代码可测试性规范):
|
||||
- `cargo test 通过` + 依赖注入写法确认
|
||||
|
||||
@ -304,6 +309,19 @@ Claude 在每次对话中应遵循以下流程:
|
||||
| "开始做 XX" | 模块 status → in_progress |
|
||||
| "为什么选这个方案" | 记录到模块的 `## 决策记录` |
|
||||
|
||||
> 开始写代码前,先判断改动类型:**Bug Fix**(意外偏差,直接执行)还是 **Feature**(新增能力/主动改变行为,必须先输出确认卡等触发词)。完整触发规则见 AGENTS.md「需求对齐协议」章节。
|
||||
|
||||
#### 动工前辩证收缩(M/L 卡必做,S 卡可跳过)
|
||||
|
||||
任务卡拆好后、加锁动工前,对每张 M/L 卡做一轮**多角度辩证**,典型追问:
|
||||
|
||||
- **该不该做**:这张卡解决的问题是真实的吗?做了会不会伤及已有体验(如自动触发打断用户节奏)?→ 可能直接否决
|
||||
- **载体对不对**:实现层选对了吗(hook vs 数据层、前端 vs 后端、轮询 vs 事件)?→ 可能翻转载体
|
||||
- **设计有没有洞**:边界条件、并发、旧数据兼容有没有漏?→ 补进任务卡
|
||||
|
||||
辩证结论写回任务卡(否决的标 abandoned 并记原因)。
|
||||
实证(2026-07-15 炼境自身):3 张初稿卡辩证后 1 张否决、1 张载体翻转、1 张补两处设计缺陷——直做会错 2/3;实施后 U3/B4 零返工。
|
||||
|
||||
### 阶段 3:实现功能 — 两阶段执行锁
|
||||
|
||||
任务卡执行遵循**两阶段提交**:开始写第一行代码前加锁(信息1),全部完成后解锁(信息2)。只有信息1没有信息2,说明任务被中断。
|
||||
@ -373,7 +391,7 @@ Claude 应关注这些信号来决定是否更新蓝图:
|
||||
> B) 现在拉清单,走 /architect 重新规划
|
||||
> C) 继续执行,我在 session 结束前汇总成任务卡
|
||||
|
||||
**Session 结束汇总**:任何有代码改动但无预建任务卡的会话,结束前 Claude 应输出:
|
||||
**Session 结束汇总**(仅适用于漂移检测触发后用户主动选择 C 选项的场景,**不得作为跳过确认卡的通道**):
|
||||
|
||||
> 📋 本次会话实际改动:[文件列表 + 简述]
|
||||
> 是否整理为蓝图任务卡?
|
||||
@ -638,6 +656,8 @@ cp -r .claude/skills/splitter ~/.claude/skills/
|
||||
|
||||
## Rust 代码可测试性规范
|
||||
|
||||
> ⚠️ **本节仅适用于 Tauri / Rust 项目**,非 Tauri 项目跳过本节全部内容。
|
||||
|
||||
> 本规范仅适用于**新增** Rust 函数,存量代码不强制回填。
|
||||
|
||||
### 强制规则
|
||||
|
||||
@ -370,10 +370,11 @@ modules:
|
||||
- id: onboarding-pack
|
||||
name: 接入包
|
||||
area: backend
|
||||
status: in_progress
|
||||
progress: 99
|
||||
note: "体系分发主线:T1/T2/B1/B2/B3/V1 ✅(T2 2026-07-04:git-workflow 复杂度感知分支策略;B3 2026-07-04:manifest agent_button_carrier + splitter 三级载体继承);剩 B4 健康诊断遵守度(后置)"
|
||||
status: done
|
||||
progress: 100
|
||||
note: "体系分发主线全卡完成:…/V1/U3/B4/U1 ✅(2026-07-15 择升环三断点修复收官:U3 规则校验 / B4 沉默项目信号 / U1 全托管升级+批量重分发)。U1 批量模式 MCP 实调待重建安装后终验,通过后模块收官 done"
|
||||
position: [3500, 0]
|
||||
updated: 2026-07-15
|
||||
|
||||
- id: gitea-integration
|
||||
name: Gitea 集成
|
||||
@ -416,6 +417,23 @@ modules:
|
||||
note: "项目(默认A+可选B)& 产品组(默认F+可选G)四视图,多对一关系,用户可新建"
|
||||
position: [4000, 0]
|
||||
|
||||
- id: governance-feedback-loop
|
||||
name: 治理反馈闭环
|
||||
area: backend
|
||||
status: concept
|
||||
progress: 0
|
||||
note: "老板-员工手册隐喻:用户反馈子项目问题 → 结构化落账 → 梦核蒸馏进规则/模板 → 批量分发 → 飞轮观测生效。三缺口:反馈入口、反馈进梦核高权重输入、接入包批量重分发"
|
||||
position: [1250, 650]
|
||||
|
||||
- id: mcp-single-server
|
||||
name: MCP 单一服务器化
|
||||
area: backend
|
||||
status: done
|
||||
progress: 100
|
||||
updated: 2026-07-06
|
||||
note: "T1-T6 全部完成并端到端终验(2026-07-06):单一 lian-jing 服务器上线,~/.claude.json 12 条旧注册清零,3 项目抽查新格式通过,旧路由 404。T4 暴露并修复 git-bash 硬编码存量 bug(0d1427f)。遗留:同路径重复登记待清理(见模块 done_note)"
|
||||
position: [1750, 500]
|
||||
|
||||
edges:
|
||||
# 前端依赖
|
||||
- from: workspace-mgmt
|
||||
@ -662,3 +680,22 @@ edges:
|
||||
- from: gitea-integration
|
||||
to: github-decommission
|
||||
type: related
|
||||
|
||||
# MCP 单一服务器化
|
||||
- from: mcp-server
|
||||
to: mcp-single-server
|
||||
type: dependency
|
||||
- from: mcp-config-inject
|
||||
to: mcp-single-server
|
||||
type: dependency
|
||||
|
||||
# 治理反馈闭环
|
||||
- from: blueprint-feedback
|
||||
to: governance-feedback-loop
|
||||
type: dependency
|
||||
- from: blueprint-governance
|
||||
to: governance-feedback-loop
|
||||
type: dependency
|
||||
- from: onboarding-pack
|
||||
to: governance-feedback-loop
|
||||
type: dependency
|
||||
|
||||
74
.blueprint/modules/governance-feedback-loop.md
Normal file
74
.blueprint/modules/governance-feedback-loop.md
Normal file
@ -0,0 +1,74 @@
|
||||
# 治理反馈闭环
|
||||
|
||||
老板-员工手册隐喻:用户发现子项目不足 → 告诉炼境(结构化落账)→ 炼境更新"企业文化与员工手册"(CONVENTIONS 规则 + 接入包模板)→ 批量分发给子项目 → 飞轮观测新规则是否生效。
|
||||
|
||||
## 为什么
|
||||
|
||||
- 现在用户发现子项目问题只能在会话里口头说(如 2026-07-06 的 group_id 探路问题),不落任何结构化通道,问题→体系改进全靠当次会话的执行力,跨会话就丢
|
||||
- 治理管线已存在大半(blueprint-governance 的规则编辑与批量同步、blueprint-feedback 的梦核蒸馏、onboarding-pack 的模板版本化分发),但三处断环让"反馈驱动体系进化"没有闭合
|
||||
|
||||
## 三个缺口(待 /architect 细化)
|
||||
|
||||
### ① 治理反馈入口
|
||||
- 现状:`append_project_note` 挂单项目、无类型标记,是"员工工作日志"而非"给老板的管理反馈"
|
||||
- 方向:`report_governance_feedback` 类入口(MCP 工具优先,UI 上报按钮可后置),记录:哪个项目、什么问题、疑似哪条规则/模板缺陷;落 project_notes 加类型字段或独立治理账本表
|
||||
- 形态待定:MCP 工具 vs UI 按钮 vs 笔记类型约定(三选或组合,architect 阶段定)
|
||||
|
||||
### ② 反馈进入梦核蒸馏
|
||||
- 现状:梦核吃被动数据(commit 指标、复盘笔记、停滞模块、高风险 scope)
|
||||
- 方向:用户主动治理反馈作为梦核 prompt 的高权重数据段(老板亲口说的问题 > 统计推断的问题),蒸馏产出规则修订/模板修订建议
|
||||
|
||||
### ③ 接入包批量重分发
|
||||
- 现状:`sync_blueprint_rules` 有批量,但接入包 `apply_onboarding_pack` 单项目一次一发;模板更新后无"全员换发新手册"动作
|
||||
- 方向:批量 re-apply(复用 ONBOARDING_MANAGED 块安全覆写机制),输出报告:哪些项目更新了哪些块、哪些跳过/失败
|
||||
|
||||
## 闭环全景
|
||||
|
||||
```
|
||||
发现问题 → ① 结构化落账 → ② 梦核蒸馏 → 更新 CONVENTIONS/模板
|
||||
↑ ↓
|
||||
飞轮观测生效(Rules-Applied trailer / 复盘) ← ③ 批量分发
|
||||
```
|
||||
|
||||
每一环都有数据留痕,管理动作可回溯。
|
||||
|
||||
## 设计档案:agent 自验协议的辩证与务实版选择(2026-07-06)
|
||||
|
||||
用户确立的协作协议:**需求对话 → 构建默认 agent 按钮化 → agent 自验 → 快速自修 → 结构化反馈 → 人终验**。经多角度辩证后识别出五个内生张力与对应调节器,最终按"大量开发者经验(流程死于执行成本而非设计错误;检查单短才有效;机制记的留下、靠记忆的蒸发)"选择**最小执行版**落地,完整辩证存档于此备查:
|
||||
|
||||
| 内生张力 | 调节器 | 关键点 |
|
||||
|---------|--------|--------|
|
||||
| 运动员兼裁判(验证与功能共享理解偏差) | 负面披露(「没验什么」一节) | 只覆盖已知盲区;未知盲区由人终验兜底——两柱互补,缺一超载。实证:mcp-single-server 自验全绿但 git-bash 层全挂,靠端到端终验抓住 |
|
||||
| Goodhart(宽松断言让自己过) | 改断言需报备 | 非自觉依赖:断言修改在 diff 天然可审计 |
|
||||
| 橡皮图章化(终验退化为签字) | 意外感触发深查 | 深查频率是自由参数,框架内无机制阻止衰减——最终锚是人的纪律,框架诚实承认此点方自洽 |
|
||||
| 默认按钮化 vs 少而稳 | 默认分层 | 默认的是"存在可验证路径",不是"永久驻军测试";豁免需声明 |
|
||||
| 不可按钮化工作(审美/体感/探索) | 显式豁免 | 强推得到仪式性假按钮 |
|
||||
|
||||
**关键澄清(两把尺子)**:acceptance 由 agent 参与撰写,可能继承需求理解偏差——agent 自验对 acceptance,人终验必须对**意图**,否则是拿被污染的尺子量两次。
|
||||
|
||||
**落地物**(最小版,执行成本趋零):AGENTS.md / 接入包模板 / agents-meta-rules 增设「自验报告纪律」三条;用户侧两个习惯(先看「没验什么」;意外感或外部契约变更时深查)。刻意不做:新工具、强制 hook、报告模板系统——坑驱动增量,人肉版跑出疼点后再在本模块机制化。
|
||||
|
||||
## 使者回传处置台账(2026-07-18 消化,共 23 条)
|
||||
|
||||
### 已落地(本轮 3 项)
|
||||
- **/ship 三处模板修正**(`~/.claude/commands/ship.md`,全局命令即分发源):① blueprint 尾 commit 铁律改为「每轮 /ship 的最后一个 PR 末位 commit」(多 PR 场景 manifest 单文件不可能每 PR 都带);② 规模闸补两个不可拆例外(单一功能域强耦合 / 全新业务线初版)→ 警告放行不阻塞;③ MCP 连接错误注明可原地重试 2~3 次(偶发抖动非业务失败)
|
||||
- **envoy.md 分类硬门槛**(接入包全托管文件):Step 3 前置追问「任何有经验的工程师是否会提前想到?」——常识性知识与过窄 corner case 一律留本项目,堵住 2 例误回传的根因
|
||||
- **CONVENTIONS v1.12.0**:飞轮工作流阶段 2 末新增「动工前辩证收缩」(M/L 卡必做),实证返工率对照写入
|
||||
|
||||
### 此前会话已落地(11 条,本轮核实关闭)
|
||||
MANAGED 块警示(5b0b068)、Bug Fix/Feature 分类 + S 级轻量卡 + 汇总补录限定语(67bdacb AGENTS v1.5.0)、蓝图机器门控(f04eb6e blueprint-gate 双层,回应 07-09「文档约束无机器门控」假设)、lefthook API typecheck 必填项 + CONVENTIONS scope 前置提示(be2f695 v1.11.0)、Playwright 版本锁死(AGENTS tmpl)、agent_verify/human_verify 分层(AGENTS + CONVENTIONS)、/ship 阶段八 rebase 前 stash 保护(已在 ship.md)、空路径分发防护(473cf7e)
|
||||
|
||||
### 待观察(log_for_review,暂不动模板)
|
||||
- **verify_onboarding lefthook 键名匹配是名义非语义**:等价功能不同键名会误报缺失;enterprise-system 6/7 fail 待核实是缺失还是改名
|
||||
- **/ship 运行期间并行开发混入变更**(under_validation):建议最终报告对比第零步 status 快照、新增变更单独列出不纳入本轮——再积累 1~2 次实例后进模板
|
||||
- **/antirot 命令模板化**:enterprise-system 实现的文档防腐命令(机器验+文档一致性+蓝图健康三步),候选纳入接入包标配
|
||||
- **Loop 模型路由**(Haiku=Observer / Sonnet=Actor)与**种子层/土壤层分工**:enterprise-system 特有 Loop 体系,尚不构成跨项目通用约定,观察其长期效果
|
||||
- **OpenAI SDK tool_calls 联合类型守卫**:可作 AI 代码审查检查项候选(project_type:web)
|
||||
|
||||
### 遗留尾巴
|
||||
- ~~src-tauri/ 下空路径污染残留~~ → 2026-07-18 辩证收缩后三步收官:①9 个污染文件已移出(暂存 `%TEMP%\liangjing-pollution-20260718`);②两条空路径僵尸登记(agent编排界面(opencode)/银龙集团应用)已 unregister(磁盘无对应目录,实为死登记);③辩证挖出 **inject 链路漏拦同一空路径洞**(`project_root()` 对空串 win_path 穿透写 cwd,正是 settings.json/liangjing.json 污染的来源)——已单点补 `validate_registered_path`(拒空+拒相对路径)+ 2 条回归测试。空路径事故至此两链路(apply/inject)全堵
|
||||
- envoy.md 硬门槛已进模板,**需重建安装炼境后 apply 才会分发到子项目**(模板打包在二进制资源)
|
||||
|
||||
## 任务卡
|
||||
|
||||
(concept 阶段,L 级——待 /architect 设计确认后由 /splitter 拆卡)
|
||||
@ -2,9 +2,13 @@
|
||||
|
||||
产品组成员变更时,炼境自动向对应项目的 `.claude/settings.json` 注入或移除 MCP 服务器配置,实现热插拔。
|
||||
|
||||
> ⚠️ 2026-07-06 起组级 key 方案被 [mcp-single-server](./mcp-single-server.md) 取代:
|
||||
> 注入 key 收敛为单一 `"lian-jing"`(URL `/mcp`),寻址不再依赖 group_id。
|
||||
> 本文任务卡中的 `lian-jing:group-{group_id}` 描述保留为历史记录。
|
||||
|
||||
## 决策记录
|
||||
|
||||
- 使用 `lian-jing:group-{group_id}` 命名空间前缀标记炼境写入的 key,与用户自有配置严格隔离
|
||||
- 使用 `lian-jing:group-{group_id}` 命名空间前缀标记炼境写入的 key,与用户自有配置严格隔离(已被 mcp-single-server 取代)
|
||||
- 炼境只操作自己命名空间下的 key,其余配置永远不动
|
||||
- 加入/退出操作由炼境 UI 驱动,炼境必然在线,无需异步清理机制
|
||||
- 注入前做 JSON 有效性校验,文件损坏时不覆盖,在 UI 提示用户
|
||||
|
||||
109
.blueprint/modules/mcp-single-server.md
Normal file
109
.blueprint/modules/mcp-single-server.md
Normal file
@ -0,0 +1,109 @@
|
||||
# MCP 单一服务器化
|
||||
|
||||
将组级挂载的 `lian-jing-{group_id}`(每产品组一个 MCP 服务器)收敛为单一全局 `lian-jing` 服务器。
|
||||
|
||||
## 为什么
|
||||
|
||||
- **摩擦随项目数复利增长,与飞轮方向相反**:每新增一个产品组 = 多一次 `claude mcp add` + 多一个授权单元 + 每个会话多一份重复工具清单(现状 12 组 × 28 工具 ≈ 300+ 工具注册)。炼境目标是"管理项目越多 → agent 辅助越强",客户端配置成本必须是 O(1)。
|
||||
- **组级挂载没有真正的隔离价值**:2026-07-06 实测任意组服务器可对任意项目执行 `inject_mcp`;组归属校验只覆盖部分工具(半吊子状态)。
|
||||
- **消除 group_id 身份卡问题**:单一服务器后 `liangjing.json` 只需 `project_id`,group_id 缺失/过期/移组同步这一整类问题从根上消失。
|
||||
|
||||
## 设计决策(2026-07-06 /architect)
|
||||
|
||||
### 方案选择
|
||||
|
||||
- A. 直接切换:加全局路由同时删旧路由 —— ❌ 所有存量会话立刻断链,故障静默
|
||||
- **B. 双路由过渡(选定)**:新增全局 `/mcp` 路由,旧 `/mcp/group/:gid` 保留兼容;注入逻辑切到新名字;批量迁移后旧路由在后续版本删除
|
||||
- C. 全局 alias 与组服务器并存 —— ❌ 不消除 O(N) 注册,白改
|
||||
|
||||
### 核心接口变更
|
||||
|
||||
1. **路由**(`mcp_server.rs`):新增 `POST/GET /mcp` + `POST /mcp/messages`;`process_rpc` 的组上下文改为 `Option<String>`(旧路由 `Some(gid)`,新路由 `None`)
|
||||
2. **工具层**(`mcp_tools.rs`):
|
||||
- `resolve_project_root(project_id)` 去组化——直查 `project_workspaces`,删除 JOIN `project_group_map` 的归属过滤
|
||||
- `get_project_mentor_context` / `append_project_note` / `get_project_snapshots` 三处 membership check 删除(改为项目存在性检查)
|
||||
- `list_group_projects` 新增可选参数 `group_id`:显式参数 > 挂载上下文(旧路由)> 报错提示传参;`tools_list_result` schema 同步
|
||||
3. **注入层**(`mcp_inject.rs`):
|
||||
- `inject_project_mcp(project_id)` 去掉 `group_id` 参数;settings.json 写入 key `"lian-jing"`、URL `http://127.0.0.1:{port}/mcp`
|
||||
- 注入时顺手删除同文件中所有 `lian-jing:group-*` 旧 key(迁移内建于重注入,`reinject_all_mcp` 即批量迁移器)
|
||||
- `liangjing.json` 只写 `project_id` + `mcp_port`,**主动删除**已有 `group_id` 键(避免过期数据)
|
||||
- 全局注册收敛为一次 `claude mcp add lian-jing`;新增迁移函数清理旧注册——遍历 `product_groups`,对**完整 UUID 名和短 8 位名两种格式**各尝试 `claude mcp remove -s user`(幂等,失败仅记日志)
|
||||
- `remove_group_member` 语义修正:项目退出某组时,仅当它不再属于任何组才移除 `"lian-jing"` key
|
||||
4. **附带收益**:`inject_mcp` 不再要求项目必须属于产品组("未加入任何产品组"报错可移除),未分组项目也能接入
|
||||
|
||||
### 受影响文件
|
||||
|
||||
- 修改:`src-tauri/src/mcp_server.rs` — 新增全局路由,process_rpc 组上下文 Option 化
|
||||
- 修改:`src-tauri/src/mcp_tools.rs` — resolve_project_root 去组化、3 处校验删除、list_group_projects 参数化、schema 更新、inject_mcp 分支简化
|
||||
- 修改:`src-tauri/src/mcp_inject.rs` — key/URL/注册收敛、旧 key 清理、liangjing.json 瘦身、全局旧注册迁移函数
|
||||
- 修改:`src-tauri/src/commands/groups.rs` — inject 调用签名、remove_group_member 条件移除
|
||||
- 修改:`src/components/settings/SettingsPage.tsx:537` — URL 展示改为 `/mcp`
|
||||
- 修改:`docs/ai-context/git-workflow.md` + `src-tauri/resources/onboarding/git-workflow.md.tmpl` — 寻址说明改写
|
||||
- 修改:`.claude/skills/retro-catchup/SKILL.md` — 解析 `lian-jing:group-` 前缀的逻辑改为 `lian-jing`
|
||||
- 修改:`.blueprint/modules/mcp-config-inject.md` — 补注记:组级 key 方案已被本模块取代
|
||||
- 仓库外(执行阶段处理):`~/.claude.json` 12 条旧注册清理、~15 个项目 settings.json/liangjing.json 批量重注入、用户全局 `~/.claude/CLAUDE.md` 炼境规则段改写
|
||||
|
||||
### 迁移顺序(切换编排)
|
||||
|
||||
1. 代码落地(双路由并存,零破坏)→ cargo test + curl 冒烟新路由
|
||||
2. 全局注册 `lian-jing` + 清理旧注册(新会话立即可用单服务器)
|
||||
3. `reinject_all_mcp` 批量刷新存量项目(settings.json 换 key、liangjing.json 去 group_id)
|
||||
4. 文档/模板/技能/用户全局规则同步
|
||||
5. 抽查验证:3 个项目文件内容 + `claude mcp list` 只剩 `lian-jing` + 新会话实调 `get_ci_status` 走通
|
||||
6. (后置卡,下个版本)删除旧 `/mcp/group/:gid` 路由与 `Option` 兼容层
|
||||
|
||||
### 风险点
|
||||
|
||||
- 存量会话断链 → 双路由并存规避,旧路由删除后置一个版本
|
||||
- `claude mcp remove` 部分失败 → best-effort + 日志,输出手动清理命令清单兜底
|
||||
- 误删用户自有 mcpServers 配置 → 只删 `lian-jing:group-` 前缀 key(沿用现有精确删除机制)
|
||||
- webhook 已核实不受影响(路由 `/webhook/gitea/:project_id` 不含 group_id)
|
||||
|
||||
## 任务卡
|
||||
|
||||
### ✅ T1. 服务器层双路由:全局 /mcp + 组上下文 Option 化
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src-tauri/src/mcp_server.rs
|
||||
- acceptance: "[HTTP] POST http://127.0.0.1:{port}/mcp (initialize) → 200 且 serverInfo.name=炼境,GET /mcp 返回 SSE 事件流(endpoint 指向 /mcp/messages)"
|
||||
- done_note: 2026-07-06 完成。process_rpc 驻军测试覆盖握手路径(cargo test 全绿);HTTP 层实测归入 T4(需新二进制运行)。注:T6 随后并入同一发版,双路由过渡态只存在于中间 commit
|
||||
|
||||
### ✅ T2. 工具层去组化:resolve_project_root 按 project_id 直查
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src-tauri/src/mcp_tools.rs
|
||||
- depends: T1
|
||||
- acceptance: "[HTTP] 经 /mcp 调 get_project_blueprint(project_id) → 返回 manifest 内容,无'不属于产品组'错误;list_group_projects(group_id=xxx) 显式传参 → 返回成员列表;cargo test 全绿"
|
||||
- done_note: 2026-07-06 完成。resolve_project_root 直查 project_workspaces;3 处组归属校验替换为 ensure_project_exists;list_group_projects schema 改 required group_id。HTTP 实测归入 T4
|
||||
|
||||
### ✅ T3. 注入层收敛:lian-jing 单 key + 旧配置清理 + liangjing.json 瘦身
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src-tauri/src/mcp_inject.rs, src-tauri/src/mcp_tools.rs, src-tauri/src/commands/groups.rs, src-tauri/src/commands/projects.rs, src-tauri/src/lib.rs
|
||||
- depends: T1
|
||||
- acceptance: "[Tauri command] inject_project_mcp(pid) → 项目 settings.json 含 key \"lian-jing\"(URL 以 /mcp 结尾)且无 lian-jing:group-* 残留;liangjing.json 含 project_id+mcp_port 且 group_id 键被删除;remove_group_member 仅在项目不属于任何组时移除 key;cargo test 全绿"
|
||||
- done_note: 2026-07-06 完成。额外实现 migrate_to_single_server 启动时一次性迁移(标记位 mcp_single_migrated,claude CLI 缺失时不落标记等重试);delete_group/remove_group_member 加"仍属其他组则保留"判断;文件实测归入 T4
|
||||
|
||||
### ✅ T4. 存量批量迁移执行 + 端到端验证
|
||||
- status: done
|
||||
- complexity: S
|
||||
- files: src-tauri/src/mcp_inject.rs(迁移暴露 git-bash 探测 bug,修复见 0d1427f)
|
||||
- depends: T1, T2, T3, T6
|
||||
- acceptance: "[CLI] claude mcp list → lian-jing-* 旧条目清零、仅剩 lian-jing;[MCP] 抽查 3 个项目 settings.json/liangjing.json 符合新格式;经 /mcp 实调 get_diagnostics → 返回 JSON 无 error;[HTTP] POST /mcp/group/{gid} → 404"
|
||||
- done_note: 2026-07-06 完成。端到端验证暴露真 bug:CLAUDE_CODE_GIT_BASH_PATH 回退路径硬编码 Program Files,Scoop 安装的 Git 导致 claude mcp add/remove 全挂(存量 bug,组级时代即存在)——修复为多位置探测 + already exists 视为成功 + 迁移标记位以注册成功为条件(0d1427f)。首次迁移的全局注册部分手动补齐(本会话 claude mcp remove ×12 + add lian-jing),文件迁移由 migrate_to_single_server 自动完成。验证:~/.claude.json 仅剩 lian-jing、3 项目(dev-manager-tauri/enterprise-system/life-memo)配置全部新格式、新路由握手 + get_diagnostics 实调通过、旧路由 404。遗留:同路径重复登记(legacy id "dev-manager-tauri" 组 0001 与 2c5b54ef 并存)导致批量重注入时 liangjing.json project_id 翻转,待用户决定是否 unregister legacy 条目
|
||||
|
||||
### ✅ T5. 文档/模板/技能/UI 寻址说明同步
|
||||
- status: done
|
||||
- complexity: S
|
||||
- files: docs/ai-context/git-workflow.md, src-tauri/resources/onboarding/git-workflow.md.tmpl, .claude/skills/retro-catchup/SKILL.md, src/components/settings/SettingsPage.tsx, .blueprint/modules/mcp-config-inject.md
|
||||
- depends: T3
|
||||
- acceptance: "[Bash] grep -rE 'lian-jing-\\{group_id\\}|lian-jing:group-' docs/ src/ src-tauri/resources/ → 零命中(mcp-config-inject.md 保留历史注记除外);pnpm typecheck 通过"
|
||||
- done_note: 2026-07-06 完成。grep 零命中、typecheck 通过;用户全局 ~/.claude/CLAUDE.md 炼境规则段已同步改为单一 lian-jing 寻址
|
||||
|
||||
### ✅ T6. 删除旧 /mcp/group 路由与兼容层
|
||||
- status: done
|
||||
- complexity: S
|
||||
- files: src-tauri/src/mcp_server.rs, src-tauri/src/mcp_tools.rs
|
||||
- depends: T3
|
||||
- acceptance: "cargo test 全绿;grep 'mcp/group' src-tauri/src/ 零命中;[HTTP] POST /mcp/group/{gid} → 404(归入 T4 实测)"
|
||||
- done_note: 2026-07-06 完成。原计划后置一个版本,经评估提前并入本次发版:迁移在新版启动时自动重写全部客户端配置,旧路由的实际消费者只剩升级前的存量会话(服务重启后本就断开),单人环境保留价值趋近于零。tools_call 签名去 Option 化,list_group_projects 改 required 参数
|
||||
@ -30,6 +30,9 @@
|
||||
- 「完整分发」是陷阱,正确形态是分级菜单:新项目种全套,旧项目增量接入(复用 mcp_inject 的「命名空间隔离 + 合并不覆盖」语义,不动用户已有配置)
|
||||
- 接入动作:现有 `apply_blueprint_buff` 升格为 `apply_onboarding_pack`,按档编排注入
|
||||
- 语义层进料口隐患:决策/验收/踩坑仍需主动写(与饿死复盘笔记同源),须配「从 PR 标题/commit body/review 评论半自动提取」,不再单纯指望人自觉
|
||||
- **分发文件三档升级语义(2026-07-15 择升环评估)**:① 版本化块文件(AGENTS/git-workflow)→ MANAGED 块原位升级;② 炼境全托管文件(envoy.md/blueprint-gate.cjs,用户不定制)→ 内容不一致即覆盖;③ 用户可定制文件(lefthook.yml)→ 绝不覆盖,但 verify_onboarding 须能检测落后。此前 ②③ 混用「存在即跳过」导致回流修正推不到存量项目
|
||||
- **本地定制严禁写进 MANAGED 块内(2026-07-15 实调反噬实锤)**:炼境自身曾把「分支策略表 / Agent 按钮清单 / 降级约定 / 启动协议第 4 条」写在托管块内,v1.6.0/1.11.0 块升级时全部被原位覆盖抹掉(托管块的设计行为)。已恢复至块外「炼境自身约定」区。规则:托管块只放分发内容,项目定制一律块外;改块内容只能改炼境模板源头再分发
|
||||
- **回流触发不自动化(2026-07-15 多角度辩证)**:否决子项目侧 hook 自动触发 /envoy——自动化触发拆掉人肉过滤层,诱导低价值回传污染通道,伤「择」换「环」转速。回流频率问题用炼境侧可观测性解(「沉默项目」诊断信号,见 B4 卡),人留在过滤环里。校验类需求优先放统计消费端(炼境 ingest)而非分发 hook(R12 边界单点 + 零分发成本 + 可回溯)
|
||||
|
||||
## 复用的现有能力(~60% 地基已具备)
|
||||
|
||||
@ -132,8 +135,44 @@
|
||||
- 版本化闭环要求(2026-07-03 用户澄清,体系分发的灵魂): 分发不是"发了就不管"——每类资产带版本分发 → 子项目按该版本实践产生经验(成功/失败)→ 经验自吸反哺炼境(trailer/failure_cases/复盘/返工率)→ 按版本分组对比效果 → 修订出新版 → 再分发。CONVENTIONS 是模范生(version+队列对比+梦核+同步,1.8→1.9 已跑通一整圈);B 系列每类资产按此标准建闭环。**前置依赖提升**:「接入包模板演进分发(MANAGED 块化)」concept 卡——模板无版本机制是当前最大缺口,没有它体系 v2 到不了存量项目
|
||||
- notes: 2026-07-03 概念探讨收束后的分发需求(体系详见 memory concept_dual_projection 与 mcp-server 模块 concept 卡)。用户全 agent 开发形态下,"给开发者立规范"="给 agent 注入规范",可完全机器化。四条现成管道分发四类资产:① B1(S) CONVENTIONS 1.10.0 加 R12 边界单点实现 + R13 验收须真实走通验证路径 + 载体选择原则;② B2(S) 接入包 AGENTS.md 模板加 agent 按钮章节(三要素/载体谱系/侦察驻军);③ B3(M) init/sync 提示词升级——建蓝图时声明项目按钮载体、acceptance 生成为可执行验证路径格式;④ B4(S,后置) 健康诊断加体系遵守度检查。移植体验=零新增动作,接入流程不变。
|
||||
|
||||
### ✅ U1. 全托管文件升级通道 + 批量重分发(择升环断点修复 ①)
|
||||
- status: done
|
||||
- done_note: 2026-07-15 完成(分支 feat/onboarding-pack-u1 → PR)。dispatch_fully_managed 三态(written/skipped/updated,CRLF 归一化防覆盖抖动),envoy.md/blueprint-gate.cjs 切换全托管并加「由炼境托管」声明头;verify_onboarding 新增第 7 项「lefthook 检查段齐全」——期望键集合从内嵌 TMPL_LEFTHOOK 自省提取(lefthook_command_keys 纯函数,注释行不算键),零硬编码;MCP apply_onboarding_pack 支持批量模式(project_id 字段缺失→全部已登记项目,空串仍按无效单项目报错),schema required 放宽为空并同步测试断言(契约放宽属验收要求,已声明)。cargo test 102 全过。**实调终验(2026-07-15 dev 实例,遗留已销)**:批量分发 25 项目 0 失败——存量项目 envoy/gate 全部 updated(全托管通道生效)、AGENTS 块多项目原位升 v1.6.0;ingest 全量重跑 19 项目清洗 0 条(存量本就干净,防线在位);verify 抽查 enterprise-system 与炼境自身均 6/7,唯一 fail 恰是新增「lefthook 检查段」并准确列出各自缺失键(检测落后功能真实工作)。PR #4 已 merge。batch_apply.py/scan_onboarded.py 可清理(用户处置)
|
||||
- complexity: M
|
||||
- depends: onboarding-pack(卡 T1)
|
||||
- files: src-tauri/src/commands/onboarding.rs, src-tauri/src/mcp_tools.rs, src-tauri/resources/onboarding/.claude/commands/envoy.md, src-tauri/resources/onboarding/.claude/hooks/blueprint-gate.cjs
|
||||
- acceptance: [Tauri command] 对存量项目重跑 apply_onboarding_pack → envoy.md / blueprint-gate.cjs 与内嵌模板不一致时覆盖(action=updated),一致时 skipped,文件头含「由炼境托管,重跑接入包会覆盖」声明;[cargo test] dispatch_fully_managed 三态(不存在→written / 一致→skipped / 不一致→updated);[MCP tool] verify_onboarding 的 lefthook 检查项从内嵌 TMPL_LEFTHOOK 自省提取 commands 键集合,项目 lefthook.yml 缺键 → fail + 列出缺失键(禁止硬编码具体检查项名,模板演进校验器零维护);[MCP tool] apply_onboarding_pack 不传 project_id → 对所有已登记项目批量执行并返回逐项目摘要(复用 ingest_git_history 先例),验收通过后删除 batch_apply.py / scan_onboarded.py
|
||||
- notes: dispatch_one「存在即跳过」使回流修正只能推到 AGENTS/git-workflow 两份版本化文档——envoy/gate 的 bug 修复永远到不了存量 12 项目,批量重分发靠临时 python 脚本硬扛(batch_apply.py 为实锤)。三档升级语义见决策记录。已知盲点(辩证承认):首次覆盖时子项目文件尚无托管声明,存在一次性盲覆盖窗口——envoy/gate 分发仅一周、被定制概率极低,且 updated 会在报告显式出现,风险接受
|
||||
|
||||
### ✅ U3. Rules-Applied 真实性校验(ingest 层单点实现,择升环断点修复 ②)
|
||||
- status: done
|
||||
- done_note: 2026-07-15 完成。纯函数 extract_rule_ids(R+数字 token 边界识别,防 PR13/R2D2 误提取)+ filter_valid_rules(valid=None 不校验 fail-open);校验接入全部 4 条写入链路(buff 增量 / webhook push / gitea 轮询 / 全量导入),规则集每项目每批次加载一次;sanitize_rules_applied 显式回溯清洗通道(UPSERT「不覆盖」约束原样保留,两者并行不悖);IngestResult 新增 sanitized 字段(Rust/TS/MCP 输出/FlywheelPanel 四层同步);record_commit 收窄为 #[cfg(test)],生产链路只剩 validated 版防绕过。cargo test 98 全过(新增 5 测:token 边界/过滤/入库剔除/message 链路/清洗幂等+仅清洗指定项目),pnpm typecheck 干净
|
||||
- complexity: S
|
||||
- files: src-tauri/src/commands/commit_metrics.rs
|
||||
- acceptance: [cargo test] trailer 解析校验纯函数——规则 ID 不存在于该项目 .blueprint/CONVENTIONS.md(如 R99)→ 从 rules_applied 剔除并可观测(日志或 invalid 计数),真实 ID 保留;项目无 CONVENTIONS.md → 不校验全保留;[MCP tool] ingest_git_history 重跑 → 「规则命中 × 返工」交叉统计不再含未知 ID(存量污染可回溯清洗)
|
||||
- notes: 辩证翻转载体:原方案(子项目 commit-msg hook)违反 R12 边界单点——统计消费端在炼境,校验就该在炼境 ingest 层。收益:零分发成本覆盖全部项目、重跑即回溯清洗、cargo test 可测(炼境自身 agent 按钮载体)。commit-msg hook 即时反馈版降为可选增强(若做依赖 U1 通道,不在本卡)。上限诚实:只挡 typo/幻觉 ID,「真 ID 假触发」留给宁缺毋滥纪律
|
||||
|
||||
### ✅ B4. 健康诊断遵守度 + 回传覆盖率(吸收被否决的 U2,择升环断点修复 ③)
|
||||
- status: done
|
||||
- done_note: 2026-07-15 完成。onboarding.rs 新增 silent_projects(conn)(R06 注入):近 30 天有 commit_metrics 但零使者回传的已登记项目,SQL 经 blueprint_buffs UUID→win_path→project_workspaces 双路径关联(复用 get_all_commit_health 身份解析形状),未登记项目不列入(无法跑 /envoy);get_diagnostics 新增「使者回传覆盖率」段,沉默项目列名称+commit 数并提示跑 /envoy。cargo test 99 全过(新测覆盖:UUID 关联命名/普通笔记不算回传/未登记不列入/回传过期重新变沉默)
|
||||
- complexity: S
|
||||
- files: src-tauri/src/mcp_tools.rs, src-tauri/src/commands/onboarding.rs
|
||||
- acceptance: [MCP tool] get_diagnostics(或 verify_onboarding 检查链)新增「沉默项目」信号:项目近 30 天 commit_metrics 有数据但使者回传(list_envoy_returns 同源数据)为 0 → 列出项目名 + commit 数,提示去该项目跑 /envoy;近 30 天有回传或无 commit 活动 → 不报
|
||||
- notes: U2(子项目侧 Stop hook 自动触发 /envoy)经多角度辩证否决(2026-07-15):自动化触发会拆掉人肉过滤层,诱导 agent 用 under_validation 噪声交差灌满回传通道——伤「择」换「环」转速,本末倒置。正解是炼境侧可观测性:让遗忘可被看见(信号驱动),人留在过滤环里决定是否回传。同时销掉原 B4 后置欠账(体系遵守度检查)
|
||||
|
||||
## 复盘笔记
|
||||
|
||||
【复盘】U1 全托管文件升级通道 + 批量重分发(2026-07-15)
|
||||
任务类型: 分发引擎 + MCP 工具 + 验收链扩展
|
||||
复杂度: M
|
||||
实际轮数: 1 轮实现 + 1 轮实调修复(预估 1 轮)
|
||||
是否返工: 是(实调暴露空路径 bug,473cf7e 补修)
|
||||
有效策略: 立卡前多角度辩证砍掉伪需求(U2 否决、U3 载体翻转),实现返工率显著低于直做;检查项从内嵌模板自省(lefthook_command_keys)延续 V1 范式,校验器零维护;dev 实例实调=真实终验路径,两小时内筛出两个纯靠单测到不了的坑(空路径写 cwd、托管块抹本地定制)
|
||||
踩坑: ① 批量 SQL 只滤 NULL 没滤空串,空路径登记让接入包写进进程 cwd(src-tauri/ 被污染)——路径入口防护必须在能力本体层单点做,已驻军回归测试;② 本地定制写在 MANAGED 块内被版本升级原位抹掉(分支策略表/按钮清单/降级约定/启动协议第4条)——托管块的"设计行为"反噬使用侧违规,恢复至块外并入决策记录
|
||||
遗留: 全托管首次覆盖的盲窗口已发生(5 个存量项目 envoy/gate 被覆盖,未逐一核对覆盖前有无定制——各项目 git 历史可兜底恢复);「块内被手改检测」(升级前 diff 旧块 vs 旧版模板,不符则警告)为候选改进,暂不立卡
|
||||
auto_applied_rules: R06, R07, R13
|
||||
rule_effectiveness: R06=有效(conn 注入使 silent_projects/校验全链可单测), R07=有效(净增 10 条测试含 2 条坑驱动回归), R13=有效(实调走通即抓出两坑,验证"分发成功≠功能生效")
|
||||
|
||||
【复盘】V1 接入生效验收(2026-07-04)
|
||||
任务类型: 后端命令 + MCP工具 + 前端类型同步
|
||||
复杂度: M
|
||||
|
||||
@ -37,3 +37,16 @@ progress: 100
|
||||
- `builtin-vite-react`:React 19 + TS + TailwindCSS v4 + React Router v7,纯前端 SPA 起点
|
||||
- `builtin-tauri-react-tailwind`:Tauri v2 + React + TailwindCSS v4,客户端应用带样式
|
||||
- `builtin-web-mobile-monorepo`:Web + Mobile 共享 Monorepo,packages/core(业务逻辑)+ packages/types + apps/web(Vite+Tailwind)+ apps/mobile(Expo+NativeWind)
|
||||
|
||||
### ✅ 知识库(择升环)模板
|
||||
- status: done
|
||||
- complexity: S
|
||||
- files: src-tauri/src/commands/templates.rs
|
||||
- acceptance: |
|
||||
agent_verify: [Rust] cargo build 通过;seed 数据含 builtin-knowledge-loop,files 覆盖 CORE.md/concepts/dialogues/cases/AGENTS.md
|
||||
human_verify: 应用内新建项目选该模板 → 生成目录结构与晋升规则内容符合预期
|
||||
|
||||
新增内置模板 `builtin-knowledge-loop`:方法论/知识沉淀文档库。
|
||||
三层晋升结构(dialogues 原料 → concepts 概念卡 → CORE.md 稳定核心),
|
||||
晋升规则写入模板 AGENTS.md,agent 进库即按择升环工作流运转。init_commands 为 `git init`。
|
||||
agent_verify 已过(cargo check + 种子条目确认,commit 55ee5a4);human_verify 待应用内新建项目实测。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
53
.claude/commands/envoy.md
Normal file
53
.claude/commands/envoy.md
Normal file
@ -0,0 +1,53 @@
|
||||
<!-- 由炼境托管:重跑接入包会用最新模板覆盖本文件,请勿在此做项目定制 -->
|
||||
|
||||
你是**炼境使者**——在子项目会话里代表炼境执行现场感知与回传。
|
||||
|
||||
直接执行以下步骤,无需用户提供任何输入。
|
||||
|
||||
## Step 1:读取项目身份
|
||||
|
||||
用 Read 工具读取 `.claude/liangjing.json`,获取 `project_id` 和项目名称。
|
||||
若文件不存在:输出 `⚠️ 未找到 .claude/liangjing.json,该项目未接入炼境` 并停止。
|
||||
|
||||
## Step 2:扫描本次会话
|
||||
|
||||
回顾本次会话的完整对话,识别有价值的洞察。
|
||||
跳过:纯代码实现细节、具体 bug 修复、一次性操作记录。
|
||||
|
||||
## Step 3:分类每条洞察
|
||||
|
||||
**📦 带回炼境**(满足任一条):
|
||||
- 炼境分发的模板 / 约定 / 接入包有 bug 或可改进
|
||||
- 某工作流或实践有明确代价或验证结果(有具体证据)
|
||||
- 会影响多个项目的体系级改进(哪怕还是假设)
|
||||
|
||||
**🏠 留本项目**(同时满足以下所有):
|
||||
- 只影响本项目的具体实现
|
||||
- 无任何跨项目复用价值
|
||||
- 纯猜测、零证据
|
||||
|
||||
## Step 4:发回炼境
|
||||
|
||||
对每条「带回」洞察,单独调用一次 `mcp__lian-jing__append_project_note`。
|
||||
- `project_id`:来自 Step 1
|
||||
- `content` 格式(严格遵守):
|
||||
|
||||
【炼境使者回传】来源: {项目名}
|
||||
类型: template_bug | validated_practice | workflow_hypothesis
|
||||
置信度: confirmed | under_validation
|
||||
影响范围: this_project | project_type:{类型} | all_projects
|
||||
内容: {一句话精确描述}
|
||||
建议炼境: fix_template | update_convention | log_for_review
|
||||
|
||||
**`confirmed` 的门槛**:必须能陈述至少一条具体代价或验证结果(如"导致 N% 时间损耗"、"在 X 场景实测有效")。
|
||||
无法陈述具体证据的,一律标 `under_validation`,不得标 `confirmed`。
|
||||
|
||||
## Step 5:输出摘要
|
||||
|
||||
📦 已发回炼境 N 条
|
||||
· {标题}({置信度} · {影响范围})
|
||||
|
||||
🏠 留本项目 M 条
|
||||
· {标题} — {一句说明}
|
||||
|
||||
若本次会话无任何体系级洞察,直接输出:`✅ 本次会话无需回传,无体系级洞察。`
|
||||
32
.claude/hooks/blueprint-gate.cjs
Normal file
32
.claude/hooks/blueprint-gate.cjs
Normal file
@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env node
|
||||
// 由炼境托管:重跑接入包会用最新模板覆盖本文件,请勿在此做项目定制。
|
||||
// 蓝图门控:pre-commit 检查未关闭的 🔵 任务卡,warn only(exit 0,不阻断提交)。
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const modulesDir = path.join(process.cwd(), '.blueprint', 'modules');
|
||||
|
||||
if (!fs.existsSync(modulesDir)) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const unclosed = [];
|
||||
for (const file of fs.readdirSync(modulesDir).filter(f => f.endsWith('.md'))) {
|
||||
const content = fs.readFileSync(path.join(modulesDir, file), 'utf8');
|
||||
for (const line of content.split('\n')) {
|
||||
if (line.startsWith('### 🔵')) {
|
||||
unclosed.push({ file, title: line.replace(/^###\s*🔵\s*/, '').trim() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (unclosed.length > 0) {
|
||||
process.stderr.write('\n⚠️ 蓝图门控:发现未关闭任务卡\n');
|
||||
for (const { file, title } of unclosed) {
|
||||
process.stderr.write(` 🔵 ${title}(${file})\n`);
|
||||
}
|
||||
process.stderr.write('\n 已完成 → 改为 ✅;仍进行中 → 属预期,可忽略本提示。\n\n');
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
@ -2,3 +2,7 @@
|
||||
globs: src-tauri/resources/onboarding/**
|
||||
---
|
||||
修改接入包模板时,检查 `docs/templates/agents-meta-rules.md` 是否需要同步更新——两者描述同一套元规则,必须保持一致(单一事实来源:tmpl 是分发实体,docs/templates/ 是设计草案)。
|
||||
|
||||
**改模板内容必须同步升级 `ONBOARDING_MANAGED_START version="x.y.z"` 版本号**——版本是分发引擎唯一的升级信号,内容变了版本不动,批量分发会全部 skipped(2026-07-06 实测踩坑)。模板改完后跑 `cargo test onboarding` 确认版本相关测试通过。
|
||||
|
||||
分发到子项目需要**重建安装炼境**(模板打包在二进制资源里),再调 `apply_onboarding_pack`。
|
||||
|
||||
@ -12,12 +12,7 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"mcpServers": {
|
||||
"lian-jing:group-ffa9e42d": {
|
||||
"type": "sse",
|
||||
"url": "http://127.0.0.1:27190/mcp/group/ffa9e42d"
|
||||
}
|
||||
},
|
||||
"mcpServers": {},
|
||||
"permissions": {
|
||||
"defaultMode": "bypassPermissions"
|
||||
}
|
||||
|
||||
@ -16,22 +16,18 @@ allowed-tools: Read, Glob, Grep, Bash(git log *), Bash(git diff *), Bash(python
|
||||
|
||||
**① 原生 MCP**:若 `append_project_note` 工具可用,直接调用。
|
||||
|
||||
**② Python HTTP 直调**:若原生 MCP 不可用(Claude Code 与炼境握手失败的已知问题),读取项目 `.claude/settings.json`,提取 group_id,用 Python 直接 POST 炼境 API:
|
||||
**② Python HTTP 直调**:若原生 MCP 不可用(Claude Code 与炼境握手失败的已知问题),读取项目 `.claude/liangjing.json` 取 project_id 与端口,用 Python 直接 POST 炼境单一全局服务器:
|
||||
|
||||
```python
|
||||
import urllib.request, json, sys
|
||||
|
||||
# 从 .claude/settings.json 提取 group_id
|
||||
with open('.claude/settings.json') as f:
|
||||
cfg = json.load(f)
|
||||
group_id = None
|
||||
for key in cfg.get('mcpServers', {}):
|
||||
if key.startswith('lian-jing:group-'):
|
||||
group_id = key.split('lian-jing:group-')[1]
|
||||
break
|
||||
# 从 .claude/liangjing.json 提取 project_id 和端口
|
||||
with open('.claude/liangjing.json') as f:
|
||||
ctx = json.load(f)
|
||||
PROJECT_ID = ctx['project_id']
|
||||
port = ctx.get('mcp_port', 27190)
|
||||
|
||||
# project_id 通过先调用 list_group_projects 获得
|
||||
url = f'http://localhost:27190/mcp/group/{group_id}'
|
||||
url = f'http://localhost:{port}/mcp'
|
||||
|
||||
def mcp_call(method, params={}):
|
||||
payload = json.dumps({'jsonrpc':'2.0','id':1,'method':method,'params':params}).encode()
|
||||
@ -57,7 +53,7 @@ result = mcp_call('tools/call', {
|
||||
按以下顺序确定:
|
||||
|
||||
1. 检测 `append_project_note` 是否在可用工具中 → 是则用**通道①**
|
||||
2. 否则检测 `localhost:27190` 是否可达(`curl -s --max-time 2 http://localhost:27190` 或 Python urlopen)→ 可达则用**通道②**,同时读取 `.claude/settings.json` 提取 `group_id`,再调用 `list_group_projects` 获取当前项目 `project_id`
|
||||
2. 否则检测 `localhost:27190` 是否可达(`curl -s --max-time 2 http://localhost:27190` 或 Python urlopen)→ 可达则用**通道②**,读取 `.claude/liangjing.json` 直接获取 `project_id` 与端口
|
||||
3. 均不可达 → 用**通道③**,无需 project_id
|
||||
|
||||
告知用户当前使用哪个通道,例如:`使用通道②(Python HTTP 直调)`。
|
||||
|
||||
138
AGENTS.md
138
AGENTS.md
@ -15,11 +15,9 @@ Git 多空间开发管理器,同时也是**项目 Agent 化改造引擎**。
|
||||
- MCP Server:内嵌于 Tauri 进程(HTTP),供 Claude Code 调用炼境数据
|
||||
- 包管理:pnpm(禁止 npm/yarn/npx/bun)
|
||||
|
||||
## 核心开发约束
|
||||
## 炼境特有开发约束
|
||||
|
||||
**包管理**:pnpm 唯一,禁止 npm / yarn / npx / bun。
|
||||
|
||||
**TypeScript**:优先 TypeScript,避免 `any`;函数/变量 camelCase,组件 PascalCase;async/await,不用裸 Promise 链;try/catch 不吞异常。
|
||||
> 通用约束(pnpm / TS 规范 / 需求对齐协议)见下方 ONBOARDING_MANAGED 块——单一事实来源,勿在此复抄。
|
||||
|
||||
**Rust 业务函数**:禁止在函数内部调用 `pool().get()`,连接由调用方传入(依赖注入,便于 in-memory DB 测试)。Tauri command 层负责获取连接并调用(薄包装,无需测试)。
|
||||
|
||||
@ -45,16 +43,6 @@ docs/templates/ 炼境接入包模板草案(设计层)
|
||||
2. **SQLite 新增表或字段 → 必须在 `db.rs` 的 `migrate()` 里补 migration 语句**,否则旧版本升级后启动报错
|
||||
3. **修改接入包模板(`resources/onboarding/`)→ 检查 `docs/templates/agents-meta-rules.md` 是否需要同步**
|
||||
|
||||
## 多 AI CLI 协作
|
||||
|
||||
本项目可由多个 AI CLI 协同。交接时附上:
|
||||
|
||||
```
|
||||
已完成:<修改了哪些文件 + 核心决策>
|
||||
待验证:<需要对方确认的地方>
|
||||
约束继承:<影响后续工作的关键限制>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Agent 友好工程四支柱
|
||||
@ -112,7 +100,7 @@ docs/templates/ 炼境接入包模板草案(设计层)
|
||||
- [ ] `src/lib/commands.ts` 的封装覆盖了所有 `src-tauri/src/commands/` 下的 command 吗?
|
||||
- [ ] 接入包模板(`resources/onboarding/`)和 `docs/templates/` 内容一致吗?
|
||||
|
||||
<!-- BLUEPRINT_MANAGED_START version="1.10.0" -->
|
||||
<!-- BLUEPRINT_MANAGED_START version="1.11.0" -->
|
||||
## 项目蓝图
|
||||
|
||||
本项目使用 `.blueprint/` 目录维护项目全景架构图,驱动需求讨论和任务管理。
|
||||
@ -150,7 +138,9 @@ docs/templates/ 炼境接入包模板草案(设计层)
|
||||
完整规则(迭代管理、阻塞处理、复盘笔记等)详见 `.blueprint/CONVENTIONS.md`。
|
||||
<!-- BLUEPRINT_MANAGED_END -->
|
||||
|
||||
<!-- ONBOARDING_MANAGED_START version="1.2.0" -->
|
||||
<!-- ONBOARDING_MANAGED_START version="1.6.1" -->
|
||||
> ⚠️ 本块由炼境版本化托管,升级时**整块原位覆盖**——项目定制请写到块外,块内手改必丢(2026-07-15 炼境自身实锤丢失 5 段定制后新增本警示)。
|
||||
|
||||
## 核心开发约束
|
||||
|
||||
### 包管理
|
||||
@ -163,38 +153,43 @@ docs/templates/ 炼境接入包模板草案(设计层)
|
||||
- 异步统一 `async/await`,不用裸 Promise 链
|
||||
- 错误处理明确 `try/catch`,不吞异常
|
||||
- 优先编辑现有文件,避免创建不必要的新文件
|
||||
- **Playwright 版本固定**:`package.json` 里的 `playwright` 锁死版本号,去掉 `^`;
|
||||
另建 `test:install` 脚本显式安装浏览器(`playwright install --with-deps`),
|
||||
升级时有意识操作,避免 pnpm install 静默触发 ~200MB 的浏览器重下
|
||||
|
||||
## 需求对齐协议
|
||||
|
||||
开始写代码前,先判断改动类型:
|
||||
|
||||
**Bug Fix**(当前行为与预期的意外偏差)
|
||||
→ 建任务卡,直接执行,无需等确认词。
|
||||
|
||||
**Feature / 行为变更**(新增能力或主动改变已有行为)
|
||||
→ 必须先输出确认卡,等用户说「确认」再动手:
|
||||
|
||||
- **S 级**(单文件、行为对外无变化,任务卡 complexity=S):输出三行轻量卡
|
||||
```
|
||||
改哪:<文件或模块>
|
||||
改什么:<一句话>
|
||||
验收:<agent_verify 路径>
|
||||
```
|
||||
- **M / L 级**:输出完整任务卡(含 `files` + `acceptance` + `complexity`),等确认
|
||||
|
||||
**边界模糊时**:保守判断,按 Feature 处理,走完整确认卡。
|
||||
|
||||
**提交时**:`feat:` 提交须在 commit message 末尾加 `Feature-Confirmed: true` trailer(lefthook commit-msg 强制检查)。这是确认卡已输出的机器层存证。
|
||||
|
||||
> ⚠️ Session 结束汇总补录 ≠ 确认卡替代——补录仅适用于漂移检测触发后用户主动选择补录的场景,不得作为「先做再补」的通道。
|
||||
|
||||
## Git 工作流
|
||||
|
||||
见 `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 强制),炼境据此采集飞轮数据。
|
||||
|
||||
### 炼境自身的分支策略
|
||||
|
||||
> `git-workflow.md` 是分发给被管理子项目的接入包模板(dev→main 双分支)。
|
||||
> 炼境自身使用以下策略,不套用子项目模板。
|
||||
|
||||
| 任务复杂度 | 分支策略 |
|
||||
|-----------|---------|
|
||||
| S(简单增删改) | 直接 push `master`,不开分支 |
|
||||
| M(中等逻辑) | 开 `feat/<blueprint-module-id>` 分支,完成后 PR → `master` |
|
||||
| L(复杂系统) | 开 `feat/<blueprint-module-id>` 分支,必须走 PR + CI 验证 |
|
||||
|
||||
**分支命名**:直接对应蓝图模块 ID,如 `feat/manage-multi-view`、`feat/flywheel-intelligence`。
|
||||
|
||||
**为什么 M/L 要开分支**:
|
||||
- PR body 是 agent 自验报告的归宿,飞轮从中采集结构化数据(PR 事件比 push 事件包含更多意图信息)
|
||||
- feat/* 分支触发 PR CI,是 agent 自验链条的最后一环(本地 typecheck → PR CI → 人终验)
|
||||
- 炼境推荐子项目走 PR 工作流,自身也应遵守同一套规范
|
||||
|
||||
## agent 按钮与验证路径
|
||||
|
||||
> 完整体系设计见 `docs/ai-context/agent-button-system.md`。
|
||||
|
||||
**分工原则**:人定目标 → agent 执行 → agent 自验(走 agent 按钮)→ 人终验。
|
||||
人只保留两件事:**需求澄清(目标定义权)** 和 **目标验收(终验权)**。
|
||||
|
||||
开发任何功能时遵守:
|
||||
功能的本体是代码层的能力;UI 按钮是它给人的投影,**agent 按钮**是给 agent 的投影
|
||||
(可发现 + 可调用 + 有边界的能力声明)。开发任何功能时遵守:
|
||||
|
||||
1. **边界单点实现**:业务校验只写在能力本体层(API/command),UI 只呈现不复制逻辑——
|
||||
否则人与 agent 的验证路径会分裂
|
||||
@ -207,7 +202,7 @@ docs/templates/ 炼境接入包模板草案(设计层)
|
||||
| UI 呈现层 | Playwright 驱动真界面(最后手段,仅关键路径) |
|
||||
3. **完成的定义**:功能实现后,由 agent 沿投影路径真实走通一次(起服务 → 调接口 →
|
||||
断言行为),再交人做 UI 终验——不许绕过功能手改产物冒充验证
|
||||
4. **侦察 → 驻军**:agent 走通的关键路径,顺手固化为一条测试(炼境主路径为 cargo test;vitest 仅在引入独立纯逻辑模块后按需添加);
|
||||
4. **侦察 → 驻军**:agent 走通的关键路径,顺手固化为一条测试(桌面应用用 cargo test;Web 项目纯逻辑层用 vitest);
|
||||
一次性验收不固化(少而稳 > 多而脆)
|
||||
5. **acceptance 可执行化**:`/splitter` 生成任务卡时,`acceptance` 字段必须是可执行验证路径,
|
||||
格式:`[载体] 调用/运行什么(参数) → 断言什么`。
|
||||
@ -215,6 +210,54 @@ docs/templates/ 炼境接入包模板草案(设计层)
|
||||
- 正例:`[HTTP API] POST /api/users → 201,body.id 非空`
|
||||
- 反例:`功能正常可用`(模糊,agent 无法自验)
|
||||
|
||||
**分层原则**:凡含 UI 操作路径的 acceptance,必须拆成两段:
|
||||
- `agent_verify:` — 机器可断言(HTTP 状态码、数据库记录、函数返回值);stop hook / CI 只验此段
|
||||
- `human_verify:` — 需人眼判断(视觉效果、拖拽手势、30 秒浏览器终验)
|
||||
纯 API / 命令行任务可省略 `human_verify:`;UI 拖拽 / 动画 / 视觉类必须拆,否则 stop hook 无法收敛。
|
||||
6. **自验报告纪律**:交人终验前的反馈必须含「**没验什么**」一节(未覆盖的路径、依赖的
|
||||
假设、不确定的地方);自修过程中改了断言/验收标准必须显式声明理由(改功能让测试过是
|
||||
常规,改测试让功能过需报备);agent 自验对 acceptance,人终验对意图——acceptance
|
||||
可能继承需求理解偏差,两把尺子不可合一
|
||||
|
||||
## 多 AI CLI 协作
|
||||
|
||||
本项目可由多个 AI CLI 协同。交接时把结果总结成 3 条传给下一个:
|
||||
|
||||
```
|
||||
1. 已完成:<改了哪些文件 + 核心决策>
|
||||
2. 待验证:<需要确认的地方>
|
||||
3. 约束继承:<影响后续的关键限制>
|
||||
```
|
||||
<!-- ONBOARDING_MANAGED_END -->
|
||||
|
||||
## 炼境自身约定(块外区——托管块升级不会触碰本节)
|
||||
|
||||
> ⚠️ 教训(2026-07-15):本地定制严禁写进 MANAGED 块内——块会被版本升级原位覆盖。
|
||||
> 本节内容曾因写在块内被 v1.6.0 升级抹掉,现恢复至块外。
|
||||
|
||||
### 炼境自身的分支策略
|
||||
|
||||
> 块内 git-workflow 是分发给子项目的通用策略;炼境自身按下表执行。
|
||||
|
||||
| 任务复杂度 | 分支策略 |
|
||||
|-----------|---------|
|
||||
| S(简单增删改) | 直接 push `master`,不开分支 |
|
||||
| M(中等逻辑) | 开 `feat/<blueprint-module-id>` 分支,完成后 PR → `master` |
|
||||
| L(复杂系统) | 开 `feat/<blueprint-module-id>` 分支,必须走 PR + CI 验证 |
|
||||
|
||||
**分支命名**:直接对应蓝图模块 ID,如 `feat/manage-multi-view`;同名旧分支已存在时加卡号后缀(如 `feat/onboarding-pack-u1`)。
|
||||
|
||||
**为什么 M/L 要开分支**:
|
||||
- PR body 是 agent 自验报告的归宿,飞轮从中采集结构化数据(PR 事件比 push 事件包含更多意图信息)
|
||||
- feat/* 分支触发 PR CI,是 agent 自验链条的最后一环(本地 typecheck → PR CI → 人终验)
|
||||
- 炼境推荐子项目走 PR 工作流,自身也应遵守同一套规范
|
||||
|
||||
### 分工原则
|
||||
|
||||
人定目标 → agent 执行 → agent 自验(走 agent 按钮)→ 人终验。
|
||||
人只保留两件事:**需求澄清(目标定义权)** 和 **目标验收(终验权)**。
|
||||
完整体系设计见 `docs/ai-context/agent-button-system.md`。
|
||||
|
||||
### 炼境自己的 Agent 按钮清单
|
||||
|
||||
| 能力 | 调用方式 | 通过标准 |
|
||||
@ -232,14 +275,3 @@ docs/templates/ 炼境接入包模板草案(设计层)
|
||||
1. 失败原因(具体报错或不通过的断言)
|
||||
2. 已尝试的修正路径
|
||||
3. 需要人决策的具体问题
|
||||
|
||||
## 多 AI CLI 协作
|
||||
|
||||
本项目可由多个 AI CLI 协同。交接时把结果总结成 3 条传给下一个:
|
||||
|
||||
```
|
||||
1. 已完成:<改了哪些文件 + 核心决策>
|
||||
2. 待验证:<需要确认的地方>
|
||||
3. 约束继承:<影响后续的关键限制>
|
||||
```
|
||||
<!-- ONBOARDING_MANAGED_END -->
|
||||
|
||||
@ -47,7 +47,7 @@ docs/templates/ 炼境接入包模板草案(agents-meta-rules
|
||||
- 代码重构/简化:/simplify → /code-review
|
||||
- 发现深层问题:/Dev-deep-think
|
||||
|
||||
<!-- BLUEPRINT_MANAGED_START version="1.10.0" -->
|
||||
<!-- BLUEPRINT_MANAGED_START version="1.11.0" -->
|
||||
## 项目蓝图
|
||||
|
||||
蓝图工作流规则统一维护在 @AGENTS.md 的 BLUEPRINT_MANAGED 区(跨工具单一来源,勿在此复制)。
|
||||
@ -57,3 +57,6 @@ docs/templates/ 炼境接入包模板草案(agents-meta-rules
|
||||
2. 读取 `.blueprint/CONVENTIONS.md` 了解完整工作流规则
|
||||
3. 遵循 AGENTS.md 中的蓝图工作流(需求→蓝图同步、复杂度决策、两阶段提交)
|
||||
<!-- BLUEPRINT_MANAGED_END -->
|
||||
|
||||
**炼境自身附加启动步骤(块外,勿移入托管块——块升级会覆盖)**:
|
||||
4. 调用 `mcp__lian-jing__list_envoy_returns` 检查子项目使者回传——若有待处理条目,在开始其他任务前先列出,询问是否立即处置
|
||||
|
||||
@ -3,7 +3,8 @@
|
||||
> 把此文件粘贴给任何 AI Agent,让它了解本项目的 git 工作方式。由炼境接入包分发。
|
||||
> `ONBOARDING_MANAGED` 标记块由炼境版本化管理(重跑接入包可原位升级),块外可自由补充项目特有约定。
|
||||
|
||||
<!-- ONBOARDING_MANAGED_START version="1.3.0" -->
|
||||
<!-- ONBOARDING_MANAGED_START version="1.5.1" -->
|
||||
> ⚠️ 本块由炼境版本化托管,升级时**整块原位覆盖**——项目定制请写到块外,块内手改必丢。
|
||||
|
||||
## 分支策略(复杂度感知)
|
||||
|
||||
@ -48,7 +49,7 @@ git commit -m "fix(api/user): 修正分页 off-by-one"
|
||||
|
||||
## Rules-Applied trailer(规则命中回传)
|
||||
|
||||
若本次任务中触发过 CONVENTIONS 自动推荐规则(R01-R11,见 `.blueprint/CONVENTIONS.md` 规则列表),
|
||||
若本次任务中触发过项目约定规则(见项目 CONVENTIONS.md 或 `.blueprint/CONVENTIONS.md`),
|
||||
在 commit message 末尾追加一行 trailer(与正文空行分隔):
|
||||
|
||||
```
|
||||
@ -78,6 +79,7 @@ PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否
|
||||
## PR 创建(炼境托管项目)
|
||||
|
||||
本项目由炼境管理,MCP 已注入且 `.claude/liangjing.json` 含 `project_id`。
|
||||
炼境 MCP 是单一全局服务器 `lian-jing`,所有工具显式传 `project_id` 寻址,无需 group_id。
|
||||
|
||||
**推送分支后,通过炼境 MCP 创建 PR,禁止用 curl + git credential fill 提取 token:**
|
||||
|
||||
|
||||
9
docs/templates/agents-meta-rules.md
vendored
9
docs/templates/agents-meta-rules.md
vendored
@ -124,6 +124,15 @@ acceptance 是 agent 自验的唯一判断依据,必须是**可执行断言**
|
||||
|
||||
含 acceptance 的任务卡才可派发;acceptance 可执行,自验才是真自验——这是整个体系的核心关节。
|
||||
|
||||
### 自验报告纪律
|
||||
|
||||
- 交人终验前的反馈必须含「**没验什么**」一节:未覆盖的路径、依赖的假设、不确定的地方
|
||||
- 自修过程中改了断言/验收标准必须显式声明理由(改功能让测试过是常规,改测试让功能过需报备)
|
||||
- agent 自验对 acceptance,人终验对**意图**——acceptance 可能继承需求理解偏差,两把尺子不可合一
|
||||
|
||||
> 为什么:自验的结构性盲区是"验证代码与功能代码共享同一份理解偏差",负面披露收窄已知盲区,
|
||||
> 人终验(对意图、带意外感触发的深查)兜底未知盲区。两者互补,缺一另一超载。
|
||||
|
||||
### 自验失败降级约定
|
||||
|
||||
- **失败 2 次内**:自行修正,继续自验
|
||||
|
||||
13
lefthook.yml
13
lefthook.yml
@ -1,11 +1,18 @@
|
||||
# 炼境自身 lefthook 配置(对齐接入包模板的检查段键名,verify_onboarding 按键自省检测)
|
||||
pre-commit:
|
||||
parallel: true
|
||||
commands:
|
||||
typecheck:
|
||||
glob: "src/**/*.{ts,tsx}"
|
||||
run: pnpm typecheck
|
||||
blueprint-gate:
|
||||
run: node .claude/hooks/blueprint-gate.cjs || true
|
||||
|
||||
commit-msg:
|
||||
scripts:
|
||||
"check-conventional.sh":
|
||||
runner: bash
|
||||
commands:
|
||||
conventional:
|
||||
run: "head -1 .git/COMMIT_EDITMSG | grep -qE '^(feat|fix|docs|chore|refactor|ci|perf|style|test|build|revert)(\\(.+\\))?!?: .+' || exit 1"
|
||||
feature-confirmed:
|
||||
run: |
|
||||
head -1 .git/COMMIT_EDITMSG | grep -qE '^feat' || exit 0
|
||||
grep -q 'Feature-Confirmed: true' .git/COMMIT_EDITMSG || { printf '\n⚠️ feat: 提交须含 Feature-Confirmed: true trailer\n 在 commit message 末尾加一行:Feature-Confirmed: true\n (确认卡已与用户对齐,方可提交)\n\n' >&2; exit 1; }
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dev-manager-tauri",
|
||||
"private": true,
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.19",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@ -10,7 +10,8 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"tauri": "tauri"
|
||||
"tauri": "tauri",
|
||||
"build:local": "tauri build --config src-tauri/tauri.local.conf.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.95.0",
|
||||
|
||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@ -880,7 +880,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dev-manager-tauri"
|
||||
version = "0.1.18"
|
||||
version = "0.1.19"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
|
||||
@ -47,3 +47,6 @@ serde_yaml = "0.9"
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["macros", "rt"] }
|
||||
|
||||
|
||||
60
src-tauri/resources/onboarding/.claude/commands/envoy.md
Normal file
60
src-tauri/resources/onboarding/.claude/commands/envoy.md
Normal file
@ -0,0 +1,60 @@
|
||||
在子项目会话结束时扫描体系级洞察、回传给炼境。Use at the end of a session in a managed project.
|
||||
|
||||
<!-- 由炼境托管:重跑接入包会用最新模板覆盖本文件,请勿在此做项目定制 -->
|
||||
|
||||
你是**炼境使者**——在子项目会话里代表炼境执行现场感知与回传。
|
||||
|
||||
直接执行以下步骤,无需用户提供任何输入。
|
||||
|
||||
## Step 1:读取项目身份
|
||||
|
||||
用 Read 工具读取 `.claude/liangjing.json`,获取 `project_id` 和项目名称。
|
||||
若文件不存在:输出 `⚠️ 未找到 .claude/liangjing.json,该项目未接入炼境` 并停止。
|
||||
|
||||
## Step 2:扫描本次会话
|
||||
|
||||
回顾本次会话的完整对话,识别有价值的洞察。
|
||||
跳过:纯代码实现细节、具体 bug 修复、一次性操作记录。
|
||||
|
||||
## Step 3:分类每条洞察
|
||||
|
||||
**前置硬门槛(先于一切分类判断)**:问自己——**任何有经验的工程师是否会提前想到这一点?**
|
||||
- 会(常识性设计知识,如"外键优于业务字段关联")→ 一律归「留本项目」,不得回传
|
||||
- 触发条件过窄的 corner case(复现依赖本项目特定组合)→ 同样归「留本项目」
|
||||
- 只有**非显而易见、且跨项目可复用**的教训才有资格进入下方分类
|
||||
|
||||
**📦 带回炼境**(通过硬门槛后,满足任一条):
|
||||
- 炼境分发的模板 / 约定 / 接入包有 bug 或可改进
|
||||
- 某工作流或实践有明确代价或验证结果(有具体证据)
|
||||
- 会影响多个项目的体系级改进(哪怕还是假设)
|
||||
|
||||
**🏠 留本项目**(同时满足以下所有):
|
||||
- 只影响本项目的具体实现
|
||||
- 无任何跨项目复用价值
|
||||
- 纯猜测、零证据
|
||||
|
||||
## Step 4:发回炼境
|
||||
|
||||
对每条「带回」洞察,单独调用一次 `mcp__lian-jing__append_project_note`。
|
||||
- `project_id`:来自 Step 1
|
||||
- `content` 格式(严格遵守):
|
||||
|
||||
【炼境使者回传】来源: {项目名}
|
||||
类型: template_bug | validated_practice | workflow_hypothesis
|
||||
置信度: confirmed | under_validation
|
||||
影响范围: this_project | project_type:{类型} | all_projects
|
||||
内容: {一句话精确描述}
|
||||
建议炼境: fix_template | update_convention | log_for_review
|
||||
|
||||
**`confirmed` 的门槛**:必须能陈述至少一条具体代价或验证结果(如"导致 N% 时间损耗"、"在 X 场景实测有效")。
|
||||
无法陈述具体证据的,一律标 `under_validation`,不得标 `confirmed`。
|
||||
|
||||
## Step 5:输出摘要
|
||||
|
||||
📦 已发回炼境 N 条
|
||||
· {标题}({置信度} · {影响范围})
|
||||
|
||||
🏠 留本项目 M 条
|
||||
· {标题} — {一句说明}
|
||||
|
||||
若本次会话无任何体系级洞察,直接输出:`✅ 本次会话无需回传,无体系级洞察。`
|
||||
@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env node
|
||||
// 由炼境托管:重跑接入包会用最新模板覆盖本文件,请勿在此做项目定制。
|
||||
// 蓝图门控:pre-commit 检查未关闭的 🔵 任务卡,warn only(exit 0,不阻断提交)。
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const modulesDir = path.join(process.cwd(), '.blueprint', 'modules');
|
||||
|
||||
if (!fs.existsSync(modulesDir)) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const unclosed = [];
|
||||
for (const file of fs.readdirSync(modulesDir).filter(f => f.endsWith('.md'))) {
|
||||
const content = fs.readFileSync(path.join(modulesDir, file), 'utf8');
|
||||
for (const line of content.split('\n')) {
|
||||
if (line.startsWith('### 🔵')) {
|
||||
unclosed.push({ file, title: line.replace(/^###\s*🔵\s*/, '').trim() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (unclosed.length > 0) {
|
||||
process.stderr.write('\n⚠️ 蓝图门控:发现未关闭任务卡\n');
|
||||
for (const { file, title } of unclosed) {
|
||||
process.stderr.write(` 🔵 ${title}(${file})\n`);
|
||||
}
|
||||
process.stderr.write('\n 已完成 → 改为 ✅;仍进行中 → 属预期,可忽略本提示。\n\n');
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
@ -18,7 +18,9 @@
|
||||
|
||||
<!-- TODO: 列出 README / ARCHITECTURE.md / 其他关键文档及用途。 -->
|
||||
|
||||
<!-- ONBOARDING_MANAGED_START version="1.2.0" -->
|
||||
<!-- ONBOARDING_MANAGED_START version="1.6.1" -->
|
||||
> ⚠️ 本块由炼境版本化托管,升级时**整块原位覆盖**——项目定制请写到块外,块内手改必丢(2026-07-15 炼境自身实锤丢失 5 段定制后新增本警示)。
|
||||
|
||||
## 核心开发约束
|
||||
|
||||
### 包管理
|
||||
@ -31,6 +33,33 @@
|
||||
- 异步统一 `async/await`,不用裸 Promise 链
|
||||
- 错误处理明确 `try/catch`,不吞异常
|
||||
- 优先编辑现有文件,避免创建不必要的新文件
|
||||
- **Playwright 版本固定**:`package.json` 里的 `playwright` 锁死版本号,去掉 `^`;
|
||||
另建 `test:install` 脚本显式安装浏览器(`playwright install --with-deps`),
|
||||
升级时有意识操作,避免 pnpm install 静默触发 ~200MB 的浏览器重下
|
||||
|
||||
## 需求对齐协议
|
||||
|
||||
开始写代码前,先判断改动类型:
|
||||
|
||||
**Bug Fix**(当前行为与预期的意外偏差)
|
||||
→ 建任务卡,直接执行,无需等确认词。
|
||||
|
||||
**Feature / 行为变更**(新增能力或主动改变已有行为)
|
||||
→ 必须先输出确认卡,等用户说「确认」再动手:
|
||||
|
||||
- **S 级**(单文件、行为对外无变化,任务卡 complexity=S):输出三行轻量卡
|
||||
```
|
||||
改哪:<文件或模块>
|
||||
改什么:<一句话>
|
||||
验收:<agent_verify 路径>
|
||||
```
|
||||
- **M / L 级**:输出完整任务卡(含 `files` + `acceptance` + `complexity`),等确认
|
||||
|
||||
**边界模糊时**:保守判断,按 Feature 处理,走完整确认卡。
|
||||
|
||||
**提交时**:`feat:` 提交须在 commit message 末尾加 `Feature-Confirmed: true` trailer(lefthook commit-msg 强制检查)。这是确认卡已输出的机器层存证。
|
||||
|
||||
> ⚠️ Session 结束汇总补录 ≠ 确认卡替代——补录仅适用于漂移检测触发后用户主动选择补录的场景,不得作为「先做再补」的通道。
|
||||
|
||||
## Git 工作流
|
||||
|
||||
@ -61,6 +90,15 @@
|
||||
- 正例:`[HTTP API] POST /api/users → 201,body.id 非空`
|
||||
- 反例:`功能正常可用`(模糊,agent 无法自验)
|
||||
|
||||
**分层原则**:凡含 UI 操作路径的 acceptance,必须拆成两段:
|
||||
- `agent_verify:` — 机器可断言(HTTP 状态码、数据库记录、函数返回值);stop hook / CI 只验此段
|
||||
- `human_verify:` — 需人眼判断(视觉效果、拖拽手势、30 秒浏览器终验)
|
||||
纯 API / 命令行任务可省略 `human_verify:`;UI 拖拽 / 动画 / 视觉类必须拆,否则 stop hook 无法收敛。
|
||||
6. **自验报告纪律**:交人终验前的反馈必须含「**没验什么**」一节(未覆盖的路径、依赖的
|
||||
假设、不确定的地方);自修过程中改了断言/验收标准必须显式声明理由(改功能让测试过是
|
||||
常规,改测试让功能过需报备);agent 自验对 acceptance,人终验对意图——acceptance
|
||||
可能继承需求理解偏差,两把尺子不可合一
|
||||
|
||||
## 多 AI CLI 协作
|
||||
|
||||
本项目可由多个 AI CLI 协同。交接时把结果总结成 3 条传给下一个:
|
||||
|
||||
@ -3,7 +3,8 @@
|
||||
> 把此文件粘贴给任何 AI Agent,让它了解本项目的 git 工作方式。由炼境接入包分发。
|
||||
> `ONBOARDING_MANAGED` 标记块由炼境版本化管理(重跑接入包可原位升级),块外可自由补充项目特有约定。
|
||||
|
||||
<!-- ONBOARDING_MANAGED_START version="1.3.0" -->
|
||||
<!-- ONBOARDING_MANAGED_START version="1.5.1" -->
|
||||
> ⚠️ 本块由炼境版本化托管,升级时**整块原位覆盖**——项目定制请写到块外,块内手改必丢。
|
||||
|
||||
## 分支策略(复杂度感知)
|
||||
|
||||
@ -48,7 +49,7 @@ git commit -m "fix(api/user): 修正分页 off-by-one"
|
||||
|
||||
## Rules-Applied trailer(规则命中回传)
|
||||
|
||||
若本次任务中触发过 CONVENTIONS 自动推荐规则(R01-R11,见 `.blueprint/CONVENTIONS.md` 规则列表),
|
||||
若本次任务中触发过项目约定规则(见项目 CONVENTIONS.md 或 `.blueprint/CONVENTIONS.md`),
|
||||
在 commit message 末尾追加一行 trailer(与正文空行分隔):
|
||||
|
||||
```
|
||||
@ -78,6 +79,7 @@ PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否
|
||||
## PR 创建(炼境托管项目)
|
||||
|
||||
本项目由炼境管理,MCP 已注入且 `.claude/liangjing.json` 含 `project_id`。
|
||||
炼境 MCP 是单一全局服务器 `lian-jing`,所有工具显式传 `project_id` 寻址,无需 group_id。
|
||||
|
||||
**推送分支后,通过炼境 MCP 创建 PR,禁止用 curl + git credential fill 提取 token:**
|
||||
|
||||
|
||||
@ -9,8 +9,22 @@ pre-commit:
|
||||
typecheck:
|
||||
glob: "*.{ts,tsx}"
|
||||
run: pnpm typecheck
|
||||
# 🚨 【必填项】项目有 API / 后端层时,必须取消注释并按实际路径配置——
|
||||
# 否则后端类型错误不会被 pre-commit 拦截(前端 typecheck 只覆盖 src/ 层)。
|
||||
# 按实际填写一行,其余示例行可删掉:
|
||||
# api-typecheck:
|
||||
# glob: "api/**/*.{ts,tsx}"
|
||||
# run: pnpm --filter api typecheck # monorepo workspace
|
||||
# # run: pnpm exec tsc -p api/tsconfig.json --noEmit # 独立 tsconfig
|
||||
# # run: pnpm -r typecheck # 递归所有包
|
||||
blueprint-gate:
|
||||
run: node .claude/hooks/blueprint-gate.cjs || true
|
||||
|
||||
commit-msg:
|
||||
commands:
|
||||
conventional:
|
||||
run: "head -1 .git/COMMIT_EDITMSG | grep -qE '^(feat|fix|docs|chore|refactor|ci|perf|style|test|build|revert)(\\(.+\\))?!?: .+' || exit 1"
|
||||
feature-confirmed:
|
||||
run: |
|
||||
head -1 .git/COMMIT_EDITMSG | grep -qE '^feat' || exit 0
|
||||
grep -q 'Feature-Confirmed: true' .git/COMMIT_EDITMSG || { printf '\n⚠️ feat: 提交须含 Feature-Confirmed: true trailer\n 在 commit message 末尾加一行:Feature-Confirmed: true\n (确认卡已与用户对齐,方可提交)\n\n' >&2; exit 1; }
|
||||
|
||||
@ -128,12 +128,7 @@ fn detect_contracts(root: &Path) -> Vec<DetectedContract> {
|
||||
let cargo_content = std::fs::read_to_string(root.join("Cargo.toml"))
|
||||
.or_else(|_| std::fs::read_to_string(root.join("src-tauri/Cargo.toml")))
|
||||
.unwrap_or_default();
|
||||
// Tauri 项目:commands 目录在 src-tauri/src/commands/
|
||||
let tauri_commands_path = if path_exists(root, "src-tauri/src/commands") {
|
||||
"src-tauri/src/commands"
|
||||
} else {
|
||||
"src/commands"
|
||||
};
|
||||
|
||||
|
||||
// ── Node.js 框架层 ────────────────────────────────────────────────────────
|
||||
|
||||
@ -226,6 +221,37 @@ fn detect_contracts(root: &Path) -> Vec<DetectedContract> {
|
||||
});
|
||||
}
|
||||
|
||||
// React(前端组件层)
|
||||
if pkg_deps.iter().any(|k| k == "react") {
|
||||
contracts.push(DetectedContract {
|
||||
label: "前端组件类型(React/TypeScript)".into(),
|
||||
paths: vec!["src/**/*.tsx".into()],
|
||||
doc: "组件 Props 类型变更需检查所有消费方".into(),
|
||||
confidence: "medium".into(),
|
||||
});
|
||||
}
|
||||
|
||||
// Vite 构建配置
|
||||
if pkg_deps.iter().any(|k| k == "vite") {
|
||||
let p = "vite.config.ts";
|
||||
contracts.push(DetectedContract {
|
||||
label: "构建配置(Vite)".into(),
|
||||
paths: vec![p.into()],
|
||||
doc: "Vite 配置变更影响构建输出和 dev 行为(Tauri 项目尤其注意)".into(),
|
||||
confidence: confidence(root, p),
|
||||
});
|
||||
}
|
||||
|
||||
// Zustand 状态层
|
||||
if pkg_deps.iter().any(|k| k == "zustand") {
|
||||
contracts.push(DetectedContract {
|
||||
label: "前端状态类型(Zustand store)".into(),
|
||||
paths: vec!["src/store/**".into()],
|
||||
doc: "Store 类型变更影响所有消费组件,需同步更新".into(),
|
||||
confidence: "medium".into(),
|
||||
});
|
||||
}
|
||||
|
||||
// pnpm monorepo 共享类型
|
||||
if path_exists(root, "pnpm-workspace.yaml") {
|
||||
// 优先已知命名约定;否则列出 packages/ 下实际存在的子包目录
|
||||
@ -300,21 +326,23 @@ fn detect_contracts(root: &Path) -> Vec<DetectedContract> {
|
||||
});
|
||||
}
|
||||
|
||||
// Tauri(自身的 command 层)
|
||||
let tauri_frontend_ts = if path_exists(root, "src/lib/commands.ts") {
|
||||
"src/lib/commands.ts"
|
||||
} else {
|
||||
"src/commands.ts"
|
||||
};
|
||||
if cargo_has(&cargo_content, "tauri") && path_exists(root, tauri_commands_path) {
|
||||
// Tauri(command 层)
|
||||
// 注意:不要求 commands/ 目录已存在——新项目初始化时只有 lib.rs,但契约路径应预先声明
|
||||
if cargo_has(&cargo_content, "tauri") {
|
||||
let tauri_cmd_dir = "src-tauri/src/commands";
|
||||
let tauri_frontend_ts = if path_exists(root, "src/lib/commands.ts") {
|
||||
"src/lib/commands.ts"
|
||||
} else {
|
||||
"src/commands.ts"
|
||||
};
|
||||
contracts.push(DetectedContract {
|
||||
label: "Tauri command 接口(前后端契约)".into(),
|
||||
paths: vec![
|
||||
format!("{tauri_commands_path}/**/*.rs"),
|
||||
format!("{tauri_cmd_dir}/**/*.rs"),
|
||||
tauri_frontend_ts.into(),
|
||||
],
|
||||
doc: "新增 command 时前后端必须同步".into(),
|
||||
confidence: "high".into(),
|
||||
confidence: if path_exists(root, tauri_cmd_dir) { "high".into() } else { "medium".into() },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -159,9 +159,16 @@ fn check_project(app: &AppHandle, project_path: &str) -> Result<(), String> {
|
||||
);
|
||||
}
|
||||
// 批量写入用事务包裹(首次可达数十条,避免逐条 fsync)
|
||||
// U3:规则 ID 集合每批次加载一次,剔除 trailer 里的未知 ID
|
||||
let valid_rules = crate::commands::commit_metrics::load_project_rule_ids(root);
|
||||
let tx = conn.unchecked_transaction();
|
||||
for entry in &commits {
|
||||
let _ = crate::commands::commit_metrics::record_commit_entry(&conn, &project_id, entry);
|
||||
let _ = crate::commands::commit_metrics::record_commit_entry(
|
||||
&conn,
|
||||
&project_id,
|
||||
entry,
|
||||
valid_rules.as_ref(),
|
||||
);
|
||||
}
|
||||
if let Ok(tx) = tx {
|
||||
let _ = tx.commit();
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
|
||||
use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// 一条 commit 的解析结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@ -107,6 +108,104 @@ pub fn parse_rules_applied(message: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
// ── U3:Rules-Applied 真实性校验(ingest 层单点,防幻觉/typo ID 污染交叉统计)──
|
||||
|
||||
/// 从 CONVENTIONS.md 内容提取全部规则 ID(大写 R+数字,前后不粘字母数字)。纯函数。
|
||||
pub fn extract_rule_ids(text: &str) -> HashSet<String> {
|
||||
let b = text.as_bytes();
|
||||
let mut ids = HashSet::new();
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
if b[i] == b'R' && (i == 0 || !b[i - 1].is_ascii_alphanumeric()) {
|
||||
let mut j = i + 1;
|
||||
while j < b.len() && b[j].is_ascii_digit() {
|
||||
j += 1;
|
||||
}
|
||||
if j > i + 1 && (j == b.len() || !b[j].is_ascii_alphanumeric()) {
|
||||
ids.insert(text[i..j].to_string());
|
||||
}
|
||||
i = j;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
/// 过滤 rules_applied 串:剔除不在 valid 集合中的 ID;全部无效返回 None。
|
||||
/// valid 为 None(项目无 CONVENTIONS.md)时不校验、原样保留。纯函数。
|
||||
pub fn filter_valid_rules(rules: &str, valid: Option<&HashSet<String>>) -> Option<String> {
|
||||
let Some(set) = valid else {
|
||||
return Some(rules.to_string());
|
||||
};
|
||||
let kept: Vec<&str> = rules
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|t| !t.is_empty() && set.contains(*t))
|
||||
.collect();
|
||||
if kept.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(kept.join(","))
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取项目 .blueprint/CONVENTIONS.md 提取规则 ID 集合;文件不存在返回 None(不校验)。
|
||||
/// 每项目每批次调用一次,勿放进逐 commit 循环。
|
||||
pub fn load_project_rule_ids(root: &std::path::Path) -> Option<HashSet<String>> {
|
||||
let content = std::fs::read_to_string(root.join(".blueprint").join("CONVENTIONS.md")).ok()?;
|
||||
Some(extract_rule_ids(&content))
|
||||
}
|
||||
|
||||
/// 远程采集链路(webhook/轮询)无本地 root,按 project_id 查 blueprint_buffs 路径再加载。
|
||||
pub fn load_rule_ids_by_project_id(conn: &Connection, project_id: &str) -> Option<HashSet<String>> {
|
||||
let path: String = conn
|
||||
.query_row(
|
||||
"SELECT project_path FROM blueprint_buffs WHERE project_id = ?1",
|
||||
rusqlite::params![project_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok()?;
|
||||
load_project_rule_ids(std::path::Path::new(&path))
|
||||
}
|
||||
|
||||
/// 回溯清洗存量行:剔除 rules_applied 中不在 valid 集合的 ID(全无效置 NULL)。
|
||||
/// 与 UPSERT 的「不覆盖已有值」约束并行不悖——本函数是显式清洗通道,仅在全量导入时调用。
|
||||
/// 返回被清洗的行数,重跑幂等(已干净的行不再计数)。
|
||||
pub fn sanitize_rules_applied(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
valid: &HashSet<String>,
|
||||
) -> Result<u32, String> {
|
||||
let rows: Vec<(String, String)> = {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT sha, rules_applied FROM commit_metrics
|
||||
WHERE project_id = ?1 AND rules_applied IS NOT NULL",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mapped = stmt
|
||||
.query_map(rusqlite::params![project_id], |r| {
|
||||
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
mapped.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
let mut cleaned = 0u32;
|
||||
for (sha, rules) in rows {
|
||||
let filtered = filter_valid_rules(&rules, Some(valid));
|
||||
if filtered.as_deref() != Some(rules.as_str()) {
|
||||
conn.execute(
|
||||
"UPDATE commit_metrics SET rules_applied = ?1 WHERE sha = ?2",
|
||||
rusqlite::params![filtered, sha],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
cleaned += 1;
|
||||
}
|
||||
}
|
||||
Ok(cleaned)
|
||||
}
|
||||
|
||||
/// UPSERT 写入一条 commit 记录:新 sha 插入;已存在且 rules_applied 为空时仅回填
|
||||
/// rules_applied(绝不覆盖已有值——回填幂等的关键约束)。返回是否产生了写入。
|
||||
fn upsert_metrics(
|
||||
@ -140,9 +239,27 @@ fn upsert_metrics(
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
/// 解析并写入一条 commit 记录(从完整 message 提取 conventional + Rules-Applied trailer)。
|
||||
/// 生产链路的 git log 只给 subject,trailer 走 `record_commit_entry`;本函数保留给
|
||||
/// 持有完整 message 的调用方与测试。
|
||||
/// 解析并写入一条 commit 记录(从完整 message 提取 conventional + Rules-Applied trailer),
|
||||
/// 并按 valid_rules 剔除未知规则 ID(U3)。远程采集生产链路(webhook/轮询)用本函数,
|
||||
/// valid_rules 每项目加载一次(`load_rule_ids_by_project_id`)。
|
||||
pub fn record_commit_validated(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
sha: &str,
|
||||
message: &str,
|
||||
committed_at: Option<&str>,
|
||||
valid_rules: Option<&HashSet<String>>,
|
||||
) -> Result<(), String> {
|
||||
let p = parse_conventional(message);
|
||||
let rules_applied =
|
||||
parse_rules_applied(message).and_then(|r| filter_valid_rules(&r, valid_rules));
|
||||
upsert_metrics(conn, project_id, sha, &p, rules_applied.as_deref(), committed_at)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 不校验规则 ID 的写入(等价 valid_rules=None)。仅供测试铺数据;
|
||||
/// 生产链路一律走 `record_commit_validated` / `record_commit_entry`(U3 校验强制)。
|
||||
#[cfg(test)]
|
||||
pub fn record_commit(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
@ -150,21 +267,21 @@ pub fn record_commit(
|
||||
message: &str,
|
||||
committed_at: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let p = parse_conventional(message);
|
||||
let rules_applied = parse_rules_applied(message);
|
||||
upsert_metrics(conn, project_id, sha, &p, rules_applied.as_deref(), committed_at)?;
|
||||
Ok(())
|
||||
record_commit_validated(conn, project_id, sha, message, committed_at, None)
|
||||
}
|
||||
|
||||
/// 写入一条来自 `read_git_log` 的记录(subject 与 trailer 分列,trailer 由 git 原生解析)。
|
||||
/// 同时把触碰文件写入 commit_files(INSERT OR IGNORE 幂等,供文件级风险画像)。
|
||||
/// valid_rules 每项目加载一次传入(U3 校验);None 表示项目无 CONVENTIONS.md,不校验。
|
||||
pub fn record_commit_entry(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
entry: &GitLogEntry,
|
||||
valid_rules: Option<&HashSet<String>>,
|
||||
) -> Result<bool, String> {
|
||||
let p = parse_conventional(&entry.subject);
|
||||
let rules = normalize_rule_tokens(&entry.rules_trailer);
|
||||
let rules = normalize_rule_tokens(&entry.rules_trailer)
|
||||
.and_then(|r| filter_valid_rules(&r, valid_rules));
|
||||
let date = if entry.committed_at.is_empty() {
|
||||
None
|
||||
} else {
|
||||
@ -300,6 +417,8 @@ pub struct IngestResult {
|
||||
pub imported: u32,
|
||||
/// 已存在且无需回填(跳过)
|
||||
pub skipped: u32,
|
||||
/// 存量行中被清洗掉未知规则 ID 的行数(U3 回溯清洗;项目无 CONVENTIONS.md 时恒为 0)
|
||||
pub sanitized: u32,
|
||||
}
|
||||
|
||||
/// git log 单条记录。`rules_trailer` 是 git 原生解析出的 Rules-Applied trailer 值
|
||||
@ -406,16 +525,23 @@ pub fn ingest_full_git_history(
|
||||
)
|
||||
.unwrap_or(project_id);
|
||||
|
||||
// U3:每项目加载一次规则 ID 集合,新写入过滤 + 存量回溯清洗
|
||||
let valid_rules = load_project_rule_ids(root);
|
||||
|
||||
let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
|
||||
let mut imported = 0u32;
|
||||
for entry in &commits {
|
||||
// 新 sha 插入;已存在的行 UPSERT 回填 rules_applied(trailer 死链修复后的存量补数路径)
|
||||
if record_commit_entry(&conn, &effective_id, entry)? {
|
||||
if record_commit_entry(&conn, &effective_id, entry, valid_rules.as_ref())? {
|
||||
imported += 1;
|
||||
}
|
||||
}
|
||||
let sanitized = match &valid_rules {
|
||||
Some(set) => sanitize_rules_applied(&conn, &effective_id, set)?,
|
||||
None => 0,
|
||||
};
|
||||
tx.commit().map_err(|e| e.to_string())?;
|
||||
Ok(IngestResult { imported, skipped: total - imported })
|
||||
Ok(IngestResult { imported, skipped: total - imported, sanitized })
|
||||
}
|
||||
|
||||
// ── 跨项目 Git 健康度聚合 ─────────────────────────────────────────────────────
|
||||
@ -784,22 +910,22 @@ mod tests {
|
||||
assert_eq!(rules_of(&conn, "old"), None);
|
||||
|
||||
// 回填:同 sha 带 trailer 重写 → 补上
|
||||
let changed = record_commit_entry(&conn, "proj", &entry("old", "feat(a): x", "R01,R06")).unwrap();
|
||||
let changed = record_commit_entry(&conn, "proj", &entry("old", "feat(a): x", "R01,R06"), None).unwrap();
|
||||
assert!(changed, "回填应计为写入");
|
||||
assert_eq!(rules_of(&conn, "old").as_deref(), Some("R01,R06"));
|
||||
|
||||
// 不覆盖:已有值的行,不同 trailer 重写 → 保持原值
|
||||
let changed = record_commit_entry(&conn, "proj", &entry("old", "feat(a): x", "R99")).unwrap();
|
||||
let changed = record_commit_entry(&conn, "proj", &entry("old", "feat(a): x", "R99"), None).unwrap();
|
||||
assert!(!changed, "已有值不应被覆盖");
|
||||
assert_eq!(rules_of(&conn, "old").as_deref(), Some("R01,R06"));
|
||||
|
||||
// 无 trailer 重写已有 NULL 行 → 不产生无意义 UPDATE
|
||||
record_commit(&conn, "proj", "old2", "chore: y", None).unwrap();
|
||||
let changed = record_commit_entry(&conn, "proj", &entry("old2", "chore: y", "")).unwrap();
|
||||
let changed = record_commit_entry(&conn, "proj", &entry("old2", "chore: y", ""), None).unwrap();
|
||||
assert!(!changed);
|
||||
|
||||
// 新行带 trailer 直接写入
|
||||
assert!(record_commit_entry(&conn, "proj", &entry("new", "fix(b): z", "R05")).unwrap());
|
||||
assert!(record_commit_entry(&conn, "proj", &entry("new", "fix(b): z", "R05"), None).unwrap());
|
||||
assert_eq!(rules_of(&conn, "new").as_deref(), Some("R05"));
|
||||
}
|
||||
|
||||
@ -844,8 +970,8 @@ mod tests {
|
||||
let conn = crate::db::conn_with_schema();
|
||||
let mut e = entry("s1", "feat(a): x", "");
|
||||
e.files = vec!["src/a.rs".into(), "src/b.rs".into()];
|
||||
record_commit_entry(&conn, "proj", &e).unwrap();
|
||||
record_commit_entry(&conn, "proj", &e).unwrap(); // 重跑
|
||||
record_commit_entry(&conn, "proj", &e, None).unwrap();
|
||||
record_commit_entry(&conn, "proj", &e, None).unwrap(); // 重跑
|
||||
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM commit_files WHERE sha = 's1'", [], |r| r.get(0))
|
||||
@ -990,5 +1116,80 @@ Rules-Applied: R06", None).unwrap();
|
||||
record_commit(&conn, "pz", "s9", "feat: plain", None).unwrap();
|
||||
assert!(rule_hit_stats(&conn).unwrap().is_empty());
|
||||
}
|
||||
|
||||
// ── U3:Rules-Applied 真实性校验 ────────────────────────────────────────
|
||||
|
||||
fn valid_set(ids: &[&str]) -> HashSet<String> {
|
||||
ids.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_rule_ids_respects_token_boundaries() {
|
||||
let text = "### R01 规则一\n- R12:xxx(见 |R06| 表格)\nPR13 前粘字母不算,R2D2 后粘字母不算,r03 小写不算";
|
||||
let ids = extract_rule_ids(text);
|
||||
assert!(ids.contains("R01"));
|
||||
assert!(ids.contains("R12"));
|
||||
assert!(ids.contains("R06"));
|
||||
assert!(!ids.contains("R13"), "PR13 内的 R13 不是独立 ID");
|
||||
assert!(!ids.contains("R2"), "R2D2 内的 R2 不是独立 ID");
|
||||
assert!(!ids.contains("R03") && !ids.contains("r03"), "小写 r 不提取");
|
||||
assert!(extract_rule_ids("没有任何规则").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_valid_rules_drops_unknown_ids() {
|
||||
let set = valid_set(&["R01", "R06"]);
|
||||
assert_eq!(filter_valid_rules("R01,R99", Some(&set)).as_deref(), Some("R01"));
|
||||
assert_eq!(filter_valid_rules("R99", Some(&set)), None, "全无效返回 None");
|
||||
assert_eq!(
|
||||
filter_valid_rules("R01,R99", None).as_deref(),
|
||||
Some("R01,R99"),
|
||||
"无 CONVENTIONS.md 时不校验"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_commit_entry_filters_unknown_ids_at_ingest() {
|
||||
let conn = crate::db::conn_with_schema();
|
||||
let set = valid_set(&["R01"]);
|
||||
record_commit_entry(&conn, "proj", &entry("v1", "feat(a): x", "R01,R99"), Some(&set)).unwrap();
|
||||
assert_eq!(rules_of(&conn, "v1").as_deref(), Some("R01"), "未知 ID 应在入库前剔除");
|
||||
record_commit_entry(&conn, "proj", &entry("v2", "feat(a): y", "R99"), Some(&set)).unwrap();
|
||||
assert_eq!(rules_of(&conn, "v2"), None, "全无效则该列为 NULL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_commit_validated_filters_trailer_in_message() {
|
||||
let conn = crate::db::conn_with_schema();
|
||||
let set = valid_set(&["R05"]);
|
||||
record_commit_validated(&conn, "proj", "w1", "feat(x): a\n\nRules-Applied: R05,R99", None, Some(&set)).unwrap();
|
||||
assert_eq!(rules_of(&conn, "w1").as_deref(), Some("R05"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_cleans_stored_pollution_idempotently() {
|
||||
let conn = crate::db::conn_with_schema();
|
||||
// 存量污染:校验上线前写入的行
|
||||
record_commit_entry(&conn, "proj", &entry("p1", "feat: a", "R01,R99"), None).unwrap();
|
||||
record_commit_entry(&conn, "proj", &entry("p2", "fix: b", "R99"), None).unwrap();
|
||||
record_commit_entry(&conn, "proj", &entry("p3", "feat: c", "R01"), None).unwrap();
|
||||
record_commit_entry(&conn, "other", &entry("q1", "feat: d", "R99"), None).unwrap();
|
||||
|
||||
let set = valid_set(&["R01"]);
|
||||
let cleaned = sanitize_rules_applied(&conn, "proj", &set).unwrap();
|
||||
assert_eq!(cleaned, 2, "p1 剔除 R99、p2 置 NULL;p3 干净不动");
|
||||
assert_eq!(rules_of(&conn, "p1").as_deref(), Some("R01"));
|
||||
assert_eq!(rules_of(&conn, "p2"), None);
|
||||
assert_eq!(rules_of(&conn, "p3").as_deref(), Some("R01"));
|
||||
assert_eq!(rules_of(&conn, "q1").as_deref(), Some("R99"), "只清洗指定项目");
|
||||
|
||||
// 清洗后交叉统计不再含未知 ID(acceptance 断言)
|
||||
let stats = rule_hit_stats(&conn).unwrap();
|
||||
assert!(stats.iter().filter(|s| s.rule_id == "R99").all(|s| s.project_count == 1),
|
||||
"proj 的 R99 已清,仅剩 other 项目的");
|
||||
|
||||
// 幂等:重跑零清洗
|
||||
assert_eq!(sanitize_rules_applied(&conn, "proj", &set).unwrap(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -517,13 +517,16 @@ pub fn poll_all_repos() -> Result<(usize, usize), String> {
|
||||
Err(_) => continue,
|
||||
};
|
||||
polled += 1;
|
||||
// U3:按 project_id 加载规则 ID 集合(每仓库一次),剔除 trailer 里的未知 ID
|
||||
let valid_rules =
|
||||
crate::commands::commit_metrics::load_rule_ids_by_project_id(&conn, project_id);
|
||||
for (sha, message, date) in parse_gitea_commits(&json) {
|
||||
let before: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM commit_metrics WHERE sha = ?1", params![sha], |r| r.get(0),
|
||||
).unwrap_or(0);
|
||||
if before == 0
|
||||
&& crate::commands::commit_metrics::record_commit(
|
||||
&conn, project_id, &sha, &message, date.as_deref(),
|
||||
&& crate::commands::commit_metrics::record_commit_validated(
|
||||
&conn, project_id, &sha, &message, date.as_deref(), valid_rules.as_ref(),
|
||||
).is_ok()
|
||||
{
|
||||
ingested += 1;
|
||||
|
||||
@ -74,7 +74,18 @@ pub fn delete_group(id: String) -> Result<(), String> {
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
for project_id in &member_ids {
|
||||
if let Err(e) = mcp_inject::remove_project_mcp(project_id, &id) {
|
||||
// 单一 lian-jing key 为全项目共用:成员还属于其他组时保留配置
|
||||
let in_other_group: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM project_group_map WHERE project_id = ?1 AND group_id != ?2",
|
||||
params![project_id, id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
if in_other_group > 0 {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = mcp_inject::remove_project_mcp(project_id) {
|
||||
let ctx = serde_json::json!({"project_id": project_id, "group_id": id}).to_string();
|
||||
log_event("mcp_inject", "warn", &format!("删除组时清除 MCP 配置失败: {e}"), Some(&ctx));
|
||||
}
|
||||
@ -153,7 +164,7 @@ pub fn ensure_standalone_group(project_id: &str) -> Result<(), String> {
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if let Err(e) = mcp_inject::inject_project_mcp(project_id, &group_id) {
|
||||
if let Err(e) = mcp_inject::inject_project_mcp(project_id) {
|
||||
let ctx = serde_json::json!({"project_id": project_id, "group_id": &group_id}).to_string();
|
||||
log_event("mcp_inject", "warn", &format!("独立项目 MCP 注入失败: {e}"), Some(&ctx));
|
||||
}
|
||||
@ -170,7 +181,7 @@ pub fn add_group_member(project_id: String, group_id: String, role: Option<Strin
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
// DB 写入成功后注入 MCP 配置(非致命,注入失败不影响成员关系)
|
||||
let inject_warning = match mcp_inject::inject_project_mcp(&project_id, &group_id) {
|
||||
let inject_warning = match mcp_inject::inject_project_mcp(&project_id) {
|
||||
Ok(()) => None,
|
||||
Err(e) => {
|
||||
let ctx = serde_json::json!({"project_id": &project_id, "group_id": &group_id}).to_string();
|
||||
@ -184,10 +195,19 @@ pub fn add_group_member(project_id: String, group_id: String, role: Option<Strin
|
||||
#[tauri::command]
|
||||
pub fn remove_group_member(project_id: String, group_id: String) -> Result<(), String> {
|
||||
let conn = pool().get().map_err(|e| e.to_string())?;
|
||||
// 先清除 MCP 配置(需要在 DB 记录删除前查询项目路径)
|
||||
if let Err(e) = mcp_inject::remove_project_mcp(&project_id, &group_id) {
|
||||
let ctx = serde_json::json!({"project_id": &project_id, "group_id": &group_id}).to_string();
|
||||
log_event("mcp_inject", "warn", &format!("清除 MCP 配置失败: {e}"), Some(&ctx));
|
||||
// 仅当项目退出后不再属于任何组时才清除 MCP 配置(单一 lian-jing key 为全项目共用)
|
||||
let in_other_group: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM project_group_map WHERE project_id = ?1 AND group_id != ?2",
|
||||
params![project_id, group_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
if in_other_group == 0 {
|
||||
if let Err(e) = mcp_inject::remove_project_mcp(&project_id) {
|
||||
let ctx = serde_json::json!({"project_id": &project_id, "group_id": &group_id}).to_string();
|
||||
log_event("mcp_inject", "warn", &format!("清除 MCP 配置失败: {e}"), Some(&ctx));
|
||||
}
|
||||
}
|
||||
conn.execute(
|
||||
"DELETE FROM project_group_map WHERE project_id = ?1 AND group_id = ?2",
|
||||
|
||||
@ -16,6 +16,8 @@ const MANAGED_END: &str = "<!-- ONBOARDING_MANAGED_END -->";
|
||||
const TMPL_AGENTS: &str = include_str!("../../resources/onboarding/AGENTS.md.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_ENVOY: &str = include_str!("../../resources/onboarding/.claude/commands/envoy.md");
|
||||
const TMPL_BLUEPRINT_GATE: &str = include_str!("../../resources/onboarding/.claude/hooks/blueprint-gate.cjs");
|
||||
|
||||
/// 单个模板的分发结果。
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||
@ -60,6 +62,38 @@ fn dispatch_one(root: &Path, relative: &str, content: &str) -> TemplateResult {
|
||||
}
|
||||
}
|
||||
|
||||
/// 全托管文件分发(三档升级语义之②,U1):炼境独占管理、用户不定制(文件头有托管声明)。
|
||||
/// 不存在→written;与内嵌模板一致→skipped;不一致→覆盖 updated。
|
||||
/// 比较前归一化行尾(子项目 git autocrlf 可能把 LF 检出为 CRLF,避免永久 updated 抖动)。
|
||||
fn dispatch_fully_managed(root: &Path, relative: &str, content: &str) -> TemplateResult {
|
||||
let target = root.join(relative);
|
||||
if !target.exists() {
|
||||
return dispatch_one(root, relative, content);
|
||||
}
|
||||
let normalize = |s: &str| s.replace("\r\n", "\n");
|
||||
if let Ok(existing) = std::fs::read_to_string(&target) {
|
||||
if normalize(&existing) == normalize(content) {
|
||||
return TemplateResult {
|
||||
name: relative.to_string(),
|
||||
action: "skipped".into(),
|
||||
detail: "内容已是最新模板".into(),
|
||||
};
|
||||
}
|
||||
}
|
||||
match std::fs::write(&target, content) {
|
||||
Ok(_) => TemplateResult {
|
||||
name: relative.to_string(),
|
||||
action: "updated".into(),
|
||||
detail: "全托管文件已覆盖为最新模板".into(),
|
||||
},
|
||||
Err(e) => TemplateResult {
|
||||
name: relative.to_string(),
|
||||
action: "failed".into(),
|
||||
detail: format!("覆盖失败: {e}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 从模板中提取 ONBOARDING_MANAGED 块(含首尾标记行)。
|
||||
fn extract_managed_block(template: &str) -> Option<&str> {
|
||||
let start = template.find(MANAGED_START)?;
|
||||
@ -145,6 +179,8 @@ pub fn dispatch_templates(project_path: &str) -> Vec<TemplateResult> {
|
||||
dispatch_versioned(root, "AGENTS.md", TMPL_AGENTS),
|
||||
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),
|
||||
]
|
||||
}
|
||||
|
||||
@ -228,6 +264,12 @@ pub fn apply_onboarding_pack(
|
||||
project_path: String,
|
||||
project_id: String,
|
||||
) -> Result<OnboardingReport, String> {
|
||||
// 空/无效路径必须在此拦截:Path::new("") 是相对路径,模板会写进炼境进程的
|
||||
// cwd(2026-07-15 批量实调实锤:空路径登记把接入包写进了 src-tauri/)
|
||||
let root = Path::new(project_path.trim_end_matches(['/', '\\']));
|
||||
if project_path.trim().is_empty() || !root.is_dir() {
|
||||
return Err(format!("项目路径为空或不存在,拒绝分发: {project_path:?}"));
|
||||
}
|
||||
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)?;
|
||||
@ -313,7 +355,59 @@ fn check_origin(origin_host: Option<&str>, instance_hosts: &[String]) -> (bool,
|
||||
}
|
||||
}
|
||||
|
||||
/// 接入生效验收:五项检查链。分发成功 ≠ 功能生效,本函数验证"真的用起来了"。
|
||||
/// 从 lefthook.yml 内容提取 commands 键集合(` commands:` 下缩进 4 空格的键行)。
|
||||
/// 纯函数;注释与更深缩进(run: | 多行体等)忽略。用于 U1 检查项自省——
|
||||
/// 期望键集合取自内嵌模板,禁止硬编码具体检查项名(模板演进校验器零维护)。
|
||||
pub fn lefthook_command_keys(yml: &str) -> Vec<String> {
|
||||
let mut keys = Vec::new();
|
||||
let mut in_commands = false;
|
||||
for line in yml.lines() {
|
||||
let trimmed = line.trim_start();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let indent = line.len() - trimmed.len();
|
||||
if indent == 2 && trimmed.trim_end() == "commands:" {
|
||||
in_commands = true;
|
||||
continue;
|
||||
}
|
||||
if indent < 4 {
|
||||
in_commands = false;
|
||||
continue;
|
||||
}
|
||||
if in_commands && indent == 4 {
|
||||
if let Some(key) = trimmed.trim_end().strip_suffix(':') {
|
||||
keys.push(key.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
/// U1 检查:项目 lefthook.yml 是否缺少模板要求的检查段(lefthook 不自动覆盖,只检测落后)。
|
||||
fn check_lefthook_keys(project_yml: Option<&str>, template_yml: &str) -> (bool, String) {
|
||||
let Some(yml) = project_yml else {
|
||||
return (false, "lefthook.yml 缺失——重跑接入包写入".to_string());
|
||||
};
|
||||
let have: Vec<String> = lefthook_command_keys(yml);
|
||||
let missing: Vec<String> = lefthook_command_keys(template_yml)
|
||||
.into_iter()
|
||||
.filter(|k| !have.contains(k))
|
||||
.collect();
|
||||
if missing.is_empty() {
|
||||
(true, "模板要求的检查段齐全".to_string())
|
||||
} else {
|
||||
(
|
||||
false,
|
||||
format!(
|
||||
"缺少检查段: {}——lefthook.yml 不自动覆盖,请手动对照模板补齐",
|
||||
missing.join(", ")
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 接入生效验收:七项检查链。分发成功 ≠ 功能生效,本函数验证"真的用起来了"。
|
||||
/// conn 由调用方注入(R06)。
|
||||
pub fn verify_onboarding(
|
||||
conn: &rusqlite::Connection,
|
||||
@ -341,7 +435,11 @@ pub fn verify_onboarding(
|
||||
};
|
||||
checks.push(VerifyCheck { name: "lefthook hook 挂载".into(), pass, detail });
|
||||
|
||||
// 4 origin 指向已登记 Gitea 实例
|
||||
// 4 lefthook 检查段齐全(期望键从内嵌模板自省,U1)
|
||||
let (pass, detail) = check_lefthook_keys(read("lefthook.yml").as_deref(), TMPL_LEFTHOOK);
|
||||
checks.push(VerifyCheck { name: "lefthook 检查段齐全".into(), pass, detail });
|
||||
|
||||
// 5 origin 指向已登记 Gitea 实例
|
||||
let origin_host = read(".git/config")
|
||||
.as_deref()
|
||||
.and_then(parse_origin_url)
|
||||
@ -360,7 +458,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 });
|
||||
|
||||
// 5 CI workflow 存在
|
||||
// 6 CI workflow 存在
|
||||
let yml_count = std::fs::read_dir(root.join(".gitea/workflows"))
|
||||
.map(|rd| {
|
||||
rd.flatten()
|
||||
@ -383,7 +481,7 @@ pub fn verify_onboarding(
|
||||
},
|
||||
});
|
||||
|
||||
// 6 飞轮数据在流入(近 30 天 commit_metrics)
|
||||
// 7 飞轮数据在流入(近 30 天 commit_metrics)
|
||||
let recent: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM commit_metrics WHERE project_id = ?1 AND committed_at >= datetime('now','-30 days')",
|
||||
@ -404,6 +502,43 @@ pub fn verify_onboarding(
|
||||
checks
|
||||
}
|
||||
|
||||
// ── B4:使者回传覆盖率(「沉默项目」信号)──────────────────────────────────────
|
||||
|
||||
/// 「沉默项目」:近 30 天有 commit 采集但零使者回传的已登记项目(名称, commit 数)。
|
||||
///
|
||||
/// 回流触发不自动化(决策 2026-07-15):不在子项目装 hook 催回传,
|
||||
/// 而是让遗忘可被看见——人看到信号后自行决定是否去该项目跑 /envoy。
|
||||
/// commit_metrics.project_id 可能是 blueprint_buffs 的 UUID,经 buff 路径
|
||||
/// 关联回 project_workspaces;未登记项目(无法跑 /envoy)不列入。
|
||||
pub fn silent_projects(conn: &rusqlite::Connection) -> Result<Vec<(String, i64)>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT
|
||||
COALESCE(pp.name, pw.id) AS name,
|
||||
COUNT(*) AS commits
|
||||
FROM commit_metrics cm
|
||||
LEFT JOIN blueprint_buffs bb ON bb.project_id = cm.project_id
|
||||
LEFT JOIN project_workspaces pw
|
||||
ON pw.id = cm.project_id OR pw.win_path = bb.project_path
|
||||
LEFT JOIN project_profiles pp ON pw.profile_id = pp.id
|
||||
WHERE cm.committed_at >= datetime('now','-30 days')
|
||||
AND pw.id IS NOT NULL
|
||||
GROUP BY pw.id
|
||||
HAVING (SELECT COUNT(*) FROM project_notes pn
|
||||
WHERE pn.project_id = pw.id
|
||||
AND pn.content LIKE '【炼境使者回传】%'
|
||||
AND pn.created_at >= datetime('now','localtime','-30 days')) = 0
|
||||
ORDER BY commits DESC",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@ -459,8 +594,15 @@ mod tests {
|
||||
assert_eq!(agents_r.action, "skipped");
|
||||
|
||||
// 态二(旧版本):把版本改旧 + 块外加用户内容 → updated,块升级、用户内容保留
|
||||
// 版本号从模板动态取,避免模板升版时测试跟着改(曾因硬编码 1.2.0 断裂)
|
||||
let tpl_ver = extract_managed_block(TMPL_AGENTS)
|
||||
.and_then(block_version)
|
||||
.expect("模板必须带版本标记");
|
||||
let old = agents
|
||||
.replace("version=\"1.2.0\"", "version=\"1.0.0\"")
|
||||
.replace(
|
||||
&format!("version=\"{tpl_ver}\""),
|
||||
"version=\"1.0.0\"",
|
||||
)
|
||||
.replace("## 项目概况", "## 项目概况
|
||||
|
||||
我的项目说明")
|
||||
@ -473,7 +615,10 @@ mod tests {
|
||||
let agents_r = r3.iter().find(|t| t.name == "AGENTS.md").unwrap();
|
||||
assert_eq!(agents_r.action, "updated");
|
||||
let upgraded = std::fs::read_to_string(base.join("AGENTS.md")).unwrap();
|
||||
assert!(upgraded.contains("version=\"1.2.0\""), "块应升级到模板版本");
|
||||
assert!(
|
||||
upgraded.contains(&format!("version=\"{tpl_ver}\"")),
|
||||
"块应升级到模板版本"
|
||||
);
|
||||
assert!(upgraded.contains("我的项目说明"), "块外用户内容(前)须保留");
|
||||
assert!(upgraded.contains("用户自加的章节"), "块外用户内容(后)须保留");
|
||||
assert_eq!(upgraded.matches(MANAGED_START).count(), 1, "只有一个管理块");
|
||||
@ -536,12 +681,12 @@ mod tests {
|
||||
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(), 6, "检查链应为六项");
|
||||
assert_eq!(checks.len(), 7, "检查链应为七项");
|
||||
assert!(checks.iter().all(|c| !c.pass), "空项目应全部不通过");
|
||||
|
||||
// 铺齐全部生效条件后:六项全通过
|
||||
// 铺齐全部生效条件后:七项全通过
|
||||
dispatch_templates(base.to_str().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();
|
||||
@ -588,5 +733,147 @@ mod tests {
|
||||
assert!(!check_managed_version(None, expected).0, "文件缺失应 fail");
|
||||
assert!(!check_managed_version(Some("手写内容,无标记"), expected).0, "无标记应 fail");
|
||||
}
|
||||
|
||||
/// 回归:空路径登记曾把接入包写进炼境进程 cwd(src-tauri/,2026-07-15 批量实调实锤)。
|
||||
#[test]
|
||||
fn apply_rejects_empty_or_missing_path() {
|
||||
let err = apply_onboarding_pack(String::new(), "p".into()).unwrap_err();
|
||||
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();
|
||||
assert!(err.contains("拒绝分发"), "不存在路径应拒绝: {err}");
|
||||
}
|
||||
|
||||
// ── U1:全托管文件三态 + lefthook 检查段自省 ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn fully_managed_dispatch_three_states() {
|
||||
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();
|
||||
|
||||
// 不存在 → written
|
||||
let r = dispatch_fully_managed(&base, "envoy.md", "v2 内容");
|
||||
assert_eq!(r.action, "written");
|
||||
|
||||
// 一致 → skipped(CRLF 检出也视为一致)
|
||||
let r = dispatch_fully_managed(&base, "envoy.md", "v2 内容");
|
||||
assert_eq!(r.action, "skipped");
|
||||
std::fs::write(base.join("envoy.md"), "v2 内容".replace('\n', "\r\n")).unwrap();
|
||||
let r = dispatch_fully_managed(&base, "envoy.md", "v2 内容");
|
||||
assert_eq!(r.action, "skipped", "仅行尾差异不应触发覆盖");
|
||||
|
||||
// 不一致(旧版模板/被改动)→ 覆盖 updated
|
||||
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 内容");
|
||||
|
||||
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 _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
let pp = base.to_str().unwrap();
|
||||
|
||||
dispatch_templates(pp);
|
||||
// 模拟旧版本 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();
|
||||
assert_eq!(envoy.action, "updated", "全托管文件应被升级,而非跳过");
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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.len() >= 3, "模板检查段应不止一个: {tpl_keys:?}");
|
||||
// 注释掉的示例段不提取
|
||||
assert!(!tpl_keys.contains(&"api-typecheck".to_string()), "注释行不算键");
|
||||
|
||||
// 项目文件与模板一致 → pass
|
||||
let (pass, _) = check_lefthook_keys(Some(TMPL_LEFTHOOK), TMPL_LEFTHOOK);
|
||||
assert!(pass);
|
||||
|
||||
// 缺段 → fail 且列出缺失键
|
||||
let partial = "pre-commit:\n commands:\n typecheck:\n run: pnpm typecheck\n";
|
||||
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}");
|
||||
|
||||
// 文件缺失 → fail
|
||||
assert!(!check_lefthook_keys(None, TMPL_LEFTHOOK).0);
|
||||
}
|
||||
|
||||
// ── B4:使者回传覆盖率(沉默项目信号)────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn silent_projects_flags_active_without_returns() {
|
||||
let conn = crate::db::conn_with_schema();
|
||||
// 两个已登记项目:pw-a 有 commit 无回传(沉默);pw-b 有 commit 有回传
|
||||
conn.execute_batch(
|
||||
"INSERT INTO project_profiles (id, name) VALUES ('prof-a','项目A'),('prof-b','项目B');
|
||||
INSERT INTO project_workspaces (id, profile_id, win_path)
|
||||
VALUES ('pw-a','prof-a','E:/a'),('pw-b','prof-b','E:/b');",
|
||||
)
|
||||
.unwrap();
|
||||
// pw-a 的 commit 走 blueprint_buffs UUID 关联(生产真实形状)
|
||||
conn.execute(
|
||||
"INSERT INTO blueprint_buffs (project_id, project_path) VALUES ('uuid-a','E:/a')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO commit_metrics (sha, project_id, commit_type, is_rework, is_ci_auto, committed_at, parsed_at)
|
||||
VALUES ('c1','uuid-a','feat',0,0,datetime('now'),datetime('now')),
|
||||
('c2','uuid-a','fix',1,0,datetime('now'),datetime('now')),
|
||||
('c3','pw-b','feat',0,0,datetime('now'),datetime('now'))",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
// pw-b 近 30 天有使者回传;pw-a 只有普通笔记(不算回传)
|
||||
conn.execute(
|
||||
"INSERT INTO project_notes (project_id, content) VALUES
|
||||
('pw-b','【炼境使者回传】来源: 项目B ...'),
|
||||
('pw-a','普通笔记,不是回传')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
// 未登记项目的 commit 不列入(无法跑 /envoy)
|
||||
conn.execute(
|
||||
"INSERT INTO commit_metrics (sha, project_id, commit_type, is_rework, is_ci_auto, committed_at, parsed_at)
|
||||
VALUES ('c9','orphan','feat',0,0,datetime('now'),datetime('now'))",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let silent = silent_projects(&conn).unwrap();
|
||||
assert_eq!(silent.len(), 1, "只有 pw-a 沉默:{silent:?}");
|
||||
assert_eq!(silent[0].0, "项目A", "经 buff UUID 关联解析出名称");
|
||||
assert_eq!(silent[0].1, 2, "commit 数按项目聚合");
|
||||
|
||||
// 老回传(>30 天)不算覆盖:把 pw-b 的回传改旧后应变沉默
|
||||
conn.execute(
|
||||
"UPDATE project_notes SET created_at = datetime('now','localtime','-40 days') WHERE project_id = 'pw-b'",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let silent = silent_projects(&conn).unwrap();
|
||||
assert_eq!(silent.len(), 2, "pw-b 的回传过期后也应列为沉默");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -277,18 +277,9 @@ pub fn delete_project(id: String, delete_local: Option<bool>) -> Result<(), Stri
|
||||
)
|
||||
.unwrap_or((None, None, None));
|
||||
|
||||
// 清理产品组 MCP 配置(在删除 project_group_map 记录之前)
|
||||
let group_ids: Vec<String> = conn
|
||||
.prepare("SELECT group_id FROM project_group_map WHERE project_id = ?1")
|
||||
.map_err(|e| e.to_string())?
|
||||
.query_map(params![id], |row| row.get::<_, String>(0))
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
for group_id in &group_ids {
|
||||
if let Err(e) = mcp_inject::remove_project_mcp(&id, group_id) {
|
||||
eprintln!("[MCP remove] 删除项目时清除失败 project={} group={}: {}", id, group_id, e);
|
||||
}
|
||||
// 清理炼境 MCP 配置(在删除 project_workspaces 记录之前,需查项目路径)
|
||||
if let Err(e) = mcp_inject::remove_project_mcp(&id) {
|
||||
eprintln!("[MCP remove] 删除项目时清除失败 project={}: {}", id, e);
|
||||
}
|
||||
|
||||
// 清理关联数据(旧 FK 指向 projects 表,需手动清理)
|
||||
|
||||
@ -263,7 +263,7 @@ mod tests {
|
||||
rules_trailer: String::new(),
|
||||
files: files.iter().map(|s| s.to_string()).collect(),
|
||||
};
|
||||
record_commit_entry(conn, pid, &entry).unwrap();
|
||||
record_commit_entry(conn, pid, &entry, None).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -406,7 +406,7 @@ pub fn seed_builtin_templates() {
|
||||
name: "pnpm Monorepo",
|
||||
description: "多包 monorepo,含 apps/ 与 packages/ 目录",
|
||||
platform: "both",
|
||||
tech_stack: "Node,pnpm",
|
||||
tech_stack: r#"{"客户端":["Web"],"框架/工具":["Node","pnpm"]}"#,
|
||||
init_commands: "pnpm install",
|
||||
variants: None,
|
||||
files: vec![
|
||||
@ -439,7 +439,7 @@ pub fn seed_builtin_templates() {
|
||||
name: "pnpm App",
|
||||
description: "单包 Node.js 应用",
|
||||
platform: "both",
|
||||
tech_stack: "Node,pnpm",
|
||||
tech_stack: r#"{"客户端":["Web"],"框架/工具":["Node","pnpm"]}"#,
|
||||
init_commands: "pnpm install",
|
||||
variants: None,
|
||||
files: vec![
|
||||
@ -463,7 +463,7 @@ pub fn seed_builtin_templates() {
|
||||
name: "Rust",
|
||||
description: "Rust 二进制项目",
|
||||
platform: "both",
|
||||
tech_stack: "Rust",
|
||||
tech_stack: r#"{"客户端":["CLI","Desktop"],"语言":["Rust"]}"#,
|
||||
init_commands: "cargo build",
|
||||
variants: None,
|
||||
files: vec![
|
||||
@ -493,7 +493,7 @@ edition = "2021"
|
||||
name: "Tauri + React",
|
||||
description: "桌面应用,Tauri v2 + React 19 + TypeScript + Vite",
|
||||
platform: "both",
|
||||
tech_stack: "Rust,Node,pnpm,React,TypeScript,Tauri",
|
||||
tech_stack: r#"{"客户端":["Desktop"],"语言":["TypeScript","Rust"],"框架/工具":["React","Tauri","Node","pnpm"]}"#,
|
||||
init_commands: "pnpm install",
|
||||
variants: None,
|
||||
files: vec![
|
||||
@ -680,7 +680,7 @@ pub fn run() {
|
||||
name: "Expo",
|
||||
description: "React Native 移动端 App(Android / iOS)。TypeScript + Expo SDK,适合游戏、工具、内容类手机应用。选择下方模板变体来确定初始化方式。",
|
||||
platform: "both",
|
||||
tech_stack: "React Native,TypeScript,Expo",
|
||||
tech_stack: r#"{"客户端":["Android","iOS"],"语言":["TypeScript"],"框架/工具":["React Native","Expo"]}"#,
|
||||
init_commands: "pnpm create expo-app@latest . --template blank-typescript",
|
||||
variants: Some(r#"[
|
||||
{"label":"blank-typescript(推荐)","description":"最小化 TypeScript 起步,适合游戏类、工具类、独立 App","initCommands":"pnpm create expo-app@latest . --template blank-typescript"},
|
||||
@ -695,7 +695,7 @@ pub fn run() {
|
||||
name: "pnpm Monorepo + Expo",
|
||||
description: "pnpm 多包 Monorepo:apps/ 内含 Expo Router (React Native) 应用,packages/ 内含共享 @repo/types 包。Metro 已配置 monorepo 路径解析(含 Windows 兼容修复),适合多 App 扩展场景。",
|
||||
platform: "both",
|
||||
tech_stack: "React Native,TypeScript,Expo,pnpm",
|
||||
tech_stack: r#"{"客户端":["Android","iOS","Web"],"语言":["TypeScript"],"框架/工具":["React Native","Expo","pnpm"]}"#,
|
||||
init_commands: "pnpm install",
|
||||
variants: None,
|
||||
files: vec![
|
||||
@ -905,7 +905,7 @@ const styles = StyleSheet.create({
|
||||
name: "React + Vite SPA",
|
||||
description: "纯前端 Web 应用,React 19 + TypeScript + TailwindCSS v4 + React Router v7",
|
||||
platform: "both",
|
||||
tech_stack: "React,TypeScript,Vite,TailwindCSS",
|
||||
tech_stack: r#"{"客户端":["Web"],"语言":["TypeScript"],"框架/工具":["React","Vite","TailwindCSS"]}"#,
|
||||
init_commands: "pnpm install",
|
||||
variants: None,
|
||||
files: vec![
|
||||
@ -1020,7 +1020,7 @@ export default function App() {
|
||||
name: "Tauri + React + Tailwind",
|
||||
description: "桌面应用,Tauri v2 + React 19 + TypeScript + TailwindCSS v4",
|
||||
platform: "both",
|
||||
tech_stack: "Rust,Node,pnpm,React,TypeScript,Tauri,TailwindCSS",
|
||||
tech_stack: r#"{"客户端":["Desktop"],"语言":["TypeScript","Rust"],"框架/工具":["React","Tauri","TailwindCSS","Node","pnpm"]}"#,
|
||||
init_commands: "pnpm install",
|
||||
variants: None,
|
||||
files: vec![
|
||||
@ -1220,7 +1220,7 @@ pub fn run() {
|
||||
name: "Web + Mobile Monorepo",
|
||||
description: "跨平台代码复用:packages/core 共享业务逻辑,apps/web(Vite + Tailwind)+ apps/mobile(Expo + NativeWind)",
|
||||
platform: "both",
|
||||
tech_stack: "React,React Native,TypeScript,Expo,Vite,TailwindCSS,NativeWind,pnpm",
|
||||
tech_stack: r#"{"客户端":["Android","iOS","Web"],"语言":["TypeScript"],"框架/工具":["React","React Native","Expo","Vite","TailwindCSS","NativeWind","pnpm"]}"#,
|
||||
init_commands: "pnpm install",
|
||||
variants: None,
|
||||
files: vec![
|
||||
@ -1589,13 +1589,833 @@ export default function HomeScreen() {
|
||||
"#),
|
||||
],
|
||||
},
|
||||
// ── 三端体系模板(同一架构,按需起步)────────────────────────────────────
|
||||
Tpl {
|
||||
id: "builtin-react-web",
|
||||
name: "React Web",
|
||||
description: "三端体系 Web 基础。React 18 + TypeScript + Ant Design 5 + Zustand,可随时扩展为 Capacitor Android 或 Tauri Desktop。",
|
||||
platform: "both",
|
||||
tech_stack: r#"{"体系":["三端"],"客户端":["Web"],"语言":["TypeScript"],"框架/工具":["React","Ant Design","Zustand","Vite","pnpm"]}"#,
|
||||
init_commands: "pnpm install",
|
||||
variants: None,
|
||||
files: vec![
|
||||
("package.json", r#"{
|
||||
"name": "{{project_name_kebab}}",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"web": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"antd": "^5.21.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
"#),
|
||||
("vite.config.ts", r#"import { defineConfig, loadEnv } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
loadEnv(mode, process.cwd(), "");
|
||||
return {
|
||||
plugins: [react()],
|
||||
base: mode === "capacitor" ? "./" : "/",
|
||||
};
|
||||
});
|
||||
"#),
|
||||
("tsconfig.json", r#"{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
"#),
|
||||
("index.html", r#"<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{{project_name}}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
"#),
|
||||
(".env", "VITE_PLATFORM=web\n"),
|
||||
("src/main.tsx", r#"import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
"#),
|
||||
("src/App.tsx", r#"import { Button } from "antd";
|
||||
import { useAppStore } from "./store";
|
||||
|
||||
export default function App() {
|
||||
const { count, increment } = useAppStore();
|
||||
return (
|
||||
<div style={{ padding: "2rem", fontFamily: "sans-serif" }}>
|
||||
<h1>{{project_name}}</h1>
|
||||
<p>React 18 + Ant Design 5 + Zustand</p>
|
||||
<Button type="primary" onClick={increment}>
|
||||
count: {count}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
"#),
|
||||
("src/store/index.ts", r#"import { create } from "zustand";
|
||||
|
||||
interface AppStore {
|
||||
count: number;
|
||||
increment: () => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppStore>((set) => ({
|
||||
count: 0,
|
||||
increment: () => set((s) => ({ count: s.count + 1 })),
|
||||
}));
|
||||
"#),
|
||||
(".gitignore", "node_modules/\ndist/\n*.log\n.env\n.env.*\n!.env.example\n"),
|
||||
],
|
||||
},
|
||||
Tpl {
|
||||
id: "builtin-react-capacitor",
|
||||
name: "React + Capacitor",
|
||||
description: "Web + Android。React 18 + Ant Design 5 + Zustand + Capacitor 8,pnpm build:cap 打包为原生 APK。",
|
||||
platform: "both",
|
||||
tech_stack: r#"{"体系":["三端"],"客户端":["Web","Android"],"语言":["TypeScript"],"框架/工具":["React","Ant Design","Zustand","Capacitor","Vite","pnpm"]}"#,
|
||||
init_commands: "pnpm install",
|
||||
variants: None,
|
||||
files: vec![
|
||||
("package.json", r#"{
|
||||
"name": "{{project_name_kebab}}",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"web": "vite",
|
||||
"build": "vite build",
|
||||
"build:cap": "vite build --mode capacitor && npx cap sync",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/core": "^8.0.0",
|
||||
"antd": "^5.21.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/android": "^8.0.0",
|
||||
"@capacitor/cli": "^8.0.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
"#),
|
||||
("vite.config.ts", r#"import { defineConfig, loadEnv } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
loadEnv(mode, process.cwd(), "");
|
||||
return {
|
||||
plugins: [react()],
|
||||
base: mode === "capacitor" ? "./" : "/",
|
||||
};
|
||||
});
|
||||
"#),
|
||||
("tsconfig.json", r#"{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src", "capacitor.config.ts"]
|
||||
}
|
||||
"#),
|
||||
("index.html", r#"<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{{project_name}}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
"#),
|
||||
(".env", "VITE_PLATFORM=web\n"),
|
||||
(".env.capacitor", "VITE_PLATFORM=android\n"),
|
||||
("capacitor.config.ts", r#"import type { CapacitorConfig } from "@capacitor/cli";
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: "com.example.{{project_name_snake}}",
|
||||
appName: "{{project_name}}",
|
||||
webDir: "dist",
|
||||
};
|
||||
|
||||
export default config;
|
||||
"#),
|
||||
("src/main.tsx", r#"import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
"#),
|
||||
("src/App.tsx", r#"import { Button } from "antd";
|
||||
import { useAppStore } from "./store";
|
||||
|
||||
export default function App() {
|
||||
const { count, increment } = useAppStore();
|
||||
return (
|
||||
<div style={{ padding: "2rem", fontFamily: "sans-serif" }}>
|
||||
<h1>{{project_name}}</h1>
|
||||
<p>React 18 + Ant Design 5 + Zustand + Capacitor</p>
|
||||
<Button type="primary" onClick={increment}>
|
||||
count: {count}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
"#),
|
||||
("src/store/index.ts", r#"import { create } from "zustand";
|
||||
|
||||
interface AppStore {
|
||||
count: number;
|
||||
increment: () => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppStore>((set) => ({
|
||||
count: 0,
|
||||
increment: () => set((s) => ({ count: s.count + 1 })),
|
||||
}));
|
||||
"#),
|
||||
(".gitignore", "node_modules/\ndist/\n*.log\n.env\n.env.*\n!.env.example\nandroid/\n"),
|
||||
],
|
||||
},
|
||||
Tpl {
|
||||
id: "builtin-react-tauri-antd",
|
||||
name: "React + Tauri",
|
||||
description: "Web + Desktop。React 18 + Ant Design 5 + Zustand + Tauri 2,pnpm build:desktop 打包为原生桌面应用。",
|
||||
platform: "both",
|
||||
tech_stack: r#"{"体系":["三端"],"客户端":["Web","Desktop"],"语言":["TypeScript","Rust"],"框架/工具":["React","Ant Design","Zustand","Tauri","Vite","pnpm"]}"#,
|
||||
init_commands: "pnpm install",
|
||||
variants: None,
|
||||
files: vec![
|
||||
("package.json", r#"{
|
||||
"name": "{{project_name_kebab}}",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"web": "vite",
|
||||
"build": "vite build",
|
||||
"build:desktop": "tauri build",
|
||||
"dev:desktop": "tauri dev",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"antd": "^5.21.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
"#),
|
||||
("vite.config.ts", r#"import { defineConfig, loadEnv } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
loadEnv(mode, process.cwd(), "");
|
||||
return {
|
||||
plugins: [react()],
|
||||
base: mode === "capacitor" ? "./" : "/",
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
watch: {
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
"#),
|
||||
("tsconfig.json", r#"{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
"#),
|
||||
("index.html", r#"<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{{project_name}}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
"#),
|
||||
(".env", "VITE_PLATFORM=web\n"),
|
||||
("src/main.tsx", r#"import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
"#),
|
||||
("src/App.tsx", r#"import { Button } from "antd";
|
||||
import { useAppStore } from "./store";
|
||||
|
||||
export default function App() {
|
||||
const { count, increment } = useAppStore();
|
||||
return (
|
||||
<div style={{ padding: "2rem", fontFamily: "sans-serif" }}>
|
||||
<h1>{{project_name}}</h1>
|
||||
<p>React 18 + Ant Design 5 + Zustand + Tauri 2</p>
|
||||
<Button type="primary" onClick={increment}>
|
||||
count: {count}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
"#),
|
||||
("src/store/index.ts", r#"import { create } from "zustand";
|
||||
|
||||
interface AppStore {
|
||||
count: number;
|
||||
increment: () => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppStore>((set) => ({
|
||||
count: 0,
|
||||
increment: () => set((s) => ({ count: s.count + 1 })),
|
||||
}));
|
||||
"#),
|
||||
("src-tauri/tauri.conf.json", r#"{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "{{project_name_kebab}}",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.example.{{project_name_snake}}",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm web",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "pnpm build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "{{project_name}}",
|
||||
"width": 1200,
|
||||
"height": 800
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": []
|
||||
}
|
||||
}
|
||||
"#),
|
||||
("src-tauri/Cargo.toml", r#"[package]
|
||||
name = "{{project_name_snake}}"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = []
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "{{project_name_snake}}_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
"#),
|
||||
("src-tauri/build.rs", "fn main() {\n tauri_build::build()\n}\n"),
|
||||
("src-tauri/src/main.rs", r#"// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
{{project_name_snake}}_lib::run()
|
||||
}
|
||||
"#),
|
||||
("src-tauri/src/lib.rs", r#"#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
"#),
|
||||
("src-tauri/capabilities/default.json", r#"{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default"
|
||||
]
|
||||
}
|
||||
"#),
|
||||
(".gitignore", "node_modules/\ndist/\n*.log\n.env\n.env.*\n!.env.example\nsrc-tauri/target/\nsrc-tauri/gen/\n"),
|
||||
],
|
||||
},
|
||||
Tpl {
|
||||
id: "builtin-react-triplatform",
|
||||
name: "React Tri-Platform",
|
||||
description: "三端合一。同一套 React 18 + Ant Design 5 + Zustand 代码,pnpm web / build:cap / build:desktop 分别构建 Web / Android / Desktop。",
|
||||
platform: "both",
|
||||
tech_stack: r#"{"体系":["三端"],"客户端":["Web","Android","Desktop"],"语言":["TypeScript","Rust"],"框架/工具":["React","Ant Design","Zustand","Capacitor","Tauri","Vite","pnpm"]}"#,
|
||||
init_commands: "pnpm install",
|
||||
variants: None,
|
||||
files: vec![
|
||||
("package.json", r#"{
|
||||
"name": "{{project_name_kebab}}",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"web": "vite",
|
||||
"build": "vite build",
|
||||
"build:cap": "vite build --mode capacitor && npx cap sync",
|
||||
"build:desktop": "tauri build",
|
||||
"dev:desktop": "tauri dev",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/core": "^8.0.0",
|
||||
"@tauri-apps/api": "^2",
|
||||
"antd": "^5.21.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/android": "^8.0.0",
|
||||
"@capacitor/cli": "^8.0.0",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
"#),
|
||||
("vite.config.ts", r#"import { defineConfig, loadEnv } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
loadEnv(mode, process.cwd(), "");
|
||||
return {
|
||||
plugins: [react()],
|
||||
base: mode === "capacitor" ? "./" : "/",
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
watch: {
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
"#),
|
||||
("tsconfig.json", r#"{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src", "capacitor.config.ts"]
|
||||
}
|
||||
"#),
|
||||
("index.html", r#"<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{{project_name}}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
"#),
|
||||
(".env", "VITE_PLATFORM=web\n"),
|
||||
(".env.capacitor", "VITE_PLATFORM=android\n"),
|
||||
("capacitor.config.ts", r#"import type { CapacitorConfig } from "@capacitor/cli";
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: "com.example.{{project_name_snake}}",
|
||||
appName: "{{project_name}}",
|
||||
webDir: "dist",
|
||||
};
|
||||
|
||||
export default config;
|
||||
"#),
|
||||
("src/main.tsx", r#"import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
"#),
|
||||
("src/App.tsx", r#"import { Button } from "antd";
|
||||
import { useAppStore } from "./store";
|
||||
|
||||
export default function App() {
|
||||
const { count, increment } = useAppStore();
|
||||
return (
|
||||
<div style={{ padding: "2rem", fontFamily: "sans-serif" }}>
|
||||
<h1>{{project_name}}</h1>
|
||||
<p>React 18 + Ant Design 5 + Zustand · Web / Android / Desktop</p>
|
||||
<Button type="primary" onClick={increment}>
|
||||
count: {count}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
"#),
|
||||
("src/store/index.ts", r#"import { create } from "zustand";
|
||||
|
||||
interface AppStore {
|
||||
count: number;
|
||||
increment: () => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppStore>((set) => ({
|
||||
count: 0,
|
||||
increment: () => set((s) => ({ count: s.count + 1 })),
|
||||
}));
|
||||
"#),
|
||||
("src-tauri/tauri.conf.json", r#"{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "{{project_name_kebab}}",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.example.{{project_name_snake}}",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm web",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "pnpm build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "{{project_name}}",
|
||||
"width": 1200,
|
||||
"height": 800
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": []
|
||||
}
|
||||
}
|
||||
"#),
|
||||
("src-tauri/Cargo.toml", r#"[package]
|
||||
name = "{{project_name_snake}}"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = []
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "{{project_name_snake}}_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
"#),
|
||||
("src-tauri/build.rs", "fn main() {\n tauri_build::build()\n}\n"),
|
||||
("src-tauri/src/main.rs", r#"// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
{{project_name_snake}}_lib::run()
|
||||
}
|
||||
"#),
|
||||
("src-tauri/src/lib.rs", r#"#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
"#),
|
||||
("src-tauri/capabilities/default.json", r#"{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default"
|
||||
]
|
||||
}
|
||||
"#),
|
||||
(".gitignore", "node_modules/\ndist/\n*.log\n.env\n.env.*\n!.env.example\nandroid/\nsrc-tauri/target/\nsrc-tauri/gen/\n"),
|
||||
],
|
||||
},
|
||||
// ── 知识库(择升环)────────────────────────────────────────────────────
|
||||
Tpl {
|
||||
id: "builtin-knowledge-loop",
|
||||
name: "知识库(择升环)",
|
||||
description: "方法论 / 知识沉淀文档库。三层晋升结构:dialogues 对话原料 → concepts 概念卡 → CORE 稳定核心,晋升规则内置于 AGENTS.md,agent 进库即按择升环工作流运转。",
|
||||
platform: "both",
|
||||
tech_stack: r#"{"体系":["知识库"],"框架/工具":["Markdown","Git"]}"#,
|
||||
init_commands: "git init",
|
||||
variants: None,
|
||||
files: vec![
|
||||
("CORE.md", r#"# {{project_name}} · 核心层
|
||||
|
||||
> 本文件是知识库的稳定核心——只收录经过实践检验、表述已收敛的内容。
|
||||
> ⚠️ 过滤铁律:全文上限 300 行。收录新内容超限时,必须合并或挤掉旧内容——
|
||||
> 只升不择的核心层会失去判断力。
|
||||
|
||||
## 定义
|
||||
|
||||
<!-- 本知识库研究的核心概念,一句话可复述的稳定定义 -->
|
||||
|
||||
## 已验证原理
|
||||
|
||||
<!-- 每条原理注明来源,格式:
|
||||
### 原理名
|
||||
一段话陈述。
|
||||
来源:concepts/<概念卡>.md · 验证案例:cases/<案例>.md
|
||||
-->
|
||||
|
||||
## 修订记录
|
||||
|
||||
<!-- 日期 · 进了什么 · 挤掉/合并了什么 · 为什么 -->
|
||||
"#),
|
||||
("AGENTS.md", r#"# {{project_name}} — AI Agent 工作规程
|
||||
|
||||
本仓库是知识库,不是代码库。目录即晋升流水线:
|
||||
|
||||
```
|
||||
dialogues/(对话原料,允许粗糙)
|
||||
→ concepts/(概念卡,一事一文件)
|
||||
→ CORE.md(稳定核心,上限 300 行)
|
||||
```
|
||||
|
||||
cases/ 是检验层:概念卡晋升 CORE 的依据来自真实案例。
|
||||
|
||||
## 每次会话必须执行
|
||||
|
||||
1. 先读 `CORE.md` 与本次话题相关的 `concepts/` 卡——上一轮的结论是这一轮的输入
|
||||
2. 探讨结束后,把整理稿写入 `dialogues/YYYY-MM-DD-主题.md`
|
||||
3. 对照晋升规则检查是否触发晋升 / 降级,触发则执行
|
||||
4. git commit 收尾——本库以 git 历史作为演化台账,scope 标注层级(见下)
|
||||
|
||||
## Commit 层级信号
|
||||
|
||||
统一格式 `docs(<层级>): <摘要>`,让外部观测者(如炼境飞轮)凭 commit 流
|
||||
即可读出晋升频率与环的健康度——dialogue 多而 concept 少 = 只积累不过滤;
|
||||
concept 多而 core 不动 = 过滤了但不收敛。
|
||||
|
||||
- `docs(dialogue): YYYY-MM-DD-主题` — 新对话沉淀
|
||||
- `docs(concept): <概念名> 立卡|修订|废弃` — 概念卡变动
|
||||
- `docs(core): <结论>入核,挤掉/合并<旧条目>` — 核心层变动
|
||||
- `docs(case): <事件>复盘` — 案例复盘
|
||||
|
||||
## 晋升规则(择)
|
||||
|
||||
| 从 | 到 | 条件 |
|
||||
|----|----|------|
|
||||
| dialogues/ | concepts/ | 同一想法在 ≥2 次对话中出现,或 ≥1 个真实案例站住 |
|
||||
| concepts/ | CORE.md | 状态「稳定」+ 经 cases/ 检验 + 表述收敛为一段话 |
|
||||
|
||||
降级同样重要:被案例证伪的概念卡标记废弃;CORE.md 超 300 行必须挤旧。
|
||||
只升不择,核心层会膨胀成噪音。
|
||||
|
||||
## 写作纪律
|
||||
|
||||
- 单一事实来源:同一结论只在一层的一个文件里详述,其他位置引用
|
||||
- 概念卡必须写「边界」(什么条件下不成立)——没有边界的结论不可信
|
||||
- 修改 CORE.md 必须在其修订记录写明:进了什么、挤了什么、为什么
|
||||
- 案例复盘必做归因二分:执行缺口(概念没用好)修行动;
|
||||
规格缺口(概念本身有漏洞)回改概念卡——后者是全库最值钱的信号
|
||||
"#),
|
||||
("concepts/README.md", r#"# concepts/ — 概念卡层
|
||||
|
||||
一个概念一个文件,文件名 kebab-case(如 `pinggu-wai-huan.md`)。
|
||||
|
||||
## 概念卡格式
|
||||
|
||||
```markdown
|
||||
# 概念名
|
||||
|
||||
- 状态:孵化 | 稳定 | 已入核 | 已废弃
|
||||
- 出处:dialogues/YYYY-MM-DD-xxx.md
|
||||
- 最近验证:YYYY-MM-DD(cases/xxx.md)
|
||||
|
||||
## 定义
|
||||
一段话说清它是什么。
|
||||
|
||||
## 为什么成立
|
||||
支撑它的推理或证据。
|
||||
|
||||
## 边界
|
||||
它在什么条件下不成立。
|
||||
```
|
||||
|
||||
## 晋升与降级
|
||||
|
||||
- 晋升进 CORE.md 后,状态改「已入核」,详述保留在卡内,CORE 只收一段话结论
|
||||
- 被案例证伪 → 状态改「已废弃」并写明证伪案例,不直接删(失败也是判断力)
|
||||
"#),
|
||||
("dialogues/README.md", r#"# dialogues/ — 对话沉淀层(原料)
|
||||
|
||||
每次探讨结束后,把整理稿存入本目录。这一层的目标是「不丢」,不是「精炼」。
|
||||
|
||||
- 命名:`YYYY-MM-DD-主题.md`
|
||||
- 开头写三行摘要:讨论了什么 / 新想法 / 待验证
|
||||
- 同一想法第二次出现时,检查是否触发晋升为概念卡
|
||||
"#),
|
||||
("cases/README.md", r#"# cases/ — 实例复盘层(检验)
|
||||
|
||||
记录概念在真实事件中的应用与翻车。案例是概念卡晋升 CORE 的检验依据。
|
||||
|
||||
- 命名:`YYYY-MM-DD-事件.md`
|
||||
- 必写字段:用了哪个概念 / 预期 / 实际结果 / 归因(执行缺口 or 规格缺口)
|
||||
- 规格缺口 → 回改概念卡;执行缺口 → 概念不动,记录教训
|
||||
"#),
|
||||
(".gitignore", ".DS_Store\nThumbs.db\n*.tmp\n"),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for t in &templates {
|
||||
let _ = conn.execute(
|
||||
"INSERT OR IGNORE INTO project_templates
|
||||
"INSERT INTO project_templates
|
||||
(id, name, description, platform, tech_stack, init_commands, is_builtin, variants)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7)",
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
description = excluded.description,
|
||||
tech_stack = excluded.tech_stack,
|
||||
init_commands = excluded.init_commands,
|
||||
variants = excluded.variants",
|
||||
params![
|
||||
t.id,
|
||||
t.name,
|
||||
@ -1606,16 +2426,11 @@ export default function HomeScreen() {
|
||||
t.variants,
|
||||
],
|
||||
);
|
||||
// 已存在的模板也补充 variants(INSERT OR IGNORE 不更新已有行)
|
||||
let _ = conn.execute(
|
||||
"UPDATE project_templates SET variants = ?1 WHERE id = ?2 AND is_builtin = 1",
|
||||
params![t.variants, t.id],
|
||||
);
|
||||
|
||||
for (path, content) in &t.files {
|
||||
let file_id = format!("{}-{}", t.id, path.replace('/', "-"));
|
||||
let _ = conn.execute(
|
||||
"INSERT OR IGNORE INTO project_template_files (id, template_id, path, content)
|
||||
"INSERT OR REPLACE INTO project_template_files (id, template_id, path, content)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![file_id, t.id, path, content],
|
||||
);
|
||||
|
||||
@ -61,6 +61,12 @@ pub fn run() {
|
||||
let mcp_port = mcp_server::read_port();
|
||||
tauri::async_runtime::spawn(mcp_server::start(mcp_port));
|
||||
|
||||
// ── MCP 单一服务器化一次性迁移(settings 标记位防重复)────
|
||||
std::thread::spawn(|| {
|
||||
std::thread::sleep(std::time::Duration::from_secs(10)); // 等 DB 就绪
|
||||
mcp_inject::migrate_to_single_server();
|
||||
});
|
||||
|
||||
// ── Gitea 远端轮询(G6):每 10 分钟拉取绑定仓库 commit ───
|
||||
// 出站 HTTPS 采集,适配公网 Gitea + 内网炼境拓扑(webhook 不可达时的降级)
|
||||
std::thread::spawn(|| {
|
||||
|
||||
@ -2,10 +2,29 @@ use crate::{db, db::log_event};
|
||||
use serde_json::{json, Value};
|
||||
use std::path::PathBuf;
|
||||
|
||||
const MCP_KEY_PREFIX: &str = "lian-jing:group-";
|
||||
/// 单一全局服务器的 settings.json key / 全局注册名
|
||||
const MCP_KEY: &str = "lian-jing";
|
||||
/// 旧组级 key 前缀——仅用于注入时清理存量配置,T6 收尾后可删
|
||||
const LEGACY_KEY_PREFIX: &str = "lian-jing:group-";
|
||||
|
||||
// ── 路径解析 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 校验并解析登记路径:空串/空白/相对路径一律拒绝。
|
||||
/// 空路径经 PathBuf::from("") 会解析到炼境进程 cwd——2026-07-15 空路径登记
|
||||
/// 曾把 settings.json/liangjing.json 写进 src-tauri/(apply 链路同日已拦,
|
||||
/// inject 链路本处单点补防)。
|
||||
fn validate_registered_path(raw: &str, field: &str) -> Result<PathBuf, String> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(format!("{field} 为空,拒绝解析(会写进炼境进程 cwd)"));
|
||||
}
|
||||
let path = PathBuf::from(trimmed);
|
||||
if !path.is_absolute() {
|
||||
return Err(format!("{field} 非绝对路径,拒绝解析: {trimmed:?}"));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// 解析项目根目录路径(WSL 项目自动转为 Windows UNC 路径)
|
||||
fn project_root(project_id: &str) -> Result<PathBuf, String> {
|
||||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||
@ -21,6 +40,9 @@ fn project_root(project_id: &str) -> Result<PathBuf, String> {
|
||||
|
||||
if platform.as_deref() == Some("wsl") {
|
||||
let wsl = wsl_path.ok_or_else(|| "WSL 项目未配置 wsl_path".to_string())?;
|
||||
if wsl.trim().is_empty() {
|
||||
return Err("wsl_path 为空,拒绝解析(会指向 WSL 发行版根目录)".to_string());
|
||||
}
|
||||
let distro: String = conn
|
||||
.query_row(
|
||||
"SELECT value FROM settings WHERE key = 'wsl_distro'",
|
||||
@ -35,7 +57,7 @@ fn project_root(project_id: &str) -> Result<PathBuf, String> {
|
||||
)))
|
||||
} else {
|
||||
let win = win_path.ok_or_else(|| "Windows 项目未配置 win_path".to_string())?;
|
||||
Ok(PathBuf::from(win))
|
||||
validate_registered_path(&win, "win_path")
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,18 +69,25 @@ fn liangjing_path(project_id: &str) -> Result<PathBuf, String> {
|
||||
Ok(project_root(project_id)?.join(".claude").join("liangjing.json"))
|
||||
}
|
||||
|
||||
/// 写炼境上下文文件,供子项目 Claude 会话直接读取 project_id / group_id
|
||||
fn write_liangjing_context(project_id: &str, group_id: &str, port: u16) -> Result<(), String> {
|
||||
/// 写炼境上下文文件,供子项目 Claude 会话直接读取 project_id。
|
||||
/// 合并写入:保留文件中已有的其他字段;主动删除 group_id(单一服务器寻址后已无用,留着会过期误导)
|
||||
fn write_liangjing_context(project_id: &str, port: u16) -> Result<(), String> {
|
||||
let path = liangjing_path(project_id)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("创建 .claude 目录失败: {}", e))?;
|
||||
}
|
||||
let ctx = json!({
|
||||
"project_id": project_id,
|
||||
"group_id": group_id,
|
||||
"mcp_port": port
|
||||
});
|
||||
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 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))
|
||||
@ -102,24 +131,67 @@ fn find_claude_bin() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// 将产品组 MCP 注册到 Claude 全局配置(~/.claude.json),best-effort,失败仅记录日志
|
||||
fn try_register_globally(group_id: &str, port: u16) {
|
||||
let server_name = format!("lian-jing-{}", group_id);
|
||||
let server_url = format!("http://127.0.0.1:{}/mcp/group/{}", port, group_id);
|
||||
/// 查找 git-bash 路径(claude CLI 在 Windows 上必需)。
|
||||
/// 顺序:环境变量(校验存在)→ 常见安装位置(Program Files / Scoop)→ 从 git.exe 位置推导
|
||||
fn find_git_bash() -> Option<String> {
|
||||
if let Ok(p) = std::env::var("CLAUDE_CODE_GIT_BASH_PATH") {
|
||||
if std::path::Path::new(&p).exists() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
|
||||
let home = std::env::var("USERPROFILE").unwrap_or_default();
|
||||
let scoop = std::env::var("SCOOP").unwrap_or_else(|_| format!(r"{}\scoop", home));
|
||||
let candidates = [
|
||||
r"C:\Program Files\Git\bin\bash.exe".to_string(),
|
||||
format!(r"{}\apps\git\current\bin\bash.exe", scoop),
|
||||
];
|
||||
for c in &candidates {
|
||||
if std::path::Path::new(c).exists() {
|
||||
return Some(c.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// 从 git.exe 推导:<root>\cmd\git.exe / <root>\mingw64\bin\git.exe → <root>\bin\bash.exe
|
||||
if let Ok(out) = std::process::Command::new("where").arg("git.exe").output() {
|
||||
if out.status.success() {
|
||||
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
||||
let git = std::path::Path::new(line.trim());
|
||||
if let Some(root) = git.parent().and_then(|d| d.parent()) {
|
||||
let bash = root.join("bin").join("bash.exe");
|
||||
if bash.exists() {
|
||||
return Some(bash.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 将单一全局 lian-jing 服务器注册到 Claude 全局配置(~/.claude.json)。
|
||||
/// best-effort,失败仅记录日志;返回是否成功(同名已存在视为成功,作为 CLI 链路健康信号)
|
||||
fn try_register_globally(port: u16) -> bool {
|
||||
let server_url = format!("http://127.0.0.1:{}/mcp", port);
|
||||
|
||||
let Some(claude_bin) = find_claude_bin() else {
|
||||
db::log_event(
|
||||
"mcp_inject",
|
||||
"warn",
|
||||
"claude CLI 未找到,跳过全局 Mcp 注册",
|
||||
"claude CLI 未找到,跳过全局 MCP 注册",
|
||||
None,
|
||||
);
|
||||
return;
|
||||
return false;
|
||||
};
|
||||
let Some(git_bash) = find_git_bash() else {
|
||||
db::log_event(
|
||||
"mcp_inject",
|
||||
"warn",
|
||||
"git-bash 未找到(claude CLI 依赖),跳过全局 MCP 注册",
|
||||
None,
|
||||
);
|
||||
return false;
|
||||
};
|
||||
|
||||
let git_bash = std::env::var("CLAUDE_CODE_GIT_BASH_PATH").unwrap_or_else(|_| {
|
||||
r"C:\Program Files\Git\bin\bash.exe".to_string()
|
||||
});
|
||||
|
||||
match std::process::Command::new(&claude_bin)
|
||||
.args([
|
||||
@ -129,38 +201,90 @@ fn try_register_globally(group_id: &str, port: u16) {
|
||||
"sse",
|
||||
"-s",
|
||||
"user",
|
||||
&server_name,
|
||||
MCP_KEY,
|
||||
&server_url,
|
||||
])
|
||||
.env("CLAUDE_CODE_GIT_BASH_PATH", &git_bash)
|
||||
.output()
|
||||
{
|
||||
Ok(out) if out.status.success() => {
|
||||
db::log_event(
|
||||
"mcp_inject",
|
||||
"info",
|
||||
"全局 MCP 注册成功",
|
||||
Some(&server_name),
|
||||
);
|
||||
db::log_event("mcp_inject", "info", "全局 MCP 注册成功", Some(MCP_KEY));
|
||||
true
|
||||
}
|
||||
Ok(out) => {
|
||||
let msg = format!(
|
||||
"claude mcp add 非零退出: {}",
|
||||
let combined = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
db::log_event("mcp_inject", "warn", &msg, Some(&server_name));
|
||||
// 同名已存在(exit 1)不是故障——CLI 链路健康,注册目标已达成
|
||||
if combined.contains("already exists") {
|
||||
return true;
|
||||
}
|
||||
db::log_event(
|
||||
"mcp_inject",
|
||||
"warn",
|
||||
&format!("claude mcp add 非零退出: {}", combined),
|
||||
Some(MCP_KEY),
|
||||
);
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
db::log_event(
|
||||
"mcp_inject",
|
||||
"warn",
|
||||
&format!("执行 claude mcp add 失败: {}", e),
|
||||
Some(&server_name),
|
||||
Some(MCP_KEY),
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理旧组级全局注册(lian-jing-{gid},含完整 UUID 与短 8 位两种历史命名)。
|
||||
/// 幂等 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);
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(git_bash) = find_git_bash() else {
|
||||
db::log_event("mcp_inject", "warn", "git-bash 未找到,跳过旧注册清理", None);
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(conn) = db::pool().get() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let group_ids: Vec<String> = conn
|
||||
.prepare("SELECT id FROM product_groups")
|
||||
.and_then(|mut stmt| {
|
||||
stmt.query_map([], |r| r.get::<_, String>(0))
|
||||
.map(|iter| iter.filter_map(|r| r.ok()).collect())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut removed = Vec::new();
|
||||
for gid in &group_ids {
|
||||
let mut names = vec![format!("lian-jing-{}", gid)];
|
||||
if gid.len() > 8 {
|
||||
names.push(format!("lian-jing-{}", &gid[..8]));
|
||||
}
|
||||
for name in names {
|
||||
let ok = std::process::Command::new(&claude_bin)
|
||||
.args(["mcp", "remove", "-s", "user", &name])
|
||||
.env("CLAUDE_CODE_GIT_BASH_PATH", &git_bash)
|
||||
.output()
|
||||
.map(|out| out.status.success())
|
||||
.unwrap_or(false);
|
||||
if ok {
|
||||
db::log_event("mcp_inject", "info", "旧组级 MCP 注册已移除", Some(&name));
|
||||
removed.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
// ── JSON 读写 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fn read_json(path: &PathBuf) -> Result<Value, String> {
|
||||
@ -192,8 +316,9 @@ fn write_json(path: &PathBuf, value: &Value) -> Result<(), String> {
|
||||
|
||||
// ── 公开接口 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 向项目的 .claude/settings.json 注入产品组 MCP 配置
|
||||
pub fn inject_project_mcp(project_id: &str, group_id: &str) -> Result<(), String> {
|
||||
/// 向项目的 .claude/settings.json 注入单一 lian-jing MCP 配置,
|
||||
/// 并清理旧组级 key(迁移内建于重注入)
|
||||
pub fn inject_project_mcp(project_id: &str) -> Result<(), String> {
|
||||
let port = crate::mcp_server::read_port();
|
||||
let path = settings_path(project_id)?;
|
||||
let mut settings = read_json(&path)?;
|
||||
@ -208,44 +333,94 @@ pub fn inject_project_mcp(project_id: &str, group_id: &str) -> Result<(), String
|
||||
|
||||
let servers = obj.entry("mcpServers").or_insert(json!({}));
|
||||
|
||||
let key = format!("{}{}", MCP_KEY_PREFIX, group_id);
|
||||
servers[&key] = json!({
|
||||
if let Some(map) = servers.as_object_mut() {
|
||||
map.retain(|k, _| !k.starts_with(LEGACY_KEY_PREFIX));
|
||||
}
|
||||
servers[MCP_KEY] = json!({
|
||||
"type": "sse",
|
||||
"url": format!("http://127.0.0.1:{}/mcp/group/{}", port, group_id)
|
||||
"url": format!("http://127.0.0.1:{}/mcp", port)
|
||||
});
|
||||
|
||||
write_json(&path, &settings).inspect(|_| {
|
||||
let ctx = serde_json::json!({"project_id": project_id, "group_id": group_id}).to_string();
|
||||
let ctx = serde_json::json!({"project_id": project_id}).to_string();
|
||||
log_event("mcp_inject", "info", "MCP 配置注入成功", Some(&ctx));
|
||||
})?;
|
||||
|
||||
// 写炼境上下文(含 group_id),让子项目 Claude 会话无需遍历即可知道 project_id + group_id
|
||||
let _ = write_liangjing_context(project_id, group_id, port);
|
||||
// 写炼境上下文,让子项目 Claude 会话无需遍历即可知道 project_id
|
||||
let _ = write_liangjing_context(project_id, port);
|
||||
|
||||
// 同步注册到 ~/.claude.json,让 Claude Code 能加载 MCP 工具(best-effort)
|
||||
try_register_globally(group_id, port);
|
||||
try_register_globally(port);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从项目的 .claude/settings.json 移除产品组 MCP 配置
|
||||
pub fn remove_project_mcp(project_id: &str, group_id: &str) -> Result<(), String> {
|
||||
/// 从项目的 .claude/settings.json 移除炼境 MCP 配置(含旧组级 key)。
|
||||
/// 调用方需自行保证项目已不属于任何产品组时才调用。
|
||||
pub fn remove_project_mcp(project_id: &str) -> Result<(), String> {
|
||||
let path = settings_path(project_id)?;
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut settings = read_json(&path)?;
|
||||
|
||||
let key = format!("{}{}", MCP_KEY_PREFIX, group_id);
|
||||
if let Some(servers) = settings
|
||||
.get_mut("mcpServers")
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
servers.remove(&key);
|
||||
servers.remove(MCP_KEY);
|
||||
servers.retain(|k, _| !k.starts_with(LEGACY_KEY_PREFIX));
|
||||
}
|
||||
|
||||
write_json(&path, &settings)
|
||||
}
|
||||
|
||||
/// 单一服务器化一次性迁移:批量重注入所有托管项目(换新 key + 清旧 key + liangjing.json 瘦身)
|
||||
/// 并清理旧组级全局注册。settings 表 mcp_single_migrated 标记位保证只执行一次;
|
||||
/// 部分项目失败不阻塞(记日志,可对单个项目手动重注入补救)。
|
||||
pub fn migrate_to_single_server() {
|
||||
let already: Option<String> = db::pool().get().ok().and_then(|c| {
|
||||
c.query_row(
|
||||
"SELECT value FROM settings WHERE key = 'mcp_single_migrated'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok()
|
||||
});
|
||||
if already.as_deref() == Some("1") {
|
||||
return;
|
||||
}
|
||||
|
||||
let errors = reinject_all_mcp();
|
||||
let removed = cleanup_legacy_global_registrations();
|
||||
let register_ok = try_register_globally(crate::mcp_server::read_port());
|
||||
|
||||
let summary = format!(
|
||||
"单一服务器迁移完成:重注入失败 {} 个,移除旧全局注册 {} 条,全局注册 {}{}",
|
||||
errors.len(),
|
||||
removed.len(),
|
||||
if register_ok { "成功" } else { "失败" },
|
||||
if errors.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(";失败详情: {}", errors.join("; "))
|
||||
}
|
||||
);
|
||||
let level = if errors.is_empty() && register_ok { "info" } else { "warn" };
|
||||
db::log_event("mcp_inject", level, &summary, None);
|
||||
|
||||
// 全局注册失败说明 claude CLI 链路不通(CLI 缺失/git-bash 缺失/命令报错),
|
||||
// 此时旧注册也清不掉——不落标记位,等下次启动重试(整个流程幂等)
|
||||
if !register_ok {
|
||||
return;
|
||||
}
|
||||
if let Ok(conn) = db::pool().get() {
|
||||
let _ = conn.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('mcp_single_migrated', '1')",
|
||||
[],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 端口变更后重新注入所有产品组成员的 MCP 配置
|
||||
/// 返回注入失败的错误列表(部分失败不影响其他项目)
|
||||
pub fn reinject_all_mcp() -> Vec<String> {
|
||||
@ -254,21 +429,49 @@ pub fn reinject_all_mcp() -> Vec<String> {
|
||||
Err(e) => return vec![e.to_string()],
|
||||
};
|
||||
|
||||
let mappings: Vec<(String, String)> = {
|
||||
let mut stmt = match conn.prepare("SELECT project_id, group_id FROM project_group_map") {
|
||||
let project_ids: Vec<String> = {
|
||||
let mut stmt = match conn.prepare("SELECT DISTINCT project_id FROM project_group_map") {
|
||||
Ok(s) => s,
|
||||
Err(e) => return vec![e.to_string()],
|
||||
};
|
||||
stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
|
||||
stmt.query_map([], |row| row.get(0))
|
||||
.map(|iter| iter.filter_map(|r| r.ok()).collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
drop(conn);
|
||||
|
||||
let mut errors = Vec::new();
|
||||
for (project_id, group_id) in mappings {
|
||||
if let Err(e) = inject_project_mcp(&project_id, &group_id) {
|
||||
for project_id in project_ids {
|
||||
if let Err(e) = inject_project_mcp(&project_id) {
|
||||
errors.push(format!("项目 {}: {}", project_id, e));
|
||||
}
|
||||
}
|
||||
errors
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 回归:空路径登记曾使 inject 链路把 settings.json/liangjing.json 写进
|
||||
/// 炼境进程 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("src-tauri", "win_path").is_err(),
|
||||
"相对路径应拒绝(相对炼境 cwd 解析)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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());
|
||||
assert!(
|
||||
validate_registered_path("\\\\wsl.localhost\\Ubuntu\\home\\u\\p", "win_path").is_ok(),
|
||||
"WSL UNC 路径应通过"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::{
|
||||
sse::{Event, KeepAlive, Sse},
|
||||
@ -42,10 +42,10 @@ pub async fn start(port: u16) {
|
||||
|
||||
let app = Router::new()
|
||||
.route("/health", get(health))
|
||||
// Streamable HTTP(POST)— 向后兼容,Python 直调仍可用
|
||||
.route("/mcp/group/:group_id", post(handle_mcp).get(handle_sse_connect))
|
||||
// 单一全局服务器路由(lian-jing):寻址不依赖 group_id,工具显式传 project_id
|
||||
.route("/mcp", post(handle_mcp).get(handle_sse_connect))
|
||||
// SSE 消息端点:客户端通过此 URL 发送 JSON-RPC,响应经 SSE 推回
|
||||
.route("/mcp/group/:group_id/messages", post(handle_sse_message))
|
||||
.route("/mcp/messages", post(handle_sse_message))
|
||||
// Gitea webhook:接收 push/pull_request 事件写入飞轮
|
||||
.route("/webhook/gitea/:project_id", post(crate::mcp_webhook::handle_gitea_webhook))
|
||||
.with_state(sessions);
|
||||
@ -85,7 +85,7 @@ struct SessionQuery {
|
||||
|
||||
// ── 共享 RPC 处理逻辑 ─────────────────────────────────────────────────────────
|
||||
|
||||
async fn process_rpc(group_id: &str, req: &RpcRequest) -> Result<Value, String> {
|
||||
async fn process_rpc(req: &RpcRequest) -> Result<Value, String> {
|
||||
match req.method.as_str() {
|
||||
"initialize" => Ok(json!({
|
||||
"protocolVersion": "2024-11-05",
|
||||
@ -93,19 +93,19 @@ async fn process_rpc(group_id: &str, req: &RpcRequest) -> Result<Value, String>
|
||||
"serverInfo": { "name": "炼境", "version": env!("CARGO_PKG_VERSION") }
|
||||
})),
|
||||
"tools/list" => Ok(crate::mcp_tools::tools_list_result()),
|
||||
"tools/call" => Ok(crate::mcp_tools::tools_call(group_id, req.params.as_ref()).await),
|
||||
"tools/call" => Ok(crate::mcp_tools::tools_call(req.params.as_ref()).await),
|
||||
_ => Err(format!("Method not found: {}", req.method)),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Streamable HTTP(POST)─────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_mcp(Path(group_id): Path<String>, Json(req): Json<RpcRequest>) -> Response {
|
||||
async fn handle_mcp(Json(req): Json<RpcRequest>) -> Response {
|
||||
if req.id.is_none() {
|
||||
return StatusCode::ACCEPTED.into_response();
|
||||
}
|
||||
let id = req.id.clone();
|
||||
match process_rpc(&group_id, &req).await {
|
||||
match process_rpc(&req).await {
|
||||
Ok(result) => Json(json!({ "jsonrpc": "2.0", "id": id, "result": result })).into_response(),
|
||||
Err(msg) => Json(json!({
|
||||
"jsonrpc": "2.0", "id": id,
|
||||
@ -117,18 +117,17 @@ async fn handle_mcp(Path(group_id): Path<String>, Json(req): Json<RpcRequest>) -
|
||||
|
||||
// ── SSE 传输(GET + POST /messages)─────────────────────────────────────────
|
||||
|
||||
/// GET /mcp/group/:group_id — Claude Code SSE 握手,首条事件告知后续 POST URL
|
||||
/// GET /mcp — Claude Code SSE 握手,首条事件告知后续 POST URL
|
||||
async fn handle_sse_connect(
|
||||
State(sessions): State<Sessions>,
|
||||
Path(group_id): Path<String>,
|
||||
) -> Sse<impl futures_core::Stream<Item = Result<Event, Infallible>>> {
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
let (tx, rx) = mpsc::channel::<(String, String)>(32);
|
||||
|
||||
let port = read_port();
|
||||
let endpoint_uri = format!(
|
||||
"http://127.0.0.1:{}/mcp/group/{}/messages?session_id={}",
|
||||
port, group_id, session_id
|
||||
"http://127.0.0.1:{}/mcp/messages?session_id={}",
|
||||
port, session_id
|
||||
);
|
||||
let _ = tx.send(("endpoint".to_string(), endpoint_uri)).await;
|
||||
sessions.lock().await.insert(session_id, tx);
|
||||
@ -139,11 +138,35 @@ async fn handle_sse_connect(
|
||||
Sse::new(stream).keep_alive(KeepAlive::default())
|
||||
}
|
||||
|
||||
/// POST /mcp/group/:group_id/messages?session_id=xxx
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rpc(method: &str) -> RpcRequest {
|
||||
RpcRequest {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
method: method.to_string(),
|
||||
id: Some(json!(1)),
|
||||
params: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 单一 lian-jing 服务器的握手路径:initialize / tools/list 必须可用
|
||||
#[tokio::test]
|
||||
async fn process_rpc_handshake() {
|
||||
let init = process_rpc(&rpc("initialize")).await.unwrap();
|
||||
assert_eq!(init["serverInfo"]["name"], "炼境");
|
||||
|
||||
let tools = process_rpc(&rpc("tools/list")).await.unwrap();
|
||||
let list = tools["tools"].as_array().unwrap();
|
||||
assert!(list.len() >= 20, "工具清单不应因去组化而缺失,实际 {}", list.len());
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /mcp/messages?session_id=xxx
|
||||
/// 立即返回 202,后台异步处理工具调用并经 SSE channel 推回响应
|
||||
async fn handle_sse_message(
|
||||
State(sessions): State<Sessions>,
|
||||
Path(group_id): Path<String>,
|
||||
Query(query): Query<SessionQuery>,
|
||||
Json(req): Json<RpcRequest>,
|
||||
) -> Response {
|
||||
@ -151,18 +174,17 @@ async fn handle_sse_message(
|
||||
return StatusCode::ACCEPTED.into_response();
|
||||
}
|
||||
|
||||
let sessions_clone = sessions.clone();
|
||||
let session_id = query.session_id.clone();
|
||||
tokio::spawn(async move {
|
||||
let id = req.id.clone();
|
||||
let response_json = match process_rpc(&group_id, &req).await {
|
||||
let response_json = match process_rpc(&req).await {
|
||||
Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
|
||||
Err(msg) => json!({
|
||||
"jsonrpc": "2.0", "id": id,
|
||||
"error": { "code": -32601, "message": msg }
|
||||
}),
|
||||
};
|
||||
let mut guard = sessions_clone.lock().await;
|
||||
let mut guard = sessions.lock().await;
|
||||
if let Some(tx) = guard.get(&session_id) {
|
||||
if tx
|
||||
.send(("message".to_string(), response_json.to_string()))
|
||||
|
||||
@ -14,8 +14,14 @@ pub fn tools_list_result() -> Value {
|
||||
},
|
||||
{
|
||||
"name": "list_group_projects",
|
||||
"description": "列出产品组内所有成员项目,包含名称、路径、平台、技术栈、角色等信息",
|
||||
"inputSchema": { "type": "object", "properties": {}, "required": [] }
|
||||
"description": "列出产品组内所有成员项目,包含名称、路径、平台、技术栈、角色等信息(group_id 可从 list_all_projects 获取)",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"group_id": { "type": "string", "description": "产品组 ID" }
|
||||
},
|
||||
"required": ["group_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_project_blueprint",
|
||||
@ -51,6 +57,14 @@ pub fn tools_list_result() -> Value {
|
||||
"required": ["project_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_envoy_returns",
|
||||
"description": "列出所有子项目通过「炼境使者」发回炼境的待处理洞察,按项目分组展示。炼境会话启动时调用,用于检查是否有需要处置的治理反馈(模板 bug、验证有效的实践、体系改进假设)。无需任何参数。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "append_project_note",
|
||||
"description": "将 Claude 的分析结论或复盘记录写入项目笔记,供未来会话和用户在炼境中查阅。完成 M/L 复杂度任务后必须调用,写入结构化复盘笔记。",
|
||||
@ -221,18 +235,18 @@ pub fn tools_list_result() -> Value {
|
||||
},
|
||||
{
|
||||
"name": "apply_onboarding_pack",
|
||||
"description": "向项目分发标准接入包:写入 AGENTS.md / lefthook / git-workflow 模板(已存在则原位升级 ONBOARDING_MANAGED 块,绝不覆盖块外用户内容),并挂载 buff 自动监听 git 提交。等价于炼境 UI「接入包」区域的「应用接入包」按钮。模板版本升级后对存量项目重新调用即可推送新版本。",
|
||||
"description": "向项目分发标准接入包:写入 AGENTS.md / lefthook / git-workflow / envoy / blueprint-gate 模板(版本化块原位升级、全托管文件内容不一致即覆盖、lefthook 存在即跳过,块外用户内容绝不触碰),并挂载 buff 自动监听 git 提交。等价于炼境 UI「接入包」区域的「应用接入包」按钮。模板升级后对存量项目重新调用即可推送新版本;不传 project_id 则对所有已登记项目批量执行。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" }
|
||||
"project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects)。省略则批量分发到所有已登记项目" }
|
||||
},
|
||||
"required": ["project_id"]
|
||||
"required": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "verify_onboarding",
|
||||
"description": "接入生效验收:检查接入包在项目里是否真正生效(模板管理块版本到位 / lefthook hook 真挂在 .git/hooks / origin 指向已登记 Gitea / CI workflow 存在 / 近 30 天飞轮 commit 数据流入)。分发成功 ≠ 功能生效,apply_onboarding_pack 之后应调用本工具做消费者角色验收。",
|
||||
"description": "接入生效验收:检查接入包在项目里是否真正生效(模板管理块版本到位 / lefthook hook 真挂在 .git/hooks / lefthook 检查段对齐模板 / origin 指向已登记 Gitea / CI workflow 存在 / 近 30 天飞轮 commit 数据流入)。分发成功 ≠ 功能生效,apply_onboarding_pack 之后应调用本工具做消费者角色验收。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -300,7 +314,7 @@ pub fn tools_list_result() -> Value {
|
||||
|
||||
// ── 工具分发 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
|
||||
pub async fn tools_call(params: Option<&Value>) -> Value {
|
||||
let tool_name = params
|
||||
.and_then(|p| p.get("name"))
|
||||
.and_then(|n| n.as_str())
|
||||
@ -310,18 +324,26 @@ pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
|
||||
.and_then(|p| p.get("arguments"))
|
||||
.cloned()
|
||||
.unwrap_or(json!({}));
|
||||
let gid = group_id.to_string();
|
||||
|
||||
let outcome = tokio::task::spawn_blocking(move || match tool_name.as_str() {
|
||||
"get_diagnostics" => get_diagnostics(),
|
||||
"list_group_projects" => list_group_projects(&gid),
|
||||
"list_group_projects" => {
|
||||
let group_id = args
|
||||
.get("group_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty());
|
||||
match group_id {
|
||||
Some(g) => list_group_projects(g),
|
||||
None => Err("需显式传 group_id 参数(组 ID 可从 list_all_projects 获取)".to_string()),
|
||||
}
|
||||
}
|
||||
"get_project_blueprint" => {
|
||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
get_project_blueprint(&gid, pid)
|
||||
get_project_blueprint(pid)
|
||||
}
|
||||
"get_blueprint_status" => {
|
||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
resolve_project_root(&gid, pid).and_then(|root| {
|
||||
resolve_project_root(pid).and_then(|root| {
|
||||
crate::commands::blueprint::get_blueprint_status(root.to_string_lossy().to_string())
|
||||
.and_then(|s| serde_json::to_string_pretty(&s).map_err(|e| e.to_string()))
|
||||
})
|
||||
@ -332,7 +354,7 @@ pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
|
||||
if mode != "init" && mode != "sync" {
|
||||
Err(format!("mode 只允许 init 或 sync,收到: {mode}"))
|
||||
} else {
|
||||
resolve_project_root(&gid, pid).and_then(|root| {
|
||||
resolve_project_root(pid).and_then(|root| {
|
||||
crate::commands::blueprint::generate_blueprint_prompt(
|
||||
root.to_string_lossy().to_string(),
|
||||
mode.to_string(),
|
||||
@ -342,7 +364,7 @@ pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
|
||||
}
|
||||
"sync_blueprint_rules" => {
|
||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
resolve_project_root(&gid, pid).and_then(|root| {
|
||||
resolve_project_root(pid).and_then(|root| {
|
||||
crate::commands::blueprint::sync_blueprint_rules(root.to_string_lossy().to_string())
|
||||
.and_then(|r| serde_json::to_string_pretty(&r).map_err(|e| e.to_string()))
|
||||
})
|
||||
@ -350,21 +372,24 @@ pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
|
||||
"get_project_file" => {
|
||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let rel = args.get("relative_path").and_then(|v| v.as_str()).unwrap_or("");
|
||||
get_project_file(&gid, pid, rel)
|
||||
get_project_file(pid, rel)
|
||||
}
|
||||
"get_project_mentor_context" => {
|
||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
get_project_mentor_context(&gid, pid)
|
||||
get_project_mentor_context(pid)
|
||||
}
|
||||
"list_envoy_returns" => {
|
||||
list_envoy_returns()
|
||||
}
|
||||
"append_project_note" => {
|
||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let content = args.get("content").and_then(|v| v.as_str()).unwrap_or("");
|
||||
append_project_note(&gid, pid, content)
|
||||
append_project_note(pid, content)
|
||||
}
|
||||
"get_project_snapshots" => {
|
||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let days = args.get("days").and_then(|v| v.as_u64()).unwrap_or(30) as u32;
|
||||
get_project_snapshots(&gid, pid, days)
|
||||
get_project_snapshots(pid, days)
|
||||
}
|
||||
"create_pull_request" => {
|
||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
@ -391,17 +416,8 @@ pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
|
||||
}
|
||||
"inject_mcp" => {
|
||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||
let group_id: String = conn
|
||||
.query_row(
|
||||
"SELECT group_id FROM project_group_map WHERE project_id = ?1 LIMIT 1",
|
||||
rusqlite::params![pid],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.map_err(|_| format!("项目 {} 未加入任何产品组", pid))?;
|
||||
drop(conn);
|
||||
crate::mcp_inject::inject_project_mcp(pid, &group_id)
|
||||
.map(|_| format!("MCP 注入成功:project_id={} group_id={}", pid, group_id))
|
||||
crate::mcp_inject::inject_project_mcp(pid)
|
||||
.map(|_| format!("MCP 注入成功:project_id={}(单一 lian-jing 服务器,无需 group_id)", pid))
|
||||
}
|
||||
"get_ci_status" => {
|
||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
@ -550,7 +566,10 @@ pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
|
||||
) {
|
||||
Ok(r) => {
|
||||
ok += 1;
|
||||
lines.push(format!("- {}: 写入 {} 条,跳过 {} 条", id, r.imported, r.skipped));
|
||||
lines.push(format!(
|
||||
"- {}: 写入 {} 条,跳过 {} 条,清洗 {} 条无效规则ID",
|
||||
id, r.imported, r.skipped, r.sanitized
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
@ -588,9 +607,66 @@ pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
|
||||
let private = args.get("private").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
crate::commands::gitea::gitea_api_setup_repo(pid, repo_name, private)
|
||||
}
|
||||
// U1 批量模式:project_id 字段缺失(而非空串——空串按无效单项目报错)→ 全部已登记项目
|
||||
"apply_onboarding_pack" if args.get("project_id").and_then(|v| v.as_str()).is_none() => {
|
||||
// 复用 ingest_git_history 先例;替代曾经的 batch_apply.py 临时脚本
|
||||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||
let targets: Vec<(String, String)> = {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, win_path FROM project_workspaces WHERE win_path IS NOT NULL AND TRIM(win_path) != ''")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
rows
|
||||
};
|
||||
drop(conn);
|
||||
if targets.is_empty() {
|
||||
Err("无已登记的 Windows 路径项目可分发".to_string())
|
||||
} else {
|
||||
let mut lines = Vec::new();
|
||||
let (mut ok, mut failed) = (0u32, 0u32);
|
||||
for (id, path) in &targets {
|
||||
match crate::commands::onboarding::apply_onboarding_pack(
|
||||
path.clone(),
|
||||
id.clone(),
|
||||
) {
|
||||
Ok(report) => {
|
||||
ok += 1;
|
||||
let summary: Vec<String> = report
|
||||
.templates
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let short = t.name.rsplit('/').next().unwrap_or(&t.name);
|
||||
format!("{short}={}", t.action)
|
||||
})
|
||||
.collect();
|
||||
lines.push(format!(
|
||||
"- {}: {} | hook={}",
|
||||
id,
|
||||
summary.join(", "),
|
||||
report.hook.status
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
lines.push(format!("- {}: ❌ {}", id, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(format!(
|
||||
"批量接入包分发完成:{} 成功,{} 失败\n\n{}\n\n建议对关键项目调用 verify_onboarding 抽查生效。",
|
||||
ok,
|
||||
failed,
|
||||
lines.join("\n")
|
||||
))
|
||||
}
|
||||
}
|
||||
"apply_onboarding_pack" => {
|
||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
resolve_project_root(&gid, pid).and_then(|root| {
|
||||
resolve_project_root(pid).and_then(|root| {
|
||||
let path = root.to_string_lossy().to_string();
|
||||
crate::commands::onboarding::apply_onboarding_pack(path, pid.to_string()).map(
|
||||
|report| {
|
||||
@ -628,7 +704,7 @@ pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
|
||||
}
|
||||
"verify_onboarding" => {
|
||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let root = resolve_project_root(&gid, pid)?;
|
||||
let root = resolve_project_root(pid)?;
|
||||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||
let checks = crate::commands::onboarding::verify_onboarding(&conn, pid, &root);
|
||||
let passed = checks.iter().filter(|c| c.pass).count();
|
||||
@ -733,6 +809,20 @@ fn get_diagnostics() -> Result<String, String> {
|
||||
}
|
||||
}
|
||||
|
||||
out.push_str("\n### 使者回传覆盖率(近 30 天)\n\n");
|
||||
match crate::commands::onboarding::silent_projects(&conn) {
|
||||
Ok(list) if list.is_empty() => {
|
||||
out.push_str("_无沉默项目:有 commit 活动的已接入项目均有使者回传(或近期无活动)_ ✅\n");
|
||||
}
|
||||
Ok(list) => {
|
||||
out.push_str("以下项目近 30 天有 commit 活动但 **0 条使者回传**——环的回流段在这些项目上是断的,考虑去该项目会话跑一次 `/envoy`:\n\n");
|
||||
for (name, commits) in &list {
|
||||
out.push_str(&format!("- **{}** — {} 条 commit,0 回传\n", name, commits));
|
||||
}
|
||||
}
|
||||
Err(e) => out.push_str(&format!("_查询失败: {e}_\n")),
|
||||
}
|
||||
|
||||
out.push_str("\n### 系统健康\n\n");
|
||||
let report = crate::commands::settings::collect_health_report();
|
||||
out.push_str(&format!("检查时间:{}\n\n", report.checked_at));
|
||||
@ -817,35 +907,33 @@ fn list_group_projects(group_id: &str) -> Result<String, String> {
|
||||
Ok(format!("产品组成员(共 {} 个项目):\n\n{}", rows.len(), rows.join("\n\n")))
|
||||
}
|
||||
|
||||
fn get_project_blueprint(group_id: &str, project_id: &str) -> Result<String, String> {
|
||||
let root = resolve_project_root(group_id, project_id)?;
|
||||
fn get_project_blueprint(project_id: &str) -> Result<String, String> {
|
||||
let root = resolve_project_root(project_id)?;
|
||||
let path = root.join(".blueprint").join("manifest.yaml");
|
||||
std::fs::read_to_string(&path)
|
||||
.map_err(|e| format!("读取蓝图失败({}): {}", path.display(), e))
|
||||
}
|
||||
|
||||
fn get_project_file(group_id: &str, project_id: &str, relative_path: &str) -> Result<String, String> {
|
||||
fn get_project_file(project_id: &str, relative_path: &str) -> Result<String, String> {
|
||||
if relative_path.contains("..") {
|
||||
return Err("路径不允许包含 \"..\"".to_string());
|
||||
}
|
||||
let root = resolve_project_root(group_id, project_id)?;
|
||||
let root = resolve_project_root(project_id)?;
|
||||
let path = root.join(relative_path);
|
||||
std::fs::read_to_string(&path)
|
||||
.map_err(|e| format!("读取文件失败({}): {}", path.display(), e))
|
||||
}
|
||||
|
||||
pub fn resolve_project_root(group_id: &str, project_id: &str) -> Result<std::path::PathBuf, String> {
|
||||
/// 按 project_id 直查项目根目录(单一服务器寻址,不做组归属过滤)
|
||||
pub fn resolve_project_root(project_id: &str) -> Result<std::path::PathBuf, String> {
|
||||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||
let row: rusqlite::Result<(Option<String>, Option<String>, Option<String>)> = conn.query_row(
|
||||
"SELECT pw.win_path, pw.wsl_path, pw.platform
|
||||
FROM project_group_map pgm
|
||||
JOIN project_workspaces pw ON pw.id = pgm.project_id
|
||||
WHERE pgm.group_id = ?1 AND pgm.project_id = ?2",
|
||||
[group_id, project_id],
|
||||
"SELECT win_path, wsl_path, platform FROM project_workspaces WHERE id = ?1",
|
||||
[project_id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
);
|
||||
let (win_path, wsl_path, platform) = row
|
||||
.map_err(|_| format!("项目 {} 不属于产品组 {},或项目不存在", project_id, group_id))?;
|
||||
.map_err(|_| format!("项目 {} 不存在", project_id))?;
|
||||
|
||||
if platform.as_deref() == Some("wsl") {
|
||||
let wsl = wsl_path.ok_or_else(|| "WSL 项目未配置 wsl_path".to_string())?;
|
||||
@ -860,40 +948,76 @@ pub fn resolve_project_root(group_id: &str, project_id: &str) -> Result<std::pat
|
||||
}
|
||||
}
|
||||
|
||||
fn get_project_mentor_context(group_id: &str, project_id: &str) -> Result<String, String> {
|
||||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||
/// 项目存在性检查(替代旧的组归属校验——单一服务器寻址后组不再是访问边界)
|
||||
fn ensure_project_exists(conn: &rusqlite::Connection, project_id: &str) -> Result<(), String> {
|
||||
let exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM project_group_map WHERE group_id = ?1 AND project_id = ?2",
|
||||
[group_id, project_id],
|
||||
"SELECT COUNT(*) FROM project_workspaces WHERE id = ?1",
|
||||
[project_id],
|
||||
|r| r.get::<_, i64>(0),
|
||||
)
|
||||
.map(|n| n > 0)
|
||||
.unwrap_or(false);
|
||||
if !exists {
|
||||
return Err(format!("项目 {} 不属于产品组 {},或项目不存在", project_id, group_id));
|
||||
return Err(format!("项目 {} 不存在", project_id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_project_mentor_context(project_id: &str) -> Result<String, String> {
|
||||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||
ensure_project_exists(&conn, project_id)?;
|
||||
let ctx = crate::commands::mentor::get_mentor_context(project_id.to_string())
|
||||
.map_err(|e| format!("获取导师上下文失败: {e}"))?;
|
||||
Ok(ctx.mentor_markdown)
|
||||
}
|
||||
|
||||
fn append_project_note(group_id: &str, project_id: &str, content: &str) -> Result<String, String> {
|
||||
fn list_envoy_returns() -> Result<String, String> {
|
||||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT pn.id, pp.name, pn.content, pn.created_at
|
||||
FROM project_notes pn
|
||||
JOIN project_workspaces pw ON pn.project_id = pw.id
|
||||
JOIN project_profiles pp ON pw.profile_id = pp.id
|
||||
WHERE pn.content LIKE '【炼境使者回传】%'
|
||||
ORDER BY pn.created_at DESC
|
||||
LIMIT 50",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let rows: Vec<(i64, String, String, String)> = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
if rows.is_empty() {
|
||||
return Ok("📭 暂无待处理的炼境使者回传".to_string());
|
||||
}
|
||||
|
||||
let mut out = format!("📬 炼境使者回传(共 {} 条)\n\n", rows.len());
|
||||
for (_id, project_name, content, created_at) in &rows {
|
||||
let date = &created_at[..created_at.len().min(10)];
|
||||
out.push_str(&format!("---\n**来源**: {} | **时间**: {}\n{}\n\n", project_name, date, content));
|
||||
}
|
||||
out.push_str("---\n💡 处置参考:fix_template → 改接入包模板文件;update_convention → 改 CONVENTIONS.md;log_for_review → 记录待观察,暂不动模板");
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn append_project_note(project_id: &str, content: &str) -> Result<String, String> {
|
||||
if content.trim().is_empty() {
|
||||
return Err("笔记内容不能为空".to_string());
|
||||
}
|
||||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||
let exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM project_group_map WHERE group_id = ?1 AND project_id = ?2",
|
||||
[group_id, project_id],
|
||||
|r| r.get::<_, i64>(0),
|
||||
)
|
||||
.map(|n| n > 0)
|
||||
.unwrap_or(false);
|
||||
if !exists {
|
||||
return Err(format!("项目 {} 不属于产品组 {},或项目不存在", project_id, group_id));
|
||||
}
|
||||
ensure_project_exists(&conn, project_id)?;
|
||||
conn.execute(
|
||||
"INSERT INTO project_notes (project_id, source, content) VALUES (?1, 'mcp', ?2)",
|
||||
rusqlite::params![project_id, content],
|
||||
@ -902,19 +1026,9 @@ fn append_project_note(group_id: &str, project_id: &str, content: &str) -> Resul
|
||||
Ok("笔记已保存 ✅".to_string())
|
||||
}
|
||||
|
||||
fn get_project_snapshots(group_id: &str, project_id: &str, days: u32) -> Result<String, String> {
|
||||
fn get_project_snapshots(project_id: &str, days: u32) -> Result<String, String> {
|
||||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||
let exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM project_group_map WHERE group_id = ?1 AND project_id = ?2",
|
||||
[group_id, project_id],
|
||||
|r| r.get::<_, i64>(0),
|
||||
)
|
||||
.map(|n| n > 0)
|
||||
.unwrap_or(false);
|
||||
if !exists {
|
||||
return Err(format!("项目 {} 不属于产品组 {},或项目不存在", project_id, group_id));
|
||||
}
|
||||
ensure_project_exists(&conn, project_id)?;
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
@ -1181,11 +1295,20 @@ mod tests {
|
||||
fn apply_onboarding_pack_schema_requires_project_id() {
|
||||
let result = tools_list_result();
|
||||
let tools = result["tools"].as_array().unwrap();
|
||||
for name in ["apply_onboarding_pack", "verify_onboarding"] {
|
||||
let tool = tools.iter().find(|t| t["name"] == name).unwrap();
|
||||
let required = tool["inputSchema"]["required"].as_array().unwrap();
|
||||
let fields: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
|
||||
assert!(fields.contains(&"project_id"), "{name} 应必填 project_id");
|
||||
}
|
||||
// verify 必填 project_id;apply 自 U1 起可省略(批量模式)
|
||||
let verify = tools.iter().find(|t| t["name"] == "verify_onboarding").unwrap();
|
||||
let fields: Vec<&str> = verify["inputSchema"]["required"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.collect();
|
||||
assert!(fields.contains(&"project_id"), "verify_onboarding 应必填 project_id");
|
||||
|
||||
let apply = tools.iter().find(|t| t["name"] == "apply_onboarding_pack").unwrap();
|
||||
assert!(
|
||||
apply["inputSchema"]["required"].as_array().unwrap().is_empty(),
|
||||
"apply_onboarding_pack 的 project_id 应可省略(U1 批量模式)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -133,14 +133,18 @@ async fn handle_push(project_id: &str, body: &Bytes) -> Response {
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
// U3:按 project_id 找到本地路径加载规则 ID 集合,剔除 trailer 里的未知 ID
|
||||
let valid_rules =
|
||||
crate::commands::commit_metrics::load_rule_ids_by_project_id(&conn, project_id);
|
||||
let mut received = 0;
|
||||
for c in &commits {
|
||||
if crate::commands::commit_metrics::record_commit(
|
||||
if crate::commands::commit_metrics::record_commit_validated(
|
||||
&conn,
|
||||
project_id,
|
||||
&c.sha,
|
||||
&c.message,
|
||||
c.timestamp.as_deref(),
|
||||
valid_rules.as_ref(),
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
|
||||
5
src-tauri/tauri.local.conf.json
Normal file
5
src-tauri/tauri.local.conf.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"bundle": {
|
||||
"createUpdaterArtifacts": false
|
||||
}
|
||||
}
|
||||
@ -112,17 +112,21 @@ export function FlywheelPanel() {
|
||||
setIngestSummary(null);
|
||||
let totalImported = 0;
|
||||
let totalSkipped = 0;
|
||||
let totalSanitized = 0;
|
||||
for (const { id, path } of projectsWithPath) {
|
||||
try {
|
||||
const r = await ingestFullGitHistory(id, path);
|
||||
totalImported += r.imported;
|
||||
totalSkipped += r.skipped;
|
||||
totalSanitized += r.sanitized;
|
||||
} catch {
|
||||
// 单个项目失败不中断其他项目
|
||||
}
|
||||
}
|
||||
await refetchHealth();
|
||||
setIngestSummary(`新导入 ${totalImported} 条,跳过 ${totalSkipped} 条`);
|
||||
setIngestSummary(
|
||||
`新导入 ${totalImported} 条,跳过 ${totalSkipped} 条${totalSanitized > 0 ? `,清洗 ${totalSanitized} 条无效规则ID` : ""}`,
|
||||
);
|
||||
setIngesting(false);
|
||||
};
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ import { PriorityBadge, StatusBadge } from "../ui/Badge";
|
||||
import { Dialog } from "../ui/Dialog";
|
||||
import { TemplateEditor } from "./TemplateEditor";
|
||||
import { NewProjectModal } from "./NewProjectModal";
|
||||
import { renderTechStack } from "./techStackUtils";
|
||||
import { TagsManager } from "./TagsManager";
|
||||
import { RegistryTab } from "../dashboard/RepoRegistryModal";
|
||||
|
||||
@ -849,15 +850,7 @@ function TemplatesTab({ spaceId: _spaceId }: { spaceId?: string }) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{t.techStack && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{t.techStack.split(",").map((tag) => (
|
||||
<span key={tag} className="text-[10px] px-1.5 py-0.5 rounded-full bg-indigo-50 text-indigo-500">
|
||||
{tag.trim()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{t.techStack && renderTechStack(t.techStack)}
|
||||
{t.initCommands && (
|
||||
<code className="text-[11px] text-gray-400 font-mono">
|
||||
$ {t.initCommands.split("\n")[0]}
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
listTemplates, createProjectFromTemplate, TemplateVariant,
|
||||
} from "../../lib/commands";
|
||||
import { useUIStore } from "../../store/ui";
|
||||
import { renderTechStack } from "./techStackUtils";
|
||||
|
||||
interface Props {
|
||||
spaceId?: string;
|
||||
@ -286,15 +287,7 @@ export function NewProjectModal({ spaceId, onClose }: Props) {
|
||||
{t.description && (
|
||||
<p className="text-xs text-gray-400 mt-0.5">{t.description}</p>
|
||||
)}
|
||||
{t.techStack && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{t.techStack.split(",").map((tag) => (
|
||||
<span key={tag} className="text-[10px] px-1.5 py-0.5 rounded-full bg-indigo-50 text-indigo-500">
|
||||
{tag.trim()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{t.techStack && <div className="mt-1.5">{renderTechStack(t.techStack)}</div>}
|
||||
{variants.length === 0 && t.initCommands && (
|
||||
<p className="text-[11px] text-gray-300 mt-1 font-mono">$ {t.initCommands.split("\n")[0]}</p>
|
||||
)}
|
||||
|
||||
@ -149,13 +149,13 @@ export function TemplateEditor({ templateId, onClose }: Props) {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">技术栈标签(逗号分隔)</label>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">技术栈标签</label>
|
||||
<input
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-200"
|
||||
value={techStack}
|
||||
onChange={(e) => setTechStack(e.target.value)}
|
||||
disabled={isBuiltin}
|
||||
placeholder="Node,pnpm,TypeScript"
|
||||
placeholder='{"客户端":["Web"],"语言":["TypeScript"],"框架/工具":["React","Vite"]}'
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
|
||||
41
src/components/manage/techStackUtils.tsx
Normal file
41
src/components/manage/techStackUtils.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
const TECH_STACK_COLORS: Record<string, string> = {
|
||||
"体系": "bg-orange-50 text-orange-500",
|
||||
"客户端": "bg-green-50 text-green-600",
|
||||
"语言": "bg-blue-50 text-blue-600",
|
||||
"框架/工具": "bg-indigo-50 text-indigo-500",
|
||||
};
|
||||
|
||||
export function renderTechStack(techStack: string) {
|
||||
try {
|
||||
const parsed = JSON.parse(techStack) as Record<string, string[]>;
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{Object.entries(parsed).map(([cat, tags]) =>
|
||||
tags.length > 0 ? (
|
||||
<div key={cat} className="flex flex-wrap items-center gap-1">
|
||||
<span className="text-[9px] text-gray-400 mr-0.5">{cat}</span>
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded-full ${TECH_STACK_COLORS[cat] ?? "bg-indigo-50 text-indigo-500"}`}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} catch {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{techStack.split(",").map((tag) => (
|
||||
<span key={tag} className="text-[10px] px-1.5 py-0.5 rounded-full bg-indigo-50 text-indigo-500">
|
||||
{tag.trim()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -532,9 +532,9 @@ export function SettingsPage() {
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1.5">
|
||||
产品组 MCP 地址:
|
||||
MCP 地址(单一全局服务器):
|
||||
<code className="font-mono bg-gray-50 px-1 rounded">
|
||||
http://localhost:{mcpPort}/mcp/group/<group_id>
|
||||
http://localhost:{mcpPort}/mcp
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -614,6 +614,7 @@ export interface ProjectTemplate {
|
||||
name: string;
|
||||
description?: string;
|
||||
platform: string; // "win" | "wsl" | "both"
|
||||
/** 逗号字符串(旧格式)或分类 JSON `{"客户端":[],"语言":[],"框架/工具":[]}` */
|
||||
techStack?: string;
|
||||
initCommands?: string;
|
||||
isBuiltin: boolean;
|
||||
@ -1580,6 +1581,8 @@ export const getCommitStats = (projectId: string) =>
|
||||
export interface IngestResult {
|
||||
imported: number;
|
||||
skipped: number;
|
||||
/** 存量行中被清洗掉未知规则 ID 的行数(U3;项目无 CONVENTIONS.md 时为 0) */
|
||||
sanitized: number;
|
||||
}
|
||||
|
||||
export const ingestFullGitHistory = (projectId: string, projectPath: string) =>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user