diff --git a/.claude/skills/pm-dispatch/references/contract-review.md b/.claude/skills/pm-dispatch/references/contract-review.md index 28bc6e0c4c8..3ac662e2809 100644 --- a/.claude/skills/pm-dispatch/references/contract-review.md +++ b/.claude/skills/pm-dispatch/references/contract-review.md @@ -20,6 +20,12 @@ - 四面皆不碰 ⇒ CI 加席位自读 ⛔ 不起第二个 agent;谁跑 = 派发席,达档者席内审。 - 派发席达档调用被配额拒(HTTP 429)⇒ ⛔ 不降档审;PR 落交接行、留等复核标记,交分诊席。 - `Contract-review-handoff: triage — CONTRACT_REVIEW_TIER unavailable (, ) · head ` +- 分诊只接无人在审的欠审 PR(draft 亦算):现 head 无记录、无活认领,且 (a)(b)(c) 之一成立。 +- (a) 交接行点名现 head 或 PR 带等复核标记,同轮接;(b) 现 head 已等满一轮分诊(约 60 分钟); +- (c) 车道席已收班或不在总表;欠审清单 = `node scripts/pm/check-owed-reviews.mjs`,只读。 +- 车道或分诊起审前读 PR:现 head 有记录或活认领 ⇒ ⛔ 不起;否则先落认领行,一 head 一行: +- `Contract-review-claim: · head · ` +- 认领随 head 移动或贴后 2 小时无记录失效;同 head 双认 ⇒ 车道胜,分诊回帖撤回、弃产出。 - 复核记录 = 一条评论落 PR 或卡,席内与子代理同形;散文、dev 自评、`os-dev-report` 恒不算。 - 同形 = `## Contract review` 题头、head sha 独占码段、①②③ 逐项(申报面外三面加 ④)、 独立性对、PASS/FAIL。 @@ -51,5 +57,4 @@ - 只作等复核标记,⛔ 不是闸门;单载体 = PR,卡上副本在规则外、不是要求。 - 挂:ACCEPT 时条款②任一肢命中而现 head 无同形 PASS 在案 ⇒ 派发席同笔挂于 PR。 - 摘:现 head 同形 PASS 在案 ⇒ 写记录的席同笔摘;PR 合并或关闭 ⇒ 派发席摘;FAIL 不摘。 -- 只带标记无交接行者仍归本席,分诊 ⛔ 不碰;交接后 head 再动 ⇒ 新交接行或本席自审。 -- ⛔ 无 check、workflow、队列守卫或巡查脚本读它;入队只认同形记录,标记有无不改判。 +- ⛔ 无 check、workflow 或队列守卫读标记与认领行;入队只认同形记录,标记有无不改判。 diff --git a/.claude/skills/pm-dispatch/references/triage-duties.md b/.claude/skills/pm-dispatch/references/triage-duties.md index d35af2b6fa8..d317c77923d 100644 --- a/.claude/skills/pm-dispatch/references/triage-duties.md +++ b/.claude/skills/pm-dispatch/references/triage-duties.md @@ -8,12 +8,12 @@ - 查重缓存住席内:新会话全量拉,其后 `since` 增量;⛔ 不建分支、不共享、不依赖备份。 - 每日一 fire 跑两仓全量对账并归集日频职责;选层按 fire 时刻 ⛔ 不用计数器,简报写明层。 - 每条枚举比对返回数与 `totalCount`,不等 ⇒ 报 `sweep INCOMPLETE` 点名缺口,⛔ 永不报干净。 -- 复审交接:每轮列最新交接行点名现 head 的 open PR,隔离子代理达档跑同形记录、署本席。 -- 本席也被配额拒 ⇒ PR 写一次、留标,⛔ 永不降档审;交接行读法 `record-recognisers.mjs`。 +- 复审:每轮先跑 `check-owed-reviews.mjs`(open PR 含 draft,读交接与认领行),欠审同轮认领后接。 +- 隔离子代理达档跑同形记录、署本席;本席也被配额拒 ⇒ PR 写一次留标,⛔ 永不降档审。 - 每轮比对自审记录 `Head-sha` 与现 head,越过即过期,纯重生成/只动散文面例外(判已提交树)。 - 自审 FAIL 过期 ⇒ 同轮达档重审新 head,逐条复查 must-fix,delta commit 当改动审。 - 自审 PASS 过期 ⇒ PR 记一条 PASS 不再管辖、欠 delta 复审,归本席或新交接行。 -- 轮次记录逐张自审 PR 写 `记录 head → 现 head → 状态`(现行/过期已重审/过期已记)。 +- 轮次记录逐张自审 PR 写 `认领者 · 记录 head→现 head→状态`(现行/过期已重审/过期已记)。 - Backlog sweep 是常设职责,每 fire 扫任一析取命中的卡。 - 析取 ①:全裸(无 `pm:*`、无 `needs-user-decision`、无 `domain:*`)。 - 析取 ②:有 `pm:queue` 无 `domain:*`(仅多车道仓);析取 ③:有 `domain:*` 无 pm-state。 diff --git a/package.json b/package.json index 3d4a41cfe04..1c4c491860b 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "check:declared-population-live": "node scripts/check-declared-population-live.mjs --self-test && node scripts/check-declared-population-live.mjs", "check:pm-half-states": "node scripts/pm/check-half-states.mjs --self-test", "check:issue-citations": "node scripts/check-issue-citations.mjs --self-test", - "check:pm-record-recognisers": "node scripts/pm/record-recognisers.mjs --self-test", + "check:pm-record-recognisers": "node scripts/pm/record-recognisers.mjs --self-test && node scripts/pm/check-owed-reviews.mjs --self-test", "check:pm-prior-rulings": "node scripts/pm/check-prior-rulings.mjs --self-test", "check:pm-stale-finding": "node scripts/pm/sweep-stale-finding.mjs --self-test", "check:pm-write-pace": "node scripts/pm/write-pace.mjs --self-test", diff --git a/scripts/pm/check-owed-reviews.mjs b/scripts/pm/check-owed-reviews.mjs new file mode 100644 index 00000000000..bd71a9813d3 --- /dev/null +++ b/scripts/pm/check-owed-reviews.mjs @@ -0,0 +1,602 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * check-owed-reviews — REPORT-ONLY list of the open pull requests, drafts + * included, whose face owes a contract review, and whether anybody is doing it. + * + * node scripts/pm/check-owed-reviews.mjs [--repo OWNER/NAME] [--board OWNER/NAME] [--now ISO] [--json] + * node scripts/pm/check-owed-reviews.mjs --self-test # offline, no network + * node scripts/pm/check-owed-reviews.mjs --help + * + * ## Why it is a file in the repository + * + * The rule text (the pm-dispatch skill's `references/contract-review.md`, + * 〈复核归属与资格〉) says the lane reviews its own pull requests and the triage + * seat takes a contract review only when nobody is doing it; the triage duties + * run this list first in their review step. Before it existed the list was a + * session-local scratch script: lost when a container was replaced, rebuilt + * without drafts and without the hand-off lines, and two Tier H drafts waited + * about five rounds with a hand-off naming their head. A list every seat can + * run from the tree is the cure, so ⛔ no round relies on a session-local copy. + * + * ## What one row says (one row per open pull request whose face owes a review) + * + * face why a review is owed: `schema` / `rules` (the path hits of + * `reviewFace`, the one function the queue guard decides the face + * with), `narrowing` / `declaration` (the PR body's `Clause-②:` + * line, read by `readClause2Line`), `clause2-unreadable` (a line + * nobody can grade, listed rather than dropped), `label` (the + * `needs:contract-review` waiting label). Drafts are IN: a Tier H + * pull request stays draft until its review is on file. + * head the current head and the minutes it has waited: since its commit's + * committer date, or since the pull request was opened when that is + * later (a head committed long before its PR existed has not waited). + * record `current` (a record on this head — PASS / FAIL as the record's + * VERDICT line states it, and by whom), `stale` (the newest record + * names an earlier head: the delta re-review stays with that + * reviewer), `none`, or `unjudged` (the read could not be made). + * hand-off the newest `Contract-review-handoff:` line, as `contractReviewHandoff` + * reads it: `triage` (it names the current head), `stale`, `absent`, + * `unreadable`. + * claim the `Contract-review-claim:` lines: `live` (who holds the head, + * the lane winning a tie), `lapsed`, `none`. + * lane seat the lane of the PR's card (`domain:*`) against the open seat + * posts: `seated`, `stood-down`, `absent`, `unknown`. + * status `TAKE` (owed, nobody on it, a trigger holds), `WAIT` (owed, + * nobody on it, no trigger yet — the lane's own), `HELD` (a live + * claim on this head), `DONE` (a record on this head). + * trigger the first that holds: (a) a hand-off line naming the current + * head, or the waiting label — taken the same round; (b) the head + * has waited a full triage round (`TRIAGE_ROUND_MINUTES`); (c) the + * lane's seat has stood down or is absent from the seat roster. + * + * ## Report-only — the exit is 0 on every completed or incomplete read + * + * ⛔ Not a gate (the skill's 「新增门禁默认否」): it reads, prints and exits 0, + * and it writes nothing — no comment, no label, no claim. A read that fails is + * printed as `sweep INCOMPLETE` naming the pull request and the read, ⛔ never + * dropped and never reported as clean; the exit stays 0 because a board fact is + * no reason to fail whoever ran the list. Usage errors exit 2. The verdict on + * a row — start the review, or not — is the reader's, under the rule text. + */ + +import process from 'node:process'; + +import { isEntrypoint } from '../invoked-as.mjs'; +import { CONTRACT_REVIEW_HEADING_MARKER, latestMarkedComment } from './check-half-states.mjs'; +import { repoIdForSlug } from './check-governed-merges.mjs'; +import { readClause2Line } from './clause2-line.mjs'; +import { parseSeat } from './fleet-roster.mjs'; +import { ghApi, readAllPages } from './gh-pages.mjs'; +import { + REVIEWED_BY_LINE, + contractReviewClaims, + contractReviewHandoff, + deliveredCardNumber, + locateReviewOfRecord, + reviewFace, +} from './record-recognisers.mjs'; + +export const EXIT_OK = 0; +export const EXIT_USAGE = 2; + +export const DEFAULT_REPO = 'objectstack-ai/objectstack'; +/** The waiting label the dispatching seat hangs at ACCEPT (`references/contract-review.md` 〈等复核标记〉). */ +export const WAITING_LABEL = 'needs:contract-review'; +/** One full triage round — the rule's 「一轮分诊(约 60 分钟)」, trigger (b). */ +export const TRIAGE_ROUND_MINUTES = 60; +/** The seat posts' label (`seat-post-protocol.md`); `parseSeat` reads each one. */ +export const SEAT_LABEL = 'pm:seat'; + +const REPO_SHAPE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; +const VERDICT_LINE = /^[ \t>]*(?:\*\*)?VERDICT:[ \t]*(PASS|FAIL)\b/im; +const labelNames = (x) => (Array.isArray(x?.labels) ? x.labels.map((l) => (typeof l === 'string' ? l : l?.name)).filter(Boolean) : []); + +// --------------------------------------------------------------------------- +// The pure core +// --------------------------------------------------------------------------- + +export function parseArgs(argv) { + const opts = { repo: DEFAULT_REPO, board: null, now: null, json: false, selfTest: false, help: false, errors: [] }; + const args = [...argv]; + const value = (flag) => { + const v = args.shift(); + if (v === undefined || v.startsWith('--')) { + opts.errors.push(`${flag} needs a value`); + return null; + } + return v; + }; + while (args.length) { + const a = args.shift(); + if (a === '--repo' || a === '--board') { + const v = value(a); + if (v !== null && !REPO_SHAPE.test(v)) opts.errors.push(`${a} takes OWNER/NAME, not ${v}`); + // ⛔ The rules face is read off the governed register's rows for THIS repository, so a pull list + // from a repository outside the register cannot be graded — refused, never graded as another's. + else if (v !== null && a === '--repo' && repoIdForSlug(v) === null) opts.errors.push(`--repo ${v} is outside the governed register (check-governed-merges.mjs GOVERNED_REPOS)`); + else if (v !== null) opts[a.slice(2)] = v; + } else if (a === '--now') { + const v = value(a); + if (v !== null && !Number.isFinite(Date.parse(v))) opts.errors.push(`--now takes an ISO time, not ${v}`); + else if (v !== null) opts.now = v; + } else if (a === '--json') opts.json = true; + else if (a === '--self-test') opts.selfTest = true; + else if (a === '--help' || a === '-h') opts.help = true; + else opts.errors.push(`unknown argument ${a}`); + } + if (opts.selfTest && argv.length !== 1) opts.errors.push('--self-test takes no other argument'); + if (opts.board === null) opts.board = opts.repo; + return opts; +} + +/** + * Why this pull request owes a contract review — the limbs, in the order a + * reader is told them; empty means it owes none. Pure. + * + * ⭐ Composed from the fleet's readers, ⛔ never a second classification: the + * path limbs are `reviewFace`'s `hits` (the queue guard's own reading), the + * declaration limbs are `readClause2Line` on the PR body, and the face named + * beside them is `reviewFace`'s answer for the same inputs. + */ +export function owedFace(pull, repo = DEFAULT_REPO) { + const paths = Array.isArray(pull?.files) ? pull.files : []; + const body = String(pull?.body ?? ''); + // `reviewFace` takes the register's repository ID; an unregistered slug reaches it as null and is refused there, loudly. + const face = reviewFace(paths, repoIdForSlug(repo), { declaredOn: [body] }); + const limbs = []; + if (face.hits.schema.length > 0) limbs.push('schema'); + if (face.hits.rules.length > 0) limbs.push('rules'); + const declared = readClause2Line(body); + if (declared?.kind === 'declared' && declared.arm === 'narrowing') limbs.push('narrowing'); + else if (declared?.kind === 'declared' && declared.value === 'yes') limbs.push('declaration'); + else if (declared?.kind === 'malformed') limbs.push('clause2-unreadable'); + if (labelNames(pull).includes(WAITING_LABEL)) limbs.push('label'); + return { limbs, face: face.face, tierName: face.tierName }; +} + +/** The `Reviewed-by:` value of a record, decoration stripped, or null. Pure. */ +export function reviewedByOf(body) { + for (const line of String(body ?? '').split(/\r?\n/)) { + const m = REVIEWED_BY_LINE.exec(line); + if (m) return m[1].replace(/[`*]/g, '').trim().split(/\s+/)[0] || null; + } + return null; +} + +/** PASS / FAIL as the record's own `VERDICT:` line states it — a display, never a judgment. Pure. */ +export function verdictOf(body) { + return VERDICT_LINE.exec(String(body ?? ''))?.[1] ?? 'unstated'; +} + +/** + * The record on this head, or the newest one on another head, or none. Pure + * over the threads. `locateReviewOfRecord` decides "on this head" — the queue + * guard's own reading; it is handed no git reader, so a `Regen-provenance:` + * carry is `unjudged` here rather than guessed. + */ +export function recordReading(pull) { + const pair = { + pr: pull.number, + card: pull.card ?? null, + headSha: pull.head?.sha ?? null, + prComments: pull.comments, + cardComments: pull.card === null || pull.card === undefined ? [] : pull.cardComments, + }; + const located = locateReviewOfRecord(pair); + if (located.state === 'unreadable') return { state: 'unjudged', gaps: located.gaps }; + const rows = [...(Array.isArray(pair.prComments) ? pair.prComments : []), ...(Array.isArray(pair.cardComments) ? pair.cardComments : [])]; + if (located.state === 'found' || located.state === 'unsigned') { + const row = rows.find((r) => (r?.id ?? null) === located.id) ?? null; + return { state: 'current', id: located.id, where: located.where, by: reviewedByOf(row?.body), verdict: verdictOf(row?.body), signed: located.state === 'found' }; + } + const newest = latestMarkedComment(rows, CONTRACT_REVIEW_HEADING_MARKER); + if (!newest) return { state: 'none' }; + const row = rows[newest.index]; + return { state: 'stale', id: row?.id ?? null, by: reviewedByOf(row?.body), verdict: verdictOf(row?.body) }; +} + +/** + * The PR's lane seat, read off the card's `domain:*` label and the open seat + * posts. Pure. `seats` null means the roster could not be read. + */ +export function laneSeatOf(cardLabels, seats) { + const lane = (Array.isArray(cardLabels) ? cardLabels : []).find((l) => /^domain:[a-z][a-z0-9-]*$/.test(l)) ?? null; + if (lane === null) return { lane: null, seat: 'unknown' }; + if (!Array.isArray(seats)) return { lane, seat: 'unknown' }; + const rows = seats.filter((s) => s?.lane === lane); + if (rows.some((s) => s.status === 'seated')) return { lane, seat: 'seated' }; + return { lane, seat: rows.length > 0 ? 'stood-down' : 'absent' }; +} + +/** + * One row for one pull request, or null when its face owes no review. Pure. + * + * @param {object} pull a REST pull row plus `files` (paths), `comments`, `card`, `cardComments`, + * `cardLabels`, `headCommittedAt` (ISO) and `gaps` (reads that failed) + * @param {{ now: number, seats: object[]|null, repo?: string }} context + */ +export function owedReviewRow(pull, { now, seats, repo = DEFAULT_REPO }) { + const owed = owedFace(pull, repo); + if (owed.limbs.length === 0) return null; + const head = String(pull?.head?.sha ?? ''); + const committed = Date.parse(pull?.headCommittedAt ?? ''); + // The later of the head's commit and the PR's opening: a head cannot have waited longer than its PR has existed. + const opened = Date.parse(pull?.created_at ?? ''); + const since = Number.isFinite(committed) ? Math.max(committed, Number.isFinite(opened) ? opened : committed) : Number.NaN; + const waited = Number.isFinite(since) ? Math.max(0, Math.floor((now - since) / 60000)) : null; + const record = recordReading(pull); + const handoff = contractReviewHandoff(pull.comments, head); + const claim = contractReviewClaims(pull.comments, head, { now }); + const lane = laneSeatOf(pull.cardLabels, seats); + const triggers = []; + if (handoff.state === 'triage') triggers.push('(a) hand-off'); + if (owed.limbs.includes('label')) triggers.push('(a) label'); + if (waited !== null && waited >= TRIAGE_ROUND_MINUTES) triggers.push(`(b) waited ${waited} min`); + if (lane.seat === 'stood-down' || lane.seat === 'absent') triggers.push(`(c) lane seat ${lane.seat}`); + let status; + if (record.state === 'current') status = 'DONE'; + else if (claim.state === 'live') status = 'HELD'; + else status = triggers.length > 0 ? 'TAKE' : 'WAIT'; + return { + pr: pull.number, + draft: pull.draft === true, + face: owed.limbs, + tierName: owed.tierName, + head, + waited, + record, + handoff: { state: handoff.state, id: handoff.id ?? null }, + claim: { state: claim.state, holder: claim.holder ?? null, tie: claim.tie ?? false, unreadable: claim.unreadable ?? [] }, + lane: lane.lane, + laneSeat: lane.seat, + status, + trigger: status === 'TAKE' || status === 'WAIT' ? (triggers[0] ?? 'none yet') : null, + gaps: Array.isArray(pull.gaps) ? pull.gaps : [], + }; +} + +const STATUS_ORDER = Object.freeze(['TAKE', 'WAIT', 'HELD', 'DONE']); + +/** Every owing row, TAKE first, then by PR number. Pure. */ +export function owedReviewRows(snapshot, { now }) { + const rows = (snapshot?.pulls ?? []) + .map((pull) => owedReviewRow(pull, { now, seats: snapshot?.seats ?? null, repo: snapshot?.repo ?? DEFAULT_REPO })) + .filter((row) => row !== null); + return rows.sort((a, b) => STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status) || a.pr - b.pr); +} + +const short = (sha) => String(sha ?? '').slice(0, 12); + +/** One printed line for one row. Pure. */ +export function renderRow(row) { + const record = row.record.state === 'current' || row.record.state === 'stale' + ? `${row.record.state} ${row.record.verdict} by ${row.record.by ?? 'nobody named'} (${row.record.id})` + : row.record.state; + const holder = row.claim.holder; + const claim = holder === null + ? row.claim.state + : `live ${holder.session} (${holder.side}${row.claim.tie ? ', lane wins the tie' : ''}, ${holder.id}, ${holder.time})`; + const parts = [ + `${row.status} #${row.pr} ${row.draft ? 'draft' : 'ready'}`, + `face ${row.face.join('+')}`, + `head ${short(row.head)} (${row.waited === null ? '? min' : `${row.waited} min`})`, + `record ${record}`, + `hand-off ${row.handoff.state}${row.handoff.id === null ? '' : ` (${row.handoff.id})`}`, + `claim ${claim}`, + `lane ${row.lane ?? '?'} ${row.laneSeat}`, + ]; + const tail = row.trigger === null ? '' : ` → ${row.trigger}`; + const gaps = row.gaps.length > 0 ? ` ⚠️ UNREAD: ${row.gaps.join('; ')}` : ''; + return `${parts.join(' · ')}${tail}${gaps}`; +} + +/** The printed report. Pure. */ +export function renderReport(rows, { repo, now, read, gaps }) { + const count = (s) => rows.filter((r) => r.status === s).length; + const lines = [ + `check-owed-reviews — ${repo}, ${new Date(now).toISOString().replace(/:\d\d\.\d{3}Z$/, 'Z')}: ${read.pulls} open PR(s) read (${read.drafts} draft); ` + + `${rows.length} owe a contract review — ${count('TAKE')} to take, ${count('WAIT')} waiting on the lane, ${count('HELD')} claimed, ${count('DONE')} reviewed on this head.`, + '', + ...rows.map(renderRow), + ]; + if (gaps.length > 0) { + lines.push('', `⚠️ sweep INCOMPLETE — ${gaps.length} read(s) failed; the rows above are not the whole answer:`, ...gaps.map((g) => ` - ${g}`)); + } + lines.push('', 'Report-only: exit 0, nothing written. The rule is `references/contract-review.md` 〈复核归属与资格〉.'); + return lines; +} + +// --------------------------------------------------------------------------- +// The live read — every read through one injected `get`, so the self-test drives it offline +// --------------------------------------------------------------------------- + +/** + * Read the board into the snapshot the pure core takes. ⛔ A failed read is + * recorded as a GAP on its pull request (or on the sweep) and the read goes + * on — never a partial list returned as whole, and never a throw that loses + * the rows already read. + */ +export function readSnapshot({ repo, board, get = ghApi }) { + const gaps = []; + const list = (path) => readAllPages(path, { get }); + const pullsRead = list(`/repos/${repo}/pulls?state=open`); + if (!pullsRead.ok) return { repo, pulls: [], seats: null, gaps: [`the open pull list (${pullsRead.error})`] }; + const seatsRead = list(`/repos/${board}/issues?state=open&labels=${encodeURIComponent(SEAT_LABEL)}`); + const seats = seatsRead.ok ? seatsRead.value.map((post) => parseSeat(post, board)).filter((s) => s !== null) : null; + if (!seatsRead.ok) gaps.push(`the seat posts on ${board} (${seatsRead.error}) — trigger (c) is not judged`); + const pulls = []; + for (const raw of pullsRead.value) { + const pull = { ...raw, files: [], comments: [], card: null, cardComments: [], cardLabels: [], headCommittedAt: null, gaps: [] }; + const files = list(`/repos/${repo}/pulls/${raw.number}/files`); + if (files.ok) pull.files = files.value.flatMap((f) => [f.filename, f.previous_filename].filter((p) => typeof p === 'string')); + else pull.gaps.push(`files (${files.error})`); + pulls.push(pull); + // Threads, card and commit only where a review is owed: the face is read first, from the paths, body and labels. + if (files.ok && owedFace(pull, repo).limbs.length === 0) continue; + const comments = list(`/repos/${repo}/issues/${raw.number}/comments`); + if (comments.ok) pull.comments = comments.value; + else { + pull.comments = null; + pull.gaps.push(`comments (${comments.error})`); + } + const delivered = deliveredCardNumber(raw); + if (delivered.card !== null) { + pull.card = delivered.card; + const card = get(`/repos/${repo}/issues/${delivered.card}`); + if (card.ok) pull.cardLabels = labelNames(card.value); + else pull.gaps.push(`card #${delivered.card} (${card.error})`); + const cardComments = list(`/repos/${repo}/issues/${delivered.card}/comments`); + if (cardComments.ok) pull.cardComments = cardComments.value; + else { + pull.cardComments = null; + pull.gaps.push(`card #${delivered.card} comments (${cardComments.error})`); + } + } + const commit = get(`/repos/${repo}/commits/${raw.head?.sha}`); + if (commit.ok) pull.headCommittedAt = commit.value?.commit?.committer?.date ?? null; + else pull.gaps.push(`head commit (${commit.error})`); + } + for (const pull of pulls) for (const g of pull.gaps) gaps.push(`#${pull.number}: ${g}`); + return { repo, pulls, seats, gaps }; +} + +const USAGE = [ + 'usage: node scripts/pm/check-owed-reviews.mjs [--repo OWNER/NAME] [--board OWNER/NAME] [--now ISO] [--json]', + ' node scripts/pm/check-owed-reviews.mjs --self-test | --help', + '', + ' Lists the open pull requests, drafts included, whose face owes a contract review, with the record,', + ' hand-off and claim on the current head and the trigger that lets the triage seat take it.', + ` --repo the repository whose pulls are read (default ${DEFAULT_REPO}); --board, where the seat posts live`, + ' --now the clock (default: now); --json prints the rows as JSON', + ' Report-only: reads through `gh api`, writes nothing, exits 0 on every read; 2 on a usage error.', +].join('\n'); + +export function main(argv, { get = ghApi, log = console.log, error = console.error } = {}) { + const opts = parseArgs(argv); + if (opts.errors.length > 0) { + for (const e of opts.errors) error(`check-owed-reviews: ${e}`); + error(`\n${USAGE}`); + return EXIT_USAGE; + } + if (opts.help) { + log(USAGE); + return EXIT_OK; + } + const now = opts.now === null ? Date.now() : Date.parse(opts.now); + const snapshot = readSnapshot({ repo: opts.repo, board: opts.board, get }); + const rows = owedReviewRows(snapshot, { now }); + const read = { pulls: snapshot.pulls.length, drafts: snapshot.pulls.filter((p) => p.draft === true).length }; + if (opts.json) log(JSON.stringify({ repo: opts.repo, now: new Date(now).toISOString(), read, gaps: snapshot.gaps, rows }, null, 2)); + else for (const line of renderReport(rows, { repo: opts.repo, now, read, gaps: snapshot.gaps })) log(line); + return EXIT_OK; +} + +// --------------------------------------------------------------------------- +// Self-test — battery roster, floor and handshake (AGENTS.md § Writing a `--self-test`) +// --------------------------------------------------------------------------- + +let selfTestReachedVerdict = false; + +const SELF_TEST_BATTERIES = Object.freeze({ + 'arguments: modes and every refusal': 6, + 'the owed face: paths, the Clause-② line, the label — drafts included, nothing else owes': 9, + 'the record: on this head (by whom, as stated), on an earlier head, none, unjudged': 6, + 'the triggers: (a) hand-off or label the same round, (b) a full round, (c) the lane seat': 8, + 'a live claim holds the head and a record settles it; a lapsed claim holds nothing': 5, + 'the live instance: two Tier H drafts with a hand-off on the current head list as TAKE under (a)': 4, + 'the live read: every read through one reader; a failed read is INCOMPLETE, never clean': 6, + 'the report: one line per owing PR, TAKE first, exit 0': 5, +}); +const SELF_TEST_BATTERY_FLOOR = 8; +const UNATTRIBUTED_BATTERY = '(no battery open)'; + +export function selfTest() { + const cases = []; + const seen = new Map(); + let open = null; + const battery = (name) => { + open = name; + }; + const t = (name, ok, detail) => { + const b = open ?? UNATTRIBUTED_BATTERY; + seen.set(b, (seen.get(b) ?? 0) + 1); + cases.push({ name: `[${b}] ${name}`, ok: Boolean(ok), detail }); + }; + + const NOW = Date.parse('2026-10-28T13:00:00Z'); + const HEAD = '3beee64fe77741d07f035f1b80eeaa07e7280ab4'; + const HEAD2 = 'ff079cc066b82261e0827f995c83d075302f6798'; + const TRIAGE = 'session_01AavokzJ5DndAwitDXvKy4U'; + const LANE = 'session_01JQwxbZeBv2y91towxasSUT'; + const handoff = (head, at) => ({ id: 6463525633, created_at: at, body: `## Seat note · contract review owed\n\nContract-review-handoff: triage — CONTRACT_REVIEW_TIER unavailable (ceiling model out for the fleet since 2026-10-27, seat runs at TIER_DEFAULT, ${at.slice(0, 16)}Z) · head \`${head}\`` }); + const claimRow = (session, head, at, sig) => ({ id: 6468788431, created_at: at, body: `${sig}\n\nContract-review-claim: \`${session}\` · head \`${head}\` · ${at.slice(0, 16)}Z\n\nTaking the review.` }); + const recordRow = (head, verdict = 'PASS', id = 6469007202) => ({ id, created_at: '2026-10-28T12:29:43Z', body: `## Contract review\n\nServed-tier: \`CONTRACT_REVIEW_TIER\`\nHead-sha: \`${head}\`\nLocal-runs: none\n\nReviewed-by: \`${TRIAGE}\`\n\n**VERDICT: ${verdict}**` }); + const pull = (o = {}) => ({ + number: 27136, + draft: true, + body: 'Part of #25922\n\nClause-②: no\nMetadata-protocol: none', + head: { sha: HEAD, ref: 'claude/project-thread-6d34fw' }, + labels: [], + files: ['.github/workflows/ci.yml'], + comments: [], + card: null, + cardComments: [], + cardLabels: ['domain:ui'], + headCommittedAt: '2026-10-28T07:30:00Z', + gaps: [], + ...o, + }); + const SEATED = [{ lane: 'domain:ui', status: 'seated' }, { lane: 'domain:skills', status: 'seated' }]; + const row = (o, seats = SEATED) => owedReviewRow(pull(o), { now: NOW, seats }); + + battery('arguments: modes and every refusal'); + const d = parseArgs([]); + t('defaults: this repository, its own board, no clock, text', d.errors.length === 0 && d.repo === DEFAULT_REPO && d.board === DEFAULT_REPO && d.now === null && d.json === false); + t('--repo moves the board with it unless --board is named', parseArgs(['--repo', 'objectstack-ai/objectui']).board === 'objectstack-ai/objectui' && parseArgs(['--repo', 'objectstack-ai/objectui', '--board', 'o/b']).board === 'o/b'); + t('a repo not shaped OWNER/NAME, or outside the governed register, is refused', parseArgs(['--repo', 'nope']).errors.some((e) => e.includes('OWNER/NAME')) && parseArgs(['--repo', 'o/a']).errors.some((e) => e.includes('governed register'))); + t('--now takes an ISO time; anything else is refused', parseArgs(['--now', '2026-10-28T13:00Z']).now === '2026-10-28T13:00Z' && parseArgs(['--now', 'soon']).errors.length === 1); + t('an unknown flag is refused, never ignored, and a flag with no value says so', parseArgs(['--bogus']).errors.some((e) => e.includes('--bogus')) && parseArgs(['--repo']).errors.some((e) => e.includes('needs a value'))); + t('--self-test runs alone', parseArgs(['--self-test']).errors.length === 0 && parseArgs(['--self-test', '--json']).errors.length === 1); + + battery('the owed face: paths, the Clause-② line, the label — drafts included, nothing else owes'); + t('⭐ a governed path owes (the rules face, read by `reviewFace`) — on a DRAFT', (() => { const r = row({}); return r !== null && r.face.includes('rules') && r.draft === true; })()); + t('a published schema path owes the schema face', owedFace(pull({ files: ['packages/spec/src/data/field.zod.ts'] })).limbs.includes('schema')); + t('⭐ a `Clause-②: no (narrowing)` body owes with no contract path (the narrowing face)', owedFace(pull({ files: ['ui/packages/plugin-ai/package.json'], body: 'Clause-②: no (narrowing)\nMetadata-protocol: narrowed' })).limbs.join() === 'narrowing'); + t('a `Clause-②: yes` body owes the declaration face', owedFace(pull({ files: ['packages/runtime/src/x.ts'], body: 'Clause-②: yes\nMetadata-protocol: added' })).limbs.join() === 'declaration'); + t('a malformed `Clause-②:` line is listed, never dropped', owedFace(pull({ files: ['packages/runtime/src/x.ts'], body: 'Clause-②: maybe' })).limbs.join() === 'clause2-unreadable'); + t('⭐ the waiting label alone owes', owedFace(pull({ files: ['packages/runtime/src/x.ts'], body: 'Clause-②: no', labels: [{ name: WAITING_LABEL }] })).limbs.join() === 'label'); + t('⛔ an ordinary runtime diff declaring `Clause-②: no` owes NOTHING — no row', row({ files: ['packages/runtime/src/x.ts'], body: 'Clause-②: no' }) === null); + t('test files under the spec are no schema face (the suspect surface excepts them)', owedFace(pull({ files: ['packages/spec/src/data/field.test.ts'], body: '' })).limbs.length === 0); + t('a ready PR owes exactly as a draft does', row({ draft: false }).face.join() === row({}).face.join() && row({ draft: false }).draft === false); + + battery('the record: on this head (by whom, as stated), on an earlier head, none, unjudged'); + const done = row({ comments: [recordRow(HEAD)] }); + t('⭐ a record on this head ⇒ DONE, current, PASS as stated, by the `Reviewed-by:` session', done.status === 'DONE' && done.record.state === 'current' && done.record.verdict === 'PASS' && done.record.by === TRIAGE && done.trigger === null); + t('a FAIL record on this head is still DONE — the reviewer rendered; the delta stays theirs', row({ comments: [recordRow(HEAD, 'FAIL')] }).status === 'DONE'); + t('a record on the CARD thread counts as on the PR', row({ card: 25922, cardComments: [recordRow(HEAD)] }).status === 'DONE'); + const stale = row({ comments: [recordRow(HEAD2, 'PASS', 11)] }); + t('⭐ a record on an EARLIER head ⇒ stale, naming the earlier reviewer; the review is owed again', stale.record.state === 'stale' && stale.record.by === TRIAGE && stale.status !== 'DONE'); + t('no record at all ⇒ none', row({}).record.state === 'none'); + t('⛔ an unreadable thread is unjudged, never none', row({ comments: null }).record.state === 'unjudged'); + + battery('the triggers: (a) hand-off or label the same round, (b) a full round, (c) the lane seat'); + const fresh = '2026-10-28T12:30:00Z'; + t('⭐ (a) a hand-off naming the current head ⇒ TAKE the same round, even 30 minutes in', (() => { const r = row({ headCommittedAt: fresh, comments: [handoff(HEAD, '2026-10-28T12:40:00Z')] }); return r.status === 'TAKE' && r.trigger === '(a) hand-off' && r.handoff.state === 'triage'; })()); + t('⭐ (a) the waiting label ⇒ TAKE the same round', (() => { const r = row({ headCommittedAt: fresh, labels: [{ name: WAITING_LABEL }] }); return r.status === 'TAKE' && r.trigger === '(a) label'; })()); + t('a hand-off on an EARLIER head is no (a) — stale', (() => { const r = row({ headCommittedAt: fresh, comments: [handoff(HEAD2, '2026-10-28T12:40:00Z')] }); return r.handoff.state === 'stale' && r.status === 'WAIT'; })()); + t('⭐ (b) the head has waited a full triage round with no record and no claim ⇒ TAKE', (() => { const r = row({ headCommittedAt: '2026-10-28T11:59:00Z' }); return r.status === 'TAKE' && r.trigger === '(b) waited 61 min'; })()); + t('under a full round, nothing else holding ⇒ WAIT (the lane\'s own), trigger none yet', (() => { const r = row({ headCommittedAt: '2026-10-28T12:01:00Z' }); return r.status === 'WAIT' && r.trigger === 'none yet' && r.waited === 59; })()); + t('⭐ (c) the lane seat stood down, or absent from the roster ⇒ TAKE', row({ headCommittedAt: fresh }, [{ lane: 'domain:ui', status: 'paused' }]).trigger === '(c) lane seat stood-down' && row({ headCommittedAt: fresh }, [{ lane: 'domain:skills', status: 'seated' }]).trigger === '(c) lane seat absent'); + t('an unread roster or a card with no lane judges no (c)', row({ headCommittedAt: fresh }, null).laneSeat === 'unknown' && row({ headCommittedAt: fresh, cardLabels: [] }).laneSeat === 'unknown'); + t('an unknown commit time judges no (b); a PR opened after its head commit waits from its opening', row({ headCommittedAt: null }).waited === null && row({ headCommittedAt: null }).status === 'WAIT' && row({ headCommittedAt: '2026-10-28T07:30:00Z', created_at: '2026-10-28T12:30:00Z' }).waited === 30); + + battery('a live claim holds the head and a record settles it; a lapsed claim holds nothing'); + t('⭐ a live claim on the current head ⇒ HELD, naming the holder — whatever trigger holds', (() => { const r = row({ labels: [{ name: WAITING_LABEL }], comments: [claimRow(TRIAGE, HEAD, '2026-10-28T12:18:56Z', `triage#1 · os-zhuang · ${TRIAGE}`)] }); return r.status === 'HELD' && r.claim.holder.session === TRIAGE && r.trigger === null; })()); + t('⭐ the lane wins a tie: triage first, lane later ⇒ the lane holds', (() => { const r = row({ comments: [claimRow(TRIAGE, HEAD, '2026-10-28T12:18:56Z', `triage#1 · os-zhuang · ${TRIAGE}`), { ...claimRow(LANE, HEAD, '2026-10-28T12:20:00Z', `domain:ui#1 · os-bill · ${LANE}`), id: 2 }] }); return r.status === 'HELD' && r.claim.holder.session === LANE && r.claim.tie === true; })()); + t('a record on the head settles it even beside a live claim ⇒ DONE', row({ comments: [claimRow(TRIAGE, HEAD, '2026-10-28T12:18:56Z', `triage#1 · os-zhuang · ${TRIAGE}`), recordRow(HEAD)] }).status === 'DONE'); + t('⛔ a claim two hours old with no record holds nothing ⇒ the trigger reads again', (() => { const r = row({ comments: [claimRow(TRIAGE, HEAD, '2026-10-28T10:30:00Z', `triage#1 · os-zhuang · ${TRIAGE}`)] }); return r.claim.state === 'lapsed' && r.status === 'TAKE'; })()); + t('⛔ a claim on an earlier head holds nothing — the head moved', row({ comments: [claimRow(TRIAGE, HEAD2, '2026-10-28T12:50:00Z', `triage#1 · os-zhuang · ${TRIAGE}`)] }).claim.state === 'none'); + + battery('the live instance: two Tier H drafts with a hand-off on the current head list as TAKE under (a)'); + // Modelled on the two drafts that waited about five rounds: a root workflow step deleted (Tier H, draft, + // hand-off on the current head since 07:43Z) and a published package retired as a narrowing (draft, its + // first hand-off stale after a merge-forward, renewed at 12:13Z on the new head). + const ci = pull({ number: 27136, comments: [handoff(HEAD, '2026-10-28T07:43:43Z')], labels: [{ name: WAITING_LABEL }] }); + const ai = pull({ number: 27042, head: { sha: HEAD2, ref: 'claude/project-thread-26wk5s' }, files: ['ui/packages/plugin-ai/package.json', 'pnpm-lock.yaml'], body: 'Clause-②: no (narrowing)\nMetadata-protocol: narrowed', headCommittedAt: '2026-10-28T07:34:00Z', labels: [{ name: WAITING_LABEL }], comments: [{ ...handoff('7040d92f78f504b1ef037d8451106c0be452f375', '2026-10-28T03:10:38Z'), id: 6459640213 }, { ...handoff(HEAD2, '2026-10-28T12:14:25Z'), id: 6468695894 }] }); + const live = owedReviewRows({ repo: DEFAULT_REPO, pulls: [ci, ai], seats: SEATED }, { now: Date.parse('2026-10-28T12:15:00Z') }); + t('⭐ both list, both TAKE, both under (a) by their hand-off line — drafts, the case the rebuilt scan skipped', live.length === 2 && live.every((r) => r.status === 'TAKE' && r.trigger === '(a) hand-off' && r.draft === true)); + t('the workflow draft owes on the rules face, the retirement on the narrowing face and the label', live.find((r) => r.pr === 27136).face.join('+') === 'rules+label' && live.find((r) => r.pr === 27042).face.join('+') === 'narrowing+label'); + t('the renewed hand-off governs: the newest line names the current head', live.find((r) => r.pr === 27042).handoff.id === 6468695894); + t('once the triage seat posts its claim, both read HELD and nobody else starts', owedReviewRows({ repo: DEFAULT_REPO, pulls: [{ ...ci, comments: [...ci.comments, claimRow(TRIAGE, HEAD, '2026-10-28T12:18:56Z', `triage#1 · os-zhuang · ${TRIAGE}`)] }], seats: SEATED }, { now: Date.parse('2026-10-28T12:20:00Z') })[0].status === 'HELD'); + + battery('the live read: every read through one reader; a failed read is INCOMPLETE, never clean'); + const paths = []; + const fake = (routes) => (path) => { + paths.push(path); + // The LONGEST matching route answers, so a card's thread is never answered by the card itself. + const key = Object.keys(routes).filter((k) => path.startsWith(k)).sort((a, b) => b.length - a.length)[0]; + if (key === undefined) return { ok: false, error: `no route for ${path}` }; + return routes[key]; + }; + const owing = { number: 27136, draft: true, body: 'Part of #25922\nClause-②: no', head: { sha: HEAD, ref: 'x' }, labels: [] }; + const quiet = { number: 27200, draft: false, body: 'Fixes #27100\nClause-②: no', head: { sha: HEAD2, ref: 'claude/issue-27100-x' }, labels: [] }; + const routes = { + '/repos/objectstack-ai/objectstack/pulls?': { ok: true, value: [owing, quiet] }, + '/repos/objectstack-ai/objectstack/issues?': { ok: true, value: [{ number: 1, title: '[PM seat] domain:ui — 🟢 os-bill', body: '' }] }, + '/repos/objectstack-ai/objectstack/pulls/27136/files': { ok: true, value: [{ filename: '.github/workflows/ci.yml' }] }, + '/repos/objectstack-ai/objectstack/pulls/27200/files': { ok: true, value: [{ filename: 'packages/runtime/src/x.ts' }] }, + '/repos/objectstack-ai/objectstack/issues/27136/comments': { ok: true, value: [handoff(HEAD, '2026-10-28T07:43:43Z')] }, + '/repos/objectstack-ai/objectstack/issues/25922': { ok: true, value: { labels: [{ name: 'domain:ui' }] } }, + '/repos/objectstack-ai/objectstack/issues/25922/comments': { ok: true, value: [] }, + '/repos/objectstack-ai/objectstack/commits/': { ok: true, value: { commit: { committer: { date: '2026-10-28T07:30:00Z' } } } }, + }; + const snap = readSnapshot({ repo: DEFAULT_REPO, board: DEFAULT_REPO, get: fake(routes) }); + t('the snapshot holds both pulls, the seat roster parsed, no gap', snap.pulls.length === 2 && snap.seats.length === 1 && snap.seats[0].lane === 'domain:ui' && snap.gaps.length === 0, JSON.stringify([snap.pulls.length, snap.seats, snap.gaps])); + t('⭐ threads, card and commit are read ONLY for the pull that owes — the face is read first', paths.some((p) => p.startsWith('/repos/objectstack-ai/objectstack/issues/27136/comments')) && !paths.some((p) => p.startsWith('/repos/objectstack-ai/objectstack/issues/27200/comments')) && paths.some((p) => p.startsWith('/repos/objectstack-ai/objectstack/issues/25922/comments'))); + t('the card is the one the body delivers (`deliveredCardNumber`), and its lane label is read', snap.pulls[0].card === 25922 && snap.pulls[0].cardLabels.join() === 'domain:ui'); + const broken = readSnapshot({ repo: DEFAULT_REPO, board: DEFAULT_REPO, get: fake({ ...routes, '/repos/objectstack-ai/objectstack/issues/27136/comments': { ok: false, error: 'HTTP 502' } }) }); + t('⛔ a failed thread read is a GAP naming the PR and the read, and the record reads unjudged — never none', broken.gaps.some((g) => g.startsWith('#27136: comments')) && owedReviewRows(broken, { now: NOW })[0].record.state === 'unjudged'); + const noList = readSnapshot({ repo: DEFAULT_REPO, board: DEFAULT_REPO, get: fake({}) }); + t('⛔ a failed pull list is a gap and zero rows — never an empty board reported clean', noList.pulls.length === 0 && noList.gaps.length === 1 && renderReport([], { repo: DEFAULT_REPO, now: NOW, read: { pulls: 0, drafts: 0 }, gaps: noList.gaps }).some((l) => l.includes('sweep INCOMPLETE'))); + const noSeats = readSnapshot({ repo: DEFAULT_REPO, board: DEFAULT_REPO, get: fake({ ...routes, '/repos/objectstack-ai/objectstack/issues?': { ok: false, error: 'HTTP 403' } }) }); + t('an unread seat roster is a gap and judges no (c)', noSeats.seats === null && noSeats.gaps.some((g) => g.includes('trigger (c) is not judged'))); + + battery('the report: one line per owing PR, TAKE first, exit 0'); + const printed = []; + const code = main(['--repo', DEFAULT_REPO, '--now', '2026-10-28T13:00:00Z'], { get: fake(routes), log: (l) => printed.push(l), error: (l) => printed.push(l) }); + t('⭐ exit 0, and the one owing PR prints as one TAKE line under (a)', code === EXIT_OK && printed.filter((l) => /^(TAKE|WAIT|HELD|DONE) {2}#/.test(l)).length === 1 && printed.some((l) => l.startsWith('TAKE #27136 draft') && l.includes('→ (a) hand-off'))); + t('the header counts what was read and what is owed', printed[0].includes('2 open PR(s) read (1 draft)') && printed[0].includes('1 owe a contract review — 1 to take')); + t('rows sort TAKE, WAIT, HELD, DONE, then by number', owedReviewRows({ pulls: [pull({ number: 3, comments: [recordRow(HEAD)] }), pull({ number: 2, headCommittedAt: '2026-10-28T12:59:00Z' }), pull({ number: 1, labels: [{ name: WAITING_LABEL }] })], seats: SEATED }, { now: NOW }).map((r) => `${r.status}${r.pr}`).join() === 'TAKE1,WAIT2,DONE3'); + const jsonOut = []; + t('--json prints the rows as one JSON document, still exit 0', main(['--repo', DEFAULT_REPO, '--json', '--now', '2026-10-28T13:00:00Z'], { get: fake(routes), log: (l) => jsonOut.push(l) }) === EXIT_OK && JSON.parse(jsonOut.join('\n')).rows[0].pr === 27136); + t('a usage error exits 2 and prints the usage', main(['--bogus'], { get: fake({}), log: () => {}, error: () => {} }) === EXIT_USAGE); + + // Floor: the opened set equals the declared set, each battery at or above its pin. + const declared = Object.keys(SELF_TEST_BATTERIES); + if (declared.length < SELF_TEST_BATTERY_FLOOR) cases.push({ name: `SELF_TEST_BATTERIES declares ${declared.length} batteries, below the pinned ${SELF_TEST_BATTERY_FLOOR}`, ok: false }); + for (const [name, n] of seen) if (!declared.includes(name)) cases.push({ name: `battery "${name}" registered ${n} case(s) but is not declared`, ok: false }); + for (const name of declared) { + const n = seen.get(name) ?? 0; + if (n < SELF_TEST_BATTERIES[name]) cases.push({ name: `battery "${name}" registered ${n} case(s), below its floor of ${SELF_TEST_BATTERIES[name]} — cases stopped running`, ok: false }); + } + + const failed = cases.filter((c) => !c.ok); + for (const c of failed) console.error(` ✗ ${c.name}${c.detail ? ` — ${c.detail}` : ''}`); + if (failed.length > 0) { + console.error(`✗ check-owed-reviews self-test: ${failed.length} of ${cases.length} case(s) failed.`); + return 1; + } + console.log( + `✓ check-owed-reviews self-test: ${cases.length} cases pass in ${declared.length} batteries (the owed face from paths, ` + + 'the Clause-② line and the label, drafts included; the record on this head, an earlier head, none, unjudged; ' + + 'the (a) / (b) / (c) triggers; claims holding and lapsing, the lane winning a tie; the two live drafts as TAKE; ' + + 'every read through one reader with failures as gaps; the report, exit 0).', + ); + selfTestReachedVerdict = true; + return 0; +} + +if (isEntrypoint(import.meta.url)) { + const argv = process.argv.slice(2); + if (argv.length === 1 && argv[0] === '--self-test') { + let code; + try { + code = selfTest(); + } catch (error) { + console.error(`\n✗ check-owed-reviews self-test threw: ${String(error?.stack ?? error)}\n`); + process.exit(1); + } + if (!selfTestReachedVerdict) { + console.error( + '\n✗ check-owed-reviews self-test: selfTest() returned without reaching its verdict,\n' + + 'so no success line was printed. Exiting 0 here would report a self-test\n' + + 'that never finished as a self-test that passed.\n', + ); + process.exit(1); + } + process.exit(code); + } + process.exit(main(argv)); +} diff --git a/scripts/pm/dispatch-gates.data.mjs b/scripts/pm/dispatch-gates.data.mjs index 349258342b6..1a725273c54 100644 --- a/scripts/pm/dispatch-gates.data.mjs +++ b/scripts/pm/dispatch-gates.data.mjs @@ -508,6 +508,14 @@ export const COMPOUND_ANCHOR_LEDGER = [ * 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`. + * + * ## The sixth row: the review claim line's spelling pin + * + * The same self-test reads the pm-dispatch references' `contract-review.md` + * to pin the `Contract-review-claim:` template and its two-hour lapse verbatim + * in the rule text — the same population claim, on a second governed file. The + * one `self-test-reads` line names both paths, so a card touching either 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 }), @@ -531,6 +539,11 @@ export const GOVERNED_READ_FLOOR = Object.freeze([ file: '.claude/skills/pm-dispatch/references/project-forms.md', declared: true, }), + Object.freeze({ + script: 'scripts/pm/record-recognisers.mjs', + file: '.claude/skills/pm-dispatch/references/contract-review.md', + declared: true, + }), ]); /** diff --git a/scripts/pm/record-recognisers.mjs b/scripts/pm/record-recognisers.mjs index ac892b8cdbf..78f2aca6cce 100644 --- a/scripts/pm/record-recognisers.mjs +++ b/scripts/pm/record-recognisers.mjs @@ -26,7 +26,7 @@ * reviewScopeHits the at-tier review's scope floor — `reviewFace`'s `hits`, the record's `Scope:` line * recordCarriesDesignJudgment whether a face's record carries item ④, the full review's design judgment * readLocalRuns the `Local-runs:` line, and `localRunsStand` on it — judged when present, absent not re-judged - * contractReviewHandoff whose review a PR's contract review is — the newest `Contract-review-handoff:` line against the head; `readContractReviewHandoff` reads the line + * contractReviewHandoff whose review a PR's contract review is — the newest `Contract-review-handoff:` line against the head; `readContractReviewHandoff` reads the line; `contractReviewClaims` (`readContractReviewClaim`, `contractReviewClaimLapse`) who holds the head * isModelIdentifierToken the token a refusal must never quote back * REVIEW_OF_RECORD_THREADS the ONE thread set, and REVIEW_OF_RECORD_LOCATION its words * unexplainedPathsBetween the pure-regeneration test a carried record re-runs, prose arm included @@ -69,12 +69,12 @@ * which exit — is the guard's. */ -// The self-test's one read of a GOVERNED file, declared so a card editing that +// The self-test's reads of GOVERNED files, declared so a card editing either // 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 +// dispatch-gates: self-test-reads .claude/skills/pm-dispatch/references/project-forms.md .claude/skills/pm-dispatch/references/contract-review.md -- the mailbox doorbell battery pins each of the seven key templates verbatim in project-forms.md's 〈信箱 PR〉 section, and the claim-line battery pins the `Contract-review-claim:` template and its lapse hours in contract-review.md, so a change to either spelling there is judged here import process from 'node:process'; import { spawnSync } from 'node:child_process'; @@ -91,6 +91,8 @@ import { deliveryEvidenceNote, latestMarkedComment, partOfTargets, + readSignatureLine, + SEAT_SIGNATURE_SESSION_SOURCE, stripSignatureLine, } from './check-half-states.mjs'; // The tier `isModelIdentifierToken` refuses by VALUE. IMPORTED, never restated: @@ -904,6 +906,154 @@ export function contractReviewHandoff(rows, headSha) { return { state: current ? 'triage' : 'stale', head: line.head, reason: line.reason, handedAt: line.at, ...stamp }; } +// --------------------------------------------------------------------------- +// The `Contract-review-claim:` line — one per head, so two reviewers never start on one +// --------------------------------------------------------------------------- + +/** + * The `Contract-review-claim:` key line -- who has STARTED the contract review + * of one head, posted by that reviewer before it starts, lane seat or triage. + * + * ⭐ The rule it makes machine-findable (the skill's contract-review reference, + * 〈复核归属与资格〉): the lane reviews its own; the triage seat takes a review + * only when nobody is doing it. Before starting, every reviewer reads the PR: + * a record or a LIVE claim on the current head means do not start. Otherwise it + * posts ONE comment carrying this line first, then reviews: + * + * Contract-review-claim: session_01AavokzJ5DndAwitDXvKy4U · head 3beee64fe77741d07f035f1b80eeaa07e7280ab4 · 2026-10-28T12:18Z + * + * Three facts, each required, separated by the doorbell's dot: the SESSION that + * reviews (the signature line's session shape, `SEAT_SIGNATURE_SESSION_SOURCE`, + * imported, ⛔ never a second spelling), the head as the FULL 40-hex sha -- + * the rule says full, because a claim is compared against a head that may move + * to a sibling sharing a short prefix -- and the UTC time to the minute (seconds + * allowed). Backtick wrapping on any value reads; a line missing a fact, or + * with a fourth part, is started-and-unreadable, and an unreadable claim is NO + * claim: it holds no head. + * + * A claim LAPSES (`contractReviewClaimLapse`) when the head moves, or + * {@link CONTRACT_REVIEW_CLAIM_LAPSE_HOURS} hours after it was posted -- the + * reviewer died -- after which the next reviewer may claim. A record on the + * head settles the review whatever any claim says; that is the caller's read + * (`locateReviewOfRecord`), never this one's. When a lane claim and a triage + * claim are both live on one head the LANE wins: triage withdraws by replying + * once and discards its subagent's work (`contractReviewClaims` names the + * holder). + * + * ⛔ No gate reads it. The queue guard keeps reading the ONE `## Contract + * review` record; this line is coordination between reviewers, never a merge + * condition. `contractReviewClaims` below is the reading a seat or the + * owed-review list (`check-owed-reviews.mjs`) takes. + */ +export const CONTRACT_REVIEW_CLAIM_KEY = 'Contract-review-claim'; +/** + * The line exactly as the rule text prints it, placeholders included. This file's self-test pins it + * VERBATIM in `references/contract-review.md`, so the text and the reader cannot drift apart. + */ +export const CONTRACT_REVIEW_CLAIM_TEMPLATE = `${CONTRACT_REVIEW_CLAIM_KEY}: · head · `; +/** Hours after posting that a claim with no record on its head lapses -- the rule's 「2 小时」. */ +export const CONTRACT_REVIEW_CLAIM_LAPSE_HOURS = 2; +const CONTRACT_REVIEW_CLAIM_LINE = keyLineRegex(CONTRACT_REVIEW_CLAIM_KEY); +const CLAIM_SESSION = new RegExp(`^\`?(${SEAT_SIGNATURE_SESSION_SOURCE})\`?$`); +const CLAIM_HEAD = /^head[ \t]+`?([0-9a-fA-F]{40})`?$/; +const CLAIM_TIME = /^`?(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?Z)`?$/; + +/** + * What one comment declares as a claim on a contract review. The FIRST claim + * line of the comment is read, after one leading signature line is set aside + * (`stripSignatureLine`, as every reader here does). + * + * @returns {{ state: 'missing' } + * | { state: 'unreadable', line: string } + * | { state: 'read', session: string, head: string, time: string, at: number }} + */ +export function readContractReviewClaim(text) { + for (const line of stripSignatureLine(String(text ?? '')).split(/\r?\n/)) { + const m = CONTRACT_REVIEW_CLAIM_LINE.exec(line); + if (!m) continue; + const unreadable = { state: 'unreadable', line: quoteLine(line) }; + // Leading decoration is the key's (`stripValueDecoration`); trailing spaces and bold are the line's. + const parts = stripValueDecoration(m[1]).replace(/(?:[ \t]|\*\*)+$/, '').split(MAILBOX_SEPARATOR); + if (parts.length !== 3) return unreadable; + const session = CLAIM_SESSION.exec(parts[0]); + const head = CLAIM_HEAD.exec(parts[1]); + const time = CLAIM_TIME.exec(parts[2]); + const at = time ? Date.parse(time[1]) : Number.NaN; + if (!session || !head || !Number.isFinite(at)) return unreadable; + return { state: 'read', session: session[1], head: head[1].toLowerCase(), time: time[1], at }; + } + return { state: 'missing' }; +} + +/** + * Is this claim still holding its head? Pure arithmetic, no thread read. + * + * live it names the current head and was posted under the lapse window ago + * head-moved it names another head -- the head moved past it + * expired it names the current head and was posted the lapse window ago or more + * + * `postedAt` is the comment's own `created_at` when the caller holds it -- the + * platform's stamp of when the claim was POSTED, which the rule times from -- + * else the time the line itself states. A head is matched in FULL (both + * lowercased): the claim spells the full sha, and so does a pull row's head. + * + * @param {{ head: string, at: number }} claim a `read` claim + * @param {{ headSha: string, now: number, postedAt?: number }} context + * @returns {'live'|'head-moved'|'expired'} + */ +export function contractReviewClaimLapse(claim, { headSha, now, postedAt } = {}) { + if (String(claim?.head ?? '').toLowerCase() !== String(headSha ?? '').toLowerCase()) return 'head-moved'; + const posted = Number.isFinite(postedAt) ? postedAt : claim?.at; + return now - posted >= CONTRACT_REVIEW_CLAIM_LAPSE_HOURS * 3600 * 1000 ? 'expired' : 'live'; +} + +/** + * Who holds this pull request's current head, read off its comment thread: + * + * live a live claim names the current head -- `holder` is the claim to respect: the LANE's + * when a lane claim and a triage claim are both live (`tie` true), else the earliest posted + * lapsed claims named the current head, and every one has expired + * none no claim names the current head (claims on earlier heads lapsed when it moved) + * unreadable the thread, the head or the clock could not be read + * + * A claim's SIDE is read off its comment's signature line (`readSignatureLine`): the triage seat + * signs `triage#N · …`, and every other claim -- a lane seat's, a cross-account reviewer's, an + * unsigned one -- is the `lane` side, which is the side the tie rule protects. Unreadable claim + * lines are returned by comment id and hold nothing. + * + * @param {{ id?: number, body?: string, created_at?: string }[]} rows the PR's comment rows + * @param {string} headSha the pull request's current head, full + * @param {{ now: number }} clock milliseconds since the epoch + */ +export function contractReviewClaims(rows, headSha, { now } = {}) { + const gaps = []; + if (!Array.isArray(rows)) gaps.push("the pull request's comment thread"); + const head = String(headSha ?? '').toLowerCase(); + if (!/^[0-9a-f]{40}$/.test(head)) gaps.push("the pull request's full head sha"); + if (!Number.isFinite(now)) gaps.push('the clock the lapse is measured on'); + if (gaps.length > 0) return { state: 'unreadable', gaps }; + const claims = []; + const unreadable = []; + for (const row of rows) { + const line = readContractReviewClaim(row?.body); + if (line.state === 'missing') continue; + if (line.state === 'unreadable') { + unreadable.push({ id: row?.id ?? null, line: line.line }); + continue; + } + const stamped = Date.parse(row?.created_at ?? ''); + const postedAt = Number.isFinite(stamped) ? stamped : line.at; + const side = readSignatureLine(row?.body)?.lane === 'triage' ? 'triage' : 'lane'; + const lapse = contractReviewClaimLapse(line, { headSha: head, now, postedAt }); + claims.push({ id: row?.id ?? null, session: line.session, head: line.head, time: line.time, postedAt, side, lapse }); + } + const live = claims.filter((c) => c.lapse === 'live').sort((a, b) => a.postedAt - b.postedAt); + const tie = live.some((c) => c.side === 'lane') && live.some((c) => c.side === 'triage'); + const holder = live.find((c) => c.side === 'lane') ?? live[0] ?? null; + const state = holder !== null ? 'live' : claims.some((c) => c.lapse === 'expired') ? 'lapsed' : 'none'; + return { state, holder, tie, claims, unreadable }; +} + // --------------------------------------------------------------------------- // The mailbox DOORBELL — seven first-line keys, a CLOSED vocabulary // --------------------------------------------------------------------------- @@ -1748,6 +1898,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ '⭐ 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, '⭐ the signature line: the reader reads LANE#N · ACCOUNT · SESSION exactly, and one leading signature changes no first-line reading': 22, + '⭐ the `Contract-review-claim:` line — session, FULL head, time; lapses on a moved head or after two hours; the lane wins a tie': 21, }); // DELETING an entry silences that battery's floor exactly as effectively as @@ -1764,7 +1915,8 @@ const SELF_TEST_BATTERIES = Object.freeze({ // Raised 13 → 14 with the mailbox doorbell battery (the maintainer's per-seat mailbox and its closed // vocabulary): one battery, one row. // Raised 14 → 15 with the signature-line battery (the maintainer's signed-post rule): one battery, one row. -const SELF_TEST_BATTERY_FLOOR = 15; +// Raised 15 → 16 with the claim-line battery (the maintainer's one-claim-per-head rule): one battery, one row. +const SELF_TEST_BATTERY_FLOOR = 16; // 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 @@ -2150,6 +2302,47 @@ 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 `Contract-review-claim:` line ---------------------------------------- + // + // The maintainer's rule: the lane reviews its own, triage takes only a review + // nobody is doing, and one claim line per head keeps two reviewers off one + // head. These cases read the line as the triage seat really posted it, with + // and without the signature above it, refuse every shape that names no head + // or no time, and pin the lapse arithmetic and the lane-wins tie. + battery('⭐ the `Contract-review-claim:` line — session, FULL head, time; lapses on a moved head or after two hours; the lane wins a tie'); + const CL_HEAD = '3beee64fe77741d07f035f1b80eeaa07e7280ab4'; + const CL_SESSION = 'session_01AavokzJ5DndAwitDXvKy4U'; + const CL_LANE_SESSION = 'session_01JQwxbZeBv2y91towxasSUT'; + const CL_TRIAGE_SIG = `triage#1 · os-zhuang · ${CL_SESSION}`; + const CL_LANE_SIG = `domain:ui#1 · os-bill · ${CL_LANE_SESSION}`; + const CL_LINE = (session = CL_SESSION, head = CL_HEAD, time = '2026-10-28T12:18Z') => `${CONTRACT_REVIEW_CLAIM_KEY}: \`${session}\` · head \`${head}\` · ${time}`; + // The comment exactly as the triage seat posted it on the first draft it took (signature, line, prose). + const CL_BODY = (sig = CL_TRIAGE_SIG, line = CL_LINE()) => `${sig === null ? '' : `${sig}\n\n`}${line}\n\nTriage seat, taking the hand-off that names this head.`; + const CL_ROW = (body, id = 6468788431, at = '2026-10-28T12:18:56Z') => ({ id, created_at: at, body }); + const CL_NOW = Date.parse('2026-10-28T13:00:00Z'); + const CL_J = (v) => JSON.stringify(v); + t('⭐ the live specimen reads: the session, the FULL head and the minute, behind the signature line', (() => { const r = readContractReviewClaim(CL_BODY()); return r.state === 'read' && r.session === CL_SESSION && r.head === CL_HEAD && r.time === '2026-10-28T12:18Z' && r.at === Date.parse('2026-10-28T12:18:00Z'); })()); + t('⭐ with and without the signature line the reading is the same, byte for byte', CL_J(readContractReviewClaim(CL_BODY())) === CL_J(readContractReviewClaim(CL_BODY(null))) && readContractReviewClaim(CL_BODY(null)).state === 'read'); + t('decoration reads: bare values, a bulleted bolded key, a blockquoted line, seconds on the time', [`${CONTRACT_REVIEW_CLAIM_KEY}: ${CL_SESSION} · head ${CL_HEAD} · 2026-10-28T12:18Z`, `- **${CONTRACT_REVIEW_CLAIM_KEY}:** \`${CL_SESSION}\` · head \`${CL_HEAD}\` · \`2026-10-28T12:18Z\``, `> ${CL_LINE()}`, CL_LINE(CL_SESSION, CL_HEAD, '2026-10-28T12:18:30Z')].every((l) => readContractReviewClaim(l).state === 'read' && readContractReviewClaim(l).head === CL_HEAD)); + t('the head is read lowercased, so an uppercased spelling holds the same head', readContractReviewClaim(CL_LINE(CL_SESSION, CL_HEAD.toUpperCase())).head === CL_HEAD); + t('⛔ an ABBREVIATED head is unreadable — the rule says full, and a short prefix can name a sibling head', readContractReviewClaim(CL_LINE(CL_SESSION, CL_HEAD.slice(0, 8))).state === 'unreadable'); + t('⛔ a missing fact, a fourth part, a non-UTC or impossible time, a session of another shape: unreadable, never a reading', [`${CONTRACT_REVIEW_CLAIM_KEY}: ${CL_SESSION} · head ${CL_HEAD}`, `${CL_LINE()} · round 3`, CL_LINE(CL_SESSION, CL_HEAD, '2026-10-28 12:18'), CL_LINE(CL_SESSION, CL_HEAD, '2026-10-28T12:18+08:00'), CL_LINE(CL_SESSION, CL_HEAD, '2026-13-45T12:18Z'), CL_LINE('domain:skills#2', CL_HEAD), CL_LINE('session_x', CL_HEAD), `${CONTRACT_REVIEW_CLAIM_KEY}:`].every((l) => readContractReviewClaim(l).state === 'unreadable')); + t('⛔ the TEMPLATE copied unedited is unreadable — a placeholder holds no head', readContractReviewClaim(CONTRACT_REVIEW_CLAIM_TEMPLATE).state === 'unreadable' && CONTRACT_REVIEW_CLAIM_TEMPLATE === 'Contract-review-claim: · head · '); + t('the unreadable line is quoted back, key included', readContractReviewClaim(`${CONTRACT_REVIEW_CLAIM_KEY}: soon`).line.includes(CONTRACT_REVIEW_CLAIM_KEY)); + 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_CLAIM_LINE.source === keyLineRegex(CONTRACT_REVIEW_CLAIM_KEY).source && readContractReviewClaim(`triage posts the ${CONTRACT_REVIEW_CLAIM_KEY}: line first`).state === 'missing' && readContractReviewClaim('').state === 'missing'); + t('the FIRST claim line of a comment is the one read', readContractReviewClaim(`${CL_LINE(CL_SESSION, CL_HEAD)}\n${CL_LINE(CL_LANE_SESSION, CL_HEAD)}`).session === CL_SESSION); + t('⭐ lapse: the current head under two hours ⇒ live; two hours or more ⇒ expired; another head ⇒ head-moved', (() => { const c = readContractReviewClaim(CL_LINE()); const at = c.at; return contractReviewClaimLapse(c, { headSha: CL_HEAD, now: at + 119 * 60000 }) === 'live' && contractReviewClaimLapse(c, { headSha: CL_HEAD, now: at + 120 * 60000 }) === 'expired' && contractReviewClaimLapse(c, { headSha: 'f'.repeat(40), now: at }) === 'head-moved' && CONTRACT_REVIEW_CLAIM_LAPSE_HOURS === 2; })()); + t('the lapse is timed from when the comment was POSTED when that stamp is held, else from the line\'s own time', (() => { const c = readContractReviewClaim(CL_LINE()); const posted = c.at + 90 * 60000; return contractReviewClaimLapse(c, { headSha: CL_HEAD, now: c.at + 150 * 60000, postedAt: posted }) === 'live' && contractReviewClaimLapse(c, { headSha: CL_HEAD, now: c.at + 150 * 60000 }) === 'expired'; })()); + t('⭐ the thread: a live claim on the current head holds it — named by session, comment and side', (() => { const r = contractReviewClaims([CL_ROW(CL_BODY())], CL_HEAD, { now: CL_NOW }); return r.state === 'live' && r.holder.session === CL_SESSION && r.holder.id === 6468788431 && r.holder.side === 'triage' && r.tie === false; })()); + t('⭐ the LANE wins a tie: a triage claim posted FIRST and a lane claim posted later, both live ⇒ the lane holds, tie named', (() => { const r = contractReviewClaims([CL_ROW(CL_BODY(), 1, '2026-10-28T12:18:56Z'), CL_ROW(CL_BODY(CL_LANE_SIG, CL_LINE(CL_LANE_SESSION, CL_HEAD, '2026-10-28T12:20Z')), 2, '2026-10-28T12:20:10Z')], CL_HEAD, { now: CL_NOW }); return r.state === 'live' && r.tie === true && r.holder.side === 'lane' && r.holder.session === CL_LANE_SESSION; })()); + t('two claims on one side: the EARLIEST posted holds, and no tie is declared', (() => { const r = contractReviewClaims([CL_ROW(CL_BODY(CL_LANE_SIG, CL_LINE(CL_LANE_SESSION)), 2, '2026-10-28T12:30:00Z'), CL_ROW(CL_BODY(null, CL_LINE(CL_SESSION)), 1, '2026-10-28T12:10:00Z')], CL_HEAD, { now: CL_NOW }); return r.holder.id === 1 && r.tie === false; })()); + t('an UNSIGNED claim is the lane side — only a `triage#N` signature makes a claim triage\'s', contractReviewClaims([CL_ROW(CL_BODY(null))], CL_HEAD, { now: CL_NOW }).holder.side === 'lane'); + t('⭐ the head MOVED past every claim ⇒ none; every claim on this head expired ⇒ lapsed; nothing holds either way', (() => { const moved = contractReviewClaims([CL_ROW(CL_BODY())], 'a'.repeat(40), { now: CL_NOW }); const old = contractReviewClaims([CL_ROW(CL_BODY())], CL_HEAD, { now: Date.parse('2026-10-28T14:19:00Z') }); return moved.state === 'none' && moved.holder === null && old.state === 'lapsed' && old.holder === null && old.claims[0].lapse === 'expired'; })()); + t('an unreadable claim line holds nothing and is returned by comment id', (() => { const r = contractReviewClaims([CL_ROW(`${CL_TRIAGE_SIG}\n${CL_LINE(CL_SESSION, CL_HEAD.slice(0, 8))}`, 77)], CL_HEAD, { now: CL_NOW }); return r.state === 'none' && r.unreadable.length === 1 && r.unreadable[0].id === 77; })()); + t('a thread that is not an array, an abbreviated head, or no clock is a read that could not be made', contractReviewClaims(null, CL_HEAD, { now: CL_NOW }).state === 'unreadable' && contractReviewClaims([], CL_HEAD.slice(0, 12), { now: CL_NOW }).state === 'unreadable' && contractReviewClaims([], CL_HEAD, {}).state === 'unreadable' && contractReviewClaims([], CL_HEAD, { now: CL_NOW }).state === 'none'); + t('⛔ no gate reads it: a claim beside a record on this head leaves the record reading FOUND, and the hand-off reading is untouched', (() => { const rec = locateReviewOfRecord(pairOf({ prComments: [RECORD_ON_9AF9, CL_ROW(CL_BODY(CL_TRIAGE_SIG, CL_LINE(CL_SESSION, HEAD_9AF9.padEnd(40, '0'))))] })); return rec.state === 'found' && rec.id === 3301 && contractReviewHandoff([CL_ROW(CL_BODY())], CL_HEAD).state === 'absent'; })()); + t('⭐ the line is spelled VERBATIM, in a code span, in the rule text (`references/contract-review.md`), beside its two-hour lapse', (() => { const rules = readFileSync(new URL('../../.claude/skills/pm-dispatch/references/contract-review.md', import.meta.url), 'utf8'); return rules.includes(`\`${CONTRACT_REVIEW_CLAIM_TEMPLATE}\``) && rules.includes(`${CONTRACT_REVIEW_CLAIM_LAPSE_HOURS} 小时`); })()); + // -- the mailbox doorbell ----------------------------------------------------- // // One mailbox PR per seat, and a CLOSED vocabulary on it (the maintainer's @@ -2496,7 +2689,8 @@ export async function selfTest() { + '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; 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 ' + + 'an eighth key, a second line and an over-long line refused; the review claim line — read signed and unsigned, ' + + 'spelled verbatim in the rule text, lapsing on a moved head or after two hours, the lane winning a tie; and the ONE ' + 'thread set, driven through the queue guard itself).', );