Skip to content
Back to skills

Pr Merge

BSecurity

Wait for PR reviews, address feedback, and merge. Completes the Issue-to-PR

  • 8 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added November 5, 2026
developmenttypescriptpythonrustgoc#shellbashreactnoderails

Works with

  • claude code
  • cursor
  • vscode
  • terminal
  • cli
  • api

Security analysis

B88/100
  • criticalSends environment variables or credentials to an external URL

Pro scans all 10 files and shows the line behind each finding

Scanned November 5, 2026

npx -y skills add nightgauge/nightgauge --skill pr-merge --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Pr Merge?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Pr Merge
[![Security: B — Skills Directory](https://www.skillsdirectory.com/api/skills/nightgauge-pr-merge/badge)](https://www.skillsdirectory.com/skills/nightgauge-pr-merge)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: pr-merge
description: Wait for PR reviews, address feedback, and merge. Completes the Issue-to-PR
  pipeline after /pr-create.
license: Apache-2.0
metadata:
  author: nightgauge
  version: "1.19.0"
  source: https://github.com/nightgauge/nightgauge
allowed-tools: Read Write Edit Glob Grep Bash Task
model: haiku
inputs:
  - pipeline/pr-{N}.json
outputs: []
disable-model-invocation: true
---

## System Context

**Product**: Nightgauge — AI-powered Issue-to-PR pipeline.

**Architecture**: Three-layer stack: portable Skills (SKILL.md), a TypeScript
SDK, and a VSCode Extension, over a Go deterministic layer. Six pipeline
stages execute as isolated subagents with JSON context handoff files.

**Execution model**: Each pipeline stage runs in a fresh conversation. Context
is passed exclusively through JSON files in the clone's pipeline state
directory (`nightgauge layout path pipeline`) — never through conversation
history. Every stage reads its predecessor's context file
and writes its own.

**Configuration**: 6-tier config system — built-in defaults → global config →
project config → local config → env vars → CLI flags. Reference:
[docs/CONFIGURATION.md](https://github.com/nightgauge/nightgauge/blob/main/docs/CONFIGURATION.md).

**Standards**: Code standards in `docs/CODE_STANDARDS.md`. Security rules in
`standards/security.md`. Testing patterns in `docs/TESTING.md`. Git workflow in
`docs/GIT_WORKFLOW.md`.

**Critical rules**: Never push to main. Never hardcode secrets. Never downgrade
versions. Pre-submission validation is mandatory.

## Autonomy Contract

This run is autonomous. No human is watching, and nobody can answer a
question mid-stage. `AskUserQuestion` is disabled — and _behaving_ as if it
were available is the same failure.

- **Proceed without asking** for any reversible action that follows from the
  stage's task. If a decision is genuinely undecidable from the issue, the
  context files, and the repo, fail fast with a clear error — never pause to
  ask, and never silently pick between materially different product
  directions.
- **Never end a turn on a promise.** Before ending your turn, check your last
  paragraph: if it is a plan, a question, a list of next steps, or a promise
  about work not yet done ("I'll now…", "Next, I will…"), do that work now
  with tool calls instead of describing it. A turn that ends on stated intent
  with no corresponding tool call is recorded as a `premature_turn_end` stage
  failure, not a success.
- **Do not stop because the session feels long.** End the turn only when the
  stage's output contract is satisfied (its context file and phase markers
  are written) or you are genuinely blocked — and a genuine block is reported
  as an explicit failure, never as an open question.
- **Never end a turn with background work outstanding.** There is no "await
  and resume" — your turn ending IS stage completion, and the pipeline
  advances immediately. If you started something in the background, block on
  it and read its result before you finish. "I'll wait for that to complete
  rather than poll, and pick the pipeline back up once it lands" is not a
  handoff; it is the stage reporting success on work it never saw (#202).
- **Work only inside the worktree you were given.** Never delegate the
  stage's implementation to a subagent running under worktree isolation, and
  never `cd` outside your workspace to edit files. Only your own worktree is
  read by later stages: on #202 a subagent wrote the entire fix into
  `.claude/worktrees/agent-<id>`, so the stage passed, the branch stayed
  empty, and the work was invisible to every stage after it. A gate now fails
  the stage when this happens — but the run is dead either way, so do the work
  where you stand.


# PR Merge

> Wait for reviews, address feedback, and merge pull requests

## Description

This skill completes the Issue-to-PR pipeline by:

1. Waiting for CI checks and reviews to complete
2. Fetching and parsing review feedback
3. Auto-addressing minor issues when possible
4. Presenting major/critical issues for user decision
5. Merging the PR when approved
6. Cleaning up branches and updating issue status

## Invocation

| Tool           | Command                             |
| -------------- | ----------------------------------- |
| Claude Code    | `/nightgauge:pr-merge` (via plugin) |
| OpenAI Codex   | `$nightgauge-pr-merge`              |
| GitHub Copilot | Invoke via Agent Skills             |
| Cursor         | Invoke via Agent Skills             |

## Arguments

```bash
# Merge current branch's PR (default behavior)
/nightgauge-pr-merge

# Specify PR number explicitly
/nightgauge-pr-merge --pr 57

# Set custom timeout for CI checks (default: 10 minutes)
/nightgauge-pr-merge --timeout 10

# Auto-fix minor issues without confirmation
/nightgauge-pr-merge --auto-fix

# Skip branch cleanup after merge
/nightgauge-pr-merge --no-cleanup

# Use different merge strategy
/nightgauge-pr-merge --merge    # Regular merge (preserve history)
/nightgauge-pr-merge --rebase   # Rebase and merge

# Skip CI check gate (NOT recommended - use only for emergencies)
/nightgauge-pr-merge --skip-ci-gate

# Disable auto-fix for CI failures (just report and exit on failure)
/nightgauge-pr-merge --no-auto-fix-ci

# Emergency only: bypass the blockedBy pre-merge guard
/nightgauge-pr-merge --force
```

## Prerequisites

- **GitHub CLI**: Must have `gh` installed and authenticated
- **Open PR**: Must have an open PR for the current branch
- **Feature branch**: Must be on a feature branch (not main)

## Philosophy

- **Complete automation** — Handle the entire review-to-merge workflow
- **Smart categorization** — Distinguish blocking vs non-blocking feedback
- **User control** — Present critical decisions, automate routine tasks
- **Clean state** — Leave repository in a clean, updated state after merge

## Spike Issues (`type:spike`)

For `type:spike` issues, the orchestrator appends a `spike-materialize` stage
after this skill completes. That stage parses the merged artifact's YAML
recommendations block and creates follow-up issues — it also updates the PR
description with a `## Created Follow-up Issues` section. This skill MUST NOT
attempt to populate that section itself; leave the placeholder from
`pr-create` intact for the materializer to replace. See
[docs/SPIKE_CONTRACT.md](https://github.com/nightgauge/nightgauge/blob/main/docs/SPIKE_CONTRACT.md).

---

## Supporting files (load on demand)

- `_includes/context-bootstrap.md` (same directory as this SKILL.md) — read in Phase 0 (stage start + context reconstruction)
- `_includes/validate-environment.md` (same directory as this SKILL.md) — read in Phase 1 (verify branch, PR state, pre-CI Go build check)
- `_includes/reviews.md` (same directory as this SKILL.md) — read in Phase 3 (fetch & parse review feedback, CI status)
- `_includes/merge.md` (same directory as this SKILL.md) — read in Phase 6 (ruleset pre-check, knowledge conformance gate, conflict resolution, merge gate, execute merge)
- `_includes/post-merge.md` (same directory as this SKILL.md) — read in Phase 7 (post-merge build, issue close, epic completion, branch cleanup, outcome recording)
- `_includes/feedback.md` (same directory as this SKILL.md) — read in Phase 7.8 (retrospective feedback)
- `_includes/failure-cleanup.md` (same directory as this SKILL.md) — read in Failure Cleanup (cleanup_failed_pr function + exit-point usage)

---

### Configuration Reference

Pipeline skills read configuration from `.nightgauge/config.yaml`.

**Full reference**: See [docs/CONFIGURATION.md](https://github.com/nightgauge/nightgauge/blob/main/docs/CONFIGURATION.md)
for the complete configuration guide including the 6-tier precedence chain, all
config options, and examples. `nightgauge config show` prints the resolved
configuration for the current repository.

**Key config sections by stage**:

| Stage            | Key Options                                           |
| ---------------- | ----------------------------------------------------- |
| issue-pickup     | `branch.base`, `branch.prefixes.*`, `project.*`       |
| feature-planning | `project.number`, `project.auto_dates`                |
| feature-dev      | `pipeline.auto_fix`, `commands.*`                     |
| feature-validate | `commands.test`, `pipeline.skip.tests`                |
| pr-create        | `pr.draft_by_default`, `pr.reviewers`, `validation.*` |
| pr-merge         | `pr.merge_strategy`, `pr.delete_branch`               |

#### Issue-Pickup Configuration

| Config Key                   | Default     | Description                       |
| ---------------------------- | ----------- | --------------------------------- |
| `branch.base`                | `main`      | Default base branch for PRs       |
| `branch.protected`           | `[main]`    | Branches that cannot be pushed to |
| `branch.prefixes.*`          | (see below) | Custom branch prefix mappings     |
| `project.number`             | -           | GitHub Project number             |
| `project.auto_dates`         | `false`     | Auto-populate Start/Target dates  |
| `project.sprint.auto_assign` | `false`     | Assign current sprint on pickup   |

**Branch Prefixes (defaults):**

| Type       | Default     |
| ---------- | ----------- |
| `feature`  | `feat/`     |
| `bugfix`   | `fix/`      |
| `docs`     | `docs/`     |
| `refactor` | `refactor/` |
| `chore`    | `chore/`    |
| `test`     | `test/`     |
| `hotfix`   | `hotfix/`   |

**Environment overrides:**

```bash
export NIGHTGAUGE_BRANCH_BASE=develop
export NIGHTGAUGE_BRANCH_PROTECTED=main,develop
export NIGHTGAUGE_PROJECT_AUTO_DATES=true
```

#### PR-Merge Configuration

| Config Key                 | Default  | Description                                             |
| -------------------------- | -------- | ------------------------------------------------------- |
| `pr.merge_strategy`        | `squash` | Merge strategy for sub-issue PRs: squash, merge, rebase |
| `pr.epic_merge_strategy`   | `merge`  | Merge strategy for epic→main PRs: merge, squash, rebase |
| `pr.delete_branch`         | `true`   | Delete feature branch after merge                       |
| `pr.auto_fix_ci`           | `true`   | Auto-fix CI failures before merge                       |
| `pr.auto_fix_max_attempts` | `3`      | Maximum auto-fix retry attempts                         |
| `pr.ci_check_timeout`      | `10`     | Timeout for CI checks in minutes                        |
| `project.number`           | -        | GitHub Project number for status sync                   |

**Environment overrides:**

```bash
export NIGHTGAUGE_PR_ADMIN_MERGE=true
export NIGHTGAUGE_PR_MERGE_STRATEGY=rebase
export NIGHTGAUGE_PR_DELETE_BRANCH=false
export NIGHTGAUGE_PR_AUTO_FIX_CI=true
export NIGHTGAUGE_PR_AUTO_FIX_MAX_ATTEMPTS=3
export NIGHTGAUGE_PR_CI_CHECK_TIMEOUT=10  # minutes (not seconds)
```

**Important notes:**

- There is no admin merge bypass — nothing skips branch protection, and CI
  checks are always waited for unless `--skip-ci-gate` is explicitly used
  (#186).
- If no CI checks are configured on the repository, the skill proceeds normally
  without waiting.

### Config Helper Functions

When skills need to read configuration values, use these inline patterns
rather than undefined helper functions:

#### Single value with default

```bash
VALUE=$(yq -r '.path.to.key // "default"' .nightgauge/config.yaml 2>/dev/null || echo "default")
```

#### Boolean with default

```bash
ENABLED=$(yq -r '.path.to.key // "true"' .nightgauge/config.yaml 2>/dev/null || echo "true")
```

#### With env var override (6-tier precedence)

```bash
# Env var takes priority, then config file, then default
VALUE="${ENV_VAR_OVERRIDE:-$(yq -r '.path.to.key // "default"' .nightgauge/config.yaml 2>/dev/null || echo "default")}"
```

**NOTE**: Do not use `get_config_bool` or `get_config_value` — these functions
are not defined. Use the inline `yq` patterns above.


---

## Input Contract

This skill requires `pr-{N}.json` from `/nightgauge-pr-create`. Every context
file named here lives in the clone's pipeline state directory: read it at
`$(nightgauge layout path pipeline <file>)`.

It also reads prior pipeline context for history and validation:

- `nightgauge layout path pipeline issue-{N}.json` (from issue-pickup)
- `nightgauge layout path pipeline planning-{N}.json` (from feature-planning)
- `nightgauge layout path pipeline dev-{N}.json` (from feature-dev)

**Full schema**: See
[docs/CONTEXT_ARCHITECTURE.md](https://github.com/nightgauge/nightgauge/blob/main/docs/CONTEXT_ARCHITECTURE.md) for complete
schema documentation including all field types and requirements.

---

## Orchestration

This skill intentionally declares **no** `orchestration:` frontmatter block. PR
merge is a **single-agent deterministic phase** by design — it is never fanned
out (epic #3899,
[docs/WORKFLOW_ORCHESTRATION.md](https://github.com/nightgauge/nightgauge/blob/main/docs/WORKFLOW_ORCHESTRATION.md) §Safety &
guardrails). The capability-routed `WorkflowEngine` runs it as one deterministic
phase node alongside the orchestrated stages.

## Gotchas

- **Never blindly accept one side of a merge conflict.** Understand both sides
  before resolving — a reflexive "accept theirs/ours" silently drops work.
- **Don't `--watch` CI from a forge loop.** Use `nightgauge ci wait` — an
  interactive `--watch` mode can hang the headless run.
- **Clean up on failure (prevents stale PRs).** A failed merge attempt must leave
  no half-open/abandoned PR state behind.
- **No follow-up issues in the merge description.** Don't bake created follow-up
  issues into the PR/merge body.
- See also the cross-cutting gotchas in
  [`_shared/GOTCHAS.md`](../_shared/GOTCHAS.md).

## Workflow

### Phase Marker Protocol

At the start of each phase, emit a structured phase marker as an HTML comment on
its own line. Format:

`<!-- phase:start name="{phase-name}" index={N} total={T} stage="pr-merge" -->`

This enables the orchestrator to track phase progress. Emit the marker BEFORE
any other output for that phase.

**IMPORTANT**: ALL phase markers MUST be emitted even in fast-track paths (e.g.,
no review feedback, CI already passed). The orchestrator counts emitted markers
to display progress (e.g., "11/11 phases"). Skipping markers causes incorrect
counts. If a phase has no work to do, still emit its marker and immediately
proceed to the next phase.

### Phase 0: Read PR Context

### Phase 0: Environment Preflight

**PURPOSE**: Verify the nightgauge binary is available and the environment
is healthy before executing any pipeline stage. Skills halt immediately with
actionable instructions when this check fails.

Three-layer check:

1. **Binary discovery** — shell-level, runs before invoking the binary
2. **Doctor check** — binary-level, full environment health via `doctor --json`
3. **Project environment** — the checks the project declares under
   `preflight.environment` (#101); opt-in, silent when none are declared

The binary discovery cascade below is **provider-neutral** (issue 4029): it works
identically under every adapter (Claude, Codex, Gemini, …) and references no
VSCode-extension-specific path. The host that spawns the skill (the VSCode
extension's `skillRunner`, or the Go `nightgauge` binary in auto/CLI mode)
resolves the binary authoritatively and exports `NIGHTGAUGE_BIN`, which
this cascade honors first. The remaining steps (PATH, the development-only repo
`bin/` and worktree canonical-repo `bin/`, `~/go/bin`) are fallbacks for direct
or terminal invocation.

This intentionally **diverges** from
`claude-plugins/nightgauge/hooks/lib/guard.sh` (issue 3262 → issue 4029): guard.sh is
Claude-Code-only and retains a trailing `~/.vscode/extensions/...` glob to serve
the standalone-terminal-Claude case (where no host exports `NIGHTGAUGE_BIN`).
Skills must stay portable Markdown and cannot reference that VSCode-only path —
so the shared resolution order (`NIGHTGAUGE_BIN` → PATH → gated repo → gated
canonical → `~/go/bin`) stays in sync, and only guard.sh carries the extra Claude-only
fallback. The `nightgauge preflight skill-portability` gate enforces that
no skill reintroduces a `.vscode/extensions` path, and that every cascade keeps
the `NIGHTGAUGE_DEV_REPO_BIN` gate (#2758): a foreign repository's
`bin/nightgauge` must never run.

After the cascade resolves `$BINARY`, the preflight prepends
`dirname($BINARY)` to `PATH` so subsequent bare `nightgauge ...`
invocations later in the same skill body resolve through PATH rather than
failing with "command not found" (issue 3262).

```bash
# Layer 1: provider-neutral binary discovery (NIGHTGAUGE_BIN → PATH → gated repo
# bin → gated canonical-repo bin → ~/go/bin). No VSCode-extension path — see issue 4029.
# Repo bins need NIGHTGAUGE_DEV_REPO_BIN=1 and a verified core checkout (#2758).
BINARY="${NIGHTGAUGE_BIN:-}"
[ -n "$BINARY" ] && [ ! -x "$BINARY" ] && BINARY=""
[ -z "$BINARY" ] && BINARY=$(command -v nightgauge 2>/dev/null || echo "")
if [ -z "$BINARY" ] && [ "${NIGHTGAUGE_DEV_REPO_BIN:-}" = "1" ]; then
  for NG_ROOT in "$(git rev-parse --show-toplevel 2>/dev/null)" \
    "$(cd "$(git rev-parse --git-common-dir 2>/dev/null)/.." 2>/dev/null && pwd)"; do
    [ -x "$NG_ROOT/bin/nightgauge" ] || continue
    grep -qx 'module github.com/nightgauge/nightgauge' "$NG_ROOT/go.mod" 2>/dev/null || continue
    NG_ORIGIN="$(git -C "$NG_ROOT" config --get remote.origin.url 2>/dev/null)"
    case "${NG_ORIGIN%.git}" in
      git@github.com:nightgauge/nightgauge | https://github.com/nightgauge/nightgauge | \
        ssh://git@github.com/nightgauge/nightgauge) BINARY="$NG_ROOT/bin/nightgauge"; break ;;
    esac
  done
fi
[ -z "$BINARY" ] && [ -x "$HOME/go/bin/nightgauge" ] && BINARY="$HOME/go/bin/nightgauge"
if [ -z "$BINARY" ]; then
  echo "ERROR: nightgauge binary not found via NIGHTGAUGE_BIN, PATH or ~/go/bin" >&2
  echo "" >&2
  echo "Install via: go install github.com/nightgauge/nightgauge/cmd/nightgauge@latest" >&2
  echo "Or download from: https://github.com/nightgauge/nightgauge/releases" >&2
  echo "Run \`nightgauge doctor\` after installing to verify your environment." >&2
  exit 1
fi
# Make the resolved binary callable via bare `nightgauge ...` later in this skill body (issue 3262).
export PATH="$(dirname "$BINARY"):$PATH"

# Per-repo GitHub token: export GH_TOKEN so every `gh` call in this skill
# authenticates as the *current repo's* configured user instead of the machine-
# global `gh auth` active account. `forge auth token` resolves per-repo (config
# github_user/token → GITHUB_TOKEN → `gh auth token --user <github_user>`), so
# concurrent sessions/workspaces owned by different GitHub users each use their
# own token. GH_TOKEN outranks GITHUB_TOKEN and the gh keyring. Only resolve when
# unset (an upstream env value — e.g. the VSCode extension's terminal env —
# wins); never fail preflight over it. Mirrors hooks/lib/guard.sh (sync, issue 3262).
if [ -z "${GH_TOKEN:-}" ]; then
  IB_REPO_TOKEN="$("$BINARY" forge auth token 2>/dev/null || true)"
  if [ -n "$IB_REPO_TOKEN" ]; then
    export GH_TOKEN="$IB_REPO_TOKEN"
    export GITHUB_TOKEN="${GITHUB_TOKEN:-$IB_REPO_TOKEN}"
  fi
  unset IB_REPO_TOKEN
fi

# Layer 2: full environment health check via doctor --json
DOCTOR_RESULT=$("$BINARY" doctor --json 2>/dev/null)
DOCTOR_EXIT=$?
if [ "$DOCTOR_EXIT" -eq 2 ]; then
  echo "ERROR: Environment check failed — nightgauge doctor reports broken environment." >&2
  FAILED_CHECKS=$(printf '%s\n' "$DOCTOR_RESULT" | jq -r '.failed_checks[]? // empty' 2>/dev/null)
  if [ -n "$FAILED_CHECKS" ]; then
    echo "Failing check(s): $(echo "$FAILED_CHECKS" | tr '\n' ',' | sed 's/,$//')" >&2
  fi
  printf '%s\n' "$DOCTOR_RESULT" | jq -r '.errors[]' >&2 2>/dev/null || true
  INSTALL_MSG=$(printf '%s\n' "$DOCTOR_RESULT" | jq -r '.install_instructions // empty' 2>/dev/null)
  [ -n "$INSTALL_MSG" ] && echo "$INSTALL_MSG" >&2
  exit 1
fi
if [ "$DOCTOR_EXIT" -eq 1 ]; then
  echo "WARNING: Environment has non-critical issues:" >&2
  printf '%s\n' "$DOCTOR_RESULT" | jq -r '.warnings[]' >&2 2>/dev/null || true
  # Continue — warnings do not block skill execution
fi

# Layer 3: project-declared environment checks (preflight.environment, #101).
# Opt-in: with none declared this prints nothing and continues. The verb
# writes one `[environment-halt]` line per failed check, with its remedy.
if ! "$BINARY" preflight environment >/dev/null; then
  echo "[environment-halt] Project environment check failed — fix the environment, not the code." >&2
  exit 1
fi
```

**Exit codes from `nightgauge doctor`**:

| Code | Meaning                         | Skill behavior                          |
| ---- | ------------------------------- | --------------------------------------- |
| 0    | Healthy                         | Continue                                |
| 1    | Degraded (warnings only)        | Continue with warning printed to stderr |
| 2    | Broken (required checks failed) | Halt immediately with error details     |

**Layer 3** halts the stage when any check declared under
`preflight.environment` in the workspace's `.nightgauge/config.yaml` fails
(exit 1) or the declaration is invalid (exit 2). The failure is in the
operator's environment, not the code: do not edit code to work around it, do
not run the remedy yourself, and end the stage. The pipeline records it as
`environment_halt` and holds the issue for an operator.

Do not theorize about the cause beyond what is printed here — the failing
check name (`failed_checks`) and its `.errors[]` message are the complete
diagnosis (#277).


### Repo Identity Assertion (HARD GATE — non-recoverable)

If the `NIGHTGAUGE_TARGET_REPO` environment variable is set, the orchestrator
has pinned the exact repo this stage must run in. Verify the current repository
matches **before doing any work**:

```bash
if [ -n "$NIGHTGAUGE_TARGET_REPO" ]; then
  # Derive this checkout's identity from its own git remote. The question
  # is "which repository is this working directory?", and the remote answers
  # it exactly — locally, with no API call. This used to shell out to the
  # GitHub CLI for the same string, spending a request per stage on every
  # repo in the workspace, invisible to `nightgauge api-usage`.
  ACTUAL_REPO=$(git remote get-url origin 2>/dev/null \
    | sed -E 's#^git@[^:]+:#-#; s#^https?://[^/]+/#-#; s#^-##; s#\.git$##')
  if [ -n "$ACTUAL_REPO" ] && [ "$ACTUAL_REPO" != "$NIGHTGAUGE_TARGET_REPO" ]; then
    echo "[repo-mismatch] FATAL: repository identity assertion failed"
    echo "  Expected: $NIGHTGAUGE_TARGET_REPO"
    echo "  Actual:   $ACTUAL_REPO"
    echo "  CWD:      $(pwd)"
    echo ""
    echo "The orchestrator expected this stage to run in $NIGHTGAUGE_TARGET_REPO"
    echo "but the working directory belongs to $ACTUAL_REPO."
    exit 1
  fi
fi
```

**This is a terminal, non-recoverable stop condition. If the check prints
`[repo-mismatch]` and exits 1, you MUST immediately end the stage with a failure.**

Specifically, on a mismatch you must NOT:

- `cd` into a different directory, switch worktrees, or `gh repo set-default` to
  "make it match" — the orchestrator owns CWD; second-guessing it corrupts state.
- Continue to later phases, create or push a branch, or open a PR — every
  downstream artifact would land in the wrong repo.
- Ask the user a question (`AskUserQuestion`) — autonomous runs have no human at
  the prompt; the question is auto-dismissed and you would proceed on a guess.
- Write a context/assessment file that claims success.

The correct and ONLY action is to stop now and let the stage exit non-zero. This
is a configuration fault in repo routing (the orchestrator set the wrong
`NIGHTGAUGE_TARGET_REPO` for this worktree); it is fixed upstream in the
orchestrator, never worked around inside the stage. The `[repo-mismatch]` marker
is how the pipeline classifies and surfaces it.

This check is a no-op — and the stage proceeds normally — when
`NIGHTGAUGE_TARGET_REPO` is unset (e.g. manual single-stage CLI
invocations) or already matches the current repo.


---

```bash
printf '<!-- phase:start name="read-pr-context" index=0 total=13 stage="pr-merge" -->\n'
```

Read PR context: resolve the issue number from the branch,
load `"$(nightgauge layout path pipeline pr-{N}.json)"`, signal stage start,
and reconstruct the context file from GitHub if it is missing.

> **Read `_includes/context-bootstrap.md` (same directory as this SKILL.md) now and follow its instructions before continuing this phase.**

---

### Phase 1: Validate Environment

```bash
printf '<!-- phase:start name="validate-environment" index=1 total=13 stage="pr-merge" -->\n'
```

Verify the feature branch (handling detached HEAD), resolve the PR number and
state, extract the issue number, and run the pre-CI Go build integrity check.

> **Read `_includes/validate-environment.md` (same directory as this SKILL.md) now and follow its instructions before continuing this phase.**

---

### Phase 2: Wait for CI Checks (CI Gate)

```bash
printf '<!-- phase:start name="ci-gate" index=2 total=13 stage="pr-merge" -->\n'
```

### CI Check Gate

## Contents

- [Check Skip CI Gate Flag](#check-skip-ci-gate-flag)
- [Detect Epic Branch PRs](#detect-epic-branch-prs-no-ci-workflow)
- [Wait for CI Checks](#wait-for-ci-checks-deterministic)
- [Handle No CI Checks Configured](#handle-no-ci-checks-configured)
- [Handle Timeout](#handle-timeout)
- [Check for Failures](#check-for-failures)
- [Pre-Merge Local Test Safety Net](#pre-merge-local-test-safety-net)

**PURPOSE**: Wait for CI checks to complete before merge. This gate can only
be bypassed with explicit `--skip-ci-gate`.

**REQUIRED CHECK**: The `CI` workflow (`.github/workflows/ci.yml`) runs
`npm run build` and `npm run test` on every PR. The Go binary `nightgauge ci wait` command
automatically detects and waits for it.

#### Check Skip CI Gate Flag

```bash
if [ "$ARG_SKIP_CI_GATE" = "true" ]; then
  echo "WARNING: Skipping CI check gate (--skip-ci-gate)"
  CI_GATE_SKIP=true
fi
```

If `CI_GATE_SKIP=true`, skip CI waiting and the pre-merge safety net.

#### Detect Epic Branch PRs (No CI Workflow)

The CI workflow (`.github/workflows/ci.yml`) only triggers on PRs targeting
`main`. PRs targeting epic branches (e.g., `epic/1941-*`) will never receive
`build-and-test` or `codex-smoke` checks, so `ci wait` would time out. Skip
CI waiting for these PRs — the local pre-merge safety net still runs.

```bash
if [ -n "$BASE_BRANCH" ] && printf '%s\n' "$BASE_BRANCH" | grep -q "^epic/"; then
  echo "PR targets epic branch ($BASE_BRANCH) — CI workflows do not trigger for non-main targets"
  echo "Skipping CI wait; local pre-merge safety net will run instead"
  CI_CHECKS_PASSED=true
  CI_EPIC_SKIP=true
fi
```

#### Wait for CI Checks (Deterministic)

**CRITICAL**: You MUST use the Go binary `nightgauge ci wait` to poll for
CI checks. **NEVER** write your own polling loop using `gh pr checks`, `gh api`,
`sleep`, or any other ad-hoc approach. The `gh pr checks --jq` pattern is broken
(`--jq` requires `--json` which changes the output format) and causes the
pr-merge stage to hang for 15+ minutes. The Go binary handles polling correctly
with proper timeout, interval, and terminal-state detection.

**CHUNKED WAIT (#187)**: a single Bash tool call is budgeted ~2 minutes, so
one long `ci wait --timeout 10` is SIGTERMed (exit 143) before CI finishes.
Each Bash tool call therefore runs ONE bounded 90-second chunk
(`--timeout-secs 90`); a deadline file carries the cumulative `$TIMEOUT`
budget across calls. Chunk outcomes: exit 0 = green, exit 1 = failed,
**exit 2 = chunk expired with checks still pending**. On exit 2 with
cumulative budget remaining, END the current Bash call and re-run this same
wait block in a **new** Bash tool call — never `sleep` inline, never switch
to ad-hoc polling.

```bash
BINARY="${NIGHTGAUGE_BIN:-}"
[ -n "$BINARY" ] && [ ! -x "$BINARY" ] && BINARY=""
[ -z "$BINARY" ] && BINARY=$(command -v nightgauge 2>/dev/null || echo "")
if [ -z "$BINARY" ] && [ "${NIGHTGAUGE_DEV_REPO_BIN:-}" = "1" ]; then
  for NG_ROOT in "$(git rev-parse --show-toplevel 2>/dev/null)" \
    "$(cd "$(git rev-parse --git-common-dir 2>/dev/null)/.." 2>/dev/null && pwd)"; do
    [ -x "$NG_ROOT/bin/nightgauge" ] || continue
    grep -qx 'module github.com/nightgauge/nightgauge' "$NG_ROOT/go.mod" 2>/dev/null || continue
    NG_ORIGIN="$(git -C "$NG_ROOT" config --get remote.origin.url 2>/dev/null)"
    case "${NG_ORIGIN%.git}" in
      git@github.com:nightgauge/nightgauge | https://github.com/nightgauge/nightgauge | \
        ssh://git@github.com/nightgauge/nightgauge) BINARY="$NG_ROOT/bin/nightgauge"; break ;;
    esac
  done
fi
[ -z "$BINARY" ] && [ -x "$HOME/go/bin/nightgauge" ] && BINARY="$HOME/go/bin/nightgauge"
[ -n "$BINARY" ] && export PATH="$(dirname "$BINARY"):$PATH"
TIMEOUT=${ARG_TIMEOUT:-10}  # minutes (Go binary --timeout is in minutes)

if [ -z "$BINARY" ]; then
  echo "ERROR: nightgauge binary not found via NIGHTGAUGE_BIN, PATH or ~/go/bin" >&2
  echo "This binary is required for the CI check gate." >&2
  exit 1
fi

CI_CHUNK_PENDING=false
CI_DEADLINE_NAME="ci-wait-deadline-${PR_NUMBER}"
CI_DEADLINE_FILE="$(nightgauge layout path pipeline "$CI_DEADLINE_NAME")"

if [ "$CI_EPIC_SKIP" = "true" ]; then
  echo "Skipping ci wait (epic branch PR)"
  CI_RESULT='{"state":"SUCCESS","total":0,"completed":0,"successful":0,"failed":0,"pending":0,"checks":[],"isTerminal":true,"elapsedSecs":0}'
  CI_EXIT_CODE=0
  CI_STDERR=""
else
  # Cumulative budget bookkeeping (#187): first chunk writes the deadline;
  # later chunks (fresh Bash calls) read it back.
  _CI_NOW=$(date +%s)
  if [ ! -s "$CI_DEADLINE_FILE" ]; then
    echo $(( _CI_NOW + TIMEOUT * 60 )) \
      | nightgauge layout write pipeline "$CI_DEADLINE_NAME" >/dev/null
  fi
  _CI_DEADLINE=$(cat "$CI_DEADLINE_FILE" 2>/dev/null || echo $(( _CI_NOW + TIMEOUT * 60 )))
  _CI_REMAINING=$(( _CI_DEADLINE - _CI_NOW ))
  _CI_CHUNK=90
  [ "$_CI_REMAINING" -lt "$_CI_CHUNK" ] && _CI_CHUNK=$_CI_REMAINING

  if [ "$_CI_REMAINING" -le 0 ]; then
    # Cumulative budget exhausted across chunks — treat as a real CI timeout.
    CI_RESULT='{"state":"TIMEOUT","total":0,"completed":0,"successful":0,"failed":0,"pending":0,"checks":[],"isTerminal":true,"elapsedSecs":0}'
    CI_EXIT_CODE=2
    CI_STDERR=""
  else
    # CRITICAL: capture stderr — silently swallowing it caused #2868, where
    # rate-limited `ci wait` runs were indistinguishable from "no checks
    # configured" and merges proceeded against unverified PRs.
    CI_STDERR_FILE=$(mktemp)
    CI_RESULT=$("$BINARY" ci wait "$PR_NUMBER" --timeout-secs "$_CI_CHUNK" --required-only --json 2>"$CI_STDERR_FILE")
    CI_EXIT_CODE=$?
    CI_STDERR=$(cat "$CI_STDERR_FILE" 2>/dev/null || echo "")
    rm -f "$CI_STDERR_FILE"

    # Chunk expired but cumulative budget remains → not a CI timeout yet.
    if [ "$CI_EXIT_CODE" = "2" ] && [ $(( _CI_DEADLINE - $(date +%s) )) -gt 0 ]; then
      CI_CHUNK_PENDING=true
      echo "[ci-gate] Chunk expired, checks still pending — $(( _CI_DEADLINE - $(date +%s) ))s of CI budget remain. Re-run this wait block in a NEW Bash call."
    fi
  fi
fi

# Terminal outcome (green/failed/cumulative-timeout) — empty the deadline file
# so the next PR (or a re-run after fixes) starts a fresh budget. An empty file
# reads as "no deadline" above; nothing is deleted under the git directory.
if [ "$CI_CHUNK_PENDING" != "true" ]; then
  printf '' | nightgauge layout write pipeline "$CI_DEADLINE_NAME" >/dev/null
fi

CI_ALL_PASSED=$(printf '%s\n' "$CI_RESULT" | jq -r 'if .state == "SUCCESS" then "true" else "false" end')
CI_HAS_CHECKS=$(printf '%s\n' "$CI_RESULT" | jq -r 'if .total > 0 then "true" else "false" end')

REQUIRED_CHECKS=$(printf '%s\n' "$CI_RESULT" | jq -r '.requiredCheckNames // [] | join(", ")' 2>/dev/null)
if [ -n "$REQUIRED_CHECKS" ]; then
  echo "[ci-gate] Waited on required checks: $REQUIRED_CHECKS"
fi

# #1248: checks that FAILED but are not required to merge. `--required-only`
# computes .state from required checks alone — deliberately, since blocking on
# an advisory job would hang the pipeline on any non-required check — so these
# leave .state == SUCCESS and used to vanish entirely — two PRs in one
# workspace repo merged minutes after a non-required end-to-end job went red,
# with nothing anywhere to say so.
#
# This does NOT gate the merge. It makes the merge say what it proceeded past,
# so the stage log and the run record carry it and an operator is not the only
# detector.
CI_ADVISORY_FAILED=$(printf '%s\n' "$CI_RESULT" | jq -r '.advisoryFailedNames // [] | join(", ")' 2>/dev/null)
if [ -n "$CI_ADVISORY_FAILED" ]; then
  echo "[ci-gate] WARNING: proceeding past FAILING non-required check(s): $CI_ADVISORY_FAILED"
  echo "[ci-gate] These do not block the merge because they are absent from the branch's required-status-check list."
  echo "[ci-gate] If one of them is meant to gate merges, add it to branch protection — the pipeline cannot infer intent from a check that runs but is not required."
fi
CI_FAILED_COUNT=$(printf '%s\n' "$CI_RESULT" | jq -r '.failed // 0')
CI_PENDING_COUNT=$(printf '%s\n' "$CI_RESULT" | jq -r '.pending // 0')

case $CI_EXIT_CODE in
  0) CI_CHECKS_PASSED=true ;;
  2)
    # Exit 2 = wait window expired with checks still pending (#187). A chunk
    # expiry with cumulative budget remaining re-runs the block (see
    # CI_CHUNK_PENDING above); only cumulative exhaustion is a real timeout.
    CI_CHECKS_PASSED=false
    [ "$CI_CHUNK_PENDING" != "true" ] && CI_TIMEOUT=true
    ;;
  *) CI_CHECKS_PASSED=false; CI_FETCH_FAILED=true ;;
esac

# Exit 0 means the WAIT finished cleanly — not that checks passed: a state
# of FAILURE also exits 0. Derive pass/fail from the reported state (#187;
# the old exit-1 branch that set CI_HAS_FAILURES never fired).
if [ "$CI_EXIT_CODE" -eq 0 ] && [ "$CI_ALL_PASSED" != "true" ]; then
  CI_CHECKS_PASSED=false
  [ "${CI_FAILED_COUNT:-0}" -gt 0 ] && CI_HAS_FAILURES=true
fi

# #2868: distinguish "ci wait errored" (network/rate-limit/auth) from
# "ci wait succeeded with zero checks". Only the latter is safe to
# treat as "no checks configured". The former MUST fail closed —
# otherwise a rate-limited fetch becomes an unguarded merge.
# Exit 2 (timeout / chunk expiry) is a DETERMINATE outcome, not a fetch
# failure — it is excluded here (#187).
if [ "$CI_EXIT_CODE" -ne 0 ] && [ "$CI_EXIT_CODE" -ne 2 ] && [ "$CI_EPIC_SKIP" != "true" ]; then
  CI_FETCH_FAILED=true
  if printf '%s\n' "$CI_STDERR" | grep -qiE "rate.?limit|api rate"; then
    CI_RATE_LIMITED=true
    echo "ERROR: ci wait failed due to GitHub API rate limit. Cannot verify CI status." >&2
  fi
  if [ -n "$CI_STDERR" ]; then
    echo "[ci-gate] ci wait stderr: $CI_STDERR" >&2
  fi
fi

# Chunk pending (#187): checks are still running and cumulative budget
# remains. STOP at the end of this Bash call and re-run this entire
# "Wait for CI Checks" block in a NEW Bash tool call — do not proceed to
# failure handling, do not sleep inline, do not poll ad hoc.
if [ "$CI_CHUNK_PENDING" = "true" ]; then
  echo "[ci-gate] CI still pending — continue the wait in a fresh Bash call."
fi
```

#### Handle No CI Checks Configured

```bash
# Only treat "no checks" as "ok to merge" when ci wait succeeded. A failed
# fetch (rate-limit, network, auth) leaves CI_HAS_CHECKS=false too — but
# proceeding then would be an unverified merge (#2868).
if [ "$CI_HAS_CHECKS" = "false" ] && [ "$CI_EXIT_CODE" -eq 0 ]; then
  echo "No CI checks configured on this repository — proceeding with merge"
  CI_CHECKS_PASSED=true
fi

# Hard fail when we couldn't determine CI status. This is the fail-closed
# gate from #2868. Bypass requires explicit --skip-ci-gate.
if [ "$CI_FETCH_FAILED" = "true" ] && [ "$CI_GATE_SKIP" != "true" ]; then
  echo "ERROR: Cannot verify CI status (ci wait exit=$CI_EXIT_CODE)." >&2
  if [ "$CI_RATE_LIMITED" = "true" ]; then
    echo "       GitHub API rate limit exhausted. Wait for reset or use --skip-ci-gate." >&2
  fi
  echo "       Refusing to merge against unverified CI status." >&2
  exit 1
fi
```

#### Handle Timeout

`CI_TIMEOUT=true` only fires after the CUMULATIVE budget is exhausted across
chunks (a single expired chunk with budget remaining sets `CI_CHUNK_PENDING`
and re-runs the wait block instead — #187). If timeout reached in batch mode,
treat as failure and proceed to auto-fix or exit. Options: keep waiting
(re-run the wait block; a fresh deadline file grants a new budget), check
status, cancel.

#### Check for Failures

```bash
if [ "$CI_HAS_FAILURES" = "true" ]; then
  FAILED_CHECKS=$(printf '%s\n' "$CI_RESULT" | jq -r '[.checks[] | select(.conclusion == "FAILURE") | .name] | .[]' 2>/dev/null)

  AUTO_FIX_CI=$([ "${ARG_NO_AUTO_FIX_CI:-false}" = "true" ] && echo "false" || echo "true")

  if [ "$AUTO_FIX_CI" = "true" ]; then
    PROCEED_TO_AUTO_FIX=true
  fi
fi
```

If failures exist and auto-fix is disabled, options: view failures, attempt
auto-fix, cancel.

### Pre-Merge Local Test Safety Net

**PURPOSE**: Run build and tests locally as a final safety net before merging.
This catches issues that CI may have missed due to environment differences,
transient failures, or caching. This gate is only skipped when
`--skip-ci-gate` is explicitly set.

**SKIP CONDITION**: If `CI_GATE_SKIP=true` (from `--skip-ci-gate`), skip this
entirely.

```bash
if [ "$CI_GATE_SKIP" != "true" ]; then
  echo "Running pre-merge local test safety net..."

  # Build all workspaces
  if ! npm run build; then
    echo "ERROR: Pre-merge build failed. Fix build errors before merging."
    exit 1
  fi

  # Run all workspace tests
  # NOTE: Workspace test scripts MUST use `vitest run` (not bare `vitest`)
  # to avoid hanging in watch mode. Verify package.json "test" scripts.
  if ! npm run test; then
    echo "ERROR: Pre-merge tests failed. Fix failing tests before merging."
    echo "This is a hard gate — tests must pass before merge."
    exit 1
  fi

  echo "Pre-merge safety net: build and tests passed"
fi
```

**Important**: There is no admin merge bypass in this pipeline — never pass
`--admin` or `--auto` to a merge command (#186). Only `--skip-ci-gate`
bypasses the CI wait and the local test run, and nothing bypasses branch
protection.


---

### Phase 2.5: Auto-Fix Retry Loop

```bash
printf '<!-- phase:start name="auto-fix-retry" index=3 total=13 stage="pr-merge" -->\n'
```

### Auto-Fix Retry Loop

## Contents

- [Configuration and Loop Structure](#configuration-and-loop-structure)
- [Step 0: Fast-Path Merge State Check](#step-0-fast-path-merge-state-check)
- [Step 0.5: Transient-Failure Re-Run Gate](#step-05-transient-failure-re-run-gate-deterministic)
- [Step 1: Fetch Failure Logs](#step-1-fetch-failure-logs)
- [Step 2: Classify Failure Type](#step-2-classify-failure-type)
- [Step 2.5: Baseline-Failure Detection](#step-25-baseline-failure-detection-deterministic)
- [Step 3: Generate Fix](#step-3-generate-fix-probabilistic---ai)
- [Step 4: Commit and Push Fix](#step-4-commit-and-push-fix)
- [Step 5: Hand Back](#step-5-hand-back)
- [Handle Loop Exit](#handle-loop-exit)

**PURPOSE**: Automatically attempt to fix CI failures before merge, following
the Ralph Loop pattern. Skipped if CI checks pass or auto-fix is disabled.

**ENTRY CONDITIONS**: `PROCEED_TO_AUTO_FIX=true` or user selected auto-fix.

**EXIT CONDITIONS**:

- Fix pushed -> Hand back (Step 5): write the marker and end the session
- Max attempts reached -> Report failure and exit
- Non-fixable failure (security) -> Report and exit

#### Configuration and Loop Structure

```bash
AUTO_FIX_MAX_ATTEMPTS=${NIGHTGAUGE_PR_AUTO_FIX_MAX_ATTEMPTS:-2}
AUTO_FIX_ATTEMPT=1
AUTO_FIX_SUCCESS=false

# Steps 0-5 run ONCE per session. `break` below means "leave the auto-fix
# steps and go to Handle Loop Exit".
while :; do
  # Steps 0-5
  break
done
```

One session makes one fix. After it pushes, Step 5 hands the PR back to the
orchestrator: the deterministic pr-merge runner waits for CI and merges or
enqueues. If CI fails again, the orchestrator runs this skill again. It stops
after `pr.auto_fix_max_attempts` sessions (default 2, lowered in issue 3108;
`NIGHTGAUGE_PR_AUTO_FIX_MAX_ATTEMPTS` overrides it) and fails the stage with
`auto-fix-exhausted`. Do not count attempts or poll CI here (#2504).

#### Step 0: Fast-Path Merge State Check

Before each fix iteration, check whether the PR already merged out-of-band (e.g. a previous attempt's push triggered a successful CI run that auto-merged, or a teammate manually merged). This is the cheapest possible deterministic guard against the failure mode in issue 3108: the LLM kept iterating on E2E selectors after the PR had already merged, burning ~$8 in the 60s after the work shipped.

```bash
BINARY="${NIGHTGAUGE_BIN:-}"
[ -n "$BINARY" ] && [ ! -x "$BINARY" ] && BINARY=""
[ -z "$BINARY" ] && BINARY=$(command -v nightgauge 2>/dev/null || echo "")
if [ -z "$BINARY" ] && [ "${NIGHTGAUGE_DEV_REPO_BIN:-}" = "1" ]; then
  for NG_ROOT in "$(git rev-parse --show-toplevel 2>/dev/null)" \
    "$(cd "$(git rev-parse --git-common-dir 2>/dev/null)/.." 2>/dev/null && pwd)"; do
    [ -x "$NG_ROOT/bin/nightgauge" ] || continue
    grep -qx 'module github.com/nightgauge/nightgauge' "$NG_ROOT/go.mod" 2>/dev/null || continue
    NG_ORIGIN="$(git -C "$NG_ROOT" config --get remote.origin.url 2>/dev/null)"
    case "${NG_ORIGIN%.git}" in
      git@github.com:nightgauge/nightgauge | https://github.com/nightgauge/nightgauge | \
        ssh://git@github.com/nightgauge/nightgauge) BINARY="$NG_ROOT/bin/nightgauge"; break ;;
    esac
  done
fi
[ -z "$BINARY" ] && [ -x "$HOME/go/bin/nightgauge" ] && BINARY="$HOME/go/bin/nightgauge"
[ -n "$BINARY" ] && export PATH="$(dirname "$BINARY"):$PATH"
PR_PRECHECK_STATE=$("$BINARY" pr view "$PR_NUMBER" --json 2>/dev/null | jq -r '.state // "UNKNOWN"')
case "$PR_PRECHECK_STATE" in
  MERGED)
    echo "PR #$PR_NUMBER already merged — exiting auto-fix loop (saved one LLM iteration)."
    AUTO_FIX_SUCCESS=true
    break
    ;;
  CLOSED)
    echo "PR #$PR_NUMBER closed without merge — escalating, no further auto-fix attempts."
    break
    ;;
esac
```

This call costs roughly one GraphQL request (~50ms, $0) and runs before any model turn for the iteration. It also handles the race where attempt N's push lands while we are evaluating attempt N+1.

#### Step 0.5: Transient-Failure Re-Run Gate (Deterministic)

**PURPOSE**: Honor the transient-failure signal pr-create already wrote to
`pr-{N}.json`. Before spending any model turns generating a fix, check whether
the failures the previous stage observed were classified as transient
(`is_transient: true`) — network blips, registry timeouts, runner outages.
For those, `gh run rerun --failed` avoids an unnecessary model turn and often
resolves the failure faster than generating a code change for an infrastructure
problem.

For example, when pr-create classifies an action-download timeout as transient
and writes `notes: "pr-merge should re-trigger CI"`, pr-merge should honor the
signal instead of asking RALPH to modify code for a network failure.

```bash
ISSUE_NUMBER="${NIGHTGAUGE_ISSUE_NUMBER:-$(git branch --show-current | sed -n 's#^[^/]*/\([0-9]*\)-.*#\1#p')}"
: "${ISSUE_NUMBER:?set NIGHTGAUGE_ISSUE_NUMBER or check out the issue branch}"
# Read transient-failure flags from the prior stage's handoff.
# pr-merge reads pr-{N}.json (written by pr-create Phase 4).
PR_CONTEXT="$(nightgauge layout path pipeline pr-${ISSUE_NUMBER}.json)"
if [ -f "$PR_CONTEXT" ]; then
  ALL_TRANSIENT=$(jq -r '
    (.ci_monitoring.failures // []) as $f
    | if ($f | length) == 0 then "false"
      else ($f | all(.is_transient == true)) | tostring
      end' "$PR_CONTEXT" 2>/dev/null || echo "false")
  TRANSIENT_NOTE=$(jq -r '.ci_monitoring.notes // ""' "$PR_CONTEXT" 2>/dev/null || echo "")
else
  ALL_TRANSIENT="false"
  TRANSIENT_NOTE=""
fi

if [ "$ALL_TRANSIENT" = "true" ]; then
  echo "[transient-rerun-gate] All failures classified as transient by prior stage."
  echo "[transient-rerun-gate] Note from prior stage: ${TRANSIENT_NOTE:-(none)}"
  echo "[transient-rerun-gate] Re-running failed checks deterministically before engaging RALPH."

  # Find the most recent failed run for this PR and rerun its failed jobs.
  FAILED_RUN=$("$BINARY" pr ci-wait "$PR_NUMBER" --timeout 1 --json 2>/dev/null \
    | jq -r '.checks[] | select(.conclusion == "FAILURE") | .detailsUrl' \
    | grep -oE '[0-9]+' | head -1)
  if [ -z "$FAILED_RUN" ]; then
    # Fallback: ask gh directly for the latest failed run on this PR's branch.
    BRANCH=$(gh pr view "$PR_NUMBER" --json headRefName --jq '.headRefName' 2>/dev/null || echo "")
    if [ -n "$BRANCH" ]; then
      FAILED_RUN=$(gh run list --branch "$BRANCH" --status failure --limit 1 --json databaseId --jq '.[0].databaseId' 2>/dev/null || echo "")
    fi
  fi

  if [ -n "$FAILED_RUN" ]; then
    gh run rerun "$FAILED_RUN" --failed 2>&1 | head -3 || true
    echo "[transient-rerun-gate] Re-run triggered for run $FAILED_RUN. Handing back."
    # The re-run is the fix: hand back exactly as Step 5 does, so the
    # deterministic runner waits for CI (#2504). Do not wait here.
  else
    echo "[transient-rerun-gate] Could not locate a failed run to rerun. Falling through to RALPH."
  fi
fi
```

If the re-run was triggered, go straight to Step 5. Otherwise continue with
Step 1.

**Cost**: one `gh run rerun` call — $0 from a token perspective. If the
transient hypothesis was right, the runner merges after the re-run without a
single model turn. If it was wrong, the runner punts `failed-ci-checks` and
the orchestrator runs this skill again.

**Trust boundary**: the gate only fires when the prior stage's classifier
marked **every** failure transient. A mixed batch (one transient + one code
bug) skips this gate and goes straight to RALPH so the code bug gets attention.

This gate exists to prevent model-driven repair from running before a cheap,
deterministic retry of a purely transient failure.

#### Step 1: Fetch Failure Logs

Find the failed workflow run for the PR, then fetch its logs with the binary.
`ci logs` takes a run ID, not a PR number:

```bash
FAILED_RUN=$("$BINARY" pr ci-wait "$PR_NUMBER" --timeout 1 --json 2>/dev/null \
  | jq -r '[.checks[] | select(.conclusion == "FAILURE")][0].detailsUrl // empty' \
  | grep -oE '/runs/[0-9]+' | grep -oE '[0-9]+')
[ -n "$FAILED_RUN" ] && CI_LOGS=$("$BINARY" ci logs "$FAILED_RUN" --json)
```

The JSON carries `runId`, `status`, `url` and `content` (the failure log text).
Take the failing check's `name` from the `ci-wait` checks list above.

#### Step 2: Classify Failure Type

| Check Name Pattern                            | Failure Type | Auto-Fixable |
| --------------------------------------------- | ------------ | ------------ |
| `*lint*`, `*eslint*`, `*pylint*`, `*style*`   | `lint`       | Yes          |
| `*test*`, `*spec*`, `*vitest*`, `*jest*`      | `test`       | Maybe        |
| `*build*`, `*compile*`, `*bundle*`            | `build`      | Maybe        |
| `*type*`, `*tsc*`, `*typescript*`, `*mypy*`   | `typecheck`  | Maybe        |
| `*format*`, `*prettier*`, `*black*`           | `format`     | Yes          |
| `*security*`, `*audit*`, `*snyk*`, `*codeql*` | `security`   | No (break)   |

If `security` type detected, break out of retry loop immediately.

#### Step 2.5: Baseline-Failure Detection (Deterministic)

**PURPOSE**: Distinguish failures introduced by this PR (regressions) from
failures that already exist on the base branch. Before spending model turns
attempting an LLM fix, run the same failing test(s) at the PR's merge-base on
the base branch. This costs one local test run (~1-3 min for most repos), no
model spend, and routinely saves $10+ of useless LLM iteration when a PR
inherits a broken main.

**When to run this check**: Only for `test`, `build`, `typecheck`, or `unknown`
failure types. Lint/format failures are deterministic and don't benefit from
the comparison.

**Procedure** (skip cleanly if any step errors — we treat the _classification_
as a best-effort optimization. Once a classification exists, the _exit gate_
in step 9 below is mandatory, not advisory — see acme-api#100
retro for why making this advisory burned a sham `[skip build]` commit).

1. Extract the specific test names / build targets from the failure logs.
2. Identify the merge base: `MERGE_BASE=$(git merge-base HEAD origin/$BASE_REF)`.
3. Stash any uncommitted changes. The message shape is a contract (#330) —
   only a stash carrying the `nightgauge:` marker can be reclaimed by
   `nightgauge stash sweep` or reported by `nightgauge doctor`, and an
   unmarked one is indistinguishable from the operator's own, so no tool will
   ever touch it:
   `git stash push -u -m "nightgauge:baseline:${PR}:auto-fix" 2>/dev/null`.
4. `git checkout "$MERGE_BASE"` (detached HEAD is fine).
5. Run the same failing tests/builds on the base. Capture pass/fail per item.
6. `git checkout -` and `git stash pop 2>/dev/null` to return to the PR
   branch. If the stage is killed before this line the stash survives; the
   marker in step 3 is what lets a later `nightgauge stash sweep` reclaim it,
   since a SIGKILL runs no cleanup here at all.
7. Classify each failure as one of:
   - **`regression`** — passed on base, fails on HEAD. Caused by this PR.
     The agent must fix.
   - **`inherited`** — fails on both base and HEAD. Pre-existing on main; the
     PR did not introduce it. Do **not** ask the LLM to repair main from
     within a dependent PR (see step 9).
8. Persist the classification as `auto-fix-baseline-{PR}.json` with
   `nightgauge layout write pipeline auto-fix-baseline-{PR}.json` (JSON on stdin)
   so subsequent iterations can read it without re-running.
9. **Inherited-only exit gate (deterministic, mandatory).** Count failures by
   classification:

   ```bash
   BASELINE="$(nightgauge layout path pipeline auto-fix-baseline-${PR_NUMBER}.json)"
   if [ -f "$BASELINE" ]; then
     INHERITED=$(jq '[.failures[]? | select(.classification == "inherited")] | length' "$BASELINE" 2>/dev/null || echo 0)
     REGRESSIONS=$(jq '[.failures[]? | select(.classification == "regression")] | length' "$BASELINE" 2>/dev/null || echo 0)
     if [ "$REGRESSIONS" = "0" ] && [ "$INHERITED" -gt "0" ]; then
       echo "[baseline-exit-gate] $INHERITED inherited failure(s), 0 regressions."
       echo "[baseline-exit-gate] Surfacing to user — main is broken, not this PR."
       # Surface: post a PR comment with the inherited test names + a hint that
       # the fix belongs on main, then label the PR `pipeline-failed-inherited`
       # and exit the loop. Do NOT proceed to Step 3 (LLM fix generation) —
       # asking the model to repair main from a feature branch produces sham
       # commits like `chore: re-trigger CI [skip build]` (platform#956).
       AUTO_FIX_EXIT_REASON="inherited-only"
       break
     fi
     if [ "$REGRESSIONS" -gt "0" ] && [ "$INHERITED" -gt "0" ]; then
       echo "[baseline-exit-gate] Mixed: $REGRESSIONS regression(s) + $INHERITED inherited. Fixing regressions only."
       # Continue to Step 3, but the fix-generation prompt MUST be scoped to
       # the `regression` failures (Step 3 reads the baseline file).
     fi
   fi
   ```

   This is the load-bearing change vs. the original advisory text: when the
   PR introduces _zero_ regressions, the loop exits before any model spend
   instead of trusting the LLM to choose "surface or fix" correctly.

**Cost guardrail**: If more than `NIGHTGAUGE_PR_BASELINE_MAX_FAILURES`
(default 30) tests fail on base, skip baseline detection entirely and treat all
failures as inherited — at that scale the cheapest action is to surface to
the user, not to spend model turns blindly fixing main. Emit a one-line
warning: `"baseline check skipped: base branch has N failing tests (>$cap)"`.

**Agent instruction when failures are mostly `inherited`**: tell the user
plainly. Example output:

> Detected 22 failing tests on PR #218. Re-running the same tests on the
> merge-base showed 22 of 22 also fail on main — these are not regressions
> introduced by this PR. Main is broken (likely from PR #197). Recommend
> blocking on a main-branch fix before merging further work, or fixing all
> 22 in this PR. Estimated fix scope: ~12 of 22 are stale `find.text` matches
> easy to repair; remainder need Riverpod 3.x AsyncValue handling changes.

This step is the highest-leverage addition to auto-fix: a deterministic check
that prevents the LLM from chasing inherited failures it has no signal to
recognize. Reference: issue 3662 (Flutter PR #218 burned $12 hitting `num_turns: 40`
on a stack of 22 pre-existing failures it could not have known were not its).

#### Step 3: Generate Fix (Probabilistic - AI)

**This is the ONLY probabilistic step in the auto-fix loop.** Based on failure
type and logs, generate an appropriate fix.

**Subagent Model**: When spawning a Task subagent for fix generation, specify
`model: "sonnet"` to use a cost-optimized model.

| Failure Type | Fix Approach                                           |
| ------------ | ------------------------------------------------------ |
| `lint`       | Run linter with --fix, or manually fix reported issues |
| `format`     | Run formatter (prettier, black, etc.)                  |
| `typecheck`  | Fix type errors based on compiler output               |
| `build`      | Fix missing imports, syntax errors, config issues      |
| `test`       | Analyze test failure, fix assertion or implementation  |
| `unknown`    | Read logs carefully, attempt minimal targeted fix      |

Fix generation process:

1. Read the failure logs to understand the specific error
2. If Step 2.5 ran, read
   `"$(nightgauge layout path pipeline auto-fix-baseline-{PR}.json)"`.
   **Only attempt to fix failures whose `classification` is `regression`.**
   Inherited failures were already handled by Step 2.5's exit gate
   (inherited-only) or are intentionally being ignored by Step 2.5's
   mixed-batch branch — touching them here re-introduces the same
   wasted-spend pattern.
3. Identify the affected file(s) and line number(s)
4. Make the minimal fix required to address the failure
5. Ensure fix doesn't break other functionality
6. If you cannot produce a real diff for any regression failure, stop.
   Step 4's sham-commit guard will reject an empty commit anyway — making
   that decision here saves a model turn.

#### Step 4: Commit and Push Fix

Check for changes (`git diff --quiet`). If no changes, **break out of the
loop** — do not continue to the next attempt and do not invent a re-trigger
commit. "No diff" means Step 3 produced nothing meaningful; making another
attempt without new diagnostics will produce the same nothing.

Otherwise stage the working tree and run the sham-commit guard **before**
calling `git commit`:

```bash
ISSUE_NUMBER="${NIGHTGAUGE_ISSUE_NUMBER:-$(git branch --show-current | sed -n 's#^[^/]*/\([0-9]*\)-.*#\1#p')}"
: "${ISSUE_NUMBER:?set NIGHTGAUGE_ISSUE_NUMBER or check out the issue branch}"
git add -A

# --- Sham-commit guard (deterministic, mandatory) ---
# Reject any "fix" that has no real diff or that resembles a CI re-trigger
# nudge. These produce noise on the PR, do not change CI behavior, and hide
# the real failure from the user. See acme-api#100 retro:
# the LLM pushed `chore: re-trigger CI [skip build]` (an empty commit, with
# a Netlify-only directive that GitHub Actions ignores) after baseline
# detection had already classified all failures as inherited.

if git diff --cached --quiet; then
  echo "[sham-commit-guard] No staged changes — refusing to push empty commit."
  echo "[sham-commit-guard] Surfacing to user; main loop will exit."
  AUTO_FIX_EXIT_REASON="empty-fix"
  break
fi

PROPOSED_MSG="fix(#$ISSUE_NUMBER): auto-fix CI failure (attempt $AUTO_FIX_ATTEMPT)"
# Defense-in-depth: even if a future change moves the commit message into a
# subagent's hands, refuse known re-trigger phrasings.
case "$PROPOSED_MSG" in
  *"re-trigger CI"*|*"retrigger CI"*|*"[skip build]"*|*"[skip ci]"*|*"empty commit"*)
    echo "[sham-commit-guard] Commit message matches a re-trigger pattern — refusing."
    AUTO_FIX_EXIT_REASON="sham-commit"
    break
    ;;
esac

git commit -m "$PROPOSED_MSG"
```

Then push the fix to the remote so CI can see the new commit:

```bash
git push origin HEAD
```

If push fails (e.g., remote rejected, network error), report the error and break
out of the retry loop — do NOT proceed to Step 5. CI cannot check commits
that are not on the remote.

**Never use `git commit --allow-empty` in the auto-fix loop.** An empty commit
is a strong signal that the loop has run out of useful work; the correct
response is to break and surface, not to pretend a fix happened.

#### Step 5: Hand Back

Do not wait for CI and do not merge. Record the pushed head and end the
session:

```bash
ISSUE_NUMBER="${NIGHTGAUGE_ISSUE_NUMBER:-$(git branch --show-current | sed -n 's#^[^/]*/\([0-9]*\)-.*#\1#p')}"
: "${ISSUE_NUMBER:?set NIGHTGAUGE_ISSUE_NUMBER or check out the issue branch}"
jq -n --argjson pr "$PR_NUMBER" --arg sha "$(git rev-parse HEAD)" \
  --argjson attempt "$AUTO_FIX_ATTEMPT" \
  '{result: "fix-pushed", pr_number: $pr, head_sha: $sha, attempt: $attempt}' \
  | nightgauge layout write pipeline "pr-merge-fix-${ISSUE_NUMBER}.json" >/dev/null
echo "Fix pushed to PR #$PR_NUMBER — handing back to the deterministic merge path."
exit 0
```

The orchestrator reads `pr-merge-fix-{N}.json`, deletes it, and runs the
deterministic runner, which waits up to `pr.ci_check_timeout` for CI. Green CI
merges or enqueues the PR. A new `failed-ci-checks` result runs this skill
again while attempts remain. Any other result fails the stage. Outside the
orchestrator nothing reads the marker: finish with
`nightgauge pr merge "$PR_NUMBER"`.

**NEVER** poll CI after a push with repeated `nightgauge ci wait` calls,
`gh pr checks`, or `sleep`. The deterministic path waits for you (#2504).

#### Handle Loop Exit

If `AUTO_FIX_SUCCESS != true` after the loop ends:

- In batch mode: write failure info to context file (`ci_auto_fix_failed`,
  `ci_failure` with name/url/type/attempts), set `ci_auto_fix_exit_reason`
  from `$AUTO_FIX_EXIT_REASON` (one of: `max-attempts`, `inherited-only`,
  `empty-fix`, `sham-commit`, `security`, `push-failed`), then exit 1.
- pr-merge uses `ci_auto_fix_exit_reason` to choose the surface message and
  PR label:
  - `inherited-only` → label `pipeline-failed-inherited`, comment names the
    failing tests and the most-recent merge to the base branch as the likely
    culprit. Does **not** ask the PR author to fix main from this branch.
  - `empty-fix` / `sham-commit` → label `pipeline-failed-no-diagnosis`,
    comment includes the last failure log and the regressions list — the
    pipeline ran out of useful fixes, not the user.
  - `max-attempts` → label `pipeline-failed`, existing behavior.
- Options the user has from the surface comment: view failure details, try
  more attempts, fix manually, force merge with admin.

#### Auto-Fix Success

On success, update context file with `ci_auto_fix_applied: true` and
`ci_auto_fix_attempts`, then proceed to the pre-merge safety net.


---

### Phase 3: Fetch & Parse Review Feedback

```bash
printf '<!-- phase:start name="fetch-reviews" index=4 total=13 stage="pr-merge" -->\n'
```

Fetch PR details and reviews, wait for CI status, fetch inline review comments
and review summaries, and parse both automated and human reviews.

> **Read `_includes/reviews.md` (same directory as this SKILL.md) now and follow its instructions before continuing this phase.**

---

### Phase 4: Categorize Issues

```bash
printf '<!-- phase:start name="categorize-issues" index=5 total=13 stage="pr-merge" -->\n'
```

#### Step 4.1: Define Categories

| Category     | Keywords                                                        | Action                   |
| ------------ | --------------------------------------------------------------- | ------------------------ |
| **Critical** | blocking, must fix, security, REQUIRED                          | Must fix before merge    |
| **Major**    | should fix, important, recommended, please fix                  | Should fix, user decides |
| **Minor**    | suggestion, nit, consider, non-blocking, optional, low priority | Can merge as-is          |

#### Step 4.2: Determine Merge Readiness

```bash
REVIEW_DECISION=$("$BINARY" pr view "$PR_NUMBER" --json 2>/dev/null | jq -r '.reviewStatus // "REVIEW_REQUIRED"')
```

Ready if `APPROVED` and no critical issues. Not ready if `REVIEW_REQUIRED` or
`CHANGES_REQUESTED`. Otherwise ready (no review required).

---

### Phase 5: Address Feedback

```bash
printf '<!-- phase:start name="address-feedback" index=6 total=13 stage="pr-merge" -->\n'
```

The binary decides what a review finding may do, and how many rounds a PR may
spend. A green, mergeable PR enqueues; remaining findings become issues, not
more commits on this PR (#2730). Ask it first:

```bash
ISSUE_NUMBER="${NIGHTGAUGE_ISSUE_NUMBER:-$(git branch --show-current | sed -n 's#^[^/]*/\([0-9]*\)-.*#\1#p')}"
: "${ISSUE_NUMBER:?set NIGHTGAUGE_ISSUE_NUMBER or check out the issue branch}"
REVIEW_GATE=$(nightgauge pr-stage review-gate "$ISSUE_NUMBER" --json)
printf '%s\n' "$REVIEW_GATE" | jq '{proceed, verdict, reason, rounds, hold, filed}'
```

#### Step 5.0: Check for Fast-Track Merge

Fast-track conditions (all must be true): `READY_TO_MERGE`, `verdict` is
`enqueue`, CI passed, PR mergeable.

If `FAST_TRACK=true`, skip Steps 5.1-5.4 and proceed to Phase 6.

#### Step 5.1: Handle Hold Findings

`verdict` `fix-round` lists the findings in `hold`: every blocker, plus any
correctness major on a documented user path. Those are the ONLY findings this
stage fixes.

#### Step 5.2: Leave Every Other Finding Alone

Do not fix any other major or nitpick here, whether or not it looks easy. The
gate files each remaining major as an issue before the merge, and every extra
commit re-runs CI and restarts the review.

#### Step 5.3: Stop on a Refusal

`verdict` `hold-at-cap` or `wait-round`, or any `path` of `refused`, is terminal
for this stage: report the `reason` and stop. Do not start another round and do
not merge. `MaxReviewFixRounds` is a hard cap for the owner to look at.

#### Step 5.4: Apply One Fix Round

Wrap the fix in a round. `begin` refuses past the cap, and that refusal is final.
While a round is open nothing may enqueue, so `end` only after the push:

```bash
ISSUE_NUMBER="${NIGHTGAUGE_ISSUE_NUMBER:-$(git branch --show-current | sed -n 's#^[^/]*/\([0-9]*\)-.*#\1#p')}"
: "${ISSUE_NUMBER:?set NIGHTGAUGE_ISSUE_NUMBER or check out the issue branch}"
nightgauge pr-stage review-round begin "$ISSUE_NUMBER" || exit 1
# Fix the `hold` findings, stage, commit with
# `fix(#$ISSUE_NUMBER): address review feedback`, and push.
nightgauge pr-stage review-round end "$ISSUE_NUMBER"
```

Run one round, then wait for CI to re-run on the new head and continue to the
next phase. Never loop back to Phase 3.

---

### Phase 5.5: Proactive Freshness Check

```bash
printf '<!-- phase:start name="freshness-check" index=7 total=13 stage="pr-merge" -->\n'
```

**PURPOSE**: Proactively merge the latest base branch into the feature branch
BEFORE attempting merge. This prevents the reactive conflict resolution in Phase
6 from ever being needed in most cases. Critical for epic batch processing where
concurrent sub-issues merge and shift main/epic branch forward.

### Base Branch Freshness Check

**PURPOSE**: Ensure the feature branch is up-to-date with the latest base branch
before proceeding. This prevents merge conflicts and build failures caused by
concurrent epic sub-issues or other PRs merging into the base branch while this
branch was being worked on.

**WHEN**: Run this check before any build/test validation and before merge
attempts. It is especially critical during epic batch processing where multiple
sub-issues merge concurrently.

**Merge, never rebase (#2699)**: the branch may already back an open PR. A
rebased tip stops descending from origin's tip, so the next push is rejected as
non-fast-forward and the run ends `branch_forked`. Merging the base keeps
origin's tip an ancestor, so a plain push always fast-forwards. Never rebase
the branch and never force-push it.

```bash
# Determine base branch (epic branch or main)
FRESHNESS_BASE="${BASE_BRANCH:-main}"
echo "Checking freshness against $FRESHNESS_BASE..."

# Fetch latest state of the base branch
git fetch origin "$FRESHNESS_BASE" 2>/dev/null

# Count commits on base that are NOT in our branch
BEHIND_COUNT=$(git rev-list --count "HEAD..origin/$FRESHNESS_BASE" 2>/dev/null || echo "0")

if [ "$BEHIND_COUNT" -gt 0 ]; then
  echo "Branch is $BEHIND_COUNT commit(s) behind origin/$FRESHNESS_BASE. Merging the base..."

  CURRENT_BRANCH=$(git branch --show-current)

  if git merge --no-edit -m "Merge origin/$FRESHNESS_BASE into $CURRENT_BRANCH" "origin/$FRESHNESS_BASE" 2>/dev/null; then
    echo "Merge successful. Branch is now up-to-date with $FRESHNESS_BASE."
    # Plain push; push-branch never forces and recovers a self-inflicted fork.
    # Best-effort: the cause is printed with the fork tokens filtered out, so a
    # rejection here cannot stamp the `[branch-forked]` terminal marker on a
    # stage that carries on (#2749). The filter copies the `branch_forked`
    # clauses of internal/terminalkind/table.json.
    PUSH_ERR=$(mktemp)
    if ! nightgauge git push-branch 2>"$PUSH_ERR"; then
      echo "WARNING: Failed to push merged branch. Continuing with the local merge."
      grep -viE 'branch[-_]forked|non-fast-forward|push rejected|fetch first' "$PUSH_ERR" || true
    fi
    rm -f "$PUSH_ERR"
  else
    # Merge stopped on conflicts — check if they are resolvable.
    #
    # Enumerated with `ls-files -u -z`, NOT `git diff --name-only
    # --diff-filter=U`: the latter C-quotes any path with a non-ASCII or
    # control byte, so a conflict in `café.txt` arrives as the literal
    # 12-character string `"caf\303\251.txt"` and the `git add` below fails on
    # a path that does not exist — leaving that conflict silently unresolved
    # (#301). `-z` emits raw path bytes with a NUL terminator, so neither
    # quoting nor a newline in a path can confuse the loop. git sorts the
    # output by path, so skipping a repeat of the previous path collapses that
    # path's index stages to one entry.
    #
    # Append with `+=`, never `CONFLICT_FILES[$N]=` from N=0: zsh arrays are
    # 1-indexed and abort the whole loop with "assignment to invalid subscript
    # range" on index 0, enumerating nothing — and the agent shell is not
    # guaranteed to be bash (zsh has been the macOS login shell since Catalina).
    CONFLICT_FILES=()
    CONFLICT_PREV=""
    while IFS= read -r -d '' CONFLICT_REC; do
      CONFLICT_PATH="${CONFLICT_REC#*$'\t'}"
      if [ "$CONFLICT_PATH" != "$CONFLICT_PREV" ]; then
        CONFLICT_FILES+=("$CONFLICT_PATH")
        CONFLICT_PREV="$CONFLICT_PATH"
      fi
    done < <(git ls-files -u -z 2>/dev/null)
    CONFLICT_COUNT=${#CONFLICT_FILES[@]}

    if [ "$CONFLICT_COUNT" -gt 0 ]; then
      printf 'Merge conflicts detected in:'
      printf ' %s' "${CONFLICT_FILES[@]}"
      printf '\n'
      echo "Attempting AI-assisted conflict resolution..."

      # For each conflicted file:
      # 1. Read the file with conflict markers
      # 2. Understand BOTH sides. Under `git merge`, the `<<<<<<< HEAD` side
      #    is this branch's own feature work and the `>>>>>>> origin/<base>`
      #    side is the base being merged in.
      # 3. Produce a logically correct merge preserving BOTH changes
      # 4. Stage the resolved file
      #
      # CRITICAL RULES:
      # - NEVER blindly accept one side
      # - If resolution is ambiguous, abort and fail with clear error
      # - After resolution, code MUST compile

      for FILE in "${CONFLICT_FILES[@]}"; do
        echo "Resolving: $FILE"
        # ... AI resolves the conflict ...
        git add "$FILE"
      done

      if git commit --no-edit 2>/dev/null; then
        echo "Conflict resolution successful."
        PUSH_ERR=$(mktemp)
        if ! nightgauge git push-branch 2>"$PUSH_ERR"; then
          echo "WARNING: Failed to push merged branch. Continuing with the local merge."
          grep -viE 'branch[-_]forked|non-fast-forward|push rejected|fetch first' "$PUSH_ERR" || true
        fi
        rm -f "$PUSH_ERR"
      else
        echo "ERROR: Committing the merge failed after conflict resolution."
        git merge --abort 2>/dev/null || true
        echo "FRESHNESS_CHECK_FAILED=true"
        echo "Manual conflict resolution required. Base branch has diverged significantly."
        # Do NOT exit — let the calling phase decide how to handle
        FRESHNESS_CHECK_FAILED=true
      fi
    else
      git merge --abort 2>/dev/null || true
      echo "ERROR: Merge failed with no conflict markers. Unexpected state."
      FRESHNESS_CHECK_FAILED=true
    fi
  fi
else
  echo "Branch is up-to-date with origin/$FRESHNESS_BASE."
fi
```

**Output variables**:

- `BEHIND_COUNT` — how many commits the branch was behind (0 = already fresh)
- `FRESHNESS_CHECK_FAILED` — set to `true` if the merge + conflict resolution
  failed. The calling phase should decide whether to abort or continue.

**Safety**: Merges the base and pushes with `nightgauge git push-branch`, which
never forces. If the push fails, the cause is printed without the terminal
marker, and the local merge is still valid for build/test validation.


If `FRESHNESS_CHECK_FAILED=true`, proceed to Phase 6 anyway — the reactive
conflict resolution (Step 6.1.5) may still succeed with a different strategy.
If the merge succeeded and pushed, CI re-runs on the new head. Do not wait
for it here: hand back to the deterministic merge path, which waits for CI and
merges (#2504):

```bash
if [ "$BEHIND_COUNT" -gt 0 ] && [ "$FRESHNESS_CHECK_FAILED" != "true" ] && [ "$CI_EPIC_SKIP" != "true" ]; then
  ISSUE_NUMBER="${NIGHTGAUGE_ISSUE_NUMBER:-$(git branch --show-current | sed -n 's#^[^/]*/\([0-9]*\)-.*#\1#p')}"
  jq -n --argjson pr "$PR_NUMBER" --arg sha "$(git rev-parse HEAD)" \
    '{result: "fix-pushed", pr_number: $pr, head_sha: $sha, attempt: 0}' \
    | nightgauge layout write pipeline "pr-merge-fix-${ISSUE_NUMBER}.json" >/dev/null
  echo "Base merged and pushed — handing back to the deterministic merge path."
  exit 0
fi
```

The marker contract is in `skills/_shared/AUTO_FIX_LOOP.md` Step 5. **NEVER**
poll CI here with repeated `nightgauge ci wait` calls.

---

### Phase 6: Merge

```bash
printf '<!-- phase:start name="merge" index=8 total=13 stage="pr-merge" -->\n'
```

Run the ruleset pre-check, final mergeable verification, the knowledge
conformance gate (every entry this issue wrote must carry the frontmatter
contract), conflict resolution, merge-strategy selection, the review gate, the
deterministic Go-binary merge (with its `blockedBy` gate), and merge
verification.

> **NEVER pass `--admin` (or `--auto`) to any merge command — no admin bypass
> exists in this pipeline.** A merge blocked by branch protection or required
> checks is TERMINAL for this stage: report the blocker and escalate; do not
> improvise an admin-bypass merge via raw `gh` (incident: #186). A PreToolUse
> hook blocks these flags during pipeline sessions.

> **Read `_includes/merge.md` (same directory as this SKILL.md) now and follow its instructions before continuing this phase.**

---

### Phase 7: Post-Merge Verification & Cleanup

```bash
printf '<!-- phase:start name="post-merge-cleanup" index=9 total=13 stage="pr-merge" -->\n'
```

Verify the post-merge build, close the issue and sync the board deterministically,
fire the post-merge hook, check epic completion, delete the feature branch, and
record the outcome to the complexity model.

> **Read `_includes/post-merge.md` (same directory as this SKILL.md) now and follow its instructions before continuing this phase.**

---

### Phase 7.8: Retrospective Feedback

```bash
printf '<!-- phase:start name="retrospective-feedback" index=10 total=13 stage="pr-merge" -->\n'
```

Capture non-blocking post-merge workflow feedback (interactive only; skipped in
headless mode) and persist it to the context file.

> **Read `_includes/feedback.md` (same directory as this SKILL.md) now and follow its instructions before continuing this phase.**

---

### Phase 8: Output Summary

```bash
printf '<!-- phase:start name="output-summary" index=11 total=13 stage="pr-merge" -->\n'
```

```
PR:       #57
Title:    feat(#26): add parallel PR context gathering
Merged:   via squash
Branch:   feat/26-parallel-pr-context (deleted)

Status Updates:
- Issue #26: Closed
- Project board: Done (via GitHub built-in workflow)

Summary:
- CI checks: All passed
- Post-merge build: Verified OK
- Reviews: Approved (9/10 quality score)
- Merge: Squash merged to main

Next Steps:
You're now on the main branch with all changes merged.
Ready for the next issue: /nightgauge-issue-pickup
```

#### Step 8.1: Signal Stage Complete

```bash
ISSUE_NUMBER="${NIGHTGAUGE_ISSUE_NUMBER:-$(git branch --show-current | sed -n 's#^[^/]*/\([0-9]*\)-.*#\1#p')}"
: "${ISSUE_NUMBER:?set NIGHTGAUGE_ISSUE_NUMBER or check out the issue branch}"
# Go binary: project move-status
BINARY="${NIGHTGAUGE_BIN:-}"
[ -n "$BINARY" ] && [ ! -x "$BINARY" ] && BINARY=""
[ -z "$BINARY" ] && BINARY=$(command -v nightgauge 2>/dev/null || echo "")
if [ -z "$BINARY" ] && [ "${NIGHTGAUGE_DEV_REPO_BIN:-}" = "1" ]; then
  for NG_ROOT in "$(git rev-parse --show-toplevel 2>/dev/null)" \
    "$(cd "$(git rev-parse --git-common-dir 2>/dev/null)/.." 2>/dev/null && pwd)"; do
    [ -x "$NG_ROOT/bin/nightgauge" ] || continue
    grep -qx 'module github.com/nightgauge/nightgauge' "$NG_ROOT/go.mod" 2>/dev/null || continue
    NG_ORIGIN="$(git -C "$NG_ROOT" config --get remote.origin.url 2>/dev/null)"
    case "${NG_ORIGIN%.git}" in
      git@github.com:nightgauge/nightgauge | https://github.com/nightgauge/nightgauge | \
        ssh://git@github.com/nightgauge/nightgauge) BINARY="$NG_ROOT/bin/nightgauge"; break ;;
    esac
  done
fi
[ -z "$BINARY" ] && [ -x "$HOME/go/bin/nightgauge" ] && BINARY="$HOME/go/bin/nightgauge"
[ -n "$BINARY" ] && export PATH="$(dirname "$BINARY"):$PATH"
if [ -n "$BINARY" ]; then
  "$BINARY" project move-status "$ISSUE_NUMBER" "done" 2>/dev/null || true
fi
```

---

### Phase 9: Self-Assessment Epilogue

```bash
printf '<!-- phase:start name="self-assessment" index=12 total=13 stage="pr-merge" -->\n'
```

### Self-Assessment Epilogue

**PURPOSE**: Evaluate whether this skill's instructions matched reality during
this execution. This phase is **non-blocking** — skip entirely if any main phase
failed. A perfectly working skill produces **no output** from this phase.

> See [docs/SKILL_SELF_ASSESSMENT.md](https://github.com/nightgauge/nightgauge/blob/main/docs/SKILL_SELF_ASSESSMENT.md) for
> the full strategy, synthesis algorithm, and integration architecture.

#### When to Skip

- Any prior phase exited with an error before completing
- The skill was cancelled or timed out
- Running in `--dry-run` mode

#### Step 1: Evaluate Execution Friction

Review the execution that just completed. Answer these questions honestly:

1. **Command failures**: Did any command, script, or binary call in the skill
   instructions fail? (e.g., script not found, wrong flags, missing binary)
2. **Workarounds**: Did you have to deviate from the skill instructions to
   accomplish the goal? (e.g., used `gh` directly because a referenced script
   didn't exist, skipped a step that referenced a nonexistent file)
3. **Stale references**: Did any file path, function name, API endpoint, or tool
   referenced in the instructions not exist in the current codebase?
4. **Unclear instructions**: Were any instructions ambiguous enough that you had
   to guess at the intended behavior?
5. **Missing instructions**: Was there a significant step you had to figure out
   on your own that should have been documented in this skill?

**If ALL answers are "no" — write nothing and complete the skill normally.** The
goal is silence when everything works.

#### Step 2: Write Assessment Record

Only if friction was detected in Step 1. Write a single JSON file,
`assessments/{STAGE_NAME}-${ISSUE_NUMBER}.json` in the clone's pipeline state
directory, through the binary (JSON on stdin; never a direct file write):

```bash
ISSUE_NUMBER="${NIGHTGAUGE_ISSUE_NUMBER:-$(git branch --show-current | sed -n 's#^[^/]*/\([0-9]*\)-.*#\1#p')}"
: "${ISSUE_NUMBER:?set NIGHTGAUGE_ISSUE_NUMBER or check out the issue branch}"
nightgauge layout write pipeline "assessments/{STAGE_NAME}-${ISSUE_NUMBER}.json" <<'JSON'
{ ... the record below ... }
JSON
```

The assessment record MUST follow this schema:

```json
{
  "schema_version": "1",
  "skill": "{STAGE_NAME}",
  "skill_file": "skills/nightgauge-{STAGE_NAME}/SKILL.md",
  "issue_number": 42,
  "timestamp": "2026-03-10T14:30:00Z",
  "friction": [
    {
      "type": "command_failure",
      "severity": "high",
      "description": "hooks/lib/add-to-project.sh not found — script was deleted",
      "skill_line_hint": "claude-plugins/nightgauge/hooks/lib/add-to-project.sh <issue-number>",
      "actual_resolution": "Used gh api graphql to add issue to project board directly",
      "suggested_fix": "Replace with: nightgauge project add <issue-number>"
    }
  ]
}
```

**Friction types**: `command_failure`, `workaround`, `stale_reference`,
`unclear_instruction`, `missing_instruction`

**Severity levels**:

- `high` — instruction is **broken**. Required manual workaround to complete.
- `medium` — instruction is **misleading**. Agent adapted without user help.
- `low` — instruction is **suboptimal**. No functional impact.

**Rules**:

- **One record per execution** — multiple friction items go in the `friction`
  array, not separate files.
- **Be specific** — quote the exact instruction text that was wrong. Not "some
  commands didn't work" but "Step 5.2 calls `hooks/lib/add-to-project.sh` but
  this script was deleted in commit 65915701."
- **Suggest the fix** — every finding MUST include `suggested_fix` with the
  concrete SKILL.md change needed. Not "update the docs" but "replace
  `hooks/lib/add-to-project.sh <N>` with `nightgauge project add <N>`."
- **Don't invent friction** — only report issues you actually encountered during
  this execution. Do not speculate about potential problems.

#### Step 3: Validate and Complete

```bash
# Validate JSON if written
ISSUE_NUMBER="${NIGHTGAUGE_ISSUE_NUMBER:-$(git branch --show-current | sed -n 's#^[^/]*/\([0-9]*\)-.*#\1#p')}"
: "${ISSUE_NUMBER:?set NIGHTGAUGE_ISSUE_NUMBER or check out the issue branch}"
ASSESSMENT_FILE="$(nightgauge layout path pipeline "assessments/{STAGE_NAME}-${ISSUE_NUMBER}.json")"
if [ -f "$ASSESSMENT_FILE" ]; then
  python3 -m json.tool "$ASSESSMENT_FILE" > /dev/null 2>&1 || \
    echo "WARNING: Assessment record is not valid JSON" >&2
fi
```


---

## Failure Cleanup (CRITICAL — Prevents Stale PRs)

**EVERY `exit 1` in this skill MUST go through this cleanup function first.**
Without this, failed pipeline runs leave orphaned PRs that nobody notices until
they pile up.

When the skill is about to exit with a non-zero code AND a PR number is known,
define and invoke the `cleanup_failed_pr` function. Replace ALL bare `exit 1`
calls in the phases above with the `cleanup_failed_pr` + `exit 1` pattern.

> **Read `_includes/failure-cleanup.md` (same directory as this SKILL.md) now and follow its instructions before relying on this section.**

---

## Error Handling

| Condition         | Action                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| No open PR        | Exit with error: "No open PR found for branch." Suggest `/nightgauge-pr-create`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| Already merged    | Exit 0: "PR has already been merged."                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| CI failures       | Auto-fix up to 3 attempts. On exhaustion: label PR `pipeline-failed`, comment with details, move issue to Ready, exit 1.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| Changes requested | Report reviewer feedback. On critical unresolved: label PR `pipeline-failed`, comment, exit 1.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| Merge conflicts   | Attempt automatic rebase + AI conflict resolution (Step 6.1.5). If unresolvable: capture `conflict-context-{N}.json` (files + both sides) **before** `git rebase --abort`, emit a `CONFLICT_RESOLUTION_NEEDED` feedback signal targeting feature-dev, **keep the branch**, exit 1. The recovery loop re-dispatches feature-dev on the same branch to resolve — bounded by `pipeline.recovery.conflict_recovery.max_dev_redispatch`, then escalates with the specific files. (No fresh-branch restart / `conflict-restart-{N}.json`.)                                                                                                           |
| Branch protection | **Non-retryable.** Include the raw merge error (e.g. `Required status check "X" is expected`) in the failure output so the Go classifier records `ruleset-blocked` and the orchestrator skips the retry (#185). Write the structured `blocker` record into `pr-{N}.json` (`{classification, remediation, non_retryable: true}` — see context-bootstrap.md) so the orchestrator surfaces the blocked terminal state (#190). Label PR `pipeline-failed`, comment with the blocker + remediation from Step 6.0's precheck (`config_mismatches[].remediation` when present), exit 1. Never re-attempt the merge or re-run failing required checks. |

Files in this skill

  • CHANGELOG.md7.5 KB
  • SKILL.md83.3 KB
  • _includes/context-bootstrap.md7.6 KB
  • _includes/failure-cleanup.md6.5 KB
  • _includes/feedback.md6.5 KB
  • _includes/merge.md40.3 KB
  • _includes/post-merge.md19.8 KB
  • _includes/reviews.md4.8 KB
  • _includes/validate-environment.md5.7 KB
  • _profiles/compact.md9 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.