Skip to content

Commit 36ce254

Browse files
os-zhuangclaude
andauthored
fix(pm): derive the mandatory model tier from the card's file surface (#8640) (#8988)
dispatch-gates.mjs now emits the model tier a card's paths MANDATE, beside the gate families they move, so a claim comment quotes a derived value instead of a recalled one. Claude-Session: https://claude.ai/code/session_017TNzEetykdh7ceZGwuAPLq Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8c65046 commit 36ce254

1 file changed

Lines changed: 260 additions & 5 deletions

File tree

scripts/pm/dispatch-gates.mjs

Lines changed: 260 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,19 @@
33

44
/**
55
* dispatch-gates (#7341 item 4) — map a card's file surface to the `check:*`
6-
* gate families that watch it, derived from the tree AT RUNTIME.
6+
* gate families that watch it, and to the model tier its paths MANDATE, both
7+
* derived from the tree AT RUNTIME.
78
*
89
* node scripts/pm/dispatch-gates.mjs <path> [<path> ...] # e.g. packages/spec/src/data/filter.zod.ts
910
* node scripts/pm/dispatch-gates.mjs --residue <path> ... # + name every family the derivation did not place
11+
* node scripts/pm/dispatch-gates.mjs --tier <path> ... # the tier verdict alone, for the claim comment
1012
* node scripts/pm/dispatch-gates.mjs --self-test
1113
*
14+
* The tier half is a FLOOR from paths only, and it says so on every run: see
15+
* MANDATORY_TIER_GLOBS for what it encodes (clause ①, a file-surface
16+
* predicate) and what no path derivation can reach (clause ②, judged from the
17+
* card's content).
18+
*
1219
* ## Why derived, never listed
1320
*
1421
* The step-5 dispatch template's "Local gates for this card" line is filled by
@@ -1035,6 +1042,181 @@ export function residueLines({ discovered, matched, undetermined, silent }, kind
10351042
];
10361043
}
10371044

1045+
// ---------------------------------------------------------------------------
1046+
// Model-tier derivation — the half of the tier decision that IS a path question
1047+
// ---------------------------------------------------------------------------
1048+
1049+
/**
1050+
* The globs that MANDATE a model tier for any card whose file surface touches
1051+
* them, as DATA. This is the one list in this file besides CHANGE_KIND_GATES,
1052+
* and it is here for the same reason: it is enumerable, so a guard can hold it.
1053+
*
1054+
* ## Why a tier is derived here at all (#8640)
1055+
*
1056+
* Gate families used to be hand-recalled per card; this script exists because
1057+
* recall expires. The model tier had the same shape and had not been fixed: the
1058+
* PM recalled the mandatory roots and wrote free prose into the claim comment's
1059+
* `Container & model` line. Measured incident: a card whose surface included
1060+
* `.claude/skills/pm-dispatch/references/review-checklist.md` was claimed as
1061+
* "not under the fable-mandatory roots" and dispatched at opus. One
1062+
* misclassification sentence flowed unchecked from claim to dispatch to model
1063+
* choice, and only a downstream seat's skepticism caught it — at PR time, after
1064+
* the work was done, when the compensation available was a re-review rather
1065+
* than a re-dispatch. Nothing mechanical had compared the recorded surface
1066+
* against the mandatory globs, because nothing mechanical could: the globs
1067+
* lived only in prose.
1068+
*
1069+
* So the invariant this section installs is narrow and total: a mandatory path
1070+
* anywhere in the surface ⇒ the output cannot say otherwise. `deriveTier`
1071+
* refuses to return a result whose parts contradict each other and `tierLines`
1072+
* refuses to render one, in the same shape as the residue partition guard —
1073+
* a derivation that cannot complete exits non-zero rather than printing a wrong
1074+
* answer.
1075+
*
1076+
* ## What this derivation CANNOT promise, stated where it cannot be missed
1077+
*
1078+
* The mandatory-tier policy has two clauses and only the first is a question
1079+
* about paths:
1080+
*
1081+
* - clause ①, encoded below: a card editing the PM dispatch skill is
1082+
* `claude-fable-5`, references included. That is a file-surface predicate
1083+
* and it is exactly what this script already takes as argv;
1084+
* - clause ②, NOT encoded and deliberately not: a card that changes contract
1085+
* accept/reject behaviour or widens the public surface is also
1086+
* `claude-fable-5`. That is judged from the card's CONTENT — what the change
1087+
* does to the contract — and a path cannot answer it. An ordinary-looking
1088+
* surface (one package's source file) is the NORMAL shape of a clause-②
1089+
* card.
1090+
*
1091+
* A path derivation that pretended to cover clause ② would produce the failure
1092+
* this whole file is written against, one level up: a "no mandate" line read as
1093+
* a clearance. So the no-mandate output says which clause it checked and which
1094+
* it cannot reach, every time, rather than leaving the reader to remember there
1095+
* were two. The output is a FLOOR, never a ceiling.
1096+
*
1097+
* The quota exemption (fable measured unavailable ⇒ opus, never lower) is a
1098+
* claim-time note about a model's availability, not a property of the file
1099+
* surface. This tool states the mandate; the seat records the exemption and its
1100+
* reason in the claim comment.
1101+
*
1102+
* ## Why the globs are matched with `hintCovers`, asymmetry included
1103+
*
1104+
* Same matcher as the gate half, so there is one path-comparison rule in this
1105+
* file rather than two — and so a glob gets the segment-boundary semantics for
1106+
* free: `.claude/skills/pm-dispatchers/x.md` is not under
1107+
* `.claude/skills/pm-dispatch/**`, which a string prefix would have mandated.
1108+
*
1109+
* `hintCovers` also matches in the other direction — an input that is an
1110+
* ANCESTOR of the glob (a surface declared as `.claude/skills`) counts as a
1111+
* hit. For gate matching that direction is a fabricated lead; here it is the
1112+
* correct one, because the error costs are not the same in the two halves. An
1113+
* over-matched gate pastes a wrong command into a prompt; an under-mandated
1114+
* tier crosses a maintainer guardrail and is only visible afterwards. A surface
1115+
* declared as a directory that CONTAINS a mandatory root may well touch it, so
1116+
* the derivation errs toward the mandate. Both directions are pinned in the
1117+
* self-test.
1118+
*
1119+
* ## Keeping this list from rotting
1120+
*
1121+
* Two guards, both live. Every declared glob must name a path that EXISTS in
1122+
* this tree — a renamed skill root would otherwise leave dead data that
1123+
* mandates nothing while reading as protection, which is the incident class
1124+
* itself. And two globs that cover one path with DIFFERENT tiers is a
1125+
* derivation this file cannot complete honestly (nothing here orders tiers), so
1126+
* it throws rather than picking one.
1127+
*
1128+
* ## One measured side effect of putting a path in a MODULE BODY
1129+
*
1130+
* Comment masking cannot reach a module-body string, so this glob is now a
1131+
* watch hint of this file's own source: measured, `extractWatchHints` yields 5
1132+
* hints here against 4 on the base, the new one being the glob itself. It is
1133+
* inert today because no check family resolves to THIS file — the gate that
1134+
* covers it is `check:pm-dispatch-gates`, which resolves to
1135+
* `check-dispatch-gates.mjs` and matches this file through that file's one
1136+
* constant. If the tool is ever wired as its own gate (a shape
1137+
* `check-dispatch-gates.mjs`'s header measures and refuses), this hint would
1138+
* start printing that gate as MATCHED for every card editing the PM skill —
1139+
* a fabricated lead. The refusal already recorded there is what keeps it inert;
1140+
* this note is so the next reader knows the cost is known, not unnoticed.
1141+
*
1142+
* The authority for the policy is the maintainer ruling quoted in the PM
1143+
* dispatch skill (2026-08-10 three-tier ruling, clause ① of its 强制条款).
1144+
* This table is a machine-readable copy of ONE predicate from it, not a second
1145+
* statement of the policy: when they disagree, the skill wins and this table is
1146+
* the thing to fix.
1147+
*/
1148+
export const MANDATORY_TIER_GLOBS = [
1149+
{
1150+
glob: '.claude/skills/pm-dispatch/**',
1151+
tier: 'claude-fable-5',
1152+
why: 'clause ① of the model-tiering ruling: a card editing the PM dispatch skill is fable-mandatory, references included — the skill is the lane\'s own operating protocol and a wrong edit propagates to every later dispatch',
1153+
},
1154+
];
1155+
1156+
/** The tier floor for a card with no mandate — the ruling's 最低下限. */
1157+
export const TIER_FLOOR = 'sonnet';
1158+
1159+
/** The default judgment tier for a card with no mandate — the ruling's 默认判断档. */
1160+
export const TIER_DEFAULT = 'opus';
1161+
1162+
/**
1163+
* Place a card's file surface against the mandatory globs. Pure over its
1164+
* inputs, so the self-test can drive every branch offline.
1165+
*
1166+
* Throws when two globs covering the same surface mandate DIFFERENT tiers:
1167+
* this file encodes no ordering over tiers, so choosing between them would be a
1168+
* guess printed as a derivation.
1169+
*/
1170+
export function deriveTier(paths, globs = MANDATORY_TIER_GLOBS) {
1171+
const hits = [];
1172+
for (const p of paths) {
1173+
for (const g of globs) {
1174+
if (hintCovers(g.glob, p)) hits.push({ path: p, glob: g.glob, tier: g.tier, why: g.why });
1175+
}
1176+
}
1177+
const tiers = [...new Set(hits.map((h) => h.tier))];
1178+
if (tiers.length > 1) {
1179+
throw new Error(
1180+
`mandatory tier is ambiguous for this surface: ${tiers.join(' vs ')} — ` +
1181+
'two globs cover it with different tiers and this script orders no tiers',
1182+
);
1183+
}
1184+
return { mandatory: hits.length > 0, tier: tiers[0] ?? null, hits, declared: globs.length };
1185+
}
1186+
1187+
/**
1188+
* Render the tier verdict. Throws on a result whose parts contradict each other
1189+
* — a hit with no tier, or a tier with no hit to justify it — because the one
1190+
* thing this section owes the reader is that a mandatory surface cannot print
1191+
* as anything else.
1192+
*/
1193+
export function tierLines(result) {
1194+
const { mandatory, tier, hits, declared } = result;
1195+
if (mandatory !== hits.length > 0 || mandatory !== Boolean(tier)) {
1196+
throw new Error(
1197+
`tier verdict is self-contradictory: mandatory=${mandatory}, tier=${tier ?? 'none'}, ` +
1198+
`${hits.length} hit(s) — a mandatory surface must print its mandate`,
1199+
);
1200+
}
1201+
const clause2 =
1202+
' Clause ② is NOT reachable from paths: a card that changes contract accept/reject behaviour or widens the public' +
1203+
' surface is fable-mandatory too, judged from the card CONTENT. This line is a FLOOR, never a clearance.';
1204+
if (!mandatory) {
1205+
return [
1206+
`Model tier — no path-derived mandate: the surface hits none of the ${declared} declared glob(s), derived here, not recalled.`,
1207+
` The tier stays the PM's per-card judgment call (floor ${TIER_FLOOR} · default ${TIER_DEFAULT} · ceiling fable).`,
1208+
clause2,
1209+
];
1210+
}
1211+
return [
1212+
`Model tier — MANDATORY: ${tier} (derived from the file surface, not recalled).`,
1213+
...hits.map((h) => ` - ${h.path} ⇢ '${h.glob}' — ${h.why}`),
1214+
' The only exit is the measured quota exemption (fable unavailable ⇒ opus, never lower), recorded with its reason' +
1215+
" in the claim comment's `Container & model` line.",
1216+
clause2,
1217+
];
1218+
}
1219+
10381220
// ---------------------------------------------------------------------------
10391221
// Live derivation
10401222
// ---------------------------------------------------------------------------
@@ -1091,6 +1273,12 @@ function derive(paths, { showResidue = false } = {}) {
10911273
}
10921274

