diff --git a/.claude/skills/pm-dispatch/SKILL.md b/.claude/skills/pm-dispatch/SKILL.md index b5acebdf4d..6d3d7ae8cf 100644 --- a/.claude/skills/pm-dispatch/SKILL.md +++ b/.claude/skills/pm-dispatch/SKILL.md @@ -621,8 +621,8 @@ PM 的工作是循环:选卡 → 认领 → 派发 → 收集 → 复核 → 报 - ④ 轮次报告单列 awaiting a human merge。 - 已入队才读到本条 ⇒ 转 draft 与 disable 都做;出队以阳性探针答,ref 缺席只旁证。 - skills 车道自有 PR:纯代码面如 `scripts/pm/` 由本席按达档自审(清单不减)后落地。 -- 受管面两层:事实层仅本技能 `references/`,其余为规则层(含发布 `skills/**` 与 SKILL.md)。 -- 规则层四件套等人批;事实层 PR(受管路径全在该目录)经席内达档复核后 ready → 入队。 +- 受管面两层:Tier H(规则层)= `AGENTS.md`+`CLAUDE.md`+`docs/adr/**`+`docs/NORTH-STAR.md`+发布 `skills/**`。 +- Tier S = `.claude/**` 全树;Tier H 四件套等人批;Tier S 经席内达档复核 PASS 在案后 ready → 入队。 - 路径面干净的才转 ready → 入队;队列是唯一被认可的落地路径,⛔ 永不队列外合并。 - 入队资格:每 check 绿或预期 skip,⛔ 非必查子集;名单 check-expected-skips.mjs 只判 objectstack。 - 非必查红是真缺陷或坏门,归 PM 入队前处置;第三种按设计而红,三条全立才可带红入队: diff --git a/.claude/skills/pm-dispatch/references/contract-review.md b/.claude/skills/pm-dispatch/references/contract-review.md index 42faa4dc40..c2689c515c 100644 --- a/.claude/skills/pm-dispatch/references/contract-review.md +++ b/.claude/skills/pm-dispatch/references/contract-review.md @@ -43,7 +43,7 @@ - 放宽 tell 由 `scripts/pm/check-widening-tells.mjs` 判,`no` 撞新键/成员/导出/登记即拒,附 file:line。 - ③ PR check 全绿,⛔ 非 required 子集;例外:merge-base 同签名的红不计、按设计而红见 SKILL.md。 - 签名 = 失败步 + 首错行,读 base check runs 的 API ⛔ 不凭口述;主干红止血立单不变。 -- 规则层等维护者的字;受管路径全在 `.claude/skills/pm-dispatch/references/` 者达档过本三条入队。 +- Tier H(规则层)等维护者的字;受管路径全在 Tier S 面(`.claude/**`)者达档过本三条入队。 ## 降档保险丝(机读) diff --git a/.claude/skills/pm-dispatch/references/core-rules.md b/.claude/skills/pm-dispatch/references/core-rules.md index 325f5c8ba4..a39c8710a7 100644 --- a/.claude/skills/pm-dispatch/references/core-rules.md +++ b/.claude/skills/pm-dispatch/references/core-rules.md @@ -119,7 +119,7 @@ - 停摆永不自愈,按梯度复位、三次即判不可靠重派;报告丢失时按草稿 PR 直接验收。 - 复核对 GitHub 核验 ⛔ 不对自述核验,逐项过清单并亲核形态、范围与整包价值密度。 - CI 收敛读数只属于复核侧;判决三种:验收落卡、返工最多两轮、升级走决策通道。 -- 验收后取路径面,命中规则层即分叉 ⛔ 不翻正式不入队;`references/` 席内达档复核后入队。 +- 验收后取路径面,命中规则层即分叉 ⛔ 不翻正式不入队;Tier S 席内达档复核 PASS 后入队。 - 受管面 PR 留 draft 并向两个授权批准账户请审;契约卡无同形复核记录 PASS ⛔ 禁止入队。 - 入队资格是每一个检查全绿 ⛔ 不是必查子集;碰生成物的 PR 入队前先同步再重生成。 - 唯一例外:源码自述 pushed 上按设计而红、不跑 `merge_group`、评论记明门与因,三条全立。 diff --git a/AGENTS.md b/AGENTS.md index 8de80cf4cf..ab507b240f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -252,40 +252,40 @@ localStorage / auth gotchas. When a cloud decision's **mechanism half** governs open code here, this repo carries its own ADR — own number, a `## Provenance` section naming the cloud record and its date, the commercial half left in cloud — and the cloud record gains a one-line pointer. Files never move between registries and numbers are never reassigned. -14. **⛔ A governed surface is confirmed and merged by the maintainer, by hand — or confirmed by an authorized - approval and then landed by the owning seat; before that approval no AI seat merges, queues, or arms auto-merge on a - PR whose diff touches one.** The governed surfaces are `docs/adr/**`, `.claude/**` (agents, hooks and settings — - not only skills), `skills/**`, `AGENTS.md`, `CLAUDE.md` and `docs/NORTH-STAR.md` — the file you are reading is - one — and a mixed diff is governed whole on a single path hit. The register is the `GOVERNED_SURFACES` table in - `scripts/pm/check-governed-merges.mjs`; adding a surface is an edit *there*, never here, and `pnpm - check:pm-governed-prose` reds per-PR when this paragraph names fewer surfaces than the register — or more. When it - reds, name the surface here. +14. **⛔ A governed surface lands only the way its tier allows — Tier H by the maintainer's hand or by an authorized + approval, Tier S by the owning seat on a contract-tier review of record; before that record no AI seat merges, + queues, or arms auto-merge on a PR whose diff touches one.** The governed surfaces are `docs/adr/**`, + `docs/NORTH-STAR.md`, `.claude/**` (agents, hooks and settings — not only skills), `skills/**`, `AGENTS.md` and + `CLAUDE.md` — the file you are reading is one — and a mixed diff is governed whole on a single path hit. The + register is the `GOVERNED_SURFACES` table in `scripts/pm/check-governed-merges.mjs`, each row carrying its tier; + adding a surface is an edit *there*, never here, and `pnpm check:pm-governed-prose` reds per-PR when this + paragraph names fewer surfaces than the register — or more. When it reds, name the surface here. **Authoring stays open to every seat** — drafting, pushing, opening and revising the PR. What is reserved is the **landing**: on a PR whose diff touches a governed surface ⛔ never merge, ⛔ never queue, ⛔ never arm auto-merge, ⛔ never flip it out of draft to make any of those possible — judged on the PR's **file list**, not its description; a **mixed diff is not a proportion question**, one path hit is enough; to land the rest, split off - the governed files. **Those four lift only for an authorized APPROVED review** — by an account in - `GOVERNED_APPROVERS` (`scripts/pm/check-governed-queue-guard.mjs`), on ANY commit and not dismissed. That word is - spent once per PR: the OWNING seat then lands it, later pushes included, re-queuing after an ejection or a rebase on - its own pre-landing check; this gate does not re-review it. Hand-authored governed content needs that approval; a PR - whose only governed paths are register rows the queue leg regenerates byte-exact clears with zero approvals — an - uncertified recompute, drift or a hand-authored sibling keeps it governed. Unapproved, no seat lands it: the - ending is that approval, then the owning seat. **Landing is tiered**: a PR whose governed paths all lie under - `.claude/skills/pm-dispatch/references/` lands through the queue after the skills seat's contract-tier review; every - other governed path is the rules layer and waits for the maintainer's word, which the director seat requests as ONE - batch of at most five rows — the approval stays the maintainer's click. ⛔ **No agent seat submits an approving - review on a governed-surface PR, under any account** — an authorized account is agent-operated too; "CI is green" - carries no information about a governance change. + the governed files. **The landing is tiered by the register; one Tier H path makes the whole PR Tier H.** **Tier H** + (人合: `docs/adr/**`, `docs/NORTH-STAR.md`, `skills/**`, `AGENTS.md`, `CLAUDE.md`): those four lift only for an + authorized APPROVED review by an account in `GOVERNED_APPROVERS`, on ANY commit and not dismissed; that word is + spent once per PR — the OWNING seat then lands it, later pushes included; the director seat requests the word as + ONE batch of at most five rows, and the click stays the maintainer's. **Tier S** (席内达档复核落地: all of + `.claude/**`): those four lift once the PR thread or its card carries a `## Contract review` record for the PR's + current head with `Served-tier: CONTRACT_REVIEW_TIER` and a PASS verdict, `check-clause2-carriers.mjs --pair N` + reads 0 and every check is green — the owning seat then lands it through the queue; the post-merge audit is the + compensating control. A PR whose only governed paths are register rows the queue leg regenerates byte-exact clears + with zero approvals — an uncertified recompute, drift or a hand-authored sibling keeps it governed. ⛔ **No agent + seat submits an approving review on a governed-surface PR, under any account** — an authorized account is + agent-operated too; "CI is green" carries no information about a governance change. **Already armed or queued when you read this?** Convert it back to **draft** AND disable auto-merge — draft is what removes queue membership, disabling alone drops only the arming — then confirm from the remote that it is in neither the queue nor `origin/main`. **Draft is no barrier by itself — the barrier is this directive**, and a - spent approval IS the review record, ⛔ not a relaxation. Behind it: the queue guard refuses an unpinned governed - diff; CODEOWNERS routes review requests for `docs/adr/` only, so nothing summons the maintainer on the other four; - the post-merge audit (`scripts/pm/check-governed-merges.mjs`) lists every governed-surface merge with its approver - and merger — a merger the maintainer does not recognise, or any agent approval, is a seat violation, filed and - rolled back. The rule has no exception for a seat to judge. + spent approval or a standing record IS the review record, ⛔ not a relaxation. Behind it: the queue guard refuses a + governed diff without its tier's record; CODEOWNERS routes review requests for `docs/adr/` only, so nothing summons + the maintainer on the other Tier H surfaces; the post-merge audit (`scripts/pm/check-governed-merges.mjs`) lists + every governed-surface merge with its approver and merger — a merger the maintainer does not recognise, any agent + approval, or a Tier S merge without a PASS record is a seat violation, filed and rolled back. No seat judges this. 15. **⛔ A version release is performed by the maintainer, by hand — no AI seat publishes, tags, cuts a Release, or triggers a release workflow, and none merges the Version Packages PR.** A rule that binds every seat lives here, @@ -797,7 +797,7 @@ working in its domain — browse the directory, never a hand-written list here: - `.claude/skills/` — repo-internal agent playbooks; every entry must carry `metadata.internal: true`. -⛔ **Both roots are governed surfaces** — human-merge only, or **Prime Directive #14**'s pinned-approval path. +⛔ **Both roots are governed surfaces** — `skills/` is Tier H, `.claude/skills/` Tier S (**Prime Directive #14**). --- @@ -1059,8 +1059,8 @@ Both non-handshake shapes, and how to classify and probe your own: §7: never straight to `main`; never arm a PR that isn't green yet). A finished task = a merged PR, not a dirty working tree. ⛔ **Except a diff touching a governed surface** (Prime Directive #14 names them — more than ADRs): push it, open the PR, and stop - there; landing waits for the maintainer's word. For that class, a finished task = a PR - left visibly awaiting that word. + there; landing waits for its tier's record — the maintainer's word, or a seat's contract-tier + review. For that class, a finished task = a PR left visibly awaiting that record. 3. **Add a changeset for anything that publishes.** Feature, functional improvement or fix — run `pnpm changeset` (or add a `.changeset/*.md` entry) describing it before committing. A bug fix in a released package takes a **`patch`** changeset — never none, and ⛔ never `skip-changeset`: that label is for a diff that publishes diff --git a/scripts/pm/check-clause2-carriers.mjs b/scripts/pm/check-clause2-carriers.mjs index 3a270ef2eb..a408d16387 100644 --- a/scripts/pm/check-clause2-carriers.mjs +++ b/scripts/pm/check-clause2-carriers.mjs @@ -8132,11 +8132,17 @@ export async function selfTest() { created_at: '2026-09-16T10:00:00Z', body: contractReviewRecordLines({ headSha: PIN_HEAD, reviewedBy: 'session_01PINSEAT' }).join('\n'), }; + // A Tier S path (the register's `.claude/**` row since #19133; the fact layer + // under it was the whole tier under #18020). The control below asks the + // register, so a row moving tiers reddens here instead of silently driving + // the approval leg. + const TIER_S_PATH = '.claude/skills/pm-dispatch/references/contract-review.md'; + t('⛔ CONTROL: the fixture path is Tier S under the register, so the record leg is the one being driven', GUARD.governedTierFor([TIER_S_PATH]) === GUARD.TIER_S); const pinRun = async (where) => { const threadsRead = []; const verdict = await GUARD.runGuard({ event: GUARD.EVENT_MERGE_GROUP, - rows: [{ sha: 'e'.repeat(40), subject: `x (#${PIN_PR})`, pr: PIN_PR, paths: [`${GUARD.REFERENCES_TIER_PREFIX}contract-review.md`] }], + rows: [{ sha: 'e'.repeat(40), subject: `x (#${PIN_PR})`, pr: PIN_PR, paths: [TIER_S_PATH] }], fetchReviews: async () => [], fetchPull: async () => ({ sha: PIN_HEAD, body: `Fixes #${PIN_CARD}`, headRef: `claude/issue-${PIN_CARD}-x` }), fetchComments: async (n) => { diff --git a/scripts/pm/check-governed-merges.mjs b/scripts/pm/check-governed-merges.mjs index 9cef48e0cb..ef8565bc32 100644 --- a/scripts/pm/check-governed-merges.mjs +++ b/scripts/pm/check-governed-merges.mjs @@ -42,7 +42,13 @@ * `--test` mode is a PREDICATE, so it answers on its own codes and shares only * the failure code with the sweep: * 0 the given paths are NOT governed — ordinary queue landing applies. - * 3 the given paths ARE governed — human merge only. Deliberately NOT 1 or + * 3 the given paths ARE governed — landing per the TIER the verdict line and + * `--json` name (Tier H: a human merge or an authorized approval; Tier S: + * a review of record on the PR thread — "The two landing tiers" below). + * ONE code for both tiers, deliberately: every reader of this status asks + * "may a seat arm this on green alone?", and the answer is no for both; + * the tier is a second fact and travels in the words and the JSON, never + * in the status, so no `$?` reader learns a new number. Deliberately NOT 1 or * 2: a governed verdict must be impossible to confuse with the sweep's * "could not sweep" / "incomplete", so `if cmd; then` and `$?` readings * cannot silently turn a governed answer into an environment complaint. @@ -210,6 +216,52 @@ * explicitly REJECTED by the same ruling. Removing this row NARROWS what the * exception machinery lifts; nothing that was governed became clear. * + * ## The two landing tiers (#19133, ruled 2026-09-18) + * + * The maintainer, on the skills seat's proposal, verbatim 「同意改规则。」, then + * 「我觉得这些我也没必要确认:.claude/settings.json、.claude/hooks/**」. The + * proposal, verbatim: 「把『受管 = 人合』的范围缩到真正的法——`AGENTS.md`、 + * `CLAUDE.md`、`docs/adr/**`、`docs/NORTH-STAR.md`、`.claude/settings.json`、 + * `.claude/hooks/**`;而 `.claude/skills/**`、`.claude/agents/**` 这些舰队自己的 + * 仪器,凭席内 `CONTRACT_REVIEW_TIER` 复核 PASS 就走队列,事后由总监席的受管合并 + * 审计(职责四)抽查。」 — and the amendment moved settings and hooks over too. + * + * MEMBERSHIP did not move: every row below still governs exactly what it did, + * one hit still forks the whole PR, and `--test` still exits 3 on every one of + * them. What moved is the LANDING each row waits for, carried as `tier` on the + * row and derived per PR by `governedTierFor`: + * + * H 人合 — `docs/adr/**`, `docs/NORTH-STAR.md`, `skills/**`, `AGENTS.md`, + * `CLAUDE.md`: the maintainer's hand, or an authorized APPROVED review by + * `GOVERNED_APPROVERS` and then the owning seat lands it. Unchanged. + * S 席内达档复核落地 — the whole `.claude/**` tree: the owning seat lands it + * through the queue once the PR thread (or its card) carries a + * `## Contract review` record for the PR's CURRENT head with + * `Served-tier: CONTRACT_REVIEW_TIER` and `**VERDICT: PASS**`, + * `check-clause2-carriers.mjs --pair N` reads 0 and every check is green. + * Exactly the path the fact layer (`.claude/skills/pm-dispatch/references/`) + * used since #17950, generalised to the tier; the post-merge audit (this + * sweep) and the director seat's 职责四 are the compensating control, and a + * Tier S merge without a PASS record is that audit's finding. + * + * ⭐ ALL, not ANY: a PR is Tier S only when EVERY governed path in it lies under + * a Tier S row — 「混合 diff 一条命中即整 PR 分叉」 one level down. One Tier H path + * and the whole PR is Tier H; an empty or ungoverned list answers H (fail + * closed — the tier of nothing is never the cheaper one), and the tier is + * recomputed on the LIFTED slice, since a certified regeneration under + * `skills/**` can be the only Tier H hit in a diff. `.claude/settings.json` is + * the permission set itself and lands under Tier S by the amendment; the + * auto-mode classifier still refuses AI writes to it, so the maintainer's hand + * stays its writer and the tier removes only the approval click. + * + * Published `skills/**` stays Tier H: the seat did not propose moving it. + * `check-governed-queue-guard.mjs` reads `tier` and `governedTierFor` from HERE + * (a module-scope import; the cycle that forces its mirrors is with + * `check-half-states.mjs`, not with this file), so there is ONE register and one + * tier function, and `check-governed-prose.mjs` keeps pinning the SET of globs + * the instruction prose names — the tier is an attribute the prose states in + * words, which that gate deliberately does not parse. + * * ### The generator co-edit fence (#11084) — a NARROWING, not a widening * * A recompute has to run the tree under test's OWN generator (it must — the @@ -752,8 +804,9 @@ import { isEntrypoint } from '../invoked-as.mjs'; // must not red. A battery BELOW its floor means cases stopped running; the // remedy is to find what stopped registering. const SELF_TEST_BATTERIES = Object.freeze({ - 'the governed predicate: the 2026-08-18 unified list, exactly': 8, - 'the dispatch-gates declaration (#9979)': 8, + 'the governed predicate: the 2026-08-18 unified list, exactly': 9, + 'the dispatch-gates declaration (#9979)': 9, + '⚖️ the two landing tiers (#19133, ruled 2026-09-18)': 19, 'since parsing': 4, 'the window: landing order, not committer dates (#12633)': 15, 'since-ref is topological (#12633 route B)': 7, @@ -782,7 +835,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ // DELETING an entry silences that battery's floor exactly as effectively as // zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 25; +const SELF_TEST_BATTERY_FLOOR = 26; // 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 @@ -857,23 +910,50 @@ export const EXIT_INCOMPLETE = 2; export const EXIT_TEST_GOVERNED = 3; export const EXIT_TEST_NOT_GOVERNED = 0; +/** + * The two landing tiers (header section "The two landing tiers"). Every register + * row carries one as `tier`; `governedTierFor` derives a PR's tier from the rows + * its governed paths hit. The VALUES are single letters on purpose — they render + * inside verdict lines and `--json`, and a reader greps `Tier H` / `Tier S`. + * `landing` is the sentence a verdict prints for the tier: what lifts the four + * prohibitions, and who lands after that. + */ +export const GOVERNED_TIER_H = 'H'; +export const GOVERNED_TIER_S = 'S'; +export const GOVERNED_TIERS = Object.freeze({ + [GOVERNED_TIER_H]: Object.freeze({ + id: GOVERNED_TIER_H, + name: 'Tier H(人合)', + landing: "the maintainer's hand, or an authorized APPROVED review (GOVERNED_APPROVERS) and then the owning seat lands it", + }), + [GOVERNED_TIER_S]: Object.freeze({ + id: GOVERNED_TIER_S, + name: 'Tier S(席内达档复核落地)', + landing: + 'the owning seat lands it through the queue on a `## Contract review` record for the CURRENT head ' + + '(`Served-tier: CONTRACT_REVIEW_TIER`, `**VERDICT: PASS**`), `check-clause2-carriers.mjs --pair N` at 0 and every check green', + }), +}); + /** * The governed surfaces, in report order — the 2026-08-18 unified definition - * (see header). `prefix` entries match path prefixes; `exact` entries match - * one repo-relative path byte-for-byte (the repo-ROOT instruction files, not - * `examples/AGENTS.md`, not template copies — and `docs/NORTH-STAR.md`, the - * maintainer's North Star, joined 2026-09-18 on his word as the law above - * `AGENTS.md`; the PM skill cites it by section). One path hit governs a whole - * PR — 「混合 diff 一条命中即整 PR 分叉」; proportion is never a question. - * The register is repo-agnostic: it applies in all of `GOVERNED_REPOS`. + * (see header), tiered by the 2026-09-18 ruling. `prefix` entries match path + * prefixes; `exact` entries match one repo-relative path byte-for-byte (the + * repo-ROOT instruction files, not `examples/AGENTS.md`, not template copies — + * and `docs/NORTH-STAR.md`, the maintainer's North Star, joined 2026-09-18 on + * his word as the law above `AGENTS.md`; the PM skill cites it by section). One + * path hit governs a whole PR — 「混合 diff 一条命中即整 PR 分叉」; proportion is + * never a question, and `tier` decides only what the landing waits for, never + * whether the path is governed. The register is repo-agnostic: it applies in + * all of `GOVERNED_REPOS`. */ export const GOVERNED_SURFACES = Object.freeze([ - Object.freeze({ id: 'adr', prefix: 'docs/adr/', glob: 'docs/adr/**', what: 'architecture decision records' }), - Object.freeze({ id: 'claude-tree', prefix: '.claude/', glob: '.claude/**', what: 'the agent instruction tree (skills, agents, hooks, settings)' }), - Object.freeze({ id: 'skills-catalog', prefix: 'skills/', glob: 'skills/**', what: 'the published skills catalog' }), - Object.freeze({ id: 'agents-md', exact: 'AGENTS.md', glob: 'AGENTS.md', what: 'the repo-root agent instruction file' }), - Object.freeze({ id: 'claude-md', exact: 'CLAUDE.md', glob: 'CLAUDE.md', what: 'the repo-root Claude instruction file' }), - Object.freeze({ id: 'north-star', exact: 'docs/NORTH-STAR.md', glob: 'docs/NORTH-STAR.md', what: "the maintainer's North Star — the law above AGENTS.md" }), + Object.freeze({ id: 'adr', prefix: 'docs/adr/', glob: 'docs/adr/**', tier: GOVERNED_TIER_H, what: 'architecture decision records' }), + Object.freeze({ id: 'claude-tree', prefix: '.claude/', glob: '.claude/**', tier: GOVERNED_TIER_S, what: 'the agent instruction tree (skills, agents, hooks, settings)' }), + Object.freeze({ id: 'skills-catalog', prefix: 'skills/', glob: 'skills/**', tier: GOVERNED_TIER_H, what: 'the published skills catalog' }), + Object.freeze({ id: 'agents-md', exact: 'AGENTS.md', glob: 'AGENTS.md', tier: GOVERNED_TIER_H, what: 'the repo-root agent instruction file' }), + Object.freeze({ id: 'claude-md', exact: 'CLAUDE.md', glob: 'CLAUDE.md', tier: GOVERNED_TIER_H, what: 'the repo-root Claude instruction file' }), + Object.freeze({ id: 'north-star', exact: 'docs/NORTH-STAR.md', glob: 'docs/NORTH-STAR.md', tier: GOVERNED_TIER_H, what: "the maintainer's North Star — the law above AGENTS.md" }), ]); /** @@ -1051,6 +1131,10 @@ export function applyGeneratedExceptions(verdict, provenanceByPath = new Map()) matched, hitPaths: verdict.hitPaths.filter((p) => !lifted.has(p)), governed: matched.length > 0, + // Recomputed on the LIFTED slice (#19133): the lifted path may have been + // the only Tier H hit, and a stale pre-lift `H` would send a Tier S diff to + // the maintainer's click the ruling removed. + tier: matched.length > 0 ? landingTierOf(matched) : null, exceptions, }; } @@ -1088,6 +1172,35 @@ export function governedPathsIn(paths) { })).filter((surface) => surface.files.length > 0); } +/** + * The landing tier of an already-matched governed slice (`governedPathsIn`'s + * shape — post-lift callers hand in the lifted slice, so a certified + * regeneration that was the only Tier H hit no longer decides). Pure. + * + * ⭐ ALL, not ANY: `S` only when every matched row is Tier S. An EMPTY slice + * answers `H` — fail closed: a caller that reached a tier question with nothing + * governed has lost the fact the question rests on, and the tier of nothing is + * never the cheaper one. `testVerdict` reports `null` for that case itself, + * before asking here. + */ +export function landingTierOf(matched) { + const list = Array.isArray(matched) ? matched : []; + if (list.length === 0) return GOVERNED_TIER_H; + return list.every((s) => s?.tier === GOVERNED_TIER_S) ? GOVERNED_TIER_S : GOVERNED_TIER_H; +} + +/** + * The landing tier of a PR with these paths — `H` or `S`, never anything else. + * Register-keyed: the rows the paths hit carry the answer, so no second copy of + * "which surfaces land on a record" exists anywhere (the queue guard imports + * THIS function). Ungoverned riders in the list are not consulted and cannot + * demote a Tier H list or promote a Tier S one; an empty or wholly ungoverned + * list answers `H` (see `landingTierOf`). + */ +export function governedTierFor(paths) { + return landingTierOf(governedPathsIn(paths)); +} + /** `owner/name` out of any git remote spelling, or null. Pure. */ export function slugFromRemote(url) { const m = /(?:github\.com[:/])([\w.-]+\/[\w.-]+?)(?:\.git)?\/*\s*$/.exec(String(url ?? '')); @@ -1218,6 +1331,9 @@ export function testVerdict(paths) { const hit = new Set(matched.flatMap((s) => s.files)); return { governed: matched.length > 0, + // The landing tier (#19133): `H` / `S` while governed, `null` when nothing + // is — a `--json` reader never sees a tier for a diff that has none. + tier: matched.length > 0 ? landingTierOf(matched) : null, checked: list.length, surfacesChecked: GOVERNED_SURFACES.length, matched, @@ -1279,11 +1395,31 @@ export function renderTestVerdict(verdict) { return ` ${s.glob} ×${s.files.length} — ${s.what}\n${files}`; }); const clear = verdict.clearPaths.length > 0 ? `\n paths not on the register: ${verdict.clearPaths.slice(0, 8).join(', ')}` : ''; + // The tier (#19133): Tier H keeps its wording word for word and gains one + // line naming its tier; Tier S prints its OWN verdict block naming the + // record-on-thread landing. Both say GOVERNED and the same three verbs, so + // every grep reader of this text keeps its answer; both exit 3. + const tier = verdict.tier ?? landingTierOf(verdict.matched); + if (tier === GOVERNED_TIER_S) { + return ( + `${head}\n` + + ` ⛔ GOVERNED — Tier S(席内达档复核落地): every governed path here lies under a Tier S surface, so the OWNING\n` + + ` seat lands this PR through the queue once its thread (or its card) carries a \`## Contract review\` record\n` + + ` for the CURRENT head — \`Served-tier: CONTRACT_REVIEW_TIER\`, \`**VERDICT: PASS**\` — with\n` + + ` \`check-clause2-carriers.mjs --pair N\` at 0 and every check green (AGENTS.md Prime Directive #14).\n` + + ` Before that record no seat flips it ready, enqueues it, or arms auto-merge; ⛔ no seat approves it either,\n` + + ` and no maintainer click is waited for. One hit governs the whole PR — 「混合 diff 一条命中即整 PR 分叉」;\n` + + ` one Tier H path among the hits and the whole PR would be Tier H.\n` + + `${lines.join('\n')}${clear}` + + renderExceptionLines(verdict) + ); + } return ( `${head}\n` + ` ⛔ GOVERNED — a human merge is the review record for this PR (#9495 regime).\n` + ` No seat flips it ready, enqueues it, or arms auto-merge (AGENTS.md Prime Directive #14).\n` + ` One hit governs the whole PR — 「混合 diff 一条命中即整 PR 分叉」; proportion is not a question.\n` + + ` ⚖️ landing tier: H(人合) — ${GOVERNED_TIERS[GOVERNED_TIER_H].landing}.\n` + `${lines.join('\n')}${clear}` + renderExceptionLines(verdict) ); @@ -3237,7 +3373,7 @@ async function selfTest() { assert('north-star-exact', ids(['docs/NORTH-STAR.md']).join() === 'north-star'); // Near misses, each load-bearing: prefixes need their trailing slash; the // exact entries are the repo-root files only (see header). - assert('near-misses-stay-out', ids(['docs/adrs/z.md', '.claude-x/y.md', 'skillsx/a.md', 'examples/AGENTS.md', 'packages/create-objectstack/src/templates/AGENTS.md', 'apps/CLAUDE.md.bak', 'docs/north-star.md', 'docs/NORTH-STAR.md.bak', 'examples/docs/NORTH-STAR.md']).length === 0, JSON.stringify(ids(['examples/AGENTS.md']))); + assert('near-misses-stay-out', ids(['docs/adrs/z.md', '.claude-x/y.md', 'skillsx/a.md', 'examples/AGENTS.md', 'packages/create-objectstack/src/templates/AGENTS.md', 'apps/CLAUDE.md.bak', 'docs/north-star.md', 'docs/NORTH-STAR.md.bak', 'examples/docs/NORTH-STAR.md', 'docs/NORTH-STAR.mdx', 'content/docs/concepts/north-star.mdx']).length === 0, JSON.stringify(ids(['examples/AGENTS.md']))); assert('a-mixed-diff-groups-by-surface', ids(['docs/adr/0001.md', 'AGENTS.md', 'package.json']).join() === 'adr,agents-md'); // ── the dispatch-gates declaration (#9979) ─────────────────────────────── @@ -3266,6 +3402,47 @@ async function selfTest() { assert('mid-title-issue-citation-is-not-the-pr', pullNumberFromSubject('docs: checklist names the renamed check run (#9420) (#9490)') === 9490); assert('no-pr-in-subject', pullNumberFromSubject('chore: direct push') === null); + // ── the two landing tiers (#19133, ruled 2026-09-18) ───────────────────── + // + // Membership is pinned above and unchanged; what this battery pins is the + // LANDING attribute: which rows carry S, that a PR is S only when every + // governed path is S, that the verdict line and `--json` carry the tier while + // the exit stays shared, and that a lift recomputes it. + battery('⚖️ the two landing tiers (#19133, ruled 2026-09-18)'); + const tierOf = (id) => GOVERNED_SURFACES.find((s) => s.id === id)?.tier; + assert('every-row-carries-a-tier-and-it-is-H-or-S', GOVERNED_SURFACES.every((s) => s.tier === GOVERNED_TIER_H || s.tier === GOVERNED_TIER_S), JSON.stringify(GOVERNED_SURFACES.map((s) => [s.id, s.tier]))); + assert('the-tier-values-are-distinct-single-letters-a-reader-greps', GOVERNED_TIER_H === 'H' && GOVERNED_TIER_S === 'S' && Object.keys(GOVERNED_TIERS).join() === 'H,S'); + assert('Tier-S-is-exactly-the-whole-claude-tree-row', GOVERNED_SURFACES.filter((s) => s.tier === GOVERNED_TIER_S).map((s) => s.id).join() === 'claude-tree'); + assert('Tier-H-is-the-law-adr-north-star-skills-catalog-agents-md-claude-md', GOVERNED_SURFACES.filter((s) => s.tier === GOVERNED_TIER_H).map((s) => s.id).join() === 'adr,skills-catalog,agents-md,claude-md,north-star'); + assert('settings-and-hooks-are-Tier-S-by-the-amendment', governedTierFor(['.claude/settings.json', '.claude/hooks/guard-main-checkout.sh']) === GOVERNED_TIER_S); + assert('skills-agents-and-the-fact-layer-are-Tier-S', governedTierFor(['.claude/skills/pm-dispatch/SKILL.md', '.claude/agents/os-dev.md', '.claude/skills/pm-dispatch/references/contract-review.md']) === GOVERNED_TIER_S); + assert('each-Tier-H-surface-answers-H-alone', ['docs/adr/0001-x.md', 'docs/NORTH-STAR.md', 'skills/objectstack-ui/SKILL.md', 'AGENTS.md', 'CLAUDE.md'].every((p) => governedTierFor([p]) === GOVERNED_TIER_H)); + assert('ALL-not-ANY-one-Tier-H-path-makes-the-whole-list-Tier-H', governedTierFor(['.claude/agents/os-dev.md', '.claude/settings.json', 'AGENTS.md']) === GOVERNED_TIER_H); + assert('an-ungoverned-rider-neither-demotes-a-Tier-H-list-nor-promotes-a-Tier-S-one', governedTierFor(['README.md', 'AGENTS.md']) === GOVERNED_TIER_H && governedTierFor(['README.md', '.claude/agents/os-dev.md']) === GOVERNED_TIER_S); + assert('an-EMPTY-or-UNGOVERNED-list-answers-H-fail-closed', governedTierFor([]) === GOVERNED_TIER_H && governedTierFor(undefined) === GOVERNED_TIER_H && governedTierFor(['README.md']) === GOVERNED_TIER_H); + assert('the-published-skills-catalog-stays-Tier-H-the-seat-did-not-propose-moving-it', tierOf('skills-catalog') === GOVERNED_TIER_H); + assert('--test-carries-the-tier-and-null-when-nothing-is-governed', testVerdict(['.claude/agents/os-dev.md']).tier === GOVERNED_TIER_S && testVerdict(['AGENTS.md']).tier === GOVERNED_TIER_H && testVerdict(['README.md']).tier === null); + const tierSCase = testVerdict(['packages/spec/src/index.ts', '.claude/agents/os-dev.md']); + const tierHCase = testVerdict(['packages/spec/src/index.ts', '.claude/agents/os-dev.md', 'AGENTS.md']); + const tierSText = renderTestVerdict(tierSCase); + const tierHText = renderTestVerdict(tierHCase); + assert('a-Tier-S-verdict-renders-its-OWN-block-naming-the-record-on-thread-landing', /Tier S/.test(tierSText) && /## Contract review/.test(tierSText) && /CONTRACT_REVIEW_TIER/.test(tierSText) && /--pair/.test(tierSText), tierSText); + assert('and-still-says-GOVERNED-with-the-three-verbs-so-every-grep-reader-keeps-its-answer', /GOVERNED/.test(tierSText) && /arms auto-merge/.test(tierSText) && !/human merge is the review record/.test(tierSText), tierSText); + assert('a-Tier-H-verdict-keeps-its-wording-word-for-word-and-names-its-tier', /a human merge is the review record for this PR/.test(tierHText) && /landing tier: H/.test(tierHText) && !/Tier S/.test(tierHText), tierHText); + assert('a-mixed-list-renders-H-the-one-Tier-H-path-decides', tierHCase.tier === GOVERNED_TIER_H && tierHCase.hitPaths.join() === '.claude/agents/os-dev.md,AGENTS.md', JSON.stringify(tierHCase.hitPaths)); + assert('exit-3-is-SHARED-by-both-tiers-the-tier-lives-in-the-words-and-the-json-never-the-status', runTestModeExitFor(['.claude/agents/os-dev.md']) === EXIT_TEST_GOVERNED && runTestModeExitFor(['AGENTS.md']) === EXIT_TEST_GOVERNED && runTestModeExitFor(['README.md']) === EXIT_TEST_NOT_GOVERNED); + // A lift can change the tier: a diff whose only Tier H hit is a certified + // regeneration under `skills/**` is, post-lift, a Tier S diff — the tier is + // recomputed on the lifted slice, never read off the pre-lift one. + const liftPath = 'skills/objectstack-ui/references/_index.md'; + const lifted = new Map([[liftPath, { pureRegeneration: true, reason: 'byte-equal to the generator' }]]); + const preLift = testVerdict([liftPath, '.claude/agents/os-dev.md']); + const postLift = applyGeneratedExceptions(preLift, lifted); + assert('the-tier-is-recomputed-on-the-LIFTED-slice', preLift.tier === GOVERNED_TIER_H && postLift.tier === GOVERNED_TIER_S && postLift.governed === true, JSON.stringify([preLift.tier, postLift.tier])); + const allLifted = applyGeneratedExceptions(testVerdict([liftPath]), lifted); + assert('and-null-once-every-hit-is-lifted', allLifted.governed === false && allLifted.tier === null, JSON.stringify(allLifted.tier)); + assert('the-tier-words-name-the-landing-each-waits-for', /GOVERNED_APPROVERS/.test(GOVERNED_TIERS.H.landing) && /Contract review/.test(GOVERNED_TIERS.S.landing) && /--pair/.test(GOVERNED_TIERS.S.landing)); + // ── --since parsing ─────────────────────────────────────────────────────── battery('since parsing'); const now = new Date('2026-08-18T12:00:00Z'); @@ -5021,7 +5198,7 @@ async function selfTest() { for (const failure of failures) console.error(` • ${failure}`); process.exit(1); } - console.log(`✓ check-governed-merges --self-test: ${checked} assertions (the unified governed predicate + near misses, subject→PR spellings, window parsing, the #12633 landing window — the QS-7 regression pin in both directions, the topological close beyond the budget, the unproven-boundary EDGE, the listed-or-INCOMPLETE invariant over every fixture, the escalating floors, per-repo --since-ref resolution and its named fallback, and the window words — the replay fixtures, the five-repo resolution incl. absent/wrong-origin/relocated checkouts, the attribution channel chain + its proxy-transport re-arm plan and its one named fallback line, the three-way attribution column (resolved · every-channel-failed · NOT LOOKED UP, and the note pointer that belongs to the middle one alone), the --test pre-arm predicate, the generated-artifact provenance exception — the register's invariants incl. the RETIRED #9866 row staying retired (no row lifts anything under .claude/**, and the audit workflow is plainly governed again), a row with no recompute failing closed, lift/reject/absent-provenance semantics, the untouched mixed-diff rule, named-rows-not-a-class, the #11084 generator co-edit fence in both directions incl. a row with no instrument tree, and its render words — the #11705 generator-owned rows inside skills/** (a genuine generated file passes, the same path hand-edited does not, a path no generator declares is hand-authored content, per-row fences, and the enumeration read from the real generator), the exit table, the report wording pins, and the #13307 remote-reachability leg — the pure freshness verdicts in every branch (unreachable · a remote naming no commit · an unreadable local tip · a mirror behind its remote · the two-unreadable-shas degenerate case that must never read as a match), the report words in both directions (an unreachable repo never renders the tick, a reachable one still says a MEASURED zero, and a row with no remote reading never claims one), and the REAL prober on local bare-repo fixtures over the file transport — a live remote, a deleted one, the --exit-code branch, and a mirror the remote moved past — the #13423 identity leg (an origin no slug parses from refuses, pure and end-to-end, with audited reachable only through a parsed matching slug), the #13424 per-repo window resolution (a sibling-only pin resolves in its own repo, the self-only control still errors, and the end-to-end sibling-pin sweep reports instead of exiting 1), the #13307 sweep-code provenance line in all three branches, and the #13836 attribution set — every refusal carries its precondition category on the row, in the footer, and in --json; the shallow-clone path in both directions; and the run-1-vs-run-2 flip reproduced on real fixtures with zero local writes — and the live battery's own PREREQUISITE, asked before a single case runs: an uninstalled checkout refuses with the repo-wide NOT-MEASURED code end to end instead of reporting a shrunken battery, while the floor still names the battery, by itself, for a case that genuinely stopped registering) — and the #15406 replay of PR #15284: the sweep still CLASSIFIES a certified regeneration as a governed merge and still lists it, its row now names the register row it does not recompute and where certification is recorded, and the --test head no longer reports a post-lift zero as if nothing had hit the register — and the #17003 derivation set: the Link walk that ends on rel=next rather than on a short page, a rename reaching the predicate as BOTH of its paths, a walk the PR's own count contradicts refusing rather than answering on a subset, a channel chosen once and never spliced mid-walk, every --branch leg on an injected git incl. the uncomputable merge base that REFUSES instead of falling back to two-dot, and the card's own reproduction run end to end on a real repo — a branch behind a main that has since touched a governed path answers GOVERNED two-dot and NOT governed three-dot, a rename out of a governed path is a hit only because the diff is taken --no-renames, the merge-base refusal prints no verdict at all, and the verdict is byte-identical through --branch and through --test on the same list. — and the #18055 banner set: the INCOMPLETE banner is BUILT on the attribution-failure path instead of throwing while it is built, it still returns EXIT_INCOMPLETE, the proxy hint renders from the plan the sweep now binds and stays empty both when the plan says no hint and when the incompleteness is not about attribution, and a real sweep whose every attribution channel fails prints the banner on STDERR and exits 2.\n ${liveNote}`); + console.log(`✓ check-governed-merges --self-test: ${checked} assertions (the unified governed predicate + near misses, subject→PR spellings, window parsing, the #12633 landing window — the QS-7 regression pin in both directions, the topological close beyond the budget, the unproven-boundary EDGE, the listed-or-INCOMPLETE invariant over every fixture, the escalating floors, per-repo --since-ref resolution and its named fallback, and the window words — the replay fixtures, the five-repo resolution incl. absent/wrong-origin/relocated checkouts, the attribution channel chain + its proxy-transport re-arm plan and its one named fallback line, the three-way attribution column (resolved · every-channel-failed · NOT LOOKED UP, and the note pointer that belongs to the middle one alone), the --test pre-arm predicate, the generated-artifact provenance exception — the register's invariants incl. the RETIRED #9866 row staying retired (no row lifts anything under .claude/**, and the audit workflow is plainly governed again), a row with no recompute failing closed, lift/reject/absent-provenance semantics, the untouched mixed-diff rule, named-rows-not-a-class, the #11084 generator co-edit fence in both directions incl. a row with no instrument tree, and its render words — the #11705 generator-owned rows inside skills/** (a genuine generated file passes, the same path hand-edited does not, a path no generator declares is hand-authored content, per-row fences, and the enumeration read from the real generator), the exit table, the report wording pins, and the #13307 remote-reachability leg — the pure freshness verdicts in every branch (unreachable · a remote naming no commit · an unreadable local tip · a mirror behind its remote · the two-unreadable-shas degenerate case that must never read as a match), the report words in both directions (an unreachable repo never renders the tick, a reachable one still says a MEASURED zero, and a row with no remote reading never claims one), and the REAL prober on local bare-repo fixtures over the file transport — a live remote, a deleted one, the --exit-code branch, and a mirror the remote moved past — the #13423 identity leg (an origin no slug parses from refuses, pure and end-to-end, with audited reachable only through a parsed matching slug), the #13424 per-repo window resolution (a sibling-only pin resolves in its own repo, the self-only control still errors, and the end-to-end sibling-pin sweep reports instead of exiting 1), the #13307 sweep-code provenance line in all three branches, and the #13836 attribution set — every refusal carries its precondition category on the row, in the footer, and in --json; the shallow-clone path in both directions; and the run-1-vs-run-2 flip reproduced on real fixtures with zero local writes — and the live battery's own PREREQUISITE, asked before a single case runs: an uninstalled checkout refuses with the repo-wide NOT-MEASURED code end to end instead of reporting a shrunken battery, while the floor still names the battery, by itself, for a case that genuinely stopped registering) — and the #15406 replay of PR #15284: the sweep still CLASSIFIES a certified regeneration as a governed merge and still lists it, its row now names the register row it does not recompute and where certification is recorded, and the --test head no longer reports a post-lift zero as if nothing had hit the register — and the #17003 derivation set: the Link walk that ends on rel=next rather than on a short page, a rename reaching the predicate as BOTH of its paths, a walk the PR's own count contradicts refusing rather than answering on a subset, a channel chosen once and never spliced mid-walk, every --branch leg on an injected git incl. the uncomputable merge base that REFUSES instead of falling back to two-dot, and the card's own reproduction run end to end on a real repo — a branch behind a main that has since touched a governed path answers GOVERNED two-dot and NOT governed three-dot, a rename out of a governed path is a hit only because the diff is taken --no-renames, the merge-base refusal prints no verdict at all, and the verdict is byte-identical through --branch and through --test on the same list. — and the #18055 banner set: the INCOMPLETE banner is BUILT on the attribution-failure path instead of throwing while it is built, it still returns EXIT_INCOMPLETE, the proxy hint renders from the plan the sweep now binds and stays empty both when the plan says no hint and when the incompleteness is not about attribution, and a real sweep whose every attribution channel fails prints the banner on STDERR and exits 2 — and the #19133 landing tiers: every register row carries H or S, Tier S is exactly the .claude/** row, a list is S only when every governed path is S (empty or ungoverned answers H), the verdict line and --json carry the tier while both tiers share exit 3, and the tier is recomputed on the lifted slice.\n ${liveNote}`); return SELF_TEST_VERDICT; } diff --git a/scripts/pm/check-governed-prose.mjs b/scripts/pm/check-governed-prose.mjs index 7b6f26d8d2..c4e3a8d6c3 100644 --- a/scripts/pm/check-governed-prose.mjs +++ b/scripts/pm/check-governed-prose.mjs @@ -50,6 +50,13 @@ * covers the three glob-shaped ones. That boundary is the honest claim, and it * is stated here rather than implied by the code. * + * ⛔ The register's `tier` (H / S, the 2026-09-18 landing tiers) is likewise + * NOT parsed out of the prose. Tiers are an attribute of a row, not a second + * set: a two-tier paragraph still yields ONE set of globs, and that set is + * what this gate pins. Which row lands on which record is pinned where the + * attribute lives — `check-governed-merges.mjs --self-test` — and the prose + * states it in words this gate deliberately does not read. + * * ## Regions, and why a missing anchor is RED * * A region is delimited by two literal anchors that already exist in the file, @@ -145,7 +152,7 @@ export const PROSE_SURFACES = Object.freeze([ Object.freeze({ path: 'AGENTS.md', what: 'Prime Directive #14 — the definition every seat reads before a ready-flip', - start: 'A governed surface is confirmed and merged by the maintainer, by hand', + start: 'A governed surface lands only the way its tier allows', end: 'A version release is performed by the maintainer, by hand', }), Object.freeze({ diff --git a/scripts/pm/check-governed-queue-guard.mjs b/scripts/pm/check-governed-queue-guard.mjs index 9a111f6c0a..aa926bef69 100644 --- a/scripts/pm/check-governed-queue-guard.mjs +++ b/scripts/pm/check-governed-queue-guard.mjs @@ -407,9 +407,12 @@ import { fileURLToPath } from 'node:url'; import { GENERATED_SURFACE_EXCEPTIONS, GOVERNED_SURFACES, + GOVERNED_TIER_H, + GOVERNED_TIER_S, applyGeneratedExceptions, generatedExceptionFor, governedPathsIn, + governedTierFor, groupHitsByException, pullNumberFromSubject, recomputeProvenanceFor, @@ -443,8 +446,8 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'the 2026-09-04 unpinned predicate (the queue leg\'s)': 12, 'decomposition, and the multi-PR group trap': 6, 'the verdict table, both events': 10, - 'the pull_request leg is an EARLY WARNING and never reddens': 3, - 'the replay fixtures: the three incidents this guard descends from': 9, + 'the pull_request leg is an EARLY WARNING and never reddens': 5, + 'the replay fixtures: the three incidents this guard descends from': 11, '⭐ the ordering guarantee, measured with a spy that THROWS': 7, 'the words a reader acts on (requirement (e))': 26, '⭐ #14063 END TO END: what the dependency install actually buys': 9, @@ -453,7 +456,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ '⭐ #14063: the environment the exemption needs, pinned to the YAML': 7, '⭐ #15406: a CLEAR reached through a lift is not a clear that saw nothing': 10, '⛔ #17040: the contract-review carrier is the enqueue gate': 39, - '⭐ #18020: the references tier — a review of record, not an approval': 40, + '⭐ #18020 → #19133 Tier S: a review of record, not an approval': 43, '⭐ #18701: the record lives on the PR or its card, and BOTH are read': 14, }); @@ -548,39 +551,47 @@ export const GOVERNED_APPROVERS = Object.freeze(['os-zhuang', 'hotlong']); */ export const CONTRACT_REVIEW_LABEL = 'needs:contract-review'; -// ── the references TIER: a review of record in place of an approval (#18020) ─ +// ── the landing TIER: Tier S lands on a review of record, Tier H on an approval ─ /** - * The ONE governed prefix that lands through the merge queue on a review of - * record (#17950, ruled 2026-09-13 「我点头」; charter text landed by PR #18018). - * - * ⛔ Spelled as a PREFIX with its trailing slash, never as a `**` glob. The - * glob shape is the REGISTER's vocabulary — `check-governed-prose` reads every - * `**`-shaped code span in an instruction surface as a claim about - * `GOVERNED_SURFACES` — and this is not a register entry: the register's - * `.claude/**` row still AUDITS every path under it, exactly as before. What - * this constant names is a LANDING tier inside that row, which is why ⛔ nothing - * here touches `GOVERNED_SURFACES` and why a seat reading the register still - * gets the same answer to "is this governed": yes. - * - * The trailing slash is load-bearing rather than tidy: without it - * `.claude/skills/pm-dispatch/references-draft/x.md` would classify into the - * tier on a bare `startsWith`, and a sibling directory one character away from - * the ruled one is the cheapest possible way to widen a governance boundary - * nobody agreed to widen. The battery pins that path in the refusing direction. + * The two landing tiers, READ from the register (#19133, ruled 2026-09-18 + * 「同意改规则。」 on the skills seat's proposal; the amendment moved + * `.claude/settings.json` and `.claude/hooks/**` over too: 「我觉得这些我也没 + * 必要确认」). Every `GOVERNED_SURFACES` row carries `tier`, and + * `governedTierFor` — imported from the register's own file at module scope, + * like every other predicate this guard reads — derives a pull request's tier + * from the rows its GOVERNED paths hit. ⛔ Nothing here spells a prefix, a glob + * or a row: the #18020 references tier was a prefix constant declared here + * (`REFERENCES_TIER_PREFIX`, the fact layer alone); it is gone, and a seat + * reading the register gets the same answer to "what lands this on a record" + * as this guard does. The register's `.claude/**` row still AUDITS every path + * under it exactly as before — the tier is a LANDING rule inside the row, never + * a membership one, which is why ⛔ nothing here touches `GOVERNED_SURFACES`. + * + * `H` (人合) is the DEFAULT in every ambiguous case, because the two are not + * symmetric: reading a Tier H path as S lands a maintainer-owned file on a + * seat's own review, while reading a Tier S path as H costs one authorized + * approval — the maintainer is asked to look at a pull request they need not + * have, and the claiming seat lands it from there. An empty list answers H for + * the same reason (the register's `landingTierOf` does; see its docblock). + * + * ⭐ ALL, not ANY, and not a proportion: 「混合 diff 一条命中即整 PR 分叉」 is the + * regime's own rule one level up, and this is the same rule one level down. One + * Tier H path in the diff and the whole pull request is Tier H. The paths + * handed to `governedTierFor` are the GOVERNED paths of one entry — + * `decomposeGovernedWork` has already dropped everything the register does not + * match, so an ordinary source file riding along cannot demote it. */ -export const REFERENCES_TIER_PREFIX = '.claude/skills/pm-dispatch/references/'; +export const TIER_H = GOVERNED_TIER_H; +export const TIER_S = GOVERNED_TIER_S; /** - * The two landing tiers. `rules` is the DEFAULT in every ambiguous case, - * because the two are not symmetric: reading a rules-layer path as references - * lands a maintainer-owned file on a seat's own review, while reading a - * references path as rules costs one authorized approval — the maintainer is - * asked to look at a pull request they need not have, and the claiming seat - * lands it from there. + * The Tier S surfaces in the register's own glob spelling — for the WORDS a + * verdict prints, never for a second match (the match is `governedTierFor`). */ -export const TIER_RULES = 'rules'; -export const TIER_REFERENCES = 'references'; +export function tierSGlobs() { + return GOVERNED_SURFACES.filter((s) => s.tier === GOVERNED_TIER_S).map((s) => s.glob).join(', '); +} /** * WHERE a review of record may live, in the words a refusal prints — a MIRROR @@ -599,25 +610,12 @@ export const TIER_REFERENCES = 'references'; export const REVIEW_OF_RECORD_LOCATION = 'the PR or its card'; /** - * Which tier a governed pull request's governed paths fall in. - * - * ⭐ ALL, not ANY, and not a proportion: 「混合 diff 一条命中即整 PR 分叉」 is the - * regime's own rule one level up, and this is the same rule one level down. One - * rules-layer path in the diff and the whole pull request is rules-layer. - * - * `paths` are the GOVERNED paths of one entry — `decomposeGovernedWork` has - * already dropped everything the register does not match, so an ordinary source - * file riding along in the same PR is not consulted here and cannot demote it. - * - * An EMPTY list answers `rules`: an entry with no governed paths never reaches - * this function, and a caller that got one anyway has lost the fact this - * decision rests on. + * `governedTierFor` — the register's own function, re-exported so this file's + * readers (`check-clause2-carriers.mjs`'s cross-tool pin) and its battery keep + * one name. ⛔ Not a wrapper and not a copy: the register answers, this file + * relays. The tier docblock above carries the ALL-not-ANY rule and the H default. */ -export function governedTierFor(paths) { - const list = (Array.isArray(paths) ? paths : []).map((path) => String(path ?? '')); - if (list.length === 0) return TIER_RULES; - return list.every((path) => path.startsWith(REFERENCES_TIER_PREFIX)) ? TIER_REFERENCES : TIER_RULES; -} +export { governedTierFor }; /** Where each imported recogniser lives. Named, so a failure can say which file. */ export const RECOGNISER_SOURCES = Object.freeze({ @@ -980,16 +978,16 @@ export function unreadableApproval(reason) { /** * Is this governed pull request SATISFIED? * - * ⭐ The approval limb is first and is unchanged, which is what makes the - * references tier MONOTONE: an entry the 2026-09-04 predicate already cleared - * is cleared here by the same reading, at the same cost, in the same words. The - * tier limb can only ADD a pass, and only on an entry whose `record` key exists - * — merge_group, references tier. ⛔ Never reorder these two: a record consulted - * ahead of an approval would let a seat's own review displace a maintainer's. + * ⭐ The approval limb is first and is unchanged, which is what makes Tier S + * MONOTONE: an entry the 2026-09-04 predicate already cleared is cleared here + * by the same reading, at the same cost, in the same words. The tier limb can + * only ADD a pass, and only on an entry whose `record` key exists — merge_group, + * Tier S. ⛔ Never reorder these two: a record consulted ahead of an approval + * would let a seat's own review displace a maintainer's. */ export function entrySatisfied(entry) { if (entry?.approval?.state === 'approved') return true; - return entry?.tier === TIER_REFERENCES && entry?.record?.state === 'stands'; + return entry?.tier === TIER_S && entry?.record?.state === 'stands'; } /** @@ -1000,7 +998,7 @@ export function entrySatisfied(entry) { */ export function entryUnreadable(entry) { if (entry?.approval?.state === 'unreadable') return true; - return entry?.tier === TIER_REFERENCES && entry?.record?.state === 'unreadable'; + return entry?.tier === TIER_S && entry?.record?.state === 'unreadable'; } /** @@ -1012,10 +1010,10 @@ export function guardVerdict({ event, governed = [], unattributed = [], approval ...entry, approval: approvals.get(entry.pr) ?? unreadableApproval('no review reading was recorded for this pull request'), // ⭐ The record key EXISTS only where the tier leg ran — merge_group, on a - // references-tier entry. That is not a nicety: the `pull_request` leg's - // rendering is byte-identical to the pre-#18020 one BY CONSTRUCTION, since - // the renderer can only print what the key's presence lets it see. - ...(event === EVENT_MERGE_GROUP && entry.tier === TIER_REFERENCES + // Tier S entry. That is not a nicety: the `pull_request` leg's record + // block is absent BY CONSTRUCTION, since the renderer can only print what + // the key's presence lets it see (the tier LINE itself prints on both legs). + ...(event === EVENT_MERGE_GROUP && entry.tier === TIER_S ? { record: records.get(entry.pr) ?? { state: 'unreadable', reason: 'no record reading was recorded for this pull request' } } : {}), })); @@ -1056,6 +1054,23 @@ export function renderGuardVerdict(verdict) { ...s.files.slice(0, 12).map((f) => ` - ${f}`), ...(s.files.length > 12 ? [` … and ${s.files.length - 12} more`] : []), ]); + // ⚖️ The landing tier and what it waits for (#19133), printed on BOTH legs so + // the early warning names the tier as the card requires. The queue leg's + // record block follows it where the record key exists. + const tierLines = (entry) => + entry.tier === TIER_S + ? [ + ` ⚖️ landing tier: S(席内达档复核落地) — every governed path above lies under a Tier S surface (${tierSGlobs()}),`, + ' so a review of record on the CURRENT head lands it in place of an authorized approval: a `## Contract', + ` review\` comment on ${REVIEW_OF_RECORD_LOCATION} naming this head, \`Served-tier: CONTRACT_REVIEW_TIER\`,`, + ' `**VERDICT: PASS**`, `check-clause2-carriers.mjs --pair N` at 0 and every check green; then the OWNING seat', + ' lands it (#19133, maintainer 2026-09-18 「同意改规则。」). One Tier H path here and this line would read H.', + ] + : [ + ' ⚖️ landing tier: H(人合) — waits for the maintainer\'s hand or an authorized APPROVED review', + ` (GOVERNED_APPROVERS: ${GOVERNED_APPROVERS.join(', ')}); then the OWNING seat lands it. No review of record`, + ' substitutes here: one Tier H path in the diff and the whole pull request is Tier H.', + ]; // ⚠️ The `pull_request` leg's wording is BYTE-IDENTICAL to the pre-pinning // guard (the 2026-08-27 card's own constraint) — only the queue leg, where @@ -1128,15 +1143,10 @@ export function renderGuardVerdict(verdict) { for (const entry of verdict.entries) { lines.push('', ` #${entry.pr} — governed:`); lines.push(...surfaceLines(entry)); - // ⭐ Printed only where the tier leg ran, so the rules layer's block and the - // whole `pull_request` leg keep their bytes. - if (entry.record !== undefined) { - lines.push( - ` ⚖️ landing tier: REFERENCES — every governed path above is under ${REFERENCES_TIER_PREFIX}, so a`, - ' review of record on the CURRENT head satisfies this check in place of an authorized approval', - ' (#17950, ruled 2026-09-13 「我点头」). One rules-layer path here and this line would be absent.', - ); - } + // The pre-#19133 `pull_request` byte-identity constraint (2026-08-27) is + // superseded by that ruling's own requirement: the early warning names the + // tier and what it waits for. + lines.push(...tierLines(entry)); if (entry.approval.state === 'approved') { lines.push( queueLeg(entry.approval) @@ -1244,26 +1254,51 @@ export function renderGuardVerdict(verdict) { lines.push(''); if (verdict.conclusion === 'warned') { + // Per tier (#19133): the early warning names which tier each pull request + // is and what it waits for. A Tier H block and a Tier S block, each printed + // only when an entry of that tier is present. + const tierH = verdict.entries.filter((e) => e.tier !== TIER_S); + const tierS = verdict.entries.filter((e) => e.tier === TIER_S); lines.push( ' ⚠️ EARLY WARNING, not a failure — this run is on the pull request, and this check is deliberately', - ' GREEN here. A governed PR parked in draft while it waits for an authorized approval IS the', + ' GREEN here. A governed PR parked in draft while it waits for what its tier lands on IS the', ' regime\'s healthy resting state (「四件套留 draft 等人批,⛔ 不翻正式不入队」), and a check that', ' reddens on the healthy case is the permanently-red gate the 2026-08-18 ruling retired', - ' (红灯常态化本身有毒).', - '', - ' ⛔ What a seat must NOT do with this PR while no authorized APPROVED review is on record:', - ' flip it ready, enqueue it, or arm auto-merge (AGENTS.md Prime Directive #14 — its four', - ' prohibitions lift for that approval and for nothing else). One governed path governs the', - ' whole PR — 「混合 diff 一条命中即整 PR 分叉」; proportion is not a question.', - '', - ` ✅ What a seat DOES do once an account in GOVERNED_APPROVERS (${GOVERNED_APPROVERS.join(', ')}) has APPROVED it,`, - ' on ANY commit: the CLAIMING SEAT lands it — ruling C (#17971, maintainer 2026-09-13, verbatim', - ' 「C. approve 后不管后续改动都由席位落地:」), 「席位落地 = 过落地前检、清标、ready、', - ' auto-merge,踢出/变基同法。」 Unapproved, the maintainer\'s own direct merge (人工直合) is', - ' the only landing this pull request has.', + ' (红灯常态化本身有毒). The tier line on each entry above names its tier and what it waits for.', + ); + if (tierH.length > 0) { + lines.push( + '', + ` ⚖️ Tier H (人合) — ${tierH.map((e) => `#${e.pr}`).join(', ')}:`, + ' ⛔ What a seat must NOT do with this PR while no authorized APPROVED review is on record:', + ' flip it ready, enqueue it, or arm auto-merge (AGENTS.md Prime Directive #14 — its four', + ' prohibitions lift for that approval and for nothing else). One governed path governs the', + ' whole PR — 「混合 diff 一条命中即整 PR 分叉」; proportion is not a question.', + '', + ` ✅ What a seat DOES do once an account in GOVERNED_APPROVERS (${GOVERNED_APPROVERS.join(', ')}) has APPROVED it,`, + ' on ANY commit: the CLAIMING SEAT lands it — ruling C (#17971, maintainer 2026-09-13, verbatim', + ' 「C. approve 后不管后续改动都由席位落地:」), 「席位落地 = 过落地前检、清标、ready、', + ' auto-merge,踢出/变基同法。」 Unapproved, the maintainer\'s own direct merge (人工直合) is', + ' the only landing this pull request has.', + ); + } + if (tierS.length > 0) { + lines.push( + '', + ` ⚖️ Tier S (席内达档复核落地) — ${tierS.map((e) => `#${e.pr}`).join(', ')}: every governed path lies under a Tier S`, + ` surface (${tierSGlobs()}). ⛔ What a seat must NOT do while no review of record for the CURRENT head is on`, + ' the thread: flip it ready, enqueue it, or arm auto-merge — and ⛔ no seat submits an approving review', + ' in its place. ✅ What lifts those: a `## Contract review` comment on', + ` ${REVIEW_OF_RECORD_LOCATION} naming this head with \`Served-tier: CONTRACT_REVIEW_TIER\` and \`**VERDICT: PASS**\`,`, + ' `check-clause2-carriers.mjs --pair N` at 0 and every check green; then the OWNING seat lands it through', + ' the queue — no maintainer click is waited for (#19133, maintainer 2026-09-18 「同意改规则。」). A record', + ' on an OLDER head does not carry forward: a record names the head it judged.', + ); + } + lines.push( '', ' If it IS enqueued anyway, the merge-queue run of this same check will REFUSE it unless every', - ' governed pull request above carries an APPROVED review by then.', + ' governed pull request above carries what its tier waits for by then — the approval, or the record.', ); return lines.join('\n'); } @@ -1276,15 +1311,15 @@ export function renderGuardVerdict(verdict) { const viaRecord = verdict.entries.filter((e) => e.approval.state !== 'approved' && e.record?.state === 'stands'); if (viaRecord.length > 0) { lines.push( - ' ✅ CLEARED — and NOT every pull request below cleared on an approval. ⚖️ The references tier (#17950,', - ` ruled 2026-09-13 「我点头」) satisfied ${viaRecord.length} of them: ${viaRecord.map((e) => `#${e.pr}`).join(', ')} —`, - ` every governed path in each lies under ${REFERENCES_TIER_PREFIX}, and each carries the skills seat's`, + ' ✅ CLEARED — and NOT every pull request below cleared on an approval. ⚖️ Tier S (#17950\'s references', + ` path, generalised by #19133, maintainer 2026-09-18 「同意改规则。」) satisfied ${viaRecord.length} of them: ${viaRecord.map((e) => `#${e.pr}`).join(', ')} —`, + ` every governed path in each lies under a Tier S surface (${tierSGlobs()}), and each carries the owning seat's`, ' review of record on its CURRENT head (`## Contract review`, `Reviewed-by:`, a standing `Served-tier:`).', ' ⛔ This is NOT the approval clear: no account in GOVERNED_APPROVERS acted on those pull requests, and', ' the record above is the entire review. ⚠️ Existence and provenance only — whether it reads PASS is', - ' precondition ① of the landing check and stays human. Every OTHER governed path is the rules layer and', - ' still needs the authorized approval; the post-merge audit', - ' (`node scripts/pm/check-governed-merges.mjs`) lists these landings exactly as it always has.', + ' precondition ① of the landing check and stays human. Every Tier H path still needs the authorized', + ' approval; the post-merge audit (`node scripts/pm/check-governed-merges.mjs`) lists these landings', + ' exactly as it always has, and a Tier S merge without a PASS record is that audit\'s finding.', ); return lines.join('\n'); } @@ -1340,8 +1375,8 @@ export function renderGuardVerdict(verdict) { ); if (verdict.entries.some((e) => e.record !== undefined)) { lines.push( - ` 3. Or — ONLY for a pull request whose governed paths all lie under ${REFERENCES_TIER_PREFIX},`, - ' which the tier line on each entry above says outright — the skills seat posts its review of record', + ` 3. Or — ONLY for a pull request whose governed paths all lie under a Tier S surface (${tierSGlobs()}),`, + ' which the tier line on each entry above says outright — the owning seat posts its review of record', ` on the CURRENT head and re-queues: a \`## Contract review\` comment on ${REVIEW_OF_RECORD_LOCATION} naming`, ' this head, carrying a `Reviewed-by:` line and a `Served-tier:` line whose token is the NAME', ' `CONTRACT_REVIEW_TIER` — ⛔ never its value and never any model identifier, because `AGENTS.md`', @@ -1349,7 +1384,7 @@ export function renderGuardVerdict(verdict) { ' seat\'s own transcript grep, which leaves no repository artifact at all.', ' ⛔ A record on an OLDER head does not carry forward — unlike an approval,', ' which since 2026-09-04 does — because a record names the head it judged. ⛔ And it widens to nothing:', - ' one rules-layer path in the diff and option 1 or 2 is the only way through.', + ' one Tier H path in the diff and option 1 or 2 is the only way through.', ); } lines.push( @@ -1423,7 +1458,7 @@ export async function runGuard({ event, rows, fetchReviews, fetchPull, fetchComm approvals.set(entry.pr, unreadableApproval(String(error?.message ?? error).split('\n')[0])); } } - // ── the references tier (#18020) ──────────────────────────────────────── + // ── Tier S: the record leg (#18020, generalised to the tier by #19133) ── // // ⭐ LAST, and only for what is still unsatisfied. Two properties come out of // that placement and neither is decoration: the leg is MONOTONE (it is never @@ -1433,7 +1468,7 @@ export async function runGuard({ event, rows, fetchReviews, fetchPull, fetchComm if (event === EVENT_MERGE_GROUP) { let recognisers = null; for (const entry of governed) { - if (entry.tier !== TIER_REFERENCES) continue; + if (entry.tier !== TIER_S) continue; if (approvals.get(entry.pr)?.state === 'approved') continue; if (recognisers === null) { try { @@ -2179,10 +2214,14 @@ export async function selfTest() { // The same authorized approval, on a commit that is no longer the head. Under // the retired sha pin this exact fixture was the REFUSAL case. const authorizedPassOnOlder = (login = GOVERNED_APPROVERS[0]) => authorizedApprovalVerdict([approvedAt(login, OLD)], HEAD); - const run = (event, rows, approvals = new Map()) => { + const run = (event, rows, approvals = new Map(), records = new Map()) => { const { governed, unattributed } = decomposeGovernedWork(rows); - return guardVerdict({ event, governed, unattributed, approvals, apiCalls: governed.length }); + return guardVerdict({ event, governed, unattributed, approvals, records, apiCalls: governed.length }); }; + // The record reading a Tier S entry gets when the thread was READ and held + // nothing — the incident shape (#19133): "no record" is a refusal of its own + // kind, distinct from "the thread could not be read". + const absentRecord = (pr) => new Map([[pr, { state: 'absent', read: { pr: 0, card: 0 }, readSummary: '0 on the PR and 0 on its card', cardNote: 'replay fixture, no card thread searched' }]]); // ── the register is READ, never restated (#9840) ────────────────────────── // @@ -2388,13 +2427,35 @@ export async function selfTest() { ), ); assert('and-an-unattributed-governed-commit-does-not-redden-a-pr-run-either', run('pull_request', [{ sha: 'c'.repeat(40), subject: 'x', pr: null, paths: ['CLAUDE.md'] }]).exitCode === EXIT_CLEAR); + // #19133: the early warning names the TIER and what it waits for, on the + // pull_request leg — where no record is read, so the tier line alone carries it. + const warnedS = run('pull_request', [row(9528, ['.claude/agents/os-dev.md'])], new Map([[9528, approvalVerdict([])]])); + const warnedSText = renderGuardVerdict(warnedS); + assert( + 'the-early-warning-names-Tier-S-and-the-record-it-waits-for-and-asks-for-no-approval', + warnedS.conclusion === 'warned' && /landing tier: S/.test(warnedSText) && /## Contract review/.test(warnedSText) && /CONTRACT_REVIEW_TIER/.test(warnedSText) && + !/What a seat DOES do once an account in GOVERNED_APPROVERS/.test(warnedSText), + warnedSText, + ); + const warnedHText = renderGuardVerdict(warnedV); + assert('and-names-Tier-H-and-the-approval-it-waits-for-with-no-Tier-S-block', /landing tier: H/.test(warnedHText) && /authorized APPROVED review/.test(warnedHText) && !/Tier S \(/.test(warnedHText), warnedHText); // ── the replay fixtures: the three incidents this guard descends from ───── battery('the replay fixtures: the three incidents this guard descends from'); for (const replay of REPLAYS) { const rows = [row(replay.pr, replay.files, 'e'.repeat(40), replay.subject)]; - const queued = run('merge_group', rows, new Map([[replay.pr, authorizedApprovalVerdict([], HEAD)]])); - assert(`replay-REFUSES-at-the-queue: ${replay.name}`, queued.exitCode === EXIT_REFUSED_UNAPPROVED, JSON.stringify(queued.conclusion)); + // #19133: two of the three incidents are `.claude/**` and therefore Tier S + // today, so the queue judges them on the record leg too. Each incident's + // thread carried no record — the reading is ABSENT, and the refusal is the + // same "unapproved" the incident deserved. A Tier S entry with NO reading + // at all refuses as UNREADABLE instead: "could not find out" never clears. + const tier = governedTierFor(replay.files); + const queued = run('merge_group', rows, new Map([[replay.pr, authorizedApprovalVerdict([], HEAD)]]), tier === TIER_S ? absentRecord(replay.pr) : new Map()); + assert(`replay-REFUSES-at-the-queue: ${replay.name}`, queued.exitCode === EXIT_REFUSED_UNAPPROVED, JSON.stringify([queued.conclusion, tier])); + if (tier === TIER_S) { + const unread = run('merge_group', rows, new Map([[replay.pr, authorizedApprovalVerdict([], HEAD)]])); + assert(`replay-is-Tier-S-and-with-NO-record-reading-refuses-UNREADABLE-never-clears: ${replay.name}`, unread.exitCode === EXIT_REFUSED_UNREADABLE, JSON.stringify(unread.conclusion)); + } const early = run('pull_request', rows, new Map([[replay.pr, approvalVerdict([])]])); assert(`replay-only-WARNS-on-the-pr: ${replay.name}`, early.conclusion === 'warned' && early.exitCode === EXIT_CLEAR); const text = renderGuardVerdict(queued); @@ -3253,23 +3314,30 @@ export async function selfTest() { // the REAL ones, loaded through the real lazy import, because a battery run // against hand-made stubs would keep passing the day an upstream rename broke // the live leg — which is the entire failure mode importing them avoids. - battery('⭐ #18020: the references tier — a review of record, not an approval'); - const REF_A = `${REFERENCES_TIER_PREFIX}platform-readings.md`; - const REF_B = `${REFERENCES_TIER_PREFIX}lanes/skills.md`; - const RULES_PATH = '.claude/skills/pm-dispatch/SKILL.md'; - // ⛔ The sibling one character away from the ruled directory. A bare - // `startsWith` without the trailing slash lands this in the tier. - const NEAR_MISS = '.claude/skills/pm-dispatch/references-draft/x.md'; - - assert('a-references-only-path-set-is-the-REFERENCES-tier', governedTierFor([REF_A, REF_B]) === TIER_REFERENCES); - assert('⛔ the-adjacent-directory-is-NOT-the-tier-the-trailing-slash-is-the-control', governedTierFor([NEAR_MISS]) === TIER_RULES, NEAR_MISS); - assert('ONE-rules-layer-path-makes-the-WHOLE-entry-rules-layer', governedTierFor([REF_A, REF_B, RULES_PATH]) === TIER_RULES); - assert('a-rules-only-set-is-rules', governedTierFor([RULES_PATH]) === TIER_RULES); - assert('an-EMPTY-path-set-defaults-to-rules-never-to-the-tier', governedTierFor([]) === TIER_RULES && governedTierFor(undefined) === TIER_RULES); + battery('⭐ #18020 → #19133 Tier S: a review of record, not an approval'); + // The fact layer that WAS the whole tier under #18020, and the siblings the + // 2026-09-18 ruling moved in beside it — skills, agents, hooks, settings. + const REF_A = '.claude/skills/pm-dispatch/references/platform-readings.md'; + const REF_B = '.claude/skills/pm-dispatch/references/lanes/skills.md'; + const SKILL_PATH = '.claude/skills/pm-dispatch/SKILL.md'; + const AGENT_PATH = '.claude/agents/os-dev.md'; + const HOOK_PATH = '.claude/hooks/guard-main-checkout.sh'; + const SETTINGS_PATH = '.claude/settings.json'; + // ⛔ The law. One of these in the diff and the whole entry is Tier H. + const RULES_PATH = 'AGENTS.md'; + const TIER_H_PATHS = ['AGENTS.md', 'CLAUDE.md', 'docs/adr/0001-x.md', 'docs/NORTH-STAR.md', 'skills/objectstack-ui/SKILL.md']; + + assert('a-Tier-S-only-path-set-is-Tier-S', governedTierFor([REF_A, REF_B, SKILL_PATH, AGENT_PATH, HOOK_PATH, SETTINGS_PATH]) === TIER_S); + assert('⛔ the-old-references-boundary-is-GONE-SKILL-md-and-a-references-draft-sibling-are-Tier-S-alike', governedTierFor([SKILL_PATH]) === TIER_S && governedTierFor(['.claude/skills/pm-dispatch/references-draft/x.md']) === TIER_S); + assert('ONE-Tier-H-path-makes-the-WHOLE-entry-Tier-H', governedTierFor([REF_A, REF_B, RULES_PATH]) === TIER_H); + assert('each-Tier-H-surface-alone-is-Tier-H', TIER_H_PATHS.every((p) => governedTierFor([p]) === TIER_H), TIER_H_PATHS.join()); + assert('an-EMPTY-path-set-defaults-to-Tier-H-never-to-S', governedTierFor([]) === TIER_H && governedTierFor(undefined) === TIER_H); + assert('the-tier-constants-are-the-registers-own-two-distinct-values', TIER_H === GOVERNED_TIER_H && TIER_S === GOVERNED_TIER_S && TIER_H !== TIER_S); assert( 'the-tier-travels-on-the-decomposed-entry-so-the-verdict-never-re-derives-it', - decomposeGovernedWork([row(5, [REF_A])]).governed[0].tier === TIER_REFERENCES && - decomposeGovernedWork([row(5, [REF_A, RULES_PATH])]).governed[0].tier === TIER_RULES, + decomposeGovernedWork([row(5, [REF_A])]).governed[0].tier === TIER_S && + decomposeGovernedWork([row(5, [AGENT_PATH])]).governed[0].tier === TIER_S && + decomposeGovernedWork([row(5, [REF_A, RULES_PATH])]).governed[0].tier === TIER_H, ); // The real recognisers, and the tier VALUE read from the constant's one home @@ -3346,6 +3414,21 @@ export async function selfTest() { tierPass.exitCode === EXIT_CLEAR && tierPass.conclusion === 'cleared' && tierPass.entries[0].record.state === 'stands', JSON.stringify(tierPass.entries[0].record), ); + // ⭐ #19133, end to end: the generalised tier. An agents + settings diff lands + // on the same record — and its lit control, the same diff with NO record, is + // refused (the card's own acceptance line). + const agentPass = await tierRun({ files: [AGENT_PATH, SETTINGS_PATH], comments: [recordComment()] }); + assert( + 'a-claude-agents-plus-settings-PR-with-a-valid-record-PASSES-with-zero-approvals-Tier-S-generalised', + agentPass.exitCode === EXIT_CLEAR && agentPass.conclusion === 'cleared' && agentPass.entries[0].tier === TIER_S && agentPass.entries[0].record.state === 'stands', + JSON.stringify(agentPass.entries[0].record), + ); + const agentNoRecord = await tierRun({ files: [AGENT_PATH, SETTINGS_PATH], comments: [] }); + assert( + '⛔ CONTROL: the-same-Tier-S-PR-with-NO-record-is-REFUSED', + agentNoRecord.exitCode === EXIT_REFUSED_UNAPPROVED && agentNoRecord.entries[0].tier === TIER_S && agentNoRecord.entries[0].record.state === 'absent', + JSON.stringify(agentNoRecord.entries[0].record), + ); // The lit controls: the same fixture, one fact away, in five directions. const tierOldHead = await tierRun({ comments: [recordComment({ sha: REF_OLD.slice(0, 12) })] }); assert( @@ -3510,11 +3593,11 @@ export async function selfTest() { ); // ⭐ The BOUNDARY: one rules-layer path and the tier is not reachable at all. - battery('⭐ #18020: the references tier — a review of record, not an approval'); + battery('⭐ #18020 → #19133 Tier S: a review of record, not an approval'); const tierMixed = await tierRun({ files: [REF_A, RULES_PATH], comments: [recordComment()] }); assert( - '⛔ a-MIXED-diff-with-one-rules-layer-path-is-REFUSED-even-with-a-perfect-record', - tierMixed.exitCode === EXIT_REFUSED_UNAPPROVED && tierMixed.entries[0].tier === TIER_RULES && tierMixed.entries[0].record === undefined, + '⛔ a-MIXED-diff-with-one-Tier-H-path-is-REFUSED-even-with-a-perfect-record', + tierMixed.exitCode === EXIT_REFUSED_UNAPPROVED && tierMixed.entries[0].tier === TIER_H && tierMixed.entries[0].record === undefined, ); assert('and-the-mixed-entry-never-even-BOUGHT-the-thread-read', tierApiCalls === 2, `api calls: ${tierApiCalls}`); @@ -3608,7 +3691,7 @@ export async function selfTest() { // The words a reader acts on — requirement (e) reaches the new leg too. const tierRefusalText = renderGuardVerdict(tierAbsent); - assert('the-refusal-names-the-tier-on-the-entry-so-a-reader-knows-why-a-record-would-help', /landing tier: REFERENCES/.test(tierRefusalText)); + assert('the-refusal-names-the-tier-on-the-entry-so-a-reader-knows-why-a-record-would-help', /landing tier: S/.test(tierRefusalText), tierRefusalText); assert('and-offers-the-record-as-a-THIRD-remedy-naming-the-three-facts-it-must-carry', /3\. Or — ONLY for a pull request whose governed paths all lie under/.test(tierRefusalText) && /Reviewed-by:/.test(tierRefusalText) && /Served-tier:/.test(tierRefusalText)); assert('⛔ and-a-RULES-layer-refusal-is-offered-no-such-remedy-the-control-for-the-line-above', !/3\. Or — ONLY for a pull request/.test(renderGuardVerdict(rulesDismissed))); assert('the-below-tier-refusal-names-the-reading-it-actually-got', /does not stand/.test(renderGuardVerdict(tierBelow)) && /a-lesser-tier/.test(renderGuardVerdict(tierBelow))); @@ -3660,9 +3743,9 @@ export async function selfTest() { Object.keys(RECOGNISER_SOURCES).join(', '), ); assert( - '⛔ the-tier-prefix-keeps-its-trailing-slash-and-is-NOT-written-as-a-register-glob', - REFERENCES_TIER_PREFIX.endsWith('/') && !REFERENCES_TIER_PREFIX.includes('*'), - REFERENCES_TIER_PREFIX, + '⛔ this-file-spells-NO-tier-prefix-Tier-S-is-the-register-rows-that-carry-S-and-today-that-is-the-whole-claude-tree', + GOVERNED_SURFACES.filter((s) => s.tier === TIER_S).map((s) => s.glob).join() === '.claude/**' && tierSGlobs() === '.claude/**', + tierSGlobs(), ); assert( '⛔ and-this-leg-added-NO-surface-to-the-register-the-tier-is-a-landing-rule-not-a-membership-one',