#!/usr/bin/env python3
"""rhc — the RH canon tool. The only machinery agents need.

Subcommands:
  claim KEY --mode M --summary S [--paths P...]   atomically claim work
  touch KEY                                       refresh a claim
  release KEY --outcome O                         release + log a claim
  list                                            show active claims
  reap                                            clear stale claims (>2h)
  reconcile [KEY]                                audit or close retained scouts
  preflight QUERY...                              advisory search of canon
  newid TYPE                                      atomically reserve a record id
  unreserve ID                                    abandon an unused record id
  check [FILES...] [--all]                        validate canon records
  registry                                        rebuild canon/registry.json
  state                                           rebuild STATE.md

Design: files + atomic directory creation, nothing else. A claim is a
directory under scratch/claims/ (mkdir is atomic on every filesystem).
Record IDs use the same mechanism under scratch/id-reservations/. Preflight
is advisory. check validates only what you pass it (or --all); there is no
global freshness gate and no cross-agent serialization.
"""
import argparse
import datetime
import glob
import hashlib
import json
import os
import re
import socket
import sys
import tempfile
import time

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CLAIMS = os.path.join(ROOT, "scratch", "claims")
CLAIMS_LOG = os.path.join(ROOT, "scratch", "claims-log")
RESERVATIONS = os.path.join(ROOT, "scratch", "id-reservations")
CANON = os.path.join(ROOT, "canon")
SCRATCH_INFRA = {"claims", "claims-log", "id-reservations"}
RELEASE_EVENT = "claim-release"
RECONCILIATION_EVENT = "scout-reconciliation"
STALE_SECONDS = 2 * 3600
BLIND_SCOUT_CAP = 2
BLIND_SCOUT_STALE_SECONDS = 3600
TYPE_PREFIX = {
    "obstruction": "O",
    "claim": "C",
    "construction": "K",
    "source": "S",
}
TYPE_DIR = {"obstruction": "obstructions", "claim": "claims",
            "construction": "constructions", "source": "sources"}
STATUS_VALUES = {
    "obstruction": {"binding", "superseded"},
    "claim": {"proved-unconditional", "proved-conditional-on-RH",
              "proved-conditional-other", "refuted", "open"},
    "construction": {"available", "superseded"},
    "source": {"published", "preprint", "superseded"},
}
REQUIRED = ["id", "type", "title", "status", "family", "statement", "sources"]
TOKEN_STOPWORDS = {
    "the", "a", "an", "of", "for", "and", "or", "with", "is", "to", "in",
    "on", "by", "its", "this", "that", "rh", "zeta", "riemann",
}


def now_utc():
    return datetime.datetime.now(datetime.timezone.utc).strftime(
        "%Y-%m-%dT%H:%M:%SZ")


def tokenize(text):
    terms = (
        set(re.findall(r"[a-z0-9]+", text.lower()))
        - TOKEN_STOPWORDS
    )
    expanded = set(terms)
    for term in terms:
        if (
            len(term) > 4
            and term.endswith("s")
            and not term.endswith(("ss", "us"))
        ):
            expanded.add(term[:-1])
    return expanded


def semantic_overlap(left, right):
    """Return overlap coefficient and shared terms for two route summaries."""
    left_terms = tokenize(left)
    right_terms = tokenize(right)
    if not left_terms or not right_terms:
        return 0.0, set()
    shared = left_terms & right_terms
    return len(shared) / min(len(left_terms), len(right_terms)), shared


def atomic_write_text(path, text):
    """Replace a generated text file without exposing a partial write."""
    directory = os.path.dirname(path)
    os.makedirs(directory, exist_ok=True)
    fd, temporary = tempfile.mkstemp(
        dir=directory,
        prefix=f".{os.path.basename(path)}.",
    )
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            f.write(text)
        os.replace(temporary, path)
    finally:
        try:
            os.remove(temporary)
        except FileNotFoundError:
            pass


def atomic_write_json(path, value):
    atomic_write_text(path, json.dumps(value, indent=2) + "\n")


def exclusive_write_json(path, value):
    data = (json.dumps(value, indent=2) + "\n").encode()
    fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
    try:
        os.write(fd, data)
        os.fsync(fd)
    finally:
        os.close(fd)


def key_to_dir(key):
    return os.path.join(CLAIMS, key.replace("/", "--"))


def dir_to_key(d):
    return os.path.basename(d).replace("--", "/")


def load_families():
    with open(os.path.join(CANON, "families.json")) as f:
        return json.load(f)["families"]


def read_claim(d):
    try:
        with open(os.path.join(d, "claim.json")) as f:
            c = json.load(f)
        c["_dir"] = d
        c["_age"] = time.time() - os.path.getmtime(os.path.join(d, "claim.json"))
        return c
    except (OSError, ValueError):
        return None


def active_claims():
    out = []
    for d in sorted(glob.glob(os.path.join(CLAIMS, "*"))):
        if os.path.isdir(d):
            c = read_claim(d)
            if c:
                out.append(c)
    return out


def overlaps(a, b):
    return a == b or a.startswith(b + "/") or b.startswith(a + "/")


