---
title: Markdown merge driver (why two agents can edit one document)
---

# Markdown Merge Driver

## What it is

A custom Git **merge driver** that auto-resolves the most common merge conflict in this repo — two agents independently _appending_ to the same memory document — and steps aside (falls back to normal conflict markers) for anything it can't resolve safely. It is developer / repo-maintenance infrastructure, not an in-app feature: there is no UI, no setting, and no button. It runs automatically inside `git merge` once installed.

### The problem it solves

Omniscio is normally driven by many Claude Code agents working in **parallel git worktrees** at the same time. Those agents constantly edit the same shared "memory" documents — appending a bullet to `MEMORY.md`, adding a row to a postmortem index, recording a new gotcha, dropping a new feedback note. These files are append-heavy logs by nature.

Git's default text merge is line-range based. The moment two branches insert different lines at the **same place** in such a file, Git declares a conflict and writes `<<<<<<<` / `=======` / `>>>>>>>` markers, even though both edits were really just _additions_ that could coexist. Before this driver, the repo's `safe-merge` workflow hit these spurious conflicts constantly, and historically the bash helper would even abort outright on `CLAUDE.md`. Each one cost a human (or an agent) a manual resolve for a merge that was never actually in conflict.

## Where to find it

Nowhere in the app. It is part of how this repository's git is configured, so the way you meet it is by merging a branch: the conflict you expected simply does not appear.

## How it behaves

### What it does, in plain language

When Git performs a three-way merge of a file, it knows three versions: the common ancestor (**base**), the version on your current branch (**ours**), and the version being merged in (**theirs**). A registered merge driver receives all three and decides the outcome for that one file.

For each registered memory document, this driver asks a single conservative question: **did both sides _only insert_ new lines, without deleting or rewriting any existing line?**

- **Yes — both sides are purely additive** → it produces a **union**: every original line stays in place, and each side's inserted lines are interleaved at the position where that side put them. If both sides happened to add the _exact same_ line at the same spot (e.g. two agents appended the identical bullet), the duplicate is collapsed to one copy. The pairing is one copy per side, so a line that ONE side wrote twice (two new rules that each end in the same "Enforced" line) still appears twice — until 2026-09-24 it collapsed too, and the git-authority guard refused that land as dropped work. Git is told the file merged cleanly — no conflict markers, no human intervention.
  - **Except when the two additions are rival VERSIONS of one block** — both branches append the same new section, differing in a few lines in the middle. Collapsing the lines they share would leave the lines they worded differently stranded at the end of the file, cut off from the sentences around them. The driver detects that case (a shared line that comes _after_ a line only one side has) and **conflicts instead**, because two versions of one paragraph are a disagreement to resolve, not two separate additions to concatenate. This is the 2026-09-13 contract corruption: three link citations a fix had already converted to plain text came back as orphaned sentence fragments and re-broke the doc gate, with git reporting a clean merge.
- **No — anything else** (one side deleted or edited an existing line in place, the two sides gave the same frontmatter field different values, a section was reordered, etc.) → the driver **declines** and hands the file back to Git's standard merge, so you get the normal `<<<<<<<` / `=======` / `>>>>>>>` markers and resolve it by hand, exactly as you would have without the driver installed.

The bias toward declining is deliberate. **A missed auto-merge costs a few seconds of manual resolve; an incorrect auto-merge silently corrupts a memory document that every future agent session trusts.** When in doubt, it conflicts.

### How "additive" is decided

The driver treats each file as two regions and runs the same logic on each:

1. **YAML frontmatter** (the `--- ... ---` block at the top, if present). Metadata is too semantically loaded to blend silently, so it merges **field by field**: one side's changed fields combine with fields the other side only added, but if both sides gave the **same** field different values, **each** side rewrote a different existing field, or a side removed or reordered fields, that region conflicts even when the body could have merged.
2. **The markdown body.** The body auto-merges only when the base version's lines appear, in order, as a subsequence of _both_ ours and theirs — i.e. every original line is still present and in its original relative order on each side. That is exactly the signature of "only insertions happened." If any base line went missing or moved on either side (a deletion, an in-place edit, a reorder), the subsequence check fails and the body conflicts.

