feat(mcp/envoy): 使者回传闭环——回执 + 三端回传入口 #28

Merged
lanrtop merged 2 commits from feat/governance-feedback-loop into master 2026-10-03 00:44:04 +08:00
15 changed files with 1101 additions and 154 deletions
Showing only changes of commit 3095df3d2a - Show all commits

View File

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

View File

@ -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 条
· {标题} — {一句说明} · {标题} — {一句说明}

View File

@ -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 工具链

View File

@ -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/ 接入包分发模板

View File

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

View File

@ -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 三端必读)。
--- ---

View File

@ -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 未出现,按以下顺序降级,不猜测状态:

View File

@ -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 条
· {标题} — {一句说明} · {标题} — {一句说明}

View File

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

View File

@ -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 工具链

View File

@ -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";
/// 单个模板的分发结果。 /// 单个模板的分发结果。

View File

@ -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
View 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 = &note[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 {
&timestamp[..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("孤儿回传"), "最旧的一条超出展示上限");
}
}

View File

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

View File

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