diff --git a/.claude/skills/pm-dispatch/SKILL.md b/.claude/skills/pm-dispatch/SKILL.md index ce804134b40..580d3a1e028 100644 --- a/.claude/skills/pm-dispatch/SKILL.md +++ b/.claude/skills/pm-dispatch/SKILL.md @@ -17,7 +17,7 @@ metadata: 你是 PM 座位。PM 永不写代码,代码全由 `os-dev` 开发 agent 完成,一单一 agent 一 worktree。 PM 的工作是循环:选卡 → 认领 → 派发 → 收集 → 复核 → 报告 → 下一批。 -维护者只在两点进入循环:轮次报告,与 `needs-user-decision` 决策卡。 +维护者只在三点进入循环:轮次报告,`needs-user-decision` 决策卡,`pm:awaiting-merge` PR。 ## 红线 @@ -104,7 +104,7 @@ PM 的工作是循环:选卡 → 认领 → 派发 → 收集 → 复核 → 报 | open + 队列标签 + 无 assignee | 可派发(同卡带 `pm:retriage` 的除外);`pm:queue` 卡恒无 assignee,有即半态 | | assignee 已设 | 已认领/在飞,不是你的就永不碰;离手恒走释放 —— 三因 = 改路由(限未派发)、前提证伪、弃飞无接管,去向 = 新标签态或车道;在飞卡跟到 MERGED,⛔ 不静默摘 assignee | | `pm:dispatched` | 已派发(派发评论记轮次),恒带 assignee;与摘 `pm:queue` 同一次标签写入成对落地 | -| `needs-user-decision` | 决定待做:永不派发、永不代答;维护者的收件箱,只由人工裁 | +| `needs-user-decision` | 决定待做:永不派发、永不代答;维护者的决策收件箱,只挂决策卡(issue)、⛔ 永不挂 PR,只由人工裁 | | `pm:on-hold` | 决定已做且答案是暂不做:不派发不催;仅当带机器可读 `Restart-when:` 行才合法 | | `pm:blocked` + 正文行 `Blocked-by: #N` | 等上游:选择期跳过,#N 关单(PR 则合并,关而未合并不放)时由解锁扫描或 `scheduled-full-run-card.yml` 放回;工已完而被门禁卡住的同用本态;双通道行首 `Blocked-by:` 指向无 `Ruled:` 的 open `needs-user-decision` 卡 ⇒ ⛔ 不派发,直至其 `Ruled:` | | `pm:awaiting-maintainer` | 决定已做,只剩一次 GitHub 之外的人工动作:不派发不催;与其它 pm 状态标签互斥;入态恒带行首 `Maintainer-action:` 行(细则见 `references/state-machine.md`),无行即半态 | @@ -186,12 +186,12 @@ PM 的工作是循环:选卡 → 认领 → 派发 → 收集 → 复核 → 报 - 路径面命中 Tier H ⇒ ACCEPT 换终局四件套,混合 diff ⛔ 不按比例判;要拆让 dev 单独开 PR。 - 改动 >3000 行(含生成物)同换终局四件套含③请审,⛔ 无 Tier S 例外;读数=PR additions+deletions。 - ① 席位记录落 PR 先于 `pr_ready`/入队且钉 head:速读六行+席位意见,其下 `## 席位核对`。 -- ② PR 悬着等人批,同笔挂 `needs-user-decision`;人工直合即审核记录,授权批准 ⇒ 席位落地。 +- ② PR 悬着等人批,同笔挂 `pm:awaiting-merge`;人工直合即审核记录,授权批准 ⇒ 席位落地。 - 指针 `ACCEPT → PR #n comment `;受管/spec 面欠完整复核 ①–④,席未达档起隔离达档复核。 - 核对六项:形态/前提实测/门禁 check-runs/接受的偏差/范围外去向+`Skills-docs:`/落地条款。 - 草稿归 dev:PR 正文带 `## 维护者速读` 六行(见 os-dev.md),中文、业务角度,不写席位意见。 - 终稿 = 席位对照自己读的 diff 校正六行、加席位意见行,置顶席位记录;维护者只读评论。 -- PR 上的标签 = 待维护者审阅,不入六态;与请审同为等人批清单,随获批或撤回判决离开。 +- `pm:awaiting-merge` 只挂 PR、不入六态,与请审同为等人批清单,随获批或撤回判决离开。 - 批准判定单源 = 队列守卫常量 `GOVERNED_APPROVERS`:授权账户 APPROVED 即算,⛔ 不卡 `commit_id`。 - 批准后再推亦不过期;席位落地 = 过落地前检、清标、ready、auto-merge,踢出/变基同法。 - ③ 请审 = 席位交棒:ACCEPT 与现 head 同形 PASS 俱在案,同轮中继 `pr_request_reviewers` 请审批人。 diff --git a/.claude/skills/pm-dispatch/references/lanes/director.md b/.claude/skills/pm-dispatch/references/lanes/director.md index 4d0088b3878..6c06839184b 100644 --- a/.claude/skills/pm-dispatch/references/lanes/director.md +++ b/.claude/skills/pm-dispatch/references/lanes/director.md @@ -22,7 +22,6 @@ - 改 skill、指令或治理文本归 skills 席;出裁决、清复审标、清点待人工清单归本席。 - 项目内卡线程报 `needs_decision` ⇒ 席位写四维分析加推荐,并入决裁批次呈维护者人工裁。 - 维护者令「必须人工决裁」撤销「有把握自己决定」;⛔ 无裁决线程、代裁、置信门。 -- 本席任何形态都纯召唤制,⛔ 无任何 routine;分诊席默认同项目召唤,职责见 `triage.md`。 - 应用仓召唤本席复核卡的业务理由首触,三出口同分诊;没把握 ⇒ 报该应用的总监(人)裁。 ## 基本原则 @@ -51,7 +50,8 @@ - 入批前评论卡,首行 `Brief-claimed-by: session_ · batch # · `;只认会话 id,⛔ 不认账号。 - 他会话该行未满 24h 即跳过,不呈不裁不改标;逾 24h 无 `Ruling:` 失效可再写;录裁/关卡即释。 - 判据:开场标记→首批 ≤ 读五张;录裁只录维护者回字,⛔ 不自录、不自转工作态。 -- 决策收件箱 = 全部仓的 `needs-user-decision`,卡留立卡仓;⛔ 不复活已退役的汇总 issue。 +- 决策收件箱 = 全部仓的 `needs-user-decision` 卡,留立卡仓;⛔ 不复活已退役的汇总 issue。 +- 待您审合箱 = 全部仓 `pm:awaiting-merge` PR;呈批与摘要分写「决策卡 N」「待您审合 PR M」。 - 入箱勤务:校验/补全四棱块、速读与 `Prior rulings on this card:` 行;入箱同笔现读前提。 - 线程有 `Ruling:`/RULED 的卡再入箱,该行逐 id 各一句变化;缺行 ⇒ 拒入,`pm:retriage` 回立卡者。 - 一次只开一批:维护者对批 N 回字(一字即可)后,新到才合成批 N+1;续批至清零才收工。 diff --git a/.claude/skills/pm-dispatch/references/seat-lifecycle.md b/.claude/skills/pm-dispatch/references/seat-lifecycle.md index 8b7df65cef2..7ea42fef72b 100644 --- a/.claude/skills/pm-dispatch/references/seat-lifecycle.md +++ b/.claude/skills/pm-dispatch/references/seat-lifecycle.md @@ -71,8 +71,8 @@ - 与产品提交占比:已合提交不触 `.claude/` `AGENTS.md` `scripts/pm` 门禁脚本 `.github/workflows` 者。 - 产品提交占比连续多轮偏低 ⇒ 席位报维护者。 - 报告含 `UNRECOGNISED` 行:对本轮门禁日志 grep `UNRECOGNISED` 逐行照录,`NOT APPLICABLE` 行也在内。 -- 健康指标五个:可派发库存(open `pm:queue` 未认领及趋势);决策箱(待维护者数)。 -- 决策箱指标还要点名带开放下游依赖的决策卡,从 `Blocked-by:` 反向索引现算。 +- 健康指标五个:可派发库存(open `pm:queue` 未认领及趋势);决策箱:决策卡 N·待您审合 PR M。 +- 两数 ⛔ 不合计;决策卡还要点名带开放下游依赖者,从 `Blocked-by:` 反向索引现算。 - finding 数与中位年龄:裸标签数即未定级数,hold 已换标不在内。 - 裸卡数与中位年龄:sweep 析取①,未定级数另计析取④;老化 = 首触欠账。 - 发版阻塞 = 三仓 `target:` 之和,归零 = 三张板都空;总 open 数刻意不在指标内。 diff --git a/.claude/skills/pm-dispatch/references/state-machine.md b/.claude/skills/pm-dispatch/references/state-machine.md index 93c014fd013..b32b04c9d30 100644 --- a/.claude/skills/pm-dispatch/references/state-machine.md +++ b/.claude/skills/pm-dispatch/references/state-machine.md @@ -4,7 +4,7 @@ ## 跨状态通则 -- 六态 = `pm:{queue,dispatched,blocked,on-hold,awaiting-maintainer}` 加 `needs-user-decision`。 +- 六态 = `pm:{queue,dispatched,blocked,on-hold,awaiting-maintainer}` 加 `needs-user-decision`,皆 issue 态。 - 六态 ONE-OF 的成因恒是半写的转换;判定以 `check-half-states.mjs` 的 H29/H30 为权威。 - 生成物不分叉:`skills/**` 生成器产物跑 `check-governed-merges.mjs --test`,⛔ 不按路径手判。 - 交接即标签:只写交接评论而不同笔挂标 = 空交接,收件箱只认标签。 diff --git a/.claude/skills/pm-dispatch/references/triage-duties.md b/.claude/skills/pm-dispatch/references/triage-duties.md index 17bef94a0cd..4a72f592470 100644 --- a/.claude/skills/pm-dispatch/references/triage-duties.md +++ b/.claude/skills/pm-dispatch/references/triage-duties.md @@ -8,8 +8,8 @@ - 每日一 fire 跑两仓全量对账并归集日频职责;选层按 fire 时刻 ⛔ 不用计数器,简报写明层。 - 每条枚举比对返回数与 `totalCount`,不等 ⇒ 报 `sweep INCOMPLETE` 点名缺口,⛔ 永不报干净。 - 轮次记录按车道计欠审逾一轮的 head 数并点名车道(`check-owed-reviews.mjs`),只报 ⛔ 不接审。 +- 轮次记录分写「决策卡 N」与「待您审合 PR M」,不合计;后者 = open `pm:awaiting-merge` PR。 - 分诊旧记录过期:delta 复审归车道或复审席,照 contract-review.md;⛔ 分诊不再审。 -- Backlog sweep 是常设职责,每 fire 扫任一析取命中的卡。 - 析取 ①:全裸(无 `pm:*`、无 `needs-user-decision`、无 `domain:*`)。 - 析取 ②:有 `pm:queue` 无 `domain:*`(仅多车道仓);析取 ③:有 `domain:*` 无 pm-state。 - 析取 ④:有 `pm:queue`+`domain:*` 无 `priority:*`;②③④ 只取 `updated_at` 早于 ~2 分钟。 @@ -40,7 +40,7 @@ - `tooling` 入 `pm:queue` 仅凭三门之一:首行带 `Unblocks: #N`(open 产品卡)或点名所护的已发布面; - 或实测拖慢车队开发:正确源上假红/假绿、反复重跑、同因丢复核轮;卡须具名实例。 - `pm:queue` 也收恢复不变量的 finding、test-only pin,与实现未被裁错的说明书脱节(修文档)。 -- `needs-user-decision` = 设计卡、feature/契约形状提案、需要 appetite 的多周程序。 +- `needs-user-decision` = 设计卡、feature/契约形状提案、需要 appetite 的多周程序;只挂 issue。 - 先例已定新键/值/导出名非契约形状提案;分诊审计 ⛔ 不自加门,唯语义/形状真分歧呈人。 - 碰迁移形状或删已发布能力也进决策箱;说明书脱节不进:有裁定出处才是 (b),否则改文档。 - 决策卡速读居四棱块之上、同题,用业务语言先讲事情与选项,末句只问一字:A/B/C 或是否。 diff --git a/scripts/pm/check-governed-merges.mjs b/scripts/pm/check-governed-merges.mjs index 830ada57b20..5dec572bb33 100644 --- a/scripts/pm/check-governed-merges.mjs +++ b/scripts/pm/check-governed-merges.mjs @@ -218,7 +218,7 @@ * * Read as: a pull request whose changed line count exceeds 3,000 lands by the * same terminal as a Tier H governed diff (ACCEPT on the card, - * `needs-user-decision` on the PR, a final 维护者速读, review requested from + * `pm:awaiting-merge` on the PR, a final 维护者速读, review requested from * `GOVERNED_APPROVERS`), whatever paths it touches — on 2026-09-18 that meant * the maintainer's own merge alone; since 2026-09-27 it means the two landings * a Tier H path has (the "SIZE limb's LIFT" section below). 「比如」 made the @@ -1943,7 +1943,7 @@ export function renderSizeLines(size) { ` as a Tier H governed diff: no seat flips it ready, enqueues it, or arms auto-merge until an account in\n` + ` GOVERNED_APPROVERS has APPROVED it, on ANY commit (${SIZE_LIFT_RULING}) — then the OWNING seat lands it\n` + ` through the queue; the maintainer's own merge is the other landing. ACCEPT on the card,\n` + - ` \`needs-user-decision\` on the PR, the final 维护者速读, review requested from GOVERNED_APPROVERS.` + ` \`pm:awaiting-merge\` on the PR, the final 维护者速读, review requested from GOVERNED_APPROVERS.` ); } diff --git a/scripts/pm/check-governed-queue-guard.mjs b/scripts/pm/check-governed-queue-guard.mjs index 0cebc122918..f2b2e91faf0 100644 --- a/scripts/pm/check-governed-queue-guard.mjs +++ b/scripts/pm/check-governed-queue-guard.mjs @@ -450,7 +450,7 @@ * ⭐ AN AUTHORIZED APPROVAL LIFTS THIS LIMB (maintainer ruling 2026-09-27, * #20153), exactly as it lifts a Tier H path. Until that day the landed * predicate said a human MERGE and nothing else — 「人工审核」 landed as the same - * terminal a governed diff has (ACCEPT on the card, `needs-user-decision` on + * terminal a governed diff has (ACCEPT on the card, `pm:awaiting-merge` on * the PR, a final 维护者速读, review requested from `GOVERNED_APPROVERS`) and * the maintainer's own click (人工直合); the 2026-08-27 ruling had said * "approval lifts" of PATHS, nothing had said it of the NUMBER, and this file @@ -2324,7 +2324,7 @@ export function renderSizeVerdict(verdict) { ' remote that it is in neither the queue nor `origin/main` — the pull-back of AGENTS.md Prime', ' Directive #14. Out of the queue and in draft is the SAFE state.', ' 2. Then ONE of the landings — the same terminal a Tier H governed diff has: ACCEPT on the card,', - ' `needs-user-decision` on the PR, a final 维护者速读, review requested from GOVERNED_APPROVERS', + ' `pm:awaiting-merge` on the PR, a final 维护者速读, review requested from GOVERNED_APPROVERS', ` (${GOVERNED_APPROVERS.join(', ')}); and then EITHER`, ' (a) an APPROVED review by one of those accounts, on ANY commit (2026-09-27, objectstack#20153) —', ' the OWNING seat then lands it through the queue (席位落地): ready, auto-merge, this check', diff --git a/scripts/pm/check-half-states.mjs b/scripts/pm/check-half-states.mjs index 3cdd5456163..8ac264a6e97 100644 --- a/scripts/pm/check-half-states.mjs +++ b/scripts/pm/check-half-states.mjs @@ -4791,11 +4791,12 @@ export const INSTRUCTION_FOUR_RULING = * `buildBlockingIndex` map H14 is judged against. ④ costs this patrol one * `Map.get` per decision card. * - * The inbox label is `GOVERNED_PR_DECISION_LABEL`, deliberately NOT a second - * spelling of the same string: H52 already reads that constant on CARDS while - * H48 reads it on PULL REQUESTS, and the self-test pins the single-spelling - * rule there (「the label constant is the one H48 already owns, not a second - * spelling」). It is declared further down this file; a `const` in the temporal + * The inbox label is `DECISION_INBOX_LABEL`, deliberately NOT a second + * spelling of the same string: H52, H62 and H73 already read that constant on + * CARDS, and the self-test pins the single-spelling rule there (「the label + * constant is the card inbox one, not a second spelling」). H48 reads a + * DIFFERENT label on PULL REQUESTS (`GOVERNED_PR_AWAITING_MERGE_LABEL`), never + * this one. It is declared further down this file; a `const` in the temporal * dead zone is fine HERE because nothing calls this at module-evaluation time. * * A decision card with NO dependent is omitted rather than rendered empty, for @@ -4821,7 +4822,7 @@ export function decisionDependentIndex(issues, index) { const rows = []; let inbox = 0; for (const issue of issues ?? []) { - if (!labelNames(issue ?? {}).includes(GOVERNED_PR_DECISION_LABEL)) continue; + if (!labelNames(issue ?? {}).includes(DECISION_INBOX_LABEL)) continue; inbox++; const dependents = index?.get?.(issue.number) ?? []; if (dependents.length === 0) continue; @@ -11320,8 +11321,11 @@ export function h47ReleaseRecordDesync(issue, commentRows) { // // A Tier H surface lands only on the maintainer's word (Prime Directive #14), // so the seat's review is not a merge — it is a HANDOFF, and the handoff is one -// act with two writes: the `needs-user-decision` label puts the PR in the -// maintainer's inbox, and a `## 维护者速读` comment is what makes reading it a +// act with two writes: the `pm:awaiting-merge` label puts the PR in the +// maintainer's awaiting-merge inbox (`needs-user-decision` is the decision-card +// inbox, ISSUES only — the maintainer ruled the two inboxes apart so a count +// of decisions owed never includes a PR), and a `## 维护者速读` comment is what +// makes reading it a // two-minute job rather than a diff review. One act, two writes means exactly // the shapes this row reads: // @@ -11348,7 +11352,7 @@ export function h47ReleaseRecordDesync(issue, commentRows) { // ## The three readings, each from the PR's own state // // VERDICT an issue-comment on the PR whose line begins `**ACCEPT**`. -// LABEL `needs-user-decision` on the PR's labels — read off the LIST row, +// LABEL `pm:awaiting-merge` on the PR's labels — read off the LIST row, // no request. // BRIEF an issue-comment on the PR whose line begins `## 维护者速读`. // @@ -11390,12 +11394,22 @@ export function h47ReleaseRecordDesync(issue, commentRows) { /** * The label half of the verdict's second write. * - * ⚠️ On a PULL REQUEST this label means「awaiting the maintainer's review」and - * it is NOT one of the six card states — ⛔ never add it to a card-state table - * here (H24/H29 own that vocabulary) and ⛔ never read a card's copy of it - * through this constant. + * ⚠️ PULL REQUESTS ONLY: this label means「landing waits on the maintainer's own + * review and merge」(SKILL.md 复核 ②) and it is NOT one of the six card states — + * ⛔ never add it to a card-state table here (H24/H29 own that vocabulary), + * ⛔ never put it on an issue, and ⛔ never read the decision-card inbox + * through this constant: that is `DECISION_INBOX_LABEL` below. The maintainer + * ruled the two inboxes apart (verbatim: 「同意 pm:awaiting-merge」) so the count + * of decisions owed and the count of PRs awaiting a click never share a label. */ -export const GOVERNED_PR_DECISION_LABEL = 'needs-user-decision'; +export const GOVERNED_PR_AWAITING_MERGE_LABEL = 'pm:awaiting-merge'; + +/** + * The decision-card inbox label — ISSUES only, one of the six card states. + * H52/H62/H73 and the ④ dependents index read cards through this constant; + * ⛔ never H48, whose PR-side label is `GOVERNED_PR_AWAITING_MERGE_LABEL`. + */ +export const DECISION_INBOX_LABEL = 'needs-user-decision'; /** One comment page, and the quota backstop on the walk. */ export const H48_COMMENTS_PAGE_SIZE = 100; @@ -11483,7 +11497,7 @@ export function h48GovernedVerdictWithoutBrief(pr, governed, commentRows) { const verdict = latestMarkedComment(rows, ACCEPT_VERDICT_MARKER); if (!verdict) return null; const hasBrief = latestMarkedComment(rows, MAINTAINER_BRIEF_MARKER) !== null; - const hasLabel = labelNames(pr ?? {}).includes(GOVERNED_PR_DECISION_LABEL); + const hasLabel = labelNames(pr ?? {}).includes(GOVERNED_PR_AWAITING_MERGE_LABEL); if (hasLabel && hasBrief) return null; const surfaces = governed ?? []; @@ -11496,17 +11510,17 @@ export function h48GovernedVerdictWithoutBrief(pr, governed, commentRows) { surfaces.length > H48_SURFACE_LIST_CAP ? `, +${surfaces.length - H48_SURFACE_LIST_CAP} more` : ''; const when = verdict.createdAt ? ` (newest one ${verdict.createdAt})` : ''; const missing = [ - hasLabel ? null : `no \`${GOVERNED_PR_DECISION_LABEL}\` label`, + hasLabel ? null : `no \`${GOVERNED_PR_AWAITING_MERGE_LABEL}\` label`, hasBrief ? null : 'no comment whose line begins `## 维护者速读`', ].filter(Boolean); const remedy = [ - hasLabel ? null : `add \`${GOVERNED_PR_DECISION_LABEL}\``, + hasLabel ? null : `add \`${GOVERNED_PR_AWAITING_MERGE_LABEL}\``, hasBrief ? null : 'post the final 维护者速读 brief as a COMMENT', ].filter(Boolean); const cost = hasLabel ? 'the maintainer opens a governed diff with nothing saying what is being asked of them' : hasBrief - ? 'the inbox filter that lists what is waiting for a ruling does not list this PR' + ? 'the awaiting-merge filter that lists what waits on the maintainer\'s click does not list this PR' : 'the PR is neither listed in the inbox nor readable in two minutes once it is found'; const briefClause = hasBrief ? '' @@ -12421,7 +12435,7 @@ export function h52OpenQuestionsUnrouted(issue, commentRows) { if (!Array.isArray(commentRows)) return null; if (!h52SpeaksAbout(issue)) return null; // The card is already IN the inbox — the state this row exists to reach. - if (labelNames(issue).includes(GOVERNED_PR_DECISION_LABEL)) return null; + if (labelNames(issue).includes(DECISION_INBOX_LABEL)) return null; const report = latestDevReport(commentRows); if (!report) return null; // A payload that did not read has an UNKNOWN array — decline, never accuse. @@ -12444,7 +12458,7 @@ export function h52OpenQuestionsUnrouted(issue, commentRows) { : 'none of them carries a readable `question` text'; return ( `the newest \`os-dev-report\` (${named}, ${stamped}) carries ${n} non-empty \`open_questions\` entry(ies) ` + - `while the card carries NO \`${GOVERNED_PR_DECISION_LABEL}\` label: ${listed}. So a question only the ` + + `while the card carries NO \`${DECISION_INBOX_LABEL}\` label: ${listed}. So a question only the ` + 'maintainer can answer is filed, well-formed and UNREACHABLE — it is in no inbox, no candidate query and ' + 'no staleness alert, while the card\'s own state label — whichever of the six it is — reads as work in ' + 'progress on every board, so nothing a reader can see says a decision is owed. ⚠️ This is the shape ' + @@ -12778,7 +12792,7 @@ export function contractReviewVerdictOf(body) { // remedy sentence says so rather than prescribing a relabel. // // ⛔ And no new label, by the same ruling: 「不要新增标签。你已经证明词汇表够用」. -// `needs-user-decision` is live on cards and PRs and `pm:awaiting-maintainer` +// `needs-user-decision` is live on cards and `pm:awaiting-maintainer` // is live on cards, so the state the remedy points at already exists and the // deliverable triage ruled for is 逐卡改判, not a new state. // @@ -12879,7 +12893,7 @@ export function h54DecisionTitledQueueCard(issue) { 'must stop saying otherwise); if it is genuinely UNRULED it belongs in the decision inbox — ' + '`needs-user-decision`, carrying its four-facet analysis at the moment the label lands, ⛔ not ' + 'left for the maintainer to fill in on arrival. ⛔ No new label is needed or wanted: the ' + - 'vocabulary already carries the state (`needs-user-decision` on cards and PRs, ' + + 'vocabulary already carries the state (`needs-user-decision` on cards, ' + '`pm:awaiting-maintainer` on cards), and the ruled deliverable is 逐卡改判 rather than a new ' + 'state. Report-only: this row writes no label, relabels nothing, and its verdict is a ' + 'disagreement between two carriers — ⛔ never a finding that the card is mislabelled. ' + @@ -14827,7 +14841,7 @@ export function h60ClaimBranchUnparsed(issue, governance) { // // ## Population, and why BODY only // -// OPEN cards carrying `GOVERNED_PR_DECISION_LABEL` — the constant H48 and H52 +// OPEN cards carrying `DECISION_INBOX_LABEL` — the constant H52 and H73 // already own, ⛔ never a second spelling. The listing is the UNSCOPED open one // (H13/H18's channel) for their reason: `needs-user-decision` is not a // member of `SEEN_LABEL_PAGES`, so a decision card carrying no `pm:*` state is @@ -14968,7 +14982,7 @@ export function h62SpeaksAbout(issue) { if (issue?.pull_request) return false; if (issue?.state === 'closed') return false; if (!Array.isArray(issue?.labels)) return false; - return labelNames(issue).includes(GOVERNED_PR_DECISION_LABEL); + return labelNames(issue).includes(DECISION_INBOX_LABEL); } /** @@ -14997,7 +15011,7 @@ export function h62DecisionCardWithoutFacetBlock(issue) { 'exact gap #15929\'s text-level remedy left open and #16934 fell into (a card presented for ' + 'a question ADR-0131 D8 already decides).'; return ( - `\`${GOVERNED_PR_DECISION_LABEL}\` and its BODY carries no \`${DECISION_FACET_MARKER}\` marker in ` + + `\`${DECISION_INBOX_LABEL}\` and its BODY carries no \`${DECISION_FACET_MARKER}\` marker in ` + 'EITHER spelling — neither the plain-text line nor its HTML-comment form — so the four-facet block ' + '〈四棱卡面块固定形状〉 requires is not machine-findable on the card face. 「落卡即带,⛔ 不留待维护者' + '到场再补」: the block is a FILING duty, so a marker parked in a COMMENT is not the shape and this ' + @@ -15118,7 +15132,7 @@ export function h73SpeaksAbout(issue) { if (!issue || issue.pull_request) return false; if (issue.state === 'closed') return false; if (!Array.isArray(issue.labels)) return false; - return labelNames(issue).includes(GOVERNED_PR_DECISION_LABEL); + return labelNames(issue).includes(DECISION_INBOX_LABEL); } /** @@ -15148,7 +15162,7 @@ export function h73RuledCardStillInBox(issue, nowMs = Date.now()) { ? `owed by the ${domains.map((d) => `\`${d}\``).join(' / ')} lane seat` : 'owed by NO lane yet — the card carries no `domain:*` label, so the TRIAGE seat routes it first'; return ( - `\`${GOVERNED_PR_DECISION_LABEL}\` still on a card whose BODY carries a ruling record: ` + + `\`${DECISION_INBOX_LABEL}\` still on a card whose BODY carries a ruling record: ` + `\`${RULED_LINE_KEY}:\` ruling comment ${governing.id}, ${age}. The ruling is recorded and the exit ` + `out of the decision box is not: ${owes}, which moves the card in one write as the ruled letter ` + 'says (`pm:queue`, `pm:blocked` or close) — nothing else will, because the director writes no label. ' + @@ -19216,7 +19230,7 @@ export function renderDecisionDependents(inventory, { markdown = false } = {}) { head.push( `Report-only and derived at READ TIME: a card below is an open ` + - `\`${GOVERNED_PR_DECISION_LABEL}\` card that at least one OPEN card's \`Blocked-by:\` line ` + + `\`${DECISION_INBOX_LABEL}\` card that at least one OPEN card's \`Blocked-by:\` line ` + `names, and ⛔ no priority label is written for it. The standing instruction this ` + `discharges, verbatim: 「${INSTRUCTION_FOUR_RULING}」 It is one lookup over the same ` + `reverse index H14 is judged against — no second channel and no second parser (#17968).`, @@ -19244,7 +19258,7 @@ export function renderDecisionDependents(inventory, { markdown = false } = {}) { } const count = - `${rows.length} of ${inbox} open \`${GOVERNED_PR_DECISION_LABEL}\` card(s) have an open ` + + `${rows.length} of ${inbox} open \`${DECISION_INBOX_LABEL}\` card(s) have an open ` + `downstream dependent.`; const notMeasured = `NOT MEASURED beside it: ${unmeasured} open \`pm:blocked\` card(s) carry no machine-readable ` + @@ -28284,7 +28298,7 @@ async function selfTest() { const facetCard = (over = {}) => ({ number: 16934, state: 'open', - labels: [{ name: GOVERNED_PR_DECISION_LABEL }, { name: 'priority:p2' }], + labels: [{ name: DECISION_INBOX_LABEL }, { name: 'priority:p2' }], body: '## 一句话问题\n单租户下谁是租户墙?\n', ...over, }); @@ -28309,11 +28323,11 @@ async function selfTest() { // ── Direction 2, continued: population boundaries, each with its control ── t('⛔ H62: a card without the decision label is out of scope', h62DecisionCardWithoutFacetBlock(facetCard({ labels: [{ name: 'priority:p2' }] })), null); - t('H62 control: …and adding exactly that label to the same card fires it', typeof h62DecisionCardWithoutFacetBlock(facetCard({ labels: [{ name: 'priority:p2' }, { name: GOVERNED_PR_DECISION_LABEL }] })), 'string'); + t('H62 control: …and adding exactly that label to the same card fires it', typeof h62DecisionCardWithoutFacetBlock(facetCard({ labels: [{ name: 'priority:p2' }, { name: DECISION_INBOX_LABEL }] })), 'string'); t('⛔ H62: a CLOSED decision card is out — the label on it is residue, H22/H39\'s reading', h62DecisionCardWithoutFacetBlock(facetCard({ state: 'closed' })), null); t('⛔ H62: a PULL REQUEST row that slipped into the listing is not a card', h62DecisionCardWithoutFacetBlock(facetCard({ pull_request: {} })), null); t('H62 control: …and the byte-identical row WITHOUT the `pull_request` field fires, so it is the PR-ness that excluded it', typeof h62DecisionCardWithoutFacetBlock(facetCard()), 'string'); - t('⛔ H62: …and the decision label on a PULL REQUEST is H48\'s subject, whose own constant this is', GOVERNED_PR_DECISION_LABEL, 'needs-user-decision'); + t('⛔ H62: …and the card inbox constant is `needs-user-decision`, never H48\'s PR label `pm:awaiting-merge`', [DECISION_INBOX_LABEL, GOVERNED_PR_AWAITING_MERGE_LABEL].join('|'), 'needs-user-decision|pm:awaiting-merge'); t('⛔ H62: an UNREADABLE `labels` is excluded, never read as unlabelled (#4690)', h62DecisionCardWithoutFacetBlock(facetCard({ labels: undefined })), null); t('⛔ H62: a missing row does not crash', h62DecisionCardWithoutFacetBlock(undefined), null); t('⛔ H62: an EMPTY body is a finding, not a crash — nothing was written at all', typeof h62DecisionCardWithoutFacetBlock(facetCard({ body: null })), 'string'); @@ -28359,8 +28373,8 @@ async function selfTest() { // ── The row's text: the platform hazard, the remedy, the boundaries ─────── t('H62 text: ⛔ no less-than fragment — the body is written into a GitHub issue', /[<>]/.test(row62(facetCard())), false); t('H62 text: …so the comment spelling is named in WORDS', row62(facetCard()).includes('HTML-comment form'), true); - t('H62 text: the label constant is printed, ⛔ never a re-typed literal', row62(facetCard()).includes(`\`${GOVERNED_PR_DECISION_LABEL}\``), true); - t('H62 text: …and it is the constant H48/H52 already own', GOVERNED_PR_DECISION_LABEL, 'needs-user-decision'); + t('H62 text: the label constant is printed, ⛔ never a re-typed literal', row62(facetCard()).includes(`\`${DECISION_INBOX_LABEL}\``), true); + t('H62 text: …and it is the card inbox constant H52 already owns', DECISION_INBOX_LABEL, 'needs-user-decision'); t('H62 text: WHO acts is the FILING seat, in one body edit', row62(facetCard()).includes('the FILING seat, in one body edit'), true); t('H62 text: …and the prior-ruling reader is named as the paste source', row62(facetCard()).includes('check:pm-prior-rulings'), true); t('H62 text: ⛔ never a label written from this script', row62(facetCard()).includes('never a label written from this script'), true); @@ -28395,7 +28409,7 @@ async function selfTest() { t('H63: …while the state row calls the trigger a position on the work state machine', row63(gradedCarrier(['finding', 'pm:queue'])).includes('a position on the work state machine'), true); t('H63: this very card\'s own shape fires — `finding` + `pm:queue` + `priority:p3`', typeof h63StaleFindingBesideGrade(gradedCarrier(['finding', 'pm:queue', 'priority:p3', 'domain:skills'])), 'string'); t('H63: …naming BOTH triggers, state first', row63(gradedCarrier(['finding', 'pm:queue', 'priority:p3'])).includes('`pm:queue`, `priority:p3`'), true); - t('H63: every grading state is a trigger — `needs-user-decision` included', typeof h63StaleFindingBesideGrade(gradedCarrier(['finding', GOVERNED_PR_DECISION_LABEL])), 'string'); + t('H63: every grading state is a trigger — `needs-user-decision` included', typeof h63StaleFindingBesideGrade(gradedCarrier(['finding', DECISION_INBOX_LABEL])), 'string'); // ── Direction 2: it is SILENT where the screen leaves the card, each silence // with a lit control on the byte-identical card ───────────────────────── @@ -28577,7 +28591,7 @@ async function selfTest() { const card73 = (extra = {}) => ({ number: 28658, state: 'open', - labels: [{ name: GOVERNED_PR_DECISION_LABEL }, { name: 'domain:server' }, { name: 'priority:p2' }], + labels: [{ name: DECISION_INBOX_LABEL }, { name: 'domain:server' }, { name: 'priority:p2' }], body: `director#1 · os-support-ai · session_x\n${RULED73}\n\n## 维护者速读\n\n- 问题:…`, ...extra, }); @@ -28601,13 +28615,13 @@ async function selfTest() { b(BATTERY29386, 'H73: …and the list form under that line is read too', h73({ body: `${RULED73}\n\n**Prior rulings on this card:**\n- 6566731522 — the old question\n` }), null); b(BATTERY29386, 'H73 control: …while the same line naming a DIFFERENT ruling still fires', typeof h73({ body: `${RULED73}\n\nPrior rulings on this card: 6400000001 — another ruling.` }), 'string'); b(BATTERY29386, 'H73 control: …and an id after a blank line below it is not that line\'s', typeof h73({ body: `Prior rulings on this card: none\n\n${RULED73}` }), 'string'); - b(BATTERY29386, 'H73: no `domain:*` label names the TRIAGE seat instead of inventing a lane', row73({ labels: [{ name: GOVERNED_PR_DECISION_LABEL }] }).includes('the TRIAGE seat routes it first'), true); - b(BATTERY29386, 'H73: two `domain:*` labels are both named', row73({ labels: [{ name: GOVERNED_PR_DECISION_LABEL }, { name: 'domain:spec' }, { name: 'domain:server' }] }).includes('`domain:spec` / `domain:server`'), true); + b(BATTERY29386, 'H73: no `domain:*` label names the TRIAGE seat instead of inventing a lane', row73({ labels: [{ name: DECISION_INBOX_LABEL }] }).includes('the TRIAGE seat routes it first'), true); + b(BATTERY29386, 'H73: two `domain:*` labels are both named', row73({ labels: [{ name: DECISION_INBOX_LABEL }, { name: 'domain:spec' }, { name: 'domain:server' }] }).includes('`domain:spec` / `domain:server`'), true); b(BATTERY29386, '⛔ H73 population: a closed card, a PR row, an unreadable `labels`, a missing row and an empty body are all silent, none a crash', [h73({ state: 'closed' }), h73({ pull_request: {} }), h73({ labels: undefined }), h73RuledCardStillInBox(undefined, NOW73), h73({ body: null })].every((v) => v === null), true); b(BATTERY29386, 'H73 reader: the live shapes read — a parenthesised re-ruling note and a seconds stamp', JSON.stringify([...ruledRecords('Ruled: 6439314592 · letter A (re-ruled from B, 6435350063) · 2026-10-27T10:22Z'), ...ruledRecords('- Ruled: 6398569691 · letter A · 2026-10-25T13:58:53Z')]), JSON.stringify([{ id: '6439314592', at: at73('2026-10-27T10:22Z') }, { id: '6398569691', at: at73('2026-10-25T13:58:53Z') }])); b(BATTERY29386, 'H73 text: no angle-bracket fragment and no tracker number — the row is written into a GitHub issue', /[<>]/.test(row73()) || /#[0-9]/.test(row73()), false); b(BATTERY29386, 'H73 text: names the one-act rule, the bell, the shape\'s printout and the report-only boundary', ['ONE act (lanes/director.md)', '`Notify: ruling`', '--mailbox-keys', 'never a label written from this script'].every((x) => row73().includes(x)), true); - b(BATTERY29386, 'H73 text: the label is the constant H48/H52/H62 own, ⛔ never a re-typed literal', row73().startsWith(`\`${GOVERNED_PR_DECISION_LABEL}\``), true); + b(BATTERY29386, 'H73 text: the label is the card inbox constant H52/H62 own, ⛔ never a re-typed literal', row73().startsWith(`\`${DECISION_INBOX_LABEL}\``), true); b(BATTERY29386, 'H73 constants: a six-hour threshold, one standing patrol cycle', `${RULED_IN_BOX_STALE_HOURS}/${PATROL_CADENCE_HOURS}`, '6/6'); b(BATTERY29386, 'H73 band: registered as `stall`, and the sweep really pushes it', `${familyBand('H73')}/${familyRegistryCoverage().emitted.includes('H73')}`, 'stall/true'); b(BATTERY29386, 'H73: not a loud finding — it never escalates a sweep', isLoudFinding(row73()), false); @@ -33345,7 +33359,7 @@ Doubles as the fire's **write self-check** (step 0). \`201\` is not the reading. const GOV48 = [{ glob: '.claude/**', files: ['.claude/agents/os-dev.md'] }]; const VERDICT48 = '**ACCEPT** — lands #15894 as ruled. Governed (`.claude/**`), predicate exit 3.'; const BRIEF48 = '## 维护者速读\n\n一句话:巡查行,报告态,不写标签。'; - const LABEL48 = GOVERNED_PR_DECISION_LABEL; + const LABEL48 = GOVERNED_PR_AWAITING_MERGE_LABEL; const cm48 = (body, at = '2026-09-05T14:30:00Z') => ({ body, created_at: at }); // ⚠️ The fixture BODY always carries the dev's six-line draft under the SAME // `## 维护者速读` heading the comment marker reads, so every case below that @@ -33366,13 +33380,15 @@ Doubles as the fire's **write self-check** (step 0). \`201\` is not the reading. t('H48: verdict + label, NO brief -> finding', typeof h48GovernedVerdictWithoutBrief(pr48([LABEL48]), GOV48, [cm48(VERDICT48)]), 'string'); t('H48: …and the row names the brief half that is missing', h48row(pr48([LABEL48]), GOV48, [cm48(VERDICT48)]).includes('no comment whose line begins `## 维护者速读`'), true); t('H48: …and the remedy is posting it as a COMMENT', h48row(pr48([LABEL48]), GOV48, [cm48(VERDICT48)]).includes('post the final 维护者速读 brief as a COMMENT'), true); - t('H48: …and never asks for the label, which is already there', h48row(pr48([LABEL48]), GOV48, [cm48(VERDICT48)]).includes('add `needs-user-decision`'), false); + t('H48: …and never asks for the label, which is already there', h48row(pr48([LABEL48]), GOV48, [cm48(VERDICT48)]).includes('add `pm:awaiting-merge`'), false); t('H48: verdict + brief, NO label -> finding', typeof h48GovernedVerdictWithoutBrief(pr48([]), GOV48, [cm48(VERDICT48), cm48(BRIEF48)]), 'string'); - t('H48: …and the row names the label half that is missing', h48row(pr48([]), GOV48, [cm48(VERDICT48), cm48(BRIEF48)]).includes('no `needs-user-decision` label'), true); - t('H48: …and the remedy is the label write', h48row(pr48([]), GOV48, [cm48(VERDICT48), cm48(BRIEF48)]).includes('Remedy: add `needs-user-decision`.'), true); + t('H48: …and the row names the label half that is missing', h48row(pr48([]), GOV48, [cm48(VERDICT48), cm48(BRIEF48)]).includes('no `pm:awaiting-merge` label'), true); + t('⛔ H48: the OLD shape — `needs-user-decision` on the PR — no longer satisfies the label half (the maintainer split the inboxes)', typeof h48GovernedVerdictWithoutBrief(pr48([DECISION_INBOX_LABEL]), GOV48, [cm48(VERDICT48), cm48(BRIEF48)]), 'string'); + t('H48: the PR-side constant is `pm:awaiting-merge`, PRs only, and NOT one of the six card states', [GOVERNED_PR_AWAITING_MERGE_LABEL, PM_EXCLUSIVE_STATE_LABELS.includes(GOVERNED_PR_AWAITING_MERGE_LABEL)].join('|'), 'pm:awaiting-merge|false'); + t('H48: …and the remedy is the label write', h48row(pr48([]), GOV48, [cm48(VERDICT48), cm48(BRIEF48)]).includes('Remedy: add `pm:awaiting-merge`.'), true); t('H48: …and never asks for a brief that is already posted', h48row(pr48([]), GOV48, [cm48(VERDICT48), cm48(BRIEF48)]).includes('as a COMMENT'), false); - t('H48: verdict and NEITHER half -> ONE finding naming both', h48row(pr48([]), GOV48, [cm48(VERDICT48)]).includes('no `needs-user-decision` label and no comment whose line begins `## 维护者速读`'), true); - t('H48: …and both remedies in one sentence, never two rows', h48row(pr48([]), GOV48, [cm48(VERDICT48)]).includes('add `needs-user-decision`, and post the final 维护者速读 brief as a COMMENT'), true); + t('H48: verdict and NEITHER half -> ONE finding naming both', h48row(pr48([]), GOV48, [cm48(VERDICT48)]).includes('no `pm:awaiting-merge` label and no comment whose line begins `## 维护者速读`'), true); + t('H48: …and both remedies in one sentence, never two rows', h48row(pr48([]), GOV48, [cm48(VERDICT48)]).includes('add `pm:awaiting-merge`, and post the final 维护者速读 brief as a COMMENT'), true); t('H48: …and it really is ONE row', (h48row(pr48([]), GOV48, [cm48(VERDICT48)]).match(/Report-only patrol INPUT/gu) ?? []).length, 1); // The BODY draft is not the brief — the half this row exists to find must not @@ -33928,7 +33944,7 @@ Doubles as the fire's **write self-check** (step 0). \`201\` is not the reading. t('H52 fires: a non-empty `open_questions` on a card with no `needs-user-decision`', typeof h52(OPEN52), 'string'); t('H52 negative control: an EMPTY `open_questions` array is NOT reported', h52(EMPTY52), null); t('H52 negative control: a card ALREADY carrying `needs-user-decision` is NOT reported again', h52(OPEN52, card52(['pm:dispatched', 'needs-user-decision'])), null); - t('H52: the label constant is the one H48 already owns, not a second spelling', GOVERNED_PR_DECISION_LABEL, 'needs-user-decision'); + t('H52: the label constant is the card inbox one, not a second spelling — and not H48\'s PR label', DECISION_INBOX_LABEL, 'needs-user-decision'); t('H52: a thread with no report at all is silent', h52([cm52(1, 'Triage: routing only.', T52)]), null); // The marker — two spellings, and what is deliberately NOT one. diff --git a/scripts/pm/check-handover.mjs b/scripts/pm/check-handover.mjs index 2557438f5d4..9f7497df956 100644 --- a/scripts/pm/check-handover.mjs +++ b/scripts/pm/check-handover.mjs @@ -105,7 +105,8 @@ * 3. WAITING that pull is in the merge queue (its timeline's newest queue event is `added_to_merge_queue`: * `enqueuerFromTimeline`, the queue guard's reading), has auto-merge armed (read only in this * finding-reducing direction, as H12 reads it), or awaits a human approval - * (`needs-user-decision` on the pull, the label the seat writes beside the approval request). + * (`pm:awaiting-merge` on the pull, the label the seat writes beside the approval request; + * `needs-user-decision` is the decision-card inbox and never sits on a pull). * * Short of any one, it stays `[claim-unreleased]` and says which one failed; the measured half-state (#23193, never a * declared tail) is refused exactly as before, and a card (a′) names is refused whatever it declares. @@ -131,7 +132,8 @@ import { isEntrypoint } from '../invoked-as.mjs'; import { CLAIM_COMMENT_MARKER, DECISION_FACET_MARKER, - GOVERNED_PR_DECISION_LABEL, + DECISION_INBOX_LABEL, + GOVERNED_PR_AWAITING_MERGE_LABEL, OPEN_LISTING_PAGE_CEILING, OS_DEV_REPORT_MARKER, PM_EXCLUSIVE_STATE_LABELS, @@ -173,9 +175,6 @@ export const PROXY_REARM_GUARD = 'OS_CHECK_HANDOVER_PROXY_REARMED'; /** The identity every relay write carries; (b) reads the departing session's comments by this author only. */ export const FLEET_BOT_LOGIN = 'objectstack-fleet[bot]'; -/** The decision inbox label, and the one state a card awaiting the maintainer may carry. */ -export const DECISION_INBOX_LABEL = 'needs-user-decision'; - /** (b)'s default window: a seat shift measured 41 h and 49 h in the two vacant bodies the card read; ×3 headroom. */ export const DEFAULT_WINDOW_HOURS = 168; @@ -354,8 +353,8 @@ export function acceptedPullWait(pr, comments, timeline) { const rows = Array.isArray(comments) ? comments : []; const record = latestMarkedComment(rows, SEAT_RECORD_MARKER); if (record === null || !seatRecordAccepts(rows[record.index]?.body)) return { state: 'no-accept' }; - if (labelNames(pr ?? {}).includes(GOVERNED_PR_DECISION_LABEL)) { - return { state: 'waiting', how: `awaiting a human approval (\`${GOVERNED_PR_DECISION_LABEL}\` on the pull)` }; + if (labelNames(pr ?? {}).includes(GOVERNED_PR_AWAITING_MERGE_LABEL)) { + return { state: 'waiting', how: `awaiting a human approval (\`${GOVERNED_PR_AWAITING_MERGE_LABEL}\` on the pull)` }; } if (pr?.auto_merge != null) return { state: 'waiting', how: 'armed for auto-merge' }; if (timeline === undefined) return { state: 'needs-timeline' }; @@ -815,13 +814,14 @@ export function selfTest() { // the narrowed stay-behind: conditions 2 and 3 over one delivering pull const PR = (extra = {}) => ({ number: 25427, state: 'open', draft: false, auto_merge: null, labels: [], body: 'Fixes #23193', head: { ref: 'claude/issue-23193-x' }, ...extra }); - const LABELLED = PR({ labels: [{ name: GOVERNED_PR_DECISION_LABEL }] }); + const LABELLED = PR({ labels: [{ name: GOVERNED_PR_AWAITING_MERGE_LABEL }] }); const ACCEPT_REC = comment(30, 300, '## 席位核对\n\n**ACCEPT** · domain:devx seat 1 · head `7cc88889`', FLEET_BOT_LOGIN, 25427); const REWORK_REC = comment(31, 330, '## 席位核对 — REWORK · tests owed', FLEET_BOT_LOGIN, 25427); const QUEUED = [{ event: 'added_to_merge_queue', actor: { login: 'os-justin' }, created_at: at(340) }]; const DEQUEUED = [...QUEUED, { event: 'removed_from_merge_queue', actor: { login: 'github-merge-queue[bot]' }, created_at: at(350) }]; t('⭐ wait: ACCEPTed and in the merge queue — the timeline\'s newest queue event is the add', acceptedPullWait(PR(), [ACCEPT_REC], QUEUED).how === 'in the merge queue'); - t('wait: ACCEPTed and awaiting a human approval (`needs-user-decision` on the pull) answers without the timeline', acceptedPullWait(LABELLED, [ACCEPT_REC]).state === 'waiting'); + t('wait: ACCEPTed and awaiting a human approval (`pm:awaiting-merge` on the pull) answers without the timeline', acceptedPullWait(LABELLED, [ACCEPT_REC]).state === 'waiting'); + t('⛔ wait: the OLD shape — `needs-user-decision` on the pull — is no longer a wait (the maintainer split the inboxes); it asks for the timeline like an unlabelled pull', acceptedPullWait(PR({ labels: [{ name: DECISION_INBOX_LABEL }] }), [ACCEPT_REC]).state === 'needs-timeline'); t('wait: ACCEPTed with auto-merge armed answers without the timeline', acceptedPullWait(PR({ auto_merge: { merge_method: 'squash' } }), [ACCEPT_REC]).how === 'armed for auto-merge'); t('wait: ACCEPTed, unlabelled and unarmed asks for the timeline before it judges', acceptedPullWait(PR(), [ACCEPT_REC]).state === 'needs-timeline'); t('⛔ wait: ACCEPTed, but the newest queue event is a removal, or there is none — not waiting', acceptedPullWait(PR(), [ACCEPT_REC], DEQUEUED).state === 'not-waiting' && acceptedPullWait(PR(), [ACCEPT_REC], []).state === 'not-waiting'); diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index 9c2f6353c81..d6c49755618 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -12239,7 +12239,7 @@ export function changedLineLines(size) { `${reading} ⛔ OVER — this PR lands as a Tier H surface does: an authorized APPROVED review (GOVERNED_APPROVERS, on ANY` + ' commit) and then the owning seat, or a HUMAN MERGE (maintainer rulings 2026-09-18 and 2026-09-27; no exemption for' + ' generated files, regen artefacts, docs builds or reverts). The governed terminal: no seat flips it ready, enqueues' + - ' it, or arms auto-merge before that — ACCEPT on the card, `needs-user-decision` on the PR, the final 维护者速读,' + + ' it, or arms auto-merge before that — ACCEPT on the card, `pm:awaiting-merge` on the PR, the final 维护者速读,' + ' review requested from GOVERNED_APPROVERS. The landing pre-check `check-governed-merges.mjs --pr ` reads the' + ' PR\'s own number.', ]; diff --git a/scripts/pm/ensure-pm-labels.sh b/scripts/pm/ensure-pm-labels.sh index 662020e132f..0b67e61c9a8 100644 --- a/scripts/pm/ensure-pm-labels.sh +++ b/scripts/pm/ensure-pm-labels.sh @@ -193,8 +193,11 @@ gh() { for R in objectstack-ai/objectstack objectstack-ai/objectui objectstack-ai/cloud objectstack-ai/objectos objectstack-ai/hotcrm objectstack-ai/mobile; do gh label create pm:queue -R "$R" -c 0e8a16 -d "Ready for the PM dispatch loop" 2>/dev/null || true gh label create pm:dispatched -R "$R" -c 1d76db -d "Dispatched to a dev agent by /pm-dispatch" 2>/dev/null || true - # needs-user-decision on a PR means awaiting the maintainer's review (SKILL.md 复核, 终局四件套); on a card it is the ruling inbox. - gh label create needs-user-decision -R "$R" -c d93f0b -d "Blocked on a maintainer decision — do not dispatch; on a PR: awaiting the maintainer's review" 2>/dev/null || true + # needs-user-decision is the RULING inbox and lives on ISSUES only (SKILL.md 状态模型: 只挂决策卡, + # ⛔ 永不挂 PR). A PR waiting on the maintainer's own review and merge carries pm:awaiting-merge + # (below) instead — the maintainer ruled the two inboxes apart so the decision count and the + # awaiting-merge count never share a label. + gh label create needs-user-decision -R "$R" -c d93f0b -d "Blocked on a maintainer decision — do not dispatch; decision cards only, never on a PR" 2>/dev/null || true gh label create pm:on-hold -R "$R" -c e4e669 -d "Decision made, deliberately deferred — no dispatch, no nag; restart condition in the hold comment" 2>/dev/null || true gh label create pm:blocked -R "$R" -c b60205 -d "Blocked by another issue/PR — body carries Blocked-by: #N" 2>/dev/null || true # pm:awaiting-maintainer — the state for a card whose remaining work is a @@ -220,6 +223,25 @@ for R in objectstack-ai/objectstack objectstack-ai/objectui objectstack-ai/cloud # Colour: needs-user-decision's family (a HUMAN owes something), one shade # lighter because the ruling here is already given. gh label create pm:awaiting-maintainer -R "$R" -c e99695 -d "Awaiting a manual maintainer action — ruling already given; no dispatch, no machine exit" 2>/dev/null || true + # pm:awaiting-merge — a PULL REQUEST whose landing waits on the maintainer's + # own review and merge (SKILL.md 复核 ②: 「PR 悬着等人批,同笔挂 pm:awaiting-merge」; + # maintainer ruling, verbatim: 「同意 pm:awaiting-merge」). It exists because that + # PR used to carry needs-user-decision, so the maintainer's one inbox mixed + # decision cards (a letter owed) with PRs (a review and a click owed) and no + # count could tell them apart. ⛔ PRs ONLY — never on an issue; it is NOT one + # of the six card states (SKILL.md 状态模型: 不入六态) and never joins + # PM_EXCLUSIVE_STATE_LABELS. Kept distinct from pm:awaiting-maintainer, which + # is an ISSUE waiting on a human act OUTSIDE GitHub. Named consumers: SKILL.md + # 复核 ② (the seat writes it beside the approval request), H48 in + # scripts/pm/check-half-states.mjs (GOVERNED_PR_AWAITING_MERGE_LABEL: a + # governed PR with an ACCEPT verdict must carry it), acceptedPullWait in + # scripts/pm/check-handover.mjs (a stay-behind pull "awaits a human approval" + # by this label), and scripts/pm/fleet-roster.mjs (the "awaiting merge" column + # and the 「决策卡 N · 待您审合 PR M」 summary line). Per-repo loop: the sweep is + # repo-parameterized and a governed PR can be opened on any repo of the loop. + # Colour: the same HUMAN-owes family as needs-user-decision, between it and + # pm:awaiting-maintainer, because here the click is still owed. + gh label create pm:awaiting-merge -R "$R" -c e07b63 -d "PR only: landing waits on the maintainer's own review and merge (SKILL.md 复核 ②); never on an issue" 2>/dev/null || true # priority:p0 is the QUEUE-JUMP tier of the lane pull order, and ordering # only: a p0 card still obeys the same-file serial queue and the claim # protocol (SKILL.md state model, 「优先是排序,不是豁免」). Set by the triage diff --git a/scripts/pm/fleet-roster.fixture.json b/scripts/pm/fleet-roster.fixture.json index 4bab699027c..bceddc732e5 100644 --- a/scripts/pm/fleet-roster.fixture.json +++ b/scripts/pm/fleet-roster.fixture.json @@ -30,7 +30,7 @@ { "number": 25401, "labels": [{ "name": "needs-user-decision" }, { "name": "domain:devx" }], "assignees": [], "pull_request": { "url": "x" }, "updated_at": "2026-10-23T02:37:00Z" } ], "pulls": [ - { "number": 25427, "state": "open", "head": { "ref": "claude/project-thread-aaaa", "sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, "body": "Fixes #25412", "labels": [] }, + { "number": 25427, "state": "open", "head": { "ref": "claude/project-thread-aaaa", "sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, "body": "Fixes #25412", "labels": [{ "name": "pm:awaiting-merge" }] }, { "number": 25392, "state": "open", "head": { "ref": "claude/project-thread-bbbb" }, "body": "Mailbox-of: domain:devx#1 · os-elon-musk\n\nFixes #25416", "labels": [{ "name": "pm:mailbox" }, { "name": "skip-changeset" }] }, { "number": 25393, "state": "open", "head": { "ref": "mailbox/os-elon-musk/skills-2" }, "body": "Mailbox-of: domain:skills#2 · os-elon-musk\n\nThe mailbox of the seat `domain:skills#2`", "labels": [{ "name": "pm:mailbox" }, { "name": "pinned" }] }, { "number": 25394, "state": "open", "head": { "ref": "mailbox/os-sales/devx-2" }, "body": "", "labels": [{ "name": "pm:mailbox" }] }, diff --git a/scripts/pm/fleet-roster.mjs b/scripts/pm/fleet-roster.mjs index 86b7a66b93e..cc5c12315f9 100644 --- a/scripts/pm/fleet-roster.mjs +++ b/scripts/pm/fleet-roster.mjs @@ -59,6 +59,13 @@ * awaiting ruling open `needs-user-decision` cards of the lane (issues, not * pulls): on the seat whose account is assigned, else on * the lane's lowest seated seat number, else its lowest. + * awaiting merge open pulls carrying `pm:awaiting-merge` — the PR half of + * the maintainer's inbox (SKILL.md 复核 ②; PRs only, ⛔ never + * an issue) — that close an in-flight card of the seat, + * attributed exactly as awaiting landing is. No new read: + * the labels ride on the open-pulls listing. The line under + * the table counts the two inboxes apart, every repo in: + * 「决策卡 N · 待您审合 PR M」, never summed. * awaiting landing open pulls on a `claude/` head that close an in-flight * card of the seat (`Fixes|Closes|Resolves #n`) and carry a * review verdict — a `## 席位核对` comment whose verdict @@ -133,7 +140,7 @@ import { LEDGER_EXCLUSION, MAILBOX_BRANCH_PREFIX, MAILBOX_LABEL, mailboxOwner, S // re-spells it. The relay that imports `ROSTER_MARKER` from here (`fleet-write/dispatch.mjs`) already imports // check-half-states itself, so this import loads nothing new there. The opener's lane-word lists cannot import it // (the patrol loads the opener lazily, so that edge would be a cycle); the self-test holds the two equal. -import { SEAT_SIGNATURE_LINE } from './check-half-states.mjs'; +import { GOVERNED_PR_AWAITING_MERGE_LABEL, SEAT_SIGNATURE_LINE } from './check-half-states.mjs'; import { contractReviewClaimLapse, readContractReviewClaim } from './record-recognisers.mjs'; // The seat record's heading and verdict have ONE reading, shared with H69 in // check-half-states; this file asks it, never re-spells the heading. @@ -440,6 +447,13 @@ export function buildRows(snapshot, { now }) { if (mailboxOwner(pr) !== null || !String(pr?.head?.ref ?? '').startsWith(CARD_BRANCH_PREFIX) || !hasLandingVerdict(comments)) continue; for (const k of closingRefs(pr.body, repo)) landing.set(k, [...(landing.get(k) ?? []), ref(repo, pr.number)]); } + const merging = new Map(); // card key → [pull refs carrying the awaiting-merge label] + for (const { repo, pr } of pulls) { + // The label IS the reading (a seat wrote it beside the approval request, SKILL.md 复核 ②): no verdict walk, + // no head-prefix test. A mailbox pull never counts, whatever it carries. + if (mailboxOwner(pr) !== null || !labelNames(pr).includes(GOVERNED_PR_AWAITING_MERGE_LABEL)) continue; + for (const k of closingRefs(pr.body, repo)) merging.set(k, [...(merging.get(k) ?? []), ref(repo, pr.number)]); + } const out = rows.map((row) => { const acct = lc(row.account); @@ -450,6 +464,7 @@ export function buildRows(snapshot, { now }) { const sharesWith = first !== row ? first.seat : null; const inFlight = row.account && sharesWith === null && claims === null ? cards.filter((c) => cardLaneMatch(c, row) && c.assignees.includes(acct)) : []; const awaitingLanding = inFlight.flatMap((c) => landing.get(keyOf(c)) ?? []); + const awaitingMerge = inFlight.flatMap((c) => merging.get(keyOf(c)) ?? []); // Per seat, never per account: one account holds several seats, and each seat opens its own. const mailbox = mailboxes.filter((m) => m.slug !== null && m.slug === seatSlugOf(row)).map((m) => m.ref); // The seat's own traces: a seat sharing its lane's account with a lower seat has no in-flight cards of its own. @@ -462,6 +477,7 @@ export function buildRows(snapshot, { now }) { inFlight: claims ?? inFlight.map((c) => ref(c.repo, c.number)).sort(byRef), awaitingRuling: [], awaitingLanding: awaitingLanding.sort(byRef), + awaitingMerge: awaitingMerge.sort(byRef), lastActivity, suspectedWall: row.status === 'seated' && !isEpicLane(row.lane) && row.wallNote === null && ageHours !== null && ageHours > STALE_HOURS, mailbox, @@ -490,6 +506,22 @@ function age(iso, now) { return `${iso.slice(0, 16).replace('T', ' ')}Z (${rel})`; } +/** + * The maintainer's inbox in its two halves, every repo in — counted APART and never summed (maintainer ruling: + * 「同意 pm:awaiting-merge」). Decision cards are open `needs-user-decision` ISSUES (a pull carrying that label is the + * retired shape and counts nowhere); awaiting-merge pulls are every open pull carrying `pm:awaiting-merge`, exactly + * the filter the maintainer opens — the column above is the per-seat attribution of the same pulls. Pure. + */ +export function inboxTotals(snapshot) { + let decisionCards = 0; + let awaitingMergePulls = 0; + for (const s of Object.values(snapshot?.repos ?? {})) { + for (const i of s.rulings ?? []) if (!i.pull_request && i.state !== 'closed') decisionCards++; + for (const p of s.pulls ?? []) if (p.state !== 'closed' && labelNames(p).includes(GOVERNED_PR_AWAITING_MERGE_LABEL)) awaitingMergePulls++; + } + return { decisionCards, awaitingMergePulls }; +} + /** The reads made for `repos`, in the order made — the footer's provenance. Pure. */ export function readsFor(repos) { return repos.flatMap((r) => [ @@ -504,15 +536,15 @@ export function readsFor(repos) { } /** The roster body, markdown; `unassigned` is `unassignedMailboxes`' list. Pure. */ -export function renderBody(rows, { now, repos, session = null, unassigned = [] }) { +export function renderBody(rows, { now, repos, session = null, unassigned = [], inboxes = null }) { const lines = [ ROSTER_MARKER, '# 车队总表 — fleet roster', '', 'A generated view of the seat posts (`pm:seat`), the `pm:*` labels, assignees and open pulls — **never a second tracker**: the cards and seat posts are the record, and the next run overwrites this body.', '', - '| repo | lane | seat | account | in flight | awaiting ruling | awaiting landing | wall note | last activity | mailbox PR |', - '|:--|:--|:-:|:--|:--|:--|:--|:--|:--|:--|', + '| repo | lane | seat | account | in flight | awaiting ruling | awaiting merge | awaiting landing | wall note | last activity | mailbox PR |', + '|:--|:--|:-:|:--|:--|:--|:--|:--|:--|:--|:--|', ]; for (const r of rows) { const who = r.routine ? 'Routine' : r.account ? `\`${r.account}\`` : '—'; @@ -520,13 +552,16 @@ export function renderBody(rows, { now, repos, session = null, unassigned = [] } const wall = r.wallNote ?? (r.suspectedWall ? `⚠️ ${SUSPECTED_WALL}` : '—'); const inFlight = r.sharesWith !== null ? `= seat ${r.sharesWith}` : refs(r.inFlight); lines.push( - `| ${r.repo.split('/')[1]} | ${cell(r.lane)} | [${r.seat}](https://github.com/${r.board}/issues/${r.post}) | ${cell(account)} | ${inFlight} | ${refs(r.awaitingRuling)} | ${refs(r.awaitingLanding)} | ${cell(wall)} | ${age(r.lastActivity, now)} | ${refs(r.mailbox)} |`, + `| ${r.repo.split('/')[1]} | ${cell(r.lane)} | [${r.seat}](https://github.com/${r.board}/issues/${r.post}) | ${cell(account)} | ${inFlight} | ${refs(r.awaitingRuling)} | ${refs(r.awaitingMerge)} | ${refs(r.awaitingLanding)} | ${cell(wall)} | ${age(r.lastActivity, now)} | ${refs(r.mailbox)} |`, ); } const seated = rows.filter((r) => r.status === 'seated'); lines.push( '', `${rows.length} seat rows (${rows.filter((r) => isEpicLane(r.lane)).length} epic) · ${seated.length} seated · ${rows.filter((r) => r.suspectedWall).length} ${SUSPECTED_WALL} (seated, no \`Wall:\` line, last activity older than ${STALE_HOURS} h).`, + ...(inboxes + ? ['', `决策卡 ${inboxes.decisionCards} · 待您审合 PR ${inboxes.awaitingMergePulls} — the maintainer's inbox in its two halves, every repo in: open \`needs-user-decision\` issues (a letter owed) and open \`pm:awaiting-merge\` pulls (a review and a click owed), never summed.`] + : []), ...(unassigned.length ? ['', `⚠️ ${unassigned.length} mailbox PR(s) on no seat row — never attributed to an account's seats; close a legacy one and open one per seat: ${unassigned.map((m) => `${shortRef(m.ref)} (${[m.seat ?? m.slug, m.login].filter(Boolean).join(' · ') || 'no owner'}: ${m.why})`).join(' · ')}.`] : []), @@ -647,11 +682,11 @@ export async function main(argv, { env = process.env } = {}) { const rows = buildRows(snapshot, { now }); const unassigned = unassignedMailboxes(snapshot, rows); if (opts.json) { - console.log(JSON.stringify({ now, repos, rows, unassignedMailboxes: unassigned }, null, 2)); + console.log(JSON.stringify({ now, repos, rows, unassignedMailboxes: unassigned, inboxes: inboxTotals(snapshot) }, null, 2)); return EXIT_OK; } const session = env.OS_FLEET_SESSION || env.CLAUDE_CODE_REMOTE_SESSION_ID || null; - const body = renderBody(rows, { now, repos, session, unassigned }); + const body = renderBody(rows, { now, repos, session, unassigned, inboxes: inboxTotals(snapshot) }); if (opts.write === null) { console.log(body); return EXIT_OK; @@ -815,6 +850,9 @@ async function selfTest() { t('a PR with no verdict does not count', row(1353, 'objectstack-ai/hotcrm').awaitingLanding, []); t('an unassigned ruling sits on the lane’s lowest seated seat', [row(6023).awaitingRuling, row(20163).awaitingRuling], [[{ repo: 'objectstack-ai/objectstack', number: 25373 }], []]); t('a ruling assigned to seat 2’s account sits on seat 2', row(19287).awaitingRuling, [{ repo: 'objectstack-ai/objectstack', number: 25344 }]); + t('⭐ a pull carrying `pm:awaiting-merge` that closes an in-flight card sits on that card’s seat, in its own column — the PR half of the inbox', row(19287).awaitingMerge, [os(25427)]); + t('⛔ a pull labelled `needs-user-decision` (the retired shape) is awaiting merge nowhere, and awaiting ruling never counts a pull', [rows.some((r) => r.awaitingMerge.length && r.post !== 19287), rows.some((r) => r.awaitingRuling.some((x) => x.number === 25401))], [false, false]); + t('the two inbox halves are counted apart, every repo in, and never summed', inboxTotals(fixture), { decisionCards: 2, awaitingMergePulls: 1 }); t('⭐ the mailbox column is the SEAT\'s pm:mailbox PR — by body line on a --head branch, by head slug, by both', [row(6023).mailbox, row(19287).mailbox, row(20163).mailbox], [[os(25392)], [os(25393)], [os(25394)]]); t('⛔ one login on three seats: each seat shows only its own mailbox, and a seat that opened none shows none', [row(6023).mailbox.length, row(19287).mailbox.length, row(1353, 'objectstack-ai/hotcrm').mailbox], [1, 1, []]); t('⛔ a legacy per-login mailbox and a seat no post or open epic parent carries sit on no row — flagged, never attributed to the login\'s seats', [unassignedMailboxes(fixture, rows).map((m) => [m.ref.number, m.seat, m.login, m.why]), [row(5560, 'objectstack-ai/objectui').mailbox, row(10243, 'objectstack-ai/objectui').mailbox]], [[[25395, null, 'os-sales', 'legacy per-login, names no seat'], [25396, 'domain:spec#5', 'os-tesla', 'no seat post or epic parent carries this seat'], [25398, 'epic:#28130#1', 'os-zhuang', 'no seat post or epic parent carries this seat']], [[], []]]); @@ -868,7 +906,7 @@ async function selfTest() { })(), [true, false]); t('the comment read starts STALE_HOURS before now, and the footer names it', [activitySince(NOW), readsFor([R]).some((q) => q.startsWith(`GET /repos/${R}/issues/comments?since=`))], ['2026-11-01T22:00:00Z', true]); const { readSignatureLine } = await import('./check-half-states.mjs'); - t('the signature pattern is check-half-states\' SEAT_SIGNATURE_LINE, imported — no second spelling of the lane alternation in this file', [/^import \{ SEAT_SIGNATURE_LINE \} from '\.\/check-half-states\.mjs';$/mu.test(readFileSync(SELF_PATH, 'utf8')), readFileSync(SELF_PATH, 'utf8').includes(['triage', 'director', 'coordinator'].join('|'))], [true, false]); + t('the signature pattern is check-half-states\' SEAT_SIGNATURE_LINE, imported — no second spelling of the lane alternation in this file (and the awaiting-merge label rides the same import, no second spelling of it either)', [/^import \{ GOVERNED_PR_AWAITING_MERGE_LABEL, SEAT_SIGNATURE_LINE \} from '\.\/check-half-states\.mjs';$/mu.test(readFileSync(SELF_PATH, 'utf8')), readFileSync(SELF_PATH, 'utf8').includes(['triage', 'director', 'coordinator'].join('|')), readFileSync(SELF_PATH, 'utf8').includes(["'pm:", 'awaiting-merge', "'"].join(''))], [true, false, false]); // The lane grammar's two remaining owners, held equal here (the only file that imports both): the signature's // alternation and the opener's lane-word lists. `coordinator` is the one lane that signs and has no mailbox. const signatureLanes = (/^\^\(\(\?:([^()]*)\)\)#/u.exec(SEAT_SIGNATURE_LINE.source)?.[1] ?? '').split('|'); @@ -878,14 +916,16 @@ async function selfTest() { } battery('the rendered body: marker, columns, footer, determinism'); - const body = renderBody(rows, { now: fixture.now, repos: Object.keys(fixture.repos).sort(), session: 'cse_01ABC' }); + const body = renderBody(rows, { now: fixture.now, repos: Object.keys(fixture.repos).sort(), session: 'cse_01ABC', inboxes: inboxTotals(fixture) }); t('the body opens with the marker', body.split('\n')[0], ROSTER_MARKER); - t('the header names the ten columns', body.includes('| repo | lane | seat | account | in flight | awaiting ruling | awaiting landing | wall note | last activity | mailbox PR |'), true); + t('the header names the eleven columns', body.includes('| repo | lane | seat | account | in flight | awaiting ruling | awaiting merge | awaiting landing | wall note | last activity | mailbox PR |'), true); + t('⭐ the inbox line spells both halves in the ruled words, apart and never summed', body.includes('决策卡 2 · 待您审合 PR 1'), true); + t('…and a render handed no inbox totals prints no inbox line rather than a wrong one', renderBody(rows, { now: fixture.now, repos: Object.keys(fixture.repos).sort() }).includes('待您审合 PR'), false); t('one table row per seat post', body.split('\n').filter((l) => /^\| (?:hotcrm|objectstack|objectui) \|/.test(l)).length, rows.length); t('the footer names the generation time and each read', [body.includes(`Generated ${fixture.now}`), body.includes('`GET /repos/objectstack-ai/hotcrm/issues?state=open&labels=pm:seat`')], [true, true]); t('the suspected wall is spelled in the row', body.includes(`⚠️ ${SUSPECTED_WALL}`), true); t('a shared seat reads = seat N in the in-flight cell', body.includes('| = seat 1 |'), true); - t('⭐ a review row renders its claimed pull in the in-flight cell, never `= seat N`', body.includes('| objectstack | review | [1](https://github.com/objectstack-ai/objectstack/issues/25500) | 🟢 `os-zhuang` | objectstack#25433 | — | — | — |'), true); + t('⭐ a review row renders its claimed pull in the in-flight cell, never `= seat N`', body.includes('| objectstack | review | [1](https://github.com/objectstack-ai/objectstack/issues/25500) | 🟢 `os-zhuang` | objectstack#25433 | — | — | — | — |'), true); t('the mailboxes on no seat row are flagged under the table, each with its owner and why', (() => { const flagged = renderBody(rows, { now: fixture.now, repos: Object.keys(fixture.repos).sort(), unassigned: unassignedMailboxes(fixture, rows) }); return [flagged.includes('⚠️ 3 mailbox PR(s) on no seat row'), flagged.includes('objectstack#25395 (os-sales: legacy per-login, names no seat)'), flagged.includes('objectstack#25396 (domain:spec#5 · os-tesla: no seat post or epic parent carries this seat)'), body.includes('on no seat row')];