Skip to content

Commit 7397b02

Browse files
committed
feat(loop): rebase — automated rebase-after-sibling-merge for unmergeable bot PRs (issue #96, part 3)
Adds pr-rebase.sh (detects bot PRs that went mergeable=CONFLICTING against base, excluding feedback/comment-fix/ci-fix candidates, with a per-base-commit anti-livelock budget of 2 attempts before escalating to needs-human) and wires it into loop-tick.sh's verdict chain (rebase sits between ci-fix and advance), loop-daemon.sh's transient unit naming, and loop-event.sh's driver-prompt builder (label claude-rebasing, rebase onto base, force-push-with-lease on clean, abort+escalate on conflict). Also threads rebase_prs into loop-census.sh's cadence telemetry so a rebase-only backlog still ticks FAST, and updates pr-loop.md/pr-loop-self.md's STEP 0 docs to match.
1 parent e361c09 commit 7397b02

13 files changed

Lines changed: 758 additions & 99 deletions

.claude/commands/pr-loop.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,19 @@ Prompt to use (the tick logic, with adaptive STEP 0):
2323

2424
> Run one tick of the autonomous PR loop. ALL `gh` interaction (yours and every agent's) MUST run as the bot via `.claude/scripts/bot-gh.sh` — never bare `gh`; only `git` commits/pushes stay as the owner.
2525
>
26-
> STEP 0 — run the tick. Invoke, as a REAL bash tool call, exactly: `bash ${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/loop-tick.sh`. This one script runs census, then notify-poll.sh, merge-ready.sh, pr-feedback.sh, pr-comment-fix.sh, and pr-ci-fix.sh, IN ORDER, with their full output, and ends with exactly one machine-readable verdict line as the LAST line of output: `action=none`, `action=advance issue=N`, `action=feedback pr=N`, `action=comment-fix pr=N`, or `action=ci-fix pr=N`. That verdict line is the SOLE source of truth for what to do next: never hand-count open PRs, planned issues, feedback PRs, comment-fix PRs, or CI-red PRs yourself, and never skip this invocation because the tick "looks quiet" — it must run, and its output must be read, on every single tick with no exceptions.
26+
> STEP 0 — run the tick. Invoke, as a REAL bash tool call, exactly: `bash ${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/loop-tick.sh`. This one script runs census, then notify-poll.sh, merge-ready.sh, pr-feedback.sh, pr-comment-fix.sh, pr-ci-fix.sh, and pr-rebase.sh, IN ORDER, with their full output, and ends with exactly one machine-readable verdict line as the LAST line of output: `action=none`, `action=advance issue=N`, `action=feedback pr=N`, `action=comment-fix pr=N`, `action=ci-fix pr=N`, or `action=rebase pr=N`. That verdict line is the SOLE source of truth for what to do next: never hand-count open PRs, planned issues, feedback PRs, comment-fix PRs, CI-red PRs, or unmergeable PRs yourself, and never skip this invocation because the tick "looks quiet" — it must run, and its output must be read, on every single tick with no exceptions.
2727
>
28-
> CADENCE: the script's `=== 1/6 loop-census.sh ===` section includes a line `cadence=FAST|WATCH|IDLE cron=<expr>` — this is the desired cadence; consume it as-is, do NOT re-derive it from counts. If this cron job's current schedule differs from that `cron=<expr>`, CronDelete this job and CronCreate a durable replacement with this SAME prompt at the desired schedule.
28+
> CADENCE: the script's `=== 1/7 loop-census.sh ===` section includes a line `cadence=FAST|WATCH|IDLE cron=<expr>` — this is the desired cadence; consume it as-is, do NOT re-derive it from counts. If this cron job's current schedule differs from that `cron=<expr>`, CronDelete this job and CronCreate a durable replacement with this SAME prompt at the desired schedule.
2929
>
30-
> Then obey the verdict line (the tick already ran poll/merge/feedback-detection/comment-fix-detection/ci-fix-detection above — do not re-run those scripts). Precedence when more than one is ready: `feedback` > `comment-fix` > `ci-fix` > `advance` (a PR with unaddressed feedback outranks everything; a PR with an unresolved qualifying review-comment thread outranks a CI fix or a fresh advance):
30+
> Then obey the verdict line (the tick already ran poll/merge/feedback-detection/comment-fix-detection/ci-fix-detection/rebase-detection above — do not re-run those scripts). Precedence when more than one is ready: `feedback` > `comment-fix` > `ci-fix` > `rebase` > `advance` (a PR with unaddressed feedback outranks everything; a PR with an unresolved qualifying review-comment thread outranks a CI fix, a rebase, or a fresh advance; a CI fix outranks a rebase or a fresh advance; an unmergeable PR needing a rebase outranks only a fresh advance):
3131
> - `action=feedback pr=N` → address PR N's feedback: run orchestrator → worktree implementer → reviewer lenses (per .claude/gates.json) on the SAME branch, push to update the PR in place, and post the `<!-- claude-addressed -->` marker comment via bot-gh.sh. Do NOT merge.
32-
> - `action=comment-fix pr=N` → address PR N's unresolved review-comment thread(s): label the PR `claude-comment-fixing` via bot-gh.sh first (in-flight guard), then run orchestrator → worktree implementer on the SAME branch (checkout the PR's existing branch, do NOT create a new one) → reviewer lenses (per .claude/gates.json), push to update the PR in place. For each thread you actually addressed, resolve it on GitHub and post a bot comment containing `<!-- claude-comment-addressed:<thread-id>:<attempt> -->` for each (the real thread id and attempt number from the `5/6 pr-comment-fix.sh` section above) so pr-comment-fix.sh's cursor recognizes it as addressed. Do NOT merge, and do NOT force-push.
32+
> - `action=comment-fix pr=N` → address PR N's unresolved review-comment thread(s): label the PR `claude-comment-fixing` via bot-gh.sh first (in-flight guard), then run orchestrator → worktree implementer on the SAME branch (checkout the PR's existing branch, do NOT create a new one) → reviewer lenses (per .claude/gates.json), push to update the PR in place. For each thread you actually addressed, resolve it on GitHub and post a bot comment containing `<!-- claude-comment-addressed:<thread-id>:<attempt> -->` for each (the real thread id and attempt number from the `5/7 pr-comment-fix.sh` section above) so pr-comment-fix.sh's cursor recognizes it as addressed. Do NOT merge, and do NOT force-push.
3333
> - `action=ci-fix pr=N` → fix PR N's failing CI: label the PR `claude-ci-fixing` via bot-gh.sh first (in-flight guard), then run orchestrator → worktree implementer on the SAME branch (checkout the PR's existing branch, do NOT create a new one) → reviewer lenses (per .claude/gates.json) to fix the failure, push to update the PR in place. After pushing, query the PR's current head SHA (`bot-gh.sh pr view N --json headRefOid`) and post a bot comment containing exactly `<!-- claude-ci-addressed:<head-sha> -->` (the real SHA substituted in) so pr-ci-fix.sh's cursor recognizes this head as already addressed. Do NOT merge, and do NOT force-push.
34+
> - `action=rebase pr=N` → rebase PR N onto base: label the PR `claude-rebasing` via bot-gh.sh first (in-flight guard), checkout the PR's EXISTING branch/worktree (do NOT create a new one), `git fetch` then `git rebase origin/<baseBranch>`. On a CLEAN rebase: `git push --force-with-lease`, rerun the adapter's gates, and post a bot comment containing `<!-- claude-rebase-attempted:<base-sha>:<attempt> -->` (the real base SHA and attempt number from the `7/7 pr-rebase.sh` section above) so pr-rebase.sh's cursor recognizes this attempt. On a CONFLICT: `git rebase --abort` (never leave the worktree mid-rebase), label the PR `needs-human`, and post a comment explaining the conflict (also containing the same marker). NEVER merge, and NEVER force-push anything but this bot-owned branch.
3435
> - `action=advance issue=N` → advance issue N through the orchestrator (scope → worktree implementer → gate.sh gates → reviewer lenses → bot PR). One issue in flight at a time. `backlog` issues are owner-unapproved: never pick them, and if you file an issue yourself, label it `backlog` — NEVER `planned` (that label is the owner's formal approval and is assigned by the owner alone; see docs/USAGE.md → "Autonomous loop & the issue queue").
3536
> - `action=none` → reply exactly one line: "No actionable activity." This is the ONLY path to that phrase — never reply it without loop-tick.sh having actually been invoked (and its output read) earlier in this same turn.
3637
>
37-
> Token discipline: only read docs/USAGE.md and .claude/agents/* when the verdict actually requires orchestrating agents (advance/feedback/ci-fix); an `action=none` tick needs only the script's own output. Keep the tick report to a few lines — it is telemetry, not documentation.
38+
> Token discipline: only read docs/USAGE.md and .claude/agents/* when the verdict actually requires orchestrating agents (advance/feedback/ci-fix/rebase); an `action=none` tick needs only the script's own output. Keep the tick report to a few lines — it is telemetry, not documentation.
3839
3940
## 2. Run one tick now
4041
Execute the tick logic above immediately so the loop doesn't wait for the next cron fire. Report what happened (polled items, merges, feedback addressed, issue advanced — or "No actionable activity").

.claude/scripts/loop-ceilings.test.sh

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,11 @@ check() {
4343
# issue #96 -- defaults to empty, i.e. no ci-fix candidates, so every existing
4444
# 3/4-arg call site keeps working unchanged). $6=fake_commentfix (TSV body,
4545
# issue #96 part 2 -- defaults to empty, i.e. no comment-fix candidates, so
46-
# every existing 3/4/5-arg call site keeps working unchanged).
46+
# every existing 3/4/5-arg call site keeps working unchanged). $7=fake_rebase
47+
# (TSV body, issue #96 part 3 -- defaults to empty, i.e. no rebase candidates,
48+
# so every existing 3/4/5/6-arg call site keeps working unchanged).
4749
new_fixture() {
48-
local name="$1" fake_census="$2" fake_feedback="$3" with_gh="${4:-0}" fake_cifix="${5:-}" fake_commentfix="${6:-}"
50+
local name="$1" fake_census="$2" fake_feedback="$3" with_gh="${4:-0}" fake_cifix="${5:-}" fake_commentfix="${6:-}" fake_rebase="${7:-}"
4951
local dir="$work/$name/.claude/scripts"
5052
mkdir -p "$dir" "$work/$name/.claude/state" 2>/dev/null
5153
rm -rf "$work/$name/.claude/state" # loop-tick.sh must mkdir -p it itself
@@ -89,6 +91,12 @@ EOF
8991
cat <<'COMMENTFIX'
9092
$fake_commentfix
9193
COMMENTFIX
94+
EOF
95+
cat > "$dir/pr-rebase.sh" <<EOF
96+
#!/usr/bin/env bash
97+
cat <<'REBASE'
98+
$fake_rebase
99+
REBASE
92100
EOF
93101
chmod +x "$dir"/*.sh
94102

.claude/scripts/loop-census.sh

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515
# already a feedback candidate, not already
1616
# addressed for its current state
1717
# (pr-comment-fix.sh, issue #96 part 2)
18+
# rebase_prs=N bot PRs that went unmergeable (mergeable=
19+
# CONFLICTING) against base, not already a
20+
# feedback/comment-fix/ci-fix candidate, not
21+
# already rebased for the current base commit
22+
# (pr-rebase.sh, issue #96 part 3)
1823
# planned_issues=N open issues labelled `planned` AND one of the
1924
# adapter's module:* labels, one detail line each:
2025
# issue=<n> branch=<feat/issue-n-* or none> title=<title>
@@ -318,6 +323,9 @@ echo "ci_fix_prs=$ci_fix_prs"
318323
comment_fix_prs=$(bash "$script_dir/pr-comment-fix.sh" "$repo" | grep -c . || true)
319324
echo "comment_fix_prs=$comment_fix_prs"
320325

326+
rebase_prs=$(bash "$script_dir/pr-rebase.sh" "$repo" | grep -c . || true)
327+
echo "rebase_prs=$rebase_prs"
328+
321329
# Open `planned` issues carrying any of the adapter's module labels, ascending.
322330
planned=$(gh issue list -R "$repo" --state open --label planned --json number,title,labels \
323331
--jq '.[] | [.number, ([.labels[].name]|join(",")), .title] | @tsv' | sort -n)
@@ -469,7 +477,7 @@ fi
469477
echo "advance_ready=$advance_ready"
470478

471479
# Desired cadence per the loop policy: FAST only when the loop can ACT now.
472-
if [ "$feedback_prs" -ge 1 ] || [ "$comment_fix_prs" -ge 1 ] || [ "$ci_fix_prs" -ge 1 ] || { [ "$open_prs" -eq 0 ] && [ "$planned_count" -ge 1 ]; }; then
480+
if [ "$feedback_prs" -ge 1 ] || [ "$comment_fix_prs" -ge 1 ] || [ "$ci_fix_prs" -ge 1 ] || [ "$rebase_prs" -ge 1 ] || { [ "$open_prs" -eq 0 ] && [ "$planned_count" -ge 1 ]; }; then
473481
echo 'cadence=FAST cron=* * * * *'
474482
elif [ "$open_prs" -ge 1 ]; then
475483
echo 'cadence=WATCH cron=*/5 * * * *'

.claude/scripts/loop-census.test.sh

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,10 @@ cat > "$scripts_dir/pr-comment-fix.sh" <<'EOF'
115115
#!/usr/bin/env bash
116116
exit 0
117117
EOF
118+
cat > "$scripts_dir/pr-rebase.sh" <<'EOF'
119+
#!/usr/bin/env bash
120+
exit 0
121+
EOF
118122

119123
# Fake bot-gh.sh: no network, no real `gh` — dispatches on the subcommand and
120124
# a `--json` marker to canned, fixture-appropriate output.
@@ -209,6 +213,7 @@ check "planned_issues=4 counted" bash -c 'printf "%s\n" "$1" | grep -qx "planned
209213
check "issue=42 branch line shows the origin-prefixed remote-tracking name" bash -c 'printf "%s\n" "$1" | grep -q "^issue=42 branch=origin/feat/issue-42-y"' _ "$out"
210214
check "ci_fix_prs=0 counted (no-op pr-ci-fix.sh stub, issue #96)" bash -c 'printf "%s\n" "$1" | grep -qx "ci_fix_prs=0"' _ "$out"
211215
check "comment_fix_prs=0 counted (no-op pr-comment-fix.sh stub, issue #96 part 2)" bash -c 'printf "%s\n" "$1" | grep -qx "comment_fix_prs=0"' _ "$out"
216+
check "rebase_prs=0 counted (no-op pr-rebase.sh stub, issue #96 part 3)" bash -c 'printf "%s\n" "$1" | grep -qx "rebase_prs=0"' _ "$out"
212217

213218
# ---------------------------------------------------------------------------
214219
# ci_fix_prs (issue #96): loop-census.sh must surface pr-ci-fix.sh's own
@@ -247,6 +252,19 @@ git -C "$fixture" -c [email protected] -c user.name=t commit -q -m "swap in comm
247252
outCommentFix="$(env -u GATES_FILE bash "$scripts_dir/loop-census.sh" "acme/repo")"
248253
check "comment_fix_prs=1 counted when pr-comment-fix.sh reports one candidate" bash -c 'printf "%s\n" "$1" | grep -qx "comment_fix_prs=1"' _ "$outCommentFix"
249254

255+
# ---------------------------------------------------------------------------
256+
# rebase_prs (issue #96 part 3): same wrapping contract as ci_fix_prs/
257+
# comment_fix_prs above, exercised against pr-rebase.sh instead.
258+
# ---------------------------------------------------------------------------
259+
cat > "$scripts_dir/pr-rebase.sh" <<'EOF'
260+
#!/usr/bin/env bash
261+
printf '30\tfeat/issue-30-a\tsha30\tbase30\t1\n'
262+
EOF
263+
git -C "$fixture" add .claude/scripts/pr-rebase.sh
264+
git -C "$fixture" -c [email protected] -c user.name=t commit -q -m "swap in rebase_prs stub (test fixture)"
265+
outRebase="$(env -u GATES_FILE bash "$scripts_dir/loop-census.sh" "acme/repo")"
266+
check "rebase_prs=1 counted when pr-rebase.sh reports one candidate" bash -c 'printf "%s\n" "$1" | grep -qx "rebase_prs=1"' _ "$outRebase"
267+
250268
# ---------------------------------------------------------------------------
251269
# driver_unit_active guard (issue #119 post-review finding #5): loop-census.sh
252270
# must never report advance_ready for an issue whose transient driver unit
@@ -285,6 +303,10 @@ EOF
285303
cat > "$scripts/pr-comment-fix.sh" <<'EOF'
286304
#!/usr/bin/env bash
287305
exit 0
306+
EOF
307+
cat > "$scripts/pr-rebase.sh" <<'EOF'
308+
#!/usr/bin/env bash
309+
exit 0
288310
EOF
289311
cat > "$scripts/bot-gh.sh" <<'EOF'
290312
#!/usr/bin/env bash
@@ -380,7 +402,11 @@ EOF
380402
#!/usr/bin/env bash
381403
exit 0
382404
EOF
383-
chmod +x "$scripts/pr-feedback.sh" "$scripts/pr-ci-fix.sh" "$scripts/pr-comment-fix.sh" "$scripts/cockpit.sh" "$scripts/loop-census.sh"
405+
cat > "$scripts/pr-rebase.sh" <<'EOF'
406+
#!/usr/bin/env bash
407+
exit 0
408+
EOF
409+
chmod +x "$scripts/pr-feedback.sh" "$scripts/pr-ci-fix.sh" "$scripts/pr-comment-fix.sh" "$scripts/pr-rebase.sh" "$scripts/cockpit.sh" "$scripts/loop-census.sh"
384410
git -C "$dir" init -q -b main
385411
git -C "$dir" -c [email protected] -c user.name=t commit -q --allow-empty -m init
386412
}
@@ -628,6 +654,10 @@ cat > "$scriptsStall/pr-comment-fix.sh" <<'EOF'
628654
#!/usr/bin/env bash
629655
exit 0
630656
EOF
657+
cat > "$scriptsStall/pr-rebase.sh" <<'EOF'
658+
#!/usr/bin/env bash
659+
exit 0
660+
EOF
631661
cat > "$scriptsStall/bot-gh.sh" <<'EOF'
632662
#!/usr/bin/env bash
633663
case "$1" in

0 commit comments

Comments
 (0)