Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/pm-skill-landing-and-ci-discipline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
---

docs(skills): 补齐「跟到合并为止 / CI 诊断纪律 / 生成物同步」四类 PM 经验(#4892)

发布面零变化 —— 只改 `.claude/skills/` 下的两份内部 agent 说明,不含任何
workspace 包的代码或产物,故为空 changeset。

`pm-dispatch` 的 Operational notes 由四条扩到八条,新增的四条都是 #4885
(同日另一车道的沉淀)覆盖边界之外的:

- **5** —— `rerun_failed_jobs` 复用原 run 的提交/合并 ref,不重算。红的原因若是
「基上缺一个已合并的修复」,重跑本身无效,只有推新提交才拿得到新的合并 ref。
- **6** —— 读数纪律:`cd X && cmd` 短路(跨仓一律 `git -C`)、
`git grep -c | wc -l` 数的是文件数、裸名 grep 被幸存家族当子串命中。
- **7** —— CI 红了先取完整日志归档:completeness check 绿 ≠ 测试通过、
turbo 并发输出相邻 ≠ 因果、不要只看 tail。
- **8** —— 共享基础设施类修复按症状复查 main,并写明
`duplicate-fix-guard.yml` 只覆盖「同仓 + 同一个 `Fixes #N`」。

另在 Operational notes 1 上补了「不在 main 上」是二义读数、队列分支 base sha 串成
链、转 draft 会同时掉 auto-merge 与队列成员资格;并在 step 7 之后新增「入队与落地」
小节,写清 `merge=os-regen` 的七条路径与四步同步协议,以及「跟到 MERGED 为止」。

`spec-property-retirement` 新增「四张 ratchet 的可见性按路线相反」一节(枚举值收窄
不可见 vs 整 def 删除必须变化),并修好第 2 节指向 `plugin-runtime.zod.ts:243-248`
的先例引用 —— 那个文件已被 #4878 整体删除。
113 changes: 110 additions & 3 deletions .claude/skills/pm-dispatch/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,9 @@ protocol is identical.)

## Operational notes(实测坑位)

队列与平台层的四条实测结论。共同点:**判据取命令的输出,不取 API 字段的字面值,
也不取本地工作树的现状** —— 每一条都是在这一步上咬过人之后写下来的。
队列与平台层的八条实测结论。共同点:**判据取命令的输出,不取 API 字段的字面值,
也不取本地工作树的现状,更不取「看起来相邻」的两行日志** —— 每一条都是在这一步上
咬过人之后写下来的。

