---
name: apt:close-task
description: "Post-merge closer — confirm PR merged, flip phase, GC state"
apt-skill-version: {{APT_VERSION}}
stage: ship
intent: x-close-task
when_to_use: "A shipped task's PR has merged and the closer side (phase flip, GC) needs to run."
user_invocable: true
internal: false
spawns_agent: false
agent_name: null
task_context: require-existing
default_execution_mode: auto
execution_modes:
  - auto
  - step
allowed-tools: "Bash, Read, Grep, Glob, Write"
argument-hint: "apt:close-task [--all | --task <id>] [--narrate-only]"
gates: []
---
<objective>
Confirm a shipped PR has merged on GitHub, then run the closer side of the task lifecycle: flip `lifecycle_phase` from `shipped-pending-merge` to `completed`, strip stale per-PR overrides from `.aperant/config.json`, clean up worktree per policy, and emit `task.closed.merged` events for the audit trail. This is the post-merge counterpart to `/apt:ship`.
</objective>

<your_environment>
- **Working directory:** The project root (where you were invoked)
- **apt-tools path:** `node packages/framework/bin/apt-tools.mjs`
- **GitHub CLI:** `gh` must be installed and authenticated; offline invocations are tolerated (tasks skipped with `reason: 'offline'`)
</your_environment>

<state_files>
## State Files

**Reads:**
- `.aperant/state.json` — `active_tasks[*]` — filtered to those with `lifecycle_phase === 'shipped-pending-merge'`.
- `.aperant/config.json.pr_review.authorship_overrides` — stripped per-PR on confirmed merge.

**Writes (via apt-tools task close-merged):**
- `.aperant/state.json` — moves merged tasks from `active_tasks` → `completed_tasks` (via the same close path as `task close`).
- `.aperant/events/{today}.jsonl` — emits `task.closed.merged`.
- `.aperant/config.json` — strips `authorship_overrides['pr-{N}']` entries for merged PRs.
- `.aperant/roadmap/{scope}/**` — linked-phase flip (when `task_isolation.auto_close_phase: true`).
- `.aperant/ci-watches/{pr}.json` — removed for each merged PR (BUG-027 (b) side-effect: prevents watcher state files leaking past PR-terminal-state). Idempotent — already-removed state is a no-op. For retroactive cleanup of orphan state files left behind by pre-fix runs, use `apt-tools ci-watch sweep . --orphaned`.
</state_files>

<process>

## 1. Enumerate candidates

Run `apt-tools task close-merged . --all --dry-run` first to see what would happen without mutating state. The envelope lists:

- `candidates` — every task currently in `shipped-pending-merge`.
- For each, its `pr_url`, its `gh pr view` result (state, mergedAt), and the action the non-dry-run would take (close, skip-open, skip-offline, skip-no-pr-url).

If `--task <id>` is passed, narrow to that task.

## 2. Confirm merge

For each candidate the dry-run says `close`:

```bash
node packages/framework/bin/apt-tools.mjs task close-merged . --task <id>
```

(or `--all` to iterate every confirmed-merged task in one invocation.)

Internally, close-merged does:

1. `gh pr view <pr_url> --json state,mergedAt` — offline failure → `{reason:'offline'}`, rate-limit → retry once then `{reason:'rate_limited'}`.
2. When `state === 'MERGED'`:
   a. `apt-tools task close . --id {task-id} --verdict approved` (respects `task_isolation.auto_close_phase`).
   b. Strips `config.pr_review.authorship_overrides['pr-{N}']` if present.
   c. Emits `task.closed.merged` event.
   d. Appends a `pending_narration[]` ledger row to `.aperant/state.json` so `--narrate-only` (see §4) can pick it up. Additive only; never blocks the close.
3. When `state !== 'MERGED'`, the task stays in `shipped-pending-merge` with a skip reason the user can inspect.

## 2.5 Narrate completed phases (moved from /apt:ship in C56 B5)

Before C56, `/apt:ship` spawned `apt-team-docs-narrator` to write "Recent activity" prose into ROADMAP.md immediately after opening the PR. That produced landed-work claims about unlanded code. Ownership moved here — narration runs when the PR is confirmed merged, not when it's opened.