Everything is compared as **opaque lines** — a table row, a bullet, and a heading are all "just a line." There is no markdown parsing, no "merge these two bullets by their leading text," no heading-dedup-by-slug. Every smarter heuristic that was considered introduced a case where two _different_ additions silently collapsed into one, so the driver deliberately stays dumb and literal. Blank lines always pass through (they are meaningful paragraph separators in markdown).

### Which files it applies to

The driver is wired up in [`.gitattributes`](../../.gitattributes) (the `merge=mddoc` attribute). As of **2026-05-31 a single catch-all covers EVERY memory document**:

- `.claude/memory/**/*.md` — every hand-edited memory doc, at any depth
- `docs/llm-library/*.md` — this library's index **and all its doc bodies**, plus `docs/developing/DEVELOPER_GUIDE.md` _(INDEX 2026-05-30; bodies + guide 2026-08-01 — additive prose, so two branches' disjoint edits union and a same-section change still falls back to conflict markers)_

This replaced an earlier hand-maintained prefix list (`gotchas-*`, `postmortems/*`, `feedback_*`, `project_*`, `reference_*`, `contracts/*-contract.md`) that silently missed any file whose name matched none of those patterns — about **half** the memory corpus, including hubs like `agent-test-concurrency.md`, `data-model.md`, `process-management.md`, `ipc-contracts.md`, and the `frontend-*` / `testing-*` / `patterns-*` families. Those uncovered files fell back to Git's default text merge and hard-conflicted on the simplest two-branch append, which **aborted a whole "Full Local Git" batch land** and forced a manual rebase+resolve of the offending branch. The catch-all closes that gap; a lint test ([`tests/unit/lint/memory-merge-driver-coverage.test.ts`](../../tests/unit/lint/memory-merge-driver-coverage.test.ts)) now fails if any memory file is ever left uncovered.

The **generated** indexes — `MEMORY.md`, and the `contracts-index*` / `postmortems-index*` / `postmortems-archive*` catalogs — sit _below_ the catch-all in `.gitattributes` and **override** it (last match wins) to dedicated drivers (`memory-index`, `contracts-index`, `postmortems-index`) that resolve a merge by **combining the rows already present in both branches' copies of the index** and re-rendering them in canonical order — _not_ by rebuilding from the source files. (At merge time the incoming branch's newly-added source file isn't on disk yet, so a rebuild would silently drop its row; reading both sides' already-rendered indexes recovers it.) The contracts and postmortems catalogs are each a **hub + per-domain files** (e.g. `contracts-index-<domain>.md`), so their driver routes by pathname: a per-domain file unions its rows, a hub recomputes each domain's count (guarded against a degenerate / wiped ancestor so the arithmetic can never _inflate_ — see the auto-lander contract's [S25](../../.claude/memory/contracts/auto-lander-contract.md)). In the normal case they resolve rather than conflict — but "never" is too strong: safe-merge keeps an explicit `--ours` fallback for each of these paths, used only when git has actually left the file unmerged, so do not treat that handling as dead code. `mddoc` is only for files a human or agent edits directly.

Because a hub's count is **derived** (merged by count arithmetic, not by re-reading the row sets), a **from-disk re-settle** is the authoritative backstop once the merge completes and the source files are materialized: [`.husky/post-merge`](../../.husky/post-merge) regenerates the catalogs after a `git merge` only in the master sync's regen-only mode (an ordinary merge or pull regenerates nothing), and — since the **auto-lander lands in a HOOK-LESS recovery worktree** (mostly by cherry-pick; a clean-merge branch whose replay would diverge lands by `git merge --no-ff` instead — but that recovery worktree has no `.husky/`, so post-merge never fires on EITHER path) — the lander runs the equivalent settle after every land ([`land-index-settle.ts`](../../src/main/services/auto-lander/land-index-settle.ts), auto-lander contract S25). Without that lander settle the `contracts-index` hub count silently doubled against a wiped ancestor on every contract-touching land (811 → 1606 → …) and `contracts-index-fresh` stayed red; with it, a committed hub always matches a fresh regen.

**`CLAUDE.md` is deliberately _not_ registered.** It lives at the repository root, not under `.claude/memory/`, so the catch-all above never reaches it. It has structural rules — table-row "contracts," an ordered Frontend / Backend / Security section layout — where a naive additive union could interleave two edits into something subtly wrong. It also carries a hard CI size budget — **10,000 tokens** for the whole always-loaded rule set, with an 8,000-token target. The budget counts tokens, not lines: what is measured is the text a session actually loads (CLAUDE.md plus every file under `.claude/rules/`), so a big-print file and a dense one are not treated alike. Because an additive union keeps _both_ branches' new rules, two branches each adding bullets could quietly push it over the cap and turn a safe conflict into a failed build. For now `CLAUDE.md` keeps Git's default text merge and the `safe-merge` workflow's hand-resolve flow.

