---
name: skill-merge
type: skill
description: "Merge approved pull requests on behalf of the PM agent. Use this whenever running the merge leg, or when a ticket in In Review carries agent:R3-LAY — the R3-LAY label on an In Review ticket is the approval signal, set autonomously by the delivery loop's clean-pass routing of unflagged work, or by Aled on manual approval. Squash-merges the PR, deletes the claude/ branch, moves the ticket to Done, and comments confirmation. On CI failure or merge conflict, moves to Blocked, sets priority Urgent, assigns Aled, and comments the specific issue. Never merges without the R3-LAY approval signal. Part of the PM agent alongside triage and skill-pipeline-qualify."
license: Proprietary — Aled Pritchard workspace use.
---

# merge

The merge leg of the Claude Code PM agent. Executes the physical merge once a ticket carries the
approval signal — `agent:R3-LAY` on an In Review ticket, arrived at either autonomously by the
delivery loop's clean-pass routing of unflagged work, or by Aled's explicit approval of a ticket he
has reviewed. Runs as the delivery loop's merge leg, and is independently pollable. Requires GitHub
write access. Follows `convention-linear` and Pattern A.

## Context

- Processes issues in **In Review** carrying `agent:R3-LAY` — invoked directly as the delivery
  loop's merge leg, and pollable across all delivery projects.
- **The approval signal is `agent:R3-LAY` on an In Review ticket.** It is set in one of two trusted
  ways, and both authorise the merge:
  - **Loop routing (autonomous).** Within `routine-delivery-loop`, an **unflagged, clean, unblocked**
    QA pass routes the ticket to `agent:R3-LAY`. The loop's clean-pass routing *is* the approval
    signal for unflagged work — the merge proceeds **without** a separate `@relay` comment.
  - **Operator approval (manual).** Aled sets `agent:R3-LAY` himself on a ticket he has reviewed,
    with his `@relay` signal — for flagged work, or any ticket he chooses to gate by hand.
- The label is the gate because it is only ever reached in those two ways: the loop sets it solely
  for unflagged, clean, unblocked work, and **flagged work, bounce-cap escalations, QA blocks, and
  merge failures are routed to `human` (the `agent`-group value) and assigned to Aled — they never carry `agent:R3-LAY`,
  so they never reach autonomous merge.** Do not merge an In Review ticket that does not carry
  `agent:R3-LAY`.
- Never touches Pipeline team tickets.
- Repos: clones `claude-ops` (for skill loading) + the delivery repo(s) for GitHub writes.
  `claude-ops` is also a permitted **merge target** when the PR originates from an **os.Claude
  (Apps team) delivery ticket** carrying the `agent:R3-LAY` approval signal.

## Trigger

Process issues in **In Review** carrying `agent:R3-LAY` — the loop hands these to the merge leg, and
a poll finds any others. Skip if none. Note: the label is stored as `R3-LAY` inside the `agent`
group — resolve by that name.

## Behaviour

For each qualifying ticket:

1. **Locate the PR.** Find the linked pull request from the issue's comments or relations. If no PR
   can be found, treat as a merge conflict (see Blocked path below).

2. **Check PR status.** Before merging, verify:
   - **Every box in *both* the `## Acceptance criteria` and `## Definition of done` sections is ticked** (`- [x]`), or struck through with a recorded one-line waiver — this is the Definition of Done (Pattern A). A ticket carrying the `agent:R3-LAY` approval signal but with an unticked, unwaived box in *either* section is **not ready**: do not merge it (see the Not-ready path below).
   - All required CI checks have **concluded** and passed (or there are no required checks).
   - **The test suite actually ran on this branch — not merely a green aggregate.** GitHub's "all checks passed" summary is green both when the suite ran and passed *and* when the suite never triggered on the branch (e.g. the branch predates the workflow, or a path/branch filter skipped it). A never-run suite reads as green but has verified nothing. Before an **autonomous** merge (the loop's clean-pass route, where no human has eyeballed CI), confirm the expected required checks are actually **present and concluded** on the head commit — not just that the aggregate is green with the suite absent. If the expected suite is missing from the head commit's checks, treat it as a non-passing CI state: take the Blocked path, do not merge. (Worked case: A1-72, "all checks passed" while the Playwright suite never ran because the branch predated the workflow — a false green that would have auto-merged unverified work, 2026-06-30.) A manual operator approval (`@relay`) carries Aled's own eyes on CI, so this present-and-concluded check is the autonomous-merge guard specifically.
   - There are no unresolved merge conflicts.
   - The branch is mergeable.

   **Not-ready path — unticked acceptance box.** If any acceptance box is unticked and unwaived, do not merge and do not treat it as a CI/conflict failure. Leave the ticket **In Review**, post a comment @mentioning Aled naming the unticked box(es) and that the DoD is not yet met, and assign Aled. Leave the `agent:R3-LAY` label and the PR in place — this is a definition-of-done gap for the operator or a bounce to exec, not a merge failure.

   **CI-wait — poll in-progress checks to a conclusion through the GitHub MCP, never `gh`/curl.** When
   required checks are still queued or running (not failed), do not decide immediately and do not
   guess a fixed delay. **This environment has no working `gh` CLI and blocks raw `curl`/API-token
   HTTP to the GitHub REST API (the egress proxy 403s it, silently, so a `curl -s` poll misreads the
   empty body as "checks passed" — a false green); only the `mcp__github__*` tools have real access.**
   So the canonical CI-wait here is a **bounded, `date`-driven bash delay that alternates a sleep with
   an MCP check** — re-query the head commit's check state via `mcp__github__pull_request_read`
   (method `get_check_runs`) each iteration, inside an overall time cap:

   ```
   end=$(( $(date +%s) + 600 ))
   until [ $(date +%s) -ge $end ]; do sleep 20; done   # one wait interval; re-query check_runs via the MCP between waits
   ```

   Gate the loop on the **MCP check result**, never on a network call that can itself be blocked
   (that is what produced the false green), and never on a bare `sleep N` (the harness blocks a
   standalone sleep — the condition-based `date` delay is the working substitute). This supersedes
   the earlier `gh pr checks <pr> --watch` example, which does not run in this environment. On a clean
   conclusion within the window, take the happy path; only if checks are still unconcluded when the
   timeout elapses does the Blocked path apply. The full pattern and its failure modes are canon in
   `convention-github` *Reading CI / PR state in autonomous runs*. (Worked cases: A1-78 merge leg — a
   guessed background timer is fragile when CI runs faster or slower than the guess, 2026-06-30; PR #49
   Playwright check reported green when it had not run, APP-450; the CI-wait clunkiness re-surfaced on
   the meirionpritchard-com loop of A1-150/151/154/152, 2026-07-07, APP-458.)

