diff --git a/plugins/ultraplan-local/commands/ultraexecute-local.md b/plugins/ultraplan-local/commands/ultraexecute-local.md
index 1042f2c..c7bb0b7 100644
--- a/plugins/ultraplan-local/commands/ultraexecute-local.md
+++ b/plugins/ultraplan-local/commands/ultraexecute-local.md
@@ -1267,6 +1267,57 @@ Do NOT dispatch a third recovery. Report to the user.
Always produce a final report.
+### Main-merge gate (MAIN_MERGE_GATE — always pauses)
+
+Before writing the final progress + state files (and before any "merge to
+main" prose), emit the `main-merge-gate` lifecycle event so observability
+and operator tooling can see that the pipeline reached its terminal
+boundary:
+
+```bash
+node ${CLAUDE_PLUGIN_ROOT}/lib/stats/event-emit.mjs \
+ --event main-merge-gate \
+ --payload "{\"plan\":\"${PLAN_PATH}\",\"wave_count\":${WAVE_COUNT}}"
+```
+
+Pause for operator confirmation via `AskUserQuestion`:
+
+**Question:** "All waves merged. Ready to proceed with the main-merge step
+and finalize the run?"
+
+| Option | Action |
+|--------|--------|
+| **Confirm — main-merge** | Emit `main-merge-approved`, proceed to write progress + state files. |
+| **Decline — hold for review** | Emit `main-merge-declined`. Wave commits remain on their session branches for inspection. The run is recorded as `partial`. |
+
+This pause is **always on**, regardless of `gates_mode`. The `--gates` flag
+re-enables earlier per-wave pauses; this gate is the one boundary that
+ALWAYS pauses on every run — it is the safety stop between completed waves
+and the merge that publishes the integrated result.
+
+On confirm:
+```bash
+node ${CLAUDE_PLUGIN_ROOT}/lib/stats/event-emit.mjs \
+ --event main-merge-approved \
+ --payload "{\"plan\":\"${PLAN_PATH}\"}"
+```
+
+On decline:
+```bash
+node ${CLAUDE_PLUGIN_ROOT}/lib/stats/event-emit.mjs \
+ --event main-merge-declined \
+ --payload "{\"plan\":\"${PLAN_PATH}\",\"reason\":\"${reason}\"}"
+```
+
+**Recovery surface:** if declined, `--resume re-enters at the gate` —
+re-running `/ultraexecute-local --resume --project
` jumps directly
+back to the main-merge gate AskUserQuestion (skipping completed waves).
+The wave session branches are preserved for inspection (Hard Rule 19's
+push-before-cleanup ensures they survive on the remote even if local
+cleanup ran). To inspect: `git log ultraplan//session-N` per wave.
+
+### Progress + state-file writes
+
Update progress file: `status` to `completed`/`failed`/`stopped`, `updated_at`, `summary`.
**Also atomically write `.session-state.local.json`** (Handover 7) at this
diff --git a/plugins/ultraplan-local/tests/lib/main-merge-gate.test.mjs b/plugins/ultraplan-local/tests/lib/main-merge-gate.test.mjs
new file mode 100644
index 0000000..7c11e8b
--- /dev/null
+++ b/plugins/ultraplan-local/tests/lib/main-merge-gate.test.mjs
@@ -0,0 +1,42 @@
+// tests/lib/main-merge-gate.test.mjs
+// Step 12 (plan-v2) — pin that commands/ultraexecute-local.md Phase 8
+// names the main-merge-gate lifecycle event, the decline + recovery
+// surface, and the always-on gate prose.
+
+import { test } from 'node:test';
+import { strict as assert } from 'node:assert';
+import { readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const ROOT = join(HERE, '..', '..');
+const CMD = readFileSync(join(ROOT, 'commands/ultraexecute-local.md'), 'utf-8');
+
+test('Phase 8 names the main-merge-gate lifecycle event', () => {
+ assert.ok(
+ CMD.includes('main-merge-gate'),
+ 'commands/ultraexecute-local.md should emit `main-merge-gate` from Phase 8',
+ );
+});
+
+test('Phase 8 documents both approved + declined event branches', () => {
+ assert.ok(CMD.includes('main-merge-approved'), 'should emit main-merge-approved on confirm');
+ assert.ok(CMD.includes('main-merge-declined'), 'should emit main-merge-declined on decline');
+});
+
+test('Phase 8 documents the --resume recovery surface for the main-merge gate', () => {
+ assert.ok(
+ CMD.includes('--resume re-enters'),
+ 'Phase 8 should document that `--resume re-enters at the gate` after a decline',
+ );
+});
+
+test('Phase 8 main-merge gate is always-on (regardless of gates_mode)', () => {
+ // Main-merge gate is the one boundary that pauses on every run; the prose
+ // must say so explicitly so the contract survives copy-edit drift.
+ assert.ok(
+ /always[\s\S]{0,200}gates_mode|gates_mode[\s\S]{0,200}always|always pauses on every run/.test(CMD),
+ 'Phase 8 should state main-merge gate is always-on, regardless of gates_mode',
+ );
+});