def cmd_claim(args):
    key = args.key.strip("/")
    fams = load_families()
    fam = key.split("/")[0]
    if fam in fams:
        st = fams[fam]["status"]
        if st == "terminated-equivalent" and args.byproduct:
            print(f"note: family '{fam}' is terminated-equivalent; claim "
                  "admitted ONLY under the byproduct exemption.")
            print(f"  declared ladder-independent lemma: {args.byproduct}")
            print("  The rung itself has zero route value. The product of "
                  "this claim is the standalone lemma, which must be "
                  "promotable to adjacent-unconditional without mentioning "
                  "this ladder. If the lemma evaporates, release with "
                  "outcome 'abandoned' — do not keep the rung.")
        elif st.startswith("terminated") or st == "closed":
            print(f"REFUSED: family '{fam}' is {st}.")
            print(f"  rule: {fams[fam]['rule']}")
            print("  A terminated family may only be reopened by a new canon "
                  "record changing its status. Pick different work.")
            if st == "terminated-equivalent":
                print("  Exception: a rung whose proof REQUIRES a new "
                      "ladder-independent lemma (a standalone "
                      "adjacent-unconditional statement) may be claimed with "
                      "--byproduct \"<the lemma>\". See FAIL-FAST.md, "
                      "byproduct exemption.")
            return 1
        if st == "probation":
            print(f"note: family '{fam}' is on probation — work is allowed "
                  f"ONLY on its declared terminal obligation:")
            print(f"  {fams[fam]['terminal_theorem']}")
    if args.mode == "blind-scout":
        open_scouts = [c for c in active_claims()
                       if c.get("mode") == "blind-scout"
                       and c["_age"] < BLIND_SCOUT_STALE_SECONDS]
        if len(open_scouts) >= BLIND_SCOUT_CAP:
            print(f"REFUSED: {len(open_scouts)} blind-scout claim(s) already "
                  f"open (repo-wide cap {BLIND_SCOUT_CAP}). Do gated work "
                  "instead, or wait for a scout to release.")
            return 1
        print("note: blind-scout claim. One session, NO canon writes, NO "
              "object construction. The only permitted deliverables are a "
              "valid scout contract (release: completed) or a failure "
              "fingerprint (release: abandoned). See FAIL-FAST.md, "
              "blind-scout mode.")
    for c in active_claims():
        if overlaps(key, c["key"]) and c["_age"] < STALE_SECONDS:
            print(f"REFUSED: overlaps fresh claim '{c['key']}' "
                  f"({c['owner']}, {int(c['_age']//60)}m old): {c['summary']}")
            print("  Do not rename the same work to evade this. "
                  "Pick a genuinely different obligation.")
            return 1
        if overlaps(key, c["key"]):
            print(f"note: replacing stale claim '{c['key']}' "
                  f"({int(c['_age']//3600)}h old)")
            _log_claim(c, "reaped")
            continue
        score, shared = semantic_overlap(args.summary, c.get("summary", ""))
        if score >= 0.55 and len(shared) >= 4:
            terms = ", ".join(sorted(shared)[:10])
            print(
                f"WARNING: semantic overlap {score:.2f} with active claim "
                f"'{c['key']}' ({c['owner']}); shared terms: {terms}"
            )
            print(
                "  Confirm the obligations are genuinely distinct. "
                "The key-level lock cannot decide this automatically."
            )
    claim = {
        "key": key, "mode": args.mode, "summary": args.summary,
        "owner": args.owner or f"{socket.gethostname()}-{os.getpid()}",
        "paths": args.paths or [], "started": now_utc(), "family": fam,
        "claim_id": (
            f"{socket.gethostname()}-{os.getpid()}-{time.time_ns()}"
        ),
    }
    if args.byproduct:
        claim["byproduct"] = args.byproduct
    d = key_to_dir(key)
    try:
        os.makedirs(d, exist_ok=False)
    except FileExistsError:
        previous = latest_claim_event(key)
        if not previous or previous.get("outcome") != "parked":
            print(f"REFUSED: claim directory already exists for '{key}' "
                  "(simultaneous claim). Pick different work.")
            return 1
    try:
        exclusive_write_json(os.path.join(d, "claim.json"), claim)
    except FileExistsError:
        print(f"REFUSED: claim directory already contains a live claim for "
              f"'{key}' (simultaneous claim). Pick different work.")
        return 1
    print(f"claimed: {key}")
    cmd_board()
    return 0


def cmd_touch(args):
    p = os.path.join(key_to_dir(args.key.strip("/")), "claim.json")
    if not os.path.exists(p):
        print(f"no such claim: {args.key}")
        return 1
    os.utime(p)
    print(f"refreshed: {args.key}")
    return 0


def claim_log_events():
    events = []
    for path in glob.glob(os.path.join(CLAIMS_LOG, "*.json")):
        try:
            with open(path, encoding="utf-8") as f:
                event = json.load(f)
        except (OSError, ValueError):
            continue
        event["_path"] = path
        events.append(event)
    return events


def is_release_event(event):
    return (
        event.get("event_type", RELEASE_EVENT) == RELEASE_EVENT
        and bool(event.get("outcome"))
    )


def latest_claim_event(key):
    latest = None
    for event in claim_log_events():
        if not is_release_event(event):
            continue
        if event.get("key") != key:
            continue
        if latest is None or event.get("ended", "") > latest.get("ended", ""):
            latest = event
    return latest


def latest_reconciliation_event(key, claim_id):
    latest = None
    for event in claim_log_events():
        if event.get("event_type") != RECONCILIATION_EVENT:
            continue
        if event.get("key") != key or event.get("claim_id") != claim_id:
            continue
        if latest is None or event.get("created", "") > latest.get("created", ""):
            latest = event
    return latest


def _receipt_metadata(path):
    absolute = (
        os.path.abspath(path)
        if os.path.isabs(path)
        else os.path.abspath(os.path.join(ROOT, path))
    )
    if not os.path.isfile(absolute):
        raise FileNotFoundError(f"receipt does not exist: {path}")
    if os.path.getsize(absolute) == 0:
        raise ValueError(f"receipt is empty: {path}")
    digest = hashlib.sha256()
    with open(absolute, "rb") as f:
        for chunk in iter(lambda: f.read(1024 * 1024), b""):
            digest.update(chunk)
    try:
        display = os.path.relpath(absolute, ROOT)
        if display.startswith(".."):
            display = absolute
    except ValueError:
        display = absolute
    return {"path": display, "sha256": digest.hexdigest()}


def _claim_event_path(event):
    stamp = re.sub(r"[^0-9TZ]", "", event["ended"])
    safe = event["key"].replace("/", "--")
    claim_id = event.get("claim_id") or "legacy"
    return os.path.join(
        CLAIMS_LOG,
        f"{stamp}-{safe}-{claim_id}.json",
    )


def _reconciliation_event_path(event):
    stamp = re.sub(r"[^0-9TZ]", "", event["created"])
    safe = event["key"].replace("/", "--")
    claim_id = event.get("claim_id") or "legacy"
    event_id = event["reconciliation_id"].replace("/", "-")
    return os.path.join(
        CLAIMS_LOG,
        f"{stamp}-{safe}-{claim_id}-reconcile-{event_id}.json",
    )


def _path_is_within(path, directory):
    try:
        return os.path.commonpath(
            [os.path.abspath(path), os.path.abspath(directory)]
        ) == os.path.abspath(directory)
    except ValueError:
        return False


def _substantive_scout_files(directory):
    files = []
    if not os.path.isdir(directory):
        return files
    for root, dirs, names in os.walk(directory):
        dirs[:] = [
            name for name in dirs
            if name not in {"__pycache__", ".git"}
        ]
        for name in names:
            if name == ".DS_Store" or name.endswith(".pyc"):
                continue
            files.append(os.path.join(root, name))
    return files


