diff --git a/.claude/commands/pr-loop.md b/.claude/commands/pr-loop.md index 311f892..9c0a284 100644 --- a/.claude/commands/pr-loop.md +++ b/.claude/commands/pr-loop.md @@ -23,18 +23,19 @@ Prompt to use (the tick logic, with adaptive STEP 0): > 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. > -> 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. +> 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. > -> CADENCE: the script's `=== 1/6 loop-census.sh ===` section includes a line `cadence=FAST|WATCH|IDLE cron=` — 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=`, CronDelete this job and CronCreate a durable replacement with this SAME prompt at the desired schedule. +> CADENCE: the script's `=== 1/7 loop-census.sh ===` section includes a line `cadence=FAST|WATCH|IDLE cron=` — 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=`, CronDelete this job and CronCreate a durable replacement with this SAME prompt at the desired schedule. > -> 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): +> 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): > - `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 `` marker comment via bot-gh.sh. Do NOT merge. -> - `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 `` 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. +> - `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 `` 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. > - `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 `` (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. +> - `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/`. On a CLEAN rebase: `git push --force-with-lease`, rerun the adapter's gates, and post a bot comment containing `` (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. > - `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"). > - `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. > -> 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. +> 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. ## 2. Run one tick now 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"). diff --git a/.claude/scripts/loop-ceilings.test.sh b/.claude/scripts/loop-ceilings.test.sh index 89218d9..5e543aa 100644 --- a/.claude/scripts/loop-ceilings.test.sh +++ b/.claude/scripts/loop-ceilings.test.sh @@ -43,9 +43,11 @@ check() { # issue #96 -- defaults to empty, i.e. no ci-fix candidates, so every existing # 3/4-arg call site keeps working unchanged). $6=fake_commentfix (TSV body, # issue #96 part 2 -- defaults to empty, i.e. no comment-fix candidates, so -# every existing 3/4/5-arg call site keeps working unchanged). +# every existing 3/4/5-arg call site keeps working unchanged). $7=fake_rebase +# (TSV body, issue #96 part 3 -- defaults to empty, i.e. no rebase candidates, +# so every existing 3/4/5/6-arg call site keeps working unchanged). new_fixture() { - local name="$1" fake_census="$2" fake_feedback="$3" with_gh="${4:-0}" fake_cifix="${5:-}" fake_commentfix="${6:-}" + local name="$1" fake_census="$2" fake_feedback="$3" with_gh="${4:-0}" fake_cifix="${5:-}" fake_commentfix="${6:-}" fake_rebase="${7:-}" local dir="$work/$name/.claude/scripts" mkdir -p "$dir" "$work/$name/.claude/state" 2>/dev/null rm -rf "$work/$name/.claude/state" # loop-tick.sh must mkdir -p it itself @@ -89,6 +91,12 @@ EOF cat <<'COMMENTFIX' $fake_commentfix COMMENTFIX +EOF + cat > "$dir/pr-rebase.sh" < branch= title= @@ -318,6 +323,9 @@ echo "ci_fix_prs=$ci_fix_prs" comment_fix_prs=$(bash "$script_dir/pr-comment-fix.sh" "$repo" | grep -c . || true) echo "comment_fix_prs=$comment_fix_prs" +rebase_prs=$(bash "$script_dir/pr-rebase.sh" "$repo" | grep -c . || true) +echo "rebase_prs=$rebase_prs" + # Open `planned` issues carrying any of the adapter's module labels, ascending. planned=$(gh issue list -R "$repo" --state open --label planned --json number,title,labels \ --jq '.[] | [.number, ([.labels[].name]|join(",")), .title] | @tsv' | sort -n) @@ -469,7 +477,7 @@ fi echo "advance_ready=$advance_ready" # Desired cadence per the loop policy: FAST only when the loop can ACT now. -if [ "$feedback_prs" -ge 1 ] || [ "$comment_fix_prs" -ge 1 ] || [ "$ci_fix_prs" -ge 1 ] || { [ "$open_prs" -eq 0 ] && [ "$planned_count" -ge 1 ]; }; then +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 echo 'cadence=FAST cron=* * * * *' elif [ "$open_prs" -ge 1 ]; then echo 'cadence=WATCH cron=*/5 * * * *' diff --git a/.claude/scripts/loop-census.test.sh b/.claude/scripts/loop-census.test.sh index d70cb81..c973900 100644 --- a/.claude/scripts/loop-census.test.sh +++ b/.claude/scripts/loop-census.test.sh @@ -115,6 +115,10 @@ cat > "$scripts_dir/pr-comment-fix.sh" <<'EOF' #!/usr/bin/env bash exit 0 EOF +cat > "$scripts_dir/pr-rebase.sh" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF # Fake bot-gh.sh: no network, no real `gh` — dispatches on the subcommand and # 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 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" 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" 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" +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" # --------------------------------------------------------------------------- # ci_fix_prs (issue #96): loop-census.sh must surface pr-ci-fix.sh's own @@ -247,6 +252,19 @@ git -C "$fixture" -c user.email=t@e.st -c user.name=t commit -q -m "swap in comm outCommentFix="$(env -u GATES_FILE bash "$scripts_dir/loop-census.sh" "acme/repo")" 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" +# --------------------------------------------------------------------------- +# rebase_prs (issue #96 part 3): same wrapping contract as ci_fix_prs/ +# comment_fix_prs above, exercised against pr-rebase.sh instead. +# --------------------------------------------------------------------------- +cat > "$scripts_dir/pr-rebase.sh" <<'EOF' +#!/usr/bin/env bash +printf '30\tfeat/issue-30-a\tsha30\tbase30\t1\n' +EOF +git -C "$fixture" add .claude/scripts/pr-rebase.sh +git -C "$fixture" -c user.email=t@e.st -c user.name=t commit -q -m "swap in rebase_prs stub (test fixture)" +outRebase="$(env -u GATES_FILE bash "$scripts_dir/loop-census.sh" "acme/repo")" +check "rebase_prs=1 counted when pr-rebase.sh reports one candidate" bash -c 'printf "%s\n" "$1" | grep -qx "rebase_prs=1"' _ "$outRebase" + # --------------------------------------------------------------------------- # driver_unit_active guard (issue #119 post-review finding #5): loop-census.sh # must never report advance_ready for an issue whose transient driver unit @@ -285,6 +303,10 @@ EOF cat > "$scripts/pr-comment-fix.sh" <<'EOF' #!/usr/bin/env bash exit 0 +EOF + cat > "$scripts/pr-rebase.sh" <<'EOF' +#!/usr/bin/env bash +exit 0 EOF cat > "$scripts/bot-gh.sh" <<'EOF' #!/usr/bin/env bash @@ -380,7 +402,11 @@ EOF #!/usr/bin/env bash exit 0 EOF - chmod +x "$scripts/pr-feedback.sh" "$scripts/pr-ci-fix.sh" "$scripts/pr-comment-fix.sh" "$scripts/cockpit.sh" "$scripts/loop-census.sh" + cat > "$scripts/pr-rebase.sh" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF + 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" git -C "$dir" init -q -b main git -C "$dir" -c user.email=t@e.st -c user.name=t commit -q --allow-empty -m init } @@ -628,6 +654,10 @@ cat > "$scriptsStall/pr-comment-fix.sh" <<'EOF' #!/usr/bin/env bash exit 0 EOF +cat > "$scriptsStall/pr-rebase.sh" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF cat > "$scriptsStall/bot-gh.sh" <<'EOF' #!/usr/bin/env bash case "$1" in diff --git a/.claude/scripts/loop-daemon.sh b/.claude/scripts/loop-daemon.sh index 6bb1478..07fa70b 100644 --- a/.claude/scripts/loop-daemon.sh +++ b/.claude/scripts/loop-daemon.sh @@ -21,7 +21,7 @@ # so a driver's own bash children can never be orphaned by a bare SIGTERM. # # RUN LEDGER: one line per driver appended to .claude/state/loop-runs.log: -# pid=<pgid> session=<session_id> verdict=<advance issue=N|feedback pr=N|ci-fix pr=N> ts=<ISO8601> [result=exit|timeout|phantom rc=N] [pr=N] [debris=empty|publishable|half-done [action=deleted|resumable]] [verify=skipped] +# pid=<pgid> session=<session_id> verdict=<advance issue=N|feedback pr=N|ci-fix pr=N|comment-fix pr=N|rebase pr=N> ts=<ISO8601> [result=exit|timeout|phantom rc=N] [pr=N] [debris=empty|publishable|half-done [action=deleted|resumable]] [verify=skipped] # session_id is parsed out of the driver's own --output-format json stdout, # so a hung/dead driver can be inspected later with # `claude --resume <session_id> --fork-session` (safe while it's still @@ -54,8 +54,8 @@ # reboot/sleep, or `wsl --shutdown`. Evidence 2026-07-14/15: five of six # drivers died ledger-less this way. The fix: when `systemd-run` is on PATH, # `run_driver` spawns each driver as its OWN transient `--user` unit -# (`pr-loop-driver-issue<N>`/`pr-loop-driver-pr<N>`/`pr-loop-driver-cifix-pr<N>`, -# derived from the verdict) +# (`pr-loop-driver-issue<N>`/`pr-loop-driver-pr<N>`/`pr-loop-driver-cifix-pr<N>`/ +# `pr-loop-driver-rebase-pr<N>`, derived from the verdict) # via `systemd-run --user --wait --collect --unit=... -p RuntimeMaxSec=<LOOP_DRIVER_TIMEOUT>`. # The driver's real parent becomes the user manager — it lives in ITS OWN # scope, independent of the daemon's cgroup — so a daemon restart/crash kills @@ -280,11 +280,12 @@ classify_debris() { # # Only applies to `advance issue=N` verdicts with a non-timeout/non-spawn-error # rc (124/137/127 pass $3 straight through unchanged: a killed/never-spawned -# driver has no work product to verify yet). `feedback pr=N` and `ci-fix -# pr=N` verdicts are DELIBERATELY excluded too (fall straight into the `*` -# pass-through arm below) — both push commits onto an EXISTING PR branch -# rather than creating a new local `feat/issue-N-*` one, so there is no -# freshly-created branch/worktree for this debris check to classify; the PR +# driver has no work product to verify yet). `feedback pr=N`, `comment-fix +# pr=N`, `ci-fix pr=N`, and `rebase pr=N` verdicts are DELIBERATELY excluded +# too (fall straight into the `*` pass-through arm below) — all four push +# commits onto an EXISTING PR branch rather than creating a new local +# `feat/issue-N-*` one, so there is no freshly-created branch/worktree for +# this debris check to classify; the PR # itself already existed before the driver ran. All GitHub access goes through # bot-gh.sh; a failed/offline/empty query degrades to appending `verify=skipped` # — it NEVER falsely declares `result=phantom`, and NEVER deletes anything, on @@ -374,28 +375,34 @@ verify_and_classify_post_exit() { } # --- transient systemd unit naming (issue #119 pt 1; issue #96 pt ci-fix; -# issue #96 part 2 pt comment-fix) ------------------------------------------- +# issue #96 part 2 pt comment-fix; issue #96 part 3 pt rebase) --------------- # $1=verdict -> "pr-loop-driver-issue<N>" for "advance issue=N", # "pr-loop-driver-pr<N>" for "feedback pr=N", "pr-loop-driver-cifix-pr<N>" -# for "ci-fix pr=N", or "pr-loop-driver-commentfix-pr<N>" for -# "comment-fix pr=N". Used both to SPAWN the unit (run_driver) and, in reverse +# for "ci-fix pr=N", "pr-loop-driver-commentfix-pr<N>" for +# "comment-fix pr=N", or "pr-loop-driver-rebase-pr<N>" for "rebase pr=N". +# Used both to SPAWN the unit (run_driver) and, in reverse # (verdict_from_unit_name below), to recover the verdict from a unit already # running when this daemon process starts up (reattach_orphaned_drivers) — the -# two must stay exact inverses of each other. ci-fix and comment-fix EACH get -# their OWN distinct unit name (neither reuses "pr-loop-driver-pr<N>") so a -# feedback driver, a comment-fix driver, and a ci-fix driver on the SAME PR -# can never collide in naming or reattach — the verdict precedence -# (feedback > comment-fix > ci-fix) makes more than one of these firing for -# the same PR in the SAME tick impossible, but a driver from a PRIOR tick -# could still be finishing up while a LATER tick, after that reaction was -# addressed, dispatches a DIFFERENT kind of driver for the identical PR -# number; distinct unit names keep those spawns/reattaches from ever being -# confused with each other. +# two must stay exact inverses of each other. ci-fix, comment-fix, and rebase +# EACH get their OWN distinct unit name (none reuses "pr-loop-driver-pr<N>") +# so a feedback driver, a comment-fix driver, a ci-fix driver, and a rebase +# driver on the SAME PR can never collide in naming or reattach — the verdict +# precedence (feedback > comment-fix > ci-fix > rebase) makes more than one of +# these firing for the same PR in the SAME tick impossible, but a driver from +# a PRIOR tick could still be finishing up while a LATER tick, after that +# reaction was addressed, dispatches a DIFFERENT kind of driver for the +# identical PR number; distinct unit names keep those spawns/reattaches from +# ever being confused with each other. "pr-loop-driver-rebase-pr<N>" cannot +# collide with the plain "pr-loop-driver-pr<N>" shape either: the literal +# string immediately following the common "pr-loop-driver-" prefix is +# "rebase-pr" vs bare "pr", so neither is ever a prefix of the other, and the +# same holds against the "cifix-pr"/"commentfix-pr" shapes. driver_unit_name() { case "$1" in "advance issue="*) printf 'pr-loop-driver-issue%s' "${1#advance issue=}" ;; "comment-fix pr="*) printf 'pr-loop-driver-commentfix-pr%s' "${1#comment-fix pr=}" ;; "ci-fix pr="*) printf 'pr-loop-driver-cifix-pr%s' "${1#ci-fix pr=}" ;; + "rebase pr="*) printf 'pr-loop-driver-rebase-pr%s' "${1#rebase pr=}" ;; "feedback pr="*) printf 'pr-loop-driver-pr%s' "${1#feedback pr=}" ;; *) printf 'pr-loop-driver-unknown' ;; esac @@ -417,6 +424,7 @@ verdict_from_unit_name() { pr-loop-driver-issue*) printf 'advance issue=%s' "${unit#pr-loop-driver-issue}" ;; pr-loop-driver-cifix-pr*) printf 'ci-fix pr=%s' "${unit#pr-loop-driver-cifix-pr}" ;; pr-loop-driver-commentfix-pr*) printf 'comment-fix pr=%s' "${unit#pr-loop-driver-commentfix-pr}" ;; + pr-loop-driver-rebase-pr*) printf 'rebase pr=%s' "${unit#pr-loop-driver-rebase-pr}" ;; pr-loop-driver-pr*) printf 'feedback pr=%s' "${unit#pr-loop-driver-pr}" ;; *) : ;; esac @@ -685,7 +693,7 @@ run_once() { none|"") : # nothing actionable — no driver spawned ;; - "advance issue="*|"feedback pr="*|"comment-fix pr="*|"ci-fix pr="*) + "advance issue="*|"feedback pr="*|"comment-fix pr="*|"ci-fix pr="*|"rebase pr="*) local model prompt_file model="$(printf '%s\n' "$out" | sed -n 's/^loop-event: model=//p' | tail -1)" model="${model:-${LOOP_MODEL:-sonnet}}" diff --git a/.claude/scripts/loop-daemon.test.sh b/.claude/scripts/loop-daemon.test.sh index 7cc92b4..3de7661 100644 --- a/.claude/scripts/loop-daemon.test.sh +++ b/.claude/scripts/loop-daemon.test.sh @@ -160,12 +160,16 @@ check "verify_and_classify_post_exit: ci-fix verdict passes extra through unchan vp_commentfix="$(verify_and_classify_post_exit 'comment-fix pr=23' 0 'result=exit rc=0')" check "verify_and_classify_post_exit: comment-fix verdict passes extra through unchanged (issue #96 part 2 -- no fresh branch/worktree to classify)" [ "$vp_commentfix" = "result=exit rc=0" ] +vp_rebase="$(verify_and_classify_post_exit 'rebase pr=23' 0 'result=exit rc=0')" +check "verify_and_classify_post_exit: rebase verdict passes extra through unchanged (issue #96 part 3 -- no fresh branch/worktree to classify)" [ "$vp_rebase" = "result=exit rc=0" ] + # --- driver_unit_name / verdict_from_unit_name (issue #119 pt 1; issue #96; -# issue #96 part 2) ----------------------------------------------------------- -# ci-fix and comment-fix EACH get their OWN distinct unit name (neither reuses -# the feedback shape pr-loop-driver-pr<N>) so a feedback driver, a comment-fix -# driver, and a ci-fix driver on the SAME PR number can never collide in -# naming or reattach -- and the reverse mapping must be an exact inverse. +# issue #96 part 2; issue #96 part 3) ----------------------------------------- +# ci-fix, comment-fix, and rebase EACH get their OWN distinct unit name +# (neither reuses the feedback shape pr-loop-driver-pr<N>) so a feedback +# driver, a comment-fix driver, a ci-fix driver, and a rebase driver on the +# SAME PR number can never collide in naming or reattach -- and the reverse +# mapping must be an exact inverse. un_advance="$(driver_unit_name 'advance issue=42')" check "driver_unit_name: advance issue=42 -> pr-loop-driver-issue42" [ "$un_advance" = "pr-loop-driver-issue42" ] un_feedback="$(driver_unit_name 'feedback pr=9')" @@ -176,6 +180,12 @@ check "driver_unit_name: ci-fix and feedback unit names for the SAME PR number n un_commentfix="$(driver_unit_name 'comment-fix pr=9')" check "driver_unit_name: comment-fix pr=9 -> pr-loop-driver-commentfix-pr9 (distinct from feedback's and ci-fix's same PR number)" [ "$un_commentfix" = "pr-loop-driver-commentfix-pr9" ] check "driver_unit_name: comment-fix, ci-fix, and feedback unit names for the SAME PR number never collide" bash -c '[ "$1" != "$2" ] && [ "$1" != "$3" ] && [ "$2" != "$3" ]' _ "$un_commentfix" "$un_cifix" "$un_feedback" +un_rebase="$(driver_unit_name 'rebase pr=9')" +check "driver_unit_name: rebase pr=9 -> pr-loop-driver-rebase-pr9 (distinct from feedback's/ci-fix's/comment-fix's same PR number)" [ "$un_rebase" = "pr-loop-driver-rebase-pr9" ] +check "driver_unit_name: rebase, comment-fix, ci-fix, and feedback unit names for the SAME PR number never collide" bash -c ' + [ "$1" != "$2" ] && [ "$1" != "$3" ] && [ "$1" != "$4" ] && + [ "$2" != "$3" ] && [ "$2" != "$4" ] && [ "$3" != "$4" ] +' _ "$un_rebase" "$un_commentfix" "$un_cifix" "$un_feedback" vun_advance="$(verdict_from_unit_name 'pr-loop-driver-issue42.service')" check "verdict_from_unit_name: pr-loop-driver-issue42.service -> advance issue=42 (exact inverse)" [ "$vun_advance" = "advance issue=42" ] @@ -185,6 +195,8 @@ vun_cifix="$(verdict_from_unit_name 'pr-loop-driver-cifix-pr9.service')" check "verdict_from_unit_name: pr-loop-driver-cifix-pr9.service -> ci-fix pr=9 (exact inverse)" [ "$vun_cifix" = "ci-fix pr=9" ] vun_commentfix="$(verdict_from_unit_name 'pr-loop-driver-commentfix-pr9.service')" check "verdict_from_unit_name: pr-loop-driver-commentfix-pr9.service -> comment-fix pr=9 (exact inverse)" [ "$vun_commentfix" = "comment-fix pr=9" ] +vun_rebase="$(verdict_from_unit_name 'pr-loop-driver-rebase-pr9.service')" +check "verdict_from_unit_name: pr-loop-driver-rebase-pr9.service -> rebase pr=9 (exact inverse)" [ "$vun_rebase" = "rebase pr=9" ] # ============================================================================= # (B) Integration checks: real subprocess, fake loop-event.sh + fake claude. @@ -422,6 +434,38 @@ check "scenario 4c (comment-fix): ledger records verdict=comment-fix pr=14" bash ' _ "$ledger4c" check "scenario 4c: prompt file was cleaned up after the driver ran" [ ! -f "$prompt4c_dir/prompt.txt" ] +# --------------------------------------------------------------------------- +# 4d. action=rebase pr=N (issue #96 part 3): same driver path as +# feedback/ci-fix/comment-fix, a distinct verdict text, and its own +# transient unit name (pr-loop-driver-rebase-pr<N>, not +# pr-loop-driver-pr<N>/-cifix-pr<N>/-commentfix-pr<N>) so it can never +# collide with a feedback/ci-fix/comment-fix driver on the same PR number. +# --------------------------------------------------------------------------- +prompt4d_dir="$work/scenario4d-support" +mkdir -p "$prompt4d_dir" +printf 'Run the REBASE step for PR #21. +' > "$prompt4d_dir/prompt.txt" +dir4d="$(new_fixture scenario4d "#!/usr/bin/env bash +echo 'cadence=FAST cron=* * * * *' +echo 'loop-event: action=rebase pr=21' +echo 'loop-event: model=sonnet' +echo 'loop-event: prompt-file=$prompt4d_dir/prompt.txt' +exit 0")" +fake_bin "$dir4d" setsid '#!/usr/bin/env bash +exec "$@"' +fake_bin "$dir4d" timeout '#!/usr/bin/env bash +shift; shift +exec "$@"' +fake_bin "$dir4d" claude '#!/usr/bin/env bash +echo "{\"session_id\":\"sess-rebase-21\"}" +exit 0' +run_daemon_once "$dir4d" >/dev/null 2>&1 +ledger4d="$dir4d/.claude/state/loop-runs.log" +check "scenario 4d (rebase): ledger records verdict=rebase pr=21" bash -c ' + grep -Eq "^pid=[0-9]+ session=sess-rebase-21 verdict=rebase pr=21 ts=[0-9T:Z-]+ result=exit rc=0$" "$1" +' _ "$ledger4d" +check "scenario 4d: prompt file was cleaned up after the driver ran" [ ! -f "$prompt4d_dir/prompt.txt" ] + # --------------------------------------------------------------------------- # 5. Driver timeout: fake timeout stub exits 124 (as GNU timeout does on a # real kill) without ever invoking claude; ledger must record diff --git a/.claude/scripts/loop-event.sh b/.claude/scripts/loop-event.sh index abefcb4..1642a0e 100644 --- a/.claude/scripts/loop-event.sh +++ b/.claude/scripts/loop-event.sh @@ -25,6 +25,7 @@ # loop-event: action=feedback pr=N # loop-event: action=comment-fix pr=N (issue #96 part 2) # loop-event: action=ci-fix pr=N +# loop-event: action=rebase pr=N (issue #96 part 3) # loop-event: model=<model> # loop-event: prompt-file=<absolute path to a plain-text file holding the # verdict-obeying prompt for the driver session> @@ -88,6 +89,7 @@ case "$verdict" in "action=feedback pr="*) n="${verdict#action=feedback pr=}" ;; "action=comment-fix pr="*) n="${verdict#action=comment-fix pr=}" ;; "action=ci-fix pr="*) n="${verdict#action=ci-fix pr=}" ;; + "action=rebase pr="*) n="${verdict#action=rebase pr=}" ;; *) echo "loop-event: unexpected verdict line: $verdict" >&2 echo "loop-event: action=none" @@ -155,14 +157,22 @@ Drive issue #$n through the orchestrator: scope → worktree implementer → gat ;; action=comment-fix*) prompt="Run the COMMENT-FIX step of the autonomous PR loop for PR #$n (issue #96 part 2). $common -PR #$n has one or more UNRESOLVED, qualifying inline review-comment threads (see the \`5/6 pr-comment-fix.sh\` section above for the exact thread id(s) and next attempt number(s), formatted \`<thread_id>:<attempt>\`). Before starting, label the PR \`claude-comment-fixing\` via bot-gh.sh (create the label with --force if it doesn't exist yet) as an in-flight guard against a second tick double-dispatching this same PR. Address the threads: orchestrator → worktree implementer on the SAME branch (checkout the PR's existing branch, do NOT create a new one) → reviewer lenses per the adapter, push the fix(es) to update the PR in place. Then, for EACH thread you actually addressed (and ONLY those — do not claim one you didn't touch): (1) resolve that review thread on GitHub (\`bot-gh.sh api graphql -f query='mutation{resolveReviewThread(input:{threadId:\"<thread_id>\"}){thread{id}}}'\`), and (2) post ONE bot comment on the PR whose body contains, for every addressed thread, a line \`<!-- claude-comment-addressed:<thread_id>:<attempt> -->\` (substituting the real thread id and the EXACT attempt number pr-comment-fix.sh's output gave you for that thread — this is the cursor pr-comment-fix.sh checks so an already-fixed-but-not-yet-re-reviewed thread isn't re-dispatched every tick, while a genuinely NEW comment reopening the thread still re-triggers). Do NOT merge, and do NOT force-push." +PR #$n has one or more UNRESOLVED, qualifying inline review-comment threads (see the \`5/7 pr-comment-fix.sh\` section above for the exact thread id(s) and next attempt number(s), formatted \`<thread_id>:<attempt>\`). Before starting, label the PR \`claude-comment-fixing\` via bot-gh.sh (create the label with --force if it doesn't exist yet) as an in-flight guard against a second tick double-dispatching this same PR. Address the threads: orchestrator → worktree implementer on the SAME branch (checkout the PR's existing branch, do NOT create a new one) → reviewer lenses per the adapter, push the fix(es) to update the PR in place. Then, for EACH thread you actually addressed (and ONLY those — do not claim one you didn't touch): (1) resolve that review thread on GitHub (\`bot-gh.sh api graphql -f query='mutation{resolveReviewThread(input:{threadId:\"<thread_id>\"}){thread{id}}}'\`), and (2) post ONE bot comment on the PR whose body contains, for every addressed thread, a line \`<!-- claude-comment-addressed:<thread_id>:<attempt> -->\` (substituting the real thread id and the EXACT attempt number pr-comment-fix.sh's output gave you for that thread — this is the cursor pr-comment-fix.sh checks so an already-fixed-but-not-yet-re-reviewed thread isn't re-dispatched every tick, while a genuinely NEW comment reopening the thread still re-triggers). Do NOT merge, and do NOT force-push." action_line="action=comment-fix pr=$n" ;; action=ci-fix*) prompt="Run the CI-FIX step of the autonomous PR loop for PR #$n. $common -PR #$n has a FAILING CI check on its current head (see the \`6/6 pr-ci-fix.sh\` section above for which check(s) and its head SHA). Before starting, label the PR \`claude-ci-fixing\` via bot-gh.sh (create the label with --force if it doesn't exist yet) as an in-flight guard against a second tick double-dispatching this same PR. Address the failure: orchestrator → worktree implementer on the SAME branch (checkout the PR's existing branch, do NOT create a new one) → reviewer lenses per the adapter, push the fix to update the PR in place. After pushing, query the PR's CURRENT head commit SHA (bot-gh.sh pr view $n --json headRefOid) and post a bot comment containing exactly \`<!-- claude-ci-addressed:<that-head-sha> -->\` (substituting the real SHA) — this is the cursor pr-ci-fix.sh checks so an unresolved-but-still-rerunning check isn't re-dispatched every tick, while a genuinely NEW failure on a NEW commit still re-triggers. Do NOT merge, and do NOT force-push." +PR #$n has a FAILING CI check on its current head (see the \`6/7 pr-ci-fix.sh\` section above for which check(s) and its head SHA). Before starting, label the PR \`claude-ci-fixing\` via bot-gh.sh (create the label with --force if it doesn't exist yet) as an in-flight guard against a second tick double-dispatching this same PR. Address the failure: orchestrator → worktree implementer on the SAME branch (checkout the PR's existing branch, do NOT create a new one) → reviewer lenses per the adapter, push the fix to update the PR in place. After pushing, query the PR's CURRENT head commit SHA (bot-gh.sh pr view $n --json headRefOid) and post a bot comment containing exactly \`<!-- claude-ci-addressed:<that-head-sha> -->\` (substituting the real SHA) — this is the cursor pr-ci-fix.sh checks so an unresolved-but-still-rerunning check isn't re-dispatched every tick, while a genuinely NEW failure on a NEW commit still re-triggers. Do NOT merge, and do NOT force-push." action_line="action=ci-fix pr=$n" ;; + action=rebase*) + prompt="Run the REBASE step of the autonomous PR loop for PR #$n (issue #96 part 3). $common +PR #$n has gone unmergeable (GitHub reports mergeable=CONFLICTING against base), typically because a sibling PR merged into base first (see the \`7/7 pr-rebase.sh\` section above for its branch, head/base SHA, and this attempt's number). Before starting, label the PR \`claude-rebasing\` via bot-gh.sh (create the label with --force if it doesn't exist yet) as an in-flight guard against a second tick double-dispatching this same PR. Checkout the PR's EXISTING branch/worktree — do NOT create a new branch and do NOT create a new PR. Run \`git fetch origin\` then \`git rebase origin/<the adapter's merge.baseBranch>\` on that branch. Exactly one of two outcomes follows: +1. CLEAN rebase: force-push it back in place with \`git push --force-with-lease\` (a deliberate, accepted policy exception — this is the bot's own branch), then rerun the adapter's build/lint/test gates (\`GATES_FILE=... bash $script_dir/gate.sh <name>\`, per the adapter clause above) to confirm the rebased branch is still green, then post ONE bot comment on the PR containing exactly \`<!-- claude-rebase-attempted:<base_sha>:<attempt> -->\` (substituting the PR's CURRENT base_sha and the EXACT attempt number pr-rebase.sh's output gave you) — this is the cursor pr-rebase.sh checks so a PR that stays CONFLICTING against the SAME base commit isn't re-dispatched forever, while a NEW sibling merge (a new base_sha) always resets the budget. +2. CONFLICT: run \`git rebase --abort\` immediately — never leave the worktree mid-rebase. Label the PR \`needs-human\` via bot-gh.sh (create the label with --force if it doesn't exist yet, color b60205, mirroring needs-human.sh's seam) and post ONE bot comment explaining the conflict (which files/hunks conflicted) that ALSO contains \`<!-- claude-rebase-attempted:<base_sha>:<attempt> -->\` (same substitution as above), so pr-rebase.sh's cursor still reflects this attempt if a human later clears the label. +NEVER force-merge, and NEVER merge, in either outcome." + action_line="action=rebase pr=$n" + ;; *) prompt="Run the ADDRESS FEEDBACK step of the autonomous PR loop for PR #$n. $common Address the unaddressed CHANGES_REQUESTED feedback on PR #$n: orchestrator → worktree implementer → reviewer lenses 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." diff --git a/.claude/scripts/loop-event.test.sh b/.claude/scripts/loop-event.test.sh index a0dc187..15edf28 100644 --- a/.claude/scripts/loop-event.test.sh +++ b/.claude/scripts/loop-event.test.sh @@ -170,6 +170,34 @@ check "scenario 3c: prompt file mandates FOREGROUND-only spawn (run_in_backgroun check "scenario 3c: prompt file forbids ending the turn before the work product exists on GitHub" bash -c 'grep -q "do NOT end your turn until the work product exists on GitHub" "$1"' _ "$pf3c" check "scenario 3c: prompt file mandates deleting debris on failure" bash -c 'grep -q "delete any local feat/issue-N-\* branch and worktree" "$1"' _ "$pf3c" +# --------------------------------------------------------------------------- +# 3d. action=rebase pr=N (issue #96 part 3) -> same one-shot contract, REBASE +# wording, in-flight-guard/marker-comment/conflict-abort instructions, +# LOOP_MODEL honored. +# --------------------------------------------------------------------------- +dir3d="$(new_fixture scenario3d 'cadence=FAST cron=* * * * * +action=rebase pr=21')" +out3d="$(cd "$dir3d" && PATH="/usr/bin:/bin" LOOP_MODEL=opus bash .claude/scripts/loop-event.sh)"; rc3d=$? +check "scenario 3d (rebase): exits 0" [ "$rc3d" -eq 0 ] +check "scenario 3d: emits loop-event: action=rebase pr=21" bash -c 'printf "%s +" "$1" | grep -qxF "loop-event: action=rebase pr=21"' _ "$out3d" +check "scenario 3d: LOOP_MODEL is honored (model=opus)" bash -c 'printf "%s +" "$1" | grep -qxF "loop-event: model=opus"' _ "$out3d" +pf3d="$(printf '%s +' "$out3d" | sed -n 's/^loop-event: prompt-file=//p')" +check "scenario 3d: prompt file mentions PR #21" bash -c 'grep -q "PR #21" "$1"' _ "$pf3d" +check "scenario 3d: prompt file says REBASE step, and NEVER merge" bash -c 'grep -q "REBASE step" "$1" && grep -qi "NEVER merge" "$1"' _ "$pf3d" +check "scenario 3d: prompt file mandates the claude-rebasing in-flight guard label" bash -c 'grep -q "claude-rebasing" "$1"' _ "$pf3d" +check "scenario 3d: prompt file mandates the claude-rebase-attempted marker comment" bash -c 'grep -q "claude-rebase-attempted" "$1"' _ "$pf3d" +check "scenario 3d: prompt file mandates force-push-with-lease on a clean rebase" bash -c 'grep -q -- "--force-with-lease" "$1"' _ "$pf3d" +check "scenario 3d: prompt file mandates aborting the rebase on conflict" bash -c 'grep -q -- "rebase --abort" "$1"' _ "$pf3d" +check "scenario 3d: prompt file mandates the needs-human label on conflict" bash -c 'grep -q "needs-human" "$1"' _ "$pf3d" + +# Same one-shot contract clauses, on the REBASE prompt this time. +check "scenario 3d: prompt file mandates FOREGROUND-only spawn (run_in_background: false)" bash -c 'grep -qi "FOREGROUND" "$1" && grep -q "run_in_background: false" "$1"' _ "$pf3d" +check "scenario 3d: prompt file forbids ending the turn before the work product exists on GitHub" bash -c 'grep -q "do NOT end your turn until the work product exists on GitHub" "$1"' _ "$pf3d" +check "scenario 3d: prompt file mandates deleting debris on failure" bash -c 'grep -q "delete any local feat/issue-N-\* branch and worktree" "$1"' _ "$pf3d" + # --------------------------------------------------------------------------- # 4. Garbage verdict line -> non-zero exit, action=none fallback line, no # prompt-file (never spawns on a verdict it can't parse). diff --git a/.claude/scripts/loop-tick.sh b/.claude/scripts/loop-tick.sh index 30dad60..6cee424 100644 --- a/.claude/scripts/loop-tick.sh +++ b/.claude/scripts/loop-tick.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # loop-tick.sh — one-shot orchestration tick for the autonomous PR loop. # -# Runs the loop's six step scripts, IN ORDER, with their FULL output +# Runs the loop's seven step scripts, IN ORDER, with their FULL output # preserved (never swallowed or `tail -1`'d), then emits exactly one # machine-readable verdict line as the LAST line of output: # action=none @@ -9,6 +9,7 @@ # action=feedback pr=N # action=comment-fix pr=N (issue #96 part 2 -- see PRECEDENCE below) # action=ci-fix pr=N +# action=rebase pr=N (issue #96 part 3 -- see PRECEDENCE below) # action=resume issue=N branch=<name> (issue #98 -- see STEP 0.5 below) # # WHY THIS EXISTS (issue #81): the tick used to be a multi-step PROMPT @@ -27,35 +28,50 @@ # adds the verdict arithmetic + the spawn lock (see # .claude/state/loop-advance.lock below). # -# Precedence (issue #96): feedback > comment-fix > ci-fix > advance > resume. +# Precedence (issue #96): feedback > comment-fix > ci-fix > rebase > advance > +# resume. # - unaddressed CHANGES_REQUESTED feedback (pr-feedback.sh) always wins over # everything else — a human is waiting on a reply. When multiple PRs need # feedback addressed, the lowest-numbered PR is picked. -# - COMMENT-FIX (pr-comment-fix.sh, issue #96 part 2) wins over CI-FIX and -# ADVANCE, but never over feedback: a PR that is BOTH a feedback candidate -# AND has an unresolved qualifying review-comment thread is handled as -# feedback, never comment-fix (pr-comment-fix.sh itself already excludes -# feedback candidates from its own output, so this precedence is enforced -# twice — belt and suspenders). When multiple PRs need a comment fix, the -# lowest-numbered PR is picked, same tie-break as feedback. -# - CI-FIX (pr-ci-fix.sh) wins over ADVANCE, but never over feedback or -# comment-fix: a PR that is BOTH a comment-fix candidate AND has failing -# CI is handled as comment-fix first (the reopened review conversation is -# addressed before chasing a possibly-unrelated CI failure). pr-ci-fix.sh -# does NOT itself exclude comment-fix candidates (the two conditions are -# independent signals on the SAME PR, unlike feedback's stronger -# "human is waiting" precedence) — this ordering is enforced solely at the -# verdict-decision level below. When multiple PRs need a CI fix, the -# lowest-numbered PR is picked, same tie-break as feedback. +# - COMMENT-FIX (pr-comment-fix.sh, issue #96 part 2) wins over CI-FIX, +# REBASE, and ADVANCE, but never over feedback: a PR that is BOTH a +# feedback candidate AND has an unresolved qualifying review-comment +# thread is handled as feedback, never comment-fix (pr-comment-fix.sh +# itself already excludes feedback candidates from its own output, so +# this precedence is enforced twice — belt and suspenders). When multiple +# PRs need a comment fix, the lowest-numbered PR is picked, same tie-break +# as feedback. +# - CI-FIX (pr-ci-fix.sh) wins over REBASE and ADVANCE, but never over +# feedback or comment-fix: a PR that is BOTH a comment-fix candidate AND +# has failing CI is handled as comment-fix first (the reopened review +# conversation is addressed before chasing a possibly-unrelated CI +# failure). pr-ci-fix.sh does NOT itself exclude comment-fix candidates +# (the two conditions are independent signals on the SAME PR, unlike +# feedback's stronger "human is waiting" precedence) — this ordering is +# enforced solely at the verdict-decision level below. When multiple PRs +# need a CI fix, the lowest-numbered PR is picked, same tie-break as +# feedback. +# - REBASE (pr-rebase.sh, issue #96 part 3) wins over ADVANCE, but never +# over feedback, comment-fix, or ci-fix: a PR that went CONFLICTING +# against base (typically because a sibling PR merged first) is the +# LOWEST of the four PR-event reactions — a merge conflict alone is not +# proof that something is WRONG with this PR's own change, unlike +# unaddressed feedback, an unresolved review thread, or red CI, so it only +# gets attention once none of those three apply. pr-rebase.sh itself +# already excludes feedback/comment-fix/ci-fix candidates from its own +# output (belt and suspenders, mirroring the other two siblings). When +# multiple PRs need a rebase, the lowest-numbered PR is picked, same +# tie-break as feedback. # - ADVANCE additionally requires: census says advance_ready=N (already # means zero open PRs + a planned+module issue + no existing branch), N is # not census's in_flight=N (a feat/issue-N-* branch with no open PR — # someone/something is already mid-flight on it), and the spawn lock # (below) is not already held for N. # - RESUME (issue #98, see STEP 0.5 below) is lowest precedence: it only -# fires when neither FEEDBACK, CI-FIX, nor a fresh ADVANCE claimed the -# tick (advance_ready=none), and picks the lowest-numbered in_flight issue -# that census's stall clock or debris classifier flags as stuck. +# fires when neither FEEDBACK, COMMENT-FIX, CI-FIX, REBASE, nor a fresh +# ADVANCE claimed the tick (advance_ready=none), and picks the +# lowest-numbered in_flight issue that census's stall clock or debris +# classifier flags as stuck. # # Spawn lock: .claude/state/loop-advance.lock (root-relative; .claude/state/ # is already gitignored). Written the moment this script emits @@ -151,6 +167,7 @@ write_tick_record() { "action=feedback pr="*) action="feedback"; pr="${verdict#action=feedback pr=}" ;; "action=comment-fix pr="*) action="comment-fix"; pr="${verdict#action=comment-fix pr=}" ;; "action=ci-fix pr="*) action="ci-fix"; pr="${verdict#action=ci-fix pr=}" ;; + "action=rebase pr="*) action="rebase"; pr="${verdict#action=rebase pr=}" ;; "action=resume issue="*) action="resume" issue="${verdict#action=resume issue=}" @@ -215,28 +232,32 @@ write_tick_record() { return 0 } -echo "=== 1/6 loop-census.sh ===" +echo "=== 1/7 loop-census.sh ===" census_out="$(bash "$script_dir/loop-census.sh" "$repo")" printf '%s\n' "$census_out" -echo "=== 2/6 notify-poll.sh ===" +echo "=== 2/7 notify-poll.sh ===" bash "$script_dir/notify-poll.sh" "$repo" -echo "=== 3/6 merge-ready.sh ===" +echo "=== 3/7 merge-ready.sh ===" bash "$script_dir/merge-ready.sh" "$repo" -echo "=== 4/6 pr-feedback.sh ===" +echo "=== 4/7 pr-feedback.sh ===" feedback_out="$(bash "$script_dir/pr-feedback.sh" "$repo")" printf '%s\n' "$feedback_out" -echo "=== 5/6 pr-comment-fix.sh ===" +echo "=== 5/7 pr-comment-fix.sh ===" commentfix_out="$(bash "$script_dir/pr-comment-fix.sh" "$repo")" printf '%s\n' "$commentfix_out" -echo "=== 6/6 pr-ci-fix.sh ===" +echo "=== 6/7 pr-ci-fix.sh ===" cifix_out="$(bash "$script_dir/pr-ci-fix.sh" "$repo")" printf '%s\n' "$cifix_out" +echo "=== 7/7 pr-rebase.sh ===" +rebase_out="$(bash "$script_dir/pr-rebase.sh" "$repo")" +printf '%s\n' "$rebase_out" + echo "=== verdict ===" # --- Parse census telemetry needed for the verdict ------------------------- @@ -294,6 +315,22 @@ cifix_branch="$(printf '%s\n' "$cifix_line" | awk -F'\t' '{print $2}')" # dispatches for issue N (do NOT invent a new counter file). cifix_issue="$(printf '%s\n' "$cifix_branch" | sed -n 's#.*feat/issue-\([0-9][0-9]*\)-.*#\1#p')" +# --- Parse pr-rebase.sh's TSV (num, branch, head_sha, base_sha, attempt) --- +# Lowest-numbered PR wins when several need a rebase, same tie-break as +# feedback above. pr-rebase.sh already excludes feedback/comment-fix/ci-fix +# candidates from its own output (precedence, issue #96 part 3), so no +# additional filtering is needed here beyond the verdict decision below. +rebase_line="$(printf '%s\n' "$rebase_out" | awk -F'\t' 'NF>=2 && $1 ~ /^[0-9]+$/ {print $1"\t"$2}' | sort -t $'\t' -k1,1n | head -1)" +rebase_pr="$(printf '%s\n' "$rebase_line" | awk -F'\t' '{print $1}')" +rebase_branch="$(printf '%s\n' "$rebase_line" | awk -F'\t' '{print $2}')" +# Same #95 per-issue attempt budget key derivation as feedback_issue above — +# rebase dispatches for issue N share the SAME counter as advance/feedback/ +# comment-fix/ci-fix dispatches for issue N (do NOT invent a new counter +# file for THIS budget; the separate per-BASE-COMMIT retry budget is tracked +# entirely inside pr-rebase.sh itself via the claude-rebase-attempted marker's +# embedded attempt number — see that script's header doc). +rebase_issue="$(printf '%s\n' "$rebase_branch" | sed -n 's#.*feat/issue-\([0-9][0-9]*\)-.*#\1#p')" + # --- Spawn lock: read + self-heal against the FRESH census above ----------- # TTL rationale: this lock is written the instant a tick emits # `action=advance issue=N`, before the orchestrator that will push @@ -550,20 +587,23 @@ if [ -z "$ceiling_block" ] && [ "$daily_count" -ge "$daily_action_ceiling" ]; th fi fi -# --- 3) per-issue advance/feedback/comment-fix/ci-fix attempt budget --------- +# --- 3) per-issue advance/feedback/comment-fix/ci-fix/rebase attempt budget -- # .claude/state/loop-issue-attempts.json: { "<issue>": {attempts,escalated} }. # Keyed by the ORIGINATING issue number (advance_ready directly; feedback/ -# comment-fix/ci-fix via feedback_issue/commentfix_issue/cifix_issue, parsed -# from the PR's feat/issue-N-* branch) so advance-phase, feedback-phase, -# comment-fix-phase, and ci-fix-phase dispatches for the same issue share ONE -# counter (issue #96 reuses the SAME #95 counter, no new state file) -- the -# candidate mirrors the SAME precedence the verdict decision below applies -# (feedback beats comment-fix beats ci-fix beats advance; in_flight/ -# lock-held advance candidates are never charged). This is INDEPENDENT of -# pr-comment-fix.sh's own per-THREAD retry budget (2 attempts/thread, tracked -# via the claude-comment-addressed marker) -- this counter bounds how many -# TIMES issue N gets dispatched across ANY reaction, that one bounds how many -# times ONE THREAD gets retried. +# comment-fix/ci-fix/rebase via feedback_issue/commentfix_issue/cifix_issue/ +# rebase_issue, parsed from the PR's feat/issue-N-* branch) so advance-phase, +# feedback-phase, comment-fix-phase, ci-fix-phase, and rebase-phase +# dispatches for the same issue share ONE counter (issue #96 reuses the SAME +# #95 counter, no new state file) -- the candidate mirrors the SAME +# precedence the verdict decision below applies (feedback beats comment-fix +# beats ci-fix beats rebase beats advance; in_flight/lock-held advance +# candidates are never charged). This is INDEPENDENT of pr-comment-fix.sh's +# own per-THREAD retry budget (2 attempts/thread, tracked via the +# claude-comment-addressed marker) and pr-rebase.sh's own per-BASE-COMMIT +# retry budget (2 attempts/base commit, tracked via the +# claude-rebase-attempted marker) -- this counter bounds how many TIMES issue +# N gets dispatched across ANY reaction, those bound how many times ONE +# THREAD/BASE-COMMIT gets retried. attempts_file="$state_dir/loop-issue-attempts.json" attempt_issue="" attempt_escalate_kind="" attempt_escalate_num="" if [ -n "$feedback_pr" ]; then @@ -578,6 +618,10 @@ elif [ -n "$cifix_pr" ]; then attempt_issue="${cifix_issue:-$cifix_pr}" attempt_escalate_kind="pr" attempt_escalate_num="$cifix_pr" +elif [ -n "$rebase_pr" ]; then + attempt_issue="${rebase_issue:-$rebase_pr}" + attempt_escalate_kind="pr" + attempt_escalate_num="$rebase_pr" elif [ "$advance_ready" != "none" ] && [ -n "$advance_ready" ] \ && ! printf '%s\n' "$in_flight_issues" | grep -qx "$advance_ready" \ && [ "$lock_issue" != "$advance_ready" ]; then @@ -784,6 +828,8 @@ elif [ -n "$commentfix_pr" ]; then verdict="action=comment-fix pr=$commentfix_pr" elif [ -n "$cifix_pr" ]; then verdict="action=ci-fix pr=$cifix_pr" +elif [ -n "$rebase_pr" ]; then + verdict="action=rebase pr=$rebase_pr" elif [ "$advance_ready" != "none" ] && [ -n "$advance_ready" ]; then if printf '%s\n' "$in_flight_issues" | grep -qx "$advance_ready"; then # Defensive only: real census can never report the SAME issue as both @@ -897,9 +943,10 @@ else fi # --- Spend-ceiling bookkeeping: increment counts on an ACTUAL dispatch ------ -# Only runs when the verdict just decided is a genuine advance/feedback/ci-fix -# dispatch (never on action=none, ceiling-blocked or not) -- so a blocked -# tick never itself grows the very counters that blocked it. action=resume is +# Only runs when the verdict just decided is a genuine advance/feedback/ +# comment-fix/ci-fix/rebase dispatch (never on action=none, ceiling-blocked or +# not) -- so a blocked tick never itself grows the very counters that blocked +# it. action=resume is # DELIBERATELY excluded here: resume attempts are tracked in the SIBLING # loop-resume-attempts.json (written above, alongside the verdict decision), # precisely so a resume never charges issue #95's advance/feedback dispatch @@ -932,6 +979,14 @@ case "$verdict" in dispatch_issue="$dispatch_pr" fi ;; + "action=rebase pr="*) + dispatch_pr="${verdict#action=rebase pr=}" + if [ "$dispatch_pr" = "$rebase_pr" ] && [ -n "${rebase_issue:-}" ]; then + dispatch_issue="$rebase_issue" + else + dispatch_issue="$dispatch_pr" + fi + ;; esac if [ -n "$dispatch_issue" ]; then diff --git a/.claude/scripts/loop-tick.test.sh b/.claude/scripts/loop-tick.test.sh index afe5f31..3f3b37d 100644 --- a/.claude/scripts/loop-tick.test.sh +++ b/.claude/scripts/loop-tick.test.sh @@ -35,13 +35,14 @@ check() { } # Build one fresh fake "consumer project" per scenario: <fixture>/.claude/scripts/. -# fake_census / fake_feedback / fake_cifix / fake_commentfix are the exact -# stdout the corresponding real script would print; notify-poll.sh and -# merge-ready.sh are stubbed to just print a marker line (their output is -# passed through, never parsed). fake_cifix/fake_commentfix default to empty -# (no candidates) so every existing 3/4-arg call site keeps working unchanged. +# fake_census / fake_feedback / fake_cifix / fake_commentfix / fake_rebase are +# the exact stdout the corresponding real script would print; notify-poll.sh +# and merge-ready.sh are stubbed to just print a marker line (their output is +# passed through, never parsed). fake_cifix/fake_commentfix/fake_rebase +# default to empty (no candidates) so every existing 3/4/5-arg call site keeps +# working unchanged. new_fixture() { - local name="$1" fake_census="$2" fake_feedback="$3" fake_cifix="${4:-}" fake_commentfix="${5:-}" + local name="$1" fake_census="$2" fake_feedback="$3" fake_cifix="${4:-}" fake_commentfix="${5:-}" fake_rebase="${6:-}" local dir="$work/$name/.claude/scripts" mkdir -p "$dir" "$work/$name/.claude/state" 2>/dev/null rm -rf "$work/$name/.claude/state" # loop-tick.sh must mkdir -p it itself @@ -86,6 +87,12 @@ EOF cat <<'COMMENTFIX' $fake_commentfix COMMENTFIX +EOF + cat > "$dir/pr-rebase.sh" <<EOF +#!/usr/bin/env bash +cat <<'REBASE' +$fake_rebase +REBASE EOF chmod +x "$dir"/*.sh printf '%s\n' "$dir" @@ -189,15 +196,16 @@ check "scenario 6: self-heal diagnostic mentions the cleared stale issue=3" bash check "scenario 6: lock file now records the NEW issue=9, not the stale 3" grep -q '^issue=9 ts=' "$lock6" # --------------------------------------------------------------------------- -# 7. All six step scripts' full output is preserved (never swallowed). +# 7. All seven step scripts' full output is preserved (never swallowed). # --------------------------------------------------------------------------- -check "all six labeled step headers appear in the tick's output" bash -c ' - printf "%s\n" "$1" | grep -q "1/6 loop-census.sh" && - printf "%s\n" "$1" | grep -q "2/6 notify-poll.sh" && - printf "%s\n" "$1" | grep -q "3/6 merge-ready.sh" && - printf "%s\n" "$1" | grep -q "4/6 pr-feedback.sh" && - printf "%s\n" "$1" | grep -q "5/6 pr-comment-fix.sh" && - printf "%s\n" "$1" | grep -q "6/6 pr-ci-fix.sh" +check "all seven labeled step headers appear in the tick's output" bash -c ' + printf "%s\n" "$1" | grep -q "1/7 loop-census.sh" && + printf "%s\n" "$1" | grep -q "2/7 notify-poll.sh" && + printf "%s\n" "$1" | grep -q "3/7 merge-ready.sh" && + printf "%s\n" "$1" | grep -q "4/7 pr-feedback.sh" && + printf "%s\n" "$1" | grep -q "5/7 pr-comment-fix.sh" && + printf "%s\n" "$1" | grep -q "6/7 pr-ci-fix.sh" && + printf "%s\n" "$1" | grep -q "7/7 pr-rebase.sh" ' _ "$out1" check "notify-poll.sh full output line passed through, not swallowed" bash -c 'printf "%s\n" "$1" | grep -qF "fake notify-poll output"' _ "$out1" check "merge-ready.sh full output line passed through, not swallowed" bash -c 'printf "%s\n" "$1" | grep -qF "merge-ready: merged=0 skipped=0"' _ "$out1" @@ -431,6 +439,70 @@ check "scenario 12h: action=comment-fix tick record captures pr number and caden if (obj.cadence !== "WATCH") throw new Error("cadence mismatch: " + JSON.stringify(obj)); ' "$ticks12h" +# --------------------------------------------------------------------------- +# 12i. Rebase candidate present, no feedback/comment-fix/ci-fix, nothing +# advance_ready -> picked as action=rebase pr=N (issue #96 part 3), +# lowest-numbered PR wins among rebase candidates, and (mirroring +# scenario 12b's "no spawn lock" for ci-fix) never writes the advance +# spawn lock. +# --------------------------------------------------------------------------- +dir12i="$(new_fixture scenario12i 'open_prs=1 +feedback_prs=0 +planned_issues=0 +advance_ready=none +cadence=WATCH cron=*/5 * * * *' '' '' '' "$(printf '9\tfeat/issue-9-x\tsha9\tbase9\t1 +4\tfeat/issue-4-y\tsha4\tbase4\t1')")" +out12i="$(run_tick "$dir12i")" +check "scenario 12i (rebase, lowest-numbered PR wins): verdict is action=rebase pr=4" bash -c '[ "$(printf "%s\n" "$1" | tail -1)" = "action=rebase pr=4" ]' _ "$out12i" +check "scenario 12i: no spawn lock written (advance never attempted)" [ ! -e "$dir12i/../state/loop-advance.lock" ] + +# --------------------------------------------------------------------------- +# 12j. Rebase wins over an ALSO-ready advance (issue #96 part 3 precedence: +# rebase > advance), same shape as scenario 12c's ci-fix-beats-advance +# check. +# --------------------------------------------------------------------------- +dir12j="$(new_fixture scenario12j 'open_prs=0 +feedback_prs=0 +planned_issues=1 +issue=7 branch=none title=Some issue +advance_ready=7 +cadence=FAST cron=* * * * *' '' '' '' "$(printf '11\tfeat/issue-11-x\tsha11\tbase11\t1')")" +out12j="$(run_tick "$dir12j")" +check "scenario 12j (rebase beats an also-ready advance): verdict is action=rebase pr=11" bash -c '[ "$(printf "%s\n" "$1" | tail -1)" = "action=rebase pr=11" ]' _ "$out12j" +check "scenario 12j: no spawn lock written (advance never attempted)" [ ! -e "$dir12j/../state/loop-advance.lock" ] + +# --------------------------------------------------------------------------- +# 12k. CI-fix wins over an ALSO-ready rebase (issue #96 part 3 precedence: +# ci-fix > rebase), same shape as scenario 12f's comment-fix-beats-ci-fix +# check. +# --------------------------------------------------------------------------- +dir12k="$(new_fixture scenario12k 'open_prs=0 +feedback_prs=0 +planned_issues=1 +issue=7 branch=none title=Some issue +advance_ready=7 +cadence=FAST cron=* * * * *' '' "$(printf '20\tfeat/issue-20-x\tbuild\tsha20')" '' "$(printf '11\tfeat/issue-11-x\tsha11\tbase11\t1')")" +out12k="$(run_tick "$dir12k")" +check "scenario 12k (ci-fix beats an also-ready rebase and advance): verdict is action=ci-fix pr=20" bash -c '[ "$(printf "%s\n" "$1" | tail -1)" = "action=ci-fix pr=20" ]' _ "$out12k" +check "scenario 12k: no spawn lock written (advance never attempted)" [ ! -e "$dir12k/../state/loop-advance.lock" ] + +# action=rebase tick record: pr number captured, cadence round-trips. +dir12l="$(new_fixture scenario12l 'open_prs=1 +feedback_prs=0 +planned_issues=0 +advance_ready=none +cadence=WATCH cron=*/5 * * * *' '' '' '' "$(printf '15\tfeat/issue-15-x\tsha15\tbase15\t2')")" +ticks12l="$work/scenario12l-ticks.jsonl" +out12l="$(CLAUDE_TICKS_FILE="$ticks12l" run_tick "$dir12l")" +check "scenario 12l: verdict is still the LAST stdout line for action=rebase" bash -c '[ "$(printf "%s\n" "$1" | tail -1)" = "action=rebase pr=15" ]' _ "$out12l" +check "scenario 12l: action=rebase tick record captures pr number and cadence" node -e ' + const fs = require("fs"); + const obj = JSON.parse(fs.readFileSync(process.argv[1], "utf8").trim()); + if (obj.action !== "rebase") throw new Error("action mismatch: " + JSON.stringify(obj)); + if (obj.pr !== "15") throw new Error("pr mismatch: " + JSON.stringify(obj)); + if (obj.cadence !== "WATCH") throw new Error("cadence mismatch: " + JSON.stringify(obj)); +' "$ticks12l" + # --------------------------------------------------------------------------- # 11. Rotation: LOOP_TICKS_MAX_LINES caps the tick log to the last N lines # across repeated ticks (mirrors log-event.sh's rotation, log-event.test.sh @@ -820,6 +892,10 @@ EOF cat > "$scripts/pr-comment-fix.sh" <<'EOF' #!/usr/bin/env bash exit 0 +EOF + cat > "$scripts/pr-rebase.sh" <<'EOF' +#!/usr/bin/env bash +exit 0 EOF cat > "$scripts/notify-poll.sh" <<'EOF' #!/usr/bin/env bash diff --git a/.claude/scripts/pr-rebase.sh b/.claude/scripts/pr-rebase.sh new file mode 100644 index 0000000..0d49918 --- /dev/null +++ b/.claude/scripts/pr-rebase.sh @@ -0,0 +1,213 @@ +#!/usr/bin/env bash +# pr-rebase.sh — print open, bot-authored PRs that have gone CONFLICTING +# against their base (typically because a sibling PR merged into base first), +# so the loop can dispatch a driver to rebase the SAME branch onto the new +# base and force-push it back in place. This is capability #3 of issue #96 +# (rebase-after-sibling-merge); capability #1 (CI-failure fixing) is +# pr-ci-fix.sh, capability #2 (review-comment convergence) is +# pr-comment-fix.sh. Prints one TSV line per PR needing a rebase: +# <number>\t<branch>\t<head_sha>\t<base_sha>\t<attempt> +# +# A PR is listed when ALL hold: +# - authored by the bot ($BOT_LOGIN, default robercano-ghbot), open, base is +# the adapter's merge.baseBranch ($GATES_FILE, default .claude/gates.json +# — same node-read as loop-census.sh/pr-ci-fix.sh/pr-comment-fix.sh); +# - GitHub's own `mergeable` field on the PR is EXACTLY `CONFLICTING`. This +# is the one and only trigger: a PR that WAS cleanly mergeable but no +# longer is has almost always gone stale because ANOTHER PR merged into +# base first (the very thing this capability exists to recover from). +# `MERGEABLE` obviously doesn't qualify (nothing to fix), and — mirroring +# pr-ci-fix.sh's "PENDING CI doesn't qualify" reasoning for its own +# rollup — `PENDING`/`UNKNOWN` do NOT qualify either: GitHub computes +# `mergeable` asynchronously and hasn't finished yet, so treating either +# as a green light would dispatch a rebase driver against a stale/unknown +# answer instead of waiting one more tick for GitHub to settle; +# - it is NOT ALSO a pr-feedback.sh, pr-comment-fix.sh, OR pr-ci-fix.sh +# candidate. VERDICT PRECEDENCE (issue #96, all three parts): feedback > +# comment-fix > ci-fix > rebase > advance — rebase is the LOWEST of the +# four PR-event reactions (a merge conflict, unlike unaddressed feedback, +# an unresolved review thread, or red CI, is not itself proof that +# something is WRONG with this PR's own change — it only needs to catch +# up with base), so a PR that also qualifies for any of the other three +# is left ENTIRELY to that script here (excluded, not merely +# deprioritized), and the caller (loop-tick.sh) additionally enforces the +# same precedence at the verdict level; +# - not labeled `needs-human` (the anti-livelock budget below already gave +# up on this PR for its CURRENT base commit, or some OTHER escalation +# gave up on it) and not labeled `claude-rebasing` (an in-flight guard, +# mirroring pr-ci-fix.sh's `claude-ci-fixing`/pr-comment-fix.sh's +# `claude-comment-fixing`, so overlapping ticks don't double-dispatch the +# SAME PR while a rebase is already being worked); +# - the rebase has NOT already been exhausted for the CURRENT base commit +# (see ANTI-LIVELOCK below). +# +# ANTI-LIVELOCK (bounded per-base-commit retries, marker-cursor, no new state +# file — mirrors pr-ci-fix.sh's head_sha-keyed marker and pr-comment-fix.sh's +# per-thread attempt-number derivation): the driver that attempts a rebase +# posts a bot comment containing +# `<!-- claude-rebase-attempted:<base_sha>:<attempt> -->` after EVERY attempt +# (both a clean rebase+force-push AND an aborted conflicting one — see +# loop-event.sh's rebase prompt). For the PR's CURRENT `baseRefOid` (base_sha): +# - no marker at all for this EXACT base_sha -> next attempt is 1, this PR +# is a fresh candidate; +# - the highest-attempt marker found for this EXACT base_sha is K -> next +# attempt is K+1; +# - once the next attempt would be 3 (i.e. 2 completed attempts already +# recorded against this SAME base_sha), this script escalates instead of +# emitting another candidate: applies the `needs-human` label via +# needs-human.sh's `needs_human_flag` (same seam pr-comment-fix.sh's own +# inline escalation uses) and drops the PR from this tick's output. +# A marker's base_sha is compared for EXACT equality only — a marker posted +# against an OLDER base_sha never counts toward the CURRENT base_sha's +# budget. This is what makes the budget self-resetting: once a NEW sibling PR +# merges into base, `baseRefOid` changes, every marker on file was written +# against the now-stale base_sha, so the count for the fresh base_sha starts +# back at zero and this PR is eligible again (attempt=1) even if it had +# previously exhausted its budget against the OLD base_sha and been labeled +# needs-human for it. needs_human_flag's own "already labeled" check makes +# repeated escalation ticks against the SAME base_sha a no-op (no repeat +# comment spam); once the label lands, the very next tick's guard-label check +# above excludes the whole PR from further rebase automation (of ANY base +# commit) until a human clears it — a PR that keeps conflicting needs a +# human's judgment on the underlying change, not more automated churn. +# +# Repo derived from the git remote; override with $1. Bot login via +# $BOT_LOGIN. Adapter (baseBranch) via $GATES_FILE, default .claude/gates.json +# — same fallback the two siblings use (the SELF run passes GATES_FILE +# explicitly). Invoke as `bash .claude/scripts/pr-rebase.sh` (pre-approve that +# exact command). Read-only w.r.t. PR state: detects only. The +# `claude-rebasing` label and the `<!-- claude-rebase-attempted:... -->` +# marker comment are written by the DRIVER this script's output goes on to +# dispatch, never by this script. Mutates ONLY via needs-human.sh's +# needs_human_flag on a genuine escalation — never rebases, never +# force-pushes, never posts a `claude-rebase-attempted` marker itself. +set -euo pipefail + +# Two-root derivation (issue #63): script_dir = sibling scripts, root = consumer project. +# shellcheck source=resolve-roots.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/resolve-roots.sh" +# Route EVERY gh call through the bot identity (see bot-gh.sh). +gh() { bash "$script_dir/bot-gh.sh" "$@"; } +repo="${1:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}" +bot="${BOT_LOGIN:-robercano-ghbot}" + +# needs_human_flag (issue #99 seam): sourced AFTER `gh` is defined above, so +# its calls run through the bot identity too. Guarded (not a bare source +# under `set -e`) so a fixture missing this sibling file degrades to "no +# escalation" rather than aborting the whole script. +if [ -f "$script_dir/needs-human.sh" ]; then . "$script_dir/needs-human.sh"; fi + +gates_rel="${GATES_FILE:-.claude/gates.json}" +case "$gates_rel" in /*) gates="$gates_rel" ;; *) gates="$root/$gates_rel" ;; esac +base="$(node -e 'const g=require(process.argv[1]); console.log((g.merge&&g.merge.baseBranch)||"main")' "$gates" 2>/dev/null || echo main)" + +# Feedback, comment-fix, AND ci-fix candidates OUTRANK rebase (precedence) — +# exclude their PR numbers up front so a PR that qualifies for any of them +# never shows up here at all. +feedback_prs="$(bash "$script_dir/pr-feedback.sh" "$repo" 2>/dev/null \ + | awk -F'\t' 'NF>=2 && $1 ~ /^[0-9]+$/ {print $1}' || true)" +is_feedback_candidate() { + local n="$1" + case $'\n'"$feedback_prs"$'\n' in + *$'\n'"$n"$'\n'*) return 0 ;; + *) return 1 ;; + esac +} +comment_fix_prs="$(bash "$script_dir/pr-comment-fix.sh" "$repo" 2>/dev/null \ + | awk -F'\t' 'NF>=2 && $1 ~ /^[0-9]+$/ {print $1}' || true)" +is_comment_fix_candidate() { + local n="$1" + case $'\n'"$comment_fix_prs"$'\n' in + *$'\n'"$n"$'\n'*) return 0 ;; + *) return 1 ;; + esac +} +ci_fix_prs="$(bash "$script_dir/pr-ci-fix.sh" "$repo" 2>/dev/null \ + | awk -F'\t' 'NF>=2 && $1 ~ /^[0-9]+$/ {print $1}' || true)" +is_ci_fix_candidate() { + local n="$1" + case $'\n'"$ci_fix_prs"$'\n' in + *$'\n'"$n"$'\n'*) return 0 ;; + *) return 1 ;; + esac +} + +gh pr list -R "$repo" --state open --base "$base" \ + --json number,headRefName,author,labels,mergeable,baseRefOid,headRefOid \ + --jq '.[] | select(.author.login=="'"$bot"'")' \ +| while IFS= read -r pr_json; do + [ -z "$pr_json" ] && continue + + # One node call per PR: parse number/branch/head/base/labels and compute + # the guard-skip + qualifies decisions together, so the rest of this loop + # body only branches on plain shell values. Extracted via `cut -f`, NOT + # `IFS=$'\t' read` — bash's `read` classifies tab as "IFS whitespace" + # REGARDLESS of what IFS is set to, so it silently collapses consecutive + # tabs (an empty field would swallow the NEXT field too); `cut` never + # does that (same rationale pr-ci-fix.sh documents for its own parse). + parsed="$(printf '%s' "$pr_json" | node -e ' + const p = JSON.parse(require("fs").readFileSync(0, "utf8")); + const labels = (p.labels || []).map((l) => l.name); + const guardSkip = labels.includes("needs-human") || labels.includes("claude-rebasing"); + const qualifies = p.mergeable === "CONFLICTING"; + console.log([p.number, p.headRefName || "", p.headRefOid || "", p.baseRefOid || "", guardSkip ? 1 : 0, qualifies ? 1 : 0].join("\t")); + ')" + num="$(printf '%s' "$parsed" | cut -f1)" + branch="$(printf '%s' "$parsed" | cut -f2)" + head_sha="$(printf '%s' "$parsed" | cut -f3)" + base_sha="$(printf '%s' "$parsed" | cut -f4)" + guard_skip="$(printf '%s' "$parsed" | cut -f5)" + qualifies="$(printf '%s' "$parsed" | cut -f6)" + + [ -z "${num:-}" ] && continue + [ "$guard_skip" = "1" ] && continue + [ "$qualifies" = "1" ] || continue # not CONFLICTING (or GitHub still computing it) -> nothing to rebase + is_feedback_candidate "$num" && continue + is_comment_fix_candidate "$num" && continue + is_ci_fix_candidate "$num" && continue + + # This PR's own bot comments (the only place `claude-rebase-attempted` + # markers are ever posted) -- fed to node so the anti-livelock + # next-attempt/escalate decision below is made once, in one process. + marker_comments_json="$(gh api "repos/$repo/issues/$num/comments" \ + --jq '[.[]|select(.user.login=="'"$bot"'")|{body:.body}]' \ + 2>/dev/null || echo '[]')" + [ -z "$marker_comments_json" ] && marker_comments_json='[]' + + decision="$(node -e ' + const markers = JSON.parse(process.argv[1] || "[]"); + const baseSha = process.argv[2]; + const markerRe = /<!-- claude-rebase-attempted:([^:]+):(\d+) -->/g; + let maxAttempt = 0; + for (const c of markers) { + let m; + markerRe.lastIndex = 0; + while ((m = markerRe.exec(c.body || "")) !== null) { + const sha = m[1]; + const att = parseInt(m[2], 10); + if (sha === baseSha && att > maxAttempt) maxAttempt = att; + } + } + const nextAttempt = maxAttempt + 1; + if (nextAttempt >= 3) { + console.log("ESCALATE"); + } else { + console.log("FIX\t" + nextAttempt); + } + ' "$marker_comments_json" "$base_sha" 2>/dev/null || true)" + + kind="$(printf '%s' "$decision" | cut -f1)" + if [ "$kind" = "ESCALATE" ]; then + if command -v needs_human_flag >/dev/null 2>&1; then + needs_human_flag "pr:$num" "rebase-attempt-budget" "high" \ + "PR #$num: rebase attempts exhausted for base $base_sha" \ + "PR #$num became unmergeable (mergeable=CONFLICTING) against base commit $base_sha and has already had 2 automated rebase attempts against this SAME base commit (budget: 2 attempts/base commit). The loop will not retry it automatically -- labeling \`needs-human\`. Resolve the conflict by hand; a FUTURE sibling merge that moves base to a new commit will reset this budget automatically." + fi + continue + fi + [ "$kind" = "FIX" ] || continue + attempt="$(printf '%s' "$decision" | cut -f2)" + [ -z "$attempt" ] && continue + + printf '%s\t%s\t%s\t%s\t%s\n' "$num" "$branch" "$head_sha" "$base_sha" "$attempt" + done diff --git a/.claude/scripts/pr-rebase.test.sh b/.claude/scripts/pr-rebase.test.sh new file mode 100644 index 0000000..8ef4fb9 --- /dev/null +++ b/.claude/scripts/pr-rebase.test.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# pr-rebase.test.sh — offline smoke test for pr-rebase.sh (issue #96 +# capability #3: rebase-after-sibling-merge detection). +# +# Builds a throwaway .claude/scripts/ fixture containing the REAL +# pr-rebase.sh + resolve-roots.sh + needs-human.sh + notify.sh next to a FAKE +# bot-gh.sh (which stands in for `gh` entirely, mirroring pr-ci-fix.test.sh's +# and pr-comment-fix.test.sh's own fake-gh convention) and FAKE pr-feedback.sh +# / pr-comment-fix.sh / pr-ci-fix.sh (so the three-way precedence exclusion +# can be exercised without any real detection logic in those siblings). No +# network, no real `gh` CLI required. +# +# Exit 0 on success, non-zero if any assertion fails. Runnable bare: +# bash .claude/scripts/pr-rebase.test.sh +set -uo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +src="$script_dir/pr-rebase.sh" +resolve_roots_src="$script_dir/resolve-roots.sh" + +work="$(mktemp -d "${TMPDIR:-/tmp}/pr-rebase-test.XXXXXX")" +trap 'rm -rf "$work"' EXIT + +fail=0 +ok=0 +check() { + local desc="$1"; shift + if "$@"; then + ok=$((ok + 1)) + echo "ok - $desc" + else + fail=1 + echo "FAIL - $desc" + fi +} + +fixture="$work/fixture1" +scripts_dir="$fixture/.claude/scripts" +mkdir -p "$scripts_dir" "$fixture/.claude/state" +cp "$src" "$scripts_dir/pr-rebase.sh" +cp "$resolve_roots_src" "$scripts_dir/resolve-roots.sh" +cp "$script_dir/needs-human.sh" "$scripts_dir/needs-human.sh" +cp "$script_dir/notify.sh" "$scripts_dir/notify.sh" + +cat > "$fixture/.claude/gates.json" <<'EOF' +{ + "modules": [{ "name": "test", "path": ".", "description": "", "owner": "" }], + "merge": { "baseBranch": "main" } +} +EOF + +# --- fixture PR set ---------------------------------------------------------- +# 10: mergeable=CONFLICTING, no marker -> EMITTED attempt=1 +# 11: mergeable=MERGEABLE -> NOT emitted (nothing to rebase) +# 12: mergeable=CONFLICTING, labeled needs-human -> NOT emitted (guard) +# 13: mergeable=CONFLICTING, labeled claude-rebasing -> NOT emitted (in-flight guard) +# 14: mergeable=CONFLICTING, ALSO a pr-feedback.sh candidate -> NOT emitted (precedence) +# 15: mergeable=CONFLICTING, ALSO a pr-comment-fix.sh candidate -> NOT emitted (precedence) +# 20: mergeable=CONFLICTING, ALSO a pr-ci-fix.sh candidate -> NOT emitted (precedence) +# 16: mergeable=CONFLICTING, marker attempt=1 for SAME base_sha -> EMITTED attempt=2 +# 17: mergeable=CONFLICTING, marker attempt=2 for SAME base_sha -> NOT emitted (escalated) +# 18: mergeable=UNKNOWN -> NOT emitted (still computing) +# 19: mergeable=CONFLICTING, marker attempt=2 for a DIFFERENT (old) base_sha +# -> EMITTED attempt=1 (reset) +cat > "$scripts_dir/bot-gh.sh" <<'BOTGH' +#!/usr/bin/env bash +# Log every invocation (mirrors pr-comment-fix.test.sh's gh-call-log +# convention) so escalation side effects (needs_human_flag's `gh pr edit ... +# --add-label needs-human`) can be asserted on directly, not just inferred +# from "the PR wasn't emitted". +log_dir="$(cd "$(dirname "$0")" && pwd)" +printf '%s\n' "$*" >> "$log_dir/gh-calls.log" +case "$1" in + repo) echo "acme/repo" ;; + label) exit 0 ;; # needs_human_flag's `gh label create needs-human ...` + pr) + case "$2" in + edit|comment) exit 0 ;; # needs_human_flag's `gh pr edit`/`gh pr comment` + view) echo ""; exit 0 ;; # _needs_human_already_labeled's `gh pr view --json labels` + esac + if printf '%s\n' "$*" | grep -q 'headRefOid'; then + cat <<'JSON' +{"number":10,"headRefName":"feat/issue-10-a","author":{"login":"testbot"},"labels":[],"mergeable":"CONFLICTING","baseRefOid":"base1","headRefOid":"sha10"} +{"number":11,"headRefName":"feat/issue-11-a","author":{"login":"testbot"},"labels":[],"mergeable":"MERGEABLE","baseRefOid":"base1","headRefOid":"sha11"} +{"number":12,"headRefName":"feat/issue-12-a","author":{"login":"testbot"},"labels":[{"name":"needs-human"}],"mergeable":"CONFLICTING","baseRefOid":"base1","headRefOid":"sha12"} +{"number":13,"headRefName":"feat/issue-13-a","author":{"login":"testbot"},"labels":[{"name":"claude-rebasing"}],"mergeable":"CONFLICTING","baseRefOid":"base1","headRefOid":"sha13"} +{"number":14,"headRefName":"feat/issue-14-a","author":{"login":"testbot"},"labels":[],"mergeable":"CONFLICTING","baseRefOid":"base1","headRefOid":"sha14"} +{"number":15,"headRefName":"feat/issue-15-a","author":{"login":"testbot"},"labels":[],"mergeable":"CONFLICTING","baseRefOid":"base1","headRefOid":"sha15"} +{"number":20,"headRefName":"feat/issue-20-a","author":{"login":"testbot"},"labels":[],"mergeable":"CONFLICTING","baseRefOid":"base1","headRefOid":"sha20"} +{"number":16,"headRefName":"feat/issue-16-a","author":{"login":"testbot"},"labels":[],"mergeable":"CONFLICTING","baseRefOid":"base1","headRefOid":"sha16"} +{"number":17,"headRefName":"feat/issue-17-a","author":{"login":"testbot"},"labels":[],"mergeable":"CONFLICTING","baseRefOid":"base1","headRefOid":"sha17"} +{"number":18,"headRefName":"feat/issue-18-a","author":{"login":"testbot"},"labels":[],"mergeable":"UNKNOWN","baseRefOid":"base1","headRefOid":"sha18"} +{"number":19,"headRefName":"feat/issue-19-a","author":{"login":"testbot"},"labels":[],"mergeable":"CONFLICTING","baseRefOid":"base-new","headRefOid":"sha19"} +JSON + else + echo "fake-bot-gh.sh: unexpected pr subcommand: $*" >&2 + exit 1 + fi + ;; + api) + case "$*" in + *"issues/16/comments"*) + echo '[{"user":{"login":"testbot"},"body":"<!-- claude-rebase-attempted:base1:1 -->"}]' + ;; + *"issues/17/comments"*) + echo '[{"user":{"login":"testbot"},"body":"<!-- claude-rebase-attempted:base1:2 -->"}]' + ;; + *"issues/19/comments"*) + echo '[{"user":{"login":"testbot"},"body":"<!-- claude-rebase-attempted:base-old:2 -->"}]' + ;; + *"issues/"*"/comments"*) echo '[]' ;; + *) echo "fake-bot-gh.sh: unhandled api call: $*" >&2; exit 1 ;; + esac + ;; + *) echo "fake-bot-gh.sh: unhandled args: $*" >&2; exit 1 ;; +esac +BOTGH +chmod +x "$scripts_dir/bot-gh.sh" + +# 14 is a feedback candidate (precedence: feedback outranks rebase). +cat > "$scripts_dir/pr-feedback.sh" <<'EOF' +#!/usr/bin/env bash +printf '14\tfeat/issue-14-a\towner\t2026-01-01T00:00:00Z\n' +EOF +chmod +x "$scripts_dir/pr-feedback.sh" + +# 15 is a comment-fix candidate (precedence: comment-fix outranks rebase). +cat > "$scripts_dir/pr-comment-fix.sh" <<'EOF' +#!/usr/bin/env bash +printf '15\tfeat/issue-15-a\tTABC:1\tsha15\n' +EOF +chmod +x "$scripts_dir/pr-comment-fix.sh" + +# 20 is a ci-fix candidate (precedence: ci-fix outranks rebase). +cat > "$scripts_dir/pr-ci-fix.sh" <<'EOF' +#!/usr/bin/env bash +printf '20\tfeat/issue-20-a\tbuild\tsha20\n' +EOF +chmod +x "$scripts_dir/pr-ci-fix.sh" + +out="$(env -u GATES_FILE BOT_LOGIN=testbot bash "$scripts_dir/pr-rebase.sh" "acme/repo")" + +check "PR 10 (CONFLICTING, no marker): emitted attempt=1" bash -c ' + printf "%s\n" "$1" | grep -qF "$(printf "10\tfeat/issue-10-a\tsha10\tbase1\t1")"' _ "$out" +check "PR 11 (MERGEABLE): NOT emitted" bash -c '! printf "%s\n" "$1" | grep -qE "^11\b"' _ "$out" +check "PR 12 (needs-human guard): NOT emitted" bash -c '! printf "%s\n" "$1" | grep -qE "^12\b"' _ "$out" +check "PR 13 (claude-rebasing in-flight guard): NOT emitted" bash -c '! printf "%s\n" "$1" | grep -qE "^13\b"' _ "$out" +check "PR 14 (also a feedback candidate -- precedence): NOT emitted" bash -c '! printf "%s\n" "$1" | grep -qE "^14\b"' _ "$out" +check "PR 15 (also a comment-fix candidate -- precedence): NOT emitted" bash -c '! printf "%s\n" "$1" | grep -qE "^15\b"' _ "$out" +check "PR 20 (also a ci-fix candidate -- precedence): NOT emitted" bash -c '! printf "%s\n" "$1" | grep -qE "^20\b"' _ "$out" +check "PR 16 (marker attempt=1 for SAME base_sha): emitted attempt=2" bash -c ' + printf "%s\n" "$1" | grep -qF "$(printf "16\tfeat/issue-16-a\tsha16\tbase1\t2")"' _ "$out" +check "PR 17 (marker attempt=2 for SAME base_sha -- budget exhausted): NOT emitted (escalated)" bash -c '! printf "%s\n" "$1" | grep -qE "^17\b"' _ "$out" +check "PR 18 (UNKNOWN mergeable, still computing): NOT emitted" bash -c '! printf "%s\n" "$1" | grep -qE "^18\b"' _ "$out" +check "PR 19 (marker for a DIFFERENT/old base_sha -- reset): emitted attempt=1" bash -c ' + printf "%s\n" "$1" | grep -qF "$(printf "19\tfeat/issue-19-a\tsha19\tbase-new\t1")"' _ "$out" +check "exactly 3 PRs emitted total (10, 16, 19)" bash -c '[ "$(printf "%s\n" "$1" | grep -c .)" -eq 3 ]' _ "$out" + +# --- anti-livelock escalation: positive assertion on the ACTUAL gh side +# effect, not just "PR 17 wasn't emitted" (which can't tell correct +# escalation apart from a silent bug that just drops the PR). +gh_log1="$scripts_dir/gh-calls.log" +check "PR 17 (budget exhausted): needs-human label ACTUALLY applied (gh pr edit 17 --add-label needs-human)" \ + grep -q "pr edit 17 --add-label needs-human" "$gh_log1" +check "PR 16 (attempt 2, budget not yet exhausted): needs-human label NOT applied" \ + bash -c '! grep -q "pr edit 16 --add-label needs-human" "$1"' _ "$gh_log1" +check "PR 19 (reset after base change): needs-human label NOT applied" \ + bash -c '! grep -q "pr edit 19 --add-label needs-human" "$1"' _ "$gh_log1" + +echo "" +if [ "$fail" -eq 0 ]; then + echo "pr-rebase.test.sh: PASS ($ok checks)" + exit 0 +else + echo "pr-rebase.test.sh: FAIL (see FAIL lines above)" + exit 1 +fi diff --git a/.claude/self/pr-loop-self.md b/.claude/self/pr-loop-self.md index 92bd47e..3c32242 100644 --- a/.claude/self/pr-loop-self.md +++ b/.claude/self/pr-loop-self.md @@ -25,18 +25,19 @@ Prompt to use (the tick logic, with adaptive STEP 0): > Run one tick of the self-hosted PR loop. Export `GATES_FILE=.claude/self/gates.json` for every gate/orchestration step, and instruct every spawned agent (orchestrator, implementers, reviewers) to read `.claude/self/gates.json` — NOT the placeholder root `.claude/gates.json` — as its adapter (module map, gates, review lenses). Reviewer lenses + consensus per `.claude/self/gates.json` (`correctness`, `tests`; consensus `all`). Every `gate.sh` invocation MUST be run as `GATES_FILE=.claude/self/gates.json bash ${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/gate.sh <name>`. 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. > -> STEP 0 — run the tick. Invoke, as a REAL bash tool call, exactly: `GATES_FILE=.claude/self/gates.json 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. (loop-census.sh reads `$GATES_FILE` directly, so it derives module labels from the self set — `module:docs`, `module:harness`, `module:examples`, `module:ci` — not the placeholder root adapter.) +> STEP 0 — run the tick. Invoke, as a REAL bash tool call, exactly: `GATES_FILE=.claude/self/gates.json 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. (loop-census.sh reads `$GATES_FILE` directly, so it derives module labels from the self set — `module:docs`, `module:harness`, `module:examples`, `module:ci` — not the placeholder root adapter.) > -> 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. +> 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. > -> 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): +> 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): > - `action=feedback pr=N` → address PR N's feedback: run orchestrator → worktree implementer → reviewer lenses (self adapter: `GATES_FILE=.claude/self/gates.json`, lenses `correctness`/`tests`, consensus `all`) 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. -> - `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 (self adapter, same lenses/consensus as feedback), 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. +> - `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 (self adapter, same lenses/consensus as feedback), 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. > - `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 (self adapter, same lenses/consensus as feedback) 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. +> - `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/main`. On a CLEAN rebase: `git push --force-with-lease`, rerun the self adapter's gates (`GATES_FILE=.claude/self/gates.json bash .claude/scripts/gate.sh <name>`), 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. > - `action=advance issue=N` → advance issue N through the orchestrator using `.claude/self/gates.json` as the adapter (scope → worktree implementer → `GATES_FILE=.claude/self/gates.json gate.sh` gates → reviewer lenses `correctness`/`tests` consensus `all` → 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"). > - `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. > -> 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. +> 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. ## 2. Run one tick now 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").