Merge pull request 'feat(mcp/envoy): 使者回传闭环——回执 + 三端回传入口' (#28) from feat/governance-feedback-loop into master
All checks were successful
Push & PR Check / check (push) Successful in 1m6s
All checks were successful
Push & PR Check / check (push) Successful in 1m6s
This commit is contained in:
commit
f948c4c802
@ -1,73 +0,0 @@
|
|||||||
---
|
|
||||||
name: "source-command-envoy"
|
|
||||||
description: "Migrated source command `envoy`"
|
|
||||||
---
|
|
||||||
|
|
||||||
# source-command-envoy
|
|
||||||
|
|
||||||
Use this skill when the user asks to run the migrated source command `envoy`.
|
|
||||||
|
|
||||||
## Command Template
|
|
||||||
|
|
||||||
<!-- 由炼境托管:重跑接入包会用最新模板覆盖本文件,请勿在此做项目定制 -->
|
|
||||||
|
|
||||||
你是**炼境使者**——在子项目会话里代表炼境执行现场感知与回传。
|
|
||||||
|
|
||||||
直接执行以下步骤,无需用户提供任何输入。
|
|
||||||
|
|
||||||
## Step 1:读取项目身份
|
|
||||||
|
|
||||||
用 Read 工具读取 `.Codex/liangjing.json`,获取 `project_id` 和项目名称。
|
|
||||||
若文件不存在:输出 `⚠️ 未找到 .Codex/liangjing.json,该项目未接入炼境` 并停止。
|
|
||||||
|
|
||||||
## Step 2:扫描本次会话
|
|
||||||
|
|
||||||
回顾本次会话的完整对话,识别有价值的洞察。
|
|
||||||
跳过:纯代码实现细节、具体 bug 修复、一次性操作记录。
|
|
||||||
|
|
||||||
> ⚠️ **炼境定位讨论不得漏判**:凡涉及「炼境自身定位 / 缺口 / 演进方向 / 与其他工具的对比」的讨论,即便看起来像聊天而非技术实现,**默认视为体系级洞察,列入带回候选清单**。此类内容在历史会话中曾被漏传(判定为聊天),经用户追问才补发。
|
|
||||||
|
|
||||||
## Step 3:分类每条洞察
|
|
||||||
|
|
||||||
**前置硬门槛(先于一切分类判断)**:问自己——**任何有经验的工程师是否会提前想到这一点?**
|
|
||||||
- 会(常识性设计知识,如"外键优于业务字段关联")→ 一律归「留本项目」,不得回传
|
|
||||||
- 触发条件过窄的 corner case(复现依赖本项目特定组合)→ 同样归「留本项目」
|
|
||||||
- 只有**非显而易见、且跨项目可复用**的教训才有资格进入下方分类
|
|
||||||
|
|
||||||
**📦 带回炼境**(通过硬门槛后,满足任一条):
|
|
||||||
- 炼境分发的模板 / 约定 / 接入包有 bug 或可改进
|
|
||||||
- 某工作流或实践有明确代价或验证结果(有具体证据)
|
|
||||||
- 会影响多个项目的体系级改进(哪怕还是假设)
|
|
||||||
|
|
||||||
**🏠 留本项目**(同时满足以下所有):
|
|
||||||
- 只影响本项目的具体实现
|
|
||||||
- 无任何跨项目复用价值
|
|
||||||
- 纯猜测、零证据
|
|
||||||
|
|
||||||
## Step 4:发回炼境
|
|
||||||
|
|
||||||
对每条「带回」洞察,单独调用一次 `mcp__lian-jing__append_project_note`。
|
|
||||||
|
|
||||||
> **MCP 不可用时的降级**:若 `mcp__lian-jing__append_project_note` 报连接错误,重试 1~2 次仍失败,则将所有待回传内容按 Step 4 格式整理成一段文本,输出到对话让用户看到,并提示:「MCP 断连,请复制以上内容到炼境会话手动执行 `/envoy` 回传,或重连后重跑本命令」。
|
|
||||||
- `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 条
|
|
||||||
· {标题} — {一句说明}
|
|
||||||
|
|
||||||
若本次会话无任何体系级洞察,直接输出:`✅ 本次会话无需回传,无体系级洞察。`
|
|
||||||
@ -13,6 +13,36 @@
|
|||||||
- 现状:`append_project_note` 挂单项目、无类型标记,是"员工工作日志"而非"给老板的管理反馈"
|
- 现状:`append_project_note` 挂单项目、无类型标记,是"员工工作日志"而非"给老板的管理反馈"
|
||||||
- 方向:`report_governance_feedback` 类入口(MCP 工具优先,UI 上报按钮可后置),记录:哪个项目、什么问题、疑似哪条规则/模板缺陷;落 project_notes 加类型字段或独立治理账本表
|
- 方向:`report_governance_feedback` 类入口(MCP 工具优先,UI 上报按钮可后置),记录:哪个项目、什么问题、疑似哪条规则/模板缺陷;落 project_notes 加类型字段或独立治理账本表
|
||||||
- 形态待定:MCP 工具 vs UI 按钮 vs 笔记类型约定(三选或组合,architect 阶段定)
|
- 形态待定:MCP 工具 vs UI 按钮 vs 笔记类型约定(三选或组合,architect 阶段定)
|
||||||
|
- 2026-10-02 定形:沿用「使者回传」笔记约定 + MCP 工具三端入口 + 独立回执表,见下卡
|
||||||
|
|
||||||
|
### 🔵 缺口①·使者回传闭环(回执 + 三端入口)
|
||||||
|
- status: in_progress
|
||||||
|
- complexity: M
|
||||||
|
- files: src-tauri/src/envoy.rs(新), src-tauri/src/mcp_tools.rs, src-tauri/src/db.rs, src-tauri/src/lib.rs, src-tauri/src/commands/onboarding.rs, src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md, src-tauri/resources/onboarding/.claude/commands/envoy.md, src-tauri/resources/onboarding/AGENTS.md.tmpl, src-tauri/resources/onboarding/.lian-jing/capability-packs.json, docs/templates/agents-meta-rules.md, docs/ai-context/project-context.yaml
|
||||||
|
- acceptance:
|
||||||
|
- agent_verify: [cargo test] resolve_envoy_return 处置后 list_envoy_returns 不再列出该条、已处置计数 +1,全部处置后待处置为 0;report_envoy_return 在项目 scoped 目录可见,resolve/list 仅全局可见。[三端无头探针] enterprise 中 Claude、Codex、DSH 各调 report_envoy_return 一条 → list_envoy_returns 可见且带「客户端」行;随后 resolve 为 rejected。[MCP] 59 条历史回传补录后 list_envoy_returns 待处置 0、已处置 59
|
||||||
|
- human_verify: 在 DSH 或 Codex 的日常会话里遇到摩擦时,能自然地完成一次回传
|
||||||
|
- 需求卡:用户 2026-10-02 确认(「按照你推荐的来」),交接文档 lj-p9-spike/handoff-governance-feedback-loop.md
|
||||||
|
- 背景:回传只进不出(无回执,`LIMIT 50` 把更早的静默挤掉);入口只有 Claude `/envoy`,08-23 后 enterprise 主力转 DSH/Codex 即零回传
|
||||||
|
- 设计:`envoy_dispositions` 表(note_id 主键,landed/carded/watching/obsolete/rejected,landed/carded 必填 reference,允许 upsert 更正);`report_envoy_return`(项目级,三端在 scoped 地址可用)、`resolve_envoy_return`(全局)、`list_envoy_returns` 只列未处置
|
||||||
|
- 动工前辩证收缩(2026-10-02):
|
||||||
|
- 载体补强:只靠 skill 不保证 DSH/Codex「自然」想起回传(skill 按需加载),AGENTS.md 托管块是三端必读面——加一行指路,细则仍只在 skill(AGENTS 块 1.9.0→1.10.0)
|
||||||
|
- 补洞①:列表原 `JOIN project_profiles` 会把登记已删项目的回传静默丢掉 → LEFT JOIN 兜底显示 project_id
|
||||||
|
- 补洞②:MCP 断连重试会重复写入 → 同项目同内容幂等,返回已有编号
|
||||||
|
- 补洞③:内容含换行会破坏行格式 → 字段压成单行
|
||||||
|
- 补洞④:`/envoy` Step 1 仍读旧 `.claude/liangjing.json` → 改读 `.lian-jing/context.json`(旧文件仅兜底)
|
||||||
|
- 不做:回执落档文件(可拆除性)——回执是炼境侧判断、归宿不在子项目 git,挂 concept 卡待第二个场景
|
||||||
|
- 实现留痕(2026-10-03):本体在 `envoy.rs`(连接注入);`report_envoy_return` 写库后复用 `archive_note_append` 双写档案;
|
||||||
|
主工具清单 `json!` 触达宏递归上限,回传/处置 schema 拆为 `envoy_tool_schemas()` 插在 list 之后(不抬全局 recursion_limit)
|
||||||
|
- 对抗审查(两路并行)采纳:① 服务端 instructions「写操作需用户确认」与「自然回传」冲突 → 工具描述 / skill / AGENTS 指路写明回传只追加信号、不必逐条征得同意;
|
||||||
|
② 硬门槛会吞掉「只在本项目复现」的模板 bug → 炼境分发物出错免门槛(scope=this_project),用户要求时直接回传;
|
||||||
|
③ 会话中途已报、结束跑 /envoy 换说法重报 → skill 与 /envoy 写明不重报、沿用编号;④ 去重先查后插有并发竞态(6 线程实测复现)→ IMMEDIATE 事务;
|
||||||
|
去重键去掉含项目名的首行、只比 1 天内(重试窗口,避免吞掉复发信号);⑤ 内容随档案进子项目 Git → 服务端兜底拒绝私钥头 / 常见令牌前缀;
|
||||||
|
⑥ resolve 覆盖旧回执 → destructiveHint=true;note_id 宽容解析 "#123"/"123";缺 project_id 明确提示;
|
||||||
|
⑦ 炼境仓库内 Codex 迁移遗留的 `.agents/skills/source-command-envoy`(读旧身份文件、走 append_project_note)已删,炼境自身 `/envoy` 副本同步模板
|
||||||
|
- 审查挂出未做:托管文件改内容忘升版本无机器拦截(另开任务,指纹测试);noteapp 未跟踪的同款遗留技能(子项目内容,不动);炼境自身 skill / 能力包副本停在 1.4.0(既有漂移,控制面项目)
|
||||||
|
- 自验(agent_verify 第一段):`cargo test --locked` 266 通过 / 0 失败(新增 envoy 9 + MCP 层 2:scoped 目录可见性与注解、dispatch 往返——项目地址回传 → 全局列表带编号与客户端 → 项目地址处置被拒 → 控制面处置后不再列出 → include_resolved 可审计);`pnpm typecheck` 通过;
|
||||||
|
只读核对真实库:使者回传恰 59 条(#77–#150),无孤儿;补录清单经复核 landed 29 / watching 23 / carded 6 / obsolete 1(#82/#93/#147 与台账口径不同处已在 reason 写明依据)
|
||||||
|
|
||||||
### ② 反馈进入梦核蒸馏
|
### ② 反馈进入梦核蒸馏
|
||||||
- 现状:梦核吃被动数据(commit 指标、复盘笔记、停滞模块、高风险 scope)
|
- 现状:梦核吃被动数据(commit 指标、复盘笔记、停滞模块、高风险 scope)
|
||||||
@ -192,4 +222,8 @@ R 规则层=CONVENTIONS 规则库(炼境强项,业务侧反而隐式)|E
|
|||||||
④ 文档防腐铁律加注「prompt/skill 文件豁免删冗余——重复是遵循度手段,非腐化」
|
④ 文档防腐铁律加注「prompt/skill 文件豁免删冗余——重复是遵循度手段,非腐化」
|
||||||
- acceptance: `[人工] 下次模板批 diff 含 4 项;版本号随批次升级`
|
- acceptance: `[人工] 下次模板批 diff 含 4 项;版本号随批次升级`
|
||||||
|
|
||||||
|
### 💭 使者回执落档(可拆除性)
|
||||||
|
- status: concept
|
||||||
|
- 内容:`envoy_dispositions` 回执只在 SQLite(同 notes 档案落地前的 project_notes),是炼境侧原创判断。归宿不宜写进子项目 git(不是子项目的事实);炼境安装态又定位不到自身仓库。第二个「炼境侧原创资产」出现时一并设计归宿(2026-10-02 缺口①卡辩证收缩挂出)
|
||||||
|
|
||||||
(其余 concept 阶段,L 级——待 /architect 设计确认后由 /splitter 拆卡)
|
(其余 concept 阶段,L 级——待 /architect 设计确认后由 /splitter 拆卡)
|
||||||
|
|||||||
@ -4,12 +4,16 @@
|
|||||||
|
|
||||||
你是**炼境使者**——在子项目会话里代表炼境执行现场感知与回传。
|
你是**炼境使者**——在子项目会话里代表炼境执行现场感知与回传。
|
||||||
|
|
||||||
|
回传入口是 MCP 工具 `report_envoy_return`(Claude、Codex、DSH 共用),字段规范以 lian-jing skill「遇到摩擦时回传炼境」一节为准。本命令负责会话结束时的扫描、分类和逐条回传。
|
||||||
|
|
||||||
直接执行以下步骤,无需用户提供任何输入。
|
直接执行以下步骤,无需用户提供任何输入。
|
||||||
|
|
||||||
## Step 1:读取项目身份
|
## Step 1:确认项目身份与回传入口
|
||||||
|
|
||||||
用 Read 工具读取 `.claude/liangjing.json`,获取 `project_id` 和项目名称。
|
用 Read 工具读取 `.lian-jing/context.json`,获取 `project_id`。如果只有旧的 `.claude/liangjing.json`,就用它的 `project_id`,并提醒用户在炼境调用 `inject_mcp` 迁移。
|
||||||
若文件不存在:输出 `⚠️ 未找到 .claude/liangjing.json,该项目未接入炼境` 并停止。
|
两个文件都没有:输出 `⚠️ 未找到 .lian-jing/context.json,该项目未接入炼境` 并停止。
|
||||||
|
|
||||||
|
确认 `mcp__lian-jing__report_envoy_return` 在当前会话可用;不可用时按 lian-jing skill 的降级顺序排查,并使用 Step 4 的降级方式。
|
||||||
|
|
||||||
## Step 2:扫描本次会话
|
## Step 2:扫描本次会话
|
||||||
|
|
||||||
@ -24,6 +28,7 @@
|
|||||||
- 会(常识性设计知识,如"外键优于业务字段关联")→ 一律归「留本项目」,不得回传
|
- 会(常识性设计知识,如"外键优于业务字段关联")→ 一律归「留本项目」,不得回传
|
||||||
- 触发条件过窄的 corner case(复现依赖本项目特定组合)→ 同样归「留本项目」
|
- 触发条件过窄的 corner case(复现依赖本项目特定组合)→ 同样归「留本项目」
|
||||||
- 只有**非显而易见、且跨项目可复用**的教训才有资格进入下方分类
|
- 只有**非显而易见、且跨项目可复用**的教训才有资格进入下方分类
|
||||||
|
- **例外**:炼境分发物(AGENTS 托管块、lian-jing skill、本命令、lefthook、CONVENTIONS、MCP 工具)本身出错或碍事,即使只在本项目复现,也直接归「带回炼境」,`scope` 填 `this_project`
|
||||||
|
|
||||||
**📦 带回炼境**(通过硬门槛后,满足任一条):
|
**📦 带回炼境**(通过硬门槛后,满足任一条):
|
||||||
- 炼境分发的模板 / 约定 / 接入包有 bug 或可改进
|
- 炼境分发的模板 / 约定 / 接入包有 bug 或可改进
|
||||||
@ -37,26 +42,21 @@
|
|||||||
|
|
||||||
## Step 4:发回炼境
|
## Step 4:发回炼境
|
||||||
|
|
||||||
对每条「带回」洞察,单独调用一次 `mcp__lian-jing__append_project_note`。
|
对每条「带回」洞察,单独调用一次 `mcp__lian-jing__report_envoy_return`。本会话中途已经回传过的洞察不要换个说法再报,摘要里沿用原编号。
|
||||||
|
|
||||||
> **MCP 不可用时的降级**:若 `mcp__lian-jing__append_project_note` 报连接错误,重试 1~2 次仍失败,则将所有待回传内容按 Step 4 格式整理成一段文本,输出到对话让用户看到,并提示:「MCP 断连,请复制以上内容到炼境会话手动执行 `/envoy` 回传,或重连后重跑本命令」。
|
- `client`:填 `claude`
|
||||||
- `project_id`:来自 Step 1
|
- `kind` / `confidence` / `scope` / `content` / `suggestion`:按 lian-jing skill 规范填写;`content` 用一句话写清现象和证据
|
||||||
- `content` 格式(严格遵守):
|
- `project_id`:项目地址已绑定,可以省略;连的是全局地址时,显式传 Step 1 读到的 `project_id`
|
||||||
|
|
||||||
【炼境使者回传】来源: {项目名}
|
|
||||||
类型: 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 场景实测有效")。
|
**`confirmed` 的门槛**:必须能陈述至少一条具体代价或验证结果(如"导致 N% 时间损耗"、"在 X 场景实测有效")。
|
||||||
无法陈述具体证据的,一律标 `under_validation`,不得标 `confirmed`。
|
无法陈述具体证据的,一律标 `under_validation`,不得标 `confirmed`。回传内容不得包含凭据、密钥、账号、内网地址或生产数据(内容会同时写进本项目 `.blueprint/notes-archive.jsonl`,随项目进入 Git)。
|
||||||
|
|
||||||
|
> **MCP 不可用时的降级**:若 `report_envoy_return` 报连接错误,原地重试 1~2 次(同内容重复调用不会重复写入)。仍失败时,把所有待回传内容按上述字段整理成一段文本输出到对话,并提示:「MCP 断连,请把以上内容转交炼境会话回传,或重连后重跑本命令」。
|
||||||
|
|
||||||
## Step 5:输出摘要
|
## Step 5:输出摘要
|
||||||
|
|
||||||
📦 已发回炼境 N 条
|
📦 已发回炼境 N 条
|
||||||
· {标题}({置信度} · {影响范围})
|
· #{编号} {标题}({置信度} · {影响范围})
|
||||||
|
|
||||||
🏠 留本项目 M 条
|
🏠 留本项目 M 条
|
||||||
· {标题} — {一句说明}
|
· {标题} — {一句说明}
|
||||||
|
|||||||
@ -31,6 +31,7 @@ src-tauri/src/db.rs SQLite 建表 + migration 逻辑
|
|||||||
src-tauri/src/credential_store.rs Windows 本机受保护凭证存储与旧值兼容迁移(SQLite 仅保存引用)
|
src-tauri/src/credential_store.rs Windows 本机受保护凭证存储与旧值兼容迁移(SQLite 仅保存引用)
|
||||||
src-tauri/src/mcp_server.rs MCP Server 路由层 + SSE 协议(agent 调用炼境的入口)
|
src-tauri/src/mcp_server.rs MCP Server 路由层 + SSE 协议(agent 调用炼境的入口)
|
||||||
src-tauri/src/mcp_tools.rs MCP 工具 schema + dispatch + 实现(工具清单以 tools_list_result 为准)
|
src-tauri/src/mcp_tools.rs MCP 工具 schema + dispatch + 实现(工具清单以 tools_list_result 为准)
|
||||||
|
src-tauri/src/envoy.rs 使者回传本体(三端回传写入、处置回执、待处置列表;连接由调用方注入)
|
||||||
src-tauri/src/mcp_webhook.rs Gitea webhook 处理(push/PR 事件写入飞轮)
|
src-tauri/src/mcp_webhook.rs Gitea webhook 处理(push/PR 事件写入飞轮)
|
||||||
src-tauri/src/mcp_inject.rs 接入注入与三端适配(canonical context、Codex 项目配置、Claude local 条目、DSH 托管块、%USERPROFILE%\.lian-jing\bin 稳定 helper 入口)
|
src-tauri/src/mcp_inject.rs 接入注入与三端适配(canonical context、Codex 项目配置、Claude local 条目、DSH 托管块、%USERPROFILE%\.lian-jing\bin 稳定 helper 入口)
|
||||||
src-tauri/resources/onboarding/ 接入包分发模板(AGENTS.md/lefthook/git-workflow)
|
src-tauri/resources/onboarding/ 接入包分发模板(AGENTS.md/lefthook/git-workflow)
|
||||||
@ -140,7 +141,7 @@ docs/templates/ 炼境接入包模板草案(设计层)
|
|||||||
完整规则(迭代管理、阻塞处理、复盘笔记等)详见 `.blueprint/CONVENTIONS.md`。
|
完整规则(迭代管理、阻塞处理、复盘笔记等)详见 `.blueprint/CONVENTIONS.md`。
|
||||||
<!-- BLUEPRINT_MANAGED_END -->
|
<!-- BLUEPRINT_MANAGED_END -->
|
||||||
|
|
||||||
<!-- ONBOARDING_MANAGED_START version="1.9.0" -->
|
<!-- ONBOARDING_MANAGED_START version="1.10.0" -->
|
||||||
> ⚠️ 本块由炼境版本化托管,升级时**整块原位覆盖**——项目定制请写到块外,块内手改必丢(2026-07-15 炼境自身实锤丢失 5 段定制后新增本警示)。
|
> ⚠️ 本块由炼境版本化托管,升级时**整块原位覆盖**——项目定制请写到块外,块内手改必丢(2026-07-15 炼境自身实锤丢失 5 段定制后新增本警示)。
|
||||||
|
|
||||||
## 核心开发约束
|
## 核心开发约束
|
||||||
@ -194,6 +195,7 @@ docs/templates/ 炼境接入包模板草案(设计层)
|
|||||||
- `.claude/liangjing.json` 只是旧项目的 Claude 兼容投影。只发现它时,调用 `inject_mcp(project_id)` 迁移,而不是手工复制或猜测 ID。
|
- `.claude/liangjing.json` 只是旧项目的 Claude 兼容投影。只发现它时,调用 `inject_mcp(project_id)` 迁移,而不是手工复制或猜测 ID。
|
||||||
- `.lian-jing/capability-packs.json` 记录炼境托管基线的版本。`verify_onboarding(project_id)` 报告 `legacy`、`version_mismatch` 等状态时,按其 `detail` 中的升级提示处理。
|
- `.lian-jing/capability-packs.json` 记录炼境托管基线的版本。`verify_onboarding(project_id)` 报告 `legacy`、`version_mismatch` 等状态时,按其 `detail` 中的升级提示处理。
|
||||||
- `apply_onboarding_pack(project_id)` 只更新炼境明确托管的接入包文件;项目自己的业务规则、测试、部署声明和手工定义的同名 MCP server 仍归项目所有,不能被升级覆盖。
|
- `apply_onboarding_pack(project_id)` 只更新炼境明确托管的接入包文件;项目自己的业务规则、测试、部署声明和手工定义的同名 MCP server 仍归项目所有,不能被升级覆盖。
|
||||||
|
- 遇到炼境分发的模板、约定或 MCP 工具的摩擦,或验证出跨项目可复用的实践时,调用 `report_envoy_return` 回传炼境(只追加信号,不必逐条征得用户同意;Claude 会话结束时也可跑 `/envoy`);何时回传、字段怎么填见 lian-jing skill「遇到摩擦时回传炼境」一节。
|
||||||
|
|
||||||
### Node/pnpm 工具链
|
### Node/pnpm 工具链
|
||||||
|
|
||||||
|
|||||||
@ -29,6 +29,7 @@ src-tauri/src/db.rs 数据库 schema + migration
|
|||||||
src-tauri/src/credential_store.rs Windows 本机凭证引用、校验与兼容迁移
|
src-tauri/src/credential_store.rs Windows 本机凭证引用、校验与兼容迁移
|
||||||
src-tauri/src/mcp_server.rs MCP Server(agent 调用炼境的入口)
|
src-tauri/src/mcp_server.rs MCP Server(agent 调用炼境的入口)
|
||||||
src-tauri/src/mcp_tools.rs MCP tool schema 与 dispatch
|
src-tauri/src/mcp_tools.rs MCP tool schema 与 dispatch
|
||||||
|
src-tauri/src/envoy.rs 使者回传本体(三端回传写入、处置回执、待处置列表)
|
||||||
src-tauri/src/commands/dev_variables.rs 项目开发变量本体(Agent 登记、人填写、受认证单项读取)
|
src-tauri/src/commands/dev_variables.rs 项目开发变量本体(Agent 登记、人填写、受认证单项读取)
|
||||||
src-tauri/src/commands/delivery_status.rs 项目交付信号聚合(Git / Gitea / 部署证据)
|
src-tauri/src/commands/delivery_status.rs 项目交付信号聚合(Git / Gitea / 部署证据)
|
||||||
src-tauri/resources/onboarding/ 接入包分发模板
|
src-tauri/resources/onboarding/ 接入包分发模板
|
||||||
|
|||||||
@ -16,7 +16,8 @@ contracts:
|
|||||||
- "src-tauri/src/commands/cicd.rs"
|
- "src-tauri/src/commands/cicd.rs"
|
||||||
- "src-tauri/src/commands/gitea.rs"
|
- "src-tauri/src/commands/gitea.rs"
|
||||||
- "src-tauri/src/commands/dev_variables.rs"
|
- "src-tauri/src/commands/dev_variables.rs"
|
||||||
doc: "工具 schema、dispatch 与调用说明必须同步;get_project_resource_status 为项目 scoped 只读状态,仅返回 Gitea PR/webhook 的状态与来源,不返回凭证、引用、URL;旧凭证未迁移时返回 pending_migration,不提前宣称 configured。MCP 不提供 reveal_api_key、get_secret 或任意 HTTP/SSH/SQL/部署代理。明文例外只限 read_project_dev_variable 的单项标量与 read_project_test_account 的单张非生产测试账户;均须匹配 scoped 项目及本机认证。register_project_test_account 只写非秘密需求元数据;列表不返回值,禁止批量导出。production delivery profile 由项目维护,炼境只读 Gitea 部署 context 与公开版本探针,双证据相符才能报告 deployed。外部来源不可用时返回显式降级状态,不得伪造成功。verify_onboarding 必须同时返回静态接入 checks 与 configured/reachable/handshake/tools_ready/reload_required 运行态证据,配置存在不得替代运行可用。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。"
|
- "src-tauri/src/envoy.rs"
|
||||||
|
doc: "工具 schema、dispatch 与调用说明必须同步;使者回传三件套:report_envoy_return 为项目级(三端在 scoped 地址可用;kind/confidence/suggestion/client 为枚举,scope 为 this_project|all_projects|project_type:<类型> 格式校验,content 压成单行且限长;无凭据字段,服务端对私钥头与常见令牌前缀做兜底拒绝,其余靠描述约束——内容会双写进子项目 .blueprint/notes-archive.jsonl 随项目进 Git;同项目同内容未处置时幂等返回原编号),resolve_envoy_return 与 list_envoy_returns 仅控制面(全局地址),子项目 agent 只回传不处置;list 默认只列未处置并报告已处置计数,include_resolved=true 附列回执,超出展示上限须明说不得静默截断,回传格式首行前缀【炼境使者回传】保持兼容(B4 沉默项目统计按前缀匹配);get_project_resource_status 为项目 scoped 只读状态,仅返回 Gitea PR/webhook 的状态与来源,不返回凭证、引用、URL;旧凭证未迁移时返回 pending_migration,不提前宣称 configured。MCP 不提供 reveal_api_key、get_secret 或任意 HTTP/SSH/SQL/部署代理。明文例外只限 read_project_dev_variable 的单项标量与 read_project_test_account 的单张非生产测试账户;均须匹配 scoped 项目及本机认证。register_project_test_account 只写非秘密需求元数据;列表不返回值,禁止批量导出。production delivery profile 由项目维护,炼境只读 Gitea 部署 context 与公开版本探针,双证据相符才能报告 deployed。外部来源不可用时返回显式降级状态,不得伪造成功。verify_onboarding 必须同时返回静态接入 checks 与 configured/reachable/handshake/tools_ready/reload_required 运行态证据,配置存在不得替代运行可用。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。"
|
||||||
|
|
||||||
- label: 被管理项目身份上下文
|
- label: 被管理项目身份上下文
|
||||||
paths:
|
paths:
|
||||||
|
|||||||
1
docs/templates/agents-meta-rules.md
vendored
1
docs/templates/agents-meta-rules.md
vendored
@ -14,6 +14,7 @@ usage: 炼境接入包分发时写入被管理项目 AGENTS.md 的元规则标
|
|||||||
- 项目根目录 `.lian-jing/context.json` 是工具无关的 `project_id` 单一事实来源;客户端项目 UI、用户级插件和全局配置都不能替代它。
|
- 项目根目录 `.lian-jing/context.json` 是工具无关的 `project_id` 单一事实来源;客户端项目 UI、用户级插件和全局配置都不能替代它。
|
||||||
- `.claude/liangjing.json` 仅用于兼容已接入的旧 Claude 项目;发现 legacy-only 状态时,应通过炼境迁移,不应复制或猜测 ID。
|
- `.claude/liangjing.json` 仅用于兼容已接入的旧 Claude 项目;发现 legacy-only 状态时,应通过炼境迁移,不应复制或猜测 ID。
|
||||||
- `.lian-jing/capability-packs.json` 仅描述炼境托管基线的版本与自检。升级必须显式报告版本不符,并且不能覆盖项目自己的业务规则、测试、部署声明或手工 MCP 配置。
|
- `.lian-jing/capability-packs.json` 仅描述炼境托管基线的版本与自检。升级必须显式报告版本不符,并且不能覆盖项目自己的业务规则、测试、部署声明或手工 MCP 配置。
|
||||||
|
- 子项目 agent(Claude、Codex、DSH)遇到炼境分发物的摩擦或验证出跨项目实践时,经同一 MCP 入口 `report_envoy_return` 回传;何时回传与字段规范只写在 lian-jing skill,AGENTS 托管块只留一行指路(skill 按需加载,AGENTS 三端必读)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: lian-jing
|
name: lian-jing
|
||||||
description: 读取炼境项目健康状态,并使用炼境 MCP 的项目管理、Gitea 与交付能力。
|
description: 读取炼境项目健康状态,并使用炼境 MCP 的项目管理、Gitea 与交付能力;遇到炼境模板、约定或工具的摩擦时回传炼境。
|
||||||
---
|
---
|
||||||
|
|
||||||
# 炼境项目技能
|
# 炼境项目技能
|
||||||
@ -17,6 +17,30 @@ description: 读取炼境项目健康状态,并使用炼境 MCP 的项目管
|
|||||||
6. 合并 PR 前调用 `get_delivery_status(project_id)`,向用户展示 PR 编号、完整 head SHA、CI 与 review。只有当前用户明确确认该 PR 与 SHA 后,才调用 `merge_pull_request(project_id, pr_number, expected_head_sha, confirmed=true)`;合并后再次读取交付状态。不得自行推断确认。
|
6. 合并 PR 前调用 `get_delivery_status(project_id)`,向用户展示 PR 编号、完整 head SHA、CI 与 review。只有当前用户明确确认该 PR 与 SHA 后,才调用 `merge_pull_request(project_id, pr_number, expected_head_sha, confirmed=true)`;合并后再次读取交付状态。不得自行推断确认。
|
||||||
7. 生成 CI workflow 时先调用 `plan_cicd_workflow(project_id, workflow_type)`,向用户展示目标文件、变更类型、`content_digest`、工具链来源与所需 secret 名称;只有用户明确确认这份摘要后,才以同一 digest 调用 `apply_cicd_workflow(..., confirmed=true)`。该流程只写项目 workflow,不配置 secret、不触发 CI 或部署;不得通过 MCP 传服务器地址、命令、路径或 secret 值,部署类配置先由用户在炼境 UI 保存。
|
7. 生成 CI workflow 时先调用 `plan_cicd_workflow(project_id, workflow_type)`,向用户展示目标文件、变更类型、`content_digest`、工具链来源与所需 secret 名称;只有用户明确确认这份摘要后,才以同一 digest 调用 `apply_cicd_workflow(..., confirmed=true)`。该流程只写项目 workflow,不配置 secret、不触发 CI 或部署;不得通过 MCP 传服务器地址、命令、路径或 secret 值,部署类配置先由用户在炼境 UI 保存。
|
||||||
8. 需要测试账号或开发变量(登录自验、沙箱 key 等)时,先调用 `list_project_dev_variables` 查找:已配置则用 `read_project_test_account` / `read_project_dev_variable` 单项读取;没有则用 `register_project_test_account` 登记名称、目标系统、非生产环境与用途,并请用户在炼境项目 UI「开发变量」中填写。不得在对话中向用户索要明文密码,不得登记生产账号,不得把读到的值写入受 Git 跟踪的文件。读取因缺少项目认证被拒时,如实报告并改用项目原有配置方式,不得绕过认证。若 lian-jing 工具中根本没有 `list_project_dev_variables`,说明当前客户端连的是全局地址 `/mcp` 而非本项目地址,按下方 `configured` 一步处理。
|
8. 需要测试账号或开发变量(登录自验、沙箱 key 等)时,先调用 `list_project_dev_variables` 查找:已配置则用 `read_project_test_account` / `read_project_dev_variable` 单项读取;没有则用 `register_project_test_account` 登记名称、目标系统、非生产环境与用途,并请用户在炼境项目 UI「开发变量」中填写。不得在对话中向用户索要明文密码,不得登记生产账号,不得把读到的值写入受 Git 跟踪的文件。读取因缺少项目认证被拒时,如实报告并改用项目原有配置方式,不得绕过认证。若 lian-jing 工具中根本没有 `list_project_dev_variables`,说明当前客户端连的是全局地址 `/mcp` 而非本项目地址,按下方 `configured` 一步处理。
|
||||||
|
9. 遇到体系级摩擦时,按下方「遇到摩擦时回传炼境」调用 `report_envoy_return`。
|
||||||
|
|
||||||
|
## 遇到摩擦时回传炼境
|
||||||
|
|
||||||
|
炼境根据各项目回传的摩擦改进分发给所有项目的模板、约定与工具。Claude Code、Codex、DSH(DeepSeek Harness)都用同一个入口:`report_envoy_return`。回传只追加一条信号,不改任何代码或配置,所以不必逐条征得用户同意;回传后在给用户的总结里列出编号即可。
|
||||||
|
|
||||||
|
以下情况直接回传:
|
||||||
|
|
||||||
|
- 炼境分发的东西出错或碍事:AGENTS.md 托管块、本技能、`/envoy`、lefthook、CONVENTIONS、MCP 工具的行为或返回。即使只在本项目复现也要回传,`scope` 填 `this_project`
|
||||||
|
- 用户说「回传」「告诉炼境」「记给炼境」
|
||||||
|
|
||||||
|
以下情况先过硬门槛:有经验的工程师会不会事先想到这一点?会,或者只在本项目的特定组合下才成立,就不回传,留在本项目处理。
|
||||||
|
|
||||||
|
- 某种做法有具体代价或验证结果,并且换个项目也成立
|
||||||
|
- 会影响多个项目的体系改进假设(即使还没验证)
|
||||||
|
|
||||||
|
回传方法:
|
||||||
|
|
||||||
|
1. 每条洞察单独调用一次 `report_envoy_return`:`kind`(template_bug / validated_practice / workflow_hypothesis)、`confidence`、`scope`(this_project / all_projects / project_type:<类型>,按复现范围填)、`content`(一句话写清现象和证据)、`suggestion`(fix_template / update_convention / log_for_review)、`client`(claude=Claude Code,codex=Codex,dsh=DeepSeek Harness,其他填 other)。项目地址已绑定 `project_id`,可以省略;只连到全局地址时,显式传 `.lian-jing/context.json` 里的 `project_id`。
|
||||||
|
2. 只有能说出至少一条具体代价或验证结果(如「返工 2 轮」「在 X 场景实测有效」)时,`confidence` 才填 `confirmed`;否则填 `under_validation`。
|
||||||
|
3. 不得写入凭据、密钥、账号、内网地址或生产数据:回传内容会同时写进本项目的 `.blueprint/notes-archive.jsonl`,随项目进入 Git。
|
||||||
|
4. 同一会话里已经回传过的洞察,不要换个说法再报一次。把返回的编号 `#note_id` 写进给用户的总结,例如「已回传炼境 N 条(#编号)」。
|
||||||
|
5. 子项目只回传、不处置:`resolve_envoy_return`、`list_envoy_returns` 属于炼境控制面会话,即使在工具列表里看到也不要调用。
|
||||||
|
6. 工具列表里没有 `report_envoy_return` 时,按下方降级顺序排查。仍不可用,就把待回传内容按上述字段整理成文本交给用户,由用户转交炼境会话;不要改用 `append_project_note` 等其他写入工具代替。
|
||||||
|
|
||||||
如果 MCP 未出现,按以下顺序降级,不猜测状态:
|
如果 MCP 未出现,按以下顺序降级,不猜测状态:
|
||||||
|
|
||||||
|
|||||||
@ -4,12 +4,16 @@
|
|||||||
|
|
||||||
你是**炼境使者**——在子项目会话里代表炼境执行现场感知与回传。
|
你是**炼境使者**——在子项目会话里代表炼境执行现场感知与回传。
|
||||||
|
|
||||||
|
回传入口是 MCP 工具 `report_envoy_return`(Claude、Codex、DSH 共用),字段规范以 lian-jing skill「遇到摩擦时回传炼境」一节为准。本命令负责会话结束时的扫描、分类和逐条回传。
|
||||||
|
|
||||||
直接执行以下步骤,无需用户提供任何输入。
|
直接执行以下步骤,无需用户提供任何输入。
|
||||||
|
|
||||||
## Step 1:读取项目身份
|
## Step 1:确认项目身份与回传入口
|
||||||
|
|
||||||
用 Read 工具读取 `.claude/liangjing.json`,获取 `project_id` 和项目名称。
|
用 Read 工具读取 `.lian-jing/context.json`,获取 `project_id`。如果只有旧的 `.claude/liangjing.json`,就用它的 `project_id`,并提醒用户在炼境调用 `inject_mcp` 迁移。
|
||||||
若文件不存在:输出 `⚠️ 未找到 .claude/liangjing.json,该项目未接入炼境` 并停止。
|
两个文件都没有:输出 `⚠️ 未找到 .lian-jing/context.json,该项目未接入炼境` 并停止。
|
||||||
|
|
||||||
|
确认 `mcp__lian-jing__report_envoy_return` 在当前会话可用;不可用时按 lian-jing skill 的降级顺序排查,并使用 Step 4 的降级方式。
|
||||||
|
|
||||||
## Step 2:扫描本次会话
|
## Step 2:扫描本次会话
|
||||||
|
|
||||||
@ -24,6 +28,7 @@
|
|||||||
- 会(常识性设计知识,如"外键优于业务字段关联")→ 一律归「留本项目」,不得回传
|
- 会(常识性设计知识,如"外键优于业务字段关联")→ 一律归「留本项目」,不得回传
|
||||||
- 触发条件过窄的 corner case(复现依赖本项目特定组合)→ 同样归「留本项目」
|
- 触发条件过窄的 corner case(复现依赖本项目特定组合)→ 同样归「留本项目」
|
||||||
- 只有**非显而易见、且跨项目可复用**的教训才有资格进入下方分类
|
- 只有**非显而易见、且跨项目可复用**的教训才有资格进入下方分类
|
||||||
|
- **例外**:炼境分发物(AGENTS 托管块、lian-jing skill、本命令、lefthook、CONVENTIONS、MCP 工具)本身出错或碍事,即使只在本项目复现,也直接归「带回炼境」,`scope` 填 `this_project`
|
||||||
|
|
||||||
**📦 带回炼境**(通过硬门槛后,满足任一条):
|
**📦 带回炼境**(通过硬门槛后,满足任一条):
|
||||||
- 炼境分发的模板 / 约定 / 接入包有 bug 或可改进
|
- 炼境分发的模板 / 约定 / 接入包有 bug 或可改进
|
||||||
@ -37,26 +42,21 @@
|
|||||||
|
|
||||||
## Step 4:发回炼境
|
## Step 4:发回炼境
|
||||||
|
|
||||||
对每条「带回」洞察,单独调用一次 `mcp__lian-jing__append_project_note`。
|
对每条「带回」洞察,单独调用一次 `mcp__lian-jing__report_envoy_return`。本会话中途已经回传过的洞察不要换个说法再报,摘要里沿用原编号。
|
||||||
|
|
||||||
> **MCP 不可用时的降级**:若 `mcp__lian-jing__append_project_note` 报连接错误,重试 1~2 次仍失败,则将所有待回传内容按 Step 4 格式整理成一段文本,输出到对话让用户看到,并提示:「MCP 断连,请复制以上内容到炼境会话手动执行 `/envoy` 回传,或重连后重跑本命令」。
|
- `client`:填 `claude`
|
||||||
- `project_id`:来自 Step 1
|
- `kind` / `confidence` / `scope` / `content` / `suggestion`:按 lian-jing skill 规范填写;`content` 用一句话写清现象和证据
|
||||||
- `content` 格式(严格遵守):
|
- `project_id`:项目地址已绑定,可以省略;连的是全局地址时,显式传 Step 1 读到的 `project_id`
|
||||||
|
|
||||||
【炼境使者回传】来源: {项目名}
|
|
||||||
类型: 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 场景实测有效")。
|
**`confirmed` 的门槛**:必须能陈述至少一条具体代价或验证结果(如"导致 N% 时间损耗"、"在 X 场景实测有效")。
|
||||||
无法陈述具体证据的,一律标 `under_validation`,不得标 `confirmed`。
|
无法陈述具体证据的,一律标 `under_validation`,不得标 `confirmed`。回传内容不得包含凭据、密钥、账号、内网地址或生产数据(内容会同时写进本项目 `.blueprint/notes-archive.jsonl`,随项目进入 Git)。
|
||||||
|
|
||||||
|
> **MCP 不可用时的降级**:若 `report_envoy_return` 报连接错误,原地重试 1~2 次(同内容重复调用不会重复写入)。仍失败时,把所有待回传内容按上述字段整理成一段文本输出到对话,并提示:「MCP 断连,请把以上内容转交炼境会话回传,或重连后重跑本命令」。
|
||||||
|
|
||||||
## Step 5:输出摘要
|
## Step 5:输出摘要
|
||||||
|
|
||||||
📦 已发回炼境 N 条
|
📦 已发回炼境 N 条
|
||||||
· {标题}({置信度} · {影响范围})
|
· #{编号} {标题}({置信度} · {影响范围})
|
||||||
|
|
||||||
🏠 留本项目 M 条
|
🏠 留本项目 M 条
|
||||||
· {标题} — {一句说明}
|
· {标题} — {一句说明}
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
"packs": [
|
"packs": [
|
||||||
{
|
{
|
||||||
"pack_id": "base-onboarding",
|
"pack_id": "base-onboarding",
|
||||||
"version": "1.6.0",
|
"version": "1.7.0",
|
||||||
"supported_clients": ["codex", "claude", "dsh"],
|
"supported_clients": ["codex", "claude", "dsh"],
|
||||||
"project_artifacts": [
|
"project_artifacts": [
|
||||||
"AGENTS.md",
|
"AGENTS.md",
|
||||||
|
|||||||
@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
<!-- TODO: 列出 README / ARCHITECTURE.md / 其他关键文档及用途。 -->
|
<!-- TODO: 列出 README / ARCHITECTURE.md / 其他关键文档及用途。 -->
|
||||||
|
|
||||||
<!-- ONBOARDING_MANAGED_START version="1.9.0" -->
|
<!-- ONBOARDING_MANAGED_START version="1.10.0" -->
|
||||||
> ⚠️ 本块由炼境版本化托管,升级时**整块原位覆盖**——项目定制请写到块外,块内手改必丢(2026-07-15 炼境自身实锤丢失 5 段定制后新增本警示)。
|
> ⚠️ 本块由炼境版本化托管,升级时**整块原位覆盖**——项目定制请写到块外,块内手改必丢(2026-07-15 炼境自身实锤丢失 5 段定制后新增本警示)。
|
||||||
|
|
||||||
## 核心开发约束
|
## 核心开发约束
|
||||||
@ -72,6 +72,7 @@
|
|||||||
- `.claude/liangjing.json` 只是旧项目的 Claude 兼容投影。只发现它时,调用 `inject_mcp(project_id)` 迁移,而不是手工复制或猜测 ID。
|
- `.claude/liangjing.json` 只是旧项目的 Claude 兼容投影。只发现它时,调用 `inject_mcp(project_id)` 迁移,而不是手工复制或猜测 ID。
|
||||||
- `.lian-jing/capability-packs.json` 记录炼境托管基线的版本。`verify_onboarding(project_id)` 报告 `legacy`、`version_mismatch` 等状态时,按其 `detail` 中的升级提示处理。
|
- `.lian-jing/capability-packs.json` 记录炼境托管基线的版本。`verify_onboarding(project_id)` 报告 `legacy`、`version_mismatch` 等状态时,按其 `detail` 中的升级提示处理。
|
||||||
- `apply_onboarding_pack(project_id)` 只更新炼境明确托管的接入包文件;项目自己的业务规则、测试、部署声明和手工定义的同名 MCP server 仍归项目所有,不能被升级覆盖。
|
- `apply_onboarding_pack(project_id)` 只更新炼境明确托管的接入包文件;项目自己的业务规则、测试、部署声明和手工定义的同名 MCP server 仍归项目所有,不能被升级覆盖。
|
||||||
|
- 遇到炼境分发的模板、约定或 MCP 工具的摩擦,或验证出跨项目可复用的实践时,调用 `report_envoy_return` 回传炼境(只追加信号,不必逐条征得用户同意;Claude 会话结束时也可跑 `/envoy`);何时回传、字段怎么填见 lian-jing skill「遇到摩擦时回传炼境」一节。
|
||||||
|
|
||||||
### Node/pnpm 工具链
|
### Node/pnpm 工具链
|
||||||
|
|
||||||
|
|||||||
@ -26,7 +26,7 @@ const TMPL_CAPABILITY_PACKS: &str =
|
|||||||
const TMPL_MISE: &str = include_str!("../../resources/onboarding/mise.toml.tmpl");
|
const TMPL_MISE: &str = include_str!("../../resources/onboarding/mise.toml.tmpl");
|
||||||
const PROJECT_CONTEXT_SCHEMA_VERSION: u64 = 1;
|
const PROJECT_CONTEXT_SCHEMA_VERSION: u64 = 1;
|
||||||
const CAPABILITY_PACK_SCHEMA_VERSION: u64 = 1;
|
const CAPABILITY_PACK_SCHEMA_VERSION: u64 = 1;
|
||||||
const BASE_ONBOARDING_PACK_VERSION: &str = "1.6.0";
|
const BASE_ONBOARDING_PACK_VERSION: &str = "1.7.0";
|
||||||
const TOOLCHAIN_PACK_VERSION: &str = "1.0.0";
|
const TOOLCHAIN_PACK_VERSION: &str = "1.0.0";
|
||||||
|
|
||||||
/// 单个模板的分发结果。
|
/// 单个模板的分发结果。
|
||||||
|
|||||||
@ -560,6 +560,20 @@ pub(crate) fn migrate(conn: &rusqlite::Connection) {
|
|||||||
)
|
)
|
||||||
.expect("create project_notes table");
|
.expect("create project_notes table");
|
||||||
|
|
||||||
|
// 使者回传回执:每条「【炼境使者回传】」笔记至多一条处置记录(允许 upsert 更正)
|
||||||
|
conn.execute_batch(
|
||||||
|
"
|
||||||
|
CREATE TABLE IF NOT EXISTS envoy_dispositions (
|
||||||
|
note_id INTEGER PRIMARY KEY REFERENCES project_notes(id) ON DELETE CASCADE,
|
||||||
|
disposition TEXT NOT NULL,
|
||||||
|
reference TEXT NOT NULL DEFAULT '',
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
resolved_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||||
|
);
|
||||||
|
",
|
||||||
|
)
|
||||||
|
.expect("create envoy_dispositions table");
|
||||||
|
|
||||||
// 用户项目个人备忘(自由文本待办,Claude 不感知)
|
// 用户项目个人备忘(自由文本待办,Claude 不感知)
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
"
|
"
|
||||||
|
|||||||
740
src-tauri/src/envoy.rs
Normal file
740
src-tauri/src/envoy.rs
Normal file
@ -0,0 +1,740 @@
|
|||||||
|
//! 使者回传:子项目 agent(Claude / Codex / DSH)把体系级洞察回传炼境,炼境处置后留回执。
|
||||||
|
//!
|
||||||
|
//! 回传仍存 `project_notes`,内容以 [`ENVOY_PREFIX`] 开头(B4 沉默项目统计按前缀匹配,格式保持兼容);
|
||||||
|
//! 回执存 `envoy_dispositions`(note_id 主键,允许 upsert 更正)。
|
||||||
|
//! 「回传只进不出」是本模块要修的病灶:列表只列未处置,处置必须写明方式与理由。
|
||||||
|
//! 所有函数由调用方注入连接(规则:业务函数不内部取池),便于 in-memory 测试。
|
||||||
|
|
||||||
|
use rusqlite::{params, Connection, OptionalExtension};
|
||||||
|
|
||||||
|
pub const ENVOY_PREFIX: &str = "【炼境使者回传】";
|
||||||
|
|
||||||
|
pub const KINDS: &[&str] = &["template_bug", "validated_practice", "workflow_hypothesis"];
|
||||||
|
pub const CONFIDENCES: &[&str] = &["confirmed", "under_validation"];
|
||||||
|
pub const SUGGESTIONS: &[&str] = &["fix_template", "update_convention", "log_for_review"];
|
||||||
|
pub const CLIENTS: &[&str] = &["claude", "codex", "dsh", "other"];
|
||||||
|
/// landed=有落点证据;carded=已转任务卡;watching=待观察/仅归档;obsolete=已过时;rejected=不由炼境处理
|
||||||
|
pub const DISPOSITIONS: &[&str] = &["landed", "carded", "watching", "obsolete", "rejected"];
|
||||||
|
|
||||||
|
const MAX_CONTENT_CHARS: usize = 1000;
|
||||||
|
/// 列表单次最多展示条数;超出时明确报告未展示条数,绝不静默截断
|
||||||
|
const LIST_DISPLAY_LIMIT: usize = 50;
|
||||||
|
|
||||||
|
/// 一条待写入的回传(字段语义与 /envoy 模板既有格式一致,新增 client)
|
||||||
|
pub struct EnvoyReport<'a> {
|
||||||
|
pub kind: &'a str,
|
||||||
|
pub confidence: &'a str,
|
||||||
|
pub scope: &'a str,
|
||||||
|
pub content: &'a str,
|
||||||
|
pub suggestion: &'a str,
|
||||||
|
pub client: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub struct ReportOutcome {
|
||||||
|
pub note_id: i64,
|
||||||
|
/// 同项目已有内容完全相同且未处置的回传(多为断连重试),未重复写入
|
||||||
|
pub duplicate: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_one_of(field: &str, value: &str, allowed: &[&str]) -> Result<(), String> {
|
||||||
|
if allowed.contains(&value) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!(
|
||||||
|
"{field} 取值无效:{value:?},只允许 {}",
|
||||||
|
allowed.join(" | ")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 回传格式是逐行「键: 值」,字段内换行会破坏格式——压成单行
|
||||||
|
fn single_line(text: &str) -> String {
|
||||||
|
text.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|line| !line.is_empty())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_scope(scope: &str) -> Result<(), String> {
|
||||||
|
let valid = matches!(scope, "this_project" | "all_projects")
|
||||||
|
|| scope
|
||||||
|
.strip_prefix("project_type:")
|
||||||
|
.is_some_and(|kind| !kind.trim().is_empty());
|
||||||
|
if valid {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!(
|
||||||
|
"scope 取值无效:{scope:?},只允许 this_project | all_projects | project_type:<类型>"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 兜底拦截明显的凭据(私钥头、常见令牌前缀后接长串)。高精度、低召回:
|
||||||
|
/// 回传会双写进子项目 `.blueprint/notes-archive.jsonl` 随项目进 Git,其余情形靠工具描述约束。
|
||||||
|
fn looks_like_secret(text: &str) -> bool {
|
||||||
|
const TOKEN_PREFIXES: &[&str] = &[
|
||||||
|
"sk-",
|
||||||
|
"ghp_",
|
||||||
|
"gho_",
|
||||||
|
"ghs_",
|
||||||
|
"github_pat_",
|
||||||
|
"glpat-",
|
||||||
|
"xoxb-",
|
||||||
|
"xoxp-",
|
||||||
|
"AKIA",
|
||||||
|
];
|
||||||
|
if text.contains("PRIVATE KEY-----") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let token_char = |c: char| c.is_ascii_alphanumeric() || c == '-' || c == '_';
|
||||||
|
text.split(|c: char| !(token_char(c) || c == '.'))
|
||||||
|
.any(|word| {
|
||||||
|
let prefixed = TOKEN_PREFIXES.iter().any(|prefix| {
|
||||||
|
word.strip_prefix(prefix)
|
||||||
|
.is_some_and(|rest| rest.len() >= 16 && rest.chars().all(token_char))
|
||||||
|
});
|
||||||
|
let jwt = word.starts_with("eyJ") && word.len() >= 30 && word.matches('.').count() == 2;
|
||||||
|
prefixed || jwt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project_display_name(conn: &Connection, project_id: &str) -> Result<String, String> {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT COALESCE(pp.name, pw.id)
|
||||||
|
FROM project_workspaces pw
|
||||||
|
LEFT JOIN project_profiles pp ON pw.profile_id = pp.id
|
||||||
|
WHERE pw.id = ?1",
|
||||||
|
[project_id],
|
||||||
|
|row| row.get::<_, String>(0),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.ok_or_else(|| format!("项目 {project_id} 不存在"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 写入一条回传,返回笔记编号。调用方负责双写 notes 档案(与 append_project_note 同一路径)。
|
||||||
|
pub fn report(
|
||||||
|
conn: &Connection,
|
||||||
|
project_id: &str,
|
||||||
|
input: &EnvoyReport,
|
||||||
|
) -> Result<ReportOutcome, String> {
|
||||||
|
ensure_one_of("kind", input.kind, KINDS)?;
|
||||||
|
ensure_one_of("confidence", input.confidence, CONFIDENCES)?;
|
||||||
|
ensure_one_of("suggestion", input.suggestion, SUGGESTIONS)?;
|
||||||
|
ensure_one_of("client", input.client, CLIENTS)?;
|
||||||
|
let scope = single_line(input.scope);
|
||||||
|
validate_scope(&scope)?;
|
||||||
|
let content = single_line(input.content);
|
||||||
|
if content.is_empty() {
|
||||||
|
return Err("content 不能为空:用一句话写清摩擦或实践及其证据".to_string());
|
||||||
|
}
|
||||||
|
if content.chars().count() > MAX_CONTENT_CHARS {
|
||||||
|
return Err(format!(
|
||||||
|
"content 超过 {MAX_CONTENT_CHARS} 字:回传只写一句话结论与证据,细节留在本项目"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if looks_like_secret(&content) {
|
||||||
|
return Err(
|
||||||
|
"content 疑似包含凭据(私钥或访问令牌),已拒绝写入:回传只描述现象,不附带任何凭据"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if project_id.trim().is_empty() {
|
||||||
|
return Err("project_id 必填:项目地址会自动绑定;连到全局地址时传 .lian-jing/context.json 中的 project_id".to_string());
|
||||||
|
}
|
||||||
|
let name = project_display_name(conn, project_id)?;
|
||||||
|
let note = format!(
|
||||||
|
"{ENVOY_PREFIX}来源: {name}\n客户端: {client}\n类型: {kind}\n置信度: {confidence}\n影响范围: {scope}\n内容: {content}\n建议炼境: {suggestion}",
|
||||||
|
client = input.client,
|
||||||
|
kind = input.kind,
|
||||||
|
confidence = input.confidence,
|
||||||
|
suggestion = input.suggestion,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 幂等只针对断连重试:同项目、1 天内、未处置、首行之后逐字相同(首行含项目名,改名不影响)。
|
||||||
|
// 先查后插放进 IMMEDIATE 事务,并发重试不会各写一条。
|
||||||
|
let body = ¬e[note.find('\n').unwrap_or(note.len())..];
|
||||||
|
let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let existing: Option<i64> = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT pn.id FROM project_notes pn
|
||||||
|
WHERE pn.project_id = ?1
|
||||||
|
AND substr(pn.content, instr(pn.content, char(10))) = ?2
|
||||||
|
AND pn.created_at >= datetime('now', 'localtime', '-1 day')
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM envoy_dispositions ed WHERE ed.note_id = pn.id)
|
||||||
|
ORDER BY pn.id LIMIT 1",
|
||||||
|
params![project_id, body],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let outcome = match existing {
|
||||||
|
Some(note_id) => ReportOutcome {
|
||||||
|
note_id,
|
||||||
|
duplicate: true,
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO project_notes (project_id, source, content) VALUES (?1, 'mcp', ?2)",
|
||||||
|
params![project_id, note],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
ReportOutcome {
|
||||||
|
note_id: tx.last_insert_rowid(),
|
||||||
|
duplicate: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tx.commit().map_err(|e| e.to_string())?;
|
||||||
|
Ok(outcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 处置一条回传(upsert,允许更正),返回此前的处置方式(首次处置为 None)。
|
||||||
|
pub fn resolve(
|
||||||
|
conn: &Connection,
|
||||||
|
note_id: i64,
|
||||||
|
disposition: &str,
|
||||||
|
reference: &str,
|
||||||
|
reason: &str,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
ensure_one_of("disposition", disposition, DISPOSITIONS)?;
|
||||||
|
let reference = single_line(reference);
|
||||||
|
let reason = single_line(reason);
|
||||||
|
if reason.is_empty() {
|
||||||
|
return Err("reason 不能为空:写明处置依据".to_string());
|
||||||
|
}
|
||||||
|
if matches!(disposition, "landed" | "carded") && reference.is_empty() {
|
||||||
|
return Err(format!(
|
||||||
|
"disposition={disposition} 必须填写 reference(提交 SHA、文件路径或任务卡标题)"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let content: Option<String> = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT content FROM project_notes WHERE id = ?1",
|
||||||
|
[note_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
match content {
|
||||||
|
None => return Err(format!("笔记 #{note_id} 不存在")),
|
||||||
|
Some(content) if !content.starts_with(ENVOY_PREFIX) => {
|
||||||
|
return Err(format!("笔记 #{note_id} 不是使者回传,不能处置"))
|
||||||
|
}
|
||||||
|
Some(_) => {}
|
||||||
|
}
|
||||||
|
let previous: Option<String> = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT disposition FROM envoy_dispositions WHERE note_id = ?1",
|
||||||
|
[note_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO envoy_dispositions (note_id, disposition, reference, reason)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)
|
||||||
|
ON CONFLICT(note_id) DO UPDATE SET
|
||||||
|
disposition = excluded.disposition,
|
||||||
|
reference = excluded.reference,
|
||||||
|
reason = excluded.reason,
|
||||||
|
resolved_at = datetime('now', 'localtime')",
|
||||||
|
params![note_id, disposition, reference, reason],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(previous)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct EnvoyReturn {
|
||||||
|
pub id: i64,
|
||||||
|
pub project: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub content: String,
|
||||||
|
/// (disposition, reference, reason, resolved_at);未处置为 None
|
||||||
|
pub resolution: Option<(String, String, String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 全部使者回传(新→旧)。LEFT JOIN:项目登记已删的回传也要列出,不得静默丢失。
|
||||||
|
pub fn all_returns(conn: &Connection) -> Result<Vec<EnvoyReturn>, String> {
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT pn.id, COALESCE(pp.name, pn.project_id), pn.content, COALESCE(pn.created_at, ''),
|
||||||
|
ed.disposition, ed.reference, ed.reason, ed.resolved_at
|
||||||
|
FROM project_notes pn
|
||||||
|
LEFT JOIN project_workspaces pw ON pn.project_id = pw.id
|
||||||
|
LEFT JOIN project_profiles pp ON pw.profile_id = pp.id
|
||||||
|
LEFT JOIN envoy_dispositions ed ON ed.note_id = pn.id
|
||||||
|
WHERE pn.content LIKE ?1
|
||||||
|
ORDER BY pn.created_at DESC, pn.id DESC",
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([format!("{ENVOY_PREFIX}%")], |row| {
|
||||||
|
let disposition: Option<String> = row.get(4)?;
|
||||||
|
Ok(EnvoyReturn {
|
||||||
|
id: row.get(0)?,
|
||||||
|
project: row.get(1)?,
|
||||||
|
content: row.get(2)?,
|
||||||
|
created_at: row.get(3)?,
|
||||||
|
resolution: match disposition {
|
||||||
|
Some(disposition) => Some((disposition, row.get(5)?, row.get(6)?, row.get(7)?)),
|
||||||
|
None => None,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn date_of(timestamp: &str) -> &str {
|
||||||
|
×tamp[..timestamp.len().min(10)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 回传正文里的「内容:」一行(列已处置时用作摘要)
|
||||||
|
fn content_summary(content: &str) -> String {
|
||||||
|
let line = content
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| line.strip_prefix("内容:"))
|
||||||
|
.map(str::trim)
|
||||||
|
.unwrap_or_else(|| content.lines().next().unwrap_or_default());
|
||||||
|
let mut summary: String = line.chars().take(80).collect();
|
||||||
|
if line.chars().count() > 80 {
|
||||||
|
summary.push('…');
|
||||||
|
}
|
||||||
|
summary
|
||||||
|
}
|
||||||
|
|
||||||
|
/// list_envoy_returns 的文本输出:默认只列未处置(带编号),附已处置计数;超出展示上限时明说
|
||||||
|
pub fn format_returns(returns: &[EnvoyReturn], include_resolved: bool) -> String {
|
||||||
|
let (resolved, pending): (Vec<&EnvoyReturn>, Vec<&EnvoyReturn>) =
|
||||||
|
returns.iter().partition(|item| item.resolution.is_some());
|
||||||
|
let mut out = if pending.is_empty() {
|
||||||
|
format!(
|
||||||
|
"📭 暂无待处置的炼境使者回传(已处置 {} 条)\n",
|
||||||
|
resolved.len()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"📬 待处置使者回传 {} 条(已处置 {} 条)\n\n",
|
||||||
|
pending.len(),
|
||||||
|
resolved.len()
|
||||||
|
)
|
||||||
|
};
|
||||||
|
for item in pending.iter().take(LIST_DISPLAY_LIMIT) {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"---\n**#{}** | **来源**: {} | **时间**: {}\n{}\n\n",
|
||||||
|
item.id,
|
||||||
|
item.project,
|
||||||
|
date_of(&item.created_at),
|
||||||
|
item.content
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if pending.len() > LIST_DISPLAY_LIMIT {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"---\n⚠️ 另有 {} 条更早的待处置回传未展示——处置上面的条目后再次调用即可看到\n\n",
|
||||||
|
pending.len() - LIST_DISPLAY_LIMIT
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if include_resolved && !resolved.is_empty() {
|
||||||
|
out.push_str(&format!("---\n### 已处置({} 条)\n\n", resolved.len()));
|
||||||
|
for item in &resolved {
|
||||||
|
if let Some((disposition, reference, reason, resolved_at)) = &item.resolution {
|
||||||
|
let reference = if reference.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("|落点: {reference}")
|
||||||
|
};
|
||||||
|
out.push_str(&format!(
|
||||||
|
"- **#{}** {} {} → **{}**{}|理由: {}({})\n {}\n",
|
||||||
|
item.id,
|
||||||
|
item.project,
|
||||||
|
date_of(&item.created_at),
|
||||||
|
disposition,
|
||||||
|
reference,
|
||||||
|
reason,
|
||||||
|
date_of(resolved_at),
|
||||||
|
content_summary(&item.content)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
if !pending.is_empty() {
|
||||||
|
out.push_str(
|
||||||
|
"---\n💡 处置:resolve_envoy_return(note_id, disposition, reference, reason)——\
|
||||||
|
landed=有落点证据、carded=已转任务卡(两者 reference 必填:提交 SHA / 文件 / 卡片标题)、\
|
||||||
|
watching=待观察或仅归档、obsolete=已过时、rejected=不由炼境处理;可重复调用更正",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn seed_project(conn: &Connection, workspace_id: &str, name: &str) {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO project_profiles (id, name) VALUES (?1, ?2)",
|
||||||
|
params![format!("profile-{workspace_id}"), name],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO project_workspaces (id, profile_id) VALUES (?1, ?2)",
|
||||||
|
params![workspace_id, format!("profile-{workspace_id}")],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn report_one(
|
||||||
|
conn: &Connection,
|
||||||
|
project_id: &str,
|
||||||
|
content: &str,
|
||||||
|
client: &str,
|
||||||
|
) -> ReportOutcome {
|
||||||
|
report(
|
||||||
|
conn,
|
||||||
|
project_id,
|
||||||
|
&EnvoyReport {
|
||||||
|
kind: "template_bug",
|
||||||
|
confidence: "confirmed",
|
||||||
|
scope: "all_projects",
|
||||||
|
content,
|
||||||
|
suggestion: "fix_template",
|
||||||
|
client,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pending_ids(conn: &Connection) -> Vec<i64> {
|
||||||
|
all_returns(conn)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|item| item.resolution.is_none())
|
||||||
|
.map(|item| item.id)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn report_writes_prefixed_note_with_client_line() {
|
||||||
|
let conn = crate::db::conn_with_schema();
|
||||||
|
seed_project(&conn, "ent", "enterprise-system");
|
||||||
|
let outcome = report_one(
|
||||||
|
&conn,
|
||||||
|
"ent",
|
||||||
|
"模板 X 在 Y 场景\n漏了 Z(实测 2 次返工)",
|
||||||
|
"codex",
|
||||||
|
);
|
||||||
|
assert!(!outcome.duplicate);
|
||||||
|
|
||||||
|
let stored: String = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT content FROM project_notes WHERE id = ?1",
|
||||||
|
[outcome.note_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(stored.starts_with("【炼境使者回传】来源: enterprise-system\n"));
|
||||||
|
assert!(stored.contains("\n客户端: codex\n"));
|
||||||
|
assert!(
|
||||||
|
stored.contains("\n内容: 模板 X 在 Y 场景 漏了 Z(实测 2 次返工)\n"),
|
||||||
|
"字段内换行须压成单行: {stored}"
|
||||||
|
);
|
||||||
|
assert!(stored.ends_with("\n建议炼境: fix_template"));
|
||||||
|
// B4 沉默项目统计按前缀 LIKE 匹配,新格式必须仍命中
|
||||||
|
let matched: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM project_notes WHERE content LIKE '【炼境使者回传】%'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(matched, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn report_rejects_invalid_fields_and_unknown_project() {
|
||||||
|
let conn = crate::db::conn_with_schema();
|
||||||
|
seed_project(&conn, "ent", "enterprise-system");
|
||||||
|
let base = EnvoyReport {
|
||||||
|
kind: "template_bug",
|
||||||
|
confidence: "confirmed",
|
||||||
|
scope: "all_projects",
|
||||||
|
content: "一句话",
|
||||||
|
suggestion: "fix_template",
|
||||||
|
client: "dsh",
|
||||||
|
};
|
||||||
|
for bad in [
|
||||||
|
EnvoyReport {
|
||||||
|
kind: "bug",
|
||||||
|
..base
|
||||||
|
},
|
||||||
|
EnvoyReport {
|
||||||
|
confidence: "sure",
|
||||||
|
..base
|
||||||
|
},
|
||||||
|
EnvoyReport {
|
||||||
|
scope: "everywhere",
|
||||||
|
..base
|
||||||
|
},
|
||||||
|
EnvoyReport {
|
||||||
|
scope: "project_type: ",
|
||||||
|
..base
|
||||||
|
},
|
||||||
|
EnvoyReport {
|
||||||
|
suggestion: "fix",
|
||||||
|
..base
|
||||||
|
},
|
||||||
|
EnvoyReport {
|
||||||
|
client: "cursor",
|
||||||
|
..base
|
||||||
|
},
|
||||||
|
EnvoyReport {
|
||||||
|
content: " \n ",
|
||||||
|
..base
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
assert!(report(&conn, "ent", &bad).is_err());
|
||||||
|
}
|
||||||
|
let long = "长".repeat(MAX_CONTENT_CHARS + 1);
|
||||||
|
assert!(report(
|
||||||
|
&conn,
|
||||||
|
"ent",
|
||||||
|
&EnvoyReport {
|
||||||
|
content: &long,
|
||||||
|
..base
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
assert!(report(&conn, "ghost", &base)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("不存在"));
|
||||||
|
assert!(report(
|
||||||
|
&conn,
|
||||||
|
"ent",
|
||||||
|
&EnvoyReport {
|
||||||
|
scope: "project_type:capacitor_android",
|
||||||
|
..base
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.is_ok());
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM project_notes", [], |row| row.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 1, "非法输入不得落库");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identical_retry_is_idempotent_until_resolved() {
|
||||||
|
let conn = crate::db::conn_with_schema();
|
||||||
|
seed_project(&conn, "ent", "enterprise-system");
|
||||||
|
let first = report_one(&conn, "ent", "断连重试场景", "claude");
|
||||||
|
let retry = report_one(&conn, "ent", "断连重试场景", "claude");
|
||||||
|
assert_eq!(
|
||||||
|
retry,
|
||||||
|
ReportOutcome {
|
||||||
|
note_id: first.note_id,
|
||||||
|
duplicate: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 已处置后再次出现同样摩擦 = 复发信号,必须新开一条
|
||||||
|
resolve(&conn, first.note_id, "watching", "", "待观察").unwrap();
|
||||||
|
let recurrence = report_one(&conn, "ent", "断连重试场景", "claude");
|
||||||
|
assert!(!recurrence.duplicate);
|
||||||
|
assert_ne!(recurrence.note_id, first.note_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retry_dedup_survives_rename_but_not_the_one_day_window() {
|
||||||
|
let conn = crate::db::conn_with_schema();
|
||||||
|
seed_project(&conn, "ent", "enterprise-system");
|
||||||
|
let first = report_one(&conn, "ent", "改名后重试", "codex");
|
||||||
|
conn.execute("UPDATE project_profiles SET name = '企业系统'", [])
|
||||||
|
.unwrap();
|
||||||
|
let retry = report_one(&conn, "ent", "改名后重试", "codex");
|
||||||
|
assert_eq!(retry.note_id, first.note_id, "首行项目名不参与去重");
|
||||||
|
|
||||||
|
// 超过 1 天的同内容未处置回传 = 摩擦复发,不能被幂等吞掉(否则沉默项目统计也看不到)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE project_notes SET created_at = datetime('now', 'localtime', '-3 days')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let later = report_one(&conn, "ent", "改名后重试", "codex");
|
||||||
|
assert!(!later.duplicate);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn report_refuses_obvious_credentials() {
|
||||||
|
let conn = crate::db::conn_with_schema();
|
||||||
|
seed_project(&conn, "ent", "enterprise-system");
|
||||||
|
let input = |content| EnvoyReport {
|
||||||
|
kind: "template_bug",
|
||||||
|
confidence: "confirmed",
|
||||||
|
scope: "all_projects",
|
||||||
|
content,
|
||||||
|
suggestion: "fix_template",
|
||||||
|
client: "codex",
|
||||||
|
};
|
||||||
|
for leaked in [
|
||||||
|
"部署失败,token=ghp_abcdefghijklmnopqrstuvwxyz0123",
|
||||||
|
"环境变量 OPENAI_API_KEY=sk-proj-AbCdEfGhIjKlMnOpQrSt 不生效",
|
||||||
|
"粘了 -----BEGIN OPENSSH PRIVATE KEY----- 到 Secret",
|
||||||
|
"header Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2lnbmF0dXJlLXZhbHVl",
|
||||||
|
] {
|
||||||
|
let error = report(&conn, "ent", &input(leaked)).unwrap_err();
|
||||||
|
assert!(error.contains("疑似包含凭据"), "{leaked} → {error}");
|
||||||
|
}
|
||||||
|
// 只描述现象、提到令牌前缀本身不算凭据
|
||||||
|
for clean in [
|
||||||
|
"Windows 复制 SSH 私钥须用 cat | clip 保 LF,否则 ssh.ParsePrivateKey 报 no key found",
|
||||||
|
"OpenAI key 以 sk- 开头,task-runner 与 desk-check 不受影响",
|
||||||
|
] {
|
||||||
|
assert!(report(&conn, "ent", &input(clean)).is_ok(), "{clean}");
|
||||||
|
}
|
||||||
|
assert!(report(&conn, " ", &input("缺项目"))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("project_id 必填"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolved_returns_leave_pending_list_and_count_up() {
|
||||||
|
let conn = crate::db::conn_with_schema();
|
||||||
|
seed_project(&conn, "ent", "enterprise-system");
|
||||||
|
let a = report_one(&conn, "ent", "摩擦 A", "claude").note_id;
|
||||||
|
let b = report_one(&conn, "ent", "摩擦 B", "codex").note_id;
|
||||||
|
let c = report_one(&conn, "ent", "摩擦 C", "dsh").note_id;
|
||||||
|
assert_eq!(pending_ids(&conn).len(), 3);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
resolve(&conn, a, "landed", "commit abc1234", "已修模板").unwrap(),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
let text = format_returns(&all_returns(&conn).unwrap(), false);
|
||||||
|
assert!(
|
||||||
|
text.contains("待处置使者回传 2 条(已处置 1 条)"),
|
||||||
|
"{text}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!text.contains(&format!("**#{a}**")),
|
||||||
|
"已处置不得再列出: {text}"
|
||||||
|
);
|
||||||
|
assert!(text.contains(&format!("**#{b}**")) && text.contains(&format!("**#{c}**")));
|
||||||
|
|
||||||
|
resolve(
|
||||||
|
&conn,
|
||||||
|
b,
|
||||||
|
"carded",
|
||||||
|
"governance-feedback-loop 杂项挂车批",
|
||||||
|
"转卡",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
resolve(&conn, c, "rejected", "", "探针测试").unwrap();
|
||||||
|
assert!(pending_ids(&conn).is_empty(), "全部处置后待处置应为 0");
|
||||||
|
let text = format_returns(&all_returns(&conn).unwrap(), false);
|
||||||
|
assert!(
|
||||||
|
text.starts_with("📭 暂无待处置的炼境使者回传(已处置 3 条)"),
|
||||||
|
"{text}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let audit = format_returns(&all_returns(&conn).unwrap(), true);
|
||||||
|
assert!(audit.contains(&format!("**#{a}** enterprise-system")));
|
||||||
|
assert!(audit.contains("→ **landed**|落点: commit abc1234|理由: 已修模板"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_validates_and_allows_correction() {
|
||||||
|
let conn = crate::db::conn_with_schema();
|
||||||
|
seed_project(&conn, "ent", "enterprise-system");
|
||||||
|
let id = report_one(&conn, "ent", "摩擦", "claude").note_id;
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO project_notes (project_id, content) VALUES ('ent', '【复盘】普通笔记')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let plain = conn.last_insert_rowid();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
resolve(&conn, id, "done", "x", "y").is_err(),
|
||||||
|
"非法 disposition"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
resolve(&conn, id, "landed", " ", "有理由").is_err(),
|
||||||
|
"landed 缺 reference"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
resolve(&conn, id, "carded", "", "有理由").is_err(),
|
||||||
|
"carded 缺 reference"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
resolve(&conn, id, "watching", "", " ").is_err(),
|
||||||
|
"缺 reason"
|
||||||
|
);
|
||||||
|
assert!(resolve(&conn, plain, "watching", "", "理由")
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("不是使者回传"));
|
||||||
|
assert!(resolve(&conn, 9_999, "watching", "", "理由")
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("不存在"));
|
||||||
|
|
||||||
|
assert_eq!(resolve(&conn, id, "watching", "", "先观察").unwrap(), None);
|
||||||
|
assert_eq!(
|
||||||
|
resolve(&conn, id, "landed", "commit def5678", "已落地").unwrap(),
|
||||||
|
Some("watching".to_string())
|
||||||
|
);
|
||||||
|
let (disposition, reference): (String, String) = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT disposition, reference FROM envoy_dispositions WHERE note_id = ?1",
|
||||||
|
[id],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(disposition.as_str(), reference.as_str()),
|
||||||
|
("landed", "commit def5678")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_keeps_orphaned_returns_and_reports_overflow() {
|
||||||
|
let conn = crate::db::conn_with_schema();
|
||||||
|
// 项目登记已删:旧实现 JOIN project_profiles 会静默丢掉这条
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO project_notes (project_id, content, created_at)
|
||||||
|
VALUES ('gone-project', '【炼境使者回传】来源: 旧项目\n内容: 孤儿回传', '2026-07-01 10:00:00')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
for i in 0..LIST_DISPLAY_LIMIT {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO project_notes (project_id, content, created_at)
|
||||||
|
VALUES ('gone-project', ?1, '2026-08-01 10:00:00')",
|
||||||
|
[format!("【炼境使者回传】来源: 旧项目\n内容: 第 {i} 条")],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let returns = all_returns(&conn).unwrap();
|
||||||
|
assert_eq!(returns.len(), LIST_DISPLAY_LIMIT + 1);
|
||||||
|
assert_eq!(
|
||||||
|
returns.last().unwrap().project,
|
||||||
|
"gone-project",
|
||||||
|
"无登记时回退显示 project_id"
|
||||||
|
);
|
||||||
|
|
||||||
|
let text = format_returns(&returns, false);
|
||||||
|
assert!(text.contains(&format!("待处置使者回传 {} 条", LIST_DISPLAY_LIMIT + 1)));
|
||||||
|
assert!(
|
||||||
|
text.contains("另有 1 条更早的待处置回传未展示"),
|
||||||
|
"超限必须明说: {text}"
|
||||||
|
);
|
||||||
|
assert!(!text.contains("孤儿回传"), "最旧的一条超出展示上限");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
mod commands;
|
mod commands;
|
||||||
mod credential_store;
|
mod credential_store;
|
||||||
mod db;
|
mod db;
|
||||||
|
mod envoy;
|
||||||
mod mcp_inject;
|
mod mcp_inject;
|
||||||
mod mcp_server;
|
mod mcp_server;
|
||||||
mod mcp_tools;
|
mod mcp_tools;
|
||||||
|
|||||||
@ -59,10 +59,12 @@ pub fn tools_list_result() -> Value {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "list_envoy_returns",
|
"name": "list_envoy_returns",
|
||||||
"description": "列出所有子项目通过「炼境使者」发回炼境的待处理洞察,按项目分组展示。炼境会话启动时调用,用于检查是否有需要处置的治理反馈(模板 bug、验证有效的实践、体系改进假设)。无需任何参数。",
|
"description": "列出子项目通过「炼境使者」回传、尚未处置的洞察(带编号 #note_id、来源项目、客户端),并报告已处置条数;超出展示上限时明确说明未展示条数。炼境会话启动时调用,检查是否有需要处置的治理反馈;处置用 resolve_envoy_return。",
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {}
|
"properties": {
|
||||||
|
"include_resolved": { "type": "boolean", "description": "为 true 时附列已处置条目及其处置方式、落点与理由(审计/更正用),默认 false" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -388,10 +390,56 @@ pub fn tools_list_result() -> Value {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
if let Some(tools) = result.get_mut("tools").and_then(Value::as_array_mut) {
|
||||||
|
let after_list = tools
|
||||||
|
.iter()
|
||||||
|
.position(|tool| tool["name"] == "list_envoy_returns")
|
||||||
|
.map_or(tools.len(), |index| index + 1);
|
||||||
|
if let Value::Array(envoy_tools) = envoy_tool_schemas() {
|
||||||
|
tools.splice(after_list..after_list, envoy_tools);
|
||||||
|
}
|
||||||
|
}
|
||||||
apply_tool_annotations(&mut result);
|
apply_tool_annotations(&mut result);
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 使者回传两件工具(单独成函数:并入主清单会超出 json! 宏递归上限)
|
||||||
|
fn envoy_tool_schemas() -> Value {
|
||||||
|
json!([
|
||||||
|
{
|
||||||
|
"name": "report_envoy_return",
|
||||||
|
"description": "炼境使者回传:把本会话遇到的体系级摩擦或经验证的实践回传炼境(Claude Code、Codex、DSH 通用入口)。只追加一条信号、不改任何代码或配置,不必逐条征得用户同意,回传后在总结里列出返回的编号。直接回传:炼境分发的模板/约定/skill/lefthook/MCP 工具出错或碍事(即使只在本项目复现,scope 填 this_project);用户要求回传。经验与假设类须非显而易见且跨项目成立,常识与只在本项目特定组合下成立的 corner case 不回传。每条洞察单独调用一次,同一会话不要换说法重报;断连重试同内容不会重复写入。不得包含凭据、密钥、账号、内网地址或生产数据(内容会写进本项目 .blueprint/notes-archive.jsonl 随项目进 Git)。",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"project_id": { "type": "string", "description": "项目 workspace ID(.lian-jing/context.json 中的 project_id)" },
|
||||||
|
"kind": { "type": "string", "enum": ["template_bug", "validated_practice", "workflow_hypothesis"], "description": "template_bug=炼境模板/约定/工具有缺陷;validated_practice=有证据的有效实践;workflow_hypothesis=待验证的体系改进假设" },
|
||||||
|
"confidence": { "type": "string", "enum": ["confirmed", "under_validation"], "description": "confirmed 须能陈述至少一条具体代价或验证结果;否则一律 under_validation" },
|
||||||
|
"scope": { "type": "string", "description": "影响范围:this_project | all_projects | project_type:<类型>" },
|
||||||
|
"content": { "type": "string", "description": "一句话写清摩擦/实践及证据(换行会被压成单行,最多 1000 字)" },
|
||||||
|
"suggestion": { "type": "string", "enum": ["fix_template", "update_convention", "log_for_review"], "description": "建议炼境:fix_template=改接入包模板;update_convention=改 CONVENTIONS;log_for_review=记录待观察" },
|
||||||
|
"client": { "type": "string", "enum": ["claude", "codex", "dsh", "other"], "description": "当前执行回传的 agent 客户端:claude=Claude Code,codex=Codex,dsh=DeepSeek Harness,其他填 other" }
|
||||||
|
},
|
||||||
|
"required": ["project_id", "kind", "confidence", "scope", "content", "suggestion", "client"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "resolve_envoy_return",
|
||||||
|
"description": "处置一条使者回传并留回执,处置后不再出现在 list_envoy_returns 默认列表。可重复调用更正(覆盖原回执)。只能处置以「【炼境使者回传】」开头的笔记。仅供炼境控制面会话使用:子项目 agent 只回传、不处置。",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"note_id": { "type": "integer", "description": "回传编号(list_envoy_returns 中的 #note_id)" },
|
||||||
|
"disposition": { "type": "string", "enum": ["landed", "carded", "watching", "obsolete", "rejected"], "description": "landed=已落地(有落点证据);carded=已转任务卡;watching=待观察/仅归档;obsolete=已过时;rejected=不由炼境处理" },
|
||||||
|
"reference": { "type": "string", "description": "落点:提交 SHA、文件路径或任务卡标题;landed/carded 必填" },
|
||||||
|
"reason": { "type": "string", "description": "处置依据(一句话,必填)" }
|
||||||
|
},
|
||||||
|
"required": ["note_id", "disposition", "reason"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
fn is_project_scoped_tool(tool: &Value) -> bool {
|
fn is_project_scoped_tool(tool: &Value) -> bool {
|
||||||
let name = tool.get("name").and_then(Value::as_str).unwrap_or_default();
|
let name = tool.get("name").and_then(Value::as_str).unwrap_or_default();
|
||||||
if name == "get_diagnostics" {
|
if name == "get_diagnostics" {
|
||||||
@ -540,6 +588,7 @@ fn apply_tool_annotations(result: &mut Value) {
|
|||||||
| "prepare_toolchain"
|
| "prepare_toolchain"
|
||||||
| "setup_gitea_repo"
|
| "setup_gitea_repo"
|
||||||
| "create_pull_request"
|
| "create_pull_request"
|
||||||
|
| "resolve_envoy_return"
|
||||||
);
|
);
|
||||||
let idempotent = read_only
|
let idempotent = read_only
|
||||||
|| matches!(
|
|| matches!(
|
||||||
@ -550,6 +599,8 @@ fn apply_tool_annotations(result: &mut Value) {
|
|||||||
| "sync_blueprint_rules"
|
| "sync_blueprint_rules"
|
||||||
| "backfill_notes_archive"
|
| "backfill_notes_archive"
|
||||||
| "ingest_git_history"
|
| "ingest_git_history"
|
||||||
|
| "report_envoy_return"
|
||||||
|
| "resolve_envoy_return"
|
||||||
);
|
);
|
||||||
let open_world = matches!(
|
let open_world = matches!(
|
||||||
name,
|
name,
|
||||||
@ -778,7 +829,20 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option<Str
|
|||||||
get_project_mentor_context(pid)
|
get_project_mentor_context(pid)
|
||||||
}
|
}
|
||||||
"list_envoy_returns" => {
|
"list_envoy_returns" => {
|
||||||
list_envoy_returns()
|
let include_resolved = args.get("include_resolved").is_some_and(|value| {
|
||||||
|
value.as_bool() == Some(true) || value.as_str() == Some("true")
|
||||||
|
});
|
||||||
|
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||||
|
crate::envoy::all_returns(&conn)
|
||||||
|
.map(|returns| crate::envoy::format_returns(&returns, include_resolved))
|
||||||
|
}
|
||||||
|
"report_envoy_return" => {
|
||||||
|
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||||
|
report_envoy_return(&conn, &args)
|
||||||
|
}
|
||||||
|
"resolve_envoy_return" => {
|
||||||
|
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||||
|
resolve_envoy_return(&conn, &args)
|
||||||
}
|
}
|
||||||
"append_project_note" => {
|
"append_project_note" => {
|
||||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
@ -1364,7 +1428,7 @@ fn get_diagnostics() -> Result<String, String> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
Ok(list) => {
|
Ok(list) => {
|
||||||
out.push_str("以下项目近 30 天有 commit 活动但 **0 条使者回传**——环的回流段在这些项目上是断的,考虑去该项目会话跑一次 `/envoy`:\n\n");
|
out.push_str("以下项目近 30 天有 commit 活动但 **0 条使者回传**——环的回流段在这些项目上是断的,考虑在该项目会话里回传一次(Claude 跑 `/envoy`,Codex/DSH 调 `report_envoy_return`):\n\n");
|
||||||
for (name, commits) in &list {
|
for (name, commits) in &list {
|
||||||
out.push_str(&format!("- **{}** — {} 条 commit,0 回传\n", name, commits));
|
out.push_str(&format!("- **{}** — {} 条 commit,0 回传\n", name, commits));
|
||||||
}
|
}
|
||||||
@ -1550,47 +1614,79 @@ fn get_project_mentor_context(project_id: &str) -> Result<String, String> {
|
|||||||
Ok(ctx.mentor_markdown)
|
Ok(ctx.mentor_markdown)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn list_envoy_returns() -> Result<String, String> {
|
/// MCP 薄包装:参数提取 + 写库(envoy 模块)+ 双写 notes 档案(与 append_project_note 同一路径)
|
||||||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
fn report_envoy_return(conn: &rusqlite::Connection, args: &Value) -> Result<String, String> {
|
||||||
let mut stmt = conn
|
let field = |key: &str| args.get(key).and_then(Value::as_str).unwrap_or("");
|
||||||
.prepare(
|
let project_id = field("project_id");
|
||||||
"SELECT pn.id, pp.name, pn.content, pn.created_at
|
let input = crate::envoy::EnvoyReport {
|
||||||
FROM project_notes pn
|
kind: field("kind"),
|
||||||
JOIN project_workspaces pw ON pn.project_id = pw.id
|
confidence: field("confidence"),
|
||||||
JOIN project_profiles pp ON pw.profile_id = pp.id
|
scope: field("scope"),
|
||||||
WHERE pn.content LIKE '【炼境使者回传】%'
|
content: field("content"),
|
||||||
ORDER BY pn.created_at DESC
|
suggestion: field("suggestion"),
|
||||||
LIMIT 50",
|
client: field("client"),
|
||||||
)
|
};
|
||||||
.map_err(|e| e.to_string())?;
|
let outcome = crate::envoy::report(conn, project_id, &input)?;
|
||||||
|
if outcome.duplicate {
|
||||||
let rows: Vec<(i64, String, String, String)> = stmt
|
return Ok(format!(
|
||||||
.query_map([], |row| {
|
"同内容回传已存在(#{},待炼境处置),未重复写入",
|
||||||
Ok((
|
outcome.note_id
|
||||||
row.get::<_, i64>(0)?,
|
|
||||||
row.get::<_, String>(1)?,
|
|
||||||
row.get::<_, String>(2)?,
|
|
||||||
row.get::<_, String>(3)?,
|
|
||||||
))
|
|
||||||
})
|
|
||||||
.map_err(|e| e.to_string())?
|
|
||||||
.filter_map(|r| r.ok())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if rows.is_empty() {
|
|
||||||
return Ok("📭 暂无待处理的炼境使者回传".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut out = format!("📬 炼境使者回传(共 {} 条)\n\n", rows.len());
|
|
||||||
for (_id, project_name, content, created_at) in &rows {
|
|
||||||
let date = &created_at[..created_at.len().min(10)];
|
|
||||||
out.push_str(&format!(
|
|
||||||
"---\n**来源**: {} | **时间**: {}\n{}\n\n",
|
|
||||||
project_name, date, content
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
out.push_str("---\n💡 处置参考:fix_template → 改接入包模板文件;update_convention → 改 CONVENTIONS.md;log_for_review → 记录待观察,暂不动模板");
|
let archive_note = match archive_note_append(conn, outcome.note_id) {
|
||||||
Ok(out)
|
Ok(()) => "已同步项目 .blueprint/notes-archive.jsonl".to_string(),
|
||||||
|
Err(e) => {
|
||||||
|
db::log_event(
|
||||||
|
"mcp_tools",
|
||||||
|
"warn",
|
||||||
|
&format!("使者回传档案双写失败(DB 已保存,backfill_notes_archive 可自愈): {e}"),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
"项目档案双写失败已记日志,可调 backfill_notes_archive 自愈".to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(format!(
|
||||||
|
"已回传炼境 ✅ 编号 #{}(客户端 {};{})。炼境会在控制面处置并留回执,子项目无需跟进。",
|
||||||
|
outcome.note_id, input.client, archive_note
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_envoy_return(conn: &rusqlite::Connection, args: &Value) -> Result<String, String> {
|
||||||
|
// agent 常把编号写成 "#123"、"123" 或 123.0,统一宽容解析
|
||||||
|
let raw = args.get("note_id");
|
||||||
|
let note_id = raw
|
||||||
|
.and_then(|value| {
|
||||||
|
value
|
||||||
|
.as_i64()
|
||||||
|
.or_else(|| {
|
||||||
|
value
|
||||||
|
.as_f64()
|
||||||
|
.filter(|f| f.fract() == 0.0)
|
||||||
|
.map(|f| f as i64)
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
value
|
||||||
|
.as_str()
|
||||||
|
.and_then(|s| s.trim().trim_start_matches('#').parse().ok())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.ok_or_else(|| match raw {
|
||||||
|
None => "note_id 必填(list_envoy_returns 中的 #编号)".to_string(),
|
||||||
|
Some(value) => format!("note_id 无法解析为编号:{value}"),
|
||||||
|
})?;
|
||||||
|
let field = |key: &str| args.get(key).and_then(Value::as_str).unwrap_or("");
|
||||||
|
let disposition = field("disposition");
|
||||||
|
let previous = crate::envoy::resolve(
|
||||||
|
conn,
|
||||||
|
note_id,
|
||||||
|
disposition,
|
||||||
|
field("reference"),
|
||||||
|
field("reason"),
|
||||||
|
)?;
|
||||||
|
Ok(match previous {
|
||||||
|
Some(previous) => format!("已更正回传 #{note_id} 的处置:{previous} → {disposition} ✅"),
|
||||||
|
None => format!("已处置回传 #{note_id} → {disposition} ✅"),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn append_project_note(project_id: &str, content: &str) -> Result<String, String> {
|
fn append_project_note(project_id: &str, content: &str) -> Result<String, String> {
|
||||||
@ -2234,6 +2330,145 @@ mod tests {
|
|||||||
assert_eq!(global_write["structuredContent"]["code"], "scope_violation");
|
assert_eq!(global_write["structuredContent"]["code"], "scope_violation");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn envoy_tools_split_between_project_scope_and_control_plane() {
|
||||||
|
let scoped = tools_list_result_for_project("p1");
|
||||||
|
let scoped_names: Vec<&str> = scoped["tools"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|tool| tool["name"].as_str())
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
scoped_names.contains(&"report_envoy_return"),
|
||||||
|
"三端在项目地址上必须能回传"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!scoped_names.contains(&"resolve_envoy_return"),
|
||||||
|
"处置只属于控制面"
|
||||||
|
);
|
||||||
|
assert!(!scoped_names.contains(&"list_envoy_returns"));
|
||||||
|
|
||||||
|
let global = tools_list_result();
|
||||||
|
let annotations = |name: &str| {
|
||||||
|
global["tools"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|tool| tool["name"] == name)
|
||||||
|
.unwrap_or_else(|| panic!("缺少 {name}"))["annotations"]
|
||||||
|
.clone()
|
||||||
|
};
|
||||||
|
assert_eq!(annotations("report_envoy_return")["readOnlyHint"], false);
|
||||||
|
assert_eq!(annotations("report_envoy_return")["destructiveHint"], false);
|
||||||
|
assert_eq!(annotations("resolve_envoy_return")["readOnlyHint"], false);
|
||||||
|
assert_eq!(
|
||||||
|
annotations("resolve_envoy_return")["destructiveHint"],
|
||||||
|
true,
|
||||||
|
"upsert 会覆盖旧回执"
|
||||||
|
);
|
||||||
|
assert_eq!(annotations("list_envoy_returns")["readOnlyHint"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn envoy_return_round_trip_over_dispatch() {
|
||||||
|
db::init_test_pool();
|
||||||
|
let base = temp_base("envoy_dispatch");
|
||||||
|
{
|
||||||
|
let conn = db::pool().get().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO project_profiles (id, name) VALUES ('envoy-fixture-profile', 'Envoy fixture')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO project_workspaces (id, profile_id, win_path) VALUES ('envoy-fixture', 'envoy-fixture-profile', ?1)",
|
||||||
|
[base.to_str().unwrap()],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let text_of = |value: &Value| {
|
||||||
|
value["content"][0]["text"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 项目地址:project_id 由连接绑定,agent 无需显式传
|
||||||
|
let reported = tools_call_for_project(
|
||||||
|
Some(&json!({"name": "report_envoy_return", "arguments": {
|
||||||
|
"kind": "template_bug", "confidence": "under_validation", "scope": "all_projects",
|
||||||
|
"content": "dispatch 往返测试", "suggestion": "log_for_review", "client": "dsh"
|
||||||
|
}})),
|
||||||
|
"envoy-fixture",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_ne!(reported["isError"], true, "{reported}");
|
||||||
|
let reported_text = text_of(&reported);
|
||||||
|
let note_id: i64 = reported_text
|
||||||
|
.split('#')
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|rest| rest.split(|c: char| !c.is_ascii_digit()).next())
|
||||||
|
.and_then(|digits| digits.parse().ok())
|
||||||
|
.unwrap_or_else(|| panic!("回传结果应带编号: {reported_text}"));
|
||||||
|
let archive =
|
||||||
|
std::fs::read_to_string(base.join(".blueprint").join("notes-archive.jsonl")).unwrap();
|
||||||
|
assert!(
|
||||||
|
archive.contains("客户端: dsh"),
|
||||||
|
"须与 append_project_note 同样双写档案"
|
||||||
|
);
|
||||||
|
|
||||||
|
let listed = text_of(
|
||||||
|
&tools_call(Some(
|
||||||
|
&json!({"name": "list_envoy_returns", "arguments": {}}),
|
||||||
|
))
|
||||||
|
.await,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
listed.contains(&format!("**#{note_id}** | **来源**: Envoy fixture")),
|
||||||
|
"{listed}"
|
||||||
|
);
|
||||||
|
assert!(listed.contains("客户端: dsh"));
|
||||||
|
|
||||||
|
let scoped_resolve = tools_call_for_project(
|
||||||
|
Some(&json!({"name": "resolve_envoy_return", "arguments": {
|
||||||
|
"note_id": note_id, "disposition": "rejected", "reason": "x"
|
||||||
|
}})),
|
||||||
|
"envoy-fixture",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
scoped_resolve["structuredContent"]["code"],
|
||||||
|
"scope_violation"
|
||||||
|
);
|
||||||
|
|
||||||
|
let resolved = tools_call(Some(&json!({"name": "resolve_envoy_return", "arguments": {
|
||||||
|
"note_id": format!("#{note_id}"), "disposition": "rejected", "reason": "探针测试"
|
||||||
|
}})))
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
text_of(&resolved).contains(&format!("已处置回传 #{note_id} → rejected")),
|
||||||
|
"{resolved}"
|
||||||
|
);
|
||||||
|
let listed_after = text_of(
|
||||||
|
&tools_call(Some(
|
||||||
|
&json!({"name": "list_envoy_returns", "arguments": {}}),
|
||||||
|
))
|
||||||
|
.await,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!listed_after.contains(&format!("**#{note_id}**")),
|
||||||
|
"处置后不得再列出"
|
||||||
|
);
|
||||||
|
let audit = text_of(
|
||||||
|
&tools_call(Some(
|
||||||
|
&json!({"name": "list_envoy_returns", "arguments": {"include_resolved": "true"}}),
|
||||||
|
))
|
||||||
|
.await,
|
||||||
|
);
|
||||||
|
assert!(audit.contains(&format!("**#{note_id}** Envoy fixture")));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scoped_call_injects_omitted_project_id() {
|
fn scoped_call_injects_omitted_project_id() {
|
||||||
let mut args = json!({});
|
let mut args = json!({});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user