def _event_scratch_paths(event):
    scratch_root = os.path.join(ROOT, "scratch")
    paths = []
    for value in event.get("paths") or []:
        absolute = (
            os.path.abspath(value)
            if os.path.isabs(value)
            else os.path.abspath(os.path.join(ROOT, value))
        )
        if absolute == scratch_root or not _path_is_within(
            absolute, scratch_root
        ):
            continue
        relative = os.path.relpath(absolute, scratch_root)
        if relative.split(os.sep, 1)[0] in SCRATCH_INFRA:
            continue
        if os.path.isdir(absolute):
            paths.append(absolute)
    return paths


def scout_paths_for(key, event=None):
    scratch_root = os.path.join(ROOT, "scratch")
    candidates = [
        os.path.join(scratch_root, key.replace("/", "--")),
        os.path.join(scratch_root, *key.split("/")),
    ]
    if event:
        candidates.extend(_event_scratch_paths(event))
    output = []
    for path in candidates:
        absolute = os.path.abspath(path)
        if absolute in output or not _substantive_scout_files(absolute):
            continue
        output.append(absolute)
    return output


def _release_events_by_key(events):
    releases = {}
    for event in events:
        if not is_release_event(event):
            continue
        key = event.get("key")
        if not key:
            continue
        previous = releases.get(key)
        if previous is None or event.get("ended", "") > previous.get(
            "ended", ""
        ):
            releases[key] = event
    return releases


def discover_retained_scouts(active, releases):
    scratch_root = os.path.join(ROOT, "scratch")
    discovered = {}
    claimed_paths = {}
    events = {**releases, **active}
    for key, event in events.items():
        paths = scout_paths_for(key, event)
        discovered[key] = paths
        for path in paths:
            claimed_paths[path] = key

    family_names = set()
    try:
        family_names.update(load_families())
    except (OSError, ValueError):
        pass
    family_names.update(key.split("/", 1)[0] for key in events)

    if not os.path.isdir(scratch_root):
        return discovered, []

    orphans = []
    for name in sorted(os.listdir(scratch_root)):
        path = os.path.join(scratch_root, name)
        if not os.path.isdir(path) or name in SCRATCH_INFRA:
            continue
        if name in family_names:
            for route in sorted(os.listdir(path)):
                route_path = os.path.join(path, route)
                if not os.path.isdir(route_path):
                    continue
                key = f"{name}/{route}"
                if not _substantive_scout_files(route_path):
                    continue
                if route_path in claimed_paths:
                    continue
                discovered.setdefault(key, []).append(route_path)
                claimed_paths[route_path] = key
            continue
        if "--" in name:
            key = name.replace("--", "/")
            if _substantive_scout_files(path) and path not in claimed_paths:
                discovered.setdefault(key, []).append(path)
                claimed_paths[path] = key
            continue
        if _substantive_scout_files(path) and path not in claimed_paths:
            orphans.append(path)
    return discovered, orphans


def _receipt_check(metadata):
    if not metadata:
        return False, "missing receipt"
    path = metadata.get("path")
    expected = metadata.get("sha256")
    if not path or not expected:
        return False, "malformed receipt metadata"
    try:
        current = _receipt_metadata(path)
    except (FileNotFoundError, ValueError) as error:
        return False, str(error)
    if current["sha256"] != expected:
        return False, (
            f"receipt hash drift: {path} expected {expected} "
            f"got {current['sha256']}"
        )
    return True, path


def _effective_receipt(release):
    release_receipt = release.get("receipt")
    if release_receipt:
        valid, _ = _receipt_check(release_receipt)
        if valid:
            return release_receipt, "release"
    claim_id = release.get("claim_id") or "legacy"
    reconciliation = latest_reconciliation_event(
        release["key"], claim_id
    )
    if reconciliation and reconciliation.get("receipt"):
        return reconciliation["receipt"], "reconciliation"
    if release_receipt:
        return release_receipt, "release"
    return None, None


def audit_scouts():
    events = claim_log_events()
    active = {claim["key"]: claim for claim in active_claims()}
    releases = _release_events_by_key(events)
    discovered, orphans = discover_retained_scouts(active, releases)
    rows = []
    issues = []

    for key in sorted(set(discovered) | set(active) | set(releases)):
        paths = discovered.get(key, [])
        if key in active:
            claim = active[key]
            stale = claim.get("_age", 0) > STALE_SECONDS
            rows.append({
                "key": key,
                "state": "stale-active" if stale else "active",
                "paths": paths,
            })
            if stale:
                issues.append({
                    "key": key,
                    "code": "stale-active",
                    "detail": "active claim is older than the stale threshold",
                })
            continue

        release = releases.get(key)
        if not release:
            for path in paths:
                issues.append({
                    "key": key,
                    "code": "orphan",
                    "detail": os.path.relpath(path, ROOT),
                })
            continue

        receipt, source = _effective_receipt(release)
        valid, detail = _receipt_check(receipt)
        requires_receipt = bool(paths) or release.get("outcome") == "parked"
        if requires_receipt and not valid:
            issues.append({
                "key": key,
                "code": "unreconciled",
                "detail": detail,
            })
        elif valid and paths:
            receipt_path = receipt["path"]
            receipt_absolute = (
                receipt_path
                if os.path.isabs(receipt_path)
                else os.path.join(ROOT, receipt_path)
            )
            if not any(
                _path_is_within(receipt_absolute, path) for path in paths
            ):
                issues.append({
                    "key": key,
                    "code": "external-receipt",
                    "detail": (
                        f"{receipt_path} is outside retained scout paths"
                    ),
                })
        rows.append({
            "key": key,
            "state": release.get("outcome", "released"),
            "paths": paths,
            "receipt": receipt.get("path") if valid else None,
            "receipt_source": source,
        })

    for path in orphans:
        issues.append({
            "key": None,
            "code": "orphan",
            "detail": os.path.relpath(path, ROOT),
        })
    return rows, issues


def _receipt_inside_scout(path, scout_paths):
    absolute = (
        os.path.abspath(path)
        if os.path.isabs(path)
        else os.path.abspath(os.path.join(ROOT, path))
    )
    return any(_path_is_within(absolute, directory) for directory in scout_paths)


