diff --git a/.claude/skills/fleet-spawn-workers/SKILL.md b/.claude/skills/fleet-spawn-workers/SKILL.md index a17fbc8a..a5766dcf 100644 --- a/.claude/skills/fleet-spawn-workers/SKILL.md +++ b/.claude/skills/fleet-spawn-workers/SKILL.md @@ -98,6 +98,8 @@ bound.** These rows are the part that does not. | The outside ladder is a shape to copy, not a size to adopt | Anthropic's write-up of its multi-agent research system keeps the sizing rule in the orchestrator. It calls the lack of one a common failure mode, and records fifty subagents spawned for one simple query. | | Its integers do not come with it | The ladder runs from one agent for simple fact-finding, through two to four for a direct comparison, to ten or more for complex research. | | The condition that stops them transferring | **Those were measured on research tasks, and the same post says coding parallelises worse.** No number in that ladder has been measured on this fleet. | -| What bounds YOUR number is in your own playbook | [MANAGER.md](../../../roles/MANAGER.md), *A Manager's fan-out is bounded by one window and by shared runners*. The retired [CONSOLE.md](../../../roles/retired/CONSOLE.md) bounded a fan-out by accounts and by what the Lander could land; that is record, not a live bound. | +| What bounds YOUR number is in your own playbook | For a Manager, [MANAGER.md](../../../roles/MANAGER.md), *Brief at full speed*: usage holds bound it, and the merge queue does not (owner ruling 2026-10-26). | +| **CORRECTED 2026-10-26** | The row above cited *"A Manager's fan-out is bounded by one window and by shared runners"*, a MANAGER.md heading that does not exist. | +| The retired bound | [CONSOLE.md](../../../roles/retired/CONSOLE.md) bounded a fan-out by accounts and by what the Lander could land. That is record, not a live bound. | --- diff --git a/docs/KORUS-BUILD.md b/docs/KORUS-BUILD.md index 286e36ba..b4bf0d99 100644 --- a/docs/KORUS-BUILD.md +++ b/docs/KORUS-BUILD.md @@ -167,7 +167,8 @@ Read each builder's result. Check the branch reached the remote yourself: You decide when to cut a pull request. Default to one per wave: merge the wave's branches onto a fresh branch from origin/main with git merge --no-ff, run the checks once on the combined tree, and open one pull request. Build the batch in a throwaway worktree, never your own. Cut it when -every builder has reported, when it holds five items, or before you close, whichever comes first. +every builder has reported or before you close, whichever comes first. There is no item cap. Cut +without a builder still running about 30 minutes after the rest reported. Give an item its own pull request if it fixes a red main, changes a security control, supersedes an ADR, or must land in order against another open pull request. Send an item that is red or conflicts back to a builder. Never write a conflict resolution yourself. roles/MANAGER.md, "When to cut a pull request", holds diff --git a/docs/roles/manager.card.md b/docs/roles/manager.card.md index daa299f4..09ccf5ec 100644 --- a/docs/roles/manager.card.md +++ b/docs/roles/manager.card.md @@ -15,7 +15,8 @@ pull request (PR), label it `qa` with the Builder's QA line under it, and hand i *When to cut a pull request*, holds the rules. - Default to one PR per wave, merged in a throwaway worktree and checked as one tree. -- Cut it when every Builder has reported, at five items, or before you close. +- Cut it when every Builder has reported, or before you close. No item cap (Owner ruling 2026-10-26). + Cut without a Builder still running about 30 minutes after the rest reported. - Its own PR: a red-`main` fix, a security control, an ADR supersession, an ordering constraint. - An item that is red or conflicts goes back to a Builder. Never write that resolution yourself. @@ -47,6 +48,9 @@ before assigning files. - Do not check the pool before you open a PR. Owner ruling 2026-09-18, which retired the check that told you to. Five Managers all reading "clear" open together, which manufactures the burst the check was meant to prevent. Open when your own work is ready. +- Do not limit briefing by the open PR count, the merge queue's depth, or the Lander's merge rate. + Owner ruling 2026-10-26: brief at full speed and batch; the Lander works the queue down. Usage + holds still bind. `roles/MANAGER.md`, *Brief at full speed*. - Do not assume you are the only Manager. Check other workers' holdings before assigning a file. - Never infer an account roster. Only the Owner assigns it. diff --git a/roles/BUILDER.md b/roles/BUILDER.md index 1774a3ff..2c178c17 100644 --- a/roles/BUILDER.md +++ b/roles/BUILDER.md @@ -46,8 +46,9 @@ a gate, and never mislead a teammate or the owner about what you built. **A team building nothing at all is a failure to raise at once, not a quiet lane.** Say so to the Manager, the seat that can act on it and the seat that opens the pull request. -**Two to four is the most you oversee, not the least you must reach.** Owner ruling 2026-08-28. See -*A claim that outlives the work is a slot nobody can see*, which holds the rest of this rule. +**RETIRED 2026-10-26 by owner ruling: the lane ceiling.** This read *"Two to four is the most you +oversee, not the least you must reach. Owner ruling 2026-08-28."* Work at full speed. *A claim that +outlives the work is a slot nobody can see* says what still stops a start. **This file carries no live state on purpose.** Item numbers, lane assignments, queue tables, pull request numbers and "pick up here" lists belong in a dated episode note. See *The role file holds @@ -109,12 +110,13 @@ Console, and do not wait on one. | Where a question actually lands | **In your report to the Manager, which carries it onto the pull request it opens.** Findings on a pull request outlive the session; findings in mail do not. | | Why you cannot post it yourself | For a NEW branch the pull request does not exist until you have exited. | | The one case where you can | A follow-up brief onto a branch whose pull request is already open. Then the brief names the number, and you comment there as well as reporting. | -| Sections below that still name a Dispatcher or a Console | A record of how the fleet ran, not a live route. **Who holds the START THROTTLE now is OPEN.** | +| Sections below that still name a Dispatcher or a Console | A record of how the fleet ran, not a live route. | +| **CORRECTED 2026-10-26 by owner ruling: the START THROTTLE** | The row above also read *"Who holds the START THROTTLE now is OPEN."* The owner retired the throttle, so no seat holds it. 5d says what still stops a start. | | Expiry | This retires when the owner names a successor seat or restores one of the three. | -Do not assume the throttle moved to the Manager, and do not read a quiet lane as a throttled one on -that assumption. Mail waits for the recipient's next turn. See [COMMON.md](COMMON.md), *Mail is a -mailbox, not a doorbell, and it expires in 72 hours*. +**Do not assume a quiet lane was throttled.** Since 2026-10-26 only the usage controls in 5d stop a +start. Mail waits for the recipient's next turn: [fleet-message-a-peer](../.claude/skills/fleet-message-a-peer/SKILL.md), +*Mail is a mailbox, not a doorbell, and it expires in 72 hours*. ### If the brief does not answer something you must know, do not guess and do not wait @@ -594,8 +596,8 @@ claim id, a pull request number, a count from `claim.ps1 -List`, a timestamp. **Never a self-assessment of effort.** "No -- 1 of 4 moving, 3 blocked on the parser item" is checkable; "Mixed, working hard" is not. -**On a "no": say the number and what you can take.** "I hold 2, I can take 2 more, my lane is " -is actionable; "not enough" is not, to anyone replenishing four lanes. +**On a "no": say the number and what you can take.** "I hold 2, I can start 3 more now, my lane is " +is actionable; "not enough" is not, to anyone replenishing lanes. Reasoning goes in the prose above the table. **A dashboard that has to be read is not a dashboard.** @@ -1270,19 +1272,24 @@ ever fire step 14 on them. Release those claims before you exit, and say you did. The same list is in [COMMON.md](COMMON.md), *What a Builder still releases*, and in 4b; if the three disagree, COMMON governs. -**An item counts against your four while it is being *worked*, not while its claim is held.** -Built-and-awaiting-merge is zero occupancy. Say so, so others count it the same way. +**An item is occupancy while it is being *worked*, not while its claim is held.** +Built-and-awaiting-merge is zero occupancy. Say so, so others count it the same way. This read +*"counts against your four"* until 2026-10-26, when the four went. -**And four is the MOST YOU OVERSEE, not the least you must reach.** Owner ruling 2026-08-28: starts -are throttled by burn, and **that throttle is not yours.** +**RETIRED 2026-10-26 by owner ruling: the four-item ceiling and the throttle on starts.** This read +*"four is the MOST YOU OVERSEE"* and *"starts are throttled by burn"*. An owner ruling of 2026-08-28 +set both. Asked about both, the owner selected *"Retire it"*. -The ruling named the DISPATCHER as its holder. That seat was retired 2026-09-01 and the owner has -not named a successor, so **who holds it is OPEN.** A lane holding four items with one running -because its starts are held is *compliant*, and should report the reason, not a shortfall. +That ruling named the Dispatcher as the throttle's holder. After that seat retired on 2026-09-01, +this file recorded who held it as OPEN. -**So separate the two lanes that both look like "under four".** One is running less because it was -told to. The other could start something and has not. **This trap is about the second.** Reporting -the first as a failure buries the second, which is the one worth finding. +**Work at full speed. Usage controls can still stop a start.** [MANAGER.md](MANAGER.md), *Brief at +full speed*, points to them; section 8 here covers the Workflow gate. A lane held by one is +*compliant*, and reports the control, not a shortfall. + +**So separate the two quiet lanes.** One is running less because a hold told it to. The other could +start something and has not. **This trap is about the second.** Reporting the first as a failure +buries the second, which is the one worth finding. Measured 2026-08-13: a builder concluded two items into an armed pull request. It correctly could not release the claims, and correctly stated both as deliberately held in three places. **Then it diff --git a/roles/LANDER.md b/roles/LANDER.md index 717c18e6..e8af68f9 100644 --- a/roles/LANDER.md +++ b/roles/LANDER.md @@ -1237,6 +1237,9 @@ Coordination can order a queue; it cannot widen one. The most expensive mistake to run parallel producers into a serialised queue and then spend the session ordering the pile-up. It has happened: 13 PRs merged in one drain while the open count still grew from 3 to 6. +**The remedy is batching, never fewer producers** (owner ruling 2026-10-26). Managers brief at full +speed; the second table's *Never ask a Manager to brief less* row says so. + | Fact | Consequence | | --- | --- | | `ci.yml` triggers on `pull_request` and `push: branches: [main]` only | A feature-branch push runs NOTHING of `ci.yml`. Only `branch-leak-scan.yml` fires, and it is small. Unopened branches are nearly free to hold. **CORRECTED 2026-09-23**: this read *"runs NOTHING"*, which ignored that scan. | @@ -1251,6 +1254,7 @@ has happened: 13 PRs merged in one drain while the open count still grew from 3 | Re-measure any cost model | The old docs-only figures (2, 2, 2 and 13 minutes) were measured against an older required set and no longer hold. Re-measure with `max(completedAt) - min(startedAt)` over the REQUIRED contexts only. | | Do this | Batch independent code changes into one PR. Keep at most ONE code PR in flight and hold the rest as pushed branches. Serialise only genuine ordering constraints. | | Whose decisions that row governs | Yours, in the queue. Since 2026-09-23 it does not govern a Manager's opening: a Manager does not count open PRs before it opens one. | +| Never ask a Manager to brief less -- ADDED 2026-10-26 | Owner ruling: Managers brief at full speed, and you work the queue down. Batching is this section's remedy, not fewer briefs. [MANAGER.md](MANAGER.md), *Brief at full speed*. | | Who batches -- ADDED 2026-09-23 | **The Manager, at step 9.** Owner ruling. Before it, this table said to batch and named no seat to do it, while MANAGER.md said to keep independent changes apart. [MANAGER.md](MANAGER.md), *When to cut a pull request*, now holds the rule. | | Do NOT tell Builders "small and independent is the right shape" | That is correct for avoiding CONFLICTS and exactly wrong for a queue rate-limited by PR COUNT. The two pieces of advice look identical at the branch level and diverge only at the PR level. | | Batching trap 1 | `git cherry-pick` does not run pre-commit, so batched commits pass no local gate on creation. Run the ledger and backlog checks by hand, plus the affected tests. | diff --git a/roles/MANAGER.md b/roles/MANAGER.md index a7cc68ab..23d58129 100644 --- a/roles/MANAGER.md +++ b/roles/MANAGER.md @@ -29,6 +29,7 @@ Within the limits of the following rules, you SHOULD ALWAYS BE PROACTIVE IN YOUR | **RETIRED 2026-09-18: the pool check before opening a pull request** | Owner ruling. **Open the pull request when your own work is ready**, then tell the Lander. *Do not check the pool before you open* carries why. | | What that row read | *"Do not create more PRs than the Lander can handle. Find the Lander and communicate with it before creating a PR."* | | You still do not manage the merge queue or the repository | That half of the retired row survives. Leave both to the Lander. | +| **Brief at full speed (owner ruling 2026-10-26)** | The merge queue never limits briefing; usage holds still do. *Brief at full speed* carries the ruling. | | Every brief ends with push, report, exit. | Do not lose work. Subagents die when you do, so unpushed work is destroyed silently. See *Your work has to survive your exit*. | | You do not edit another Manager's worktree, or the primary checkout | *Never Do These* carries it, with what would end it. | | Conflicts between this file and COMMON | **Raise it to the owner,** unless a recorded delegated decision already resolves it. No seat picks a winner. [COMMON.md](COMMON.md), *Where a role playbook and this file disagree, the owner decides*, carries the 2026-08-28 owner ruling verbatim. | @@ -143,6 +144,38 @@ gh pr comment --body "" **A Builder that reports no QA line has not told you the step ran.** `BUILDER.md` 4c permits an empty result and requires it be reported, so "nothing found" and "nothing said" are different answers. +### Brief at full speed + +**Owner ruling 2026-10-26.** Never hold a brief or shrink a wave for the open pull request count, +the merge queue's depth, or the Lander's merge rate. Batch each wave, less the own-pull-request items +in *When to cut a pull request*. The Lander works the queue down. + +The owner quoted *"Don't brief more than the Lander can land"*, then ruled: *"This is NEVER a thing +in our processes. Builders and managers work at full speed. Managers batch work into combined PRs +as much as possible. A merge queue develops. The lander works it down."* + +**The merge queue never limits a start. Usage controls still can, at least these two.** Read each +where it lives; do not act on a paraphrase. [BUILDER.md](BUILDER.md) 5d links here. + +| Control | Where it lives | +| --- | --- | +| A Steward's stop on starting | [STEWARD.md](STEWARD.md), *3a. The worse window sets the STATE; only the 5-hour window carries the STOP*, and *3d. A live owner ruling can suspend the stop half without suspending the warning* | +| The 90 percent gate on a new Workflow | [COMMON.md](COMMON.md), *Prohibitions that bind before any task starts* | + +A high weekly rung with a low 5-hour rung does not stop briefing (3a). The burn throttle, in the +third answer below, was a separate limit, and it is retired. + +Path contention, in section 3, still shapes what you brief. + +**The three questions this ruling raised were answered on 2026-10-26.** The owner selected these +answers in the question dialog, in session. + +| Question | The owner's selection | +| --- | --- | +| Does *"as much as possible"* lift the five-item cut in *When to cut a pull request*? | *"Remove the cap"*. Batch every item the wave holds. The own-pull-request exceptions stay. | +| Does the ruling retire section 3's hold on filing ledger rows you cannot land soon? | *"Confirm retired"*. | +| Does it retire [BUILDER.md](BUILDER.md)'s four-item lane ceiling and its throttle on starts by burn? | *"Retire it"*. Builders work at full speed. The usage controls above still stop a start. | + ### Do not check the pool before you open **Open when your own work is ready.** Do not count open pull requests, do not ask the Lander whether @@ -237,9 +270,10 @@ measurements. | Item | Rule | | --- | --- | | The default | One pull request per wave. A wave is the set of Builders you dispatched together. | -| Cut it at the FIRST of | Every Builder in the wave has reported and its branch is on the remote. Five items are ready. You are about to close this instance. | +| Cut it at the FIRST of | Every Builder in the wave has reported and its branch is on the remote. You are about to close this instance. | +| **CORRECTED 2026-10-26 by owner ruling: no item cap** | That row also read *"Five items are ready."* The owner selected *"Remove the cap"*. Batch every item the wave holds. *Brief at full speed* records the question. | | A Builder still running | About 30 minutes after the rest reported, cut without it. It rides the next batch. | -| Why five | One red item blocks the whole batch until it is dropped. Five bounds that and keeps the diff readable for the Lander. | +| **RETIRED 2026-10-26: why five** | It read *"Five bounds that and keeps the diff readable for the Lander."* One red item still blocks the batch. Steps 4 and 6 drop the conflicts and reds you catch; a hosted-leg red blocks every item. | | Its own pull request, never batched | An item that fixes a red `main` or a broken gate: ship it now. An item that changes a security control. | | Also its own pull request | An item that reverses a recorded posture or supersedes an ADR; [LANDER.md](LANDER.md) 4i requires its own title. An item that must land in order against another open pull request. | | But two items in one wave | Two items that must land in order may share one batch. | @@ -365,11 +399,14 @@ What bound that run was the repository. See, in the constitution: --- -## 2. Never Conflict with the Lander or Clog the Queue +## 2. Never conflict with the Lander + +**CORRECTED 2026-10-26 by owner ruling.** This heading read *"Never Conflict with the Lander or Clog +the Queue"*. The queue half narrowed on 2026-09-18 (*Do not check the pool before you open*) and +went on 2026-10-26 (*Brief at full speed*). -**The queue half of this heading narrowed on 2026-09-18.** You no longer hold a pull request back on -what the queue looks like. See *Do not check the pool before you open*. What survives is granularity, -decided at dispatch in section 2a and at step 9 in *When to cut a pull request*. +What survives is granularity, decided at dispatch in section 2a and at step 9 in *When to cut a pull +request*. **An engine pull request no longer edits the ledger.** The ledger moved to the vault on 2026-09-13 (BACKLOG #1250). The Lander writes each banner there after the merge, from the text in the Builder's @@ -451,6 +488,7 @@ carried here rather than re-derived by this seat. | **SUPERSEDED 2026-09-13** | Banners no longer ride with code: the Lander writes them in the vault after the merge. So those 36 no longer touch the ledger at all. | | So | Collapsing the 12 moves arrivals from about 9.7/hour to 7.7/hour. **The queue still diverges.** Worth doing, and not a queue fix. | | And batching CORRELATES FAILURE | One red check or one conflict blocks every item in the wave, where a bad row today blocks only itself. At heavy oversubscription that is a real trade. | +| **CORRECTED 2026-10-26 by owner ruling** | *"The queue still diverges"* and *"heavy oversubscription"* treat a growing queue as a Manager's problem. *Brief at full speed* says it is not. The failure-correlation trade stands. | | The measurement | Two dispatch waves added 17 pull requests in about 35 minutes. Open non-draft went 35 to 54 in one hour, and **zero** merged in it. **24 of the 54 were DIRTY**, overwhelmingly on the ledger tail. | | What those seven actually were | 13 to 69 lines each, of `docs/BACKLOG.md` only. They could have been one pull request. | | If an item must be its own pull request | The ledger edit is a FINAL COMMIT, ALONE. Section 2 states that rule, under *put your ledger row in its OWN commit, LAST*. | @@ -463,19 +501,29 @@ carried here rather than re-derived by this seat. | The measured case | A Manager ran `gh pr list` against the vault, read **1** open pull request, and sized a ten-subagent dispatch on it. The engine had **38**. | | So | Pass `--repo MEFORORG/MessageFoundry` explicitly on anything you will act on. The vault was renamed to `wshallwshall/MessageFoundry-vault` the same day and the old slug STILL REDIRECTS, so a stale `--repo wshallwshall/MessageFoundry` still answers, and answers about the vault. | | **CORRECTED 2026-09-24: the vault's slug is `MEFORORG/MessageFoundry-vault`** | The row above keeps what it said. `gh repo view --json nameWithOwner` in the vault checkout returns `MEFORORG/MessageFoundry-vault`, and `wshallwshall/MessageFoundry-vault` redirects there too. The advice holds: name the repository with `--repo`. | +| **CORRECTED 2026-10-26 by owner ruling** | *Verify the remote before sizing a wave* and *The measured case* keep what they said. Sizing a wave on an open pull request count is itself retired, on any repository: *Brief at full speed*. The `--repo` lesson stands. | | **EXPIRY** | This holds only while the ledger is one file whose new rows land at one tail, and while conflict resolution is serial and manual. | | How to check the expiry | Re-examine it if `docs/BACKLOG.md` is split, if a merge driver is adopted for it, or if rows stop being appended in number order. | -**Worked example, 2026-09-05, on when to brief NOTHING.** 62 open non-draft pull requests against a -merge rate of 2.2 to 2.7 an hour. 29 opened in one three-hour window, or 9.7 an hour. That is a -utilisation of 3.6 to 5.0. +**RETIRED 2026-10-26 by owner ruling: the worked example on when to brief NOTHING.** It set a +work-in-progress limit keyed to the Lander's merge rate. *Brief at full speed* carries the ruling. -**Above 1.0 a queue does not settle at some depth. It grows without bound.** Median age at merge was -already 15 hours and the oldest open item was two days. **At that point briefing anything new -subtracts from the fleet's throughput.** +It reasoned: *"Above 1.0 a queue does not settle at some depth. It grows without bound."* It ended: +*"At that point briefing anything new subtracts from the fleet's throughput."* -**Why this is a Manager rule and not a Builder one.** A Builder cannot batch its own output: it is -dispatched against one item and its process ends before any pull request exists. +**The 2026-09-05 figures stay as a record, not a rule.** + +| Figure, 2026-09-05 | Value | +| --- | --- | +| Open non-draft pull requests | 62 | +| Merge rate | 2.2 to 2.7 an hour | +| Opened | 29 in one three-hour window, or 9.7 an hour | +| Utilisation | 3.6 to 4.4: opened over merge rate. The retired text gave 3.6 to 5.0, with no instrument for 5.0. | +| Median age at merge | 15 hours | +| Oldest open item | Two days | + +**Why batching is a Manager rule and not a Builder one.** A Builder cannot batch its own output: it +is dispatched against one item and its process ends before any pull request exists. **Granularity is decided at dispatch and at step 9, and nowhere else.** A convention written into a Builder brief binds nobody after the fact, because there is nobody left to bind. @@ -518,12 +566,15 @@ were dirty. Of those trailing by one or none, 0 of 10 were. No misclassification The dirty five were 25 to 28 hours old and the clean ten were under two. The fleet lands a ledger commit about every 35 minutes, so a pull request that waits overnight wakes behind 30 of them. -**So do not cut a ledger row you cannot land soon.** A row landing within about two hours collides -with nothing; the same row a day later collides with everything. If a batch cannot land today, do not -file the rows today. +A row landing within about two hours collides with nothing; the same row a day later collides with +everything. + +**RETIRED 2026-10-26 by owner ruling, confirmed the same day.** This read *"So do not cut a ledger +row you cannot land soon"*. It went on: *"If a batch cannot land today, do not file the rows +today"*. File rows when the work is ready. The owner selected *"Confirm retired"*. -**Nothing enforces this today.** The claim registry covers items. Until it covers paths, this is -yours to do by reading, and a brief that names no paths has skipped it rather than passed it. +**Nothing enforces the path check today.** The claim registry covers items. Until it covers paths, +this is yours to do by reading, and a brief that names no paths has skipped it rather than passed it. **A claim on a whole batch is necessary, and it is not sufficient.** Take the batch-level claim before you dispatch, because a check of per-item claims does not see it. Then, before you cut the diff --git a/tests/test_role_cards.py b/tests/test_role_cards.py index eed0fc8e..882c2508 100644 --- a/tests/test_role_cards.py +++ b/tests/test_role_cards.py @@ -699,22 +699,44 @@ class RetiredRulesDoNotSurviveInACard(unittest.TestCase): to make, and the card supplies an authoritative-sounding reason. `docs/ROLE-CARDS.md` names the mechanism -- a card is injected at SessionStart, so it outranks the document that corrects it. - NARROW ON PURPOSE. This guards one retired rule by name rather than pretending to a general - test, because no registry of retired rules exists to check against. Widen it by adding a row - when the next rule retires, and the pair below keeps it honest meanwhile. + NARROW ON PURPOSE. This guards named retired rules rather than pretending to a general test, + because no registry of retired rules exists to check against. Widen it by adding a row when the + next rule retires. Every row carries its own planted live line, and the positive control below + runs each one, so no pattern can be added without a control. """ - #: (pattern, why it is retired). Matched case-insensitively, line by line. + #: (pattern, why it is retired, a live line it must catch). Matched case-insensitively, line by + #: line. The planted line is the rule as it was last stated live, so the control is real text. RETIRED = [ - ("reviewed", "the `reviewed` merge gate was removed 2026-09-04; an unlabelled PR merges"), + ( + "reviewed", + "the `reviewed` merge gate was removed 2026-09-04; an unlabelled PR merges", + "- **Merge an unlabelled PR.** `reviewed` is the gate, so re-check after any push.", + ), + ( + "lander can land", + "owner ruling 2026-10-26 retired the briefing limit keyed to the Lander's merge rate", + "- **Brief more than the Lander can land.**", + ), + ( + "five items", + "owner ruling 2026-10-26 removed the five-item cut on a batched PR", + "- Cut it when every Builder has reported, at five items, or before you close.", + ), + ( + "most you oversee", + "owner ruling 2026-10-26 retired the Builder's four-item lane ceiling", + "**Two to four is the most you oversee, not the least you must reach.** Owner ruling 2026-08-28.", + ), ] - def _live_hits(self, text: str) -> list[str]: + def _live_hits(self, text: str, patterns: list[str] | None = None) -> list[str]: """Lines naming a retired rule without marking it retired.""" + wanted = [row[0] for row in self.RETIRED] if patterns is None else patterns out = [] for line in text.splitlines(): low = line.lower() - for pattern, _ in self.RETIRED: + for pattern in wanted: if pattern in low and "retired" not in low and "read *\"" not in line: out.append(line.strip()) return out @@ -731,16 +753,20 @@ def test_no_card_asserts_a_retired_rule_as_live(self): "a role card names a retired rule without marking it retired:\n" + "\n".join(f" {name}: {h}" for name, hs in offenders.items() for h in hs) + "\n\nThe reasons: " - + "; ".join(why for _, why in self.RETIRED), + + "; ".join(why for _, why, _ in self.RETIRED), ) def test_the_scan_fires_on_the_shape_it_exists_to_catch(self): - """The positive control. Without it, a pattern that matches nothing passes silently.""" - planted = "- **Merge an unlabelled PR.** `reviewed` is the gate, so re-check after any push." - self.assertTrue( - self._live_hits(planted), - "the scan did not fire on the exact line that shipped to main, so it guards nothing", - ) + """The positive control, per row. Without it, a pattern that matches nothing passes silently. + + Each planted line must fire on ITS OWN pattern, so a row whose pattern has a typo goes red + even when another row's pattern happens to match the same line.""" + for pattern, _, planted in self.RETIRED: + with self.subTest(pattern=pattern): + self.assertTrue( + self._live_hits(planted, [pattern]), + f"the scan for {pattern!r} did not fire on its planted live line, so it guards nothing", + ) def test_the_scan_accepts_a_correctly_retracted_line(self): """The negative arm. A retraction names the rule on purpose and must not be flagged."""