sova-integrate-pr
Skill3 starsChanged 6 days agoApache-2.0View on GitHub
Full integration pipeline. Verify mergeability, gate silent reverts, wait for CI, merge, clean up. One click from approved PR to done.
- Reads credentials
- Deletes or force-pushes
- Commits and pushes
In this repository44 other files it gives its agents.
- AGENTS.mdAGENTS.md
- templates/AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- templates/CLAUDE.mdCLAUDE.md
- dashboard-designSkill
- database-patternsSkill
- design-systemSkill
- design-tasteSkill
- issue-templateSkill
- sova-address-prSkill
- sova-after-mergeSkill
- sova-agent-readinessSkill
- sova-audit-instructionsSkill
- sova-debugSkill
- sova-develop-explainSkill
- sova-develop-fullSkill
- sova-developSkill
- sova-extract-knowledgeSkill
- sova-find-taskSkill
- sova-health-auditSkill
- sova-ingest-reviewSkill
- sova-issueSkill
- sova-new-featureSkill
- sova-optimize-knowledgeSkill
- sova-prSkill
- sova-rearrange-commitsSkill
- sova-researchSkill
- sova-review-fullSkill
- sova-review-prSkill
- sova-reviewSkill
- sova-simplifySkill
- sova-specSkill
- sova-standupSkill
- sova-statusSkill
- sova-testSkill
- sova-update-docsSkill
- testing-patternsSkill
- visual-auditSkill
- dashboard-designSkill
- database-patternsSkill
- design-tasteSkill
- issue-templateSkill
- testing-patternsSkill
- visual-auditSkill
The file
Show its rules and text
We split this skill into the rules it gives an agent
41 rules. About 0 are common, 23 are distinctive, and 18 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈23
Distinctive · estimate
This author's own choices. Worth a look.
18
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 16
Shell blocks: Every fenced block below runs as its own shell invocation: no variable survives from one block to the next, so each block …
- Distinctive: line 46
From recent PRs: if on the base branch, list recent open PRs authored by the current user:
- Distinctive: line 50
Ask the user which one to integrate (unless running autonomously, in which case stop and report ambiguity).
- Distinctive: line 52
If no PR can be identified, stop and report clearly.
- Distinctive: line 94
Ask GitHub whether the PR can merge:
- Very specific: line 111
mergeable: MERGEABLEandmergeStateStatus: BEHIND: no conflicting lines, but the branch has not incorporated everything merged into the … - Very specific: line 128
If that returns false, a BEHIND state is not a merge blocker there and this step may be skipped, matching the MERGEABLE/not-BEHIND case …
- Very specific: line 141
Get onto the PR branch safely: Never run git checkout <HEAD_BRANCH> in the primary checkout: if a worktree already holds the branch, the …
- Very specific: line 153
If that prints a path, cd into it and verify git branch --show-current equals <HEAD_BRANCH>.
- Distinctive: line 190
STOP and hand off instead of guessing:
- Very specific: line 195
After each resolution, git add <file> and git rebase --continue, then verify nothing is left behind:
- Distinctive: line 281
When all commits are replayed, run the project's linter and tests (see CLAUDE.md).
- Distinctive: line 308
If the check finds something stale, stage it now (do not commit or push it separately).
- Very specific: line 338
Return to the primary checkout: before continuing, whether or not step 1 cd'd into a worktree.
- Very specific: line 359
Run the whole gate as one block.
- Very specific: line 463
GUARD_NOT_LIVE: STOP. The base branch's guard passes anything, so neither this gate nor the Phase 6 audit can be trusted. - Very specific: line 465
GATE_ERROR: STOP and report the error. - Very specific: line 480
Only run the rest of this phase if .claude/agent-memory/ exists in the project AND PUSHED is 0.
- Distinctive: line 493
Check documentation counts: run the verification commands (test count, service count, router count) and compare against AGENTS.md, …
- Very specific: line 503
NOT amend and do NOT push.
- Very specific: line 519
Override: if the arguments provided when this skill is invoked contains --with-docs, the user has explicitly accepted the extra CI cycle.
- Distinctive: line 590
If it printed "Skipping the poll", go to Phase 5.
- Very specific: line 592
When the base branch has required checks (rulesets, every page of gh api --paginate repos/{owner}/{repo}/rules/branches/<base>, plus every …
- Very specific: line 696
If neither (1) nor (2) produced any text (only a check name and link from $FAILED), stop.
- Distinctive: line 718
If merge queue is detected:
- Distinctive: line 722
If enqueued, write a merge queue marker file in the primary checkout so the dashboard can track the PR:
- Distinctive: line 734
If merge queue is NOT detected:
- Very specific: line 901
Check cfg.task_source.jira_state_transitions for custom transition name overrides (e.g., on_qa = "Move to QA" takes priority over the …
- Distinctive: line 902
If no matching transition is available on the Jira board, log a warning and skip
- Distinctive: line 904
Check for stale stashes that belong to the merged branch:
- Distinctive: line 910
If any stash entries reference the merged branch name, report them to the user (do not drop without confirmation).
- Distinctive: line 912
Run the full issue-aware GC to clean up any remaining stale worktrees and branches across the project:
- Distinctive: line 947
When the pipeline stops at any phase, report clearly:
- Distinctive: line 964
Never stop between phases unless there is a hard failure
- Distinctive: line 967
Never background a poll loop and stop to wait for a notification (see the Phase 4 note on foreground calls and timeouts)
- Very specific: line 968
Use the merge method from [integration] config (default: auto, uses GitHub repo default)
- Very specific: line 970
Use --force-with-lease for pushes, never --force
- Very specific: line 971
Never swallow a failed branch or worktree deletion with || true: report what was kept and why
- Distinctive: line 972
Only record actionable, specific lessons in memory, not generic advice
- Distinctive: line 973
Do not duplicate existing memory entries
- Distinctive: line 974
NEVER use emojis in any output
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Integrate PR
Merge a PR that is already ready, then clean up. Verifies mergeability, checks the replayed commits for silent reverts, merges, audits what actually landed, cleans up branches/worktrees/stashes, closes the linked issue, and runs post-merge cleanup. Replaces the need to run the sova-after-merge skill separately. Works for both manual invocation and autonomous agent use.
This command does not push by default. A push creates a new head SHA and spends a full CI cycle, which on a ready PR is pure waste and delays the merge by the length of the suite. The PR is expected to arrive here mergeable, with review learnings and documentation already folded in by the sova-address-pr skill. Two things justify a push, both meaning another PR merged into the base branch since this one was last pushed: a real conflict (mergeable: CONFLICTING), or, under this repository's strict status-check policy, the branch simply being behind (mergeStateStatus: BEHIND) even with no conflicting lines. Phase 2 resolves either case and is explicit about the cost.
PR: the arguments provided when this skill is invoked
Instructions
Shell blocks. Every fenced block below runs as its own shell invocation: no variable survives from one block to the next, so each block assigns every variable it reads. <PR_NUMBER>, <BASE_BRANCH>, <HEAD_BRANCH> and <ISSUE_NUMBER> are filled in from the Phase 1 metadata.
Primary checkout. State shared between phases lives in the PRIMARY checkout's .claude/agent-control/. The Update subroutine may cd into a per-issue worktree, and .claude/agent-control/ is not mirrored into worktrees, so a bare relative path would read and write a different file depending on where each phase runs. Every block that needs it opens with these two lines (the first is git rev-parse --git-common-dir; the case maps a linked worktree to its primary checkout), never assuming the current directory is it:
COMMON_DIR=$(git rev-parse --git-common-dir 2>/dev/null)
case "$COMMON_DIR" in /*) PRIMARY_ROOT="${COMMON_DIR%/.git}" ;; *) PRIMARY_ROOT="$(git rev-parse --show-toplevel)" ;; esacSilent-revert guard. Phase 2b and Phase 6 use the project's silent-revert guard if it has one: a script that flags commits silently undoing other work and honours an explicit commit range (for example invariants/silent-revert-guard.sh <worktree> <base> with SILENT_REVERT_RANGE=<a>..<b>). Both blocks resolve its repo-relative path with the same expression, re-derived in each block because shells do not carry over:
GUARD_PATH="${SILENT_REVERT_GUARD:-invariants/silent-revert-guard.sh}"If the project's CLAUDE.md or AGENTS.md names a different silent-revert guard script, set SILENT_REVERT_GUARD to its repo-relative path before running these blocks (for example by prefixing the block with export SILENT_REVERT_GUARD=<path>). Both phases run the copy from origin/<BASE_BRANCH>, never the PR's own copy, so a PR cannot weaken the check that judges it. If no guard honouring an explicit range exists at the resolved path, both phases print a SKIPPED line naming the path they checked and continue; say so in the Phase 7 report, since a wrong path skips both gates silently otherwise.
Phase 1: Identify the PR
echo "::sova-phase::identify"Determine the PR number from available context, in priority order:
- From arguments (
the arguments provided when this skill is invoked): use directly if a number is provided - From current branch: query for an open PR on the current branch:
gh pr view --json number,title,body,state,baseRefName,headRefName,statusCheckRollup,reviewDecision,commits,mergeable - From recent PRs: if on the base branch, list recent open PRs authored by the current user:
Ask the user which one to integrate (unless running autonomously, in which case stop and report ambiguity).gh pr list --author @me --state open --limit 10
If no PR can be identified, stop and report clearly.
Once identified, fetch full PR metadata:
gh pr view <PR_NUMBER> --json number,title,body,state,baseRefName,headRefName,statusCheckRollup,reviewDecision,commits,mergeableStop if:
- PR state is
CLOSED: report and stop. - PR state is
MERGED: skip straight to Phase 6 (cleanup). Phase 6 still runs its post-merge silent-revert audit, which is the only check an out-of-band merge (web UI, another agent, a merge queue) ever gets.
Resolve the linked issue, which names the worktree to clean up and goes into the merge-queue marker. This is optional: the pipeline works without a linked issue. On a GitHub project, read it from the PR body:
sova linked-issues <PR_NUMBER>It prints {"closes": [...], "part_of": [...]}: the issues a closing keyword names (Closes #N, Fixes #N, Resolves #N and their other forms), then the Part of #N issues the PR only partly finishes. <ISSUE_NUMBER> is the first closes entry, else the first part_of entry. A bare #N in the body or the title is a mention, not a link. On a Jira project it is the ticket key the PR title starts with ([PROJ-123]). <ISSUE_NUMBER> never decides which issues get closed: Phase 6 does, from the merged PR's body.
Log the review decision status (APPROVED, CHANGES_REQUESTED, etc.) but do NOT require formal approval to proceed. The user invoking this command is the approval.
Phase 2: Assess Mergeability (push only when the PR cannot merge as it stands)
The default path through this command is: do not touch the branch, do not push, do not re-run CI. Every push here creates a new head SHA and costs a full CI cycle, so a push must be justified by GitHub refusing to merge, never by routine hygiene.
Reset the push-tracking state file first. It is a file rather than a shell variable because Phases 2 through 4 run as separate shell invocations:
echo "::sova-phase::mergeability"
COMMON_DIR=$(git rev-parse --git-common-dir 2>/dev/null)
case "$COMMON_DIR" in /*) PRIMARY_ROOT="${COMMON_DIR%/.git}" ;; *) PRIMARY_ROOT="$(git rev-parse --show-toplevel)" ;; esac
mkdir -p "$PRIMARY_ROOT/.claude/agent-control"
echo 0 > "$PRIMARY_ROOT/.claude/agent-control/integrate-pushed"Ask GitHub whether the PR can merge:
gh pr view <PR_NUMBER> --json mergeable,mergeStateStatus,baseRefName,headRefNameAct on mergeable first, then, when it is clean, on mergeStateStatus:
mergeable: CONFLICTING: a PR merged into the base branch since this one was last pushed and the two diverge on the same lines. Run the update subroutine below; expect it to hit real conflicts.mergeable: UNKNOWN: GitHub is still computing mergeability. Re-query up to 5 times, 5 seconds apart. If it is stillUNKNOWN, treat it asMERGEABLEand let the merge attempt in Phase 5 surface the real answer. Do not rebase speculatively.mergeable: MERGEABLEandmergeStateStatus: BEHIND: no conflicting lines, but the branch has not incorporated everything merged into the base branch since it was last pushed. This repository'smain-protectionruleset setsstrict_required_status_checks_policy: true, so GitHub will refuse to merge until the branch is updated, regardless of how green its existing checks are. Run the update subroutine below; expect it to complete cleanly with no conflicts, sincemergeablealready ruled those out.This is a real, unavoidable CI cost, not a routine-hygiene push: skipping it would leave the branch permanently unmergeable under this ruleset. Confirm the policy before assuming it applies in another project:
gh api repos/<OWNER>/<REPO>/rulesets --jq '.[] | select(.target == "branch") | .id' gh api repos/<OWNER>/<REPO>/rulesets/<ID> \ --jq '.rules[] | select(.type == "required_status_checks") | .parameters.strict_required_status_checks_policy'If that returns
false, aBEHINDstate is not a merge blocker there and this step may be skipped, matching theMERGEABLE/not-BEHINDcase below.mergeable: MERGEABLEand anything else (typicallyCLEAN): nothing to do. Do NOT rebase, do NOT push. Go to Phase 2b.
Update subroutine
Handles both a real conflict (mergeable: CONFLICTING) and a clean-but-stale
branch (mergeStateStatus: BEHIND). The rebase step is identical either way;
only step 3 (conflict resolution) has anything to do when there are no
conflicts to resolve.
- Get onto the PR branch safely. Never run
git checkout <HEAD_BRANCH>in the primary checkout: if a worktree already holds the branch, the checkout fails or, worse, leaves uncommitted work that bleeds onto the base branch later. Resolve the worktree first:
If that prints a path,echo "::sova-phase::update" git fetch origin || { echo "Fetch failed: STOP, refs may be stale" >&2; exit 1; } git worktree prune HEAD_BRANCH_REF="branch refs/heads/<HEAD_BRANCH>" git worktree list --porcelain | grep -Fx -B2 "$HEAD_BRANCH_REF" \ | grep "^worktree " | head -1 | sed 's/^worktree //'cdinto it and verifygit branch --show-currentequals<HEAD_BRANCH>. If it prints nothing and the current branch already matches<HEAD_BRANCH>, you are in the right place. If neither holds, STOP and report: there is no safe place to rebase. - Rebase. Save the pre-rebase commit first, as a ref rather than a shell
variable so step 6 (a later shell) can still read it: once the rebase
completes,
git rebase --abortreports no rebase is in progress and cannot undo it, so a validation failure discovered afterward needs this to restore the branch.git update-ref "refs/integrate-backup/<PR_NUMBER>" HEAD git rebase origin/<BASE_BRANCH> Resolve only conflicts you fully understand. Bounded: at most 5 conflicting commits, at most 3 attempts each.
Safe to resolve autonomously:
- both sides added entries to the same list, import block, or registry
- both sides edited the same file in non-overlapping places
- the base branch renamed or moved something this PR also touches, where the intent of each side is unambiguous
- a lockfile or generated file that can be regenerated
Resolve a lockfile or generated file in exactly one of two ways: edit the conflicting hunk so both sides' entries survive, or regenerate the file from the rebased manifests with the project's own tool. Never take one side's whole file (
git checkout --ours <file>or--theirs <file>) for any lockfile or generated file the PR changed, even when the conflicting hunk is the PR's only change to it. During a rebase--oursis the base branch, so--ourskeeps the base's file and silently discards every change the PR made to it;--theirsis the PR's commit, so it keeps the PR's file and silently discards what the base gained since the fork. Step 4b catches a file lost the--oursway, but only after the fact, and it cannot see the--theirsway at all: the file then matches the backup.STOP and hand off instead of guessing:
- both sides changed the same function's behaviour or signature
- the conflict spans a semantic decision (which of two implementations wins)
- resolving would silently drop a change from either side
- After each resolution,
git add <file>andgit rebase --continue, then verify nothing is left behind:
This must produce no output.grep -rn "<<<<<<<\|>>>>>>>" <resolved_files>git addclears a file's unmerged index entry whether or not the markers were removed, so the index alone does not prove the conflict was resolved.
4b. Check the rebase did not drop PR content. Run this once the rebase has completed (also after a clean rebase with no conflicts), in the PR worktree from step 1 and before step 5. A conflict resolved by taking one side's whole file leaves no marker and no failing test: the file just vanishes from the PR's diff. This compares the PR's file list and per-file changed-line counts, read from the backup ref step 2 saved, against the rebased head. It uses local git only, so a clean rebase adds no push and no CI cycle.
BACKUP="refs/integrate-backup/<PR_NUMBER>"
BASE="origin/<BASE_BRANCH>"
# Paths below are repo-relative: run from the top, so a shell left in a subdirectory still checks every file.
if ! cd "$(git rev-parse --show-toplevel)"; then
echo "DROPPED_FILE_CHECK_ERROR: not inside the PR worktree: STOP." >&2
exit 1
fi
if ! git rev-parse --verify --quiet "$BACKUP^{commit}" >/dev/null; then
echo "DROPPED_FILE_CHECK_ERROR: $BACKUP is missing, but step 2 always creates it. The sequence was broken: STOP." >&2
exit 1
fi
if [ -d "$(git rev-parse --git-path rebase-merge)" ] || [ -d "$(git rev-parse --git-path rebase-apply)" ]; then
echo "DROPPED_FILE_CHECK_ERROR: the rebase is still in progress. Finish it first." >&2
exit 1
fi
LIST=$(mktemp)
# --no-renames: a rename is a delete plus an add on both sides, independent of rename detection.
if ! git diff --numstat -z --no-renames "$BASE...$BACKUP" > "$LIST"; then
echo "DROPPED_FILE_CHECK_ERROR: could not read the PR's file list from $BACKUP against $BASE: STOP." >&2
rm -f "$LIST"
exit 1
fi
LOST=0
# One NUL-terminated "added<TAB>deleted<TAB>path" record per file; a path may hold tabs or newlines.
while IFS= read -r -d '' rec; do
added=${rec%%$'\t'*}; rest=${rec#*$'\t'}; deleted=${rest%%$'\t'*}; file=${rest#*$'\t'}
case "$added$deleted" in
''|*[!0-9-]*) echo "UNPARSEABLE: a numstat record from $BACKUP has no line counts" >&2; LOST=1; continue ;;
esac
# Nothing the PR wrote was lost when the rebased file is byte-identical to the backup's.
git --literal-pathspecs diff --quiet "$BACKUP" HEAD -- "$file" && continue
now=$(git --literal-pathspecs diff --numstat --no-renames "$BASE...HEAD" -- "$file")
if [ -z "$now" ]; then
# Left the diff. That is legitimate only when the base already carries the PR's exact version of the file
# (both sides deleted counts: both absent compares equal).
if ! git --literal-pathspecs diff --quiet "$BASE" "$BACKUP" -- "$file"; then
echo "DROPPED: $file is in the PR before the rebase, is absent after it, and the base's version differs" >&2
LOST=1
fi
else
read -r now_added now_deleted _ <<< "$now"
# A binary side (numstat "-" before or after the rebase) has no line counts: presence only, above.
if [ "$added" != "-" ] && [ "$now_added" != "-" ]; then
before=$((added + deleted))
after=$((now_added + now_deleted))
# Sharp shrink: under half the changed lines, and at least 5 lines lost.
if [ $((after * 2)) -lt "$before" ] && [ $((before - after)) -ge 5 ]; then
echo "SHRUNK: $file had $before changed lines before the rebase and has $after after it" >&2
LOST=1
fi
fi
fi
done < "$LIST"
rm -f "$LIST"
if [ "$LOST" -ne 0 ]; then
echo "DROPPED_FILE_CHECK_FAIL: the rebase lost PR content. Do not push: follow step 6 (reset to the backup ref) and hand off." >&2
exit 1
fi
echo "DROPPED_FILE_CHECK_PASS"Act on the marker it prints. DROPPED_FILE_CHECK_PASS on stdout: continue to
step 5. Anything else, including no output at all, is a failure: do not try
to repair the file here, follow step 6 and point the user at
the sova-address-pr skill <PR_NUMBER>. One false alarm remains possible: when the base
landed part of the PR's change itself and the rebased file still differs
from the backup's, SHRUNK can report lines the base now carries. Say so in
the handoff when the missing lines are visibly in the base.
- When all commits are replayed, run the project's linter and tests (see CLAUDE.md). The rebase pulled in new base-branch code, so a green result from before the rebase means nothing.
- On any failure:
- Unresolvable conflict or attempt budget exhausted (rebase still in
progress):
git rebase --abort - Tests failing, or the dropped-file check failing, after the rebase
completed (steps 4b and 5, no rebase in progress:
git rebase --aborthere reports nothing to abort and leaves the rebased commits in place):git reset --hard "refs/integrate-backup/<PR_NUMBER>"
git update-ref -d "refs/integrate-backup/<PR_NUMBER>"), stop, and report the conflicting files (for a step 4b failure, theDROPPED/SHRUNKfiles it named), pointing the user at thesova-address-prskill <PR_NUMBER>. Never leave the worktree mid-rebase. - Unresolvable conflict or attempt budget exhausted (rebase still in
progress):
Before pushing, fold in any stale documentation for free. This push is already required, so capturing documentation now costs nothing extra: run the Phase 3 check (steps 1-2 below) right here, before this push goes out. Any later amend would only rewrite the local commit and never reach the PR head that Phase 5 merges.
If the check finds something stale, stage it now (do not commit or push it separately). The loop skips paths absent from both the worktree and the index, and paths the project gitignores:
git addexits non-zero on either, and some projects keep.claude/local-only, so their memory and rules never belong in a commit. Tracked entries are staged unconditionally first (git check-ignoreon a directory can report it ignored even though it has tracked descendants, which would otherwise skip their deletions too), and the ignore check applies only to staging new, untracked files:for p in .claude/agent-memory/ AGENTS.md README.md docs/ .claude/rules/; do [ -n "$(git ls-files -- "$p")" ] && git add -u -- "$p" { [ -e "$p" ] && ! git check-ignore -q "$p"; } && git add -A -- "$p" doneThen amend anything staged into this commit and push once:
COMMON_DIR=$(git rev-parse --git-common-dir 2>/dev/null) case "$COMMON_DIR" in /*) PRIMARY_ROOT="${COMMON_DIR%/.git}" ;; *) PRIMARY_ROOT="$(git rev-parse --show-toplevel)" ;; esac if ! git diff --cached --quiet; then git commit --amend --no-edit fi git push --force-with-lease && echo 1 > "$PRIMARY_ROOT/.claude/agent-control/integrate-pushed" \ && git update-ref -d "refs/integrate-backup/<PR_NUMBER>"Stop if the push fails (branch protection, permissions) and report.
Phase 3 is then a no-op: its state-file check below sees
1and skips entirely, since the check already ran here, before this push.- Return to the primary checkout before continuing, whether or not step 1
cd'd into a worktree. Every later phase assumes it is running from the primary checkout; the Update subroutine is the one place in this command that changes that:COMMON_DIR=$(git rev-parse --git-common-dir 2>/dev/null) case "$COMMON_DIR" in /*) PRIMARY_ROOT="${COMMON_DIR%/.git}" ;; *) PRIMARY_ROOT="$(git rev-parse --show-toplevel)" ;; esac cd "$PRIMARY_ROOT"
Phase 2b: Silent-Revert Trial Merge Gate (hard-blocking)
A textually clean merge can still silently drop work: a branch forked before a later base-branch commit can carry a stale copy of a file, and landing it overwrites the newer content without a single conflict marker. mergeable: CLEAN only means git's merge did not stop. This gate reproduces locally, at zero CI cost, what Phase 5 will land on the current base tip, and runs the silent-revert guard against exactly that. It runs after Phase 2 whether or not the Update subroutine ran.
The reproduction follows the merge method Phase 5 will use (merge_method from the config loader; for auto, the methods the repository allows) and the shape of the PR:
- Rebase replay, only when the method is certainly
rebase(configured, orautoon a repository that allows nothing else) and the PR contains no merge commits. The PR's commits are replayed one by one in a throwaway worktree and the guard judges each replayed commit under its own subject's scope, exactly as they will land. - Merged result, in every other case: squash or merge-commit methods, an
automethod that could be either, or any PR containing merge commits. A branch that merged its base in to resolve a conflict keeps that resolution in the merge commit, so replaying its linear commits conflicts even though GitHub merges it cleanly and keeps reportingMERGEABLE/CLEAN; judging that replay would block the PR forever. Instead the gate computes the single merged tree (git merge-tree --write-tree, git 2.38 or later), wraps it in one commit on top of the base whose message is the PR title (so a scope-based guard reads the conventional-commit scope, as it would on a squash commit) followed by everysilent-revert-ok:line from the PR's commit bodies (so declared exemptions still apply), and runs the guard on that one commit. A PR that legitimately deletes across several scopes names them all in its title (feat(core,imports): ...) or carries the guard's justification trailer. On a git older than 2.38 the gate falls back to the rebase replay and says so.
This gate is hard-blocking, with no override flag: these reverts happen invisibly, so a bypass would defeat it. The only path past a real violation is fixing the branch (drop or amend the offending commit, or, when the cross-scope deletion is intentional, add the guard's justification trailer such as silent-revert-ok: <reason>) and letting the sova-pr skill re-push.
Run the whole gate as one block; the subshell's trap removes the trial worktree, the trial ref and the guard copy on every exit path:
echo "::sova-phase::revert-gate"
(
PR=<PR_NUMBER>
GUARD_PATH="${SILENT_REVERT_GUARD:-invariants/silent-revert-guard.sh}"
COMMON_DIR=$(git rev-parse --git-common-dir 2>/dev/null)
case "$COMMON_DIR" in /*) PRIMARY_ROOT="${COMMON_DIR%/.git}" ;; *) PRIMARY_ROOT="$(git rev-parse --show-toplevel)" ;; esac
cd "$PRIMARY_ROOT" || exit 1
BASE=$(gh pr view "$PR" --json baseRefName --jq .baseRefName)
[ -n "$BASE" ] || { echo "GATE_ERROR: cannot resolve the base branch"; exit 1; }
git fetch origin "$BASE" || { echo "GATE_ERROR: fetch of origin/$BASE failed"; exit 1; }
if ! git show "origin/$BASE:$GUARD_PATH" 2>/dev/null | grep -q SILENT_REVERT_RANGE; then
echo "GATE_SKIPPED: no silent-revert guard honouring SILENT_REVERT_RANGE at origin/$BASE:$GUARD_PATH"
exit 0
fi
TRIAL_REF="refs/pr-trial/$PR"
GUARD=$(mktemp)
TRIAL=$(mktemp -d)
trap 'git -C "$TRIAL" rebase --abort 2>/dev/null; git worktree remove --force "$TRIAL" 2>/dev/null;
rm -rf "$TRIAL" "$GUARD"; git update-ref -d "$TRIAL_REF" 2>/dev/null; git worktree prune' EXIT
git show "origin/$BASE:$GUARD_PATH" > "$GUARD"
# The PR ref works for same-repo and fork PRs alike; a fork's branch is not in origin.
git fetch origin "+refs/pull/$PR/head:$TRIAL_REF" || { echo "GATE_ERROR: cannot fetch the PR head"; exit 1; }
HEAD_OID=$(gh pr view "$PR" --json headRefOid --jq .headRefOid)
if [ "$(git rev-parse "$TRIAL_REF")" != "$HEAD_OID" ]; then
echo "GATE_ERROR: fetched PR head is not headRefOid $HEAD_OID yet (a push just landed); retry shortly"
exit 1
fi
# Reproduce what Phase 5 will land. Only a certain rebase merge of a linear PR
# is replayed commit by commit; everything else is judged as one merged result.
METHOD=$(python3 -c "from pathlib import Path; from sova.config.loader import load_config; \
print(load_config(Path('.')).integration.merge_method)" 2>/dev/null)
[ -n "$METHOD" ] || METHOD=auto
if [ "$METHOD" = auto ]; then
ALLOWED=$(gh repo view --json rebaseMergeAllowed,squashMergeAllowed,mergeCommitAllowed \
--jq '"\(.rebaseMergeAllowed) \(.squashMergeAllowed) \(.mergeCommitAllowed)"' 2>/dev/null)
[ "$ALLOWED" = "true false false" ] && METHOD=rebase
fi
REPLAY=merged
if [ "$METHOD" = rebase ] && [ -z "$(git rev-list --merges "origin/$BASE..$TRIAL_REF")" ]; then
REPLAY=rebase
fi
GIT_VER=$(git version | sed -E 's/^git version ([0-9]+)\.([0-9]+).*/\1 \2/')
GIT_MAJOR=${GIT_VER% *}; GIT_MINOR=${GIT_VER#* }
if [ "$REPLAY" = merged ] && { [ "$GIT_MAJOR" -lt 2 ] || { [ "$GIT_MAJOR" -eq 2 ] && [ "$GIT_MINOR" -lt 38 ]; }; }; then
echo "NOTE: git $GIT_MAJOR.$GIT_MINOR has no merge-tree --write-tree (needs 2.38+); falling back to the rebase replay, which can conflict where the real $METHOD merge would not"
REPLAY=rebase-fallback
fi
echo "Merge method: $METHOD; replay: $REPLAY"
if [ "$REPLAY" = merged ]; then
MERGE_OUT=$(git merge-tree --write-tree "origin/$BASE" "$TRIAL_REF")
RC=$?
if [ "$RC" -eq 1 ]; then
echo "GATE_CONFLICT: merging the PR head into origin/$BASE conflicts"
exit 2
fi
[ "$RC" -eq 0 ] || { echo "GATE_ERROR: git merge-tree failed with exit $RC"; exit 1; }
TITLE=$(gh pr view "$PR" --json title --jq .title)
[ -n "$TITLE" ] || { echo "GATE_ERROR: cannot read the PR title"; exit 1; }
EXEMPT=$(git log --format=%B "origin/$BASE..$TRIAL_REF" | grep -i '^[[:space:]]*silent-revert-ok:')
if [ -n "$EXEMPT" ]; then set -- -m "$TITLE" -m "$EXEMPT"; else set -- -m "$TITLE"; fi
TRIAL_COMMIT=$(GIT_AUTHOR_NAME=integrate-pr GIT_AUTHOR_EMAIL=integrate-pr@localhost \
GIT_COMMITTER_NAME=integrate-pr GIT_COMMITTER_EMAIL=integrate-pr@localhost \
git commit-tree "$(printf '%s\n' "$MERGE_OUT" | head -1)" -p "origin/$BASE" "$@") \
|| { echo "GATE_ERROR: cannot build the trial merge commit"; exit 1; }
DIR="$PRIMARY_ROOT"
RANGE="origin/$BASE..$TRIAL_COMMIT"
else
git worktree add --detach "$TRIAL" "$TRIAL_REF" >/dev/null || { echo "GATE_ERROR: cannot create the trial worktree"; exit 1; }
if ! git -C "$TRIAL" rebase "origin/$BASE" >/dev/null 2>&1; then
echo "GATE_REPLAY_CONFLICT: replaying the PR's commits one by one onto origin/$BASE conflicts"
exit 2
fi
DIR="$TRIAL"
RANGE="origin/$BASE..HEAD"
fi
# Negative control: a guard that passes an unresolvable range is not live.
if SILENT_REVERT_RANGE="0000000000000000000000000000000000000000..HEAD" \
bash "$GUARD" "$DIR" "$BASE" >/dev/null 2>&1; then
echo "GUARD_NOT_LIVE: the guard exited 0 on an unresolvable range"
exit 3
fi
if SILENT_REVERT_RANGE="$RANGE" bash "$GUARD" "$DIR" "$BASE"; then
echo "GATE_PASS $HEAD_OID"
else
echo "GATE_FAIL"
exit 4
fi
)Act on the marker it prints:
GATE_PASS <sha>orGATE_SKIPPED: continue to Phase 3. Record which one, and the replay line printed above it, for the Phase 7 report, and keep the gated<sha>: Phase 5 merges only that exact head.GATE_CONFLICT(merged result): the PR head and the base tip conflict as a merge, which GitHub did not report in Phase 2, so the base branch almost certainly moved since then. Do not resolve it here. Stop and report; re-running thesova-integrate-prskill <PR_NUMBER> re-assesses mergeability, nowCONFLICTING, and routes through the Update subroutine.GATE_REPLAY_CONFLICT(rebase replay): the commits do not replay cleanly onto the base, although the PR may well merge cleanly as a whole: GitHub computesmergeable/CLEANfor a merge, not a rebase, so do not report the PR as unmergeable and do not suggest simply re-running, which would hit the same replay. When the replay line saysrebase, the rebase merge Phase 5 would perform fails on these same commits. Stop and report the remedy: rebase<HEAD_BRANCH>ontoorigin/<BASE_BRANCH>, resolve the conflicts, and push, then re-run thesova-integrate-prskill <PR_NUMBER>; the rewritten commits replay cleanly. When the replay line saysrebase-fallback, the conflict may be an artefact of the old git rather than of the merge Phase 5 would perform: upgrading git to 2.38 or later and re-running judges the merged result instead, and the rebase-and-push remedy also works.GUARD_NOT_LIVE: STOP. The base branch's guard passes anything, so neither this gate nor the Phase 6 audit can be trusted. Report it as a defect in the guard itself.GATE_FAIL: STOP. Do not proceed to Phase 3, 4 or 5. Report the guard's output verbatim (commit, file, line counts, scope) and the fix described above. On the merged-result path the flagged commit is the synthetic trial commit, whose subject is the PR title: if the title's scope is too narrow for a legitimate cross-scope change, widening the title's scope is a fix as valid as the justification trailer.GATE_ERROR: STOP and report the error; nothing was checked.
Phase 3: Documentation and Knowledge (verify; do not push on its own)
Knowledge capture and documentation freshness belong to the sova-address-pr skill and the sova-review skill, which run while the branch is already being pushed. This phase only verifies that they did their job. It must never create a head SHA of its own: a documentation amend on an otherwise-ready PR buys nothing and costs a full CI cycle.
Skip this phase entirely if Phase 2 already pushed (the state file reads 1): the Update subroutine's step 7 already ran this exact check before that push, and a second run risks a confusing amend after the push has gone out. Read the state file from the primary checkout:
echo "::sova-phase::docs"
COMMON_DIR=$(git rev-parse --git-common-dir 2>/dev/null)
case "$COMMON_DIR" in /*) PRIMARY_ROOT="${COMMON_DIR%/.git}" ;; *) PRIMARY_ROOT="$(git rev-parse --show-toplevel)" ;; esac
PUSHED=$(cat "$PRIMARY_ROOT/.claude/agent-control/integrate-pushed" 2>/dev/null || echo 0)
echo "PUSHED=$PUSHED"Only run the rest of this phase if .claude/agent-memory/ exists in the
project AND PUSHED is 0.
- Check for uncaptured review learnings: fetch review data from the PR
(
gh pr view,gh api repos/.../pulls/<N>/comments,gh api repos/.../pulls/<N>/reviews). Enumerate every review thread from the whole PR history, resolved and unresolved, and check whether the actionable ones are already reflected in the primary checkout's cookbook (cookbook/domain files when split, otherwisecookbook.md). When.claude/agent-memory/is gitignored, missing cookbook entries need no queue or push: write them straight to$PRIMARY_ROOT/.claude/agent-memory/and queue only tracked documentation below. - Check documentation counts: run the verification commands (test count,
service count, router count) and compare against
AGENTS.md,README.md, and the project's vision or roadmap doc, if AGENTS.md or README.md links one. Act on the result:
- Nothing stale: continue to Phase 4. This is the expected outcome when
the
sova-address-prskill ran properly. - Stale: Phase 2 did not push, so there is no push to ride for free. Do
NOT amend and do NOT push. Queue the content for the next branch in the
PRIMARY checkout, so it survives this PR's worktree cleanup: run
mkdir -p "$PRIMARY_ROOT/.claude/agent-control"(resolvingPRIMARY_ROOTas above), then append the following block to$PRIMARY_ROOT/.claude/agent-control/pending-docs.mdwith a file-editing tool, not a shell heredoc (agent-generated content may contain a line reading exactlyEOF, which would end a heredoc early and run the rest).
the## From PR #<PR_NUMBER> (<DATE>) <the rule promotions and count corrections for tracked files, verbatim>sova-address-prskill drains this queue at the start of its next run, resolving the primary checkout the same way, and folds the content into that branch's own commits, which are pushed anyway. Report the queued items in Phase 7. - Override: if
the arguments provided when this skill is invokedcontains--with-docs, the user has explicitly accepted the extra CI cycle. Amend and push directly, then re-run Phase 2b (the head SHA changed):COMMON_DIR=$(git rev-parse --git-common-dir 2>/dev/null) case "$COMMON_DIR" in /*) PRIMARY_ROOT="${COMMON_DIR%/.git}" ;; *) PRIMARY_ROOT="$(git rev-parse --show-toplevel)" ;; esac for p in .claude/agent-memory/ AGENTS.md README.md docs/ .claude/rules/; do [ -n "$(git ls-files -- "$p")" ] && git add -u -- "$p" { [ -e "$p" ] && ! git check-ignore -q "$p"; } && git add -A -- "$p" done if ! git diff --cached --quiet; then git commit --amend --no-edit git push --force-with-lease && echo 1 > "$PRIMARY_ROOT/.claude/agent-control/integrate-pushed" fi
- Nothing stale: continue to Phase 4. This is the expected outcome when
the
Phase 4: Wait for CI
Fast path (skip the wait entirely). This is the expected path. Nothing was
pushed unless Phase 2 had to update the branch, so the PR head SHA is unchanged
and any CI that already ran is still valid. When the push-tracking state file
still reads 0, confirm the existing checks are green and proceed straight to
Phase 5 without polling:
echo "::sova-phase::ci-wait"
COMMON_DIR=$(git rev-parse --git-common-dir 2>/dev/null)
case "$COMMON_DIR" in /*) PRIMARY_ROOT="${COMMON_DIR%/.git}" ;; *) PRIMARY_ROOT="$(git rev-parse --show-toplevel)" ;; esac
# Default to 0 if the state file is missing (defensive: never assume a push).
PUSHED=$(cat "$PRIMARY_ROOT/.claude/agent-control/integrate-pushed" 2>/dev/null || echo 0)
if [ "$PUSHED" -eq 0 ]; then
# `gh pr checks` exits non-zero (exit 8) while checks are pending but still
# writes valid JSON, so never append a fallback document with `||`: keep the
# real output and treat empty or invalid JSON as non-green.
# Any failed lookup below leaves the fast path unmet; the poll then decides.
BASE=$(gh pr view <PR_NUMBER> --json baseRefName --jq .baseRefName 2>/dev/null) || BASE=
FP_ERR=$(mktemp) || BASE=
# Required names: rulesets (every page); a plan without rulesets counts as none. Classic branch protection
# is not read (admin-only endpoint). gh prints the error on stderr and the body on stdout: match both.
if [ -n "$BASE" ] \
&& { RULES_JSON=$(gh api --paginate "repos/{owner}/{repo}/rules/branches/$BASE?per_page=100" 2>"$FP_ERR") \
|| { [[ $RULES_JSON$(cat "$FP_ERR") == *"Upgrade to GitHub Pro"* ]] && RULES_JSON="[]"; }; } \
&& REQUIRED_JSON=$(jq -sce '
if length == 0 or any(.[]; type != "array") then error("not a rule list") else
[.[][] | select(.type == "required_status_checks") | (.parameters.required_status_checks // [])[].context] | unique end' \
<<<"$RULES_JSON" 2>/dev/null); then
CHECKS_JSON=$(gh pr checks <PR_NUMBER> --json name,bucket 2>/dev/null)
# Names GitHub reports as required join the ruleset names (a check only classic protection requires still
# decides). An unreadable lookup leaves DECIDE_JSON and STATS empty, so the fast path stays unmet.
GH_REQ_OUT=$(gh pr checks <PR_NUMBER> --required --json name 2>"$FP_ERR")
if jq -e 'type == "array"' >/dev/null 2>&1 <<<"$GH_REQ_OUT"; then GH_REQ_JSON=$(jq -c '[.[].name]' <<<"$GH_REQ_OUT")
elif grep -q "no required checks reported\|no checks reported" "$FP_ERR"; then GH_REQ_JSON="[]"
else GH_REQ_JSON=; fi
DECIDE_JSON=
[ -z "$GH_REQ_JSON" ] || DECIDE_JSON=$(jq -nc --argjson a "$REQUIRED_JSON" --argjson b "$GH_REQ_JSON" '$a + $b | unique' 2>/dev/null) || DECIDE_JSON=
# TOTAL: every check. PENDING, FAILED: deciding names only (all when none). MISSING: deciding names not registered.
STATS=$(jq -r --argjson req "${DECIDE_JSON:-null}" '
(if ($req | length) == 0 then . else map(select(.name as $n | any($req[]; . == $n))) end) as $c
| [length, ($c | map(select(.bucket == "pending")) | length),
($c | map(select(.bucket == "fail" or .bucket == "cancel")) | length),
($req - map(.name) | length)] | @tsv' <<<"$CHECKS_JSON" 2>/dev/null) || STATS=
IFS=$'\t' read -r TOTAL PENDING FAILED MISSING <<<"$STATS"
# Require at least one check: zero checks is not a green fast path, poll instead.
if [ "${TOTAL:-0}" -gt 0 ] && [ "${PENDING:-1}" -eq 0 ] && [ "${FAILED:-1}" -eq 0 ] && [ "${MISSING:-1}" -eq 0 ]; then
echo "No re-push: existing CI is complete and green. Skipping the poll."
fi
fi
[ -z "${FP_ERR:-}" ] || rm -f "$FP_ERR"
fiIf it printed "Skipping the poll", go to Phase 5. Otherwise (a push happened,
or checks are still pending, failed or absent), fall through to the poll below.
When the base branch has required checks (rulesets, every page of gh api --paginate repos/{owner}/{repo}/rules/branches/<base>,
plus every name gh pr checks <PR_NUMBER> --required reports, which covers classic protection),
the poll decides on the union of those names: one not yet registered blocks a pass, and non-required checks such as
review bots (e.g., CodeRabbit) neither block nor fail it, and zero checks is never NO_CHECKS. Classic branch
protection is not read for names directly (its endpoint needs the admin right, which a push-only account lacks),
so a classic-only required check is known only once it has registered. With none required (a repository relying on
classic protection alone with nothing registered, or a private repository whose plan has no rulesets)
it covers every check. Requires gh CLI v2.32+ (for the bucket field).
Run the poll as a synchronous foreground command and wait for it to finish. Do NOT launch it as a background task and then stop to "wait" for a notification: headless mode has no mechanism to resume you when a background task completes, and ending your turn without a tool call terminates the run, leaving the PR unmerged. One call polls at most 16 x 30s (8 minutes), which exceeds the Bash tool's 2 minute default timeout, so pass an explicit timeout of at least 540000 ms (never above the tool's 600000 ms cap). If more waiting is needed, repeat the same block as a new, separate call rather than lengthening one loop past the cap.
# bucket (not state) normalizes raw states into: pass, fail, pending, skipping, cancel.
# Grace period: the first 5 iterations tolerate TOTAL=0 while checks register after a push.
BASE=$(gh pr view <PR_NUMBER> --json baseRefName --jq .baseRefName) || { echo "Unable to resolve the PR base branch" >&2; exit 1; }
# Required names: rulesets (every page: --paginate prints one JSON array per page); a plan without rulesets is no names.
# Classic branch protection is not read (admin-only endpoint).
ERR_FILE=$(mktemp) || { echo "Unable to create a temp file" >&2; exit 1; }
trap 'rm -f "$ERR_FILE"' EXIT
RULES_JSON=$(gh api --paginate "repos/{owner}/{repo}/rules/branches/$BASE?per_page=100" 2>"$ERR_FILE") \
|| { [[ $RULES_JSON$(cat "$ERR_FILE") == *"Upgrade to GitHub Pro"* ]] && RULES_JSON="[]"; } \
|| { echo "Unable to read the required checks of $BASE: $(cat "$ERR_FILE")" >&2; exit 1; }
REQUIRED_JSON=$(jq -sce '
if length == 0 or any(.[]; type != "array") then error("not a rule list") else
[.[][] | select(.type == "required_status_checks") | (.parameters.required_status_checks // [])[].context] | unique end' \
<<<"$RULES_JSON" 2>/dev/null) || { echo "Unable to parse the required checks of $BASE" >&2; exit 1; }
for i in $(seq 1 16); do
echo "CI poll attempt $i/16"
CHECKS_JSON=$(gh pr checks <PR_NUMBER> --json name,bucket 2>/dev/null)
[ -n "$CHECKS_JSON" ] || CHECKS_JSON="[]"
echo "$CHECKS_JSON" | jq -r '.[] | "\(.bucket)\t\(.name)"'
# Names GitHub itself reports as required (a ruleset or classic protection) join the ruleset names: a check
# only classic protection requires still decides. "no required checks reported" means none.
GH_REQ_OUT=$(gh pr checks <PR_NUMBER> --required --json name 2>"$ERR_FILE")
if jq -e 'type == "array"' >/dev/null 2>&1 <<<"$GH_REQ_OUT"; then GH_REQ_JSON=$(jq -c '[.[].name]' <<<"$GH_REQ_OUT")
elif grep -q "no required checks reported\|no checks reported" "$ERR_FILE"; then GH_REQ_JSON="[]"
else echo "Unable to read the required checks of PR <PR_NUMBER>: $(cat "$ERR_FILE")" >&2; exit 1; fi
DECIDE_JSON=$(jq -nc --argjson a "$REQUIRED_JSON" --argjson b "$GH_REQ_JSON" '$a + $b | unique')
# TOTAL: every check. PENDING, FAILED, DECIDING: the deciding names only (all when none). MISSING: deciding names not registered.
STATS=$(jq -r --argjson req "$DECIDE_JSON" '
(if ($req | length) == 0 then . else map(select(.name as $n | any($req[]; . == $n))) end) as $c
| [length, ($c | map(select(.bucket == "pending")) | length),
($c | map(select(.bucket == "fail" or .bucket == "cancel")) | length),
($req - map(.name) | length), ($c | length)] | @tsv' <<<"$CHECKS_JSON" 2>/dev/null) || { echo "Failed to parse CI check status"; break; }
IFS=$'\t' read -r TOTAL PENDING FAILED MISSING DECIDING <<< "$STATS"
if [ "$TOTAL" -eq 0 ] && [ "$MISSING" -eq 0 ]; then
if [ "$i" -lt 5 ]; then
echo "No checks registered yet (grace period $i/5)"
sleep 30
continue
fi
echo "NO_CHECKS: no CI checks configured (grace period expired)"
break
fi
if [ "$PENDING" -eq 0 ]; then
if [ "$FAILED" -gt 0 ]; then echo "CI FAILED: $FAILED check(s) failed"; break; fi
if [ "$MISSING" -eq 0 ]; then
if [ "$DECIDE_JSON" = "[]" ]; then echo "CI PASSED: all $TOTAL checks passed"
else echo "CI PASSED: all $DECIDING required checks passed or were skipped ($TOTAL checks total)"; fi
break
fi
fi
if [ "$i" -eq 16 ]; then
MSG="CI TIMEOUT: checks still pending after 8 minutes"
[ "$MISSING" -eq 0 ] || MSG="$MSG; $MISSING required not registered: $(jq -r --argjson req "$DECIDE_JSON" '$req - map(.name) | join(", ")' <<<"$CHECKS_JSON")"
echo "$MSG"
break
fi
sleep 30
doneAct on the result:
- CI PASSED: also verify that no blocking
CHANGES_REQUESTEDreview remains (gh pr view <PR_NUMBER> --json reviewDecision;gh pr checksmonitors CI status only, not review decisions).- If
reviewDecisionisCHANGES_REQUESTED: stop. Never merge while a review (bot or human, including CodeRabbit) is requesting changes; treat it as a real reviewer with its own address-review cycle. Write a handoff pointing at thesova-address-prskill <PR_NUMBER> and report that integration is blocked on unaddressed review feedback. - Otherwise, proceed to Phase 5.
- If
- NO_CHECKS (no CI checks configured): proceed to Phase 5. No checks means nothing to wait for.
- CI FAILED: gather failure evidence, trying progressively looser sources until one actually produces text, then analyze it briefly:
- Project hook: prefer the project's own
.claude/scripts/ci-failures.shhook when it exists and is executable (mirroring theprepush-check.shpattern in thesova-prskill workflow). It takes the PR number as its only argument and prints failure text to stdout; a non-zero exit or empty stdout means "no evidence from this source".if [ -x .claude/scripts/ci-failures.sh ]; then .claude/scripts/ci-failures.sh <PR_NUMBER> fi - GitHub Actions log: when the hook gave nothing, and a failing check's
linkpoints at a GitHub Actions run, read that run's own failed-step log directly. Every job in the same workflow run shares one run id, so run ids are deduplicated before fetching and each log is capped, mirroringget_ci_failure_logs()insova/git/pr.py:FAILED=$(gh pr checks <PR_NUMBER> --json name,bucket,link \ --jq '.[] | select(.bucket == "fail" or .bucket == "cancel") | "\(.name)\t\(.link)"') || true printf '%s\n' "$FAILED" | cut -f2 | sed -n 's#.*/actions/runs/\([0-9]\{1,\}\).*#\1#p' | sort -u | \ while read -r RUN_ID; do [ -n "$RUN_ID" ] || continue echo "=== run $RUN_ID ===" gh run view "$RUN_ID" --log-failed | tail -c 8000 done printf '%s\n' "$FAILED" | while IFS=$'\t' read -r NAME LINK; do [ -n "$NAME" ] || continue case "$LINK" in */actions/runs/*) ;; # already covered by the run-id loop above *) echo "$NAME: $LINK (not a GitHub Actions run, no log available from this source)" ;; esac donegh pr checksexits non-zero by design whenever any check is failing, so the|| trueabove is expected and does not mean the capture failed; only an empty$FAILEDmeans no evidence came from this call.
- Retry only after reading actual failure text from (1) or (2) and confirming an infrastructure or flaky issue (network timeouts, resource limits, unrelated tests): post a retry comment and re-run the polling block once more. On second failure, stop and report the diagnosis.
- If neither (1) nor (2) produced any text (only a check name and link from
$FAILED), stop. Report the failed check and its link. Do not retry or re-run the polling block. - For real code issues, stop and report the diagnosis with failing check details.
- Project hook: prefer the project's own
- The poll stops with
Unable to(resolve the base branch, read or parse its required checks, read the PR's required checks): aghfailure, never a pass. Stop and report it; do not proceed to Phase 5. - CI TIMEOUT: re-run the polling block once more as a separate call. If it times out again, stop and report, naming any required check the verdict lists as not registered. The user can re-run the
sova-integrate-prskill after CI completes.
Phase 5: Merge
Resolve the merge settings through the config loader (they are not in the sova config table):
echo "::sova-phase::merge"
python3 -c "from pathlib import Path; from sova.config.loader import load_config; \
i=load_config(Path('.')).integration; print(i.merge_method, i.delete_branch, i.post_merge_state)"merge_method: "auto" (repo default), "squash", "rebase", or "merge"delete_branch: true/false (default true)merge_queue_enabled: "auto" (detect via GraphQL), "true", "false"post_merge_state: "done" (close issue) or "on_qa" (add label, keep open), applied in Phase 6 only to the issues the PR body closes
Merge queue detection: using the base branch from Phase 1, query the GraphQL API to check if a merge queue is configured.
If merge queue is detected:
- Omit merge strategy flags (queue controls strategy)
- Omit
--delete-branch(handled after queue processing) - Run:
gh pr merge <PR_NUMBER> - If enqueued, write a merge queue marker file in the primary checkout so the dashboard can track the PR:
COMMON_DIR=$(git rev-parse --git-common-dir 2>/dev/null) case "$COMMON_DIR" in /*) PRIMARY_ROOT="${COMMON_DIR%/.git}" ;; *) PRIMARY_ROOT="$(git rev-parse --show-toplevel)" ;; esac mkdir -p "$PRIMARY_ROOT/.claude/agent-control" python3 -c "import json; print(json.dumps({'pr_number': <PR_NUMBER>, 'repo': '<OWNER/REPO>', 'issue_number': '<ISSUE_NUMBER>', 'branch_name': '<HEAD_BRANCH>'}))" > "$PRIMARY_ROOT/.claude/agent-control/merge-queue-<PR_NUMBER>.json" - Then poll merge queue status via GraphQL every
merge_queue_poll_intervalseconds (default 30), under the same foreground and timeout rules as the Phase 4 poll - On MERGED: proceed to Phase 6. If
delete_branch = true, delete remote branch via GitHub API - On UNMERGEABLE: report ejection and stop
- On TIMEOUT: report the PR is still enqueued, stop
If merge queue is NOT detected:
gh pr merge <PR_NUMBER> [--squash|--rebase|--merge] [--delete-branch] [--match-head-commit <GATED_SHA>]If merge_method is "auto", omit strategy flags to use the GitHub repo default. Otherwise use the configured method. Only include --delete-branch when delete_branch = true. When Phase 2b printed GATE_PASS <sha>, pass --match-head-commit <sha> so GitHub refuses the merge if the head moved after the gate ran; on that refusal, re-run the sova-integrate-pr skill <PR_NUMBER> from Phase 2.
Stop if merge fails: report the error (usually merge conflicts, branch protection, or required reviews). Recover by cause:
- "the base branch policy prohibits the merge": a repository ruleset blocks CLI merges outright, and every merge method fails the same way even when the PR is approved and green. Do not retry with a different method. Ask the user to merge with the GitHub UI button, then re-run the
sova-integrate-prskill <PR_NUMBER>; Phase 1 sees the PR asMERGEDand continues with cleanup. - Head changed (
--match-head-commitrefusal): re-run thesova-integrate-prskill <PR_NUMBER> from Phase 2, as above. Do not send the user to the UI: the new head has not passed the gate. - Conflicts, pending required reviews or checks: report what blocks the merge; it must be resolved before any merge path, CLI or UI, can succeed.
Phase 6: Post-Merge Cleanup (incorporates the sova-after-merge skill)
Return to the primary checkout first: the Update subroutine may have cd'd into a per-issue worktree, where git checkout <BASE_BRANCH> fails (git refuses to check out a branch that is checked out elsewhere).
echo "::sova-phase::cleanup"
COMMON_DIR=$(git rev-parse --git-common-dir 2>/dev/null)
case "$COMMON_DIR" in
/*) cd "${COMMON_DIR%/.git}" ;; # linked worktree: return to primary checkout
*) ;; # already primary
esac
git checkout <BASE_BRANCH>
git pull origin <BASE_BRANCH>Post-merge silent-revert audit (non-skippable backstop). Phase 2b gates every merge this command performs itself, but a PR can also arrive here already MERGED (Phase 1's skip-ahead path) where Phase 2b never ran. This audit runs the guard against what the merge actually landed, resolved from GitHub's own merge record rather than local pre/post-pull tips (which break when local state was stale before an out-of-band merge), and derived from the landed commit's shape:
- Merge commit (two parents): guards skip merge commits, and a conflict resolution lives only in one, so the merge's first-parent diff is wrapped in a single commit (titled with the PR title plus the PR's
silent-revert-ok:lines, dated as the merge) and judged as one, exactly as Phase 2b judges a merged result. - One PR commit: whether rebased or squashed, exactly one commit landed.
- Rebase merge: the landed window's subjects match the PR's commits in order.
- Squash merge: the landed subject ends with
(#<PR_NUMBER>), it has one parent, and none of the PR's commit subjects appear among the commits just before it. - Anything else (a rebase merge that dropped an empty commit or linearized one of the PR's merge commits lands fewer commits than the PR lists, which shifts the window): the wider
MERGE_SHA~COUNT..MERGE_SHAwindow, with a note. Over-auditing is safe; a silently narrowed audit is not.
(
PR=<PR_NUMBER>
GUARD_PATH="${SILENT_REVERT_GUARD:-invariants/silent-revert-guard.sh}"
BASE=$(gh pr view "$PR" --json baseRefName --jq .baseRefName)
git fetch origin "$BASE" || { echo "AUDIT_UNRESOLVED: fetch of origin/$BASE failed; verify by hand before cleanup"; exit 1; }
if ! git show "origin/$BASE:$GUARD_PATH" 2>/dev/null | grep -q SILENT_REVERT_RANGE; then
echo "AUDIT_SKIPPED: no silent-revert guard honouring SILENT_REVERT_RANGE at origin/$BASE:$GUARD_PATH"
exit 0
fi
MERGE_SHA=$(gh pr view "$PR" --json mergeCommit --jq '.mergeCommit.oid // empty')
COUNT=$(gh pr view "$PR" --json commits --jq '.commits | length')
if [ -z "$MERGE_SHA" ] || [ "${COUNT:-0}" -lt 1 ] || ! git cat-file -e "${MERGE_SHA}^{commit}" 2>/dev/null; then
echo "AUDIT_UNRESOLVED: cannot resolve merge SHA/commit count; verify by hand before cleanup"
exit 1
fi
PARENTS=$(( $(git rev-list --parents -n 1 "$MERGE_SHA" | wc -w) - 1 ))
if [ "$PARENTS" -ge 2 ]; then
TITLE=$(gh pr view "$PR" --json title --jq .title)
[ -n "$TITLE" ] || { echo "AUDIT_UNRESOLVED: cannot read the PR title; verify by hand before cleanup"; exit 1; }
EXEMPT=$(git log --format=%B "${MERGE_SHA}^1..${MERGE_SHA}^2" | grep -i '^[[:space:]]*silent-revert-ok:')
if [ -n "$EXEMPT" ]; then set -- -m "$TITLE" -m "$EXEMPT"; else set -- -m "$TITLE"; fi
MERGE_DATE=$(git log -1 --format=%cI "$MERGE_SHA")
AUDIT_COMMIT=$(GIT_AUTHOR_NAME=integrate-pr GIT_AUTHOR_EMAIL=integrate-pr@localhost GIT_AUTHOR_DATE="$MERGE_DATE" \
GIT_COMMITTER_NAME=integrate-pr GIT_COMMITTER_EMAIL=integrate-pr@localhost GIT_COMMITTER_DATE="$MERGE_DATE" \
git commit-tree "${MERGE_SHA}^{tree}" -p "${MERGE_SHA}^1" "$@") \
|| { echo "AUDIT_UNRESOLVED: cannot build the first-parent audit commit; verify by hand before cleanup"; exit 1; }
RANGE="${MERGE_SHA}^1..${AUDIT_COMMIT}"; HOW="merge commit, judged as its first-parent diff"
elif [ "$COUNT" -eq 1 ]; then
RANGE="${MERGE_SHA}~1..${MERGE_SHA}"; HOW="single-commit PR"
else
if ! git rev-parse -q --verify "${MERGE_SHA}~${COUNT}" >/dev/null; then
echo "AUDIT_UNRESOLVED: ${MERGE_SHA}~${COUNT} does not exist; verify by hand before cleanup"
exit 1
fi
# Compare the first 60 characters of each subject only.
PR_SUBJECTS=$(gh pr view "$PR" --json commits --jq '.commits[].messageHeadline' | cut -c1-60)
LANDED=$(git log --reverse --format=%s "${MERGE_SHA}~${COUNT}..${MERGE_SHA}" | cut -c1-60)
PRIOR=$(git log --format=%s -n "$COUNT" "${MERGE_SHA}~1" | cut -c1-60)
PR_COMMIT_ON_BASE=0
while IFS= read -r SUBJECT; do
[ -n "$SUBJECT" ] && printf '%s\n' "$PRIOR" | grep -qFx -- "$SUBJECT" && PR_COMMIT_ON_BASE=1
done <<< "$PR_SUBJECTS"
case "$(git log -1 --format=%s "$MERGE_SHA")" in *"(#$PR)") PR_SUFFIX=1 ;; *) PR_SUFFIX=0 ;; esac
if [ "$PR_SUBJECTS" = "$LANDED" ]; then
RANGE="${MERGE_SHA}~${COUNT}..${MERGE_SHA}"; HOW="rebase merge"
elif [ "$PR_SUFFIX" -eq 1 ] && [ "$PR_COMMIT_ON_BASE" -eq 0 ]; then
RANGE="${MERGE_SHA}~1..${MERGE_SHA}"; HOW="squash merge"
else
RANGE="${MERGE_SHA}~${COUNT}..${MERGE_SHA}"; HOW="unclassified landing, widest window"
echo "NOTE: cannot tell how PR #$PR's $COUNT commit(s) landed; auditing the whole $COUNT-commit window, which may include other PRs' commits. Judge any flag on a commit that is not this PR's on its own merits."
fi
fi
echo "Auditing $RANGE ($HOW, $(git rev-list --count "$RANGE") commit(s))"
GUARD=$(mktemp)
trap 'rm -f "$GUARD"' EXIT
git show "origin/$BASE:$GUARD_PATH" > "$GUARD"
if SILENT_REVERT_RANGE="0000000000000000000000000000000000000000..HEAD" bash "$GUARD" . "$BASE" >/dev/null 2>&1; then
echo "GUARD_NOT_LIVE: the guard exited 0 on an unresolvable range"
exit 3
fi
if SILENT_REVERT_RANGE="$RANGE" bash "$GUARD" . "$BASE"; then echo "AUDIT_PASS"; else echo "AUDIT_FAIL"; exit 4; fi
)Unless it printed AUDIT_PASS or AUDIT_SKIPPED, stop here. Do not delete branches, remove worktrees or close the issue: they may be needed to investigate. For AUDIT_UNRESOLVED and GUARD_NOT_LIVE, report that the merge was not audited and must be checked by hand. For AUDIT_FAIL, never auto-revert (picking a last known-good SHA per flagged file is a judgment call): report the guard's output verbatim and hand the operator the restore recipe for each flagged file, to be applied on a fresh branch and PR:
git log --oneline -- <file> # find the last known-good commit
git show <good-sha>:<file> > <file> # restore its contentIf the audit passed or was skipped, continue. Remove worktrees for this PR or issue first, since git refuses to delete a branch a worktree still has checked out. Do not pass --force: if a worktree has uncommitted changes, leave it in place and report it rather than discarding someone's work.
git worktree list
git worktree remove <WORKTREE_PATH>
git worktree pruneThen delete the merged branch, locally and on the remote:
BASE=<BASE_BRANCH>
# Local branch, if it still exists. After a rebase or squash merge, `-d`
# refuses with "not fully merged" even though the content landed.
if git show-ref --verify --quiet "refs/heads/<HEAD_BRANCH>"; then
git branch -d <HEAD_BRANCH> || git diff --stat "origin/$BASE" <HEAD_BRANCH>
fi
# Delete remote branch if delete_branch = true (fallback: Phase 5 may have
# already handled this via --delete-branch, but covers merge-queue path and
# already-merged PRs where Phase 5 is skipped). Skip for a fork PR: HEAD_BRANCH
# names a branch, not a repository, and `origin` is the base repository, so
# deleting <HEAD_BRANCH> there could remove an unrelated branch that happens
# to share the fork contributor's branch name.
IS_FORK=$(gh pr view <PR_NUMBER> --json isCrossRepository --jq '.isCrossRepository')
if [ "$IS_FORK" != "true" ]; then
if git ls-remote --exit-code --heads origin <HEAD_BRANCH> >/dev/null; then
git push origin --delete <HEAD_BRANCH>
fi
fiIf -d refuses ("not fully merged", expected after a rebase merge), confirm the diff against the base is empty (BASE=$(gh pr view <PR_NUMBER> --json baseRefName --jq .baseRefName); git diff "origin/$BASE" <branch-name>), then git branch -D <branch-name>. If the diff is non-empty only because the base branch moved on after the merge, compare against the merge commit instead (git diff <MERGE_SHA> <branch-name>); if that is not empty either, keep the branch and report it.
Handle the linked issues based on post_merge_state from [integration] config.
GitHub projects (task_source is github, the default): the merged PR's body decides which issues the merge finishes, never <ISSUE_NUMBER>. Run:
sova linked-issues <PR_NUMBER> --applyIt acts on each issue the body links and prints one line per issue:
- Closing keyword (
Closes #N,Fixes #N,Resolves #Nor another GitHub form): the issue moves topost_merge_state. "done" (default) closes it; "on_qa" adds theagent:on-qalabel and keeps it open; any other value leaves it as it is and is reported as not updated. Part of #N: the issue stays open, whateverpost_merge_statesays. It gets one comment naming this PR and itsNot done in this PR:bullets (else the issue's unchecked criteria), once. It also loses theagent:in-review,agent:in-progress,sova:reviseandsova:blocklabels unless another open PR links it.- A bare
#N: left alone.
A non-zero exit means the PR body could not be read or an issue could not be updated. Report the output and carry on with the rest of this phase, but never close or relabel an issue by hand instead: closing a Part of issue is the bug this step exists to prevent.
Jira projects (task_source.type = "jira"):
The Jira connection settings resolve through the config loader as cfg.task_source.jira_base_url, jira_email, jira_api_token, and jira_project_key. Read them inside the script that calls Jira; never print jira_api_token to the terminal or into a log. Use the Jira REST API to transition the issue:
- "done": trigger a Jira workflow transition matching "Done", "Closed", "Resolved", or "Close"
- "on_qa": trigger a Jira workflow transition matching "On QA", "QA", "Verification", or "Ready for QA". Also add the
agent:on-qalabel. - Check
cfg.task_source.jira_state_transitionsfor custom transition name overrides (e.g.,on_qa = "Move to QA"takes priority over the defaults) - If no matching transition is available on the Jira board, log a warning and skip
Check for stale stashes that belong to the merged branch:
git stash listIf any stash entries reference the merged branch name, report them to the user (do not drop without confirmation).
Run the full issue-aware GC to clean up any remaining stale worktrees and branches across the project:
sova cleanup --all --project <PROJECT_DIR>sova cleanup --all only removes a worktree once its own issue is confirmed closed on GitHub, no agent is actively using it, and its working tree is clean, so it will not touch other issues' in-progress work.
Phase 7: Report
echo "::sova-phase::report"Note: this command makes no post-merge commits. Documentation and knowledge are captured by the sova-address-pr skill on the branch; anything Phase 3 found missing was queued for the next branch rather than pushed.
Output a concise summary covering:
- PR number, title, and base branch it was merged into
- Whether a rebase was needed, and if so which conflicts were resolved
- Whether anything was pushed, and why (a push means a CI cycle was spent, so it must be accounted for)
- Silent-revert checks: the Phase 2b and Phase 6 results (pass, or skipped because no guard honouring an explicit range exists at the path checked), which replay Phase 2b used (rebase, merged, or rebase-fallback), and how Phase 6 classified the landing, including any wide-window note
- CI status (passed, retried, or skipped because nothing was re-pushed)
- Branches cleaned up (local + remote), and any worktree or branch kept and why
- Linked issues: each line
sova linked-issues --applyprinted (closed or moved to on_qa, left open asPart of, not updated), or no linked issue - Documentation and knowledge: "already captured" or the items queued to
.claude/agent-control/pending-docs.mdfor the next branch - Stale stashes found (if any)
- Manual post-merge actions (omit if none apply), derived from
gh pr diff <PR_NUMBER> --name-only:- service or unit files changed (systemd units, launchd plists, cron entries): they need install, reload and restart on the host, because the running copy is not the repo copy
- services deployed outside the main pipeline: they need a manual rebuild and redeploy
- new keys in env templates (
*.env.exampleand similar): the host env files need updating - for any infrastructure change, the check that verifies the outcome on the host itself, since a merged PR is not proof the outcome is live
Error Recovery
When the pipeline stops at any phase, report clearly:
- Which phase failed
- The specific error
- What to do next (resolve conflicts, fix CI, retry, etc.)
The user can fix the issue and re-run the sova-integrate-pr skill <PR_NUMBER> to resume. The command is idempotent: it detects the current state and picks up from where it left off (e.g., if already rebased, it skips to CI; if already merged, it skips to cleanup and the post-merge audit).
Cross-References
- Replaces: the
sova-after-mergeskill (cleanup), built into Phase 6 - Knowledge capture happens upstream: the
sova-address-prskill folds review learnings and documentation into the branch's own commits. This command only verifies and, if something was missed, queues it for the next branch. - Before this: the
sova-review-fullskill or thesova-address-prskill to prepare the PR - Next: the
sova-find-taskskill or thesova-standupskill to pick up the next task
Rules
- Never stop between phases unless there is a hard failure
- Never push unless GitHub refuses to merge the PR as it stands. The only justification is
mergeable: CONFLICTING(orBEHINDunder a strict status-check policy). Documentation and doc-count drift are never a reason to push from this command: queue them for the next branch instead. - Never skip Phase 2b or the Phase 6 audit when the project has a guard. Both are hard-blocking with no override flag, even under
--with-docs. A guard that passes the negative control is not live: stop. - Never background a poll loop and stop to wait for a notification (see the Phase 4 note on foreground calls and timeouts)
- Use the merge method from
[integration]config (default: auto, uses GitHub repo default) - Handle merge queue when detected or configured
- Use
--force-with-leasefor pushes, never--force - Never swallow a failed branch or worktree deletion with
|| true: report what was kept and why - Only record actionable, specific lessons in memory, not generic advice
- Do not duplicate existing memory entries
- NEVER use emojis in any output
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

