Skip to content

feat: 机器可读输出协议 —— 信封 + 效应 + --format 归一 (2026.8.8.4) - #385

Merged
speak-agent merged 8 commits into
mainfrom
docs/machine-readable-protocol-review
Aug 8, 2026
Merged

feat: 机器可读输出协议 —— 信封 + 效应 + --format 归一 (2026.8.8.4)#385
speak-agent merged 8 commits into
mainfrom
docs/machine-readable-protocol-review

Conversation

@speak-agent

Copy link
Copy Markdown
Member

回应 #379,逐条实跑核对其事实主张(不看代码推断),并提出三处修正。

核对结果

RFC 的分析基本成立(文档 §1 有对照表:--json 事实拼写、self env 无 JSON、xpkg parse 无 schemaVersion、xlings 20/20 空 outputSchema、CDB 早于 ninja 写出 —— 全部实测确认)。

三处修正

1. 未知选项污染 stdout,而且在依赖里

$ mcpp cache list --format json
stdout: Error: unknown option: --format     ← 协议要独占的通道
rc: 1,stderr 空

mcpp 自己的 ui::error 写 stderr 是对的;洞开在参数解析边界,代码在 mcpplibs.cmdline:127。RFC 阶段 0 只规定未知,而客户端先撞到的是未知选项 —— 且修它是跨包工作,不在行数估算里。

2. 协商入口解决不了它被设计来解决的问题

在任何尚未实现 --protocol-version 的 mcpp 上,它自己就是那个「可能失败的命令」,失败与成功同通道。换 --json 拼写也一样 —— 拼写之争对发现机制毫无影响

⇒ 唯一稳健的客户端规则是正向识别:读 stdout 尝试 JSON 解析,含 schemaVersionkind 才算数;不得以退出码或「没报错」为判据。这也是 envelope 必须自识别的理由。

3. destructive 落在错误的一侧

VSCode 的 untrusted 门要在执行之前知道,而拿到 envelope 时副作用已发生。建议同时进 --protocol-version 的静态表。

两处需要拍板的分歧

  1. 未知格式走 stderr + rc=2(我倾向)还是 RFC 原案的 stdout + envelope?这决定客户端规则怎么写,必须先定。
  2. pack --format 直接声明为例外(我倾向),还是加 --layout 别名迁移?

纯文档,无代码改动。

逐条实跑核对 #379 的事实主张(不看代码推断)。基本成立,但有一个结构性遗漏会让它
的阶段 0–2 全部落空。

**未知选项的报错走 stdout,rc=1,stderr 为空** —— 而且那段代码在依赖包
`mcpplibs.cmdline` 里,不在 mcpp。RFC 的阶段 0 只规定未知**值**,但客户端探测能力
时在旧版本上先撞到的是未知**选项**;只规定值,等于把最常走的那条路留在污染状态,
而修它是跨包工作,不在 RFC 的行数估算里。

由此推出更关键的一条:RFC 提议的 `mcpp --protocol-version` **解决不了它被设计来解决
的问题**。在任何尚未实现它的 mcpp 上,它自己就是那个「可能失败的命令」,且失败与
成功同通道。换 `--json` 拼写也一样 —— 拼写之争对发现机制毫无影响。唯一稳健的客户端
规则是正向识别:读 stdout 尝试 JSON 解析,含 schemaVersion 与 kind 才算数;不得以
退出码或「没报错」为判据。

第三条:`destructive` 只放 envelope 不够。VSCode 的 untrusted 门要在**执行之前**知道,
而拿到 envelope 时副作用已经发生。建议同时进 `--protocol-version` 的静态表。

核对过程本身有一处自纠:我先用 grep 断定 `cache list --json` 不存在,实跑后推翻 ——
flag 注册不带横线。文档里每条结论都以实跑为准。

另补一条可执行纪律:golden fixture 必须反向验证过(改字段名要变红)。只「跑通了」
而没有「改坏了会红」的 fixture,和没有 fixture 等价 —— 本轮我自己写过三个因为错误
的理由而通过的测试,都是主动把实现改坏才发现的。
用户报 Windows 上 CDB 的 `-fmodule-file=` 带多余引号,每次构建后要手工删。核实后
比报告的更严重,而且在 Linux 上就能复现 —— 带空格的项目路径 + llvm 工具链:

  '-fmodule-file=std=/tmp/.../my project/.../std.pcm      <- 闭合引号没了
  '-fprebuilt-module-path=/tmp/.../my project/.../pcm.cache'

前两条的闭合引号被切掉:一个参数被从中间劈成两半,还带着孤立的开引号。clangd 逐字
exec `arguments`,拿到的是两个残缺参数。

机制是四步各自合理的叠加:`shell_quote_arg` 的 kNeedsQuote 含反斜杠(Windows 路径必
含,所以那里每个带路径的 flag 都被加引号)→ 加引号对 ninja 是对的 → `split_flags`
撤销 ninja 转义并重新分词 → 但它不认引号。