def _result_closeout_error(path, outcome):
    absolute = (
        os.path.abspath(path)
        if os.path.isabs(path)
        else os.path.abspath(os.path.join(ROOT, path))
    )
    try:
        with open(absolute, encoding="utf-8") as f:
            text = f.read()
    except OSError as error:
        return str(error)
    outcome_match = re.search(
        r"(?im)^outcome:\s*([a-z-]+)\s*$",
        text,
    )
    if not outcome_match:
        return "RESULT.md must contain an 'Outcome: <value>' line"
    if outcome_match.group(1) != outcome:
        return (
            f"RESULT.md outcome '{outcome_match.group(1)}' does not match "
            f"release outcome '{outcome}'"
        )
    if not re.search(r"(?im)^result:\s*\S", text):
        return "RESULT.md must contain a nonempty 'Result:' line"
    if outcome == "parked" and not re.search(
        r"(?im)^resume when:\s*\S", text
    ):
        return "parked RESULT.md must contain a nonempty 'Resume when:' line"
    return None


def _log_claim(c, outcome, *, released_by=None, checks=None, receipt=None):
    """Atomically close a claim and write an immutable release receipt."""
    os.makedirs(CLAIMS_LOG, exist_ok=True)
    receipt_metadata = _receipt_metadata(receipt) if receipt else None
    d = c.get("_dir") or key_to_dir(c["key"])
    claim_path = os.path.join(d, "claim.json")
    lock = f"{d}.closing"
    try:
        os.mkdir(lock)
    except (FileExistsError, FileNotFoundError):
        return None

    try:
        if not os.path.isfile(claim_path):
            return None

        event = {k: v for k, v in c.items() if not k.startswith("_")}
        event["event_type"] = RELEASE_EVENT
        event["outcome"] = outcome
        event["ended"] = now_utc()
        event["released_by"] = (
            released_by or f"{socket.gethostname()}-{os.getpid()}"
        )
        if checks:
            event["checks"] = list(checks)
        if receipt_metadata:
            event["receipt"] = receipt_metadata
        if not event.get("claim_id"):
            event["claim_id"] = f"legacy-{time.time_ns()}"
        atomic_write_json(_claim_event_path(event), event)

        try:
            os.remove(claim_path)
            os.rmdir(d)
        except OSError:
            pass
        return event
    finally:
        os.rmdir(lock)


def cmd_reconcile(args):
    if args.key:
        if args.strict:
            print("--strict is only valid for the repository-wide audit")
            return 1
        key = args.key.strip("/")
        if not args.receipt:
            print("reconciling one scout requires --receipt")
            return 1
        if any(claim["key"] == key for claim in active_claims()):
            print(
                f"REFUSED: '{key}' is active; attach the receipt with "
                "rhc release instead."
            )
            return 1
        release = latest_claim_event(key)
        if not release:
            print(f"no released claim to reconcile: {key}")
            return 1
        try:
            receipt = _receipt_metadata(args.receipt)
        except (FileNotFoundError, ValueError) as error:
            print(str(error))
            return 1
        scout_paths = scout_paths_for(key, release)
        if scout_paths and not _receipt_inside_scout(
            args.receipt, scout_paths
        ):
            print(
                "REFUSED: reconciliation receipt must live inside a "
                "retained scout directory:"
            )
            for path in scout_paths:
                print(f"  - {os.path.relpath(path, ROOT)}")
            return 1
        existing, source = _effective_receipt(release)
        valid, _ = _receipt_check(existing)
        if valid:
            if existing == receipt:
                print(
                    f"already reconciled: {key} "
                    f"({source}: {receipt['path']})"
                )
                return 0
            if source == "release":
                print(
                    "REFUSED: the immutable release already pins a "
                    "different valid receipt."
                )
                return 1

        event = {
            "event_type": RECONCILIATION_EVENT,
            "key": key,
            "claim_id": release.get("claim_id") or "legacy",
            "outcome": release.get("outcome"),
            "created": now_utc(),
            "reconciled_by": (
                args.owner or f"{socket.gethostname()}-{os.getpid()}"
            ),
            "reconciliation_id": (
                f"{socket.gethostname()}-{os.getpid()}-{time.time_ns()}"
            ),
            "claim_receipt": _receipt_metadata(release["_path"]),
            "receipt": receipt,
        }
        if args.checks:
            event["checks"] = list(args.checks)
        if args.note:
            event["note"] = args.note
        path = _reconciliation_event_path(event)
        atomic_write_json(path, event)
        print(f"reconciled: {key}")
        print(f"receipt: {os.path.relpath(path, ROOT)}")
        return 0

    if args.receipt or args.checks or args.note or args.owner:
        print("--receipt/--check/--note/--owner require a claim key")
        return 1
    rows, issues = audit_scouts()
    retained = sum(bool(row.get("paths")) for row in rows)
    active = sum(row["state"] in {"active", "stale-active"} for row in rows)
    reconciled = sum(bool(row.get("receipt")) for row in rows)
    print("# scout reconciliation")
    print(
        f"active={active} retained={retained} "
        f"reconciled={reconciled} unresolved={len(issues)}"
    )
    for row in rows:
        if not row.get("paths") and not row.get("receipt"):
            continue
        paths = ",".join(
            os.path.relpath(path, ROOT) for path in row.get("paths", [])
        ) or "-"
        receipt = row.get("receipt") or "-"
        print(
            f"[{row['state']}] {row['key']} "
            f"paths={paths} receipt={receipt}"
        )
    for issue in issues:
        key = issue.get("key") or "(unknown)"
        print(
            f"UNRESOLVED {issue['code']}: {key}: {issue['detail']}"
        )
    return 1 if args.strict and issues else 0


