Finalize a session: verify it is clean and mergeable, land it on main
through exactly one merge-commit PR, fast-forward the local main ref
from origin/main, then delete the branch (and, if the session ran in a
worktree, remove the worktree). Handles both shapes a session can take
under .claude/rules/workflow.md: an isolated feature/* worktree, or a
feature/* branch checked out in the primary tree with no worktree.
It never merges locally. It pushes the session branch itself with a normal,
non-force push; the one human checkpoint is an explicit confirmation before
the PR is merged. Everything else goes through gh. The fix for the
local-vs-origin/main divergence (ISS-W3) is the never-merge-locally
discipline, independent of remote merge strategy: local main only ever
fast-forwards from origin/main after the remote merge, so it cannot
diverge from the GitHub result. The PR is merged with --merge (a merge
commit), which keeps the merged branch tip a real ancestor of
origin/main so the local branch deletes with a self-verifying
git branch -d, never a forced -D.
Project configuration
The manifest is this skill's configuration. If
$(git rev-parse --show-toplevel)/.claude/lifecycle-manifest.md does not exist, the
project has not adopted the kit: say so, offer to create it, and stop. Never infer
the keys and run anyway. The kit ships the template as
lifecycle-manifest.template.md; locate the installed copy with
find ~/.claude/plugins -path '*lifecycle-kit*' -name lifecycle-manifest.template.md | head -1.
The marketplace's ADOPTING.md carries a repo-inspecting setup prompt.
Read .claude/lifecycle-manifest.md first (resolve it via
$(git rev-parse --show-toplevel)/.claude/lifecycle-manifest.md). Where a
step below names a manifest key in code font (build_commands,
test_commands, subpkg_guard, merge_strategy, pr_base, branch_glob,
branch_pattern, prose_gate, code_reviewer, review_command, log_dir,
log_presence_regex), substitute that key's value; the manifest is the single
source of truth for them. The git commands below write the refs as <key>
placeholders (<integration_ref>, <local_main>, <remote>); replace each
with the manifest value before running. The surrounding prose still names
origin/main and main with Untype's values for readability. Incident
references ("session 85", "finding 3") are documentation of why a guard
exists, not configuration.
Arguments
- No arguments: detect the session from context (phase 1). With exactly
one finalize candidate this is unambiguous; with more than one the
skill does not guess (see phase 1 step 2).
$ARGUMENTS = worktree path or branch name — target that specific
worktree or branch.
--skip-build anywhere in $ARGUMENTS: skip the build/test preflight.
/session-end passes this because its phase 1 already gated build/test.
When invoked standalone without it, the build/test preflight runs.
--review anywhere in $ARGUMENTS: auto-dispatch the committed
code_reviewer subagent on the branch diff before the PR (phase 2 step
6). Without it, the skill only prints a recommendation to run
review_command by hand. Either way the review is advisory; it never
blocks the merge.
Preconditions
Abort with a clear message if any of the following is true. Do not attempt to fix them — leave that to the user, since each has multiple valid responses:
- No
branch_glob session can be found (no branch_glob worktree and the primary tree is not on a branch_glob branch)
- Branch does not match
branch_pattern (refuse to finalize main or other protected refs)
- Session tree has uncommitted changes (
git status --porcelain non-empty), session log included — the log must already be committed onto this branch (one-PR-per-session: the log rides this PR)
- Branch has zero commits ahead of
origin/main (nothing to merge)
- No session log for today matching the manifest's
log_pattern under log_dir (sessions/YYYY-MM-DD_session*.md for Untype) is present in the branch's commits (<integration_ref>..HEAD)
In worktree mode the primary worktree's state is not a
precondition: the skill never touches the primary working tree, it only
fast-forwards the main ref after the remote merge, so the primary
checkout may be on any branch with work in progress. In branch-only
mode the primary tree is the target, so precondition 3 (clean tree,
session log already committed) applies to it; /session-end phase 2.5
has already committed the log and stopped on any other dirt, so this
holds in the normal flow.
Process
Run all checks against the session's tree ($WT) using git -C "$WT";
in branch-only mode $WT is the primary tree. Report each step as it runs.
Phase 1 — Detect the session shape and the primary path
git worktree list --porcelain to enumerate worktrees. The first worktree line is the primary worktree — record it as $MAIN. Do not hardcode a path; the on-disk repo directory name is not the product name.
- Enumerate all finalize candidates: every worktree whose branch matches
branch_glob, plus $MAIN itself if it is on a branch_glob branch (a branch-only candidate). A primary tree on main or on a branch outside branch_glob (e.g. a parallel chore/* or docs/* session) is not a candidate and does not block — ignore it. Then:
$ARGUMENTS names a worktree path or branch: target exactly that. worktree mode if the target is a non-primary worktree, else branch-only mode.
- No
$ARGUMENTS, exactly one candidate: target it (mode by the same rule).
- No
$ARGUMENTS, more than one candidate: do not guess. Ask the user which (AskUserQuestion); if running non-interactively (e.g. invoked from /session-end), abort and print the candidate list, instructing the caller to pass an explicit target. Auto-picking can push and merge an unrelated parallel session's in-progress work (session 85).
- Zero candidates: abort (precondition 1).
- Record
$MODE (worktree or branch-only), $WT (tree path), $BRANCH (branch name).
Phase 2 — Verify clean and mergeable
- Confirm
$BRANCH matches branch_glob — abort otherwise.
git -C $WT status --porcelain — must be empty (precondition 3).
git -C $WT fetch <remote> then git -C $WT log <integration_ref>..HEAD --oneline — must be non-empty (precondition 4).
- Session log present:
git -C $WT log <integration_ref>..HEAD --name-only --format= | grep '<log_presence_regex>' must return at least one path (precondition 5). log_presence_regex encodes log_dir plus log_pattern's date form; it is the manifest's value, not a literal to hand-edit. If missing, stop and tell the user to run /session-end first.
- Build/test preflight, unless
--skip-build was passed: cd "$WT", then run each entry in <build_commands> in order, aborting on the first failure; then each entry in <test_commands> in order, same abort rule. The final test_commands entry runs only when subpkg_guard exists (a path check), so it no-ops cleanly where that sub-package is absent. Skipped when invoked from /session-end (its phase 1 owns this gate).
- Pre-PR review. Code review before the PR is advisory and never blocks the merge, so by default this skill recommends it rather than running it. Two paths:
- Default: print one line recommending the user run
review_command before the push in phase 3 (skip the line if code_reviewer is none). Nothing is dispatched.
--review in $ARGUMENTS: if code_reviewer is none the project has no reviewer, so print that and skip; otherwise dispatch the committed code_reviewer subagent (Untype: code-reviewer, at .claude/agents/code-reviewer.md) via the Agent tool, scoped to git -C "$WT"'s <integration_ref>...HEAD (three-dot diff: the branch's own changes), and print its findings inline.
The review is advisory and never blocks — do not abort on findings. The user decides whether a finding earns a fix commit before phase 3 (which is fine: more commits on an open branch ride the same PR). On a docs/config-only diff the subagent returns "no code changes" cheaply, so --review is safe to pass unconditionally.
Phase 3 — Push, PR, merge
All forge interaction below goes through the verb table of the preset named by
forge (forges/<forge>.md; defaults to github). Call the verb, never a
provider CLI directly. forge: none replaces this phase — there is no push
and no PR, and the merge gate is local; follow forges/none.md "Phase 3,
replaced" instead of steps 1–4, then rejoin at phase 4.
Push the session branch (non-force). The agent runs this itself. Use the unambiguous HEAD: refspec form — a bare branch arg has been split by terminal line-wrap and silently not pushed (session 85):
git -C "$WT" push -u <remote> HEAD:<BRANCH>
Never force-push (--force/-f are denied and are never the answer; if a normal push is rejected as non-fast-forward, stop and report). If the push command is denied in this environment (the push allow-rule has not been applied), fall back to emitting that exact line for the user to run with the ! prefix and wait. Either way, assert it actually landed before continuing: git -C "$WT" ls-remote --heads <remote> "$BRANCH" is non-empty and its SHA equals git -C "$WT" rev-parse HEAD. If not, the push did not land — retry (or re-emit the line) and wait; do not proceed to the PR.
Detect or create the PR. <forge.pr_find> "$BRANCH" if it exists; otherwise <forge.pr_create> "$BRANCH" <pr_base> "$TITLE" "$BODYFILE" (pr_base is main for Untype) with a title following the project PR-title convention (CLAUDE.md §Commits: prefix(topic): short description, never a narrative Session N: ... title) and a body that leads with a one-paragraph summary, then lists the branch's commit titles (git -C $WT log <integration_ref>..HEAD --format='%s'). If the PR already exists with a wrong title, fix it with <forge.pr_edit> (see step 3), never a provider's pr edit subcommand.
Prose-check the PR body before merging (prose rule; finalize creates the PR so it owns this scan). If prose_gate is none, the project has no prose gate; skip this step and merge without it. Otherwise the gate is the manifest's prose_gate (Untype: bin/check-prose.sh):
<forge.pr_body_get> <n> | "$(git rev-parse --show-toplevel)/<prose_gate>" --stdin --strict --label "PR #<n>"
--strict makes the scan exit non-zero on any hit (without it the scan prints findings but exits 0, leaving the re-scan loop no termination signal to test). On a non-zero exit, rewrite the corrected body to a temp file and apply it with pr_edit, which also sets the title — so use it for any title fix on an existing PR too:
<forge.pr_edit> <n> "prefix(topic): ..." <tmpfile>
Every preset implements this as a REST call taking the body from a file, which sidesteps multi-line shell quoting. On GitHub specifically it must not degrade to gh pr edit: that issues a GraphQL projectCards query which errors under the Projects-classic sunset and aborts with nothing changed (sessions 82–83). Re-scan until clean.
First check whether the PR already merged. A prior run can have merged it on the forge but aborted before phase 4 reconciled locally (finding 3), and re-running /session-end re-enters this phase. <forge.pr_state> <n>: if it is already MERGED, do not call pr_merge again (every provider errors on a merged PR) — skip straight to phase 4, which is idempotent and finishes the local reconcile. Only when the state is OPEN, pause for the merge gate: show the PR URL and a one-line summary, and ask the user to confirm the merge before proceeding. This is the single human checkpoint now that the push is automated; without an explicit confirmation, stop and leave the PR open. On confirmation, merge with the manifest's merge_strategy: <forge.pr_merge> <n> <merge_strategy> (Untype: merge). This assumes the repo allows that strategy — check once with <forge.merge_strategy_ok> <merge_strategy>; if it returns false the merge will fail, so set merge_strategy to one the repo allows (squash/rebase) and note that a squash or rebase tip is then not an ancestor of <integration_ref>, which changes the branch-delete safety in phase 4 step 4. The authoritative success signal is the forge state, not the command's exit code: confirm <forge.pr_state> <n> returns MERGED. Only a genuine remote failure is fatal — merge conflicts, required checks red, or PR not MERGED: report and stop, do not retry pr_merge (re-merging a merged PR errors) and never force.
GitHub specifically: pr_merge carries --delete-branch, whose local cleanup (checkout default, delete local branch) is best-effort and aborts before merging back when it cannot git checkout main — e.g. fatal: 'main' is already used by worktree at ... when another worktree holds main (finding 3, session 85). That abort is not a merge failure: the remote merge already landed and phase 4 owns (and re-verifies) the local reconcile. The forgejo preset deletes the branch server-side and does no local cleanup, so it cannot hit this; phase 4's remote-branch step is written to no-op either way.
Phase 4 — Reconcile local state to the merged result
Only if phase 3 confirmed the PR is MERGED on the forge (under forge: none, that its merge gate completed). This phase is
assert-then-reconcile: it never assumes what the forge tooling did or did
not do locally (gh's --delete-branch is unreliable across worktrees, finding
3). Every step checks actual state and acts only if its target is unmet;
all steps are idempotent and safe to re-run. Mode (worktree vs
branch-only) changes only step 3.
The worktree is removed before the branch it holds is deleted. That order
is load bearing, not cosmetic: git refuses to delete a branch that any worktree
has checked out, so in worktree mode the branch delete cannot succeed until the
worktree is gone. /cleanup-worktrees sequences its own removals the same way.
git -C "$MAIN" fetch <remote> --prune (refresh origin/main; prune the deleted remote ref).
- Target: local
main == origin/main. Skip if already equal. Else find the worktree $M whose checked-out branch is main per git -C "$MAIN" worktree list (often $MAIN; in branch-only mode gh may have moved a tree onto main):
$M exists: it must fast-forward. First confirm the merge changeset does not intersect $M's dirty or untracked paths (compare git -C "$M" status --porcelain against git -C "$M" diff --name-only <local_main> <integration_ref>). No intersection and the move is a true fast-forward: git -C "$M" merge --ff-only <integration_ref>. Intersection, or not a fast-forward: stop and report — a dirty file blocks it or local main diverged; never force.
- No worktree has
main checked out: git -C "$MAIN" branch -f <local_main> <integration_ref> (ref-only; touches no working tree).
- Target: worktree gone (worktree mode only). If
$WT is not $MAIN: git -C "$MAIN" worktree remove "$WT" then git -C "$MAIN" worktree prune. worktree remove fails safe if $WT still has local changes (shouldn't, by phase 2). In branch-only mode there is no worktree; skip this step. This precedes the branch delete because $WT has $BRANCH checked out.
- Target: local
$BRANCH gone. Requires step 3 to have run: while a worktree holds $BRANCH, git branch -d fails with cannot delete branch '<BRANCH>' used by worktree at ..., which is a checkout conflict rather than a containment signal and must not be read as one. If git -C "$MAIN" rev-parse --verify --quiet "refs/heads/$BRANCH" resolves: git -C "$MAIN" branch -d "$BRANCH". With --merge the merged tip is a real ancestor of origin/main, so -d (safe delete) succeeds and self-verifies. If -d refuses under a --merge merge and no worktree holds the branch, it is genuinely unmerged: stop and report; never reach for -D. (If the PR was merged with --squash or --rebase, the branch tip is not an ancestor of origin/main, so -d refuses even though the work landed. Then do not force blindly: confirm the PR is MERGED and the branch's net diff against origin/main is empty (git -C "$MAIN" diff --quiet <integration_ref> "$BRANCH"), and only then delete; that containment check replaces -d's self-verification.)
- Target: remote
$BRANCH gone. If git -C "$MAIN" ls-remote --heads <remote> "$BRANCH" is non-empty (gh's --delete-branch aborted before deleting it, finding 3): <forge.remote_branch_delete> "$BRANCH" — a REST call in every preset, never git push, never GraphQL. Under forge: none this is a no-op and the ls-remote check is skipped with it.
- Verify the end state — all must hold, report any that do not:
git -C "$MAIN" rev-parse <local_main> equals git -C "$MAIN" rev-parse <integration_ref>; git -C "$MAIN" branch --list "$BRANCH" is empty; git -C "$MAIN" ls-remote --heads <remote> "$BRANCH" is empty; in worktree mode git -C "$MAIN" worktree list no longer mentions $WT. Do not paper over a failed check.
Report format
✓ Pushed and merged PR #<n> ($BRANCH, merged into main)
<integration_ref> now <sha>; local main fast-forwarded to match
✓ Removed worktree $WT (worktree mode only)
✓ Branch $BRANCH deleted (remote + local)
One PR for the session. Nothing left unpushed.
In branch-only mode the worktree line is omitted; the branch line is the
same deleted (remote + local). Phase 4 reconciles the local tree
itself (assert-then-reconcile); it does not rely on the forge's branch-delete
side effect.
Rules
- Never finalize
main, master, or any branch outside branch_glob.
- Push the session branch with a normal, non-force push; never force-push (
--force/-f stay denied and are never the answer here). If the push is denied in this environment, fall back to handing the user the !-prefixed line. The human checkpoint is the merge gate (phase 3 step 4), not the push.
- Never merge locally — the PR is merged on the forge (the manifest's
merge_strategy, merge for Untype); local main only fast-forwards from <integration_ref>. The one exception is forge: none, where there is no remote to diverge from and the local --no-ff merge is the integration step; forges/none.md sets out what that trades away.
- Never force-delete a branch.
--merge makes the merged tip a real ancestor of origin/main, so phase 4's git branch -d "$BRANCH" is a safe self-verifying delete; if it refuses, that is a real "not merged" signal — stop, do not override with -D. (/cleanup-worktrees does use -D, and correctly: it deletes only after its own independent ahead=0 containment proof. The seam is the proof, not the flag — -D is permitted only where containment in main is already established by other means; finalize's flow has no such precomputed proof, so it must rely on -d self-verifying.)
- Never skip hooks (
--no-verify, etc.); never auto-abort a conflicted or failed merge — leave state for the user.
- The pre-PR review (phase 2 step 6) is advisory: surface findings, never abort or block the merge on them. Build/test (step 5) is the only hard code gate.
- Never run phase 4 unless the PR is confirmed
MERGED on the forge (<forge.pr_state> <n>), not merely that phase 3's command exited 0.
- Phase 4 owns the local reconcile in both modes and never assumes gh's
--delete-branch succeeded (it is unreliable across worktrees, finding 3). In worktree mode it touches no primary feature work: it only fast-forwards main (a ref move, or an ff in whichever tree has main checked out) and removes $WT. It stops rather than forcing when local main cannot fast-forward.
- In branch-only mode there is no worktree; never run
worktree remove or worktree prune.
1---2name: finalize-worktree3description: Merge a feature-branch session (worktree or branch-only) into main via one merge-commit PR, then clean up4---56Finalize a session: verify it is clean and mergeable, land it on `main`7through exactly one merge-commit PR, fast-forward the local `main` ref8from `origin/main`, then delete the branch (and, if the session ran in a9worktree, remove the worktree). Handles both shapes a session can take10under `.claude/rules/workflow.md`: an isolated `feature/*` worktree, or a11`feature/*` branch checked out in the primary tree with no worktree.1213It never merges locally. It pushes the session branch itself with a normal,14non-force push; the one human checkpoint is an explicit confirmation before15the PR is merged. Everything else goes through `gh`. The fix for the16local-vs-`origin/main` divergence (ISS-W3) is the never-merge-locally17discipline, independent of remote merge strategy: local `main` only ever18fast-forwards from `origin/main` after the remote merge, so it cannot19diverge from the GitHub result. The PR is merged with `--merge` (a merge20commit), which keeps the merged branch tip a real ancestor of21`origin/main` so the local branch deletes with a self-verifying22`git branch -d`, never a forced `-D`.2324## Project configuration2526The manifest is this skill's configuration. If27`$(git rev-parse --show-toplevel)/.claude/lifecycle-manifest.md` does not exist, the28project has not adopted the kit: say so, offer to create it, and stop. Never infer29the keys and run anyway. The kit ships the template as30`lifecycle-manifest.template.md`; locate the installed copy with31`find ~/.claude/plugins -path '*lifecycle-kit*' -name lifecycle-manifest.template.md | head -1`.32The marketplace's `ADOPTING.md` carries a repo-inspecting setup prompt.3334Read `.claude/lifecycle-manifest.md` first (resolve it via35`$(git rev-parse --show-toplevel)/.claude/lifecycle-manifest.md`). Where a36step below names a manifest key in `code font` (`build_commands`,37`test_commands`, `subpkg_guard`, `merge_strategy`, `pr_base`, `branch_glob`,38`branch_pattern`, `prose_gate`, `code_reviewer`, `review_command`, `log_dir`,39`log_presence_regex`), substitute that key's value; the manifest is the single40source of truth for them. The git commands below write the refs as `<key>`41placeholders (`<integration_ref>`, `<local_main>`, `<remote>`); replace each42with the manifest value before running. The surrounding prose still names43`origin/main` and `main` with Untype's values for readability. Incident44references ("session 85", "finding 3") are documentation of why a guard45exists, not configuration.4647## Arguments4849- No arguments: detect the session from context (phase 1). With exactly50 one finalize candidate this is unambiguous; with more than one the51 skill does not guess (see phase 1 step 2).52- `$ARGUMENTS` = worktree path or branch name — target that specific53 worktree or branch.54- `--skip-build` anywhere in `$ARGUMENTS`: skip the build/test preflight.55 `/session-end` passes this because its phase 1 already gated build/test.56 When invoked standalone without it, the build/test preflight runs.57- `--review` anywhere in `$ARGUMENTS`: auto-dispatch the committed58 `code_reviewer` subagent on the branch diff before the PR (phase 2 step59 6). Without it, the skill only prints a recommendation to run60 `review_command` by hand. Either way the review is advisory; it never61 blocks the merge.6263## Preconditions6465Abort with a clear message if any of the following is true. Do **not** attempt to fix them — leave that to the user, since each has multiple valid responses:66671. No `branch_glob` session can be found (no `branch_glob` worktree and the primary tree is not on a `branch_glob` branch)682. Branch does not match `branch_pattern` (refuse to finalize `main` or other protected refs)693. Session tree has uncommitted changes (`git status --porcelain` non-empty), session log included — the log must already be committed onto this branch (one-PR-per-session: the log rides this PR)704. Branch has zero commits ahead of `origin/main` (nothing to merge)715. No session log for today matching the manifest's `log_pattern` under `log_dir` (`sessions/YYYY-MM-DD_session*.md` for Untype) is present in the branch's commits (`<integration_ref>..HEAD`)7273In **worktree mode** the primary worktree's state is **not** a74precondition: the skill never touches the primary working tree, it only75fast-forwards the `main` ref after the remote merge, so the primary76checkout may be on any branch with work in progress. In **branch-only77mode** the primary tree *is* the target, so precondition 3 (clean tree,78session log already committed) applies to it; `/session-end` phase 2.579has already committed the log and stopped on any other dirt, so this80holds in the normal flow.8182## Process8384Run all checks against the session's tree (`$WT`) using `git -C "$WT"`;85in branch-only mode `$WT` is the primary tree. Report each step as it runs.8687### Phase 1 — Detect the session shape and the primary path88891. `git worktree list --porcelain` to enumerate worktrees. The **first** `worktree ` line is the primary worktree — record it as `$MAIN`. Do not hardcode a path; the on-disk repo directory name is not the product name.902. Enumerate **all finalize candidates**: every worktree whose branch matches `branch_glob`, plus `$MAIN` itself if it is on a `branch_glob` branch (a branch-only candidate). A primary tree on `main` or on a branch outside `branch_glob` (e.g. a parallel `chore/*` or `docs/*` session) is **not** a candidate and does not block — ignore it. Then:91 - `$ARGUMENTS` names a worktree path or branch: target exactly that. **worktree mode** if the target is a non-primary worktree, else **branch-only mode**.92 - No `$ARGUMENTS`, exactly one candidate: target it (mode by the same rule).93 - No `$ARGUMENTS`, more than one candidate: **do not guess.** Ask the user which (AskUserQuestion); if running non-interactively (e.g. invoked from `/session-end`), abort and print the candidate list, instructing the caller to pass an explicit target. Auto-picking can push and merge an unrelated parallel session's in-progress work (session 85).94 - Zero candidates: abort (precondition 1).953. Record `$MODE` (`worktree` or `branch-only`), `$WT` (tree path), `$BRANCH` (branch name).9697### Phase 2 — Verify clean and mergeable98991. Confirm `$BRANCH` matches `branch_glob` — abort otherwise.1002. `git -C $WT status --porcelain` — must be empty (precondition 3).1013. `git -C $WT fetch <remote>` then `git -C $WT log <integration_ref>..HEAD --oneline` — must be non-empty (precondition 4).1024. Session log present: `git -C $WT log <integration_ref>..HEAD --name-only --format= | grep '<log_presence_regex>'` must return at least one path (precondition 5). `log_presence_regex` encodes `log_dir` plus `log_pattern`'s date form; it is the manifest's value, not a literal to hand-edit. If missing, stop and tell the user to run `/session-end` first.1035. Build/test preflight, unless `--skip-build` was passed: `cd "$WT"`, then run each entry in `<build_commands>` in order, aborting on the first failure; then each entry in `<test_commands>` in order, same abort rule. The final `test_commands` entry runs only when `subpkg_guard` exists (a path check), so it no-ops cleanly where that sub-package is absent. Skipped when invoked from `/session-end` (its phase 1 owns this gate).1046. **Pre-PR review.** Code review before the PR is advisory and never blocks the merge, so by default this skill recommends it rather than running it. Two paths:105 - **Default**: print one line recommending the user run `review_command` before the push in phase 3 (skip the line if `code_reviewer` is `none`). Nothing is dispatched.106 - **`--review` in `$ARGUMENTS`**: if `code_reviewer` is `none` the project has no reviewer, so print that and skip; otherwise dispatch the committed `code_reviewer` subagent (Untype: `code-reviewer`, at `.claude/agents/code-reviewer.md`) via the Agent tool, scoped to `git -C "$WT"`'s `<integration_ref>...HEAD` (three-dot diff: the branch's own changes), and print its findings inline.107 The review is **advisory and never blocks** — do not abort on findings. The user decides whether a finding earns a fix commit before phase 3 (which is fine: more commits on an open branch ride the same PR). On a docs/config-only diff the subagent returns "no code changes" cheaply, so `--review` is safe to pass unconditionally.108109### Phase 3 — Push, PR, merge110111All forge interaction below goes through the verb table of the preset named by112`forge` (`forges/<forge>.md`; defaults to `github`). Call the verb, never a113provider CLI directly. **`forge: none` replaces this phase** — there is no push114and no PR, and the merge gate is local; follow `forges/none.md` "Phase 3,115replaced" instead of steps 1–4, then rejoin at phase 4.1161171. Push the session branch (non-force). The agent runs this itself. Use the unambiguous `HEAD:` refspec form — a bare branch arg has been split by terminal line-wrap and silently not pushed (session 85):118 ```119 git -C "$WT" push -u <remote> HEAD:<BRANCH>120 ```121 Never force-push (`--force`/`-f` are denied and are never the answer; if a normal push is rejected as non-fast-forward, stop and report). If the push command is denied in this environment (the push allow-rule has not been applied), fall back to emitting that exact line for the user to run with the `!` prefix and wait. Either way, **assert it actually landed** before continuing: `git -C "$WT" ls-remote --heads <remote> "$BRANCH"` is non-empty and its SHA equals `git -C "$WT" rev-parse HEAD`. If not, the push did not land — retry (or re-emit the line) and wait; do not proceed to the PR.1222. Detect or create the PR. `<forge.pr_find> "$BRANCH"` if it exists; otherwise `<forge.pr_create> "$BRANCH" <pr_base> "$TITLE" "$BODYFILE"` (`pr_base` is `main` for Untype) with a title following the project PR-title convention (`CLAUDE.md` §Commits: `prefix(topic): short description`, never a narrative `Session N: ...` title) and a body that leads with a one-paragraph summary, then lists the branch's commit titles (`git -C $WT log <integration_ref>..HEAD --format='%s'`). If the PR already exists with a wrong title, fix it with `<forge.pr_edit>` (see step 3), never a provider's `pr edit` subcommand.1233. Prose-check the PR body before merging (prose rule; finalize creates the PR so it owns this scan). If `prose_gate` is `none`, the project has no prose gate; skip this step and merge without it. Otherwise the gate is the manifest's `prose_gate` (Untype: `bin/check-prose.sh`):124 ```bash125 <forge.pr_body_get> <n> | "$(git rev-parse --show-toplevel)/<prose_gate>" --stdin --strict --label "PR #<n>"126 ```127 `--strict` makes the scan exit non-zero on any hit (without it the scan prints findings but exits 0, leaving the re-scan loop no termination signal to test). On a non-zero exit, rewrite the corrected body to a temp file and apply it with `pr_edit`, which also sets the title — so use it for any title fix on an existing PR too:128 ```bash129 <forge.pr_edit> <n> "prefix(topic): ..." <tmpfile>130 ```131 Every preset implements this as a REST call taking the body from a file, which sidesteps multi-line shell quoting. On GitHub specifically it must not degrade to `gh pr edit`: that issues a GraphQL `projectCards` query which errors under the Projects-classic sunset and aborts with nothing changed (sessions 82–83). Re-scan until clean.1324. **First check whether the PR already merged.** A prior run can have merged it on the forge but aborted before phase 4 reconciled locally (finding 3), and re-running `/session-end` re-enters this phase. `<forge.pr_state> <n>`: if it is already `MERGED`, do **not** call `pr_merge` again (every provider errors on a merged PR) — skip straight to phase 4, which is idempotent and finishes the local reconcile. Only when the state is `OPEN`, **pause for the merge gate**: show the PR URL and a one-line summary, and ask the user to confirm the merge before proceeding. This is the single human checkpoint now that the push is automated; without an explicit confirmation, stop and leave the PR open. On confirmation, merge with the manifest's `merge_strategy`: `<forge.pr_merge> <n> <merge_strategy>` (Untype: `merge`). This assumes the repo allows that strategy — check once with `<forge.merge_strategy_ok> <merge_strategy>`; if it returns `false` the merge will fail, so set `merge_strategy` to one the repo allows (`squash`/`rebase`) and note that a squash or rebase tip is then not an ancestor of `<integration_ref>`, which changes the branch-delete safety in phase 4 step 4. The authoritative success signal is the **forge** state, not the command's exit code: confirm `<forge.pr_state> <n>` returns `MERGED`. Only a genuine **remote** failure is fatal — merge conflicts, required checks red, or PR not `MERGED`: report and stop, do not retry `pr_merge` (re-merging a merged PR errors) and never force.133134 *GitHub specifically:* `pr_merge` carries `--delete-branch`, whose local cleanup (checkout default, delete local branch) is best-effort and **aborts before merging back when it cannot `git checkout main`** — e.g. `fatal: 'main' is already used by worktree at ...` when another worktree holds `main` (finding 3, session 85). That abort is **not** a merge failure: the remote merge already landed and phase 4 owns (and re-verifies) the local reconcile. The `forgejo` preset deletes the branch server-side and does no local cleanup, so it cannot hit this; phase 4's remote-branch step is written to no-op either way.135136### Phase 4 — Reconcile local state to the merged result137138Only if phase 3 confirmed the PR is `MERGED` on the forge (under `forge:139none`, that its merge gate completed). This phase is140**assert-then-reconcile**: it never assumes what the forge tooling did or did141not do locally (gh's `--delete-branch` is unreliable across worktrees, finding1423). Every step checks actual state and acts only if its target is unmet;143all steps are idempotent and safe to re-run. Mode (`worktree` vs144`branch-only`) changes only step 3.145146The worktree is removed **before** the branch it holds is deleted. That order147is load bearing, not cosmetic: git refuses to delete a branch that any worktree148has checked out, so in worktree mode the branch delete cannot succeed until the149worktree is gone. `/cleanup-worktrees` sequences its own removals the same way.1501511. `git -C "$MAIN" fetch <remote> --prune` (refresh `origin/main`; prune the deleted remote ref).1522. **Target: local `main` == `origin/main`.** Skip if already equal. Else find the worktree `$M` whose checked-out branch is `main` per `git -C "$MAIN" worktree list` (often `$MAIN`; in branch-only mode gh may have moved a tree onto `main`):153 - `$M` exists: it must fast-forward. First confirm the merge changeset does not intersect `$M`'s dirty or untracked paths (compare `git -C "$M" status --porcelain` against `git -C "$M" diff --name-only <local_main> <integration_ref>`). No intersection and the move is a true fast-forward: `git -C "$M" merge --ff-only <integration_ref>`. Intersection, or not a fast-forward: **stop and report** — a dirty file blocks it or local `main` diverged; never force.154 - No worktree has `main` checked out: `git -C "$MAIN" branch -f <local_main> <integration_ref>` (ref-only; touches no working tree).1553. **Target: worktree gone (worktree mode only).** If `$WT` is not `$MAIN`: `git -C "$MAIN" worktree remove "$WT"` then `git -C "$MAIN" worktree prune`. `worktree remove` fails safe if `$WT` still has local changes (shouldn't, by phase 2). In branch-only mode there is no worktree; skip this step. This precedes the branch delete because `$WT` has `$BRANCH` checked out.1564. **Target: local `$BRANCH` gone.** Requires step 3 to have run: while a worktree holds `$BRANCH`, `git branch -d` fails with `cannot delete branch '<BRANCH>' used by worktree at ...`, which is a checkout conflict rather than a containment signal and must not be read as one. If `git -C "$MAIN" rev-parse --verify --quiet "refs/heads/$BRANCH"` resolves: `git -C "$MAIN" branch -d "$BRANCH"`. With `--merge` the merged tip is a real ancestor of `origin/main`, so `-d` (safe delete) succeeds and **self-verifies**. If `-d` refuses under a `--merge` merge *and* no worktree holds the branch, it is genuinely unmerged: **stop and report; never reach for `-D`**. (If the PR was merged with `--squash` or `--rebase`, the branch tip is not an ancestor of `origin/main`, so `-d` refuses even though the work landed. Then do not force blindly: confirm the PR is `MERGED` and the branch's net diff against `origin/main` is empty (`git -C "$MAIN" diff --quiet <integration_ref> "$BRANCH"`), and only then delete; that containment check replaces `-d`'s self-verification.)1575. **Target: remote `$BRANCH` gone.** If `git -C "$MAIN" ls-remote --heads <remote> "$BRANCH"` is non-empty (gh's `--delete-branch` aborted before deleting it, finding 3): `<forge.remote_branch_delete> "$BRANCH"` — a REST call in every preset, never `git push`, never GraphQL. Under `forge: none` this is a no-op and the `ls-remote` check is skipped with it.1586. **Verify the end state — all must hold, report any that do not:** `git -C "$MAIN" rev-parse <local_main>` equals `git -C "$MAIN" rev-parse <integration_ref>`; `git -C "$MAIN" branch --list "$BRANCH"` is empty; `git -C "$MAIN" ls-remote --heads <remote> "$BRANCH"` is empty; in worktree mode `git -C "$MAIN" worktree list` no longer mentions `$WT`. Do not paper over a failed check.159160## Report format161162```163✓ Pushed and merged PR #<n> ($BRANCH, merged into main)164 <integration_ref> now <sha>; local main fast-forwarded to match165✓ Removed worktree $WT (worktree mode only)166✓ Branch $BRANCH deleted (remote + local)167168One PR for the session. Nothing left unpushed.169```170171In branch-only mode the worktree line is omitted; the branch line is the172same `deleted (remote + local)`. Phase 4 reconciles the local tree173itself (assert-then-reconcile); it does not rely on the forge's branch-delete174side effect.175176## Rules177178- Never finalize `main`, `master`, or any branch outside `branch_glob`.179- Push the session branch with a normal, non-force push; never force-push (`--force`/`-f` stay denied and are never the answer here). If the push is denied in this environment, fall back to handing the user the `!`-prefixed line. The human checkpoint is the merge gate (phase 3 step 4), not the push.180- Never merge locally — the PR is merged on the forge (the manifest's `merge_strategy`, `merge` for Untype); local `main` only fast-forwards from `<integration_ref>`. The one exception is `forge: none`, where there is no remote to diverge from and the local `--no-ff` merge *is* the integration step; `forges/none.md` sets out what that trades away.181- Never force-delete a branch. `--merge` makes the merged tip a real ancestor of `origin/main`, so phase 4's `git branch -d "$BRANCH"` is a safe self-verifying delete; if it refuses, that is a real "not merged" signal — stop, do not override with `-D`. (`/cleanup-worktrees` does use `-D`, and correctly: it deletes only after its own independent `ahead=0` containment proof. The seam is the proof, not the flag — `-D` is permitted only where containment in `main` is already established by other means; finalize's flow has no such precomputed proof, so it must rely on `-d` self-verifying.)182- Never skip hooks (`--no-verify`, etc.); never auto-abort a conflicted or failed merge — leave state for the user.183- The pre-PR review (phase 2 step 6) is advisory: surface findings, never abort or block the merge on them. Build/test (step 5) is the only hard code gate.184- Never run phase 4 unless the PR is confirmed `MERGED` on the forge (`<forge.pr_state> <n>`), not merely that phase 3's command exited 0.185- Phase 4 owns the local reconcile in both modes and never assumes gh's `--delete-branch` succeeded (it is unreliable across worktrees, finding 3). In worktree mode it touches no primary feature work: it only fast-forwards `main` (a ref move, or an ff in whichever tree has `main` checked out) and removes `$WT`. It stops rather than forcing when local `main` cannot fast-forward.186- In branch-only mode there is no worktree; never run `worktree remove` or `worktree prune`.