"""``mgf-fed court`` — the fleet-wide-act cadence, with an exit code.

**Why this is code and not a paragraph in CLAUDE.md.** On 2026-07-30 this estate
held five hand-written verifiers referenced by zero pipelines: release currency,
CI freshness, governance boundary, secret scopes, fleet membership. Each was
written after a real incident, ran once, and never ran again — so the classes
recurred and produced the next bespoke script. SC-23 was a declared MUST enforced
in exactly one repo for two weeks. The estate's own rule, in ``CLAUDE.md``:

    A verifier that isn't in a gate's exit code does not exist.

A "do a court round after every fleet-wide act" written as prose would have been
the sixth verifier in that drawer. So it gets a subcommand and an exit code.

**What a fleet-wide act is** (founder-adopted 2026-08-04; the proposal is
``mgf/court/proposals/2026-08-04-court-round-cadence/``): a change landing in
**≥3 repos**, or touching **canon**, or **a gate's logic**, or the **credential
plane**, or **a release consumers install**. Two repos is a pair you can hold in
your head; three is where you stop editing and start scripting, and every
roster/sample defect in this programme lived in a script.

**Detection is of TRIGGERS, not of intent.** "Which commits formed one act" is a
judgement no tool can make, and pretending otherwise would put a guess where the
round belongs. What IS mechanical is whether a trigger *fired* in a window: did
canon change, did a gate change, did ≥3 repos take commits. This module answers
that, and then asks the only question it can answer honestly — **is there a round
naming it?**

**A skip is not a pass** (the audit's corollary, and the reason it exists): a
window this cannot evaluate exits non-zero and says so, because "I could not
check" must never share an exit code with "I checked and it was clean".
"""

from __future__ import annotations

import subprocess
import tomllib
from dataclasses import dataclass, field
from datetime import UTC, datetime
from datetime import date as _Date
from pathlib import Path
from typing import Any, Literal

#: Where rounds are recorded. The court workspace is the court's own surface
#: (CRT-01: "a review never edits its target"), so rounds live there and not in
#: the repos they judge.
COURT_ROUNDS_DIR = Path("mgf") / "court" / "rounds"

#: The number of repos at which a change stops being an edit and becomes a wave.
WAVE_THRESHOLD = 3

#: Paths whose change IS a fleet-wide act regardless of how many repos moved.
#: Keyed by trigger name -> (repo, path prefixes).
CANON_PATHS: dict[str, tuple[str, tuple[str, ...]]] = {
    "canon": (
        "mgf-standard",
        ("templates/", "MGF_LIBRARIES.json", "docs/standards/", "SECRETS.toml"),
    ),
    "gate": (
        "mgf-fed",
        (
            "src/mgf/fed/_audit.py",
            "src/mgf/fed/_drift.py",
            "src/mgf/fed/_court.py",
            # `_roster.py` earns its place the hard way. The PAPER-144 fix moved
            # ELEVEN roster predicates out of `_audit`/`_court`/`ledger_seed` and
            # into one `is_git_repo()` here — and a fresh-context chair measured
            # the consequence: a commit touching only `_roster.py` produced
            # "(NONE — no round is demanded)", while the same change with one
            # `_audit.py` line convened a chair. A refactor had quietly shrunk
            # the court's field of view; whoever decides what counts as a repo
            # decides what every gate can see.
            "src/mgf/fed/_roster.py",
        ),
    ),
}

#: The docket. Five questions, each one a RECORDED failure — see the proposal for
#: the citation behind each. A round that does not answer all five is incomplete,
#: because a generic "looks fine" is what a court round decays into.
DOCKET: tuple[str, ...] = (
    "gate_reality",  # what reality does the gate read, and did I verify against THAT?
    "gate_list_run",  # did I run the target's own gate list, or a remembered subset?
    "sabotage_bites",  # does my sabotage actually sabotage?
    "blind_spots",  # what can this check NOT see — roster, scope, sample?
    "blast_radius",  # if wrong, how many repos break, and can it be undone?
)


