Skip to content

fix: guard git push by shape so feature-branch pushes run unattended - #620

Closed
johanzander wants to merge 8 commits into
mainfrom
fix/push-permission-granularity
Closed

fix: guard git push by shape so feature-branch pushes run unattended#620
johanzander wants to merge 8 commits into
mainfrom
fix/push-permission-granularity

Conversation

@johanzander

Copy link
Copy Markdown
Owner

Problem

The blanket Bash(git push *) ask rule stalled every autonomous run — implement-issue Step 9 and every sweep-prs push. In this session it blocked a routine sweep three times before the work could continue.

scripts/quality-check.sh justified the blanket rule like this:

the dangerous shapes put their marker at an arbitrary argument position … which a prefix glob cannot reach

That claim is false, and the proof is in the same file. matches() is:

re.fullmatch(re.escape(inner).replace(r"\*", ".*"), command)

* becomes .*, which spans spaces — so Bash(git push *--force*) matches git push origin main --force perfectly well. The greedy-globs note ~140 lines further down says exactly this. The two comments contradicted each other, and the blanket rule was built on the wrong one.

Why the earlier attempt really leaked: it was prefix-anchored (Bash(git push --force*)), which genuinely cannot reach argument position 3. The *marker* spelling can. That distinction is the whole fix.

Change

Push is guarded per shape: force in any position, refspec +, ref deletion, --mirror/--prune/--tags, release tags, and anything naming main/master/beta in either the origin main or HEAD:main spelling. A push naming a feature branch runs unattended.

Branch protection already refuses the case the blanket prompt was standing in for, so it cost an autonomous run and bought nothing.

Verification

Every push spelling the gate already pinned, plus real branch names from this repo:

still asks now unattended
git push origin main --force / --force-with-lease / -f origin main git push origin HEAD:fix/issue-592-vpp-idle-at-floor
git push origin +beta-release-9.9 git push origin feat/phase4c-charge-commands
git push origin --delete release-9.9, :main git push -u origin fix/issue-604-signed-pair-aliases
git push origin v9.9.0, --tags, --mirror git push origin worktree-po-followups
git push origin HEAD:main, HEAD:refs/heads/main, master git push origin maintenance-cleanup ← the main-substring trap
git push beta main, HEAD:beta, beta-release-*-tmp git -C .claude/worktrees/x push origin HEAD:fix/…
bare git push, and all of the above via git -C … / git -c …

0 holes, 0 false prompts. ./scripts/quality-check.sh green: "Permission surface intact (85 command shapes checked, 20 require deny, 20 must stay unattended)", Errors: 0, full suite 1992 passed / 125 frontend.

The push that created this PR ran without a prompt — the change is demonstrated on itself.

Gate changes

  • MUST_BE_GUARDED gains the colon-refspec forms a * main pattern cannot see (HEAD:main, HEAD:refs/heads/main, :main, HEAD:beta), plus --force-with-lease, -f, --mirror, --tags.
  • MUST_NOT_BE_GUARDED gains the feature-branch pushes and maintenance-cleanup. That last one pins the substring trap that would reappear if the protected-ref rules were loosened to * main*.
  • REQUIRED["ask"] no longer names the push rules. 52 pattern names would recreate the presence-check failure mode this gate exists to catch; the command strings hold it instead.

Deliberately unchanged

gh api keeps its blanket rule. It is not separable this way: any -f, -F or -X turns a read into a mutation, so no lexical marker isolates the safe subset. If it stalls runs, the fix is an allow-list of read-only paths, not a shape guard.

--force-with-lease still asks. A lease protects against a concurrent writer, not against wrong local history.

Docs

CLAUDE.md's Permissions section asserted the opposite in three places ("Every git push asks", "guarded bluntly, and that is deliberate", and the --force-with-lease rationale). All three are corrected, including the false "prefix globbing cannot reach it" premise.

Follow-ups not in this PR

🤖 Generated with Claude Code

The blanket `Bash(git push *)` ask rule stalled every autonomous run at
`implement-issue` Step 9 and at every `sweep-prs` push. It was justified in
`quality-check.sh` by the claim that a marker at an arbitrary argument
position is unreachable by a prefix glob — and that claim was false. The
matcher in the same file is `re.fullmatch` with `*` -> `.*`, which spans
spaces, so `Bash(git push *--force*)` matches `git push origin main --force`.
The greedy-globs note further down said exactly that; the two comments
contradicted each other for four review rounds and the blanket rule was
built on the wrong one.

The earlier enumeration leaked because it was prefix-ANCHORED
(`Bash(git push --force*)`), which really cannot reach position 3. The
`*marker*` spelling can. Push is now guarded per shape: force in any
position, refspec `+`, ref deletion, `--mirror`/`--prune`/`--tags`, release
tags, and anything naming main/master/beta in either the `origin main` or
`HEAD:main` spelling. A push naming a feature branch runs unattended.

Verified against every push spelling the gate already pinned plus real
branch names from this repo: 20 dangerous shapes still guarded, 20 read-only
and feature-branch shapes unattended, 0 holes and 0 false prompts.
`git push --force-with-lease` still asks, deliberately.

- `MUST_BE_GUARDED` gains the colon-refspec forms a `* main` pattern cannot
  see (`HEAD:main`, `HEAD:refs/heads/main`, `:main`, `HEAD:beta`), plus
  `--force-with-lease`, `-f`, `--mirror` and `--tags`.
- `MUST_NOT_BE_GUARDED` gains the feature-branch pushes and
  `maintenance-cleanup`, which pins the `main`-substring trap that would
  return if the protected-ref rules were loosened to `* main*`.
- `REQUIRED["ask"]` no longer names the push rules: 52 pattern names would
  recreate the presence-check failure mode this gate exists to catch. The
  command strings hold it instead.
- `gh api` keeps its blanket rule — any `-f`/`-F`/`-X` turns a read into a
  mutation, so its safe subset has no lexical marker.

CLAUDE.md's Permissions section asserted the opposite in three places and is
corrected to match.
#617 landed the gh release verb split, which touches the same three files.
Both conflicts were in lists that each PR appends to:

- CLAUDE.md's escapes-to-GitHub row: keep this branch's per-shape push
  clause and main's `delete-asset` addition.
- quality-check.sh MUST_NOT_BE_GUARDED: keep both blocks, the
  feature-branch pushes and the read-only gh release verbs.

Also pins `gh release download v9.9.0`, which #617's review flagged as
claimed-unattended in its body and CLAUDE.md but pinned by no command
string. Behaviour was already correct; only the verification was missing --
the same "rule that looks right and matches nothing" failure this list
exists to catch.

Gate after resolution: 95 command shapes checked, 20 require deny,
25 must stay unattended, Errors: 0.
@bess-agent

Copy link
Copy Markdown
Collaborator

@claude-bot review

@bess-manager-claude-bot bess-manager-claude-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

This PR replaces the blanket Bash(git push *) ask rule with a per-shape enumeration (force, delete, mirror/prune/tags, release tags, protected-branch names). The stated goal — closing the false-prompt-that-stalls-autonomous-runs problem while keeping main/master/beta protected — is legitimate, and the PR does the right diligence: it cites matches()'s actual regex semantics, runs scripts/quality-check.sh's pinned command-string gate, and documents the reasoning at length in CLAUDE.md.

However, I verified the new enumeration against real git push semantics (not just the pinned test strings) and found two more real holes in the same category this PR exists to close — i.e. exactly the "leaked, twice" failure mode the PR's own description warns about, now a third time, in the PR meant to fix it.

Findings

1. git push origin refs/heads/main runs unattended — CONFIRMED, blocking

.claude/settings.json lines 103–115 (ask) key every protected-branch guard off a literal space ("Bash(git push * main)") or colon ("Bash(git push *:main)", "Bash(git push *:refs/heads/main*)") immediately preceding the branch name. But git push origin refs/heads/main is a completely ordinary, single-sided refspec — the character before main is /, not a space or colon — and it pushes local main straight to remote main.

