# -*- coding: utf-8 -*-
"""Instruction-file consistency gate — keeps CLAUDE.md files from silently regrowing.

Usage:  python3 scripts/instructions_gate.py <repo-root> [<repo-root> ...]
        python3 scripts/instructions_gate.py --fast <repo-root>      (identical: no network,
                                                                      no container runtime)

THIS IS A CONSISTENCY GATE, NOT A BUDGET GATE. Measured 2026-08-06 on a 1M-token window: the
instruction files occupied 15k tokens against 869k free. Space is NOT the constraint here and no
failure message may claim it is. The line ceiling exists because longer instruction files reduce
ADHERENCE — Anthropic's guidance is to keep a CLAUDE.md under 200 lines — and because a file nobody
can hold in their head is where contradictions hide. Four were found in this project on 2026-08-06,
two of which decided where a destructive drill runs.

WHAT IS COUNTED. Every check runs against EFFECTIVE text: block-level HTML comments are stripped
first, because they are stripped before injection and never reach the model. Verified empirically on
Claude Code 2.1.222 with a control (two plain markers -> both seen) and a treatment (one marker
inside <!-- -->, twice -> not seen). That is the whole point of the comment convention: earned
rationale stays in the repo for human readers at zero cost to the instructions.

CHECKS (each names the file and the reason; a missing input is a FAILURE, never a skip):

  1. <root>/CLAUDE.md is at most MAX_LINES effective lines.
  2. Every <root>/.claude/rules/*.md declares `paths:` in frontmatter, or `unconditional: true`.
     NOTE: `paths:` is a Claude Code feature — a rule carrying it loads only when a file matching
     one of its globs is read. `unconditional: true` is NOT a product feature; it is OUR marker,
     asserting that always-loading was deliberate. The product loads a rule with no `paths:` key
     unconditionally either way, so this check catches the ACCIDENT, not the product behaviour.
  3. No component version literal in effective text. Versions change several times a day and the
     fleet is not uniform, so a version in an instruction file is stale within a day (ask the hub's
     /hosts + /configs, or the box). Historical citations belong in an HTML comment beside the rule
     they justify, where they inform a human and cannot go stale in the model's view.
     IPv4 addresses are NOT versions — a bare \\d+\\.\\d+\\.\\d+ matches the first three octets of
     every one of them, which is how this check would otherwise fail on its own repo.
  4. No TEMPORARY block carrying a date already past. The block this gate was written for said
     "TEMPORARY - until ~2026-08-02 ... Delete this block on return" and was still being read as
     current fact on 2026-08-06, while a sibling repo's CLAUDE.md asserted the opposite.
  5. The workspace-root CLAUDE.md and its versioned copy at
     felhom.eu/documentation/runbooks/workspace-CLAUDE.md are byte-identical. The live file sits in
     a directory that is not a git repo, so the copy is the only version-controlled record of it;
     nothing but this check enforces that they agree.
  6. The auto-memory index (<workspace>/.claude-memory/MEMORY.md) is within its line and byte
     limits, every top-level topic file is referenced by it, and its CONTENT is warned about
     (version literals, host addresses, expired statements, stale-open register citations).
  7. Every R-nnn cited in a CLAUDE.md or a .claude/rules/*.md that is CALLED OPEN really is open.

WHY 7 EXISTS (2026-08-06). Four instruction files asserted that CI was still owed, citing R-168,
four days after it closed — and one of the four contradicted itself, its release section already
saying R-168 mails the failure. A trim is a VOLUME operation: it does not validate content, and the
sentence survived because it read as settled. This class, at least, is mechanical.

  - THE TRIGGER IS AN OPENNESS CLAIM, NOT ANY CITATION. Requiring every mention of a non-open item
    to carry "CLOSED" would fire on ~30 legitimate provenance citations — "(R-161)", "R-117 spike
    §6.3" — which cite an item as the SOURCE OF A FACT, not as outstanding work. A gate that noisy
    is switched off, which is the fate R-29 documented.
  - THE EXEMPTION IS SCOPED TO THE CITATION'S CLAUSE. A line-wide test pardoned a stale claim
    because the words "shipped" appeared elsewhere on the same line, in a title and a filename.
  - A CITATION OF AN ITEM IN NEITHER REGISTER FAILS. A reference to nothing cannot be checked by
    anyone, which is worse than a stale one.
  - THE STATE MARKER IS NOT SELF-CLOSING. Real rows read `**SHIPPED — and the alarm is
    DEMONSTRATED**`. An earlier parser required `**SHIPPED**` and therefore read R-168 — the row
    this check exists for — as open. A gate that cannot convict its own founding case is decoration.

WHY 6 EXISTS, AND WHY ITS THREE OUTCOMES DIFFER (2026-08-06).

MEMORY.md is the LARGER half of what loads before a word is typed: measured at the workspace root,
the hand-written root CLAUDE.md was 6.6k tokens and MEMORY.md 8.4k. It is also the one instruction
file nobody hand-edits — Claude writes it — so nothing was watching it.

  - OVER THE LIMIT IS A FAILURE. Content past the auto-memory limit is dropped with NO error. A
    silent truncation of the index is the failure mode with no observable at all, which is exactly
    the class this project keeps getting bitten by.
  - AN ORPHAN IS A WARNING, NOT A FAILURE. The store changes between sessions and lives outside git;
    a hard fail would block pushes for something no commit can fix. On 2026-08-06 the index
    referenced 113 files while 157 existed — 44 held knowledge nothing would ever read.
  - AN ABSENT STORE PASSES, AND SAYS SO OUT LOUD. This is a DELIBERATE exception to check 1's
    "a missing input is a FAILURE, never a skip": the store is machine-local by design and a clone
    on any other host legitimately has none. The reason is printed in the tally so an absent store
    can never be mistaken for a silent skip — which is the only thing that made the exception safe
    to grant.

THE POSITIVE OBSERVABLE. Every root prints a per-check tally with the measured numbers, not just a
verdict. "0 failures" alone cannot tell a working gate from a blind one — if the effective line
count suddenly reads 3, the stripper broke, and the count is where you see it.

The kill condition is pinned by scripts/test_instructions_gate.py: an over-length CLAUDE.md still
FAILS, and a rule file with neither marker still FAILS.
"""
import os
import re
import sys