Files larger than **1 MiB** also skip the driver and fall back to Git's default merge, to avoid pathological memory use if an oversized file is ever registered by accident.

### Frontmatter: when two branches both add a field

The rule above — "if both sides changed the frontmatter, conflict" — was too blunt in the one case
that happens most. Contracts carry small metadata fields (`system:`, `subsystem:`, `roadmap:`), and
a repo-wide re-classification means dozens of branches each adding a field to the same file.

So the frontmatter now merges when **both sides only ADDED keys** — even the same key with the same
value, added in different places, collapses to a single line. It still refuses when the two sides
give the **same key different values**: only a human knows which value is right.

**Field by field (2026-09-23).** One side editing a field while the other adds a different one used
to conflict too. Measured on three postmortem files: master bumped `status:`, `recurrences:` and
`last_updated:` while a branch added `symptom:` — plain git merges that cleanly, but the driver
conflicted the whole frontmatter and the auto-lander bounced the branch. The field merge now
handles it: a field only one side changed is taken from that side, and a field only one side added
is kept where that side put it. It still conflicts when both sides change the **same** field
differently, when **each** side rewrote a different field that was already there (metadata fields
are coupled — `system` and `subsystem`, say — and only an author knows whether two edits agree), or
when a side deletes or reorders fields.

There is a second, sharper reason this matters. When the driver used to decline a frontmatter
merge, it handed the file to Git's plain text merge — which has no idea that frontmatter is a
_list of fields_ rather than a list of lines. Given two additions at different spots it kept
**both** and reported success. On 2026-09-05 that produced a contract with two `system:` lines from
a merge that looked perfectly clean; nothing noticed until an unrelated tool crashed trying to read
it, hours later and in a different place. A refused frontmatter merge now writes real conflict
markers itself, so "clean" always means clean. A repo-wide check also fails the build if any
memory doc, contract, or postmortem ever ends up with a repeated field.

### Frontmatter: the four stamps a machine writes

Most header fields are written by a person — a title, a status, a `roadmap:` anchor — and when two
branches set one of those differently, only a person knows which is right, so the driver still
stops. Four fields are different, because nobody types them:

- `last_updated` — the date a commit hook stamps on every commit that touches the doc.
- `tokens` — a size estimate the same hook recomputes from the text.
- `last_incident` and `recurrences` — a postmortem's most recent incident date and how many times
  it has happened.

Two branches that touched one doc on different days disagree on these nearly every time, and until
2026-09-23 the driver treated that like a real disagreement: the whole doc conflicted even when the
prose merged cleanly. In the week before the change, 68 of the 90 doc conflicts the driver refused
were fights over these lines and nothing else.

So the driver now works them out instead of asking:

- the **later** of the two dates;
- the incident count as the starting count **plus each side's increase** — two branches that each
  record one new incident end two above where they started, while the same increase arriving from
  both sides (a replayed commit) counts once;
- the size **re-counted from the merged text** — never either side's stamp, since each side counted
  only its own text — and only once that text itself merged cleanly.

It still refuses — ordinary conflict markers, a person decides — whenever the answer is not
mechanical: a count one side lowered, a stamp one side deleted while the other changed it, or a value
that does not read as a date or a whole number. Every hand-written field keeps the old rule too,
including one branch editing an existing field while the other adds a new one: the owner kept that
case stopping for a person on 2026-09-23, to revisit after the weekly merge-health report. Setting
`AMC_DISABLE_MDDOC_COMPUTED_FIELDS=1` puts the four stamps back on the old rule — for bisecting one
suspect merge, never as a way of working.

### Generated files get regenerated, not merged

Two documents describe how the app throttles its own background work: a full generated table
(`agent-load-governance-registry.md`) and a hand-written map with one generated table inside it
(`performance-gating-map.md`). Both are produced from a single source — the registry, which is
**one file per entry** under `scripts/lib/agent-load-governance-registry/`, assembled by the
generated collector beside it.

