Generated from cat-harness/skills/sdlc/sdlc-core/prepare-merge.md — do not edit here.

✎ Edit this page’s source

Prepare-merge — get a feature branch ready to land

Canonical, repo-agnostic skill for taking a claude/* (or any feature) branch to a clean, conflict-free, green, pushed state so it can be merged with no surprises. This is the generic source of truth; downstream repos (e.g. qou) vendor or sync it rather than hand-maintaining their own copy.

“Prepare-merge” ≠ “merge”. It makes the branch mergeable and stops. It does not push to the default branch and does not merge a PR. Both are outward-facing, hard-to-reverse actions — do them only on an explicit request. The merge itself is its own sub-process with its own gate, merge-to-main: evidence on the head, the owner’s authorisation for THAT merge, recorded, then the merge. Asking for prepare-merge (or “ship it”) is not that authorisation.

The problem this solves

“prepare-merge” / “make it mergeable” / “ship it” is a plain-English ask, not a built-in command. Without a codified recipe each agent re-derives the steps and may do them inconsistently or unsafely (force-push over a sibling, push straight to main, declare green while sitting on pre-existing failures). This skill is the one recipe.

fsh-guts must not reach publication

Before a branch that touches the knowledge graph ships, the published export must contain no reference to fsh-guts — not the graph typology, not the declared directory, not the skill that documents it, and not an edge naming any of them. Owner’s rule, 2026-09-19.

scripts/tests/fsh-guts-unpublished.test.ts asserts it against the built artefact and runs in bun test, so this is checked rather than remembered. The mechanism, and the three separate emitters that had to be filtered, are in kg-export §”fsh-guts NEVER reaches a published graph”.

Recipe

  1. Clean + committed. git status --short must be empty. Commit outstanding work first (the container is ephemeral — uncommitted work is lost on resume).
  2. Fetch the base + your branch (retry on network error, backoff 2/4/8/16s): git fetch origin <base> <branch>.
  3. Integrate if the base moved. Compare git merge-base HEAD origin/<base> to git rev-parse origin/<base>:
    • Equal → base hasn’t moved; nothing to do (your branch is a clean fast-forward).
    • Different → rebase onto the base: git rebase origin/<base> (preferred for a linear history) or merge it in. Resolve conflicts, then re-run the green check. A rebase needs a force-push with lease: git push --force-with-lease — never a bare --force (it clobbers sibling pushes). A conflict in a generated QA sidecar has a command — bun run cat qa:resolve-conflicts. A conflict in test/attestations/ is a judgement and has none. See §”Conflicts in test/results/ and test/attestations/” below before resolving either by hand.

    Merge with bun run cat merge:main, never a plain git merge followed by regen. It resolves every conflict a declared pattern covers (glossaries, translated glossaries, UML, viewer pages, site data, QA sidecars), refuses the rest with the tree restored, and runs regen on the result. A plain git merge stops on those same generated files, one by one (measured 2026-10-06, bean xpcu).

    And after EVERY base merge, conflicted or not, run bun run cat regen. A clean merge is not evidence that the generated artefacts are right — see §”A clean merge can produce a wrong artefact” below. If you merged with bun run cat merge:main, its regen already was that run — do not run a second one. For a merge whose conflicts were all generated, the round is merge-conflict-patterns §”A merge round — run each check ONCE”: targeted checks and a push, with CI’s sharded run as the full gate set in place of a local bun run cat gates or check:merged. An authored conflict, or a merge that touched code, still owes both.

  4. Prove it merges cleanly (no assumptions):
    • git merge-base --is-ancestor origin/<base> HEAD → success means a clean fast-forward: git fast-forwards without running a merge, so conflicts are impossible. This alone is sufficient.
    • Otherwise (base moved, not yet rebased) dry-run with the modern form and trust its exit code AND its first line: git merge-tree --write-tree origin/<base> HEAD (exit 0 = clean; non-zero = not clean; add --name-only to list the conflicted paths). Exit 1 means CONFLICT only when stdout opens with the merged tree’s id — git 2.43 also exits 1 for a ref it cannot merge (unfetched, mistyped), printing no tree, and that is “could not determine”, never “conflicted with nothing in conflict” (bean 0s6w).
    • Capture $? on the line after the command, before anything else runs — git merge-tree … >/dev/null 2>&1; rc=$?, or cmd || rc=$?. echo "base($(git rev-parse --short origin/main)) rc=$?" reports the SUBSTITUTION’s status, always 0 when the ref exists, and a steward announced a conflicted PR clean on exactly that line for a whole session (2026-10-03). if ! cmd; then rc=$? is the same trap: inside that branch $? is ! cmd’s status, 0. shell-exit-status.test.ts fails on either shape in a tracked script or workflow.
    • GitHub’s HTTP 405 “Pull Request has merge conflicts” is a backstop, not a check. It refuses a conflicted merge whatever the client believed, so a mis-read rc caused no bad merge on 2026-10-03 — but it is learned at merge time, after the PR was announced clean, and nothing in that sequence was the steward’s own verification. Do not grep the old three-arg output for <<<<<<< / “changed in both” — that false-positives on files which legitimately contain those literals (docs about merge conflicts, test fixtures — this very skill tripped that check when it was first run).
    • A clean text merge is not a clean state. merge-tree answers “do the files conflict?”, not “do the gates pass on the result?”. Before asking for a merge, run bun run cat check:merged: it builds the merge with the current base in a throwaway worktree and runs the full bun run cat gates there (exit 0 passes, 1 fails, 2 could not determine — never read as clean). Bean nytj: three times on 2026-09-23 a generated measurement was green on the branch, green on the base, merged without a conflict, and stale on the result.
    • The structural fix is GitHub’s merge queue, which tests exactly the commit that will land; the gating workflows carry merge_group: so it works the moment the owner turns it on. Until then, check:merged is the agent’s half.
  5. Green check. Run the project’s tests/build (here: bun test, plus bun build <file> --target=bun for type-checking touched files). Report honestly: distinguish failures you caused from pre-existing ones (diff the counts against a baseline run on the merge-base). Do not call a branch green by silently inheriting red.

    Then run bun run cat security:gate, by name, and quote its result. It runs every security check this repository has (workflow injection, secret leaks, lockfile and toolchain pins, QA reviewer permission, materialised fixity) plus two advisories, and refuses when a blocking check fails OR could not be run. Most of those checks are also inside bun run cat gates; the point of naming them here is that until 2026-10-07 no merge or publish step named any security check, so a release was covered only if the CI run before it happened to be the right one (bean ieum). Its advisories (240 of 240 third-party actions unpinned, dependency advisories) are reported in the PR and are not cleared by a green gate.

    Check the adversarial review verdict with bun run cat check:adversarial-review. On any branch with agent provenance (trailers, claude/* branch, bot committer), verifies that an independent adversarial review verdict exists, binds to the head SHA, and covers the full diff without silent truncation. Under the 2026-10-03 owner ruling, findings are warn-only (“would have blocked”), recording data for gate promotion without holding the merge.

    Know what the target covered. Green is a claim about the files the build compiled, which is usually fewer than the files on disk — build targets default to a root plus its transitive imports. Confirm the files you touched were in it (artifact count vs. source count, or build the module by name), and scope any corpus-wide number you quote to what actually ran. In qou, lake build reaches 853 of 1618 Lean modules, so a touched file can be green purely by never having been compiled.

    For contentType: paper branches that touch content blocks, also run the block_pdf_render gate:

    bun run scripts/render-changed-blocks.ts [--upload-drive]
    

    This compiles a standalone PDF for every changed block and scrapes the LaTeX log; --upload-drive additionally pushes clean PDFs to Google Drive when it is configured. Exits 1 on LaTeX errors (fix required); exits 0 with warnings only (advisory); exits 2 on setup problems — including no local latexmk, which is the common case in a container. Exit 2 means the gate did not run; report it that way rather than folding it into a green, per the same honesty rule as the build above. For a branch that changes a folio’s blocks, report review coverage. content-change-review.bpmn’s coverage gate (GW_Covered, table decisions/review-coverage-gate.dmn) reads the block counts below and, where the preview published a rendered-impact.json, three page counts (unreviewed pages, undetermined inputs, missed pages; see rendered-impact). Report them in the PR body, in the gate’s own terms, and a page count the preview did not publish as “not known”:

    • open defects: review comments of kind defect still open or addressed, from the preview’s review-comments.json;
    • changed blocks with neither a verdict nor a waiver on their current version.

    Both come from one command, the folio-review-coverage Tool (bean px0t), run on the preview’s published files with --changeset changeset.json --blocks blocks.json --comments review-comments.json [--rendered rendered-impact.json --measured rendered-measured.json]. Its command line is the Tool node’s invoke, declared by the content layer that owns the review process; this skill names the Tool, not that layer’s path. Quote its stderr summary. If the preview has no review-comments.json, or one with no verdicts field, write “not measured”, never 0. And never count resolved comments as coverage: a resolved comment is not a reviewer’s verdict on the block.

    This REPORTS; it does not merge or refuse. The gate belongs to the review process, and the committee decides the outcome. A prepare-merge that passed a branch whose coverage it could not measure, without saying so, would read as “reviewed”.

  6. Push the feature branch (retry/backoff as in step 2): git push -u origin <branch>. Then ask whether the push produced a run: bun run cat check:head-has-run.

    This does not change what you do next — step 7 dispatches either way. It changes what you can honestly SAY. A push here can silently produce no run at all (beans 3pqn and yv4z, six observations between 2026-09-19 and 2026-09-20 — this said “three times” until 2026-09-24, which is 3pqn’s original figure and understates how routine it is; §Guardrails below carries them), and a pull request showing zero checks is indistinguishable from one whose checks have not started, so a reviewer cannot tell “CI is coming” from “CI is never coming”. If the check reports no run, write that in the PR body alongside the dispatched run’s URL — otherwise the PR looks like it is merely early.

    Three states, and the third is not a finding: could not ask (no remote, no network, 403, 404) means nothing was established, and it must not be reported as either answer.

  7. Dispatch the repo’s CI on the pushed branch, and fold the result into the PR. Local green is not CI green — CI runs on a cold cache, its own toolchain, and without whatever build flags you set locally. So after the push, trigger the relevant workflow against your branch (gh workflow run <wf>.yml --ref <branch>, or the actions_run_trigger MCP tool), wait for it, and put the run URL and conclusion in the PR body. Red → fix and re-dispatch. Do not open a PR citing a workflow nobody ran.

    If you lack actions: write and dispatch returns 403, do not quietly skip it: state in the PR that CI was not run, and give the exact command a maintainer should run.

    Find the workflow by reading .github/workflows/, not by name. The example this step used to give — qou’s lean_ci.yml — was deleted in that repo’s workflows migration, and an agent following the old text went looking for it, found nothing, and guessed build.yml, which is a publish workflow. Guessing a workflow name is how this gate gets skipped while appearing to run.

    In qou today the Lean-adjacent gates are lean-orphan-gate.yml, lean-content-siblings.yml and probe-float64-gate.yml. All of them, and every other workflow in that repo, are workflow_dispatch-only — an owner directive of 2026-06-30 disabled auto-triggers over Actions billing. So nothing runs on push or PR, and “CI is green” is never true by default; it is only ever true because someone dispatched it.

    Several of those files document a local equivalent in their own header — e.g. bun run pipeline/lean-orphan-audit.ts --diff <changed .lean> and python3 scripts/probe-float64-guard.py --diff <changed .py>. Run those; they are the same check without the dispatch. Read the workflow before assuming there is no local path.

    Why this is a step and not advice: qou’s Lean CI last ran 2026-04-25 and failed on main. Nothing dispatched it for four months, and 37 modules silently stopped compiling — several with plain parse errors, i.e. files that had never compiled at all. The regression was invisible precisely because a workflow existed and no one ran it. Check when CI last ran (actions_list → list_workflow_runs) as part of this step; “there is a workflow” is not evidence anything is being checked, and in a dispatch-only repo it is evidence of the opposite.

  8. Stop here unless a PR / merge was explicitly requested. If a PR was requested, see below.

What a green LOCAL run entitles you to claim

Bean 46l0. The same commit, 3c2523a8071, measured three ways on 2026-09-26:

wherebun test failures
GitHub Actions7
a clean git worktree of that exact sha, full suite8
the two disagreeing files, run alone0

The sets were not nested. CI had two the worktree did not; the worktree had three CI did not. So a green local run is not a weaker version of a green CI run — it is a different measurement, and either can be the one that is wrong.

What it entitles you to say, exactly:

The gates I ran passed, on the tree I ran them on, in this environment.

And what it does not entitle, each with what it costs:

  • “CI will pass.” CI tests refs/pull/N/merge, not your head — a tree neither parent holds. A staleness that exists only in the merge tree cannot be seen from any checkout, however pristine (bean 1xhc).
  • “The gate set passed.” Only if you ran the gate set. A subset chosen by hand is chosen from memory: 2026-09-27, tsc, eslint and a dozen named check:* scripts all passed and CI went red twice — first on check:partition (a new script must be classified in scripts/partition/instance-rules.ts), then on no check script is unrun (a new check:* needs a SCRIPT_EXEMPTIONS entry when no workflow runs it — or, for a script owned by an instance above cat-harness, a gateExemptions.scripts row in that instance’s <instance>.json, bean 0r7u). Neither is visible to any of the three. bun run cat gates derives its list from the workflow, which is why it cannot drift from what CI runs.
  • “Nothing is failing, so nothing is wrong.” A conflicted head produces no merge ref, so pull_request workflows never fire and the PR shows zero check runs — measured the same day. That reads as untested, not as red, and the two look identical if you only glance at the absence of a red mark.
  • “It passed in isolation, so the failure is not mine.” Running the file alone removes the only condition under which it failed. 9v4m is that shape, and sff8 measured a case of it: four tests at 77-82 % of a 5 s budget, which ordinary jitter crossed only under load.

Your environment is part of the claim, and it can now be checked. Re-measured 2026-09-27 at a much later commit: CI 0 failures, normal checkout 0, detached worktree at the same sha with real dependencies 0 — 12231 pass, 56 skip. The 2026-09-26 divergence does not reproduce, so this section is not a standing warning about those five tests; it is about the inference.

The one failure that did appear in that worktree was caused by the measurement itself: node_modules symlinked in, which check:environment named on sight — “the root node_modules is a SYMLINK, so any tool that resolves a real path through it reports a location this repository does not contain” (bean qook). Confirming the symlink is git-ignored is not enough: that addresses corpus scanning, and path resolution is a separate confound. Run bun run check:environment before trusting a run from any hand-built tree, and install dependencies rather than borrowing them.

A clean merge can produce a wrong artefact (STRICT)

bun run cat regen after every base merge. Not only after a conflicted one — after every one. bun run cat merge:main runs it as part of the merge, so after merge:main it has already happened once and a second run re-asks the same tree (4–5 min, measured 2026-10-04).

Measured on main at 3341108a, 2026-09-22, bean lxpq. Two branches changed one committed generated file in NON-OVERLAPPING places:

sidechange to docs/assets/voices/index.json
mainadded tile.voices.count — a projection declares its own count
the branchadded a sixth voice

One touched the header, the other the array. Git merged them with no conflict and produced a projection declaring count: 5 while listing 6 voices — an artefact neither side would ever emit. It reached main.

A conflict is a question. A clean merge is an assertion that the result is correct — and for a generated file that assertion is worth nothing, because git is merging text it has no way to evaluate.

So the rule is not “resolve conflicts carefully”. It is:

After a merge, RE-RUN THE GENERATORS. Never read the diff to decide whether you need to. The merged file looks plausible from either side, which is exactly what let this one through.

bun run cat regen does it by asking each gate first, so its output is the set of artefacts the merge actually broke rather than a wholesale rewrite:

bun run cat regen                   # repair what is stale in the whole gate set
bun run cat regen --fast            # ...only the jobs that install no browser
bun run cat regen --dry-run         # report what is stale, change nothing
bun run cat regen --changed <base>  # ask only the pairs whose inputs changed since <base>
bun run cat regen --explain         # say, per pair, why it was asked or not

The QA working copy is part of the run — do not build it by hand around regen (bean 7how, issue #2319). Generators such as uml:overview and readme:subgraphs read the computed, ignored */test/results/ tree. Before every pass regen asks the copy’s stamp (bun run cat qa:working-copy -- --status) whether it was built from the tree as it stands, and rebuilds it if not. The paths that rebuild changed join the pass’s change set, so any pair that reads them is asked again. A failed build exits 2, and regen never grades a pass over a half-built copy. The recipe that grew around the gap (regen → qa:working-copy → kg:detangle → regen) is therefore one regen, or one merge:main. gates uses the same stamp, so the gates that follows a regen on the same tree builds nothing.

