diff --git a/.claude/skills/pm-dispatch/references/dispatch-runbook.md b/.claude/skills/pm-dispatch/references/dispatch-runbook.md index 84d823b148..0b76db8a21 100644 --- a/.claude/skills/pm-dispatch/references/dispatch-runbook.md +++ b/.claude/skills/pm-dispatch/references/dispatch-runbook.md @@ -65,6 +65,21 @@ issue 编号。 一),⛔ 不定级、不改标签 —— 定级单一生产者是分诊席。唯一例外:skills 车道的 finding 由该席自分诊(座位贴协议既有裁定),全仓分诊轮跳过该车道,防双生产者。 +## 座位贴活性巡查(分诊轮常设项;维护者 2026-08-14) + +出处:「结论当轮执行」同批裁定;先例是七条 🟢 贴一日内被逐条手工纠正。分诊 +Routine 每 fire 附带一次: + +- **扫描面**:`label:pm:seat` 列表页上标题挂 `🟢 ` 的贴; + `🟢 Routine` 座位以调度器读数为准,不入本巡查。 +- **判据只认在任者自己的产出**(自有评论/认领/贴正文编辑 —— 作者与时间戳是平台盖 + 章的硬读数),⛔ 不算别人发给它的跨座位通知 —— 收到消息不是活着的证据。 +- **>24h 无自有产出(与既有回收线同一条)⇒ 当场降级**:标题改 `⏳ vacant` + 摘 + assignee **同笔**,留证据评论(最后自有产出的时间戳与链接);在飞认领照认领协议 + 由原认领者跟完。 +- 惰性判定(接管冲突时)照旧,本巡查是其常设出口,把「标题挂 🟢 而人已下班」的窗 + 口压到一个班次以内(三元同笔与回收细则见 `seat-post-protocol.md`)。 + ## 落卡与裁决记录细则(维护者 2026-08-13) 出处原话(逐字,未译):「还有很多我发现分诊或者决裁后没有改状态,这个也是问题」… @@ -90,6 +105,19 @@ issue 编号。 前提 / 具体问题 / 选项 / 推荐 / 相关单与 PR;标签就是维护者的收件箱,答复后按上 面四件录裁。 +## 结论当轮执行(维护者 2026-08-14) + +出处:维护者裁(逐字,未译)「现在开工派发」;当日实测是成批「建议关闭」复测结论 +与结论表躺置多日无人执行。 + +- **结论产生的那一轮就执行它。** 复测/审计/裁决得出结论而不在同一轮完成对应的关 + 闭/换标写入,本身就是半状态,与半状态家族同列 —— 记录后离场 + (record-and-walk-away)⛔ 禁止;结论表同规,表不是交付物,落账才是。 +- **无权执行 ⇒ 点名有权者,并当轮把状态搬进对方收件箱**(换标进对方的收件箱视图/ + 决策箱),⛔ 不许只留一条评论等人路过 —— 评论不是任何人的收件箱,标签才是。 +- **机械半边**:「交付 PR 已合并而卡仍挂 `pm:dispatched`」由半状态巡查的 + report-only 项浮出(细节以脚本头为权威);旗标是兜底,当轮执行才是修法。 + ## 云卡(`mode:cloud`)四课 一次性云卡用 `create_session`,⛔ 不用 create_trigger+fire(维护者 2026-08-07 拍 diff --git a/.claude/skills/pm-dispatch/references/seat-post-protocol.md b/.claude/skills/pm-dispatch/references/seat-post-protocol.md index 81a54eec7b..9f347941e1 100644 --- a/.claude/skills/pm-dispatch/references/seat-post-protocol.md +++ b/.claude/skills/pm-dispatch/references/seat-post-protocol.md @@ -33,10 +33,12 @@ - **残余竞态纪律**(唯一剩下的多写手场景是空缺座位争用):动手前重新 fetch 贴正 文;审计评论**时间戳先到先得**;写后回读。新增座位贴(拆域)同样先查 `pm:seat` 索引再立贴。 -- **无心跳,活性惰性判定**:座位不定期报活;只在**接管冲突**时评估一次 —— - Routine 座位查调度器(`last_fired` / `next_run`),会话座位查最近一条产出评论的 - 时间戳,**>24h 无产出即可回收**(改贴正文 + 审计评论)。子树/批次里在飞的认领仍 - 由原认领者跟完。 +- **无心跳,活性判定两条路**:座位不定期报活。惰性半边在**接管冲突**时评估一次 + —— Routine 座位查调度器(`last_fired` / `next_run`),会话座位查最近一条产出评 + 论的时间戳,**>24h 无产出即可回收**(改贴正文 + 审计评论);常设半边是分诊 + Routine 每 fire 附带的座位贴活性巡查,按同一 >24h 判据主动把静默的 🟢 贴降级 + `⏳ vacant`(细则见 `dispatch-runbook.md`)。子树/批次里在飞的认领仍由原认领者 + 跟完。 - **每轮巡检核对自己的座位贴正文**:协议或结构升级会迁移状态,自查一贴成本为零; 发现不符当场改正文 + 审计评论。Routine 座位的收尾简报也落自己的座位贴(它是下一 轮自退守卫的读数)。 diff --git a/scripts/pm/check-half-states.mjs b/scripts/pm/check-half-states.mjs index 326c8435fd..9ce9f79e7f 100644 --- a/scripts/pm/check-half-states.mjs +++ b/scripts/pm/check-half-states.mjs @@ -67,9 +67,19 @@ * carrying a closing keyword bound to that same `#N` — contradictory by * construction. `Part of` is the protocol saying "merging this must NOT * close the card"; a closing keyword is GitHub being told it must. - * GitHub wins, silently, on merge. This is the only item over PULL + * GitHub wins, silently, on merge. This was the first item over PULL * REQUESTS rather than issues, because the PR body is the surface where * the fact is still fixable — see the next section. + * H8 a card's delivering PR is MERGED while the card still carries + * `pm:dispatched` — the merge's paired write (drop the label, re-grade + * the remainder) never landed (#8683). Delivery is read from merged PR + * bodies with H7's code-stripped extractors (`Part of #N`, or a closing + * keyword bound to `#N` — either way an OPEN dispatched card named by a + * merged PR is a half-state, whichever mechanism failed). Live mode + * feeds H8 a bounded window of recently merged PRs, so it is a patrol + * accelerator, never an exhaustive audit: a delivery older than the + * window is invisible, and the finding clears when the paired write + * lands, not when the PR ages out. * * ## The close mechanism, measured (#8293) * @@ -414,6 +424,45 @@ export function h7PartOfWithClosingKeyword(pr) { .join('; '); } +// --------------------------------------------------------------------------- +// H8 — delivering PR merged, card still `pm:dispatched` (#8683). +// +// Pure over the shapes the sweep already consumes (REST issue + `/pulls` +// rows), reusing H7's code-stripped extractors so the measured reference- +// parser behavior (#8293) carries over. No new API layer. +// --------------------------------------------------------------------------- + +/** + * H8 — null when clean, else the finding sentence. + * + * A PR "delivers" card N when its body declares `Part of #N` or binds a + * closing keyword to `#N`, read through `stripMarkdownCode` (a body QUOTING + * either spelling in backticks does not deliver). Only `merged_at`-set PRs + * count — closed-unmerged is an abandoned attempt, not a delivery. Bound per + * issue number exactly like H7. + */ +export function h8MergedPrStillDispatched(issue, mergedPrs) { + if (!labelNames(issue).includes('pm:dispatched')) return null; + const n = String(issue.number); + const delivering = []; + for (const pr of mergedPrs ?? []) { + if (!pr?.merged_at) continue; + const body = pr.body ?? ''; + if (partOfTargets(body).has(n) || closingKeywordTargets(body).has(n)) { + delivering.push(pr); + } + } + if (delivering.length === 0) return null; + const list = delivering + .map((p) => `#${p.number} (merged ${String(p.merged_at).slice(0, 10)})`) + .join(', '); + return ( + `delivering PR ${list} is MERGED but the card still carries \`pm:dispatched\` — ` + + `the merge's paired write never landed. Drop \`pm:dispatched\` and re-grade the ` + + `remainder (re-queue, close, or block the un-delivered half) in the same stroke.` + ); +} + // --------------------------------------------------------------------------- // Transport prerequisite — the classifier (pure) and the probe that feeds it. // @@ -776,10 +825,11 @@ async function sweep() { const findings = []; const seen = new Map(); const seenPrs = new Map(); + const seenMerged = new Map(); try { - await sweepInto(findings, seen, seenPrs); + await sweepInto(findings, seen, seenPrs, seenMerged); } catch (err) { - err.sweptSoFar = seen.size + seenPrs.size; + err.sweptSoFar = seen.size + seenPrs.size + seenMerged.size; throw err; } @@ -788,8 +838,8 @@ async function sweep() { console.log(` ${code} #${issue.number} ${msg}\n ${issue.html_url}`); } console.log( - `check-half-states: swept ${seen.size} open pm-labeled issue(s) and ${seenPrs.size} open PR(s) ` + - `in ${OWNER_REPO} — ${findings.length} half-state(s) found. ` + + `check-half-states: swept ${seen.size} open pm-labeled issue(s), ${seenPrs.size} open PR(s) ` + + `and ${seenMerged.size} recently-merged PR(s) in ${OWNER_REPO} — ${findings.length} half-state(s) found. ` + `Report-only: findings are patrol input, not a gate verdict.`, ); } @@ -804,7 +854,27 @@ async function listOpenPullRequests() { return out; } -async function sweepInto(findings, seen, seenPrs) { +/** + * The merged-PR window H8 reads: most recently UPDATED closed PRs, merged + * ones only, capped at two pages — a quota decision whose consequence is + * H8's stated boundary (a delivery older than the window is invisible). At + * ~18 merges/day two pages reach well past the longest measured + * unexecuted-verdict latency; `sort=updated` so a long-lived PR that merges + * late is still in the window when it matters. + */ +async function listRecentlyMergedPullRequests() { + const out = []; + for (let page = 1; page <= 2; page++) { + const batch = await rest( + `/repos/${OWNER_REPO}/pulls?state=closed&sort=updated&direction=desc&per_page=100&page=${page}`, + ); + out.push(...batch.filter((p) => p.merged_at)); + if (batch.length < 100) break; + } + return out; +} + +async function sweepInto(findings, seen, seenPrs, seenMerged) { for (const label of ['pm:dispatched', 'pm:queue', 'pm:blocked', 'pm:seat']) { for (const issue of await listIssues(label)) seen.set(issue.number, issue); } @@ -848,6 +918,15 @@ async function sweepInto(findings, seen, seenPrs) { const contradiction = h7PartOfWithClosingKeyword(pr); if (contradiction) findings.push([pr, 'H7', contradiction]); } + + // H8 — one bounded merged-PR listing (window note at the helper), matched + // against the already-collected open `pm:dispatched` cards; no per-card fetch. + for (const pr of await listRecentlyMergedPullRequests()) seenMerged.set(pr.number, pr); + const mergedWindow = [...seenMerged.values()]; + for (const issue of seen.values()) { + const stale = h8MergedPrStillDispatched(issue, mergedWindow); + if (stale) findings.push([issue, 'H8', stale]); + } } // --------------------------------------------------------------------------- @@ -1011,6 +1090,74 @@ function selfTest() { ); t('H7: a fenced-only keyword is not a finding', h7PartOfWithClosingKeyword(pr('Part of #5\n\n```\nFixes #5\n```')), null); + // -- H8: delivering PR merged, card still `pm:dispatched` (#8683) ---------- + // Fixtures reuse H7's extractor pins, so the stripping and per-number- + // binding measurements carry over rather than being re-proved. + const dispatched = (n) => ({ ...issue(['pm:dispatched'], ['os-help']), number: n }); + const mergedPr = (number, body, merged_at = '2026-08-13T10:00:00Z') => ({ number, body, merged_at }); + + t( + 'H8: merged Part-of PR + still dispatched -> finding', + typeof h8MergedPrStillDispatched(dispatched(4321), [mergedPr(4400, 'Part of #4321 — the non-destructive half only.')]), + 'string', + ); + t( + 'H8: …and the finding names the delivering PR', + h8MergedPrStillDispatched(dispatched(4321), [mergedPr(4400, 'Part of #4321')]).includes('#4400'), + true, + ); + t( + 'H8: …and prescribes the paired write, not just the fact', + h8MergedPrStillDispatched(dispatched(4321), [mergedPr(4400, 'Part of #4321')]).includes('pm:dispatched'), + true, + ); + // The closing-keyword arm: an OPEN dispatched card named by a merged PR's + // closing keyword is a half-state whichever mechanism failed (see header). + t( + 'H8: merged closing-keyword PR + still-open dispatched card -> finding', + typeof h8MergedPrStillDispatched(dispatched(4321), [mergedPr(4400, 'Fixes #4321')]), + 'string', + ); + t( + 'H8: card without pm:dispatched is out of scope', + h8MergedPrStillDispatched({ ...issue(['pm:queue'], ['os-help']), number: 4321 }, [mergedPr(4400, 'Part of #4321')]), + null, + ); + // Closed-unmerged is an abandoned attempt, not a delivery: demanding the + // paired write for work that never landed would be a phantom finding. + t( + 'H8: closed-unmerged PR is not a delivery', + h8MergedPrStillDispatched(dispatched(4321), [{ number: 4400, body: 'Part of #4321', merged_at: null }]), + null, + ); + // Bound per issue number, exactly like H7. + t( + 'H8: merged PR delivering a DIFFERENT card -> clean', + h8MergedPrStillDispatched(dispatched(4321), [mergedPr(4400, 'Part of #9999\n\nFixes #8888')]), + null, + ); + // Strip reuse: a body QUOTING the spelling in backticks does not deliver — + // the same careful-author protection H7's reading 4 measured. + t( + 'H8: reference inside backticks does not deliver', + h8MergedPrStillDispatched(dispatched(4321), [mergedPr(4400, 'the dispatch asked for `Fixes #4321` and `Part of #4321`')]), + null, + ); + // A plain prose mention is neither declaration: only the two protocol + // spellings establish the delivering relation. + t( + 'H8: plain prose mention does not deliver', + h8MergedPrStillDispatched(dispatched(4321), [mergedPr(4400, 'follow-up to #4321, measurement only')]), + null, + ); + t( + 'H8: two merged deliverers -> both named', + h8MergedPrStillDispatched(dispatched(4321), [mergedPr(4400, 'Part of #4321'), mergedPr(4500, 'Fixes #4321')]).includes('#4500'), + true, + ); + t('H8: empty merged window -> clean', h8MergedPrStillDispatched(dispatched(4321), []), null); + t('H8: missing merged window -> clean', h8MergedPrStillDispatched(dispatched(4321), undefined), null); + // -- transport prerequisite (#7412) --------------------------------------- // The three container classes are REAL measurements, not invented fixtures; // each names where it was taken, so a future transport change can be checked