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
- thesammykins/skillstheir own version, first seen Nov 1, 2026
- gerph/riscos-agent-skillstheir own version, first seen Nov 1, 2026
- lightdash/lightdashtheir own version, first seen Oct 8, 2026
- streamlit/streamlittheir own version, first seen Oct 8, 2026
- weselben/RooForgetheir own version, first seen Aug 17, 2026
- codecharmhq/claude-code-skillstheir own version, first seen May 17, 2026
Other versions6 projects use a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 6
- streamlit/streamlit★ 46ksame name
- lightdash/lightdash★ 6.2ksame name
- gerph/riscos-agent-skills★ 4same name
- weselben/RooForge★ 4same name
- codecharmhq/claude-code-skills★ 1same name
- thesammykins/skills★ 0same name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository44 other files it gives its agents.
- AGENTS.mdAGENTS.md
- .claude/CLAUDE.mdCLAUDE.md
- auditing-uiSkill
- browser-testingSkill
- capturing-product-mediaSkill
- ci-local-exportSkill
- ci-pulseSkill
- ci-quarantineSkill
- ci-statusSkill
- debugging-systematicallySkill
- debugging-test-failuresSkill
- debugging-typescript-errorsSkill
- deps-weeklySkill
- designing-frontendSkill
- opensrcSkill
- organizing-fsd-architectureSkill
- runtime-watchSkill
- stewarding-ci-pipelineSkill
- syncing-agent-skillsSkill
- verification-before-completionSkill
- vision-drift-checkSkill
- visual-companionSkill
- writing-adrsSkill
- writing-app-copySkill
- writing-blog-postsSkill
- writing-changelogsSkill
- writing-developer-guidesSkill
- writing-for-humansSkill
- writing-to-usersSkill
- adding-config-fieldsSkill
- clarifying-requirementsSkill
- maintaining-dependenciesSkill
- maintaining-dev-playgroundSkill
- managing-specsSkill
- marketplace-devSkill
- orchestrating-parallel-workSkill
- reading-session-transcriptsSkill
- receiving-code-reviewSkill
- requesting-code-reviewSkill
- styling-with-tailwind-shadcnSkill
- test-driven-developmentSkill
- upgrading-runtime-dependenciesSkill
- working-in-worktreesSkill
- dorkbot-triageSkill
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
- Distinctive: line 45
Every reason below is a cost measured on 2026-07-27/28, not a preference:
- Very specific: line 77
Commit conventions and the pre-push gate live in the git:commit / git:push commands.
- Very specific: line 85
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 …
- Very specific: line 183
Start with /ci-status (pnpm ci:status): it names the current constraint, the SLOs and every open experiment's verdict, which is where a …
- Very specific: line 203
Never write verified, partial, failed or inconclusive as a status.
- Distinctive: line 334
If you need a review without rebasing first, run the workflow by hand:
- Very specific: line 395
Use review:deep for risky changes (security, migrations, broad refactors, deletions).
- Very specific: line 422
Leave off the strategy flag.
- Distinctive: line 447
Arming is not the same as walking away: Read the next section before you treat an armed PR as finished.
- Distinctive: line 466
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 …
- Very specific: line 561
Separate a required failure from a standing red: Not every red check blocks the merge, and not every red check is yours.
- Distinctive: line 567
required: check that went red on this PR.
- Distinctive: line 576
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 …
- Very specific: line 660
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 … - Very specific: line 677
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> …
- Very specific: line 686
HELD_BY_LABEL: not yours to lift unless you added the label. - Very specific: line 740
A textually clean merge is not a semantically clean one: When sibling branches landed on one seam, git merge origin/main can resolve …
- Very specific: line 796
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 …
- Distinctive: line 823
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 …
- Distinctive: line 883
A stalled merge queue may be GitHub, not you: Before debugging why merge-group runs sit "queued" for hours, check githubstatus.com.
“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.
Files
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:
- Build in an isolated worktree and run the local gates.
- Push the branch. Open nothing.
- A reviewer fetches and checks out that branch. It does not need a PR.
- Findings, fixes, convergence.
- Squash to one clean commit carrying one changelog fragment.
- 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
mainmove under it. One night of that took roughly a dozen branch updates (the pre-queue rule, retired 2026-07-28) and still left aDIRTYPR 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-presentfails. 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 helperCommit 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 initon that PR's branch, thengh stack add <your-branch>, thengh stack submit). Your PR's base is the branch below it, so its diff is only your change. - There is none: branch from
origin/mainas 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(ordocs((git commit --amend, thengit 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-onlyThe 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>(--helplists its flags). Check it:node packages/ci-steward/src/cli.ts ledger-check. The PR-only coverage question isnode packages/ci-steward/src/cli.ts ledger-check --coverage --base "$(git merge-base origin/main HEAD)". - A pipeline change carries a hypothesis.
kind:isexperiment,incident-fixorhygiene, and every kind buthygienenames one metric, a baseline, a target andafter_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-statusand 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,failedorinconclusiveas a status. Those are verdicts the machine computes, and the ledger check rejects them onmain. - The coverage step ("CI Steward ledger coverage" in the required
typecheckjob) 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 reviewA 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 appears | On merge |
| A bare id in the title or the branch name | Closes the ticket |
A magic word anywhere, body included — Closes/Fixes/Resolves | Closes the ticket |
A bare id in the body, or Refs DOR-634 | Links 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:
| Event | Review? |
| PR opened (non-draft) | One full review |
| Draft marked ready-for-review | One full review of the final state |
| New commits pushed | No auto-review (CI tests still run) |
re-review label applied | One re-review, scoped to the delta |
| PR has merge conflicts | Nothing runs at all — see below |
| PR edits this review workflow | Auto-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.mdand.huskyin the checkout to themainversions 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
| Label | Effect |
skip-review | No automatic review at all. Apply at open (the workflow checks labels on the triggering event). |
review:light | Quick pass: only Important findings; skips nits and the deletion sweep. |
review:deep | Exhaustive: traces every caller, runs the full dangling-reference sweep. |
re-review | Request 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 itRun 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
mainhas 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 -->typecheckfragment-presentno-fragment-under-skip-labelversion-outranks-basetestbrowser-testlintcredential-free-builddb-checkopenapi-freshfixtures- Required workflow: dork-labs/dorkos,
.github/workflows/security-gate.ymlfromrefs/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 checkA 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 nameSeparate 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 cycleEvery 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:
| Token | What it means | Who acts |
MERGED, CLOSED | Terminal | nobody |
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 between | you: is it the same job? |
CONFLICTING | Needs a rebase; a conflicting PR runs no CI and no review | you |
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 one | you: re-run it once |
STUCK_UNMERGEABLE | A dead queue entry that keeps its position | you, 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 labels | whoever set the label |
UNRESOLVED_THREADS(n[,armed]) | Open threads, outdated included; ,armed means it will merge with them open | you |
UNARMED_CLEAN | Green and unarmed | merge-tail, within 10 min |
QUEUED(pos), PENDING | Informational | nobody |
RECOVERED | Healthy again after a red, cancelled, stuck, stalled or blind state | nobody |
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-
Vercelcheck 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 Nannounces its own expiry (exit 3), and three failedghcycles in a row printWATCHER BLINDonce instead of silence; keep that shape, and answer it. An armed PR that outlives its watcher deserves a directgh pr checkslook, 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 onmainor in other groups, or a flake or infra error in its log), re-arm it once yourself withgh 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 whoseheadBranchcontainspr-<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 onmaintoo (a break that landed there, like the wall-clock fixture of 2026-09-17): wait for the fix onmain, then re-arm. Different checks each time: still likely flaky; re-arm withgh pr merge --auto <n>.EJECTED(merge_conflict)(orinvalid_merge_commit,git_tree_invalid): rebase ontoorigin/mainand push once.EJECTED(manual): someone removed it on purpose; read the timeline before re-arming.EJECTED(checks_timed_out)andSTALLED_IN_QUEUE: a queue or runner stall, not your PR; do nothing to the branch and check githubstatus.com if it repeats.FAILINGon the PR: read the log first. Caused by your change: fix and push. Not yours (the same job is red onmainor on other PRs, or the log shows an infra error such as a lost runner):gh run rerun <run-id> --failed, once. Ifmainwas broken when the check ran and is fixed now, rebase ontoorigin/mainand 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 withgh 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 startThe 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 withgit show HEAD:<file> > <file>—git checkout -- <path>is hook-banned here. Confirm with a three-dot diff against$BASEshowing only the branch's own work. Reuse$BASE; never nameorigin/maintwice in one comparison — worktrees isolate working trees, not refs, so another session'sgit fetchmoves it between your commands and nothing errors. Seeworking-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/maincan 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 mergingmaininto 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 0e8a16Gotchas
- A
git pushkilled 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 killedlocal-pushrun: 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>againstgit 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-verifypush is invisible to the timing that would show the gate needs bounding (the seededpre-push bounded to about two minutesexperiment). 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, anddotenvfromnode_modules, which a just-created worktree does not have — so lefthook either is not onPATH("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'sprettier --checkstep (inside the requiredtypecheckjob) 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 nonode_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/testand fails on the missingturbo), pass--no-verify— CI runs the real gates on the PR regardless. This is exactly what reddened the v0.58.0 releasetypecheck.- 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 frommain, 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.ymlcan silence@claudeon that PR without disablingclaude-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 greenclaude-code-reviewcheck 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 orre-reviewdoes 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_TOKENhit 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 byscripts/test-review-classifier.sh(run bypnpm verifyand by thescripts-testworkflow) — 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.- This PR edits the review workflow. The action refuses to run from a copy
of its own workflow that differs from
- A red
lintcheck is often the formatting gate, not ESLint. The requiredlintworkflow runspnpm format:checkas its first step (moved there fromtypecheckin 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-repoformat:checkon a branch behindmainreports files that are not yours (mergeorigin/mainin before believing it), and a merge commit made withLEFTHOOK=0skips the format hook, so merge-combined files reach CI unformatted — after any hook-skipped commit,pnpm exec prettier --writethe touched files. - The pre-push
--affectedgate pins its own base — do not pull to shrink it. Until DOR-1717 the hook diffed against the localmainref, so a shared checkout sitting behind origin exploded the affected set to the whole repo and hit unrelated flakes; the workaround was togit pull --ff-onlyin the clean main checkout before pushing (it bit three times in one session, 2026-08-25). The hook now setsTURBO_SCM_BASEtoorigin/mainitself, 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, orpnpm-lock.yaml(both global inputs), not staleness. - A schema or route change owes a regenerated OpenAPI export. Anything
touching
packages/sharedschemas that project into the API, or server routes, needspnpm docs:export-api(and the site'sgenerate:api-docs) in the same branch, oropenapi-freshgoes 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-commithook writes a changelog fragment underchangelog/unreleased/from the commit subject (it dedupes across amend/rebase and never touchesCHANGELOG.md). For changes that should not land in the user-facing changelog,touch .claude/.changelog-populator.lockbefore committing (the lock is gitignored) and delete any fragment it already wrote. - A new Linear issue lands in Triage, not the backlog.
issueCreatewithout an explicitstateIdleaves 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/flowlinear-adapterskill (AGENTS.md), which sets state for you. PassstateIdyourself 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.
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
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.

