Merge an approved change — merge the PR, switch to main and pull the latest, and delete the feature branch (local and remote). The final step of the end sequence in the ProductOS command model. Use when a reviewed, approved PR is ready to land.
Scanned 9/10/2026
Install to Claude Code
npx -y skills add kapilvirenahuja/garura --skill merge-change --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Merge Change?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/kapilvirenahuja-merge-change)More formats (shields.io, HTML) on the badges page.
---
name: merge-change
position: end
description: 'Merge an approved change — merge the PR, switch to main and pull the latest, and delete the feature branch (local and remote). The final step of the end sequence in the ProductOS command model. Use when a reviewed, approved PR is ready to land.'
user-invocable: true
---
# merge-change
Merge an approved change: merge the PR, switch to main and pull the latest, and delete the
feature branch (local and remote).
**Pipeline position: end** (fourth step of the end sequence: commit-change →
propose-change → review-change → merge-change). The D2 pipeline-position rule injects this
sequence into any play declared `position: end`. Independent, invokable play; ordering is
enforced by pre-flight (the PR must be approved and mergeable). **Supersedes the old
merge-pr play.**
## Compiled From
This play was compiled from the merge-change ICE (`reference/ice.md`) by play-editor
(#466 Batch A, Level 3 rollout per ADR 025; #467 Batch C gate ruling; #484 scripted chain).
Intent defines constraints (C1–C8) and failure conditions (F1–F8); the expectation defines
success scenarios (S1–S4), a Done means (D1–D3, baked to `stop-condition.yaml`), and one
recovery entry per failure condition.
To modify this play, update `reference/ice.md` and recompile with play-editor.
Do NOT edit this file manually — it is a compiled artifact.
## Role
You are the orchestrator. You own the workflow and the step order. The merge work is
mechanical — a fixed command sequence with zero judgment — so you run it through **bundled
scripts** calling git/gh directly, not an agent dispatch (C8, #484). Reading the merge
state runs in `read_merge_state.py`; the merge + sync + cleanup runs in `merge_pr.py`; both
reach the code host through the bundled `platform_adapter.py`. No agent boots to do this.
**Forbidden:** running the mechanical git/gh work through an agent dispatch (the #484 bug);
ad-hoc `git`/`gh` reasoned out in play prose (it runs in the bundled scripts); merging
without first verifying the approval verdict and mergeability.
**Agents:** **none.** This play holds no judgment step — every operation is a bundled
script. Agent dispatch is reserved for genuine judgment, of which merge-change has none, so
it dispatches **zero agents** (domain or utility).
## Pre-flight
| Check | Constraint | Action on Failure |
|-------|-----------|-------------------|
| Resolve working base + config (`.garura/core/config.yaml`) | — | Hard halt |
| An open PR exists for the branch | — | Graceful exit |
| Platform CLI available (`gh`) | C2 | Hard halt |
| PR has an approval verdict from review-change | C1 | Hard halt |
| PR is mergeable (no conflicts, required checks not failing) | C4 | Hard halt |
| Already-merged check | C5 | Graceful no-op |
Resolve the deterministic pre-flight facts with the bundled resolver — config tokens,
`branch`, `issue` (from the branch name), `evidence_record`:
```
python3 scripts/preflight.py --play merge-change \
--config .garura/core/config.yaml --branch "$(git branch --show-current)"
```
Right after the resolver, record the session identity stamp's start marker (#463 — soft-fail, never a halt):
```
python3 scripts/session_stamp.py --phase start \
--marker "{stm_base}{issue}/status/session-stamp-merge-change.json" \
--cwd "$(pwd)" --branch "$(git branch --show-current)"
```
The open-PR check, `gh` availability, the approval verdict, mergeability, and the
already-merged check are live host state resolved by the bundled `read_merge_state.py`
(Step 1, via `platform_adapter.py`) — no agent (C8). If the PR is already merged, exit
cleanly as a no-op (C5).
## Task DAG
Create ALL tasks immediately after resolving the working base — before any domain work.
The play owns this DAG; agents must not edit its top-level tasks.
```
[T1] Verify Approval + Mergeable blockedBy: []
[T2] Confirm Merge blockedBy: [T1]
[T3] Merge + Sync + Cleanup blockedBy: [T2]
[T4] Scenario Validation blockedBy: [T3]
[T5] Close blockedBy: [T4]
```
Mark each task in-progress before its step and completed right after its eval passes. No
runtime reordering. On resume, skip completed and reset in-progress to pending.
## Workflow
### Phase: Execution
**Step 1 — Verify Approval + Mergeable** · Owner: play (script) · Depends on: pre-flight
Run the bundled script — it reads the PR state (mergeable, already-merged, required-checks)
through `platform_adapter.py` and the approval from the review verdict, and writes the gate
(no agent, C8):
```
python3 scripts/read_merge_state.py --config .garura/core/config.yaml \
--branch "$(git branch --show-current)" \
--verdict "{stm_base}{issue}/review/decision.yaml" \
--out "{stm_base}{issue}/review/merge-gate.json"
```
`merge-gate.json` records `{ approved: true|false, mergeable: true|false, already_merged,
checks_pass, pr_number, pr_merged, branch_deleted }` — the record ALWAYS ends with the two
Done-means booleans
(C6), on both paths including the no-op: `pr_merged` is true on a fresh merge AND on the
already-merged no-op path; `branch_deleted` is true when the feature branch is gone
locally and on origin (on the no-op path, true when it was already gone). On the no-op
path Step 1 finalizes both fields itself; otherwise Step 3 finalizes them after cleanup.
**SE-1 (F1/C1):** `merge-gate.json.approved == true` — an approval verdict exists before
any merge.
**SE-2 (F4/C4):** `merge-gate.json.mergeable == true` — the PR has no conflicts and no
failing required checks before any merge.
**Step 2 — Confirm Merge (class: one-way-door, pinned)** · Owner: play · Depends on: Step 1
Merging is outward-facing and irreversible on `main`, so this checkpoint is **pinned**
(C7): no config value, policy, or ledger turns it off — the land on `main` always keeps
its human beat (the #467 re-pin correction). *Skip as a no-op when `already_merged` is
true (C5).*
- **Machine wall first (verified before presenting).** Confirm the three preconditions from
`merge-gate.json` (Step 1, written by `read_merge_state.py`): `approved: true`,
`mergeable: true`, and `checks_pass: true` (no failing required checks). If any is false,
HALT exactly as pre-flight does (REC7), naming the failed precondition — there is nothing
to approve, so the human is not asked.
- **Then the human beat.** With all three true, present the PR, its approval, and
mergeability, and wait for a typed approval. Approve → continue to Step 3; cancel →
halt without merging. The merge proceeds solely on the typed approval; the three
booleans are recorded as what the merge stood on.
**Step 3 — Merge + Sync + Cleanup** · Owner: play (script) · Depends on: Step 2
Run the bundled script — it merges the PR (via `platform_adapter.py`), switches to main and
pulls, deletes the feature branch locally and on remote, finalizes `merge-gate.json`'s
two Done-means (`pr_merged: true`, `branch_deleted: true`) on both paths incl. the no-op
(C6), **commits the run records on main itself** (#491) — `merge-gate.json` + the
review dir, message `chore(stm): record merge-change run records (#<issue>)` (ADR 012,
evidence self-commit) — and **pushes that records commit** (#493): the pinned Confirm
Merge approval (C7) already put the human on the outward act, so the bookkeeping push
rides on that authorization, no second ask. Its output carries `records_committed` and
`records_pushed`, so a leftover or an unpushed record is a visible state, never silent.
No agent (C8):
```
python3 scripts/merge_pr.py --config .garura/core/config.yaml \
--branch "$(git branch --show-current)" --base main \
--gate "{stm_base}{issue}/review/merge-gate.json" \
--issue <issue> --records-dir "{stm_base}{issue}/review/"
```
The tree is clean after the step and main is level with origin; a
`records_committed: false` output is a failed step, and a `records_pushed: false` output
leaves the push owed and visibly reported (soft-fail — offline or a host guard), never
silent.
**SE-3 (F2/C2):** after the step, the current branch is `main` and is up to date with
`origin/main`.
**SE-4 (F3/C3):** the feature branch no longer exists locally or on remote.
**SE-5 (F5/C5):** when the PR was already merged at pre-flight, no second merge was
attempted and the run ended as a clean no-op.
**SE-6 (F6/C6):** `merge-gate.json` reads `pr_merged: true` + `branch_deleted: true`; the
records commit exists on main; the stop-condition verdict reads held before any
COMPLETED close.
**SE-7 (F7/C7):** the merge proceeded only on the pinned human approval, AND the Step 2
record shows all three machine preconditions true — `approved: true`, `mergeable: true`,
and no failing required checks — verified before the human was presented; no merge
proceeded without both the wall and the human beat.
**SE-8 (F8/C8):** the merge state read and the merge+sync+cleanup ran as the bundled
scripts (`read_merge_state.py`, `merge_pr.py`) — no agent was dispatched for the mechanical
git/gh work; the play's agent count is zero.
### Phase: Scenario Validation
**Step 4 — Scenario Evals** · Owner: play · Depends on: Step 3
- **SCE-1 (S1 — developer, approved PR):** the PR is merged; the checkout is on updated
main; the feature branch is gone locally and on remote; `merge-gate.json` reads
`pr_merged: true` and `branch_deleted: true`; the records commit exists on main; the
stop-condition verdict reads held.
- **SCE-2 (S2 — developer, not approved):** with no approval verdict, the run halted at
pre-flight and the PR is not merged.
- **SCE-3 (S3 — developer, not mergeable):** with conflicts or failing checks, the run
halted at pre-flight and the PR is not merged.
- **SCE-4 (S4 — developer, already merged):** a re-run on a merged PR was a clean no-op,
no second merge attempted; `merge-gate.json` reads `pr_merged: true` and
`branch_deleted: true`; the records commit exists on main; the stop-condition verdict
reads held.
### Phase: Evidence & Close
**Step 5 — Close** · Owner: play · Depends on: Step 4
Run the Standard Play Close. Evidence recording is play-only and config-gated per the D1
evidence rule (`standards/rules/evidence-recording.md`).
```bash
# --- Standard Play Close (canonical; see standards/rules/play-close.md) ---
# Path tokens resolved at pre-flight (resolve here if not already):
# ltm_project_target = yq '.ltm.project-target' .garura/core/config.yaml
# evidence_base, slug (project-scoped play):
# evidence_base="${stm_base}${issue}/evidence/merge-change/" ; slug="#${issue}"
evidence_template=$(cat "${ltm_project_target}standards/templates/evidence-file.md")
delivery_template=$(cat "${ltm_project_target}standards/templates/delivery-report.md")
ts=$(date -u +%Y%m%d-%H%M%S)
evidence_dest="${evidence_base}${ts}.md"
mkdir -p "$(dirname "$evidence_dest")"
# Session identity stamp (#463) — close phase; start phase ran at pre-flight
session_stamp=$(python3 scripts/session_stamp.py --phase close \
--marker "${stm_base}${issue}/status/session-stamp-merge-change.json")
# Stop-condition gate (#464) — Step C0: this play carries a baked manifest, so the
# gate is LIVE. Evaluate the Done means as the close's authoritative input.
python3 scripts/check_stop_condition.py \
--manifest "<play-dir>/stop-condition.yaml" \
--base "${stm_base}${issue}/" \
--out "${stm_base}${issue}/status/stop-condition-merge-change.yaml"
sc_exit=$? # 0 held · 1 unmet · 2 error
```
**Step C0 — bind the verdict.** `sc_exit == 0` (held) permits `status: COMPLETED`.
Anything else closes `HALTED` with `exit_reason: stop_condition_unmet` and the evidence's
Stop Condition section names every unmet clause — first fix the state per REC6 (delete a
surviving branch, rewrite the gate record) and re-evaluate; the close stays HALTED until
the verdict reads held (F6/C6). An unevaluable verdict is never a pass.
**Step C1 — Write evidence file.** Gated by the resolved `evidence.record` flag (global +
per-play `evidence.plays.merge-change`; first match wins, absent ⇒ record). When false,
skip the write and record `evidence skipped (record=false)` in the report's pointer line.
Otherwise fill the `evidence-file.md` slots (play `merge-change`, run_id
`merge-change-${ts}`, issue, started_at/completed_at, status per C0, artifacts produced:
`merge-gate.json`, the merge commit SHA, the records commit SHA, the stop-condition
verdict; step/scenario eval results; checkpoint decision
from Step 2 (or its config-skip row with the three precondition booleans, C7);
commit reference = the merge commit SHA; and the session identity stamp fields
from $session_stamp (#463): session_id, ledger_file, ledger_start_offset, ledger_end_offset
(null when unresolved — never blocks the close)) and write to `$evidence_dest`. Do
NOT hand-author the body.
**Step C2 — Render delivery report.** Also render the **Next** line: resolve this play in `standards/rules/pipeline-next.md` and emit `**Next:** /<command> — <why>. Or run /next to see all recommended actions.` (only /next pointer, or omit, when the mapped command is null), per `play-close.md`. Fill the `delivery-report.md` slots and output the
report: `## merge-change Delivered — #${issue}`, the Run Summary table, the Pipeline Steps
table from the task DAG, the Artifacts Produced table (incl. the merge commit), Next Steps
(the change has landed on main), and a pointer to `$evidence_dest`. Always emitted; never
gated.
```bash
# --- end Standard Play Close ---
```
## Scenario Validation
| Scenario | Persona | Eval |
|----------|---------|------|
| S1 — approved PR | developer | SCE-1 |
| S2 — not approved | developer | SCE-2 |
| S3 — not mergeable | developer | SCE-3 |
| S4 — already merged | developer | SCE-4 |
## Recovery
| For | Trigger | Direction | Handoff |
|-----|---------|-----------|---------|
| F1 | a merge was attempted without an approval verdict | halt and require review-change to approve the PR first | human |
| F2 | after merge the checkout isn't on updated main | check out main and pull the latest | autonomous |
| F3 | the feature branch is still present after merge | delete the branch locally and on remote | autonomous |
| F4 | the PR is not mergeable | halt; conflicts must be resolved or failing checks fixed before retrying | human |
| F5 | a re-run on an already-merged PR | detect the merged state and no-op cleanly | autonomous |
| F6 | the run is about to close COMPLETED with the Done means unmet | fix the state — delete the surviving branch / rewrite the gate record — and re-evaluate; the close stays HALTED until the verdict reads held | autonomous |
| F7 | a merge would proceed without the pinned human approval, or a precondition (approved, mergeable, no-failing-required-checks) was not verified true before presenting | halt; on a failed precondition surface which one and do not present; if the human beat was skipped, present and wait | autonomous |
| F8 | a mechanical git/gh op ran through an agent instead of its bundled script | route it through the script (`read_merge_state.py` / `merge_pr.py`, via `platform_adapter.py`) and remove the agent dispatch | autonomous |
## Pause and Resume
Steps run top to bottom. On entry, resolve the PR from the branch, check the status marker
and whether the PR is already merged, skip completed steps, reset any in-progress step to
pending, and continue. A re-run on an already-merged PR is a clean no-op (C5, F5).
## Compilation Metadata
| Field | Value |
|-------|-------|
| fingerprint | sha256:bc52977cd41e90484c267177bf38c6bb9b8004f9e478fd522a22440349b87632 (of `reference/ice.md`) |
| compiled_by | play-editor (#466 Batch A; #467 Batch C; #484 scripted-chain) |
| pipeline_position | end |
| workflow_structure | B (fast execution flow, single-pass; stop-condition gated close) |
| stop_condition | stop-condition.yaml (D1–D3), gate live at Step C0 |
| domain_agents | 0 |
| utility_agents | 0 (#484 — mechanical git/gh moved to bundled scripts) |
| scripts | 6 (preflight.py, session_stamp.py, check_stop_condition.py, platform_adapter.py, read_merge_state.py, merge_pr.py) |
| step_evals | 8 (SE-1…SE-8) |
| scenario_evals | 4 (SCE-1…SCE-4) |
| recovery_entries | 8 (one per failure condition; 6 autonomous / 2 human) |
## Recompile note (#493, records auto-push)
Intent change via `reference/ice.md` → recompile. C6 rewritten: the play now PUSHES its
records commit itself after the pinned Confirm Merge approval — the human authorized the
outward irreversible act at the merge gate (C7, still pinned, untouched), and the records
commit is bookkeeping about that approved merge, so no second human step is asked (ruled by
Kapil 2026-07-05 on #493). `merge_pr.py` pushes after a successful records self-commit and
reports `records_pushed`; a failed push (offline, host guard) is a visible soft-fail with
the push left owed, never silent. Fingerprint recomputed over the edited ICE.
## Direct-edit deviation note (#491, records self-commit)
Non-intent executor move: C6 always required the run-records commit on main at close, but
that duty lived in Step 3 PROSE and got skipped, leaving `merge-gate.json` uncommitted
after runs (the observed leftover). `merge_pr.py` now performs the commit itself as its
final act on both paths (fresh merge + already-merged no-op), staging the gate file + the
review dir, standard message, never pushing; it reports `records_committed` so a leftover
is a visible failure. No constraint, failure, scenario, or eval changed — the executor
moved from prose to the script that is already standing on main at that moment.
`reference/ice.md` and the fingerprint stand. Canonical copy:
`play-creator/references/merge_pr.py`; a rebuild reproduces it.
## Recompile note (#484, scripted chain)
Intent change via `reference/ice.md` → recompile. Added C8 (mechanical git/gh runs in
bundled scripts, never agent dispatch), F8, REC8, and SE-8. Steps 1 and 3 no longer
dispatch `repo-orchestrator` → `merge-pr`/`platform-adapter`; they run the bundled
`read_merge_state.py` (state read) and `merge_pr.py` (merge + sync + cleanup), both reaching
the host through `platform_adapter.py` (canonical copies in `play-creator/references/`,
stamped here). Utility agents dropped 1 → 0; scripts 3 → 6. The guarantees (approval-gated,
mergeable-gated, branch deleted, pinned human land, Done-means proven) are unchanged — only
the executor moved from an agent to a script. Fingerprint recomputed over the edited ICE.
## Recompile note (#467 Batch C → re-pin correction)
Intent change via `reference/ice.md` → play-editor, in two moves. **Batch C** first turned
the Confirm Merge gate off per config (`gates.plays.merge-change: off`), merging on the
three machine preconditions alone. **The re-pin correction** (this recompile) then made
C7 **pinned**: the land on `main` is irreversible, so it always keeps its human beat — no
config value turns it off. The three preconditions (approved, mergeable, no failing
required checks) stay, now as a machine wall verified BEFORE the human is presented (a
failed precondition halts without asking; only all-true reaches the human). Field evidence
drove the correction: the environment held the automatic land on main for a human beat on
both #483 and #485, correctly — merge-to-main belongs with the pinned gates. The rest of
merge-change (Step 1 verification, Step 3 execution) stays automatic. `gates.plays.merge-change`
in config is now moot for this checkpoint (pinned overrides it) and is dropped from the
off-list to avoid implying a reversible switch. No new scripts. The fingerprint reflects
the updated ICE.
## Direct-edit deviation note (#434, pre-flight resolver)
Pre-flight resolution moved from orchestrator inference to the bundled `scripts/preflight.py`
(harness-led: config/branch/issue/changeset resolution is a script returning JSON facts; the
play keeps only the halt policy; the live open-PR/`gh` checks are in the table, and the
approval/mergeability/already-merged reads now run in `read_merge_state.py` (#484, was
repo-orchestrator). The script is the canonical
resolver stamped from `play-creator/references/preflight.py`; a rebuild reproduces it
(play-creator step 4). Non-intent change — no constraint, failure, scenario, eval, or
`reference/ice.md` touched, so the fingerprint stands and no recompile is required. Direct
edit; no recompile needed.
## Direct-edit deviation note (#463, session identity stamp)
Non-intent change: the Standard Play Close gained the session identity stamp — `scripts/session_stamp.py` (canonical copy: `play-creator/references/session_stamp.py`) runs `--phase start` at pre-flight and `--phase close` in the close block; the evidence frontmatter carries session_id / ledger_file / ledger_start_offset / ledger_end_offset. Source of truth: `standards/rules/play-close.md`; play-creator emits the same lines so a rebuild converges. No constraint, failure, scenario, or eval changed; the fingerprint stands.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!