diff --git a/.claude/commands/pr-loop.md b/.claude/commands/pr-loop.md index b1611fb..311f892 100644 --- a/.claude/commands/pr-loop.md +++ b/.claude/commands/pr-loop.md @@ -23,12 +23,13 @@ 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, 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`, 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, 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, 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. > -> CADENCE: the script's `=== 1/5 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/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. > -> Then obey the verdict line (the tick already ran poll/merge/feedback-detection/ci-fix-detection above — do not re-run those scripts). Precedence when more than one is ready: `feedback` > `ci-fix` > `advance` (a PR with BOTH unaddressed feedback and failing CI is handled as feedback, never ci-fix): +> 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): > - `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=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=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. diff --git a/.claude/gates.json b/.claude/gates.json index 121a101..f7b3eae 100644 --- a/.claude/gates.json +++ b/.claude/gates.json @@ -73,6 +73,9 @@ "_notify_note": "issue #99 — needs-human push-notification seam, read by .claude/scripts/notify.sh (GATES_FILE-aware; same empty-means-skip convention as `gates`). Empty string = disabled (offline/CI-safe no-op) — the default; fill in a command to enable push notifications when the autonomous loop blocks on YOU (PR ready for review, CHANGES_REQUESTED addressed and awaiting re-review, attempt-budget/stall escalation, ...). Contract: severity/title/body-line reach the command BOTH as positional args ($1/$2/$3) and as NOTIFY_SEVERITY/NOTIFY_TITLE/NOTIFY_BODY env vars; throttled to one notification per (kind,target) per window (default 1800s — see notify.sh). Example commands (uncomment/adapt ONE):\n ntfy: curl -s -d \"$NOTIFY_BODY\" -H \"Title: $NOTIFY_TITLE\" -H \"Priority: $NOTIFY_SEVERITY\" ntfy.sh/\n notify-send: notify-send \"$NOTIFY_TITLE\" \"$NOTIFY_BODY\"\n webhook: curl -s -X POST -H 'Content-Type: application/json' -d \"{\\\"severity\\\":\\\"$NOTIFY_SEVERITY\\\",\\\"title\\\":\\\"$NOTIFY_TITLE\\\",\\\"body\\\":\\\"$NOTIFY_BODY\\\"}\" https://example.invalid/hook", "notify": "", + "_commentFix_note": "issue #96 (part 2) — bot-commenter allowlist for the comment-fix reaction, read by .claude/scripts/pr-comment-fix.sh. commentFix.botAllowlist: an array of bot GitHub logins whose inline PR review comments qualify a thread for automated comment-fix, in ADDITION to the repo owner (who always qualifies, regardless of this list). Empty array (the default, BOTH shipped adapters) = disabled — only the owner's own review threads are ever comment-fixed. Fill in bot logins (e.g. a linter/reviewer bot account) to let their unresolved review threads trigger the same automated fix-attempt path.", + "commentFix": { "botAllowlist": [] }, + "_plan_note": "issue #100 — optional spec/plan gate before implementation starts. plan.gate: off (default; today's single-pass advance behavior, unchanged) | label (gate only planned issues that ALSO carry a plan-first label) | always (gate every planned+module:* issue). Unknown/missing value falls back to off. When gated, the loop posts ONE structured plan comment on the issue (marked ``) and labels it plan-review + needs-human instead of implementing; the owner reviews the comment and either replaces plan-review with plan-approved (approve — the next tick implements with the plan injected into the implementer and reviewers as authoritative scope) or removes plan-review (request changes — the loop re-plans). Owner-only approval is NOT identity-enforced — same limitation as the `planned` label itself (see docs/USAGE.md). Read by loop-census.sh/loop-tick.sh/loop-event.sh.", "plan": { "gate": "off" } } diff --git a/.claude/scripts/loop-ceilings.test.sh b/.claude/scripts/loop-ceilings.test.sh index d337329..89218d9 100644 --- a/.claude/scripts/loop-ceilings.test.sh +++ b/.claude/scripts/loop-ceilings.test.sh @@ -41,9 +41,11 @@ check() { # all, matching loop-tick.test.sh's own "gh must never be invoked" contract # for scenarios that expect zero gh side effects). $5=fake_cifix (TSV body, # issue #96 -- defaults to empty, i.e. no ci-fix candidates, so every existing -# 3/4-arg call site keeps working unchanged). +# 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). new_fixture() { - local name="$1" fake_census="$2" fake_feedback="$3" with_gh="${4:-0}" fake_cifix="${5:-}" + local name="$1" fake_census="$2" fake_feedback="$3" with_gh="${4:-0}" fake_cifix="${5:-}" fake_commentfix="${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 @@ -81,6 +83,12 @@ EOF cat <<'CIFIX' $fake_cifix CIFIX +EOF + cat > "$dir/pr-comment-fix.sh" < branch= title= @@ -310,6 +315,9 @@ echo "feedback_prs=$feedback_prs" ci_fix_prs=$(bash "$script_dir/pr-ci-fix.sh" "$repo" | grep -c . || true) 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" + # 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) @@ -461,7 +469,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 ] || [ "$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 ] || { [ "$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 25d9acf..d70cb81 100644 --- a/.claude/scripts/loop-census.test.sh +++ b/.claude/scripts/loop-census.test.sh @@ -111,6 +111,10 @@ cat > "$scripts_dir/pr-ci-fix.sh" <<'EOF' #!/usr/bin/env bash exit 0 EOF +cat > "$scripts_dir/pr-comment-fix.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. @@ -204,6 +208,7 @@ check "exactly one in_flight line total (only issue 42 qualifies)" bash -c '[ "$ check "planned_issues=4 counted" bash -c 'printf "%s\n" "$1" | grep -qx "planned_issues=4"' _ "$out" 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" # --------------------------------------------------------------------------- # ci_fix_prs (issue #96): loop-census.sh must surface pr-ci-fix.sh's own @@ -229,6 +234,19 @@ git -C "$fixture" -c user.email=t@e.st -c user.name=t commit -q -m "swap in ci_f outCiFix="$(env -u GATES_FILE bash "$scripts_dir/loop-census.sh" "acme/repo")" check "ci_fix_prs=2 counted when pr-ci-fix.sh reports two candidates" bash -c 'printf "%s\n" "$1" | grep -qx "ci_fix_prs=2"' _ "$outCiFix" +# --------------------------------------------------------------------------- +# comment_fix_prs (issue #96 part 2): same wrapping contract as ci_fix_prs +# above, exercised against pr-comment-fix.sh instead. +# --------------------------------------------------------------------------- +cat > "$scripts_dir/pr-comment-fix.sh" <<'EOF' +#!/usr/bin/env bash +printf '20\tfeat/issue-20-a\tTABC:1\tsha20\n' +EOF +git -C "$fixture" add .claude/scripts/pr-comment-fix.sh +git -C "$fixture" -c user.email=t@e.st -c user.name=t commit -q -m "swap in comment_fix_prs stub (test fixture)" +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" + # --------------------------------------------------------------------------- # 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 @@ -263,6 +281,10 @@ EOF cat > "$scripts/pr-ci-fix.sh" <<'EOF' #!/usr/bin/env bash exit 0 +EOF + cat > "$scripts/pr-comment-fix.sh" <<'EOF' +#!/usr/bin/env bash +exit 0 EOF cat > "$scripts/bot-gh.sh" <<'EOF' #!/usr/bin/env bash @@ -354,7 +376,11 @@ EOF #!/usr/bin/env bash exit 0 EOF - chmod +x "$scripts/pr-feedback.sh" "$scripts/pr-ci-fix.sh" "$scripts/cockpit.sh" "$scripts/loop-census.sh" + cat > "$scripts/pr-comment-fix.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/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 } @@ -598,6 +624,10 @@ cat > "$scriptsStall/pr-ci-fix.sh" <<'EOF' #!/usr/bin/env bash exit 0 EOF +cat > "$scriptsStall/pr-comment-fix.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 31ab151..6bb1478 100644 --- a/.claude/scripts/loop-daemon.sh +++ b/.claude/scripts/loop-daemon.sh @@ -373,46 +373,52 @@ verify_and_classify_post_exit() { printf '%s' "$out" } -# --- transient systemd unit naming (issue #119 pt 1; issue #96 pt ci-fix) --- +# --- transient systemd unit naming (issue #119 pt 1; issue #96 pt ci-fix; +# issue #96 part 2 pt comment-fix) ------------------------------------------- # $1=verdict -> "pr-loop-driver-issue<N>" for "advance issue=N", -# "pr-loop-driver-pr<N>" for "feedback pr=N", or -# "pr-loop-driver-cifix-pr<N>" for "ci-fix 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 gets its OWN distinct unit name (not "pr-loop-driver-pr<N>") so a -# feedback driver and a ci-fix driver on the SAME PR can never collide in -# naming or reattach — the verdict precedence (feedback > ci-fix) makes both -# firing for the same PR in the same tick impossible, but a feedback driver -# from a PRIOR tick could still be finishing up while a later tick, after that -# feedback was addressed, dispatches a ci-fix driver for the identical PR -# number; distinct unit names keep those two spawns/reattaches from ever being +# "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 +# (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. driver_unit_name() { case "$1" in - "advance issue="*) printf 'pr-loop-driver-issue%s' "${1#advance issue=}" ;; - "ci-fix pr="*) printf 'pr-loop-driver-cifix-pr%s' "${1#ci-fix pr=}" ;; - "feedback pr="*) printf 'pr-loop-driver-pr%s' "${1#feedback pr=}" ;; - *) printf 'pr-loop-driver-unknown' ;; + "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=}" ;; + "feedback pr="*) printf 'pr-loop-driver-pr%s' "${1#feedback pr=}" ;; + *) printf 'pr-loop-driver-unknown' ;; esac } # --- reverse of driver_unit_name: unit name -> verdict (issue #119 pt 3) ---- # Prints nothing (not an error) for a unit name that doesn't match the # expected naming convention — reattach_orphaned_drivers skips those rather -# than guessing. The `pr-loop-driver-cifix-pr*` arm MUST be checked before -# `pr-loop-driver-pr*` would even matter for disambiguation (it doesn't here — -# "cifix-pr..." never matches the "pr..." prefix pattern either way — but the -# ordering keeps the two ci-fix/feedback arms visually adjacent to their -# distinct name shapes in driver_unit_name above, so the pairing stays obvious -# on read). +# than guessing. The `pr-loop-driver-cifix-pr*`/`pr-loop-driver-commentfix-pr*` +# arms MUST be checked before `pr-loop-driver-pr*` would even matter for +# disambiguation (it doesn't here — neither "cifix-pr..." nor +# "commentfix-pr..." ever matches the plain "pr..." prefix pattern either +# way — but the ordering keeps all three non-advance arms visually adjacent to +# their distinct name shapes in driver_unit_name above, so the pairing stays +# obvious on read). verdict_from_unit_name() { local unit="${1%.service}" case "$unit" in - 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-pr*) printf 'feedback pr=%s' "${unit#pr-loop-driver-pr}" ;; - *) : ;; + 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-pr*) printf 'feedback pr=%s' "${unit#pr-loop-driver-pr}" ;; + *) : ;; esac } @@ -679,7 +685,7 @@ run_once() { none|"") : # nothing actionable — no driver spawned ;; - "advance issue="*|"feedback pr="*|"ci-fix pr="*) + "advance issue="*|"feedback pr="*|"comment-fix pr="*|"ci-fix 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 61f81cb..7cc92b4 100644 --- a/.claude/scripts/loop-daemon.test.sh +++ b/.claude/scripts/loop-daemon.test.sh @@ -157,11 +157,15 @@ check "verify_and_classify_post_exit: spawn-error rc=127 passes extra through un vp_cifix="$(verify_and_classify_post_exit 'ci-fix pr=23' 0 'result=exit rc=0')" check "verify_and_classify_post_exit: ci-fix verdict passes extra through unchanged (issue #96 -- no fresh branch/worktree to classify)" [ "$vp_cifix" = "result=exit rc=0" ] -# --- driver_unit_name / verdict_from_unit_name (issue #119 pt 1; issue #96) -- -# ci-fix gets its OWN distinct unit name (pr-loop-driver-cifix-pr<N>, not the -# feedback shape pr-loop-driver-pr<N>) so a feedback 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. +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" ] + +# --- 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. 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')" @@ -169,6 +173,9 @@ check "driver_unit_name: feedback pr=9 -> pr-loop-driver-pr9" [ "$un_feedback" = un_cifix="$(driver_unit_name 'ci-fix pr=9')" check "driver_unit_name: ci-fix pr=9 -> pr-loop-driver-cifix-pr9 (distinct from feedback's same PR number)" [ "$un_cifix" = "pr-loop-driver-cifix-pr9" ] check "driver_unit_name: ci-fix and feedback unit names for the SAME PR number never collide" bash -c '[ "$1" != "$2" ]' _ "$un_cifix" "$un_feedback" +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" 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" ] @@ -176,6 +183,8 @@ vun_feedback="$(verdict_from_unit_name 'pr-loop-driver-pr9.service')" check "verdict_from_unit_name: pr-loop-driver-pr9.service -> feedback pr=9 (exact inverse)" [ "$vun_feedback" = "feedback pr=9" ] 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" ] # ============================================================================= # (B) Integration checks: real subprocess, fake loop-event.sh + fake claude. @@ -381,6 +390,38 @@ check "scenario 4b (ci-fix): ledger records verdict=ci-fix pr=23" bash -c ' ' _ "$ledger4b" check "scenario 4b: prompt file was cleaned up after the driver ran" [ ! -f "$prompt4b_dir/prompt.txt" ] +# --------------------------------------------------------------------------- +# 4c. action=comment-fix pr=N (issue #96 part 2): same driver path as +# feedback/ci-fix, a distinct verdict text, and its own transient unit +# name (pr-loop-driver-commentfix-pr<N>, not pr-loop-driver-pr<N> or +# pr-loop-driver-cifix-pr<N>) so it can never collide with a feedback or +# ci-fix driver on the same PR number. +# --------------------------------------------------------------------------- +prompt4c_dir="$work/scenario4c-support" +mkdir -p "$prompt4c_dir" +printf 'Run the COMMENT-FIX step for PR #14. +' > "$prompt4c_dir/prompt.txt" +dir4c="$(new_fixture scenario4c "#!/usr/bin/env bash +echo 'cadence=FAST cron=* * * * *' +echo 'loop-event: action=comment-fix pr=14' +echo 'loop-event: model=sonnet' +echo 'loop-event: prompt-file=$prompt4c_dir/prompt.txt' +exit 0")" +fake_bin "$dir4c" setsid '#!/usr/bin/env bash +exec "$@"' +fake_bin "$dir4c" timeout '#!/usr/bin/env bash +shift; shift +exec "$@"' +fake_bin "$dir4c" claude '#!/usr/bin/env bash +echo "{\"session_id\":\"sess-commentfix-14\"}" +exit 0' +run_daemon_once "$dir4c" >/dev/null 2>&1 +ledger4c="$dir4c/.claude/state/loop-runs.log" +check "scenario 4c (comment-fix): ledger records verdict=comment-fix pr=14" bash -c ' + grep -Eq "^pid=[0-9]+ session=sess-commentfix-14 verdict=comment-fix pr=14 ts=[0-9T:Z-]+ result=exit rc=0$" "$1" +' _ "$ledger4c" +check "scenario 4c: prompt file was cleaned up after the driver ran" [ ! -f "$prompt4c_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 0426c79..abefcb4 100644 --- a/.claude/scripts/loop-event.sh +++ b/.claude/scripts/loop-event.sh @@ -23,6 +23,7 @@ # -> nothing else is printed. NO model/driver process must be spawned. # loop-event: action=advance issue=N # 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: model=<model> # loop-event: prompt-file=<absolute path to a plain-text file holding the @@ -85,6 +86,7 @@ case "$verdict" in ;; "action=advance issue="*) n="${verdict#action=advance issue=}" ;; "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=}" ;; *) echo "loop-event: unexpected verdict line: $verdict" >&2 @@ -151,9 +153,14 @@ Drive issue #$n through the orchestrator: scope → worktree implementer → gat ;; esac ;; + 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." + 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 \`5/5 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/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." action_line="action=ci-fix pr=$n" ;; *) diff --git a/.claude/scripts/loop-event.test.sh b/.claude/scripts/loop-event.test.sh index 745344f..a0dc187 100644 --- a/.claude/scripts/loop-event.test.sh +++ b/.claude/scripts/loop-event.test.sh @@ -144,6 +144,32 @@ check "scenario 3b: prompt file mandates FOREGROUND-only spawn (run_in_backgroun check "scenario 3b: 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"' _ "$pf3b" check "scenario 3b: prompt file mandates deleting debris on failure" bash -c 'grep -q "delete any local feat/issue-N-\* branch and worktree" "$1"' _ "$pf3b" +# --------------------------------------------------------------------------- +# 3c. action=comment-fix pr=N (issue #96 part 2) -> same one-shot contract, +# COMMENT-FIX wording, in-flight-guard/marker-comment instructions, +# LOOP_MODEL honored. +# --------------------------------------------------------------------------- +dir3c="$(new_fixture scenario3c 'cadence=FAST cron=* * * * * +action=comment-fix pr=14')" +out3c="$(cd "$dir3c" && PATH="/usr/bin:/bin" LOOP_MODEL=opus bash .claude/scripts/loop-event.sh)"; rc3c=$? +check "scenario 3c (comment-fix): exits 0" [ "$rc3c" -eq 0 ] +check "scenario 3c: emits loop-event: action=comment-fix pr=14" bash -c 'printf "%s +" "$1" | grep -qxF "loop-event: action=comment-fix pr=14"' _ "$out3c" +check "scenario 3c: LOOP_MODEL is honored (model=opus)" bash -c 'printf "%s +" "$1" | grep -qxF "loop-event: model=opus"' _ "$out3c" +pf3c="$(printf '%s +' "$out3c" | sed -n 's/^loop-event: prompt-file=//p')" +check "scenario 3c: prompt file mentions PR #14" bash -c 'grep -q "PR #14" "$1"' _ "$pf3c" +check "scenario 3c: prompt file says COMMENT-FIX step, and Do NOT merge" bash -c 'grep -q "COMMENT-FIX step" "$1" && grep -q "Do NOT merge" "$1"' _ "$pf3c" +check "scenario 3c: prompt file mandates the claude-comment-fixing in-flight guard label" bash -c 'grep -q "claude-comment-fixing" "$1"' _ "$pf3c" +check "scenario 3c: prompt file mandates the claude-comment-addressed marker comment keyed to thread+attempt" bash -c 'grep -q "claude-comment-addressed" "$1"' _ "$pf3c" +check "scenario 3c: prompt file mandates resolving ONLY the threads actually addressed" bash -c 'grep -qi "ONLY those" "$1"' _ "$pf3c" + +# Same one-shot contract clauses, on the COMMENT-FIX prompt this time. +check "scenario 3c: prompt file mandates FOREGROUND-only spawn (run_in_background: false)" bash -c 'grep -qi "FOREGROUND" "$1" && grep -q "run_in_background: false" "$1"' _ "$pf3c" +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" + # --------------------------------------------------------------------------- # 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 e7780f1..30dad60 100644 --- a/.claude/scripts/loop-tick.sh +++ b/.claude/scripts/loop-tick.sh @@ -1,12 +1,13 @@ #!/usr/bin/env bash # loop-tick.sh — one-shot orchestration tick for the autonomous PR loop. # -# Runs the loop's five step scripts, IN ORDER, with their FULL output +# Runs the loop's six 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 # action=advance issue=N # action=feedback pr=N +# action=comment-fix pr=N (issue #96 part 2 -- see PRECEDENCE below) # action=ci-fix pr=N # action=resume issue=N branch=<name> (issue #98 -- see STEP 0.5 below) # @@ -26,15 +27,25 @@ # adds the verdict arithmetic + the spawn lock (see # .claude/state/loop-advance.lock below). # -# Precedence (issue #96): feedback > ci-fix > advance > resume. +# Precedence (issue #96): feedback > comment-fix > ci-fix > 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. -# - CI-FIX (pr-ci-fix.sh) wins over ADVANCE, but never over feedback: a PR -# that is BOTH a feedback candidate AND has failing CI is handled as -# feedback, never ci-fix (pr-ci-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 CI fix, the +# - 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. # - ADVANCE additionally requires: census says advance_ready=N (already # means zero open PRs + a planned+module issue + no existing branch), N is @@ -138,6 +149,7 @@ write_tick_record() { case "$verdict" in "action=advance issue="*) action="advance"; issue="${verdict#action=advance issue=}" ;; "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=resume issue="*) action="resume" @@ -203,21 +215,25 @@ write_tick_record() { return 0 } -echo "=== 1/5 loop-census.sh ===" +echo "=== 1/6 loop-census.sh ===" census_out="$(bash "$script_dir/loop-census.sh" "$repo")" printf '%s\n' "$census_out" -echo "=== 2/5 notify-poll.sh ===" +echo "=== 2/6 notify-poll.sh ===" bash "$script_dir/notify-poll.sh" "$repo" -echo "=== 3/5 merge-ready.sh ===" +echo "=== 3/6 merge-ready.sh ===" bash "$script_dir/merge-ready.sh" "$repo" -echo "=== 4/5 pr-feedback.sh ===" +echo "=== 4/6 pr-feedback.sh ===" feedback_out="$(bash "$script_dir/pr-feedback.sh" "$repo")" printf '%s\n' "$feedback_out" -echo "=== 5/5 pr-ci-fix.sh ===" +echo "=== 5/6 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 ===" cifix_out="$(bash "$script_dir/pr-ci-fix.sh" "$repo")" printf '%s\n' "$cifix_out" @@ -249,11 +265,27 @@ feedback_branch="$(printf '%s\n' "$feedback_line" | awk -F'\t' '{print $2}')" # number itself when the branch doesn't follow that convention. feedback_issue="$(printf '%s\n' "$feedback_branch" | sed -n 's#.*feat/issue-\([0-9][0-9]*\)-.*#\1#p')" +# --- Parse pr-comment-fix.sh's TSV (num, branch, thread_ids:attempts_csv, +# head_sha) --- Lowest-numbered PR wins when several need a comment fix, same +# tie-break as feedback above. pr-comment-fix.sh already excludes feedback +# candidates from its own output (precedence, issue #96 part 2), so no +# additional filtering is needed here beyond the verdict decision below. +commentfix_line="$(printf '%s\n' "$commentfix_out" | awk -F'\t' 'NF>=2 && $1 ~ /^[0-9]+$/ {print $1"\t"$2}' | sort -t $'\t' -k1,1n | head -1)" +commentfix_pr="$(printf '%s\n' "$commentfix_line" | awk -F'\t' '{print $1}')" +commentfix_branch="$(printf '%s\n' "$commentfix_line" | awk -F'\t' '{print $2}')" +# Same #95 per-issue attempt budget key derivation as feedback_issue above — +# comment-fix dispatches for issue N share the SAME counter as +# advance/feedback/ci-fix dispatches for issue N (do NOT invent a new counter +# file for THIS budget; the separate per-THREAD retry budget is tracked +# entirely inside pr-comment-fix.sh itself via the claude-comment-addressed +# marker's embedded attempt number — see that script's header doc). +commentfix_issue="$(printf '%s\n' "$commentfix_branch" | sed -n 's#.*feat/issue-\([0-9][0-9]*\)-.*#\1#p')" + # --- Parse pr-ci-fix.sh's TSV (num, branch, failing_checks_csv, head_sha) --- # Lowest-numbered PR wins when several need a CI fix, same tie-break as -# feedback above. pr-ci-fix.sh already excludes feedback candidates from its -# own output (precedence, issue #96), so no additional filtering is needed -# here beyond the verdict decision below. +# feedback above. pr-ci-fix.sh already excludes feedback AND comment-fix +# candidates from its own output (precedence, issue #96), so no additional +# filtering is needed here beyond the verdict decision below. cifix_line="$(printf '%s\n' "$cifix_out" | awk -F'\t' 'NF>=2 && $1 ~ /^[0-9]+$/ {print $1"\t"$2}' | sort -t $'\t' -k1,1n | head -1)" cifix_pr="$(printf '%s\n' "$cifix_line" | awk -F'\t' '{print $1}')" cifix_branch="$(printf '%s\n' "$cifix_line" | awk -F'\t' '{print $2}')" @@ -518,21 +550,30 @@ if [ -z "$ceiling_block" ] && [ "$daily_count" -ge "$daily_action_ceiling" ]; th fi fi -# --- 3) per-issue advance/feedback/ci-fix attempt budget --------------------- +# --- 3) per-issue advance/feedback/comment-fix/ci-fix attempt budget --------- # .claude/state/loop-issue-attempts.json: { "<issue>": {attempts,escalated} }. # Keyed by the ORIGINATING issue number (advance_ready directly; feedback/ -# ci-fix via feedback_issue/cifix_issue, parsed from the PR's feat/issue-N-* -# branch) so advance-phase, feedback-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 ci-fix beats advance; in_flight/ -# lock-held advance candidates are never charged). +# 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. attempts_file="$state_dir/loop-issue-attempts.json" attempt_issue="" attempt_escalate_kind="" attempt_escalate_num="" if [ -n "$feedback_pr" ]; then attempt_issue="${feedback_issue:-$feedback_pr}" attempt_escalate_kind="pr" attempt_escalate_num="$feedback_pr" +elif [ -n "$commentfix_pr" ]; then + attempt_issue="${commentfix_issue:-$commentfix_pr}" + attempt_escalate_kind="pr" + attempt_escalate_num="$commentfix_pr" elif [ -n "$cifix_pr" ]; then attempt_issue="${cifix_issue:-$cifix_pr}" attempt_escalate_kind="pr" @@ -739,6 +780,8 @@ if [ -n "$ceiling_block" ]; then verdict="action=none" elif [ -n "$feedback_pr" ]; then verdict="action=feedback pr=$feedback_pr" +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 [ "$advance_ready" != "none" ] && [ -n "$advance_ready" ]; then @@ -873,6 +916,14 @@ case "$verdict" in dispatch_issue="$dispatch_pr" fi ;; + "action=comment-fix pr="*) + dispatch_pr="${verdict#action=comment-fix pr=}" + if [ "$dispatch_pr" = "$commentfix_pr" ] && [ -n "${commentfix_issue:-}" ]; then + dispatch_issue="$commentfix_issue" + else + dispatch_issue="$dispatch_pr" + fi + ;; "action=ci-fix pr="*) dispatch_pr="${verdict#action=ci-fix pr=}" if [ "$dispatch_pr" = "$cifix_pr" ] && [ -n "${cifix_issue:-}" ]; then diff --git a/.claude/scripts/loop-tick.test.sh b/.claude/scripts/loop-tick.test.sh index 6eb9271..afe5f31 100644 --- a/.claude/scripts/loop-tick.test.sh +++ b/.claude/scripts/loop-tick.test.sh @@ -35,13 +35,13 @@ check() { } # Build one fresh fake "consumer project" per scenario: <fixture>/.claude/scripts/. -# fake_census / fake_feedback / fake_cifix 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 defaults to empty (no ci-fix candidates) so every -# existing 3-arg call site keeps working unchanged. +# 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. new_fixture() { - local name="$1" fake_census="$2" fake_feedback="$3" fake_cifix="${4:-}" + local name="$1" fake_census="$2" fake_feedback="$3" fake_cifix="${4:-}" fake_commentfix="${5:-}" 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 @@ -80,6 +80,12 @@ EOF cat <<'CIFIX' $fake_cifix CIFIX +EOF + cat > "$dir/pr-comment-fix.sh" <<EOF +#!/usr/bin/env bash +cat <<'COMMENTFIX' +$fake_commentfix +COMMENTFIX EOF chmod +x "$dir"/*.sh printf '%s\n' "$dir" @@ -183,14 +189,15 @@ 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 five step scripts' full output is preserved (never swallowed). +# 7. All six step scripts' full output is preserved (never swallowed). # --------------------------------------------------------------------------- -check "all five labeled step headers appear in the tick's output" bash -c ' - printf "%s\n" "$1" | grep -q "1/5 loop-census.sh" && - printf "%s\n" "$1" | grep -q "2/5 notify-poll.sh" && - printf "%s\n" "$1" | grep -q "3/5 merge-ready.sh" && - printf "%s\n" "$1" | grep -q "4/5 pr-feedback.sh" && - printf "%s\n" "$1" | grep -q "5/5 pr-ci-fix.sh" +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" ' _ "$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" @@ -364,6 +371,66 @@ check "scenario 12d: action=ci-fix tick record captures pr number and cadence" n if (obj.cadence !== "WATCH") throw new Error("cadence mismatch: " + JSON.stringify(obj)); ' "$ticks12d" +# --------------------------------------------------------------------------- +# 12e. Comment-fix candidate present, no feedback, nothing advance_ready, no +# ci-fix -> picked as action=comment-fix pr=N (issue #96 part 2), lowest- +# numbered PR wins among comment-fix candidates, and (mirroring scenario +# 12b's "no spawn lock" for ci-fix) never writes the advance spawn lock. +# --------------------------------------------------------------------------- +dir12e="$(new_fixture scenario12e 'open_prs=1 +feedback_prs=0 +planned_issues=0 +advance_ready=none +cadence=WATCH cron=*/5 * * * *' '' '' "$(printf '9\tfeat/issue-9-x\tTABC:1\tsha9 +4\tfeat/issue-4-y\tTDEF:1\tsha4')")" +out12e="$(run_tick "$dir12e")" +check "scenario 12e (comment-fix, lowest-numbered PR wins): verdict is action=comment-fix pr=4" bash -c '[ "$(printf "%s\n" "$1" | tail -1)" = "action=comment-fix pr=4" ]' _ "$out12e" +check "scenario 12e: no spawn lock written (advance never attempted)" [ ! -e "$dir12e/../state/loop-advance.lock" ] + +# --------------------------------------------------------------------------- +# 12f. Comment-fix wins over an ALSO-ready ci-fix AND an also-ready advance +# (issue #96 part 2 precedence: comment-fix > ci-fix > advance). +# --------------------------------------------------------------------------- +dir12f="$(new_fixture scenario12f '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\tTABC:1\tsha11')")" +out12f="$(run_tick "$dir12f")" +check "scenario 12f (comment-fix beats an also-ready ci-fix and advance): verdict is action=comment-fix pr=11" bash -c '[ "$(printf "%s\n" "$1" | tail -1)" = "action=comment-fix pr=11" ]' _ "$out12f" +check "scenario 12f: no spawn lock written (advance never attempted)" [ ! -e "$dir12f/../state/loop-advance.lock" ] + +# --------------------------------------------------------------------------- +# 12g. Feedback still wins over an also-ready comment-fix (issue #96 part 2 +# precedence: feedback > comment-fix), same shape as scenario 4's +# feedback-beats-advance check. +# --------------------------------------------------------------------------- +dir12g="$(new_fixture scenario12g 'open_prs=1 +feedback_prs=1 +planned_issues=0 +advance_ready=none +cadence=FAST cron=* * * * *' "$(printf '3\tfeat/issue-3-x\towner\t2026-01-01T00:00:00Z')" '' "$(printf '6\tfeat/issue-6-x\tTABC:1\tsha6')")" +out12g="$(run_tick "$dir12g")" +check "scenario 12g (feedback beats an also-ready comment-fix): verdict is action=feedback pr=3" bash -c '[ "$(printf "%s\n" "$1" | tail -1)" = "action=feedback pr=3" ]' _ "$out12g" + +# action=comment-fix tick record: pr number captured, cadence round-trips. +dir12h="$(new_fixture scenario12h 'open_prs=1 +feedback_prs=0 +planned_issues=0 +advance_ready=none +cadence=WATCH cron=*/5 * * * *' '' '' "$(printf '8\tfeat/issue-8-x\tTABC:1\tsha8')")" +ticks12h="$work/scenario12h-ticks.jsonl" +out12h="$(CLAUDE_TICKS_FILE="$ticks12h" run_tick "$dir12h")" +check "scenario 12h: verdict is still the LAST stdout line for action=comment-fix" bash -c '[ "$(printf "%s\n" "$1" | tail -1)" = "action=comment-fix pr=8" ]' _ "$out12h" +check "scenario 12h: action=comment-fix 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 !== "comment-fix") throw new Error("action mismatch: " + JSON.stringify(obj)); + if (obj.pr !== "8") throw new Error("pr mismatch: " + JSON.stringify(obj)); + if (obj.cadence !== "WATCH") throw new Error("cadence mismatch: " + JSON.stringify(obj)); +' "$ticks12h" + # --------------------------------------------------------------------------- # 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 @@ -749,6 +816,10 @@ EOF cat > "$scripts/pr-ci-fix.sh" <<'EOF' #!/usr/bin/env bash exit 0 +EOF + cat > "$scripts/pr-comment-fix.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-ci-fix.sh b/.claude/scripts/pr-ci-fix.sh index ff805b7..1df68e4 100755 --- a/.claude/scripts/pr-ci-fix.sh +++ b/.claude/scripts/pr-ci-fix.sh @@ -17,12 +17,16 @@ # EXACT parse merge-ready.sh's `decide()` uses for the same rollup shape. # PENDING/in-progress checks do NOT qualify (a re-run in flight means # "wait", not "fix"); -# - it is NOT ALSO a pr-feedback.sh candidate. VERDICT PRECEDENCE (issue -# #96): feedback > ci-fix > advance — owner CHANGES_REQUESTED always -# outranks a CI fix, so a PR that is both red-CI and awaiting an -# unaddressed review is left entirely to pr-feedback.sh here (excluded, -# not merely deprioritized) and the caller (loop-tick.sh) additionally -# enforces the same precedence at the verdict level; +# - it is NOT ALSO a pr-feedback.sh candidate, and NOT ALSO a +# pr-comment-fix.sh candidate. VERDICT PRECEDENCE (issue #96, part 2): +# feedback > comment-fix > ci-fix > advance — owner CHANGES_REQUESTED +# always outranks a CI fix, and an unresolved qualifying review-comment +# thread outranks a CI fix too (the reopened conversation is addressed +# before chasing a possibly-unrelated CI failure), so a PR that is both +# red-CI and awaiting EITHER kind of review action is left entirely to +# pr-feedback.sh/pr-comment-fix.sh 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 loop's #95 attempt-budget escalation # already gave up on this PR/issue — see loop-tick.sh's per-issue attempt # budget) and not labeled `claude-ci-fixing` (an in-flight guard, mirroring @@ -62,8 +66,9 @@ 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 candidates OUTRANK ci-fix (precedence) — exclude their PR numbers -# up front so a PR that qualifies for both never shows up here at all. +# Feedback AND comment-fix candidates OUTRANK ci-fix (precedence) — exclude +# their PR numbers up front so a PR that qualifies for either 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() { @@ -73,6 +78,15 @@ is_feedback_candidate() { *) 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 +} gh pr list -R "$repo" --state open --base "$base" \ --json number,headRefName,author,labels,statusCheckRollup,headRefOid \ @@ -112,6 +126,7 @@ gh pr list -R "$repo" --state open --base "$base" \ [ "$guard_skip" = "1" ] && continue [ -z "$failing" ] && continue # no failing check on the current head -> nothing to fix is_feedback_candidate "$num" && continue + is_comment_fix_candidate "$num" && continue # Already-addressed cursor: skip if a bot comment carries the marker tied # to THIS EXACT head_sha (see header doc above). diff --git a/.claude/scripts/pr-ci-fix.test.sh b/.claude/scripts/pr-ci-fix.test.sh index 54a7c3b..3a471d3 100755 --- a/.claude/scripts/pr-ci-fix.test.sh +++ b/.claude/scripts/pr-ci-fix.test.sh @@ -50,6 +50,8 @@ cp "$resolve_roots_src" "$scripts_dir/resolve-roots.sh" # -> NOT emitted (already-addressed cursor) # 16: failing CI via the LEGACY StatusContext shape (state=ERROR, no # "conclusion" field at all) -> EMITTED (dual-shape parse, same as merge-ready.sh) +# 17: failing CI, ALSO a pr-comment-fix.sh candidate (issue #96 part 2) +# -> NOT emitted (precedence) cat > "$scripts_dir/bot-gh.sh" <<'EOF' #!/usr/bin/env bash case "$1" in @@ -63,6 +65,7 @@ case "$1" in {"number":14,"headRefName":"feat/issue-14-a","author":{"login":"testbot"},"labels":[],"statusCheckRollup":[{"name":"build","conclusion":"FAILURE"}],"headRefOid":"sha14"} {"number":15,"headRefName":"feat/issue-15-a","author":{"login":"testbot"},"labels":[],"statusCheckRollup":[{"name":"build","conclusion":"FAILURE"}],"headRefOid":"sha15"} {"number":16,"headRefName":"feat/issue-16-a","author":{"login":"testbot"},"labels":[],"statusCheckRollup":[{"context":"legacy-ci","state":"ERROR"}],"headRefOid":"sha16"} +{"number":17,"headRefName":"feat/issue-17-a","author":{"login":"testbot"},"labels":[],"statusCheckRollup":[{"name":"build","conclusion":"FAILURE"}],"headRefOid":"sha17"} JSON else echo "fake-bot-gh.sh: unexpected pr subcommand: $*" >&2 @@ -86,6 +89,13 @@ cat > "$scripts_dir/pr-feedback.sh" <<'EOF' printf '14\tfeat/issue-14-a\towner\t2026-01-01T00:00:00Z\n' EOF +# 17 is a comment-fix candidate (precedence, issue #96 part 2: comment-fix +# outranks ci-fix). +cat > "$scripts_dir/pr-comment-fix.sh" <<'EOF' +#!/usr/bin/env bash +printf '17\tfeat/issue-17-a\tTABC:1\tsha17\n' +EOF + chmod +x "$scripts_dir"/*.sh out="$(BOT_LOGIN=testbot bash "$scripts_dir/pr-ci-fix.sh" "acme/repo")" @@ -99,6 +109,7 @@ check "PR 14 (also a feedback candidate — precedence): NOT emitted" bash -c '! check "PR 15 (already-addressed marker for the current head): NOT emitted" bash -c '! printf "%s\n" "$1" | grep -qE "^15\b"' _ "$out" check "PR 16 (failing CI via legacy StatusContext shape): emitted" bash -c ' printf "%s\n" "$1" | grep -qF "$(printf "16\tfeat/issue-16-a\tlegacy-ci\tsha16")"' _ "$out" +check "PR 17 (also a comment-fix candidate -- precedence, issue #96 part 2): NOT emitted" bash -c '! printf "%s\n" "$1" | grep -qE "^17\b"' _ "$out" check "exactly 2 PRs emitted total (10 and 16 only)" bash -c '[ "$(printf "%s\n" "$1" | grep -c .)" -eq 2 ]' _ "$out" echo "" diff --git a/.claude/scripts/pr-comment-fix.sh b/.claude/scripts/pr-comment-fix.sh new file mode 100644 index 0000000..ec57f41 --- /dev/null +++ b/.claude/scripts/pr-comment-fix.sh @@ -0,0 +1,228 @@ +#!/usr/bin/env bash +# pr-comment-fix.sh — print open, bot-authored PRs that have an UNRESOLVED +# inline review-comment thread from the owner (or an allowlisted bot +# commenter) that hasn't already been addressed, so the loop can dispatch an +# implementer to push a fix onto the SAME branch. This is capability #2 of +# issue #96 (review-comment convergence); capability #1 (CI-failure fixing) +# is pr-ci-fix.sh, capability #3 (rebase-after-sibling-merge) is a follow-up. +# Prints one TSV line per PR needing a comment fix: +# <number>\t<branch>\t<thread_id>:<next_attempt>[,<thread_id>:<next_attempt>...]\t<head_sha> +# +# 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); +# - it is NOT ALSO a pr-feedback.sh candidate. VERDICT PRECEDENCE (issue +# #96): feedback > comment-fix > ci-fix > advance — owner +# CHANGES_REQUESTED always outranks a comment-fix dispatch, so a PR that +# is both a feedback candidate AND has unresolved review threads is left +# entirely to pr-feedback.sh here (excluded, not merely deprioritized), +# and loop-tick.sh additionally enforces the same precedence at the +# verdict level; +# - not labeled `needs-human` (the per-thread attempt budget below already +# gave up, or some OTHER escalation gave up, on this PR) and not labeled +# `claude-comment-fixing` (an in-flight guard, mirroring pr-ci-fix.sh's +# `claude-ci-fixing`, so overlapping ticks don't double-dispatch the SAME +# PR while a fix is already being worked); +# - it has at least one review thread (GraphQL `reviewThreads`) that is +# UNRESOLVED, has at least one comment from the repo OWNER (derived from +# the `owner/repo` slug, overridable via $OWNER_LOGIN for tests) or a +# bot commenter on the adapter's `commentFix.botAllowlist` (gates.json; +# empty array = disabled — only the owner qualifies by default; see +# gates.json's `_commentFix_note`), and hasn't already been addressed for +# its CURRENT state. +# +# ALREADY-ADDRESSED CURSOR + PER-THREAD ATTEMPT NUMBER (both derived from the +# marker itself, no extra state file — mirrors pr-ci-fix.sh's head_sha-keyed +# marker, and pr-feedback.sh's timestamp-cursor discipline): the implementer +# that fixes a thread posts a bot comment containing +# `<!-- claude-comment-addressed:<thread_id>:<attempt> -->` after pushing and +# resolving the threads it actually addressed. For a given thread: +# - no marker at all -> next attempt is 1, thread is a fresh candidate; +# - a marker exists and its comment's created_at is >= the thread's own +# newest comment's createdAt -> nothing has happened on the thread since +# the fix was posted (still unresolved because the reviewer hasn't +# re-reviewed yet, or GitHub's resolved state lags) -> skip this tick; +# - a marker exists but the thread has a NEWER comment since -> the thread +# REOPENED after a completed fix attempt -> next attempt is +# marker-attempt + 1. +# +# ANTI-LIVELOCK (bounded per-thread retries): once a thread has reopened +# after 2 completed fix attempts (i.e. its next attempt would be 3), this +# script escalates instead of emitting another candidate for it — applies the +# `needs-human` label to the PR via needs-human.sh's needs_human_flag (same +# seam pr-feedback.sh's own inline escalation uses), and drops that thread +# from this PR's candidate list. needs_human_flag's own "already labeled" +# check makes repeated escalation ticks a no-op (no repeat comment spam), and +# once the label lands, the very next tick's guard-label check above excludes +# the WHOLE PR from further comment-fix automation until a human clears it — +# by design: a thread that keeps reopening after fixes needs a human's +# judgment, not more automated churn. Other STILL-fixable threads on the same +# PR are not excluded from the CURRENT tick's output (only from the NEXT +# tick, once the label has landed) — one last dispatch can still land their +# fixes before automation halts on this PR. +# +# Repo derived from the git remote; override with $1. Bot login via +# $BOT_LOGIN. Owner login via $OWNER_LOGIN (default: the part of `owner/repo` +# before the slash). Adapter (baseBranch, commentFix.botAllowlist) via +# $GATES_FILE, default .claude/gates.json. Invoke as +# `bash .claude/scripts/pr-comment-fix.sh` (pre-approve that exact command). +# Mutates ONLY via needs-human.sh's needs_human_flag on a genuine escalation +# (label + one-time comment + throttled notify) — never resolves a thread, +# never posts a `claude-comment-addressed` marker itself; those are written +# by the driver this script's output goes on to dispatch. +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}" +owner_login="${OWNER_LOGIN:-${repo%%/*}}" +repo_name="${repo#*/}" +repo_owner="${repo%%/*}" + +# 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)" +allowlist_json="$(node -e ' + const g = require(process.argv[1]); + const list = (g.commentFix && Array.isArray(g.commentFix.botAllowlist)) ? g.commentFix.botAllowlist : []; + console.log(JSON.stringify(list)); +' "$gates" 2>/dev/null || echo '[]')" + +# Feedback candidates OUTRANK comment-fix (precedence) — exclude their PR +# numbers up front so a PR that qualifies for both never shows up here. +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 +} + +gh pr list -R "$repo" --state open --base "$base" \ + --json number,headRefName,author,labels,headRefOid \ + --jq '.[] | select(.author.login=="'"$bot"'")' \ +| while IFS= read -r pr_json; do + [ -z "$pr_json" ] && continue + + 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-comment-fixing"); + console.log([p.number, p.headRefName || "", p.headRefOid || "", guardSkip ? 1 : 0].join("\t")); + ')" + num="$(printf '%s' "$parsed" | cut -f1)" + branch="$(printf '%s' "$parsed" | cut -f2)" + head_sha="$(printf '%s' "$parsed" | cut -f3)" + guard_skip="$(printf '%s' "$parsed" | cut -f4)" + + [ -z "${num:-}" ] && continue + [ "$guard_skip" = "1" ] && continue + is_feedback_candidate "$num" && continue + + # Unresolved review threads for this PR, GraphQL (REST has no + # "resolved" concept for review-comment threads). Each node carries the + # thread id, isResolved, and every comment's author login + createdAt — + # enough to decide qualification/already-addressed/attempt-number below + # in a single node pass, no second network round-trip per thread. + threads_json="$(gh api graphql -f query=' + query($owner:String!, $name:String!, $number:Int!) { + repository(owner:$owner, name:$name) { + pullRequest(number:$number) { + reviewThreads(first:100) { + nodes { + id + isResolved + comments(first:100) { nodes { author { login } createdAt } } + } + } + } + } + }' -f owner="$repo_owner" -f name="$repo_name" -F number="$num" \ + --jq '.data.repository.pullRequest.reviewThreads.nodes' 2>/dev/null || echo '[]')" + [ -z "$threads_json" ] && threads_json='[]' + [ "$threads_json" = "null" ] && threads_json='[]' + + # This PR's own bot comments (the only place `claude-comment-addressed` + # markers are ever posted) -- fed to node alongside the threads so the + # already-addressed/attempt-number decision below is made once, in one + # process, per PR. + marker_comments_json="$(gh api "repos/$repo/issues/$num/comments" \ + --jq '[.[]|select(.user.login=="'"$bot"'")|{body:.body,created_at:.created_at}]' \ + 2>/dev/null || echo '[]')" + [ -z "$marker_comments_json" ] && marker_comments_json='[]' + + decisions="$(node -e ' + const threads = JSON.parse(process.argv[1] || "[]"); + const markers = JSON.parse(process.argv[2] || "[]"); + const owner = process.argv[3]; + const allowlist = new Set(JSON.parse(process.argv[4] || "[]")); + + const markerRe = /<!-- claude-comment-addressed:([^:]+):(\d+) -->/g; + const markerMap = {}; + for (const c of markers) { + let m; + markerRe.lastIndex = 0; + while ((m = markerRe.exec(c.body || "")) !== null) { + const tid = m[1], att = parseInt(m[2], 10); + const cur = markerMap[tid]; + if (!cur || att > cur.attempt || (att === cur.attempt && c.created_at > cur.created_at)) { + markerMap[tid] = { attempt: att, created_at: c.created_at }; + } + } + } + + for (const t of threads) { + if (t.isResolved) continue; + const comments = (t.comments && t.comments.nodes) || []; + if (comments.length === 0) continue; + const qualifies = comments.some((c) => { + const login = c.author && c.author.login; + return login === owner || allowlist.has(login); + }); + if (!qualifies) continue; + let lastActivity = comments[0].createdAt; + for (const c of comments) { if (c.createdAt > lastActivity) lastActivity = c.createdAt; } + const marker = markerMap[t.id]; + const currentAttempt = marker ? marker.attempt : 0; + if (marker && marker.created_at >= lastActivity) continue; // nothing new since the fix + if (currentAttempt >= 2) { + console.log("ESCALATE\t" + t.id); + } else { + console.log("FIX\t" + t.id + "\t" + (currentAttempt + 1)); + } + } + ' "$threads_json" "$marker_comments_json" "$owner_login" "$allowlist_json" 2>/dev/null || true)" + + [ -z "$decisions" ] && continue + + fix_pairs="" + while IFS=$'\t' read -r kind tid attempt; do + [ -z "$kind" ] && continue + if [ "$kind" = "ESCALATE" ]; then + if command -v needs_human_flag >/dev/null 2>&1; then + needs_human_flag "pr:$num" "comment-fix-thread-budget" "high" \ + "PR #$num: a review thread reopened after 2 fix attempts" \ + "Review thread $tid on PR #$num has reopened after 2 automated fix attempts (budget: 2 attempts/thread). The loop will not retry it automatically -- labeling \`needs-human\`. Address it by hand, then either resolve the thread or leave a fresh comment once it's fixed." + fi + elif [ "$kind" = "FIX" ]; then + [ -n "$fix_pairs" ] && fix_pairs+="," + fix_pairs+="${tid}:${attempt}" + fi + done <<< "$decisions" + + [ -z "$fix_pairs" ] && continue + printf '%s\t%s\t%s\t%s\n' "$num" "$branch" "$fix_pairs" "$head_sha" + done diff --git a/.claude/scripts/pr-comment-fix.test.sh b/.claude/scripts/pr-comment-fix.test.sh new file mode 100644 index 0000000..db98232 --- /dev/null +++ b/.claude/scripts/pr-comment-fix.test.sh @@ -0,0 +1,293 @@ +#!/usr/bin/env bash +# pr-comment-fix.test.sh — offline smoke test for pr-comment-fix.sh (issue #96 +# capability #2: review-comment-thread convergence detection). +# +# Builds a throwaway .claude/scripts/ fixture containing the REAL +# pr-comment-fix.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 own fake-gh convention) and a FAKE pr-feedback.sh (so +# the precedence exclusion can be exercised without any real +# feedback-detection logic). No network, no real `gh` CLI required. +# +# Exit 0 on success, non-zero if any assertion fails. Runnable bare: +# bash .claude/scripts/pr-comment-fix.test.sh +set -uo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +src="$script_dir/pr-comment-fix.sh" +resolve_roots_src="$script_dir/resolve-roots.sh" + +work="$(mktemp -d "${TMPDIR:-/tmp}/pr-comment-fix-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-comment-fix.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" }, + "commentFix": { "botAllowlist": ["allowed-bot"] } +} +EOF + +# --- fixture PR set ---------------------------------------------------------- +# 10: one unresolved thread, owner comment, no marker yet -> EMITTED attempt=1 +# 11: one unresolved thread, RANDOM (non-owner/non-allowlisted) commenter +# -> NOT emitted (doesn't qualify) +# 12: unresolved thread, owner comment, labeled needs-human -> NOT emitted (guard) +# 13: unresolved thread, owner comment, labeled claude-comment-fixing +# -> NOT emitted (in-flight guard) +# 14: unresolved thread, owner comment, ALSO a pr-feedback.sh candidate +# -> NOT emitted (precedence) +# 15: unresolved thread, owner comment, marker already posted AFTER the +# thread's last comment (already addressed, no new activity) +# -> NOT emitted (already-addressed cursor) +# 16: unresolved thread, owner comment, marker attempt=1 posted BEFORE a +# newer comment (thread reopened after 1 fix) -> EMITTED attempt=2 +# 17: unresolved thread, owner comment, marker attempt=2 posted BEFORE a +# newer comment (thread reopened after 2 fixes -- budget exhausted) +# -> NOT emitted (escalated to needs-human) +# 18: RESOLVED thread, owner comment -> NOT emitted (resolved) +# 19: unresolved thread, ALLOWLISTED bot commenter -> EMITTED attempt=1 +cat > "$scripts_dir/bot-gh.sh" <<'BOTGH' +#!/usr/bin/env bash +# Log every invocation (mirrors pr-feedback.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" (which can't distinguish correct escalation from a silent +# drop). +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":[],"headRefOid":"sha10"} +{"number":11,"headRefName":"feat/issue-11-a","author":{"login":"testbot"},"labels":[],"headRefOid":"sha11"} +{"number":12,"headRefName":"feat/issue-12-a","author":{"login":"testbot"},"labels":[{"name":"needs-human"}],"headRefOid":"sha12"} +{"number":13,"headRefName":"feat/issue-13-a","author":{"login":"testbot"},"labels":[{"name":"claude-comment-fixing"}],"headRefOid":"sha13"} +{"number":14,"headRefName":"feat/issue-14-a","author":{"login":"testbot"},"labels":[],"headRefOid":"sha14"} +{"number":15,"headRefName":"feat/issue-15-a","author":{"login":"testbot"},"labels":[],"headRefOid":"sha15"} +{"number":16,"headRefName":"feat/issue-16-a","author":{"login":"testbot"},"labels":[],"headRefOid":"sha16"} +{"number":17,"headRefName":"feat/issue-17-a","author":{"login":"testbot"},"labels":[],"headRefOid":"sha17"} +{"number":18,"headRefName":"feat/issue-18-a","author":{"login":"testbot"},"labels":[],"headRefOid":"sha18"} +{"number":19,"headRefName":"feat/issue-19-a","author":{"login":"testbot"},"labels":[],"headRefOid":"sha19"} +JSON + else + echo "fake-bot-gh.sh: unexpected pr subcommand: $*" >&2 + exit 1 + fi + ;; + api) + # gh receives `-F number=<N>` as separate argv entries -- "$*" joins them + # with spaces, so " number=<N> " (padded) is an exact, unambiguous match. + case " $* " in + *" number=10 "*) + echo '[{"id":"T10","isResolved":false,"comments":{"nodes":[{"author":{"login":"acme"},"createdAt":"2026-01-01T00:00:00Z"}]}}]' + ;; + *" number=11 "*) + echo '[{"id":"T11","isResolved":false,"comments":{"nodes":[{"author":{"login":"random-user"},"createdAt":"2026-01-01T00:00:00Z"}]}}]' + ;; + *" number=12 "*) + echo '[{"id":"T12","isResolved":false,"comments":{"nodes":[{"author":{"login":"acme"},"createdAt":"2026-01-01T00:00:00Z"}]}}]' + ;; + *" number=13 "*) + echo '[{"id":"T13","isResolved":false,"comments":{"nodes":[{"author":{"login":"acme"},"createdAt":"2026-01-01T00:00:00Z"}]}}]' + ;; + *" number=14 "*) + echo '[{"id":"T14","isResolved":false,"comments":{"nodes":[{"author":{"login":"acme"},"createdAt":"2026-01-01T00:00:00Z"}]}}]' + ;; + *" number=15 "*) + echo '[{"id":"T15","isResolved":false,"comments":{"nodes":[{"author":{"login":"acme"},"createdAt":"2026-01-01T00:00:00Z"}]}}]' + ;; + *" number=16 "*) + echo '[{"id":"T16","isResolved":false,"comments":{"nodes":[{"author":{"login":"acme"},"createdAt":"2026-01-01T00:00:00Z"},{"author":{"login":"acme"},"createdAt":"2026-03-01T00:00:00Z"}]}}]' + ;; + *" number=17 "*) + echo '[{"id":"T17","isResolved":false,"comments":{"nodes":[{"author":{"login":"acme"},"createdAt":"2026-01-01T00:00:00Z"},{"author":{"login":"acme"},"createdAt":"2026-03-01T00:00:00Z"}]}}]' + ;; + *" number=18 "*) + echo '[{"id":"T18","isResolved":true,"comments":{"nodes":[{"author":{"login":"acme"},"createdAt":"2026-01-01T00:00:00Z"}]}}]' + ;; + *" number=19 "*) + echo '[{"id":"T19","isResolved":false,"comments":{"nodes":[{"author":{"login":"allowed-bot"},"createdAt":"2026-01-01T00:00:00Z"}]}}]' + ;; + *"graphql"*) + echo '[]' + ;; + *"issues/15/comments"*) + echo '[{"user":{"login":"testbot"},"body":"<!-- claude-comment-addressed:T15:1 -->","created_at":"2026-02-01T00:00:00Z"}]' + ;; + *"issues/16/comments"*) + echo '[{"user":{"login":"testbot"},"body":"<!-- claude-comment-addressed:T16:1 -->","created_at":"2026-02-01T00:00:00Z"}]' + ;; + *"issues/17/comments"*) + echo '[{"user":{"login":"testbot"},"body":"<!-- claude-comment-addressed:T17:2 -->","created_at":"2026-02-01T00:00:00Z"}]' + ;; + *"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 comment-fix). +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" + +# Unset GATES_FILE explicitly: this test may itself run from inside a gate +# invocation that exports GATES_FILE=.claude/self/gates.json for the OUTER +# repo (mirrors loop-census.test.sh's own guard) — which would leak in here +# and make pr-comment-fix.sh read the outer self-adapter's commentFix.botAllowlist +# instead of this fixture's own gates.json. +out="$(env -u GATES_FILE BOT_LOGIN=testbot OWNER_LOGIN=acme bash "$scripts_dir/pr-comment-fix.sh" "acme/repo")" + +check "PR 10 (unresolved, owner comment, no marker): emitted attempt=1" bash -c ' + printf "%s\n" "$1" | grep -qF "$(printf "10\tfeat/issue-10-a\tT10:1\tsha10")"' _ "$out" +check "PR 11 (unresolved, non-qualifying commenter): 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-comment-fixing 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 (already-addressed marker, no new activity): NOT emitted" bash -c '! printf "%s\n" "$1" | grep -qE "^15\b"' _ "$out" +check "PR 16 (thread reopened after 1 fix): emitted attempt=2" bash -c ' + printf "%s\n" "$1" | grep -qF "$(printf "16\tfeat/issue-16-a\tT16:2\tsha16")"' _ "$out" +check "PR 17 (thread reopened after 2 fixes -- budget exhausted): NOT emitted (escalated)" bash -c '! printf "%s\n" "$1" | grep -qE "^17\b"' _ "$out" +check "PR 18 (resolved thread): NOT emitted" bash -c '! printf "%s\n" "$1" | grep -qE "^18\b"' _ "$out" +check "PR 19 (allowlisted bot commenter): emitted attempt=1" bash -c ' + printf "%s\n" "$1" | grep -qF "$(printf "19\tfeat/issue-19-a\tT19:1\tsha19")"' _ "$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 thread). +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" + +# --- fixture2: the SHIPPED DEFAULT (commentFix.botAllowlist: []) must +# actually be exercised -- fixture1 above hardcodes a non-empty allowlist for +# every scenario, so an empty allowlist (bots disabled by default; only the +# owner qualifies) is never exercised without this fixture. +# 30: unresolved thread, only a BOT (non-owner, non-allowlisted) commenter +# -> NOT emitted (empty allowlist = no bots qualify) +# 31: unresolved thread, owner comment, same run -> EMITTED attempt=1 (owner always qualifies) +fixture2="$work/fixture2" +scripts_dir2="$fixture2/.claude/scripts" +mkdir -p "$scripts_dir2" "$fixture2/.claude/state" +cp "$src" "$scripts_dir2/pr-comment-fix.sh" +cp "$resolve_roots_src" "$scripts_dir2/resolve-roots.sh" +cp "$script_dir/needs-human.sh" "$scripts_dir2/needs-human.sh" +cp "$script_dir/notify.sh" "$scripts_dir2/notify.sh" + +cat > "$fixture2/.claude/gates.json" <<'EOF' +{ + "modules": [{ "name": "test", "path": ".", "description": "", "owner": "" }], + "merge": { "baseBranch": "main" }, + "commentFix": { "botAllowlist": [] } +} +EOF + +cat > "$scripts_dir2/bot-gh.sh" <<'BOTGH' +#!/usr/bin/env bash +case "$1" in + repo) echo "acme/repo" ;; + label) exit 0 ;; + pr) + case "$2" in + edit|comment) exit 0 ;; + view) echo ""; exit 0 ;; + esac + if printf '%s\n' "$*" | grep -q 'headRefOid'; then + cat <<'JSON' +{"number":30,"headRefName":"feat/issue-30-a","author":{"login":"testbot"},"labels":[],"headRefOid":"sha30"} +{"number":31,"headRefName":"feat/issue-31-a","author":{"login":"testbot"},"labels":[],"headRefOid":"sha31"} +JSON + else + echo "fake-bot-gh.sh: unexpected pr subcommand: $*" >&2 + exit 1 + fi + ;; + api) + case " $* " in + *" number=30 "*) + echo '[{"id":"T30","isResolved":false,"comments":{"nodes":[{"author":{"login":"some-bot"},"createdAt":"2026-01-01T00:00:00Z"}]}}]' + ;; + *" number=31 "*) + echo '[{"id":"T31","isResolved":false,"comments":{"nodes":[{"author":{"login":"acme"},"createdAt":"2026-01-01T00:00:00Z"}]}}]' + ;; + *"graphql"*) + echo '[]' + ;; + *"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_dir2/bot-gh.sh" + +# No feedback candidates in this fixture -- empty stub. +cat > "$scripts_dir2/pr-feedback.sh" <<'EOF' +#!/usr/bin/env bash +EOF +chmod +x "$scripts_dir2/pr-feedback.sh" + +out2="$(env -u GATES_FILE BOT_LOGIN=testbot OWNER_LOGIN=acme bash "$scripts_dir2/pr-comment-fix.sh" "acme/repo")" + +check "fixture2/PR 30 (empty allowlist, only a bot commenter): NOT emitted (default-disabled)" \ + bash -c '! printf "%s\n" "$1" | grep -qE "^30\b"' _ "$out2" +check "fixture2/PR 31 (empty allowlist, owner commenter, same run): emitted attempt=1 (owner always qualifies)" bash -c ' + printf "%s\n" "$1" | grep -qF "$(printf "31\tfeat/issue-31-a\tT31:1\tsha31")"' _ "$out2" +check "fixture2: exactly 1 PR emitted total (31 only)" bash -c '[ "$(printf "%s\n" "$1" | grep -c .)" -eq 1 ]' _ "$out2" + +echo "" +if [ "$fail" -eq 0 ]; then + echo "pr-comment-fix.test.sh: PASS ($ok checks)" + exit 0 +else + echo "pr-comment-fix.test.sh: FAIL (see FAIL lines above)" + exit 1 +fi diff --git a/.claude/self/gates.json b/.claude/self/gates.json index 8693bc3..3e0194c 100644 --- a/.claude/self/gates.json +++ b/.claude/self/gates.json @@ -52,6 +52,9 @@ "_notify_note": "issue #99 — needs-human push-notification seam, read by .claude/scripts/notify.sh (GATES_FILE-aware, same empty-means-skip convention as `gates`/`budget`). Empty string here = disabled (offline/CI-safe no-op); left EMPTY deliberately for this repo's own dogfooding — the owner picks a default later. Contract: severity/title/body-line reach the configured command BOTH as positional args ($1/$2/$3) and as NOTIFY_SEVERITY/NOTIFY_TITLE/NOTIFY_BODY env vars. Called by .claude/scripts/needs-human.sh's needs_human_flag() at every loop block-on-owner point (attempt-budget/stall escalation in loop-tick.sh, PR-ready-for-review/re-approval-needed in merge-ready.sh, CHANGES_REQUESTED-addressed-awaiting-re-review in pr-feedback.sh), throttled to one notification per (kind,target) per window (default 1800s, see notify.sh). Example commands (uncomment/adapt ONE):\n ntfy: curl -s -d \"$NOTIFY_BODY\" -H \"Title: $NOTIFY_TITLE\" -H \"Priority: $NOTIFY_SEVERITY\" ntfy.sh/<your-topic>\n notify-send: notify-send \"$NOTIFY_TITLE\" \"$NOTIFY_BODY\"\n webhook: curl -s -X POST -H 'Content-Type: application/json' -d \"{\\\"severity\\\":\\\"$NOTIFY_SEVERITY\\\",\\\"title\\\":\\\"$NOTIFY_TITLE\\\",\\\"body\\\":\\\"$NOTIFY_BODY\\\"}\" https://example.invalid/hook", "notify": "", + "_commentFix_note": "issue #96 (part 2) — bot-commenter allowlist for the comment-fix reaction, read by .claude/scripts/pr-comment-fix.sh. commentFix.botAllowlist: bot GitHub logins whose inline PR review comments ALSO qualify a thread for automated comment-fix, in addition to the repo owner (who always qualifies). Empty array = disabled (only the owner's own review threads trigger it) — left empty here, same as the root adapter; the owner picks a default later.", + "commentFix": { "botAllowlist": [] }, + "_plan_note": "issue #100 — optional spec/plan gate. plan.gate: off (default; unchanged single-pass advance behavior, byte-identical census output) | label (gate only planned issues that ALSO carry the plan-first label) | always (gate every planned+module candidate). Unknown/missing value falls back to off. Read by loop-census.sh (derives each candidate's plan state from its labels: plan-approved -> gated-approved, else plan-review -> awaiting-owner (blocked on the owner, skipped for advance_ready/fallback_ready like a 'Blocked by' edge), else gated-by-mode -> needs-plan, else ungated), loop-tick.sh (passes advance_mode through as telemetry), and loop-event.sh (builds a PLAN-ONLY driver prompt for needs-plan, injects the approved plan as authoritative scope for gated-approved, and today's single-pass prompt for ungated/off). Left off here so this repo's own self-hosted loop keeps its existing single-pass behavior — see docs/USAGE.md.", "plan": { "gate": "off" } } diff --git a/.claude/self/pr-loop-self.md b/.claude/self/pr-loop-self.md index 15e974b..92bd47e 100644 --- a/.claude/self/pr-loop-self.md +++ b/.claude/self/pr-loop-self.md @@ -25,12 +25,13 @@ 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, 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`, 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, 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, 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.) > -> CADENCE: the script's `=== 1/5 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/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. > -> Then obey the verdict line (the tick already ran poll/merge/feedback-detection/ci-fix-detection above — do not re-run those scripts). Precedence when more than one is ready: `feedback` > `ci-fix` > `advance` (a PR with BOTH unaddressed feedback and failing CI is handled as feedback, never ci-fix): +> 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): > - `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=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=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.