It reports four states, and unrepaired is the one to read: a check that still fails after its writer ran is a real defect, not staleness, and the command exits non-zero rather than claiming a repair it did not make. So is a check with no writer.

--changed asks less, and says what it assumed (bean 94zs). A pair whose declared inputs, script sources and commands meet none of the paths changed since <base> (plus the working tree) is not asked: its answer is its answer at <base>. Undeclared and {tracked} pairs are always asked, as is any pair whose inputs cannot be determined. bun run cat merge:main passes the merge’s fork point, so a skipped pair is one NEITHER side touched, and it is current if the fork point, the branch tip or the base tip was current. merge:main -- --full-regen asks every pair. Separately, every pass after the first asks only the pairs whose inputs the previous pass actually changed (measured from git, not declared). Both cuts are only as good as the task-io.ts declarations — and, for a task owned by an instance above cat-harness, that instance’s own taskIo block in its <instance>.json (bean 0r7u). task-io.ts collects them and refuses a task declared twice, so declare a row where its script lives, never in both places. The whole-tree pairs (kg:audit:all:check, skill:register:check, kg:audit:check) still run after every merge.

Submodules: check the pointers BEFORE you stage the merge (STRICT)

A base merge can roll a submodule back without a conflict, and git add -A is how. Measured on PR #1968, 2026-10-03:

  1. Early in the session, git submodule update --init bootstrap-tools checked out the submodule at the branch’s pointer (c5e5e254).
  2. git merge origin/main brought in a NEWER pointer (30464126). Git updates the gitlink in the index but not the submodule’s working checkout, so the checkout still sat at c5e5e254.
  3. During conflict resolution, git add -A staged that stale checkout as the submodule’s state, rolling both submodules back in the merge commit.
  4. bun run cat regen then ran against the old tools and “repaired” ~130 generated files to their pre-bump form, stripping the generator banners main had just added. CI went red on a typecheck error (generatedBanner is not exported) in code the PR never touched.