`split_flags` 自己的注释把原则写对了(「消费者逐字 exec,所以转义必须撤销」),却只
实现了一半 —— 漏掉同一理由下的 shell 引号。

判据必须是「任何 token 不得以引号开头结尾,且带空格的路径是一个 token」,不能是
「clangd 能用了」。回归测试在 Linux 上就能跑;它至今没被发现,正是因为所有 e2e 的
项目路径都不含空格。

同时记下已定的两条:未知格式走 stderr + rc=2;pack --format 声明为例外。
消费者(clangd)逐字 exec `arguments`,不经 shell。所以一个仍带着 shell 引号的 token
不是 flag,而是一个不存在的文件名。

用户报的是 Windows 上 `-fmodule-file=` 带多余双引号、每次构建后要手工删。核实后比
报告更严重,而且在 Linux 上就能复现 —— 带空格的项目路径 + llvm:

  '-fmodule-file=std=/tmp/.../my project/.../std.pcm      <- 闭合引号没了
  '-fprebuilt-module-path=/tmp/.../my project/.../pcm.cache'

第一条不只是多个引号:token 在引号**内部**的空格处被切断,一个参数变成两个,其中一个
带着永不闭合的开引号。

机制是四步各自合理的叠加:`shell_quote_arg` 的触发集含反斜杠(Windows 路径必含,所以
那里每个带路径的 flag 都被引号包住)→ 加引号对 ninja 是对的 → `split_flags` 撤销 ninja
转义并重新分词 → 但它不认引号。

`split_flags` 原来的注释把原则写对了 ——「消费者逐字 exec,所以转义必须撤销」—— 只实现
了一半,漏掉同一理由下的 shell 引号。现在三件事都做,且**顺序**是要点:ninja 的 `$ `
反转义必须发生在引号**内**,否则被引号包住的空格先把 token 切断,那正是原来的 bug。

中间的引号是数据不是引用:`-DGREETING="hi"` 保留内部引号,否则宏的含义就变了。

判据写成「任何 token 不以引号开头/结尾,且带空格的路径是一个 token」,不是「clangd 能
用了」—— 后者在不含空格的路径上恒真,正是它至今没被发现的原因:所有 e2e 的项目路径都
不含空格。

`split_flags` 由匿名 namespace 提升为导出:它就是那份契约,该由测试钉住而不是靠通读
生成文档来推断。

  tests/unit/test_compile_commands.cpp  +5(9 条);端到端复验:带引号 token 归零
W1(阶段 0):`App::run(argc, argv)` 的 parse 错误用 std::println 打 **stdout** 并返回 1。
stdout 是机器可读请求独占的通道,于是 `mcpp cache list --format json | jq` 拿到的是
`Error: unknown option: --format`,而 stderr 是空的 —— 客户端无从区分「这个 mcpp 太旧」
和「命令失败了」。

那句 print 在 mcpplibs.cmdline(已发布依赖)里。改为 mcpp 侧接管 ParseResult,不跨包
发版,并给整个 CLI 一条规则:mcpp 说自己的话一律走 stderr。usage 错误 rc=2,与
`pack --format bogus` 对齐 —— 后者原本就对,而且是两者中唯一对的。

e2e 202 断言未知选项与未知值在通道与退出码上一致。

同时把第二轮 review 写进设计文档(综合 @wellwei / @Ximiaw 反馈)。开头先记一条:
**第一轮没有做需求侧分析** —— 全在核对 mcpp 现在的行为,没读消费者的实际需求。
wellwei 的四条里有两条正是因此漏掉的:

- `--json` 的 payload 兼容:`cache list --json` 顶层是 {entries,root},包进 envelope
  是破坏性变更,本仓库 e2e 就会断。第一轮把它当纯拼写别名,错了。
- `self env` 的副作用。这条我第一次也测错了:只在全新 home 上跑一次、看到 6 个文件就
  下结论,没有排除「任何命令的首次初始化」。加对照才定住 —— `--version` 在全新 home
  上连目录都不建,所以是 `self env` 首次运行触发一次性初始化,已初始化后只读。结论
  不变(新机器上标 destructive:false 仍是谎),但要拆的是「读 vs 首次初始化」,不是
  「读 vs 写」。方法教训也写进去了:「跑一次看有没有文件」不足以给副作用定责,必须
  有一个不走同一路径的对照命令。

另外三条:destructive 单个 bool 不够(要效应集合,exec-build-script 单列);全局
schemaVersion 把不相关 kind 耦合;manifest 固定段的 supported-key 只覆盖少数段
(Ximiaw 实测 24 段)。

