diff --git a/README.md b/README.md
index 5e311f17..c09879d1 100644
--- a/README.md
+++ b/README.md
@@ -1117,22 +1117,22 @@ directory first — which is what makes the verdict offline.
For an assembly root the rows are **naming** (this standard's own
`contracts/repository-naming.yaml`), **manifest** and **pins** (the project's
OWN `scripts/validate-manifest.py` and `scripts/validate-pins.py` when it
-carries them — they are the pinned copies, which is the point — else the
-template copies, and the row says which answered), **manifest kinds** (every
-`kind:`-bearing manifest in the root against a registry, so a `contracts/*.yaml`
-nothing validates is a finding by name rather than a silence, and so is a
-`family.yaml` beside `project.yaml`), **shape currency** (`update-shape.py
-check`'s per-file verdicts, summarised), **legs** (each one mounted, and its
-checked-out commit against the pin, with no fetch), **leg shape files** (each
-leg's `AGENTS.md`, `CLAUDE.md` and `.gitignore` against `templates/-root/`
-— a comparison and not a gate: no leg file is pinned anywhere), **placement**
-(every tracked path of every leg, against the path policy), **agent files**, and
-**machine**. A FAMILY holder gets **family**, **manifest kinds**, **shape
-currency**, **placement** (`n/a` — a holder has no legs of its own), **members**
-and **machine**; a directory that is neither gets what it is CALLED under the
-naming policy, what IS in it, and the way in — and a repository the advisory is
-not silent about gets the **Triad** row, status `advisory`: the preference said
-once and stepped over by the verdict (a leg gets its assembly root).
+carries them — the pinned copies — else the template copies, and the row says
+which answered), **manifest kinds** (every `kind:`-bearing manifest in the
+root against a registry, so a `contracts/*.yaml` nothing validates, or a
+`family.yaml` beside `project.yaml`, is a finding by name), **shape currency**
+(`update-shape.py check`'s per-file verdicts, summarised), **legs** (each one
+mounted, and its checked-out commit against the pin, with no fetch), **leg
+shape files** (each leg's `AGENTS.md`, `CLAUDE.md` and `.gitignore` against
+`templates/-root/`, a comparison: no leg file is pinned), **placement**
+(every tracked path of every leg, against the path policy), **agent files**,
+and **machine**. A FAMILY holder gets **family**, **manifest kinds**, **shape
+currency**, **placement** (`n/a`: no legs of its own), **agent files**, **members**
+and **machine**. Either, mid-merge, gets **merge** first: a `FINDING` naming the
+unmerged paths, or a `note` if none is left. A directory that is neither gets what
+it is CALLED under the naming policy, what IS in it, the way in and **machine** —
+and a repository the advisory is not silent about gets the **Triad** row, status
+`advisory`: said once and stepped over by the verdict (a leg gets its root).
Every row is `ok`, `note`, `FINDING` or `n/a` — or `advisory`, the Triad row
alone — and every one that is not `ok` names the exact command that fixes it,
@@ -1151,22 +1151,22 @@ verdict, and its exit code:
| `COMPLIANT` | 0 | every row ok, and the pin names this standard's commit |
| `COMPLIANT, SHAPE BEHIND (n upstream-changed, m upstream-added)` | 1 | valid, and the standard has moved on |
| `DRIFTED (…)` | 1 | a copy was edited here, or a leg is off its pin |
-| `MISPLACED (n paths)` | 1 | a tracked path sits in a leg the path policy puts elsewhere |
-| `INVALID ()` | 1 | a validator went red |
+| `MISPLACED (n paths)` | 1 | a tracked path sits in a leg the path policy puts elsewhere, or a submodule in a leg other than the one its `submodules:` record names |
+| `INVALID ()` | 1 | a validator went red, or (`merge`) the index holds an unmerged path |
| `NOT A SHAPE ROOT` | 2 | neither manifest is there — adopt, or scaffold |
Exit 3 is usage or environment — nothing about it is a statement about the
tree you pointed at: no such path, a `shape-doctor.py` not sitting in a
checkout of the standard, or a pin naming a commit **this** checkout does not
-carry (`CANNOT ANSWER`). **Drift outranks a red validator**
-on the verdict line, deliberately — an edited shape copy is exactly what makes
-`validate-pins.py` red, so answering `INVALID (pins)` would send the reader at
-the symptom while the fix is `update-shape.py`. The pins row is printed either
-way, with its own next command. `MISPLACED` sits between the two: a path in
-the wrong leg is a fact about the shape that no validator, pin or manifest
-here can see, while a red validator names itself in the table whether or not
-it also names the verdict line. `--json` is the same report as one object,
-each row carrying its `id`.
+carry (`CANNOT ANSWER`). **Drift outranks a red validator** on the verdict
+line, deliberately — an edited shape copy is exactly what makes
+`validate-pins.py` red, so `INVALID (pins)` would send the reader at the
+symptom while the fix is `update-shape.py`; the pins row still prints its own
+next command. `MISPLACED` sits between the two: a path in the wrong leg is a
+fact about the shape no validator, pin or manifest here can see. **An
+unfinished merge outranks all three**, as `INVALID (merge)`: the conflict is
+what turns the other rows red, and no fix commits until it is finished.
+`--json` is the same report as one object, each row carrying its `id`.
**`placement` is the row that reads what is *inside* the legs.** Brett Heap,
2026-09-10: *"we have to look for code in spec and spec in code"*. It runs the
diff --git a/docs/handbook.html b/docs/handbook.html
index f3b51681..fdcf2c1b 100644
--- a/docs/handbook.html
+++ b/docs/handbook.html
@@ -1452,10 +1452,13 @@
Is this repository compliant? openRepoShape --doctor
policy), agent files and machine. A
family holder gets family, manifest
kinds, shape currency, placement
- (n/a — a holder has no legs of its own), members and
- machine; a directory that is neither gets what it is CALLED under the naming
- policy, what IS in it, and the way in — and a repository the advisory is not silent
- about gets the Triad row, status advisory: the
+ (n/a — a holder has no legs of its own), agent files,
+ members and machine. Either one in the middle of a merge
+ gets merge first: a FINDING naming the unmerged paths, or a
+ note if nothing is left unmerged. A directory that is neither gets what it is
+ CALLED under the naming policy, what IS in it, the way in and machine
+ — and a repository the advisory is not silent about gets the
+ Triad row, status advisory: the
preference said once and stepped over by the verdict (a leg gets its
assembly root).
Every row is ok, note, FINDING or
@@ -1474,8 +1477,8 @@
Is this repository compliant? openRepoShape --doctor
COMPLIANT
0
every row ok, and the pin names this standard’s commit
COMPLIANT, SHAPE BEHIND (n upstream-changed, m upstream-added)
1
valid, and the standard has moved on
DRIFTED (…)
1
a copy was edited here, or a leg is off its pin
-
MISPLACED (n paths)
1
a tracked path sits in a leg the path policy puts elsewhere
-
INVALID (<rows>)
1
a validator went red
+
MISPLACED (n paths)
1
a tracked path sits in a leg the path policy puts elsewhere, or a submodule in a leg other than the one its submodules: record names
+
INVALID (<rows>)
1
a validator went red, or (merge) the index holds an unmerged path
NOT A SHAPE ROOT
2
neither manifest is there — adopt, or scaffold
@@ -1489,6 +1492,8 @@
Is this repository compliant? openRepoShape --doctor
either way, with its own next command. MISPLACED sits between the two: a path in
the wrong leg is a fact about the shape that no validator, pin or manifest here can see, while
a red validator names itself in the table whether or not it also names the verdict line.
+ An unfinished merge outranks all three, as INVALID (merge): the
+ conflict is what turns the other rows red, and no fix commits until it is finished.
--json is the same report as one object,
each row carrying its id.
@@ -1822,9 +1827,10 @@
For AI assistants
branch and naming the branch the real run needs, added (#477); the one network
read a project’s own validators can make, the gh api recheck of a neutral
product’s pin, said where this page and the README had said “offline”,
- added (#411) — a page cannot name the commit that contains it, and the blob below is
- the fact a test checks.
- README.md blob 5e311f176503d43a188d9974c7988b396dd0e0e1. Every command, flag, exit code
+ added (#411); the doctor’s merge row, and INVALID (merge)
+ above drift, added (#424) — a page cannot name the commit that contains it, and the
+ blob below is the fact a test checks.
+ README.md blob c09879d13f9a618c5de7afb6d3b555e59888df17. Every command, flag, exit code
and file name on this page is quoted from that README and from AGENTS.md.
diff --git a/shape-doctor.py b/shape-doctor.py
index 1f93561c..28d65dd2 100755
--- a/shape-doctor.py
+++ b/shape-doctor.py
@@ -57,6 +57,9 @@
THE ROWS, for an assembly root, in this order:
+ merge only in the middle of a merge: a FINDING naming the
+ index's unmerged paths, or a `note` once nothing is
+ left unmerged; no row at all otherwise (#424)
naming the three leg names against the STANDARD's own
`contracts/repository-naming.yaml`
manifest the project's own `scripts/validate-manifest.py` when it
@@ -85,9 +88,10 @@
machine whether this workstation can RUN the fixes named above
(`setup-project.py --preflight`, plus `git-filter-repo`)
-and for a FAMILY holder: `family` (its own `scripts/validate-family.py`),
-`manifest kinds`, `shape currency`, `placement` (`n/a`: a holder has no legs
-of its own), `members` and `machine`. A directory that
+and for a FAMILY holder: `merge` likewise, `family` (its own
+`scripts/validate-family.py`), `manifest kinds`, `shape currency`,
+`placement` (`n/a`: a holder has no legs of its own), `agent files`,
+`members` and `machine`. A directory that
is NEITHER gets `naming` (what it is called, under the policy), `what is
here`, `the way in` -- `adopt-project.py plan` for a repository that already
exists, `setup.sh` for a new one -- and `machine`.
@@ -105,6 +109,18 @@
and declares its kind still decides first, so no root that declared one
reads differently.
+A ROOT IN THE MIDDLE OF A MERGE IS NOT COMPLIANT (#424). The other rows read
+the working tree, and a leg's commit from the index -- or from HEAD, for a
+gitlink a conflicted merge holds only at stages 1, 2 and 3. That is right
+for a validator and wrong for a verdict: a root whose index holds an
+unmerged path is one `git commit` refuses, and it read COMPLIANT, exit 0,
+once the conflict markers were out of the files those rows read. So the
+`merge` row, FIRST on both kinds of root, asks git for the unmerged paths
+and is a FINDING naming them, with `git -C status` as its NEXT, and
+the verdict is `INVALID (merge)`, exit 1, on a rung above every other but
+CANNOT ANSWER (`merge_verdict` says why). A merge with nothing left unmerged
+is a `note`: git commits it as it stands. With neither, there is no row.
+
AND ONE MORE ROW, `Triad`, WHERE THE RULE SAYS SO. Brett Heap, 2026-10-06:
"ratify 1249 as recommended" (openxFactory `prefer-triad-project-shape`):
the Triad -- this shape, an assembly root with a spec leg and a code leg --
@@ -238,7 +254,9 @@
wrong leg is a fact about the SHAPE -- the subject of this whole standard --
that no validator, pin or manifest here can see, whereas a red validator names
itself in the table either way and loses nothing by not being on the verdict
-line.
+line. An unfinished merge outranks all three, for the reason drift outranks a
+red validator: the conflict it leaves is what turns the other rows red, and
+none of their fixes can be committed until it is finished.
STANDARD LIBRARY ONLY, like everything else shipped here. Printed text is
ASCII: a verdict a cp1252 console cannot render is a verdict nobody reads, and
@@ -723,11 +741,12 @@ class Check:
takes, for the same reason: putting a repository back into shape rewrites
somebody's tree.
- `run` RETURNS ONE ROW, OR NONE FOR A CHECK THAT IS SILENT BY RULE. One
- check is: the Triad advisory, which the ratified rule makes SILENT in an
+ `run` RETURNS ONE ROW, OR NONE FOR A CHECK THAT IS SILENT BY RULE. Two
+ checks are: the Triad advisory, which the ratified rule makes SILENT in an
elected Triad, a family holder, a `-wip` workspace and a project
that recorded staying single -- and silent means no row at all, not a row
- saying so. `run_checks` skips a None.
+ saying so -- and `merge`, which speaks only while a merge is unfinished
+ (#424). `run_checks` skips a None.
"""
def __init__(self, check_id: str, label: str, applies_to: tuple,
@@ -5696,12 +5715,187 @@ def _triad_advisory_row(ctx: Context) -> Row | None:
return None
+# ---------------------------------------------------------------------------
+# A root in the middle of a merge
+# ---------------------------------------------------------------------------
+
+
+#: Named once: `check_merge`'s two rows and the `CHECKS` entry below must
+#: print the same row name.
+LABEL_MERGE = "merge"
+
+#: How many unmerged paths the `merge` row's sentence names before it counts
+#: the rest. `--json` lists every one.
+UNMERGED_NAMED = 5
+
+
+def unmerged_paths(root: Path) -> list[str] | None:
+ """Each path the root's index holds UNMERGED, once, in git's order, or
+ None where git gives no answer (#424).
+
+ `git ls-files -u` lists one record per STAGE, `