They used to merge like ordinary prose, which is wrong for generated content in a specific way:
when two branches each regenerate the same table, the right answer is neither branch's version —
it is a fresh render from the combined source. A text merge just sees two rewrites of the same rows
and conflicts on every one. On 2026-09-05 that was a large share of a 71-conflict rebase where the
correct resolution, every single time, was "re-run the generator."

They now have their own driver that does exactly that: it re-renders the fully generated document
from source, and for the mixed one it merges the human prose normally while re-rendering just the
generated block inside it. A genuine disagreement in the prose still conflicts for a human, because
nothing can regenerate someone's sentence. If the driver cannot run for any reason it steps aside
rather than failing — a merge driver that crashes makes Git report a conflict that does not exist,
which would bounce a perfectly clean branch out of the automatic merge queue.

The registry's collector, `index.generated.mjs`, gets its own driver, `load-governance-registry`,
suited to a list. It holds one import line and one array entry per governor file, in id order, so
two branches that each add a governor whose names sort side by side insert at the same spot, and a
text merge conflicts even though the answer is simply "keep both". The driver reads the list of
entries out of each side, keeps every entry either side added, drops every entry either side
removed, and writes a fresh render of the result. It never rebuilds the list from the entry files
on disk: while Git merges, the other branch's new files are not in the folder yet, so a rebuild
would silently drop them. A side that is not an exact generated copy — a hand edit, or a file cut
short — goes to Git's ordinary text merge instead, the merge this file got before it had a driver,
so a damaged side can never be read as "every governor was deleted".

### Generated files and registries: the structural merge rules (2026-09-27)

Four more rules sit beside this driver, for the conflict shapes that stopped the most sessions in a
scan of 664 stopped sessions over eight days:

- **A file the land rebuilds keeps one side.** The alert catalog pair, the guard registry and the
  help-site analytics script are re-rendered by the auto-lander after every land, so a merge keeps
  master's copy and moves on (the `settle-owned` rule). Only a file whose name or header says it is
  generated qualifies; a rebuilt file with no such header keeps a listed exemption until its
  generator writes one, because the rebase check would otherwise report the dropped side as lost work.
- **A front door's generated table merges row by row.** When two branches each re-render the
  generated block of a front door, its rows merge by their link, so both branches' new rows survive;
  the hand-written part merges exactly as described above.
- **Hand-written registries merge entry by entry.** Lists every branch appends to — the lander
  switches, the alert type table, the alert key sets, a guard's allow-list — carry a
  `// @merge-keyed-list` line above their declaration. Two branches that each add a different entry
  both keep theirs, the same entry changed two ways still conflicts, and anything the rule cannot
  prove gets git's ordinary merge. Off switch: `AMC_DISABLE_KEYED_LIST_MERGE=1`.
- **Every conflict shows the original.** The installer pins `merge.conflictStyle` to `zdiff3`, so each
  conflict carries the base text between the two sides, and the auto-lander restores the setting if a
  rebuilt config ever loses it.

A new generated file has to arrive with one of these rules or a reasoned exemption — a build check
refuses it otherwise. The rules themselves are in the structural merge rules contract.

### When the fallback itself cannot run

"Hands the file back to Git" means the driver shells out to `git merge-file`, which is what actually
writes the `<<<<<<<` markers. On a fork-starved box that child can fail to **start** — the same
process-exhaustion storm the driver family already retries around. Until 2026-08-26 the driver then
reported a conflict while leaving the file byte-identical to your side, with no markers in it at
all: Git left the path unmerged, but anyone opening the file saw something that looked cleanly
merged, staged it, and silently dropped the incoming branch’s changes. All four dedicated drivers
now share one fallback that writes the markers itself when `git merge-file` never ran, so a
"conflict" always means there is genuinely something to resolve in the file.

### The two lists that must agree

`.gitattributes` says WHICH driver a path uses; a separate installer
([`scripts/setup-md-merge-driver.mjs`](../../scripts/setup-md-merge-driver.mjs)) REGISTERS what each
driver name actually runs. Git does not check that those two lists match — an attribute naming a
driver nobody registered silently falls back to the plain text merge, with no warning anywhere. A
lint now compares the two sets in both directions, so a renamed or forgotten driver fails the build
instead of quietly turning the protection off.

### How it gets installed

The driver is a small TypeScript program. Two pieces have to be in place for Git to use it:

1. **The `.gitattributes` rule** — committed to the repo, so it travels with every clone. This says "for these paths, use the merge driver named `mddoc`."
2. **A local Git config entry** that maps the name `mddoc` to the actual command. This is **per-clone** and lives in `.git/config` (it is not, and cannot be, committed — Git deliberately ignores committed driver definitions for security).

Step 2 is handled automatically by an idempotent installer, [`scripts/setup-md-merge-driver.mjs`](../../scripts/setup-md-merge-driver.mjs), which runs:

- as part of `npm install` (a `postinstall` hook in [`package.json`](../../package.json)), and
- as a fallback step inside the repo's `safe-merge` skill, so the driver self-heals if a clone never ran a full install.

It registers each driver as a bounded `sh` **retry loop** wrapping `node --import "file://<main-checkout>/scripts/lib/node-ts-register.mjs" scripts/md-merge-driver.ts %O %A %B %P` — the repo's **shared TypeScript preload**, which runs the driver on Node's own type stripping (see below). The four `%`-tokens are how Git passes the base / ours / theirs file paths and the file's pathname to the program. Three robustness details matter:

- **`node --import` + the absolute shared preload, not a bare `tsx` and not a shim.** Git runs merge drivers with the ambient PATH of whatever triggered the merge — `merge-all-ready`'s recon probe, a cherry-pick, a plain `git merge` — so nothing but `node` can be assumed on PATH (a bare `tsx` died there with `command not found`). An **absolute `file://` URL to the main checkout's** preload resolves from any working directory, including a recovery worktree or an older commit replayed during a land that has no preload of its own. The launcher has moved three times, each for a measured failure: the `node_modules/.bin/tsx` shim was banned after an incident class wiped the whole `.bin/` directory while the packages survived (2026-07-04, every driver a permanent `not found`); the tsx **CLI** gave way to tsx's in-process `--import` loader because it forked a child `node` per driver per file, which EAGAIN-failed or was OOM-killed under load (2026-07-31); and **since 2026-09-27 every driver starts on the shared preload** (`scripts/lib/node-ts-register.mjs`), which runs TypeScript on Node's own type stripping — one process, no loader thread, no esbuild, and no listing of tsx's shared cache folder, which had grown past a million files and stalled merges (2026-09-24). The absolute URL's space (in "Agent Orchestrator") is `%20`-encoded, which Git preserves through its `%O`/`%A`/`%B`/`%P` placeholder expansion.
- **Spawn-retry resilience.** When the machine is under heavy load (many parallel worktrees / processes), the OS can momentarily refuse to start a new process — even loader mode's single `node` can fail with "Resource temporarily unavailable" before the driver runs (loader mode makes this far rarer than the old CLI's double-fork, but not impossible). Git cannot tell "the driver could not start" apart from "the files genuinely conflict", so without help a momentary hiccup becomes a phantom conflict that bounces an otherwise-clean branch out of the auto-lander. The retry loop catches exactly that: a **spawn-level failure** (the shell could not run the driver at all) is retried a few times — it succeeds the instant a slot frees — while a **genuine** non-zero result (the driver ran and reported a real conflict) is passed straight through, never retried. The loop is bounded, so sustained exhaustion still gives up cleanly.
- **The auto-lander verifies driver health before landing.** Its per-repo health gate existence-checks every registered driver's launcher file (the preload, or tsx's loader on an older registration), re-runs this installer automatically when one is missing, and — only if that self-heal doesn't take — pauses landing for the repo with an inbox alert instead of bouncing clean branches as fake conflicts. The same gate is how the preload reached every machine without anyone running `npm install`: a registration still on tsx's loader triggers one re-run of the installer (another only while each run makes progress). Since tsx left the dependencies (2026-09-28) that loader file is missing, so such a registration is a broken launcher like any other.

Because all worktrees of one clone share a single `.git/config`, **installing once registers the driver for every worktree of that clone** — you don't re-run it per worktree.

### Pre-built bundles: why `node --import` is the _fallback_, not the fast path

Every registration is a two-armed guard:

```
if [ -f scripts/generated/merge-drivers/<name>.mjs ]; then
  AMC_MERGE_DRIVER_TSX="<preload url>" node scripts/generated/merge-drivers/<name>.mjs %O %A %B %P
else
  node --import "<preload url>" scripts/<driver>.ts %O %A %B %P
fi
```

