diff --git a/.claude/skills/pm-dispatch/references/project-forms.md b/.claude/skills/pm-dispatch/references/project-forms.md index 99201a4ffbf..1e3655b9c43 100644 --- a/.claude/skills/pm-dispatch/references/project-forms.md +++ b/.claude/skills/pm-dispatch/references/project-forms.md @@ -33,7 +33,7 @@ ## 信箱 PR -- 每席一个 draft PR,键是席位 ⛔ 非账号:分支 `mailbox//`,标签 `pm:mailbox`+`pinned`。 +- 每席一个 draft PR,键是席位 ⛔ 非账号,epic 席 `epic:##` 同样开;标签 `pm:mailbox`+`pinned`。 - 正文首行 `Mailbox-of: <席位> · `;坐席即开(`mailbox-open.mjs`)并订阅,存根记 `Mailbox:`。 - 永不合并,不欠 CI,落地与台账查询排除它;移交或换账号关旧开新;评论首行 `To: <席位>`。 - 次行恒为一键,下列事件必敲;一事一行 ≤200 字节,只写键与编号;表外即半态。 diff --git a/scripts/pm/fleet-roster.fixture.json b/scripts/pm/fleet-roster.fixture.json index 7469d654808..5efbc261236 100644 --- a/scripts/pm/fleet-roster.fixture.json +++ b/scripts/pm/fleet-roster.fixture.json @@ -10,6 +10,13 @@ { "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": 999, "title": "Fleet seat registry", "body": "", "labels": [{ "name": "pm:seat" }], "assignees": [], "updated_at": "2026-10-01T00:00:00Z" } ], + "epics": [ + { "number": 27293, "title": "[Epic] Marketplace and connector auto-shelf", "body": "epic:#27293#1 · sunhaolin · session_017xRBcVBKAFk1ntXSjvPt7U\n\n## Epic PM registration", "labels": [{ "name": "pm:epic" }], "sub_issues_summary": { "total": 31 }, "updated_at": "2026-10-20T09:00:00Z" }, + { "number": 26676, "title": "epic: record page v2", "body": "Reserved for the epic PM session `session_cdb7a717-def7`.", "labels": [{ "name": "pm:epic" }], "sub_issues_summary": { "total": 20 }, "updated_at": "2026-10-22T09:00:00Z" }, + { "number": 28647, "title": "a child of #27293", "body": "epic:#27293#1 · sunhaolin · session_017xRBcVBKAFk1ntXSjvPt7U", "labels": [{ "name": "pm:epic" }], "sub_issues_summary": { "total": 0 }, "updated_at": "2026-10-23T09:00:00Z" }, + { "number": 28800, "title": "[Epic] delegated, no sub-issue filed yet", "body": "epic:#28800#2 · os-tesla · session_01FreshEpicPm", "labels": [{ "name": "pm:epic" }], "sub_issues_summary": { "total": 0 }, "updated_at": "2026-10-23T10:00:00Z" }, + { "number": 27108, "title": "[Epic] not yet delegated", "body": "Owner: undecided.", "labels": [{ "name": "pm:epic" }], "sub_issues_summary": { "total": 3 }, "updated_at": "2026-10-23T09:00:00Z" } + ], "dispatched": [ { "number": 25416, "labels": [{ "name": "pm:dispatched" }, { "name": "domain:devx" }, { "name": "tooling" }], "assignees": [{ "login": "os-elon-musk" }], "updated_at": "2026-10-23T13:28:00Z" }, { "number": 25300, "labels": [{ "name": "pm:dispatched" }, { "name": "domain:devx" }], "assignees": [{ "login": "os-sales" }], "updated_at": "2026-10-23T02:00:00Z" }, @@ -28,6 +35,10 @@ { "number": 25394, "state": "open", "head": { "ref": "mailbox/os-sales/devx-2" }, "body": "", "labels": [{ "name": "pm:mailbox" }] }, { "number": 25395, "state": "open", "head": { "ref": "claude/project-thread-eeee" }, "body": "To: os-sales\n\nThe mailbox of the seat held by `os-sales`", "labels": [{ "name": "pm:mailbox" }, { "name": "pinned" }] }, { "number": 25396, "state": "open", "head": { "ref": "claude/project-thread-ffff" }, "body": "Mailbox-of: domain:spec#5 · os-tesla", "labels": [{ "name": "pm:mailbox" }] }, + { "number": 25397, "state": "open", "head": { "ref": "mailbox/sunhaolin/epic-27293-1" }, "body": "Mailbox-of: epic:#27293#1 · sunhaolin", "labels": [{ "name": "pm:mailbox" }, { "name": "pinned" }] }, + { "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": [] } ], "comments": { @@ -40,6 +51,7 @@ { "number": 5560, "title": "[PM seat] domain:ui @ objectui — 🟢 os-sales · 由Sales账户的项目负责", "body": "## 1. Current PM\n\n- **Wall:** limit reached, resets at 15:00Z\n", "labels": [{ "name": "pm:seat" }], "assignees": [{ "login": "os-sales" }], "updated_at": "2026-10-22T18:06:56Z" }, { "number": 10243, "title": "[PM seat] domain:ui · seat 4 — 🟢 os-sales · session_01SJn1GE · ui lane", "body": "", "labels": [{ "name": "pm:seat" }], "assignees": [{ "login": "os-sales" }], "updated_at": "2026-10-17T16:05:27Z" } ], + "epics": [], "dispatched": [ { "number": 13330, "labels": [{ "name": "pm:dispatched" }, { "name": "domain:ui" }], "assignees": [{ "login": "os-sales" }], "updated_at": "2026-10-23T13:28:00Z" } ], @@ -52,6 +64,7 @@ { "number": 2180, "title": "[PM seat] repo:hotcrm · seat 2 — ⏳ vacant", "body": "", "labels": [{ "name": "pm:seat" }], "assignees": [], "updated_at": "2026-10-21T08:38:12Z" }, { "number": 1353, "title": "[PM seat] repo:hotcrm — 🟢 os-elon-musk · 由 os-elon-musk 账户的项目负责", "body": "", "labels": [{ "name": "pm:seat" }], "assignees": [{ "login": "os-elon-musk" }], "updated_at": "2026-10-22T14:39:07Z" } ], + "epics": [], "dispatched": [ { "number": 2253, "labels": [{ "name": "pm:dispatched" }], "assignees": [{ "login": "os-elon-musk" }], "updated_at": "2026-10-23T13:50:00Z" } ], diff --git a/scripts/pm/fleet-roster.mjs b/scripts/pm/fleet-roster.mjs index f9137f8ef83..13beaf2d070 100644 --- a/scripts/pm/fleet-roster.mjs +++ b/scripts/pm/fleet-roster.mjs @@ -27,6 +27,20 @@ * `director`, `triage`); seat = `· seat N` or `repo:x#N`, * else 1; repo = `repo:x`'s repo, else `@ `, else the * repo the post lives on. + * epic rows an open `pm:epic` PARENT — sub-issues under it AND a + * `session_…` registration in its body, the paired + * delegation signal (`seat-lifecycle.md` 〈Epic 子树车道〉; + * H45's `h45DelegationNodeSignals`, both halves) — or a + * registered `pm:epic` issue an open mailbox names (a + * parent delegated before its first sub-issue; a child is + * never a seat's mailbox) is lane `epic:#`, with no seat + * post: one row per `epic:##` seat holding an open + * mailbox, matched by its slug `epic--` as the mailbox + * column is (seat k, seated by the mailbox's login — opened + * at seating, closed at hand-over), else one row, seat 1, + * status unknown, so an epic seat nobody can ring shows as + * one. No epic in-flight or ruling is read, so an epic row + * is never 「疑似撞墙」. * account the login after 🟢 in the title, else the post's first * assignee while seated. `🟢 Routine` reads as Routine. * project 「由 X 账户的项目负责」 in the title or body — the line @@ -61,7 +75,7 @@ * them reads it cannot be told: it is listed under the * table as assigned to no seat, ⛔ never shown on the * login's rows. So is a per-seat one whose seat no post - * carries. + * or epic parent carries. * * The footer names the generation time and every read made, so a reader can * re-run each one. @@ -100,7 +114,7 @@ import { ghApi, PER_PAGE, readAllPages } from './gh-pages.mjs'; // whose mailbox a pull is have ONE owner, the mailbox opener (#25417, keyed by // seat since #27637); this file reads them, never re-spells them. import * as mailboxOpen from './mailbox-open.mjs'; -import { LEDGER_EXCLUSION, MAILBOX_BRANCH_PREFIX, MAILBOX_LABEL, mailboxOwner, seatKey } from './mailbox-open.mjs'; +import { LEDGER_EXCLUSION, MAILBOX_BRANCH_PREFIX, MAILBOX_LABEL, mailboxOwner, SEAT_EPIC_KIND, seatKey } from './mailbox-open.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'; @@ -241,6 +255,34 @@ export function hasLandingVerdict(comments) { }); } +/** + * Is this open `pm:epic` issue a delegated PARENT — sub-issues under it AND a `session_…` registration in its body? + * Both halves of H45's `h45DelegationNodeSignals` (check-half-states), which the self-test holds this equal to; it is + * not imported, so the relay (which imports `ROSTER_MARKER` from here) never loads the patrol. A child carries + * `pm:epic` and a signature's session too, never sub-issues of its own. Pure. + */ +export const isEpicParent = (i) => Number(i?.sub_issues_summary?.total ?? 0) > 0 && isEpicRegistered(i); +/** H45's registration half alone: a `session_…` id in the body. Never a row on its own — every seat-filed child has one. Pure. */ +const isEpicRegistered = (i) => /session_[A-Za-z0-9]{4,}/.test(String(i?.body ?? '')); +/** + * The second condition for an epic row: a registered `pm:epic` issue that an open mailbox already names — a parent + * delegated before its first sub-issue, whose PM opened `epic:##` at seating. A child never has a mailbox named + * after itself. `held` is that issue's seat → login map (`epicSeatsHeld`). Pure. + */ +export const isSeatedFreshEpic = (i, held) => held.size > 0 && isEpicRegistered(i); +/** + * Epic `lane`'s seats holding an open mailbox, seat → login, matched by `seatKey`'s slug `epic--` exactly as the + * mailbox column matches — a `Mailbox-of:` line or a head-only `mailbox//epic--` alike. Pure. + */ +export function epicSeatsHeld(lane, mailboxes) { + const stem = seatKey(lane).slug.replace(/1$/, ''); + return new Map(mailboxes.flatMap((m) => { + const k = m.slug?.startsWith(stem) ? m.slug.slice(stem.length) : null; + return k !== null && seatKey(`${lane}#${k}`)?.slug === m.slug ? [[Number(k), m.login]] : []; + })); +} +const isEpicLane = (lane) => lane.startsWith(`${SEAT_EPIC_KIND}:`); + /** A seat row's mailbox slug — its `LANE#N` as the opener spells it in a ref — or null. Pure. */ const seatSlugOf = (row) => seatKey(`${row.lane}#${row.seat}`)?.slug ?? null; @@ -264,7 +306,7 @@ export function unassignedMailboxes(snapshot, rows) { const read = new Set(rows.map(seatSlugOf).filter(Boolean)); return readMailboxes(snapshot) .filter((m) => m.slug === null || !read.has(m.slug)) - .map((m) => ({ ...m, why: m.slug === null ? 'legacy per-login, names no seat' : 'no seat post carries this seat' })); + .map((m) => ({ ...m, why: m.slug === null ? 'legacy per-login, names no seat' : 'no seat post or epic parent carries this seat' })); } const cardLaneMatch = (card, row) => { @@ -287,6 +329,7 @@ export function buildRows(snapshot, { now }) { const cards = []; const rulings = []; const pulls = []; + const mailboxes = readMailboxes(snapshot); for (const repo of repos) { const s = snapshot.repos[repo] ?? {}; for (const post of s.seats ?? []) { @@ -294,6 +337,16 @@ export function buildRows(snapshot, { now }) { const seat = parseSeat(post, repo); if (seat) rows.push(seat); } + for (const epic of s.epics ?? []) { + if (epic.pull_request || epic.state === 'closed' || !labelNames(epic).includes('pm:epic')) continue; + const lane = `${SEAT_EPIC_KIND}:#${epic.number}`; + // Its seats are the ones holding a mailbox; none open ⇒ seat 1, unknown. + const held = epicSeatsHeld(lane, mailboxes); + if (!isEpicParent(epic) && !isSeatedFreshEpic(epic, held)) continue; + for (const [seat, login] of held.size ? held : new Map([[1, null]])) { + rows.push({ board: repo, post: epic.number, name: lane, lane, seat, repo, status: login ? 'seated' : 'unknown', mark: login ? '🟢' : null, account: login, routine: false, project: null, wallNote: null, updatedAt: epic.updated_at ?? null }); + } + } const asCard = (i) => ({ repo, number: i.number, labels: labelNames(i), assignees: assigneeLogins(i).map(lc), updatedAt: i.updated_at ?? null }); for (const i of s.dispatched ?? []) if (!i.pull_request && i.state !== 'closed') cards.push(asCard(i)); for (const i of s.rulings ?? []) if (!i.pull_request && i.state !== 'closed') rulings.push(asCard(i)); @@ -307,7 +360,6 @@ 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 mailboxes = readMailboxes(snapshot); const out = rows.map((row) => { const acct = lc(row.account); @@ -326,7 +378,7 @@ export function buildRows(snapshot, { now }) { awaitingRuling: [], awaitingLanding: awaitingLanding.sort(byRef), lastActivity, - suspectedWall: row.status === 'seated' && row.wallNote === null && ageHours !== null && ageHours > STALE_HOURS, + suspectedWall: row.status === 'seated' && !isEpicLane(row.lane) && row.wallNote === null && ageHours !== null && ageHours > STALE_HOURS, // Per seat, never per account: one account holds several seats, and each seat opens its own. mailbox: mailboxes.filter((m) => m.slug !== null && m.slug === seatSlugOf(row)).map((m) => m.ref), }; @@ -358,6 +410,7 @@ function age(iso, now) { export function readsFor(repos) { return repos.flatMap((r) => [ `GET /repos/${r}/issues?state=open&labels=pm:seat`, + `GET /repos/${r}/issues?state=open&labels=pm:epic`, `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`, @@ -388,7 +441,7 @@ export function renderBody(rows, { now, repos, session = null, unassigned = [] } const seated = rows.filter((r) => r.status === 'seated'); lines.push( '', - `${rows.length} seat posts · ${seated.length} seated · ${rows.filter((r) => r.suspectedWall).length} ${SUSPECTED_WALL} (seated, no \`Wall:\` line, last activity older than ${STALE_HOURS} h).`, + `${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).`, ...(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(' · ')}.`] : []), @@ -440,6 +493,7 @@ function readLive(repos) { for (const repo of repos) { const s = { seats: need(`/repos/${repo}/issues?state=open&labels=${encodeURIComponent('pm:seat')}&per_page=100`), + epics: need(`/repos/${repo}/issues?state=open&labels=${encodeURIComponent('pm:epic')}&per_page=100`), dispatched: need(`/repos/${repo}/issues?state=open&labels=${encodeURIComponent('pm:dispatched')}&per_page=100`), rulings: need(`/repos/${repo}/issues?state=open&labels=needs-user-decision&per_page=100`), pulls: need(`/repos/${repo}/pulls?state=open&per_page=100`), @@ -551,8 +605,8 @@ 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': 18, - 'the rendered body: marker, columns, footer, determinism': 9, + 'the fixture snapshot: rows for the three repos': 25, + 'the rendered body: marker, columns, footer, determinism': 10, '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, @@ -642,12 +696,25 @@ async function selfTest() { 'objectstack:domain:devx:2', 'objectstack:domain:skills:1', 'objectstack:domain:skills:2', + 'objectstack:epic:#26676:1', + 'objectstack:epic:#27293:1', + 'objectstack:epic:#27293:2', + 'objectstack:epic:#28800:2', 'objectstack:triage:1', 'objectui:domain:ui:1', 'objectui:domain:ui:4', ]); - const laneListed = (lane) => (lane.includes(':') ? mailboxOpen.SEAT_LANE_KINDS.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', 'triage:true', 'domain:ui:true']); + 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']); + 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: [] }); + t('⭐ an epic parent with no mailbox open is one row, seat 1, status unknown — an epic seat nobody can ring shows as one', ep(26676), { lane: 'epic:#26676', seat: 1, status: 'unknown', mark: null, account: null, mailbox: [], suspectedWall: false, inFlight: [] }); + t('⛔ a pm:epic child (a session, no sub-issues, no mailbox named after it) and a parent with no session registration are no row', rows.filter((r) => r.post === 28647 || r.post === 27108).length, 0); + t('⭐ a head-only epic mailbox (no `Mailbox-of:` line) seats its row by slug, as the mailbox column reads it', (({ post, seat, status, account, mailbox }) => ({ post, seat, status, account, mailbox }))(rows.find((r) => r.post === 27293 && r.seat === 2)), { post: 27293, seat: 2, status: 'seated', account: 'os-bill', mailbox: [os(25400)] }); + t('⭐ a parent delegated before its first sub-issue is a row once its PM opened a mailbox — seated, never flagged as on no seat row', [ep(28800), unassignedMailboxes(fixture, rows).some((m) => m.ref.number === 25399)], [{ lane: 'epic:#28800', seat: 2, status: 'seated', mark: '🟢', account: 'os-tesla', mailbox: [os(25399)], suspectedWall: false, inFlight: [] }, false]); + t('the two conditions apart: registration + a held seat is a row without sub-issues; registration alone, or a held seat with no registration, is not', [isSeatedFreshEpic({ body: 'session_01Abcd' }, new Map([[1, 'a']])), isSeatedFreshEpic({ body: 'session_01Abcd' }, new Map()), isSeatedFreshEpic({ body: 'no id' }, new Map([[1, 'a']])), [...epicSeatsHeld('epic:#27293', readMailboxes(fixture)).keys()]], [true, false, false, [1, 2]]); + t('isEpicParent is both halves of H45\'s delegation signals, over every fixture epic and a bare card', await (async () => { const { h45DelegationNodeSignals } = await import('./check-half-states.mjs'); return [...fixture.repos['objectstack-ai/objectstack'].epics, {}].map((i) => { const h = h45DelegationNodeSignals(i); return isEpicParent(i) === (h.subIssues && h.registration); }); })(), [true, true, true, true, true, true]); t('the non-seat issue in the seat list is skipped', rows.some((r) => r.post === 999), false); t('today’s three seats carry the right accounts', [row(19287).account, row(6023).account, row(1353, 'objectstack-ai/hotcrm').account], ['os-elon-musk', 'os-elon-musk', 'os-elon-musk']); t('devx seat 1: its assigned dispatched card, not seat 2’s', row(6023).inFlight, [{ repo: 'objectstack-ai/objectstack', number: 25416 }]); @@ -658,10 +725,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 }]); - const os = (number) => ({ repo: 'objectstack-ai/objectstack', number }); 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 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 carries this seat']], [[], []]]); + 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'); 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]); @@ -676,8 +742,9 @@ async function selfTest() { t('a shared seat reads = seat N in the in-flight cell', body.includes('| = seat 1 |'), 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('⚠️ 2 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 carries this seat)'), body.includes('on no seat row')]; + 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')]; })(), [true, true, true, false]); + t('an epic row renders its lane, its parent card as the seat link, its seat holder or `? —`, and the summary counts it', [body.includes('| objectstack | epic:#27293 | [1](https://github.com/objectstack-ai/objectstack/issues/27293) | 🟢 `sunhaolin` |'), body.includes('| objectstack | epic:#26676 | [1](https://github.com/objectstack-ai/objectstack/issues/26676) | ? — |'), body.includes(`${rows.length} seat rows (4 epic) ·`)], [true, true, true]); t('the attribution footer closes the body', body.endsWith('\n\n---\n_Generated by [Claude Code](https://claude.ai/code/session_01ABC)_'), true); const shuffled = { ...fixture, repos: Object.fromEntries(Object.entries(fixture.repos).reverse().map(([k, v]) => [k, { ...v, seats: [...v.seats].reverse() }])) }; t('the same facts in another read order render the same body', renderBody(buildRows(shuffled, { now: fixture.now }), { now: fixture.now, repos: Object.keys(fixture.repos).sort() }), renderBody(rows, { now: fixture.now, repos: Object.keys(fixture.repos).sort() })); @@ -720,7 +787,7 @@ async function selfTest() { } catch {} t('--fixture --json exits 0 with no gh on PATH (nothing reached for)', jsonRun.status, EXIT_OK); t('the printed rows are the built rows', parsed?.rows?.length, rows.length); - t('the printed JSON carries the mailboxes on no seat row', parsed?.unassignedMailboxes?.map((m) => m.ref.number), [25395, 25396]); + t('the printed JSON carries the mailboxes on no seat row', parsed?.unassignedMailboxes?.map((m) => m.ref.number), [25395, 25396, 25398]); t('--fixture prints the markdown table', cli(['--fixture', FIXTURE_PATH]).stdout.split('\n')[0], ROSTER_MARKER); t('a usage error is exit 2', cli(['--write', 'x']).status, EXIT_USAGE); t('a missing fixture is exit 3', cli(['--fixture', join(PM_DIR, 'no-such-fixture.json')]).status, EXIT_PREREQUISITE); diff --git a/scripts/pm/mailbox-open.mjs b/scripts/pm/mailbox-open.mjs index 6c8decc480e..3f1eee43db3 100644 --- a/scripts/pm/mailbox-open.mjs +++ b/scripts/pm/mailbox-open.mjs @@ -18,7 +18,8 @@ * nothing. So each SEAT — a lane seat, the triage seat, the director seat — * keeps ONE draft PR as its mailbox: labelled `pm:mailbox` and `pinned`, on * the branch `mailbox//` (`mailbox/os-bill/ui-4` for the - * seat `domain:ui#4` held by `os-bill`), its body's first line + * seat `domain:ui#4` held by `os-bill`; an epic seat opens one like any seat), + * its body's first line * `Mailbox-of: · `, opened at seating and subscribed by the * seated session, ⛔ never merged, closed at hand-over or account switch (the * successor opens its own). A message on it is ONE comment: the line @@ -40,15 +41,19 @@ * was a per-seat one; the board falsified that premise, and nothing here reads * a login as a seat any more. * - * A seat is spelled `LANE#N` — `domain:ui#4`, `repo:cloud#1`, `triage` — its - * lane one of the four a seat post's title names (`domain:`, `repo:`, - * `triage`, `director`), so a login never reads as a seat, and an absent - * number read as seat 1 (`seatKey`). It is compared lower-cased and written - * canonically with its number (`triage#1`). Its SLUG is that key as one ref - * segment, because a ref cannot carry `:` or `#`: the default `domain:` kind - * dropped, every other `:` and the `#` turned into `-` (`ui-4`, - * `repo-cloud-1`, `triage-1`). No `domain:` lane may be named `triage`, - * `director` or `repo-…` — `seatKey` refuses one — which keeps the slug one-to-one. + * A seat is spelled `LANE#N` — `domain:ui#4`, `repo:cloud#1`, `triage`, + * `epic:#27293#1` — its lane one of the four a seat post's title names + * (`domain:`, `repo:`, `triage`, `director`) 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 + * (`seatKey`). It is compared lower-cased and written canonically with its + * 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`, `epic-27293-1`). No `domain:` + * lane may be named `triage`, `director`, `repo-…` or `epic-…` — `seatKey` + * refuses one — which keeps the slug one-to-one. * * ## What it does, in order (open) * @@ -158,19 +163,21 @@ 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`, the four a seat post's title names — then `#N`; -// absent = seat 1. Read lower-cased. The bare lanes are a closed pair, so a login is never read as a seat. The -// two 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`). +// A seat: its lane — `domain:`, `repo:`, `triage`, `director`, the four 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 pair, 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 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 pair: `triage`, `director`. */ export const SEAT_BARE_LANES = Object.freeze(['triage', 'director']); -const SEAT_SHAPE = new RegExp(`^((?:${SEAT_LANE_KINDS.join('|')}):[a-z0-9._-]+|${SEAT_BARE_LANES.join('|')})(?:#([1-9][0-9]*))?$`); +/** 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]*))?$`); // What a `domain:` lane may NOT be named, so the slug stays one-to-one (header): a bare lane word (`domain:triage` // would slug to `triage-1`, the triage seat's), or another kind's name with its colon turned into the hyphen the slug -// uses (`domain:repo-cloud` would slug to `repo-cloud-1`, `repo:cloud#1`'s). -const RESERVED_DOMAIN_LANE = new RegExp(`^(?:(?:${SEAT_BARE_LANES.join('|')})$|(?:${SEAT_LANE_KINDS.filter((k) => k !== 'domain').join('|')})-)`); +// uses (`domain:repo-cloud` would slug to `repo-cloud-1`, `repo:cloud#1`'s; `domain:epic-12` to `epic-12-1`, `epic:#12#1`'s). +const RESERVED_DOMAIN_LANE = new RegExp(`^(?:(?:${SEAT_BARE_LANES.join('|')})$|(?:${[...SEAT_LANE_KINDS.filter((k) => k !== 'domain'), SEAT_EPIC_KIND].join('|')})-)`); /** A per-seat mailbox body's first line, `Mailbox-of: · `. */ const MAILBOX_OF_LINE = /^Mailbox-of:[ \t]*(\S+)[ \t]*·[ \t]*(\S+)$/; /** The first lines a legacy per-login mailbox opened with: `To: ` (this file's), `Mailbox-of: `. */ @@ -188,7 +195,7 @@ export function seatKey(text) { if (!m) return null; if (m[1].startsWith('domain:') && RESERVED_DOMAIN_LANE.test(m[1].slice('domain:'.length))) return null; const n = m[2] ?? '1'; - return { key: `${m[1]}#${n}`, slug: `${m[1].replace(/^domain:/, '').replace(/:/g, '-')}-${n}` }; + return { key: `${m[1]}#${n}`, slug: `${m[1].replace(/^domain:/, '').replace(/:#?/g, '-')}-${n}` }; } /** `mailbox//`; `seat` is a `seatKey` reading. */ @@ -235,7 +242,7 @@ export function parseArgs(argv) { } else { const seat = opts.seat === null ? null : seatKey(opts.seat); if (!opts.seat) opts.errors.push('--seat is required to open a mailbox — the mailbox is the seat\'s, never the login\'s'); - else if (!seat) opts.errors.push(`--seat ${JSON.stringify(opts.seat)} is not a seat — spell it LANE#N, e.g. domain:ui#4`); + else if (!seat) opts.errors.push(`--seat ${JSON.stringify(opts.seat)} is not a seat — spell it LANE#N, e.g. domain:ui#4 or epic:#27293#1`); if (!opts.login) opts.errors.push('--login is required to open a mailbox'); else if (!LOGIN_SHAPE.test(opts.login)) opts.errors.push(`--login ${JSON.stringify(opts.login)} is not a GitHub login`); else if (seat && opts.head === null && !BRANCH_SHAPE.test(mailboxBranch(opts.login, seat))) opts.errors.push(`the seat's branch ${JSON.stringify(mailboxBranch(opts.login, seat))} is not a legal ref`); @@ -480,15 +487,15 @@ export async function main(argv, { env = process.env } = {}) { let selfTestReachedVerdict = false; const SELF_TEST_BATTERIES = Object.freeze({ - 'arguments: the two modes and every refusal': 13, - 'the seat: canonical key, ref slug, and the per-seat branch': 7, - 'the open plan: branch, labels, body, and the relay validator': 10, + 'arguments: the two modes and every refusal': 14, + 'the seat: canonical key, ref slug, and the per-seat branch': 11, + 'the open plan: branch, labels, body, and the relay validator': 11, 'the close plan: comment then close, judged by the validator': 3, - 'whose mailbox a pull is: the seat on the body line or the head, a legacy login, neither': 7, - "finding a seat's open mailbox: by seat, never by login": 9, + 'whose mailbox a pull is: the seat on the body line or the head, a legacy login, neither': 8, + "finding a seat's open mailbox: by seat, never by login": 10, 'refusing to close what is not an open mailbox': 4, 'the ledger exclusion and the footer': 4, - 'the CLI: dry run prints the actions file and sends nothing': 6, + 'the CLI: dry run prints the actions file and sends nothing': 7, 'paging: a three-page read through the loop equals the slurped array': 4, }); const SELF_TEST_BATTERY_FLOOR = 10; @@ -518,7 +525,8 @@ async function selfTest() { t('no --repo is refused', parseArgs(['--seat', 'triage', '--login', 'a']).errors.some((e) => e.includes('--repo')), true); t('a repo not shaped owner/name is refused', parseArgs(['--repo', 'nope', '--seat', 'triage', '--login', 'a']).errors.some((e) => e.includes('owner/name')), true); t('⭐ open without --seat is refused, naming why — the mailbox is the seat\'s, never the login\'s', parseArgs(['--repo', 'o/r', '--login', 'os-bill']).errors.some((e) => e.includes('--seat is required') && e.includes('never the login')), true); - t('a --seat that is not LANE#N is refused, a login among them', ['ui 4', 'domain:ui#0', '#4', 'domain:', 'domain:ui#4#5', 'os-bill', 'marchtian'].every((s) => parseArgs(['--repo', 'o/r', '--seat', s, '--login', 'a']).errors.some((e) => e.includes('is not a seat'))), true); + t('a --seat that is not LANE#N is refused, a login and a malformed epic among them', ['ui 4', 'domain:ui#0', '#4', 'domain:', 'domain:ui#4#5', 'os-bill', 'marchtian', 'epic:27293', 'epic:#27293#0'].every((s) => parseArgs(['--repo', 'o/r', '--seat', s, '--login', 'a']).errors.some((e) => e.includes('is not a seat'))), true); + t('⭐ an epic seat opens like any seat: --seat epic:#N#K parses, its branch a legal ref', parseArgs(['--repo', 'o/r', '--seat', 'epic:#27293#1', '--login', 'sunhaolin']).errors, []); t('open without --login is refused', parseArgs(['--repo', 'o/r', '--seat', 'triage']).errors.some((e) => e.includes('--login')), true); t('a login GitHub would refuse is refused', parseArgs(['--repo', 'o/r', '--seat', 'triage', '--login', '-bad--']).errors.some((e) => e.includes('not a GitHub login')), true); t('a seat whose branch is no legal ref is refused', parseArgs(['--repo', 'o/r', '--seat', 'repo:a..b#1', '--login', 'a']).errors.some((e) => e.includes('not a legal ref')), true); @@ -532,12 +540,17 @@ async function selfTest() { 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('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', 'epic:#12'].map(seatKey), [null, null, null, null, null, null, null, null, null]); + 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]); + t('⭐ an epic seat reads: `epic:#N#K` keeps its spelling, slugs to epic-N-K; `epic:#N` is seat 1; read lower-cased', [seatKey('epic:#27293#1'), seatKey('epic:#12'), seatKey(' Epic:#12#2 ').key], [{ key: 'epic:#27293#1', slug: 'epic-27293-1' }, { key: 'epic:#12#1', slug: 'epic-12-1' }, 'epic:#12#2']); + t('⛔ a malformed epic spelling is not a seat — no `#`, no number, a zero or leading zero, a word, a third number', ['epic', 'epic:', 'epic:#', 'epic:12', 'epic#12', 'epic:#0', 'epic:#012', 'epic:#12#0', 'epic:#12#', 'epic:#12#1#2', 'epic:#abc', 'epic:#12-1', 'epic:skills#1'].map(seatKey).filter((k) => k !== null), []); + 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'].map(seatKey), [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 pair of lane-word lists', SEAT_LANE_KINDS.join() === 'domain,repo' && SEAT_BARE_LANES.join() === 'triage,director' && SEAT_BARE_LANES.every((w) => seatKey(w)?.key === `${w}#1` && seatKey(`domain:${w}`) === 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' && 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']); battery('the open plan: branch, labels, body, and the relay validator'); @@ -553,6 +566,8 @@ async function selfTest() { const ph = planOpen({ repo: 'o/r', seat: 'triage', login: 'os-bill', head: 'claude/project-thread-x' }); t('--head names the branch and skips the push; the body still names the seat', [ph.branch, ph.push, ph.actions[0].head, ph.actions[0].body.split('\n')[0]], ['claude/project-thread-x', false, 'claude/project-thread-x', 'Mailbox-of: triage#1 · os-bill']); t('no draft key is sent — the relay forces draft itself', 'draft' in p.actions[0], false); + const pe = planOpen({ repo: 'objectstack-ai/objectstack', seat: 'Epic:#27293#1', login: 'sunhaolin' }); + t('⭐ an epic seat\'s plan: its branch, its Mailbox-of line, its To: line, and the validator accepts it', [pe.branch, pe.actions[0].title, pe.actions[0].body.split('\n')[0], pe.actions[0].body.includes('the line `To: epic:#27293#1`'), pe.errors], ['mailbox/sunhaolin/epic-27293-1', 'pm:mailbox — epic:#27293#1 · sunhaolin', 'Mailbox-of: epic:#27293#1 · sunhaolin', true, []]); battery('the close plan: comment then close, judged by the validator'); const c = planClose({ repo: 'o/r', pull: 25392 }); @@ -566,6 +581,7 @@ async function selfTest() { t('a head mailbox// with no body line reads the slug and the login', mailboxOwner(pr({ head: { ref: 'mailbox/os-bill/ui-4' } })), { seat: null, slug: 'ui-4', login: 'os-bill' }); t('the body outranks the head', mailboxOwner(pr({ head: { ref: 'mailbox/os-bill/ui-4' }, body: 'Mailbox-of: domain:ui#2 · os-bill' })).slug, 'ui-2'); t('⛔ a legacy per-login mailbox names a login and NO seat — head mailbox/, a To: or a Mailbox-of: first line', [mailboxOwner(pr({ head: { ref: 'mailbox/os-justin' } })), mailboxOwner(pr({ head: { ref: 'claude/x' }, body: 'To: os-sales\n\nThe mailbox' })), mailboxOwner(pr({ body: 'Mailbox-of: os-elon-musk\n' }))], [{ seat: null, slug: null, login: 'os-justin' }, { seat: null, slug: null, login: 'os-sales' }, { seat: null, slug: null, login: 'os-elon-musk' }]); + t('⭐ an epic seat\'s body reads back as its seat, and its default head as its slug', [mailboxOwner(pr({ head: { ref: 'claude/x' }, body: pe.actions[0].body })), mailboxOwner(pr({ head: { ref: pe.branch } }))], [{ seat: 'epic:#27293#1', slug: 'epic-27293-1', login: 'sunhaolin' }, { seat: null, slug: 'epic-27293-1', login: 'sunhaolin' }]); t('a pm:mailbox pull naming neither is a mailbox of nobody, never a non-mailbox', mailboxOwner(pr({ body: 'hello' })), { seat: null, slug: null, login: null }); t('a malformed seat or login on the body line names no seat', [mailboxOwner(pr({ body: 'Mailbox-of: ui 4 · os-bill' })).slug, mailboxOwner(pr({ body: 'Mailbox-of: domain:ui#4 · -bad-' })).slug], [null, null]); t('without the label it is not a mailbox', mailboxOwner(pr({ labels: [], head: { ref: 'mailbox/os-bill/ui-4' } })), null); @@ -586,6 +602,7 @@ async function selfTest() { t('the seat match is case- and spelling-insensitive (domain:skills ≡ domain:skills#1)', [openMailboxesOf([opened('domain:skills', 'a', 1)], seatKey('Domain:Skills#1')).length, openMailboxesOf([pr({ head: { ref: 'mailbox/a/UI-4' } })], UI4).length], [1, 1]); t('⛔ a legacy per-login mailbox blocks no seat of its login', [openMailboxesOf([pr({ head: { ref: 'mailbox/os-bill' } }), pr({ body: 'To: os-bill' })], UI4).length], [0]); t('…and is named for its login, case-insensitively, never for another', [legacyMailboxesOf([pr({ number: 5, head: { ref: 'mailbox/OS-Bill' } }), pr({ number: 6, body: 'To: os-bill' }), pr({ number: 8, body: 'To: os-sales' }), opened('domain:ui#4', 'os-bill', 7)], 'os-bill').map((x) => x.number)], [[5, 6]]); + t('⭐ an epic seat\'s open mailbox refuses a second for that seat only — never for another epic, nor for a domain:epic lane', (() => { const box = [opened('epic:#27293#1', 'sunhaolin', 11)]; return [openMailboxesOf(box, seatKey('epic:#27293')).map((x) => x.number), openMailboxesOf(box, seatKey('epic:#27293#2')).length, openMailboxesOf(box, seatKey('epic:#2729#31')).length, openMailboxesOf(box, seatKey('domain:epic')).length]; })(), [[11], 0, 0, 0]); t('without the label it is not a mailbox', openMailboxesOf([pr({ labels: [], head: { ref: 'mailbox/os-bill/ui-4' } })], UI4).length, 0); t('a closed one does not block a new one', openMailboxesOf([pr({ state: 'closed', head: { ref: 'mailbox/os-bill/ui-4' } })], UI4).length, 0); @@ -632,6 +649,7 @@ async function selfTest() { t('close dry run exits 0 and prints both actions', [cli(['--repo', 'o/r', '--close', '7', '--dry-run']).status, (cli(['--repo', 'o/r', '--close', '7', '--dry-run']).stdout.match(/"op":/g) ?? []).length], [EXIT_OK, 2]); t('a usage error is exit 2 — an open with no --seat among them', [cli(['--repo', 'o/r']).status, cli(['--repo', 'o/r', '--login', 'os-bill', '--dry-run']).status], [EXIT_USAGE, EXIT_USAGE]); t('--help is exit 0', cli(['--help']).status, EXIT_OK); + t('an epic seat\'s dry run exits 0 and pushes its own branch; a malformed epic is exit 2', (() => { const e = cli(['--repo', 'o/r', '--seat', 'epic:#27293#1', '--login', 'sunhaolin', '--dry-run']); return [e.status, e.stdout.includes('refs/heads/mailbox/sunhaolin/epic-27293-1'), cli(['--repo', 'o/r', '--seat', 'epic:27293', '--login', 'sunhaolin', '--dry-run']).status]; })(), [EXIT_OK, true, EXIT_USAGE]); const floor = []; const declared = Object.keys(SELF_TEST_BATTERIES);