| 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).
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_stamp=$(python3 scripts/session_stamp.py --phase close \
--marker "${stm_base}${issue}/status/session-stamp-merge-change.json")
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=$?
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.
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.