agentleFS
dork-labsdork-labs/dorkos/.agents/skills/creating-pull-requests/SKILL.md

creating-pull-requests

Skill11 starsChanged 48 days agoMITView on GitHub

When to open a pull request in the DorkOS repo (review the pushed branch first, open the PR after it converges), how the automated review behaves (skip-review, review:light/deep, re-review), and how a PR lands through merge-tail and the merge queue, including what to do when it goes red or is ejected. Use when finishing a branch, opening a PR, deciding how much review a PR should get, requesting a re-review, or watching a PR until it merges.

  • Deletes or force-pushes
  • Commits and pushes

6 other projects use a version of this.

Adopted by

Most recently taken up by

The file

Show its rules and text

We split this skill into the rules it gives an agent

20 rules. 0 are common, 8 are distinctive, and 12 are very specific to this project.

  • 0

    Common

    Most of the 5 edited versions we read keep these.

  • 8

    Distinctive

    This author's own choices. Worth a look.

  • 12

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Every reason below is a cost measured on 2026-07-27/28, not a preference:

    line 45
  2. Very specific:

    Commit conventions and the pre-push gate live in the git:commit / git:push commands.

    line 77
  3. Very specific:

    Before you start, check whether an open PR touches the same files or has to land first (gh pr list -R dork-labs/dorkos --search <path or …

    line 85
  4. Very specific:

    Start with /ci-status (pnpm ci:status): it names the current constraint, the SLOs and every open experiment's verdict, which is where a …

    line 183
  5. Very specific:

    Never write verified, partial, failed or inconclusive as a status.

    line 203
  6. Distinctive:

    If you need a review without rebasing first, run the workflow by hand:

    line 334
  7. Very specific:

    Use review:deep for risky changes (security, migrations, broad refactors, deletions).

    line 395
  8. Very specific:

    Leave off the strategy flag.

    line 422
  9. Distinctive:

    Arming is not the same as walking away: Read the next section before you treat an armed PR as finished.

    line 447
  10. Distinctive:

    Run it at the start of a working session rather than the end of one — that way it collects the PRs that merged while you were away, which …

    line 466
  11. Very specific:

    Separate a required failure from a standing red: Not every red check blocks the merge, and not every red check is yours.

    line 561
  12. Distinctive:

    required: check that went red on this PR.

    line 567
  13. Distinctive:

    Every watcher on this machine spends the operator's one GraphQL budget (5,000 points an hour, shared with merge-tail), so poll no faster …

    line 576
  14. Very specific:

    EJECTED_REPEAT(failed_checks,k): stop re-arming: If the same check failed again on the same head commit, it is a regression only the …

    line 660
  15. Very specific:

    Not yours (the same job is red on main or on other PRs, or the log shows an infra error such as a lost runner): gh run rerun <run-id> …

    line 677
  16. Very specific:

    HELD_BY_LABEL: not yours to lift unless you added the label.

    line 686
  17. Very specific:

    A textually clean merge is not a semantically clean one: When sibling branches landed on one seam, git merge origin/main can resolve …

    line 740
  18. Very specific:

    If the dead hook also blocks the commit or push itself (it runs lint/test and fails on the missing turbo), pass --no-verify — CI runs the …

    line 796
  19. Distinctive:

    A red review check is not always a finding: When the review breaks in a way that cost you the verdict, it posts a comment saying so and …

    line 823
  20. Distinctive:

    A stalled merge queue may be GitHub, not you: Before debugging why merge-group runs sit "queued" for hours, check githubstatus.com.

    line 883

“Common” is measured: kept by at least 6 in 10 of the 5 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Creating Pull Requests

How DorkOS PRs are opened and how the automated Claude review behaves on them. This repo is routinely multi-agent, so the mechanics below keep PRs clean and the review loop cheap.

When to use

  • You have finished a branch and are deciding when to open the PR.
  • You are about to open a PR (from an agent or by hand).
  • A PR already has review feedback and you want another pass once it is addressed.
  • You want to dial a PR's review up, down, or off.
  • A PR of yours is red, ejected from the merge queue, or not merging, and you need to know whether acting helps or only adds load.

The order: review the branch, then open the PR

The independent adversarial review runs against a pushed branch, before a PR exists. Opening the PR is the last step, not the first:

  1. Build in an isolated worktree and run the local gates.
  2. Push the branch. Open nothing.
  3. A reviewer fetches and checks out that branch. It does not need a PR.
  4. Findings, fixes, convergence.
  5. Squash to one clean commit carrying one changelog fragment.
  6. Then open the PR, already reviewed, so the repo's automated review spends its single pass on final content.

A pushed branch that will not become a PR is yours to delete. GitHub removes a branch from origin only when its PR merges, so a review branch you abandon, or an attempt that lost to a sibling, stays forever. Delete it from origin (git push origin --delete <branch>) the moment it is abandoned or superseded. A codex/archive/* twin goes the same way once the PR it certifies has merged.

Only main and */site-* branches get a Vercel preview. Each preview makes a Neon database branch, so apps/site/vercel.json turns deploys off for every other branch. A PR that needs a preview site names its topic site-… (designer/site-hero-copy); every other PR still gets the site-build check.

Every reason below is a cost measured on 2026-07-27/28, not a preference:

  • Merge churn. A PR held open across review rounds watches main move under it. One night of that took roughly a dozen branch updates (the pre-queue rule, retired 2026-07-28) and still left a DIRTY PR needing a semantic conflict resolved by hand.
  • The changelog gate. A fragment claims the commits that existed when it was written. Review-fix commits arrive afterwards uncovered, and fragment-present fails. Three PRs failed this way in one night. One commit and one fragment removes the failure mode instead of working around it.
  • An open PR is an invitation to act. Another session armed auto-merge on a PR carrying two open blocking findings. Both were browser-only and untested, so every check was green with the defects live. A branch invites nothing.
  • The automated review fires on open, so opening early spends it on a draft.

The one real cost: CI does not run until the PR opens. Cover it two ways. Run the local gates yourself, which is cheap (pnpm verify, plus the changelog gate below). And when a runner-only check is the honest gate (smoke tests, the packaged runtime, Docker, anything path-filtered to its own workflow), open a draft so CI runs without inviting a merge, then mark ready once review converges.

Before you open: branch from a worktree

Code PRs come from an isolated worktree, never the shared main checkout (see the working-in-worktrees skill). Base the worktree on origin/main, not local main, so the PR diff contains only your changes:

git fetch origin
git gtr new <branch> --from origin/main --yes   # the repo's worktree helper

Commit conventions and the pre-push gate live in the git:commit / git:push commands. End commit messages with the Co-Authored-By trailer.

Stack on a PR you depend on, otherwise branch from origin/main

One topic, one PR. Fold related fixes from one lane into one PR. Keep security-sensitive changes, migrations and risky work in a PR of their own.

Before you start, check whether an open PR touches the same files or has to land first (gh pr list -R dork-labs/dorkos --search <path or ticket>, then gh pr diff <n> --name-only):

  • There is one: stack on it with GitHub's native stacks, gh stack (gh 2.90+; gh stack init on that PR's branch, then gh stack add <your-branch>, then gh stack submit). Your PR's base is the branch below it, so its diff is only your change.
  • There is none: branch from origin/main as above.

