Git Workflow Skill Not Here Releases: . BLOCKED-PR diagnosis: . GitHub only. Everything below the branching and commit sections — , , the merge gate, review threads — speaks GitHub GraphQL. For a GitLab merge request the equivalent calls are in § GitLab: the same gate with . Critical Rules (Non-Negotiable) 1. No direct push to main — always open a PR. 2. No merge before all threads resolved ( ). 3. No squash unless asked — preserves atomic commits, signatures, bisection. 4. No "tested/verified/working" without pasted command output — else say so. 5. No edits to installed skill/plugin cache pa…

)\n\n# Right: name the segment\nrid=$(gh api \"repos/$R/check-runs/$ID\" --jq '.details_url' \\\n | grep -oE 'runs/[0-9]+' | cut -d/ -f2)\n\n# Or take the run id straight from the commit's check-runs\ngh api \"repos/$R/commits/$SHA/check-runs?per_page=100\" --paginate \\\n --jq '.check_runs[] | select(.status != \"completed\")\n | \"\\(.name)\\trun=\\(.details_url | capture(\"runs/(?\u003cr>[0-9]+)\").r)\"'\n```\n\nBecause the exit code cannot distinguish \"watched and passed\" from \"never\nfound the run\", assert the run exists before watching it, or re-read the\ncheck's state afterwards rather than trusting the watcher's return.\n\n### `commits/\u003csha>/check-runs` returns one entry per run, not per check\n\nA second workflow run on the **same commit** — a closed-and-reopened pull\nrequest, a `workflow_dispatch`, an `on: schedule` firing — adds another set of\ncheck-runs beside the first. The old ones keep their old conclusion, so a\ncounter over that endpoint reports failures that no longer exist:\n\n```\nAll security checks: success 22:13:53\nAll security checks: failure 22:10:01 \u003c- previous run, same SHA\nfuzz / Preflight (event gate): success 22:11:32\nfuzz / Preflight (event gate): failure 22:09:34 \u003c- previous run, same SHA\n```\n\nMeasured on a pull request whose fuzz preflight was red until an upstream\nworkflow was fixed, then closed and reopened. On that one commit:\n\n| Query | Failures reported |\n| --- | --- |\n| every check-run with `conclusion == \"failure\"` | 2 |\n| newest run per name only | 0 |\n| `gh pr checks` | 0 |\n\nNote which names duplicate: the two that had actually failed. Checks that were\ngreen in both runs duplicate too, but silently — so a spot check on a green\nname shows two identical entries and looks harmless.\n\n`gh run rerun` does **not** do this — it updates the existing check-run in\nplace. Verified on two pull requests where a rerun turned a red check green:\none entry each, conclusion `success`.\n\nSo when the question is \"is this pull request green now\":\n\n```bash\n# Right: gh pr checks reports the latest attempt per check\ngh pr checks \"$PR\" --repo \"$R\" --json name,bucket\n\n# If you must use the API, keep only the newest run per name\ngh api \"repos/$R/commits/$SHA/check-runs?per_page=100\" --paginate \\\n --jq '[.check_runs[]] | group_by(.name)\n | map(max_by(.started_at)) | map(select(.conclusion == \"failure\")) | length'\n```\n\n### Ask which step failed before reading any log\n\nThe jobs API names it in one call, and the name is usually enough to reproduce\nthe failure locally:\n\n```bash\ngh api repos/$R/actions/jobs/$JOB \\\n --jq '.steps[] | select(.conclusion==\"failure\") | .name'\n```\n\nReaching for the log first costs rounds that return nothing, because the\nfailing step's output is often not in what you get back (see the next section)\nand every keyword filter then matches the surrounding noise instead —\nprovisioning, `tar` invocations, a `harden-runner` audit stream, and in one case\na validator's own green `Errors: 0` summary. Four such calls produced no\ninformation about a failure whose step was called `Python lint`; the API call\nabove answered on the first try.\n\n#### Reproduce with the version CI runs, not the one your cache holds\n\n\"Usually enough to reproduce locally\" has a condition attached: the same tool at\nthe same version. A step written as `uvx ruff check .` or `npx \u003ctool>` resolves\nto the newest release on every CI run, while the local cache can be months old\nand answer green on the identical file. The local pass then reads as \"the check\nis wrong\" or \"it is an infrastructure problem\", and the actual finding is never\nseen.\n\nRead the version out of the workflow file, and when it is unpinned force the\ncurrent one:\n\n```bash\ngrep -nE 'uvx |npx |pip install|--version' .github/workflows/\u003cfile>.yml\nuvx ruff@latest check . # what an unpinned CI step actually runs\nuvx [email protected] check . # or the pin, where there is one\n```\n\nMeasured: a required check went red on scripts that had not changed, reporting\n`FURB105`, `EXE001` and `BLE001`, while the locally cached ruff passed the same\nfiles. All three rules are non-preview in current ruff, so the CI runner had\nsimply moved to a release that enforces them and the cache had not. Three real\nfindings, invisible until the versions matched.\n\n### A green pre-commit run is not a green CI lint step\n\nTwo distinct reasons, and the second is the one that is easy to miss.\n\n**The run was not green.** `pre-commit` reports a reformatting hook as a\nfailure, and the report is easy to discard: `pre-commit run --files … | grep -vE\n'Skipped|Passed' | tail -4` printed `- files were modified by this hook` and `2\nfiles reformatted`, which was read as success. Never filter or truncate\n`pre-commit` output — `files were modified by this hook` is a failed run, and\nthe hook names are how you tell which.\n\n**The versions differ.** Even a genuinely green run proves only what the\n*pinned* version thinks. `.pre-commit-config.yaml` pins\n`astral-sh/ruff-pre-commit` by `rev`, while the CI step pins its own\n(`uvx [email protected] check`). Measured on the same file: ruff 0.15.14, the\npre-commit pin, reported `All checks passed!` while ruff 0.16.0, the CI pin,\nfailed it on `SIM905`. The rule was newer than the hook. So keep the two pins in\nparity and let dependency updates move them together; when they disagree, the\nCI's version is the one that decides, and running the linter merely \"by name\" is\nnot enough.\n\n### The step output of a failed job comes from the RUN's log archive\n\n`gh api repos/$R/actions/jobs/$JOB/logs` and `gh run view --job $JOB --log`\nroutinely hand back only the runner's own preamble — image provisioning,\nhardening-agent chatter, `Cleaning up orphan processes` — with the failing\nstep's output absent entirely. One of them can also return an empty body and\nexit 0. Filtering that noise then produces nothing and reads as \"the log says\nnothing about why it failed\", which sends you diagnosing the wrong layer.\n\nDownload the **run's** archive instead. It contains one text file per job, with\nthe real step output:\n\n```bash\ngh api \"repos/$R/actions/runs/$RUN/logs\" > /tmp/logs.zip\nunzip -q -o /tmp/logs.zip -d /tmp/logs\ngrep -rn '##\\[error\\]' /tmp/logs | grep -viE 'armour|agentservice|pam_unix'\n```\n\nOn 2026-08-12 this was the difference between three unexplained merge-queue\nejections and one line — `[ERROR] Undefined constant …PHPUnitSetList::PHPUNIT_110`\n— that named the cause outright. The job-level calls had been tried first and\nshowed nothing but setup.\n\n**While the run is still in progress, the archive does not exist yet** —\n`gh run view --log` answers `run … is still in progress; logs will be\navailable when it is complete`, and so does the run-archive endpoint. A job\nthat has already FAILED inside that running run is still readable through the\njob endpoint, with two traps: `gh api` refuses a body containing terminal\nescape sequences unless told otherwise, and the body arrives ANSI-colored:\n\n```bash\ngh api \"repos/$R/actions/jobs/$JOB/logs\" --allow-escape-sequences \\\n | sed 's/\\x1b\\[[0-9;]*m//g'\n```\n\nWithout the flag the only output is\n`the response contains terminal escape sequences; pass --allow-escape-sequences\nto output it anyway` — one line, exit 0, easy to mistake for an empty log.\nMeasured on 2026-08-18 (a matrix leg had failed while the rest of the run was\nstill executing): the job-level body carried the full PHPUnit failure, so \"job\nlogs show only preamble\" (above) is a per-job lottery, not a law — try the job\nendpoint first while the run lives, fall back to the run archive once it is\ncomplete.\n\nNote the archive is per *run*, so for a queue ejection you need the\n`gh-readonly-queue/*` run, not the pull request's own:\n\n```bash\ngh run list --repo \"$R\" --limit 20 --json headBranch,databaseId,conclusion \\\n --jq '.[] | select(.headBranch | startswith(\"gh-readonly-queue\"))'\n```\n\nHand-rolled `gh pr checks | jq` poll loops re-derive those semantics from\nundocumented field shapes (a running check's `conclusion` may be `\"\"`, `null`,\nor absent) and are a recurring source of bugs. The sharpest one: a poll run\n**immediately after a push, reopen, or re-trigger** reads \"0 pending\" *before*\nthe freshly-queued run has registered, so the loop reports a false \"all green\"\nand you act prematurely. A bare `[ \"$pending\" -eq 0 ] && break` snapshot is true\nboth before runs start and after they finish — it cannot tell the two apart.\n\nIf you must hand-roll (e.g. watching something with no native watcher), gate on\na **named required check reaching a terminal `pass`/`fail` state**, never on a\nzero-pending count, and confirm the run belongs to the current head SHA first.\n\n`pr-status.sh --json` answers this in one field: **`checks_settled`** is true\nonly when nothing is pending, every required context has reported at least\nonce, *and* no job that never got a runner belongs to a run that is still\ngoing (see `merge-gate-watcher.md`, \"Jobs that never started\"). Gate on that\nrather than re-deriving it — and note the `NEXT:` line does not carry it,\nbecause the review branches of the ladder outrank every CI branch. On a repo\nwith the `copilot_code_review` ruleset, `NEXT:` says `request-review` from the\nsecond a commit lands and keeps saying it; the CI state now rides along in\nthat answer's `why`, but a script should read the field.\n\n**`gh run list --jq` is not `jq`: it takes no `--arg`.** The filter is evaluated\nby `gh`'s embedded jq, which accepts the expression and nothing else, so\n`--jq --arg s \"$SHA\" '… select(.headSha==$s) …'` exits with\n`unknown command \"s\" for \"gh run list\"`. Inside a poll loop that failure is\nsilent in the worst way: `pending=$(gh run list … )` captures the empty output,\nthe `[ \"$pending\" = \"0\" ]` test is false forever, and the loop reports \"still\nrunning\" until its timeout — describing a condition it never actually\nevaluated. Filter with the flag instead of a jq variable:\n\n```bash\ngh run list --repo \"$R\" --commit \"$SHA\" --json name,status,conclusion \\\n --jq '.[] | \"\\(.status) \\(if (.conclusion // \"\") == \"\" then \"-\" else .conclusion end) \\(.name)\"'\n```\n\n**`//` does not catch the empty string, and a running run's `conclusion` is `\"\"`.**\njq's alternative operator only replaces `null` and `false`, so `\\(.conclusion // \"-\")`\nrenders a running run as an empty field, and a loop that treats \"conclusion is\nnon-empty\" — or `.conclusion // .status` — as \"finished\" reports a run that is\nstill going as done. Test the emptiness explicitly, as above, or filter on\n`.status != \"completed\"`. This cost a false \"all green\" on three workflows that\nwere still queued.\n\n`--commit` (`-c`) is the supported way to scope runs to one SHA, and it is what\nthe merge-triggered runs on the base branch need after a merge — `--branch main`\nalone also matches the runs of every earlier merge.\n\n**`--commit` takes the full 40-character SHA.** A short SHA matches nothing and\nreturns an empty list with exit 0, not an error: `--commit 63df7f6` listed 0\nruns where the full SHA of the same commit listed 1. The REST filter\n`actions/runs?head_sha=` behaves the same way (`total_count` 0 against 1), and\nthe watchers in `merge-gate-watcher.md` are built on it. What the empty list\ndoes depends on the loop: one that waits for a run to appear waits out its\nbudget and reports \"no run\"; one that counts only unfinished runs reads\n`pending=0` and reports \"settled\" at once. The exit-status guard\nbelow does not catch it, because the query succeeded — require at least one\nrun, as the loop below and `merge-gate-watcher.md` (\"true before any run\nexists\") do. Resolve\nthe SHA first, with `SHA=$(git rev-parse --verify \"$REF^{commit}\") || exit 1`\nor `headRefOid` from `gh pr view --json headRefOid`. Plain `git rev-parse \"$REF\"`\nechoes an unknown short hash back unchanged and exits 128 inside the\nassignment, so it hands the same short string on.\n\nThe general rule behind it: **a polling loop must distinguish \"the query failed\"\nfrom \"the condition is not met yet\"**, and it has to *act* on the difference.\nCounting instead of testing emptiness is not enough on its own — a failed query\nstill yields the empty string, and `[ \"$pending\" = \"0\" ]` reads that as \"not met\"\nand keeps polling. Gate on the exit status, or refuse a value that is not a\nnumber:\n\n```bash\nLIMIT=200 # gh run list returns 20 runs unless told otherwise\nif ! out=$(gh run list --repo \"$R\" --commit \"$SHA\" --limit \"$LIMIT\" --json status 2>&1); then\n echo \"query failed: $out\" >&2; exit 1\nfi\ntotal=$(printf '%s' \"$out\" | jq 'length')\npending=$(printf '%s' \"$out\" | jq '[.[] | select(.status != \"completed\")] | length')\nfor n in \"$total\" \"$pending\"; do\n case $n in ''|*[!0-9]*) echo \"unusable count: ${n@Q}\" >&2; exit 1 ;; esac\ndone\n# A full page may hide an unfinished run beyond it: refuse, do not count on.\n[ \"$total\" -lt \"$LIMIT\" ] || { echo \"run list truncated at $total\" >&2; exit 1; }\n# No run yet is not \"all finished\": wait until at least one exists.\n[ \"$total\" -gt 0 ] && [ \"$pending\" -eq 0 ] && break\n```\n\nA loop that cannot fail loudly waits out its whole timeout and then reports on a\ncondition it never evaluated.\n\n## Before you add or edit a workflow file: read the repo's Actions policy\n\nA workflow that violates the repository's Actions policy fails at **`Set up job`**\n— before a single step runs — so the log shows no step output and the failure\nlooks unrelated to the change. One API call answers it:\n\n```bash\ngh api repos/$R/actions/permissions --jq '{allowed_actions, sha_pinning_required}'\ngh api orgs/${R%/*}/actions/permissions --jq '{allowed_actions, sha_pinning_required}'\n```\n\nCheck the **org** as well: it can require pinning that the repo's own setting\ndoes not mention. With `sha_pinning_required: true`, every `uses:` needs a full\ncommit SHA — a tag ref (`actions/checkout@v6`) is refused:\n\n```bash\ngh api repos/actions/checkout/git/ref/tags/v6 --jq '.object.sha' # then: @\u003csha> # v6\n```\n\nThe same policy is what the workflow-security linters enforce, so the cost of\nskipping this check is not one red check but several: adding a job with two\nunpinned `uses:` turned **six** checks red at once — `Set up job`, zizmor,\nOpengrep, CodeQL, SonarCloud and the aggregate security gate — all reporting the\nsame two lines. Prefer dropping an action over pinning it where the runner\nalready provides the tool (`setup-php` for a script that needs no extensions,\n`setup-node` for a plain `npx`).\n\n## A CI linter's finding is not refuted by a differently-scoped local run\n\nRunning the same linter locally and getting the same *number* of findings is not\nevidence that they are the same findings. Two axes differ routinely:\n\n- **Scope.** A CI integration usually reports only what the pull request\n *changed*; a local invocation lints the whole file or the whole tree.\n- **Policy source.** The CI action may load a config, a baseline, or an\n organisation policy the local binary does not see — and vice versa.\n\nObserved: a local `zizmor` run flagged three unpinned `uses:` and the same three\nappeared on the base branch, which read as \"pre-existing, not mine\". CI's zizmor\nreports changed code only, and its three findings were the author's own two new\nlines plus a note on them. Same count, disjoint findings, and the wrong\nconclusion was stated publicly before the alerts were read.\n\nCompare **locations**, not counters:\n\n```bash\ngh api \"repos/$R/code-scanning/alerts?tool_name=\u003ctool>&pr=$PR&state=open\" \\\n --jq '.[] | \"\\(.rule.id)\\t\\(.most_recent_instance.location.path):\\(.most_recent_instance.location.start_line)\"'\n```\n\nIf the paths and lines are yours, the finding is yours — whatever the local run\nsays.\n\n## A presence check is not a correctness check: assert the count, not the existence\n\nA pipeline gate written as \"the thing is there\" passes for every wrong number of\nthe thing. One repository ended its publish job with\n\n```bash\ngrep -q '\u003cselect name=\"repository\"' public/index.html \\\n || { echo \"dropdown injection failed\" >&2; exit 1; }\n```\n\nwhich is satisfied by one copy and equally by the 190 that had silently\naccumulated, because the injecting script was not idempotent and each run\nappended another. The gate existed precisely to catch a broken injection and was\nstructurally blind to the failure that happened. It shipped 496 KB index pages\nfor months.\n\nWhere a build injects, generates or de-duplicates something, the assertion is a\ncount and an equality, across every artifact rather than the one that happens to\nbe convenient:\n\n```bash\nseen=0\nwhile IFS= read -r -d '' f; do\n seen=$((seen + 1))\n n=$(grep -o '\u003cselect name=\"repository\"' \"$f\" | wc -l)\n [ \"$n\" -eq 1 ] || { echo \"$f carries $n, expected exactly 1\" >&2; exit 1; }\ndone \u003c \u003c(find public -name index.html -print0)\n[ \"$seen\" -gt 0 ] || { echo \"no index.html found — nothing was checked\" >&2; exit 1; }\n```\n\nThree shapes in that loop, each of which has silently broken a gate:\n\n- **`-print0` with a NUL-delimited read**, not `for f in $(find …)`. The\n unquoted substitution word-splits, so a path containing a space becomes two\n paths and the gate checks neither.\n- **Process substitution, not a pipe.** `find … | while …` puts the loop in a\n subshell, where `exit 1` ends the subshell and leaves the caller running — the\n gate then reports its failure to nobody, unless `set -e` and `pipefail` happen\n to be on in whatever copied it.\n- **Fail closed on an empty list.** Finding no artifacts is not a pass. A gate\n that validated zero files is exactly as green as one that validated all of\n them, which is how a renamed output directory goes unnoticed.\n\nTwo habits follow:\n\n- **`grep -q` answers \"at least one\".** Count instead — but count *matches*:\n `grep -c` counts matching **lines**, so generated or minified output that puts\n several occurrences on one line reads as 1 and the gate passes on exactly the\n artifact most likely to be wrong. `grep -o … | wc -l` is the one that answers\n the question asked.\n- **Prove the guard fails.** Disable the fix, run the gate, watch it exit\n non-zero with the message you expect, restore. A gate never seen red is a\n claim; the run that reddens it is the evidence. The same session's idempotency\n fix passed its own count check while still growing the file by one byte per\n run — the count was right and the artifact was not, which only a\n byte-comparison of two consecutive runs surfaced.\n\n## Git Mirror Repositories\n\nUse `git clone --mirror` + `git push --mirror` to keep a target repository in sync with an\nupstream source — for example, mirroring a public TYPO3 repository into a private GitLab\ninstance or creating read-only forks for controlled distribution.\n\n```bash\ngit clone --mirror \"$SOURCE_URL\" repo.git\ncd repo.git\ngit push --mirror \"$TARGET_URL\"\n```\n\n### Default Branch Requirement\n\n`git push --mirror` pushes **all** refs from the source and **deletes** any ref at the target\nthat no longer exists in the source. GitLab and GitHub will refuse to delete their repository's\ndefault branch, causing the push to fail with an error like:\n\n```\nremote: GitLab: You can only delete protected branches using the web interface.\nerror: failed to push some refs to '[email protected]:org/repo.git'\n```\n\n**Root cause**: the target was initialised with a default branch (e.g. `main`) that does not\nexist in the upstream (e.g. source uses `12.4`). Every mirror run tries to delete `main` and\nGitLab refuses.\n\n**Fix — preferred**: create the target as an **empty project** (no README, no initial commit).\nThe default branch is then set automatically when the first `git push --mirror` runs.\n\n**Fix — existing repo**: change the default branch in Settings before mirroring. On GitLab:\n*Settings → Repository → Default branch*. On GitHub: *Settings → Branches → Default branch*.\n\n### Notes-Ref Gotcha\n\n`git push --mirror` deletes any ref at the target that does not exist in the upstream. If you\nstore cache data (e.g. split commit maps) in `refs/notes/*` on the mirror target, those refs\nwill be wiped on every sync run because they are absent from the upstream.\n\nDo not rely on `refs/notes/*` for persistent caching in mirror repositories. Store such state\nin a separate repository, a file in object storage, or a CI/CD cache artifact.\n\n### Example CI Job (GitLab)\n\n```yaml\nmirror-sync:\n image: alpine/git:2.43.0\n script:\n - git clone --mirror \"$SOURCE_URL\" repo.git\n - cd repo.git\n - git push --mirror \"$TARGET_URL\"\n only:\n - schedules\n```\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":22814,"content_sha256":"96f3dd68f4bc50a1d4f73d27c31f92d40550d4f2757958eedeee9dbbff44c52b"},{"filename":"references/claude-code-hooks.md","content":"\u003c!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->\n\u003c!-- SPDX-FileCopyrightText: Netresearch DTT GmbH -->\n\n# Claude Code Hooks for Workflow Enforcement\n\nReady-to-drop `settings.json` hook recipes that enforce the critical rules from `SKILL.md` at tool-invocation time. These run in the Claude Code harness, not in git — they catch violations before the command executes.\n\nFor git-side hooks (pre-commit, pre-push), see `references/git-hooks-setup.md` instead.\n\n## Where to put these\n\n| Scope | File | When to use |\n|-------|------|-------------|\n| Personal, all projects | `~/.claude/settings.json` | Enforce your own rules everywhere |\n| Team, committed to repo | `.claude/settings.json` | Enforce team rules for this project |\n| Personal, one project | `.claude/settings.local.json` | Overrides for this project only |\n\nMerge carefully — read the existing `hooks:` block, add to arrays, never replace wholesale.\n\n## Recipe 1: Block `gh pr merge` When Review Threads Are Open\n\nBlocks any `gh pr merge` invocation that would merge a PR with unresolved review threads or a non-CLEAN merge state.\n\nThe merge-gate logic is non-trivial, so it ships as an external script — `scripts/merge-gate.sh` in this skill — rather than inline JSON. Install it and reference it:\n\n```bash\ncp \u003cskill>/scripts/merge-gate.sh ~/.claude/hooks/merge-gate.sh\nchmod +x ~/.claude/hooks/merge-gate.sh\n```\n\n```json\n{\n \"hooks\": {\n \"PreToolUse\": [\n {\n \"matcher\": \"Bash\",\n \"hooks\": [\n {\n \"type\": \"command\",\n \"if\": \"Bash(gh pr merge *)\",\n \"command\": \"~/.claude/hooks/merge-gate.sh\"\n }\n ]\n }\n ]\n }\n}\n```\n\n\nThe hook denies `gh pr merge` when review threads are unresolved or `mergeStateStatus != CLEAN` (`UNSTABLE` — a non-required check red — is still a deny). It deliberately does not gate on `reviewDecision`: repos without a required-approval rule report `\"\"` and merge legitimately when CLEAN. It parses the three PR-reference forms `gh pr merge` accepts (plain number — with long/short and `=`-joined flags before it — full URL, `owner/repo#N`), paginates the thread list, and honors an explicit `--repo`/`-R` flag — without that, the PR number resolves against the CWD repo and produces false denials when the command targets another repo. If parsing or the PR lookup fails, the hook allows the call rather than producing false-positive denies. Two usage caveats: the hook evaluates at Bash-call time, so never chain a wait-until-CLEAN loop and the merge in one invocation (wait in one call, merge in the next); and it is a personal-harness gate — server-side branch protection remains the authoritative enforcement.\n\n## Recipe 2: Reject Edits to Installed Cache Paths\n\nPrevents `Write` / `Edit` / `MultiEdit` from targeting `~/.claude/skills/...`, `~/.claude/plugins/cache/...`, or any `.bare/` path — which would be silently clobbered on the next update.\n\n```json\n{\n \"hooks\": {\n \"PreToolUse\": [\n {\n \"matcher\": \"Write|Edit|MultiEdit\",\n \"hooks\": [\n {\n \"type\": \"command\",\n \"command\": \"jq -r '.tool_input.file_path // \\\"\\\"' | { read -r P; case \\\"$P\\\" in */.claude/skills/*|*/.claude/plugins/cache/*|*/.claude/plugins/marketplaces/*|*/.bare/*) echo \\\"{\\\\\\\"hookSpecificOutput\\\\\\\":{\\\\\\\"hookEventName\\\\\\\":\\\\\\\"PreToolUse\\\\\\\",\\\\\\\"permissionDecision\\\\\\\":\\\\\\\"deny\\\\\\\",\\\\\\\"permissionDecisionReason\\\\\\\":\\\\\\\"cache path rejected: $P — edit the source worktree instead\\\\\\\"}}\\\";; esac; }\"\n }\n ]\n }\n ]\n }\n}\n```\n\n## Recipe 3: Warn on Unauthorized Squash\n\nDoes not block — just emits a warning. Squash is legitimate on repos with a squash policy; full blocking would be too noisy. The warning is enough to prompt the user to confirm intent.\n\n```json\n{\n \"hooks\": {\n \"PreToolUse\": [\n {\n \"matcher\": \"Bash\",\n \"hooks\": [\n {\n \"type\": \"command\",\n \"if\": \"Bash(gh pr merge *)\",\n \"command\": \"jq -r '.tool_input.command' | grep -qE -- '--squash\\\\b' && echo '{\\\"systemMessage\\\":\\\"⚠ squash merge requested — confirm the repo uses squash policy; default is atomic commits\\\"}' || true\"\n }\n ]\n }\n ]\n }\n}\n```\n\n## Recipe 4: Auto-Lint Go Files After Write/Edit\n\nRuns `golangci-lint` on the file's directory after any write. Silent-success; logs only on failure.\n\n```json\n{\n \"hooks\": {\n \"PostToolUse\": [\n {\n \"matcher\": \"Write|Edit|MultiEdit\",\n \"hooks\": [\n {\n \"type\": \"command\",\n \"command\": \"jq -r '.tool_input.file_path // .tool_response.filePath // \\\"\\\"' | { read -r F; [ -z \\\"$F\\\" ] && exit 0; case \\\"$F\\\" in *.go) cd \\\"$(dirname \\\"$F\\\")\\\" && golangci-lint run --fast 2>&1 | head -40 || true;; esac; } 2>/dev/null\"\n }\n ]\n }\n ]\n }\n}\n```\n\nSwap `golangci-lint` for your project's linter of choice. For PHP: `vendor/bin/php-cs-fixer fix --dry-run -- \"$F\"`. For JS/TS: `bunx eslint \"$F\"`.\n\n## Recipe 5: Sentinel on \"Verified\" Claims Without Tool Output\n\nExperimental — uses a `prompt` hook to audit assistant messages declaring pass/verified. Only runs on Stop events (end of assistant turn).\n\n```json\n{\n \"hooks\": {\n \"Stop\": [\n {\n \"hooks\": [\n {\n \"type\": \"prompt\",\n \"prompt\": \"Check the just-ended assistant turn. If it contains any of: 'verified', 'tested', 'all green', 'tests pass', 'should work now', 'try again' — AND no Bash/Read tool result in the same turn shows actual command output substantiating the claim — emit a systemMessage warning. Otherwise stay silent.\\n\\n$ARGUMENTS\"\n }\n ]\n }\n ]\n }\n}\n```\n\nThis is a soft guardrail — the hook can't block a past message, only flag it to the user. Useful as a \"you said tested but didn't run anything\" reminder.\n\n## Deploying Hooks\n\nAfter editing `settings.json`:\n\n```bash\n# Validate JSON syntax first — broken JSON silently disables ALL settings\njq -e '.hooks' .claude/settings.json\n\n# Reload config — open and close the /hooks menu in Claude Code, or restart\n```\n\nThe settings watcher only picks up new hook files if `.claude/` existed at session start. If you created `.claude/settings.json` during a session, open `/hooks` once to reload.\n\n## Recipe 6: Block `git commit` While Staged Content Has Conflict Markers\n\nA resolver that exits non-zero does **not** stop a following `git add -A && git commit` in the same Bash call — the `&&` chain starts fresh after the failed command, so the commit lands with `\u003c\u003c\u003c\u003c\u003c\u003c\u003c` in the tree and nothing reports it until a later gate run. `verify-git-workflow.sh` finds markers on demand; this denies the commit that would create them.\n\n```bash\ncp \u003cskill>/scripts/conflict-marker-gate.py ~/.claude/hooks/\nchmod +x ~/.claude/hooks/conflict-marker-gate.py\n```\n\n```json\n{\n \"hooks\": {\n \"PreToolUse\": [\n {\n \"matcher\": \"Bash\",\n \"hooks\": [\n { \"type\": \"command\", \"command\": \"uv run $HOME/.claude/hooks/conflict-marker-gate.py || true\" }\n ]\n }\n ]\n }\n}\n```\n\nIt fires only when the command actually runs `git commit` (`git -C \u003cdir> commit` and `git -c k=v commit` included) and only when staged blobs carry a marker. It matches `\u003c\u003c\u003c\u003c\u003c\u003c\u003c` and `>>>>>>>` (each followed by a space) at line start but deliberately **not** a bare `=======`, which is an ordinary RST section underline and would otherwise deny every docs commit. Any error, non-repo cwd or unreadable diff allows the command — the gate never blocks on its own failure.\n\nVerify it against both directions before relying on it: stage a file containing a real marker (must deny) and a `.rst` file with a `=======` underline (must allow).\n\n## Recipe 7: Block a git write inside the reference `main/` worktree\n\n`references/advanced-git.md` states that `main/` is reference only. The rule\ngets broken anyway because breaking it needs no intent: one Bash call omits its\n`cd`, inherits the working directory of an earlier call, and the commit lands on\nthe shared branch locally — where a later push or a `jj git export` moves it\nunder every sibling worktree. Prose cannot catch an inherited working directory;\na gate reads it out of the payload.\n\n```bash\ncp \u003cskill>/scripts/reference-worktree-gate.py ~/.claude/hooks/\nchmod +x ~/.claude/hooks/reference-worktree-gate.py\n```\n\n```json\n{\n \"hooks\": {\n \"PreToolUse\": [\n {\n \"matcher\": \"Bash\",\n \"hooks\": [\n { \"type\": \"command\", \"command\": \"uv run $HOME/.claude/hooks/reference-worktree-gate.py || true\" }\n ]\n }\n ]\n }\n}\n```\n\nIt denies `commit`, `add`, `merge`, `rebase`, `cherry-pick`, `revert`, `am`,\n`stash` and `reset --hard` when the effective directory is a `main/` (or\n`master/`) that sits beside a `.bare`. It deliberately does **not** deny\n`git -C \u003cpath> …` or a call that starts with `cd \u003cpath> &&` — both name where the\nwrite lands, which is the shape it is steering toward — nor reads, `git fetch`,\nor `git worktree add`, which are what the directory is for.\n\nNor `git merge --ff-only` / `git pull --ff-only`: the reference checkout has to\nbe *current* to serve as a base, so refreshing it is the directory's purpose and\na fast-forward can neither create a commit nor discard work. `reset --hard`\nreaches the same state by discarding and stays refused. (That exemption is not\ntheory — the gate blocked its own author's `reset --hard` in `main/` minutes\nafter installation, which is how the over-block was found.) Roots default to\n`~/projects` and `~/p`; set `GIT_WORKTREE_ROOTS` to a colon-separated list for\nothers.\n\nAlready have a Bash gate? Extend it instead of adding a second hook — the\ndirectory test is three lines (`basename in {main,master}`, parent under a known\nroot, `.bare` sibling exists) and reusing one hook keeps the denial messages in\none place.\n\nVerify both directions before relying on it: `uv run --python 3.11 python scripts/test_reference_worktree_gate.py`\nbuilds a throwaway layout and checks that a commit in `main/` denies while\n`git -C \u003cbranch> commit` and a commit in a branch worktree pass.\n\n## Recipe 8: Deny a blanket `git add` while untracked files are lying around\n\nShipped in `scripts/validate_git_command.py` as `blanket_git_add()` (PreToolUse, Bash). `git add -A`, `git add --all` and `git add .` stage every untracked, non-ignored file in the tree; the rule \"add the paths you changed by name\" was written down and still violated — a `var/` DI-container cache (40k lines) rode into a commit and had to be amended and force-pushed out (2026-08-20). The gate reads the repository the command names (`git -C \u003cdir>`) or the payload's `cwd`, lists untracked, non-ignored files with `git ls-files --others --exclude-standard` (fsmonitor, the untracked cache and every transport switched off, so the look into a repository the command has not yet been approved for runs no program that repository's configuration names), and denies only when there are any, listing them. A clean tree, a tree with only tracked edits, a named path and `git add -u` pass. Escape hatch, shared with the destructive-git gate: `DESTRUCTIVE_GIT_GATE_OFF=1`. The deny message is the specification (see below): it names the files, the rule, the incident and the way out.\n\n## Recipe 9: Deny a git write that does not name its directory\n\nShipped in `scripts/validate_git_command.py` as `git_write_without_named_dir()` (PreToolUse, Bash). `cd` survives between tool calls, so a `git commit`, `push`, `reset`, `rebase`, `cherry-pick`, `worktree` or any other write that omits its directory runs wherever the previous call stopped — and in the wrong repository it does not fail, it succeeds there. The rule \"every call names its directory\" was written down and still lapsed under a long chain of commands: on 2026-09-03 a `git push` ran in a simulator checkout instead of the addon worktree, and only a branch that did not exist there kept it from pushing. Reads had an advisory for the same shape, delivered once per session; a write cannot afford a warning its author may not read, so writes are denied until they name where they run, with `git -C /abs/path …` or a `cd /abs/path &&` in the same scope (a `cd` inside a quoted payload counts, as it does for the advisory). Reads keep the advisory; sharper gates (blanket add, conflict markers) report first. The listing forms of subcommands that otherwise write pass, because they change nothing: `git stash list`, `git stash show`, `git worktree list`, and `git branch` / `git tag` without an operand or with `-l`/`--list` — unless a writing flag (`-d`, `-D`, `-m`, `-f`, …) rides along. Denying them sent `git stash list` in a cleanup sweep back as a write (2026-09-14).\n\nThe write is found by scanning tokens rather than by one pattern, because a pattern that knew only `-c` missed `git --no-pager push` and one that required a statement separator missed `env X=1 git push`. Global options are skipped until the subcommand; `-C` and `--git-dir` among them count as naming the directory, but only with a real operand, since git reads `git -C \"\" push` as no directory change at all (checked against git 2.55.0). A `cd` likewise counts only with a real operand. `cd && git push` leaves the inherited directory in place; `cd -` does change it, to `$OLDPWD`, but names no directory the reader of the command can see, which is what the gate is asking for.\n\nQuoting decides whether text is a command. A quoted run is blanked before the scan, so `git -C /r commit -m \"fix git push\"` is not read as a second, unnamed write. The exception is a payload a local shell will run: `bash -c 'git push'` is the write it looks like and is denied, while `bash -c 'cd /repo && git push'` passes because the `cd` is in the payload's own scope. `ssh host '…'` is deliberately not inspected — its working directory is on another machine, which is not what this gate is about.\n\nA heredoc body is data, not a command. `cat > doc.md \u003c\u003c'EOF' … git commit … EOF` writes text ABOUT a commit; it makes none. Scanning it denied writing documentation, release notes and the gate's own test fixtures — the same false denial the harness-local ancestor of this gate produced, from the same cause. Bodies are blanked before the scan, with one exception that decides the rule: an UNQUOTED body expands, so a `$( … )` or backtick span inside one really does run and stays in, while the prose around it does not. In a quoted body nothing expands, so the whole body goes. The blanking is length-preserving, because the scan reasons about offsets when it looks backwards for a `cd`.\n\nThe same blind spot bit a harness-local attribution gate on the same day, in its other form: it read a `git commit` inside `bash -c '…'` as text and let an undisclosed commit through. Both are the one question — does this span reach a shell? — and a hook that answers it in one place should answer it in the other.\n\nThe escape hatch counts only where a shell would read it as an environment assignment, and only on the statement it prefixes: as a substring it disabled the gate from inside a commit message that merely named it (which the messages in this repository do), and as a whole-command test `DESTRUCTIVE_GIT_GATE_OFF=1 true; git push origin main` disabled it for a write the assignment never prefixed. That predicate is shared with `blanket_git_add()`, which had the same weakness.\n\nEscape hatch, shared with the destructive-git gate: `DESTRUCTIVE_GIT_GATE_OFF=1`. Verify both directions: `python3 -m unittest discover -s scripts -p 'test_*.py'` denies `git push origin x` and passes `git -C /repo push origin x`.\n\n## Recipe 10: Advise on `git rev-parse --short` with more than one revision\n\nShipped in `scripts/validate_git_command.py` as `rev_parse_short_many_revisions()` (PreToolUse, Bash, advisory — never a deny). `--short` implies `--verify`, and both accept exactly one revision, so `git -C \"$REPO\" rev-parse --short HEAD origin/main` always exits 128 with `fatal: Needed a single revision` — or 1 and silently with `--quiet`, which the advisory then says instead (git 2.56.0). Agents kept writing it to compare a local and a remote head, several times a day, while the rule was documented ([#380](https://github.com/netresearch/git-workflow-skill/issues/380)). The command fails harmlessly on its own, so the hook does not refuse it; it hands over the two forms that work: `git -C \"$REPO\" rev-parse HEAD origin/main | cut -c1-7`, and `for r in HEAD origin/main; do git -C \"$REPO\" rev-parse --short \"$r\"; done`. `--verify` with several revisions draws the same advisory, without the `cut`.\n\nThe invocation is parsed with the same token scanner as Recipe 9, so `-C \u003cdir>`, `-c k=v` and other global options are skipped, compound commands are split at `;`, `&&` and `|`, a `bash -c '…'` payload counts as a command and quoted text, a heredoc body or a trailing `# …` comment does not (a quoted or escaped `#` stays an argument). Only revisions after the first `--short`/`--verify` count, because the flag applies to what follows it: `git rev-parse HEAD --short origin/main` prints the full and then the short hash and exits 0. Flags such as `--verify`, `--quiet` and `--abbrev-ref` do not count, nor does the value of `--default`, `--prefix`, `--git-path` or `--resolve-git-dir` (`--short --default HEAD origin/main` exits 0), nor a redirection, nor anything after `--` (paths). A range word (`A..B`, `A^!`) counts as none, because in `--verify` mode git prints it without counting it (`--short HEAD..origin/main HEAD` exits 0); a `$( … )` or backtick operand counts as one, and the `)` or backtick closing a substitution the call itself sits in ends it. A `$( … )` inside double quotes is not seen; the shared scanner masks those runs whole. Ref-expanding options (`--all`, `--branches`, `--tags`) fail the same way with `--verify` but are not counted.\n\nTwo things set it apart from the named-directory advisory. It reports a certain failure of the command in hand, not a general rule, so it is not deduplicated per session — the second slip needs the same help as the first. And it is the field the model reads that matters: besides `systemMessage` (shown to the user) it sets `hookSpecificOutput.additionalContext`, which the hook documentation describes as passed into Claude's context, with no `permissionDecision`, so whether the command runs is decided exactly as without the hook. The same change went to every other advisory and reminder of this hook (named directory, run poll, conventional commit, force push and the rest): before, they used `systemMessage` or plain stdout, which PreToolUse writes to the debug log, so none reached the agent. Because a bare `git rev-parse` also matches the named-directory advisory and only one message goes out per call, this one is checked first.\n\n## Anti-Patterns\n\n| Anti-pattern | Why wrong | Fix |\n|--------------|-----------|-----|\n| Using `xargs` on stdin JSON | xargs splits on spaces; breaks paths with spaces | `{ read -r F; ... \"$F\"; }` pattern |\n| Forgetting `2>/dev/null \\|\\| true` on PostToolUse | Hook failure pollutes transcript | Wrap non-blocking hooks |\n| `Write\\|Edit` matcher without file-path extraction | Hook runs on wrong files | `jq -r '.tool_input.file_path'` |\n| Blocking hooks that hit flaky services | One GitHub-API outage blocks all merges | Soft-fail: warn instead of deny for infra-dependent gates |\n| Per-hook large shell scripts inline in JSON | Unreadable, un-testable | Keep inline ≤3 lines; call external script for more |\n| An exception the gate reads from the environment | The hook is its own process; a `VAR=1 cmd` prefix never reaches it, so the documented way out is inert | Read it off the command text, anchored as a leading assignment, and copy the mechanism from the gate already shipping in that hook |\n| A deny text whose promises nothing tests | The message is the contract; an untested promise is usually the case the gate gets wrong | One test per clause of the message — see below |\n| A gate that scans a heredoc body | A body is data unless it expands; scanning it denies writing any document that mentions the command | Blank the body, length-preserving, keeping `$( … )` spans in an unquoted one |\n| Blanking from a `\u003c\u003c` on sight | `\u003c\u003c` is also an arithmetic left shift, `\u003c\u003c\u003c` a here-string, and a `\u003c\u003cEOF` inside a quoted argument is prose — each looks like an opener, so blanking from there swallows every invocation that follows and the gate goes quiet exactly where it should fire | Recognise an opener only outside quotes, and blank only up to a terminator that is actually present; exclude `\u003c\u003c\u003c`, and leave the lines as script when no terminator follows. Do not mask quoted runs first — a delimiter may carry its own quotes (`\u003c\u003c'EOF'`) and masking them stops the opener matching at all |\n| A gate built from an incident that covers one command shape | An incident is plural; the shape you remember is rarely the only one it used | Extract every command shape from the transcript and assert the predicate on each |\n\n### The deny message is a specification\n\nWhatever a denial says is what its author will be held to, and the exception\na message carves out is the case most likely to be wrong — it is the one the\nimplementation had to think about separately. Turn each clause into a test\nbefore shipping the gate.\n\nThe forge-body language gate is the worked example. Its first message made\nthree claims, and the gate honoured one of them:\n\n| Clause of the message | Test | First version |\n|---|---|---|\n| a German body is denied | German prose → `deny` | held |\n| quoting a German string inside an English body \"is fine\" | English body with a German stack trace → not `deny` | denied it |\n| `FORGE_LANGUAGE_GATE_OFF=1` exempts a German repository | the documented command verbatim → not `deny` | inert: read from the environment, which a Bash prefix never reaches |\n\nBoth failures were found by writing the tests from the message rather than\nfrom the implementation. A fourth clause — that the exemption is a prefix and\nnot a mention — entered the message only with the fix, which is itself the\npattern: sharpening a promise is how the missing case gets named, so\nre-derive the tests whenever the message changes.\n\nA gate that denies also needs its threshold calibrated against a negative\ncorpus wider than the documents that motivated it. In that gate a German\nfunction-word marker list contained `mit`, which is the licence every skill\nrepository names. A false deny blocks legitimate work, so in a denying gate a\nmarker that fires on the negative corpus is worse than a missing one — and\nthe calibration belongs in a test rather than a comment, because a marker\nlist is a decision and a comment cannot fail when the decision is reversed.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":22800,"content_sha256":"b13b15413cff462bad899a6b51bc6cbb96db7ba7d204d639e713957d43db386d"},{"filename":"references/code-quality-tools.md","content":"\u003c!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->\n\u003c!-- SPDX-FileCopyrightText: Netresearch DTT GmbH -->\n\n# Code Quality Tools\n\nShell linting, formatting, smart fixup commits, and structural diffs.\n\n## shellcheck - Shell Script Linter\n\n### Rule Categories\n\n| Prefix | Category | Examples |\n|--------|----------|----------|\n| SC1xxx | Syntax errors | SC1009 (missing ` git-workflow — Skillopedia on variable), SC1073 (couldn't parse) |\n| SC2xxx | Suggestions/warnings | SC2086 (double quote to prevent globbing), SC2046 (quote to prevent splitting) |\n| SC3xxx | Portability | SC3010 (`[[ ]]` not POSIX), SC3030 (arrays not POSIX) |\n| SC4xxx | Deprecation | Deprecated features that should be avoided |\n\n### .shellcheckrc Configuration\n\nPlace `.shellcheckrc` in the project root:\n\n```ini\n# Default shell dialect\nshell=bash\n\n# Globally disabled rules\ndisable=SC1091 # Don't follow sourced files\ndisable=SC2154 # Referenced but not assigned (common with env vars)\n\n# Enable optional checks\nenable=require-variable-braces\nenable=check-unassigned-uppercase\n```\n\n### Common Rules and Fixes\n\n```bash\n# SC2086: Double quote to prevent globbing and word splitting\n# Bad:\necho $var\n# Good:\necho \"$var\"\n\n# SC2046: Quote command substitution to prevent splitting\n# Bad:\nfiles=$(find . -name \"*.sh\")\n# Good:\nfiles=\"$(find . -name \"*.sh\")\"\n\n# SC2155: Declare and assign separately to avoid masking return values\n# Bad:\nlocal foo=\"$(mycmd)\"\n# Good:\nlocal foo\nfoo=\"$(mycmd)\"\n\n# SC2164: Use cd ... || exit to handle cd failure\n# Bad:\ncd /some/dir\n# Good:\ncd /some/dir || exit 1\n```\n\n### Gotcha: Command Substitution with Empty Output\n\n`set -e` fires when `var=$(failing_cmd)` — the assignment propagates the non-zero exit code and\naborts the script. **However**, `set -e` cannot detect a command that exits 0 but produces\n**no output**. An empty variable is a valid result from the shell's point of view. No shellcheck\nrule covers this case for regular (non-`local`) variables.\n\nThis means downstream code that uses `$var` without checking can silently misbehave. A common\nconsequence: an empty variable interpolated into a refspec becomes an accidental delete operation.\n\n```bash\n# Silent failure — git commit-tree succeeds but produces no output\n# (e.g. tree already exists, permission issue, or wrong arguments).\n# set -e does NOT fire here. commit is empty string \"\".\ncommit=$(git commit-tree \"$tree\" -p \"$parent\" -m \"msg\")\n\n# Downstream: the empty commit SHA becomes a delete refspec.\n# This pushes \":refs/tags/v1.0\" — which DELETES the tag on the remote.\ngit push origin \"${commit}:refs/tags/v1.0\"\n```\n\nAlways add an explicit empty-value guard after command substitution whose output is load-bearing:\n\n```bash\ntag=\"v1.0\"\ncommit=$(git commit-tree \"$tree\" -p \"$parent\" -m \"msg\")\nif [ -z \"${commit}\" ]; then\n echo \"ERROR: git commit-tree produced no output for tag ${tag}\" >&2\n exit 1 # or: continue, if inside a loop\nfi\ngit push origin \"${commit}:refs/tags/${tag}\"\n```\n\nNote: SC2155 (`Declare and assign separately to avoid masking return values`) only applies to\n`local var=$(cmd)` — where the `local` builtin masks the exit code. For plain `var=$(cmd)`, the\nexit code is propagated and `set -e` works normally. The empty-output case is distinct and not\ncovered by any shellcheck rule.\n\n### Gotcha: Capturing an exit code, in a script that runs under `set -e`\n\nA shell test runner — one that asserts \"this input must exit 1, that one must exit 0\" — walks into\ntwo traps that both fail in the same misleading direction: **every case collapses at once**, so the\nrunner looks wholly broken rather than subtly wrong.\n\n**`env` cannot call a shell function.** `env VAR=1 my_func` execs a *program*; the function is\ninvisible to it, so the run dies with `127` and every case fails identically. Set the variable in a\nsubshell instead of reaching for `env`:\n\n```bash\nguard() { python3 ./scripts/guard.py \"$@\"; }\n\n# Wrong: env execs a binary, never the function -> 127 on every case.\nenv -u FLAG FLAG=1 guard \"$fixture\"\n```\n\n**`set -e` inside `$( )` aborts before the exit code is reported — under `sh`, not under `bash`.**\nThe substitution's subshell inherits `set -e`, so the case that is *supposed* to fail kills it\nbefore the `echo $?` that was going to capture the code. The shell decides whether this bites, and\nthe split is what makes it expensive: the same file, unchanged, run three ways —\n\n```text\nsh probe.sh exit=1 \"suite\" # dies after the header\ndash probe.sh exit=1 \"suite\" # same\nbash probe.sh exit=0 \"suite | ok | ok | ende\" # runs clean\n```\n\nSo a runner written with `#!/usr/bin/env sh` passes while you develop it with `bash runner.sh` and\ndies in CI, where `sh` is dash or busybox. `set +e` inside the subshell fixes all three:\n\n```bash\nactual=$(\n set +e # or the next non-zero line ends the subshell\n unset FLAG_A FLAG_B # each case starts from a known environment\n if [ -n \"$assignment\" ]; then\n # shellcheck disable=SC2163 # exporting NAME=value, not the variable name\n export \"$assignment\"\n fi\n guard \"$fixture\" >/dev/null 2>&1\n echo $?\n)\n```\n\nBoth were hit while writing a guard's regression suite: the first turned 16 passing cases into 16\nfailures, the second produced a runner that printed its header and nothing else. Neither is caught\nby shellcheck — SC2163 fires on the deliberate `export \"$assignment\"`, which is the one line in the\nblock that is correct as written.\n\nReproducing the second one takes a faithful structure, not a minimal one. `bash -c 'set -e;\nout=$(false; echo $?)'` prints `1` and suggests there is nothing here; the trap needs the real\nshape — a function returning the code, inside `$( )`, inside a script under `set -e`, run as `sh`.\nA probe that is too small is how a real trap gets written off as folklore.\n\n### CI Integration\n\n```yaml\n# GitHub Actions - shellcheck\n- name: Run shellcheck\n uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # v2.0.0\n with:\n scandir: './scripts'\n severity: warning\n```\n\n---\n\n## shfmt - Shell Script Formatter\n\n### Flag Reference\n\n| Flag | Description | Example |\n|------|-------------|---------|\n| `-w` | Write result to file (in-place) | `shfmt -w script.sh` |\n| `-d` | Display diff (error if not formatted) | `shfmt -d script.sh` |\n| `-i N` | Indent with N spaces (0 = tabs) | `shfmt -i 2 script.sh` |\n| `-bn` | Binary ops like `&&` and `\\|` may start a line | `shfmt -bn script.sh` |\n| `-ci` | Indent switch cases | `shfmt -ci script.sh` |\n| `-sr` | Redirect operators followed by a space | `shfmt -sr script.sh` |\n| `-fn` | Function opening brace on a separate line | `shfmt -fn script.sh` |\n| `-ln` | Language variant: `bash`, `posix`, `mksh`, `bats` | `shfmt -ln bash script.sh` |\n\n### EditorConfig Integration\n\nshfmt reads `.editorconfig` settings automatically:\n\n```ini\n# .editorconfig\n[*.sh]\nindent_style = space\nindent_size = 2\nshell_variant = bash\nbinary_next_line = true\nswitch_case_indent = true\nspace_redirects = true\n```\n\nWhen an `.editorconfig` is present, run `shfmt -w .` without explicit flags.\n\n### CI Integration\n\n```yaml\n# GitHub Actions - shfmt\n- name: Check shell formatting\n uses: mvdan/sh@8202166b7d1e3473a7c65eeac53ddbdb55d5b808 # v3.12.0\n with:\n sh-version: latest\n args: '-d -i 2 .'\n```\n\n---\n\n## git-absorb - Smart Fixup Commits\n\n### How It Works\n\n1. You stage changes with `git add` (the fixes you want to absorb)\n2. `git absorb` analyzes the staged hunks\n3. For each hunk, it finds the most recent commit that modified those exact lines\n4. It creates `fixup!` commits targeting those parent commits\n5. `git rebase --autosquash` folds the fixup commits into their targets\n\n### Typical Workflow\n\n```bash\n# 1. You have a feature branch with 5 commits\n# 2. Code review requests changes to lines in commits 2 and 4\n# 3. Make the fixes in your working tree\n# 4. Stage them\ngit add -p\n\n# 5. Let git-absorb figure out which commits they belong to\ngit absorb\n\n# 6. Verify the fixup commits look correct\ngit log --oneline main..HEAD\n\n# 7. Squash fixups into their parent commits\ngit rebase --autosquash main\n\n# 8. Force-push the cleaned-up branch\ngit push --force-with-lease\n```\n\n### Comparison with Manual Fixup Workflow\n\n| Step | Manual | git-absorb |\n|------|--------|------------|\n| Identify target commit | `git log --oneline`, find SHA | Automatic |\n| Create fixup commit | `git commit --fixup=\u003cSHA>` | `git absorb` |\n| Apply fixups | `git rebase --autosquash main` | `git absorb --and-rebase` or same |\n| Multiple fixes | Repeat per commit | Single command handles all |\n\n### Limitations\n\n- Only works with staged changes (use `git add -p` for partial staging)\n- Cannot absorb changes to lines that were not in any prior commit (new code)\n- Ambiguous hunks (lines modified in multiple commits) are skipped with a warning\n- Requires a clean working tree for `--and-rebase`\n\n### Installation\n\n```bash\n# macOS\nbrew install git-absorb\n\n# Arch Linux\npacman -S git-absorb\n\n# Cargo (any platform)\ncargo install git-absorb\n\n# Ubuntu/Debian (via cargo or from releases)\ncargo install git-absorb\n```\n\n---\n\n## difft (difftastic) - Structural Diff\n\n### Language Support\n\ndifft supports 50+ languages including: Bash, C, C++, C#, CSS, Dart, Elixir, Elm, Go, Haskell, HTML, Java, JavaScript, JSON, Kotlin, Lua, Nix, OCaml, PHP, Python, Ruby, Rust, Scala, SQL, Swift, TOML, TypeScript, YAML, and more.\n\n### Configuration Options\n\n```bash\n# Set as default git diff tool (persistent)\ngit config --global diff.external difft\n\n# Set display width\ngit config --global difftool.difftastic.cmd 'difft --width 80 \"$LOCAL\" \"$REMOTE\"'\n\n# Color output\nexport DFT_COLOR=always # always | never | auto\n\n# Display mode\nexport DFT_DISPLAY=side-by-side-show-both # side-by-side | side-by-side-show-both | inline\n\n# Syntax highlighting\nexport DFT_SYNTAX_HIGHLIGHT=on # on | off\n\n# Context lines around changes\nexport DFT_CONTEXT=3\n```\n\n### Integration with Git\n\n```bash\n# One-off usage with git diff\nGIT_EXTERNAL_DIFF=difft git diff\n\n# With git log\nGIT_EXTERNAL_DIFF=difft git log -p --ext-diff\n\n# With git show\nGIT_EXTERNAL_DIFF=difft git show --ext-diff HEAD\n\n# As a difftool (interactive)\ngit config --global difftool.difftastic.cmd 'difft \"$LOCAL\" \"$REMOTE\"'\ngit difftool --tool=difftastic\n```\n\n### Integration with delta\n\nIf you use `delta` as your git pager, you can combine them:\n\n```bash\n# Use difft for structural diff, delta for everything else\n# In .gitconfig:\n[diff]\n external = difft\n[pager]\n diff = delta\n log = delta\n show = delta\n```\n\nNote: When `diff.external` is set, `delta` won't process diff output since difft handles it directly. Use `--no-ext-diff` to bypass difft and use delta instead when needed:\n\n```bash\ngit diff --no-ext-diff # Uses delta pager instead of difft\n```\n\n### Installation\n\n```bash\n# macOS\nbrew install difftastic\n\n# Arch Linux\npacman -S difftastic\n\n# Cargo\ncargo install --locked difftastic\n\n# Ubuntu/Debian (from GitHub releases)\ncurl -Lo difft.tar.gz https://github.com/Wilfred/difftastic/releases/latest/download/difft-x86_64-unknown-linux-gnu.tar.gz\ntar xf difft.tar.gz && sudo mv difft /usr/local/bin/\n```\n\n---\n\n## Pre-commit Hook Integration\n\nIntegrate all tools into a pre-commit hook:\n\n```bash\n#!/usr/bin/env bash\n# .git/hooks/pre-commit or via pre-commit framework\n\nset -euo pipefail\n\n# Find staged shell scripts\nstaged_scripts=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\\.(sh|bash)

Git Workflow Skill Not Here Releases: . BLOCKED-PR diagnosis: . GitHub only. Everything below the branching and commit sections — , , the merge gate, review threads — speaks GitHub GraphQL. For a GitLab merge request the equivalent calls are in § GitLab: the same gate with . Critical Rules (Non-Negotiable) 1. No direct push to main — always open a PR. 2. No merge before all threads resolved ( ). 3. No squash unless asked — preserves atomic commits, signatures, bisection. 4. No "tested/verified/working" without pasted command output — else say so. 5. No edits to installed skill/plugin cache pa…

|| true)\n\nif [[ -n \"$staged_scripts\" ]]; then\n echo \"Running shellcheck...\"\n echo \"$staged_scripts\" | xargs shellcheck || {\n echo \"shellcheck failed. Fix issues before committing.\"\n exit 1\n }\n\n echo \"Running shfmt...\"\n echo \"$staged_scripts\" | xargs shfmt -d -i 2 || {\n echo \"shfmt: formatting issues found. Run 'shfmt -w -i 2' on the files above.\"\n exit 1\n }\nfi\n```\n\n### Using pre-commit Framework\n\n```yaml\n# .pre-commit-config.yaml\nrepos:\n - repo: https://github.com/koalaman/shellcheck-precommit\n rev: v0.10.0\n hooks:\n - id: shellcheck\n - repo: https://github.com/scop/pre-commit-shfmt\n rev: v3.12.0-2\n hooks:\n - id: shfmt\n args: ['-i', '2', '-w']\n```\n\n---\n\n## GitHub Actions Workflow\n\nComplete workflow integrating all shell quality tools:\n\n```yaml\nname: Shell Quality\non:\n pull_request:\n paths:\n - '**/*.sh'\n - '**/*.bash'\n - '.shellcheckrc'\n - '.editorconfig'\n\njobs:\n shell-quality:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n\n - name: Install tools\n run: |\n sudo apt-get update\n sudo apt-get install -y shellcheck\n go install mvdan.cc/sh/v3/cmd/shfmt@latest\n echo \"$(go env GOPATH)/bin\" >> \"$GITHUB_PATH\"\n\n - name: shellcheck\n run: |\n git ls-files '*.sh' '*.bash' | xargs --no-run-if-empty shellcheck\n\n - name: shfmt\n run: |\n shfmt -d -i 2 .\n```\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":12934,"content_sha256":"4f53d3dc710bdaa5d22c24339a8dff64262c338feebbd554e4ffad1f6a1250d3"},{"filename":"references/commit-conventions.md","content":"\u003c!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->\n\u003c!-- SPDX-FileCopyrightText: Netresearch DTT GmbH -->\n\n# Commit Conventions\n\n## Conventional Commits\n\n### Specification\n\n```\n\u003ctype>[optional scope]: \u003cdescription>\n\n[optional body]\n\n[optional footer(s)]\n```\n\n### Commit Types\n\n| Type | Description | Version Bump |\n|------|-------------|--------------|\n| `feat` | New feature | MINOR |\n| `fix` | Bug fix | PATCH |\n| `docs` | Documentation only | - |\n| `style` | Code style (formatting) | - |\n| `refactor` | Code refactoring | - |\n| `perf` | Performance improvement | PATCH |\n| `test` | Adding/updating tests | - |\n| `build` | Build system changes | - |\n| `ci` | CI configuration | - |\n| `chore` | Maintenance tasks | - |\n| `revert` | Reverting changes | - |\n\n### The type is not the whole answer for the version\n\nThe table maps the *intent* of a change. The version has to describe what a\n**consumer** observes, and the two come apart in one recurring case:\n\n> **If a change takes away something the consumer was getting before, it is at\n> least MINOR — even when the commit is a `fix:`.**\n\n`fix:` says *why* you are doing it. It does not say that nobody depended on\nthe behaviour you removed. Fixing a defect that was quietly doing someone a\nfavour still changes what they get.\n\nA worked case, because a rule without the case it came from reads as an\nopinion:\n\n```\nfix(apt): never upgrade docker-ce or containerd.io unattended\n```\n\nGenuinely a defect — the role pulled from every APT origin including\nthird-party repositories, so unattended-upgrades restarted `dockerd` on an\ningress node and on both halves of a DNS resolver pair, outside any window.\nThe sibling platform had been scoped correctly all along, so this was\nrepairing an oversight, and it was tagged as a PATCH.\n\nBut hosts that had been getting third-party packages automatically stopped\ngetting them. Nothing in the version said so. A consumer upgrading a patch\nlevel does not read release notes, which is the entire point of patch levels.\n\n**What to do instead:** bump MINOR, and say in the tag or release notes what\nstops happening. If a patch tag is already published, do not re-tag the same\ncommit — two tags on one commit create two truths for one change, which is\nworse in hindsight than a version that was one step too low. Put the\nbehaviour change where the next reader will look and move on.\n\n`BREAKING CHANGE:` is still reserved for what breaks a consumer outright. This\nrule covers the quieter case in between: nothing breaks, something stops.\n\n### Examples\n\n```bash\n# Simple feature\nfeat: add user authentication\n\n# Feature with scope\nfeat(auth): add OAuth2 login support\n\n# Bug fix\nfix: resolve null pointer in user service\n\n# Bug fix with issue reference\nfix(api): handle empty response from external service\n\nFixes #123\n\n# Breaking change\nfeat!: remove deprecated v1 API endpoints\n\nBREAKING CHANGE: The /api/v1/* endpoints have been removed.\nMigrate to /api/v2/* before upgrading.\n\n# Multiple footers\nfix(security): patch XSS vulnerability in comment parser\n\nReviewed-by: John Doe\nRefs: #456\n```\n\n### Scope Guidelines\n\nScopes should be consistent across the project:\n\n```bash\n# By feature area\nfeat(auth): ...\nfeat(payment): ...\nfeat(notification): ...\n\n# By layer\nfix(api): ...\nfix(db): ...\nfix(ui): ...\n\n# By component\nstyle(button): ...\nrefactor(modal): ...\n```\n\n## Commit Message Best Practices\n\n### Body\n\n**Write multi-line or special-char bodies to a file, not inline `-m`.** The shell parses a double-quoted `-m \"...\"` before git sees it: an *unescaped* `\"` in the body closes the string early, after which a bare `&` backgrounds the fragment and the message silently truncates (a tell-tale `... command not found` may scroll past). Even with the quotes balanced, the shell still expands `$var`, `` `cmd` ``/`$(...)`, and backslashes inside double quotes, so a body containing those is altered. A file or a *single-quoted* heredoc (`\u003c\u003c'EOF'`) sidesteps all of it — the text is passed verbatim:\n\n```bash\ngit commit -S --signoff -F - \u003c\u003c'EOF'\nfix: prevent race condition in order processing\n\nBody may contain \"quotes\", & ampersands, `backticks` — all literal.\nEOF\n```\n\n**Hard-wrap the commit *body* (~72 cols); don't hard-wrap prose whose wrapping\nyou don't control.** The 72-column convention is for commit message bodies —\nwhich is exactly why a commit body must not be piped into a PR description\nunchanged; see *The rule breaks at the pipe* in `no-editorializing.md`. For\n**PR/MR & issue descriptions, release notes, review comments, and chat**, write\none line per paragraph (and per list item) and let the renderer soft-wrap — a\nhard break mid-paragraph there does nothing for the output. For **committed\nMarkdown files**, follow the repo's existing convention: many hard-wrap prose\ndocs for readable diffs, so match the surrounding files rather than mixing two\nstyles in one tree.\n\n## Reading Trailers Back: `git interpret-trailers`, Never a Regex\n\nAnything that makes a decision from a commit trailer — a release note generator,\nan audit, a CI gate honouring an opt-in — must ask git what a trailer is. A\n`^Name:` regex over `%B` matches text that is not a trailer at all:\n\n```bash\ngit log --format='%B' -1 | git interpret-trailers --parse --no-divider\n```\n\nThree ways the regex is wrong, all reproduced rather than imagined:\n\n- **Inside a fenced code block.** A commit documenting the trailer format, whose\n prose says \"this is only an example\", hands out a real one.\n- **In the quoted body of a `Revert \"...\"`** that a person pasted in. (A real\n `git revert` does not carry the reverted commit's trailers forward — that path\n is safe. A hand-written message is not.)\n- **Case.** With `re.I`, `allow-policy-narrowing:` works where the documentation\n says `Allow-Policy-Narrowing:`. git looks trailers up case-insensitively, but\n an opt-in should be written the way it is documented — match the name as\n written.\n\n`--no-divider` is not optional for this. Without it a **divider line** ends the\nparse and every trailer after it disappears — that is a line beginning with\n`---` followed by a space or the end of the line, so a markdown rule or the\n`---` git itself puts above a diffstat both count. Measured: `--- a note` and a\nbare `---` both swallow the trailer; `----` and a `---` in the middle of a line\ndo not. A pasted patch or a rule in the body therefore drops a trailer that is\nreally there. Failing closed is right; failing closed *silently* sends the\nauthor to debug the wrong thing.\n\nTwo properties worth knowing before designing around trailers:\n\n- What counts as the trailer block is a **heuristic**. A prose line directly\n above can put a trailer outside it; a `Signed-off-by` footer usually keeps it\n in. Check with the command above rather than by eye.\n- A **squash merge does not reliably preserve them**. Both forges compose that\n message from the pull/merge request rather than by aggregating the source\n commits' trailers; GitLab can inject some metadata through\n `squash_commit_template`, but neither platform guarantees that a particular\n source trailer survives. So a mechanism that reads a trailer on the target\n branch cannot depend on one being there after a squash. If the mechanism\n matters, turn squash off for the project (`squash_option: never` on GitLab)\n rather than documenting the hazard.\n\n## Signed Commits + DCO Sign-Off (Required)\n\nRun every commit with both flags explicit:\n\n```bash\ngit commit -S --signoff -m \"feat: add login endpoint\"\n```\n\n**Why explicit `-S`.** Git honors `commit.gpgsign=true` only when the configuration is actually loaded. Subprocess environments (CI runners, some IDEs, tools that set their own `$HOME` or scrub env) can miss the global config — and without the config, git doesn't even *try* to sign. Git records the commit as unsigned with no error, because from its perspective signing was never requested. Explicit `-S` pins the requirement to the invocation: now git *always* attempts to sign, and if the signing agent (gpg-agent, or ssh-agent when using `gpg.format=ssh`) or its pinentry prompt is unreachable, the commit aborts noisily. You find out now, not when branch protection rejects the push later.\n\n**Why `--signoff`.** Adds the `Signed-off-by:` trailer. Required for DCO compliance on any repo that has the DCO check enabled (most netresearch repos do).\n\n**`--signoff` on an amend doubles the sign-off when other trailers follow it.** Git leaves the line out only when the *last* trailer already is that exact `Signed-off-by:`. A message whose trailer block reads `Signed-off-by:`, `Assisted-by:`, `Agent-Session:` gets a second `Signed-off-by:` below the rest — so rewording a commit with `git commit --amend -s -F msg.txt`, where `msg.txt` came from `git log -1 --format=%B`, ships the sign-off twice. Measured with git 2.55.0: sign-off as the last trailer → one line, sign-off followed by another trailer → two. When the message file already carries its trailers, amend without `-s`, and count before pushing:\n\n```bash\ngit log -1 --format=%B | git interpret-trailers --parse --no-divider | grep -c '^Signed-off-by:'\n```\n\n**Sign-off identity must match `git config user.{name,email}`.** Mismatched identities fail the DCO check with an unhelpful \"signoff required\" error. Validate before the first commit in a new worktree — and specifically check that the values are not swapped (an email address in `user.name` is a silent misconfiguration that produces a malformed `Signed-off-by:` trailer):\n\n```bash\ngit config user.name # must look like \"Firstname Lastname\", NOT an email address\ngit config user.email # must contain \"@\", NOT a plain name\n\n# Fix if swapped:\ngit config --global user.name \"Firstname Lastname\"\ngit config --global user.email \"[email protected]\"\n```\n\n**Commit locally to satisfy verified-signature branch protection — not via the host's Contents API.** Commits created through the GitHub Contents API (`PUT /repos/.../contents/...`), or any host-side write that supplies its own `committer`/`author`, come out **unsigned** (`verification.verified=false`, `reason: unsigned`) — the host does not web-flow-sign them. A repo that enforces verified signatures rejects the merge, and when admin enforcement is on you cannot bypass it. So when scripting commits across many repos, check the signature requirement up front alongside required checks and reviews:\n\n```bash\ngh api repos/\"$R\"/branches/main/protection \\\n --jq '.required_signatures?.enabled? // false' # $R=\"owner/repo\"; also inspect rulesets\n```\n\nFor any repo that enforces signatures, create the commit **locally** with signing configured (`git commit -S --signoff`) rather than through the API — a locally SSH/GPG-signed commit verifies on the host, an API-authored one does not.\n\n**SSH signing keys on GitHub: auth keys ≠ signing keys.** An SSH key registered under *Settings → SSH and GPG keys → Authentication Key* cannot verify commits. It must also be added as a *Signing Key* (same page, different Key type dropdown). GitHub reports unsigned-with-known-key commits as `reason: unknown_key` in the commits API — identical to an unregistered key. Check before the first push to a repo with verified-signature branch protection:\n\n```bash\n# Verify the key is registered as a signing key (requires admin:ssh_signing_key token scope):\ngh auth refresh -h github.com -s admin:ssh_signing_key\ngh api /user/ssh_signing_keys --jq '.[].key'\n\n# Or check commit verification after pushing one commit:\ngh api /repos/{owner}/{repo}/commits/HEAD --jq '.commit.verification | {verified, reason}'\n# \"reason\":\"valid\" → OK\n# \"reason\":\"unknown_key\" → key not registered as signing key\n# \"reason\":\"unsigned\" → -S flag not used or signing config missing\n```\n\n**Trust the inherited ssh-agent; don't hunt for sockets.** When commits are SSH-signed through a running ssh-agent, the `SSH_AUTH_SOCK` already present in the inherited shell environment normally holds the signing key and works transparently. Do **not** reflexively export a hardcoded `SSH_AUTH_SOCK` or probe the filesystem for agent sockets — a stale or wrong socket path overrides the working inherited one and breaks signing that would otherwise have succeeded. If signing actually fails (`failed to write commit object`, `error: gpg failed to sign the data`, or a `publickey` error), stop and surface the error to the operator rather than autonomously searching for the \"right\" socket.\n\n**A signing key dropped from the agent mid-session breaks signing *and* SSH git at once.** If the agent had the key earlier but `ssh-add -l` now prints `The agent has no identities.` (a passphrase-protected key can time out or be evicted), then `git commit -S` aborts (`ssh_askpass: … No such file`, \"incorrect passphrase\", `failed to write commit object`) **and** `git fetch`/`git push` over an SSH remote fail with `Permission denied (publickey)`. The push half has a fallback — route it over HTTPS with the `gh` token (`gh auth setup-git`, then `git push https://github.com/owner/repo.git \u003cbranch>`) — but **signing has no such fallback**: the SSH key file must be re-added. Do not bypass signing to get unblocked; ask the operator to re-add it (`ssh-add \u003csigning-key>`, e.g. `~/.ssh/id_ed25519`, which prompts for the passphrase). Both signing and SSH git recover once it is loaded.\n\n**Never amend a commit with pre-commit-hook failures.** If the pre-commit hook fails, the commit **did not happen**. Running `git commit --amend` then modifies the PREVIOUS commit, which can destroy work. Fix the hook issue, re-stage, and create a new commit.\n\n**Prove the commit landed before reporting it — compare HEAD, not the last commit.** A reformatting hook (`ruff format`, `black`, `isort`, `prettier`) rewrites the staged files and *aborts* the commit. Every check that reads \"the last commit\" then describes the commit before yours and reports success:\n\n```bash\nBEFORE=$(git rev-parse HEAD)\ngit add -- path/to/file path/to/other\ngit commit -s -F msg.txt\n[ \"$(git rev-parse HEAD)\" != \"$BEFORE\" ] || { echo \"commit aborted — re-stage and retry\" >&2; exit 1; }\ngit log -1 --format='%h %s' # now describes YOUR commit\n```\n\nChecking `git commit`'s own exit code works, but only if nothing reads it first — and in practice the next thing in the block is a status line that answers from the *previous* commit and looks right. `git log -1 --format='%G?'` prints the same value it printed before the aborted commit, and the `git push` that follows succeeds while pushing nothing new, because the branch never moved. HEAD is the one value the aborted commit did not leave intact, which is why comparing it answers the question the others only appear to.\n\n**Never skip hooks** unless explicitly told to. `--no-verify` bypasses hook enforcement that exists for good reasons. If a hook fails, diagnose the root cause. The one exception is a throwaway *probe* commit that is deleted moments later and never enters history — `signing-preflight.sh` retries with `--no-verify` there, and only to tell a hook rejection apart from a signing failure.\n\n**Never bypass signing** unless explicitly told to. `--no-gpg-sign` and `-c commit.gpgsign=false` disable commit signing; the result will fail branch-protection or policy checks that require signed commits later.\n\n**Verify signing capability without committing on `main`.** To check that signing\nactually works (right key, agent reachable), do **not** create a probe commit on the\ndefault branch — even an immediately-reset `git commit --allow-empty -S` on `main` is a\ntransient commit on a protected branch and violates \"no direct commits to main\". Inspect\nconfiguration instead, no commit required:\n\n```bash\ngit config commit.gpgsign # expect: true\ngit config gpg.format # ssh (SSH signing) or empty (GPG)\ngit config user.signingkey # the key/path that will be used\n```\n\nIf you must actually exercise the signing path, run `scripts/signing-preflight.sh`\n— it does the whole dance below and cleans up after itself. By hand, do it on a\nthrowaway branch and discard it:\n\n```bash\ngit switch -c tmp/sign-probe\n# conventional msg so a commit-msg hook won't reject it; header, not %G? — see \"Detecting a Signed Commit\" below\ngit commit --allow-empty -S -m \"chore: signing probe\" \\\n && (git cat-file commit HEAD | sed -n '/^$/q;p' | grep -qE '^gpgsig(-sha256)? ' && echo signed || echo \"NOT signed\") \\\n || echo \"NOT signed — commit failed\" # keep the chain: unchained, the check reads the parent commit\ngit switch - && git branch -D tmp/sign-probe\n```\n\n### Verifying a Signed Tag\n\nA tag needs two properties, and they fail independently:\n\n```bash\ngit for-each-ref refs/tags/v1.2.3 --format='%(objecttype)' # expect: tag, not commit\ngit tag -v v1.2.3\n```\n\n`objecttype: commit` means a **lightweight** tag — created without `-a`/`-s`,\ncarrying no signature and no tagger. Many release pipelines reject these\noutright, and `git tag -v` on one reports an error that reads like a bad\nsignature rather than a missing one.\n\n**Do not grep for `Good signature`.** The wording depends on the signing\nbackend, and an SSH-signed tag does not use that phrase:\n\n| Backend | Output |\n|---|---|\n| GPG | `Good signature from \"Name \u003cmail>\"` |\n| SSH (`gpg.format=ssh`) | `Good \"git\" signature for mail with ED25519 key SHA256:…` |\n\nWith `gpg.format=ssh` set — increasingly the default in this org — a check for\n`Good signature` returns nothing and a correctly signed tag looks unsigned.\nMatch on both, or on the object type plus a looser pattern:\n\n```bash\ngit tag -v v1.2.3 2>&1 | grep -qE 'Good (\"git\" )?signature' && echo signed\n```\n\n`git tag -v` needs `gpg.ssh.allowedSignersFile` as well. Without it, it prints\n`error: gpg.ssh.allowedSignersFile needs to be configured and exist for SSH\nsignature verification`, exits 1, and the pattern above matches nothing (git\n2.54.0) — so a failed match means \"not verifiable *here*\", not \"unsigned\".\n\n### Detecting a Signed Commit\n\nSignedness and verification are different questions, and only the first has a\nstable local answer: whether a commit *carries* a signature is a property of the\nobject, whether *this machine* trusts that signature depends on local config.\nThe check below answers the first one only.\n\nFor that question, do not reach for `git log --format='%G?'`: it is\nbackend-independent but *not* verification-config-independent. Under\n`gpg.format=ssh` with no `gpg.ssh.allowedSignersFile`, `%G?` returns `N` and\n`--show-signature` prints `No signature` on a correctly signed commit (git\n2.54.0) — `N` is indistinguishable from unsigned, and setting that config flips\nthe identical commit to `G`. Read the commit header instead, which both backends\nwrite (`-----BEGIN SSH SIGNATURE-----` / `-----BEGIN PGP SIGNATURE-----`) and no\nlocal config gates:\n\n```bash\nscripts/signing-preflight.sh --check-commit HEAD # exit 0 signed, 1 unsigned\n# by hand:\ngit cat-file commit HEAD | sed -n '/^$/q;p' | grep -qE '^gpgsig(-sha256)? ' && echo signed\n```\n\n`gpgsig-sha256` is the header name in SHA-256 repositories; the alternation\ncovers both. Match the header name and the space, so nothing else in the header\nblock can pass.\n\nCut the header at the first blank line — over the whole object, `^gpgsig` also\nmatches a message body line starting with `gpgsig` and reports an unsigned commit\nas signed. Whether the *host* accepts the key is a separate question, answered\nonly by the commits API check above.\n\n## Atomic Commits\n\nEach commit should be a **single, self-contained logical change** that builds and passes tests independently.\n\n**Good:**\n\n- `feat: add user authentication endpoint` (one feature, complete)\n- `fix: correct SAML attribute name mapping` (one bug, fixed)\n- `chore(deps): bump go-ldap/ldap/v3 from 3.4.8 to 3.4.11` (one bump)\n\n**Bad:**\n\n- `feat: add auth + fix typo + update deps` (three unrelated concerns)\n- `wip` / `fixup` (leftover scratch commits)\n\nRewrite messy history before opening the PR:\n\n```bash\ngit rebase -i main # interactive, squash / reword / reorder\ngit rebase -i --autosquash main # auto-pick fixup!/squash! commits\n```\n\n## Push Upstream on First Push\n\nWhen pushing a new branch for the first time, set upstream tracking with `-u`:\n\n```bash\ngit push -u origin feature-branch\n```\n\nThis makes subsequent `git pull` / `git push` work without specifying remote+branch. Without `-u`, everyone who clones the branch later has to set it up themselves.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":20481,"content_sha256":"6afaad5ba0bf3e57610630916620516cfc25735933f29a0545438d1eb2064038"},{"filename":"references/git-hooks-setup.md","content":"\u003c!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->\n\u003c!-- SPDX-FileCopyrightText: Netresearch DTT GmbH -->\n\n# Git Hooks Setup\n\n## Why Hooks Matter\n\nGit hooks catch issues before they reach CI — faster feedback, fewer wasted CI runs.\nFor autonomous agents, hooks are essential: they enforce commit message format,\nprevent secrets, and ensure code quality without requiring the agent to \"remember\" rules.\n\n## Hook Frameworks\n\n| Framework | Language | Config File | Install |\n|-----------|----------|-------------|---------|\n| **lefthook** | Go binary | `lefthook.yml` | `go install github.com/evilmartians/lefthook@latest && lefthook install` |\n| **captainhook** | PHP | `captainhook.json` | `composer install` (auto via plugin) |\n| **husky** | Node.js | `.husky/` | `npm install` (auto via prepare) |\n| **pre-commit** | Python | `.pre-commit-config.yaml` | `pip install pre-commit && pre-commit install` |\n\n## Detection — One Command\n\n```bash\nls lefthook.yml .lefthook.yml captainhook.json .pre-commit-config.yaml .husky/pre-commit 2>/dev/null || echo \"No hook framework configured\"\n```\n\nThen install based on what's found:\n- `lefthook.yml` → `lefthook install` (or `make setup`)\n- `captainhook.json` → `composer install` (auto)\n- `.husky/` → `npm install` (auto)\n- `.pre-commit-config.yaml` → `pre-commit install`\n- Nothing → suggest adding one based on project language\n\n## Updating Hook Versions (`pre-commit autoupdate`)\n\n`pre-commit autoupdate` bumps every hook's `rev` to the repo's **latest tag —\nincluding pre-releases**. It happily pins a beta (observed: isort\n`8.0.1 → 9.0.0b1`). After running it, review each bumped `rev` and pin any\n`aN`/`bN`/`rcN` back to the latest stable tag before committing. When a hook\nmirrors a locked dev dependency (black, isort), keep the hook `rev` aligned\nwith the lockfile version instead of blindly taking the newest tag.\n\n### Nobody bumps a `rev` unless you arrange it\n\n`autoupdate` is a manual command, so a repository that never runs it keeps its\nhooks forever. Renovate can do it, but **its pre-commit manager is disabled by\ndefault** — enable it explicitly:\n\n```json\n{ \"extends\": [\"config:recommended\"], \"pre-commit\": { \"enabled\": true } }\n```\n\nWithout that line no hook is ever offered an update, and nothing reports it:\nan absent pull request looks exactly like being up to date. Measured across one\nfleet of 37 repositories, the six without the manager sat on a validator tag 15\nminor versions behind while the other 31 tracked current — the correlation was\ncomplete.\n\n**A stale `rev` keeps enforcing the rule that version had.** Those six failed\ncommits with `SKILL.md is 501 words (max 500)` months after the word cap had\nbeen replaced by a line-based one. When a pinned checker rejects your change,\nread the pin before you edit the file to satisfy it: an error message names the\nrule the *pinned* version enforces, which is not necessarily the rule that\napplies. Shortening prose to pass a retired cap is work spent against nothing.\n\n**Do not reach for a moving ref to escape this.** `rev: main`, or a `v1` branch\nthat tracks main, is worse rather than better:\n\n```\n[WARNING] The 'rev' field ... appears to be a mutable reference (moving tag /\nbranch). Mutable references are never updated after first install and are not\nsupported.\n```\n\npre-commit clones a mutable ref once and never refreshes it, so the running\nversion freezes per machine, invisibly and differently for each developer. An\nimmutable tag in the file plus a bot that moves it is the supported shape.\n\n## Recommended Hooks by Stage\n\n### pre-commit (fast, \u003c5s)\n- Code formatting (gofmt, php-cs-fixer, prettier)\n- Import sorting\n- YAML/JSON validation\n- Secret detection\n\n### commit-msg\n- Conventional commits validation\n- DCO sign-off enforcement\n- Minimum message length\n\n### pre-push (can be slower)\n- Full linting (golangci-lint, phpstan)\n- Smoke tests\n- Security scanning\n\n## Rules for Agents\n\n- NEVER skip hooks with `--no-verify`\n- If a hook fails, fix the underlying issue\n- If hooks aren't installed, install them before first commit\n- If no hook framework exists, suggest adding one in the PR\n\n## Hooks Are Fast Feedback — CI Is the Gate\n\nLocal hooks are per-checkout, individually bypassable, and **absent in fresh\nclones and in most secondary worktrees**. Treat them as fast feedback, not as an\nenforcement boundary. The actual gate must be CI: every substantive check a hook\nruns locally (format, lint, static analysis, tests, secret scan, commit-message\nvalidation) needs an equivalent job in the pipeline. A check that runs only in a\nlocal hook is silently unenforced for anyone who never installed it. When\nauditing a repo, confirm that parity — a missing local hook is only a real gap\nif CI doesn't cover the same check.\n\nThis also means it is legitimate to deliberately leave a repo with **no** local\nhooks when the hook can't run reliably outside its container (e.g. a pre-commit\nthat needs a Docker-only service) — provided CI runs the equivalent check.\nForcing such a hook onto every checkout just turns it into a landmine.\n\n## Auditing Installed Hooks (`--git-path` cwd gotcha)\n\nTo find where a checkout's hooks live, prefer the path git computes itself:\n\n```bash\ncd \"$repo\" && git rev-parse --git-path hooks\n```\n\n**Run it from inside the repo.** For a plain (non-worktree) clone,\n`git rev-parse --git-path hooks` returns a path **relative to the current\ndirectory** (`.git/hooks`). Resolve or `find` it from elsewhere and you look in\nthe wrong place and false-report \"no hooks installed\". (Worktrees and a\nconfigured `core.hooksPath` return absolute paths, which masks the bug — so it\nonly bites on ordinary clones.) `cd` into the repo first, or resolve the path\nagainst the repo root, before inspecting it.\n\n## Troubleshooting\n\n### CaptainHook + git worktrees (FAQ)\n\n- **Symptom**: `composer install` fails with\n `Shiver me timbers! CaptainHook could not install yer git hooks! (invalid .git path)`\n when run in a secondary git worktree.\n- **Cause**: Git worktrees use a `.git` *pointer file* (e.g. `gitdir: /path/to/bare/worktrees/NAME`),\n not a directory. `captainhook/hook-installer` ≤ 1.x does not resolve the pointer correctly\n and aborts.\n- **Fix (recommended)**: `mkdir -p \"$(git rev-parse --git-path hooks)\" && composer install` —\n creates the hooks dir at the effective hooks path (honors `core.hooksPath` if configured,\n falls back to `\u003cgit-dir>/hooks` otherwise). Works with captainhook's plugin in place, so other\n Composer plugins (phpstan/extension-installer, TYPO3 composer installers, etc.) continue to\n auto-register normally.\n- **Fix (scoped alternative)**: `composer config extra.captainhook.disable-plugin true`\n before the install disables ONLY the captainhook plugin — all other Composer plugins\n (phpstan/extension-installer, TYPO3 installers, …) keep working. It edits `composer.json`,\n so revert it before committing (`git checkout composer.json`); useful for one-off\n installs in throwaway worktrees where hooks are not wanted anyway.\n- **Fix (last-resort fallback)**: `composer install --no-plugins` — only if the hooks-dir\n workaround above doesn't resolve it. Be aware this disables *all* Composer plugins for that\n install, which has broader side effects: phpstan extensions won't auto-register, TYPO3\n composer installers won't place extensions, and captainhook itself won't install hooks. Hooks\n still work in the primary worktree where `.git` is a real directory.\n- **When this matters**: Repos using a bare-repo + worktrees layout (see\n [git-worktree(1)](https://git-scm.com/docs/git-worktree)) hit this on every `composer install`\n in a secondary worktree, since `.git` is a pointer file rather than a directory.\n- **Cross-reference**: The `netresearch/typo3-ci-workflows` meta-package bundles\n `captainhook/hook-installer`; its README section \"Git Worktree + captainhook Workaround\"\n is the canonical source.\n\n### Hooks fail in worktrees / hang on host-unreachable services (FAQ)\n\n- **Symptom A**: `git commit` in a secondary worktree fails with\n `./bin/captainhook: not found` — even though `--no-verify` was passed to\n `git commit`.\n- **Cause A**: hooks installed by the primary checkout run with the worktree as\n CWD and reference `./bin/captainhook` relatively; the worktree has no\n `vendor/`/`bin/`. The commit-level `--no-verify` has a blind spot:\n it skips only `pre-commit` and `commit-msg` — **`prepare-commit-msg` always\n runs**, so a broken hook of that type still fails the commit. (`git push`\n has its *own* `--no-verify`, which does skip `pre-push` entirely; the\n commit-level flag simply does not cover it.)\n- **Symptom B**: a pre-commit hook that runs the test suite hangs forever on\n the host because the tests need a docker-only service (e.g. a test DB only\n resolvable inside the compose network). Killing the runner can leave a\n zombie process holding the index lock. Note that in a worktree `.git` is a\n pointer **file**, not a directory — the lock lives at\n `$(git rev-parse --git-dir)/index.lock`. First confirm no git or hook\n process is still alive (`pgrep -fl 'git|captainhook'`); only then remove\n the stale lock and retry — deleting it under a live process corrupts the\n index.\n- **Controlled bypass** (the only sanctioned exception to \"never skip hooks\"):\n first run the hook's checks *manually via equivalent commands* (linters and\n static analysis on the changed files, the test suite inside its docker\n environment), then bypass the broken hook explicitly and disclose it:\n `git -c core.hooksPath=\"$(mktemp -d)\" commit -s ...` (an empty hooks\n directory disables all hook types for that one command and — unlike\n `/dev/null` — is portable to Windows) and `git push --no-verify`. Never\n make the bypass the default; fix the hook environment or commit from the\n primary checkout when possible.\n- **Symptom C**: a hook wrapper that runs CaptainHook (or any checker) inside a\n docker compose service fails in a secondary worktree, while the same commit\n passes in a plain clone. A fallback in the wrapper (e.g. \"`composer install`\n failed, try `composer update`\") then rewrites `composer.lock` as a side\n effect.\n- **Cause C**: the compose service mounts only the working-tree directory\n (e.g. `.:/app`). In a worktree, `.git` is a pointer file whose `gitdir:`\n names a path outside that mount (the main repo's `.git/worktrees/\u003cname>`, or\n `.bare/worktrees/\u003cname>` in a bare layout), so every git call inside the\n container fails with `fatal: not a git repository: …`. The wrapper's install\n step fails for the same reason (compare the\n [CaptainHook + git worktrees](#captainhook--git-worktrees-faq) FAQ) and falls\n through to `update`.\n- **Fix C — make the git dir resolvable in the container**: mount\n `$(git rev-parse --git-common-dir)` at the identical absolute path, or create\n worktrees with relative links (`git worktree add --relative-paths`, or\n `worktree.useRelativePaths=true`, git ≥ 2.48) and mount their common parent\n directory, with each worktree at the same relative path beneath it (set the\n service's working directory to that subpath instead of mounting the worktree\n separately at e.g. `/app`). Relative links set the\n `extensions.relativeWorktrees` repository extension in the shared config, so\n every git that touches the repository — including the one in the container\n image — must be ≥ 2.48. Or commit from a\n checkout whose `.git` is a directory (a plain clone; a bare layout has none,\n so use the mount).\n- **If you must commit from this worktree anyway**: undo the wrapper's rewrite\n first. A lock change you are committing is staged, so restore from the\n **index**, not from the branch: `git restore -- composer.lock`\n (`git diff -- composer.lock` shows what this discards;\n `git diff --cached -- composer.lock` shows the staged change it keeps).\n Unstaged lock edits cannot be separated from the rewrite — regenerate them\n (re-run the intended `composer require` / `update \u003cpkg>`) where the container\n works. Then run the hook's checks yourself inside the container and use the\n controlled bypass above, and say so in the MR/PR.\n\n### Distinguish \"the hook is broken\" from \"the check failed\"\n\nThe bypass above is for a hook that cannot *run*. A hook that ran fine and\nreported a genuine failure is a different situation with the same exit code, and\nconflating them turns the sanctioned bypass into a habit.\n\nThird case, easy to misread as either: the hook ran, the check ran, and the\ncheck failed for a reason that lives in the **environment**, not the change.\nTells:\n\n- `Doctrine\\DBAL\\Exception\\DriverException: could not find driver` — the host\n has no `pdo_mysql`; the container does.\n- `Container /path/var/cache/dev/App_KernelDevDebugContainer.xml does not exist`\n — `phpstan-symfony` needs a warmed container: `php bin/console cache:warmup`\n before the analysis, not a baseline entry.\n- A failure count that does not move when you revert your change.\n\nThe fix is the **venue**, not the flag. Run the identical gate where the\nenvironment is complete — usually the project's own compose service:\n\n```bash\ndocker compose run --rm app-e2e php -d memory_limit=1G bin/phpunit --testsuite unit --no-coverage\n```\n\nOnly after that passes does the shim bypass apply, and say in the commit or the\nPR which gate ran where. Reaching for `--no-verify` on this class hides a\nresult you could have had.\n\nWatch for a cold-start race when you script this: a suite started immediately\nafter `docker compose up -d` can fail one or two DB-dependent tests while the\ndatabase is still accepting connections. Re-run once against the warm stack\nbefore believing the failure.\n\n### Fresh worktree: missing deps and stale `hooksPath`\n\nA freshly-created worktree often breaks pre-commit/pre-push hooks — and can even\nabort the worktree creation itself:\n\n- It has no `vendor/`/`node_modules`, so a hook binary (`vendor/bin/grumphp`,\n `captainhook`, a husky script) is missing.\n- A stale or broken `core.hooksPath` points at a nonexistent binary in **another**\n worktree.\n\nEither failure aborts worktree-add, commits, and pushes. Bypass cleanly and\n**scoped** — don't disable hooks globally:\n\n```bash\ngit -c core.hooksPath=/dev/null commit -s ... # scoped to this one command\ngit push --no-verify\n```\n\n(`core.hooksPath=\"$(mktemp -d)\"` is the Windows-portable equivalent — see the\ncontrolled-bypass note above.) CI is authoritative for these repos (see \"Hooks\nAre Fast Feedback — CI Is the Gate\"), so a scoped bypass here is safe. Also: in a\nfresh worktree, `Read` a file before `Write`/`Edit` — there is no cached state\nfor a path the tooling has not seen yet.\n\n### pre-commit first run: slow env build, lost stash on interrupt\n\nThe **first** commit that triggers `pre-commit` builds a fresh virtualenv for\nevery hook repo (ruff, markdownlint, shellcheck, …). That can take minutes, and\nit is easy to interrupt or time out that commit before it finishes.\n\n`pre-commit` stashes your **unstaged** changes for the duration of the run, then\nrestores them at the end. If the run is killed mid-way, that restore may not\nhappen — the unstaged edits silently vanish from the working tree. So **always\n`git status` after an interrupted commit**, and if files reverted, reapply the\nstash patch pre-commit left behind:\n\n```bash\n# pre-commit's cache dir is configurable: PRE_COMMIT_HOME, else XDG_CACHE_HOME/pre-commit,\n# else ~/.cache/pre-commit (macOS/other platforms may differ).\nPCH=\"${PRE_COMMIT_HOME:-${XDG_CACHE_HOME:-$HOME/.cache}/pre-commit}\"\nPATCH=$(ls -t \"$PCH\"/patch* 2>/dev/null | head -1) # newest patch\u003cepoch>-\u003cpid>\ngit apply \"$PATCH\"\n```\n\nThen re-run the commit (the hook envs are now built, so it is fast).\n\n### Formatter hooks rewrite files — the first commit always bounces\n\nA hook that *reformats* rather than merely reports (`black`, `isort`,\n`ruff format`, `prettier`, `shfmt`, `end-of-file-fixer`) rewrites the staged\nfile and then **fails the commit**, because what it wrote is not what you\nstaged:\n\n```\nblack....................................................................Failed\n- hook id: black\n- files were modified by this hook\nreformatted tests/test_completion.py\n```\n\nThe commit did **not** happen. The fix is `git add` the now-reformatted files\nand commit again — the second run passes because the file is already formatted.\nBudget two `commit` invocations, and never `--amend` here (see\n\"Never amend a commit with pre-commit-hook failures\" in\n`commit-conventions.md`): the first commit does not exist, so `--amend` would\nrewrite the *previous* one.\n\nTo avoid the bounce entirely, run the formatter before committing — matching\nwhatever the repo actually uses, which is not always the one you expect:\n\n```bash\n# read the hook ids the repo declares, then run those\nyq '.repos[].hooks[].id' .pre-commit-config.yaml\npre-commit run black isort --files \u003cchanged files> # or: ruff format . && ruff check .\n```\n\nChecking the config matters: a project can use `black`+`isort` where you assumed\n`ruff format`, and a habit built around only one of them still bounces on every\ncommit in the other.\n\n### `--no-verify` only after confirming CI parity\n\nA local hook the CI pipeline does **not** run is not a real merge gate (see\n\"Hooks Are Fast Feedback — CI Is the Gate\"). If such a hook (say a repo-only\n`black`, `isort`, or `shellcheck`) reports the tree dirty and running its\nformatter would rewrite **hundreds of unrelated lines**, bypassing it with\n`--no-verify` is defensible — but only *after* you have read the CI workflow and\nconfirmed it does not enforce that check. Concretely: verify the pipeline's\n`flake8` uses a narrow `--select`, that there is no `shellcheck`/`black` job,\netc. A hook enforced only locally is already silently unenforced — don't let it\ndictate a massive diff that has nothing to do with your change. If CI *does* run\nthe check, it is a real gate: fix the finding instead of bypassing it.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":18019,"content_sha256":"5cf2c15f69c1f5bb724412a2b354bcab538296bba5ee6e794604e3bdf8d87bf6"},{"filename":"references/github-releases.md","content":"\u003c!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->\n\u003c!-- SPDX-FileCopyrightText: Netresearch DTT GmbH -->\n\n# GitHub Releases\n\nRelease creation and management moved to the\n[`github-release`](https://github.com/netresearch/github-release-skill) skill,\nthe canonical owner of this content. Invoke that skill for:\n\n- GitHub Immutable Releases (deleted-tag reuse is permanently blocked)\n- `--latest=false` for non-default-branch releases\n- Multi-branch release sequencing and recovery after a failed publish\n\nThis skill (`git-workflow`) still owns the git side of a release — signed\ncommits, PR merge, and tagging conventions. See\n`references/commit-conventions.md` and `references/pull-request-workflow.md`.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":701,"content_sha256":"9fc0ee43b2e7c69135e062c0bb47bd8dc0469b249ccc0ccb5d42a5b6593802e9"},{"filename":"references/merge-gate-watcher.md","content":"\u003c!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->\n\u003c!-- SPDX-FileCopyrightText: Netresearch DTT GmbH -->\n\n# Merge-Gate Watcher\n\nCanonical polling loop to drive a PR to merge once review threads are handled. Hand-rolling this per PR invites classification bugs (a soft check counted as hard ⇒ false HOLD; a missed one ⇒ premature merge).\n\n## Driving many PRs at once\n\n`scripts/pr-sweep.sh LISTFILE` (one `owner/repo N` per line) is the shipped driver. Each pass reads every open PR with `pr-status.sh --json`, runs `pr-merge.sh` on `merge`, keeps waiting on `wait`, `await-review`, `await-checks` and `await-capacity`, and on every other `.next.action` — `request-review`, `rebase`, a closed PR, a value added in a later version — writes the PR and its full NEXT to the attention file, finishes the pass and exits 3. A gate read that fails on two consecutive passes, or `rules-unavailable` twice, stops it the same way. It logs one line per state change, exits 0 with `ALL MERGED`, and 4 on its overall timeout with each open PR's last state. It never passes `--self-reviewed`. It exists because hand-written sweeps sent unknown actions to \"wait\": two of them sat four hours on `NEXT: request-review` without a line of output.\n\nWithout it: one `pr-status.sh --watch` per PR, all in parallel in one background command — each instance returns at *its* first actionable event, so a slow PR never delays acting on a fast one:\n\n```bash\nd=$(mktemp -d) # fresh dir: no interleaved writes, no stale files from a prior run\nfor p in \"owner/repo-a 11\" \"owner/repo-b 22\"; do\n ( set -- $p\n pr-status.sh -R \"$1\" \"$2\" --watch | grep '^NEXT:' | tail -1 | sed \"s|^|$1#$2 |\" > \"$d/${1//\\//_}-$2\" ) &\ndone\nwait; cat \"$d\"/*\n```\n\nThen act per line: `NEXT: merge` → `pr-merge.sh` in a **new** invocation (merge-gate hooks evaluate at call time — never chain the wait and the merge in one command); `resolve-threads` / `address-comments` → handle that PR individually while the rest keep going. Do not write a bespoke driver that re-reads PR state in a loop and dispatches on it — use `pr-sweep.sh` above; a hand-written one re-implements `pr-status.sh` badly, waits for the one outcome it was told about, and sleeps through the rest (verified 2026-08-03: a 7-repo release sweep completed on this pattern with zero hand-rolled polling). The failure is silent, which is why it costs time: on 2026-10-07 a bespoke driver over 27 PRs acted only on `merge` and on red checks, and netresearch/t3x-nr-passkeys-fe#92 sat at `NEXT: rebase — branch is behind main` for about 35 minutes after its last check turned green (18:39 to 19:14 UTC) without a single log line, until the PR was read by hand. The parallel `--watch` loop above returns on that action like on any other.\n\n## Check taxonomy\n\nClassify every failing check BEFORE reacting:\n\n| Class | Examples | Reaction |\n|-------|----------|----------|\n| **Hard** | unit/integration/E2E tests, lint, build | HOLD and fix — except known infra flakes (Docker Hub pull timeout, buildx setup): one `gh run rerun \u003cid> --failed` |\n| **Soft, self-healing** | `codecov/*` while sibling jobs still run (partial uploads) | Ignore while `pending > 0`; if persisting after completion: one full `gh run rerun \u003cid>` |\n| **Soft, structural** | SonarCloud PR gate on refactor PRs | Introspect before deciding (below) |\n| **Not started** | `failure` with zero steps, empty `runner_name` and the annotation `The job was not started because it repeatedly failed to be acquired`; `pr-status.sh` lists it under `not started` | Nothing to fix in the PR. `gh run rerun \u003cid> --failed` once the whole run has completed; `NEXT: rerun-ci` names the runs (below) |\n| **Infra, silent upload** | CodeQL / SAST job red in its code-scanning upload step; the failed step ends at `Uploading results` and the job log has no `##[error]` | One `gh run rerun \u003cid> --failed`; investigate only if it repeats |\n\n### Jobs that never started, and uploads that fail without an error\n\nA queued job whose runner GitHub could not acquire is concluded `failure` with **zero steps**, an empty `runner_name` and the annotation `The job was not started because it repeatedly failed to be acquired (5 attempts).` Aggregate gates such as `All CI checks` then fail as well, because a job they need failed, so a PR whose code is fine reads as a dozen red checks.\n\nObserved once, on 2026-10-07, and it coincided with a GitHub incident ([githubstatus.com, djlmxz2zd0j7](https://www.githubstatus.com/incidents/djlmxz2zd0j7): Actions, Pull Requests and Git Operations disrupted between 15:06 and 15:16 UTC). On netresearch/t3x-nr-textdb#169 twelve jobs created between 14:23 and 14:25 concluded this way between 15:08 and 15:16, while jobs of the same runs that were still queued got a runner from 15:16 on. `gh run rerun --failed` turned the twelve green. Whether a busy pool alone produces this outside an incident is not established.\n\nTell it apart in one call per job — the check-run id is the job id:\n\n```bash\ngh api \"repos/$R/actions/jobs/$JOB\" --jq '{runner_name, steps: (.steps|length), conclusion}'\n# runner_name \"\" and steps 0 -> read the annotations:\ngh api \"repos/$R/check-runs/$JOB/annotations\" --jq '.[].message'\n# \"... repeatedly failed to be acquired ...\" -> re-run, do not debug\n```\n\nZero steps alone does not identify it: other refusals to start a job carry a different annotation and a different fix. `pr-status.sh` therefore requires the annotation as well, lists such rows under `not started` with their run ids, and still counts them as failing, so the gate stays shut. When they are the only failures and each of their runs has concluded, `NEXT` is `rerun-ci` with one `gh run rerun \u003crun> --failed` per run. While a job of such a run is still going, `NEXT` is whatever else is due (an open thread still comes first), and a `wait` names the not-started rows. With any other failure mixed in, `fix-ci` / `triage-ci` name that failure and list the not-started rows separately; while a required check is still pending, a non-required failure is only named in the `wait`, as for any red non-required check. `gh run rerun` refuses a run that is still in progress, so a run with one job still queued has to finish first.\n\nA second failure in the same window looked like a finding and was not one: a code-scanning job failed in its upload step, whose output ended at `Uploading results`, with no `##[error]` line anywhere in the job log and no error annotation. The job log itself goes on for about 140 lines of post-job cleanup, including `CodeQL job status was failure`, so its tail shows the cleanup, not the tell. The analysis had finished; the upload was not accepted. Seen on netresearch/t3x-nr-passkeys-fe#92 (`codeql / Analyze (javascript-typescript)`, step `Perform CodeQL Analysis`, 15:07) and netresearch/t3x-nr-xliff-streaming#54 (`security / SAST (Opengrep)`, step `Upload SARIF to code scanning`, 15:12), both inside the incident window; one re-run cleared both. The step name alone also matches a real analysis failure, so `pr-status.sh` does not classify this one. Check the signature before re-running — one hit for the first pattern and none for the second:\n\n```bash\ngh run view --repo \"$R\" --job \"$JOB\" --log | grep -n -e 'Uploading results' -e '##\\[error\\]'\n```\n\n## One shard red in a sharded suite: flake vs. real regression\n\nWhen one of N test shards fails, read its **first** error before you reach for a rerun — whether the Hard-class failure above is a flake or a real bug turns on it:\n\n- **Infra flake** — the first error is a stack-boot / health-check line (`App failed to start within timeout`, DB-not-ready, a 5xx from the app root). Every assertion failure below it is collateral: there was no app to talk to. Only that one shard is red; the siblings pass. Reaction: one `gh run rerun \u003cid> --failed` (a rebase + push also re-triggers a clean run).\n- **Real regression** — *typically* the same spec(s) fail **across all shards deterministically**, and the first error is an assertion (or an actionability timeout), not a boot line. A regression in shared code does not politely confine itself to one shard. (The Playwright case below is the exception — a real regression that can surface on a single shard.)\n\nPlaywright tell: `locator.check` / `locator.click: Test timeout` is an **actionability** failure — the element never became visible / stable / hit-testable — usually a CSS or DOM change that broke a hit target. Treat it as a real regression to investigate even when it surfaces on a single shard, not as a flake to rerun. (Seen: a `.field-check-row` restyle moved a label out of the node a spec located by, so `getByText`-anchored `.check()` hung 30s — red on shard 1 only, looked exactly like a boot flake, was a real DOM regression.)\n\n## Sonar gate introspection\n\nNever merge on a red Sonar gate without knowing *why* it is red:\n\n```bash\nAUTH=\"Authorization: Bearer $SONAR_TOKEN\"\ncurl -s -H \"$AUTH\" \"https://sonarcloud.io/api/qualitygates/project_status?projectKey=$KEY&pullRequest=$PR\" \\\n | jq -r '[.projectStatus.conditions[]|select(.status!=\"OK\")|.metricKey]|join(\",\")'\ncurl -s -H \"$AUTH\" \"https://sonarcloud.io/api/issues/search?componentKeys=$KEY&pullRequest=$PR&resolved=false&ps=1\" | jq .total\n```\n\nMerge-despite is defensible only when the sole failing condition is a touched-line **re-attribution** metric, open PR issues are 0, and the PR body documents the rationale. Real findings: fix them.\n\n**`new_duplicated_lines_density` is only sometimes that metric — check which case you are in before invoking the exemption.** It is re-attribution when the PR *moved or touched* existing lines and Sonar consequently charged the surrounding, already-duplicated block to the diff. It is a real finding when the PR *added* files: three new sibling classes written in one sitting are copy-paste, and the metric is measuring exactly that. `api/issues/search` cannot tell them apart — duplication is a measure, not an issue, so it returns 0 in both cases and the \"open issues are 0\" half of the exemption is satisfied either way.\n\nAsk which files carry the new duplicated lines, then read the blocks:\n\n```bash\ncurl -s -H \"$AUTH\" \"https://sonarcloud.io/api/measures/component_tree?component=$KEY&pullRequest=$PR&metricKeys=new_duplicated_lines&ps=200\" \\\n | jq -r '.components[] | (.measures[0] | (.value // .periods[0].value // \"0\")) as $v\n | select($v != \"0\") | select(.qualifier==\"FIL\") | \"\\($v)\\t\\(.path)\"'\ncurl -s -H \"$AUTH\" \"https://sonarcloud.io/api/duplications/show?key=$KEY%3A\u003cpath>&pullRequest=$PR\" \\\n | jq '.duplications[].blocks | map(\"\\(.from)-\\(.from + .size - 1)\")'\n```\n\nTwo shapes to get right or the first command prints nothing: a **`new_*`** metric\ncarries its value under `periods[0].value`, not `.value`, and the response lists\ndirectories as well as files, so filter on `qualifier==\"FIL\"` to get paths you\ncan pass to `duplications/show`.\n\nIf the files listed are ones the PR *added*, dedupe them — the exemption does not apply. `typo3-testing-skill/references/sonarcloud.md` (\"Gotchas: new-code duplication\") carries the same rule from the analyzer side and the recovery patterns for it.\n\n## Watcher skeleton\n\n```bash\nR=owner/repo; PR=123; BR=branch; RERUN_DONE=0\nfor i in $(seq 1 100); do\n sleep 30\n STATE=$(gh pr view $PR --repo $R --json state,mergeStateStatus) || continue\n [ \"$(jq -r .state \u003c\u003c\u003c\"$STATE\")\" = \"MERGED\" ] && exit 0\n MS=$(jq -r .mergeStateStatus \u003c\u003c\u003c\"$STATE\")\n UNRES=$(gh api graphql -f query=\"{repository(owner:\\\"${R%/*}\\\",name:\\\"${R#*/}\\\"){pullRequest(number:$PR){reviewThreads(first:100){nodes{isResolved}}}}}\" \\\n --jq '[.data.repository.pullRequest.reviewThreads.nodes[]|select(.isResolved|not)]|length') || continue\n CHECKS=$(gh pr checks $PR --repo $R 2>/dev/null)\n PENDING=$(grep -c -E \"pending|in_progress\" \u003c\u003c\u003c\"$CHECKS\" || true)\n HARD=$(grep \"fail\" \u003c\u003c\u003c\"$CHECKS\" | grep -v -c -E \"codecov|SonarCloud Code Analysis\" || true)\n SOFT=$(grep \"fail\" \u003c\u003c\u003c\"$CHECKS\" | grep -c -E \"codecov|SonarCloud Code Analysis\" || true)\n [ \"$MS\" = \"BLOCKED\" ] && [ \"$UNRES\" -gt 0 ] && { echo \"HOLD: $UNRES threads\"; exit 1; }\n if [ \"$HARD\" -gt 0 ] && [ \"$PENDING\" -eq 0 ]; then\n # one rerun for infra flakes only, then HOLD\n if [ \"$RERUN_DONE\" -eq 0 ] && grep \"fail\" \u003c\u003c\u003c\"$CHECKS\" | grep -qE \"E2E|Integration|docker\"; then\n # Resolve first and check it: an empty id, or a rerun that fails, must\n # NOT burn the single retry — otherwise every later iteration skips the\n # rerun that never happened.\n RID=$(gh run list --repo $R --branch $BR --workflow ci.yml --limit 1 \\\n --json databaseId --jq '.[0].databaseId') || RID=\"\"\n if [ -n \"$RID\" ] && gh run rerun \"$RID\" --repo $R --failed; then\n RERUN_DONE=1; sleep 60; continue\n fi\n echo \"HOLD: rerun could not be dispatched (id='${RID:-none}')\"; exit 1\n fi\n echo \"HOLD: hard fails\"; grep fail \u003c\u003c\u003c\"$CHECKS\"; exit 1\n fi\n if [ \"$PENDING\" -eq 0 ] && [ \"$UNRES\" -eq 0 ] && { [ \"$MS\" = \"CLEAN\" ] || [ \"$MS\" = \"UNSTABLE\" ]; } && [ \"$HARD\" -eq 0 ] && [ \"$SOFT\" -eq 0 ]; then\n gh pr merge $PR --repo $R --merge && exit 0\n fi\ndone\n```\n\nPitfalls baked in: `grep -c` exits 1 on zero matches (`|| true`); decide hard-fail only at `PENDING -eq 0` (codecov posts transient FAILURE mid-run); never count a check class you did not explicitly list.\n\n**On a merge-queue repo, drop the strategy flag.** `gh pr merge $PR --merge` (or `--squash`/`--rebase`) on a repo whose `main` uses a merge queue prints `! The merge strategy for main is set by the merge queue` and ignores the flag — but it still **enqueues** the PR, so that line is a notice, not a failure. Confirm via the queue-entry check (below), not the command's output. Call `gh pr merge $PR` without a strategy flag there and let the queue decide; keep the explicit strategy only for non-queue repos.\n\n**Right after a queue merge, the REST view lags.** `gh pr view --json state` can still answer `OPEN` (with `mergeQueueEntry` already `null`) for a short window after the queue merged the PR — which reads exactly like an ejection. The GraphQL state the watcher polls is the settled verdict; when the two disagree, re-query before reporting either. (Observed 2026-08-25: a merged PR was reported \"thrown out of the queue\" off the stale REST answer and the report had to be corrected minutes later.)\n\n## Two facts the loop depends on\n\n**`gh run rerun` reuses the original `GITHUB_SHA`.** For `pull_request` events that is the merge commit computed at first run — a rerun after a base-branch fix still tests against the broken base. Rerun is only for flakes; to pick up a repaired base, rebase the branch and push.\n\n**Review bots converge over multiple rounds.** A `copilot_code_review` rule is not a merge gate: Copilot leaves a `COMMENT` review, which per GitHub's docs does \"not count toward required approvals and will not block merging\". Nor does every push invalidate the standing review — re-review on push is the rule's `review_on_push` parameter, and when it is unset \"Copilot will only review the pull request once\". Read the rule's `parameters`, not just its presence: `gh api repos/$R/rules/branches/$BRANCH --jq '.[] | select(.type==\"copilot_code_review\")'`. Re-request when you want fresh feedback on a new head: `gh api repos/$R/pulls/$PR/requested_reviewers -X POST -f 'reviewers[]=copilot-pull-request-reviewer[bot]'`, then confirm the request registered off the timeline, not off `requested_reviewers` — a reviewer that has *started* drops off that list without having submitted (see `references/pull-request-workflow.md`, \"Review on an earlier head + `CLEAN`\"). Later rounds may flag UNCHANGED lines adjacent to the diff (latent legacy bugs) — triage each finding on its merits; expect 3–6 rounds on large refactor PRs, with finding severity decreasing per round. Re-arm the watcher after every push.\n\n**CodeRabbit reports its refusal as a passing commit status.** It writes a legacy commit\nstatus named `CodeRabbit`, and when it declined the description carries the reason while the\nstate stays green:\n\n```bash\ngh api \"repos/$R/commits/$SHA/status\" --jq '.statuses[] | \"\\(.context): \\(.state) — \\(.description)\"'\n# CodeRabbit: success — Review rate limited\n# CodeRabbit: success — Review completed\n```\n\n`success — Review rate limited` is a passed check that means *no review happened*, and it\ncounts toward the \"all checks green\" line in every summary, including this skill's own.\n`pr-status.sh` reports the same refusal from the bot's own summary comment, so the tool and\nthe status agree — but a human reading the checks tab sees only green. When the question is\nwhether the head was reviewed, read the description, not the state.\n\n**A bot review can be a failure notice, not a review — read the body, not the state.** `copilot-pull-request-reviewer` posts its quota and capacity failures as an ordinary `COMMENTED` review whose body is `Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.` Every state-based check reads that as a satisfied gate: `reviews` is non-empty, `reviewThreads` is `0`, inline `comments` is `0`, and `mergeStateStatus` is `CLEAN` — indistinguishable from a clean review that found nothing. Before treating a bot review as landed, read the body:\n\n```bash\ngh pr view $PR --repo $R --json reviews \\\n --jq '.reviews[] | select(.author.login|test(\"copilot\")) | .body'\n```\n\nTreat `unable to review` as **no review** and re-request; if the re-request returns the same notice the quota is still exhausted, and merging means merging unreviewed. Check the repo's recent merged PRs the same way before concluding that a bot review is the local norm — a quota outage can span every PR in a window, so \"the last three merged PRs also show COMMENTED\" is not evidence they were reviewed.\n\n**Your own thread replies are review rows too.** Replying to a review thread creates a `COMMENTED` review under *your* login, on the current head. A wait-for-review watcher that fires on \"any review with a `submitted_at`\" therefore announces a review after your third reply while the only bot review still sits on the previous head (observed 2026-09-01: three replies, three `COMMENTED` rows by the PR author, \"review arrived\" reported). Filter on the bot's login **and** the head SHA; the REST rows carry `user.login` and `commit_id`:\n\n```bash\nH=$(gh pr view $PR --repo $R --json headRefOid --jq .headRefOid)\ngh api \"repos/$R/pulls/$PR/reviews\" --paginate \\\n | jq -r --arg h \"$H\" '.[] | select(.user.login == \"copilot-pull-request-reviewer[bot]\" and .commit_id == $h)\n | \"\\(.state) \\(.submitted_at)\"'\n```\n\nOnce you know the quota is exhausted, mind *when* `--watch` returns: `pr-status.sh --watch` (and any read whose `NEXT` is `request-review`) fires on that review-state event **immediately, even while CI is still running** — the event is independent of check completion, so it returns before the gate can be `CLEAN`. Do not merge on that first return. After deciding to proceed unreviewed, re-arm with `--watch --ignore-action request-review` — it holds through the quota-dead review state and returns once the checks settle (`SETTLED: NEXT is still the ignored action`, exit 0) or something else becomes actionable — and merge only at `mergeState=CLEAN`; only `CLEAN` passes the merge gate — `BLOCKED` means checks or threads are outstanding and `UNSTABLE` means a non-required check is still pending (seen 2026-08-12: a docs PR cycled `BLOCKED → UNSTABLE → CLEAN` across three re-arms while the bot stayed quota-dead).\n\nA CodeRabbit review in flight holds both settled returns — `SETTLED` and the quota-dead `ACTIONABLE: request-review` — until it finishes, so arming the watch straight after `@coderabbitai review` waits for that review. Until the summary shows the new review, the only trace is CodeRabbit's reply reading `Review triggered.`, which it later edits to the outcome — and sometimes not even that: CodeRabbit may skip the reply and answer only by editing the summary minutes later. `pr-status.sh` therefore reads as `coderabbit=in-progress` both such a reply from the CodeRabbit bot and an `@coderabbitai review` that no CodeRabbit comment has been written or edited after, each only when newer than the head's commit, also over a block that already refused this head as rate limited. Before that, a watch armed seconds after the request returned `SETTLED` on its first poll and the review that followed went unwatched (netresearch/retro-skill#169).\n\n**On a docs/prose PR the loop does not decay — it must be actively terminated.** The bot re-reads the whole changed file each round and keeps surfacing a *new cosmetic* nit (wording, an illustrative example value, a spelling), so pushing a fix just triggers another round almost indefinitely. To converge: once a finding is purely cosmetic and defensible, **reply on the thread and resolve it *without* a new commit** — no push means no re-review means no new nit. Reserve fresh pushes for substantive findings; batch several real fixes into one push rather than one-per-thread.\n\n## A polling watcher must emit on the transition, not on the state\n\nThe skeleton above `exit`s when it is done, so it reports each outcome once. A\nwatcher that instead *streams* events — a `Monitor`-style loop whose stdout lines\nbecome notifications — has no such protection: an emit condition written as a\n**state** (`pending == 0 && failures == 0`) is true on every subsequent poll and\nrepublishes the same line every cycle until something stops it. Three identical\n\"checks complete\" notifications for one PR is the usual first symptom, and the\nnoise buries the event that actually changed.\n\nLatch the last emitted message and print only on change:\n\n```bash\nlast=\"\"\nwhile true; do\n s=$(gh pr view \"$PR\" --repo \"$R\" --json state,statusCheckRollup) || { sleep 60; continue; }\n [ \"$(jq -r .state \u003c\u003c\u003c\"$s\")\" != \"OPEN\" ] && { echo \"PR#$PR $(jq -r .state \u003c\u003c\u003c\"$s\")\"; break; }\n fail=$(jq -r '[.statusCheckRollup[]?|select(.conclusion==\"FAILURE\" or .conclusion==\"TIMED_OUT\")]|length' \u003c\u003c\u003c\"$s\")\n pend=$(jq -r '[.statusCheckRollup[]?|select(.status!=\"COMPLETED\")]|length' \u003c\u003c\u003c\"$s\")\n if [ \"$fail\" != 0 ]; then msg=\"PR#$PR RED\"\n elif [ \"$pend\" = 0 ]; then msg=\"PR#$PR checks complete, 0 failures\"\n else msg=\"\"; fi\n [ -n \"$msg\" ] && [ \"$msg\" != \"$last\" ] && { echo \"$msg\"; last=\"$msg\"; }\n sleep 60\ndone\n```\n\nWatching several PRs in one loop needs one latch **per PR** (`declare -A seen`),\nnot one shared variable — otherwise two PRs reaching the same state alternate and\neach re-emits.\n\nTwo more traps sit in the fan-out shape, both measured, and both produce a\n*confident* wrong answer rather than an error:\n\n**A per-PR output file keyed by `owner/repo` writes into a directory that does\nnot exist.** `out=\"$DIR/gate-$1-$2.txt\"` with `$1=netresearch/foo` expands to\n`$DIR/gate-netresearch/foo-12.txt`; the redirect fails, every watcher in the\nfan-out dies at once, and the only evidence is seven `No such file or directory`\nlines buried above the summary. Slugify the whole identity — **not** the\nbasename: `${1##*/}` drops the owner, so `alice/api` and `bob/api` both land on\n`gate-api-12.txt` and one silently overwrites the other, which is the same class\nof loss one level down.\n\n**Do not end the fan-out with an `echo` that asserts a state you have not\nread.** `for …; do … & done; wait; echo \"all gates settled\"` prints that line\nwhether the watchers ran, died on a bad redirect, or were never started —\nit is the loop's own claim about itself, not a measurement. Print the\nper-PR results, or a count of the result files actually produced:\n\n```bash\nPRS=(\"netresearch/foo 11\" \"netresearch/bar 12\")\ndir=$(mktemp -d)\nfor p in \"${PRS[@]}\"; do\n set -- $p\n slug=${1//[^A-Za-z0-9._-]/_} # netresearch/foo -> netresearch_foo\n pr-status.sh -R \"$1\" \"$2\" --watch > \"$dir/gate-$slug-$2.txt\" 2>&1 &\ndone\nwait\nls \"$dir\"/gate-*.txt | wc -l # what exists, not what was intended\n```\n\nBoth fired in one session: the fan-out over seven pull requests produced no\nfiles and reported that every gate had settled.\n\n### Running `pr-status.sh --watch` under a Monitor\n\n`pr-status.sh --watch` already exits on the first actionable event, so it is the\nbetter producer for a streaming watcher than a hand-rolled loop — but it prints a\n`waiting:` line on every poll, and those lines carry advisory prose. A filter that\nmatches on a bare `error` fires on text such as \"delete that file if it was\nrecorded in error\" and turns every poll into a notification. Drop the heartbeat\nfirst, then keep only the lines you act on:\n\n```bash\npr-status.sh -R OWNER/REPO \"$PR\" --watch 2>&1 \\\n | grep --line-buffered -vE '^waiting' \\\n | grep --line-buffered -E '^(ACTIONABLE|TIMEOUT|SETTLED|NEXT)|^\\s*(failing|checks)|pr-status:'\n```\n\n`ACTIONABLE`, `TIMEOUT`, `SETTLED` (the return of `--ignore-action` once checks\nsettle) and `pr-status:` (the tool's own failures, including the\n`pr-status: UNREADABLE —` line for a gate that cannot be queried at all, with\nthe cause on stderr) cover every terminal state, so silence still means\n\"waiting\", never \"crashed unseen\". Anything added to the producer has to carry\none of these prefixes or be added here — a state the filter drops is a state\nnobody sees.\n\nPrefer the Monitor tool over a backgrounded shell for these long waits. Two\nbackgrounded `pr-status.sh --watch` processes in one session were stopped by the\nharness with \"the system is running low on memory\" while CI queued for hours; the\nsame watch under Monitor ran to its event. A watch that was killed reports\nnothing, so after such a stop, read the gate once directly\n(`pr-status.sh -R OWNER/REPO \"$PR\"`) before arming the next one.\n\n### Key the verdict on `--json .next.action`, never on the `NEXT:` prose\n\nThe rule above is about noise; this one is about a wrong verdict, and it is the\nsame mechanism one step worse. A hand-rolled loop that polls `pr-status.sh` and\ndecides by matching the human `NEXT:` line matches the **explanation**, not the\nstate: the `why` text for `request-review` reads *\"no review on the current head\n… do not merge unreviewed\"*, so a `case \"$line\" in *merge*)` arm fires on a gate\nthat is shut and announces the opposite of the truth.\n\n```bash\n# ❌ matches the reason, not the state — \"do not merge unreviewed\" contains \"merge\"\nline=$(pr-status.sh -R \"$R\" \"$PR\" | grep '^NEXT:')\ncase \"$line\" in *merge*) echo \"GATE OPEN\";; esac\n\n# ✅ the state is a field\nact=$(pr-status.sh -R \"$R\" \"$PR\" --json | jq -r '.next.action')\n[ \"$act\" = \"merge\" ] && echo \"GATE OPEN\"\n```\n\nThe human line is for a human; `--json` is the machine contract, and\n`.next.action` is one token from a fixed vocabulary. Do not copy that vocabulary\ninto your loop — it grows with the script (18 values at 1.32.4, from `merge` and\n`ready` through `await-capacity` and `rules-unavailable`). Match the handful you\nact on by exact string, name the waiting values you hold through\n(`wait`, `await-review`, `await-checks`, `await-capacity`), retry\n`rules-unavailable` once since it reports a failed read rather than a state, and\nSTOP on every other value. \"Keep waiting\" as the\ndefault is the four-hour silence `pr-sweep.sh` was written against:\n`request-review` is not a value a loop over `merge` was told about, and nothing\nelse ever moves it. `pr-sweep.sh` is that rule as a script.\n\n### A watcher is armed on a head; stop it when the head moves\n\nA loop polling a pull request keeps polling after a force-push, and its next\nnotification describes a state that no longer exists — while the watcher you\narmed on the *new* head reports beside it. Two watchers on one PR disagreeing is\nindistinguishable from a flapping gate. Stop the old one when you rewrite the\nbranch (`TaskStop`, or whatever ends the producer), and name the head in the\nwatcher's description so a stale event is recognisable as stale.\n\n## Check the producer is switched on before arming the watcher\n\nA watch whose event can never be produced is indistinguishable from one whose\nevent has not arrived yet: both are silence. Before waiting on a pipeline,\nconfirm the host will create one at all — a project can have CI switched off\nentirely, and then no push, force-push or retarget produces anything to watch.\n\nA `403` confined to one endpoint family while everything else answers `200` with\nthe same token is *consistent with* that feature being switched off, and equally\nwith the token lacking the scope for it. Rule the rate limits out by their body\nfirst — `API rate limit exceeded` or `You have exceeded a secondary rate limit`,\nper \"Watcher cost\" below, which is also a 403 and is *not* always global. Then\nread the flags, which separate the remaining two:\n\n```bash\n# Capture, then parse: `glab … | jq …` exits with jq's status, and jq on empty\n# input exits 0, so a || fallback on the pipeline can never fire.\nout=$(glab api \"projects/:id\") \\\n || { echo \"probe refused — that is the access case, not the feature case\"; }\nprintf '%s' \"$out\" | jq '{jobs_enabled, builds_access_level}'\n# {\"jobs_enabled\": false, \"builds_access_level\": \"disabled\"} -> nothing will run\n```\n\n`projects/:id` resolves from the current clone's remote, so run it inside one.\n\nObserved cost: two watchers armed across ~40 minutes for a merge request whose\nproject had `builds_access_level: disabled`, reported to the user as \"no\npipeline yet\" when the correct answer was \"no pipeline, ever, until someone\nre-enables CI\". Give a wait a stop condition it can actually reach, and when a\nwatch stays silent past the expected window, re-check the producer rather than\nextending the timeout.\n\n### Armed in the same breath as the push: \"no checks reported\", exit 0\n\nThe producer can be switched on and the runs still not exist yet. `gh pr checks\n\u003cn> --watch` started seconds after a push prints\n\n```\nno checks reported on the 'task/whatever' branch\n```\n\nand **exits 0** — GitHub had not registered the workflow runs for the new head\nwhen the first poll landed. Exit 0 plus no failures reads exactly like \"all\ngreen\", which is the dangerous part: nothing in that output says the watcher\nnever watched anything.\n\nTwo consequences for the loop:\n\n- Read the watcher's output, not only its exit code. A run that ends on the\n \"no checks reported\" line watched nothing and has to be re-armed.\n- Give the runs a moment to appear, or arm the watcher on a read that has\n already seen at least one check. A plain `gh pr checks \u003cn>` right after the\n push shows whether anything is `pending` yet; arm `--watch` once it does.\n\nSeen 2026-09-18 on a rebase-and-force-push: the watcher returned immediately,\n`gh pr checks` a moment later listed seven checks all `pending`, and the\nre-armed watcher then reported them green.\n\n## Auto-merge armed + CLEAN but never enqueued: disable/re-enable to nudge\n\nOn a merge-queue repo a PR can sit `CLEAN` with auto-merge **armed** and every required check green, yet never gets a `mergeQueueEntry` — it silently fails to enter the queue, so the watcher just times out. Confirm the symptom, then re-arm to force GitHub to re-evaluate enqueue-readiness:\n\n```bash\ngh pr view $PR --repo $R --json mergeStateStatus,autoMergeRequest \\\n --jq '{merge:.mergeStateStatus, autoMerge:(.autoMergeRequest!=null)}' # CLEAN + true\ngh api graphql -F o=\"${R%/*}\" -F r=\"${R#*/}\" -F p=$PR -f query='query($o:String!,$r:String!,$p:Int!){repository(owner:$o,name:$r){pullRequest(number:$p){mergeQueueEntry{state}}}}' \\\n --jq '.data.repository.pullRequest.mergeQueueEntry // \"not queued\"' # \"not queued\" = stalled\n\ngh pr merge $PR --repo $R --disable-auto # then re-arm\ngh pr merge $PR --repo $R --auto # → now enters the queue (QUEUED)\n```\n\nThis is distinct from a PR that entered the queue and was then **dequeued/cancelled** (that one *was* `QUEUED` and dropped — usually a transient queue check failure; re-arm `--auto` there too). Both recover by re-arming; neither is fixed by `--admin`. Renovate/Dependabot PRs arm auto-merge via the deps workflow — a rebase onto current base (they lag) plus this nudge is the non-hand-merge way to complete them.\n\n## Post-merge: confirm merge-triggered jobs by commit SHA, not by run list\n\nAfter merge, the base branch (`main`) fires its own runs (CI, release, deploy). To confirm those, query the **commit's** checks keyed on the merge SHA — never filter `gh run list` by `headSha`:\n\n```bash\nSHA=$(gh pr view $PR --repo $R --json mergeCommit --jq '.mergeCommit?.oid')\ngh api repos/$R/commits/$SHA/check-runs --jq '.check_runs[]?|{name,status,conclusion}'\ngh api repos/$R/commits/$SHA/status --jq '{state, total:(.statuses|length)}' # legacy commit statuses (Sonar/codecov)\n```\n\n`gh run list --json … --jq 'select(.headSha==\"'$SHA'\")'` is unreliable here: the list window is small and time-ordered, so a still-running `main` job scrolls out behind unrelated activity and the filter returns empty — which then feeds a `gh run view \"\"` (HTTP 404) and tempts a hand-rolled `sleep`-poll loop that just times out. The check-runs/status API is authoritative and SHA-addressed. For PR-head checks, `gh pr checks $PR --watch` already blocks to completion — prefer it over any custom loop.\n\n**Pre-existing red ≠ your regression.** If a post-merge gate (e.g. SonarCloud \"Quality Gate failed\" on N Security Hotspots) is red, check the *prior* base commit before owning it: `gh api repos/$R/commits/\u003cprev-sha>/check-runs --jq '.check_runs[]?|select(.name==\"\u003cgate>\")|.conclusion'`. Identical red on the parent + a diff that touched no relevant code = a pre-existing backlog to report, not a regression to fix.\n\n### A check run is named after the JOB, not the workflow\n\nLooking for a workflow by its own name in `commits/$SHA/check-runs` finds nothing, and the natural conclusion — \"that run does not exist for this commit\" — is wrong. The entry is there under the **job** name.\n\nMeasured on `netresearch/typo3-demo`: workflow run 32536686040 is `Deploy (Update)` (`event=workflow_run`, `check_suite_id=88200944968`); its check run 96938832284 is named `deploy`. Same check suite, and the check run's `html_url` points back at `/actions/runs/32536686040/job/96938832284`. Reproduced 4/4 there and 2/2 on `netresearch/ofelia`, where `Verify Release` appears as `Verify / Verify Release`.\n\nSo match on the check suite or on the job names you expect, not on the workflow name — and never infer absence from a name miss.\n\n### When you want run-level state, filter server-side with `head_sha=`\n\nThe workflow run's own `status`/`conclusion` (as opposed to its jobs') lives on the runs endpoint. Do **not** reach back for a windowed list and filter it yourself — `actions/runs` takes the SHA as a query parameter and filters on the server:\n\n```bash\n[ -n \"$SHA\" ] || { echo \"no SHA — refusing to watch\"; exit 1; }\ngh api \"repos/$R/actions/runs?head_sha=$SHA&per_page=50\" \\\n --jq '.workflow_runs[] | \"\\(.name)=\\(.status)/\\(.conclusion // \"-\")\"'\n```\n\nThe guard on the first line is not decoration. An **empty** `$SHA` drops the filter silently and the API answers with the unfiltered list — 4506 runs on one repository measured this way, against 0 for a syntactically valid unknown SHA and 0 for outright garbage. A watcher whose SHA lookup failed then extracts some unrelated run's state and reports it as the watched commit's; the extraction is not empty, so the value guard below cannot catch it.\n\nA **shortened** SHA fails the other way. `head_sha=` resolves only the full 40-character SHA, so an abbreviated one (`5ddd65df8`) is just another unknown value: every tick returns an empty list, and a loop that reads \"no runs\" as \"still running\" spins through its whole budget in silence. Take the SHA from `git rev-parse` or `headRefOid`, never from a short form typed for readability.\n\n`repos/$R/actions/runs?per_page=20` piped into a client-side `select(.head_sha==$s)` has the same window defect as `gh run list`, with one difference that makes it worse in a loop: **it works on the first tick.** The commit is recent, so it sits inside the window; twenty minutes later unrelated runs have pushed it out and every subsequent tick sees nothing. A watcher built that way reports progress, then goes quiet, and quiet is indistinguishable from \"still running\" — it will sit out its full budget without ever emitting. (Observed 2026-08-21; the first tick listed five runs, later ticks listed none.)\n\n### The emptiness guard must test the extracted value, not the response\n\nThe natural guard is on the API call:\n\n```bash\njson=\"$(gh api \"…\" 2>/dev/null || echo '')\"\nif [ -z \"$json\" ]; then sleep 45; continue; fi # never fires\n```\n\nThat checks the wrong thing. When the window scrolls past the commit the response is a perfectly valid `{\"workflow_runs\": []}` — non-empty, well-formed, and about a different set of runs. Guard on the value you actually extracted, and make a persistent extraction failure say so, because a watcher that has gone blind must not look like a watcher that is waiting:\n\n```bash\nstate=\"$(printf '%s' \"$json\" | jq -r '…')\"\nif [ -z \"$state\" ]; then\n errors=$((errors + 1))\n [ \"$errors\" = \"5\" ] && echo \"query has returned nothing for 5 rounds — this watch is blind\"\n sleep 45; continue\nfi\nerrors=0\n```\n\nA fallback can corrupt the **value** a correct guard then accepts. `curl -s -w '%{http_code}'` already prints `000` on a connection failure, so `code=$(curl -s -o /dev/null -w '%{http_code}' \"$URL\" || echo 000)` yields `000000` on that path; a guard written as the negation of failure (`[ \"$code\" != \"000\" ]`) passes it, and the watcher reports a terminal state on its first tick (observed 2026-09-07: `TERMINAL: 000000`, a false \"the site is up\" before its certificate existed). A tool that already emits a sentinel on failure needs no `|| echo` fallback, and the failure case must be matched **positively and first**. Bound the request as well: without `--max-time` a stalled connection never reaches the check (a timed-out request also prints `000`):\n\n```bash\ncode=$(curl -s --connect-timeout 10 --max-time 30 -o /dev/null -w '%{http_code}' \"$URL\")\ncase \"$code\" in\n 000|\"\") echo \"unreadable: '$code'\"; sleep 30; continue ;;\nesac\n```\n\n### \"Every run has finished\" is true before any run exists — and a second query cannot repair it\n\nA tag push or a merge does not create the workflow runs immediately; for a second or two `actions/runs?head_sha=…` answers `{\"workflow_runs\": []}` for a SHA that will carry several. A loop whose condition is \"no run has a status other than `completed`\" is therefore **vacuously true on its first tick**, exits at once, and reports a settled state that never happened. The guard in the previous section does not catch this one: the extraction succeeds, it just extracts a count of zero from an honest empty list.\n\nThe reflex repair is a second condition — \"and the list is not empty\". Put it in a **second API call** and the two conditions stop describing the same moment:\n\n```bash\n# WRONG — two calls, two different instants\nuntil [ \"$(gh api \"…\" --jq '[.workflow_runs[]|select(.status!=\"completed\")]|length')\" = 0 ] \\\n && [ \"$(gh api \"…\" --jq '.workflow_runs|length')\" != 0 ]; do sleep 60; done\n```\n\nThe runs appear *between* the calls: the first sees none unfinished because it sees none at all, the second sees them and reports non-empty, and the loop exits on its first pass. Observed 2026-09-22 on `netresearch/ldap-manager` v1.7.0 — the loop returned instantly and printed `Release: null` and `CI: null`, which are a `queued` and an `in_progress` run.\n\nBoth facts have to come out of **one** response:\n\n```bash\ngh api \"repos/$R/actions/runs?head_sha=$SHA&per_page=100\" \\\n --jq '[.workflow_runs[]] as $r | ($r|length) > 0 and ([$r[]|select(.status!=\"completed\")]|length) == 0'\n```\n\nEven that only answers for the runs that exist *now*, so a watcher waiting on two workflows can still settle while the second has not been created. Where you know which workflow matters, resolve its run once and let `gh` block on it — the id fixes the subject, and the exit code carries the conclusion:\n\n```bash\nRID=$(gh api \"repos/$R/actions/runs?head_sha=$SHA&per_page=100\" \\\n --jq '[.workflow_runs[]|select(.name==\"Release\")][0].id')\n[ -n \"$RID\" ] && [ \"$RID\" != \"null\" ] || { echo \"no Release run for $SHA yet\"; exit 1; }\ngh run watch \"$RID\" --repo \"$R\" --exit-status\n```\n\n### A file read right after a merge can still return the pre-merge content\n\n`contents/\u003cpath>?ref=\u003cbranch>` has been observed answering with pre-merge content immediately after a merge. A post-merge verification that reads the branch ref can therefore report the merged change as **absent** — an invented regression, produced by measuring too early rather than by anything being wrong. The mechanism is not established here (no attempt was made to reproduce it against a controlled merge); what is established is that the branch ref answered stale and the merge commit answered correctly. Address the merge commit, the same way the rest of this section does:\n\n```bash\nMC=$(gh api \"repos/$R/pulls/$PR\" --jq '.merge_commit_sha')\ngh api \"repos/$R/contents/\u003cpath>?ref=$MC\" --jq '.content' | base64 -d | grep -q '\u003cmarker>'\n```\n\nObserved 2026-08-21: a watcher fired \"the trigger is NOT on main\" the moment the PR merged; a direct read at the merge SHA a minute later showed it present, at the expected lines.\n\n## Delete the branch/worktree only after the merge is CONFIRMED, never on watcher exit\n\nA merge-gate watcher loop can exit for reasons that are **not** \"merged\": the\nPR went `BLOCKED`, an auto-merge was cancelled, or the loop's own condition\ntripped on unresolved review threads. Deleting the local branch (or removing the\nworktree) the moment the watcher returns — before reading the PR's actual\nstate — throws away work that is not yet on `main`.\n\nGate the cleanup on the merge itself, not on the loop returning:\n\n```bash\nSTATE=$(gh pr view $PR --repo $R --json state --jq .state)\n[ \"$STATE\" = \"MERGED\" ] || { echo \"not merged ($STATE) — keep the branch\"; exit 0; }\ngit -C .bare worktree remove \u003cdir>\ngit -C .bare branch -D \u003cbranch>\n```\n\nIf the branch was already deleted prematurely, it is usually recoverable from\nthe remote (`git fetch origin` then re-add the worktree tracking\n`origin/\u003cbranch>`) — but only while the remote ref still exists (a merged PR's\nbranch is often auto-deleted). The discipline is cheaper than the recovery:\n**confirm `state == MERGED` before any destructive cleanup.**\n\n## A queued PR can silently leave the merge queue\n\nA PR queued via `gh pr merge --auto` on a merge-queue repo can drop back out with no visible event: `isInMergeQueue` flips to `false`, `mergeStateStatus` reads `CLEAN`, and nothing merges. Verify the real queue state via GraphQL (`state` / `merged` / `isInMergeQueue` / `mergeStateStatus`) — a status read that only looks at `mergeStateStatus` reports a dropped PR as merge-ready. Confirm the drop on the PR timeline first — the latest queue event is `removed_from_merge_queue`, with no `added_to_merge_queue` or `merged` after it (query in `pull-request-workflow.md`, \"Verify a 'dropped' queue entry via the issue timeline before re-arming\") — because re-queuing a PR whose queue run is still in flight cancels that run (see below). Then re-arm once (`gh pr merge --disable-auto`, then `--auto`, which forces the queue to re-evaluate); if it drops again, diagnose the queue's required contexts instead of re-arming repeatedly.\n\n### The dequeue reason is on the `gh-readonly-queue` branch, never on the PR\n\nThe queue runs the required checks on its own branch, `gh-readonly-queue/\u003cbase>/pr-\u003cn>-\u003csha>`, and a failure there dequeues the entry **silently**: no bot comment, no failed check on the PR, `mergeStateStatus` unchanged. Every PR-scoped query therefore answers \"ready and waiting\" for a PR that was already thrown out. The runs on that branch are the only record:\n\n```bash\nR=owner/repo; PR=123\n# --jq is gh's built-in filter and takes no --arg; pipe to real jq when you need one.\ngh api \"repos/$R/actions/runs?per_page=40\" \\\n | jq -r --arg p \"gh-readonly-queue/main/pr-$PR-\" '\n .workflow_runs[] | select(.head_branch | startswith($p))\n | \"\\(.created_at) \\(.name) \\(.status)/\\(.conclusion)\"' | sort -r\n```\n\nThen open the failing run's jobs and steps:\n\n```bash\nRID=\u003cid from above>\ngh api \"repos/$R/actions/runs/$RID/jobs\" --jq '.jobs[] | select(.conclusion==\"failure\") | .name'\ngh api \"repos/$R/actions/jobs/\u003cjob-id>/logs\" # the step output, for the actual cause\n```\n\nThree consequences for the diagnosis:\n\n- **Time-box the branch filter.** A re-queued PR produces a *second* run set on a branch whose name shares the `pr-\u003cn>-` prefix. A filter matching only the prefix returns the old failed run alongside the new one and reads as a fresh failure. Bind the re-queue time in the `jq` call — `--arg since \"$REQUEUE_TIME\"`, ISO 8601 such as `2026-09-22T22:20:00Z`, the format `created_at` uses, so the string comparison orders correctly — and add `select(.created_at > $since)` to the filter.\n- **A dequeue is not evidence of a defect in the PR.** Observed 2026-08-09 (netresearch/t3x-nr-llm#686): five of six workflows green, `Checks` red on one job — `composer audit` exited 100 because `https://packagist.org/api/security-advisories/` answered HTTP 502. The identical workflow had passed on the previous queue branch 30 minutes earlier. Read the step log before concluding anything about the branch; a network-dependent step in a required check turns any upstream outage into a dequeue.\n- **A red job can be a race with the queue's own cleanup.** Observed 2026-09-22 (netresearch/t3x-nr-llm#957): the required `All security checks` job failed because its SARIF upload targeted the queue branch after the queue had already deleted it — `ref 'refs/heads/gh-readonly-queue/main/pr-\u003cn>-\u003csha>' not found` — while every Opengrep finding was suppressed and 0 were uploaded. That is not a finding. Re-queue.\n\n### A check run on the queue commit's SHA did not necessarily run in the queue\n\nA check run on the SHA of a merge-queue commit is not evidence that it ran on the queue ref. The queue fast-forwards the base branch to the queue commit, so the same SHA later also carries the base branch's `push` runs, and `commits/\u003csha>/check-runs` lists both kinds side by side. Attribute a check run by its check suite's branch: a queue suite has `head_branch` `gh-readonly-queue/\u003cbase>/pr-\u003cn>-…`, a post-merge suite has the base branch. For Actions runs, `actions/runs?event=merge_group` lists the queue's runs directly. Measured 2026-10-21 on netresearch/typo3-demo: the `Analyze (actions)` and `Analyze (python)` check runs on queue commit `09c4429` belonged to suite 107341419296 on branch `main`, created 13:38 after the merge, while the queue's own suites (dco, coderabbit, sonarqubecloud, github-actions) were on `gh-readonly-queue/main/pr-326-…`, created 12:51; 0 of the latest 100 `merge_group` runs there were CodeQL. Reading by SHA alone had led to the wrong conclusion that CodeQL default setup reports in the queue.\n\n```bash\ngh api \"repos/$R/commits/$SHA/check-suites?per_page=100\" --paginate \\\n --jq '.check_suites[] | {app: .app.slug, branch: .head_branch, created: .created_at}'\ngh api \"repos/$R/actions/runs?event=merge_group&per_page=100\" \\\n --jq '.workflow_runs[] | \"\\(.head_branch) \\(.name)\"'\n```\n\n### A queue entry also drops when its jobs never start\n\nThe queue has its own timeout, and it is not on the PR or in the workflow: it is the ruleset's `merge_queue` parameter `check_response_timeout_minutes`.\n\n```bash\ngh api \"repos/$R/rulesets/\u003cid>\" --jq '.rules[] | select(.type==\"merge_queue\") | .parameters'\n# {\"check_response_timeout_minutes\":60, …}\n```\n\nWhen the queue run's jobs are still `queued` — not `in_progress` — once that limit passes, the entry is dropped with **no failed check** anywhere. That is runner starvation, not a defect in the PR. Tell it apart on the queue run itself:\n\n```bash\ngh api \"repos/$R/actions/runs/$RID/jobs\" --jq '.jobs[] | \"\\(.name) \\(.status)\"' # every job `queued`\ngh api \"repos/$R/actions/runs?status=in_progress\" --jq '.total_count' # 0\n```\n\nObserved 2026-09-22 on netresearch/t3x-nr-llm#957: from 22:20Z the CI run's 39 jobs stayed `queued` for over an hour, while short workflows on the same queue branch finished and other repositories in the organisation also had only queued runs. githubstatus.com reported Actions operational, and billing was not the cause.\n\nRe-enqueueing after such a drop is not reliable. On the same PR GitHub re-enqueued it once on its own (auto-merge was still armed) and once did not — although that run later finished green, after the timeout had already dropped the entry. Once runners move again, re-queue by hand: `gh pr merge \u003cn>`, or `pr-merge.sh`.\n\n### Re-queuing cancels the run already in flight\n\nRe-adding a PR to the merge queue **cancels the queue run in progress**, so a retry loop prevents the very merge it is meant to cause. Observed 2026-08-06 on netresearch/t3x-nr-llm#616: a loop tried to re-queue the PR about every two minutes, 128 attempts, and the timeline records six `added_to_merge_queue` events. The first queue run completed the required E2E workflow green; in the next two the E2E run shows `cancelled` — once two seconds before the matching `removed_from_merge_queue` — and the PR merged on the fourth queue run, which was left to finish.\n\nThe loop's trigger was a false ejection: the GraphQL `mergeQueue.entries` list reads **transiently empty** for an entry that is still queued. Requiring two consecutive empty reads did not fix it; the signal is wrong, not noisy. Let one queue attempt run to completion, and re-queue only when the latest queue event on the timeline is `removed_from_merge_queue`, with no `added_to_merge_queue` or `merged` after it, never because the entry is missing from the queue list. When a required check on the queue branch never reports, diff required against reported contexts, and check whether something you are doing is cancelling the run.\n\n### Watching a queued PR: ask git\n\nWhat the PR-scoped tools do once the PR is queued:\n\n- `pr-status.sh --watch` keeps polling. `wait` is not an actionable NEXT, so every interval (default 20 s) it prints `waiting: already in the merge queue at position …` — a heartbeat, not a hang. It returns when the entry leaves the queue: `ACTIONABLE: none` with `PR is MERGED` after the merge, the next actionable step (for example `merge`) after a silent drop, or `TIMEOUT` after `--max-wait` (default 3600 s). Each poll is a GraphQL read, so a long queue wait spends GraphQL budget (see [Watcher cost](#watcher-cost-graphql-and-rest-rate-limits-are-separate-budgets)).\n- `gh pr view --json isInMergeQueue` fails with `Unknown JSON field`. The field exists only in GraphQL, as `repository.pullRequest.isInMergeQueue`.\n\nThe git ancestry check in [\"Has it merged yet?\" costs nothing — ask git, not the API](#has-it-merged-yet-costs-nothing--ask-git-not-the-api) answers \"has it merged\" without any API budget.\n\n## Watcher cost: GraphQL and REST rate limits are separate budgets\n\n`gh pr view --json statusCheckRollup` is a GraphQL query and an expensive one. Two watchers polling it every 60 s exhausted the **GraphQL** budget (29 of 5000 left) while the REST **core** budget still showed 4614 of 5000 — and once that happened, plain REST calls also began returning `403 API rate limit exceeded`. That combination (one resource drained, the other healthy, both refused) is the **secondary** limit reacting to request density, not the quota. Its 403 body says `API rate limit exceeded` or `You have exceeded a secondary rate limit`, which is what tells it apart from an authorization 403 — read the body, not just the status. Read the resources separately rather than trusting a single number:\n\n```bash\ngh api rate_limit --jq '.resources | to_entries[] | \"\\(.key): \\(.value.remaining)/\\(.value.limit)\"'\n```\n\nThree rules follow, and they cost nothing:\n\n- **One watcher per subject.** Two loops on the same PR double the spend and tell you the same thing.\n- **Poll REST, not GraphQL, for liveness.** `gh api repos/$R/pulls/$PR` and `gh api repos/$R/commits/$SHA/check-runs` answer state and checks from the cheaper budget.\n- **180 s, not 60 s.** A merge queue does not resolve in a minute; the faster interval buys nothing and is what drains the budget.\n\nRecovery is waiting: `gh api rate_limit --jq '.resources.graphql.reset'` is an epoch timestamp — sleep to it in **one** background command rather than retrying into the limit. `pr-status.sh --watch` does this itself: when a gate read fails on a rate limit it prints one `pr-status: UNREADABLE — GitHub rate limit …` line and waits once instead of retrying every interval: to that reset when the GraphQL budget is spent (`remaining` 0), and a bounded 60 s when budget is left (a secondary limit, which the counter does not show) or the lookup fails — never past `--max-wait`.\n\n### What still works while GraphQL is drained\n\n`pr-status.sh` is GraphQL end to end, so it exits `GraphQL query failed` and the\nwhole finish flow appears blocked. Most of it is not. `rate_limit` can even\nreport `5000/5000` for both resources while GraphQL refuses — the counter is not\nwhere a secondary limit shows up, so probe it directly instead of believing the\nnumber, and classify the failure rather than assuming the worst: a 401, a DNS\nerror and a throttle all make `gh api` exit non-zero, and only one of them is\nworth waiting an hour for.\n\n```bash\nerr=$(gh api graphql -f query='{viewer{login}}' 2>&1 >/dev/null) && echo up \\\n || case \"$err\" in\n *RATE_LIMIT*|*\"rate limit\"*|*\"secondary rate\"*) echo throttled ;;\n *) echo \"graphql down, not throttled: $err\" ;;\n esac\n```\n\nAnswerable from REST, which is usually still healthy:\n\n| Question | REST call |\n|---|---|\n| head SHA, base, draft state | `gh api repos/$R/pulls/$PR` |\n| check runs on the head | `gh api \"repos/$R/commits/$SHA/check-runs?per_page=100\" --paginate` |\n| legacy commit statuses | `gh api \"repos/$R/commits/$SHA/status?per_page=100\" --paginate` |\n| reviews submitted | `gh api \"repos/$R/pulls/$PR/reviews?per_page=100\" --paginate` |\n| reviewers still requested | `gh api repos/$R/pulls/$PR/requested_reviewers` |\n| effective rulesets on the base | `gh api \"repos/$R/rules/branches/$ENC_BASE?per_page=100\" --paginate` |\n| merge (direct only) | `gh api repos/$R/pulls/$PR/merge -X PUT -f sha=\"$SHA\" -f merge_method=merge` |\n\nFour details the table hides.\n\n**Checks are two endpoints, not one.** `check-runs` does not carry legacy commit\nstatuses, so a repository whose required contexts are classic statuses looks\ngreen with none of them read. Query both.\n\n**Every one of these paginates.** Default page size is 30 — `reviews`,\n`check-runs` and the rulesets array all truncate silently, which is the same\ntrap this file warns about elsewhere.\n\n**The rulesets path takes the branch as a path segment**, so a base like\n`release/2.1` must be URI-encoded first (`pr-status.sh` encodes it for exactly\nthis reason):\n\n```bash\nENC_BASE=$(printf %s \"$BASE\" | jq -sRr @uri)\n```\n\n**Pin the head when merging through REST.** Without `-f sha=\"$SHA\"` the endpoint\nmerges whatever the head is at the moment it runs, so a push that lands between\nthe gate check and the call is merged unreviewed. This row is also\ndirect-merge-only: the endpoint has no merge-queue mode, so a queue repository\nneeds its own flow rather than this fallback.\n\nThe one thing with **no** REST equivalent is converting a draft to ready\n(`markPullRequestReadyForReview`); review-thread resolution\n(`resolveReviewThread`) is GraphQL-only too. Requesting a review is not on that\nlist — `POST /pulls/$PR/requested_reviewers` works on a draft, and Copilot does\nreview drafts, so a bot-review ruleset is not automatically a hard stop while\nthe budget is out. Confirm the request off the **timeline**, not off\n`requested_reviewers`: the response body does not echo a bot reviewer, and a\nreviewer that has started drops off that list without having submitted.\n\n### \"Has it merged yet?\" costs nothing — ask git, not the API\n\nThe three rules above make a watcher cheaper. This one makes the commonest watcher free. Once a PR is queued the only question left is whether its head landed on the base, and git answers that with no API budget at all:\n\n```bash\ngit fetch origin main --quiet\ngit merge-base --is-ancestor \"$HEAD_SHA\" origin/main && echo MERGED\n```\n\n`$HEAD_SHA` is the PR head you pushed, which you already know. This keeps working while both budgets are exhausted, which is exactly when a watcher is most likely to be running. Reserve `pr-status.sh` for the merge *gate* — checks, threads, reviews, the `NEXT` line — and use git for the merge *fact*.\n\n**Ancestry answers only where the merge preserves the commit.** `--merge` and `--rebase` do; **squash does not** — it writes one new commit with a new hash, so the original head is never an ancestor and the check reads \"not merged\" forever. In a squash-merge repo, accept one cheap REST call instead: `gh api repos/$R/pulls/$PR --jq .merged`. Know which strategy the repo allows before relying on ancestry — `pr-status.sh` prints it as `merge: methods=[…]`.\n\n**`git log --grep=\"#\u003cpr>\"` is not that test.** It is the tempting one-liner and it produces false positives: `--grep` searches the whole commit *message*, and a dependency bump carries its upstream changelog in the body — including that upstream's issue numbers, from a different repository. Observed 2026-08-13: a watcher on PR #765 reported `MERGED` on its first tick, seven months after the commit it matched. That commit was `chore(deps): bump actions/attest-build-provenance` from January, whose embedded changelog links `actions/attest-build-provenance` issue #765. The PR being watched was in fact `CONFLICTING` and needed a rebuild.\n\nAncestry is a fact about the graph; `--grep` is a text search over prose that nobody wrote for you to parse.\n\nThe same asymmetry as the empty-result rule, inverted: an empty result is first a broken query, and a *positive* result from a text search is first a coincidence.\n\n### When GraphQL is exhausted, REST still opens the PR\n\n`gh pr create` and `gh pr view` are GraphQL; the two budgets drain independently, so `graphql: 0/5000` with `core: 4700/5000` leaves the whole `gh pr *` surface dead while REST is untouched. The REST endpoint takes the same arguments:\n\n```bash\ngh api repos/$OWNER/$REPO/pulls -X POST \\\n -f title=\"…\" -f head=\"\u003cbranch>\" -f base=main -F [email protected] --jq '.html_url'\n```\n\n`gh api repos/$OWNER/$REPO/issues/$PR/comments -X POST -F body=@file` posts a PR comment the same way. Both worked on 2026-08-13 while `gh pr create` returned `GraphQL: API rate limit already exceeded`.\n\nOne flag trap while you are there: `gh api --paginate --slurp` is **rejected** together with `--jq` (`the --slurp option is not supported with --jq or --template`). Write the paginated JSON to a file first, then run `jq` over it.\n\n### `gh api` writes its error to stdout — test a field, never emptiness\n\nOn a 404 (or any error) `gh api` prints a JSON error object to **stdout** and exits non-zero. A watcher that decides on \"did I get output?\" reads the error as the answer:\n\n```bash\nrel=$(gh api repos/$R/releases/tags/$TAG --jq '.tag_name' 2>/dev/null)\n[ -n \"$rel\" ] && echo \"release exists\" # WRONG — fires on the 404 body\ncase \"$rel\" in \"$TAG\") echo \"release exists\";; esac # right — tests the value\n```\n\nObserved 2026-08-09: a release watcher announced \"release published\" while the API was still answering 404 and the workflow was mid-run. Match the value you expect, or add `-q` handling that distinguishes exit status from output.\n\n### Pass `--workflow` a file name — a display name resolves only while it is unique\n\n`--workflow` accepts a name, an id or a file name, so `gh run list --workflow CI`\nis valid — right up to the moment the selector matches more than one workflow.\nThen `gh` refuses with\n\n```\ncould not resolve to a unique workflow; found: codeql.yml codeql\n```\n\nand prints it on **stderr with a zero-length stdout**. Inside an `until` loop\nthat compares a captured count, the empty string is not the terminating value,\nso the loop never exits — it spins on the error for as long as it is left\nrunning, and from the outside that is indistinguishable from \"the run has not\nfinished yet\". Observed on `netresearch/.github`, where a watcher polled a\nresolution error for hours while the run it was waiting for had long since\ncompleted.\n\nPrefer the file name — `--workflow codeql.yml`, `--workflow ci.yml` — which is\nunique by construction and cannot start colliding later when somebody adds a\nworkflow. And when a loop's condition is built from a command that can fail,\nmake the failure terminate it rather than feed it: check the exit status, or\n`|| break`.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":61100,"content_sha256":"864ffc7c62652fca024c5dec164772f04dceba5c08cc60c545c808f966d5ee4b"},{"filename":"references/no-editorializing.md","content":"\u003c!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->\n\u003c!-- SPDX-FileCopyrightText: Netresearch DTT GmbH -->\n\n# No editorializing — inform, don't sell (tone, not wordlist)\n\nApplies to every written artifact: commit messages, PR/MR descriptions, review\ncomments, issue/ticket text, chat — and code comments, docstrings,\ndocumentation, README and changelog files.\n\nEditorializing is a matter of **tone and intent, not specific words** — no\nbanned-word list catches it, and the same word can be fine or not depending on\nwhether it carries a fact. The failure is writing about *how good, clean, or\ncareful the work is* instead of *what it does*. The reader has the diff and the\nartifact; anything that only flatters the work or reassures them adds nothing,\nand to a reviewer it reads as salesmanship — it provokes a counter-reaction\nbefore they reach the substance.\n\nApply three tests before a sentence stays:\n\n1. **Deletion** — remove the phrase. Did the reader lose a fact? If not, cut it.\n2. **Subject** — is the sentence about the change, or about *you / your work*\n (its quality, your diligence)? The latter goes.\n3. **Voice** — would a terse maintainer write this, or does it read like a\n cover letter?\n\nTwo recurring failure modes:\n\n- **Announcing the expected.** Passing tests, clean linters, \"documented\", \"no\n regressions\", \"works as expected\" are the baseline — do not narrate them.\n State a check's status only to flag an *exception* (something knowingly\n failing or skipped). In a test/verification list, say what was *added or\n covered*, not that it is green.\n\n- **Self-praise and reassurance.** Grading your own output (\"clean\", \"robust\",\n \"elegant\", \"foolproof\", \"tidy\", \"genuinely new\", \"production-ready\"); framings\n that reassure (\"the honest breaking change\", \"deliberately scoped, not\n hidden\", \"where it belongs\"); and the diligence humble-brag (\"I carefully…\",\n \"I made sure to…\", \"thoroughly tested\"). These describe the author, not the\n change. Show the fact; drop the framing. (The words are only symptoms — judge\n by the three tests above, not by the word.)\n\nUse plain labels, not graded ones: \"Breaking change\", \"Tests\", \"Limitations\" —\nnot \"Tests (all green)\" or \"Breaking change (honest)\". If a limitation's cause\nmatters, it is already stated in the item.\n\n## Line wrapping — GitHub comment surfaces vs `.md` files\n\nSeparate from tone: the artifacts above render through two different Markdown\npipelines, and hard-wrapping prose is right in one and wrong in the other.\n\n- **`.md` files** (README, CHANGELOG, docs) render as CommonMark: a single\n newline inside a paragraph collapses to a **space**, so hard-wrapping the\n source at ~80 columns reflows invisibly on render. Match the file's existing\n wrap.\n- **GitHub comment surfaces** — PR/MR descriptions, review comments, issue/ticket\n bodies, and **release notes** — render with `breaks: true`: every single\n newline becomes a `\u003cbr>`. Hard-wrapping there carries the breaks into the\n rendered page as ragged mid-sentence line breaks. Write each paragraph and list\n item as ONE long line; use blank lines only for real paragraph/item boundaries.\n\nDo not wrap a release body or PR/comment the way you wrap a `.md` file. Fix one\nalready published with hard-wraps via `gh release edit \u003ctag> --notes-file \u003cfile>`\n(release bodies stay editable) or by editing the PR/comment.\n\n### The rule breaks at the pipe, not at the keyboard\n\nKnowing the rule is not enough, because the way it gets violated does not feel\nlike writing prose at all:\n\n```bash\ngit log -1 --format=%b > /tmp/body.md\ngh pr create --body-file /tmp/body.md # \u003c-- wrapped, every time\n```\n\nA commit body is *correctly* hard-wrapped at ~72 columns — `commit-conventions.md`\nsays so. Piping it into a PR body moves that text across the boundary this\nsection is about, and nothing in the command looks wrong. Observed on six of six\npull requests created that way in one session, while every body and comment the\nsame author typed by hand in the same session was clean: the rule was held for\nwriting and lost for plumbing.\n\nThe same applies to any other correctly-wrapped source: a CHANGELOG entry, an\nADR paragraph, a quoted issue body.\n\n**Join the paragraphs before posting**, and strip the trailers while you are\nthere — a commit body carries `Signed-off-by`, which is noise in a PR\ndescription:\n\n```bash\ngit log -1 --format=%b \\\n | sed '/^Signed-off-by:/d' \\\n | awk 'BEGIN{RS=\"\";ORS=\"\\n\\n\"} {gsub(/\\n/,\" \"); gsub(/ +/,\" \"); print}' > /tmp/body.md\n```\n\nThat `awk` joins each blank-line-separated paragraph onto one line. It is\ndeliberately naive — it will also join a fenced code block or a list, so read\nthe result before posting rather than trusting it on a body that has either.\n\n**The fingerprint.** `Signed-off-by:` visible in a rendered PR description means\nthat description came from a commit message, which means it is hard-wrapped.\nGrep for it when auditing:\n\n```bash\ngh pr view \"$PR\" --repo \"$R\" --json body --jq .body | grep -c '^Signed-off-by:'\n```\n\n## A negative capability claim needs the call and the response that produced it\n\n\"X cannot be done\" is the most expensive sentence to get wrong in a skill,\nbecause nobody re-tests it. Every reader after you inherits a limitation that\nmay not exist, and the ones who would have discovered otherwise are precisely\nthe ones who now do not try.\n\nIt is also the sentence that hardens fastest. One session went, across three\nrestatements and about ninety minutes:\n\n1. an observation — a request returned an empty body;\n2. a hedge in chat — \"apparently the request did not register\";\n3. a flat statement merged into a public skill — \"a review cannot be requested\n on a draft, so the ruleset is a hard stop\".\n\nA reviewer refuted it the same day, and the counter-example was **in that very\nsession**: a review had been requested on a draft and delivered. The empty body\nwas the API not echoing a bot reviewer; the timeline showed the request the\nwhole time.\n\nSo, when writing a limitation into a reference:\n\n- **Cite the call and the response.** The command, the status, the body. A claim\n with its evidence attached invites correction; one without it invites belief.\n- **Distinguish \"did not work here\" from \"cannot be done\".** An empty response,\n a 404 and a silent no-op are all observations about one attempt.\n- **Write the hedge if it is a hedge.** \"Observed once, not confirmed\" costs one\n clause and keeps the next reader looking.\n- **Prefer the positive form.** \"Confirm the request off the timeline\" is both\n true and useful; \"requests do not register on drafts\" was neither.\n\nThe same applies to a limitation you are about to route around: if the\nworkaround is expensive, spend two minutes proving the limitation first.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":6787,"content_sha256":"eef5a1647e3de0de4cf450db8ee6e0ba5d2316288ab234d71cf8d89b1c26d3af"},{"filename":"references/pull-request-workflow.md","content":"\u003c!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->\n\u003c!-- SPDX-FileCopyrightText: Netresearch DTT GmbH -->\n\n# Pull Request Workflow\n\nCovers the PR lifecycle for Netresearch repos: branch and tooling checks\nbefore opening a PR, commit discipline, merge strategies, review-thread\nresolution, and the merge gate. See `references/commit-conventions.md` for\ncommit message formatting.\n\n## Start Here: `scripts/pr-status.sh`\n\n**Do not probe the merge gate one endpoint at a time.** `mergeStateStatus:\nBLOCKED` never says *why*, and the reason lives in five different places:\nchecks, rulesets, review threads, whether a review exists on the *current*\nhead, and the repository's allowed merge methods.\n\nThe script is not on `PATH`, and you run it from the repository under\ninspection, so a relative `./scripts/…` misses. Bind the skill directory once,\nthen call it by that path:\n\n```bash\n# sort -V, not plain sort: 1.10.0 must beat 1.9.0. A directly installed\n# skill sorts after the cache paths and so wins when both exist.\nGW=$(ls -d ~/.claude/skills/git-workflow \\\n ~/.claude/plugins/cache/*/git-workflow/*/skills/git-workflow \\\n 2>/dev/null | sort -V | tail -1)\n\nbash \"$GW/scripts/pr-status.sh\" -R owner/repo 123 # human summary + NEXT action\nbash \"$GW/scripts/pr-status.sh\" -R owner/repo 123 --json # for scripts and merge drivers\nbash \"$GW/scripts/pr-status.sh\" -R owner/repo 123 --watch\n```\n\nTwo API calls (a third, admin-only, when the PR is review-blocked — it reads\nclassic branch protection, whose review gates like `require_last_push_approval`\nare invisible to the rules endpoint; the same gate set in a *ruleset* is a\nparameter of the `pull_request` rule and needs no admin token), and the output\nends in a computed `NEXT:` — rebase, fix-ci,\ntriage-ci, rerun-ci (every red job never got a runner), resolve-threads, address-comments, request-review, wait, or merge (with the method\nthis repo actually allows and a warning when a merge queue is active). The\nJSON form carries each unresolved thread's `threadId` *and* `commentId`, which\nis everything needed to reply and resolve without another query.\n\n`address-comments` covers the channel the other two miss. A reviewer who writes\nunder the pull request instead of on a line of the diff produces an *issue\ncomment*, which lives in neither `reviewThreads` nor the check rollup — so a\nreport built from those alone says `threads: 0 unresolved` while the findings\nsit unread. Anything posted after the last word of the author counts as\nunanswered; comments from bots are shown but never drive the `NEXT:` line, so a\nRenovate note cannot push its own pull request off the auto-merge rung.\n\nMeasured on a 40-PR rollout that did not have it: **183 of 370 shell calls\nwere PR-status probing**, the rulesets endpoint was queried exactly once, and\n`copilot_code_review` blocked four merges by surprise.\n\n**GitHub only.** The script speaks GitHub GraphQL, so it cannot answer for a\nGitLab merge request — `-R git.netresearch.de/group/project` is accepted at the\ncommand line and then fails with a bare `pr-status: GraphQL query failed`, and\n`--watch` produces nothing at all until it is killed. Nothing in the name says\nso, which is how a GitLab MR ended up behind a watcher that could never fire.\nFor GitLab the equivalent is one `glab api` call:\n\n```bash\nP=$(printf %s 'group/project' | jq -sRr @uri) # the path must be URI-encoded\nglab api \"projects/$P/merge_requests/123\" \\\n | jq '{state, detailed_merge_status, pipeline: .head_pipeline.status}'\nglab api \"projects/$P/merge_requests/123/discussions\" \\\n | jq '[.[] | select(.notes[0].resolvable==true and .notes[0].resolved==false)] | length'\n```\n\n`detailed_merge_status` is GitLab's counterpart to `mergeStateStatus` and,\nunlike it, does name the reason.\n\n## Then Merge: `scripts/pr-merge.sh`\n\n```bash\n./scripts/pr-merge.sh -R owner/repo 123\n./scripts/pr-merge.sh -R owner/repo 123 --dry-run # print the command only\n```\n\nIt reads `pr-status.sh --json` and refuses unless `NEXT` is `merge`, printing\nthe gate that is shut instead. When it does merge it uses the method the\nrepository allows and drops `--delete-branch` where a merge queue is active,\nwhere the head branch lives in a fork (*Taking over a contributor's fork pull\nrequest* below), or where another open pull request is based on that branch\n(*Stacked PRs: retarget before you merge* below) — it asks\n`gh pr list --base \u003chead-branch> --state open` and keeps the branch when\nanything comes back, naming the dependent pull requests so they can be\nretargeted first. A query that fails, and a `pr-status.sh` too old to report\nthe head branch, both count as \"something is stacked\": an empty answer from a\nquestion that was never answered reads exactly like \"nothing is\", and here the\ndifference decides whether somebody else's pull request survives.\nAfterwards it reads the PR back and reports only what it observed — `merged`\nwhen the state says so, `queued` when the PR really holds a queue entry, and a\nfailure with exit 2 otherwise. `gh pr merge` exiting 0 proves nothing on a\nmerge-queue repo; see *A pending auto-merge request silently swallows the\nenqueue* below.\n\nBoth of those are silent traps for a hand-written `gh pr merge --merge\n--delete-branch`. A repository with `allow_merge_commit: false` answers\n\"Merge commits are not allowed on this repository\"; one with a merge queue\nanswers \"Cannot use `--delete-branch` when merge queue enabled\". A 54-repository\nrollout hit each of them three times before the detection was written down\nonce. Squash is never used — it discards the atomic commits and their\nsignatures.\n\n### `--watch` returns on the first actionable event, not at full settle\n\nAn `until [ pending == 0 ]` loop learns nothing until the slowest matrix job\nends — long after the first failure was visible and workable. Across sessions,\n45 such loops were written against 2 of any other shape. `--watch` returns as\nsoon as a check fails, a thread needs an answer, a review is missing, or the\nrequired checks conclude. **Start fixing what is already red instead of\nwaiting for green checks you do not need.**\n\nThe first-event rule has one blind spot: an action already set at invocation\nthat retrying cannot clear — a `request-review` held open by an exhausted\nCopilot quota — makes every re-arm return the same line within a second\n(#165). Once you have seen that action and decided not to take it, re-arm\nwith `--watch --ignore-action \u003caction>` (repeatable): the watch holds through\nit, still returns on every other actionable event, and answers\n`SETTLED: NEXT is still the ignored action` (exit 0) once the checks settle\nwith the ignored action still on top.\n\n### Never merge an unreviewed PR\n\nIf `pr-status.sh` reports `reviews: NONE on current head`, do not merge.\nRequest one and say so:\n\n```bash\ngh api repos/OWNER/REPO/pulls/N/requested_reviewers -X POST \\\n -f \"reviewers[]=copilot-pull-request-reviewer[bot]\"\n```\n\nA force-push invalidates a prior review: the old review stays attached to the\nold commit, so a repo with a `copilot_code_review` rule goes back to BLOCKED\nand needs a fresh request against the new head.\n\nIt also throws away a review that has been *requested and not yet delivered*,\nwhich is the more expensive half because nothing reports it. **Rebase before\nrequesting, never after.** On 2026-09-09 three reviews were requested, each\nacknowledged, and the branches were then rebased onto a merged sibling: the\nforce-push discarded all three runs and no review ever arrived. The PRs sat\n`CLEAN` with `reviews: NONE on current head` and nothing said why.\n\n#### CodeRabbit answers when it declines, and does not catch up afterwards\n\nThese properties decide whether waiting for CodeRabbit is worth anything:\n\n- **It reviews on events, not on request backlog.** Its own wording: *\"CodeRabbit\n is an incremental review system and does not re-review already reviewed\n commits.\"* A review that did not happen at push or ready-for-review time is not\n pending — it is not going to happen, and waiting produces nothing.\n- **A rate limit is an answer, not silence.** The reply to `@coderabbitai review`\n is a comment reading `Review rate limited.` under an **`⚠️ Action not\n completed`** heading. Read it: a request that was refused looks identical to\n one still running if you only count `reviews: []`. The allowance is hourly and\n applies to public repositories independently of the organisation's plan — the\n same notice reports `Plan: Advanced`, `up to 1 included review per hour` and\n \"you've used all free OSS reviews for now\" together, so a paid plan is no\n reason to assume this does not apply.\n\n- **The refusal names its reset, and nothing happens at that time.** The\n rate-limited block in the summary carries `Next included review available in\n 48 minutes.` CodeRabbit does not come back to the refused head when the\n allowance returns, so the time is when to send `@coderabbitai review` once —\n wait for it with one timer, not with a watcher for a state change, which waits\n for ever. On netresearch/terraform-provider-ad#66 such a watcher read `Review\n rate limited` for 90 minutes until the request was sent by hand; the same\n session had done timer-then-request three times before a context compaction\n dropped the procedure. `pr-status.sh` reads the time from the block and prints\n it as `coderabbit=rate-limited (request again after …)`, and in `--json` as\n `coderabbit_retry_at`. It counts from the summary's last edit, so it can be\n later than the real reset, never earlier.\n\n- **A clean review leaves no review object.** With nothing to report it posts a\n summary comment reading `No actionable comments were generated in the recent\n review.` and submits no review and no inline comments. `reviews[]` stays empty\n and `pr-status.sh` still says `reviews: NONE on current head` — identical to\n never having run.\n\n- **It skips drafts, and its check still passes.** Unless the repository's\n `.coderabbit.yaml` sets `reviews.auto_review.drafts: true`, a draft PR gets no\n review, its check still reports `pass`, and the summary comment says\n `Draft PR not reviewed`. On a draft that green check is not evidence of a\n review. `gh pr ready` starts the review, and its verdict overwrites that same\n comment in place, so nothing new appears in the comment list — read it by\n commit range as below instead of sending `@coderabbitai review`, which answers\n `Already reviewed the last commit` when the review has already run and still\n counts against the hourly allowance.\n\nSo `reviews[]` alone cannot distinguish *refused*, *never triggered* and\n*reviewed, nothing found*. This is the delivery-side twin of the request-side\ntrap below — an empty `requested_reviewers` has three producers of its own and\ndoes not establish the Copilot wall. Same shape, different array, different\nbot: neither array carries the reason it is empty. The summary comment is the\nonly place all three are told apart, and each is terminal — a wait loop keyed\non \"a review will appear\" spins forever through all of them:\n\n**Read it by commit range, not by grepping for a phrase.** CodeRabbit keeps\n**one** summary comment and edits it in place — `created_at` differs from\n`updated_at`, there is no second comment — and it accumulates a block per push,\neach naming the range it covers:\n\n```text\n\u003c!-- rate limited by coderabbit.ai -->\n> Reviewing files that changed … between 78e7936d and 0dcc7ea1 \u003c- current head, REFUSED\n\u003c!-- end of auto-generated comment: rate limited by coderabbit.ai -->\n\nNo actionable comments were generated in the recent review. 🎉\nReviewing files that changed … between 8b867a4d and 78e7936d \u003c- previous head, clean\n```\n\nSo the same body carries \"rate limited\" *and* \"No actionable comments\" at once,\nabout different commits. A grep for either phrase answers about whichever push\nhappened to leave it — on the body above, matching `No actionable comments`\nreports the head as reviewed and clean when it was in fact refused, which is the\ndirection that authorises a merge it should not.\n\nThe sound read is to locate the block whose range **ends at the current head**\nand see which marker encloses it:\n\n```bash\nR=owner/repo; PR=123\nH=$(gh pr view \"$PR\" --repo \"$R\" --json headRefOid --jq .headRefOid)\ngh api \"repos/$R/issues/$PR/comments\" > /tmp/cr.json\n\n# 1. the comment that NAMES $H, if there is one — not the newest (see below)\njq -r --arg h \"$H\" '[.[] | select(.user.login==\"coderabbitai[bot]\")\n | select(.body | contains($h))] | last | .body' /tmp/cr.json \\\n | grep -nE \"rate limited by coderabbit|Currently processing new changes|No actionable comments|and ${H}\\.\"\n\n# 2. nothing named $H -> the short-sha shape may still cover it. Search EVERY\n# CodeRabbit comment for it; never fall back to the newest comment, which can\n# be an `Already reviewed the last commit` reply describing a different head.\njq -r '.[] | select(.user.login==\"coderabbitai[bot]\") | .body' /tmp/cr.json \\\n | grep -oE 'up to `[0-9a-f]+`'\n# a hit here is NOT a verdict: resolve the sha below before believing it\n```\n\nThree of those markers are terminal states of a range-shaped block: refused,\nreviewed-clean, never triggered. None of them will change by waiting. The fourth,\n`Merge Risk`, is the shape that carries no range at all and is read differently.\n`Currently processing new changes` is the one state that is **not** terminal — a\nreview in flight, which the merge gate says never to merge over.\n\n**The gap is asymmetric, which is what makes it confusing.** When CodeRabbit has\nfindings it posts inline comments, and those DO register as a review — `reviews:\ncoderabbitai=COMMENTED`, plus an unresolved thread the gate already counts. It is\nthe **clean** pass that leaves nothing but the comment, so the only case where\n`reviews: NONE on current head` is actively misleading is the case where there\nwas nothing to say. A reviewed-clean head and a never-reviewed head look\nidentical on that line; that is what `coderabbit_on_head` separates.\n\n**`last` alone reads the wrong comment.** \"It keeps one comment\" is true of the\nsummary; a reply to `@coderabbitai review` is a second comment by the same author\nand it is the *newer* one, so taking the last CodeRabbit comment lands on\n`Already reviewed the last commit` and reports a reviewed head as never reviewed.\nSelect the comment that names `$H`, as the filter above now does — measured on\n`netresearch/matrix-skill#151`, a 512-character reply sitting after a\n7334-character summary.\n\n**A fourth shape says which head was assessed without naming a range at all.**\nThe summary can carry a verdict block instead of the `between X and Y` line:\n\n```text\n**Merge Risk:** _🔵 Low_ · up to `2cf7a`\n```\n\nThat `up to` sha is the head the assessment covers, and it is a **short** sha, so\na grep built from the full `$H` above misses it and reports \"never reviewed\" for\na pull request that was in fact reviewed — at an older commit.\n\nDo not compare a fixed number of characters: five hex digits is short enough that\ntwo commits in the repository can share them, and a prefix match then reports an\nolder assessment as covering the current head — the one direction that authorises\na merge it should not. Resolve it instead, and require it to name **exactly one**\ncommit:\n\n```bash\nSHORT=$(gh api \"repos/$R/issues/$PR/comments\" \\\n | jq -r '[.[] | select(.user.login==\"coderabbitai[bot]\")] | last | .body' \\\n | grep -oE 'up to `[0-9a-f]+`' | tail -1 | tr -d '`' | awk '{print $3}')\ngit rev-parse --verify --quiet \"$SHORT^{commit}\" >/dev/null \\\n && [ \"$(git rev-parse \"$SHORT\")\" = \"$H\" ] \\\n && echo \"the block covers the current head\"\n# ambiguous, unknown, or a different commit -> treat this head as unreviewed\n```\n\nRun it where the commit is fetched — from a checkout of the pull request, not\nfrom a clone that has never seen the branch, or an unfetched sha resolves to\nnothing and reads as the ambiguous case. That fallback is the safe one, but it\nis the wrong reason.\n\nThe marker is advisory, so every outcome other than \"one commit, and it is `$H`\"\nfalls back to unreviewed. When it names an earlier commit the automatic pass will\nnot come back for the current head — `@coderabbitai review` is the only way to\nask, and it may answer with the rate limit above. Observed 2026-09-17 on\n`netresearch/skill-repo-skill#322`: `up to 2cf7a` while the head was `889fc34`,\ntwo commits later.\n\n`pr-status.sh` runs the **range-shaped** half of this for you and reports it as\n`coderabbit_on_head` (`clean` · `findings` · `in-progress` · `rate-limited` ·\n`unknown` · `none`),\nalso on the prose `reviews` line as `coderabbit=…`. For `rate-limited` with a\nreset in the block, the JSON adds `coderabbit_retry_at` and the prose line adds\n`(request again after \u003ctime>)`. It does not resolve the\nshort-sha shape — that needs a checkout — so it answers `unknown` when a\n`up to \\`…\\`` marker is the only thing naming a head, and the resolution above is\nstill yours to run. Nothing gates on the field: a comment is not a review, so\n`has_review_on_head` and the merge gate are unchanged, and the value exists so a\nself-review note can say what the bots did rather than claiming they said nothing.\n\n**A passed SonarCloud Quality Gate can still carry new issues.** The default gate\nfails on ratings, coverage and duplication, not on the issue count, so a code\nsmell lands on the base branch with every check green: on\n`netresearch/data-tools-skill#97` the comment read \"Quality Gate passed\" and\n\"1 New issue\", and after the merge the `python:S3776` finding stood open on\n`main`. `pr-status.sh` reports the count from the newest SonarCloud comment as\n`sonar_new_issues`, and prints a `sonar` line when it is above zero. Nothing\ngates on it, and the comment names no commit, so it is the latest analysis\nrather than provably the head. Before merging, read the issues it links and fix\nthem in the PR or decide to accept them; do not leave them for `main`.\n\nWhen *both* reviewers are walled — Copilot out of monthly quota, CodeRabbit rate\nlimited — no bot review is obtainable and the documented path is to read the diff\nyourself and merge on the attestation (`pr-merge.sh --self-reviewed`), noting in\nthe PR that the bot review was unavailable. That is not a shortcut around the\ngate; it is the gate's own fallback, and it is worth doing properly: in the same\nsession, the one PR CodeRabbit *did* review returned a genuine defect, and the\nhand review of the remaining three found four more.\n\n**On a DRAFT PR the request is silently dropped.** The REST call above answers\n200, but the returned object's `requested_reviewers` stays `[]` and no review\never starts — nothing errors, the request just does not take. A\n`copilot_code_review` ruleset triggers its review only when the PR leaves\ndraft (`gh pr ready`). So do not diagnose a \"broken\" reviewer request on a\ndraft: mark ready first, then check `requested_reviewers` / the reviews list.\nThe 2xx-is-not-proof rule applies — read the response body, not the status.\n(Observed 2026-08-26, netresearch/ldap-manager#659: two 200-acknowledged\nrequests on the draft, zero effect; the ruleset review fired within a minute\nof `gh pr ready`.)\n\n#### A failed Copilot review looks exactly like a delivered one\n\nCopilot reports its own failures *as a review* — a normal `COMMENTED` row whose\nbody is the error:\n\n```text\nCopilot encountered an error and was unable to review this pull request.\nCopilot was unable to review this pull request because the user who requested\nthe review has reached their quota limit.\n```\n\nNothing in the review's `state` distinguishes that from a real review, so\n\"a review exists on the head\" is not the same as \"this PR was reviewed\".\n`pr-status.sh` detects it and reports `copilot_review_errored: true` with\n`NEXT: request-review` and a `copilot_error_count`. From the second failure on a\nhead it drops the retry command and tells you to review it yourself — in every\nrepo, with or without the `copilot_code_review` ruleset, since the generic\nreview gate re-requests the same bot. By hand, read the review **body**, not\njust its state.\n\nThe action stays `request-review` even then, deliberately. The tool cannot\nobserve that a human read the diff — a review by the PR author is excluded from\nthe review gate by design — so a distinct \"you are done now\" action would be one\nnothing could ever satisfy, and it would re-fire forever on the operator it was\nwritten for.\n\nThe failure is also a failing `copilot-pull-request-reviewer` check-run — but\nonly in the REST `check-runs` API. It is **absent from GraphQL\n`statusCheckRollup`**, so a rollup-based check cannot see it.\n\nRe-request once. If it fails a second time, stop retrying and **review it\nyourself**: an outage may clear, but a quota ceiling does not clear by asking\nagain, and the alternatives — merging unreviewed or waiting indefinitely on\nthird-party infrastructure — are both worse than a self-review that says so.\n\nA body naming the **quota** is not an outage, so it costs no second strike:\n`pr-status.sh` withholds the retry command on the first failure and remembers\nthe wall in\n`${XDG_CACHE_HOME:-~/.cache}/pr-status/copilot-quota-exhausted-YYYY-MM`. The\nquota is account-wide and monthly while the evidence is per PR — a pull request\nnobody ever requested a review on carries none at all — so without that file\nthe next PR in the next repo is handed a `requested_reviewers` POST the same\nexhausted quota rejects (`netresearch/maint` #52 and #53, hours after the wall\nwas proven elsewhere). Later runs read the marker, report\n`copilot_quota_exhausted: true` and answer with the self-review guidance and no\ncommand. The month is in the **filename**, so the marker stops applying at the\nreset rather than being aged out; delete the file to undo a verdict recorded in\nerror.\n\nThe quota wall has a second, earlier face: the `requested_reviewers` POST\nitself can be **silently dropped** — HTTP 200, but no pending request appears\nand no errored review follows either (five requests across four repos were\nswallowed this way on 2026-08-18 before a later errored review named the\nquota).\n\nAn empty `requested_reviewers` does **not** establish that, and reading it as\nthe quota tell states a swallowed request where there was none. The array has\nthree producers and cannot tell them apart: the response body never echoes a\n**bot** reviewer at all, a reviewer that has **started** drops off the list\nwithout having submitted, and only the third case is a genuinely dropped POST.\nConfirm off the **timeline** or the reviews list instead, as\n`merge-gate-watcher.md` § *\"What still works while GraphQL is drained\"*\nprescribes — an errored Copilot row on the head is the same wall, said out\nloud, and `pr-status.sh` reports it as `copilot_quota_hit`. Once the wall is\nestablished by either route, stop requesting everywhere and go to the\nself-review path above.\n\nA **404** from the POST is a status code, not a diagnosis. It reads like\n\"Copilot cannot be requested on this repository\", and that is a claim about the\nrepository nobody measured: on 2026-09-18 a 404 there turned out to be the\naccount-wide wall, already recorded in the marker by an earlier session. One\nobservation does not make a 404 the quota tell either. Run `pr-status.sh` (or\nread the marker **and its age** — it lapses after `PR_STATUS_QUOTA_TTL_HOURS`,\n6 by default, and the file itself can outlive that) **before** requesting; after\nan accepted request, wait for the delivered row and read its body — it says\nwhether the cause was the quota or an outage.\nNever write the marker by hand while a request is still in flight.\n\nA 404 has a second producer that has nothing to do with Copilot: the account\nhas no triage or write permission on the base repository, which is the normal\ncase for a fork contributor to an upstream project. GitHub answers a\n`POST .../pulls/N/requested_reviewers` it will not accept from that account\nwith `404 Not Found`, for a human reviewer as much as for a bot. Check the\npermission first:\n\n```bash\ngh api repos/OWNER/REPO --jq .permissions\n```\n\nMeasured on 2026-09-26 on TYPO3-Documentation/TYPO3CMS-Guide-HowToDocument:\n`{\"admin\":false,\"maintain\":false,\"pull\":true,\"push\":false,\"triage\":false}`, and\nthe POST requesting a human reviewer returned `404 Not Found`. Without\n`triage`, do not request; a reply in the review thread already notifies the\nreviewer.\n\n### Putting the self-review on the record (#203)\n\n\"Review it yourself and say so in the PR\" used to end outside the tooling: the\nreview-note comment satisfied the policy while `pr-merge.sh` still refused the\nmerge, so the merge ran as raw `gh pr merge` — three times in one sweep, past\nthe very script that exists to be the safe path. The fallback is now a\nfirst-class input:\n\n```bash\npr-merge.sh -R owner/repo 123 --self-reviewed\n```\n\nposts a PR comment whose body carries the line `Self-review: \u003chead-sha>` (as\nthe PR author — the flag refuses any other authenticated user; hand-written\nmarkers need at least the first 12 sha chars, since an 8-char prefix is\ngrindable by vanity-sha tools), then re-reads the gate and merges.\n`pr-status.sh` reads the attestation back from the last 100 comments and\nhonours it where the refusing branch stamped a reason the author may satisfy in\nperson: `next.reason: review-required`, the ordinary case where the policy wants\na review and does not care whose, or `next.reason: bot-review-unsatisfiable`,\nwhere the demanded bot review cannot arrive at all. **A review is mandatory; a\nbot review is not.** Until 17 September 2026 the attestation counted only in the\nsecond case, which tied the whole self-review path to Copilot — a bot most\nrepositories neither require nor have the quota for — and left a repository\nwithout the `copilot_code_review` ruleset unable to merge a reviewed pull\nrequest at all. What the policy demands is that somebody read the diff, and the\nauthor reading it is somebody.\n\nThe one thing still worth waiting for is a bot review actually in flight: a\npending Copilot request keeps the attestation inert and the ladder answers\n`await-review`, because requesting a reviewer commits you to waiting for its\nanswer. The flag keys on the stamped reason, never on the account-global quota\nstate, so a satisfiable refusal (say, classic `require_last_push_approval`)\ncan never receive a false attestation, and the comment body states which of the\ntwo demands it satisfies rather than claiming an unsatisfiable bot where none\nwas demanded. That keying is necessary\nand was not sufficient: until #214 the refusing branch itself stamped the reason\nwhile an `APPROVED` review sat on the very same head, and seven approved PRs in\none sweep got the attestation anyway. The bot branches now also require that no\napproval is on the current head — checked against the approval list rather than\n`has_review_on_head`, which a failed Copilot review satisfies by being an\nordinary `COMMENTED` row. With a bot review in flight\nthe attestation changes nothing, a non-author comment never counts, the\nattestation opens the gate only for a run authenticated as the PR author (read\nby anyone else it is reported in `self_review_on_head` and changes nothing), a human\n`CHANGES_REQUESTED` or a host-required approval keeps it inert, `--dry-run`\npreviews the comment without posting it, and the next push invalidates the\nattestation because the sha stops matching. This is an explicit operator assertion the tool reads back, not a\nstate it claims to observe — the same reason the old \"review-yourself\"\n*action* was removed. The attestation comment is permanent PR history: post\nit only when the diff was actually reviewed, and say what was looked at in\nthe review-note comment beside it.\n\n### After a rebase, `range-diff` is what says the review still holds\n\nThe attestation dies with the next push, and a rebase is a push. Under\n`strict_required_status_checks_policy: true` every merge into the base makes the\nremaining PRs stale, so a sweep of sibling PRs rebases each one in turn and\narrives at a fresh head with no review on it — while the review that *was* done\nusually still describes the code exactly. Re-reading the whole diff to re-attest\nis the honest expensive answer. The cheap one is to prove the patches did not\nchange:\n\n```bash\ngit range-diff origin/main...\u003cold-head> origin/main...\u003cnew-head>\n```\n\nEvery commit printed with `=` is byte-identical in its patch; a `\u003c`/`>` pair\nwith a diff-of-diffs beneath it is a commit the rebase altered, and that one\nneeds reading. A series that comes back all `=` means the rebase moved the base\nand nothing else, so the earlier review — yours, a bot's, a human's — still\napplies to the new head and can be cited as the basis for the attestation.\nNaming the old and new sha in the review note is what makes that checkable\nafterwards.\n\nThis answers a different question from the reference-merge check under *\"I need\nto rebase a long branch onto a base that moved\"*: that one proves the resulting\n**tree** is the intended end state, this one proves the **patches** are still\nthe ones somebody read. A rebase can land the right tree through changed\ncommits, and reviewers read commits.\n\nIt is also the check to run before reusing a bot review across a rebase.\nCodeRabbit and Copilot both refuse heads while rate-limited or out of quota, and\na refusal is not a review — `range-diff` says whether the review you already\nhave is still about this code, which is the only part you can establish\nyourself.\n\n### A pull request you did not author takes the other path (#280)\n\nThe attestation is an assertion by the author, so it is unavailable on any pull\nrequest that is not yours. A Renovate or Dependabot one is the obvious case —\nnobody can authenticate as the bot — but the common one is finishing a\ncolleague's pull request: you are a perfectly good reviewer of it and you still\ncannot post its author's attestation, and `--self-reviewed` refuses. That\nleaves the ordinary path, which was open the whole time and went unnamed — a\nhuman `APPROVED` review on the current head satisfies the\nnever-merge-unreviewed policy on its own, in the `copilot_code_review` branch\nas well as the generic one:\n\n```bash\ngh pr review 123 --repo owner/repo --approve # after reading the diff\npr-merge.sh -R owner/repo 123 # no flag\n```\n\nBeing someone other than the author is what makes that approval *count*: a\nreview by the author is excluded from the gate, so on your own pull request the\nattestation is the only self-service route and on everyone else's the approval\nis. The two cases never overlap.\n\nA `COMMENTED` review is not enough — that is what a CodeRabbit note or a\nthread reply registers as, and the gate reads the approval list, not\n`has_review_on_head`. `pr-status.sh` reports `viewer`, `viewer_is_author` and\n`attestation_available`, and swaps the attestation advice for this command\nwhenever the attestation cannot be posted — bot author or simply not you;\n`pr-merge.sh --self-reviewed` names it in its refusal. Nothing about the gate is relaxed: a third party still cannot mint\nan attestation for someone else's pull request, and the approval is a real\nreview on the record rather than a flag. This is the case `deps-no-automerge`\nand `deps-major` route to a human by design — `netresearch/.github`'s\n`auto-merge-deps.yml` excludes both labels, and its own documentation says\nmajors are approved but left for a human to merge.\n\n**On a bot PR the `address-comments` rung measures against YOUR last word, not\nthe author's (#319).** Everywhere else it measures against the author: you\nopened the pull request, somebody wrote under it, you answer. A bot author posts\nonce at creation and never again, so measuring against that would count every\nlater comment for the life of the pull request — the repository's own status\nnotes from Codecov, SonarCloud and Dependency Review, a CodeRabbit \"review\nskipped, bot user detected\", and the approval note you just wrote — with no\naction left that clears it, because answering adds one more comment that is\ncounted too. Observed before the fix on `netresearch/t3x-contexts_geolocation#56`:\n`CLEAN`, 62 checks green, zero unresolved threads, an `APPROVED` review on the\nhead, and `pr-merge.sh` refusing.\n\nThe guarantee that does not change: a comment posted by somebody else *after*\nyour last word still raises the rung, on a bot pull request as anywhere else.\n\n### Taking over a contributor's fork pull request (#308)\n\nA contributor opens a pull request from their fork, CI cannot be satisfied from\ntheir side, and they hand it over. `maintainerCanModify` is what makes the\ntakeover possible without a cherry-pick — check it first, because it decides\nwhether their authorship survives:\n\n```bash\ngh pr view \u003cn> --repo OWNER/REPO --json maintainerCanModify,headRepositoryOwner,headRefName\ngit -C .bare remote add \u003ctheir-login> https://github.com/\u003ctheir-login>/\u003crepo>.git\ngit -C .bare fetch \u003ctheir-login>\ngit -C .bare worktree add ../pr-\u003cn> -b \u003clocal-branch> \u003ctheir-login>/\u003chead-branch>\n# rebase, then your fixes as your OWN commits on top — never amended into theirs\ngit -C ../pr-\u003cn> push \u003ctheir-login> HEAD:\u003chead-branch> --force-with-lease\n```\n\nTheir commits keep their author and their `Signed-off-by`; yours carry yours.\nThe rebase re-signs every commit with your key, which is expected — the author\nfield is what attribution rests on, not the signature.\n\nThree things about the merge differ from an ordinary pull request:\n\n1. **The head branch is not yours to delete.** `gh pr merge --delete-branch`\n deletes it in the *fork* when your token has push rights there, and the\n contributor loses the branch their work sits on. `pr-merge.sh` reads\n `cross_repository` from `pr-status.sh` and drops the flag; a hand-written\n `gh pr merge` must drop it too.\n2. **`--self-reviewed` is not available to you.** The attestation is an\n assertion by the author, and on a takeover you are not the author. The path\n is the one the bot-authored case above names: read the diff, `gh pr review\n \u003cn> --approve` as yourself, then `pr-merge.sh` with no flag — **as long as\n you pushed none of the commits.** Once you have fixed the takeover on its\n branch, an approval from you approves your own code: it attests to no second\n reader, and an agent harness may deny it as self-approval (retro-skill#164,\n three of four commits by the viewer, `--approve` refused). `pr-status.sh`\n then counts your commits (`viewer_commits`; merge commits that only bring\n the base in do not count) and stops offering `--approve`. It also withholds\n it when a branch has more commits, or a commit more authors, than one query\n page reads (`authorship_complete: false`): unknown is not \"none of yours\".\n The review has to come from someone who wrote none of them, or the merge from\n an explicit instruction by whoever owns that decision; post the review you\n did do on the PR as a comment, saying which commits are yours. A `/pr-finish`\n run is that instruction: after the comment, approve with `gh pr review\n --approve` and a body that names the instruction and links the comment, then\n merge with `pr-merge.sh` — do not stop to ask for a manual approve\n (2026-10-23, netresearch/typo3_http_guard#21 and #23 each waited on a hand\n approve the command already covered). Under `require_last_push_approval` the\n host discounts an approval from the last pusher whatever the instruction\n says: `pr-status.sh` answers `request-review`, and someone other than the\n last pusher approves.\n3. **Your own comments used to keep `address-comments` shut.** The rung\n measures against the *author*, the contributor has often stopped answering,\n and every comment you write is newer than their last word — so the counter\n rose with each one, including the comment answering the bot notices. The\n viewer is now excluded from it the way bots are. A reply from the\n contributor still raises the rung.\n\nPin the head commit on the merge, since a takeover has usually pushed twice:\n`gh pr merge \u003cn> --repo OWNER/REPO --merge --match-head-commit \u003cfull-sha>`.\nRead it and answer it.\n\nWhere `viewer` is missing from the GraphQL response — an older `gh`, a stubbed\none — the rung falls back to the author and behaves as it did before, which is\nthe safe direction: it can hold a merge, never open one.\n\n**One bot account reaches you under three logins**, so any check written\nagainst a hardcoded name is wrong for two of them. Measured on\n`netresearch/github-release-skill#110`, all three naming the same Renovate\ninstall: GraphQL answers `renovate` with `__typename: Bot`, `gh pr view --json\nauthor` answers `app/renovate` with `is_bot: true`, and the webhook payload\n(what `auto-merge-deps.yml` matches on) answers `renovate[bot]`. `__typename`\nis the only authority — prefer it, and anchor every login pattern you fall back\nto, or `renovate-maintainer` reads as a bot and that person is refused\n`--self-reviewed` on their own pull request.\n\n### Before believing a script cannot do something: ask which copy is running\n\nEvery script in `scripts/` answers `--version`, and prints the resolved path beneath it:\n\n```\n$ pr-merge.sh --version\npr-merge.sh 1.27.0\npath: /home/you/.agents/skills/git-workflow/scripts/pr-merge.sh\n```\n\nThe version is read from the `SKILL.md` next to the script, never from a checkout elsewhere, so a cached copy reports the number it was packaged with. The path is there because the number alone is not enough: two installations on one machine declared the same version while shipping different scripts, and `--self-reviewed` existed in only one of them (#209). The flag was reported as missing — which reads exactly like a feature that was never built — and about a dozen PRs were merged by hand instead of through the attestation flow.\n\nSo when a documented flag is rejected as unknown, that is a question about the installation before it is a question about the tool. `--version` needs no repository, no network and no `gh`: it is the one thing that must still answer when everything else is broken.\n\n## A Rebase Conflict Can Mean the PR Is Superseded\n\nWhen `NEXT: rebase` turns into a conflict, look at **what the main side of the\nhunk contains** before resolving anything. If main's side already implements\nwhat the PR implements — the same feature, ported independently or landed via a\nsibling PR — the conflict is not a merge problem, it is the discovery that the\nPR is superseded. This is the normal fate of a fix PR that sat for days while\nthe incident it came from was also worked elsewhere.\n\nThe reflexive resolution — keep the PR's side, it is what you came to merge —\nis exactly wrong here: main's version has usually moved on (hardening, an extra\ncall, review feedback the PR never saw), and preferring the PR side silently\n**reverts** that. Observed 2026-08-18 on `netresearch/jira-skill` #194: main's\ncopy of the identical stdin feature had a mention-gate call integrated\n(`check_mentions_cli`); taking the PR's hunk would have merged green and\ndropped the gate.\n\nWhat to do instead: diff the PR's intent against current main and keep only the\ndelta main lacks (`git reset --hard origin/main`, re-apply just that delta,\nreword the commit) — or close the PR outright if nothing remains. Either way,\nupdate the PR title and body to describe what it now is; a re-scoped PR wearing\nits old description misleads its reviewer. And when reviewing a PR that is more\nthan a couple of days old, check `mergeable`/`mergeStateStatus` first — a\n`CONFLICTING` docs-or-fix PR is a \"has this landed already?\" prompt before it\nis a content-review task.\n\n## Check the Default Branch Before Operating\n\nNot every repo uses `main` — older repos often use `master`, and some use\n`develop` or `trunk`. Before pushing, opening a PR, or scripting across many\nrepos, resolve the actual default branch instead of assuming:\n\n```bash\ngh repo view OWNER/REPO --json defaultBranchRef --jq '.defaultBranchRef.name'\n```\n\nAssuming the wrong name silently pushes to (or creates) the wrong branch, or\ntargets a PR at a branch that isn't the integration branch.\n\n## After a Detour to Another PR, Switch Back — and Verify\n\nWorking two PRs at once, a fix for PR B often means checking out B's branch\nmid-task. Nothing switches you back afterwards, and the next edits land on B\nwhile you believe you are on A. Because `git status` looks normal — modified\ntracked files, no conflict — the mistake surfaces only later, e.g. when a value\nyou \"already added\" reads back as absent.\n\nRe-assert the branch before resuming edits, and again before staging:\n\n```bash\ngit branch --show-current # cheap; run it after ANY cross-PR detour\n```\n\nIf edits did land on the wrong branch, move them rather than redoing them:\n\n```bash\ngit stash push -m \"misplaced work\" -- \u003cpaths> # path-scoped: leaves the branch's own work alone\ngit checkout \u003cintended-branch>\ngit stash pop\n```\n\nPrefer a separate worktree per PR (`references/advanced-git.md`) when the two\nare worked in parallel — then no checkout is shared and the detour cannot\nmisplace anything.\n\n## Prefer the `gh` CLI / GitHub MCP Over Raw API or Web UI\n\nFor GitHub operations (PRs, issues, reviews, releases), reach for `gh` or the\nGitHub MCP tools before hand-rolling `curl`/REST calls or clicking through the\nweb UI: consistent authentication, structured `--json` output, and clearer\nerrors. Drop to raw `gh api` only for endpoints the porcelain commands don't\ncover yet.\n\n### Two `gh` shapes that read as bugs and are not\n\n`gh pr view --json merged` fails with `Unknown JSON field: \"merged\"` and then\nprints the whole valid-field list starting at `additions`, which buries the\nanswer. There is no `merged` boolean: ask for `state` (`MERGED`), `mergedAt`,\nor `mergeCommit`.\n\n`gh repo fork \u003cowner/repo> --remote=false` is refused outright —\n`the --remote flag is unsupported when a repository argument is provided`. The\nflag only applies when forking the repo you are standing in. To fork something\nelse without touching local remotes, pass only `--clone=false`.\n\nNeither is worth a second attempt with the same shape, and they fail for\ndifferent reasons: the first names a JSON field that does not exist, the second\ncombines a flag with an argument it is not valid alongside.\n\n### The quotas are session-shared pools — act, don't re-preflight\n\nEvery `gh` call in a session draws from a shared pool — REST 5,000/h and\nGraphQL 5,000 points/h are SEPARATE pools, each shared across watchers,\nagents and scripts, plus short burst limits on top. A heavy session (fleet\nsurvey, repeated preflight batteries, parallel reviewers) usually kills the\n**GraphQL pool first** — and `gh pr merge`, `gh pr view --json`, and\n`pr-status.sh` are GraphQL-backed, so exactly the merge you verified\neverything for stops working (observed 2026-08-13, between \"all gates green\"\nand the merge).\n\nTwo practices keep this from biting:\n\n- **When the full gate was verified on head X and nothing was pushed since,\n the action is ONE call — not another preflight battery.** The REST merge\n endpoint takes a `sha` pin whose 409 on mismatch IS the freshness check:\n\n ```bash\n gh api -X PUT \"repos/$R/pulls/$PR/merge\" \\\n -f merge_method=merge -f \"sha=$(git rev-parse HEAD)\"\n ```\n\n Take the SHA from local git, never by retyping a short SHA into a long one\n — the pin rejects a fabricated tail exactly as it rejects a moved head.\n- **GraphQL dead ≠ blocked.** The REST twins keep answering: merge (above),\n branch delete (`gh api -X DELETE repos/$R/git/refs/heads/\u003cbranch>`; a 422\n means auto-delete beat you to it), reviews and comments lists. `gh api\n rate_limit` is exempt and tells you both pools' reset times. Prefer one\n `--watch` over repeated status reads, and stop any watcher whose answer\n you already have.\n- **`rate_limit` can read full while GraphQL refuses you.** A mutation\n answering `{\"type\":\"RATE_LIMIT\",\"code\":\"graphql_rate_limit\",\"message\":\"API\n rate limit already exceeded for user ID \u003cn>\"}` is a per-user wall, not the\n points pool `rate_limit` reports — that endpoint showed `graphql.remaining:\n 5000` in the same minute. So do not read `rate_limit` as clearance, and do\n not conclude the token lost a scope: the shape of the error body is what\n tells them apart. It lifts on its own; here in under 20 minutes.\n- **Answering a review thread has a REST twin; resolving it does not.** Reply\n with `gh api repos/$R/pulls/$PR/comments/\u003ccomment_id>/replies -X POST -f\n body=…` (the id is the *first* comment of the thread, from\n `pulls/$PR/comments`), which lands in the same thread as the GraphQL\n `addPullRequestReviewThreadReply`. `resolveReviewThread` has no REST\n equivalent, so when GraphQL is walled the only move is to retry it — put the\n retry in the background and carry on rather than holding the session on it.\n\n## Describing the Change (PR Body, Commit Message, Issue)\n\nThe rest of this file is about getting a change *merged*. This section is about\nmaking it *reviewable*. A reviewer who cannot see what changed has to reproduce\nyour work before they can judge it.\n\n### Show before/after for anything observable\n\nIf the change alters output, an error message, a rendered page, a CLI line or an\nAPI response, the PR body carries **both states**. Not a description of them —\nthe captured text:\n\n```markdown\n**Before**\n\n Reason: An error occured on handling the request.\n\n**After**\n\n Reason: An error occured on handling the request. (HTTP 500, code 1603956982)\n```\n\nProduce them the same way: run the old and the new code against the *same*\ninput. A stub server, a fixture, or `git checkout \u003cbase> -- \u003cfile>` for one run\nand back again all work, and take a couple of minutes:\n\n```bash\nrun_it > after.txt\ngit checkout \u003cbase> -- src/Thing.php # old behaviour; \u003cbase> is the PR's target, not always main\nrun_it > before.txt\ngit checkout HEAD -- src/Thing.php # restore; verify with git status\n```\n\nSay in the body that the transcripts are captured output rather than\nillustrations — the difference matters to a reviewer deciding how much to trust\nthem.\n\nCover the shapes the change can meet, not only the motivating one. A table of\ninput → before → after exposes the cases you would otherwise never run.\n\n### \"Unchanged\" is a claim, and needs the same proof\n\nNegative claims escape verification because nothing looks wrong when you skip\nthem. \"The fallback keeps its wording\", \"existing callers are unaffected\",\n\"no behaviour change for empty input\" — each asserts the result of a run you\nhave to actually perform.\n\nBuild the before/after table *before* writing the summary sentence. Filling in\nthe rows is what catches the case you assumed was untouched; writing the\nsentence first only records the assumption.\n\n### On a small, gated diff, point review rounds at the claims, not the code\n\nA two-file change that every gate has already passed has very little room left\nfor a code defect, and a great deal of room for a wrong sentence about it. The\nprose is the part nothing checks: no linter reads the commit message, no test\nruns the PR body, and a maintainer decides from exactly those.\n\nThe asymmetry is easy to measure once you look for it. Across four review\nrounds on a two-file config fix, the tree never changed — same hash through\nfour commit revisions — while every round found something: a mechanism claimed\nbackwards, a blast radius copied from an issue and never traced, a verification\nmatrix that claimed more coverage than was run, a `grep` quoted in a form that\nreturns different results than stated, a cited line number pointing at a file\nthe installed version no longer ships.\n\nSo when the diff is small and the gates are green, brief the reviewer on the\ntext: every number, every cited file and line, every version range, every\n\"affects X\", every \"I ran Y\". Ask for the claim to be checked against the\nsource, not for an opinion on the code. Two things make this cheap and worth\nrepeating:\n\n- Give the reviewer the SHA and require it back. That does not stop a finding\n from describing a revision you have already replaced — it makes the mismatch\n visible, which is the part you can act on.\n- Verify each finding yourself before acting on it. A reviewer's premise can be\n wrong too, and a confidently wrong correction is worse than the error it\n replaces.\n\nStop when a round returns only wording, not substance. Rounds that converge on\nphrasing have found the floor.\n\n### \"Review until no new findings\" needs a bounded finding criterion from round one\n\nA loop that runs until a round finds nothing may fail to end when the reviewer's\ninput space is open-ended. On code that reads an open-ended input -- a parser, a\nheuristic over shell commands, a text classifier -- an adversarial reviewer may\nconstruct another input the code gets wrong when such a defect exists, and the\nloop can then continue instead of reaching zero. Each fix also adds new shapes\nof its own, and the next round finds their variants.\n\nMeasured on one PR (netresearch/retro-skill#124): with the reviewer free to\nconstruct inputs, rounds 1-10 found 6, 8, 7, 6, 5, 7, 5, 7, 7 and 6 defects. Once\nthe criterion was bounded, rounds 11-19 found 3, 4, 1, 1, 2, 1, 2, 1 and 0. In\nrounds 13-18, 6 of the 8 findings were regressions of the previous round's fix.\n\nSo state the criterion in the first brief, not after round ten. A finding counts\nwhen it:\n\n- reproduces on real data (stored logs, transcripts, production input), or\n- breaks a literal input an earlier round turned into a test, or\n- contradicts a sentence the change's own documentation states, or\n- sits in a part of the change that has no open input space.\n\nConstructed variants outside these do not count; the documentation states the\nlimit instead. And before pushing a fix, probe the shapes the fix itself\nintroduces -- that is where the next round's findings come from.\n\n### A PR body describes the branch it had, not the branch it has\n\nA body written months ago documents a state the branch has since left. Every\nrebase, revert and upstream merge invalidates part of it, and the tooling\nnotices at most that the body is older than the head, never which part went\nstale. Before publishing an update, re-derive each factual claim from\n`git diff \u003ctarget>...HEAD` — image tags, memory limits, thresholds, and above\nall the New/Changed column: a job listed as **New** that the target already has\nhides whatever your version alters about it.\n\nObserved in one MR: a \"switched coverage to PCOV\" section after PCOV had been\nreverted, `php:8.3` images that were `8.4`, `MSI 70/80` against an actual\n`19/77`, and a job marked **New** that existed upstream — which concealed that\nthe change also dropped its memory limit from 2G to 512M.\n\nWhen a change is gone, **delete its section**. Rewording it to \"X stays the\ncoverage driver\" replaces a false statement with a true but empty one: the body\nis the delta against the target, and something that does not change has no row\nin it.\n\n**Elapsed time is the obvious cause; a review is the frequent one, and it fires\nwithin the hour.** The body was written to describe the first version of the\nchange, the review found that version wrong, and the fix replaced the approach\nthe body still explains. Seen on one PR the same day it opened: the body\ndocumented an escape hatch read from the environment that the head commit had\nalready replaced with one read off the command line — the mechanism was gone,\nthe explanation was not.\n\nRe-derive the body after every push that answers a review, not only after a\nrebase, and **re-measure every number it states rather than copying it\nforward**. Figures survive edits that invalidate them: the same PR claimed a\n\"63k-word corpus\" while the code comment beside the measurement said 60k. Both\nhad been measured — of different corpora, weeks apart in reading order and\nminutes apart in writing — which is exactly why the disagreement is worth\ncatching. Two figures for one quantity means at least one is answering a\nquestion you are no longer asking.\n\n`pr-status.sh` prints a `body` line when the head commit was committed after\nthe body was last edited (or written, if nobody edited it); `--json` carries the\ncomparison as `body_freshness`. It is a reminder, not a check: it compares two\ntimestamps, so a rebase that changed nothing the body says also trips it, and it\nnever changes the `NEXT:` line or the merge gate.\n\n**A state change invalidates the body as surely as a push, and is easier to\nmiss.** A push makes you re-read the body because you just wrote the change;\nflipping a flag does not. Yet the body is the one place that *asserts* the state\nin words, so it is the one place that can contradict it: a PR marked ready for\nreview while the body still reads \"still in draft while it waits for a look\"\nsays two different things to whoever opens it, and the sentence is the one they\nbelieve.\n\nTreat `gh pr ready`, `gh pr ready --undo` and `gh pr reopen` as edits to the\nbody's factual content. After each, search your own text for the words naming\nthe state you just left:\n\n```bash\ngh pr view \"$PR\" --json body --jq .body \\\n | grep -niE 'draft|WIP|work in progress|waiting on|blocked on|once .* lands'\n```\n\nObserved on a PR whose body stayed accurate through three force-pushes and then\nwent stale the moment it was marked ready — the one change that did not involve\nwriting a diff.\n\n### Write the body in its own tool call, after the push\n\nA body that cites a commit can only be written once that commit is on the\nremote, and the hash belongs in it by lookup — `git rev-parse --short HEAD` or\n`git log -1 --format=%h` — never typed from memory. A reader who follows a\nwrong hash finds nothing, and the claim it supported becomes unverifiable.\n\nThat ordering has a second, sharper reason: **a denied command loses every side\neffect it contained.** Bundling the push, a heredoc that writes the body file,\nand `gh pr edit --body-file` into one shell invocation means a gate that rejects\nany part of it rejects all of it — and the parts that already ran do not roll\nback. Observed: the push landed, the heredoc never ran because the same call was\nrejected for naming a not-yet-pushed hash, and the following command failed with\n`no such file or directory` — a confusing error two steps downstream of the\nactual refusal.\n\n```bash\n# ✅ Three calls, each with one job\ngit push\ngit rev-parse --short HEAD # take the hash from here\n# …write body.md…\ngh pr edit 123 --body-file body.md\n\n# ❌ One call: the gate rejects the whole thing, the push already happened,\n# and the body file that the next step needs was never created\ngit push && cat > body.md \u003c\u003c'EOF' … EOF && gh pr edit 123 --body-file body.md\n```\n\nThe general rule: never bundle a file write with a push or an API call. Keep\nstate-changing steps separable, so a refusal costs you the step and not the\nscaffolding around it.\n\n### When you already have the fix, lead with it\n\nIssue templates order evidence before solution — they are written for reports\nwhere the cause is still unknown. When you arrive with a diagnosis *and* a\npatch, that ordering buries the actionable part under everything that proves it,\nand a maintainer reading top-down never reaches it.\n\nState the fix in the summary, then keep the evidence below it:\n\n```markdown\n### Summary\n\n\u003csymptom, one paragraph>\n\n**The fix is to \u003cX>** — \u003cwhy it is safe>. Diff under [Possible fixes](#possible-fixes)\nbelow; everything in between is the evidence for the diagnosis.\n```\n\n### A revert PR's first fact is whether the break is in a release\n\nBefore arguing that something must be reverted — a regression, a removed class,\na changed default — establish whether the change ever shipped. The answer is one\ncommand, it changes the argument, and it decides whether a backport is part of\nthe work:\n\n```bash\nSHA=\u003cthe merge commit being reverted>\ngit fetch origin --tags\ngit tag --contains \"$SHA\" # empty -> not in any tag\ngh release list --repo \"$OWNER/$REPO\" --limit 5\njq -r '.targetBranchChoices[]' .backportrc.json 2>/dev/null # if the repo backports\ngit branch -r --list \"origin/\u003cthat branch>\" # does it exist?\n```\n\nEach answer writes a different paragraph:\n\n- **In no tag.** Say so, and say what follows from it: merging before the next\n release means no published version ever carried the break, and no backport is\n needed. This is usually the strongest sentence such a PR has, and it is also\n perishable — it names a deadline (the next tag) that a maintainer weighing the\n revert needs to know about.\n- **In a tag.** Name the version the consumer is on today, and say whether the\n revert needs to reach a maintenance branch as well as the default branch. A\n reviewer asks this within one comment of reading \"BC break\"; answering it\n first saves the round-trip.\n- **A backport branch is configured but does not exist.** Say that too — it\n stops the reviewer from looking for one.\n\nObserved 2026-09-18 on phpDocumentor/guides#1397, a revert of a merged\n`[TASK]` PR that had removed a class downstream test suites construct. The\nmerge was three months newer than the latest tag, so nothing was released and\nthe repo's `.backportrc.json` target branch did not exist upstream at all —\ntwo facts that only reached the PR body on the second pass, because nobody had\nasked the question in the first.\n\nKeep the template's sections — reviewers navigate by them — and add the pointer\nrather than reordering them.\n\n## Atomic Commits (Default — No Squash Unless Asked)\n\n**The project default is atomic commits preserved end-to-end.** Squash is destructive: it loses GPG signatures, collapses bisection granularity, and destroys narrative. Never squash unless the user asks for it in this task.\n\n### What \"atomic\" means\n\n- One commit = one self-contained logical change\n- Each commit builds and passes tests independently\n- No \"WIP\", \"fixup\", or \"oops\" commits in final history — rebase them away before merge\n- Mixed changes get split (`git add -p`, `git commit --fixup`, `git rebase --autosquash`)\n\n### Preferred merge strategies (in order)\n\n1. **Rebase + merge commit** (`gh pr merge --merge` after `git rebase origin/main`): linear feature history with an explicit merge point. Preserves signatures. This is the default for Netresearch repos.\n2. **Fast-forward merge** (local `git merge --ff-only`): when signed commits are required AND only rebase is allowed (see \"Signed Commits with Rebase Merge\" below).\n3. **Squash**: only when the user explicitly asks.\n\n### If you catch yourself typing `--squash`\n\nStop. Re-read the task. Did the user say \"squash\"? If not, use `--merge` or `--rebase` (with the signed-commits caveat). The correction \"no squash! atomic commits!\" is a repeat interruption — prevent it by defaulting to merge-commit.\n\n## Reviewing a PR: Read the Standing Review State First\n\nBefore composing review findings — your own or a subagent's — fetch the PR's\nexisting reviews and review threads in the same batch as the diff\n(`gh pr view --json reviews` plus the unresolved-thread GraphQL query, or MCP\n`pull_request_read: get_reviews` / `get_review_comments`). It costs no extra\nround-trip, and the diff alone is not the review context:\n\n- **Dedupe against standing feedback.** A finding another reviewer already\n raised gets referenced or skipped, not re-posted as new. Reposting reads as\n noise to the contributor and hides which comment is the actionable one.\n- **A moved head may BE the response to a prior review.** When the latest\n commits were pushed after a CHANGES_REQUESTED review, new findings are often\n regressions introduced by exactly the changes that review demanded — which\n reframes severity and tone, and the connection is invisible from the diff\n alone.\n- **The merge gate is the standing blocking review, not your new comment.** An\n open CHANGES_REQUESTED review keeps blocking after its points are addressed;\n the next action is that reviewer dismissing or re-reviewing, and a review\n posted without knowing that mis-states what happens next.\n\nObserved (usercentrics-widgets#143): a four-comment review posted straight from\nthe diff duplicated one point of the repo owner's own standing CHANGES_REQUESTED\nreview and missed that the head under review was the contributor's response to\nit — the \"new\" case-sensitivity finding was a regression introduced by that\nreview's requested guards, and the real next step (re-review of the standing\nblocker) surfaced only after posting.\n\n### Feedback sits on three surfaces — query all of them\n\n\"Read the comments on this PR\" is three separate API calls, and each one returns\nan empty list when the feedback lives on another surface:\n\n| Surface | Query | Holds |\n|---|---|---|\n| issue comments | `gh api repos/$R/issues/$PR/comments --paginate` | the plain comment box under the PR |\n| reviews | `gh api repos/$R/pulls/$PR/reviews --paginate` | a review's own body, including `suggestion` blocks written there |\n| review comments | `gh api repos/$R/pulls/$PR/comments --paginate` | the inline comments anchored to diff lines |\n\nA maintainer can put a whole review — prose, reasoning, several `suggestion`\nblocks — into the review **body** rather than onto a diff line. Both other\nqueries then return `[]`, and \"there are no comments on this PR\" is wrong while\na full review is waiting. Observed on\nTYPO3-Documentation/TYPO3CMS-Reference-CoreApi#6992, 2026-09-18: issue comments\n0, review comments 0, and one `COMMENTED` review carrying two suggestions.\n\nTwo consequences for the reply. The committable-suggestion control belongs to a\n**line comment on the Files-changed tab** — GitHub's own instruction is \"to\nsuggest a specific change to the line or lines, click [the suggestion icon],\nthen edit the text within the suggestion block\"\n([Commenting on a pull request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/commenting-on-a-pull-request)).\nA `suggestion` block typed into a review body is therefore ordinary fenced\ncode: the change is made by hand, and \"I committed your suggestion\" mis-states\nwhat happened. And the review body is not a thread, so there is nothing to\nresolve — answer with a PR comment citing the commit SHA.\n\n### Subagent findings: verify line anchors against the diff you fetched\n\nInline comments anchor to diff positions. A file:line pair reported by a review\nsubagent is a claim, not a coordinate — recompute it from the hunk headers\nbefore posting (observed: a reported `:185` pointed at a context `}`; the\nfinding's code sat at 184). A wrong anchor lands the comment on an unrelated\nline or fails the review submission outright.\n\n### A review you post makes claims too: check each against what measured it\n\n\"Point review rounds at the claims\" (above) is about the text of your own pull\nrequest; the same holds for a review of someone else's. A review states facts\nabout the change (\"converts to zero calls\", \"this format is used by versions X\nto Y\", \"the detector then reports a false finding\"), and the author acts on\nthem. Before the `POST`, list every factual sentence in the body and in each\ninline comment next to the command that measured it and the scope it was\nmeasured on: which versions, which files, which sample. A claim measured on a\nsubset is written as that subset.\n\nThe gap this closes is generalising from the part you measured to the whole.\nOn netresearch/retro-skill#168 a review stated a version range that started\nat the wrong version, gave an output format measured on Codex 0.142.x as the\nformat of every version back to 0.38, and described an impact that no run had\nshown. All three were corrected in a follow-up comment after the review had\ngone out; the list would have caught each one before it.\n\n### Posting a review with inline comments, and reading it back\n\nOne `POST` submits the verdict and every inline comment together. Build the\nbody as a JSON file with the Write tool, so no heredoc quoting touches the\nbackticks and fences inside the comments, and check it parses before sending:\n\n```json\n{\n \"commit_id\": \"\u003chead SHA>\",\n \"event\": \"REQUEST_CHANGES\",\n \"body\": \"Summary, one logical line per paragraph.\",\n \"comments\": [\n { \"path\": \"docs/guide.md\", \"line\": 385, \"side\": \"RIGHT\", \"body\": \"...\" },\n { \"path\": \"docs/guide.md\", \"start_line\": 400, \"start_side\": \"RIGHT\",\n \"line\": 403, \"side\": \"RIGHT\",\n \"body\": \"Why.\\n\\n```suggestion\\nreplacement for lines 400 to 403\\n```\" }\n ]\n}\n```\n\n```bash\npython3 -c \"import json,sys; json.load(open(sys.argv[1]))\" review.json &&\n gh api -X POST \"repos/$R/pulls/$PR/reviews\" --input review.json \\\n --jq '.id, .state, .html_url'\n```\n\n- **`line` is the line number in the file at `commit_id`**, with `side: \"RIGHT\"`\n for the new version. Take it from that exact revision\n (`gh api \"repos/$R/contents/\u003cpath>?ref=\u003chead SHA>\" --jq .content | base64 -d | grep -n '\u003cunique line>'`)\n rather than counting through a hunk by hand.\n- **A multi-line comment adds `start_line` and `start_side`**, the first line\n of the range and its side; `line` is then its last. GitHub's reference lists\n `start_side` for multi-line comments; a range posted without it on\n netresearch/concourse-ci-skill#76 was stored with `start_side: \"RIGHT\"`, but\n set it explicitly rather than rely on that. A `suggestion` block in that\n comment's body replaces exactly the lines `start_line..line`, and unlike a\n `suggestion` block in the review body (see above) it is committable. Leave\n `start_line` and `start_side` out for a single-line comment.\n- **`event` is `APPROVE`, `REQUEST_CHANGES` or `COMMENT`.** GitHub refuses the\n first two on a PR you authored, so check `author.login` against\n `gh api user --jq .login` first.\n- **Read the anchors back from `pulls/$PR/comments`, not from\n `pulls/$PR/reviews/\u003cid>/comments`.** The review-scoped endpoint returned\n `line: null` for all four comments of a correctly anchored review, while\n `pulls/$PR/comments` returned 385, 397, 301 and 405 for the same four\n (netresearch/docker-development-skill#97, 2026-09-27). A read-back through\n the review-scoped endpoint reports a working review as unanchored. The same\n holds for ranges: on netresearch/concourse-ci-skill#76 (review 5327789206,\n 2026-09-27) it returned `start_line`, `side` and `start_side` as `null` too,\n with only `position` set, while `pulls/$PR/comments` returned\n `start_line: 1040, line: 1043` for the multi-line comment.\n\n```bash\n# $ID is the review id the POST printed\ngh api \"repos/$R/pulls/$PR/comments\" --paginate \\\n --jq \".[] | select(.pull_request_review_id == $ID)\n | \\\"\\(.start_line) \\(.line) \\(.path) \\(.html_url)\\\"\"\n```\n\n## Review Thread Resolution (SHA Citation Required)\n\n**Never reply with \"Addressed\" or \"Fixed\" without citing the resolving commit SHA.** Review threads are resolved on GitHub's side, not by agent assertion.\n\n### Correct reply pattern\n\n```bash\n# After pushing the fix\nSHA=$(git rev-parse HEAD)\n\ngh api graphql -f query='\n mutation($body: String!, $id: ID!) {\n addPullRequestReviewThreadReply(input: {body: $body, pullRequestReviewThreadId: $id}) {\n comment { id }\n }\n }' \\\n -f body=\"Fixed in ${SHA:0:7} — \u003c1-sentence explanation of what changed and why>.\" \\\n -f id=\"PRRT_xxx\"\n\n# Then resolve the thread\ngh api graphql -f query='mutation { resolveReviewThread(input: {threadId: \"PRRT_xxx\"}) { thread { isResolved } } }'\n```\n\n### Resolving is a claim about the whole ask, not about the file you edited\n\nA finding often names more than one target — two config files, \"the table *and*\nthe examples\", every caller of a symbol. Fixing the one you opened, replying with\nits SHA and resolving reads exactly like completion, and the thread then carries\na green mark that stops anyone looking. Nothing in the API checks the claim.\n\nSo before `resolveReviewThread`, re-read the comment and enumerate the targets it\nnames — by noun, not by impression — and confirm each. A reviewer that writes\n\"update the bootstrap table and all affected enforcement and maturity examples\"\nhas named three things; a commit touching the first two satisfies neither the ask\nnor the thread.\n\nTwo habits make this cheap: quote the ask's own list back in the reply and mark\neach item, and grep for the defect's shape across the repository rather than\nacross the file you happened to open — the second file is usually the one the\nreviewer could see and you could not.\n\nWhere you deliberately do *not* act on part of an ask, say which part and why in\nthe reply. \"Resolved\" with a silent omission is the failure this section exists\nfor; an explicit decline is a legitimate outcome the reviewer can argue with.\n\n### Refusing the lazy pattern\n\nThese replies are banned:\n- `Addressed` (no SHA, no explanation)\n- `Fixed — merged` (merged what? where?)\n- `Done` (done how?)\n- `Good point, updated` (updated what, in which commit?)\n\nEvery resolving reply that changes code must include: commit SHA (7+ chars), one sentence of what changed, one sentence of why if not obvious from the diff. A reply that resolves a thread without a code change — the hunk is kept for a documented reason (next section) — carries no SHA; it names that reason and where it is written instead.\n\n### Before changing a flagged hunk, read why it was written\n\nA reviewer, human or bot, sees the diff, not the reasoning behind it. Before\nchanging a hunk a thread flags, read the PR description and the message of the\ncommit that introduced the hunk (`git log -L\u003cstart>,\u003cend>:\u003cfile>` or\n`git blame` names it). When they state a reason for exactly this change — a\nrule ID such as SonarCloud S1172, a precedent commit, an ADR — answer the\nthread with that reason instead of reverting the change. Change the code only\nwhen the reason does not hold for this hunk, and say so in the reply.\n\nThe reason can hold while the flagged change still breaks something the\nreviewer names — a caller, a test, a behaviour. Then keep the documented\nchange, fix the regression without undoing it, and say both in the reply: why\nthe change stays, and which commit fixes the regression. That reply changes\ncode, so it carries the SHA.\n\nSuch a reply changes no code, so it carries the reason where a fixing reply\ncarries a SHA: *\"Kept on purpose: the PR description removes this parameter\nfor SonarCloud S1172, following f6b647b6, which did the same for\n`setName()`.\"*\n\nOn `netresearch/timetracker#827` CodeRabbit asked to restore the `$day`\nparameter of `Holiday::setDay()`, which the PR description said was removed\nfor S1172 following f6b647b6. Applying the suggestion first cost a commit, a\nrevert and a corrected thread reply.\n\n### Verifying AI-reviewer claims before acting\n\nAI reviewers (GitHub Copilot, Gemini Code Assist, SonarCloud) mix correct findings with confident hallucinations. Before applying **or** declining a review comment, verify its load-bearing factual claim against an authoritative source — the framework/library code, official docs, or a quick local probe — not the reviewer's assertion alone.\n\n- **Applying blindly** ships wrong code (e.g. an edit based on a false API claim, which may also fail your own linter/type-checker).\n- **Declining blindly** dismisses real bugs — the same reviewer is often right about the next comment.\n\nReply citing the evidence either way. When you applied a change, the reply must still carry the commit SHA and the what/why required above (e.g. `Verified against \u003csource>: \u003cfact> — applied in \u003cSHA>, which …`); when you declined, state the source and fact (e.g. `Verified against \u003csource>: \u003cfact> — declining.`). When the suggestion is a code change, run the project's checks (lint, types, tests) on it before resolving, so the reply cites a green result rather than a guess.\n\n**A bot's risk banner is a claim about CI, and CI answers it in one call.** The\nsection above is about review *comments*; the same reviewers also post a summary\ncomment with a merge-risk line and a pre-merge-checks table. That channel opens\nno thread, so nothing forces a reply, and its assertions read as observations\nrather than as claims. They are claims. CodeRabbit on\n`netresearch/t3x-nr-passkeys-be#148` wrote *\"Two CI matrix jobs fail during\ndependency installation before tests run; regenerate the lock file before\nmerging\"* against a head where all 24 matrix cells were green and the repository\ntracks no lock file at all:\n\n```bash\n# What actually concluded on this head — tally before believing a banner\ngh api \"repos/$R/commits/$SHA/check-runs?per_page=100\" --paginate \\\n --jq '[.check_runs[] | .conclusion] | group_by(.) | map({(.[0]//\"pending\"): length}) | add'\ngh api \"repos/$R/commits/$SHA/check-runs?per_page=100\" --paginate \\\n --jq '.check_runs[] | select(.conclusion==\"failure\") | \"\\(.name)\\t\\(.html_url)\"'\n```\n\nThe second call is the one that matters: a `failure` line naming\n`copilot-pull-request-reviewer` is the review quota, not a test job, and a\nbanner that counts it as \"CI failing\" is describing something else. Answer the\nbanner in the pull request with the tally either way — it is what a human reads\nfirst, and an unanswered false claim outlives the merge.\n\n**Intentional SAST findings on test code: dismiss the alert, don't contort the test.** A static-analysis finding (SonarCloud, CodeQL / GitHub Advanced Security) that fires on a *deliberate* test input — an SSRF test hitting `169.254.169.254`, a clear-text `http://…` URL a denial test asserts on, a synthetic secret fixture — is a false positive against the test's intent. Rewriting the test to satisfy the analyzer weakens the very case it exists to prove. Instead **dismiss the alert at its source**, which also clears the blocking `github-advanced-security` review thread that a plain reply cannot resolve:\n\n```bash\n# Find the alert number for the flagged file/rule, then dismiss:\ngh api repos/$R/code-scanning/alerts --jq '.[]? | {number, rule: .rule.id, path: .most_recent_instance.location.path}'\ngh api repos/$R/code-scanning/alerts/$N -X PATCH \\\n -f state=dismissed -f dismissed_reason='used in tests' \\\n -f dismissed_comment='Intentional test input — \u003cone line why>.'\n```\n\n`dismissed_reason` is one of `false positive` / `won't fix` / `used in tests`; use `used in tests` for deliberate test inputs. SonarCloud has the equivalent \"Won't fix / Safe\" transition in its UI (auto-analysis ignores `sonar.issue.ignore.*`, so mark it there, not in config). Reply to the thread citing the dismissal, then resolve it.\n\n**A GitHub dismissal of a SonarCloud-originated alert reaches SonarCloud.** For an issue, the PATCH above is enough, and the SonarCloud UI step is not needed. Measured 2026-10-22: dismissing code-scanning alert #93 of `netresearch/jira-to-openproject` (`shell:S5332`, SonarCloud type VULNERABILITY) as `false positive` turned SonarCloud issue `AZ-AJskE9KJrCoCmTo-1` from `OPEN` to `RESOLVED` / `FALSE-POSITIVE` in `api/issues/search?issues=\u003ckey>` about 20 seconds later. Dismissing two `docker:S8541` alerts on a pull request the same way flipped that pull request's quality gate (`new_security_rating`) to OK. Limits: measured for issues, not for security hotspots, and only in the direction GitHub to SonarCloud. Read the issue back with `api/issues/search` before relying on it.\n\n**A SAST finding you actually fixed leaves its thread open too — and that thread still blocks the merge.** The case above is the *false positive*. The opposite case looks identical from `mergeStateStatus` and must not be handled the same way: you push a real fix, the analyzer re-scans and closes the issue, the quality gate flips to green — and the `github-advanced-security` review thread stays open, holding the PR at `BLOCKED` with zero failing checks and nothing obvious to fix. The thread is a snapshot of the commit it was posted against; a re-scan does not retract it.\n\nBefore assuming either case, ask the analyzer which one you are in:\n\n```bash\n# SonarCloud: is this issue actually resolved on the PR?\n# Header auth, not `curl -u` — keeps the token out of the process list, and\n# secret scanners flag the `-u` form on sight.\ncurl -s -H \"Authorization: Bearer $SONAR_TOKEN\" \\\n \"https://sonarcloud.io/api/issues/search?issues=$KEY&pullRequest=$PR&componentKeys=$PROJECT\" \\\n | jq '.issues[] | {rule, status, resolution}'\n# -> {\"status\":\"CLOSED\",\"resolution\":\"FIXED\"} = you fixed it\n# -> {\"status\":\"OPEN\"} = not fixed; fix or dismiss\n```\n\nThe issue key is embedded in the thread body as `\u003c!--SONAR_ISSUE_KEY:...-->`. For CodeQL, `gh api repos/$R/code-scanning/alerts/$N --jq '{state, fixed_at, dismissed_reason}'` answers the same question.\n\nOn `CLOSED / FIXED`, treat it as an ordinary already-fixed bot thread: reply with the fixing SHA and resolve. **Do not dismiss the alert** — dismissal records \"we decided not to act\" on a finding you did act on, which is a false audit trail and hides the rule from firing again. Dismissal is only for the intentional-finding case above.\n\nObserved 2026-07-30 on a `docker:S8544` finding: gate `OK`, 0 open issues, 0 failing checks, `mergeStateStatus: BLOCKED` on one stale thread — a merge that looked inexplicably stuck until the thread was read.\n\n### `gh pr update-branch` re-writes the head UNSIGNED\n\nBoth forms (merge and `--rebase`) create the new commit server-side, signed by\nnobody. In a repo that requires signed commits — including a requirement\nliving in **classic branch protection**, which neither the rulesets endpoint\nnor a non-admin protection query can see — the PR then sits at\n`mergeStateStatus: BLOCKED` with every visible gate green (observed on a PR\nthat reported request-review for an hour while the real blocker was the\nsignature). Rebase locally instead: signing is wired into git, so a plain\n`git rebase origin/main` (or `git commit --amend --no-edit` when only the\nsignature is missing) re-signs, then push with `--force-with-lease`. Two\ntraps in that push: a checkout created from `FETCH_HEAD` has no lease\nbaseline and fails with `stale info` — pass the lease explicitly as\n`--force-with-lease=\u003cbranch>:\u003cremote-sha>`; and that remote SHA must be\n**measured** (`git ls-remote origin \u003cbranch>`), never retyped from memory.\n\n### Signature verification: the GitHub API is the source of truth, not your keyring\n\nFor \"is this commit signed?\" in a review, ask the API:\n\n```bash\ngh api repos/OWNER/REPO/commits/SHA --jq '.commit.verification' # verified: true|false, reason\n```\n\nLocal `git log --show-signature` / `--format='%G?'` only consults the reviewer's local keyring — a status of `E` means \"key not imported here\", NOT \"unsigned\". Flagging `%G? = E` as a missing signature produces a false finding the contributor cannot fix (one such finding had to be publicly retracted; GitHub showed \"verified\" all along).\n\n### A systemic failure mode gets fixed centrally — not per-symptom plus a follow-up issue\n\nWhen a PR fixes one code path of a failure that can occur on every path (one endpoint catching a session-expiry redirect while all endpoints share the failure mode), the review recommendation is NOT \"keep it narrowly scoped + open a follow-up for centralization\". Rework the fix at the central layer (session hook, base class, middleware) — the symptom-fix then becomes unnecessary or trivial. Opening a follow-up issue \"for the real fix later\" is the smell that the current PR sits in the wrong place: it cements the wrong location, ships a known-imperfect fix, and adds tracking overhead to undo. The only accepted split: the central fix has a hard external dependency that genuinely cannot land in the same PR.\n\n### Triage findings one by one — batched \"out of scope\" hides cheap fits\n\nWhen several reviewers (or parallel review agents) return a list of findings, do not sort them into \"applied\" vs \"out of scope\" as a batch. Fit-check each finding individually first:\n\n1. **Does it require a code change at all?** \"Document the invariant\" / \"explain the discarded return\" is a comment-only addition — it fits in any PR that touches the code it explains.\n2. **If it needs code: is it under ~10 lines and inside already-touched files?** Then it fits.\n3. **Is there a doc-only alternative?** A comment documenting the trade-off often satisfies the concern at near-zero cost.\n\nDefault to \"fits\" for comment- or docstring-only suggestions; reserve \"out of scope\" for real surgery (interface changes, cross-file refactors, unrelated pre-existing bugs). In one 12-finding round, batching hid two one-line doc-comment additions among genuinely out-of-scope refactors — the reviewer asked \"none of them fit?\" and the re-examination cost an extra round-trip.\n\nWhen you do defer a finding, \"filed as follow-up\" is a claim of action: file the issue in the same turn and quote its URL in the summary, or ask explicitly whether to file — never write \"will file\" / \"tracked separately\" without the link in the same paragraph.\n\nA scope word in a comment has a weaker and a stronger reading, and the stronger one is the expensive mistake. \"Concentrate on\", \"focus on\", \"less on\" and \"edge case\" can mean *shorten or move down* or *remove*; when removing would delete documentation or behaviour that still exists, apply the weaker reading or ask the author (or the user) first, and name the reading you chose in the thread reply before resolving the thread. On TYPO3-Documentation/TYPO3CMS-Guide-HowToDocument#571 (2026-09-26) the reviewer wrote \"I would concentrate on linking the core changelog, everything else is real edge cases\"; the response deleted the documentation of three forms that are still supported — links into other manuals, the local `#anchor` form and custom link text — and the user had to ask whether the feature documentation was gone.\n\n### A suggestion against verbatim material is a suggestion to falsify it\n\nReviewers read a diff, not its provenance. When a comment proposes tidying something\nthat was **copied verbatim** — a captured transcript, a quoted log line, an error\nstring, a fixture recorded from a real system — check the source of truth before\ntouching it. Improving such a line does not improve the artifact; it turns evidence\ninto an approximation, and quietly breaks anyone who greps their own logs for the\nstring as it is actually emitted.\n\n**Real case:** a review asked to correct \"occured\" to \"occurred\" in a before/after\ntranscript. The misspelling belonged to the server under discussion:\n\n```php\n// ter_rest/Classes/Http/RouteHandler.php:125, quoted as it stands\n: $this->responseFactory->createErrorResponse($request, 1603956982, 'An error occured on handling the request.');\n```\n\nDeclined, citing that line. The reply matters as much as the decision — a bare \"no\"\nreads as resistance, while the quoted source makes the reason checkable in seconds.\n\nThe general form: before editing any line, know whether you are its author or its\nwitness. Prose you wrote is yours to improve; recorded output is not.\n\n### Check whether the literal fix is complete before applying it\n\nA correct diagnosis does not guarantee a sufficient remedy. A reviewer sees the line\nthey commented on, not the rest of the mechanism — so applying the suggestion exactly\nas written can leave a half-change that is worse than the original.\n\n**Real case:** a comment correctly objected to `time()` in a test-setup example as\nnon-deterministic and asked for a fixed instant. Applying that to the shown line alone\nwould have frozen the clock the code reads while the fixtures around it still came\nfrom `time()` — putting the two years apart and failing every assertion as expired. A\nrare flake would have become a certain failure. The fix that works pins **one**\nconstant and drives both from it.\n\nTrace the suggested change through the code it touches before committing to it. When\nit turns out to be incomplete, say so in the reply and describe what you did instead —\nthat is the part a reviewer cannot see, and the reason to prefer it over the literal\nreading.\n\n### AI-authored commits on the branch are untrusted\n\nDistinct from a review *comment*: Copilot **Autofix**, Gemini \"apply suggestion\", and similar bot-authored **commits already pushed onto the PR branch** are patches, not settled work — they can carry real bugs, and they arrive looking done. In one `/pr-finish` run the autofix commits had (a) deleted a variable's initialization while keeping the line that reads it — an `UnboundLocalError` on every non-account query — and (b) added an unbounded `while True` pagination loop that later OOM-crashed the machine (see the *Cap memory when the fix activates or relies on a loop* bullet under *Fixing the failure*). Treat every bot commit like an untrusted patch:\n\n- **Read its net diff against your last human commit**, not just the headline — `git diff \u003cyour-last-sha> HEAD -- \u003cfile>` (or `git show \u003cbot-commit-sha>` for one specific commit). The fix a bot \"applied\" often removes or rewrites more than the comment implied.\n- **Squash them into the atomic feature commit and re-run the FULL suite** — not only the check that was failing. A green \"the failing check now passes\" does not clear a bug the bot introduced *elsewhere*; only the whole suite does (this is the *Verify the activated code path* rule — a bot fix can make dead code live).\n- Their missing `Signed-off-by`/signature is also why the DCO / signed-commits gate fails; squashing under your own signed, signed-off commit fixes correctness and the gate in one step.\n\n### Minimizing bot-review rounds (collapse the ping-pong)\n\nOn a repo with an incremental AI-reviewer ruleset (`copilot_code_review`, Gemini), **every push re-triggers a fresh required review round** that re-BLOCKs the PR — and AI reviewers surface *semantic* nits a linter never catches (heading structure, code-wrap conventions, cross-reference/notation consistency). Pushing one fix per comment turns this into 3+ rounds of request → wait (minutes each) → re-block. Collapse it:\n\n- **Semantic self-review before the first push.** Lint/markdownlint passing is not enough — re-read the diff for the convention nits an AI reviewer will flag, and fix them pre-emptively.\n- **Batch all review fixes into ONE push**, not per-comment. Each push restarts the round; one push = one new round.\n- **Pre-empt the recurring code-quality nit classes** — AI reviewers reliably re-flag the same gaps, so fixing them *before* the first push removes whole rounds. On new code, self-check: **bound every paginate-until-metadata loop** with a hard cap that raises (never trust the response to signal \"last page\"); **coerce external-payload fields to the expected type** before downstream use (a field documented as an object can arrive as a string — guarantee the shape, don't assume it); **availability probes treat 5xx and 401/403 as \"unavailable,\" not only 404** (else an outage/auth failure selects the backend and dies on the real call); **add a test for every new code path** (an untested new path is both a coverage nit and where the worst bugs hide).\n\nExpect 2–3 rounds even so; the loop *mechanics* (wait for the bot to review the latest head SHA, never merge over an in-flight re-review — see *Merge Gate*) still apply. This tactic reduces the **number** of rounds, not how you survive each one.\n\n### Verifying thread state from GitHub, not memory\n\nBefore declaring a PR review-complete, re-fetch thread state from GitHub. Never trust your own belief about what you resolved:\n\n```bash\ngh api graphql -f query='\n query($owner: String!, $repo: String!, $pr: Int!) {\n repository(owner: $owner, name: $repo) {\n pullRequest(number: $pr) {\n reviewThreads(first: 100) {\n nodes { id isResolved comments(first: 1) { nodes { body author { login } } } }\n }\n }\n }\n }' -f owner=OWNER -f repo=REPO -F pr=NUMBER \\\n | jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false) | {id, first_comment: .comments.nodes[0].body[:80]}'\n```\n\nIf that returns any rows, the PR is not merge-ready.\n\n#### Reading a bot body: strip the `\u003cdetails>` blocks, never split on them\n\n`body[:80]` above tells threads apart; it does not let you read one. CodeRabbit\nopens every comment with a 54-character severity header (`_🎯 Functional\nCorrectness_ | _🟡 Minor_ | _⚡ Quick win_`), so 80 characters buy the header\nplus about twenty of the title.\n\nSplitting on the tag is the tempting fix and it fails silently, because the\nblocks appear on **both sides** of the finding: \"Supported by static analysis\"\nand web-search evidence before it, \"Committable suggestion\" and \"Prompt for AI\nAgents\" after it. Over 18 CodeRabbit comments in one session, 7 had a block\nbefore the finding — so `split(\"\u003cdetails>\")[0]` returns the bare severity header\nabout a third of the time, with nothing to say which third. `split(\"\u003c/details>\")[-1]`\nis worse: it returns the trailing `\u003c!-- fingerprinting -->` comments.\n\nRemove the blocks non-greedily across newlines instead, then the HTML comments:\n\n```python\nimport re\n\nbody = re.sub(r\"\u003cdetails>.*?\u003c/details>\", \"\", raw, flags=re.S) # re.S load-bearing\nbody = re.sub(r\"\u003c!--.*?-->\", \"\", body, flags=re.S)\nbody = re.sub(r\"\\n{3,}\", \"\\n\\n\", body).strip()\n```\n\nThis holds for any reviewer that folds evidence into `\u003cdetails>`. Read the\nfinding, not the wrapper, before deciding whether a thread is actionable — and\nbefore replying to it.\n\n### Wait for the async re-review before trusting `unresolved == 0`\n\n`unresolved == 0` is **not** merge-ready if you sampled it right after a push.\nGitHub Copilot (and Gemini, and similar bot reviewers) re-review the PR\n**asynchronously** — typically 1–2 minutes after each push — and each round can\npost entirely **new** review threads against the fresh head. Reasoning about\nmerge-readiness on a zero count that predates the bot's re-review produces a\npremature \"threads clear\" call; the bot then lands more valid findings a minute\nlater. In one session, sampling too early gave a false all-clear **twice**, and\nthe bot posted five more legitimate findings on each following round.\n\nSo don't check thread count first — **check that the bot has actually reviewed\nthe current head SHA first**, then re-check threads. Poll until a bot review\nwhose `commit_id` equals the PR head has landed:\n\n```bash\nHEAD=$(gh pr view \"$PR\" --repo \"$R\" --json headRefOid --jq .headRefOid)\n# A Copilot review keyed to the current head must exist before you trust the count.\nSEEN=$(gh api \"repos/$R/pulls/$PR/reviews\" \\\n --jq \"[.[]? | select(.user?.login? // \\\"\\\" | test(\\\"copilot\\\";\\\"i\\\")) | select((.commit_id? // \\\"\\\")==\\\"$HEAD\\\")] | length\")\n[ \"${SEEN:-0}\" -ge 1 ] || { echo \"bot has not re-reviewed head $HEAD yet — keep polling\"; }\n```\n\nOnly once `SEEN >= 1` is the unresolved-threads query above meaningful. This is\nthe same \"review the latest head SHA\" gate the [Merge-Gate Watcher](merge-gate-watcher.md)\nenforces — apply it here too, before ever declaring the review done.\n\n## A Review Bot Can Be Older Than Your Toolchain\n\nWhen a PR raises a toolchain or language version, some of what it enables is\nnewer than what the reviewing bot knows, and the bot can report those constructs\nas **compile errors** with a suggested fix that undoes the upgrade. Observed\nonce, on a Go 1.27 promoted-field literal: two `Critical` findings on one\n10-line diff, whose suggestion would have removed the feature the PR existed to\nadopt. Which component produced such a finding is not observable from outside —\ntreat the pattern as the thing to recognise, not the mechanism.\n\nThe tell is a finding that contradicts an artefact you already have. So do not\nreach for the editor. Answer with evidence and resolve:\n\n1. **Name the run that disproves it — after checking it says what you need.**\n Two preconditions, both cheap and both easy to skip. The thread is a snapshot\n of the commit it was posted against, so confirm that commit is the head the\n run built; a stale thread is refuted by nothing. And confirm the run actually\n compiled the affected target with the toolchain the repository declares — a\n required check can be green without building the package in question, which\n is the same trap as treating a suite's existence as proof it runs.\n2. **Add the version probe.** Build the same construct under the old and the new\n language version and paste both outputs — a compiler that says\n `requires go1.27 or later` (or the equivalent) settles it in one line, and\n makes the thread useful to a human reader later.\n3. **Say which half of the suggestion is already true**, where one is. Bots\n often bundle a correct observation with a wrong diagnosis, and acknowledging\n the correct part is what stops the next round re-raising it.\n\nThen resolve the thread, where the thread is one a reply can resolve — some\nclasses are not (see the note on `github-advanced-security` above), and resolving\ndoes not clear a `CHANGES_REQUESTED` review, which still needs a re-review on the\nnew head. This is one of the few cases where \"not applying this\" is the right\noutcome on a `Critical` finding, so the reply has to carry the evidence — a bare\n\"false positive\" leaves the next reader with two claims and no way to choose.\n\nThe same shape appears without a version bump whenever a repository is ahead of\nthe bot: a new stdlib package, a new builtin, a lint rule the project adopted\ndeliberately. Check the artefact before the argument.\n\n## Merge Strategies\n\n### Merge Commit\n\n```bash\n# Creates a merge commit, preserves all history\ngit checkout main\ngit merge --no-ff feature/my-feature\n\n# Result:\n# * Merge branch 'feature/my-feature'\n# |\\\n# | * feat: add feature part 2\n# | * feat: add feature part 1\n# |/\n# * Previous main commit\n```\n\n**Use when:**\n- Want to preserve complete branch history\n- Complex features with meaningful intermediate commits\n- Audit trail required\n\n### Squash and Merge\n\n```bash\n# Combines all commits into one\ngit checkout main\ngit merge --squash feature/my-feature\ngit commit -m \"feat: complete feature implementation\"\n\n# Result:\n# * feat: complete feature implementation\n# * Previous main commit\n```\n\n**Use when:**\n- Feature branch has messy history\n- WIP commits, fixups, \"oops\" commits\n- Want clean linear history\n\n### Rebase and Merge\n\n```bash\n# Replays commits on top of main\ngit checkout feature/my-feature\ngit rebase main\ngit checkout main\ngit merge --ff-only feature/my-feature\n\n# Result:\n# * feat: add feature part 2\n# * feat: add feature part 1\n# * Previous main commit\n```\n\n**Use when:**\n- Clean commit history in feature branch\n- Each commit is meaningful and tested\n- Want linear history without merge commits\n\n### Comparison\n\n| Strategy | History | Complexity | Traceability |\n|----------|---------|------------|--------------|\n| Merge | Preserved | High | High |\n| Squash | Combined | Low | Medium |\n| Rebase | Linear | Low | Medium |\n\n## Merging Divergent Upstream History (Forks)\n\nCatching a fork up with its upstream looks like a merge-strategy question. It is\nmostly a **scope** question, and four traps sit between the two.\n\n### \"Merge\" is a constraint on history rewriting — not an instruction to import everything\n\nWhen a maintainer rejects a rebase because *\"rebasing would break our releases\"*\nand says **\"we need to merge\"**, the load-bearing word is not *merge* — it is\n*don't rewrite the SHAs our releases point at*. Merge is one mechanism that\nsatisfies that; **cherry-pick satisfies it too**, and so does doing nothing.\n\nEstablish the **net delta before choosing the mechanism**, and say it out loud:\n\n```bash\nUPSTREAM=hashicorp/some-project # the repo you forked\nFORK=your-org/some-project # your fork\n\ngit fetch upstream\ngit log --oneline origin/main..upstream/main | wc -l # what we would gain\ngh api \"repos/$UPSTREAM/compare/main...${FORK%%/*}:main\" --jq '{ahead: .ahead_by, behind: .behind_by}'\n# Where the conflict surface actually lives — often one directory dominates\ngh api \"repos/$UPSTREAM/compare/main...${FORK%%/*}:main?per_page=100\" \\\n --jq '[.files[].filename | split(\"/\")[0]] | group_by(.) | map({dir: .[0], n: length}) | sort_by(-.n) | .[0:5]'\n```\n\nFor *what we add*, prefer `git cherry` over `git log`: it compares by **patch-id**,\nso a change of yours that upstream already carries under a different SHA is\ncorrectly reported as already-there. `git log upstream/main..origin/main` counts it\nas yours and overstates the delta.\n\n```bash\ngit cherry -v upstream/main origin/main | grep -c '^+' # genuinely ours\ngit cherry -v upstream/main origin/main | grep '^-' # already upstream, other SHA\n```\n\nThis is not hypothetical: on the fork below, `git log` reported 27 commits while\n`git cherry` reported 26 — the difference being the fork's own **re-authored port**\nof an upstream fix, which `git cherry` matched to the upstream original despite a\ndifferent SHA, author, *and* commit message.\n\nIf the valuable delta is a handful of commits — or one typo fix — a merge of the\nfull history buys you every conflict and every unsigned commit in that history to\ndeliver it. Cherry-pick the delta instead; the SHA-preservation constraint is met\neither way.\n\n**Real case:** a fork 22 ahead / 11 behind an **archived** upstream. The merge\nproduced 548 conflicts and 11 DCO-breaking commits; the entire net gain was a\ntwo-line typo fix (the other 10 commits were vendor churn, the upstream's own\nrelease CI, and dependency bumps the fork had already surpassed). The delta had\nbeen measured *before* the merge and the merge was run anyway. The maintainer's\ncorrection — *\"if the typo is the only change, pull in the typo, nothing more\"* —\nwas the whole job.\n\n**Tell:** you are resolving conflicts in files your fork deliberately diverged on\n(vendor trees, CI, templates) to obtain something you could name in one sentence.\n\n### Resolve the repo's allowed merge methods *before* authoring a merge commit\n\nA repository that permits **only rebase-merge** cannot land a merge commit: `--rebase`\nreplays the branch and **flattens the merge**, rewriting exactly the SHAs the merge\nexisted to preserve. Discovering this at merge time means the work was mis-shaped from\nthe start.\n\n```bash\ngh api \"repos/$OWNER/$REPO\" --jq '{allow_merge_commit, allow_rebase_merge, allow_squash_merge, merge_commit_title}'\n```\n\nRead the settings, never the history. A `(#N)` suffix on a subject on `main`\ndoes not mean the repo squashes: with `merge_commit_title: PR_TITLE` a plain\nmerge commit is titled `\u003cPR title> (#N)`, with `MERGE_MESSAGE` it is\n`Merge pull request #N from …`. On `netresearch/typo3-testing-skill`\n(`allow_squash_merge: false`) every `chore(release): vX.Y.Z (#N)` subject is a\ntwo-parent merge commit. The parent count (`git log --merges`) is what tells a\nmerge from a squash, not the subject.\n\nRun it **before** you build the merge, not at step \"merge\". If merge commits are\ndisabled but a true merge is required, the options are: enable `allow_merge_commit`\n(a repo-policy change affecting every future PR), a local fast-forward push (see\n*Signed Commits with Rebase Merge* — `main` can fast-forward to the merge commit\nwhen its first parent is `main`'s head), or a different mechanism entirely.\n\n### Conflicts are not the whole merge — check clean ADDs under a deleted path\n\n`git merge` only reports conflicts for paths **both sides touched**. Files the other\nside **added** that your side never had merge **silently, with no conflict** — so if\nyour fork *deleted* a directory upstream still maintains, resolving every conflict\nstill leaves you re-importing it.\n\n```bash\n# 545 conflicts resolved... and 252 files quietly staged as clean additions\ngit status --porcelain | grep '^A' | grep ' vendor/' | wc -l\ngit diff --cached --name-only -- vendor | wc -l\n```\n\n**Real case:** a fork that had run `chore: unvendor` merged an upstream that still\nvendors. 545 paths conflicted `DU` (deleted by us / modified by them) — and **252\nmore merged cleanly as additions**, because upstream's vendor upgrade had *added*\nfiles the fork never carried. Resolving the conflicts alone would have silently\nre-vendored the project and reverted the unvendoring, with a green merge.\n\nAfter any merge involving a path one side removed:\n\n```bash\ngit rm -rfq --ignore-unmatch -- \u003cpath> # plain `git rm` refuses when the index has staged changes\ngit ls-files -- \u003cpath> | wc -l # must be 0\n```\n\n### A clean auto-merge can drop lines neither side deleted\n\nThe previous section is about files. The same silence applies *inside* a file: where\nboth sides rewrote overlapping regions, git may resolve the hunk in one side's favour\nand report nothing. Entries the other side had are then simply gone, with no conflict\nmarker to notice.\n\n```bash\n# After every merge, read what came out of the files both sides touched\ngit diff --numstat HEAD -- composer.json package.json # unexpected churn?\ngit show HEAD:composer.json | jq -S '.[\"require-dev\"]' > /tmp/before.json\njq -S '.[\"require-dev\"]' composer.json | diff /tmp/before.json -\n```\n\n**Real case:** a merge of an 82-commit `develop` into a long-lived branch resolved\n`composer.json` without conflict — and dropped two `require-dev` entries the branch had\nadded. One of them registered a PHPStan extension, so the analysis would have run\nagainst a baseline generated *with* that extension while no longer loading it. Nothing\nin the merge output mentioned either package.\n\n### Choosing a side resolves the signature, not the body\n\nWhen several conflicts share a shape, it is tempting to resolve them with one rule —\n\"their change is a subset of ours, take ours\". The rule is about the *conflicting lines*;\nwhether it holds depends on code that is **not** in the conflict.\n\nThe recurring trap is a parameter whose nullability changed:\n\n```php\n// theirs — a minimal deprecation fix\npublic function f(?int $max = null)\n// ours — went further, and looks like a superset\npublic function f(int $max = 0)\n\n// …but the body, untouched and outside the conflict:\n$max = (int)($max ?? end($allLtsVersions) ?: 0); // ?? is now dead, $max stays 0\n```\n\n**Real case:** six conflicts of exactly this shape, resolved with one rule. Four were\nright. In the other two the branch had tightened `?Type $x = null` to a non-null\ndefault while the body still tested for null — one silently returned an empty version\nrange, the other resolved a path to `/` instead of the repository root. Both had been\nfailing for months; the merge preserved them.\n\nAfter resolving by side-selection, read the body of every function whose signature you\njust decided on, and check the callers of any method whose parameters were reordered —\npositional call sites do not conflict and do not warn.\n\n### DCO and third-party history are structurally incompatible\n\nA DCO check requires every commit to carry a `Signed-off-by` **matching its author**.\nUpstream's commits carry none, and **you cannot sign off on someone else's authorship**\n— sign-off is a declaration about work you have the right to submit. So *any* fork\nmerging *any* third-party history fails DCO by construction. This is not a mistake to\nfix; it is a property of the operation.\n\nDo **not** follow the DCO bot's own advice here. It suggests `git rebase HEAD~N --signoff`,\nwhich rewrites the upstream commits and flattens the merge — destroying the ancestry the\nmerge existed to record, and forging sign-offs on other people's commits.\n\nReal options, in order:\n\n1. **Don't merge the history — port the change.** Cherry-pick, then re-author under your\n own sign-off, crediting the original in the message. `git cherry-pick -x` keeps the\n original author and therefore still fails DCO; `git commit --amend --reset-author -S --signoff`\n makes it your commit, which is honest for a two-line port and passes the gate:\n\n ```bash\n git cherry-pick -x \u003cupstream-sha>\n git commit --amend --reset-author -S --signoff # message credits upstream \u003csha> + author\n ```\n2. **Check whether DCO is actually required** before treating it as a blocker —\n `gh api repos/$R/branches/$BASE/protection --jq '.required_status_checks.contexts'`.\n A red-but-not-required DCO is a policy call, not a gate.\n3. **Third-party remediation** via `.github/dco.yml` (`allowRemediationCommits: {thirdParty: true}`)\n — a legal declaration on someone else's work. A human decides that, never an agent.\n\n## Automated Checks\n\n### GitHub Actions for PRs\n\n```yaml\n# .github/workflows/pr-checks.yml\nname: PR Checks\n\non:\n pull_request:\n branches: [main, develop]\n\njobs:\n lint:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-node@v4\n with:\n node-version: '20'\n - run: npm ci\n - run: npm run lint\n\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-node@v4\n with:\n node-version: '20'\n - run: npm ci\n - run: npm test -- --coverage\n\n build:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-node@v4\n with:\n node-version: '20'\n - run: npm ci\n - run: npm run build\n\n pr-size:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n with:\n fetch-depth: 0\n - name: Check PR size\n run: |\n ADDITIONS=$(gh pr view ${{ github.event.pull_request.number }} --json additions -q '.additions')\n if [ \"$ADDITIONS\" -gt 1000 ]; then\n echo \"::warning::Large PR detected ($ADDITIONS lines). Consider splitting.\"\n fi\n env:\n GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n```\n\n### Required Status Checks\n\n```yaml\n# Branch protection settings\nrequired_status_checks:\n strict: true\n contexts:\n - lint\n - test\n - build\n - security-scan\n```\n\n### CODEOWNERS\n\n```bash\n# .github/CODEOWNERS\n\n# Default owners for everything\n* @default-team\n\n# Frontend owners\n/src/components/ @frontend-team\n/src/styles/ @frontend-team @design-team\n\n# Backend owners\n/src/api/ @backend-team\n/src/database/ @backend-team @dba-team\n\n# DevOps owners\n/.github/ @devops-team\n/docker/ @devops-team\n/terraform/ @devops-team\n\n# Documentation\n/docs/ @docs-team\n*.md @docs-team\n\n# Security-sensitive files\n/src/auth/ @security-team @backend-team\n/src/crypto/ @security-team\n```\n\n## Contributing to a Repo You Do Not Own\n\nBefore opening the PR, run the target repo's own contribution gate — all of it, from its `CONTRIBUTING.md`, not the subset that resembles your usual one. Their checklist is the review contract, and the items that look like boilerplate are the ones that fail.\n\n```bash\ngh api repos/OWNER/REPO/contents/CONTRIBUTING.md --jq '.content' | base64 -d\n```\n\nThen actually run each item. Two that are routinely skipped and routinely red:\n\n- **The whole-tree analyzer, not the package you touched.** `staticcheck ./...`, `go vet ./...`, `phpstan analyse` at the configured level — scoping it to your directory hides failures your change caused elsewhere.\n- **The spell checker.** Repos running cspell in CI usually set `incremental_files_only: true`, so it checks exactly the files your PR touched — a domain term absent from the project dictionary fails the build on a doc-only change. Run it before pushing, and add the word to the project word list using the repo's own tooling rather than rewording to dodge it.\n\nCheck `SECURITY.md` before filing anything security-flavoured as a public issue. Many projects route vulnerabilities to a private address, so a public issue is both wrong and unwelcome — and this applies to a finding you were going to *mention* in a PR body too.\n\nMatch the repo's commit conventions exactly: Conventional Commits type and scope, sign-off, signature, and any attribution trailer their `CONTRIBUTING.md` requires. A commit-message linter is a pre-commit hook in many repos and will reject the message locally before CI ever sees it.\n\n## PR Lifecycle\n\n### States\n\n```\nDraft → Ready for Review → Changes Requested → Approved → Merged\n ↑______________|↑_____________________|\n```\n\n**Draft is a state the PR returns to, not one it only starts in.** Opening every PR with `--draft` is the well-known half. The half that gets missed: when work resumes on a PR that is already \"ready for review\" — a rebase, a round of review fixes, another commit of any kind — convert it back *before the first push*, with `gh pr ready --undo \u003cn>` (`glab mr update \u003ciid> --draft`). Mark it ready again as a separate step, once the checks are green and the user has asked for it. Watching the parked PR is compatible with this: `pr-status.sh --watch` holds through the draft while checks run and returns on the first real event — a red check, an open thread — or with `NEXT: ready` once nothing is running any more (with `--ignore-action ready` that same-poll return is labelled `SETTLED` instead); it does not need the PR to leave draft first (#228).\n\nThe reason is what \"ready for review\" tells everyone else. It is a standing request for a maintainer's time against a specific head, and mid-work heads do not deserve it: between the first fix commit and the last one, the PR advertises for review a state you already know is incomplete — sometimes one you know is broken, when the work is a response to a reviewer's finding. Reviewers who look during that window spend attention on a diff that is about to change, and a green CI run on an intermediate head reads as an endorsement of work that is not finished. The round-trip is two commands.\n\nThe tell that this was skipped is a PR whose recent commits are labelled as review fixes while it stayed out of draft throughout. Check before you start, not after — note that `gh pr view --json` spells the field `isDraft`, while the REST payload and `gh api` call it `draft`:\n\n```bash\ngh pr view \"$PR\" --json isDraft,headRefName,state --jq '{isDraft,headRefName,state}'\n```\n\n**That state can change from outside your session, and `isDraft` does not say who changed it.** The rule above tells you to convert a ready PR back to draft before your first push — apply it to work *you* resumed, not to a state somebody else just set deliberately. On a shared account the two are indistinguishable in `gh pr view`: the field reads `false` either way, and the account name on the event is yours in both cases. The timeline separates them, and it also dates the change against your own pushes:\n\n```bash\ngh api \"repos/$OWNER/$REPO/issues/$PR/timeline\" --jq \\\n '.[] | select(.event|test(\"ready_for_review|convert_to_draft|head_ref_force_pushed\"))\n | \"\\(.created_at) \\(.event) \\(.actor.login // \"-\")\"'\n```\n\nA `ready_for_review` that is *newer* than your last push, or that sits beside a comment you did not write, was a person's decision. Report it and ask; converting it back silently undoes a deliberate act, and the PR author is the one who will be asked why their PR went back into draft. Observed 2026-09-18 on phpDocumentor/guides#1397: the human marked it ready and commented on it at 15:13–15:14Z, a rebase then force-pushed at 15:18Z and 15:20Z, and the agent noticed the state only when an unrelated `gh pr view` printed `isDraft: false`.\n\n**The Draft → Ready edge only exists if the workflow listens for it.** `ready_for_review` is not in the `pull_request` default type set (`opened`, `synchronize`, `reopened`). A workflow declaring a bare `pull_request:` therefore never runs on that transition. Where such a workflow carries the auto-approval — typically a `pr-quality` job gated on `github.event.pull_request.draft == false` — the job skips while the PR is a draft and **nothing re-runs it when the draft is lifted**. The PR then sits at `reviewDecision: REVIEW_REQUIRED` with nothing red and no pending job, which reads as \"waiting for a human\" and is really \"waiting for an event that will never come\".\n\n```yaml\non:\n pull_request:\n types: [opened, synchronize, reopened, ready_for_review]\n```\n\n**Diagnose it by absence, not by the red.** There is no failing check to find. Compare the auto-approve job's runs against the PR's events: if it ran once at `opened` and skipped, and no run exists for the ready transition, this is the cause — not the Copilot review quota, which is usually also true at that moment and is not why the PR is stuck.\n\n**The opposite face: a workflow that does fire on `ready_for_review` but skips a job there.** The transition then starts a *second* run of the same workflow on the same head, and a required context produced by a job that run skips is reported only by the older run. GitHub shows it as \"Expected — Waiting for status to be reported\" and keeps the PR `BLOCKED`, although a green check run of that name sits on the commit. Observed on netresearch/t3x-nr-image-optimize#207: `Checks` ran as #212 (with `security / Composer Audit`) and as #213 for `ready_for_review`, where the calling job had `if: github.event.action != 'ready_for_review'`. Re-running the job inside #212 did not clear the block; removing the context from the ruleset did. That GitHub evaluates the newest run of the workflow is an inference from this, not documented behaviour. `pr-status.sh` reports the state as `fix-required-context` and names both runs. Require a check every run of the workflow reports (an aggregate gate job) instead of a job that is skipped on some events.\n\n**The PR that adds the trigger does not deadlock itself.** It is tempting to conclude that opening such a fix as a draft is a trap, since lifting the draft would need the trigger the PR is still adding. For `pull_request` it is not: GitHub reads the workflow file from the **PR's merge ref**, so the `on:` block — `types:` included — comes from the branch under review and is already in force on that PR. Verified on two PRs that *introduced* their own workflow (`netresearch/ofelia#442`, `netresearch/git-workflow-skill#156`): both show a run of the new workflow at their own head SHA with `event=pull_request`.\n\nThe trap is real only for `pull_request_target`, which deliberately loads the workflow from the base branch — there the new trigger takes effect for the *next* PR, not this one. Check which of the two the workflow uses before deciding the draft convention has to be set aside.\n\n### Commands\n\n```bash\n# Check PR status\ngh pr status\ngh pr view 123\n\n# Request review\ngh pr edit 123 --add-reviewer \"@reviewer1,@reviewer2\"\n\n# Mark ready for review\ngh pr ready 123\n\n# Convert to draft — also the first step whenever work resumes on a ready PR\ngh pr ready 123 --undo\n\n# Approve PR\ngh pr review 123 --approve\n\n# Request changes\ngh pr review 123 --request-changes --body \"Please fix X\"\n\n# Merge PR\ngh pr merge 123 --squash --delete-branch\n\n# Close without merging\ngh pr close 123\n```\n\n### A stacked PR loses its approvals the moment its base merges\n\nStacking — PR B opened against PR A's branch so B can build on text or code that exists only there — is the right shape when B has no anchor without A. In such a hand-built stack (GitHub-native stacks behave differently, see [GitHub-native stacked PRs](#github-native-stacked-prs-merge-the-tip-not-each-pr-in-turn)), merging A does not retarget B: GitHub retargets B to `main` only when A's **branch is deleted** — by the repository's `delete_branch_on_merge` setting (`gh api repos/$OWNER/$REPO --jq .delete_branch_on_merge`) or by hand — and even then the child can be closed instead (see [Stacked PRs: retarget before you merge](#stacked-prs-retarget-before-you-merge---delete-branch-only-at-the-end)). With the setting off, B stays on A's stale branch until you rebase it onto `main` and run `gh pr edit B --base main` yourself; say that in B's body rather than promising automation. Whichever way the retarget happens, it also **dismisses every review on B**, because the base changed:\n\n```\nreviews : github-actions=DISMISSED decision=REVIEW_REQUIRED\n```\n\nNothing about B changed. Its head SHA is the same, its checks are still green, no file moved. But `REVIEW_REQUIRED` is a host gate, not advice, so B cannot merge until an approval lands on that head again — and an automated approver only reruns on a new head. The recovery is to give it one, with the plain (merging) form of the command in [Updating a PR branch without a local clone](#updating-a-pr-branch-without-a-local-clone--gh-pr-update-branch---rebase):\n\n```bash\ngh pr update-branch $PR --repo $OWNER/$REPO\n```\n\nThat merges the now-advanced base into B, which re-triggers CI **and** the approval workflow. Budget the full check matrix again, not a re-check.\n\n**There is a cheaper recovery, and it is the usual case.** `update-branch` is the right tool when B's *tree* has to catch up — the base advanced and B needs its content, or the two conflict. When nothing about B is stale and only the approval was dismissed by the retarget, **re-running the workflow that produced the approval restores it without a new head and without a second matrix**:\n\n```bash\nRUN=$(gh api --paginate \"repos/$OWNER/$REPO/actions/runs?branch=$BRANCH&per_page=100\" \\\n --jq '[.workflow_runs[]|select(.name==\"\u003cthe approving workflow>\")]|sort_by(.created_at)|last|.id')\ngh api \"repos/$OWNER/$REPO/actions/runs/$RUN/rerun\" -X POST\n```\n\nThe head SHA never changes, so nothing else is dismissed and the existing green checks still count. Find the workflow name from the check-run that carries the approval (`gh api repos/$OWNER/$REPO/commits/$SHA/check-runs`), not from the review's author — the review is submitted by `github-actions[bot]` whatever produced it.\n\nObserved 2026-08-21 on netresearch/t3x-nr-llm#871: `update-branch` would have re-run 93 checks to recover one dismissed approval on a two-file documentation PR whose tree was already correct.\n\n**The cheap recovery does not hold when the dismissal message is \"The merge-base changed after approval.\"** The cause is that the merge base changed after the approval — in a merge-queue repository typically because a sibling PR merged into the base — and the branch still forks from the older base. Every later approval on that fork is dismissed again with the same message — also one on a new head pushed without a rebase — so rebase the branch onto the current base (or run `gh pr update-branch`) instead of re-running the approver. Observed once, 2026-10-28 on netresearch/t3x-nr-vault#480: after #479 merged, the approvals on three new heads pushed within eleven minutes were each dismissed with that message, and the first approval after the rebase held.\n\n**A trap in the diagnosis, not the fix.** `pr-merge.sh` reports the gate it knows how to name, and a dismissed approval is not one of them — on #871 it answered \"Copilot is OUT OF REVIEW QUOTA\" and asked for a self-review attestation that was already posted, correctly, for the current head. Both statements were true and neither was the cause. The cause is only visible in the timeline:\n\n```bash\ngh api --paginate \"repos/$OWNER/$REPO/issues/$PR/timeline?per_page=100\" \\\n --jq '.[]|select(.event|test(\"review_dismissed|reviewed|head_ref_force_pushed\"))\n |\"\\(.created_at) \\(.event) \\(.dismissed_review.dismissal_message // \"\")\"'\n```\n\n```\n08:22:33 review_dismissed approved\n08:23:10 reviewed\n10:53:59 review_dismissed The base branch was changed.\n```\n\nRead `reviewDecision` before believing a merge tool's `NEXT:` line. `REVIEW_REQUIRED` on a PR whose checks are green and whose threads are resolved means an approval was dismissed, and the message naming a review *path* is describing the door it checked, not the one that is shut.\n\nNote the difference in *why* you reach for it. That section's caution is that `--rebase` force-updates and *can* reset approvals; here the approvals are **already** gone before you touch anything, dismissed by the retarget itself, and the update is what restores them.\n\nTwo things follow when you plan a stack:\n\n- **Expect the second CI run.** The stack saves you a conflict, not a pipeline. If B's diff would conflict only trivially with A, opening B against `main` and resolving once may be cheaper than a retarget plus a full rerun.\n- **Merging the base advances `main`, so the child may now conflict.** The retarget makes B's *diff* correct, not its *tree*: anything both PRs touched — a shared `CHANGELOG.md` \"Unreleased\" section is the usual one — conflicts on the update-branch. Resolve it there; do not merge the base into the child while the child is queued.\n\nObserved 2026-08-13 on netresearch/t3x-nr-llm#759, stacked on #758 for a documentation anchor that existed only on that branch.\n\n**The same cost without a stack: sibling PRs that all edit `CHANGELOG.md`.** Splitting one plan into N independent PRs against `main` avoids retargeting, but if each PR adds its own entry under `[Unreleased]` and the base branch requires branches to be up to date before merging (`strict_required_status_checks_policy: true`, visible in `gh api repos/OWNER/REPO/rules/branches/main`), the merges serialize: every merge moves `main`, every remaining PR is now `DIRTY` on the changelog, and each needs a rebase, a conflict resolution and a **full CI run** before it can merge. N PRs cost N−1 extra pipelines, one after another — five PRs in one repository took four such rounds at roughly an hour each while runners were scarce. Decide this when you split the work, not when the second merge lands:\n\n- **Keep the changelog out of the siblings.** Add all entries in one final PR (or in the release PR, where the repository allows it), so the feature PRs touch disjoint files and stay mergeable in any order.\n- **Or accept the serial cost deliberately** and fix the merge order up front, so each rebase happens once, against the PR merged just before it — rebasing all of them after every merge only multiplies the pipelines.\n- **Resolve by keeping every entry,** in Keep a Changelog order (Added, Changed, Deprecated, Removed, Fixed, Security), and check the result by listing the section's headings and entry titles: each entry exactly once.\n- **Re-run the local checks after each rebase.** The branch now contains every earlier merge; a conflict elsewhere (a shared `Services.yaml` exclude list, for example) is a semantic merge, not a formality.\n\nObserved 2026-09-14 on netresearch/t3x-nr-vault#345–#349.\n\n### Handling Stale PRs\n\n```yaml\n# .github/workflows/stale.yml\nname: Mark Stale PRs\n\non:\n schedule:\n - cron: '0 0 * * *' # Daily\n\njobs:\n stale:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/stale@v9\n with:\n repo-token: ${{ secrets.GITHUB_TOKEN }}\n stale-pr-message: 'This PR has been inactive for 14 days. Please update or close.'\n days-before-stale: 14\n days-before-close: 7\n stale-pr-label: 'stale'\n```\n\n## Conflict Resolution\n\n### Two independent PRs needing the same hunk — make it byte-identical\n\nWhen two PRs split out of one piece of work both need the same addition (a\n`catch` block both paths now reach, an enum case both use, the same import),\nthere are three options and only one of them is good:\n\n| Approach | Cost |\n|---|---|\n| Stack the second PR on the first | Second PR can no longer merge on its own |\n| Put the hunk in one PR only | The other PR is broken until that one lands — a merge-order trap |\n| **Put the identical hunk in both** | **Git merges it once, in any order** |\n\nGit treats the same change at the same place as *one* change, so both PRs merge\ncleanly and the hunk appears exactly once in the result. That removes the\nordering constraint entirely, which matters because maintainers merge in\nwhatever order they review.\n\nIt only works if the text matches **byte for byte** — same wording, same\nindentation, same position, and the same import placement (run the project's\nformatter in both branches, or the import sorter will reorder one of them). Copy\nit mechanically rather than retyping, and compare the two extracts. `\u003cfirst-line>`\nand `\u003clast-line>` are the first and last lines of the hunk, used as `sed`\naddress patterns:\n\n```bash\ngit show \u003cother-branch>:\u003cpath> | sed -n '/\u003cfirst-line>/,/\u003clast-line>/p' > /tmp/hunk\ndiff \u003c(sed -n '/\u003cfirst-line>/,/\u003clast-line>/p' \u003cpath>) /tmp/hunk && echo identical\n```\n\nThen prove the property instead of assuming it — merge every order in a\nthrowaway clone and check the result. Abort the run on the first conflict:\ncontinuing would leave the repo mid-merge and every later result meaningless.\n\n```bash\ngit clone -q --shared \u003cbare-repo> /tmp/mergetest && cd /tmp/mergetest\nfor order in \"\u003cbranch-A> \u003cbranch-B>\" \"\u003cbranch-B> \u003cbranch-A>\"; do\n git reset -q --hard \u003cbase>\n for b in $order; do\n git merge -q --no-edit \"$b\" || { echo \"CONFLICT: $b in order [$order]\"; git merge --abort; exit 1; }\n done\ndone\n# counts matching LINES, so use one distinctive line of the hunk, not the hunk\ngrep -cF '\u003cone distinctive line of the hunk>' \u003cpath> # must be 1, not 2\n```\n\nState in both PR bodies that the hunk is identical and why, so a reviewer does\nnot \"clean up\" the duplicate in one of them.\n\n### Before Merging\n\n```bash\n# Update feature branch with latest main\ngit checkout feature/my-feature\ngit fetch origin\ngit rebase origin/main\n\n# If conflicts occur\n# 1. Edit conflicting files\n# 2. Stage resolved files\ngit add \u003cresolved-file>\n# 3. Continue rebase\ngit rebase --continue\n\n# Force push (only on feature branches!)\ngit push --force-with-lease\n```\n\n### Merge Conflicts in PR\n\n```bash\n# Option 1: Rebase (preferred for clean history)\ngit checkout feature/my-feature\ngit fetch origin\ngit rebase origin/main\n# Resolve conflicts\ngit push --force-with-lease\n\n# Option 2: Merge main into feature\ngit checkout feature/my-feature\ngit merge origin/main\n# Resolve conflicts\ngit commit\ngit push\n```\n\n### A series of sibling branches: rebuild append-only sections, don't merge them\n\nWhen several of your own branches wait on one another, each needs `main` merged\nin again after the previous one lands. For an **append-only** section — a\nCHANGELOG's `[Unreleased]`, a toctree, an index — the second and later merges of\nthat series behave differently from the first, and a resolver that works on the\nfirst silently corrupts the rest.\n\nThe trap is any resolver whose premise is *\"ours holds only my additions\"* —\nfold-ours-into-theirs, `git checkout --ours` on a hunk, a script that appends one\nside to the other. That premise holds for the first merge only. Afterwards\n\"ours\" already contains everything the previous merge brought in, so each round\nappends `main`'s entries **again**. There are no conflict markers, the diff looks\nplausible, and the damage is visible only by counting: five branches merged in\nsequence produced ten `[Unreleased]` bullets standing three and four times over,\nand two of the PRs carried it to `main` before anyone noticed.\n\nRebuild instead of merging. Take *theirs* (current `main`) verbatim, lift your\nown block out of *ours* by a distinctive first line, and insert it under the\nexisting heading:\n\n```bash\ngit show :3:CHANGELOG.md > /tmp/theirs.md # current main\ngit show :2:CHANGELOG.md > /tmp/ours.md # your branch\n# extract your block from ours, insert into theirs under the matching heading\n```\n\nThen assert before committing — the three checks that catch every variant of\nthis failure:\n\n- the heading count is unchanged (no second `### Added`),\n- your block appears exactly once, above the newest released section,\n- no top-level bullet's first line occurs twice in the section.\n\nThe same three assertions find it after the fact, which is how the duplication\nabove was eventually caught.\n\n### Updating a PR branch without a local clone — `gh pr update-branch --rebase`\n\nTo bring a **conflict-free** PR up to date with its base without checking it out, rebase its head branch remotely:\n\n```bash\ngh pr update-branch \u003cnumber> --repo \u003cowner>/\u003crepo> --rebase\n```\n\nUnlike the plain `gh pr update-branch` (which *merges* base into the branch and leaves a merge commit), `--rebase` keeps linear history — compatible with rebase-only repos. It only succeeds cleanly when the PR has no conflicts (`mergeable: MERGEABLE`); on conflicts, fall back to a local rebase. It **force-updates** the branch, so it re-triggers CI and can reset review approvals — only worth it when staleness actually blocks the merge. Works well for a bulk \"rebase all my open PRs that need it\" sweep (`gh search prs --author=@me --state=open` → loop).\n\n**Not in a repository that requires signed commits.** GitHub writes the new commits server-side and signs none of them, so every PR it touches is then `BLOCKED` on the missing signature — see [`gh pr update-branch` re-writes the head UNSIGNED](#gh-pr-update-branch-re-writes-the-head-unsigned) for the local rebase that re-signs. A Renovate pull request is the case where that local rebase is wrong too — not for the signature, which a maintainer rebase replaces as expected (see the contributor-PR section above), but because Renovate stops updating a branch somebody else pushed to, and the shared auto-merge workflow acts only on events whose actor is the bot: ask Renovate to rebase instead, with the `rebase` label or the rebase checkbox in the PR body (github-project skill, `references/dependency-management.md`). Check the PR's labels before that: a PR labelled `deps-no-automerge` or `deps-major` is skipped by the shared auto-merge workflow and waits for a human review after the rebase, so the rebase alone does not merge it. Observed 2026-10-23 on netresearch/typo3_http_guard#21, #23 and #24: three Renovate PRs rebased with `--rebase` went `BLOCKED` on unsigned heads until the rebase checkbox had Renovate push signed commits.\n\n**Judge \"behind\" correctly — don't trust `mergeStateStatus` alone.** GitHub only reports `mergeStateStatus: BEHIND` when the base enforces *\"require branches to be up to date before merging.\"* Without that rule a PR many commits behind base still shows `CLEAN`/`BLOCKED`, never `BEHIND`. To know how far behind a PR actually is, ask the compare API:\n\n```bash\ngh api \"repos/\u003cowner>/\u003crepo>/compare/\u003cbase>...\u003cheadSha>\" --jq '{behind: .behind_by, ahead: .ahead_by}'\n```\n\nA conflict-free PR that is merely behind (no `BEHIND` flag, `mergeable: MERGEABLE`) does **not** need a rebase to merge — rebasing it is optional churn that re-runs CI. Reserve it for PRs the base blocks on staleness, or ones so far behind they should be re-validated against current base.\n\n### Commit Before Rebase — Correct Push Ordering\n\nWhen you have uncommitted local changes that need to be pushed, the order matters:\n\n```bash\n# ✅ Correct — commit first, then sync, then push\ngit add \u003cfiles>\ngit commit -m \"message\"\ngit fetch origin\ngit rebase origin/\u003cbranch>\ngit push\n\n# ❌ Wrong — rebase aborts with \"please commit your changes or stash them\"\ngit fetch origin\ngit rebase origin/\u003cbranch> # aborts with error if working tree is dirty\ngit add \u003cfiles>\ngit commit -m \"message\"\ngit push # rejected as non-fast-forward\n```\n\nThe \"fetch+rebase before push\" rule means **before pushing**, not before committing. `git rebase` requires a clean working tree — it aborts with an error when uncommitted changes are present, leaving the branch behind the remote. The subsequent push is then rejected as non-fast-forward, requiring an extra fix cycle.\n\n### Verify a push actually landed (never grep push output)\n\nA push can silently fail to land while a piped command swallows the signal — `git push … | grep 'new branch'` or `… | tail` can hide a non-zero exit (wrong remote/auth, or an *empty* commit because a path was silently excluded by `.gitignore`). Confirm the remote ref moved, by SHA — don't infer success from push output:\n\n```bash\ngit push -u origin \"$BR\"\nREMOTE_SHA=$(git ls-remote origin refs/heads/\"$BR\" | cut -f1) # no fetch, no fatal if branch absent\n[ \"$(git rev-parse HEAD)\" = \"$REMOTE_SHA\" ] && echo \"landed\" || echo \"DID NOT LAND\"\n```\n\nLikewise verify staging of any path that might be gitignored with `git status --short` before committing — an empty commit pushes \"successfully\" yet changes nothing.\n\nThe same trap applies to **every command whose exit code gates the next step**, not\njust `git push`: in POSIX shells a pipeline's status is that of its **last** command\n(`tail`, `grep`) unless `set -o pipefail` is active — and even with pipefail, a\ntrailing `grep` that matches nothing fails a *green* build. Real case:\n`docker build … 2>&1 | tail -2 && echo OK` printed `OK` for a **failed** build, and\nthe broken branch was pushed before anyone noticed. Gate on the command's own exit\ncode; keep log inspection out of the gate:\n\n```bash\ndocker build . > build.log 2>&1\nrc=$?\ntail -20 build.log # inspection only — never part of the gate\n[ \"$rc\" -eq 0 ] || exit 1\n```\n\n### `--force-with-lease` Rejected with \"stale info\"\n\nThree different causes produce this message: the tracking ref moved (below), there is no tracking ref at all (the subsection after it), or the remote branch is gone because the PR already merged (the subsection after that). None of them is a reason to escalate to plain `--force`.\n\nOn PRs that bots touch (auto-approve, Renovate/Dependabot, a CI step that pushes), `git push --force-with-lease` can be rejected with `stale info` even when your local work is correct: a bot updated the remote branch since your last fetch, so the lease's expected ref (your `origin/\u003cbranch>` tracking ref) no longer matches and the push aborts. This is the safety check working — don't escalate to plain `--force`.\n\nFetch, see what arrived, then push — the lease now matches the ref you just fetched:\n\n```bash\nBR=feature/my-feature\ngit fetch origin \"$BR\"\ngit log HEAD..origin/\"$BR\" # what the bot pushed — safe to discard?\ngit push --force-with-lease origin \"$BR\" # lease compares against the fetched tracking ref\n```\n\nIf a bot keeps pushing inside the fetch→push window so the plain lease never matches, pin it to the head you just inspected. This pins, not skips, the check — it accepts exactly that SHA, so only run it right after the `git log` above confirms those commits are safe to discard:\n\n```bash\ngit push --force-with-lease=\"$BR:$(git rev-parse origin/\"$BR\")\" origin \"$BR\"\n```\n\n#### When the push target is a URL, not a remote\n\nFetching does not help — and cannot — if the push names a URL:\n\n```bash\ngit push --force-with-lease [email protected]:me/repo.git HEAD:my-branch\n# ! [rejected] HEAD -> my-branch (stale info)\n```\n\nA URL has no remote-tracking ref, so the lease has nothing to compare against and the push is rejected every time, however recently you fetched. This is not the safety check catching a real race; it is the check with no input. Typically hit when pushing to a fork that was never added as a remote — `gh repo fork --clone=false` creates the fork on the server but adds nothing locally.\n\nAdd the remote once, fetch it, then push to it by name:\n\n```bash\ngit remote add fork [email protected]:me/repo.git\ngit fetch fork\ngit push --force-with-lease fork HEAD:my-branch\n```\n\n`git remote -v` before force-pushing is the cheap check: if the target is not listed there, the lease cannot work. Do not reach for plain `--force` — that discards the protection instead of supplying it.\n\n#### When the branch is gone because the PR already merged\n\nThe same `stale info` appears when there is nothing left to push to: auto-merge took the PR while you were preparing the amend, and the merge deleted the branch. The lease has no remote ref to compare against, so it refuses — identically to the two cases above, and the documented remedy for the first one does not help.\n\nThe tell is in the fetch, not the push, and it is easy to miss because the fetch is usually run with its output discarded:\n\n```bash\ngit fetch origin \"$BR:refs/remotes/origin/$BR\"\n# fatal: couldn't find remote ref release/v1.8.2 ← the answer is HERE\ngit push --force-with-lease\n# ! [rejected] (stale info) ← same message, different world\n```\n\nThe local `origin/\u003cbranch>` usually still exists — your own earlier push created it — so the tracking ref looks healthy and only the fetch knows the truth. Do not pin the lease to it as a workaround: `--force-with-lease=\"$BR:$(git rev-parse origin/\"$BR\")\"` is the documented remedy for the *first* cause, and here `git rev-parse` may fail outright, leaving the substitution empty and the expectation meaningless.\n\nSo when a lease refresh does not fix `stale info`, read the fetch's own output before pinning the lease harder, and check the PR rather than the ref: `gh pr view \u003cn> --json state,mergedAt`. If it says `MERGED`, the PR's changes are on its base branch — under squash or rebase merges as new commits, so the original head SHA need not be there at all — and a local `git commit --amend` cannot change the merged result. Do not recreate or rewrite the branch only to change a commit message. Observed on a fleet release sweep where four bump PRs auto-merged between the commit and the amend; two rounds went into the lease before the `couldn't find remote ref` line was read.\n\n### Complex Conflicts\n\n```bash\n# Use a merge tool\ngit mergetool\n\n# Or use specific tool\ngit mergetool --tool=vscode\ngit mergetool --tool=meld\n\n# Configure default tool\ngit config --global merge.tool vscode\ngit config --global mergetool.vscode.cmd 'code --wait $MERGED'\n```\n\n## PR Analytics\n\n### Metrics to Track\n\n1. **PR Size**: Average lines changed\n2. **Review Time**: Time from creation to first review\n3. **Time to Merge**: Creation to merge\n4. **Review Rounds**: Number of change requests\n5. **Throughput**: PRs merged per week\n\n### GitHub Insights\n\n```bash\n# List PR stats\ngh pr list --state merged --json number,title,createdAt,mergedAt,additions,deletions\n\n# PR age analysis\ngh pr list --state open --json number,createdAt | jq 'map({number, age: (now - (.createdAt | fromdateiso8601)) / 86400})'\n```\n\n## Review Thread Management\n\n### Replying to Review Threads\n\nWhen addressing review feedback, reply directly to the thread (not a new comment):\n\n```bash\n# Find the thread ID for a comment\ngh api repos/OWNER/REPO/pulls/NUMBER/comments \\\n --jq '.[] | {id, node_id, body}'\n\n# Reply to a review thread via GraphQL\ngh api graphql -f query='\n mutation($body: String!, $threadId: ID!) {\n addPullRequestReviewThreadReply(input: {\n body: $body,\n pullRequestReviewThreadId: $threadId\n }) {\n comment { id }\n }\n }' \\\n -f body=\"Fixed in commit abc123\" \\\n -f threadId=\"PRRT_xxxxx\"\n```\n\n### Resolving Review Threads\n\nAfter addressing feedback and pushing fixes:\n\n```bash\n# Resolve a review thread\ngh api graphql -f query='\n mutation($threadId: ID!) {\n resolveReviewThread(input: {threadId: $threadId}) {\n thread { isResolved }\n }\n }' \\\n -f threadId=\"PRRT_xxxxx\"\n\n# List unresolved threads\ngh api graphql -f query='\n query($owner: String!, $repo: String!, $pr: Int!) {\n repository(owner: $owner, name: $repo) {\n pullRequest(number: $pr) {\n reviewThreads(first: 50) {\n nodes {\n id\n isResolved\n comments(first: 1) {\n nodes { body }\n }\n }\n }\n }\n }\n }' -f owner=OWNER -f repo=REPO -F pr=NUMBER\n```\n\n### Handling Many Review Threads (Pagination)\n\n**Critical:** GitHub GraphQL API has a limit of 100 items per page. For PRs with many\nreview comments (e.g., 127+ threads from automated reviewers), you MUST use pagination:\n\n```bash\n# Fetch ONE page of up to 100 threads; repeat with the returned endCursor\n# until hasNextPage is false to cover all threads\ngh api graphql -f query='\n query($owner: String!, $repo: String!, $pr: Int!, $cursor: String) {\n repository(owner: $owner, name: $repo) {\n pullRequest(number: $pr) {\n reviewThreads(first: 100, after: $cursor) {\n pageInfo {\n hasNextPage\n endCursor\n }\n nodes {\n id\n isResolved\n comments(first: 1) {\n nodes { body path }\n }\n }\n }\n }\n }\n }' -f owner=OWNER -f repo=REPO -F pr=NUMBER\n\n# Loop until pageInfo.hasNextPage is false, passing each endCursor:\n# -f cursor=\"Y3Vyc29yOnYyOpHOABCD...\"\n```\n\n**Real-world lesson (PR #575):** Automated reviewers can generate 100+ comment threads.\nWithout pagination, only the first 100 threads are returned, leaving others unaddressed.\n\n## A Green Job Is Not a Green Test Run\n\nJob status answers \"did the command exit 0\". It does not answer \"did the tests pass\".\nWhere a pipeline publishes a test report, read it — the two disagree more often than\nthey should, and the report is the stronger statement.\n\n```bash\n# Same placeholders as the GitLab section below: GITLAB_HOST, $P the URL-encoded\n# project path or numeric id, $TOKEN a PRIVATE-TOKEN.\nexport GITLAB_HOST=git.example.com\n\n# GitLab: the report, not just the job list\ncurl -s -H \"PRIVATE-TOKEN: $TOKEN\" \"https://$GITLAB_HOST/api/v4/projects/$P/pipelines/$PIPELINE/test_report\" \\\n | jq '{total_count, failed_count, suites: [.test_suites[] | {name, failed_count, total_count}]}'\n\n# GitHub: the rollup hides startup failures entirely — see the section below\ngh run list --repo \"$R\" --commit \"$SHA\" --json name,status,conclusion\n```\n\nTwo ways a check stops being a gate without anyone noticing:\n\n- **The command mutates instead of checking.** A job running `php-cs-fixer fix`\n (rather than `fix --dry-run`) repairs the files in the container, throws them away\n with it, and exits 0. Every repaired file is recorded as a failed case in the JUnit\n report while the job is green. One project had run that way for three and a half\n years — 30 standing violations, a permanently green gate, and the evidence expiring\n with the artifact about an hour after each run.\n- **Nothing is enforced because nothing fails.** `allow_failure: true` (GitLab) and\n non-required checks (GitHub) are legitimate, but they make the pipeline's colour a\n poor summary. Before reading a pipeline as approval, list which jobs can actually\n block:\n\n```bash\ncurl -s -H \"PRIVATE-TOKEN: $TOKEN\" \"https://$GITLAB_HOST/api/v4/projects/$P/pipelines/$PIPELINE/jobs\" \\\n | jq -r '.[] | select(.allow_failure == false) | \"\\(.status) \\(.name)\"'\n```\n\nWhen a report's numbers look implausible — everything failing, or nothing at all —\ncheck the artifact before believing it. An expired or never-collected JUnit artifact\nreports `total_count: 0`, which reads like \"no failures\" and means \"no data\".\n\n## Diagnosing CI Failures (Annotations First)\n\n> Failure first-step, not pre-merge gate. The Merge Gate below uses `annotations_count` as a *warnings present?* signal after success. This section is the inverse: when a workflow has *failed* and you don't yet know why, read the annotation text **first**, before any other diagnostic action.\n\n### Anti-pattern\n\nWhen a GitHub Actions run fails — especially with `startup_failure`, \"no jobs ran\", \"config invalid\", or any failure where the PR summary view shows just a red X with no detail — do **not**:\n\n- Speculate about transient infra issues\n- Blame upstream commits or reusable-workflow regressions\n- Diff the workflow YAML against the last known good revision\n- Re-run the workflow hoping it passes\n\n…before reading the check-runs annotations. The literal validator error is almost always sitting there in one line. Annotations are **invisible in the PR summary view** — they're only rendered in the Actions UI under each job's \"Annotations\" panel, easy to miss.\n\n### Recipe\n\n```bash\nSHA=$(git rev-parse HEAD) # or the failing commit SHA\n\n# 1. Find every check run on that commit that has annotations\n# {owner}/{repo} are gh api placeholders — auto-resolved from cwd or $GH_REPO\ngh api \"repos/{owner}/{repo}/commits/$SHA/check-runs\" --paginate \\\n --jq '.check_runs[] | select(.output?.annotations_count? // 0 > 0) | \"\\(.id)\\t\\(.name)\"' |\nwhile IFS=

Git Workflow Skill Not Here Releases: . BLOCKED-PR diagnosis: . GitHub only. Everything below the branching and commit sections — , , the merge gate, review threads — speaks GitHub GraphQL. For a GitLab merge request the equivalent calls are in § GitLab: the same gate with . Critical Rules (Non-Negotiable) 1. No direct push to main — always open a PR. 2. No merge before all threads resolved ( ). 3. No squash unless asked — preserves atomic commits, signatures, bisection. 4. No "tested/verified/working" without pasted command output — else say so. 5. No edits to installed skill/plugin cache pa…

\\t' read -r run_id name; do\n echo \"=== $name ===\"\n # 2. Print the annotation text (level, file, line, message).\n # --paginate guards against runs with > 100 annotations (rare for startup\n # failures, common for linters like reviewdog).\n gh api \"repos/{owner}/{repo}/check-runs/$run_id/annotations\" --paginate \\\n --jq '.[] | \"[\\(.annotation_level)] \\(.path):\\(.start_line) \\(.message)\"'\n echo \"\"\ndone\n```\n\nDrop this into the troubleshooting flow as **step 0**. If the annotations are empty, *then* fall back to logs (`gh run view RUN_ID --log-failed`) and YAML diffs.\n\n### Real-world example\n\nA reusable-workflow caller failed with `startup_failure` and zero jobs. Multiple turns were spent blaming upstream `netresearch/typo3-ci-workflows@main` commits and even pinning to a known-good SHA as a workaround. The annotation said the actual cause in one line:\n\n> Error calling workflow '...'. The nested job 'preflight' is requesting 'actions: read', but is only allowed 'actions: none'.\n\nFix: one-line `actions: read` add to the caller's `permissions:` block ([t3x-nr-passkeys-be@0533835](https://github.com/netresearch/t3x-nr-passkeys-be/commit/0533835)). Reading the annotations first would have collapsed a 6-step diagnostic loop into a 2-step fix.\n\n### Fixing the failure: reproduce the *exact* job, gate the push on a read verify\n\nThree traps when fixing a red CI job:\n\n- **Reproduce the exact failing step, not a proxy.** A passing *local* `make test` / `phpunit` does not prove the failing CI job is fixed — the job may fail on a different step or matrix cell (e.g. a `php -l` lint sweep over `vendor/` on PHP 8.4/8.5, or a stricter runner version) that your proxy never runs. Read which job + step failed and run *that* command, on that version, before claiming the fix.\n- **Make the push a separate step, gated on a verify you actually read.** Bundling verify-and-push in one `&&` block force-pushes before the result is seen — a run that printed `FAIL` still gets pushed. Capture the verify to a file, read it, and push only on a confirmed-clean result:\n\n```bash\nrun_tests > /tmp/verify.log 2>&1; RC=$?\ncat /tmp/verify.log; echo \"rc=$RC\" # read the log + exit code first\n[ \"$RC\" -eq 0 ] && git push || echo \"STOP — tests failed, do not push\"\n```\n\n- **Cap memory when the fix activates or relies on a loop.** A bug-fix can make previously-dead code *live* — and if the now-live path paginates or loops over an external (or mocked) response, an **unbounded** loop can exhaust RAM and OOM-crash the whole machine when you reproduce the test locally. (Real case: restoring a deleted variable unblocked a code path whose `while True` pagination loop then grew a MagicMock to >20 GB RSS and took down the VM.) Two defenses, apply both:\n - **Run the repro under a memory cap** so a runaway loop fails fast instead of freezing the box: `( ulimit -v 6000000; pytest tests/… )` (≈6 GB virtual). Do this whenever a loop's termination depends on code you just changed. `ulimit -v` is a Linux mechanism — it is ignored on macOS/Darwin, so there run the repro inside a container instead (`docker run --memory=6g …`) or use another runtime-level cap.\n - **Bound the loop itself** — `for _ in range(MAX_PAGES): … else: raise RuntimeError(...)` — rather than trusting the response's metadata (or a test mock) to signal the last page; and give the test a finite mock (`side_effect=[page1, page2]`), never a bare mock whose `.get()` is truthy forever.\n\n### Relationship to the Merge Gate annotations check\n\n| Stage | Question | Endpoint |\n|-------|----------|----------|\n| Failure diagnosis (this section) | \"Why did the run fail?\" | `/check-runs/{id}/annotations` (read messages) |\n| Pre-merge gate (below) | \"Are there warnings to clear before merging green CI?\" | `/commits/{sha}/check-runs` (count > 0) |\n\nSame endpoint family, different question — read the annotation text on failure, count it on success.\n\n## Merge Gate\n\nBefore merging any PR, run this gate. If any check fails, stop and fix the underlying issue rather than overriding.\n\n### Pre-Merge Checklist\n\n- [ ] **All review threads resolved** — no unresolved conversations\n- [ ] **No ongoing review, and the bot's latest review is on the head commit** (if assigned) — a `copilot_code_review` ruleset can re-block when the head changes; see \"Rulesets\" below\n- [ ] **Rulesets checked** — `gh api repos/{owner}/{repo}/rules/branches/BASE`, not just classic branch protection\n- [ ] **Branch rebased on target** — no stray merge commits in PR branch\n- [ ] **All CI checks pass** — green status on every required check\n- [ ] **No CI annotations** — check job annotations, not just pass/fail (see below)\n- [ ] **Signed commits** — every commit in the PR is signed (see \"Signing and DCO Failures\" below if blocked)\n- [ ] **DCO sign-off** — every commit has a `Signed-off-by:` trailer matching `git config user.{name,email}` (required when the `probot/dco` check is enabled)\n- [ ] **No intermediate planning artifacts** — `bash skills/git-workflow/scripts/spec-cleanup-guard.sh` exits 0; superpowers specs/plans (`docs/superpowers/**`) and other scratch planning files must not reach the base branch (see \"Spec-Cleanup Guard\" below and `references/spec-cleanup.md`)\n\n### PR-green is not main-green — jobs gated on `push`/`merge_group` don't run on the PR\n\nA PR's checks are only the workflows that trigger on `pull_request`. A job gated on `push: [main]` (or the `merge_group` event) never runs on the PR, so a green PR does **not** clear it — the job fires *after* merge and can turn `main` red on a change the PR \"passed\". Before merging, diff each workflow's trigger against what actually ran on the PR; for any `push`/`merge_group`-only job (a deploy, a boot/smoke test, a container-compile), reproduce it locally on the merge result first.\n\nThis bites hardest on dependency bumps. A resolver succeeding (`composer update` / `npm install` resolves cleanly) is **necessary but not sufficient**: a loose constraint can select a *released* sibling whose code predates compatibility with the new dependency, so it installs but fails at runtime.\n\n- Real case: a project bumped `nr-llm ^0.12 → ^0.22`. Every `pull_request` check passed. The `validate` job — gated on `push: main`, so absent from the PR — boots the app and compiles the DI container; post-merge it failed because a *released* sibling (`t3-cowriter v3.1.1`, constraint `nr-llm >=0.3 \u003c1.0`) referenced a class the new `nr-llm` had removed. Resolution was green; the runtime compile was not. Recovery cost a sibling release plus a follow-up PR.\n- Verify the runtime path, not just resolution: run the actual boot/compile (or the exact `push:main`-only job) against the resolved tree locally before merging. For the class-not-found family, a fast proxy is to confirm every referenced upstream symbol still exists at the *resolved* version.\n\n### A parallel-merged config PR turns your green check red on main\n\nThe second way PR-green diverges from main-green needs no `push:`-gated job: a PR's lint/analysis runs are pinned to the **config on the PR branch**, and a sibling PR that adopts a stricter shared config (rector/eslint/phpstan rule sets) can merge between your last CI run and your merge. Both PRs are green in isolation; the merge result on `main` fails the very check both passed. Nothing lied — the config changed under you.\n\n- Real case (2026-08-04, t3x-nr-vault): PR A was rebased and merged in parallel with PR B (\"adopt the shared org rector config\", which added `NewlineAfterStatementRector`). PR A's green Rector run predated that config; `ci / Rector` went red on `main` over one missing blank line. Recovery was a one-line fix-forward PR.\n- Rebasing before merge does **not** close the window — the config PR can land after your rebase. Only a merge queue or a \"require branches to be up to date\" rule makes it structurally impossible; where neither is on, expect this occasionally on active repos.\n- Consequence: watching the post-merge run on `main` is part of the merge, because this failure class is visible nowhere else. When it fires, diff `main`'s history for config-touching merges since your branch's last CI run and fix forward against the new config — it is mechanical, not an investigation into which check was wrong.\n\n### A stack needs push access to the repository the PRs target\n\nA pull request's base must be a branch **in the repository the PR is opened\nagainst**. Stacking PR2 on PR1's branch therefore requires that branch to exist\nthere — which it does when you push branches to the target repo, and does not\nwhen you contribute from a fork. Upstream has no `feature/x`; it lives in your\nfork, and `gh pr create --base feature/x` answers with four clauses, of which\nonly the last is the cause:\n\n```\npull request create failed: GraphQL: Head sha can't be blank, Base sha can't be\nblank, No commits between upstream:feature/x and myfork:follow-up, Base ref must\nbe a branch (createPullRequest)\n```\n\n`No commits between` is the misleading one — the branches differ, and diffing\nthem to find out why wastes the time this note exists to save.\n\nCheck before you plan around a stack, not after you have built it:\n\n```bash\ngh api \"repos/$UPSTREAM\" --jq '.permissions.push' # false → no stack there\n```\n\nWith `push: false` the options are a single PR, or a second PR that waits for\nthe first to merge and then targets the default branch. Opening the follow-up in\nyour own fork keeps it reviewable and off the upstream's list, but it is not a\nstack: the maintainers cannot see it in the queue.\n\nThis bites hardest when a review suggests splitting one PR into two, since the\nsplit is proposed in the same breath as the stack that would deliver it. On a\nfork, \"I'll split this into a base PR and a follow-up\" is a promise about the\nmerge order, not about what can be opened today — say which one you mean.\n\n### Before splitting a PR, run each half without the other\n\nA split is only real if both halves stand on their own. The half that carries a\ntest and the half that makes CI accept it are the common trap: they read like\n\"the change\" and \"the infrastructure for the change\", and they are one commit's\nworth of coupling. Check it the cheap way — build each half and run the gates on\nit — before proposing the split, because a proposal is what the reviewer will\nplan around.\n\nThe tell that they cannot be separated is usually already in front of you: if\nyou diagnosed a CI failure that one half causes and the other half fixes, the\nquestion is settled and the split is a promise you cannot keep. Reviewing your\nown diagnosis before proposing is faster than reversing a split after the branch,\nthe PR and the description have been rebuilt around it.\n\n### A closed PR's branch: read the closing note before deleting it\n\nA branch left behind by a PR that was closed rather than merged looks like the\ncleanest possible cleanup target — no merge, no history on the base, nothing\nreferencing it. Sometimes it is. Sometimes the closing comment is the record of\na decision to keep it, and deleting it discards work nobody wrote down anywhere\nelse.\n\nTwo branches from closed PRs in one repository, both stale by every mechanical\ntest, opposite answers:\n\n```\nci/e2e-playwright PR #352 closed — \"everything here is already\n on main, in a later form\" → dead\nsecurity/fix-globals-and-makeinstance PR #338 closed — \"The branch is kept.\n Nothing here is lost — the five commits\n stand. Reopen this PR, or branch from that\n ref, when the work is picked up again.\"\n```\n\nThe second was closed for being red, not for being wrong, and the branch is the\nwhole of the unfinished work. `git log`, `rev-list --count` and \"no open PR\" say\nthe same thing about both.\n\nSo before deleting a branch whose PR is closed:\n\n```bash\ngh pr list --repo OWNER/REPO --state all --head \u003cbranch> --json number,state\ngh pr view \u003cn> --repo OWNER/REPO --json comments --jq '.comments[-1].body'\n```\n\nRead the last comment. If it does not say what should happen to the branch,\nthat is a gap worth filling when you close a PR: say in the closing comment\nwhether the branch is kept and why, because the next person doing a cleanup\nsweep has no other source.\n\n### Stacked PRs: retarget before you merge, `--delete-branch` only at the end\n\nA stacked chain (PR2 based on PR1's branch, PR3 on PR2's, …) merges\nbottom-up — but two GitHub behaviours break the naive loop:\n\n1. **`gh pr merge --delete-branch` on a stacked base CLOSES the child PR.**\n GitHub documents retargeting dependent PRs to the merged PR's base when a\n head branch is deleted, but it is unreliable: the child can be closed\n instead, its base still pointing at the deleted branch (observed 2026-08-01\n mid-stack, and again 2026-09-19 on\n `netresearch/ldap-selfservice-password-changer#685`, the child of a\n two-PR stack).\n\n `pr-merge.sh` prevents it — it withholds `--delete-branch` when\n `gh pr list --base \u003chead-branch> --state open` returns anything. The\n recovery below is for a merge made by hand or by an older copy of the\n script.\n\n The two obvious repairs block each other, so only one order moves:\n\n ```bash\n # The branch has to come back in the repository the pull requests live in.\n # `origin` is a local alias and points at the fork in a fork-based checkout,\n # where recreating the branch would leave the child's base still missing.\n # `git remote -v` says which remote is owner/repo.\n BASE=origin\n\n # 1. restore the base branch BY SHA — `--delete-branch` deletes the local\n # branch too, so a name-based refspec fails with \"src refspec does not\n # match any\" in exactly the situation this is written for. The push needs\n # the object locally, which it is not after a squash or rebase merge, or\n # in a shallow clone. refs/pull/\u003cN>/head survives both the merge and the\n # branch deletion, and fetching it is idempotent, so just do it.\n SHA=$(gh pr view \u003cMERGED_PR> -R owner/repo --json headRefOid --jq .headRefOid)\n git fetch \"$BASE\" \"refs/pull/\u003cMERGED_PR>/head\"\n git push \"$BASE\" \"$SHA:refs/heads/\u003cmerged-branch>\"\n\n # 2. reopen — refused while the base branch is missing\n # (\"Could not open the pull request\")\n gh pr reopen \u003cCHILD_NUMBER> -R owner/repo\n\n # 3. retarget — refused while the PR is closed\n # (\"Cannot change the base branch of a closed pull request\")\n gh pr edit \u003cCHILD_NUMBER> -R owner/repo --base main\n\n # 4. now the branch can go\n git push \"$BASE\" --delete \u003cmerged-branch>\n ```\n\n Merging the child first, then its base, avoids the situation entirely and\n is worth preferring when the review order allows it.\n2. **`mergeStateStatus` needs time and only `CLEAN` is trustworthy.** After a\n retarget it cycles through `UNKNOWN`/`BLOCKED`/`UNSTABLE` before settling.\n `UNSTABLE` means a **non-required** check is failing — decide explicitly\n whether that is acceptable; a merge gate that requires `CLEAN` treats it\n as blocked.\n\nThe robust bottom-up sequence for each child after its parent merged:\n\n```bash\ngh pr edit \u003cCHILD_NUMBER> --base main # retarget FIRST, while everything is open\n# wait until mergeStateStatus == CLEAN # separate step — state recomputes async\ngh pr merge \u003cCHILD_NUMBER> --merge # NO --delete-branch mid-stack\n# verify: git ls-remote origin main == the PR's mergeCommit.oid\n```\n\nDelete all stack branches in one pass **after the last PR merged** (a repo\nwith \"automatically delete head branches\" usually does it for you).\n\n#### GitHub-native stacked PRs: merge the tip, not each PR in turn\n\nThe sequence above is for a **hand-built** chain. GitHub's native Stacked PRs\nfeature (public preview, `gh stack`) behaves differently and the difference is\nthe whole point of using it: **merging the top PR merges every PR beneath it in\none operation.** Walking the stack bottom-up there is not merely slower — on a\nmerge-queue repo it is actively worse, because each PR costs its own queue cycle\nand the queue's merge-correctness validation rejects two chained entries that are\nin flight at the same time (\"invalid changes in the merge commit\").\n\n```bash\ngh pr merge \u003cTIP_NUMBER> --merge --auto # the whole stack lands\n```\n\nVerify containment before assuming a lower PR is covered, rather than reading it\noff the UI:\n\n```bash\ngit merge-base --is-ancestor \"$(gh pr view \u003cLOWER> --json headRefOid --jq .headRefOid)\" \\\n \"$(gh pr view \u003cTIP> --json headRefOid --jq .headRefOid)\" \\\n && echo \"LOWER is contained in TIP\"\n```\n\nObserved 2026-08-09 (netresearch/t3x-nr-llm): merging only #665 landed #663, #664 and #665 together; the preceding attempt to queue all three separately was rejected by the queue three times. The PRs that were merged this way close as\n`MERGED`; PRs whose commits reach `main` through a *different* PR (see batching,\nbelow) close as `CLOSED` and need their issues closed by hand.\n\nOne preview-era caveat worth knowing before relying on it: deleting a lower\nbranch can **close** the PR above it rather than retarget it, and reopening it\nwas refused. The base branch was not restored in that case, so whether the\nrestore-first sequence in point 1 would have reopened it is unmeasured. Leave\n`--delete-branch` off until the stack is fully merged; if it happens anyway,\ntry the restore-first sequence before concluding the PR is lost.\n\nRelated: a workflow **rerun executes the frozen merge commit** — it does not\nre-resolve `refs/pull/N/merge` against the moved base. A check that depends\non base state (template drift, conflict detection) stays wrong after main\nmoved; push a base-merge into the PR branch instead of rerunning.\n\n### Many independent PRs, one queue: batch them into an integration branch\n\nA merge queue serializes by design, so N ready PRs cost N queue cycles — and if\nthey all touch one accumulating file (`CHANGELOG.md`, a docs index), each merge\nalso invalidates the next one's merge commit, so every PR needs a forward-merge\nbefore its turn. Thirteen PRs took several hours this way and merged one per\nhour at best.\n\nWhen the PRs are genuinely independent, merge them locally into one integration\nbranch and send that through the queue instead. Two cycles replaced thirteen\n(netresearch/t3x-nr-llm#685 with ten PRs, #686 with three chain tips, both\n2026-08-09).\n\n```bash\ngit -C .bare worktree add ../integration -b integration/batch-$(date +%F) origin/main\ncd ../integration\nfor pr in 648 653 676 677; do\n git merge --no-edit \"$(gh pr view $pr --json headRefOid --jq .headRefOid)\"\ndone\n```\n\nFour things decide whether this pays off, and skipping any of them costs more\nthan the batching saved:\n\n- **Resolve the accumulator files once, deliberately.** The conflicts are almost\n always confined to append-only files. Auto-resolving them by keeping *both*\n sides works for the content but **duplicates entries and flattens section\n assignment** when the same entry was reworded on both sides — verify the\n merged file afterwards (count entries, check for duplicates) instead of\n trusting the resolver. Anything outside that known set is a real conflict:\n stop and resolve it by hand.\n- **Run the full gate on the integration branch, not on the individual PRs.**\n Combination breaks exist that no single PR can show: a constructor argument\n another PR makes mandatory, two PRs extending the same factory differently.\n Three such breaks appeared across two batches here — all invisible per-PR,\n all caught by the batch gate.\n- **Prove containment before closing anything.** `git merge-base --is-ancestor\n \u003cpr-head> origin/main` per PR, after the batch merges. This is the only\n evidence that a PR's work actually landed.\n- **The batched PRs close as `CLOSED`, not `MERGED`,** so GitHub does not run\n their `Closes #N` keywords. Close those issues explicitly and say which PR\n carried them, or the backlog silently keeps them open.\n\nDo not batch PRs that are chained (a stack) — merge the tip instead, see above.\n\n### Follow-up pushes to an armed PR branch: confirm the PR is still OPEN\n\nOnce auto-merge (or a queue) is armed, a fast merge plus branch auto-delete can\nland the PR before your follow-up commit does. `git push` to the deleted branch\nthen silently **re-creates it** — off a now-stale base, with your commit\ndangling and no PR attached; nothing errors and nothing merges.\n\nBefore pushing any follow-up to an armed branch, confirm the PR is still open:\n\n```bash\ngh pr view NUMBER --json state --jq '.state' # must print OPEN\n```\n\nAny other state (`MERGED`, `CLOSED`) is a stop signal: put the follow-up on a\nfresh branch off updated main and open a new PR. If a dangling re-created\nbranch already exists, delete it and rescue the commit via cherry-pick.\n\n### Auto-Merge / Merge-Queue Arming Gate\n\n`gh pr merge --auto` is a **deferred merge with no human in the loop** — and a\nmerge queue only re-runs *required checks*; it does **not** wait for review\nthreads, bot reviews in flight, or Sonar-style informational checks. Arming at\nPR creation therefore merges over unaddressed review feedback the moment CI is\ngreen.\n\nArm auto-merge / enqueue **only when all four hold**:\n\n1. **Zero unresolved review threads** (GraphQL `reviewThreads`, not the UI).\n2. **All checks green** — including non-required ones you intend to honor.\n3. **No pending review request** (`gh pr view --json reviewRequests` is `[]`)\n — a re-requested bot review that has not landed yet counts as pending.\n4. **A quiet runner pool** — see below.\n\n**Do not enqueue into a busy runner pool.** A merge queue drops an entry whose\nrequired checks do not report within `check_response_timeout_minutes`. The REST\ndocs state no default for that parameter — read the configured value from the\nruleset (the query below shows it; the two netresearch repos checked run 30\nand 60 minutes).\nThat clock starts at enqueue and covers the wait for a *runner*, not just\nthe run: with the pool saturated, the jobs sit in `queued` with no runner and\nthe entry is discarded having executed nothing.\n\nRetrying makes it worse. Each attempt spawns a full set of runs on a\n`gh-readonly-queue/*` branch, and a dropped entry **does not cancel them** — so\nevery retry leaves more runs holding slots and slows the next attempt. In\nnetresearch/ofelia this piled up 18 unfinished runs and turned a four-attempt\nloop into a guaranteed failure, while the same required checks concluded in\n**2.0 minutes** in PR context, well inside the window.\n\n**Tell a busy pool from a slow one before deciding.** `queued` and\n`in_progress` are different states and the API reports them separately;\ncollapsing both into \"pending\" reads as \"CI is running\" when in truth no runner\nhas picked anything up. `pr-status.sh` keeps them apart — the checks line shows\n`N pending (X running, Y queued)` — and answers `await-capacity` instead of\n`wait` when a *required* check is queued while nothing at all is running. That\nis the precondition for the paragraph above: enqueueing in that state is what\ngets the entry dropped.\n\nThe concurrency limit itself is not readable. `GET /orgs/{org}/actions/hosted-runners/limits`\nanswers `404 \"GitHub hosted runners are not supported for this organization\"`\noutside the larger-runner product, and the per-plan ceiling for standard runners\nexists only in the documentation. So do not try to compare against a number —\nobserve the state instead: required checks queued with zero running is\nsufficient on its own, and needs no knowledge of the plan.\n\nGate on it instead: enqueue when the repo has at most ~2 unfinished runs.\n\n```bash\ngh run list --repo \"$R\" --limit 25 --json status \\\n --jq '[.[]|select(.status!=\"completed\")]|length'\n```\n\nDone that way, three consecutive PRs merged on the first attempt after one had\nbeen thrown out four times from a loaded pool. If an entry still drops on a\nquiet pool, the timeout is genuinely too short — read it rather than retrying:\n\n```bash\ngh api \"repos/$R/rules/branches/main\" \\\n --jq '.[]|select(.type==\"merge_queue\")|.parameters'\n```\n\nBefore that one retry, cancel your own orphaned queue runs — only your PR's,\nsince other PRs thrash the same way and their runs are not yours to kill:\n\n```bash\ngh run list --repo \"$R\" --limit 40 --json databaseId,status,headBranch \\\n | jq -r --arg p \"gh-readonly-queue/main/pr-$PR-\" \\\n '.[] | select(.status != \"completed\")\n | select(.headBranch | startswith($p)) | .databaseId' \\\n | xargs -r -n1 gh run cancel --repo \"$R\"\n```\n\n**A sweep across many repositories shares one pool.** The count above looks at\none repository, but the concurrency limit for GitHub-hosted runners belongs to\nthe account's plan, not to a repository: 20 concurrent standard jobs on Free,\n60 on Team (\u003chttps://docs.github.com/en/actions/reference/limits>; read the plan\nwith `gh api orgs/\u003corg> --jq .plan.name`). Jobs above the limit queue. One\npull request in a repository with 58 check contexts already exceeds 20, so\npushing a change to 27 such repositories at once (2026-10-07) queued jobs for\nmore than 50 minutes, and merge-queue entries of three pull requests were\ndropped four times with `checks_timed_out` (the PR timeline's\n`removed_from_merge_queue` events; `check_response_timeout_minutes` is 60 in\nall three repositories). Two of the four drops lie outside the incident\nbelow: netresearch/t3x-nr-llm#1013 (queued 11:34, dropped 13:15 UTC) and\nthe second drop of netresearch/t3x-nr-wellknown#21 (re-queued 18:24,\ndropped 19:24, exactly the 60-minute timeout). Its first drop and the one\nof netresearch/t3x-rte_ckeditor_image#915 spanned the incident. Push a\nsweep in small groups and start the next group once the previous group's\nruns have finished; count the unfinished runs across every repository of\nthe sweep, not only the one you are about to enqueue. The jobs that failed\noutright during that sweep did so inside a GitHub incident (see\n`merge-gate-watcher.md`, \"Jobs that never started\") and are not attributed\nto the sweep.\n\n**Ejection with ZERO dispatched runs is a third failure mode** — not a busy\npool (runs would exist as `queued`) and not a short timeout (runs would exist\nand be running). Here the `merge_group` events never reach Actions at all:\n\n```bash\ngh run list --repo \"$R\" --event merge_group --limit 5 \\\n --json createdAt,headBranch # newest run predates your enqueue → no dispatch\n```\n\nThe tell: every queue entry drops **at the same instant** when the oldest\nentry's response timeout expires, timeline events show\n`removed_from_merge_queue` with no reason, and `gh run list` has no\n`gh-readonly-queue/*` runs for any of them — while `pull_request`/`push`\nworkflows on the same repo dispatch normally. That is a transient GitHub-side\ndispatch outage, not a workflow or ruleset problem (verify the required\ncontexts' workflows do carry the `merge_group:` trigger before concluding\nthis). Requeue once later and watch for the first `merge_group` run within a\nfew minutes — it decides between outage-over and genuinely-broken. (Observed\n2026-08-25, netresearch/ldap-manager — its `check_response_timeout_minutes`\nis 60, hence the hour: two entries starved ~60 min with zero runs and dropped\ntogether at 23:59; the same PR requeued 40 min later dispatched immediately\nand merged.)\n\nBot reviews (Copilot, Gemini) land 2–5 minutes after each push — wait that\nwindow out before concluding \"no threads\".\n\n**A cancelled run leaves rows behind, and they are not failures.** When a push\nsupersedes a run, its jobs conclude `CANCELLED` and those check-runs stay on the\n*earlier* commit forever. Where a workflow's `name:` is an unevaluated\nexpression they are doubly confusing, showing up as rows literally called\n`e2e / matrix.typo3 != '' && …`. `pr-status.sh` splits them: a cancelled context\nthat later reported under the same name is counted as **stale** and dropped from\nthe numbers, while one that never reported again still shuts the gate and is\nlisted under `cancelled — re-run, do not debug`. Before this split, two stale\ne2e rows read as `2 fail` with `NEXT: fix-ci` on a pull request GitHub itself\ncalled `CLEAN`, which cost an hour of log archaeology on a run that had been\ncancelled on purpose.\n\n**Review on an earlier head + `CLEAN`: decide via the timeline, not the\nreview list.** After a follow-up push (docs-only changes often do not\nre-trigger Copilot), the only review on record may sit on a previous commit\nwhile `mergeStateStatus` reports `CLEAN` off it. Whether that is mergeable\ndepends on one question: was any review (re)announced *after* the latest\npush? The reviews list cannot answer it — query the timeline events:\n\n```bash\nR=\u003cowner/repo>; PR=\u003cnumber>\ngh api repos/$R/issues/$PR/timeline --jq \\\n '[.[] | select(.event==\"review_requested\" or .event==\"reviewed\")\n | {event, actor: (.actor.login // .user.login), at: (.created_at // .submitted_at)}]'\n```\n\n- Last `review_requested` is **before** the latest push and a matching\n `reviewed` followed it, no newer request → no review is in flight; the\n old-head review + `CLEAN` is mergeable.\n- A `review_requested` **after** the latest push with no `reviewed` yet →\n a review is in flight; wait (see *Never merge over an announced review*).\n\n**Recovery when armed too early:**\n\n```bash\n# A PR already picked up by the queue rejects --disable-auto AND branch pushes\n# (\"Pull request is already queued to merge\"). Dequeue it via GraphQL:\nPRID=$(gh api graphql -F owner=OWNER -F repo=REPO -F pr=NUMBER \\\n -f query='query($owner:String!,$repo:String!,$pr:Int!){repository(owner:$owner,name:$repo){pullRequest(number:$pr){id}}}' \\\n --jq .data.repository.pullRequest.id)\ngh api graphql -F id=\"$PRID\" \\\n -f query='mutation($id:ID!){ dequeuePullRequest(input:{id:$id}) { mergeQueueEntry { id } } }'\n# Branch is pushable again; fix threads, then re-arm through this gate.\n```\n\nThe push rejection is a **protected-branch hook**, not a permissions problem:\n\n```\nremote: error: GH006: Protected branch update failed for refs/heads/\u003cbranch>.\nremote: - A pull request for this branch has been added to a merge queue.\nremote: Branches that are queued for merging cannot be updated.\n```\n\nNote the mutation input field is `id:`, not `pullRequestId:` — the latter fails\nwith `InputObject 'DequeuePullRequestInput' doesn't accept argument`.\n\n**An enqueued PR still reads as `CLEAN`, so \"clean, go merge\" is the wrong\nverdict.** `mergeStateStatus` describes the branch, not the queue: a PR sitting\nin the queue keeps answering `CLEAN` with every check green and no open thread,\nwhich is exactly what a merge-readiness check looks for. Ask the PR itself\nwhether it is already in line:\n\n```bash\ngh api graphql -f query='{repository(owner:\"OWNER\",name:\"REPO\"){\n pullRequest(number:NNN){ mergeQueueEntry{ state position estimatedTimeToMerge } }}}'\n```\n\n`mergeQueueEntry` is null unless this PR holds an entry; when it does, it\ncarries `position`, `state` (`QUEUED`, `AWAITING_CHECKS`, `MERGEABLE`,\n`UNMERGEABLE`, `LOCKED`) and an ETA in seconds. The repo-level signal — a\n`merge_queue` ruleset exists — answers a different question and cannot stand in\nfor it. `pr-status.sh` reports both, as `queue=` and an `in queue` line, and\nanswers `wait` rather than `merge` while an entry is held; `pr-merge.sh`\nrefuses on that same verdict. Enqueueing an entry that already exists only\nrestarts its checks and pushes the merge further out.\n\n**`--delete-branch` is rejected while a merge queue is enabled.** `gh pr merge`\nfails outright with:\n\n```\nCannot use -d or --delete-branch when merge queue enabled\n```\n\nThis is a hard error, not a warning: the merge does not happen. In a batch\nmerge loop the flag silently costs a full round per PR — four rounds were burned\non it in one rollout before the message was read. Drop `--delete-branch` from\nany script that merges across repos where some enable a queue, and let the\nrepo's own \"automatically delete head branches\" setting handle cleanup. Passing\na merge-method flag (`--merge` / `--rebase`) is likewise pointless once a queue\nowns the branch; it responds with `The merge strategy for main is set by the\nmerge queue`.\n\n**Verify a \"dropped\" queue entry via the issue timeline before re-arming.**\nRight after a queue merge, `gh pr view --json state` can report `OPEN` and the\nqueue listing can show the entry gone for several minutes — the exact signature\nof a silent drop, except the PR already merged. Re-arming (or re-diagnosing) on\nthat stale read wastes a round-trip. The issue **timeline** is authoritative:\n\n```bash\ngh api repos/{owner}/{repo}/issues/NUMBER/timeline --paginate \\\n --jq '.[]? | select(.event | IN(\"added_to_merge_queue\", \"removed_from_merge_queue\",\n \"merged\", \"closed\")) | \"\\(.created_at) \\(.event)\"'\n# removed_from_merge_queue immediately followed by merged -> it landed; do nothing.\n# removed_from_merge_queue is the LATEST queue event\n# (no added_to_merge_queue or merged after it) -> real silent drop; re-arm.\n```\n\n### \"Never merge Dependabot/Renovate by hand\" presumes a deps workflow exists\n\nLeaving bot PRs alone is right *because* an auto-merge workflow lands them once\nthey are green. Where no such workflow is installed, the rule has no mechanism\nbehind it and simply strands the PRs. One repo in a sweep carried **12 open bot\nPRs** — including a `tar` security update sitting `CLEAN` and untouched for five\nweeks — because its `.github/workflows/` held only `ci.yml` and a publish\nworkflow.\n\nCheck for the mechanism before deferring to it:\n\n```bash\ngh api \"repos/$R/contents/.github/workflows\" --jq '.[].name'\n```\n\n- **A deps auto-merge workflow exists** → the rule applies. Do not merge by\n hand; bring the PR green and let the workflow land it. If it is green and\n still open, the workflow ran before the checks passed — re-run it rather than\n merging (`gh run rerun \u003cid>` on that workflow's run for the PR's head SHA).\n- **No such workflow** → the rule does not apply. Merging bot PRs by hand is not\n forbidden, it is simply unautomated: report the stranded PRs and the missing\n automation instead of leaving them to rot. Flag security updates and majors\n separately — a `tar` advisory fix and a `jest` v30 major do not carry the same\n risk and should not be swept together.\n\nAlso note: queue membership is GraphQL-only — `isInMergeQueue` /\n`mergeQueueEntry` are **not** `gh pr view --json` fields (the call errors);\nquery `pullRequest { mergeQueueEntry { state position } }` via `gh api graphql`.\n\n**`autoMergeRequest: null` does NOT mean \"not armed\" on a merge-queue repo.**\nWhen you arm a queue PR, `gh pr merge --auto` prints *\"merge strategy set by the\nmerge queue\"* and returns immediately — and `gh pr view --json autoMergeRequest`\nthen reports `null`, because the queue owns the merge, not GitHub's auto-merge\nfeature. Reading that `null` as \"arming failed\" and re-running `--auto` is a\nwasted round-trip (and can error \"already queued\"). Confirm the PR is enqueued\nvia the GraphQL `mergeQueueEntry { state position }` or the timeline\n`added_to_merge_queue` event — never via `autoMergeRequest`.\n\n**A pending auto-merge request silently swallows the enqueue.** The converse of\nthe note above, and the more expensive one: when an auto-merge request *is*\nattached to the PR, `gh pr merge \u003cn> --repo \u003cr> --merge` on a merge-queue\nrepository prints its usual success line and **exits 0 while adding nothing to\nthe queue**. Measured on one PR: immediately after the call `isInMergeQueue` was\n`false` and the queue was empty; after `gh pr merge \u003cn> --repo \u003cr>\n--disable-auto` the identical call put it at position 1. Nothing shows the\nfailure — `gh pr checks` is green, `mergeStateStatus` stays `CLEAN`, no timeline\nevent is written, and the shell sees a zero exit — so a driver that trusts the\nexit status reports \"queued\" for a PR that will never merge. This cost several\nhours of misdiagnosis before the queue was read back. Two habits follow: never\nreport an enqueue you have not read back, and check `autoMergeRequest` first\nwhen a queue entry fails to appear.\n\n```bash\ngh api graphql -f query='{repository(owner:\"OWNER\",name:\"REPO\"){\n pullRequest(number:NNN){ state isInMergeQueue\n autoMergeRequest{ enabledBy{ login } } }}}'\n# state MERGED -> it landed.\n# isInMergeQueue true -> really queued.\n# neither, autoMergeRequest non-null -> the request swallowed it: --disable-auto, then retry.\n```\n\n`pr-merge.sh` does exactly this after every merge call, polls a few seconds for\nthe entry to register, and exits 2 with the `--disable-auto` remedy rather than\nclaiming an enqueue that did not happen. It reports only — clearing someone\nelse's auto-merge request is a caller decision, not a side effect of asking to\nmerge.\n\n### Signing Readiness (Preflight — Before Committing)\n\nTwo failures hide behind \"signing is broken\", and they surface at opposite ends of the run. A **local** signing failure surfaces immediately: `git commit -S` aborts and the branch does not move. A **host** verification failure — the key signs fine but GitHub does not recognise it — surfaces only at the *merge gate* (BLOCKED on DCO / \"verified signatures\"), i.e. **after** all the work is committed and pushed, forcing a full re-sign cycle. Preflight both before a commit-heavy run (e.g. `/pr-finish`): confirm a signing key is actually available and that `git commit -S` will sign, rather than assuming it.\n\n```bash\nscripts/signing-preflight.sh # exit 0 READY · 1 NOT READY · 2 could not probe\n```\n\n`signing-preflight.sh` is the mechanical form of everything below: it probes on a throwaway branch through a temporary index (so a staged change is never swept into the probe commit), asserts on the commit object, retries once with `--no-verify` to tell a hook rejection apart from a signing failure, and removes the branch either way. `--check-commit \u003crev>` answers the same question for an existing commit, `--config-only` reports the config without committing. Its regression suite is `tests/test_signing_preflight.sh`; checkpoint GW-17 applies the same rule to a repository being assessed.\n\nBy hand, where the script is not available:\n\n```bash\n# SSH-signing setups: is a key the agent can sign with actually loaded?\nssh-add -l # \"no identities\" → signing (and any SSH git auth) will fail until re-added\n# Definitive probe: a throwaway signed commit carries a signature, then drop it —\n# on a throwaway branch, not on `main` (see commit-conventions.md, \"Verify signing capability without committing on `main`\")\ngit switch -c tmp/sign-probe\n# conventional msg — a commit-msg hook rejecting `probe` aborts the commit and reads as a signing failure\ngit commit -S --allow-empty -m \"chore: signing probe\" \\\n && (git cat-file commit HEAD | sed -n '/^$/q;p' | grep -qE '^gpgsig(-sha256)? ' && echo \"SIGNING READY\" || echo \"SIGNING NOT READY\") \\\n || echo \"SIGNING NOT READY — commit failed\"\ngit switch - && git branch -D tmp/sign-probe\n```\n\n**Assert on the commit object, not on local verification.** `git log --show-signature` / `%G?` answer the narrower question \"can *this machine* verify the signature\", and report failure on setups that sign perfectly well. With `gpg.format=ssh` and no `gpg.ssh.allowedSignersFile`, `git log --show-signature -1` prints `error: gpg.ssh.allowedSignersFile needs to be configured and exist for SSH signature verification` and then `No signature`, and `%G?` returns `N` — on a commit that carries a valid signature and that the host reports as `verified: true` once the key is registered as a signing key. The command still **exits 0** while printing that error, so a driver reading `$?` sees success too.\n\n`N` is indistinguishable from genuinely unsigned, which makes it more dangerous than the `E` case in *Signature verification: the GitHub API is the source of truth, not your keyring* above — `E` at least reads as \"could not check here\". Setting `gpg.ssh.allowedSignersFile` flips that same unchanged commit to `Good \"git\" signature` / `%G? = G` (measured on git 2.54.0), which is the fix if you also want local verification to work.\n\nThe `gpgsig` header depends on no verification config and is written by both backends (`-----BEGIN SSH SIGNATURE-----` under `gpg.format=ssh`, `-----BEGIN PGP SIGNATURE-----` under GPG), so it answers exactly what the preflight asks: did `git commit -S` produce a signature. Cut the header at the first blank line (`sed -n '/^$/q;p'`) — matching `^gpgsig` against the whole object also matches a message *body* line starting with `gpgsig`, and reports an unsigned commit as signed.\n\n**Do not assert on `Good` either.** Unanchored, it matches the `Author:` line, so an unsigned commit by an author named e.g. \"Goodwin\" reports `SIGNING READY`. And a local `Good` only proves your own allowed-signers file accepts your key, never that GitHub does — that is the `unknown_key` API check under *Signing and DCO Failures* below. **Keep the commit chained to the check with `&&`.** `git commit -S` cannot silently produce an unsigned commit: with a key it cannot load it aborts (`fatal: failed to write commit object`, exit 128, HEAD unmoved). But an unchained check then reads the *parent* commit, which in signed history carries its own `gpgsig` header — so it prints `SIGNING READY` for a probe that never happened.\n\nIf the probe fails (no askpass, a locked/dropped key, or an unloadable `user.signingkey`), resolve it **before** doing the work — the mid-run remedy is the same `rebase --exec` re-sign as a reactive failure, but you avoid discovering it at the gate. `commit failed` is not by itself a signing verdict: a `commit-msg` hook rejecting the probe message aborts the commit exactly the same way, so read the commit output before chasing keys. See *Signing and DCO Failures* below for that remedy.\n\n### Signing and DCO Failures\n\nWhen `mergeStateStatus: BLOCKED` and the blocking check is `dco` or a \"Commits must have verified signatures\" branch-protection rule, act on these in order:\n\n> If the unsigned commits are **someone else's** — e.g. a fork merging upstream history —\n> none of the steps below apply: you cannot sign off on another author's work, and the\n> rebase in Step 2 would forge it. See\n> [Merging Divergent Upstream History (Forks)](#dco-and-third-party-history-are-structurally-incompatible).\n> If they already carry their author's own `Signed-off-by` and lack only the cryptographic\n> signature, see [Re-signing a contributor's unsigned commits](#re-signing-a-contributors-unsigned-commits).\n\n**Step 1 — Verify git identity is correct (not swapped).**\nA swapped name/email pair silently produces a malformed `Signed-off-by:` trailer that the DCO bot rejects:\n\n```bash\ngit config user.name # must look like \"Firstname Lastname\", NOT an email address\ngit config user.email # must contain \"@\", NOT a plain name\n\n# Fix if swapped:\ngit config --global user.name \"Firstname Lastname\"\ngit config --global user.email \"[email protected]\"\n```\n\n**Step 2 — Add missing sign-offs to all commits in the branch.**\nRebase with `--exec` to amend every commit at once. Use `--signoff` for DCO, `-S` for signature, or both:\n\n```bash\n# Both DCO sign-off and GPG/SSH signature in one pass:\ngit rebase origin/main --exec 'git commit --amend --no-edit --signoff -S'\ngit push --force-with-lease\n```\n\nIf some commits in the range already carry a sign-off followed by other trailers, `--signoff` adds a second one to each of them; the guarded form that skips those commits, and the check that counts sign-offs afterwards, are in `advanced-git.md` § *Driving `rebase -i` Without an Editor*.\n\n**Step 3 — If signatures still show `reason: unknown_key`, the SSH key is not registered as a *Signing Key* on GitHub.**\nAuth keys and signing keys are separate registrations. An authentication key cannot verify commits:\n\n```bash\n# Check commit verification after pushing:\ngh api /repos/{owner}/{repo}/commits/HEAD --jq '.commit.verification | {verified, reason}'\n# \"reason\":\"valid\" → OK\n# \"reason\":\"unknown_key\" → key is not registered as a signing key\n# \"reason\":\"unsigned\" → -S flag was not used or signing config is missing\n```\n\nIf `unknown_key`: go to *github.com → Settings → SSH and GPG keys*, find your key, and add it again under *New signing key* (same public key, different \"Key type\"). After adding, re-verify with the API call above.\n\n#### Re-signing a contributor's unsigned commits\n\nThe case: a contributor's commits satisfy DCO — each carries their own `Signed-off-by` — but have no cryptographic signature, and branch protection requires verified signatures. A linear `git rebase -S \u003cbase>` fails when the branch contains merges from the base that resolved conflicts: each replayed commit conflicts again.\n\nRe-create every commit above the merge base instead, with `git commit-tree -S` on its exact tree, message and author. Parents inside the range are remapped to their new copies; parents outside the range are kept, so merges stay merges. Assert the tree per commit and for the head:\n\n```bash\nbase=$(git merge-base HEAD origin/main)\nold_head=$(git rev-parse HEAD)\ndeclare -A new\nfor c in $(git rev-list --reverse --topo-order \"$base..HEAD\"); do\n parents=()\n for p in $(git rev-list --parents -n 1 \"$c\" | cut -d' ' -f2-); do\n parents+=(-p \"${new[$p]:-$p}\") # inside the range: the new copy; outside: unchanged\n done\n new[$c]=$(git cat-file commit \"$c\" | sed '1,/^$/d' |\n GIT_AUTHOR_NAME=\"$(git show -s --format=%an \"$c\")\" \\\n GIT_AUTHOR_EMAIL=\"$(git show -s --format=%ae \"$c\")\" \\\n GIT_AUTHOR_DATE=\"$(git show -s --format=%ad --date=raw \"$c\")\" \\\n git commit-tree -S \"${parents[@]}\" \"$c^{tree}\")\n [ \"$(git rev-parse \"${new[$c]}^{tree}\")\" = \"$(git rev-parse \"$c^{tree}\")\" ] || { echo \"tree differs: $c\"; exit 1; }\ndone\n[ \"$(git rev-parse \"${new[$old_head]}^{tree}\")\" = \"$(git rev-parse \"$old_head^{tree}\")\" ]\ngit update-ref \"refs/heads/$(git branch --show-current)\" \"${new[$old_head]}\" \"$old_head\"\n```\n\nIf the branch is out of date, merge the current base in with one signed merge (`git merge -S --signoff origin/main`; the merge is your own commit, so the sign-off is yours) rather than rebasing.\n\nThis adds the pusher's committer signature. It does not add or forge a `Signed-off-by` — the message is copied byte for byte — so the warning above about signing off on another author's work does not apply. The contributor stays the author.\n\nWhen the pull request allows maintainer edits, push to the contributor's branch with a lease on the head you rewrote: `git push --force-with-lease=\u003cbranch>:\u003cold head> \u003cfork-remote> HEAD:\u003cbranch>`.\n\n### Merge-Gate Command\n\n```bash\n# The gate is TWO queries. `reviewThreads` is NOT a valid `gh pr view --json`\n# field — gh errors \"Unknown JSON field: reviewThreads\" (its whitelist has\n# reviews / reviewRequests / reviewDecision, not reviewThreads), and passing it\n# fails the WHOLE call. Thread resolution is only available via GraphQL.\n#\n# (1) PR-level fields via gh pr view (--json takes a no-spaces comma list):\ngh pr view NUMBER --json reviewDecision,mergeStateStatus,mergeable,statusCheckRollup\n\n# (2) unresolved-thread count via GraphQL (must be 0):\ngh api graphql -f query='{repository(owner:\"OWNER\",name:\"REPO\"){pullRequest(number:NUMBER){\n reviewThreads(first:100){nodes{isResolved}}}}}' \\\n --jq '[.data.repository.pullRequest?.reviewThreads?.nodes[]? | select(.isResolved==false)] | length'\n\n# Merge-ready requires ALL of:\n# reviewDecision == \"APPROVED\" OR \"\" (empty = no\n# human-approval rule; CLEAN then\n# already encodes the gate — do\n# NOT treat \"\" as a blocker)\n# mergeStateStatus == \"CLEAN\"\n# mergeable == \"MERGEABLE\"\n# every statusCheckRollup[].conclusion == \"SUCCESS\"\n# unresolved-thread count (query 2) == 0\n```\n\n**The gate and the merge are two separate invocations.** Run the gate query,\nread its output, and only then issue `gh pr merge` as a new command. Never\nchain them (`gate-query && gh pr merge`, or query-then-merge in one\nheredoc/compound command): shell chaining decides on **exit codes**, not on\nthe gate's content — `gh pr view` exits 0 whether it reports zero unresolved\nthreads or three, so the merge fires before anyone has read the gate's\noutput. And `mergeStateStatus: CLEAN` does **not** imply zero unresolved\nthreads — GitHub only couples the two when the \"require conversation\nresolution\" branch-protection rule is enabled, which most repos don't turn on.\n\n```bash\n# ❌ Wrong — merge already executed by the time the gate output is visible\ngh pr view 42 --json mergeStateStatus && gh pr merge 42 --merge\n\n# ✅ Right — run the gate queries, READ the output, then merge as a new command\ngh pr view 42 --json reviewDecision,mergeStateStatus,mergeable,statusCheckRollup\ngh api graphql -f query='{repository(owner:\"OWNER\",name:\"REPO\"){pullRequest(number:42){reviewThreads(first:100){nodes{isResolved}}}}}' --jq '[.data.repository.pullRequest?.reviewThreads?.nodes[]?|select(.isResolved==false)]|length'\n# READ both: all threads resolved (count 0)? all checks green? Only then:\ngh pr merge 42 --merge\n```\n\n#### Diagnosing `mergeStateStatus: BLOCKED`\n\n`BLOCKED` tells you the PR is not mergeable; it never tells you **why**. Derive the cause from the gate fields above — not from the branch-protection / ruleset inventory (`gh api repos/{repo}/rules/branches/{branch}` or `…/branches/{branch}/protection`). That inventory lists which rules *exist*, not which one is *currently failing*; reading \"copilot_code_review is configured\" and concluding \"Copilot is blocking\" is a classic false attribution. Walk the decisive evidence in this order:\n\n| Symptom in the gate output | Actual blocker |\n|---|---|\n| `reviewDecision: \"REVIEW_REQUIRED\"` | a required approving review is missing — request/await it |\n| `reviewDecision: \"\"` **and** still BLOCKED | **not** a review-approval block — keep looking (this is the field that disproves \"a reviewer is blocking it\") |\n| any `statusCheckRollup[].conclusion != \"SUCCESS\"` (incl. pending) | a required check — name *that* check, not a rule |\n| `reviewThreads[].isResolved == false` exists | unresolved conversations + the repo's `required_conversation_resolution` toggle — resolve the threads |\n| all the above clean, still BLOCKED | branch behind base (needs update), or merge-queue / required-deployment gate |\n\nWhen unsure which protection toggle couples to the symptom, read it directly: `gh api repos/{repo}/branches/{branch}/protection --jq '{conversation: .required_conversation_resolution, reviews: .required_pull_request_reviews, checks: .required_status_checks.contexts, strict: .required_status_checks.strict}'`. State the cause only once you can point at the field that proves it.\n\nThe PR-level gate above covers review decision, merge state, required checks, and thread resolution in one response. A second check is needed for CI annotations (warnings — reviewdog / actionlint / CodeQL deprecations — that don't fail their check but still need addressing). These are a commit-level property, not a PR-level one:\n\n```bash\ngh api \"repos/{owner}/{repo}/commits/SHA/check-runs\" \\\n --jq '.check_runs[] | select(.output.annotations_count > 0) | {name: .name, annotations: .output.annotations_count}'\n```\n\n> **Important:** CI annotations are invisible in the PR summary view but visible in the job detail \"Annotations\" section on the Files Changed tab. Always check for annotations before declaring a PR clean.\n\nFor automated enforcement at tool-invocation time, see the `merge-gate.sh` hook recipe in `references/claude-code-hooks.md`. The hook enforces the **runtime-checkable subset** — `mergeStateStatus` and unresolved thread count — which covers the most common block reasons. It deliberately does not gate on `reviewDecision`: repos without a required-approval rule report `reviewDecision` \"\" and merge legitimately when CLEAN, so that gate would false-positive-block them; `mergeStateStatus == CLEAN` already encodes any required-approval rule. Signed-commits and CI-annotations checks are not enforced by the hook (annotations in particular require the commit-level API call above); rely on the repo's branch-protection rules and local pre-commit hook for those.\n\n> **Important:** CI checks can PASS while emitting warning annotations (e.g., actionlint/shellcheck via reviewdog, CodeQL deprecation notices). These are invisible in the PR summary view but visible in the job detail \"Annotations\" section. Always check for annotations before declaring a PR clean.\n\n### Spec-Cleanup Guard\n\nIntermediate planning artifacts (superpowers specs/plans, ad-hoc `PLAN.md`,\nplanning-tool output) must not ride into the base branch. The guard is\ndeterministic and **read-only** — it detects and reports, never deletes.\n\n```bash\n# Exit 0 = clean; exit 1 = artifacts found (resolve before merge); exit 2 = config error.\nbash skills/git-workflow/scripts/spec-cleanup-guard.sh\n```\n\nIf it exits 1, resolve via the `/pr-finish` spec-cleanup step (convert to an ADR /\nremove / acknowledge) so the branch is clean, then re-run. Full capability —\nconfig, three-state detection, Capture flow — is in `references/spec-cleanup.md`.\n\n### Rulesets: the gate `gh pr view` doesn't show\n\n`mergeStateStatus: BLOCKED` with `reviewDecision: \"\"`, every check green, and\nevery thread resolved almost always means a **repository ruleset** — rulesets\nare evaluated for merge but are invisible to both the merge-gate `gh pr view`\nand the classic `branches/{branch}/protection` API. Don't discover this by\ntrial-and-error; fetch the *effective* rules as part of the gate:\n\n```bash\n# gh resolves {owner}/{repo} from git context but NOT the branch — fill in BASE,\n# the branch you merge INTO (e.g. main / develop), not the feature branch.\n# The endpoint returns an array of rule objects, so group_by(.type) works:\ngh api repos/{owner}/{repo}/rules/branches/BASE \\\n --jq 'group_by(.type)[] | {type: .[0].type, n: length}'\n```\n\nThe common culprit is a `copilot_code_review` rule: it requires a Copilot\nreview on the **latest commit**. A push *may* trigger a fresh review, but not\nalways, and Copilot is not reliably re-requested automatically — so never\nassume the review state tracks your latest commit. If the gate is blocked and\nthe bot's latest review is on a commit that predates the head, re-request\nexplicitly, then re-poll the gate:\n\n```bash\ngh api repos/{owner}/{repo}/pulls/NUMBER/requested_reviewers \\\n -X POST -f 'reviewers[]=copilot-pull-request-reviewer[bot]'\n```\n\n(`gh pr edit --add-reviewer` rejects the bot login with \"Could not resolve\nuser\"; the REST `requested_reviewers` endpoint is the working path.)\n\n#### Is that red check actually blocking? `isRequired` is the only answer\n\nA failing check is not automatically a blocker, and neither `gh pr checks` nor\n`statusCheckRollup` says which ones the base branch requires — so a red\nnon-required check gets chased for nothing while the real blocker stays\ninvisible. `isRequired(pullRequestNumber:)` on the rollup contexts answers it\ndirectly, and unlike `repos/{owner}/{repo}/rules/branches/BASE` it does not need\npermissions that a token may lack:\n\n```bash\ngh api graphql -f query='\n{ repository(owner:\"OWNER\", name:\"REPO\") {\n pullRequest(number:NUMBER) {\n mergeable mergeStateStatus\n commits(last:1){nodes{commit{statusCheckRollup{contexts(last:100){nodes{\n ...on CheckRun{name conclusion isRequired(pullRequestNumber:NUMBER)}\n ...on StatusContext{context state isRequired(pullRequestNumber:NUMBER)}}}}}}}\n } } }' --jq '\n [.data.repository.pullRequest.commits.nodes[0].commit.statusCheckRollup.contexts.nodes[]\n | select(.isRequired==true) | \"\\(.name // .context) = \\(.conclusion // .state)\"] | .[]'\n```\n\nA `null` in that output is a required check still running — that, not the red\none, is what `BLOCKED` is waiting on. Observed 2026-08-09\n(netresearch/t3x-nr-llm#687): `copilot-pull-request-reviewer` was red for the\nwhole run and never appeared in the required list; the PR merged on it. Note the\nnumber is passed twice — once to `pullRequest(number:)` and once to each\n`isRequired(pullRequestNumber:)`; omitting the second yields a schema error, not\na default.\n\n**Always check for an ongoing review before merging — don't merge on a\ntransient `CLEAN`.** A bot review can be *in progress* (after a re-request, and\nsometimes after a push): `mergeStateStatus` can read `CLEAN` for a few seconds\nbefore the bot posts its comments, and merging then strands fresh review\nthreads on a closed PR. A **pending review request is the in-progress signal** —\ntreat the PR as not ready while it persists. Poll until the request clears\n*and* the bot's latest review matches the head commit `oid`:\n\n```bash\ngh api graphql -f query='{repository(owner:\"OWNER\",name:\"REPO\"){pullRequest(number:NUMBER){\n headRefOid\n reviewRequests(first:10){nodes{requestedReviewer{... on Bot{login} ... on User{login}}}}\n reviews(last:20){nodes{author{login} state commit{oid}}}}}}' # last:N must exceed the review count\n# Ready only when: no pending reviewRequests AND the bot's latest review.commit.oid == headRefOid.\n```\n\nOther ruleset rules to expect: `required_approving_review_count`, `required_review_thread_resolution`, `non_fast_forward`.\n\n> **Front-load the whole picture.** Gather merge state, checks, rulesets,\n> requested reviewers, and thread IDs in one mechanical block before reasoning\n> about merge-readiness — see the Merge-Gate Command above plus this ruleset\n> call. Discovering gates one round-trip at a time is the anti-pattern.\n\n## Self-Authored PR Merge (Permission Classifier)\n\nWhen you drive your own PR to merge (e.g. via `/pr-finish`), the auto-mode\npermission classifier blocks self-merges and admin self-bypass — a self-authored\nmerge is treated as requiring a human. Do **not** attempt the merge twice and\nthen bounce to the human; two denials read as stalling.\n\n- Recognize up front that finishing a self-authored PR will hit the classifier,\n and settle the merge path **before** starting.\n- For archive/cleanup tasks, take the local-clone + signed-commit + PR path from\n the start — not a Contents-API commit or an admin bypass the classifier will\n reject.\n- If a human merge is genuinely required, ask **one** structured question up\n front rather than discovering the block via two denials.\n\n## Shared-Account and Parallel-Job PR Races\n\nWhen several agent jobs run under the **same** git/GitHub identity (a shared\nbot/CI account), a PR can be force-pushed or merged out from under your review or\ntake-over by a parallel job — and you cannot prove your own job didn't do it.\nDefend:\n\n1. **Snapshot head + merge state** at the start of any review/take-over, and\n re-check immediately before acting; abort or rebase if it moved:\n\n ```bash\n gh pr view \u003cNUMBER> --json headRefOid,state,mergeStateStatus\n ```\n\n2. **Never trust a pre-existing shared worktree** for review/fix — a parallel job\n may churn or delete it mid-task. Create your own isolated worktree for the PR\n branch (or off a freshly-fetched `origin/main` if starting a new branch).\n3. **`gh pr diff` vs the file you `Read` disagree?** The branch was force-pushed\n between calls — re-fetch and re-derive from the committed state on origin.\n\n## Before Editing Shared Config, List Your Own Open PRs\n\nThe section below assumes you know the sibling PR exists. Often you do not — and\nthe one you are most likely to miss is **your own**, opened weeks earlier and\nforgotten.\n\nShared config is the collision surface: a CI workflow, `composer.json` /\n`package.json`, a linter or static-analysis config, a Renovate or Dependabot\nfile. Two open PRs editing the same few lines conflict whichever merges second,\nand **neither pipeline can show it** — each only ever runs its own copy of the\nfile. Nothing goes red until a maintainer hits the conflict, or worse, until the\nsecond merge quietly reverts the first.\n\nOne call, before the edit:\n\n```bash\n# Every PR you have open on this repo, and what each one touches.\ngh pr list --repo OWNER/REPO --author @me --state open \\\n --json number,title,files \\\n --jq '.[] | \"#\\(.number) \\(.title)\\n \" + ([.files[].path] | join(\"\\n \"))'\n```\n\nIf a file you are about to edit appears there, pick one of three deliberately:\n\n1. **Drop the edit** and let the existing PR carry it — usually right when the\n other PR does the same thing for its own reasons.\n2. **Rebase onto it** if it is about to merge, making yours depend on it.\n3. **Keep both** if both edits must stand — then simulate the merged tree\n (`git merge-tree --write-tree`, see the section below) and say in both PRs\n whether the order matters.\n\nWhichever you pick, say so in the PR body: a conflict a maintainer discovers is\na round trip, one you flagged is a decision. Where the answer changes what the\nother PR should do, it is theirs to make, not yours to assume.\n\nThe same check belongs in the post-merge sweep. After a sibling merges, rebase\nand re-run the gates rather than trusting the last green run: your branch was\ngreen against the *old* base, and a redundant hunk of yours may now conflict\nwith, or silently duplicate, what landed.\n\n## A New Gate Retroactively Raises the Bar for Sibling PRs\n\nWhen one PR in a related set introduces a new check — a linter, a security scan\n(zizmor/trivy), a conformance script — that check applies to the **whole repo on\nevery push and PR**, not just the PR that added it. Two failure modes follow, and\nboth surface only after a merge:\n\n1. **Sibling PRs that predate the gate.** A second open PR adding a new file\n (e.g. a new reusable workflow) was written before the gate existed. The moment\n the gate PR merges, `main` — and that sibling PR's own CI — goes red, because\n the new file was never hardened to pass a check that didn't exist when it was\n written. Harden every sibling artifact to the new gate **before** either PR\n merges.\n\n2. **\"Validated earlier\" was validated against the *old* criteria.** If you ran\n `actionlint + yamllint` on an artifact last week and then added `zizmor` to the\n gate this week, the artifact was never checked by zizmor. Passing the *previous*\n gate is not passing the *current* one — re-run the **full current** gate over\n anything you're about to merge, not the subset that existed when you first\n validated it.\n\n**Catch it before merging, in any order.** Simulate the merged tree of the whole\nPR-set and run the complete gate over it — don't reason about it:\n\n```bash\n# Three-way merge of two branches without touching either working tree.\n# `--write-tree` exits non-zero on conflict; check that status with `if` rather\n# than masking it through a pipe — `... | head -1` would swallow the conflict\n# exit code and hand you a tree with conflict markers to lint.\ngit -C .bare fetch origin\nif MERGE_OUT=$(git merge-tree --write-tree origin/pr-a-branch origin/pr-b-branch); then\n TREE=$(printf '%s' \"$MERGE_OUT\" | head -1) # first line is the merged tree OID\n # Materialize $TREE and run every gate check (lint, security, conformance),\n # or just run the checks in each PR's branch after rebasing it on the other.\nelse\n echo \"PRs conflict on merge — resolve the conflict before checking the gate\"\nfi\n```\n\nIf the gate is green on both PRs individually **and** on their merged tree, they\nare safe to merge in any order. If only the individuals are green, the first\nmerge will break the second.\n\n## Signed Commits with Rebase Merge\n\n### The Problem\n\nWhen a repository requires:\n1. Signed commits AND\n2. Only rebase merge (no merge commits, no squash)\n\nGitHub **cannot** sign rebased commits automatically:\n\n```bash\ngh pr merge 123 --rebase\n# Error: Base branch requires signed commits.\n# Rebase merges cannot be automatically signed by GitHub.\n```\n\n### The Solution: Local Fast-Forward Merge\n\nSince commits are already signed locally, merge locally and push:\n\n```bash\n# 1. Ensure local main is up to date\ngit checkout main\ngit pull origin main\n\n# 2. Verify feature branch is rebased (should be fast-forward)\ngit log --oneline main..feature-branch\n\n# 3. Fast-forward merge (preserves original signatures)\ngit merge feature-branch --ff-only\n\n# 4. Push to main\ngit push origin main\n\n# 5. Close the PR (it will auto-close if commits match)\n# Or manually: gh pr close NUMBER\n```\n\n### Why This Works\n\n- Original commits retain their GPG/SSH signatures\n- Fast-forward merge doesn't create new commits\n- GitHub recognizes the commits and auto-closes the PR\n\n### When to Use\n\n| Scenario | Solution |\n|----------|----------|\n| Signed commits required + squash allowed | `gh pr merge --squash` (GitHub signs) |\n| Signed commits required + merge commit allowed | `gh pr merge --merge` (GitHub signs merge commit) |\n| Signed commits required + rebase only | Local fast-forward merge (this solution) |\n\n### Automation Option\n\n```bash\n#!/bin/bash\n# merge-signed-pr.sh - Merge PR with signed commits via fast-forward\n\nPR_NUMBER=$1\nBRANCH=$(gh pr view $PR_NUMBER --json headRefName -q '.headRefName')\n\ngit fetch origin\ngit checkout main\ngit pull origin main\n\n# Verify it's a fast-forward\nif ! git merge-base --is-ancestor main origin/$BRANCH; then\n echo \"Error: Branch needs rebase first\"\n exit 1\nfi\n\ngit merge origin/$BRANCH --ff-only\ngit push origin main\n\necho \"PR #$PR_NUMBER merged via fast-forward\"\n```\n\n## GitLab: the same gate with `glab` (merge requests)\n\nEverything above assumes GitHub. GitLab has the same concepts under different\nfield names, a different CLI and a different thread model — translate it, don't\nimprovise mid-run. Export `GITLAB_HOST` (or pass `--hostname`) for self-managed\ninstances.\n\n### \"Can I contribute here?\" is a flag lookup, not an inference\n\nBefore concluding that a project takes issues, forks or merge requests only from\nmembers, read the project's own capability flags:\n\n```bash\ncurl -s -L -H \"PRIVATE-TOKEN: $TOKEN\" \"https://$GITLAB_HOST/api/v4/projects/$P\" \\\n | jq '{issues_enabled, merge_requests_enabled, forking_access_level, permissions}'\n```\n\nTwo traps sit on that call:\n\n- **Anonymous requests null the flags.** Without a token the same fields come\n back `null`, which reads like \"disabled\" and means \"not visible to you\". Only\n an authenticated answer distinguishes the two.\n- **Follow redirects.** Instances reachable under two hostnames answer the API\n on one of them; without `-L` you get a `307`/`404` and may conclude the project\n or its API does not exist.\n\nDo not substitute observation for the flags. \"The last 20 MRs all came from\nin-repo branches\" is consistent with forks being forbidden *and* with nobody\nhaving needed one — a project whose `forking_access_level` is `enabled` will\nshow exactly the same history if all its contributors are members. Likewise, an\n`open_issues_count` you cannot read is not an absent tracker.\n\nThe flags also separate two failure modes that look identical from outside: a\nproject that refuses outside contributions, and an account that may not create\nprojects (`Limit reached — You cannot create projects in your personal\nnamespace`). The first is a policy to respect; the second is a permission to\nrequest, and saying which one blocks you is the difference between a useful\nreport and a shrug.\n\n### One-block preflight\n\nThe GitLab analogue of the `gh pr view` + rulesets + `reviewThreads` block.\nRun it once, up front, and re-run only after a state-changing push. Note that\n`glab api` has no `--jq` flag (verified on glab 1.95.0) — unlike `gh api`, pipe\nits output to `jq`:\n\n```bash\nexport GITLAB_HOST=git.example.com\nP=\u003cgroup>%2F\u003cproject> # URL-encoded path, or the numeric project id\nM=\u003cmr_iid>\n\n# (1) MR state and WHY it is blocked. detailed_merge_status names the blocker;\n# merge_status alone does not.\nglab api \"projects/$P/merge_requests/$M\" \\\n | jq '{state,draft,merge_status,detailed_merge_status,has_conflicts,\n blocking_discussions_resolved,user_notes_count,sha,target_branch,title}'\n\n# (2) approval rule and who approved (approvals_required null = no rule)\nglab api \"projects/$P/merge_requests/$M/approvals\" \\\n | jq '{approvals_required,approved_by:[.approved_by[].user.username]}'\n\n# (3) threads. individual_note==true is a plain comment, not a thread.\nglab api \"projects/$P/merge_requests/$M/discussions\" \\\n | jq '[.[] | select(.individual_note|not)\n | {id, resolved: .notes[0].resolved, author: .notes[0].author.username}]'\n\n# (4) what protects the target branch (GitLab's answer to rulesets).\n# Use the target_branch from (1) — it is not always the default branch.\nglab api \"projects/$P/protected_branches/\u003ctarget-branch>\"\n\n# (5) pipeline on the MR head SHA\nglab ci status --branch \u003csource-branch>\n\n# (6) how this project merges — so you do not squash by accident\nglab api \"projects/$P\" | jq '{merge_method,squash_option,\n only_allow_merge_if_pipeline_succeeds,remove_source_branch_after_merge}'\n```\n\n### Field mapping\n\n| GitHub | GitLab |\n|---|---|\n| `mergeStateStatus` (`BLOCKED` / `CLEAN`) | `detailed_merge_status` (`draft_status`, `not_approved`, `discussions_not_resolved`, `ci_must_pass`, `mergeable`, …) |\n| `mergeable` | `merge_status` + `has_conflicts` |\n| `reviewDecision` | `approvals_required` + `approved_by` (`/approvals`) |\n| GraphQL `reviewThreads[].isResolved` | `/discussions` → `notes[0].resolved`; aggregate flag `blocking_discussions_resolved` |\n| `resolveReviewThread` mutation | `PUT /merge_requests/:iid/discussions/:id?resolved=true` |\n| Rulesets (`/rules/branches/main`) | `/protected_branches/:name` + approval rules |\n| `statusCheckRollup` | `glab ci status` / `/pipelines` |\n| Draft PR | `draft: true` → `glab mr update \u003ciid> --ready` |\n\n`detailed_merge_status: draft_status` means the **only** blocker is the draft\nflag. That is the most common \"the MR refuses to merge and nothing looks wrong\"\ncase — check it before hunting for approvals or failing jobs.\n\n### Replying to and resolving a thread\n\nReply *into* the thread, never as a new top-level comment, then resolve and\nverify — the same discipline as the GitHub GraphQL flow:\n\n```bash\nglab api -X POST \"projects/$P/merge_requests/$M/discussions/\u003cdiscussion_id>/notes\" \\\n -f body=\"Fixed in \u003csha>. \u003cwhat changed and why>\"\n\nglab api -X PUT \"projects/$P/merge_requests/$M/discussions/\u003cdiscussion_id>?resolved=true\"\n\n# verify — the aggregate flag must be true before merging\nglab api \"projects/$P/merge_requests/$M\" | jq .blocking_discussions_resolved\n```\n\n### Merge\n\n```bash\n# Check merge_method FIRST (preflight 6): \"merge\" -> merge commit,\n# \"ff\" -> fast-forward only, \"rebase_merge\" -> semi-linear.\n# Never pass --squash unless the user asked for it.\nglab mr merge \"$M\" --remove-source-branch --yes\n```\n\n`glab mr merge` waits for the pipeline and refuses while it is still running, so\na green pipeline is a precondition of the command rather than something to\nre-check afterwards.\n\n**The merge itself is asynchronous** — the command returns before the target\nbranch has moved. A `git pull` fired right after races the server-side merge\nand can deliver the *pre-merge* tip while the MR already reports\n`state=merged`; anything derived from that checkout (a release tag, a branch\ncut from \"main\") then pins the old commit. One session tagged `v1.1.0` on the\npre-merge tip this way and the tag pipeline published an image without the\nfeature. Before tagging or branching off a freshly merged target:\n\n```bash\n# the remote target must equal the MR's merge commit\ntest \"$(git ls-remote origin \"$TARGET\" | cut -f1)\" \\\n = \"$(glab api \"projects/$P/merge_requests/$M\" | jq -r .merge_commit_sha)\"\n```\n\nAnd never swallow the pull's outcome (`git pull --ff-only 2>&1 | tail -1`\nhides a refused fast-forward) — confirm with `git rev-parse HEAD` that the\ncheckout actually sits on the expected commit.\n\n### Rebase when the MR is behind\n\nOn projects with `merge_method: rebase_merge` (semi-linear history) a behind\nbranch IS a blocker: `glab mr merge` fails with a bare\n`{message: 405 Method Not Allowed}` and the MR shows\n`detailed_merge_status: need_rebase`. The light path is a server-side rebase —\nno local fetch dance needed:\n\n```bash\nglab mr rebase \u003cMR>\n# poll until the status leaves need_rebase (it passes through \"checking\")\nglab mr view \u003cMR> --output json | jq -r '.detailed_merge_status'\n```\n\nThe rebase creates a new head SHA, so wait for the freshly triggered pipeline\nbefore retrying the merge.\n\nOn plain merge-commit projects GitLab will happily report `mergeable` while\nthe branch is behind the target — being behind is not a blocker there, unlike\na GitHub merge queue. If the\nprocedure calls for a rebase, drive it locally and fetch the base explicitly\n(bare-repo worktrees often lack `remote.origin.fetch`, and `--force-with-lease`\nthen fails with \"stale info\"):\n\n```bash\ngit fetch origin \u003ctarget-branch>:refs/remotes/origin/\u003ctarget-branch>\ngit rebase origin/\u003ctarget-branch>\ngit push --force-with-lease origin \u003csource-branch>\n```\n\nRe-run the preflight afterwards: the rebase produces a new head SHA, so the\npipeline result you looked at no longer applies.\n\n## Upstream and Community PRs: the Maintainer Is Watching Every Push\n\nIn your own repository, churn is private until someone looks. In a foreign\nrepository it is the contribution. A maintainer sees each force-push, each body\nrewrite and each comment as it happens, and a PR that changes shape three times\ncosts them the attention they were going to spend reviewing it.\n\n### A maintainer's question is a question\n\nThe single most expensive mistake in this shape: a maintainer asks whether\nsomething *should* also happen, and you build it.\n\n> *\"`SKIP_LINKS` affects every `listContents()` call, not just the index lookup.\n> Should `FileCollector` also check `listSkippedLinks()` and warn when\n> non-empty?\"*\n\nThat was read as an instruction. The answer produced roughly 250 lines of\ndiagnostics, a logger threaded into a collector, a path-traversal guard and a\nsecond directory walk — all of which were deleted two hours later when the\nright answer turned out to be \"remove the half-wired diagnostic instead of\nwiring it everywhere\". The maintainer had spotted an inconsistency, not\nspecified a feature.\n\nAnswer the question first, in a comment, with the evidence. Build only what the\nanswer turns out to require. If a proposal is worth building, say what it would\ninvolve and let the maintainer choose — the reply costs one comment, the build\ncosts a review cycle for both of you.\n\n### Land the minimal fix, propose the rest separately\n\nThe fix that closes the reported bug and the improvement you noticed while\nreading the code are two contributions. Shipping them together means the bug\nfix waits for agreement on the improvement.\n\nThe same branch above finished as two changed lines and one regression test.\nEverything else became its own issue and its own pull request, and each could\nthen be accepted or rejected on its own merits. Split at the point where a\nmaintainer could plausibly say \"yes to this, no to that\".\n\n### Batch the noise\n\nThree consecutive comments answering one review are three notifications and one\nreadable answer. A body rewritten after every push is a diff nobody can follow.\n\n- Answer a review in **one** comment, after the work, not as you go.\n- Rewrite the body **once**, when the branch has its final shape.\n- Prefer one force-push over four: finish the local sequence, verify it, then push.\n- When you do rewrite history on a PR under review, say in a comment what\n changed and why the previous discussion still applies — or no longer does.\n\n### Do not repeat in the PR what the issue already says\n\nIf a PR references an issue that catalogues everything out of scope, the PR\nbody does not need that catalogue. It needs what this diff does, why it is\nsafe, how it was verified, and one line pointing at the issue. Restating the\nissue is filler that pushes the reviewable part below the fold.\n\n### Read the repository before the first artifact\n\nThe README **and** the parts that encode rules no prose mentions. Reading only the second half is a trap of its own: a small repository often has no `CONTRIBUTING` at all and states the entire contract in the README — target branch, sign-off, the one command CI runs, whether issues are wanted before PRs. A preflight that skips it reports \"no contribution docs\" about a repository that documented everything.\n\n`scripts/repo-contribution-preflight.sh` returns all of it in one call: contribution and community-health docs (`CONTRIBUTING`, `CODE_OF_CONDUCT`, `SUPPORT`, `GOVERNANCE`, `SECURITY`, found case-insensitively under the root, `.github/` and `docs/`) with the pages they link, the README headings that carry rules, issue and PR templates including the `PULL_REQUEST_TEMPLATE/` directory form, the CI matrix that actually runs, `.gitattributes` export rules, and the pinned tool versions the pipeline uses.\n\nTwo things it deliberately does not do. It never answers \"there are no rules\": when a repository has no contribution docs of its own, GitHub serves the owner's `.github` repository defaults instead, and those bind identically — so the script prints the query for that fallback rather than an absence it did not check. And it does not treat a missing heading as a missing rule: a three-line README can require a sign-off in running prose.\n\nDiscovering `CONTRIBUTING.rst` after three artifacts are already public means retrofitting them in view of the people you are contributing to.\n\n## Full PR Lifecycle Checklist\n\nComplete end-to-end workflow for merging a PR, from CI verification through post-merge cleanup.\n\n### 1. Verify CI Status\n\n```bash\n# Check all checks\ngh pr checks \u003cNUMBER>\n\n# If failing, get detailed error logs\ngh run view \u003cRUN_ID> --log-failed 2>&1 | grep \"There were\"\n\n# Check annotations (warnings that don't block but should be fixed)\ngh api \"repos/OWNER/REPO/commits/SHA/check-runs\" \\\n --jq '.check_runs[] | select(.output.annotations_count > 0) | {name, annotations: .output.annotations_count}'\n```\n\n### 2. Resolve Review Comments\n\n**Work threads the moment they land — decoupled from CI.** Review comments\nare workable input 2–5 minutes after a push; there is no reason to wait for\nthe full check matrix before starting on them. Poll `reviewThreads`\nindependently of `gh pr checks` (a watcher that gates thread reporting on\n\"all checks settled\" hides actionable feedback for the length of the longest\njob). Bot reviews also race your pushes: a thread may flag code a commit you\njust pushed already fixed — answer it with the fixing SHA and resolve; no\nchurn needed.\n\n```bash\n# List unresolved threads\ngh api graphql -f query='query {\n repository(owner: \"OWNER\", name: \"REPO\") {\n pullRequest(number: NUMBER) {\n reviewThreads(first: 30) {\n nodes {\n id\n isResolved\n comments(first: 1) {\n nodes { body author { login } }\n }\n }\n }\n }\n }\n}' --jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false) | {id, author: .comments.nodes[0].author.login, comment: .comments.nodes[0].body[:100]}'\n\n# Reply to a thread\ngh api graphql -f query='mutation($body: String!, $id: ID!) {\n addPullRequestReviewThreadReply(input: {body: $body, pullRequestReviewThreadId: $id}) {\n comment { id }\n }\n}' -f body=\"Fixed in latest commit.\" -f id=\"PRRT_xxx\"\n\n# Resolve a thread\ngh api graphql -f query='mutation { resolveReviewThread(input: {threadId: \"PRRT_xxx\"}) { thread { isResolved } } }'\n```\n\n### 3. Merge\n\n```bash\n# Auto-detect merge strategy and queue.\n# Prefer atomic-history methods; NEVER auto-pick squash (see \"Never squash\n# unless the user asks\" above). Squash is selected only if it is the sole\n# method the repo allows — and then warn, because it rewrites history.\nSTRATEGY=$(gh api \"repos/OWNER/REPO\" --jq '\n if .allow_merge_commit then \"--merge\"\n elif .allow_rebase_merge then \"--rebase\"\n elif .allow_squash_merge then \"--squash\"\n else \"\" end') || { echo \"ERROR: could not query repo merge methods\" >&2; exit 1; }\n# Fail fast rather than fall through to gh's default method (which may be squash).\n[ -z \"$STRATEGY\" ] && { echo \"ERROR: no merge method enabled on this repo\" >&2; exit 1; }\n[ \"$STRATEGY\" = \"--squash\" ] && echo \"WARNING: only squash is enabled — this rewrites history and drops signatures\" >&2\ngh pr merge \u003cNUMBER> --auto \"$STRATEGY\"\n\n# For repos with merge queue, queue it — but ONLY after passing the\n# \"Auto-Merge / Merge-Queue Arming Gate\" above (the queue ignores\n# unresolved review threads and in-flight bot reviews).\ngh pr merge \u003cNUMBER> --auto\n```\n\n### 4. Post-Merge Cleanup\n\n```bash\n# Switch to main and pull\ngit checkout main && git pull\n\n# Delete local feature branch\ngit branch -d \u003cbranch-name>\n\n# Remote branch is auto-deleted if repo setting enabled, otherwise:\ngit push origin --delete \u003cbranch-name>\n```\n\n### Common Blockers\n\n| Blocker | Diagnosis | Fix |\n|---------|-----------|-----|\n| `REVIEW_REQUIRED` but no pending reviewers | Auto-approve raced with Copilot review | Re-run PR Quality Gates workflow |\n| `BLOCKED` with all checks green | Unresolved review threads (even from old commits) | Resolve all threads via GraphQL |\n| Auto-merge dropped after push | New commits nullify `autoMergeRequest` | Re-queue with `gh pr merge --auto` |\n| CI annotations but status green | Reviewdog warnings don't block by default | Fix annotations or set `fail_level: error` |\n| `startup_failure` / \"no jobs ran\" / config invalid | Workflow validator rejected the run before any job started | Read annotations first (see [Diagnosing CI Failures (Annotations First)](#diagnosing-ci-failures-annotations-first) above) — the literal validator error is in one line |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":226387,"content_sha256":"1770f2a98e6aad51f19eef690e9e49e840859460e046fcb1428669da3a5f9348"},{"filename":"references/spec-cleanup.md","content":"\u003c!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->\n\u003c!-- SPDX-FileCopyrightText: Netresearch DTT GmbH -->\n\n# Spec-Cleanup — Keep Intermediate Planning Artifacts Out of the Base Branch\n\nDev-time planning artifacts — superpowers specs/plans (`docs/superpowers/**`),\nad-hoc `PLAN.md` scratch files, output from other planning tools — are throwaway\nworking notes. When they get committed to a feature branch they ride into the\nbase branch on merge, rot there, and pollute history (the HMKG-2227 incident:\n~5000 lines of specs/plans dragged across branches via `develop`).\n\nThis capability has two layers, wired into the merge gate and `/pr-finish`:\n\n- **Guard** — deterministic, **read-only**. Detects the artifacts and blocks the\n gate. Safe to run in CI.\n- **Capture** — interactive, in-session. Distils durable knowledge into an ADR\n (proposed as a reviewable diff), then removes the raw files **recoverably**.\n\n## Guard — `scripts/spec-cleanup-guard.sh`\n\nRead-only **invariant**: the Guard never deletes, stages, or modifies any file.\nIt detects the *presence* of configured intermediate paths in three states and\nexits non-zero if any are found. It is branch-local (presence ⇒ fail), so there\nis no base-branch resolution to get wrong.\n\n| State | Mechanism |\n|-------|-----------|\n| tracked (committed) | `git ls-files` |\n| staged | `git diff --cached --name-only --diff-filter=AM` |\n| untracked | `git ls-files --others --exclude-standard` |\n\n```bash\nbash skills/git-workflow/scripts/spec-cleanup-guard.sh # enforce: exit 1 on matches\nbash skills/git-workflow/scripts/spec-cleanup-guard.sh --dry-run # manifest only, exit 0\nbash skills/git-workflow/scripts/spec-cleanup-guard.sh --selftest # internal fixtures\n```\n\nConfig globs are normalized to **git pathspecs** (these are NOT shell globs):\na directory glob `docs/superpowers/**` → directory pathspec `docs/superpowers/`\n(recurses); a suffix glob → `:(glob)docs/superpowers/**/*.plan.md` (anchored —\na bare `**/*.plan.md` is forbidden because it sweeps in real files like\n`src/foo.plan.md`). `exclude:` entries become `:(exclude)` pathspecs (or\n`:(exclude,glob)` when the pattern contains glob characters).\n\n## Config — `.spec-cleanup.yml` (optional)\n\nSingle machine-readable source of truth for paths. See\n`.spec-cleanup.yml.example`. Without a config the baked-in defaults apply\n(`docs/superpowers/**`, `claudedocs/**`, `docs/working/**`,\n`docs/superpowers/**/*.plan.md`). If the file **is** present but `yq`\n(mikefarah v4) is not installed, the Guard **fails closed** (exit 2) rather than\nsilently under-enforcing.\n\n```yaml\nintermediate_paths:\n - docs/superpowers/**\nexclude:\n - docs/superpowers/specs/**/KEEP-*.md\ncapture_targets:\n adr: docs/adr/ # v1 target (append new file)\nmode: convert # convert | remove\n```\n\n## Capture — the `/pr-finish` spec-cleanup step (before Rebase)\n\nRuns in-session so the branch is clean before the gate. Sequence:\n\n1. **Manifest + confirm.** Print every match grouped by tracked/staged/untracked.\n The untracked subset is called out separately — it is the only class not\n recoverable from git by default. Require explicit confirmation.\n2. **Convert (default).** A sub-agent reads the artifacts and **proposes an ADR\n diff** into `capture_targets.adr`. Routing heuristic for v1: *a decision with\n alternatives considered → ADR*. (PRD update-in-place and user-doc stubs are\n deferred phase-2 targets.) Human reviews the diff; on accept, commit the docs.\n3. **Verify before removing.** Assert the docs commit exists and contains the\n intended paths (`git show --stat HEAD`) and the tree no longer reports them\n uncommitted. On any failure (blocked hook, signing failure, empty diff),\n **abort removal** and surface the error.\n4. **Recoverable removal.** Stage **only the manifested artifact paths**\n (`git add -- \u003cpath>`, never `git add -A/-u`, so unrelated working-tree changes\n are not absorbed) so they enter history, then commit their deletion as\n `chore: remove working specs/plans (captured in \u003cADR>)`. Recoverability holds\n **only** for merge/rebase-merged PRs (never squashed); in a squash-merge repo\n the captured ADR is the sole durable record — confirm it landed first. **Bare\n `rm` of untracked content is forbidden** — every removal is a git deletion of a\n tracked file.\n5. **Acknowledge path.** If nothing is durable (or `mode: remove`), skip step 2;\n still manifest+confirm and removal, and record a `Spec-Cleanup: acknowledged`\n trailer on the removal commit listing the removed paths. The acknowledging\n actor is whoever runs `/pr-finish`; the trailer commit is signed/DCO-compliant.\n\n**Mode precedence:** `mode` is the default offered; a human may escalate to a\nstricter outcome (decline conversion → remove) but the tooling never silently\nloosens.\n\n**Discovery vs enforcement:** the Guard (and CI) enforce only the declared\nglobs. In-session, Capture may additionally surface session-known artifacts that\nmatch no glob (ad-hoc `PLAN.md`, other-tool output) and propose **adding them to\n`.spec-cleanup.yml`** so the deterministic Guard catches them thereafter — it\nnever silently deletes a non-globbed path. CI cannot see session-only paths;\nthat residual gap is closed only once discovery registers them in config.\n\n## Wiring\n\n- **Merge gate** — a Pre-Merge Checklist item + command recipe in\n `references/pull-request-workflow.md` runs the Guard and blocks on non-zero.\n- **Optional runtime block** — extend the `merge-gate.sh` PreToolUse hook recipe\n in `references/claude-code-hooks.md`, or add an **off-by-default** git\n `pre-commit` template under `Build/hooks/` (a *git* hook — distinct from the\n Claude-hook config in `hooks/hooks.json`). Off by default because committing\n working notes mid-branch is fine; only reaching the base branch is not.\n\n## Adoption\n\nOther repos adopt by shipping their own `.spec-cleanup.yml` (own globs + ADR\ntarget). No forked logic; composes with existing QA agents (e.g.\n`oro-qa-reviewer`). Note: the `git-workflow-skill` repo ships only\n`.spec-cleanup.yml.example` (no active config), so the Guard is not wired into its\nown merge gate/CI. Run manually with the baked-in defaults it *does* flag the\ndogfooded design spec under `docs/superpowers/specs/` — the intended\nself-demonstration (design §8).\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":6363,"content_sha256":"84a4482c57c3ae1b3100ff16dd9007a792e553f3a8d73ac7e79d18576f61432c"},{"filename":"scripts/conflict-marker-gate.py","content":"#!/usr/bin/env -S uv run --script\n# SPDX-License-Identifier: MIT\n# SPDX-FileCopyrightText: Netresearch DTT GmbH\n#\n# /// script\n# requires-python = \">=3.11\"\n# dependencies = []\n# ///\n\"\"\"PreToolUse hook for Bash: deny `git commit` while staged content still carries\nmerge-conflict markers.\n\nWhy this exists: a resolver script that aborts does NOT stop a following\n`git add -A && git commit` in the same Bash call — the `&&` chain starts fresh\nafter the failed command, so the commit lands with `\u003c\u003c\u003c\u003c\u003c\u003c\u003c` in the tree.\nObserved twice in one session (2026-08-09, netresearch/t3x-nr-llm) during a\nbatch-merge, caught only by a later gate run and costing a full test cycle each\ntime.\n\nverify-git-workflow.sh checks the working tree on demand; this is the\ncommit-time gate, and it looks at staged content for every file extension.\n\nWire it as a PreToolUse hook with matcher \"Bash\" — see\nreferences/claude-code-hooks.md, Recipe 6.\n\nFails open: any error, non-repo cwd, or unreadable diff allows the command.\n\"\"\"\n\nimport json\nimport re\nimport shlex\nimport subprocess\nimport sys\n\n# Real markers are at line start. `=======` alone is excluded on purpose: it is\n# a legitimate RST section underline and would fire on every docs commit.\nMARKER = re.compile(r\"^(\u003c\u003c\u003c\u003c\u003c\u003c\u003c |>>>>>>> )\", re.MULTILINE)\n\nGUIDANCE = (\n \"Staged content still contains merge-conflict markers ({files}). \"\n \"A resolver that exits non-zero does not stop a following `git add && git commit` \"\n \"— the chain starts fresh. Resolve the markers, re-stage, then commit. \"\n \"Check with: git diff --cached | grep -n '^\u003c\u003c\u003c\u003c\u003c\u003c\u003c \\\\|^>>>>>>> '\"\n)\n\n\ndef is_git_commit(command):\n \"\"\"True when the command runs `git commit` in any segment of the shell line.\"\"\"\n try:\n lexer = shlex.shlex(command, posix=True, punctuation_chars=True)\n lexer.whitespace_split = True\n tokens = list(lexer)\n except ValueError: # unbalanced quotes — nothing reliable to say\n return False\n\n for i, token in enumerate(tokens):\n if token != \"git\":\n continue\n # Skip global options and their values (-C \u003cdir>, -c \u003ck=v>, --git-dir=…)\n j = i + 1\n while j \u003c len(tokens) and tokens[j].startswith(\"-\"):\n if tokens[j] in (\"-C\", \"-c\", \"--git-dir\", \"--work-tree\"):\n j += 1\n j += 1\n if j \u003c len(tokens) and tokens[j] == \"commit\":\n return True\n return False\n\n\ndef staged_conflict_files():\n \"\"\"Names of staged files whose staged content carries conflict markers.\"\"\"\n try:\n names = subprocess.run(\n [\"git\", \"diff\", \"--cached\", \"--name-only\", \"--diff-filter=ACMR\"],\n capture_output=True,\n text=True,\n timeout=10,\n check=False,\n )\n if names.returncode != 0:\n return []\n hits = []\n for name in names.stdout.split(\"\\n\"):\n name = name.strip()\n if not name:\n continue\n blob = subprocess.run(\n [\"git\", \"show\", f\":{name}\"],\n capture_output=True,\n text=True,\n timeout=10,\n check=False,\n )\n if blob.returncode == 0 and MARKER.search(blob.stdout):\n hits.append(name)\n if len(hits) >= 5:\n break\n return hits\n except (subprocess.SubprocessError, OSError):\n return []\n\n\ndef main():\n try:\n payload = json.load(sys.stdin)\n except (json.JSONDecodeError, ValueError):\n return 0\n\n command = (payload.get(\"tool_input\") or {}).get(\"command\") or \"\"\n if not command or not is_git_commit(command):\n return 0\n\n files = staged_conflict_files()\n if not files:\n return 0\n\n print(\n json.dumps(\n {\n \"hookSpecificOutput\": {\n \"hookEventName\": \"PreToolUse\",\n \"permissionDecision\": \"deny\",\n \"permissionDecisionReason\": GUIDANCE.format(files=\", \".join(files)),\n }\n }\n )\n )\n return 0\n\n\nif __name__ == \"__main__\":\n sys.exit(main())\n","content_type":"text/x-python; charset=utf-8","language":"python","size":4149,"content_sha256":"5012b22e1441f0555721bf01489504ac70a2ffccb88a82db7fa7ebc3bb56de49"},{"filename":"scripts/merge-gate.sh","content":"#!/usr/bin/env bash\n# SPDX-License-Identifier: MIT\n# SPDX-FileCopyrightText: Netresearch DTT GmbH\n# PreToolUse gate for `gh pr merge`. Reads the Claude Code hook payload on stdin\n# and emits a deny if the target PR has unresolved review threads or a non-CLEAN\n# merge state — the exact gap that caused repeated mis-merges/mis-diagnoses.\n#\n# NOTE: deliberately does NOT gate on reviewDecision. NR repos routinely have\n# reviewDecision \"\" (no human-approval rule) and merge legitimately when CLEAN;\n# mergeStateStatus==CLEAN already encodes GitHub's required-approval gate. Gating\n# on reviewDecision!=APPROVED would false-positive-block every such repo.\nset -euo pipefail\n\n# --version answers \"which copy am I running\" without diffing installations.\n# Two installations can declare the SAME number while shipping different\n# scripts (netresearch/git-workflow-skill#209 measured exactly that, and the\n# missing flag read as a missing feature for a dozen merges). So the resolved\n# path is printed beside the version: the number says what the copy claims to\n# be, the path says which file actually answered.\n#\n# The version is read from the SKILL.md NEXT TO the script, never from a\n# checkout elsewhere -- a cached copy must report the number it was packaged\n# with, or the answer is worse than none. \\042 and \\047 are the quote\n# characters by octal code, so this awk program contains no quote of its own\n# to terminate the single-quoted string it lives in.\nskill_version() {\n local here skill v\n here=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n skill=\"$here/../SKILL.md\"\n v=\"unknown\"\n if [ -f \"$skill\" ]; then\n v=\"$(awk '/^[ \\t]*version:/ {\n s = $0\n sub(/^[ \\t]*version:[ \\t]*/, \"\", s)\n gsub(/[\\042\\047]/, \"\", s)\n gsub(/[ \\t\\r]+$/, \"\", s)\n if (s != \"\") { print s; exit }\n }' \"$skill\" 2>/dev/null)\" || v=\"\"\n [ -n \"$v\" ] || v=\"unknown\"\n fi\n printf '%s %s\\n' \"$(basename \"${BASH_SOURCE[0]}\")\" \"$v\"\n printf 'path: %s/%s\\n' \"$here\" \"$(basename \"${BASH_SOURCE[0]}\")\"\n}\n\n[ \"${1:-}\" = \"--version\" ] && { skill_version; exit 0; }\nCMD=$(jq -r '.tool_input.command // \"\"')\n\n# Parse the PR id from the three `gh pr merge` reference forms. Flags before\n# the number may be long or short, with a separate or =-joined value\n# (--merge, --repo owner/repo, --repo=owner/repo, -R owner/repo).\nPR=\"\"; REPO_FLAG=()\nif [[ \"$CMD\" =~ gh[[:space:]]+pr[[:space:]]+merge[[:space:]]+((-{1,2}[A-Za-z][A-Za-z-]*(=[^[:space:]]*)?([[:space:]]+[^-][^[:space:]]*)?)[[:space:]]+)*([0-9]+)([[:space:]]|$) ]]; then\n PR=\"${BASH_REMATCH[5]}\"\nelif [[ \"$CMD\" =~ gh[[:space:]]+pr[[:space:]]+merge[[:space:]]+.*https?://github\\.com/([^/]+/[^/]+)/pull/([0-9]+) ]]; then\n PR=\"${BASH_REMATCH[2]}\"; REPO_FLAG=(--repo \"${BASH_REMATCH[1]}\")\nelif [[ \"$CMD\" =~ gh[[:space:]]+pr[[:space:]]+merge[[:space:]]+.*([^/[:space:]]+/[^#[:space:]]+)#([0-9]+) ]]; then\n PR=\"${BASH_REMATCH[2]}\"; REPO_FLAG=(--repo \"${BASH_REMATCH[1]}\")\nfi\n# Honor an explicit --repo/-R owner/repo if present (overrides URL/shorthand).\n# Both spellings, space- or =-separated: without this the PR number was\n# resolved against the CWD repo and produced false denials (2026-08-03).\nif [[ \"$CMD\" =~ (--repo|-R)([[:space:]]+|=)([^[:space:]]+) ]]; then\n REPO_FLAG=(--repo \"${BASH_REMATCH[3]}\")\nfi\n\n# Could not parse a PR id — allow rather than false-positive block.\n[[ -z \"$PR\" ]] && exit 0\n\n# NB: `reviewThreads` is NOT a valid `gh pr view --json` field — gh errors\n# \"Unknown JSON field\". Fetch mergeStateStatus + url here, then get thread\n# resolution via GraphQL (owner/repo/number parsed from the url).\nINFO=$(gh pr view \"$PR\" \"${REPO_FLAG[@]}\" --json mergeStateStatus,url 2>/dev/null) || exit 0\nMSS=$(echo \"$INFO\" | jq -r '.mergeStateStatus // \"null\"')\n# UNKNOWN usually means GitHub is still computing the state (fresh push, or a\n# merge landed elsewhere moments ago). Re-poll briefly before evaluating, so a\n# legitimate merge isn't denied on a transient; a persistent UNKNOWN still\n# denies below (fail-closed). This is a bounded pre-check inside a PreToolUse\n# hook, not a merge-driver loop — pr-status.sh --watch remains the watcher.\ntries=0\nwhile [[ \"$MSS\" == \"UNKNOWN\" && $tries -lt 3 ]]; do\n sleep 3\n INFO=$(gh pr view \"$PR\" \"${REPO_FLAG[@]}\" --json mergeStateStatus,url 2>/dev/null) || exit 0\n MSS=$(echo \"$INFO\" | jq -r '.mergeStateStatus // \"null\"')\n tries=$((tries + 1))\ndone\nURL=$(echo \"$INFO\" | jq -r '.url // \"\"')\n[[ \"$URL\" =~ github\\.com/([^/]+)/([^/]+)/pull/([0-9]+) ]] || exit 0\nO=\"${BASH_REMATCH[1]}\"; RN=\"${BASH_REMATCH[2]}\"; NUM=\"${BASH_REMATCH[3]}\"\n# Paginate the thread list — first:100 alone silently under-counts on PRs\n# with more threads (a round count is a truncation smell).\nUNRES=0; CURSOR=\"\"\nwhile :; do\n ARGS=(-F owner=\"$O\" -F name=\"$RN\" -F num=\"$NUM\")\n [[ -n \"$CURSOR\" ]] && ARGS+=(-F cursor=\"$CURSOR\")\n # shellcheck disable=SC2016 # $-names are GraphQL variables, not shell\n PAGE=$(gh api graphql \"${ARGS[@]}\" -f query='query($owner:String!,$name:String!,$num:Int!,$cursor:String){repository(owner:$owner,name:$name){pullRequest(number:$num){reviewThreads(first:100,after:$cursor){pageInfo{hasNextPage endCursor}nodes{isResolved}}}}}' 2>/dev/null) || break\n N=$(jq -r '[.data.repository.pullRequest.reviewThreads.nodes[]? | select(.isResolved==false)] | length' \u003c\u003c\u003c\"$PAGE\" 2>/dev/null) || break\n UNRES=$((UNRES + N))\n [[ \"$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage' \u003c\u003c\u003c\"$PAGE\")\" == \"true\" ]] || break\n CURSOR=$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.endCursor' \u003c\u003c\u003c\"$PAGE\")\ndone\n\nif [[ \"${UNRES:-0}\" -gt 0 || \"$MSS\" != \"CLEAN\" ]]; then\n jq -cn --arg r \"merge-gate: unresolved-threads=$UNRES, mergeState=$MSS — resolve threads / clear the block (check the ruleset & thread state) before merging\" \\\n '{hookSpecificOutput: {hookEventName: \"PreToolUse\", permissionDecision: \"deny\", permissionDecisionReason: $r}}'\nfi\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":5986,"content_sha256":"28f3f9c6375db4115fd63a6bbb89dc447efd80ff7f2c64d49f0a0d5d26965d2b"},{"filename":"scripts/pr-merge.sh","content":"#!/usr/bin/env bash\n# pr-merge.sh — merge a pull request with the method the repository allows,\n# and only when the merge gate is actually open.\n#\n# Why this exists: `gh pr merge --merge --delete-branch` is wrong in several\n# repository configurations and gives no useful error until it fails. Two of\n# them answer with a message; the other two are silent. A repo\n# with `allow_merge_commit: false` answers \"Merge commits are not allowed on\n# this repository\"; a repo with a merge queue answers \"Cannot use --delete-branch\n# when merge queue enabled\". Hand-rolling the detection per call site is how a\n# 54-repository rollout hit both, three times each. The silent two are a fork\n# head, which is not ours to delete, and a branch another open pull request is\n# based on, whose deletion closes that pull request. pr-status.sh already knows\n# most of the answer — this reads it instead of guessing.\n#\n# Squash is never used: it discards atomic commits and their signatures.\n#\n# Usage:\n# pr-merge.sh # PR for the current branch\n# pr-merge.sh 123\n# pr-merge.sh -R owner/repo 123\n# pr-merge.sh -R owner/repo 123 --dry-run # print the command, run nothing\n# pr-merge.sh -R owner/repo 123 --self-reviewed\n# # the review the gate demands is one an\n# # exhausted Copilot quota (or two failed\n# # reviews on this head) makes\n# # unsatisfiable, and the operator has\n# # reviewed the diff instead: post the\n# # on-the-record `Self-review: \u003chead-sha>`\n# # attestation comment as the PR author,\n# # then merge. Refused whenever a live\n# # review path still exists, and whenever\n# # the authenticated gh user is not the\n# # PR author (#203).\n#\n# Exit codes: 0 merged (or queued) AND confirmed by reading the PR back, 1 the\n# gate is shut and nothing was attempted, 2 an error — usage, lookup, the merge\n# call itself failed, or it exited 0 while nothing merged and nothing entered\n# the queue. 1 is retryable later; 2 needs a human.\n\n# SPDX-License-Identifier: MIT\n# SPDX-FileCopyrightText: Netresearch DTT GmbH\n\nset -uo pipefail\n\n# --version answers \"which copy am I running\" without diffing installations.\n# Two installations can declare the SAME number while shipping different\n# scripts (netresearch/git-workflow-skill#209 measured exactly that, and the\n# missing flag read as a missing feature for a dozen merges). So the resolved\n# path is printed beside the version: the number says what the copy claims to\n# be, the path says which file actually answered.\n#\n# The version is read from the SKILL.md NEXT TO the script, never from a\n# checkout elsewhere -- a cached copy must report the number it was packaged\n# with, or the answer is worse than none. \\042 and \\047 are the quote\n# characters by octal code, so this awk program contains no quote of its own\n# to terminate the single-quoted string it lives in.\nskill_version() {\n local here skill v\n here=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n skill=\"$here/../SKILL.md\"\n v=\"unknown\"\n if [ -f \"$skill\" ]; then\n v=\"$(awk '/^[ \\t]*version:/ {\n s = $0\n sub(/^[ \\t]*version:[ \\t]*/, \"\", s)\n gsub(/[\\042\\047]/, \"\", s)\n gsub(/[ \\t\\r]+$/, \"\", s)\n if (s != \"\") { print s; exit }\n }' \"$skill\" 2>/dev/null)\" || v=\"\"\n [ -n \"$v\" ] || v=\"unknown\"\n fi\n printf '%s %s\\n' \"$(basename \"${BASH_SOURCE[0]}\")\" \"$v\"\n printf 'path: %s/%s\\n' \"$here\" \"$(basename \"${BASH_SOURCE[0]}\")\"\n}\n\nREPO=\"\"; PR=\"\"; DRY=0; SELF_REVIEWED=0\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n\ndie() { printf 'pr-merge: %s\\n' \"$1\" >&2; exit 2; }\nneed() { [ $# -ge 2 ] && [ -n \"${2:-}\" ] || die \"$1 requires a value\"; }\n\nwhile [ $# -gt 0 ]; do\n case \"$1\" in\n -R|--repo) need \"$@\"; REPO=\"$2\"; shift 2 ;;\n --dry-run) DRY=1; shift ;;\n --self-reviewed) SELF_REVIEWED=1; shift ;;\n --version) skill_version; exit 0 ;;\n -h|--help) awk 'NR>1 && /^#/ {sub(/^# ?/,\"\"); print; next} NR>1 {exit}' \"$0\"; exit 0 ;;\n -*) die \"unknown flag: $1\" ;;\n *) PR=\"$1\"; shift ;;\n esac\ndone\n\ncommand -v gh >/dev/null || die \"gh not found\"\ncommand -v jq >/dev/null || die \"jq not found\"\n[ -x \"$SCRIPT_DIR/pr-status.sh\" ] || die \"pr-status.sh not found next to this script\"\n\nread_status() {\n STATUS=$(\"$SCRIPT_DIR/pr-status.sh\" ${REPO:+-R \"$REPO\"} ${PR:+\"$PR\"} --json) \\\n || die \"pr-status.sh failed\"\n\n # Tab-separated, read with a tab-only IFS: the default IFS splits on spaces\n # too, which would tear `why` apart, and it collapses an empty field so every\n # later variable shifts by one. `jq -e` turns a schema change or truncated\n # output into a failure here rather than an empty ACTION further down.\n FIELDS=$(printf '%s' \"$STATUS\" | jq -er '\n [ .next.action,\n (.next.why // \"-\"),\n .repo,\n (.number|tostring),\n (.queue_active|tostring),\n (.merge_methods|join(\",\")),\n (.cross_repository|tostring),\n (.head // \"\")\n ] | @tsv') || die \"pr-status.sh returned unexpected JSON\"\n\n IFS=

Git Workflow Skill Not Here Releases: . BLOCKED-PR diagnosis: . GitHub only. Everything below the branching and commit sections — , , the merge gate, review threads — speaks GitHub GraphQL. For a GitLab merge request the equivalent calls are in § GitLab: the same gate with . Critical Rules (Non-Negotiable) 1. No direct push to main — always open a PR. 2. No merge before all threads resolved ( ). 3. No squash unless asked — preserves atomic commits, signatures, bisection. 4. No "tested/verified/working" without pasted command output — else say so. 5. No edits to installed skill/plugin cache pa…

\\t' read -r ACTION WHY REPO PR QUEUE METHODS CROSS HEADREF \u003c\u003cEOF\n$FIELDS\nEOF\n [ -n \"$ACTION\" ] && [ -n \"$REPO\" ] && [ -n \"$PR\" ] || die \"pr-status.sh returned no action\"\n}\n\nread_status\n\n# --self-reviewed clears a review demand that the author may satisfy in person.\n# Two refusals qualify: `review-required`, where the policy wants a review and\n# does not care whose, and `bot-review-unsatisfiable`, where the demanded bot\n# review cannot arrive at all. Both are demands for A review; neither is a\n# host gate. A pending bot request is not on the list, because requesting a\n# reviewer commits you to waiting for its answer, and pr-status.sh keeps the\n# attestation inert while one is in flight.\n#\n# The flag does not open the gate directly — it posts the on-the-record\n# `Self-review: \u003chead-sha>` attestation comment pr-status.sh reads back, then\n# asks again. Everything else about the gate stays exactly as strict: any\n# other refusal, a non-author caller, or a live review path leaves the flag\n# without effect.\nif [ \"$SELF_REVIEWED\" = \"1\" ] && [ \"$ACTION\" = \"request-review\" ]; then\n SR_FIELDS=$(printf '%s' \"$STATUS\" | jq -er '\n [ (.next.reason // \"-\"),\n ((.self_review_on_head // false)|tostring),\n (.headOid // \"\"), (.author // \"\"),\n ((.author_is_bot // false)|tostring)\n ] | @tsv') || die \"pr-status.sh returned unexpected JSON\"\n IFS=

Git Workflow Skill Not Here Releases: . BLOCKED-PR diagnosis: . GitHub only. Everything below the branching and commit sections — , , the merge gate, review threads — speaks GitHub GraphQL. For a GitLab merge request the equivalent calls are in § GitLab: the same gate with . Critical Rules (Non-Negotiable) 1. No direct push to main — always open a PR. 2. No merge before all threads resolved ( ). 3. No squash unless asked — preserves atomic commits, signatures, bisection. 4. No "tested/verified/working" without pasted command output — else say so. 5. No edits to installed skill/plugin cache pa…

\\t' read -r SR_REASON SR_HAVE SR_HEAD SR_AUTHOR SR_AUTHOR_BOT \u003c\u003cEOF\n$SR_FIELDS\nEOF\n # Keyed on the reason the REFUSING BRANCH stamped, never re-derived from the\n # account-global quota state: a request-review can also come from e.g. the\n # classic require_last_push_approval gate — a satisfiable human approval —\n # and with the monthly quota marker set on this machine a global test would\n # post a factually false \"unsatisfiable\" attestation into permanent PR\n # history there.\n case \"$SR_REASON\" in\n review-required|bot-review-unsatisfiable) ;;\n *)\n die \"--self-reviewed refused: this request-review is not one the author may satisfy in person (reason: $SR_REASON) — a live review path exists, use it\" ;;\n esac\n # A bot-authored pull request can never satisfy the author check below, so\n # the generic refusal would send the operator looking for a way to become\n # renovate. Named separately, with the path that does exist: approving the\n # head as yourself satisfies the same gate, and pr-merge then merges without\n # any flag (#280). No relaxation — a third party still cannot mint an\n # attestation for someone else's pull request.\n if [ \"$SR_AUTHOR_BOT\" = \"true\" ]; then\n die \"--self-reviewed refused: $SR_AUTHOR is a bot, and the attestation is an assertion by the PR author — a bot never reads the diff, so this flag has no meaning here. Review the diff and approve it as yourself: gh pr review $PR --repo $REPO --approve, then re-run this script without --self-reviewed.\"\n fi\n VIEWER=$(gh api user --jq .login 2>/dev/null) || die \"--self-reviewed: could not resolve the authenticated gh user\"\n if [ \"$VIEWER\" != \"$SR_AUTHOR\" ]; then\n die \"--self-reviewed refused: the attestation must come from the PR author ($SR_AUTHOR); gh is authenticated as $VIEWER\"\n fi\n if [ \"$SR_HAVE\" != \"true\" ]; then\n # The body states which of the two demands is being satisfied. Writing\n # \"unsatisfiable\" where the truth is \"a review was required and I wrote it\"\n # puts a false claim about a bot into permanent pull-request history.\n if [ \"$SR_REASON\" = \"bot-review-unsatisfiable\" ]; then\n WHY_TEXT='The bot review this pull request demands is unsatisfiable (Copilot quota wall or repeated bot failures on this head).'\n else\n WHY_TEXT='This pull request requires a review and no bot review is in flight; a review by the author satisfies that requirement.'\n fi\n BODY=$(printf 'Self-review: %s\\n\\n%s The diff on this head was reviewed by the PR author; this comment is the on-the-record attestation the merge gate reads back. It stops matching on the next push.' \"$SR_HEAD\" \"$WHY_TEXT\")\n if [ \"$DRY\" = \"1\" ]; then\n # A dry run must not flip persistent gate state: the attestation comment\n # IS the gate-opening write, so it is previewed, never posted.\n printf 'pr-merge: dry-run — would post the Self-review attestation for %s on %s#%s, re-read the gate and merge\\n' \"${SR_HEAD:0:12}\" \"$REPO\" \"$PR\"\n exit 0\n fi\n gh pr comment \"$PR\" --repo \"$REPO\" --body \"$BODY\" >/dev/null \\\n || die \"--self-reviewed: posting the attestation comment failed\"\n printf 'pr-merge: posted Self-review attestation for %s on %s#%s\\n' \"${SR_HEAD:0:12}\" \"$REPO\" \"$PR\" >&2\n fi\n read_status\nfi\n\nif [ \"$ACTION\" != \"merge\" ]; then\n printf 'pr-merge: not merging %s#%s — %s: %s\\n' \"$REPO\" \"$PR\" \"$ACTION\" \"$WHY\" >&2\n exit 1\nfi\n\n# pr-status reports the method it picked; fall back to the allowed list. Squash\n# is excluded on purpose even when it is the only method the repo offers — in\n# that case pr-status already answers `blocked` and we never get here.\nMETHOD=$(printf '%s' \"$STATUS\" | jq -r '.next.method // \"\"')\nif [ -z \"$METHOD\" ]; then\n case \",$METHODS,\" in\n *,merge,*) METHOD=\"--merge\" ;;\n *,rebase,*) METHOD=\"--rebase\" ;;\n *) die \"repo allows only [$METHODS] — enable merge or rebase, squash is not used\" ;;\n esac\nfi\n\n# A merge queue rejects --delete-branch outright, and deletes the branch itself\n# once the entry merges. On a pull request from a FORK the head branch is not\n# ours to delete: `gh` happily deletes it when the token carries push rights\n# through maintainerCanModify, and the contributor loses the branch their work\n# is on — the one case where the flag destroys somebody else's state rather\n# than tidying our own.\n# Third case: the head branch is the BASE of another open pull request. Deleting\n# it CLOSES that pull request — and a closed pull request's base cannot be\n# retargeted, so recovery is pushing the branch back, reopening, retargeting and\n# deleting again, in that order. (GitHub documents retargeting dependent pull\n# requests when a merged head branch is deleted; on\n# netresearch/ldap-selfservice-password-changer#685 it closed instead, so the\n# branch is kept rather than the documented behaviour trusted.)\n#\n# An empty answer from a FAILED query would look exactly like \"nothing is\n# stacked\", so the query's exit status decides, and a failure keeps the branch.\nSTACKED=\"\"\nSTACKED_UNKNOWN=\"\"\nTMPERR=$(mktemp)\ntrap 'rm -f \"$TMPERR\"' EXIT\nif [ \"$QUEUE\" != \"true\" ] && [ \"$CROSS\" != \"true\" ]; then\n if [ -z \"$HEADREF\" ]; then\n # Nothing to ask about: an older pr-status.sh that does not report the head\n # branch leaves the same doubt a failed query does, and is answered the\n # same way.\n STACKED_UNKNOWN=\"pr-status.sh reported no head branch\"\n elif ! STACKED=$(gh pr list --repo \"$REPO\" --base \"$HEADREF\" --state open \\\n --json number --jq '[.[].number | \"#\" + tostring] | join(\" \")' 2>\"$TMPERR\"); then\n STACKED=\"\"\n STACKED_UNKNOWN=$(tr '\\n' ' ' \u003c \"$TMPERR\")\n [ -n \"$STACKED_UNKNOWN\" ] || STACKED_UNKNOWN=\"gh pr list failed\"\n fi\nfi\n\nCMD=(gh pr merge \"$PR\" --repo \"$REPO\" \"$METHOD\")\nif [ \"$QUEUE\" != \"true\" ] && [ \"$CROSS\" != \"true\" ] && [ -z \"$STACKED\" ] && [ -z \"$STACKED_UNKNOWN\" ]; then\n CMD+=(--delete-branch)\nfi\n\nif [ -n \"$STACKED\" ]; then\n printf 'pr-merge: keeping branch %s — open pull requests are based on it: %s. Retarget them, then delete it.\\n' \\\n \"$HEADREF\" \"$STACKED\" >&2\nelif [ -n \"$STACKED_UNKNOWN\" ]; then\n printf 'pr-merge: keeping branch %s — could not check for dependent pull requests: %s\\n' \\\n \"${HEADREF:-(unknown)}\" \"$STACKED_UNKNOWN\" >&2\nfi\n\nif [ \"$DRY\" = \"1\" ]; then\n printf '%q ' \"${CMD[@]}\"; printf '\\n'\n exit 0\nfi\n\nOUT=$(\"${CMD[@]}\" 2>&1); RC=$?\nif [ \"$RC\" -ne 0 ]; then\n printf 'pr-merge: %s#%s failed: %s\\n' \"$REPO\" \"$PR\" \"$(printf '%s' \"$OUT\" | tr '\\n' ' ')\" >&2\n exit 2\nfi\n\n# Exiting 0 is not proof that anything happened. On a repository with a merge\n# queue, `gh pr merge --merge` prints its usual success line and adds NOTHING to\n# the queue when an auto-merge request is already attached to the PR: the\n# pending request swallows the enqueue. Observed twice on one repository —\n# immediately after the call `isInMergeQueue` was false and the queue was empty;\n# after `gh pr merge \u003cn> --disable-auto` the identical call put the PR at\n# position 1. Relaying the exit status as \"queued\" asserted a state that did not\n# exist and cost hours of misdiagnosis, so the outcome is read back before it is\n# claimed. This script reports; clearing the auto-merge request is the caller's\n# decision, not a side effect of asking to merge.\nOWNER=\"${REPO%%/*}\"; NAME=\"${REPO##*/}\"\n\noutcome() {\n # shellcheck disable=SC2016 # $owner/$name/$pr are GraphQL variables, not shell\n gh api graphql -f owner=\"$OWNER\" -f name=\"$NAME\" -F pr=\"$PR\" -f query='\n query($owner:String!,$name:String!,$pr:Int!){\n repository(owner:$owner,name:$name){\n pullRequest(number:$pr){\n state isInMergeQueue autoMergeRequest{ enabledBy{ login } }\n }\n }\n }' 2>/dev/null\n}\n\n# A queue entry is registered asynchronously, so the first read can legitimately\n# answer false for an entry that lands a second later. The poll is bounded: a PR\n# that has not appeared by then does not appear on its own, and a longer wait\n# would be paid by every successful merge too.\nSTATE=\"\"; INQ=\"\"; AUTOBY=\"-\"; PROBED=0; ATTEMPT=0\nwhile :; do\n ATTEMPT=$((ATTEMPT + 1))\n if Q=$(outcome) && F=$(printf '%s' \"$Q\" | jq -er '.data.repository.pullRequest\n | [ .state, (.isInMergeQueue | tostring),\n (.autoMergeRequest.enabledBy.login // \"-\") ] | @tsv'); then\n IFS=

Git Workflow Skill Not Here Releases: . BLOCKED-PR diagnosis: . GitHub only. Everything below the branching and commit sections — , , the merge gate, review threads — speaks GitHub GraphQL. For a GitLab merge request the equivalent calls are in § GitLab: the same gate with . Critical Rules (Non-Negotiable) 1. No direct push to main — always open a PR. 2. No merge before all threads resolved ( ). 3. No squash unless asked — preserves atomic commits, signatures, bisection. 4. No "tested/verified/working" without pasted command output — else say so. 5. No edits to installed skill/plugin cache pa…

\\t' read -r STATE INQ AUTOBY \u003c\u003cEOF\n$F\nEOF\n PROBED=1\n [ \"$STATE\" = \"MERGED\" ] && break\n # Keyed on what the PR reports, not on the repo-level QUEUE flag: an entry\n # that exists is an entry, whatever the configuration said a moment ago.\n [ \"$INQ\" = \"true\" ] && break\n fi\n [ \"$ATTEMPT\" -ge 5 ] && break\n sleep 2\ndone\n\nif [ \"$STATE\" = \"MERGED\" ]; then\n printf 'pr-merge: %s#%s merged (%s)\\n' \"$REPO\" \"$PR\" \"$METHOD\"\n exit 0\nfi\nif [ \"$INQ\" = \"true\" ]; then\n printf 'pr-merge: %s#%s queued (%s, strategy set by the queue)\\n' \"$REPO\" \"$PR\" \"$METHOD\"\n # Name the waiter at the moment it is needed. Without it the next step was a\n # hand-written poll on `gh pr view --json isInMergeQueue` -- a field that\n # exists only in GraphQL, so every poll failed with `Unknown JSON field`.\n # pr-status.sh --watch holds on a queued entry (`wait`) and returns once the\n # PR is merged (`none`) or has dropped out (the next actionable step).\n printf ' wait with: %q -R %q %q --watch (returns when merged or dropped from the queue)\\n' \\\n \"$SCRIPT_DIR/pr-status.sh\" \"$REPO\" \"$PR\"\n exit 0\nfi\n\n# Nothing landed. A probe that failed is kept apart from a probe that succeeded\n# and saw nothing: an unreachable API says something about the request, not\n# about the PR, and folding the two together would put a transport failure on\n# the record as a fact about GitHub state. Both exit 2 — what this block exists\n# for is to stop reporting an outcome that was never confirmed.\nif [ \"$PROBED\" = \"0\" ]; then\n {\n printf 'pr-merge: %s#%s: the merge call exited 0 but the outcome could not be\\n' \"$REPO\" \"$PR\"\n printf ' read back — %d GraphQL attempt(s) failed. Whether it merged or was\\n' \"$ATTEMPT\"\n printf ' enqueued is unknown; establish that before reporting anything:\\n'\n printf ' gh pr view %s --repo %s --json state,mergedAt\\n' \"$PR\" \"$REPO\"\n } >&2\n exit 2\nfi\n\nif [ \"$QUEUE\" = \"true\" ]; then\n OBSERVED=\"not in the merge queue (state=$STATE, isInMergeQueue=$INQ)\"\nelse\n OBSERVED=\"not merged (state=$STATE)\"\nfi\n{\n printf 'pr-merge: %s#%s failed: gh pr merge %s exited 0 but the PR is %s\\n' \\\n \"$REPO\" \"$PR\" \"$METHOD\" \"$OBSERVED\"\n if [ \"$AUTOBY\" != \"-\" ]; then\n printf ' An auto-merge request enabled by %s is attached to this PR. A pending\\n' \"$AUTOBY\"\n printf ' auto-merge request swallows the enqueue: the call succeeds, the queue\\n'\n printf ' stays empty, and neither gh pr checks nor mergeStateStatus shows it.\\n'\n printf ' Clear it, then run this script again:\\n'\n printf ' gh pr merge %s --repo %s --disable-auto\\n' \"$PR\" \"$REPO\"\n else\n printf ' No auto-merge request is attached, so the usual cause (a pending one\\n'\n printf ' swallowing the enqueue) is ruled out here. Re-read the gate before\\n'\n printf ' retrying:\\n'\n printf ' pr-status.sh -R %s %s\\n' \"$REPO\" \"$PR\"\n fi\n} >&2\nexit 2\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":17934,"content_sha256":"68759cb5d161ca08b80819c7f653598504b5b71eadbbea716f67be5adf9a596f"},{"filename":"scripts/pr-status.sh","content":"#!/usr/bin/env bash\n# pr-status.sh — one-shot, complete merge-readiness picture for a pull request,\n# plus the next valid action.\n#\n# Why this exists: `mergeStateStatus: BLOCKED` never says *why*. Discovering the\n# reason one API call at a time (checks, then rulesets, then threads, then the\n# allowed merge methods, then the queue) burns round-trips and still misses\n# gates — in one 40-PR rollout, 183 of 370 shell calls were PR-status probing,\n# the rulesets endpoint was queried exactly once, and `copilot_code_review`\n# blocked four merges by surprise.\n#\n# Two API calls on the green path: one GraphQL document for the PR, its\n# checks, reviews, threads and the repository's merge configuration; one REST\n# call for the effective branch rules (which is the only place rulesets show\n# up). A status rollup over 100 contexts costs one call per further page and\n# one final membership probe, all bound to the original head SHA. A\n# review-blocked PR costs a third, admin-only call for classic branch\n# protection, whose review gates (require_last_push_approval, approval count,\n# code-owner reviews) no other endpoint exposes. Only a PR that ends the ladder\n# at `investigate` pays for more REST reads: check-runs and check-suites, both\n# paginated (one call per 100 entries), the compare API when a strict\n# status-check rule exists, and classic protection when it was not read yet.\n# They feed the evidence block printed under that NEXT line; with\n# `--watch --ignore-action investigate` they are skipped on every poll.\n#\n# Usage:\n# pr-status.sh # PR for the current branch\n# pr-status.sh 123 # PR number in the current repo\n# pr-status.sh -R owner/repo 123\n# pr-status.sh --json # machine-readable, no prose\n# pr-status.sh --watch # return on the FIRST actionable event\n# pr-status.sh --watch --ignore-action request-review\n# # hold through a standing action you have\n# # decided not to take (repeatable)\n#\n# --watch deliberately does not wait for every check to finish. It returns as\n# soon as something can be worked on: a failing check, a new annotation, or all\n# required checks concluded. Waiting for `pending == 0` means learning nothing\n# until the slowest matrix job ends, long after the first failure was visible.\n#\n# --ignore-action exists for the standing state the first-event rule cannot\n# serve (#165): when `.next.action` is already actionable at invocation and\n# retrying cannot clear it — a request-review made unsatisfiable by an\n# exhausted Copilot quota — every re-arm of --watch returns the same line\n# within a second, and the watch cannot be used to wait for anything else.\n# Naming that action holds the watch through it: the watch still returns on\n# every OTHER actionable event, and once the checks settle while NEXT still\n# names the ignored action it returns `SETTLED: NEXT is still the ignored\n# action` (exit 0) instead of idling into the timeout. A new check failure\n# still returns, except when the ignored action IS the CI action\n# (fix-ci/triage-ci).\n#\n# Copilot review quota\n#\n# The quota is per ACCOUNT and per MONTH, but the only evidence a single pull\n# request carries is an errored review body on its own head — a PR nobody ever\n# requested a review on carries none at all, and used to be answered with a\n# re-request command the exhausted quota rejects. So the wall proven on one PR\n# is written to ${XDG_CACHE_HOME:-~/.cache}/pr-status/, one file per calendar\n# month, and every later invocation reads it before offering that command.\n# The month lives in the FILENAME, so a marker from an earlier month is ignored\n# rather than carried over the reset; delete the file to undo the effect.\n#\n# When the wall stands, the documented fallback (review the diff yourself,\n# note it in the PR, decide) can be put on the record so the merge gate reads\n# it back: a PR comment BY THE PR AUTHOR containing the line\n# `Self-review: \u003chead-sha>` (at least the first 12 chars). The ladder honours it\n# ONLY while the demanded bot review is unsatisfiable — quota wall, or two\n# failed reviews on this head — and only in a run authenticated as the PR\n# author; the attestation dies with the next push,\n# because the sha stops matching. `pr-merge.sh --self-reviewed` posts the\n# comment and merges in one step. This is an explicit operator assertion the\n# tool reads back, not a state it claims to observe (#203).\n#\n# --json contract\n#\n# One object, and its field names are THIS SCRIPT'S — not the GraphQL names\n# `gh pr view` uses. `mergeState`, not `mergeStateStatus`. `base`, not\n# `baseRefName`. Guessing costs more than reading: jq answers a missing key\n# with `null` and says nothing, so a loop waiting for\n# `.mergeStateStatus == \"CLEAN\"` never fires and reads as \"still running\"\n# forever. Top-level keys:\n#\n# state mergeable mergeState draft number title repo author author_is_bot\n# viewer viewer_is_author attestation_available viewer_commits viewer_committed\n# authorship_complete commits_read commits_total\n# base head headOid cross_repository\n# checks checks_settled threads unresolved_threads\n# unanswered_comments unanswered_human unanswered_by unanswered_urls\n# body_freshness\n# reviewDecision reviews_on_head has_review_on_head coderabbit_on_head\n# coderabbit_retry_at sonar_new_issues\n# has_copilot_review_on_head copilot_latest_on_head_ok copilot_review_errored\n# copilot_error_count copilot_quota_hit copilot_quota_exhausted\n# self_review_on_head self_review_url\n# requested_reviewers\n# merge_methods auto_merge_allowed queue_active queue_entry\n# rulesets rules_fetched required_contexts undispatched unsigned\n# classic_protection last_push_rulesets awaiting_approval\n# next\n#\n# `checks` is {total,pass,fail,pending,skip,...} and `next` is\n# {action,why,cmd} — the same two things the prose rendering leads with.\n# On `investigate` only, `next.evidence` carries what the extra reads found\n# (ruleset rules, classic branch protection, required contexts per reporting\n# app, failed suites outside the rollup, pull_request rule parameters,\n# candidates); it is never a verdict. On `fix-required-context`,\n# `next.stale_contexts` lists each required context reported only by a\n# superseded workflow run: {context, workflow, reported_in, newest_run}, the\n# two runs as {run_id, run_number, event, created_at, url}.\n#\n# `body_freshness` is {edited_at, edited, head_committed_at, older_than_head}:\n# the last edit of the PR body (its creation time when never edited, then\n# `edited` is false) against the committer date of the head commit. It is\n# information only and never changes `next`; the prose rendering prints a\n# `body` line when `older_than_head` is true.\n#\n# Before writing a jq filter against any of these, consider whether --watch\n# already answers the question; it usually does, and a hand-rolled poll loop is\n# how the wrong field name gets guessed in the first place.\n#\n# tests/test_pr_status_json_contract.sh fails when this list and the emitted\n# object drift apart, in either direction.\n\n# SPDX-License-Identifier: MIT\n# SPDX-FileCopyrightText: Netresearch DTT GmbH\n\nset -uo pipefail\n\n# --version answers \"which copy am I running\" without diffing installations.\n# Two installations can declare the SAME number while shipping different\n# scripts (netresearch/git-workflow-skill#209 measured exactly that, and the\n# missing flag read as a missing feature for a dozen merges). So the resolved\n# path is printed beside the version: the number says what the copy claims to\n# be, the path says which file actually answered.\n#\n# The version is read from the SKILL.md NEXT TO the script, never from a\n# checkout elsewhere -- a cached copy must report the number it was packaged\n# with, or the answer is worse than none. \\042 and \\047 are the quote\n# characters by octal code, so this awk program contains no quote of its own\n# to terminate the single-quoted string it lives in.\nskill_version() {\n local here skill v\n here=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n skill=\"$here/../SKILL.md\"\n v=\"unknown\"\n if [ -f \"$skill\" ]; then\n v=\"$(awk '/^[ \\t]*version:/ {\n s = $0\n sub(/^[ \\t]*version:[ \\t]*/, \"\", s)\n gsub(/[\\042\\047]/, \"\", s)\n gsub(/[ \\t\\r]+$/, \"\", s)\n if (s != \"\") { print s; exit }\n }' \"$skill\" 2>/dev/null)\" || v=\"\"\n [ -n \"$v\" ] || v=\"unknown\"\n fi\n printf '%s %s\\n' \"$(basename \"${BASH_SOURCE[0]}\")\" \"$v\"\n printf 'path: %s/%s\\n' \"$here\" \"$(basename \"${BASH_SOURCE[0]}\")\"\n}\n\n# The jq programs of evaluate() and investigate_evidence() live next to this\n# script (pr-status-evaluate.jq, pr-status-investigate.jq). A symlink to the\n# script is followed to the real file.\nscript_path=\"${BASH_SOURCE[0]}\"\nwhile [ -L \"$script_path\" ]; do\n link=\"$(readlink \"$script_path\")\"\n case \"$link\" in\n /*) script_path=\"$link\" ;;\n *) script_path=\"$(dirname \"$script_path\")/$link\" ;;\n esac\ndone\nSCRIPT_DIR=\"$(CDPATH='' cd -P \"$(dirname \"$script_path\")\" && pwd)\"\n\nREPO=\"\"; PR=\"\"; JSON=0; WATCH=0; INTERVAL=20; MAXWAIT=3600; IGNORE=\"\"\n\n# Every action the watch loop returns on. --ignore-action accepts exactly\n# these: any other value could never have fired the loop, so accepting one\n# would let a typo behave as if the flag were absent. The waiting actions\n# (wait, await-*, rules-unavailable) heartbeat instead of returning, so they\n# are deliberately not in here. tests/test_pr_status_draft_watch.sh pins the\n# two lists against every action literal this script and its .jq programs can\n# emit — a new action must land in one of them.\nACTIONABLE=\"fix-ci triage-ci rerun-ci resolve-threads address-comments request-review rebase resolve-conflicts merge blocked none fix-signatures ready investigate approve-workflow-runs fix-required-context\"\n\ndie() { printf 'pr-status: %s\\n' \"$1\" >&2; exit 2; }\n\nneed() { [ $# -ge 2 ] && [ -n \"${2:-}\" ] || die \"$1 requires a value\"; }\n\nin_list() { case \" $1 \" in *\" $2 \"*) return 0 ;; *) return 1 ;; esac; }\nis_ignored() { in_list \"$IGNORE\" \"$1\"; }\n\nwhile [ $# -gt 0 ]; do\n case \"$1\" in\n -R|--repo) need \"$@\"; REPO=\"$2\"; shift 2 ;;\n --json) JSON=1; shift ;;\n --watch) WATCH=1; shift ;;\n --interval) need \"$@\"; INTERVAL=\"$2\"; shift 2 ;;\n --max-wait) need \"$@\"; MAXWAIT=\"$2\"; shift 2 ;;\n --ignore-action) need \"$@\"\n # Whitespace first: \"fix-ci triage-ci\" happens to be a\n # substring of ACTIONABLE and would slip past in_list.\n case \"$2\" in (*[[:space:]]*)\n die \"--ignore-action: one action per flag (got '$2')\" ;;\n esac\n in_list \"$ACTIONABLE\" \"$2\" \\\n || die \"--ignore-action: unknown action '$2' (one of: $ACTIONABLE)\"\n IGNORE=\"$IGNORE $2\"; shift 2 ;;\n # Prints the whole leading comment block rather than a fixed line range:\n # a hard-coded range silently truncates --help the moment the header\n # grows, which is how a documented contract stops being visible.\n --version) skill_version; exit 0 ;;\n -h|--help) awk 'NR>1 && /^#/ {sub(/^# ?/,\"\"); print; next} NR>1 {exit}' \"$0\"; exit 0 ;;\n -*) die \"unknown flag: $1\" ;;\n *) PR=\"$1\"; shift ;;\n esac\ndone\n\nif [ -n \"$IGNORE\" ] && [ \"$WATCH\" = \"0\" ]; then\n die \"--ignore-action is only meaningful with --watch\"\nfi\n\ncommand -v gh >/dev/null || die \"gh not found\"\ncommand -v jq >/dev/null || die \"jq not found\"\n# Checked once here: a missing program would otherwise make every --watch poll\n# read as an unreadable gate until --max-wait ran out.\nfor f in pr-status-evaluate.jq pr-status-investigate.jq; do\n [ -f \"$SCRIPT_DIR/$f\" ] || die \"$SCRIPT_DIR/$f is missing; it ships beside pr-status.sh\"\ndone\n\nif [ -z \"$REPO\" ]; then\n REPO=$(gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null) \\\n || die \"not in a repo; pass -R owner/repo\"\nfi\nif [ -z \"$PR\" ]; then\n PR=$(gh pr view --repo \"$REPO\" --json number --jq .number 2>/dev/null) \\\n || die \"no PR for the current branch; pass a number\"\nfi\nOWNER=\"${REPO%%/*}\"; NAME=\"${REPO##*/}\"\n\n# This script speaks GitHub GraphQL and nothing else. A GitLab project reads\n# host/group/project, which parses here without complaint and then dies deep in\n# the query as a bare \"GraphQL query failed\" — and --watch heartbeats into its\n# timeout on a query that can never succeed. Refuse it up front and name where\n# the answer lives instead (#250).\ncase \"$REPO\" in\n */*/*)\n die \"\\\"$REPO\\\" is not owner/repo. pr-status.sh is GitHub-only; for a GitLab merge request see references/pull-request-workflow.md § \\\"GitLab: the same gate with glab\\\"\" ;;\nesac\ncase \"${OWNER}\" in\n *.*)\n die \"\\\"$OWNER\\\" looks like a host, not a GitHub owner. pr-status.sh is GitHub-only; for a GitLab merge request see references/pull-request-workflow.md § \\\"GitLab: the same gate with glab\\\"\" ;;\nesac\n\n# --------------------------------------------------------- quota marker -----\n# The Copilot review quota is account-wide and monthly; the evidence for it is\n# not. It arrives as an error body on ONE pull request, and every other PR of\n# that account looks untouched — so the ladder below kept offering a\n# re-request command that the same exhausted quota would reject (observed on\n# netresearch/maint#52 and #53, hours after the wall was proven on another\n# repo). One file per calendar month carries the fact across invocations.\nQUOTA_DIR=\"${XDG_CACHE_HOME:-$HOME/.cache}/pr-status\"\n# The month is in the NAME, not in the mtime: any later write would refresh an\n# mtime and make a marker outlive the reset it describes.\nQUOTA_MARKER=\"$QUOTA_DIR/copilot-quota-exhausted-$(date -u +%Y-%m)\"\n# ... and the marker expires, because the wall does not last the month. Measured\n# on one machine: recorded 2026-08-18, a normal Copilot review delivered\n# 2026-08-29, the wall back ten minutes later. A marker believed until the month\n# rolls over turns one transient error into weeks of suppressed bot review, and\n# nothing re-probes. After the TTL the next run asks again and either re-arms it\n# or leaves it gone (#255). remember_quota_hit() never rewrites an existing\n# marker, so the mtime stays the moment the wall was first proven.\nQUOTA_TTL_HOURS=\"${PR_STATUS_QUOTA_TTL_HOURS:-6}\"\n# Bounded wait of --watch after a rate-limited read that does not wait for the\n# GraphQL reset (budget left, or the lookup failed). Settable for the tests,\n# which cannot afford a minute per case.\nRATE_LIMIT_WAIT=\"${PR_STATUS_RATE_LIMIT_WAIT:-60}\"\n# A value that is not a positive integer would skip both clamps below and\n# reach sleep as is; it gets the default.\n[[ \"$RATE_LIMIT_WAIT\" =~ ^[1-9][0-9]*$ ]] || RATE_LIMIT_WAIT=60\n\nquota_marker_seen() {\n [ -f \"$QUOTA_MARKER\" ] || { echo false; return; }\n if [ -n \"$(find \"$QUOTA_MARKER\" -mmin \"+$((QUOTA_TTL_HOURS * 60))\" 2>/dev/null)\" ]; then\n rm -f \"$QUOTA_MARKER\" 2>/dev/null || :\n echo false; return\n fi\n echo true\n}\n\n# A delivered Copilot review is the only positive evidence the wall is gone.\n# Acting on it beats waiting out the TTL: the very run that sees the review\n# would otherwise still route to self-review.\nforget_quota_hit() { rm -f \"$QUOTA_MARKER\" 2>/dev/null || :; }\n\n# Best-effort by design: the marker only saves a wasted re-request, so a\n# read-only or full cache directory must never turn a status query into a\n# failure. Content is for the human who wonders where the verdict came from.\nremember_quota_hit() {\n [ -f \"$QUOTA_MARKER\" ] && return 0\n mkdir -p \"$QUOTA_DIR\" 2>/dev/null || return 0\n printf 'copilot review quota exhausted; proven on %s#%s at %s\\n' \\\n \"$REPO\" \"$PR\" \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" >\"$QUOTA_MARKER\" 2>/dev/null || return 0\n}\n\n# ---------------------------------------------------------------- data ------\n# Swallows gh's chatter on success -- a deprecation notice is not this tool's\n# news -- but re-emits it verbatim when the call FAILS, because that body is the\n# only thing separating an expired token from a rate limit from a network drop.\n# Callers point the operator at stderr for the cause; before this, stderr held\n# nothing but `pr-status: GraphQL query failed`.\ncollect() {\n local out err rc\n err=$(mktemp)\n out=$(collect_raw 2>\"$err\"); rc=$?\n [ \"$rc\" -eq 0 ] || cat \"$err\" >&2\n rm -f \"$err\"\n printf '%s' \"$out\"\n return \"$rc\"\n}\n\ncollect_raw() {\n # shellcheck disable=SC2016 # $owner/$name/$pr are GraphQL variables, not shell\n gh api graphql -f owner=\"$OWNER\" -f name=\"$NAME\" -F pr=\"$PR\" -f query='\n query($owner:String!,$name:String!,$pr:Int!){\n # Who is running this. Needed only for a bot-authored pull request, where\n # the answering party is the reviewer rather than the author (#319). Costs\n # no extra call: it rides along in the query that was going out anyway.\n viewer{ login }\n repository(owner:$owner,name:$name){\n nameWithOwner\n mergeCommitAllowed rebaseMergeAllowed squashMergeAllowed autoMergeAllowed\n # A merge queue set up through classic branch protection has no ruleset,\n # so the rules endpoint never lists it, queue_active read false, and\n # pr-merge.sh passed --delete-branch into a queue that refuses it\n # (glpi-docker-compose-stack#41). The mergeQueue field of the pull request\n # is the queue of its base branch; it rides along here at no extra call.\n # No apostrophes in these comments: the query is a single-quoted string.\n pullRequest(number:$pr){\n number title state isDraft mergeable mergeStateStatus reviewDecision\n mergeQueue{ id }\n mergeQueueEntry{ state position estimatedTimeToMerge }\n author{login __typename}\n baseRefName headRefName headRefOid isCrossRepository\n # When the body was last written, against committedDate of the head\n # below: see body_freshness. lastEditedAt is null for a body nobody\n # edited, and createdAt is then when it was written.\n createdAt lastEditedAt\n # The last page, not the first: a Self-review attestation (see the\n # header) is posted at the end of a conversation, and an old page\n # would go blind on exactly the PRs long enough to need one.\n comments(last:100){ nodes{ author{login __typename} body url createdAt lastEditedAt } }\n reviews(last:50){ nodes{ author{login} state commit{oid} body } }\n reviewRequests(first:20){ nodes{ requestedReviewer{\n ... on User{login} ... on Bot{login} ... on Team{slug} } } }\n reviewThreads(first:100){ nodes{ id isResolved isOutdated\n comments(first:1){ nodes{ databaseId author{login} path } } } }\n # One unsigned commit anywhere on the branch shuts a required_signatures\n # ruleset, and GitHub surfaces that only as mergeStateStatus BLOCKED —\n # no red check, nothing in the rollup. Without this the tool can only\n # say \"investigate\".\n # authors: who wrote each commit, so a non-author who pushed fixes is\n # not told to approve what they wrote themselves (retro-skill#164);\n # parents: a merge commit brings the base in and holds no code of theirs.\n # totalCount on both says whether the first page held everything.\n allCommits: commits(first:100){ totalCount nodes{ commit{ oid signature{ isValid }\n parents{ totalCount } authors(first:10){ totalCount nodes{ user{ login } } } } } }\n commits(last:1){ nodes{ commit{ oid committedDate statusCheckRollup{ state\n contexts(first:100){ totalCount pageInfo{ hasNextPage endCursor } nodes{\n __typename\n # checkSuite.workflowRun names the Actions run each row came from.\n # It is what tells a required context reported only by an OLDER\n # run of a workflow apart from one the newest run reported; see\n # $stale_required below. Null for a check run of any other app.\n # steps and annotations identify a failed Actions job that no\n # runner ever picked up; see NOSTART below.\n ... on CheckRun{ id name conclusion status detailsUrl startedAt\n steps(first:1){ totalCount }\n annotations(first:10){ nodes{ message } }\n checkSuite{ status workflowRun{ databaseId runNumber event createdAt url\n workflow{ databaseId name } } } }\n ... on StatusContext{ id context state targetUrl }\n } } } } } }\n }\n }\n }'\n}\n\n# Further pages must never follow a moving PR branch. The immutable commit SHA\n# names the same object as the initial snapshot, including for a fork head.\ncollect_context_page() {\n local head=\"$1\" cursor=\"${2:-}\" args=()\n [[ -z \"$cursor\" ]] || args=(-f cursor=\"$cursor\")\n # shellcheck disable=SC2016 # These are GraphQL variables.\n gh api graphql -f owner=\"$OWNER\" -f name=\"$NAME\" -f head=\"$head\" ${args[@]+\"${args[@]}\"} -f query='\n query StatusContextsPage($owner:String!,$name:String!,$head:GitObjectID!,$cursor:String){\n repository(owner:$owner,name:$name){object(oid:$head){... on Commit{oid\n statusCheckRollup{contexts(first:100,after:$cursor){totalCount pageInfo{hasNextPage endCursor} nodes{\n __typename\n ... on CheckRun{id name conclusion status detailsUrl startedAt\n steps(first:1){totalCount} annotations(first:10){nodes{message}}\n checkSuite{status workflowRun{databaseId runNumber event createdAt url workflow{databaseId name}}}}\n ... on StatusContext{id context state targetUrl}\n }}}\n }}}\n }'\n}\n\n# Assemble only the status-context connection. Count and identity checks detect\n# observable membership drift; they do not make changing check states atomic.\n# Payloads stay on stdin, preserving the large-response/Windows argv guards.\ncomplete_contexts() {\n local g conn head total page next cursor old first_ids paged=false seen=()\n local shape='type == \"object\" and (.nodes | type == \"array\")\n and (.totalCount | type == \"number\" and . >= 0 and . == floor)\n and (.pageInfo.hasNextPage | type == \"boolean\")\n and all(.nodes[]; (.id | type == \"string\" and length > 0))\n and (([.nodes[].id] | unique | length) == (.nodes | length))'\n g=$(cat) || return 1\n jq -e '(.errors // []) | type == \"array\" and length == 0' \u003c\u003c\u003c\"$g\" >/dev/null || return 1\n conn=$(jq -c '.data.repository.pullRequest.commits.nodes[0].commit.statusCheckRollup.contexts // null' \u003c\u003c\u003c\"$g\") || return 1\n # A commit with no checks legitimately has no rollup.\n if [[ \"$conn\" == null ]]; then printf '%s\\n' \"$g\"; return 0; fi\n jq -e '.pageInfo.hasNextPage | type == \"boolean\"' \u003c\u003c\u003c\"$conn\" >/dev/null || return 1\n # A single page needs no assembly. When membership metadata is present,\n # also reject an early exhaustion flag; multiple pages require that metadata.\n if jq -e 'has(\"totalCount\")' \u003c\u003c\u003c\"$conn\" >/dev/null; then\n jq -e \"$shape\" \u003c\u003c\u003c\"$conn\" >/dev/null || return 1\n total=$(jq '.totalCount' \u003c\u003c\u003c\"$conn\")\n jq -e '(.nodes | length) \u003c= .totalCount\n and (.pageInfo.hasNextPage == ((.nodes | length) \u003c .totalCount))' \u003c\u003c\u003c\"$conn\" >/dev/null || return 1\n fi\n if ! jq -e '.pageInfo.hasNextPage' \u003c\u003c\u003c\"$conn\" >/dev/null; then printf '%s\\n' \"$g\"; return 0; fi\n jq -e \"$shape\" \u003c\u003c\u003c\"$conn\" >/dev/null || return 1\n total=$(jq '.totalCount' \u003c\u003c\u003c\"$conn\")\n first_ids=$(jq -c '[.nodes[].id]' \u003c\u003c\u003c\"$conn\")\n head=$(jq -er '.data.repository.pullRequest.commits.nodes[0].commit.oid\n | select(type == \"string\" and test(\"^[a-fA-F0-9]{40}$\"))' \u003c\u003c\u003c\"$g\") || return 1\n jq -e --arg head \"$head\" '.data.repository.pullRequest.headRefOid == $head' \u003c\u003c\u003c\"$g\" >/dev/null || return 1\n while jq -e '.pageInfo.hasNextPage' \u003c\u003c\u003c\"$conn\" >/dev/null; do\n cursor=$(jq -er '.pageInfo.endCursor | select(type == \"string\" and length > 0\n and (test(\"[\\\\x00-\\\\x1f\\\\x7f]\") | not))' \u003c\u003c\u003c\"$conn\") || return 1\n for old in ${seen[@]+\"${seen[@]}\"}; do [[ \"$cursor\" != \"$old\" ]] || return 1; done\n seen+=(\"$cursor\")\n page=$(collect_context_page \"$head\" \"$cursor\") || return 1\n jq -e --arg head \"$head\" '((.errors // []) | type == \"array\" and length == 0)\n and .data.repository.object.oid == $head' \u003c\u003c\u003c\"$page\" >/dev/null || return 1\n next=$(jq -c '.data.repository.object.statusCheckRollup.contexts' \u003c\u003c\u003c\"$page\") || return 1\n jq -e \"$shape\" \u003c\u003c\u003c\"$next\" >/dev/null || return 1\n jq -e --argjson total \"$total\" '.totalCount == $total and (.nodes | length) > 0' \u003c\u003c\u003c\"$next\" >/dev/null || return 1\n # A terminal page must also advance its cursor, not silently repeat a page.\n old=$(jq -er '.pageInfo.endCursor | select(type == \"string\" and length > 0\n and (test(\"[\\\\x00-\\\\x1f\\\\x7f]\") | not))' \u003c\u003c\u003c\"$next\") || return 1\n for cursor in ${seen[@]+\"${seen[@]}\"}; do [[ \"$old\" != \"$cursor\" ]] || return 1; done\n conn=$(printf '%s\\n' \"$conn\" \"$next\" | jq -n 'input as $a | input as $b | $b | .nodes = ($a.nodes + $b.nodes)') || return 1\n jq -e \"$shape\" \u003c\u003c\u003c\"$conn\" >/dev/null || return 1\n jq -e '(.nodes | length) \u003c= .totalCount\n and (.pageInfo.hasNextPage == ((.nodes | length) \u003c .totalCount))' \u003c\u003c\u003c\"$conn\" >/dev/null || return 1\n paged=true\n done\n if [[ \"$paged\" == true ]]; then\n # A bounded final probe detects additions after the last page and movement\n # of the initial page. It checks membership, not atomic status values.\n page=$(collect_context_page \"$head\") || return 1\n jq -e --arg head \"$head\" '((.errors // []) | type == \"array\" and length == 0)\n and .data.repository.object.oid == $head' \u003c\u003c\u003c\"$page\" >/dev/null || return 1\n next=$(jq -c '.data.repository.object.statusCheckRollup.contexts' \u003c\u003c\u003c\"$page\") || return 1\n jq -e \"$shape\" \u003c\u003c\u003c\"$next\" >/dev/null || return 1\n jq -e '.pageInfo.hasNextPage == ((.nodes | length) \u003c .totalCount)' \u003c\u003c\u003c\"$next\" >/dev/null || return 1\n printf '%s\\n' \"$next\" \"$first_ids\" | jq -ne --argjson total \"$total\" 'input as $p | input as $ids\n | $p.totalCount == $total and ([$p.nodes[].id] == $ids)' >/dev/null || return 1\n fi\n printf '%s\\n' \"$g\" \"$conn\" | jq -n 'input as $g | input as $c | $g\n | .data.repository.pullRequest.commits.nodes[0].commit.statusCheckRollup.contexts = $c'\n}\n\n# A branch legitimately has no rules and answers `[]`, so an empty result is NOT\n# an error — but a failed call must never be folded into the same value:\n# \"could not fetch\" silently becoming \"no required checks\" would make this tool\n# report a PR as more mergeable than it is, the one direction it must not get\n# wrong. The fetch is inline rather than a function because a `$( )` subshell\n# would discard the status flag.\n\nevaluate() {\n local gql=\"$1\" rules=\"$2\" ok=\"$3\" marker=\"$4\" prot=\"${5:-null}\" runs=\"${6:-[]}\"\n # The jq program comes from a file and the JSON payloads from stdin, not\n # argv. Linux caps one argument at 128 KiB, which a pull request with long\n # comments passes; Windows caps the whole command line at 32767 characters,\n # which the program alone passes (#395). The program takes exactly four\n # documents and fails on any other count, so an empty rules answer stays an\n # error instead of reading as \"no rules\".\n printf '%s\\n' \"$gql\" \"$rules\" \"$prot\" \"$runs\" \\\n | jq -n --argjson ok \"$ok\" --argjson marker \"$marker\" --arg marker_path \"$QUOTA_MARKER\" \\\n -f \"$SCRIPT_DIR/pr-status-evaluate.jq\"\n}\n\n# ------------------------------------------------------- investigate --------\n# `investigate` used to be one sentence, and the reader then guessed a cause\n# and reported it as fact (t3x-nr-image-optimize#201: BLOCKED, 72 checks pass,\n# 0 fail, 0 threads, APPROVED). This gathers what can be read about the rest\n# and lists it: the ruleset rules on the base branch, the classic branch\n# protection (reviews, conversation resolution, signatures, admin enforcement,\n# status checks), each required context with the app that reported it, failed\n# check suites the GraphQL rollup does not show, and the pull_request rule\n# parameters. It never names a cause. A parameter this script cannot evaluate\n# is listed as set and not evaluable, nothing more.\n#\n# Called only on the investigate path, so the common path costs nothing. A\n# failed read is carried as not fetched rather than folded into an empty list:\n# \"could not read the check-runs\" must not print as \"nothing is missing\". Each\n# read affects only the lines built from it.\n\n# Classic branch protection, read into PROT / PROT_STATE / PROT_ERR. Called\n# directly, never in $( ), so the three values survive. \"not protected\" is the\n# 404 \"Branch not protected\" GitHub answers for a branch without classic\n# protection; every other failure (the plain 404 \"Not Found\" a caller without\n# admin rights gets, a 403, a network error) is \"failed\" and keeps the message.\nPROT='null'; PROT_STATE='not requested'; PROT_ERR=''\nread_protection() {\n local enc=\"$1\" perr raw\n perr=$(mktemp)\n if raw=$(gh api \"repos/$REPO/branches/$enc/protection\" 2>\"$perr\") \\\n && jq -e 'type == \"object\"' \u003c\u003c\u003c\"$raw\" >/dev/null 2>&1; then\n PROT=\"$raw\"; PROT_STATE='read'; PROT_ERR=''\n elif grep -q 'Branch not protected' \"$perr\" && grep -q 'HTTP 404' \"$perr\"; then\n PROT='null'; PROT_STATE='not protected'; PROT_ERR=''\n else\n PROT='null'; PROT_STATE='failed'\n PROT_ERR=$(grep -v '^[[:space:]]*

Git Workflow Skill Not Here Releases: . BLOCKED-PR diagnosis: . GitHub only. Everything below the branching and commit sections — , , the merge gate, review threads — speaks GitHub GraphQL. For a GitLab merge request the equivalent calls are in § GitLab: the same gate with . Critical Rules (Non-Negotiable) 1. No direct push to main — always open a PR. 2. No merge before all threads resolved ( ). 3. No squash unless asked — preserves atomic commits, signatures, bisection. 4. No "tested/verified/working" without pasted command output — else say so. 5. No edits to installed skill/plugin cache pa…

\"$perr\" | tail -n 1 | sed 's/^gh: //')\n [ -n \"$PROT_ERR\" ] || PROT_ERR='no error message'\n fi\n rm -f \"$perr\"\n}\n\ninvestigate_evidence() {\n local g=\"$1\" r=\"$2\" prot=\"$3\" prot_state=\"$4\" prot_err=\"$5\" enc=\"$6\" head_oid=\"$7\"\n # The check-runs and check-suites travel through files, not arguments: one\n # argument is capped at 128 KiB, and a head with several hundred check-runs\n # passed that even after trimming them to the fields used below.\n local tmp raw cr_ok=false cs_ok=false cmp='null' cmp_state='\"not needed\"'\n tmp=$(mktemp -d) || return 1\n if raw=$(gh api --paginate \"repos/$REPO/commits/$head_oid/check-runs?per_page=100\" 2>/dev/null) \\\n && jq -sc '[.[].check_runs[]? | {name, status, conclusion, started_at,\n app: {id: .app.id}, check_suite: {id: .check_suite.id}}]' \\\n \u003c\u003c\u003c\"$raw\" >\"$tmp/cr\" 2>/dev/null; then cr_ok=true; else echo null >\"$tmp/cr\"; fi\n if raw=$(gh api --paginate \"repos/$REPO/commits/$head_oid/check-suites?per_page=100\" 2>/dev/null) \\\n && jq -sc '[.[].check_suites[]? | {id, conclusion, app: {id: .app.id, slug: .app.slug}}]' \\\n \u003c\u003c\u003c\"$raw\" >\"$tmp/cs\" 2>/dev/null; then cs_ok=true; else echo null >\"$tmp/cs\"; fi\n raw=''\n # By sha, not by branch name: the GraphQL read does not carry the head\n # owner, and a fork branch name would be looked up in the wrong repository.\n # Asked when either source demands an up-to-date branch: a strict ruleset\n # rule, or strict status checks in classic protection.\n if jq -e '[.[]? | select(.type == \"required_status_checks\")\n | .parameters.strict_required_status_checks_policy // false] | any' \u003c\u003c\u003c\"$r\" >/dev/null 2>&1 \\\n || jq -e '.required_status_checks.strict // false' \u003c\u003c\u003c\"$prot\" >/dev/null 2>&1; then\n if raw=$(gh api \"repos/$REPO/compare/$enc...$head_oid\" 2>/dev/null) \\\n && cmp=$(jq -c '{behind_by, ahead_by}' \u003c\u003c\u003c\"$raw\" 2>/dev/null); then cmp_state='\"read\"'\n else cmp='null'; cmp_state='\"failed\"'; fi\n fi\n # Through stdin for the same reason as in evaluate(): argv caps one argument\n # on Linux and the whole command line on Windows.\n printf '%s\\n' \"$g\" \"$r\" \"$prot\" \\\n | jq -n --arg prot_state \"$prot_state\" --arg prot_err \"$prot_err\" \\\n --slurpfile crf \"$tmp/cr\" --slurpfile csf \"$tmp/cs\" --argjson cmp \"$cmp\" \\\n --argjson cr_ok \"$cr_ok\" --argjson cs_ok \"$cs_ok\" --argjson cmp_state \"$cmp_state\" \\\n -f \"$SCRIPT_DIR/pr-status-investigate.jq\"\n local rc=$?\n rm -rf \"$tmp\"\n return $rc\n}\n\nrender() {\n jq -r '\n \"PR #\\(.number) \\(.title)\",\n \" state : \\(.state)\\(if .draft then \" (DRAFT)\" else \"\" end) mergeable=\\(.mergeable) mergeState=\\(.mergeState)\",\n \" head : \\(.headOid[0:8]) on \\(.head) -> \\(.base)\",\n \" checks : \\(.checks.pass) pass, \\(.checks.fail) fail, \\(.checks.pending) pending\\(if .checks.pending > 0 then \" (\\(.checks.running) running, \\(.checks.queued) queued)\" else \"\" end), \\(.checks.skip) skip (of \\(.checks.total))\",\n (if (.checks.failing|length) > 0 then \" failing : \\(.checks.failing|join(\", \"))\" else empty end),\n (if (.checks.cancelled|length) > 0 then \" cancelled : \\(.checks.cancelled|join(\", \")) — re-run, do not debug\" else empty end),\n (if ((.checks.not_started // [])|length) > 0 then \" not started : \\(.checks.not_started|join(\", \")) — no runner acquired; gh run rerun \u003crun> --failed once that run has completed (run \\(.checks.not_started_runs|map(tostring)|join(\", \"))), do not debug\" else empty end),\n (if .checks.stale > 0 then \" stale : \\(.checks.stale) cancelled row(s) from a superseded run, ignored\" else empty end),\n (if (.checks.failing_required|length) > 0 then \" ^ REQUIRED : \\(.checks.failing_required|join(\", \"))\" else empty end),\n \" rulesets : \\(if (.rulesets|length)>0 then (.rulesets|join(\", \")) else \"none\" end)\",\n (if .classic_protection then\n \" classic : approvals>=\\(.classic_protection.approvals_required)\\(if .classic_protection.last_push_approval then \", last-push-approval\" else \"\" end)\\(if .classic_protection.code_owner_reviews then \", code-owner-reviews\" else \"\" end) (classic branch protection — invisible to the rulesets endpoint)\"\n else empty end),\n \" reviews : \\(if .has_review_on_head then (.reviews_on_head|to_entries|map(\"\\(.key)=\\(.value)\")|join(\", \")) else \"NONE on current head\" end)\\(if .coderabbit_on_head == \"none\" then \"\" else \" coderabbit=\\(.coderabbit_on_head)\\(if .coderabbit_retry_at then \" (request again after \\(.coderabbit_retry_at))\" else \"\" end)\" end) decision=\\(if .reviewDecision==\"\" then \"-\" else .reviewDecision end)\",\n (if (.sonar_new_issues // 0) > 0 then\n \" sonar : \\(.sonar_new_issues) new issue(s) in the latest Quality Gate comment — not gating; read them before merging, a passed gate still lets them through\"\n else empty end),\n \" threads : \\(.unresolved_threads) unresolved\",\n (if .unanswered_comments > 0 then\n \" comments : \\(.unanswered_comments) unanswered (\\(.unanswered_by|join(\", \")))\\(if .unanswered_human == 0 then \" — bots only, not gating\" else \"\" end)\"\n else empty end),\n (if .body_freshness.older_than_head == true then\n \" body : \\(if .body_freshness.edited then \"last edited\" else \"written\" end) \\(.body_freshness.edited_at), head committed \\(.body_freshness.head_committed_at) — re-read the body against the diff\"\n else empty end),\n \" merge : methods=[\\(.merge_methods|join(\",\"))] auto=\\(.auto_merge_allowed) queue=\\(.queue_active)\",\n (if .queue_entry then\n \" in queue : position \\(.queue_entry.position), \\(.queue_entry.state)\\(if (.queue_entry.eta != null) then \", ~\\(((.queue_entry.eta) / 60) | floor) min\" else \"\" end)\"\n else empty end),\n \"\",\n \"NEXT: \\(.next.action) — \\(.next.why)\",\n (if .next.method then \" method: \\(.next.method)\" else empty end),\n (if .next.note then \" note : \\(.next.note)\" else empty end),\n (if .next.cmd then \" cmd : \\(.next.cmd)\" else empty end),\n (if .next.stale_contexts then (.next.stale_contexts[]\n | \" stale ctx : \\(.context) — \\(.workflow) run #\\(.reported_in.run_number) \\(.reported_in.url // \"\")\"\n + \" superseded by run #\\(.newest_run.run_number) \\(.newest_run.url // \"\")\") else empty end),\n (if .next.threads then (.next.threads[]|\" thread \\(.threadId) (comment \\(.commentId)) by \\(.author) on \\(.path)\") else empty end),\n (if .next.urls then (.next.urls[]|\" \\(.)\") else empty end),\n (if .next.evidence then (.next.evidence as $e\n | \"\",\n \"EVIDENCE — cause NOT determined; candidates, not a verdict\",\n \" rules : rulesets: \\(if ($e.rules|length) > 0 then ($e.rules|map(\"\\(.type)(\\(.ruleset_id))\")|join(\", \")) else \"none\" end)\",\n ($e.classic_protection as $c\n | if $c.fetched == \"read\" then\n \" classic prot: read — \\(if $c.required_pull_request_reviews == null then \"no pull request reviews required\"\n else ($c.required_pull_request_reviews | \"approvals \\(.approvals_on_head)/\\(.required_approving_review_count), dismiss_stale_reviews=\\(.dismiss_stale_reviews), code_owner_reviews=\\(.require_code_owner_reviews), last_push_approval=\\(.require_last_push_approval)\") end), conversation_resolution=\\($c.required_conversation_resolution), signatures=\\($c.required_signatures), enforce_admins=\\($c.enforce_admins), status checks \\(if $c.required_status_checks == null then \"none\" else \"strict=\\($c.required_status_checks.strict) (\\($c.required_status_checks.contexts|join(\", \")))\" end)\"\n elif $c.fetched == \"not protected\" then \" classic prot: none — the base branch has no classic protection\"\n else \" classic prot: not fetched — \\($c.error // $c.fetched)\" end),\n ($e.status_check_rules[] | \" status rule : \\(.source) strict=\\(.strict)\\(if .strict then \" behind_by=\\(.behind_by // \"unreadable\")\" else \"\" end)\"),\n (if $e.fetched.check_runs | not then \" contexts : check-runs could not be read — per-context state unknown\" else empty end),\n # An unknown context is not a finding of its own: the one candidate is\n # the failed read, so these rows carry \"?\" rather than FLAG.\n ($e.required_contexts[] | \" \\(if .flag == null then \"ok \" elif .flag == \"unknown\" then \"? \" else \"FLAG\" end) : \\(.context) — required app \\(.required_integration_id), reported by \\(if .reported_by_apps == null then \"unknown\" elif (.reported_by_apps|length) == 0 then \"nobody\" else (.reported_by_apps|map(tostring)|join(\",\")) end), \\(.state)\\(if .flag != null then \" [\\(.flag)]\" else \"\" end) (\\(.source))\"),\n (if $e.failed_suites_outside_rollup == null then \" suites : check suites could not be read\"\n elif ($e.failed_suites_outside_rollup|length) == 0 then \" suites : no failed check suite outside the rollup\"\n else ($e.failed_suites_outside_rollup[]\n | \" suite : \\(.suite_id) (\\(.app // \"unknown app\")) \\(.conclusion), \"\n + (if .outside_rollup != null then \"outside the rollup: \\(.outside_rollup|join(\", \"))\"\n else \"runs \\(if .check_runs == null then \"unknown\" else (.check_runs|join(\", \")) end), \\(.rollup_comparison)\" end)) end),\n ($e.pull_request_rules[] | \" pr rule : ruleset \\(.ruleset_id) approvals \\(.approvals_on_head)/\\(.required_approving_review_count)\\(if (.approvers|length) > 0 then \" (\\(.approvers|join(\", \")))\" else \"\" end), last_push_approval=\\(.require_last_push_approval), extra_approval_for_unattributed_changes=\\(.require_extra_approval_for_unattributed_changes), code_owner_review=\\(.require_code_owner_review), thread_resolution=\\(.required_review_thread_resolution)\"),\n ($e.candidates[] | \" candidate : \\(.)\"),\n \" summary : \\($e.summary)\")\n else empty end)\n '\n}\n\nsnapshot() {\n local g r base st\n g=$(collect) || die \"GraphQL query failed\"\n\n # GitHub computes mergeStateStatus lazily: the first read of a PR often\n # answers UNKNOWN and only schedules the calculation. Every gate decision\n # below (BEHIND, CLEAN, UNSTABLE) depends on it, so ask again once rather\n # than reporting a verdict built on UNKNOWN.\n st=$(jq -r '.data.repository.pullRequest.mergeStateStatus // \"UNKNOWN\"' \u003c\u003c\u003c\"$g\")\n if [ \"$st\" = \"UNKNOWN\" ] &&\n [ \"$(jq -r '.data.repository.pullRequest.state' \u003c\u003c\u003c\"$g\")\" = \"OPEN\" ]; then\n sleep 2\n g=$(collect) || die \"GraphQL query failed\"\n fi\n\n g=$(printf '%s\\n' \"$g\" | complete_contexts) || die \"status contexts are incomplete or changed while reading\"\n\n base=$(jq -r '.data.repository.pullRequest.baseRefName // \"main\"' \u003c\u003c\u003c\"$g\")\n\n local enc r ok out\n enc=$(printf '%s' \"$base\" | jq -sRr @uri)\n # A private repository on a plan without branch rules answers 403 \"Upgrade to\n # GitHub Pro or make this repository public\". That is not an unreadable gate:\n # no ruleset and no protection can exist there, so the required list is empty\n # by construction and the verdict rests on mergeStateStatus and the checks.\n # Every other failure still means \"unknown\" (claude-code-marketplace-P#8).\n local rerr\n rerr=$(mktemp)\n if r=$(gh api \"repos/$REPO/rules/branches/$enc\" 2>\"$rerr\"); then ok=1\n elif grep -q 'Upgrade to GitHub Pro' \"$rerr\" && grep -q 'HTTP 403' \"$rerr\"; then ok=1; r='[]'\n else ok=0; r='[]'; fi\n rm -f \"$rerr\"\n\n # Classic branch protection holds review gates the rules endpoint never\n # shows (require_last_push_approval, approval count, code-owner reviews;\n # go-cron#399 sat review-blocked with every visible gate green because of\n # one). Admin-only endpoint: a 403/404 leaves it null and the ladder says\n # nothing. Fetched lazily — only when the PR is BLOCKED or a review is\n # demanded — so the common green path keeps its two API calls.\n local st2 rd\n st2=$(jq -r '.data.repository.pullRequest.mergeStateStatus // \"\"' \u003c\u003c\u003c\"$g\")\n rd=$(jq -r '.data.repository.pullRequest.reviewDecision // \"\"' \u003c\u003c\u003c\"$g\")\n if [ \"$st2\" = \"BLOCKED\" ] || [ \"$rd\" = \"REVIEW_REQUIRED\" ] || [ \"$rd\" = \"CHANGES_REQUESTED\" ]; then\n read_protection \"$enc\"\n fi\n\n # A fork pull request's workflow runs need a maintainer's approval after\n # every new SHA, and an unapproved run is invisible in the check rollup\n # (#310). Fetched only when the head is cross-repository, so a same-repo PR\n # keeps its existing call count. A failed call leaves the list empty, which\n # understates rather than invents — the same direction the rules fetch takes.\n local runs='[]' head_oid rr\n if [ \"$(jq -r '.data.repository.pullRequest.isCrossRepository // false' \u003c\u003c\u003c\"$g\")\" = \"true\" ]; then\n head_oid=$(jq -r '.data.repository.pullRequest.headRefOid // \"\"' \u003c\u003c\u003c\"$g\")\n if [ -n \"$head_oid\" ] && rr=$(gh api \"repos/$REPO/actions/runs?head_sha=$head_oid&per_page=100\" 2>/dev/null); then\n runs=$(jq -c '[.workflow_runs[]?\n | select(.status == \"action_required\" or .conclusion == \"action_required\")\n | {id: .id, name: .name}]' \u003c\u003c\u003c\"$rr\") || runs='[]'\n fi\n fi\n\n out=$(evaluate \"$g\" \"$r\" \"$ok\" \"$(quota_marker_seen)\" \"$PROT\" \"$runs\")\n # Written from the EVIDENCE field, never from the verdict: with\n # copilot_quota_exhausted the marker would re-assert itself, and the PR named\n # inside it would be whichever one read the file rather than the one that\n # proved the wall — the single thing the content is there to answer.\n # Order decides, so this is asked FIRST: an errored quota row and a delivered\n # review can both sit on the same head, and then copilot_quota_hit alone would\n # re-arm the marker although the wall has since lifted (#255).\n if [ \"$(jq -r '.copilot_latest_on_head_ok // false' \u003c\u003c\u003c\"$out\")\" = \"true\" ]; then\n forget_quota_hit\n elif [ \"$(jq -r '.copilot_quota_hit // false' \u003c\u003c\u003c\"$out\")\" = \"true\" ]; then\n remember_quota_hit\n fi\n # The extra reads happen only here, on the one rung that explains nothing.\n # If building the evidence fails, the verdict still stands, and the why says\n # the evidence is absent rather than pointing at a block that is not there.\n # Under --watch with investigate ignored, the watch never returns on it, so\n # gathering the evidence on every poll would only spend API calls.\n if [ \"$(jq -r '.next.action' \u003c\u003c\u003c\"$out\")\" = \"investigate\" ] \\\n && [ \"$WATCH\" = \"1\" ] && is_ignored investigate; then\n out=$(jq -c '.next.why += \" (evidence not gathered: investigate is ignored under --watch)\"' \u003c\u003c\u003c\"$out\")\n elif [ \"$(jq -r '.next.action' \u003c\u003c\u003c\"$out\")\" = \"investigate\" ]; then\n local ev\n # Protection is read lazily above; investigate can be reached from a\n # merge state that did not trigger that read.\n [ \"$PROT_STATE\" = \"not requested\" ] && read_protection \"$enc\"\n if ev=$(investigate_evidence \"$g\" \"$r\" \"$PROT\" \"$PROT_STATE\" \"$PROT_ERR\" \"$enc\" \"$(jq -r '.headOid' \u003c\u003c\u003c\"$out\")\") \\\n && [ -n \"$ev\" ]; then\n # Both through stdin: the evidence block grows with the required checks\n # and the failed suites, and passes 32767 characters at about 135\n # required contexts.\n out=$(printf '%s\\n' \"$out\" \"$ev\" | jq -cn 'input as $o | input as $ev | $o\n | .next.evidence = $ev\n | .next.why += \" — the evidence below lists what was read and the remaining candidates, none of them a verdict\"')\n else\n out=$(jq -c '.next.why += \" (the evidence block could not be built; cause on stderr above)\"' \u003c\u003c\u003c\"$out\")\n fi\n fi\n printf '%s\\n' \"$out\"\n}\n\nemit() {\n if [ \"$JSON\" = \"1\" ]; then jq . \u003c\u003c\u003c\"$1\"; else render \u003c\u003c\u003c\"$1\"; fi\n}\n\n# ---------------------------------------------------------------- run -------\nif [ \"$WATCH\" = \"0\" ]; then\n # Same reason as the --watch branch below: `die` inside snapshot() runs in\n # this command substitution's subshell, so a failed GraphQL read left $s\n # empty and this path went on to `exit 0`. A caller keying on the exit\n # status — a hook, a Makefile, a monitor — then read a failed gate read as a\n # passed gate, and `--json` handed an empty string to whatever parses it.\n if ! s=$(snapshot) || [ -z \"$s\" ] || ! jq -e '.next.action' \u003c\u003c\u003c\"$s\" >/dev/null 2>&1; then\n die \"could not read the gate for ${REPO}#${PR}; cause on stderr above\"\n fi\n emit \"$s\"\n exit 0\nfi\n\n# Watch: stop at the first thing that can be acted on, not at full settle.\nstart=$(date +%s); seen_fail=\"\"\nwhile :; do\n # A snapshot that could not be read is its own branch, not \"still waiting\".\n # `die` inside $( ) kills only the subshell, so a failed collect left $s\n # empty, printed its reason to stderr -- where a watcher harness never turns\n # it into an event -- and then emitted a bare `waiting:` on stdout every\n # interval. The operator could not tell a quiet gate from a broken query and\n # killed a watch that was reporting nothing (observed during a GraphQL user\n # rate limit, netresearch/typo3-ci-workflows#250).\n # snapshot's stderr goes through a file so the failed read's cause, which\n # collect() already re-emits, can be classified below without a second call.\n # It is passed on to stderr unchanged, success or not.\n snap_err=$(mktemp)\n snap_ok=1\n s=$(snapshot 2>\"$snap_err\") || snap_ok=0\n cat \"$snap_err\" >&2\n if [ \"$snap_ok\" = \"0\" ] || [ -z \"$s\" ] || ! jq -e '.next.action' \u003c\u003c\u003c\"$s\" >/dev/null 2>&1; then\n rate_limited=0\n # The forms GitHub sends for a rate limit, and only those: a message that\n # merely mentions a limit (a missing `rate_limit` scope, say) keeps the\n # interval retry.\n { grep -qE 'RATE_LIMIT' \"$snap_err\" || grep -qiE 'API rate limit|secondary rate limit' \"$snap_err\"; } && rate_limited=1\n rm -f \"$snap_err\"\n elapsed=$(($(date +%s) - start))\n if [ \"$elapsed\" -ge \"$MAXWAIT\" ]; then\n echo \"TIMEOUT after ${MAXWAIT}s — the gate stayed unreadable\"\n exit 1\n fi\n # `pr-status:` prefix deliberately: the documented watcher filter in\n # references/merge-gate-watcher.md keeps ACTIONABLE/TIMEOUT/SETTLED/NEXT and\n # this tool's own `pr-status:` diagnostics, and drops everything else. An\n # unprefixed line would be filtered out and leave the consumer in exactly\n # the silent state this branch exists to end.\n if [ \"$rate_limited\" = \"1\" ]; then\n # Retrying every interval into a rate limit keeps it engaged: three reads\n # 20 s apart all failed with graphql_rate_limit while `rate_limit` showed\n # budget left (2026-09-29). An exhausted budget (remaining 0) lifts at the\n # GraphQL reset, so sleep to it in one go. Budget left means a secondary\n # limit: the counter does not show it, its reset field is merely the end\n # of the hourly window, and it usually lifts within a minute -- a bounded\n # 60 s. So does a lookup that fails. Never shorter than the interval,\n # never past --max-wait, measured after the lookup.\n lookup=(gh api rate_limit --jq '.resources.graphql | \"\\(.remaining) \\(.reset)\"')\n command -v timeout >/dev/null 2>&1 && lookup=(timeout 15 \"${lookup[@]}\")\n remaining=\"\" reset=\"\"\n read -r remaining reset \u003c \u003c(\"${lookup[@]}\" 2>/dev/null || true) || true\n now=$(date +%s)\n elapsed=$((now - start))\n if [ \"$elapsed\" -ge \"$MAXWAIT\" ]; then\n echo \"TIMEOUT after ${MAXWAIT}s — the gate stayed unreadable\"\n exit 1\n fi\n if [[ \"$remaining\" =~ ^[0-9]+$ ]] && [ \"$remaining\" -eq 0 ] \\\n && [[ \"$reset\" =~ ^[0-9]+$ ]] && [ \"$reset\" -gt \"$now\" ]; then\n rl_wait=$((reset - now + 1))\n rl_until=\"the GraphQL reset at $(jq -rn --argjson t \"$reset\" '$t | todate')\"\n elif [[ \"$remaining\" =~ ^[0-9]+$ ]] && [ \"$remaining\" -gt 0 ]; then\n rl_wait=$RATE_LIMIT_WAIT\n rl_until=\"a bounded ${rl_wait}s (GraphQL budget left: a secondary limit)\"\n else\n rl_wait=$RATE_LIMIT_WAIT\n rl_until=\"a bounded ${rl_wait}s (GraphQL budget unreadable or reset already past)\"\n fi\n [ \"$rl_wait\" -lt \"$INTERVAL\" ] && rl_wait=$INTERVAL\n [ \"$rl_wait\" -gt $((MAXWAIT - elapsed)) ] && rl_wait=$((MAXWAIT - elapsed))\n echo \"pr-status: UNREADABLE — GitHub rate limit on ${REPO}#${PR}; cause on stderr above. Waiting ${rl_wait}s for ${rl_until} instead of retrying every ${INTERVAL}s.\"\n sleep \"$rl_wait\"\n continue\n fi\n echo \"pr-status: UNREADABLE — cannot read the gate for ${REPO}#${PR}; cause on stderr above. Retrying in ${INTERVAL}s.\"\n sleep \"$INTERVAL\"\n continue\n fi\n rm -f \"$snap_err\"\n act=$(jq -r '.next.action' \u003c\u003c\u003c\"$s\")\n # Not-started rows are left out: they are answered by NEXT (rerun-ci once\n # their run has concluded, wait before), not by a check-failed return.\n fails=$(jq -r '.checks.failing_other|join(\",\")' \u003c\u003c\u003c\"$s\")\n # A red REQUIRED check is actionable the moment it appears. A red\n # non-required one is not, while a required check is still running: it\n # cannot be what keeps the gate shut yet, and returning on it ends the watch\n # with nothing to do (#249). Hold until the required checks conclude — then\n # the same red check is the reason the gate stays shut, and this fires.\n fails_required=$(jq -r '.checks.failing_required_other|join(\",\")' \u003c\u003c\u003c\"$s\")\n pending_required=$(jq -r '.checks.pending_required|length' \u003c\u003c\u003c\"$s\")\n if [ -z \"$fails_required\" ] && [ \"$pending_required\" -gt 0 ]; then\n fails=\"\"\n fi\n\n # The ignore test sits on this exit too, but ONLY when the standing action\n # the caller declined IS the CI action (fix-ci/triage-ci) — then its failing\n # checks are part of the state the caller already saw, not a fresh event.\n # Any other ignored action must not eat a failure: resolve-conflicts and\n # rebase outrank fix-ci in the NEXT ladder, so a red check can appear while\n # the action still names the ignored non-CI blocker.\n if [ -n \"$fails\" ] && [ \"$fails\" != \"$seen_fail\" ] \\\n && ! { is_ignored \"$act\" && in_list \"fix-ci triage-ci\" \"$act\"; }; then\n seen_fail=\"$fails\"\n echo \"ACTIONABLE: check failed -> $fails\"\n emit \"$s\"; exit 0\n fi\n # A CodeRabbit review in flight is a review the gate must wait for, and it\n # finishes on its own, so neither settled exit below may return over it.\n cr_busy=false\n [ \"$(jq -r '.coderabbit_on_head' \u003c\u003c\u003c\"$s\")\" = \"in-progress\" ] && cr_busy=true\n if is_ignored \"$act\"; then\n # A standing action the caller has already seen and declined (#165): never\n # return on it. Once the checks settle, nothing about it will change\n # either — say so instead of idling into the timeout. The message claims\n # exactly what was checked: NEXT (the highest-priority action) is still\n # the ignored one — lower-ranked work may well remain in the snapshot.\n if [ \"$(jq -r '.checks_settled' \u003c\u003c\u003c\"$s\")\" = \"true\" ] && [ \"$cr_busy\" = false ]; then\n echo \"SETTLED: NEXT is still the ignored action -> $act\"\n emit \"$s\"; exit 0\n fi\n elif [ \"$act\" = \"request-review\" ]; then\n # request-review while CI has not settled is not actionable yet — the\n # ladder itself stamps \"do not enqueue on this reading\" onto the why.\n # Keep waiting: a failing check fires the branch above, and once the\n # checks settle this returns on the review state (#186). The quota\n # dead-end holds here too: waiting cannot clear the quota, but the\n # checks are the thing that still moves, and returning early hands the\n # operator a manual re-arm for every push (observed twice on\n # 2026-08-18). It returns below once CI settles.\n if [ \"$(jq -r '.checks_settled' \u003c\u003c\u003c\"$s\")\" != \"true\" ] || [ \"$cr_busy\" = true ]; then\n :\n # A request-review whose cause is an exhausted review bot is a standing\n # condition, not an event: waiting cannot clear a quota ceiling, so every\n # re-arm of --watch returns instantly with the same line. Saying so —\n # and naming the flag that holds through it — is what stops an operator\n # re-arming it three times before giving up on the tool.\n elif [ \"$(jq -r '.copilot_quota_exhausted // false' \u003c\u003c\u003c\"$s\")\" = \"true\" ]; then\n echo \"ACTIONABLE: request-review (UNSATISFIABLE — Copilot is OUT OF REVIEW QUOTA\" \\\n \"for the month; this will NOT change until the monthly reset, on this or any\" \\\n \"other PR. Checks are settled — do not re-request and do not re-arm, a\" \\\n \"re-arm returns immediately with this same line. Review the diff yourself\" \\\n \"and decide; to hold through this standing action for other reasons:\" \\\n \"pr-status.sh -R $(jq -r '.repo' \u003c\u003c\u003c\"$s\") $(jq -r '.number' \u003c\u003c\u003c\"$s\") --watch --ignore-action request-review)\"\n elif [ \"$(jq -r '.copilot_error_count // 0' \u003c\u003c\u003c\"$s\")\" -ge 2 ]; then\n echo \"ACTIONABLE: request-review (UNSATISFIABLE — the review bot has failed\" \\\n \"$(jq -r '.copilot_error_count' \u003c\u003c\u003c\"$s\")x on this head; re-arming this watch\" \\\n \"returns immediately. Review the diff yourself, or wait for the checks:\" \\\n \"pr-status.sh -R $(jq -r '.repo' \u003c\u003c\u003c\"$s\") $(jq -r '.number' \u003c\u003c\u003c\"$s\") --watch --ignore-action request-review)\"\n else\n echo \"ACTIONABLE: request-review\"\n fi\n # Unsettled-CI hold: emit nothing, fall through to the wait line.\n # The quota dead-end returns only here, WITH the checks settled — a\n # re-arm on a settled PR still comes straight back with the same\n # UNSATISFIABLE line, which is what its message says.\n if [ \"$(jq -r '.checks_settled' \u003c\u003c\u003c\"$s\")\" = \"true\" ] && [ \"$cr_busy\" = false ]; then\n emit \"$s\"; exit 0\n fi\n elif in_list \"$ACTIONABLE\" \"$act\"; then\n echo \"ACTIONABLE: $act\"\n emit \"$s\"; exit 0\n fi\n\n now=$(date +%s)\n if [ $((now - start)) -ge \"$MAXWAIT\" ]; then\n echo \"TIMEOUT after ${MAXWAIT}s — still: $(jq -r '.next.why' \u003c\u003c\u003c\"$s\")\"\n emit \"$s\"; exit 1\n fi\n echo \"waiting: $(jq -r '.next.why' \u003c\u003c\u003c\"$s\")\"\n sleep \"$INTERVAL\"\ndone\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":54987,"content_sha256":"7635ddb0b2bd3415f46acd44880b63a0b00210fe30a88e41afcc50d7372bb288"},{"filename":"scripts/pr-sweep.sh","content":"#!/usr/bin/env bash\n# pr-sweep.sh — drive a list of pull requests to merge, and stop on anything\n# it was not told how to handle.\n#\n# Why this exists: pr-status.sh --watch follows ONE pull request. A sweep over\n# twenty or more PRs in many repositories kept being hand-written, and the\n# hand-written drivers dispatched on a few actions and sent everything else to\n# a default \"keep waiting\" branch. Two of them sat silent for four hours with\n# `NEXT: request-review` on their PRs, because that action fell into the\n# default branch. Here the default branch STOPS: only the actions listed below\n# are handled, and every other value — including one pr-status.sh adds in a\n# later version — ends the sweep with a line in the attention file.\n#\n# Usage:\n# pr-sweep.sh [--interval SEC] [--timeout SEC] [--attention FILE] LISTFILE\n#\n# LISTFILE one `owner/repo number` per line; `#` starts a comment,\n# blank lines are ignored\n# --interval seconds between passes (default 480)\n# --timeout overall limit in seconds (default 14400, i.e. 4 h)\n# --attention file the attention lines are written to; it is truncated at\n# start (default LISTFILE.attention)\n#\n# Each pass runs `pr-status.sh -R owner/repo N --json` once per PR that is not\n# finished yet and dispatches on `.state` and `.next.action`, never on prose:\n#\n# .state MERGED done\n# merge pr-merge.sh -R owner/repo N; rc 0 enqueued\n# or merged, rc 1 gate shut (next pass\n# reads again), anything else: attention\n# wait, await-review,\n# await-checks, await-capacity keep waiting\n# rules-unavailable, or a failed\n# pr-status.sh read keep waiting once; on the second\n# consecutive pass: attention\n# anything else attention — request-review, a closed PR,\n# an action this script does not know\n#\n# A second `merge` for a PR whose earlier pr-merge.sh returned 0 is also an\n# attention stop: the entry most likely left the merge queue, and re-queuing\n# cancels a queue run that may still be in flight\n# (references/merge-gate-watcher.md, \"Re-queuing cancels the run already in\n# flight\"). pr-merge.sh --self-reviewed is never passed: the attestation is a\n# statement about a diff somebody read, so request-review stops the sweep.\n#\n# An attention stop does not abort the pass: the remaining PRs are still read\n# and merged, then the sweep exits. One log line per state change per PR, with\n# a UTC timestamp; a poll that changes nothing prints nothing.\n#\n# Exit codes: 0 every PR merged (last line `ALL MERGED`), 2 usage or input\n# error, 3 at least one PR needs attention, 4 overall timeout (the open PRs and\n# their last action are printed).\n\n# SPDX-License-Identifier: MIT\n# SPDX-FileCopyrightText: Netresearch DTT GmbH\n\nset -euo pipefail\n\n# --version answers \"which copy am I running\" without diffing installations.\n# Two installations can declare the SAME number while shipping different\n# scripts (netresearch/git-workflow-skill#209 measured exactly that, and the\n# missing flag read as a missing feature for a dozen merges). So the resolved\n# path is printed beside the version: the number says what the copy claims to\n# be, the path says which file actually answered.\n#\n# The version is read from the SKILL.md NEXT TO the script, never from a\n# checkout elsewhere -- a cached copy must report the number it was packaged\n# with, or the answer is worse than none. \\042 and \\047 are the quote\n# characters by octal code, so this awk program contains no quote of its own\n# to terminate the single-quoted string it lives in.\nskill_version() {\n local here skill v\n here=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n skill=\"$here/../SKILL.md\"\n v=\"unknown\"\n if [ -f \"$skill\" ]; then\n v=\"$(awk '/^[ \\t]*version:/ {\n s = $0\n sub(/^[ \\t]*version:[ \\t]*/, \"\", s)\n gsub(/[\\042\\047]/, \"\", s)\n gsub(/[ \\t\\r]+$/, \"\", s)\n if (s != \"\") { print s; exit }\n }' \"$skill\" 2>/dev/null)\" || v=\"\"\n [ -n \"$v\" ] || v=\"unknown\"\n fi\n printf '%s %s\\n' \"$(basename \"${BASH_SOURCE[0]}\")\" \"$v\"\n printf 'path: %s/%s\\n' \"$here\" \"$(basename \"${BASH_SOURCE[0]}\")\"\n}\n\nINTERVAL=480; TIMEOUT=14400; ATTN=\"\"; LIST=\"\"\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n\ndie() { printf 'pr-sweep: %s\\n' \"$1\" >&2; exit 2; }\nneed() { [ $# -ge 2 ] && [ -n \"${2:-}\" ] || die \"$1 requires a value\"; }\nis_uint() { case \"$1\" in ''|*[!0-9]*) return 1 ;; *) return 0 ;; esac; }\n\nwhile [ $# -gt 0 ]; do\n case \"$1\" in\n --interval) need \"$@\"; INTERVAL=\"$2\"; shift 2 ;;\n --timeout) need \"$@\"; TIMEOUT=\"$2\"; shift 2 ;;\n --attention) need \"$@\"; ATTN=\"$2\"; shift 2 ;;\n --version) skill_version; exit 0 ;;\n -h|--help) awk 'NR>1 && /^#/ {sub(/^# ?/,\"\"); print; next} NR>1 {exit}' \"$0\"; exit 0 ;;\n -*) die \"unknown flag: $1\" ;;\n *) [ -z \"$LIST\" ] || die \"one list file only (got '$LIST' and '$1')\"\n LIST=\"$1\"; shift ;;\n esac\ndone\n\n[ -n \"$LIST\" ] || die \"usage: pr-sweep.sh [--interval SEC] [--timeout SEC] [--attention FILE] LISTFILE\"\n[ -f \"$LIST\" ] || die \"list file not found: $LIST\"\nis_uint \"$INTERVAL\" || die \"--interval must be a whole number of seconds (got '$INTERVAL')\"\nis_uint \"$TIMEOUT\" || die \"--timeout must be a whole number of seconds (got '$TIMEOUT')\"\n[ -n \"$ATTN\" ] || ATTN=\"$LIST.attention\"\n\ncommand -v jq >/dev/null || die \"jq not found\"\nfor s in pr-status.sh pr-merge.sh; do\n [ -x \"$SCRIPT_DIR/$s\" ] || die \"$s not found next to this script\"\ndone\n\n# ------------------------------------------------------------ the list -----\nPRS=()\ndeclare -A SEEN=()\nn=0\nwhile IFS= read -r line || [ -n \"$line\" ]; do\n n=$((n + 1))\n line=\"${line%%#*}\"\n r=\"\"; num=\"\"; extra=\"\"\n read -r r num extra \u003c\u003c\u003c\"$line\" || true\n [ -z \"$r\" ] && continue\n [ -n \"$num\" ] && [ -z \"$extra\" ] || die \"$LIST:$n: expected 'owner/repo number', got '$line'\"\n case \"$r\" in\n */*/*|/*|*/) die \"$LIST:$n: '$r' is not owner/repo\" ;;\n */*) ;;\n *) die \"$LIST:$n: '$r' is not owner/repo\" ;;\n esac\n is_uint \"$num\" || die \"$LIST:$n: '$num' is not a PR number\"\n key=\"$r#$num\"\n [ -n \"${SEEN[$key]:-}\" ] && continue\n SEEN[$key]=1\n PRS+=(\"$key\")\ndone \u003c \"$LIST\"\n\n# An empty list must not end in `ALL MERGED`: zero PRs merged is not every PR\n# merged, and a sweep that was handed the wrong file would report success.\n[ \"${#PRS[@]}\" -gt 0 ] || die \"$LIST lists no pull requests\"\n\n: > \"$ATTN\" || die \"cannot write the attention file $ATTN\"\n\n# ----------------------------------------------------------- the sweep -----\ndeclare -A FINISHED=() LAST=() FAILS=() ENQUEUED=()\nERR=\"$(mktemp)\"\ntrap 'rm -f \"$ERR\"' EXIT\n\nts() { date -u +%Y-%m-%dT%H:%M:%SZ; }\n\n# One line per change of state, keyed per PR: the same state on the next poll\n# prints nothing, so the log stays readable over hours and every line in it is\n# news.\nnote() { # note \u003cpr> \u003cstate> \u003ctext>\n if [ \"${LAST[$1]:-}\" != \"$2\" ]; then\n printf '%s %s %s\\n' \"$(ts)\" \"$1\" \"$3\"\n LAST[$1]=\"$2\"\n fi\n}\n\nATTENTION=0\nattention() { # attention \u003cpr> \u003ctext>\n printf '%s %s\\n' \"${1/\\#/ }\" \"$2\" >> \"$ATTN\"\n printf '%s %s ATTENTION: %s\\n' \"$(ts)\" \"$1\" \"$2\"\n LAST[$1]=\"attention\"\n FINISHED[$1]=\"attention\"\n ATTENTION=1\n}\n\nprobe_failed() { # probe_failed \u003cpr> \u003creason>\n FAILS[$1]=$(( ${FAILS[$1]:-0} + 1 ))\n if [ \"${FAILS[$1]}\" -ge 2 ]; then\n attention \"$1\" \"pr-status.sh unreadable on two consecutive passes: $2\"\n else\n note \"$1\" \"probe-failed\" \"probe failed once, reading again next pass: $2\"\n fi\n}\n\n# pr-merge.sh prints several lines: the result first, then a hint or a\n# command, and some results span two lines. The last line alone is that hint,\n# so the whole output goes into the log and the attention file, as one line.\none_line() { tr -s '\\n\\t ' ' ' \u003c\u003c\u003c\"$1\" | sed 's/^ //; s/ $//'; }\n\nsweep_one() { # sweep_one \u003cowner/repo#N>\n local pr=\"$1\" repo num out rc state action why mout\n repo=\"${pr%#*}\"; num=\"${pr##*#}\"\n\n rc=0\n out=$(\"$SCRIPT_DIR/pr-status.sh\" -R \"$repo\" \"$num\" --json 2>\"$ERR\") || rc=$?\n if [ \"$rc\" -ne 0 ]; then\n probe_failed \"$pr\" \"exit $rc: $(one_line \"$(cat \"$ERR\")\")\"\n return\n fi\n if ! state=$(jq -er '.state' \u003c\u003c\u003c\"$out\" 2>/dev/null) \\\n || ! action=$(jq -er '.next.action' \u003c\u003c\u003c\"$out\" 2>/dev/null); then\n probe_failed \"$pr\" \"no .state/.next.action in the --json output\"\n return\n fi\n why=$(jq -r '.next.why // \"\"' \u003c\u003c\u003c\"$out\" 2>/dev/null) || why=\"\"\n\n if [ \"$state\" = \"MERGED\" ]; then\n FAILS[$pr]=0\n FINISHED[$pr]=\"merged\"\n note \"$pr\" \"merged\" \"MERGED\"\n return\n fi\n\n case \"$action\" in\n rules-unavailable)\n probe_failed \"$pr\" \"NEXT: $action — $why\" ;;\n wait|await-review|await-checks|await-capacity)\n FAILS[$pr]=0\n note \"$pr\" \"$action\" \"waiting: NEXT: $action — $why\" ;;\n merge)\n FAILS[$pr]=0\n if [ -n \"${ENQUEUED[$pr]:-}\" ]; then\n attention \"$pr\" \"NEXT: merge again after pr-merge.sh returned 0 — the entry probably left the merge queue; read the timeline before re-queuing ($why)\"\n return\n fi\n rc=0\n mout=$(\"$SCRIPT_DIR/pr-merge.sh\" -R \"$repo\" \"$num\" 2>&1) || rc=$?\n case \"$rc\" in\n 0) ENQUEUED[$pr]=1\n LAST[$pr]=\"\"\n note \"$pr\" \"merge-rc0\" \"pr-merge.sh rc=0: $(one_line \"$mout\")\" ;;\n 1) LAST[$pr]=\"\"\n note \"$pr\" \"merge-rc1\" \"pr-merge.sh rc=1 (gate shut, reading again next pass): $(one_line \"$mout\")\" ;;\n *) attention \"$pr\" \"pr-merge.sh rc=$rc: $(one_line \"$mout\")\" ;;\n esac ;;\n *)\n FAILS[$pr]=0\n attention \"$pr\" \"NEXT: $action — $why\" ;;\n esac\n}\n\nstart=$(date +%s)\ndeadline=$((start + TIMEOUT))\nprintf '%s sweep of %d PR(s), interval %ss, timeout %ss, attention file %s\\n' \\\n \"$(ts)\" \"${#PRS[@]}\" \"$INTERVAL\" \"$TIMEOUT\" \"$ATTN\"\n\nwhile :; do\n for pr in \"${PRS[@]}\"; do\n [ -n \"${FINISHED[$pr]:-}\" ] && continue\n sweep_one \"$pr\"\n done\n\n if [ \"$ATTENTION\" = \"1\" ]; then\n printf '%s stopped: %s PR(s) need attention, listed in %s\\n' \\\n \"$(ts)\" \"$(wc -l \u003c \"$ATTN\" | tr -d ' ')\" \"$ATTN\"\n exit 3\n fi\n\n open=0\n for pr in \"${PRS[@]}\"; do\n [ \"${FINISHED[$pr]:-}\" = \"merged\" ] || open=$((open + 1))\n done\n if [ \"$open\" -eq 0 ]; then\n echo \"ALL MERGED\"\n exit 0\n fi\n\n now=$(date +%s)\n if [ \"$now\" -ge \"$deadline\" ]; then\n printf '%s TIMEOUT after %ss with %d PR(s) open:\\n' \"$(ts)\" \"$((now - start))\" \"$open\"\n for pr in \"${PRS[@]}\"; do\n [ \"${FINISHED[$pr]:-}\" = \"merged\" ] || printf ' %s last: %s\\n' \"$pr\" \"${LAST[$pr]:-not read}\"\n done\n exit 4\n fi\n\n left=$((deadline - now))\n sleep \"$(( INTERVAL \u003c left ? INTERVAL : left ))\"\ndone\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":10785,"content_sha256":"c17e903c4c41ffcb7c2611232e38feaf09b0c6994dbd9623f1667129033ace79"},{"filename":"scripts/repo-contribution-preflight.sh","content":"#!/usr/bin/env bash\n# repo-contribution-preflight.sh — what this repository expects, before the first artifact.\n#\n# A repository's binding rules are scattered: some in the README, some in\n# contribution docs two links deep, some in issue and PR templates, in\n# .gitattributes export rules, in the CI matrix, and in pinned tool versions.\n# Each is one command; together they are fifteen, which is why they get skipped\n# and then discovered afterwards — with three artifacts already public and a\n# maintainer watching the retrofit.\n#\n# The README is checked, not skipped. A small repository often states the whole\n# contract there — target branch, sign-off, the command CI runs — and never\n# writes a CONTRIBUTING at all. This script therefore reports the README\n# headings that carry rules, fence-aware, so a \"# Contributing\" line inside a\n# fenced example is not mistaken for a section.\n#\n# This returns all of it in one call. It reads; it changes nothing.\n#\n# Usage: repo-contribution-preflight.sh [--repo \u003cdir>] [--section \u003cname>]\n#\n# --repo \u003cdir> repository to inspect (default: current directory)\n# --section \u003cname> one of: docs, templates, packaging, ci, tools\n#\n# Exit status is 0 whenever the repository could be read. Absence of a file is a\n# finding, not an error — \"no CONTRIBUTING\" is the answer to the question asked.\n\n# SPDX-License-Identifier: MIT\n# SPDX-FileCopyrightText: Netresearch DTT GmbH\n\nset -uo pipefail\n\n# --version answers \"which copy am I running\" without diffing installations.\n# Two installations can declare the SAME number while shipping different\n# scripts (netresearch/git-workflow-skill#209 measured exactly that, and the\n# missing flag read as a missing feature for a dozen merges). So the resolved\n# path is printed beside the version: the number says what the copy claims to\n# be, the path says which file actually answered.\n#\n# The version is read from the SKILL.md NEXT TO the script, never from a\n# checkout elsewhere -- a cached copy must report the number it was packaged\n# with, or the answer is worse than none. \\042 and \\047 are the quote\n# characters by octal code, so this awk program contains no quote of its own\n# to terminate the single-quoted string it lives in.\nskill_version() {\n local here skill v\n here=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n skill=\"$here/../SKILL.md\"\n v=\"unknown\"\n if [ -f \"$skill\" ]; then\n v=\"$(awk '/^[ \\t]*version:/ {\n s = $0\n sub(/^[ \\t]*version:[ \\t]*/, \"\", s)\n gsub(/[\\042\\047]/, \"\", s)\n gsub(/[ \\t\\r]+$/, \"\", s)\n if (s != \"\") { print s; exit }\n }' \"$skill\" 2>/dev/null)\" || v=\"\"\n [ -n \"$v\" ] || v=\"unknown\"\n fi\n printf '%s %s\\n' \"$(basename \"${BASH_SOURCE[0]}\")\" \"$v\"\n printf 'path: %s/%s\\n' \"$here\" \"$(basename \"${BASH_SOURCE[0]}\")\"\n}\n\nREPO=\".\"\nSECTION=\"all\"\n\nwhile [ $# -gt 0 ]; do\n case \"$1\" in\n --repo) REPO=\"${2:?--repo needs a directory}\"; shift 2 ;;\n --section) SECTION=\"${2:?--section needs a name}\"; shift 2 ;;\n --version) skill_version; exit 0 ;;\n -h|--help) awk 'NR>1 && /^#/ {sub(/^# ?/,\"\"); print; next} NR>1 {exit}' \"$0\"; exit 0 ;;\n *) echo \"unknown argument: $1\" >&2; exit 2 ;;\n esac\ndone\n\ncd \"$REPO\" || { echo \"cannot enter $REPO\" >&2; exit 2; }\ngit rev-parse --git-dir >/dev/null 2>&1 || { echo \"not a git repository: $REPO\" >&2; exit 2; }\n\nwant() { [ \"$SECTION\" = \"all\" ] || [ \"$SECTION\" = \"$1\" ]; }\nhead_() { printf '\\n=== %s ===\\n' \"$1\"; }\nnone() { printf ' (none)\\n'; }\n\n# --- Contribution docs, and the pages they link -----------------------------\nif want docs; then\n head_ \"Contribution docs\"\n found=0\n # -iname rather than a fixed list: GitHub resolves these case-insensitively,\n # so a repository carrying contributing.md is not one carrying nothing. The\n # community-health set is here in full (SUPPORT, GOVERNANCE, SECURITY,\n # CODE_OF_CONDUCT) because each can carry a rule that decides whether a\n # contribution is accepted, and each is served from .github/ or docs/ just\n # as often as from the root.\n while IFS= read -r f; do\n [ -f \"$f\" ] || continue\n # The name globs are deliberately broad, so the extension decides. Without\n # this, -iname SECURITY* claims .github/workflows/security.yml as a\n # community-health document -- found by running the section against this\n # repository, which has exactly that workflow. An extensionless\n # CONTRIBUTING or SECURITY is a real document and stays.\n case \"${f##*/}\" in\n *.md|*.markdown|*.rst|*.txt|*.adoc) ;;\n *.*) continue ;;\n *) ;;\n esac\n found=1\n printf ' %-34s %s lines\\n' \"${f#./}\" \"$(grep -c \"\" \"$f\")\"\n # A short CONTRIBUTING is usually a signpost: surface what it points at.\n # shellcheck disable=SC2016 # backticks and $ are regex syntax here, not shell\n links=$(grep -oE '\\[[^]]+\\]\\([^)]+\\)|\u003chttps?://[^>]+>|`[^`]+\\.(md|rst)`' \"$f\" 2>/dev/null)\n [ -n \"$links\" ] || continue\n printf '%s\\n' \"$links\" | head -8 | sed 's/^/ -> /'\n n=$(printf '%s\\n' \"$links\" | grep -c \"\")\n # Never truncate silently: a capped list reads as a complete one.\n [ \"$n\" -gt 8 ] && printf ' -> (%s more link(s) not shown — read the file)\\n' \"$((n - 8))\"\n done \u003c \u003c(find . -maxdepth 3 \\\n \\( -path ./.git -o -path ./node_modules -o -path ./vendor \\) -prune -o \\\n -type f \\( -iname 'CONTRIBUTING*' -o -iname 'CODE_OF_CONDUCT*' \\\n -o -iname 'SUPPORT*' -o -iname 'GOVERNANCE*' \\\n -o -iname 'SECURITY*' -o -iname 'AGENTS.md' \\\n -o -iname 'CLAUDE.md' \\) -print 2>/dev/null | sort)\n if [ \"$found\" = 0 ]; then\n # Deliberately not \"(none)\". GitHub serves CONTRIBUTING, CODE_OF_CONDUCT,\n # SUPPORT, SECURITY and both template kinds from the OWNER's .github\n # repository whenever the repository itself has none, and those defaults\n # bind exactly like local ones. This script does not go to the network,\n # so it names the query rather than answering it: an unchecked fallback\n # must never be reported as an absence.\n printf ' none IN THIS REPOSITORY — the owner default may still supply them\\n'\n owner=$(git remote get-url origin 2>/dev/null \\\n | sed -E 's#^[^:]*://[^/]+/##; s#^[^:]*:##; s#/.*$##')\n if [ -n \"$owner\" ]; then\n printf ' -> gh api repos/%s/.github/contents --jq \".[].name\"\\n' \"$owner\"\n else\n printf ' -> gh api repos/\u003cowner>/.github/contents --jq \".[].name\"\\n'\n fi\n fi\nfi\n\n# --- The README, which is where a small repository states the whole contract -\nif want docs; then\n head_ \"README sections that carry rules\"\n found=0\n while IFS= read -r f; do\n [ -f \"$f\" ] || continue\n # Fence-aware on purpose: a heading regex is blind to code blocks, and a\n # README demonstrating `## Contributing` inside a fenced example would\n # otherwise be reported as having that section. Handles ATX (#) and the\n # underline form used by RST and setext Markdown.\n hits=$(awk '\n function rulebearing(h) {\n return tolower(h) ~ /contribut|pull request|merge request|code of conduct|commit|sign|coding|style|standard|develop|hacking|test|build|releas|governance|support|securit|licen[cs]e|getting started|setup|workflow|branch|patch/\n }\n /^[ \\t]*(```|~~~)/ { fence = !fence; prev = \"\"; next }\n fence { prev = $0; next }\n /^[ \\t]*#{1,6}[ \\t]+/ {\n h = $0\n sub(/^[ \\t]*#+[ \\t]+/, \"\", h)\n sub(/[ \\t]*#*[ \\t]*$/, \"\", h)\n if (rulebearing(h)) printf \" %5d %s\\n\", NR, h\n prev = $0; next\n }\n /^[ \\t]*[=~^*+#\"-]{3,}[ \\t]*$/ {\n if (prev ~ /[^ \\t]/) {\n h = prev\n gsub(/^[ \\t]+|[ \\t]+$/, \"\", h)\n if (rulebearing(h)) printf \" %5d %s\\n\", NR - 1, h\n }\n prev = $0; next\n }\n { prev = $0 }\n ' \"$f\")\n [ -n \"$hits\" ] || continue\n found=1\n printf ' %s\\n' \"${f#./}\"\n printf '%s\\n' \"$hits\"\n done \u003c \u003c(find . -maxdepth 2 \\\n \\( -path ./.git -o -path ./node_modules -o -path ./vendor \\) -prune -o \\\n -type f -iname 'README*' -print 2>/dev/null | sort)\n if [ \"$found\" = 0 ]; then\n printf ' no rule-bearing headings in the README\\n'\n printf ' -> absence of a heading is not absence of a rule: a short README\\n'\n printf ' can state the target branch or the sign-off in running prose\\n'\n fi\nfi\n\n# --- Issue and PR templates -------------------------------------------------\nif want templates; then\n head_ \"Issue / PR templates\"\n found=0\n for f in .github/ISSUE_TEMPLATE/config.yml .github/ISSUE_TEMPLATE/config.yaml; do\n [ -f \"$f\" ] || continue\n found=1\n printf ' %s\\n' \"$f\"\n # blank_issues_enabled: false means a template is mandatory.\n grep -E 'blank_issues_enabled' \"$f\" | sed 's/^/ /'\n done\n # PULL_REQUEST_TEMPLATE has a DIRECTORY form for multiple templates, and\n # both template kinds are also honoured at the root and under docs/ — a\n # .github-only list reports \"no template\" for repositories that have one.\n for f in .github/ISSUE_TEMPLATE/*.yml .github/ISSUE_TEMPLATE/*.yaml \\\n .github/ISSUE_TEMPLATE/*.md .github/PULL_REQUEST_TEMPLATE.md \\\n .github/pull_request_template.md .github/PULL_REQUEST_TEMPLATE/* \\\n .github/ISSUE_TEMPLATE.md .github/issue_template.md \\\n ISSUE_TEMPLATE.md PULL_REQUEST_TEMPLATE.md pull_request_template.md \\\n docs/ISSUE_TEMPLATE.md docs/PULL_REQUEST_TEMPLATE.md \\\n docs/pull_request_template.md \\\n .gitlab/issue_templates/* \\\n .gitlab/merge_request_templates/*; do\n [ -f \"$f\" ] || continue\n case \"$f\" in */config.y*ml) continue ;; esac\n found=1\n printf ' %s\\n' \"$f\"\n done\n [ \"$found\" = 1 ] || none\nfi\n\n# --- What a published package actually ships --------------------------------\nif want packaging; then\n head_ \"Packaging (what ships, what is a separate package)\"\n\n # export-ignore decides whether tests/docs exist in the published artifact.\n found=0\n while IFS= read -r f; do\n [ -f \"$f\" ] || continue\n ignored=$(grep -E 'export-ignore' \"$f\" | awk '{print $1}' | tr '\\n' ' ')\n [ -n \"$ignored\" ] || continue\n found=1\n printf ' %-44s export-ignore: %s\\n' \"$f\" \"$ignored\"\n done \u003c \u003c(printf '%s\\n' .gitattributes packages/*/.gitattributes */.gitattributes 2>/dev/null)\n [ \"$found\" = 1 ] || printf ' no export-ignore rules\\n'\n\n # Sub-splitting makes per-package metadata load-bearing rather than cosmetic.\n splits=\"\"\n for f in .github/workflows/*split*; do\n [ -f \"$f\" ] && splits=\"$splits $f\"\n done\n if [ -n \"$splits\" ]; then\n printf ' sub-split workflow present:%s\\n' \"$splits\"\n printf ' -> each packages/* is published on its own; its composer.json is the contract\\n'\n fi\n\n # An empty production autoload at the root makes root-level checks blind.\n if [ -f composer.json ] && command -v jq >/dev/null 2>&1; then\n root_autoload=$(jq -r 'if (.autoload | type) == \"object\" then \"present\" else \"ABSENT\" end' composer.json)\n printf ' root composer.json production autoload: %s\\n' \"$root_autoload\"\n [ \"$root_autoload\" = \"ABSENT\" ] && \\\n printf ' -> checks run at the root scan nothing; run them per package\\n'\n fi\nfi\n\n# --- The matrix that actually runs ------------------------------------------\nif want ci; then\n head_ \"CI\"\n found=0\n for f in .github/workflows/*.y*ml .gitlab-ci.yml Jenkinsfile .concourse/*.y*ml; do\n [ -f \"$f\" ] || continue\n found=1\n printf ' %s\\n' \"$f\"\n if command -v yq >/dev/null 2>&1; then\n case \"$f\" in\n *.yml|*.yaml)\n # `//` not an if-expression: the go-yq lexer rejects inline `if` here.\n yq -r '(.jobs // {}) | to_entries[] | \" \" + .key + \" -> \" + (.value.uses // \"inline\")' \\\n \"$f\" 2>/dev/null | head -12\n ;;\n esac\n fi\n done\n [ \"$found\" = 1 ] || none\n printf '\\n The matrix a reusable workflow expands to is only visible on a real run:\\n'\n printf ' gh api \"repos/\u003cowner>/\u003crepo>/commits/\u003csha>/check-runs?per_page=100\" --jq \".check_runs[].name\" | sort -u\\n'\nfi\n\n# --- Pinned tool versions ---------------------------------------------------\nif want tools; then\n head_ \"Pinned tools\"\n found=0\n if [ -f .phive/phars.xml ]; then\n found=1\n printf ' .phive/phars.xml\\n'\n grep -oE 'name=\"[^\"]+\" version=\"[^\"]+\"( installed=\"[^\"]+\")?' .phive/phars.xml \\\n | sed 's/^/ /'\n printf ' -> a pinned analyser can be older than the code it parses; an abort\\n'\n printf ' can print nothing and read exactly like a clean run\\n'\n fi\n for f in .tool-versions .php-version .nvmrc rust-toolchain.toml; do\n [ -f \"$f\" ] || continue\n found=1\n printf ' %-24s %s\\n' \"$f\" \"$(tr '\\n' ' ' \u003c \"$f\")\"\n done\n [ \"$found\" = 1 ] || none\nfi\n\nprintf '\\n'\nexit 0\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":13584,"content_sha256":"79e70c2a1947085e8766988308f6d4ff9175ac7f425e5536662b6a145a433fd3"},{"filename":"scripts/signing-preflight.sh","content":"#!/usr/bin/env bash\n# signing-preflight.sh — does `git commit -S` actually produce a signature here?\n#\n# Answers the one question a commit-heavy run needs answered up front, and\n# answers it from the commit object rather than from local verification:\n#\n# git log --show-signature / %G? → \"can THIS MACHINE verify the signature\"\n# gpgsig header in the object → \"did git WRITE a signature\"\n#\n# Those differ. With gpg.format=ssh and no gpg.ssh.allowedSignersFile, git\n# 2.54.0 reports `%G? = N` and `No signature` for a perfectly signed commit —\n# indistinguishable from unsigned, and it exits 0 while printing the error, so\n# a driver reading $? sees success. Whether the *host* accepts the key is a\n# third question again, answered only by the GitHub commits API.\n#\n# The probe never touches the working tree, the index or the current branch:\n# it commits on a throwaway branch through a temporary index and removes both.\n#\n# Usage: signing-preflight.sh [--config-only] [--check-commit \u003crev>]\n# [--repo \u003cdir>] [--quiet]\n#\n# (default) probe: prove that `git commit -S` signs, here, now\n# --config-only report the signing config without creating a commit\n# --check-commit report whether an existing commit carries a signature\n#\n# Exit codes:\n# 0 READY / signed\n# 1 NOT READY / unsigned — no signature, or the commit aborted with hooks\n# already ruled out by the --no-verify retry\n# 2 INCONCLUSIVE — --config-only cannot answer from the config alone\n# 3 USAGE — not a git repository / bad arguments\n\n# SPDX-License-Identifier: MIT\n# SPDX-FileCopyrightText: Netresearch DTT GmbH\n\nset -uo pipefail\n\n# --version answers \"which copy am I running\" without diffing installations.\n# Two installations can declare the SAME number while shipping different\n# scripts (netresearch/git-workflow-skill#209 measured exactly that, and the\n# missing flag read as a missing feature for a dozen merges). So the resolved\n# path is printed beside the version: the number says what the copy claims to\n# be, the path says which file actually answered.\n#\n# The version is read from the SKILL.md NEXT TO the script, never from a\n# checkout elsewhere -- a cached copy must report the number it was packaged\n# with, or the answer is worse than none. \\042 and \\047 are the quote\n# characters by octal code, so this awk program contains no quote of its own\n# to terminate the single-quoted string it lives in.\nskill_version() {\n local here skill v\n here=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n skill=\"$here/../SKILL.md\"\n v=\"unknown\"\n if [ -f \"$skill\" ]; then\n v=\"$(awk '/^[ \\t]*version:/ {\n s = $0\n sub(/^[ \\t]*version:[ \\t]*/, \"\", s)\n gsub(/[\\042\\047]/, \"\", s)\n gsub(/[ \\t\\r]+$/, \"\", s)\n if (s != \"\") { print s; exit }\n }' \"$skill\" 2>/dev/null)\" || v=\"\"\n [ -n \"$v\" ] || v=\"unknown\"\n fi\n printf '%s %s\\n' \"$(basename \"${BASH_SOURCE[0]}\")\" \"$v\"\n printf 'path: %s/%s\\n' \"$here\" \"$(basename \"${BASH_SOURCE[0]}\")\"\n}\n\nCONFIG_ONLY=0\nQUIET=0\nREPO_DIR=\".\"\nCHECK_REV=\"\"\n\nwhile [ $# -gt 0 ]; do\n case \"$1\" in\n --config-only) CONFIG_ONLY=1 ;;\n --check-commit) shift; CHECK_REV=\"${1:-}\" ;;\n --quiet) QUIET=1 ;;\n --repo) shift; REPO_DIR=\"${1:-}\" ;;\n --version) skill_version; exit 0 ;;\n -h|--help) awk 'NR>1 && /^#/ {sub(/^# ?/,\"\"); print; next} NR>1 {exit}' \"$0\"; exit 0 ;;\n *) echo \"unknown argument: $1\" >&2; exit 3 ;;\n esac\n shift\ndone\n\nsay() { [ \"$QUIET\" -eq 1 ] || printf '%s\\n' \"$*\"; }\n\ncd \"$REPO_DIR\" 2>/dev/null || { echo \"no such directory: $REPO_DIR\" >&2; exit 3; }\ngit rev-parse --git-dir >/dev/null 2>&1 || { echo \"not a git repository: $REPO_DIR\" >&2; exit 3; }\n\n# --- the check itself -------------------------------------------------------\n# Both backends write the signature as a commit *header*: `gpgsig` (SHA-1\n# repositories) or `gpgsig-sha256` (SHA-256), carrying `-----BEGIN SSH\n# SIGNATURE-----` or `-----BEGIN PGP SIGNATURE-----`. No local config gates it.\n#\n# Cut the object at the first blank line before matching: over the whole\n# object, `^gpgsig` also matches a message *body* line that starts with\n# `gpgsig`, which reports an unsigned commit as signed.\ncommit_carries_signature() {\n git cat-file commit \"$1\" 2>/dev/null | sed -n '/^$/q;p' | grep -qE '^gpgsig(-sha256)? '\n}\n\nif [ -n \"$CHECK_REV\" ]; then\n git rev-parse --verify --quiet \"${CHECK_REV}^{commit}\" >/dev/null \\\n || { echo \"no such commit: $CHECK_REV\" >&2; exit 3; }\n if commit_carries_signature \"$CHECK_REV\"; then\n say \"signed: $CHECK_REV carries a signature header\"\n exit 0\n fi\n say \"unsigned: $CHECK_REV carries no signature header\"\n exit 1\nfi\n\n# --- configuration ----------------------------------------------------------\ngpgsign=$(git config --get commit.gpgsign || echo \"\")\ngpgformat=$(git config --get gpg.format || echo \"openpgp\")\nsigningkey=$(git config --get user.signingkey || echo \"\")\n\nsay \"config: commit.gpgsign=${gpgsign:-\u003cunset>} gpg.format=$gpgformat user.signingkey=${signingkey:-\u003cunset>}\"\n\nif [ \"$gpgformat\" = \"ssh\" ] && command -v ssh-add >/dev/null 2>&1; then\n if ! ssh-add -l >/dev/null 2>&1; then\n # Not fatal: user.signingkey may name a private key file, which signs\n # without an agent. Worth reporting, since a dropped agent key is the\n # most common cause of a mid-run signing failure.\n say \"note: ssh-agent holds no identities — signing works only if user.signingkey names a private key file\"\n fi\nfi\n\nif [ \"$CONFIG_ONLY\" -eq 1 ]; then\n if [ -z \"$signingkey\" ] && [ \"$gpgformat\" = \"ssh\" ]; then\n say \"INCONCLUSIVE — gpg.format=ssh without user.signingkey; config cannot tell whether signing works\"\n exit 2\n fi\n say \"config present — run without --config-only to prove signing actually works\"\n exit 0\nfi\n\n# --- probe ------------------------------------------------------------------\nPROBE_BRANCH=\"tmp/sign-probe-$\"\nORIG_REF=$(git symbolic-ref -q --short HEAD || git rev-parse --verify HEAD 2>/dev/null || echo \"\")\nTMP_INDEX=$(mktemp)\nSTDERR_LOG=$(mktemp)\n\n# shellcheck disable=SC2329 # invoked by the EXIT trap below\ncleanup() {\n # Restore first, then drop the branch: a branch cannot be deleted while\n # it is checked out.\n if [ -n \"$ORIG_REF\" ]; then\n git checkout -q \"$ORIG_REF\" -- 2>/dev/null || git checkout -q \"$ORIG_REF\" 2>/dev/null || true\n fi\n git branch -q -D \"$PROBE_BRANCH\" 2>/dev/null || true\n rm -f \"$TMP_INDEX\" \"$STDERR_LOG\"\n}\ntrap cleanup EXIT\n\n# A temporary index seeded from HEAD keeps the real index untouched — without\n# it, `commit --allow-empty` sweeps whatever the user had staged into the\n# throwaway commit and unstages it when the branch is deleted.\nexport GIT_INDEX_FILE=\"$TMP_INDEX\"\nif git rev-parse --verify HEAD >/dev/null 2>&1; then\n git read-tree HEAD || { echo \"could not seed temporary index\" >&2; exit 3; }\nfi\n\ngit checkout -q -b \"$PROBE_BRANCH\" 2>/dev/null || { echo \"could not create probe branch\" >&2; exit 3; }\n\n# Conventional message: a commit-msg hook rejecting `probe` aborts the commit\n# and is indistinguishable from a signing failure at the exit code.\nprobe_commit() { git commit -S --allow-empty \"$@\" -m \"chore: signing probe\" >/dev/null 2>\"$STDERR_LOG\"; }\n\nHOOK_BLOCKED=0\nif ! probe_commit; then\n # Retry with hooks off. If it now commits, the first failure was a hook,\n # not signing — a distinction the plain probe cannot make. --no-verify is\n # confined to this throwaway commit, which is deleted moments later.\n if probe_commit --no-verify; then\n HOOK_BLOCKED=1\n say \"note: a hook rejected the probe commit; retried with --no-verify to isolate signing\"\n else\n # --no-verify skips pre-commit and commit-msg, so hooks are ruled out\n # by the retry: what remains is signing, or the repository itself.\n say \"NOT READY — git commit -S aborted with hooks disabled:\"\n say \"$(sed 's/^/ /' \"$STDERR_LOG\")\"\n exit 1\n fi\nfi\n\nif commit_carries_signature HEAD; then\n say \"SIGNING READY — the probe commit carries a signature header\"\n [ \"$HOOK_BLOCKED\" -eq 1 ] && say \" (hooks reject a plain probe here; real commits must satisfy them)\"\n exit 0\nfi\n\nsay \"NOT READY — the probe commit was created without a signature\"\nsay \" commit.gpgsign is ${gpgsign:-unset}; pass -S explicitly or set commit.gpgsign=true\"\nexit 1\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":8591,"content_sha256":"cb0ec056922c92d1329a067729a9f63ca71612701d7f53eeba311020e24d6d45"},{"filename":"scripts/spec-cleanup-guard.sh","content":"#!/usr/bin/env bash\n#\n# spec-cleanup-guard.sh — deterministic, READ-ONLY gate for intermediate\n# planning artifacts (superpowers specs/plans, scratch plans, planning-tool\n# output) that must not land in the base branch.\n#\n# Invariant: this script NEVER deletes, stages, or modifies any file. It detects\n# and reports. Only the interactive Capture step (/pr-finish) removes files.\n#\n# Detection is branch-local and state-based: it flags the PRESENCE of configured\n# intermediate paths in three states — committed/tracked, staged, untracked —\n# independent of any base branch (intermediate paths must never be tracked, so\n# presence => fail). This sidesteps base-branch resolution entirely.\n#\n# Exit codes: 0 clean · 1 intermediate artifacts found · 2 usage/config error.\n#\n# Usage:\n# spec-cleanup-guard.sh enforce: list matches, exit 1 if any\n# spec-cleanup-guard.sh --dry-run list matches, always exit 0 (manifest only)\n# spec-cleanup-guard.sh --selftest run internal fixtures, exit 0/1\n# spec-cleanup-guard.sh --help\n#\n# Config: .spec-cleanup.yml at repo root (optional). Reads intermediate_paths[]\n# and exclude[]. If the file is present but `yq` is unavailable the guard FAILS\n# CLOSED (exit 2) rather than silently under-enforcing. Without a config file the\n# baked-in defaults below apply.\n\n# SPDX-License-Identifier: MIT\n# SPDX-FileCopyrightText: Netresearch DTT GmbH\n\nset -euo pipefail\n\n# --version answers \"which copy am I running\" without diffing installations.\n# Two installations can declare the SAME number while shipping different\n# scripts (netresearch/git-workflow-skill#209 measured exactly that, and the\n# missing flag read as a missing feature for a dozen merges). So the resolved\n# path is printed beside the version: the number says what the copy claims to\n# be, the path says which file actually answered.\n#\n# The version is read from the SKILL.md NEXT TO the script, never from a\n# checkout elsewhere -- a cached copy must report the number it was packaged\n# with, or the answer is worse than none. \\042 and \\047 are the quote\n# characters by octal code, so this awk program contains no quote of its own\n# to terminate the single-quoted string it lives in.\nskill_version() {\n local here skill v\n here=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n skill=\"$here/../SKILL.md\"\n v=\"unknown\"\n if [ -f \"$skill\" ]; then\n v=\"$(awk '/^[ \\t]*version:/ {\n s = $0\n sub(/^[ \\t]*version:[ \\t]*/, \"\", s)\n gsub(/[\\042\\047]/, \"\", s)\n gsub(/[ \\t\\r]+$/, \"\", s)\n if (s != \"\") { print s; exit }\n }' \"$skill\" 2>/dev/null)\" || v=\"\"\n [ -n \"$v\" ] || v=\"unknown\"\n fi\n printf '%s %s\\n' \"$(basename \"${BASH_SOURCE[0]}\")\" \"$v\"\n printf 'path: %s/%s\\n' \"$here\" \"$(basename \"${BASH_SOURCE[0]}\")\"\n}\n\nDEFAULT_PATHS=(\n \"docs/superpowers/**\"\n \"claudedocs/**\"\n \"docs/working/**\"\n \"docs/superpowers/**/*.plan.md\"\n)\nDEFAULT_EXCLUDES=()\n\nCONFIG_FILE=\".spec-cleanup.yml\"\n\ndie() { printf 'spec-cleanup-guard: %s\\n' \"$*\" >&2; exit 2; }\n\n# Normalize a config glob to a git pathspec.\n# dir glob \"docs/superpowers/**\" -> \"docs/superpowers/\" (recurses)\n# suffix \"docs/superpowers/**/*.plan.md\" -> \":(glob)docs/.../*.plan.md\"\n# plain \"docs/x.md\" -> \"docs/x.md\"\nnormalize_pathspec() {\n local g=\"$1\" base\n if [[ \"$g\" == */\\*\\* ]]; then\n base=\"${g%/\\*\\*}\"\n if [[ \"$base\" != *[\\*\\?\\[]* ]]; then printf '%s/' \"$base\"; return; fi\n fi\n if [[ \"$g\" != *[\\*\\?\\[]* ]]; then printf '%s' \"$g\"; return; fi\n printf ':(glob)%s' \"$g\"\n}\n\n# Normalize an exclude glob to an exclude pathspec.\nnormalize_exclude() {\n local g=\"$1\"\n if [[ \"$g\" == *[\\*\\?\\[]* ]]; then printf ':(exclude,glob)%s' \"$g\"\n else printf ':(exclude)%s' \"$g\"; fi\n}\n\nload_config() {\n PATHS=() EXCLUDES=()\n if [[ -f \"$CONFIG_FILE\" ]]; then\n if [[ \"${SPEC_CLEANUP_FAKE_NO_YQ:-0}\" == \"1\" ]] || ! command -v yq >/dev/null 2>&1; then\n die \"$CONFIG_FILE present but 'yq' is not installed; refusing to under-enforce (install yq).\"\n fi\n local p\n while IFS= read -r p; do [[ -n \"$p\" ]] && PATHS+=(\"$p\"); done \\\n \u003c \u003c(yq '.intermediate_paths[]' \"$CONFIG_FILE\" 2>/dev/null || true)\n while IFS= read -r p; do [[ -n \"$p\" && \"$p\" != \"null\" ]] && EXCLUDES+=(\"$p\"); done \\\n \u003c \u003c(yq '.exclude[]' \"$CONFIG_FILE\" 2>/dev/null || true)\n [[ ${#PATHS[@]} -gt 0 ]] || die \"$CONFIG_FILE has no intermediate_paths.\"\n else\n PATHS=(\"${DEFAULT_PATHS[@]}\")\n EXCLUDES=(\"${DEFAULT_EXCLUDES[@]+\"${DEFAULT_EXCLUDES[@]}\"}\")\n fi\n # Reject unanchored leading-glob patterns (e.g. **/*.plan.md, *.md): they match\n # arbitrary project files. Anchor under a directory (docs/superpowers/**/*.plan.md).\n local g\n for g in \"${PATHS[@]}\"; do\n if [[ \"$g\" == '*'* ]]; then\n die \"unanchored glob '$g' in intermediate_paths — anchor it under a directory (e.g. docs/superpowers/**/*.plan.md) so it cannot match unrelated files.\"\n fi\n done\n}\n\nbuild_pathspecs() {\n PATHSPECS=()\n local g\n for g in \"${PATHS[@]}\"; do PATHSPECS+=(\"$(normalize_pathspec \"$g\")\"); done\n for g in \"${EXCLUDES[@]+\"${EXCLUDES[@]}\"}\"; do PATHSPECS+=(\"$(normalize_exclude \"$g\")\"); done\n}\n\n# Populate TRACKED / STAGED / UNTRACKED arrays (each file in exactly one bucket).\ndetect() {\n mapfile -t STAGED \u003c \u003c(git -c core.quotePath=false diff --cached --name-only --diff-filter=ACMR -- \"${PATHSPECS[@]}\" 2>/dev/null | sort -u)\n mapfile -t UNTRACKED \u003c \u003c(git -c core.quotePath=false ls-files --others --exclude-standard -- \"${PATHSPECS[@]}\" 2>/dev/null | sort -u)\n # Committed-tracked = index entries matching, minus anything already reported as a\n # staged change, so a newly-staged file is not double-listed as \"committed\".\n # ls-files (not ls-tree) is required here: ls-tree rejects the :(glob)/:(exclude)\n # pathspec magic the guard relies on.\n if ((${#STAGED[@]})); then\n mapfile -t TRACKED \u003c \u003c(git -c core.quotePath=false ls-files -- \"${PATHSPECS[@]}\" 2>/dev/null | sort -u | grep -vxF \"$(printf '%s\\n' \"${STAGED[@]}\")\" || true)\n else\n mapfile -t TRACKED \u003c \u003c(git -c core.quotePath=false ls-files -- \"${PATHSPECS[@]}\" 2>/dev/null | sort -u)\n fi\n}\n\nprint_group() {\n local title=\"$1\"; shift\n (( $# == 0 )) && return\n printf ' %s:\\n' \"$title\"\n printf ' %s\\n' \"$@\"\n}\n\nrun() {\n local dry=\"$1\"\n git rev-parse --is-inside-work-tree >/dev/null 2>&1 || die \"not inside a git work tree.\"\n # Anchor config lookup + pathspecs at the repo root so the gate cannot silently\n # pass when invoked from a subdirectory (e.g. a hook or CI step with a different CWD).\n cd \"$(git rev-parse --show-toplevel)\" || die \"cannot cd to repo root.\"\n load_config\n build_pathspecs\n detect\n local n=$(( ${#TRACKED[@]} + ${#STAGED[@]} + ${#UNTRACKED[@]} ))\n if (( n == 0 )); then\n echo \"spec-cleanup-guard: clean — no intermediate planning artifacts.\"\n return 0\n fi\n echo \"spec-cleanup-guard: found $n intermediate planning artifact(s) that must not reach the base branch:\"\n print_group \"tracked (committed)\" \"${TRACKED[@]+\"${TRACKED[@]}\"}\"\n print_group \"staged\" \"${STAGED[@]+\"${STAGED[@]}\"}\"\n print_group \"untracked\" \"${UNTRACKED[@]+\"${UNTRACKED[@]}\"}\"\n echo \"Resolve via /pr-finish: convert (propose ADR) · remove · acknowledge.\"\n echo \"Untracked files are listed so you can confirm before any removal.\"\n [[ \"$dry\" == \"1\" ]] && return 0\n return 1\n}\n\n# --------------------------- selftest ---------------------------\ngit_q() { git -c commit.gpgsign=false -c [email protected] -c user.name=test \"$@\"; }\nst_pass=0 st_fail=0\nst_assert() { # desc expected_rc actual_rc\n if [[ \"$2\" == \"$3\" ]]; then st_pass=$((st_pass+1)); printf ' ok %s (rc=%s)\\n' \"$1\" \"$3\"\n else st_fail=$((st_fail+1)); printf ' FAIL %s (want rc=%s, got %s)\\n' \"$1\" \"$2\" \"$3\"; fi\n}\nst_rc() { local rc=0; \"$@\" >/dev/null 2>&1 || rc=$?; printf '%s' \"$rc\"; }\n\nselftest() {\n local self; self=\"$(cd \"$(dirname \"$0\")\" && pwd)/$(basename \"$0\")\"\n local t; t=\"$(mktemp -d \"${TMPDIR:-/tmp}/scg-selftest.XXXXXX\")\"\n trap 'rm -rf \"$t\"' RETURN\n ( cd \"$t\"\n git_q init -q\n mkdir -p docs/superpowers/specs/a/b src docs/working\n echo readme > README.md\n echo real > src/real.plan.md # real file, NON-intermediate path\n git_q add README.md src/real.plan.md; git_q commit -qm init\n\n echo \"T4 no config — default guard:\"\n st_assert \"T6 clean branch -> 0\" 0 \"$(st_rc bash \"$self\")\"\n\n echo c > docs/superpowers/specs/a/b/c.md # nested, untracked\n st_assert \"T2/T3 nested untracked -> 1\" 1 \"$(st_rc bash \"$self\")\"\n st_assert \"T3 nested caught also in --dry-run (rc 0)\" 0 \"$(st_rc bash \"$self\" --dry-run)\"\n mkdir -p sub/deeper\n st_assert \"GC-1 caught when run from subdirectory -> 1\" 1 \"$(cd sub/deeper && st_rc bash \"$self\")\"\n\n git_q add docs/superpowers/specs/a/b/c.md # now staged\n st_assert \"T_staged staged intermediate -> 1\" 1 \"$(st_rc bash \"$self\")\"\n # A newly-staged file must appear ONLY under \"staged\", never \"tracked (committed)\".\n staged_out=\"$(bash \"$self\" 2>/dev/null || true)\"\n if printf '%s' \"$staged_out\" | grep -q 'staged:' && ! printf '%s' \"$staged_out\" | grep -q 'tracked (committed):'; then\n st_assert \"staged file not double-listed as tracked\" 0 0\n else\n st_assert \"staged file not double-listed as tracked\" 0 1\n fi\n git_q commit -qm spec # now tracked\n st_assert \"T1 tracked intermediate -> 1\" 1 \"$(st_rc bash \"$self\")\"\n\n git_q rm -q docs/superpowers/specs/a/b/c.md; git_q commit -qm rm\n st_assert \"T6b removed -> clean 0\" 0 \"$(st_rc bash \"$self\")\"\n\n echo \"T_overmatch real src/*.plan.md must NOT be flagged by default:\"\n st_assert \"default ignores src/real.plan.md -> 0\" 0 \"$(st_rc bash \"$self\")\"\n\n echo \"T5 exclude + T7 yq-fail-closed (config present):\"\n if command -v yq >/dev/null 2>&1; then\n printf 'intermediate_paths:\\n - docs/superpowers/**\\nexclude:\\n - docs/superpowers/keep/**\\n' > .spec-cleanup.yml\n mkdir -p docs/superpowers/keep\n echo k > docs/superpowers/keep/wanted.md # untracked but excluded\n st_assert \"T5 excluded path -> clean 0\" 0 \"$(st_rc bash \"$self\")\"\n echo f > docs/superpowers/flagme.md\n st_assert \"T5b non-excluded under config -> 1\" 1 \"$(st_rc bash \"$self\")\"\n st_assert \"T7 config present + no yq -> 2 (fail closed)\" 2 \\\n \"$(SPEC_CLEANUP_FAKE_NO_YQ=1 st_rc bash \"$self\")\"\n printf 'intermediate_paths:\\n - \"**/*.plan.md\"\\n' > .spec-cleanup.yml\n st_assert \"GC-2 unanchored glob in config -> 2 (rejected)\" 2 \"$(st_rc bash \"$self\")\"\n rm -f .spec-cleanup.yml docs/superpowers/flagme.md\n rm -rf docs/superpowers/keep\n else\n echo \" skip T5/T7 (yq not installed)\"\n fi\n echo \"selftest: $st_pass passed, $st_fail failed\"\n [[ \"$st_fail\" == \"0\" ]] # subshell exit status reflects failures\n )\n}\n\n# ------------------------------ main ----------------------------\ncase \"${1:-}\" in\n --help|-h) awk 'NR==1{next} /^#/{sub(/^# ?/,\"\");print;next} {exit}' \"$0\"; exit 0 ;;\n --selftest) selftest ;;\n --dry-run) run 1 ;;\n \"\") run 0 ;;\n --version) skill_version; exit 0 ;;\n *) die \"unknown argument: $1 (try --help)\";;\nesac\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":11229,"content_sha256":"e431b14b9142d458c6bc20645e4075384808fafd0b9e3d7c91fff50c676556d3"},{"filename":"scripts/verify-git-workflow.sh","content":"#!/bin/bash\n# SPDX-License-Identifier: MIT\n# SPDX-FileCopyrightText: Netresearch DTT GmbH\n# Git Workflow Verification Script\n# Checks repository for git workflow best practices\n\nset -e\n\n# --version answers \"which copy am I running\" without diffing installations.\n# Two installations can declare the SAME number while shipping different\n# scripts (netresearch/git-workflow-skill#209 measured exactly that, and the\n# missing flag read as a missing feature for a dozen merges). So the resolved\n# path is printed beside the version: the number says what the copy claims to\n# be, the path says which file actually answered.\n#\n# The version is read from the SKILL.md NEXT TO the script, never from a\n# checkout elsewhere -- a cached copy must report the number it was packaged\n# with, or the answer is worse than none. \\042 and \\047 are the quote\n# characters by octal code, so this awk program contains no quote of its own\n# to terminate the single-quoted string it lives in.\nskill_version() {\n local here skill v\n here=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n skill=\"$here/../SKILL.md\"\n v=\"unknown\"\n if [ -f \"$skill\" ]; then\n v=\"$(awk '/^[ \\t]*version:/ {\n s = $0\n sub(/^[ \\t]*version:[ \\t]*/, \"\", s)\n gsub(/[\\042\\047]/, \"\", s)\n gsub(/[ \\t\\r]+$/, \"\", s)\n if (s != \"\") { print s; exit }\n }' \"$skill\" 2>/dev/null)\" || v=\"\"\n [ -n \"$v\" ] || v=\"unknown\"\n fi\n printf '%s %s\\n' \"$(basename \"${BASH_SOURCE[0]}\")\" \"$v\"\n printf 'path: %s/%s\\n' \"$here\" \"$(basename \"${BASH_SOURCE[0]}\")\"\n}\n\n[ \"${1:-}\" = \"--version\" ] && { skill_version; exit 0; }\n\nREPO_DIR=\"${1:-.}\"\nERRORS=0\nWARNINGS=0\n\necho \"=== Git Workflow Verification ===\"\necho \"Repository: $REPO_DIR\"\necho \"\"\n\n# Change to repo directory\ncd \"$REPO_DIR\"\n\n# Check if it's a git repository. Asking git, not testing for a `.git`\n# directory: in a worktree `.git` is a *file* pointing at the real gitdir, so\n# the directory test refused to run in every worktree — including the layout\n# this skill's own references recommend.\nif ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then\n echo \"❌ Not a git repository\"\n exit 1\nfi\n\n# Check branch naming\necho \"=== Branch Naming Convention ===\"\nBRANCHES=$(git branch -a 2>/dev/null | sed 's/^[* ]*//' | grep -v \"HEAD\" | sed 's/remotes\\/origin\\///' | sort -u)\n\nVALID_PATTERN=\"^(main|master|develop|feature\\/|fix\\/|bugfix\\/|hotfix\\/|release\\/|chore\\/|docs\\/|test\\/|refactor\\/)\"\nINVALID_BRANCHES=\"\"\n\nfor branch in $BRANCHES; do\n if ! echo \"$branch\" | grep -qE \"$VALID_PATTERN\"; then\n INVALID_BRANCHES=\"$INVALID_BRANCHES $branch\"\n fi\ndone\n\nif [[ -n \"$INVALID_BRANCHES\" ]]; then\n echo \"⚠️ Non-standard branch names found:\"\n echo \" $INVALID_BRANCHES\"\n echo \" Expected: main, develop, feature/*, fix/*, release/*, hotfix/*\"\n WARNINGS=$((WARNINGS + 1))\nelse\n echo \"✅ All branch names follow conventions\"\nfi\n\n# Check commit message format\necho \"\"\necho \"=== Commit Message Format ===\"\nRECENT_COMMITS=$(git log --oneline -20 2>/dev/null | head -20)\n\nCONV_PATTERN=\"^[a-f0-9]+ (feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\\(.+\\))?(!)?: .+\"\nINVALID_COMMITS=0\nVALID_COMMITS=0\n\nwhile IFS= read -r commit; do\n if echo \"$commit\" | grep -qE \"$CONV_PATTERN\"; then\n VALID_COMMITS=$((VALID_COMMITS + 1))\n else\n # Allow merge commits\n if ! echo \"$commit\" | grep -qE \"^[a-f0-9]+ Merge\"; then\n INVALID_COMMITS=$((INVALID_COMMITS + 1))\n fi\n fi\ndone \u003c\u003c\u003c \"$RECENT_COMMITS\"\n\nTOTAL_COMMITS=$((VALID_COMMITS + INVALID_COMMITS))\nif [[ $TOTAL_COMMITS -gt 0 ]]; then\n PERCENT=$((VALID_COMMITS * 100 / TOTAL_COMMITS))\n if [[ $PERCENT -ge 80 ]]; then\n echo \"✅ $PERCENT% of commits follow Conventional Commits format\"\n elif [[ $PERCENT -ge 50 ]]; then\n echo \"⚠️ $PERCENT% of commits follow Conventional Commits format\"\n WARNINGS=$((WARNINGS + 1))\n else\n echo \"⚠️ Only $PERCENT% of commits follow Conventional Commits format\"\n WARNINGS=$((WARNINGS + 1))\n fi\nfi\n\n# Check for .gitignore\necho \"\"\necho \"=== .gitignore Check ===\"\nif [[ -f \".gitignore\" ]]; then\n echo \"✅ .gitignore exists\"\n\n # Check for common patterns\n COMMON_IGNORES=(\"node_modules\" \".env\" \"*.log\" \"dist\" \"build\" \".DS_Store\")\n MISSING_IGNORES=\"\"\n\n for pattern in \"${COMMON_IGNORES[@]}\"; do\n if ! grep -q \"$pattern\" .gitignore 2>/dev/null; then\n MISSING_IGNORES=\"$MISSING_IGNORES $pattern\"\n fi\n done\n\n if [[ -n \"$MISSING_IGNORES\" ]]; then\n echo \" ℹ️ Consider adding:$MISSING_IGNORES\"\n fi\nelse\n echo \"⚠️ No .gitignore file found\"\n WARNINGS=$((WARNINGS + 1))\nfi\n\n# Check for hooks\necho \"\"\necho \"=== Git Hooks ===\"\nif [[ -d \".git/hooks\" ]]; then\n ACTIVE_HOOKS=$(find .git/hooks -type f ! -name \"*.sample\" 2>/dev/null | wc -l)\n if [[ $ACTIVE_HOOKS -gt 0 ]]; then\n echo \"✅ Found $ACTIVE_HOOKS active hook(s)\"\n find .git/hooks -type f ! -name \"*.sample\" -exec basename {} \\; 2>/dev/null | sed 's/^/ /'\n else\n echo \"ℹ️ No active git hooks\"\n fi\nfi\n\n# Check for husky\nif [[ -d \".husky\" ]]; then\n echo \"✅ Husky hooks directory found\"\nfi\n\n# Check for commitlint\nif [[ -f \"commitlint.config.js\" ]] || [[ -f \".commitlintrc\" ]] || [[ -f \".commitlintrc.json\" ]]; then\n echo \"✅ Commitlint configuration found\"\nfi\n\n# Check for branch protection (via CODEOWNERS)\necho \"\"\necho \"=== Code Ownership ===\"\nif [[ -f \"CODEOWNERS\" ]] || [[ -f \".github/CODEOWNERS\" ]] || [[ -f \"docs/CODEOWNERS\" ]]; then\n echo \"✅ CODEOWNERS file found\"\nelse\n echo \"ℹ️ No CODEOWNERS file (optional)\"\nfi\n\n# Check for PR template\necho \"\"\necho \"=== PR Templates ===\"\nif [[ -f \".github/PULL_REQUEST_TEMPLATE.md\" ]] || [[ -d \".github/PULL_REQUEST_TEMPLATE\" ]]; then\n echo \"✅ PR template(s) found\"\nelse\n echo \"ℹ️ No PR template (recommended)\"\nfi\n\n# Check for CI/CD configuration\necho \"\"\necho \"=== CI/CD Configuration ===\"\nCI_FOUND=false\n\nif [[ -d \".github/workflows\" ]]; then\n WORKFLOW_COUNT=$(find .github/workflows -name \"*.yml\" -o -name \"*.yaml\" 2>/dev/null | wc -l)\n if [[ $WORKFLOW_COUNT -gt 0 ]]; then\n echo \"✅ GitHub Actions: $WORKFLOW_COUNT workflow(s)\"\n CI_FOUND=true\n fi\nfi\n\nif [[ -f \".gitlab-ci.yml\" ]]; then\n echo \"✅ GitLab CI configuration found\"\n CI_FOUND=true\nfi\n\nif [[ -f \"Jenkinsfile\" ]]; then\n echo \"✅ Jenkinsfile found\"\n CI_FOUND=true\nfi\n\nif [[ -f \".circleci/config.yml\" ]]; then\n echo \"✅ CircleCI configuration found\"\n CI_FOUND=true\nfi\n\nif [[ -f \"azure-pipelines.yml\" ]]; then\n echo \"✅ Azure Pipelines configuration found\"\n CI_FOUND=true\nfi\n\nif [[ \"$CI_FOUND\" == \"false\" ]]; then\n echo \"⚠️ No CI/CD configuration found\"\n WARNINGS=$((WARNINGS + 1))\nfi\n\n# Check for semantic release\necho \"\"\necho \"=== Release Configuration ===\"\nif [[ -f \".releaserc\" ]] || [[ -f \".releaserc.json\" ]] || [[ -f \".releaserc.yml\" ]] || [[ -f \"release.config.js\" ]]; then\n echo \"✅ Semantic release configuration found\"\nfi\n\n# Check for CHANGELOG\nif [[ -f \"CHANGELOG.md\" ]] || [[ -f \"CHANGELOG\" ]]; then\n echo \"✅ CHANGELOG found\"\nelse\n echo \"ℹ️ No CHANGELOG (recommended for releases)\"\nfi\n\n# Check for versioning\nif [[ -f \"package.json\" ]]; then\n VERSION=$(grep '\"version\"' package.json | head -1 | sed 's/.*: *\"\\([^\"]*\\)\".*/\\1/')\n if [[ -n \"$VERSION\" ]]; then\n echo \"✅ Package version: $VERSION\"\n fi\nfi\n\n# Unreleased commits since the last tag. This was checkpoint GW-15, which the\n# runner's allowlist rejected outright — a rev-range needs `\u003ctag>..HEAD` and the\n# count needs command substitution, and `..` and `$(` are both refused. It never\n# ran once. Here a full shell is available, so the rule survives intact.\nLAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || true)\nif [[ -n \"$LAST_TAG\" ]]; then\n UNRELEASED=$(git rev-list \"${LAST_TAG}..HEAD\" --count 2>/dev/null || echo 0)\n if [[ \"$UNRELEASED\" -le 20 ]]; then\n echo \"✅ $UNRELEASED commit(s) since $LAST_TAG\"\n else\n echo \"⚠️ $UNRELEASED commits since $LAST_TAG — cut a release\"\n WARNINGS=$((WARNINGS + 1))\n fi\nelse\n echo \"ℹ️ No tags yet\"\nfi\n\n# Check current branch\necho \"\"\necho \"=== Current State ===\"\nCURRENT_BRANCH=$(git branch --show-current 2>/dev/null)\necho \"Current branch: $CURRENT_BRANCH\"\n\n# Check for uncommitted changes\nif git diff --quiet 2>/dev/null && git diff --cached --quiet 2>/dev/null; then\n echo \"✅ Working directory clean\"\nelse\n CHANGES=$(git status --porcelain 2>/dev/null | wc -l)\n echo \"⚠️ $CHANGES uncommitted change(s)\"\nfi\n\n# Check if up to date with remote\nif git remote | grep -q \"origin\" 2>/dev/null; then\n git fetch origin --quiet 2>/dev/null || true\n # --verify --quiet, because plain `git rev-parse origin/\u003cbranch>` echoes the\n # ref NAME back on stdout when it does not resolve. The literal string then\n # passed the -n test, the rev-list below failed on it, and `set -e` killed\n # the script three sections early — silently, on every unpushed branch.\n LOCAL=$(git rev-parse --verify --quiet \"$CURRENT_BRANCH\" || true)\n REMOTE=$(git rev-parse --verify --quiet \"origin/$CURRENT_BRANCH\" || true)\n\n if [[ -n \"$LOCAL\" && -n \"$REMOTE\" ]]; then\n if [[ \"$LOCAL\" == \"$REMOTE\" ]]; then\n echo \"✅ Up to date with origin/$CURRENT_BRANCH\"\n else\n BEHIND=$(git rev-list --count \"$LOCAL..$REMOTE\" 2>/dev/null || echo \"?\")\n AHEAD=$(git rev-list --count \"$REMOTE..$LOCAL\" 2>/dev/null || echo \"?\")\n echo \"ℹ️ Branch is $AHEAD ahead, $BEHIND behind origin/$CURRENT_BRANCH\"\n fi\n elif [[ -z \"$REMOTE\" ]]; then\n echo \"ℹ️ Branch not pushed to origin yet\"\n fi\nfi\n\n# Check for merge conflicts markers\necho \"\"\necho \"=== Conflict Markers ===\"\n# Tracked files of ANY type: markers land in .md/.rst/.yaml just as often as in\n# code, and an extension allow-list silently passes those. Anchored at line\n# start, and WITHOUT a bare \"=======\" branch — that is an ordinary RST section\n# underline and would report every docs tree as conflicted.\nCONFLICT_FILES=$(git grep -lE '^(\u003c\u003c\u003c\u003c\u003c\u003c\u003c |>>>>>>> )' -- . 2>/dev/null | head -5)\nif [[ -z \"$CONFLICT_FILES\" ]] && ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then\n CONFLICT_FILES=$(grep -rlE '^(\u003c\u003c\u003c\u003c\u003c\u003c\u003c |>>>>>>> )' . 2>/dev/null | grep -v node_modules | grep -v vendor | head -5)\nfi\nif [[ -n \"$CONFLICT_FILES\" ]]; then\n echo \"❌ Conflict markers found in files:\"\n while IFS= read -r conflict_file; do echo \" $conflict_file\"; done \u003c\u003c\u003c \"$CONFLICT_FILES\"\n ERRORS=$((ERRORS + 1))\nelse\n echo \"✅ No conflict markers found\"\nfi\n\necho \"\"\necho \"=== Commit Signing ===\"\n\n# Read the signature from the commit object, not from `git log --show-signature`\n# or `%G?`: those answer \"can this machine verify it\", and report a correctly\n# signed commit as unsigned whenever gpg.format=ssh is set without\n# gpg.ssh.allowedSignersFile. Same rule as checkpoint GW-17 and\n# signing-preflight.sh. Cut at the first blank line so a `gpgsig` line in the\n# message body cannot pass as a signature.\nUNSIGNED=\"\"\nwhile read -r sha; do\n [[ -z \"$sha\" ]] && continue\n if ! git cat-file commit \"$sha\" 2>/dev/null | sed -n '/^$/q;p' | grep -qE '^gpgsig(-sha256)? '; then\n UNSIGNED=\"$UNSIGNED $sha\"\n fi\ndone \u003c \u003c(git log -10 --format=%H 2>/dev/null)\n\nif [[ -z \"$UNSIGNED\" ]]; then\n echo \"✅ Last 10 commits all carry a signature\"\nelse\n UNSIGNED_COUNT=$(wc -w \u003c\u003c\u003c \"$UNSIGNED\")\n echo \"⚠️ $UNSIGNED_COUNT of the last 10 commits carry no signature:\"\n for sha in $UNSIGNED; do echo \" ${sha:0:8}\"; done\n echo \" Signature presence only — whether a signature verifies is the host's\"\n echo \" answer, not this check's. Probe your own setup with signing-preflight.sh.\"\n WARNINGS=$((WARNINGS + 1))\nfi\n\n# Summary\necho \"\"\necho \"=== Summary ===\"\necho \"Errors: $ERRORS\"\necho \"Warnings: $WARNINGS\"\n\nif [[ $ERRORS -gt 0 ]]; then\n echo \"❌ Verification FAILED\"\n exit 1\nelif [[ $WARNINGS -gt 3 ]]; then\n echo \"⚠️ Verification completed with warnings\"\n exit 0\nelse\n echo \"✅ Verification PASSED\"\n exit 0\nfi\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":12271,"content_sha256":"b168a1b63108826bd2385fd428640245a0a79bfb7d5473a57dcc7d21548de199"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"Git Workflow Skill","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Not Here","type":"text"}]},{"type":"paragraph","content":[{"text":"Releases: ","type":"text"},{"text":"github-release","type":"text","marks":[{"type":"code_inline"}]},{"text":". BLOCKED-PR diagnosis: ","type":"text"},{"text":"github-project","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"GitHub only.","type":"text","marks":[{"type":"strong"}]},{"text":" Everything below the branching and commit sections — ","type":"text"},{"text":"pr-status.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"pr-merge.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":", the merge gate, review threads — speaks GitHub GraphQL. For a GitLab merge request the equivalent ","type":"text"},{"text":"glab","type":"text","marks":[{"type":"code_inline"}]},{"text":" calls are in ","type":"text"},{"text":"references/pull-request-workflow.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" § ","type":"text"},{"text":"GitLab: the same gate with ","type":"text","marks":[{"type":"em"}]},{"text":"glab","type":"text","marks":[{"type":"code_inline"},{"type":"em"}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Critical Rules (Non-Negotiable)","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"No direct push to main","type":"text","marks":[{"type":"strong"}]},{"text":" — always open a PR.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"No merge before all threads resolved","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"references/pull-request-workflow.md","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"No squash unless asked","type":"text","marks":[{"type":"strong"}]},{"text":" — preserves atomic commits, signatures, bisection.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"No \"tested/verified/working\" without pasted command output","type":"text","marks":[{"type":"strong"}]},{"text":" — else say so.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"No edits to installed skill/plugin cache paths","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"~/.claude/skills/","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"~/.claude/plugins/cache/","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"**/.bare/**","type":"text","marks":[{"type":"code_inline"}]},{"text":") — always the repo worktree, verified by ","type":"text"},{"text":"pwd","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Force-push only with ","type":"text","marks":[{"type":"strong"}]},{"text":"--force-with-lease","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — never plain ","type":"text"},{"text":"--force","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Commit before rebase","type":"text","marks":[{"type":"strong"}]},{"text":" — ","type":"text"},{"text":"add → commit → fetch → rebase → push","type":"text","marks":[{"type":"code_inline"}]},{"text":" (a dirty tree aborts it).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"No editorializing","type":"text","marks":[{"type":"strong"}]},{"text":" — state what changed, not how good it is (","type":"text"},{"text":"references/no-editorializing.md","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Reference Files","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Reference","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Content Triggers","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/commit-conventions.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Conventional commits, DCO sign-off","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/pull-request-workflow.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"PR merge gate, signed rebase, posting a review with inline suggestions","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/ci-cd-integration.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"CI watching, git mirrors","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/advanced-git.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Rebase (incl. non-interactive ","type":"text"},{"text":"rebase -i","type":"text","marks":[{"type":"code_inline"}]},{"text":"), cherry-pick, bisect, stash, worktrees, reflog, hanging push/fetch (SSH mux), parsing git output in a script (flags that pin diff/log/status output against user config), fetching one branch in a single-branch clone (refspec)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/github-releases.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"→ ","type":"text"},{"text":"github-release","type":"text","marks":[{"type":"code_inline"}]},{"text":" skill","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/git-hooks-setup.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Hook frameworks, hooks per stage","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/claude-code-hooks.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"settings.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" merge gate, cache-path rejection, auto-lint","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/code-quality-tools.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"shellcheck, shfmt, git-absorb, difftastic","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/merge-gate-watcher.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Waiting on a gate, a watcher the harness killed, merge-driver loop, check taxonomy, stale-SHA rerun","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/spec-cleanup.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Planning artifacts off the base branch","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/no-editorializing.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"No self-praise, no narrating the expected","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Conventional Commits","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"\u003ctype>[scope]: \u003cdescription>","type":"text"}]},{"type":"paragraph","content":[{"text":"feat","type":"text","marks":[{"type":"code_inline"}]},{"text":" MINOR, ","type":"text"},{"text":"fix","type":"text","marks":[{"type":"code_inline"}]},{"text":" PATCH; full type list and DCO sign-off in ","type":"text"},{"text":"references/commit-conventions.md","type":"text","marks":[{"type":"code_inline"}]},{"text":". ","type":"text"},{"text":"Breaking","type":"text","marks":[{"type":"strong"}]},{"text":": ","type":"text"},{"text":"!","type":"text","marks":[{"type":"code_inline"}]},{"text":" after type, or ","type":"text"},{"text":"BREAKING CHANGE:","type":"text","marks":[{"type":"code_inline"}]},{"text":" in the footer. ","type":"text"},{"text":"Branches","type":"text","marks":[{"type":"strong"}]},{"text":": ","type":"text"},{"text":"feature/TICKET-123-description","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"release/1.2.0","type":"text","marks":[{"type":"code_inline"}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Hook Detection","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"ls lefthook.yml .lefthook.yml captainhook.json .pre-commit-config.yaml .husky/pre-commit 2>/dev/null || echo \"No hooks\"","type":"text"}]},{"type":"paragraph","content":[{"text":"Install commands per framework: ","type":"text"},{"text":"references/git-hooks-setup.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"PR Merge Requirements","type":"text"}]},{"type":"paragraph","content":[{"text":"Before merging: threads resolved, CI green (incl. annotations), rebased, signed, ","type":"text"},{"text":"and reviewed","type":"text","marks":[{"type":"strong"}]},{"text":" on the current head — by a human, a bot, or the author through ","type":"text"},{"text":"pr-merge.sh --self-reviewed","type":"text","marks":[{"type":"code_inline"}]},{"text":". A review is mandatory; a ","type":"text"},{"text":"bot","type":"text","marks":[{"type":"em"}]},{"text":" review is not, and one is waited for only while it is actually in flight. Rebase-only + signed: ","type":"text"},{"text":"git merge --ff-only","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Verification","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"./scripts/verify-git-workflow.sh /path/to/repository\n# Gate state, next action:\n./scripts/pr-status.sh [-R owner/repo] [PR] [--json] [--watch]\n# Merge; refuses when the gate is shut:\n./scripts/pr-merge.sh [-R owner/repo] [PR] [--dry-run|--self-reviewed]\n# Many PRs (`owner/repo N` per line) to merge; stops on any NEXT it does not handle:\n./scripts/pr-sweep.sh [--interval SEC] [--timeout SEC] [--attention FILE] LISTFILE\n# What a repository expects, before the first artifact:\n./scripts/repo-contribution-preflight.sh [--repo \u003cdir>] [--section docs|templates|packaging|ci|tools]\n# Which copy is installed here (every script answers this):\n./scripts/pr-status.sh --version","type":"text"}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"Contributing:","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"https://github.com/netresearch/git-workflow-skill","type":"text","marks":[{"type":"link","attrs":{"href":"https://github.com/netresearch/git-workflow-skill","title":null}}]}]}]}]},"metadata":{"date":"2026-11-02","name":"git-workflow","author":"@skillopedia","source":{"stars":59,"repo_name":"git-workflow-skill","origin_url":"https://github.com/netresearch/git-workflow-skill/blob/HEAD/skills/git-workflow/SKILL.md","repo_owner":"netresearch","body_sha256":"126a9f8dd5254109a4031af9da0f4a0da8b83c9023f401e121e09c5c2aa4d73c","cluster_key":"de71ee5565b1cdcf41e9d8bb163e6faa000960ac5a8cdf82adb1a4de48c2514c","clean_bundle":{"format":"clean-skill-bundle-v1","source":"netresearch/git-workflow-skill/skills/git-workflow/SKILL.md","text_bytes":711916,"source_commit":"93a542da981c0fe2537018cbe3f69112e28e208d","skills_sh_rank":15679,"attachment_count":22,"text_attachments":22,"attachment_storage":"skill_attachments","binary_attachments":0},"cluster_size":1,"skill_md_path":"skills/git-workflow/SKILL.md","import_metadata":{"date":"2026-11-02","author":"@skillopedia","version":"v1","category":"devops-infrastructure","category_label":"DevOps"},"exact_dupes_collapsed_into_this":0},"license":"(MIT AND CC-BY-SA-4.0). See LICENSE-MIT and LICENSE-CC-BY-SA-4.0","version":"v1","category":"devops-infrastructure","metadata":{"author":"Netresearch DTT GmbH","version":"1.38.0","repository":"https://github.com/netresearch/git-workflow-skill"},"import_tag":"skills-sh-top20k-2026-11","description":"Use when establishing branching strategies, implementing Conventional Commits, creating or reviewing PRs, resolving PR review comments, merging PRs (including CI verification, auto-merge queues, and post-merge cleanup), managing PR review threads, merging PRs with signed commits, handling merge conflicts, verifying a merge didn't silently drop changes, syncing a long-diverged branch (e.g. master into integration), rebasing a long-lived branch onto a moved base or splitting one into several PRs, integrating Git with CI/CD, setting up git hooks (lefthook, captainhook, husky, pre-commit), debugging hook-install failures in git worktrees, or working in a bare-repo plus per-branch-worktree layout, including consolidating a directory that holds both a plain clone and a .bare. The pull-request tooling is GitHub-only; references/pull-request-workflow.md carries the GitLab equivalents. Not for creating releases (use github-release) or diagnosing BLOCKED/won't-merge PRs (use github-project).","allowed-tools":"Bash(git:*) Bash(gh:*) Read Write","compatibility":"Requires uv (runs the scripts on Python 3.11 or later), git, gh CLI; yq for .spec-cleanup.yml. The plugin hook fails closed from Claude Code v2.1.295 (onFailure)."}},"renderedAt":1793665988856}

Git Workflow Skill Not Here Releases: . BLOCKED-PR diagnosis: . GitHub only. Everything below the branching and commit sections — , , the merge gate, review threads — speaks GitHub GraphQL. For a GitLab merge request the equivalent calls are in § GitLab: the same gate with . Critical Rules (Non-Negotiable) 1. No direct push to main — always open a PR. 2. No merge before all threads resolved ( ). 3. No squash unless asked — preserves atomic commits, signatures, bisection. 4. No "tested/verified/working" without pasted command output — else say so. 5. No edits to installed skill/plugin cache pa…