Generated from
cat-harness/skills/sdlc/sdlc-core/merge-conflict-patterns.md— do not edit here.
Merge-conflict patterns — what a merge may resolve on its own
bun run cat merge:main is the command; processes/sdlc/merge-base.bpmn is the
process it executes, called from Task_PrepareMerge in
code-change-review.bpmn. The patterns themselves are data in
cat-harness-tools/scripts/merge-conflict-patterns.ts. This page says what each one
is FOR, so a reader can tell a deliberate refusal from a gap.
Why this exists
Measured 2026-09-30 over 300 main-into-branch merges on origin/claude/*
(bean y7b3, issue #1707): 235 conflicted, and 147 (63 %) conflicted only on
generated files. Every one of those resolves the same way — take the base’s
copy, regenerate — and each cost an agent a round and the PR another CI run.
Owner, 2026-10-01: “1 + new skills/tools for each common churn/conflict
pattern”, and “put in merge process bpmn”.
The three rules that make it safe
- All or nothing. Every conflicted path is classified before anything is touched. One refusal ABORTS the merge and restores the tree. Resolving nine generated files and leaving one authored conflict half-done reads as progress, and is not.
Proved, not assumed. After resolving,
bun run cat regenasks every check the CI workflow runs and runs each stale one’s writer until the tree settles. Anythingunrepairedaborts the merge. No pattern names its own check: the gate set is derived from the workflow and cannot drift, a hand-kept list of check names can.- Classify with the BASE’s patterns, not your tree’s.
pathClassreadsPATTERNSfrom the checkout it is running in. A branch that forked before a pattern was declared therefore classifies by the OLD declaration, and every path in a newly-declared family readsauthored— the one verdict that routes work to a person.
A path that no pattern names is refused. Adding automation is adding a pattern, deliberately, with its reason — never widening a glob on a hunch.
Why rule 3 is a rule and not a footnote
Measured 2026-10-03, within minutes of #1943 declaring skos-glossary-export,
glossary-generated and skill-instructions. The merge steward classified
eight open PRs from a working tree that predated that merge:
| PR | authored, stale patterns | authored, main’s patterns |
|---|---|---|
| #1888 | 9 | 2 |
| #1892 | 3 | 1 |
| #1764 | 3 | 2 |
| #1935 · #1934 · #1816 · #1808 · #1804 | 0 | 0 |
Fifteen authored conflicts across the queue, against a true six. Nine paths were reported as needing a person when the base already declared them mechanical, and the error is one-directional: a stale tree can only ever over-report, never under-report, because a pattern is added and an authored path is the fallback. A sibling session was about to start on #1888 believing its authored conflict was a bean definition; it is not in conflict at all.
So before classifying anything, git fetch and read the patterns from the
BASE — or do what the measurement did and classify against
git show origin/<base>:cat-harness-tools/scripts/merge-conflict-patterns.ts. The
number that decides whether a human is needed must come from the declaration
the merge will actually be resolved under.
This is the same shape as 1xhc one level out: a stale instrument and a
correct one are indistinguishable from their output, and the stale one here
reads as more work rather than less, so nothing about the result looks
wrong.
A merge round — run each check ONCE (owner ruling, 2026-10-05)
Owner, 2026-10-05, verbatim: “trim duplicated steps”. A round of folding
main into a PR was costing about 45 minutes, and most of it was the same
question asked twice. Measured 2026-10-04 on PR #1898’s merge rounds, bean
7xmc:
| step | cost | what it adds |
|---|---|---|
merge:main, including its own regen | 5–10 min — regen 238–309 s with nothing stale, 601–639 s when a writer runs | THE regen; rule 2 above |
skill:register | 1–2 min | the derived artefacts the merge left owed |
a standalone regen after merge:main | 4–5 min | nothing — same tree, same gate set |
full local bun run cat gates | ~24 min (1429 s), nearly all bun test (~21.8k tests) on 3–4 local cores | what CI’s sharded run says in ~5 min, 4 ways |
regen again in a fresh checkout, to prove a no-op | 4–5 min | what CI’s own clean checkout already proves |
Three of five rows repeat a measurement somebody else, or the same command, has already made. So:
When every conflict is generated — the trimmed round
“Generated” means every conflicted path was resolved by a declared
take-base, region or sidecar pattern below, and the merge brought in no
authored change of yours to reconcile.
bun run cat state:mount, thenbun run cat merge:main.merge:main’s regen IS the regen. Do not run another one after it.bun run cat skill:registeruntilbun run cat skill:register:checkpasses. The chain can need two passes: one writer’s output is another’s input.- Targeted checks, and only these:
skill:register:checkandkg:detangle:check— the two reds a generated-only merge actually hit in CI;subgraph:jsonld:check— a merge or edit that changes a skill also changes its payload hash;skill:registerdoes not runsubgraph:jsonld, so run the writer (bun run cat subgraph:jsonld) when the check is red. Measured on #2139 itself: CI’sgen-slice-sqlitetest failed on three edited skills whose payloads the published tree did not hold;check:declared-paths(never with--updatehere) andcheck:process-index;bun run typecheck, andbun run cat lintat 0 errors;- the deletion audit —
git diff --name-status --diff-filter=D HEAD^1 HEAD -- '*/test/results/*'must list only filesmainitself deleted. A gitignored-but-tracked file is dropped by a merge silently, local checks stay green because the writer recreated it on disk, and CI’s fresh checkout fails (bean8j9e).merge:mainnow refuses such a drop itself (§”When one side deleted the file”); after a hand merge, restore each one main still tracks withgit checkout HEAD^2 -- <path>; - submodule pins equal to
main’s —git ls-tree HEAD bootstrap bootstrap-toolsagainstgit ls-tree origin/main bootstrap bootstrap-tools. Notgit submodule status:merge-queue§”git add -Aafter a merge silently reverts the submodule gitlinks” says why it cannot see this; - the
fsh-gutsmount is not committed —git diff --stat origin/main HEAD -- fsh-gutsis empty; git merge-tree --write-tree origin/main HEADreports 0 conflicts, with the exit status captured on the next line (prepare-mergestep 4).
- Push, never with force. CI’s sharded run is the full gate set. Skip the
local full
bun run cat gatesand the fresh-checkout regen for this kind of merge: each repeats something CI does better.
When it is NOT generated-only — keep the full local run
An AUTHORED file conflicted, or the merge touched code (yours or main’s
reconciled against yours): run the full local bun run cat gates before pushing,
and read prepare-merge §”What a green LOCAL run entitles
you to claim” before quoting it. The targeted list above is chosen for a merge
that changed only artefacts with one right answer. A semantic conflict — #2043
importing a module #2112 had moved, in
merge-queue §”A train admits only members that STACK
cleanly” — is exactly what that list cannot see.
Four things a round needs to know
merge:mainhas no “continue after a manual fix” mode. When one path blocks it, it aborts and restores the tree (rule 1). Do its steps by hand:git merge origin/main, fix the blocking path, take each declared path withgit checkout --theirs <path>and stage it withgit add -f <path>, then run the regen it would have run.- Give the regen a long leash. Cold, it needs ≥1200 s of wall time. Run it in the background rather than under a default 2-minute tool timeout, which kills it part-way and leaves a half-written tree.
merge:maincommits with git’s default message. Amend that commit to add the session’s trailers (git commit --amend) before pushing — it is your unpushed commit, so this is not a rewrite of shared history.- Do not fold
mainin while another big PR is minutes from landing. Wait for it. Merging now buys a second round the moment it lands — on trainmerge-train-2026-10-04a, #2112 landing mid-round re-conflicted the train and cost another 12-minute regen plus a semantic conflict (merge-queue).
A pattern is not always the answer — ask what the file’s record is
Read this before declaring a pattern for a path that already has one. The registry answers how a conflict is resolved. It never answers whether one arises, and reaching for it a second time on the same path is the move to stop and check.
Two nearby things are already written down and neither is this one:
- bean
in5a— a declared pattern settles a file whose content is a function of the TREE, and settles nothing about one whose content is a function of the machine that built it. There, take-base is a loop: take either side, regenerate, and the value is whatever this container sees. - this section — the content is tree-determined, the pattern does resolve it, and the path still conflicts on every merge.
The worked example is bean tqjj, and the measurement is what makes it a rule
rather than a preference. **/test/results/lsi/** and
**/test/results/tool-runs/** have been declared under derived-results all
along, and cat-harness/docs/lsi/** under glossary. On 2026-10-04 those three
paths were still blocking seven open pull requests each, 317-319 of the last
400 commits on main touching them. Regenerating skills.lsi.json on an
unchanged tree gave a byte-identical file, so in5a did not apply; but
appending one sentence to one of 229 skill files rewrote the fingerprint,
9 of 12 dimensions entries and 166 of 229 neighbours entries. An
SVD rotation is globally sensitive, so the convergent resolution is a rewrite
of 94 % of the file — and nothing on main judged those bytes: corrupting the
sidecar’s fingerprint left lsi:skills:check at exit 0 (.gitattributes,
bean eqxp).
So the question to ask is not which strategy, it is:
What is this file’s record, and is
mainit?
mainis the record — an authored file, or a generated one a reader navigates to by name. Declare the pattern; that is what the registry is for.mainis not the record — the file’s record is theqa-reportsentry for its commit, or a build output, and the copy onmainis a duplicate kept in step by a gate. Then take it offmain, and keep the declaration for the branches still carrying it, where the base has deleted the file and take-base resolves that too (see the next section).- Part of it is, and part is not — the common case, and the one to look for
before concluding either of the above. Cut by what each value is a function
of: a value the TREE determines stays committed and is reviewable; a value
determined by some other artefact’s CONTENT is produced where that artefact
lives.
docs/lsi/index.mdis the worked example — front matter, prose and the verdict table stay, each index’s size, poles and findings go — and the front matter is why it could not be taken off whole: it declares the harness tile, so an uncommitted page is a page with no way in.
The trap on the way out. Taking a page off its store and leaving it
committed can make it worse: once lsi:viz had no sidecar in the checkout it
read qa-reports at main, which resolves to the latest published entry,
so the committed page would have gone stale whenever anyone else pushed — a
value depending on when the gate ran rather than on the tree. in5a’s loop,
arriving over the network. Whatever stays committed must be computed from the
tree, deliberately and not by luck.
.gitattributes reached the same conclusion from the -merge side and states
it plainly: “Removing these conflicts, rather than tidying them, needs the
files off main altogether.” Two things make that safe rather than merely
tidy, and both are checks, not care: every reader must already cope with the
file’s absence (bean oq1j for the LSI readers), and something must still
PRODUCE it where its record lives — qa:refresh decides that per writer,
so a family can be removed on its own instead of all 1,186 at once.
When one side deleted the file
A modify/delete conflict has no stage for the side that deleted it, so
checkout --theirs (or --ours) has nothing to take. Every resolving
strategy takes the base’s side of the deletion instead: its copy when the
base kept the file, git rm when the base removed it — takeBase in
merge-base.ts, and provisionalSide in qa-resolve-conflicts.ts for the
delegated sidecars (#1854). Regeneration recreates the file if it is still
produced. Classification is by path, so an authored path in a modify/delete
conflict is refused exactly like any other conflict on it. The owned-tree
patterns are the exception. They read which parent commits hold the path
rather than which stages exist, because in a rename/rename “no stage 3” does
not mean the base deleted anything (see subgraph-payload below).
Taking a deletion is the ONLY way a merge may drop a path, and
merge-base.ts checks this before every merge commit (beans vsv7,
8j9e). droppedInMerge compares the index with the merge base and both
parents. A path either parent tracks that the result lacks is a drop, unless
one side deleted it since the merge base. A path both parents hold, or one a
side added, must survive. The check runs twice. Right after resolution, before
anything else is staged, every drop is refused. After regen and add -A, only
a drop the disk still holds is refused, because a writer may delete what it
owns: subgraph:jsonld replaces a content-addressed payload when the merge
changes its node, and its :check proves that. The check exists because of
the gitignored-but-tracked files under */test/results/. Once one of them
leaves the index, regen rewrites it on disk and git add -A does not stage it
again, since it is now untracked and ignored. The failure signature is
green locally, red in CI, with the check reporting the file missing (“has
none”). git add -A is not what removes such a file, though. It stages a
rewrite of a file that is still tracked; it only fails to restore one that
something has already removed from the index. On #1898 (edf52fcf6) that was
a git rm from takeBase. qa:resolve-conflicts claims every conflicted
path under a declared qa directory, detangle sidecars included, and had
already resolved three of them. The pre-vsv7 takeBase then read “no
stages” as “the base deleted it”. The refusal line says when the dropped file
is still on disk, which is the case that local checks cannot see.
The patterns
Counts are conflicted files in the 2026-09-30 measurement.
kg-qa-sidecar — delegated (53)
**/test/results/kg-qa/**. A kg-audit sidecar can carry an attestation that
an earlier run or a person recorded, which no generator reproduces.
qa:resolve-conflicts reads git’s stages and refuses such a file, so this
pattern hands over rather than taking either side. Listed FIRST: these paths
are also under test/results/, and falling through to take-base would drop
a recorded adjudication silently.
kg-qa-manifest — take the base, regenerate
**/test/results/**/kg-qa.manifest.json. One per instance (hosted ones one level deeper), holding only the
auditor’s script hash, so any change to kg-audit.ts restamps every one at
once. Not in the first measurement’s top list; found when a replay of 40 real
merges refused one merge on these files alone. It holds no attestation, unlike
the sidecars it indexes.
qa-results — take the base, regenerate (292)
**/*.qa-results.json. Whole-artefact QA results, rewritten whole by their
producer. They carried updated_at until #1714; they still change whenever
any finding does.
derived-results — take the base, regenerate (142)
LSI indexes, detangle sidecars, tool-run records and translation-qa sidecars under test/results/.
Recomputed from the whole corpus, so any concurrent skill or schema change
touches them.
The LSI half is untracked on main since bean tqjj, and the globs are
kept for the branches still carrying it. See §”A pattern is not always the
answer”: this family is the measured case where the declaration was correct,
did what it claimed, and removed no conflicts.
auto-docs — take the base, regenerate (352)
The generated docs index pages, already -merge in .gitattributes. One page
per directory, so a new file anywhere changes one.
uml — take the base, regenerate (201)
Generated overview diagrams and their SVGs.
prov-qaqc — take the base, regenerate
The PROV-O QA/QC report (docs/prov-qaqc/) and its per-instance logs
(docs/assets/prov/). Generated WHOLE from the workflow instances under
beans/workflows/, so any branch that records an instance — which every branch
doing process work does — rewrites the index page and adds a .prov.jsonld.
Added 2026-10-03, and the occasion is the reason. The runner refused #1892
with docs/prov-qaqc/index.md as the SINGLE unclassified path among 32
resolved by pattern. One file nobody authors, blocking a merge nobody can
usefully resolve by hand — which is this catalogue’s whole purpose, missing one
entry.
The globs are instance-agnostic (**/docs/prov-qaqc/**), like
derived-results and unlike glossary: a dependent folio runs prov:qaqc
over its own instances and writes the same two shapes under its own root.
It names no check of its own, per rule 2: regen runs prov:qaqc because
check:prov-qaqc is a workflow gate, and a pattern that named its own check
would be a hand-kept list able to drift from the workflow.
glossary — take the base, regenerate (207)
The generated glossary and LSI pages: whole-corpus aggregates where concurrent term additions always collide.
The LSI page still conflicts, but much less often since bean tqjj: its
per-index detail — the half a one-sentence skill edit moved — is added by the
docs-site build rather than committed, and what is left is a function of the
tree. The glossary page has no equivalent split: every number on it is a term
count over the whole corpus, so there is no half that only the tree moves. That
is the second branch of §”A pattern is not always the answer” — main is still
the glossary page’s record, and declaring the strategy is all there is to do.
translated-glossary — take the base, regenerate
cat-harness/docs/{ar,es,fr,ru,zh}/glossary/index.md. The per-locale glossary
pages glossary-page.ts writes whole beside the English one (check:glossary),
so they collide exactly when it does. Not in the first measurement; found
2026-10-01 when a merge:main on #1754 refused on all five. Only the glossary
page is named: the rest of each locale directory is authored translation, and
a locale no generator writes (de/) is refused.
viewer-pages — take the base, regenerate
docs/external-schemas/index.md, docs/methodologies/index.md,
docs/processes/*.md, docs/qa/index.html and
docs/translation-status/index.html: whole-file viewer pages, each with its
writer’s --check in the CI workflow (external-schemas:viz,
methodologies:viz, processes:viz, state:visualizer,
translation:status). A new schema, diagram or translation anywhere rewrites
them. Found the same way, on the same merge.
It also holds state:visualizer’s other pages, docs/{beans,todos,health,
issue-marks,swimlane-glossary,uploads}/index.html, each headed “Generated by
scripts/state-visualizer.ts”. docs/uploads/index.html is the viewer OF
uploads/, not an upload — before 2026-10-01 the uploads refusal’s
**/uploads/** caught it, and #1764 refused on it. First match wins, so the
viewer entries sit above the refusal.
docs/fsh-guts/index.md USED to be here: any PR that archived an upload
rewrote it, so two such PRs always collided (#1766, 2026-10-03). It is no
longer committed at all. It is derived from fsh-guts/, which is kept on a
branch, so it is built at publish by derive:publish (bean 0b8c, #2230),
and a merge cannot meet it. That is the general answer for any derived page
whose input lives on a branch — see
directory-conventions
§”The storage clock” — and is better than a take-base pattern, which only
makes the collision cheap. The archive itself (fsh-guts/**) is kept content
and stays refused.
viewer-namespace — take the base, regenerate
docs/cat-harness/{catalogue,folio,library,schemas,uploads,voices}/**: the
pages gen-library-viz, gen-folio-viz and gen-schema-viz place through
viewerPlacement, each rewritten whole from the corpus and checked by its
:viz --check. Same false positive as above: its uploads/ pages render
uploads rather than being them (#1775). auto-docs/ under the same prefix
keeps its own auto-docs entry.
navbar-include — take the base, regenerate
docs/_includes/generated/**: the navbar/footer include written whole by
gen-navbar-include.ts (navbar:include --check). Any new page, graph or tile
rewrites it. Its authored neighbours in docs/_includes/ stay refused.
viewer-nav-qa — take the base, regenerate
test/results/viewer-nav/viewer-nav.qa.json: check-viewer-nav’s mechanical
layout verdict over every viewer page. Recomputed from the pages, and it
carries no reviewer’s attestation — unlike a kg-qa sidecar, which is why it is
not delegated to qa:resolve-conflicts.
handler-index — take the base, regenerate
docs/cat-harness/published-graphs.md, the handler’s index of every published
graph and declared viewer, written whole by gen-handler-index.ts
(handler:index:check). Any new graph or viewer rewrites it. Found on #1754’s
third merge, 2026-10-01.
skos-glossary-export — take the base, regenerate (54)
**/docs/assets/glossary/*.skos.jsonld: the published SKOS export, written
whole by glossary:export (glossary-export.ts, gated by glossary:check).
Bean 8rff measured 54 pair-path hits across 32 open PRs — every
instance’s export restamps whenever any declared role, skill or term moves, so
two PRs that touch unrelated skills still collide here.
It is not the ledger, and that distinction is the whole safety argument. glossary-ledger.json carries forward: glossary-export.ts reads the prior
ledger and preserves a concept’s earlier names as skos:hiddenLabel (#1168
B10b), so taking one side would drop a term’s history. The ledger sits one
level up from generated/ and matches no glob here — verified, and pinned by
a test. This is .gitattributes’s rule applied to a resolver: the test is not
is it generated but does its producer carry anything forward.
.skos.jsonld does not match site-data’s cat-harness/docs/assets/**/*.json
— .jsonld is not .json — which is why it needed its own entry rather than
falling through.
glossary-generated — take the base, regenerate (50)
**/glossary/generated/**: the per-instance generated glossary JSON, same
writer and same check as skos-glossary-export. 50 pair-path hits
(8rff). The glob stops at generated/, so the sibling ledger is untouched.
skill-instructions — take the base, regenerate (30)
**/docs/reference/skill-instructions/**: skill instruction bodies, written
whole by skills:docs (gen-skill-docs.ts’s OUT_DIR, gated by
skills:docs:check). 30 pair-path hits (8rff), and AGENTS.md says of
this directory: “Never hand-edit either generated dir.”
Safe because emit() is compare-or-write with no merge — under --check it
compares and records drift, otherwise it writes the content whole — so nothing
is carried forward from the file on disk.
The authored neighbour is the skill itself. skills/**/*.md is the SOURCE
these pages are generated from and stays refuse; a test pins that pair,
because resolving the generated copy while taking a side on the source is the
mistake this entry would otherwise invite.
docs-pages — take the base, regenerate (17)
The 17 cat-harness/docs/**.md pages gen-docs-pages.ts writes whole from the
authored blocks under cat-harness/docs/source/<slug>/ (docs:pages, gated
by docs:pages:check). Each one says so in its own front matter:
generated: scripts/gen-docs-pages.ts — do not hand-edit; run `bun run cat docs:pages`
Bean 8c6v: none of the 17 was named by a pattern, so classify()
returned refuse / — none — and the merge handed back for hand-editing the
files that forbid it. docs/process/publication-workflow.md was one of the 2
refusals on #1888 after 53 of its 55 conflicts had resolved by pattern —
one undeclared family holding up a 53-file resolution is the all-or-nothing
rule working as designed, and the gap it exposed.
Safe because nothing is carried forward. emit() is compare-or-write: the
only read of a prior page is inside its --check branch, for the comparison,
and every other read in the script is of an INPUT (readBlock over
docs/source/, BPMN XML, manifests). Its one walk of the output directory
skips files carrying the generated marker. This is bean 8rff’s discipline —
confirm the writer by reading the script, not by how whole-file it looks —
applied again, and here it comes out the other way than it did for the
glossary ledger.
Gated on EXACT content, which is why take-base is verifiable rather than
merely convenient. The generator’s own docblock says a difference in a page
is “somebody who added a node, renamed a block, ran a first sweep, or moved a
sidecar, and did not regenerate”. So after take-base, docs:pages:check
proves the regenerated page is right — the resolution is not trusted, it is
checked.
The 17 slugs are ENUMERATED, not globbed, and that is deliberate. cat-harness/docs/*.md is a mix: measured 2026-10-03, the directory holds
31 .md pages of which 13 are generated, so 18 authored ones sit beside them
(architecture.md, index.md, getting-started.md, platform.md, …), and
guides/ holds 9 of which 4 are generated, leaving agent-onboarding.md,
voices.md and 3 more. A directory glob would take a side on
authored prose — the one thing a resolver must never do. The cost is that a
page added to docs/source/ is refused until its slug is declared here,
which is the safe direction to be wrong in, and a test reads the pages’ own
front matter from the TREE so the enumeration cannot go quietly stale.
That test must read the front matter, not the file, and it earned the
distinction immediately. Its first cut tested the whole file for the marker
and the very next merge of main turned up an 18th subject: the generated
body of this skill, docs/reference/skill-instructions/merge-conflict-patterns.md,
which quotes the marker in the code fence three paragraphs above. A detector
that reads a quotation as a declaration finds its own documentation — “a
docblock that documents a tag necessarily contains the tag”, from
audit-coverage. That page’s own front
matter names gen-skill-docs.ts, and skill-instructions is declared before
docs-pages, so the first match already gives it the right owner. A test pins
all three facts.
The authored neighbour is the source, and it stays refused. cat-harness/docs/source/process-publication-workflow/every-workflow-in-the-repo.md
is the hand-written INPUT for docs/process/publication-workflow.md; on #1888 both
sides had only added rows to it, but a union of additions is a property of
that instance and not of the path, so the next conflict there could be a
contested edit. A test pins the pair, and pins that nothing under content/
is claimed at any depth.
health-report — take the base’s measurement
test/health/results/*.health-report.json. Not a derivation of the tree but a
measurement of external state (publish branch, clone size, the work plan),
written by bun run cat health and refreshed daily on the base by the
health-check workflow, so the base’s copy is simply the newer measurement and
a branch’s older one carries nothing worth keeping. check:harness-state
judges its producer hash; if the merge changed the producer, bun run cat health
rewrites it. Found on the same merge.
qa-witnesses — take the base, regenerate
test/results/witnesses/**: witness projections (qa-witness/v1) and page
verdict indexes, written by gen-docs-pages.ts from the kg-qa sidecars and the
live subject. A projection, never an attestation — the sidecars it reads are
the delegated kg-qa-sidecar family — and the docs-site build regenerates them
at publish. Found on #1754’s eighth merge, 2026-10-01.
pot-templates — take the base, regenerate
translations/**/*.pot: gettext templates extracted from the English pages by
pot-for-pages.ts (translation:pot:check), so every edit to a source page
rewrites its template in every locale. The .po files beside them are
authored translations and stay refused. Found on the same merge.
site-data — take the base, regenerate (36)
Generated site data indexes under docs/assets/**/*.json and docs/_data/.
subgraph-index and subgraph-payload — owned tree, regenerate
**/docs/subgraph/** holds the subgraph JSON-LD indexes. **/docs/payload/sha256/**
holds the content-addressed payloads (<hex> is the sha256 of its bytes, with
a <hex>.json sidecar beside it). bun run cat subgraph:jsonld writes both
directories whole, and it deletes every file in them that it did not write.
Any skill edit rewrites an index and moves a payload, so until #2176
(2026-10-05) every merge of main into a PR that edited a skill was refused,
and a person finished it by hand: checkout --theirs, then subgraph:jsonld.
Why not take-base. When both sides change one node’s payload, git reads
it as a rename/rename. The base’s hex goes to the branch’s hex on one side
and to main’s hex on the other. Git leaves the old name at stage 1 only, the
branch’s new name at stage 2 only, and main’s at stage 3 only. Both new stages
hold git’s three-way merge of the two bodies, with conflict markers. So
take-base fails in two ways:
- It reads “stage 2 only” as “the base deleted it” and
git rms the branch’s new payload. That is a path the branch added, sodroppedInMergerefuses it at theresolvedcheckpoint. checkout --theirswrites marked bytes under a name that is the hash of other bytes.
The owned-tree strategy (takeOwnedTree in merge-base.ts) reads the
parents’ commits, never the stages. For each path:
- it takes the base’s committed blob when the base has the path;
- it takes the branch’s committed blob when only the branch has it;
- it removes the path only when neither parent has it, which is a deletion both sides made.
The resolution therefore drops nothing a parent holds, and every kept payload
holds the bytes its name hashes. The orphan it keeps is the writer’s to remove.
The writer is named in the pattern as prunedBy: "subgraph:jsonld", a writer
and not a check: regen still derives the check from the CI workflow. When
regen runs that writer, the writer deletes the orphan.
Both #2145 checkpoints still hold, and they are why this works. The
resolved checkpoint sees no drop. The staged checkpoint sees the writer’s
deletion of the superseded payload, a path the branch added, and allows it
because the disk no longer holds it either. That is the legitimate replacement
of a content-addressed payload, which a single post-regen check would have
refused. The test fixture in merge-base.test.ts covers this, and so does its
refusal sibling, which shows that take-base on the same merge is refused.
Adding another owned-tree pattern needs a writer that deletes what it
did not write. Without one, the kept orphan survives regen, and only the
writer’s :check can catch it. The test requires every owned-tree pattern
to name a prunedBy script that has a :check twin. Under --no-regen (a
merge train), the orphan stays in the member’s merge commit until the train’s
final regen.
navbar-rail-data — owned tree, regenerate
**/docs/assets/navbar/rail-*.js is the navbar’s shared rail data. Each file
is named rail-<hash>.js after the content hash of the rail JSON it carries
(lib/harness-rail.ts), so a changed rail is a new file and the old one is
orphaned. bun run cat navbar:assets deletes every rail file no committed page
names, and navbar:assets:check fails on one. Both sides of a merge add their
own hash, which is why this is owned-tree and not take-base, for the same
reason as subgraph-payload above. Found 2026-10-06 when #2197’s merge was
refused as no declared pattern.
The neighbours navbar.js and navbar.css are not covered: they have
fixed names and a byte-for-byte check, and stay refused.
seed:ready reads the same declaration: deleting or renaming an owned-tree
path is not a move of the layer, because a regen renames it anyway (owner,
2026-10-06).
readme-generated-regions — hunk by hunk (209)
Directory READMEs mix authored prose with generated regions
(<!-- kg:subgraph:begin --> … :end -->) whose file counts and listings
change on every concurrent addition. A hunk inside a region takes the
base’s side and the generator rewrites the region; a hunk in authored prose,
or one that moves a region boundary, refuses. The file-count churn
(beans/README.md, 76 alone) is resolved here rather than by changing what
the README shows — the owner kept the exact counts (#1707).
standalone-baseline — take the base, regenerate NOTHING
cat-harness-tools/scripts/standalone-baseline.json, check:standalone’s list of
accepted standalone failures (bean ho66, #1977). It is the one take-base
pattern with no regeneration, because it is a ratchet. Re-measuring after a
merge would write any new standalone failure into the list unreviewed, which
is exactly what the gate exists to stop. Git conflicts on it only when BOTH sides
changed the list, so taking the base is fail-closed: if this side’s change was
a new failure, its CI goes red until its author runs bun run
standalone:baseline on purpose; if it was a fix, all that is lost is a shorter
list, and the check reports it. Not measured by replay: the file was new when
the pattern was added, so there was no merge history to count.
beans — refused, by declaration (44)
Before you go looking for the other session, check whether there is one.
A beans/defs/** conflict has two causes and they need different handling:
| cause | how to tell | what to do |
|---|---|---|
| two sessions edited one bean | the default branch’s claim note names a branch that is not yours | a coordination question — talk to them |
| one session: the claim landed on the default branch, the completion stayed on the branch | the note names your own branch | no coordination needed; keep the completing value and the note |
The second is beans:claim’s normal path, not an edge case: it pushes the
claim to the default branch so a sibling sees it before your PR exists (bean
35nj), and your branch then edits the same bean to finish it. Bean 24fa
measured it, and the refusal text used to assert the first cause for both —
sending an agent to hunt a sibling that does not exist.
claim-bean.ts now brings the claim COMMIT into the claiming branch after the
push (bean 24fa, owner option 1, #2376): a fast-forward when the branch has
nothing of its own yet, otherwise a merge commit. The claim is then in the
branch’s merge base, so finishing the bean merges cleanly. Copying only the
claim’s text was not enough. On #2344 the copy was byte-identical and the bean
still conflicted on updated_at and the end of the body, and a conflicted PR
gets no CI at all, so it can never turn green.
The conflict therefore now means the claim could NOT be brought in: a detached
checkout, local edits in the way, or a merge that would conflict. beans:claim
says so (NOT in this branch's history) and falls back to the text mirror
(mirrorClaimNote). Resolve the remainder by merging the default branch in, or
by keeping the completing branch’s value and the note — and never by unioning
the front matter, since a duplicated updated_at is
check-bean-front-matter’s recorded defect.
Bean definitions are authored work-plan state. Two sessions editing one bean
is a coordination question (bean-coordination), and a duplicated
updated_at from a careless resolution is check-bean-front-matter’s
recorded defect.
artefact-verification — refused, by declaration
scripts/artefact-verification.json, the per-check consumer-verification
declaration. This is the entry that exists to stop a sweep, not to resolve
one — and the one most likely to be globed by mistake, because it reads like a
generated sidecar (under scripts/, a .json, its key set derived from
package.json) and because it is the only path that refused on two open PRs
at once (#1958, #1955, swept 2026-10-03).
Two facts, either one sufficient:
task-io.tsclassifiescheck:artefact-verificationasREAD_ONLY. No script writes the file, soregenhas no writer to run andtake-baseis a silent discard rather than a resolution.- Its own
_commentrequires everynoneentry to carry a reason, in prose, and says the file “may only SHRINK”. A branch that adds a gated check adds an authored sentence — precisely what taking base would drop.
So a conflict here is a genuine editorial merge: both reasons are wanted, and which survives is a judgement. Human merge is the cost of the file’s shape, not a hole in this catalogue.
The falsifier is recorded (bean mjl3): if a --write is ever added that
composes the derived key set and carries existing reasons forward, the file
becomes regenerable and this entry becomes take-base. Nothing today wants such
a writer, and adding one to make merges cheaper would be building a mechanism to
serve the merge tool rather than the gate.
uploads — refused, by declaration (30)
Uploaded source material: provenance-bearing input, never regenerated.
Adding a pattern
- Measure first: replay recent merges with
git merge-tree --write-treeand look at what the conflicting lines are, not just which files. - Add an entry with its
whyand the narrowest glob that covers it. Order matters: the first match decides. - Add a test that the unsafe neighbour is refused, not only that the case resolves.
- Add a section here.
In CI — opt in with the merge-main label
.github/workflows/merge-main.yml is a second CALLER of the same command,
never a second resolver (bean d33q part B). When main moves, it runs
bun run cat merge:main on every open, same-repository PR labelled merge-main
that is behind main, one live run per PR (a newer run cancels an older one).
- It pushes only a proved merge, as a fast-forward of the branch it checked out; if the author pushed meanwhile the push is rejected and nothing is overwritten.
- A refusal pushes nothing and lists the ✗ paths in its comment. Adding a
pattern stays a person’s change, made here. It does NOT label the PR: owner
ruling 2026-10-03, “stop using
needs-merge-human, it’s confusing things”, and the measurement behind it is that a label asserting “a person is needed” outlives the condition that set it. Measured the same day: of ten PRs carrying it, six were stale — clean againstmainAND pushable by the bot, because the workflow change that had blocked them was already in their merge-base. A stale hold is indistinguishable from a live one without re-deriving why the push failed, which is themerge-queueskill’s own §”A hold has an EXPIRY and a trigger, or it outlives its reason” at the scale of a whole queue. The reason lives in the comment, which is dated and names the run; a label is a claim with no timestamp. - A declared resolution that FAILS is a refusal too, not an unexplained
red job. If taking the base or resolving a region throws,
merge-baseprints ` ✗[ : could not resolve] — <git's first error line>` and aborts, so the bot's comment names the path and the error. Measured 2026-10-03 on #1801: the branch ignores `cat-harness/test/results/` while the files stay tracked, git exits 1 when staging an UNMERGED ignored path (it stages it anyway), and merge-main went red on every push to main. Staging a conflicted path uses `git add -f` in both resolvers, safe because the path is one git listed as unmerged. `regen` reporting an unrepaired check is still a red job: that is a defect for a person to read, not a conflict. - One comment per PR, edited in place on every run — except a run that
was cancelled (a newer push to
mainsuperseded it) or whose merge step reported no status, which leaves the comment untouched. Before #1854 such a run rewrote it to “Error (exit )”. The text is composed bycat-harness-tools/scripts/merge-main-comment.ts, which is unit-tested. - The merge commit is still judged by CI: pushed with
MERGE_MAIN_TOKENwhen that secret exists, otherwise followed by a dispatch ofcode-quality-gates.ymlon the branch, because a GITHUB_TOKEN push triggers no workflow.
Why it is worth a runner: on #1754 (2026-10-01) one round took 19–46 min in a
shared agent container while main moved every few minutes, and GitHub runs no
pull_request CI on a conflicted PR — a 28-conflict resolution went ‘dirty’
again within a minute of its push. Use the label on a PR that is waiting on
review rather than on its author; leave it off a branch somebody is pushing to.
When a merge-train member is refused: bean, hand back, or dispatch
A merge steward builds a train: merge-base.ts --no-regen for each
member, then one bun run cat regen, then one CI run. A member is refused
when its merge hits an authored or undeclared conflict, or when the train’s
combined result fails a gate that the member alone did not fail. The steward
drops it and the train goes on without it. processes/sdlc/merge-refusal.bpmn
executes what happens to the dropped member. The author’s side, the queue
and the bounce-back are the merge-manager SOP in
#1802 (steps 10 and
12). The hand-back format is the
agent-handoff skill.
Why this exists. Owner, 2026-10-02: “if a merge in queue cannot be merged for some reason, create a new bean (under appropriate epic/story…), hand it back to the sibling (use the agent-to-agent handoff process with a fail condition) for resolution, or dispatch an agent as appropriate.” Before this, each refusal got an ad-hoc PR comment or chat message, and nothing in the work plan said it had happened. The refusals on 2026-10-02 alone show the three causes this section has to cover:
| PR | refused on | cause |
|---|---|---|
| #1808, #1819 | bean ob3m | two sibling navbar PRs edited the same bean (beans, refused by declaration) |
| #1804 | artefact-verification.json | authored file, no declared pattern |
| #1822 | gen-library-jsonld.ts | authored conflict with #1881, which overlaps it |
| #1852 | proposals/index.md | authored index, no declared pattern |
| #1764 | glossary page budget | each PR passed alone; the combination exceeded the budget |
1. Open one bean per refused PR, and check before you create
beans create dedupes on nothing (see todo-manager §”Check before you
create”). The title carries the PR number, so the check is exact:
beans list --json --search 'title:"Merge refused: #1822"' \
| jq -r '.[] | select(.title | startswith("Merge refused: #1822 "))
| select(.status != "completed" and .status != "scrapped") | .id'
- An open bean already exists for this PR: do not create a second one.
Add the new refusal to it under
## Attemptswith--body-append(never--body-file, which replaces the body). - Otherwise create one:
type: bug, titledMerge refused: #<n> <first refused path or gate>.--parentand the create are one action (todo-manager§”After you create”).
The parent is the epic or story the PR’s own work belongs to, not the
steward’s epic. Find it in this order: the bean the PR’s branch adds or claims
(git diff --name-only origin/main...<head> -- beans/defs/), then a bean id in
the PR body or commit messages. Parent the refusal under that bean’s parent
when the PR’s bean is a task, or under the PR’s bean itself when it is an epic
or story. Then mark it --blocking <the PR's bean>. Only when the PR names no
bean at all does it go under the steward’s own merge-gate epic (nok9 here),
and the bean body says so.
The body records what the owning session needs in order to act without asking:
PR #1822, head <sha> (the sha the train used), dropped from train
`<train name>` (its other members, and the base sha it merged onto).
## Refused
- `cat-harness/scripts/gen-library-jsonld.ts`: authored conflict, no declared
pattern. Overlaps #1881, which is already in main.
## Done when
- [ ] the PR's head merges main with `bun run cat merge:main` and no refusal, and
CI is green on that head
- [ ] the PR re-enters a train with a new `ready: <sha>`, and lands
For a combined-gate refusal (#1764), list the gate, its first failing line, and which other members the train held, because the fix may belong to either side.
2. Hand it back to the owning session, with a fail condition
The owning session is the one in the PR’s Claude-Session: trailer, or in its
body. Hand the bean back to it in agent-handoff form: the steward is the
coordinator and verifier, the owning session is the executor, and the
owner rules on anything authored. The bean carries:
## Roles: steward (coordinator, verifier, closes), owning session (executor), owner (authored conflicts, any exception).## Report to: the PR. One line per event:<id>: started,<id>: done <sha>,<id>: blocked <why>.## Done when: as above. The verifier is the next train, not the executor’s own log.## Fails if, the fail condition, which is when the steward takes the bean back:- no
startedline on the PR within 4 hours; - or no push to the PR branch within 24 hours;
- or the owning session is gone: archived, failed, or unknown to
get_session; - or the executor reports
blockedon something it cannot settle.
- no
Send the hand-back to the session itself (send_message), as one line
that names the bean and the branch, never the instructions. The instructions
are in the bean, on the branch. After that, the steward does not edit the
bean: anything it learns goes to the PR (agent-handoff §1).
The fail condition is a takeover, not a reminder, and that differs from
agent-handoff §7 on purpose. There, the executor is in another environment
and the coordinator cannot do the step, so an expiry only means asking
again. Here, the work is in this repository and an agent can do it, so a
missed deadline moves to §3. The one thing that is never done is putting a
second writer on a branch whose first writer may still be live: before taking
over, read the bean’s holder note and the PR’s last push (bean-coordination
§”A quiet claim”).
3. When no live owner can be reached, dispatch one agent
If the owning session is gone, or the fail condition has fired, dispatch one
agent on the PR’s branch (dispatch-agent). One agent is not a swarm; a
second one is, and needs the owner’s per-swarm permission (swarm-management).
Its budget is modest: one fix, one push, one report.
- It may do what the patterns would have done had they been declared: bring
main in with
merge:main, regenerate, and fix a gate failure that has one mechanical answer. It pushes merge commits only, and never force-pushes. - It may not decide an authored conflict. Two sibling PRs editing one bean
(
ob3m), one script changed differently on both sides (#1822 against #1881), or a page over its budget only in combination (#1764): each of these is a choice between authors. The agent writes the question to the owner on the PR, with both sides quoted, the options and a default, and stops (interaction-modality§4.1). - The dispatch is recorded on the bean by the steward, before the agent
starts:
## Attempts, with the date, the reason the fail condition fired, the agent’s session or task id, and the budget.
Two failed attempts with the same cause go to the owner instead of a third
dispatch (agent-handoff §7).
4. Comment on the PR once, and edit that comment
One comment per refused PR, edited in place as the bean moves on, as
merge-main.yml does with its own comment. It names the bean, the head sha,
the train, the refused paths, and who holds the fix (the owning session, a
dispatched agent, or the owner). A second refusal of the same PR updates the
same comment. Remove ready-to-merge when #1802’s SOP is in force (step 10).
5. Close the bean when the PR’s fate is settled
The steward is the verifier, so the steward closes:
- the PR lands:
completed, with the merge commit as evidence; - the PR is closed unmerged:
scrapped, with the reason and a link. The bean is never deleted (bean-coordination).
A fix that is pushed but has not landed does not close the bean. The member
must survive a train first. A refusal bean left open after its PR has gone is
the stale state that the needs-merge-human label already shows (bean u7be
item 4).
Processes that run this skill
| process | step(s) that name it |
|---|---|
| Merge the base branch in | Classify every conflicted path against the declared patterns; Resolve each by its declared strategy; Abort, restore the tree, list what was refused |
| A refused merge-train member | Record the refusal on the bean; Comment on the PR once, linking the bean; Hand back, with a fail condition; Merge main in, regenerate, fix mechanical gates; Re-enter a train on the new head |
| A merge train | Hand it back (calls a sub-process); Hand the culprit back (calls a sub-process) |