Git runs a merge driver **once per conflicted file**, and a TypeScript launch is paid per invocation. Under tsx's loader it registered a module hook and transpiled the driver plus its whole relative import graph, with no cache — measured on the owner's box 2026-09-17, same fixture, real `--merge` mode: **18.14 s per per-domain index shard under tsx against 0.36 s for a pre-built bundle**, with `node -e 0` at 0.15 s and tsx startup alone at ~16 s. The shared preload that replaced tsx (2026-09-27) is far cheaper, and a plain bundle is cheaper still. A rebase of a memory-doc branch conflicts on a dozen or more regenerated index shards at once, which is how a 5- and a 10-minute tool budget both blew inside the first few files.

So [`scripts/build-merge-drivers.mjs`](../../scripts/build-merge-drivers.mjs) (`npm run merge-drivers:build`) pre-bundles each qualifying driver with esbuild into a plain committed `.mjs` under `scripts/generated/merge-drivers/`. Three properties matter:

- **The bundle is a plain `node` program**, so the common arm costs process startup only. It is committed rather than built on demand because the auto-lander merges inside a _recovery worktree with no `node_modules`_ — and because the bundle a worktree runs then comes from the same commit as the sources in that worktree.
- **Staleness is FAIL-OPEN.** Each bundle records the sha256 of every source that went into it and re-checks them at startup; on any mismatch it runs the TypeScript source instead. It never exits non-zero for being stale — git records a failing driver as a CONFLICT, which is the class that wedged the auto-lander three times (2026-06-25 / 07-04 / 07-31).
- **The bundle graph must stay inside `scripts/`** (`BUNDLE_SOURCE_ROOT`). A bundle hashes every file it inlined, so each becomes a file somebody must rebuild after touching — fair for merge-driver infrastructure, unfair for a high-churn shared utility like `src/shared/utils.ts`. A driver that grows such an import silently drops back to the TypeScript arm.

#### `bundleEntry` — bundling from a file that is not the registered script

The two memory-doc index drivers (`postmortems-index`, `contracts-index`) could not be bundled as written: the generators reach `src/shared/utils.ts` and `src/shared/token-estimate.ts`, which the size-and-scope rule refuses. Only their **corpus scan** needs those, so each driver's merge surface was split into `scripts/lib/` and given its own bundle entry.

That splits the registration from the thing it runs, and the registration must NOT be re-pointed at the new entry: `.git/config` is shared by every worktree of the clone, so a new path would make an **older commit replayed during a land** resolve to a file that does not exist in its tree — a driver that cannot start, which git records as a phantom conflict. Instead an entry is declared as `bundleEntry` in [`scripts/setup-md-merge-driver.mjs`](../../scripts/setup-md-merge-driver.mjs): the launcher string and the bundle's FILENAME stay exactly as they were, and only the file the bundle is _built from_ changes. An old tree with no bundle still takes the `else` arm to a script that exists there.

- `contracts-index` runs entirely in-process: neither its shard path (a row union re-rendered through the shared renderer) nor its hub (count arithmetic over the two sides) scans the corpus.
- `postmortems-index` resolves a per-domain shard in-process and **delegates the hub** — one or two files of the family — to the generator through the same spawn the bundle already uses for staleness, because a hub's count is DERIVED from the whole corpus. Nothing approximates it.

Both bundles are rebuilt by `npm run merge-drivers:build` after any change to the modules they inline, and [`tests/unit/lint/merge-driver-build-freshness.test.ts`](../../tests/unit/lint/merge-driver-build-freshness.test.ts) fails if a bundle no longer matches its sources. Never hand-edit a file under `scripts/generated/merge-drivers/`.

### Verifying it's active

```
git config --local --get merge.mddoc.driver
```

prints a bounded `sh` retry loop wrapping `node --import` + an **absolute** `file://` URL to the main checkout's shared preload, e.g.:

```
n=0; until if [ -f scripts/generated/merge-drivers/md-merge-driver.mjs ]; then AMC_MERGE_DRIVER_TSX="file://<main>/scripts/lib/node-ts-register.mjs" node scripts/generated/merge-drivers/md-merge-driver.mjs %O %A %B %P; else node --import "file://<main>/scripts/lib/node-ts-register.mjs" scripts/md-merge-driver.ts %O %A %B %P; fi; do … done
```

If it prints nothing — or prints tsx's loader `.../node_modules/tsx/dist/loader.mjs` (pre-2026-09-27), a bare `tsx scripts/...`, any `node_modules/.bin/tsx` form (pre-2026-07-04), the tsx **CLI** `.../tsx/dist/cli.mjs` form (pre-2026-07-31), or an unwrapped value with no retry loop (pre-2026-06-25) — run `node scripts/setup-md-merge-driver.mjs` from the repo root (or just `npm install`) to re-register the current value. tsx is no longer installed (since 2026-09-28), so a tsx-shaped value fails as soon as a merge needs the TypeScript source rather than the pre-built bundle, and git reads that file as a conflict. The auto-lander also re-registers automatically when its health gate finds a broken launcher.

Since 2026-09-18 the installer also checks the OTHER direction for you, on every run. After it has written the drivers it reads the names `.gitattributes` attributes and warns, naming them, for any this clone has no `merge.<name>.driver` for — a warning only, never a failure, so `npm install` still exits 0. That line is the sole signal for this failure mode, because git does not error on an attribute pointing at an unconfigured driver: no log line, no exit code, no marker, just its default text merge. **Silence from it means every attributed driver is registered.** (It says so explicitly when `git config --local` is unusable for the checkout, rather than implying coverage it could not check.)

### Debugging a single merge by hand

You can invoke the driver directly against three files:

```
node --import ./scripts/lib/node-ts-register.mjs scripts/md-merge-driver.ts <base> <ours> <theirs> <pathname>
```

It overwrites `<ours>` with the merged result and exits `0` on a clean merge or non-zero on a conflict — the same exit code Git itself honors.

### Relationship to the merge workflows

This driver is a low-level assist that sits _underneath_ the repo's higher-level merge tooling (the `safe-merge` skill, the `merge-all-ready` batch orchestrator, and the PR merge queue). Those workflows still run; the driver just removes a class of spurious conflicts they would otherwise have surfaced. In particular, `merge-all-ready`'s recon step trial-merges each ready branch with `git merge-tree`, which **honors this driver** — so a branch whose only conflicts are in the registered additive files is reported "clean" and auto-lands, while a branch with a real code/prose clash is held back for a human. The driver changes nothing about which branches get merged, by whom, or under what approval — only the outcome of the file-level three-way merge for the registered documents.

## For agents

### What lives where (for repo-access agents)

- **Merge logic** — [`scripts/md-merge-driver.ts`](../../scripts/md-merge-driver.ts). The pure function `mergeMarkdown(base, ours, theirs)` returns `{ merged: string | null }` (`null` means "conflict"); `runCli(argv)` is the only part that touches the filesystem (read the three files, merge, write the result or fall back to `git merge-file`).
- **Installer** — [`scripts/setup-md-merge-driver.mjs`](../../scripts/setup-md-merge-driver.mjs). Plain `.mjs` (not TypeScript) so it can run before `npm install` has.
- **Registration** — [`.gitattributes`](../../.gitattributes).
- **Tests** — [`tests/unit/scripts/md-merge-driver.test.ts`](../../tests/unit/scripts/md-merge-driver.test.ts), backed by fixture folders under `tests/unit/scripts/md-merge-driver/fixtures/` (each holds `base.md` + `ours.md` + `theirs.md` and either an `expected.md` for an auto-merge case or an `expected-conflict` sentinel for a must-conflict case). The 1 MiB size-cap boundary has its own file, [`tests/unit/scripts/md-merge-driver-size-cap.test.ts`](../../tests/unit/scripts/md-merge-driver-size-cap.test.ts) — run both when you change the driver.
- **The contract** — [`md-merge-driver-contract.md`](../../.claude/memory/contracts/md-merge-driver-contract.md) is the present-tense engineering source of truth: the exact region cascade, the test-locked invariants (its whole **"Invariants — each locked by a test"** section — read to the end of it, not to a remembered number), and the checklist for changing the driver without regressing it. Read it before touching the merge logic.

## Related

- [land-on-local-master.md](../../.claude/memory/land-on-local-master.md) — how finished branches reach master, and where these merges actually run.
- [git-guardrails.md](git-guardrails.md) — the rules that keep shared branches safe around those merges.
- [agent-lanes.md](agent-lanes.md) — why so many agents are editing the same documents at once.