@dataclass
class Act:
    """A fleet-wide trigger that fired in the window."""

    trigger: str  # "wave" | "canon" | "gate"
    detail: str
    repos: list[str] = field(default_factory=list)
    #: The act's OWN date — the newest commit it is made of, as a local ISO date.
    #: A round must be at least this old; see `_ACT_DATE_RULE`.
    latest: _Date | None = None


def _names_a_repo(act_text: str, repos: list[str]) -> bool:
    """Does ``act_text`` name at least one of ``repos``?

    Whole-token match, so ``mgf-common`` in the text does NOT satisfy an act
    that only touched ``mgf-common-zig`` — the substring would otherwise let a
    round about one repo license a sibling whose name extends it, which is the
    same "close enough" failure this check exists to stop.
    """
    import re

    return any(re.search(rf"(?<![\w-]){re.escape(name)}(?![\w-])", act_text) for name in repos)


@dataclass
class Round:
    """A recorded court round."""

    path: Path
    act: str
    trigger: str
    chair: str
    date: str = ""  # ISO YYYY-MM-DD; a round judges the act in front of it
    answers: dict[str, str] = field(default_factory=dict)

    @property
    def missing_answers(self) -> list[str]:
        return [q for q in DOCKET if not (self.answers.get(q) or "").strip()]

    @property
    def on(self) -> _Date | None:
        """``date`` as a DATE, or ``None`` if it is not one.

        The field is hand-typed TOML and was never validated, while the code
        compared it as a string. A chair measured the consequence: rounds dated
        ``yesterday``, ``TBD``, ``n/a``, ``soon`` and ``pending`` all sort above
        ``"2026-…"`` and so were permanently in-window — licensing their trigger
        forever, which is D1 arriving through the operand D1's fix did not touch.
        A value that is not a date is not a date; the round fails loudly.
        """
        try:
            return _Date.fromisoformat(self.date.strip())
        except ValueError:
            return None


#: A bare ``YYYY-MM-DD`` is a TRAP in git and this gate fell into it on its first
#: run. Git's approxidate fills unspecified fields from **now**, so
#: ``--since=2026-08-04`` evaluated at 23:28 means "since 23:28 today", not
#: midnight — and the later in the day you run it, the emptier the window. The
#: first `court --check` reported "no fleet-wide act detected" on a day with FOUR,
#: which is this programme's own defect class committed by the check written to
#: police it. Normalised to midnight so the window means what it says.
_BARE_DATE_LEN = 10


def _normalise_since(since: str) -> str:
    s = since.strip()
    if len(s) == _BARE_DATE_LEN and s[4] == "-" and s[7] == "-" and s.replace("-", "").isdigit():
        return f"{s} 00:00:00"
    return s


#: D3 — **the window bound was compared as a date it was not.**
#:
#: `run_court` took `_normalise_since(since)[:10]` as the window's start and then
#: compared round dates to it with `>=`. That is a LEXICOGRAPHIC comparison, and
#: it is only chronological when both sides are ISO dates. A relative expression
#: passes `_normalise_since` through untouched, so the bound was the literal
#: string:
#:
#:     "2026-08-11" >= "1.day.ago"   -> True   (every round ever written matches)
#:     "2026-08-11" >= "3.days.ago"  -> False  (no round ever written matches)
#:
#: The pre-commit hook that runs this gate passes `--since 1.day.ago`. So D1 —
#: "a round must not license its trigger forever", the defect this module calls
#: its worst — was DISARMED in the only invocation that actually runs, from the
#: day the hook shipped. Its test passes because the test uses a bare date.
#:
#: The fix is to ask git what the expression means instead of assuming its shape.
#: `git rev-parse --since=<expr>` is approxidate itself, so every form git accepts
#: on `--since` resolves here identically — no second date parser to drift.
_SINCE_PREFIX = "--max-age="