**1. 判断 PR 是否在合并队列,看 `gh-readonly-queue/*` 分支,不看 `auto_merge`
字段。** 本仓 PR 入队后,REST 返回的 `auto_merge` 回落为 off(队列条目取代了挂起的
Expand All @@ -82,7 +83,17 @@ auto-merge),该字段对「在不在队列里」零信息量,据它反推会得
git ls-remote --heads origin 'refs/heads/gh-readonly-queue/*'
```

分支名里带着这一批被打包的 PR 号;没有匹配分支才是真的没入队。
分支名里带着这一批被打包的 PR 号;没有匹配分支才是真的没入队。另有三点在同一处
咬过人:

- **「判据不在 `origin/main` 上」是个二义读数。** 它同时兼容「在队列里等」和「压根
没入队」,而两者的处置完全相反(前者等,后者要动手)。#4852 的 auto-merge 从 10:15
就挂着,每轮只查 main、判为「排队中」,实际它因 CI 红从未入队 —— 空转 **100 分钟**。
落地检查永远是**两个读数**:队列分支 **和** `origin/main`,缺一不可。
- 队列分支名里的 base sha 是**串成链**的(`pr-4878-<链上一条的结果 sha>`),顺着链读
得出自己排第几。
- **PR 被转回 draft 会同时掉 auto-merge 与队列成员资格**,且不会自动恢复;转正之后
必须重新挂。

**2. 队列踢出:先认签名,再决定重投。** 被踢出不等于 PR 有问题。今天 #4796 那条已知
flaky 连踢五个互不相关的 PR,核对失败签名一致后原样重投,五个全部一次通过 —— 认签名
Expand Down Expand Up @@ -120,6 +131,62 @@ git show origin/main:<path> # 看某个文件在 main 上的现
这条也写进派发词(step 5):dev 在自己的 worktree 里同样会踩,而 worktree 是从
`origin/main` 切的、后续不会自己更新。

**5. `rerun_failed_jobs` 复用原 run 的提交与合并 ref,不会拿新的 main 重算。**
第 2 条讲的是「同一签名再现要重新诊断」;这条讲另一半 —— 当红的原因是**基上缺一个
已经合并的修复**时,重跑这个动作本身就是无效的。#4852 的 CI 红在止血 PR #4856
落地**之前**,重跑一次仍是同一个 5000ms;直到 `git merge origin/main` 推了新提交,
才拿到新的合并 ref。判别方法:比对那个修复的合并时间与本 run 的创建时间 —— 前者
晚于后者,就只能推提交,重跑多少次都没用。

> 与 `.github/workflows/rerun-safety-nightly.yml` **无关**:那个查的是「同一 checkout
> 里跑两遍是否自洽」的测试污染,不是重跑语义。

**6. 读数纪律 —— 三条各自产出过一个「我信了并据此行动」的错读数。** 第 4 条管的是
「在哪棵树上读」,这一条管的是「命令本身是否在回答你以为的那个问题」。

- **`cd X && cmd` 会短路。** Bash 工具每次调用 cwd 重置;`cd /home/user/objectui &&
git grep ...` 在路径不存在时 `cd` 失败、整条命令继续,于是**在当前仓里执行**,产出
假的「objectui 零消费方」。⛔ 跨仓一律 `git -C <path> grep`,不要用 `cd`。
- **`git grep -c <pat> | wc -l` 数的是文件数,不是命中数。** 曾据此得出「分支比 main
命中更多」的荒谬结论。要命中数就不要再套 `wc -l`。
- **裸名 grep 会被幸存家族当子串命中。** 核验 `system/EmailTemplate` 是否已退役时,
裸名命中的是仍然活着的 `EmailTemplateDefinition` 一族。退役核验一律**带引号精确
名**;更硬的判据是查**声明式**(`^(export )?(const|type|interface) <Name>\b`)而不是
查提及 —— 注释、pin 测试的断言词、迁移散文里出现该名是**正常且应当的**。

统一原则:**零命中必须用一个「确定存在的邻近词」反查**,证伪「扫描器坏了 / 路径错了」
这个解释。没有这个反查,零命中不成立。

**7. CI 红了先拿完整日志归档,再下结论 —— 三条读日志的纪律。** 这是 2026-08-03 当天
最贵的错误:公开断定四次 CI 红是**内核 OOM-killer 杀掉 DTS 构建**,据此开了 PR #4853,
然后被 #4853 自己的 CI 推翻(它挂着新参数跑,红得一模一样)。真因是 #4796 那一族的
5000ms 超时,由 #4856 修掉。完整更正见 #4845。三个叠加的错误各成一条:

- **「completeness check 绿」≠「测试通过」。** `check-test-completeness.mjs` 只断言
没有 worker 静默死掉;workflow 自己的注释写着 *"A red suite plus a GREEN
completeness check means real test failures"*。
- **turbo 并发输出的「相邻」≠「因果」。** `test` 的 `dependsOn` 只有 `["^build"]`
(只含上游),`packages/spec` 没有 `pretest`,所以 `--concurrency=4` 下 `spec#build`
与 `spec#test` 同时在跑,GitHub 又给整组打同一个时间戳。`X start` 紧接着
`ELIFECYCLE` 完全可能来自两个无关进程。**先查 `turbo.json` 的依赖边**,再谈因果。
- **不要只看日志 tail。** 那次的 ~10 KB 尾巴被 `gen:schema` 的 1675 行清单吃光,真正
的失败行根本不在里面。取完整日志归档再判。

