Skip to content

Commit 372931e

Browse files
huangyiireneclaude
andauthored
pm(tooling): the contract-review tier is fable — revert the opus constant and the retired-word list (#19684)
Fixes #19680 Clause-②: no The contract-review tier is fable. PR #19573 moved `CONTRACT_REVIEW_TIER` to the opus id and filled `RETIRED_TIER_WORDS` because one agent's first request at the tier came back HTTP 429 on 2026-09-21. The maintainer's ruling of 2026-09-22 (issue comment 5771798588) is that this was a temporary lack of authorization on one session, not a retirement, and that it must not have changed the skills — verbatim and untranslated, in order: > 「复核档应该就是 fable 啊」 · 「某个agent临时没有fable给的特殊授权,不应该改变skills」 · 「fable 撤回卡 你来创建」 ## What lands — `scripts/pm/dispatch-gates.mjs` only 1. **The constant.** `CONTRACT_REVIEW_TIER` reads the fable id again, so `TIER_CEILING` derives back to the fable word and the ladder reads `floor sonnet · default opus · ceiling fable`. 2. **The retired list is empty.** `RETIRED_TIER_WORDS = Object.freeze([])` — no tier word is retired. The guard itself stays. 3. **The docblock beside the constants carries the principle**, as one rule with the ruling quoted by date and comment id: a tier word is RETIRED only by the maintainer's explicit ruling that NAMES a retirement; a 429, an exhausted quota or a missing authorization on one session is never a retirement; and a seat this tier is not served to renders the review through an isolated at-tier subagent or waits outside the queue — never by editing the constant. What one session is authorized for is a property of that session; the constant is a property of the lane's governance. `SKILL.md:516` points every tier value at this file, so this docblock is that principle's home. 4. **The non-vacuity pin moved off the live list onto a MUTATED copy** (see below). **Kept, deliberately — these were never the defect.** `tierWordOf()`, the derived `TIER_CEILING`, the retired-spelling guard, and every rendering that reads the constant (the ladder, the exits, the clause-② note, the suspicion line) are PR #19573's structurally sound half: deriving the ceiling from the constant is exactly what stops the pair drifting, in either direction. The exits text is tier-agnostic and reads `TIER_DEFAULT`, so it follows the constant with no edit. **Not touched:** `.claude/**` (step ② of #19061 owns the reference text), `scripts/check-commit-card-trailers.mjs:848` (a rejection vocabulary that must keep listing the id), any workflow. ## The pin that could not survive the revert, and what replaced it `the retired-spelling guard is not vacuous — it names at least one word` cannot hold on an empty list, and an empty list clears every rendering for free. Counting entries on the LIVE list is therefore not available as a control any more. The guard now runs through one function that both legs call, and the non-vacuity case is proved on a **mutated copy**: feed the guard a word the ladder demonstrably prints and it must red. Three cases now stand where one stood: ``` ✓ ⛔ no RETIRED tier word survives in any live RULE — … (dirty: none) ✓ no tier word is RETIRED today — the list is empty and frozen, which is what "a temporary lack of authorization is not a retirement" looks like in data (holds: none) ✓ …and the guard is NOT vacuous, proved on a MUTATED copy: a list naming the ladder's own ceiling word (fable) REDS, so the green above is the empty list and not a broken search (mutated hits: 0/fable 1/fable 2/fable 3/fable) ✓ …and the invariant that copy stands for: no tier word still in the ladder may ever enter the live list ``` The mutated-copy control fires on all four renderings, printed on every run — so the guard's green is measured on every run, not only in the one-off ablation below. ## Evidence **The two `--tier` renders, verbatim, at `c39a60e`** (`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --tier PATH`, exit 0 both): ``` Model tier — MANDATORY: claude-fable-5-1 (derived from the file surface, not recalled). ``` ``` Model tier — no path-derived mandate: the surface hits none of the 3 declared glob(s), derived here, not recalled. The tier stays the PM's per-card judgment call (floor sonnet · default opus · ceiling fable). ``` **`pnpm check:pm-dispatch-gates`** — `EXIT=0`, `✓ dispatch-gates self-test: 1906 cases pass.`, zero reds, run to completion on the final commit `c39a60e`. Run detached per this file's own header (the battery re-spawns the tool's CLI and took 675.0s on this box, over the container's foreground cap) with the exit code captured to a file, never through a pipe. **Derived gate families** — `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at the FINAL head derived 29 commands (byte-identical to the derivation taken at the first commit). All 29 were run, each exit code captured before any pipe, and handed back through `--ran`: ``` ✓ dispatch-gates --ran: 29 derived famil(ies) accounted for — 29 run, 0 NOT-MEASURED (a DERIVED zero — all 29 recorded an exit code and none of them is 3). ``` Four of them (`check-ci-filter-parity`, `check-closing-keyword-parity` and its self-test, `check-comment-mask-corpus`) first exited **3 `PREREQUISITE NOT MET`** in a worktree with no `node_modules`. That is NOT MEASURED, never a pass and never a finding — they were re-run green after `pnpm install`, and only the second reading is recorded above. **Run by hand, outside the derivation** — `node scripts/pm/check-clause2-carriers.mjs --self-test` → exit 0, `1140 cases pass`. It imports `CONTRACT_REVIEW_TIER`, so this change moves its verdict, and the path derivation does not name it (the watch-hint blind spot PR #19573 reported). **Governed-surface predicate** — `node scripts/pm/check-governed-merges.mjs --test scripts/pm/dispatch-gates.mjs` → exit 0, `governed-surface predicate: 0 of 1 path(s) hit the register (6 surfaces, repo-agnostic)`, `NOT governed — ordinary queue landing applies to a PR with exactly this file list`. Head repo and size are NOT MEASURED by `--test`; re-read on the PR's own number before landing. **Changed lines** — 62 (+50 / −12, 1 file), under the 5000 human-merge threshold. **Lint, narrowed and declared.** `eslint scripts/pm/dispatch-gates.mjs --no-inline-config` → 0 errors, 0 warnings. The narrowing is measured, not assumed: ① eslint's own population is `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` (`eslint.config.mjs:971`), so the population is the JS/TS tree; ② the file count is read from `--format json` (1); ③ `eslint.config.mjs:327` states the config enables no type-aware linting for ANY file (no `parserOptions.project`, no typed rules), so this diff cannot move the verdict on a file it does not touch. The repo-wide `pnpm lint` sweep is CI's run. **Control bytes.** `grep -naP` for the control class over the changed file prints nothing (exit 1); the control — the same `-P` engine asked for printable ASCII on the same file — fires on 28,427 lines. `pnpm check:nul-bytes` exit 0. **Changeset — `skip-changeset`, measured.** Every one of the 70 published packages in this workspace declares an explicit `files[]`, and the distinct entries across all of them are `CHANGELOG.md`, `README.md`, `api-surface`, `dist`, `json-schema`, `liveness`, `llms.txt`, `prompts`, `spec-changes.json`, `src/**/*.zod.ts`. Not one reaches `scripts/`, so `scripts/pm/dispatch-gates.mjs` ships in no tarball and this diff publishes nothing. ### Reverse verification — the live guard can still fail Landed through `scripts/ablation-replace.mjs` (anchor count checked, blob hash recorded before and after), the direction declared BEFORE the run: put the fable word back into the retired list, expect exactly three reds in the retired-guard block and no others. ``` ablation-replace: ok mutation landed: anchor 1 -> 0, blob f04cb35 -> 2d7ada7ab81a ✗ ⛔ no RETIRED tier word survives in any live RULE — … (dirty: 0/fable 1/fable 2/fable 3/fable) ✗ no tier word is RETIRED today — the list is empty and frozen … (holds: fable) ✗ …and the invariant that copy stands for: no tier word still in the ladder may ever enter the live list ✗ dispatch-gates self-test: 3 of 1906 case(s) failed. ablation-replace: ok restored: blob == HEAD (f04cb35) and `git diff HEAD` is empty ``` Three reds out of 1906, exactly the three predicted and no others — the guard, the emptiness reading and the invariant are three different halves of one promise, and each one binds. The restore is proved by blob equality against HEAD and an empty `git diff HEAD`, not by an exit code. ## 维护者速读(草稿) **改了什么** — 复核档常量改回 fable;退役词表清空;常量旁的注释块写下这条原则的正文(含 2026-09-22 三句裁决原话与评论号);那条「表里至少有一个词」的自检钉不可能在空表上成立,改成在变异副本上证明守卫仍会红。只动 `scripts/pm/dispatch-gates.mjs` 一个文件。 **为什么改** — 2026-09-21 把常量从 fable 改成 opus 的依据,是某个 session 的 HTTP 429。那是「这一个 agent 此刻没拿到授权」,不是「这个档位退役了」。把前者写进常量,等于让一次临时授权缺失改写整条车道的治理值。维护者 2026-09-22 明确撤回。 **风险与代价(含回滚)** — 风险低:改动只在一个未受管文件里,29 个派生门禁族全绿、自检 1906 例全过,并做了消融证明守卫仍会红。代价是复核档重新高于默认档,席位若当时拿不到 fable,须走隔离子 agent 或在队列外等档,⛔ 不得自行降档。回滚就是把这两行改回去 —— 但那正是本卡禁止的动作,除非维护者另行裁决退役。 **席位意见** — (留空,待席位定稿) **你要做的** — 确认这条原则的措辞就是你的意思:「档位退役只认你点名退役的裁决;429/额度/单 session 无授权都不算退役」。确认后本 PR 可照常走队列落地。 ## Acceptance notes Reported, not fixed here — all outside this card's declared file surface (`scripts/pm/dispatch-gates.mjs`): - **Three of PR #19573's own acceptance notes are cleared by this revert without an edit**, and are recorded here so nobody files them again: `.claude/skills/checklist-test/SKILL.md:103` states 「floor sonnet · default opus · ceiling fable」 as a live rule — the ladder prints exactly that again; `scripts/pm/check-dispatch-gates.mjs:263` calls the mandated surface "the fable-mandatory surface" — accurate again; `.claude/skills/pm-dispatch/SKILL.md:538` 「额度耗尽豁免仅当契约复审档实测不可用才落默认判断档」 had no room left to exempt while the ceiling equalled the default, and has room again now. - **A forward interaction worth naming before #19061 step ② lands.** Two self-test cases in this file read `.claude/skills/pm-dispatch/references/contract-review.md` and assert its 降档保险丝 lines (「额度耗尽豁免只及派发 ⛔ 不及复核」 and 「档位退役 ≠ 耗尽,恒维护者裁决 ⛔ 非席位读数」). Deleting that section per the maintainer's 「降档保险丝 不留」 turns both red. That is #19061's to carry — its diff must retire those two pins in the same PR. Content-wise the deleted line stays true of this change; it is only the pins that need moving. - `scripts/pm/check-clause2-carriers.mjs` consumes `CONTRACT_REVIEW_TIER` but is still not derived by `dispatch-gates --commands` for a diff that changes it — the same watch-hint blind spot PR #19573 reported, unchanged. Run by hand here; green. --- _Generated by [Claude Code](https://claude.ai/code/session_01Wnstp2kTth7sGXfr8fXypc)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent eea7ccc commit 372931e

1 file changed

Lines changed: 50 additions & 12 deletions

File tree

‎scripts/pm/dispatch-gates.mjs‎

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12243,7 +12243,8 @@ export function residueLines(
1224312243
* convenience. The review label deliberately names WHAT is reviewed, never a
1224412244
* model (maintainer, 2026-08-16: 「needs:fable-review 这个标签不好,下次模型升级怎么办」).
1224512245
*
12246-
* ## A tier that is GONE is not a tier that is EXHAUSTED (#19544)
12246+
* ## A tier that is GONE is not a tier that is EXHAUSTED — and a SESSION not
12247+
* served one is neither of them (#19544, reversed by #19680)
1224712248
*
1224812249
* The exits below carry a QUOTA exemption: a tier that is exhausted comes
1224912250
* back, so the card waits out of the queue and the review is never downgraded
@@ -12254,15 +12255,31 @@ export function residueLines(
1225412255
* retirement is a maintainer ruling and ⛔ never a seat's reading — and when
1225512256
* the ruling lands, this VALUE is the one line that moves. The ceiling of the
1225612257
* ladder `tierLines` prints is DERIVED from it ({@link TIER_CEILING}) so the
12257-
* two cannot drift apart. This value moved off the retired ceiling on the
12258-
* maintainer's 2026-09-21 ruling — verbatim: 「fable 没有了」, answered with
12259-
* 「改成 opus」 — which is why the docblocks below still name the retired tier
12260-
* where they record what was ruled AT THE TIME; a record of what was served
12261-
* then stays true. Rule text: `references/contract-review.md` 「降档保险丝」.
12258+
* two cannot drift apart.
12259+
*
12260+
* A THIRD case is what this line was once actually moved on, and it is neither
12261+
* of the two above. On 2026-09-21 two review dispatches died on their first
12262+
* request with an HTTP 429 quota refusal at this tier; that reading — ONE
12263+
* agent, temporarily, not authorized — was written in here as a retirement,
12264+
* and the skills moved with it. It is not a retirement. Maintainer,
12265+
* 2026-09-22, verbatim and untranslated (ruling record: issue comment
12266+
* 5771798588): 「复核档应该就是 fable 啊」 ·
12267+
* 「某个agent临时没有fable给的特殊授权,不应该改变skills」 ·
12268+
* 「fable 撤回卡 你来创建」.
12269+
*
12270+
* As one rule, and this docblock is its home — the PM skill points every tier
12271+
* value at this file, so there is nowhere else it could live: a tier word is
12272+
* RETIRED only by the maintainer's explicit ruling that NAMES a retirement; a
12273+
* 429, an exhausted quota or a missing authorization on one session is ⛔
12274+
* never a retirement; and a seat this tier is not served to renders the review
12275+
* through an isolated at-tier subagent or waits outside the queue — ⛔ never
12276+
* by editing this line. What one session is authorized for is a property of
12277+
* that session; this constant is a property of the lane's governance, and the
12278+
* two ⛔ never trade places.
1226212279
*
1226312280
* Rulebook: `.claude/skills/pm-dispatch/SKILL.md` 「入队与落地」 — the clause-② gate and the `needs:contract-review` review-chain bullets.
1226412281
*/
12265-
export const CONTRACT_REVIEW_TIER = 'claude-opus-5';
12282+
export const CONTRACT_REVIEW_TIER = 'claude-fable-5-1';
1226612283

1226712284
/**
1226812285
* The globs that MANDATE a model tier for any card whose file surface touches
@@ -12506,15 +12523,23 @@ export function tierWordOf(modelId) {
1250612523
export const TIER_CEILING = tierWordOf(CONTRACT_REVIEW_TIER);
1250712524

1250812525
/**
12509-
* Tier family words this ladder once printed and must never print again.
12526+
* Tier family words a maintainer ruling has RETIRED — none today.
1251012527
*
1251112528
* ⛔ Not a tier table and ⛔ not an ordering — a RETIRED-SPELLING guard, the
1251212529
* same shape as the retired clause-② keys pinned further down. The self-test
1251312530
* asserts no rendering contains one, so the day a ceiling is written down by
1251412531
* hand again it reds instead of quietly outliving the harness that served it.
12515-
* A word leaves this list only when a maintainer ruling brings the tier back.
12532+
*
12533+
* EMPTY is this list's correct steady state, ⛔ not a disabled guard. A word
12534+
* enters it only on the maintainer's explicit ruling that names a retirement
12535+
* and leaves it only on a ruling that brings the tier back — the conditions
12536+
* {@link CONTRACT_REVIEW_TIER}'s docblock states; the one entry this list held
12537+
* was written on a session's quota refusal, which is none of those. Because an
12538+
* empty list clears every rendering for free, the self-test proves the guard
12539+
* on a MUTATED copy — a list naming a word the ladder really prints has to red
12540+
* — so the green above it is measured rather than vacuous.
1251612541
*/
12517-
export const RETIRED_TIER_WORDS = Object.freeze(['fable']);
12542+
export const RETIRED_TIER_WORDS = Object.freeze([]);
1251812543

1251912544
/**
1252012545
* Place a card's file surface against the mandatory globs. Pure over its
@@ -25476,9 +25501,22 @@ function selfTest() {
2547625501
t('the ladder prints a ceiling DERIVED from the contract-review constant, so the two cannot drift apart', ladderLine.includes(`ceiling ${tierWordOf(CONTRACT_REVIEW_TIER)})`), ladderLine || 'no ladder line was rendered at all');
2547725502
t("…in the ladder's own vocabulary — the constant's FAMILY word, so a parseable id NEVER reaches the ladder verbatim", /^claude-[a-z]+-/.test(CONTRACT_REVIEW_TIER) && TIER_CEILING === tierWordOf(CONTRACT_REVIEW_TIER) && !ladderLine.includes(CONTRACT_REVIEW_TIER), ladderLine);
2547825503
t('tierWordOf reads the family out of an id, and hands an unreadable one back VERBATIM rather than guessing a word', tierWordOf('claude-example-9-9') === 'example' && tierWordOf('an-unfamiliar-shape') === 'an-unfamiliar-shape' && tierWordOf(null) === '');
25479-
t(`⛔ no RETIRED tier word survives in any live RULE — ladder, exits, clause-② note and suspicion line all read the constant (dirty: ${ladderRenderings.map((l, i) => RETIRED_TIER_WORDS.filter((w) => l.toLowerCase().includes(w)).map((w) => `${i}/${w}`).join(' ')).filter(Boolean).join(' ') || 'none'})`, ladderRenderings.every((l) => RETIRED_TIER_WORDS.every((w) => !l.toLowerCase().includes(w))));
25504+
// The guard is driven through ONE function so the live reading and its
25505+
// control run the same search. RETIRED_TIER_WORDS is EMPTY — no ruling has
25506+
// retired a tier word — so the live case below clears every rendering for
25507+
// free, and a case asserting only that green would pass just as happily on a
25508+
// search that had stopped matching anything. The non-vacuity case therefore
25509+
// moved off the LIVE list, where it could only ever count entries, and onto
25510+
// a MUTATED copy: feed the guard a word the ladder demonstrably prints and
25511+
// it must red. That is the invariant an empty list has to keep provable — a
25512+
// word still in the ladder can never be in this list.
25513+
const retiredWordHits = (words) =>
25514+
ladderRenderings.flatMap((l, i) => words.filter((w) => l.toLowerCase().includes(w)).map((w) => `${i}/${w}`));
25515+
t(`⛔ no RETIRED tier word survives in any live RULE — ladder, exits, clause-② note and suspicion line all read the constant (dirty: ${retiredWordHits(RETIRED_TIER_WORDS).join(' ') || 'none'})`, retiredWordHits(RETIRED_TIER_WORDS).length === 0);
2548025516
t('…and the guard is not reading an empty string — every rendering it clears still carries its own rule text', ladderRenderings.every((l) => l.includes('Model tier')) && ladderRenderings.some((l) => l.includes('Exits,')));
25481-
t('the retired-spelling guard is not vacuous — it names at least one word, and none of them is a tier still in the ladder', RETIRED_TIER_WORDS.length > 0 && !RETIRED_TIER_WORDS.includes(TIER_FLOOR) && !RETIRED_TIER_WORDS.includes(TIER_DEFAULT) && !RETIRED_TIER_WORDS.includes(TIER_CEILING));
25517+
t(`no tier word is RETIRED today — the list is empty and frozen, which is what "a temporary lack of authorization is not a retirement" looks like in data (holds: ${[...RETIRED_TIER_WORDS].join(', ') || 'none'})`, Array.isArray(RETIRED_TIER_WORDS) && RETIRED_TIER_WORDS.length === 0 && Object.isFrozen(RETIRED_TIER_WORDS));
25518+
t(`…and the guard is NOT vacuous, proved on a MUTATED copy: a list naming the ladder's own ceiling word (${TIER_CEILING}) REDS, so the green above is the empty list and not a broken search (mutated hits: ${retiredWordHits([TIER_CEILING]).join(' ') || 'NONE — the control never fired'})`, retiredWordHits([TIER_CEILING]).length > 0 && retiredWordHits(['a-word-no-rendering-prints']).length === 0);
25519+
t('…and the invariant that copy stands for: no tier word still in the ladder may ever enter the live list', [TIER_FLOOR, TIER_DEFAULT, TIER_CEILING].every((w) => !RETIRED_TIER_WORDS.includes(w)));
2548225520
// A sibling case further down pins the constant's CURRENT value to exactly
2548325521
// one site under these roots. What that case cannot see is a RETIRED id left
2548425522
// behind — it is not the current value, so nothing compares it to anything,

0 commit comments

Comments
 (0)