# gatekeep-pr: cleanup

Read from `decision-menu.md` when `cleanup` is picked on a terminal overlay. Input: the digest (`pr.url`, `pr.merge_commit` - `mergeCommit.oid` from Section A or `### Merge course`, `pr.state`, `pr.headRefOid`, `pr.baseRefName`, `issue.ref` or `issue: null`, the `worktree` block) and the continuation record, plus the `## cleanup` section of the merge-base overrides file (`assessment.md` `## Configuration`). Output: one rendered line per step - `<label>: done`, `<label>: skipped - <reason>`, `<label>: failed - <first error line>`, or `<label>: invoked /skill:<name> - <where it paused or what it returned>` for a `/skill:` item - then set `cleanup_done` and re-render the same terminal menu without the `cleanup` row.

**Landed** means `pr.state` is `MERGED`. **Primary** is the parent directory of `git -C <worktree.path> rev-parse --git-common-dir`; it is the checkout the session runs in. Never remove it and never change its branch. Run every git command as `git -C <primary>`; touch nothing under the removed worktree path after step 1.

Run the steps in order; a failing step prints its first error line and the loop continues; retry nothing.

1. **Worktree.** Eligible when `worktree.path` is not the primary, `git -C <worktree.path> status --porcelain --untracked-files=no` is empty (tracked-clean; untracked files never block removal), and - when landed - `git -C <worktree.path> log origin/<baseRefName>..HEAD` shows no commit outside the assessed head's history (`git -C <worktree.path> merge-base --is-ancestor HEAD <headRefOid>` succeeds). When the merge-base `## using-git-worktrees` section has at least one of `- home:`, `- create:`, `- destroy:` (the keyed contract), eligibility also requires the ownership match of `../../finishing-a-development-branch/SKILL.md` Step 6 (the recorded `worktree.destroy` already carries the recovered identifier): `home:` resolved against `$PRIMARY` (absent -> `.worktrees/<name>`), the `<name>` segment matched as `<prefix>*<suffix>` within one segment (`../acme-mono-<name>` against `/work/acme-mono-fix-x` recovers `fix-x`), `$WORKTREE` (the digest `worktree.path`) and the pattern both through `realpath`; an ambiguous match stops; no match -> `worktree: skipped - outside configured home, left at <path>` and the branch step reports it still checked out. Eligible and (`worktree.created` or landed) -> run `worktree.destroy` when recorded (from the primary; a non-zero exit prints `worktree: failed - <its output>`, keeps the branch (the branch step reports it still checked out), and never falls back to a forced removal), else `git worktree remove --force <worktree.path>` (the single `--force` removes a tree whose only dirt is untracked files; those files are deleted); then `git worktree prune`; print `worktree: done`. Reused and not landed -> `worktree: skipped - reused, left at <path>`. Dirty -> `worktree: skipped - uncommitted changes at <path>`. Commits beyond the assessed head -> `worktree: skipped - unmerged commits <shas> at <path>`. Under `approved` on a `rebased locally` head this step reports `unmerged commits` and leaves the tree, and `stop` leaves the rewritten local head in place and names it.
2. **Branch.** A non-zero `worktree.destroy` exit in step 1 -> `branch: skipped - worktree destroy failed`, whatever `git worktree list` shows. Landed, the branch exists locally, and no worktree holds it (`git worktree list --porcelain` lists no `branch refs/heads/<worktree.branch>`) -> `git branch -D <worktree.branch>`; print `branch: done`. Not landed -> `branch: skipped - PR not merged`. Still checked out -> `branch: skipped - checked out at <path>`. Never delete a remote branch.
3. **Base.** Landed, the primary is on `<baseRefName>`, and its tree is tracked-clean -> `git fetch origin` then `git pull --ff-only`; print `base: synced <sha>` when `git merge-base --is-ancestor <pr.merge_commit> HEAD` succeeds, else `base: failed - merge commit not on local <baseRefName>`. Primary on another branch -> `base: skipped - primary on <branch>`; dirty -> `base: skipped - uncommitted changes`; not landed -> `base: skipped - PR not merged`. Never switch the primary's branch.
4. **Override steps**, one per `## cleanup` item in list order. An item is `<label>: <text>`; any other shape -> `skipped - malformed item: <text>`.
   - Landed-change guard first: not landed and the text contains `<merge-sha>` or `/skill:release` -> `skipped - needs a landed change`. The guard decides before any placeholder is read, so an item it skips never prints a `no <name> resolved` line, whatever its placeholders hold. A `/skill:release` item when step 3 did not print `synced` -> `skipped - base not synced`.
   - Substitute: `<issue>` is `issue.ref` with a leading `#` stripped for the bare `#N` form (other forms verbatim); `<pr>` is `pr.url`; `<merge-sha>` is `pr.merge_commit`. A null `<merge-sha>` -> `skipped - needs a landed change` in both the cleanup row text and the result block. Other null values -> `skipped - no <name> resolved`: `skipped - no issue resolved`, `skipped - no pr resolved`. Any other `<...>` token -> `skipped - malformed item: <text>`. Insert each value as literal argument text: inside an existing quoted span verbatim; outside, single-quoted as one shell word.
   - Text starting with `/skill:` -> invoke that skill with the rest of the text as its arguments, gates intact: the step's line is `<label>: invoked /skill:<name> - <where it paused or what it returned>` (for example `release: invoked /skill:release - paused at its level pick`) - never a `skipped` or `failed` line about skill availability, because the gate itself runs as a skill and every `/skill:` item is invokable; `/skill:release` with no level runs the skill's propose-then-pick gate; a level written in the item (`patch`, `minor`, `major`) authorizes the release without a pause. Any other text -> run it as a non-interactive shell command with cwd the primary, bounded by the resolved `timeout minutes` (default 15) through the Safety contract ladder of `../verification-brief.md` Section B; a timeout prints `failed - timed out after <n> minutes` and later steps still run.