def cmd_release(args):
    key = args.key.strip("/")
    d = key_to_dir(key)
    c = read_claim(d)
    if not c:
        previous = latest_claim_event(key)
        if previous:
            print(
                f"already released: {key} "
                f"({previous.get('outcome')} at {previous.get('ended')} by "
                f"{previous.get('released_by', 'unknown')})"
            )
            return 0
        print(f"no such claim: {key}")
        return 1
    if args.owner and args.owner != c.get("owner") and not args.force:
        print(
            f"REFUSED: claim owner is '{c.get('owner')}', not "
            f"'{args.owner}'. Use --force only for an audited takeover."
        )
        return 1
    scout_paths = scout_paths_for(key, c)
    receipt_required = args.outcome == "parked" or bool(scout_paths)
    if receipt_required and not args.receipt:
        target = (
            os.path.join(scout_paths[0], "RESULT.md")
            if scout_paths
            else os.path.join(
                ROOT, "scratch", *key.split("/"), "RESULT.md"
            )
        )
        print(
            "REFUSED: retained or parked scouts require an immutable "
            "closeout receipt."
        )
        print(f"  write: {os.path.relpath(target, ROOT)}")
        print(
            "  release with: --receipt "
            f"{os.path.relpath(target, ROOT)}"
        )
        return 1
    if args.receipt:
        if os.path.basename(args.receipt) != "RESULT.md":
            print("REFUSED: new scout closeouts must be named RESULT.md")
            return 1
        try:
            _receipt_metadata(args.receipt)
        except (FileNotFoundError, ValueError) as error:
            print(str(error))
            return 1
        closeout_error = _result_closeout_error(
            args.receipt, args.outcome
        )
        if closeout_error:
            print(f"REFUSED: {closeout_error}")
            return 1
        if scout_paths and not _receipt_inside_scout(
            args.receipt, scout_paths
        ):
            print(
                "REFUSED: RESULT.md must live inside the retained scout "
                "directory."
            )
            return 1
    try:
        event = _log_claim(
            c,
            args.outcome,
            released_by=(
                args.owner or f"{socket.gethostname()}-{os.getpid()}"
            ),
            checks=args.checks,
            receipt=args.receipt,
        )
    except (FileNotFoundError, ValueError) as error:
        print(str(error))
        return 1
    if event is None:
        previous = latest_claim_event(key)
        if previous:
            print(
                f"already released: {key} "
                f"({previous.get('outcome')} at {previous.get('ended')} by "
                f"{previous.get('released_by', 'unknown')})"
            )
            return 0
        print(f"claim close raced without a durable receipt: {key}")
        return 1
    print(f"released: {key} ({args.outcome})")
    print(
        "receipt: "
        f"{os.path.relpath(_claim_event_path(event), ROOT)}"
    )
    cmd_board()
    return 0


def parked_claims():
    """Latest log entry per key where the outcome is parked and the receipt
    still exists — resumable sieges, not history."""
    latest = {}
    for path in sorted(glob.glob(os.path.join(CLAIMS_LOG, "*.json"))):
        try:
            with open(path) as f:
                c = json.load(f)
        except (OSError, ValueError):
            continue
        if c.get("key"):
            latest[c["key"]] = c
    active = {c["key"] for c in active_claims()}
    out = []
    for key, c in sorted(latest.items()):
        if c.get("outcome") != "parked" or key in active:
            continue
        r = c.get("receipt")
        if isinstance(r, dict):
            r = r.get("path")
        if r and os.path.exists(os.path.join(ROOT, r)):
            out.append((key, c.get("summary", ""), r))
    return out


def cmd_list(args=None):
    cs = active_claims()
    if not cs:
        print("no active claims")
    for c in cs:
        stale = " [STALE]" if c["_age"] > STALE_SECONDS else ""
        print(f"{c['key']}  ({c['mode']}, {c['owner']}, "
              f"{int(c['_age']//60)}m){stale}\n    {c['summary']}")
    parked = parked_claims()
    if parked:
        print("\nPARKED work with receipts (resume if relevant to your goal — "
              "read the receipt first; a parked siege is often "
              "higher-leverage than a fresh route):")
        for key, summary, receipt in parked:
            print(f"  {key}\n    receipt: {receipt}\n    {summary[:160]}")
    print("\nChoose your own work: explore STATE.md, canon, and any parked "
          "receipts, then claim the most effective and interesting "
          "obligation for your goal. Nothing is assigned; judgment is "
          "yours.")
    return 0


def cmd_reap(args=None):
    claims_reaped = 0
    for c in active_claims():
        if c["_age"] > STALE_SECONDS:
            _log_claim(c, "reaped")
            claims_reaped += 1
    reservations_reaped = 0
    for reservation in active_id_reservations():
        if reservation["_age"] > STALE_SECONDS \
                or record_id_exists(reservation["id"]):
            reservations_reaped += release_id_reservation(
                reservation["id"]
            )
    print(
        f"reaped {claims_reaped} stale claim(s), "
        f"{reservations_reaped} stale/finalized id reservation(s)"
    )
    cmd_board()
    return 0


def cmd_board(args=None):
    lines = ["# Active claims", "",
             "Generated by `bin/rhc.py`. Do not edit by hand.", ""]
    cs = active_claims()
    if cs:
        lines += ["| Key | Mode | Owner | Age | Summary |", "|---|---|---|---|---|"]
        for c in cs:
            lines.append(f"| `{c['key']}` | {c['mode']} | {c['owner']} | "
                         f"{int(c['_age']//60)}m | {c['summary']} |")
    else:
        lines.append("(none)")
    atomic_write_text(
        os.path.join(ROOT, "CLAIMS.md"),
        "\n".join(lines) + "\n",
    )
    return 0


# ---------- canon records ----------

def parse_frontmatter(path):
    """Minimal YAML frontmatter parser for the schema's flat subset."""
    with open(path, encoding="utf-8") as f:
        text = f.read()
    if not text.startswith("---"):
        return None, "missing frontmatter"
    end = text.find("\n---", 3)
    if end < 0:
        return None, "unterminated frontmatter"
    fm, cur_key, cur_val = {}, None, None
    for raw in text[3:end].splitlines():
        if not raw.strip() or raw.strip().startswith("#"):
            continue
        if raw.startswith(("  ", "\t")) and cur_key:
            item = raw.strip()
            if item.startswith("- "):
                if not isinstance(fm.get(cur_key), list):
                    fm[cur_key] = []
                fm[cur_key].append(item[2:].strip().strip('"'))
            else:
                fm[cur_key] = (fm.get(cur_key) or "") + " " + item
            continue
        m = re.match(r"^([A-Za-z_]+):\s*(.*)$", raw)
        if not m:
            continue
        cur_key, val = m.group(1), m.group(2).strip()
        if val in (">", "|", ">-", "|-"):
            fm[cur_key] = ""
        elif val.startswith("[") and val.endswith("]"):
            inner = val[1:-1].strip()
            fm[cur_key] = ([x.strip().strip('"').strip("'")
                            for x in inner.split(",") if x.strip()]
                           if inner else [])
        else:
            fm[cur_key] = val.strip('"')
    for k in ("statement", "scope", "witness"):
        if k in fm and isinstance(fm[k], str):
            fm[k] = fm[k].strip()
    return fm, None


def all_record_paths():
    out = []
    for sub in TYPE_DIR.values():
        out += sorted(glob.glob(os.path.join(CANON, sub, "*.md")))
    return out


