ahel is live on Product Hunt today. Upvote

approve-merge

SkillDev tools

Record per-PR CEO approval and merge in one turn. ONLY on an explicit per-PR "approved", never on umbrella "go".

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the approve-merge skill

What this skill tells your AI

The instructions your AI receives, as published by me2resh/apexyard in .claude/skills/approve-merge/SKILL.md and read by ahel’s review.

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:

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

gh pr view <pr> --repo "$REPO" --json state,isDraft,mergeable,headRefOid

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.

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.

# Resolve the OPS FORK ROOT, not git toplevel. Inside workspace/<project>/,
# git toplevel is the project clone; markers live in the ops fork above.
# See me2resh/apexyard#229 + #230. Resolve PIN-FIRST — the same strategy the
# merge gate uses (_lib-ops-root.sh::resolve_ops_root). 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 _lib-review-markers.sh doesn't exist, so the
# CEO marker lands where the gate can't see it (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)}"

# 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")
[ -f "$REX" ] && [ "$(tr -d '[:space:]' < "$REX")" = "<headRefOid from step 3>" ]

If Rex's marker is missing or its SHA doesn't match the PR HEAD, refuse and tell the user to re-invoke the code-reviewer first. Do not write the CEO marker on a stale base.

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:

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:

FieldWhy
sha=<HEAD>Binds the approval to a specific commit. SHA must match the PR's GitHub HEAD.
approved_by=userMarker 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:

FieldUse
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):

# (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 PR title starts with sync( (the canonical /release-sync PR title prefix)

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).

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-' || \
   echo "$PR_TITLE" | grep -qE '^sync\('; 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 on sync/-prefixed PRs — so even a direct gh pr merge <sync-pr> --squash (or the glab equivalent) will be blocked, protecting against both accidental and deliberate strategy errors. 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.

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).

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
501
Forks
274
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
approve-merge
Source
github.com/me2resh/apexyard