With no overrides file or no `## cleanup` section, run steps 1-3 only. On a closed-unmerged PR steps 2 and 3 print `PR not merged` and the landed-change guard skips release and `<merge-sha>` items before substitution. Do not delete the consent record, helper output files, or temporary body files here - `show evidence` and the post rows stay available after `cleanup`; `post-selection-loop.md` `### Teardown` deletes them on terminal exit.

Consequence clause for the menu row (`decision-menu.md`): `remove <worktree.path> (untracked files there are deleted) and branch <worktree.branch>, pull <baseRefName>, then <label> (<resolved command>), ...` with every placeholder already substituted, so the tracker comment text and the release invocation are confirmed by the one pick and `/skill:release` keeps its own level gate; steps the guards would skip render as `<label> (skipped - <reason>)`; with no `## cleanup` section the clause is `tear down the gate's checkout and local branch (untracked files there are deleted)`.

## Non-final stop

Follow the [shared layout](../../brainstorming/reference/menu-layout.md).

Start the message with `gauntlet: stuck` followed on that line by the reason: use the obstacle's `stuck - ` text as the reason, or `stopped at <template id>` for initial, assess-terminal, and contract-exception. On a failed reset/assertion use the actual error instead. Put cleanup results below that one first line.

Enter here for `stop` on the initial, assess-terminal, obstacle, or contract-exception menu. On a `rebased locally` head, run `git -C <worktree.path> reset --hard <pre_head>` first; assert `HEAD == <pre_head>` and a clean tree. Stop on a failed assertion.

Reuse step 1's primary and keyed ownership computation: resolve `home:` and the `<name>` pattern against `$PRIMARY`, and pass both the pattern and `worktree.path` through `realpath`. Keep the primary even when clean; print `worktree: kept - primary at <path>`.

For a path that is not the primary, apply these checks in order; created and reused trees are both eligible:

| Check | On failure |
|---|---|
| `git -C <worktree.path> status --porcelain --untracked-files=no` is empty | Keep: `uncommitted changes` |
| `git -C <worktree.path> merge-base --is-ancestor HEAD <last pushed head>` succeeds; use `repair.wave.pushed_head` when set, else the assessed `headRefOid` | Keep: `unpushed commits <shas>`; list commits from `<last pushed head>..HEAD` |
| Under a keyed `## using-git-worktrees` contract, step 1's ownership match is unique | Keep: `outside configured home` for no match, or `ambiguous home match` for multiple matches |

For a failed check print `worktree: kept - <reason> at <path>` and leave the tree. For `unpushed commits <shas>` also state that discarding those commits is a separate explicit human decision - never a consequence of `stop` - and that the run neither offers nor performs it. Untracked residue alone does not block removal and is deleted. For an eligible tree run recorded `worktree.destroy` from the primary; a failed custom destroy keeps the tree, prints `worktree: failed - <first error line>, left at <path>`, and must never fall back to forced removal. With no recorded destroy command, run `git -C <primary> worktree remove --force <worktree.path>`. After successful destruction run `git -C <primary> worktree prune` and print `worktree: done`. Touch nothing under the removed path.

Never run terminal steps 2-4 here: no branch deletion, base pull, or `## cleanup` overrides. Delegate record, helper-output, and temporary body-file deletion to `post-selection-loop.md` `### Teardown`.