def rh_resolution_contract_errors(path, fm):
    """Validate the exceptional metadata contract for a five-star claim."""
    errors = []
    score = str(fm.get("interestingness") or "")
    marker = fm.get("resolves_rh")
    has_marker = marker not in (None, "", [])

    if fm.get("type") != "claim":
        if has_marker:
            errors.append(
                f"{path}: 'resolves_rh' is allowed only on five-star claims"
            )
        return errors

    if score == "5":
        if marker != "true":
            errors.append(
                f"{path}: claim interestingness 5 requires exact "
                "'resolves_rh: true'"
            )
        if fm.get("status") != "proved-unconditional":
            errors.append(
                f"{path}: claim interestingness 5 requires status "
                "'proved-unconditional'"
            )
        if fm.get("family") != "head-on":
            errors.append(
                f"{path}: claim interestingness 5 requires family 'head-on'"
            )
    elif has_marker:
        errors.append(
            f"{path}: 'resolves_rh' is reserved for claim interestingness 5"
        )
    return errors


def declared_verifier_paths(fm):
    """Return every non-empty verifier path declared by a canon record."""
    paths = fm.get("verifiers") or []
    if isinstance(paths, str):
        paths = [paths]
    else:
        paths = list(paths)
    verifier = fm.get("verifier")
    if verifier not in (None, "", "none"):
        paths.append(verifier)
    return list(dict.fromkeys(path for path in paths if path not in ("", "none")))


def declared_verifier_errors(path, fm):
    """Reject declared verifier paths that are not regular files."""
    errors = []
    for verifier in declared_verifier_paths(fm):
        absolute = (
            verifier
            if os.path.isabs(verifier)
            else os.path.join(ROOT, verifier)
        )
        if not os.path.exists(absolute):
            errors.append(
                f"{path}: declared verifier '{verifier}' does not exist"
            )
        elif not os.path.isfile(absolute):
            errors.append(
                f"{path}: declared verifier '{verifier}' is not a regular file"
            )
    return errors


def record_numbers(record_type):
    prefix = TYPE_PREFIX[record_type]
    numbers = set()
    for path in glob.glob(
        os.path.join(CANON, TYPE_DIR[record_type], "*.md")
    ):
        match = re.match(rf"^{prefix}-(\d+)", os.path.basename(path))
        if match:
            numbers.add(int(match.group(1)))
    return numbers


def reservation_dir(record_id):
    return os.path.join(RESERVATIONS, record_id)


def active_id_reservations():
    out = []
    for path in sorted(glob.glob(os.path.join(RESERVATIONS, "*"))):
        if not os.path.isdir(path):
            continue
        record_id = os.path.basename(path)
        match = re.match(r"^([OCKS])-(\d{4,})$", record_id)
        if not match:
            continue
        metadata = {}
        try:
            with open(
                os.path.join(path, "reservation.json"),
                encoding="utf-8",
            ) as f:
                metadata = json.load(f)
        except (OSError, ValueError):
            pass
        metadata["id"] = record_id
        metadata["_dir"] = path
        metadata["_age"] = time.time() - os.path.getmtime(path)
        out.append(metadata)
    return out


def reservation_numbers(record_type):
    prefix = TYPE_PREFIX[record_type]
    return {
        int(reservation["id"].split("-", 1)[1])
        for reservation in active_id_reservations()
        if reservation["id"].startswith(prefix + "-")
    }


def record_id_exists(record_id):
    prefix = record_id.split("-", 1)[0]
    record_type = next(
        (
            candidate
            for candidate, candidate_prefix in TYPE_PREFIX.items()
            if candidate_prefix == prefix
        ),
        None,
    )
    if record_type is None:
        return False
    pattern = os.path.join(
        CANON,
        TYPE_DIR[record_type],
        f"{record_id}-*.md",
    )
    exact = os.path.join(
        CANON,
        TYPE_DIR[record_type],
        f"{record_id}.md",
    )
    return os.path.exists(exact) or bool(glob.glob(pattern))


def reserve_next_id(record_type, owner=None):
    """Reserve the next ID using mkdir as the cross-process atomic step."""
    os.makedirs(RESERVATIONS, exist_ok=True)
    occupied = record_numbers(record_type) | reservation_numbers(record_type)
    number = max(occupied, default=0) + 1
    prefix = TYPE_PREFIX[record_type]
    while True:
        record_id = f"{prefix}-{number:04d}"
        path = reservation_dir(record_id)
        try:
            os.mkdir(path)
        except FileExistsError:
            number += 1
            continue

        # Cover a legacy agent creating a record between the initial scan
        # and our atomic reservation.
        if record_id_exists(record_id):
            os.rmdir(path)
            number += 1
            continue

        atomic_write_json(
            os.path.join(path, "reservation.json"),
            {
                "id": record_id,
                "type": record_type,
                "owner": owner or f"{socket.gethostname()}-{os.getpid()}",
                "created": now_utc(),
            },
        )
        return record_id


def release_id_reservation(record_id):
    path = reservation_dir(record_id)
    if not os.path.isdir(path):
        return 0
    try:
        os.remove(os.path.join(path, "reservation.json"))
    except FileNotFoundError:
        pass
    try:
        os.rmdir(path)
    except OSError:
        return 0
    return 1


def cmd_newid(args):
    print(reserve_next_id(args.type, owner=args.owner))
    return 0


def cmd_unreserve(args):
    if not re.match(r"^[OCKS]-\d{4,}$", args.id):
        print(f"malformed record id: {args.id}")
        return 1
    if not release_id_reservation(args.id):
        print(f"no active reservation: {args.id}")
        return 1
    print(f"released id reservation: {args.id}")
    return 0


def check_record(path, fams, seen_ids):
    errs = []
    fm, err = parse_frontmatter(path)
    if err:
        return [f"{path}: {err}"]
    for k in REQUIRED:
        if k not in fm or fm[k] in ("", []):
            errs.append(f"{path}: missing required field '{k}'")
    t = fm.get("type")
    if t not in TYPE_DIR:
        errs.append(f"{path}: bad type '{t}'")
        return errs
    rid = fm.get("id", "")
    base = os.path.basename(path)
    if not base.startswith(rid):
        errs.append(f"{path}: filename does not start with id '{rid}'")
    if not re.match(rf"^{TYPE_PREFIX[t]}-\d{{4}}$", rid):
        errs.append(f"{path}: id '{rid}' malformed for type {t}")
    if rid in seen_ids:
        errs.append(f"{path}: duplicate id '{rid}' (also {seen_ids[rid]})")
    seen_ids[rid] = path
    if fm.get("status") not in STATUS_VALUES.get(t, set()):
        errs.append(f"{path}: bad status '{fm.get('status')}' for {t}")
    if fm.get("family") not in fams:
        errs.append(f"{path}: unknown family '{fm.get('family')}' "
                    "(declare it in canon/families.json)")
    if t == "claim":
        interestingness = fm.get("interestingness")
        if interestingness in (None, "", []):
            errs.append(f"{path}: claim missing 'interestingness'")
        elif not re.fullmatch(r"[1-5]", str(interestingness)):
            errs.append(
                f"{path}: claim interestingness must be an integer from 1 to 5"
            )
    errs += rh_resolution_contract_errors(path, fm)
    errs += declared_verifier_errors(path, fm)
    if fm.get("id") == "TBD" or "staging" in path:
        errs.append(f"{path}: staged record (id TBD or under staging/) — "
                    "not valid final canon")
    if t == "obstruction":
        if not fm.get("scope"):
            errs.append(f"{path}: obstruction missing 'scope'")
        vers = declared_verifier_paths(fm)
        if not fm.get("witness") and not vers:
            errs.append(f"{path}: obstruction needs inline witness or verifier")
    return errs


DISTILLED_ID = re.compile(r"^(R|X|D|J|CE)-\d+")


def cmd_check(args):
    fams = load_families()
    paths = (
        list(dict.fromkeys(os.path.abspath(path) for path in args.files))
        if args.files
        else all_record_paths()
    )
    seen = {}
    if args.files:  # uniqueness needs the full id map even for partial checks
        for p in all_record_paths():
            fm, err = parse_frontmatter(p)
            if fm and fm.get("id") and p not in paths:
                seen[fm["id"]] = p
    errs = []
    for p in paths:
        errs += check_record(p, fams, seen)
    # depends resolve against the full registry
    ids = set(seen)
    for p in all_record_paths():
        fm, _ = parse_frontmatter(p)
        if fm and fm.get("id"):
            ids.add(fm["id"])
    for p in paths:
        fm, _ = parse_frontmatter(p)
        if fm:
            refs = ((fm.get("depends") or []) + (fm.get("supersedes") or [])
                    + (fm.get("instantiates") or []))
            for dep in refs:
                if dep.startswith("slug:"):
                    errs.append(f"{p}: unresolved staging ref '{dep}'")
                elif dep not in ids and not DISTILLED_ID.match(dep):
                    errs.append(f"{p}: unresolved reference '{dep}'")
    if errs:
        print("\n".join(errs))
        print(f"\nFAIL: {len(errs)} problem(s) in {len(paths)} record(s)")
        return 1
    finalized = 0
    for path in paths:
        fm, _ = parse_frontmatter(path)
        if fm and fm.get("id"):
            finalized += release_id_reservation(fm["id"])
    print(f"OK: {len(paths)} record(s) valid")
    if finalized:
        print(f"finalized {finalized} id reservation(s)")
    return 0


def build_registry(write=True):
    fams = load_families()
    recs = []
    contract_errors = []
    for p in all_record_paths():
        fm, err = parse_frontmatter(p)
        if err or not fm:
            continue
        contract_errors += rh_resolution_contract_errors(p, fm)
        contract_errors += declared_verifier_errors(p, fm)
        interestingness = fm.get("interestingness")
        recs.append({
            "id": fm.get("id"), "type": fm.get("type"),
            "title": fm.get("title"), "status": fm.get("status"),
            "family": fm.get("family"), "statement": fm.get("statement", ""),
            "interestingness": (
                int(interestingness)
                if re.fullmatch(r"[1-5]", str(interestingness or ""))
                else None
            ),
            "scope": fm.get("scope", ""), "tags": fm.get("tags") or [],
            "aliases": fm.get("aliases") or [],
            "depends": fm.get("depends") or [],
            "path": os.path.relpath(p, ROOT),
        })
    reg = {"generated": now_utc(), "count": len(recs),
           "families": sorted(fams), "records": recs}
    if write:
        if contract_errors:
            raise ValueError("\n".join(contract_errors))
        atomic_write_text(
            os.path.join(CANON, "registry.json"),
            json.dumps(reg, indent=1) + "\n",
        )
    return reg


def cmd_registry(args=None):
    try:
        reg = build_registry()
    except ValueError as error:
        print(error)
        print("\nFAIL: registry not written")
        return 1
    print(f"registry: {reg['count']} records")
    return 0


def cmd_preflight(args):
    query = " ".join(args.query)
    qtok = tokenize(query)
    reg = build_registry(write=False)
    scored = []
    for r in reg["records"]:
        hay = tokenize(" ".join([r["title"] or "", r["statement"] or "",
                                 r["scope"] or "", " ".join(r["tags"]),
                                 " ".join(r["aliases"]),
                                 r["family"] or ""]))
        inter = qtok & hay
        if inter:
            scored.append((len(inter) / (len(qtok) or 1), r, inter))
    scored.sort(key=lambda x: -x[0])
    print(f"# preflight: {query}\n")
    fams = load_families()
    hit_fams = {r["family"] for _, r, _ in scored[:8]}
    for f in sorted(hit_fams):
        if f in fams:
            print(f"family {f}: {fams[f]['status']} — {fams[f]['rule'][:140]}")
    print()
    if not scored:
        print("no canon matches. If this is genuinely new, declare its family "
              "and state the terminal theorem before starting.")
    for score, r, inter in scored[:8]:
        line = (r["scope"] or r["statement"] or "")[:180].replace("\n", " ")
        interest = (
            f", interest {r['interestingness']}/5"
            if r["interestingness"] is not None
            else ""
        )
        print(f"[{score:.2f}] {r['id']} ({r['type']}, {r['status']}, "
              f"{r['family']}{interest}) {r['title']}\n       {line}")
    # estate matches: reusable machinery left by terminated families
    estate_hits = []
    for name in sorted(fams):
        for item in fams[name].get("estate", []):
            if qtok & tokenize(item):
                estate_hits.append((name, item))
    if estate_hits:
        print("\nESTATE (machinery from terminated families that may serve "
              "this mechanism — the route is dead, its tools are not):")
        for name, item in estate_hits[:6]:
            print(f"  - [{name}] {item}")
    # regex tripwires
    trip_path = os.path.join(CANON, "tripwires.json")
    if os.path.exists(trip_path):
        with open(trip_path) as f:
            trips = json.load(f)
        entries = trips if isinstance(trips, list) else \
            trips.get("routes", trips.get("entries", []))
        hits = []
        for e in entries:
            pat = e.get("pattern") or e.get("regex")
            if pat:
                try:
                    if re.search(pat, query, re.I):
                        hits.append(e)
                except re.error:
                    pass
        if hits:
            print("\nTRIPWIRES (hard: cite or refute before proceeding):")
            for e in hits[:6]:
                print(f"  - {e.get('why', e.get('reason', ''))[:200]}")
    print("\npreflight is advisory. It cannot detect a dead idea renamed into "
          "fresh vocabulary — that is your job.")
    return 0


