End-of-session command — verify no unresolved findings, squash merge, sync main, detect per-PR follow-ups, run a full-session loose-ends sweep, and extract lessons. Nearly silent by default — a clean merge emits one line (`merged PR #N`) plus any items needing your decision; pass `--verbose` (or just ask) for the merged + follow-ups summary and the full per-phase report. Accepts an optional PR reference (`/wrap <URL>`, `/wrap #N`, `/wrap N`); with no argument it infers the PR from the current...
Installs into .claude/skills of the current project.
Are you the author of Wrap?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/auerbachb-wrap)
---
name: wrap
description: End-of-session command — verify no unresolved findings, squash merge, sync main, detect per-PR follow-ups, run a full-session loose-ends sweep, and extract lessons. Nearly silent by default — a clean merge emits one line (`merged PR #N`) plus any items needing your decision; pass `--verbose` (or just ask) for the merged + follow-ups summary and the full per-phase report. Accepts an optional PR reference (`/wrap <URL>`, `/wrap #N`, `/wrap N`); with no argument it infers the PR from the current branch, then thread context, then session-state.
argument-hint: "[URL | #N | N] [--verbose]"
---
Wrap up the current PR and session. This is the "we're done here" command that handles final verification through merge, root-main sync, follow-up detection, a full-session sweep for loose ends, and lessons.
`/wrap` accepts an **optional** PR reference as its argument — a full URL, `#N`, `owner/repo#N`, or a bare number `N`. When invoked with no argument it resolves the target PR through the inference cascade in Step 1.1 (current branch → thread context → session-state). An explicit argument bypasses all inference.
`/wrap` does **not** delete the running worktree or its branch — leaving the thread alive so it can keep working. Stale worktrees and stale local/remote branches are reaped out-of-band by `/pm-clean`, which calls `stale-cleanup.sh`.
## When to use /wrap vs /merge
- Use **/wrap** at end-of-session. Handles merge + root-main sync + follow-up detection + lessons.
- Use **/merge** for a quick mid-session merge when you'll keep working. Skips follow-up detection and lessons.
- /wrap includes everything /merge does, plus follow-ups and lessons. Don't run both.
- Phase C invokes this skill after gate and AC verification; keep merge, main-sync, follow-up, and cleanup behavior here so Phase C and `/wrap` cannot drift.
- **Target PR:** `/wrap` operates on the PR resolved in Step 1.1. Pass an explicit reference (`/wrap <URL>`, `/wrap #N`, `/wrap N`) to wrap a PR that is **not** on the current branch — common in orchestration threads that ran `/fixpr <URL>` against another worktree. With no argument, Step 1.1's cascade infers the PR.
- **Callers of the `/wrap #N` form:** Phase C (`phase-c-merger`), `/pr-monitor-and-manage` (merge-ready fleet PRs), and **`/pm`'s one-shot forgotten-PR triage** (issue #657) all merge an already-open PR by dispatching this workflow rather than reimplementing merge logic — the merge gate, AC verification, and squash-merge live here so every caller stays consistent.
## Execution Model
`/wrap` is a set-and-forget command. Once invoked, it runs all 4 phases end-to-end without mid-run confirmation prompts. It stops early only for explicit stop conditions (for example: no PR on current branch, **human** change requests on HEAD, recovery iteration cap, failed merge gate after recovery, **`/fixpr` delegation failure**, AC verification failure).
> **Always:** Execute all phases end-to-end; proceed immediately between phases when no blocker exists.
> **Ask first:** Never — all phases are autonomous once /wrap is invoked.
> **Never:** Stop to ask "should I continue?" between phases; insert confirmation prompts for non-blocker transitions. Delete the running worktree or its branch — that's `/pm-clean`'s job, not /wrap's. Modify branch protection — suggest `/admin-merge` for any enforcement_admins bypass; `admin-merge.sh --auto-plain` (no protection change) runs automatically per #754.
### Follow-up filing is autonomous (issue #633)
Both Phase 3 passes — **Part A** (per-PR follow-ups) and **Part B** (full-session sweep) — file every novel candidate as a GitHub issue **without asking**. There is no opt-out flag and no "file as new issue?" prompt anywhere in this skill. The deal is two-sided: filing is unconditional, and **every** filed issue is durably **recorded** — the GitHub issue itself, the run-scoped `WRAP_FILED_ISSUES` registry, and `session-state.json` `wrap_sweep.filed_issues` (Step 3.13). **Recording is not narrating (issue #851):** filings do *not* print on the default path. They are listed with number, title, one-line rationale, and clickable link under `--verbose` or on request, and any filing that needs a decision — a failed `gh issue create`, or one carrying a `Possibly duplicates #{N}` caveat — surfaces immediately in either mode. Retraction (`gh issue close`) is the escape hatch, not a confirmation round-trip. The norm is stated once for all skills in `.claude/rules/issue-planning.md`.
Autonomy does not mean filing blind: every candidate goes through the body-aware duplicate check in **Step 3.0** first (issue #652). That check can only ever *redirect* a filing onto an existing issue or *annotate* it — it never drops a finding, and every filing it suppresses is named in the `--verbose` report alongside the issue it deferred to.
### Wrap-Internal Phase Transitions
> These transitions are wrap's internal Phase 1–4 system, distinct from the subagent A/B/C phases in `subagent-orchestration.md` — the two naming schemes are parallel-but-separate.
| Transition | Action | Classification |
|------------|--------|----------------|
| Phase 1 complete (findings scan finished — may have flagged bot findings) | Begin Phase 2 recovery + merge | **Always do** |
| Unresolved review threads detected (Phase 1 scan) | Record `WRAP_UNRESOLVED_THREADS`; proceed to Phase 2 Branch B, which auto-invokes `/fixpr` when threads are the sole gate blocker (issue #455) | **Auto-recover** |
| Unresolved review threads only (Phase 2.1 — `merge-gate.sh` `missing` contains only `unresolved review thread(s)` entries) | Auto-invoke `/fixpr` (Branch B), then re-fetch HEAD + re-run `merge-gate.sh` | **Auto-recover** |
| Phase 2 recovery loop exits cleanly (gate met, ready to merge) | AC check → squash merge → Phase 3 | **Always do** |
| Phase 3 per-PR follow-ups + full-session sweep processed | Begin Phase 4 | **Always do** |
| Phase 4 lessons complete (or skipped as trivial) | Output final report | **Always do** |
| Human `CHANGES_REQUESTED` on current HEAD (`human_changes_requested` non-empty in `merge-gate.sh` JSON) | Stop with reviewer names — **never** auto-dismiss | **Genuine block** |
| Recovery iteration cap or non-recoverable gate failure | Stop with full audit + last `missing` | **Stop and report** |
| AC checkbox verification fails (Phase 2.2) | Stop and report | **Stop and report** |
> **Anti-pattern:** If you find yourself composing "Should I proceed?" or presenting a confirmation button, the answer is always yes — execute immediately.
> **"Threads only" defined (issue #455):** the unresolved-review-threads auto-recovery rows above apply when unresolved review threads are the **sole** blocker — i.e. no other blocker category is present (no CI failing/incomplete, no `BEHIND`/`DIRTY`/`CONFLICTING` merge state, no stale or human `CHANGES_REQUESTED`, no missing fresh bot `APPROVED`). When any of those co-occur, the broader Step 2.1 decision tree (issue #452) owns dispatch — first matching branch wins. The single-attempt / stop-on-mixed-blocker semantics from #455 are realized here as the threads-only branch of the bounded #452 loop.
## Output modes
`/wrap` is **nearly silent by default** (issue #851, #869): it runs all four phases in full, emits exactly `merged PR #N` on a clean merge, and otherwise says nothing — only items that need an explicit decision from you. Pass `--verbose`, or simply ask ("what did you just do?", "summarize"), for the merged + follow-ups summary, the per-phase narration, and the detailed final report. This implements `CLAUDE.md` #3 for this skill.
| Mode | Flag | Effect |
|------|------|--------|
| **Silent** | *(default)* | One line on a clean run: `merged PR #N` — no follow-up list, no lessons ack, no sweep summary. Only decision-requiring items print, one terse line each (Step 4.3 **Silent default**). |
| **Verbose** | `--verbose` *(or an explicit request)* | The full report: the `## Wrapped up` merged + follow-ups block, per-cycle recovery heartbeats, the Session Lessons block, and the multi-section "Wrap-Up Complete" report (Issues filed, Filings suppressed as duplicates, Session sweep, Verdict, Lessons). Full template: `references/wrap-report-templates.md`. |
**Verbosity is additive and human-facing only.** Every phase executes **identically** in both modes; only narration differs. Suppression is never deletion — the merge, the filings, the sweep, and the lessons are all recorded (GitHub, `WRAP_FILED_ISSUES`, `session-state.json` `wrap_sweep`, memory), so an explicit request re-renders the verbose report in full from that state. Four things always print regardless of mode:
- **State-changing blockers and stop conditions** — a merge that can't proceed, `CONFLICTING`, human `CHANGES_REQUESTED`, no PR found, the recovery cap — surface as a short one-line reason even on the silent default (Step 4.3 **Blocker path**).
- **The `[INFERRED]` merge-safety checkpoint** (Step 1.1). Prints only on the inference path, never on the normal branch path.
- **The CLAUDE.md 5-minute heartbeat** during long `/fixpr` waits. Silence never extends past the heartbeat — that is what keeps a suppressed run distinguishable from a stalled one (issue #803).
- **`[COVERAGE]` when local review was degraded** (`cr-only`, `codeant-only`, or `none`). Full coverage (`both`) is suppressible.
When `/wrap` is invoked by a phase-C subagent, the machine **`EXIT_REPORT`** block (per `phase-protocols.md`) is emitted **identically regardless of verbosity**.
## Preamble: shared helpers
Define once, use throughout all phases. The `resolve_script()` function finds the first executable candidate across the three standard install locations:
```bash
resolve_script() {
local name="$1" candidate
for candidate in \
"$HOME/.claude/skills-worktree/.claude/scripts/$name" \
"$HOME/.claude/scripts/$name" \
".claude/scripts/$name"; do
if [[ -x "$candidate" ]]; then echo "$candidate"; return 0; fi
done
return 1
}
INFER_PR=$(resolve_script infer-pr.sh || true)
DISMISS=$(resolve_script dismiss-stale-bot-changes.sh || true)
PR_STATE_SH=$(resolve_script pr-state.sh || true)
MERGE_GATE_SH=$(resolve_script merge-gate.sh || true)
AC_CHECKBOXES_SH=$(resolve_script ac-checkboxes.sh || true)
CI_STATUS_SH=$(resolve_script ci-status.sh || true)
RELEASE_DECIDE=$(resolve_script release-decide.sh || true)
RELEASE_SWEEP=$(resolve_script release-sweep.sh || true)
PR_ISSUE_REF_SH=$(resolve_script pr-issue-ref.sh || true)
ISSUE_CLAIM_SH=$(resolve_script issue-claim.sh || true)
REPO_ROOT_SH=$(resolve_script repo-root.sh || true)
DIRTY_MAIN_GUARD_SH=$(resolve_script dirty-main-guard.sh || true)
MAIN_SYNC_SH=$(resolve_script main-sync.sh || true)
HHG_STATE_SH=$(resolve_script hhg-state.sh || true)
CYCLE_COUNT_SH=$(resolve_script cycle-count.sh || true)
PR_AUTHORSHIP_SH=$(resolve_script pr-authorship.sh || true)
CR_HOURLY_SH=$(resolve_script cr-review-hourly.sh || true)
[[ -n "$PR_STATE_SH" ]] || { echo "ERROR: pr-state.sh not found (checked all three paths) — wrap PR state unavailable" >&2; exit 1; }
[[ -n "$MERGE_GATE_SH" ]] || { echo "ERROR: merge-gate.sh not found (checked all three paths) — wrap merge gate unavailable" >&2; exit 1; }
[[ -n "$AC_CHECKBOXES_SH" ]] || { echo "ERROR: ac-checkboxes.sh not found (checked all three paths) — acceptance verification unavailable" >&2; exit 1; }
[[ -n "$CI_STATUS_SH" ]] || { echo "ERROR: ci-status.sh not found (checked all three paths) — CI diagnosis unavailable" >&2; exit 1; }
[[ -n "$PR_AUTHORSHIP_SH" ]] || { echo "ERROR: pr-authorship.sh not found (checked all three paths) — wrap authorship gate unavailable" >&2; exit 1; }
```
These are resolved before any phase work. `INFER_PR` is used in Step 1.1; `DISMISS` is used in Step 2.1 Branch A. `SESSION_STATE_SH` and `ISSUE_DEDUP` are resolved later in the steps that first need them (Steps 3.5 and 3.0 respectively).
## Phase 1: Pre-Merge Verification — Check for Unresolved Findings
Before merging, verify that all reviewer feedback has been addressed.
### Step 1.1: Identify the PR
**Parse flags first (before any PR resolution).** Scan `$ARGUMENTS` for `--verbose` (anywhere, any order), set `WRAP_VERBOSE`, and **strip it** so only the PR reference remains:
```bash
WRAP_VERBOSE=0
REST=""
for tok in ${ARGUMENTS:-}; do
if [ "$tok" = "--verbose" ]; then WRAP_VERBOSE=1; else REST="${REST:+$REST }$tok"; fi
done
ARGUMENTS="$REST"
```
**Cascade summary** (first match wins — later sub-steps skipped):
1. **1.1a** Explicit argument — if `$ARGUMENTS` non-empty, resolve via `"$INFER_PR" --explicit "$ARGUMENTS"`; stop if helper missing or reference unparseable. Never fall through to 1.1b.
2. **1.1b** Current branch — `gh pr view` finds a PR; use it (non-inferred, no `[INFERRED]` line). Skip 1.1c–1.1e.
3. **1.1c** Thread context scan (AI judgment) — most recent `/fixpr`/`/wrap` invocations and explicit PR refs in this conversation.
4. **1.1d** Session-state — `"$INFER_PR" --root-repo "$ROOT_TOPLEVEL"`; capture real exit code (do NOT `|| true`).
5. **1.1e** Merge, deduplicate, resolve — single / unambiguous / ambiguous / no-candidates. Stop with `Multiple PRs in scope — please specify: /wrap <N>` on tie.
6. **1.1f** No-candidates stop — non-coding thread vs. lookup-failed (AI judgment; bias toward lookup-failed when uncertain).
Full sub-step bash, the `[INFERRED]` line format, the repo-scoping guard, and the authorship guard: **`references/wrap-pr-inference.md`**.
**Merge-safety:** `/wrap` implicitly authorizes the squash merge — an incorrect inference merges the **wrong** PR. Only auto-proceed when the target is unambiguous. The `[INFERRED]` line (emitted before any Phase 1 verification work) is the user's only catch point.
Once `PR_NUM` is fixed, fetch PR details:
```bash
gh pr view "$PR_NUM" --json number,title,headRefName,body,state \
--jq '{number, title, headRefName, body, state}'
```
If already merged or closed, skip to Phase 3.
### Step 1.2: Scan for unresolved review findings
> **pr-state.sh first (NON-NEGOTIABLE):** Before calling `gh api .../pulls/{N}/reviews`, `pulls/{N}/comments`, or `issues/{N}/comments` directly, call `pr-state.sh --pr N` first and read the cached JSON bundle. All review-state queries in this skill read from the `$BUNDLE` returned by `pr-state.sh` — do not add inline `gh api` calls to these three endpoints.
Use the shared `pr-state.sh` helper to fetch and pre-classify review activity from all three endpoints in one call. It filters to the five review bots — `coderabbitai[bot]`, `cursor[bot]` (BugBot), `codeant-ai[bot]`, `greptile-apps[bot]` and `graphite-app[bot]` — and tags each comment with `classification.class` (`finding` vs `acknowledgment`). The helper writes the JSON bundle to a tempfile and prints its **path** on stdout — capture the path, then read with `jq < "$BUNDLE"`:
```bash
PR_CREATED=$(gh pr view "$PR_NUM" --json createdAt --jq '.createdAt')
BUNDLE=$("$PR_STATE_SH" --pr "$PR_NUM" --since "$PR_CREATED")
```
Read the findings across all three endpoints with a single jq pass:
```bash
jq '[.new_since_baseline.reviews[], .new_since_baseline.inline[], .new_since_baseline.conversation[]]
| map(select(.classification.class == "finding"))' < "$BUNDLE"
```
For each finding:
1. Check if there is a reply confirming the fix
2. Check if the code at the referenced location has been updated since the comment
3. Check if the thread is resolved/outdated
**Do not stop here.** Record whether any items remain classified as `finding` as **`WRAP_PHASE1_FINDINGS`** — count + short list. Unresolved bot findings are a **trigger** for Phase 2's `/fixpr` delegation path, not a hard stop.
**Unresolved-threads detection (issue #455):**
```bash
WRAP_UNRESOLVED_THREADS=$(jq -r '.threads.unresolved_count // 0' < "$BUNDLE")
```
If `> 0`, record it. In `--verbose` mode also emit a timestamped detection heartbeat. Do **not** assert threads are the sole blocker — that is Branch B's call once the gate's full `missing` set is known. Do **not** invoke `/fixpr` from Phase 1 — the single delegation point is Step 2.1 Branch B.
Proceed immediately to Phase 2 — do not ask.
## Phase 2: Merge
### Step 2.1: Merge gate + autonomous recovery loop (issue #452)
**Authority:** `"$MERGE_GATE_SH"` JSON on stdout is the single source of truth for merge readiness. After **every** recovery action, re-fetch PR HEAD SHA and re-run `merge-gate.sh` — **no stale cache**.
```bash
WRAP_RECOVERY_MAX_ITERATIONS="${WRAP_RECOVERY_MAX_ITERATIONS:-5}"
```
**Polling ownership (issue #454):** `/wrap` has NO polling cadence — all waiting happens inside `/fixpr`'s Step 4d review-wait loop. `/wrap` delegates, trusts the returned verdict, and re-runs `merge-gate.sh` immediately.
**Per-iteration heartbeat** (verbose mode only):
```bash
if [ "$WRAP_VERBOSE" = "1" ]; then
TS=$(TZ='America/New_York' date +'%a %b %-d %I:%M %p ET')
echo "[$TS] /wrap recovery cycle $i/$WRAP_RECOVERY_MAX_ITERATIONS — gate check"
fi
```
**The silent default keeps three non-negotiable signals:** (a) the CLAUDE.md 5-minute heartbeat during `/fixpr` waits, (b) dispatch/blocker transitions always print, and (c) a clean merge emits `merged PR #N`.
After each action, append to **`WRAP_RECOVERY_AUDIT`**: cycle number, blocker summary, action taken, result. Always built; rendered to the user only under `--verbose`.
**Merge-ready shortcut:** Run `merge-gate.sh` once before the loop, capturing its stdout into `GATE_JSON` exactly as the loop does. If exit `0` and no `WRAP_PHASE1_FINDINGS` and no half-applied recovery, skip straight to Step 2.2. Capturing is not optional — Step 2.4 reads `merge_state` out of `GATE_JSON`, so a shortcut that discarded the output would leave it unset there.
**Recovery loop** (`i` from `1` through `$WRAP_RECOVERY_MAX_ITERATIONS`):
1. **Terminal checks** — read PR state via `gh pr view "$PR_NUM" --json state`. `MERGED` → exit for Phase 3. `CLOSED` → stop.
2. **Refresh gate** — `GATE_JSON=$("$MERGE_GATE_SH" "$PR_NUM")`. Exit `3` → Phase 3. Exit `2`/`4` → surface stderr, stop. Exit `0` with no pending `WRAP_PHASE1_FINDINGS` → proceed to Step 2.2. Exit `0` with `WRAP_PHASE1_FINDINGS` pending → treat as Branch B dispatch (findings still unresolved despite clean gate — delegate to `/fixpr`).
3. **Exit `1` — classify and dispatch** (first matching branch wins; full per-branch detail in `references/wrap-merge-gate-recovery.md`):
- **`human_changes_requested` non-empty** → stop; name each login.
- **A. Stale bot `CHANGES_REQUESTED`** → invoke `"$DISMISS" "$PR_NUM"`.
- **`mergeable == CONFLICTING`** → stop; recommend `/merge-conflict`.
- **`BEHIND` is transparent to branch selection** (issue #1425) → classify on `REMAINDER` = `missing[]` **minus** the `BEHIND` entry, because Step 2.4 is what clears `BEHIND`. `REMAINDER` empty and no `WRAP_PHASE1_FINDINGS` pending → **break out to Step 2.2**, then Step 2.4's clean-`BEHIND` path; do not rebase. `REMAINDER` non-empty → match the branches below **against `REMAINDER`**, carrying `BEHIND` forward to be re-evaluated next iteration. Never leave a `BEHIND`-plus-something `missing[]` unclassified.
- **B. Delegate `/fixpr`** → when `REMAINDER` has unresolved threads, failing CI, `DIRTY`, or `WRAP_PHASE1_FINDINGS` pending. `BEHIND` alongside any of these reaches `/fixpr` here — a rebase is needed regardless; a `BEHIND`-only `missing[]` exits above instead. Threads-only check via structured gate signals (issue #455). Execute **full** `fixpr/SKILL.md` workflow (Steps 0–7 including Step 4d). Parse `FIXPR_WRAP_STATUS` and `FIXPR_WAIT_SUMMARY` from `=== fixpr complete ===` footer; emit control-returned heartbeat. Full handoff semantics: `.claude/reference/wrap-fixpr-delegation.md`.
- **C. Missing fresh bot review signal** → trigger the one bot needed (CR rate-check first); delegate wait to `/fixpr`.
- **D. CI incomplete** → delegate wait to `/fixpr` (idempotent — no push). Fires when incomplete CI is the whole of `REMAINDER`, with or without `BEHIND` alongside.
- **E. Branch-protection block** → fires when the protection note is the whole of `REMAINDER`, with or without `BEHIND` alongside; suggest `/admin-merge <PR>`; never modify branch protection.
- **`merge_state == UNKNOWN`** → re-run gate next iteration.
4. **End of iteration** — if no branch matched, append "unclassified blocker" + `missing` to audit.
**Loop exit:** gate exit `0` with no `WRAP_PHASE1_FINDINGS` → Step 2.2; gate exit `1` with `BEHIND` as the sole `missing[]` item and no `WRAP_PHASE1_FINDINGS` → Step 2.2, then the Step 2.4 clean-`BEHIND` path (issue #1425); gate exit `0` with `WRAP_PHASE1_FINDINGS` still pending → Branch B (findings unresolved despite clean gate). Iteration cap → stop with last `missing` + full `WRAP_RECOVERY_AUDIT`. Genuine block (human CR, `CONFLICTING`, rate-limit, hard `/fixpr` failure) → stop per branch above.
**Re-entry from Step 2.4 (issue #1425).** Step 2.4's `BEHIND` fall-throughs rebase, which moves HEAD — invalidating the bot approval and restarting CI — so they must **return to this loop**, not merge and not end `/wrap`. Re-enter at the top of the next iteration against the **same** `i` counter and `WRAP_RECOVERY_MAX_ITERATIONS` cap, so a rebase/re-review cycle can never spin unbounded; on exhausting the cap, stop with the last `missing` and the audit. The re-entered loop re-runs the gate on the new SHA, waits out fresh CI and bot review via the branches above, and reaches Step 2.2 again — AC is re-verified on the new SHA before Step 2.4 retries the merge.
### Step 2.2: Verify acceptance criteria
```bash
ITEMS=$("$AC_CHECKBOXES_SH" "$PR_NUM" --extract)
AC_EXIT=$?
```
For each item with `checked == false`: read the criterion, read relevant source files, confirm the criterion is satisfied. Tick passing items:
```bash
"$AC_CHECKBOXES_SH" "$PR_NUM" --tick "0,2,3"
# Or:
"$AC_CHECKBOXES_SH" "$PR_NUM" --all-pass
```
Exit codes: `0` OK; `1` no Test Plan section — stop; `3` PR not found — stop; `2`/`4` script/gh error — stop. If any item fails verification, do NOT tick it — stop and report. Do NOT merge with unchecked boxes.
### Step 2.3: Pre-merge safety & CI (handled by Step 2.1)
After the recovery loop, Step 2.1 must have returned gate exit `0` immediately before Step 2.2 — implying SHA freshness, BEHIND/CI/unresolved threads cleared — **with one exception (issue #1425):** the `BEHIND`-only loop exit arrives on gate exit `1` with `BEHIND` still present, cleared later in Step 2.4. Every other precondition holds on both paths. For deeper CI forensics:
```bash
"$CI_STATUS_SH" "$PR_NUM"
"$CI_STATUS_SH" "$PR_NUM" --format summary
```
**Never add `eslint-disable`, `@ts-ignore`, `@ts-expect-error`, or any suppression comment to work around CI.** Fix the actual code.
### Step 2.3a: Pre-merge TestFlight label (iOS repos — issue #1169)
Repos that ship an iOS app cut TestFlight builds from a `release:ios` label on the merged PR. GitHub does **not** re-fire `pull_request: [closed]` for a label added to an already-closed PR, so this is the one release step that must run *before* the merge; everything else happens in Step 3.14. A repo with no `.claude/release-policy.json` — or with it disabled, which is the default — is inert here.
```bash
RELEASE_PREMERGE_RC=2; RELEASE_PREMERGE_OUT=""
if [ -x "$RELEASE_DECIDE" ]; then
RELEASE_PREMERGE_RC=0
RELEASE_PREMERGE_OUT=$("$RELEASE_DECIDE" --pr "$PR_NUM" --phase pre-merge --apply 2>&1) \
|| RELEASE_PREMERGE_RC=$?
fi
```
**Every failure path here is non-fatal and nothing is printed yet.** A missing script, a malformed policy, a `gh` error, or any non-zero exit leaves the merge untouched — release logic must never gate or delay a merge (AC4). The result is carried to Step 3.14 and reported only once the merge has actually landed, so a merge that fails after the label was applied never claims a build that did not happen. (A label left on an unmerged PR is ignored by the workflow until that PR merges — recoverable and harmless.)
### Step 2.4: Squash merge
After blockers clear (Phase 1 + Step 2.1 recovery + Step 2.2), squash-merge the resolved PR — per `CLAUDE.md` "PR MERGE AUTHORIZATION" and `cr-merge-gate.md` Step 3. Always pass the explicit identifier so `/wrap #N` invocations merge the correct PR, not the current-branch PR.
**The merge command depends on `merge_state` (issue #1425)** — read it from the last `merge-gate.sh` JSON, not from a fresh guess:
```bash
MERGE_STATE=$(printf '%s' "$GATE_JSON" | jq -r '.merge_state // empty')
```
`printf '%s'`, never `echo` — zsh's builtin corrupts the JSON payload (issue #574).
- **`CLEAN`** → `gh pr merge "$PR_NUM" --squash`, unchanged.
- **`BEHIND`** → the clean-`BEHIND` path below. A plain squash is **rejected** here by `required_status_checks.strict`.
- **Anything else** → **not merge-ready** per `cr-merge-gate.md` Step 1d — stop with the state and the last `missing[]`. Never merge on an unenumerated state: `BLOCKED` and `UNSTABLE` add no `missing[]` entry, so a gate exit `0` does not by itself clear them.
The merge-state mechanics are owned by `cr-merge-gate.md` Step 1d and implemented identically in `fixpr/SKILL.md` Step 6; the safety conditions live there and are not restated here.
**A losing race still routes correctly.** `merge_state` is a snapshot: a sibling PR can land between the gate read and the merge call, so a PR that read `CLEAN` may be `BEHIND` by the time `gh pr merge` runs. That command's `head branch is not up to date` refusal **is** the `BEHIND` signal — take the clean-`BEHIND` path below rather than reporting a failed merge.
**Clean-`BEHIND` path.** Two helpers, resolved lazily (same pattern as `ESTIMATE_LOG_SH` below) because only this branch needs them:
```bash
CLEAN_BEHIND_SH=$(resolve_script clean-behind-check.sh || true)
ADMIN_MERGE_SH=$(resolve_script admin-merge.sh || true)
[[ -n "$CLEAN_BEHIND_SH" ]] || { echo "ERROR: clean-behind-check.sh not found (checked all three paths) — clean-BEHIND verification unavailable" >&2; exit 1; }
[[ -n "$ADMIN_MERGE_SH" ]] || { echo "ERROR: admin-merge.sh not found (checked all three paths) — clean-BEHIND merge unavailable" >&2; exit 1; }
```
Both are required **only** on this branch; the `CLEAN` branch never reads them. If either is empty when this branch runs, **stop** — never fall back to a plain merge, which `strict` would reject anyway.
Then run `"$CLEAN_BEHIND_SH" "$PR_NUM"` and branch on its exit code:
| Exit | Action |
|------|--------|
| `0` (`safe_to_offer: true`) | Run `"$ADMIN_MERGE_SH" "$PR_NUM" --auto-plain --ac-verified`. On its exit `0` the PR is **already merged** — relay the `AUTO_PLAIN_MERGED` evidence block and continue to the post-merge actions below. No `AskUserQuestion`: the plain shape modifies no branch protection, so it needs no user turn. |
| `1` (`safe_to_offer: false`) | Not clean — surface `reasons_not_safe`, then take the `/fixpr` non-clean `BEHIND` path (rebase + force-push, `fixpr/SKILL.md` Step 6) and **re-enter Step 2.1** (below). |
| `2` / `3` / `4` | Tooling or usage failure — **stop** and surface stderr. Never merge on an indeterminate answer. |
`admin-merge.sh`'s own exits after a clean probe: `8` → the shape needs a protection change, or an auto attempt already ran — **offer `/admin-merge <PR>`** and surface the printed command, never auto-run it. `1` → the clean-`BEHIND` state no longer held at merge time (main advanced) — take the `/fixpr` non-clean `BEHIND` path and **re-enter Step 2.1** (below). Any other exit → stop and surface stderr.
**Both fall-throughs re-enter the Step 2.1 recovery loop — they never merge from here and never end `/wrap` (issue #1425).** The rebase moves HEAD, which invalidates the bot approval that satisfied the gate and restarts CI, so the post-rebase SHA is unreviewed by construction: merging it would bypass the gate, and stopping would abandon a PR that is one review round from mergeable. Re-entry runs against the **same** `i` counter and `WRAP_RECOVERY_MAX_ITERATIONS` cap, so rebase → re-review → `BEHIND`-again cannot spin unbounded; exhausting the cap stops with the last `missing` and the full `WRAP_RECOVERY_AUDIT`. Because re-entry passes back through Step 2.2, AC is re-verified against the rebased SHA before Step 2.4 runs again — which is what keeps the later `--ac-verified` attestation truthful on the retry.
`--ac-verified` is justified because **Step 2.2 already ran unconditionally** — every Test Plan checkbox was verified against the source at this SHA on both paths. `clean-behind-check.sh` counts unchecked boxes as `reasons_not_safe`, so running it before Step 2.2 would report exit `1` for bookkeeping rather than for a real blocker.
**Release the issue claim** once the merge succeeds — on **either** path, the `CLEAN` squash and the clean-`BEHIND` `--auto-plain` alike — a merged PR is the terminal state that makes the issue startable again (issue #873). Step 3.1 resolves the linked issue for follow-ups, but that runs later, so resolve it here too:
**Release every issue the PR closed, not just one.** `pr-issue-ref.sh` is set-valued: a PR carrying two `Closes` trailers prints both numbers, one per line. Loop over them — taking a single value here is exactly what stranded #1541's claim after PR #1546 merged, and #1407's after PR #1489 (issue #1492):
```bash
MERGED_ISSUES=$([[ -n "$PR_ISSUE_REF_SH" ]] && "$PR_ISSUE_REF_SH" "$PR_NUM" 2>/dev/null || true)
if [ -n "$MERGED_ISSUES" ] && [ -n "$ISSUE_CLAIM_SH" ]; then
printf '%s\n' "$MERGED_ISSUES" | while IFS= read -r MERGED_ISSUE; do
[ -n "$MERGED_ISSUE" ] || continue
"$ISSUE_CLAIM_SH" "$MERGED_ISSUE" --release >/dev/null 2>&1 || true
done
fi
```
Releasing an issue you do not hold is safe and silent: `issue-claim.sh --release` compares logins and drops **your own** claim only, never a collaborator's, so an extra number in the set is a documented no-op rather than a stomp.
Best-effort by design: the merge has already landed, so a failed release is a warning, never a non-zero exit from `/wrap`. An unreleased claim ages out on its own within `CLAIM_STALE_HOURS`; failing an already-completed merge would be far worse. This is the only release call on the merge path — Phase C (`phase-c-merger.md`) runs `/wrap` and inherits it, so do not add a second one there.
**Append actuals log entry** — after the merge and claim-release, on either merge path, append one row to `~/.claude/estimate-log.jsonl`. This is best-effort: a logging failure prints one `WARN:` line and never blocks or delays anything downstream.
```bash
ESTIMATE_LOG_SH=$(resolve_script estimate-log.sh || true)
if [[ -n "$ESTIMATE_LOG_SH" ]]; then
LOG_RC=0
LOG_OUT=$("$ESTIMATE_LOG_SH" --append "$PR_NUM" 2>&1) || LOG_RC=$?
if [[ $LOG_RC -ne 0 || -n "$LOG_OUT" ]]; then
echo "WARN: estimate-log.sh --append PR #$PR_NUM exited $LOG_RC: $LOG_OUT" >&2
fi
fi
```
The script always exits 0 when called with `--append` (issue AC). The outer `|| LOG_RC=$?` captures unexpected failures; the `WARN:` line surfaces them without blocking the merge path.
Do NOT use `--delete-branch`. The current worktree is still checked out on the feature branch — git refuses to delete a branch held by a worktree. The branch is cleaned up out-of-band by `/pm-clean` via `stale-cleanup.sh`.
### Step 2.5: Sync root repo main (aggressive reset)
**Both helpers take `--repo "$ROOT_REPO"`; neither may be wrapped in a `cd`.** A worktree-isolated Phase C agent — the caller on the normal A/B/C merge path — is refused both `(cd "$ROOT_REPO" && …)` ("too complex to verify that it stays inside the worktree") and `git -C "$ROOT_REPO" …`. A plain script call is an allowed shape, so passing the path as a flag is what makes this step reachable at all; the earlier `cd` form made every Phase C merge take the degraded branch below and leave root `main` unsynced (issue #1411). Keep the two calls symmetric — do not reintroduce a `cd`. Full catalog of refused vs allowed shapes, and the wrapper for each recurring case: `.claude/reference/worktree-isolation-command-shapes.md` (issue #1470).
```bash
ROOT_REPO=$([[ -n "$REPO_ROOT_SH" ]] && "$REPO_ROOT_SH" 2>/dev/null || true)
MAIN_SYNC_STATUS=""
QUARANTINE_STATUS=""
QUARANTINE_OK=0
if [ -z "$ROOT_REPO" ] || [ ! -d "$ROOT_REPO" ]; then
MAIN_SYNC_STATUS="failed: could not determine root repo path"
else
if [[ -n "$DIRTY_MAIN_GUARD_SH" ]] && "$DIRTY_MAIN_GUARD_SH" --check --repo "$ROOT_REPO" >/dev/null 2>&1; then
QUARANTINE_STATUS="clean"
QUARANTINE_OK=1
elif [[ -z "$DIRTY_MAIN_GUARD_SH" ]]; then
QUARANTINE_STATUS="DEGRADED: dirty-main-guard.sh not found (checked all three paths) — quarantine unavailable"
else
QUARANTINE_RC=0
QUARANTINE_STATUS=$("$DIRTY_MAIN_GUARD_SH" --quarantine --repo "$ROOT_REPO" 2>&1) || QUARANTINE_RC=$?
if [[ "$QUARANTINE_RC" -eq 0 ]]; then
QUARANTINE_OK=1
else
QUARANTINE_STATUS="failed(rc=$QUARANTINE_RC): $QUARANTINE_STATUS"
fi
fi
MAIN_SYNC_RC=0
if [[ "$QUARANTINE_OK" -ne 1 ]]; then
MAIN_SYNC_RC=1
MAIN_SYNC_STATUS="skipped: root quarantine did not complete; aggressive reset was not run"
elif [[ -z "$MAIN_SYNC_SH" ]]; then
MAIN_SYNC_RC=127
MAIN_SYNC_STATUS="ERROR: main-sync.sh not found (checked all three paths) — root main sync unavailable"
else
MAIN_SYNC_STATUS=$("$MAIN_SYNC_SH" --reset --repo "$ROOT_REPO" 2>&1) || MAIN_SYNC_RC=$?
fi
if [ "$MAIN_SYNC_RC" -ne 0 ] && ! [[ "$MAIN_SYNC_STATUS" == aborted:* || "$MAIN_SYNC_STATUS" == skipped:* ]]; then
MAIN_SYNC_STATUS="failed(rc=$MAIN_SYNC_RC): $MAIN_SYNC_STATUS"
fi
fi
if [ "$WRAP_VERBOSE" = "1" ]; then
echo "Main quarantine: $QUARANTINE_STATUS"
echo "Main sync: $MAIN_SYNC_STATUS"
fi
```
See `main-sync.sh --help` and `dirty-main-guard.sh --help` for full contracts.
**If `MAIN_SYNC_STATUS` starts with `aborted:`**: surface the full status line in the final report so the user can run `git log origin/main..main` against the root repo and decide. The PR merge has already succeeded — main-sync failure does not un-merge anything.
Store `MAIN_SYNC_STATUS` and `QUARANTINE_STATUS` for the final report.
## Phase 3: Follow-Up Detection and Full-Session Sweep
Phase 3 has **two parts**, run in order:
- **Part A — Per-PR follow-up detection (Steps 3.1–3.4):** derive follow-ups from the merging PR and its linked issue, dedup, and auto-create GitHub issues.
- **Part B — Full-session sweep (Steps 3.5–3.13):** answer "is there anything I should have ticketed but didn't?" across the entire session. Eight categories: loose ends, ticket coverage, external/process state, memory persistence, PM hygiene, churn hotspots, time-sensitive items, future-self handoff.
**Shared filed-issue registry (`WRAP_FILED_ISSUES`) — initialize before Step 3.1.** Both parts file into the same run-scoped registry. Each entry is `{number, title, keywords, rationale}`. Part A appends in Step 3.3; Part B checks the registry before its own dedup search in Step 3.7.
### Step 3.0: Dedup helper setup (both parts — issue #652)
Resolve the helper once, before Step 3.1:
```bash
ISSUE_DEDUP=$(resolve_script issue-dedup.sh || true)
DEDUP_EXCLUDE=""
DEDUP_DEGRADED=""
DEDUP_ANY_DEGRADED=""
WRAP_FILED_ISSUES="" # newline-separated JSON objects: {number, title, keywords, rationale}; both Part A and Part B append here
dedup_search() { # sets DEDUP_JSON/DUP_NUM/DUP_STATE
local kw="$1" rc=0
DEDUP_JSON='[]'
DEDUP_DEGRADED=""
if [ -n "$ISSUE_DEDUP" ] && [ -n "$kw" ]; then
DEDUP_JSON=$("$ISSUE_DEDUP" "$kw" ${DEDUP_EXCLUDE:+--exclude "$DEDUP_EXCLUDE"}) || rc=$?
if [ "$rc" -gt 1 ]; then
DEDUP_DEGRADED="helper exit $rc"
DEDUP_ANY_DEGRADED="$DEDUP_DEGRADED"
DEDUP_JSON=$(gh issue list --search "${kw} in:title" --state open \
--json number,title,state --jq '[.[0] // empty]' 2>/dev/null || echo '[]')
fi
elif [ -n "$kw" ]; then
DEDUP_DEGRADED="helper not installed"
DEDUP_ANY_DEGRADED="$DEDUP_DEGRADED"
DEDUP_JSON=$(gh issue list --search "${kw} in:title" --state open \
--json number,title,state --jq '[.[0] // empty]' 2>/dev/null || echo '[]')
fi
DUP_NUM=$(printf '%s' "$DEDUP_JSON" | jq -r '.[0].number // empty')
DUP_STATE=$(printf '%s' "$DEDUP_JSON" | jq -r '.[0].state // empty' | tr '[:lower:]' '[:upper:]')
WEAK_DUP_NUM=""
}
```
`issue-dedup.sh <keywords>` prints ranked candidates as JSON, scoring **title and body** across open plus recently-closed issues. Exit `0` = candidates, `1` = none ("no duplicate"), `2`/`4` = failure (degrade to title-only; never treat as "no match").
**A degraded search is never a strong match.** `DEDUP_DEGRADED` resets per call so one transient failure does not force every later candidate down the degraded path. **The helper only finds candidates — it never decides.** Classification (strong / weak / none) and the four strong-match criteria are specified in **`.claude/reference/autofile-dedup.md`**. Two invariants: bias toward filing; suppression is never silent.
> **Phase C / subagent context:** Part B degrades gracefully (transcript-derived categories find little); Part A and git-data categories still run normally. Never block a Phase C merge on a Part B finding — Part B is advisory.
### Part A — Per-PR follow-up detection
### Step 3.1: Detect follow-up items
1. Extract the linked issue number from the PR body via `pr-issue-ref.sh --first` (matches all nine GitHub closing keywords). `--first` is required: follow-up attribution wants one primary issue, and the default mode is set-valued, so a PR closing two issues would otherwise hand `gh issue view` a two-line argument. Distinguish exit `1` (no link — expected) from exits `2`/`3`/`4` (real errors):
```bash
PR_NUMBER="$PR_NUM"
PR_TITLE=$(gh pr view "$PR_NUMBER" --json title --jq '.title')
ISSUE_N=""
if [[ -n "$PR_ISSUE_REF_SH" ]] && RAW_REF=$("$PR_ISSUE_REF_SH" --first "$PR_NUMBER" 2>&1); then
ISSUE_N="$RAW_REF"
else
REF_RC=$?
if [ "$REF_RC" -ne 1 ]; then
echo "Warning: pr-issue-ref.sh exit $REF_RC: $RAW_REF — skipping linked-issue lookup" >&2
fi
fi
ISSUE_TITLE=""
ISSUE_BODY=""
if [ -n "$ISSUE_N" ]; then
ISSUE_TITLE=$(gh issue view "$ISSUE_N" --json title --jq '.title' 2>/dev/null || echo "")
ISSUE_BODY=$(gh issue view "$ISSUE_N" --json body --jq '.body' 2>/dev/null || echo "")
fi
```
2. If a parent issue exists (check for "parent" or "epic" references in the issue body), fetch sibling issues:
```bash
gh issue view {parent_N} --json body --jq .body
```
Look for task lists or child issue references. Check which are still open.
3. Check for related issues mentioned in the PR or issue thread:
```bash
gh api "repos/{owner}/{repo}/issues/{N}/comments?per_page=100" --jq '.[].body'
```
Scan for issue references (`#NNN`), "follow-up", "TODO", "next step", "migration", "deploy" mentions.
4. Check if the issue itself has sub-tasks (task list checkboxes) that are unchecked.
Collect each detected follow-up as a `{title, body, keywords}` record.
### Step 3.2: HHG two-ticket pattern detection
If the PR title, linked issue title, or linked issue body contains "HHG" (case-insensitive), **override** any generic follow-ups with exactly **two** HHG follow-ups (scraping + ETL):
```bash
HHG_MATCH=$(printf '%s\n%s\n%s\n' "$PR_TITLE" "$ISSUE_TITLE" "$ISSUE_BODY" | grep -iE 'HHG' || true)
if [ -n "$HHG_MATCH" ]; then
COMBINED=$(printf '%s %s %s' "$PR_TITLE" "$ISSUE_TITLE" "$ISSUE_BODY")
STATE=$([[ -n "$HHG_STATE_SH" ]] && "$HHG_STATE_SH" "$COMBINED" || true)
if [ -z "$STATE" ]; then
STATE=""
echo "WARNING: HHG PR detected but no state code found in PR title, issue title, or issue body — skipping HHG auto-creation. Create the scraping and ETL issues manually once you know the state."
fi
fi
```
**If `STATE` is empty, skip HHG auto-creation entirely** — do NOT create issues with placeholder titles. Report the skip in Step 3.4.
Two HHG follow-up titles: `{STATE} HHG — Export carriers and run scraper` and `{STATE} HHG — Seed product codes and load scrape results to Neon`. Create scraping issue first (capture number as `SCRAPE_NUM`), then ETL with `Depends on #${SCRAPE_NUM}` in its body.
**HHG override trade-off:** Replaces any generic follow-ups to keep the two-ticket pattern clean.
### Step 3.3: Dedup check and create
For each follow-up item (the HHG pair or the generic list):
1. **Dedup check:**
```bash
dedup_search "$KEYWORDS" # Step 3.0 — sets DEDUP_JSON, DUP_NUM, DUP_STATE, WEAK_DUP_NUM
```
Classify per `.claude/reference/autofile-dedup.md`:
- **Strong** → comment the follow-up onto `#{DUP_NUM}`; record `"{title}" — appended to #{DUP_NUM}` for Step 4.3.
- **Weak / ambiguous or closed** → set `WEAK_DUP_NUM="$DUP_NUM"` and file with a `Possibly duplicates #{DUP_NUM}` body line.
- **None** → file.
2. **Create the issue** (strong match excepted):
```bash
LINKED_SOURCE=""
if [ -n "$ISSUE_N" ]; then
LINKED_SOURCE=$'\n\n'"Linked source: #${ISSUE_N}"
fi
POSSIBLE_DUP=""
if [ -n "${WEAK_DUP_NUM:-}" ]; then
POSSIBLE_DUP=$'\n\n'"Possibly duplicates #${WEAK_DUP_NUM} — {one line on the overlap and what is unclear}."
fi
ISSUE_TITLE="{derived title}"
ISSUE_BODY="Follow-up from PR #${PR_NUMBER}.${POSSIBLE_DUP}
{context from detection}${LINKED_SOURCE}
_Filed via /wrap._"
# Surface title+body before filing (--verbose only; the silent default records without printing):
if [ "$WRAP_VERBOSE" = "1" ]; then
echo "Filing follow-up: $ISSUE_TITLE"
echo "$ISSUE_BODY"
fi
if NEW_URL=$(gh issue create \
--title "$ISSUE_TITLE" \
--body "$ISSUE_BODY" 2>&1); then
NEW_NUM=$(echo "$NEW_URL" | grep -oE '[0-9]+$')
if [ -z "$NEW_NUM" ]; then
echo "WARNING: created issue but could not parse number from: $NEW_URL"
fi
# Append {number, title, keywords, rationale: "follow-up from PR #${PR_NUMBER}"} to WRAP_FILED_ISSUES.
# Append $NEW_NUM to DEDUP_EXCLUDE.
else
echo "WARNING: gh issue create failed: $NEW_URL"
fi
```
### Step 3.4: Carry Part A results into the unified report
Part A does **not** print its own "Created" list — every issue it filed is in `WRAP_FILED_ISSUES` and rendered once in Step 4.3's verbose report. Carry forward: filed issues (already in registry), suppressed-as-duplicates, filed-with-caveat, and failures. Filed-with-caveat entries and creation failures are the two that also print on the silent default. If nothing detected and nothing filed, the verbose report reads "No follow-up items detected." Proceed immediately to Part B.
### Part B — Full-session sweep
Part B sweeps the **whole session** for loose ends the per-PR detection in Part A cannot see. It produces two buckets — **Auto-handled** and **Needs your decision** — and a one-line **verdict**.
**Safety boundaries (non-negotiable — issue #471):** never auto-file without recording the full body (rendered under `--verbose` or on request — issue #851); never auto-act on anything affecting shared state (live monitors, active subagents, human-owned issues/PRs, recovery branches); auto-handling limited to: stopping a **dead** session Monitor task and deleting a **stale** handoff file.
#### Step 3.5: Sweep setup & idempotency guard
```bash
SESSION_STATE_SH=$(resolve_script session-state.sh || true)
SWEEP_PRIOR_FILED=""
if [[ -n "$SESSION_STATE_SH" ]]; then
SWEEP_PRIOR_FILED=$("$SESSION_STATE_SH" --get ".prs[\"$PR_NUMBER\"].wrap_sweep.filed_issues" 2>/dev/null || echo "")
fi
```
Initialize `SWEEP_AUTO_HANDLED` and `SWEEP_NEEDS_DECISION` (free-form bullet lists). Cap each rendered section at 3–5 bullets (overflow → "+ N more" summary); auto-filed tickets are **exempt** from the cap — every created issue's title + body is surfaced in full under `--verbose`.
#### Step 3.6: Category 1 — Session loose ends (transcript introspection)
**Model-introspection step** — no shell command can read the transcript. Review your own recent messages and tool-call history for deferral signals: `later`, `TODO`, `follow-up`, `come back to`, `worth investigating`, `for now`, `deferred`, `out of scope`, `punt`, `we should eventually`, `not in this PR`, `leaving X for a separate change`. After compaction, fall back to tool-call history and `git diff origin/main...HEAD` scan for new `TODO`/`FIXME` comments.
Collect each loose end as `{summary, context, keywords}`.
#### Step 3.7: Category 2 — Ticket coverage (dedup, then file)
For **each** loose end from Step 3.6, dedup before filing:
**Stage 1 — cross-batch (this run).** Check against `WRAP_FILED_ISSUES` first. Collapse only when same primary artifact and already-filed scope covers this finding. Record `Collapsed into #{N} (filed earlier this run) — "<summary>"` in `SWEEP_AUTO_HANDLED`.
**Stage 2 — cross-session (the repo).**
```bash
dedup_search "$KEYWORDS" # Step 3.0
```
Classify per `.claude/reference/autofile-dedup.md` and take exactly one path:
- **Strong match** (open, same artifact, quotable criterion, `coverage ≥ 0.6`) → comment onto `#{DUP_NUM}`; if `DUP_NUM` is in `SWEEP_PRIOR_FILED`, **omit entirely** (idempotent). Record in `SWEEP_AUTO_HANDLED`.
- **Weak / closed** → set `WEAK_DUP_NUM` and file with `Possibly duplicates #{DUP_NUM}` body line; add to `SWEEP_NEEDS_DECISION`.
- **None** → file.
On filing: create immediately, **surface title + body under `--verbose`**, append to `WRAP_FILED_ISSUES` (rationale `loose end: <summary>`), append number to `DEDUP_EXCLUDE`, add to `SWEEP_AUTO_HANDLED`. Use same `gh issue create` guard as Step 3.3 (validate parsed number; on failure record and continue):
```bash
POSSIBLE_DUP=""
if [ -n "${WEAK_DUP_NUM:-}" ]; then
POSSIBLE_DUP=$'\n\n'"Possibly duplicates #${WEAK_DUP_NUM} — <one line on the overlap and what is unclear>."
fi
if NEW_URL=$(gh issue create \
--title "<derived title>" \
--body "Follow-up surfaced by /wrap session sweep on PR #${PR_NUMBER}.${POSSIBLE_DUP}
<context from transcript>
_Filed via /wrap._" 2>&1); then
NEW_NUM=$(echo "$NEW_URL" | grep -oE '[0-9]+$')
if [ -n "$NEW_NUM" ]; then
SWEEP_FILED="${SWEEP_FILED} ${NEW_NUM}"
# Append {number, title, keywords, rationale} to WRAP_FILED_ISSUES.
else
echo "WARNING: created issue but could not parse number from: $NEW_URL"
fi
else
echo "WARNING: gh issue create failed: $NEW_URL"
fi
```
#### Step 3.8: Category 3 — External / process state
```bash
[ -n "$SESSION_STATE_SH" ] && POLLING_JOBS=$("$SESSION_STATE_SH" --get '.polling_jobs' 2>/dev/null || echo "null")
# A map keyed by agent id since issue #1631 — test emptiness with `length`, not
# an array check. Its entries are surfaced below, never auto-acted on.
[ -n "$SESSION_STATE_SH" ] && ACTIVE_AGENTS=$("$SESSION_STATE_SH" --get '.active_agents' 2>/dev/null || echo "null")
ACTIVE_AGENT_COUNT=$(jq -r 'if type == "object" or type == "array" then length else 0 end' <<<"$ACTIVE_AGENTS" 2>/dev/null || echo 0)
```
- **Dead Monitor tasks (auto-stop).** For each watcher whose target PR is merged/closed: read the complete `.prs["$N"].babysit.monitor_task_id` + `.monitor_generation` identity pair, stop that exact task with `TaskStop` when present, then atomically set `stop_requested=true`, `active=false`, `monitor_task_id=null`, and `monitor_generation=null` via one `session-state.sh --set` batch. Clear neither identity field unless exact `TaskStop` succeeds. Record `Stopped stale Monitor task (PR #N watcher — PR already merged)` in `SWEEP_AUTO_HANDLED`. A missing/incomplete identity or failed task stop is a decision item, never a claimed successful stop.
- **Stale handoffs (auto-delete).** Scan both layout patterns (`~/.claude/handoffs/pr-*.json` and `~/.claude/handoffs/*/*/pr-*.json`). For each merged PR: resolve path with `handoff-state.sh --owner-repo <owner>/<repo> --path N` and delete via `handoff-state.sh --owner-repo <owner>/<repo> --delete N`. Never `rm -f` directly — that bypasses the state-lock advisory lock (issue #682). Deleting a **flat-layout** file is the one legitimate unscoped delete here: pass `--legacy-flat` on that call and only that call (issue #1366 — an omitted scope now derives owner/repo from the cwd, or exits 2 writing nothing; it no longer falls back to flat).
- **Surface (never auto-act).** Add to `SWEEP_NEEDS_DECISION`: live `CronCreate` jobs, any `active_agents` entries (`ACTIVE_AGENT_COUNT` above 0), `monitoring_active=true`, and any `recovery/dirty-main-*` branches.
#### Step 3.9: Category 4 — Memory persistence (defers to Phase 4)
Memory writes are owned by **Phase 4**. Category 4 only *flags* as `SWEEP_MEMORY_CANDIDATES` (handed to Phase 4 Step 4.2): decisions made this session worth persisting, and existing memories the session contradicted. Surface a memory item in the sweep report only if Phase 4 is skipped as trivial.
#### Step 3.10: Category 5 — PM hygiene (all touched issues/PRs)
Build the set of issues/PRs touched this session — session-state `.prs` keys, the merging PR + its linked issue, any issue/PR numbers from tool-call history. For each, check and flag drift under `SWEEP_NEEDS_DECISION` (do not auto-edit — shared state):
- **Status accuracy** — issue still open but its work merged? PR merged but linked issue not auto-closed?
- **Linkage** — PR missing `Closes #N` for work that clearly resolves an issue?
- **AC checkbox truthfulness** — Test Plan boxes checked that the code does not actually satisfy?
#### Step 3.10a: Category 5a — Churn hotspots (issue #755)
Run **after** Step 2.5's root-main sync:
```bash
CHURN_SH=$(resolve_script churn-hotspots.sh || true)
CHURN_PLAN_SH=$(resolve_script churn-hotspot-wrap-plan.sh || true)
CHURN_JSON=""; CHURN_PLAN=""; CHURN_RC=0
if [ -n "$CHURN_SH" ]; then
CHURN_JSON=$("$CHURN_SH" --json \
--exclude ".claude/reference/script-extraction-audit.md" \
2>/dev/null) || CHURN_RC=$?
fi
```
Exit `1` (nothing crossed threshold) or missing detector → end category immediately, file nothing, add no report line. `existing_lookup_failed == true` → add one `SWEEP_NEEDS_DECISION` bullet: ``Churn hotspot check ran but the existing-issue lookup failed — re-run `churn-hotspots.sh --json` before filing.`` Then **end this category immediately**: do not invoke the classifier, comment, or file. The classifier independently rejects this envelope as a defense in depth.
On detector exit `0`, classify the envelope with the read-only consumer before any mutation:
```bash
if [ -z "$CHURN_PLAN_SH" ]; then
SWEEP_NEEDS_DECISION="${SWEEP_NEEDS_DECISION}
- Churn hotspot classifier is unavailable — no hotspot issue or comment was changed; restore \`churn-hotspot-wrap-plan.sh\` and re-run /wrap."
elif ! CHURN_PLAN=$(printf '%s' "$CHURN_JSON" | "$CHURN_PLAN_SH" --pr "$PR_NUMBER" 2>/dev/null); then
SWEEP_NEEDS_DECISION="${SWEEP_NEEDS_DECISION}
- Churn hotspot classifier failed — no hotspot issue or comment was changed; run \`churn-hotspot-wrap-plan.sh --help\` and re-run /wrap."
fi
```
An empty `CHURN_PLAN` ends the category after recording that failure. A valid plan has disjoint `comment_set`, `file_set`, `material_growth_set`, `unknown_state_set`, and `suppressed_set` arrays. It also selects the top file candidate and computes the aggregate summary. **Do not reimplement those predicates inline** — that would let the tested consumer and the mutating workflow drift.
Score formula, threshold calibration, and the capping rationale: **`.claude/reference/churn-hotspots.md`**.
**Four independent branches operate on the classifier's different sets** (do NOT select once and then branch):
- **Comment set** — every row in `CHURN_PLAN.comment_set`, uncapped. The classifier includes an open match only when its `pr_numbers` contain the PR this wrap just merged, preserving comment idempotency. For each member:
```bash
gh issue comment "$EXISTING" --body "Still churning: ${PR_COUNT} distinct merged PRs have touched \`${FILE}\` since ${SINCE}${CONFLICT_CLAUSE}: ${PR_LIST}.
_Evidence appended by /wrap after PR #${PR_NUMBER} merged._"
```
Record `Appended evidence to #{EXISTING} — churn hotspot \`{file}\`` for Step 4.3.
- **File set** — **at most one new issue per run**, still. Use `CHURN_PLAN.selected_file`; the classifier sorts all eligible `file_set` rows by score, PR count, then path. Eligible hotspots remain exactly two kinds:
1. `existing_hotspot_issue == null` — never ticketed. Files clean, exactly as before.
2. `existing_hotspot_issue_state == "closed"` **and** `conflict_rounds > 0` — a **closed-match re-file** (issue #915). Adds the caveat line below and is reported under `SWEEP_NEEDS_DECISION`, not `SWEEP_AUTO_HANDLED`.
A closed match with `conflict_rounds == 0` is **not eligible** — it falls to the growth-or-suppression branches below. Title: `Refactor hotspot: {file}`. Body must include the `<!-- churn-hotspot: {file} -->` marker verbatim (re-find fallback):
```bash
# Set only for kind 2 — a closed exact match is a WEAK match in the
# `autofile-dedup.md` sense, so it re-files with a caveat, never cleanly.
CLOSED_DUP=""
if [ -n "${CLOSED_DUP_NUM:-}" ]; then
CLOSED_DUP=$'\n\n'"Possibly duplicates #${CLOSED_DUP_NUM} — same file, closed after review; re-filed because the churn has since cost ${CONFLICT_ROUNDS} conflict re-resolution(s)."
fi
if NEW_URL=$(gh issue create \
--title "Refactor hotspot: ${FILE}" \
--body "Filed by /wrap churn detection after PR #${PR_NUMBER} merged.${CLOSED_DUP}
\`${FILE}\` was touched by ${PR_COUNT} distinct merged PRs since ${SINCE}${CONFLICT_CLAUSE}: ${PR_LIST}.
This is an observational report, not a prescription — it flags a file worth a
closer look, not a decision that it should be split.
<!-- churn-hotspot: ${FILE} -->
_Filed via /wrap._" 2>&1); then
NEW_NUM=$(echo "$NEW_URL" | grep -oE '[0-9]+$')
if [ -n "$NEW_NUM" ]; then
SWEEP_FILED="${SWEEP_FILED} ${NEW_NUM}"
# Append {number, title, keywords, rationale: "churn hotspot: {file}"} to WRAP_FILED_ISSUES.
# Append $NEW_NUM to DEDUP_EXCLUDE.
# Kind 2 only: a re-file over a closed issue is a decision, not an
# auto-handled filing, so it must reach SWEEP_NEEDS_DECISION (Step 3.13
# renders from that list — appending to SWEEP_FILED alone would ship the
# re-file with no owner-visible flag).
if [ -n "${CLOSED_DUP_NUM:-}" ]; then
SWEEP_NEEDS_DECISION="${SWEEP_NEEDS_DECISION}
- Re-filed #${NEW_NUM} for churn hotspot \`${FILE}\` over closed #${CLOSED_DUP_NUM} — the churn has now cost ${CONFLICT_ROUNDS} conflict re-resolution(s). Confirm the re-file or close it as still-by-design."
fi
else
echo "WARNING: created issue but could not parse number from: $NEW_URL"
fi
else
echo "WARNING: gh issue create failed: $NEW_URL"
fi
```
`CONFLICT_CLAUSE` is empty when `conflict_rounds` is 0; otherwise ` and re-resolved conflicts {conflict_rounds} time(s)`. `CONFLICT_ROUNDS` is that hotspot's `conflict_rounds`, and `CLOSED_DUP_NUM` its `existing_hotspot_issue` — both set only on a kind-2 re-file, left unset for kind 1.
- **Material-growth set** — every row in `CHURN_PLAN.material_growth_set`. These are closed matches with zero conflict cost whose current score is at least **2×** the score recorded in `.claude/reference/churn-hotspot-baselines.json`. File nothing, but add one `SWEEP_NEEDS_DECISION` bullet naming the file, issue, current score, baseline score, and baseline date. Doubling is the material-growth threshold: it is a step-change in centrality rather than the routine linear growth expected from catalogs and junction files. The five standing no-action verdicts on Issues #816, #860, #900, #916, and #1223 remain suppressed until this gate trips.
- **Suppressed set** — every row in `CHURN_PLAN.suppressed_set`. Add **no per-file decision bullet**. These are closed matches with zero conflict cost that are below the 2× gate, plus closed matches with no valid file-and-issue baseline. A missing baseline means growth is unproven, not that growth occurred; the row remains visible through the aggregate count. When a zero-conflict hotspot issue is closed or the owner reaffirms a keep verdict, add or refresh its file-keyed baseline with the issue number, score, PR count, and decision date. A baseline records a real owner decision and must never be raised merely to silence a growth signal.
- **Unknown-state set** — every row in `CHURN_PLAN.unknown_state_set`. **Neither comment nor file**, and add one `SWEEP_NEEDS_DECISION` bullet naming the issue and file. The classifier treats any non-null issue whose state is not open/closed as unknown; only a null issue is a never-ticketed file candidate.
After all five branches, append exactly one `SWEEP_AUTO_HANDLED` entry from `CHURN_PLAN.summary`, followed by `` — run `churn-hotspots.sh` to see them.`` The verbose shape is: ``{total} churn hotspots, {conflict-cost count} with conflict cost, {decision count} surfaced for decision, {suppressed count} suppressed (closed, no conflict cost)``. This replaces N closed/no-cost decision bullets with one auditable count and does not print on the silent default. When `held_back_file_count > 0`, append the existing file-cap clause to that same entry: ``; filed the top hotspot, {N} further candidate(s) held back``. Do not create a second churn summary line.
This category's authoritative re-find is the script's **exact** title/marker match — do not run `dedup_search` here.
#### Step 3.11: Category 6 — Time-sensitive items
Scan transcript for deadline/date/day-of-week phrases: `by Thursday`, `before the release`, `next week`, `end of month`, `by EOD`, `in N days`. Convert every relative reference to an absolute date:
```bash
TODAY=$(date +%Y-%m-%d)
```
`/schedule` available → propose a `/schedule` task per item (surface the proposed command; do not auto-schedule). Unavailable → plain-text reminder with absolute date. No phrases found → **produce no output**.
Add any items to `SWEEP_NEEDS_DECISION`.
#### Step 3.12: Category 7 — Future-self handoff
**Only if the session deferred something meaningful** (Category 1, 2, 5, or 6 produced surfaced items). On a clean session, **skip entirely**. When warranted, generate **one paragraph**: task, what shipped, what was deliberately left, the single most important next step. Add as the final `SWEEP_NEEDS_DECISION` entry.
#### Step 3.13: Persist sweep state & compute verdict
```bash
if [ -n "$SESSION_STATE_SH" ]; then
# Build from the run-scoped WRAP_FILED_ISSUES registry (includes both Part A and Part B filings),
# not SWEEP_FILED alone (Part B only). Extract the .number field from each entry.
ALL_FILED_JSON=$(printf '%s\n' "$WRAP_FILED_ISSUES" | jq -sc '[.[].number] | map(numbers)')
NEW_FILED_JSON=$ALL_FILED_JSON
PRIOR_FILED_JSON="$SWEEP_PRIOR_FILED"
case "$PRIOR_FILED_JSON" in ""|null) PRIOR_FILED_JSON='[]' ;; esac
FILED_JSON=$(jq -cn --argjson a "$PRIOR_FILED_JSON" --argjson b "$NEW_FILED_JSON" '($a + $b) | unique')
AUTO_JSON=$(printf '%s\n' "$SWEEP_AUTO_HANDLED" | jq -R 'select(length>0)' | jq -cs .)
NEEDS_JSON=$(printf '%s\n' "$SWEEP_NEEDS_DECISION" | jq -R 'select(length>0)' | jq -cs .)
if ! "$SESSION_STATE_SH" \
--set ".prs[\"$PR_NUMBER\"].wrap_sweep.swept_at=\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"" \
--set ".prs[\"$PR_NUMBER\"].wrap_sweep.filed_issues=$FILED_JSON" \
--set ".prs[\"$PR_NUMBER\"].wrap_sweep.auto_handled=$AUTO_JSON" \
--set ".prs[\"$PR_NUMBER\"].wrap_sweep.needs_decision=$NEEDS_JSON"; then
echo "WARNING: failed to persist wrap_sweep state — a re-run may not be idempotent; the Step 3.7 dedup search still guards against open duplicates." >&2
fi
fi
```
**UNION** this run's filed numbers with any recorded by a prior sweep — never overwrite. A re-run with no new filings must NOT erase the earlier record.
Compute **verdict** (one of exactly two canonical strings — no improvised wording):
- `0` pending → **`Clear to archive`**
- `N > 0` pending → **`N items pending your decision before archive`**
Proceed immediately to Step 3.14 — do not ask.
#### Step 3.14: Post-merge TestFlight release (iOS repos — issue #1169)
The merge has landed, so the release decision can be reported and the deferred half executed. Three things happen, in order; all are non-fatal.
```bash
# RELEASE_DECIDE and RELEASE_SWEEP were resolved in the preamble.
# 1. Mechanisms that must fire AFTER the merge (workflow_dispatch, none).
# Dispatching before the merge would have built the pre-merge default branch.
REL_RC=0; REL_OUT=""
if [ -x "$RELEASE_DECIDE" ]; then
REL_OUT=$("$RELEASE_DECIDE" --pr "$PR_NUM" --phase post-merge --apply 2>&1) || REL_RC=$?
fi
# 2. ONE success line for the whole step, no matter how many phases applied or
# how many repos the sweep touched. `/wrap`'s silent path allows exactly one
# `merged PR #N` line, so a successful release must not add several more.
# Failures and blockers still print one terse line each.
#
# A blocker is reported for EVERY exit 3, including one that produced no
# parseable output — a release that failed is exactly what must not vanish.
# `.reason // "unknown"` alone would not cover that: jq's default fills a
# MISSING KEY, but on non-JSON input (a crash message, a partial write) jq
# exits non-zero and prints nothing, leaving an empty reason. So each field
# falls back explicitly, ending at the raw output and then at the exit code.
# Diagnostics are collapsed to one line before printing — release-decide.sh
# stderr can be multi-line, and a blocker that spans six lines is not "one
# terse line".
RELEASE_CUT=""
one_line() { printf '%s' "$1" | tr '\n' ' ' | sed 's/ */ /g; s/^ //; s/ $//' | cut -c1-300; }
for pair in "$RELEASE_PREMERGE_RC:$RELEASE_PREMERGE_OUT" "$REL_RC:$REL_OUT"; do
rc="${pair%%:*}"; out="${pair#*:}"
applied=$(printf '%s' "$out" | jq -r '.applied // false' 2>/dev/null)
repo=$(printf '%s' "$out" | jq -r '.repo // empty' 2>/dev/null)
reason=$(printf '%s' "$out" | jq -r '.reason // empty' 2>/dev/null)
[ -n "$repo" ] || repo="this repo"
[ -n "$reason" ] || reason="${out:-no output from release-decide.sh (exit $rc)}"
if [ "$rc" = "0" ] && [ "$applied" = "true" ]; then
RELEASE_CUT="$repo"
elif [ "$rc" = "3" ]; then
echo "TestFlight release blocked — $repo: $(one_line "$reason")" >&2
fi
done
# 3. Follow in-flight builds to a terminal state and cut any pending build whose
# window has opened — including markers left by threads that have since ended.
# Scoped to the repo just merged: sweeping every other repo carrying release
# state is the periodic surfaces' job (session start, each PMM tick).
# --quiet keeps its human lines out of the silent path; attention events are
# re-emitted below, one line each.
MERGED_REPO=$(gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null || true)
if [ -x "$RELEASE_SWEEP" ]; then
SWEEP_OUT=$("$RELEASE_SWEEP" ${MERGED_REPO:+--repo "$MERGED_REPO"} --json 2>/dev/null) || true
if [ -n "${SWEEP_OUT:-}" ]; then
# A build the sweep cut folds into the single success line rather than adding
# one of its own.
SWEEP_CUT=$(printf '%s' "$SWEEP_OUT" | jq -r '[.[]? | select(.kind == "build_cut")] | first | .repo // empty' 2>/dev/null)
[ -n "$SWEEP_CUT" ] && [ -z "$RELEASE_CUT" ] && RELEASE_CUT="$SWEEP_CUT"
printf '%s' "$SWEEP_OUT" | jq -r '.[]? | select(.needs_attention == true) | .line' 2>/dev/null >&2
fi
fi
# 4. The single success line, emitted once, only when something actually shipped.
[ -n "$RELEASE_CUT" ] && echo "cut TestFlight build — $RELEASE_CUT (PR #$PR_NUM)"
```
Exit `3` from either phase is a blocker worth one line (a trigger that failed, or a build cut whose state could not be saved and so will not be followed). Exit `1` (pending or suppressed), `2` (inert), and `4` (environment) are all silent and all non-fatal — `/wrap` never fails a completed merge over release automation.
Mechanism, policy schema, and the per-repo trigger table: `.claude/reference/release-cadence.md`.
Proceed immediately to Phase 4 — do not ask.
## Phase 4: Lessons Learned (Depth-Adaptive)
### Step 4.1: Assess session complexity
- **Cycle count** — resolve it without conflating an unavailable/failed helper with a real zero:
```bash
CYCLES=""
CYCLE_COUNT_VALID=0
if [[ -z "$CYCLE_COUNT_SH" ]]; then
echo "DEGRADED: cycle-count.sh not found (checked all three paths) — cycle count unavailable" >&2
elif CYCLES=$("$CYCLE_COUNT_SH" "$PR_NUM") && [[ "$CYCLES" =~ ^[0-9]+$ ]]; then
CYCLE_COUNT_VALID=1
else
CYCLES=""
echo "DEGRADED: cycle-count.sh failed or returned a non-numeric value — cycle count unavailable" >&2
fi
```
- **Thread length** — count user + assistant messages. "Short" = fewer than 15 total.
- **PR size** — `gh pr view N --json files --jq '.files | length'`
**Trivial threshold:** `CYCLE_COUNT_VALID=1` AND cycle count = 0 AND conversation short (<15 messages) AND <5 files changed. An unknown cycle count is non-trivial.
### Step 4.2: Run lessons (or skip)
**If trivial:** set `WRAP_LESSONS_COUNT=0` and skip to Step 4.3.
**If non-trivial:** reflect on the session — task, what was accomplished, what went wrong, patterns, surprises, workarounds to codify. **Merge in `SWEEP_MEMORY_CANDIDATES`** from Phase 3 Category 4 and dedup against the lessons before writing.
For each actionable, novel lesson:
- Check `MEMORY.md` for duplicates — update existing memories rather than creating new ones
- Write memory files with proper frontmatter (`feedback`, `project`, or `user` type)
- Add pointers to `MEMORY.md`
Memory writes happen in both modes. Record `WRAP_LESSONS_COUNT`. Verbose mode prints the full `## Session Lessons` block; the silent default prints no lessons ack at all — the memory files are the record.
### Step 4.3: Final report
`/wrap` has three output paths: the **silent default**, the **verbose report**, and the **Blocker path** (prints in *both* modes).
#### Silent default
On a clean run, print exactly one line — `merged PR #N` — then nothing else: no follow-up list, no lessons ack, no sweep summary. All of it is already recorded (GitHub, `WRAP_FILED_ISSUES`, `session-state.json` `wrap_sweep`, memory) and re-renders in full under `--verbose` or on request.
This bounds `/wrap`'s **own** output only. The thread's single end-of-task wrap-up (`.claude/reference/final-wrapup-format.md`) is a task-level always-emit exception under `CLAUDE.md` #3 — it is not `/wrap` output, and it does not violate this default. Its trigger is the **task** reaching terminal state, never a `/wrap` return: a fleet loop such as `/pr-monitor-and-manage`, which calls `/wrap` once per merge-ready PR and keeps managing the fleet, emits no wrap-up on those returns — only one when the fleet run itself ends.
Print **only** items that need an explicit decision from the user, one terse line each:
- **Blocker or stop condition** → the Blocker path below. Mandatory — silence must never swallow a stop.
- **A filing that needs your judgment** → `gh issue create` failed, or the issue was filed with a `Possibly duplicates #{N}` caveat: `Filed [#{a}](url) — possibly duplicates #{b}.` A filing with no caveat is recorded silently.
- **Sweep verdict, only when items are pending** → `{N} item(s) pending your decision — run /wrap --verbose for detail.` Omit entirely on `Clear to archive`.
- **`[COVERAGE]` degraded** and the **`[INFERRED]` checkpoint** — per the always-print list in **Output modes**.
When none of those apply, `/wrap` emits exactly one line: `merged PR #N`. (`CLAUDE.md` #3)
#### Verbose report (`--verbose`, or on explicit request)
Print this block first:
```
## Wrapped up
**Merged:** PR #{N} ({title}) — {≤3 sentences on the gist of what changed}.{ Only when main-sync was noteworthy — an `aborted:` outcome or a quarantine actually ran: " Main: {one-line status}."}
**Follow-ups:** {either "Opened [#{a}](url) — {one line}; [#{b}](url) — {one line}." listing every WRAP_FILED_ISSUES entry (≤3 sentences), or "No follow-ups opened."}{ When any filing was suppressed as a duplicate: " {M} finding(s) appended to existing issues."}
{lessons ack, one line: "{WRAP_LESSONS_COUNT} lesson(s) captured to memory." | "Clean session — no lessons."}
{sweep line — ONLY when verdict is "N items pending your decision before archive": "Session sweep: {N} item(s) pending your decision — run `/wrap --verbose` for detail." Omit on "Clear to archive".}
```
Rules: **Merged ≤3 sentences; Follow-ups ≤3 sentences.** Every opened issue is listed (number + one-line + clickable link) — an issue not listed is indistinguishable from one silently dropped. Suppressed-as-duplicate findings: never silently dropped (surface count). Sweep verdict: appears only when decisions are pending.
Then the full `## Wrap-Up Complete` multi-section template, rendering rules, section caps, and exemptions: **`references/wrap-report-templates.md`**.
#### Blocker path (both modes)
Print a **single short line** naming the reason. Under `--verbose`, also append the full `WRAP_RECOVERY_AUDIT` / `missing` detail:
- **Merge gate not met after recovery cap** → `Merge blocked: {last missing summary}. Re-run /wrap or /fixpr.`
- **`mergeable == CONFLICTING`** → `Merge blocked: merge conflicts — run /merge-conflict.`
- **Human `CHANGES_REQUESTED` on HEAD** → `Merge blocked: changes requested by {login(s)}.`
- **AC checkbox verification failure (Step 2.2)** → `Merge blocked: AC item "{text}" not yet satisfied — fix the code first.`
- **`/fixpr` delegation failure (Step 2.1 Branch B)** → `Merge blocked: /fixpr could not resolve {blocker} — {FIXPR_WRAP_STATUS}.`
- **No PR found** → the Step 1.1f message verbatim.
The blocker line is **mandatory on the silent default** — silence must never swallow a stop.
The worktree and feature branch are intentionally left in place, reaped out-of-band by `/pm-clean`'s stale-cleanup pass. See `stale-cleanup.sh --help`.