You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
* 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
+
exportconstMANDATORY_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
+
exportconstTIER_FLOOR='sonnet';
1158
+
1159
+
/** The default judgment tier for a card with no mandate — the ruling's 默认判断档. */
1160
+
exportconstTIER_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
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
t('a partition that does not account for every discovered family is REFUSED',refused);
1694
1882
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
+
constfableOf=(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');
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
+
constmandLines=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'));
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.
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')));
0 commit comments