diff --git a/.claude/skills/pm-dispatch/SKILL.md b/.claude/skills/pm-dispatch/SKILL.md index bf9fefc5f2e..b304e19d667 100644 --- a/.claude/skills/pm-dispatch/SKILL.md +++ b/.claude/skills/pm-dispatch/SKILL.md @@ -162,7 +162,7 @@ PM 的工作是循环:选卡 → 认领 → 派发 → 收集 → 复核 → 报 | `domain:spec` | `packages/spec` 整包:schema 形状、`contracts/**`、退役行为半边、strictness 台账;describe/JSDoc/墓碑散文/错误 guidance 与 alias 表;`packages/spec/scripts/**`、`packages/spec/docs/**` 及按锚定规则的例外归本域的工具链(域边界枚举与席内分派见 `references/lanes/spec.md`) | | `domain:ui` | `ui/**` 全部代码:`@object-ui/*` 库与 `ui/apps/*`、`ui/packages/types` 与 schema 语料、`ui/scripts` 与门禁工具、文档随所记录的面走;`packages/console`(dist 由 `pnpm console:build` 生成 ⛔ 不手改);协议恒归 `domain:spec`,受管面归 `domain:skills`;详见 `references/lanes/ui.md` | | `domain:cloud` | 涉 cloud 即整卡归 cloud PM(`repo:cloud` 席,同挂 `repo:cloud`):读或改 cloud 代码、生产方、事实或带 cloud 半边;本仓车道 ⛔ 不认领;cloud PM 跟到关卡,余下纯本仓活由它写明 cloud 事实后另立卡;判据见 `references/cross-repo-coordination.md` | -| `security`(车道,无 `domain:*` 标签) | 安全发现(无文件面;修复经无卡派发令落在归属包);席位 `security#N`、接收、台账、派修与复核见 `references/lanes/security.md`;永不公开的规则只住 `references/security-findings.md` | +| `security` · `review`(车道,无 `domain:*` 标签) | 安全发现(无文件面;修复经无卡派发令落在归属包);席位 `security#N`、接收、台账、派修与复核见 `references/lanes/security.md`;永不公开的规则只住 `references/security-findings.md`。复核席 `review#N` 见 `references/lanes/review.md` | | (无固定归属,按落点分诊) | `packages/apps/*`、`examples/*`(归它演练的子系统)、`docs/audits/**` | - 表未覆盖的包首次分诊时归类并走 PR 更新本表;新增或退役 `domain:*` 必须同批改本表。 diff --git a/.claude/skills/pm-dispatch/references/contract-review.md b/.claude/skills/pm-dispatch/references/contract-review.md index bbb84a52067..fc0e99dbd71 100644 --- a/.claude/skills/pm-dispatch/references/contract-review.md +++ b/.claude/skills/pm-dispatch/references/contract-review.md @@ -2,7 +2,7 @@ 见 landing-operations.md 〈入队与落地〉的条款②闸门;本文只放复核归属、资格与等复核标记。 -## 复核归属与资格(按面) +## 复核归属与资格(按面;复核席章程见 `lanes/review.md`) - 欠不欠按面判 ⛔ 不按车道:diff 或认领申报碰下列任一面即欠达档复核,交付后当轮完成。 - 复核面 = 契约四面:`Clause-②: yes` 申报、收窄申报、已发布 schema(`packages/spec/src/**` 非测 @@ -18,12 +18,12 @@ - 已发布面以包的 `exports` 映射为准:从入口类型图不可达、且 `exports` 不可寻址的 `.d.ts` 是 出货字节,⛔ 不是已发布接受集,改它不触发 `Clause-②: yes`;legacy node10 深引用在契约之外。 - 四面皆不碰 ⇒ CI 加席位自读 ⛔ 不起第二个 agent;谁跑 = 派发席,达档者席内审。 -- 派发席席内达档审;被配额拒(HTTP 429)⇒ ⛔ 不降档,留标记落交接行,点复审席,无则兄弟席: +- 派发席席内达档审;被配额拒(HTTP 429)⇒ ⛔ 不降档,留标记落交接行,点复核席,无则兄弟席: - `Contract-review-handoff: #N> — CONTRACT_REVIEW_TIER unavailable (, ) · head ` -- 欠审 PR(含 draft)现 head 无记录、无活认领且 (a)(b)(c) 之一 ⇒ 车道或复审席接,⛔ 非分诊。 +- 欠审 PR(含 draft)现 head 无记录无活认领且 (a)(b)(c) 之一 ⇒ 复核席先接,车道次之,⛔ 非分诊 - (a) 交接行点名现 head 或带等复核标记,同轮由所点席接;(b) 现 head 已等满一轮(约 60 分钟); -- (c) 车道收班或离总表:欠审入交班简报待下席或复审席;清单 `scripts/pm/check-owed-reviews.mjs`。 -- 车道或复审席起审前读 PR:现 head 有记录或活认领 ⇒ ⛔ 不起;否则先落认领行,一 head 一行: +- (c) 车道收班或离总表:欠审入交班简报待下席或复核席;清单 `scripts/pm/check-owed-reviews.mjs`。 +- 复核席或车道起审前读 PR:现 head 有记录或活认领 ⇒ ⛔ 不起;否则先落认领行,一 head 一行: - `Contract-review-claim: · head · ` - 认领随 head 移动或贴后 2 小时无记录失效;双认 ⇒ 先贴者持有,后者回帖撤回、弃产出。 - 车道暂缓本 head 送审落 `Contract-review-hold: · head · · until ` @@ -50,7 +50,7 @@ 而已挂 auto-merge ⇒ 派发席读到即照 AGENTS.md disarm 句撤挂,FAIL 记录点名欠撤挂;重挂照 ①。 - ② PR check 全绿,⛔ 非 required 子集;例外:merge-base 同签名的红不计、按设计而红见 SKILL.md。 - 签名 = 失败步 + 首错行,读 base check runs 的 API ⛔ 不凭口述;主干红止血立单不变。 -- 落地时仍欠复核的 PR 在落地 head 复审,车道席或复审席按 (a)(b)(c) 接;FAIL ⛔ 不修已关 PR。 +- 落地时仍欠复核的 PR 在落地 head 复审,复核席或车道席按 (a)(b)(c) 接;FAIL ⛔ 不修已关 PR。 - 每条 must-fix 引该记录立新卡、按发现定级;回退仅限主干红(p0,main-red 约定①)或安全边界。 ## 等复核标记 `needs:contract-review` diff --git a/.claude/skills/pm-dispatch/references/lanes/review.md b/.claude/skills/pm-dispatch/references/lanes/review.md new file mode 100644 index 00000000000..0021ff186a0 --- /dev/null +++ b/.claude/skills/pm-dispatch/references/lanes/review.md @@ -0,0 +1,21 @@ +# 席位章程:复核席(review) +见 contract-review.md;本文是本席的岗位说明,现值状态恒在座位贴,⛔ 不迁入;复核本身照该文。 + +## 座位 + +- 席位 `review#N`,首席 `review#1`,一席一座位贴;坐席者 = 能达 `CONTRACT_REVIEW_TIER` 的账号的会话。 +- 会话 effort `high`;复核子代理恒契约复审档;仅申报面 `TIER_DEFAULT`,档随面定照 contract-review.md +- 每席开一个 `pm:mailbox` PR(`mailbox-open.mjs --seat review#N`);签名、坐席与收班同他席。 + +## 路由 + +- 车道 `Request: contract-review` 敲任一复核席信箱,空闲者接;无复核席在座敲兄弟席,⛔ 非分诊 +- 互斥 = 既有 `Contract-review-claim:` 行:同 head 先贴者持有,复核席之间同此;后者撤回弃产出。 +- 欠审表 `check-owed-reviews.mjs` 先给复核席;车道只在无复核席在座或 (b) 后再一轮无认领才接 +- delta 复审归原复核者;复核席下班留下旧记录 ⇒ 下一复核席接 delta,交接行点名它。 + +## 独立性与边界 + +- ⛔ 不复核本账号派发或实现的 PR;记录恒带 `Implemented-by:`/`Reviewed-by:` 对。 +- ⛔ 不 merge、不 ready、不派发、不定级、不做人工地板动作;只写复核记录、认领行与门铃 +- 安全发现照 `security-findings.md`:记录只停在规则层,机制只进本席本地笔记。 diff --git a/.claude/skills/pm-dispatch/references/lanes/triage.md b/.claude/skills/pm-dispatch/references/lanes/triage.md index 90663d3382a..918ae3364ec 100644 --- a/.claude/skills/pm-dispatch/references/lanes/triage.md +++ b/.claude/skills/pm-dispatch/references/lanes/triage.md @@ -12,7 +12,7 @@ - ⑥车道不动:p1 卡在无席车道逾 4h;点名升级逾 6h 无答且回收需其一句;open PR 逾 6h 未更新, 先敲其席(无信箱按名)过下轮无答且回收需其一句;待裁或被具名 `Blocked-by:`、`status:parked` 卡、所骑栈阻者与信箱、Version PR 不算。⑦席位或子代理被拒、需人做的动作,⛔ 不代做。 -- 不推:定级、治半状态、解锁、契约复核(PASS/FAIL 归车道)、门铃答复、健康绿读数。 +- 不推:定级、治半状态、解锁、契约复核(归复核席,`review.md`)、门铃答复、健康绿读数。 - sweep 加推导 `pm:queue`/`pm:blocked` 无审计评论卡(豁免照 H74 行),照未定级首触补审计并报数。 - 每轮扫 `pm:dispatched` 卡与非信箱 open PR,进展=head 提交/非分诊评论/其席改标,非 `updated_at`。 - 进展逾 3h 即敲认领席 `Request: progress`,下轮读答复,无答列轮次记录;两轮无答视为掉线。 diff --git a/.claude/skills/pm-dispatch/references/project-forms.md b/.claude/skills/pm-dispatch/references/project-forms.md index 35a7ef4dfc1..6ce4f153780 100644 --- a/.claude/skills/pm-dispatch/references/project-forms.md +++ b/.claude/skills/pm-dispatch/references/project-forms.md @@ -12,7 +12,7 @@ - 项目指令只有一行:`pm-dispatch <席位> [<席位>…]`,如 `pm-dispatch 总监`、`pm-dispatch ui skills`。 - 首词恒为技能目录名 `pm-dispatch`;⛔ 不用 `fleet`,它已是 bot 与中继的名字。 -- 席位词 = 中文名或车道文件名:总监 = `lanes/director.md`,分诊 = `lanes/triage.md`。 +- 席位词 = 中文名或车道文件名:总监 = `lanes/director.md`,分诊 = `triage.md`,复核 = `review.md`。 - 其余席位词同 `lanes/.md` 文件名:ui、skills、devx、server、spec、hotcrm、mobile。 - 一车道多席:协调者取 🟢 行写本账号的那一席;无则取空席;再无则问维护者一次。 - 其余一切住本文:⛔ 不往项目里贴模板;⛔ 不在账号层复制技能。 @@ -37,7 +37,7 @@ - 正文首行 `Mailbox-of: <席位> · `;坐席即开(`mailbox-open.mjs`)并订阅,存根记 `Mailbox:`。 - 永不合并,不欠 CI,落地与台账查询排除它;移交或换账号关旧开新;评论首行 `To: <席位>`。 - 次行恒为一键,下列事件必敲;一事一行 ≤200 字节,只写键与编号;表外即半态。 -- 车道→复审席或兄弟席同交接行 `Request: contract-review`;反向随记录 `Notify: contract-review`。 +- 车道→复核席或兄弟席同交接行 `Request: contract-review`;反向随记录 `Notify: contract-review`。 - 任一席→分诊:p0 嫌疑、main 红、确定性队列红 `Request: urgent-triage`。 - 立锚卡者→分诊(缺则总监)`Request: main-red`,→破坏 PR 的席与锚卡车道 `Notify: main-red`。 - 锚卡关闭:分诊(缺则总监)→正文带 `Waits-on: main-red #n` 的每张 open PR 之席 `Notify: main-green`。 diff --git a/scripts/pm/check-half-states.mjs b/scripts/pm/check-half-states.mjs index 33cf7008902..3cdd5456163 100644 --- a/scripts/pm/check-half-states.mjs +++ b/scripts/pm/check-half-states.mjs @@ -1255,7 +1255,7 @@ export const RELEASE_COMMENT_MARKER = /^\s*>?\s*Release\s*:/mi; // // LANE a seat as the role table spells it: `domain:` or `repo:` plus a lowercase // name (`domain:engine-core` carries a hyphen, measured on the label set), -// `epic:#` plus the parent card, `triage`, `director`, `coordinator` or `security` +// `epic:#` plus the parent card, `triage`, `director`, `coordinator`, `security` or `review` // N the seat number, from 1 // ACCOUNT the login a claim's `Account:` line carries — GitHub's own login rule // SESSION the full id, in the relay envelope's shape (`SESSION_SHAPE`, fleet-write/ops.mjs, @@ -1279,7 +1279,7 @@ export const RELEASE_COMMENT_MARKER = /^\s*>?\s*Release\s*:/mi; // --------------------------------------------------------------------------- /** The four parts' patterns, unanchored, in order — the ONE spelling every reader and writer composes from. */ -const SIGNATURE_LANE_SOURCE = '(?:domain:[a-z][a-z0-9-]*|repo:[a-z][a-z0-9-]*|epic:#[1-9][0-9]*|triage|director|coordinator|security)'; +const SIGNATURE_LANE_SOURCE = '(?:domain:[a-z][a-z0-9-]*|repo:[a-z][a-z0-9-]*|epic:#[1-9][0-9]*|triage|director|coordinator|security|review)'; const SIGNATURE_ACCOUNT_SOURCE = '[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}'; /** The relay envelope's session shape without its anchors — `SESSION_SHAPE` in `fleet-write/ops.mjs`. */ export const SEAT_SIGNATURE_SESSION_SOURCE = 'session_[A-Za-z0-9]{6,}'; diff --git a/scripts/pm/check-owed-reviews.mjs b/scripts/pm/check-owed-reviews.mjs index 4199f5f06d7..87ca160f300 100644 --- a/scripts/pm/check-owed-reviews.mjs +++ b/scripts/pm/check-owed-reviews.mjs @@ -12,10 +12,14 @@ * ## Why it is a file in the repository * * The rule text (the pm-dispatch skill's `references/contract-review.md`, - * 〈复核归属与资格〉) says the dispatching seat reviews its own pull requests in - * its own seat, and a hand-off or an owed head goes to a review seat or a - * sibling seat of the lane — ⛔ never to triage, which takes no contract review - * and only counts the owed heads per lane in its round record. Before it existed the list was a + * 〈复核归属与资格〉, and the review seat's charter `references/lanes/review.md`) + * says the dispatching seat reviews its own pull requests in its own seat, and + * a hand-off or an owed head goes to a REVIEW SEAT FIRST (`review#N`, a seat + * whose only job is the review, on any account that reaches the tier) — the + * lane's own seat takes it only while no review seat is seated, or once the + * head has waited one further round past (b) with no claim — ⛔ never to + * triage, which takes no contract review and only counts the owed heads per + * lane in its round record. Before it existed the list was a * session-local scratch script: lost when a container was replaced, rebuilt * without drafts and without the hand-off lines, and two Tier H drafts waited * about five rounds with a hand-off naming their head. A list every seat can @@ -55,6 +59,13 @@ * lapse. * lane seat the lane of the PR's card (`domain:*`) against the open seat * posts: `seated`, `stood-down`, `absent`, `unknown`. + * review seat the review seats (`review#N`) against the same posts: `seated` + * (and which), `barred` (every seated one sits on this PR's own + * implementing or dispatching account — `lanes/review.md` 独立性; + * a barred seat is never named), `unjudged` (seated ones, but the + * card's comments could not be read, so the dispatching account + * is unknown and none is printed as free), `stood-down`, + * `absent`, `unknown` (roster unread). * status `TAKE` (owed, nobody on it, a trigger holds), `WAIT` (owed, * nobody on it, no trigger yet — the lane's own, a lane's live hold * included), `HELD` (a live claim on this head), `DONE` (a record on @@ -67,8 +78,12 @@ * absent from the seat roster. A live hold holds (b) alone: the * lane that holds ends it with (a), and a lane gone is (c). * taker on a TAKE row, who takes it: the seat the hand-off names; else - * the lane seat or a review seat; with the lane seat gone (c), a - * review seat or the lane's next seat. ⛔ Never triage. + * a seated review seat FIRST (named), the lane seat joining only + * once the head has waited a further round past (b); with no + * review seat seated, or every one barred, the lane seat at + * once; with the lane seat gone (c), + * a review seat or the lane's next seat; with the roster unread, + * either is named. ⛔ Never triage. * * ## Report-only — the exit is 0 on every completed or incomplete read * @@ -83,7 +98,7 @@ import process from 'node:process'; import { isEntrypoint } from '../invoked-as.mjs'; -import { CONTRACT_REVIEW_HEADING_MARKER, latestMarkedComment } from './check-half-states.mjs'; +import { CLAIM_COMMENT_MARKER, CONTRACT_REVIEW_HEADING_MARKER, latestMarkedComment, markerMatches, readSignatureLine } from './check-half-states.mjs'; import { repoIdForSlug } from './check-governed-merges.mjs'; import { readClause2Line } from './clause2-line.mjs'; import { parseSeat } from './fleet-roster.mjs'; @@ -110,6 +125,10 @@ export const WAITING_LABEL = 'needs:contract-review'; export const TRIAGE_ROUND_MINUTES = 60; /** The seat posts' label (`seat-post-protocol.md`); `parseSeat` reads each one. */ export const SEAT_LABEL = 'pm:seat'; +/** The review seat's lane word, as its seat post and signature spell it (`lanes/review.md`, `mailbox-open.mjs` bare lanes). */ +export const REVIEW_LANE = 'review'; +/** The lane seat joins a seated review seat as taker once the head has waited this many rounds — one further round past (b). */ +export const LANE_JOINS_AFTER_ROUNDS = 2; const REPO_SHAPE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; const VERDICT_LINE = /^[ \t>]*(?:\*\*)?VERDICT:[ \t]*(PASS|FAIL)\b/im; @@ -232,6 +251,53 @@ export function laneSeatOf(cardLabels, seats) { return { lane, seat: rows.length > 0 ? 'stood-down' : 'absent' }; } +/** + * The review seats (`lanes/review.md`: lane `review`, seats `review#N`) on the open seat posts. Pure. + * `seats` null means the roster could not be read. `seated` names every seated one, canonically. + */ +export function reviewSeatsOf(seats, ownAccounts = new Set()) { + if (!Array.isArray(seats)) return { state: 'unknown', seated: [], barred: [] }; + const rows = seats.filter((s) => s?.lane === REVIEW_LANE); + const name = (s) => `${REVIEW_LANE}#${Number(s.seat) > 0 ? Number(s.seat) : 1}`; + const seatedRows = rows.filter((s) => s.status === 'seated'); + // The card's comments could not be read: the dispatching account is unknown, so no seated seat is printed as free. + if (ownAccounts === null && seatedRows.length > 0) return { state: 'unjudged', seated: seatedRows.map(name).sort(), barred: [] }; + // `lanes/review.md` 独立性: a review seat never reviews a PR its own account dispatched or implemented. + const isBarred = (s) => typeof s.account === 'string' && ownAccounts !== null && ownAccounts.has(s.account.toLowerCase()); + const seated = seatedRows.filter((s) => !isBarred(s)).map(name).sort(); + const barred = seatedRows.filter(isBarred).map(name).sort(); + if (seated.length > 0) return { state: 'seated', seated, barred }; + if (barred.length > 0) return { state: 'barred', seated: [], barred }; + return { state: rows.length > 0 ? 'stood-down' : 'absent', seated: [], barred: [] }; +} + +/** + * The accounts no review seat of which may take this PR (`lanes/review.md` 独立性): the IMPLEMENTING + * account — the login in the body's `claude-projects-attribution` line — and the DISPATCHING account — + * the login signing each `Claim:` comment on the delivered card. Lowercased logins; an unreadable or + * absent line names no account. `null` when the card's comments could not be read (`cardComments` + * null beside a delivered card): the dispatching account is then UNJUDGED, not absent. Pure. + */ +export function ownAccountsOf(pull) { + if (pull?.card !== null && pull?.card !== undefined && pull?.cardComments === null) return null; + const out = new Set(); + const m = /claude-projects-attribution:\s*(\{[^\n]*?\})/.exec(String(pull?.body ?? '')); + if (m) { + try { + const login = JSON.parse(m[1])?.github_login; + if (typeof login === 'string' && login !== '') out.add(login.toLowerCase()); + } catch { + // an unreadable attribution object names no account + } + } + for (const c of Array.isArray(pull?.cardComments) ? pull.cardComments : []) { + if (!markerMatches(CLAIM_COMMENT_MARKER, c?.body)) continue; + const account = readSignatureLine(c?.body)?.account; + if (typeof account === 'string' && account !== '') out.add(account.toLowerCase()); + } + return out; +} + /** * One row for one pull request, or null when its face owes no review. Pure. * @@ -253,6 +319,7 @@ export function owedReviewRow(pull, { now, seats, repo = DEFAULT_REPO }) { const claim = contractReviewClaims(pull.comments, head, { now }); const hold = contractReviewHold(pull.comments, head, { now }); const lane = laneSeatOf(pull.cardLabels, seats); + const review = reviewSeatsOf(seats, ownAccountsOf(pull)); const triggers = []; if (handoff.state === 'handed') triggers.push('(a) hand-off'); if (owed.limbs.includes('label')) triggers.push('(a) label'); @@ -292,24 +359,45 @@ export function owedReviewRow(pull, { now, seats, repo = DEFAULT_REPO }) { }, lane: lane.lane, laneSeat: lane.seat, + reviewSeat: review.state, + reviewSeats: review.seated, + reviewBarred: review.barred, status, trigger: status === 'TAKE' || status === 'WAIT' ? (triggers[0] ?? 'none yet') : null, - taker: takerOf(status, handoff, lane), + taker: takerOf(status, handoff, lane, review, waited), gaps: Array.isArray(pull.gaps) ? pull.gaps : [], }; } /** - * Who takes a TAKE row (null on every other status). Pure. The rule text: the seat a hand-off on this - * head names; otherwise the lane's seat or a review seat; a lane whose seat is gone waits for a review - * seat or its next seat. ⛔ Never triage — it takes no contract review. + * Who takes a TAKE row (null on every other status). Pure. The rule text (`contract-review.md`, + * `lanes/review.md`): the seat a hand-off on this head names; otherwise a seated review seat FIRST, + * the lane's own seat joining only once the head has waited `LANE_JOINS_AFTER_ROUNDS` rounds with no + * claim; with no review seat seated, the lane's seat; a lane whose seat is gone waits for a review + * seat or its next seat; with the roster unread, either is named. ⛔ Never triage — it takes no + * contract review. */ -export function takerOf(status, handoff, lane) { +export function takerOf(status, handoff, lane, review = { state: 'unknown', seated: [] }, waited = null) { if (status !== 'TAKE') return null; if (handoff?.state === 'handed' && typeof handoff.to === 'string') return handoff.to; const seat = lane?.lane ? `the ${lane.lane} seat` : 'the lane seat'; - if (lane?.seat === 'stood-down' || lane?.seat === 'absent') return `a review seat, or ${seat} next on duty`; - return `${seat} or a review seat`; + const laneGone = lane?.seat === 'stood-down' || lane?.seat === 'absent'; + if (review?.state === 'seated') { + const named = `a review seat (${review.seated.join(', ')})`; + if (laneGone) return `${named}, or ${seat} next on duty`; + if (waited !== null && waited >= LANE_JOINS_AFTER_ROUNDS * TRIAGE_ROUND_MINUTES) return `${named} first, or ${seat} (a further round waited, no claim)`; + return `${named} first`; + } + if (review?.state === 'unknown') return laneGone ? `a review seat, or ${seat} next on duty` : `${seat} or a review seat`; + if (review?.state === 'unjudged') { + const named = `a review seat (${review.seated.join(', ')}) unless on this PR's own account (card comments unread)`; + return laneGone ? `${named}, or ${seat} next on duty` : `${named}, or ${seat}`; + } + if (review?.state === 'barred') { + const why = `review seat ${review.barred.join(', ')} is on this PR's own account`; + return laneGone ? `${seat} next on duty (${why})` : `${seat} (${why})`; + } + return laneGone ? `${seat} next on duty (no review seat seated)` : `${seat} (no review seat seated)`; } const STATUS_ORDER = Object.freeze(['TAKE', 'WAIT', 'HELD', 'DONE']); @@ -342,6 +430,7 @@ export function renderRow(row) { `claim ${claim}`, `hold ${holdText(row.hold)}`, `lane ${row.lane ?? '?'} ${row.laneSeat}`, + `review seat ${row.reviewSeat}${row.reviewSeats.length > 0 ? ` ${row.reviewSeats.join(',')}` : ''}${row.reviewBarred.length > 0 ? ` (barred ${row.reviewBarred.join(',')})` : ''}`, ]; const tail = row.trigger === null ? '' : ` → ${row.trigger}${row.taker ? ` · take: ${row.taker}` : ''}`; const listed = row.hold.listed ? ` ⏳ HOLD ${holdAge(row.hold)} — list it in the triage round record` : ''; @@ -413,7 +502,7 @@ export function readSnapshot({ repo, board, get = ghApi }) { if (!pullsRead.ok) return { repo, pulls: [], seats: null, gaps: [`the open pull list (${pullsRead.error})`] }; const seatsRead = list(`/repos/${board}/issues?state=open&labels=${encodeURIComponent(SEAT_LABEL)}`); const seats = seatsRead.ok ? seatsRead.value.map((post) => parseSeat(post, board)).filter((s) => s !== null) : null; - if (!seatsRead.ok) gaps.push(`the seat posts on ${board} (${seatsRead.error}) — trigger (c) is not judged`); + if (!seatsRead.ok) gaps.push(`the seat posts on ${board} (${seatsRead.error}) — trigger (c) and the review seats are not judged`); const pulls = []; for (const raw of pullsRead.value) { const pull = { ...raw, files: [], comments: [], card: null, cardComments: [], cardLabels: [], headCommittedAt: null, gaps: [] }; @@ -456,7 +545,8 @@ const USAGE = [ '', ' Lists the open pull requests, drafts included, whose face owes a contract review, with the record,', ' hand-off and claim on the current head, the trigger, and who takes it: the seat the hand-off', - ' names, the lane seat or a review seat — never triage.', + ' names; else a seated review seat first (`review#N`), the lane seat once a further round has', + ' passed with no claim, or the lane seat alone while no review seat is seated — never triage.', ` --repo the repository whose pulls are read (default ${DEFAULT_REPO}); --board, where the seat posts live`, ' --now the clock (default: now); --json prints the rows as JSON', ' Report-only: reads through `gh api`, writes nothing, exits 0 on every read; 2 on a usage error.', @@ -493,13 +583,15 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'the owed face: paths, the Clause-② line, the label — drafts included, nothing else owes': 9, 'the record: on this head (by whom, as stated), on an earlier head, none, unjudged': 8, 'the triggers: (a) hand-off or label the same round, (b) a full round, (c) the lane seat': 10, + // 10 → 14 → 16: S2 and S4 of PR #29967's contract review — a review seat on the PR's own account is barred (`lanes/review.md` 独立性); an unread card thread leaves the bar unjudged. + 'review-seat first: a seated review seat takes before the lane, the lane joins after a further round, and takes alone while none is seated': 16, 'a live claim holds the head and a record settles it; a lapsed claim holds nothing': 6, 'the live instance: two Tier H drafts with a hand-off on the current head list as TAKE under (a)': 4, 'the live read: every read through one reader; a failed read is INCOMPLETE, never clean': 6, 'the report: one line per owing PR, TAKE first, exit 0': 5, 'the hold: a live hold on this head holds (b) alone, (a) and (c) still fire; a new head clears it; at 24 hours it is listed, still WAIT': 14, }); -const SELF_TEST_BATTERY_FLOOR = 9; +const SELF_TEST_BATTERY_FLOOR = 10; const UNATTRIBUTED_BATTERY = '(no battery open)'; export function selfTest() { @@ -581,16 +673,40 @@ export function selfTest() { battery('the triggers: (a) hand-off or label the same round, (b) a full round, (c) the lane seat'); const fresh = '2026-10-28T12:30:00Z'; t('⭐ (a) a hand-off naming the current head ⇒ TAKE the same round, even 30 minutes in', (() => { const r = row({ headCommittedAt: fresh, comments: [handoff(HEAD, '2026-10-28T12:40:00Z')] }); return r.status === 'TAKE' && r.trigger === '(a) hand-off' && r.handoff.state === 'handed' && r.handoff.to === 'review#1' && r.taker === 'review#1'; })()); - t('⭐ (a) the waiting label ⇒ TAKE the same round, taken by the lane seat or a review seat', (() => { const r = row({ headCommittedAt: fresh, labels: [{ name: WAITING_LABEL }] }); return r.status === 'TAKE' && r.trigger === '(a) label' && r.taker === 'the domain:ui seat or a review seat'; })()); + t('⭐ (a) the waiting label ⇒ TAKE the same round, taken by the lane seat (no review seat seated on this roster)', (() => { const r = row({ headCommittedAt: fresh, labels: [{ name: WAITING_LABEL }] }); return r.status === 'TAKE' && r.trigger === '(a) label' && r.taker === 'the domain:ui seat (no review seat seated)'; })()); t('⭐ a hand-off to a sibling lane seat names that seat as the taker — the fallback while no review seat is seated', (() => { const r = row({ headCommittedAt: fresh, comments: [handoff(HEAD, '2026-10-28T12:40:00Z', 'domain:ui#2')] }); return r.status === 'TAKE' && r.taker === 'domain:ui#2' && renderRow(r).includes('hand-off handed domain:ui#2 (6463525633)') && renderRow(r).includes('→ (a) hand-off · take: domain:ui#2'); })()); - t('⛔ a hand-off line still naming `triage` hands to nobody: unreadable, no (a) from it, and the taker is never triage', (() => { const r = row({ headCommittedAt: fresh, labels: [{ name: WAITING_LABEL }], comments: [handoff(HEAD, '2026-10-28T12:40:00Z', 'triage')] }); return r.handoff.state === 'unreadable' && r.trigger === '(a) label' && r.taker === 'the domain:ui seat or a review seat' && !/take: [^·]*triage/.test(renderRow(r)); })()); + t('⛔ a hand-off line still naming `triage` hands to nobody: unreadable, no (a) from it, and the taker is never triage', (() => { const r = row({ headCommittedAt: fresh, labels: [{ name: WAITING_LABEL }], comments: [handoff(HEAD, '2026-10-28T12:40:00Z', 'triage')] }); return r.handoff.state === 'unreadable' && r.trigger === '(a) label' && r.taker === 'the domain:ui seat (no review seat seated)' && !/take: [^·]*triage/.test(renderRow(r)); })()); t('a hand-off on an EARLIER head is no (a) — stale', (() => { const r = row({ headCommittedAt: fresh, comments: [handoff(HEAD2, '2026-10-28T12:40:00Z')] }); return r.handoff.state === 'stale' && r.status === 'WAIT'; })()); - t('⭐ (b) the head has waited a full triage round with no record and no claim ⇒ TAKE', (() => { const r = row({ headCommittedAt: '2026-10-28T11:59:00Z' }); return r.status === 'TAKE' && r.trigger === '(b) waited 61 min' && r.taker === 'the domain:ui seat or a review seat'; })()); + t('⭐ (b) the head has waited a full triage round with no record and no claim ⇒ TAKE', (() => { const r = row({ headCommittedAt: '2026-10-28T11:59:00Z' }); return r.status === 'TAKE' && r.trigger === '(b) waited 61 min' && r.taker === 'the domain:ui seat (no review seat seated)'; })()); t('under a full round, nothing else holding ⇒ WAIT (the lane\'s own), trigger none yet', (() => { const r = row({ headCommittedAt: '2026-10-28T12:01:00Z' }); return r.status === 'WAIT' && r.trigger === 'none yet' && r.waited === 59; })()); - t('⭐ (c) the lane seat stood down, or absent from the roster ⇒ TAKE, by a review seat or the lane\'s next seat', row({ headCommittedAt: fresh }, [{ lane: 'domain:ui', status: 'paused' }]).trigger === '(c) lane seat stood-down' && row({ headCommittedAt: fresh }, [{ lane: 'domain:skills', status: 'seated' }]).trigger === '(c) lane seat absent' && row({ headCommittedAt: fresh }, [{ lane: 'domain:ui', status: 'paused' }]).taker === 'a review seat, or the domain:ui seat next on duty'); + t('⭐ (c) the lane seat stood down, or absent from the roster ⇒ TAKE, by the lane\'s next seat (no review seat seated)', row({ headCommittedAt: fresh }, [{ lane: 'domain:ui', status: 'paused' }]).trigger === '(c) lane seat stood-down' && row({ headCommittedAt: fresh }, [{ lane: 'domain:skills', status: 'seated' }]).trigger === '(c) lane seat absent' && row({ headCommittedAt: fresh }, [{ lane: 'domain:ui', status: 'paused' }]).taker === 'the domain:ui seat next on duty (no review seat seated)'); t('an unread roster or a card with no lane judges no (c)', row({ headCommittedAt: fresh }, null).laneSeat === 'unknown' && row({ headCommittedAt: fresh, cardLabels: [] }).laneSeat === 'unknown'); t('an unknown commit time judges no (b); a PR opened after its head commit waits from its opening', row({ headCommittedAt: null }).waited === null && row({ headCommittedAt: null }).status === 'WAIT' && row({ headCommittedAt: '2026-10-28T07:30:00Z', created_at: '2026-10-28T12:30:00Z' }).waited === 30); + battery('review-seat first: a seated review seat takes before the lane, the lane joins after a further round, and takes alone while none is seated'); + const WITH_REVIEW = [...SEATED, { lane: REVIEW_LANE, seat: 2, status: 'seated' }, { lane: REVIEW_LANE, seat: 1, status: 'seated' }]; + const rv = (o, seats = WITH_REVIEW) => owedReviewRow(pull(o), { now: NOW, seats }); + const BOTH = 'a review seat (review#1, review#2)'; + t('⭐ the roster reads the review seats: the seated ones named canonically and sorted; stood-down; absent; unknown when the roster was not read; a post with no number is seat 1', (() => { const s = reviewSeatsOf(WITH_REVIEW); return s.state === 'seated' && s.seated.join() === 'review#1,review#2' && reviewSeatsOf([...SEATED, { lane: REVIEW_LANE, seat: 1, status: 'paused' }]).state === 'stood-down' && reviewSeatsOf(SEATED).state === 'absent' && reviewSeatsOf(SEATED).seated.length === 0 && reviewSeatsOf(null).state === 'unknown' && reviewSeatsOf([{ lane: REVIEW_LANE, status: 'seated' }]).seated.join() === 'review#1'; })()); + t('⭐ (a) the label with a review seat seated ⇒ the review seat FIRST; the lane is not yet named', (() => { const r = rv({ headCommittedAt: fresh, labels: [{ name: WAITING_LABEL }] }); return r.status === 'TAKE' && r.reviewSeat === 'seated' && r.reviewSeats.join() === 'review#1,review#2' && r.taker === `${BOTH} first`; })()); + t('⭐ (b) one round waited ⇒ still the review seat first; a FURTHER round waited with no claim ⇒ the lane seat joins the taker line', rv({ headCommittedAt: '2026-10-28T11:59:00Z' }).taker === `${BOTH} first` && rv({ headCommittedAt: '2026-10-28T10:59:00Z' }).taker === `${BOTH} first, or the domain:ui seat (a further round waited, no claim)` && LANE_JOINS_AFTER_ROUNDS === 2); + t('⭐ the card\'s acceptance case: NO review seat seated ⇒ the lane\'s own seat takes, at (b) and after — never triage', (() => { const r = rv({ headCommittedAt: '2026-10-28T10:59:00Z' }, SEATED); return r.status === 'TAKE' && r.reviewSeat === 'absent' && r.taker === 'the domain:ui seat (no review seat seated)' && !/triage/.test(renderRow(r)); })()); + t('a review seat that stood down is no review seat: the lane seat takes, and the row says stood-down', (() => { const r = rv({ headCommittedAt: fresh, labels: [{ name: WAITING_LABEL }] }, [...SEATED, { lane: REVIEW_LANE, seat: 1, status: 'paused' }]); return r.reviewSeat === 'stood-down' && r.taker === 'the domain:ui seat (no review seat seated)'; })()); + t('(c) the lane seat gone: a seated review seat, or the lane\'s next seat; none seated ⇒ the next seat alone', rv({ headCommittedAt: fresh }, [{ lane: REVIEW_LANE, status: 'seated' }]).taker === 'a review seat (review#1), or the domain:ui seat next on duty' && rv({ headCommittedAt: fresh }, [{ lane: 'domain:skills', status: 'seated' }]).taker === 'the domain:ui seat next on duty (no review seat seated)'); + t('the roster unread: either is named, as before, and the row says unknown', (() => { const r = rv({ headCommittedAt: '2026-10-28T11:59:00Z' }, null); return r.reviewSeat === 'unknown' && r.taker === 'the domain:ui seat or a review seat'; })()); + t('a hand-off names its seat whatever the roster says — a sibling lane seat under a seated review seat included', rv({ headCommittedAt: fresh, comments: [handoff(HEAD, '2026-10-28T12:40:00Z', 'domain:ui#2')] }).taker === 'domain:ui#2' && rv({ headCommittedAt: fresh, comments: [handoff(HEAD, '2026-10-28T12:40:00Z')] }, SEATED).taker === 'review#1'); + t('⭐ a live claim by a review seat ⇒ HELD on the review side, no taker, nobody else starts', (() => { const r = rv({ labels: [{ name: WAITING_LABEL }], comments: [claimRow(REVIEWER, HEAD, '2026-10-28T12:18:56Z', `review#1 · os-zhuang · ${REVIEWER}`)] }); return r.status === 'HELD' && r.claim.holder.side === 'review' && r.taker === null && renderRow(r).includes('(review,'); })()); + const ATTR = '\n_Requested by **Y**_\n\nPart of #25922\n\nClause-②: no\nMetadata-protocol: none'; + const CLAIMED = [{ id: 7, created_at: '2026-10-28T09:00:00Z', body: 'domain:skills#3 · os-zhuang · session_01Abcdefgh\n\nClaim: PM loop round 1 · 2026-10-28T09:00Z\nBranch: `claude/x`' }]; + const OWN = [...SEATED, { lane: REVIEW_LANE, seat: 1, status: 'seated', account: 'os-support-ai' }]; + t('⭐ own accounts: the body\'s attribution login (lowercased) and the signer of each Claim: comment on the card; an unsigned or non-claim card comment, an unreadable attribution and no body name none', [...ownAccountsOf({ body: ATTR, card: 25922, cardComments: [...CLAIMED, { body: 'os-dev-report\n{}' }, { body: 'domain:ui#1 · os-ui · session_01Abcdefgh\n\nnote' }] })].sort().join() === 'os-support-ai,os-zhuang' && ownAccountsOf({ body: '' }).size === 0 && ownAccountsOf({}).size === 0); + t('⭐ S2: the only seated review seat sits on the IMPLEMENTING account ⇒ barred, the lane\'s own seat takes at once — no further round, no triage', (() => { const r = rv({ body: ATTR, headCommittedAt: fresh, labels: [{ name: WAITING_LABEL }] }, OWN); return r.status === 'TAKE' && r.reviewSeat === 'barred' && r.reviewSeats.length === 0 && r.reviewBarred.join() === 'review#1' && r.taker === "the domain:ui seat (review seat review#1 is on this PR's own account)" && !/triage/.test(renderRow(r)) && renderRow(r).includes('review seat barred (barred review#1)'); })()); + t('⭐ S2: a review seat on the DISPATCHING account (the card claim\'s signer) is barred too; a review seat on a third account still takes first, the barred one named', (() => { const r = rv({ card: 25922, cardComments: CLAIMED, headCommittedAt: fresh, labels: [{ name: WAITING_LABEL }] }, [...SEATED, { lane: REVIEW_LANE, seat: 1, status: 'seated', account: 'OS-Zhuang' }, { lane: REVIEW_LANE, seat: 2, status: 'seated', account: 'os-other' }]); const alone = rv({ card: 25922, cardComments: CLAIMED, headCommittedAt: '2026-10-28T10:59:00Z' }, [...SEATED, { lane: REVIEW_LANE, seat: 1, status: 'seated', account: 'os-zhuang' }]); return r.reviewSeat === 'seated' && r.reviewSeats.join() === 'review#2' && r.reviewBarred.join() === 'review#1' && r.taker === 'a review seat (review#2) first' && alone.taker === "the domain:ui seat (review seat review#1 is on this PR's own account)"; })()); + t('S2: the lane seat gone and the only review seat barred ⇒ the lane\'s next seat; a review seat with no account on its post is never barred', rv({ body: ATTR, headCommittedAt: fresh }, [{ lane: REVIEW_LANE, seat: 1, status: 'seated', account: 'os-support-ai' }]).taker === "the domain:ui seat next on duty (review seat review#1 is on this PR's own account)" && rv({ body: ATTR, headCommittedAt: fresh, labels: [{ name: WAITING_LABEL }] }, [...SEATED, { lane: REVIEW_LANE, seat: 1, status: 'seated', account: null }]).taker === 'a review seat (review#1) first'); + t('⭐ S4: the card\'s comments unreadable ⇒ own accounts null, the bar UNJUDGED: a seated review seat is named with the caveat, never as free, and the lane seat beside it; with no seated review seat the states read as before', (() => { const unread = { card: 25922, cardComments: null, headCommittedAt: fresh, labels: [{ name: WAITING_LABEL }] }; const r = rv(unread, OWN); const gone = rv({ card: 25922, cardComments: null, headCommittedAt: fresh }, [{ lane: REVIEW_LANE, seat: 1, status: 'seated', account: 'os-support-ai' }]); return ownAccountsOf({ card: 25922, cardComments: null }) === null && ownAccountsOf({ card: null, cardComments: [] }).size === 0 && r.reviewSeat === 'unjudged' && r.reviewSeats.join() === 'review#1' && r.reviewBarred.length === 0 && r.taker === "a review seat (review#1) unless on this PR's own account (card comments unread), or the domain:ui seat" && gone.taker === "a review seat (review#1) unless on this PR's own account (card comments unread), or the domain:ui seat next on duty" && rv(unread, SEATED).reviewSeat === 'absent' && rv(unread, [...SEATED, { lane: REVIEW_LANE, seat: 1, status: 'paused' }]).reviewSeat === 'stood-down' && renderRow(r).includes('review seat unjudged review#1'); })()); + t('S4: a readable but empty comment thread on the card, or no card at all, judges the bar — the attribution login alone bars', rv({ card: 25922, cardComments: [], body: ATTR, headCommittedAt: fresh, labels: [{ name: WAITING_LABEL }] }, OWN).reviewSeat === 'barred' && rv({ body: ATTR, headCommittedAt: fresh, labels: [{ name: WAITING_LABEL }] }, OWN).reviewSeat === 'barred'); + t('the printed row carries the review-seat reading and the taker', renderRow(rv({ headCommittedAt: fresh, labels: [{ name: WAITING_LABEL }] })).includes(`review seat seated review#1,review#2 → (a) label · take: ${BOTH} first`) && renderRow(rv({ headCommittedAt: fresh, labels: [{ name: WAITING_LABEL }] }, SEATED)).includes('review seat absent → (a) label · take: the domain:ui seat (no review seat seated)')); + battery('a live claim holds the head and a record settles it; a lapsed claim holds nothing'); t('⭐ a live claim on the current head ⇒ HELD, naming the holder — whatever trigger holds', (() => { const r = row({ labels: [{ name: WAITING_LABEL }], comments: [claimRow(TRIAGE, HEAD, '2026-10-28T12:18:56Z', `triage#1 · os-zhuang · ${TRIAGE}`)] }); return r.status === 'HELD' && r.claim.holder.session === TRIAGE && r.trigger === null; })()); t('⭐ a legacy `triage#N`-signed claim yields: triage first (posted before triage stopped reviewing), lane later ⇒ the lane holds', (() => { const r = row({ comments: [claimRow(TRIAGE, HEAD, '2026-10-28T12:18:56Z', `triage#1 · os-zhuang · ${TRIAGE}`), { ...claimRow(LANE, HEAD, '2026-10-28T12:20:00Z', `domain:ui#1 · os-bill · ${LANE}`), id: 2 }] }); return r.status === 'HELD' && r.claim.holder.session === LANE && r.claim.tie === true; })()); @@ -641,7 +757,7 @@ export function selfTest() { const noList = readSnapshot({ repo: DEFAULT_REPO, board: DEFAULT_REPO, get: fake({}) }); t('⛔ a failed pull list is a gap and zero rows — never an empty board reported clean', noList.pulls.length === 0 && noList.gaps.length === 1 && renderReport([], { repo: DEFAULT_REPO, now: NOW, read: { pulls: 0, drafts: 0 }, gaps: noList.gaps }).some((l) => l.includes('sweep INCOMPLETE'))); const noSeats = readSnapshot({ repo: DEFAULT_REPO, board: DEFAULT_REPO, get: fake({ ...routes, '/repos/objectstack-ai/objectstack/issues?': { ok: false, error: 'HTTP 403' } }) }); - t('an unread seat roster is a gap and judges no (c)', noSeats.seats === null && noSeats.gaps.some((g) => g.includes('trigger (c) is not judged'))); + t('an unread seat roster is a gap and judges no (c)', noSeats.seats === null && noSeats.gaps.some((g) => g.includes('trigger (c) and the review seats are not judged'))); battery('the report: one line per owing PR, TAKE first, exit 0'); const printed = []; diff --git a/scripts/pm/check-skill-line-ratchet.mjs b/scripts/pm/check-skill-line-ratchet.mjs index a7cd3df2276..e55a5ef38a8 100644 --- a/scripts/pm/check-skill-line-ratchet.mjs +++ b/scripts/pm/check-skill-line-ratchet.mjs @@ -1662,6 +1662,18 @@ export const CEILINGS = new Map([ // 兄弟席」, the idle reading and the sibling hand-over, which names this // file). Measured delta +3 (17 → 20 lines); landed count, headroom 0. ['.claude/skills/pm-dispatch/references/lanes/triage.md', 20], + // Review seat charter (`review#N`): the seat whose only job is the contract + // review, held on any account that reaches CONTRACT_REVIEW_TIER — its seat + // word, mailbox, review-seat-first routing of `Request: contract-review` + // and the owed-review list, the earliest-claim mutual exclusion, the delta + // rule, independence (never a PR its own account dispatched or implemented) + // and what it never does; the review itself stays in contract-review.md. + // Ruling of record: the maintainer's question 「契约复审我想分拆到其他账户,怎么处理。」 + // and ruling 「选 B,立卡」 (dedicated review seats), quoted verbatim on the + // card that ordered this file (filing gate ③). A NEW file, so this is an + // added row and no other row moves; set at the landed line count (headroom + // 0, same convention as every lane charter above). + ['.claude/skills/pm-dispatch/references/lanes/review.md', 21], // 399 → 405 (#11126): maintainer-ruled (2026-08-23, option B, quoted in that // PR) — the +6-line cross-repo dispatch-gates caveat, sized so the queued // #11137 (395→399 on main) and this PR's +6 compose to exactly 405. @@ -2462,6 +2474,7 @@ export const MAX_TABLE_ROW_BYTES = new Map([ ['.claude/skills/pm-dispatch/references/lanes/security.md', 0], ['.claude/skills/pm-dispatch/references/lanes/director.md', 0], ['.claude/skills/pm-dispatch/references/lanes/triage.md', 0], + ['.claude/skills/pm-dispatch/references/lanes/review.md', 0], ['.claude/agents/os-dev.md', 0], ['.claude/skills/checklist-test/SKILL.md', 221], ['.claude/skills/checklist-author/SKILL.md', 0], diff --git a/scripts/pm/fleet-roster.fixture.json b/scripts/pm/fleet-roster.fixture.json index 5efbc261236..4bab699027c 100644 --- a/scripts/pm/fleet-roster.fixture.json +++ b/scripts/pm/fleet-roster.fixture.json @@ -8,6 +8,7 @@ { "number": 7623, "title": "[PM seat] domain:skills — ⏳ vacant", "body": "", "labels": [{ "name": "pm:seat" }], "assignees": [], "updated_at": "2026-10-22T16:23:45Z" }, { "number": 19287, "title": "[PM seat] domain:skills · seat 2 — 🟢 os-elon-musk", "body": "由 os-elon-musk 账户的项目负责", "labels": [{ "name": "pm:seat" }], "assignees": [{ "login": "os-elon-musk" }], "updated_at": "2026-10-23T12:00:00Z" }, { "number": 6015, "title": "[PM seat] triage (objectstack-wide) — 🟢 Routine · session_01AavokzJ5DndAwitDXvKy4U · trig_01NcnCtMS2tH46nUg1a5TaMJ (hourly)", "body": "", "labels": [{ "name": "pm:seat" }], "assignees": [], "updated_at": "2026-10-23T12:59:06Z" }, + { "number": 25500, "title": "[PM seat] review · seat 1 — 🟢 os-zhuang", "body": "", "labels": [{ "name": "pm:seat" }], "assignees": [{ "login": "os-zhuang" }], "updated_at": "2026-10-23T09:00:00Z" }, { "number": 999, "title": "Fleet seat registry", "body": "", "labels": [{ "name": "pm:seat" }], "assignees": [], "updated_at": "2026-10-01T00:00:00Z" } ], "epics": [ @@ -29,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" }, "body": "Fixes #25412", "labels": [] }, + { "number": 25427, "state": "open", "head": { "ref": "claude/project-thread-aaaa", "sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, "body": "Fixes #25412", "labels": [] }, { "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" }] }, @@ -39,12 +40,18 @@ { "number": 25399, "state": "open", "head": { "ref": "mailbox/os-tesla/epic-28800-2" }, "body": "Mailbox-of: epic:#28800#2 · os-tesla", "labels": [{ "name": "pm:mailbox" }, { "name": "pinned" }] }, { "number": 25400, "state": "open", "head": { "ref": "mailbox/os-bill/epic-27293-2" }, "body": "", "labels": [{ "name": "pm:mailbox" }] }, { "number": 25398, "state": "open", "head": { "ref": "mailbox/os-zhuang/epic-28130-1" }, "body": "Mailbox-of: epic:#28130#1 · os-zhuang", "labels": [{ "name": "pm:mailbox" }, { "name": "pinned" }] }, - { "number": 25433, "state": "open", "head": { "ref": "claude/project-thread-cccc" }, "body": "Fixes #25417", "labels": [] } + { "number": 25433, "state": "open", "head": { "ref": "claude/project-thread-cccc", "sha": "cccccccccccccccccccccccccccccccccccccccc" }, "body": "Fixes #25417", "labels": [], "updated_at": "2026-10-23T12:30:00Z" }, + { "number": 25434, "state": "open", "head": { "ref": "mailbox/os-zhuang/review-1" }, "body": "Mailbox-of: review#1 · os-zhuang", "labels": [{ "name": "pm:mailbox" }, { "name": "pinned" }], "updated_at": "2026-10-23T09:30:00Z" } ], "comments": { "25427": [{ "body": "## 席位核对\n\nACCEPT · `domain:skills#2` · `os-elon-musk` · head `abc`" }], "25392": [{ "body": "## 席位核对\n\n**ACCEPT** · this must never count" }] - } + }, + "recentComments": [ + { "id": 1, "issue_url": "https://api.github.com/repos/objectstack-ai/objectstack/issues/25433", "created_at": "2026-10-23T13:30:00Z", "updated_at": "2026-10-23T13:30:00Z", "body": "review#1 · os-zhuang · session_01ReviewSeatFixture\n\nContract-review-claim: session_01ReviewSeatFixture · head cccccccccccccccccccccccccccccccccccccccc · 2026-10-23T13:30:00Z\n\nReading the diff." }, + { "id": 2, "issue_url": "https://api.github.com/repos/objectstack-ai/objectstack/issues/25427", "created_at": "2026-10-23T11:30:00Z", "updated_at": "2026-10-23T11:30:00Z", "body": "review#1 · os-zhuang · session_01ReviewSeatFixture\n\nContract-review-claim: session_01ReviewSeatFixture · head aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa · 2026-10-23T11:30:00Z" }, + { "id": 3, "issue_url": "https://api.github.com/repos/objectstack-ai/objectstack/issues/25433", "created_at": "2026-10-23T12:00:00Z", "updated_at": "2026-10-23T12:00:00Z", "body": "review#1 · os-zhuang · session_01ReviewSeatFixture\n\nContract-review-claim: session_01ReviewSeatFixture · head dddddddddddddddddddddddddddddddddddddddd · 2026-10-23T12:00:00Z" } + ] }, "objectstack-ai/objectui": { "seats": [ diff --git a/scripts/pm/fleet-roster.mjs b/scripts/pm/fleet-roster.mjs index 0c8eaa6b1e4..86b7a66b93e 100644 --- a/scripts/pm/fleet-roster.mjs +++ b/scripts/pm/fleet-roster.mjs @@ -50,7 +50,12 @@ * seat's repo; a `repo:x` lane is every card of repo x plus * the post's own repo's cards labelled `repo:x`. An account * on two seats of one lane shows them on its lowest seat - * only; the other reads `= seat N`. + * only; the other reads `= seat N`. A `review` seat + * (`lanes/review.md`) holds no card: its in-flight is the + * open pulls whose CURRENT head a live + * `Contract-review-claim:` signed `review#N` holds, read + * off the signed-comment read below — per seat, so two + * review seats never share and never read `= seat N`. * 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. @@ -129,6 +134,7 @@ import { LEDGER_EXCLUSION, MAILBOX_BRANCH_PREFIX, MAILBOX_LABEL, mailboxOwner, S // 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 { 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. import * as seatRecord from './seat-record.mjs'; @@ -317,6 +323,36 @@ export function signedActivity(snapshot) { return out; } +/** The review lane's bare word (`lanes/review.md`), one of the opener's `SEAT_BARE_LANES` — pinned equal in the self-test. */ +export const REVIEW_LANE = 'review'; + +/** + * The review seats' in-flight: `review#N` → the open pulls whose CURRENT head a live + * `Contract-review-claim:` signed by that seat holds (`readContractReviewClaim`, lapse by + * `contractReviewClaimLapse` from the comment's own `created_at`), over every repo's + * `recentComments` — the claim lapses in 2 h and the read starts `STALE_HOURS` back, so no + * live claim is ever past the read. An expired claim, one on a moved head, one on a closed + * pull or one signed by any other seat is nothing here. Pure. + */ +export function reviewClaims(snapshot, { now }) { + const nowMs = Date.parse(now); + const out = new Map(); + for (const repo of Object.keys(snapshot?.repos ?? {})) { + const s = snapshot.repos[repo] ?? {}; + const heads = new Map((s.pulls ?? []).filter((p) => p.state !== 'closed' && p?.head?.sha).map((p) => [p.number, p.head.sha])); + for (const c of s.recentComments ?? []) { + const seat = signedSeatOf(c?.body); + if (seat === null || !seat.startsWith(`${REVIEW_LANE}#`)) continue; + const number = Number(/\/issues\/(\d+)$/.exec(String(c?.issue_url ?? ''))?.[1]); + const claim = readContractReviewClaim(c?.body); + if (!heads.has(number) || claim.state !== 'read') continue; + if (contractReviewClaimLapse(claim, { headSha: heads.get(number), now: nowMs, postedAt: Date.parse(c?.created_at ?? '') }) !== 'live') continue; + out.set(seat, [...(out.get(seat) ?? []), ref(repo, number)]); + } + } + return out; +} + /** Where the signed-comment read starts: `STALE_HOURS` before `now` — no older comment can lift a flag. Pure. */ export const activitySince = (now) => new Date(Date.parse(now) - STALE_HOURS * 3_600_000).toISOString().replace(/\.\d{3}Z$/, 'Z'); @@ -368,6 +404,7 @@ export function buildRows(snapshot, { now }) { const pulls = []; const mailboxes = readMailboxes(snapshot); const signed = signedActivity(snapshot); + const claimed = reviewClaims(snapshot, { now }); const pullUpdatedAt = new Map(); // pull key → its updated_at, for the mailbox's share of last activity for (const repo of repos) { const s = snapshot.repos[repo] ?? {}; @@ -406,21 +443,23 @@ export function buildRows(snapshot, { now }) { const out = rows.map((row) => { const acct = lc(row.account); + // A review seat's claims are signed per seat, so it never shares: its in-flight is the pulls its live claims hold. + const claims = row.lane === REVIEW_LANE ? (claimed.get(`${row.lane}#${row.seat}`) ?? []).sort(byRef) : null; // One account on two seats of one lane: the cards cannot say which seat, so the lowest seat shows them once. - const first = row.account ? rows.find((r) => r.repo === row.repo && r.lane === row.lane && lc(r.account) === acct) : row; + const first = row.account && claims === null ? rows.find((r) => r.repo === row.repo && r.lane === row.lane && lc(r.account) === acct) : row; const sharesWith = first !== row ? first.seat : null; - const inFlight = row.account && sharesWith === null ? cards.filter((c) => cardLaneMatch(c, row) && c.assignees.includes(acct)) : []; + 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)) ?? []); // 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. - const times = [row.updatedAt, ...inFlight.map((c) => c.updatedAt), ...mailbox.map((m) => pullUpdatedAt.get(keyOf(m))), signed.get(`${row.lane}#${row.seat}`)].filter(Boolean).sort(); + const times = [row.updatedAt, ...inFlight.map((c) => c.updatedAt), ...(claims ?? []).map((m) => pullUpdatedAt.get(keyOf(m))), ...mailbox.map((m) => pullUpdatedAt.get(keyOf(m))), signed.get(`${row.lane}#${row.seat}`)].filter(Boolean).sort(); const lastActivity = times.at(-1) ?? null; const ageHours = lastActivity && Number.isFinite(nowMs) ? (nowMs - Date.parse(lastActivity)) / 3_600_000 : null; return { ...row, sharesWith, - inFlight: inFlight.map((c) => ref(c.repo, c.number)).sort(byRef), + inFlight: claims ?? inFlight.map((c) => ref(c.repo, c.number)).sort(byRef), awaitingRuling: [], awaitingLanding: awaitingLanding.sort(byRef), lastActivity, @@ -459,7 +498,7 @@ export function readsFor(repos) { `GET /repos/${r}/issues?state=open&labels=pm:dispatched`, `GET /repos/${r}/issues?state=open&labels=needs-user-decision`, `GET /repos/${r}/pulls?state=open`, - `GET /repos/${r}/issues/comments?since=(now − ${STALE_HOURS} h), each read for its first-line seat signature`, + `GET /repos/${r}/issues/comments?since=(now − ${STALE_HOURS} h), each read for its first-line seat signature and, signed review#N, for a live Contract-review-claim`, `GET /repos/${r}/issues/{n}/comments for each claude/ pull closing an in-flight card (${LEDGER_EXCLUSION} excluded)`, ]); } @@ -652,10 +691,11 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'arguments: modes and every refusal': 9, 'seat titles: lane, seat, repo, status, account, project': 13, 'wall notes, closing refs, verdicts and mailboxes': 13, - 'the fixture snapshot: rows for the three repos': 25, + // 25 → 29: the review seat's row, its claim reading, the lane-word pin (lanes/review.md, #29242). + 'the fixture snapshot: rows for the three repos': 29, // 13 → 14: the lane-grammar pin (the signature alternation equals the opener's lane-word lists, `coordinator` aside). 'one account on two seats of a lane: the second seat reads its own traces': 14, - 'the rendered body: marker, columns, footer, determinism': 10, + 'the rendered body: marker, columns, footer, determinism': 11, 'refusing to overwrite what is not the roster': 6, 'the CLI: fixture runs offline, usage errors are exit 2': 6, 'paging: a three-page read through the loop equals the slurped array': 4, @@ -749,12 +789,13 @@ async function selfTest() { 'objectstack:epic:#27293:1', 'objectstack:epic:#27293:2', 'objectstack:epic:#28800:2', + 'objectstack:review:1', 'objectstack:triage:1', 'objectui:domain:ui:1', 'objectui:domain:ui:4', ]); const laneListed = (lane) => (lane.includes(':') ? [...mailboxOpen.SEAT_LANE_KINDS, mailboxOpen.SEAT_EPIC_KIND].includes(lane.slice(0, lane.indexOf(':'))) : mailboxOpen.SEAT_BARE_LANES.includes(lane)); - t('every fixture lane is a kind or bare word from the opener\'s lane-word lists — imported, not re-spelled', [...new Set(rows.map((r) => r.lane))].map((lane) => `${lane}:${laneListed(lane)}`), ['repo:hotcrm:true', 'domain:devx:true', 'domain:skills:true', 'epic:#26676:true', 'epic:#27293:true', 'epic:#28800:true', 'triage:true', 'domain:ui:true']); + t('every fixture lane is a kind or bare word from the opener\'s lane-word lists — imported, not re-spelled', [...new Set(rows.map((r) => r.lane))].map((lane) => `${lane}:${laneListed(lane)}`), ['repo:hotcrm:true', 'domain:devx:true', 'domain:skills:true', 'epic:#26676:true', 'epic:#27293:true', 'epic:#28800:true', 'review:true', 'triage:true', 'domain:ui:true']); const os = (number) => ({ repo: 'objectstack-ai/objectstack', number }); const ep = (post) => (({ lane, seat, status, mark, account, mailbox, suspectedWall, inFlight }) => ({ lane, seat, status, mark, account, mailbox, suspectedWall, inFlight }))(row(post)); t('⭐ an epic parent is a row: lane epic:#n, the seat its open mailbox names, seated by that login, the mailbox in its column — and, stale with no in-flight read, never 疑似撞墙', ep(27293), { lane: 'epic:#27293', seat: 1, status: 'seated', mark: '🟢', account: 'sunhaolin', mailbox: [os(25397)], suspectedWall: false, inFlight: [] }); @@ -779,6 +820,11 @@ async function selfTest() { 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']], [[], []]]); t('stale and no wall note ⇒ 疑似撞墙; a wall note silences it; vacant never', [row(20163).suspectedWall, row(5560, 'objectstack-ai/objectui').suspectedWall, row(5560, 'objectstack-ai/objectui').wallNote, row(7623).suspectedWall], [true, false, 'limit reached, resets at 15:00Z', false]); t('last activity is the newest of post and in-flight cards', row(6023).lastActivity, '2026-10-23T13:28:00Z'); + const rv = (({ lane, seat, status, account, sharesWith, inFlight, awaitingLanding, awaitingRuling, mailbox, lastActivity, suspectedWall }) => ({ lane, seat, status, account, sharesWith, inFlight, awaitingLanding, awaitingRuling, mailbox, lastActivity, suspectedWall }))(row(25500)); + t('⭐ a review seat is a row: lane `review`, seated by its login, never sharing; in flight = the open pulls whose current head its live Contract-review-claim holds, its mailbox in its column, and its last activity the claim comment', rv, { lane: REVIEW_LANE, seat: 1, status: 'seated', account: 'os-zhuang', sharesWith: null, inFlight: [os(25433)], awaitingLanding: [], awaitingRuling: [], mailbox: [os(25434)], lastActivity: '2026-10-23T13:30:00Z', suspectedWall: false }); + t('⛔ of the fixture\'s three review-signed claims only the live one on the current head counts: the expired one (2 h) and the one on a moved head hold nothing', [fixture.repos['objectstack-ai/objectstack'].recentComments.filter((c) => /^review#/.test(c.body)).length, [...reviewClaims(fixture, { now: fixture.now }).entries()].map(([k, v]) => `${k}=${v.map((r) => r.number)}`)], [3, ['review#1=25433']]); + t('⛔ a claim signed by a lane seat, or by nobody, is no review in-flight; a review seat holds no dispatched card of the lanes', [reviewClaims({ repos: { r: { pulls: [{ number: 1, state: 'open', head: { sha: 'a'.repeat(40) } }], recentComments: [{ issue_url: 'x/issues/1', created_at: fixture.now, body: `domain:ui#1 · a · session_01Abcdef\n\nContract-review-claim: session_01Abcdef · head ${'a'.repeat(40)} · ${fixture.now}` }, { issue_url: 'x/issues/1', created_at: fixture.now, body: `Contract-review-claim: session_01Abcdef · head ${'a'.repeat(40)} · ${fixture.now}` }] } } }, { now: fixture.now }).size, rows.filter((r) => r.lane === REVIEW_LANE && r.inFlight.some((p) => fixture.repos['objectstack-ai/objectstack'].dispatched.some((d) => d.number === p.number))).length], [0, 0]); + t('the review lane word is one of the opener\'s bare lanes — imported, not a third spelling', mailboxOpen.SEAT_BARE_LANES.includes(REVIEW_LANE), true); t('one account on two seats of a lane: cards on the lowest seat, the other reads = seat 1', [row(5560, 'objectstack-ai/objectui').inFlight.length, row(10243, 'objectstack-ai/objectui').sharesWith, row(10243, 'objectstack-ai/objectui').inFlight, row(10243, 'objectstack-ai/objectui').suspectedWall], [1, 1, [], true]); battery('one account on two seats of a lane: the second seat reads its own traces'); @@ -839,6 +885,7 @@ async function selfTest() { 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('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')]; diff --git a/scripts/pm/mailbox-open.mjs b/scripts/pm/mailbox-open.mjs index 94b5f03614f..560327c1c6c 100644 --- a/scripts/pm/mailbox-open.mjs +++ b/scripts/pm/mailbox-open.mjs @@ -42,8 +42,8 @@ * a login as a seat any more. * * A seat is spelled `LANE#N` — `domain:ui#4`, `repo:cloud#1`, `triage`, - * `epic:#27293#1` — its lane one of the five a seat post's title names - * (`domain:`, `repo:`, `triage`, `director`, `security`) or the epic lane + * `epic:#27293#1` — its lane one of the six a seat post's title names + * (`domain:`, `repo:`, `triage`, `director`, `security`, `review`) or the epic lane * `epic:#`, the seat signature's spelling for the PM delegated a * `pm:epic` parent card (pm-dispatch `seat-lifecycle.md` 〈Epic 子树车道〉), so a * login never reads as a seat, and an absent number read as seat 1 @@ -51,8 +51,8 @@ * number (`triage#1`, `epic:#12#1`). Its SLUG is that key as one ref segment, * because a ref cannot carry `:` or `#`: the default `domain:` kind dropped, * every other `:` (with the epic lane's `#` after it) and the seat's `#` turned - * into `-` (`ui-4`, `repo-cloud-1`, `triage-1`, `security-1`, `epic-27293-1`). No `domain:` - * lane may be named `triage`, `director`, `security`, `repo-…` or `epic-…` — `seatKey` + * into `-` (`ui-4`, `repo-cloud-1`, `triage-1`, `security-1`, `review-1`, `epic-27293-1`). No `domain:` + * lane may be named `triage`, `director`, `security`, `review`, `repo-…` or `epic-…` — `seatKey` * refuses one — which keeps the slug one-to-one. * * ## What it does, in order (open) @@ -163,17 +163,17 @@ const REPO_SHAPE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; const LOGIN_SHAPE = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/; const BRANCH_SHAPE = /^(?!\/|.*\/\/|.*\.\.|.*[\s~^:?*[\\]|.*\.lock$|.*\/$)[A-Za-z0-9._/-]+$/; const SESSION_SHAPE = /^(?:session|cse)_[A-Za-z0-9]+$/; -// A seat: its lane — `domain:`, `repo:`, `triage`, `director`, `security`, the five a seat post's title names, -// or the epic lane `epic:#` — then `#N`; absent = seat 1. Read lower-cased. The bare lanes are a closed set of -// three, so a login is never read as a seat. The lists below are the ONE spelling of those lane words: the shape is -// built from them, and so is the reserved set a `domain:` lane may not be named after (`seatKey`). The same three -// words close the seat signature's lane alternation (`SIGNATURE_LANE_SOURCE`, check-half-states.mjs), which this +// A seat: its lane — `domain:`, `repo:`, `triage`, `director`, `security`, `review`, the six a seat post's title +// names, or the epic lane `epic:#` — then `#N`; absent = seat 1. Read lower-cased. The bare lanes are a closed +// set of four, so a login is never read as a seat. The lists below are the ONE spelling of those lane words: the shape is +// built from them, and so is the reserved set a `domain:` lane may not be named after (`seatKey`). The same +// four words close the seat signature's lane alternation (`SIGNATURE_LANE_SOURCE`, check-half-states.mjs), which this // list cannot import (the patrol loads this file lazily, so that edge would be a cycle); fleet-roster's self-test, // which imports both, holds the two equal, `coordinator` (it signs and has no mailbox) aside. /** The lane kinds that carry a name after the colon: `domain:`, `repo:`. `domain` is the default kind the slug drops. */ export const SEAT_LANE_KINDS = Object.freeze(['domain', 'repo']); -/** The bare lanes, a closed set: `triage`, `director`, `security` (the seat that owns security findings, `lanes/security.md`). */ -export const SEAT_BARE_LANES = Object.freeze(['triage', 'director', 'security']); +/** The bare lanes, a closed set: `triage`, `director`, `security` (the seat that owns security findings, `lanes/security.md`), `review` (the contract-review seat, `lanes/review.md`). */ +export const SEAT_BARE_LANES = Object.freeze(['triage', 'director', 'security', 'review']); /** The epic lane's kind: `epic:#` names the `pm:epic` parent card's number, never a word (`epic:#27293#1`). */ export const SEAT_EPIC_KIND = 'epic'; const SEAT_SHAPE = new RegExp(`^((?:${SEAT_LANE_KINDS.join('|')}):[a-z0-9._-]+|${SEAT_EPIC_KIND}:#[1-9][0-9]*|${SEAT_BARE_LANES.join('|')})(?:#([1-9][0-9]*))?$`); @@ -542,6 +542,7 @@ async function selfTest() { t('domain:ui#4 → key domain:ui#4, slug ui-4 (the default kind dropped)', seatKey('domain:ui#4'), { key: 'domain:ui#4', slug: 'ui-4' }); t('an absent number is seat 1, spelled', [seatKey('triage'), seatKey('domain:skills')], [{ key: 'triage#1', slug: 'triage-1' }, { key: 'domain:skills#1', slug: 'skills-1' }]); t('⭐ the security seat is a bare lane: `security#1` → key security#1, slug security-1; `security` alone is seat 1; `security#2` is its own seat', [seatKey('security#1'), seatKey('security'), seatKey('security#2').slug], [{ key: 'security#1', slug: 'security-1' }, { key: 'security#1', slug: 'security-1' }, 'security-2']); + t('⭐ the review seat is a bare lane: `review#1` → key review#1, slug review-1; `review` alone is seat 1; `review#2` is its own seat, and a `Mailbox-of: review#2 · login` body reads back as it', [seatKey('review#1'), seatKey('review'), seatKey('review#2').slug, mailboxOwner({ head: { ref: 'claude/project-thread-r' }, body: 'Mailbox-of: review#2 · os-zhuang', labels: [{ name: MAILBOX_LABEL }] })], [{ key: 'review#1', slug: 'review-1' }, { key: 'review#1', slug: 'review-1' }, 'review-2', { seat: 'review#2', slug: 'review-2', login: 'os-zhuang' }]); t('another kind keeps its name, its colon turned into a hyphen', [seatKey('repo:cloud#1').slug, seatKey('repo:www.objectos.ai#2').slug], ['repo-cloud-1', 'repo-www.objectos.ai-2']); t('read lower-cased, so one seat has one key', [seatKey('Domain:UI#4').key, seatKey(' domain:ui#4 ').key], ['domain:ui#4', 'domain:ui#4']); t('not a seat ⇒ null — ⛔ a login above all, the confusion a per-login mailbox made', ['', 'ui 4', 'domain:ui#0', '#4', 'domain:', null, 'os-bill', 'marchtian'].map(seatKey), [null, null, null, null, null, null, null, null]); @@ -550,9 +551,9 @@ async function selfTest() { t('⛔ a `domain:` lane named `epic-…` is refused — its slug would be an epic seat\'s — while `domain:epic` and `domain:epics` read', [seatKey('domain:epic-12#1'), seatKey('domain:epic-tools'), seatKey('domain:epic').slug, seatKey('domain:epics').slug], [null, null, 'epic-1', 'epics-1']); t('⭐ the epic slug collides with no lane slug: one key per slug across every lane kind', (() => { const keys = ['epic:#12#1', 'domain:epic#12', 'domain:epic-12#1', 'repo:epic-12#1', 'domain:12#1', 'triage#12', 'epic:#1#21', 'epic:#12#1'].map(seatKey).filter(Boolean); return new Set(keys.map((k) => k.slug)).size === new Set(keys.map((k) => k.key)).size; })(), true); t('⛔ a short form is not a seat — `skills#2` names no lane, so it is refused as a login is', ['skills#2', 'ui#4', 'server', 'skills'].map(seatKey), [null, null, null, null]); - t('⛔ a `domain:` lane named after a reserved lane word is refused — its slug would be another seat\'s', ['domain:triage', 'domain:director#2', 'domain:repo-cloud#1', 'Domain:Triage#1', 'domain:security#1'].map(seatKey), [null, null, null, null, null]); + t('⛔ a `domain:` lane named after a reserved lane word is refused — its slug would be another seat\'s', ['domain:triage', 'domain:director#2', 'domain:repo-cloud#1', 'Domain:Triage#1', 'domain:security#1', 'domain:review#1'].map(seatKey), [null, null, null, null, null, null]); t('…while a `domain:` lane that merely CONTAINS a reserved word, and the reserved words as their own lanes, still read', [seatKey('domain:triage-tools#1').slug, seatKey('domain:directory').slug, seatKey('domain:repository').slug, seatKey('triage#2').slug, seatKey('repo:cloud').slug], ['triage-tools-1', 'directory-1', 'repository-1', 'triage-2', 'repo-cloud-1']); - t('the shape and the reserved set are built from the ONE set of lane-word lists', SEAT_LANE_KINDS.join() === 'domain,repo' && SEAT_BARE_LANES.join() === 'triage,director,security' && SEAT_EPIC_KIND === 'epic' && SEAT_BARE_LANES.every((w) => seatKey(w)?.key === `${w}#1` && seatKey(`domain:${w}`) === null) && seatKey(`${SEAT_EPIC_KIND}:#7`)?.key === 'epic:#7#1' && seatKey(`domain:${SEAT_EPIC_KIND}-7`) === null, true); + t('the shape and the reserved set are built from the ONE set of lane-word lists', SEAT_LANE_KINDS.join() === 'domain,repo' && SEAT_BARE_LANES.join() === 'triage,director,security,review' && SEAT_EPIC_KIND === 'epic' && SEAT_BARE_LANES.every((w) => seatKey(w)?.key === `${w}#1` && seatKey(`domain:${w}`) === null) && seatKey(`${SEAT_EPIC_KIND}:#7`)?.key === 'epic:#7#1' && seatKey(`domain:${SEAT_EPIC_KIND}-7`) === null, true); t('the branch is mailbox// — no `:` and no `#` in a ref', mailboxBranch('os-bill', seatKey('domain:ui#4')), 'mailbox/os-bill/ui-4'); t('an epic seat\'s branch is mailbox//epic-N-K', mailboxBranch('sunhaolin', seatKey('epic:#27293#1')), 'mailbox/sunhaolin/epic-27293-1'); t('⭐ two seats under ONE login get two branches', [mailboxBranch('os-sales', seatKey('domain:server#1')), mailboxBranch('os-sales', seatKey('domain:ui#1'))], ['mailbox/os-sales/server-1', 'mailbox/os-sales/ui-1']); diff --git a/scripts/pm/record-recognisers.mjs b/scripts/pm/record-recognisers.mjs index 0133b973ae2..9439cb3cb96 100644 --- a/scripts/pm/record-recognisers.mjs +++ b/scripts/pm/record-recognisers.mjs @@ -1028,15 +1028,18 @@ export function contractReviewClaimLapse(claim, { headSha, now, postedAt } = {}) * Who holds this pull request's current head, read off its comment thread: * * live a live claim names the current head -- `holder` is the claim to respect: the earliest - * posted, except that a legacy `triage#N` claim yields to any other live claim (`tie` true) + * posted, a review seat's and a lane seat's alike (the rule's 「先贴者持有」, which is what + * keeps two review seats off one head), except that a legacy `triage#N` claim yields to + * any other live claim (`tie` true) * lapsed claims named the current head, and every one has expired * none no claim names the current head (claims on earlier heads lapsed when it moved) * unreadable the thread, the head or the clock could not be read * * A claim's SIDE is read off its comment's signature line (`readSignatureLine`): a claim signed * `triage#N · …` -- posted before triage stopped taking contract reviews -- is the `triage` side, - * and every other claim -- a lane seat's, a review seat's, a sibling lane seat's, an unsigned one - * -- is the `lane` side, among which the earliest posted holds. Unreadable claim lines are + * one signed `review#N · …` is the `review` side (`lanes/review.md`), and every other claim -- a + * lane seat's, a sibling lane seat's, an unsigned one -- is the `lane` side. Between the review + * and lane sides there is no precedence: the earliest posted holds. Unreadable claim lines are * returned by comment id and hold nothing. * * @param {{ id?: number, body?: string, created_at?: string }[]} rows the PR's comment rows @@ -1061,13 +1064,14 @@ export function contractReviewClaims(rows, headSha, { now } = {}) { } const stamped = Date.parse(row?.created_at ?? ''); const postedAt = Number.isFinite(stamped) ? stamped : line.at; - const side = readSignatureLine(row?.body)?.lane === 'triage' ? 'triage' : 'lane'; + const signedLane = readSignatureLine(row?.body)?.lane ?? null; + const side = signedLane === 'triage' ? 'triage' : signedLane === 'review' ? 'review' : 'lane'; const lapse = contractReviewClaimLapse(line, { headSha: head, now, postedAt }); claims.push({ id: row?.id ?? null, session: line.session, head: line.head, time: line.time, postedAt, side, lapse }); } const live = claims.filter((c) => c.lapse === 'live').sort((a, b) => a.postedAt - b.postedAt); - const tie = live.some((c) => c.side === 'lane') && live.some((c) => c.side === 'triage'); - const holder = live.find((c) => c.side === 'lane') ?? live[0] ?? null; + const tie = live.some((c) => c.side !== 'triage') && live.some((c) => c.side === 'triage'); + const holder = live.find((c) => c.side !== 'triage') ?? live[0] ?? null; const state = holder !== null ? 'live' : claims.some((c) => c.lapse === 'expired') ? 'lapsed' : 'none'; return { state, holder, tie, claims, unreadable }; } @@ -2262,7 +2266,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ // Raised 41 → 42 with the `Notify: main-green` reading (the knock that resumes a PR held for a main red). '⭐ the mailbox doorbell: `To: `, then one key of the table read — seat, kind, numbers, sha, login, time — and anything else refused': 42, '⭐ the signature line: the reader reads LANE#N · ACCOUNT · SESSION exactly, and one leading signature changes no first-line reading': 22, - '⭐ the `Contract-review-claim:` line — session, FULL head, time; lapses on a moved head or after two hours; the earliest live claim holds': 21, + '⭐ the `Contract-review-claim:` line — session, FULL head, time; lapses on a moved head or after two hours; the earliest live claim holds': 24, '⭐ the `Contract-review-hold:` line — session, head, reason, until; a new head clears it; at 24 hours it reads overdue and still holds': 23, }); @@ -2746,6 +2750,13 @@ export async function selfTest() { t('⭐ a legacy `triage#N` claim YIELDS: a triage claim posted FIRST and a lane claim posted later, both live ⇒ the later claim holds, tie named', (() => { const r = contractReviewClaims([CL_ROW(CL_BODY(), 1, '2026-10-28T12:18:56Z'), CL_ROW(CL_BODY(CL_LANE_SIG, CL_LINE(CL_LANE_SESSION, CL_HEAD, '2026-10-28T12:20Z')), 2, '2026-10-28T12:20:10Z')], CL_HEAD, { now: CL_NOW }); return r.state === 'live' && r.tie === true && r.holder.side === 'lane' && r.holder.session === CL_LANE_SESSION; })()); t('two claims on one side: the EARLIEST posted holds, and no tie is declared', (() => { const r = contractReviewClaims([CL_ROW(CL_BODY(CL_LANE_SIG, CL_LINE(CL_LANE_SESSION)), 2, '2026-10-28T12:30:00Z'), CL_ROW(CL_BODY(null, CL_LINE(CL_SESSION)), 1, '2026-10-28T12:10:00Z')], CL_HEAD, { now: CL_NOW }); return r.holder.id === 1 && r.tie === false; })()); t('an UNSIGNED claim is the lane side — only a `triage#N` signature makes a claim triage\'s', contractReviewClaims([CL_ROW(CL_BODY(null))], CL_HEAD, { now: CL_NOW }).holder.side === 'lane'); + const CL_REVIEW_SESSION = 'session_01ReviewSeatFixture7Qx'; + const CL_REVIEW2_SESSION = 'session_01ReviewSeatTwoFixt9Zz'; + const CL_REVIEW_SIG = `review#1 · os-zhuang · ${CL_REVIEW_SESSION}`; + const CL_REVIEW2_SIG = `review#2 · os-tesla · ${CL_REVIEW2_SESSION}`; + t('⭐ a `review#N`-signed claim is the review side, and between a review seat and a lane seat the EARLIEST posted holds with no tie — the review seat first ⇒ the review seat holds', (() => { const r = contractReviewClaims([CL_ROW(CL_BODY(CL_REVIEW_SIG, CL_LINE(CL_REVIEW_SESSION)), 1, '2026-10-28T12:18:56Z'), CL_ROW(CL_BODY(CL_LANE_SIG, CL_LINE(CL_LANE_SESSION, CL_HEAD, '2026-10-28T12:20Z')), 2, '2026-10-28T12:20:10Z')], CL_HEAD, { now: CL_NOW }); return r.state === 'live' && r.holder.side === 'review' && r.holder.session === CL_REVIEW_SESSION && r.tie === false; })()); + t('…and the lane seat first ⇒ the lane seat holds: a review seat has no precedence over a lane seat, only over a legacy triage claim', (() => { const r = contractReviewClaims([CL_ROW(CL_BODY(CL_LANE_SIG, CL_LINE(CL_LANE_SESSION)), 1, '2026-10-28T12:18:56Z'), CL_ROW(CL_BODY(CL_REVIEW_SIG, CL_LINE(CL_REVIEW_SESSION, CL_HEAD, '2026-10-28T12:20Z')), 2, '2026-10-28T12:20:10Z'), CL_ROW(CL_BODY(), 3, '2026-10-28T12:10:00Z')], CL_HEAD, { now: CL_NOW }); return r.holder.side === 'lane' && r.holder.session === CL_LANE_SESSION && r.tie === true; })()); + t('⭐ two review seats on one head: the EARLIEST posted holds, the later withdraws — the rule the review seats share with every other pair, no tie', (() => { const r = contractReviewClaims([CL_ROW(CL_BODY(CL_REVIEW2_SIG, CL_LINE(CL_REVIEW2_SESSION, CL_HEAD, '2026-10-28T12:25Z')), 2, '2026-10-28T12:25:10Z'), CL_ROW(CL_BODY(CL_REVIEW_SIG, CL_LINE(CL_REVIEW_SESSION)), 1, '2026-10-28T12:18:56Z')], CL_HEAD, { now: CL_NOW }); return r.holder.id === 1 && r.holder.side === 'review' && r.tie === false && r.claims.every((c) => c.side === 'review'); })()); t('⭐ the head MOVED past every claim ⇒ none; every claim on this head expired ⇒ lapsed; nothing holds either way', (() => { const moved = contractReviewClaims([CL_ROW(CL_BODY())], 'a'.repeat(40), { now: CL_NOW }); const old = contractReviewClaims([CL_ROW(CL_BODY())], CL_HEAD, { now: Date.parse('2026-10-28T14:19:00Z') }); return moved.state === 'none' && moved.holder === null && old.state === 'lapsed' && old.holder === null && old.claims[0].lapse === 'expired'; })()); t('an unreadable claim line holds nothing and is returned by comment id', (() => { const r = contractReviewClaims([CL_ROW(`${CL_TRIAGE_SIG}\n${CL_LINE(CL_SESSION, CL_HEAD.slice(0, 8))}`, 77)], CL_HEAD, { now: CL_NOW }); return r.state === 'none' && r.unreadable.length === 1 && r.unreadable[0].id === 77; })()); t('a thread that is not an array, an abbreviated head, or no clock is a read that could not be made', contractReviewClaims(null, CL_HEAD, { now: CL_NOW }).state === 'unreadable' && contractReviewClaims([], CL_HEAD.slice(0, 12), { now: CL_NOW }).state === 'unreadable' && contractReviewClaims([], CL_HEAD, {}).state === 'unreadable' && contractReviewClaims([], CL_HEAD, { now: CL_NOW }).state === 'none');