approve-merge · git:20261008.3d1669c · 2026-10-08 · sha256 a4a8dbb89b0a32b5
approve-merge git:20261008.3d1669cA
Immutable. This exact content never changes and is served at /api/v1/blob/a4a8dbb89b0a32b5.
---
name: approve-merge
description: Record per-PR CEO approval and merge in one turn. ONLY on an explicit per-PR "approved" — never on umbrella "go".
disable-model-invocation: true
argument-hint: "<pr-number> [--no-merge]"
effort: low
---
## Writing rule
When this skill writes a durable artifact, read .claude/rules/writing-standard.md. Use the controlled technical writing profile.
# /approve-merge — Record CEO Approval and Merge
Writes a structured marker at `.claude/session/reviews/<owner>__<repo>__<pr>-ceo.approved` (repo-qualified path, see AgDR-0060), then runs the merge (`gh pr merge <pr> --squash --delete-branch`, or the `glab mr merge` equivalent on a GitLab-forge project) in the same turn via `tracker_pr_merge` — the tracker-agnostic merge adapter in `_lib-tracker.sh` (#759, mirrors `tracker_review_submit` from #758). The marker contains required key/value fields (not just a bare SHA) so a raw `echo SHA > file` from the model is mechanically rejected by `block-unreviewed-merge.sh`.
This is the **mechanical enforcement** of the "plan-level 'go' is not merge approval" rule in `.claude/rules/pr-workflow.md`. The load-bearing semantic is "every merge needs an explicit per-PR approval", **not** "every merge needs two user messages."
## The one rule you must not break
**INVOKE THIS SKILL ONLY ON EXPLICIT, PER-PR, USER-NAMED MERGE APPROVAL.**
The valid invocation triggers look like this:
- "approved" / "approve" / "merge" / "merge it" / "ship it" / "go ahead and merge" — **if and only if** the surrounding context clearly names a specific PR and the PR being asked about is known.
- "PR #42 is approved" / "yes, merge #42" / "ship #42" — names the PR.
- A reply to your own "Ready to merge PR #42 — approved?" message that consists of any affirmative token — because you just named the PR and the user is responding to that specific question.
**Invalid triggers** (do NOT run this skill):
- "go" / "continue" / "proceed" / "execute the plan" / "ship it" — **when these are said in response to a plan that happens to include a merge step but is not specifically about the merge**. This is the exact failure mode this skill exists to prevent. See the example in `.claude/rules/pr-workflow.md` § "Plan-level 'go' is NOT merge approval".
- "yes" / "ok" / "sure" — if you cannot point at a specific "Ready to merge PR #X?" question in the last two turns of conversation, these are too ambiguous.
- Your own inference that "the user probably wants the merge now because they said 'go' on the plan." NO. Stop and ask explicitly.
**If in doubt: STOP AND ASK.** The cost of one extra "PR #X ready — approved?" question is one message. The cost of a wrong merge is real work to revert.
The fact that this skill now runs the merge as part of its default flow does **not** weaken this rule — it sharpens it. The invocation moment IS the merge moment; you don't get a free second-message safety net to rethink. Invoke only when you're certain.
## Process
### 0. Resolve the configured approver display title (SOFT, prose-only)
Before addressing the user in any confirmation question or report, read the configured display title for the human per-PR merge approver:
```bash
source "$(git rev-parse --show-toplevel)/.claude/hooks/_lib-read-config.sh"
APPROVER_TITLE=$(config_get_or '.review_markers.human_approver_title' 'CEO')
```
Use `$APPROVER_TITLE` (default `"CEO"`) whenever you address the human approver in prose — e.g. "PR #X is ready to merge. Just confirming — explicit approval to merge PR #X, now, ${APPROVER_TITLE}?" or a status line naming the approver.
**This is DISPLAY ONLY and SOFT (prose-level), not mechanically enforced.** It changes nothing about:
- the marker filename (`<owner>__<repo>__<pr>-ceo.approved` — always `-ceo`, never renamed)
- the structured marker fields (`sha=`, `approved_by=user`, `skill_version=`) written in step 5 — write them exactly as documented below, regardless of the configured title
- `block-unreviewed-merge.sh`'s gate logic — it reads the same marker and fields whether the configured title is "CEO", "Maintainer", or anything else
If `_lib-read-config.sh` or `jq` is unavailable, `config_get_or` degrades to the literal fallback `'CEO'` — never let a config-read failure block the rest of this skill.
### 1. Parse the PR number, the repo, and flags
Extract the PR number from `$ARGUMENTS`. If no number is given, try to infer from:
- The current branch's open PR via `gh pr view --json number --jq '.number'`
- The user's most recent message, if it named a PR explicitly
If the PR number is ambiguous (multiple PRs on the branch, unclear which was approved), STOP and ask the user which PR.
**Also resolve the repo (`REPO`).** Accept a fully-qualified `owner/repo#N` form, or an explicit `owner/repo` token in `$ARGUMENTS`. When neither was given, fall back to the CURRENT checkout's own remote (`git remote get-url origin`, parsed to `owner/repo`) — a deterministic, non-ambient source of truth. Do **NOT** resolve this via an unscoped `gh pr view <pr> --json headRepository`: that call reads the wrong field (the PR's head/fork, not something to key markers on) and is itself an ambient-resolved gh query that can silently prefer the wrong repo in a fork checkout (me2resh/apexyard#887). Every `gh pr view` call below must pass `--repo "$REPO"`.
Recognise the optional `--no-merge` flag. When present, the skill writes the marker but does NOT run the merge. Useful for the rare cases below — see § "Notes" for when to use it.
### 2. Sanity-check the user's intent
Before doing anything, re-read the user's most recent message:
- Did the user explicitly name this PR, or can I point at a direct "Ready to merge PR #X — approved?" question from me that they are responding to?
- Is the user's message a standalone merge nod, or is it an umbrella "go" on a broader plan?
- If the latter — **STOP**. Reply with a per-PR explicit question instead:
> "PR #X is ready to merge. Just confirming — explicit approval to merge PR #X, now?"
Only proceed past this step if the user has given an unambiguous per-PR approval.
### 3. Verify the PR state
```bash
gh pr view <pr> --repo "$REPO" --json state,isDraft,mergeable,headRefOid,mergeStateStatus,baseRefName
```
Sanity checks:
- `state` must be `OPEN`. Refuse if it's `MERGED`, `CLOSED`, or `DRAFT`.
- `mergeable` should be `MERGEABLE` or `UNKNOWN` (GitHub hasn't computed yet). Refuse on `CONFLICTING`.
- Capture `headRefOid` — this is the **PR's HEAD on GitHub**, which is the SHA both markers must match. Don't use `git rev-parse HEAD` from the local working tree — it's rarely the PR branch and the merge gate compares against the GitHub-reported HEAD.
- Capture `baseRefName` for step 3a below. Step 3a does NOT use
`mergeStateStatus` to decide "behind" — see step 3a for why.
### 3a. Stop if the PR is behind its base branch (me2resh/apexyard#1386, `merge.require_up_to_date`)
A merge queue creates a race: PR A merges to the base branch first, and PR B's
last CI run still reflects the old base.
First resolve the **ops fork root**, not git toplevel. Inside
`workspace/<project>/`, git toplevel is the project clone, and
`_lib-read-config.sh` and `_lib-merge-behind.sh` do not exist there
(me2resh/apexyard#229, #230). Step 4 below reuses this same `MARKER_HOME` —
resolve it once, here:
```bash
# Resolve the OPS FORK ROOT the same way the merge gate does
# (_lib-ops-root.sh::resolve_ops_root). Pin-first, then a fallback walk-up.
# The session pin points at the real ops fork even from a workspace clone;
# a plain walk-up resolves to the private portfolio sibling in
# split-portfolio mode (it has onboarding.yaml + apexyard.projects.yaml)
# where these libs don't exist (me2resh/apexyard#559).
OPS_ROOT=""
PIN_FILE="${APEXYARD_OPS_PIN_DIR:-$HOME/.claude/apexyard}/ops-root-${CLAUDE_CODE_SESSION_ID:-}"
if [ -z "${APEXYARD_OPS_DISABLE_PIN:-}" ] && [ -n "${CLAUDE_CODE_SESSION_ID:-}" ] && [ -f "$PIN_FILE" ]; then
IFS= read -r OPS_ROOT < "$PIN_FILE" || OPS_ROOT=""
fi
# Validate the pin (self-heal a stale one): must satisfy a fork anchor.
if [ -n "$OPS_ROOT" ] && [ ! -f "$OPS_ROOT/.apexyard-fork" ] && \
{ [ ! -f "$OPS_ROOT/onboarding.yaml" ] || [ ! -f "$OPS_ROOT/apexyard.projects.yaml" ]; }; then
OPS_ROOT=""
fi
# Fallback: walk up from git toplevel (pre-#381 behaviour, safety net).
if [ -z "$OPS_ROOT" ]; then
r=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
while [ -n "$r" ] && [ "$r" != "/" ]; do
if [ -f "$r/.apexyard-fork" ] || { [ -f "$r/onboarding.yaml" ] && [ -f "$r/apexyard.projects.yaml" ]; }; then
OPS_ROOT="$r"; break
fi
r=$(dirname "$r")
done
fi
MARKER_HOME="${OPS_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
```
Load the config library from `$MARKER_HOME`, not git toplevel. Stop if it
fails to load — a load failure is not evidence the check is off:
```bash
if ! source "$MARKER_HOME/.claude/hooks/_lib-read-config.sh"; then
echo "Cannot load _lib-read-config.sh from $MARKER_HOME. Stopping — the behind-base check cannot run." >&2
exit 1
fi
REQUIRE_UP_TO_DATE=$(config_get_or '.merge.require_up_to_date' 'true')
```
**Treat any value other than the literal `false` as on.** An empty value, an
unset variable, or a config key that is missing are all "on", not "off" — the
check defaults to on, and only an explicit `false` turns it off.
**Do not decide "behind" from `mergeStateStatus`.** GitHub only reports
`mergeStateStatus=BEHIND` when the base branch's ruleset has
`strict_required_status_checks_policy=true`. That policy is off by default,
and off on this repo's own `dev` ruleset — so a PR that is genuinely behind
its base reports `BLOCKED`, `CLEAN`, or `UNKNOWN` instead, and a check that
reads `mergeStateStatus == BEHIND` alone never fires on the case it exists
to catch.
Unless `$REQUIRE_UP_TO_DATE` is the literal `false`, compute "behind" directly
from the compare API instead, using `$baseRefName` and `$headRefOid` already
captured in step 3. Load `_lib-merge-behind.sh` from `$MARKER_HOME` too, and
stop if it fails to load:
```bash
if ! source "$MARKER_HOME/.claude/hooks/_lib-merge-behind.sh"; then
echo "Cannot load _lib-merge-behind.sh from $MARKER_HOME. Stopping — the behind-base check cannot run." >&2
exit 1
fi
BEHIND=$(is_pr_behind_base "$REPO" "<baseRefName>" "<headRefOid>")
```
**Stop on any `$BEHIND` value other than the literal `false`.** This includes
`true`, `unknown`, an empty value, and an unset variable — none of those are
evidence the PR is up to date.
- **`false`** — proceed to step 4.
- **`true`** — before stopping, check whether the refresh is actually
needed (me2resh/apexyard#1437). `is_pr_behind_base` only says the base
moved — it says nothing about whether the base's new commits touch
anything this PR could be affected by. When they don't, refreshing buys
no protection: no file the base changed can break a PR that shares none
of those files.
```bash
# BASE_MERGE_COMMIT_SHA: .merge_base_commit.sha from the SAME compare
# call is_pr_behind_base made — do not make a second, possibly
# inconsistent, compare call. Re-read it explicitly:
BASE_MERGE_COMMIT_SHA=$(gh api "repos/${REPO}/compare/<baseRefName>...<headRefOid>" -q '.merge_base_commit.sha' 2>/dev/null)
# config_get (not config_get_or) for the multi-line array filter — same
# convention as .branch.type_whitelist[] elsewhere in this codebase.
SHARED_PATTERNS=$(config_get '.merge.shared_file_patterns[]' 2>/dev/null)
REFRESH=$(merge_refresh_required "$REPO" "<baseRefName>" <pr> "$BASE_MERGE_COMMIT_SHA" "$SHARED_PATTERNS")
```
`merge_refresh_required` (`_lib-merge-behind.sh`) echoes `required` or
`skippable`. It fails closed to `required` on anything it cannot fully
verify: a failed `gh api` call, a truncated compare (300 files or more on
either side), an empty `BASE_MERGE_COMMIT_SHA`, or a base commit set that
touches a file the PR itself changed OR a file matching
`merge.shared_file_patterns` (default: `.claude/hooks/_lib-*.sh`,
`.claude/settings.json`, `.claude/project-config.defaults.json`,
`bin/run-pre-push-checks.sh`, `.githooks/*`, `.github/workflows/*`).
- **`skippable`** — the base moved, but touches nothing this PR shares.
Proceed to step 4 on the CURRENT `<headRefOid>` — no branch update, no
re-review, no new CI run. Tell the user the refresh was skipped and
name the reason (behind by N commits, none overlapping this PR's files
or the shared set).
- **`required`** — **stop here**, the same as before #1437. Do not verify
the Rex marker, do not write the CEO marker, and do not merge. Tell the
user:
```
PR #<pr> is behind its base branch (<baseRefName>) and the base's new
commits touch a file this PR shares. Before this can merge:
1. Update the branch: gh pr update-branch <pr> --repo <owner/repo>
2. Wait for green CI on the updated branch.
3. Get a short Rex re-review of the new merge commit — OR, if the
merge is a clean replay of the base with no conflicts
(`rex_approval_carries_over` returns true: forge-verified
two-parent merge whose tree matches an isolated
`git merge-tree --write-tree` of the parents), the merge gate
carries the existing Rex approval forward on its own. See
block-unreviewed-merge.sh's rex_approval_carries_over check
(me2resh/apexyard#1437, hardened in #1456) — no marker to write
by hand either way.
4. Run /approve-merge <pr> again.
```
Use `AskUserQuestion` to ask who runs step 1. Put `Run it yourself
(Recommended)` first. Its description says the operator runs the shown
command. Put `Approve me to run it` second. Its description says the
agent pushes the branch update. If the harness lacks `AskUserQuestion`,
present these two choices as a numbered list. Do not update the branch
before the operator chooses. Updating the branch pushes a merge commit
to the PR's head branch. On a fork PR with maintainer edits that
branch belongs to the contributor. The update is a separate, visible
action the user should see happen, not one this skill takes on its
own.
- **`unknown`, empty, or unset** — the compare call failed, or an argument
was empty. **Stop here, the same as `true`.** Do not verify the Rex marker,
do not write the CEO marker, and do not merge. A failed check is not
evidence the PR is up to date. Tell the user:
```
PR #<pr>'s behind-base check could not run (the compare API call failed).
This can be a network or auth issue. Before this can merge:
1. Retry the check, or verify manually whether <baseRefName> has commits
this PR's branch does not.
2. Run /approve-merge <pr> again once you know the PR's state.
```
This check does not change `block-unreviewed-merge.sh` — it stops the merge
one step earlier, in this skill, before any marker is touched.
### 4. Verify the Rex marker exists at the PR's HEAD
The CEO approval is a stamp on top of a Rex-approved HEAD, not a standalone action.
```bash
# MARKER_HOME was already resolved in step 3a above — reuse it here, do not
# re-derive it from git toplevel (me2resh/apexyard#229, #230).
# Source the marker path helper — repo-qualified naming (#485, AgDR-0060).
# shellcheck source=/dev/null
. "$MARKER_HOME/.claude/hooks/_lib-review-markers.sh"
# The PR's BASE (host) repo — the canonical marker key AND the repo you merge
# against. Rex wrote its marker under the BASE (that is what code-reviewer.md and
# the merge gate use, #765), so read + write markers here. `pr_base_repo` REQUIRES
# the $REPO resolved in step 1 (the repo you already know hosts this PR) and
# scopes its own gh query to it — never gh's ambient/parent-preferring default
# (#887) — so same-repo PRs still resolve unchanged. Use $PR_HOST_REPO as
# `--repo` for EVERY gh call in this skill (`<owner/repo>` throughout =
# $PR_HOST_REPO) — you cannot merge a fork's copy.
PR_HOST_REPO=$(pr_base_repo <pr> "$REPO")
REX=$(review_marker_path "$PR_HOST_REPO" <pr> rex "$MARKER_HOME")
```
If the Rex marker's SHA already equals `<headRefOid from step 3>`, proceed to step 5.
**If the marker exists but its SHA does NOT match HEAD, do not immediately refuse** (me2resh/apexyard#1437 round-2 review, Rex 2). Check the SAME carry-over the merge gate checks, using the SAME function — a skill that refuses here while the gate would accept the merge anyway is a dead end, not a safety margin:
```bash
# _lib-merge-behind.sh was already sourced in step 3a above.
REX_SHA=$(tr -d '[:space:]' < "$REX")
CARRY_OVER="false"
if [ -n "$REX_SHA" ] && command -v rex_approval_carries_over >/dev/null 2>&1; then
CARRY_OVER=$(rex_approval_carries_over "$PR_HOST_REPO" "$REX_SHA" "<headRefOid from step 3>" "<baseRefName from step 3>")
fi
```
- **`true`** — the marker carries over. Proceed to step 5 exactly as if the SHA had matched (write the CEO marker against `<headRefOid from step 3>`, the CURRENT head — never the old, Rex-reviewed SHA).
- Anything else (`false`, `unknown`) — refuse. Tell the user Rex's marker is stale and ask them to re-invoke the code-reviewer on the current HEAD. Do not write the CEO marker on a stale base.
Never write a marker yourself to force this check to pass. `rex_approval_carries_over` is read-only — it decides, you read its answer.
**On a MISSING marker, check for the gate-invisible near-miss before reporting it (me2resh/apexyard#1144).** A reviewer handed a literal marker path in its spawn prompt writes the bare-number form instead of the repo-qualified one. That file is read by no gate, but `ls .claude/session/reviews/` makes it look like a perfectly good approval — so "marker missing" is true of the path you looked at and false of what the operator can see on disk. Name the discrepancy:
```bash
NEAR_MISS=$(unqualified_marker_path "$MARKER_HOME" <pr> rex)
if [ ! -f "$REX" ] && [ -f "$NEAR_MISS" ]; then
# Refuse, and say WHY it isn't the marker:
# found: $NEAR_MISS (bare-number — no gate reads this)
# expected: $REX (repo-qualified, AgDR-0060)
# Then state the recovery explicitly, because the wrong one is the obvious one.
fi
```
Tell the user, in these terms: **do not move, rename, or copy that file into place.** Relocating a file to satisfy a gate records a review this session cannot vouch for — the behaviour `.claude/rules/pr-workflow.md` § "Build agents cannot self-review" exists to prevent. The correct recovery is `rm` the gate-invisible file and re-run `/code-review <pr>`, passing it no marker path.
This is the same diagnosis `block-unreviewed-merge.sh` now prints via `unqualified_marker_hint`; surfacing it here means the operator sees it at `/approve-merge` time rather than one failed merge later.
### 5. Write the structured CEO marker
The marker is a key/value file with required fields. The format:
```
sha=<40-char hex — must be the PR HEAD from step 3>
approved_by=user
approved_at=<ISO-8601 UTC timestamp, e.g. 2026-05-03T13:25:42Z>
skill_version=2
approval_summary="<truncated user approval message, ≤200 chars>"
```
Required fields the merge gate verifies:
| Field | Why |
|-------|-----|
| `sha=<HEAD>` | Binds the approval to a specific commit. SHA must match the PR's GitHub HEAD. |
| `approved_by=user` | Marker that distinguishes a skill-written marker from a model-fabricated raw `echo SHA > file`. |
| `skill_version=2` (or higher) | Format version. Bare-SHA legacy markers (no `skill_version=`) are rejected by the new gate. Version bump signals a behaviour change to anyone reading the file. |
Optional fields the gate stores but doesn't validate:
| Field | Use |
|-------|-----|
| `approved_at=<ISO>` | Audit-log timestamp. Helpful when reviewing past merges. |
| `approval_summary=<text>` | First ≤200 chars of the user's approval message, sanitised (no shell metachars). Audit trail for "what did the user say when they approved this." |
Use the **ops fork root** as the path anchor (NOT git toplevel — see #229 + #230 for the workspace-clone bug this avoids). Reuse the same MARKER_HOME and the `_lib-review-markers.sh` helper (already sourced in step 4):
```bash
# (MARKER_HOME and PR_HOST_REPO already resolved in step 4 — reuse them here.)
mkdir -p "$MARKER_HOME/.claude/session/reviews"
ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
# Sanitise: drop newlines, drop shell-special chars, truncate to 200.
summary=$(echo "<user approval message>" | tr '\n' ' ' | tr -d '"`$\\' | cut -c1-200)
# CEO marker keyed on the BASE repo — same key as Rex's marker and the gate's
# lookup (#765). Keying it on the fork would leave a cross-fork merge blocked.
CEO=$(review_marker_path "$PR_HOST_REPO" <pr> ceo "$MARKER_HOME")
cat > "$CEO" <<EOF
sha=<headRefOid>
approved_by=user
approved_at=${ts}
skill_version=2
approval_summary="${summary}"
EOF
```
### 6. Determine merge strategy and release metadata
Before running the merge, check whether this is a **sync-class PR**. A PR is sync-class if either:
- Its head branch matches `sync/main-to-dev-after-*` (the canonical `/release-sync` branch prefix), OR
- Its head branch matches `/update`'s `chore/(#<TICKET>-)?sync-upstream-apexyard` or `chore/(#<TICKET>-)?sync-upstream-dev` convention, OR
- Its PR title starts with `sync(` (the canonical `/release-sync` PR title prefix), OR
- Its PR title starts with `chore(` or `chore:` and says `sync ops fork with upstream` (the `/update` PR shape)
Run this entire block in one shell. The release subject and body file are
intentionally derived immediately before `tracker_pr_merge`; a separate code
block is not a shell scope and must not carry either value (#1196).
```bash
PR_HEAD_BRANCH=$(gh pr view <pr> --repo "$PR_HOST_REPO" --json headRefName -q '.headRefName' 2>/dev/null)
PR_TITLE=$(gh pr view <pr> --repo "$PR_HOST_REPO" --json title -q '.title' 2>/dev/null)
MERGE_STRATEGY="squash" # default for all other PRs — bare enum, not a CLI flag (tracker_pr_merge normalises it per-forge)
if echo "$PR_HEAD_BRANCH" | grep -qE '^(sync/main-to-dev-after-|chore/(#[^/]+-)?sync-upstream-(apexyard|dev)$)' || \
echo "$PR_TITLE" | grep -qE '^sync\(' || \
echo "$PR_TITLE" | grep -qE '^chore(\([^)]*\))?: sync ops fork with upstream'; then
MERGE_STRATEGY="merge"
fi
# Next, check whether this is a release-class PR — the sibling special-case
# for /release (#1136, AgDR-0132). A release-class PR has a head branch that
# matches release/v[0-9]+.[0-9]+.[0-9]+, or a title that starts with release(.
RELEASE_SUBJECT=""
RELEASE_BODY_FILE=""
if echo "$PR_HEAD_BRANCH" | grep -qE '^release/v[0-9]+\.[0-9]+\.[0-9]+$' || \
echo "$PR_TITLE" | grep -qE '^release\('; then
# Rex M1: guard the TITLE read too, not only the body read. $PR_TITLE comes
# from a `gh pr view` that swallows its own errors, so a transient failure
# leaves it empty while the branch match still fires. Refuse rather than
# merge a release PR with a gh-defaulted subject.
if [ -z "$PR_TITLE" ]; then
echo "ERROR: could not read the release PR's title." >&2
echo "Refusing to merge — retry /approve-merge once gh responds." >&2
exit 1
fi
RELEASE_SUBJECT="$PR_TITLE"
RELEASE_BODY_FILE=$(mktemp)
if ! gh pr view <pr> --repo "$PR_HOST_REPO" --json body -q '.body' > "$RELEASE_BODY_FILE" 2>/dev/null \
|| [ ! -s "$RELEASE_BODY_FILE" ]; then
echo "ERROR: could not read the release PR's body." >&2
echo "Refusing to merge with a bare squash — that would drop the" >&2
echo "Released-From trailer (#1136). Read the PR body manually," >&2
echo "confirm it ends in the trailer, then retry." >&2
rm -f "$RELEASE_BODY_FILE"
exit 1
fi
fi
# _lib-tracker.sh lives alongside _lib-review-markers.sh, already sourced in
# step 4 from $MARKER_HOME (the ops fork root, not necessarily git toplevel).
# shellcheck source=/dev/null
. "$MARKER_HOME/.claude/hooks/_lib-tracker.sh"
# tracker_pr_merge MUST be invoked as its own bare, top-level statement —
# NEVER wrapped in `$(...)` / backticks. The merge-gate hooks fire off a
# `Bash(tracker_pr_merge *)` matcher (#759) on the Bash tool's raw command
# text; whether that matcher recognises a command-substitution-wrapped
# invocation (`X=$(tracker_pr_merge ...)`) is unverified, and a merge gate
# is not something to leave to an unverified assumption — a `$(...)`
# substitution runs its content in a subshell, a materially different
# construct from a plain sequential statement, so treat the two as NOT
# equivalent for matcher purposes. Redirect the JSON result to a temp file
# instead, and read it back in a separate step — `cat` isn't a merge
# command, so wrapping THAT in `$(...)` is fine.
#
# The repo argument is $PR_HOST_REPO — the PR's BASE repo (#765). On a cross-fork
# PR you cannot merge the fork's copy; the merge, like every other host call in
# this skill, must target the base (`<owner/repo>` throughout = $PR_HOST_REPO).
MERGE_RESULT_FILE=$(mktemp)
tracker_pr_merge "$PR_HOST_REPO" "<pr>" "${MERGE_STRATEGY}" true "$RELEASE_SUBJECT" "$RELEASE_BODY_FILE" > "$MERGE_RESULT_FILE"
MERGE_RC=$?
MERGE_RESULT="$(cat "$MERGE_RESULT_FILE")"
MERGE_SHA=$(printf '%s' "$MERGE_RESULT" | jq -r '.sha // empty' 2>/dev/null)
rm -f "$MERGE_RESULT_FILE" "$RELEASE_BODY_FILE"
```
Sync-class detection stays on `gh pr view` deliberately — sync PRs are a `/release-sync` concept, and `/release-sync` only ever runs against the `gh`-hosted apexyard framework fork itself, never a downstream GitLab-forge managed project. There's nothing to make forge-aware here.
**Why auto-detect instead of a flag:** a `--merge-strategy` flag would require the operator to remember to pass it on every sync PR merge. Sync PRs squashed silently — the v2.2.0 incident — show that operator ceremony is not a reliable safeguard. Auto-detection makes the correct behaviour the default; an operator who wants to override can do so via the CLI directly. See `AgDR-0053`.
**Why `merge` (not `squash`) for sync PRs:** the sync branch's top commit is a true two-parent merge commit (branch = dev, second parent = main's release squash). That two-parent relationship is the ancestry link that makes future `dev → main` release PRs conflict-free. Squash-merging discards the second parent permanently, defeating the skill's entire purpose. See `AgDR-0053`.
**Why release metadata is inline:** this repo has `squash_merge_commit_message=COMMIT_MESSAGES` — GitHub's default squash body concatenates every commit message on the PR branch. A release branch is cut from `dev`, so from `main`'s perspective it "contains" the entire dev↔main divergence (hundreds of commits); a bare `gh pr merge --squash` buries the release commit's `Released-From` trailer mid-body, where `%(trailers:...)` can no longer see it. `/release` Rule 11 already prescribes the fix for its own manual merge step (an explicit `--subject`/`--body-file`); this block makes `/approve-merge` — the mandated human-only merge path since #1042 — apply the same fix automatically, so the two skills stop conflicting. See `AgDR-0132`.
**Fail-safe, not fallback:** if the PR body can't be read (network/auth failure, empty body), the block STOPS the merge rather than silently degrading to a bare squash — a silent degrade here is exactly how #1136 happened. Keeping its creation and use in this one block also prevents a lost shell variable from bypassing that check (#1196). Fix the read failure and retry `/approve-merge`; the CEO marker written in step 5 is unaffected and does not need to be re-approved.
### 7. Process the merge result — DEFAULT FLOW
Unless `--no-merge` was passed, the preceding block runs the merge in the same turn via the tracker-agnostic adapter — `tracker_pr_merge` in `_lib-tracker.sh` (#759, the same kind-dispatch pattern `tracker_review_submit` uses for review submission, #758) — using the strategy and release metadata it determined.
`$RELEASE_SUBJECT` and `$RELEASE_BODY_FILE` are empty strings for every non-release PR, so the invocation remains the pre-#1136 bare squash/merge/rebase with no `--subject`/`--body-file` for those PRs. `tracker_pr_merge` treats a `""` body_file the same as an omitted one (see `_lib-tracker.sh`'s fail-safe check, which only fires when `body_file` is non-empty).
`tracker_pr_merge` dispatches on the project's `tracker_kind <owner/repo>` (the same per-project resolution `tracker_review_submit` and `tracker_create` use): a `gh`-kind project runs `gh pr merge <pr> --repo <owner/repo> --squash|--merge|--rebase --delete-branch`; a `glab`-kind project runs the `glab mr merge` equivalent (`--squash`/`--rebase`/no-flag-for-a-plain-merge, `--remove-source-branch`). **Note what actually gates this call:** the `gh`/`glab` command above runs *inside* `_lib-tracker.sh`, a sourced shell function — the merge-gate hooks (`block-unreviewed-merge.sh`, `block-merge-on-red-ci.sh`, `require-design-review-for-ui.sh`, `require-architecture-review.sh`) match the OUTER Bash command text this step actually submits (the `tracker_pr_merge "<owner/repo>" "<pr>" "${MERGE_STRATEGY}" true > "$MERGE_RESULT_FILE"` line above), and that text never literally contains `gh pr merge` or `glab mr merge` — those strings live inside already-sourced library code, not in this step's command. So the wrapper call itself is a dedicated, gate-recognised merge shape in its own right: `is_merge_command` and the PR/repo extractors in `_lib-extract-pr.sh` have a `tracker_pr_merge <owner/repo> <pr> ...` branch (#759), and `settings.json` carries a matching `Bash(tracker_pr_merge *)` matcher for all four hooks, alongside the existing `gh`/`glab` matchers (#764/#767/#793). The gates fire on the wrapper form directly — not by recognising the inner CLI command it happens to run, and ONLY when that form is issued as the bare top-level statement shown above — never inside a `$(...)`.
The `block-unreviewed-merge.sh` hook also includes a guard that refuses `--squash` or `--rebase` on sync-class PRs, including both `/release-sync` and `/update` branch shapes. A direct merge command is therefore blocked when it would discard the ancestry link. If anything else is wrong, `MERGE_RC` is non-zero and the failure message is the same one the user would see running the underlying CLI directly. The CEO marker stays on disk so the user can retry the merge after fixing the cause without re-approving.
On success (`MERGE_RC` = 0), `MERGE_SHA` already carries the merge commit SHA — `tracker_pr_merge` resolves it itself (gh: `gh pr view --json mergeCommit`; glab: `glab mr view --output json` → `.merge_commit_sha` / `.squash_commit_sha`), so no separate reporting call is needed.
`MERGE_RC` = 3 means `tracker.kind` is `none` — no host CLI is configured. The CEO marker is still written and valid; tell the user the merge itself needs to happen manually on the host, and no further `/approve-merge` re-invocation is needed once they've done it.
### 8. Move the board card to "Measurement" (opt-in)
After a successful merge, call `board_move_card` to signal that the work has
shipped and is entering the measurement/observe phase. This is a no-op unless
`enable_auto_moves` is `true` in the fork's `github_projects` config.
```bash
source "$(git rev-parse --show-toplevel)/.claude/hooks/_lib-project-board.sh"
board_move_card "<pr>" "measurement"
```
`board_move_card` degrades gracefully — any failure warns to stderr and returns
0. It never blocks the merge report.
> **Tip — GitHub-native "Done" transition:** the "PR merged → Done" and
> "Item closed → Done" built-in Workflows in GitHub Projects (Settings →
> Workflows) handle the final Done hop for free. Enable them in the GitHub UI
> and your board will reflect closed tickets automatically without any
> additional hook wiring.
### 9. Report
Single-line confirmation (include the merge strategy used so the operator can see it):
```
✓ Merged PR #<pr> as commit <MERGE_SHA> (strategy: squash). Branch deleted.
```
or for sync PRs:
```
✓ Merged PR #<pr> as commit <MERGE_SHA> (strategy: merge, auto-detected sync PR — ancestry preserved). Branch deleted.
```
If the merge gate blocked (`MERGE_RC` non-zero and not 3), surface the exact error and tell the user how to retry:
```
✗ Merge blocked: <reason from gate>. Marker still on disk at <CEO path from review_marker_path> — fix the issue and re-invoke `/approve-merge <pr>` (the marker is still valid, no need to re-approve).
```
If `MERGE_RC` = 3 (`tracker.kind: none`, no host CLI configured):
```
✓ CEO approval recorded for PR #<pr>. No tracker CLI is configured (tracker.kind: none) — merge <pr> manually on the host; the marker on disk covers the approval, no further /approve-merge invocation needed.
```
### 10. Optional: post-merge child-issue closure
If the PR's merge commit / PR body contains `Closes <owner/repo>#<N>` references that GitHub's auto-closer didn't catch (squash merges with cross-repo refs sometimes silently miss), you can offer to close them with a comment. This is **out of scope for the default flow** — only do it if the user explicitly asks. Don't auto-close child issues; that's another externally-visible action that needs its own per-issue confirmation.
## --no-merge opt-out
`/approve-merge <pr> --no-merge` writes the marker and stops. Useful when:
- CI is still running and you want to record approval before the green light, then merge later
- You want to record approval but defer the merge for a batch
- You want the marker on disk to unblock a teammate who'll do the merge themselves
- A regulated environment requires temporal separation between approval and execution
The skill never writes the marker AND defers the merge in any other case. The default IS auto-merge.
## Notes
- The marker is gitignored (`.claude/session/` is in `.gitignore`). It's session state, not code.
- Re-running `/approve-merge <pr>` on the same PR is idempotent — overwrites with current HEAD/timestamp. Useful for a small follow-up (rebase, comment-only fixup) where re-running Rex isn't needed.
- New commits after the marker is written invalidate the approval — the hook refuses to merge because `sha=` no longer matches PR HEAD. Re-run Rex + `/approve-merge`. **One exception (me2resh/apexyard#1437):** when the new HEAD is a forge-verified, conflict-free base-branch merge of the approved SHA — see step 4 and `rex_approval_carries_over` — the gate carries the existing marker forward on its own. Nothing needs re-running for that specific case, and no marker gets rewritten to make it happen.
- The marker format is **versioned**. A bare-SHA legacy marker (skill_version absent) is rejected by the merge gate as of `block-unreviewed-merge.sh` v2 — same release as this skill. Adopters with stale legacy markers from earlier sessions just re-run `/approve-merge` once.
- The skill intentionally does **not** wait/poll for "the user's 'approved'." The skill exists to be invoked, not to poll.
## Why this default changed
Earlier versions of this skill stopped after writing the marker and required a second user message ("merge it" / "go") before running `gh pr merge`. The split was procedural ceremony, not safety: by the time the skill ran, the user had explicitly named the PR, both Rex and CEO markers were on disk with matching SHAs, and the mechanical merge gates would catch any failure. The second message added latency on every merge for a hypothetical "user changes their mind in 30 seconds" case that almost never happened.
The hardened structured-marker format (introduced in the same change) closes the bypass surface that the two-message ceremony was indirectly hedging against — the model writing a marker via raw `echo` to short-circuit approval. Once that bypass is mechanically blocked, the second message has no work left to do.
See AgDR-0012 for the full trade-off.
## Anti-pattern
```
You: "I'll execute the plan. Step 1: approve-merge, Step 2: gh pr merge."
CEO: "go"
You: *tries to invoke /approve-merge* ← FAILURE, twice over:
the "go" was plan-level, AND since
#1042 the model cannot invoke this
skill at all.
```
The CEO's "go" was on the plan. It was not a per-PR approval for the merge. The correct flow:
```
You: *executes the non-merge steps*
You: "All other steps done. PR #X is ready to merge — run /approve-merge X when you're happy."
CEO: /approve-merge X ← writes the structured marker AND merges in one turn
```
The discrete approval moment is **the invocation of /approve-merge**, not a separate "now do the merge" message. Since #1042 that invocation is mechanically restricted to a human (`disable-model-invocation: true`), so the model's job ends at getting the PR ready and saying so. Treat the invocation with the seriousness the merge warrants: once it runs, the merge runs.
---
*Part of [ApexYard](https://github.com/me2resh/apexyard) — multi-project SDLC framework for Claude Code · MIT.*