诊断结论一旦公开发出又被推翻,**更正要发在同样公开的位置**,并把据它开的 PR 撤回
draft、解绑 `Fixes`,免得一个错结论继续被当作已立案的事实引用。

**8. 共享基础设施类修复,入队前按「症状」复查 main,不按 issue 号。**
`.github/workflows/duplicate-fix-guard.yml` 已经在两个 PR 声明**同一个 `Fixes #N`**
时把后开的那个判红(#4588 的产物)。**要记清它的覆盖边界**:同仓、同一个 issue 号。

今天这一例正好落在边界外 —— PR #4864(本线)与 PR #4856(另一车道)修的是**同一个
基础设施问题**,却挂在不同 issue 号下,门禁看不到任何重复。更糟的是 #4856 先合了
`testTimeout: 60_000`,**#4864 若合进去会把它降回 30s**,即一次静默回退。

规则:CI 配置、超时、构建脚本、门禁这类**共享基础设施**的修复,入队前跑
`git log --oneline origin/main -- <该文件>`,确认在飞期间没有别人已经修掉;真撞上了,
先比**数值与作用域**再决定关哪个 —— 后合的那个可能是回退,不是改进。

## Multi-repo coordination (backend / frontend / cloud)

The product spans three repos with a fixed dependency direction:
Expand Down Expand Up @@ -644,6 +711,46 @@ Verdict per issue:
rounds** per issue; a third failure escalates instead.
- **ESCALATE** — see step 8.

#### 入队与落地 —— ACCEPT 之后才是最容易丢单的一段

**A. 碰生成物的 PR,入队前必须先同步 + 整体重生成。** 第 3 步只保证**同一批内**
file-disjoint;它管不到**先后两单都碰 `packages/spec` 生成物**的情形 —— 而协议变更
几乎必然如此。`.gitattributes` 把这七条路径路由到 `merge=os-regen`(⛔ 别只记住前
五条 —— 后两条是文档产物,同样会被静默吞):

```
packages/spec/spec-changes.json
packages/spec/authorable-surface.json
packages/spec/json-schema.manifest.json
packages/spec/api-surface.json
packages/spec/api-surface-signatures.json
docs/protocol-upgrade-guide.md
content/docs/references/**
```

权威清单是 `.gitattributes` 本身(`grep os-regen .gitattributes`),不是这份拷贝 ——
它增删过,以文件为准。

该驱动会让 merge **exit 0、零冲突标记**,却**静默丢掉一侧的改动** —— 只有重新生成
才暴露。2026-08-03 一天内在 #4809 / #4846 / #4841 三个 PR 上各复现一次。要求 dev
按顺序做,四步缺一不可:

1. `git merge origin/main`(⛔ 禁 rebase / force-push,AGENTS.md §3)
2. `git checkout origin/main -- <上述生成物>`
3. **整体重新生成**(⛔ 绝不做文本合并)
4. 断言**所有兄弟单的条目都还在** —— #4878 落地时是四条 step17 条目并存

一个比「条目还在」更硬的旁证:去查**上一单的实现体**是否完好(#4878 合并后核
`conversions/registry.ts` 里 #4391 的 D2 conversion 仍有 16 处命中)。条目是索引,
实现体才是被吞的重灾区。驱动本身另有缺陷(把绝对路径写死进最后跑过 `pnpm install`
的那个 worktree),见 #4868。

**B. 跟到 MERGED 为止,不是跟到「已入队」为止。** 「auto-merge 已挂上」不是终点,
维护者对此有过明确纠正。每轮同时读**队列分支**与 `origin/main`(Operational notes
1);红了先分签名,再在「原样重投 / 推新提交 / 重新诊断」三者里选(notes 2 与 5)。
落地之后**再核一次落地判据本身** —— 队列的合并同样走 os-regen 驱动,A 里那个静默
吞并在队列合并这一步一样能发生。

### 8. Escalate uncertainties to the maintainer

**First, apply the escalation bar — most things that FEEL like decisions are
Expand Down
31 changes: 30 additions & 1 deletion .claude/skills/spec-property-retirement/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ Two corollaries:
|---|---|---|
| **not `.strict()`** | `retiredKey()` tombstone | `retiredKey(guidance)` in `packages/spec/src/shared/retired-key.ts` — `z.never({ error: () => guidance }).optional()`. Two channels: `tsc` (input type `never`) and the parse (the prescription itself, not "unrecognized key"). |
| **`.strict()`** | delete the key + guidance map | Delete from the shape; add an entry to a `*_RETIRED_KEY_GUIDANCE` record consumed by a `z.core.$ZodErrorMap` passed as `z.object(shape, { error: … }).strict()`. Reference: `packages/spec/src/ai/tool.zod.ts:29-93,180`. Also `object.zod.ts`'s `UNKNOWN_KEY_GUIDANCE` for object top-level keys. |
| **nothing parses it** | neither | A prescription nobody can receive is noise. Drop the baseline lines deliberately and say so in the changeset — precedent `packages/spec/src/kernel/plugin-runtime.zod.ts:243-248`. |
| **nothing parses it** | neither | A prescription nobody can receive is noise. Drop the baseline lines deliberately and say so in the changeset — precedents #3896 and #4834 (PR #4878), both in the kernel plugin-runtime family. The explanatory block that survived the family's deletion is `packages/spec/src/kernel/index.ts` (search `plugin-runtime.zod`). |

Never plain-delete a key from a non-strict schema: zod strips it silently and
you have replaced one silent no-op with another (the #2169 "Mark Done does
Expand Down Expand Up @@ -127,6 +127,35 @@ Note template for a tombstone entry (verbatim house style, e.g.

> `REMOVED <date> (#<issue>) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-<N> conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); <what to do instead>.`

### ⚠ 四张 ratchet 的可见性,按路线是**相反**的 —— 拿错对照就会判错

验收时最常问的一句是「四张 ratchet 零变化,正常吗?」。**答案完全取决于你走的是哪条
路线**,2026-08-03 一天内两种形态各实测到一例,正好互为对照:

| 退役形态 | `api-surface` / `authorable-surface` / `json-schema.manifest` / `api-surface-signatures` | 实例 |
|---|---|---|
| 枚举**值**收窄(def 还在,少一个 value) | **字节完全相同 —— 仪器上不可见** | #4391 `crypto.hash`(PR #4871) |
| 整 **def** 删除(schema 不再被 emit) | **必须变化** | #4834(PR #4878):`api-surface −12` / `authorable −23` / `manifest −5` |

为什么会这样:这四张 ratchet 记录的是**导出面与 def 的存在性**,不是 def 内部的取值
集合。枚举少一个 value,导出的名字、schema 的 key、def 的数量都没变,于是四张全都
一模一样。

后果是双向的,两边都很贵:

- 把「枚举值收窄」的零变化**判成异常** → 白折腾,以为 agent 漏做了生成物;
- 把「整 def 删除」的零变化**判成正常** → 放过一个**根本没真正删掉**的 def。

所以验收顺序是:**先确定路线,再决定该期待什么读数**,不要反过来用读数去猜路线。
整 def 删除还有一条自证信号:`json-schema.manifest.json` 的 ratchet(#2978)会先开火,
要求你**有意删除**对应的 manifest key;删完重跑,per-key ratchet 会自行判定为 #4650
路径 3(`def no longer emitted by this build`)。这串输出本身就是路线的证据,留在 PR 里。

枚举值收窄既然对四张 ratchet 不可见,它的处方就只能挂在**枚举自己的 `error` map**
上、按 `issue.input` 分派(`packages/spec/src/data/hook-body.zod.ts` 的
`HookBodyCapability`,沿用 `object.managedBy: 'system'` 的先例)—— 三条路线里没有一条
适用于「def 存活、只少一个值」。

### Writing the guidance string

Five conventions, obeyed by all ~28 tombstones in tree:
Expand Down
Loading