For each entry in the close-merged envelope's `closed[]` whose record has a non-null `phase_id` (and the phase just flipped to `completed` as part of step 2a), spawn the narrator:

- `subagent_type`: `apt-team-docs-narrator`
- `run_in_background`: `true`
- `description`: `Narrate shipped phase {phase_id}`
- `prompt`: `Run in phase-ship mode with --phase {phase_id} --scope {scope}. Reference the apt-team-docs-narrator agent definition for the anchor-based replace logic and mediated commit flow.`

Do NOT wait on the narrator. Its status lands in `.aperant/digests/.last-run.json`. Skip narrator spawn entirely when `closed[]` is empty or no closed item has a phase_id.

After step 2.5 spawns narrators for the just-closed candidates, the skill ALSO drains `state.pending_narration[]` (entries the passive sweep parked there) — see §4.

## 2.6 Drop user-facing release-note fragment (persona-aware release-notes opt-in)

This step is additive bookkeeping — it must NEVER block the close path or surface a destructive failure. Runs ONLY when `.aperant/config.json` has `changelog.release_notes.enabled === true`. When the toggle is `false` (the default for projects that don't publish user-facing notes), skip the section entirely.

For each entry in the close-merged envelope's `closed[]`:

1. Read the merged PR body via `gh pr view <pr_number> --json body`. Look for a single `Release note:` line (case-insensitive). The line is what `/apt:ship` Section 2.6 auto-drafted (or a manual edit by the author).
2. Look for a persona tag in the PR body or in commits. Reasonable sources: the `Release note:` line itself if the author included one (e.g. `Release note: [Maya] …`), a `release-note-persona:` trailer in the most recent commit, or the persona name embedded in the auto-drafted line by §2.6.2 of /apt:ship.
3. Invoke the deterministic drafter:

```bash
node packages/framework/bin/apt-tools.mjs release-notes draft . \
  --pr <pr_number> \
  --note "<release-note-line-text>" \
  [--persona <persona-name>]
```

This call is synchronous, sub-100ms, and idempotent per subtask 2 — re-running close-task does not duplicate the fragment.

4. On non-zero exit from `release-notes draft`, append a `pending_release_note[]` row to `.aperant/state.json` so a manual re-run can recover. Do NOT block close-merged on this — it is additive bookkeeping, not core lifecycle. Use `apt-tools state-update . --append-pending-release-note '{"task_id": "<id>", "pr_number": <n>, "reason": "<error>"}'` if such a helper exists, otherwise call `apt-tools state-artifacts . --note '<msg>'` or write a one-line warning to stderr.

Skip the section quietly when:
- The PR body has no `Release note:` line AND no auto-draft signal (intentional infra-only PR, or the `no-notes` label was applied at ship time).
- The CLI returns `{status: 'ok', written: false, reason: 'no-change'}` — already idempotent-handled by subtask 2's contract; treat as success.

## 3. Summary

close-merged emits an envelope with:

- `closed[]` — tasks transitioned to `completed`.
- `skipped[]` — tasks that stayed in `shipped-pending-merge` with the reason (`pr_open`, `pr_closed`, `offline`, `rate_limited`, `no-pr-url`).
- `errors[]` — unexpected failures (task record missing, state.json malformed).

Report the summary verbatim. If anything landed in `skipped[]` with a recoverable reason (`offline`, `rate_limited`), suggest the user re-run once the network clears.

**Local cleanup recap (best-effort).** On confirmed merge, `task close-merged` runs `computeWorktreeCleanup` against the primary repo (`packages/framework/src/cli/task/worktree-cleanup.mjs:140-230`): `git fetch origin <base>` (failure → recorded as `fetch_failed` warning, cleanup proceeds), `git checkout <base>` (failure → returns `action: 'checkout_failed'` and preserves BOTH the worktree and the task branch — nothing is removed), `git merge --ff-only origin/<base>` (failure → `ff_only_failed` warning, GC still runs), `git worktree remove <wtPath>`, and `git branch -d <taskBranch>` (failure on squash/rebase merges → `branch_delete_failed` warning, `branch_deleted: false` in the envelope; user can `git branch -D` manually). This is why `/apt:watch-ci` auto-merge MUST NOT pass `gh pr merge --delete-branch` — that flag would try to do the same work from inside the worktree where `git switch <base>` cannot succeed (see FRAMEWORK-BUG-019).

## 4. Narrate-only mode

When invoked with `--narrate-only`, skip §1 (no `gh pr view` polling) and §2 (no destructive close path). The deterministic close already ran via the passive post-merge sweep — your job is to drain the `state.pending_narration[]` ledger the sweep parked.

Flags:
- `--narrate-only --all` (default when `--narrate-only` is bare) — drain every ledger entry.
- `--narrate-only --task <id>` — drain only the entry for `<id>`.

Per ledger entry `{ task_id, scope, phase_id, closed_at, pr_number }`:

0. **Enumerate the ledger first.** Run `apt-tools task close-merged . --narrate-only [--task <id>]`. The envelope's `closed[]` is the read-only list of pending narration rows (each carries `narration_pending: true`); state.json is not mutated. When `closed[]` is empty, exit early with `{status: 'ok', narrated: [], skipped: [], remaining: 0}` — there is nothing to drain.
1. Run `apt-tools features-audit . --apply-stubs --task <task_id>` so the feature registry catches whatever the closed task introduced. If the subprocess exits non-zero, skip the entry with `reason: 'features-audit-failed'` — never block narration on registry update.
2. When `phase_id` is non-null, spawn `apt-team-docs-narrator` (background, fire-and-forget) using the same shape as §2.5: `subagent_type: apt-team-docs-narrator`, `run_in_background: true`, `description: Narrate shipped phase {phase_id}`, `prompt: Run in phase-ship mode with --phase {phase_id} --scope {scope}.`. When `phase_id` is null, skip the spawn with `reason: 'no-phase-id'` (quick tasks have no phase to narrate).
3. On successful spawn (or successful no-phase skip), drain the row via `apt-tools task narration-drain . --task <task_id>` which acquires `withFileLock(state.json)` and removes the matching `pending_narration[]` entry. On spawn failure, leave the row in place with `reason: 'narrator-spawn-failed'` so the user can re-invoke.

**At-least-once retry semantics (FRAMEWORK-BUG-020).** Step 0 is read-only — it does NOT remove rows. Steps 1–3 are the existing per-row drain contract: a row stays in `pending_narration[]` until step 3 calls `narration-drain` successfully. If the orchestrator crashes between step 2 (narrator spawn) and step 3 (drain), the row remains parked and a retry will spawn narration again. Re-spawning is safe because the narrator is **anchor-replace idempotent** by design (see `agents/apt-team-docs-narrator.md` §6 / §2 and the "Idempotent operations only" rule in §notes) — it uses HTML-comment anchors to bound the replaced region in ROADMAP.md, so a second run replaces the same span rather than appending.

Emit a summary envelope:

```json
{
  "status": "ok",
  "command": "close-task-narrate-only",
  "narrated": [{ "task_id": "<id>", "phase_id": "<p|null>", "scope": "<s>" }],
  "skipped": [{ "task_id": "<id>", "reason": "no-phase-id|features-audit-failed|narrator-spawn-failed" }],
  "remaining": <ledger.length>
}
```

The regular `apt:close-task` flow ALSO falls through to this drain step at the tail of §2.5 — so users who run the skill manually after the sweep get narration without remembering the flag. The `--narrate-only` flag exists for the explicit "drain only, don't poll gh" case.

</process>

<notes>
- This skill is deliberately thin — the business logic lives in `apt-tools task close-merged` so it's testable and shares the same `withFileLock(state.json)` guard that `task close` uses (R-08: no races between `/apt:close-task` and a manual `task close`).
- Do NOT run `task prune` from this skill. Prune is user-opt-in retention (e.g. `--older-than 30d`) and belongs in a separate invocation.
- `shipped-pending-merge` is a waypoint, not a terminal state — tasks flow `reviewing → shipped-pending-merge → completed`. `/apt:ship` sets the first transition, this skill sets the second.
</notes>