Nothing in that sequence conflicted, and every local check that ran against the stale submodule passed. So, after any base merge and before staging:

git submodule update --init --recursive   # move each checkout to the merged pointer
git diff --cached --submodule=short -- bootstrap bootstrap-tools   # must be empty unless YOU bumped it

Then regenerate. Stage paths by name during a merge, never git add -A, and before pushing confirm the merge carries only your change:

git diff --stat origin/<base> HEAD   # should list your files, not hundreds

A diff against the base far larger than your PR is the signal. On #1968 it was 146 files for a 16-file change.

Why it is not qa:resolve-conflicts, and not bean 520m. That command only ever inspects UNMERGED paths, and here there were none; 520m is about generated artefacts that conflict, which is noisy but git stops you. This is the inverse and worse, which is why it is a separate bean with a separate repair.

Why it asks the gates rather than regenerating everything. A blanket regeneration needs a list, and gates.ts already settled where the authority lives — the workflow, not package.json, since 21 of this repository’s :check scripts appear in no workflow at all. It also cannot tell repair from damage: rewriting artefacts that were already correct leaves a diff that says nothing about what the merge broke.

Conflicts in test/results/ and test/attestations/ — regenerate one, read the other

First, what each tree is now (arc 3fva, owner rulings D1 and D2 (a)):

treeholdsdurable homeon a conflict
<instance>/test/results/**DERIVED verdicts — every script entrythe orphan qa-reports branch, main/<sha>/ and pr/<n>/<sha>/, written by the CI job qa-publishregenerate: bun run cat qa:resolve-conflicts, then bun run cat regen
<instance>/test/attestations/**JUDGEMENTS — every agent, human and baseline-pair attestation, qa-attestations/v1main, as authored contenta person reads both sides; never regenerated, never “take ours”

The test/results/ files are still committed on main until bean 5hox removes them, so a base merge can still conflict there. Treat such a file as a working copy that has a right answer, not as a record: the record of a commit is its qa-reports entry, which no merge touches. Do not hand-resolve one and do not regenerate one in order to “commit the result” — regenerate it so the gates see the merged tree, and let qa-publish store it.

A conflict in test/attestations/ is different in kind: it is two judgements that disagree, and qa:attestations:migrate refuses that case for the same reason — reconciling them is a person’s call. qa:resolve-conflicts leaves it untouched and unstaged, because it resolves only under the declared qa graph. Keep both sides’ entries unless they are the same judgement; then run bun run cat qa:attestations:migrate:check, which exits 1 if a judgement is still only in a derived file.

Measured, not impressionistic. Across one working session on PR #773 and its successor: four base merges, four conflicts, every one in a committed generated QA sidecar and none in authored code. Bean 520m.

bun run cat qa:resolve-conflicts             # resolve what is safe, report the rest
bun run cat qa:resolve-conflicts --dry-run   # say what it would do, change nothing
bun run cat qa:resolve-conflicts --explain   # ...and why, per file

Why regenerating is a resolution and not a guess. Both writers are idempotent — bun run cat translation:block-qa and bun run cat kg:audit over an unchanged tree write nothing, because sameScriptVerdict keeps a reproduced entry verbatim and ignores reviewed_at, reviewed_sha and script_commit_sha. So a sidecar is a pure function of the tree, and the merged tree has exactly one correct answer; both sides are stale with respect to it by definition, which is why which side you take does not matter.

That also corrects the first explanation anyone reaches for. These conflicts are not a timestamp both sides restamped — the machinery preventing that already exists and works. Both sides really had changed the inputs. The verdict being unchanged does not make the conflict empty; it makes it trivially resolvable.

The 13. Across all 630 committed sidecars there are 5,883 script entries and 13 agent ones (11 block-qa/v1, 2 translation-qa/v1). Regenerating blindly is right for 5,883 and would silently destroy 13 — two of them in the family that churns most.

Since bean 8wj1 the guard reads the attestation store. Every block and translation writer now takes its non-script entries from test/attestations/ rather than from the file it is rewriting, so a sidecar whose non-script entries are ALL held in the store, byte for byte, regenerates without losing one and is resolved. The command still refuses a file carrying a judgement the store does NOT hold, a store file that is itself conflicted, or a store that cannot be read — a corrupt store reads UNKNOWN, and unknown is never resolved as clean. It checks the same predicate again after regenerating, and leaves every conflict outside the declared qa graph untouched and unstaged. If it refuses one, move the judgement into the store first (bun run cat qa:attestations:migrate, or let a writer move it on its next save) and re-run — do not hand-edit the derived file.

And the habit this guards: a conflict an agent resolves without reading teaches that conflicts in test/results/ are safe to wave through, which is exactly what would wave through the one that is not.

The owner chose the script over a git merge driver, 2026-09-21. A driver needs a git config step in every clone and CI runner, and the people hitting these conflicts are mostly agents in fresh containers — where a setup step nobody ran is a driver that is not there, failing open and silently.

Shipping is not merging — hand over on the PR itself

What happens after the handover is merge-to-main, called by merge-train.bpmn for a queued PR and by crdm-close.bpmn for a CRDM feature.

When the branch is green and pushed, it reaches the Merge Steward only by its own state: label ready-to-merge, a signed ready: <head sha> comment, and no needs-merge-human. merge-queue §”Handing a PR to the queue” has the six points, and when NOT to spend CI keeping up while far back. Never post the handover anywhere but the PR.

Opening the PR (only when asked)

GitHub access here is via the GitHub MCP server, whose tools are deferred (lazy-loaded). At session start they appear as names only — you cannot call them until you load the schema:

ToolSearch  "select:mcp__github__create_pull_request"

Then call mcp__github__create_pull_request with owner, repo, head (the feature branch), base. This “load-then-call” round-trip is by design — not a failure — and is needed once per deferred tool per session. The same applies to every mcp__github__* tool (review, comment, merge, CI status).

PR body content: pr-description, written for a first-time reader. PR body convention: end with the Claude Code footer + session link (see the harness git instructions). Do not include the model identifier in the PR.

Guardrails

  • Never push to the default branch and never merge a PR without an explicit ask. Prepare-merge leaves the decision to the human. When the ask comes, the merge runs merge-to-main; a broad instruction such as “fix all issues until green” is not the ask.
  • In folio-assistant an agent never merges to main at all (owner, 2026-10-01). When CI is green on every job: mark the PR Ready for review, add the label ready-to-merge, and comment ready: <head sha>. The Merge Steward session (“Separation”) brings main in, regenerates and merges. If it reports red: fix, then re-label. The policy and why are in continual-progress §”Green means handed to the Merge Steward”.
  • Check the change against the skills that govern it before calling it ready, an agent’s PR above all. Green CI says the code does what its tests say; it does not say the change keeps the rules the owner set for that area. Read the governing skills (from the mounted layers when the MCP skill server is unreachable) and hold the diff to them: a step that, say, copies the IG Publisher’s finished HTML into a site passes every test and breaks ig-render-jekyll (owner, 2026-10-14). A design choice the PR body describes in passing (“copies every Publisher page the site lacks”) is a question for the owner when a skill already answers it, never an implementation detail to merge past.
  • --force-with-lease, never bare --force when a rebase rewrote history.
  • Honest green. Pre-existing red is reported as pre-existing, with evidence; your-change red blocks the “ready” claim.

  • NO CHECKS IS NOT GREEN — verify against the run list, never the PR page. This is the third state, and it is the one the PR page cannot show you: a PR with zero check runs renders identically to one whose checks have not started. Both are “nothing red”. Bean 3pqn.

    It is not rare and it is not theoretical. Six observations on this repository between 2026-09-19 and 2026-09-20: a push, a PR opened shortly after, and no pull_request-event run ever fires for that head. The newest run on the branch is for the previous head — usually the commit the branch’s last PR had just merged. Three hypotheses have been tested and falsified (a paths filter, a ref-update/opened race, app-token suppression), and the elapsed-time series is flat: 52 s fail, 45 s pass, 30 s fail, 13 s pass, seconds fail, seconds fail. Waiting longer is not the remedy, and neither is force-push avoidance — two of the six were plain fast-forwards.

    So before calling a branch ready, and before any merge:

    1. Read mergeable_state FIRST — and believe it only when it says dirty. clean and unknown are not evidence here: fx5r measured the PR endpoint serving a merged PR’s pre-merge view for 45 minutes, and unknown means GitHub has not finished computing it (h2s9). dirty is the one value worth acting on unilaterally, because its remedy — merging the base in — costs nothing on a PR that did not need it.

      If it is dirty, the PR conflicts with its base and that is very likely why nothing ran: this workflow is on: pull_request: with actions/checkout@v4 and no ref:, so it checks out refs/pull/N/merge — the merge commit GitHub computes between head and base — and a conflicting PR has no such commit. Measured on PR #715, 2026-09-21: the conflicting commit reached main 27 minutes BEFORE the PR was opened, zero runs fired for that head, and CI fired immediately once the base was merged in and the conflict resolved. Bean yv4z, observation seven; stated there as a refinement of the surviving hypothesis rather than as a settled cause.

      When it is dirty, merge the base branch in — do NOT dispatch. This part does not depend on the cause being right: a workflow_dispatch run resolves refs/heads/<branch>, so it tests the branch, not the merge result. On a conflicted PR that is a green signal for a tree that will never exist, which is worse than the absence it replaced. The workaround in step 3 is for a MERGEABLE PR whose run never fired.

    2. Read the PR’s head_sha.
    3. mcp__github__actions_list → list_workflow_runs, filtered by branch, and find a run whose head_sha matches.
    4. No run for that sha → the branch is NOT ready, whatever the PR page shows. Say so in those words rather than “checks pending”, and re-run them with mcp__github__actions_run_trigger → run_workflow against the branch. That is the workaround every one of the six needed; it is written here rather than left as folklore (3pqn Done-when 3).
    5. Re-check after every push. A new commit moves the head, and the head is what the absence attaches to — measured on PR #552, which hit this twice in eight minutes, on two different commits.

    Why this sits under a guardrail rather than in the recipe. An agent holding a standing merge authorisation will read zero checks as nothing-red and merge unverified. That is not a hypothetical either: the session that wrote this rule was operating under exactly such an authorisation when #552 reproduced the bug, and was one step from merging a PR with no coverage at all. The same three-state discipline the rest of this repository applies to check:l1-complete, ci-health and toc_source — could-not-determine is never rendered as clean — has to reach the merge decision too, because that is where it costs the most.

  • One branch. Develop on the assigned claude/* branch; moving work to a different branch needs explicit permission.
  • NEVER DELETE A FEATURE OR STAGING BRANCH without explicit assent from the user or the repository owner. Not after a merge, not as cleanup, not to quiet a tool that complains about it, and never by passing --delete-branch or its API equivalent. A merged branch is still the record of how the work was done, the base a revert or a follow-up is cut from, and the ref a staging deploy is served under; deleting it is irreversible from the agent’s side and buys nothing the owner asked for.

    This is stated in prepare-merge-auto.md too — Phase 4 step 4 and Phase 5 — and it was stated there only, which is how it got broken. AGENTS.md §More, “Shipping a branch”, points at THIS file, so an agent that followed the pointer never met the rule: measured 2026-09-19, a session proposed “deleting the branch after each merge” as a way to stop a stop-hook false positive. If you change one copy, change the other.

    When a tool or hook complains about a branch, fix the tool. The false positive above was a stop hook counting a branch restarted from the default branch as having unpushed work; the answer was a reachability check in the hook, not a deletion.

Processes that run this skill

processstep(s) that name it
Actor and role administrationBranch, gates, PR and review (calls a sub-process)
CRDM Phase 6 — implement, MVP, acceptancePhase 6: Implement (feature branch + PR)
Code change and reviewPrepare the merge, and watch it through (calls a sub-process)
Merge the base branch inMerge the base in, without committing; Regenerate, asking every CI gate; Resolve by hand, then regenerate
A refused merge-train memberFix the PR, report on the PR
A merge trainMerge each member onto the base (calls a sub-process)
Adopting an upstream version bumpMove the pin and open the PR
Content Change and ReviewMerge, on explicit confirmation

folio-assistant — code under the Apache License 2.0, documentation under CC BY 3.0. Generated schema and API references are rebuilt from source on every push to main.

deployed dev