MAX_LINES = 200

# The auto-memory index. Limits are the store's own, not this project's taste: content past them is
# dropped with no error.
MEMORY_DIRNAME = ".claude-memory"
MEMORY_INDEX = "MEMORY.md"
MEMORY_ARCHIVE = "archive"
MAX_MEMORY_LINES = 200
MAX_MEMORY_BYTES = 25 * 1024

# Block-level HTML comments: stripped before injection, so they cost nothing and are not counted.
COMMENT_RE = re.compile(r"<!--.*?-->", re.S)

# A semver-ish literal that is NOT part of a dotted quad (IPv4) and not part of a longer run.
VERSION_RE = re.compile(r"(?<![\d.])\d+\.\d+\.\d+(?![\d.])")

# "TEMPORARY" anywhere on a line, plus an ISO date somewhere in that block.
TEMPORARY_RE = re.compile(r"TEMPORARY")
ISO_DATE_RE = re.compile(r"(\d{4})-(\d{2})-(\d{2})")

WORKSPACE_COPY = os.path.join(
    "felhom.eu", "documentation", "runbooks", "workspace-CLAUDE.md"
)


def effective(text):
    """The text the model actually receives: HTML comments removed."""
    return COMMENT_RE.sub("", text)


def today_tuple():
    """Local date as (y, m, d). Injectable via FELHOM_GATE_TODAY for the test suite."""
    override = os.environ.get("FELHOM_GATE_TODAY")
    if override:
        m = ISO_DATE_RE.match(override.strip())
        if m:
            return tuple(int(g) for g in m.groups())
    import datetime

    d = datetime.date.today()
    return (d.year, d.month, d.day)


def check_length(path, failures, tally):
    with io_open(path) as fh:
        eff = effective(fh.read())
    n = eff.count("\n")
    tally.append("  CLAUDE.md effective lines : %d (ceiling %d)" % (n, MAX_LINES))
    if n > MAX_LINES:
        failures.append(
            "%s: %d effective lines, ceiling %d. This is an ADHERENCE limit, not a space "
            "limit — long instruction files get followed less reliably and hide "
            "contradictions. Move path-bound guidance into .claude/rules/*.md with a `paths:` "
            "list, procedures into the skill that already covers them, and earned rationale "
            "into an HTML comment (free: stripped before injection)."
            % (path, n, MAX_LINES)
        )


