Merging to Main
Overview
Merge a work or feature branch to main via PR with CI gating. merge-assemble computes the node
ceremony hard-gate, tag-prefix resolution, release-tag cut, coverage gate, PR body, portability
sweep, illegal-path scan, completion-log flip, and orphan-branch sweep. What follows is what it
cannot precompute: your judgment calls, plus the steps depending on live PR/merge state (branch
recovery, PR creation, the merge, local cleanup).
Announce at start: "I'm using the coordinator:merging-to-main skill to merge this branch to main."
On a PowerShell host, invoke the .exe launcher by absolute path through the call operator
(Shape W) — ladder and shapes: snippets/resolve-coordinator-bin.md.
Compute and apply are one verb — brief was removed (K-114): merge-assemble apply [--session-id <id>] [--force] [--decisions-file <path>], resolved per that ladder. Resolve every judgment_points[] entry it returns, via --decisions-file, before its gated directive(s) proceed. --force bypasses only the node ceremony hard-gate (d0).
First Officer Doctrine: EM may refuse to merge and alert the PM on a branch with known issues.
Step 1: Test Suite Gate
Pre-authorized Tier-U ceremony — grant first: tier-u-grant-cli grant ceremony "merging-to-main implicit Tier-U grant for the pre-merge unscoped project test suite" --ceremony merging-to-main.
d0 (node --test tests/plugin-ecosystem/run.js, halt-on-fail) runs first, then detect and run
the project's own test runner (pnpm test/npm test, pytest/python -m pytest, /validate, or
project-specific from CLAUDE.md/package.json). This is the most expensive step in the whole
ceremony — the project's full suite is a machine-wide event, not a cheap check; its actual
magnitude is whatever python coordinator/tests/_spawn_budget.py reports for this repo, never a
hardcoded figure here, but treat "run the suite" as heavy every time you grant it. Fail on either →
halt: "Test suite failed. Fix first, or use /merging-to-main --force to bypass for hotfixes."
--force (skill flag, distinct from apply's): skips this step including the Tier-U grant and
d0. Log: "Force-merge requested — test suite gate bypassed."
Step 2: Pre-flight
Commit only paths this session touched, on the commit invocation itself (detail: wiki).
On a work/feature branch → continue. On main with unpushed commits ahead of origin/main →
auto-recover via merge-recovery-and-tag-cut recovery-branch (syncs local main, cuts a fresh
work/<host>/<date> branch off the pre-sync state, pushes, hard-resets main, returns to the new
branch, prints BRANCH=<name>), then continue there. On main with nothing unpushed → abort:
"Already on main with nothing to merge. Switch to a work or feature branch first."
Resolve the branch via coordinator-current-branch, compare against its remote (git log origin/<branch>..HEAD), push with --set-upstream if unpushed commits exist.
Step 3: Release Surface (your call)
d6/d3/d5/d1/d2 are directives (illegal-path scan, coverage gate, portability sweep,
tag-prefix resolution, release-tag cut). Judgment:
Ship verdict (ship_verdict). EM stages one line, PM confirms or overrides; don't merge on
hold/split without PM redirect. /staff-session vp-product gives a structured second opinion.
**Ship verdict:** [ship | ship-behind-flag | hold | split | spike-only] — [one-sentence rationale]
| Verdict | Meaning |
|---|---|
| ship | AC satisfied/waived, evidence supports merge |
| ship-behind-flag | Ready but gated — name the flag |
| hold | Don't merge — name the concern |
| split | Two changes land separately — name them |
| spike-only | Informative only, don't merge |
Release-note framing. Prefer the most recent state/week-changelog/*-pending-release.md
accumulator; absent, draft inline grouped by impact (Added/Changed/Fixed/Deps/Internal, omit
empty — even a trivial merge gets one line; template: wiki). Prepend to a repo-root CHANGELOG.md
if one exists, committed before Step 5. Skip only for tasks//tmp/-only merges (still get an
"Internal" line).
Demo path (user-visible merges) — append a Demo Path section (template: wiki) to the PR body.
Version-bump (version_bump_final). version_bump is a proposal only — confirm/override
before d2 fires (mode detail: wiki).
Portability (portability_disposition). Empty sweep report continues silently; non-empty needs
a per-finding PM disposition (options: wiki). Not a merge blocker;
COORDINATOR_OVERRIDE_PORTABILITY=1 skips it for a one-off.
Step 4: UE-specific checks (project_type: game-dev, project_subtypes: unreal)
Otherwise skip. Full table: wiki. UBT gate and reverse-drift gate have live producers
(scan_unresolved_ubt_records.py, list_reverse_drift_cmds.py) — non-zero halts with remediation
(/workday-complete / example_game_repo_recover --step reverse-drift, or the matching
COORDINATOR_OVERRIDE_*). Plugin-version-matrix, structural-index-schema, and
customer-facing-install-path touches still need eyeball diff-path classification — no producer yet
- Plugin version matrix — detection: touches under
control/plugin/**. Action: verify the 5-version CI matrix is green. - Customer-facing install path — detection: touches under
scripts/install-*.{sh,ps1}. Action: verify doc parity and replaytests/install/.
A schema bump needs schema-migration-auditor dispatched, the Staff Engineer review before merge.
Step 5: Create PR
Compose the PR body via merge-gate-and-pr pr-body --ship-verdict "$SHIP_VERDICT" --release-notes "$RELEASE_NOTES" --commit-range main..HEAD (d4), then gh pr create --base main --head "$BRANCH" --title "$TITLE" --body "$BODY".
If a version bump was suggested but not yet PM-confirmed, surface it in the PR body: "Suggested bump: patch ({old} → {new}) — confirm before tagging."
Step 6: Wait for CI
gh pr checks --watch. ci_failure_interpretation: "no checks reported" (exit 1) is a pass; a real
failure blocks merge — "CI failed on {check}. Fix and re-run /merging-to-main, or investigate via
coordinator:systematic-debugging." A flaky-retry re-runs CI instead of blocking.
Step 7: Merge
Pre-merge quiet check: merge-gate-and-pr active-branch-guard --pr "$PR" halts if the PR's newest
commit is younger than 300 seconds. Override with the skill's own --force-merge-active-branch.
Merge via gh pr merge with --delete-branch, merge commit (never squash). Recovery recipes for
"base branch policy prohibits" and "head not up to date": wiki. Merge conflicts — do not force
through; offer the PM merge-main-in-and-resolve (recommended) or rebase; stop and wait.
CI is advisory; the PR requirement (0 approvals) is the primary gate.
IF d2 CUT A RELEASE TAG, VERIFY IT CONTAINS THE RELEASE — HERE, BEFORE ANYTHING ELSE:
git fetch origin --tags && git rev-list --count <tag>..origin/main
Non-zero means the tag does not contain the release and the publish is wrong. Retarget it
against the merge commit gh pr merge produced and force-push with a pinned lease; do not proceed
to Step 8 until it reads 0.
This check exists because d2 fires in Step 3 while the merge lands here, and the tag-cut
core (merge-recovery-and-tag-cut.py, engine plane) resolves origin/main at call time and names
the result merge_sha — the variable name encodes the assumption that it runs after the merge.
Called from Step 3 it tags main without the branch, so the release tag contains none of the
release. Confirmed in project-rag: v0.17.2 shipped pointing at zero of its 82
commits.
Nothing else catches it. Every directive returns 0, the ceremony report says
release_tag_cut: <tag>, and d2's own MERGE_SHA=... / TAG_CUT=... stdout is the evidence of
the bug rendered as a success line. cut_tag's only guard is an idempotence check comparing the
tag against the same wrong ref, so it confirms itself. Step 10's completion-log flip then reads
that tag and is quietly wrong the same way.
This is the interim guard, not the fix — the ordering is what is wrong, and moving d2 after the
merge is tracked separately. Run the check every time until d2 moves.
Step 8: Post-Merge Re-Verify Shared Infra
After a conflict-resolved or concurrently-edited merge, confirm each touched file still carries a
canonical phrase from your change at HEAD — last-writer-wins can silently revert a naively
resolved hunk. Highest risk: shared infra. Missing phrase → re-apply and push a follow-up commit.
Step 9: Local Cleanup
Check out main (COORDINATOR_OVERRIDE_BRANCH=1), pull, delete the local branch. Any stray worktree
found here is debris to clear (git worktree remove <path>), not state to keep.
Step 10: Completion-Log Status Flip
Runs when a release tag was cut (d2 landed); skip otherwise. mkdir -p archive/release-notes/, then d7 (merge-release-notes-derive flip-tags <tag> <sha> <date> $ENTRY_PATHS) flips every matching entry to the earliest release tag whose history contains it. The
reconcile-sweep verb that once preceded the flip is retired with the rest of the completion-
reconcile family; the CLI carries flip-tags alone, so there is no unaccounted-commit pass here.
Best-effort git mv the pending-release accumulator to archive/release-notes/, scoped-commit
$ENTRY_PATHS + accumulator + release notes file, push to main.
Step 11: Report
## Merged to Main
- **PR:** {url}
- **Merge commit:** {sha}
- **Branch deleted:** {branch} (local + remote)
- **Now on:** main @ {sha}
d8 (orphan-branch-sweep --format text --severity-min warning, non-OK lines) surfaces other
in-flight branches: "Multiple work branches in flight — verify these don't carry work intended for
this PR."
Negative-spec — the auto-memory drain gate is gone from this ceremony, do not restore it. PM
directive, 2026-08-07: the gate's cadence at every merge was too aggressive. It stays live at
/workday-complete and /workweek-complete only.
Red Flags
Never: squash commits; push directly to main. Concurrent-writer caveat: cap commit sweeps at ~6 and accept a moving target — don't loop trying to converge.
Integration
Called by: coordinator:finishing-a-development-branch (Option 1); PM/EM directly, never
/workday-complete. Pairs with: finishing-a-development-branch (its Step 5 handles stray
worktree removal the same way).