I verified this against a real repo (not just the regex):

$ git push origin refs/heads/main --dry-run -v
To ../remote.git
   2412ebb..9866033  main -> main

And against the PR's own matches() function (scripts/quality-check.sh:396-399), none of the 50 MUST_BE_GUARDED strings, nor any of the 25 ask push patterns, match git push origin refs/heads/main (same for refs/heads/master, refs/heads/beta).

Needs a source-side *refs/heads/main* / *refs/heads/master* / *refs/heads/beta* pattern (mirroring the destination-side *:refs/heads/main* ones already present) in both ask and MUST_BE_GUARDED.

2. git push --all (and git push --all origin) runs unattended — CONFIRMED, blocking

The same ask block guards --mirror, --prune, --tags (.claude/settings.json:97-99) but not --all. --all pushes every local branch, including main, to the remote.

Verified against a real repo:

$ git push --all origin --dry-run -v
To ../remote.git
   2412ebb..9866033  main -> main
 * [new branch]      some-feature -> some-feature

Confirmed via the matcher: no ask/deny pattern matches git push --all or git push --all origin.

Checklist

  • Matches a linked issue's root cause? N/A — this is a self-initiated infra PR (no linked GitHub issue), with root cause well-documented in the PR body itself and traceable to the false claim in the pre-existing quality-check.sh comment.
  • Would the added test catch a regression? Partially. scripts/quality-check.sh's MUST_BE_GUARDED/MUST_NOT_BE_GUARDED gate is a good mechanism and correctly pins every scenario it enumerates (verified: ran it on the PR branch, green, 95 shapes). But it only proves "the strings we thought of are covered" — it does not prove "no valid git-push spelling reaches a protected branch," which is the actual property this PR wants. The two holes above show the enumeration is still incomplete after two prior rounds of the same mistake.
  • Minimal / no scope creep? Yes — the diff is tightly scoped to the push-permission shapes plus matching docs/gate updates.
  • Rule violations? None from docs/agents/rules.md (no Python/architecture rules apply to this config/docs/bash-script change).

Recommendation

Add the two missing shapes (refs/heads/<protected-branch> as a bare source-side ref, and --all) to ask and MUST_BE_GUARDED, and re-run scripts/quality-check.sh. Given this is the third leak of the same enumeration category in this repo's history, it's also worth asking — per the PR's own "Fitness of approach" self-critique — whether a normalization step (resolve the refspec's effective destination ref via git rev-parse/git for-each-ref semantics before matching, rather than pattern-matching the raw command string) would close this class of gap for good rather than requiring a fourth round of enumeration next time it's found. Not blocking this PR by itself, but worth a follow-up issue.

🤖 Generated with Claude Code — PR Review Bot

@johanzander

Copy link
Copy Markdown
Owner Author

Prerequisite is now in place — this PR should shrink

enforce_admins was false on both remotes, which invalidated the premise this PR's protected-ref patterns were being judged against. It is now true:

origin/main enforce_admins=true
beta/main   enforce_admins=true

So main is genuinely unpushable — including by the owner token every agent uses. That was the missing piece.

Why that matters for this PR

Review found two more holes (git push origin refs/heads/main, git push --all), both verified against a real repo. I then found two more the review didn't (git push origin refs/tags/v1.2.3 source-side, and git push origin HEAD — which pushes main when you are on main). That is the third consecutive round of the same enumeration failure, and the review's second checklist point is the real finding:

it only proves "the strings we thought of are covered" — it does not prove "no valid git-push spelling reaches a protected branch"

Correct. Prefix globs cannot express that property, so enumerating protected-ref spellings will keep leaking.

Agreed direction

Delete the protected-ref half of the enumeration and keep only the shapes that are lexically unambiguous and not otherwise prevented:

  • keep: --force / -f in any position, --force-with-lease, +refspec, --delete, --mirror, --prune, --tags, --all, refs/tags/ (both source and destination side), and any push to the beta remote
  • drop: every * main / *:main / *refs/heads/main pattern and the master and beta-ref equivalents — GitHub now rejects those server-side, which is a provable guarantee rather than an enumerated one

Net effect: a much smaller pattern set, and the half that has failed three times stops existing.

Still to do here

  1. Remove the protected-ref patterns from .claude/settings.json and the matching MUST_BE_GUARDED strings.
  2. Add --all and source-side refs/tags/ to both the ask list and MUST_BE_GUARDED — these are genuine gaps that branch protection does not cover (--all pushes every branch; a tag push is not a branch push).
  3. Re-point CLAUDE.md's Permissions section at the branch-protection argument instead of the enumeration argument, and record enforce_admins: true as load-bearing — if anyone turns it off, this PR's reasoning collapses and the patterns must come back.
  4. Re-run ./scripts/quality-check.sh and re-request review.

