plugins/coordinator/skills/merging-to-main/SKILL.md
Use when a branch is ready to merge to main. Drafts release notes, creates PR, waits for CI, merges, cleans up.
npx skillsauth add oduffy-delphi/coordinator-claude merging-to-mainInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Merge a work or feature branch to main via PR with CI gating.
Announce at start: "I'm using the coordinator:merging-to-main skill to merge this branch to main."
Acceptance oracle is NOT re-gated here. The authoritative gate lives at
coordinator:workstream-completeStep 3.8 — one workstream = one plan = one AC table in frame. By the time a workstream's commits reach/merge-to-main, its oracle verdict has already been stamped (or refused) upstream./merge-to-mainoperates per-branch, and branches routinely aggregate multiple workstreams (daily-rollup shape) plus doctrine edits, archive sweeps, and memo actions — no single AC table governs the union, so a per-branch oracle re-run is either ceremony evasion (skip-with-offer noise) or a false-positive on multi-plan branches. Trust the upstream marker; daily-rollups merge cleanly.Belt-and-braces re-run is available but never load-bearing:
bash check-acceptance-oracle.sh <plan-path>on demand. Override env varCOORDINATOR_OVERRIDE_ACCEPTANCE_GATE=1is retired at this surface — there is no gate to override.
Run the coordinator hook test suite first:
node --test ~/.claude/plugins/coordinator/tests/plugin-ecosystem/run.js
If this fails, halt and report which tests failed. The hook suite covers load-bearing infra (coordinator-safe-commit, verify-preamble-sync, coordinator-auto-push, session-init) and must pass before any merge.
Detect project test runner: pnpm test / npm test (Node.js), pytest / python -m pytest (Python), /validate (CI), or project-specific from CLAUDE.md/package.json.
Run the project test suite. Pass → proceed to Step 1. Fail → halt: "Test suite failed. Fix first, or use /merge-to-main --force to bypass for hotfixes." Do NOT proceed to PR creation.
--force escape hatch: Skips Step 0 entirely. Log: "Force-merge requested — test suite gate bypassed." Proceed to Step 1. (Acceptance-oracle is not gated at this surface — see preamble above.)
First Officer Doctrine: EM may refuse to merge and alert the PM if the branch has known issues.
Check for uncommitted changes. Commit only paths this session touched — do NOT use coordinator-safe-commit here (SC-DR-008):
git add -- <path1> <path2> ... && git commit -m "pre-merge quick-save" -- <path1> <path2> ...
Handle current branch:
If on a work/feature branch: proceed to step 3.
If on main with unpushed commits ahead of origin/main: Auto-recover: commits go through a PR, not a direct push.
~/.claude/plugins/coordinator/bin/sync-main.sh || {
echo "sync-main.sh failed — local main has diverged. Investigate before creating a recovery branch."
exit 1
}
BRANCH="work/$(hostname | tr '[:upper:]' '[:lower:]')/$(date +%Y-%m-%d)"
# Review: patrik F1 — inline override required; block-off-daily-branch.sh hook
# would deny git checkout -b and git checkout here without it.
COORDINATOR_OVERRIDE_BRANCH=1 COORDINATOR_OVERRIDE_BRANCH_REASON="merging-to-main step 1 create recovery branch" \
git checkout -b "$BRANCH"
git push origin "$BRANCH" --set-upstream
COORDINATOR_OVERRIDE_BRANCH=1 COORDINATOR_OVERRIDE_BRANCH_REASON="merging-to-main step 1 checkout main for reset" \
git checkout main && git reset --hard origin/main
COORDINATOR_OVERRIDE_BRANCH=1 COORDINATOR_OVERRIDE_BRANCH_REASON="merging-to-main step 1 return to work branch" \
git checkout "$BRANCH"
Then proceed to step 3 on the new branch.
If on main with no unpushed commits: abort: "Already on main with nothing to merge. Switch to a work or feature branch first."
Verify remote is up-to-date:
_BR=$(~/.claude/plugins/coordinator/bin/coordinator-current-branch)
git log origin/"$_BR"..HEAD 2>/dev/null
If unpushed commits exist, push explicitly:
git push origin "$_BR" --set-upstream
Every merge to main produces a PR body composed of three parts: ship verdict, release notes, and demo path (user-visible work only). The VP-of-Product lens is the PM's lens — applied in meatspace; request /staff-session with vp-product for a structured second opinion.
Part 1 — Ship Verdict (every merge)
EM stages one line; PM confirms or overrides. Don't merge on hold/split without PM redirect. Always present for audit history.
**Ship verdict:** [ship | ship-behind-flag | hold | split | spike-only] — [one-sentence rationale]
| Verdict | Meaning | |---------|---------| | ship | AC satisfied/waived; evidence supports merge; no blocking concerns | | ship-behind-flag | Code ready but rollout should be gated. Name the flag | | hold | Don't merge — specific concern remains. Name it | | split | Two changes that should land separately. Name them | | spike-only | Informative only — don't merge to main |
Part 2 — Release Notes (every merge)
PENDING_RELEASE=$(ls state/week-changelog/*-pending-release.md 2>/dev/null | sort | tail -1)
$PENDING_RELEASE set (normal path): Use as primary; skip steps 1–5, go to step 6. Set PENDING_RELEASE_FILE="$PENDING_RELEASE" (retain for Step 5.5).PENDING_RELEASE_FILE="".Inventory the merge:
COMMITS=$(git log main..HEAD --oneline) && CHANGED_FILES=$(git diff --name-only main..HEAD)
COMMIT_COUNT=$(git rev-list --count main..HEAD) && STATS=$(git diff --shortstat main..HEAD)
Group by impact (don't mirror commit-by-commit): Added, Changed, Fixed, Deps, Internal (omit if trivial). Single-commit dep-bump merges still get a one-line note — don't skip "trivial" merges.
Detect repo-root CHANGELOG.md:
if [ -f CHANGELOG.md ]; then HAS_CHANGELOG=1; else HAS_CHANGELOG=0; fi
Present → always update it. Absent → do NOT auto-create; embed notes in PR body only.
Determine version bump suggestion (advisory — surfaced for PM, never auto-applied):
version_source: tag in coordinator.local.md: do NOT read the manifest (may be frozen sentinel). Surface version per docs/wiki/versioning-convention.md — PM to confirm number; skip the rest.version_source: manifest, the default): read ecosystem manifest (package.json / pyproject.toml / Cargo.toml). Suggest: patch (fixes/deps/internal), minor (new compat features), major (breaking). When unsure, suggest lower.Tagged-publish leg (drives off bump suggestion — no separate triviality gate):
Evaluate both before proposing:
>= patch (not a skip).tasks/, tmp/, or other internal-only paths.If BOTH hold, check coordinator.local.md for tag_anchor to select publish mode:
Mode A — tag_anchor: git-tag (repo declares git-tag-only mode):
If coordinator.local.md frontmatter tag_anchor: git-tag, this is a git-tag-only disclosure
repo — propose ONLY the annotated tag, no GitHub Release step. Two OPTIONAL companion fields
(defaults reproduce bare-v*, manifest-driven behavior; repos without them are unchanged — see DR-149):
tag_prefix: (default empty) — namespace prefix. Empty → vX.Y.Z; holodeck- → holodeck-vX.Y.Z.
The cut here and the consumer's currency check MUST resolve the same ${tag_prefix}v* pattern.
Coupling warning: tag_prefix and the consumer's currency-check resolver drift silently — update in lockstep.version_source: (default manifest) — manifest → read ecosystem manifest for version SSOT;
tag → latest ${tag_prefix}v* tag is the SSOT; manifest is NOT read (may be a frozen sentinel,
e.g. holodeck's pyproject at 0.0.0). Number choice per consumer's versioning-convention.md (PM-confirmed).Propose:
Suggested bump: <prefix>vX.Y.Z (patch|minor|major — rationale, or "per versioning-convention.md").
Tagged publish: propose cutting the annotated git tag <prefix>vX.Y.Z on origin/main.
Confirm version and tag, or adjust.
PM confirms inline (release surface — never a silent EM auto-tag). Mode A only when tag_anchor: git-tag is explicitly set; repos without it use Mode B. Before adding, confirm GitHub Releases are not in use for the version line this tag governs.
On confirmation, cut and push the annotated tag:
# Replace the version with the confirmed number — do not run this block literally.
# tag_anchor=git-tag mode: annotated ${TAG_PREFIX}v* tag is the sole disclosure anchor.
# → project-rag/docs/plans/2026-06-01-version-disclosure-and-boot-currency-hook.md § C4; DR-149.
# Fail-loud on quoted tag_prefix (detect-then-fail-loud, not detect-then-silently-pick).
TAG_PREFIX="$(awk -F':[ \t]*' '
/^---[ \t]*$/ { f++; next }
f==1 && /^tag_prefix:/ { v=$2; sub(/[ \t]+#.*$/, "", v); print v; exit }
f>=2 { exit }
' coordinator.local.md)"
case "$TAG_PREFIX" in
*[\"\']*) echo "FATAL: tag_prefix in coordinator.local.md must be unquoted (got: $TAG_PREFIX)" >&2; exit 1 ;;
esac
TAG="${TAG_PREFIX}vX.Y.Z" # e.g. v0.9.0 OR holodeck-v0.4.0
git fetch origin main
MERGE_SHA="$(git rev-parse origin/main)"
# Idempotent on retry: only (re)cut+push the tag if it does not already point at MERGE_SHA.
existing="$(git rev-parse "$TAG" 2>/dev/null || true)"
if [ "$existing" != "$MERGE_SHA" ]; then
git tag -a "$TAG" "$MERGE_SHA" -m "$TAG"
git push origin "$TAG"
fi
No gh release command is run. git tag -a is hardcoded (annotated-never-lightweight). Tag-cut is idempotent.
Mode B — default (no tag_anchor field, or tag_anchor is not git-tag):
Propose a tagged version bump + GitHub-release publish alongside the bump suggestion:
Suggested bump: vX.Y.Z (patch|minor|major — rationale).
Tagged publish: propose cutting the vX.Y.Z git tag on coordinator-claude and un-drafting
the corresponding GitHub release. Confirm version and publish, or adjust.
PM confirms inline. On confirmation, cut and push the v* git tag explicitly — do NOT rely on un-drafting a release to create the tag as a side-effect:
# The boot currency check anchors on the latest v* GIT TAG via git ls-remote --tags, NOT
# the Release object. Tag MUST be pushed first — un-drafted release without tag push leaves
# consumers on stale anchor. → docs/wiki/release-cadence-and-currency-notification.md
git fetch origin main
MERGE_SHA="$(git rev-parse origin/main)"
# Idempotent on retry: only (re)cut+push the tag if it does not already point at MERGE_SHA.
existing="$(git rev-parse "vX.Y.Z" 2>/dev/null || true)"
if [ "$existing" != "$MERGE_SHA" ]; then
git tag -a "vX.Y.Z" "$MERGE_SHA" -m "vX.Y.Z"
git push origin "vX.Y.Z"
fi
# Then publish the human-facing release (un-draft existing, or create if absent):
gh release edit vX.Y.Z --repo dbc-oduffy/coordinator-claude --draft=false --latest \
|| gh release create vX.Y.Z --repo dbc-oduffy/coordinator-claude --latest --notes-file <release-notes>
git push origin vX.Y.Z is load-bearing for currency; gh release is human-facing discoverability. Both run; release-edit failure must not leave the tag un-pushed. Tag-cut is idempotent.
Publish target: OSS publish repo(s) touched by this workstream (e.g. dbc-oduffy/coordinator-claude). Release notes from Step 1.5 Part 2 are the release body.
Claude Prime (source_is_live) is NEVER tagged. The meta-repo at ~/.claude has propagation_mode = "source_is_live" — skip this leg silently when active repo is the meta-repo.
If either condition does NOT hold (bump is skip-eligible or merge is internal-only), skip the tagged-publish proposal silently.
Draft the entry:
## v{suggested-version} — {YYYY-MM-DD}
### Added / Changed / Fixed / Deps / Internal
- {one-line bullet per logical change; omit empty sections}
For trivial single-commit merges, collapse to a single bullet — don't pad sections that don't apply.
If HAS_CHANGELOG=1: prepend entry to CHANGELOG.md (above prior entries, below header). Commit on branch:
git add -- CHANGELOG.md && git commit -m "docs(changelog): release notes for upcoming merge" -- CHANGELOG.md
git push origin "$BRANCH"
Stash entry text for use as PR body in Step 2.
Skip rule (rare): Skip only when the merge ONLY touches tasks/, tmp/, or other internal-only paths. Log: "Release notes skipped — merge touches only internal-tracking paths." Even then, prefer a one-line "Internal" entry over a skip.
Part 3 — Demo Path (user-visible merges only)
Append to PR body (omit for internal merges):
### Demo Path
**Setup:** [commands, seed data, environment]
**Steps:** 1. [action] 2. [action] 3. [observe result]
**Expected:** [what should happen] | **Known limitations:** [what *not* to claim]
The composed PR body flows into Step 2's gh pr create --body.
If coordinator.local.md declares project_type: game-dev AND project_subtypes contains unreal, run these additional checks after the main release-readiness steps.
| Check | Detection | Action |
|---|---|---|
| Plugin version matrix touched? | Path globs: control/plugin/**, control/server/**, .github/workflows/build-plugin-*.yml (any path match triggers the check) | Verify CI matrix run for all 5 UE versions (5.3–5.7) is green; flag if the diff post-dates the last green CI run |
| Structural-index schema bumped? | Path globs: mcp_server/structural_index/*.py, project-rag/cli.py, scripts/download-structural-index.sh. Content-grep patterns: MIN_SUPPORTED_SCHEMA, authority_version, manifest_version (any path or grep match triggers the check) | Dispatch schema-migration-auditor to enumerate downstream readers; require the Staff Engineer review of the audit before merge |
| Customer-facing install path touched? | Path globs: scripts/install-*.{sh,ps1}, scripts/lib/install-shell-utils.{sh,ps1}, marketplace.json, docs/wiki/holodeck-for-your-ue-project.md | Verify customer-deployment doc parity (no hardcoded local drive paths to peer repos, no internal-PC assumptions); replay install-shell-utils tests in tests/install/ |
| UBT gate | bin/check-ubt-build-fresh.sh exists in cwd | Scan state/review-trail/ for any *.ubt-compile.pending.json records without a corresponding *.ubt-compile.resolved.json sibling. If found, halt with remediation: run /workday-complete to resolve the pending records, or override with COORDINATOR_OVERRIDE_UBT_GATE=1. A pending record WITH a resolved sibling passes silently. |
| Reverse-drift gate | bin/check-reverse-drift.sh is executable in cwd | Run it. On non-zero exit (a copy_install live install hand-edited since last install — the case forward-SHA check-plugin-drift.sh is blind to), halt with the script's remediation: run holodeck_recover --step reverse-drift to back-propagate live→source, or override with COORDINATOR_OVERRIDE_REVERSE_DRIFT=1. No script present → passes silently. Mirrors /workweek-complete Step 4g. |
Otherwise skip.
Run portability-sweep <repo-root> --diff-only origin/main..HEAD --report-format md.
Portability sweep: 3 migration opportunities in this branch.).
PM dispositions each finding before merge:
--apply-safe (sibling category only).portability-allowlist.toml with rationale.NOT a merge blocker. PM can override the entire step with
COORDINATOR_OVERRIDE_PORTABILITY=1 for a one-off skip.
Tripwire registration: register COORDINATOR_OVERRIDE_PORTABILITY in
docs/wiki/coordinator-tripwires.md in the same commit that lands this step.
BRANCH=$(~/.claude/plugins/coordinator/bin/coordinator-current-branch)
# work/<machine>/2026-03-13 → "Work: <machine> 2026-03-13"; feature/my-feature → "Feature: my-feature"
BODY="$(cat <<EOF
$SHIP_VERDICT
$YK_VERDICT
$RELEASE_NOTES
---
<details>
<summary>Commit log</summary>
$(git log main..HEAD --oneline)
</details>
EOF
)"
gh pr create --base main --head "$BRANCH" --title "$TITLE" --body "$BODY"
<details>.gh pr checks <pr-number> --watch
/merge-to-main, or investigate via docs/wiki/systematic-debugging.md."Pre-merge quiet check (5-minute activity gate). Run before gh pr merge:
last_iso=$(gh pr view "$PR" --json commits -q '.commits[-1].committedDate')
last=$(python -c "import datetime,sys; print(int(datetime.datetime.fromisoformat(sys.argv[1].replace('Z','+00:00')).timestamp()))" "$last_iso")
now=$(python -c "import time; print(int(time.time()))")
if [ $((now - last)) -lt 300 ]; then
branch=$(gh pr view "$PR" --json headRefName -q .headRefName)
echo "Source branch $branch has commits younger than 5 minutes — wait for activity to settle, or pass --force-merge-active-branch."
exit 1
fi
Note: gh pr view --json commits returns commits in chronological order (verified against gh 2.87.3). .commits[-1] is the newest.
Override: If $ARGUMENTS contains --force-merge-active-branch, skip this gate entirely.
Use merge commit (not squash) — preserves commit history as breadcrumbs.
gh pr merge <pr-number> --merge --delete-branch
If "base branch policy prohibits the merge":
Auto-recover with --auto (merges when requirements are satisfied):
gh pr merge <pr-number> --merge --delete-branch --auto
Verify:
sleep 5 && gh pr view <pr-number> --json state --jq '.state'
If MERGED, proceed to Step 5. If still OPEN, auto-merge is queued — wait and check again.
Note: As of 2026-03-13, rulesets no longer require status checks or block force push. Primary gate is the PR requirement (0 approvals). CI advisory.
If "head branch is not up to date with base": Auto-recover — do NOT stop or ask:
git fetch origin main
git merge origin/main -m "merge main into work branch"
git push origin $(~/.claude/plugins/coordinator/bin/coordinator-current-branch)
gh pr merge <pr-number> --merge --delete-branch # retry
If merge conflicts: Do NOT force. Report conflicting files: "Main has diverged. Options: (a) merge main in and resolve conflicts, (b) rebase. Recommend (a)." Stop and wait for PM.
After merge — especially when conflicts were resolved or main had concurrent edits — re-verify intended changes survived (last-writer-wins silently reverts edits on naively resolved hunks).
git show HEAD:<file-path> | grep -F "<canonical phrase from your change>"
~/.claude/, config files, shared scripts).# Review: patrik F1 — inline override required; switching to main is off-daily.
COORDINATOR_OVERRIDE_BRANCH=1 COORDINATOR_OVERRIDE_BRANCH_REASON="merging-to-main step 5 checkout main post-merge" \
git checkout main
git pull origin main
git branch -d <branch> # local branch delete
If on a worktree: git worktree remove <path> instead.
Runs only when $PENDING_RELEASE_FILE was set in Step 1.5. Skip if empty.
Ensure archive directory exists: mkdir -p archive/release-notes/
Flip all pending-release completion entries to released:
ENTRY_PATHS=$(query-completions --where "status=pending-release" --format paths)
MERGE_SHA=$(git rev-parse HEAD)
MERGE_DATE=$(date +%Y-%m-%d)
# For each entry path, set frontmatter — do NOT use git add -A.
Set four frontmatter fields in each entry:
status: released
released_in: <version-tag> # e.g. v1.4.0
released_at: <MERGE_DATE>
released_sha: <MERGE_SHA>
Archive the pending-release file:
PENDING_BASENAME=$(basename "$PENDING_RELEASE_FILE")
git mv "$PENDING_RELEASE_FILE" "archive/release-notes/${PENDING_BASENAME%-pending-release.md}-${VERSION_TAG}-pending-release.md"
Commit on main — scoped, never git add -A:
ARCHIVED_PENDING="archive/release-notes/${PENDING_BASENAME%-pending-release.md}-${VERSION_TAG}-pending-release.md"
git add -- $ENTRY_PATHS "$ARCHIVED_PENDING"
# git add -- "$RELEASE_FILE" # if a human-readable release notes file was written
git commit -m "release: flip completion entries to released for ${VERSION_TAG}" -- $ENTRY_PATHS "$ARCHIVED_PENDING"
Alternative if shell word-splitting on $ENTRY_PATHS is awkward: use git add --pathspec-from-file=<tmpfile>.
git push origin main
Result: all pending-release entries stamped released; pending-release file archived under archive/release-notes/.
## Merged to Main
- **PR:** {url}
- **Merge commit:** {sha}
- **Branch deleted:** {branch} (local + remote)
- **Now on:** main @ {sha}
Other unmerged branches:
~/.claude/plugins/coordinator/bin/orphan-branch-sweep.sh --format text --severity-min warning | grep -v "^OK"
If any output, include in the report: "Multiple work branches in flight — verify these don't carry work intended for this PR."
Never: squash commits; push directly to main.
Use judgment: CI failures are advisory — review them, but they don't block merge. Force push is allowed by the ruleset if needed.
Concurrent-writer caveat: When /merge-to-main runs alongside an active concurrent writer, cap commit sweeps at ~6 and accept a moving target. Don't loop trying to converge.
Called by: coordinator:finishing-a-development-branch (Option 1); PM/EM directly (no longer called by /workday-complete).
Pairs with: No worktrees — worktrees are forbidden. Use the daily branch for WIP parking.
tools
Orient session — preflight, load context, choose work
documentation
Wrap up finished work — capture lessons, update docs
testing
Use before commit, /merge-to-main, /workday-complete, or to validate repo state. Resolves and runs the project's configured fast-test command.
development
Root-cause discipline for ONE identified bug, test failure, or unexpected behavior — pin the premise, reproduce, trace to source, fix at source, verify. For a single known issue, not a codebase sweep.