def cmd_state(args=None):
    try:
        reg = build_registry()
    except ValueError as error:
        print(error)
        print("\nFAIL: STATE.md not written")
        return 1
    fams = load_families()
    recs = reg["records"]
    uncond = [r for r in recs if r["status"] == "proved-unconditional"]
    cond = [r for r in recs if r["status"].startswith("proved-conditional")]
    obs = [r for r in recs if r["type"] == "obstruction"
           and r["status"] == "binding"]
    open_claims = [r for r in recs if r["status"] == "open"]
    lines = [
        "# STATE — generated by `bin/rhc.py state`. Do not edit.",
        "",
        f"_{reg['generated']} — {reg['count']} canon records._",
        "",
        "## Scoreboard",
        "",
        "The ONLY progress metric is **unconditional statements new to the "
        "literature**. Everything else — equivalences, conditional results, "
        "kills — is recorded as cost, not output.",
        "",
        f"- Displacement (proved-unconditional claims): **{len(uncond)}**",
        f"- Binding obstructions (cost, and armor): {len(obs)}",
        f"- Conditional-on-RH results (cost): {len(cond)}",
        f"- Open claims: {len(open_claims)}",
        "- Interestingness (proved-unconditional): "
        + ", ".join(
            f"{score}/5: "
            f"{sum(1 for r in uncond if r['interestingness'] == score)}"
            for score in range(5, 0, -1)
        ),
        "",
        "## Unconditional results",
        "",
    ]
    for r in sorted(uncond, key=lambda r: r["id"]):
        lines.append(
            f"- **{r['id']}** [{r['interestingness']}/5] "
            f"{r['title']} (`{r['family']}`)"
        )
    if not uncond:
        lines.append("(none yet — that is the honest current state)")
    lines += ["", "## Families", ""]
    order = {"active": 0, "probation": 1, "terminated-equivalent": 2,
             "terminated-class": 2, "closed": 3}
    for name in sorted(fams, key=lambda n: (order.get(fams[n]["status"], 9), n)):
        f = fams[name]
        n_rec = sum(1 for r in recs if r["family"] == name)
        lines.append(f"### {name} — {f['status']} ({n_rec} records)")
        lines.append(f"_{f['title']}_")
        lines.append(f"- terminal: {f['terminal_theorem']}")
        lines.append(f"- rule: {f['rule']}")
        lines.append("")
    lines += ["## Open claims (frontier)", ""]
    for r in sorted(open_claims, key=lambda r: r["id"]):
        lines.append(f"- **{r['id']}** {r['title']} (`{r['family']}`) — "
                     f"{(r['statement'] or '')[:160]}")
    if not open_claims:
        lines.append("(none)")
    lines += ["", "## Active work", "",
              "See `CLAIMS.md` (generated by `bin/rhc.py`).", ""]
    atomic_write_text(
        os.path.join(ROOT, "STATE.md"),
        "\n".join(lines) + "\n",
    )
    print(f"STATE.md: {len(uncond)} unconditional, {len(obs)} obstructions, "
          f"{len(fams)} families")
    return 0


def main():
    ap = argparse.ArgumentParser(prog="rhc")
    sub = ap.add_subparsers(dest="cmd", required=True)
    c = sub.add_parser("claim")
    c.add_argument("key")
    c.add_argument("--mode", required=True,
                   choices=["regular", "moonshot", "blind-scout"])
    c.add_argument("--summary", required=True)
    c.add_argument("--paths", nargs="*")
    c.add_argument("--owner")
    c.add_argument("--byproduct",
                   help="ladder-independent lemma justifying a rung claim "
                        "into a terminated-equivalent family")
    t = sub.add_parser("touch"); t.add_argument("key")
    r = sub.add_parser("release"); r.add_argument("key")
    r.add_argument("--outcome", required=True,
                   choices=["completed", "abandoned", "parked", "superseded"])
    r.add_argument("--owner")
    r.add_argument("--force", action="store_true")
    r.add_argument("--receipt")
    r.add_argument(
        "--check",
        dest="checks",
        action="append",
        default=[],
        help="validation receipt line; repeat for multiple checks",
    )
    q = sub.add_parser("reconcile")
    q.add_argument("key", nargs="?")
    q.add_argument("--receipt")
    q.add_argument(
        "--check",
        dest="checks",
        action="append",
        default=[],
        help="validation receipt line; repeat for multiple checks",
    )
    q.add_argument("--note")
    q.add_argument("--owner")
    q.add_argument(
        "--strict",
        action="store_true",
        help="exit nonzero when the repository audit finds unresolved scouts",
    )
    sub.add_parser("list"); sub.add_parser("reap"); sub.add_parser("board")
    p = sub.add_parser("preflight"); p.add_argument("query", nargs="+")
    n = sub.add_parser("newid")
    n.add_argument("type", choices=TYPE_PREFIX)
    n.add_argument("--owner")
    u = sub.add_parser("unreserve")
    u.add_argument("id")
    k = sub.add_parser("check"); k.add_argument("files", nargs="*")
    k.add_argument("--all", action="store_true")
    sub.add_parser("registry"); sub.add_parser("state")
    args = ap.parse_args()
    fn = {"claim": cmd_claim, "touch": cmd_touch, "release": cmd_release,
          "reconcile": cmd_reconcile, "list": cmd_list,
          "reap": cmd_reap, "board": cmd_board,
          "preflight": cmd_preflight, "newid": cmd_newid,
          "unreserve": cmd_unreserve, "check": cmd_check,
          "registry": cmd_registry, "state": cmd_state}[args.cmd]
    sys.exit(fn(args))


if __name__ == "__main__":
    main()