#: WITHDRAWN — ``_MIN_WINDOW_SECONDS``, a one-hour floor under the window.
#:
#: It was meant to catch "approxidate returned *now* because it did not understand
#: you". It caught that, and it also refused three legitimate windows a chair
#: measured: ``--since <today>`` (the form this module's own help text recommends)
#: for the first hour of every day, ``--since 30.minutes.ago``, and a post-merge CI
#: invocation windowed to the merge that just happened — the most precise window
#: anyone could ask for. A floor under the WINDOW is a proxy for the thing actually
#: worth refusing, which is a bound the caller did not mean. So ask that directly:
#: resolve the caller's expression and ``now`` in ONE ``rev-parse`` call (no clock
#: race between them) and refuse only when they are the same instant.
_NOW: Literal["now"] = "now"

#: `--since=<expr>` and `--since=now`, asked together so no clock ticks between.
_TWO_BOUNDS = 2


def _window_start(since: str, probe: Path | None) -> datetime | None | Literal["now"]:
    """The window's start as a real instant, resolved by git's own approxidate.

    Three outcomes, because there are three:

    * a ``datetime`` — the bound the caller meant;
    * ``"now"`` — approxidate did not understand the expression and silently
      returned the current instant, which is a zero-width window rather than a
      bound (measured: ``not-a-date``, the empty string and ``0`` all do this);
    * ``None`` — neither git nor python could make a date of it at all.

    The last two are both "I could not evaluate", never "clean"; they are
    distinguished so the operator is told which mistake they made.

    Needs ``probe`` because ``rev-parse`` requires a repository even for a
    question that has nothing to do with one. A fully-specified ISO date is
    resolved WITHOUT git if the probe cannot answer; relative forms are
    approxidate's alone and stay unresolved, because guessing at them is how the
    string comparison above got written.
    """
    if probe is not None:
        # Both bounds in ONE invocation: two calls a second apart could disagree
        # about "now" and turn this guard into a flake.
        lines = [
            ln[len(_SINCE_PREFIX) :]
            for ln in _git(probe, "rev-parse", f"--since={since}", f"--since={_NOW}").splitlines()
            if ln.startswith(_SINCE_PREFIX)
        ]
        if len(lines) == _TWO_BOUNDS:  # one bound asked, one reference
            try:
                asked, reference = int(lines[0]), int(lines[1])
            except ValueError:
                asked = reference = -1
            if asked >= 0:
                # `--since now` lands here too, and belongs here: a window that
                # starts at this instant is zero-width whether the caller meant
                # it or not.
                if asked == reference:
                    return _NOW
                return datetime.fromtimestamp(asked).astimezone()
    try:
        return datetime.fromisoformat(since).astimezone()
    except ValueError:
        return None


def _git(repo: Path, *args: str) -> str:
    try:
        p = subprocess.run(  # noqa: S603
            ["git", "-C", str(repo), *args],  # noqa: S607 — git from PATH, house convention
            capture_output=True,
            text=True,
            timeout=60,
            check=False,
        )
    except (OSError, subprocess.SubprocessError):
        return ""
    return p.stdout if p.returncode == 0 else ""


def _default_ref(repo: Path) -> str:
    """``origin/<default>`` for this repo, resolved from the REMOTE.

    D2. `detect_acts` ran `git log --since` with no ref, i.e. against whatever the
    checkout happened to have on HEAD. A gate change merged to `main` therefore
    VANISHED the moment the working tree was parked on another branch — proven:
    the act passed unreviewed, exit 0.

    That is the counterfeit-ref discipline `CLAUDE.md` states outright, inverted,
    inside the tool whose whole job is refusing a false pass. Acts are properties
    of what LANDED, so they are read from the landed ref.
    """
    out = _git(repo, "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD").strip()
    if out:
        return out
    for cand in ("origin/main", "origin/master"):
        if _git(repo, "rev-parse", "--verify", "--quiet", cand).strip():
            return cand
    # No remote at all — `PlasmaAI` is the live example. There is no "landed"
    # ref to read, so HEAD is the only truth this repo has. Returned rather than
    # skipped, because silently dropping a repo from act detection is how a wave
    # goes uncounted; the caller reports what it read either way.
    return "HEAD"