The gh release download pin and the merge of main (#617 conflict) are already in.

johanzander added a commit that referenced this pull request Aug 17, 2026
`implement-issue` is used for TODO.md items and refactors, not only for
issues, so a draft PR with no linked issue is normal rather than a defect. The
pass reported "no issue references this PR; finish it by hand", which left every
self-directed PR with no owner in the loop -- exactly how #620, #622 and #623
all ended up driven by hand today.

No flag distinguishes the two cases: GitHub numbers issues and PRs from ONE
sequence per repo, so a bare number is already unambiguous and Step 0 can
resolve whichever it is. An earlier draft of this used `--pr <n>`; that
distinction carries no information.

Where an issue IS linked it is still named, because it carries the diagnosis.
Where none is, the PR number is the handle, and a strong one: it holds the
branch, the diff, the scope assessment and the review verdict, which is
everything Step 0 reads.

Step 0 accepting a PR number is a matching change to implement-issue's SKILL.md,
which lives on the fix/review-verdict-placeholder branch (#622) where Step 0 was
added. Both have to land for the loop to cover this case.
johanzander added a commit that referenced this pull request Aug 17, 2026
This skill is used for TODO.md items and for refactors that never had an issue,
so "issue number" was too narrow a contract. Step 0 already keys off observable
state; a PR is simply another entry point to it, and the stronger one -- it
carries the branch, the diff, the `## Scope assessment` and the review verdict,
which is everything Step 0 reads.

No flag is needed. GitHub numbers issues and PRs from ONE sequence per
repository, so a bare number is unambiguous: try `gh pr view <n>`, fall back to
`gh issue view <n>`. An earlier draft used `--pr <n>`; that distinction carries
no information.

Why it matters beyond tidiness: scripts/backlog-rhythm.sh hands unfinished
drafts back to this skill, and for a PR with no linked issue it had nothing to
hand -- it reported "no issue references this PR; finish it by hand". That left
every self-directed PR with no owner in the loop, which is how #620, #622 and
#623 all ended up driven by hand in one session.

Where an issue IS linked, nothing changes: it is still read for the diagnosis.
Where none is, Step 2's root cause comes from the maintainer's own framing
rather than a Stage 2 comment, and Step 9 records it in the PR body as usual.
johanzander added a commit that referenced this pull request Aug 17, 2026
* fix: make backlog grooming reflect what actually blocks an issue

The digest misclassified enough of the board that grooming could not be
trusted, and every misclassification pushed work in the same direction:
towards looking more ready than it was. Measured against the live board,
Ready went 1 -> 0 and In Progress 5 -> 1.

Five defects, each traced to a real item.

1. `analyzed` was tested BEFORE any wait, so an analysed-but-blocked item
   reported Ready. #96 was labelled `analyzed`, prioritised P2, carried no
   blocking label, and still could not be built because its approach was
   undecided. It read as dispatchable, a session was dispatched at it, and
   that session deadlocked on three design questions. Waits now outrank
   `analyzed`.

2. The board's `Awaiting` field was never read. 17 items have it set
   (`discussion` x11, `reporter` x6) and the digest derived its own value from
   labels instead, so recorded grooming had no effect on anything. The field is
   now authoritative, with `awaiting_source` and `awaiting_suggested` exposed
   so an unset field can be reconciled rather than silently invented.

3. `awaiting: discussion` was returned for ANY human comment, which is not a
   blocker -- thanks, a "me too" and a follow-up question all pushed an item to
   Analysis. Only a recorded wait or a blocking label does that now.

4. A worktree left on disk pinned its issue to In Progress forever. #593,
   #571, #542 and #466 all reported In Progress while their PRs (#618, #579,
   #591, #517) had merged. Staleness is decided by comparing the worktree's own
   branch against merged PRs -- an exact match, deliberately not the fuzzy
   issue-number match used to associate a worktree with an issue.

5. `blocked` did not fail Ready, so #571 reported Ready for Dev while labelled
   `blocked`. Definition of Ready criterion 5 now holds, including an
   unresolved `Blocked by #N`.

Ready for Dev also finally requires a Priority, which the design always
specified and the code deferred "until a board exists". It exists.

New: `last_comment` {author, days, is_reporter, is_bot}. Without it the digest
could not represent the transition that matters most to grooming -- the
reporter answering us. A count and a date cannot tell that from a nudge we
posted, which is why #621 crossed the Definition of Ready line unnoticed.

REJECTED while building this: mapping a merged PR to Done. It reclassified 7
open issues (#118, #120, #403 among them) as finished, and it contradicts this
project's rule that beta PRs omit `Closes #N` until graduation -- an open issue
with a merged fix is the NORMAL state. `merged_pr` is reported; it moves no
column, and a test pins that.

Merged PR bodies are reduced to their closing references before reaching jq;
passing 200 of them through --argjson overflows the argument list.

Tests: 25 pass. The `gh` shim now branches on the full argument string, because
`pr list` is called twice with different `--state` values and matching only the
subcommand returned the open list for both -- which would have made every open
PR look merged. Fixtures gained `createdAt` on comments, which real gh always
sends and whose absence failed `strptime` rather than testing anything. `_run`
now surfaces the digest's stderr instead of a bare "exit status 5".

* fix: only a still-open Blocked by #N fails Ready

Review of #623 found a real gap, and it contradicted an explicit claim: both
the commit message and SKILL.md said this enforced an "unresolved" blocker,
while `blocked_by` was a pure text scan that never checked whether the blocker
was still open.

A `Blocked by #N` line is never edited out of an issue body once N lands, so
that scan pins the item out of Ready for Dev permanently. That is the same
failure this script exists to fix, pointing the other way: an item reading
wrong relative to its real state.

`$issues` is already the open-issue list, so membership decides it with no
extra API call. `blocked_by` keeps the raw parse so the reference stays
visible; the new `blocked_by_open` is the subset that actually blocks, and
`$blocked` reads that.

Also pins the precedence question the review asked me to confirm rather than
guess at: a recorded wait DOES outrank a live worktree in `column`, because
unsettled scope must not read as progress. The risk is hiding active
undelivered code, so the worktree stays reported on the item — the wait
changes the column, not the evidence. Now tested and documented in the
reconciliation table instead of being implied.

27 tests pass.

* feat: a Rhythm pass that carries work from incoming to a ready PR

Every follow-up rule in the backlog skill had been written down and NONE had
ever fired. The 14-day reporter chase, the 28-day park, the reporter-replied
re-check, the stale-worktree handoff: all decoration, because each needed a
model to notice it and nothing scheduled one.

So the noticing is deterministic now and lives in scripts/backlog-rhythm.sh.
Every rule is a comparison over the digest — no judgement, no tokens. A quiet
backlog prints "RHYTHM: nothing due." for the cost of one process, which is
what makes it worth running on a timer at all. The PO agent is needed only to
ACT, and only when something is due.

The pass covers BOTH halves of the path to an approvable PR:

Issue side — recheck_ready, nudge_reporter, park, surface_discussion,
set_awaiting, set_priority, triage_labels, dispatchable.

PR side — and this is the half that actually hands the maintainer something:
  mark_ready           approved but still a draft   <- the finish line
  awaiting_maintainer  approved and out of draft
  request_review       draft with no review at all
  rework               changes requested
  resolve_conflict     CONFLICTING (produces no CI run, so it reads as
                       "checks never fired" and nobody investigates)

Those two states were invisible in practice. #615 and #617 sat APPROVED and
still drafts overnight with nothing left but the merge; #619 was never
reviewed at all. Nothing was watching either transition.

Ordering is load-bearing. PR actions come first because they are closest to
the finish line, and recheck_ready outranks the chases: nudging someone who
has already replied is the worst output this pass could produce.

Quiet time is measured from the LAST COMMENT, not updatedAt — a label change
or a board move bumps updatedAt, so an issue nobody has spoken on for a month
would look active and never age into a chase.

A bare COMMENTED review is not treated as a verdict, because the review bot
posts its inline notes as one before the summary.

Against the live board the pass finds 30 due actions, including PR #490
awaiting the maintainer, #162 park (quiet 54d), three stale worktrees and
three conflicted PRs.

RHYTHM_DIGEST_FILE / RHYTHM_PRS_FILE are test seams, the same shape as
BESS_ENV_FILE in gh-agent.sh. 16 tests pin the rules, including that a quiet
backlog is a noop and that a reply beats the chase.

Still not wired to a schedule — that is the invocation, not the logic, and it
is deliberately a separate step.

* refactor: hand unfinished PRs back to implement-issue instead of duplicating it

The previous commit built request_review / mark_ready / rework into the Rhythm
pass, which is a second copy of implement-issue Step 11. That contradicts the
argument used to put resume in Step 0 rather than in a separate skill: two
copies of one review loop means one of them goes stale.

It also mis-diagnosed the symptom. #615 and #617 did not sit APPROVED-but-draft
because nothing was watching for that state; they sat there because the
sessions that owned them exited before Step 11 finished. The fix belongs where
the loop already lives.

So every unfinished draft now resolves to ONE action, `resume_implementation`,
carrying the issue number so the handoff is directly runnable. Step 0 re-enters
at the earliest incomplete step, whether the PR needs a first review, a rework,
or just the ready flag it never got. Two fleet-level exceptions stay in the
pass, because implement-issue deliberately does not widen to them:
`awaiting_maintainer` (report only) and `resolve_conflict` (sweep-prs).

Adds the stalled-work rule this was missing: a LIVE worktree with no session
behind it is an implementation that stopped mid-flight -- the machine
restarted, the session was killed, or the agent exited between steps. Nothing
picked these up, and an audit found 34 such worktrees, 8 holding real unpushed
commits and one with 32. Against the live board it finds #466 and #602.

`pr == null` guards that rule so work with a PR is reported once, by the PR
branch, rather than twice.

It is always a RESUME, never a restart: Step 4 branches fresh from origin/main
and would delete commits that exist nowhere else. The detail string says so,
and a test pins it.

SKILL.md gains the reasons a future pass must not re-learn this: do not drive
the review loop here; a session reporting `working` may have written nothing
(three dispatches produced zero writes in one day while reporting healthy
state); and read `claude agents --json` unsandboxed, since ~/.claude/jobs is
sandbox-denied and a sandboxed listing returned 1 session where the truth was
17.

19 rhythm tests, 27 digest tests, gate green.

* fix: match Blocked by #N per line, and drop dead code the refactor left

Review of #623 found a real misclassification path in a PR whose whole point is
eliminating them.

`blocked_by` was a free `scan` over the issue body, so it matched the substring
regardless of what preceded it. "not blocked by #500 anymore" and "no longer
blocked by #500" both registered as live blockers -- and those are the natural
way to update an issue once its blocker resolves, so the false positive fired
exactly when the blocker was GONE. The item would be pinned out of Ready for
Dev permanently.

The severity is new, not latent: on main `blocked_by` was extracted and never
fed into `column()`, so a bad parse was inert. Gating `column()` on it is what
gave it teeth.

Matched per LINE and anchored to the line start now, optionally bulleted, which
is the convention the skill documents ("a `Blocked by #N` line in the issue
body"). Anchoring rejects the negations without a blacklist that would only
cover the phrasings someone happened to think of. It also fixes the reviewer's
third point: an untriaged issue merely mentioning a blocker in prose no longer
moves Backlog -> Analysis with no human triage behind it.

Four tests: the bulleted form still counts, three negated phrasings do not, and
an incidental mid-sentence mention does not reclassify.

Also from the same review:

- `human_comments` became dead code when `awaiting` stopped deriving
  `discussion` from comment activity. Removed, and the stale comment on
  `comments:` that still described that mechanism is corrected.
- SKILL.md claimed the board's custom-field JSON shape was "confirmed" where
  the previous text had explicitly said unconfirmed, without showing the
  evidence. It was verified live; the command and its result are now recorded,
  with a note that the tests fabricate that shape and so cannot prove it.

49 tests pass (27 digest + 19 rhythm, plus the 3 new negation cases).

* fix: resume a PR by its own number when no issue is linked

`implement-issue` is used for TODO.md items and refactors, not only for
issues, so a draft PR with no linked issue is normal rather than a defect. The
pass reported "no issue references this PR; finish it by hand", which left every
self-directed PR with no owner in the loop -- exactly how #620, #622 and #623
all ended up driven by hand today.

No flag distinguishes the two cases: GitHub numbers issues and PRs from ONE
sequence per repo, so a bare number is already unambiguous and Step 0 can
resolve whichever it is. An earlier draft of this used `--pr <n>`; that
distinction carries no information.

Where an issue IS linked it is still named, because it carries the diagnosis.
Where none is, the PR number is the handle, and a strong one: it holds the
branch, the diff, the scope assessment and the review verdict, which is
everything Step 0 reads.

Step 0 accepting a PR number is a matching change to implement-issue's SKILL.md,
which lives on the fix/review-verdict-placeholder branch (#622) where Step 0 was
added. Both have to land for the loop to cover this case.
johanzander added a commit that referenced this pull request Aug 17, 2026
…sume for dead sessions (#622)

* fix: wait for a terminal review verdict, not the bot's placeholder

`request-pr-review.sh` took the LAST review newer than its trigger and
called it the verdict. The review bot posts its inline notes first, as a
COMMENTED review whose body is "Inline notes below; summary review to
follow.", then submits the real APPROVED/CHANGES_REQUESTED summary seconds
later. Measured on PR #617: placeholder at 06:57:13Z, APPROVED at
06:58:03Z — 50 seconds apart.

Any poll landing in that window returned COMMENTED. `implement-issue`
Step 11 then saw a non-APPROVED verdict and skipped `gh pr ready`, so an
approved PR stayed a draft with nothing left to do but the merge. PR #615
sat that way overnight: CHANGES_REQUESTED, fixed, APPROVED at 21:13, still
a draft the next morning.

Filter on state BEFORE taking `last`, so only APPROVED or
CHANGES_REQUESTED ends the wait. Verified against #617's real review
history by simulating a poll at 06:57:30Z, when the placeholder was the
newest review: the old expression returns COMMENTED, the new one returns
empty and keeps waiting.

The timeout path also conflated two opposite faults that printed the same
message — a review that started and never summarised, versus a trigger
that never reached the workflow. It now reports which one happened. PR #619
is currently the second kind, and that was invisible before.

Step 11's own text told the agent to act on `COMMENTED`, so it is corrected
to match; a bare COMMENTED can no longer reach the caller at all.

* feat: resume an issue whose session died mid-flight (implement-issue Step 0)

Sessions die mid-issue routinely and nothing picked them up. A fleet audit
found 34 worktrees whose sessions had exited: 8 with real unpushed commits
and no PR (one with 32 commits), plus three PRs sitting green-or-reviewed
with no owner left. #615 was APPROVED and still a draft the next morning;
#614 carried CHANGES_REQUESTED with nobody to act on it. `sweep-prs`
refuses that job by design, so the work simply stopped.

This lives in `implement-issue` rather than a new skill because the loop
that acts on review feedback is Step 11 and already lives here. A second
skill would duplicate it, and duplicating a review loop is how one of them
goes stale.

Step 0 keys off state observable from OUTSIDE the dead session — branch,
worktree, commits, PR body sections, CI status, review verdict — and
re-enters at the earliest incomplete step. The one thing that dies with the
session is Step 2's diagnosis, which Step 11 depends on holding; it is
recoverable only because this skill already forces it to be written down
(the Stage 2 analyze comment, and the PR body's `## Scope assessment` and
`## Test plan`). When those do not reconstruct a coherent approach, Step 0
STOPS rather than re-diagnosing on top of commits encoding decisions it
cannot see.

Hard rules, each from an observed failure:
- never run Step 4's fresh-from-origin/main worktree when a branch for the
  issue already has commits — that deletes them
- never reset or force-push a resumed branch; its commits are the only copy
- check for a live session unscoped AND unsandboxed: a sandboxed
  `claude agents --json` returned 1 session where the truth was 17, because
  ~/.claude/jobs is sandbox-denied, so every other session read as dead
- treat uncommitted tracked changes as unfinished work; WIP-commit first
- if the same issue has died twice, say so and stop

CI mode gets a Step 0 row too: Stage 3 is re-triggered by hand, so a second
`@claude-bot fix` on an issue that already has a has-fix-pr PR is a resume,
not a restart, and must not open a second PR.

* fix: resolve an ambiguous COMMENTED review by time, and stop emitting it

Review of #622 found the previous commit's fix incomplete, and it was right.
`pr-review.yml:75` documents COMMENT as a legitimate FINAL verdict
("questions/observations only"), submitted with `gh pr review --comment`,
which produces the same state == "COMMENTED" as the bot's inline-notes
placeholder. Treating every COMMENTED as non-terminal therefore swallowed a
real COMMENT verdict: the loop waited out the full timeout and reported "never
submitted a summary" while a summary with findings sat on the PR. That
over-generalised "the placeholder is COMMENTED" into "COMMENTED is always the
placeholder".

Fixed at the source and in the consumer.

Source: pr-review.yml step 3 permitted `gh pr review` for inline notes, and
that is what submits the extra review. It now requires `gh api
.../pulls/N/comments`, so exactly ONE review is submitted per run -- the step
4 summary. No placeholder means no ambiguity.

Consumer: the script no longer decides by state alone, and deliberately does
NOT parse the placeholder's body -- that text is bot-generated prose with no
contract behind it. APPROVED/CHANGES_REQUESTED return immediately; a
COMMENTED-only state is held `grace` seconds (180, against observed
placeholder-to-summary gaps of 16s on #622 and 50s on #617) to let a summary
supersede it, and is returned as the verdict if none does. The grace window is
what keeps this correct for reviews already on older PRs and if the bot
regresses.

Verified against #622's real review history: with both reviews visible the
decisive branch returns CHANGES_REQUESTED and grace is never entered; in a
window containing only the placeholder the COMMENTED branch finds it while the
decisive branch is empty, so grace holds.

The timeout message is also now correct rather than merely different: a
COMMENTED-only run can no longer reach it, so reaching it means no review of
any state was submitted -- a trigger fault, which is what #619 hit twice.

SKILL.md's Step 11 said "It will never hand you COMMENTED"; a COMMENTED that
now reaches the caller IS the verdict, so it is documented as carrying
findings and not earning the ready flag, same as CHANGES_REQUESTED.

* feat: Step 0 resolves a bare number to an issue OR a pull request

This skill is used for TODO.md items and for refactors that never had an issue,
so "issue number" was too narrow a contract. Step 0 already keys off observable
state; a PR is simply another entry point to it, and the stronger one -- it
carries the branch, the diff, the `## Scope assessment` and the review verdict,
which is everything Step 0 reads.

No flag is needed. GitHub numbers issues and PRs from ONE sequence per
repository, so a bare number is unambiguous: try `gh pr view <n>`, fall back to
`gh issue view <n>`. An earlier draft used `--pr <n>`; that distinction carries
no information.

Why it matters beyond tidiness: scripts/backlog-rhythm.sh hands unfinished
drafts back to this skill, and for a PR with no linked issue it had nothing to
hand -- it reported "no issue references this PR; finish it by hand". That left
every self-directed PR with no owner in the loop, which is how #620, #622 and
#623 all ended up driven by hand in one session.

Where an issue IS linked, nothing changes: it is still read for the diagnosis.
Where none is, Step 2's root cause comes from the maintainer's own framing
rather than a Stage 2 comment, and Step 9 records it in the PR body as usual.

* fix: decide a COMMENTED review by run state, and test the decision

Third round on the same finding, and the previous two "fixes" were asserted
rather than demonstrated -- verification was quality-check.sh plus `bash -n`,
neither of which executes the decision path. So this commit changes the
mechanism AND adds the missing tests.

The grace window was the wrong instrument, and the review named the reason: it
competed against the ORIGINAL deadline instead of extending it, so a COMMENTED
first seen in the last `grace` seconds of the window could never satisfy the
`elapsed >= grace` branch -- the loop exited first and reported "no review
landed", which was false. Observed live on #622: the stub landed 12:15:14, the
real CHANGES_REQUESTED 12:16:39, and the script returned the stub.

Sizing it differently would not have helped. The gap that matters is not
placeholder-to-summary (16s on #622, 50s on #617) but placeholder-to-END-OF-RUN:
the bot posts an early permission-check comment within a couple of minutes and
works for five to eight more. No constant is both short enough to return a real
COMMENT promptly and long enough never to pre-empt a summary.

So ask the run instead. `review_run_state` reads the PR Review workflow run
started since the trigger:

  running  -> a COMMENTED decides nothing; keep waiting
  finished -> a COMMENTED last word IS the verdict, per pr-review.yml's own
              three-verdict contract (APPROVE / REQUEST_CHANGES / COMMENT)
  failed   -> report at once; do not burn the timeout on a dead run
  none     -> the trigger never reached the workflow, a different fault

That last one matters as much as the first. A dead run and a thinking one are
both silence if you only poll for reviews, which is how #623's run -- already
failed on "Reached maximum number of turns (60)" -- was waited on for 16
minutes.

Also from this review round:

- pr-review.yml no longer REQUIRES `gh api` for inline comments. The reviewer
  reported that `gh api` is permission-gated and unavailable to it, and that it
  probed with `gh pr review` to find out -- which submits, and is where the
  stray "test permission check" reviews came from. The prompt now states the
  one hard rule (submit exactly ONE review, never probe with it), prefers
  `gh api` for inline notes, and says to fold findings into the summary with
  file:line when it is unavailable, rather than falling back to a second
  review.
- SKILL.md Step 0 keyed a resume signal on `## Scope assessment`, which only
  CI mode writes into the PR body. An interactive-mode PR never carries it, so
  the row would not match for most PRs this skill opens. It now keys on the PR
  existing, which is what actually proves Step 9 was reached.

7 new tests, REVIEW_POLL_INTERVAL added as their seam. Two of them are the
discriminating pair: identical reviews (COMMENTED only), opposite outcomes,
differing only in run state -- so the decision is provably driven by the new
signal and not by timing. The shim applies `--jq` like real gh does; an earlier
version echoed raw JSON and the script reported it as a verdict.

* fix: give the verdict tests their own env file so they pass in CI

The new test file could not pass in CI, and the review caught it with the
failing run: `Fast tests` was red on this PR while local `quality-check.sh` was
green. Reproduced both sides before fixing.

Cause: request-pr-review.sh posts its trigger through scripts/gh-agent.sh,
which reads a real BESS_AGENT_TOKEN and exits 1 before `gh` is reached.
Shimming `gh` on PATH does not help -- gh-agent.sh is invoked by a
repo-relative path, not looked up on PATH. Worse, it resolves its env file from
the MAIN checkout (`dirname $(git rev-parse --git-common-dir)`), so this
worktree having no `.env` of its own was irrelevant: the developer's real token
was read anyway. CI provisions no `.env` and no such secret, so the suite failed
unconditionally there. The abandoned `(d / "scripts").mkdir(...)` in the fixture
was an attempt at this that did nothing.

Fixed with the seam gh-agent.sh already documents for exactly this
(`BESS_ENV_FILE`, "a seam tests use to point at a fixture .env instead"), so no
production code changes: each test now supplies its own env file carrying a
dummy token.

Also adds a test that PINS that dependency, because the fix is otherwise
invisible and could be dropped again silently: point the seam at an empty file
and the script must fail before polling, naming the missing token.

Worth recording how I nearly mis-verified this: my first attempt set
BESS_ENV_FILE from OUTSIDE pytest and saw the tests still pass, which looked
like proof of CI-safety. It was not -- the test sets that variable in the
subprocess env, so it overrides any outer value and the experiment could not
fail. The in-test pin above is the version that can actually discriminate.

Full backend suite: 507 passed.

* fix: an unreadable run state must not promote a placeholder to a verdict

Review of #622 found a real correctness bug, reproduced against the shipped
script: `review_run_state` falls back to `unknown` whenever `gh run list` itself
fails -- network blip, rate limit, transient auth error -- and the COMMENTED
branch tested only `state = running`, so `unknown` fell through to the else and
reported the bot's placeholder as the verdict.

That re-opens the exact race this script exists to close, gated on API
flakiness instead of timing. "I could not tell whether the reviewer is still
working" must never mean "it finished". It now waits, same as `running`:
waiting costs one more poll, and a genuine COMMENT verdict still returns as soon
as the state resolves.

A test drives it, with the shim making `run list` exit 1 rather than returning a
run shape -- which is what the failure actually looks like.

That test also exposed a second defect in the same path: the timeout branch ends
with `gh run list ... >&2` for diagnostics, and under `set -e` a failing `gh` --
precisely the `unknown` case -- aborted the script with exit 1 instead of the
exit 2 that means "no verdict". Diagnostics must not decide the exit code, so it
is `|| true` now.

Also fixes the doc/implementation mismatch the review flagged: SKILL.md still
described the 180s hold from the superseded commit, which would actively mislead
the next reader about how Step 11 decides. It now describes the run-state
mechanism, including that an unreadable state waits.

9 tests. Worth noting the `pr-review.yml` change from this PR is already
working: this round the bot folded its findings into a single summary review and
posted no stray placeholder.
johanzander added a commit that referenced this pull request Aug 17, 2026
…e approvals as merge-ready

Two defects, both in the same direction: the surface told the maintainer to
act on work that was already handled.

REVIEW STATE (addresses the Stage 4 review on this PR, which reproduced it).
The previous commit asked "is there an APPROVED review anywhere" before
consulting `reviewDecision`. GitHub never rewrites an old review when a later
round requests changes, so an approved-then-reworked PR keeps its stale
APPROVED entry forever and was reported "nothing left but your merge" — the
exact failure the commit set out to close, reintroduced by the fix for it.

The review proposed keying on `reviewDecision` instead. That alone is also
wrong here, and measurably: `reviewDecision` is only populated when the repo
REQUIRES reviews, and this one does not. It reads CHANGES_REQUESTED for
#619/#620/#614 but "" for #490, which carries two genuine APPROVED reviews —
so keying on it alone reports an approved PR as never reviewed. Neither
signal is sufficient, and each fails toward "merge it", so the order is the
whole content of the rule: trust `reviewDecision` when set, otherwise fall
back to the LAST non-COMMENTED review. Last, not any — same staleness trap.

SESSION LIVENESS. `resume_implementation` keyed off `session == null`, and
`session` comes from `claude agents`, which lists BACKGROUND agents only. A
session started in the terminal — `claude`, then `/implement-issue <n>` — is a
foreground session and never appears; even a background agent carries a
generated descriptive name rather than the `issue-<n>` the dispatch convention
promises. Measured: 41 worktrees on disk, `claude agents --json` returning one
entry. So every worktree read as abandoned, and #624 was reported "no live
session, /implement-issue 624 to resume" while actively being worked — routing
a second session onto a branch the advice itself calls the only copy.

The worktree LOCK is what tracks a live session: git records
`locked claude session <name> (pid N start ...)`, and 4 of those 41 were
locked — exactly the four live sessions, foreground and background alike.

Live effect: #626 moves request_review -> rework_review (the review landed),
#490 stays awaiting_maintainer despite its empty reviewDecision, and #624 is
no longer reported as stalled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LExo6fcbup75vtc9NfoAR
johanzander added a commit that referenced this pull request Aug 17, 2026
* fix: make a backlog pass runnable without a shell preamble

`/loop /backlog` died on its first line, every tick. `backlog-digest.sh`
required PROJECT_NUMBER in the environment, but it lives in the gitignored
`.env` that nothing exports — so the script exited "the backlog board has not
been created yet", which is the most misleading message it could emit: the
board is Project #1, populated, and entirely fine. The documented workaround
(`set -a; . ./.env; set +a; ...`) cannot survive an unattended pass, because
the caller is a skill and not a shell someone typed into.

The digest now sources `.env` itself, located via
`git rev-parse --path-format=absolute --git-common-dir`. That is one rule
rather than a search over candidate paths: `.env` is gitignored, so it exists
only in the main checkout and never in a worktree, and the common dir resolves
to the main checkout from either. `--path-format=absolute` is load-bearing —
git otherwise answers the relative `.git`, whose dirname is `.`, silently
resolving against the caller's cwd. The environment still wins over the file,
or an explicit `PROJECT_NUMBER=2` would be un-overridable.

Two gaps the skill could not close without a second API call by hand:

- `board_status` — where the card sits NOW, alongside `column` (where the
  evidence says it belongs), in the same six Status strings. The `board` verb
  says "reconcile every card against the derived column, the digest always
  wins" and had nothing to reconcile against. Seven live mismatches were
  invisible.
- `issue_no_card` — an open issue with no card at all. `Ready for Dev`
  requires a Priority and priority is a board field, so an off-board issue can
  never become dispatchable however well analysed, while reading as an
  ordinary Backlog item. #621 and #624 were both in that state.

The rhythm pass told the PO to "set Priority" on those two, i.e. to set a
field on a card that does not exist; it now asks for `add_card`, and reports
`move_card` for a card the evidence disagrees with.

Tests pin all of it. The git shim answers `rev-parse` so `.env` discovery is
deterministic — letting the real git through would read the maintainer's own
gitignored file, which CI does not have.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LExo6fcbup75vtc9NfoAR

* fix: never report an unreviewed PR as ready to merge

The rhythm surface resolved any non-draft PR to `awaiting_maintainer`,
"nothing left but your merge". That rests on an assumption that does not
hold: that only Step 11 clears the draft flag, and only after an APPROVED
verdict.

#626 broke it in the obvious way. It was flipped out of draft by hand because
it LOOKED stuck, had zero reviews at the time, and the next pass duly reported
it as ready to merge. A Stage 3 CI-mode PR has the same hole from the other
direction — it never runs Step 11 at all. Stage 4 is the gate the entire
pipeline is built around, so a surface that routes around it is worse than no
surface.

An APPROVED review is now the bar, matching Step 11 exactly. Not
`reviewDecision`, which reads "" both for never-reviewed and for
approved-then-dismissed; and not COMMENTED, which the bot also posts as a
placeholder before its real verdict. Two new actions carry the cases that used
to be silently collapsed into "merge it": `request_review` (Stage 4 never ran)
and `rework_review` (out of draft with changes requested).

Both fields were already fetched by `gh pr list` and simply unused.

Live effect on the current fleet: #490 stays `awaiting_maintainer` — it
carries real APPROVED reviews — and #626 becomes `request_review`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LExo6fcbup75vtc9NfoAR

* fix: stop the rhythm surface reporting live work as stalled, and stale approvals as merge-ready

Two defects, both in the same direction: the surface told the maintainer to
act on work that was already handled.

REVIEW STATE (addresses the Stage 4 review on this PR, which reproduced it).
The previous commit asked "is there an APPROVED review anywhere" before
consulting `reviewDecision`. GitHub never rewrites an old review when a later
round requests changes, so an approved-then-reworked PR keeps its stale
APPROVED entry forever and was reported "nothing left but your merge" — the
exact failure the commit set out to close, reintroduced by the fix for it.

The review proposed keying on `reviewDecision` instead. That alone is also
wrong here, and measurably: `reviewDecision` is only populated when the repo
REQUIRES reviews, and this one does not. It reads CHANGES_REQUESTED for
#619/#620/#614 but "" for #490, which carries two genuine APPROVED reviews —
so keying on it alone reports an approved PR as never reviewed. Neither
signal is sufficient, and each fails toward "merge it", so the order is the
whole content of the rule: trust `reviewDecision` when set, otherwise fall
back to the LAST non-COMMENTED review. Last, not any — same staleness trap.

SESSION LIVENESS. `resume_implementation` keyed off `session == null`, and
`session` comes from `claude agents`, which lists BACKGROUND agents only. A
session started in the terminal — `claude`, then `/implement-issue <n>` — is a
foreground session and never appears; even a background agent carries a
generated descriptive name rather than the `issue-<n>` the dispatch convention
promises. Measured: 41 worktrees on disk, `claude agents --json` returning one
entry. So every worktree read as abandoned, and #624 was reported "no live
session, /implement-issue 624 to resume" while actively being worked — routing
a second session onto a branch the advice itself calls the only copy.

The worktree LOCK is what tracks a live session: git records
`locked claude session <name> (pid N start ...)`, and 4 of those 41 were
locked — exactly the four live sessions, foreground and background alike.

Live effect: #626 moves request_review -> rework_review (the review landed),
#490 stays awaiting_maintainer despite its empty reviewDecision, and #624 is
no longer reported as stalled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LExo6fcbup75vtc9NfoAR

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
johanzander and others added 2 commits August 17, 2026 18:55
Stage 4 review of this PR found two more spellings that reach a protected
branch unattended — the third round of the same enumeration leaking. Both
reproduced against the real matcher before being acted on, and both were
holes pre-fix and guarded post-fix.

`git push origin refs/heads/main` is an ordinary one-sided refspec that
pushes local main to remote main. Every protected-branch rule keyed off the
character BEFORE the branch name — a space in `* main`, a colon in `*:main`
and `*:refs/heads/main*` — and in `refs/heads/main` that character is `/`, so
nothing matched. The guards are now spelled `*refs/heads/main*` without the
colon, which subsumes the destination-side form rather than sitting beside
it; the same for master and beta, and for the `git -* push` twins.

`git push --all` pushes every local branch, main included, while its
neighbours `--mirror`, `--prune` and `--tags` were all covered. `--branches`
is its documented synonym (`git push --help`: "--all, --branches"), so it is
added in the same pass rather than left to become the fourth leak — the
review found two holes, this closes three.

Verified the new pins discriminate: all 8 are holes against the pre-fix
settings and guarded against the post-fix ones, while `git push origin
my-feature`, `git push -u origin fix/issue-620` and `git push origin
fix-all-the-things` all stay unattended — the last confirming `*--all*` does
not over-match a branch name merely containing "all". The gate now checks 103
shapes, up from 95.

CLAUDE.md records the new spellings, that this was the third incomplete
round, and — per the reviewer's non-blocking point — that the gate proves
"the strings we thought of are covered" rather than "no spelling reaches a
protected branch". Normalising the destination ref is the durable fix and is
tracked in #628.

No CHANGELOG entry: agent tooling, no user-visible effect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LExo6fcbup75vtc9NfoAR
@bess-agent

Copy link
Copy Markdown
Collaborator

@claude-bot review

@bess-agent

Copy link
Copy Markdown
Collaborator

Status: parked pending a direction decision

What is on the branch now (e9943be6, CI green, gate at 103 shapes):

The two holes Stage 4 found are closed, plus one it did not:

Spelling Before After
git push origin refs/heads/main (+ master, beta) hole guarded
git push --all / --all origin hole guarded
git push --branches origin hole guarded
git -C x push origin refs/heads/main hole guarded

--branches is a documented synonym for --all (git push --help: "--all, --branches"), added in the same pass rather than left to become a fourth leak. All eight new pins were verified as holes against the pre-fix settings and guarded after; git push origin my-feature, git push -u origin fix/issue-620 and git push origin fix-all-the-things all stay unattended, the last confirming *--all* does not over-match a branch name containing "all".

Why it is parked. The comment above proposes the opposite direction for the protected-ref half — delete it entirely, on the grounds that enforce_admins: true makes GitHub reject those pushes server-side, which is a provable guarantee rather than an enumerated one. That argument is sound, and the commits currently on this branch go the other way (they extend the enumeration). Rather than churn the branch twice, the direction is being settled first.

Verified while assessing it: enforce_admins is true on both origin/main and beta/main.

Open questions the shrink proposal does not yet cover:

  1. The premise is unchecked. enforce_admins is a GitHub setting, not repo state — untracked, and silently flippable. If it is turned off, the protection evaporates with no signal. Worth asserting it in scripts/quality-check.sh so the gate goes red instead, which would turn a load-bearing assumption into a checked invariant.
  2. beta-release-* branches on origin are not protected — branch protection covers main only, so dropping the * beta* pattern opens a gap that the server side does not backfill.
  3. Tag pushes are not branch pushes. git push origin v9.9.0 (no refs/ prefix) is caught by neither refs/tags/ patterns nor branch protection.

#628 tracks the durable fix for the underlying class problem: normalise a refspec's effective destination ref before matching, instead of pattern-matching the raw command string. Three consecutive incomplete rounds is the evidence that enumeration will not converge on its own.

…covers

`git push origin refs/tags/v1.2.3` published a release tag unattended. The
guard was spelled `*:refs/tags/*`, which only ever saw the destination-side
form — the same colon-shaped blind spot that let `refs/heads/main` through, one
line up in the same list. Both are now spelled without the colon
(`*refs/tags/*`, `*refs/heads/main*`), each subsuming its destination-side form
rather than sitting beside it.

The maintainer found this one by hand and named it on the PR; it was left open
by the previous commit, which read the reviews and not the thread. That reading
gap is fixed separately in `fix/resume-reads-comments`.

This entry is deliberately settled ahead of the open question about the
protected-BRANCH patterns, because it does not depend on it: a tag is not a
branch, so no `enforce_admins` setting covers publishing one, and the guard is
required whichever way that argument goes. `git push origin v9.9.0` — a tag
without the `refs/` prefix — is caught by `* v*`, which stays.

Verified both new pins are holes against the pre-fix settings and guarded
after, that the destination-side spelling `HEAD:refs/tags/v1.2.3` still matches
(so dropping the colon regressed nothing), and that `git push origin
my-feature` and `git push -u origin fix/issue-620` stay unattended. Gate now
checks 105 shapes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LExo6fcbup75vtc9NfoAR
johanzander added a commit that referenced this pull request Aug 17, 2026
Reviews and conversation comments are two different feeds, and
`gh pr view --json reviews` returns only the first. Step 0's rehydration list
said "the diff itself, and any inline review comments" and never mentioned the
conversation, so a session that followed it exactly still missed the thing that
mattered most.

That is not hypothetical. On #620 the maintainer posted "this PR should shrink"
with a four-step plan: branch protection now rejects pushes to `main`
server-side, so the protected-ref enumeration should be DELETED rather than
extended. A later session read the reviews, did not read the comments, and
spent a full rework ADDING protected-ref patterns — the exact opposite of a
standing instruction sitting in the PR's own thread. Two further holes the
maintainer had found by hand (`git push origin refs/tags/v1.2.3`,
`git push origin HEAD`) were in that same comment and stayed open.

The asymmetry is structural, not incidental: a review must attach to a diff, so
direction — "do this differently", "this whole approach changed" — can only be
expressed in a comment. That makes the feed the skill ignored the one carrying
the highest-authority input.

So: `comments` joins `reviews` in the Step 0 query; the rehydration list names
the conversation explicitly and gives both commands; and a maintainer comment
is stated to outrank every bot review on the PR, including later ones. Step 11
re-checks comments each round rather than only at Step 0, because a direction
posted mid-loop is invisible to the verdict feed — the bot will keep approving
a diff the maintainer has already asked you to redo. The CI-mode table gets the
same note, where it matters more: Stage 3's re-trigger IS a comment, so the
reason is usually in the same thread.

Two Rationalizations rows and two Red Flags name the failure directly, since
"I read the reviews, so I know what this PR needs" is exactly how it reads from
the inside.

No CHANGELOG entry: agent tooling, no user-visible effect.


Claude-Session: https://claude.ai/code/session_012LExo6fcbup75vtc9NfoAR

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
johanzander added a commit that referenced this pull request Aug 18, 2026
…iewer"

The Stage 4 bot only ever acts when triggered by an `@claude-bot review`
comment. So "green, no verdict yet" has two completely different
meanings, and only one of them belongs to the reviewer:

  request NEWER than last push -> the bot is genuinely working: reviewer
  request OLDER, or absent      -> nobody has asked: DISPATCHER

Collapsing those parked six of eleven open PRs on someone who had not
been asked and was never going to act. Measured on the live fleet:

  #637, #635  never requested at all
  #620        requested 17:40:53, pushed 18:33:05
  #619        requested 10:50:40, pushed 21:53:00
  #614        requested 06:55:38, pushed 07:08:20
  #490        requested 08-15 13:41, pushed 08-16 15:30

Every one had been reported as `awaiting-review [reviewer]`. After this
change the same fleet shows zero PRs waiting on the reviewer and zero
waiting on the maintainer — the pipeline owes an action on all of them.

That is the failure this whole branch is about, seen from the other side.
The gate stops a confused loop asking too often; this surfaces the loop
that stopped asking at all. Both are the same lost state — whether the
last verdict has been consumed — and both are recoverable from the PR
rather than from a session that died.

`comments` joins the field set for this. It fits inside the GraphQL node
budget at --limit 30, which is already bounded by `commits`.

Verified by mutation: disabling the request-feed check reddens
test_a_review_never_requested_is_the_dispatchers_turn_not_the_reviewers
and test_a_push_after_the_last_request_owes_a_new_round.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
johanzander and others added 2 commits August 18, 2026 07:17
…d check it

Per the maintainer direction on this PR: delete the protected-ref half rather
than extend it.

`enforce_admins` is true on both `origin/main` and `beta/main`, so GitHub
refuses a push to `main` server-side, including from the owner token every
agent uses. That is a provable guarantee. An enumeration of command spellings
is only ever a list of the ones somebody thought of, and this one leaked three
times -- the last two spellings (`git push origin refs/heads/main`,
`git push --all`) found by the Stage 4 review of the PR meant to fix it, and
two more (`git push origin refs/tags/v1.2.3`, `git push origin HEAD`) found by
the maintainer by hand. A fourth round was the expected outcome of keeping it.

So 20 `ask` rules are gone: `* main`, `*:main`, `*refs/heads/main*`, the
`master` equivalents, and their `git -* push` twins. Those commands now run
unattended and are rejected by GitHub instead of by a prompt.

WHAT STAYS is what branch protection does NOT cover, and each is a real gap
rather than belt-and-braces: force in any position and refspec `+`; ref
deletion, --mirror/--prune/--tags/--all/--branches; tags on either side
(`refs/tags/`, `* v*`) since a tag is not a branch and no protected-branch
setting will ever cover publishing one; and the `beta` remote plus
`beta-release-*` on origin, since only `main` is protected.

`enforce_admins` IS THEREFORE LOAD-BEARING, and it is a GitHub setting rather
than repo state: untracked, and flippable in two clicks. Turn it off and this
repo has no guard on pushing to main at all, with nothing to say so. So
`quality-check.sh` now asserts it on both remotes, turning the assumption into
a checked invariant. A false answer is an ERROR naming both remedies; an
unreadable one is a WARNING, because a CI token cannot read branch protection
and "I could not check" is not "it is off" -- collapsing those would make the
gate lie in one direction or the other.

Verified all three outcomes against a stubbed `gh`: true -> ok (live, both
remotes), false -> error, unreadable -> warning. The permission gate itself is
green at 96 shapes, and `git push origin my-feature` still runs unattended,
which is the point of the PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LExo6fcbup75vtc9NfoAR
@bess-agent

Copy link
Copy Markdown
Collaborator

@claude-bot review

@bess-manager-claude-bot bess-manager-claude-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review summary

Scope: This PR replaces the blanket Bash(git push *) ask-rule with per-shape push guards, and (per an in-branch commit noting explicit maintainer direction) deletes the protected-ref (main/master) enumeration entirely in favor of relying on GitHub's enforce_admins branch protection, adding a new check for that setting to scripts/quality-check.sh.

Does the fix match the root cause?

Yes for the stated problem (blanket push-ask stalling autonomous runs due to a false "prefix glob can't reach it" premise) — matches() does use re.fullmatch with .*, confirmed by reading scripts/quality-check.sh:410-413, so per-shape *marker* patterns are a real fix, not a guess.

Correctness / verification gaps found (ran the actual gate + tested the shipped patterns against .claude/settings.json in a worktree of this branch)

  1. .claude/settings.json:19 — undocumented, out-of-scope change. "~/.claude/jobs" is added to sandbox.filesystem.allowWrite. This is unrelated to git push permissions and is not mentioned anywhere in the PR body, any commit message, or the CLAUDE.md rewrite — even though CLAUDE.md documents a paragraph of rationale for every other allowWrite entry ("Why each non-default knob is there"). Per this repo's own Scope Discipline rule ("Do NOT modify, remove, or 'clean up' items the user hasn't asked you to change" / "list every file and symbol changed so the user can confirm nothing unrelated was touched"), this needs either a rationale added or to be dropped from the diff. Confirmed via git show origin/main:.claude/settings.json that this key is not on main today.

  2. The one command this PR's headline claim rests on is never pinned. I tested the shipped ask/deny lists directly: git push origin main, git push -u origin main, git push origin HEAD:main, and git push origin refs/heads/main all match zero rule in .claude/settings.json — i.e. they now run fully unattended, exactly as claimed. But scripts/quality-check.sh's MUST_BE_GUARDED/MUST_NOT_BE_GUARDED lists (lines ~264-407) never pin the bare string "git push origin main" — only the force variants ("git push origin main --force", "...--force-with-lease") are tested. Given this PR's own stated methodology — "a rule that looks right and matches nothing is the failure mode this exists to catch," "pin by command string, not by name" — omitting the literal case the whole redesign turns on is a real hole in the gate's coverage, not a nitpick.

  3. The new branch-protection check verifies a necessary but not sufficient condition. The added check (scripts/quality-check.sh, "Checking branch protection" section) only reads .enforce_admins.enabled. enforce_admins merely extends whatever protection rules exist to admins/the owner token — it grants no protection by itself. If required_pull_request_reviews/push restrictions were ever disabled while enforce_admins stayed true (or was never paired with them), this check would report green while git push origin main from the owner token succeeds unattended. The PR text itself calls enforce_admins "load-bearing" and "flippable in two clicks" — the check should confirm the actual push-blocking rule is active, not just the admin-inclusion flag.

  4. This safety net isn't wired into standard CI. scripts/quality-check.sh (and thus the new branch-protection assertion) is only invoked from issue-fix.yml's agentic pipeline — not from ci.yml, which runs on every PR (verified via grep -rn quality-check .github/workflows/*.yml). So the "provable guarantee" this redesign leans on for dropping the local ask-prompt is checked only incidentally, not continuously. A silent drift in GitHub's branch protection (a risk the PR itself flags) could go unnoticed indefinitely.

  5. Minor: PR description says "85 command shapes checked, 20 require deny, 20 must stay unattended" — running the extracted permission-check block against the current branch head prints "96 command shapes checked, 20 require deny, 25 must stay unattended" (two later commits, b1a9268 and 370849b, added entries after the description was written). Cosmetic, but worth updating before merge so the description matches what's actually shipped.

Minimal / scope creep

Otherwise minimal and tightly scoped to the stated push-guard problem — the per-shape patterns, the extensive comments explaining prior leaks, and the CLAUDE.md rewrite are all in service of the one change. Item #1 above is the exception.

Tests

The MUST_BE_GUARDED/MUST_NOT_BE_GUARDED/MUST_BE_DENIED additions are real regression tests in the sense CLAUDE.md asks for (pinned command strings, would fail without the fix) — I confirmed the extracted permission-check section runs clean on this branch. The gap is coverage, not test quality: see #2 above.

Verdict

Requesting changes for #1 (undocumented scope creep, contradicts the repo's own Scope Discipline rule) and #2 (the headline behavior change has no regression pin). #3 and #4 are real gaps worth addressing but could reasonably be a fast follow-up if the maintainer prefers — they don't block on their own.

…ocking rule

Three findings from the Stage 4 review, all verified before acting.

ENFORCE_ADMINS ALONE PROTECTS NOTHING (review #3, and the one that could have
invalidated the whole approach). It only extends whatever rules already exist
to admins and the owner token; with no such rule it extends nothing, and
`git push origin main` would succeed while the new check reported green.
Measured on this repo: `required_pull_request_reviews` and `restrictions` are
both ABSENT, and `required_status_checks` is the rule actually doing the
blocking -- so the check as written was asserting the wrong half. The premise
holds today, but only by a condition it was not looking at.

It now requires both: some blocking rule (status checks, required reviews, a
push restriction, or a branch lock -- any one, so it does not pin today's
choice) AND enforce_admins to extend it to the owner token. The two failures
report differently, because they need different fixes.

THE HEADLINE BEHAVIOUR WAS UNPINNED (review #2). `git push origin main` and its
spellings were deleted from MUST_BE_GUARDED, which proves nothing: a list that
no longer mentions a command cannot say whether it is guarded. They are now in
MUST_NOT_BE_GUARDED, asserted to run unattended by the same command-string
method the rest of the gate uses -- the failure this file exists to catch is
exactly a rule that looks right and matches nothing.

That list is also where the decision reverses, and the comment says so: if the
branch-protection check ever fires, restore the settings.json patterns AND move
these back. They are a pair.

`~/.claude/jobs` DOCUMENTED (review #1). It was in the diff with no rationale
while CLAUDE.md documents every other allowWrite entry. The reason is concrete
and was hit tonight: `claude agents --json` reads the session list from there,
and a sandboxed call TRUNCATES it rather than failing -- 1 session where the
truth was 12. `backlog-rhythm.sh` keys `resume_implementation` off "no live
session", so it told the maintainer to re-enter a worktree a live session was
working. An under-count, not an error; nothing about a short list looks wrong.

Gate now at 102 shapes, 31 must-stay-unattended.

Review #4 (quality-check.sh is not wired into ci.yml, so the assertion is not
continuous) is real and confirmed -- ci.yml runs pytest directly. It is a
workflow-architecture change rather than a fix to this diff, and is filed
separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LExo6fcbup75vtc9NfoAR
@johanzander

Copy link
Copy Markdown
Owner Author

Superseded by the merged server-ruleset decision. The Phase-1 agent-fleet-sandboxing merge (#670) pins test_no_client_rule_duplicates_a_server_ruleset (test_agent_permissions.py): no client-side git push ask-rule may exist, because main/beta/tags are already protected by GitHub rulesets with empty bypass lists. This PR's premise — that feature-branch pushes need shape guards in .claude/settings.json — is exactly what that test forbids, so it cannot land as written, and the need it served (unattended feature-branch pushes) is met by the rulesets themselves. Closing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants