From 3095df3d2ac83d3ec1a2784b74248d2fb4198602 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 3 Oct 2026 01:15:40 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(mcp/envoy):=20=E4=BD=BF=E8=80=85?= =?UTF-8?q?=E5=9B=9E=E4=BC=A0=E9=97=AD=E7=8E=AF=E2=80=94=E2=80=94=E5=9B=9E?= =?UTF-8?q?=E6=89=A7=20+=20=E4=B8=89=E7=AB=AF=E5=9B=9E=E4=BC=A0=E5=85=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 回传只进不出:list_envoy_returns 无处置状态、LIMIT 50 把更早的回传静默挤掉; 入口只有 Claude /envoy,08-23 后 enterprise 主力转 DSH/Codex 即零回传。 - envoy.rs 业务本体(连接注入)+ envoy_dispositions 回执表(migrate 补表) - report_envoy_return(项目级,Claude/Codex/DSH 在项目地址均可用):字段枚举校验、 内容压单行限长、兜底拒绝明显凭据;同项目 1 天内同内容未处置幂等返回原编号 (IMMEDIATE 事务防并发重复);复用 append_project_note 的档案双写 - resolve_envoy_return(控制面):landed/carded 必填落点、reason 必填、允许更正 - list_envoy_returns 默认只列未处置(带编号),附已处置计数,超出展示上限明说; LEFT JOIN 不再丢掉登记已删项目的回传;include_resolved 可审计回执 - lian-jing skill 新增「遇到摩擦时回传炼境」;/envoy 改为指向该入口的薄指引 (保留扫描与分类硬门槛,炼境分发物出错免门槛,身份改读 .lian-jing/context.json); AGENTS 托管块加一行指路(1.9.0→1.10.0),能力包 base-onboarding 1.6.0→1.7.0 - 删除炼境仓库内 Codex 迁移遗留的 source-command-envoy 技能(读旧身份文件、走旧写入路径) - 契约与架构文档同步:project-context.yaml、project-brief.md、AGENTS.md、agents-meta-rules.md Rules-Applied: R05,R06,R12 Feature-Confirmed: true Co-Authored-By: Claude Opus 5.5 --- .agents/skills/source-command-envoy/SKILL.md | 73 -- .claude/commands/envoy.md | 32 +- AGENTS.md | 4 +- docs/ai-context/project-brief.md | 1 + docs/ai-context/project-context.yaml | 3 +- docs/templates/agents-meta-rules.md | 1 + .../.agents/skills/lian-jing/SKILL.md | 26 +- .../onboarding/.claude/commands/envoy.md | 32 +- .../.lian-jing/capability-packs.json | 2 +- src-tauri/resources/onboarding/AGENTS.md.tmpl | 3 +- src-tauri/src/commands/onboarding.rs | 2 +- src-tauri/src/db.rs | 14 + src-tauri/src/envoy.rs | 740 ++++++++++++++++++ src-tauri/src/lib.rs | 1 + src-tauri/src/mcp_tools.rs | 321 +++++++- 15 files changed, 1101 insertions(+), 154 deletions(-) delete mode 100644 .agents/skills/source-command-envoy/SKILL.md create mode 100644 src-tauri/src/envoy.rs diff --git a/.agents/skills/source-command-envoy/SKILL.md b/.agents/skills/source-command-envoy/SKILL.md deleted file mode 100644 index b47371c..0000000 --- a/.agents/skills/source-command-envoy/SKILL.md +++ /dev/null @@ -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 条 - · {标题} — {一句说明} - -若本次会话无任何体系级洞察,直接输出:`✅ 本次会话无需回传,无体系级洞察。` diff --git a/.claude/commands/envoy.md b/.claude/commands/envoy.md index 59d9eec..8c8dc80 100644 --- a/.claude/commands/envoy.md +++ b/.claude/commands/envoy.md @@ -4,12 +4,16 @@ 你是**炼境使者**——在子项目会话里代表炼境执行现场感知与回传。 +回传入口是 MCP 工具 `report_envoy_return`(Claude、Codex、DSH 共用),字段规范以 lian-jing skill「遇到摩擦时回传炼境」一节为准。本命令负责会话结束时的扫描、分类和逐条回传。 + 直接执行以下步骤,无需用户提供任何输入。 -## Step 1:读取项目身份 +## Step 1:确认项目身份与回传入口 -用 Read 工具读取 `.claude/liangjing.json`,获取 `project_id` 和项目名称。 -若文件不存在:输出 `⚠️ 未找到 .claude/liangjing.json,该项目未接入炼境` 并停止。 +用 Read 工具读取 `.lian-jing/context.json`,获取 `project_id`。如果只有旧的 `.claude/liangjing.json`,就用它的 `project_id`,并提醒用户在炼境调用 `inject_mcp` 迁移。 +两个文件都没有:输出 `⚠️ 未找到 .lian-jing/context.json,该项目未接入炼境` 并停止。 + +确认 `mcp__lian-jing__report_envoy_return` 在当前会话可用;不可用时按 lian-jing skill 的降级顺序排查,并使用 Step 4 的降级方式。 ## Step 2:扫描本次会话 @@ -24,6 +28,7 @@ - 会(常识性设计知识,如"外键优于业务字段关联")→ 一律归「留本项目」,不得回传 - 触发条件过窄的 corner case(复现依赖本项目特定组合)→ 同样归「留本项目」 - 只有**非显而易见、且跨项目可复用**的教训才有资格进入下方分类 +- **例外**:炼境分发物(AGENTS 托管块、lian-jing skill、本命令、lefthook、CONVENTIONS、MCP 工具)本身出错或碍事,即使只在本项目复现,也直接归「带回炼境」,`scope` 填 `this_project` **📦 带回炼境**(通过硬门槛后,满足任一条): - 炼境分发的模板 / 约定 / 接入包有 bug 或可改进 @@ -37,26 +42,21 @@ ## 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` 回传,或重连后重跑本命令」。 -- `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 +- `client`:填 `claude` +- `kind` / `confidence` / `scope` / `content` / `suggestion`:按 lian-jing skill 规范填写;`content` 用一句话写清现象和证据 +- `project_id`:项目地址已绑定,可以省略;连的是全局地址时,显式传 Step 1 读到的 `project_id` **`confirmed` 的门槛**:必须能陈述至少一条具体代价或验证结果(如"导致 N% 时间损耗"、"在 X 场景实测有效")。 -无法陈述具体证据的,一律标 `under_validation`,不得标 `confirmed`。 +无法陈述具体证据的,一律标 `under_validation`,不得标 `confirmed`。回传内容不得包含凭据、密钥、账号、内网地址或生产数据(内容会同时写进本项目 `.blueprint/notes-archive.jsonl`,随项目进入 Git)。 + +> **MCP 不可用时的降级**:若 `report_envoy_return` 报连接错误,原地重试 1~2 次(同内容重复调用不会重复写入)。仍失败时,把所有待回传内容按上述字段整理成一段文本输出到对话,并提示:「MCP 断连,请把以上内容转交炼境会话回传,或重连后重跑本命令」。 ## Step 5:输出摘要 📦 已发回炼境 N 条 - · {标题}({置信度} · {影响范围}) + · #{编号} {标题}({置信度} · {影响范围}) 🏠 留本项目 M 条 · {标题} — {一句说明} diff --git a/AGENTS.md b/AGENTS.md index b7d4226..79bb325 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,7 @@ src-tauri/src/db.rs SQLite 建表 + migration 逻辑 src-tauri/src/credential_store.rs Windows 本机受保护凭证存储与旧值兼容迁移(SQLite 仅保存引用) 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/envoy.rs 使者回传本体(三端回传写入、处置回执、待处置列表;连接由调用方注入) 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/resources/onboarding/ 接入包分发模板(AGENTS.md/lefthook/git-workflow) @@ -140,7 +141,7 @@ docs/templates/ 炼境接入包模板草案(设计层) 完整规则(迭代管理、阻塞处理、复盘笔记等)详见 `.blueprint/CONVENTIONS.md`。 - + > ⚠️ 本块由炼境版本化托管,升级时**整块原位覆盖**——项目定制请写到块外,块内手改必丢(2026-07-15 炼境自身实锤丢失 5 段定制后新增本警示)。 ## 核心开发约束 @@ -194,6 +195,7 @@ docs/templates/ 炼境接入包模板草案(设计层) - `.claude/liangjing.json` 只是旧项目的 Claude 兼容投影。只发现它时,调用 `inject_mcp(project_id)` 迁移,而不是手工复制或猜测 ID。 - `.lian-jing/capability-packs.json` 记录炼境托管基线的版本。`verify_onboarding(project_id)` 报告 `legacy`、`version_mismatch` 等状态时,按其 `detail` 中的升级提示处理。 - `apply_onboarding_pack(project_id)` 只更新炼境明确托管的接入包文件;项目自己的业务规则、测试、部署声明和手工定义的同名 MCP server 仍归项目所有,不能被升级覆盖。 +- 遇到炼境分发的模板、约定或 MCP 工具的摩擦,或验证出跨项目可复用的实践时,调用 `report_envoy_return` 回传炼境(只追加信号,不必逐条征得用户同意;Claude 会话结束时也可跑 `/envoy`);何时回传、字段怎么填见 lian-jing skill「遇到摩擦时回传炼境」一节。 ### Node/pnpm 工具链 diff --git a/docs/ai-context/project-brief.md b/docs/ai-context/project-brief.md index ee7f635..a480146 100644 --- a/docs/ai-context/project-brief.md +++ b/docs/ai-context/project-brief.md @@ -29,6 +29,7 @@ src-tauri/src/db.rs 数据库 schema + migration src-tauri/src/credential_store.rs Windows 本机凭证引用、校验与兼容迁移 src-tauri/src/mcp_server.rs MCP Server(agent 调用炼境的入口) 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/delivery_status.rs 项目交付信号聚合(Git / Gitea / 部署证据) src-tauri/resources/onboarding/ 接入包分发模板 diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index 312bdd4..750bca7 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -16,7 +16,8 @@ contracts: - "src-tauri/src/commands/cicd.rs" - "src-tauri/src/commands/gitea.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: 被管理项目身份上下文 paths: diff --git a/docs/templates/agents-meta-rules.md b/docs/templates/agents-meta-rules.md index dafbbbb..c1a293d 100644 --- a/docs/templates/agents-meta-rules.md +++ b/docs/templates/agents-meta-rules.md @@ -14,6 +14,7 @@ usage: 炼境接入包分发时写入被管理项目 AGENTS.md 的元规则标 - 项目根目录 `.lian-jing/context.json` 是工具无关的 `project_id` 单一事实来源;客户端项目 UI、用户级插件和全局配置都不能替代它。 - `.claude/liangjing.json` 仅用于兼容已接入的旧 Claude 项目;发现 legacy-only 状态时,应通过炼境迁移,不应复制或猜测 ID。 - `.lian-jing/capability-packs.json` 仅描述炼境托管基线的版本与自检。升级必须显式报告版本不符,并且不能覆盖项目自己的业务规则、测试、部署声明或手工 MCP 配置。 +- 子项目 agent(Claude、Codex、DSH)遇到炼境分发物的摩擦或验证出跨项目实践时,经同一 MCP 入口 `report_envoy_return` 回传;何时回传与字段规范只写在 lian-jing skill,AGENTS 托管块只留一行指路(skill 按需加载,AGENTS 三端必读)。 --- diff --git a/src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md b/src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md index 55765d5..fdd9c12 100644 --- a/src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md +++ b/src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md @@ -1,6 +1,6 @@ --- 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)`;合并后再次读取交付状态。不得自行推断确认。 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` 一步处理。 +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 未出现,按以下顺序降级,不猜测状态: diff --git a/src-tauri/resources/onboarding/.claude/commands/envoy.md b/src-tauri/resources/onboarding/.claude/commands/envoy.md index 59d9eec..8c8dc80 100644 --- a/src-tauri/resources/onboarding/.claude/commands/envoy.md +++ b/src-tauri/resources/onboarding/.claude/commands/envoy.md @@ -4,12 +4,16 @@ 你是**炼境使者**——在子项目会话里代表炼境执行现场感知与回传。 +回传入口是 MCP 工具 `report_envoy_return`(Claude、Codex、DSH 共用),字段规范以 lian-jing skill「遇到摩擦时回传炼境」一节为准。本命令负责会话结束时的扫描、分类和逐条回传。 + 直接执行以下步骤,无需用户提供任何输入。 -## Step 1:读取项目身份 +## Step 1:确认项目身份与回传入口 -用 Read 工具读取 `.claude/liangjing.json`,获取 `project_id` 和项目名称。 -若文件不存在:输出 `⚠️ 未找到 .claude/liangjing.json,该项目未接入炼境` 并停止。 +用 Read 工具读取 `.lian-jing/context.json`,获取 `project_id`。如果只有旧的 `.claude/liangjing.json`,就用它的 `project_id`,并提醒用户在炼境调用 `inject_mcp` 迁移。 +两个文件都没有:输出 `⚠️ 未找到 .lian-jing/context.json,该项目未接入炼境` 并停止。 + +确认 `mcp__lian-jing__report_envoy_return` 在当前会话可用;不可用时按 lian-jing skill 的降级顺序排查,并使用 Step 4 的降级方式。 ## Step 2:扫描本次会话 @@ -24,6 +28,7 @@ - 会(常识性设计知识,如"外键优于业务字段关联")→ 一律归「留本项目」,不得回传 - 触发条件过窄的 corner case(复现依赖本项目特定组合)→ 同样归「留本项目」 - 只有**非显而易见、且跨项目可复用**的教训才有资格进入下方分类 +- **例外**:炼境分发物(AGENTS 托管块、lian-jing skill、本命令、lefthook、CONVENTIONS、MCP 工具)本身出错或碍事,即使只在本项目复现,也直接归「带回炼境」,`scope` 填 `this_project` **📦 带回炼境**(通过硬门槛后,满足任一条): - 炼境分发的模板 / 约定 / 接入包有 bug 或可改进 @@ -37,26 +42,21 @@ ## 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` 回传,或重连后重跑本命令」。 -- `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 +- `client`:填 `claude` +- `kind` / `confidence` / `scope` / `content` / `suggestion`:按 lian-jing skill 规范填写;`content` 用一句话写清现象和证据 +- `project_id`:项目地址已绑定,可以省略;连的是全局地址时,显式传 Step 1 读到的 `project_id` **`confirmed` 的门槛**:必须能陈述至少一条具体代价或验证结果(如"导致 N% 时间损耗"、"在 X 场景实测有效")。 -无法陈述具体证据的,一律标 `under_validation`,不得标 `confirmed`。 +无法陈述具体证据的,一律标 `under_validation`,不得标 `confirmed`。回传内容不得包含凭据、密钥、账号、内网地址或生产数据(内容会同时写进本项目 `.blueprint/notes-archive.jsonl`,随项目进入 Git)。 + +> **MCP 不可用时的降级**:若 `report_envoy_return` 报连接错误,原地重试 1~2 次(同内容重复调用不会重复写入)。仍失败时,把所有待回传内容按上述字段整理成一段文本输出到对话,并提示:「MCP 断连,请把以上内容转交炼境会话回传,或重连后重跑本命令」。 ## Step 5:输出摘要 📦 已发回炼境 N 条 - · {标题}({置信度} · {影响范围}) + · #{编号} {标题}({置信度} · {影响范围}) 🏠 留本项目 M 条 · {标题} — {一句说明} diff --git a/src-tauri/resources/onboarding/.lian-jing/capability-packs.json b/src-tauri/resources/onboarding/.lian-jing/capability-packs.json index 444c79a..3dd52bc 100644 --- a/src-tauri/resources/onboarding/.lian-jing/capability-packs.json +++ b/src-tauri/resources/onboarding/.lian-jing/capability-packs.json @@ -3,7 +3,7 @@ "packs": [ { "pack_id": "base-onboarding", - "version": "1.6.0", + "version": "1.7.0", "supported_clients": ["codex", "claude", "dsh"], "project_artifacts": [ "AGENTS.md", diff --git a/src-tauri/resources/onboarding/AGENTS.md.tmpl b/src-tauri/resources/onboarding/AGENTS.md.tmpl index 9082eab..6c1e479 100644 --- a/src-tauri/resources/onboarding/AGENTS.md.tmpl +++ b/src-tauri/resources/onboarding/AGENTS.md.tmpl @@ -18,7 +18,7 @@ - + > ⚠️ 本块由炼境版本化托管,升级时**整块原位覆盖**——项目定制请写到块外,块内手改必丢(2026-07-15 炼境自身实锤丢失 5 段定制后新增本警示)。 ## 核心开发约束 @@ -72,6 +72,7 @@ - `.claude/liangjing.json` 只是旧项目的 Claude 兼容投影。只发现它时,调用 `inject_mcp(project_id)` 迁移,而不是手工复制或猜测 ID。 - `.lian-jing/capability-packs.json` 记录炼境托管基线的版本。`verify_onboarding(project_id)` 报告 `legacy`、`version_mismatch` 等状态时,按其 `detail` 中的升级提示处理。 - `apply_onboarding_pack(project_id)` 只更新炼境明确托管的接入包文件;项目自己的业务规则、测试、部署声明和手工定义的同名 MCP server 仍归项目所有,不能被升级覆盖。 +- 遇到炼境分发的模板、约定或 MCP 工具的摩擦,或验证出跨项目可复用的实践时,调用 `report_envoy_return` 回传炼境(只追加信号,不必逐条征得用户同意;Claude 会话结束时也可跑 `/envoy`);何时回传、字段怎么填见 lian-jing skill「遇到摩擦时回传炼境」一节。 ### Node/pnpm 工具链 diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index 9db5343..6bd63eb 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -26,7 +26,7 @@ const TMPL_CAPABILITY_PACKS: &str = const TMPL_MISE: &str = include_str!("../../resources/onboarding/mise.toml.tmpl"); const PROJECT_CONTEXT_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"; /// 单个模板的分发结果。 diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 5fe41c8..ba8c40c 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -560,6 +560,20 @@ pub(crate) fn migrate(conn: &rusqlite::Connection) { ) .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 不感知) conn.execute_batch( " diff --git a/src-tauri/src/envoy.rs b/src-tauri/src/envoy.rs new file mode 100644 index 0000000..50899a4 --- /dev/null +++ b/src-tauri/src/envoy.rs @@ -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::>() + .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 { + 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 { + 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 = 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, 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 = 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 = 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, 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 = 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::, _>>() + .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 { + 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("孤儿回传"), "最旧的一条超出展示上限"); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1417aa3..27f1997 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod commands; mod credential_store; mod db; +mod envoy; mod mcp_inject; mod mcp_server; mod mcp_tools; diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index fe8c974..e50020f 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -59,10 +59,12 @@ pub fn tools_list_result() -> Value { }, { "name": "list_envoy_returns", - "description": "列出所有子项目通过「炼境使者」发回炼境的待处理洞察,按项目分组展示。炼境会话启动时调用,用于检查是否有需要处置的治理反馈(模板 bug、验证有效的实践、体系改进假设)。无需任何参数。", + "description": "列出子项目通过「炼境使者」回传、尚未处置的洞察(带编号 #note_id、来源项目、客户端),并报告已处置条数;超出展示上限时明确说明未展示条数。炼境会话启动时调用,检查是否有需要处置的治理反馈;处置用 resolve_envoy_return。", "inputSchema": { "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); 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 { let name = tool.get("name").and_then(Value::as_str).unwrap_or_default(); if name == "get_diagnostics" { @@ -540,6 +588,7 @@ fn apply_tool_annotations(result: &mut Value) { | "prepare_toolchain" | "setup_gitea_repo" | "create_pull_request" + | "resolve_envoy_return" ); let idempotent = read_only || matches!( @@ -550,6 +599,8 @@ fn apply_tool_annotations(result: &mut Value) { | "sync_blueprint_rules" | "backfill_notes_archive" | "ingest_git_history" + | "report_envoy_return" + | "resolve_envoy_return" ); let open_world = matches!( name, @@ -778,7 +829,20 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option { - 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" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); @@ -1364,7 +1428,7 @@ fn get_diagnostics() -> Result { ); } 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 { out.push_str(&format!("- **{}** — {} 条 commit,0 回传\n", name, commits)); } @@ -1550,47 +1614,79 @@ fn get_project_mentor_context(project_id: &str) -> Result { Ok(ctx.mentor_markdown) } -fn list_envoy_returns() -> Result { - let conn = db::pool().get().map_err(|e| e.to_string())?; - let mut stmt = conn - .prepare( - "SELECT pn.id, pp.name, pn.content, pn.created_at - FROM project_notes pn - JOIN project_workspaces pw ON pn.project_id = pw.id - JOIN project_profiles pp ON pw.profile_id = pp.id - WHERE pn.content LIKE '【炼境使者回传】%' - ORDER BY pn.created_at DESC - LIMIT 50", - ) - .map_err(|e| e.to_string())?; - - let rows: Vec<(i64, String, String, String)> = stmt - .query_map([], |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - )) - }) - .map_err(|e| e.to_string())? - .filter_map(|r| r.ok()) - .collect(); - - if rows.is_empty() { - return Ok("📭 暂无待处理的炼境使者回传".to_string()); - } - - let mut out = format!("📬 炼境使者回传(共 {} 条)\n\n", rows.len()); - for (_id, project_name, content, created_at) in &rows { - let date = &created_at[..created_at.len().min(10)]; - out.push_str(&format!( - "---\n**来源**: {} | **时间**: {}\n{}\n\n", - project_name, date, content +/// MCP 薄包装:参数提取 + 写库(envoy 模块)+ 双写 notes 档案(与 append_project_note 同一路径) +fn report_envoy_return(conn: &rusqlite::Connection, args: &Value) -> Result { + let field = |key: &str| args.get(key).and_then(Value::as_str).unwrap_or(""); + let project_id = field("project_id"); + let input = crate::envoy::EnvoyReport { + kind: field("kind"), + confidence: field("confidence"), + scope: field("scope"), + content: field("content"), + suggestion: field("suggestion"), + client: field("client"), + }; + let outcome = crate::envoy::report(conn, project_id, &input)?; + if outcome.duplicate { + return Ok(format!( + "同内容回传已存在(#{},待炼境处置),未重复写入", + outcome.note_id )); } - out.push_str("---\n💡 处置参考:fix_template → 改接入包模板文件;update_convention → 改 CONVENTIONS.md;log_for_review → 记录待观察,暂不动模板"); - Ok(out) + let archive_note = match archive_note_append(conn, outcome.note_id) { + 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 { + // 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 { @@ -2234,6 +2330,145 @@ mod tests { 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] fn scoped_call_injects_omitted_project_id() { let mut args = json!({}); From c6ebc2d775c5ba81e8eb2e16b1fc287a109e13d0 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 3 Oct 2026 01:16:12 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs(blueprint/governance-feedback-loop):?= =?UTF-8?q?=20=E7=BC=BA=E5=8F=A3=E2=91=A0=20=E7=AB=8B=E5=8D=A1=E4=BD=BF?= =?UTF-8?q?=E8=80=85=E5=9B=9E=E4=BC=A0=E9=97=AD=E7=8E=AF=EF=BC=8C=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E5=AE=9E=E7=8E=B0=E3=80=81=E5=AE=A1=E6=9F=A5=E4=B8=8E?= =?UTF-8?q?=E8=87=AA=E9=AA=8C=EF=BC=8C=E8=BF=9B=E8=A1=8C=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../modules/governance-feedback-loop.md | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/.blueprint/modules/governance-feedback-loop.md b/.blueprint/modules/governance-feedback-loop.md index 0dae66c..eeb4b3b 100644 --- a/.blueprint/modules/governance-feedback-loop.md +++ b/.blueprint/modules/governance-feedback-loop.md @@ -13,6 +13,36 @@ - 现状:`append_project_note` 挂单项目、无类型标记,是"员工工作日志"而非"给老板的管理反馈" - 方向:`report_governance_feedback` 类入口(MCP 工具优先,UI 上报按钮可后置),记录:哪个项目、什么问题、疑似哪条规则/模板缺陷;落 project_notes 加类型字段或独立治理账本表 - 形态待定: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) @@ -192,4 +222,8 @@ R 规则层=CONVENTIONS 规则库(炼境强项,业务侧反而隐式)|E ④ 文档防腐铁律加注「prompt/skill 文件豁免删冗余——重复是遵循度手段,非腐化」 - acceptance: `[人工] 下次模板批 diff 含 4 项;版本号随批次升级` +### 💭 使者回执落档(可拆除性) +- status: concept +- 内容:`envoy_dispositions` 回执只在 SQLite(同 notes 档案落地前的 project_notes),是炼境侧原创判断。归宿不宜写进子项目 git(不是子项目的事实);炼境安装态又定位不到自身仓库。第二个「炼境侧原创资产」出现时一并设计归宿(2026-10-02 缺口①卡辩证收缩挂出) + (其余 concept 阶段,L 级——待 /architect 设计确认后由 /splitter 拆卡)