def _repos(root: Path) -> list[Path]:
    # Imported here, not at module scope: this module stays first-party-import-light
    # so the court verb keeps working on a bare core (see `federation_root` below).
    from mgf.fed._roster import is_git_repo

    return sorted(
        p for p in root.iterdir() if p.is_dir() and not p.name.startswith("_") and is_git_repo(p)
    )


def detect_acts(root: Path, since: str) -> list[Act]:
    """Which fleet-wide triggers fired since ``since`` (a git date, e.g. ``2026-08-04``).

    Reads the local checkouts. That is a real limit and it is NAMED rather than
    hidden: a repo not checked out here cannot be examined, and `check` reports
    the count it looked at so a partial view can never read as a full one.
    """
    since = _normalise_since(since)
    acts: list[Act] = []
    touched: list[str] = []
    newest: _Date | None = None
    for repo in _repos(root):
        ref = _default_ref(repo)
        if not ref:
            continue  # no landed ref to judge; counted as unread by the caller
        log = _git(repo, "log", ref, f"--since={since}", "--oneline")
        if not log.strip():
            continue
        touched.append(repo.name)
        when = _last_commit_date(repo, ref, since)
        if when is not None and (newest is None or when > newest):
            newest = when
        for trigger, (owner, prefixes) in CANON_PATHS.items():
            if repo.name != owner:
                continue
            files = _git(repo, "log", ref, f"--since={since}", "--name-only", "--pretty=format:")
            hits = sorted(
                {
                    ln.strip()
                    for ln in files.splitlines()
                    if ln.strip() and ln.strip().startswith(prefixes)
                }
            )
            if hits:
                acts.append(
                    Act(
                        trigger=trigger,
                        detail=f"{owner}: {len(hits)} {trigger} path(s) changed, e.g. {hits[0]}",
                        repos=[owner],
                        # The date of the newest commit that touched THESE PATHS,
                        # not the repo's newest commit. Caught live on the first
                        # 7-day run: `mgf-fed`'s gate paths last moved 2026-08-05,
                        # but an unrelated commit on 08-11 dated the act 08-11 and
                        # reddened three rounds that had judged it correctly. An
                        # act is dated by the change it IS.
                        latest=_last_commit_date(repo, ref, since, paths=prefixes),
                    )
                )
    if len(touched) >= WAVE_THRESHOLD:
        acts.append(
            Act(
                trigger="wave",
                detail=f"{len(touched)} repos took commits (threshold {WAVE_THRESHOLD})",
                repos=touched,
                latest=newest,
            )
        )
    return acts


def _last_commit_date(
    repo: Path, ref: str, since: str, paths: tuple[str, ...] = ()
) -> _Date | None:
    """The date of the newest commit in the window on ``ref`` — the ACT's own date.

    ``paths`` narrows it to the commits that touched those pathspecs, which is
    what makes the date belong to the ACT rather than to the repo: a canon act is
    dated by when canon moved, not by whatever else landed in the same window.

    ``format-local:`` and NOT ``--date=short``. The difference is a whole day and
    it bit within the hour of shipping: ``--date=short`` renders in the COMMIT's
    own timezone, and Codeberg stamps merge commits ``+0200``. So four merges
    made at 15:44 local (UTC-7) came back dated **2026-08-13** — a day ahead of
    any date a human writing a round on this bench could produce, making the gate
    unsatisfiable by construction until the calendar caught up.

    A round is dated by a person reading a local calendar. The act must be read
    on the same calendar, or the two are not comparable at all. ``format-local``
    is git doing that conversion; deriving it from ``%cI`` and slicing would
    reintroduce the class this module keeps paying for.
    """
    argv = [ref, f"--since={since}", "-1", "--date=format-local:%Y-%m-%d", "--format=%cd"]
    if paths:
        argv += ["--", *paths]
    out = _git(repo, "log", *argv).strip()
    try:
        return _Date.fromisoformat(out.splitlines()[0].strip())
    except (ValueError, IndexError):
        return None


def load_rounds(root: Path) -> list[Round]:
    """Every recorded round. A malformed round is NOT silently skipped — it is
    returned with empty answers so it fails the docket check loudly."""
    out: list[Round] = []
    d = root / COURT_ROUNDS_DIR
    if not d.is_dir():
        return out
    for p in sorted(d.glob("*.toml")):
        try:
            data: dict[str, Any] = tomllib.loads(p.read_text(encoding="utf-8"))
        except (OSError, tomllib.TOMLDecodeError):
            out.append(Round(path=p, act="<unparseable>", trigger="", chair="", date=""))
            continue
        r = data.get("round") or {}
        out.append(
            Round(
                path=p,
                act=str(r.get("act", "")),
                trigger=str(r.get("trigger", "")),
                chair=str(r.get("chair", "")),
                date=str(r.get("date", "")),
                answers={k: str(v) for k, v in (data.get("answers") or {}).items()},
            )
        )
    return out


def run_court(*, since: str | None, root: Path | None = None, check: bool = True) -> int:
    """The gate half. Exit 0 only when every fired trigger has a complete round."""
    from mgf.fed._roster import federation_root

    root = root or federation_root()
    if not since:
        # NOT "a date or a ref", which is what this said. Git's `--since` is
        # approxidate only: it turns `HEAD~5` into a silent nonsense timestamp
        # (measured: seven days ago) rather than rejecting it.
        print(
            "court --check needs --since (a git DATE), e.g. --since 2026-08-04 "
            "or --since 3.days.ago. A ref is not a date."
        )
        return 2  # cannot evaluate != clean
    if not root.is_dir():
        print(f"court: no federation root at {root} — cannot evaluate")
        return 2

    repos = _repos(root)
    n_repos = len(repos)
    if not n_repos:
        print(f"court: no repos under {root} — nothing was examined, which is NOT a pass")
        return 2

    # D3 — resolve the window through git's approxidate rather than assuming the
    # caller wrote an ISO date. See `_window_start`.
    start = _window_start(_normalise_since(since), repos[0])
    if start is None:
        print(
            f"CANNOT EVALUATE — git could not resolve --since {since!r} to a date. "
            f"Pass something approxidate accepts (`2026-08-04`, `3.days.ago`)."
        )
        return 2
    if isinstance(start, str):  # `_NOW`; narrows `start` to a datetime below
        print(
            f"CANNOT EVALUATE — --since {since!r} resolves to this instant. Approxidate "
            f"does not reject what it cannot parse, it returns *now* (measured: "
            f"`not-a-date`, `''` and `0` all do), so a zero-width window is what an "
            f"unparsed expression looks like. Nothing can have happened in it."
        )
        return 2
    now = datetime.now(UTC).astimezone()  # DP-12: never naive, even when the result is local
    if start > now:
        print(
            f"CANNOT EVALUATE — --since {since!r} resolves to "
            f"{start:%Y-%m-%d %H:%M:%S}, which is in the FUTURE. Nothing can have "
            f"happened in that window, so an empty result says nothing about the fleet."
        )
        return 2

    acts = detect_acts(root, since)
    rounds = load_rounds(root)
    # Counted over the LANDED ref, same as detect_acts — otherwise the
    # "did anything move?" sanity check disagrees with the act detection it guards.
    moved = sum(
        1
        for r in repos
        if (ref := _default_ref(r))
        and _git(r, "log", ref, f"--since={_normalise_since(since)}", "--oneline").strip()
    )

    print(
        f"court --check — since {start:%Y-%m-%d %H:%M:%S} "
        f"(--since {since!r}), {n_repos} repo(s) on disk"
    )

    # A window in which NOTHING moved anywhere is far more likely a bad window
    # than a quiet fleet — and that is exactly how this gate failed its own first
    # run (a bare date silently meant "since this moment"). "I could not evaluate"
    # must never share an exit code with "I evaluated and it was clean".
    #
    # D4 — WITHDRAWN, and the withdrawal is worth more than the feature was.
    # 0.25.3 briefly replaced this refusal with a freshness probe: `.git/FETCH_HEAD`
    # mtime, read as "when this machine last ASKED the forge", so that a
    # freshly-fetched-but-quiet fleet could pass instead of blocking commits. A
    # fresh-context chair killed it with one measurement: a FAILED `git fetch`
    # writes FETCH_HEAD too. Verified — an unreachable remote exits 128 and CREATES
    # the file with a current mtime. So in a forge outage, every repo fails to
    # fetch, every repo looks fetched, and the gate would have printed "a quiet
    # fleet rather than a blind gate" over precisely the blind bench it existed to
    # detect. The probe answered "did something fetch-SHAPED run", not "is this
    # bench current".
    #
    # The wolf-cry it was meant to fix is real, and the honest remedy is the
    # WINDOW, not the verdict: a week-wide window is quiet only when the estate
    # genuinely is, and then a human should look. `--no-verify` is a worse
    # outcome than a refusal, but a false PASS is worse than both.
    if not moved:
        print(
            f"CANNOT EVALUATE — no commits on any landed ref in {n_repos} repos since "
            f"{start:%Y-%m-%d %H:%M:%S}. `origin/<default>` is a LOCAL CACHE, so an "
            f"unfetched bench and an idle fleet are the same bytes and this gate cannot "
            f"tell them apart. Widen --since, or refresh the bench (`git fetch` in the "
            f"repos you expect to have moved) and re-run."
        )
        return 2

    if not acts:
        print(
            f"no fleet-wide act in the window — {moved} repo(s) moved, "
            f"below the {WAVE_THRESHOLD}-repo threshold and touching no canon/gate path"
        )
        return 0

    problems: list[str] = []
    malformed = [r for r in rounds if r.date and r.on is None]
    for r in malformed:
        # L-4. `date` is a hand-typed TOML field that was never validated while the
        # code compared it as a string, so anything sorting above "2026-…" was
        # permanently in-window. Measured by a chair: `yesterday`, `TBD`, `n/a`,
        # `soon` and `pending` each licensed their trigger forever, exit 0.
        problems.append(
            f"round {r.path.name} has date = {r.date!r}, which is not a date. "
            f"A round dated in words cannot be placed against any act; write "
            f"YYYY-MM-DD"
        )
    for act in acts:
        # D1 — a round must be no older than THE ACT IT JUDGES.
        #
        # This matched on `r.trigger == act.trigger` alone, so a single round
        # licensed its trigger forever: a 2019-dated round made every future gate
        # change pass, exit 0. A gate that can be satisfied once and then never
        # again is the drawer of dead verifiers this module's docstring is written
        # against — built into the module itself.
        #
        # The bound was then `window_start`, i.e. the CALLER's `--since` — which
        # made D1's strictness a function of an argument. A chair proved the
        # consequence on the very act that introduced it: widening the court hook
        # from `1.day.ago` to `7.days.ago` (for coverage, a sound goal) bought
        # seven days of licensing slack as a side effect, and one gate act was
        # printed OK four times over by four rounds that had judged other things.
        #
        # So the bound is the act's OWN date. `--since` now decides only WHICH
        # ACTS ARE ASKED ABOUT; it can no longer decide how old a round may be.
        # Widening the window is now purely more coverage, which is what the
        # operator widening it believes they are buying.
        cutoff = act.latest
        if cutoff is None:
            # An act whose own date could not be read cannot be placed against any
            # round, and "I could not place it" is not a licence. Same reasoning as
            # the empty-`repos` branch below: a guard disabled by a missing field is
            # the shape this module keeps paying for.
            problems.append(
                f"{act.trigger}: {act.detail} — the act carries NO date, so no round "
                f"can be placed against it. This is a defect in `detect_acts`, not "
                f"a reason to pass."
            )
            continue
        matching = [
            r for r in rounds if r.trigger == act.trigger and r.on is not None and r.on >= cutoff
        ]
        stale = [r for r in rounds if r.trigger == act.trigger and r not in matching]
        if not matching and stale:
            problems.append(
                f"{act.trigger}: {act.detail} — {len(stale)} round(s) exist for this "
                f"trigger but all predate the act itself (act dated "
                f"{cutoff.isoformat()}; {', '.join(r.path.name for r in stale[:3])}). "
                f"A round judges the act in front of it, not the trigger forever"
            )
            continue
        if not matching:
            problems.append(
                f"{act.trigger}: {act.detail} — NO ROUND. Record one with "
                f"`mgf-fed court --write` (docket: {', '.join(DOCKET)})"
            )
            continue
        # D2 — a round must NAME the act in front of it.
        #
        # D1 stopped a round licensing its trigger FOREVER. It did not stop one
        # licensing a DIFFERENT act in the same window: `court --check` passed a
        # 5-repo remediation using a round whose `act` field named four entirely
        # other PRs, because the match was (trigger, date-in-window) and nothing
        # asked whether the round described the commits that were counted. Zero
        # repo overlap, and it printed OK.
        #
        # That is F-45's shape a fourth time — the check asked whether a FIELD
        # WAS PRESENT, not whether it SAID SOMETHING ADMISSIBLE. The bar here is
        # deliberately low (name ONE of the repos that moved) because a 35-repo
        # wave cannot list all of them, and a low bar still makes zero-overlap
        # impossible.
        # An act with no repos is a PRODUCER bug, not a licence. `detect_acts`
        # populates `repos` for every trigger it emits; if it ever stops, D2
        # would silently become opt-out and the hole below reopens. Fail loudly
        # instead — a guard that is disabled by an empty field is the shape this
        # module's own `_FRESH_CONTEXT_TRIGGERS` comment indicts.
        if not act.repos:
            problems.append(
                f"{act.trigger}: {act.detail} — the act names NO repos, so no round "
                f"can be checked against it. This is a defect in `detect_acts`, not "
                f"a reason to pass."
            )
            continue
        if not any(_names_a_repo(r.act, act.repos) for r in matching):
            problems.append(
                f"{act.trigger}: {act.detail} — {len(matching)} round(s) match this "
                f"trigger and window, but NONE names any repo the act touched "
                f"({', '.join(sorted(act.repos)[:4])}"
                f"{', …' if len(act.repos) > 4 else ''}). "
                f"A round judges the act in front of it — matching the trigger and "
                f"the date is not judging it. Record a round whose `act` names "
                f"what moved."
            )
            continue
        # NOTE: every matching round is still docket/chair-checked below. An
        # earlier version narrowed `matching` to the covering rounds, which
        # meant an in-window round with ZERO answered docket questions was
        # silently dropped whenever a sibling happened to name a repo — a new
        # false-pass introduced by the fix for a false-pass. A chair found it.
        for r in matching:
            if r.missing_answers:
                problems.append(
                    f"{act.trigger}: round {r.path.name} answers "
                    f"{len(DOCKET) - len(r.missing_answers)}/{len(DOCKET)} docket "
                    f"question(s) — missing: {', '.join(r.missing_answers)}"
                )
            elif not r.chair.strip():
                problems.append(f"{act.trigger}: round {r.path.name} names no chair (CRT-01)")
            elif (bad := _chair_problem(act.trigger, r.chair)) is not None:
                problems.append(f"{act.trigger}: round {r.path.name} {bad}")
            else:
                print(f"  OK   {act.trigger}: {act.detail}")
                print(f"       round {r.path.name}, chair={r.chair}")
    for p in problems:
        print(f"  FAIL {p}")
    if problems:
        print(f"\nFAIL — {len(problems)} fleet-wide act(s) without a complete round")
        return 1
    print(f"\nPASS — every fleet-wide act in the window has a complete round ({len(acts)} act(s))")
    return 0


#: Triggers on which CRT-01 disqualifies the implementer. A plain wave may be
#: self-judged; changing canon, a gate, the credential plane or a published
#: release may not. This is the split the round SKELETON has always printed —
#: `"fresh-context" for canon/gate/credential acts; "self" for a plain wave` —
#: and which nothing enforced.
#: Derived from the producer's own vocabulary, never written out by hand. The
#: first version was the literal set `{"canon", "gate", "credentials", "release"}`
#: — and `detect_acts` can emit exactly three triggers: `CANON_PATHS`' keys plus
#: `"wave"`. So two members were dead strings, no test could cover them, and the
#: tool READ as if it enforced CRT-01 on credential and release acts while
#: enforcing nothing there (a release act is never even detected, so no round is
#: demanded). A court chair found it.
#:
#: That is a magic-string set not read back from its producer — the same defect
#: F-44 fixed one module over, committed in the same act as the fix. Deriving it
#: means a new entry in `CANON_PATHS` is fresh-context-gated the moment it exists.
_FRESH_CONTEXT_TRIGGERS = frozenset(CANON_PATHS)

#: The one chair value that satisfies CRT-01. Compared EXACTLY (case-folded,
#: stripped) rather than by substring, because a substring test is how the
#: defect below got through.
_FRESH_CONTEXT_CHAIR = "fresh-context"


def _chair_problem(trigger: str, chair: str) -> str | None:
    """Why this chair cannot judge this act, or ``None`` if it can.

    **The third proven false-pass path in this tool** (2026-08-05). `--check`
    asked only whether `chair` was non-empty, so a gate act signed

        chair = "self — CRT-01 NOT SATISFIED, a fresh-context chair is OWED"

    printed `OK`, echoed that string back to the operator, and exited 0. A round
    that says in its own chair field that it does not satisfy CRT-01 passed the
    gate whose entire purpose is CRT-01.

    The skeleton has always told the writer which chair each trigger needs. The
    checker never read it back. That gap — a rule stated in the artifact a human
    fills in, enforced nowhere in the exit code — is this whole workspace's
    subject matter, sitting in its own instrument for the second session running.

    Session 6's two false-pass paths here were a round licensing its trigger
    forever, and act detection reading the parked checkout. This is the third,
    and all three share a shape: the tool checked that a FIELD WAS PRESENT rather
    than that it SAID SOMETHING ADMISSIBLE.
    """
    if trigger not in _FRESH_CONTEXT_TRIGGERS:
        return None
    if chair.strip().casefold() == _FRESH_CONTEXT_CHAIR:
        return None
    return (
        f"is chaired by {chair.strip()!r}, but a `{trigger}` act needs a "
        f"fresh-context chair (CRT-01: the implementer is disqualified from judging "
        f'his own work). Set chair = "{_FRESH_CONTEXT_CHAIR}" and have one seated — '
        f"do not describe the gap in the chair field, which passed until today"
    )


