Generated from
cat-harness/skills/sdlc/sdlc-core/merge-queue.md— do not edit here.
Merge queue — what lands next, and as what
processes/sdlc/merge-train.bpmn is the process. The ORDER is computed by
processes/sdlc/decisions/merge-priority.dmn; the facts it reads are derived
by scripts/merge-queue.ts; what the steward decided is recorded against
schemas/merge-queue.ts. This page says what each piece is FOR. Bean hfag.
The rule everything else follows from
The queue stores decisions, never GitHub’s facts. CI status, mergeability,
labels and head SHA change without the queue being told, so a stored copy is
stale the moment it is written and a reader cannot tell which. They are read
live at each step. What a queue entry holds is what the steward DECIDED — the
placement, a hold, an ejection — with who decided it, when, and the evidence
URL it acted on. FORBIDDEN_FACT_KEYS in the schema refuses an entry that
carries a fact.
Handing a PR to the queue — the sibling session’s side (STRICT)
merge-train.bpmn has two lanes that matter here:
sibling-session(“Sibling session”): whoever submits a PR to the queue and fixes it when it is handed back.merge-steward(“Merge steward”): whoever orders and lands the queue.
Both are ROLES, not sessions. Owner, 2026-10-04: “Merge Manager may change”. So nothing in this section depends on which session holds either lane.
The PR is the message
The steward reads open PRs labelled ready-to-merge, and nothing else. A
comment posted anywhere else reaches nobody: not on a PR you believe is the
steward’s, and not on an issue such as #1800. Measured 2026-10-03/04 (bean
j5iq): a merge-order change was posted to #1959 three hours after it
merged, and the pokes on #1800 changed none of the three facts that kept
#1968 out of the queue.
Ask the user before submitting
When you finish your work and are ready to merge, ask the user before submitting to the merge queue. Do not apply ready-to-merge or post the ready: marker autonomously without asking the user if you should submit. Submitting to the queue requests landing the code on main; the user or owner confirms the submission.
Submitting to the queue
To submit, do all of these on YOUR PR:
- Owed CI is green on the head. See §”Admission asks which runs are OWED”.
- The PR is ready for review, not a draft.
- It carries the label
ready-to-merge. - It has a comment
ready: <full head sha>, signed with your session link (https://claude.ai/code/session_…). Every session acts with the owner’s token, so the link is the only thing that tells sessions apart. A_Generated by Claude Code_footer alone is not a signature. - It does not carry
needs-merge-human.merge-main.ymladds that label when it refuses a merge and never removes it. If you have since mergedmainby hand, remove the label, and say why in a comment. - The body has no unticked box, and there is no open question to the owner or the steward.
- An agent-touched PR has an adversarial review on its head, recorded on
the
merge-reviewsstate branch by a session that is not the author (adversarial-code-review). The gate is warn-only for now, so a missing verdict is reported, not refused. A train keeps each PR’s verdict, because the review is per head.
Watch status after submitting — never fire-and-forget
When you submit something to the merge queue, you need to watch what its status is. Submitting is not the end of your turn. Watch the GitHub Actions runs on the head (check:head-has-run), evaluate merge-guard, and monitor whether the PR is admitted into a train or whether it is refused, held, or failing checks. If CI fails, diagnose and repair the failure immediately rather than leaving a failing PR in the queue.
Merge order goes on the PR, and setting an order is the owner’s call.
- Write it in the
ready:comment, on every PR it concerns (“merge before #N”). - Quote the owner’s ruling with its date.
- When two PRs must not land in the wrong order, keep the label OFF the later one until the earlier one lands. A label is the only hold the steward is certain to see.
After submitting: do not spend CI you do not need yet
Owner, 2026-10-04: once CI has confirmed the PR, a submitter far back in the
queue should not burn CI cycles keeping up with a churning main. Every
merge of main into your PR re-runs the whole suite. While other PRs are
landing, each of those runs is obsolete before the steward reaches you.
- The first green is what admits you. Your
ready:marker stays valid acrossmerge-mainbot merges (see “A push makes the marker stale” below), so the steward can still see a PR that is a few merges behind. - Far back, stand still. If your PR is not in the next train or two (the
steward’s status comment on your PR gives your position), do not merge
mainby hand to stay current. Takemerge-mainoff as well: that label makes the bot mergemaininto your PR on every push tomain(§”Your merge cadence is an input to the bot’s throughput”). - Near the front, catch up once. When you are in the next train, or the
steward asks you to:
- put
merge-mainback on, or mergemainby hand (prepare-merge§”Submodules: check the pointers BEFORE you stage the merge”); - let CI go green;
- post a fresh signed
ready:.
- put
- A conflict is the exception. If
mainnow conflicts with your PR, fix it when you see it, wherever you are in the queue. A conflict is work, not churn.
A push makes the marker stale. A head newer than your ready: sha is not
intake, unless every commit since is a merge-main bot merge. Post ready:
again for the new head once CI is green on it. Do not post “ready soon”: it is
not intake, and it costs the steward a read.
The steward’s side: reach a submitter who has fallen behind
While watching the queue, and after every reshuffle or disentangling of trains, the steward checks the PRs near the front. A PR there can be:
- behind
mainand conflicted; - red, or with an owed run missing on its head;
- carrying a stale
ready:; - carrying
needs-merge-human.
When the steward finds one, it tells the submitter. It never fixes the PR on the submitter’s behalf, and never waits in silence. It uses the skills it already has:
- The status comment on the PR, edited in place. It states the position,
what is missing, and by when. Its shape is the one in
merge-conflict-patterns§”When a merge-train member is refused”. - A hand-back bean when the PR is ejected or handed back (same section).
The submitting role picks it up even if the original session is gone.
Every hand-back gets one, not only a train ejection. That covers
Rule_Refused,Rule_HeadNotGreen,Rule_NotReady, and amerge:guardrefusal on a PR the owner asked to land. A PR comment and a queue entry are not a hand-back on their own: the work plan cannot see them.- Shape: titled
Merge refused: #<n> <cause>,type: bug, parented under the PR’s bean’s parent (or under the PR’s bean when it is an epic or feature), marked--blockingthat bean. - Body: the refused checks, roles, the brief (link the takeover plan if there is one), where to report, Done when, and Fails if.
- Dedupe: check first, because
beans creatededupes on nothing. - Process: run it through
processes/sdlc/merge-refusal.bpmn(workflow_start), not from memory.
merge:stewardnow prints✗ handed back with NO open hand-back bean: #…and carrieshandBackBeanper PR in--json.Measured 2026-10-04: a steward handed back a dozen PRs in one session with comments and queue entries only. The owner asked “why did you have a process failure here?”. The procedure lived in another skill (
merge-conflict-patterns) that the steward never loaded, and nothing checked for it. Owner: “Backfill + enforce”. The 13 beans were backfilled (mfhcand twelve more). - Shape: titled
- A direct message as the fast path, by the recipe below. Use it when the PR is next and waiting costs a train.
- The queue entry records the hold or ejection, with its reason and expiry (§”The steward answers in the queue”).
A submitter told “you are next” catches up at once. That is the trade this section makes: CI is spent on the PRs about to land, not on the whole queue.
Address the ROLE, never a session
The Merge Steward is a lane in merge-train.bpmn, not a particular session,
and whoever holds it today may not hold it tomorrow. So the handover must not
depend on who that is:
- no session id written anywhere, since it dies with its container;
- no “they are active on PR #N”, since that PR may merge or the role may move;
- no message as the channel.
Whoever takes the lane next reads the same open PRs and the same queue entries.
A direct message is a fast path on top of that, never a replacement. Owner,
2026-10-04: use one when it is the more efficient tool, and say how you used
it. It saves the receiver a polling cycle, but it never counts as handing
over: the PR’s own state still has to say ready. The same recipe works in
both directions:
- Find whoever holds the other role now. Do not use a remembered session
id.
- Cloud: run
list_sessions(claude-code-remote). Match the session whose title orexternal_metadata.current_branchesshows the lane, or the PR’s head branch when you are looking for a submitter. - Same machine: run
ListAgents.
- Cloud: run
- Check it can receive. For a cloud session, run
get_session(id)and confirmcross_session_inbound == "available". - Send one line. Use
send_message(cloud) orSendMessage(same machine), naming the PR and the head sha. The PR comment stays the record; the message only points at it. - If any step fails, stop. Do not post the message as a comment on some other PR or issue instead.
coordinate §”What actually reaches a sibling” has the
measurements of which tool reaches which kind of session.
The steward answers in the queue, and the queue lives on the beans branch
Once the steward has decided something about your PR (a placement, a hold,
an ejection), that decision is an entry under beans/queue/<pr>.json. The
entry carries pr and beans, the beans and epics the PR serves; name
them in your PR body so the steward can copy them. That list is the
association between a PR and its beans.
Where to read it depends on the state-branch cutover (arc fs43, bean
9ofm). Bean ugxd moves the queue to its OWN branch,
cat/cat-harness/merge-queue, ahead of fs43 — §”Where the queue is GOING”
below — and where the two disagree, that section is the newer plan:
- Before the cutover,
mainis authoritative. Entries reach it only through the steward’s own PRs, a PR cycle late, so the edited-in-place status comment on your PR is the live answer. - After the cutover,
beans/(withqueue/inside it) is authoritative on the orphan branchcat/cat-harness/beans. Read it withbun run cat state:mount. The steward writes an entry the moment it decides, thenbun run cat state:push, which splices only the changed paths onto the tip and stops on a conflicting write instead of overwriting it. The entry is then live for everyone, and the status comment is a courtesy that points at it.
The branch’s manifest.json says which state it is in ("authoritative").
Read that rather than this paragraph.
The cutover also changes what a bean can do here. Today a bean created on
your branch is invisible until your PR lands, so it cannot carry a merge
request. After the cutover, a bean written through state:push is visible at
once. A submitter can then note on the PR’s bean that it is submitted, and the
steward can answer on the same bean. The PR state above stays the intake
signal either way: the bean is where the conversation is kept.
Not GitHub’s merge queue. It is not offered on a personal-account
repository (bean 1hjm). The merge-guard commit status (bean uoob) checks
the six submission points mechanically; once it lands, run it against your own
PR before you announce.
Where the queue is GOING, and the tool that will write it (bean najo)
Not cut over yet. The queue is still on main under beans/queue/
(owner, 2026-10-05: “Land code now, switch later”). The store and CLI below
landed first; the cutover to its own branch, cat/cat-harness/merge-queue, is
bean ugxd, a separate small PR during which the steward pauses queue writes.
Until it lands, merge:queue:read reads the checkout and merge:queue:record refuses — keep committing entries to main as §”The steward answers in the
queue” describes. The branch’s manifest.json says which state it is in.
Why the queue comes off main at all is structural: an entry on main arrives
only through a pull request, and the steward does no development work — it
must not open PRs of its own, which would have it setting its own priority in
the queue it manages, spending the CI the queue is starved of, and judging its
own head. For the two days after the graph was declared it therefore held no
entry at all: the one actor whose decisions it records was the one actor that
could not write to it.
After the cutover:
bun run cat state:mount # the queue on disk at its declared path
bun run cat merge:queue:read # the recorded decisions
bun run cat merge:queue:record --pr <n> --class <c> --rank <r> --rule <id> \
--reason "<why>" --by <your session URL> # ...or --position <n> for an owner override
recordpushes by default, as a splice onto the branch tip carrying the blob each file was read at. So the entry is live for every sibling at once, and a steward who edited the same entry since gets this write stopped as aconflictwith the path named rather than overwritten. On a conflict, re-mount and decide again; nothing was pushed and the local edit is the only copy.--no-pushleaves it in the mount, where nobody else can see it.--reasonand--byare required. A placement nobody can explain cannot be honoured or safely undone by the next steward, and every session here acts with the owner’s token, so the session link is the only thing that says which steward decided.- An unreachable queue is not an empty one.
merge:queue:readexits 4 andreadQueueEntriesTHROWS when the branch is not mounted, because “no decisions recorded, all clear” printed over a graph nobody read isdh4f. Exit 1 — declared-but-absent — is a different answer with a different remedy. - The decisions are read back.
merge:stewardfeeds recorded overrides into the table’sownerOverrideinput and their positions into the order, treats an un-cleared ejection as refused, and prints a live hold. Until 2026-10-04 it passed none of them: a decision nobody reads back is a decision nobody took.
The pairing with workflows/ is what this cost. The queue held the
decisions and a finished train run next door held the evidence they met, in one
store. beans/ is still on main until arc fs43 lands (beans 9ofm,
p3ny), so for now the two are one state:mount apart. That trade was made
knowingly: the pairing was costing the graph every one of its nodes.
Placement is computed, not chosen
GW_Placement is DMN-backed, so workflow_complete refuses a hand-supplied
outcome there. The table (hit policy FIRST) answers a route (admit or
hand back), a class (PRIORITY_CLASSES), a rank and whether the PR
rides alone.
- An owner override is an INPUT (
ownerOverride), never an outcome. The owner places a PR by hand inTask_Override; the schema refuses anoverrideplacement without a reason. - Independence is decided on AUTHORED paths (
authoredPaths,touchesShared). Generated paths are excluded becausemerge-conflict-patternsalready resolves them; counting them would make every pair of PRs conflict. - Ties go to the oldest PR (
orderQueue): overrides at their positions, then rank, then PR number.
Admission asks which runs are OWED, never whether anything is red
A member is admitted on ownCi == "green" and green means every workflow
owed for the head’s event ran and succeeded. It does not mean “nothing is
failing”. The two readings come apart, and the gap is the whole reason this
section exists.
Measured 2026-10-02 on #1889’s head 7ab6119405:
| check runs on the head | 9 |
| gating job NAMES present, of 9 expected | 9 — a complete name set |
runs in_progress with conclusion: null | 3 |
check SUITES completed with conclusion: action_required and latest_check_runs_count: 0 | 3 |
runs whose conclusion is failure | 0 |
So a filter for conclusion == "failure" returns nothing, and three suites
completed having executed nothing at all. Counting the runs does not catch
it either — nine were present and every expected name matched. Only asking
which runs are owed for this event catches it, and
check-head-has-run is the one
thing here that asks (bean 3pqn). Do not write a second checker: it already
keeps the states apart, has a test, and is already wired into
pr-checks-present.bpmn.
Its five states are the five values of ownCi, and collapsing any two is the
defect:
| value | means |
|---|---|
green | every owed run present and successful — the only admitting value |
red | an owed run ran and failed |
missing-required | it HAS runs, but not the ones owed |
none | GitHub answered and nothing names this head |
unknown | could not ask: no network, rate limit, HTTP error |
unknown is handed back, never admitted and never reported as red (bean
0qjq): telling somebody their head is unverified when you merely could not
look trains them to ignore the signal, which costs more than the gap.
This is a gate, not advice. Rule_HeadNotGreen in
merge-priority.dmn
sits immediately after Rule_Refused under hitPolicy="FIRST", so anything
but green routes to hand-back before any admitting rule can fire, and
workflow_gate refuses a hand-supplied outcome.
A dispatch is not an owed run. workflow_dispatch is the designed
workaround for the bot-actor push condition (bean 0qjq, and merge-main.yml
dispatches the gating workflows for exactly that reason), but a dispatched run
is evidence about the TREE, not evidence that the owed pull_request run
exists. The session that wrote this section made that error on #1888 — read
eight dispatched jobs as “green” because the names matched — and
check:head-has-run caught it. Report the two facts separately or the
distinction is lost.
Overlap has a KIND, and a train is the right answer to one of the four
conflictRisk answers is there a collision. A steward needs is it the kind
a train is for, which is overlapKind:
| kind | means | what it needs |
|---|---|---|
none | no path shared with any live member | nothing |
generated-only | shares paths, every one resolved by a declared pattern | regeneration |
shared-declaration | the only authored paths shared are shared declarations | ordering |
authored | shares an authored path that is not a declaration | a train |
Four values rather than three, and the reason is measured rather than tidy. A
shared declaration is a subset of an authored path, not a sibling:
classify("package.json").strategy is refuse, so it is authored, and isSharedDeclaration("package.json") is true (both measured 2026-10-02). A
three-value partition therefore files a declaration-only collision under
authored.
That is not hypothetical. merge:overlap over all 36 open PRs on 2026-10-02
(459 pairs) found #1907 × #1909 independent: false with
authored_overlap = 0 — colliding on package.json and generated regions
only. A pipeline that treats that like a content collision builds trains
nobody needed.
authored outranks shared-declaration, which outranks generated-only,
because at each step the earlier kind is the one the later kind’s remedy
cannot resolve. The partition is tested in
scripts/tests/merge-queue.test.ts
— including that a refused member cannot be the other side of a collision,
since it never enters a train.
A train admits only members that STACK cleanly (STRICT)
Owner ruling, 2026-10-04 ~17:40Z: “require that a merge train has PRs that are stacked one on top of another. You’re wasting too much time resolving conflicts in the train and blocking other stuff from happening.”
A train is a stack: member n+1 merges onto main + members 1…n with
no conflict at all, generated paths included, and with no semantic break
(tsc and the gate set pass on the stack). Check every candidate before
admitting it:
c=origin/main
for p in <members in order>; do
t=$(git merge-tree --write-tree $c pr/$p) || { echo "#$p does not stack"; break; }
c=$(git commit-tree $t -p $c -p pr/$p -m stack)
done
A member that does not stack is not admitted. It does not go in on the
promise that merge:main will resolve it. It goes back to its own session,
which merges main on its own branch, gets CI green, and re-signals. Conflict
resolution belongs on the member’s branch, never in the train, and never in the
steward’s time.
Why, measured on train merge-train-2026-10-04a (#2113): two members with
declared conflicts took about an hour and five pushes to reach green:
- The train tool dropped the gitignored-but-tracked LSI files (bean
u4up). - That drop staled the auto-docs pages.
- Another session landed #2112, and the train conflicted again.
- Re-merging
maintook a 12-minute regen. - A semantic conflict surfaced: #2043 imported a module that #2112 had moved.
While the steward resolved conflicts, nothing else landed. Landing the cleanly stacking PRs one at a time, as #2109, #2078 and #2102 did in the same window, took one guard run each.
The overlapKind table below still says what a collision NEEDS. The steward
now provides only the none row. Every other kind goes back to its owner.
Regenerate on a clean tree, and check it
git status --porcelain is empty before a member is merged and the tree
regenerated. Checked, not assumed, on Task_Admit.
Generated directory READMEs carry a per-subdirectory file count, and the
writer counts the files git would commit —
git ls-files --cached --others --exclude-standard
(bootstrap-tools/scripts/git-files.ts:23). --exclude-standard keeps
ignored junk out, which is the __pycache__ case that drove that line, but
--others still counts an untracked file nobody has ignored. So a transient
written by an earlier train step moves a committed count,
readme:subgraphs:check goes red, and the red belongs to nobody. Train 6
hit this.
The precondition does not make the count stable, and saying so is the point: two
members each adding a file still write different integers to one line. Bean
y7b3 measured 76 of 300 replayed merges conflicting on beans/README.md,
every one on the | defs/ | N files | line — and the class is wider than that
bean states: 54 generated READMEs across 14 instances carry 207 such rows
(measured 2026-10-02). merge:main already resolves them under
readme-generated-regions, so the residual cost falls on merges made by plain
git rather than by the pipeline.
The fix is bean ba9e: take the integer off main into the KG’s _data
layer, which classifies take-base and is therefore already auto-resolved by
merge-conflict-patterns.ts:171. Not -merge and not a merge driver — both
are refused in .gitattributes on measured grounds, the first because it
tidies conflicts without removing them and the second because it needs
per-checkout git config.
A red train ejects one member, not the train
- Retry only a declared-flaky gate, and only once (
Task_RetryFlaky). A deterministic gate retried learns nothing. - Attribute from each member’s own CI first (
Task_Attribute). A member whose own CI is red, or never ran on its head — GitHub runs nopull_requestCI while a PR conflicts — is the prime suspect, at no cost. - Bisect only when every member is green alone (
Task_Bisect). - Eject and hand back with the reason (
Task_Eject, thenmerge-refusal.bpmn). The ejection is written on the member’s entry; the rest re-run without it. The owning session fixes and re-signals ready; it is never merged on its behalf.
A PR lands only through merge:guard (STRICT)
Every merge a steward makes goes through bun run cat merge:guard <pr> --merge
--session <your session id>. Never gh api -X PUT …/pulls/<n>/merge,
never the web button, never merge_pull_request from an MCP tool, and, while a
Merge Manager is active, never by marking a PR ready or labelling it yourself
first (with none active, see §”When no Merge Manager is active”). The script
(cat-harness-tools/scripts/merge-guard.ts, bean uoob, child (f) of nok9) is
the merge: it evaluates eight checks over GitHub’s live facts, and performs
the PUT, pinned to the head it evaluated, only when every one passes.
When no Merge Manager is active, the owning session merges (owner ruling 2026-10-06)
The separation between the session that marks a PR ready and the session that merges it exists only while a Merge Manager is active. Owner, 2026-10-06: “that only applies if there is an active Merge Manager. there isnt one right now”, and “not just for this session”.
With no Merge Manager active, the PR’s own session lands it once the owner has said to merge. It still goes through the guard:
bun run cat merge:guard <pr> --merge --session <your session id> --no-merge-manager
--no-merge-manager lifts one thing: check 2’s refusal of a merging
session that marked the PR ready itself. Every other check still applies,
and so does check 2’s own-session attribution. In particular, the
ready: <head sha> marker signed by this session, ready-to-merge, green
owed CI and a clean merge are all still required. The verdict says the
separation was lifted, so the landing is auditable.
Where the guard cannot run (for example a cloud sandbox whose gh has
no usable token), read its verdict instead. The merge-guard workflow posts
it as the merge-guard commit status on every head. Merge only when that
status on the exact current head is success, and pin the merge to that
sha (expectedHeadSha / sha=), which is the same pin the guard’s own PUT
uses. Say on the PR that the status was read rather than re-run. Any other
state (pending, failure, or a status on an older head) is a refusal, as
it would be from the script.
How to tell whether a Merge Manager is active: the owner says so, or a live
session holds the merge-steward lane (§”Address the ROLE, never a session”,
step 1). If you cannot tell, ask the owner. Do not assume none. A stale
memory of “there was one yesterday” is not an answer in either direction.
“The owner said merge” is still required. Submitting to main is the owner’s
call (§”Ask the user before submitting”), and this ruling does not change
that.
Owner ruling 2026-10-03, after three PRs were landed unfinished by a steward calling the PUT directly on the same day:
| PR | landed with | check that refuses it |
|---|---|---|
| #1937 | base = #1764’s head branch, after #1764 had merged; head newer than its ready: (a hand merge and two claim commits since); an unsigned ready:; needs-merge-human on; its own pull_request runs failed, only a dispatch green | 1, 2, 3, 4, 5 |
| #1960 | no ready-to-merge, no ready: comment, - [ ] CI green in the body | 3, 4, 6 |
| #1957 | the steward itself called ready_for_review and added ready-to-merge 75 s before merging; no ready: comment | 2, 3 |
The eight checks, each named in a refusal by number and id:
base— open, based onmain, and the base is not the head branch of a merged PR. A stacked PR is retargeted by its owning session first.ready-for-review— not a draft, and the latestready_for_reviewevent is attributable to the PR’s own session, never to yours. With no Merge Manager active,--no-merge-managerlifts the “never to yours” half (§”When no Merge Manager is active”).ready-marker— aready: <sha>comment signed with the PR’s own session link (the one in its body), naming the head, or with only merge-main bot merges after it.labels—ready-to-mergepresent,needs-merge-humanabsent.ci— everypull_requestrun on the head issuccessorskipped, and every workflow owed for that event ran. Aworkflow_dispatchrun counts only as the stand-in for apull_requestrun GitHub held for approval (action_required, bean0qjq) on a head that is a merge-main bot merge: merge-main has already merged main into that head, so the head IS the merge, and a dispatch on it judges the tree that would land. The latest dispatch of the same workflow on the head decides — green counts, running is not-ready, red is a defect. Held with no dispatch: not-ready ifmerge-main.yml’sfor wf in … ; do gh workflow runline names that workflow’s file (the dispatch is still owed), reported not judged if it does not (Feature Staging, preview-only), andunknownif that line cannot be parsed. A green dispatch never rescues apull_requestrun that executed and went red (#1937), and anywhere else it is reported and not counted. Feature Staging is judged, never waited for (owner ruling 2026-10-05, beangnnj): a staging run still in flight, or not started, is not a refusal — its deploy is held by the #1956gh-pagesrate limit, measured at up to 41 min with eight previews queued — and the verdict says so as not waited for. Once it finishes, red still refuses, sincestageruns real checks before it deploys — unless the only failed step is the deploy itself (DEPLOY_ONLY_STEP): a push window that never opened, or three rejected pushes, is not a verdict on the tree (owner ruling 2026-10-05). The guard reads the run’s failed steps to tell; if it cannot read them, red refuses as before. The list isNOT_WAITED_FOR_WORKFLOW_FILESinmerge-guard.ts.checklist— no unticked- [ ]in the body.open-question— no comment after the marker asks the owner or the Merge Manager a question (a heuristic; its limits are onopenQuestionsin the script — a freshready:after the answer moves the window).mergeable— GitHub says the head still merges cleanly intomain(beanvihx). Check 5’s runs tested the head merged withmainasmainwas when the head was pushed; oncemainmoves, a stale green head with a valid marker passed checks 1-7 while it conflicted (#1898, and two HTTP 405 “merge conflicts” refusals on 2026-10-05). The PR API’smergeable: false(mergeable_state: dirty) refuses not-ready — mergemaininto the head and re-signready:.mergeable: nullmeans GitHub has not computed it yet: the guard re-asks for up to 14 s, then reports unknown, never a pass.unstableandblockedpass here, because they are about checks and reviews, which check 5 and the owner judge. The status workflow does not fire whenmainmoves, so a postedsuccesscan go stale on exactly this check;--mergere-evaluates live.
Exit 0 is pass (or merged), 1 refused, 2 could not determine — never a
pass. A refusal is handed back to the owning session through
merge-refusal.bpmn with the check’s text; the steward does not fix the PR
to make it pass, which is the move all three incidents made.
What the OWNING session does, so a finished PR passes: mark it ready,
then post ready: <head sha> signed with its session link —
https://claude.ai/code/session_… in the footer — within ten minutes of
each other, and apply ready-to-merge. Every session acts with the owner’s
token, so the timeline’s actor is always the owner and cannot tell sessions
apart; the signature is the only thing that can. An unsigned marker,
like #1937’s, is refused.
The status has four states, and red means wrong, not unfinished. A PR
that is merely not ready yet (a draft, no marker, no label, an unticked box,
CI still running, an open question, a conflict with main) posts pending. Only a defect posts
failure: a base that is not main or is dead, needs-merge-human, red CI
on the head, or a marker or ready-flip by a session other than the PR’s own.
success is pass and error is could-not-determine. The status is posted on
every open PR, and red on every draft would teach readers to ignore red. A
required check blocks the merge in every state but success.
The status is the backstop, not the gate. .github/workflows/merge-guard.yml
runs the evaluate mode on label, draft, body, comment and CI-completion
events and posts a merge-guard commit status on the head. Making that
context REQUIRED is a ruleset the owner adds; this skill does not, and no
agent changes repository settings.
It runs only on events that can change a verdict (#2099: ~25 runs in the
6 min after #2000). A comment counts only when it carries text a check reads
— ready:, a ?, a claude.ai/code/session_ footer, or merge-main’s
marker; a CI completion only on a PR head; an edited only when the body or
base changed. So a check that starts reading a comment for anything else
must add its text to the workflow’s if:, or the status goes stale on
exactly that comment. Evaluations collapse per PR. The status is a snapshot
either way, and --merge re-evaluates live; never land on a status alone.
Rule_NotReady in
merge-priority.dmn
applies the cheap half of checks 1-3 at placement (readiness, from
LivePr.draft, baseRef, readySha, readyBy), so an unfinished PR is
handed back before it is put in a train rather than at the PUT.
When the authors stall: the steward dispatches unblockers
Owner, 2026-10-04, verbatim: “dispatch agent to handle where CI isnt green. make sure you dont use up all github workspace” and “if there is something small an agent can handle that unblocks, then do so”. Authors’ sessions hit usage limits together, so the whole hand-back column can go quiet for an hour while every member in it is one small fix away from admission.
This adds a second actor to step 4 above. It changes nothing else. An
unblocker fixes the head. It never merges, never adds ready-to-merge, and
never posts a ready: marker. The author still vouches for the PR, and the
steward still lands it only after the author signals ready, or under the
owner’s explicit release.
Who goes: the PRs merge:steward routes to hand-back. Leave out drafts,
PRs whose author was active in the last 15 minutes, PRs another session has
claimed, and PRs under an owner block (a ruling such as “this blocks the PR”
is a judgement about content, and fixing the code does not lift it). Group
them into a few agents, each in its own worktree. The general swarm rules are
in swarm-management.
Every unblocker gets the same rules, written once and handed to all of them:
| rule | why |
|---|---|
at most ONE push per PR, after bun run cat gates locally — or, when every conflict was generated, after the targeted checks of merge-conflict-patterns §”A merge round — run each check ONCE” | each push runs full CI and deploys a staging preview into gh-pages, which has a size budget. Pushing speculatively spends both |
no workflow_dispatch, re-run, empty commit or close/reopen | the same budget, and a dispatched run is not an owed run (above) |
| merge commits only: no rebase, amend or force-push | it is somebody else’s branch |
| authored conflict → resolve only dead code, or a pure addition carried over verbatim; otherwise quote both sides and stand down | choosing between two behaviours is the author’s call |
git submodule update --init and bun run cat state:mount before regen | without the submodules merge:steward cannot load. Without the mount, fsh-guts:viz:check exits non-zero (it judges that the mounted graph renders; the page itself is built at publish, bean 0b8c) and audit:coverage:strict goes red |
delete the worktree’s node_modules at the end | four parallel installs run out of the container’s disk |
| one comment per PR: root cause, the commit it pushed or the reason it stood down | the author comes back to an explanation, not a mystery commit |
missing-required is often a head that CI never started, not a slow
one. The causes to check, in order:
- the PR edits a workflow file and GitHub cannot parse it, so no run is created at all;
- the head was pushed with
GITHUB_TOKEN, which starts nopull_requestrun; - the run is held at
action_required.
The first one is not visible in the check list, so look at the YAML.
After any merge of main, check for silently dropped files. Some files
under */test/results/ (LSI indexes, QA and detangle sidecars) are gitignored
but still tracked. Once a resolution step removes one from the index, regen
rewrites it on disk and git add -A does not stage it again. Local checks
still pass, but CI’s fresh checkout fails. merge:main and the merge-main bot
now refuse such a merge and name the paths (droppedInMerge in
merge-base.ts, see merge-conflict-patterns §”When one side deleted the
file”). A hand git merge gets no such check. After one, run
git diff --diff-filter=D HEAD^1 HEAD -- '*/test/results/*', and restore
anything that main still tracks with git checkout HEAD^2 -- <path> (bean
8j9e).
Stalled for days, with no handover report: write it, then hand it to a takeover agent
Owner, 2026-10-04, verbatim: “several agents (~8) just stalled for week. they didnt generate handover reports. disaptach agent to review PRs and generate plan … to takeover work by anotther agent”.
An unblocker suits a short pause. When the authors are gone for days, the steward does something else:
- Writes the handover report each author did not. It goes as one
comment per PR, headed
## Takeover plan, in thehandover-reportformat and measured, not recalled: intent with the owner’s rulings quoted; head SHA, conflicts split into authored and generated, owed CI; done and remaining; approval and any scope drift; ordered next steps with their falsifiers; owner questions; couplings with other open PRs. This step is read-only apart from the comment. - Gives the owner one paste-able brief for the takeover agent: the cold start, the work order across PRs (the couplings decide it), the merge rules, and the open owner questions. The owner starts that agent, possibly on another account, so the brief must stand alone.
- The takeover session becomes each PR’s owning session.
merge:guardaccepts aready:marker only from the session the PR body names, and check 2 keeps the merging session from vouching for its own landing. So the takeover agent edits the body’s session line to its own session, records “taken over from(stalled)", claims the beans, and then signs. Owner, 2026-10-04, chose this over the steward signing or a one-time bypass: *"Wait for takeover agent"*.
A PR that is green but refused by checks 3 and 4 (no marker, no label) is
waiting-on-author in the queue, not active, until the takeover session
signs it.
Landing
Re-run merge:steward after every merge, before the next one. Each merge
moves main. A member admitted against the old main can turn dirty and
then cannot land at all. Measured 2026-10-04: #2059 was admitted at
f8e1006d, then reported dirty once #2069 landed. Its tested head is still
green, but GitHub refuses to merge a conflicted PR, so it waits for its
merge:main round and fresh CI on the new head.
Only with the owner’s release, which is now the authorisation half of
merge-to-main (Call_MergeToMain in merge-train.bpmn;
it was Task_Release until 2026-10-09): explicit, or a standing ruling
quoted verbatim with its date, and only through bun run cat merge:guard <pr>
--merge --session <id> (section above). What lands is exactly the SHA CI
tested — the guard pins the PUT to the head it evaluated, so GitHub refuses it
if the head moved; if main moved after the train’s CI started, re-run
rather than land.
A standing release covers the queue AS IT STOOD, not every PR that arrives later. Owner, 2026-10-04, verbatim: “my approcal - that counts for the PRs that are in queue. new PRs need my approval exp-licity (through you or siblign)”. So on every sweep the steward lists the PRs opened since the release, and puts them to the owner in one question, with the table’s verdict on each. One answer (“approve all 6”) may cover the whole batch.
Post-merge asymmetry: why main needs its own regeneration pipeline (bean ey1c)
.github/workflows/merge-main.yml runs bun run cat merge:main on an opted-in
PR branch when main moves, resolving declared conflicts and regenerating.
There is a structural asymmetry in the other direction: after a PR merges into
main, nothing in that workflow regenerates main.
So any merge changing an enumerated or counted directory can leave main red on a
hard gate, even when the PR merged cleanly with zero git conflicts. The only reason
it usually does not is an accident: the PR branch happened to regenerate against a
base that had not moved yet. When that accident does not hold, main goes red and
stays red until somebody notices.
The accidental-repair pattern (observed 3 times in 2 hours)
On 2026-10-03 on main’s own runs, step 65 (“Every declared directory’s README is
current”, readme:subgraphs:check) demonstrated this pattern:
5187a4df361 after #1936 green
8688288494a after #1938 (arXiv licences → uploads/) RED
268c0911a06 RED
ea1d8b10593 after #1940 green
70882785607 "Add files via upload" (web UI) RED
PR #1938 added PDFs to uploads/ without updating the generated table in
uploads/README.md. It broke main. Then PR #1940’s branch happened to carry a
regenerated README, which repaired main incidentally; then a web-UI upload
broke it again with no generator run at all.
The trap: A green on a derived gate must NOT be read as “was never broken” or
“the commit before it was clean”. Unrelated PR branches that happen to carry
regenerated files incidentally repair main, creating untrustworthy gate
histories.
Why this halts development: While main is red on a hard gate, every open PR
inherits that red, so no PR can show green, and nothing can be merged on green CI.
PR sessions start debugging their own diffs for failures that belong to main.
The automated post-merge repair pipeline
To resolve this asymmetry, .github/workflows/post-merge-regen.yml triggers on
push to main and runs bun run cat post-merge:regen --create-pr.
- Detected and ACTED on: It audits verify/write pairs on the post-merge tree. If derived artefacts are stale, it does not merely fail silently or log red; it acts by running the generators and generating a reviewable repair.
- Reviewable PR, never a direct push to
main: The repair is pushed to anautomation/post-merge-regen-<sha>branch and opened as a PR targetingmain. This ensures the repair cannot bypass gates, full PR CI runs on it, and it remains auditable and reviewable. - NEGATIVE CONTROL: A merge that changes nothing counted triggers 0 repairs
and opens NO PR (
status: "clean", exit 0), preventing churn (beando70).
Merge steward procedure: post-merge verification
Decision on merge steward role vs automation:
- Automation handles post-merge verification: The steward does not manually run
regen sweeps after every landing. Automation via
post-merge-regen.ymlexecutes on push tomainacross all types of landings (steward trains, direct pushes, web uploads). - Steward triage when main goes red on a derived gate: If
mainturns red on a verify/write check (e.g.readme:subgraphs:check,check:prov-qaqc), the steward checks whether an automated repair PR (automation/post-merge-regen-...) has already been opened before dispatching unblockers or opening duplicate PRs. - Priority admission for repair PRs: When the automated repair PR passes CI,
the steward admits and lands it with priority under
merge-priority.dmnas an unblock-main repair.
ACK every PR that enters the queue, on its bean (STRICT)
Owner, 2026-10-04: “as part of merge manager skill you need to ACK a new PR in queue on its bean”. A submitter who hears nothing cannot tell “queued” from “lost”, and the bean is where the next session looks.
When a PR enters the queue (first seen on a sweep, or approved by the owner), the steward writes three things in the same change:
- The queue entry,
beans/queue/<owner>--<repo>--<pr>.json. It holds the table’s placement, areasonthat beginsACK:and says what the PR waits on, andbeans, copied from the PR body. Validate it againstMergeQueueEntrySchema. - A note on each bean the PR serves, one file per bean per branch:
beans/notes/<bean>--<date>--<branch>.md. A note, never an append to the bean def;bean-coordinationsays why. Then runbun run cat beans:notes, or CI’sbeans:notes:checkgoes red. - One comment on the PR naming the entry and the beans. Before the beans
cutover, items 1 and 2 reach
mainonly through the steward’s own PR, a cycle late, so the comment is the ACK the author sees now. After the cutover, write 1 and 2 withstate:push; the comment then just points at them.
A PR whose body names no bean gets the entry with beans: [] and a comment
asking the author to name one. The steward does not invent the association.
Every open PR has an entry: review the whole list, then triage it
Owner, 2026-10-04, verbatim: “review all open PRs…. anything not in merge queue? put in as status unknown any found. then try to figure out where they are, what’s stale, etc.” and “add to merge manager skills.”
merge:steward skips drafts, and a PR that nobody ACKed has no entry, so the
queue silently shrinks to the PRs someone happened to look at. Measured on
the first review: 18 of 26 open PRs had no entry, 9 of them drafts.
On a review, and at least once per working session:
- List every open PR, drafts included, from the API; not from the steward’s table and not from memory.
- Every PR without an entry gets one with
status: "unknown". A draft gets the placementrule: "draft",class: "hand-back",rank: 99, and itsdecisionnames the steward, not the DMN, because the table never placed it. Claiming the table did would be a fact the steward invented. Triage each one into a status (
QUEUE_STATUSESinschemas/merge-queue.ts), with the evidence inreason:status when activeits author pushed or commented within 24 hours waiting-on-authorhanded back with a named ask, and the author is not back yet blockedwaits on another PR, bean or ruling, named in reasonstaleno push and no human comment for more than 24 hours. Bot pushes from merge-maindo not count as activityapproval-voidthe owner approved it, and then its scope changed landedmerged. The entry stays as history; it is never deleted - Report the stale ones to the owner as a decision, never close them:
closing a PR is the author’s or the owner’s call
(
deletion-requires-confirmation).
An approval is for the content that was approved. Measured on the first
review: #2082 was approved as a three-bean close, and an hour later its
branch carried an 11-file code change under a new title. Compare the title
and the changed-file count against what was approved before landing. On a
mismatch, set approval-void and ask again. The typed fix is a release
bound to releasedSha (the zmdo session’s draft on #2065); until it lands,
this check is the steward’s.
Your merge cadence is an input to the bot’s throughput
Pacing and concurrency are one question, not two. merge-main.yml sweeps
the merge-main-labelled PRs on every push to main, so each merge you land
starts a sweep — and the per-PR merge job’s concurrency group decides what
happens to the sweep already running.
Measured 2026-10-03 over the last 100 runs of that workflow (bean o8s9):
| trigger | success | cancelled |
|---|---|---|
push | 20 | 21 |
pull_request_target | 14 | 2 |
workflow_dispatch | 0 | 2 |
Half of every push-triggered sweep was discarded. Run 37111610566 is the
worked case: twelve per-PR jobs, six cancelled inside ten seconds of each
other by the next sweep, and five of those six PRs still conflicted when
re-probed minutes later. Sweep wall time is 1-15 min while a steward draining
the queue lands a merge every 5-10, so under cancel-in-progress: true a
sweep rarely survived to finish.
The rule, now that cancel-in-progress is trigger-dependent and a push sweep
QUEUES rather than kills:
- A sweep in flight is work in progress — do not count a PR as unmergeable while its merge job is queued behind your last landing. Re-read it after the sweep settles.
- When you are landing faster than the sweep completes, you are the reason
the behind-PRs are not catching up. The concurrency fix bounds the loss at
zero rather than half, but a merge still carries only
main@T(n-1) -> T(n)per sweep; a burst of landings leaves a backlog of steps to carry.
A hold has an EXPIRY and a trigger, or it outlives its reason
A hold is a decision, and like every queue entry it records what the steward decided — not a fact. Facts move. So a hold is only valid while the condition that justified it is still true, and the steward re-derives that condition at every sweep rather than inheriting it.
Write a hold with three parts or do not write one:
| part | why |
|---|---|
| what it waits on | the condition, stated so it can be checked mechanically |
| what ends it | the observation that lifts it, not a time |
| what it costs | which PRs are being held, so the price is visible |
Measured, 2026-10-03. A steward froze merges so a merge-main sweep could
finish without being cancelled — correct at the time, and the owner confirmed
the order. The sweep finished. The hold did not. It then survived three more
sweeps, and the steward kept reporting “held for ordering” while four PRs sat
clean and green for roughly forty minutes. The owner had to ask “what is
blocker on merging?” to end it. The answer was the steward.
Two specific failures worth naming, because both look like diligence:
- Inheriting a hold across its own justification. The premise was “a merge
now cancels the in-flight job”. Once that job completed — and once bean
o8s9’s fix made push sweeps queue instead of cancel — the premise was simply false, and nothing re-checked it. A hold whose reason you cannot restate from current facts is not a hold, it is a habit. - Holding to protect a PR that was already conflicted. The train head was
rc=1and needed a forward merge whatever happened, so holding other merges protected nothing. If the thing you are protecting already needs the work your hold is avoiding, the hold is free of benefit and not free of cost.
Sweep the whole queue; a tracked handful is not the queue
Compute the mergeable set from the open-PR list every time, never from the PRs you happen to be following. The same session that held four green PRs was also unaware of two others that had opened and gone green in the meantime — they were not in its mental list, so they were in no list at all.
The sweep is cheap and mechanical: for every open non-draft PR, read
base.ref, run git merge-tree --write-tree origin/main <head> for the rc,
and read the check runs on that exact head. Three columns, one pass. A PR with base == main and
rc == 0 is a candidate; whether it is mergeable is still the OWED question
above, not a count of failures.
no failures and nothing pending is NOT green, and this rule got that wrong
on its first use. The sweep that followed it marked #1953 mergeable on
ok=1 bad=0 wait=0 — one completed check, the .jsonld siblings one, against
the 23 that a full suite produces here. Nothing had failed and nothing was
pending because almost nothing had been asked. That is bean 1xhc inside
the sweep itself: a gate that did not fire is indistinguishable from one that
passed, and a tally of failures cannot tell them apart.
So the third column is the owed SET, never a count: compare the check-run
names on that head against the set owed for its event, and treat a missing
name exactly as a red one. A steward that cannot say why it is not merging a PR
which is green on the owed set is holding it by accident; one that merges on
bad=0 wait=0 alone is merging unverified.
Read base.ref BEFORE anything else: a stacked PR is not a main-queue member
merge-tree origin/main <head> answers a question nobody asked when the
PR’s base is not main. Mergeability, independence and placement are all
computed against the base, so a stacked PR measured against main is measured
against the wrong tree — and the merge button lands it on the wrong tree too.
Measured, 2026-10-03. PR #1937 was based on claude/quirky-davinci-ixuymr,
another PR’s branch. Every check the steward ran was against origin/main:
rc=0, 16/16 green, all true and all irrelevant. The merge went to the stacked
base, the PR closed as merged, and its nine commits were not on main. It took
a second PR to re-land, and a sibling agent independently misdiagnosed the
absence as a force-push eating a merge — a scarier cause than the truth, with a
destructive remedy attached.
The tell is cheap and it is first: gh api repos/<o>/<r>/pulls/<n> --jq .base.ref.
In one sweep of 30 open PRs here, four had a base other than main. A
queue sweep that does not print the base will mis-handle roughly one PR in
eight.
A stacked PR is not admitted to the main queue. Either its base lands first and
the PR is re-measured against main, or it is rebased onto main by its
author. The steward’s job is to notice, not to resolve it.
git add -A after a merge silently reverts the submodule gitlinks
And git submodule status does not catch it. This is bean ygga one layer
deeper, and it defeats the check that bean prescribes.
A merge resolves a submodule gitlink like any other path. git add -A then
re-records every submodule at whatever the working tree happens to have
checked out, overwriting the resolution. If you initialised submodules
before merging, and the base advanced them, you have just reverted them to
the merge base — and git submodule status shows no + and no -, because
the index and the working tree agree with each other. They are simply both
wrong.
Measured, 2026-10-03, on #1819. Main had moved bootstrap to ebfa406 and
bootstrap-tools to 3046412; the branch never touched either, so the
three-way merge correctly took main’s side, and git add -A undid it. The
symptom was not a submodule error — it was regen reporting five checks as
“a real defect, not staleness” (readme:sync in its four spellings plus
translate-bpmn:bootstrap), all of them one module-load failure:
SyntaxError: Export named 'generatedBanner' not found in
.../bootstrap-tools/scripts/generated-by.ts
generatedBanner exists only from 3046412. With the pins right, all five
pass untouched and regen settles at 0 unrepaired. The agent had run
bun install, so this is not the missing-dependencies case either.
Two rules follow:
- Stage explicitly after a merge, never
git add -A, while any submodule is in the tree. - Verify the pins against the ref, not against the working tree.
git ls-tree <ref> bootstrap bootstrap-toolsis what exposes this;git submodule statuscannot, and reporting it as clean on that basis is the1xhcfailure — a check that cannot see the defect is not evidence of its absence.
A local gate run in a contended container is not evidence — and here is the ratio
Measured 2026-10-03. A full bun test shard in this container: 14,625
tests across 715 files in 1438.88 s. The workflow’s own comment puts the same
shard at 2 m 07 s – 2 m 21 s on a dedicated runner. That is roughly ten
times slower, at load average 11 with three concurrent bun test runs and
a regen belonging to other sessions.
At that ratio the default 5 s per-test budget stops measuring the code:
14554 pass · 57 skip · 14 fail
grep -c "timed out after 5000ms" -> 14
grep -cE "^error:|Expected:|Received:|toBe|toEqual" -> 0
Fourteen failures, every one a timeout, not one assertion failure, at real durations of 5.0–10.9 s. The same shard was green on the dedicated runner.
So the signature is cheap to check and worth checking before you believe a red
local run: all failures are timed out after Nms and the assertion-failure
count is zero. That is contention. Report it as inconclusive under
contention — never as green, and never as a defect — and let CI on the exact
sha be the authority. What you must not do is “fix” a test that is not broken,
and never skip, disable or quarantine a test to get green applies with full
force here, because the temptation is strongest when the failure is not real.
Two ways this measurement was nearly got wrong, both bean 0s6w:
- The agent first reported “exactly one failure” from a partial log, then the run finished at 14. A count read before the run ends is not a count.
- A compound command’s exit status is the last command’s, and a test runner’s status has to be captured before anything else runs.
Agentic pre-merge review: warn-only and the would-have-blocked counterfactual
Bean h1uq. Owner ruled agentic merge review warn-only on 2026-10-02 and confirmed 2026-10-03: “warn only. proposal predates ruling, update it.”
Architectural decision: staying WARN-ONLY with measured justification
The decision to keep agentic code review warn-only is governed by empirical evidence, not caution:
- Literature gap: Across five ingested agentic SE papers (CodeAgent arXiv:2402.02172v5, SWE-Debate arXiv:2404.04834v4, SWE-Router arXiv:2507.23348v1, TCAndon-Router arXiv:2601.04544v1, He et al. arXiv:2607.00053v1), zero empirical false-positive rates (FPR) exist for an LLM judge.
- CodeAgent’s unconfirmed flag rate: CodeAgent’s own human annotation revealed that 48.6% of GPT-4’s flags were unconfirmed. A reviewer where nearly half of all flags are unconfirmed cannot be a blocking gate.
- SWE-Router & SWE-Debate noise boundaries: LLM-as-a-judge suffers from self-enhancement bias, verbosity bias, and positional bias.
- Deterministic compile checks stay blocking: G5 (Lean module compilation), G6 (SUSHI FHIR compilation), and G7 (JSON-LD schema validation) are deterministic compile gates that have exact binary outcomes and remain blocking. An agentic reviewer does not share this property.
- Backlog inversion: A blocking gate over an active codebase with unreviewed content nodes would fail on existing background patterns rather than PR delta, inverting the order of necessary backfill.
The counterfactual instrument: why only a warn-only phase can measure FPR
A blocking gate destroys the counterfactual: when a gate blocks, authors modify code, bypass checks, or abandon PRs. It is impossible to determine whether the flagged condition would have actually broken production or represented a false positive.
By contrast, a warn-only gate records blocking-severity findings as “would have blocked” (WouldHaveBlockedRecordSchema in schemas/merge-queue.ts), pinned to the PR’s headSha. The PR then merges to main. Subsequent monitoring observes whether any defect materialized, enabling humans to adjudicate findings:
true_positive: Finding correctly identified a genuine defect.false_positive: Finding flagged benign, correct code.unknown: Third state (inconclusive, unverified, or cannot be determined).
The third-state rule: unknown is strictly preserved and never folded into true_positive (which would artificially deflate FPR) or false_positive (which would artificially inflate FPR).
Pre-registered promotion criteria
To prevent choosing thresholds to fit data, promotion criteria are registered as numbers and windows before data collection begins (AgenticPromotionCriteriaSchema):
minimumPrWindow: 50 evaluated PRs.maximumFalsePositiveRate: <= 0.05 (5% FPR:confirmedFalsePositives / (confirmedTruePositives + confirmedFalsePositives)).minimumReviewCoverage: >= 0.80 (80% non-unknown adjudication:(confirmedTruePositives + confirmedFalsePositives) / warnedFindings).
Until all three criteria are empirically proven, the agentic review remains warn-only.
Tool-integrated agentic pre-merge review (CodeAgent / 2402.02172v5)
Evaluated under bean gs0u and codified in methodologies/agentic-pre-merge-review.md.
Multi-agent tool-integrated code review architectures (Tang et al., arXiv:2402.02172v5) structure automated pre-merge evaluation into specialized roles (Reviewer, Coder, QA-Checker, Consistency, Security) across four sequential phases.
Three operational rules govern its integration into this repository’s merge queue:
- The Deterministic vs. Judged Boundary (Owner ruling 2026-10-02/10-03, bean
nok9): Deterministic compile gates (Lean formal proofs, SUSHI/FHIR IG AST, JSON-LD schema renders, linter and test suites) answer questions with one computable truth — these MUST BLOCK. LLM-mediated agentic code reviews answer questions with uncalibrated error rates — these MUST WARN (“would have blocked”), exiting 0 and logging structured finding sidecars. - The 49% False-Positive Guard: Standalone LLMs exhibit up to 48.58% false-positive
rates in code vulnerability detection (arXiv:2402.02172v5, Table 2). An automated review gate
must never block merge on unverified LLM assertions, which induce severe alert fatigue.
Promotion from warn to block requires measured calibration against recorded repository
defects (
plj1,dh4f,w4tq,7u3g). - Bounded Dialogue Budgets: Conversational QA-Checker loops must cap at a maximum of 2
adjustment iterations to avoid latency explosions (CodeAgent measured 5 min / $0.122 per review)
and context window exhaustion. Deterministic formatters and linters must run directly in CLI
gates (
bun run cat gates) rather than consuming multi-turn LLM reasoning.
What this does not do yet
The train’s size is a fixed cap. Sizing it by risk waits for this process’s
own run records, which is why each run is a committed instance under
beans/workflows/. The tools named on the steps (merge:overlap,
merge:train, merge:leftover) live on branch claude/merge-pipeline-tools
until it lands.
Processes that run this skill
| process | step(s) that name it |
|---|---|
| A merge train | Read each queued PR’s live facts; Take the next PR in queue order; Admit it to the train, record the train id; Attribute the failure: each member’s own CI first; Bisect the train; Eject the culprit, record why |