diff --git a/plugins/source-control/.claude-plugin/plugin.json b/plugins/source-control/.claude-plugin/plugin.json index c25161492..d13b24bad 100644 --- a/plugins/source-control/.claude-plugin/plugin.json +++ b/plugins/source-control/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "source-control", - "version": "0.53.16", + "version": "0.53.17", "description": "Git and GitHub delivery workflow: /commit (Conventional Commits + Co-authored-by trailer via safe heredoc mechanics), /pull-request (prep, create, CI monitoring, review-comment triage, merge, CI-log fetch), /babysit-prs (self-pacing fleet loop \u2014 safe by default; opt-in worker/autopilot tiers add gate-checked merge and thread resolution behind a deterministic Python engine), /babysit-loop (the loop-lane merge lane: a standing or drain loop that invokes babysit-prs per cycle, configured through repo-scoped babysit_loop_* keys on the layered source-control.md seam, with merge authority human-only until the target repo's tracked config adopts the lane, a gate-proven C2-mechanical baseline once adopted, and standing merge-rung raises binding from the team-tracked layer only \u2014 with one named exception, where an invocation line explicitly typing both the autopilot tier keyword and the dedicated raise argument --merge c3-this-run widens that single invocation's merge authority up to C3 behind a fresh independent frontier-tier resolver, while C4-structural and C5-untrusted-provenance stay unconditionally human-merge), /worktree (create, status, cleanup, audit for parallel-session isolation), /setup (check the effective commit-subject / PR-title convention merged across its config layers and the babysit-prs config, or apply \u2014 interview the repo and write the convention config to a chosen layer), and /resolve-conflicts (intent-first merge/rebase conflict resolution with a semantic-conflict sweep \u2014 never --abort). The commit-subject / PR-title convention is configurable via a source-control.md config written by a re-runnable setup skill, layered across a ~/.claude user-global file, the tracked team file, and a gitignored .claude/source-control.local.md personal overlay merged per key; Conventional Commits is the default when no convention is declared.", "author": { "name": "Melodic Software", diff --git a/plugins/source-control/CHANGELOG.md b/plugins/source-control/CHANGELOG.md index 52593352e..f3cd180da 100644 --- a/plugins/source-control/CHANGELOG.md +++ b/plugins/source-control/CHANGELOG.md @@ -3,6 +3,14 @@ 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.53.17] + +### Fixed + +- **Branch refresh and review triggers no longer halt on self-authored human stops (#902).** + `human_stop.external_required` excludes configured self-logins from automation gates while + `human_stop.required` still blocks merge when the maintainer posts under their own login. + ## [0.53.16] ### Changed diff --git a/plugins/source-control/skills/babysit-prs/scripts/babysit_delta.py b/plugins/source-control/skills/babysit-prs/scripts/babysit_delta.py index b7ac6aa08..12f46bf23 100755 --- a/plugins/source-control/skills/babysit-prs/scripts/babysit_delta.py +++ b/plugins/source-control/skills/babysit-prs/scripts/babysit_delta.py @@ -464,12 +464,19 @@ def classify_pr( dispositions = dict(prev.get("feedback_dispositions") or {}) feedback = collect_feedback(pr, inline_comments, dispositions, config.feedback) review_decision = str(pr.get("reviewDecision") or "") + self_logins = normalize_self_logins(config.self_logins) human_changes_requested = any( item.get("kind") == "review" and item.get("state") == "CHANGES_REQUESTED" for item in feedback["human_blocking"] ) + external_human_blocking = [ + item + for item in feedback["human_blocking"] + if not is_self_login(item.get("author"), self_logins) + ] human_stop = { "required": bool(feedback["human_blocking"]), + "external_required": bool(external_human_blocking), "human_changes_requested": human_changes_requested, "human_blocking_count": len(feedback["human_blocking"]), } @@ -581,7 +588,6 @@ def classify_pr( # gate. Filtering it there instead would silently strip the solo maintainer's # ability to human-stop their own PR. Here it only stops re-dispatching a # worker onto the engine's own prior output. - self_logins = normalize_self_logins(config.self_logins) new_blocking_feedback = [ item for item in feedback["blocking"] if item["id"] not in prev_blocking_ids ] diff --git a/plugins/source-control/skills/babysit-prs/scripts/babysit_feedback.py b/plugins/source-control/skills/babysit-prs/scripts/babysit_feedback.py index f95bd15c6..0337bb447 100755 --- a/plugins/source-control/skills/babysit-prs/scripts/babysit_feedback.py +++ b/plugins/source-control/skills/babysit-prs/scripts/babysit_feedback.py @@ -55,6 +55,7 @@ "body_text", "collect_feedback", "fetch_current_human_stop", + "human_stop_blocks_automation", "has_blocking_severity", "has_blocking_text", "human_stop_state", @@ -258,14 +259,27 @@ def human_stop_state( pr: dict[str, Any], inline_comments: list[dict[str, Any]] | None, config: FeedbackConfig = DEFAULT_FEEDBACK_CONFIG, + *, + self_logins: frozenset[str] | None = None, ) -> dict[str, Any]: feedback = collect_feedback(pr, inline_comments, config=config) human_changes_requested = any( item.get("kind") == "review" and item.get("state") == "CHANGES_REQUESTED" for item in feedback["human_blocking"] ) + normalized_self = ( + normalize_self_logins(self_logins) if self_logins is not None else frozenset() + ) + external_blocking = [ + item + for item in feedback["human_blocking"] + if not is_self_login(item.get("author"), normalized_self) + ] return { "required": bool(feedback["human_blocking"]), + # Self-authored classification replies must not block freshness refresh or + # review triggers (#902); merge/triage still consult `required`. + "external_required": bool(external_blocking), "human_changes_requested": human_changes_requested, "human_blocking_count": len(feedback["human_blocking"]), } @@ -276,9 +290,24 @@ def fetch_current_human_stop( number: int, pr: dict[str, Any], config: FeedbackConfig = DEFAULT_FEEDBACK_CONFIG, + *, + self_logins: frozenset[str] | None = None, ) -> dict[str, Any]: hydrated = dict(pr) hydrated["comments"] = fetch_issue_comments(repo, number) rest_hydrate_reviews(hydrated, repo, number) inline_comments = fetch_unresolved_review_comments(repo, number) - return human_stop_state(hydrated, inline_comments, config) + return human_stop_state( + hydrated, inline_comments, config, self_logins=self_logins + ) + + +def human_stop_blocks_automation(human_stop: dict[str, Any]) -> bool: + """Return whether automation (refresh, review trigger) must halt. + + Prefer `external_required` when present (#902): self-authored lane replies + stay in `required` for merge/triage but must not block freshness refresh. + """ + if "external_required" in human_stop: + return bool(human_stop.get("external_required")) + return bool(human_stop.get("required")) diff --git a/plugins/source-control/skills/babysit-prs/scripts/refresh_pr_branch.py b/plugins/source-control/skills/babysit-prs/scripts/refresh_pr_branch.py index e6ebad592..7a5f62cd8 100755 --- a/plugins/source-control/skills/babysit-prs/scripts/refresh_pr_branch.py +++ b/plugins/source-control/skills/babysit-prs/scripts/refresh_pr_branch.py @@ -12,7 +12,7 @@ import babysit_lease as leases from babysit_delta import compute_branch_freshness, head_repository_scope -from babysit_feedback import fetch_current_human_stop +from babysit_feedback import fetch_current_human_stop, human_stop_blocks_automation from babysit_gh import ( find_open_prs_for_head_ref, gh_json, @@ -64,7 +64,7 @@ def validate_current_candidate( raise RuntimeError("PR has a merge conflict; refusing branch refresh") if compute_branch_freshness(current)["state"] != "behind": raise RuntimeError("PR is no longer behind its base branch") - if fetch_current_human_stop(repo, number, current)["required"]: + if human_stop_blocks_automation(fetch_current_human_stop(repo, number, current)): raise RuntimeError("human review stop is active; refusing branch refresh") matching_prs = find_open_prs_for_head_ref(current) if matching_prs != [f"{repo}#{number}"]: @@ -125,7 +125,7 @@ def run_locked( "branch_write_allowed" ): raise RuntimeError("snapshot does not allow writes to this PR head repository") - if json_object(pr_state.get("human_stop")).get("required"): + if human_stop_blocks_automation(json_object(pr_state.get("human_stop"))): raise RuntimeError("snapshot has an active human review stop") uniqueness = json_object(pr_state.get("head_ref_uniqueness")) if not uniqueness.get("checked") or not uniqueness.get("unique"): diff --git a/plugins/source-control/skills/babysit-prs/scripts/request_review.py b/plugins/source-control/skills/babysit-prs/scripts/request_review.py index e6b5941a2..91766df22 100755 --- a/plugins/source-control/skills/babysit-prs/scripts/request_review.py +++ b/plugins/source-control/skills/babysit-prs/scripts/request_review.py @@ -13,7 +13,7 @@ import babysit_lease as leases from babysit_delta import compute_branch_freshness, head_repository_scope -from babysit_feedback import fetch_current_human_stop +from babysit_feedback import fetch_current_human_stop, human_stop_blocks_automation from babysit_gh import ( flatten_paginated_items, gh_json, @@ -135,7 +135,7 @@ def validate_current_candidate( reaction_signals, expected_head_sha, review_trigger ) human_stop = fetch_current_human_stop(repo, number, current) - if human_stop["required"]: + if human_stop_blocks_automation(human_stop): raise RuntimeError( "human review stop is active; refusing to post a review trigger" ) @@ -324,7 +324,7 @@ def run_locked( raise RuntimeError( "snapshot base repository is outside configured owners or archived" ) - if json_object(pr_state.get("human_stop")).get("required"): + if human_stop_blocks_automation(json_object(pr_state.get("human_stop"))): raise RuntimeError("snapshot has an active human review stop") allowed_owners = allowed_owners_from_policy(mutation_policy) diff --git a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_delta.py b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_delta.py index dc1abebc4..f0e19ef5d 100644 --- a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_delta.py +++ b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_delta.py @@ -451,6 +451,7 @@ def test_self_login_item_is_still_classified_human_blocking(self) -> None: for item in result["feedback"]["human_blocking"]] self.assertEqual(authors, ["solo"]) self.assertTrue(result["human_stop"]["required"]) + self.assertFalse(result["human_stop"]["external_required"]) self.assertFalse(result["pr_clean_ready_for_direct_gate"]) def test_other_login_still_fires_under_same_config(self) -> None: