Generated from
cat-harness/skills/sdlc/sdlc-core/prepare-merge.md— do not edit here.
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
- Clean + committed.
git status --shortmust be empty. Commit outstanding work first (the container is ephemeral — uncommitted work is lost on resume). - Fetch the base + your branch (retry on network error, backoff 2/4/8/16s):
git fetch origin <base> <branch>. - Integrate if the base moved. Compare
git merge-base HEAD origin/<base>togit 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 intest/attestations/is a judgement and has none. See §”Conflicts intest/results/andtest/attestations/” below before resolving either by hand.
Merge with
bun run cat merge:main, never a plaingit mergefollowed 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 plaingit mergestops on those same generated files, one by one (measured 2026-10-06, beanxpcu).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 withbun 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 ismerge-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 localbun run cat gatesorcheck:merged. An authored conflict, or a merge that touched code, still owes both. - 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-onlyto 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” (bean0s6w). - Capture
$?on the line after the command, before anything else runs —git merge-tree … >/dev/null 2>&1; rc=$?, orcmd || 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.tsfails 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
rccaused 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-treeanswers “do the files conflict?”, not “do the gates pass on the result?”. Before asking for a merge, runbun run cat check:merged: it builds the merge with the current base in a throwaway worktree and runs the fullbun run cat gatesthere (exit 0 passes, 1 fails, 2 could not determine — never read as clean). Beannytj: 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:mergedis the agent’s half.
Green check. Run the project’s tests/build (here:
bun test, plusbun build <file> --target=bunfor 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 insidebun 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 (beanieum). 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 buildreaches 853 of 1618 Lean modules, so a touched file can be green purely by never having been compiled.For
contentType: paperbranches that touch content blocks, also run theblock_pdf_rendergate:bun run scripts/render-changed-blocks.ts [--upload-drive]This compiles a standalone PDF for every changed block and scrapes the LaTeX log;
--upload-driveadditionally 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 locallatexmk, 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, tabledecisions/review-coverage-gate.dmn) reads the block counts below and, where the preview published arendered-impact.json, three page counts (unreviewed pages, undetermined inputs, missed pages; seerendered-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
defectstillopenoraddressed, from the preview’sreview-comments.json; - changed blocks with neither a verdict nor a waiver on their current version.
Both come from one command, the
folio-review-coverageTool (beanpx0t), 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’sinvoke, 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 noreview-comments.json, or one with noverdictsfield, 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”.
- open defects: review comments of kind
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
3pqnandyv4z, six observations between 2026-09-19 and 2026-09-20 — this said “three times” until 2026-09-24, which is3pqn’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.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 theactions_run_triggerMCP 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: writeand 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’slean_ci.yml— was deleted in that repo’s workflows migration, and an agent following the old text went looking for it, found nothing, and guessedbuild.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.ymlandprobe-float64-gate.yml. All of them, and every other workflow in that repo, areworkflow_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>andpython3 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.- 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:
| where | bun test failures |
|---|---|
| GitHub Actions | 7 |
a clean git worktree of that exact sha, full suite | 8 |
| the two disagreeing files, run alone | 0 |
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 (bean1xhc). - “The gate set passed.” Only if you ran the gate set. A subset chosen by
hand is chosen from memory: 2026-09-27,
tsc,eslintand a dozen namedcheck:*scripts all passed and CI went red twice — first oncheck:partition(a new script must be classified inscripts/partition/instance-rules.ts), then onno check script is unrun(a newcheck:*needs aSCRIPT_EXEMPTIONSentry when no workflow runs it — or, for a script owned by an instance above cat-harness, agateExemptions.scriptsrow in that instance’s<instance>.json, bean0r7u). Neither is visible to any of the three.bun run cat gatesderives 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_requestworkflows 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.
9v4mis that shape, andsff8measured 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:
| side | change to docs/assets/voices/index.json |
|---|---|
| main | added tile.voices.count — a projection declares its own count |
| the branch | added 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:
- Early in the session,
git submodule update --init bootstrap-toolschecked out the submodule at the branch’s pointer (c5e5e254). git merge origin/mainbrought in a NEWER pointer (30464126). Git updates the gitlink in the index but not the submodule’s working checkout, so the checkout still sat atc5e5e254.- During conflict resolution,
git add -Astaged that stale checkout as the submodule’s state, rolling both submodules back in the merge commit. bun run cat regenthen ran against the old tools and “repaired” ~130 generated files to their pre-bump form, stripping the generator bannersmainhad just added. CI went red on a typecheck error (generatedBanneris 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)):
| tree | holds | durable home | on a conflict |
|---|---|---|---|
<instance>/test/results/** | DERIVED verdicts — every script entry | the orphan qa-reports branch, main/<sha>/ and pr/<n>/<sha>/, written by the CI job qa-publish | regenerate: bun run cat qa:resolve-conflicts, then bun run cat regen |
<instance>/test/attestations/** | JUDGEMENTS — every agent, human and baseline-pair attestation, qa-attestations/v1 | main, as authored content | a 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
scriptentries and 13agentones (11block-qa/v1, 2translation-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
mainat all (owner, 2026-10-01). When CI is green on every job: mark the PR Ready for review, add the labelready-to-merge, and commentready: <head sha>. The Merge Steward session (“Separation”) bringsmainin, regenerates and merges. If it reports red: fix, then re-label. The policy and why are incontinual-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--forcewhen 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:
Read
mergeable_stateFIRST — and believe it only when it saysdirty.cleanandunknownare not evidence here:fx5rmeasured the PR endpoint serving a merged PR’s pre-merge view for 45 minutes, andunknownmeans GitHub has not finished computing it (h2s9).dirtyis 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 ison: pull_request:withactions/checkout@v4and noref:, so it checks outrefs/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 reachedmain27 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. Beanyv4z, 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: aworkflow_dispatchrun resolvesrefs/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.- Read the PR’s
head_sha. mcp__github__actions_list→list_workflow_runs, filtered by branch, and find a run whosehead_shamatches.- 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_workflowagainst the branch. That is the workaround every one of the six needed; it is written here rather than left as folklore (3pqnDone-when 3). - 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-healthandtoc_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-branchor 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.mdtoo — 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
| process | step(s) that name it |
|---|---|
| Actor and role administration | Branch, gates, PR and review (calls a sub-process) |
| CRDM Phase 6 — implement, MVP, acceptance | Phase 6: Implement (feature branch + PR) |
| Code change and review | Prepare the merge, and watch it through (calls a sub-process) |
| Merge the base branch in | Merge the base in, without committing; Regenerate, asking every CI gate; Resolve by hand, then regenerate |
| A refused merge-train member | Fix the PR, report on the PR |
| A merge train | Merge each member onto the base (calls a sub-process) |
| Adopting an upstream version bump | Move the pin and open the PR |
| Content Change and Review | Merge, on explicit confirmation |