Compare commits

...

40 Commits

Author SHA1 Message Date
lanrtop
c12e8df0ae feat(templates): 知识库模板 AGENTS.md 增补 Commit 层级信号协定
All checks were successful
Push & PR Check / check (push) Successful in 53s
docs(dialogue|concept|core|case) scope 约定:外部观测者(炼境飞轮)凭
commit 流即可读出知识库晋升频率与环的健康度,无需理解库内部结构。

Feature-Confirmed: true
2026-07-18 10:48:55 +09:00
lanrtop
3d9336d908 feat(templates): 三端体系内置模板 + 技术栈分类标签渲染
上一会话遗留的完整功能补提交:
- 新增 4 个三端体系内置模板:React Web / React + Capacitor /
  React + Tauri (Antd) / React Tri-Platform(同一架构按需起步)
- tech_stack 升级为分类 JSON 格式(客户端/语言/框架),
  新增 techStackUtils.renderTechStack 兼容旧逗号格式,
  ManagePage / NewProjectModal 接线,TemplateEditor 占位符同步

Feature-Confirmed: true
2026-07-18 10:48:27 +09:00
lanrtop
1bc0629827 chore(blueprint): 知识库模板任务卡收卡——agent_verify 已过,human_verify 待实测
All checks were successful
Push & PR Check / check (push) Successful in 54s
2026-07-18 10:26:07 +09:00
lanrtop
55ee5a483c feat(templates): 新增内置模板「知识库(择升环)」
三层晋升结构的知识沉淀文档库初始化:dialogues 对话原料 → concepts 概念卡
→ CORE.md 稳定核心(上限 300 行强制过滤),cases/ 为检验层。
晋升/降级规则与写作纪律内置于模板 AGENTS.md,agent 进库即按择升环工作流运转;
init_commands 为 git init,建库即版本化。

Feature-Confirmed: true
2026-07-18 10:25:01 +09:00
lanrtop
cbc1d46c4e fix(mcp-inject): inject 链路空路径单点防护——补齐 07-15 空路径事故第二入口
All checks were successful
Push & PR Check / check (push) Successful in 52s
辩证收缩实战产出:清理污染文件时追问「删文件是不是正确动作层」,
发现 473cf7e 只拦了 apply 链路,inject 链路 project_root() 对空串
win_path 穿透(ok_or_else 拦 NULL 不拦空串),settings.json/
liangjing.json 正是经此写进 src-tauri/。

- validate_registered_path 单点校验:拒空串/空白/相对路径(顺带堵
  相对路径解析到 cwd 的边角),WSL 分支拒空 wsl_path
- 2 条回归测试驻军(纯函数无 DB,cargo test 105 全绿)
- 台账同步:污染文件已移出、两条空路径僵尸登记已注销

Rules-Applied: R06,R07
2026-07-18 10:24:03 +09:00
lanrtop
d15d3895d7 docs(conventions): v1.12.0——动工前辩证收缩进约定 + 使者回传处置台账
All checks were successful
Push & PR Check / check (push) Successful in 1m24s
- CONVENTIONS 飞轮工作流阶段 2 末新增「动工前辩证收缩」(M/L 卡必做),
  附 07-15 实证:3 张初稿卡直做会错 2/3,辩证后 U3/B4 零返工
- governance-feedback-loop 落 23 条使者回传处置台账:本轮 3 项落地
  (/ship 铁律与规模闸修正、MCP 重试说明、envoy 硬门槛)、
  11 条核实此前已落地、5 项 log_for_review 待观察、2 个遗留尾巴
2026-07-18 09:43:57 +09:00
lanrtop
f0e5b70025 fix(onboarding/envoy): 使者分类加「非显而易见」前置硬门槛
常识性设计知识与触发条件过窄的 corner case 一律留本项目,
堵住 enterprise-system 2 例误回传的根因(07-18 使者回传 confirmed)。
顺带补 skill 描述行便于命令发现。
2026-07-18 09:43:41 +09:00
lanrtop
b01f1d1d81 fix(agent-infra): 修复框架扫描漏报 Tauri+React 项目的两个 bug
Bug 1:Tauri 检测条件过严
- 原条件要求 src-tauri/src/commands/ 目录存在才能识别 Tauri
- 新项目初始化时只有 lib.rs + main.rs,commands/ 未创建,导致整个 Tauri 检测跳过
- 修复:只检查 Cargo.toml 含 tauri 依赖,commands/ 用 confidence 反映是否已建

