webroom-webhook · 往作战室推一条消息(客户端集成套件)
本 Skill 是 AIWHO 指挥台(作战室)的客户端集成套件——它只包含客户端侧的东西:
MCP 服务(mcp/warroom-webhook.cjs)、宿主 Hook(.workbuddy/hooks/warroom-hook.cjs)、
安装器(scripts/install-hook.mjs)、契约唯一来源(lib/mcp-contract.mjs / lib/demo-secret.mjs)与本文档。
🔴 本套件不含、也不需要后端服务。后端(AIWHO WEBHOOK中台 / 作战室)是独立部署的云端服务, 你推消息是推给那个已经在跑的中台,不是推给你本地。 不要为了用本套件去 clone 或启动任何后端——MCP / Hook 只与云端中台对话。 中台地址与工位密钥由部署方提供,或经你所在平台的密钥管理注入。
它同时是通道 A(MCP)与通道 C(裸 HTTP)共用的契约说明:
mcp/warroom-webhook.cjs 暴露的两个工具与本文件口径必须一致,
由 node scripts/webroom-contract-check.mjs 做十字校验(改一边不改另一边即失败)。
⚠️ 四条先读:
- 本文件不含任何真实密钥,一律从环境变量取。正文会被 AI 完整读到,写死密钥等于公开泄露。
whsec_demo_<channelId>是公开演示值,不是密钥(公式公开可推导)。生产必须用真密钥。urgent是语音播报的唯一触发条件。级别写错 = 大屏不播报,且不会有任何报错。- 多 AI 接入请带上
source(谁推的)。不带的话,大屏上所有 AI 推的消息长一个样, 分不清是谁 —— 这是「多 AI 共用一块大屏」时最先被投诉的一条。
〇、安装三步(客户端侧,无需后端)
把本套件装进你的 AI 宿主即可,不用 clone 服务端、不用起任何服务:
- 放 Skill:把
.agents/skills/webroom-webhook/整个目录放进你的宿主 skills 目录。 不同宿主目录不同,例如:WorkBuddy 是~/.workbuddy/skills/或<项目>/.workbuddy/skills/; Claude Code 是~/.claude/skills/;Codex 是~/.codex/skills/或.agents/skills/; 其他宿主请查自身文档。 - 接 MCP(主线 stdio,可选 HTTP):
- 🌟 主线 · stdio(服务端直接接收
?key=):在 MCP 配置加warroom-webhook,command指向mcp/warroom-webhook.cjs,用系统 node 绝对路径(见 §7.3,别写死托管/IDE 管理的 node 版本路径)。 接好后调setup_receiving_endpoint(endpoint=<含 ?key= 的入站 URL>)完成绑定。 - 可选 · HTTP 传输(零本地依赖):在宿主 MCP 配置加一条
streamableHttp/http, 指向中台POST /api/v1/mcp,带Authorization: Bearer <ApiToken>(见 §2.7)。根本不需要本地 node。 若只能弹窗/设置页添加:显示名称warroom-webhook、协议 HTTP Streamable(别选 SSE)、 Server 地址<中台>/api/v1/mcp、认证方式 Bearer Token、Token 填<ApiToken>(注意不是?key=)。
- 🌟 主线 · stdio(服务端直接接收
- 装宿主 Hook(可选,用于事件自动推送):跑
node scripts/install-hook.mjs,把.workbuddy/hooks/warroom-hook.cjs注册进宿主配置(见 §7.4)。不装也能用,只是少了「收到指令 / 会话恢复」等自动播报。 仅部分宿主支持 hook(见 §7.4 支持矩阵),不支持的宿主直接用 MCP 工具推送即可。
⚠️ 就这三步。你不需要「本地起一个中台再推」——那个中台由部署方提供,是云端独立服务。
一、30 秒上手
bash# 必需环境变量(缺一个就直接失败退出,不做任何猜测) export WARROOM_API_BASE="https://<你的中台域名>" # 后端基址,由部署方提供 export WARROOM_CHANNEL="ch-workbuddy" # 目标工位 channelId export WARROOM_WEBHOOK_KEY="<工位密钥>" # 真密钥;不设则回落演示推导值(仅本地)
不确定
WARROOM_API_BASE该填什么? 问你的中台管理员,或从中台「工位管理」复制的 「复制即用」入站 URL 里取(形如https://<中台>/api/v1/webhook/<工位>?key=<密钥>)。 别手拼 localhost——你本地没有中台服务(见 §八)。
bashcurl -sS -X POST "$WARROOM_API_BASE/api/v1/webhook/$WARROOM_CHANNEL?key=$WARROOM_WEBHOOK_KEY" \ -H 'Content-Type: application/json' \ -d '{"title":"部署完成","body":"web@1.2.3 已发布到生产","level":"info"}'
返回体(状态码与 created 的两个含义如下,别只看状态码):
json{ "id":"clx…", "channelId":"ch-workbuddy", "dedupeHash":"9f2c…", "level":"info", "icon":"info", "created":true, "receivedAt":"2026-09-19T02:00:00.000Z" }
| 情形 | HTTP | created | 含义 |
|---|---|---|---|
| 新去重键 | 201 | true | 落了一条新消息 |
| 同键 + 内容逐字相同 | 200 | false | 幂等重复,无事发生(不是错误) |
| 同键 + 内容有任意差异 | 201 | true | ⚠️ 不是新消息 —— 原地改写已存在的那条(id 不变),见 §2.5 |
只看 created 判断「新增」是不够的:created:true 有两层含义(新落 / 原地更新),
要区分只能看 id 是否变过。三种情形都是成功,不要因看到 200 或 false 就重试。
二、契约(唯一权威口径)
2.1 端点
| 项 | 值 |
|---|---|
| 方法 / 路径 | POST {WARROOM_API_BASE}/api/v1/webhook/{channelId}?key={secret} |
| 备选路径 | POST /api/v1/ingest/{sourceType} + X-Channel-Id 头(sourceType 取 custom / business 最省事) |
| Content-Type | application/json(也支持 text/plain 与 application/x-www-form-urlencoded) |
| 请求体上限 | 1 MiB(超限 413 PAYLOAD_TOO_LARGE) |
| 限流 | 单工位 120 请求/分钟,全局限 1200/分钟;超限 429 RATE_LIMITED |
2.6 载荷字段 source(多 AI 可分辨,新增)
多 AI 推同一块大屏时,必须能分辨是谁推的。做法是把「AI 来源名」放进载荷:
| 载荷字段 | 落点 | 说明 |
|---|---|---|
source | 标题 + externalId | 推荐写法。例:{"title":"部署完成","source":"CodeBuddy"} |
aiName | 同上 | source 的别名(两者都给时 aiName 优先) |
行为:
- MCP / HTTP 传输:
notify_task_complete的标题自动变成✅ {source} 任务完成; 不带source时退回✅ 任务完成。push_warroom_event的标题由你给,source不影响它。 notify_task_created/notify_task_done:标题分别自动变成📋 {source} 新建任务计划:<subject>与✅ {source} 已完成任务计划:<subject>(<subject>是你传的待办标题,必填);不带source时退回无来源版本。 这两个工具针对「某条具体待办被新建 / 完成」的事件,与notify_task_complete(一轮收尾摘要)正交 —— 别混用。externalId缺省自动带ai:<source>;给了commit时优先用commit-<hash>。 ⚠️ 缺省键只有「来源」、不含内容也不含时间窗 → 同一来源推的不同消息会落到同一条记录、 后推覆盖先推(看起来「推了但历史里只有最后一条」)。一个来源要推多种事件时, 必须自带每次都不同的externalId(如hook:notify:<sid>:<type>,见 §四--external-id)。- 裸 HTTP 直推:服务端只按 §2.3 的键抽取,不认
source键 —— 请自己把来源写进title,并自带externalId(如{"title":"✅ CodeBuddy 任务完成","externalId":"ai:CodeBuddy"})。
口径来源:本套件的
lib/mcp-contract.mjs的taskCompleteTitle/sourceExternalId。 标题模板与externalId前缀改一处必须改三处(契约 / MCP stdio / HTTP 传输), 由scripts/mcp-contract-check.mjs十字校验。
2.7 MCP over HTTP(可选,零本地依赖)
没有 shell 的 AI(浏览器里的、云端的)走不了本文 §一 的 curl,也 spawn 不了 MCP 子进程。 为此中台内置了一条 HTTP 型 MCP 传输(这是中台的能力,不是本套件要你部署的):
| 项 | 值 |
|---|---|
| 端点 | POST {WARROOM_API_BASE}/api/v1/mcp |
| 报文 | JSON-RPC 2.0,Content-Type: application/json(单个对象或数组皆可) |
| 鉴权 | Authorization: Bearer <ApiToken>(必须,且不接受任何演示推导密钥) |
| 支持方法 | initialize / ping / tools/list / tools/call |
| 不支持 | resources/* / prompts/* / completion/* / GET 长连接(SSE)/ 上行通知 |
bashcurl -sS -X POST "$WARROOM_API_BASE/api/v1/mcp" \ -H "Authorization: Bearer $WARROOM_API_TOKEN" -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"push_warroom_event","arguments":{"title":"部署完成","body":"web@1.2.3 已发布","level":"info","channelId":"ch-workbuddy","source":"CodeBuddy"}}}'
工具返回的内容是一段 JSON 文本,created 是判断「新增还是去重命中」的唯一依据:
json{ "ok": true, "channelId": "ch-workbuddy", "level": "info", "source": "CodeBuddy", "created": true, "dedupeHash": "9f2c…", "messageId": "clx…", "note": "已新增并推上大屏" }
已知边界(不要以为是接错了):
- 发
resources/list等方法 →-32601,error.data.supported会列出实际支持的方法; - 发
notifications/*→204空响应(规范:通知不回包); GET端点 →405,且响应体说明「只支持 POST」;- 不支持 SSE,所以中台不会主动推消息——只能由 AI 调用工具来推。
💡 HTTP 传输适合的场景:它零本地依赖(根本不需要 node),与云端部署同构,托管升级也永不断链; 而 stdio 要宿主能 spawn 本地 node,且容易踩 §7.3 的 node 路径坑。所以无本地 node 的云端 / 容器 / 浏览器 AI选 HTTP,其余走主线 stdio(见 §〇)。
2.2 鉴权(二选一)
方式 A · URL 共享密钥(推荐给 AI,最省事):?key=<工位密钥>。
适用于只允许填 URL 的平台,也适用于本 Skill 的 curl 模板。
方式 B · 请求头(打 /webhook/{channelId} 时按工位 type 决定):
| 工位 type | 必须的头 | 值 |
|---|---|---|
custom / business / sentry / ci | X-Source-Secret | 工位密钥明文 |
github | X-Hub-Signature-256 | sha256= + HMAC-SHA256(密钥, 原始 body 字节) |
grafana | X-Signature | HMAC-SHA256(密钥, 原始 body 字节) |
可选 X-Request-Timestamp(unix 秒)——只在「不带 ?key=、走签名头」时才校:
- 带
?key=→ 完全不看时间戳(传了也忽略)。重放防护由去重层承担。 - 走签名头 → 传了就校,偏移超 ±300s →
401 SIG_TIMESTAMP_SKEW;不传则不校。
结论:用 ?key= 时不要传 X-Request-Timestamp,传了没意义;走签名头时必须保证时钟准。
为了"看起来安全"随便填一个时间戳,在签名头路径下会直接把请求打死。
HMAC 必须对原始字节算,不能先
JSON.parse再JSON.stringify(键序/空格会变,签名必失败)。 用 curl 时把 body 写进文件,--data-binary @file。
2.3 请求体字段(source= custom / business 时的抽取规则)
服务端按确定性映射抽取,不扫整包 JSON。取第一个非空命中的字段:
| 归一化字段 | 依次尝试的键 | 约束 |
|---|---|---|
title | title → subject → name → summary → event | ≤ 512 字,超出被截断 |
body | body → text → content → message → description | ≤ 4000 字 |
level | level → severity → priority → urgency | 见 §2.4 |
externalId | id → eventId → event_id → externalId → external_id | 只接受标量,≤191 字 |
标题或正文至少有一个非空,否则整条被标 unrecognized=true 且 level 强制降为 info(消息仍会收下,不报错)。
2.4 level 口径(最容易踩的坑)
服务端白名单只有三个:info | warning | urgent。大小写不敏感,且有别名表。
| 你写 | 落库为 | 后果 |
|---|---|---|
info / notice / debug / low / ok | info | 仅大屏显示 |
warning / warn / medium / moderate / p2 | warning | 大屏黄色 |
urgent / fatal / critical / error / err / high / p0 / p1 / sev1 | urgent | 触发语音播报 + 红色抖动 |
| 空串 / 无法识别 / 字段缺失 / 整个 payload 认不出来 | info | 静默降级,无报错 |
建议:只写 info / warning / urgent 三个原值。
写别名能通,但你失去的是确定性 —— 一旦服务端别名表调整,你的语义就变了。
2.5 去重语义(决定"为什么它没上屏")
去重键 dedupeHash:
- 带
externalId→sha256(channelId \x1f externalId)。同一externalId永远只落一条。 - 不带
externalId→sha256(channelId \x1f title \x1f body \x1f 时间窗),时间窗默认按去重窗口切桶。
命中同一去重键时,服务端不忽略、而是原地 upsert:
- 内容逐字全同(
title+body+level+icon全等)→ 返回created:false,什么都不做,这是真幂等。 - 任意字段有差异 → 更新那条已有记录(
id不变、receivedAt刷新),并返回created:true。
所以 externalId 是**「一行」的锚**,不是「一份内容」的锚 —— 同键重推会改写历史。
推论(实践中最常踩):
- 重试安全:仅当重发逐字相同时不会产生第二条、也不会改写。差一个字就是「原地更新」。
- "我推了但没上屏":多半是撞了去重键且内容没变 → 想强制产生新消息,就改
title/body, 或带一个每次都不同的externalId(如deploy-20260919-0201)。 - 同一
externalId换了内容 ≠ 新消息:它会把旧那条改掉(大屏上不新增、数量不涨)。 想追加而非改写,必须换externalId;想改已落记录也可走PATCH /api/v1/messages/{id}(需 ApiToken)。 - ⚠️ 同键「抬级别」会二次播报:
level从info改成urgent是「内容有差异」, 服务端 upsert 后无条件broadcastMessage,浏览器侧TtsEngine收到一条新的urgent→ 又播一遍。 这既不是新增、也不是幂等,是重复打扰。重试时请保持 payload 逐字不变。
三、载荷样例
样例文件见 examples/,可直接 curl --data-binary @examples/xxx.json:
| 文件 | 用途 | level |
|---|---|---|
task-complete.json | AI 一轮任务完成通知(标题带来源) | info |
task-complete-cursor.json | 第二个 AI 的同工位通知(演示多 AI 分辨) | info |
task-created.json | 新建一条待办通知(subject 写待办标题,标题带来源) | info |
task-done.json | 某条具体已完成任务计划通知(subject 写待办标题,标题带来源) | info |
turn-start.json | 轮次开始「🙋 收到指令」(仅无 hook 宿主,AI 每轮调 notify_turn_start) | info |
turn-end.json | 轮次结束「✅ {source} 任务完成」(仅无 hook 宿主,AI 每轮调 notify_turn_end) | info |
alert-critical.json | 生产紧急告警(会播报) | urgent |
build-failed.json | CI 构建失败 | urgent |
need-action.json | 待你操作 / 阻塞 | warning |
deploy-done.json | 部署完成 | info |
plain-text.txt | 纯文本投递(text/plain) | info |
AI 任务完成通知的最小可用写法(externalId 用 commit 哈希,天然幂等):
json{ "title": "✅ CodeBuddy 任务完成", "body": "优化 MCP 插件参数校验\n\n已推送远程 76c38c5", "level": "info", "externalId": "commit-76c38c5" }
走裸 HTTP 时,来源写在
title里(服务端不认source键,见 §2.6)。 走 MCP / HTTP 传输时,直接传source参数,标题会由工具自动拼好。
四、CLI 封装(不想手写 curl 时)
套件自带 MCP 插件的 CLI 模式(同一个 HTTP 通道,只是贴了层壳):
bashnode mcp/warroom-webhook.cjs --notify --summary "本轮做了什么" \ [--level info] [--commit <hash>] [--channel ch-workbuddy] [--source CodeBuddy] [--external-id <key>] # 自定义标题(告警 / 待办 / 阻塞类),不套「✅ 任务完成」: node mcp/warroom-webhook.cjs --notify --title "⚠️ 需要你操作:证书 3 天后过期" --summary "..." --level warning
给了 --title 就走通用推送;不给则走任务完成通知,并自动附「已推送远程 <commit>」。
--commit 省略时自动 git rev-parse --short HEAD。
--source 也可用环境变量 WARROOM_AI_NAME 固定(各宿主在 mcp.json 里设一次即可)。
--external-id <key>(幂等键,强烈建议显式给):不给时的缺省是 ai:<source>(有 commit 则 commit-<hash>)。
⚠️ 缺省值不含内容、也不含时间窗 —— 同一个 source 推的不同消息会落到同一条记录、后推覆盖先推。
所以「一个来源推多种事件」(如宿主 hook 的「收到指令 / 任务完成 / 需要你操作」)必须各自显式给 key,例如:
| 事件 | --external-id 写法 | 语义 |
|---|---|---|
| 同一提示词 | hook:prompt:<sid>:<hash>:<30s桶> | 30s 内重推折叠,跨桶新增 |
| 需要你操作 | hook:notify:<sid>:<type>:<hash> | 同类型同内容折叠 |
| 会话状态变更 | hook:session:<source>:<sid> | 仅 resume/clear/compact;startup 不推(见下) |
| 会话结束 | hook:session-end:<sid> | 一个会话一条 |
🔴
SessionStart的startup必须静音:新会话的第一条指令会在 1s 内接着触发UserPromptSubmit(实测两条落库间隔 943ms,且顺序不定 —— 「收到指令」反而先到),大屏上就是重复的两条。 引擎的emitSessionStart(session, source)有四种 source:startup(应用启动 / 新会话,静音)、resume(恢复/切换会话)、clear(/clear)、compact(上下文压缩)。 后三种才是值得提醒人的真状态变更 —— 因为上下文可能已不完整,文案分别用🔄 会话已恢复/🧹 会话已清空/🗜️ 上下文已压缩,level=info。 「有人来了」这个信号统一交给UserPromptSubmit(🙋 收到指令),不要两处都推。
口径:同事件重推 → 同 key(幂等);不同事件 → 不同 key(不互相覆盖)。
🔴
notify_task_complete的缺省键是commit-<hash>,在两次提交之间是常量。 不频繁提交时,同一提交内的每一轮「任务完成」都会命中同一条去重记录。 但注意:这不等于「正文不变、大屏不新增」——同键且内容逐字相同才不新增; 若正文不同,它会原地改写上一条(编号不变、大屏不涨)——两种结果都是「看起来像没推」。 要让每轮都真正新增,就必须显式给每次都不同的键 (如hook:stop:<会话id>:<本轮答复指纹>)。
--source 的输出长这样(多 AI 分辨靠它):
✅ 已推送「任务完成」通知至 ch-workbuddy(level=info,source=CodeBuddy,commit=76c38c5) ✅ 已存在(去重命中,未新增)「任务完成」通知至 ch-workbuddy(level=info,source=CodeBuddy,commit=76c38c5)
created/去重一律看文案前缀:已推送= 服务端接受了它(可能是新增,也可能是原地改写已存在的那条 —— 前缀不区分这两者);已存在(去重命中,未新增)= 逐字重复、什么都没做。 两者都是成功,不要因为看到「已存在」就重试(重试还是同一条)。 想确认究竟是「新增」还是「改写」,看两次响应的id是否相同(去重文档 §2.5)。
参数失败时的口径(都是显式报错,不静默):
| 情况 | 行为 |
|---|---|
既写了 arguments 又平铺了参数 | 报错且不推送(形状混用) |
| 工具名拼错 | 报错且不推送(不再静默当成另一个工具执行) |
channelId 带 %(URL 编码过的) | 报错,提示传未编码的裸 id |
没发 initialize 直接 tools/call | -32002 拒绝 |
title 超 512 字 / 带裸换行 | 自动截断 + 换行压成空格(不再原样发出) |
level 写 critical / URGENT | 按服务端口径收敛为 urgent(会播报) |
五、排错表(按 HTTP 状态码 / 业务码定位)
| 现象 | 业务码 | 原因与改法 |
|---|---|---|
| 401 | SIG_MISSING | 既没带 ?key=,也没带对应签名头。→ 补 ?key= |
| 401 | SIG_INVALID | 密钥不对;或 HMAC 对的是 parse 后的 body 而非原始字节 |
| 401 | SIG_TIMESTAMP_SKEW | 走签名头路径且传了 X-Request-Timestamp,与本机时钟差 > ±300s。→ 校准,或干脆别传(带 ?key= 时不会出这个码) |
| 403 | SSRF_BLOCKED | 只在出站转发路径出现,入站不会 — 若见到说明打错端点了 |
| 404 | RESOURCE_NOT_FOUND | channelId 不存在。→ 去「设置 → 工位管理」核对 |
| 410 | CHANNEL_DISABLED | 工位被禁用(不是不存在)。→ 启用工位,或换一个 |
| 413 | PAYLOAD_TOO_LARGE | 请求体 > 1 MiB。→ 精简 body(大日志只放摘要 + 链接) |
| 415 | UNSUPPORTED_MEDIA_TYPE | Content-Type 畸形/不支持。→ 老老实实写 application/json |
| 422 | UNSUPPORTED_SOURCE_TYPE | im_feishu / im_dingtalk / im_wecom 属 P1,未启用 |
| 429 | RATE_LIMITED | 超 120/min/工位。→ 按 Retry-After 退避后重投(去重保证不重复) |
| 500 | INTERNAL_ERROR | 服务端异常。→ 重试 2 次仍失败则上报 |
| HTTP 传输 401 | AUTH_TOKEN_MISSING | 打 /api/v1/mcp 没带 Authorization: Bearer。→ 补上(该端点不接受 ?key=) |
| HTTP 传输 401 | AUTH_TOKEN_INVALID | Bearer 令牌不对 / 已吊销;用演示密钥当令牌也会落到这一码。→ 换真实 ApiToken |
| HTTP 传输 403 | AUTH_SCOPE_INSUFFICIENT | 令牌没有 write scope。→ 换有写权限的令牌 |
| HTTP 传输 405 | METHOD_NOT_ALLOWED | 用 GET 打了端点。→ 只支持 POST(无 SSE) |
HTTP 传输 -32601 | — | 调了 resources/* / prompts/* 等未实现方法。→ 看 error.data.supported,只用列出的四个方法 |
| HTTP 传输 无响应体 | — | 发的是 notifications/*(规范:通知不回包,服务端返 204)。→ 正常,无需处理 |
| 2xx 但不播报 | — | 99% 是 level 不是 urgent(别名写错或整条 unrecognized 降级) |
| 2xx 但大屏不新增 | — | 撞去重键(见 §2.5)。→ 换 title/body/externalId |
契约承诺 429 响应带
RateLimit-*/Retry-After头,但当前服务端未实现这些响应头。 客户端不能依赖它自适应退避 —— 请用固定指数退避(1s → 2s → 4s,最多 3 次)。此为已知缺口。
六、AI 调用时请遵守
-
别猜
channelId。默认工位是ch-workbuddy;不确定就问用户,或从GET /api/v1/stations(需 ApiToken)取。 -
别在代码/文档里写死密钥。用
$WARROOM_WEBHOOK_KEY。若某平台只能填静态 URL, 把密钥放环境变量或密钥管理,不要提交进仓库。 -
级别宁低勿高。
urgent会打断人(语音播报)。只有真的需要人立刻看时才用。 -
正文给结论,不给原始日志。
body第一行写结论,细节放链接。 正文会进播报文本,不要塞 JSON 键名或换行堆叠。 -
带上来源,便于多 AI 接入后在大屏上分辨谁推的。两条通道的写法不同:
- 走 MCP / HTTP 传输 → 传
source参数(如"source":"CodeBuddy"),标题由工具拼好; - 走裸 HTTP → 服务端不认
source键,请自己写成"title":"✅ CodeBuddy 任务完成", 并自带"externalId":"ai:CodeBuddy"(否则重推会产生多条)。
来源名用稳定的短标识(
CodeBuddy/Cursor/ci-bot),不要每次都带时间戳 —— 那样每条都是新消息、刷屏。 - 走 MCP / HTTP 传输 → 传
-
重试无害,但要幂等:优先带
externalId。 -
本 Skill 只负责推送。查工位、看消息、改配置需要 ApiToken, 走
POST /api/v1/auth/login+Authorization: Bearer <token>,不在本 Skill 范围。 -
轮次事件看宿主(详见 §7.2 C):有 hook 的宿主由 hook 推「收到指令 / 任务完成」,不要再调
notify_turn_start/notify_turn_end;无 hook 的宿主每轮都要调(开始notify_turn_start、 结束notify_turn_end,含普通对话),否则大屏看不到轮次边界。
七、与 MCP 的关系(三条通道怎么选)
MCP stdio(mcp/warroom-webhook.cjs) | 本 Skill(裸 HTTP / CLI) | MCP over HTTP(POST /api/v1/mcp) | |
|---|---|---|---|
| 适用宿主 | 能 spawn 子进程的本地客户端 | 任何有 shell 的 AI | 没有 shell 的 AI:浏览器里的 / 云端 Agent |
| 需要配置 | mcp.json | 零配置(读本文件即可) | 一个端点 + 一个 Bearer 令牌 |
| 浏览器里的 AI | 接不进来 | 接不进来 | 可以了(本文件 §2.7) |
| 鉴权 | 本机环境变量(可回落演示密钥) | ?key= 或签名头 | 必须 ApiToken(不接受演示密钥) |
| 契约定义 | 写在 inputSchema 里 | 写在本文件里 | 同 stdio(工具口径一致) |
7.1 接入流程:先装服务,后给接收端(不再握手即推)
旧版在 initialize 握手时自动推一条「🟢 服务上线」,发生在用户还没给服务端地址 / 工位 / 密钥之前,
等于「连上即推测试」。现已改为两步分离 —— 把「安装」和「推送」彻底拆开:
- 安装服务(仅接线):MCP stdio 接入后不会向任何地址发消息;AI 读到这里也只是知道有哪些工具可用。
- 用户给接收端:用户从服务端「工位管理」复制该工位的「复制即用」入站 URL
(形如
https://<中台>/api/v1/webhook/<工位channelId>?key=<密钥>)贴给 AI。 - AI 识别接收端:AI 调用
setup_receiving_endpoint(endpoint=<那条URL>),自动解析出 服务端基址 / 工位 channelId / 密钥,并设为本次会话的默认推送目标。 - 确认连接:在这一步才推送一条
🟢 服务上线:<source>(level=info)—— 此时地址与密钥才是真的。 - 回显全部模板:工具返回本工位会出现的全部消息模板清单(A=AI 主动推送 + B=宿主 Hook 自动推送,见 §7.2),告诉用户每种事件会出现什么。
- 咨询是否修改模板(必须,不可跳过):回显后向用户提问,例如:「上面这些模板的标题 / 文案需要改吗?比如把「✅ {source} 任务完成」换个说法,或哪一类事件不希望出现在大屏上?」
- 用户不改 → 直接进第 7 步;
- 用户要改 → 逐项记清「哪个模板 + 改成什么」,按 §7.2 的落地位置改
lib/mcp-contract.mjs(标题模板唯一来源)与mcp/warroom-webhook.cjs的TEMPLATE_GUIDE(回显清单),改完跑 §七 两个契约检查,回显确认后再进第 7 步; - ⚠️ 模板是本仓库代码级的,改了会影响所有走这套件的工位,不是「单工位开关」;若用户只是想让某一类事件「不出现」,用「不调对应工具 / 不装对应 Hook」实现,而非改文案。
- 之后 AI 才能用
notify_task_complete/notify_task_created/notify_task_done/notify_turn_start/notify_turn_end/push_warroom_event真正推事件。
🔑 绑定会持久化(2026-10-10 修复):
setup_receiving_endpoint解析出的基址 / 工位 / 密钥会写进本地状态文件 (WARROOM_RECEIVING_STATE环境变量 >~/.warroom/receiving-endpoint.json),进程重启 / 宿主重新信任连接器 / 升级都不会丢,也不会再回落http://localhost:3500(本机 dev 端口,平时不开 → 静默推失败)。 宿主 Hook(独立进程)也读这份状态文件,所以用户设一次接收端,A 列 MCP 工具与 B 列 Hook 自动推送都能用。 想改工位 / 服务端,重新调一次setup_receiving_endpoint即可覆盖;想彻底清掉,删该状态文件。
| 项 | 值 |
|---|---|
| 触发「服务上线」的时机 | 用户给出接收端后,setup_receiving_endpoint 内部推(不再在 initialize 推) |
| 绑定落盘位置 | WARROOM_RECEIVING_STATE > ~/.warroom/receiving-endpoint.json(重启 / Hook 均读回) |
| 身份来源 | setup_receiving_endpoint 的 source / aiName,其次环境变量 WARROOM_AI_NAME,再次 未署名客户端 |
| 工位 / 密钥来源 | 从接收端 URL 的 /webhook/<channelId> 路径与 ?key= 解析,不靠手填 |
| 幂等键 | mcp:online:<name>:<10 分钟桶> —— 抖动重连折叠,隔段时间再接入算新一次;<name> slug 化,纯中文名记成 unknown |
| 关闭上线通知 | 环境变量 WARROOM_ONLINE_NOTIFY=0(默认开;护栏脚本会设它避免污染抓包断言) |
⚠️ MCP over HTTP 传输(§2.7)无此流程 —— 它无会话概念,且 AI 已通过 Bearer 令牌直连服务端,不需要再解析接收端。
7.2 工具与「本工位会出现的全部消息模板」
⚠️ 区分两个来源,否则会误以为「模板只有 4 个」:
- A. AI 主动推送:AI 通过本 MCP 的工具显式调用产生(需 AI 调)。
- B. 宿主 Hook 自动推送:宿主事件驱动、无需 AI 调用 —— ⚠️ 但必须先装(见 §7.4):
脚本在本套件
.workbuddy/hooks/warroom-hook.cjs(不是~/.workbuddy/hooks/,那儿没有这个目录), 注册进宿主自己的 hook 配置。🔴 连接器包里没有 hook 槽位 —— 装了连接器市场包也不会自动推 B 列这些事件。
A. AI 主动推送(本 MCP 工具)
| 工具 | 触发场景 | 大屏标题 / 模板 |
|---|---|---|
setup_receiving_endpoint(接入第一步·必调) | 用户贴接收端 URL → 识别工位+密钥、确认连接 | 推「🟢 服务上线:」并回显下方模板 |
notify_task_complete | 一轮任务收尾 / post-commit | ✅ {source} 任务完成 |
notify_task_created | 新建一条待办 | 📋 {source} 新建任务计划:<待办标题> |
notify_task_done | 某条具体已完成任务计划 | ✅ {source} 已完成任务计划:<待办标题> |
notify_turn_start | 仅无 hook 宿主:每轮开始(收到用户消息) | 🙋 收到指令 |
notify_turn_end | 仅无 hook 宿主:每轮结束(含普通对话) | ✅ {source} 任务完成 |
push_warroom_event | 通用(告警 / 阻塞 / 进展)→ 自定义标题 | 你给的 title;level=urgent/warning ⇒ ⚠️ 需要你操作,触发语音播报 |
B. 宿主 Hook 自动推送(⚠️ 需先注册宿主 hook,装好后无需 AI 调用)
| 宿主事件 | 大屏模板 |
|---|---|
UserPromptSubmit(有人发指令) | 🙋 收到指令 |
resume(恢复 / 切换会话) | 🔄 会话已恢复 |
/clear | 🧹 会话已清空 |
compact(上下文压缩) | 🗜️ 上下文已压缩 |
session-end(一个会话一条) | (会话结束) |
setup_receiving_endpoint的入参:endpoint(必填) = 服务端「复制即用」入站 URL;source可选(多 AI 分辨,否则回落「未署名客户端」)。调用它之前,本服务不会向任何地址发消息。
C. 轮次事件 × 谁负责(🔴 别两头都推)
「🙋 收到指令 / ✅ 任务完成」是轮次级事件。MCP 是客户端驱动协议 —— 服务端只在客户端发 tools/call
时才知道发生了什么,没有原语能感知「用户刚开口 / 助手刚答完」。所以这两个信号只能由宿主 hook 产生,
或由 AI 每轮自己调工具。两条路互斥,两头都推 = 同一轮在大屏重复两条:
| 宿主类型 | 收到指令 | 任务完成(轮次) | AI 该怎么做 |
|---|---|---|---|
| 有 hook(WorkBuddy / Claude Code,见 §7.4) | Hook UserPromptSubmit 自动推 | Hook Stop 自动推 | 不要调 notify_turn_start / notify_turn_end / notify_task_complete |
| 无 hook(OpenClaw / Codex / Gemini CLI 等) | AI 每轮调 notify_turn_start | AI 每轮调 notify_turn_end | 每轮开始与结束各调一次(含普通对话) |
🔴 纯 MCP 宿主上「任务完成」用
notify_turn_end,别用notify_task_complete:后者的缺省幂等键是commit-<hash>,在两次提交之间是常量 → 第二轮起命中去重(去重命中不更新正文)→ 大屏永远只有第一条, 现场就是「调了也白调」。notify_turn_end的键按「摘要指纹 + 30s 桶」生成,每轮天然不同。🔴 「每轮必调」没法由 MCP 协议强制(协议不定义轮次边界),只能靠三层约规兜底: ①
initialize.instructions(服务端唯一能在会话开始发声处)② 本文件 ③ 宿主常驻规则文件 (AGENTS.md / 系统提示 / custom instructions —— 等价于给纯 MCP 宿主装一个「软件 hook」)。 若宿主连常驻规则都没有,则退回 A 列(AI 主动推送告警)或 gitpost-commit兜底。
7.3 🔴 配置 mcp.json / settings.json:node 路径陷阱(会被升级静默打断)
本 MCP(stdio)与宿主 Hook 都要靠一个 node 进程跑 *.cjs。给它们配 command(运行 node)时,
有一个会反复重演的坑:
❌ 不要写死托管/IDE 管理的 node 版本化路径,例如:
C:/Users/<你>/.workbuddy/binaries/node/versions/22.22.2-6/node.exe
这类路径带构建号或版本号,IDE/宿主升级 node 时旧目录会被直接删除 → 这条路径没了 → 进程起不来 → (MCP)工具静默消失、(Hook)全部自动推送静默停摆。无报错,只是不工作。
✅ 正确写法(按优先级):
| 做法 | 配置示例 | 说明 |
|---|---|---|
| 零本地依赖(最稳) | MCP 改用 HTTP 传输:"type":"streamableHttp","url":"https://<中台>/api/v1/mcp","headers":{"Authorization":"Bearer <ApiToken>"}(见 §2.7) | 根本不需要本地 node,升级永不断链;Hook 无此选项 |
| 系统 node 绝对路径(宿主型推荐) | "command":"C:/Program Files/nodejs/node.exe" | 系统 node 在 Program Files 下,IDE/宿主托管升级删的是它自己的版本目录,碰不到系统 node;node 官方升级是原地覆盖同路径,也稳 |
runtime 托管(官方文档写法) | "command":"node","runtime":{"type":"node"} | 仅市场连接器包可靠;个人配置文件可能不解析 runtime,且宿主自身 PATH 缺 node 时裸 command:"node" 会 spawn 失败 —— 本机用系统 node 绝对路径更稳 |
🔴 第二个静默坑:路径含空格必须加引号。 Hook 的 command 是交给 bash 执行的(宿主只给脚本路径加了引号,
node 路径没加)→ C:/Program Files/nodejs/node.exe 里的空格被 bash 当分隔符 → C:/Program: command not found,
exit code=127,照样静默不工作。所以 Hook command 里 node 路径也必须用引号包住:
json"command": "\"C:/Program Files/nodejs/node.exe\" \"<本套件目录>/.workbuddy/hooks/warroom-hook.cjs\""
验证配置真的生效(改完别只看 JSON,要实测):
- MCP:重启连接器后调一次任意工具(如
push_warroom_event)看是否created:true;进程清单里应有node …/warroom-webhook.cjs在跑。 - Hook:恢复 / 切换一次会话,看日志是否出现
SessionStart(resume) … exit=0(而不是卡在旧日期)。
这两处不用手写:
node scripts/install-hook.mjs会挑系统 node、加引号、幂等合并、 写前备份,并且--check可干跑比对、--uninstall可干净移除(只动它自己写的 handler,不碰别人的)。 宿主支持矩阵与「为什么 Claude Code 要带--host=claude-code」见 §7.4。
7.4 宿主 × 自动推送:hook 装得上吗(连接器包不含 hook)
🔴 先说结论:市场连接器包的规范一般只有
connector-meta.json / mcp.json / token-schema.json / icon.svg / skills/ —— 没有 hook 槽位。
所以上面 B 列那些事件不会随连接器安装而生效,必须在用户机上另装一次。
| 宿主 | B 列自动推送 | 怎么装 | 备注 |
|---|---|---|---|
| WorkBuddy | ✅ | node scripts/install-hook.mjs | 注册 9 个事件到 ~/.workbuddy/settings.json |
| Claude Code | ✅ | node scripts/install-hook.mjs --host=claude-code | 写 ~/.claude/settings.json;事件名与配置结构与 WorkBuddy 同源 |
| Cursor / Copilot CLI / Windsurf | ⚠️ 有 hook,但未适配 | 装不上(事件名与配置结构都不同) | 退回 A 列(MCP 工具) |
| Codex / Gemini CLI / Cline / Roo / Continue / Zed | ❌ 无 hook 机制 | 装不上 | 退回 A 列 |
任何会 git commit 的 AI | ✅(与 AI 品牌无关) | 在你的仓库加 post-commit 推送 | 提交后自动推一条 |
为什么 Claude Code 能复用同一个脚本:本脚本吃的事件名与 stdin 字段
(session_id / transcript_path / cwd / hook_event_name / stop_hook_active / prompt)
以及配置形状({"hooks":{"<事件>":[{"hooks":[{"type":"command",…}]}]}})
就是 Claude Code 的 hook 协议(同名同形)。
🔴 但有一个必须改的点:WorkBuddy 认 stdout 上的 {"async":true} 握手(让 Stop 不阻塞「本轮收尾」),
而 Claude Code 会把 hook 的 stdout 当上下文注入下一轮 —— 原样照搬等于每次提问都往模型上下文里塞一段 JSON。
所以注册到 Claude Code 时必须带 --host=claude-code(脚本据此闭嘴),异步交给 handler 的 "async": true。
install-hook.mjs 已把这两点都处理好了 —— 别手抄命令。
⚠️ 只装了连接器市场包、手里没有本套件的用户:拿不到
scripts/install-hook.mjs, B 列就装不上 —— 老老实实用 A 列(MCP 工具)或 git hook。这是宿主级机制,绕不过去。
兜底顺序(宿主装不了 hook 时):
- MCP 工具(A 列)—— 所有宿主通用,但要求 AI「想起来调」;
- git hook(
post-commit)—— 任何会 commit 的 AI 都触发,与 AI 品牌无关。
三条通道共用同一套口径(level 白名单与别名、title 512 / body 4000、source 净化与 32 字上限、
任务完成标题模板、参数形状、externalId 规则),权威定义在
本套件 lib/mcp-contract.mjs(唯一来源)。
两个校验脚本守着它,改一处不改另一处会直接失败:
| 命令 | 校验什么 |
|---|---|
node scripts/mcp-contract-check.mjs | 契约 ↔ MCP stdio ↔ SKILL ↔ examples(含真起进程抓包断言 source / 标题 / 截断 / 参数形状) |
node scripts/webroom-contract-check.mjs | 契约 ↔ SKILL ↔ MCP(含 examples 载荷合法性、别名表、必填项、CLI 参数) |
本文件写错一次,所有读它的 AI 会一起推错 —— 所以改契约必须同时改 MCP 工具定义并跑这两个检查。
7.5 中台侧(服务端)交叉校验(可选)
上面的两个检查默认只校验本套件内部。若要顺带校验「契约 ↔ 服务端实现」是否还一致, 可在 webhookroom(服务端仓)经 submodule 调用并传参,例如:
bashnode scripts/mcp-contract-check.mjs \ --server-plugin=server/src/plugins/mcp-http.ts \ --server-routes=server/src/routes/index.ts \ --server-levelmap=server/src/normalizers/levelMap.ts \ --server-openapi=docs/openapi.yaml node scripts/webroom-contract-check.mjs --server-root=<webhookroom 仓根>
不传这些参数时,服务端相关检查整体跳过,保证「只拿本套件」也能全绿。
八、运行环境:你这个「进程」到底住在哪(云端 / 容器 AI 通用版)
上面 §一 那三行 export 是给自己手推用的。但真正常见的场景是:没人手推,AI 自己要在
运行时推。 而「运行时」不是一个环境,是两类,差别不在你多会写代码,而在谁去调用你:
| 常驻本地(宿主型) | 一次性容器(渲染型) | |
|---|---|---|
| 典型 | WorkBuddy / Claude Code / Cursor 的本机插件与 hook | 云端构建里的 NPC、CI 里的脚本、一次性的 Reviewer 容器 |
| 谁触发 | 宿主按事件拉起你(UserPromptSubmit / 提交后 / 会话收尾) | 平台按「一条评论 / 一次按钮 / 一次提交」把容器拉起来,跑完销毁 |
| 容器里有什么 | 用户自己配的 mcp.json / ~/.workbuddy | 一个刚 clone 的空仓库 + 平台注入的那几个 CNB_* 变量。没有 mcp.json,没有 WARROOM_* 环境变量,没有后台服务端。 |
| 推消息靠什么 | 宿主替你把环境变量注入子进程 | 只能靠你自己从仓库里读配置拼出来 —— 没有任何东西会替你注入 |
渲染型是默认会被漏掉的一类。 本套件文档此前所有命令都默认「你已经有了 WARROOM_API_BASE / WARROOM_WEBHOOK_KEY」,
而对云端 NPC 来说这三个变量在同一台机器上根本不存在,于是「照文档做」的结果是
要么回落成自己容器里的 localhost:3500(那里没有任何服务),要么直接不带密钥打过去吃 401。
🔴 关键认知:本套件不是那个中台。中台是独立部署的云端服务,由部署方提供。 你不需要、也不应该在渲染型容器里「本地起一个中台再推」。推,就推已经在跑的远端中台。
8.1 先确认自己是哪一类
bashecho "NPC=${CNB_NPC_NAME:-(无)} EVENT=${CNB_EVENT:-(无)}" # 有值 = 你跑在渲染型容器里 env | grep -c '^WARROOM_' # 0 = 没人替你注入连接参数
两条都命中(有 CNB_*、没有 WARROOM_*)→ 走下面 §8.2,别去手拼 localhost:3500。
8.2 渲染型:四个坑,按顺序踩过去
坑 1 · 你连不上任何人的中台。
同一容器里没有你的中台服务:云构建的默认镜像里没有「一条命令起本地中台」的脚本,
127.0.0.1:3500、localhost:3500、「先本地起一个再推」在渲染型里全部不成立。
要推,只能推已经在跑的远端中台(就是你平时看大屏的那个地址)。
坑 2 · 基址不在环境变量里,在被注入的配置 / 密钥管理里。 渲染型能拿到的只有平台注入的非密钥字段与你自己在部署配置里写的非密钥字段。 本套件不提供任何写死的中台地址(那是部署方的事)。所以渲染型拿基址只能靠:
- 平台 / 流水线注入的
WARROOM_API_BASE;或 - 你显式在部署配置里写好的中台域名。
三件必须知道的事:
- 中台地址未配置时,渲染型推不了 —— 此时唯一诚实的做法是不发,并把这件事说出来(见坑 4)。
- 回环地址必须判出来:
http://localhost:3500看着正常,但发给别的系统后对方会去连它自己。 判据(本项目其它出口一致):
bashcase "$BASE" in ''|*localhost*|*127.0.0.1*|*0.0.0.0*) echo "回环 / 未配置 → 渲染型推不了,别推"; ;; *) echo "可用基址前缀:$BASE/api/v1/webhook/<工位标识>" ;; esac
坑 3 · 密钥在渲染型里一定是缺的,而「带着可推导的密钥去推」是个陷阱。 真密钥由部署方保管、经平台 secret 注入,仓库里一个字都不写。于是只剩两条路,只有一条是对的:
| 做法 | 结果 |
|---|---|
回落演示推导值 whsec_demo_<channelId> | ❌ 生产环境会被直接拒(推导式密钥禁用于生产),非生产环境会静默收下 → 得出「推成功了」的假结论 |
| 把真密钥写进容器能读到的任何地方(仓库文件 / 构建配置 / 评论 / 日志) | ❌ 等于公开泄露:文档、评论、构建日志都是全仓可读 |
| 把密钥交给平台密钥管理,由容器在运行时注入 | ✅ 唯一正解:WARROOM_WEBHOOK_KEY 由流水线 secret 注入,仓库里一个字都不写 |
坑 4 · 本套件的脚本会「探活失败就静默跳过」,别把它当成功。
warroom-webhook.cjs 只会在推完之后才报错;若基址是 localhost 且没起服务,会拿到连接失败。
渲染型的现场形态是「退出码 0、流水线绿、大屏一条都没有」—— 本套件定义过的「静默降级」形态。
所以渲染型推完必须校验 created(或 CLI 文案前缀),不能只看退出码:
bash# 唯一可信的成功判据:响应体里的 created,或 CLI 的「已推送」前缀 node mcp/warroom-webhook.cjs --notify --title "…" --summary "…" --external-id "…" 2>&1 | grep -q '✅ 已推送' \ || echo "⚠️ 没推上去(去重命中 / 探活跳过 / 401),别当成功"
8.3 渲染型的最小正确做法(照抄这一份)
bash# 0) 身份:多 AI 共用一块大屏时,不带 source 就分不清谁推的 export WARROOM_AI_NAME="CodeBuddy" # 或 --source CodeBuddy # 1) 基址:由平台 / 部署配置注入,读不到就**不发**(不要手拼 localhost) BASE="${WARROOM_API_BASE:-}" if [ -z "$BASE" ] || printf '%s' "$BASE" | grep -qE 'localhost|127\.0\.0\.1|0\.0\.0\.0'; then echo "渲染型环境拿不到可用基址 —— 本轮不推送(中台地址应由平台 / 部署配置提供)。" exit 0 fi export WARROOM_API_BASE="$BASE" # 2) 密钥 + 工位:由平台 secret 注入;缺了就别猜、别回落演示值 if [ -z "${WARROOM_WEBHOOK_KEY:-}" ]; then echo "缺 WARROOM_WEBHOOK_KEY —— 渲染型不回落演示密钥(生产会 401 / 非生产会给出假成功)。" echo "请在流水线里用平台密钥管理注入这一项。" exit 0 fi # 3) 幂等键:**必须每轮都不同** # ⚠ 缺省键是 `commit-<hash>`,在两次提交之间是常量。渲染型常见形态是「一次会话里 # 连着推好几条」—— 缺省键会让第 2 条起全部命中去重,而**去重命中不更新正文**, # 现场就是「推了但大屏只有第一条」。给一个带事件语义的键: # AI 每轮:hook:stop:<会话id>:<本轮答复指纹> · 待办/阻塞:hook:notify:<sid>:<type>:<指纹> export EXT_ID="${EXT_ID:-render:${WARROOM_AI_NAME}:${CNB_EVENT:-manual}:$(date -u +%Y%m%dT%H%M)}" # 4) 推送(用 CLI 拿「已推送 / 已存在」的明确文案,比手写 curl 好判成败) node mcp/warroom-webhook.cjs --notify \ --title "🔧 本轮变更已就绪" \ --summary "改了什么的一句话结论(细节放链接,别塞日志)" \ --level info \ --source "$WARROOM_AI_NAME" \ --external-id "$EXT_ID"
8.4 想真正让渲染型「每次变更都能推」:一步到位的做法
上面那套是「在没有配置的世界里尽量不说谎」。要让它每次都推得成,得有人把两样东西送进容器: 中台地址与工位密钥。本套件已有的机制里,只有两者都由平台注入才成立:
| 手段 | 送地址 | 送密钥 | 渲染型能用吗 |
|---|---|---|---|
mcp.json(MCP stdio) | ✅ | ✅ | ❌ 渲染型起不了子进程、也没有 mcp.json |
| 仓库文件 | ✅(非密钥字段可入库) | ❌ 绝不能 | 地址可以,密钥不行 |
| 平台密钥管理 / 流水线 secret | ✅ | ✅ | ✅ 正解:WARROOM_API_BASE + WARROOM_WEBHOOK_KEY 各注入一份 |
宿主 hook(UserPromptSubmit / 提交后) | ✅ | ✅ | ❌ 那是宿主型的机制,渲染型没有宿主 |
所以回答「渲染环境能不能每次变更都推」这件事,口径只有一句:
能,前提是中台地址与工位密钥有一个可注入的来源。在那之前,渲染型至少要做到
「不推错」—— 不把消息发进一个不存在的 localhost,也不拿公开可推导的密钥去换一个假成功。
8.5 手动验证链路
- 想手动验证链路,用一个能触发推送的入口(如你平台的中台「推送一条」按钮,或装好 MCP 后调一次
push_warroom_event),看大屏是否出现、是否created:true。 - 自己手推就用不同的
externalId:同键重推会命中去重(逐字相同 → 不新增;内容有差异 → 原地改写那条),别把「没新增」当成「推失败了」。


