This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow-steward.git
The following commit(s) were added to refs/heads/main by this push:
new b72525c1 refactor(skills): make pr-management-triage Sweep 4
court-based (#547)
b72525c1 is described below
commit b72525c174768b8ef5778b4696be67f7d8830ce7
Author: Jarek Potiuk <[email protected]>
AuthorDate: Thu Jun 25 04:13:41 2026 -0400
refactor(skills): make pr-management-triage Sweep 4 court-based (#547)
ready for maintainer review now tracks whose court the ball is in:
keep the label when the next move is a maintainer's (review, merge,
workflow approval, CI rerun, branch update); strip it — with an audit
marker and the author-facing action in the same pass — only when the
next move is the author's. Maintainer status is committer-team /
write+ permission, not authorAssociation. Rotted branches are handed
back, not auto-closed.
Generated-by: Claude Code (Claude Opus 4.8)
---
skills/pr-management-triage/SKILL.md | 12 +-
skills/pr-management-triage/actions.md | 62 +++---
skills/pr-management-triage/classify-and-act.md | 36 +++-
skills/pr-management-triage/comment-templates.md | 36 +++-
skills/pr-management-triage/rationale.md | 46 +++++
skills/pr-management-triage/session-history.md | 3 +-
skills/pr-management-triage/stale-sweeps.md | 246 +++++++++++++++--------
7 files changed, 319 insertions(+), 122 deletions(-)
diff --git a/skills/pr-management-triage/SKILL.md
b/skills/pr-management-triage/SKILL.md
index d368fddb..28b3b76b 100644
--- a/skills/pr-management-triage/SKILL.md
+++ b/skills/pr-management-triage/SKILL.md
@@ -668,10 +668,14 @@ through every interactive group from Step 3 (or supplied
- convert non-draft PRs with >4 weeks of no activity to draft
- convert workflow-approval PRs with >4 weeks of no activity
to draft
-- on PRs labeled `ready for maintainer review` whose author
- has been silent ≥ 7 days after a maintainer comment,
- strip the label (4a — branch healthy) or propose `close`
- (4b — red CI or merge conflicts). See
+- on PRs labeled `ready for maintainer review` that have gone
+ quiet ≥ 7 days, re-classify live and act by *whose court the
+ ball is in*: keep the label when the next move is a
+ maintainer's (review, merge, workflow approval, CI rerun,
+ branch update); strip it (with an audit marker, plus the
+ author-facing action in the same pass) only when the next
+ move is the author's (conflict, code fix, unresolved threads,
+ readiness confirmation). See
[`stale-sweeps.md#sweep-4--stale-ready-for-review-label`](stale-sweeps.md#sweep-4--stale-ready-for-review-label).
- on PRs holding a pending author-confirmation request
(first leg of row 14c) whose author has been silent ≥ 7
diff --git a/skills/pr-management-triage/actions.md
b/skills/pr-management-triage/actions.md
index ca113910..531aa0b4 100644
--- a/skills/pr-management-triage/actions.md
+++ b/skills/pr-management-triage/actions.md
@@ -759,39 +759,47 @@ decision.
---
-## `strip-ready-label` — remove the ready-for-review label, no comment
+## `strip-ready-label` — remove the ready-for-review label + audit marker
-Used by [Sweep 4a — branch healthy → strip
label](stale-sweeps.md#4a--branch-healthy--strip-label).
-One mutation, no comment. (The rotted-branch sibling
-[Sweep 4b](stale-sweeps.md#4b--branch-rotted--propose-close)
-uses `close` instead, not this action.)
+Used by [Sweep 4 Step B](stale-sweeps.md#step-b--court-disposition) for
+**author-court** strips — when the next move to make the PR mergeable is
+the author's (a conflict to rebase, a code / static failure to fix,
+unresolved threads to address, readiness to confirm). A maintainer-court
+stale PR keeps its label and is never passed to this action.
+
+Two coupled steps, **in one pass**: post the author-facing follow-up,
+then remove the label. The strip is **never** silent.
```bash
-# Remove the now-stale ready-for-review label (idempotent —
-# a 422 "Label does not exist on this issue" is benign; log
-# and continue).
+# 1. Post the audit marker — and fold in the author-facing action when
+# it is itself a comment (ping / request-author-confirmation).
+# Template: comment-templates.md#stale-ready-label-strip
+gh pr comment <N> --repo <repo> --body-file <marker>
+# 2. Remove the now-handed-back label (idempotent — a 422 "Label does
+# not exist on this issue" is benign; log and continue).
gh pr edit <N> --repo <repo> --remove-label "ready for maintainer review"
```
The label string is read from
[`<project-config>/pr-management-config.md →
ready_for_maintainer_review_label`](../../projects/_template/pr-management-config.md);
-do not hard-code it. The same `gh` recipe is used by the
-"strip-on-downgrade" hook inside `draft` and `comment`
+do not hard-code it. The same `gh pr edit --remove-label` recipe backs
+the "strip-on-downgrade" hook inside `draft` and `comment`
(`actions.md`
§[draft](#draft--convert-to-draft-and-fold-violations-into-the-pr-body) /
-§[comment](#comment--deliver-violations--stale-review--ping-feedback)),
-but those flows additionally convert to draft / post a
-comment. The `strip-ready-label` action is **only** the
-label-removal step — no other mutation, no comment.
-
-### Why no comment
-
-The PR already carries an unanswered maintainer comment (that
-is the trigger condition; see Sweep 4a). Posting a second
-contributor-facing comment would either duplicate the
-maintainer's existing ask or race the normal-queue re-triage
-that may run on the next sweep. Removing the label silently
-is the most conservative move; the maintainer can re-add the
-label in one click if the strip was unwarranted.
+§[comment](#comment--deliver-violations--stale-review--ping-feedback));
+there the draft-conversion / violation feedback **is** the author-facing
+follow-up, so those flows do not additionally post the
+`stale-ready-label-strip` marker.
+
+### Why an audit marker (no longer silent)
+
+`ready for maintainer review` is a public signal; removing it silently
+reads as an unexplained yank and confuses contributors — a silent strip
+once drew a public *"why was this removed?"* with no trace to answer it.
+The marker records *what* was stripped, the *author-court reason*, and
+the *next move*, so the strip is always auditable and the author knows
+exactly what gets the PR back into the queue. Because the label only
+comes off when the ball is genuinely the author's, the follow-up is
+never a duplicate of an existing maintainer ask — it *is* the ask.
### Failure handling
@@ -806,7 +814,9 @@ label in one click if the strip was unwarranted.
### Order-of-operations
-One step. No comment to sequence against.
+Post the audit marker / folded author action **before** removing the
+label — the same "comment before the state change that hides it" rule
+`draft` and `close` follow.
---
@@ -826,7 +836,7 @@ post it **before** the state change that hides it:
| `flag-suspicious` | post comment → close → label *(per PR in the batch)* |
| `mark-ready` | label only |
| `request-author-confirmation` | post comment only (no label) |
-| `strip-ready-label` | remove-label only (no comment) |
+| `strip-ready-label` | post audit marker (+ folded author action) →
remove-label |
| `rerun` | rerun (no comment) |
| `rebase` | update-branch (no comment) |
| `ping` | post comment |
diff --git a/skills/pr-management-triage/classify-and-act.md
b/skills/pr-management-triage/classify-and-act.md
index 70305da6..855c0370 100644
--- a/skills/pr-management-triage/classify-and-act.md
+++ b/skills/pr-management-triage/classify-and-act.md
@@ -144,7 +144,13 @@ Action verbs are defined in [`actions.md`](actions.md).
NOT strip the label: those classify the regression as
transient (flaky CI, missing base merge, reviewer hasn't
responded) and the label is still informative if the
- follow-up succeeds.
+ follow-up succeeds. This is the same *whose-court-is-the-
+ ball-in* test [Sweep 4](stale-sweeps.md#step-b--court-disposition)
+ applies to *stale* ready PRs — strip when the next move is the
+ author's, keep when it is a maintainer's. What separates this
+ rule (a *fresh* regression: ping and keep, give it a beat) from
+ Sweep 4 (the same thread unaddressed ≥ 7 days: strip and hand
+ back) is staleness, not court.
**Exception — merit-discussion-in-flight.** If
[`merit_discussion_thread_present`](#merit_discussion_thread_present)
@@ -193,6 +199,34 @@ Action verbs are defined in [`actions.md`](actions.md).
Compound predicates referenced from the decision table. Defined
once here so the table rows stay short and unambiguous.
+### Maintainer activity
+
+"Maintainer" — for `last_maintainer_comment_at`, F5a (author-cooldown),
+F5b (maintainer-to-maintainer ping), and the Sweep-4 court
+disposition — means a member of the `committers_team` (see
+[`<project-config>/pr-management-config.md`](../../projects/_template/pr-management-config.md))
+**or** an account with repo permission `write` / `maintain` / `admin`.
+
+It is **not** `authorAssociation ∈ {COLLABORATOR, MEMBER, OWNER}` on its
+own. GitHub returns `COLLABORATOR` for any *triage*- or *read*-role
+collaborator, not just committers, so keying maintainer status off the
+association alone treats a read-only router's comment as maintainer
+activity — the failure that de-queued an approved, mergeable PR in
+[Sweep
4](stale-sweeps.md#maintainer-detection--committer-not-authorassociation).
+`authorAssociation` remains a cheap *first* filter (a `NONE`/`FIRST_TIME*`
+association is never a maintainer), but a `COLLABORATOR` hit that is
+**load-bearing for a strip / close / cooldown** must be confirmed live:
+
+```bash
+gh api repos/<upstream>/collaborators/<login>/permission --jq .permission #
write | maintain | admin
+# or: gh api orgs/<org>/teams/<committers-team-slug>/memberships/<login> --jq
.state # active
+```
+
+The live check is invoked only for the small set of load-bearing
+decisions (a Sweep-4 candidate, an F5a/F5b comment that would suppress a
+ping), not for every PR in the batch, so it adds no per-PR cost to the
+main sweep.
+
### `has_deterministic_signal`
At least one of:
diff --git a/skills/pr-management-triage/comment-templates.md
b/skills/pr-management-triage/comment-templates.md
index ee3638a9..99f6cb29 100644
--- a/skills/pr-management-triage/comment-templates.md
+++ b/skills/pr-management-triage/comment-templates.md
@@ -787,7 +787,12 @@ No label is added — the conversion itself is the signal.
*(`stale-ready-label-close` — close a label-flagged PR after author silence +
bitrot)*
-Used by [Sweep 4b](stale-sweeps.md#4b--branch-rotted--propose-close).
+Retained for a maintainer's **explicit, manual** close of a long-dead
+`ready` PR. [Sweep
4](stale-sweeps.md#persistent-bitrot--hand-back-do-not-close)
+no longer auto-closes on bitrot — it hands the branch back to the author
+(`stale-ready-label-strip`); a PR that then stays abandoned is retired
+by [Sweep 2](stale-sweeps.md#sweep-2--inactive-open-prs) (inactive →
+draft).
```markdown
@<author> This PR has had no author response for <days_since_maintainer> days
since the last maintainer comment, and the branch now has <bitrot_signal>.
Closing to keep the queue clean.
@@ -804,6 +809,35 @@ keyed off `statusCheckRollup.state == FAILURE` and
---
+## Stale ready-label strip
+
+*(`stale-ready-label-strip` — audit marker posted when [Sweep 4 Step
B](stale-sweeps.md#step-b--court-disposition) hands a PR back to its author)*
+
+Posted on **every** `ready for maintainer review` strip — the label is
+never removed silently. It records what was stripped, why (the
+author-court reason), and the next move, so the strip is auditable and
+the author knows exactly what re-qualifies the PR. When the author-court
+action is itself a comment (`ping` / `request-author-confirmation`),
+fold that action into this same comment rather than posting twice.
+
+```markdown
+@<author> I've removed the `ready for maintainer review` label because the
next step here is yours: <strip_reason>. It'll go back into the maintainer
queue automatically once that's done — no need to re-add the label by hand.
Next move: <next_move>. There is no rush.
+
+<ai_attribution_footer>
+```
+
+`<strip_reason>` ∈ {`merge conflicts with <base>`, `failing checks that
+need a code fix`, `unresolved review thread(s) from <reviewers> awaiting
+your reply`, `a maintainer asked you to confirm the PR is ready`}.
+`<next_move>` is the concrete author action (e.g. "rebase onto
+`<base>`", "address the unresolved threads", "fix the failing static
+checks", "reply to confirm readiness"). This template is **not** used
+when the next move is a maintainer's (workflow approval, CI rerun,
+branch update, re-review, or merge of an already-approved PR) — those
+keep the label.
+
+---
+
## Stale workflow approval
*(`stale-workflow-approval` — convert stale WF-approval to draft)*
diff --git a/skills/pr-management-triage/rationale.md
b/skills/pr-management-triage/rationale.md
index 755a2ebe..ce581db8 100644
--- a/skills/pr-management-triage/rationale.md
+++ b/skills/pr-management-triage/rationale.md
@@ -734,3 +734,49 @@ frustrated; do not add to the frustration. Concrete rules:
The full surface area is the templates in
[`classify-and-act.md#reason-template-rules`](classify-and-act.md#reason-template-rules).
Anything beyond that is drift.
+
+---
+
+## Sweep 4 — court-based ready-label strip
+
+`ready for maintainer review` means **the ball is in the maintainers'
+court**. Sweep 4 once read staleness as guilt: a healthy stale PR was
+stripped (4a), a rotted one was closed (4b). That is backwards. A
+healthy, author-silent ready PR — especially one a committer has
+already approved and that is mergeable — is waiting on *us* to review
+or merge; stripping its label de-queues a PR that is ready to land. A
+rotted branch is the author's to rebase; jumping straight to `close`
+throws away recoverable work.
+
+So the disposition is now a single question — *whose move is next?* —
+answered by re-classifying the PR live against the decision table:
+
+- **Maintainer's move** (review, merge, workflow approval, CI rerun,
+ branch update) → keep the label; perform the maintainer-side action
+ where there is one. The PR is exactly where it belongs.
+- **Author's move** (rebase a conflict, fix a code / static failure,
+ address unresolved threads, confirm readiness) → strip the label to
+ hand the PR back, post the author-facing action in the *same* pass,
+ and leave an audit marker.
+
+Two failures this fixes, both seen in the wild:
+
+1. **`COLLABORATOR` ≠ committer.** GitHub returns `COLLABORATOR` for
+ read- and triage-role accounts. Treating that association as
+ "maintainer" let a read-only router's comment satisfy the old
+ "author silent after a maintainer comment" trigger, and an approved,
+ mergeable PR was stripped. Maintainer status is now committer-team
+ membership or `write`+ permission, resolved live for the small set
+ of load-bearing decisions (see
+
[`classify-and-act.md#maintainer-activity`](classify-and-act.md#maintainer-activity)).
+2. **Silent strips.** Removing a public label with no comment reads as
+ an unexplained yank — a stripped PR once drew a public "why was this
+ removed?" with no trace to answer it. Every strip now carries the
+ [`stale-ready-label-strip`](comment-templates.md#stale-ready-label-strip)
+ audit marker naming the author-court reason and the next move.
+
+Handing rotted branches back rather than closing them keeps the close
+decision where it belongs — a maintainer's explicit call, or the
+existing inactivity sweep ([Sweep
2](stale-sweeps.md#sweep-2--inactive-open-prs))
+that retires genuinely abandoned PRs after four weeks. Sweep 4 no
+longer closes anything.
diff --git a/skills/pr-management-triage/session-history.md
b/skills/pr-management-triage/session-history.md
index fdb42219..ce15d8b0 100644
--- a/skills/pr-management-triage/session-history.md
+++ b/skills/pr-management-triage/session-history.md
@@ -127,8 +127,7 @@ Session block template (Markdown):
| `close` (stale-sweep 1b) | N | #... |
| `draft` (stale-sweep 2) | N | #... |
| `draft` (stale-sweep 3) | N | #... |
-| `strip-ready-label` (stale-sweep 4a) | N | #... |
-| `close` (stale-sweep 4b) | N | #... |
+| `strip-ready-label` (stale-sweep 4, author-court) | N | #... |
| `ping` (stale-sweep 5) | N | #... |
| `promote-bot-draft` (Step 0.5) | N | #... |
| `flag-suspicious` | N | #... |
diff --git a/skills/pr-management-triage/stale-sweeps.md
b/skills/pr-management-triage/stale-sweeps.md
index 6f489d80..dfea696f 100644
--- a/skills/pr-management-triage/stale-sweeps.md
+++ b/skills/pr-management-triage/stale-sweeps.md
@@ -186,10 +186,27 @@ Same as Sweep 2 — simple `[A]ll`.
## Sweep 4 — Stale ready-for-review label
-When a PR carries `ready for maintainer review` and the author
-has been silent for ≥ 7 days after a maintainer comment, branch
-health splits the disposition: 4a strips the label; 4b proposes
-`close`.
+`ready for maintainer review` means **the ball is in the maintainers'
+court**. When a PR has carried the label for ≥ 7 days and gone quiet,
+this sweep asks one question — *whose move is next?* — and acts on the
+answer:
+
+- **The next move is a maintainer's** (review, merge, workflow
+ approval, CI rerun, branch update) → the label is **correct**. Leave
+ it on; the PR is exactly where it belongs in the queue.
+- **The next move is the author's** (resolve a conflict, fix a code /
+ static failure, address an unresolved review thread, confirm
+ readiness) → strip the label to hand the PR back, **and** post that
+ author-facing action in the same pass.
+
+The label is **never** stripped silently, and **never** stripped from a
+PR a maintainer simply has not gotten to yet. Author silence alone is
+not a strip trigger; only author silence *on a move that is the
+author's to make* is. (This sweep used to strip every healthy stale PR
+and close every rotted one — the inverse of the court rule: it
+de-queued approved, mergeable PRs because their author had gone quiet,
+and jumped straight to `close` on branches the author could still
+rescue.)
### Why a separate sub-query
@@ -209,102 +226,155 @@ The label name comes from
[`<project-config>/pr-management-config.md →
ready_for_maintainer_review_label`](../../projects/_template/pr-management-config.md)
— do not hard-code the string.
-### Common trigger (4a and 4b)
-
-- The PR carries the `ready for maintainer review` label.
-- The `ready for maintainer review` label was added ≥ 7 days ago
- (`<now> - ready_label_added_at >= 7 days`, where
- `ready_label_added_at` is the most recent
- `LabeledEvent { label.name == "ready for maintainer review" }`
- timestamp from the PR's `timelineItems`).
-- The most recent maintainer comment came **after** the label
- was added (`last_maintainer_comment_at > ready_label_added_at`)
- AND `<now> - last_maintainer_comment_at >= 7 days`.
-- `last_author_activity_at` is null **or**
- `last_author_activity_at <= last_maintainer_comment_at`.
-
-The "maintainer comment after label-add" condition is the
-load-bearing guard against the freshly-promoted misfire: when a
-maintainer just added the `ready for maintainer review` label,
-the queue moves to the maintainers, not the author. A pre-label
-maintainer comment is part of the conversation that *got* the PR
-to ready; counting it as proof of author silence would close PRs
-the moment they get promoted, which is the opposite of what
-`ready for maintainer review` means. This guard mirrors the
-"after the label-add timestamp" pattern row F4 already uses for
-regression detection (see
-[`classify-and-act.md#decision-table`](classify-and-act.md), F4
-row).
-
-The author-activity condition makes this sweep about *author
-silence*, not label age — a "still working on it" reply resets
-the clock.
-
-### Branch-health resolution — re-poll mergeability live (before 4a/4b)
-
-The 4a/4b split turns on whether the branch is *healthy* or *rotted*. **Do not
-read that from the batched `mergeable` / `mergeStateStatus`.** GitHub computes
-mergeability lazily, so a batched search over the `ready` queue returns
-`UNKNOWN` for many PRs and `BLOCKED` for *most* (branch protection withholding
-the merge pending the required approval they do not have yet) — gating the
split
-on the batch value mis-routes clean-but-unapproved stale PRs into 4b (close)
when
-their branch is actually fine. The Sweep-4 candidate set is already small
(stale
-ready PRs concentrate at the back of the queue), so resolve mergeability
**live,
-per candidate**:
+### Entry condition
+
+A PR is a Sweep-4 candidate when **all** hold:
+
+- it carries the `ready for maintainer review` label;
+- the label was added ≥ 7 days ago (`<now> - ready_label_added_at >= 7
+ days`, where `ready_label_added_at` is the most recent
+ `LabeledEvent { label.name == <ready_label> }` timestamp from the
+ PR's `timelineItems`);
+- the PR has been quiet for ≥ 7 days — no commit, issue comment, or
+ review by anyone since `<now> - 7 days`.
+
+The label-age gate is the guard against the freshly-promoted misfire:
+a PR promoted minutes ago has a recent label-add and is not a
+candidate, so the queue is never yanked out from under a just-promoted
+PR. The quiet-for-7-days gate keeps the sweep off PRs that are still
+mid-conversation.
+
+**Candidacy is not a verdict.** It means only "stale enough to
+re-examine"; the disposition comes entirely from the court
+re-classification in Steps A–B. Author silence is no longer a strip
+trigger by itself — a silent PR whose next move is a maintainer's stays
+labelled.
+
+### Maintainer detection — committer, not `authorAssociation`
+
+Every "maintainer" test in this sweep — and the
+`last_maintainer_comment_at` / F5a / F5b signals it shares with
+[`classify-and-act.md`](classify-and-act.md#maintainer-activity) —
+means **a member of the `committers_team`** (see
+[`<project-config>/pr-management-config.md`](../../projects/_template/pr-management-config.md))
+**or** an account with repo permission `write`/`maintain`/`admin` —
+**not** `authorAssociation ∈ {COLLABORATOR, MEMBER, OWNER}` alone.
+GitHub returns `COLLABORATOR` for any triage/read collaborator, so
+keying off it treats a read-only router's comment as maintainer
+activity. Resolve it live for the small Sweep-4 candidate set when the
+batch cannot prove committer status:
```bash
-gh api repos/<upstream>/pulls/<N> --jq '[.mergeable, .mergeable_state]|@tsv'
+gh api repos/<upstream>/collaborators/<login>/permission --jq .permission #
want: write | maintain | admin
+# or team membership:
+gh api orgs/<org>/teams/<committers-team-slug>/memberships/<login> --jq .state
# want: active
```
-Classify the live `(mergeable, mergeable_state)` pair:
-
-- `mergeable == true` and `mergeable_state ∈ {clean, has_hooks, unstable,
behind, blocked}` → **healthy** (`blocked` is a clean branch withheld only on
the missing approval — not bitrot) → route to **4a**.
-- `mergeable == false` **or** `mergeable_state == dirty` → **conflicted** →
route to **4b**.
-- `mergeable == null` / `mergeable_state == unknown` after the live call →
**defer this run** (do not strip, do not close); it settles and re-qualifies
next sweep.
-
-`statusCheckRollup.state == FAILURE` independently routes to **4b** (red CI is
-bitrot regardless of mergeability). This mirrors the live re-poll the
-[`pr-management-quick-merge`](../pr-management-quick-merge/candidate-rules.md#stage-3--live-merge-readiness)
-skill uses for the same reason — observed: a batch mergeability gate misjudged
-~87% of a real `ready` queue.
-
-### 4a — Branch healthy → strip label
-
-**Extra trigger.** The live branch-health resolution above classifies the PR as
-**healthy** (not conflicted, and `statusCheckRollup.state != FAILURE`). A batch
-`mergeStateStatus == BLOCKED` is *healthy* here, not a reason to skip 4a.
+A `read`/`triage` collaborator's comment does not establish
+`last_maintainer_comment_at` and does not count as maintainer activity
+here.
-**Action.** `strip-ready-label`. See
-[`actions.md#strip-ready-label`](actions.md#strip-ready-label--remove-the-ready-for-review-label-no-comment).
+### Step A — re-classify live (whose move is next?)
-**Reason string.** *"Ready-for-review label stale — N days
-since maintainer comment, no author reply, branch healthy —
-strip label only"*.
+Do **not** infer the disposition from label age or author silence —
+re-classify the candidate against the live decision table. Branch state
+is half of that, and the batched `mergeable` / `mergeStateStatus` is
+unreliable here: GitHub computes mergeability lazily, so a batched
+search over the `ready` queue returns `UNKNOWN` for many PRs and
+`BLOCKED` for *most* (branch protection withholding the merge pending
+the required approval they do not have yet). The Sweep-4 candidate set
+is already small (stale ready PRs concentrate at the back of the
+queue), so resolve mergeability **live, per candidate**:
-**Group behaviour.** Simple `[A]ll` — non-destructive, no
-per-PR confirm.
+```bash
+gh api repos/<upstream>/pulls/<N> --jq '[.mergeable, .mergeable_state]|@tsv'
+```
-### 4b — Branch rotted → propose close
+Classify the live `(mergeable, mergeable_state)` pair:
-**Extra trigger.** The live branch-health resolution above classifies the PR as
-**conflicted** (`mergeable == false` / `mergeable_state == dirty`) **or**
-`statusCheckRollup.state == FAILURE`. A batch `mergeStateStatus == BLOCKED` is
-**not** a 4b trigger — that is the healthy-awaiting-approval case (4a).
+- `mergeable == true` and `mergeable_state ∈ {clean, has_hooks, unstable,
behind, blocked}` → **healthy** (`blocked` is a clean branch withheld only on
the missing approval — not bitrot).
+- `mergeable == false` **or** `mergeable_state == dirty` → **conflicted** —
author-court, only the author can rebase.
+- `mergeable == null` / `mergeable_state == unknown` after the live call →
**defer this run** (do not strip); it settles and re-qualifies next sweep.
-**Action.** `close` with the
+`statusCheckRollup.state == FAILURE` is author-court when the failures
+are the PR's own (a code / static fix is the author's move) and
+maintainer-court when they are flaky / systemic (a rerun is ours) — the
+decision table already draws that line (rows 10–13 `rerun` vs 12/12b/17
+`comment`). This mirrors the live re-poll the
+[`pr-management-quick-merge`](../pr-management-quick-merge/candidate-rules.md#stage-3--live-merge-readiness)
+skill uses for the same reason — observed: a batch mergeability gate
+misjudged ~87% of a real `ready` queue.
+
+Then run the PR through the live decision table
+([`classify-and-act.md`](classify-and-act.md)) to get its current
+`(classification, action)`, resolving "maintainer" per the section
+above. Step B reads the court off that result.
+
+### Step B — court disposition
+
+Read the court off the Step-A `(classification, action)`:
+
+| Next move (decision-table action / state) | Court | Sweep-4 action |
+|---|---|---|
+| `approve-workflow` | maintainer | **keep label**; perform the approval |
+| `rerun` — flaky / systemic CI (rows 10/11/13) | maintainer | **keep label**;
perform the rerun |
+| `rebase` — branch behind, mergeable (row 16) | maintainer | **keep label**;
perform the branch update |
+| approved + mergeable (a committer approval present, CI green, not
CONFLICTING) | maintainer (merge) | **keep label** — never strip |
+| `mark-ready` / passing `skip` — green, never reviewed (rows 19/20) |
maintainer (review) | **keep label** |
+| `stale_review` — author already pushed after CHANGES_REQUESTED (row 18) |
maintainer (re-review) | **keep label**; the row-18 `ping` is a reviewer nudge,
not a strip |
+| `mergeable == CONFLICTING` (row 9) | author (rebase) | **strip** + `ping`
(rebase) + audit marker |
+| author-caused CI / static failures (rows 12/12b/17) | author (fix) |
**strip** + post the fix request + audit marker |
+| `ping` — unresolved threads the author has not engaged (row 15) | author |
**strip** + `ping` + audit marker |
+| `request-author-confirmation` (row 14c) | author (confirm) | **strip** +
post the request + audit marker |
+
+A maintainer-court action that *does* something
+(`approve-workflow` / `rerun` / `rebase`) is performed — the PR keeps
+its label and progresses, joining that action's normal group. A
+maintainer-court `mark-ready` / `skip` / re-review simply leaves the PR
+labelled (no mutation): it is correctly in the queue.
+
+### Strip and act in one pass
+
+A strip and its author-facing action are a single unit of work **in
+this pass** — never strip now and defer the ping to a later scan. The
+author must never see the label vanish with no explanation and no
+follow-up.
+
+### Audit marker — every strip
+
+[`strip-ready-label`](actions.md#strip-ready-label--remove-the-ready-for-review-label--audit-marker)
+posts the
+[`stale-ready-label-strip`](comment-templates.md#stale-ready-label-strip)
+audit comment recording *what* was stripped, the *author-court reason*,
+and the *next move*. When the author-facing action is itself a comment
+(`ping` / `request-author-confirmation`), fold it into the **same**
+comment rather than posting twice; a quality-flag `comment`/`draft`
+keeps its own
+[`triage_feedback_channel`](../../projects/_template/pr-management-config.md)
+body and the audit marker accompanies it.
+
+### Persistent bitrot — hand back, do not close
+
+This sweep **hands rotted branches back** to the author (strip + ping);
+it does **not** auto-close them. A PR that stays conflicted / red and
+silent *after* the strip is retired by
+[Sweep 2](#sweep-2--inactive-open-prs) (inactive open PR → draft after
+4 weeks) like any other abandoned PR — there is no separate close timer
+here. (The
[`stale-ready-label-close`](comment-templates.md#stale-ready-label-close)
-comment template; **skip the quality-violations label step**
-(close reason is bitrot, not policy violation). Otherwise
-[`actions.md#close`](actions.md#close--close-with-fold-and-quality-violations-label)
-unchanged.
+template is retained only for a maintainer's explicit, manual close of
+a long-dead ready PR.)
-**Reason string.** *"Ready-for-review label stale — N days
-since maintainer comment, no author reply, branch has
-<bitrot_signal> — close"*. `<bitrot_signal>` ∈ {`failing CI`,
-`merge conflicts`, `failing CI + conflicts`}.
+### Group behaviour
-**Group behaviour.** Per-PR confirm inside the batch
-(inherited from the `close`-group rule, SKILL.md Step 3).
+Author-court strips are a single `[A]ll`-confirmable group — reversible
+and self-documenting (each carries its audit marker). The per-PR
+**reason string must name the author-court trigger** ("merge conflict —
+author must rebase", "unresolved threads, author not engaged", "failing
+static checks — code fix needed", "awaiting author readiness
+confirmation") — never a bare "ready label stale". PRs that
+re-classify to a maintainer-court disposition are not in the strip
+group at all.
---
@@ -437,7 +507,7 @@ The sweeps add no new GraphQL calls beyond what
classification
already fetched — the timestamps (`updated_at`,
`last_triage_comment_at`) come from the per-page batch query.
The one exception is Sweep 4's
-[live mergeability
re-poll](#branch-health-resolution--re-poll-mergeability-live-before-4a4b):
+[live mergeability re-poll](#step-a--re-classify-live-whose-move-is-next):
one `GET /pulls/<N>` REST call per *stale Sweep-4 candidate* (a
small set — single digits in a typical sweep), the unavoidable
cost of getting a trustworthy branch-health read. Beyond that,