结论:W2/W3/W4 在 payload 兼容策略定案前不动手 —— 那是唯一决定 wire v1 形状的分叉。
W0 与 W1 与它正交,已完成。

  67 单测;e2e 202 通过
@Sunrisepeak
Sunrisepeak force-pushed the docs/machine-readable-protocol-review branch from 0691a73 to 3a4ddb3 Compare August 8, 2026 09:43
补了对照测量,推翻两个说法 —— 包括我自己前一版的:

  --version                0 项
  xpkg parse <f> --json    0 项   <- mcpp-vscode#8 实际消费的
  cache list --json        0 项   <- 同上
  self env                 6 项
  已初始化 home + self env  只读

「首次运行必然初始化,所以任何命令都 destructive」不成立:插件真正消费的两个命令
什么都不建,字段不会退化成恒真。

而我前一版写「标 destructive:false 是谎」也过重了 —— 真正的分叉是**作用域**:
untrusted 门在乎的是碰不碰用户项目、执不执行项目代码,mcpp 建自己的 home 不属于这类。
倾向用效应集合把作用域写进名字(self env = [init-mcpp-home]),既不把它打成危险,也
不让 false 被读成「什么都不写」。

方法教训单独记:「跑一次看有没有文件」不足以给副作用定责,必须有不走同一路径的对照
命令 —— 缺了它就会得出一个由测法而非事实支撑的结论。
按 .agents/docs/2026-08-08-machine-readable-output-protocol-design.md 实施
W2–W4(W0 CDB 引号、W1 stdout 归属已在前两个提交)。

**协议本体单独成模块**(`src/wire.cppm`),不认识任何命令 —— 命令→效应表放在
`cli.cppm` 里、紧邻命令注册处,否则新增一条命令要改两个文件且容易漏。

三条设计,每条都来自实测而非偏好:

1. **信封自识别,客户端靠解析识别,不靠退出码。** `--protocol-version` 解决不了它
   看起来能解决的问题:在它出现之前的每个 mcpp 上,它自己就是未知选项,而未知选项
   过去把人类文本打到 stdout、rc=1、stderr 为空 —— 成功与失败同通道。换 `--json`
   拼写也一样。所以判据只能是「stdout 能解析出 schemaVersion + kind」。

2. **效应集合,不是 destructive 布尔。** 实测:全新 MCPP_HOME 上 `xpkg parse` 与
   `cache list` 什么都不建,`self env` 建 6 项。布尔分不开「mcpp 给自己做初始化」和
   「执行工作区里的代码」,而 IDE 的门只在乎后者。效应同时进 `--protocol-version`
   的静态表 —— 门必须在运行前决定,等信封到手事情已经发生了。

3. **`--json` 永久保留 legacy payload,`--format json` 才带信封。** 拼写兼容不等于
   payload 兼容:`cache list --json` 顶层是 `{root, entries}` 且本仓库 e2e 已断言。
   两种拼写由同一个来源产出,不会漂移。

`self env --format json` 走独立的只读路径,不调 `load_or_init`:客户端问「东西在哪」
不该成为把东西放到那儿的原因。全新 home 上返回完整路径 + `initialized: false`,
创建项数 **0**(人类路径不变,仍会初始化)。

未知选项与不支持的值统一 stderr + rc=2,stdout 一字不写。

文档:`docs/11-machine-output.md` 与中文版,含「你可以依赖什么」一节 —— 并写明每个
kind 都有一个「改字段名就变红」的测试,因为没人能弄坏的 schema 不是 schema。

  tests/unit/test_wire.cpp  +14;68 单测;e2e 202
docs/11-machine-output.md 承诺:同一 kindVersion 内字段只增不删不改名。而本模块
存在的理由之一,正是 `xlings interface --list` 那 20 个 capability —— 声明了
outputSchema,内容全是 `{"exitCode": integer}`。**没人能弄坏的 schema 不是 schema。**

所以把承诺钉住:信封、诊断、协议文档的已发布键集,以及每个 effect / severity 的
字面名(客户端按这些字符串匹配 —— 改名会悄悄改变 untrusted 门放行什么)。

加字段**不会**变红,这是契约允许的唯一变更。

验证过它真的会红:把 `kindVersion` 改成 `kind_version`、`exec-build-script` 改成
`exec-script`,5 个测试立刻失败;复原后 20/20 绿。
新增了公开契约面(信封 / --protocol-version / 效应集合 / --format json),
所以单独发一版。xlings pin 已是索引最新的 2026.8.8.1,不动。

68 单测(wire 20 条,含验证过会变红的 golden);e2e 202。
@speak-agent speak-agent changed the title docs: 机器可读输出协议 —— 对 RFC #379 的核对与修正 feat: 机器可读输出协议 —— 信封 + 效应 + --format 归一 (2026.8.8.4) Aug 8, 2026
@speak-agent
speak-agent merged commit 55a39d9 into main Aug 8, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant