diff --git a/AGENTS.md b/AGENTS.md
index 8d89b698..1199b938 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -440,6 +440,24 @@ That a `-wip` workspace repository confers anything either: it holds no
code, elects nothing and is membership of nothing — it is one person's index
of their own unfinished work, and nothing here creates one.
+**What you DO tell them instead:** that the Triad — this shape, an assembly
+root with a spec leg and a code leg — is the PREFERRED project shape; that a
+project choosing a single repository is reviewed identically and owes no
+declaration; and that the shape still confers nothing (Brett Heap, 2026-10-06,
+openxFactory `prefer-triad-project-shape`). Preferred is not required.
+
+Starting work for a person in a repository that is not a Triad, say so ONCE in
+the session, in a line or two, then carry on with what you were asked:
+`adopt-project.py` converts a repository in place when a person deciding for it
+runs it, and a project that stays single can say so in
+`single-repository.yaml`. The doctor's `advisory` row and the `triad` lines the
+scaffold and `adopt-project.py plan`/`check` print are that sentence; relay
+them once. Say nothing in an assembly root, a family holder, a `-wip`
+repository or one carrying `single-repository.yaml`; send a leg clone to its
+assembly root instead; say it everywhere else, without guessing a class. A
+subagent or an unattended run says nothing. Never convert, never write the
+record for them, never block, and never put the shape in a review or a gate.
+
## Testing your changes to this repository
`python3 -m pytest tests -q`. The suite scaffolds into bare repositories in a
diff --git a/README.md b/README.md
index 401cb98d..095a1b9e 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,40 @@ both.
> travels in the grants rather than in the layout. Review lanes and
> wallet-carried authority are OVERLAYS an org adds later; a project with no
> overlays is fully conformant.
+>
+> **The Triad is the PREFERRED shape, and still confers nothing.** The Triad is
+> this shape — an assembly root with a spec leg and a code leg; "three-leg" and
+> "three-repository project" mean the same, and no machine key changed. Ruled by
+> **Brett Heap on 2026-10-06**: *"ratify 1249 as recommended"*, ratifying
+> openxFactory's `prefer-triad-project-shape`. It is the recommended default for
+> a new project and the recommended target for an existing single repository.
+> Preferred is not required: electing it stays a person's decision per project,
+> converting stays `adopt-project.py` run by that person, and a project kept in
+> one repository is reviewed identically and owes no declaration.
+
+**Where the preference is said, and where it is not.** At the start of work,
+once, in a line or two, and never as a stop: `scaffold-project.py`'s plan names
+the Triad as what it creates, `adopt-project.py plan` and `check` say it about
+the repository they read, and `shape-doctor.py` gives a single repository one
+`advisory` row. None blocks, converts, writes or changes an exit status on its
+account, and nothing records that it was given. It is never a review input: no
+review lane, required check, validator, floor, council or merge gate reads the
+shape, and a check built on the doctor's `NOT A SHAPE ROOT` is defective. It is
+silent in an elected Triad's assembly root, a leg clone (which is told to work
+from its root instead), a family holder, a `-wip` workspace repository
+and a repository carrying `single-repository.yaml`. Every other repository —
+an aggregation, a configuration repository, a vendored fork — hears it, because
+no class is guessed.
+
+**`single-repository.yaml` is OPTIONAL and never owed.** A project that has
+decided to stay a single repository may say so once, at its root, and stop
+hearing the advisory: `schema_version: 1`, `kind: single-repository-record`,
+`decided_by`, `decided_on`, `reason` and an optional `revisit_on`. Its schema
+is `contracts/single-repository-record.yaml` and its template
+`templates/single-repository/single-repository.yaml`; nothing here writes one
+for anybody. It confers nothing and the advisory is its only reader. `kind:`
+decides: one that does not parse, or declares another kind, silences nothing
+and is reported beside the advisory, and nothing fails on its account.
- [Starting a project in a new organisation](#starting-a-project-in-a-new-organisation)
- [Quick start for a first-time user](#quick-start-for-a-first-time-user)
@@ -1090,11 +1124,14 @@ 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.
-
-Every row is `ok`, `note`, `FINDING` or `n/a`, and every one that is not
-`ok` names the exact command that fixes it. **Only a `FINDING` moves the
-verdict**, and `FINDING` means exactly *something else asserts this* — a
+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).
+
+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,
+except the Triad row, which names none in any status. **Only a `FINDING`
+moves the verdict**, and `FINDING` means exactly *something else asserts this* — a
validator, a pin row, a manifest, a gitlink. A difference nothing asserts is
a `note`: printed, in `--json`, and stepped over by the verdict. That line
was drawn after this command answered `INVALID` about a live estate whose
@@ -1339,7 +1376,9 @@ only: a required check in the repository that owns the object is what confers.
```
contracts/repository-naming.yaml the six naming families, as data
contracts/path-classification.yaml which leg a path belongs in, as data
+contracts/single-repository-record.yaml the staying-single record's schema
scripts/repo_shape.py shared helpers: YAML subset reader, digests
+scripts/shape_advisory.py the Triad advisory: who hears it, and when not
scripts/path_classify.py the classifier over the path policy
scripts/shape_materialize.py the ONE materializer, used by both tools
scripts/validate-repository-naming.py
@@ -1358,6 +1397,7 @@ templates/family-root/ the skeleton for a FAMILY holder
templates/spec-root/ the skeleton for -spec
templates/code-root/ the skeleton for -code
templates/workspace-root/ the skeleton for -wip
+templates/single-repository/ single-repository.yaml, to fill in by hand
templates/*/AGENTS-shape.md the rules of the shape, for an agent (PINNED)
templates/*/.gitattributes LF in every clone, so a copy digests as pinned
AGENTS.md the procedure an AI assistant follows
diff --git a/adopt-project.py b/adopt-project.py
index a9f45add..df58d642 100755
--- a/adopt-project.py
+++ b/adopt-project.py
@@ -89,6 +89,7 @@
election_date, env_commit, git_init_commit, materialize_assembly_root,
naming_block, run, write_lf,
)
+import shape_advisory # noqa: E402
#: The naming policy this tool classifies leg names against. One constant,
#: because three spellings of the same path is how the second one goes stale.
@@ -607,6 +608,54 @@ def _checked_plan_inputs(args, source: Source,
return names, pins
+def _print_triad_advisory(path: Path, commit: str | None = None) -> None:
+ """The Triad advisory, once, about the repository this run reads.
+
+ RULED BY BRETT HEAP ON 2026-10-06 ("ratify 1249 as recommended",
+ openxFactory `prefer-triad-project-shape`): the Triad is the preferred
+ project shape, it stays elective and confers nothing, and this tool's
+ `plan` and `check` say so beside what they already print. THIS TOOL IS
+ THE CONVERSION, so it names the preference rather than sending anybody to
+ itself, and says what it does about it: nothing until `execute`, on a
+ person's word for this project. `scripts/shape_advisory.py` decides where
+ it is silent -- an elected Triad, a family holder, a `-wip`
+ workspace, a project that recorded staying single -- and a leg clone gets
+ the existing instruction to work from its assembly root instead.
+
+ PRINTED, NEVER WRITTEN. Nothing goes into the plan file, the split commit
+ or the manifest on its account, and no exit code changes: the advisory is
+ a sentence said to a person and not a fact about their project.
+
+ ABOUT THE COMMIT BEING PLANNED. `commit` is the one this plan is written
+ against, and the record, the manifests and a leg's `AGENTS.md` are read
+ out of it rather than out of the working tree: a `single-repository.yaml`
+ that exists only on a feature branch, or only uncommitted, is not in the
+ repository being split and silences nothing about it.
+
+ AND IT CANNOT FAIL THE RUN. Every line is flattened to ASCII first, as
+ `shape-doctor.py` flattens its rows -- a record's `kind:` is a person's
+ text, and a console that cannot encode it would otherwise raise in the
+ middle of `plan`, after the plan file is already written -- and anything
+ left is swallowed: a run that lost the advisory has lost one optional
+ sentence, and one that crashed on it would have changed an exit status,
+ which the ratified rule forbids.
+ """
+ try:
+ reading = shape_advisory.read(path, commit=commit)
+ if reading.state == shape_advisory.LEG_CLONE:
+ lines = [f"NOTE {shape_advisory.leg_instruction(reading.leg)}"]
+ elif reading.advise:
+ first, second = shape_advisory.adopt_lines()
+ lines = [f"triad {first}", f" {second}"]
+ lines += [f"NOTE {line}" for line in
+ shape_advisory.record_report(reading.record_problems)]
+ else:
+ return
+ print("\n".join(shape_advisory.printable(line) for line in lines))
+ except Exception: # noqa: BLE001 - see the docstring
+ return
+
+
def _print_plan_report(args, source: Source, tree: list, entries: list,
follow_ups: list, out: Path) -> None:
"""What `plan` says to the terminal once the file is on disk.
@@ -620,6 +669,7 @@ def _print_plan_report(args, source: Source, tree: list, entries: list,
f"{source.commit[:12]} ({source.branch}), "
f"{len(tree)} files, {source.commit_count()} commits")
print(f"project {args.project} ({args.id}) in {args.org}, mode in-place")
+ _print_triad_advisory(source.path, source.commit)
_print_entries(entries)
_print_summary(entries)
print(f"\nfollow-ups ({len(follow_ups)}):")
@@ -964,6 +1014,10 @@ def cmd_check(args) -> int:
source = plan.open_source(args.source, work_root)
naming = NamingPolicy.load(NAMING_POLICY)
findings: list[str] = []
+ # FIRST, because it is said where work starts; it is never one of the
+ # findings below and never moves this command's exit code.
+ _print_triad_advisory(source.path,
+ plan.source_commit or source.commit)
if plan.source_commit and source.commit != plan.source_commit:
findings.append(
diff --git a/contracts/single-repository-record.yaml b/contracts/single-repository-record.yaml
new file mode 100644
index 00000000..7fc973a5
--- /dev/null
+++ b/contracts/single-repository-record.yaml
@@ -0,0 +1,115 @@
+schema_version: 1
+kind: single-repository-record-schema
+
+# ===========================================================================
+# single-repository-record.yaml — the SCHEMA of `single-repository.yaml`, the
+# OPTIONAL record that a project is staying a single repository.
+#
+# WHY IT EXISTS. Ruled by Brett Heap on 2026-10-06: "ratify 1249 as
+# recommended", ratifying openxFactory's `prefer-triad-project-shape`. The
+# Triad — this standard's three-repository shape, an assembly root with a
+# spec leg and a code leg — is the PREFERRED project shape, and a person
+# starting work in a repository that has not elected it is told so once (see
+# `scripts/shape_advisory.py`). A project that has DECIDED to stay a single
+# repository can say so here, once, and stop meeting that advisory. That is
+# the record's whole purpose; the ruling on that change's OQ-1 put it in a
+# file of its own at the repository root, with this schema, owned here.
+#
+# OPTIONAL, AND NEVER OWED. A project that declines the Triad and writes
+# nothing owes nothing: it is reviewed identically and meets one advisory line
+# per session. Nothing in this standard writes the record on anybody's
+# behalf — not the advisory, not the doctor, not adopt — because staying
+# single is the decision of a person deciding for that project, as electing
+# the Triad would have been.
+#
+# IT CONFERS NOTHING. No gate, no floor, no grant, no clearance eligibility,
+# no lifecycle state and no review difference, exactly as `project.yaml`
+# confers nothing. A consumer deriving a permission from its presence, its
+# absence or any field of it is defective.
+#
+# ITS ONLY READER IS THE ADVISORY. `scripts/shape_advisory.py` reads it and
+# nothing else does — there is deliberately no validator script for it, and
+# no review lane, required check, floor, council or merge gate may read it.
+# `kind:` DECIDES, not the filename: a `single-repository.yaml` that parses
+# and declares `kind: single-repository-record` silences the advisory; one
+# that does not parse, or declares any other kind, silences nothing and is
+# reported beside the advisory, and nothing fails on its account. The fields
+# below are checked against what a record of the right kind carries, and a
+# record that leaves one out is still silent — `shape-doctor.py` says which
+# field, as a `note`.
+#
+# NEVER `project.yaml`, AND NEVER A FIELD OF IT. `project.yaml` declaring
+# `kind: project-manifest` with a spec and a code leg IS how every surface
+# detects a Triad — the shared agent protocol, the workstation bootstrap and
+# `shape-doctor.py`, whose NOT A SHAPE ROOT keys on that file's absence — so
+# a single repository carrying one would turn each of them from a presence
+# test into a content test. The two files never collide: their names differ
+# and their `kind`s differ.
+# ===========================================================================
+
+record_kind: single-repository-record
+
+# Where a project puts it: ITS OWN ROOT, which is where the root read that
+# looks for `project.yaml` already looks, so finding it costs no second walk.
+file: single-repository.yaml
+location: repository-root
+
+# A copy to fill in by hand. Nothing in this standard renders it: replace
+# every `{{PLACEHOLDER}}`, and a value still spelled that way is reported as
+# a field nobody wrote.
+template: templates/single-repository/single-repository.yaml
+
+# Every field a record may carry, in the order the template writes them. A
+# key not listed here is reported as not a field of the record. `type` is one
+# of `integer`, `string` and `date` (an ISO `YYYY-MM-DD`); `value`, where
+# present, is the one value the field may hold.
+fields:
+ - name: schema_version
+ required: true
+ type: integer
+ value: 1
+ description: >-
+ The version of THIS schema the record was written against.
+ - name: kind
+ required: true
+ type: string
+ value: single-repository-record
+ description: >-
+ What decides whether the file is the record at all. Any other value
+ silences nothing.
+ - name: decided_by
+ required: true
+ type: string
+ description: >-
+ The person who decided, for this project, that it stays a single
+ repository — the same person an election of the Triad would have named
+ in `project.yaml`'s `elected_by`.
+ - name: decided_on
+ required: true
+ type: date
+ description: >-
+ The day that decision was taken.
+ - name: reason
+ required: true
+ type: string
+ description: >-
+ Why, in a sentence: a configuration repository, an aggregation, a
+ vendored fork, a project too small to split. Read by people; nothing
+ parses it.
+ - name: revisit_on
+ required: false
+ type: date
+ description: >-
+ OPTIONAL. A day the decision is worth looking at again. Nothing acts on
+ it: the advisory stays silent past it, and reconsidering is a person's
+ act, as the decision was.
+
+# A complete record, filled in, for the reader who would rather copy an
+# example than a template. Validated by `tests/test_shape_advisory.py`.
+example:
+ schema_version: 1
+ kind: single-repository-record
+ decided_by: "Dana Okafor"
+ decided_on: 2026-10-06
+ reason: "A dotfiles repository: one person's configuration, with no specification to split from code."
+ revisit_on: 2027-04-01
diff --git a/docs/handbook.html b/docs/handbook.html
index 674d846f..ce3e2616 100644
--- a/docs/handbook.html
+++ b/docs/handbook.html
@@ -555,7 +555,38 @@ The one thing to read before electing it
three-repository project are reviewed identically, because the authority travels in the
grants rather than in the layout. Review lanes and wallet-carried authority are OVERLAYS an
org adds later; a project with no overlays is fully conformant.
+ The Triad is the PREFERRED shape, and still confers nothing. The Triad
+ is this shape — an assembly root with a spec leg and a code leg; “three-leg”
+ and “three-repository project” mean the same, and no machine key changed. Ruled by
+ Brett Heap on 2026-10-06: “ratify 1249 as recommended”,
+ ratifying openxFactory’s prefer-triad-project-shape. It is the
+ recommended default for a new project and the recommended target for an existing single
+ repository. Preferred is not required: electing it stays a person’s decision per
+ project, converting stays adopt-project.py run by that person, and a project
+ kept in one repository is reviewed identically and owes no declaration.
+ Where the preference is said, and where it is not. At the start of work,
+ once, in a line or two, and never as a stop: scaffold-project.py’s plan
+ names the Triad as what it creates, adopt-project.py plan and
+ check say it about the repository they read, and
+ shape-doctor.py gives a single repository one
+ advisory row. None blocks, converts, writes or changes an exit status on its
+ account, and nothing records that it was given. It is never a review input: no review lane,
+ required check, validator, floor, council or merge gate reads the shape, and a check built on
+ the doctor’s NOT A SHAPE ROOT is defective. It is silent in an elected
+ Triad’s assembly root, a leg clone (which is told to work from its root instead), a
+ family holder, a <user>-wip workspace repository and a repository carrying
+ single-repository.yaml. Every other repository — an aggregation, a
+ configuration repository, a vendored fork — hears it, because no class is guessed.
+ single-repository.yaml is OPTIONAL and never owed. A project
+ that has decided to stay a single repository may say so once, at its root, and stop hearing
+ the advisory: schema_version: 1, kind: single-repository-record,
+ decided_by, decided_on, reason and an optional
+ revisit_on. Its schema is contracts/single-repository-record.yaml and
+ its template templates/single-repository/single-repository.yaml; nothing here
+ writes one for anybody. It confers nothing and the advisory is its only reader.
+ kind: decides: one that does not parse, or declares another kind, silences
+ nothing and is reported beside the advisory, and nothing fails on its account.
Ruled by Brett Heap on 2026-09-02: the name
openRepoShape, PUBLIC visibility, Apache-2.0; and that the ASSEMBLY leg
is the per-project root repository — the one an engineer clones. The
@@ -1394,10 +1425,14 @@
Is this repository compliant? openRepoShape --doctor
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.
+ 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).
Every row is ok, note, FINDING or
- n/a, and every one that is not ok names the exact command that
- fixes it. Only a FINDING moves the verdict, and
+ n/a — or advisory, the Triad row alone — and every one
+ that is not ok names the exact command that fixes it, except the Triad row,
+ which names none in any status. Only a FINDING moves the verdict, and
FINDING means exactly something else asserts this — a validator,
a pin row, a manifest, a gitlink. A difference nothing asserts is a note:
printed, in --json, and stepped over by the verdict. That line was drawn after
@@ -1635,7 +1670,9 @@
Layout of this repository
- contracts/repository-naming.yaml
- the six naming families, as data
- contracts/path-classification.yaml
- which leg a path belongs in, as data
+ - contracts/single-repository-record.yaml
- the staying-single record’s schema
- scripts/repo_shape.py
- shared helpers: YAML subset reader, digests
+ - scripts/shape_advisory.py
- the Triad advisory: who hears it, and when not
- scripts/path_classify.py
- the classifier over the path policy
- scripts/shape_materialize.py
- the ONE materializer, used by both tools
- scripts/validate-repository-naming.py
- answers the naming question for one name
@@ -1654,6 +1691,7 @@ Layout of this repository
- templates/spec-root/
- the skeleton for <Project>-spec
- templates/code-root/
- the skeleton for <Project>-code
- templates/workspace-root/
- the skeleton for <user>-wip
+ - templates/single-repository/
- single-repository.yaml, to fill in by hand
- templates/*/AGENTS-shape.md
- the rules of the shape, for an agent (PINNED)
- templates/*/.gitattributes
- LF in every clone, so a copy digests as pinned
- AGENTS.md
- the procedure an AI assistant follows
@@ -1717,6 +1755,15 @@ For AI assistants
are asked to make role: spec mean “spec authority lives here”, say no:
that turns a layout into a governance boundary, which is the one thing this standard exists to
prevent.
+ What you DO tell them instead: that the Triad is the
+ preferred project shape; that a project choosing a single repository
+ is reviewed identically and owes no declaration; and that the shape still confers nothing.
+ Starting work for a person in a repository that is not a Triad, say so ONCE in the session,
+ in a line or two, and carry on: adopt-project.py converts a repository in place
+ when a person deciding for it runs it, and a project that stays single can say so in
+ single-repository.yaml. Say nothing where the advisory is silent, send a leg
+ clone to its assembly root, and never convert, write the record, block, or put the shape in
+ a review or a gate.
Full text — AGENTS.md
@@ -1727,9 +1774,12 @@ For AI assistants
the repository doctor added at 2986031 (#95); the CLI reference, the docs/
rows and the shape-doctor.py row the layout list had never gained,
added (#97); the placement row and the plan it writes added (#99); the
- templates/workspace-root/ row, the fifth template, added (#154) — a page
+ templates/workspace-root/ row, the fifth template, added (#154); the Triad as
+ the preferred shape, where the advisory is said and where it is silent, the optional
+ single-repository.yaml, the doctor’s advisory row and the
+ assistant’s “what you do tell them” added (#163) — a page
cannot name the commit that contains it, and the blob below is the fact a test checks.
- README.md blob 401cb98d687e58e1d76c12a9ccceb9ea71b65e4a. Every command, flag, exit code and
+ README.md blob 095a1b9e60743fc8f8d26bd11247572956a3f144. Every command, flag, exit code and
file name on this page is quoted from that README and from AGENTS.md.
diff --git a/scaffold-project.py b/scaffold-project.py
index 96eeb3e8..80f13350 100755
--- a/scaffold-project.py
+++ b/scaffold-project.py
@@ -58,6 +58,7 @@
copy_tree, default_reference, descendant_note, election_date, env_commit,
git_init_commit, materialize_assembly_root, naming_block, run,
)
+from shape_advisory import scaffold_lines # noqa: E402
#: The pin argument: `openGlass@<40 hex>`, optionally organisation-qualified.
PIN_ARG_RE = re.compile(r"^(?P[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)?)@(?P[0-9a-fA-F]{40})$")
@@ -599,6 +600,16 @@ def _print_plan(args, values: dict, names: dict, repositories: dict,
"""
print(f"project {values['PROJECT_NAME']} ({values['PROJECT_ID']}) "
f"topic {values['TOPIC']}")
+ # THE TRIAD, OFFERED FIRST (openxFactory `prefer-triad-project-shape`,
+ # ratified by Brett Heap on 2026-10-06). This tool has no
+ # single-repository mode -- every run creates an assembly root and two
+ # legs -- so the offer is the tool itself, and the plan a person reads
+ # before typing yes names it as the preferred shape with the posture
+ # beside it. Printed once, in a dry run and a real one alike; it changes
+ # nothing this run does and no exit code.
+ first, second = scaffold_lines()
+ print(f"triad {first}")
+ print(f" {second}")
print(f"shape {SHAPE_REPOSITORY} @ {values['SHAPE_COMMIT'][:12]} "
f"(tree {values['SHAPE_TREE_SHA256'][:12]}…)")
print(f"elected by {values['ELECTED_BY']} on {values['ELECTED_ON']}")
diff --git a/scripts/shape_advisory.py b/scripts/shape_advisory.py
new file mode 100644
index 00000000..2486f21e
--- /dev/null
+++ b/scripts/shape_advisory.py
@@ -0,0 +1,733 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: Apache-2.0
+"""The Triad advisory: said once where a person starts work, and never a gate.
+
+THE RULING. Brett Heap, 2026-10-06: *"ratify 1249 as recommended"*, ratifying
+openxFactory's `prefer-triad-project-shape` on his direction of the same day,
+*"the triad should be the prefered structure and should prompt or warn the
+user if working on a non Triad repo."* The Triad is this standard's
+three-repository shape -- an assembly root with a spec leg and a code leg; a
+"three-leg project" and a "three-repository project" are the same thing, and
+no machine key was renamed for the word. It is the PREFERRED project shape,
+and it stays ELECTIVE and confers NOTHING: no gate, no floor, no grant, no
+clearance eligibility, no lifecycle state and no review difference.
+
+WHO SAYS IT, AND HOW OFTEN. Three tools, each once per run, in one or two
+lines, beside what it already reports: `scaffold-project.py` names the Triad
+as the preferred shape in the plan a person reads before typing yes (it only
+ever creates one, so it offers the Triad first by construction);
+`adopt-project.py plan` and `check` say it about the repository they read;
+and `shape-doctor.py` gives a repository that has not elected it one
+`advisory` row. None of them blocks, converts, writes, creates a repository,
+or changes an exit status on its account, and NOTHING RECORDS THAT IT WAS
+GIVEN: it is a sentence said to a person, not a state, so no later act can
+read "was advised" as a fact about a project.
+
+NEVER A GATE AND NEVER A REVIEW INPUT. No review lane, required check,
+validator, floor, council or merge gate reads anything this module answers,
+and nothing here returns an exit code. A check built on it -- or on the
+doctor's `NOT A SHAPE ROOT` verdict -- would make layout a review input,
+which the ratified doctrine calls defective; `tests/test_shape_advisory.py`
+holds the list of files allowed to import this one to the three tools above.
+
+SILENT WHERE THE SHAPE QUESTION IS ANSWERED OR DOES NOT ARISE, each read from
+a DECLARED fact -- the repository's own tree, the naming policy, or the
+person's own workspace configuration -- and never inferred:
+
+ * an elected Triad assembly root: `project.yaml` declaring `kind:
+ project-manifest`, `schema: project-repo-schema` and `legs:` naming a
+ `spec` and a `code` leg -- the same detector the shared agent protocol
+ and the workstation bootstrap use;
+ * a family holder: `family.yaml` declaring `kind: family-manifest`, with no
+ `project.yaml` declaring a project. It pins projects and is not one;
+ * a leg clone: a repository whose own name is a `-spec` or
+ `-code` leg form under `contracts/repository-naming.yaml`, or
+ whose `AGENTS.md` is the leg template's, which names the assembly root.
+ The existing instruction applies there instead -- work from the root;
+ * a `-wip` workspace repository: the `workspace` form of the same
+ naming policy, or the path `${AGENT_PROTOCOL_ROOT:-$HOME/.agents}/
+ workspace.yaml` names as the person's own workspace clone;
+ * a repository carrying `single-repository.yaml` whose `kind` is
+ `single-repository-record`.
+
+A directory that is not a repository at all is not advised either: the
+sentence begins "this is a single repository", and there is none.
+
+AND NO OTHER CLASS IS GUESSED. An aggregation, a configuration or dotfile
+repository and a vendored fork carry no declared fact that says what they
+are, so they get the advisory until they migrate or record staying single.
+Exempting a class by its name, its layout or its remote would be a rule right
+for the repositories its author had in mind and wrong for the next one (the
+ratified ruling on OQ-2); the remedy for a class that should be silent is to
+give it a declared fact, as `-wip` already has.
+
+THE ONLY READER OF `single-repository.yaml`. That record is OPTIONAL and never
+owed -- a project that declines the Triad and writes nothing owes nothing and
+is reviewed identically -- and the advisory is its one reader, by the ruling,
+which is why it has no validator script of its own: a second reader is
+exactly what a record that confers nothing must not acquire. Its schema is
+`contracts/single-repository-record.yaml` and its template
+`templates/single-repository/single-repository.yaml`. `kind:` DECIDES, not
+the filename, as it does for every manifest here: a record that parses and
+declares `single-repository-record` silences the advisory; one that does not
+parse, or declares anything else, silences nothing and is reported beside the
+advisory, and nothing fails on its account. A record that silences but leaves
+a field out is still silent; the doctor says which field, as a `note`.
+
+NOT A SHAPE COPY, DELIBERATELY. `scripts/repo_shape.py` is copied into every
+assembly root and family holder and digest-pinned there, so a function added
+to it puts an `upstream-changed` row in front of every project for a helper
+no project's own copy would ever call -- the argument `shape-doctor.py`'s
+`quote_arg` already makes. This module stays in the checkout the three tools
+run from, and imports from `repo_shape` rather than adding to it.
+
+STANDARD LIBRARY ONLY, like everything else shipped here. Every sentence is
+ASCII, because `shape-doctor.py` prints ASCII and a Windows console in its
+ANSI code page reads it.
+"""
+
+from __future__ import annotations
+
+import datetime as _dt
+import os
+import re
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+from repo_shape import ( # noqa: E402
+ NamingPolicy, Refusal, git_out, load_yaml, parse_yaml,
+)
+
+#: The checkout of the standard this file sits in. The naming policy and the
+#: record's schema are read from HERE, exactly as the doctor compares a
+#: project against the checkout it is run from.
+SHAPE_ROOT = Path(__file__).resolve().parents[1]
+
+PROJECT_MANIFEST = "project.yaml"
+FAMILY_MANIFEST = "family.yaml"
+AGENTS_MD = "AGENTS.md"
+NAMING_POLICY = "contracts/repository-naming.yaml"
+
+#: The optional staying-single record, as ruled on OQ-1: its own file at the
+#: repository root, never `project.yaml` or a field of it, because
+#: `project.yaml` with `kind: project-manifest` and two legs IS the Triad
+#: detector and a single repository carrying one would turn every reader of it
+#: from a presence test into a content test.
+RECORD_FILE = "single-repository.yaml"
+RECORD_KIND = "single-repository-record"
+RECORD_SCHEMA = "contracts/single-repository-record.yaml"
+SCHEMA_KIND = "single-repository-record-schema"
+RECORD_TEMPLATE = "templates/single-repository/single-repository.yaml"
+
+#: What `read()` answers. One of them advises; every other one is a row of the
+#: ratified list of silences, plus the directory that is not a repository.
+ADVISE = "advise"
+TRIAD_ROOT = "triad-root"
+FAMILY_HOLDER = "family-holder"
+LEG_CLONE = "leg-clone"
+WORKSPACE = "workspace"
+RECORDED_SINGLE = "recorded-single"
+NOT_A_REPOSITORY = "not-a-repository"
+#: THE ADVISORY COULD NOT BE READ HERE, AND SAYS NOTHING RATHER THAN FAIL
+#: ANYTHING. The ratified rule is absolute -- the advisory SHALL NOT change an
+#: exit status on its account -- and a sentence that is optional by
+#: construction is the one thing in a run that must never be the reason it
+#: crashed. So whatever `read()` meets that it did not expect (a record nested
+#: past the interpreter's recursion limit, a NUL in a configured path) ends
+#: here: silent, with the exception's NAME in `why` for `--json`.
+NOT_ANSWERED = "not-answered"
+STATES = (ADVISE, TRIAD_ROOT, FAMILY_HOLDER, LEG_CLONE, WORKSPACE,
+ RECORDED_SINGLE, NOT_A_REPOSITORY, NOT_ANSWERED)
+
+#: The two leg roles. A name the naming policy classifies as `project-leg` in
+#: either of them is a leg's name by this standard's own declaration.
+LEG_ROLES = ("spec", "code")
+
+#: The two sentences every leg's `AGENTS.md` carries, as
+#: `templates/spec-root/` and `templates/code-root/` render it: the opening,
+#: which says which leg this is, and the line naming the assembly root the
+#: project's rules live in. Spelled here and HELD to the templates by
+#: `tests/test_shape_advisory.py`, which renders both and reads them back --
+#: so a template reworded without this is a red test, not a leg that quietly
+#: starts being advised.
+LEG_OPENING_RE = re.compile(r"\AThis is the \*\*(spec|code) leg\*\* of ")
+LEG_ROOT_RE = re.compile(r"They are in the assembly root `([^`\s]+)`")
+
+#: A `{{PLACEHOLDER}}` the template carries and a person filling it in by hand
+#: left behind. The record's template uses the same spelling as every other
+#: template here, and a value still spelled that way is a field nobody wrote.
+PLACEHOLDER_RE = re.compile(r"\{\{[A-Z_]+\}\}")
+DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
+
+#: A UTF-8 byte-order mark, which an editor on Windows may put at the top of a
+#: file a person wrote by hand. It is not part of the first key: left in, it
+#: turns `schema_version` into a key nobody declared.
+BOM = "\ufeff"
+
+
+def printable(text: str) -> str:
+ """`text` as ASCII, so printing it can never be what fails a run.
+
+ THE SAME FLATTENING `shape-doctor.py`'s `ascii_text` applies to every row,
+ for the same reason, here for the two tools that print the advisory
+ themselves: a record's `kind:` is a person's text and may carry anything,
+ and a console in an ANSI code page -- or a `PYTHONIOENCODING=ascii`
+ pipe -- raises `UnicodeEncodeError` on the first character it cannot
+ write. The typography this repository writes is spelled out; anything
+ else becomes `?` rather than an exception.
+ """
+ for source, target in (("—", "--"), ("–", "-"),
+ ("‘", "'"), ("’", "'"),
+ ("“", '"'), ("”", '"'),
+ ("…", "..."), (" ", " ")):
+ text = text.replace(source, target)
+ return text.encode("ascii", "replace").decode("ascii")
+
+# ---------------------------------------------------------------------------
+# What is said
+# ---------------------------------------------------------------------------
+#
+# THE MEANING IS FIXED AND THE FORM IS THE SURFACE'S. The reference sentence
+# the realization brief carries is: "This is a single repository. The Triad
+# -- an assembly root with a spec leg and a code leg -- is the preferred
+# project shape: openRepoShape's adopt-project.py converts a repository in
+# place, and a project that stays single can say so in single-repository.yaml.
+# Nothing here changes; work continues." THE POSTURE TRAVELS WITH IT, every
+# time: the ratified requirement says that wherever the preference is stated
+# "confers nothing" is stated with it, so that no reader meets "preferred"
+# without also meeting that.
+
+TRIAD = ("The Triad -- an assembly root with a spec leg and a code leg -- is "
+ "the preferred project shape")
+POSTURE = ("preferred, not required: it confers nothing, and a single "
+ "repository is reviewed identically")
+
+
+def advisory_lines() -> list[str]:
+ """The advisory as a work-start surface says it: two lines."""
+ return [
+ f"This is a single repository. {TRIAD}; {POSTURE}.",
+ "openRepoShape's adopt-project.py converts a repository in place when "
+ "a person deciding for this project runs it, and a project that stays "
+ f"single can say so in {RECORD_FILE}. Nothing here changes; work "
+ "continues.",
+ ]
+
+
+def adopt_lines() -> list[str]:
+ """The same meaning, said by the tool that IS the conversion.
+
+ `adopt-project.py` cannot tell a person to run itself, so the second line
+ says what it actually does about the advice: nothing until `execute`, and
+ that only on the word of a person deciding for the project.
+ """
+ return [
+ f"This is a single repository. {TRIAD}, and this tool is how a "
+ f"repository reaches it, in place; {POSTURE}.",
+ "Nothing converts until `execute`, on the word of a person deciding "
+ "for this project, and a project that stays single can say so in "
+ f"{RECORD_FILE} instead. `plan` and `check` change nothing in it.",
+ ]
+
+
+def scaffold_lines() -> list[str]:
+ """What the scaffold's plan says: the Triad OFFERED FIRST.
+
+ `scaffold-project.py` has no single-repository mode -- every run creates
+ an assembly root and two legs -- so the offer is the tool itself, and the
+ plan a person reads before typing yes names it as the preferred shape,
+ with the posture beside it. Nothing here is about a repository that
+ already exists, so nothing here is conditional.
+ """
+ return [
+ "the Triad -- an assembly root with a spec leg and a code leg -- is "
+ "what this creates, and the preferred project shape",
+ f"{POSTURE}; a project kept in one repository owes no declaration",
+ ]
+
+
+def record_report(problems) -> list[str]:
+ """One line per reason a present `single-repository.yaml` silences
+ nothing. Reported beside the advisory, and nothing fails on its account.
+ """
+ return [f"{RECORD_FILE} does not silence this: {problem} (its schema is "
+ f"openRepoShape's {RECORD_SCHEMA}); nothing fails on its account."
+ for problem in problems]
+
+
+def leg_instruction(leg: dict) -> str:
+ """The existing instruction a leg clone gets INSTEAD of the advisory.
+
+ It is the leg templates' own "Clone the assembly root, not this
+ repository", said by the tool that found the leg. The leg IS part of a
+ Triad, so advising it to become one would be false.
+
+ IT SAYS AS MUCH AS IT KNOWS AND NO MORE. A leg whose own `AGENTS.md` is
+ the leg template's has said so itself, and names its root, so the
+ sentence is plain. A leg known by its NAME alone -- `billing-spec`, with
+ no leg `AGENTS.md` -- is a leg under the naming policy, which is a
+ declared fact and silences the advisory; but whether some `billing`
+ actually mounts it is not something a name can settle, so the sentence
+ says "named as" and "if it is one" rather than telling an unrelated
+ repository it is a clone of somebody's leg.
+ """
+ root = leg.get("assembly")
+ named = f"`{root}`" if root else "the assembly root that mounts it"
+ work = (f"work from its assembly root {named}, cloned with `git clone "
+ "--recurse-submodules` and `make bootstrap`, where the manifest "
+ "and both legs are -- rather than adopting or scaffolding this "
+ "leg")
+ if leg.get("by") == "name":
+ return (f"`{leg['name']}` is named as the {leg['role']} leg of "
+ f"{named} under {NAMING_POLICY}, so the Triad advisory is not "
+ f"given here; if it is one, {work}")
+ return (f"this is a clone of the {leg['role']} leg of a Triad "
+ f"({leg['how']}), so the Triad advisory does not apply here: "
+ f"{work}")
+
+
+# ---------------------------------------------------------------------------
+# What is read
+# ---------------------------------------------------------------------------
+
+
+class Reading:
+ """What `read()` found about one directory, and why -- never a verdict.
+
+ `state` is one of `STATES`. `why` is the declared fact that decided it,
+ in a sentence. `record_problems` are the reasons a PRESENT
+ `single-repository.yaml` silences nothing, reported beside the advisory;
+ `field_problems` are what a record that DOES silence leaves out. `leg` is
+ set for a leg clone and says which leg and how it was known.
+ """
+
+ def __init__(self, state: str, why: str, record_problems=(),
+ field_problems=(), leg: dict | None = None):
+ assert state in STATES, state
+ self.state = state
+ self.why = why
+ self.record_problems = tuple(record_problems)
+ self.field_problems = tuple(field_problems)
+ self.leg = leg
+
+ @property
+ def advise(self) -> bool:
+ """Is the advisory given here? The ONE question its callers ask."""
+ return self.state == ADVISE
+
+ def as_dict(self) -> dict:
+ return {"state": self.state, "why": self.why,
+ "advise": self.advise,
+ "record_problems": list(self.record_problems),
+ "field_problems": list(self.field_problems),
+ "leg": self.leg}
+
+
+class Tree:
+ """The files of one repository's root, read from the disk or a commit.
+
+ THE DOCTOR READS WHAT IS ON THE DISK, which is its whole contract: a
+ report about the tree in front of the person. `adopt-project.py` does
+ not -- its plan is written against ONE COMMIT of the default branch, so
+ a `single-repository.yaml` sitting uncommitted in the working tree, or
+ committed only on a feature branch, is not in the repository being
+ planned and must not silence the advisory about it. With `commit` the
+ same questions are asked of that commit's own objects, through the git
+ the plan itself reads.
+
+ `kind()` is "file", "dir" (anything there that is not a file) or None; `text()` is the decoded bytes of a
+ file, or None when there is no file to read. An undecodable file RAISES
+ `UnicodeDecodeError`, because "it is there and could not be read" is a
+ different answer from "it is not there", and the record reports the
+ first.
+ """
+
+ def __init__(self, root: Path, commit: str | None = None):
+ self.root = Path(root)
+ self.commit = commit
+
+ def kind(self, rel: str) -> str | None:
+ if self.commit is None:
+ path = self.root / rel
+ if path.is_file():
+ return "file"
+ return "dir" if path.exists() else None
+ try:
+ found = git_out(["cat-file", "-t", f"{self.commit}:{rel}"],
+ cwd=self.root)
+ except (Refusal, OSError):
+ return None
+ # A tree, or a gitlink (`commit`), is there and is not a file.
+ return "file" if found == "blob" else "dir"
+
+ def text(self, rel: str) -> str | None:
+ if self.kind(rel) != "file":
+ return None
+ if self.commit is None:
+ raw = (self.root / rel).read_bytes()
+ else:
+ raw = git_out(["cat-file", "blob", f"{self.commit}:{rel}"],
+ cwd=self.root, binary=True)
+ return raw.decode("utf-8")
+
+
+def _read_mapping(tree: Tree, rel: str):
+ """`rel` as a YAML mapping, or None -- never a refusal, never a crash.
+
+ The advisory is a sentence, not a check, so a manifest it cannot read is
+ a manifest it does not see; the doctor's own rows are where a broken
+ manifest is a finding. EVERY exception is that answer, not only the
+ three this file expects: a manifest nested past the recursion limit is
+ as unreadable as one that does not parse, and the ratified rule is that
+ the advisory never changes an exit status.
+ """
+ try:
+ text = tree.text(rel)
+ data = parse_yaml(text.lstrip(BOM)) if text is not None else None
+ except Exception: # noqa: BLE001 - see the docstring
+ return None
+ return data if isinstance(data, dict) else None
+
+
+def is_triad(manifest) -> bool:
+ """Does this `project.yaml` declare the Triad?
+
+ The detector the ratified rule names, and the one the shared agent
+ protocol and the workstation bootstrap already use: `kind:
+ project-manifest`, `schema: project-repo-schema`, and `legs:` naming a
+ `spec` and a `code` leg. A project manifest that declares fewer legs is
+ not a Triad, whatever its file is called.
+ """
+ if not isinstance(manifest, dict):
+ return False
+ if manifest.get("kind") != "project-manifest" \
+ or manifest.get("schema") != "project-repo-schema":
+ return False
+ legs = manifest.get("legs")
+ roles = {str(leg.get("role")) for leg in (legs if isinstance(legs, list)
+ else [])
+ if isinstance(leg, dict)}
+ return set(LEG_ROLES) <= roles
+
+
+def repository_name(url: str) -> str | None:
+ """The repository name at the end of a remote url, case preserved.
+
+ `git@github.com:Org/Atlas-spec.git`, `https://github.com/Org/brett-wip`
+ and `/srv/remotes/Atlas-spec.git` all end in the name a person gave the
+ repository, which is what the naming policy classifies. Only the last
+ segment is read -- the organisation is part of no naming family, as
+ `validate-repository-naming.py` says of a qualified name.
+ """
+ text = url.strip().replace("\\", "/").rstrip("/")
+ name = re.split(r"[/:]", text)[-1]
+ if name.lower().endswith(".git"):
+ name = name[:-4]
+ return name or None
+
+
+def repository_names(root: Path) -> list[str]:
+ """The names this repository goes by: its directory's, then `origin`'s.
+
+ `origin` is asked only when `root` ITSELF carries a `.git`: git walks up
+ to an ancestor otherwise, and an ancestor's name is not this directory's
+ -- the trap `shape-doctor.py`'s `repo_local_origin_name` guards against.
+ """
+ names = [root.name]
+ if (root / ".git").exists():
+ try:
+ url = git_out(["remote", "get-url", "origin"], cwd=root)
+ except (Refusal, OSError):
+ url = ""
+ name = repository_name(url) if url else None
+ if name and name not in names:
+ names.append(name)
+ return names
+
+
+def _naming_policy(shape: Path) -> NamingPolicy | None:
+ try:
+ return NamingPolicy.load(shape / NAMING_POLICY)
+ except (Refusal, OSError, UnicodeDecodeError):
+ return None
+
+
+def _leg_by_name(names: list[str], policy: NamingPolicy | None) -> dict | None:
+ """A leg NAMED as one: `-spec` or `-code`.
+
+ The second route, and the weaker: the naming policy is a declared fact,
+ so the name alone silences the advisory, but it cannot say that a
+ `` mounts this repository -- which is why `leg_instruction`
+ words a name-only match as "named as ... if it is one".
+ """
+ if policy is None:
+ return None
+ for name in names:
+ found = policy.classify(name)
+ if found is not None and found.family == "project-leg" \
+ and found.role in LEG_ROLES:
+ return {"role": found.role, "by": "name", "name": name,
+ "assembly": name[:-len(f"-{found.role}")],
+ "how": f"`{name}` is the {found.role}-leg form of "
+ f"{NAMING_POLICY}"}
+ return None
+
+
+def _leg_by_agents_md(tree: Tree) -> dict | None:
+ """A leg that says so in its own `AGENTS.md`, as the templates write it.
+
+ THE FIRST ROUTE, the one design D4 of the ratified change names: every
+ leg `templates/spec-root/` and `templates/code-root/` seeds carries an
+ `AGENTS.md` that opens by saying which leg it is and names the assembly
+ root it belongs to.
+ """
+ try:
+ text = tree.text(AGENTS_MD)
+ except Exception: # noqa: BLE001 - an unreadable file says nothing
+ return None
+ if text is None:
+ return None
+ text = text.lstrip(BOM)
+ opening = LEG_OPENING_RE.match(text)
+ if opening is None:
+ return None
+ named = LEG_ROOT_RE.search(" ".join(text.split()))
+ return {"role": opening.group(1), "by": "agents",
+ "name": tree.root.name,
+ "assembly": named.group(1) if named else None,
+ "how": f"its {AGENTS_MD} is the {opening.group(1)} leg's, as "
+ f"templates/{opening.group(1)}-root/ writes it"}
+
+
+def workspace_paths(env=None) -> list[Path]:
+ """The checkouts the person's own workspace configuration names.
+
+ `${AGENT_PROTOCOL_ROOT:-$HOME/.agents}/workspace.yaml` -- the one file the
+ README says names a person's `-wip` -- carries a top-level `path:`
+ and, optionally, an `orgs:` map whose entries carry a `path:` of their
+ own (the shape `opensoft/openRepoTools`' `resume` reads). Read only;
+ a file that is absent or does not parse names nothing, and AN ENTRY THAT
+ CANNOT BE A PATH ON THIS MACHINE -- a NUL byte in it, a `~user` nobody
+ has -- names nothing either, without costing the entries beside it. The
+ person's configuration is the one input here that is not this
+ repository's, and it is the last thing that should fail a run.
+ """
+ env = os.environ if env is None else env
+ try:
+ base = Path(env.get("AGENT_PROTOCOL_ROOT") or os.path.join(
+ os.path.expanduser("~"), ".agents"))
+ except Exception: # noqa: BLE001 - see the docstring
+ return []
+ config = _read_mapping(Tree(base), "workspace.yaml")
+ if config is None:
+ return []
+ raw = [config.get("path")]
+ orgs = config.get("orgs")
+ if isinstance(orgs, dict):
+ raw += [entry.get("path") for entry in orgs.values()
+ if isinstance(entry, dict)]
+ found: list[Path] = []
+ for value in raw:
+ if not isinstance(value, str) or not value.strip():
+ continue
+ try:
+ found.append(Path(os.path.expanduser(value.strip())).resolve())
+ except Exception: # noqa: BLE001 - `\0` raises ValueError here
+ continue
+ return found
+
+
+def _workspace(root: Path, names: list[str], policy: NamingPolicy | None,
+ env) -> str | None:
+ """Why this is a `-wip` workspace repository, or None."""
+ if policy is not None:
+ for name in names:
+ found = policy.classify(name)
+ if found is not None and found.family == "workspace":
+ return (f"`{name}` is the workspace (-wip) form of "
+ f"{NAMING_POLICY}")
+ try:
+ here = root.resolve()
+ except Exception: # noqa: BLE001 - a root this machine cannot resolve
+ return None
+ if here in workspace_paths(env):
+ return ("the person's own workspace.yaml names this checkout as "
+ "their workspace repository")
+ return None
+
+
+# -- the staying-single record ----------------------------------------------
+
+#: What reading `single-repository.yaml` can come to.
+RECORD_ABSENT = "absent"
+RECORD_SILENCES = "silences"
+RECORD_SILENCES_NOTHING = "silences-nothing"
+
+
+def record_schema(shape: Path = SHAPE_ROOT) -> dict:
+ """The record's schema, as data. A refusal when it cannot be read: it
+ ships beside this file, so a checkout without it is the STANDARD being
+ short, which the caller reports rather than guesses around."""
+ data = load_yaml(shape / RECORD_SCHEMA)
+ if not isinstance(data, dict) or data.get("kind") != SCHEMA_KIND:
+ raise Refusal("record-schema-wrong-kind",
+ f"{RECORD_SCHEMA} does not declare kind {SCHEMA_KIND!r}")
+ return data
+
+
+def _value_problem(value, field: dict) -> str | None:
+ """Why ONE present field's value is not what the schema says, or None."""
+ expected = field.get("type")
+ if expected == "integer":
+ if isinstance(value, bool) or not isinstance(value, int):
+ return f"is {value!r}, not a whole number"
+ elif expected in ("string", "date"):
+ if not isinstance(value, str) or not value.strip():
+ return f"is {value!r}, not text"
+ if PLACEHOLDER_RE.search(value):
+ return f"still carries the template's placeholder {value!r}"
+ if expected == "date":
+ try:
+ ok = bool(DATE_RE.match(value)) and \
+ _dt.date.fromisoformat(value) is not None
+ except ValueError:
+ ok = False
+ if not ok:
+ return f"is {value!r}, not an ISO date (YYYY-MM-DD)"
+ if "value" in field and value != field["value"]:
+ return f"is {value!r}; the schema fixes it at {field['value']!r}"
+ return None
+
+
+def field_problems(record: dict, shape: Path = SHAPE_ROOT) -> list[str]:
+ """What a record of the right `kind` leaves out or gets wrong, by its
+ schema. Reported as a note; the record is silent either way, because
+ `kind:` is what decides and a person who wrote the file meant it."""
+ try:
+ schema = record_schema(shape)
+ except (Refusal, OSError, UnicodeDecodeError) as exc:
+ detail = exc.detail if isinstance(exc, Refusal) else str(exc)
+ return [f"its schema could not be read to check the fields: {detail}"]
+ fields = [field for field in (schema.get("fields") or [])
+ if isinstance(field, dict) and field.get("name")]
+ problems: list[str] = []
+ for field in fields:
+ name = str(field["name"])
+ if record.get(name) is None:
+ if field.get("required"):
+ problems.append(f"`{name}` is missing")
+ continue
+ problem = _value_problem(record[name], field)
+ if problem:
+ problems.append(f"`{name}` {problem}")
+ known = {str(field["name"]) for field in fields}
+ problems += [f"`{key}` is not a field of the record"
+ for key in record if str(key) not in known]
+ return problems
+
+
+def read_record(root, shape: Path = SHAPE_ROOT,
+ commit: str | None = None) -> tuple:
+ """`(what the record comes to, why it silences nothing, its field notes)`.
+
+ `kind:` DECIDES. A file that parses as a mapping declaring
+ `single-repository-record` silences the advisory, and its field problems
+ are notes. Anything else that is present -- unreadable, undecodable,
+ unparsable, nested past the interpreter's recursion limit, not a
+ mapping, another kind, not a file at all -- silences nothing and says
+ why, so the person who wrote it learns that it is not being read; and
+ NOTHING about it raises, because a record that confers nothing must not
+ be able to fail the run that reads it. A leading UTF-8 byte-order mark is
+ an editor's, not the person's, and is dropped before the first key.
+
+ `root` is a path, or a `Tree` -- which is how `adopt-project.py` asks
+ this of the commit it plans rather than of the working tree.
+ """
+ tree = root if isinstance(root, Tree) else Tree(Path(root), commit)
+ try:
+ kind = tree.kind(RECORD_FILE)
+ if kind is None:
+ return RECORD_ABSENT, [], []
+ if kind != "file":
+ return RECORD_SILENCES_NOTHING, ["it is not a file"], []
+ data = parse_yaml(tree.text(RECORD_FILE).lstrip(BOM))
+ except Exception as exc: # noqa: BLE001 - see the docstring
+ return RECORD_SILENCES_NOTHING, [f"it could not be read "
+ f"({type(exc).__name__})"], []
+ if not isinstance(data, dict):
+ return RECORD_SILENCES_NOTHING, ["it is not a YAML mapping"], []
+ kind = data.get("kind")
+ if kind != RECORD_KIND:
+ return RECORD_SILENCES_NOTHING, [
+ f"it declares kind {kind!r}, not {RECORD_KIND!r}"], []
+ return RECORD_SILENCES, [], field_problems(data, shape)
+
+
+# -- the reading -------------------------------------------------------------
+
+
+def read(root, shape: Path = SHAPE_ROOT, env=None,
+ commit: str | None = None) -> Reading:
+ """Is the advisory given in `root`, and if not, which declared fact says so?
+
+ THE ORDER IS THE RATIFIED LIST'S, and only the advisory's own sentence
+ depends on it: every silence is a silence however it was reached. The
+ record is read LAST because a repository that is already a Triad, a
+ holder, a leg or a workspace has the shape question answered whatever a
+ stray `single-repository.yaml` in it says.
+
+ `commit` READS THE TREE OF THAT COMMIT instead of the working tree --
+ `adopt-project.py` passes the commit its plan is written against, so the
+ advisory is about the repository being planned. The `.git`, the names
+ and the person's own configuration are read from the disk either way:
+ they are facts about the checkout and the person, not about a commit.
+
+ AND IT NEVER RAISES. Anything `_read` meets that it did not expect makes
+ this answer `NOT_ANSWERED`, which is silent: the ratified rule is that
+ the advisory changes no exit status, and a crash is an exit status.
+ `KeyboardInterrupt` and `SystemExit` are not `Exception`s and still
+ propagate, because a person stopping a run is not the advisory's to
+ swallow.
+ """
+ try:
+ return _read(Path(root), shape, env, commit)
+ except Exception as exc: # noqa: BLE001 - see the docstring
+ return Reading(NOT_ANSWERED, f"the advisory could not be read here "
+ f"({type(exc).__name__}), so it says "
+ "nothing")
+
+
+def _read(root: Path, shape: Path, env, commit: str | None) -> Reading:
+ """`read()`'s body: every silence in the ratified list, then the record."""
+ tree = Tree(root, commit)
+ project = _read_mapping(tree, PROJECT_MANIFEST)
+ if is_triad(project):
+ return Reading(TRIAD_ROOT, f"{PROJECT_MANIFEST} declares the Triad: "
+ "the schema, a spec leg and a code leg")
+ family = _read_mapping(tree, FAMILY_MANIFEST)
+ if (family or {}).get("kind") == "family-manifest" \
+ and (project or {}).get("kind") != "project-manifest":
+ return Reading(FAMILY_HOLDER, f"{FAMILY_MANIFEST} declares a family "
+ "holder, which pins projects and is "
+ "not one")
+ if not (root / ".git").exists():
+ return Reading(NOT_A_REPOSITORY, "there is no repository here to "
+ "advise about")
+ policy = _naming_policy(shape)
+ names = repository_names(root)
+ # THE LEG'S OWN `AGENTS.md` FIRST: it is the route design D4 of the
+ # ratified change names, and the one that can name the assembly root.
+ leg = _leg_by_agents_md(tree) or _leg_by_name(names, policy)
+ if leg is not None:
+ return Reading(LEG_CLONE, leg["how"], leg=leg)
+ workspace = _workspace(root, names, policy, env)
+ if workspace is not None:
+ return Reading(WORKSPACE, workspace)
+ state, silences_nothing, notes = read_record(tree, shape)
+ if state == RECORD_SILENCES:
+ return Reading(RECORDED_SINGLE, f"{RECORD_FILE} records that this "
+ "project stays a single repository",
+ field_problems=notes)
+ return Reading(ADVISE, "a repository that has not elected the Triad and "
+ "has recorded nothing", silences_nothing)
diff --git a/shape-doctor.py b/shape-doctor.py
index 4e1b748c..cb36f3eb 100755
--- a/shape-doctor.py
+++ b/shape-doctor.py
@@ -81,6 +81,20 @@
here`, `the way in` -- `adopt-project.py plan` for a repository that already
exists, `setup.sh` for a new one -- and `machine`.
+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 --
+is the PREFERRED project shape, it stays elective and confers nothing, and
+this report MAY say so. A repository with no manifest gets one row with the
+status `advisory`, saying it once; a family holder, a `-wip` workspace
+and a repository carrying a `single-repository.yaml` of the right kind get
+no row at all; a leg clone gets the instruction to work from its assembly
+root instead, and `the way in` offers it no adoption. An assembly root --
+an elected Triad, or a broken one its `manifest` row already explains -- is
+not asked. The row never moves the verdict -- NOT A SHAPE ROOT is exit 2
+with or without it -- because a project's shape is never a review input;
+`check_triad_advisory` says the rest.
+
THE PLACEMENT ROW ASKS THE ADOPTION'S QUESTION OF A REPOSITORY ALREADY
SPLIT. Brett Heap, 2026-09-10: "we have to look for code in spec and spec in
code". `adopt-project.py plan` decides which leg every path of an UNSPLIT
@@ -122,8 +136,9 @@
reader improvises around -- which is the house rule the rest of this standard
is built on.
-AND ONLY A `FINDING` MOVES THE VERDICT. A row is `ok`, `note`, `FINDING` or
-`n/a`, and `FINDING` means exactly "something ELSE asserts this" -- a
+AND ONLY A `FINDING` MOVES THE VERDICT. A row is `ok`, `note`, `FINDING`,
+`n/a` or -- the Triad row alone -- `advisory`, and `FINDING` means exactly
+"something ELSE asserts this" -- a
validator, a pin row, a manifest, a gitlink. A difference nothing asserts is
a `note`: it is printed, it is in `--json`, and the verdict steps over it.
That line was drawn after this command printed `INVALID` over a live estate
@@ -261,6 +276,15 @@ def not_in_the_standard(detail: str) -> int:
except ImportError as exc: # pragma: no cover - exercised as a subprocess
sys.exit(not_in_the_standard(f"scripts/path_classify.py ({exc})"))
+try:
+ #: THE TRIAD ADVISORY (openxFactory `prefer-triad-project-shape`, ratified
+ #: by Brett Heap on 2026-10-06). One module says it for all three tools
+ #: that may, and decides where it is silent, so this report, the scaffold
+ #: and the adoption cannot disagree about which repository is told.
+ import shape_advisory # noqa: E402
+except ImportError as exc: # pragma: no cover - exercised as a subprocess
+ sys.exit(not_in_the_standard(f"scripts/shape_advisory.py ({exc})"))
+
#: The statuses a row can carry, and only ONE of them moves the verdict.
#:
#: `n/a` is a question this ROOT does not have -- a family has no legs -- or,
@@ -282,6 +306,16 @@ def not_in_the_standard(detail: str) -> int:
NOTE = "note"
FINDING = "FINDING"
NA = "n/a"
+#: `advisory` is the TRIAD ADVISORY, and nothing else carries it: a
+#: repository that has not elected the Triad is told, in this one row, that
+#: the Triad is the preferred project shape and that it confers nothing.
+#: Ruled by Brett Heap on 2026-10-06 ("ratify 1249 as recommended",
+#: openxFactory `prefer-triad-project-shape`), which is also why it can never
+#: move the verdict: a project's shape is never a review input, so a row about
+#: it that changed an exit code would be the review warning that ruling
+#: rejects. `verdict_for` reads `FINDING` and nothing else, so this is stepped
+#: over exactly as `note` is -- and it is lowercase for the reason `note` is.
+ADVISORY = "advisory"
#: Which kind of root this is. Read from the tree, never taken as a flag, the
#: same rule `update-shape.py`'s `root_kind` follows and for the same reason.
@@ -555,6 +589,12 @@ class Check:
and getting the human's yes -- the same posture `update-shape.py apply`
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
+ 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.
"""
def __init__(self, check_id: str, label: str, applies_to: tuple,
@@ -2729,7 +2769,13 @@ def check_what_is_here(ctx: Context) -> Row:
("spec", "a spec/ directory"),
("code", "a code/ directory"),
(AGENTS_MD, "an AGENTS.md"),
- ("Makefile", "a Makefile")):
+ ("Makefile", "a Makefile"),
+ # The optional staying-single record. Named here as
+ # a FILE THAT IS PRESENT, like the rest of this list,
+ # and not read: its only reader is the Triad advisory,
+ # whose row says what it found in it.
+ (shape_advisory.RECORD_FILE,
+ f"a {shape_advisory.RECORD_FILE}")):
there = (root / name).exists()
detail[name] = there
if there:
@@ -2930,6 +2976,21 @@ def project_token_for(name: str, policy: NamingPolicy) -> str:
return derived if re.match(r"^[A-Za-z]", derived) else "Project"
+def leg_clone(ctx: Context) -> bool:
+ """Does the Triad advisory read this directory as a leg clone?
+
+ ASKED OF `shape_advisory`, never answered a second time here, so `the
+ way in` and the `Triad` row cannot disagree about one directory. False
+ when the advisory cannot answer at all -- `the way in` then says what it
+ always said.
+ """
+ try:
+ return shape_advisory.read(ctx.root, ctx.shape).state \
+ == shape_advisory.LEG_CLONE
+ except Exception: # noqa: BLE001 - the advisory never fails the report
+ return False
+
+
def check_the_way_in(ctx: Context) -> Row:
"""The two ways a directory becomes a shape root, and who decides.
@@ -2962,7 +3023,17 @@ def check_the_way_in(ctx: Context) -> Row:
policy = NamingPolicy.load(ctx.shape / "contracts" /
"repository-naming.yaml")
project = project_token_for(identity, policy)
- if is_repo:
+ if is_repo and leg_clone(ctx):
+ # A LEG IS NOT ADOPTED. Splitting a spec or code leg into two more
+ # legs is the one adoption nobody wants, and offering it two rows
+ # above a `Triad` row that says "work from its assembly root" would
+ # be this report contradicting itself (#164 review). No `next` here:
+ # the Triad row names the root to work from.
+ fix = None
+ reason = ("this is a git repository with no shape manifest, NAMED OR "
+ "WRITTEN AS A LEG: a leg is not adopted or scaffolded on "
+ "its own -- see the Triad row for its assembly root")
+ elif is_repo:
fix = (f"{PYTHON} {quote_arg(ctx.shape / 'adopt-project.py')} "
f"plan --source {quote_arg(root)} --project "
f"{quote_arg(project)}")
@@ -2981,6 +3052,98 @@ def check_the_way_in(ctx: Context) -> Row:
{"git_repository": is_repo, "suggested_project": project})
+#: Named once, like `LABEL_MANIFEST_KINDS` above: this function's three
+#: returns and the `CHECKS` entry below must all print the same row name.
+#: "Triad" because human-facing text says Triad (the ratified vocabulary);
+#: the row's `id` is a machine key and says what the row is.
+LABEL_TRIAD_ADVISORY = "Triad"
+
+
+def check_triad_advisory(ctx: Context) -> Row | None:
+ """The Triad advisory, once, for a repository that has not elected it.
+
+ RULED BY BRETT HEAP ON 2026-10-06: "ratify 1249 as recommended", which
+ ratified openxFactory's `prefer-triad-project-shape`. The Triad is the
+ PREFERRED project shape, it stays elective and confers nothing, and this
+ report is one of the three surfaces that MAY say so -- once, beside what
+ it already reports, and never on a verdict's account. So the row is
+ `advisory` and nothing else: `verdict_for` reads `FINDING` only, NOT A
+ SHAPE ROOT stays exit 2 exactly as it was, and an assembly root's verdict
+ is whatever its own rows make it. THE SHAPE IS NEVER A REVIEW INPUT, so no
+ required check, review lane or merge gate may be built on this row, as
+ none may be built on the NOT A SHAPE ROOT it sits beside.
+
+ WHERE IT IS SILENT, `scripts/shape_advisory.py` decides, from declared
+ facts only -- an elected Triad, a family holder, a `-wip` workspace,
+ a `single-repository.yaml` of the right kind -- and this returns NO ROW
+ there. Two silences still have something to say, and say it as what it
+ is rather than as the advisory: a LEG CLONE gets the existing instruction
+ to work from its assembly root instead (`n/a`, because the leg is part of
+ a Triad and the advisory would be false there), and a staying-single
+ record that leaves a field out gets a `note` naming the field, because the
+ advisory is that record's only reader and this is where its reader says
+ what it found.
+
+ A RECORD THAT SILENCES NOTHING IS REPORTED IN THE ADVISORY ROW ITSELF:
+ one that does not parse, or declares another `kind`, is named with the
+ reason, and nothing fails on its account.
+
+ `next` IS EMPTY ON PURPOSE, in all three statuses. Every other `next`
+ here is the command that fixes its row, and nothing is broken: the two
+ ways on -- converting with `adopt-project.py`, or recording staying
+ single -- are named in the sentence and are a person's to choose, and
+ for any repository that is not a leg `the way in` row above already
+ carries the adoption command for it (a leg is offered none: it is sent
+ to its assembly root).
+
+ ONLY WHERE THERE IS NO MANIFEST. An assembly root whose `project.yaml`
+ declares fewer than two legs is not a Triad by the detector, but it is
+ not "a single repository" either -- it is a broken project root, and the
+ `manifest` row's finding is the guidance there. So the row is asked only
+ of a directory with neither manifest, which is also the one kind of root
+ whose verdict reads no row at all: nothing here could move a verdict even
+ if it tried.
+
+ AND IT NEVER FAILS THE REPORT. `shape_advisory.read` already answers
+ rather than raises; this says nothing rather than a traceback for
+ whatever is left -- a row nobody needed is the cheapest thing in the
+ report to lose, and the ratified rule is that the advisory changes no
+ exit status.
+ """
+ try:
+ return _triad_advisory_row(ctx)
+ except Exception: # noqa: BLE001 - see the docstring
+ return None
+
+
+def _triad_advisory_row(ctx: Context) -> Row | None:
+ """`check_triad_advisory`'s body: the row, or no row where it is silent."""
+ reading = shape_advisory.read(ctx.root, ctx.shape)
+ detail = reading.as_dict()
+ detail.update({"record": shape_advisory.RECORD_FILE,
+ "schema": shape_advisory.RECORD_SCHEMA,
+ "template": shape_advisory.RECORD_TEMPLATE})
+ if reading.advise:
+ said = shape_advisory.advisory_lines() + \
+ shape_advisory.record_report(reading.record_problems)
+ return Row("triad-advisory", LABEL_TRIAD_ADVISORY, ADVISORY,
+ " ".join(said), None, detail)
+ if reading.state == shape_advisory.LEG_CLONE:
+ return Row("triad-advisory", LABEL_TRIAD_ADVISORY, NA,
+ shape_advisory.leg_instruction(reading.leg), None, detail)
+ if reading.state == shape_advisory.RECORDED_SINGLE \
+ and reading.field_problems:
+ return Row("triad-advisory", LABEL_TRIAD_ADVISORY, NOTE,
+ f"{shape_advisory.RECORD_FILE} records that this project "
+ "stays a single repository, so no advisory is given; "
+ "against its schema ("
+ f"{shape_advisory.RECORD_SCHEMA}): "
+ + "; ".join(reading.field_problems)
+ + ". It stays silent either way; nothing fails on its "
+ "account", None, detail)
+ return None
+
+
# ---------------------------------------------------------------------------
# The registry
# ---------------------------------------------------------------------------
@@ -3008,6 +3171,14 @@ def check_the_way_in(ctx: Context) -> Row:
Check("naming", "naming", (NOT_A_ROOT,), check_not_a_root_naming),
Check("contents", "what is here", (NOT_A_ROOT,), check_what_is_here),
Check("way-in", "the way in", (NOT_A_ROOT,), check_the_way_in),
+ # AFTER EVERYTHING ABOUT THE REPOSITORY AND BEFORE THE MACHINE, and only
+ # where there is no manifest at all. A family holder is never advised; an
+ # assembly root whose manifest is short of a Triad is a broken root, which
+ # its `manifest` row says better than "this is a single repository" would.
+ # The row is `advisory` -- or absent where the rule makes it silent --
+ # and never moves the verdict; see `check_triad_advisory`.
+ Check("triad-advisory", LABEL_TRIAD_ADVISORY, (NOT_A_ROOT,),
+ check_triad_advisory),
# LAST, AND ON EVERY KIND OF ROOT. It reports the machine rather than
# the repository, so it is `n/a` by status and never moves the verdict
# -- see `check_machine`. It reads last because it is about the
@@ -3023,7 +3194,9 @@ def run_checks(ctx: Context) -> list[Row]:
if ctx.kind not in check.applies_to:
continue
try:
- rows.append(check.run(ctx))
+ row = check.run(ctx)
+ if row is not None:
+ rows.append(row)
except Refusal as exc:
rows.append(Row(check.id, check.label, FINDING,
f"refused: {exc.detail}", exc.remediation or None,
diff --git a/templates/assembly-root/AGENTS-shape.md b/templates/assembly-root/AGENTS-shape.md
index 99d3ca87..de01154f 100644
--- a/templates/assembly-root/AGENTS-shape.md
+++ b/templates/assembly-root/AGENTS-shape.md
@@ -132,6 +132,12 @@ authority travels in the grants rather than in the layout. Asked to make
`role: spec` mean "spec authority lives here", say no: that turns a layout into
a governance boundary, which is the one thing this standard exists to prevent.
+This shape is the **Triad**, and the Triad is the PREFERRED project shape
+(openxFactory `prefer-triad-project-shape`, ratified by Brett Heap on
+2026-10-06). Preferred is not required and changes none of the above: never
+tell anyone that a project kept in one repository owes anything, and never
+make the shape a review input.
+
## Declaring descent
A `` name is a CLAIM of descent, and a claim needs a
diff --git a/templates/assembly-root/project.yaml b/templates/assembly-root/project.yaml
index a07aeaae..75888d19 100644
--- a/templates/assembly-root/project.yaml
+++ b/templates/assembly-root/project.yaml
@@ -16,6 +16,16 @@ kind: project-manifest
# that declines this schema is reviewed identically to this one, because the
# authority travels in the grants rather than in the layout. A consumer
# deriving a permission from `role:` is defective.
+#
+# THE TRIAD IS THE PREFERRED SHAPE, AND THAT CHANGES NONE OF THE ABOVE. This
+# file is how a Triad — an assembly root with a spec leg and a code leg, also
+# called a three-leg or three-repository project — is declared, and the Triad
+# is the recommended default for a new project and the recommended target for
+# an existing single repository: ruled by Brett Heap on 2026-10-06, "ratify
+# 1249 as recommended" (openxFactory `prefer-triad-project-shape`). Preferred
+# is not required. Electing it is still a human's act per project, it
+# confers nothing, and a project kept in one repository is reviewed
+# identically and owes no declaration.
# ===========================================================================
id: {{PROJECT_ID}}
diff --git a/templates/single-repository/single-repository.yaml b/templates/single-repository/single-repository.yaml
new file mode 100644
index 00000000..b4e15224
--- /dev/null
+++ b/templates/single-repository/single-repository.yaml
@@ -0,0 +1,40 @@
+schema_version: 1
+kind: single-repository-record
+
+# ===========================================================================
+# single-repository.yaml — this project STAYS A SINGLE REPOSITORY, recorded.
+#
+# OPTIONAL, AND NEVER OWED. The Triad — an assembly root with a spec leg and
+# a code leg — is the PREFERRED project shape (openxFactory
+# `prefer-triad-project-shape`, ratified by Brett Heap on 2026-10-06), and a
+# person starting work here is told so once. This file is how a project that
+# has DECIDED to stay in one repository says so, once, and stops meeting that
+# advisory. A project that writes nothing owes nothing and is reviewed
+# identically.
+#
+# IT CONFERS NOTHING. No gate, no floor, no grant, no clearance eligibility,
+# no lifecycle state and no review difference. Its only reader is
+# openRepoShape's Triad advisory; no review lane, required check, validator,
+# floor, council or merge gate reads it.
+#
+# HOW TO USE IT. Copy this file to the repository's ROOT as
+# `single-repository.yaml`, replace every value in double braces below, and
+# commit it like any other change. `revisit_on:` is optional — delete its line if
+# there is nothing to revisit. The schema is openRepoShape's
+# `contracts/single-repository-record.yaml`. Converting to the Triad later is
+# `adopt-project.py`, run by a person deciding for the project; nothing
+# converts on this file's account, and nothing reads `revisit_on:` but people.
+# ===========================================================================
+
+# The person who decided, for this project, that it stays single.
+decided_by: "{{DECIDED_BY}}"
+
+# The day they decided it, as YYYY-MM-DD.
+decided_on: "{{DECIDED_ON}}"
+
+# Why, in a sentence: a configuration repository, an aggregation, a vendored
+# fork, a project too small to split.
+reason: "{{REASON}}"
+
+# OPTIONAL: a day the decision is worth looking at again, as YYYY-MM-DD.
+revisit_on: "{{REVISIT_ON}}"
diff --git a/tests/test_repo_hygiene.py b/tests/test_repo_hygiene.py
index 2f6e904c..3370c49c 100644
--- a/tests/test_repo_hygiene.py
+++ b/tests/test_repo_hygiene.py
@@ -29,6 +29,7 @@
REPO / "scripts" / "repo_shape.py",
REPO / "scripts" / "shape_materialize.py",
REPO / "scripts" / "path_classify.py",
+ REPO / "scripts" / "shape_advisory.py",
REPO / "scripts" / "validate-repository-naming.py",
REPO / "scripts" / "family.py",
REPO / "scripts" / "bump-leg.py",
@@ -53,8 +54,15 @@
#: fork that cannot install anything still has to be able to regenerate the
#: reference after it changes a tool. The rule costs it nothing and the
#: category it would otherwise sit in is "nothing checks this file".
+#:
+#: `scripts/shape_advisory.py` joins them on 2026-10-06 (#163): the Triad
+#: advisory, which `scaffold-project.py`, `adopt-project.py` and
+#: `shape-doctor.py` all import so that the three cannot disagree about where
+#: it is said. It is a local module in the same sense, and it reaches no
+#: scaffolded project either — it is in no copy list, deliberately, because
+#: `repo_shape.py` is a pinned copy and no project's own copy would call it.
LOCAL_MODULES = {"repo_shape", "shape_materialize", "path_classify",
- "conftest", "bootstrap"}
+ "shape_advisory", "conftest", "bootstrap"}
#: The bash scripts a person runs BEFORE they have a checkout, or with no
#: checkout in sight at all: the front door itself and the command that
@@ -530,9 +538,30 @@ def test_agents_md_is_short_enough_to_be_read():
stale again, because a sha corrected outside the re-pin that moved the
pin is a sha nothing keeps. `apply` now carries it, so the point an
assistant needs is the one telling them not to reach for the file
- themselves, and which readings the tool leaves alone."""
+ themselves, and which readings the tool leaves alone.
+
+ 2026-10-06: 446 -> 464 — "What you DO tell them instead" (#163),
+ openxFactory's `prefer-triad-project-shape`, ratified by Brett Heap the
+ same day ("ratify 1249 as recommended"). Eighteen lines, all of them
+ INSIDE "What you must not tell them", whose existing two paragraphs stay
+ word for word: task 5.2 says that section "must still forbid saying the
+ shape confers anything and must gain what to tell them", so the gain
+ lives there rather than in a sibling a reader could meet without the
+ prohibition. They buy what that section could not say before: until now
+ this file told an assistant only what NOT to say about the shape, and
+ the ruling gave it something it must say -- once per session, in a line
+ or two -- that the Triad is the preferred shape, that a project choosing
+ a single repository is reviewed identically, and that the shape still
+ confers nothing. The rest are the limits an assistant would otherwise get
+ wrong in one of two expensive directions: where it stays silent (an
+ assembly root, a holder, a `-wip`, a recorded single repository,
+ and a leg clone, which is sent to its root), that it guesses no other
+ class, that a subagent says nothing, and that it never converts, writes
+ the record, blocks, or puts the shape in a review or a gate -- the
+ warning-in-CI the ratified text calls an amendment of the doctrine
+ rather than a realization of it."""
lines = (REPO / "AGENTS.md").read_text().splitlines()
- assert len(lines) <= 446, f"AGENTS.md is {len(lines)} lines; the cap is 446"
+ assert len(lines) <= 464, f"AGENTS.md is {len(lines)} lines; the cap is 464"
def test_claude_md_points_at_agents_md():
@@ -1062,8 +1091,28 @@ def test_readme_is_short_enough_to_be_read():
# beside `assembly-root`, `code-root`, `family-root` and `spec-root`,
# the same row shape as the other four, so this page keeps listing
# everything this repository ships rather than four out of five.
- assert len(lines) <= 1376, (
- f"README.md is {len(lines)} lines; the cap is 1376")
+ # 2026-10-06: 1376 -> 1416 — the Triad is the PREFERRED shape (#163),
+ # openxFactory's `prefer-triad-project-shape`, ratified by Brett Heap
+ # the same day: "ratify 1249 as recommended". Ten lines extend the
+ # `> [!IMPORTANT]` box the way the ratified rule demands — wherever the
+ # preference is stated the posture is stated with it — so the box that
+ # says "elective and confers nothing" also says "preferred, and still
+ # confers nothing", with the ruling quoted in this file's own style.
+ # Fourteen say where the advisory is given and where it is SILENT, and
+ # that it is never a review input: the three tools, once, never a
+ # block; the five silences; no class guessed. A reader who meets the
+ # preference without its limits reads it as a gate, which is the one
+ # reading the ruling forbids. Ten are the OPTIONAL
+ # `single-repository.yaml` — its fields, its schema and template, that
+ # nothing writes one for anybody, and that `kind:` decides — because a
+ # record nobody can find the shape of is a record nobody writes, and
+ # then every decided single repository meets the advisory for good.
+ # Three name the doctor's new `advisory` status where the row list and
+ # the status list are, since a status a page does not list is a status
+ # a reader takes for a finding. Three are Layout rows for the schema,
+ # the advisory module and the template.
+ assert len(lines) <= 1416, (
+ f"README.md is {len(lines)} lines; the cap is 1416")
@pytest.mark.parametrize("name", SHIPPED_BASH)
diff --git a/tests/test_shape_advisory.py b/tests/test_shape_advisory.py
new file mode 100644
index 00000000..211df69e
--- /dev/null
+++ b/tests/test_shape_advisory.py
@@ -0,0 +1,722 @@
+# SPDX-License-Identifier: Apache-2.0
+"""The Triad advisory: who hears it, where it is silent, and what it never does.
+
+THE RULING. Brett Heap, 2026-10-06: "ratify 1249 as recommended", ratifying
+openxFactory's `prefer-triad-project-shape` (#163 here). The Triad -- an
+assembly root with a spec leg and a code leg -- is the PREFERRED project
+shape; it stays elective and confers nothing. A person starting work in a
+repository that has not elected it is told so once, by `scaffold-project.py`,
+`adopt-project.py plan`/`check` or `shape-doctor.py`, and is never stopped,
+converted or recorded on its account.
+
+WHAT THESE TESTS HOLD, in the order the ratified rule says it:
+
+ * a single repository hears it, and every exit code is the one it would
+ have had without it -- the doctor's NOT A SHAPE ROOT stays 2, and
+ `adopt-project.py plan` exits 0;
+ * it is SILENT in an elected Triad, a family holder, a `-wip`
+ workspace (by its name, by `origin`'s name, by the person's own
+ `workspace.yaml`) and a repository carrying a `single-repository.yaml`
+ of the right kind; a leg clone is sent to its assembly root instead;
+ * a `single-repository.yaml` that does not parse, or declares another
+ kind, silences nothing and is REPORTED beside the advisory, failing
+ nothing;
+ * it writes nothing, records nothing, and is read by the three tools and
+ by no validator, template script or workflow.
+
+NO NETWORK. Every repository here is `git init` in a temporary directory, and
+`AGENT_PROTOCOL_ROOT` is pointed at an empty one so the person running the
+suite cannot change an answer with their own `workspace.yaml`.
+"""
+
+from __future__ import annotations
+
+import ast
+import json
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+from conftest import ADOPT, REPO, SCAFFOLD, git, make_source_repo, run_script
+
+sys.path.insert(0, str(REPO / "scripts"))
+import shape_advisory # noqa: E402
+from repo_shape import load_yaml # noqa: E402
+from shape_materialize import render # noqa: E402
+
+DOCTOR = REPO / "shape-doctor.py"
+ROW = "triad-advisory"
+
+#: The meaning the advisory must carry wherever it is said: the preference,
+#: the posture beside it, and the two exits it names. Asserted as fragments so
+#: a reworded sentence that keeps its meaning is not a red test, and one that
+#: drops any of them is.
+PREFERENCE = "is the preferred project shape"
+POSTURE = "it confers nothing"
+IDENTICAL = "reviewed identically"
+CONVERT = "adopt-project.py"
+RECORD = "single-repository.yaml"
+
+#: A source repository with no ambiguous corner, so `adopt-project.py check`
+#: passes on the plan `plan` writes and its exit code is about nothing but
+#: the plan.
+PLAIN_TREE = {
+ "README.md": "# Thing\n",
+ "specs/001-feature/spec.md": "# The feature\n",
+ "src/app/main.py": "VALUE = 1\n",
+}
+PLAIN_EDITS = (("src/app/main.py", "VALUE = 2\n", "Change the value"),)
+
+
+@pytest.fixture
+def quiet_env(tmp_path) -> dict:
+ """An `AGENT_PROTOCOL_ROOT` with no `workspace.yaml` in it."""
+ root = tmp_path / "agent-protocol"
+ root.mkdir()
+ return {"AGENT_PROTOCOL_ROOT": str(root)}
+
+
+def repository(path: Path, files: dict | None = None,
+ origin: str | None = None) -> Path:
+ """A one-commit repository at `path`, optionally with an `origin`."""
+ path.mkdir(parents=True)
+ git("init", "-q", "-b", "main", ".", cwd=path)
+ for name, body in (files or {"README.md": "# here\n"}).items():
+ target = path / name
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_text(body, encoding="utf-8")
+ git("add", "-A", "--", ".", cwd=path)
+ git("-c", "user.name=t", "-c", "user.email=t@t.invalid", "commit", "-q",
+ "-m", "Initial import", cwd=path)
+ if origin:
+ git("remote", "add", "origin", origin, cwd=path)
+ return path
+
+
+def doctor_json(root: Path, env: dict) -> tuple[int, dict, dict]:
+ """`(exit, {row id: row}, payload)` for one `--json` doctor run."""
+ result = run_script(DOCTOR, "--root", str(root), "--json", env=env)
+ payload = json.loads(result.stdout)
+ return (result.returncode, {row["id"]: row for row in payload["rows"]},
+ payload)
+
+
+def record_text(**fields) -> str:
+ """A `single-repository.yaml` body, the schema's own example by default."""
+ example = dict(shape_advisory.record_schema()["example"])
+ example.update(fields)
+ lines = []
+ for key, value in example.items():
+ if value is None:
+ continue
+ lines.append(f"{key}: {json.dumps(value) if isinstance(value, str) else value}")
+ return "\n".join(lines) + "\n"
+
+
+def rendered_leg_agents(role: str) -> str:
+ """A leg's `AGENTS.md`, rendered from its template as the scaffold does."""
+ template = (REPO / "templates" / f"{role}-root" / "AGENTS.md").read_text(
+ encoding="utf-8")
+ return render(template, {
+ "PROJECT_NAME": "Atlas", "PROJECT_ID": "atlas",
+ "ASSEMBLY_REPOSITORY": "Northwind/Atlas",
+ "SPEC_REPOSITORY": "Northwind/Atlas-spec",
+ "CODE_REPOSITORY": "Northwind/Atlas-code",
+ "SPEC_PATH": "spec", "CODE_PATH": "code"}, "AGENTS.md")
+
+
+def no_traceback(result) -> bool:
+ return "Traceback" not in result.stdout + result.stderr
+
+
+def commit_all(path: Path, message: str) -> None:
+ """Commit everything in `path`'s working tree, whatever is there."""
+ git("add", "-A", "--", ".", cwd=path)
+ git("-c", "user.name=t", "-c", "user.email=t@t.invalid", "commit", "-q",
+ "-m", message, cwd=path)
+
+
+def adopt_plan(source: Path, out: Path, env: dict) -> subprocess.CompletedProcess:
+ return run_script(ADOPT, "plan", "--source", str(source), "--project",
+ "Thing", "--org", "testorg", "--elected-by",
+ "Test Human", "--elected-on", "2026-10-06", "--out",
+ str(out), env=env)
+
+
+# --- a single repository hears it, and nothing else changes -----------------
+
+def test_a_single_repository_hears_it_once_and_its_exit_is_unchanged(
+ tmp_path, quiet_env):
+ """The doctor's row is `advisory`, and NOT A SHAPE ROOT is still exit 2.
+
+ The exit is compared with the SAME repository once it has recorded
+ staying single and the advisory is silent: two runs that differ only in
+ whether the advisory was given must exit alike, or the advisory changed
+ an exit status, which the ratified rule forbids.
+ """
+ here = repository(tmp_path / "Thing")
+ code, rows, payload = doctor_json(here, quiet_env)
+ assert code == 2 and payload["verdict"] == "NOT A SHAPE ROOT", payload
+ row = rows[ROW]
+ assert row["status"] == "advisory", row
+ assert row["label"] == "Triad", row
+ assert row["next"] is None, (
+ "the advisory fixes nothing, so it names no next command")
+ for fragment in ("This is a single repository.", PREFERENCE, POSTURE,
+ IDENTICAL, CONVERT, RECORD,
+ "Nothing here changes; work continues."):
+ assert fragment in row["reason"], (fragment, row["reason"])
+ assert row["detail"]["state"] == shape_advisory.ADVISE
+
+ # THE PRINTED REPORT, which is where a row can still do damage after the
+ # verdict is decided: `report()` prints `REFUSED shape-doctor-cannot-
+ # answer` for ANY row flagged as this checkout being unable to answer,
+ # whatever the kind of root (#164 review: a mutation setting that flag on
+ # this row survived every test until this assertion).
+ text = run_script(DOCTOR, "--root", str(here), env=quiet_env)
+ assert text.returncode == 2, text.stdout + text.stderr
+ assert "REFUSED" not in text.stderr and "Traceback" not in text.stderr, \
+ text.stderr
+ assert "CANNOT ANSWER" not in text.stdout, text.stdout
+ assert " advisory Triad" in text.stdout, text.stdout
+
+ (here / RECORD).write_text(record_text(), encoding="utf-8")
+ silent_code, silent_rows, _ = doctor_json(here, quiet_env)
+ assert ROW not in silent_rows, silent_rows.get(ROW)
+ assert silent_code == code, (
+ "the advisory changed the doctor's exit code: "
+ f"{code} with it, {silent_code} without it")
+
+
+def test_adopt_plan_says_it_once_exits_zero_and_writes_none_of_it(tmp_path,
+ quiet_env):
+ """`adopt-project.py plan` and `check` over a single repository.
+
+ The advisory is PRINTED once by each, and is never written: not into the
+ plan file, which is an artifact somebody edits and `execute` acts on, and
+ not into the source, which `plan` never writes at all.
+ """
+ source = make_source_repo(tmp_path / "Thing", tree=PLAIN_TREE,
+ edits=PLAIN_EDITS)
+ before = git("status", "--porcelain", "--ignored", cwd=source).stdout
+ out = tmp_path / "adoption-plan.yaml"
+ result = adopt_plan(source, out, quiet_env)
+ assert result.returncode == 0, result.stdout + result.stderr
+ assert result.stdout.count("This is a single repository.") == 1, \
+ result.stdout
+ for fragment in (PREFERENCE, POSTURE, IDENTICAL, RECORD,
+ "Nothing converts until `execute`"):
+ assert fragment in result.stdout, (fragment, result.stdout)
+ assert "Triad" not in out.read_text(encoding="utf-8"), (
+ "the advisory is a sentence said to a person, not a fact recorded "
+ "in the plan")
+ assert git("status", "--porcelain", "--ignored", cwd=source).stdout \
+ == before
+
+ check = run_script(ADOPT, "check", "--plan", str(out), env=quiet_env)
+ assert check.returncode == 0, check.stdout + check.stderr
+ assert check.stdout.count("This is a single repository.") == 1, \
+ check.stdout
+ assert check.stdout.index("This is a single repository.") \
+ < check.stdout.index("WHAT WILL HAPPEN"), (
+ "the advisory is said where work starts, before the plan's own "
+ "report")
+
+
+def test_the_scaffold_offers_the_triad_first(tmp_path):
+ """The plan a person reads before typing yes names the Triad first.
+
+ `scaffold-project.py` creates nothing but a Triad, so the offer is the
+ tool itself: one `triad` line, above the shape revision, with the posture
+ on the line after it -- in a dry run, which creates nothing and exits 0.
+ """
+ result = run_script(SCAFFOLD, "--org", "testorg", "--project", "Atlas",
+ "--elected-by", "Test Human", "--dry-run",
+ "--local-remote-dir", str(tmp_path / "remotes"))
+ assert result.returncode == 0, result.stdout + result.stderr
+ lines = result.stdout.splitlines()
+ triad = [i for i, line in enumerate(lines) if line.startswith("triad ")]
+ assert len(triad) == 1, result.stdout
+ shape = next(i for i, line in enumerate(lines)
+ if line.startswith("shape "))
+ assert triad[0] < shape, result.stdout
+ said = " ".join(lines[triad[0]:triad[0] + 2])
+ for fragment in ("the Triad", "preferred project shape", POSTURE,
+ IDENTICAL, "owes no declaration"):
+ assert fragment in said, (fragment, said)
+ assert not (tmp_path / "remotes").exists(), "a dry run created a remote"
+
+
+# --- where it is silent -----------------------------------------------------
+
+def test_an_elected_triad_is_silent(project, quiet_env):
+ """A scaffolded assembly root: the detector, and no row at all."""
+ assert shape_advisory.read(project).state == shape_advisory.TRIAD_ROOT
+ _code, rows, payload = doctor_json(project, quiet_env)
+ assert payload["kind"] == "project"
+ assert ROW not in rows, rows.get(ROW)
+
+
+def test_a_project_manifest_with_fewer_than_two_legs_is_not_a_triad(tmp_path):
+ """The detector is the ratified one -- schema AND a spec and a code leg."""
+ here = repository(tmp_path / "Half", {
+ "project.yaml": "schema_version: 1\nkind: project-manifest\n"
+ "schema: project-repo-schema\nlegs:\n"
+ " - role: assembly\n path: \".\"\n"
+ " - role: spec\n path: spec\n"})
+ assert not shape_advisory.is_triad(load_yaml(here / "project.yaml"))
+ assert shape_advisory.read(here).advise
+
+
+def test_a_family_holder_is_silent(tmp_path, quiet_env):
+ here = repository(tmp_path / "InkRouter", {
+ "family.yaml": "schema_version: 1\nkind: family-manifest\n"
+ "id: inkrouter\nmembers: []\n"})
+ assert shape_advisory.read(here).state == shape_advisory.FAMILY_HOLDER
+ _code, rows, payload = doctor_json(here, quiet_env)
+ assert payload["kind"] == "family"
+ assert ROW not in rows, rows.get(ROW)
+
+
+@pytest.mark.parametrize("how", ["directory", "origin", "workspace.yaml",
+ "orgs map"])
+def test_a_workspace_repository_is_silent(tmp_path, quiet_env, how):
+ """`-wip`, by each declared fact that can name one."""
+ config = Path(quiet_env["AGENT_PROTOCOL_ROOT"]) / "workspace.yaml"
+ if how == "directory":
+ here = repository(tmp_path / "brett-wip")
+ elif how == "origin":
+ here = repository(tmp_path / "checkout-elsewhere",
+ origin="git@github.com:opensoft/scott-wip.git")
+ elif how == "workspace.yaml":
+ here = repository(tmp_path / "my-notes")
+ config.write_text("repository: opensoft/brett-wip\n"
+ f"path: {json.dumps(str(here))}\n",
+ encoding="utf-8")
+ else:
+ here = repository(tmp_path / "medx-notes")
+ config.write_text("repository: opensoft/brett-wip\n"
+ "path: /nowhere/brett-wip\norgs:\n MedxSoft:\n"
+ " repository: MedxSoft/brett-wip\n"
+ f" path: {json.dumps(str(here))}\n",
+ encoding="utf-8")
+ reading = shape_advisory.read(here, env=quiet_env)
+ assert reading.state == shape_advisory.WORKSPACE, reading.as_dict()
+ _code, rows, _payload = doctor_json(here, quiet_env)
+ assert ROW not in rows, rows.get(ROW)
+
+
+def test_a_valid_record_is_silent_and_named_among_what_is_here(tmp_path,
+ quiet_env):
+ """The schema's own example, written at the root: no row, no lines."""
+ here = repository(tmp_path / "dotfiles", {RECORD: record_text()})
+ reading = shape_advisory.read(here)
+ assert reading.state == shape_advisory.RECORDED_SINGLE
+ assert reading.field_problems == ()
+ _code, rows, _payload = doctor_json(here, quiet_env)
+ assert ROW not in rows, rows.get(ROW)
+ assert f"a {RECORD}" in rows["contents"]["reason"], rows["contents"]
+ result = adopt_plan(here, tmp_path / "plan.yaml", quiet_env)
+ assert result.returncode == 0, result.stdout + result.stderr
+ assert "This is a single repository." not in result.stdout
+
+
+def test_a_record_missing_a_field_stays_silent_and_the_doctor_says_which(
+ tmp_path, quiet_env):
+ """`kind:` decides; a missing `reason` is a `note`, never the advisory."""
+ here = repository(tmp_path / "dotfiles",
+ {RECORD: record_text(reason=None)})
+ code, rows, _payload = doctor_json(here, quiet_env)
+ assert code == 2
+ row = rows[ROW]
+ assert row["status"] == "note", row
+ assert "`reason` is missing" in row["reason"], row["reason"]
+ assert "This is a single repository." not in row["reason"]
+
+
+@pytest.mark.parametrize("body, why", [
+ ("schema_version: 1\nkind: project-manifest\n",
+ "declares kind 'project-manifest'"),
+ ("schema_version: 1\nkind: [single-repository-record\n",
+ "could not be read"),
+ ("- schema_version\n- kind\n", "is not a YAML mapping"),
+ (None, "is not a file"),
+], ids=["wrong-kind", "unparsable", "not-a-mapping", "a-directory"])
+def test_a_record_that_silences_nothing_is_reported_beside_the_advisory(
+ tmp_path, quiet_env, body, why):
+ """Present, and not the record: the advisory, the reason, and no failure.
+
+ Every exit is the one a repository with no such file gets, which is what
+ "reported without failing anything" means.
+ """
+ here = repository(tmp_path / "Thing")
+ plain_code, _rows, _payload = doctor_json(here, quiet_env)
+ if body is None:
+ (here / RECORD).mkdir()
+ (here / RECORD / "x").write_text("x\n", encoding="utf-8")
+ else:
+ (here / RECORD).write_text(body, encoding="utf-8")
+
+ code, rows, _payload = doctor_json(here, quiet_env)
+ assert code == plain_code == 2
+ row = rows[ROW]
+ assert row["status"] == "advisory", row
+ assert "This is a single repository." in row["reason"]
+ assert f"{RECORD} does not silence this" in row["reason"], row["reason"]
+ assert why in row["reason"], row["reason"]
+
+ # COMMITTED before `adopt-project.py` is asked, because it reads the
+ # commit it plans and not the working tree (see the planned-commit test).
+ commit_all(here, "Add a single-repository.yaml")
+ result = adopt_plan(here, tmp_path / "plan.yaml", quiet_env)
+ assert result.returncode == 0, result.stdout + result.stderr
+ assert "This is a single repository." in result.stdout
+ assert f"NOTE {RECORD} does not silence this" in result.stdout, \
+ result.stdout
+
+
+@pytest.mark.parametrize("name", ["Atlas-spec", "billing-code"])
+def test_a_leg_known_by_name_alone_is_told_so_with_a_hedge(tmp_path,
+ quiet_env, name):
+ """By its name alone: silenced, sent to a root, and not over-claimed.
+
+ `billing-code` is a leg under the naming policy, which is a declared fact
+ and silences the advisory; whether a `billing` mounts it is not something
+ a name can say, so the sentence says "named as" and "if it is one" rather
+ than calling an unrelated repository a clone of somebody's leg (#164
+ review). And `the way in` offers no adoption: a leg split into two more
+ legs is the one adoption nobody wants, and offering it two rows above
+ "work from its assembly root" would be the report contradicting itself.
+ """
+ project, role = name.rsplit("-", 1)
+ here = repository(tmp_path / name)
+ reading = shape_advisory.read(here)
+ assert reading.state == shape_advisory.LEG_CLONE
+ assert reading.leg["by"] == "name"
+ _code, rows, _payload = doctor_json(here, quiet_env)
+ row = rows[ROW]
+ assert row["status"] == "n/a", row
+ assert "This is a single repository." not in row["reason"]
+ assert f"`{name}` is named as the {role} leg of `{project}`" in \
+ row["reason"], row["reason"]
+ assert f"if it is one, work from its assembly root `{project}`" in \
+ row["reason"], row["reason"]
+ assert "this is a clone of" not in row["reason"], row["reason"]
+ way_in = rows["way-in"]
+ assert way_in["next"] is None, way_in
+ assert "adopt-project.py" not in (way_in["next"] or "")
+ assert "AS A LEG" in way_in["reason"], way_in["reason"]
+ result = adopt_plan(here, tmp_path / "plan.yaml", quiet_env)
+ assert "This is a single repository." not in result.stdout
+ assert f"NOTE `{name}` is named as the {role} leg" in result.stdout, \
+ result.stdout
+
+
+def test_a_leg_that_says_so_is_sent_to_the_root_it_names(tmp_path, quiet_env):
+ """By its own `AGENTS.md`, the primary route: plain, and naming the root."""
+ here = repository(tmp_path / "renamed-clone",
+ {"AGENTS.md": rendered_leg_agents("spec")})
+ _code, rows, _payload = doctor_json(here, quiet_env)
+ row = rows[ROW]
+ assert row["status"] == "n/a", row
+ assert "this is a clone of the spec leg of a Triad" in row["reason"]
+ assert "work from its assembly root `Northwind/Atlas`" in row["reason"]
+ assert rows["way-in"]["next"] is None, rows["way-in"]
+
+
+@pytest.mark.parametrize("role", ["spec", "code"])
+def test_the_leg_templates_are_what_the_advisory_reads(tmp_path, role):
+ """A leg cloned under ANY name says what it is in its own `AGENTS.md`.
+
+ Rendered from `templates/-root/AGENTS.md` exactly as the scaffold
+ renders it, so the two sentences `scripts/shape_advisory.py` reads are
+ held to the templates: reword a template without the module and this is
+ red, rather than a leg that quietly starts being advised.
+ """
+ here = repository(tmp_path / "renamed-clone",
+ {"AGENTS.md": rendered_leg_agents(role)})
+ reading = shape_advisory.read(here)
+ assert reading.state == shape_advisory.LEG_CLONE, reading.as_dict()
+ assert reading.leg["role"] == role
+ assert reading.leg["by"] == "agents"
+ assert reading.leg["assembly"] == "Northwind/Atlas"
+
+
+def test_a_directory_that_is_not_a_repository_is_not_advised(tmp_path,
+ quiet_env):
+ """"This is a single repository" would be false: there is none."""
+ here = tmp_path / "Loose"
+ here.mkdir()
+ (here / "notes.txt").write_text("nothing\n", encoding="utf-8")
+ assert shape_advisory.read(here).state == shape_advisory.NOT_A_REPOSITORY
+ _code, rows, _payload = doctor_json(here, quiet_env)
+ assert ROW not in rows, rows.get(ROW)
+
+
+@pytest.mark.parametrize("name", ["xFactory", "dotfiles", "vendored-fork"])
+def test_no_class_is_guessed(tmp_path, quiet_env, name):
+ """An aggregation, a configuration repository and a vendored fork hear it.
+
+ None of them carries a declared fact that says what it is, so none is
+ exempted by its name or its layout (the ratified ruling on OQ-2).
+ """
+ here = repository(tmp_path / name, {"README.md": "# aggregation\n",
+ ".gitmodules": ""})
+ assert shape_advisory.read(here).advise
+ _code, rows, _payload = doctor_json(here, quiet_env)
+ assert rows[ROW]["status"] == "advisory"
+
+
+def test_a_broken_assembly_root_is_not_called_a_single_repository(tmp_path,
+ quiet_env):
+ """A `project.yaml` short of a Triad gets its `manifest` finding, no row.
+
+ Not a Triad by the detector, and not "a single repository" either: it is
+ a project root whose manifest is wrong, which the doctor's own rows
+ explain. So the `Triad` row is not asked there (#164 review), and the
+ verdict and exit are the same whether or not a valid record would have
+ silenced it -- the advisory has nothing to do with a project root's
+ verdict, in either direction.
+ """
+ here = repository(tmp_path / "Half", {
+ "project.yaml": "schema_version: 1\nkind: project-manifest\n"
+ "schema: project-repo-schema\nlegs:\n"
+ " - role: assembly\n path: \".\"\n"
+ " - role: spec\n path: spec\n"})
+ code, rows, payload = doctor_json(here, quiet_env)
+ assert payload["kind"] == "project", payload
+ assert ROW not in rows, rows.get(ROW)
+ (here / RECORD).write_text(record_text(), encoding="utf-8")
+ recorded_code, recorded_rows, recorded = doctor_json(here, quiet_env)
+ assert ROW not in recorded_rows, recorded_rows.get(ROW)
+ assert (recorded_code, recorded["verdict"]) == (code, payload["verdict"])
+
+
+# --- it cannot fail the run that says it ------------------------------------
+
+#: Three `single-repository.yaml` bodies a person, an editor or a script can
+#: produce, each of which raised out of the advisory before #164's review
+#: round, and what the doctor now says about each instead.
+UNREADABLE = {
+ "undecodable": (b"schema_version: 1\nkind: \xff\xfe record\n",
+ "it could not be read (UnicodeDecodeError)"),
+ "nested-past-the-recursion-limit": (
+ ("kind: " + "[" * 600 + "]" * 600 + "\n").encode("ascii"),
+ "it could not be read (RecursionError)"),
+ "non-ascii-kind-on-an-ascii-console": (
+ 'schema_version: 1\nkind: "café—record"\n'.encode("utf-8"),
+ "it declares kind 'caf"),
+}
+
+
+@pytest.mark.parametrize("case", sorted(UNREADABLE))
+def test_a_record_nobody_can_read_fails_nothing(tmp_path, quiet_env, case):
+ """Reported beside the advisory; every exit the same; no traceback.
+
+ Run under `PYTHONIOENCODING=ascii`, the console a Windows code page or a
+ plain pipe gives: a `kind:` the console cannot encode is a person's text
+ reaching `print`, and it must cost one `?`, not the run.
+ """
+ body, said = UNREADABLE[case]
+ env = {**quiet_env, "PYTHONIOENCODING": "ascii"}
+ here = repository(tmp_path / "Thing")
+ (here / RECORD).write_bytes(body)
+ commit_all(here, "Add a single-repository.yaml")
+
+ code, rows, _payload = doctor_json(here, env)
+ assert code == 2
+ row = rows[ROW]
+ assert row["status"] == "advisory", row
+ assert f"{RECORD} does not silence this: {said}" in row["reason"], \
+ row["reason"]
+ text = run_script(DOCTOR, "--root", str(here), env=env)
+ assert text.returncode == 2 and no_traceback(text), \
+ text.stdout + text.stderr
+ assert "advisory Triad" in text.stdout, text.stdout
+
+ result = adopt_plan(here, tmp_path / "plan.yaml", env)
+ assert result.returncode == 0 and no_traceback(result), \
+ result.stdout + result.stderr
+ assert "This is a single repository." in result.stdout, result.stdout
+ assert f"NOTE {RECORD} does not silence this: {said}" in result.stdout, \
+ result.stdout
+
+
+def test_a_workspace_path_no_machine_can_hold_fails_nothing(tmp_path,
+ quiet_env):
+ """A NUL byte in the person's `workspace.yaml` names nothing, and only it.
+
+ `Path.resolve` raises `ValueError` on an embedded NUL. The person's own
+ configuration is the one input here that is not this repository's, so it
+ costs exactly its own entry: the `orgs:` entry beside it still names its
+ checkout, and every other repository still hears the advisory with the
+ exit it always had.
+ """
+ config = Path(quiet_env["AGENT_PROTOCOL_ROOT"]) / "workspace.yaml"
+ mine = repository(tmp_path / "medx-notes")
+ other = repository(tmp_path / "Thing")
+ config.write_bytes(
+ b"repository: opensoft/brett-wip\npath: /nowhere/\x00/brett-wip\n"
+ b"orgs:\n MedxSoft:\n repository: MedxSoft/brett-wip\n"
+ b" path: " + json.dumps(str(mine)).encode("utf-8") + b"\n")
+ assert shape_advisory.read(mine, env=quiet_env).state \
+ == shape_advisory.WORKSPACE
+ for here, row_expected in ((mine, False), (other, True)):
+ result = run_script(DOCTOR, "--root", str(here), env=quiet_env)
+ assert result.returncode == 2 and no_traceback(result), \
+ result.stdout + result.stderr
+ assert ("advisory Triad" in result.stdout) is row_expected, \
+ result.stdout
+ result = adopt_plan(other, tmp_path / "plan.yaml", quiet_env)
+ assert result.returncode == 0 and no_traceback(result), \
+ result.stdout + result.stderr
+ assert "This is a single repository." in result.stdout
+
+
+def test_a_byte_order_mark_is_the_editors_and_not_the_persons(tmp_path,
+ quiet_env):
+ """A UTF-8 BOM ahead of the first key changes no answer.
+
+ Left in, it turns `schema_version` into a key nobody declared -- a valid
+ record that names a field problem, and a leg whose `AGENTS.md` no longer
+ opens with the leg's sentence, so it is advised to become what it is.
+ """
+ bom = ""
+ here = repository(tmp_path / "dotfiles", {RECORD: bom + record_text()})
+ reading = shape_advisory.read(here, env=quiet_env)
+ assert reading.state == shape_advisory.RECORDED_SINGLE, reading.as_dict()
+ assert reading.field_problems == (), reading.field_problems
+ _code, rows, _payload = doctor_json(here, quiet_env)
+ assert ROW not in rows, rows.get(ROW)
+ result = adopt_plan(here, tmp_path / "plan.yaml", quiet_env)
+ assert "This is a single repository." not in result.stdout
+ assert "does not silence this" not in result.stdout, result.stdout
+
+ leg = repository(tmp_path / "renamed-clone",
+ {"AGENTS.md": bom + rendered_leg_agents("code")})
+ reading = shape_advisory.read(leg, env=quiet_env)
+ assert reading.state == shape_advisory.LEG_CLONE, reading.as_dict()
+ assert reading.leg["by"] == "agents"
+
+
+def test_adopt_reads_the_commit_it_plans_and_the_doctor_reads_the_disk(
+ tmp_path, quiet_env):
+ """An uncommitted record is not in the repository being split.
+
+ `adopt-project.py plan` writes its plan against one commit of the
+ default branch, so a `single-repository.yaml` that exists only in the
+ working tree says nothing about that commit, and the advisory is still
+ said. The doctor reports the tree in front of the person, so it is
+ silent about the same checkout -- and both stop once it is committed.
+ """
+ here = repository(tmp_path / "Thing")
+ (here / RECORD).write_text(record_text(), encoding="utf-8")
+ _code, rows, _payload = doctor_json(here, quiet_env)
+ assert ROW not in rows, rows.get(ROW)
+ result = adopt_plan(here, tmp_path / "plan.yaml", quiet_env)
+ assert result.returncode == 0, result.stdout + result.stderr
+ assert result.stdout.count("This is a single repository.") == 1, \
+ result.stdout
+
+ commit_all(here, "Record staying single")
+ result = adopt_plan(here, tmp_path / "plan-2.yaml", quiet_env)
+ assert result.returncode == 0, result.stdout + result.stderr
+ assert "This is a single repository." not in result.stdout, result.stdout
+
+
+# --- the record, its schema and its template agree --------------------------
+
+def test_the_schema_the_template_and_the_module_agree():
+ schema = shape_advisory.record_schema()
+ assert schema["schema_version"] == 1
+ assert schema["kind"] == shape_advisory.SCHEMA_KIND
+ assert schema["record_kind"] == shape_advisory.RECORD_KIND
+ assert schema["file"] == shape_advisory.RECORD_FILE
+ assert schema["template"] == shape_advisory.RECORD_TEMPLATE
+ names = [field["name"] for field in schema["fields"]]
+ assert names == ["schema_version", "kind", "decided_by", "decided_on",
+ "reason", "revisit_on"]
+ required = {field["name"] for field in schema["fields"]
+ if field["required"]}
+ assert required == set(names) - {"revisit_on"}, (
+ "revisit_on is the one optional field the ruling names")
+
+ template = load_yaml(REPO / shape_advisory.RECORD_TEMPLATE)
+ assert template["schema_version"] == 1
+ assert template["kind"] == shape_advisory.RECORD_KIND
+ assert list(template) == names
+
+ assert shape_advisory.field_problems(dict(schema["example"])) == []
+ unfilled = shape_advisory.field_problems(template)
+ assert len(unfilled) == 4 and all("placeholder" in p for p in unfilled), \
+ unfilled
+ filled = render((REPO / shape_advisory.RECORD_TEMPLATE).read_text(
+ encoding="utf-8"), {"DECIDED_BY": "Dana Okafor",
+ "DECIDED_ON": "2026-10-06",
+ "REASON": "a configuration repository",
+ "REVISIT_ON": "2027-04-01"}, "template")
+ from repo_shape import parse_yaml
+ assert shape_advisory.field_problems(parse_yaml(filled)) == []
+
+
+@pytest.mark.parametrize("field, value, problem", [
+ ("decided_on", "06/10/2026", "not an ISO date"),
+ ("decided_on", "2026-02-30", "not an ISO date"),
+ ("schema_version", 2, "fixes it at 1"),
+ ("decided_by", "", "not text"),
+ ("extra", "x", "`extra` is not a field of the record"),
+])
+def test_a_field_the_schema_does_not_admit_is_named(field, value, problem):
+ record = dict(shape_advisory.record_schema()["example"])
+ record[field] = value
+ found = shape_advisory.field_problems(record)
+ assert any(problem in line for line in found), found
+
+
+# --- it is never a gate, and never written ----------------------------------
+
+def test_only_the_three_tools_read_the_advisory():
+ """No validator, template script or workflow reads it -- ever.
+
+ The ratified rule: a project's shape is never a review input. The module
+ and the record it reads are imported by `scaffold-project.py`,
+ `adopt-project.py` and `shape-doctor.py` and by nothing else this
+ repository ships; a fourth importer is somebody wiring the shape into a
+ check, which the rule calls an amendment of the doctrine.
+ """
+ shipped = sorted(set(REPO.glob("*.py")) | set(REPO.glob("scripts/*.py"))
+ | set((REPO / "templates").rglob("*.py")))
+ importers = set()
+ for path in shipped:
+ tree = ast.parse(path.read_text(encoding="utf-8"))
+ for node in ast.walk(tree):
+ names = ([alias.name for alias in node.names]
+ if isinstance(node, ast.Import) else
+ [node.module] if isinstance(node, ast.ImportFrom)
+ else [])
+ if "shape_advisory" in names:
+ importers.add(path.relative_to(REPO).as_posix())
+ assert importers == {"scaffold-project.py", "adopt-project.py",
+ "shape-doctor.py"}, importers
+ for path in [*(REPO / ".github").rglob("*"),
+ *(REPO / "templates").rglob("*.py"),
+ *(REPO / "templates").rglob("*.yml")]:
+ if path.is_file():
+ text = path.read_text(encoding="utf-8")
+ assert "shape_advisory" not in text and RECORD not in text, (
+ f"{path.relative_to(REPO)} reads the Triad advisory or its "
+ "record; the shape is never a gate or a review input")
+
+
+def test_the_doctor_and_adopt_write_nothing_into_a_single_repository(
+ tmp_path, quiet_env):
+ """No record, no manifest, no marker that the advisory was given."""
+ here = repository(tmp_path / "Thing")
+ before = git("status", "--porcelain", "--ignored", cwd=here).stdout
+ doctor_json(here, quiet_env)
+ adopt_plan(here, tmp_path / "plan.yaml", quiet_env)
+ assert git("status", "--porcelain", "--ignored", cwd=here).stdout \
+ == before
+ assert not (here / RECORD).exists()
diff --git a/tests/test_shape_doctor.py b/tests/test_shape_doctor.py
index 6ecd53c1..c4cc808c 100644
--- a/tests/test_shape_doctor.py
+++ b/tests/test_shape_doctor.py
@@ -509,7 +509,12 @@ def test_an_empty_git_repository_is_not_a_shape_root(standard, tmp_path):
assert payload["verdict"] == "NOT A SHAPE ROOT"
assert payload["note"].startswith("the machine check is")
rows = rows_of(result)
- assert set(rows) == {"naming", "contents", "way-in", "machine"}
+ # `triad-advisory` since #163: an empty repository has elected nothing
+ # and recorded nothing, so it hears the Triad advisory -- an `advisory`
+ # row that the verdict steps over, which the exit code above shows.
+ assert set(rows) == {"naming", "contents", "way-in", "triad-advisory",
+ "machine"}
+ assert rows["triad-advisory"]["status"] == "advisory"
assert "placement" not in rows, (
"there are no legs here and no manifest declaring any, so there is "
"no placement question to answer; the row is absent rather than n/a")
@@ -538,7 +543,10 @@ def test_a_root_named_like_an_option_still_gets_a_real_naming_verdict(
payload = json.loads(result.stdout)
assert payload["kind"] == "none"
rows = rows_of(result)
- assert set(rows) == {"naming", "contents", "way-in", "machine"}
+ # `triad-advisory` since #163, as for any repository that has not
+ # elected the Triad; it does not touch the naming row this test is about.
+ assert set(rows) == {"naming", "contents", "way-in", "triad-advisory",
+ "machine"}
row = rows["naming"]
# The real verdict -- unclassified -- not the false OK argparse's own
# help action used to produce.