10931275
console.log(`dispatch-gates: ${byCheck.size} check famil(ies) discovered across ${workflows.length} workflow file(s) — derived at runtime, nothing listed in this script.\n`);
1276+
// The tier verdict prints on EVERY run, hit or not. Printing it only on a hit
1277+
// would make its absence mean two things at once — "no mandate" and "this
1278+
// build has no tier derivation" — and the claim comment is written from
1279+
// whatever the run said.
1280+
for (const line of tierLines(deriveTier(paths))) console.log(line);
1281+
console.log('');
10941282
if (matched.size) {
10951283
console.log('Local gates for this card (paste into the dispatch prompt):');
10961284
for (const [check, { entry, hits }] of [...matched].sort()) {
@@ -1692,6 +1880,67 @@ function selfTest() {
16921880
}
16931881
t('a partition that does not account for every discovered family is REFUSED', refused);
16941882

1883+
// ── The model-tier derivation (#8640) ─────────────────────────────────────
1884+
//
1885+
// The incident these pin: a surface containing a pm-dispatch REFERENCES file
1886+
// was claimed as "not under the fable-mandatory roots" and dispatched at
1887+
// opus. Every direction of that judgment is asserted here — the root, the
1888+
// references half that was actually missed, a mixed surface where the
1889+
// ordinary paths must not dilute the mandate, and the ordinary surface that
1890+
// must NOT be mandated (a tool that mandates everything is ignored, which
1891+
// loses the guardrail by the other road).
1892+
const fableOf = (paths) => deriveTier(paths);
1893+
t('a pm-dispatch ROOT path is fable-mandatory', fableOf(['.claude/skills/pm-dispatch/SKILL.md']).tier === 'claude-fable-5');
1894+
t('a pm-dispatch REFERENCES path is fable-mandatory too — the half the incident missed', fableOf(['.claude/skills/pm-dispatch/references/review-checklist.md']).tier === 'claude-fable-5');
1895+
const mixed = fableOf(['packages/spec/src/data/filter.zod.ts', '.claude/skills/pm-dispatch/references/review-checklist.md']);
1896+
t('a MIXED surface is mandatory — one mandatory path decides, ordinary paths do not dilute it', mixed.mandatory && mixed.tier === 'claude-fable-5');
1897+
t('the mixed verdict reports the offending path, not just the verdict', mixed.hits.length === 1 && mixed.hits[0].path.endsWith('references/review-checklist.md'));
1898+
t('an ordinary surface carries no path-derived mandate', fableOf(['packages/spec/src/data/filter.zod.ts']).mandatory === false);
1899+
t("this tool's own file is not mandatory — the card that added this section reads itself correctly", fableOf(['scripts/pm/dispatch-gates.mjs']).mandatory === false);
1900+
// Segment boundaries, both directions of the shared matcher's asymmetry.
1901+
t('a sibling directory sharing a name PREFIX is not mandated', fableOf(['.claude/skills/pm-dispatchers/notes.md']).mandatory === false);
1902+
t('a surface declared as an ANCESTOR of a mandatory root IS mandated — the safe direction here', fableOf(['.claude/skills']).mandatory === true);
1903+
t('another skill under the same parent is not mandated', fableOf(['.claude/skills/verify/SKILL.md']).mandatory === false);
1904+
// The rendering is where the invariant is actually delivered: the claim
1905+
// comment quotes THESE lines.
1906+
const mandLines = tierLines(mixed).join('\n');
1907+
t('the mandatory rendering names the tier', mandLines.includes('claude-fable-5'));
1908+
t('the mandatory rendering says MANDATORY in a word a reader cannot skim past', mandLines.includes('MANDATORY'));
1909+
t('the mandatory rendering shows its provenance — the path and the glob that covered it', mandLines.includes('.claude/skills/pm-dispatch/references/review-checklist.md') && mandLines.includes(".claude/skills/pm-dispatch/**'"));
1910+
t('the mandatory rendering names the ONE exit, so a downgrade needs a stated reason', mandLines.includes('quota exemption') && mandLines.includes('opus, never lower'));
1911+
const plainLines = tierLines(fableOf(['packages/spec/src/data/filter.zod.ts'])).join('\n');
1912+
t('the no-mandate rendering claims no mandate', !plainLines.includes('MANDATORY'));
1913+
t('the no-mandate rendering names the floor and the default, so the judgment call has its band', plainLines.includes(TIER_FLOOR) && plainLines.includes(TIER_DEFAULT));
1914+
t('BOTH renderings state that clause ② is out of reach of paths — a no-mandate line is not a clearance', plainLines.includes('Clause ②') && mandLines.includes('Clause ②'));
1915+
t('the no-mandate rendering says how many globs it checked, so an empty table cannot read as a clearance', plainLines.includes(`${MANDATORY_TIER_GLOBS.length} declared glob`));
1916+
// Refusals: a contradiction is not printed, and an ambiguity is not guessed.
1917+
let tierRefused = false;
1918+
try {
1919+
tierLines({ mandatory: false, tier: null, hits: [{ path: 'x', glob: 'y', why: 'z' }], declared: 1 });
1920+
} catch {
1921+
tierRefused = true;
1922+
}
1923+
t('a verdict with a hit but no mandate is REFUSED, never rendered', tierRefused);
1924+
let ambiguityRefused = false;
1925+
try {
1926+
deriveTier(['.claude/skills/pm-dispatch/SKILL.md'], [
1927+
{ glob: '.claude/skills/pm-dispatch/**', tier: 'claude-fable-5', why: 'a' },
1928+
{ glob: '.claude/skills/**', tier: 'opus', why: 'b' },
1929+
]);
1930+
} catch {
1931+
ambiguityRefused = true;
1932+
}
1933+
t('two globs mandating DIFFERENT tiers for one surface are REFUSED, not guessed between', ambiguityRefused);
1934+
// Live guards. A glob naming a path this tree does not have is dead data that
1935+
// mandates nothing while reading as protection — the incident class itself.
1936+
t('the mandatory table is not empty (the guard below is not vacuous)', MANDATORY_TIER_GLOBS.length > 0);
1937+
const deadGlobs = MANDATORY_TIER_GLOBS.filter(
1938+
(g) => !existsSync(join(ROOT, g.glob.replace(/\*\*?/g, '').replace(/\/+$/, ''))),
1939+
);
1940+
t(`every declared mandatory glob names a path this tree really has (dead: ${deadGlobs.map((g) => g.glob).join(', ') || 'none'})`, deadGlobs.length === 0);
1941+
t('every declared glob carries the tier it mandates and a reason', MANDATORY_TIER_GLOBS.every((g) => g.glob && g.tier && g.why));
1942+
t('the incident file is a real file, so the references case is a live claim and not a fixture', existsSync(join(ROOT, '.claude/skills/pm-dispatch/references/review-checklist.md')));
1943+
16951944
let failed = 0;
16961945
for (const [name, cond] of cases) {
16971946
if (!cond) failed++;
@@ -1708,13 +1957,19 @@ const argvPaths = process.argv.slice(2).filter((a) => !a.startsWith('--'));
17081957
if (process.argv.includes('--self-test')) {
17091958
selfTest();
17101959
} else if (argvPaths.length === 0) {
1711-
console.error('usage: node scripts/pm/dispatch-gates.mjs [--residue] <path> [<path> ...] | --self-test');
1960+
console.error('usage: node scripts/pm/dispatch-gates.mjs [--residue] [--tier] <path> [<path> ...] | --self-test');
17121961
process.exit(2);
17131962
} else {
1963+
const paths = argvPaths.map((p) => p.replace(/^\.\//, ''));
17141964
try {
1715-
derive(argvPaths.map((p) => p.replace(/^\.\//, '')), {
1716-
showResidue: process.argv.includes('--residue'),
1717-
});
1965+
// `--tier` answers the claim-time question alone: it reads no workflow and
1966+
// no check script, so it still answers on a tree where the gate derivation
1967+
// cannot run — and a claim comment is written before any of that matters.
1968+
if (process.argv.includes('--tier')) {
1969+
for (const line of tierLines(deriveTier(paths))) console.log(line);
1970+
} else {
1971+
derive(paths, { showResidue: process.argv.includes('--residue') });
1972+
}
17181973
} catch (err) {
17191974
console.error(`dispatch-gates: derivation failed — ${err.message}`);
17201975
process.exit(2);

0 commit comments

Comments
 (0)