Bug 2:detect_contracts() 无前端框架检测
- Node.js 框架段只覆盖后端(Drizzle/Prisma/Hono/Express/Fastify/tRPC)
- 新增 React / Vite / Zustand 三个检测分支
2026-07-17 16:50:51 +09:00
lanrtop
0516de5304 chore(self-onboarding): 接收 v1.6.1/v1.5.1 块升级——托管块警示行落地炼境自身
All checks were successful
Push & PR Check / check (push) Successful in 48s
2026-07-15 23:21:00 +09:00
lanrtop
5b0b068148 fix(onboarding/tmpl): 托管块首加「勿在块内定制」警示——AGENTS v1.6.1 / git-workflow v1.5.1
All checks were successful
Push & PR Check / check (push) Successful in 49s
使者回传兑现(confirmed·all_projects):炼境自身因块内写定制被
升级抹掉 5 段内容,模板块首此前无任何警示。警示行放块内随版本
升级触达全部存量项目。

Rules-Applied: R07
2026-07-15 23:18:16 +09:00
lanrtop
ade767894f docs(blueprint): 补 U1 复盘笔记——实调双坑与辩证收缩的返工率对照
All checks were successful
Push & PR Check / check (push) Successful in 52s
2026-07-15 23:05:42 +09:00
lanrtop
f7ab6dbbb8 chore(self-onboarding): 炼境自身接入产物治理——块外恢复被抹定制 + lefthook 检查段补齐
All checks were successful
Push & PR Check / check (push) Successful in 50s
批量分发实调反噬实锤:分支策略表/Agent 按钮清单/降级约定/启动协议
第 4 条曾写在 MANAGED 托管块内,v1.6.0/1.11.0 块升级原位覆盖时被抹。
全部恢复至块外「炼境自身约定」区,教训入决策记录(本地定制严禁写
进托管块)。顺带:块外与块内重复的通用约束去重(单一事实来源)、
lefthook 补齐 blueprint-gate/conventional/feature-confirmed 检查段
(verify_onboarding 复验 7/7)、收编 envoy/blueprint-gate 接入产物。

Rules-Applied: R13
2026-07-15 22:19:16 +09:00
lanrtop
7cfbfb1b86 chore(blueprint): onboarding-pack 模块收官——U1/U3/B4 实调终验通过
All checks were successful
Push & PR Check / check (push) Successful in 51s
批量分发 25 项目 0 失败(envoy/gate 全托管升级生效)、ingest 清洗
链路贯通、verify 七项链准确检出 lefthook 缺失键。模块 status→done。
2026-07-15 19:53:49 +09:00
lanrtop
473cf7e92b fix(onboarding/dispatch): 空路径登记防护——拒绝分发而非写进进程 cwd
批量实调实锤:两条空路径登记(银龙集团应用/agent编排界面)使
Path::new("") 变相对路径,接入包整套写进了 dev 进程的 src-tauri/。
apply_onboarding_pack 入口拦截空/不存在路径(单点防护),批量 SQL
同步过滤空串,回归测试驻军。