def check_rules(root, failures, tally):
    rules_dir = os.path.join(root, ".claude", "rules")
    if not os.path.isdir(rules_dir):
        tally.append("  rule files                : none (no .claude/rules/)")
        return
    names = sorted(n for n in os.listdir(rules_dir) if n.endswith(".md"))
    scoped = 0
    for name in names:
        path = os.path.join(rules_dir, name)
        with io_open(path) as fh:
            head = fh.read(2048)
        has_paths = re.search(r"^paths:", head, re.M) is not None
        has_uncond = re.search(r"^unconditional:\s*true\s*$", head, re.M) is not None
        if has_paths:
            scoped += 1
        elif not has_uncond:
            failures.append(
                "%s: rule file has neither a `paths:` frontmatter list nor an explicit "
                "`unconditional: true`. Without `paths:` Claude Code loads it in EVERY session, "
                "which is rarely what a rule file is for — add the globs it applies to, or "
                "declare `unconditional: true` to say the always-loading is deliberate." % path
            )
    tally.append(
        "  rule files                : %d (%d path-scoped)" % (len(names), scoped)
    )


def check_versions(path, failures, tally):
    with io_open(path) as fh:
        eff = effective(fh.read())
    hits = []
    for i, line in enumerate(eff.split("\n"), 1):
        for m in VERSION_RE.finditer(line):
            hits.append((i, m.group(), line.strip()[:90]))
    tally.append("  version literals          : %d" % len(hits))
    for lineno, ver, ctx in hits:
        failures.append(
            "%s:%d: component version literal %r — versions change several times a day and the "
            "fleet is not uniform, so this is stale within a day. Ask the hub (/hosts, /configs) "
            "or the box. A historical citation belongs in an HTML comment beside the rule it "
            "justifies.\n      %s" % (path, lineno, ver, ctx)
        )


def check_temporary(path, failures, tally):
    with io_open(path) as fh:
        eff = effective(fh.read())
    lines = eff.split("\n")
    today = today_tuple()
    found = 0
    for i, line in enumerate(lines, 1):
        if not TEMPORARY_RE.search(line):
            continue
        found += 1
        window = "\n".join(lines[i - 1 : i + 6])
        for m in ISO_DATE_RE.finditer(window):
            when = tuple(int(g) for g in m.groups())
            if when < today:
                failures.append(
                    "%s:%d: TEMPORARY block carrying the past date %s. A temporary block that "
                    "outlives its own deadline is read as current fact — this gate exists "
                    "because one did, for four days, while a sibling repo asserted the "
                    "opposite. Delete it; the audit trail is the record."
                    % (path, i, m.group())
                )
                break
    tally.append("  TEMPORARY blocks          : %d" % found)


def check_workspace_copy(workspace_root, failures, tally):
    """Check 5 — the live workspace CLAUDE.md agrees with its versioned copy.

    TWO LEGAL SHAPES, and the check asserts a different thing in each:

      LINK  (this workspace since 2026-08-06) — the live file IS the versioned file. Divergence is
            not possible, so there is nothing to compare; what can still break is the LINK, so that
            is what is asserted: it points at the versioned copy and resolves to a real file. A
            dangling link is worse than a diverged copy — the workspace instructions stop loading
            entirely, and nothing else would say so.
      COPY  (any other clone) — two real files, asserted byte-identical as before.

    The link shape is not forced on anyone: a clone that has two files is checked as two files.
    """
    live = os.path.join(workspace_root, "CLAUDE.md")
    copy = os.path.join(workspace_root, WORKSPACE_COPY)

    if os.path.islink(live):
        target = os.readlink(live)
        resolved = os.path.realpath(live)
        expected = os.path.realpath(copy)
        ok_target = resolved == expected
        ok_real = os.path.isfile(resolved)
        tally.append(
            "  workspace file            : SYMLINK -> %s (%s)"
            % (target, "resolves to the versioned copy" if ok_target and ok_real else "BROKEN")
        )
        if not ok_real:
            failures.append(
                "%s is a symlink to %s, which does not resolve to a real file. A dangling link "
                "means the workspace instructions load NOTHING — worse than two files that "
                "disagree, because there is no content to notice is wrong." % (live, target)
            )
        elif not ok_target:
            failures.append(
                "%s is a symlink, but it resolves to %s instead of the versioned copy %s. The "
                "whole point of the link is that there is exactly one file; pointing it elsewhere "
                "reintroduces the divergence it removed." % (live, resolved, expected)
            )
        return

    if not os.path.exists(live) or not os.path.exists(copy):
        tally.append("  workspace copy            : n/a (not this workspace)")
        return
    with io_open(live) as a, io_open(copy) as b:
        same = a.read() == b.read()
    tally.append("  workspace copy identical  : %s (two-file shape)" % ("yes" if same else "NO"))
    if not same:
        failures.append(
            "%s and %s have diverged. The live workspace file sits in a directory that is not a "
            "git repo, so the copy is its only version-controlled record — nothing but this "
            "check enforces that they agree. Copy the live file over the versioned one, or make "
            "the live file a symlink to it (see scripts/install_workspace.py --link)."
            % (live, copy)
        )


def memory_referenced(index_text):
    """Basenames the index points at, via [](x.md) links and [[wikilink]]s alike.

    Both forms count as a reference. A file reachable only by a wikilink is indexed — it just is not
    a listed entry — and calling it an orphan would push someone to add a duplicate row for it.
    """
    md = re.findall(r"\]\(([^)]+\.md)\)", index_text)
    wiki = re.findall(r"\[\[([^\]]+)\]\]", index_text)
    names = set(os.path.basename(x) for x in md)
    for w in wiki:
        names.add(os.path.basename(w if w.endswith(".md") else w + ".md"))
    return names


def check_memory(workspace_root, failures, warnings, tally):
    store = os.path.join(workspace_root, MEMORY_DIRNAME)
    if not os.path.isdir(store):
        # DELIBERATE exception to "a missing input is a FAILURE" — see the module docstring. The
        # reason is printed so this can never read as a silent skip.
        tally.append(
            "  memory index             : absent (%s/ not on this host — the auto-memory store is "
            "machine-local by design, so a clone elsewhere legitimately has none; PASS with reason)"
            % MEMORY_DIRNAME
        )
        return

    index = os.path.join(store, MEMORY_INDEX)
    if not os.path.exists(index):
        failures.append(
            "%s: the memory store exists but has no %s. An un-indexed store is unreachable "
            "knowledge — every topic file is read on demand, and the index is the only thing that "
            "names them." % (store, MEMORY_INDEX)
        )
        tally.append("  memory index             : MISSING")
        return

    with io_open(index) as fh:
        text = fh.read()
    nlines = text.count("\n")
    nbytes = len(text.encode("utf-8"))
    tally.append(
        "  memory index             : %d lines (ceiling %d), %d bytes (ceiling %d)"
        % (nlines, MAX_MEMORY_LINES, nbytes, MAX_MEMORY_BYTES)
    )

    if nlines > MAX_MEMORY_LINES:
        failures.append(
            "%s: %d lines, ceiling %d. Content past the auto-memory limit is DROPPED WITH NO "
            "ERROR — a truncated index is a silent failure with no observable. Move detail out of "
            "the index into the topic files it points at; the index is what loads, the topic files "
            "are read on demand." % (index, nlines, MAX_MEMORY_LINES)
        )
    if nbytes > MAX_MEMORY_BYTES:
        failures.append(
            "%s: %d bytes, ceiling %d. Same reason as the line ceiling — silent truncation. Move "
            "detail into topic files rather than dropping entries."
            % (index, nbytes, MAX_MEMORY_BYTES)
        )

    # --- content classes, as WARNINGS (see memory_content_warnings for why not failures) ---
    cw = memory_content_warnings(text, today_tuple())
    tally.append(
        "  memory index content     : %d version literal(s), %d host address(es), %d expired "
        "statement(s)  [WARN only]"
        % (len(cw["versions"]), len(cw["addresses"]), len(cw["expired"]))
    )
    # Fourth class, beyond the three the CLAUDE.md files are failed on: a stale-open register
    # citation. Added because TWO of the three false statements found in this file on 2026-08-06
    # were exactly this ("R-193 decision open", "OPEN R-25b" — both closed), and both were found by
    # hand. Check 7 polices the hand-written files for it; nothing policed this one.
    reg = register_state(workspace_root)
    if reg:
        stale_cites = []
        for lineno, raw in enumerate(text.split("\n"), 1):
            for item in sorted(set(CITE_RE.findall(raw))):
                cl = clause_around(raw, item)
                if not (OPENNESS_RE.search(cl) and not SAYS_CLOSED_RE.search(cl)):
                    continue
                if reg.get(item, "missing") != "open":
                    stale_cites.append((lineno, item, raw.strip()[:90]))
        if stale_cites:
            tally.append("  memory stale citations   : %d  [WARN only]" % len(stale_cites))
        for lineno, item, ctx in stale_cites[:6]:
            warnings.append(
                "%s:%d: calls %s open, but the register does not.\n      %s"
                % (index, lineno, item, ctx)
            )

    for kind, label, why in (
        ("expired", "EXPIRED statement", "it is read as current fact"),
        ("versions", "version literal", "the fleet is not uniform, so it is stale within a day"),
        ("addresses", "host address", "operations/nodes.md is the single home for these"),
    ):
        for lineno, hit, ctx in cw[kind][:6]:
            warnings.append(
                "%s:%d: %s %r — %s. Not a failure: Claude writes this file between sessions, so "
                "this warning is aimed at the model that will next edit it, not at whoever is "
                "pushing.\n      %s" % (index, lineno, label, hit, why, ctx)
            )
        if len(cw[kind]) > 6:
            warnings.append(
                "%s: ... and %d more %s(s) — full list from the tally counts above."
                % (index, len(cw[kind]) - 6, label)
            )

    referenced = memory_referenced(text)
    on_disk = set(
        n for n in os.listdir(store)
        if n.endswith(".md") and n != MEMORY_INDEX
        and os.path.isfile(os.path.join(store, n))
    )
    orphans = sorted(on_disk - referenced)
    archived = 0
    arc = os.path.join(store, MEMORY_ARCHIVE)
    if os.path.isdir(arc):
        archived = len([n for n in os.listdir(arc) if n.endswith(".md")])
    tally.append(
        "  memory topic files       : %d indexed, %d orphaned, %d archived"
        % (len(on_disk) - len(orphans), len(orphans), archived)
    )
    if orphans:
        # WARN, never FAIL: the store is outside git and changes between sessions, so a failure
        # here would block pushes for something no commit can fix.
        warnings.append(
            "%s: %d top-level topic file(s) are not referenced by %s, so nothing will ever read "
            "them: %s%s"
            % (
                store,
                len(orphans),
                MEMORY_INDEX,
                ", ".join(orphans[:8]),
                (" (+%d more)" % (len(orphans) - 8)) if len(orphans) > 8 else "",
            )
        )


# A date inside a markdown link TARGET is a filename, not a claim: `[x](vacation-...-2026-07-20.md)`.
# Matching those produced THREE false "expired statement" findings on 2026-08-06 — every one of the
# three reported that day — while missing the one real expired claim in the same file, whose deadline
# was written `~08-02` and carried no ISO date at all. A scan that reports the wrong three and misses
# the right one is worse than no scan.
LINK_TARGET_RE = re.compile(r"\]\([^)]*\)")
# Deadline language, including the bare MM-DD form the real one used.
DEADLINE_RE = re.compile(
    r"\b(till|until|by)\s+~?\s*(\d{4}-\d{2}-\d{2}|\d{2}-\d{2})\b", re.I
)
LOOPBACK = ("127.0.0.1", "0.0.0.0", "255.255.255.255")
IPV4_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")


def memory_content_warnings(text, today):
    """The three content classes the CLAUDE.md files are FAILED on, applied to the index as WARNINGS.

    WARN and not FAIL, deliberately: Claude writes this file between sessions, so a hard failure
    would refuse a human's push over a line no human typed. The warning IS the mechanism — it is read
    by the same model that writes the file, which makes the loop self-correcting rather than a chore
    for a person.
    """
    out = {"versions": [], "addresses": [], "expired": []}
    for i, raw in enumerate(text.split("\n"), 1):
        # strip link targets before ANY content judgement — a filename is not an assertion
        line = LINK_TARGET_RE.sub("]()", raw)

        for m in VERSION_RE.finditer(line):
            out["versions"].append((i, m.group(), raw.strip()[:90]))
        for m in IPV4_RE.finditer(line):
            if m.group() not in LOOPBACK:
                out["addresses"].append((i, m.group(), raw.strip()[:90]))
        for m in DEADLINE_RE.finditer(line):
            token = m.group(2)
            if len(token) == 5:  # MM-DD — assume the current year, which is how it was written
                when = (today[0], int(token[:2]), int(token[3:]))
            else:
                when = tuple(int(x) for x in token.split("-"))
            if when < today:
                out["expired"].append((i, m.group(0), raw.strip()[:90]))
    return out


def register_state(workspace_root):
    """Map R-nnn -> 'open' | 'closed' | 'historical' | 'missing'.

    OPEN-ITEMS.md is the authority on what is OPEN and says so in its own header. An item is open
    when it OWNS A ROW there whose State cell is not a closed marker. An item with no row of its own
    is not open work — it may still be mentioned inside another row, and it is usually in ROADMAP.md,
    which keeps the full history. Anything in neither file is a reference to nothing.
    """
    base = os.path.join(workspace_root, "felhom.eu", "documentation", "backlog")
    reg = os.path.join(base, "OPEN-ITEMS.md")
    hist = os.path.join(base, "ROADMAP.md")
    if not os.path.exists(reg):
        return None

    with io_open(reg) as fh:
        reg_text = fh.read()
    hist_text = ""
    if os.path.exists(hist):
        with io_open(hist) as fh:
            hist_text = fh.read()

    state = {}
    for line in reg_text.split("\n"):
        m = re.match(r"\|\s*\*\*(R-\d+[a-z]?)\*\*\s*\|", line)
        if not m:
            continue
        cells = [c.strip() for c in line.rstrip().strip("|").split("|")]
        # The State column is index 2 in BOTH table shapes here (`ID|What|State` and
        # `ID|What|State|Blocked on|Next action|Owner`). Scan from there rather than taking the
        # last cell: a row whose prose contains a `|` splits into extra cells and would otherwise
        # be read from the wrong one.
        #
        # THE MARKER IS NOT SELF-CLOSING. Real rows read `**SHIPPED — and the alarm is
        # DEMONSTRATED**`, not `**SHIPPED**`. An earlier version of this parser required the
        # closing `**` immediately after the word and therefore read R-168 — the row this whole
        # check exists for — as OPEN. A gate that cannot convict its own founding case is decoration.
        state[m.group(1)] = "open"
        for cell in cells[2:]:
            if not cell.startswith("**"):
                continue
            head = cell[: cell.find("**", 2) + 2] if cell.find("**", 2) > 0 else cell
            if re.search(r"\b(CLOSED|SHIPPED|DONE|COMPLETE)\b", head) and not re.search(
                r"\bopen\b", head, re.I
            ):
                state[m.group(1)] = "closed"
            break

    mentioned = set(re.findall(r"\bR-\d+[a-z]?\b", reg_text)) | set(
        re.findall(r"\bR-\d+[a-z]?\b", hist_text)
    )
    for item in mentioned:
        state.setdefault(item, "historical")
    return state


# Language that CLAIMS an item is still outstanding. The check fires on these, not on every
# citation — see check 7's rationale in the module docstring.
OPENNESS_RE = re.compile(
    r"\b(still owed|is owed|are owed|still open|stays open|remains open|still pending|"
    r"pending\b|still needed|not yet|awaiting|awaits|outstanding|unresolved|to be done|"
    r"OPEN\s+R-\d)", re.I
)
SAYS_CLOSED_RE = re.compile(r"\b(CLOSED|DONE|SHIPPED|RESOLVED|COMPLETE)\b", re.I)
CITE_RE = re.compile(r"\bR-\d+[a-z]?\b")
# Clause boundaries. The exemption is scoped to the clause holding the citation, NOT the whole line:
# `- [Customer RESET shipped](…) — …; OPEN R-25b` contains "shipped" twice, in a title and a
# filename, and a line-wide exemption pardoned the stale claim at the end of it. Found by this
# check's own red-proof, which failed to go red.
CLAUSE_SPLIT_RE = re.compile(r"[;.]\s+|\s+—\s+|\s+\|\s+")


def clause_around(line, item):
    """The clause of `line` containing `item` — what "the same sentence" means for the exemption."""
    for part in CLAUSE_SPLIT_RE.split(line):
        if re.search(r"\b%s\b" % re.escape(item), part):
            return part
    return line


def check_citations(root, workspace_root, failures, warnings, tally):
    """Check 7 — a citation that calls a register item OPEN must be right.

    Scope: <root>/CLAUDE.md and <root>/.claude/rules/*.md. Effective text only, so a citation
    parked in an HTML comment for history is not policed.
    """
    state = register_state(workspace_root)
    if state is None:
        tally.append("  register citations       : n/a (no OPEN-ITEMS.md — not this workspace)")
        return

    targets = []
    claude_md = os.path.join(root, "CLAUDE.md")
    if os.path.exists(claude_md):
        targets.append(claude_md)
    rules_dir = os.path.join(root, ".claude", "rules")
    if os.path.isdir(rules_dir):
        targets += [
            os.path.join(rules_dir, n) for n in sorted(os.listdir(rules_dir)) if n.endswith(".md")
        ]

    cited = 0
    for path in targets:
        with io_open(path) as fh:
            eff = effective(fh.read())
        for lineno, line in enumerate(eff.split("\n"), 1):
            items = CITE_RE.findall(line)
            if not items:
                continue
            cited += len(items)
            for item in sorted(set(items)):
                cl = clause_around(line, item)
                claims_open = bool(OPENNESS_RE.search(cl)) and not SAYS_CLOSED_RE.search(cl)
                st = state.get(item, "missing")
                if st == "missing":
                    failures.append(
                        "%s:%d: cites %s, which appears in NEITHER OPEN-ITEMS.md nor ROADMAP.md. "
                        "A reference to nothing is worse than a stale one — it cannot be checked "
                        "by anyone.\n      %s" % (path, lineno, item, line.strip()[:110])
                    )
                elif claims_open and st != "open":
                    failures.append(
                        "%s:%d: claims %s is still open, but the register says it is %s. Four "
                        "instruction files asserted CI was still owed (R-168) four days after it "
                        "closed, and one contradicted itself about it — a trim is a volume "
                        "operation and does not validate content. Either drop the claim or say "
                        "CLOSED in the same sentence.\n      %s"
                        % (path, lineno, item, "not an open row" if st == "historical" else st,
                           line.strip()[:110])
                    )
    tally.append("  register citations       : %d cited, %d register items known" % (cited, len(state)))


def io_open(path):
    return open(path, "r", encoding="utf-8")


def main(argv):
    roots = [a for a in argv[1:] if a != "--fast"]
    if not roots:
        sys.stderr.write("usage: instructions_gate.py [--fast] <repo-root> ...\n")
        return 2

    failures = []
    warnings = []
    for root in roots:
        root = os.path.abspath(root)
        print("instructions_gate: %s" % root)
        tally = []

        claude_md = os.path.join(root, "CLAUDE.md")
        if not os.path.exists(claude_md):
            failures.append(
                "%s: no CLAUDE.md. A missing input is a FAILURE, never a skip — a gate that "
                "silently passes on an absent file is how the check stops running." % root
            )
        else:
            check_length(claude_md, failures, tally)
            check_versions(claude_md, failures, tally)
            check_temporary(claude_md, failures, tally)

        check_rules(root, failures, tally)
        check_workspace_copy(os.path.dirname(root), failures, tally)
        check_memory(os.path.dirname(root), failures, warnings, tally)
        check_citations(root, os.path.dirname(root), failures, warnings, tally)

        for line in tally:
            print(line)

    if warnings:
        print("")
        for w in warnings:
            print("WARNING: %s" % w)

    if failures:
        print("")
        print("instructions_gate: %d FAILURE(S)" % len(failures))
        for f in failures:
            print("  - %s" % f)
        return 1

    print("")
    print("instructions_gate: OK%s" % (" (%d warning(s))" % len(warnings) if warnings else ""))
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv))
