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', + ); +});