diff --git a/.claude/skills/pm-dispatch/references/contract-review.md b/.claude/skills/pm-dispatch/references/contract-review.md index 60fb3d4c5ad..91ff51dfc0a 100644 --- a/.claude/skills/pm-dispatch/references/contract-review.md +++ b/.claude/skills/pm-dispatch/references/contract-review.md @@ -26,9 +26,11 @@ - 车道或分诊起审前读 PR:现 head 有记录或活认领 ⇒ ⛔ 不起;否则先落认领行,一 head 一行: - `Contract-review-claim: · head · ` - 认领随 head 移动或贴后 2 小时无记录失效;同 head 双认 ⇒ 车道胜,分诊回帖撤回、弃产出。 +- 车道暂缓本 head 送审落 `Contract-review-hold: · head · · until ` +- 新 head 即清;点名现 head 时 (b) 不起;条件满足由持有车道挂等复核标记或落交接行。 +- 逾 24 小时条件未满的 hold 列入分诊轮次记录(`check-owed-reviews.mjs` 标出)。 - 复核记录 = 一条评论落 PR 或卡,席内与子代理同形;散文、dev 自评、`os-dev-report` 恒不算。 -- 同形 = `## Contract review` 题头、head sha 独占码段、①②③ 逐项(申报面外三面加 ④)、 - 独立性对、PASS/FAIL。 +- 同形 = `## Contract review` 题头、head sha 独占码段、①②③ 逐项、独立性对、PASS/FAIL。 - 同形含首行 `Served-tier:`:值写该面所定档的常量名 `CONTRACT_REVIEW_TIER` 或 `TIER_DEFAULT`; 无此行不成裁决;模板 `node scripts/pm/record-recognisers.mjs --template`,申报面加 `--face declaration`。 - 同形带 `Local-runs:` 行:`none` 或 `probe — REASON` 具名唯一探针;有行必校,缺行旧记录不改判。 @@ -40,13 +42,11 @@ - 实质 head 各欠一轮完整复核;只落发现的 commit 由同一复核者 delta 复核;无 must-fix 即止。 - 另记作者:PR `user.login` 非 `objectstack-fleet[bot]` ⇒ ③ 注点名该 login(脱中继写),落地前读。 - 简报从 `record-recognisers.mjs --brief-template` 起稿:只读条款与输入集内建,⛔ 不删 ⛔ 行。 -- 隔离复核子代理暂存全写按所审 PR 命名的 `/pr-/`,⛔ 不读非本轮自写的暂存。 - 独立性对:`Implemented-by:`/`Reviewed-by:` 取值同见 `--template`;子代理记采纳它的席位。 - ④ 恒由起草者之外的 agent 达档跑,起草已达档不免;喂入同上,⛔ 不喂起草者的结论。 - 轮次报告设复审清单专节,与呈裁清单同为强制审计。 - 请审/求批与落地前检两条,过则 Tier S 入队、Tier H 等人批:① 现 head 同形达档 PASS 在案。 -- 例外:纯重生成或只动上列散文面的 head 后移原记录继续管;判据机读已提交树; - PR 落 `Regen-provenance:` 行。 +- 例外:纯重生成或只动上列散文面的 head 后移原记录继续管;PR 落 `Regen-provenance:` 行。 - ② PR check 全绿,⛔ 非 required 子集;例外:merge-base 同签名的红不计、按设计而红见 SKILL.md。 - 签名 = 失败步 + 首错行,读 base check runs 的 API ⛔ 不凭口述;主干红止血立单不变。 - 落地时仍欠复核的 PR 在落地 head 复审,车道席或分诊按 (a)(b)(c) 接;FAIL ⛔ 不修已关 PR。 @@ -57,4 +57,4 @@ - 只作等复核标记,⛔ 不是闸门;单载体 = PR,卡上副本在规则外、不是要求。 - 挂:ACCEPT 时条款②任一肢命中而现 head 无同形 PASS 在案 ⇒ 派发席同笔挂于 PR。 - 摘:现 head 同形 PASS 在案 ⇒ 写记录的席同笔摘;PR 合并或关闭 ⇒ 派发席摘;FAIL 不摘。 -- ⛔ 无 check、workflow 或队列守卫读标记与认领行;入队只认同形记录,标记有无不改判。 +- ⛔ 无 check、workflow 或队列守卫读标记与认领/hold 行;入队只认同形记录,标记有无不改判。 diff --git a/.claude/skills/pm-dispatch/references/triage-duties.md b/.claude/skills/pm-dispatch/references/triage-duties.md index 720d97ec08e..c6a4ac159d2 100644 --- a/.claude/skills/pm-dispatch/references/triage-duties.md +++ b/.claude/skills/pm-dispatch/references/triage-duties.md @@ -1,14 +1,13 @@ # 分诊座位职责 -分诊席每次 fire 开局读本文;本文是分诊座位的全部职责。 - +- 本文与技能他处点名分诊之条(复审、摘 `pm:retriage`、约定③ 等)即分诊座位的全部职责。 - fire 开局只按名加载互斥检查所需工具,`ToolSearch` 用 `select:`;判定本轮有活才加载其余。 - ⛔ 分诊 fresh session 开局不做泛关键词 ToolSearch;可验判据:空转轮 ~4 万 token 以内。 - 两级盘点:小时轮以 `since` 窗口读增量,锚 = 座位贴上一份收班简报的时间戳。 - 查重缓存住席内:新会话全量拉,其后 `since` 增量;⛔ 不建分支、不共享、不依赖备份。 - 每日一 fire 跑两仓全量对账并归集日频职责;选层按 fire 时刻 ⛔ 不用计数器,简报写明层。 - 每条枚举比对返回数与 `totalCount`,不等 ⇒ 报 `sweep INCOMPLETE` 点名缺口,⛔ 永不报干净。 -- 复审:每轮先跑 `check-owed-reviews.mjs`(open PR 含 draft,读交接与认领行),欠审同轮认领后接。 +- 复审:每轮先跑 `check-owed-reviews.mjs`(open PR 含 draft,读交接/认领/hold 行),欠审同轮认领后接。 - 隔离子代理达档跑同形记录、署本席;本席也被配额拒 ⇒ PR 写一次留标,⛔ 永不降档审。 - 每轮比对自审记录 `Head-sha` 与现 head,越过即过期,纯重生成/只动散文面例外(判已提交树)。 - 自审 FAIL 过期 ⇒ 同轮达档重审新 head,逐条复查 must-fix,delta commit 当改动审。 @@ -25,6 +24,8 @@ - 排序:队列每轮清空时最新优先;清不空的那一刻起改最老优先。 - 裸卡数 >15 ⇒ 下一 fire 整轮改域分批集中模式,每批 ≤5 张同族卡,照样跑到清空。 - 紧急卡直接分诊:维护者点名或 p0 嫌疑 ⇒ 立即起默认判断档分诊子代理,不等定时轮。 +- 每轮读上份轮次记录 fire 以来全部 main push、schedule 与队列结论,⛔ 不只读最新一跑。 +- 窗内无锚红当轮立锚卡、照首触定级,转绿照立并点名承修卡;逾 90 分钟无记录记明断档。 - main-red 锚卡由席位先立先派,首触事后补作记录级:可改域,⛔ 不改动作,在飞照补。 - 主干全量红与确定性队列红首触定 p0(锚卡、根因卡、full-run 卡);flake 锚卡判确定性才升。 - 车道席自分诊:本车道卡挡自有在飞(认领卡/PR/落地/拆分链)且证据清楚,方可首触定 p1/p2。 @@ -57,7 +58,7 @@ - 定义项取北极星「做出来的是什么」或仪器卡的车队决定;无则关;`none` 非分诊缺口。 - 「无则关」= 首触即关 not_planned,带理由与入队三门,⛔ 不定 p3、不 hold;下行同此。 - 所入车道队列 P0/P1 开着(= 未派发,在飞不算)时,三门皆无的 p2/p3 `tooling` 卡同样关。 -- 路由是分诊的技术判断,⛔ 不升级哪个仓的问题;涉 cloud 整卡归 cloud 车道;父单永不派发。 +- 路由属分诊技术判断,⛔ 不升级哪个仓的问题;父单永不派发,`pm:epic` 卡 `pm:retriage` 即答。 - 算涉 cloud:亦含托管面(objectos-runtime)、`Blocked-by:` cloud 卡、病因在 cloud。 - 不算涉 cloud:只在 cloud 测得而修复与验收全在本仓;仅引 cloud 卡作历史。 - 父单已有子结构的:父单队列标签即可,分诊逐个展开路由、补 `Blocked-by:` 排序。 @@ -96,7 +97,6 @@ - 轮次报告设呈裁清单专节(卡、推荐、所引权威),⛔ 无代裁清单、无追认表。 - 回翻条款:实施中发现契约终究要动 ⇒ dev 停手,卡回决策箱,⛔ 永不静默重裁。 - 请示纪律:方向性授权覆盖整条执行链一并做完,⛔ 不逐项回问。 -- 需裁事项攒批(≤5 项/批,每项一行带推荐),一句同意即全链执行。 - 中间状态与限流/重试类运维细节只进座位贴和触发器,⛔ 不弹给维护者。 - 汇报只在里程碑、真阻塞、被问到时。 - 人工地板项(发版、天花板、契约扩大)仍需明确字句,但攒批不逐项弹。 diff --git a/scripts/pm/check-owed-reviews.mjs b/scripts/pm/check-owed-reviews.mjs index bd71a9813d3..14a0fecf35d 100644 --- a/scripts/pm/check-owed-reviews.mjs +++ b/scripts/pm/check-owed-reviews.mjs @@ -41,15 +41,29 @@ * `unreadable`. * claim the `Contract-review-claim:` lines: `live` (who holds the head, * the lane winning a tie), `lapsed`, `none`. + * hold the `Contract-review-hold:` lines, as `contractReviewHold` reads + * them, per head: `live` (a readable hold names the current head: + * trigger (b) does not fire, and its age is printed), `stale` (the + * head moved past every readable hold: cleared), `absent`, + * `unreadable` (every hold line is unreadable: it holds nothing). An + * unreadable line beside a live or stale reading voids nothing and is + * PRINTED on the row by comment id, so the gap is never silent. A live + * hold at `CONTRACT_REVIEW_HOLD_LIST_HOURS` or older on a WAIT row is + * LISTED, for the triage round record — the age is a reading, never a + * lapse. * 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). + * nobody on it, no trigger yet — the lane's own, a lane's live hold + * included), `HELD` (a live claim on this head), `DONE` (a record on + * this head). Four, ⛔ no fifth for a hold: a held head is the lane's + * own wait, and a `HOLD` beside `HELD` is one letter from a misread. * 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. + * has waited a full triage round (`TRIAGE_ROUND_MINUTES`) and no + * live hold names it; (c) the lane's seat has stood down or is + * absent from the seat roster. A live hold holds (b) alone: the + * lane that holds ends it with (a), and a lane gone is (c). * * ## Report-only — the exit is 0 on every completed or incomplete read * @@ -70,9 +84,12 @@ import { readClause2Line } from './clause2-line.mjs'; import { parseSeat } from './fleet-roster.mjs'; import { ghApi, readAllPages } from './gh-pages.mjs'; import { + CONTRACT_REVIEW_HOLD_LIST_HOURS, + CONTRACT_REVIEW_HOLD_TEMPLATE, REVIEWED_BY_LINE, contractReviewClaims, contractReviewHandoff, + contractReviewHold, deliveredCardNumber, locateReviewOfRecord, reviewFace, @@ -229,16 +246,21 @@ export function owedReviewRow(pull, { now, seats, repo = DEFAULT_REPO }) { const record = recordReading(pull); const handoff = contractReviewHandoff(pull.comments, head); const claim = contractReviewClaims(pull.comments, head, { now }); + const hold = contractReviewHold(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`); + // (b) alone yields to a live hold: the lane said "not this head yet", and only (a) or (c) overrides that. + const roundWaited = waited !== null && waited >= TRIAGE_ROUND_MINUTES ? `(b) waited ${waited} min` : null; + if (roundWaited !== null && hold.state !== 'live') triggers.push(roundWaited); 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'; + // Listed for the round record: a live hold the age reading calls overdue — or cannot time — on a row it keeps waiting. + const listed = status === 'WAIT' && hold.state === 'live' && hold.overdue !== false; return { pr: pull.number, draft: pull.draft === true, @@ -249,6 +271,20 @@ export function owedReviewRow(pull, { now, seats, repo = DEFAULT_REPO }) { record, handoff: { state: handoff.state, id: handoff.id ?? null }, claim: { state: claim.state, holder: claim.holder ?? null, tie: claim.tie ?? false, unreadable: claim.unreadable ?? [] }, + hold: { + state: hold.state, + id: hold.id ?? null, + session: hold.session ?? null, + reason: hold.reason ?? null, + until: hold.until ?? null, + at: hold.at ?? null, + ageMinutes: hold.ageMinutes ?? null, + overdue: hold.overdue ?? null, + holds: hold.state === 'live' ? roundWaited : null, + listed, + gaps: hold.gaps ?? [], + unreadable: hold.unreadable ?? [], + }, lane: lane.lane, laneSeat: lane.seat, status, @@ -285,11 +321,36 @@ export function renderRow(row) { `record ${record}`, `hand-off ${row.handoff.state}${row.handoff.id === null ? '' : ` (${row.handoff.id})`}`, `claim ${claim}`, + `hold ${holdText(row.hold)}`, `lane ${row.lane ?? '?'} ${row.laneSeat}`, ]; const tail = row.trigger === null ? '' : ` → ${row.trigger}`; + const listed = row.hold.listed ? ` ⏳ HOLD ${holdAge(row.hold)} — list it in the triage round record` : ''; const gaps = row.gaps.length > 0 ? ` ⚠️ UNREAD: ${row.gaps.join('; ')}` : ''; - return `${parts.join(' · ')}${tail}${gaps}`; + return `${parts.join(' · ')}${tail}${listed}${gaps}`; +} + +/** The hold's age as printed: hours, or that it could not be timed. Pure. */ +function holdAge(hold) { + return hold.ageMinutes === null ? 'age unknown' : `${Math.floor(hold.ageMinutes / 60)} h old`; +} + +/** + * The hold part of a row: who holds, since when, until what, and the (b) it holds — and, beside a live + * or stale reading, every hold line that could not be read, by comment id: it voided nothing, and + * it is printed so the gap is seen. Pure. + */ +function holdText(hold) { + const unread = hold.unreadable ?? []; + const gap = hold.state !== 'unreadable' && unread.length > 0 + ? ` ⚠️ ${unread.length} unreadable hold line(s) (${unread.map((u) => u.id ?? '?').join(', ')})` + : ''; + if (hold.state === 'live') { + const holds = hold.holds === null ? '' : `, holds ${hold.holds}`; + return `live ${hold.session} (${hold.id}, ${holdAge(hold)}${holds}) until ${hold.until}${gap}`; + } + if (hold.state === 'stale' || hold.state === 'unreadable') return `${hold.state}${hold.id === null ? '' : ` (${hold.id})`}${gap}`; + return hold.state; } /** The printed report. Pure. */ @@ -301,6 +362,14 @@ export function renderReport(rows, { repo, now, read, gaps }) { '', ...rows.map(renderRow), ]; + const listed = rows.filter((r) => r.hold.listed); + if (listed.length > 0) { + lines.push( + '', + `⏳ ${listed.length} hold(s) ${CONTRACT_REVIEW_HOLD_LIST_HOURS} h or older keep a PR waiting — list each in the triage round record:`, + ...listed.map((r) => ` - #${r.pr} held by ${r.hold.session} since ${r.hold.at ?? '?'} (${holdAge(r.hold)}) until ${r.hold.until}`), + ); + } 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}`)); } @@ -408,8 +477,9 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'the live instance: two Tier H drafts with a hand-off on the current head list as TAKE under (a)': 4, 'the live read: every read through one reader; a failed read is INCOMPLETE, never clean': 6, 'the report: one line per owing PR, TAKE first, exit 0': 5, + 'the hold: a live hold on this head holds (b) alone, (a) and (c) still fire; a new head clears it; at 24 hours it is listed, still WAIT': 14, }); -const SELF_TEST_BATTERY_FLOOR = 8; +const SELF_TEST_BATTERY_FLOOR = 9; const UNATTRIBUTED_BATTERY = '(no battery open)'; export function selfTest() { @@ -553,6 +623,35 @@ export function selfTest() { 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); + battery('the hold: a live hold on this head holds (b) alone, (a) and (c) still fire; a new head clears it; at 24 hours it is listed, still WAIT'); + // The lane's "not this head yet", posted as the lane posts it: signature, then the one line. + const LANE_SIG = `domain:ui#1 · os-bill · ${LANE}`; + const holdRow = (head, at, id = 6472451890) => ({ id, created_at: at, body: `${LANE_SIG}\n\nContract-review-hold: \`${LANE}\` · head \`${head}\` · the stack still lands under it · until the last sibling PR merges` }); + const ripe = '2026-10-28T11:59:00Z'; // committed 61 minutes before NOW: (b) holds when nothing holds it + const heldAt = '2026-10-28T12:10:00Z'; + const held = row({ headCommittedAt: ripe, comments: [holdRow(HEAD, heldAt)] }); + t('⭐ a live hold on the current head holds (b): a full round waited, no record, no claim ⇒ WAIT, not TAKE — and with no hold the same row is TAKE under (b)', held.status === 'WAIT' && held.trigger === 'none yet' && held.hold.state === 'live' && held.hold.holds === '(b) waited 61 min' && row({ headCommittedAt: ripe }).status === 'TAKE' && row({ headCommittedAt: ripe }).trigger === '(b) waited 61 min', JSON.stringify(held.hold)); + t('the row prints the hold: who holds, the comment, its age, the (b) it holds and until what', renderRow(held).includes(`hold live ${LANE} (6472451890, 0 h old, holds (b) waited 61 min) until the last sibling PR merges`), renderRow(held)); + t('⛔ a hold on an EARLIER head holds nothing — the head moved, (b) fires ⇒ TAKE', (() => { const r = row({ headCommittedAt: ripe, comments: [holdRow(HEAD2, heldAt)] }); return r.hold.state === 'stale' && r.status === 'TAKE' && r.trigger === '(b) waited 61 min' && r.hold.holds === null; })()); + t('⭐ (a) still fires under a live hold — the label or a hand-off on this head is the holding lane ending its hold ⇒ TAKE', row({ headCommittedAt: ripe, labels: [{ name: WAITING_LABEL }], comments: [holdRow(HEAD, heldAt)] }).trigger === '(a) label' && row({ headCommittedAt: ripe, comments: [holdRow(HEAD, heldAt), handoff(HEAD, '2026-10-28T12:40:00Z')] }).trigger === '(a) hand-off'); + t('(c) still fires under a live hold — a lane seat that is gone holds nothing', row({ headCommittedAt: ripe, comments: [holdRow(HEAD, heldAt)] }, [{ lane: 'domain:ui', status: 'paused' }]).trigger === '(c) lane seat stood-down'); + t('⛔ an unreadable hold holds nothing ⇒ (b) fires, and the row names the hold comment as unreadable', (() => { const r = row({ headCommittedAt: ripe, comments: [{ id: 7701, created_at: heldAt, body: 'Contract-review-hold: soon' }] }); return r.hold.state === 'unreadable' && r.status === 'TAKE' && renderRow(r).includes('hold unreadable (7701)'); })()); + // A later comment quoting the template line at a line start (a round record, a seat note, a review) is unreadable. + const quotedTemplate = { id: 7701, created_at: '2026-10-28T12:40:00Z', body: `triage#1 · os-zhuang · ${TRIAGE}\n\nRound record: lanes hold a head with\n- \`${CONTRACT_REVIEW_HOLD_TEMPLATE}\`` }; + const voided = row({ headCommittedAt: ripe, comments: [holdRow(HEAD, heldAt), quotedTemplate] }); + t('⭐ an unreadable hold line NEWER than a live hold on this head voids nothing — the row stays WAIT under the live hold and PRINTS the unreadable line by comment id; the control (no such line) is WAIT with no mark', voided.status === 'WAIT' && voided.trigger === 'none yet' && voided.hold.state === 'live' && voided.hold.id === 6472451890 && voided.hold.holds === '(b) waited 61 min' && voided.hold.unreadable.length === 1 && voided.hold.unreadable[0].id === 7701 && renderRow(voided).includes('until the last sibling PR merges ⚠️ 1 unreadable hold line(s) (7701)') && held.status === 'WAIT' && held.hold.unreadable.length === 0 && !renderRow(held).includes('unreadable'), renderRow(voided)); + t('a hold changes neither a live claim (HELD) nor a record on this head (DONE)', row({ headCommittedAt: ripe, comments: [holdRow(HEAD, heldAt), claimRow(TRIAGE, HEAD, '2026-10-28T12:18:56Z', `triage#1 · os-zhuang · ${TRIAGE}`)] }).status === 'HELD' && row({ headCommittedAt: ripe, comments: [holdRow(HEAD, heldAt), recordRow(HEAD)] }).status === 'DONE'); + const aged = row({ headCommittedAt: '2026-10-27T08:00:00Z', comments: [holdRow(HEAD, '2026-10-27T13:00:00Z')] }); + t('⭐ at 24 hours the hold reads overdue and STILL holds — the row stays WAIT, no fifth status — and the row is LISTED for the round record', aged.status === 'WAIT' && aged.hold.state === 'live' && aged.hold.overdue === true && aged.hold.listed === true && renderRow(aged).includes('⏳ HOLD 24 h old — list it in the triage round record'), renderRow(aged)); + const report = renderReport([aged], { repo: DEFAULT_REPO, now: NOW, read: { pulls: 1, drafts: 1 }, gaps: [] }); + t('⭐ the report lists it under the rows: the PR, the holder, when it was posted, its age and the condition', report.some((l) => l.startsWith('⏳ 1 hold(s) 24 h or older keep a PR waiting')) && report.includes(` - #27136 held by ${LANE} since 2026-10-27T13:00:00Z (24 h old) until the last sibling PR merges`), report.join(' | ')); + t('under 24 hours nothing is listed; an overdue hold on a DONE or HELD row is not listed — nothing waits on it', held.hold.listed === false && !renderReport([held], { repo: DEFAULT_REPO, now: NOW, read: { pulls: 1, drafts: 1 }, gaps: [] }).some((l) => l.startsWith('⏳')) && row({ headCommittedAt: '2026-10-27T08:00:00Z', comments: [holdRow(HEAD, '2026-10-27T13:00:00Z'), recordRow(HEAD)] }).hold.listed === false && row({ headCommittedAt: '2026-10-27T08:00:00Z', comments: [holdRow(HEAD, '2026-10-27T13:00:00Z'), claimRow(TRIAGE, HEAD, '2026-10-28T12:18:56Z', `triage#1 · os-zhuang · ${TRIAGE}`)] }).hold.listed === false); + t('⛔ a live hold whose age cannot be timed is LISTED as unknown, never dropped as young', (() => { const r = row({ headCommittedAt: ripe, comments: [{ id: 9, body: holdRow(HEAD, heldAt).body }] }); return r.status === 'WAIT' && r.hold.overdue === null && r.hold.listed === true && renderRow(r).includes('⏳ HOLD age unknown'); })()); + const holdJson = []; + main(['--repo', DEFAULT_REPO, '--json', '--now', '2026-10-28T13:00:00Z'], { get: fake({ ...routes, '/repos/objectstack-ai/objectstack/issues/27136/comments': { ok: true, value: [holdRow(HEAD, '2026-10-27T13:00:00Z')] } }), log: (l) => holdJson.push(l) }); + t('--json carries the hold on the row, live and listed, through the one live reader — the status still one of the four', (() => { const r = JSON.parse(holdJson.join('\n')).rows[0]; return r.pr === 27136 && r.status === 'WAIT' && r.hold.state === 'live' && r.hold.listed === true && r.hold.session === LANE && STATUS_ORDER.includes(r.status); })()); + t('⛔ the status vocabulary stays exactly four, in order — a hold is the lane\'s own WAIT', STATUS_ORDER.join() === 'TAKE,WAIT,HELD,DONE'); + // 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 }); @@ -572,7 +671,8 @@ export function selfTest() { `✓ 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).', + + 'every read through one reader with failures as gaps; the report, exit 0; a lane\'s live hold holding (b) alone, ' + + 'cleared by a new head, an unreadable hold line voiding nothing and printed, listed at 24 hours and still WAIT).', ); selfTestReachedVerdict = true; return 0; diff --git a/scripts/pm/record-recognisers.mjs b/scripts/pm/record-recognisers.mjs index 78f2aca6cce..b00080e8ef6 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; `contractReviewClaims` (`readContractReviewClaim`, `contractReviewClaimLapse`) who holds the head + * 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; `contractReviewHold` (`readContractReviewHold`) whether a lane holds it — per head, the newest readable `Contract-review-hold:` line naming it, unreadable lines listed and never voiding, its age a reading never a lapse * 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 @@ -74,7 +74,7 @@ // 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 .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 +// 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, the claim-line battery pins the `Contract-review-claim:` template and its lapse hours in contract-review.md, and the hold-line battery pins the `Contract-review-hold:` template and its listing hours there, so a change to any of those spellings is judged here import process from 'node:process'; import { spawnSync } from 'node:child_process'; @@ -1054,6 +1054,158 @@ export function contractReviewClaims(rows, headSha, { now } = {}) { return { state, holder, tie, claims, unreadable }; } +// --------------------------------------------------------------------------- +// The `Contract-review-hold:` line — a lane's "not this head yet", one per head +// --------------------------------------------------------------------------- + +/** + * The `Contract-review-hold:` key line -- a LANE saying "do not review this head + * yet": the head will move again, or something must land first. Before it + * existed that was prose on the PR, which no reader can tell from commentary, so + * the triage seat claimed under gap rule (b) and then had to withdraw. + * + * ⭐ The rule it makes machine-findable (the skill's contract-review reference, + * 〈复核归属与资格〉, beside the claim line): the holding lane posts ONE comment + * carrying this line -- + * + * Contract-review-hold: session_01QSeKYmhU5KEj1nukYhp1Tg · head 3beee64f · the stack still lands under it · until the last sibling merges + * + * Four facts, each required, separated by the doorbell's dot: the SESSION that + * holds (the signature line's session shape, read by the claim's own reader, + * ⛔ never a second spelling), the HEAD (7 to 40 hex, matched by PREFIX as the + * hand-off line's head is), the REASON, and `until` followed by the CONDITION. + * Reason and condition are free text and are not graded -- but each must be + * there, and the dot is the separator, so a fifth part is started-and-unreadable, + * and an unreadable hold holds nothing. + * + * PER HEAD, and only a new head clears a hold (the rule's 「新 head 即清;点名现 + * head 时 (b) 不起」): the newest READABLE hold naming the CURRENT head is `live`, + * and while it is, gap rule (b) does not fire -- (a) and (c) still do. When the + * head moves past every readable hold, they are cleared (`stale`). Nothing posted + * later on the thread clears a hold on the current head: not a newer hold naming + * an earlier head, and not a newer line that cannot be read -- a later note, round + * record or review quoting this line's template is exactly such a line. An + * unreadable hold line holds nothing AND voids nothing; each is returned by + * comment id beside the reading, so the gap is printed, never silent (the claim + * reader's convention, `contractReviewClaims`). The lane that holds owns the + * review once its condition is met: it hangs `needs:contract-review` or posts a + * hand-off line, and either is (a). + * + * The AGE is a reading, ⛔ never a verdict. Whether the condition is met is free + * text no reader can judge, so nothing here lapses a hold: at + * {@link CONTRACT_REVIEW_HOLD_LIST_HOURS} hours a live hold reads `overdue`, + * which the triage round record lists so a hold cannot become a silent skip -- + * and an overdue hold still holds (b). Age is timed from the comment's + * `created_at`, the platform's stamp of when the hold was POSTED; the line + * carries no time of its own. + * + * ⛔ No gate reads it. The queue guard keeps reading the ONE `## Contract + * review` record; this line is coordination between reviewers, never a merge + * condition. `contractReviewHold` below is the reading the owed-review list + * (`check-owed-reviews.mjs`) takes. + */ +export const CONTRACT_REVIEW_HOLD_KEY = 'Contract-review-hold'; +/** + * 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_HOLD_TEMPLATE = `${CONTRACT_REVIEW_HOLD_KEY}: · head · · until `; +/** Hours after posting that a live hold reads `overdue` and is listed -- the rule's 「24 小时」. A reading, never a lapse. */ +export const CONTRACT_REVIEW_HOLD_LIST_HOURS = 24; +const CONTRACT_REVIEW_HOLD_LINE = keyLineRegex(CONTRACT_REVIEW_HOLD_KEY); +/** The same key as a MARKER over a whole comment body -- `latestMarkedComment`'s convention (multiline, `g`-less). */ +const CONTRACT_REVIEW_HOLD_MARKER = new RegExp(CONTRACT_REVIEW_HOLD_LINE.source, 'm'); +const HOLD_HEAD = /^head[ \t]+`?([0-9a-fA-F]{7,40})`?$/; +const HOLD_UNTIL = /^until[ \t]+(\S.*)$/; + +/** + * What one comment declares as a hold on a contract review. The FIRST hold line + * of the comment is read, after one leading signature line is set aside + * (`stripSignatureLine`, as the claim reader does). + * + * @returns {{ state: 'missing' } + * | { state: 'unreadable', line: string } + * | { state: 'read', session: string, head: string, reason: string, until: string }} + */ +export function readContractReviewHold(text) { + for (const line of stripSignatureLine(String(text ?? '')).split(/\r?\n/)) { + const m = CONTRACT_REVIEW_HOLD_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 !== 4) return unreadable; + const session = CLAIM_SESSION.exec(parts[0]); + const head = HOLD_HEAD.exec(parts[1]); + const reason = parts[2].trim(); + const until = HOLD_UNTIL.exec(parts[3].trim()); + // A reason that is itself the `until` clause means the reason was left out. + if (!session || !head || reason === '' || /^until\b/.test(reason) || !until) return unreadable; + return { state: 'read', session: session[1], head: head[1].toLowerCase(), reason, until: until[1].trim() }; + } + return { state: 'missing' }; +} + +/** + * Is a lane holding this pull request's current head, read off its comment thread: + * + * live a READABLE hold line names the current head -- gap rule (b) does not fire; the + * newest such line is the reading; `overdue` is true once it was posted + * CONTRACT_REVIEW_HOLD_LIST_HOURS ago or more (a reading the round record lists; the + * hold still holds), null when the comment carries no stamp to time it from + * stale readable hold lines exist and none names the current head -- the head moved past + * them, they are cleared; the newest of them is the reading + * absent no hold line on any comment + * unreadable the thread, the head or the clock could not be read, or EVERY hold line on the + * thread is unreadable -- it holds nothing + * + * Per head, not newest-over-all: a hold naming the current head stands whatever is posted after it, + * and an unreadable line neither holds nor voids. Every unreadable hold line is returned in + * `unreadable` (comment id, its stamp, the line quoted) on EVERY reading, so a caller prints the gap. + * "Newest" is `latestMarkedComment`'s order, and the head is matched by PREFIX, exactly as + * `contractReviewHandoff` reads its line. + * + * @param {{ id?: number, body?: string, created_at?: string }[]} rows the PR's comment rows + * @param {string} headSha the pull request's current head + * @param {{ now: number }} clock milliseconds since the epoch + */ +export function contractReviewHold(rows, headSha, { now } = {}) { + const gaps = []; + if (!Array.isArray(rows)) gaps.push("the pull request's comment thread"); + const head = String(headSha ?? '').toLowerCase(); + if (head.length < H51_SHA_MIN_HEX) gaps.push("the pull request's head sha"); + if (!Number.isFinite(now)) gaps.push("the clock the hold's age is read on"); + if (gaps.length > 0) return { state: 'unreadable', gaps, unreadable: [] }; + const read = []; + const unread = []; + for (const row of rows) { + const line = readContractReviewHold(row?.body); + if (line.state === 'read') read.push({ row, line }); + else if (line.state === 'unreadable') unread.push({ row, line }); + } + // The newest of a set of hold-carrying rows, by the one resolution the hand-off line uses. Every + // entry carries the key line, so the marker meets each; the thread's last entry is the fallback. + const newestOf = (entries) => { + const newest = latestMarkedComment(entries.map((e) => e.row), CONTRACT_REVIEW_HOLD_MARKER); + return entries[newest === null ? entries.length - 1 : newest.index]; + }; + const unreadable = unread.map(({ row, line }) => ({ id: row?.id ?? null, at: row?.created_at ?? null, line: line.line })); + if (read.length === 0 && unread.length === 0) return { state: 'absent', read: rows.length, unreadable }; + if (read.length === 0) { + const last = newestOf(unread).row; + const gapsOf = unreadable.map((u) => `the hold line in comment ${u.id ?? '?'} cannot be read: ${u.line}`); + return { state: 'unreadable', gaps: gapsOf, id: last?.id ?? null, at: last?.created_at ?? null, unreadable }; + } + const onHead = read.filter(({ line }) => shaMeets(head, line.head)); + const { row, line } = newestOf(onHead.length > 0 ? onHead : read); + const reading = { session: line.session, head: line.head, reason: line.reason, until: line.until, id: row?.id ?? null, at: row?.created_at ?? null, unreadable }; + if (onHead.length === 0) return { state: 'stale', ...reading }; + const posted = Date.parse(row?.created_at ?? ''); + const ageMinutes = Number.isFinite(posted) ? Math.max(0, Math.floor((now - posted) / 60000)) : null; + const overdue = ageMinutes === null ? null : ageMinutes >= CONTRACT_REVIEW_HOLD_LIST_HOURS * 60; + return { state: 'live', ...reading, ageMinutes, overdue }; +} + // --------------------------------------------------------------------------- // The mailbox DOORBELL — seven first-line keys, a CLOSED vocabulary // --------------------------------------------------------------------------- @@ -1899,6 +2051,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ '⭐ 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, + '⭐ the `Contract-review-hold:` line — session, head, reason, until; a new head clears it; at 24 hours it reads overdue and still holds': 23, }); // DELETING an entry silences that battery's floor exactly as effectively as @@ -1916,7 +2069,8 @@ const SELF_TEST_BATTERIES = Object.freeze({ // vocabulary): one battery, one row. // Raised 14 → 15 with the signature-line battery (the maintainer's signed-post rule): one battery, one row. // 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; +// Raised 16 → 17 with the hold-line battery (the maintainer's lane-hold rule): one battery, one row. +const SELF_TEST_BATTERY_FLOOR = 17; // 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 @@ -2343,6 +2497,53 @@ export async function selfTest() { 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 `Contract-review-hold:` line ----------------------------------------- + // + // The maintainer's rule: a lane that does not want a head reviewed yet says so + // in ONE line a reader can find, instead of prose the triage seat has to + // re-read. These cases read the line as a lane posts it, with and without the + // signature above it, refuse every shape missing a fact, pin the PER-HEAD reading + // (only a new head clears a hold; a later line on another head or one that + // cannot be read voids nothing, and every unreadable line is returned), and pin + // the 24-hour age as a READING: an overdue hold is listed and still holds. + battery('⭐ the `Contract-review-hold:` line — session, head, reason, until; a new head clears it; at 24 hours it reads overdue and still holds'); + const HL_HEAD = '3beee64fe77741d07f035f1b80eeaa07e7280ab4'; + const HL_SESSION = 'session_01JQwxbZeBv2y91towxasSUT'; + const HL_SIG = `domain:ui#1 · os-bill · ${HL_SESSION}`; + const HL_LINE = (head = HL_HEAD, reason = 'the stack still lands under it', until = 'the last sibling PR merges', session = HL_SESSION) => `${CONTRACT_REVIEW_HOLD_KEY}: \`${session}\` · head \`${head}\` · ${reason} · until ${until}`; + // The comment as a lane posts it: signature, the line, prose. + const HL_BODY = (sig = HL_SIG, line = HL_LINE()) => `${sig === null ? '' : `${sig}\n\n`}${line}\n\nThe lane hangs the label once the condition is met.`; + const HL_ROW = (body, id = 6472451890, at = '2026-10-28T09:00:00Z') => ({ id, created_at: at, body }); + const HL_NOW = Date.parse('2026-10-28T13:00:00Z'); + const HL_POSTED = Date.parse('2026-10-28T09:00:00Z'); + const HL_J = (v) => JSON.stringify(v); + t('⭐ the live specimen reads: the session, the head, the reason and the condition, behind the signature line', (() => { const r = readContractReviewHold(HL_BODY()); return r.state === 'read' && r.session === HL_SESSION && r.head === HL_HEAD && r.reason === 'the stack still lands under it' && r.until === 'the last sibling PR merges'; })()); + t('with and without the signature line the reading is the same, byte for byte', HL_J(readContractReviewHold(HL_BODY())) === HL_J(readContractReviewHold(HL_BODY(null))) && readContractReviewHold(HL_BODY(null)).state === 'read'); + t('decoration reads: bare values, a bulleted bolded key, a blockquoted line, an abbreviated head', [`${CONTRACT_REVIEW_HOLD_KEY}: ${HL_SESSION} · head ${HL_HEAD} · r · until c`, `- **${CONTRACT_REVIEW_HOLD_KEY}:** \`${HL_SESSION}\` · head \`${HL_HEAD.slice(0, 12)}\` · r · until c`, `> ${HL_LINE()}`].every((l) => readContractReviewHold(l).state === 'read' && HL_HEAD.startsWith(readContractReviewHold(l).head))); + t('the head is read lowercased; seven hex is the shortest head that reads, six is unreadable', readContractReviewHold(HL_LINE(HL_HEAD.toUpperCase())).head === HL_HEAD && readContractReviewHold(HL_LINE(HL_HEAD.slice(0, 7))).state === 'read' && readContractReviewHold(HL_LINE(HL_HEAD.slice(0, 6))).state === 'unreadable'); + t('⛔ a missing fact, a fifth part, an empty reason, no `until`, a reason that is the `until`, a session of another shape: unreadable', [`${CONTRACT_REVIEW_HOLD_KEY}: \`${HL_SESSION}\` · head \`${HL_HEAD}\` · until c`, `${HL_LINE()} · round 3`, HL_LINE(HL_HEAD, ''), `${CONTRACT_REVIEW_HOLD_KEY}: \`${HL_SESSION}\` · head \`${HL_HEAD}\` · r · when c`, HL_LINE(HL_HEAD, 'r', ''), HL_LINE(HL_HEAD, 'until a'), HL_LINE(HL_HEAD, 'r', 'c', 'domain:ui#1'), `${CONTRACT_REVIEW_HOLD_KEY}:`].every((l) => readContractReviewHold(l).state === 'unreadable')); + t('⛔ the TEMPLATE copied unedited is unreadable — a placeholder holds no head', readContractReviewHold(CONTRACT_REVIEW_HOLD_TEMPLATE).state === 'unreadable' && CONTRACT_REVIEW_HOLD_TEMPLATE === 'Contract-review-hold: · head · · until '); + 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_HOLD_LINE.source === keyLineRegex(CONTRACT_REVIEW_HOLD_KEY).source && readContractReviewHold(`the lane posts the ${CONTRACT_REVIEW_HOLD_KEY}: line first`).state === 'missing' && readContractReviewHold('').state === 'missing'); + t('⭐ the thread: the newest hold naming the current head is LIVE — session, reason, condition and the comment that holds', (() => { const r = contractReviewHold([HL_ROW(HL_BODY())], HL_HEAD, { now: HL_NOW }); return r.state === 'live' && r.session === HL_SESSION && r.until === 'the last sibling PR merges' && r.id === 6472451890 && r.ageMinutes === 240 && r.overdue === false; })()); + t('⭐ per head: the head MOVED past the newest hold ⇒ stale, naming the head it held — a new head clears it', (() => { const r = contractReviewHold([HL_ROW(HL_BODY())], 'f'.repeat(40), { now: HL_NOW }); return r.state === 'stale' && r.head === HL_HEAD && r.overdue === undefined; })()); + t('⭐ PER HEAD — a newer readable hold naming an EARLIER head does not clear the older hold on this head: live, read off the hold that names it; the reverse order reads live too', (() => { const older = contractReviewHold([HL_ROW(HL_BODY(), 1, '2026-10-28T09:00:00Z'), HL_ROW(HL_BODY(HL_SIG, HL_LINE('deadbeef1')), 2, '2026-10-28T10:00:00Z')], HL_HEAD, { now: HL_NOW }); const newer = contractReviewHold([HL_ROW(HL_BODY(HL_SIG, HL_LINE('deadbeef1')), 1, '2026-10-28T09:00:00Z'), HL_ROW(HL_BODY(), 2, '2026-10-28T10:00:00Z')], HL_HEAD, { now: HL_NOW }); return older.state === 'live' && older.id === 1 && older.head === HL_HEAD && older.ageMinutes === 240 && newer.state === 'live' && newer.id === 2 && newer.ageMinutes === 180; })()); + t('newest governs AMONG the holds naming this head — the reading is the newer one: its reason, condition and age', (() => { const r = contractReviewHold([HL_ROW(HL_BODY(), 1, '2026-10-28T09:00:00Z'), HL_ROW(HL_BODY(HL_SIG, HL_LINE(HL_HEAD, 'a second sibling joined', 'both siblings merge')), 2, '2026-10-28T11:00:00Z')], HL_HEAD, { now: HL_NOW }); return r.state === 'live' && r.id === 2 && r.reason === 'a second sibling joined' && r.until === 'both siblings merge' && r.ageMinutes === 120; })()); + t('every readable hold names an earlier head ⇒ stale, read off the newest of them', (() => { const r = contractReviewHold([HL_ROW(HL_BODY(HL_SIG, HL_LINE('deadbeef1')), 1, '2026-10-28T09:00:00Z'), HL_ROW(HL_BODY(HL_SIG, HL_LINE('cafebabe2')), 2, '2026-10-28T10:00:00Z')], HL_HEAD, { now: HL_NOW }); return r.state === 'stale' && r.id === 2 && r.head === 'cafebabe2' && r.unreadable.length === 0; })()); + t('the head is matched by PREFIX either way, as the hand-off line\'s is', contractReviewHold([HL_ROW(HL_BODY(HL_SIG, HL_LINE(HL_HEAD.slice(0, 8))))], HL_HEAD, { now: HL_NOW }).state === 'live' && contractReviewHold([HL_ROW(HL_BODY())], HL_HEAD.slice(0, 12), { now: HL_NOW }).state === 'live'); + t('⭐ NO hold line ⇒ absent — a claim, a hand-off line and a record on the thread change nothing', (() => { const r = contractReviewHold([CL_ROW(CL_BODY()), HO_ROW(HO_LINE(HL_HEAD)), RECORD(HL_HEAD)], HL_HEAD, { now: HL_NOW }); return r.state === 'absent' && r.read === 3; })()); + t('⭐ the age is a READING: 23 h 59 min ⇒ live, not overdue; 24 h ⇒ live AND overdue — ⛔ an overdue hold is not lapsed', (() => { const young = contractReviewHold([HL_ROW(HL_BODY())], HL_HEAD, { now: HL_POSTED + (24 * 60 - 1) * 60000 }); const old = contractReviewHold([HL_ROW(HL_BODY())], HL_HEAD, { now: HL_POSTED + 24 * 3600000 }); return young.state === 'live' && young.overdue === false && old.state === 'live' && old.overdue === true && old.ageMinutes === 1440 && CONTRACT_REVIEW_HOLD_LIST_HOURS === 24; })()); + t('the age is timed from when the comment was POSTED; a row with no stamp reads overdue null — unknown, never false', (() => { const r = contractReviewHold([{ id: 9, body: HL_BODY() }], HL_HEAD, { now: HL_NOW }); return r.state === 'live' && r.ageMinutes === null && r.overdue === null; })()); + // A later comment that quotes the template line at a line start -- a round record, a seat note, a review -- is the + // measured way an unreadable hold line lands on a thread after a readable one. + const HL_QUOTED = (id, at) => HL_ROW(`${CL_TRIAGE_SIG}\n\nRound record: lanes hold a head with\n- \`${CONTRACT_REVIEW_HOLD_TEMPLATE}\``, id, at); + t('⭐ an unreadable hold line NEWER than a readable hold on this head voids nothing: still LIVE off the readable one, the unreadable line returned by comment id — and the control (the same thread without it) reads the same hold', (() => { const ctl = contractReviewHold([HL_ROW(HL_BODY(), 1, '2026-10-28T09:00:00Z')], HL_HEAD, { now: HL_NOW }); const r = contractReviewHold([HL_ROW(HL_BODY(), 1, '2026-10-28T09:00:00Z'), HL_QUOTED(7701, '2026-10-28T10:00:00Z')], HL_HEAD, { now: HL_NOW }); return ctl.state === 'live' && ctl.unreadable.length === 0 && r.state === 'live' && r.id === ctl.id && r.session === ctl.session && r.until === ctl.until && r.ageMinutes === ctl.ageMinutes && r.unreadable.length === 1 && r.unreadable[0].id === 7701 && r.unreadable[0].at === '2026-10-28T10:00:00Z' && r.unreadable[0].line.includes(''); })()); + t('⭐ ONLY unreadable hold lines ⇒ unreadable, holding nothing: each named in the gaps, the reading naming the newest', (() => { const r = contractReviewHold([HL_ROW(HL_BODY(HL_SIG, `${CONTRACT_REVIEW_HOLD_KEY}: soon`), 7700, '2026-10-28T09:00:00Z'), HL_QUOTED(7701, '2026-10-28T10:00:00Z')], HL_HEAD, { now: HL_NOW }); return r.state === 'unreadable' && r.id === 7701 && r.gaps.length === 2 && r.gaps[0].includes('7700') && r.gaps[1].includes('7701') && r.unreadable.length === 2 && r.ageMinutes === undefined; })()); + t('every reading carries `unreadable`: empty when absent, listed beside a stale reading', contractReviewHold([CL_ROW(CL_BODY())], HL_HEAD, { now: HL_NOW }).unreadable.length === 0 && (() => { const r = contractReviewHold([HL_ROW(HL_BODY(HL_SIG, HL_LINE('deadbeef1')), 1, '2026-10-28T09:00:00Z'), HL_QUOTED(7701, '2026-10-28T10:00:00Z')], HL_HEAD, { now: HL_NOW }); return r.state === 'stale' && r.id === 1 && r.unreadable.length === 1 && r.unreadable[0].id === 7701; })()); + t('a thread that is not an array, a head too short to match, or no clock is a read that could not be made', contractReviewHold(null, HL_HEAD, { now: HL_NOW }).state === 'unreadable' && contractReviewHold([], 'abc', { now: HL_NOW }).state === 'unreadable' && contractReviewHold([], HL_HEAD, {}).state === 'unreadable' && contractReviewHold([], HL_HEAD, { now: HL_NOW }).state === 'absent'); + t('a claim line and a hand-off line are not hold lines, and a hold line is neither of them', readContractReviewClaim(HL_BODY()).state === 'missing' && readContractReviewHandoff(HL_BODY()).state === 'missing' && readContractReviewHold(CL_BODY()).state === 'missing' && readContractReviewHold(HO_LINE()).state === 'missing'); + t('⛔ no gate reads it: a hold beside a record on this head leaves the record FOUND, and the claim and hand-off readings untouched', (() => { const rec = locateReviewOfRecord(pairOf({ prComments: [RECORD_ON_9AF9, HL_ROW(HL_BODY(HL_SIG, HL_LINE(HEAD_9AF9)))] })); return rec.state === 'found' && rec.id === 3301 && contractReviewClaims([HL_ROW(HL_BODY())], HL_HEAD, { now: HL_NOW }).state === 'none' && contractReviewHandoff([HL_ROW(HL_BODY())], HL_HEAD).state === 'absent'; })()); + t('⭐ the line is spelled VERBATIM, in a code span, in the rule text (`references/contract-review.md`), beside its 24-hour listing', (() => { const rules = readFileSync(new URL('../../.claude/skills/pm-dispatch/references/contract-review.md', import.meta.url), 'utf8'); return rules.includes(`\`${CONTRACT_REVIEW_HOLD_TEMPLATE}\``) && rules.includes(`${CONTRACT_REVIEW_HOLD_LIST_HOURS} 小时`); })()); + // -- the mailbox doorbell ----------------------------------------------------- // // One mailbox PR per seat, and a CLOSED vocabulary on it (the maintainer's @@ -2690,7 +2891,10 @@ export async function selfTest() { + '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; 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 ' + + 'spelled verbatim in the rule text, lapsing on a moved head or after two hours, the lane winning a tie; the review hold ' + + 'line — read signed and unsigned, spelled verbatim in the rule text, per head and cleared only by a new head, an unreadable ' + + 'line listed and voiding nothing, its 24-hour age a reading ' + + 'that still holds; and the ONE ' + 'thread set, driven through the queue guard itself).', );