Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/source-control/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "source-control",
"version": "0.79.7",
"version": "0.79.8",
"description": "Git and GitHub delivery: /commit (convention-checked subject, Co-authored-by trailer, surgical staging), /pull-request (prep, create, CI monitoring, review triage, merge, CI logs), /babysit-prs (safe-by-default PR fleet loop, opt-in worker and autopilot tiers), /babysit-loop (merge lane; merge is human until the repo adopts it), /worktree, /resolve-conflicts (intent-first), /check, and /setup (layered source-control.md convention config; Conventional Commits by default).",
"author": {
"name": "Melodic Software",
Expand Down
7 changes: 7 additions & 0 deletions plugins/source-control/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
All notable changes to the `source-control` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.

## [0.79.8] - 2026-10-04

### Fixed

- **The babysit merge gate holds a `CLEAN` head that is behind its base ([#5955](https://github.com/melodic-software/claude-code-plugins/issues/5955)).**
Under loose required status checks GitHub reports a behind head `CLEAN`. The gate made no base compare of its own and the snapshot compared only a `BLOCKED` head, so the gate could squash-merge a behind head and drop base commits. When the gate runs on a PR that is otherwise ready (or held only by running checks), it now compares the head against the live base and holds it if it is behind or the compare cannot be read, and reports the result as `baseFreshness`. The check runs at gate time, including when the gate arms `--auto`; an auto-merge already armed is not re-checked if the base moves afterwards, a race that predates this change. The gate makes no compare on a base whose rulesets require up-to-date branches (a strict `required_status_checks` rule that lists at least one check) or a merge queue; classic branch protection is not read. The queue snapshot now compares every `BLOCKED`, `CLEAN`, or `HAS_HOOKS` PR on every cycle and reports a behind `CLEAN` or `HAS_HOOKS` head as `branch_freshness.state == "behind"` so the guarded refresh can clear the hold. For that head it also reads the base's rules, and it reports the head behind only when the read succeeds and shows no merge queue: on a failed read the head stays not behind for that cycle, so a transient failure cannot start a refresh (which disarms auto-merge and reruns CI and the AI reviews) on a queue base. A compare that keeps failing on a loose base holds the PR until a human acts. The review-request candidate skips a head the snapshot reports `behind`, matching the live re-check in `request_review.py`.

## [0.79.7] - 2026-10-04

### Fixed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,23 @@ missing from the branch), yet `mergeStateStatus` reported `BLOCKED`, never `BEHI
only ever matched the literal string `BEHIND` could never open for that PR, a chicken-and-egg an
automated queue cannot break out of on its own.

The snapshot engine closes that gap with one narrow, evidence-based fallback: when
`mergeStateStatus` is `BLOCKED`, it compares the base ref against the head SHA via GitHub's own
`GET /repos/{owner}/{repo}/compare/{basehead}`. If the compare proves outstanding base commits
(`status` in `behind`/`diverged` and `behind_by > 0`), the PR is classified
`branch_freshness.state == "behind"` (`source: "compare_api"`) exactly as if `mergeStateStatus`
had reported `BEHIND` directly. Any other cause of `BLOCKED`, a real merge conflict, a pending
human review, anything else, is untouched: the fallback only ever flips `BLOCKED` to `behind`,
A behind head can also read `CLEAN` or `HAS_HOOKS`, so the snapshot does not trust those states
as proof of freshness on a base that does not enforce it; which base settings produce that is in
the
[loose-base and merge-queue record](#upstream-drift-record-for-the-loose-base-and-merge-queue-decisions).

The snapshot engine closes both gaps with one narrow, evidence-based fallback: when
`mergeStateStatus` is `BLOCKED`, `CLEAN`, or `HAS_HOOKS`, it compares the base ref against the
head SHA via GitHub's own `GET /repos/{owner}/{repo}/compare/{basehead}`. If the compare proves
outstanding base commits (`status` in `behind`/`diverged` and `behind_by > 0`), the PR is
classified `branch_freshness.state == "behind"` (`source: "compare_api"`) exactly as if
`mergeStateStatus` had reported `BEHIND` directly. A behind `CLEAN`/`HAS_HOOKS` head is the one
exception: its base's rules are read too, and it is `behind` only when that read succeeds and
shows no merge queue. A queue base is exempt and stays `not_reported_behind`: the refresh is not
ours to run there. An unreadable rules answer also stays `not_reported_behind` for that cycle: the
refresh disarms auto-merge and its push reruns CI and the AI reviews, which a transient failure
must not start on a queue base. The next snapshot reads the rules again. Any other cause of `BLOCKED`, a real merge conflict, a pending
human review, anything else, is untouched: the fallback only ever flips these states to `behind`,
never invents eligibility the compare API did not prove, and every other invariant below
(conflict check, human-review stop, worker lease, unique head ref, the per-source-SHA refresh
ledger) is still enforced completely independently, on both the stored snapshot and a live
Expand Down Expand Up @@ -66,6 +76,26 @@ observations, reproducible with the single-PR diagnostic below: read `mergeState
refresh guarantee for `baseRefOid`, a GitHub changelog entry naming either, or a diagnostic run
where `BLOCKED` no longer co-occurs with a positive `behind_by`.

### Upstream-drift record for the loose-base and merge-queue decisions

The gate and the snapshot treat a base as enforcing freshness only when its rulesets carry a
strict required-checks rule that lists at least one check, or a merge queue; on any other base
they compare the head against the live base. Only rulesets are read, not classic branch
protection.

- **Pointer**: when deciding which base settings make GitHub report `BEHIND`, merge a behind head,
or guarantee an up-to-date merge, fetch the
[require status checks before merging](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-status-checks-before-merging)
section and
[about merge queues](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-a-merge-queue#about-merge-queues)
live. When reading a base's strict setting from rulesets, fetch the
[rules for a branch](https://docs.github.com/en/rest/repos/rules#get-rules-for-a-branch)
endpoint live.
- **As of**: 2026-10-04
- **Recheck trigger**: either docs section changing what the loose or strict setting or a merge
queue guarantees, or the rules-for-a-branch endpoint renaming or reshaping the field the code
reads for the strict setting.

## Orchestrator-Only Refresh Procedure

Only the orchestrator may refresh a branch:
Expand Down Expand Up @@ -122,7 +152,23 @@ Squash-merging while the head is behind its base can silently drop commits that
base after the PR branched, including the tests that covered them, with CI green throughout.
Treat `branch_freshness.state == "behind"` as a hard stop on the merge path even when GitHub
reports `mergeStateStatus` `CLEAN`/`HAS_HOOKS`: under a non-strict ruleset, GitHub does not itself
refuse a behind-base merge, so CLEAN does **not** imply an up-to-date base.
refuse a behind-base merge, so CLEAN does **not** imply an up-to-date base. The merge gate enforces
this on its own: on a base whose rulesets carry neither a strict required-checks rule that lists
at least one check nor a merge queue, it compares an otherwise-ready head
against the live base when it runs, and holds the head if it is behind or the compare cannot be
read (`baseFreshness` in its output). Only rulesets are read: classic branch protection is not,
so a base that is strict or queue-gated only through classic protection still gets the compare.

The check runs only when the gate runs. Once the gate has armed auto-merge (`--auto`), GitHub
merges the PR when its checks pass without the gate running again, so a base that moves after
arming is not re-checked; that race predates this check. A compare that keeps failing on a loose
base holds the PR until a human acts, because the snapshot does not report the head `behind`
without a compare that proves it, so no refresh clears the hold.

Cost: the gate makes one compare per otherwise-ready PR on a base with no ruleset strict or queue
rule, and none on one with either. The snapshot compares every `BLOCKED`, `CLEAN`, or `HAS_HOOKS`
PR on every cycle, whatever the base, and reads the base's rules once more for each `CLEAN` or
`HAS_HOOKS` head the compare shows behind.

Before any merge:

Expand Down
19 changes: 14 additions & 5 deletions plugins/source-control/skills/babysit-prs/reference/safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -466,7 +466,8 @@ auto-mode safety classifier and blocks the call before the wrapper runs.
--self-logins @me,<self-logins>` (thread list).
- **What the merge gate actually evaluates.** It gates on GitHub's own `mergeStateStatus == CLEAN`
plus explicit cross-checks of its own: branch rules, review decision, unresolved threads, the
check rollup keyed by check type and name, and head match. It reports the exact `blockers` list.
check rollup keyed by check type and name, head match, and base freshness (next bullet). It
reports the exact `blockers` list.
React to those blockers; never bypass the gate. One reading caveat: a `ready: false` immediately
following a `ready: true` on the same expected head is often GitHub's own mergeability recompute
lag, so re-run the read-only check once before treating it as a real block.
Expand All @@ -475,12 +476,20 @@ auto-mode safety classifier and blocks the call before the wrapper runs.
states that this leaves mergeability checks, conflict reporting, and rule enforcement unchanged.
So the gate keeps trusting `CLEAN` for mergeability; what can be up to 12 hours behind the base
is the merge commit `pull_request` CI ran against. Only a strict up-to-date rule (`BEHIND`) proves
the head is current at merge; under a non-strict base the stale-base rule in
[freshness.md](freshness.md) is the guard, and the gate adds no hold of its own. **Claim, basis,
as of, recheck:** that regeneration rule,
the head is current at merge. Under a base whose rulesets carry neither that rule nor a merge
queue, `CLEAN` is not proof of freshness, so when the gate runs on an otherwise-ready PR it
compares the head against the live base and holds it if it is behind or the compare cannot be
read (`baseFreshness` in the output). The compare runs at gate time, including when the gate
arms `--auto`; an auto-merge already armed is not re-checked if the base moves afterwards, a
race that predates this check. The snapshot reports the same head
`branch_freshness.state == "behind"`, and [freshness.md](freshness.md)'s refresh clears the
hold; a compare that keeps failing holds the PR until a human acts. A merge-queue base makes no
compare. Only rulesets are read, not classic branch protection. **Claim, basis, as of, recheck:** that
regeneration rule,
[changes to test merge commit generation](https://github.blog/changelog/2026-02-19-changes-to-test-merge-commit-generation-for-pull-requests),
2026-10-02, and a GitHub changelog entry that changes test-merge regeneration or says it now
affects mergeability.
affects mergeability. The loose-base and merge-queue decisions carry their own record in
[freshness.md](freshness.md#upstream-drift-record-for-the-loose-base-and-merge-queue-decisions).
- **`--self-logins @me,<self-logins>` rides on every merge form too**, read-only and mutating
alike. `@me` resolves to your own `gh` login and the `babysit_self_logins` extras follow it; drop
the trailing `,<self-logins>` when that value is empty. On the merge gate this flag is what
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
normalize_self_logins,
)
from babysit_feedback import collect_feedback, human_stop_from_feedback
from babysit_gh import find_open_prs_for_head_ref
from babysit_gh import compare_shows_behind, find_open_prs_for_head_ref
from babysit_review_trigger import (
DEFAULT_REVIEW_TRIGGER_CONFIG,
ReviewTriggerConfig,
Expand Down Expand Up @@ -233,20 +233,24 @@ def validated_stuck_check_age_seconds(value: float) -> float:
def compute_branch_freshness(pr: dict[str, Any]) -> dict[str, Any]:
"""Classify branch staleness from `mergeStateStatus`, with one fallback.

Pure function: the only I/O this depends on (the BLOCKED-branch compare)
happens once, in `view_pr`, and is read here off `pr["_blocked_base_compare"]`.
This keeps classification network-free and keeps `view_pr` the single choke
point both the snapshot orchestrator and the branch-refresh CLI's
revalidation already call, so a live re-check gets the same enrichment for
free.

Falls back to the compare-confirmed signal only when `mergeStateStatus` is
BLOCKED and the compare proves outstanding base commits (`behind_by > 0`,
`status` in {behind, diverged}). Every other cause of BLOCKED (a real merge
conflict, a pending human review, ...) is untouched by this function -- it
only ever flips BLOCKED to "behind"; conflict, human-stop, lease, unique
head-ref, and the per-source-SHA refresh ledger are all still enforced
independently by the caller.
Pure function: the only I/O this depends on (the base compare, and for a
behind CLEAN/HAS_HOOKS head the merge-queue rules read) happens once, in
`view_pr`, and is read here off `pr["_base_compare"]` and
`pr["_base_merge_queue"]`. This keeps classification network-free and keeps
`view_pr` the single choke point both the snapshot orchestrator and the
branch-refresh CLI's revalidation already call, so a live re-check gets the
same enrichment for free.

Falls back to the compare-confirmed signal only when the compare proves
outstanding base commits (`behind_by > 0`, `status` in {behind, diverged})
and `mergeStateStatus` is BLOCKED, or CLEAN/HAS_HOOKS on a base whose rules
were read and require no merge queue (a queue tests the PR against the
latest base itself; an unread answer leaves the head not behind this cycle).
Every other cause of BLOCKED (a real merge conflict, a pending human review,
...) is untouched by this function -- it only ever flips those states to
"behind"; conflict, human-stop, lease, unique head-ref, and the
per-source-SHA refresh ledger are all still enforced independently by the
caller.
"""
merge_state = str(pr.get("mergeStateStatus") or "").upper()
mergeable = str(pr.get("mergeable") or "").upper()
Expand All @@ -256,15 +260,15 @@ def compute_branch_freshness(pr: dict[str, Any]) -> dict[str, Any]:
return {"state": "behind", "source": "mergeStateStatus"}
if merge_state in {"", "UNKNOWN"}:
return {"state": "unknown", "source": "mergeStateStatus"}
if merge_state == "BLOCKED":
compare = pr.get("_blocked_base_compare")
if (
is_json_object(compare)
and compare.get("status") in {"behind", "diverged"}
and isinstance(compare.get("behind_by"), int)
and compare["behind_by"] > 0
):
return {"state": "behind", "source": "compare_api", "compare": compare}
compare = pr.get("_base_compare")
if compare_shows_behind(compare) and (
merge_state == "BLOCKED"
or (
merge_state in {"CLEAN", "HAS_HOOKS"}
and pr.get("_base_merge_queue") is False
)
):
return {"state": "behind", "source": "compare_api", "compare": compare}
return {"state": "not_reported_behind", "source": "mergeStateStatus"}


Expand Down Expand Up @@ -531,6 +535,7 @@ def classify_pr(
reaction_signals,
bool(human_stop["required"]),
review_trigger_allowed=bool(mutation_policy["review_trigger_allowed"]),
branch_behind=branch_freshness["state"] == "behind",
config=config.review_trigger,
)
foreign_activity = detect_foreign_activity(pr, prev, config)
Expand Down
Loading
Loading