From 5281099ac60d897d60956433e42ca94412f11631 Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Mon, 10 Aug 2026 10:59:07 -0700 Subject: [PATCH] fix(cursor-review): collapse catalog-drift review-me list to one row per family (BE-6911) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The weekly catalog-drift check listed 178 unpinned same-lab ids and then head-truncated each lab group at 25 rows in Cursor's raw print order, so the two genuinely new families in the 2026-08-10 catalog — gpt-5.6-terra-* and gpt-5.6-luna-* — landed inside the hidden "… and 57 more" while the visible rows were spent on gpt-5.2 and eight gpt-5.3-codex-* ids that are strictly older than the current pin. The rows are noise because the panel pins ONE reasoning/speed tier per family, so every other tier of every pinned family is an unpinned "candidate" forever. Collapse them: one row per (lab, family) naming the family, how many tiers the catalog offers and which (strongest first), plus one member's catalog note reproduced verbatim. Order rows newest-family-version first, ties broken by catalog order, so MAX_FAMILY_IDS drops the oldest families rather than an arbitrary tail of the print order. Against the catalog captured in #144 this renders 37 rows instead of 178, both new families sit at the top of the gpt group, and no group needs a "… and N more" line at all. Rendering only — analyze(), the JSON report, urgent / has_findings, the NO-ZDR handling on pins, and the unpinned-families fold are untouched. --- .github/cursor-review/catalog-drift.py | 230 ++++++++++++++++-- .../tests/fixtures/catalog-2026-08-10.txt | 197 +++++++++++++++ .../cursor-review/tests/test_catalog_drift.py | 220 ++++++++++++++++- README.md | 2 +- 4 files changed, 630 insertions(+), 19 deletions(-) create mode 100644 .github/cursor-review/tests/fixtures/catalog-2026-08-10.txt diff --git a/.github/cursor-review/catalog-drift.py b/.github/cursor-review/catalog-drift.py index 15a900b..eaf8a32 100644 --- a/.github/cursor-review/catalog-drift.py +++ b/.github/cursor-review/catalog-drift.py @@ -23,7 +23,12 @@ which the panel does *not* pin. A REVIEW-ME list, never an auto-recommendation: picking "newest highest-reasoning ZDR-eligible" needs human judgment, and ZDR especially — Cursor only marks NO-ZDR inline (e.g. a `(NO ZDR)` suffix), so any - such marker on the line is surfaced verbatim rather than interpreted. + such marker on the line is surfaced verbatim rather than interpreted. Rendered + one row per model *family* rather than per id (BE-6911): the panel pins one + reasoning/speed tier per family, so every other tier of every pinned family is + an "unpinned candidate" forever — 178 rows on the 2026-08-10 catalog, in which + the two genuinely new families were below the truncation cut. See + `TIER_SUFFIXES`. * **unpinned model families** — catalog ids whose family prefix matches no pin at all. Quieter than the above (mostly labs the panel will never pin, so it renders collapsed and is never `urgent`), but it is the ONLY place a lab the @@ -67,17 +72,37 @@ MAX_CATALOG_CHARS = 40000 MAX_BODY_CHARS = 60000 # Budgets for the report's candidate lists, so `MAX_BODY_CHARS` (a blunt -# `body[:N]`) can never cut into their markup. MAX_FAMILY_IDS caps EVERY -# per-lab id list (the same-lab review-me list, a delisted pin's alternatives, -# the families fold); MAX_FAMILY_LABS plus the CHARS budget bound the families +# `body[:N]`) can never cut into their markup. MAX_FAMILY_IDS caps the ROWS of +# every per-lab list — but a row is not always one id: the same-lab review-me +# list collapses a family's reasoning/speed tiers into a single row (see +# `TIER_SUFFIXES`), while a delisted pin's alternatives and the families fold +# still list raw ids. MAX_FAMILY_LABS plus the CHARS budget bound the families # fold, the one list whose group count the catalog controls — row caps alone # bound its rows, not its chars (40 labs × 25 capped-note rows is still ~4× the -# body cap). All generous next to a real catalog — a few dozen models across a -# handful of families — and every list leads with its highest-signal rows, so -# the first rows carry it. +# body cap). +# +# Sized against a real catalog rather than a guess: the 2026-08-10 one carried +# 178 unpinned ids across the four pinned labs, which collapse to ~40 family +# rows — so with tier collapse the cap is not reached on today's catalog at all. +# It is still a cap, so ordering is what makes it safe: the same-lab list is +# sorted newest-family-version-first (`_family_version`), so what MAX_FAMILY_IDS +# drops is the oldest families rather than an arbitrary tail of Cursor's print +# order. The families fold is a watchlist scanned whole, so it keeps catalog +# order. MAX_FAMILY_LABS = 40 MAX_FAMILY_IDS = 25 MAX_FAMILY_FOLD_CHARS = 15000 +# Tier tokens listed on one collapsed row before it says `+N more`. The tier +# vocabulary is fixed (`len(TIER_SUFFIXES)`, doubled by the `-fast` twins), so a +# row is bounded either way — but "bounded" is not "small". At this value a +# collapsed row costs about what the single id row it replaces used to, so the +# section's worst-case char budget (every pinned lab at MAX_FAMILY_IDS rows, +# every note at MAX_NOTE_CHARS) is unchanged by the collapse; listing all 17 +# would inflate it ~15% and eat into what is left for the raw catalog fold. +# Tiers are listed strongest-first and the count is always stated in full ahead +# of the list, so a truncated row still says how many tiers the family offers +# and drops the weakest — the part a promotion decision does not use. +MAX_TIERS_PER_ROW = 8 # Cap on a rendered note. Notes are third-party free text repeated across # dozens of rows; unbounded, a single note could eat the whole body budget. MAX_NOTE_CHARS = 200 @@ -110,6 +135,37 @@ # window from being too tight. LAST_CHECKED_WINDOW = 40 +# The reasoning/speed suffixes Cursor appends to a model family, and the ONE +# place they are enumerated. Cursor ships a family at every reasoning tier and +# most of them again as `-fast`, but the panel pins exactly ONE tier per family +# — so without collapsing, every other tier of every family the panel ALREADY +# pins is reported as an unpinned "candidate" forever. That is what buried the +# two genuinely new families under 178 rows on 2026-08-10 (BE-6911). +# +# `extra-high` precedes `high` because the family/tier split takes the LONGEST +# tier that matches (see `_TIER_SUFFIX_RE`) — otherwise `gpt-5.5-extra-high` +# would read as family `gpt-5.5-extra`. A bare `-fast` with no tier in front of +# it (`gpt-5.3-codex-fast`) is a tier too: it is the default tier, run fast. +# +# NOT included, deliberately: `-thinking`. It reads like a reasoning knob but +# the panel pins ON it — `claude-opus-5-thinking-max` and `claude-opus-5-max` +# are different products for this purpose — so collapsing the two together +# would hide exactly the distinction a promotion decision turns on. +TIER_SUFFIXES = ("extra-high", "minimal", "none", "low", "medium", "high", "xhigh", "max") +SPEED_SUFFIX = "fast" +_TIER_SUFFIX_RE = re.compile( + r"^(?P.+?)-(?P(?:(?:{tiers})(?:-{fast})?|{fast}))$".format( + tiers="|".join(re.escape(tier) for tier in TIER_SUFFIXES), + fast=re.escape(SPEED_SUFFIX), + ) +) +# Rough capability order of the tiers, weakest first. Used for ONE thing: which +# member of a collapsed family lends the row its catalog note (the tier a human +# would most plausibly promote). It is not a recommendation and never reaches +# the report — the note itself is still reproduced verbatim. +_TIER_STRENGTH = ("", "none", "minimal", "low", "medium", "high", "extra-high", "xhigh", "max") +_VERSION_TOKEN = re.compile(r"\d+") + class ExtractionError(Exception): """The pins could not be read out of the workflow file.""" @@ -359,6 +415,57 @@ def lab_of(model_id): return re.split(r"[-.]", model_id, maxsplit=1)[0].lower() +def family_of(model_id): + """Split an id into `(family, tier)` at its reasoning/speed suffix. + + `gpt-5.6-terra-max-fast` -> `('gpt-5.6-terra', 'max-fast')`. An id carrying + no suffix from `TIER_SUFFIXES` is its own family at the empty (default) + tier — `gpt-5.3-codex` -> `('gpt-5.3-codex', '')` — which is what merges it + with its `-low`/`-fast`/… siblings instead of stranding it as a family of + one. + + Deliberately a *suffix* rule and not a table: like `lab_of`, it stays + zero-maintenance when a lab ships a new family name. The cost is that a + family whose name happens to end in a tier word would be split — no such id + exists in Cursor's catalog, and the failure mode is a cosmetic extra row in + a review-me list, not a missed finding. + """ + match = _TIER_SUFFIX_RE.match(model_id) + if not match: + return model_id, "" + return match.group("family"), match.group("tier") + + +def _family_version(family): + """Sort key for a family — every digit run in it, in order. + + `gpt-5.6-terra` -> `(5, 6)`; `claude-opus-4-8-thinking` -> `(4, 8)`; + `kimi-k3` -> `(3,)`; `code-supernova` -> `()`, which sorts oldest. Purely + positional on purpose: nothing in a bare id says which number is the version, + and the labs write it at least three ways (`5.6`, `4-8`, `k3`), so anything + smarter would be a per-lab table — the maintenance burden this checker + avoids everywhere else. Sorting on it descending is what makes the + MAX_FAMILY_IDS cut drop the OLDEST families rather than whatever Cursor + happened to print last. + """ + return tuple(int(token) for token in _VERSION_TOKEN.findall(family)) + + +def _tier_strength(tier): + """Rank a tier for picking a collapsed row's representative — see `_TIER_STRENGTH`.""" + base, fast = tier, False + # Split the SPEED half off the END, not at the first `-`: a tier can carry a + # hyphen itself (`extra-high`), and splitting at the first one reads its base + # as `extra`, which is in no ranking and would demote the family's top tier. + if base == SPEED_SUFFIX: # a bare `-fast` id is the default tier, run fast + base, fast = "", True + elif base.endswith("-" + SPEED_SUFFIX): + base, fast = base[: -len(SPEED_SUFFIX) - 1], True + strength = _TIER_STRENGTH.index(base) if base in _TIER_STRENGTH else 0 + # Prefer the non-`fast` twin, so the note reads as the family's plain top tier. + return (strength, 0 if fast else 1) + + def analyze(panel_models, judge_model, catalog_text, last_checked, today, stale_days): """Compare the pins against the catalog and return the drift report.""" entries = catalog_entries(catalog_text) @@ -547,11 +654,13 @@ def _inline_code(text): def _candidate_list(candidates, limit=None): - """Bullet list of `{id, note}` rows — shared by both unpinned sections. + """Bullet list of `{id, note}` rows, one per id — the unpinned-families fold. `limit` caps the rows and names the remainder instead of dropping it - silently. Every caller passes one: the number of GROUPS in the same-lab - list is bounded by the pins, but the ids per group come from the catalog. + silently; the caller passes one because the ids per group come from the + catalog. The same-lab review-me list uses `_collapsed_candidate_list` + instead — the fold is a short watchlist scanned whole, so it keeps one row + per id in catalog order. """ shown = candidates if limit is None else candidates[:limit] rows = [ @@ -563,6 +672,86 @@ def _candidate_list(candidates, limit=None): return "\n".join(rows) +def collapse_tiers(candidates): + """Group `{id, note}` rows into one group per family, newest family first. + + Returns `[{family, members, _order}]` where `members` are the candidate rows + of that family in catalog order, each carrying its `tier`, and `_order` is + the family's first appearance in the catalog. Ordering is `_family_version` + descending, ties broken by that catalog order — the "highest-signal rows + first" the row caps assume. + """ + groups = [] + by_family = {} + for index, candidate in enumerate(candidates): + family, tier = family_of(candidate["id"]) + group = by_family.get(family) + if group is None: + group = by_family[family] = {"family": family, "members": [], "_order": index} + groups.append(group) + group["members"].append(dict(candidate, tier=tier)) + # Explicit `-_order` rather than leaning on sort stability under `reverse`: + # the tie-break IS the contract here (catalog order), so it is written down. + groups.sort(key=lambda g: (_family_version(g["family"]), -g["_order"]), reverse=True) + return groups + + +def _collapsed_candidate_list(candidates, pinned=(), limit=None): + """`_candidate_list`, but one row per FAMILY — the same-lab review-me list. + + The panel pins one tier per family, so a per-id list reports every other + tier of every pinned family as a candidate, forever (BE-6911: 178 rows, with + both new families below the cut). One row per family names the family, how + many tiers the catalog offers and which, and reproduces ONE member's catalog + note verbatim — the strongest tier's, since that is the plausible promotion + target. Per-id notes are not interpreted or merged: a family whose members + carry differing notes (a NO-ZDR marker on some tiers only) shows the + representative's, and the raw catalog fold below remains the full record. + + `pinned` are the lab's pinned ids, used only to mark a family the panel + already pins — the rows that are noise by construction. + """ + groups = collapse_tiers(candidates) + shown = groups if limit is None else groups[:limit] + pinned_by_family = {} + for pin in pinned: + pinned_by_family.setdefault(family_of(pin)[0], []).append(pin) + + rows = [] + for group in shown: + members = group["members"] + # `max` keeps the FIRST maximal member, i.e. catalog order breaks ties. + top = max(members, key=lambda m: _tier_strength(m["tier"])) + note = f" — {_inline_code(top['note'])}" if top["note"] else "" + already = pinned_by_family.get(group["family"]) or [] + pin_note = ( + " · _panel already pins " + ", ".join(f"`{p}`" for p in already) + "_" if already else "" + ) + if len(members) == 1: + rows.append(f"- `{top['id']}`{note}{pin_note}") + continue + # Strongest tier first, so MAX_TIERS_PER_ROW drops the weakest rather + # than whichever ones Cursor happened to print last. + ranked = sorted(members, key=lambda m: _tier_strength(m["tier"]), reverse=True) + tiers = [f"`{m['tier']}`" if m["tier"] else "_default_" for m in ranked] + if len(tiers) > MAX_TIERS_PER_ROW: + extra = len(tiers) - MAX_TIERS_PER_ROW + tiers = tiers[:MAX_TIERS_PER_ROW] + [f"+{extra} more"] + rows.append( + f"- `{group['family']}-*` — {len(members)} tiers ({', '.join(tiers)}); " + f"top `{top['id']}`{note}{pin_note}" + ) + + hidden = len(groups) - len(shown) + if hidden: + hidden_ids = sum(len(g["members"]) for g in groups[len(shown) :]) + rows.append( + f"- _… and {hidden} older famil{'y' if hidden == 1 else 'ies'} " + f"({hidden_ids} id{'s' if hidden_ids != 1 else ''}) — see the raw catalog fold below._" + ) + return "\n".join(rows) + + def render_body(report, catalog_text, run_url=None, checked_at=None): """Render the sticky issue body (also used as the run's step summary).""" pins = report["pins"] @@ -624,16 +813,25 @@ def render_body(report, catalog_text, run_url=None, checked_at=None): if report["unpinned"]: out.append( "## Unpinned same-lab catalog ids — review me\n\n" - "Catalog ids from labs the panel already pins that are **not** pinned today. This is a " - "**review-me list, not a recommendation**: picking the newest highest-reasoning " - "*ZDR-eligible* model is a human call. Cursor only marks NO-ZDR models inline, so any " - "marker on the catalog line is reproduced verbatim below — an id with no marker is " - "**not** thereby confirmed ZDR-eligible; check the catalog before promoting one." + "Catalog ids from labs the panel already pins that are **not** pinned today, **one row " + "per model family**, newest family first — ids that differ only by a reasoning/speed " + "tier (`-none`/`-low`/`-medium`/`-high`/`-xhigh`/`-max`, each optionally `-fast`) share " + "a row, since the panel pins one tier per family and the rest are not separate " + "candidates. This is a **review-me list, not a recommendation**: picking the newest " + "highest-reasoning *ZDR-eligible* model is a human call. Cursor only marks NO-ZDR " + "models inline, so any marker on the catalog line is reproduced verbatim below — an id " + "with no marker is **not** thereby confirmed ZDR-eligible; check the catalog before " + "promoting one. A collapsed row shows the note of the tier named after `top` only, so " + "consult the raw fold for the other tiers' lines before promoting." ) for group in report["unpinned"]: pinned_now = ", ".join(f"`{m}`" for m in group["pinned"]) or "_none_" out.append(f"**`{group['lab']}`** (pinned: {pinned_now})") - out.append(_candidate_list(group["candidates"], limit=MAX_FAMILY_IDS)) + out.append( + _collapsed_candidate_list( + group["candidates"], pinned=group["pinned"], limit=MAX_FAMILY_IDS + ) + ) families = report.get("unpinned_labs") or [] if families: diff --git a/.github/cursor-review/tests/fixtures/catalog-2026-08-10.txt b/.github/cursor-review/tests/fixtures/catalog-2026-08-10.txt new file mode 100644 index 0000000..818d8fa --- /dev/null +++ b/.github/cursor-review/tests/fixtures/catalog-2026-08-10.txt @@ -0,0 +1,197 @@ +Available models + +auto - Auto (default) +gpt-5.3-codex-low - Codex 5.3 Low +gpt-5.3-codex-low-fast - Codex 5.3 Low Fast +gpt-5.3-codex - Codex 5.3 +gpt-5.3-codex-fast - Codex 5.3 Fast +gpt-5.3-codex-high - Codex 5.3 High +gpt-5.3-codex-high-fast - Codex 5.3 High Fast +gpt-5.3-codex-xhigh - Codex 5.3 Extra High +gpt-5.3-codex-xhigh-fast - Codex 5.3 Extra High Fast +gpt-5.2 - GPT-5.2 +cursor-grok-4.5-high - Cursor Grok 4.5 +cursor-grok-4.5-high-fast - Cursor Grok 4.5 Fast +composer-2.5 - Composer 2.5 +claude-opus-5-thinking-high - Opus 5 1M Thinking +claude-opus-5-thinking-high-fast - Opus 5 1M Thinking Fast +claude-opus-5-thinking-xhigh - Opus 5 1M Extra High Thinking +claude-opus-5-thinking-xhigh-fast - Opus 5 1M Extra High Thinking Fast +claude-opus-4-8-thinking-high - Opus 4.8 1M Thinking +claude-opus-4-8-thinking-high-fast - Opus 4.8 1M Thinking Fast +gpt-5.6-sol-high - GPT-5.6 Sol 1M High +gpt-5.6-sol-high-fast - GPT-5.6 Sol High Fast +gpt-5.6-sol-xhigh - GPT-5.6 Sol 1M Extra High +gpt-5.6-sol-xhigh-fast - GPT-5.6 Sol Extra High Fast +gpt-5.5-high - GPT-5.5 1M High +gpt-5.5-high-fast - GPT-5.5 High Fast +claude-fable-5-thinking-high - Fable 5 1M Thinking (NO ZDR) +claude-fable-5-thinking-xhigh - Fable 5 1M Extra High Thinking (NO ZDR) +claude-sonnet-5-thinking-high - Sonnet 5 1M Thinking +claude-sonnet-5-thinking-xhigh - Sonnet 5 1M Extra High Thinking +kimi-k3-high - Kimi K3 High +cursor-grok-4.5-low - Cursor Grok 4.5 Low +cursor-grok-4.5-low-fast - Cursor Grok 4.5 Low Fast +cursor-grok-4.5-medium - Cursor Grok 4.5 Medium +cursor-grok-4.5-medium-fast - Cursor Grok 4.5 Medium Fast +composer-2.5-fast - Composer 2.5 Fast +claude-opus-5-low - Opus 5 1M Low +claude-opus-5-low-fast - Opus 5 1M Low Fast +claude-opus-5-medium - Opus 5 1M Medium +claude-opus-5-medium-fast - Opus 5 1M Medium Fast +claude-opus-5-high - Opus 5 1M +claude-opus-5-high-fast - Opus 5 1M Fast +claude-opus-5-thinking-low - Opus 5 1M Low Thinking +claude-opus-5-thinking-low-fast - Opus 5 1M Low Thinking Fast +claude-opus-5-thinking-medium - Opus 5 1M Medium Thinking +claude-opus-5-thinking-medium-fast - Opus 5 1M Medium Thinking Fast +claude-opus-5-thinking-max - Opus 5 1M Max Thinking +claude-opus-5-thinking-max-fast - Opus 5 1M Max Thinking Fast +claude-opus-4-8-low - Opus 4.8 1M Low +claude-opus-4-8-low-fast - Opus 4.8 1M Low Fast +claude-opus-4-8-medium - Opus 4.8 1M Medium +claude-opus-4-8-medium-fast - Opus 4.8 1M Medium Fast +claude-opus-4-8-high - Opus 4.8 1M +claude-opus-4-8-high-fast - Opus 4.8 1M Fast +claude-opus-4-8-xhigh - Opus 4.8 1M Extra High +claude-opus-4-8-xhigh-fast - Opus 4.8 1M Extra High Fast +claude-opus-4-8-max - Opus 4.8 1M Max +claude-opus-4-8-max-fast - Opus 4.8 1M Max Fast +claude-opus-4-8-thinking-low - Opus 4.8 1M Low Thinking +claude-opus-4-8-thinking-low-fast - Opus 4.8 1M Low Thinking Fast +claude-opus-4-8-thinking-medium - Opus 4.8 1M Medium Thinking +claude-opus-4-8-thinking-medium-fast - Opus 4.8 1M Medium Thinking Fast +claude-opus-4-8-thinking-xhigh - Opus 4.8 1M Extra High Thinking +claude-opus-4-8-thinking-xhigh-fast - Opus 4.8 1M Extra High Thinking Fast +claude-opus-4-8-thinking-max - Opus 4.8 1M Max Thinking +claude-opus-4-8-thinking-max-fast - Opus 4.8 1M Max Thinking Fast +gpt-5.6-sol-none - GPT-5.6 Sol 1M None +gpt-5.6-sol-none-fast - GPT-5.6 Sol None Fast +gpt-5.6-sol-low - GPT-5.6 Sol 1M Low +gpt-5.6-sol-low-fast - GPT-5.6 Sol Low Fast +gpt-5.6-sol-medium - GPT-5.6 Sol 1M +gpt-5.6-sol-medium-fast - GPT-5.6 Sol Fast +gpt-5.6-sol-max - GPT-5.6 Sol 1M Max +gpt-5.6-sol-max-fast - GPT-5.6 Sol Max Fast +gpt-5.5-none - GPT-5.5 1M None +gpt-5.5-none-fast - GPT-5.5 None Fast +gpt-5.5-low - GPT-5.5 1M Low +gpt-5.5-low-fast - GPT-5.5 Low Fast +gpt-5.5-medium - GPT-5.5 1M +gpt-5.5-medium-fast - GPT-5.5 Fast +gpt-5.5-extra-high - GPT-5.5 1M Extra High +gpt-5.5-extra-high-fast - GPT-5.5 Extra High Fast +claude-fable-5-low - Fable 5 1M Low (NO ZDR) +claude-fable-5-medium - Fable 5 1M Medium (NO ZDR) +claude-fable-5-high - Fable 5 1M (NO ZDR) +claude-fable-5-xhigh - Fable 5 1M Extra High (NO ZDR) +claude-fable-5-max - Fable 5 1M Max (NO ZDR) +claude-fable-5-thinking-low - Fable 5 1M Low Thinking (NO ZDR) +claude-fable-5-thinking-medium - Fable 5 1M Medium Thinking (NO ZDR) +claude-fable-5-thinking-max - Fable 5 1M Max Thinking (NO ZDR) +claude-sonnet-5-low - Sonnet 5 1M Low +claude-sonnet-5-medium - Sonnet 5 1M Medium +claude-sonnet-5-high - Sonnet 5 1M +claude-sonnet-5-xhigh - Sonnet 5 1M Extra High +claude-sonnet-5-max - Sonnet 5 1M Max +claude-sonnet-5-thinking-low - Sonnet 5 1M Low Thinking +claude-sonnet-5-thinking-medium - Sonnet 5 1M Medium Thinking +claude-sonnet-5-thinking-max - Sonnet 5 1M Max Thinking +gpt-5.6-terra-none - GPT-5.6 Terra 1M None +gpt-5.6-terra-none-fast - GPT-5.6 Terra None Fast +gpt-5.6-terra-low - GPT-5.6 Terra 1M Low +gpt-5.6-terra-low-fast - GPT-5.6 Terra Low Fast +gpt-5.6-terra-medium - GPT-5.6 Terra 1M +gpt-5.6-terra-medium-fast - GPT-5.6 Terra Fast +gpt-5.6-terra-high - GPT-5.6 Terra 1M High +gpt-5.6-terra-high-fast - GPT-5.6 Terra High Fast +gpt-5.6-terra-xhigh - GPT-5.6 Terra 1M Extra High +gpt-5.6-terra-xhigh-fast - GPT-5.6 Terra Extra High Fast +gpt-5.6-terra-max - GPT-5.6 Terra 1M Max +gpt-5.6-terra-max-fast - GPT-5.6 Terra Max Fast +claude-4.6-sonnet-medium - Sonnet 4.6 1M +claude-4.6-sonnet-medium-thinking - Sonnet 4.6 1M Thinking +claude-opus-4-7-low - Opus 4.7 1M Low +claude-opus-4-7-low-fast - Opus 4.7 1M Low Fast +claude-opus-4-7-medium - Opus 4.7 1M Medium +claude-opus-4-7-medium-fast - Opus 4.7 1M Medium Fast +claude-opus-4-7-high - Opus 4.7 1M High +claude-opus-4-7-high-fast - Opus 4.7 1M High Fast +claude-opus-4-7-xhigh - Opus 4.7 1M +claude-opus-4-7-xhigh-fast - Opus 4.7 1M Fast +claude-opus-4-7-max - Opus 4.7 1M Max +claude-opus-4-7-max-fast - Opus 4.7 1M Max Fast +claude-opus-4-7-thinking-low - Opus 4.7 1M Low Thinking +claude-opus-4-7-thinking-low-fast - Opus 4.7 1M Low Thinking Fast +claude-opus-4-7-thinking-medium - Opus 4.7 1M Medium Thinking +claude-opus-4-7-thinking-medium-fast - Opus 4.7 1M Medium Thinking Fast +claude-opus-4-7-thinking-high - Opus 4.7 1M High Thinking +claude-opus-4-7-thinking-high-fast - Opus 4.7 1M High Thinking Fast +claude-opus-4-7-thinking-xhigh - Opus 4.7 1M Thinking +claude-opus-4-7-thinking-xhigh-fast - Opus 4.7 1M Thinking Fast +claude-opus-4-7-thinking-max - Opus 4.7 1M Max Thinking +claude-opus-4-7-thinking-max-fast - Opus 4.7 1M Max Thinking Fast +gpt-5.4-low - GPT-5.4 1M Low +gpt-5.4-medium - GPT-5.4 1M +gpt-5.4-medium-fast - GPT-5.4 Fast +gpt-5.4-high - GPT-5.4 1M High +gpt-5.4-high-fast - GPT-5.4 High Fast +gpt-5.4-xhigh - GPT-5.4 1M Extra High +gpt-5.4-xhigh-fast - GPT-5.4 Extra High Fast +claude-4.6-opus-high - Opus 4.6 1M +claude-4.6-opus-max - Opus 4.6 1M Max +claude-4.6-opus-high-thinking - Opus 4.6 1M Thinking +claude-4.6-opus-max-thinking - Opus 4.6 1M Max Thinking +claude-4.5-opus-high - Opus 4.5 +claude-4.5-opus-high-thinking - Opus 4.5 Thinking +gpt-5.2-low - GPT-5.2 Low +gpt-5.2-low-fast - GPT-5.2 Low Fast +gpt-5.2-fast - GPT-5.2 Fast +gpt-5.2-high - GPT-5.2 High +gpt-5.2-high-fast - GPT-5.2 High Fast +gpt-5.2-xhigh - GPT-5.2 Extra High +gpt-5.2-xhigh-fast - GPT-5.2 Extra High Fast +gpt-5.6-luna-none - GPT-5.6 Luna 1M None +gpt-5.6-luna-none-fast - GPT-5.6 Luna None Fast +gpt-5.6-luna-low - GPT-5.6 Luna 1M Low +gpt-5.6-luna-low-fast - GPT-5.6 Luna Low Fast +gpt-5.6-luna-medium - GPT-5.6 Luna 1M +gpt-5.6-luna-medium-fast - GPT-5.6 Luna Fast +gpt-5.6-luna-high - GPT-5.6 Luna 1M High +gpt-5.6-luna-high-fast - GPT-5.6 Luna High Fast +gpt-5.6-luna-xhigh - GPT-5.6 Luna 1M Extra High +gpt-5.6-luna-xhigh-fast - GPT-5.6 Luna Extra High Fast +gpt-5.6-luna-max - GPT-5.6 Luna 1M Max +gpt-5.6-luna-max-fast - GPT-5.6 Luna Max Fast +gemini-3.6-flash-minimal - Gemini 3.6 Flash Minimal +gemini-3.6-flash-low - Gemini 3.6 Flash Low +gemini-3.6-flash-medium - Gemini 3.6 Flash Medium +gemini-3.6-flash-high - Gemini 3.6 Flash +gemini-3.1-pro - Gemini 3.1 Pro +gpt-5.4-mini-none - GPT-5.4 Mini None +gpt-5.4-mini-low - GPT-5.4 Mini Low +gpt-5.4-mini-medium - GPT-5.4 Mini +gpt-5.4-mini-high - GPT-5.4 Mini High +gpt-5.4-mini-xhigh - GPT-5.4 Mini Extra High +gpt-5.4-nano-none - GPT-5.4 Nano None +gpt-5.4-nano-low - GPT-5.4 Nano Low +gpt-5.4-nano-medium - GPT-5.4 Nano +gpt-5.4-nano-high - GPT-5.4 Nano High +gpt-5.4-nano-xhigh - GPT-5.4 Nano Extra High +claude-4.5-sonnet - Sonnet 4.5 +claude-4.5-sonnet-thinking - Sonnet 4.5 Thinking +gpt-5.1-low - GPT-5.1 Low +gpt-5.1 - GPT-5.1 +gpt-5.1-high - GPT-5.1 High +gemini-3-flash - Gemini 3 Flash +gemini-3.5-flash - Gemini 3.5 Flash +claude-4-sonnet - Sonnet 4 +claude-4-sonnet-thinking - Sonnet 4 Thinking +gpt-5-mini - GPT-5 Mini +kimi-k3-low - Kimi K3 Low +kimi-k3-max - Kimi K3 +kimi-k2.7-code - Kimi K2.7 Code +glm-5.2-high - GLM 5.2 +glm-5.2-max - GLM 5.2 Max + +Tip: use --model (or /model in interactive mode) to switch. Parameterized models also accept quoted overrides, e.g. --model 'claude-opus-4-8[context=1m,effort=high,fast=false]'. diff --git a/.github/cursor-review/tests/test_catalog_drift.py b/.github/cursor-review/tests/test_catalog_drift.py index 69aa876..fc2a80b 100644 --- a/.github/cursor-review/tests/test_catalog_drift.py +++ b/.github/cursor-review/tests/test_catalog_drift.py @@ -14,8 +14,11 @@ grouped by the labs the pins actually use, and the audit date goes stale at the threshold, not before. * **reporting** — NO-ZDR markers survive into the body verbatim, the raw - catalog is folded into a
block, and a clean run says so (that is - what closes the sticky issue). + catalog is folded into a
block, a clean run says so (that is what + closes the sticky issue), and the same-lab review-me list collapses + reasoning/speed tiers into one row per family, newest family first, so the + row cap drops the oldest rather than an arbitrary tail of Cursor's print + order (BE-6911 — see `TierCollapseTest`). Run: python3 -m unittest discover -s .github/cursor-review/tests -p 'test_*.py' """ @@ -84,6 +87,34 @@ JUDGE = "claude-opus-4-8-thinking-max" TODAY = datetime.date(2026, 7, 27) +# The real `cursor-agent models` output captured verbatim in the 2026-08-10 +# sticky issue (Comfy-Org/github-workflows#144), with the pins that run reported +# against. It is the whole reason BE-6911 exists — 178 unpinned same-lab ids, in +# which the two genuinely new families (`gpt-5.6-terra-*`, `gpt-5.6-luna-*`) sat +# below a 25-row head truncation — so the rendering fix is asserted against it +# rather than against a hand-built approximation of it. +REAL_CATALOG_PATH = os.path.join(os.path.dirname(__file__), "fixtures", "catalog-2026-08-10.txt") +REAL_PANEL = ["gpt-5.6-sol-max", "claude-opus-5-thinking-max", "gemini-3.1-pro", "kimi-k3-max"] +REAL_JUDGE = "claude-opus-5-thinking-max" + + +def real_catalog(): + with open(REAL_CATALOG_PATH, encoding="utf-8") as handle: + return handle.read() + + +def lab_rows(body, lab): + """The rendered rows of one lab group in the same-lab review-me list.""" + section = body[body.index("## Unpinned same-lab") :] + section = section[section.index(f"**`{lab}`** (pinned:") :] + rows = [] + for line in section.splitlines()[1:]: + if line.startswith("**`") or line.startswith("
") or line.startswith("## "): + break + if line.startswith("- "): + rows.append(line) + return rows + def analyze(catalog=CATALOG, panel=None, judge=JUDGE, last_checked=datetime.date(2026, 7, 14), today=TODAY): return cd.analyze( @@ -655,6 +686,191 @@ def test_clean_body_says_no_drift(self): self.assertIn("No drift", body) +class TierCollapseTest(unittest.TestCase): + """BE-6911 — the same-lab review-me list is one row per FAMILY, newest first. + + The defect this pins: the panel pins ONE reasoning/speed tier per family, so + every other tier of every already-pinned family is an "unpinned candidate" + forever. The 2026-08-10 run listed 178 of them and then head-truncated each + lab at 25 rows in Cursor's print order, which put the only two rows worth + reading — the brand-new `gpt-5.6-terra-*` and `gpt-5.6-luna-*` families — + inside the hidden "… and 57 more". + """ + + def test_family_of_splits_at_the_reasoning_tier(self): + self.assertEqual(cd.family_of("gpt-5.6-terra-max-fast"), ("gpt-5.6-terra", "max-fast")) + self.assertEqual(cd.family_of("gpt-5.6-terra-max"), ("gpt-5.6-terra", "max")) + self.assertEqual(cd.family_of("gemini-3.6-flash-minimal"), ("gemini-3.6-flash", "minimal")) + + def test_an_id_with_no_tier_suffix_is_its_familys_default_tier(self): + # `gpt-5.3-codex` must land in the SAME family as `gpt-5.3-codex-high`, + # not strand itself as a family of one. + self.assertEqual(cd.family_of("gpt-5.3-codex"), ("gpt-5.3-codex", "")) + self.assertEqual(cd.family_of("kimi-k2.7-code"), ("kimi-k2.7-code", "")) + + def test_a_bare_fast_suffix_is_a_tier_of_its_family(self): + # `gpt-5.3-codex-fast` is the default tier run fast — the same family. + self.assertEqual(cd.family_of("gpt-5.3-codex-fast"), ("gpt-5.3-codex", "fast")) + + def test_the_longest_matching_tier_wins(self): + # `extra-high` before `high`, or the family reads as `gpt-5.5-extra`. + self.assertEqual(cd.family_of("gpt-5.5-extra-high"), ("gpt-5.5", "extra-high")) + self.assertEqual(cd.family_of("gpt-5.5-extra-high-fast"), ("gpt-5.5", "extra-high-fast")) + + def test_thinking_is_not_collapsed_away(self): + # The panel pins ON `-thinking` (`claude-opus-5-thinking-max` vs + # `claude-opus-5-max`), so merging the two would hide the distinction a + # promotion decision turns on. + self.assertEqual( + cd.family_of("claude-opus-5-thinking-max"), ("claude-opus-5-thinking", "max") + ) + self.assertEqual(cd.family_of("claude-opus-5-max"), ("claude-opus-5", "max")) + + def test_collapse_groups_every_tier_into_one_row(self): + candidates = [ + {"id": f"gpt-9.1-{tier}", "note": tier} for tier in ("low", "low-fast", "high", "max") + ] + groups = cd.collapse_tiers(candidates) + self.assertEqual([g["family"] for g in groups], ["gpt-9.1"]) + self.assertEqual(len(groups[0]["members"]), 4) + + def test_collapse_orders_families_by_version_descending(self): + candidates = [ + {"id": "gpt-5.3-codex-high", "note": ""}, + {"id": "gpt-5.6-luna-max", "note": ""}, + {"id": "gpt-5.6-terra-max", "note": ""}, + {"id": "gpt-5.4-high", "note": ""}, + ] + # 5.6 families first (tie broken by catalog order: luna was printed + # first), then 5.4, then 5.3 — NOT Cursor's print order, which leads + # with 5.3. + self.assertEqual( + [g["family"] for g in cd.collapse_tiers(candidates)], + ["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.4", "gpt-5.3-codex"], + ) + + def test_a_versionless_family_sorts_last_rather_than_crashing(self): + candidates = [{"id": "gpt-supernova-max", "note": ""}, {"id": "gpt-5.1-max", "note": ""}] + self.assertEqual( + [g["family"] for g in cd.collapse_tiers(candidates)], ["gpt-5.1", "gpt-supernova"] + ) + + def test_body_renders_one_row_per_family_naming_its_tiers(self): + catalog = "\n".join(PANEL) + "".join( + f"\ngpt-5.9-nova-{tier} - Nova {tier}" for tier in ("low", "high", "max", "max-fast") + ) + body = cd.render_body(analyze(catalog=catalog), catalog) + rows = lab_rows(body, "gpt") + self.assertEqual(len(rows), 1, rows) + self.assertIn("`gpt-5.9-nova-*`", rows[0]) + self.assertIn("4 tiers", rows[0]) + for tier in ("`max`", "`max-fast`", "`high`", "`low`"): + self.assertIn(tier, rows[0]) + # The row still reproduces ONE member's catalog note verbatim. + self.assertIn("`gpt-5.9-nova-max`", rows[0]) + self.assertIn("Nova max", rows[0]) + + def test_a_family_of_one_still_renders_as_a_plain_id_row(self): + body = cd.render_body(analyze(), CATALOG) + self.assertIn("- `gpt-5.6-sol`", body) + + def test_a_family_the_panel_already_pins_is_marked_as_such(self): + # The rows that are noise by construction: the panel pins this family, + # just at another tier. + body = cd.render_body(analyze(), CATALOG) + self.assertIn("panel already pins `gpt-5.6-sol-max`", body) + + def test_a_collapsed_row_reproduces_a_no_zdr_marker_verbatim(self): + catalog = "\n".join(PANEL) + ( + "\ngpt-5.9-nova-low - Nova Low (NO ZDR)\ngpt-5.9-nova-max - Nova Max (NO ZDR)" + ) + body = cd.render_body(analyze(catalog=catalog), catalog) + self.assertIn("(NO ZDR)", lab_rows(body, "gpt")[0]) + + def test_collapsed_rows_are_capped_and_name_what_they_dropped(self): + # Silent truncation reads as "that is all of them" — the exact misreading + # this ticket exists to end. + ids = "".join( + f"\ngpt-{n}.0-fam-max - fam {n}\ngpt-{n}.0-fam-low - fam {n} low" + for n in range(cd.MAX_FAMILY_IDS + 3) + ) + catalog = "\n".join(PANEL) + ids + body = cd.render_body(analyze(catalog=catalog), catalog) + rows = lab_rows(body, "gpt") + self.assertEqual(len(rows), cd.MAX_FAMILY_IDS + 1) + self.assertIn("… and 3 older families (6 ids)", rows[-1]) + # And what it dropped is the OLDEST, not an arbitrary tail: `gpt-0.0-*` + # through `gpt-2.0-*` are the three lowest versions. + self.assertIn("`gpt-27.0-fam-*`", rows[0]) + self.assertNotIn("gpt-0.0-fam", body[: body.index("
")]) + + def test_a_brand_new_family_printed_last_is_visible_without_the_raw_fold(self): + # The BE-6911 shape in miniature: 40 older tiers printed first, the new + # family printed last. Pre-change it fell inside the hidden remainder. + older = "".join( + f"\ngpt-5.0-old{n}-{tier} - old {n} {tier}" + for n in range(20) + for tier in ("low", "max") + ) + catalog = "\n".join(PANEL) + older + "\ngpt-9.9-brandnew-max - Brand New Max" + body = cd.render_body(analyze(catalog=catalog), catalog) + rows = lab_rows(body, "gpt") + # 41 candidate ids — pre-change the 25-row cap hid the last 16, this one + # among them. Collapsed and version-ordered it leads the group. + self.assertEqual(sum(len(g["candidates"]) for g in analyze(catalog=catalog)["unpinned"]), 41) + self.assertIn("`gpt-9.9-brandnew-max`", rows[0]) + self.assertNotIn("… and", "\n".join(rows)) + + def test_the_2026_08_10_catalog_surfaces_both_new_families_in_full(self): + # The acceptance case, against the catalog captured verbatim in #144. + catalog = real_catalog() + report = cd.analyze( + REAL_PANEL, REAL_JUDGE, catalog, datetime.date(2026, 7, 28), datetime.date(2026, 8, 10), 30 + ) + # The finding itself is unchanged — 178 unpinned same-lab ids, still not + # urgent (no pin delisted, none marked NO-ZDR). Only the rendering moved. + self.assertEqual(sum(len(g["candidates"]) for g in report["unpinned"]), 178) + self.assertFalse(report["urgent"]) + self.assertIn("178 unpinned same-lab ids", cd.summary_line(report)) + + body = cd.render_body(report, catalog) + rendered = body[: body.index("
")] + rows = lab_rows(body, "gpt") + # 82 gpt ids collapse to 11 family rows — inside the cap, so no + # "… and N more" line, so nothing is hidden behind the raw fold. + self.assertEqual(len(rows), 11, rows) + self.assertLessEqual(len(rows), cd.MAX_FAMILY_IDS) + self.assertNotIn("… and", "\n".join(rows)) + # Both new families are visible, and above the older ones the pre-change + # report spent its 25 visible rows on. + self.assertIn("`gpt-5.6-terra-*`", rendered) + self.assertIn("`gpt-5.6-luna-*`", rendered) + self.assertLess(rendered.index("gpt-5.6-terra"), rendered.index("gpt-5.3-codex")) + self.assertLess(rendered.index("gpt-5.6-luna"), rendered.index("gpt-5.2-")) + # Every lab group fits, so the whole 178 collapse to well under the cap. + for lab in ("claude", "gemini", "kimi"): + self.assertNotIn("… and", "\n".join(lab_rows(body, lab)), lab) + + def test_worst_case_collapsed_rows_still_leave_room_for_the_raw_fold(self): + # The collapse adds a tier list to every row, so it has to be held to + # roughly the char cost of the id row it replaces — otherwise the report + # sections crowd out the raw catalog fold and reach the blunt clamp. + note = "context " * 60 # ~480 chars, > MAX_NOTE_CHARS + tiers = [t for suffix in cd.TIER_SUFFIXES for t in (suffix, suffix + "-fast")] + ["fast"] + lines = list(PANEL) + for lab in ("gpt", "claude", "gemini", "kimi"): + for n in range(cd.MAX_FAMILY_IDS + 5): + for tier in tiers: + lines.append(f"{lab}-9.{n}-fam-{tier} {note}") + catalog = "\n".join(lines) + "\n" + body = cd.render_body(analyze(catalog=catalog), catalog) + self.assertLessEqual(len(body), cd.MAX_BODY_CHARS) + self.assertNotIn("report truncated", body) + self.assertEqual(body.count("
"), body.count("
")) + self.assertIn("Raw cursor-agent models output", body) + self.assertIn("This issue is sticky", body) + + class MainTest(unittest.TestCase): def _run(self, workflow_text, catalog_text, extra=None): tmp = tempfile.mkdtemp() diff --git a/README.md b/README.md index 375579c..bb2ec0c 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ complete, copy-pasteable caller. | [`stale.yml`](.github/workflows/stale.yml) | Stale-PR sweeper (`actions/stale`) plus a Slack digest of what it touched. PRs inactive for N days are labeled `stale`; still-inactive PRs are closed. The digest header names the source repo so batches from different repos posted to the same channel are unambiguous. Thresholds, messages, exempt labels, and the Slack channel are inputs; the caller owns the schedule + dry-run toggle. The calling job needs `pull-requests: write` and `issues: write`. Optional `SLACK_BOT_TOKEN`. | [stale.md](docs/callers/stale.md) | | [`groom.yml`](.github/workflows/groom.yml) | Scheduled/dispatch org-wide **code-cleanup sweep** (finds only — no commits, no PRs, never merges). A read-only FINDER agent scans a clean default-branch checkout (whole-repo, not a diff) for high-value refactors; an INDEPENDENT VERIFIER agent (fresh session) re-checks each as CONFIRM/DOWNGRADE/REJECT with a stable dedup signature; survivors are deduped against a durable GitHub-issue-state ledger and filed as `groom`-labeled GitHub issues (security-adjacent ones get `groom-security` — investigate, don't auto-implement). Mirrors the cursor-review topology: briefs + ledger live in [`.github/groom/`](.github/groom) as the single source of truth. The finder/verifier/builder agent jobs invoke the Claude CLI directly and mint no GitHub token, so they need nothing beyond `contents: read`; filing runs in a separate job as the bot you configure via `bot_app_id` (Comfy: cloud-code-bot). `dry_run` reports what it would file without opening issues. Runs on a **daily base cron** with a runtime cadence gate: set repo Actions variable `GROOM_INTERVAL_DAYS` (default 7 = weekly) to retune how often a real run happens — weekly → every-3-days → daily — with no workflow-file edit; a tick within the interval no-ops before the finder (`workflow_dispatch` bypasses the interval gate, but the volume gate — when the caller leaves it on — still applies). The calling job must grant `contents: read` + `issues: write` + `pull-requests: read` + `actions: read` — the first three are declared by the `file` / `build_select` jobs (needed even with `bot_app_id` set), and the interval gate needs `actions: read` (reads run history for the last real run); GitHub rejects a shorter grant at startup. Requires `ANTHROPIC_API_KEY` (+ `BOT_APP_PRIVATE_KEY` when `bot_app_id` is set). **Opt-in auto-builder** (`builder: true`, BE-4003): the top `max_prs` (default 5) CONFIRMED, non-security findings become **review-gated PRs** (full CI + cursor-review, **never auto-merged**) instead of issues; a credential-free `build` job emits only a patch artifact and a separate `build_pr` job opens the PR as the bot, preserving the security boundary. The ledger's PR-state (open/merged/closed) stops a built finding being re-proposed. Requires `bot_app_id`. `max_prs` is typed **`string`**, not `number`, so a caller can forward its own `workflow_dispatch` input straight through (`max_prs: ${{ github.event.inputs.max_prs \|\| '1' }}`) and let an operator raise the ceiling for one manual run — no `fromJSON()` cast in the caller, and the parse/clamp (empty → default, non-numeric → 0 PRs + warning, never a failed run) happens once inside the reusable. A build that cannot become a PR (patch over `pr_size_limit`, patch touching CI-privileged paths) **bails** to a `groom` issue so the paid-for work isn't lost — that path lives in `build_pr`, so **`max_findings` does not cap it** and `max_findings: 0` alone does not silence it; set `bail_sink: none` (an operational knob, so `GROOM_CONFIG` can set it with no PR) to file nothing and get a run-log warning + summary line instead. | [groom.md](docs/callers/groom.md) | | [`agents-md-integrity.yml`](.github/workflows/agents-md-integrity.yml) | Enforces the Comfy `AGENTS.md` standard on the caller repo: a top-level `AGENTS.md` must exist and stay under a hard line ceiling (`max_lines`, default 200; warns over `warn_lines`, default 150), a `CLAUDE.md` (if present) must be a thin `@AGENTS.md` shim rather than a divergent copy, no legacy `.cursorrules` (gated `forbid_cursorrules`), every nested monorepo `AGENTS.md` needs a sibling `@AGENTS.md` shim and to be under the ceiling (gated `check_nested`), and `AGENTS.md` should have a CODEOWNERS DRI (`require_codeowners`, warn-only by default). `exclude_paths` (newline-/comma-separated globs, default empty) carves payload subtrees — a repo whose product IS agent instructions, e.g. a plugin marketplace shipping `plugins/**/AGENTS.md` + a real `CLAUDE.md` — out of the nested scan without the all-or-nothing `check_nested: false`; exclusions are applied during the walk (never scanned or line-counted), reported in the log as `EXCLUDED: (matched )`, and a glob that would exclude the ROOT `AGENTS.md`/`CLAUDE.md` — or the whole tree without saying so (`/`, `*`, `*/**`) — is rejected (exit 2). Fails with a non-zero exit + GitHub annotations so it wires in as a required status check. The checker lives in [`.github/agents-md-integrity/`](.github/agents-md-integrity) (pin `workflows_ref` to the same ref as `uses:`); no secrets required. | [agents-md-integrity.md](docs/callers/agents-md-integrity.md) | -| [`cursor-review-catalog-drift.yml`](.github/workflows/cursor-review-catalog-drift.yml) | **Internal to this repo — not `workflow_call`able.** Weekly (Mon 06:17 UTC) + `workflow_dispatch` drift check between the `cursor-review.yml` model pins and Cursor's live `cursor-agent models` catalog. Reads the pins *out of* `cursor-review.yml` (panel heredoc + `judge_model` default) rather than duplicating them, then reports: a **delisted pin** (urgent — consumer PRs are about to fail the review preflight; this also fails the run), a **pin whose catalog line now says NO-ZDR** (also urgent, and the quieter failure — nothing breaks, private review diffs just keep flowing to a model that may retain them), **unpinned same-lab catalog ids** (a review-me list, never an auto-recommendation — "newest highest-reasoning ZDR-eligible" is a human call, and NO-ZDR markers are surfaced verbatim), **catalog ids from families the panel pins nothing from** (a quieter collapsed catch-all, never urgent — a lab is an id's first `-`/`.`-separated token, so a lab the panel *does* pin can only surface there once it rebrands under a new prefix, e.g. OpenAI's `o` series alongside `gpt-*`), and a **`last checked` audit date** older than 30 days (or future-dated). Findings land in one sticky issue (`[cursor-review catalog drift]`, label `cursor-review-catalog-drift`, updated in place, closed automatically on a clean run) with the raw catalog folded in. Least privilege by construction: the job that pipes Cursor's installer into bash holds only `contents: read` and hands its rendered report to a separate `issues: write` job via an artifact. Comparison logic + tests live in [`.github/cursor-review/catalog-drift.py`](.github/cursor-review/catalog-drift.py). Requires `CURSOR_API_KEY`. | — | +| [`cursor-review-catalog-drift.yml`](.github/workflows/cursor-review-catalog-drift.yml) | **Internal to this repo — not `workflow_call`able.** Weekly (Mon 06:17 UTC) + `workflow_dispatch` drift check between the `cursor-review.yml` model pins and Cursor's live `cursor-agent models` catalog. Reads the pins *out of* `cursor-review.yml` (panel heredoc + `judge_model` default) rather than duplicating them, then reports: a **delisted pin** (urgent — consumer PRs are about to fail the review preflight; this also fails the run), a **pin whose catalog line now says NO-ZDR** (also urgent, and the quieter failure — nothing breaks, private review diffs just keep flowing to a model that may retain them), **unpinned same-lab catalog ids** (a review-me list, never an auto-recommendation — "newest highest-reasoning ZDR-eligible" is a human call, and NO-ZDR markers are surfaced verbatim; rendered one row per model *family*, newest first, since ids that differ only by a reasoning/speed tier are not separate candidates), **catalog ids from families the panel pins nothing from** (a quieter collapsed catch-all, never urgent — a lab is an id's first `-`/`.`-separated token, so a lab the panel *does* pin can only surface there once it rebrands under a new prefix, e.g. OpenAI's `o` series alongside `gpt-*`), and a **`last checked` audit date** older than 30 days (or future-dated). Findings land in one sticky issue (`[cursor-review catalog drift]`, label `cursor-review-catalog-drift`, updated in place, closed automatically on a clean run) with the raw catalog folded in. Least privilege by construction: the job that pipes Cursor's installer into bash holds only `contents: read` and hands its rendered report to a separate `issues: write` job via an artifact. Comparison logic + tests live in [`.github/cursor-review/catalog-drift.py`](.github/cursor-review/catalog-drift.py). Requires `CURSOR_API_KEY`. | — | | [`refresh-reviewers.yml`](.github/workflows/refresh-reviewers.yml) | Companion to `assign-reviewers.yml` — a scheduled drift-detector that recomputes the caller's `.github/reviewers.yml` from git history (recency-decayed commit touches per rule bucket, same glob semantics as the assigner, collaborators only, bots and generated/churn paths excluded) and opens ONE idempotent single-file PR when the committed map drifts. The rewrite is surgical (only the `reviewers:`/`default_pool:` lists change — comments preserved), the PR body carries per-rule before/after scores plus a report-only taxonomy-gap section, and a rule with too few qualifiers is left unchanged. Never a live mutator. Engine + knob rationale in [`.github/refresh-reviewers/`](.github/refresh-reviewers). Requires `vars.APP_ID` + `CLOUD_CODE_BOT_PRIVATE_KEY`, and `workflows_ref` (required) must pin the generator to the same SHA as `uses:`. | — | Per-workflow inputs and secrets are also documented in each workflow file's