diff --git a/.claude/skills/pm-dispatch/SKILL.md b/.claude/skills/pm-dispatch/SKILL.md index 700d7439ef9..4a212aee1a5 100644 --- a/.claude/skills/pm-dispatch/SKILL.md +++ b/.claude/skills/pm-dispatch/SKILL.md @@ -71,7 +71,7 @@ PM 的工作是循环:选卡 → 认领 → 派发 → 收集 → 复核 → 报 - 项目席唤醒 = PR 订阅 + 每项目一个自建自绑巡查 routine(总监席除外);⛔ 无 `send_later` 链。 - 探针先行:fire 的第一动作是一次最便宜的车道盘点查询,⛔ 在它之前不读任何长文本。 - 队列空 + 无在飞 + 无待复核 + 决策箱无新答复 ⇒ 自退。 -- 分诊席跑直连会话、默认判断档;直连会话按召唤跑,⛔ 无自醒不常驻;裁决归总监席。 +- 分诊席与车道席常驻,断粮即换账号重开该席与信箱;分诊席默认判断档;裁决归总监席。 - 子代理裁决逐份过转录核验采信。 ## 阶段细则索引 diff --git a/.claude/skills/pm-dispatch/references/execution-duties.md b/.claude/skills/pm-dispatch/references/execution-duties.md index e70294af154..deb202fc883 100644 --- a/.claude/skills/pm-dispatch/references/execution-duties.md +++ b/.claude/skills/pm-dispatch/references/execution-duties.md @@ -34,7 +34,6 @@ - 分诊折叠提议仅是建议,点名各卡皆无 open PR 才有效;否则各卡各自落地,拒折理由记一行。 - 并行纪律四条(常规,非豁免):① 认领申报文件面到区域级,拿不准就串行。 - ② 开 PR 前合一次 main;③ 兄弟卡落地后再合一次;④ 冲突交合并队列仲裁,⛔ 不手动排序。 -- 文件面不相交只保证文本可合并;跨文件语义耦合由队列 CI 逮住,⛔ 不读作不可能冲突。 - 阻塞解除后延后单重定价:前一单派发词必答 #X 变简单、变难、不必要或无影响。 - 派发后一单前用该回答重读它的选项与成本,⛔ 不沿用立单时那份。 - 在飞卡衍生三分:不修它验收过不过;in-scope ⇒ 父卡 sub-issue,认领席自有、优先级继承。 @@ -51,6 +50,7 @@ - 标签序:`priority:p0` > `pm:blocking` > 功能点位次 > `target:` 板上项 > p1 > p2 > p3 > 无级。 - `pm:blocking` 级内先按解锁扇出(从 `Blocked-by:` 反向索引现算,⛔ 扇出数不落标签)。 - 全序每级现读/现算,零逐卡维护;同级先 `Bug` 再卡龄;无级/缺 `Path:` 轮报记分诊缺口。 +- 一车道多席按全序交替取卡:席 1 奇位、席 2 偶位;只为破交替才发 `split`;认领戳仲裁。 - 解锁那一刻 PM 自己的判断最不可信:裁决收窄或关掉了那张卡是假设不是前提。 - 该假设以机制假设身份进派发令,被证伪就在同一张卡公开更正。 diff --git a/.claude/skills/pm-dispatch/references/project-forms.md b/.claude/skills/pm-dispatch/references/project-forms.md index 4b12ec107bf..402c95fd1f1 100644 --- a/.claude/skills/pm-dispatch/references/project-forms.md +++ b/.claude/skills/pm-dispatch/references/project-forms.md @@ -6,9 +6,6 @@ - 车队项目 = 任一账号上、指令行指向 pm-dispatch 的 Claude Code 项目;⛔ 不分项目类型。 - 项目做什么只由它坐的席位决定;车道席、总监席、分诊席都是席位,由维护者召唤。 -- 车道席:认领、派发、PR、跨账号复审;总监席:决策箱与决裁批、发版、日报、总表。 -- 分诊席:只写标签与审计评论;分诊与总监可召进任一项目,⛔ 不绑定项目。 -- 唯一不变量在账号不在项目:批准账号永不跑席位(SKILL.md 全体座位的不变量);本文不另加。 - 规则与指派只写角色词(总监账户、车道账户);登录名只在裁决记录里作既往事件的证据。 ## 指令行即召唤 @@ -23,32 +20,35 @@ ## 席位是读出来的,不是配出来的 - 项目的席位 = 🟢 行写本账号的座位贴(车道、总监、分诊同此);派发前先读座位贴。 -- 一张也没有 ⇒ 项目闲置;每席恰一条席位线程、恰一车道,一席一贴。 - 指令行就是指派:维护者在项目里写下或改写这一行,是唯一的指派动作。 - 项目聊天里的长句(「本账户坐 X;Y 的 PR 由本账户复审」)是等价写法。 -- 协调者每次开场把指令行与座位贴对照:逐席按 `seat-post-protocol.md` 坐席或接管。 - 坐席即把本账号写进 🟢 行、每席开一条席位线程;⛔ 无花名册文件。 - 首次坐席自建本项目的 routine:巡逻默认每小时;分诊席加每小时裸卡轮;总监席不建。 -- 总监席只被维护者召唤与 PR 订阅唤醒;摘要与收件箱扫描在召唤内做;见 `lanes/director.md`。 -- 并发上限是维护者在该项目定的姿态;⛔ 不是协议常量,本文不写数字。 ## 接管不等撞墙账号 - 🟢 行写着别的账号 ⇒ 指令行即维护者强制接管令,含在飞卡。 -- 撞墙账号写不出任何东西,接管永不等它的注记或简报。 -- 跨账号 `Release:` 点名旧会话与出处三件;新 `Claim:` 带 `Resume-from: BRANCH@SHA`,新 dev 续旧分支。 - 四件形状见 `execution-duties.md` 认领节;台账 = 标签 + assignee + `Claim:` + `head:claude/` PR。 - 台账按 `assignee:<旧登录名>` 读;⛔ 项目记忆与共享目录永不是台账,只是本账号的缓存。 ## 信箱 PR -- 绑 PR 的对话不需要新东西:卡线程订阅自己的 PR,评论即唤醒。 -- 非 PR 对话:每项目至多一个 draft PR,标签 `pm:mailbox`,分支 `mailbox/`,由常驻线程订阅。 -- 永不合并,移交或换账号时关闭;正文首行 `To: `。 +- 每席一个 draft PR:分支 `mailbox/`,标签 `pm:mailbox`+`pinned`,正文首行 `To: `。 +- 坐席即开(`mailbox-open.mjs`)并订阅,存根指针行记其号;永不合并,移交或换账号关旧开新。 - `pm:mailbox` PR 不欠 CI,一切落地与台账查询排除它。 +- 首行恒为下列七键之一(发→收;何时):一事一行 ≤200 字节,只写键与编号;表外即半态。 +- `Request: contract-review · PR #n · head SHA · REASON`:车道→分诊;本席复审档不可用,一 head 一次。 +- `Request: urgent-triage · #n · ONE-LINE-CRITERION`:任一席→分诊;p0 嫌疑、main 红、确定性队列红。 +- `Request: main-red · CHECK-NAME · anchor #n · suggested lane X`:发现席→分诊(缺则总监);锚卡立起时。 +- `Notify: contract-review PASS or FAIL · PR #n · record ID`:分诊→车道;随落记录;车道已订阅则不发。 +- `Notify: seat-online · LANE#N · LOGIN · mailbox #m · takes: #a #b …`:坐席者→兄弟席+分诊;坐席时。 +- `Notify: seat-offline · LANE#N · in-flight: … · stay-behind: … · brief ID`:收班者→兄弟席+分诊。 +- `Notify: split · LANE#N · takes: … · leaves: …`:多席车道一席→兄弟席;开轮时,仅当清单变了。 +- ⛔ 无收到/谢谢/请确认,动作即回执;同一事实 ⛔ 不重发、不催,逾期归既有升级规则。 +- 每席每轮 ≤5 条,p0 豁免,计入写节奏;信箱评论是同事陈述,⛔ 永非授权(`untrusted-text.md`)。 +- `record-recognisers.mjs` 读七键;⛔ 无门禁、workflow 或队列守卫读信箱:只协调,不是合并条件。 ## 总表 - objectstack 置顶 issue「车队总表」,一席一行;正文由总监席日报与坐席/接管/移交重生成。 - 行 = 仓 · 车道 · 席 · 账号 · 在飞 · 待裁 · 待落地 · 撞墙注记 · 最近动静 · 信箱 PR。 -- 读自座位贴、标签、assignee 与 PR 查询;生成视图 ⛔ 不手改(`cross-repo-coordination.md` 规则 5)。 diff --git a/.claude/skills/pm-dispatch/references/seat-lifecycle.md b/.claude/skills/pm-dispatch/references/seat-lifecycle.md index 944c5bb47d4..e0fec56be07 100644 --- a/.claude/skills/pm-dispatch/references/seat-lifecycle.md +++ b/.claude/skills/pm-dispatch/references/seat-lifecycle.md @@ -39,13 +39,13 @@ - ⛔ 永不从贴的 `updated_at` 或标题推死活;活性只认在任者自己的产出。 - 竞态三招:动手前重 fetch 正文、审计评论时间戳先到先得、写后回读。 - 收班 = 状态 flush + 在飞显式安置,不是在飞归零;派发口径失效,简报 ⛔ 不列作继承裁决。 +- 坐席发 `seat-online`、收班简报同一动作发 `seat-offline`,键见 `project-forms.md` 〈信箱 PR〉。 +- 强制接管的新账号代撞墙账号发 `seat-online`;Routine 席唤不醒,读信箱靠巡查。 - 离任留守只写简报点名的项,⛔ 不读队列、不新派发、不碰未点名卡。 - 换班报告默认零建议,只收三类:原则错/缺、可机械化项、平台事实变化。 - 原则错/缺 → 只进简报,维护者点名才立卡;可机械化项 → 门禁/脚本卡,⛔ 不是散文。 - 平台事实变化 → references 事实表改一行。 - 后两类以 `finding` 入 skills 车道,首触归分诊席;三类之外默认关 not planned。 -- 接管/移交 = 改正文 + 审计评论(只作审计);收尾清单等细则见 `references/seat-post-protocol.md`。 -- epic 委托不入座位贴体系;新 `packages/spec` 工作恒归 spec 座位。 ## Epic 子树车道 diff --git a/scripts/pm/dispatch-gates.data.mjs b/scripts/pm/dispatch-gates.data.mjs index 331f7511099..349258342b6 100644 --- a/scripts/pm/dispatch-gates.data.mjs +++ b/scripts/pm/dispatch-gates.data.mjs @@ -499,6 +499,15 @@ export const COMPOUND_ANCHOR_LEDGER = [ * the governed file is its input, not a fixture; it spells that path in its * module body and is MATCHED for it the same way the two undeclared rows * above are, so `declared` is false for the same reason. + * + * ## The fifth row: the mailbox doorbell's spelling pin + * + * `scripts/pm/record-recognisers.mjs` reads the seven doorbell keys off the + * pm-dispatch references' `project-forms.md` in its self-test, to pin each + * key's template verbatim in the protocol text — a population claim on a file + * a card can edit, the `check-expected-skips` shape exactly. It is DECLARED + * (`self-test-reads`), so a card touching that file derives + * `check:pm-record-recognisers`. */ export const GOVERNED_READ_FLOOR = Object.freeze([ Object.freeze({ script: 'scripts/check-commit-card-trailers.mjs', file: '.claude/agents/os-dev.md', declared: false }), @@ -517,6 +526,11 @@ export const GOVERNED_READ_FLOOR = Object.freeze([ file: '.claude/settings.json', declared: false, }), + Object.freeze({ + script: 'scripts/pm/record-recognisers.mjs', + file: '.claude/skills/pm-dispatch/references/project-forms.md', + declared: true, + }), ]); /** diff --git a/scripts/pm/dispatch-gates.self-test.mjs b/scripts/pm/dispatch-gates.self-test.mjs index 34f62dec03d..1b917aabd34 100644 --- a/scripts/pm/dispatch-gates.self-test.mjs +++ b/scripts/pm/dispatch-gates.self-test.mjs @@ -7535,7 +7535,7 @@ export function selfTest({ tier = 'full' } = {}) { } const censusRows = liveMarkerCensus.map((r) => `${r.file}:${r.line} ${r.key}`).sort(); t( - `the live tree carries the eight declarations measured for this census, and no others (${censusRows.join(' · ') || 'none'})`, + `the live tree carries the nine declarations measured for this census, and no others (${censusRows.join(' · ') || 'none'})`, censusRows.join(' · ') === [ // Seventh row, added with the declaration it names: `checklist-status.yml` // is paths-filtered (its `pull_request:` trigger is filtered to itself) and @@ -7557,13 +7557,18 @@ export function selfTest({ tier = 'full' } = {}) { 'scripts/pm/check-expected-skips.mjs:137 self-test-reads', // 736 -> 738 (#26318): the data import and its re-export each gained UI_SCRIPTS_SUITE_ROOTS. 'scripts/pm/dispatch-gates.mjs:738 inherited-population', + // Ninth row, added with the declaration it names: the recogniser's mailbox + // doorbell battery pins the seven key templates verbatim in the pm-dispatch + // references' project-forms.md, a population claim on a governed file, so + // that read is declared and `GOVERNED_READ_FLOOR` carries its row. + 'scripts/pm/record-recognisers.mjs:77 self-test-reads', ].join(' · '), censusRows.join(' · '), ); const censusCut = liveMarkerCensus.filter((r) => !r.whole).map((r) => `${r.file}:${r.line} ${r.key}`); t( `every live reason on those markers ENDS on its own marker line (cut: ${censusCut.join(', ') || 'none'})`, - censusCut.length === 0 && liveMarkerCensus.length === 8, + censusCut.length === 0 && liveMarkerCensus.length === 9, ); t( 'and every one of them carries a non-empty reason — whole is not the same claim as present, and both are owed', diff --git a/scripts/pm/mailbox-open.mjs b/scripts/pm/mailbox-open.mjs index 5078b02cc4b..d72332eebda 100644 --- a/scripts/pm/mailbox-open.mjs +++ b/scripts/pm/mailbox-open.mjs @@ -2,7 +2,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * mailbox-open — open (or close) a project's cross-account mailbox PR (#25417). + * mailbox-open — open (or close) a seat's mailbox PR (#25417; one per seat since the per-seat doorbell). * * node scripts/pm/mailbox-open.mjs --repo owner/name --login [--base main] [--dry-run] [--json] * node scripts/pm/mailbox-open.mjs --repo owner/name --login --head [--dry-run] @@ -15,17 +15,25 @@ * shares is GitHub. A comment on a pull request wakes the session subscribed * to it — measured on #25392: a relay comment woke the subscribed thread in * ~70 s, a comment from a second account in 4 s. An issue comment wakes - * nothing. So each lane project keeps ONE draft PR as its mailbox: labelled - * `pm:mailbox`, on the branch `mailbox/` (the account that holds the - * project), subscribed by a resident thread, ⛔ never merged, closed when the - * account hands over. The protocol text (who writes `To: `, who - * subscribes) is the pm-dispatch skill's; this file is only the tooling. + * 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/` (the account that holds the seat), its body's + * first line `To: `, opened at seating and subscribed by the seated + * session, ⛔ never merged, closed at hand-over or account switch (the + * successor opens its own). A comment on it is ONE line opening with one of + * the seven doorbell keys (`Request:` / `Notify:`), read by + * `record-recognisers.mjs`. The protocol text — the keys, who sends what to + * whom — is the pm-dispatch skill's `references/project-forms.md` 〈信箱 PR〉; + * this file is only the tooling. * * ## What it does, in order (open) * * ① READ the open pulls and REFUSE (exit 4) when one already carries * `pm:mailbox` for this login — by head `mailbox/` or by the body's - * `Mailbox-of: ` line. One mailbox per account per repo. + * `To: ` line. One mailbox per login per repo, which IS one per + * seat: a login holds one seat at a time, and a lane's second seat is a + * second login (measured 2026-10-28 on the seat posts, so this refusal + * needed no seat name). * ② the BRANCH. Default: one empty commit on `origin/` (`git * commit-tree` of the base's own tree — no checkout, no index, no working * tree touched), pushed as `mailbox/` through `with-fleet.sh @@ -36,8 +44,17 @@ * pushed branch, as #25392 used) and skips the push; it must be ahead of * ``, because GitHub refuses a pull with no commits. * ③ ONE relay stroke: `pr_create` (draft is forced by the relay) with labels - * `pm:mailbox` and `skip-changeset`, sent through - * `fleet-write/dispatch.mjs` as `objectstack-fleet[bot]`. + * `pm:mailbox`, `pinned` and `skip-changeset`, sent through + * `fleet-write/dispatch.mjs` as `objectstack-fleet[bot]`. `pinned` is + * `stale.yml`'s `exempt-pr-labels` entry (`pm:mailbox` is not one), so + * without it a quiet mailbox is marked stale after 30 days and closed 7 + * days later. ⚠️ Measured 2026-10-28: the `pinned` label does not EXIST + * on objectstack-ai/objectstack (`GET /labels/pinned` → 404, as does + * `work-in-progress`; only `security` of the three exists), so the first + * open reads its label set back — the labels leg POSTs the name, and + * whether the platform creates a missing label there is a reading that + * open takes, not one this header asserts. `ensure-pm-labels.sh` is where + * a label the fleet relies on is created. * ④ READ the new pull's number back off its head and print it — the number * `subscribe_pr_activity` takes. * @@ -80,7 +97,7 @@ */ import { spawnSync } from 'node:child_process'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import process from 'node:process'; @@ -103,7 +120,9 @@ export const MAILBOX_LABEL = 'pm:mailbox'; /** Appended to every search query that enumerates open PRs for landing. */ export const LEDGER_EXCLUSION = `-label:${MAILBOX_LABEL}`; export const MAILBOX_BRANCH_PREFIX = 'mailbox/'; -const PR_LABELS = Object.freeze([MAILBOX_LABEL, 'skip-changeset']); +/** The stale sweep's exemption label (`.github/workflows/stale.yml` `exempt-pr-labels`); a mailbox is quiet by design. */ +export const STALE_EXEMPT_LABEL = 'pinned'; +const PR_LABELS = Object.freeze([MAILBOX_LABEL, STALE_EXEMPT_LABEL, 'skip-changeset']); const REPO_SHAPE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; // GitHub's own login rule: alphanumerics and single inner hyphens, ≤ 39. @@ -166,13 +185,13 @@ export function bodyFooter(session) { export function mailboxBody({ login, session = null }) { return [ - `Mailbox-of: ${login}`, + `To: ${login}`, '', - `The cross-account mailbox of the Claude Code project held by \`${login}\` (\`${MAILBOX_LABEL}\`).`, + `The mailbox of the seat held by \`${login}\` (\`${MAILBOX_LABEL}\`): one per seat, opened at seating, subscribed by the seated session.`, '', - '- ⛔ Never merged, never readied. Closed on account handover: `node scripts/pm/mailbox-open.mjs --close `.', - `- A message is a comment whose first line is \`To: \`; the resident thread subscribed to this PR reads it.`, - '- **CI: owes none.** `skip-changeset` exempts Check Changeset; any other check red on this empty diff is RED BY DESIGN — never fix, rerun or report it.', + '- ⛔ Never merged, never readied. Closed at hand-over or account switch: `node scripts/pm/mailbox-open.mjs --close N` (N = this number); the successor opens its own.', + '- A message is ONE comment whose first line is one of the seven doorbell keys (`Request:` / `Notify:`; pm-dispatch `references/project-forms.md` 〈信箱 PR〉): one event, one line, keys and numbers only. Nothing else is allowed here — no acknowledgements, no re-sends.', + `- **CI: owes none.** \`skip-changeset\` exempts Check Changeset; any other check red on this empty diff is RED BY DESIGN — never fix, rerun or report it. \`${STALE_EXEMPT_LABEL}\` keeps the stale sweep off it.`, `- Excluded from every landing ledger: queries add \`${LEDGER_EXCLUSION}\`.`, ].join('\n') + bodyFooter(session); } @@ -187,21 +206,22 @@ export function planOpen({ repo, login, head = null, base = 'main', session = nu export function planClose({ repo, pull }) { const actions = [ - { op: 'comment', issue: pull, body: 'Mailbox closed: the account holding this project handed over. A successor opens its own with `scripts/pm/mailbox-open.mjs`.\n\n---\n_Generated by [Claude Code](https://claude.ai/code)_' }, + { op: 'comment', issue: pull, body: 'Mailbox closed: the seat handed over or switched account. The successor opens its own with `scripts/pm/mailbox-open.mjs`.\n\n---\n_Generated by [Claude Code](https://claude.ai/code)_' }, { op: 'issue_patch', issue: pull, state: 'closed' }, ]; return { kind: 'close', repo, pull, actions, errors: validateStroke(repo, actions) }; } const labelNames = (pr) => (Array.isArray(pr?.labels) ? pr.labels.map((l) => (typeof l === 'string' ? l : l?.name)) : []); -const mailboxOf = (pr) => /^Mailbox-of: (\S+)/.exec(typeof pr?.body === 'string' ? pr.body : '')?.[1] ?? null; +/** The body's first line, `To: ` — the addressee, which is the seat the mailbox belongs to. */ +const addresseeOf = (pr) => /^To: (\S+)/.exec(typeof pr?.body === 'string' ? pr.body : '')?.[1] ?? null; -/** The open `pm:mailbox` pulls that belong to `login`, by head branch or by the body's `Mailbox-of:` line. Pure. */ +/** The open `pm:mailbox` pulls that belong to `login`, by head branch or by the body's `To:` line. Pure. */ export function openMailboxesOf(pulls, login) { const want = String(login).toLowerCase(); return (Array.isArray(pulls) ? pulls : []).filter((pr) => { if (pr?.state !== 'open' || !labelNames(pr).includes(MAILBOX_LABEL)) return false; - return String(pr?.head?.ref ?? '').toLowerCase() === mailboxBranch(want) || String(mailboxOf(pr) ?? '').toLowerCase() === want; + return String(pr?.head?.ref ?? '').toLowerCase() === mailboxBranch(want) || String(addresseeOf(pr) ?? '').toLowerCase() === want; }); } @@ -359,7 +379,7 @@ let selfTestReachedVerdict = false; const SELF_TEST_BATTERIES = Object.freeze({ 'arguments: the two modes and every refusal': 10, - 'the open plan: branch, labels, body, and the relay validator': 8, + 'the open plan: branch, labels, body, and the relay validator': 9, 'the close plan: comment then close, judged by the validator': 3, 'finding an open mailbox: head, body line, label, state': 6, 'refusing to close what is not an open mailbox': 4, @@ -403,9 +423,10 @@ async function selfTest() { const p = planOpen({ repo: 'objectstack-ai/objectstack', login: 'os-bill', session: 'cse_01ABC' }); t('the default branch is mailbox/ and it is pushed', [p.branch, p.push], ['mailbox/os-bill', true]); t('one pr_create action, base main', [p.actions.length, p.actions[0].op, p.actions[0].base, p.actions[0].head], [1, 'pr_create', 'main', 'mailbox/os-bill']); - t('labels: pm:mailbox and skip-changeset (the changeset-check exemption)', p.actions[0].labels, ['pm:mailbox', 'skip-changeset']); - t('the body opens with Mailbox-of: ', p.actions[0].body.split('\n')[0], 'Mailbox-of: os-bill'); - t('the body states the CI posture and the never-merged rule', [/RED BY DESIGN/.test(p.actions[0].body), /Never merged/.test(p.actions[0].body)], [true, true]); + t('labels: pm:mailbox, pinned (the stale-sweep exemption) and skip-changeset (the changeset-check exemption)', p.actions[0].labels, ['pm:mailbox', 'pinned', 'skip-changeset']); + t('⭐ pinned IS stale.yml\'s exemption, read off the workflow — and pm:mailbox is not one', (() => { const y = readFileSync(join(REPO_ROOT, '.github', 'workflows', 'stale.yml'), 'utf8'); const m = /exempt-pr-labels:\s*'([^']*)'/.exec(y); const names = m ? m[1].split(',').map((s) => s.trim()) : []; return [names.includes(STALE_EXEMPT_LABEL), names.includes(MAILBOX_LABEL)]; })(), [true, false]); + t('the body opens with To: — the addressee is the seat', p.actions[0].body.split('\n')[0], 'To: os-bill'); + t('the body states the CI posture, the never-merged rule and the closed vocabulary', [/RED BY DESIGN/.test(p.actions[0].body), /Never merged/.test(p.actions[0].body), /seven doorbell keys/.test(p.actions[0].body) && /no acknowledgements/.test(p.actions[0].body)], [true, true, true]); t('the relay validator accepts the stroke', p.errors, []); const ph = planOpen({ repo: 'o/r', login: 'os-bill', head: 'claude/project-thread-x' }); t('--head names the branch and skips the push', [ph.branch, ph.push, ph.actions[0].head], ['claude/project-thread-x', false, 'claude/project-thread-x']); @@ -420,7 +441,7 @@ async function selfTest() { battery('finding an open mailbox: head, body line, label, state'); const pr = (o) => ({ state: 'open', number: 1, labels: [{ name: 'pm:mailbox' }], head: { ref: 'x' }, body: '', ...o }); t('found by head mailbox/', openMailboxesOf([pr({ head: { ref: 'mailbox/os-bill' } })], 'os-bill').length, 1); - t('found by the body line on another head', openMailboxesOf([pr({ body: 'Mailbox-of: os-bill\n\nx' })], 'os-bill').length, 1); + t('found by the body line on another head', openMailboxesOf([pr({ body: 'To: os-bill\n\nx' })], 'os-bill').length, 1); t('login match is case-insensitive', openMailboxesOf([pr({ head: { ref: 'mailbox/OS-Bill' } })], 'os-bill').length, 1); t('without the label it is not a mailbox', openMailboxesOf([pr({ labels: [], head: { ref: 'mailbox/os-bill' } })], 'os-bill').length, 0); t('another login’s mailbox is not ours', openMailboxesOf([pr({ head: { ref: 'mailbox/os-elon' } })], 'os-bill').length, 0); diff --git a/scripts/pm/record-recognisers.mjs b/scripts/pm/record-recognisers.mjs index 0aae8481a2b..206a1ac12c1 100644 --- a/scripts/pm/record-recognisers.mjs +++ b/scripts/pm/record-recognisers.mjs @@ -33,6 +33,7 @@ * isOutsideReviewSurface the prose faces a head may move on and keep its record * contractReviewTemplateLines / contractReviewRecordLines what `--template` prints * contractReviewBriefLines what `--brief-template` prints — the brief a seat hands its reviewer + * readMailboxLine / mailboxEvents / MAILBOX_KEYS the mailbox DOORBELL — the seven first-line keys a `pm:mailbox` comment may open with, read; anything else refused * * Every definition below was MOVED, names unchanged, out of * `check-clause2-carriers.mjs` at the ruling's last step, which deleted the @@ -68,8 +69,16 @@ * which exit — is the guard's. */ +// The self-test's one read of a GOVERNED file, declared so a card editing that +// file derives this family (`dispatch-gates.mjs` `declaredSelfTestReads`; the +// row is `GOVERNED_READ_FLOOR` in `dispatch-gates.data.mjs`). The derivation +// grades it against the read the self-test really performs, so it cannot be +// satisfied by deleting either half. +// dispatch-gates: self-test-reads .claude/skills/pm-dispatch/references/project-forms.md -- the mailbox doorbell battery pins each of the seven key templates verbatim in that file's 〈信箱 PR〉 section, so a change to the keys' spelling there is judged here + import process from 'node:process'; import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { isEntrypoint } from '../invoked-as.mjs'; import { @@ -894,6 +903,195 @@ export function contractReviewHandoff(rows, headSha) { return { state: current ? 'triage' : 'stale', head: line.head, reason: line.reason, handedAt: line.at, ...stamp }; } +// --------------------------------------------------------------------------- +// The mailbox DOORBELL — seven first-line keys, a CLOSED vocabulary +// --------------------------------------------------------------------------- + +/** + * The seven keys a mailbox comment may open with — the doorbell a seat rings + * on a sibling seat's or the triage seat's mailbox PR (`pm:mailbox`, one per + * seat, opened by `mailbox-open.mjs`), because only a comment on a PR wakes + * the session subscribed to it; a line on an issue wakes nobody. The protocol + * text is the pm-dispatch skill's `references/project-forms.md` 〈信箱 PR〉, + * and this list is its spelling in code: `template` is the line exactly as + * that text prints it, placeholders included, and the self-test pins each one + * verbatim in that file, so the text and the reader cannot drift apart. + * + * The rules the reader enforces, from the same text: the FIRST line of the + * comment is the key and nothing else is allowed on a mailbox; one event, one + * line, at most {@link MAILBOX_LINE_MAX_BYTES} bytes, keys and numbers only — + * reasons and readings stay on the card or the PR; no acknowledgement; no + * re-send. Anything outside the table is a half-state. The vocabulary is + * CLOSED on the maintainer's word (「只沟通必要的信息」): an eighth key is + * refused here, never added by a reader that happens to want one. + * + * ⛔ No gate, workflow or queue guard reads a mailbox: coordination, never a + * merge condition. `readMailboxLine` and `mailboxEvents` are the reading a seat + * or a patrol takes; nothing here changes an exit. + */ +export const MAILBOX_KEYS = Object.freeze([ + { id: 'request:contract-review', kind: 'Request', key: 'contract-review', from: 'lane', to: 'triage', template: 'Request: contract-review · PR #n · head SHA · REASON' }, + { id: 'request:urgent-triage', kind: 'Request', key: 'urgent-triage', from: 'any seat', to: 'triage', template: 'Request: urgent-triage · #n · ONE-LINE-CRITERION' }, + { id: 'request:main-red', kind: 'Request', key: 'main-red', from: 'the finding seat', to: 'triage, or the director when triage is absent', template: 'Request: main-red · CHECK-NAME · anchor #n · suggested lane X' }, + { id: 'notify:contract-review', kind: 'Notify', key: 'contract-review', from: 'triage', to: 'lane', template: 'Notify: contract-review PASS or FAIL · PR #n · record ID' }, + { id: 'notify:seat-online', kind: 'Notify', key: 'seat-online', from: 'the seating session', to: 'the lane\'s sibling seats and triage', template: 'Notify: seat-online · LANE#N · LOGIN · mailbox #m · takes: #a #b …' }, + { id: 'notify:seat-offline', kind: 'Notify', key: 'seat-offline', from: 'the signing-off session', to: 'the lane\'s sibling seats and triage', template: 'Notify: seat-offline · LANE#N · in-flight: … · stay-behind: … · brief ID' }, + { id: 'notify:split', kind: 'Notify', key: 'split', from: 'a seat of a multi-seat lane', to: 'its sibling seats', template: 'Notify: split · LANE#N · takes: … · leaves: …' }, +]); +/** One event, one line: the longest a mailbox line may be, in bytes (the text's 「≤200 字节」). */ +export const MAILBOX_LINE_MAX_BYTES = 200; +/** The two kinds, in `keyLineRegex`'s one convention as ONE alternation — a third kind cannot arrive unnoticed. */ +const MAILBOX_KINDS = Object.freeze(['Request', 'Notify']); +const MAILBOX_KEY_LINE = keyLineRegex(`(${MAILBOX_KINDS.join('|')})`); +/** ` · ` — the middle dot the templates spell between fields; the spaces around it are optional. */ +const MAILBOX_SEPARATOR = /[ \t]*·[ \t]*/; +const NUMBER_REF = /^#([1-9][0-9]*)$/; +const PR_REF = /^PR #([1-9][0-9]*)$/; +const HEAD_REF = /^head `?([0-9a-fA-F]{7,40})`?$/; +const ANCHOR_REF = /^anchor #([1-9][0-9]*)$/; +const MAILBOX_REF = /^mailbox #([1-9][0-9]*)$/; +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. */ +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; +} + +/** `NAME: #a #b …` → the numbers; `NAME: none` → `[]`; anything else (a comma, a word, an empty list) → null. */ +function readList(part, name) { + const m = new RegExp(`^${name}:[ \\t]*(.*)$`).exec(String(part ?? '').trim()); + if (!m) return null; + const rest = m[1].trim(); + if (rest === 'none') return []; + if (rest === '') return null; + const items = rest.split(/[ \t]+/).map((item) => NUMBER_REF.exec(item)?.[1]); + return items.every((n) => n !== undefined) ? items.map(Number) : null; +} + +/** + * One reader per key: the ` · `-split parts in (the key word is `parts[0]`), the + * fields out, or null when the line does not spell the template. `numbers` is + * every card, PR or mailbox NUMBER the line names, in order — never a seat + * number, which is not a thing on the board. + */ +const MAILBOX_FIELD_READERS = Object.freeze({ + 'request:contract-review': (p) => { + if (p.length < 4) return null; + const pr = PR_REF.exec(p[1]); + const head = HEAD_REF.exec(p[2]); + const reason = p.slice(3).join(' · ').trim(); + return pr && head && reason !== '' ? { pr: Number(pr[1]), head: head[1], reason, numbers: [Number(pr[1])] } : null; + }, + 'request:urgent-triage': (p) => { + if (p.length < 3) return null; + const card = NUMBER_REF.exec(p[1]); + const criterion = p.slice(2).join(' · ').trim(); + return card && criterion !== '' ? { card: Number(card[1]), criterion, numbers: [Number(card[1])] } : null; + }, + 'request:main-red': (p) => { + if (p.length !== 4) return null; + const check = p[1].trim(); + const anchor = ANCHOR_REF.exec(p[2]); + const lane = LANE_REF.exec(p[3]); + return check !== '' && !/#/.test(check) && anchor && lane ? { check, anchor: Number(anchor[1]), lane: lane[1], numbers: [Number(anchor[1])] } : null; + }, + 'notify:contract-review': (p) => { + if (p.length !== 3) return null; + const verdict = VERDICT_REF.exec(p[0]); + const pr = PR_REF.exec(p[1]); + const record = RECORD_REF.exec(p[2]); + return verdict && pr && record ? { verdict: verdict[1], pr: Number(pr[1]), record: Number(record[1]), numbers: [Number(pr[1])] } : null; + }, + 'notify:seat-online': (p) => { + if (p.length !== 5) return null; + const seat = readSeat(p[1]); + const login = LOGIN_REF.test(p[2]) ? p[2] : null; + const mailbox = MAILBOX_REF.exec(p[3]); + const takes = readList(p[4], 'takes'); + return seat && login && mailbox && takes ? { seat, login, mailbox: Number(mailbox[1]), takes, numbers: [Number(mailbox[1]), ...takes] } : null; + }, + 'notify:seat-offline': (p) => { + if (p.length !== 5) return null; + const seat = readSeat(p[1]); + const inFlight = readList(p[2], 'in-flight'); + const stayBehind = readList(p[3], 'stay-behind'); + const brief = BRIEF_REF.exec(p[4]); + return seat && inFlight && stayBehind && brief ? { seat, inFlight, stayBehind, brief: Number(brief[1]), numbers: [...inFlight, ...stayBehind] } : null; + }, + 'notify:split': (p) => { + if (p.length !== 4) return null; + const seat = readSeat(p[1]); + const takes = readList(p[2], 'takes'); + const leaves = readList(p[3], 'leaves'); + return seat && takes && leaves ? { seat, takes, leaves, numbers: [...takes, ...leaves] } : null; + }, +}); + +/** + * What ONE mailbox comment says, read off its first line. Body in, reading out; nothing is fetched. + * + * read the first line is one of the seven keys and spells its template: `id`, `kind`, `key`, + * the key's own fields, and `numbers` (every card, PR or mailbox number the line names) + * refused the first line is not a key line at all, or names a key outside the seven — free text, + * an acknowledgement, an eighth key: the half-state the text names + * unreadable a known key whose line does not spell its template, runs over the byte cap, or is + * followed by a second line of content (the platform's appended footer is not one) + * + * The key is read in `keyLineRegex`'s one convention (a bullet, a blockquote, bold or backticks around + * the key are decoration), the fields are split on ` · `, and a head is 7 to 40 hex as everywhere here. + * + * @returns {{ state: 'refused', reason: string, kind?: string, line?: string } + * | { state: 'unreadable', kind: string, reason: string, line: string } + * | { state: 'read', id: string, kind: string, key: string, numbers: number[] } & Record} + */ +export function readMailboxLine(text) { + const lines = String(text ?? '').split(/\r?\n/); + const at = lines.findIndex((l) => l.trim() !== ''); + if (at < 0) return { state: 'refused', reason: 'an empty comment' }; + const first = lines[at]; + const m = MAILBOX_KEY_LINE.exec(first); + if (!m) return { state: 'refused', reason: 'the first line is not a `Request:` or `Notify:` key line', line: quoteLine(first) }; + const kind = m[1]; + const unreadable = (reason) => ({ state: 'unreadable', kind, reason, line: quoteLine(first) }); + const value = stripValueDecoration(m[2]).replace(/(?:[ \t]|\*\*|`)+$/, ''); + const parts = value.split(MAILBOX_SEPARATOR); + const word = parts[0].trim().split(/[ \t]+/)[0]; + const entry = MAILBOX_KEYS.find((k) => k.kind === kind && k.key === word) ?? null; + if (!entry) return { state: 'refused', kind, reason: `\`${kind}: ${word}\` is not one of the seven keys`, line: quoteLine(first) }; + if (Buffer.byteLength(first, 'utf8') > MAILBOX_LINE_MAX_BYTES) return unreadable(`over ${MAILBOX_LINE_MAX_BYTES} bytes — one event, one line, keys and numbers only`); + const tail = lines.slice(at + 1).map((l) => l.trim()).filter((l) => l !== ''); + if (!tail.every((l) => FOOTER_RULE.test(l) || FOOTER_LINE.test(l))) return unreadable('more than one line — reasons and readings stay on the card or the PR'); + const fields = MAILBOX_FIELD_READERS[entry.id](parts.map((part) => part.trim())); + if (fields === null) return unreadable(`does not spell \`${entry.template}\``); + return { state: 'read', id: entry.id, kind, key: entry.key, ...fields }; +} + +/** + * Every comment on a mailbox PR, read: what `readMailboxLine` makes of each row, stamped with the row's + * `comment` (its id), `at` (`created_at` — the TIME a bell rang) and `by` (the author login), plus the + * comment ids of the rows that are not a reading — the half-states a patrol reports. Rows in, readings + * out; the newest is the last, as the thread orders them. The stamp is spread LAST so a reading's own + * `id` (the key id) can never shadow the comment id. + * + * @param {{ id?: number, body?: string, created_at?: string, user?: { login?: string } }[]} rows + */ +export function mailboxEvents(rows) { + if (!Array.isArray(rows)) return { state: 'unreadable', gaps: ["the mailbox's comment thread"] }; + const events = rows.map((row) => ({ ...readMailboxLine(row?.body), comment: row?.id ?? null, at: row?.created_at ?? null, by: row?.user?.login ?? null })); + return { state: 'read', events, halfStates: events.filter((e) => e.state !== 'read').map((e) => e.comment) }; +} + // --------------------------------------------------------------------------- // The record TEMPLATE — the one machine-read artefact with nothing to copy (#18042) // --------------------------------------------------------------------------- @@ -1545,6 +1743,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ '⭐ letter C: the at-tier review of a mixed diff is SCOPED — `hits` plus what they claim, on the `Scope:` line': 18, '⭐ letter B: a declared NARROWING is a face of its own at CONTRACT_REVIEW_TIER — keyed on the `Clause-②:` line, exact': 17, '⭐ the `Contract-review-handoff:` line — names the current head ⇒ triage\'s review; head moved ⇒ stale; absent ⇒ the seat\'s own': 13, + '⭐ the mailbox doorbell: the seven first-line keys read — kind, numbers, sha, login, time — and anything else refused': 21, }); // DELETING an entry silences that battery's floor exactly as effectively as @@ -1558,7 +1757,9 @@ const SELF_TEST_BATTERIES = Object.freeze({ // and the governed-register wording added one pin to each: 27 → 28 and 16 → 17. Hanging ④ on the narrowing // face (ruling record 6442046166, letter A) flipped three pins and added one to each: 28 → 29 and 17 → 18. // Raised 12 → 13 with the hand-off battery (the maintainer's exhausted-tier rule): one battery, one row. -const SELF_TEST_BATTERY_FLOOR = 13; +// Raised 13 → 14 with the mailbox doorbell battery (the maintainer's per-seat mailbox and its closed +// vocabulary): one battery, one row. +const SELF_TEST_BATTERY_FLOOR = 14; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -1944,6 +2145,48 @@ export async function selfTest() { t('the key is ONE regex, built by the one key-line convention, and prose naming it mid-sentence is not the line', CONTRACT_REVIEW_HANDOFF_LINE.source === keyLineRegex(CONTRACT_REVIEW_HANDOFF_KEY).source && readContractReviewHandoff(`the ${CONTRACT_REVIEW_HANDOFF_KEY}: line goes on the PR`).state === 'missing' && contractReviewHandoff([HO_ROW(`the ${CONTRACT_REVIEW_HANDOFF_KEY}: line goes on the PR`)], HO_HEAD).state === 'absent'); t('⛔ no gate reads it: the queue guard\'s record reading is untouched — a hand-off line beside a record on this head still reads FOUND', (() => { const r = locateReviewOfRecord(pairOf({ prComments: [RECORD_ON_9AF9, HO_ROW(HO_LINE(HEAD_9AF9))] })); return r.state === 'found' && r.id === 3301; })()); + // -- the mailbox doorbell ----------------------------------------------------- + // + // One mailbox PR per seat, and a CLOSED vocabulary on it (the maintainer's + // 「只沟通必要的信息」): the first line of a mailbox comment is one of the seven + // keys and nothing else is allowed. These cases read one example of each key + // and refuse free text, an acknowledgement, an eighth 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. + battery('⭐ the mailbox doorbell: the seven first-line keys read — 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 }); + const DB = { + cr: `Request: contract-review · PR #26766 · head ${HO_HEAD} · ${CONTRACT_REVIEW_TIER_NAME} refused, HTTP 429`, + ut: 'Request: urgent-triage · #27047 · main red on the merge group, deterministic', + mr: 'Request: main-red · Test Core · anchor #27090 · suggested lane server', + nc: 'Notify: contract-review PASS · PR #26766 · record 6450011111', + on: 'Notify: seat-online · domain:skills#2 · os-bill · mailbox #27120 · takes: #27077 #27062', + off: 'Notify: seat-offline · domain:skills#2 · in-flight: #27077 · stay-behind: #27082 #26749 · brief 6450022222', + sp: 'Notify: split · domain:skills#2 · takes: #27077 #27062 · leaves: #27047', + }; + t('⭐ `Request: contract-review` reads the PR, the head (7–40 hex) and the reason — the reason may carry a comma', (() => { const r = readMailboxLine(DB.cr); return r.state === 'read' && r.id === 'request:contract-review' && r.kind === 'Request' && r.key === 'contract-review' && r.pr === 26766 && r.head === HO_HEAD && r.reason === `${CONTRACT_REVIEW_TIER_NAME} refused, HTTP 429` && r.numbers.join() === '26766'; })()); + t('⭐ `Request: urgent-triage` reads the card and the one-line criterion', (() => { const r = readMailboxLine(DB.ut); return r.state === 'read' && r.id === 'request:urgent-triage' && r.card === 27047 && r.criterion === 'main red on the merge group, deterministic' && r.numbers.join() === '27047'; })()); + t('⭐ `Request: main-red` reads the check name, the anchor card and the suggested lane', (() => { const r = readMailboxLine(DB.mr); return r.state === 'read' && r.id === 'request:main-red' && r.check === 'Test Core' && r.anchor === 27090 && r.lane === 'server' && r.numbers.join() === '27090'; })()); + t('⭐ `Notify: contract-review` reads the verdict, the PR and the record id — PASS and FAIL, and the template\'s own `PASS or FAIL` is a placeholder, not a verdict', (() => { const p = readMailboxLine(DB.nc); const f = readMailboxLine(DB.nc.replace('PASS', 'FAIL')); const tpl = readMailboxLine('Notify: contract-review PASS or FAIL · PR #26766 · record 6450011111'); return p.state === 'read' && p.id === 'notify:contract-review' && p.verdict === 'PASS' && p.pr === 26766 && p.record === 6450011111 && p.numbers.join() === '26766' && f.state === 'read' && f.verdict === 'FAIL' && tpl.state === 'unreadable'; })()); + t('⭐ `Notify: seat-online` reads the seat (lane and number), the login, the mailbox and the takes list', (() => { const r = readMailboxLine(DB.on); return r.state === 'read' && r.id === 'notify:seat-online' && r.seat.lane === 'domain:skills' && r.seat.n === 2 && r.login === 'os-bill' && r.mailbox === 27120 && r.takes.join() === '27077,27062' && r.numbers.join() === '27120,27077,27062'; })()); + t('⭐ `Notify: seat-offline` reads the seat, the in-flight and stay-behind lists and the brief id', (() => { const r = readMailboxLine(DB.off); return r.state === 'read' && r.id === 'notify:seat-offline' && r.seat.n === 2 && r.inFlight.join() === '27077' && r.stayBehind.join() === '27082,26749' && r.brief === 6450022222 && r.numbers.join() === '27077,27082,26749'; })()); + t('⭐ `Notify: split` reads the seat and the takes and leaves lists', (() => { const r = readMailboxLine(DB.sp); return r.state === 'read' && r.id === 'notify:split' && r.seat.lane === 'domain:skills' && r.takes.join() === '27077,27062' && r.leaves.join() === '27047' && r.numbers.join() === '27077,27062,27047'; })()); + t('⭐ every key\'s template is spelled VERBATIM, in a code span, in the protocol text (`references/project-forms.md` 〈信箱 PR〉) — and that text lists exactly seven', (() => { const forms = readFileSync(new URL('../../.claude/skills/pm-dispatch/references/project-forms.md', import.meta.url), 'utf8'); const keyLines = forms.split('\n').filter((l) => /^- `(Request|Notify): /.test(l)); return MAILBOX_KEYS.every((k) => forms.includes(`\`${k.template}\``)) && keyLines.length === MAILBOX_KEYS.length && MAILBOX_KEYS.length === 7; })()); + t('decoration reads: a bulleted bolded key and a blockquoted line are the same event', (() => { const a = readMailboxLine(`- **Request:** contract-review · PR #26766 · head ${HO_HEAD} · quota exhausted`); const b = readMailboxLine(`> ${DB.sp}`); return a.state === 'read' && a.pr === 26766 && b.state === 'read' && b.id === 'notify:split'; })()); + t('the attribution block the platform APPENDS (bare or session-URL footer under a rule line) is not a second line', ['\n\n---\n_Generated by [Claude Code](https://claude.ai/code)_', `\n\n---\n_Generated by [Claude Code](https://claude.ai/code/${RECORD_SESSION})_`].every((tail) => readMailboxLine(`${DB.on}${tail}`).state === 'read')); + t('⛔ free text, an acknowledgement and an empty comment are REFUSED, naming the line — the half-state the text names', (() => { const a = readMailboxLine('Seat note: picking up the review now, will report on the card.'); const b = readMailboxLine('收到,谢谢!'); const c = readMailboxLine(''); return a.state === 'refused' && typeof a.line === 'string' && b.state === 'refused' && c.state === 'refused'; })()); + t('⛔ an EIGHTH key is refused, whatever its kind — the vocabulary is closed — and the kinds are case-sensitive', ['Request: coffee · #27077', 'Notify: ack · #27077', 'Ack: #27077', 'request: urgent-triage · #27047 · x', 'Request: contract-review-done · PR #1'].every((l) => readMailboxLine(l).state === 'refused')); + t(`⛔ a line over ${MAILBOX_LINE_MAX_BYTES} bytes is unreadable — keys and numbers only, the reasoning stays on the card`, (() => { const r = readMailboxLine(`Request: contract-review · PR #26766 · head ${HO_HEAD} · ${'x'.repeat(160)}`); return r.state === 'unreadable' && /bytes/.test(r.reason) && readMailboxLine(DB.cr).state === 'read'; })()); + t('⛔ a second line of content is unreadable — one event, one line', (() => { const r = readMailboxLine(`${DB.cr}\n\nbecause the reviewer tier was refused twice this round`); return r.state === 'unreadable' && /more than one line/.test(r.reason); })()); + 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(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('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(l).state === 'unreadable'); })()); + t('the spaces around the ` · ` separator are optional', readMailboxLine('Request: urgent-triage·#27047·main red').state === 'read' && readMailboxLine('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('Notify: split · triage · takes: none · leaves: none').seat.lane === 'triage' && readMailboxLine('Notify: split · triage · takes: none · leaves: none').seat.n === null && readMailboxLine('Notify: split · LANE#N · takes: none · leaves: none').state === 'unreadable'); + t('⭐ `mailboxEvents` reads a thread: each row\'s comment id, time and author beside its reading (the key id never shadows the comment id), and the half-states by comment id', (() => { const r = mailboxEvents([DB_ROW(DB.cr, 5101, '2026-10-28T07:00:00Z'), DB_ROW('thanks, on it', 5102, '2026-10-28T07:05:00Z'), DB_ROW(DB.on, 5103, '2026-10-28T07:10:00Z')]); return r.state === 'read' && r.events.length === 3 && r.events[0].state === 'read' && r.events[0].id === 'request:contract-review' && r.events[0].comment === 5101 && r.events[0].at === '2026-10-28T07:00:00Z' && r.events[0].by === 'os-bill' && r.events[2].mailbox === 27120 && r.events[2].comment === 5103 && r.halfStates.join() === '5102'; })()); + t('a thread that is not an array is a read that could not be made, never an empty one', mailboxEvents(null).state === 'unreadable' && mailboxEvents([]).state === 'read' && mailboxEvents([]).events.length === 0); + t('⛔ no gate reads it: the queue guard\'s record reading is untouched — a doorbell comment beside a record on this head still reads FOUND', (() => { const r = locateReviewOfRecord(pairOf({ prComments: [RECORD_ON_9AF9, DB_ROW(DB.nc, 5104)] })); return r.state === 'found' && r.id === 3301; })()); + t('the roster: seven keys, unique ids, each of the two kinds, the byte cap the text states', MAILBOX_KEYS.length === 7 && new Set(MAILBOX_KEYS.map((k) => k.id)).size === 7 && MAILBOX_KEYS.every((k) => ['Request', 'Notify'].includes(k.kind) && k.template.startsWith(`${k.kind}: ${k.key}`)) && MAILBOX_LINE_MAX_BYTES === 200); + // -- #18042: the copyable record TEMPLATE ---------------------------------- // // `references/contract-review.md` DESCRIBES the record in prose and once @@ -2177,7 +2420,9 @@ export async function selfTest() { + 'floor read off `hits` onto the `Scope:` line and into the brief, `--paths` included; the pure-regeneration carry and the prose-only carry decided on committed trees in ' + 'every direction; the copyable template, per face, driven back through the ' + 'locator and printed by `--template`; the `Local-runs:` reading — none, one named probe, absent not re-judged, ' - + 'unreadable refused; the reviewer brief printed by `--brief-template` with its read-only shape; and the ONE ' + + 'unreadable refused; the reviewer brief printed by `--brief-template` with its read-only shape; the mailbox ' + + 'doorbell — seven keys read, each spelled verbatim in the protocol text, and free text, an acknowledgement, ' + + 'an eighth key, a second line and an over-long line refused; and the ONE ' + 'thread set, driven through the queue guard itself).', );