Rules-Applied: R06,R07
2026-07-15 19:53:47 +09:00
21f1b60aea Merge pull request 'feat(onboarding/upgrade-channel): 全托管文件升级通道 + 批量重分发(U1)' (#4) from feat/onboarding-pack-u1 into master
All checks were successful
Push & PR Check / check (push) Successful in 1m12s
2026-07-15 18:48:38 +08:00
lanrtop
1672b071e4 feat(onboarding/upgrade-channel): 全托管文件升级通道 + 批量重分发(U1)
All checks were successful
Push & PR Check / check (push) Successful in 51s
Push & PR Check / check (pull_request) Successful in 51s
三档升级语义落地:envoy.md/blueprint-gate.cjs 切全托管
(内容不一致即覆盖,CRLF 归一化防抖动),文件头加托管声明;
verify_onboarding 第 7 项「lefthook 检查段齐全」从内嵌模板自省
键集合,零硬编码;MCP apply_onboarding_pack 缺省 project_id
时批量分发全部已登记项目,替代 batch_apply.py 手摇脚本。

Rules-Applied: R06,R07

Feature-Confirmed: true
2026-07-15 14:20:22 +09:00
lanrtop
2fe576b75a feat(mcp/diagnostics): get_diagnostics 新增使者回传覆盖率「沉默项目」信号(B4)
All checks were successful
Push & PR Check / check (push) Successful in 49s
近 30 天有 commit 采集但零使者回传的已登记项目浮出水面,
提示去该项目跑 /envoy——回流触发不自动化(辩证决策 2026-07-15),
用可观测性让遗忘可见,人留在过滤环里。

Rules-Applied: R06,R07
2026-07-15 14:10:28 +09:00
lanrtop
aaf3343133 feat(flywheel/rules-validation): Rules-Applied 规则 ID ingest 层真实性校验(U3)
All checks were successful
Push & PR Check / check (push) Successful in 1m17s
校验单点放统计消费端(R12 边界单点):extract_rule_ids 从项目
CONVENTIONS.md 提取合法 ID 集合,filter_valid_rules 在入库前剔除
幻觉/typo ID(无 CONVENTIONS.md 时 fail-open 不校验)。接入全部
4 条写入链路(buff 增量/webhook/gitea 轮询/全量导入);
sanitize_rules_applied 提供回溯清洗,重跑全量导入即可清历史污染。

Rules-Applied: R01,R06,R07
2026-07-15 14:05:13 +09:00
lanrtop
f04eb6e766 feat(onboarding): 接入包 v1.6.0——蓝图机器门控双层实现
A: feat: 提交须含 Feature-Confirmed: true trailer
  - lefthook.yml.tmpl commit-msg 新增 feature-confirmed 命令
  - AGENTS.md.tmpl 需求对齐协议补 trailer 说明

B: pre-commit 检查未关闭 🔵 任务卡(warn only)
  - 新增 .claude/hooks/blueprint-gate.cjs(CommonJS,兼容 ESM 项目)
  - lefthook.yml.tmpl pre-commit 新增 blueprint-gate 命令
  - onboarding.rs dispatch 新增 blueprint-gate.cjs

Feature-Confirmed: true
2026-07-15 13:38:32 +09:00
lanrtop
be2f69578a docs(conventions): v1.11.0——同步使者回传的四项治理修复
- Session 结束汇总加限定语,不得作为跳过确认卡的通道
- acceptance 格式补 agent_verify/human_verify 分层说明
- 阶段2末尾引用 AGENTS.md 需求对齐协议(Bug Fix/Feature 分类)
- Rust/Tauri 专属章节顶部加 scope 前置提示,避免污染 web 项目
- lefthook.yml.tmpl:API typecheck 注释由警告升级为 REQUIRED 措辞
2026-07-15 12:16:31 +09:00
lanrtop
8c746cf8d5 fix(onboarding): git-workflow v1.5.0——修正 CONVENTIONS.md 死链引用,改为通用路径表述
All checks were successful
Push & PR Check / check (push) Successful in 1m35s
2026-07-09 16:14:55 +09:00
lanrtop
67bdacbf2e feat(onboarding): AGENTS.md v1.5.0——新增需求对齐协议(Bug Fix/Feature 分类 + S 级轻量卡 + 汇总补录限定语) 2026-07-09 16:12:47 +09:00
lanrtop
85d488e831 chore(claude): 会话启动协议补 list_envoy_returns 检查子项目使者回传 2026-07-09 16:10:08 +09:00
lanrtop
ce3ef6e14e feat(onboarding): 接入包模板 AGENTS.md v1.4.0——acceptance 分层/Playwright 版本锁定/API typecheck 提示/envoy 命令纳入分发 2026-07-09 16:09:55 +09:00
lanrtop
92da0fda0d fix(mcp): list_envoy_returns SQL 修复——pw.name 改为 JOIN project_profiles pp 取 pp.name 2026-07-09 16:09:38 +09:00
lanrtop
fd869ee161 docs(rules): onboarding-sync 补版本升级纪律——改模板必须升 MANAGED 块版本号
All checks were successful
Push & PR Check / check (push) Successful in 51s
2026-07-06 实测踩坑:内容改了版本没动,批量分发 12 项目全部 skipped。
同时注明分发前需重建安装(模板打包在二进制资源里)。
2026-07-06 23:19:25 +09:00
lanrtop
1c44bfe30a test(onboarding): 版本号从模板动态取,消除硬编码
All checks were successful
Push & PR Check / check (push) Successful in 1m0s
模板升版(1.2.0→1.3.0)暴露测试硬编码版本号——每次升版测试必断。
改为 extract_managed_block + block_version 动态取,升版不再碰测试。
2026-07-06 22:59:24 +09:00
lanrtop
46640783f9 chore(onboarding): 模板版本升级 AGENTS 1.3.0 / git-workflow 1.4.0
All checks were successful
Push & PR Check / check (push) Successful in 51s
内容已在前两次提交中更新(自验报告纪律第 6 条 / 单一 lian-jing 寻址),
但 ONBOARDING_MANAGED 块版本号未动导致批量分发全部 skipped——
版本判断是分发引擎的唯一升级信号,改模板必须同步升版本。
2026-07-06 22:58:02 +09:00
lanrtop
86995f7472 docs(conventions): 自验报告纪律三条——负面披露/改断言报备/两把尺子
All checks were successful
Push & PR Check / check (push) Successful in 51s
用户确立 agent 协作协议执行版(多角度辩证后选最小成本版落地):
1. 交人终验前反馈必含「没验什么」一节(负面披露收窄已知盲区)
2. 自修中改断言/验收标准需显式声明(封 Goodhart 最短路径,diff 可审计)
3. agent 自验对 acceptance、人终验对意图(acceptance 可能继承需求
   理解偏差,两把尺子不可合一)

三处同步:AGENTS.md(单一来源)+ 接入包模板(分发子项目)+
agents-meta-rules(设计草案)。完整辩证(五张力/四调节器/三缝隙)
存档于 governance-feedback-loop 模块设计档案节。
实证案例:mcp-single-server 自验全绿但 git-bash 层全挂,端到端终验抓住。
2026-07-06 22:46:04 +09:00
lanrtop
4226372423 docs(blueprint): 登记 governance-feedback-loop concept 模块——治理反馈闭环
All checks were successful
Push & PR Check / check (push) Successful in 50s
老板-员工手册隐喻:用户反馈子项目问题 → 结构化落账 → 梦核蒸馏进
规则/模板 → 批量分发 → 飞轮观测生效。治理管线已存在大半
(governance 规则同步 / feedback 梦核 / onboarding 模板版本化),
三处断环:治理反馈入口、反馈进梦核高权重输入、接入包批量重分发。
L 级,待 /architect。
2026-07-06 21:35:41 +09:00
lanrtop
31f93adbb2 chore(build): 新增 build:local 脚本——本地构建关闭 updater 签名产物
All checks were successful
Push & PR Check / check (push) Successful in 50s
本地构建用于装机自验,不需要签名产物;此前会因
TAURI_SIGNING_PRIVATE_KEY 缺失 exit 1(bundle 已产出但脚本层报错)。
签名私钥保持只存于 Gitea secrets(CI 单一发版通道,已验证
OSS latest.json v0.1.19 签名完整),本地不配 key——最小暴露面。
2026-07-06 21:21:57 +09:00
lanrtop
9355c07a6f chore: 迁移收尾——本项目 settings.json 换单一 lian-jing key + package.json 版本对齐 0.1.19
All checks were successful
Push & PR Check / check (push) Successful in 50s
- .claude/settings.json:由 migrate_to_single_server 自动重写(组级 key → lian-jing)
- package.json:0.1.18 → 0.1.19,与 Cargo.toml / tauri.conf.json 对齐(发版脚本此前漏更)
- .blueprint/usage.json:飞轮快照累积数据
2026-07-06 21:16:53 +09:00
lanrtop
a05fb49347 docs(blueprint): mcp-single-server 模块收官——T4 端到端终验通过
All checks were successful
Push & PR Check / check (push) Successful in 49s
验证:~/.claude.json 仅剩 lian-jing(12 条旧注册清零)、3 项目抽查
新格式通过、新路由握手 + get_diagnostics 实调通过、旧路由 404。
T4 暴露 git-bash 硬编码存量 bug 已修(0d1427f)。
遗留:同路径重复登记(legacy id 与 uuid id 并存)待登记卫生清理。
2026-07-06 20:18:32 +09:00
lanrtop
0d1427f4e9 fix(mcp-inject): git-bash 路径多位置探测——修复 Scoop 安装 Git 时 claude CLI 调用全挂
All checks were successful
Push & PR Check / check (push) Successful in 1m32s
迁移实测发现:CLAUDE_CODE_GIT_BASH_PATH 回退路径硬编码
C:\Program Files\Git\bin\bash.exe,Scoop 安装的 Git 不在该位置,
导致 claude mcp add/remove 全部非零退出(存量 bug,组级时代同样存在)。

- find_git_bash():env(校验存在)→ Program Files / Scoop → where git.exe 推导
- try_register_globally 返回 bool;同名 already exists(exit 1)视为成功,
  不再每次 inject 刷 warn 日志
- migrate_to_single_server 标记位改以注册成功为条件——CLI 链路不通时
  不落标记,下次启动重试(幂等)
2026-07-06 20:10:53 +09:00
01eb941ab9 Merge pull request 'feat(mcp): 单一全局 lian-jing 服务器——组级挂载收敛(mcp-single-server T1-T3/T5/T6)' (#3) from feat/mcp-single-server into master
All checks were successful
Push & PR Check / check (push) Successful in 49s
2026-07-06 18:52:55 +08:00
lanrtop
df7d52a084 feat(mcp): T6 删除旧 /mcp/group 路由与兼容层——提前并入本次发版
All checks were successful
Push & PR Check / check (push) Successful in 1m6s
Push & PR Check / check (pull_request) Successful in 49s
原计划后置一个版本,经评估提前:迁移在新版启动时自动重写全部
客户端配置(settings.json/liangjing.json/全局注册),旧路由的实际
消费者只剩升级前的存量会话,而服务重启后它们本就断开重连。
单人环境下双路由过渡的保留价值趋近于零,一次收口减少一轮发版。

- tools_call 签名去 Option 组上下文,process_rpc 单参数化
- list_group_projects 的 group_id 改为 required 参数
- grep 'mcp/group' src-tauri/src/ 零命中,cargo test 93 全绿
2026-07-06 19:34:51 +09:00
lanrtop
a41161f7d1 feat(mcp): 单一全局 lian-jing 服务器——组级挂载收敛(T1-T3+T5)
All checks were successful
Push & PR Check / check (push) Successful in 1m4s
T1 服务器层:新增全局 /mcp + /mcp/messages 路由,process_rpc 组上下文
Option 化;旧 /mcp/group/:gid 路由保留过渡,T6 移除。驻军测试 2 条。
T2 工具层:resolve_project_root 按 project_id 直查 project_workspaces,
删除 3 处组归属校验(实测无隔离价值);list_group_projects 改显式传参。
T3 注入层:settings.json 收敛为单 "lian-jing" key(URL /mcp),注入时
清理旧 lian-jing:group-* key;liangjing.json 主动删除 group_id;
启动时一次性迁移(重注入全量 + 清理旧全局注册两种命名格式,标记位防重)。
T5 同步:git-workflow 文档/模板、retro-catchup 技能 Python 兜底、
设置页 URL 展示、mcp-config-inject 模块取代注记。

收益:MCP 客户端配置成本 O(N组)→O(1),授权一次永久生效;
group_id 缺失/过期/移组同步一整类问题从根上消失。
2026-07-06 19:30:01 +09:00
lanrtop
daed723438 docs(blueprint): mcp-single-server 设计定稿 + 拆卡 T1-T6
All checks were successful
Push & PR Check / check (push) Successful in 47s
方案 B 双路由过渡:全局 /mcp 新路由与旧 /mcp/group/:gid 并存一个版本。
关键决策:工具层 resolve_project_root 去组化(组归属校验实测无隔离价值)、
迁移内建于重注入(reinject_all_mcp 即批量迁移器)、旧全局注册两种命名
格式(完整 UUID/短 8 位)都清理。webhook 路由已核实不受影响。
2026-07-06 19:15:59 +09:00
lanrtop
138a06aa17 docs(blueprint): 登记 mcp-single-server concept 模块——组级挂载收敛为单一全局服务器
All checks were successful
Push & PR Check / check (push) Successful in 48s
动机:MCP 客户端配置成本随产品组数线性增长(12 组 × 28 工具),
与飞轮'项目越多越强'方向相反;实测组级挂载无隔离价值(任意组
服务器可操作任意项目)。单一服务器后 liangjing.json 只需
project_id,group_id 同步问题从根上消失。L 级,待 /architect。
2026-07-06 19:04:04 +09:00
lanrtop
bce9a0a37c fix(mcp-inject): liangjing.json 改为合并写入,重注入可回填缺失的 group_id
All checks were successful
Push & PR Check / check (push) Successful in 1m18s
旧版注入的项目 liangjing.json 缺 group_id,外部 agent 每个会话都要
先调 list_all_projects 反查归属组(多一次探路调用 + 权限弹框)。
group_id 写入已在 2116e54 补上,本次将整文件覆盖改为读取-合并-写回:
只更新 project_id / group_id / mcp_port 三个字段,保留文件已有其他字段。
存量修复路径:对旧项目重新执行 inject_mcp 即可回填。
文档同步说明:移入新产品组时自动刷新;group_id 缺失/过期时重新注入。
2026-07-06 18:43:44 +09:00
48 changed files with 4693 additions and 336 deletions

View File

@ -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 函数,存量代码不强制回填。
### 强制规则

View File

@ -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-04git-workflow 复杂度感知分支策略B3 2026-07-04manifest 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 硬编码存量 bug0d1427f。遗留同路径重复登记待清理见模块 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

View 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 拆卡)

View File

@ -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 提示用户

View 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_workspaces3 处组归属校验替换为 ensure_project_existslist_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 仅在项目不属于任何组时移除 keycargo test 全绿"
- done_note: 2026-07-06 完成。额外实现 migrate_to_single_server 启动时一次性迁移(标记位 mcp_single_migratedclaude 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 完成。端到端验证暴露真 bugCLAUDE_CODE_GIT_BASH_PATH 回退路径硬编码 Program FilesScoop 安装的 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 参数

View File

@ -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而非分发 hookR12 边界单点 + 零分发成本 + 可回溯)
## 复用的现有能力(~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/updatedCRLF 归一化防覆盖抖动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.0ingest 全量重跑 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_idsR+数字 token 边界识别,防 PR13/R2D2 误提取)+ filter_valid_rulesvalid=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 身份解析形状),未登记项目不列入(无法跑 /envoyget_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 轮)
是否返工: 是(实调暴露空路径 bug473cf7e 补修)
有效策略: 立卡前多角度辩证砍掉伪需求U2 否决、U3 载体翻转实现返工率显著低于直做检查项从内嵌模板自省lefthook_command_keys延续 V1 范式校验器零维护dev 实例实调=真实终验路径,两小时内筛出两个纯靠单测到不了的坑(空路径写 cwd、托管块抹本地定制
踩坑: ① 批量 SQL 只滤 NULL 没滤空串,空路径登记让接入包写进进程 cwdsrc-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

View File

@ -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 共享 Monorepopackages/core业务逻辑+ packages/types + apps/webVite+Tailwind+ apps/mobileExpo+NativeWind
### ✅ 知识库(择升环)模板
- status: done
- complexity: S
- files: src-tauri/src/commands/templates.rs
- acceptance: |
agent_verify: [Rust] cargo build 通过seed 数据含 builtin-knowledge-loopfiles 覆盖 CORE.md/concepts/dialogues/cases/AGENTS.md
human_verify: 应用内新建项目选该模板 → 生成目录结构与晋升规则内容符合预期
新增内置模板 `builtin-knowledge-loop`:方法论/知识沉淀文档库。
三层晋升结构dialogues 原料 → concepts 概念卡 → CORE.md 稳定核心),
晋升规则写入模板 AGENTS.mdagent 进库即按择升环工作流运转。init_commands 为 `git init`
agent_verify 已过cargo check + 种子条目确认commit 55ee5a4human_verify 待应用内新建项目实测。

File diff suppressed because it is too large Load Diff

53
.claude/commands/envoy.md Normal file
View 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 条
· {标题} — {一句说明}
若本次会话无任何体系级洞察,直接输出:`✅ 本次会话无需回传,无体系级洞察。`

View File

@ -0,0 +1,32 @@
#!/usr/bin/env node
// 由炼境托管:重跑接入包会用最新模板覆盖本文件,请勿在此做项目定制。
// 蓝图门控pre-commit 检查未关闭的 🔵 任务卡warn onlyexit 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);

View File

@ -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"` 版本号**——版本是分发引擎唯一的升级信号,内容变了版本不动,批量分发会全部 skipped2026-07-06 实测踩坑)。模板改完后跑 `cargo test onboarding` 确认版本相关测试通过。
分发到子项目需要**重建安装炼境**(模板打包在二进制资源里),再调 `apply_onboarding_pack`

View File

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

View File

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

@ -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组件 PascalCaseasync/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` trailerlefthook 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/commandUI 只呈现不复制逻辑——
否则人与 agent 的验证路径会分裂
@ -207,7 +202,7 @@ docs/templates/ 炼境接入包模板草案(设计层)
| UI 呈现层 | Playwright 驱动真界面(最后手段,仅关键路径) |
3. **完成的定义**:功能实现后,由 agent 沿投影路径真实走通一次(起服务 → 调接口 →
断言行为),再交人做 UI 终验——不许绕过功能手改产物冒充验证
4. **侦察 → 驻军**agent 走通的关键路径,顺手固化为一条测试(炼境主路径为 cargo testvitest 仅在引入独立纯逻辑模块后按需添加
4. **侦察 → 驻军**agent 走通的关键路径,顺手固化为一条测试(桌面应用用 cargo testWeb 项目纯逻辑层用 vitest
一次性验收不固化(少而稳 > 多而脆)
5. **acceptance 可执行化**`/splitter` 生成任务卡时,`acceptance` 字段必须是可执行验证路径,
格式:`[载体] 调用/运行什么(参数) → 断言什么`。
@ -215,6 +210,54 @@ docs/templates/ 炼境接入包模板草案(设计层)
- 正例:`[HTTP API] POST /api/users → 201body.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 -->

View File

@ -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` 检查子项目使者回传——若有待处理条目,在开始其他任务前先列出,询问是否立即处置

View File

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

View File

@ -124,6 +124,15 @@ acceptance 是 agent 自验的唯一判断依据,必须是**可执行断言**
含 acceptance 的任务卡才可派发acceptance 可执行,自验才是真自验——这是整个体系的核心关节。
### 自验报告纪律
- 交人终验前的反馈必须含「**没验什么**」一节:未覆盖的路径、依赖的假设、不确定的地方
- 自修过程中改了断言/验收标准必须显式声明理由(改功能让测试过是常规,改测试让功能过需报备)
- agent 自验对 acceptance人终验对**意图**——acceptance 可能继承需求理解偏差,两把尺子不可合一
> 为什么:自验的结构性盲区是"验证代码与功能代码共享同一份理解偏差",负面披露收窄已知盲区,
> 人终验(对意图、带意外感触发的深查)兜底未知盲区。两者互补,缺一另一超载。
### 自验失败降级约定
- **失败 2 次内**:自行修正,继续自验

View File

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

View File

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

@ -880,7 +880,7 @@ dependencies = [
[[package]]
name = "dev-manager-tauri"
version = "0.1.18"
version = "0.1.19"
dependencies = [
"axum",
"base64 0.22.1",

View File

@ -47,3 +47,6 @@ serde_yaml = "0.9"
hmac = "0.12"
sha2 = "0.10"
[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt"] }

View 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 条
· {标题} — {一句说明}
若本次会话无任何体系级洞察,直接输出:`✅ 本次会话无需回传,无体系级洞察。`

View File

@ -0,0 +1,32 @@
#!/usr/bin/env node
// 由炼境托管:重跑接入包会用最新模板覆盖本文件,请勿在此做项目定制。
// 蓝图门控pre-commit 检查未关闭的 🔵 任务卡warn onlyexit 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);

View File

@ -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` trailerlefthook commit-msg 强制检查)。这是确认卡已输出的机器层存证。
> ⚠️ Session 结束汇总补录 ≠ 确认卡替代——补录仅适用于漂移检测触发后用户主动选择补录的场景,不得作为「先做再补」的通道。
## Git 工作流
@ -61,6 +90,15 @@
- 正例:`[HTTP API] POST /api/users → 201body.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 条传给下一个:

View File

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

View File

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

View File

@ -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) {
// Tauricommand 层)
// 注意:不要求 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() },
});
}
}

View File

@ -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();

View File

@ -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
}
// ── U3Rules-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 只给 subjecttrailer 走 `record_commit_entry`;本函数保留给
/// 持有完整 message 的调用方与测试。
/// 解析并写入一条 commit 记录(从完整 message 提取 conventional + Rules-Applied trailer
/// 并按 valid_rules 剔除未知规则 IDU3。远程采集生产链路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_filesINSERT 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_appliedtrailer 死链修复后的存量补数路径)
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());
}
// ── U3Rules-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- R12xxx见 |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 置 NULLp3 干净不动");
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"), "只清洗指定项目");
// 清洗后交叉统计不再含未知 IDacceptance 断言)
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);
}
}

View File

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

View File

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

View File

@ -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("") 是相对路径,模板会写进炼境进程的
// cwd2026-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");
}
/// 回归:空路径登记曾把接入包写进炼境进程 cwdsrc-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");
// 一致 → skippedCRLF 检出也视为一致)
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 的回传过期后也应列为沉默");
}
}

View File

@ -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 表,需手动清理)

View File

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

View File

@ -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 移动端 AppAndroid / 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 多包 Monorepoapps/ 内含 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/webVite + Tailwind+ apps/mobileExpo + 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 8pnpm 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 2pnpm 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.mdagent 进库即按择升环工作流运转。",
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-DDcases/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,
],
);
// 已存在的模板也补充 variantsINSERT 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],
);

View File

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

View File

@ -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.jsonbest-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 写进
/// 炼境进程 cwdsrc-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 路径应通过"
);
}
}

View File

@ -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 HTTPPOST— 向后兼容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 HTTPPOST─────────────────────────────────────────────────────
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()))

View File

@ -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!("- **{}** — {} 条 commit0 回传\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.mdlog_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_idapply 自 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 批量模式)"
);
}
}

View File

@ -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()
{

View File

@ -0,0 +1,5 @@
{
"bundle": {
"createUpdaterArtifacts": false
}
}

View File

@ -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);
};

View File

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

View File

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

View File

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

View 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>
);
}
}

View File

@ -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/&lt;group_id&gt;
http://localhost:{mcpPort}/mcp
</code>
</p>
</div>

View File

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