From d8331ea6fe0d28c4ac817bacbb3b21fea6d029d7 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:12:19 +0000 Subject: [PATCH 1/3] WIP checkpoint (account swap): the Triad advisory module, the record schema and template, and the doctor's advisory row Partial realization of openxFactory prefer-triad-project-shape task 5.2 (Refs #163). Checkpointed mid-task for an account swap; NOT complete. In this commit: - scripts/shape_advisory.py: the one module that says the advisory and decides where it is silent (elected Triad, family holder, leg clone, -wip workspace, a single-repository.yaml of the right kind), from declared facts only; the only reader of single-repository.yaml. Not a shape copy, so no project's pinned repo_shape.py moves. - contracts/single-repository-record.yaml: the record's schema, as data. - templates/single-repository/single-repository.yaml: its template. - shape-doctor.py: an `advisory` status and a `triad-advisory` row (absent where silent; n/a with the move-to-root instruction for a leg clone; a note for a silent record missing a field). Never moves the verdict; NOT A SHAPE ROOT stays exit 2. Not yet done: scaffold and adopt advisory lines, README / AGENTS.md / AGENTS-shape.md / project.yaml template posture text, handbook regeneration, hygiene caps and module lists, new tests and the two updated row-set assertions. Tests are expected to be red at this commit. Lane: codeXfactory-5 Co-Authored-By: Claude Opus 5.5 --- contracts/single-repository-record.yaml | 115 ++++ scripts/shape_advisory.py | 580 ++++++++++++++++++ shape-doctor.py | 105 +++- .../single-repository/single-repository.yaml | 40 ++ 4 files changed, 839 insertions(+), 1 deletion(-) create mode 100644 contracts/single-repository-record.yaml create mode 100644 scripts/shape_advisory.py create mode 100644 templates/single-repository/single-repository.yaml 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/scripts/shape_advisory.py b/scripts/shape_advisory.py new file mode 100644 index 00000000..df2e09e9 --- /dev/null +++ b/scripts/shape_advisory.py @@ -0,0 +1,580 @@ +#!/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 NamingPolicy, Refusal, git_out, load_yaml # noqa: E402 + +#: 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" +STATES = (ADVISE, TRIAD_ROOT, FAMILY_HOLDER, LEG_CLONE, WORKSPACE, + RECORDED_SINGLE, NOT_A_REPOSITORY) + +#: 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}$") + +# --------------------------------------------------------------------------- +# 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. + """ + root = leg.get("assembly") + named = f"`{root}`" if root else "the assembly root that mounts it" + 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: work " + f"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") + + +# --------------------------------------------------------------------------- +# 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} + + +def _read_mapping(path: Path): + """`path` as a YAML mapping, or None -- never a refusal. + + 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. + """ + if not path.is_file(): + return None + try: + data = load_yaml(path) + except (Refusal, OSError, UnicodeDecodeError): + 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`.""" + 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, + "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(root: Path) -> dict | None: + """A leg that says so in its own `AGENTS.md`, as the templates write it.""" + try: + text = (root / AGENTS_MD).read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return None + 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), + "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. + """ + env = os.environ if env is None else env + base = env.get("AGENT_PROTOCOL_ROOT") or os.path.join( + os.path.expanduser("~"), ".agents") + config = _read_mapping(Path(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 (OSError, RuntimeError): + 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 (OSError, RuntimeError): + 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: Path, shape: Path = SHAPE_ROOT) -> 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, unparsable, 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. + """ + path = root / RECORD_FILE + if not path.exists(): + return RECORD_ABSENT, [], [] + if not path.is_file(): + return RECORD_SILENCES_NOTHING, ["it is not a file"], [] + try: + data = load_yaml(path) + except Refusal as exc: + return RECORD_SILENCES_NOTHING, [f"it could not be read " + f"({exc.code})"], [] + except (OSError, UnicodeDecodeError) as exc: + 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) -> 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. + """ + root = Path(root) + project = _read_mapping(root / 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(root / 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) + leg = _leg_by_name(names, policy) or _leg_by_agents_md(root) + 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(root, 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..c31e0a6d 100755 --- a/shape-doctor.py +++ b/shape-doctor.py @@ -261,6 +261,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 +291,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 +574,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, @@ -2981,6 +3006,74 @@ 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. 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 `the way in` row above already + carries the adoption command for this exact repository. + """ + 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 +3101,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 on the + # two kinds of root that can be something other than an elected Triad: a + # directory with no manifest, and a `project.yaml` that declares fewer + # than two legs. A family holder is never advised, so it is not asked. + # 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, (PROJECT, 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 +3124,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/single-repository/single-repository.yaml b/templates/single-repository/single-repository.yaml new file mode 100644 index 00000000..e5bce0cc --- /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 `{{PLACEHOLDER}}` 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}}" From ce2d84d68a0362ade6a00eb317ed29461a6f5ccb Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:16:54 +0000 Subject: [PATCH 2/3] Prefer the Triad: the advisory in scaffold, adopt and the doctor, the optional single-repository.yaml, and the posture text Realizes task 5.2 of openxFactory's ratified prefer-triad-project-shape (Refs #163): the Triad -- an assembly root with a spec leg and a code leg -- is the PREFERRED project shape, it stays elective and confers nothing, and a person starting work outside one is told so once and never stopped. - scaffold-project.py: the plan names the Triad, the only thing it creates, as the preferred shape, with the posture, above the revision. - adopt-project.py: plan and check print the advisory once in the conversion's own words; nothing of it is written into the plan. - shape-doctor.py: the module docstring and the `what is here` row name the new row and the record. - README posture box (ruled by Brett Heap on 2026-10-06, "ratify 1249 as recommended"), the advisory's scope and the optional record; AGENTS.md "What you do tell them" beside "What you must not tell them", which stays; the assembly-root AGENTS-shape.md and project.yaml posture comment; docs/handbook.html regenerated with its README blob. - tests: test_shape_advisory.py (31), the hygiene module lists and the AGENTS.md 446 -> 465 and README 1376 -> 1416 caps with dated reasons, and the two doctor row sets that now carry the advisory row. Suite: 1191 passed, 33 skipped on this tree; clean main 39d5c986 is 1159 passed, 33 skipped. The skips are environmental on both: 32 for a missing git-filter-repo, 1 for the MedxEHR-backed plan test. Lane: codeXfactory-5 Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 19 + README.md | 46 +- adopt-project.py | 36 ++ docs/handbook.html | 59 ++- scaffold-project.py | 11 + shape-doctor.py | 26 +- templates/assembly-root/AGENTS-shape.md | 6 + templates/assembly-root/project.yaml | 10 + .../single-repository/single-repository.yaml | 4 +- tests/test_repo_hygiene.py | 54 +- tests/test_shape_advisory.py | 500 ++++++++++++++++++ tests/test_shape_doctor.py | 12 +- 12 files changed, 763 insertions(+), 20 deletions(-) create mode 100644 tests/test_shape_advisory.py diff --git a/AGENTS.md b/AGENTS.md index 8d89b698..3827df6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -428,6 +428,25 @@ make bootstrap && make resume # on the other machine, after a clone `park ` and `resume ` — those verbs as INSTALLED COMMANDS — are `opensoft/openRepoTools`' now; read its AGENTS.md before you drive either. +## What you do tell them + +That the Triad — this shape: an assembly root with a spec leg and a code leg +— is the PREFERRED project shape, and that preferred is not required (Brett +Heap, 2026-10-06, openxFactory `prefer-triad-project-shape`): a project kept in +one repository is reviewed identically and owes no declaration. + +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. + ## What you must not tell them That the shape confers anything — or that membership of a family does. diff --git a/README.md b/README.md index 401cb98d..7a463d01 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,10 +1124,13 @@ 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. +naming policy, what IS in it, and the way in. A repository that has not +elected the Triad also gets the **Triad** row, status `advisory`: the +preference said once, beside the rows, and stepped over by the verdict. -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 +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 advisory, which fixes nothing. **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 @@ -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..0d148e67 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,37 @@ def _checked_plan_inputs(args, source: Source, return names, pins +def _print_triad_advisory(path: Path) -> 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. + """ + reading = shape_advisory.read(path) + if reading.state == shape_advisory.LEG_CLONE: + print(f"NOTE {shape_advisory.leg_instruction(reading.leg)}") + return + if not reading.advise: + return + first, second = shape_advisory.adopt_lines() + print(f"triad {first}") + print(f" {second}") + for line in shape_advisory.record_report(reading.record_problems): + print(f"NOTE {line}") + + 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 +652,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) _print_entries(entries) _print_summary(entries) print(f"\nfollow-ups ({len(follow_ups)}):") @@ -964,6 +997,9 @@ 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) if plan.source_commit and source.commit != plan.source_commit: findings.append( diff --git a/docs/handbook.html b/docs/handbook.html index 674d846f..d183c8a2 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. A repository that has not elected the Triad also gets + the Triad row, status advisory: the + preference said once, beside the rows, and stepped over by the + verdict.

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 advisory, which + fixes nothing. 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
@@ -1709,6 +1747,14 @@

For AI assistants

authority is not wallet-carried in this org is a report and not a fault. The new project carries AGENTS-shape.md at its assembly root, and you read that before touching the project.

+

What you do tell them: that the Triad is the + preferred project shape and that 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, 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.

What you must not tell them: that the shape confers anything — or that membership of a family does — or that a <user>-wip workspace repository does, which holds no code, elects nothing and is membership of nothing. Electing @@ -1727,9 +1773,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 7a463d01dc06cc9f2b356c94d65ec84a1d6f0dd1. 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/shape-doctor.py b/shape-doctor.py index c31e0a6d..23a1ce6a 100755 --- a/shape-doctor.py +++ b/shape-doctor.py @@ -81,6 +81,19 @@ 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 that has not elected it gets one row +with the status `advisory`, saying it once; an elected Triad, 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. 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 +135,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 @@ -2754,7 +2768,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: 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 index e5bce0cc..b4e15224 100644 --- a/templates/single-repository/single-repository.yaml +++ b/templates/single-repository/single-repository.yaml @@ -18,8 +18,8 @@ kind: single-repository-record # 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 `{{PLACEHOLDER}}` below, and commit -# it like any other change. `revisit_on:` is optional — delete its line if +# `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 diff --git a/tests/test_repo_hygiene.py b/tests/test_repo_hygiene.py index 2f6e904c..b2387d62 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,25 @@ 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 -> 465 — "What you do tell them" (#163), openxFactory's + `prefer-triad-project-shape`, ratified by Brett Heap the same day ("ratify + 1249 as recommended"). Nineteen lines, one new section beside "What you + must not tell them", which stays word for word. They buy what that + section cannot: 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 and + that preferred is not required. The rest of the lines 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) <= 465, f"AGENTS.md is {len(lines)} lines; the cap is 465" def test_claude_md_points_at_agents_md(): @@ -1062,8 +1086,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..d7483ded --- /dev/null +++ b/tests/test_shape_advisory.py @@ -0,0 +1,500 @@ +# 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 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 + + (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"] + + 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 + + +def test_a_leg_clone_is_sent_to_its_root_instead(tmp_path, quiet_env): + """By its name: `Atlas-spec` is the spec-leg form of the naming policy.""" + here = repository(tmp_path / "Atlas-spec") + reading = shape_advisory.read(here) + assert reading.state == shape_advisory.LEG_CLONE + _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 "work from its assembly root `Atlas`" in row["reason"], \ + row["reason"] + result = adopt_plan(here, tmp_path / "plan.yaml", quiet_env) + assert "This is a single repository." not in result.stdout + assert "NOTE this is a clone of the spec leg of a Triad" in result.stdout + + +@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. + """ + template = (REPO / "templates" / f"{role}-root" / "AGENTS.md").read_text( + encoding="utf-8") + text = 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") + here = repository(tmp_path / "renamed-clone", {"AGENTS.md": text}) + reading = shape_advisory.read(here) + assert reading.state == shape_advisory.LEG_CLONE, reading.as_dict() + assert reading.leg["role"] == role + 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" + + +# --- 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. From ff739588a2daf3d64b0318ce0176922c0d2a8912 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:34:13 +0000 Subject: [PATCH 3/3] Triad advisory review round: never fail the run, no adopt offer for a leg, read the planned commit The #164 review round (F1-F9): - F1: the advisory can no longer crash a run or change an exit status. shape_advisory.read() answers NOT_ANSWERED (silent) on any Exception; the doctor's Triad row is dropped rather than raised; adopt flattens its lines to ASCII and swallows the rest. KeyboardInterrupt and SystemExit still propagate. Tests: an undecodable record, a record nested past the recursion limit, a NUL byte in workspace.yaml's path, and a non-ASCII kind on an ascii console -- exit unchanged, no traceback. - F2: the single-repository doctor test now also runs the text report and asserts no REFUSED / CANNOT ANSWER, which kills the detail["environment"] mutation; a broken assembly root's verdict and exit are asserted identical with and without a valid record. - F3: `the way in` offers no adoption for a leg clone, and a leg known by its name alone is told it is "named as" a leg, "if it is one". - F4: the Triad row is asked only where there is no manifest; a project.yaml short of a Triad keeps its manifest finding instead. - F5: README and handbook say the Triad row names no command in any status. - F6: a leading UTF-8 BOM is dropped before the record's first key and before a leg AGENTS.md's opening sentence. - F7: "What you DO tell them instead" now sits inside AGENTS.md's "What you must not tell them" (Triad preferred, a single repository reviewed identically, the shape confers nothing); AGENTS.md cap 465 -> 464. - F9: adopt-project.py plan/check read the record, manifests and leg AGENTS.md from the commit being planned, not the working tree. Handbook regenerated for the README change (README.md blob 095a1b9e60743fc8f8d26bd11247572956a3f144). Refs #163 Lane: codeXfactory-5 Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 33 +++-- README.md | 10 +- adopt-project.py | 44 ++++-- docs/handbook.html | 31 ++--- scripts/shape_advisory.py | 241 +++++++++++++++++++++++++++------ shape-doctor.py | 88 +++++++++--- tests/test_repo_hygiene.py | 37 ++--- tests/test_shape_advisory.py | 252 ++++++++++++++++++++++++++++++++--- 8 files changed, 592 insertions(+), 144 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3827df6b..1199b938 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -428,12 +428,23 @@ make bootstrap && make resume # on the other machine, after a clone `park ` and `resume ` — those verbs as INSTALLED COMMANDS — are `opensoft/openRepoTools`' now; read its AGENTS.md before you drive either. -## What you do tell them +## What you must not tell them + +That the shape confers anything — or that membership of a family does. +Electing either changes no gate, no floor, no grant and no clearance +eligibility; a one-repository project in no family is reviewed identically. If you 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. + +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. -That the Triad — this shape: an assembly root with a spec leg and a code leg -— is the PREFERRED project shape, and that preferred is not required (Brett -Heap, 2026-10-06, openxFactory `prefer-triad-project-shape`): a project kept in -one repository is reviewed identically and owes no declaration. +**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: @@ -447,18 +458,6 @@ 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. -## What you must not tell them - -That the shape confers anything — or that membership of a family does. -Electing either changes no gate, no floor, no grant and no clearance -eligibility; a one-repository project in no family is reviewed identically. If you 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. - -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. - ## 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 7a463d01..095a1b9e 100644 --- a/README.md +++ b/README.md @@ -1124,14 +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. A repository that has not -elected the Triad also gets the **Triad** row, status `advisory`: the -preference said once, beside the rows, and stepped over by the verdict. +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 advisory, which fixes nothing. **Only a `FINDING` moves the -verdict**, and `FINDING` means exactly *something else asserts this* — a +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 diff --git a/adopt-project.py b/adopt-project.py index 0d148e67..df58d642 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -608,7 +608,7 @@ def _checked_plan_inputs(args, source: Source, return names, pins -def _print_triad_advisory(path: Path) -> None: +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", @@ -625,18 +625,35 @@ def _print_triad_advisory(path: Path) -> None: 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. """ - reading = shape_advisory.read(path) - if reading.state == shape_advisory.LEG_CLONE: - print(f"NOTE {shape_advisory.leg_instruction(reading.leg)}") - return - if not reading.advise: + 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 - first, second = shape_advisory.adopt_lines() - print(f"triad {first}") - print(f" {second}") - for line in shape_advisory.record_report(reading.record_problems): - print(f"NOTE {line}") def _print_plan_report(args, source: Source, tree: list, entries: list, @@ -652,7 +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) + _print_triad_advisory(source.path, source.commit) _print_entries(entries) _print_summary(entries) print(f"\nfollow-ups ({len(follow_ups)}):") @@ -999,7 +1016,8 @@ def cmd_check(args) -> int: 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) + _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/docs/handbook.html b/docs/handbook.html index d183c8a2..ce3e2616 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -1425,14 +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. A repository that has not elected the Triad also gets - the Triad row, status advisory: the - preference said once, beside the rows, and stepped over by the - verdict.

+ 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 advisory, which - fixes nothing. Only a FINDING moves the verdict, and + 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 @@ -1747,14 +1747,6 @@

For AI assistants

authority is not wallet-carried in this org is a report and not a fault. The new project carries AGENTS-shape.md at its assembly root, and you read that before touching the project.

-

What you do tell them: that the Triad is the - preferred project shape and that 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, 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.

What you must not tell them: that the shape confers anything — or that membership of a family does — or that a <user>-wip workspace repository does, which holds no code, elects nothing and is membership of nothing. Electing @@ -1763,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 @@ -1778,7 +1779,7 @@

For AI assistants

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 7a463d01dc06cc9f2b356c94d65ec84a1d6f0dd1. 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/scripts/shape_advisory.py b/scripts/shape_advisory.py index df2e09e9..2486f21e 100644 --- a/scripts/shape_advisory.py +++ b/scripts/shape_advisory.py @@ -95,7 +95,9 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) -from repo_shape import NamingPolicy, Refusal, git_out, load_yaml # noqa: E402 +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 @@ -127,8 +129,16 @@ 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) + 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. @@ -150,6 +160,30 @@ 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 # --------------------------------------------------------------------------- @@ -228,15 +262,29 @@ def leg_instruction(leg: dict) -> str: 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" - 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: work " - f"from its assembly root {named}, cloned with `git clone " + 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}") # --------------------------------------------------------------------------- @@ -276,18 +324,68 @@ def as_dict(self) -> dict: "leg": self.leg} -def _read_mapping(path: Path): - """`path` as a YAML mapping, or None -- never a refusal. +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. + 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. """ - if not path.is_file(): - return None try: - data = load_yaml(path) - except (Refusal, OSError, UnicodeDecodeError): + 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 @@ -356,31 +454,47 @@ def _naming_policy(shape: Path) -> NamingPolicy | None: def _leg_by_name(names: list[str], policy: NamingPolicy | None) -> dict | None: - """A leg named as one: `-spec` or `-code`.""" + """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, + 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(root: Path) -> dict | None: - """A leg that says so in its own `AGENTS.md`, as the templates write it.""" +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 = (root / AGENTS_MD).read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError): + 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), + 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"} @@ -393,12 +507,19 @@ def workspace_paths(env=None) -> list[Path]: 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. + 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 - base = env.get("AGENT_PROTOCOL_ROOT") or os.path.join( - os.path.expanduser("~"), ".agents") - config = _read_mapping(Path(base) / "workspace.yaml") + 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")] @@ -412,7 +533,7 @@ def workspace_paths(env=None) -> list[Path]: continue try: found.append(Path(os.path.expanduser(value.strip())).resolve()) - except (OSError, RuntimeError): + except Exception: # noqa: BLE001 - `\0` raises ValueError here continue return found @@ -428,7 +549,7 @@ def _workspace(root: Path, names: list[str], policy: NamingPolicy | None, f"{NAMING_POLICY}") try: here = root.resolve() - except (OSError, RuntimeError): + 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 " @@ -506,26 +627,32 @@ def field_problems(record: dict, shape: Path = SHAPE_ROOT) -> list[str]: return problems -def read_record(root: Path, shape: Path = SHAPE_ROOT) -> tuple: +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, unparsable, not a + 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. + 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. """ - path = root / RECORD_FILE - if not path.exists(): - return RECORD_ABSENT, [], [] - if not path.is_file(): - return RECORD_SILENCES_NOTHING, ["it is not a file"], [] + tree = root if isinstance(root, Tree) else Tree(Path(root), commit) try: - data = load_yaml(path) - except Refusal as exc: - return RECORD_SILENCES_NOTHING, [f"it could not be read " - f"({exc.code})"], [] - except (OSError, UnicodeDecodeError) as exc: + 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): @@ -540,7 +667,8 @@ def read_record(root: Path, shape: Path = SHAPE_ROOT) -> tuple: # -- the reading ------------------------------------------------------------- -def read(root, shape: Path = SHAPE_ROOT, env=None) -> 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 @@ -548,13 +676,36 @@ def read(root, shape: Path = SHAPE_ROOT, env=None) -> Reading: 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. """ - root = Path(root) - project = _read_mapping(root / PROJECT_MANIFEST) + 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(root / FAMILY_MANIFEST) + 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 " @@ -565,13 +716,15 @@ def read(root, shape: Path = SHAPE_ROOT, env=None) -> Reading: "advise about") policy = _naming_policy(shape) names = repository_names(root) - leg = _leg_by_name(names, policy) or _leg_by_agents_md(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(root, shape) + 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", diff --git a/shape-doctor.py b/shape-doctor.py index 23a1ce6a..cb36f3eb 100755 --- a/shape-doctor.py +++ b/shape-doctor.py @@ -85,14 +85,15 @@ "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 that has not elected it gets one row -with the status `advisory`, saying it once; an elected Triad, 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. 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. +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 @@ -2975,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. @@ -3007,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)}") @@ -3062,12 +3088,36 @@ def check_triad_advisory(ctx: Context) -> Row | None: 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. 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 `the way in` row above already - carries the adoption command for this exact repository. + `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, @@ -3121,13 +3171,13 @@ def check_triad_advisory(ctx: Context) -> Row | None: 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 on the - # two kinds of root that can be something other than an elected Triad: a - # directory with no manifest, and a `project.yaml` that declares fewer - # than two legs. A family holder is never advised, so it is not asked. + # 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, (PROJECT, NOT_A_ROOT), + 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 diff --git a/tests/test_repo_hygiene.py b/tests/test_repo_hygiene.py index b2387d62..3370c49c 100644 --- a/tests/test_repo_hygiene.py +++ b/tests/test_repo_hygiene.py @@ -540,23 +540,28 @@ def test_agents_md_is_short_enough_to_be_read(): assistant needs is the one telling them not to reach for the file themselves, and which readings the tool leaves alone. - 2026-10-06: 446 -> 465 — "What you do tell them" (#163), openxFactory's - `prefer-triad-project-shape`, ratified by Brett Heap the same day ("ratify - 1249 as recommended"). Nineteen lines, one new section beside "What you - must not tell them", which stays word for word. They buy what that - section cannot: 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 and - that preferred is not required. The rest of the lines 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.""" + 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) <= 465, f"AGENTS.md is {len(lines)} lines; the cap is 465" + assert len(lines) <= 464, f"AGENTS.md is {len(lines)} lines; the cap is 464" def test_claude_md_points_at_agents_md(): diff --git a/tests/test_shape_advisory.py b/tests/test_shape_advisory.py index d7483ded..211df69e 100644 --- a/tests/test_shape_advisory.py +++ b/tests/test_shape_advisory.py @@ -115,6 +115,29 @@ def record_text(**fields) -> str: 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", @@ -147,6 +170,18 @@ def test_a_single_repository_hears_it_once_and_its_exit_is_unchanged( 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) @@ -332,6 +367,9 @@ def test_a_record_that_silences_nothing_is_reported_beside_the_advisory( 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 @@ -339,20 +377,53 @@ def test_a_record_that_silences_nothing_is_reported_beside_the_advisory( result.stdout -def test_a_leg_clone_is_sent_to_its_root_instead(tmp_path, quiet_env): - """By its name: `Atlas-spec` is the spec-leg form of the naming policy.""" - here = repository(tmp_path / "Atlas-spec") +@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 "work from its assembly root `Atlas`" in row["reason"], \ - 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 "NOTE this is a clone of the spec leg of a Triad" 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"]) @@ -364,18 +435,12 @@ def test_the_leg_templates_are_what_the_advisory_reads(tmp_path, role): held to the templates: reword a template without the module and this is red, rather than a leg that quietly starts being advised. """ - template = (REPO / "templates" / f"{role}-root" / "AGENTS.md").read_text( - encoding="utf-8") - text = 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") - here = repository(tmp_path / "renamed-clone", {"AGENTS.md": text}) + 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" @@ -404,6 +469,163 @@ def test_no_class_is_guessed(tmp_path, quiet_env, name): 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():