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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .claude/commands/pr-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,17 @@ 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, and pr-feedback.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`, or `action=feedback pr=N`. That verdict line is the SOLE source of truth for what to do next: never hand-count open PRs, planned issues, or feedback 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, 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.
>
> CADENCE: the script's `=== 1/4 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/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.
>
> Then obey the verdict line (the tick already ran poll/merge/feedback-detection above — do not re-run those scripts):
> 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):
> - `action=feedback pr=N` → address PR N's feedback: run orchestrator → worktree implementer → reviewer lenses (per .claude/gates.json) on the SAME branch, push to update the PR in place, and post the `<!-- claude-addressed -->` marker comment via bot-gh.sh. Do NOT merge.
> - `action=ci-fix pr=N` → fix PR N's failing CI: label the PR `claude-ci-fixing` via bot-gh.sh first (in-flight guard), then run orchestrator → worktree implementer on the SAME branch (checkout the PR's existing branch, do NOT create a new one) → reviewer lenses (per .claude/gates.json) to fix the failure, push to update the PR in place. After pushing, query the PR's current head SHA (`bot-gh.sh pr view N --json headRefOid`) and post a bot comment containing exactly `<!-- claude-ci-addressed:<head-sha> -->` (the real SHA substituted in) so pr-ci-fix.sh's cursor recognizes this head as already addressed. Do NOT merge, and do NOT force-push.
> - `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); 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); 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").
Expand Down
50 changes: 48 additions & 2 deletions .claude/scripts/loop-ceilings.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,11 @@ check() {
# $1=name $2=fake_census $3=fake_feedback (TSV body, may be empty)
# $4=1 to also install a call-logging fake bot-gh.sh (default: no bot-gh.sh at
# all, matching loop-tick.test.sh's own "gh must never be invoked" contract
# for scenarios that expect zero gh side effects).
# 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).
new_fixture() {
local name="$1" fake_census="$2" fake_feedback="$3" with_gh="${4:-0}"
local name="$1" fake_census="$2" fake_feedback="$3" with_gh="${4:-0}" fake_cifix="${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
Expand Down Expand Up @@ -73,6 +75,12 @@ EOF
cat <<'FEEDBACK'
$fake_feedback
FEEDBACK
EOF
cat > "$dir/pr-ci-fix.sh" <<EOF
#!/usr/bin/env bash
cat <<'CIFIX'
$fake_cifix
CIFIX
EOF
chmod +x "$dir"/*.sh

Expand Down Expand Up @@ -394,6 +402,44 @@ check "scenario 10c: a SECOND 'issue create' fires (closed tracked issue is not
[ "$(gh_calls "$1" | grep -c "^issue comment")" -eq 1 ]
' _ "$dir10"

# ---------------------------------------------------------------------------
# 11. Per-issue attempt budget applies across advance, feedback, AND ci-fix
# phases of the SAME issue (issue #96): a PR (23) cut from
# feat/issue-42-x, needing a CI fix rather than feedback, ALSO inherits
# issue 42's existing (exhausted) attempt count and is refused/escalated
# as PR 23 (not issue 42) -- mirrors scenario 6 above, but through the
# cifix_pr/cifix_issue path instead of feedback_pr/feedback_issue.
# ---------------------------------------------------------------------------
CIFIX_PR_23='23 feat/issue-42-x build deadbeef'
dir11="$(new_fixture scenario11 "$CENSUS_READY_42" "" 1 "$CIFIX_PR_23")"
node -e '
const fs = require("fs");
const dir = process.argv[1] + "/../state";
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(dir + "/loop-issue-attempts.json", JSON.stringify({ "42": { attempts: 5, escalated: false } }));
' "$dir11"
out11="$(run_tick "$dir11")"
check "scenario 11 (ci-fix for issue 42's PR, budget already exhausted): verdict is action=none" bash -c '[ "$(verdict_of "$1")" = "action=none" ]' _ "$out11"
check "scenario 11: diagnostic cites the attempt budget for issue=42, not PR 23" bash -c 'printf "%s\n" "$1" | grep -q "attempt budget exceeded for issue=42"' _ "$out11"
check "scenario 11: the PR (23), not the issue, was labeled/commented needs-human" bash -c 'gh_calls "$1" | grep -q "^pr edit 23 --add-label needs-human" && gh_calls "$1" | grep -q "^pr comment 23"' _ "$dir11"

# ---------------------------------------------------------------------------
# 12. Precedence (issue #96): a ci-fix candidate wins over an ALSO-ready
# advance, but a feedback candidate still wins over ci-fix -- exercised
# end-to-end through loop-tick.sh's own verdict decision (not just
# pr-ci-fix.sh's own exclusion logic, covered separately in
# pr-ci-fix.test.sh).
# ---------------------------------------------------------------------------
CIFIX_PR_9='9 feat/issue-7-x build cafef00d'
dir12="$(new_fixture scenario12 "$CENSUS_READY_42" "" 0 "$CIFIX_PR_9")"
out12="$(run_tick "$dir12")"
check "scenario 12 (ci-fix beats an also-ready advance): verdict is action=ci-fix pr=9" bash -c '[ "$(verdict_of "$1")" = "action=ci-fix pr=9" ]' _ "$out12"
check "scenario 12: no spawn lock written (advance never attempted)" [ ! -e "$dir12/../state/loop-advance.lock" ]

dir13="$(new_fixture scenario13 "$CENSUS_READY_42" "$FEEDBACK_PR_17" 0 "$CIFIX_PR_9")"
out13="$(run_tick "$dir13")"
check "scenario 13 (feedback beats an also-ready ci-fix): verdict is action=feedback pr=17, not ci-fix" bash -c '[ "$(verdict_of "$1")" = "action=feedback pr=17" ]' _ "$out13"

echo ""
if [ "$fail" -eq 0 ]; then
echo "loop-ceilings.test.sh: PASS ($ok checks)"
Expand Down
9 changes: 8 additions & 1 deletion .claude/scripts/loop-census.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
#
# open_prs=N open PRs against the adapter's base branch
# feedback_prs=N bot PRs with unaddressed CHANGES_REQUESTED (pr-feedback.sh)
# ci_fix_prs=N bot PRs with a failing CI check on the current
# head, not already a feedback candidate, not
# already addressed for that head (pr-ci-fix.sh,
# issue #96)
# planned_issues=N open issues labelled `planned` AND one of the
# adapter's module:* labels, one detail line each:
# issue=<n> branch=<feat/issue-n-* or none> title=<title>
Expand Down Expand Up @@ -238,6 +242,9 @@ open_issue_set=$(gh issue list -R "$repo" --state open --json number --jq '.[].n
feedback_prs=$(PR_FEEDBACK_COUNT_ONLY=1 bash "$script_dir/pr-feedback.sh" "$repo" | grep -c . || true)
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"

# 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)
Expand Down Expand Up @@ -389,7 +396,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 ] || { [ "$open_prs" -eq 0 ] && [ "$planned_count" -ge 1 ]; }; then
if [ "$feedback_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 * * * *'
Expand Down
34 changes: 33 additions & 1 deletion .claude/scripts/loop-census.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ cat > "$scripts_dir/pr-feedback.sh" <<'EOF'
#!/usr/bin/env bash
exit 0
EOF
cat > "$scripts_dir/pr-ci-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.
Expand Down Expand Up @@ -179,6 +183,22 @@ check "issue 100 (local branch, already has an open PR, exact-match control) is
check "exactly one in_flight line total (only issue 42 qualifies)" bash -c '[ "$(printf "%s\n" "$1" | grep -c "^in_flight=")" -eq 1 ]' _ "$out"
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"

# ---------------------------------------------------------------------------
# ci_fix_prs (issue #96): loop-census.sh must surface pr-ci-fix.sh's own
# candidate count verbatim as `ci_fix_prs=N`, exactly mirroring how
# feedback_prs already wraps pr-feedback.sh (`grep -c .` over its TSV output)
# -- reusing fixture1's real git repo/adapter, just swapping in a pr-ci-fix.sh
# stub that prints two candidate lines instead of the no-op above.
# ---------------------------------------------------------------------------
cat > "$scripts_dir/pr-ci-fix.sh" <<'EOF'
#!/usr/bin/env bash
printf '10\tfeat/issue-10-a\tbuild\tsha10\n'
printf '11\tfeat/issue-11-a\tbuild\tsha11\n'
EOF
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"

# ---------------------------------------------------------------------------
# driver_unit_active guard (issue #119 post-review finding #5): loop-census.sh
Expand Down Expand Up @@ -210,6 +230,10 @@ EOF
cat > "$scripts/pr-feedback.sh" <<'EOF'
#!/usr/bin/env bash
exit 0
EOF
cat > "$scripts/pr-ci-fix.sh" <<'EOF'
#!/usr/bin/env bash
exit 0
EOF
cat > "$scripts/bot-gh.sh" <<'EOF'
#!/usr/bin/env bash
Expand Down Expand Up @@ -297,7 +321,11 @@ EOF
#!/usr/bin/env bash
exit 0
EOF
chmod +x "$scripts/pr-feedback.sh" "$scripts/cockpit.sh" "$scripts/loop-census.sh"
cat > "$scripts/pr-ci-fix.sh" <<'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"
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
}
Expand Down Expand Up @@ -537,6 +565,10 @@ cat > "$scriptsStall/pr-feedback.sh" <<'EOF'
#!/usr/bin/env bash
exit 0
EOF
cat > "$scriptsStall/pr-ci-fix.sh" <<'EOF'
#!/usr/bin/env bash
exit 0
EOF
cat > "$scriptsStall/bot-gh.sh" <<'EOF'
#!/usr/bin/env bash
case "$1" in
Expand Down
Loading
Loading