diff --git a/scripts/pm/mailbox-open.mjs b/scripts/pm/mailbox-open.mjs index cf9661197e0..3463a550885 100644 --- a/scripts/pm/mailbox-open.mjs +++ b/scripts/pm/mailbox-open.mjs @@ -47,8 +47,8 @@ * 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 is named `triage`, - * `director` or `repo-…`, which is what keeps the slug one-to-one. + * `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. * * ## What it does, in order (open) * @@ -159,8 +159,18 @@ 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. -const SEAT_SHAPE = /^((?:domain|repo):[a-z0-9._-]+|triage|director)(?:#([1-9][0-9]*))?$/; +// 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`). +/** 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]*))?$`); +// 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('|')})-)`); /** 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: `. */ @@ -168,11 +178,15 @@ const LEGACY_OWNER_LINE = /^(?:To|Mailbox-of):[ \t]*(\S+)$/; /** * A seat spelled `LANE#N` → `{ key, slug }`, or null when it is not one. `key` is canonical — lower case, the - * number always spelled (`triage` → `triage#1`); `slug` is the key as one ref segment (header). Pure. + * number always spelled (`triage` → `triage#1`); `slug` is the key as one ref segment (header). A `domain:` lane + * named after a reserved lane word (`domain:triage`, `domain:repo-cloud`) is not a seat either: its slug would be + * another seat's, and the header's one-to-one promise is kept here, not assumed. This is the fleet's ONE seat + * grammar — the mailbox `To:` line and the doorbell keys' `LANE#N` fields read with it, never a second shape. Pure. */ export function seatKey(text) { const m = SEAT_SHAPE.exec(String(text ?? '').trim().toLowerCase()); 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}` }; } @@ -519,6 +533,10 @@ async function selfTest() { 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('⛔ 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 branch is mailbox// — no `:` and no `#` in a ref', mailboxBranch('os-bill', seatKey('domain:ui#4')), 'mailbox/os-bill/ui-4'); 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 542632df5a4..21b3e714425 100644 --- a/scripts/pm/record-recognisers.mjs +++ b/scripts/pm/record-recognisers.mjs @@ -1266,18 +1266,23 @@ const RECORD_REF = /^record `?#?([1-9][0-9]*)`?$/; const BRIEF_REF = /^brief `?#?([1-9][0-9]*)`?$/; const LANE_REF = /^suggested lane ([A-Za-z][A-Za-z0-9:_-]*)$/; const VERDICT_REF = /^contract-review (PASS|FAIL)$/; -/** `LANE#N` — `domain:skills#2`, `skills#1`, or a seat that carries no number (`triage`, `director`). */ -const SEAT_REF = /^([A-Za-z][A-Za-z0-9:_-]*)(?:#([1-9][0-9]*))?$/; /** GitHub's own login rule, as `mailbox-open.mjs` spells it for `--login`: alphanumerics and single inner hyphens, ≤ 39. */ const LOGIN_REF = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/; /** The attribution block the platform APPENDS to a comment (AGENTS.md): a rule line and the bare or session-URL footer. Not a second line. */ const FOOTER_RULE = /^-{3,}$/; const FOOTER_LINE = /^_Generated by \[Claude Code\]\(https:\/\/claude\.ai\/code(?:\/session_[A-Za-z0-9]+)?\)_$/; -/** `LANE#N` → `{ lane, n }`, or null. */ +/** + * A key's `LANE#N` field → `{ key, lane, n }`, or null. Read with `seatKey`, the ONE seat grammar (#27891): the + * lane is `domain:`, `repo:`, `triage` or `director`, so the short form `skills#2` is refused here exactly + * as on the `To:` line, an absent number is seat 1 (`triage` → `triage#1`), and a `domain:` lane named after a + * reserved lane word is no seat. `key` is the canonical spelling; `lane` and `n` are its two halves. + */ function readSeat(part) { - const m = SEAT_REF.exec(String(part ?? '').trim()); - return m ? { lane: m[1], n: m[2] === undefined ? null : Number(m[2]) } : null; + const s = seatKey(part); + if (!s) return null; + const at = s.key.lastIndexOf('#'); + return { key: s.key, lane: s.key.slice(0, at), n: Number(s.key.slice(at + 1)) }; } /** `NAME: #a #b …` → the numbers; `NAME: none` → `[]`; anything else (a comma, a word, an empty list) → null. */ @@ -2572,7 +2577,9 @@ export async function selfTest() { // key, a second line and an over-long line — the half-state faces — and pin // each key's spelling to the protocol text, so the reader and the text cannot // drift apart. Every refusal of a KEY is asked under a `To:` line, so it - // cannot pass vacuously on the missing envelope. + // cannot pass vacuously on the missing envelope. A key's `LANE#N` field is + // read with `seatKey` (#27891): the short form and a reserved `domain:` name + // are refused there exactly as on `To:`. battery('⭐ the mailbox doorbell: `To: `, then one of the seven keys read — seat, kind, numbers, sha, login, time — and anything else refused'); const DB_ROW = (body, id = 5101, at = '2026-10-28T07:00:00Z') => ({ id, created_at: at, user: { login: 'os-bill' }, body }); // A message: the addressee line, then the key line. @@ -2603,7 +2610,10 @@ export async function selfTest() { t('⛔ a known key that does not spell its template is unreadable: no head, a 6-hex head, a login GitHub refuses, no mailbox, no lane, a record that is not a number', [`Request: contract-review · PR #26766 · ${HO_HEAD} · x`, 'Request: contract-review · PR #26766 · head 1b03a9 · x', 'Notify: seat-online · domain:skills#2 · -bad-- · mailbox #27120 · takes: none', 'Notify: seat-online · domain:skills#2 · os-bill · takes: none', 'Request: main-red · Test Core · anchor #27090', 'Notify: contract-review PASS · PR #26766 · record six'].every((l) => { const r = readMailboxLine(MSG(l)); return r.state === 'unreadable' && /does not spell/.test(r.reason); })); t('lists: `none` is the empty list, `#a #b` the numbers; a comma, an empty list or a `PR #n` item is unreadable', (() => { const ok = readMailboxLine(MSG('Notify: split · domain:skills#2 · takes: none · leaves: #1 #2')); return ok.state === 'read' && ok.takes.length === 0 && ok.leaves.join() === '1,2' && ['Notify: split · domain:skills#2 · takes: #1, #2 · leaves: none', 'Notify: split · domain:skills#2 · takes: · leaves: none', 'Notify: split · domain:skills#2 · takes: PR #1 · leaves: none'].every((l) => readMailboxLine(MSG(l)).state === 'unreadable'); })()); t('the spaces around the ` · ` separator are optional', readMailboxLine(MSG('Request: urgent-triage·#27047·main red')).state === 'read' && readMailboxLine(MSG('Request: urgent-triage ·#27047· main red')).card === 27047); - t('a seat with no number reads (`triage`), and the `LANE#N` placeholder itself does not', readMailboxLine(MSG('Notify: split · triage · takes: none · leaves: none')).seat.lane === 'triage' && readMailboxLine(MSG('Notify: split · triage · takes: none · leaves: none')).seat.n === null && readMailboxLine(MSG('Notify: split · LANE#N · takes: none · leaves: none')).state === 'unreadable'); + t('a seat with no number reads as seat 1 (`triage` → `triage#1`, as `seatKey` spells it), and the `LANE#N` placeholder itself does not', (() => { const r = readMailboxLine(MSG('Notify: split · triage · takes: none · leaves: none')); return r.seat.lane === 'triage' && r.seat.n === 1 && r.seat.key === 'triage#1' && readMailboxLine(MSG('Notify: split · LANE#N · takes: none · leaves: none')).state === 'unreadable'; })()); + t('⭐ a key\'s `LANE#N` field is read with `seatKey` — ONE seat grammar (#27891): a well-formed `domain:X#N` reads as before, carries its canonical `key`, and is read lower-cased', (() => { const r = readMailboxLine(DB.on); const u = readMailboxLine(MSG('Notify: seat-online · Domain:Skills#2 · os-bill · mailbox #27120 · takes: none')); return r.state === 'read' && r.seat.key === 'domain:skills#2' && r.seat.lane === 'domain:skills' && r.seat.n === 2 && u.state === 'read' && u.seat.key === 'domain:skills#2'; })()); + t('⛔ the short form `skills#2` is refused in a key field, the same as on `To:` — the key does not spell its template', ['Notify: seat-online · skills#2 · os-bill · mailbox #27120 · takes: none', 'Notify: seat-offline · skills#2 · in-flight: none · stay-behind: none · brief 6450022222', 'Notify: split · skills#2 · takes: none · leaves: none', 'Notify: split · server · takes: none · leaves: none'].every((l) => { const r = readMailboxLine(MSG(l)); return r.state === 'unreadable' && /does not spell/.test(r.reason); }) && readMailboxLine('To: skills#2\nRequest: urgent-triage · #27047 · x').state === 'refused'); + t('⛔ a `domain:` lane named after a reserved lane word is refused in a key field and on `To:` alike — its slug would be another seat\'s', ['domain:triage', 'domain:director#2', 'domain:repo-cloud#1'].every((seat) => readMailboxLine(MSG(`Notify: split · ${seat} · takes: none · leaves: none`)).state === 'unreadable' && readMailboxLine(`To: ${seat}\nRequest: urgent-triage · #27047 · x`).state === 'refused') && readMailboxLine(MSG('Notify: split · domain:triage-tools#1 · takes: none · leaves: none')).seat.key === 'domain:triage-tools#1'); t('⭐ `To: ` reads as the addressed seat\'s canonical key (`seatKey`\'s), beside the key\'s own fields — `to` never shadows a key\'s own `seat`', (() => { const r = readMailboxLine(DB.sp); return r.to === 'domain:skills#1' && r.seat.lane === 'domain:skills' && r.seat.n === 2 && readMailboxLine(DB.ut).to === 'triage#1' && readMailboxLine(MSG('Notify: split · triage · takes: none · leaves: none', 'Domain:UI#4')).to === 'domain:ui#4'; })()); t('⛔ a key on the FIRST line — the shape a mailbox took before the seat key — is REFUSED, naming the missing `To:`', ['Notify: split · domain:skills#2 · takes: none · leaves: none', `Request: contract-review · PR #26766 · head ${HO_HEAD} · x`].every((l) => { const r = readMailboxLine(l); return r.state === 'refused' && /`To: `/.test(r.reason) && typeof r.line === 'string'; })); t('⛔ a `To:` naming a login, or no seat at all, is REFUSED — a login is not a seat', ['os-bill', 'marchtian', '', 'domain:ui#0', 'ui 4'].every((to) => { const r = readMailboxLine(`To: ${to}\nRequest: urgent-triage · #27047 · x`); return r.state === 'refused' && /names no seat/.test(r.reason); }));