def write_round_template(root: Path, slug: str, *, trigger: str, act: str) -> Path:
    """Emit a round skeleton for the chair to fill in. Deliberately NOT
    auto-answered: a docket answered by the tool is the tool reviewing itself."""
    d = root / COURT_ROUNDS_DIR
    d.mkdir(parents=True, exist_ok=True)
    stamp = datetime.now(UTC).strftime("%Y-%m-%d")
    p = d / f"{stamp}-{slug}.toml"
    body = [
        "# Court round. CRT-01: a review never edits its target — findings live here,",
        "# the implementer implements, the founder enacts.",
        "",
        "[round]",
        f'act = "{act}"',
        f'trigger = "{trigger}"',
        f'date = "{stamp}"',
        'chair = ""  # "fresh-context" for canon/gate/credential acts; "self" for a plain wave',
        "",
        "[answers]",
        "# Each question is a RECORDED failure. Blank answers fail `court --check`.",
        'gate_reality = ""   # what reality does the gate read — did I verify against THAT?',
        'gate_list_run = ""  # did I run the target\'s own gate list, or a remembered subset?',
        'sabotage_bites = "" # does my sabotage actually sabotage?',
        'blind_spots = ""    # what can this check NOT see — roster, scope, sample?',
        'blast_radius = ""   # if wrong, how many repos break — and can it be undone?',
        "",
    ]
    p.write_text("\n".join(body), encoding="utf-8")
    return p