A stack merges only with gh stack merge from the top (gh stack merge <top-pr> --yes). It lands every layer up to that PR together, through the merge queue. Never gh pr merge --auto a layer whose base is not main: it merges into the branch below and skips the queue, so nothing tested it on top of main.

UI PRs (anything under apps/client/** or packages/ui/**) run their affected Playwright specs locally before pushing, and say so in the PR body. Write the output under the cold-storage folder (see contributing/browser-verification.md#where-local-output-goes).

Include a changelog fragment

A PR with user-facing changes must include a changelog fragment under changelog/unreleased/ — one file per change (<YYMMDD-HHMMSS>-<slug>.md; see changelog/README.md). The post-commit hook usually writes one from your commit subject; verify it exists and curate it (rewrite for a user, fix the category, add a (#PR) ref) before opening the PR. Rewriting the prose is always safe: the PR check reads the fragment's covers: frontmatter, not its wording. Write one by hand if the hook skipped it or phrased it poorly, and give it a covers: line naming the commit. If you merge two fragments into one, move the losing fragment's covers: items across. Do NOT edit CHANGELOG.md's [Unreleased] section — it no longer holds entries; only /system:release writes CHANGELOG.md.

Two habits stop the gate from turning red days later (it hit five PRs in one week: #1510, #1567, #1581, #1618, #1700). (a) A commit that genuinely isn't user-facing — a review-nit fold, a refactor, a CI tweak — takes a chore( or ci( subject, so the populator mints no stub at all. (b) A user-facing commit curates its seeded stub in the same commit that creates it — rewrite the bullet for a human, delete the seeded comment — never leaving it for CI to catch. If a curated fragment for the batch already exists, fold the stub's covers: line into it byte-for-byte and delete the stub. Full mechanics: changelog/README.md#seeded-fragments.

The skip-changelog label race, and the fix that is not an empty commit. gh pr create --label skip-changelog can lose a race: the opened run of fragment-present reads labels from its event payload, which can be captured before the label attaches, so coverage runs and fails a PR whose commits are typed feat( or fix(. Two reflexes make it worse. A re-run replays the same stale payload (on #768, attempt 2 of the failed run failed again), and toggling the label starts an unlabeled run with no label that fails on the same SHA. Every failed run on the head SHA stays in the rollup, so only a new head clears it, and that head should be a real fix, never an empty commit:

  • Not user-facing (what the label claims): reword the commit to chore(, ci( or docs( (git commit --amend, then git push --force-with-lease). Coverage then passes with or without the label, so the race cannot bite again.
  • User-facing after all: the label is wrong. Remove it and add the fragment.

Prevention is the same move up front: type a non-user-facing commit chore( or ci( before you open the PR.

Run the changelog gate locally

The gate is not "is there a fragment". It is "is every user-facing commit claimed by some fragment's covers: list". Reproduce it before you push:

python3 .claude/scripts/changelog_backfill.py --since "$(git merge-base origin/main HEAD)" --validate --changed-only
python3 .claude/scripts/changelog_backfill.py --since "$(git merge-base origin/main HEAD)" --pr <n> --check --changed-only

The first checks that the fragments you touched are well formed; the second checks coverage. Each prints a one-line verdict and exits non-zero on failure, and the failure names the uncovered commits and shows the fragment that would cover them. Drop --pr <n> before the PR exists: it only matters when a fragment claims a whole PR by number (- "#412").

The failure mode to watch: a fragment written with the first commit does not cover commits added later, so a PR that was green turns red the moment it takes review feedback. The fix is a covers: frontmatter block naming each commit's subject line verbatim:

<!-- The double quotes are load-bearing: verbatim what the post-commit hook writes. Prettier rewrites quotes inside an embedded fence, hence the ignore. --><!-- prettier-ignore -->
---
covers:
  - "fix(relay): stop a Telegram bot answering other bots and every group message"
  - "fix(relay): treat an anonymous Telegram admin as a person"
---

covers: exists precisely so the prose can be written for a human without breaking the check. Squashing to one commit before opening the PR makes the whole problem disappear, which is the deeper reason for the order above.

Pipeline-touching PRs need a ledger entry

A PR that changes the CI pipeline is an experiment, and CI Steward keeps the record. Start with /ci-status (pnpm ci:status): it names the current constraint, the SLOs and every open experiment's verdict, which is where a baseline comes from and whether another change is already moving the gate you want to touch. If your diff touches a gate source (.github/workflows/**, lefthook.yml, turbo.json, .claude/settings.json, ci/**, a script a gate invokes, packages/ci-steward/src/**, or this skill with its watcher; the coverage step's exact set comes from the census, and .claude/rules/ci-pipeline.md loads for a superset of it), add or edit one file under ci/ledger/ in the same squashed commit.

  • Scaffold one: node packages/ci-steward/src/cli.ts ledger-new --slug <slug> (--help lists its flags). Check it: node packages/ci-steward/src/cli.ts ledger-check. The PR-only coverage question is node packages/ci-steward/src/cli.ts ledger-check --coverage --base "$(git merge-base origin/main HEAD)".
  • A pipeline change carries a hypothesis. kind: is experiment, incident-fix or hygiene, and every kind but hygiene names one metric, a baseline, a target and after_days. "Faster CI" is not a hypothesis; "this gate's p90 goes from 14 to 8 minutes within 14 days" is. Copy the baseline from /ci-status and say where it came from (baseline_source:).
  • Ratchet releases block review by default. An entry that lowers a quality floor (a ratchet-release, enforced from phase 2) needs a specific reason, or the review treats it as blocking.
  • Never write verified, partial, failed or inconclusive as a status. Those are verdicts the machine computes, and the ledger check rejects them on main.
  • The coverage step ("CI Steward ledger coverage" in the required typecheck job) fails the PR when a pipeline change has no ledger entry.

The full protocol is in contributing/ci.md and the stewarding-ci-pipeline skill.

Opening the PR

Open it once the branch has converged, with the squashed commit and its fragment already pushed:

gh pr create --title "<type>(<scope>): <summary>" --body "<body>"

PR body: lead with what changed and why, link the spec or issue, and call out anything reviewers should look at first.

Open a draft instead when you still need CI to tell you something, then mark it ready once review converges:

gh pr create --draft --title "<type>(<scope>): <summary>" --body "<body>"
gh pr ready <number>        # marking ready fires exactly one full review

A draft gets no automatic review, so you can push to it freely; marking ready triggers one review of the final state.

A merged PR closes the ticket it names — from the title, the branch, or a magic word

Linear's GitHub integration moves an issue on PR lifecycle: In Progress on open, Done on merge. It never reads the diff to check whether the work is actually finished. There are three ways a PR names a ticket and they do not behave alike:

Where the identifier appearsOn merge
A bare id in the title or the branch nameCloses the ticket
A magic word anywhere, body included — Closes/Fixes/ResolvesCloses the ticket
A bare id in the body, or Refs DOR-634Links only, stays open

So match what you write to the truth — say "closes" only when the PR completes the ticket. A PR that advances a ticket without finishing it keeps the bare identifier out of the title and the branch name, and refers to it from the body in a form that does not close: a bare id, or Refs DOR-634.

The body is not automatically safe, and this repo's own habit is the trap. House style for a PR body is a closing magic word — 15 of the last 60 merged PRs use one, including #589, whose body opens Closes DOR-661. So an author who dutifully moves the identifier out of the title and then writes the body sentence everyone else writes reproduces the exact failure this rule exists to prevent, while believing they followed it.

The split is visible in the repo's own history: the magic-word PRs above all closed their tickets, while every ticket referenced by a bare id in a merged body is still open (DOR-592, DOR-666, DOR-668, DOR-669, DOR-671).

Two tickets were closed by their titles on 2026-07-28, and both had to be reopened by hand with the cause recorded on the ticket:

  • DOR-591 is a code ticket — the CommunityAdapter interface and its conformance suite. A PR titled docs(spec): … (DOR-591) delivered only the specification, and still moved it to In Progress on open and Done on merge. It was the second time that ticket had been closed this way.
  • DOR-634 was closed outright by the first PR to carry it, titled feat(rooms): … (DOR-634), which delivered only the server half while the client and migration work was still unwritten.

The corollary matters as much: when the PR genuinely completes the ticket, the identifier in the title is doing exactly what you want and saves you the transition. The rule is about what you write telling the truth, not about avoiding identifiers.

Branch names carry the same force, so the choice is made before the PR exists — see working-in-worktrees → Create the worktree.

How the automated review behaves

The claude-code-review workflow reviews on-demand, not on every push:

EventReview?
PR opened (non-draft)One full review
Draft marked ready-for-reviewOne full review of the final state
New commits pushedNo auto-review (CI tests still run)
re-review label appliedOne re-review, scoped to the delta
PR has merge conflictsNothing runs at all — see below
PR edits this review workflowAuto-run red; manual review works — see below

This mirrors how human teams work: pushes are work-in-progress, and the author pulls the reviewer back in with an explicit "ready again" signal. It avoids re-reviewing five or six times while you address feedback.

You no longer lose the review by creating a PR with labels on it. Until 2026-09-20, gh pr create --label ... fired opened plus one labeled event per label within the same second; the labeled runs evicted the still-pending opened run from its concurrency group and then skipped, so about 3% of merged PRs were never reviewed and nothing was red. Runs that will not review now sit in their own group and cannot evict one that will, and the group is keyed by head SHA, so one commit gets exactly one review.

A review lost to an infrastructure failure is re-requested for you. merge-tail applies re-review on your behalf when the check is red, up to three times per head SHA (scripts/should-redispatch-review.sh). Do not wait on it if you want a review now: GitHub throttles scheduled workflows and the median gap between merge-tail ticks is about 2.7 hours, so apply re-review yourself. It leaves a skip-review label alone, never retries a PR that edits the review workflow (that one cannot be reviewed by it at all), and waits out the Claude subscription window when the last attempt died against a quota (about 5 hours, 6 for a weekly limit) before trying again — a pause, not a give-up. If your PR is conflicting and re-review seems stuck on it, merge-tail removes it for you — a conflicting PR produces no run, so nothing else can.

Rebase before you expect a review

A pull request with merge conflicts gets no CI at all — no review, and no red check to tell you so. GitHub builds a PR's test-merge commit before it starts any pull_request workflow. When the branch conflicts with main, that commit cannot be built, so GitHub starts nothing: no run, no failure, no entry in the Actions list. The PR looks reviewed and clean because nothing ever looked at it. This hits every check in the repo at once, not just the review, and no amount of re-labelling or toggling draft will shake a run loose.

So: rebase onto origin/main and push before you open the PR, and again before you ask for a review. If GitHub's PR page says the branch has conflicts, treat every green space on that page as meaningless.

An auto-merge armed on a conflicting PR is the same silence, one step later. It waits on checks that will never run, so it never fires, and nothing tells you it is stuck. It just sits there looking armed.

If you need a review without rebasing first, run the workflow by hand:

gh workflow run claude-code-review.yml -f pr=<number>

Manual dispatch reviews the PR's head directly. It ignores skip-review and draft state (you asked for it explicitly), refuses fork PRs, and clears re-review if the PR is carrying it.

Since the concurrency rework it runs beside an automatic review rather than replacing one. A dispatch has no head SHA in its payload, so it is keyed by PR number and shares no group with the automatic run. If a review is already in flight, dispatching gets you two reviewers, two verdicts and double the subscription spend. Check for a running review check first; the hatch exists for PRs that have no run at all.

Two more differences from an automatic run, because the Claude action treats a manual trigger as having no PR identity:

  • It posts its line-level findings through the GitHub API instead of the action's inline-comment tool. Same result, slightly more turns spent.
  • It reverts .claude/, .mcp.json, CLAUDE.md and .husky in the checkout to the main versions before reviewing, so a PR can never make its own reviewer run hooks the PR wrote. The reviewer still reads those changes from the diff. (An automatic run gets the same protection from the action itself.)

That trusted main workflow can review a PR that edits .github/workflows/claude-code-review.yml and post its verdict. It does not run the PR's proposed workflow or final gate. The automatic run still proves the proposed gate fails closed when validation blocks the review. Dispatch again after the change lands to prove the successful-review path under the merged gate.

A dispatch also cancels an automatic review already running on the same PR, and gets cancelled by the next automatic trigger — the newest request wins.

Review-control labels

LabelEffect
skip-reviewNo automatic review at all. Apply at open (the workflow checks labels on the triggering event).
review:lightQuick pass: only Important findings; skips nits and the deletion sweep.
review:deepExhaustive: traces every caller, runs the full dangling-reference sweep.
re-reviewRequest another pass after addressing feedback. Auto-cleared after the review runs, so re-apply it each time you want another look.

Apply at creation so the opened event sees them:

gh pr create --draft --label review:light --title "..." --body "..."

Request a re-review after pushing fixes:

gh pr edit <number> --add-label re-review
# or, ad hoc, comment `@claude take another look` (handled by claude.yml)

Guidance: reach for skip-review only on genuinely trivial PRs (typo, version bump) where you are the merger and have full context. Prefer review:light over skip-review when in doubt: you still get Important-only coverage at low cost. Use review:deep for risky changes (security, migrations, broad refactors, deletions).

Merging: you arm it, the queue lands it

main merges through a merge queue (ADR 260728-112203). GitHub builds each queued PR on top of main plus everything ahead of it in the queue, runs the required checks on that combined tree, and merges only if they pass. Being behind main blocks nothing, so never update a branch to satisfy a gate: no gh pr update-branch, no merging main in to be current. It starts a fresh round of 19 to 25 Actions jobs against a 60-job pool every agent shares, and it disarms the PR.

Who arms it. merge-tail.yml arms auto-merge on every finished PR, but GitHub throttles its */10 schedule: over 200 scheduled runs (2026-08-25 to 09-19) the median gap was 162 minutes and the p90 305, so it runs roughly every 2-3 hours, not every 10 minutes. It is a backstop. It arms a PR that is open, not a draft, no hold label (hold, do-not-merge, wip, blocked), not conflicting, no requested changes, no unresolved review threads (outdated ones count), and every check settled green with none cancelled. It will not re-arm a PR the queue has rejected twice for the same check on the same head commit; it comments on the PR once instead, and a new commit clears it. The decision is scripts/should-arm-automerge.sh. Arm your own green PR yourself instead of waiting for it; arming is idempotent and safe (one arming path arrives in phase 1b):

gh pr merge --auto <number>

Leave off the strategy flag; the queue owns it. gh pr merge --auto --squash prints ! The merge strategy for main is set by the merge queue, which reads like a refusal and is not (the PR was enqueued, and a repeat call answers "already queued"). --delete-branch is rejected outright. Confirm with gh pr view <n> rather than reacting to stderr.

What an armed PR does not wait for. Once armed, GitHub waits only on the required checks. The Claude review is not a required check and conversation resolution is off, so on an armed PR a red review or an open thread blocks nothing. Only merge-tail's own arming respects them. That is why the order at the top of this file (review the branch, then open the PR) is the safeguard that actually holds: arm at creation only a branch that has already converged.

A new commit disarms auto-merge. GitHub drops the armed state on every push to the PR branch, silently. Re-arm it yourself once the new commit's checks are green; merge-tail's next run may be hours away.

Never an admin merge. gh pr merge --admin, a REST PUT .../pulls/<n>/merge and the mergePullRequest mutation each land a change without the queue's checks, and every agent on this machine runs as an admin. In Claude Code the PreToolUse guard .claude/hooks/merge-guard.mjs refuses them. Other harnesses may not run it (Codex reads a generated, trust-gated .codex/hooks.json, unverified for this guard), so there treat this sentence as the whole rule. Admin merges are reserved for the CI Steward break-glass path (/ci-break-glass, phase 1b, not built yet); see contributing/ci.md.

Arming is not the same as walking away. Read the next section before you treat an armed PR as finished.

The merge lands after your session ends, so clean up on your next visit

Auto-merge and the merge queue both land the PR minutes to hours after you arm it. Whatever cleanup you were planning to do "once it merges" will therefore be proposed to nobody: the session that opened the PR is usually gone. Do not leave the worktree removal as a promise to your future self.

GitHub deletes the branch on origin by itself (the repo has "automatically delete head branches" on). Your local worktree and local branch it cannot see, so those are yours to collect:

bash scripts/worktree-janitor.sh          # what would go, and why
bash scripts/worktree-janitor.sh --fix    # remove it

Run it at the start of a working session rather than the end of one — that way it collects the PRs that merged while you were away, which is most of them. It only removes what it can prove is safe, which is exactly two shapes:

  • a branch whose pull request merged and whose tip has not moved since (a later push means work main has never seen); or
  • a checkout whose every commit is already on origin and whose name origin no longer carries — both halves, because "every commit is on origin" on its own also describes a branch you pushed and have not opened a PR for yet.

Anything uncommitted, unpushed, still open, or unaskable is left alone with a reason. If it cannot reach GitHub it reports pr-state-unknown for every branch and removes nothing.

Branches on origin that never had a PR are a separate, opt-in pass: bash scripts/worktree-janitor.sh --origin lists every origin branch with no open PR and no worktree here; --origin --fix deletes only the ones already in main or whose merged PR had the same tip. The rest it reports for you to decide.

On 2026-08-01 the accumulated cost of not doing this was 116 worktrees at ~3.5 GB each, 193 local branches, and 414 branches on origin, against 5 open PRs. Method and per-item record: research/20260801_worktree-and-branch-sweep.md.

Which checks are required

Only people with write access can arm a PR. In GitHub's words: "People with write permissions to a repository can enable auto-merge for a pull request." An outside contributor on a fork PR cannot arm their own merge.

Ask the repo which checks are required; never assume. Ruleset 19893973 is the only protection on main (classic branch protection was retired on 2026-09-19), so ask the rules API:

gh api repos/{owner}/{repo}/rules/branches/main \
  --jq '[.[] | select(.type=="required_status_checks") | .parameters.required_status_checks[].context]'

The older gh api repos/{owner}/{repo}/branches/main/protection read the classic protection only. It returned 6 of the 9 required checks and missed test, lint and credential-free-build, so an agent trusting it would ignore a red required check. Today's list, generated from ci/required-checks.json (the CI Steward census checks this block byte for byte, so it cannot drift quietly):

<!-- The block below is generated from ci/required-checks.json and checked byte for byte by the CI Steward census; prettier would add blank lines inside it, hence the ignore. --><!-- prettier-ignore-start --><!-- ci-steward:required-checks:start -->
  • typecheck
  • fragment-present
  • no-fragment-under-skip-label
  • version-outranks-base
  • test
  • browser-test
  • lint
  • credential-free-build
  • db-check
  • openapi-fresh
  • fixtures
  • Required workflow: dork-labs/dorkos, .github/workflows/security-gate.yml from refs/heads/main (organization rule; no bypass actors).
<!-- ci-steward:required-checks:end --><!-- prettier-ignore-end -->

Never require a check whose workflow has a paths: filter. That workflow does not run on a PR that touches nothing under those paths, and GitHub leaves the check pending rather than skipping it: "If a workflow is skipped due to path filtering, branch filtering or a commit message, then checks associated with that workflow will remain in a 'Pending' state. A pull request that requires those checks to be successful will be blocked from merging." An auto-merge armed on such a PR waits forever too. The census now fails any required check whose workflow has a paths: filter or does not run on both pull_request and merge_group (the deadlock invariant in contributing/ci.md).

Watching an armed PR: watch the checks, not the merge state

Another silent stall, and the easiest to inflict on yourself. Once a PR is armed you will wait for it, and the obvious poll — "has it merged yet?" — is blind to the one outcome you most need to catch:

gh pr view <number> --json state --jq .state   # OPEN until MERGED/CLOSED — says NOTHING about a failed check

A required check that fails leaves the PR OPEN and unmerged, indistinguishable from a PR whose checks are still running. A merge-state poll loops until its own timeout while the PR sits dead and reports nothing wrong — because from its narrow view nothing is: GitHub is fine, the PR is not. Watch the check conclusions instead:

gh pr checks <number>                                   # one bucket per check: pass | fail | pending | skipping
gh pr view <number> --json statusCheckRollup --jq \
  '[.statusCheckRollup[] | select(.conclusion=="FAILURE") | .name]'   # the failures, by name

Separate a required failure from a standing red. Not every red check blocks the merge, and not every red check is yours. Vercel's preview deploy is frequently red on main itself; a check that fails identically on the last few main commits is a standing condition, not something this PR broke, and it is not in the required set the merge queue gates on. Chasing it burns the attention the actually-blocking check needs. Confirm the required set (the rules API command above), then act only on a required check that went red on this PR.

Do not write the watch loop from memory; run the tested one:

.agents/skills/creating-pull-requests/scripts/watch-prs.sh --interval 120 <number> [<number>...]
# pipe it into the Monitor tool for hands-free notification; --once for a single cycle

Every watcher on this machine spends the operator's one GraphQL budget (5,000 points an hour, shared with merge-tail), so poll no faster than you need: 120 seconds is plenty for a PR whose checks take 15 to 40 minutes.

It reports state transitions (never a once-per-PR "seen" flag that goes blind after the first event; a watcher written that way missed a check failure on 2026-08-24). Each line that asks something of you ends in :: <remedy>, the exact next step, so a Monitor-woken agent does not have to re-read this file. The vocabulary, the remedy text and the collector are pinned by scripts/test-watch-prs.sh; the header of watch-prs.sh is the reference. In precedence order:

TokenWhat it meansWho acts
MERGED, CLOSEDTerminalnobody
EJECTED(reason)The queue dropped the PR (only the REMOVED_FROM_MERGE_QUEUE_EVENT timeline item records this)depends on the reason
EJECTED_REPEAT(failed_checks,k)k failed-checks ejections with no new commit in betweenyou: is it the same job?
CONFLICTINGNeeds a rebase; a conflicting PR runs no CI and no reviewyou
FAILING(names)PR checks red (standing Vercel reds excluded)you, after reading the log
CANCELLED(names)A cancelled check; merge-tail never arms a PR carrying oneyou: re-run it once
STUCK_UNMERGEABLEA dead queue entry that keeps its positionyou, after 10 minutes
STALLED_IN_QUEUE(m)Queued for 90 minutes or more (the queue's p90 is about 55)nobody on your branch
HELD_BY_LABEL(l[,armed])A hold label is on it; ,armed means it was armed anyway and the queue does not read labelswhoever set the label
UNRESOLVED_THREADS(n[,armed])Open threads, outdated included; ,armed means it will merge with them openyou
UNARMED_CLEANGreen and unarmedmerge-tail, within 10 min
QUEUED(pos), PENDINGInformationalnobody
RECOVEREDHealthy again after a red, cancelled, stuck, stalled or blind statenobody
WATCHER BLIND(k cycles)Its gh calls failed k cycles running (auth, network, rate limit)you: gh auth status

Semantics it gets right that ad-hoc loops get wrong: mergeStateStatus: UNKNOWN is retry-not-terminal (mergeability is computed async); checks attach to the head SHA, so the rollup stays correct across reruns; gh pr checks never shows merge-group runs, so queue trouble is read from the queue entry and the timeline, not from check rows (the old STALLED_IN_QUEUE test counted check rows and could never fire); and a hold label beats "green and unarmed" (the old UNARMED_CLEAN remedy said to merge directly, which walked straight past a hold).

On 2026-08-06 the v0.58.0 release PR sat OPEN with a red required typecheck (its prettier --check step — see the worktree gotcha below), while a merge-state-only poll reported nothing and would have run clean to timeout. The fix was not a shorter poll interval; it was polling the right field.

The Monitor tool version of this loop fails the same way when written from memory. On 2026-08-12, PR #982 sat four hours behind one flaked browser-test while its watcher reported healthy: its terminal conditions — merged, closed, unresolved-threads-appeared, kicked-from-the-merge-queue — all stay quiet on a red check, because a PR with a failed required check never enters the queue at all. Three rules for any PR watcher, Monitor or bash:

  • A failed non-Vercel check conclusion must be a terminal condition. A watcher without one cannot see the most common stall, however many other signals it carries.
  • "Auto-merge armed and zero unresolved threads" proves nothing about checks. It is exactly the state every stuck PR was in when it got stuck.
  • A watcher that dies, or goes blind, must say so. watch-prs.sh --max-cycles N announces its own expiry (exit 3), and three failed gh cycles in a row print WATCHER BLIND once instead of silence; keep that shape, and answer it. An armed PR that outlives its watcher deserves a direct gh pr checks look, whatever the watcher reported.

Note the shape of the traps together: a conflicting PR runs nothing, a PR missing a path-filtered required check hangs pending, a held PR is green and will never be armed, and a failed-check PR reads exactly like a slow one. All of them look like a PR that is fine.

Red or ejected: wait first, act once

Twenty agents following one remedy at the same moment is the normal case here, not the edge case. One push to a PR starts 19 to 25 Actions jobs against a 60-job pool (the org is on the GitHub Team plan), so twenty pushes put 400 to 500 jobs in line, and the merge queue's own builds wait behind them. So every remedy below is the cheapest one that works, and none of them is an empty commit. An empty commit costs a full CI round, disarms the PR, and makes CI Steward score a flaky ejection as a real catch, which corrupts the numbers the pipeline is tuned by.

  • EJECTED(failed_checks), the first time: do nothing to the branch. 85% of failed-checks ejections (209 of 247 over 30 days) passed with no change on re-entry. Do not push and do not rerun. Read the failing merge-group job first: if it covers a package you changed, run its tests locally (pnpm vitest run <path>) and fix only if they fail. Once the evidence says it is not yours (the same job red on main or in other groups, or a flake or infra error in its log), re-arm it once yourself with gh pr merge --auto <n>. Green PR checks are not that evidence: the browser tests run only in the queue, so a PR that breaks one reads green everywhere else.
  • EJECTED_REPEAT(failed_checks,k): stop re-arming. If the same check failed again on the same head commit, it is a regression only the queue can see, not a flake. Do not re-arm. Find the job in each merge-group run (gh run list --event merge_group -L 100 --json headBranch,name,conclusion,databaseId, keeping rows whose headBranch contains pr-<n>-), read its log, fix it, push. merge-tail will not re-arm such a PR either: it comments once and waits for a new commit. PR #1964 was re-armed unchanged four times after the same browser assertion failed, costing 17 queue builds, its own and every PR stacked behind it. The one exception is the same check red on main too (a break that landed there, like the wall-clock fixture of 2026-09-17): wait for the fix on main, then re-arm. Different checks each time: still likely flaky; re-arm with gh pr merge --auto <n>.
  • EJECTED(merge_conflict) (or invalid_merge_commit, git_tree_invalid): rebase onto origin/main and push once. EJECTED(manual): someone removed it on purpose; read the timeline before re-arming. EJECTED(checks_timed_out) and STALLED_IN_QUEUE: a queue or runner stall, not your PR; do nothing to the branch and check githubstatus.com if it repeats.
  • FAILING on the PR: read the log first. Caused by your change: fix and push. Not yours (the same job is red on main or on other PRs, or the log shows an infra error such as a lost runner): gh run rerun <run-id> --failed, once. If main was broken when the check ran and is fixed now, rebase onto origin/main and push once: a rerun replays the original merge commit, so it cannot pick up the fix.
  • CANCELLED: re-run the cancelled run once (gh run rerun <run-id>); merge-tail will not arm the PR while it stands.
  • UNARMED_CLEAN: arm it now with gh pr merge --auto <n>. Never merge it directly. (Waiting for merge-tail made sense when it was believed to run every 10 minutes; it runs every 2-3 hours.)
  • HELD_BY_LABEL: not yours to lift unless you added the label.

Clearing a STUCK_UNMERGEABLE queue entry

The merge queue can leave a PR's entry in the state UNMERGEABLE: it is still in the queue and still shows a position, but the queue will never merge it. Because the position is there, a naive watcher reads it as a healthy QUEUED — the false green STUCK_UNMERGEABLE exists to catch. Nothing else surfaces it either: a queued PR reports autoMergeRequest: null, so it does not look armed, and gh pr merge --auto just says "already queued" and changes nothing.

Give it a dwell first: UNMERGEABLE can be the passing state an entry goes through between a failed group and its ejection, so act only when it is still there after about 10 minutes, and never while the queue is in an incident (many PRs ejected or stalled at once), when dequeues fight the recovery.

The fix is to take the PR out of the queue and put it back. Removing it uses the GraphQL dequeuePullRequest mutation. Introspecting the live GitHub GraphQL schema on 2026-08-27 confirmed its input type is DequeuePullRequestInput { id: ID!, clientMutationId: String } — the required id is the PR's node id, not its number — and it returns a DequeuePullRequestPayload { mergeQueueEntry, clientMutationId }. Get the node id from gh pr view <n> --json id, then re-arm auto-merge so it re-enters the queue from a clean start:

PR_ID=$(gh pr view <number> --json id --jq .id)
gh api graphql -f query='
  mutation($id:ID!){ dequeuePullRequest(input:{id:$id}){ mergeQueueEntry { position } } }' \
  -f id="$PR_ID"
gh pr merge --auto <number>   # re-arm; it rejoins the queue from a clean start

The watcher stays read-only — it reports STUCK_UNMERGEABLE and never mutates. Clearing the entry is a deliberate, separate step you run by hand.

Stacked branches and squash merges

Two recurring conflict shapes when several branches share files and main merges by squash, each with a recipe that has worked repeatedly:

  • Your own squashed base conflicts with you. A branch stacked on another branch (or on its own earlier PR) hits "changed in both" conflicts against the squash commit, with byte-identical content on both sides. Pin the base first — BASE=$(git rev-parse origin/main) — then verify each conflicted file is identical between the merge base and $BASE (git diff <base>:<file> "$BASE":<file> — empty means the conflict is pure squash noise), then keep the branch side wholesale. Write files out with git show HEAD:<file> > <file> — git checkout -- <path> is hook-banned here. Confirm with a three-dot diff against $BASE showing only the branch's own work. Reuse $BASE; never name origin/main twice in one comparison — worktrees isolate working trees, not refs, so another session's git fetch moves it between your commands and nothing errors. See working-in-worktrees → Two readers, one ref namespace.
  • A textually clean merge is not a semantically clean one. When sibling branches landed on one seam, git merge origin/main can resolve cleanly while leaving a new route calling a renamed helper, tests asserting copy another PR changed, or a doc paragraph another PR made false. After merging main into any branch whose neighbours touched the same area: run the typecheck, run the neighbouring tests, and regenerate anything derived (OpenAPI export, generated docs) before pushing.

A push is not landed until the remote says so. A compound command ending in ; echo ... exits 0 whatever the push did, so a refused pre-push hook reads as success — three false "pushed" reports in one night (2026-08-11). Confirm with git ls-remote origin <branch> showing the SHA; a remote-ref probe cannot report a false success.

One-time repo setup

The four labels must exist in the repo before they can be applied. Create them once:

gh label create skip-review  --description "Skip the automated Claude review"      --color ededed
gh label create review:light --description "Quick review: Important findings only" --color fbca04
gh label create review:deep  --description "Exhaustive review"                     --color b60205
gh label create re-review    --description "Request another automated review pass" --color 0e8a16

Gotchas

  • A git push killed at the tool ceiling did not necessarily fail. The pre-push test gate can outlast a 10-minute tool call; the kill takes the hook with it, and CI Steward counts it as a killed local-push run: at once when the kill was a TERM, INT or HUP (the time-wrap writes its END with 128+n), and only after the pre-push watchdog's 2-hour ceiling when it was a SIGKILL, which leaves no END, because until then the push might still be running. First check whether the push landed (git ls-remote origin <branch> against git rev-parse HEAD). If it did not, push again in the background or with a longer timeout rather than reaching for --no-verify: CI runs every gate anyway, but a --no-verify push is invisible to the timing that would show the gate needs bounding (the seeded pre-push bounded to about two minutes experiment).
  • A commit made in a fresh worktree bypasses lefthook, so its formatting is never auto-applied. The pre-commit and pre-push hooks shell out to prettier, turbo, and dotenv from node_modules, which a just-created worktree does not have — so lefthook either is not on PATH ("Can't find lefthook in PATH", hook silently skipped) or runs and dies on the missing binaries. Either way the format the hook would have applied never happens, and CI's prettier --check step (inside the required typecheck job) then fails on drift you never saw locally. It bites machine-generated JSON most — a regenerated manifest, a written-out coverage map — since hand-written Markdown passes untouched (proseWrap: preserve). Before pushing from a worktree that has no node_modules, format the changed files with a checkout that does, then re-check:

    # from the primary checkout (which HAS node_modules), pointing at the worktree's files
    ./node_modules/.bin/prettier --write <changed-files-under-the-worktree>

    If the dead hook also blocks the commit or push itself (it runs lint/test and fails on the missing turbo), pass --no-verify — CI runs the real gates on the PR regardless. This is exactly what reddened the v0.58.0 release typecheck.

  • Editing the automated-review workflow makes its automatic run fail closed. The Claude action refuses to start unless the workflow file it is running from matches the copy on main — otherwise a PR could rewrite the workflow to steal the token — and the action itself exits successfully. For .github/workflows/claude-code-review.yml, the always-running verdict gate sees that no review was posted and makes the check red. A manual dispatch uses the trusted workflow from main, so it can still review the PR's head and post a verdict without executing the PR's proposed workflow. The automatic run exercises the proposed gate's fail-closed path; after the merge, dispatch against a real PR again to prove the successful-review path under the merged version: gh workflow run claude-code-review.yml -f pr=<number>. The guard is per file: editing .github/workflows/claude.yml can silence @claude on that PR without disabling claude-code-review, and vice versa.
  • A finished review is a green check, even when the action failed. The action re-counts turns after the run and fails the step if a clean run overshot --max-turns — which used to red a review that had already posted its findings and its tally, and merge-tail will not arm a PR with a red check. It no longer does: when the review finished and its verdict is on the PR, the check goes green and you get no comment at all, just a warning annotation on the Actions run (DOR-1665). So a green claude-code-review check means the current run posted a recognized verdict. A conflicting PR has no automatic run at all, while a PR editing this review workflow gets a red automatic check and needs the trusted manual dispatch described above.
  • A red review check is not always a finding. When the review breaks in a way that cost you the verdict, it posts a comment saying so and naming which of six things happened:

    • This PR edits the review workflow. The action refuses to run from a copy of its own workflow that differs from main — a pull request must not be able to rewrite the file holding the reviewer's credentials — so it exits in under two seconds having reviewed nothing. Expected, and the check stays red on purpose. Re-running or re-review does the same thing every time; the only way to get this PR reviewed is the trusted dispatch above.
    • It never started. It ended without naming a cause, after one turn or fewer and with nothing spent, so nothing in the PR was looked at. The Claude subscription behind CLAUDE_CODE_OAUTH_TOKEN hit its usage limit (clears on its own) or the token needs regenerating.
    • It ran out of its turn budget. It reviewed, then hit the cap.
    • It hit an error. The run ended with an error it named itself — a usage limit crossed mid-review, or a tool or MCP server that failed to start. The comment quotes what it said. Not a turn-budget problem, and not about your code.
    • It finished but posted nothing. The review reported success and no verdict reached the PR, so nothing here has actually been reviewed. (If it finished and posted a verdict, the check is green — see above.)
    • It could not tell. The comment points you at the Actions log rather than guessing.

    In the middle two, whatever it already posted is worth reading, but the pass was cut short, so a re-review is owed. Read the comment before you go hunting in your diff. The wording comes from scripts/classify-review-failure.sh, and the shapes it must get right are pinned by scripts/test-review-classifier.sh (run by pnpm verify and by the scripts-test workflow) — DOR-457 was that comment confidently naming the wrong cause nine times, and then doing it again for a different shape that had no fixture, so add a fixture if you touch it.

  • A red lint check is often the formatting gate, not ESLint. The required lint workflow runs pnpm format:check as its first step (moved there from typecheck in the DOR-627 follow-up, 2026-09-02), so a prettier miss reports under the lint name — the log's turbo section can print everything successful while the check is red from the step above it. Read the log before assuming lint errors. Two related traps: a local whole-repo format:check on a branch behind main reports files that are not yours (merge origin/main in before believing it), and a merge commit made with LEFTHOOK=0 skips the format hook, so merge-combined files reach CI unformatted — after any hook-skipped commit, pnpm exec prettier --write the touched files.
  • The pre-push --affected gate pins its own base — do not pull to shrink it. Until DOR-1717 the hook diffed against the local main ref, so a shared checkout sitting behind origin exploded the affected set to the whole repo and hit unrelated flakes; the workaround was to git pull --ff-only in the clean main checkout before pushing (it bit three times in one session, 2026-08-25). The hook now sets TURBO_SCM_BASE to origin/main itself, so the local ref's position no longer affects anything and that ritual is obsolete — pulling in a checkout another agent is using is a worktree hazard for no gain. If the gate still selects more than your branch touched, the cause is a real dependency edge, turbo.json, or pnpm-lock.yaml (both global inputs), not staleness.
  • A schema or route change owes a regenerated OpenAPI export. Anything touching packages/shared schemas that project into the API, or server routes, needs pnpm docs:export-api (and the site's generate:api-docs) in the same branch, or openapi-fresh goes red.
  • A stalled merge queue may be GitHub, not you. Before debugging why merge-group runs sit "queued" for hours, check githubstatus.com. A 2026-08-26 Actions outage held every queue entry for about 4 hours.
  • Changelog populator. A post-commit hook writes a changelog fragment under changelog/unreleased/ from the commit subject (it dedupes across amend/rebase and never touches CHANGELOG.md). For changes that should not land in the user-facing changelog, touch .claude/.changelog-populator.lock before committing (the lock is gitignored) and delete any fragment it already wrote.
  • A new Linear issue lands in Triage, not the backlog. issueCreate without an explicit stateId leaves the issue in the team's triage queue, where it is easy to miss; two issues created on 2026-07-28 had to be moved by hand. Normally this is not yours to get right — all tracker I/O routes through the /flow linear-adapter skill (AGENTS.md), which sets state for you. Pass stateId yourself only as a stopgap, when you are calling the API directly because the adapter is unreachable.
  • The review is non-blocking, for now. It posts comments and is not one of the required checks, so the queue does not wait on it. It still matters twice: a red review check makes merge-tail skip the PR (until the next push, since the check belongs to the old SHA), and a PR someone armed early merges with its findings open. Address findings before the PR is armed. CI Steward phase 2 plans a required, always-running review gate that goes red on an open Important finding.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.