3. **Happy path — merge.**
   - **Squash merge** the PR into main (or the repo default branch). Squash message:
     [Ticket identifier] [Ticket title] (#PR number) — e.g. APP-104 Fix fitness tracker sync (#12).
   - **Delete** the claude/-prefixed branch after a successful merge — but first check it still
     exists on the remote (e.g. `git ls-remote --exit-code origin <branch>`). GitHub's
     "auto-delete head branches" repo setting may have already removed it on merge; if the branch
     is gone, the goal is already met — skip the delete rather than run `git push origin --delete`,
     which would otherwise fail noisily with "remote ref does not exist". If it still exists, delete
     as normal.
   - Move the Linear ticket to **Done**.
   - Evict `agent:R3-LAY` (remove the label).
   - Add a comment: "Merged: PR #[number] squash-merged into main. Branch deleted. ✓" — or, when
     GitHub had already removed it, "Branch auto-deleted by GitHub. ✓"

4. **Unblock dependents.** After a successful merge, query for tickets in **Blocked** that list the just-merged ticket as a blocker. For each:
   - Check whether any other blockers remain unresolved (i.e. not Done).
   - If no remaining blockers, move the ticket to **Todo** and comment: "Unblocked — [ticket identifier] has merged."
   - If other blockers remain, leave the ticket in Blocked (do not comment).

5. **Blocked path — CI failure or conflict.**
   If required CI checks are failing, checks are still unconcluded after the CI-wait window (step 2),
   or there is a merge conflict:
   - Move the ticket to **Blocked**.
   - Set priority **1 (Urgent)**.
   - Set `human` (the `agent`-group value; evicts `agent:R3-LAY`).
   - Assign Aled.
   - Add a comment @mentioning Aled that lists:
     - Which checks are failing (by name), or that checks did not conclude in the window, or that a conflict exists
     - The PR link
     - What is needed to unblock
   - Do **not** merge. Do not close the PR. Leave the branch in place.

## Guardrails

- Never merges without the `agent:R3-LAY` approval signal on an In Review ticket — set by the loop's
  clean-pass routing of unflagged work, or by Aled's manual approval (see Context for both routes).
- The loop's autonomous merge applies to **unflagged, clean, unblocked work only**. Flagged work,
  bounce-cap escalations, QA blocks, and merge failures never carry `agent:R3-LAY` — they stop for
  Aled, so the human gate is preserved exactly where it matters.
- Never force-merges — Blocked path applies every time CI fails, CI does not conclude in the
  CI-wait window, or conflicts block.
- **Never auto-merges on a false green.** For an autonomous (loop clean-pass) merge, verify the
  expected test suite is actually present and concluded on the head commit, not merely that GitHub's
  aggregate is green with the suite never having run. A missing-but-required suite is a non-passing
  CI state → Blocked path. This guard is what keeps the clean-pass auto-merge safe: a green that
  verified nothing must never ship unattended.
- **Read all CI / PR state through the GitHub MCP, never `gh`/curl.** The CI-wait polls
  `mcp__github__pull_request_read` (`get_check_runs`) between `date`-bounded sleeps; a raw `curl`/API
  poll is egress-blocked and silently misreads the 403 body as green (`convention-github`). The
  `gh pr checks --watch` primitive does not exist in this environment — do not reach for it.
- Never merges with an unticked, unwaived box in *either* the `## Acceptance criteria` or
  `## Definition of done` section — the Definition of Done is every box in both sections
  ticked (Pattern A). An unticked box takes the Not-ready path (leave In Review, surface to Aled),
  not the Blocked path.
- Squash only. Done is final. Delivery repos only. `claude-ops` is additionally permitted as a
  merge target for os.Claude (Apps) delivery tickets on the `agent:R3-LAY` approval signal.
  Never touches Pipeline. Never merges `claude-ops` PRs without that signal.
- Branch cleanup is idempotent: a branch already removed by GitHub's auto-delete is success, not
  an error to retry.
- Fresh session each run. All state lives in Linear and GitHub, not the agent.

## Setup

- Runs as the merge leg of `routine-delivery-loop` (Claude Code cloud), and is independently pollable.
- Connectors: Linear + GitHub (Contents: read & write; Pull requests: read & write on delivery repos).
- Repos: delivery repo(s) + the `claude-ops` clone (for skill loading).

On finish, run skill-ops-retro capture on this run's friction: file a FRICTION note to the Pulse queue for the drain pass to assess.
