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
97 changes: 96 additions & 1 deletion .claude/skills/pm-dispatch/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,34 @@ flaky 连踢五个互不相关的 PR,核对失败签名一致后原样重投,五
- REST core(15000/时)在共享身份下**同样会打满** —— 本条的三份判据(rate_limit
读数、整点重置、独立计费)对它一体适用,别把「走 REST」读成「不限量」。

**定点文本的写法纪律 —— 已删除的定时器仍会投递,且投递时文本可能已落后现实数轮。**
上面第一条让定点文本**完整**(带全待执行状态),这一条让它**过期时仍然安全**;两条
是同一枪的两面,都成立才够用。2026-08-07 跨两个座位三次实测,两种形态、同一个后果:

- `domain:spec-surface` 席**两枪已 `delete_trigger`**(回包确认 `deleted trigger …`)
的定时器**照样投递**,文本都落后现实两轮。其中一枪写着「#5783 …… 判为不可靠、
交接、**重新派发一个 fresh os-dev**,worktree `objectstack-issue-5783` 已存在」;
投递时 #5783 的 PR #6389 早已交付并通过复核 —— 照文本执行就是把一个重复 agent
塞进一个活着且已完工的 worktree,正是认领协议要防的碰撞类,只不过这次是**从
自动化里**来的,而不是从抢跑的 PM 那里。
- `domain:devx` 席的一枪**没被删,是被现实追上**:21:3x 挂、22:1x 投递,文本写的
「两个 dev 静默结束、未开 PR、未交报告 ⇒ 判定失效 ⇒ 重新派发一个新 dev」在投递
时前提已被推翻(两个 dev 都已回话正常推进,其中一个的 PR 已合并)。照做会向两个
活着且已有成果的任务各塞一个重复 dev。删与没删是两条路径,终点是同一个。

两条硬规则:

- **每一枪定点文本必须以「幂等 —— 动手前先重读状态」开头**,⛔ 不得包含未经重读
即可执行的祈使句。三次都没出事的唯一原因就是这句在文本里、且重读**真的被执行**;
定时器一旦投递,平台侧没有任何东西会替你复核它的前提 —— 把重读写进文本是**唯一**
能让过期指令失效的机制。
- 文本只许描述**判据**(「若 X 则 Y」),⛔ 不许描述**结论**(「现在去做 Y」)。
「⇒ 重新派发」「⇒ 判为不可靠」「⇒ 打回不 arm」这类祈使句正是要禁的形态:它们在
写下的那一刻可能是对的,投递时未必还是,而祈使句把「判据可能已变」这件事从文本里
抹掉了 —— 判据句自带复核,结论句把复核外包给了一个已经不在场的自己。

本条的落点在 step 6(巡检定点)与 step 7(flip 定点)各有一条同款约束,写法一致。

**4. 核验 main 的事实用 `origin/main`,不用共享检出的工作树。** 共享检出的 HEAD 由
别的 agent 摆布,可能落后 origin/main 数十提交(今天 PM 与一名 dev 都在落后 63 提交的
树上 grep 出假阴性,据此差点判了错误的结论)。一律先 `git fetch origin main`,再:
Expand Down Expand Up @@ -331,6 +359,31 @@ REST 取 `body`(原文 4321 / 5183 / 4181 字符)+ 取 `body_html`(渲染版)—
其它分类更硬:误判一次的代价是一条可入队缺陷躺一天,外加一条打给作者的假工单。
已发出的重贴指令若事后证伪,**要在同一处公开作废**(同 notes 7:诊断结论一旦
公开发出又被推翻,更正要发在同样公开的位置)。
- **同一个 sanitizer 的第二种形状 —— 写侧的「就地删除」,上面两条的判据抓不到它,
反引号也不保护。** 上面两条管的是**读侧**误判(把读取端截断当成 issue 端截断),
⚠️ 一字不改、依旧成立;这一条是新增的**另一种失效形态**,不是对它的修正:短的
`<…>` 片段在**写入时**被就地删掉,正文其余部分完好无损 —— 没有「断掉的位置」,
所以「两者在同一处断掉」这个判据在它身上恒假,双读取会一致地告诉你「正文完整」,
而它确实完整,只是少了几个片段。2026-08-07 `domain:spec-surface` 席在座位贴的
交接台账上写后回读实测三例,三例都在反引号里、三例都被吃掉:

| 写入 | 存回 |
|---|---|
| `<!-- os-dev-report -->` | (整段变成空) |
| `expected <n> to be 19` | `expected to be 19` |
| `git log -- <path>` | `git log -- ` |

第一例是有代价的:那个标记是一次座位交接中在飞 dev 报告的**全部收集路径**
(step 6 `mode:cloud` 的收集判据),台账因此让接班人去扫一个**已经从台账里被删掉
的**标记 —— 只有写后回读抓到了它。两条动作:

- 正文里凡要保留字面尖括号,一律写 HTML 实体 `&lt;` / `&gt;`,⛔ 不靠反引号或
围栏 —— 实测它们不提供保护;
- 含 HTML 注释标记(如 `<!-- os-dev-report -->`)、`<n>` / `<branch>` / `<repo>`
一类占位符、泛型参数的正文,**写后回读逐个确认这些片段仍在**,这是动作不是提醒。
label discipline 的「写后回读」是同一条纪律的上位(#5885 那两次「sanitizer 吞
内容」即本形态),本条给的是它的**具体形状与判据**:失效完全静默 —— API 返回
成功,渲染页看不出缺口,只有把存回的正文与你写的原文逐段对比才看得见。

**13. MCP 工具的两个参数语义陷阱 —— 过滤是 OR、labels 是整组替换。** spec 车道
一任内两次实测(#5925),都是 notes 6「命令没在回答你以为的问题」的 API 参数版:
Expand Down Expand Up @@ -1549,13 +1602,51 @@ attached.
末尾的重挂会整个丢失,守夜链就此断裂 —— 2026-08-06 实测:一次漏挂让
四连灭批静默了 ~100 分钟而不是探活门槛设计的 ≤45 分钟。先挂后查,链条
对中断免疫;挂错了间隔可以在本轮末尾用 delete_trigger + 重挂修正,但
「没挂」无法被本轮以外的任何机制补救。
「没挂」无法被本轮以外的任何机制补救。重挂的那一枪按 **notes 3 的定点
文本写法纪律**写:以「幂等 —— 动手前先重读状态」开头、只写判据不写结论。
巡检定点是最容易写成祈使句的一类(「⇒ 判为不可靠 ⇒ 重新派发」),也是
投递时最可能已经过期的一类 —— 在飞的 dev 在两次唤醒之间会推分支、开 PR、
交报告,而已 `delete_trigger` 的定时器仍会投递(notes 3 实测两次)。
- **批量在飞期间,主巡检间隔不得长于 45 分钟**(同一授权)。探活门槛是
45 分钟,巡检间隔一旦超过它,门槛就成了写在纸上的数字 —— 最坏情形下
一个派发后即死的 agent 要等到下一轮巡检才被发现,静默窗口 = 巡检间隔,
而非门槛值。在飞清零的待命期可放宽到 60-70 分钟;有任何 dev 在飞即收紧
回 ≤45,灭批频发期(如宿主重启风暴)进一步压到 20-30 分钟。

**45 分钟是发探针的门槛,⛔ 不是判死的门槛 —— 两者必须分开。** 上面五条回答
「什么时候去问」;这一条回答 PM 真正面对的另一个问题:「多久之后我才可以下
『它死了』的结论」。分开的理由是**后果不同** —— 探针的代价是一次 SendMessage,
判死的代价是**重新派发**,即往一个可能还活着的 worktree 里塞第二个 agent
(step 5「Handing off an interrupted dev」的碰撞面)。

- 判死的正当依据只有三类:**探针回包表明已死**(「no active task; resumed from
transcript」一类)、**宿主明确回报 stopped**、或**超过本车道基线且连续静默**。
⛔ 「探针门槛过了两次」不在其中 —— 它只说明它还在跑。
- 「超过基线」里的基线是**你自己车道实测的完工耗时**,⛔ 不是本文里的任何常数。
没有基线就先建基线再判:同形态卡片各记一个「派发 → 推分支 / 开 PR」的端到端
耗时,三五单即可用。**在基线之内的沉默不是证据。**
- 两条实测基线**只是出处样例,⛔ 不是全车队常数** —— 卡片形态不同,区间没有理由
相同,driver 或 engine-core 的重活不适用下表:

| 出处(车道 / 日期) | 卡片形态 | 实测端到端 |
|---|---|---|
| `domain:spec-surface` 席,2026-08-07(#6393) | 文本面卡:#5767 / #5622 / #5955 / #5783 | 93 / 96 / ~95 / ~110 分钟 |
| `domain:devx` 席,2026-08-07(#6393 认领评论) | 混合:#6251 / #6038 / #6405 / #6359 | ~67 / ~64 / ~160 / ~170 分钟(后两单含长 CI 等待) |

合两席九单:同一天、同一套工具下,端到端跨越 **~64 分钟到近 3 小时**。凡把单一
数字当判死线的读法,都会在这个跨度里翻车 —— 所以要建的是**你那一栏**的基线。
- 两个座位当天各误判一次,都栽在这条线上:`domain:spec-surface` 席在 92 分钟处
写下「#5783 将判为不可靠」,而它在基线之内、几分钟后就推了分支;`domain:devx`
席在派发 2 小时处判两个 dev「静默结束」并把「重新派发」写进了下一枪定点,而两个
都在做深度取证。后者靠**先 SendMessage 问状态、而不是直接重派**救回 —— 那正是
上面五条的第一条。
- 与既有两个数字的关系,一句话讲清:**45 分钟 = 探活门槛**(去问);**`mode:cloud`
的 ~2h 静默 = 本轮收集边界**(记 `blocked`、本轮不再等,下一轮从 GitHub 重收);
⛔ 两者都不是判死。下面「报告丢失 ≠ 验收停摆」把 ≥2h 与**探活确认已死**并列成
两个条件而不是一个,正是这个区分的既有写法。
- 这与 Operational notes 11(⛔ 不得据症状推断维护者中止)是同一个失效类换了个
变量:在你知道基线之前,「正常但慢」与「已死」的读数完全相同。

**A stalled subagent is this half's most common failure, and it never
self-heals.** When a dev stops mid-task reasoning that "a background watcher will
wake me", **that watcher never fires** — a completion notification is itself the
Expand Down Expand Up @@ -1819,6 +1910,10 @@ flip 定点**,到点核对门禁 job 结论(notes 10)、绿即转 ready + 挂 au
未绿再阶梯重挂。CI success webhook 不可靠是环境明示的前提 —— 一班 13 次转 ready
全部由定点驱动、零漏接(#5885);定点文本按 notes 3 的配额交接纪律携带完整待执行
状态(哪个 PR、什么判据),抗上下文丢失。⛔ 不要坐等 webhook,也不要忙轮询。
notes 3 的**写法纪律**在这里同样是硬要求:文本以「幂等 —— 动手前先重读状态」开头、
只写判据(「若 #N 的门禁 job 结论全绿 ⇒ 转 ready 并挂 auto-merge」),⛔ 不写结论
(「转 ready」)。flip 定点尤其容易过期:那 6–9 分钟里 PR 可能已被队列管家处置、
被踢出、转成 `dirty`、或被别人先入了队,而已删除的定时器仍会投递。

**落地窗口给关键 PR 挂事件订阅(`subscribe_pr_activity`,维护者 2026-08-07
拍板)。** 适用面:会话型座位、且 PR 已进入 PM 的落地窗口 —— ACCEPT 后,以及被
Expand Down
65 changes: 65 additions & 0 deletions skills/objectstack-pm-dispatch/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,27 @@ each selected issue, before dispatching, execute in order:
name exists, you lost — touch nothing of theirs, reply that you are yielding,
and pick another issue. **First comment wins.**

**Read the claim comment back — GitHub's body sanitizer deletes short `<…>`
spans in place, and backticks do not protect them.** The shape above is built
out of placeholders, and the branch name inside it is the key that both the race
check and the stale-claim reclaim below read. Measured on this loop, writing
then reading the stored body back: `<!-- dev-report -->` came back as *nothing at
all*, `expected <n> to be 19` came back as `expected to be 19`, `git log --
<path>` came back as `git log -- `. All three were inside code spans. This is
**not** the truncation shape that step 0's *Repair first* handles: the rest of
the body survives intact, so there is no truncation point to find, the rendered
page looks correct, and the API returned success. The first of those spans cost
the most — it was a report-collection marker, so the instruction to sweep for it
had been deleted from the very text that carried it.

- Write literal angle brackets as HTML entities (`&lt;` / `&gt;`), or put a
space after the `<`. A code span is not protection.
- After writing any body whose content is load-bearing — a claim comment, a
cloud-mode dispatch prompt carrying a report marker, a handover note —
**read it back and confirm each such span is still present**. The write side
needs this read-back for the same reason the read side needs two readings:
"the API returned 200" is not "the stored content is correct".

Developer agents push their branch early — a remote branch is the hardest
evidence of work in flight, and it closes the gap between "claimed" and "a PR
exists".
Expand Down Expand Up @@ -425,6 +446,50 @@ has reported, or a dispatch has been silent for over ~2 h (count it as

**Never treat the absence of a report as success.**

**Write every check-in as criteria, never as a conclusion — scheduled text
arrives in a future you cannot see.** A check-in armed now is read by a session
that has lost your context, against a world that has moved on. Measured three
times in one day on this loop: a scheduled message **still delivered after its
timer had been cancelled**, carrying text two rounds out of date. One of them
read "no branch and no report ⇒ judge the agent unreliable and dispatch a fresh
one"; by delivery time that agent had opened a PR which was already reviewed and
accepted, so executing the text verbatim would have pushed a duplicate agent
into a live, finished worktree — the exact collision the claim protocol exists to
prevent, arriving through the automation instead of through a racing PM. Two
rules make stale text harmless:

- **Open every check-in with "idempotent — re-read the state before acting"**,
and include no imperative that can be executed without that re-read. Once a
timer fires nothing on the platform side re-checks its premise for you;
putting the re-read into the text is the only mechanism that can expire an
obsolete instruction.
- **State criteria ("if X then Y"), never conclusions ("now do Y").** A
criterion re-derives itself on arrival; a conclusion has already discarded the
reading it came from. "⇒ re-dispatch", "⇒ judge it unreliable" are the shape to
avoid — correct when written, not necessarily correct when delivered. This
holds for timers you believe you cancelled too: cancellation is not a guarantee
of non-delivery.

**A silence threshold is a collection boundary, not a verdict of death.** The
~2 h above means "stop waiting this round"; it does not mean the agent is gone,
and the two must not be swapped, because their costs differ by an order of
magnitude — waiting one more round costs a round, while concluding death costs a
**duplicate dispatch into a worktree that may still be live**. Before concluding
that a dispatched agent is dead, require one of: a direct status query answered
in a way that shows it is dead, the host reporting the session stopped, or
**silence past a completion-time baseline you have actually measured**.

Measure that baseline for your own project — dispatch to first pushed branch or
PR, over a handful of comparable cards — and treat it as local. Two same-day
samples from this repo's loop show why no single number can be inherited: four
text-only documentation cards landed at 93 / 96 / ~95 / ~110 min, while four
mixed cards from the same day spanned ~64 min to ~2 h 50 min (the two long ones
waiting on CI). Nine cards, one day, one toolchain, a spread from about an hour
to nearly three. **Silence inside the baseline is not evidence**, and a check-in
threshold having passed twice is evidence only that time passed. This is the
same failure as inferring an abort from symptoms: until you know the baseline,
"working normally, slowly" and "dead" produce identical readings.

### 7. Review each report

You are the reviewer of record. For each report, verify **against GitHub — not
Expand Down
Loading