#!/usr/bin/env python3
"""Minervit AI Delivery Methodology CLI."""

from __future__ import annotations

import argparse
import base64
import difflib
import errno
import fnmatch
import hashlib
import json
import os
import posixpath
import re
import shutil
import shlex
import signal
import subprocess
import sys
import tarfile
import tempfile
import time
import webbrowser
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from html import escape
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import parse_qs, quote, urlparse
from urllib.request import Request, urlopen


REPO_ROOT = Path(__file__).resolve().parents[1]
SRC_ROOT = REPO_ROOT / "src"
if SRC_ROOT.is_dir():
    sys.path.insert(0, str(SRC_ROOT))

CANONICAL_RULES = REPO_ROOT / "methodology" / "canonical-rules.md"
ADAPTER_SCHEMA = REPO_ROOT / "methodology" / "adapter-schema.json"
PUBLIC_ADAPTER_SCHEMA_URL = (
    "https://raw.githubusercontent.com/tautlines/tautline/"
    "main/methodology/adapter-schema.json"
)
VERSION_FILE = REPO_ROOT / "VERSION"
PLUGIN_MANIFEST = REPO_ROOT / "plugins" / "tautline-core" / ".codex-plugin" / "plugin.json"
RELEASE_NOTES_ARCHIVE_PATH = "docs/releases/minervit-ai-delivery-methodology.md"
RELEASE_UPDATE_WEBHOOK_ENV = "MINERVIT_METHODOLOGY_RELEASE_GOOGLE_CHAT_WEBHOOK"
RELEASE_UPDATE_DELIVERY_FILE = REPO_ROOT / "docs" / "releases" / "release-update-delivery.json"
RELEASE_UPDATE_DELIVERY_SCHEMA = "minervit-release-update-delivery/v1"
METHODOLOGY_REPO_SLUG = "tautlines/tautline-dev"
# Pre-rebrand slug; unconverted machines/adapters still carry it (removal: METH-FU-TAUTLINE-FALLBACK-REMOVAL).
LEGACY_METHODOLOGY_REPO_SLUGS = ("minervit/minervit-ai-delivery-methodology",)
METHODOLOGY_MAIN_COMMIT_OVERRIDE_ENV = "MINERVIT_METHODOLOGY_ALLOW_MAIN_COMMIT"
USER_CONFIG_ENV = Path.home() / ".config" / "tautline" / "tautline.env"
USER_SECRETS_ENV = Path.home() / ".config" / "tautline" / "secrets.zsh"
# Pre-rebrand config surfaces; still written as compatibility mirrors and read as fallbacks so
# pre-0.9.2 launchers/hooks keep resolving (removal: METH-FU-TAUTLINE-FALLBACK-REMOVAL).
LEGACY_USER_CONFIG_ENV = Path.home() / ".config" / "minervit" / "methodology.env"
LEGACY_USER_SECRETS_ENV = Path.home() / ".config" / "minervit" / "secrets.zsh"


def resolve_user_config_env() -> Path:
    """The effective config env file: the tautline path once it exists, else the legacy path."""
    if USER_CONFIG_ENV.is_file():
        return USER_CONFIG_ENV
    if LEGACY_USER_CONFIG_ENV.is_file():
        return LEGACY_USER_CONFIG_ENV
    return USER_CONFIG_ENV


def brand_env_pairs(env: dict) -> dict:
    """Mirror each MINERVIT_* entry as its TAUTLINE_* alias (rebrand transition dual-write).

    Children read either family via resolve_env; dual-writing keeps mixed-version process
    trees consistent until METH-FU-TAUTLINE-FALLBACK-REMOVAL drops the legacy names.
    """
    for name in [key for key in env if isinstance(key, str) and key.startswith("MINERVIT_")]:
        # Overwrite, not setdefault (Codex 92a R1 P2): these writers own the MINERVIT_ value they
        # just set, so an inherited divergent TAUTLINE_ alias is stale by definition -- keeping it
        # would make resolve_env consumers disagree with legacy-name consumers in one process tree.
        env["TAUTLINE_" + name[len("MINERVIT_"):]] = env[name]
    return env


USER_BIN_DIR = Path.home() / ".local" / "bin"
MANAGED_USER_CONFIG_ENV_KEYS = {
    "MINERVIT_METHODOLOGY_REPO",
    "MINERVIT_CLAUDE_AUTOCOMPACT_PCT",
    "CLAUDE_AUTOCOMPACT_PCT_OVERRIDE",
    "MINERVIT_METHODOLOGY_UPDATE_POLICY",
    "MINERVIT_METHODOLOGY_UPDATE_PINS",
    # Snapshot-store keys: both alias spellings are managed so install-cli owns (and rewrites)
    # each one. A preserved stale MINERVIT_ export could otherwise shadow the fresh TAUTLINE_
    # alias that resolve_env prefers, pointing a converted machine at a dead store or checkout.
    "MINERVIT_METHODOLOGY_CANONICAL_REPO",
    "MINERVIT_METHODOLOGY_SNAPSHOT_STORE",
    "TAUTLINE_METHODOLOGY_REPO",
    "TAUTLINE_METHODOLOGY_CANONICAL_REPO",
    "TAUTLINE_METHODOLOGY_SNAPSHOT_STORE",
    "TAUTLINE_CLAUDE_AUTOCOMPACT_PCT",
    "PATH",
}
GENERATED_HEADER_TEMPLATE = "<!-- GENERATED -->\n"
# RuntimeTarget descriptors (prod-extensibility-3): the single place that maps a runtime/agent kind
# to its generated instruction file, replacing scattered hardcoded {CLAUDE.md, AGENTS.md}
# assumptions. Adding a runtime target is a one-line addition here (the render_adapter `agent` arg
# still selects per-runtime content; that content split is the deeper provider-interface work).
RUNTIME_TARGETS = [
    {"agent": "Claude", "outputFile": "CLAUDE.md", "runtime": "claude-code"},
    {"agent": "Codex", "outputFile": "AGENTS.md", "runtime": "codex"},
]
GENERATED_MARKDOWN_FILES = {target["outputFile"] for target in RUNTIME_TARGETS}
CLI_NAME = "tautline"
LEGACY_CLI_NAME = "minervit-methodology"
LANE_ADAPTER_FILE = ".tautline.json"
LEGACY_LANE_ADAPTER_FILE = ".minervit-ai-delivery.json"
REPO_LOCAL_ADAPTER_FILE = ".tautline/adapter.json"
LEGACY_REPO_LOCAL_ADAPTER_FILE = ".minervit/adapter.json"
FRAMEWORK_PIN_FILE = ".minervit/pin.json"
PUBLIC_CONTRACT_MANIFEST = REPO_ROOT / "methodology" / "public-contract-manifest.json"
PUBLIC_CONTRACT_MANIFEST_SCHEMA = "minervit-public-contract-manifest/v1"
POLICY_MODULES_DIR = REPO_ROOT / "methodology" / "policy"
POLICY_MODULES_MANIFEST = POLICY_MODULES_DIR / "manifest.json"
POLICY_MODULES_SCHEMA = "minervit-policy-modules/v1"
RELEASE_MIGRATION_REPORT_SCHEMA = "minervit-release-migration-report/v1"
PUBLIC_REFERENCE_ADAPTER_SLUG = "example-" + "saas"
PUBLIC_RELEASE_ALLOWED_SOURCE_ADAPTERS = {f"{PUBLIC_REFERENCE_ADAPTER_SLUG}.json"}
PUBLIC_RELEASE_PRIVATE_TERMS_ENV = "MINERVIT_PUBLIC_RELEASE_PRIVATE_TERMS"
PUBLIC_RELEASE_EXPORT_MARKER = ".minervit-public-release-export.json"
PUBLIC_RELEASE_EXPORT_MARKER_SCHEMA = "minervit-public-release-export/v1"
PUBLIC_RELEASE_PLACEHOLDER_ACCOUNT_IDS = {"000000000000", "111111111111", "123456789012"}
PUBLIC_RELEASE_MAX_ISSUES_PER_RULE = 25
RELEASE_UPDATE_DELIVERY_EXPORT_RELATIVE_PATH = "docs/releases/release-update-delivery.json"
PUBLIC_RELEASE_EXPORT_EXCLUDED_PREFIXES = (
    # The framework's own self-adapter files are maintainer-lane config that
    # references private planning conventions; the self-adapter gates skip in
    # export trees, so the public export does not need them. The export marker
    # (.minervit-public-release-export.json) matches neither prefix:
    # ".minervit/" requires the slash, and the marker filename does not start
    # with ".minervit-ai-delivery.json".
    # The public repository is a downstream mirror: `release-tail` overlays the
    # export tree onto the mirror's current HEAD and fast-forwards it (never a
    # force-push, never a history rewrite). Dependabot PRs opened there can
    # never merge and always fail the release-change contract, so dependency
    # intake stays in the private repo.
    ".github/dependabot.yml",
    ".minervit-ai-delivery.json",
    ".minervit/",
    ".tautline.json",
    ".tautline/",
    "docs/backlog/",
    "docs/productization/",
    # The release-update delivery ledger is an internal ops record of who was
    # notified about which release, and when. The rest of docs/releases/ is
    # public release material and still ships. Because the export tree cannot
    # carry the ledger, the release-update gate in public_release_issues() is
    # re-rooted to the source repository via release_update_root. NOTE: being
    # export-excluded ("don't ship it") is independent of being dirty-state-
    # excluded ("don't gate the export on its working-tree state") — see
    # public_release_dirty_state_path_included(), which deliberately does NOT
    # reuse this tuple for the ledger path.
    RELEASE_UPDATE_DELIVERY_EXPORT_RELATIVE_PATH,
    "docs/superpowers/",
)
PUBLIC_RELEASE_EXPORT_ALLOWED_PRODUCT_DOCS = (
    "docs/product/positioning.md",
    "docs/product/support-sla-model.md",
)
PUBLIC_RELEASE_PRIVATE_ADAPTER_PATH_RE = (
    r"adapters/projects/(?!"
    + re.escape(f"{PUBLIC_REFERENCE_ADAPTER_SLUG}.json")
    + r"\b)(?!\.)(?!\.validate-)(?!\.bootstrap-legacy-allowlist\.json\b)[A-Za-z0-9_.-]+\.json"
)
FRAMEWORK_CHANNELS = {"stable", "experimental", "methodology-dev"}
CLIENT_FRAMEWORK_CHANNELS = {"stable", "experimental"}
FRAMEWORK_CHANNEL_BRANCHES = {"stable": "main", "experimental": "experimental"}
FRAMEWORK_UPDATE_POLICIES = {"manual", "patch-auto", "minor-at-boundary"}
FRAMEWORK_MIGRATION_POLICIES = {"dry-run", "auto-apply-safe", "manual-only"}
DEFAULT_FRAMEWORK_PIN = {
    "channel": "stable",
    "version": "",
    "updatePolicy": "manual",
    "migrationPolicy": "dry-run",
}
_RELEASES_MODULE = None


_ADAPTER_MODULE = None


def adapter_module():
    global _ADAPTER_MODULE
    if _ADAPTER_MODULE is None:
        try:
            from minervit_methodology import adapter
        except ModuleNotFoundError as exc:
            raise SystemExit(
                "adapter helpers require the framework checkout's src/minervit_methodology "
                "package; run this command from a full checkout instead of a standalone copy of "
                "bin/tautline"
            ) from exc

        _ADAPTER_MODULE = adapter
    return _ADAPTER_MODULE


def releases_module():
    global _RELEASES_MODULE
    if _RELEASES_MODULE is None:
        try:
            from minervit_methodology import releases
        except ModuleNotFoundError as exc:
            raise SystemExit(
                "release metadata helpers require the framework checkout's src/minervit_methodology "
                "package; run this command from a full checkout instead of a standalone copy of "
                "bin/tautline"
            ) from exc

        _RELEASES_MODULE = releases
    return _RELEASES_MODULE


_UTIL_MODULE = None


def util_module():
    global _UTIL_MODULE
    if _UTIL_MODULE is None:
        try:
            from minervit_methodology import util
        except ModuleNotFoundError as exc:
            raise SystemExit(
                "utility helpers require the framework checkout's src/minervit_methodology "
                "package; run this command from a full checkout instead of a standalone copy of "
                "bin/tautline"
            ) from exc

        _UTIL_MODULE = util
    return _UTIL_MODULE


_GITUTIL_MODULE = None


def gitutil_module():
    global _GITUTIL_MODULE
    if _GITUTIL_MODULE is None:
        try:
            from minervit_methodology import gitutil
        except ModuleNotFoundError as exc:
            raise SystemExit(
                "git helpers require the framework checkout's src/minervit_methodology "
                "package; run this command from a full checkout instead of a standalone copy of "
                "bin/tautline"
            ) from exc

        _GITUTIL_MODULE = gitutil
    return _GITUTIL_MODULE


_PATHS_MODULE = None


def paths_module():
    global _PATHS_MODULE
    if _PATHS_MODULE is None:
        try:
            from minervit_methodology import paths
        except ModuleNotFoundError as exc:
            raise SystemExit(
                "path helpers require the framework checkout's src/minervit_methodology "
                "package; run this command from a full checkout instead of a standalone copy of "
                "bin/tautline"
            ) from exc

        _PATHS_MODULE = paths
    return _PATHS_MODULE


_POLICY_MODULE = None


def policy_module():
    global _POLICY_MODULE
    if _POLICY_MODULE is None:
        try:
            from minervit_methodology import policy
        except ModuleNotFoundError as exc:
            raise SystemExit(
                "policy helpers require the framework checkout's src/minervit_methodology "
                "package; run this command from a full checkout instead of a standalone copy of "
                "bin/tautline"
            ) from exc

        _POLICY_MODULE = policy
    return _POLICY_MODULE


_GUARDS_MODULE = None


def guards_module():
    global _GUARDS_MODULE
    if _GUARDS_MODULE is None:
        try:
            from minervit_methodology import guards
        except ModuleNotFoundError as exc:
            raise SystemExit(
                "guard helpers require the framework checkout's src/minervit_methodology "
                "package; run this command from a full checkout instead of a standalone copy of "
                "bin/tautline"
            ) from exc

        _GUARDS_MODULE = guards
    return _GUARDS_MODULE


_PROFILES_MODULE = None


def profiles_module():
    global _PROFILES_MODULE
    if _PROFILES_MODULE is None:
        try:
            from minervit_methodology import profiles
        except ModuleNotFoundError as exc:
            raise SystemExit(
                "work profile helpers require the framework checkout's src/minervit_methodology "
                "package; run this command from a full checkout instead of a standalone copy of "
                "bin/tautline"
            ) from exc

        _PROFILES_MODULE = profiles
    return _PROFILES_MODULE


_TELEMETRY_MODULE = None


def telemetry_module():
    global _TELEMETRY_MODULE
    if _TELEMETRY_MODULE is None:
        try:
            from minervit_methodology import telemetry
        except ModuleNotFoundError as exc:
            raise SystemExit(
                "telemetry helpers require the framework checkout's src/minervit_methodology "
                "package; run this command from a full checkout instead of a standalone copy of "
                "bin/tautline"
            ) from exc

        _TELEMETRY_MODULE = telemetry
    return _TELEMETRY_MODULE


_NAMES_MODULE = None


def names_module():
    global _NAMES_MODULE
    if _NAMES_MODULE is None:
        try:
            from minervit_methodology import names
        except ModuleNotFoundError as exc:
            raise SystemExit(
                "name availability helpers require the framework checkout's src/minervit_methodology "
                "package; run this command from a full checkout instead of a standalone copy of "
                "bin/tautline"
            ) from exc

        _NAMES_MODULE = names
    return _NAMES_MODULE


_CHAT_MODULE = None


def chat_module():
    global _CHAT_MODULE
    if _CHAT_MODULE is None:
        try:
            from minervit_methodology import chat
        except ModuleNotFoundError as exc:
            raise SystemExit(
                "Google Chat payload helpers require the framework checkout's src/minervit_methodology "
                "package; run this command from a full checkout instead of a standalone copy of "
                "bin/tautline"
            ) from exc

        _CHAT_MODULE = chat
    return _CHAT_MODULE


_DEPLOY_MODULE = None


def deploy_module():
    global _DEPLOY_MODULE
    if _DEPLOY_MODULE is None:
        try:
            from minervit_methodology import deploy
        except ModuleNotFoundError as exc:
            raise SystemExit(
                "deployment helpers require the framework checkout's src/minervit_methodology "
                "package; run this command from a full checkout instead of a standalone copy of "
                "bin/tautline"
            ) from exc

        _DEPLOY_MODULE = deploy
    return _DEPLOY_MODULE


_GHUTIL_MODULE = None


def ghutil_module():
    global _GHUTIL_MODULE
    if _GHUTIL_MODULE is None:
        try:
            from minervit_methodology import ghutil
        except ModuleNotFoundError as exc:
            raise SystemExit(
                "GitHub provider helpers require the framework checkout's src/minervit_methodology "
                "package; run this command from a full checkout instead of a standalone copy of "
                "bin/tautline"
            ) from exc

        _GHUTIL_MODULE = ghutil
    return _GHUTIL_MODULE


# --- Execution root vs canonical checkout -------------------------------------------------
# REPO_ROOT answers "where am I executing from". canonical_methodology_repo() answers "which
# mutable git checkout does sync manage". They are the same directory on a dev checkout and on
# every unconverted machine; they diverge once a machine executes an immutable snapshot from the
# snapshot store, at which point every git WRITE must target the canonical checkout while every
# self-report must describe the snapshot actually running.
SNAPSHOT_MANIFEST_NAME = ".snapshot-meta.json"
SNAPSHOT_MANIFEST_SCHEMA = "tautline-snapshot/v1"
METHODOLOGY_CANONICAL_REPO_ENV = "MINERVIT_METHODOLOGY_CANONICAL_REPO"
METHODOLOGY_SNAPSHOT_STORE_ENV = "MINERVIT_METHODOLOGY_SNAPSHOT_STORE"
# The documented rollback lever (docs/reference/operations/release-engineering.md#rollback): take a
# machine back to executing the canonical checkout without uninstalling anything and without
# touching the store. It is reached for PRECISELY when the store has published a bad snapshot, so
# it has to beat EVERY path that would execute one -- the shim's exec ladder, the launcher, and the
# trust-gated advance below. A lever that only half-works is worse than none: the first upstream
# advance would otherwise re-exec the operator straight back into the snapshot they are escaping.
METHODOLOGY_DISABLE_SNAPSHOT_EXEC_ENV = "MINERVIT_METHODOLOGY_DISABLE_SNAPSHOT_EXEC"
# Written (never read) by this process: the post-update re-exec exports the tree it adopted so the
# launcher shim -- and every hook subprocess the re-exec'd session spawns -- executes that one tree
# for the whole session instead of independently re-resolving `store/current` mid-flight.
METHODOLOGY_EXEC_ROOT_ENV = "MINERVIT_METHODOLOGY_EXEC_ROOT"
# Machine-scoped maintainer-mode key. Despite the _ENV suffix convention it shares with its
# neighbours, this key is deliberately NOT an environment read: it lives ONLY in the installed
# config env file and is read ONLY from that file (see maintainer_mode_config_value).
METHODOLOGY_MAINTAINER_MODE_ENV = "MINERVIT_METHODOLOGY_MAINTAINER_MODE"
_CANONICAL_METHODOLOGY_REPO: Path | None = None
_CANONICAL_METHODOLOGY_REPO_ANCHOR: Path | None = None


def snapshot_manifest() -> dict | None:
    """Manifest of the immutable snapshot we execute from, or None on a git checkout.

    Deliberately tolerant about extra keys and strict about the two that carry meaning: an
    unreadable, non-JSON, foreign-schema, or commit-less manifest reads exactly like "not a
    snapshot", so a corrupt file degrades to the pre-snapshot behavior instead of raising.
    """
    try:
        raw = (REPO_ROOT / SNAPSHOT_MANIFEST_NAME).read_text(encoding="utf-8")
        data = json.loads(raw)
    except (OSError, json.JSONDecodeError):
        return None
    if not isinstance(data, dict) or data.get("schema") != SNAPSHOT_MANIFEST_SCHEMA:
        return None
    commit = data.get("commit")
    if not isinstance(commit, str) or len(commit) < 12:
        return None
    return data


def running_from_snapshot() -> bool:
    return snapshot_manifest() is not None


# Both package update channels, spoken together everywhere package mode explains itself:
# pipx is the documented primary install, plain pip covers virtualenv installs. One string so
# the version hint, the sync skip surface, and the update-repin refusal can never drift apart.
PACKAGE_INSTALL_UPDATE_HINT = (
    "pipx upgrade tautline (pipx installs) / pip install -U tautline (virtualenv installs)"
)


def running_from_installed_package() -> bool:
    """True when THIS process executes a pip/pipx-installed wheel (plan Task 6, the ONE predicate).

    Keyed on the build-time manifest's `installKind: "package"` stamp
    (registry_package_snapshot_manifest), NEVER on generic snapshot detection: a snapshot-store
    machine is also a manifest-stamped export, but it updates via repin and must keep
    canonical-first behavior byte-identically. Package mode is different because pip is the only
    channel that updates the RUNNING code and data -- so sync/repin stand down (PP-R1-P1-1) and
    data-root resolution pins to the exec root's embedded tree (PP-R3-P1-1): reads and writes
    stand down together, one predicate, one rationale.
    """
    manifest = snapshot_manifest()
    return bool(manifest) and manifest.get("installKind") == "package"


def running_methodology_commit(short: bool = False) -> str:
    """The commit of the code THIS process is running.

    Snapshots are exported trees with no .git, so `rev-parse` there returns run_git's
    "unavailable" sentinel; the manifest is the only truthful source. Falls back to git on a
    real checkout.

    The short form is ALWAYS a fixed 12-char prefix of the full sha, NEVER git's
    `rev-parse --short` abbreviation. git's abbreviation length is repo-state-dependent (it grows
    with the object count) and a snapshot has no .git to ask at all, so deriving it from git would
    give one commit two names depending on WHERE we execute. That value is not merely printed:
    render_project_config embeds it as `_generated.methodologyCommit` and adapter_drift
    byte-compares the rendered adapter against the file on disk. A snapshot lane and a
    canonical-checkout lane at the SAME methodology commit would then render different bytes, each
    re-render putting the other lane back into drift -- and drift is a gate (methodology-status
    --fail-on-drift, lane-start), so the two lanes would ping-pong into repair sessions forever
    with zero methodology change. run_git's "unavailable" sentinel is 11 chars and survives the
    slice unchanged.
    """
    manifest = snapshot_manifest()
    commit = str(manifest["commit"]) if manifest else run_git(REPO_ROOT, ["rev-parse", "HEAD"])
    return commit[:12] if short else commit


def _valid_methodology_repo(candidate: Path) -> bool:
    """A canonical candidate is the MUTABLE checkout sync manages: it must be a real git
    checkout. A bin/tautline-only tree (a snapshot, a copied CLI) must never be accepted --
    fetch/merge/archive/repair run against the winner."""
    return (
        candidate.is_dir()
        and (candidate / ".git").exists()  # a file in worktrees, a directory in clones
        and (candidate / "bin" / CLI_NAME).is_file()
    )


def _managed_config_value(name: str) -> str:
    """Resolve a managed key: live env first, then the installed config env file.

    resolve_env already prefers the TAUTLINE_ alias over the MINERVIT_ name for the live
    environment; the file read mirrors that precedence explicitly, because install-cli
    dual-writes both spellings and a preserved stale MINERVIT_ export must never win over the
    fresh alias. Takes the full MINERVIT_-prefixed name, like every other managed-key reader.
    """
    live = util_module().resolve_env(name).strip()
    if live:
        return live
    spellings = [name]
    if name.startswith("MINERVIT_"):
        spellings.insert(0, "TAUTLINE_" + name[len("MINERVIT_"):])
    for spelled in spellings:
        value = user_config_env_value(spelled).strip()
        if value:
            return value
    return ""


def _exec_root_is_git_checkout() -> bool:
    """Is the tree we execute from a git checkout (rather than a snapshot / copied-out CLI)?

    Deliberately WEAKER than _valid_methodology_repo: that predicate validates a CONFIGURED
    candidate ("is this a plausible methodology checkout to point sync at?") and demands a
    bin/<cli> file. This one answers "am I self-managing?", where the only thing that matters is
    whether there is a git checkout here to manage. Requiring bin/<cli> here would be a trap: a
    git tree that merely lacks that file would be declared non-self-managing and sync would go
    hunting for a configured repository elsewhere -- which is precisely how an early cut of this
    change pointed the update machinery at the operator's deployed runtime checkout.
    """
    return (REPO_ROOT / ".git").exists()  # a file in worktrees, a directory in clones


def _resolve_canonical_methodology_repo() -> Path:
    """Which mutable git checkout does sync manage?

    THE EXEC ROOT WINS WHENEVER IT IS ITSELF A CHECKOUT. If we are executing from a git checkout,
    that checkout is by definition the one sync manages -- which is exactly what every REPO_ROOT
    call site did before this migration, so the fallback preserves pre-migration behavior byte for
    byte. A configured canonical repo may only take over when the exec root has no `.git`, i.e.
    when we run an immutable snapshot (or a copied-out CLI) and sync has nothing local to manage.

    That gate is load-bearing, not defensive: every installed `~/.config/minervit/methodology.env`
    exports MINERVIT_METHODOLOGY_REPO naming the DEPLOYED runtime checkout. If a configured value
    could outrank a live checkout, then `tautline sync-methodology` run from a dev worktree would
    fetch/merge/`reset --hard` the deployed runtime instead of the worktree the operator is
    standing in -- and the test suite (which monkeypatches REPO_ROOT at fixture repos while the
    real config env is readable) would drive rescue/reset against the operator's live checkout.
    """
    if _exec_root_is_git_checkout():
        return REPO_ROOT
    candidates: list[str] = []
    for key in (METHODOLOGY_CANONICAL_REPO_ENV, "MINERVIT_METHODOLOGY_REPO"):
        value = _managed_config_value(key)
        if value:
            candidates.append(value)
    manifest = snapshot_manifest()
    if manifest and manifest.get("canonicalRepo"):
        candidates.append(str(manifest["canonicalRepo"]))
    for raw in candidates:
        candidate = Path(raw).expanduser().resolve(strict=False)
        if _valid_methodology_repo(candidate):
            return candidate
    return REPO_ROOT


def canonical_methodology_repo() -> Path:
    """The mutable git checkout sync manages; REPO_ROOT when unset (dev and pre-cutover).

    Cached per process: this is consulted on every git write path, and a mid-process change of the
    answer would split a single sync across two repositories. The cache is keyed by the REPO_ROOT
    it was resolved against -- inert in production (REPO_ROOT is a module constant) but required
    under pytest, where one imported module is shared across suites that each point REPO_ROOT at
    their own fixture repo; an input-blind cache would hand one suite another suite's repository.
    """
    global _CANONICAL_METHODOLOGY_REPO, _CANONICAL_METHODOLOGY_REPO_ANCHOR
    if _CANONICAL_METHODOLOGY_REPO is not None and _CANONICAL_METHODOLOGY_REPO_ANCHOR == REPO_ROOT:
        return _CANONICAL_METHODOLOGY_REPO
    resolved = _resolve_canonical_methodology_repo()
    _CANONICAL_METHODOLOGY_REPO = resolved
    _CANONICAL_METHODOLOGY_REPO_ANCHOR = REPO_ROOT
    return resolved


def maintainer_mode_config_value() -> str:
    """The maintainer-mode key's value from the installed config env file, and ONLY from there.

    FILE-ONLY RESIDENCY IS LOAD-BEARING. This read deliberately bypasses _managed_config_value
    and the live environment: the archived monolith plan resolved the key live-env-first, so
    `maintainer-mode off` could strip the config file yet leave an inherited live-env key armed
    (its unresolved MM-R4-P1-1). With the installed config env file as the single source of
    truth, `off` is authoritative by construction -- it removes the only bits that can arm the
    machine. The key lives ONLY in that file, written and removed exclusively by the
    `maintainer-mode` verb; it is never emitted by render-adapters or any adapter surface, so it
    can never ride a committed file into a user repo.

    The TAUTLINE_ alias spelling is read first with blank falling through, mirroring
    _managed_config_value's file order without its live-env rung.
    """
    for spelled in _maintainer_mode_export_names():
        value = user_config_env_value(spelled).strip()
        if value:
            return value
    return ""


def _maintainer_mode_export_names() -> tuple[str, str]:
    """Both alias spellings of the mode key, TAUTLINE_ first -- the read order of
    maintainer_mode_config_value and the write order of the `maintainer-mode` verb."""
    return (
        "TAUTLINE_" + METHODOLOGY_MAINTAINER_MODE_ENV[len("MINERVIT_"):],
        METHODOLOGY_MAINTAINER_MODE_ENV,
    )


def maintainer_mode_configured() -> bool:
    """Is the maintainer-mode key set (to "1") in the installed config env file?

    Blank and "0" count as unset -- see maintainer_mode_config_value for why only the file
    (never the live environment) can configure the mode.
    """
    return maintainer_mode_config_value() == "1"


def maintainer_mode_armed() -> bool:
    """Configured AND there is a real git checkout for sync to manage.

    THE PREDICATE KEYS ON THE CANONICAL REPO, NOT THE BARE EXEC ROOT. With snapshot exec ON (a
    hard requirement of the maintainer setup), the generated launcher resolves $METHODOLOGY_CLI
    to <store>/current/bin/tautline, so the launcher-gate sync executes from an immutable
    snapshot where _exec_root_is_git_checkout() is False -- an exec-root-only predicate would
    never arm inside exactly the sessions the mode exists for. Because
    _resolve_canonical_methodology_repo returns REPO_ROOT whenever the exec root is itself a
    checkout, the canonical-repo predicate subsumes the exec-root case; on a snapshot or
    pip/package exec root with no configured canonical git checkout, the canonical repo falls
    back to a .git-less REPO_ROOT and the mode refuses to arm (nothing to stand down for).
    """
    if not maintainer_mode_configured():
        return False
    # .git is a file in worktrees, a directory in clones -- same shape as
    # _exec_root_is_git_checkout.
    return (canonical_methodology_repo() / ".git").exists()


def maintainer_mode_status_line() -> str:
    """The three-state maintainer_mode report line (methodology-status; sync's diagnostic).

    `off` / `on - ...` / `configured but not armed - ...`. The armed form names the managed
    checkout and its commit -- the same `<checkout> @ <commit>` contract as the per-launch
    banner, because with update gates off that checkout's HEAD IS the machine-wide runtime
    (heal republishes it into <store>/current on every launch). The configured-but-not-armed
    form is a diagnostic: the key is set but there is no git checkout to manage (a snapshot or
    package exec root with no configured canonical checkout), so every gate runs stock. The
    12-char slice matches running_methodology_commit(short=True), never `rev-parse --short`
    (whose length is repo-state-dependent).
    """
    if not maintainer_mode_configured():
        return "maintainer_mode: off"
    if not maintainer_mode_armed():
        return "maintainer_mode: configured but not armed - no git checkout to manage"
    canonical = canonical_methodology_repo()
    head = run_git(canonical, ["rev-parse", "HEAD"])[:12]
    return f"maintainer_mode: on - update gates off; running {canonical} @ {head}"


def maintainer_mode_banner_lines() -> list[str]:
    """The per-launch maintainer-mode banner: LOUD ON PURPOSE (the visual weight of
    warn_unpinned_launcher_window's `!!` style).

    With update gates standing down, committed dev-branch work becomes the machine-wide runtime
    via heal -- deliberate and operator-approved, but a foot-gun if forgotten. The unmissable
    per-launch banner naming exactly which checkout and commit the machine runs is the
    mitigation. The load-bearing `maintainer_mode: update gates off - running <checkout> @
    <commit>` line is deliberately unprefixed so it stays grep-able alongside the other
    machine-readable `key: value` report lines.
    """
    bar = "!" * 76
    canonical = canonical_methodology_repo()
    head = run_git(canonical, ["rev-parse", "HEAD"])[:12]
    return [
        bar,
        "!! MAINTAINER MODE -- every methodology update gate on this machine is STANDING",
        "!! DOWN: no fetch, no trust verification, no branch check, no rescue. Every",
        "!! launch republishes the canonical checkout's HEAD as the machine-wide runtime",
        "!! (snapshot heal), exactly as committed:",
        "!!",
        f"maintainer_mode: update gates off - running {canonical} @ {head}",
        "!!",
        "!! Disable with: tautline maintainer-mode off",
        bar,
    ]


def _write_maintainer_mode_exports(config_env: Path) -> None:
    """Idempotently set both maintainer-mode export lines in `config_env` -- and NOTHING else.

    Created 0600 when absent (the config env file can hold preserved secrets, sec-secrets-2's
    posture); an existing file keeps its mode, its other exports, and its line order (minus any
    stale maintainer-mode lines, so a re-`on` never duplicates). The verb writes ONLY the two
    mode exports: in particular never a snapshot-store key and never the snapshot-exec disable
    lever -- snapshot exec staying ON is a hard requirement of the maintainer setup (the store
    is what protects the operator's concurrent lanes while the canonical checkout is edited).
    The key is deliberately NOT in MANAGED_USER_CONFIG_ENV_KEYS, so install-cli reruns preserve
    it via preserved_user_config_exports instead of stripping it as an unmanaged relic.
    """
    existed = config_env.is_file()
    try:
        lines = config_env.read_text(encoding="utf-8").splitlines()
    except OSError:
        lines = []
    prefixes = tuple(f"export {name}=" for name in _maintainer_mode_export_names())
    kept = [line for line in lines if not line.strip().startswith(prefixes)]
    kept.extend(f"export {name}=1" for name in _maintainer_mode_export_names())
    config_env.parent.mkdir(parents=True, exist_ok=True)
    config_env.write_text("\n".join(kept) + "\n", encoding="utf-8")
    if not existed:
        try:
            config_env.chmod(0o600)
        except OSError:
            pass


def _strip_maintainer_mode_exports(config_env: Path) -> bool:
    """Remove both spellings' export lines from ONE config surface; True when anything changed.

    `maintainer-mode off` calls this for BOTH USER_CONFIG_ENV and LEGACY_USER_CONFIG_ENV, not
    just the resolved file: a key left in the non-resolved surface would re-arm the machine
    later if the preferred file disappeared (resolve_user_config_env falls back to the legacy
    path -- R1 finding MS-R1-P2-1). Every other line is preserved byte-for-byte.
    """
    try:
        lines = config_env.read_text(encoding="utf-8").splitlines()
    except OSError:
        return False
    prefixes = tuple(f"export {name}=" for name in _maintainer_mode_export_names())
    kept = [line for line in lines if not line.strip().startswith(prefixes)]
    if kept == lines:
        return False
    config_env.write_text("\n".join(kept) + ("\n" if kept else ""), encoding="utf-8")
    return True


def maintainer_mode_launcher_advisory_lines(missing: list[Path]) -> list[str]:
    """The loud launcher-capability advisory `maintainer-mode on` prints (never a refusal here).

    In THIS release the advisory arms anyway: under pinned/signed the Python-side standdown is
    complete with ANY launcher, so a refusal would be disproportionate; under warn/unverified
    the pre-change launcher's shell auto-rescue is still live, so the advisory names that hazard
    in so many words. The sibling 0.9.18 launcher-regen plan escalates this advisory to a
    policy-tiered refusal -- that escalation is its entire scope, not this release's.
    """
    bar = "!" * 76
    lines = [
        bar,
        f"!! MAINTAINER MODE ADVISORY -- {len(missing)} installed launcher(s) predate the",
        "!! shell-side maintainer standdown: their text lacks the maintainer-mode guard, so",
        "!! their shell rescue path cannot see the mode key. Regenerate every one of them:",
    ]
    lines.extend(f"!!   - {path}" for path in missing)
    policy = effective_methodology_update_policy()
    if policy in ("warn", "unverified"):
        lines.extend(
            [
                "!!",
                f"!! UPDATE POLICY IS '{policy}': until regenerated, a pre-change launcher's",
                "!! shell auto-rescue is still LIVE -- at session start it can fetch origin and",
                "!! hard-reset the canonical checkout you are developing on.",
            ]
        )
    lines.extend([f"next_step: {LAUNCHER_CUTOVER_COMMAND}", bar])
    return lines


def maintainer_mode_command(args: argparse.Namespace) -> int:
    """`tautline maintainer-mode on|off|status` -- the ONLY supported writer of the mode key.

    `on` writes both alias spellings into the resolved config env file (file-only residency:
    see maintainer_mode_config_value) after requiring a git checkout to manage -- arming a
    machine with nothing to stand down would make every status surface lie. `off` strips both
    spellings from BOTH default config surfaces and is authoritative BY CONSTRUCTION: those
    files are the only arming state, so unlike the archived monolith's live-env-first design
    (MM-R4-P1-1) there is no state in which `off` misreports. `status` prints the same
    three-state line methodology-status reports. Read-only surfaces never journal; the two
    file writes are machine-local config, not methodology-checkout mutations.
    """
    if args.action == "status":
        print(maintainer_mode_status_line())
        return 0
    if args.action == "off":
        for surface in (USER_CONFIG_ENV, LEGACY_USER_CONFIG_ENV):
            _strip_maintainer_mode_exports(surface)
        # Re-read the truth rather than assert it: the line is honest even if a write failed.
        print(maintainer_mode_status_line())
        return 0
    # `on`. The configured check is deliberately skipped (we are about to configure); the
    # checkout half of the arming predicate is required up front.
    canonical = canonical_methodology_repo()
    if not (canonical / ".git").exists():
        print(
            f"maintainer-mode: refusing to arm - {canonical} is not a git checkout, so there "
            "is nothing to stand down. On a snapshot or package exec root, point "
            f"{METHODOLOGY_CANONICAL_REPO_ENV} at the checkout you develop the framework in "
            f"(install-cli writes it) and rerun `{CLI_NAME} maintainer-mode on`.",
            file=sys.stderr,
        )
        return 1
    _write_maintainer_mode_exports(resolve_user_config_env())
    for line in maintainer_mode_banner_lines():
        print(line)
    missing = launchers_missing_maintainer_standdown()
    if missing:
        for line in maintainer_mode_launcher_advisory_lines(missing):
            print(line, file=sys.stderr)
    print(
        f"next_step: verify with `{CLI_NAME} maintainer-mode status`; disable with "
        f"`{CLI_NAME} maintainer-mode off`"
    )
    return 0


def methodology_snapshot_store_root() -> Path:
    value = _managed_config_value(METHODOLOGY_SNAPSHOT_STORE_ENV)
    if value:
        return Path(value).expanduser().resolve(strict=False)
    return Path.home() / ".local" / "share" / "minervit" / "tautline-releases"


def methodology_snapshot_exec_disabled() -> bool:
    """Has the operator taken the documented rollback lever?

    Checked wherever the store would be CONSULTED FOR EXECUTION -- which, on the Python side, means
    every caller of methodology_snapshot_store_enabled(): publishing a snapshot, swapping `current`,
    healing it, pinning a lane to it, and exporting an exec root are all steps of executing the
    store. The shell side (shim + launcher) enforces the same lever on its own exec ladders.
    """
    return _managed_config_value(METHODOLOGY_DISABLE_SNAPSHOT_EXEC_ENV) == "1"


def methodology_snapshot_store_enabled() -> bool:
    """Snapshot mode requires the EXPLICIT managed key (live env or the config env file), and
    yields to the disable lever.

    A stray store directory left by a rehearsal or a failed install must never activate
    snapshot behavior before the documented release -> repin -> install-cli cutover, so
    directory existence is deliberately NOT part of this answer.

    The disable lever is folded in HERE, rather than at each call site, because "snapshot mode is
    on" is exactly the question every caller is asking, and the generated launcher already answers
    it this way (it blanks SNAPSHOT_STORE when the lever is set, so a disabled machine behaves
    precisely as if the store key had never been configured). Python must agree, or the two halves
    of one machine disagree about which tree it runs.
    """
    if methodology_snapshot_exec_disabled():
        return False
    return bool(_managed_config_value(METHODOLOGY_SNAPSHOT_STORE_ENV))


def methodology_data_roots() -> list[Path]:
    """Roots to search for methodology DATA (source adapters, archives), canonical first.

    Shipped release content (methodology/, plugins/, docs/, VERSION, src/) is in the snapshot by
    construction, so reading it from the exec root is correct. DATA is different: a snapshot is an
    export of COMMITTED content, and the per-product source adapters that matter most are private
    -- they live in the canonical checkout's working tree (or a private adapter root) and are never
    committed to the public repo. Anchored on the exec root they would simply not exist, so the
    adapter reads "missing"/"untrusted" and every lane rendered from it fails.

    The exec root stays in the list as a FALLBACK, which is what makes this a no-op on every dev
    checkout and every pre-cutover machine (the two roots are then the same directory, and the list
    collapses to one entry).
    """
    if running_from_installed_package():
        # Package mode (PP-R3-P1-1): the wheel's embedded tree is the ONLY data root. pip is the
        # update channel for code AND data, and behavior must never depend on stale checkout
        # state the user forgot exists -- a leftover configured checkout would otherwise win
        # canonical-first resolution and feed this runtime data its own code never shipped.
        # Checkout and snapshot-store machines keep canonical-first below, byte-identically.
        return [REPO_ROOT]
    roots = [canonical_methodology_repo()]
    if REPO_ROOT not in roots:
        roots.append(REPO_ROOT)
    return roots


def require_dev_checkout(verb: str) -> None:
    """Refuse a maintainer verb whose exec root cannot answer git (D-cat).

    `cut-release`, the `public-release-*` family and the release-update delivery writer all read
    and write the exec root AS A GIT REPOSITORY: tag lists, dirty checks, history/remote trust
    scans, and a committed delivery ledger. A runtime snapshot is an immutable `git archive`
    export with no `.git`, so none of that exists there -- and the failure is SILENT, not loud:
    run_git degrades to the "unavailable" sentinel, so `cut-release` reports every tag as "(new)"
    and every tree as clean, and the export's history-trust scan finds nothing to object to. The
    operator gets a confident, wrong answer about a release boundary.

    Deliberately anchored on REPO_ROOT (the exec root), NOT on the canonical repo: these verbs are
    maintainer operations on the checkout the maintainer is standing in, and silently redirecting a
    release cut into some other repository would be worse than refusing. The refusal names that
    checkout so the operator can act on it.
    """
    if running_from_snapshot() or not _exec_root_is_git_checkout():
        raise SystemExit(
            f"{verb} must run from a methodology dev checkout, not a runtime snapshot; "
            f"cd {canonical_methodology_repo()} and run bin/tautline {verb}"
        )


# --- Per-lane journal of canonical-repo mutations -----------------------------------------
# Many lanes drive sync against ONE canonical checkout. When a lane later finds that checkout in
# an unexpected state, attribution is impossible without a durable record of which lane wrote
# what, and when. This is that record: append-only JSONL under the user's state dir, keyed by the
# existing instrumentation lane id.
METHODOLOGY_WRITE_JOURNAL_SCHEMA = "tautline-methodology-write/v1"
METHODOLOGY_WRITE_JOURNAL_MAX_BYTES = 5_000_000


def methodology_state_dir() -> Path:
    """Where cross-lane methodology state lives. May not exist yet: every writer mkdirs it."""
    return Path.home() / ".local" / "state" / "minervit"


def methodology_write_journal_path() -> Path:
    return methodology_state_dir() / "methodology-writes.jsonl"


def append_methodology_write_journal(
    command: str,
    old_head: str,
    new_head: str,
    outcome: str,
    detail: str = "",
    lane: Path | None = None,
) -> None:
    """Record one canonical-repo mutation attempt. Best-effort BY DESIGN.

    Forensics must never become a failure mode: an unwritable state dir, a full disk, or a
    lane-id salt that cannot be created has to leave sync working exactly as it did before the
    journal existed, so every error here is swallowed. The append is flock-serialized so
    concurrent lanes interleave whole lines instead of shredding each other's entries.
    """
    try:
        path = methodology_write_journal_path()
        # NEVER journal into a methodology tree. The journal lives under $HOME, and if $HOME sits
        # inside a methodology checkout the log becomes an untracked file in the very repository
        # whose `git status` the sync machinery reads as a CONTROL SIGNAL: a dirty tree routes the
        # next sync into the local-changes rescue branch and invalidates the post-advance re-exec
        # token (which requires a clean tree). The tool would then perpetually auto-rescue its own
        # logfile. Both roots are checked -- the canonical checkout for the reason above, and the
        # exec root because a snapshot is read-only by construction and the write would just fail.
        for root in (canonical_methodology_repo(), REPO_ROOT):
            if path_is_under(path, root):
                return
        entry: dict[str, object] = {
            "schema": METHODOLOGY_WRITE_JOURNAL_SCHEMA,
            "ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
            "lane_id": instrumentation_lane_id(lane or Path.cwd()),
            "pid": os.getpid(),
            "command": command,
            "old_head": old_head,
            "new_head": new_head,
            "outcome": outcome,
            "detail": detail,
        }
        path.parent.mkdir(parents=True, exist_ok=True)
        if path.exists() and path.stat().st_size > METHODOLOGY_WRITE_JOURNAL_MAX_BYTES:
            # Single-generation rotation: the journal is a debugging aid, so bounding it at two
            # files is worth more than unbounded history on a machine that syncs all day.
            os.replace(path, path.with_suffix(".jsonl.1"))
        with path.open("a", encoding="utf-8") as fh, advisory_flock(fh):
            fh.write(json.dumps(entry, sort_keys=True) + "\n")
    except Exception:
        pass


def release_migration_report_path(version: str) -> Path:
    return releases_module().release_migration_report_path(version)


def load_release_migration_report(version: str) -> dict | None:
    # Standalone copies of this script (no sibling src/) must keep the 0.6.202
    # behavior for read paths like methodology-status and lane-start: a missing
    # module reads the same as a missing report.
    try:
        module = releases_module()
    except SystemExit:
        return None
    return module.load_release_migration_report(version)


def changelog_release_blocks(path: Path | None = None) -> list[dict[str, str]]:
    return releases_module().changelog_release_blocks(path)


def changelog_release_block(version: str) -> dict[str, str] | None:
    return releases_module().changelog_release_block(version)


def changelog_bullets(body: str, limit: int = 4) -> list[str]:
    return releases_module().changelog_bullets(body, limit)


def release_note_blocks() -> list[dict[str, str]]:
    return releases_module().release_note_blocks()


def release_note_section(body: str, heading: str) -> str:
    return releases_module().release_note_section(body, heading)


def release_note_first_paragraph(section: str, max_chars: int = 520) -> str:
    return releases_module().release_note_first_paragraph(section, max_chars)


def release_note_bullets(section: str, limit: int = 4) -> list[str]:
    return releases_module().release_note_bullets(section, limit)


def release_update_content(version: str) -> dict[str, object]:
    return releases_module().release_update_content(version)


def release_update_releases_module():
    module = releases_module()
    previous = (
        module.VERSION_FILE,
        module.RELEASE_UPDATE_DELIVERY_FILE,
        module.RELEASE_UPDATE_DELIVERY_SCHEMA,
    )
    module.VERSION_FILE = VERSION_FILE
    module.RELEASE_UPDATE_DELIVERY_FILE = RELEASE_UPDATE_DELIVERY_FILE
    module.RELEASE_UPDATE_DELIVERY_SCHEMA = RELEASE_UPDATE_DELIVERY_SCHEMA
    return module, previous


def release_update_call(name: str, *args, **kwargs):
    module, previous = release_update_releases_module()
    try:
        return getattr(module, name)(*args, **kwargs)
    finally:
        (
            module.VERSION_FILE,
            module.RELEASE_UPDATE_DELIVERY_FILE,
            module.RELEASE_UPDATE_DELIVERY_SCHEMA,
        ) = previous


_PUBLIC_RELEASE_MODULE = None


def public_release_module():
    global _PUBLIC_RELEASE_MODULE
    if _PUBLIC_RELEASE_MODULE is None:
        try:
            from minervit_methodology import public_release
        except ModuleNotFoundError as exc:
            raise SystemExit(
                "public-release helpers require the framework checkout's src/minervit_methodology "
                "package; run this command from a full checkout instead of a standalone copy of "
                "bin/tautline"
            ) from exc

        _PUBLIC_RELEASE_MODULE = public_release
    return _PUBLIC_RELEASE_MODULE


WORK_PROFILE_SCHEMA = "minervit-work-profile/v1"
WORK_PROFILE_CHOICES = {"development", "product-docs", "support-docs"}
NON_DEV_WORK_PROFILES = {"product-docs", "support-docs"}
WORK_PROFILE_PUSH_POLICIES = {"pr-branch-only", "direct-main"}
DEFAULT_WORK_PROFILE_LOCK_PATH = ".ai-work/WORK_PROFILE.json"
DEFAULT_NON_DEV_WORK_PROFILE_ALLOWED_PATHS = [
    "*.md",
    "*.txt",
    "*.pdf",
    "docs/**",
    "documentation/**",
    "product/**",
    "requirements/**",
    "wireframes/**",
    "design/**",
    "designs/**",
    "assets/wireframes/**",
    "assets/product/**",
    "mockups/**",
    "screenshots/**",
    "research/**",
    "support/**",
    "runbooks/**",
]
DEFAULT_NON_DEV_WORK_PROFILE_ALLOWED_EXTENSIONS = [
    ".md",
    ".txt",
    ".pdf",
    ".png",
    ".jpg",
    ".jpeg",
    ".webp",
    ".gif",
    ".drawio",
    ".excalidraw",
    ".fig",
    ".figma",
    ".heic",
    ".mov",
    ".mp4",
]
DEFAULT_NON_DEV_WORK_PROFILE_BLOCKED_PATHS = [
    ".github/**",
    ".gitlab/**",
    ".minervit-ai-delivery.json",
    ".minervit/**",
    ".tautline.json",
    ".tautline/**",
    "AGENTS.md",
    "CLAUDE.md",
    "Dockerfile",
    "Dockerfile.*",
    "Makefile",
    "app/**",
    "apps/**",
    "bin/**",
    "charts/**",
    "client/**",
    "config/**",
    "db/**",
    "deploy/**",
    "docker/**",
    "docker-compose*.yml",
    "docker-compose*.yaml",
    "drizzle/**",
    "infra/**",
    "k8s/**",
    "lib/**",
    "migrations/**",
    "package.json",
    "package-lock.json",
    "packages/**",
    "pnpm-lock.yaml",
    "poetry.lock",
    "prisma/**",
    "pyproject.toml",
    "requirements*.txt",
    "scripts/**",
    "server/**",
    "src/**",
    "terraform/**",
    "tsconfig*.json",
    "web/**",
    "yarn.lock",
]
DEFAULT_WORK_PROFILES = {
    "enabled": True,
    "defaultProfile": "development",
    "profileLockPath": DEFAULT_WORK_PROFILE_LOCK_PATH,
    "prePushBase": None,
    "profiles": {
        "development": {
            "description": "Full implementation profile. All startup, git, review, test, board, and code gates apply.",
            "allowedPaths": [],
            "allowedExtensions": [],
            "blockedPaths": [],
            "maxFileBytes": 0,
            "pushPolicy": "pr-branch-only",
        },
        "product-docs": {
            "description": "Product manager profile for requirements, wireframes, product docs, planning artifacts, and approved assets.",
            "allowedPaths": DEFAULT_NON_DEV_WORK_PROFILE_ALLOWED_PATHS,
            "allowedExtensions": DEFAULT_NON_DEV_WORK_PROFILE_ALLOWED_EXTENSIONS,
            "blockedPaths": DEFAULT_NON_DEV_WORK_PROFILE_BLOCKED_PATHS,
            "maxFileBytes": 25_000_000,
            "pushPolicy": "pr-branch-only",
        },
        "support-docs": {
            "description": "Support profile for investigations, support notes, runbooks, docs, screenshots, and approved assets.",
            "allowedPaths": DEFAULT_NON_DEV_WORK_PROFILE_ALLOWED_PATHS,
            "allowedExtensions": DEFAULT_NON_DEV_WORK_PROFILE_ALLOWED_EXTENSIONS,
            "blockedPaths": DEFAULT_NON_DEV_WORK_PROFILE_BLOCKED_PATHS,
            "maxFileBytes": 25_000_000,
            "pushPolicy": "pr-branch-only",
        },
    },
}
DEPRECATED_COMMAND_REPLACEMENTS = {
    "goal-tracker-status": "backlog-provider-status",
    "goal-tracker-next": "backlog-provider-next",
    "goal-tracker-sync": "backlog-provider-sync",
    "goal-tracker-update": "backlog-provider-update",
    # 0.9.0 security exception: narrative journal publication is disabled (refuses in every mode);
    # the commands remain in the contract as deprecated with a named replacement until removal >=1.0.0.
    "publish-session-journal": "publish-instrumentation-record",
    "publish-pending-session-journals": "publish-instrumentation-record",
}
REQUIRED_COMMANDS = [
    "mainStatus",
    "fastPreflight",
    "fullPreflight",
    "testEnvironment",
    "mergeQueue",
    "openPrCheck",
    "mergeConflictCheck",
]
BOOTSTRAP_REQUIRED_PREFIX = "BOOTSTRAP REQUIRED"
DEFAULT_BOOTSTRAP_INTERVIEW_PATH = ".ai-work/ADAPTER_BOOTSTRAP_INTERVIEW.md"
LEGACY_BOOTSTRAP_ALLOWLIST = REPO_ROOT / "adapters/projects/.bootstrap-legacy-allowlist.json"
INVALID_REPO_EVIDENCE_PATHS = {".", ".git"}
WEAK_REPO_EVIDENCE_FILENAMES = {
    ".editorconfig",
    ".gitattributes",
    ".gitignore",
    "code_of_conduct.md",
    "contributing.md",
    "license",
    "license.md",
    "readme",
    "readme.md",
    "security.md",
}
GENERIC_BOOTSTRAP_SLOT_VALUES = {
    "answered",
    "answered with project-specific evidence",
    "done",
    "n/a",
    "na",
    "none",
    "project-specific evidence",
    "tbd",
    "todo",
}
DEFAULT_PLANNING_ARTIFACTS = {
    "scratchPaths": ["~/.claude/plans/"],
    "reviewExemptions": [
        "doc-only: docs/index/release/backlog/operator-guide changes with no product, runtime, infra, security, data, spec, or test-harness behavior change; implementation review and gates still apply."
    ],
}
DEFAULT_LANE_STATE = {
    "lockPath": ".minervit-methodology-lock.json",
    "runsDir": ".ai-runs",
    "executionPacket": ".ai-work/EXECUTION_PACKET.md",
    "milestoneRun": ".ai-work/MILESTONE_RUN.json",
    "goalRun": ".ai-work/GOAL_RUN.json",
    "gitIgnore": True,
}
DEFAULT_GOAL_ARTIFACTS = {
    "templateTrigger": "Substantial multi-milestone, multi-PR, overnight, or ambiguous build/ship/finish work.",
    "reviewRequired": True,
}
DEFAULT_GOAL_EXECUTION = {
    "preferredClaudeCommand": "/goal",
    "claudeGoalGuidance": True,
    "fallback": "goal-ledger",
}
DEFAULT_LANE_COORDINATION = {
    "enabled": True,
    "enforcement": "strict",
    "root": "",
    "contractPath": "",
    "boardPath": "",
    "laneStatusDir": "",
    "maxStatusAgeHours": 24,
    "rule": (
        "For simultaneous lanes, use tracked repo coordination artifacts as the source of truth for shared "
        "contracts, lane ownership, dependencies, provided interfaces, blockers, and PR refs. Each lane updates "
        "its own lane status file; shared contract/interface changes land early before large dependent PRs."
    ),
}
DEFAULT_GOAL_TRACKER = {
    "enabled": False,
    "provider": "github-projects",
    "owner": "",
    "projectNumber": 0,
    "scopeQuery": "",
    "statusField": "Status",
    "priorityField": "",
    "milestoneField": "",
    "readyStatuses": ["Ready", "Next"],
    "activeStatuses": ["In Progress"],
    "doneStatuses": ["Done"],
    "blockedStatuses": ["Blocked"],
    "authoritativeFor": ["goal-priority", "milestone-order", "goal-status"],
    "repoPlanRequired": True,
    "linkPolicy": "repo goal plan links back to project item",
}
DEFAULT_BACKLOG_PROVIDER = {
    "enabled": False,
    "provider": "github-projects",
    "owner": "",
    "projectNumber": 0,
    "scopeQuery": "",
    "itemTypes": ["goal", "milestone", "bug", "task"],
    "typeField": "",
    "statusField": "Status",
    "priorityField": "",
    "milestoneField": "",
    "readyStatuses": ["Ready", "Next"],
    "activeStatuses": ["In Progress"],
    "doneStatuses": ["Done"],
    "blockedStatuses": ["Blocked"],
    "authoritativeFor": ["goal-priority", "milestone-order", "goal-status", "bug-status", "task-priority"],
    "repoPlanRequired": True,
    "tacticalPlanningAuthority": "repo",
    "syncMode": "read-select-write-status-links-notes",
    "linkPolicy": "external backlog item links to reviewed repo source-of-truth plan before execution",
    "migrationInterviewPath": ".ai-work/BACKLOG_PROVIDER_MIGRATION.md",
    "exportMode": "interview-approved",
    "completionUnit": "goal",
    "featureSeries": {},
}
DEFAULT_STAKEHOLDER_QUESTIONS = {
    "enabled": False,
    "provider": "github-issues",
    "defaultMention": "",
    "unknownStakeholderPolicy": "ask-human-once",
    "openLabel": "needs-stakeholder-answer",
    "answeredLabel": "stakeholder-answered",
    "syncAt": ["startup", "milestone-boundary", "pr-boundary", "blocked"],
    "projectStatusOnOpen": "Blocked",
    "projectStatusOnAnswered": "In Progress",
}
DEFAULT_LATEST_CODE = {
    "enabled": True,
    "remote": "origin",
    "base": "main",
    "statusFile": ".ai-work/LATEST_CODE_BASELINE.json",
    "maxAgeMinutes": 60,
    "fetchAll": True,
    "includeOpenPrs": True,
    "includeRemoteBranches": True,
    "maxAheadBranches": 20,
    "rule": (
        "Before status, deep analysis, planning, implementation, review, or tactical subagent dispatch, "
        "fetch current remote state and inspect base plus other remote branches/PRs that may be ahead or deployed. "
        "Do not assume local HEAD or origin/main is the product surface when a deployed or active lane branch is ahead."
    ),
}
DEFAULT_CLAUDE_AUTOCOMPACT_PERCENT = 85
CLAUDE_AUTOCOMPACT_ENV_KEYS = {
    "MINERVIT_CLAUDE_AUTOCOMPACT_PCT": str(DEFAULT_CLAUDE_AUTOCOMPACT_PERCENT),
    "CLAUDE_AUTOCOMPACT_PCT_OVERRIDE": str(DEFAULT_CLAUDE_AUTOCOMPACT_PERCENT),
    "MINERVIT_CLAUDE_AUTOCOMPACT_REQUIRED": "1",
}
CLAUDE_GOAL_KICKOFF_PROMPT_TEMPLATE = (
    "Go. Work on the next active Minervit goal. Run `tautline lane-start --target {target}` and "
    "`tautline methodology-status --target {target} --fail-on-drift`. If "
    "`.ai-work/GOAL_RUN.json` exists, run `tautline goal-condition --target {target}` and start Claude "
    "`/goal` with the generated condition. If no goal ledger exists but an execution-ready source-of-truth goal plan is ready, "
    "use the `next_goal_name`, `next_goal_short_description`, `next_goal_claude_prompt`, and `next_goal_next_action` "
    "printed by startup/status/kickoff, run "
    "`tautline goal-start --target {target} --goal <goal-plan>`, then "
    "`tautline goal-condition --target {target}`, then start `/goal`. If no execution-ready goal plan "
    "exists, and the adapter enables `backlogProvider`, run `tautline backlog-provider-next --target {target}` and "
    "`tautline backlog-provider-sync --target {target} --item <item> --write` before goal-plan review. "
    "Otherwise create or update the source-of-truth goal plan and run the required cross-model review or "
    "adapter-declared exemption before tactical work. "
    "Honor risk-tier autonomy and review-before-push gates. If `/goal` is unavailable, continue through "
    "`tautline goal-next --target {target}` and `tautline milestone-next --target {target}` "
    "without stopping to ask. During long `/goal` work, context exhaustion is not a stop condition: refresh continuity, "
    "handle the session journal, compact/restart through the host, then resume the active goal. If this agent turn cannot "
    "invoke `/compact` directly, treat that as a mandatory rotation boundary: write the handoff/journal evidence and state "
    "the exact fresh-session startup action, never an opt-in question."
)
DEFAULT_MILESTONE_CONTINUATION = {
    "watchdogEnabled": False,
    "watchdogCadenceMinutes": 30,
}
DEFAULT_SESSION_JOURNAL = {
    # Opt-in since 0.8.7: journals narrate the adopter's product work and publish to the
    # framework checkout's journal branch, so they must never be on without a declaration.
    "enabled": False,
    "branch": "methodology-session-archive",
    "archiveDir": "docs/backlog/session-journals",
    "cadence": "milestone",
    "gitIgnoreLocal": True,
    "maxBytes": 12000,
}
DEFAULT_INSTRUMENTATION = {
    # Opt-in exactly like session journals (0.9.0): the sanitized instrumentation record is the ONLY
    # session evidence that can ever reach a remote (it has zero freeform capacity by construction),
    # but publishing is off until an operator declares it per lane. `enabled` alone permits the
    # publish command; `cadence` gates only whether boundary guidance PROMPTS for emission
    # (milestone = at milestone boundaries, session = every session end, off = never prompted). A
    # disabled lane is never prompted regardless of cadence. Deliberately NO branch/archiveDir keys:
    # remote metadata is a fixed constant (see the publisher's "Remote metadata is constant" design).
    "enabled": False,
    "cadence": "milestone",
}
DEFAULT_OBSERVABILITY_EVENTS = {
    "enabled": True,
    "stateDir": "$HOME/.local/state/minervit/repo-events",
    "humanLog": "events.log",
    "jsonlLog": "events.jsonl",
    "rotateBytes": 5000000,
    "retainedRotations": 5,
    "requiredBoundaryEvents": [
        "startup",
        "planning_review_gate",
        "preflight",
        "pr_queue_merge",
        "blocker",
        "rca",
        "continuity",
        "context_rotation",
        "goal_transition",
        "milestone_transition",
    ],
}
DEFAULT_USAGE_ACCOUNTING = {
    "enabled": True,
    "stateDir": "$HOME/.local/state/minervit/usage",
    "jsonlLog": "usage.jsonl",
    "rollupJson": "usage-rollup.json",
    "rotateBytes": 5000000,
    "retainedRotations": 5,
}
DEFAULT_ITERATION_REVIEW = {
    "enabled": False,
    "granularities": ["goal"],
    "outputs": {
        "page": True,
        "video": True,
    },
    "boundary": {
        "announce": True,
        "autoRunAtBoundary": True,
    },
    "recordDir": "docs/iteration-reviews",
    "hosting": {
        "store": "s3",
        "bucket": "",
        "keyPattern": "<project>/iteration-reviews/<target-id>/<media-file>",
        "cdn": "cloudfront",
        "access": "private-cdn",
        "baseUrl": "",
    },
    "delivery": {
        "enabled": False,
        "trigger": "after-merge",
        "provider": "google-chat-webhook",
        "webhookEnv": "",
        "chatSpace": "",
        "primaryUrl": "cloudfront-page",
        "dedupe": True,
        "sentStateDir": ".ai-work/iteration-review-delivery",
    },
    "video": {
        "music": {
            "enabled": True,
            "source": "adapter-approved",
            "defaultUrl": "",
            "volume": 0.18,
        },
    },
    "rendererVersion": "0.2.1",
}
DEFAULT_MILESTONE_UPDATE = {
    "enabled": False,
    "trigger": "milestone-complete",
    "provider": "google-chat-webhook",
    "webhookEnv": "",
    "chatSpace": "Product Milestones",
    "dedupe": True,
    "sentStateDir": ".ai-work/milestone-update-delivery",
    "maxChars": 4000,
}
DEFAULT_PRODUCT_CHAT = {
    "enabled": False,
    "provider": "google-chat-webhook",
    "webhookEnv": "",
    "chatSpace": "Product",
    "maxChars": 6000,
}
DEFAULT_DEPLOYMENT_NOTIFICATION = {
    "enabled": False,
    "trigger": "deploy-ready",
    "provider": "google-chat-webhook",
    "webhookEnv": "",
    "reuseIterationReviewWebhook": True,
    "chatSpace": "Product",
    "dedupe": True,
    "sentStateDir": ".ai-work/deployment-notification-delivery",
    "maxChars": 3000,
    "pipeline": {
        "required": False,
        "mode": "ci-post-deploy",
        "evidencePaths": [],
        "requiredCommand": "tautline publish-deploy-ready-update",
        "healthCheckBeforeNotify": True,
    },
}
DEPLOY_HEALTH_FAILURE_CONCLUSIONS = {"action_required", "cancelled", "failure", "startup_failure", "timed_out"}
DEFAULT_DEPLOY_HEALTH = {
    "enabled": False,
    "provider": "github-actions",
    "workflow": "",
    "branch": "main",
    "limit": 25,
    "failOnLatestFailure": True,
    "maxConsecutiveFailures": 3,
    "maxSuccessAgeHours": 72,
}
DEFAULT_CONTEXT_ROTATION = {
    "enabled": True,
    "softPercent": 60,
    "hardPercent": 75,
    "heartbeatMinutes": 15,
    "heartbeatBoundary": "goal-heartbeat",
    "safeBoundaries": [
        "pr-queued",
        "pr-complete",
        "milestone-complete",
        "goal-boundary",
        "goal-heartbeat",
        "session-summary",
        "handoff-for-review",
    ],
}
DEFAULT_REVIEW = {
    "codexFastMode": True,
    "prePushReviewEvidence": True,
    "roundBudgets": {"T0": 0, "T1": 1, "T2": 2, "T3": 2},
}
DEFAULT_MODEL_EFFORT_POLICY = {
    "defaultEffort": "high",
    "xhighPolicy": "debugging/design escalation only",
    "executionPacketSonnetEligible": True,
}
DEFAULT_LOCAL_RESOURCE_LOCKS = {
    "locksDir": "~/.minervit-ai-delivery/locks",
    "resources": [],
}
DEFAULT_LOCAL_RESOURCE_ISOLATION = {
    "envFile": ".ai-work/lane-env.sh",
    "composeProjectPrefix": "minervit",
    "portStride": 10,
    "portVariables": [],
    "envTemplates": {},
    "isolatedCommands": [],
}
DEFAULT_DOCUMENT_CONTEXT = {
    "enforcement": "warn",
    "maxIndexBytes": 16000,
    "maxIndexLines": 250,
}
# RCA: a repo can define tests yet run them nowhere in CI, so regressions ship silently while every
# methodology gate (all local, agent-invoked) stays green. The CI-test-gate health check fails closed
# when the repo has tests but no CI workflow runs them on PR/push. "warn" surfaces it loudly; "block"
# makes it a blocking gate (methodology-status --fail-on-drift + pre-push).
DEFAULT_CI_TEST_GATE = {
    "enabled": True,
    "enforcement": "warn",
    "events": ["pull_request", "push"],
    "testCommandMarkers": [],
    "requiredWorkflow": "",
    "requiredCheckName": "",
}
UI_EVIDENCE_EVENTS = {"milestone-complete", "goal-complete", "work-item-complete"}
DEFAULT_UI_EVIDENCE = {
    "enabled": False,
    "enforcement": "warn",
    "tool": "playwright",
    "evidenceDir": ".ai-work/ui-evidence",
    "manifestGlob": "*.json",
    "captureCommand": "",
    "requiredEvents": ["milestone-complete", "goal-complete"],
    "issueComments": {
        "enabled": False,
        "provider": "github-issues",
        "triggerEvents": ["milestone-complete", "goal-complete"],
        "required": False,
    },
}
DEFAULT_RUNTIME_CONFIG = {
    "enforcement": "warn",
    "requiredSecrets": [],
    "bootAssertionCommand": "",
    "secretParityCommand": "",
}
SUPPORTED_DEVELOPMENT_RUNTIMES = {"linux", "darwin", "wsl1", "wsl2", "win32"}
DEFAULT_DEVELOPMENT_ENVIRONMENT = {
    "supportedRuntimes": [],
    "windows": {
        "native": "unspecified",
        "supportedRuntime": "wsl2",
        "setup": "",
        "reason": "",
    },
    "lineEndings": {
        "policy": "",
        "windowsGuidance": "",
    },
}
DEFAULT_MIGRATION_SAFETY = {
    "enforcement": "warn",
    "migrationsPath": "",
    "ackMarker": "MIGRATION-SAFETY-ACK",
}
DEFAULT_COVERAGE_FLOOR = {
    "enforcement": "warn",
    "newCodeMinPct": 80,
    "coverageReport": "coverage.json",
    "diffBase": "origin/main",
}
DEFAULT_PLAN_ACCEPTANCE = {
    "enforcement": "off",
    "acHeadings": ["acceptance criteria"],
}
# Test-quarantine markers across common stacks. A diff that ADDS one of these (rec #14) must record an
# owner + a due/issue/trigger, so a flaky/skipped test is tracked debt, not silent gate erosion. This is
# phrase vocabulary (a list of str) -> exported to the policy-phrase SSOT.
FLAKY_QUARANTINE_MARKERS = [
    "@flaky",
    "@pytest.mark.flaky",
    "@pytest.mark.skip",
    "@pytest.mark.xfail",
    "@pytest.mark.skipif",
    "@unittest.skip",
    "pytest.skip(",
    "pytest.xfail(",
    "describe.skip(",
    "it.skip(",
    "test.skip(",
    "xit(",
    "xdescribe(",
    "@disabled",
    "@ignore",
    "t.skip(",
    "#[ignore]",
]
DEFAULT_FLAKY_QUARANTINE = {
    "enforcement": "warn",
    "maxMarkers": -1,
    "scanPath": "",
}
DEFAULT_HEALTH_CONTRACT = {"enforcement": "off"}
DEFAULT_SIDE_EFFECT_PROOF = {"enforcement": "off"}
DEFAULT_REMEDIATION = {"enforcement": "off", "ledgerPath": "docs/quality/remediation-obligations.json"}
DEFAULT_RED_GREEN = {"enforcement": "off"}
# Single-symbol mutations used by the red-green discrimination proof (rec #8). A tuple of (label,
# compiled pattern, replacement) -> excluded from the policy-phrase SSOT.
MUTATION_RULES = (
    ("return True -> return False", re.compile(r"\breturn True\b"), "return False"),
    ("return False -> return True", re.compile(r"\breturn False\b"), "return True"),
    ("== -> !=", re.compile(r"=="), "!="),
    ("!= -> ==", re.compile(r"!="), "=="),
    ("and -> or", re.compile(r"\band\b"), "or"),
    ("or -> and", re.compile(r"\bor\b"), "and"),
    ("< -> >=", re.compile(r"<(?![=<])"), ">="),
    ("> -> <=", re.compile(r"(?<![->=])>(?![=>])"), "<="),
)
# A health signal that proves only that the process is up, NOT that a customer can complete the journey.
# "Green" meaning one of these passed is provability theater (P1) -- a private product headline journey was
# 100% down while /healthcheck returned 200. A tuple so it stays out of the policy-phrase SSOT.
PROXY_HEALTH_PATTERNS = (
    r"/healthz?\b",
    r"/health[-_]?check\b",
    r"/ping\b",
    r"/status\b",
    r"/ready\b",
    r"/live(?:ness)?\b",
    r"\bhealth[-_ ]?check\b",
    r"\b200 ok\b",
    r"\breturns? (?:a )?200\b",
    r"\bdb (?:read|ping|query|connection)\b",
    r"\bdatabase (?:read|ping|connection)\b",
    r"\bheartbeat\b",
    r"\buptime\b",
)
# Built-in test-runner invocations that mark a CI step as actually running a test suite. The adapter's
# own preflight/test command tokens and any testCommandMarkers are added on top for precision.
CI_TEST_RUNNER_MARKERS = [
    "pytest", "tox", "nox", "unittest",
    "npm test", "npm run test", "yarn test", "yarn run test", "pnpm test", "pnpm run test",
    "bun test", "vitest", "jest", "playwright test", "cypress run",
    "go test", "cargo test", "rspec", "phpunit", "mvn test", "gradle test", "./gradlew test",
    "dotnet test", "rake test", "rails test", "behave", "pytest-bdd",
    "scripts/test.sh", "scripts/preflight", "preflight.sh", "run-tests", "make test",
    # Monorepo task-runner + conventional npm-script test commands (e.g. `pnpm exec turbo test:unit`,
    # `nx test`), which were not recognized and false-negatived real CI test gates (RCA follow-up).
    # Kept specific to test targets: `nx run`/`nx affected` are intentionally NOT markers because they
    # also run build/lint/deploy targets and would false-positive a non-test step (Codex).
    "turbo test", "turbo run test", "nx test",
    "test:unit", "test:integration", "test:e2e", "test:ci", "test:all", "test:coverage", "run test:",
]
DEFAULT_DEPLOYMENT_TARGETS: list[dict] = []
DEFAULT_AWS_CLI_CONVENTION = "For AWS-approved lanes, use AWS CLI: check `aws --version` and `aws sts get-caller-identity` or adapter identity check; install/login if missing; do not ask for SSH keys unless adapter declares SSH/non-AWS deployment."
DEFAULT_TECHNOLOGY_STACK = {
    "cloudProviderDefault": "AWS",
    "approvedCloudProviders": ["AWS"],
    "newProjectCloudDefault": "AWS",
    "newCloudProviderRequiresApproval": True,
    "awsCliConvention": DEFAULT_AWS_CLI_CONVENTION,
    "nonNegotiable": [
        "Cloud services default to AWS unless the adapter explicitly overrides approved providers.",
        "Non-approved cloud/hosting platforms require adapter approval or explicit human approval.",
    ],
    "rule": "Use adapter-declared stack defaults; tool/framework defaults never authorize a new deployment platform.",
}
DEFAULT_GRAPHIFY = {
    "enabled": True,
    "preferWhenPresent": True,
    "outputDir": "graphify-out",
    "reportPath": "graphify-out/GRAPH_REPORT.md",
    "graphPath": "graphify-out/graph.json",
    "installPackage": "graphifyy",
    "installCommand": "python3 -m pip install --user graphifyy",
    "setupCommand": "graphify install",
    "buildCommand": "graphify .",
    "updateCommand": "graphify . --update",
    "queryCommand": "graphify query",
    "pathCommand": "graphify path",
    "explainCommand": "graphify explain",
    "gitIgnoreOutput": True,
    "allowProjectInstaller": False,
    "freshnessEnforcement": "strict-if-present",
    "freshnessRule": (
        "After every code, docs, schema, route, test, architecture, or other system change, run `graphify . --update` "
        "before commit/push and before relying on existing Graphify output; stale graphs are blocking drift."
    ),
    "rule": (
        "When enabled and graphify-out/GRAPH_REPORT.md or graphify-out/graph.json exists, use Graphify "
        "report/query/path/explain for codebase orientation before broad grep/rg to reduce token-heavy scans; "
        "use rg for exact lexical searches or when Graphify is absent. Stale Graphify output must be refreshed before use."
    ),
}
BEHAVIOR_SOURCE_EXEMPTION_MARKER = "BEHAVIOR-SOURCE-EXEMPT:"
DEFAULT_BEHAVIOR_PENDING_TAGS = ["@pending"]
DEFAULT_BEHAVIOR_ACCEPTANCE_HARNESSES: list[dict] = []
DEFAULT_BUG_BACKLOG = {
    "sourceOfTruth": "backlogAdapter",
    "issueMirrors": [],
    "severityTaxonomy": "No project-specific bugBacklog configured; preserve project-local severity labels and do not invent tracker policy.",
    "autoP0Categories": [],
    "autoP1Categories": [
        "auth",
        "email",
        "tenant data",
        "security",
        "deploy",
        "billing",
        "data loss",
        "customer-blocking",
    ],
    "requiredFields": [
        "observed behavior",
        "expected behavior",
        "impact",
        "source files or evidence",
        "recommended fix",
        "test plan",
    ],
    "rule": "No project-specific bugBacklog configured; route bugs through backlogAdapter and do not open external issues unless project evidence requires them.",
}
COMMON_IGNORED_DOC_PATHS = [
    ".git/",
    ".ai-runs/",
    ".ai-work/",
    "node_modules/",
    "dist/",
    "build/",
    ".next/",
    ".cache/",
    ".venv/",
    "venv/",
    "__pycache__/",
]
DOCUMENT_CONTEXT_SECTIONS = [
    "Read First",
    "Active Work",
    "Ready Next",
    "Historical Evidence Only",
    "Do Not Load Routinely",
    "Needs Classification",
]
ARCHIVE_HEADER_SENTENCE = (
    "Historical evidence only. Not current process, scope, or execution authority. Start from <index path>."
)
PLAN_REVIEW_SCHEMA = "minervit-plan-review/v1"
PLAN_REVIEW_RUN_SCHEMA = "minervit-plan-review-run/v1"
PLAN_REVIEW_CLASSIFIER_VERSION = "plan-review-classifier/v2"
PLAN_REVIEW_ALLOWED_VERDICTS = {"clean", "clean-with-deferrals"}
PLAN_REVIEW_ALL_VERDICTS = sorted([*PLAN_REVIEW_ALLOWED_VERDICTS, "blocked"])
PLAN_REVIEW_TRUSTED_RECORDERS = {
    "tautline run-plan-review",
    "tautline finalize-plan-review",
    # Pre-0.8.0 manifests were recorded under the legacy CLI name; the rebrand
    # guarantees they stay trusted (downstream product repos carry months of
    # legacy-recorded, legitimately-reviewed manifests). record-plan-review
    # imports remain untrusted under both names.
    "minervit-methodology run-plan-review",
    "minervit-methodology finalize-plan-review",
}
PLAN_REVIEW_EVIDENCE_HEADING = "## Cross-Model Review Evidence"
PLAN_REVIEW_EVIDENCE_END_MARKER = "<!-- cross-model-review-evidence-end -->"
PLAN_REVIEW_EXEMPTION_HEADING = "## Plan Review Exemption"
PLAN_REVIEW_DECOMPOSE_IF_STALLED_ROUND = 3
# Convergence ladder (operator directive 2026-07-13): rounds 1-2 are the TARGET; rounds 3-4 are
# self-authorized with a recorded exception note (never an operator escalation); past round 4 the
# hard cap is absolute and refusal is unconditional. The REMEDY is chosen by the bound evidence:
# finalize it only when it is clean AND still bound to the current plan (see
# plan_review_bound_evidence_is_unfinalizable); in every other state -- including clean-but-STALE
# evidence, which plan-finalization-precheck rejects -- the split is mandatory.
PLAN_REVIEW_TARGET_ROUNDS = 2
PLAN_REVIEW_HARD_CAP_ROUNDS = 4
PLAN_REVIEW_EXCEPTION_NOTE_MIN_CHARS = 20
PLAN_REVIEW_EXCEPTION_NOTE_HELP = (
    "Recorded reason this round is needed. Required to record any round past the "
    "convergence target; rounds 3-4 are self-authorized and never need operator approval."
)
PLAN_REVIEW_NO_OUTPUT_TIMEOUT_SECONDS = 720
PLAN_REVIEW_FORBIDDEN_COMMAND_HEADS = {"echo", "printf", "cat", "true", "false", ":", "touch", "tee"}
PLAN_REVIEW_FORBIDDEN_SHELL_MARKERS = ["&&", "||", ";", "|", ">", "<", "`", "$("]
PLAN_REVIEW_OUTPUT_START = "--- review output ---"
PLAN_REVIEW_OUTPUT_END = "--- review metadata ---"
IMPLEMENTATION_REVIEW_SCHEMA = "minervit-implementation-review/v1"
IMPLEMENTATION_REVIEW_LEDGER_SCHEMA = "minervit-implementation-review-ledger/v1"
IMPLEMENTATION_STAGE1_SWEEP_SCHEMA = "minervit-implementation-stage1-sweep/v1"
CLAUDE_REVIEW_SCHEMA = "minervit-claude-review/v1"
UI_EVIDENCE_SCHEMA = "minervit-ui-evidence/v1"
IMPLEMENTATION_REVIEW_STAGE = "stage2-codex"
IMPLEMENTATION_STAGE1_SWEEP_STAGE = "stage1-native-sweep"
IMPLEMENTATION_REVIEW_DIR = ".ai-runs/review-evidence"
CLAUDE_REVIEW_DIR = ".ai-runs/claude-review"
CLAUDE_REVIEW_ALLOWED_VERDICTS = {"no_blockers", "blocked"}
# The plugin version that introduced the mandatory Stage 1 sweep on implementation-review
# evidence. Manifests recorded by an older plugin are tolerated (warn, not block) so a
# mid-session plugin auto-advance does not retroactively fail pre-recorded evidence (RCA O7).
STAGE1_SWEEP_REQUIREMENT_PLUGIN_VERSION = "0.6.79"
LANE_SESSION_FILE = "lane-session.json"
IMPLEMENTATION_REVIEW_ALLOWED_VERDICTS = {"clean", "clean-with-deferrals"}
IMPLEMENTATION_REVIEW_ALL_VERDICTS = sorted([*IMPLEMENTATION_REVIEW_ALLOWED_VERDICTS, "blocked"])
DEFAULT_MONITOR_STALE_SECONDS = 600
BASE_BRANCH_NAMES = {"main", "master", "develop", "trunk"}
GIT_BRANCH_LIVENESS_HOOKS = ["pre-commit", "pre-push"]
GIT_BRANCH_LIVENESS_HOOK_MARKER = "MINERVIT-BRANCH-LIVENESS-HOOK"
GITHUB_CACHE_TTL_SECONDS = 300
GITHUB_SHARED_SNAPSHOT_SECONDS = 15
GITHUB_RATE_LIMIT_LOW_WATERMARK = 100
GITHUB_RATE_LIMIT_CHECK_TTL_SECONDS = 30
GITHUB_LOCK_TIMEOUT_SECONDS = 30
GITHUB_LOCK_STALE_SECONDS = 120
GITHUB_PENDING_RETRY_MIN_SECONDS = 60
GITHUB_PENDING_RETRY_MAX_SECONDS = 900
GITHUB_RECENT_CALL_LIMIT = 25
METHODOLOGY_REEXEC_TOKEN_ENV = "MINERVIT_METHODOLOGY_REEXEC_TOKEN"
METHODOLOGY_RESCUE_STATE_ENV = "MINERVIT_METHODOLOGY_RESCUE_STATE_DIR"
METHODOLOGY_REEXEC_TOKEN_MAX_AGE_SECONDS = 60
PLAN_SUBSTANTIVE_REQUIREMENTS = {
    "milestone goal": [r"\bmilestone\s+goal\b", r"^##\s+goal\b", r"\bgoal\b"],
    "non-goals": [r"\bnon[- ]goals?\b", r"\bout of scope\b"],
    "evidence/source links": [r"\bevidence\b", r"\bsources?\b", r"\bsource links?\b"],
    "assumptions": [r"\bassumptions?\b"],
    "ordered scope": [r"\bordered\s+scope\b", r"\bscope\b", r"\bimplementation\b"],
    "dependencies": [r"\bdependencies\b", r"\bdependency\b"],
    "acceptance criteria": [r"\bacceptance criteria\b", r"\bacceptance\b"],
    "tests or validation": [r"\btests?\b", r"\bvalidation\b"],
    "review or merge gates": [r"\breview\b", r"\bmerge\b", r"\bgates?\b"],
    "risks": [r"\brisks?\b"],
    "open decisions": [r"\bopen decisions?\b", r"\bdecisions?\b"],
    "completion definition": [r"\bcompletion definition\b", r"\bdefinition of done\b", r"\bdone\b"],
}
RCA_REQUIRED_HEADINGS = [
    "## What happened",
    "## Evidence",
    "## Root cause",
    "## Proposed control",
    "## Validation",
]
FEATURE_REQUEST_REQUIRED_HEADINGS = [
    "## What this delivers",
    "## Why it matters",
    "## Current Gap / Evidence",
    "## Proposed Capability",
    "## Acceptance Criteria",
    "## Validation Proof",
    "## Risks / Compatibility",
    "## Suggested Triage",
    "## Immediate Next Action",
]
FEATURE_REQUEST_RCA_HEADINGS = [
    "## What happened",
    "## Root cause",
    "## Proposed control",
]
RCA_GENERIC_NEXT_ACTIONS = {
    "",
    "none",
    "n/a",
    "na",
    "continue",
    "continue current work",
    "continue with current work",
    "keep going",
    "implement fix proposal",
    "land the change",
}
RCA_CONTROL_GAP_TERMS = ["missing", "weak", "ambiguous", "hidden", "conflicting", "stale", "unvalidated"]
RCA_CONTROL_REFERENCES = ["adapter", "canonical", "generator", "generated", "rule", "skill", "validation"]
RCA_DEFAULT_ESCAPE_CLASSES = [
    "Tool/platform default overrode methodology",
    "Agent training default overrode methodology",
    "Execution pressure/shortcut behavior",
]
RCA_MEMORY_AUTHORITY_TERMS = [
    "open brain",
    "claude memor",
    "local memor",
    "project memor",
    "feedback_",
    "feedback-",
    "memory file",
    "memory note",
]
RCA_STRONG_PROCESS_AUTHORITY_MARKERS = [
    "canonical methodology",
    "canonical rule",
    "canonical rules",
    "canonical-rules.md",
    "methodology/canonical-rules.md",
    "generated adapter",
    "active generated adapter",
    "project adapter",
    "claude.md",
    "agents.md",
    ".minervit-ai-delivery.json",
    "methodology skill",
    "skill.md",
    "/skills/",
]
RCA_PROCESS_AUTHORITY_MARKERS = RCA_STRONG_PROCESS_AUTHORITY_MARKERS
RCA_PERSON_SPECIFIC_PATH_PATTERNS = [
    r"/Users/[^/\s`]+",
    r"/home/[^/\s`]+",
    r"[A-Za-z]:\\Users\\[^\\\s`]+",
]
RCA_GENERIC_USERNAME_TOKENS = {"root", "runner", "user", "users", "home", "admin", "administrator"}
RESPONSE_GUARD_PASSIVE_MONITOR_PHRASES = [
    "yielding until then",
    "will notify",
    "will be notified",
    "will report verdict",
    "monitor armed",
    "monitor watches",
    "monitor is running",
    "waiting on merge",
    "waiting on checks",
    "waiting for notification",
    "waiting for harness notification",
    "waiting for the harness notification",
    "waiting for completion",
    "waiting for the background command",
    "ci is processing",
    "checks are in progress",
    "still in progress",
    "deploy is underway",
    "review running",
    "review is running",
    "preflight still running",
    "preflight just started",
    "preflight is running",
    "preflight has started",
    "preflight run is in progress",
    "preflight underway",
    "preflight to complete",
    "final preflight underway",
    "will push as soon as",
    "r2 running",
    "r3 running",
    "quality gates are running",
    "wakeup in",
    "wake up in",
    "i'll check back",
    "i'll update when",
    "i'll resume when",
    "i'll continue when",
    "i'll be notified",
    "will resume when",
    "will continue when",
    "will signal completion",
    "will ping when done",
    "when it completes",
    "when this completes",
    "when that completes",
    "when this finishes",
    "when that finishes",
    "when codex finishes",
    "once it exits",
    "once it finishes",
    "once that's done",
    "after it's done",
    "after this completes",
    "after the review is done",
    "upon completion",
    "waiting for a result",
    "when the output arrives",
    "when the command completes",
    "when the background task completes",
    "when the background command completes",
    "i'll act when",
    "i'll handle this when",
    "i'll know when",
    "resuming once",
    "pick this up after",
    "process will notify",
    "notify on eof",
    "harness will re-invoke",
    "completion notification",
    "harness notification",
]
RESPONSE_GUARD_AUTONOMOUS_YIELD_MARKERS = [
    "schedulewakeup armed",
    "schedule wakeup armed",
    "schedulewakeup scheduled",
    "schedule wakeup scheduled",
    "next heartbeat",
    "next tick",
    "heartbeat at",
    "heartbeat due",
    "recheck in",
    "backing off",
    "autonomous loop",
    "operator-blocked",
    "operator blocked",
    "quiet.",
    "standing by",
    "yielding.",
]
# A positive "the monitor/process is alive" assertion. pgrep/process-exists liveness alone
# is insufficient: a wedged process is still "alive" (RCA O3 — a background plan-review
# wedged 3h40m while the controller reported it alive via pgrep). Such a claim must cite
# freshness evidence (monitor-status output, a log-size delta since the prior poll, or a
# freshness timestamp comparison).
RESPONSE_GUARD_MONITOR_ALIVE_CLAIM_MARKERS = [
    "verified alive",
    "confirmed alive",
    "verified running",
    "confirmed running",
    "monitor is alive",
    "monitor still alive",
    "still alive",
    "process is alive",
    "process still alive",
    "pid is alive",
    "pid is live",
    "process exists",
    "process is live",
    "pgrep",
]
RESPONSE_GUARD_MONITOR_FRESHNESS_EVIDENCE_MARKERS = [
    "monitor-status",
    "monitor_status",
    "log grew",
    "log growth",
    "log-size",
    "log size",
    "grew by",
    "bytes since",
    "lines since",
    "new lines",
    "since prior poll",
    "since the prior poll",
    "since last poll",
    "since the last poll",
    "mtime",
    "last modified",
    "modified at",
    "modified ago",
    "seconds ago",
    "minutes ago",
    "freshness",
    "timestamp",
    "artifact growth",
]
RESPONSE_GUARD_AUTONOMOUS_YIELD_REQUIRED_FIELDS = [
    "plain english",
    "progress",
    "next",
    "technical details",
    "state",
    "did not advance",
    "blocker",
    "next-poll",
]
RESPONSE_GUARD_AUTONOMOUS_YIELD_MIN_CHARS = 20
RESPONSE_GUARD_FORWARD_MOTION_MARKERS = [
    "next poll due",
    "next poll",
    "will poll",
    "poll again",
    "next work already underway",
    "continuing with",
    "working on",
    "active poll",
    "parallel-safe",
    "non-conflicting work",
    "artifact written",
    "wrote ",
    "created ",
    "drafted ",
    "opened ",
    "committed ",
    "queued ",
    "pushed ",
    "synced ",
    "branch created",
    "source-of-truth plan",
    "plan-finalization-precheck",
]
BACKGROUND_COMMAND_FAKE_MONITOR_PATTERNS = [
    r"\buntil\b[\s\S]{0,800}\bdo\b[\s\S]{0,800}\bsleep\b",
    r"\bwhile\b[\s\S]{0,800}\bdo\b[\s\S]{0,800}\bsleep\b",
    r"\btail\b[\s\S]{0,300}\|\s*grep\b",
    r"\bwait\s+\$?[A-Za-z0-9_]+\b[\s\S]{0,300}\bsleep\b",
]
RESPONSE_GUARD_DERIVABLE_NEXT_PROMPT_MARKERS = [
    "what is the next milestone",
    "what's the next milestone",
    "next milestone",
    "what is next",
    "what's next",
    "what should we do next",
    "what should be next",
    "what should i do next",
    "is this work planned",
    "is it planned",
    "needs planned",
    "needs planning",
    "if it needs planned",
    "plan it now",
    "go plan it",
    "please plan it",
]
RESPONSE_GUARD_STATUS_REPORT_NEXT_ACTION_MARKERS = [
    "needs drafting",
    "needs to be drafted",
    "needs planning",
    "needs to be planned",
    "needs a plan",
    "needs a spec",
    "needs an execution spec",
    "needs a source-of-truth",
    "missing plan",
    "missing spec",
    "per-pr spec missing",
    "requires planning",
    "requires a plan",
    "requires a spec",
    "ready for planning",
    "should be drafted",
    "should be planned",
]
RESPONSE_GUARD_TERMINAL_STOP_MARKERS = [
    "merged",
    "merge confirmed",
    "no open prs",
    "no open pr",
    "no work in flight",
    "no work-in-flight",
    "session summary",
    "final state",
    "closing state",
    "cleanup complete",
    "branch deleted",
    "worktree clean",
    "no in-flight work",
]
RESPONSE_GUARD_CONTINUITY_EVIDENCE_MARKERS = [
    ".ai-continuity/",
    "next_session.md",
    "prepare-continuity",
    "continuity handoff",
    "handoff file",
    "handoff path",
    "wrote continuity",
    "refreshed continuity",
]
RESPONSE_GUARD_SESSION_JOURNAL_EVIDENCE_MARKERS = [
    "prepare-session-journal",
    "publish-session-journal",
    "session journal",
    ".ai-runs/session-journals/",
    "journal pending",
    "journal published",
    "pending session journal",
]
RESPONSE_GUARD_OPT_IN_DIRECT_PHRASES = [
    "want me to",
    "do you want me to",
    "would you like me to",
    "deploy is yours to run",
    "production deploy is yours",
    "alpha deploy is yours",
    "i don't push to production",
    "i do not push to production",
    "deserves fresh context",
    "teed it up as the next action",
    "tee it up as the next action",
    "three forward paths",
    "forward paths:",
    "where would you like to go",
    "what would you like",
    "how do you want to proceed",
    "no work-in-flight",
    "awaiting direction",
    "good stopping point",
    "clean checkpoint",
    "obvious continuation path",
    "pausing here",
    "pause here",
    "stop here",
    "pick up next session",
    "pending your call",
    "pending your decision",
    "waiting on your call",
    "awaiting your call",
    "pick path",
    "choose path",
    "let me know if you want me to proceed",
    "if you'd like me to continue",
    "begin planning, or pause",
    "session has reached its productive limit",
    "productive limit",
    "end session / i'll /compact",
]
RESPONSE_GUARD_ANTHROPOMORPHIC_CAPACITY_MARKERS = [
    "half-asleep",
    "half asleep",
    "too tired",
    "i'm tired",
    "i am tired",
    "fatigued",
    "fatigue",
    "fresh eyes",
    "fresh-eyes",
    "fresh in the morning",
    "sleep on it",
    "sleeping on it",
    "tomorrow morning",
    "it's late",
    "it is late",
]
RESPONSE_GUARD_ANTHROPOMORPHIC_DEFERRAL_MARKERS = [
    "stop",
    "stopping",
    "pause",
    "pausing",
    "defer",
    "deferred",
    "resume",
    "continue later",
    "pick up",
    "next session",
    "fresh session",
    "new session",
    "later",
    "better to",
    "rather than",
    "not going to",
]
RESPONSE_GUARD_ANTHROPOMORPHIC_CAPACITY_RE = re.compile(
    r"(?<![\w-])(?:half[- ]asleep|too tired|i'?m tired|i am tired|fatigued|fatigue|fresh[- ]eyes|fresh in the morning|sleep(?:ing)? on it|tomorrow morning|it'?s late|it is late)(?![\w-])",
    re.I,
)
RESPONSE_GUARD_DECISION_MENU_CONTEXT_MARKERS = [
    "codex",
    "critical",
    "p1",
    "important finding",
    "review",
    "finding",
    "blocker",
    "blocked",
    "scope decision",
    "credential",
    "credentials",
    "served origin",
    "served-origin",
    "live crawl",
    "login crawl",
    "compact",
    "productive limit",
    "unverified",
    "push",
    "merge",
    "gate",
    "preflight",
]
RESPONSE_GUARD_PLAN_CAP_CONTEXT_MARKERS = [
    "2-round cap",
    "round cap",
    "round 2",
    "r2",
    "r3",
    "convergence",
    "non-converging",
    "plan-finalization gate",
    "source-of-truth plans",
    "split the work into smaller",
    "split into smaller",
    "unresolved critical",
    "unresolved p1",
]
RESPONSE_GUARD_WRONG_MERGE_QUEUE_SIGNAL_MARKERS = [
    "estimatedtimetomerge",
    "mergequeueentry",
    "awaiting_checks",
]
RESPONSE_GUARD_WRONG_MERGE_QUEUE_CONTEXT_MARKERS = [
    "still reports",
    "reports awaiting_checks",
    "wakeup",
    "wake up",
    "wait",
    "waiting",
    "poll",
    "polling",
    "reschedule",
    "rescheduling",
    "eta",
    "estimated time",
    "progress",
]
RESPONSE_GUARD_WRONG_MERGE_QUEUE_ACTIVE_CONTEXT_MARKERS = [
    "still reports",
    "reports awaiting_checks",
    "wakeup",
    "wake up",
    "wait",
    "waiting",
    "poll",
    "polling",
    "reschedule",
    "rescheduling",
]
RESPONSE_GUARD_WRONG_MERGE_QUEUE_POLICY_MARKERS = [
    "do not use",
    "never treat",
    "wrong merge-queue signal",
    "forbidden",
]
RESPONSE_GUARD_OPT_IN_ACTION_VERBS = [
    "start",
    "begin",
    "run",
    "execute",
    "implement",
    "proceed",
    "continue",
    "kill",
    "retry",
    "wait",
    "pause",
    "stop",
    "open",
    "commit",
    "push",
    "merge",
    "review",
    "write",
    "create",
    "draft",
    "plan",
    "prepare",
    "queue",
    "fix",
    "publish",
    "deploy",
    "release",
    "submit",
    "switch",
    "move",
    "trigger",
    "go",
    "get",
    "do",
]
RESPONSE_GUARD_DISCUSSION_FORBIDDEN_ACTION_VERBS = [
    "start",
    "begin",
    "run",
    "execute",
    "implement",
    "proceed",
    "continue",
    "kill",
    "retry",
    "wait",
    "pause",
    "stop",
    "open",
    "commit",
    "push",
    "merge",
    "review",
    "write",
    "create",
    "draft",
    "plan",
    "prepare",
    "queue",
    "fix",
    "publish",
    "deploy",
    "release",
    "submit",
    "switch",
    "move",
    "trigger",
    "build",
    "scaffold",
]
RESPONSE_GUARD_RCA_TRIGGERS = [
    "rca",
    "root cause",
    "decision trace",
    "postmortem",
    "post-mortem",
    "why did you",
    "why are you",
    "why were you",
    "why did this",
    "why is this",
]
RESPONSE_GUARD_STATUS_PROMPT_MARKERS = [
    "are you still",
    "why",
]
RESPONSE_GUARD_TOOL_REJECTION_MARKERS = [
    "the user doesn't want to proceed with this tool use",
    "tool use was rejected",
    "cancelled by user",
    "tool call was rejected",
    "permission denied",
]
RESPONSE_GUARD_HUMAN_DISCUSSION_REQUEST_MARKERS = [
    "chat about this",
    "chat about it",
    "chat this through",
    "chat through this",
    "chat through it",
    "talk about this",
    "talk about it",
    "talk through this",
    "talk this through",
    "talk through it",
    "discuss this",
    "discuss it",
    "discuss before",
    "discussion before",
    "let's chat",
    "lets chat",
    "let's discuss",
    "lets discuss",
    "clarify this",
    "clarify it",
    "clarify before",
    "clarify first",
    "question first",
    "question before",
    "questions before",
    "i want to chat",
    "i want to discuss",
    "i want to talk",
    "i want to think",
    "can we chat",
    "can we discuss",
    "can we talk",
    "can we think",
    "brainstorm",
    "not ready to answer",
    "before i answer",
    "before answering",
    "help me think",
    "help me reason",
    "walk me through",
    "think through this",
    "think this through",
    "work through this",
    "reason through this",
]
RESPONSE_GUARD_LIVE_GOAL_SESSION_MARKERS = [
    "goal not yet met",
    "goal met",
    "goal achieved",
    "goal complete",
    "goal still in progress",
    "goal evaluator",
    "claude /goal condition is active",
]
RESPONSE_GUARD_ASSISTANT_DISCUSSION_FRAMING_MARKERS = [
    "the user wants to clarify",
    "the user wants to chat",
    "the user wants to discuss",
    "the user wants to talk",
    "the user wants to think",
    "you want to clarify",
    "you want to chat",
    "you want to discuss",
    "you want to talk",
]
RESPONSE_GUARD_DISCUSSION_YIELD_MARKERS = [
    "what would you like to clarify",
    "what would you like to add",
    "tell me what's on your mind",
    "tell me what is on your mind",
    "i'm listening",
    "i am listening",
    "once i understand your thinking",
    "raise something the questions missed",
]
RESPONSE_GUARD_FALSE_ACTIVITY_MARKERS = [
    "yes",
    "was about to",
    "about to",
    "queued",
    "next i'll",
    "i'll now",
    "awaiting direction",
    "pausing",
    "paused",
    "stopped",
    "what would you like",
    "should i",
]
RESPONSE_GUARD_REJECTION_ACK_MARKERS = [
    "rejected",
    "denied",
    "cancelled",
    "canceled",
    "blocked",
    "interrupted",
]
RESPONSE_GUARD_CONTEXT_PRESSURE_MARKERS = [
    "context exhaustion",
    "context ceiling",
    "context window",
    "context limit",
    "context pressure",
    "conversation limit",
    "conversation length",
    "conversation window",
    "working memory",
    "low-context",
    "low context",
    "remaining context",
    "fresh context",
    "compact/restart",
    "compaction",
    "compact",
    "out of room",
    "out of space",
    "rotate-mandatory",
    "context-rotation-check",
    "rotate-now",
    "95%",
]
RESPONSE_GUARD_CONTEXT_STOP_MARKERS = [
    "cannot continue",
    "can't continue",
    "cannot complete in this session",
    "can't complete in this session",
    "cannot finish",
    "can't finish",
    "not going to start",
    "required to continue",
    "requires a fresh context",
    "requires fresh context",
    "deserves fresh context",
    "compact/restart is required",
    "restart is required",
    "compact is required",
    "fresh session",
    "new session",
    "resume from",
    "resume after",
    "next session",
    "handing off",
    "teed it up as the next action",
    "tee it up as the next action",
    "cannot keep enough state",
    "can't keep enough state",
    "stopping point",
    "stop here",
    "i'm going to stop here",
    "i am going to stop here",
    "i'll stop here",
    "i will stop here",
    "stopping here",
    "stopping now",
    "stopping work",
    "pausing here",
    "wrapping up here",
    "next-poll: none",
    "next poll: none",
    "no background process running",
    "productive limit",
    "reached its productive limit",
    "session has reached its productive limit",
]
# Context-based deferral of authorized work must cite a measured visible context
# percentage at or above the soft threshold. Claiming context limits below it, or with
# no measured number, is invalid context-exhaustion theater (RCA 20260601/20260605/
# 20260609: agents claimed "context exhaustion" at 28-44% to defer mandatory work).
RESPONSE_GUARD_CONTEXT_SOFT_THRESHOLD_PERCENT = 60
# A context "signal" for the deferral check is a context-WORD pressure marker (not the bare
# numeric "95%" marker, which would false-flag ordinary "goal is 95% complete" progress) or a
# percentage actually bound to the context level.
RESPONSE_GUARD_CONTEXT_PRESSURE_WORD_MARKERS = [
    marker for marker in RESPONSE_GUARD_CONTEXT_PRESSURE_MARKERS if not marker.rstrip("%").isdigit()
]
# A percentage counts as the context level only when a context word/alias sits next to it
# (and it is not tightly bound to a progress quantity like "80% complete" / "goal at 80%").
RESPONSE_GUARD_CONTEXT_PERCENT_ANCHORS = (
    "context",
    "conversation window",
    "conversation length",
    "working memory",
)
RESPONSE_GUARD_CONTEXT_DEFERRAL_MARKERS = [
    "defer",
    "deferring",
    "deferred",
    "postpone",
    "postponing",
    "postponed",
    "until later",
    "for later",
    "pick this up later",
    "pick it up later",
    "pick up later",
    "leave for later",
    "leave it for later",
    "follow-up later",
    "next pass",
    "set aside",
    "hand off",
    "handing off",
    "handoff",
]
RESPONSE_GUARD_CONTEXT_UNMEASURED_ESCAPE_MARKERS = [
    "does not expose",
    "doesn't expose",
    "not exposed",
    "percent not visible",
    "percentage not visible",
    "no visible context percent",
    "no visible context percentage",
    "context percent unavailable",
    "context percentage unavailable",
    "host does not show",
    "host doesn't show",
]
# D3 (RCA 20260615T181937Z): a context percentage only counts as a real, host-measured reading
# when a provenance marker (the host's own counter/meter/status line) sits next to it. Hedged
# numbers ("roughly 80%", "about 80%", "~80%", "80%+") are self-estimates and must not justify
# stopping authorized work. An agent stopped/handed off citing a fabricated "context at 88%+" and a
# token "/compact" mention, which cleared every existing context gate.
RESPONSE_GUARD_CONTEXT_PERCENT_ESTIMATE_MARKERS = [
    "roughly",
    "about",
    "around",
    "approximately",
    "~",
    "i estimate",
    "i'd estimate",
    "probably",
    "probably around",
    "i guess",
    "i'd guess",
    "my guess",
    "seems like",
    "feels like",
    "somewhere near",
    "close to",
    "ballpark",
    "maybe",
]
RESPONSE_GUARD_CONTEXT_PERCENT_HOST_SOURCE_MARKERS = [
    "host counter",
    "host reports",
    "host-reported",
    "host reported",
    "reported by the host",
    "host shows",
    "host context percent",
    "context meter",
    "context counter",
    "measured context",
    "context measured",
    "(measured)",
    "status line shows",
    "status line reports",
    "host status line",
]
# A genuine in-turn resume signal. The resume carve-out for a /compact-style rotation action
# requires one of these (the host-fallback path inherently resumes via its own fallback markers),
# so a bare "/compact and handing off" with no resume does NOT pass the carve-out.
RESPONSE_GUARD_CONTEXT_RESUME_MARKERS = [
    "resuming the goal",
    "resume the goal",
    "resuming the active goal",
    "resume the active goal",
    "resuming the same goal",
    "resume the same goal",
    "resume the same active goal",
    "and resuming",
    "then resuming",
    "then resume",
    "resume after compaction",
    "resuming after compaction",
    "continuing the goal",
    "continue the goal",
]
# Defer-to-LATER (temporal) markers only. The new gate's resume carve-out checks against THIS
# subset -- NOT the full RESPONSE_GUARD_CONTEXT_DEFERRAL_MARKERS -- so the rotation-housekeeping
# words "hand off"/"handoff"/"set aside" do not defeat a genuine host-fallback handoff (the
# validate.sh host-fallback must-PASS fixture contains "Continuity handoff:").
RESPONSE_GUARD_CONTEXT_DEFER_TO_LATER_MARKERS = [
    "until later",
    "for later",
    "pick this up later",
    "pick it up later",
    "pick up later",
    "leave for later",
    "leave it for later",
    "follow-up later",
    "next pass",
]
RESPONSE_GUARD_MEDIA_DEFERRAL_MARKERS = [
    "i don't do videos",
    "i do not do videos",
    "i can't do videos",
    "i cannot do videos",
    "we don't do videos",
    "we do not do videos",
    "doesn't do videos",
    "does not do videos",
    "don't do video",
    "do not do video",
    "can't do video",
    "cannot do video",
    "outward-facing media-production step",
    "outward-facing media production step",
    "media-production step",
    "media production step",
    "outward-facing artifacts",
]
RESPONSE_GUARD_ITERATION_REVIEW_MEDIA_CONTEXT_MARKERS = [
    "iteration review",
    "goal review",
    "recap video",
    "rendered video",
    "video",
    "poster",
    "s3",
    "cloudfront",
    "google chat",
]
RESPONSE_GUARD_CONTEXT_HUMAN_PROMPT_MARKERS = [
    "only you can trigger",
    "only you can run",
    "only you can invoke",
    "you can trigger",
    "you can run /compact",
    "you can invoke /compact",
    "compact me",
    "/compact me",
    "if you want /compact",
    "if you want, /compact",
    "if you want to compact",
    "next move is yours",
    "start a fresh/compacted session",
    "or /goal clear",
    "goal clear to pause",
    "what would you like",
    "how do you want to proceed",
    "tell me to keep going",
    "want me to keep going",
    "should i keep going",
    "keep going?",
]
RESPONSE_GUARD_CONTEXT_ROTATION_ACTION_MARKERS = [
    "running /compact",
    "running compact",
    "run /compact",
    "run compact",
    "issuing /compact",
    "issuing compact",
    "invoke /compact",
    "invoke compact",
    "invoking /compact",
    "invoking compact",
    "compact now",
    "compacting now",
    "context rotation underway",
    "rotation underway",
    "resume the active goal after compaction",
    "next work already underway",
]
RESPONSE_GUARD_CONTEXT_ROTATION_FALLBACK_MARKERS = [
    "mandatory context rotation boundary",
    "host cannot invoke /compact from this agent turn",
    "cannot invoke /compact from this agent turn",
    "cannot run /compact from this agent turn",
    "host slash command is unavailable from this agent turn",
    "exact fresh-session startup action",
    "exact next startup action",
    "resume the active goal after startup gates",
    "resume the same active goal after startup gates",
]
RESPONSE_GUARD_CONTEXT_NEGATIVE_ACTION_MARKERS = [
    "cannot invoke /compact",
    "cannot invoke `/compact`",
    "cannot compact from inside this turn",
    "cannot compact inside this turn",
    "cannot run /compact",
    "cannot run `/compact`",
    "can't invoke /compact",
    "can't invoke `/compact`",
    "can't compact from inside this turn",
    "can't compact inside this turn",
    "can't run /compact",
    "can't run `/compact`",
    "i can't compact",
    "i cannot compact",
    "unable to invoke /compact",
    "unable to invoke `/compact`",
    "unable to compact from inside this turn",
    "unable to compact inside this turn",
    "unable to run /compact",
    "unable to run `/compact`",
]
RESPONSE_GUARD_CONTEXT_ROTATION_ACTION_RE = re.compile(
    r"\b(?:run|running|invoke|invoking|issue|issuing|execute|executing|call|calling|start|starting)\s+`?/(?:compact|clear)`?\b",
    re.I,
)
DEFAULT_RCA_ARCHIVE_DIR = REPO_ROOT / "docs" / "backlog" / "methodology-regressions"
DEFAULT_RCA_BRANCH = "methodology-rca-archive"
DEFAULT_FEATURE_REQUEST_ARCHIVE_DIR = REPO_ROOT / "docs" / "backlog" / "feature-requests"
DEFAULT_FEATURE_REQUEST_BRANCH = "methodology-feature-request-archive"
DEFAULT_SESSION_JOURNAL_ARCHIVE_DIR = REPO_ROOT / "docs" / "backlog" / "session-journals"
DEFAULT_SESSION_JOURNAL_BRANCH = "methodology-session-archive"
RCA_ARCHIVE_SUBPATH = "docs/backlog/methodology-regressions"
FEATURE_REQUEST_ARCHIVE_SUBPATH = "docs/backlog/feature-requests"
SESSION_JOURNAL_ARCHIVE_SUBPATH = "docs/backlog/session-journals"


def canonical_archive_dir(default: Path, subpath: str) -> Path:
    """Re-anchor a REPO_ROOT-baked archive default on the checkout sync manages.

    Archive copies are working-tree data a maintainer commits by hand, so they belong in the
    canonical checkout -- never in the exec root, which post-cutover is an immutable snapshot
    (the write would simply EACCES). The constants stay the authored defaults and are returned
    untouched whenever the two roots are the same directory, which is every dev checkout and
    every pre-cutover machine.
    """
    canonical = canonical_methodology_repo()
    if canonical == REPO_ROOT:
        return default
    return canonical / subpath
SESSION_JOURNAL_SCHEMA = "minervit-session-journal/v1"
EVENT_LOG_SCHEMA = "minervit-repo-event/v1"
USAGE_RECORD_SCHEMA = "minervit-usage-record/v1"
EVENT_SEVERITIES = {"info", "ok", "warn", "block", "fail"}
EVENT_MAX_FIELD_CHARS = 1000
EVENT_MAX_JSON_CHARS = 20000
USAGE_CONFIDENCE_VALUES = {"exact", "estimated", "unknown"}
USAGE_MAX_JSON_CHARS = 20000
EVENT_START_SUFFIX = "_started"
EVENT_REQUIRED_BOUNDARY_FAMILIES = {
    "startup",
    "planning_review_gate",
    "preflight",
    "pr_queue_merge",
    "blocker",
    "rca",
    "continuity",
    "context_rotation",
    "goal_transition",
    "milestone_transition",
}
EVENT_TERMINAL_SUFFIXES = (
    "_passed",
    "_pass",
    "_completed",
    "_complete",
    "_finished",
    "_green",
    "_failed",
    "_fail",
    "_blocked",
    "_stale",
    "_timeout",
    "_timed_out",
    "_cancelled",
    "_canceled",
    "_queued",
    "_published",
    "_written",
)
SESSION_JOURNAL_REQUIRED_HEADINGS = [
    "## Session Runtime",
    "## Starting Context",
    "## Work Delivered Or Advanced",
    "## Planning And Review Gates",
    "## Human Interruptions Or Questions",
    "## Delays, Waits, Or Autonomy Breakdowns",
    "## Validation And PR State",
    "## Continuity Outcome",
    "## Methodology Improvement Signals",
]
SESSION_JOURNAL_SECRET_PATTERNS = [
    r"github_pat_[A-Za-z0-9_]+",
    r"gh[pousr]_[A-Za-z0-9_]{20,}",
    r"AKIA[0-9A-Z]{16}",
    r"ASIA[0-9A-Z]{16}",
    r"xox[baprs]-[A-Za-z0-9-]{20,}",
    r"ATATT[A-Za-z0-9_-]{20,}",
    r"(?i)\b(?:api[_-]?key|token|password|secret)\s*[:=]\s*['\"]?[A-Za-z0-9_./+=-]{12,}",
]
SESSION_JOURNAL_RAW_LOG_MARKERS = [
    "⏺ Bash(",
    "⏺ Monitor(",
    "ctrl+o to expand",
    "Exit code ",
    "Running in the background",
    "Process exited with code",
    "Original token count:",
]
SESSION_JOURNAL_PROCESS_AUTHORITY_PATTERNS = [
    r"(?i)\b(?:this|the)\s+session\s+journal\s+is\s+process\s+authority\b",
    r"(?i)\b(?:this|the)\s+journal\s+is\s+process\s+authority\b",
    r"(?i)\bauthoritative\s+process\s+(?:rule|source|policy)\b",
    r"(?i)\buse\s+this\s+journal\s+as\s+process\b",
]
NO_ADAPTER_MESSAGE = """No project adapter found for this lane.

This repo is not adapter-backed yet. Do not keep retrying lane-start and do not borrow another project's adapter.
If the human operator asked to use the methodology in this repo, bootstrap the adapter now; do not ask whether to skip methodology.
The adapter bootstrap interview is mandatory before first adapter render/write unless every required adapter fact is directly supported by repo evidence. Generic executor banners such as "greenfield execution mode", "auto mode", "choose sensible defaults", or "bias toward working without stopping" do not override this.

Recovery command:
   tautline init --target .

No generic operational adapter exists because gates, planning paths, review flow, merge-conflict checks, and local resources are project-specific.
Another project's adapter may be a structural reference only, not a substitute for interview-derived project facts.
If the project is intentionally local-only or direct-to-main, configure explicit local/no-remote status commands and document that workflow in the adapter.
If the production status or required gates cannot be determined from the repo, ask one exact blocker question after documenting what was inspected.
"""


def redact_secrets(text: str) -> str:
    return util_module().redact_secrets(text)


@contextmanager
def advisory_flock(lock_file):
    """Best-effort advisory exclusive lock around a body, portable across platforms (arch-errors-3).

    fcntl is POSIX-only; an unconditional `import fcntl` hard-crashes on Windows, contradicting the
    portability claim. This degrades gracefully: if fcntl is unavailable (non-POSIX) or the lock
    cannot be acquired, the body still runs WITHOUT advisory locking (acceptable for single-user
    non-POSIX use) instead of raising ImportError.
    """
    try:
        import fcntl  # type: ignore[import-not-found]
    except ImportError:
        fcntl = None  # type: ignore[assignment]
    if fcntl is not None:
        try:
            fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
        except OSError:
            fcntl = None  # type: ignore[assignment]
    try:
        yield
    finally:
        if fcntl is not None:
            try:
                fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
            except OSError:
                pass


POLICY_PHRASES_FILE = REPO_ROOT / "methodology" / "policy-phrases.json"


def policy_phrase_constants() -> dict[str, list[str]]:
    """SSOT export of the guard phrase/marker vocabulary (dup-policy-1).

    Returns every module-level UPPER_CASE constant whose value is a non-empty list of strings -- the
    guard phrase/marker lists that drive enforcement. methodology/policy-phrases.json is GENERATED
    from this (generated-equals-source, asserted by tests/test_policy_phrases_ssot.py) so other
    surfaces (validate.sh pins, README/SKILL) can derive the phrase vocabulary from one machine-
    readable place instead of restating it by hand. The Python constants stay the authored source
    (keeping their inline rationale comments); the JSON is the derived projection.
    """
    return policy_module().policy_phrase_constants(globals())


def render_policy_phrases_json() -> str:
    return policy_module().render_policy_phrases_json(globals())


POLICY_PHRASES_REFERENCE_FILE = REPO_ROOT / "methodology" / "policy-phrases-reference.md"


def render_policy_phrases_reference() -> str:
    """dup-policy-2: a human-readable surface GENERATED from the SSOT (not restated by hand).

    This is the README/SKILL-style projection of the guard phrase vocabulary, derived from the same
    constants as policy-phrases.json so documentation can transclude/point at one generated place
    instead of re-listing phrases (which drift). Regenerated by `dump-policy-phrases --write`.
    """
    return policy_module().render_policy_phrases_reference(globals())


def dump_policy_phrases(args: argparse.Namespace) -> int:
    """`dump-policy-phrases`: regenerate the SSOT projections of the guard phrase vocabulary from the
    CLI constants -- methodology/policy-phrases.json (machine-readable) and policy-phrases-reference.md
    (human-readable, dup-policy-2). --check verifies both are up to date (generated-equals-source)
    without writing.
    """
    targets = [
        (args.output or POLICY_PHRASES_FILE, render_policy_phrases_json()),
        (POLICY_PHRASES_REFERENCE_FILE, render_policy_phrases_reference()),
    ]
    if getattr(args, "check", False):
        stale = [str(p) for p, rendered in targets if (p.read_text(encoding="utf-8") if p.exists() else "") != rendered]
        if stale:
            print(
                f"policy_phrases_check: STALE - {', '.join(stale)} does not match the CLI phrase "
                "constants; run `tautline dump-policy-phrases --write`",
                file=sys.stderr,
            )
            return 1
        print(f"policy_phrases_check: up to date ({len(policy_phrase_constants())} lists)")
        return 0
    if getattr(args, "write", False):
        for path, rendered in targets:
            path.write_text(rendered, encoding="utf-8")
            print(f"policy_phrases_written: {path}")
        return 0
    sys.stdout.write(targets[0][1])
    return 0


TELEMETRY_ENABLED_ENV = "MINERVIT_METHODOLOGY_TELEMETRY"
TELEMETRY_PATH_ENV = "MINERVIT_METHODOLOGY_TELEMETRY_PATH"


def telemetry_enabled() -> bool:
    return telemetry_module().telemetry_enabled()


def telemetry_path() -> Path:
    return telemetry_module().telemetry_path()


def record_gate_telemetry(gate: str, outcome: str, **fields: object) -> None:
    """MISSING-telemetry: opt-in, local-first, anonymized gate-fire telemetry (default OFF).

    Records ONLY the gate id, outcome, coarse numeric fields (e.g. error_count) + a timestamp. NEVER
    records code, prompts, paths, or secrets -- so the vendor can converge the denylist from real
    false-block rates without exfiltrating user content. Writes a local JSONL the user can inspect
    (`telemetry --show`) or clear; this CLI never sends it anywhere. Best-effort: any error is
    swallowed so telemetry can never break a guard.
    """
    return telemetry_module().record_gate_telemetry(gate, outcome, **fields)


GUARD_EVENT_MECHANISMS = {"phrase", "state", "regex-command"}
GUARD_EVENT_DETAIL_LIMIT = 200
GUARD_EVENTS_FILE = ".ai-runs/guard-events.jsonl"


def guard_log_event(adapter_root: Path | str, check_id: str, fired: bool, mechanism: str, detail: object = "") -> None:
    return telemetry_module().guard_log_event(adapter_root, check_id, fired, mechanism, detail)


def parse_guard_report_since(value: str | None) -> datetime | None:
    return telemetry_module().parse_guard_report_since(value)


def guard_event_after_since(record: dict, since: datetime | None) -> bool:
    return telemetry_module().guard_event_after_since(record, since)


def guard_report(args: argparse.Namespace) -> int:
    target = args.target.expanduser().resolve(strict=False)
    try:
        since = parse_guard_report_since(args.since)
    except ValueError as exc:
        print(f"guard_report_error: invalid --since timestamp: {exc}", file=sys.stderr)
        return 2
    path = target / GUARD_EVENTS_FILE
    counts: dict[tuple[str, str], dict[str, int]] = {}
    if path.exists():
        for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
            if not line.strip():
                continue
            try:
                record = json.loads(line)
            except json.JSONDecodeError:
                continue
            if not guard_event_after_since(record, since):
                continue
            check_id = str(record.get("check_id") or "unknown")
            mechanism = str(record.get("mechanism") or "state")
            bucket = counts.setdefault((check_id, mechanism), {"evaluated": 0, "fired": 0})
            bucket["evaluated"] += 1
            if record.get("fired") is True:
                bucket["fired"] += 1
    rows = []
    for check_id, mechanism in sorted(counts):
        bucket = counts[(check_id, mechanism)]
        evaluated = bucket["evaluated"]
        fired = bucket["fired"]
        rows.append(
            {
                "check_id": check_id,
                "mechanism": mechanism,
                "evaluated": evaluated,
                "fired": fired,
                "fire_rate": fired / evaluated if evaluated else 0.0,
            }
        )
    if args.json:
        print(json.dumps({"target": str(target), "since": args.since, "checks": rows}, indent=2, sort_keys=True))
        return 0
    print("check_id\tmechanism\tevaluated\tfired\tfire_rate")
    for row in rows:
        print(f"{row['check_id']}\t{row['mechanism']}\t{row['evaluated']}\t{row['fired']}\t{row['fire_rate']:.3f}")
    return 0


def telemetry_command(args: argparse.Namespace) -> int:
    path = telemetry_path()
    if getattr(args, "clear", False):
        if path.exists():
            path.unlink()
        print(f"telemetry_cleared: {path}")
        return 0
    print(f"telemetry_enabled: {telemetry_enabled()}")
    print(f"telemetry_path: {path}")
    if path.exists():
        lines = path.read_text(encoding="utf-8").splitlines()
        print(f"telemetry_records: {len(lines)}")
        for line in lines[-20:]:
            print(f"  {line}")
    else:
        print(f"telemetry_records: 0 (opt in by exporting {TELEMETRY_ENABLED_ENV}=1)")
    return 0


def name_availability(name: str) -> dict[str, str]:
    """MISSING-trademark: check package-name availability for a candidate product name on npm + PyPI.

    A 404 means the name is free; a 200 means it is taken. Network errors degrade to 'unknown' (never
    raises). GitHub org/domain/USPTO clearance still require manual checks (see the trademark doc).
    """
    return names_module().name_availability(name, opener=urlopen, request_cls=Request, redact=redact_secrets)


def check_name_availability(args: argparse.Namespace) -> int:
    name = str(args.name).strip().lower()
    if not re.fullmatch(r"[a-z0-9][a-z0-9._-]*", name):
        raise SystemExit(f"check-name-availability: invalid package name: {name!r}")
    results = name_availability(name)
    for registry, status in results.items():
        print(f"name_availability_{registry}: {name} -> {status}")
    print(
        "name_availability_note: GitHub org/repo, domain, and USPTO TESS (class 9/42) clearance must "
        "be checked manually before relying on a name"
    )
    return 0


def pilot_report(args: argparse.Namespace) -> int:
    """MISSING-efficacy: aggregate the local signals a non-author pilot needs to judge whether the
    framework helped -- gate-fire counts (from opt-in telemetry) as a false-block proxy. Local-only;
    the human pilot interprets these alongside did-it-ship / time-to-first-lane / rework delta.
    """
    path = telemetry_path()
    counts: dict[str, int] = {}
    total = 0
    if path.exists():
        for line in path.read_text(encoding="utf-8").splitlines():
            try:
                record = json.loads(line)
            except json.JSONDecodeError:
                continue
            key = f"{record.get('gate', '?')}:{record.get('outcome', '?')}"
            counts[key] = counts.get(key, 0) + 1
            total += 1
    print(f"pilot_report_telemetry_path: {path}")
    print(f"pilot_report_total_events: {total}")
    for key in sorted(counts):
        print(f"pilot_report_gate: {key} = {counts[key]}")
    if not telemetry_enabled():
        print(f"pilot_report_note: enable opt-in telemetry ({TELEMETRY_ENABLED_ENV}=1) to collect gate-fire data during the pilot")
    print("pilot_report_note: pair these with did-it-ship, time-to-first-lane, and rework delta when judging whether the framework helped")
    return 0


def bootstrap_fail_command(field: str) -> str:
    return util_module().bootstrap_fail_command(field)


def title_from_slug(value: str) -> str:
    return util_module().title_from_slug(value)


def infer_repo_slug(target: Path) -> str:
    return gitutil_module().infer_repo_slug(target)


def normalize_repo_slug(value: str) -> str | None:
    return util_module().normalize_repo_slug(value)


def target_is_git_worktree(target: Path) -> bool:
    return gitutil_module().target_is_git_worktree(target)


def validate_adapter_repo_matches_target(data: dict, target: Path, require_target_identity: bool = False) -> None:
    expected = normalize_repo_slug(str(data.get("repo", "")))
    if not expected:
        return
    if not target_is_git_worktree(target):
        if require_target_identity:
            raise SystemExit(
                "Project adapter repo cannot be verified because the target is not a git worktree. "
                "Do not use another project's generated lane adapter; bootstrap or render the correct source "
                "adapter inside the project repo."
            )
        return
    actual_raw = infer_repo_slug(target)
    actual = normalize_repo_slug(actual_raw)
    if not actual:
        raise SystemExit(
            "Project adapter repo cannot be verified because the target git remote is missing or unparseable. "
            "Do not use another project's adapter; add the correct remote or bootstrap a local-only source adapter "
            "whose repo field explicitly documents the no-remote workflow."
        )
    if expected != actual:
        # Rebrand transition: the methodology repo's own new/legacy slugs are equivalent, so an
        # unconverted checkout (legacy origin URL) still starts under the updated self-adapter.
        methodology_slugs = {normalize_repo_slug(slug) for slug in methodology_repo_slugs()}
        if expected in methodology_slugs and actual in methodology_slugs:
            return
        raise SystemExit(
            "Project adapter repo does not match the target git remote "
            f"({data.get('repo')} != {actual_raw}). Do not use another project's adapter; "
            "bootstrap or render the correct source adapter for this repo."
        )


def project_scaffold(project_name: str, repo_slug: str, schema_path: str) -> dict:
    project_slug = slugify(project_name)
    return {
        "$schema": schema_path,
        "_framework": {
            "channel": "stable",
            "version": "",
            "updatePolicy": "manual",
            "migrationPolicy": "dry-run",
        },
        "project": project_name,
        "repo": repo_slug,
        "productionDeployExists": True,
        "bootstrapEvidence": {
            "project": project_name,
            "status": "BOOTSTRAP REQUIRED: interviewed | repo-evident.",
            "summary": (
                "BOOTSTRAP REQUIRED: summarize repo-evident facts and human-operator answers used to choose "
                "project stack, gates, review flow, deployment posture, autonomy boundaries, and project rules."
            ),
            "interviewArtifact": {
                "path": DEFAULT_BOOTSTRAP_INTERVIEW_PATH,
                "sha256": "BOOTSTRAP REQUIRED: write the bootstrap interview artifact, answer it, and record its SHA256.",
            },
        },
        "backlogAdapter": (
            "BOOTSTRAP REQUIRED: inspect the project backlog, issue tracker, or docs and replace this with "
            "the lower-severity finding routing policy."
        ),
        "planningArtifacts": {
            "sourceOfTruth": "docs/ai/specs/",
            "template": "docs/ai/templates/pr-execution-spec.template.md",
            "templateTrigger": "Non-trivial, protected-category, infrastructure, customer-facing, or adapter-required PR work.",
            "reviewExemptions": DEFAULT_PLANNING_ARTIFACTS["reviewExemptions"],
            "scratchPaths": ["~/.claude/plans/"],
            "rule": (
                "BOOTSTRAP REQUIRED: replace with the project-specific plan/spec source-of-truth rule. "
                "Tool default plan paths remain scratch only."
            ),
        },
        "goalArtifacts": {
            "sourceOfTruth": "docs/ai/specs/goals",
            "template": "docs/ai/specs/goals/goal.template.md",
            "templateTrigger": DEFAULT_GOAL_ARTIFACTS["templateTrigger"],
            "reviewRequired": True,
        },
        "goalExecution": DEFAULT_GOAL_EXECUTION,
        "laneCoordination": DEFAULT_LANE_COORDINATION,
        "backlogProvider": DEFAULT_BACKLOG_PROVIDER,
        "goalTracker": DEFAULT_GOAL_TRACKER,
        "stakeholderQuestions": DEFAULT_STAKEHOLDER_QUESTIONS,
        "latestCode": DEFAULT_LATEST_CODE,
        "workProfiles": scaffold_work_profiles(),
        "commands": {
            **{field: bootstrap_fail_command(field) for field in REQUIRED_COMMANDS},
            "branchLivenessCheck": "tautline branch-liveness-check --target . --strict",
        },
        "localResourceIsolation": {
            **DEFAULT_LOCAL_RESOURCE_ISOLATION,
            "composeProjectPrefix": f"minervit-{project_slug}",
        },
        "review": {
            "codexWrapper": "BOOTSTRAP REQUIRED: configure the project Codex review wrapper or native review instruction.",
            "codexPlanWrapper": "BOOTSTRAP REQUIRED: configure the project Codex plan-review wrapper or native review instruction.",
            "codexFastMode": DEFAULT_REVIEW["codexFastMode"],
            "prePushReviewEvidence": DEFAULT_REVIEW["prePushReviewEvidence"],
            "claudeReview": "BOOTSTRAP REQUIRED: configure the project Claude review workflow.",
            "crossModelTiming": "before push",
        },
        "behaviorSpecs": {
            "required": False,
            "paths": [],
            "exemption": "BEHAVIOR-SPEC-EXEMPT: <reason>",
            "sourceMaterials": [],
            "sourceMaterialRule": (
                "If reviewed external/customer/business behavior specs are provided, review and adapt them before "
                "authoring new scenarios; preserve business intent and document deviations."
            ),
            "pendingTags": DEFAULT_BEHAVIOR_PENDING_TAGS,
            "inactiveAcceptanceEnforcement": "warn",
            "maxPendingScenarios": 0,
            "pendingRequiresOwnerAndTrigger": True,
            "acceptanceHarnesses": [],
            "roleVocabulary": {
                "allowed": [],
                "forbidden": [],
            },
        },
        "bugBacklog": {
            "sourceOfTruth": "BOOTSTRAP REQUIRED: configure the bug source of truth, such as backlog specs, GitHub issues, Jira, Linear, or another tracker.",
            "issueMirrors": [],
            "severityTaxonomy": "BOOTSTRAP REQUIRED: configure the project bug severity taxonomy.",
            "autoP0Categories": [],
            "autoP1Categories": [
                "auth",
                "email",
                "tenant data",
                "security",
                "deploy",
                "billing",
                "data loss",
                "customer-blocking",
            ],
            "requiredFields": DEFAULT_BUG_BACKLOG["requiredFields"],
            "rule": "BOOTSTRAP REQUIRED: configure when to write source-of-truth bug specs, when to open external issues, and when mirrors are optional.",
        },
        "technologyStack": DEFAULT_TECHNOLOGY_STACK,
        "graphify": DEFAULT_GRAPHIFY,
        # Day-one self-proving posture (rec #10): a NEW project blocks on the ci-test-gate from commit
        # one (warn-then-block migration -- the runtime DEFAULT stays warn so existing rendered adapters
        # are never silently wedged, the 0.6.115 lesson; new projects opt into block here). A repo with
        # no deploymentTargets is still a no-op until it declares a customer-facing surface.
        "ciTestGate": {
            "enabled": True,
            "enforcement": "block",
            "events": ["pull_request", "push"],
            "expectTests": True,
        },
        "uiEvidence": DEFAULT_UI_EVIDENCE,
        "deploymentTargets": DEFAULT_DEPLOYMENT_TARGETS,
        "continuity": {
            "path": ".ai-continuity/NEXT_SESSION.md",
            "archiveDir": ".ai-continuity/archive",
            "gitIgnore": True,
        },
        "laneState": DEFAULT_LANE_STATE,
        "milestoneContinuation": DEFAULT_MILESTONE_CONTINUATION,
        "sessionJournal": DEFAULT_SESSION_JOURNAL,
        "observabilityEvents": DEFAULT_OBSERVABILITY_EVENTS,
        "usageAccounting": DEFAULT_USAGE_ACCOUNTING,
        "iterationReview": DEFAULT_ITERATION_REVIEW,
        "milestoneUpdate": DEFAULT_MILESTONE_UPDATE,
        "productChat": DEFAULT_PRODUCT_CHAT,
        "deploymentNotification": DEFAULT_DEPLOYMENT_NOTIFICATION,
        "contextRotation": DEFAULT_CONTEXT_ROTATION,
        "readiness": {"sources": []},
        "knownProjectRules": [
            "BOOTSTRAP REQUIRED: replace with verified project-specific invariants or remove this item."
        ],
        "generatedFiles": ["CLAUDE.md", "AGENTS.md", LANE_ADAPTER_FILE],
    }


ADAPTER_BOOTSTRAP_QUESTIONS = [
    (
        "Product And Deployment",
        [
            "What product or operational capability does this repo deliver, and who are the primary users/operators?",
            "Does any production, staging, demo, or customer-visible deployment already exist? If yes, what environments exist and which ones count as milestone-close deployment targets?",
            "What human approvals are required before production-impacting changes, data migrations, security changes, or cost-affecting infrastructure changes?",
            "Is there a product Google Chat space for quick human-requested notes? If yes, what environment variable should hold its webhook URL?",
            "Should deploy closeout post a Google Chat ready-to-review notification after the live dev/staging site has finished rolling forward? If yes, should it reuse iteration-review Chat or use a separate webhook env var?",
        ],
    ),
    (
        "Technical Stack Policy",
        [
            "Which parts of the technical stack are non-negotiable for this project, such as cloud provider, hosting, database, auth, queue, observability, language, or framework choices?",
            "Which cloud providers or hosting platforms are approved? Default for new projects is AWS-only unless the project adapter explicitly overrides it.",
            "For AWS-approved lanes, which AWS CLI profile, SSO/login flow, or identity check should deployments use before considering SSH keys or alternate credentials?",
            "Which new platform choices require explicit human approval because they add billing, security, deployment, operations, or support burden?",
        ],
    ),
    (
        "Workflow And Source Of Truth",
        [
            "What is the normal delivery workflow: pull request, merge queue/auto-merge, direct-to-main, release branch, or another path?",
            "Where is the authoritative backlog or work tracker? If GitHub/Jira/Linear/issues are mirrors only, say which source wins.",
            "Should an external backlog provider such as GitHub Projects supply stakeholder-facing goals, milestones, bugs, or tasks? If yes, provide provider, owner, project number, item type/status/priority/milestone fields, status values, and exactly what the board controls.",
            "Should stakeholder clarifying questions be asked and answered on GitHub Issues? If yes, which GitHub handle should be tagged by default and which open/answered labels should be used?",
            "For an existing repo backlog, should each current goal/milestone/bug/task be interviewed before export to the external provider for stakeholder prioritization?",
            "Where should non-trivial PR-level plans/specs live, and is there an existing template or naming convention?",
            "Where should source-of-truth goal plans live for multi-milestone work, and what makes a goal achievable and tightly scoped for this project?",
            "Where should cross-lane coordination artifacts live when multiple AI lanes work simultaneously, and what contract/ownership fields should every lane report?",
        ],
    ),
    (
        "Bug Backlog And Incident Intake",
        [
            "How should bugs be triaged: severity taxonomy, source-of-truth artifact, and when external issues are required versus optional mirrors?",
            "Which bug categories are automatically P0/P1, such as auth, email, tenant data, security, deploy, billing, data loss, or customer-blocking failures?",
        ],
    ),
    (
        "Required Gates",
        [
            "What command proves main is healthy before new work starts?",
            "What fast preflight should run before commit, and what full preflight/test-environment gate should run before push or merge?",
            "What command checks open PR health and what command checks the current branch for merge conflicts?",
            "Which gates require local service isolation, special env vars, seeded data, or external credentials?",
        ],
    ),
    (
        "Review And Planning",
        [
            "What is the configured Codex code-review wrapper or review command for implementation diffs?",
            "What is the configured Codex plan-review wrapper? It may be the same script only when that script has a plan-only mode and does not review implementation diffs for `--plan`.",
            "Should Codex CLI fast mode stay enabled for framework-launched Codex calls? Default is enabled; disable only with a project-specific reason.",
            "What is the configured Claude/native review workflow?",
            "Which kinds of work always require cross-model plan review before implementation?",
            "Should Claude lanes prefer `/goal` for reviewed multi-milestone work when Claude Code supports it, or should this project use only the generic goal ledger?",
        ],
    ),
    (
        "Local Resources",
        [
            "What local services, ports, Docker Compose project names, queues, browsers, or databases can collide across lanes?",
            "Are any resources truly non-isolatable and therefore need locks instead of per-lane ports/names?",
        ],
    ),
    (
        "Docs And Readiness",
        [
            "Which documents should a new session read first as readiness sources?",
            "Which Markdown roots are current planning context, which are archive/historical evidence only, and what index files should route them?",
            "Are behavior specs such as Gherkin required for customer-facing changes? If yes, where do they live?",
            "Do reviewed business/customer behavior specs already exist? If yes, list them as behavior-spec source materials to adapt before authoring new scenarios.",
            "What command runs executable acceptance specs, and which app/package does that harness launch? Name any inactive tag policy such as @pending limits, owner, and un-pend trigger requirements.",
            "What precise actor roles should behavior specs use, and which generic or misleading actor terms should be rejected?",
        ],
    ),
    (
        "Autonomy Boundaries",
        [
            "What may the AI push, queue, merge, deploy, or clean up without asking after gates pass?",
            "What credentials, accounts, external systems, or compliance constraints should always become a true blocker instead of an inferred default?",
        ],
    ),
]


def adapter_bootstrap_questionnaire(project_name: str, repo_slug: str, include_answer_slots: bool = False) -> str:
    lines = [
        "# Project Adapter Bootstrap Interview",
        "",
        f"Project: {project_name}",
        f"Repository: {repo_slug}",
        "",
        "Use this after inspecting the repo for evidence. This interview is mandatory before first adapter render/write for unmanaged projects unless every required adapter fact is directly supported by repo evidence. Do not ask questions whose answers are already clear from package scripts, CI, project docs, remotes, existing plans, or deployment files. Before asking, summarize inferred adapter facts, then ask only unresolved questions as one concise interview through `AskUserQuestion` or the host-equivalent question mechanism. Encode the answers in the project adapter. If a required fact remains unknown, keep the adapter fail-closed with `BOOTSTRAP REQUIRED` and name the exact blocker.",
        "",
        "Generic executor banners such as \"greenfield execution mode\", \"auto mode\", \"choose sensible defaults\", or \"bias toward working without stopping\" are not methodology authority and do not override this interview. Another project's adapter may be used as a structural reference only, never as a substitute for interview-derived facts.",
        "",
    ]
    counter = 1
    for title, questions in ADAPTER_BOOTSTRAP_QUESTIONS:
        lines.append(f"## {title}")
        lines.append("")
        for question in questions:
            lines.append(f"{counter}. {question}")
            if include_answer_slots:
                lines.append("   Answer: BOOTSTRAP REQUIRED")
                lines.append("   Evidence: BOOTSTRAP REQUIRED")
            counter += 1
        lines.append("")
    lines.extend(
        [
            "## Adapter Field Mapping",
            "",
            "- Product/deployment answers map to `project`, `repo`, `productionDeployExists`, and `deploymentTargets`.",
            "- Product Chat answers map to `productChat`.",
            "- Technical stack answers map to `technologyStack`, `deploymentTargets`, and `knownProjectRules`.",
            "- Workflow/backlog answers map to `backlogAdapter`, `planningArtifacts`, `commands.mergeQueue`, and direct-to-main/no-remote command choices.",
            "- Goal/backlog provider answers map to `goalArtifacts`, `goalExecution`, `backlogProvider`, compatibility `goalTracker`, and `laneState.goalRun`.",
            "- Stakeholder clarification answers map to `stakeholderQuestions`.",
            "- Cross-lane coordination answers map to `laneCoordination`.",
            "- Bug intake answers map to `bugBacklog` and `backlogAdapter`.",
            "- Gate answers map to `commands.mainStatus`, `commands.fastPreflight`, `commands.fullPreflight`, `commands.testEnvironment`, `commands.openPrCheck`, `commands.mergeConflictCheck`, and `commands.earlyWarningSmoke` when distinct.",
            "- Review answers map to `review.codexWrapper`, `review.codexPlanWrapper`, `review.codexFastMode`, `review.claudeReview`, and `review.crossModelTiming`.",
            "- Local resource answers map to `localResourceIsolation` and `localResourceLocks`.",
            "- Docs/readiness answers map to `readiness.sources`, `documentContext`, and `behaviorSpecs`.",
            "- Autonomy answers map to `knownProjectRules`, deployment target procedure/rollback fields, and project-specific approval language.",
        ]
    )
    return "\n".join(lines) + "\n"


def collect_bootstrap_placeholders(value: object, path: str = "") -> list[str]:
    placeholders: list[str] = []
    if isinstance(value, dict):
        for key, item in value.items():
            item_path = f"{path}.{key}" if path else str(key)
            placeholders.extend(collect_bootstrap_placeholders(item, item_path))
    elif isinstance(value, list):
        for index, item in enumerate(value):
            placeholders.extend(collect_bootstrap_placeholders(item, f"{path}[{index}]"))
    elif isinstance(value, str) and "BOOTSTRAP REQUIRED" in value:
        placeholders.append(path)
    return placeholders


def trusted_source_adapter_roots() -> list[Path]:
    """Roots under which an adapter JSON is accepted as a canonical methodology source adapter.

    prod-onboarding-1: the framework's `adapters/projects/` is always trusted. An operator can
    designate ONE additional trusted root via MINERVIT_METHODOLOGY_ADAPTER_ROOT (e.g. a private
    `minervit-adapters` repo, or the adopter's own product repo) so the source adapter need NOT be
    committed into the framework repo. The root is operator-controlled (env), not attacker-supplied
    cwd discovery, so it does not widen the render trust boundary the way auto-adopting any
    lane-local JSON would. Adapters under the extra root still need real bootstrapEvidence
    (repo-evident/interviewed) and pass the same schema + identity checks.

    Canonical first (see methodology_data_roots): the private adapters this trust boundary exists
    for live in the canonical checkout's working tree, not in the snapshot we execute.
    """
    roots = [(root / "adapters/projects").resolve() for root in methodology_data_roots()]
    extra = util_module().resolve_env("MINERVIT_METHODOLOGY_ADAPTER_ROOT", "").strip()
    if extra:
        try:
            roots.append(Path(extra).expanduser().resolve())
        except OSError:
            pass
    return roots


def source_adapter_requires_bootstrap_evidence(path: Path) -> bool:
    try:
        resolved = path.resolve()
    except OSError:
        return False
    for root in trusted_source_adapter_roots():
        try:
            resolved.relative_to(root)
            return True
        except ValueError:
            continue
    return False


def repo_local_source_adapter_path(target: Path) -> Path:
    canonical = target / REPO_LOCAL_ADAPTER_FILE
    if canonical.exists():
        return canonical
    legacy = target / LEGACY_REPO_LOCAL_ADAPTER_FILE
    if legacy.exists():
        return legacy
    return canonical  # default write target


def is_repo_local_source_adapter(path: Path, target: Path) -> bool:
    try:
        resolved = path.resolve()
        return resolved in (
            (target / REPO_LOCAL_ADAPTER_FILE).resolve(),
            (target / LEGACY_REPO_LOCAL_ADAPTER_FILE).resolve(),
        )
    except OSError:
        return False


def source_adapter_allowed_for_target(path: Path, target: Path) -> bool:
    return source_adapter_requires_bootstrap_evidence(path) or is_repo_local_source_adapter(path, target)


def source_adapter_repo_relative_path(path: Path) -> str | None:
    resolved = path.resolve()
    for root in methodology_data_roots():
        try:
            return resolved.relative_to(root.resolve()).as_posix()
        except ValueError:
            continue
    return None


def source_adapter_legacy_allowlisted(path: Path, project_name: str) -> bool:
    rel = source_adapter_repo_relative_path(path)
    if not rel:
        return False
    for item in legacy_bootstrap_allowlist_entries():
        if item.get("path") == rel and item.get("project") == project_name:
            return True
    return False


def legacy_bootstrap_allowlist_entries() -> list[dict]:
    if not LEGACY_BOOTSTRAP_ALLOWLIST.is_file():
        return []
    try:
        payload = json.loads(LEGACY_BOOTSTRAP_ALLOWLIST.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return []
    return [item for item in payload.get("adapters", []) if isinstance(item, dict)]


def legacy_bootstrap_project_allowlisted(project_name: str) -> bool:
    return any(item.get("project") == project_name for item in legacy_bootstrap_allowlist_entries())


def validate_relative_bootstrap_path(path_text: str, field: str) -> None:
    path = Path(path_text)
    if path.is_absolute() or ".." in path.parts:
        raise SystemExit(f"Project adapter bootstrapEvidence.{field} must be a target-relative path")


def resolve_bootstrap_target_path(target: Path, path_text: str, field: str) -> Path:
    validate_relative_bootstrap_path(path_text, field)
    target_root = target.resolve()
    candidate = target / path_text
    try:
        resolved = candidate.resolve(strict=True)
    except FileNotFoundError:
        raise
    try:
        resolved.relative_to(target_root)
    except ValueError as exc:
        raise SystemExit(
            f"Project adapter bootstrapEvidence.{field} must resolve inside the target repo: {path_text}"
        ) from exc
    return resolved


def extract_bootstrap_header(text: str, field: str) -> str | None:
    match = re.search(rf"^{re.escape(field)}:\s*(.+?)\s*$", text, re.MULTILINE)
    return match.group(1).strip() if match else None


def bootstrap_slot_values(text: str, label: str) -> list[str]:
    return [match.group(1).strip() for match in re.finditer(rf"^\s*{label}:\s*(.*)$", text, re.MULTILINE)]


def generic_bootstrap_slot_indexes(values: list[str]) -> list[int]:
    bad = []
    for index, value in enumerate(values, start=1):
        normalized = re.sub(r"\s+", " ", value.strip().lower())
        if not normalized or normalized in GENERIC_BOOTSTRAP_SLOT_VALUES:
            bad.append(index)
    return bad


def validate_bootstrap_evidence_for_target(data: dict, project_path: Path, target: Path) -> None:
    evidence = data.get("bootstrapEvidence")
    if not isinstance(evidence, dict):
        return
    status = evidence.get("status")
    if status == "interviewed":
        artifact = evidence["interviewArtifact"]
        artifact_path = target / artifact["path"]
        if not artifact_path.is_file():
            raise SystemExit(f"Project adapter bootstrap interview artifact missing: {artifact_path}")
        artifact_path = resolve_bootstrap_target_path(target, artifact["path"], "interviewArtifact.path")
        text = artifact_path.read_text(encoding="utf-8", errors="replace")
        missing_sections = [
            title for title, _questions in ADAPTER_BOOTSTRAP_QUESTIONS if f"## {title}" not in text
        ]
        if missing_sections:
            raise SystemExit(
                "Project adapter bootstrap interview artifact is not the generated questionnaire; missing section(s): "
                + ", ".join(missing_sections)
            )
        project_header = extract_bootstrap_header(text, "Project")
        repo_header = extract_bootstrap_header(text, "Repository")
        if project_header != data["project"]:
            raise SystemExit(
                "Project adapter bootstrap interview artifact Project header does not match adapter project "
                f"({project_header or 'missing'} != {data['project']})"
            )
        if repo_header != data["repo"]:
            raise SystemExit(
                "Project adapter bootstrap interview artifact Repository header does not match adapter repo "
                f"({repo_header or 'missing'} != {data['repo']})"
            )
        if BOOTSTRAP_REQUIRED_PREFIX in text:
            raise SystemExit(
                f"Project adapter bootstrap interview artifact still contains {BOOTSTRAP_REQUIRED_PREFIX}: "
                f"{artifact_path}"
            )
        if "Answer:" not in text or "Evidence:" not in text:
            raise SystemExit(
                "Project adapter bootstrap interview artifact must contain answered `Answer:` and `Evidence:` slots"
            )
        expected_answers = sum(len(questions) for _title, questions in ADAPTER_BOOTSTRAP_QUESTIONS)
        answers = bootstrap_slot_values(text, "Answer")
        answer_evidence = bootstrap_slot_values(text, "Evidence")
        if len(answers) < expected_answers or len(answer_evidence) < expected_answers:
            raise SystemExit(
                "Project adapter bootstrap interview artifact must answer every generated question "
                f"({len(answers)} Answer slots, {len(answer_evidence)} Evidence slots, expected {expected_answers})"
            )
        bad_answers = generic_bootstrap_slot_indexes(answers)
        bad_evidence = generic_bootstrap_slot_indexes(answer_evidence)
        if bad_answers or bad_evidence:
            parts = []
            if bad_answers:
                parts.append("Answer slot(s) " + ", ".join(str(item) for item in bad_answers[:5]))
            if bad_evidence:
                parts.append("Evidence slot(s) " + ", ".join(str(item) for item in bad_evidence[:5]))
            raise SystemExit(
                "Project adapter bootstrap interview artifact contains generic slot value(s): "
                + "; ".join(parts)
            )
        actual_hash = file_sha256(artifact_path)
        if actual_hash != artifact["sha256"]:
            raise SystemExit(
                "Project adapter bootstrap interview artifact SHA256 mismatch; rerun the hash after editing "
                f"{artifact_path}"
            )
    elif status == "repo-evident":
        missing = []
        directories = []
        for item in evidence["repoEvidence"]:
            evidence_path = target / item["path"]
            if not evidence_path.exists():
                missing.append(item["path"])
                continue
            resolved = resolve_bootstrap_target_path(target, item["path"], "repoEvidence.path")
            if not resolved.is_file():
                directories.append(item["path"])
        if missing:
            raise SystemExit(
                "Project adapter bootstrap repoEvidence path(s) missing from target: " + ", ".join(missing)
            )
        if directories:
            raise SystemExit(
                "Project adapter bootstrap repoEvidence path(s) must be files, not directories: "
                + ", ".join(directories)
            )
def validate_render_adapter_provenance(data: dict, project_path: Path) -> None:
    if data.get("_generated"):
        return
    if data.get("bootstrapEvidence") is None:
        raise SystemExit(
            "render-adapters requires project adapter bootstrapEvidence. Do not render from ad hoc, copied, "
            "or lane-local adapter JSON without interview or repo-evidence provenance."
        )


def require_canonical_source_adapter(path: Path, command: str) -> None:
    if source_adapter_requires_bootstrap_evidence(path):
        return
    raise SystemExit(
        f"{command} requires --project to point at a canonical source adapter under "
        "<methodology_repo>/adapters/projects/. Do not use ad hoc, copied, or lane-local adapter JSON."
    )


def require_source_adapter_for_target(path: Path, target: Path, command: str) -> None:
    if source_adapter_allowed_for_target(path, target):
        return
    raise SystemExit(
        f"{command} requires --project to point at a trusted source adapter: "
        f"<methodology_repo>/adapters/projects/*.json, MINERVIT_METHODOLOGY_ADAPTER_ROOT, or {REPO_LOCAL_ADAPTER_FILE} "
        "inside the target repo. Do not use ad hoc, copied, or generated lane-local adapter JSON."
    )


def now_iso() -> str:
    return util_module().now_iso()


def warn_deprecated_command(cmd_name: str) -> None:
    replacement = DEPRECATED_COMMAND_REPLACEMENTS.get(cmd_name)
    if not replacement:
        return
    print(
        f"deprecation_warning: command '{cmd_name}' is deprecated; use '{replacement}'. "
        "This warning does not change the command exit code and the command will not be removed before the next major release.",
        file=sys.stderr,
    )


def normalize_framework_pin(raw: object, source: str = "framework pin") -> dict:
    if raw is None:
        raw = {}
    if not isinstance(raw, dict):
        raise SystemExit(f"{source} must be an object")
    pin = {**DEFAULT_FRAMEWORK_PIN, **raw}
    channel = str(pin.get("channel") or DEFAULT_FRAMEWORK_PIN["channel"]).strip()
    if channel not in CLIENT_FRAMEWORK_CHANNELS:
        raise SystemExit(f"{source}.channel must be stable or experimental")
    update_policy = str(pin.get("updatePolicy") or DEFAULT_FRAMEWORK_PIN["updatePolicy"]).strip()
    if update_policy not in FRAMEWORK_UPDATE_POLICIES:
        raise SystemExit(f"{source}.updatePolicy must be one of: {', '.join(sorted(FRAMEWORK_UPDATE_POLICIES))}")
    migration_policy = str(pin.get("migrationPolicy") or DEFAULT_FRAMEWORK_PIN["migrationPolicy"]).strip()
    if migration_policy not in FRAMEWORK_MIGRATION_POLICIES:
        raise SystemExit(
            f"{source}.migrationPolicy must be one of: {', '.join(sorted(FRAMEWORK_MIGRATION_POLICIES))}"
        )
    version = str(pin.get("version") or "").strip()
    if version and not re.fullmatch(r"(?:[~^<>]=?)?[0-9]+(?:\.[0-9]+){1,2}(?:[-+][0-9A-Za-z.-]+)?", version):
        raise SystemExit(f"{source}.version must be an exact semver or simple semver range")
    return {
        "channel": channel,
        "version": version,
        "updatePolicy": update_policy,
        "migrationPolicy": migration_policy,
    }


def normalize_profile_path(path_text: str, source: str) -> str:
    value = str(path_text or "").strip()
    if not value:
        raise SystemExit(f"{source} must be non-blank")
    path = Path(value)
    if path.is_absolute() or value == ".." or value.startswith("../") or "/../" in value:
        raise SystemExit(f"{source} must be project-relative, not absolute or parent-relative")
    return value


def normalize_profile_string_list(raw: object, source: str, *, extensions: bool = False) -> list[str]:
    if raw is None:
        return []
    if not isinstance(raw, list):
        raise SystemExit(f"{source} must be an array")
    values: list[str] = []
    for index, item in enumerate(raw):
        value = str(item or "").strip()
        if not value:
            raise SystemExit(f"{source}[{index}] must be non-blank")
        if extensions:
            value = value.lower()
            if not value.startswith("."):
                raise SystemExit(f"{source}[{index}] must start with a dot")
        elif value.startswith("/") or value == ".." or value.startswith("../") or "/../" in value:
            raise SystemExit(f"{source}[{index}] must be project-relative, not absolute or parent-relative")
        values.append(value)
    return [value for value in dict.fromkeys(values) if value]


def default_work_profile(profile: str) -> dict:
    return json.loads(json.dumps(DEFAULT_WORK_PROFILES["profiles"][profile]))


def scaffold_work_profiles() -> dict:
    profiles = json.loads(json.dumps(DEFAULT_WORK_PROFILES))
    if profiles.get("prePushBase") is None:
        profiles.pop("prePushBase", None)
    return profiles


def normalize_single_work_profile(profile: str, raw: object, source: str) -> dict:
    if raw is None:
        raw = {}
    if not isinstance(raw, dict):
        raise SystemExit(f"{source} must be an object")
    allowed_keys = {
        "description",
        "allowedPaths",
        "additionalAllowedPaths",
        "allowedExtensions",
        "additionalAllowedExtensions",
        "blockedPaths",
        "additionalBlockedPaths",
        "maxFileBytes",
        "pushPolicy",
    }
    unknown_keys = sorted(set(raw) - allowed_keys)
    if unknown_keys:
        raise SystemExit(f"{source} contains unknown key(s): {', '.join(unknown_keys)}")
    base = default_work_profile(profile)
    merged = {**base, **{key: value for key, value in raw.items() if not key.startswith("additional")}}
    description = str(merged.get("description", "")).strip()
    if not description:
        raise SystemExit(f"{source}.description must be non-blank")
    push_policy = str(merged.get("pushPolicy", "pr-branch-only")).strip()
    if push_policy not in WORK_PROFILE_PUSH_POLICIES:
        raise SystemExit(f"{source}.pushPolicy must be one of: {', '.join(sorted(WORK_PROFILE_PUSH_POLICIES))}")
    try:
        max_file_bytes = int(merged.get("maxFileBytes", 0))
    except (TypeError, ValueError) as exc:
        raise SystemExit(f"{source}.maxFileBytes must be a non-negative integer") from exc
    if max_file_bytes < 0:
        raise SystemExit(f"{source}.maxFileBytes must be a non-negative integer")
    allowed_paths = normalize_profile_string_list(merged.get("allowedPaths", []), f"{source}.allowedPaths")
    allowed_paths.extend(
        normalize_profile_string_list(raw.get("additionalAllowedPaths", []), f"{source}.additionalAllowedPaths")
    )
    allowed_extensions = normalize_profile_string_list(
        merged.get("allowedExtensions", []), f"{source}.allowedExtensions", extensions=True
    )
    allowed_extensions.extend(
        normalize_profile_string_list(
            raw.get("additionalAllowedExtensions", []),
            f"{source}.additionalAllowedExtensions",
            extensions=True,
        )
    )
    blocked_paths = normalize_profile_string_list(merged.get("blockedPaths", []), f"{source}.blockedPaths")
    blocked_paths.extend(
        normalize_profile_string_list(raw.get("additionalBlockedPaths", []), f"{source}.additionalBlockedPaths")
    )
    if profile in NON_DEV_WORK_PROFILES and (not allowed_paths or not allowed_extensions):
        raise SystemExit(f"{source} must declare allowedPaths and allowedExtensions")
    return {
        "description": description,
        "allowedPaths": [value for value in dict.fromkeys(allowed_paths) if value],
        "allowedExtensions": [value for value in dict.fromkeys(allowed_extensions) if value],
        "blockedPaths": [value for value in dict.fromkeys(blocked_paths) if value],
        "maxFileBytes": max_file_bytes,
        "pushPolicy": push_policy,
    }


def normalize_work_profiles(raw: object) -> dict:
    if raw is None:
        raw = {}
    if not isinstance(raw, dict):
        raise SystemExit("Project adapter workProfiles must be an object")
    allowed_keys = {"enabled", "defaultProfile", "profileLockPath", "prePushBase", "profiles"}
    unknown_keys = sorted(set(raw) - allowed_keys)
    if unknown_keys:
        raise SystemExit(f"Project adapter workProfiles contains unknown key(s): {', '.join(unknown_keys)}")
    cfg = {**DEFAULT_WORK_PROFILES, **{key: value for key, value in raw.items() if key != "profiles"}}
    if not isinstance(cfg.get("enabled"), bool):
        raise SystemExit("Project adapter workProfiles.enabled must be boolean")
    default_profile = str(cfg.get("defaultProfile", "development")).strip()
    if default_profile not in WORK_PROFILE_CHOICES:
        raise SystemExit("Project adapter workProfiles.defaultProfile must be development, product-docs, or support-docs")
    if default_profile != "development":
        raise SystemExit(
            "Project adapter workProfiles.defaultProfile must remain development; use "
            "`tautline lane-start --profile <profile>` for explicit non-dev sessions"
        )
    profile_lock_path = normalize_profile_path(
        str(cfg.get("profileLockPath", DEFAULT_WORK_PROFILE_LOCK_PATH)),
        "Project adapter workProfiles.profileLockPath",
    )
    pre_push_base_raw = cfg.get("prePushBase")
    pre_push_base = None if pre_push_base_raw is None else str(pre_push_base_raw).strip()
    if pre_push_base == "":
        raise SystemExit("Project adapter workProfiles.prePushBase must be non-blank when set")
    raw_profiles = raw.get("profiles", {})
    if raw_profiles is None:
        raw_profiles = {}
    if not isinstance(raw_profiles, dict):
        raise SystemExit("Project adapter workProfiles.profiles must be an object")
    unknown_profiles = sorted(set(raw_profiles) - WORK_PROFILE_CHOICES)
    if unknown_profiles:
        raise SystemExit(
            "Project adapter workProfiles.profiles contains unsupported profile(s): "
            + ", ".join(unknown_profiles)
        )
    profiles = {
        profile: normalize_single_work_profile(
            profile,
            raw_profiles.get(profile, {}),
            f"Project adapter workProfiles.profiles.{profile}",
        )
        for profile in sorted(WORK_PROFILE_CHOICES)
    }
    normalized = {
        "enabled": bool(cfg["enabled"]),
        "defaultProfile": default_profile,
        "profileLockPath": profile_lock_path,
        "profiles": profiles,
    }
    if pre_push_base is not None:
        normalized["prePushBase"] = pre_push_base
    return normalized


def framework_pin_path(target: Path) -> Path:
    return target / FRAMEWORK_PIN_FILE


def load_framework_pin_file(target: Path) -> dict | None:
    path = framework_pin_path(target)
    if not path.exists():
        return None
    try:
        payload = json.loads(path.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        raise SystemExit(f"framework pin file is not valid JSON: {path}: {exc}") from exc
    except OSError as exc:
        raise SystemExit(f"framework pin file is unreadable: {path}: {exc}") from exc
    return normalize_framework_pin(payload, str(path))


def effective_framework_pin(data: dict, target: Path) -> tuple[dict, str]:
    adapter_pin = normalize_framework_pin(data.get("_framework"), "Project adapter _framework")
    file_pin = load_framework_pin_file(target)
    if file_pin is None:
        return adapter_pin, "adapter"
    return {**adapter_pin, **file_pin}, FRAMEWORK_PIN_FILE


def framework_pin_status_line(pin: dict, source: str) -> str:
    version = pin["version"] or "unversioned-current"
    return (
        "framework_pin: "
        f"channel={pin['channel']} version={version} updatePolicy={pin['updatePolicy']} "
        f"migrationPolicy={pin['migrationPolicy']} source={source}"
    )


def framework_channel_branch(channel: str) -> str:
    return FRAMEWORK_CHANNEL_BRANCHES.get(channel, "main")


def framework_channel_source(args: argparse.Namespace) -> str:
    if getattr(args, "adapter", False):
        return "adapter"
    return str(getattr(args, "source", "pin") or "pin").strip()


def framework_channel_adapter_path(target: Path, adapter_arg: Path | None = None) -> Path:
    if adapter_arg is not None:
        path = adapter_arg.expanduser()
        if not path.is_absolute():
            path = target / path
        return path.resolve(strict=False)
    return repo_local_source_adapter_path(target).resolve(strict=False)


def require_framework_channel_adapter_under_target(path: Path, target: Path) -> None:
    try:
        path.relative_to(target)
    except ValueError as exc:
        raise SystemExit(
            f"framework adapter path must stay inside the target repo: {path}. "
            f"Use the repo-local source adapter at {REPO_LOCAL_ADAPTER_FILE}; "
            "do not update shared methodology adapters through this lane command."
        ) from exc


def write_framework_channel_to_adapter(target: Path, channel: str, adapter_arg: Path | None = None) -> int:
    path = framework_channel_adapter_path(target, adapter_arg)
    require_framework_channel_adapter_under_target(path, target)
    if not path.exists():
        raise SystemExit(
            f"framework adapter not found: {path}. "
            f"Run `tautline migrate-adapter <adapter> --adopter-target {target} --write` "
            f"or use `set-framework-channel --target . {channel}` for a lane-local pin."
        )
    adapter = load_adapter_json(path)
    if adapter.get("_generated"):
        raise SystemExit(
            f"framework adapter source must not be a generated lane adapter: {path}. "
            f"Use the repo-local source adapter at {REPO_LOCAL_ADAPTER_FILE}."
        )
    pin = normalize_framework_pin(adapter.get("_framework"), "Project adapter _framework")
    pin["channel"] = channel
    adapter["_framework"] = pin
    validate_migrated_adapter_payload(adapter, path)
    before_text = path.read_text(encoding="utf-8")
    repo_local = is_repo_local_source_adapter(path, target)
    generated = target / LANE_ADAPTER_FILE
    generated_before = generated.read_text(encoding="utf-8") if generated.exists() else None
    pin_path = framework_pin_path(target)
    pin_before = pin_path.read_text(encoding="utf-8") if pin_path.exists() else None
    file_pin = load_framework_pin_file(target) if repo_local else None

    def restore_adapter_channel_files() -> None:
        write_text_atomic(path, before_text)
        if generated_before is None:
            try:
                generated.unlink()
            except FileNotFoundError:
                pass
        else:
            write_text_atomic(generated, generated_before)
        if pin_before is None:
            try:
                pin_path.unlink()
            except FileNotFoundError:
                pass
        else:
            write_text_atomic(pin_path, pin_before)

    write_text_atomic(path, json.dumps(adapter, indent=2, sort_keys=True) + "\n")
    if repo_local:
        render_args = argparse.Namespace(project=path, target=target, write=True, check=False, json_only=True)
        try:
            render_status = render_adapters(render_args)
            if render_status != 0:
                restore_adapter_channel_files()
                return render_status
            if file_pin is not None and file_pin.get("channel") != channel:
                file_pin["channel"] = channel
                write_text_atomic(pin_path, json.dumps(file_pin, indent=2, sort_keys=True) + "\n")
                print(f"framework_pin_aligned: {pin_path}")
        except (OSError, SystemExit):
            restore_adapter_channel_files()
            raise
        print(f"framework_adapter_written: {path}")
        if generated.exists():
            generated_data = load_adapter_json(generated)
            effective, source = effective_framework_pin(generated_data, target)
            print(framework_pin_status_line(effective, source))
    else:
        print(f"framework_adapter_written: {path}")
        print(
            "framework_channel_next: run `tautline render-adapters --project <adapter> "
            "--target . --write --json-only` before relying on this channel in a lane"
        )
    print(
        "framework_channel_safety: stable is the default for existing clients; "
        "experimental remains opt-in and should be adopted only at a safe boundary"
    )
    return 0


def set_framework_channel(args: argparse.Namespace) -> int:
    target = args.target.expanduser().resolve(strict=False)
    channel = str(args.channel).strip()
    if channel not in CLIENT_FRAMEWORK_CHANNELS:
        raise SystemExit("framework channel must be stable or experimental")
    source = framework_channel_source(args)
    if source == "adapter":
        return write_framework_channel_to_adapter(target, channel, getattr(args, "adapter_path", None))
    if source != "pin":
        raise SystemExit("framework channel source must be pin or adapter")
    existing = load_framework_pin_file(target) or normalize_framework_pin(None)
    existing["channel"] = channel
    path = framework_pin_path(target)
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(existing, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    print(f"framework_pin_written: {path}")
    print(framework_pin_status_line(existing, FRAMEWORK_PIN_FILE))
    print(
        "framework_channel_next: run `tautline methodology-status --target .` "
        "or `tautline sync-methodology --target .` at a safe boundary"
    )
    return 0


def semver_change_kind(current: str, available: str) -> str:
    current_parts = version_tuple(current)
    available_parts = version_tuple(available)
    current_major = current_parts[0] if len(current_parts) > 0 else 0
    current_minor = current_parts[1] if len(current_parts) > 1 else 0
    current_patch = current_parts[2] if len(current_parts) > 2 else 0
    available_major = available_parts[0] if len(available_parts) > 0 else 0
    available_minor = available_parts[1] if len(available_parts) > 1 else 0
    available_patch = available_parts[2] if len(available_parts) > 2 else 0
    if (available_major, available_minor, available_patch) <= (current_major, current_minor, current_patch):
        return "none"
    if available_major != current_major:
        return "major"
    if available_minor != current_minor:
        return "minor"
    return "patch"


def active_json_status(path: Path, complete_statuses: set[str]) -> tuple[bool, str]:
    if not path.exists():
        return False, "missing"
    try:
        payload = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return True, "present"
    if not isinstance(payload, dict):
        return True, "present"
    status = str(payload.get("status") or "").strip().lower()
    if status and status in complete_statuses:
        return False, status
    return True, status or "present"


def active_framework_wip_reasons(data: dict, target: Path) -> list[str]:
    reasons: list[str] = []
    packet_path = target / data["laneState"]["executionPacket"]
    if packet_path.exists():
        reasons.append(f"execution packet present at {packet_path}")
    goal_active, goal_status = active_json_status(goal_run_path(data, target), {"complete", "completed", "cancelled", "canceled"})
    if goal_active:
        reasons.append(f"goal ledger active ({goal_status})")
    milestone_active, milestone_status = active_json_status(
        milestone_run_path(data, target), {"complete", "completed", "cancelled", "canceled"}
    )
    if milestone_active:
        reasons.append(f"milestone ledger active ({milestone_status})")
    review_dir = implementation_review_evidence_dir(target)
    if review_dir.exists():
        live_review_markers = [
            path for path in review_dir.rglob("*")
            if path.is_file() and path.suffix in {".pid", ".lock", ".running"}
        ]
        if live_review_markers:
            reasons.append(f"running review evidence present under {review_dir}")
    branch = run_git(target, ["branch", "--show-current"])
    if branch not in ("", "unavailable", "main", "master"):
        reasons.append(f"active branch {branch}")
    return reasons


def framework_available_version(pin: dict) -> str:
    env_version = util_module().resolve_env("MINERVIT_METHODOLOGY_AVAILABLE_VERSION", "").strip()
    if env_version:
        return env_version
    version = pin.get("version", "")
    if version and re.fullmatch(r"[0-9]+(?:\.[0-9]+){1,2}(?:[-+][0-9A-Za-z.-]+)?", version):
        return version
    return plugin_version()


def framework_update_decision(
    pin: dict,
    data: dict,
    target: Path,
    skip_update: bool = False,
    manual_request: bool = False,
) -> dict:
    if skip_update:
        return {"action": "skip", "reason": "--skip-update requested", "wipReasons": []}
    current = plugin_version()
    available = framework_available_version(pin)
    change_kind = semver_change_kind(current, available)
    wip_reasons = active_framework_wip_reasons(data, target)
    if adapter_is_methodology_repo(data):
        return {
            "action": "skip",
            "reason": "methodology-dev invocation uses the current checkout without auto-update",
            "wipReasons": wip_reasons,
            "availableVersion": available,
            "changeKind": change_kind,
        }
    if pin["updatePolicy"] == "manual" and not manual_request:
        return {
            "action": "skip",
            "reason": "framework updatePolicy=manual; run sync-methodology intentionally at a safe boundary",
            "wipReasons": wip_reasons,
            "availableVersion": available,
            "changeKind": change_kind,
        }
    if pin["channel"] == "stable" and methodology_update_policy() not in {"signed", "pinned"}:
        return {
            "action": "skip",
            "reason": "stable channel refuses raw main updates without signed or pinned update trust",
            "wipReasons": wip_reasons,
            "availableVersion": available,
            "changeKind": change_kind,
        }
    if wip_reasons and change_kind in {"minor", "major"}:
        return {
            "action": "skip",
            "reason": f"active work blocks automatic {change_kind} framework upgrade",
            "wipReasons": wip_reasons,
            "availableVersion": available,
            "changeKind": change_kind,
        }
    if wip_reasons and change_kind == "patch":
        report = load_release_migration_report(available)
        if not (isinstance(report, dict) and report.get("wipSafe") is True):
            return {
                "action": "skip",
                "reason": "active work allows patch auto-update only when the release migration report declares wipSafe=true",
                "wipReasons": wip_reasons,
                "availableVersion": available,
                "changeKind": change_kind,
            }
    if pin["updatePolicy"] == "minor-at-boundary" and wip_reasons:
        return {
            "action": "skip",
            "reason": "minor-at-boundary update waits until no goal, milestone, packet, review, or branch work is active",
            "wipReasons": wip_reasons,
            "availableVersion": available,
            "changeKind": change_kind,
        }
    return {
        "action": "update",
        "reason": f"{pin['channel']} channel {pin['updatePolicy']} permits {change_kind} update",
        "wipReasons": wip_reasons,
        "availableVersion": available,
        "changeKind": change_kind,
    }


def scope_query_filters_status(scope_query: str, status_field: str) -> bool:
    """Pure: does a GitHub Projects scopeQuery filter on the status field? A status predicate would
    hide an item parked in an unconfigured/review status from board-currency reconciliation, so it
    is forbidden. Catches `status:`, `-status:`, and the existence qualifiers `has:status`/`no:status`
    (GitHub Projects filter syntax), matching the exact configured field name as a whole top-level
    qualifier -- not a substring, so `mystatus:`/`custom-status:`/`label:"status:x"` are NOT flagged.
    Values inside quotes are ignored so a status-like string in another qualifier's value is safe."""
    field = (status_field or "").strip().lower()
    if not field or not (scope_query or "").strip():
        return False
    unquoted = re.sub(r"\"[^\"]*\"", " ", scope_query)
    unquoted = re.sub(r"'[^']*'", " ", unquoted)
    for raw_token in unquoted.split():
        token = raw_token.lower().lstrip("-")
        if token.startswith(field + ":") or token in (f"has:{field}", f"no:{field}"):
            return True
    return False


def normalize_tracker_adapter_config(raw: object, defaults: dict, label: str) -> dict:
    if raw is not None and not isinstance(raw, dict):
        raise SystemExit(f"Project adapter {label} must be an object")
    config = {**defaults, **(raw or {})}
    if not isinstance(config.get("enabled"), bool):
        raise SystemExit(f"Project adapter {label}.enabled must be boolean")
    if config.get("provider") != "github-projects":
        raise SystemExit(f"Project adapter {label}.provider must be github-projects")
    try:
        config["projectNumber"] = int(config.get("projectNumber", 0))
    except (TypeError, ValueError) as exc:
        raise SystemExit(f"Project adapter {label}.projectNumber must be an integer") from exc
    for key in ["owner", "scopeQuery", "statusField", "priorityField", "milestoneField", "linkPolicy"]:
        config[key] = str(config.get(key, "")).strip()
    # A status predicate in scopeQuery would hide an item parked in an unconfigured/review status
    # from board-currency reconciliation (the gate only scans the scoped set), letting a lane evade
    # the no-review-column enforcement by scoping. Forbid it (Codex P2).
    if scope_query_filters_status(config["scopeQuery"], config["statusField"]):
        raise SystemExit(
            f"Project adapter {label}.scopeQuery must not filter on the status field "
            f"({config['statusField']!r}); board currency must see every in-scope item regardless of status"
        )
    for optional_key in [
        "typeField",
        "tacticalPlanningAuthority",
        "syncMode",
        "migrationInterviewPath",
        "exportMode",
        "completionUnit",
        "epicField",
        "orderField",
    ]:
        if optional_key in config:
            config[optional_key] = str(config.get(optional_key, "")).strip()
    # Board physical ordering defines the exact work order by default; 'priority' opts out.
    work_order = str(config.get("workOrder", "board") or "board").strip().lower()
    if work_order not in {"board", "priority"}:
        raise SystemExit(f"Project adapter {label}.workOrder must be 'board' or 'priority'")
    config["workOrder"] = work_order
    for key in ["readyStatuses", "activeStatuses", "doneStatuses", "blockedStatuses", "authoritativeFor"]:
        values = config.get(key, [])
        if not isinstance(values, list) or any(not str(item).strip() for item in values):
            raise SystemExit(f"Project adapter {label}.{key} must be an array of non-blank strings")
        config[key] = [str(item).strip() for item in values]
    if "itemTypes" in config:
        values = config.get("itemTypes", [])
        if not isinstance(values, list) or any(str(item).strip() not in {"goal", "milestone", "bug", "task"} for item in values):
            raise SystemExit(f"Project adapter {label}.itemTypes must contain only goal, milestone, bug, and/or task")
        config["itemTypes"] = [str(item).strip() for item in values]
    if not isinstance(config.get("repoPlanRequired"), bool):
        raise SystemExit(f"Project adapter {label}.repoPlanRequired must be boolean")
    if "tacticalPlanningAuthority" in config and config["tacticalPlanningAuthority"] != "repo":
        raise SystemExit(f"Project adapter {label}.tacticalPlanningAuthority must be repo")
    if "syncMode" in config and config["syncMode"] != "read-select-write-status-links-notes":
        raise SystemExit(f"Project adapter {label}.syncMode must be read-select-write-status-links-notes")
    if "exportMode" in config and config["exportMode"] != "interview-approved":
        raise SystemExit(f"Project adapter {label}.exportMode must be interview-approved")
    if "completionUnit" in config:
        completion_unit = str(config.get("completionUnit") or "goal").strip().lower()
        if completion_unit not in {"goal", "provider-item"}:
            raise SystemExit(f"Project adapter {label}.completionUnit must be goal or provider-item")
        config["completionUnit"] = completion_unit
    if "migrationInterviewPath" in config:
        if not config["migrationInterviewPath"]:
            raise SystemExit(f"Project adapter {label}.migrationInterviewPath must be non-blank")
        if Path(config["migrationInterviewPath"]).is_absolute():
            raise SystemExit(f"Project adapter {label}.migrationInterviewPath must be project-relative, not an absolute machine path")
    # featureSeries is a DICT {field, pattern}; type-check it in its own block (mirroring ciTestGate)
    # so the str()-coercion optional-key loop above never stringifies and corrupts the dict.
    if "featureSeries" in config:
        feature_series = config["featureSeries"]
        if not isinstance(feature_series, dict):
            raise SystemExit(f"Project adapter {label}.featureSeries must be an object")
        for member_key in ["field", "pattern"]:
            if member_key in feature_series and not isinstance(feature_series[member_key], str):
                raise SystemExit(f"Project adapter {label}.featureSeries.{member_key} must be a string")
        config["featureSeries"] = feature_series
    if config["enabled"]:
        if not config["owner"]:
            raise SystemExit(f"Project adapter {label}.owner must be non-blank when enabled")
        if config["projectNumber"] < 1:
            raise SystemExit(f"Project adapter {label}.projectNumber must be positive when enabled")
        if not config["statusField"]:
            raise SystemExit(f"Project adapter {label}.statusField must be non-blank when enabled")
        # blockedStatuses is intentionally NOT required-non-empty: a board may legitimately have no
        # "Blocked" column (the canonical Funnel/Todo/In progress/Done shape), so adopting it yields
        # an empty blockedStatuses. Requiring it here would make board-adopt write a self-invalidating
        # adapter that bricks every later load_project.
        for key in ["readyStatuses", "activeStatuses", "doneStatuses"]:
            if not config[key]:
                raise SystemExit(f"Project adapter {label}.{key} must be non-empty when enabled")
        if not config["authoritativeFor"]:
            raise SystemExit(f"Project adapter {label}.authoritativeFor must be non-empty when enabled")
        if not config["repoPlanRequired"]:
            raise SystemExit(f"Project adapter {label}.repoPlanRequired must be true when enabled")
        if not config["linkPolicy"]:
            raise SystemExit(f"Project adapter {label}.linkPolicy must be non-blank when enabled")
        if "itemTypes" in config and not config["itemTypes"]:
            raise SystemExit(f"Project adapter {label}.itemTypes must be non-empty when enabled")
        if config.get("workOrder") == "priority" and not config["priorityField"]:
            raise SystemExit(f"Project adapter {label}.priorityField must be set when workOrder is 'priority'")
    return config


def goal_tracker_from_backlog_provider(provider: dict) -> dict:
    return {
        **DEFAULT_GOAL_TRACKER,
        "enabled": provider["enabled"],
        "provider": provider["provider"],
        "owner": provider["owner"],
        "projectNumber": provider["projectNumber"],
        "scopeQuery": provider["scopeQuery"],
        "statusField": provider["statusField"],
        "priorityField": provider["priorityField"],
        "milestoneField": provider["milestoneField"],
        "readyStatuses": provider["readyStatuses"],
        "activeStatuses": provider["activeStatuses"],
        "doneStatuses": provider["doneStatuses"],
        "blockedStatuses": provider["blockedStatuses"],
        "authoritativeFor": provider["authoritativeFor"],
        "repoPlanRequired": provider["repoPlanRequired"],
        "linkPolicy": provider["linkPolicy"],
        "workOrder": provider.get("workOrder", "board"),
        "completionUnit": provider.get("completionUnit", "goal"),
        "epicField": provider.get("epicField", ""),
        "orderField": provider.get("orderField", ""),
        "featureSeries": provider.get("featureSeries", {}),
    }


def backlog_provider_from_goal_tracker(tracker: dict) -> dict:
    return {
        **DEFAULT_BACKLOG_PROVIDER,
        "enabled": tracker.get("enabled", DEFAULT_GOAL_TRACKER["enabled"]),
        "provider": tracker.get("provider", DEFAULT_GOAL_TRACKER["provider"]),
        "owner": tracker.get("owner", DEFAULT_GOAL_TRACKER["owner"]),
        "projectNumber": tracker.get("projectNumber", DEFAULT_GOAL_TRACKER["projectNumber"]),
        "scopeQuery": tracker.get("scopeQuery", DEFAULT_GOAL_TRACKER["scopeQuery"]),
        "statusField": tracker.get("statusField", DEFAULT_GOAL_TRACKER["statusField"]),
        "priorityField": tracker.get("priorityField", DEFAULT_GOAL_TRACKER["priorityField"]),
        "milestoneField": tracker.get("milestoneField", DEFAULT_GOAL_TRACKER["milestoneField"]),
        "readyStatuses": tracker.get("readyStatuses", DEFAULT_GOAL_TRACKER["readyStatuses"]),
        "activeStatuses": tracker.get("activeStatuses", DEFAULT_GOAL_TRACKER["activeStatuses"]),
        "doneStatuses": tracker.get("doneStatuses", DEFAULT_GOAL_TRACKER["doneStatuses"]),
        "blockedStatuses": tracker.get("blockedStatuses", DEFAULT_GOAL_TRACKER["blockedStatuses"]),
        "authoritativeFor": tracker.get(
            "authoritativeFor",
            ["goal-priority", "milestone-order", "goal-status", "bug-status", "task-priority"],
        ),
        "repoPlanRequired": tracker.get("repoPlanRequired", DEFAULT_GOAL_TRACKER["repoPlanRequired"]),
        "tacticalPlanningAuthority": "repo",
        "syncMode": "read-select-write-status-links-notes",
        "linkPolicy": tracker.get("linkPolicy", DEFAULT_GOAL_TRACKER["linkPolicy"]),
        "migrationInterviewPath": DEFAULT_BACKLOG_PROVIDER["migrationInterviewPath"],
        "exportMode": "interview-approved",
        "workOrder": tracker.get("workOrder", "board"),
        "completionUnit": tracker.get("completionUnit", "goal"),
        "epicField": tracker.get("epicField", ""),
        "orderField": tracker.get("orderField", ""),
        "featureSeries": tracker.get("featureSeries", {}),
    }


def normalize_deploy_health_config(raw: object, index: int) -> dict:
    return deploy_module().normalize_deploy_health_config(raw, index, DEFAULT_DEPLOY_HEALTH)


def project_json_decode_error(path: Path, exc: json.JSONDecodeError) -> str:
    return adapter_module().project_json_decode_error(path, exc, lane_adapter_file=(LANE_ADAPTER_FILE, LEGACY_LANE_ADAPTER_FILE))


def _adapter_schema() -> dict | None:
    return adapter_module().adapter_schema(ADAPTER_SCHEMA)


def _schema_type_matches(value: object, type_name: str) -> bool:
    return adapter_module().schema_type_matches(value, type_name)


def schema_validation_errors(instance: object, schema: dict, path: str = "(root)") -> list[str]:
    return adapter_module().schema_validation_errors(instance, schema, path)


def validate_adapter_schema(data: dict, path: Path) -> None:
    return adapter_module().validate_adapter_schema(data, path, ADAPTER_SCHEMA)


def validate_adapter(args: argparse.Namespace) -> int:
    """`validate-adapter <path>`: self-serve schema lint for an adapter (arch-config-7).

    Reports every schema violation at once (unknown keys, wrong types, bad enums, missing required
    keys) so an adopter can fix their adapter without round-tripping through a full lane start.
    """
    path = args.project
    try:
        with path.open("r", encoding="utf-8") as f:
            data = json.load(f)
    except FileNotFoundError:
        print(f"adapter not found: {path}", file=sys.stderr)
        return 2
    except json.JSONDecodeError as exc:
        print(f"adapter is not valid JSON: {path}: {exc}", file=sys.stderr)
        return 1
    schema = _adapter_schema()
    if schema is None:
        print(f"adapter schema unavailable ({ADAPTER_SCHEMA}); cannot validate", file=sys.stderr)
        return 2
    errors = schema_validation_errors(data, schema)
    if errors:
        print(f"adapter {path} has {len(errors)} schema violation(s):", file=sys.stderr)
        for err in errors:
            print(f"  - {err}", file=sys.stderr)
        return 1
    legacy_t0 = ((data.get("review") or {}).get("roundBudgets") or {}).get("T0")
    if isinstance(legacy_t0, int) and not isinstance(legacy_t0, bool) and legacy_t0 > 0:
        print(
            f"adapter warning: review.roundBudgets.T0={legacy_t0} is legacy; "
            "runtime coerces T0 to 0 because T0 does not use cross-model implementation review",
            file=sys.stderr,
        )
    print(f"adapter {path} matches the adapter schema ({ADAPTER_SCHEMA.name})")
    return 0


def load_project(path: Path) -> dict:
    try:
        with path.open("r", encoding="utf-8") as f:
            data = json.load(f)
    except json.JSONDecodeError as exc:
        raise SystemExit(project_json_decode_error(path, exc))
    if isinstance(data, dict):
        # A freshly-init'd bootstrap adapter is known-incomplete: surface the actionable
        # "replace your placeholders" guidance before the per-field validators below (and the
        # schema net at the end of this function), so the user sees the fix-this-first message
        # rather than a confusing minLength/non-blank error on an expected-empty placeholder.
        bootstrap_placeholders = collect_bootstrap_placeholders(data)
        if bootstrap_placeholders:
            raise SystemExit(
                "Project adapter still contains BOOTSTRAP REQUIRED placeholders: "
                + ", ".join(bootstrap_placeholders)
                + ". Replace them before rendering or starting a lane."
            )
    required = [
        "project",
        "repo",
        "productionDeployExists",
        "backlogAdapter",
        "planningArtifacts",
        "commands",
        "review",
        "behaviorSpecs",
        "continuity",
        "readiness",
        "generatedFiles",
    ]
    missing = [key for key in required if key not in data]
    if missing:
        raise SystemExit(f"Project adapter missing required keys: {', '.join(missing)}")
    data["_framework"] = normalize_framework_pin(data.get("_framework"), "Project adapter _framework")
    data["workProfiles"] = normalize_work_profiles(data.get("workProfiles"))
    data["derivedArtifacts"] = normalize_derived_artifacts(data.get("derivedArtifacts"))
    bootstrap_evidence = data.get("bootstrapEvidence")
    if source_adapter_requires_bootstrap_evidence(path) and bootstrap_evidence is None:
        raise SystemExit(
            "Project adapter bootstrapEvidence missing. Source adapters under adapters/projects must record "
            "interview/repo-evidence provenance before render."
        )
    if bootstrap_evidence is not None:
        if not isinstance(bootstrap_evidence, dict):
            raise SystemExit("Project adapter bootstrapEvidence must be an object")
        if not isinstance(data["project"], str):
            raise SystemExit("Project adapter project must be a string")
        project_name = data["project"].strip()
        if not project_name:
            raise SystemExit("Project adapter project must be non-blank")
        data["project"] = project_name
        for key in ["project", "status", "summary"]:
            raw_value = bootstrap_evidence.get(key, "")
            if not isinstance(raw_value, str):
                raise SystemExit(f"Project adapter bootstrapEvidence.{key} must be a string")
            value = raw_value.strip()
            if not value:
                raise SystemExit(f"Project adapter bootstrapEvidence.{key} must be non-blank")
            bootstrap_evidence[key] = value
        if bootstrap_evidence["project"] != project_name:
            raise SystemExit(
                "Project adapter bootstrapEvidence.project must match project; copied adapter evidence is not valid "
                f"for this project ({bootstrap_evidence['project']} != {project_name})"
            )
        status = bootstrap_evidence["status"]
        if status.startswith(BOOTSTRAP_REQUIRED_PREFIX):
            pass
        elif status not in {"interviewed", "repo-evident", "legacy-reviewed"}:
            raise SystemExit(
                "Project adapter bootstrapEvidence.status must be interviewed, repo-evident, or legacy-reviewed"
            )
        elif status == "legacy-reviewed":
            if not legacy_bootstrap_project_allowlisted(project_name):
                raise SystemExit(
                    "Project adapter bootstrapEvidence.status legacy-reviewed is reserved for explicitly "
                    "grandfathered adapters; new projects must use interviewed or repo-evident"
                )
            if source_adapter_requires_bootstrap_evidence(path) and not source_adapter_legacy_allowlisted(path, project_name):
                raise SystemExit(
                    "Project adapter bootstrapEvidence.status legacy-reviewed is reserved for explicitly "
                    "grandfathered adapters; new projects must use interviewed or repo-evident"
                )
        elif status == "interviewed":
            artifact = bootstrap_evidence.get("interviewArtifact")
            if not isinstance(artifact, dict):
                raise SystemExit("Project adapter bootstrapEvidence.interviewArtifact must be an object")
            for key in ["path", "sha256"]:
                raw_value = artifact.get(key, "")
                if not isinstance(raw_value, str):
                    raise SystemExit(f"Project adapter bootstrapEvidence.interviewArtifact.{key} must be a string")
                value = raw_value.strip()
                if not value:
                    raise SystemExit(f"Project adapter bootstrapEvidence.interviewArtifact.{key} must be non-blank")
                artifact[key] = value
            validate_relative_bootstrap_path(artifact["path"], "interviewArtifact.path")
            if not re.fullmatch(r"[0-9a-f]{64}", artifact["sha256"]):
                raise SystemExit("Project adapter bootstrapEvidence.interviewArtifact.sha256 must be a SHA256 hex digest")
            bootstrap_evidence["interviewArtifact"] = artifact
        elif status == "repo-evident":
            repo_evidence = bootstrap_evidence.get("repoEvidence")
            if not isinstance(repo_evidence, list) or len(repo_evidence) < 2:
                raise SystemExit("Project adapter bootstrapEvidence.repoEvidence must contain at least two evidence files")
            normalized_evidence = []
            seen_evidence_paths = set()
            for index, item in enumerate(repo_evidence):
                if not isinstance(item, dict):
                    raise SystemExit(f"Project adapter bootstrapEvidence.repoEvidence[{index}] must be an object")
                normalized_item = {}
                for key in ["path", "fact"]:
                    raw_value = item.get(key, "")
                    if not isinstance(raw_value, str):
                        raise SystemExit(
                            f"Project adapter bootstrapEvidence.repoEvidence[{index}].{key} must be a string"
                        )
                    value = raw_value.strip()
                    if not value:
                        raise SystemExit(
                            f"Project adapter bootstrapEvidence.repoEvidence[{index}].{key} must be non-blank"
                        )
                    normalized_item[key] = value
                validate_relative_bootstrap_path(normalized_item["path"], f"repoEvidence[{index}].path")
                path_key = normalized_item["path"].strip().rstrip("/")
                if path_key in seen_evidence_paths:
                    raise SystemExit(
                        f"Project adapter bootstrapEvidence.repoEvidence[{index}].path is duplicated: "
                        f"{normalized_item['path']}"
                    )
                seen_evidence_paths.add(path_key)
                if path_key in INVALID_REPO_EVIDENCE_PATHS:
                    raise SystemExit(
                        f"Project adapter bootstrapEvidence.repoEvidence[{index}].path is too generic: "
                        f"{normalized_item['path']}"
                    )
                if Path(path_key).name.lower() in WEAK_REPO_EVIDENCE_FILENAMES:
                    raise SystemExit(
                        f"Project adapter bootstrapEvidence.repoEvidence[{index}].path is weak boilerplate evidence: "
                        f"{normalized_item['path']}"
                    )
                normalized_evidence.append(normalized_item)
            bootstrap_evidence["repoEvidence"] = normalized_evidence
        data["bootstrapEvidence"] = bootstrap_evidence
    data["laneState"] = {**DEFAULT_LANE_STATE, **data.get("laneState", {})}
    data["milestoneContinuation"] = {
        **DEFAULT_MILESTONE_CONTINUATION,
        **data.get("milestoneContinuation", {}),
    }
    if not isinstance(data["milestoneContinuation"].get("watchdogEnabled"), bool):
        raise SystemExit("Project adapter milestoneContinuation.watchdogEnabled must be boolean")
    try:
        data["milestoneContinuation"]["watchdogCadenceMinutes"] = int(
            data["milestoneContinuation"]["watchdogCadenceMinutes"]
        )
    except (TypeError, ValueError) as exc:
        raise SystemExit("Project adapter milestoneContinuation.watchdogCadenceMinutes must be a positive integer") from exc
    if data["milestoneContinuation"]["watchdogCadenceMinutes"] < 1:
        raise SystemExit("Project adapter milestoneContinuation.watchdogCadenceMinutes must be a positive integer")
    data["sessionJournal"] = {**DEFAULT_SESSION_JOURNAL, **data.get("sessionJournal", {})}
    if not isinstance(data["sessionJournal"].get("enabled"), bool):
        raise SystemExit("Project adapter sessionJournal.enabled must be boolean")
    if not isinstance(data["sessionJournal"].get("gitIgnoreLocal"), bool):
        raise SystemExit("Project adapter sessionJournal.gitIgnoreLocal must be boolean")
    if data["sessionJournal"].get("cadence") not in {"milestone", "session", "off"}:
        raise SystemExit("Project adapter sessionJournal.cadence must be milestone, session, or off")
    for key in ["branch", "archiveDir"]:
        value = str(data["sessionJournal"].get(key, "")).strip()
        if not value:
            raise SystemExit(f"Project adapter sessionJournal.{key} must be non-blank")
        if key == "archiveDir" and Path(value).is_absolute():
            raise SystemExit("Project adapter sessionJournal.archiveDir must be project-relative, not an absolute machine path")
        data["sessionJournal"][key] = value
    try:
        data["sessionJournal"]["maxBytes"] = int(data["sessionJournal"]["maxBytes"])
    except (TypeError, ValueError) as exc:
        raise SystemExit("Project adapter sessionJournal.maxBytes must be a positive integer") from exc
    if data["sessionJournal"]["maxBytes"] < 1:
        raise SystemExit("Project adapter sessionJournal.maxBytes must be a positive integer")
    data["observabilityEvents"] = {
        **DEFAULT_OBSERVABILITY_EVENTS,
        **data.get("observabilityEvents", {}),
    }
    observability = data["observabilityEvents"]
    if not isinstance(observability.get("enabled"), bool):
        raise SystemExit("Project adapter observabilityEvents.enabled must be boolean")
    for key in ["stateDir", "humanLog", "jsonlLog"]:
        value = str(observability.get(key, "")).strip()
        if not value:
            raise SystemExit(f"Project adapter observabilityEvents.{key} must be non-blank")
        if key in {"humanLog", "jsonlLog"} and (Path(value).is_absolute() or "/" in value or "\\" in value):
            raise SystemExit(f"Project adapter observabilityEvents.{key} must be a filename, not a path")
        observability[key] = value
    if observability["humanLog"] == observability["jsonlLog"]:
        raise SystemExit("Project adapter observabilityEvents.humanLog and jsonlLog must be different filenames")
    try:
        observability["rotateBytes"] = int(observability["rotateBytes"])
        observability["retainedRotations"] = int(observability["retainedRotations"])
    except (TypeError, ValueError) as exc:
        raise SystemExit("Project adapter observabilityEvents.rotateBytes and retainedRotations must be positive integers") from exc
    if observability["rotateBytes"] < 1 or observability["retainedRotations"] < 1:
        raise SystemExit("Project adapter observabilityEvents.rotateBytes and retainedRotations must be positive integers")
    required_events = observability.get("requiredBoundaryEvents", [])
    if not isinstance(required_events, list) or not all(isinstance(item, str) and item.strip() for item in required_events):
        raise SystemExit("Project adapter observabilityEvents.requiredBoundaryEvents must be a list of non-blank strings")
    observability["requiredBoundaryEvents"] = [slugify(item, fallback="event").replace("-", "_") for item in required_events]
    data["instrumentation"] = {**DEFAULT_INSTRUMENTATION, **data.get("instrumentation", {})}
    instrumentation = data["instrumentation"]
    unknown_instrumentation_keys = set(instrumentation) - set(DEFAULT_INSTRUMENTATION)
    if unknown_instrumentation_keys:
        raise SystemExit(
            "Project adapter instrumentation.* has unsupported key(s): "
            + ", ".join(sorted(unknown_instrumentation_keys))
            + " -- telemetry remote metadata is a fixed constant, so there are no branch/archiveDir knobs"
        )
    if not isinstance(instrumentation.get("enabled"), bool):
        raise SystemExit("Project adapter instrumentation.enabled must be boolean")
    if instrumentation.get("cadence") not in {"milestone", "session", "off"}:
        raise SystemExit("Project adapter instrumentation.cadence must be milestone, session, or off")
    if instrumentation["enabled"] and not observability["enabled"]:
        # Config-coherence: instrumentation aggregates the observability event log, so enabling it
        # while events are disabled would publish empty/misleading records. Reject at load time.
        raise SystemExit(
            "Project adapter instrumentation.enabled requires observabilityEvents.enabled: the "
            "instrumentation record is an aggregation over the local observability event log and "
            "cannot be produced with event logging disabled"
        )
    data["usageAccounting"] = {
        **DEFAULT_USAGE_ACCOUNTING,
        **data.get("usageAccounting", {}),
    }
    usage = data["usageAccounting"]
    if not isinstance(usage.get("enabled"), bool):
        raise SystemExit("Project adapter usageAccounting.enabled must be boolean")
    for key in ["stateDir", "jsonlLog", "rollupJson"]:
        value = str(usage.get(key, "")).strip()
        if not value:
            raise SystemExit(f"Project adapter usageAccounting.{key} must be non-blank")
        if key in {"jsonlLog", "rollupJson"} and (Path(value).is_absolute() or "/" in value or "\\" in value):
            raise SystemExit(f"Project adapter usageAccounting.{key} must be a filename, not a path")
        usage[key] = value
    if usage["jsonlLog"] == usage["rollupJson"]:
        raise SystemExit("Project adapter usageAccounting.jsonlLog and rollupJson must be different filenames")
    try:
        usage["rotateBytes"] = int(usage["rotateBytes"])
        usage["retainedRotations"] = int(usage["retainedRotations"])
    except (TypeError, ValueError) as exc:
        raise SystemExit("Project adapter usageAccounting.rotateBytes and retainedRotations must be positive integers") from exc
    if usage["rotateBytes"] < 1 or usage["retainedRotations"] < 1:
        raise SystemExit("Project adapter usageAccounting.rotateBytes and retainedRotations must be positive integers")
    data["iterationReview"] = {
        **DEFAULT_ITERATION_REVIEW,
        **data.get("iterationReview", {}),
    }
    iteration_review = data["iterationReview"]
    if not isinstance(iteration_review.get("enabled"), bool):
        raise SystemExit("Project adapter iterationReview.enabled must be boolean")
    granularities = iteration_review.get("granularities", [])
    if not isinstance(granularities, list) or any(str(item).strip() not in {"goal", "milestone"} for item in granularities):
        raise SystemExit("Project adapter iterationReview.granularities must contain only goal and/or milestone")
    if not granularities:
        raise SystemExit("Project adapter iterationReview.granularities must contain at least one value")
    iteration_review["granularities"] = [str(item).strip() for item in granularities]
    outputs = {**DEFAULT_ITERATION_REVIEW["outputs"], **iteration_review.get("outputs", {})}
    if not isinstance(outputs.get("page"), bool) or not isinstance(outputs.get("video"), bool):
        raise SystemExit("Project adapter iterationReview.outputs.page and outputs.video must be boolean")
    if iteration_review["enabled"] and not any(outputs.values()):
        raise SystemExit("Project adapter iterationReview.outputs must enable page or video when iterationReview is enabled")
    iteration_review["outputs"] = outputs
    boundary = {**DEFAULT_ITERATION_REVIEW["boundary"], **iteration_review.get("boundary", {})}
    if not isinstance(boundary.get("announce"), bool) or not isinstance(boundary.get("autoRunAtBoundary"), bool):
        raise SystemExit("Project adapter iterationReview.boundary.announce and boundary.autoRunAtBoundary must be boolean")
    iteration_review["boundary"] = boundary
    record_dir = str(iteration_review.get("recordDir", "")).strip()
    if not record_dir:
        raise SystemExit("Project adapter iterationReview.recordDir must be non-blank")
    if Path(record_dir).is_absolute():
        raise SystemExit("Project adapter iterationReview.recordDir must be project-relative, not an absolute machine path")
    iteration_review["recordDir"] = record_dir
    hosting = {**DEFAULT_ITERATION_REVIEW["hosting"], **iteration_review.get("hosting", {})}
    for key in ["store", "keyPattern", "cdn", "access"]:
        value = str(hosting.get(key, "")).strip()
        if not value:
            raise SystemExit(f"Project adapter iterationReview.hosting.{key} must be non-blank")
        hosting[key] = value
    hosting["bucket"] = str(hosting.get("bucket", "")).strip()
    hosting["baseUrl"] = str(hosting.get("baseUrl", "")).strip()
    if hosting["store"] not in {"s3"}:
        raise SystemExit("Project adapter iterationReview.hosting.store must be s3")
    if hosting["cdn"] not in {"cloudfront"}:
        raise SystemExit("Project adapter iterationReview.hosting.cdn must be cloudfront")
    if outputs["video"] and iteration_review["enabled"] and not hosting["bucket"]:
        raise SystemExit("Project adapter iterationReview.hosting.bucket must be non-blank when enabled video output is configured")
    iteration_review["hosting"] = hosting
    delivery = {**DEFAULT_ITERATION_REVIEW["delivery"], **iteration_review.get("delivery", {})}
    if not isinstance(delivery.get("enabled"), bool):
        raise SystemExit("Project adapter iterationReview.delivery.enabled must be boolean")
    if not isinstance(delivery.get("dedupe"), bool):
        raise SystemExit("Project adapter iterationReview.delivery.dedupe must be boolean")
    for key in ["trigger", "provider", "primaryUrl", "sentStateDir"]:
        value = str(delivery.get(key, "")).strip()
        if not value:
            raise SystemExit(f"Project adapter iterationReview.delivery.{key} must be non-blank")
        delivery[key] = value
    delivery["webhookEnv"] = str(delivery.get("webhookEnv", "")).strip()
    delivery["chatSpace"] = str(delivery.get("chatSpace", "")).strip()
    if delivery["trigger"] not in {"after-merge"}:
        raise SystemExit("Project adapter iterationReview.delivery.trigger must be after-merge")
    if delivery["provider"] not in {"google-chat-webhook"}:
        raise SystemExit("Project adapter iterationReview.delivery.provider must be google-chat-webhook")
    if delivery["primaryUrl"] not in {"cloudfront-page"}:
        raise SystemExit("Project adapter iterationReview.delivery.primaryUrl must be cloudfront-page")
    if Path(delivery["sentStateDir"]).is_absolute():
        raise SystemExit("Project adapter iterationReview.delivery.sentStateDir must be project-relative, not an absolute machine path")
    if delivery["enabled"] and not re.fullmatch(r"[A-Z_][A-Z0-9_]*", delivery["webhookEnv"]):
        raise SystemExit("Project adapter iterationReview.delivery.webhookEnv must name an environment variable when delivery is enabled")
    if iteration_review["enabled"] and delivery["enabled"] and (not hosting["bucket"] or not hosting["baseUrl"]):
        raise SystemExit("Project adapter iterationReview delivery requires iterationReview.hosting.bucket and baseUrl")
    iteration_review["delivery"] = delivery
    deployment_notification_raw = data.get("deploymentNotification")
    deployment_notification = {
        **DEFAULT_DEPLOYMENT_NOTIFICATION,
        **(deployment_notification_raw or {}),
    }
    if deployment_notification_raw is None:
        deployment_notification["enabled"] = bool(iteration_review["enabled"] and delivery["enabled"])
    if not isinstance(deployment_notification.get("enabled"), bool):
        raise SystemExit("Project adapter deploymentNotification.enabled must be boolean")
    if not isinstance(deployment_notification.get("reuseIterationReviewWebhook"), bool):
        raise SystemExit("Project adapter deploymentNotification.reuseIterationReviewWebhook must be boolean")
    if not isinstance(deployment_notification.get("dedupe"), bool):
        raise SystemExit("Project adapter deploymentNotification.dedupe must be boolean")
    deployment_pipeline_raw = deployment_notification.get("pipeline") or {}
    if not isinstance(deployment_pipeline_raw, dict):
        raise SystemExit("Project adapter deploymentNotification.pipeline must be an object")
    deployment_pipeline = {
        **DEFAULT_DEPLOYMENT_NOTIFICATION["pipeline"],
        **deployment_pipeline_raw,
    }
    if (
        deployment_notification_raw is not None
        and "pipeline" not in deployment_notification_raw
        and bool(deployment_notification.get("enabled"))
    ):
        deployment_pipeline["required"] = True
    if not isinstance(deployment_pipeline.get("required"), bool):
        raise SystemExit("Project adapter deploymentNotification.pipeline.required must be boolean")
    if not isinstance(deployment_pipeline.get("healthCheckBeforeNotify"), bool):
        raise SystemExit("Project adapter deploymentNotification.pipeline.healthCheckBeforeNotify must be boolean")
    deployment_pipeline["mode"] = str(deployment_pipeline.get("mode", "")).strip()
    if deployment_pipeline["mode"] not in {"ci-post-deploy", "manual-command"}:
        raise SystemExit("Project adapter deploymentNotification.pipeline.mode must be ci-post-deploy or manual-command")
    deployment_pipeline["requiredCommand"] = str(deployment_pipeline.get("requiredCommand", "")).strip()
    if not deployment_pipeline["requiredCommand"]:
        raise SystemExit("Project adapter deploymentNotification.pipeline.requiredCommand must be non-blank")
    evidence_paths = deployment_pipeline.get("evidencePaths") or []
    if not isinstance(evidence_paths, list) or not all(isinstance(item, str) and item.strip() for item in evidence_paths):
        raise SystemExit("Project adapter deploymentNotification.pipeline.evidencePaths must be a list of non-blank project-relative paths")
    normalized_evidence_paths = []
    for evidence_path in evidence_paths:
        evidence_path = evidence_path.strip()
        if Path(evidence_path).is_absolute() or evidence_path == ".." or evidence_path.startswith("../") or "/../" in evidence_path:
            raise SystemExit("Project adapter deploymentNotification.pipeline.evidencePaths must be project-relative, not absolute or parent paths")
        normalized_evidence_paths.append(evidence_path)
    deployment_pipeline["evidencePaths"] = normalized_evidence_paths
    deployment_notification["pipeline"] = deployment_pipeline
    for key in ["trigger", "provider", "sentStateDir"]:
        value = str(deployment_notification.get(key, "")).strip()
        if not value:
            raise SystemExit(f"Project adapter deploymentNotification.{key} must be non-blank")
        deployment_notification[key] = value
    deployment_notification["webhookEnv"] = str(deployment_notification.get("webhookEnv", "")).strip()
    deployment_notification["chatSpace"] = str(deployment_notification.get("chatSpace", "")).strip()
    if deployment_notification["trigger"] not in {"deploy-ready"}:
        raise SystemExit("Project adapter deploymentNotification.trigger must be deploy-ready")
    if deployment_notification["provider"] not in {"google-chat-webhook"}:
        raise SystemExit("Project adapter deploymentNotification.provider must be google-chat-webhook")
    if Path(deployment_notification["sentStateDir"]).is_absolute():
        raise SystemExit("Project adapter deploymentNotification.sentStateDir must be project-relative, not an absolute machine path")
    if deployment_notification["webhookEnv"] and not re.fullmatch(r"[A-Z_][A-Z0-9_]*", deployment_notification["webhookEnv"]):
        raise SystemExit("Project adapter deploymentNotification.webhookEnv must name an environment variable when set")
    effective_deploy_webhook_env = deployment_notification["webhookEnv"]
    if not effective_deploy_webhook_env and deployment_notification["reuseIterationReviewWebhook"]:
        effective_deploy_webhook_env = str(delivery.get("webhookEnv") or "")
    if deployment_notification["enabled"] and not re.fullmatch(r"[A-Z_][A-Z0-9_]*", effective_deploy_webhook_env or ""):
        raise SystemExit(
            "Project adapter deploymentNotification requires webhookEnv or reuseIterationReviewWebhook with iterationReview.delivery.webhookEnv when enabled"
        )
    if (
        deployment_notification["enabled"]
        and deployment_notification["reuseIterationReviewWebhook"]
        and not deployment_notification["webhookEnv"]
        and str(delivery.get("chatSpace") or "").strip()
        and (not deployment_notification["chatSpace"] or deployment_notification_raw is None)
    ):
        deployment_notification["chatSpace"] = str(delivery.get("chatSpace") or "Product").strip()
    if deployment_notification["enabled"] and not deployment_notification["chatSpace"]:
        deployment_notification["chatSpace"] = str(delivery.get("chatSpace") or "Product").strip()
    try:
        deployment_notification["maxChars"] = int(
            deployment_notification.get("maxChars", DEFAULT_DEPLOYMENT_NOTIFICATION["maxChars"])
        )
    except (TypeError, ValueError) as exc:
        raise SystemExit("Project adapter deploymentNotification.maxChars must be a positive integer") from exc
    if deployment_notification["maxChars"] < 100:
        raise SystemExit("Project adapter deploymentNotification.maxChars must be at least 100")
    data["deploymentNotification"] = deployment_notification
    data["milestoneUpdate"] = {
        **DEFAULT_MILESTONE_UPDATE,
        **data.get("milestoneUpdate", {}),
    }
    milestone_update = data["milestoneUpdate"]
    if not isinstance(milestone_update.get("enabled"), bool):
        raise SystemExit("Project adapter milestoneUpdate.enabled must be boolean")
    if not isinstance(milestone_update.get("dedupe"), bool):
        raise SystemExit("Project adapter milestoneUpdate.dedupe must be boolean")
    for key in ["trigger", "provider", "sentStateDir"]:
        value = str(milestone_update.get(key, "")).strip()
        if not value:
            raise SystemExit(f"Project adapter milestoneUpdate.{key} must be non-blank")
        milestone_update[key] = value
    milestone_update["webhookEnv"] = str(milestone_update.get("webhookEnv", "")).strip()
    milestone_update["chatSpace"] = str(milestone_update.get("chatSpace", "")).strip()
    if milestone_update["trigger"] not in {"milestone-complete"}:
        raise SystemExit("Project adapter milestoneUpdate.trigger must be milestone-complete")
    if milestone_update["provider"] not in {"google-chat-webhook"}:
        raise SystemExit("Project adapter milestoneUpdate.provider must be google-chat-webhook")
    if Path(milestone_update["sentStateDir"]).is_absolute():
        raise SystemExit("Project adapter milestoneUpdate.sentStateDir must be project-relative, not an absolute machine path")
    if milestone_update["enabled"] and not re.fullmatch(r"[A-Z_][A-Z0-9_]*", milestone_update["webhookEnv"]):
        raise SystemExit("Project adapter milestoneUpdate.webhookEnv must name an environment variable when enabled")
    if milestone_update["enabled"] and not milestone_update["chatSpace"]:
        raise SystemExit("Project adapter milestoneUpdate.chatSpace must be non-blank when enabled")
    try:
        milestone_update["maxChars"] = int(milestone_update.get("maxChars", DEFAULT_MILESTONE_UPDATE["maxChars"]))
    except (TypeError, ValueError) as exc:
        raise SystemExit("Project adapter milestoneUpdate.maxChars must be a positive integer") from exc
    if milestone_update["maxChars"] < 500:
        raise SystemExit("Project adapter milestoneUpdate.maxChars must be at least 500")
    data["productChat"] = {
        **DEFAULT_PRODUCT_CHAT,
        **data.get("productChat", {}),
    }
    product_chat = data["productChat"]
    if not isinstance(product_chat.get("enabled"), bool):
        raise SystemExit("Project adapter productChat.enabled must be boolean")
    product_chat["provider"] = str(product_chat.get("provider", "")).strip()
    product_chat["webhookEnv"] = str(product_chat.get("webhookEnv", "")).strip()
    product_chat["chatSpace"] = str(product_chat.get("chatSpace", "")).strip()
    if product_chat["provider"] not in {"google-chat-webhook"}:
        raise SystemExit("Project adapter productChat.provider must be google-chat-webhook")
    if product_chat["enabled"] and not re.fullmatch(r"[A-Z_][A-Z0-9_]*", product_chat["webhookEnv"]):
        raise SystemExit("Project adapter productChat.webhookEnv must name an environment variable when enabled")
    if product_chat["enabled"] and not product_chat["chatSpace"]:
        raise SystemExit("Project adapter productChat.chatSpace must be non-blank when enabled")
    try:
        product_chat["maxChars"] = int(product_chat.get("maxChars", DEFAULT_PRODUCT_CHAT["maxChars"]))
    except (TypeError, ValueError) as exc:
        raise SystemExit("Project adapter productChat.maxChars must be a positive integer") from exc
    if product_chat["maxChars"] < 100:
        raise SystemExit("Project adapter productChat.maxChars must be at least 100")
    video = {**DEFAULT_ITERATION_REVIEW["video"], **iteration_review.get("video", {})}
    music = {**DEFAULT_ITERATION_REVIEW["video"]["music"], **video.get("music", {})}
    if not isinstance(music.get("enabled"), bool):
        raise SystemExit("Project adapter iterationReview.video.music.enabled must be boolean")
    music["source"] = str(music.get("source", "")).strip()
    music["defaultUrl"] = str(music.get("defaultUrl", "")).strip()
    try:
        music["volume"] = float(music.get("volume", 0.18))
    except (TypeError, ValueError) as exc:
        raise SystemExit("Project adapter iterationReview.video.music.volume must be a number between 0 and 1") from exc
    if music["source"] not in {"adapter-approved"}:
        raise SystemExit("Project adapter iterationReview.video.music.source must be adapter-approved")
    if music["volume"] < 0 or music["volume"] > 1:
        raise SystemExit("Project adapter iterationReview.video.music.volume must be between 0 and 1")
    video["music"] = music
    iteration_review["video"] = video
    renderer_version = str(iteration_review.get("rendererVersion", "")).strip()
    if not renderer_version:
        raise SystemExit("Project adapter iterationReview.rendererVersion must be non-blank")
    iteration_review["rendererVersion"] = renderer_version
    data["contextRotation"] = {**DEFAULT_CONTEXT_ROTATION, **data.get("contextRotation", {})}
    if not isinstance(data["contextRotation"].get("enabled"), bool):
        raise SystemExit("Project adapter contextRotation.enabled must be boolean")
    for key in ["softPercent", "hardPercent"]:
        try:
            data["contextRotation"][key] = int(data["contextRotation"][key])
        except (TypeError, ValueError) as exc:
            raise SystemExit(f"Project adapter contextRotation.{key} must be an integer percent") from exc
        if data["contextRotation"][key] < 1 or data["contextRotation"][key] > 100:
            raise SystemExit(f"Project adapter contextRotation.{key} must be between 1 and 100")
    if data["contextRotation"]["hardPercent"] < data["contextRotation"]["softPercent"]:
        raise SystemExit("Project adapter contextRotation.hardPercent must be greater than or equal to softPercent")
    try:
        data["contextRotation"]["heartbeatMinutes"] = int(data["contextRotation"]["heartbeatMinutes"])
    except (TypeError, ValueError) as exc:
        raise SystemExit("Project adapter contextRotation.heartbeatMinutes must be a positive integer") from exc
    if data["contextRotation"]["heartbeatMinutes"] < 1:
        raise SystemExit("Project adapter contextRotation.heartbeatMinutes must be a positive integer")
    heartbeat_boundary = str(data["contextRotation"].get("heartbeatBoundary", "")).strip()
    if not heartbeat_boundary:
        raise SystemExit("Project adapter contextRotation.heartbeatBoundary must be non-blank")
    data["contextRotation"]["heartbeatBoundary"] = heartbeat_boundary
    safe_boundaries = data["contextRotation"].get("safeBoundaries")
    if not isinstance(safe_boundaries, list) or not safe_boundaries:
        raise SystemExit("Project adapter contextRotation.safeBoundaries must be a non-empty array")
    normalized_boundaries = []
    for boundary in safe_boundaries:
        value = str(boundary).strip()
        if not value:
            raise SystemExit("Project adapter contextRotation.safeBoundaries must contain non-blank values")
        normalized_boundaries.append(value)
    data["contextRotation"]["safeBoundaries"] = normalized_boundaries
    data["planningArtifacts"] = {**DEFAULT_PLANNING_ARTIFACTS, **data["planningArtifacts"]}
    planning_source = str(data["planningArtifacts"].get("sourceOfTruth", "")).strip().rstrip("/")
    data["goalArtifacts"] = {**DEFAULT_GOAL_ARTIFACTS, **data.get("goalArtifacts", {})}
    data["goalArtifacts"].setdefault("sourceOfTruth", f"{planning_source}/goals" if planning_source else "goals")
    data["goalArtifacts"].setdefault(
        "template",
        f"{str(data['goalArtifacts']['sourceOfTruth']).strip().rstrip('/')}/goal.template.md",
    )
    for key in ["sourceOfTruth", "template", "templateTrigger"]:
        value = str(data["goalArtifacts"].get(key, "")).strip()
        if not value:
            raise SystemExit(f"Project adapter goalArtifacts.{key} must be non-blank")
        if Path(value).is_absolute():
            raise SystemExit(f"Project adapter goalArtifacts.{key} must be project-relative, not an absolute machine path")
        data["goalArtifacts"][key] = value
    if not isinstance(data["goalArtifacts"].get("reviewRequired"), bool):
        raise SystemExit("Project adapter goalArtifacts.reviewRequired must be boolean")
    data["goalExecution"] = {**DEFAULT_GOAL_EXECUTION, **data.get("goalExecution", {})}
    for key in ["preferredClaudeCommand", "fallback"]:
        value = str(data["goalExecution"].get(key, "")).strip()
        if not value:
            raise SystemExit(f"Project adapter goalExecution.{key} must be non-blank")
        data["goalExecution"][key] = value
    if not data["goalExecution"]["preferredClaudeCommand"].startswith("/"):
        raise SystemExit("Project adapter goalExecution.preferredClaudeCommand must be a slash command such as /goal")
    if not isinstance(data["goalExecution"].get("claudeGoalGuidance"), bool):
        raise SystemExit("Project adapter goalExecution.claudeGoalGuidance must be boolean")
    if data["goalExecution"]["fallback"] not in {"goal-ledger"}:
        raise SystemExit("Project adapter goalExecution.fallback must be goal-ledger")
    lane_coordination = {**DEFAULT_LANE_COORDINATION, **data.get("laneCoordination", {})}
    if not isinstance(lane_coordination.get("enabled"), bool):
        raise SystemExit("Project adapter laneCoordination.enabled must be boolean")
    lane_coordination["enforcement"] = str(
        lane_coordination.get("enforcement", DEFAULT_LANE_COORDINATION["enforcement"])
    ).strip()
    if lane_coordination["enforcement"] not in {"warn", "strict"}:
        raise SystemExit("Project adapter laneCoordination.enforcement must be warn or strict")
    coordination_root = str(lane_coordination.get("root") or f"{planning_source}/coordination").strip().rstrip("/")
    if not coordination_root:
        coordination_root = "coordination"
    lane_coordination["root"] = coordination_root
    lane_coordination["contractPath"] = str(
        lane_coordination.get("contractPath") or f"{coordination_root}/cross-lane-contract.md"
    ).strip()
    lane_coordination["boardPath"] = str(
        lane_coordination.get("boardPath") or f"{coordination_root}/lane-board.md"
    ).strip()
    lane_coordination["laneStatusDir"] = str(
        lane_coordination.get("laneStatusDir") or f"{coordination_root}/lanes"
    ).strip().rstrip("/")
    for key in ["root", "contractPath", "boardPath", "laneStatusDir"]:
        value = str(lane_coordination.get(key, "")).strip()
        if not value:
            raise SystemExit(f"Project adapter laneCoordination.{key} must be non-blank")
        if Path(value).is_absolute():
            raise SystemExit(f"Project adapter laneCoordination.{key} must be project-relative, not an absolute machine path")
        lane_coordination[key] = value
    try:
        lane_coordination["maxStatusAgeHours"] = int(
            lane_coordination.get("maxStatusAgeHours", DEFAULT_LANE_COORDINATION["maxStatusAgeHours"])
        )
    except (TypeError, ValueError) as exc:
        raise SystemExit("Project adapter laneCoordination.maxStatusAgeHours must be a positive integer") from exc
    if lane_coordination["maxStatusAgeHours"] < 1:
        raise SystemExit("Project adapter laneCoordination.maxStatusAgeHours must be a positive integer")
    lane_coordination["rule"] = str(lane_coordination.get("rule", "")).strip()
    if not lane_coordination["rule"]:
        raise SystemExit("Project adapter laneCoordination.rule must be non-blank")
    data["laneCoordination"] = lane_coordination
    legacy_goal_tracker = normalize_tracker_adapter_config(data.get("goalTracker"), DEFAULT_GOAL_TRACKER, "goalTracker")
    if "backlogProvider" in data:
        backlog_provider = normalize_tracker_adapter_config(data.get("backlogProvider"), DEFAULT_BACKLOG_PROVIDER, "backlogProvider")
    elif legacy_goal_tracker["enabled"]:
        backlog_provider = {
            **DEFAULT_BACKLOG_PROVIDER,
            **goal_tracker_from_backlog_provider(legacy_goal_tracker),
            "itemTypes": ["goal", "milestone"],
            "tacticalPlanningAuthority": "repo",
            "syncMode": "read-select-write-status-links-notes",
            "migrationInterviewPath": DEFAULT_BACKLOG_PROVIDER["migrationInterviewPath"],
            "exportMode": "interview-approved",
        }
    else:
        backlog_provider = normalize_tracker_adapter_config(None, DEFAULT_BACKLOG_PROVIDER, "backlogProvider")
    data["backlogProvider"] = backlog_provider
    data["goalTracker"] = goal_tracker_from_backlog_provider(backlog_provider) if backlog_provider["enabled"] else legacy_goal_tracker
    stakeholder_questions = {
        **DEFAULT_STAKEHOLDER_QUESTIONS,
        **data.get("stakeholderQuestions", {}),
    }
    if not isinstance(stakeholder_questions.get("enabled"), bool):
        raise SystemExit("Project adapter stakeholderQuestions.enabled must be boolean")
    if stakeholder_questions.get("provider") != "github-issues":
        raise SystemExit("Project adapter stakeholderQuestions.provider must be github-issues")
    for key in [
        "defaultMention",
        "unknownStakeholderPolicy",
        "openLabel",
        "answeredLabel",
        "projectStatusOnOpen",
        "projectStatusOnAnswered",
    ]:
        stakeholder_questions[key] = str(stakeholder_questions.get(key, "")).strip()
    if stakeholder_questions["unknownStakeholderPolicy"] != "ask-human-once":
        raise SystemExit("Project adapter stakeholderQuestions.unknownStakeholderPolicy must be ask-human-once")
    sync_at = stakeholder_questions.get("syncAt", [])
    allowed_sync = {"startup", "milestone-boundary", "pr-boundary", "blocked"}
    if not isinstance(sync_at, list) or any(str(item).strip() not in allowed_sync for item in sync_at):
        raise SystemExit("Project adapter stakeholderQuestions.syncAt must contain only startup, milestone-boundary, pr-boundary, and/or blocked")
    stakeholder_questions["syncAt"] = [str(item).strip() for item in sync_at]
    if stakeholder_questions["enabled"]:
        for key in ["openLabel", "answeredLabel"]:
            if not stakeholder_questions[key]:
                raise SystemExit(f"Project adapter stakeholderQuestions.{key} must be non-blank when enabled")
        if stakeholder_questions["openLabel"] == stakeholder_questions["answeredLabel"]:
            raise SystemExit("Project adapter stakeholderQuestions.openLabel and answeredLabel must differ")
    data["stakeholderQuestions"] = stakeholder_questions
    latest_code = {
        **DEFAULT_LATEST_CODE,
        **data.get("latestCode", {}),
    }
    if not isinstance(latest_code.get("enabled"), bool):
        raise SystemExit("Project adapter latestCode.enabled must be boolean")
    for key in ["remote", "base", "statusFile", "rule"]:
        value = str(latest_code.get(key, "")).strip()
        if not value:
            raise SystemExit(f"Project adapter latestCode.{key} must be non-blank")
        latest_code[key] = value
    if Path(latest_code["statusFile"]).is_absolute():
        raise SystemExit("Project adapter latestCode.statusFile must be project-relative, not absolute")
    for key in ["fetchAll", "includeOpenPrs", "includeRemoteBranches"]:
        if not isinstance(latest_code.get(key), bool):
            raise SystemExit(f"Project adapter latestCode.{key} must be boolean")
    try:
        latest_code["maxAgeMinutes"] = int(latest_code["maxAgeMinutes"])
        latest_code["maxAheadBranches"] = int(latest_code["maxAheadBranches"])
    except (TypeError, ValueError) as exc:
        raise SystemExit("Project adapter latestCode.maxAgeMinutes and maxAheadBranches must be positive integers") from exc
    if latest_code["maxAgeMinutes"] < 1 or latest_code["maxAheadBranches"] < 0:
        raise SystemExit("Project adapter latestCode.maxAgeMinutes must be positive and maxAheadBranches must be non-negative")
    data["latestCode"] = latest_code
    model_effort_policy = {
        **DEFAULT_MODEL_EFFORT_POLICY,
        **data.get("modelEffortPolicy", {}),
    }
    if model_effort_policy["defaultEffort"] not in {"low", "medium", "high", "xhigh"}:
        raise SystemExit("Project adapter modelEffortPolicy.defaultEffort must be low, medium, high, or xhigh")
    model_effort_policy["xhighPolicy"] = str(model_effort_policy.get("xhighPolicy", "")).strip()
    if not model_effort_policy["xhighPolicy"]:
        raise SystemExit("Project adapter modelEffortPolicy.xhighPolicy must be non-blank")
    if not isinstance(model_effort_policy.get("executionPacketSonnetEligible"), bool):
        raise SystemExit("Project adapter modelEffortPolicy.executionPacketSonnetEligible must be boolean")
    data["modelEffortPolicy"] = model_effort_policy
    data["localResourceLocks"] = {**DEFAULT_LOCAL_RESOURCE_LOCKS, **data.get("localResourceLocks", {})}
    data["localResourceIsolation"] = {
        **DEFAULT_LOCAL_RESOURCE_ISOLATION,
        **data.get("localResourceIsolation", {}),
    }
    document_context = {**DEFAULT_DOCUMENT_CONTEXT, **data.get("documentContext", {})}
    if document_context["enforcement"] not in {"warn", "strict"}:
        raise SystemExit("Project adapter documentContext.enforcement must be warn or strict")
    for key in ["contextIndexPaths", "trackedDocRoots", "historicalPaths", "ignoredDocPaths"]:
        if key in document_context and any(not str(path).strip() for path in document_context.get(key, [])):
            raise SystemExit(f"Project adapter documentContext.{key} must contain non-blank paths")
        if key in document_context and any(Path(str(path).strip()).is_absolute() for path in document_context.get(key, [])):
            raise SystemExit(f"Project adapter documentContext.{key} must use project-relative or home-relative paths, not absolute machine paths")
    for key in ["maxIndexBytes", "maxIndexLines"]:
        try:
            document_context[key] = int(document_context[key])
        except (TypeError, ValueError) as exc:
            raise SystemExit(f"Project adapter documentContext.{key} must be a positive integer") from exc
        if document_context[key] < 1:
            raise SystemExit(f"Project adapter documentContext.{key} must be a positive integer")
    data["documentContext"] = document_context
    data["ciTestGate"] = normalize_ci_test_gate(data)
    data["uiEvidence"] = normalize_ui_evidence(data)
    data["developmentEnvironment"] = normalize_development_environment(data)
    data["runtimeConfig"] = normalize_runtime_config(data)
    data["criticalJourneys"] = normalize_critical_journeys(data)
    data["migrationSafety"] = normalize_migration_safety(data)
    data["coverageFloor"] = normalize_coverage_floor(data)
    data["planAcceptance"] = normalize_plan_acceptance(data)
    data["flakyQuarantine"] = normalize_flaky_quarantine(data)
    data["healthContract"] = normalize_health_contract(data)
    data["sideEffectProof"] = normalize_side_effect_proof(data)
    missing_commands = [key for key in REQUIRED_COMMANDS if key not in data["commands"]]
    if missing_commands:
        raise SystemExit(f"Project adapter commands missing required keys: {', '.join(missing_commands)}")
    data["review"] = {**DEFAULT_REVIEW, **data["review"]}
    review_missing = [key for key in ["codexWrapper", "claudeReview", "crossModelTiming"] if key not in data["review"]]
    if review_missing:
        raise SystemExit(f"Project adapter review missing required keys: {', '.join(review_missing)}")
    if not isinstance(data["review"].get("codexFastMode"), bool):
        raise SystemExit("Project adapter review.codexFastMode must be boolean")
    if not isinstance(data["review"].get("prePushReviewEvidence"), bool):
        raise SystemExit("Project adapter review.prePushReviewEvidence must be boolean")
    round_budgets = {**DEFAULT_REVIEW["roundBudgets"], **data["review"].get("roundBudgets", {})}
    for tier in ("T0", "T1", "T2", "T3"):
        try:
            round_budgets[tier] = int(round_budgets[tier])
        except (TypeError, ValueError) as exc:
            raise SystemExit(f"Project adapter review.roundBudgets.{tier} must be a non-negative integer") from exc
        if round_budgets[tier] < 0:
            raise SystemExit(f"Project adapter review.roundBudgets.{tier} must be a non-negative integer")
        if tier == "T0" and round_budgets[tier] != 0:
            print(
                f"Project adapter review.roundBudgets.T0={round_budgets[tier]} is legacy; "
                "coercing to 0 because T0 does not use cross-model implementation review",
                file=sys.stderr,
            )
            round_budgets[tier] = 0
        if tier != "T0" and round_budgets[tier] < 1:
            raise SystemExit(f"Project adapter review.roundBudgets.{tier} must be at least 1")
    data["review"]["roundBudgets"] = round_budgets
    data["review"]["codexPlanWrapper"] = str(data["review"].get("codexPlanWrapper") or data["review"]["codexWrapper"]).strip()
    if not data["review"]["codexPlanWrapper"]:
        raise SystemExit("Project adapter review.codexPlanWrapper must be non-blank")
    behavior_missing = [key for key in ["required", "paths", "exemption"] if key not in data["behaviorSpecs"]]
    if behavior_missing:
        raise SystemExit(f"Project adapter behaviorSpecs missing required keys: {', '.join(behavior_missing)}")
    behavior = data["behaviorSpecs"]
    behavior["sourceMaterials"] = behavior.get("sourceMaterials", [])
    behavior["sourceMaterialRule"] = behavior.get(
        "sourceMaterialRule",
        "If reviewed external/customer/business behavior specs are provided, review and adapt them before authoring new scenarios; preserve business intent and document deviations.",
    )
    for key in ["paths", "sourceMaterials"]:
        values = behavior.get(key, [])
        if not isinstance(values, list) or any(not str(value).strip() for value in values):
            raise SystemExit(f"Project adapter behaviorSpecs.{key} must be an array of non-blank strings")
        if any(Path(str(value).strip()).is_absolute() for value in values):
            raise SystemExit(f"Project adapter behaviorSpecs.{key} must use project-relative paths, not absolute machine paths")
    if not str(behavior.get("sourceMaterialRule", "")).strip():
        raise SystemExit("Project adapter behaviorSpecs.sourceMaterialRule must be non-blank")
    role_vocabulary = behavior.get("roleVocabulary", {})
    if role_vocabulary:
        for key in ["allowed", "forbidden"]:
            values = role_vocabulary.get(key, [])
            if not isinstance(values, list) or any(not str(value).strip() for value in values):
                raise SystemExit(f"Project adapter behaviorSpecs.roleVocabulary.{key} must be an array of non-blank strings")
    behavior["pendingTags"] = behavior.get("pendingTags", DEFAULT_BEHAVIOR_PENDING_TAGS)
    if not isinstance(behavior["pendingTags"], list) or any(not str(value).strip() for value in behavior["pendingTags"]):
        raise SystemExit("Project adapter behaviorSpecs.pendingTags must be an array of non-blank strings")
    behavior["inactiveAcceptanceEnforcement"] = str(
        behavior.get("inactiveAcceptanceEnforcement", "strict" if behavior["required"] else "warn")
    ).strip()
    if behavior["inactiveAcceptanceEnforcement"] not in {"warn", "strict"}:
        raise SystemExit("Project adapter behaviorSpecs.inactiveAcceptanceEnforcement must be warn or strict")
    try:
        behavior["maxPendingScenarios"] = int(behavior.get("maxPendingScenarios", 0))
    except (TypeError, ValueError) as exc:
        raise SystemExit("Project adapter behaviorSpecs.maxPendingScenarios must be a non-negative integer") from exc
    if behavior["maxPendingScenarios"] < 0:
        raise SystemExit("Project adapter behaviorSpecs.maxPendingScenarios must be a non-negative integer")
    behavior["pendingRequiresOwnerAndTrigger"] = bool(behavior.get("pendingRequiresOwnerAndTrigger", True))
    behavior["acceptanceHarnesses"] = behavior.get("acceptanceHarnesses", DEFAULT_BEHAVIOR_ACCEPTANCE_HARNESSES)
    if not isinstance(behavior["acceptanceHarnesses"], list):
        raise SystemExit("Project adapter behaviorSpecs.acceptanceHarnesses must be an array")
    for index, harness in enumerate(behavior["acceptanceHarnesses"], start=1):
        if not isinstance(harness, dict):
            raise SystemExit(f"Project adapter behaviorSpecs.acceptanceHarnesses[{index}] must be an object")
        for key in ["name", "command", "appPath"]:
            if not str(harness.get(key, "")).strip():
                raise SystemExit(f"Project adapter behaviorSpecs.acceptanceHarnesses[{index}].{key} must be non-blank")
        for key in ["appPath", "command"]:
            value = str(harness[key]).strip()
            if key == "appPath" and Path(value).is_absolute():
                raise SystemExit(f"Project adapter behaviorSpecs.acceptanceHarnesses[{index}].appPath must be project-relative")
        harness["featurePaths"] = harness.get("featurePaths", [])
        harness["targetProof"] = harness.get("targetProof", [])
        for key in ["featurePaths", "targetProof"]:
            values = harness[key]
            if not isinstance(values, list) or any(not str(value).strip() for value in values):
                raise SystemExit(f"Project adapter behaviorSpecs.acceptanceHarnesses[{index}].{key} must be an array of non-blank strings")
            if key == "featurePaths" and any(Path(str(value).strip()).is_absolute() for value in values):
                raise SystemExit(f"Project adapter behaviorSpecs.acceptanceHarnesses[{index}].featurePaths must use project-relative paths")
    data["bugBacklog"] = {**DEFAULT_BUG_BACKLOG, **data.get("bugBacklog", {})}
    for key in ["sourceOfTruth", "severityTaxonomy", "rule"]:
        if not str(data["bugBacklog"].get(key, "")).strip():
            raise SystemExit(f"Project adapter bugBacklog.{key} must be non-blank")
    for key in ["issueMirrors", "autoP0Categories", "autoP1Categories", "requiredFields"]:
        values = data["bugBacklog"].get(key, [])
        if not isinstance(values, list) or any(not str(value).strip() for value in values):
            raise SystemExit(f"Project adapter bugBacklog.{key} must be an array of non-blank strings")
    raw_stack = data.get("technologyStack", {})
    data["technologyStack"] = {**DEFAULT_TECHNOLOGY_STACK, **raw_stack}
    for key in ["cloudProviderDefault", "newProjectCloudDefault", "rule"]:
        value = str(data["technologyStack"].get(key, "")).strip()
        if not value:
            raise SystemExit(f"Project adapter technologyStack.{key} must be non-blank")
        data["technologyStack"][key] = value
    approved_providers = data["technologyStack"].get("approvedCloudProviders", [])
    if not isinstance(approved_providers, list) or any(not str(provider).strip() for provider in approved_providers):
        raise SystemExit("Project adapter technologyStack.approvedCloudProviders must be an array of non-blank strings")
    approved_providers = [str(provider).strip() for provider in approved_providers]
    if not approved_providers:
        raise SystemExit("Project adapter technologyStack.approvedCloudProviders must contain at least one provider")
    data["technologyStack"]["approvedCloudProviders"] = approved_providers
    if data["technologyStack"]["cloudProviderDefault"] not in approved_providers:
        raise SystemExit("Project adapter technologyStack.cloudProviderDefault must appear in approvedCloudProviders")
    if data["technologyStack"]["newProjectCloudDefault"] not in approved_providers:
        raise SystemExit("Project adapter technologyStack.newProjectCloudDefault must appear in approvedCloudProviders")
    if not isinstance(data["technologyStack"].get("newCloudProviderRequiresApproval"), bool):
        raise SystemExit("Project adapter technologyStack.newCloudProviderRequiresApproval must be boolean")
    aws_is_approved = any(provider.upper() == "AWS" for provider in approved_providers)
    if aws_is_approved or "awsCliConvention" in raw_stack:
        value = str(data["technologyStack"].get("awsCliConvention", "")).strip()
        if not value:
            raise SystemExit("Project adapter technologyStack.awsCliConvention must be non-blank")
        data["technologyStack"]["awsCliConvention"] = value
    else:
        data["technologyStack"].pop("awsCliConvention", None)
    stack_rules = data["technologyStack"].get("nonNegotiable", [])
    if not isinstance(stack_rules, list) or any(not str(rule).strip() for rule in stack_rules):
        raise SystemExit("Project adapter technologyStack.nonNegotiable must be an array of non-blank strings")
    data["technologyStack"]["nonNegotiable"] = [str(rule).strip() for rule in stack_rules]
    data["graphify"] = {**DEFAULT_GRAPHIFY, **data.get("graphify", {})}
    graphify = data["graphify"]
    for key in ["enabled", "preferWhenPresent", "gitIgnoreOutput", "allowProjectInstaller"]:
        if not isinstance(graphify.get(key), bool):
            raise SystemExit(f"Project adapter graphify.{key} must be boolean")
    for key in [
        "outputDir",
        "reportPath",
        "graphPath",
        "installPackage",
        "installCommand",
        "setupCommand",
        "buildCommand",
        "updateCommand",
        "queryCommand",
        "pathCommand",
        "explainCommand",
        "freshnessRule",
        "freshnessEnforcement",
        "rule",
    ]:
        value = str(graphify.get(key, "")).strip()
        if not value:
            raise SystemExit(f"Project adapter graphify.{key} must be non-blank")
        if key in {"outputDir", "reportPath", "graphPath"} and Path(value).is_absolute():
            raise SystemExit(f"Project adapter graphify.{key} must be project-relative, not an absolute machine path")
        graphify[key] = value.rstrip("/") if key == "outputDir" else value
    if graphify["freshnessEnforcement"] not in {"strict-if-present", "warn", "disabled"}:
        raise SystemExit("Project adapter graphify.freshnessEnforcement must be strict-if-present, warn, or disabled")
    if ".." in Path(graphify["outputDir"]).parts:
        raise SystemExit("Project adapter graphify.outputDir must stay inside the project")
    for key in ["reportPath", "graphPath"]:
        if ".." in Path(graphify[key]).parts:
            raise SystemExit(f"Project adapter graphify.{key} must stay inside the project")
    if graphify["outputDir"].strip(".") == "":
        raise SystemExit("Project adapter graphify.outputDir must not be the project root")
    data["deploymentTargets"] = data.get("deploymentTargets", DEFAULT_DEPLOYMENT_TARGETS)
    if not isinstance(data["deploymentTargets"], list):
        raise SystemExit("Project adapter deploymentTargets must be an array when present")
    for index, target_config in enumerate(data["deploymentTargets"]):
        if not isinstance(target_config, dict):
            raise SystemExit(f"Project adapter deploymentTargets[{index}] must be an object")
        required_deploy_fields = ["name", "milestoneClose", "target", "procedure", "healthCheck"]
        missing_deploy_fields = [key for key in required_deploy_fields if key not in target_config]
        if missing_deploy_fields:
            raise SystemExit(
                f"Project adapter deploymentTargets[{index}] missing required keys: {', '.join(missing_deploy_fields)}"
            )
        for key in ["name", "target", "procedure", "healthCheck"]:
            if not str(target_config.get(key, "")).strip():
                raise SystemExit(f"Project adapter deploymentTargets[{index}].{key} must be non-blank")
        if not isinstance(target_config["milestoneClose"], bool):
            raise SystemExit(f"Project adapter deploymentTargets[{index}].milestoneClose must be boolean")
        for key in ["rollback", "buildIdentity"]:
            if key in target_config and not str(target_config.get(key, "")).strip():
                raise SystemExit(f"Project adapter deploymentTargets[{index}].{key} must be non-blank when present")
        signals = target_config.get("outcomeSignals", [])
        if not isinstance(signals, list) or not all(isinstance(s, str) for s in signals):
            raise SystemExit(f"Project adapter deploymentTargets[{index}].outcomeSignals must be a list of strings")
        side_effects = target_config.get("sideEffects", [])
        if not isinstance(side_effects, list) or not all(isinstance(s, dict) for s in side_effects):
            raise SystemExit(f"Project adapter deploymentTargets[{index}].sideEffects must be a list of objects")
        target_config["deployHealth"] = normalize_deploy_health_config(target_config.get("deployHealth"), index)
    continuity_missing = [key for key in ["path", "archiveDir", "gitIgnore"] if key not in data["continuity"]]
    if continuity_missing:
        raise SystemExit(f"Project adapter continuity missing required keys: {', '.join(continuity_missing)}")
    lane_state_missing = [key for key in DEFAULT_LANE_STATE if key not in data["laneState"]]
    if lane_state_missing:
        raise SystemExit(f"Project adapter laneState missing required keys: {', '.join(lane_state_missing)}")
    planning_missing = [
        key
        for key in ["sourceOfTruth", "template", "templateTrigger", "scratchPaths", "rule"]
        if key not in data["planningArtifacts"]
    ]
    if planning_missing:
        raise SystemExit(f"Project adapter planningArtifacts missing required keys: {', '.join(planning_missing)}")
    planning_blank = [
        key
        for key in ["sourceOfTruth", "template", "templateTrigger", "rule"]
        if not str(data["planningArtifacts"].get(key, "")).strip()
    ]
    if planning_blank:
        raise SystemExit(f"Project adapter planningArtifacts has blank required values: {', '.join(planning_blank)}")
    scratch_paths = data["planningArtifacts"].get("scratchPaths", [])
    if not scratch_paths or any(not str(path).strip() for path in scratch_paths):
        raise SystemExit("Project adapter planningArtifacts.scratchPaths must contain at least one non-blank path")
    review_exemptions = data["planningArtifacts"].get("reviewExemptions", [])
    if not isinstance(review_exemptions, list) or any(not str(item).strip() for item in review_exemptions):
        raise SystemExit("Project adapter planningArtifacts.reviewExemptions must be an array of non-blank strings")
    data["planningArtifacts"]["reviewExemptions"] = [str(item).strip() for item in review_exemptions]
    if not str(data["localResourceLocks"].get("locksDir", "")).strip():
        raise SystemExit("Project adapter localResourceLocks.locksDir must be non-blank")
    for index, resource in enumerate(data["localResourceLocks"].get("resources", [])):
        for key in ["name", "reason", "commands"]:
            if key not in resource:
                raise SystemExit(f"Project adapter localResourceLocks.resources[{index}] missing required key: {key}")
        if not str(resource["name"]).strip():
            raise SystemExit(f"Project adapter localResourceLocks.resources[{index}].name must be non-blank")
        if not str(resource["reason"]).strip():
            raise SystemExit(f"Project adapter localResourceLocks.resources[{index}].reason must be non-blank")
        if not resource["commands"] or any(not str(command).strip() for command in resource["commands"]):
            raise SystemExit(f"Project adapter localResourceLocks.resources[{index}].commands must contain non-blank commands")
    isolation = data["localResourceIsolation"]
    if not str(isolation.get("envFile", "")).strip():
        raise SystemExit("Project adapter localResourceIsolation.envFile must be non-blank")
    if not str(isolation.get("composeProjectPrefix", "")).strip():
        raise SystemExit("Project adapter localResourceIsolation.composeProjectPrefix must be non-blank")
    try:
        port_stride = int(isolation.get("portStride", 0))
    except (TypeError, ValueError) as exc:
        raise SystemExit("Project adapter localResourceIsolation.portStride must be a positive integer") from exc
    if port_stride < 1:
        raise SystemExit("Project adapter localResourceIsolation.portStride must be a positive integer")
    for index, item in enumerate(isolation.get("portVariables", [])):
        for key in ["name", "base"]:
            if key not in item:
                raise SystemExit(f"Project adapter localResourceIsolation.portVariables[{index}] missing required key: {key}")
        if not re.fullmatch(r"[A-Z_][A-Z0-9_]*", str(item["name"])):
            raise SystemExit(f"Project adapter localResourceIsolation.portVariables[{index}].name must be an env var name")
        try:
            base_port = int(item["base"])
        except (TypeError, ValueError) as exc:
            raise SystemExit(
                f"Project adapter localResourceIsolation.portVariables[{index}].base must be an integer"
            ) from exc
        if base_port < 1 or base_port > 65535:
            raise SystemExit(f"Project adapter localResourceIsolation.portVariables[{index}].base must be a valid TCP port")
    for name, template in isolation.get("envTemplates", {}).items():
        if not re.fullmatch(r"[A-Z_][A-Z0-9_]*", str(name)):
            raise SystemExit(f"Project adapter localResourceIsolation.envTemplates key must be an env var name: {name}")
        if not str(template).strip():
            raise SystemExit(f"Project adapter localResourceIsolation.envTemplates.{name} must be non-blank")
    isolated_commands = isolation.get("isolatedCommands", [])
    if any(not str(command).strip() for command in isolated_commands):
        raise SystemExit("Project adapter localResourceIsolation.isolatedCommands must contain non-blank commands")
    readiness_missing = [key for key in ["sources"] if key not in data["readiness"]]
    if readiness_missing:
        raise SystemExit(f"Project adapter readiness missing required keys: {', '.join(readiness_missing)}")
    bootstrap_placeholders = collect_bootstrap_placeholders(data)
    if bootstrap_placeholders:
        raise SystemExit(
            "Project adapter still contains BOOTSTRAP REQUIRED placeholders: "
            + ", ".join(bootstrap_placeholders)
            + ". Replace them before rendering or starting a lane."
        )
    if isinstance(data, dict):
        # Schema validation runs LAST: the per-field validators above emit specific, friendly
        # errors (e.g. "bugBacklog.sourceOfTruth must be non-blank"); the schema is the final net
        # for unknown top-level keys (additionalProperties:false) and type/structure issues they do
        # not cover, so a duplicated minLength/type rule never preempts the dedicated message.
        validate_adapter_schema(data, path)
    return data


def display_project_arg(project_path: Path) -> str:
    # Canonical, host-OS-independent form: as_posix() always emits forward slashes,
    # so a Windows render and a POSIX render of the same adapter produce identical
    # committed files and resolve on every host. See source_adapter_path().
    # Relativized against the canonical checkout first: the stored value is re-resolved later by
    # source_adapter_path(), and a private adapter only ever exists under the canonical root.
    relative = source_adapter_repo_relative_path(project_path)
    return relative if relative is not None else project_path.resolve().as_posix()


def source_adapter_path(value: str) -> Path:
    """Resolve a stored `_generated.sourceAdapter` value to a filesystem path.

    Renders write the canonical forward-slash form, but a lane adapter produced on
    Windows by an older build may carry backslash separators; normalize them so
    the source adapter still resolves on POSIX hosts.

    A relative value is resolved canonical-first (see methodology_data_roots): the exec root is a
    snapshot that cannot carry a private adapter, and the caller's failure mode is a hard
    "sourceAdapter is missing" on a file that is sitting right there in the canonical checkout.
    The first root wins when the file exists nowhere, so the error message names the canonical
    path an operator can actually fix.
    """
    normalized = str(value).replace("\\", "/")
    path = Path(normalized)
    if path.is_absolute():
        return path
    roots = methodology_data_roots()
    for root in roots:
        candidate = root / path
        if candidate.exists():
            return candidate
    return roots[0] / path


def generated_header(project_arg: str) -> str:
    return GENERATED_HEADER_TEMPLATE.format(source="repository checkout", project_arg=project_arg)


def command_needs_lane_run(command: str, isolated_commands: list[str]) -> bool:
    try:
        command_parts = set(shlex.split(command))
    except ValueError:
        command_parts = set(command.split())
    for isolated in isolated_commands:
        if command == isolated:
            return True
        try:
            isolated_parts = shlex.split(isolated)
        except ValueError:
            isolated_parts = isolated.split()
        if isolated_parts and all(part in command_parts for part in isolated_parts):
            return True
    return False


def project_source_arg(data: dict, project_path: Path) -> str:
    generated = data.get("_generated", {})
    if generated.get("sourceAdapter"):
        return str(generated["sourceAdapter"]).replace("\\", "/")
    if project_path.name in (LANE_ADAPTER_FILE, LEGACY_LANE_ADAPTER_FILE):
        return project_path.name
    return display_project_arg(project_path)


def instrumentation_guidance_line(instrumentation: dict) -> str:
    """The rendered `## Session Signal` instrumentation line. Boundary-publish guidance is gated on
    the effective config: a disabled/default lane is never told to run publish-instrumentation-record
    (which refuses while disabled), and an enabled `cadence: off` lane is told to publish MANUALLY
    with no boundary prompt (its defined behavior) rather than "at the off boundary"."""
    if not instrumentation["enabled"]:
        return (
            "- Instrumentation (`false`): sanitized, zero-product-data upstream signal; disabled here, "
            "opt in with `instrumentation.enabled` (do not run `publish-instrumentation-record` until "
            "then -- it refuses while disabled).\n"
        )
    cadence = instrumentation["cadence"]
    if cadence == "off":
        return (
            "- Instrumentation (`true`, cadence `off`): sanitized, zero-product-data record; publish "
            "MANUALLY with `publish-instrumentation-record` (no boundary prompting).\n"
        )
    return (
        f"- Instrumentation (`true`, cadence `{cadence}`): sanitized, zero-product-data record; "
        f"contribute upstream at the `{cadence}` boundary with `publish-instrumentation-record`.\n"
    )


def render_adapter(data: dict, agent: str, project_arg: str) -> str:
    project = data["project"]
    commands = data["commands"]
    branch_liveness_command = commands.get(
        "branchLivenessCheck",
        "tautline branch-liveness-check --target . --strict",
    )
    review = data["review"]
    model_effort = data["modelEffortPolicy"]
    codex_t1_review_command = f"tautline codex-run --target . --risk-tier T1 --review-round R1 -- {review['codexWrapper']}"
    codex_fast_mode = codex_fast_mode_status(data)
    review_evidence_line = (
        "- Evidence: `tautline finalize-implementation-review --target . --manifest <manifest>`; track `.impl-reviews/` evidence before push.\n"
        if review.get("prePushReviewEvidence", DEFAULT_REVIEW["prePushReviewEvidence"])
        else "- Pre-push review evidence hook is adapter-disabled; the source-of-truth plan must explain the project-specific replacement gate before push.\n"
    )
    if adapter_is_methodology_repo(data):
        codex_review_line = (
            f"- Codex review: `{codex_t1_review_command}`; T2/T3 change tier and add Stage 1 note/sweep args before `--`\n"
            if agent == "Claude"
            else f"- Codex wrapper: `{codex_t1_review_command}`; T2/T3 change tier and add Stage 1 note/sweep args before `--`\n"
        )
    else:
        codex_review_line = (
            f"- Codex review: T1 `codex-run --target . --risk-tier T1 --review-round R1 -- {review['codexWrapper']}`; T2/T3 add `--native-review-note` and `--stage1-sweep` (fast_mode={codex_fast_mode}; no bare `codex review`).\n"
            if agent == "Claude"
            else f"- External Codex wrapper: use `codex-run --target . --risk-tier T1|T2|T3 --review-round R1` with `{review['codexWrapper']}` (fast_mode={codex_fast_mode}).\n"
        )
    behavior = data["behaviorSpecs"]
    continuity = data["continuity"]
    lane_state = data["laneState"]
    session_journal = data["sessionJournal"]
    observability = data["observabilityEvents"]
    usage_accounting = data["usageAccounting"]
    context_rotation = data["contextRotation"]
    planning = data["planningArtifacts"]
    goal_artifacts = data["goalArtifacts"]
    goal_execution = data["goalExecution"]
    stakeholder_questions = data["stakeholderQuestions"]
    isolation = data["localResourceIsolation"]
    document_context = derived_document_context(data)
    prod = "yes" if data["productionDeployExists"] else "no"
    native_stage = "configured native review"
    cross_stage = "configured cross-model review before push"
    behavior_text = "required" if behavior["required"] else "project optional"
    behavior_paths = ", ".join(f"`{p}`" for p in behavior["paths"])
    behavior_source_materials = "\n".join(f"- `{p}`" for p in behavior.get("sourceMaterials", []))
    behavior_source_material_line = (
        f"- Behavior source materials:\n{behavior_source_materials}\n"
        "- Review/adapt source materials first; preserve business intent and document deviations.\n"
        if behavior_source_materials
        else ""
    )
    role_vocabulary = behavior.get("roleVocabulary", {})
    role_vocabulary_line = ""
    if role_vocabulary.get("allowed") or role_vocabulary.get("forbidden"):
        allowed_roles = ", ".join(f"`{role}`" for role in role_vocabulary.get("allowed", [])) or "not declared"
        forbidden_roles = ", ".join(f"`{role}`" for role in role_vocabulary.get("forbidden", [])) or "none declared"
        role_vocabulary_line = f"- Behavior roles: allowed {allowed_roles}; forbidden {forbidden_roles}.\n"
    known_rules = "\n".join(f"- {rule}" for rule in data.get("knownProjectRules", []))
    deployment_targets = data.get("deploymentTargets", [])
    if deployment_targets:
        deployment_lines = []
        for target in deployment_targets:
            health_cfg = target.get("deployHealth") or {}
            deploy_health = (
                f"; deploy health `{health_cfg['workflow']}` on `{health_cfg['branch']}`"
                if health_cfg.get("enabled")
                else ""
            )
            deployment_lines.append(
                "- `{name}`: deploy `{procedure}`; health `{health}`; close `{milestone}`{rollback}{deploy_health}".format(
                    name=target["name"],
                    milestone=str(target["milestoneClose"]).lower(),
                    procedure=target["procedure"],
                    health=target["healthCheck"],
                    rollback=f"; rollback `{target['rollback']}`" if target.get("rollback") else "",
                    deploy_health=deploy_health,
                )
            )
        deployment_target_text = "\n".join(deployment_lines)
    else:
        deployment_target_text = "- None declared."
    deploy_health_startup_text = (
        " Then run `deploy-health --target .`; failed/stale/unreadable deploy history outranks feature work."
        if deploy_health_configured_targets(data)
        else ""
    )
    stack = data["technologyStack"]
    approved_cloud = ", ".join(f"`{provider}`" for provider in stack["approvedCloudProviders"])
    stack_rules = "\n".join(f"- {rule}" for rule in stack.get("nonNegotiable", []))
    aws_cli_convention = str(stack.get("awsCliConvention", "")).strip()
    if aws_cli_convention:
        aws_cli_convention = aws_cli_convention.replace("For AWS-approved lanes, use AWS CLI: ", "").replace("deployment.", "deploy.")
        aws_cli_convention = aws_cli_convention.replace(
            "check `aws --version` and `aws sts get-caller-identity` or adapter identity check; ",
            "check `aws --version` and identity; ",
        ).replace(
            "do not ask for SSH keys unless adapter declares SSH/non-AWS deploy.",
            "no SSH keys unless adapter declares SSH/non-AWS.",
        )
    aws_cli_line = f"- AWS CLI convention: {aws_cli_convention}\n" if aws_cli_convention else ""
    scratch_paths = "\n".join(f"- `{p}`" for p in planning.get("scratchPaths", []))
    review_exemptions = "\n".join(f"- {item}" for item in planning.get("reviewExemptions", []))
    context_indexes = "\n".join(f"- `{p}`" for p in document_context["contextIndexPaths"])
    lane_env_file = isolation["envFile"]
    development_environment = data["developmentEnvironment"]
    supported_runtimes = ", ".join(f"`{runtime}`" for runtime in development_environment["supportedRuntimes"]) or "`unspecified`"
    windows_cfg = development_environment["windows"]
    line_endings = development_environment["lineEndings"]
    windows_guidance = windows_cfg.get("setup") or "Use the adapter-supported Windows runtime for lane gates."
    line_endings_guidance = (
        f"- Line endings: `{line_endings['policy']}`. {line_endings['windowsGuidance']}\n"
        if line_endings.get("policy") or line_endings.get("windowsGuidance")
        else ""
    )
    development_environment_text = (
        "## Development Environment\n\n"
        + f"- Supported lane runtimes: {supported_runtimes}.\n"
        + f"- Windows native shell support: `{windows_cfg['native']}`; supported Windows runtime: `{windows_cfg['supportedRuntime']}`.\n"
        + f"- Windows guidance: {windows_guidance}\n"
        + line_endings_guidance
        + "- `lane-start`/`methodology-status --fail-on-drift` report runtime. If native Windows is unsupported, use WSL2 for proof gates.\n\n"
    )
    early_warning_smoke = commands.get("earlyWarningSmoke", commands["mainStatus"])
    early_warning_is_distinct = "earlyWarningSmoke" in commands and early_warning_smoke != commands["mainStatus"]
    early_warning_shell = f"bash -lc {shlex.quote(early_warning_smoke)}"
    early_warning_runner = (
        f"tautline lane-run --target . -- {early_warning_shell}"
        if command_needs_lane_run(early_warning_smoke, isolation.get("isolatedCommands", []))
        else early_warning_shell
    )
    early_warning_log = f"{lane_state['runsDir']}/early-warning-smoke-$(date -u +%Y%m%dT%H%M%SZ).log"
    early_warning_start = (
        f"tautline background-run --log {early_warning_log} -- {early_warning_runner}"
        if early_warning_is_distinct
        else "covered by startup main-status gate; do not launch a duplicate background monitor"
    )
    merge_conflict_check = commands["mergeConflictCheck"]
    merge_conflict_startup_step = f"6. Branch/main conflict check: `{merge_conflict_check}`; failures outrank feature work.\n"
    early_warning_startup_step = (
        f"7. Conditional early-warning smoke: use `{early_warning_start}` only when adapter/plan/issue/operator/risk requires it; reuse live matching monitors after PID/log checks.\n"
        if early_warning_is_distinct
        else "7. No distinct early-warning smoke is configured; step 4 covers baseline health unless adapter/plan/issue/operator/risk asks.\n"
    )
    isolated_commands = "\n".join(
        f"- `{command}` -> `tautline lane-run --target . -- {command}`"
        for command in isolation.get("isolatedCommands", [])
    )
    stakeholder_question_line = (
        "- Stakeholder Q&A: use `stakeholder-question-ask`; sync with `stakeholder-question-status --sync` at startup/boundaries.\n"
        if stakeholder_questions["enabled"]
        else ""
    )
    provider_item_mode = provider_item_completion_enabled(data)
    if provider_item_mode:
        startup_work_selection_text = (
            f"3. Provider item is the completion unit. Run `backlog-provider-status --target .` and `backlog-provider-next --target .`; sync/review the selected issue plan before execution. `{lane_state['goalRun']}` is internal only and never outranks the board.\n"
        )
        goal_orchestration_text = (
            "## Work Item Orchestration\n\n"
            "- Completion unit: provider item. The GitHub issue/Project item is the source of completion; internal goals/milestones decompose large work and report progress only.\n"
            f"- Planning path/template: `{planning['sourceOfTruth']}` / `{planning['template']}`; optional internal goal ledger: `{lane_state['goalRun']}`.\n"
            f"- Backlog provider: board selects goals/milestones/bugs/tasks; repo plans execute; `completionUnit=provider-item`.\n"
            "- Keep issue body/checklists/comments and Project `Status` current at all times. Do not let stale goal ledger state outrank the selected issue.\n"
            + stakeholder_question_line
            + "- Migrate item-by-item; export only approved customer-facing items.\n"
            + "- Large work may be broken into milestones; progress belongs in the GitHub issue and board, not private chat.\n\n"
        )
        end_boundary_text = (
            "## End-of-Item Boundary\n\n"
            "- Clean close: commit, push origin/main, deploy when adapter/plan closeout requires it, post required evidence to the GitHub issue, update board Status to done, refresh continuity/journal, continue. Routine push/deploy is Done=shipped, not permission.\n\n"
        )
        ui_evidence_submit_hint = "`ui-evidence-submit --issue <issue> --event work-item-complete --manifest <manifest>`"
    else:
        startup_work_selection_text = (
            f"3. After gates: if `{lane_state['goalRun']}` exists, run `goal-next --target .` and check `{lane_state['executionPacket']}`; else use emitted `next_goal_*`. Lane status must be clean before multi-lane work.\n"
        )
        goal_orchestration_text = (
            "## Goal Orchestration\n\n"
            + "- Hierarchy: Goal -> Milestone -> PR/tactical item.\n"
            + f"- Goal path/template: `{goal_artifacts['sourceOfTruth']}` / `{goal_artifacts['template']}`; ledger: `{lane_state['goalRun']}`.\n"
            + f"- Claude `{goal_execution['preferredClaudeCommand']}`: fallback `goal-next`.\n"
            + f"- Backlog provider `{str(data['backlogProvider']['enabled']).lower()}`: board selects goals/milestones/bugs/tasks; repo plans execute.\n"
            + "- Sync reviewed repo plans before execution; Project `Status` drift blocks.\n"
            + stakeholder_question_line
            + "- Migrate item-by-item; export only approved items.\n"
            + "- Multi-session goals: use `goal-orchestration`.\n\n"
        )
        end_boundary_text = (
            "## End-of-Goal Boundary\n\n"
            + "- Clean close: commit, push origin/main, deploy when adapter/plan closeout requires it, run required iteration review, pass delivery check, goal-complete --iteration-review-record, refresh continuity/journal, continue. Routine push/deploy is Done=shipped, not permission.\n\n"
        )
        ui_evidence_submit_hint = "`goal-advance --ui-evidence-manifest <manifest>`"
    ui_evidence = data["uiEvidence"]
    ui_comments = ui_evidence["issueComments"]
    ui_required_events = ", ".join(f"`{event}`" for event in ui_evidence["requiredEvents"]) or "none"
    ui_capture_command = ui_evidence.get("captureCommand") or "project Playwright screenshot command"
    ui_evidence_text = (
        "## UI Evidence\n\n"
        + f"- Playwright proof: enforcement `{ui_evidence['enforcement']}`; manifest `{ui_evidence['evidenceDir']}/{ui_evidence['manifestGlob']}`; required at {ui_required_events}; issue comments `{str(ui_comments['enabled']).lower()}`/required `{str(ui_comments['required']).lower()}`.\n"
        + f"- Capture: `{ui_capture_command}`.\n"
        + f"- UI-capable work needs screenshots plus a `minervit-ui-evidence/v1` manifest (path, sha256, route/viewport, URL when hosted). No UI/no served origin needs `status: not_applicable` with a concrete reason. Submit proof with {ui_evidence_submit_hint}; required GitHub issue comments post there.\n\n"
        if ui_evidence["enabled"]
        else ""
    )

    return (
        generated_header(project_arg)
        + f"# {project} - {agent} Adapter\n\n"
        + "Canonical process: `methodology/canonical-rules.md` in the methodology repo.\n\n"
        + "Memories/Open Brain are evidence only; never process authority.\n\n"
        + "## Deployment Targets\n\n"
        + deployment_target_text
        + "\n\n"
        + "- Adapter-owned deploy procedure/health is authoritative.\n\n"
        + "## Technical Stack Policy\n\n"
        + f"- Cloud default `{stack['cloudProviderDefault']}`; approved {approved_cloud}; new provider approval `{str(stack['newCloudProviderRequiresApproval']).lower()}`.\n"
        + "- Tool/framework defaults do not authorize new platforms; adapter rule is authoritative.\n"
        + (f"- Non-negotiable: {'; '.join(stack.get('nonNegotiable', []))}.\n" if stack_rules else "")
        + "\n"
        + "## Session Start\n\n"
        + "Before gates, resolve CLI via env or `$MINERVIT_METHODOLOGY_REPO/bin/tautline`.\n"
        + "1. Run `lane-start --target .` and `methodology-status --target . --fail-on-drift`; fix drift before planning.\n"
        + f"2. Read `{continuity['path']}` when present; resume `Next Action` after gates.\n"
        + startup_work_selection_text
        + f"4. Main/deploy health: `{commands['mainStatus']}`.{deploy_health_startup_text} Red state outranks feature work.\n"
        + f"5. PR/liveness: configured open-PR check; on PR branches `{branch_liveness_command}`. Inactive/conflicted state stops branch work.\n"
        + merge_conflict_startup_step
        + early_warning_startup_step
        + "8. Monitor smoke within 10m when triggered. Bad/unclear gates halt commit/push/merge.\n\n"
        + "- Latest-code baseline: `.ai-work/LATEST_CODE_BASELINE.json`; run `latest-code-status --target . --write` before deep analysis/state changes.\n\n"
        + development_environment_text
        + "## Work Profiles\n\n"
        + "- `development`: all gates. Docs/wireframes: `lane-start --profile product-docs` or `support-docs`; docs/assets only; code/config/generated/base-branch pushes block. Code: rerun `--profile development`.\n\n"
        + "## Lane Methodology Updates\n\n"
        + "- Product/client lanes do not raw-pull latest methodology; sync at boundaries.\n"
        + "- Lane pin: `set-framework-channel --target . stable|experimental`; old repos default to stable/manual/dry-run.\n"
        + "- Adapter channel: `set-framework-channel --target . --source adapter stable|experimental`; no hand edits.\n"
        + "- Status: version/commit; stale host skill metadata means restart host/session.\n"
        + "- Config changes: `render-adapters --write --json-only`; never overwrite hand-written `CLAUDE.md`/`AGENTS.md`.\n"
        + "\n"
        + "## Local Resource Isolation\n\n"
        + f"- Lane startup writes lane-local env to `{lane_env_file}`.\n"
        + "- Use `tautline lane-run --target . -- <command>` for isolated local-service gates.\n"
        + "- Port collisions are not human blockers; use lane-local ports/Compose and lock only non-isolatable resources.\n"
        + (
            f"- Isolated commands: {', '.join(f'`{command}`' for command in isolation.get('isolatedCommands', []))}.\n"
            if isolated_commands
            else ""
        )
        + "\n"
        + "## Graphify Navigation\n\n"
        + "- If current, use Graphify report/query/path/explain before broad `rg`/grep; run `graphify . --update` before commit/push. Never run Graphify installers or commit `graphify-out/`.\n\n"
        + "## Context Continuity\n\n"
        + f"- Handoff path: `{continuity['path']}`.\n"
        + "- Use `prepare-continuity --target . --stdin` before yield/end-turn summaries.\n"
        + f"- Context indexes: {', '.join(f'`{p}`' for p in document_context['contextIndexPaths'])}; read indexes first.\n"
        + f"- Context rotate: boundary or {context_rotation['heartbeatMinutes']}m goal heartbeat, >= {context_rotation['softPercent']}% if safe; >= {context_rotation['hardPercent']}% next safe boundary. Handoff, `/compact` or exact fresh-session action; resume.\n"
        + "- Next sessions: gates, handoff, goal/milestone next action; no work -> create/review plan.\n\n"
        + "## Session Signal\n\n"
        + f"- Session journals (`{str(session_journal['enabled']).lower()}`): LOCAL evidence only via `prepare-session-journal`/`validate-session-journal` under `.ai-runs/`; they can no longer be published to any remote (0.9.0).\n"
        + instrumentation_guidance_line(data["instrumentation"])
        + "- Repo events: `event-log-path`; `event-viewer`; `log-event`; no direct file writes; `event-observability`.\n\n"
        + goal_orchestration_text
        + "## Delivery And Accounting\n\n"
        + "- Iteration review when enabled: status/validate/generate/publish; media out of git; after merge publish CloudFront + Chat. `video=false` skips recap; review Chat is not live-deploy proof.\n"
        + "- Milestone/product notes when enabled: `publish-milestone-update --target . --milestone <id> --stdin` and `publish-product-note --target . --title <title> --stdin`; never commit webhooks.\n"
        + "- Usage: `usage-record`, `usage-import-claude`, `usage-report`; record source+confidence, no direct JSONL/fake exact costs.\n\n"
        + end_boundary_text
        + "## Milestone Continuation\n\n"
        + f"- Ledger: `{lane_state['milestoneRun']}`. Start/refresh: `tautline milestone-start --target . --plan <source-of-truth-plan>`; inspect: `tautline milestone-next --target .`.\n"
        + "- Missing ledger: run `milestone-start`. After PR boundary/completion: `milestone-advance --target . --event <event>` and start the printed `next_action`.\n"
        + "- At milestone-complete, publish the internal milestone update when enabled before moving on.\n"
        + f"- Watchdog: `{str(data['milestoneContinuation']['watchdogEnabled']).lower()}`/{data['milestoneContinuation']['watchdogCadenceMinutes']}m; recovery visibility only.\n\n"
        + "## Autonomy And Planning\n\n"
        + "- Risk tiers: T0 docs/config nits use near-zero ceremony; T1 uses brief inline/packet planning; T2/T3 require full plan review plus explicit/standing approval before implementation.\n"
        + "- Named next work creates motion: start/update the source-of-truth plan unless a true blocker exists.\n"
        + "- Plans must be substantive and use the project template; state benefit + wall-clock estimate before implementation.\n"
        + "- T2/T3: run `plan-finalization-precheck --target . --plan <source-of-truth-plan>` and plan review R1/R2 when required.\n"
        + "- Review target is 2 rounds; rounds 3-4 self-authorize with `--exception-note` "
        + "(never ask the operator); past round 4 refusal is unconditional -- split into smaller "
        + "plans unless the bound evidence is clean and current.\n"
        + "- Evidence is tracked `.plan-reviews/`; do not hand-edit manifests, disable hooks, skip ExitPlanMode checks, or ask for bypass.\n"
        + "- Detailed policy: canonical `Planning`, `risk-tier-autonomy`, `review-before-push`.\n\n"
        + "## Planning Artifacts And Backlog Source Of Truth\n\n"
        + f"- Source/template: `{planning['sourceOfTruth']}` / `{planning['template']}`; manifests: `{Path(planning['sourceOfTruth']) / '.plan-reviews'}`.\n"
        + f"- Trigger: {planning['templateTrigger']}\n"
        + (f"- Exemptions: {'; '.join(planning.get('reviewExemptions', []))}.\n" if review_exemptions else "")
        + "- Tool-default plan paths are scratch only; migrate relevant scratch before continuing. Source-of-truth paths beat memory/global defaults.\n"
        + "- `methodology-status --target . --fail-on-drift` fails on missing paths or relevant scratch; do not freehand `Cross-Model Review Evidence`.\n\n"
        + "## Autonomy And Status\n\n"
        + "- Own safe authorized forward motion; ask one exact blocker question only for decisions changing approved scope/risk/cost/security/production/data/approval.\n"
        + "- Session-scope recovery is safe work; operator-scope destructive actions need approval. After landed/queued PR or blocked tools, sync/status/start next source-of-truth item.\n"
        + "- Process authority: canonical methodology, this generated adapter, or methodology skills (not memories/Open Brain).\n"
        + "- Status leads with plain-language outcome/current state and next action. If skills are unavailable/stale, resolve via `$MINERVIT_METHODOLOGY_REPO`, env, or `version --no-remote`.\n"
        + "- Methodology/process regressions use framework-intake; RCA-shaped responses require lane-local artifact and pushed archive copy before memory notes.\n\n"
        + "## Delivery Summaries\n\n"
        + "- Landed/shipped/queued reports start with executive outcome before technical detail.\n"
        + "- After clean local gates + pushed/queued PR, summarize immediately.\n"
        + "- Cover outcome, value, lifecycle position, percent/readiness, next item, files/behavior, gates, risks, and skipped validation.\n"
        + "- Refresh continuity before summaries are complete; handoff refresh is housekeeping, not a stop signal.\n"
        + "- A queued-delivery summary ends with next work already underway or `No authorized next work remains` with checks performed. Detailed policy: `delivery-summary` skill.\n\n"
        + "## Execution Packet Work Loop\n\n"
        + f"- Codex milestone planning may produce `{lane_state['executionPacket']}` after the milestone plan is approved.\n"
        + "- Packet defines scope, ordered queue, dependencies, safe parallelism, gates, blockers, and completion definition.\n"
        + "- Consume items in order, record evidence, push/PR/queue when gates pass, and continue until exhausted, true blocker, or human direction change.\n\n"
        + "## TDD And Behavior Specs\n\n"
        + "- Functional changes require TDD: name tests in the plan, write tests first where practical, then implement.\n"
        + f"- Behavior specs are {behavior_text} for customer-facing changes under adapter `behaviorSpecs.paths`.\n"
        + f"- Exemption marker: `{behavior['exemption']}`.\n"
        + behavior_source_material_line
        + role_vocabulary_line
        + "- Behavior gate: `behavior-spec-status --target .`; inactive specs/wrong-app harness = skipped validation. Missing tests for new functions/endpoints/pages are Critical.\n\n"
        + "## Early-Warning Smoke\n\n"
        + f"- Early-warning smoke command: `{early_warning_smoke}`.\n"
        + f"- Start command: `{early_warning_start}`.\n"
        + "- Early-warning smoke is no longer a standing gate. Session startup/main health covers baseline branch health; run this smoke only when the adapter, plan, issue, human operator, or concrete risk asks for it.\n"
        + "- Smoke failures interrupt the risky action, but no routine PR waits on ambient poll bookkeeping.\n\n"
        + "## Model And Effort Policy\n\n"
        + f"- Default effort: `{model_effort['defaultEffort']}`. `xhigh` is reserved for {model_effort['xhighPolicy']}.\n"
        + f"- Execution-packet items may use Sonnet when marked eligible: `{str(model_effort['executionPacketSonnetEligible']).lower()}`.\n\n"
        + "## Local Gates\n\n"
        + f"- Before commit: `tautline lane-run --target . -- {commands['fastPreflight']}`.\n"
        + f"- Before push: `tautline lane-run --target . -- {commands['testEnvironment']}` then `tautline lane-run --target . -- {commands['fullPreflight']}`.\n"
        + "- Pre-merge gates are required before push/queue. During final preflight on a frozen PR tip, plan next work or poll; do not edit the proving diff.\n"
        + "- If preflight fails, repair the PR; if it passes, push/queue before next implementation.\n"
        + "- Do not substitute GitHub Actions for local preflight or as a feedback loop.\n\n"
        + ui_evidence_text
        + "## Review Before Push\n\n"
        + f"- Native/cross-model: {native_stage}; {cross_stage}.\n"
        + "- T0 self-review + gates; T1+ one cross-model round; T2/T3 add recorded Stage 1 native sweep.\n"
        + codex_review_line
        + review_evidence_line
        + f"- Plan-review wrapper: `{review['codexPlanWrapper']}` plan-only; `run-plan-review` launches one run, `finalize-plan-review` classifies it.\n"
        + "- Repeat the Session Start PR-liveness check before review/push/subagent dispatch.\n"
        + "- Inspect logs on parser failure; fix Critical/C1/P1/Important before merge. P2/P3/Nit may be fixed or backlog-routed with evidence.\n"
        + "- Review caps are escalation triggers, not permission to ship blockers; final/R3/rerun starts do not complete the gate.\n\n"
        + "## Merge Queue\n\n"
        + f"- Routine merge command: `{commands['mergeQueue']}`.\n"
        + "- Do not use routine `--admin` merge. `--admin` is break-glass only and requires a documented reason.\n"
        + "- After auto-merge/queue, record PR, send queued-delivery summary, run `milestone-advance --target . --event pr-queued --pr <PR>`, start next action.\n"
        + "- Queued/auto-merge/merged/closed PR branches are inactive; sync main and continue source-of-truth work.\n"
        + "- Do not idle on Actions/queue after clean local gates/review; check PR `state`, not estimated merge time.\n"
        + "- Queue rejection, closed-without-merge, main red, deploy failure, blocked PR checks, merge conflict, or unclear mergeability is P0.\n\n"
        + "## Background Commands\n\n"
        + "- Long/background commands require monitor with command, log, PID, heartbeat, terminal summary.\n"
        + "- Routine clean queued PRs are not background work; queue, summarize, and continue.\n"
        + "- Work that may outlast the turn or foreground >10m review/test/gates need <=10m checkpoints; shell sleep loops are not monitors.\n"
        + "- Poll concrete artifacts when no parallel-safe task exists. Provider/API failures use <=5m retries, capped <=15m.\n"
        + "- Verify PID/log freshness with `monitor-status`; no growth for 2x cadence means recover/rerun. Follow `response-guard` recovery actions.\n"
        + "- Platform/CI/wakeup/reminder/monitor/shell notifications do not replace active supervision. Detail: `background-task-monitoring`, `merge-queue-monitoring` skills.\n\n"
        + (f"## {project}-Specific Invariants\n\n{known_rules}\n" if known_rules else "")
    )


def render_project_config(
    data: dict,
    project_arg: str,
    source_path_override: Path | None = None,
    target_root: Path | None = None,
) -> str:
    payload = {key: value for key, value in data.items() if key != "_generated"}
    source_path = source_path_override or Path(project_arg)
    if not source_path.is_absolute():
        # Canonical-first: from a snapshot exec root a private adapter is absent, the hash degrades
        # to "unavailable", and the SAME commit renders different bytes on a snapshot lane than on
        # a canonical-checkout lane -- and _generated.sourceAdapterSha256 is byte-compared by
        # adapter_drift, which is a gate. That is the same permanent drift ping-pong the fixed-width
        # short commit closes, re-entering through the adapter hash.
        source_path = source_adapter_path(str(source_path))
    source_sha256 = file_sha256(source_path) if source_path.is_file() else "unavailable"
    methodology_commit = running_methodology_commit(short=True)
    adapter_target = target_root or canonical_methodology_repo()
    if adapter_is_methodology_repo(data) and is_repo_local_source_adapter(source_path, adapter_target):
        methodology_commit = "self-referential-methodology-repo"
    # Mode-independent by design (PP-R3-P2-2): `bin/tautline` does not exist in a pipx/pip
    # user's project, while `tautline` resolves on EVERY supported install (the wheel's console
    # script; the install-cli shim on checkout/snapshot machines). The spelling must NOT be
    # mode-conditional — this string is rendered CONTENT, byte-compared by adapter_drift, and a
    # per-mode value would reintroduce the exact mixed-mode ping-pong provenance-stamp
    # equivalence (adapter_stamp_equivalent_content) exists to kill.
    regenerate = f"tautline render-adapters --project {project_arg} --target <project> --write"
    if adapter_is_methodology_repo(data) and is_repo_local_source_adapter(source_path, adapter_target):
        regenerate += " --json-only"
    rendered = {
        **payload,
        "_generated": {
            "doNotEdit": True,
            "source": "Minervit AI Delivery Methodology",
            "sourceAdapter": project_arg,
            "sourceAdapterSha256": source_sha256,
            "methodologyCommit": methodology_commit,
            "pluginVersion": plugin_version(),
            "regenerate": regenerate,
        },
    }
    return json.dumps(rendered, indent=2, sort_keys=True) + "\n"


# Provenance-stamp equivalence (the mixed-mode identity fix). The adapter gate's job is "does the
# adapter on disk match what this methodology would RENDER?" — a CONTENT question. The two
# `_generated` identity stamps below say WHO rendered it, and byte-comparing them inside a gate
# surface makes two runtimes at different identities but identical content (a pip-installed wheel
# and a canonical checkout co-maintaining one project) put each other into permanent drift, each
# re-render flipping the stamps back — the same disease the fixed-width short commit in
# running_methodology_commit and the self-referential-methodology-repo marker each patched one
# symptom of. Nothing reads `_generated.methodologyCommit` as an input (grep-verified), so
# exempting the stamp VALUES from the comparison loses no enforcement; the stamp comes to mean
# "the identity that last CHANGED the rendered content" — strictly more useful provenance.
# Declared as a tuple (not an UPPER_CASE list of str) so the policy-phrases SSOT collector
# ignores it.
ADAPTER_PROVENANCE_STAMP_KEYS: tuple[str, ...] = ("methodologyCommit", "pluginVersion")
ADAPTER_TRUE_DRIFT_ALIGNMENT_HINT = (
    "adapter was rendered by a different methodology build; align versions "
    "(pip install -U tautline / update-repin) before re-rendering"
)


def adapter_provenance_stamps(text: str) -> dict | None:
    """The identity stamps of a rendered lane adapter document, or None when the document does
    not parse as a stamped adapter (fail closed: no equivalence, no build-skew hint)."""
    try:
        data = json.loads(text)
    except json.JSONDecodeError:
        return None
    if not isinstance(data, dict):
        return None
    generated = data.get("_generated")
    if not isinstance(generated, dict):
        return None
    return {key: generated.get(key) for key in ADAPTER_PROVENANCE_STAMP_KEYS}


def adapter_stamp_equivalent_content(fresh: str, on_disk: str) -> str:
    """The freshly rendered LANE_ADAPTER_FILE content, normalized to the on-disk document's
    provenance stamps for comparison purposes.

    Callers byte-compare the on-disk content against this: a stamp-only difference compares
    equal (no drift, no rewrite), while every content difference still gates. Fail-closed
    edges: an unparseable or `_generated`-less on-disk document gets NO normalization (the
    fresh bytes return unchanged, so the comparison fails and drift stands), and hand
    reformatting still trips because normalization re-serializes the FRESH render through
    render_project_config's own serializer — it never launders on-disk formatting. Only the
    stamp KEYS present in the on-disk document are substituted, so a document missing a stamp
    key stays drifted rather than being treated as equivalent.
    """
    try:
        on_disk_data = json.loads(on_disk)
    except json.JSONDecodeError:
        return fresh
    if not isinstance(on_disk_data, dict):
        return fresh
    on_disk_generated = on_disk_data.get("_generated")
    if not isinstance(on_disk_generated, dict):
        return fresh
    fresh_data = json.loads(fresh)
    fresh_generated = fresh_data.get("_generated")
    if not isinstance(fresh_generated, dict):
        return fresh
    for key in ADAPTER_PROVENANCE_STAMP_KEYS:
        if key in on_disk_generated:
            fresh_generated[key] = on_disk_generated[key]
    return json.dumps(fresh_data, indent=2, sort_keys=True) + "\n"


def scaffold_test_harness_files(project_slug: str) -> dict[str, str]:
    """Day-one self-proving test harness (rec #10): a NEW repo's first commit ships a runner, a real CI
    workflow (no continue-on-error) that runs tests on push/PR, a passing smoke, and a written procedure
    to prove the gate can actually go RED on a CI run. project_scaffold sets ciTestGate.enforcement=block
    so this harness is required from commit one (exactly the private-product greenfield window)."""
    ci = (
        "# Generated by tautline (rec #10 day-one harness). The coverage/test step must NOT\n"
        "# use continue-on-error -- a failing test has to fail the job (no FM1/FM3 anti-pattern).\n"
        f"name: ci\n"
        "on:\n"
        "  push:\n"
        "    branches: [main]\n"
        "  pull_request:\n"
        "jobs:\n"
        "  test:\n"
        "    runs-on: ubuntu-latest\n"
        "    steps:\n"
        "      - uses: actions/checkout@v4\n"
        "      - name: run tests\n"
        "        run: bash scripts/test.sh\n"
    )
    runner = (
        "#!/usr/bin/env bash\n"
        "# Generated by tautline (rec #10). Replace the smoke with the real suite, but keep\n"
        "# set -euo pipefail so a failing test exits non-zero and fails CI.\n"
        "set -euo pipefail\n"
        "\n"
        "echo '==> day-one smoke (replace with the real test command, e.g. pytest / npm test)'\n"
        "bash tests/smoke_test.sh\n"
        "\n"
        "# BOOTSTRAP REQUIRED: add the project test command below, e.g.:\n"
        "#   pytest -q --cov --cov-config=.coveragerc\n"
        "#   npm test\n"
    )
    smoke = (
        "#!/usr/bin/env bash\n"
        "# A trivially-true smoke so CI is green from commit one. Prove the gate BITES via\n"
        "# docs/quality/gate-self-proof.md before trusting it.\n"
        "set -euo pipefail\n"
        "test \"1\" = \"1\"\n"
        "echo 'smoke ok'\n"
    )
    self_proof = (
        "# Gate self-proof (rec #10): prove the test gate can go RED\n\n"
        f"Project: {project_slug}\n\n"
        "A green pipeline only proves the gate works if you have seen it go red on a real CI run.\n"
        "Do this once at setup, and any time the CI changes:\n\n"
        "1. On a throwaway branch, add a deliberately failing test (e.g. `test \"1\" = \"2\"` in\n"
        "   `tests/smoke_test.sh`).\n"
        "2. Push and open a PR. Poll the run on that exact SHA:\n"
        "   `gh run list --branch <branch> --json headSha,conclusion,status`.\n"
        "3. Confirm the required check **concluded `failure`** on that SHA -- not `cancelled`, not\n"
        "   skipped. That is the proof the gate bites.\n"
        "4. Revert the failing test. The gate is now trusted.\n\n"
        "Until step 3 has been observed, treat the gate as unproven (P1 provability theater).\n"
    )
    return {
        ".github/workflows/ci.yml": ci,
        "scripts/test.sh": runner,
        "tests/smoke_test.sh": smoke,
        "docs/quality/gate-self-proof.md": self_proof,
    }


def scaffold_test_harness(args: argparse.Namespace) -> int:
    target = args.target.resolve() if args.target else Path(".").resolve()
    slug = args.project_slug or slugify(target.name)
    files = scaffold_test_harness_files(slug)
    if not args.write:
        for rel, content in files.items():
            print(f"===== {rel} =====")
            print(content)
        return 0
    for rel, content in files.items():
        out = target / rel
        if out.exists() and not args.overwrite:
            print(f"skip (exists): {out}")
            continue
        out.parent.mkdir(parents=True, exist_ok=True)
        out.write_text(content, encoding="utf-8")
        if rel.endswith(".sh"):
            out.chmod(0o755)
        print(f"wrote {out}")
    return 0


def init_project_adapter(args: argparse.Namespace) -> int:
    target = args.target.resolve()
    project_name = args.project_name or title_from_slug(target.name)
    output = args.output or repo_local_source_adapter_path(target)
    output = output.resolve()
    if output.exists() and not args.overwrite:
        raise SystemExit(f"Adapter already exists: {output}. Pass --overwrite to replace it.")
    repo_slug = args.repo or infer_repo_slug(target)
    if is_repo_local_source_adapter(output, target):
        schema_path = PUBLIC_ADAPTER_SCHEMA_URL
    else:
        # The canonical checkout, NOT the exec root. Under snapshot execution REPO_ROOT is
        # `<store>/<sha12>` -- an immutable export that prune COLLECTS -- so a path anchored there
        # dangles as soon as the store rotates, in an adapter file that outlives many releases. The
        # canonical checkout is the one methodology tree that persists, and on a dev checkout (and
        # every pre-cutover machine) it IS REPO_ROOT, so this is a no-op there.
        schema_root = canonical_methodology_repo() / "methodology/adapter-schema.json"
        try:
            schema_path = os.path.relpath(schema_root, output.parent)
        except ValueError:
            schema_path = str(schema_root)
    adapter = project_scaffold(project_name, repo_slug, schema_path)
    output.parent.mkdir(parents=True, exist_ok=True)
    output.write_text(json.dumps(adapter, indent=2) + "\n", encoding="utf-8")
    print(f"wrote {output}")
    print(f"questions_before_render: tautline adapter-bootstrap-questions --target {target} --project-name {shlex.quote(project_name)} --repo {shlex.quote(repo_slug)} --write")
    print("next: inspect the target repo, run the mandatory adapter bootstrap interview if not already done, record bootstrapEvidence, and replace every BOOTSTRAP REQUIRED placeholder before rendering")
    print("warning: do not copy another project's adapter values as a substitute for interview-derived facts")
    print(f"render: tautline render-adapters --project {output} --target {target} --write")
    print(f"start: tautline lane-start --target {target}")
    return 0


def adapter_bootstrap_questions(args: argparse.Namespace) -> int:
    target = args.target.resolve()
    project_name = args.project_name or title_from_slug(target.name)
    repo_slug = args.repo or infer_repo_slug(target)
    questionnaire = adapter_bootstrap_questionnaire(project_name, repo_slug, include_answer_slots=args.write)
    if args.write:
        dest = target / DEFAULT_BOOTSTRAP_INTERVIEW_PATH
        dest.parent.mkdir(parents=True, exist_ok=True)
        dest.write_text(questionnaire, encoding="utf-8")
        print(f"wrote {dest}")
        print("next: answer every BOOTSTRAP REQUIRED item, then record this hash in bootstrapEvidence.interviewArtifact.sha256:")
        print(f"sha256: {file_sha256(dest)}")
        return 0
    print(questionnaire, end="")
    return 0


def init_fail_closed_command(field: str) -> str:
    return f"echo 'minervit init: configure commands.{field} in .minervit/adapter.json before using this gate' >&2; exit 1"


def adapter_from_answered_bootstrap_interview(target: Path, interview_path: Path) -> dict:
    text = interview_path.read_text(encoding="utf-8", errors="replace")
    project_name = extract_bootstrap_header(text, "Project") or title_from_slug(target.name)
    repo_slug = extract_bootstrap_header(text, "Repository") or infer_repo_slug(target)
    adapter = project_scaffold(project_name, repo_slug, PUBLIC_ADAPTER_SCHEMA_URL)
    adapter["productionDeployExists"] = False
    adapter["bootstrapEvidence"] = {
        "project": project_name,
        "status": "interviewed",
        "summary": (
            "Initial adapter generated from the answered bootstrap interview. "
            "Project-specific gates, review wrappers, backlog routing, and autonomy rules remain fail-closed "
            "until the project owner replaces the init placeholders with verified project policy."
        ),
        "interviewArtifact": {
            "path": DEFAULT_BOOTSTRAP_INTERVIEW_PATH,
            "sha256": file_sha256(interview_path),
        },
    }
    adapter["backlogAdapter"] = "Initial interview completed; configure project-specific backlog routing before deferring lower-severity findings."
    adapter["planningArtifacts"]["rule"] = (
        "Initial interview completed; keep non-trivial plans under the configured source-of-truth path "
        "and replace this rule with the project-specific planning contract before feature development."
    )
    for field in REQUIRED_COMMANDS:
        adapter["commands"][field] = init_fail_closed_command(field)
    adapter["review"]["codexWrapper"] = "minervit init: configure the project Codex implementation-review wrapper before push."
    adapter["review"]["codexPlanWrapper"] = "minervit init: configure the project Codex plan-review wrapper before plan finalization."
    adapter["review"]["claudeReview"] = "minervit init: configure the project Claude/native review workflow before push."
    adapter["bugBacklog"]["sourceOfTruth"] = "Initial interview completed; configure the project bug source of truth before routing bugs."
    adapter["bugBacklog"]["severityTaxonomy"] = "Initial interview completed; configure the project severity taxonomy before triage."
    adapter["bugBacklog"]["rule"] = "Initial interview completed; configure when to write bug specs and when external issues are required."
    adapter["latestCode"] = {**DEFAULT_LATEST_CODE, "enabled": False}
    adapter["knownProjectRules"] = [
        "Initial interview completed; replace this init placeholder with verified project-specific invariants before product work."
    ]
    return adapter


def print_init_interview_questions(interview_path: Path) -> None:
    print("## Questions for the human operator")
    print("")
    counter = 1
    for _title, questions in ADAPTER_BOOTSTRAP_QUESTIONS:
        for question in questions:
            print(f"{counter}. {question}")
            print(f"   artifact: {interview_path} Answer slot {counter}")
            counter += 1


def init_methodology_project(args: argparse.Namespace) -> int:
    target = args.target.expanduser().resolve(strict=False)
    generated = adapter_marker_path(target)
    source = repo_local_source_adapter_path(target)
    if generated.exists():
        print("already managed; run: tautline lane-start --target .")
        return 0
    project_name = args.project_name or title_from_slug(target.name)
    repo_slug = args.repo or infer_repo_slug(target)
    interview_path = target / DEFAULT_BOOTSTRAP_INTERVIEW_PATH
    if not args.continue_init:
        if interview_path.exists():
            print(f"interview_existing: {interview_path}")
        else:
            questionnaire = adapter_bootstrap_questionnaire(project_name, repo_slug, include_answer_slots=True)
            interview_path.parent.mkdir(parents=True, exist_ok=True)
            interview_path.write_text(questionnaire, encoding="utf-8")
            print(f"interview_written: {interview_path}")
        print_init_interview_questions(Path(DEFAULT_BOOTSTRAP_INTERVIEW_PATH))
        print("after answering, run: tautline init --target . --continue")
        return 0
    if not interview_path.is_file():
        print(
            f"init_error: bootstrap interview artifact missing: {interview_path}; "
            "run `tautline init --target .` first",
            file=sys.stderr,
        )
        return 1
    if source.exists():
        print(
            f"init_error: source adapter already exists: {source}; "
            "run render-adapters and lane-start for this adapter, or move it before continuing init",
            file=sys.stderr,
        )
        return 1
    for generated_instruction in ("CLAUDE.md", "AGENTS.md"):
        generated_path = target / generated_instruction
        if generated_path.exists():
            print(
                f"init_error: generated instruction file already exists: {generated_path}; "
                "move it or adopt the repo through the lower-level bootstrap flow before continuing init",
                file=sys.stderr,
            )
            return 1
    adapter = adapter_from_answered_bootstrap_interview(target, interview_path)
    validate_bootstrap_evidence_for_target(adapter, source, target)
    source.parent.mkdir(parents=True, exist_ok=True)
    source.write_text(json.dumps(adapter, indent=2) + "\n", encoding="utf-8")
    print(f"adapter_written: {source}")
    render_args = argparse.Namespace(project=source, target=target, write=True, check=False, json_only=False)
    render_status = render_adapters(render_args)
    if render_status != 0:
        return render_status
    lane_args = argparse.Namespace(
        project=None,
        target=target,
        skip_update=True,
        allow_non_main=False,
        profile=None,
    )
    lane_status = lane_start(lane_args)
    if lane_status != 0:
        return lane_status
    print("onboarded: yes")
    print("generated: CLAUDE.md")
    print("generated: AGENTS.md")
    print(f"generated: {LANE_ADAPTER_FILE}")
    print(f"source_adapter: {REPO_LOCAL_ADAPTER_FILE}")
    print(f"next: replace init placeholders in {REPO_LOCAL_ADAPTER_FILE} before product development")
    return 0


def expected_files(
    data: dict,
    project_arg: str,
    source_path_override: Path | None = None,
    target_root: Path | None = None,
) -> dict[str, str]:
    files = {
        target["outputFile"]: render_adapter(data, target["agent"], project_arg)
        for target in RUNTIME_TARGETS
    }
    files[LANE_ADAPTER_FILE] = render_project_config(data, project_arg, source_path_override, target_root)
    return files


def selected_expected_files(
    data: dict,
    project_arg: str,
    json_only: bool = False,
    source_path_override: Path | None = None,
    target_root: Path | None = None,
) -> dict[str, str]:
    files = expected_files(data, project_arg, source_path_override, target_root)
    if json_only:
        return {LANE_ADAPTER_FILE: files[LANE_ADAPTER_FILE]}
    return files


def expected_files_render_context(data: dict, project_path: Path, target: Path) -> tuple[str, Path | None]:
    if is_repo_local_source_adapter(project_path, target):
        return project_path.resolve().relative_to(target.resolve()).as_posix(), project_path
    project_arg = project_source_arg(data, project_path)
    if project_arg in (REPO_LOCAL_ADAPTER_FILE, LEGACY_REPO_LOCAL_ADAPTER_FILE):
        source = repo_local_source_adapter_path(target)
        if source.exists():
            return source.relative_to(target).as_posix(), source
    return project_arg, None


def is_methodology_generated_markdown(path: Path) -> bool:
    try:
        with path.open("r", encoding="utf-8") as handle:
            return handle.read(len(GENERATED_HEADER_TEMPLATE)) == GENERATED_HEADER_TEMPLATE
    except OSError:
        return False


def protected_handwritten_markdown(expected: dict[str, str], target: Path) -> list[Path]:
    protected: list[Path] = []
    for rel in expected:
        if rel not in GENERATED_MARKDOWN_FILES:
            continue
        dest = target / rel
        if dest.exists() and not is_methodology_generated_markdown(dest):
            protected.append(dest)
    return protected


def protected_markdown_message(paths: list[Path]) -> str:
    protected = ", ".join(str(path) for path in paths)
    return (
        "refusing to overwrite non-generated adapter markdown: "
        f"{protected}. Use `render-adapters --write --json-only` for lane JSON/config-only updates. "
        "For a full migration, first move hand-written instructions into the canonical project adapter "
        "or other source-of-truth docs, then remove/rename the hand-written Markdown and render generated files."
    )


DEFAULT_RENDER_BUDGET = {"maxGeneratedBytes": 31000, "minGeneratedBytes": 15000, "enforcement": "warn"}


def render_budget_for(data: dict) -> dict:
    return adapter_module().render_budget_for(data)


def render_budget_errors(data: dict, expected: dict) -> list[str]:
    """cross-counterproductive-2: enforce the adapter-tunable generated-file size budget at render
    time (replacing the hardcoded 15KB/31KB validate.sh ceiling). Only the generated runtime markdown
    (CLAUDE.md/AGENTS.md) is bounded; the JSON adapter is not size-limited.
    """
    return adapter_module().render_budget_errors(data, expected, generated_markdown_files=GENERATED_MARKDOWN_FILES)


def render_adapters(args: argparse.Namespace) -> int:
    target = args.target.resolve()
    require_source_adapter_for_target(args.project.resolve(), target, "render-adapters")
    raw_data = load_raw_adapter_json(args.project)
    data = load_project(args.project)
    if "goalTracker" in raw_data:
        # arch-terminology-1: goalTracker is deprecated in favor of backlogProvider. Warn at the
        # authoring/render boundary (not on every load) so the migration is visible without noise.
        print(
            "render_adapters_warning: adapter key 'goalTracker' is DEPRECATED; migrate to "
            "'backlogProvider'",
            file=sys.stderr,
        )
    validate_render_adapter_provenance(data, args.project)
    validate_bootstrap_evidence_for_target(data, args.project, target)
    validate_adapter_repo_matches_target(data, target)
    if args.write or args.check:
        # RCA O17: a re-render reproduces the adapter from canonical source; if a canonical
        # adapter fix is stranded on a local rescue ref (not on the base branch), surface it so
        # the render does not silently reproduce the pre-rescue adapter.
        print_methodology_rescue_ref_warnings()
    if is_repo_local_source_adapter(args.project.resolve(), target):
        project_arg = args.project.resolve().relative_to(target.resolve()).as_posix()
        source_path_override = args.project.resolve()
    else:
        project_arg = project_source_arg(data, args.project)
        source_path_override = None
    expected = selected_expected_files(data, project_arg, args.json_only, source_path_override, target)
    budget_errors = render_budget_errors(data, expected)
    if budget_errors:
        enforcement = render_budget_for(data)["enforcement"]
        for err in budget_errors:
            print(f"render_budget_{'error' if enforcement == 'block' else 'warning'}: {err}", file=sys.stderr)
        if enforcement == "block":
            raise SystemExit("render-adapters: generated files violate renderBudget (enforcement=block)")
    if args.write:
        protected = protected_handwritten_markdown(expected, target)
        if protected:
            raise SystemExit(protected_markdown_message(protected))
        for rel, content in expected.items():
            dest = target / rel
            if rel == LANE_ADAPTER_FILE:
                try:
                    current = dest.read_text(encoding="utf-8") if dest.exists() else None
                except OSError:
                    current = None
                if (
                    current is not None
                    and current == adapter_stamp_equivalent_content(content, current)
                ):
                    # Stamp-only difference: the content is already this render — rewriting
                    # would only flip the identity stamps (the mixed-mode ping-pong).
                    print(f"unchanged {dest} (provenance-stamp-only difference preserved)")
                    continue
            write_text_atomic(dest, content)  # arch-errors-5: crash-safe generated-file write
            print(f"wrote {dest}")
        return 0
    if args.check:
        failed = False
        protected = protected_handwritten_markdown(expected, target)
        for dest in protected:
            print(f"protected: {dest} (non-generated; use --json-only or migrate before full render)")
            failed = True
        for rel, content in expected.items():
            dest = target / rel
            if dest in protected:
                continue
            current = dest.read_text(encoding="utf-8") if dest.exists() else None
            if rel == LANE_ADAPTER_FILE and current is not None:
                content = adapter_stamp_equivalent_content(content, current)
            if current != content:
                print(f"drift: {dest}")
                failed = True
        return 1 if failed else 0
    for rel, content in expected.items():
        print(f"===== {rel} =====")
        print(content)
    return 0


def adapter_json_text(data: dict) -> str:
    return json.dumps(data, indent=2, sort_keys=True) + "\n"


def load_adapter_json(path: Path) -> dict:
    try:
        payload = json.loads(path.read_text(encoding="utf-8"))
    except FileNotFoundError as exc:
        raise SystemExit(f"adapter not found: {path}") from exc
    except json.JSONDecodeError as exc:
        raise SystemExit(f"adapter is not valid JSON: {path}: {exc}") from exc
    if not isinstance(payload, dict):
        raise SystemExit(f"adapter must be a JSON object: {path}")
    return payload


def load_raw_adapter_json(path: Path) -> dict:
    try:
        payload = json.loads(path.read_text(encoding="utf-8"))
    except FileNotFoundError as exc:
        raise SystemExit(f"adapter not found: {path}") from exc
    except json.JSONDecodeError as exc:
        raise SystemExit(project_json_decode_error(path, exc)) from exc
    if not isinstance(payload, dict):
        raise SystemExit(f"adapter must be a JSON object: {path}")
    return payload


def migrate_adapter_payload(data: dict) -> tuple[dict, list[str]]:
    migrated = json.loads(json.dumps(data))
    steps: list[str] = []
    if "schemaVersion" not in migrated:
        migrated["schemaVersion"] = "1.0.0"
        steps.append("add schemaVersion=1.0.0")
    if "_framework" not in migrated:
        migrated["_framework"] = dict(DEFAULT_FRAMEWORK_PIN)
        steps.append("add _framework stable/manual/dry-run pin defaults")
    else:
        migrated["_framework"] = normalize_framework_pin(migrated["_framework"], "adapter _framework")
    if "goalTracker" in migrated:
        tracker = normalize_tracker_adapter_config(migrated.get("goalTracker"), DEFAULT_GOAL_TRACKER, "goalTracker")
        if "backlogProvider" not in migrated:
            migrated["backlogProvider"] = backlog_provider_from_goal_tracker(tracker)
            steps.append("create backlogProvider from deprecated goalTracker")
        del migrated["goalTracker"]
        steps.append("remove deprecated goalTracker compatibility key")
    return migrated, steps


def validate_migrated_adapter_payload(data: dict, label: Path) -> None:
    validate_adapter_schema(data, label)
    with tempfile.TemporaryDirectory(prefix="minervit-adapter-migration-") as tmpdir:
        temp_path = Path(tmpdir) / "adapter.json"
        temp_path.write_text(adapter_json_text(data), encoding="utf-8")
        load_project(temp_path)


def migration_unified_diff(path: Path, before: str, after: str) -> str:
    return "".join(
        difflib.unified_diff(
            before.splitlines(keepends=True),
            after.splitlines(keepends=True),
            fromfile=f"{path}:before",
            tofile=f"{path}:after",
        )
    )


def migrate_adapter(args: argparse.Namespace) -> int:
    source = args.adapter.resolve()
    adopter_target = args.adopter_target.resolve() if args.adopter_target else None
    destination = repo_local_source_adapter_path(adopter_target) if adopter_target else source
    if args.dry_run and args.write:
        print("migrate_adapter_error: --dry-run and --write are mutually exclusive", file=sys.stderr)
        return 2
    original_text = source.read_text(encoding="utf-8")
    payload = load_adapter_json(source)
    migrated, steps = migrate_adapter_payload(payload)
    validate_migrated_adapter_payload(migrated, destination)
    migrated_text = adapter_json_text(migrated)
    before_for_diff = destination.read_text(encoding="utf-8") if adopter_target and destination.exists() else original_text
    diff = migration_unified_diff(destination, before_for_diff, migrated_text)
    mode = "write" if args.write else "dry-run"
    print(f"migrate_adapter_mode: {mode}")
    print(f"migrate_adapter_source: {source}")
    print(f"migrate_adapter_destination: {destination}")
    if steps:
        print("migrate_adapter_steps:")
        for step in steps:
            print(f"  - {step}")
    else:
        print("migrate_adapter_steps: none")
    if diff:
        print("migrate_adapter_format: deterministic JSON normalization may reorder keys; review the diff before --write")
        print("migrate_adapter_diff:")
        print(diff, end="" if diff.endswith("\n") else "\n")
    else:
        print("migrate_adapter_diff: clean")
    if args.write:
        write_text_atomic(destination, migrated_text)
        print(f"migrate_adapter_written: {destination}")
        validate_migrated_adapter_payload(load_adapter_json(destination), destination)
        print("migrate_adapter_validation: ok")
        if adopter_target and source != destination:
            print(
                "migrate_adapter_next: update render/lane commands to use "
                f"{REPO_LOCAL_ADAPTER_FILE}; delete the old source adapter only after product lanes are pinned and re-rendered"
            )
    else:
        print("migrate_adapter_written: no")
    return 0


def release_migration_report_data(
    version: str | None = None,
    wip_safe: bool = False,
    products_tested: list[str] | None = None,
) -> dict:
    version = version or methodology_version()
    parsed_version = version_tuple(version)
    manifest = public_contract_manifest_data()
    deprecated_surfaces = []
    deprecated_sections = ["commands", "adapterKeys"]
    if parsed_version >= version_tuple("0.6.244"):
        deprecated_sections.append("skills")
    for section in deprecated_sections:
        for item in manifest.get(section, []):
            if item.get("status") == "deprecated":
                deprecated_surfaces.append({"section": section, **item})
    if parsed_version < version_tuple("0.9.0"):
        # Narrative journal publishers were deprecated in 0.9.0 (security exception: narrative
        # publication disabled). Reports for earlier releases must stay byte-identical, so this
        # deprecation must not surface retroactively -- same version-scoping idiom as the skills
        # section (0.6.244) and the historical RCA-alias surface below.
        deprecated_surfaces = [
            item
            for item in deprecated_surfaces
            if item.get("name") not in {"publish-session-journal", "publish-pending-session-journals"}
        ]
    if parsed_version <= version_tuple("0.7.1"):
        # Reports released before the 0.8.0 plugin rename snapshot the pre-rename
        # plugin directories; keep historical --check regeneration byte-identical.
        for item in deprecated_surfaces:
            path = str(item.get("path", ""))
            if path.startswith("plugins/tautline-core/"):
                item["path"] = "plugins/minervit-ai-delivery-methodology/" + path[len("plugins/tautline-core/"):]
            elif path.startswith("plugins/tautline-ops/"):
                item["path"] = "plugins/minervit-delivery-ops/" + path[len("plugins/tautline-ops/"):]
    if version_tuple("0.6.244") <= parsed_version <= version_tuple("0.6.263"):
        # The methodology-regression-rca alias skill was removed in 0.6.264;
        # committed reports for 0.6.244-0.6.263 snapshot it as deprecated, so
        # historical --check runs must keep deriving the same surface, in the
        # position the manifest's alphabetical skills order gave it.
        historical_rca_surface = {
            "section": "skills",
            "name": "methodology-regression-rca",
            "path": "plugins/minervit-ai-delivery-methodology/skills/methodology-regression-rca/SKILL.md",
            "removeAfter": "1.0.0",
            "replacement": "framework-intake",
            "status": "deprecated",
        }
        insert_at = next(
            (
                index
                for index, item in enumerate(deprecated_surfaces)
                if item["section"] == "skills" and item["name"] > historical_rca_surface["name"]
            ),
            len(deprecated_surfaces),
        )
        deprecated_surfaces.insert(insert_at, historical_rca_surface)
    if parsed_version > version_tuple("0.10.0"):
        raise SystemExit(
            f"release migration report data is not declared for {version}; "
            "add a release-specific branch before cutting the release"
        )
    if version_tuple("0.9.17") < parsed_version < version_tuple("0.10.0"):
        # The 0.10.0 minor bump skips the 0.9.18+ patch range; same pattern as every
        # prior minor bump below -- an undeclared version must refuse, never fall through.
        raise SystemExit(
            f"release migration report data is not declared for {version}; "
            "add a release-specific branch before cutting the release"
        )
    if version_tuple("0.8.9") < parsed_version < version_tuple("0.9.0"):
        raise SystemExit(
            f"release migration report data is not declared for {version}; "
            "add a release-specific branch before cutting the release"
        )
    if version_tuple("0.7.1") < parsed_version < version_tuple("0.8.0"):
        raise SystemExit(
            f"release migration report data is not declared for {version}; "
            "add a release-specific branch before cutting the release"
        )
    if version_tuple("0.6.268") < parsed_version < version_tuple("0.7.0"):
        raise SystemExit(
            f"release migration report data is not declared for {version}; "
            "add a release-specific branch before cutting the release"
        )
    if parsed_version in {version_tuple("0.6.128"), version_tuple("0.6.129")}:
        raise SystemExit(
            f"release migration report data is not declared for {version}; "
            "add a release-specific branch before cutting the release"
        )
    if parsed_version == version_tuple("0.10.0"):
        # NOT WIP-safe: a minor release, and the drift gate's comparison semantics change under
        # every lane that renders adapters (stamp-only equivalence + the mode-independent
        # regenerate line re-renders each adapter once). WIP lanes hold by policy and adopt at
        # a boundary.
        wip_safe = False
        required_migrations = [
            {
                "id": "reinstall-cli-for-tautline-shim",
                "surface": "cli-install",
                "description": (
                    "Plugin hooks now invoke `tautline`, the command name every supported "
                    "install resolves: the PyPI wheel ships it as a console script and "
                    "install-cli writes both shims. Only a machine that installed before the "
                    "`tautline` shim existed and never re-ran install-cli lacks it -- on such "
                    "machines every Claude guard hook would fail open. Required exactly where "
                    "`command -v tautline` prints nothing; a machine that resolves `tautline` "
                    "needs no action."
                ),
                "command": "bin/tautline install-cli",
            },
        ]
        optional_migrations = []
        behavior_changes = [
            "The PyPI package is real: `pipx install tautline` (or `pip install tautline` in a "
            "virtualenv) installs the full CLI as a thin wrapper embedding the committed release "
            "tree under `tautline/_dist/`, stamped with a build-time `.snapshot-meta.json` "
            "carrying `installKind: \"package\"`, and shipping BOTH console scripts (`tautline` "
            "and the legacy `minervit-methodology`). `registry-package --registry pypi` builds "
            "that payload from the committed tree (fail-closed on version/tree disagreement and "
            "on non-git exec roots); the npm package remains a namespace pointer whose copy now "
            "points at pipx. `publish-pypi.yml` ships the real wheel with an explicit "
            "`--channel stable` stamp, and a PR-blocking `fresh-install-smoke` CI job installs "
            "the wheel into a clean Python 3.10 venv and drives the full front door on every "
            "pull request.",
            "The adapter drift gate (`adapter_drift`, `render-adapters --check`) and every "
            "generated-file writer treat provenance-stamp-only differences "
            "(`_generated.methodologyCommit`, `_generated.pluginVersion`) as equivalent "
            "content: a stamp-only skew is not drift and never triggers a rewrite, so two "
            "runtimes at identical rendered content (a pip wheel and a canonical checkout "
            "co-maintaining one project) stop re-stamping each other's adapters. Every content "
            "difference still gates -- unparseable or `_generated`-less on-disk adapters stay "
            "drifted, hand reformatting still trips -- and true cross-build drift now carries a "
            "version-alignment hint (align runtimes, do not re-render harder).",
            "Rendered adapters carry the mode-independent regenerate line "
            "(`tautline render-adapters ...` instead of `bin/tautline ...`), because "
            "`bin/tautline` does not exist in a pipx user's project. Existing adapters "
            "re-render once on their next render; with stamp equivalence that is the only "
            "rewrite mixed fleets see.",
            "Package-mode UX and isolation, keyed on the manifest's `installKind: \"package\"` "
            "and never on generic snapshot detection: `version` reports "
            "`install_kind: package` plus an `update_hint` naming both package channels "
            "(`pipx upgrade tautline` / `pip install -U tautline`); `sync-methodology` stands "
            "down with the same hint BEFORE canonical-repo resolution and never fetches, "
            "ff-merges, or installs guards into a leftover configured checkout; `update-repin` "
            "refuses BEFORE the fetch with a truthful remedy (the connectivity diagnosis stays "
            "for real fetch failures on real checkouts); and adapter data resolution "
            "(`methodology_data_roots`, `source_adapter_path`) pins to the wheel's embedded "
            "tree. Snapshot-store and checkout machines keep canonical-first behavior "
            "byte-identically.",
            "Registry copy is per-registry (PyPI documents the real install with the Python "
            "3.10+ floor; npm points at pipx), the README Quickstart states the 3.10+ "
            "prerequisite and the mandatory `tautline install-claude-launcher --force` cutover "
            "step, and the 3.10 floor rationale in pyproject.toml/CONTRIBUTING.md is corrected "
            "to the verified reason (zip(strict=) is 3.10-only, and CI proves the floor on "
            "3.10 every PR).",
        ]
        rollback_notes = [
            "Pinning back to 0.9.x restores the pointer-building `registry-package` (the real "
            "PyPI payload path disappears; already-published wheels stay on PyPI untouched) and "
            "stamp-strict drift comparison.",
            "A project whose adapter was rendered under 0.10.0 will re-drift ONCE under a 0.9.x "
            "runtime: the 0.9.x gate byte-compares the provenance stamps again, and the "
            "mode-independent `tautline` regenerate line differs from the 0.9.x render. "
            "Re-rendering under the pinned runtime restores the 0.9.x bytes.",
            "Plugin hooks invoke `tautline` regardless of the pinned CLI version (hooks.json "
            "ships with the plugin): after rollback the hooks keep working wherever the "
            "`tautline` shim exists, which is every machine that ran install-cli since the "
            "shim was introduced.",
        ]
    elif parsed_version == version_tuple("0.9.17"):
        # WIP-safe. Stock machines see zero behavior change: every new gate branch is keyed on
        # a maintainer-mode config key that nothing ships, nothing generates into a project, and
        # no installer writes -- the standdown is opt-in via `tautline maintainer-mode on`, run
        # by a framework maintainer on the maintainer's own machine. A lane with active work
        # adopts this release mid-conversation without any step changing behavior under it.
        wip_safe = True
        required_migrations = []
        optional_migrations = [
            {
                "id": "enable-maintainer-mode",
                "surface": "methodology-update-gates",
                "description": (
                    "For framework-maintainer machines only -- machines whose canonical "
                    "methodology repo is a git checkout the operator develops in. Enabling "
                    "maintainer mode stands the launcher-gate methodology update and the "
                    "per-launch remote probe down machine-wide so the checkout is never "
                    "fetched, ff-merged, rescued, or repair-escalated underneath maintainer "
                    "work; snapshot execution, heal, and every other guard stay on. The verb "
                    "prints a content-keyed advisory naming any generated launcher that still "
                    "lacks the shell standdown guard; regenerate those with "
                    "`tautline install-claude-launcher --force` so the launcher's own "
                    "auto-rescue stands down too. Machines that are not framework maintainers "
                    "should not run this."
                ),
                "command": "tautline maintainer-mode on",
            },
        ]
        behavior_changes = [
            "New `tautline maintainer-mode` verb (contract status: experimental) with `on`, "
            "`off`, and `status` actions. `on` refuses unless the canonical methodology repo "
            "is a git checkout to manage, then writes both key spellings "
            "(`TAUTLINE_METHODOLOGY_MAINTAINER_MODE` and `MINERVIT_METHODOLOGY_MAINTAINER_MODE`) "
            "into the user config env file; `off` strips both spellings from both the current "
            "and the legacy config surface and reports the re-read state, so it is "
            "authoritative rather than assertive. The key is file-only by design: a live "
            "environment variable of either spelling is inert, in Python and in the "
            "regenerated launcher's shell guard alike.",
            "With maintainer mode armed, the launcher-gate methodology update stands down at "
            "its single choke point: the launch reports `methodology_update: skipped - "
            "maintainer mode - update gates stand down; checkout left untouched (disable with "
            "`tautline maintainer-mode off`)` and the checkout is never fetched, ff-merged, "
            "rescued, or repair-escalated. Heal and the freshness stamp still flow, so "
            "committed maintainer work republishes the snapshot store's `current` at the very "
            "next launch. Snapshot execution, release guards, lane-start gates, "
            "methodology-status drift and debt remediation, WIP holds, every Claude guard "
            "hook, and the skip-permissions interlock all stay on -- there is no carve-out.",
            "Every armed launch prints a loud stderr banner whose load-bearing line is "
            "`maintainer_mode: update gates off - running <checkout> @ <commit>`, so a session "
            "running with gates down is unmistakable and names exactly which tree and commit "
            "it is executing.",
            "New status surfaces: an armed launch reports `remote_status: skipped - "
            "maintainer mode` instead of probing the remote (no ls-remote at launch), and "
            "`methodology-status` gains a three-state `maintainer_mode:` line (off / on with "
            "checkout and commit / configured but not armed). Regenerated Claude launchers "
            "carry a shell guard that parses the config file directly and stands the "
            "launcher's own auto-rescue down while the key is set; launchers generated before "
            "this release keep their old behavior until regenerated, and the verb says so.",
        ]
        rollback_notes = [
            "Pinning back to 0.9.16 restores stock Python gate behavior outright: no 0.9.16 "
            "code reads the maintainer-mode key, so an armed machine simply loses the "
            "standdown, the banner, and the status lines, and the launcher-gate update runs "
            "stock again.",
            "BUT the rollback has a second half: a launcher regenerated at 0.9.17 keeps its "
            "shell standdown guard, which parses the config file directly and independently "
            "of the pinned CLI, so it keeps standing the launcher auto-rescue down for as "
            "long as the key remains in the config file -- repinning alone does not undo it.",
            "Full rollback is `tautline maintainer-mode off` BEFORE repinning (0.9.16 has no "
            "such verb; alternatively remove both export lines from both config surfaces "
            "after), plus, on `warn`/`unverified` update-policy machines that want the "
            "pre-0.9.17 launcher auto-rescue back, `tautline install-claude-launcher --force` "
            "from the pinned checkout.",
            "This release is WIP-safe. Every behavior change is gated on a config key that "
            "no stock machine carries and only the new verb writes, so no lane in progress "
            "changes behavior when it adopts 0.9.17 -- the standdown reaches a machine only "
            "when its operator explicitly arms it.",
        ]
    elif parsed_version == version_tuple("0.9.16"):
        # WIP-safe. This release changes printed remedy wording, policy prose, and test
        # enforcement. No command, adapter key, on-disk format, or execution substrate moves,
        # and the plan-review refusal itself is unconditional before and after, so a lane with
        # active work can adopt it mid-conversation without any step changing behavior under it.
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Past the plan-review hard cap, the printed remedy is now chosen by asking whether the "
            "bound manifest's evidence can actually be finalized, in every manifest writer. "
            "`finalize-plan-review` and `record-plan-review` previously chose it from the "
            "SUBMITTED round's blocker counts, but the refused round is never written, so its "
            "counts say "
            "nothing about what the operator can finalize: submitting clean counts past the cap "
            "printed \"finalize the existing review evidence\" even when the bound evidence was "
            "clean-but-STALE, which `plan-finalization-precheck` then rejects -- a dead end. The "
            "refusal stays unconditional at every door; only the wording changes.",
            "`run-plan-review`'s printed next-action remedy now renders the resolved absolute "
            "`--target` path instead of a literal `--target .`. On a multi-agent machine a "
            "copy-pasted `--target .` binds to whichever checkout the shell happens to be sitting "
            "in -- possibly a worktree owned by another lane. The `--target .` written into a "
            "plan's committed Cross-Model Review Evidence block is deliberately unchanged: an "
            "absolute path there would be a machine token committed to a tracked file.",
            "`methodology/canonical-rules.md` and every policy mirror now state the shipped "
            "past-cap rule -- the refusal is unconditional, the remedy is chosen from bound "
            "evidence -- rather than an unconditional split. Nothing reads these documents at "
            "runtime, which is exactly why the drift mattered: no gate caught it, and agents "
            "follow the text.",
            "The retired-domain ban in `tests/test_public_boundary_scan.py` is now derived from "
            "the CLI's own public-export rules rather than a hand-maintained file allowlist, "
            "widening "
            "the swept set from 104 files to 563. `GOVERNANCE.md`, `ROADMAP.md`, `LICENSE`, "
            "`docs/README.md` and the rest of the exported tree all ship and none was scanned "
            "before. This is test-only: no shipped surface changed, because none was carrying the "
            "retired domain.",
        ]
        rollback_notes = [
            "Rolling back to 0.9.15 restores the misleading past-cap remedy wording: "
            "`finalize-plan-review` and `record-plan-review` will again tell an operator to "
            "finalize bound evidence that `plan-finalization-precheck` will refuse as stale. The "
            "refusal itself is unconditional in both directions, so no plan is admitted past the "
            "cap either way -- the operator is only sent down a dead end rather than to the split.",
            "Plan-review manifests are unchanged in schema and content by this release; a manifest "
            "written by 0.9.16 is read by 0.9.15 and vice versa. Only printed remedy text and "
            "policy prose differ, so nothing on disk needs converting in either direction.",
            "Rolling back narrows the public-boundary domain scan back to its hand-maintained "
            "allowlist, so a retired-domain string added to a shipped-but-unlisted surface such as "
            "`GOVERNANCE.md` would once again pass a fully green test run. Nothing breaks on the "
            "way back; the enforcement simply stops covering most of what it ships.",
            "This release is WIP-safe. It changes printed remedy wording, policy prose, and test "
            "enforcement -- no command, adapter key, configuration surface, on-disk format, or "
            "execution substrate changes, so no lane in progress alters behavior.",
        ]
    elif parsed_version == version_tuple("0.9.15"):
        # NOT WIP-safe, unlike every recent patch. This release moves the tree the CLI executes
        # from: lanes run a snapshot instead of the methodology checkout, and session pinning is
        # delivered by a regenerated launcher. Letting a lane with active work auto-adopt that
        # mid-conversation would swap the execution substrate underneath it before the launcher
        # that makes pinning real exists -- precisely the half-converted state this release is
        # built to end. Operators adopt it between lanes, with the two commands below.
        wip_safe = False
        required_migrations = [
            {
                "id": "convert-to-snapshot-execution",
                "surface": "cli-install",
                "description": (
                    "0.9.15 executes the CLI from an immutable snapshot store instead of from the "
                    "methodology checkout. Re-run the installer to convert the machine: it "
                    "materializes the current commit as a read-only snapshot and points the store's "
                    "`current` target at it. Until this runs, the machine keeps executing the "
                    "checkout directly, which is the behavior a concurrent lane can corrupt."
                ),
                "command": "tautline install-cli",
            },
            {
                "id": "regenerate-claude-launcher",
                "surface": "claude-launcher",
                "description": (
                    "Session pinning is delivered by the launcher, so regenerating it is a required "
                    "step of this upgrade rather than a convenience. Until the launcher is "
                    "regenerated, a session is not pinned to a snapshot and can still change "
                    "Tautline versions mid-conversation -- the exact failure this release exists to "
                    "remove. `install-cli` and `sync-methodology` both name any launcher that still "
                    "needs regenerating."
                ),
                "command": "tautline install-claude-launcher --force",
            },
        ]
        optional_migrations = []
        behavior_changes = [
            "Lanes execute Tautline from an immutable snapshot store rather than from the "
            "methodology checkout. On a trusted update the new commit is copied into a read-only "
            "tree under the user data directory, the store's `current` target is swapped "
            "atomically, and the CLI re-execs the new tree. A `git pull` in the checkout can no "
            "longer rewrite the code a running session is executing, which is what made concurrent "
            "lanes on one machine unsafe.",
            "A session is pinned to the snapshot it started on: every hook it spawns loads the same "
            "Tautline for the life of the conversation, even while other lanes advance the store. "
            "Sessions adopt the new snapshot at their next launch. A lane still running an older "
            "snapshot after a release is the isolation working, not a fault.",
            "New store operations: `snapshot-status` reports the store, the current target, every "
            "published snapshot and every live lane pin; `snapshot-prune --keep` removes superseded "
            "snapshots while refusing to delete the current target, a pinned snapshot, or a "
            "recently-current one; `snapshot-pin --target` protects the snapshot a lane is "
            "executing from retention.",
            "Simultaneous launches no longer stampede the network. The first lane through the "
            "startup gate fetches and the others reuse its result for a freshness window "
            "(`MINERVIT_METHODOLOGY_SYNC_FRESHNESS_MINUTES`, default 10) instead of each running "
            "its own fetch and merge against the same checkout. The gate is serialized with a lock "
            "that survives the re-exec.",
            "Every mutation of the checkout or the store is appended to a write journal under the "
            "user state directory with the command, old and new head, outcome and the responsible "
            "lane, so an unexpected advance, a held update or a rescue can be attributed rather "
            "than guessed at.",
            "`version` and `methodology-status` now distinguish the checkout that updates are "
            "fetched into (`methodology_canonical_commit`) from the tree the running process was "
            "loaded from (`methodology_exec_root`). These are no longer the same thing, and any "
            "tooling that parsed one value as both must pick the one it meant.",
            "Release and public-export commands refuse to run from a snapshot and name the checkout "
            "to run them from. Executed from an immutable tree they had no git history to read, so "
            "they would previously report a tag as new without ever having looked one up, and skip "
            "the dirty-tree check entirely. Maintainer lanes must run these verbs from the "
            "checkout.",
            "A methodology commit now reports one short form regardless of the tree the CLI is "
            "executing from. Generated adapters embed that value, so two lanes at the same commit "
            "previously rendered different adapter bytes and each re-render put the other back into "
            "drift -- a permanent drift ping-pong through `methodology-status --fail-on-drift` with "
            "no methodology change behind it.",
            "Escape hatch: set `MINERVIT_METHODOLOGY_DISABLE_SNAPSHOT_EXEC=1` to hold a machine on "
            "checkout execution. Deleting the store is also safe -- the CLI warns once and falls "
            "back to the checkout rather than failing.",
        ]
        rollback_notes = [
            "Rolling back to 0.9.14 restores checkout execution: lanes once again run the "
            "methodology checkout directly, and with it the concurrency hazard this release "
            "removes. Two lanes on one machine are unsafe again, so roll back the machine, not one "
            "lane of several.",
            "Regenerate the launcher after rolling back (`tautline install-claude-launcher "
            "--force`). A launcher written by 0.9.15 pins a session to a snapshot path; the older "
            "CLI does not manage snapshots, so a stale pinned launcher would keep aiming sessions "
            "at a tree the rolled-back version no longer advances.",
            "The snapshot store and the write journal are additive on-disk state. An older version "
            "neither reads nor prunes them, so they are inert after a rollback and cost only disk "
            "until a re-upgrade adopts them again; remove the store directory by hand if that disk "
            "matters.",
            "This release is NOT WIP-safe and will not be auto-adopted by a lane with active work. "
            "It changes the tree the CLI executes from and requires the two migration commands "
            "above, so adopt it between lanes rather than mid-conversation.",
        ]
    elif parsed_version == version_tuple("0.9.14"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "The canonical public domain is `minervit.ai` on every live shippable surface: the "
            "contact addresses in `README.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`, and the "
            "support SLA model, the owner email in `.claude-plugin/marketplace.json`, the demo "
            "tape, and the `$id` of both JSON Schemas (`methodology/adapter-schema.json`, "
            "`methodology/bootstrap-legacy-allowlist-schema.json`).",
            "No adapter migration is required and no adapter has to change. The schema `$id` is an "
            "identifier, not a resolution target: the CLI loads the schema by filesystem path and "
            "validates with a stdlib-only subset validator that implements no `$ref`/`$id` "
            "resolution and makes no network call. Adapter documents point at the schema through "
            "`$schema` -- a raw.githubusercontent.com URL for generated scaffolds, a repo-relative "
            "path in-tree -- never through the `$id`, and the schema accepts any string there. An "
            "adapter still pinning the retired `$id` validates unchanged. The one case worth "
            "checking is your own: if you copied the `$id` literal into an allowlist, cache key, "
            "or test fixture of your own making, update that literal -- nothing in Tautline will "
            "fail for you, so no tool will tell you.",
            "`tests/test_public_boundary_scan.py` now enforces the flip rather than fighting it. "
            "It bans the two retired domains on shippable surfaces "
            "(it previously banned `minervit.ai`, so every hand-fix of the README silently "
            "reverted at the next test run) and adds a positive assertion that each swept surface "
            "actually carries its canonical-domain string, so a deletion or typo cannot pass "
            "merely because the retired domain is absent.",
            "The domain sweep now also covers `.claude-plugin/marketplace.json` and "
            "`docs/assets/demo.tape`. Both ship to users; neither was scanned before.",
            "Historical records keep the retired domain verbatim by design: release migration "
            "JSONs, archived changelogs, and the frozen 0.6.254 release-note literals in "
            "`bin/tautline` whose exact wording `tests/test_release_tracks_and_migrations.py` "
            "asserts. Any other retired-domain line in `bin/tautline` still fails the scan.",
        ]
        rollback_notes = [
            "Rolling back to 0.9.13 restores the retired domain as the enforced canonical one, so "
            "the public boundary scan will once again fail any surface carrying `minervit.ai`. "
            "The content and the enforcement move together in both directions; do not roll back "
            "one without the other.",
            "The schema `$id` reverts to the retired identifier on rollback. Because no consumer "
            "resolves or pins the `$id`, this breaks nothing: adapters validate by path, not by "
            "identifier, and the schema accepts any `$schema` string. An adapter authored against "
            "0.9.14 keeps validating on 0.9.13.",
            "This release is WIP-safe. It changes documentation contact addresses, two schema "
            "`$id` identifiers, and test enforcement -- no command, adapter key, configuration "
            "surface, or runtime code path changes, so no lane in progress alters behavior.",
        ]
    elif parsed_version == version_tuple("0.9.13"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Plan review converges on a two-round target with a hard cap of four rounds, replacing "
            "the single fixed cap of two. Rounds 3 and 4 are self-authorizing: a run proceeds on a "
            "recorded `--exception-note` or on a detected convergence state (blockers fixed after "
            "the bound round, or a successful run voided by a plan edit before it could be "
            "finalized). A round that was finalized and later superseded by another round is NOT a "
            "voided run and never self-authorizes: the manifest binds one round at a time, so "
            "every earlier round is orphaned by design. The tool never stops to ask an operator to "
            "authorize a round, so a plan that needs a third pass is no longer dead-ended at the "
            "cap.",
            "Past the hard cap of four rounds, refusal is unconditional -- clean, blocked, "
            "ambiguous, and stale states are all refused, and no exception note overrides it. Only "
            "the remedy message varies, and it is chosen by whether the bound evidence can "
            "actually be finalized: finalize-the-existing-evidence only when that evidence is "
            "clean AND still bound to the current plan, and a mandatory split otherwise. Evidence "
            "that is clean but STALE takes the split, because plan-finalization-precheck rejects a "
            "stale manifest -- naming finalization there would be a dead end.",
            "`--exception-note` is required to WRITE a plan-review manifest past the two-round "
            "target, on every writer: `finalize-plan-review`, `run-plan-review`'s inline "
            "finalization, and `record-plan-review`. The secondary/diagnostic writer is covered so "
            "it cannot be used as a bypass. `run-plan-review`'s printed next-action carries the "
            "flag, so an agent that follows the tool's own instructions never hits the validation "
            "error.",
            "A run voided by a plan edit no longer triggers the \"finalize that run instead\" "
            "refusal. Such a run can never be finalized, so refusing there was a dead end.",
            "A recorded exception is rendered into the plan's committed `## Cross-Model Review "
            "Evidence` block as an `- Exception:` line, so the reason a past-target round was "
            "taken is visible where reviewers read rather than only inside the JSON manifest.",
            "Plans that converge within two rounds are unaffected: no exception line, no "
            "`exception_note` field, no `- Exception:` evidence line, and round status still "
            "renders against the target (\"round 1 of 2\"). The hard cap governs refusal, not "
            "display.",
        ]
        rollback_notes = [
            "Rolling back to 0.9.12 restores the single fixed cap of two rounds, so a plan that "
            "has not converged by round 2 is dead-ended again: rounds 3 and 4 are refused outright "
            "rather than self-authorizing on an exception note or a detected convergence state.",
            "Plan-review manifests written by 0.9.13 past the two-round target carry an "
            "`exception_note` field that 0.9.12 never wrote. Re-check any such plan on the older "
            "build before relying on its round accounting.",
            "The `--exception-note` flag disappears from `finalize-plan-review`, `run-plan-review`, "
            "and `record-plan-review` on rollback, so scripts or agent instructions that pass it "
            "will fail argument parsing. This is additive-only in the forward direction: no "
            "existing command, adapter key, or configuration surface changes.",
            "This release is WIP-safe: it only loosens a refusal that previously had no override, "
            "so no lane in progress changes behavior at rounds 1-2.",
        ]
    elif parsed_version == version_tuple("0.9.12"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "The ExitPlanMode plan guard no longer hijacks a plan that sits under the adapter's "
            "`planningArtifacts.scratchPaths` to an unrelated source-of-truth plan. Transcript "
            "reference matching used to run before the configured-scratch check, so whenever the "
            "recent transcript happened to name exactly one plan path, a configured scratch plan "
            "bound to that plan instead of reaching the plan-mode escape. Configured scratch is "
            "now classified first. Content-hash matching still runs ahead of it, so a scratch "
            "copy of a real repo plan continues to bind to its source.",
            "Same-filename adoption is dropped for configured scratch plans. The configured-scratch "
            "check also precedes filename matching, so a plan under `planningArtifacts.scratchPaths` "
            "whose filename happens to equal a source-of-truth plan's no longer adopts that plan. "
            "Previously it did and then ran the finalization precheck against it, which could BLOCK "
            "ExitPlanMode; it now reaches the non-blocking plan-mode escape instead. This is "
            "deliberate: a filename collision is weak evidence of identity, while the content-hash "
            "match that still runs first is strong evidence. Plans that actually live under the "
            "source-of-truth root are unaffected and keep their full precheck gate.",
            "Every `--target` the plan guards print in their command examples is now the resolved "
            "absolute path of the target, shell-quoted, instead of a literal `--target .`. On a "
            "multi-agent machine `.` names whatever checkout the agent is sitting in, which may "
            "be a worktree owned by another lane, so a copy-pasted remedy could act on the wrong "
            "repository. This covers the run-plan-review, finalize-plan-review, "
            "plan-finalization-precheck, and goal-start remedies.",
        ]
        rollback_notes = [
            "Rolling back to 0.9.11 restores the reference-matching precedence, so a configured "
            "scratch plan can again be hijacked to an unrelated repo plan and be denied the "
            "plan-mode escape.",
            "Rolling back also restores same-filename adoption, so a configured scratch plan whose "
            "filename matches a source-of-truth plan again binds to it and is again gated by that "
            "plan's finalization precheck (which can block ExitPlanMode). Roll back if you were "
            "relying on filename collision to gate scratch drafts.",
            "It also restores the literal `--target .` in every guard remedy, which can direct a "
            "copy-pasted command at another lane's checkout.",
            "No command, adapter key, or configuration surface changes in either direction; "
            "`planningArtifacts.scratchPaths` is read, not redefined.",
            "This release is WIP-safe: it does not change behavior for lanes in progress.",
        ]
    elif parsed_version == version_tuple("0.9.11"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "public-release-check no longer reports an `account-id` blocker for a 12-digit run "
            "that sits inside a hex digest. A sha-256 digest contains a run of exactly twelve "
            "digits whenever the hex characters flanking it happen to be letters, so review "
            "ledgers, plan evidence headers, changelogs and checksum manifests produced false "
            "blockers. The scan is tree-wide, so a single digest anywhere could block a release "
            "export.",
            "The narrow exemption that skipped every 12-digit token on an `.impl-reviews/` "
            "`\"...sha...\":` line is removed, because the digest rule subsumes it. That "
            "exemption also hid genuine account identifiers on those lines; such an identifier "
            "is now correctly reported. If a scan newly fails on an `.impl-reviews/` sha line, "
            "it is reporting a real leaked identifier, not a regression.",
        ]
        rollback_notes = [
            "Rolling back to 0.9.10 restores the false `account-id` blockers on digest "
            "fragments, and restores the blanket `.impl-reviews/` sha-line exemption that can "
            "hide a genuinely leaked account identifier.",
            "No command, adapter key, or configuration surface changes in either direction.",
            "This release is WIP-safe: it does not change behavior for lanes in progress.",
        ]
    elif parsed_version == version_tuple("0.9.10"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "No user-visible change. This release adds internal backlog notes only; those notes "
            "live in a file that is excluded from every public export and never ships to users.",
        ]
        rollback_notes = [
            "No behavior to roll back: this release changes no command, adapter key, or "
            "configuration surface.",
            "Stable clients remain on the stable channel unless they explicitly opt into "
            "experimental.",
            "This release is WIP-safe: it does not change behavior for lanes in progress.",
        ]
    elif parsed_version == version_tuple("0.9.9"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Publishing to npm failed again right after the 0.9.8 fix landed, this time with "
            "ENEEDAUTH. npm's OIDC trusted-publishing exchange mints a short-lived credential "
            "only when the published package's repository.url matches the workflow's identity "
            "claim, and npm compares that field case-sensitively. The generated package "
            "manifest's homepage, repository.url and bugs fields all read the lowercase "
            "tautlines, but GitHub's canonical owner is Tautlines (capital T), so the comparison "
            "never matched, no token was minted, and npm fell back to demanding an interactive "
            "login. These fields are now generated from GitHub's exact canonical casing, and a "
            "guard test pins it going forward. Publishing to PyPI, which does not compare this "
            "field case-sensitively, was never affected.",
        ]
        rollback_notes = [
            "Fix only: pinning back to 0.9.8 restores the npm publish path that failed with "
            "ENEEDAUTH because the package manifest's repository URL did not match GitHub's "
            "canonical casing. No command, adapter key or configuration surface changes.",
            "Stable clients remain on the stable channel unless they explicitly opt into "
            "experimental.",
            "This release is WIP-safe: it repairs maintainer-only release plumbing and does not "
            "change behavior for lanes in progress.",
        ]
    elif parsed_version == version_tuple("0.9.8"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Publishing to npm never authenticated, so no release could reach npm. The publish "
            "workflow held no secrets by design, yet still told npm to read an authentication "
            "token from the environment. No such token existed, so npm sent an empty credential "
            "rather than exchanging its OIDC identity, and the registry rejected every attempt "
            "with a 404. The workflow no longer configures a registry token in any form, and npm "
            "authenticates over OIDC Trusted Publishing as it was always meant to. Publishing to "
            "PyPI was never affected.",
            "`release-tail` could not resume once it had tagged a release. It compared the tag's "
            "object identifier against the commit it had just pushed, but an annotated tag's "
            "reference names a tag object rather than a commit, so the two never matched and the "
            "command refused to continue, reporting a correctly placed tag as pointing somewhere "
            "else. Tags are now resolved to their commit before the comparison. A tag that "
            "genuinely points at a different commit is still refused: a published tag is never "
            "moved.",
        ]
        rollback_notes = [
            "Fixes only: pinning back to 0.9.7 restores the broken npm publish path (which never "
            "authenticated) and the tag comparison that prevented `release-tail` from resuming. "
            "No command, adapter key or configuration surface changes.",
            "Stable clients remain on the stable channel unless they explicitly opt into "
            "experimental.",
            "This release is WIP-safe: it repairs maintainer-only release plumbing and does not "
            "change behavior for lanes in progress.",
        ]
    elif parsed_version == version_tuple("0.9.7"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "The release tail is now a single command. `release-tail` runs export, public-mirror "
            "commit, tag, GitHub Release, registry publish and drift verification in one step, "
            "with per-step evidence. It offers `--dry-run` (prints the whole plan, changes "
            "nothing, makes no network calls) and `--skip-registry` (stops at a draft Release, so "
            "no publish fires). Resuming needs no flag: rerunning the command continues a "
            "partially-completed tail without repeating finished steps or publishing twice. "
            "The public mirror is only ever "
            "fast-forwarded: the tail never force-pushes it and never rewrites its history, so "
            "existing clones stay valid.",
            "`release-drift-check` compares the version held by the repository, npm and PyPI and "
            "exits non-zero when they disagree. It runs after a publish and on a daily schedule "
            "against the public repository, so a stale registry is caught within a day.",
            "`registry-package` generates the npm and PyPI pointer-package metadata from one "
            "source, so the text published to the registries can no longer drift away from what "
            "the project actually ships.",
            "Registry publishing moves to OIDC Trusted Publishing and stores no credentials. The "
            "publish workflows hold no secrets: the registry mints a short-lived credential from "
            "the workflow's identity token, so there is no API token to create, paste, rotate or "
            "leak. Because a registry publish cannot be undone, each workflow first asks the "
            "registry whether the version already exists, no-ops when it does, and fails closed "
            "rather than publishing blind when the registry cannot be read.",
            "Publishing to npm and PyPI requires the maintainer to configure Trusted Publishing "
            "on each registry once, binding it to the repository and the publish workflow "
            "filenames. Until that one-time configuration is done, the publish workflows fail "
            "closed; they never fall back to a token. The workflow filenames are part of the "
            "trust relationship, so renaming one breaks it. Both the bootstrap order and the "
            "registry configuration steps are documented in the release-engineering reference.",
        ]
        rollback_notes = [
            "Additive maintainer tooling: pinning back to 0.9.6 removes the release-tail, "
            "release-drift-check and registry-package commands and the publish workflows, and "
            "returns the release tail to its previous manual sequence. No existing command, "
            "adapter key or configuration surface changes.",
            "Stable clients remain on the stable channel unless they explicitly opt into "
            "experimental.",
            "This release is WIP-safe: it adds maintainer-only release commands and does not "
            "change behavior for lanes in progress.",
        ]
    elif parsed_version == version_tuple("0.9.6"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "A regression test now freezes the project's line-length lint exclusion (E501) at "
            "its current hit count, so the previously-unbounded exclusion cannot grow further; "
            "lowering the pinned count remains a manual follow-up as overlong lines are "
            "cleaned up.",
        ]
        rollback_notes = [
            "Test-suite and lint-configuration-comment changes only: pinning back to 0.9.5 "
            "removes the new regression test; no runtime or configuration surface changes "
            "either way.",
            "Stable clients remain on the stable channel unless they explicitly opt into "
            "experimental.",
            "This release is WIP-safe: it carries no executable behavior change.",
        ]
    elif parsed_version == version_tuple("0.9.5"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Documentation truth pass on the public surface: TERMS.md now describes the auto-update "
            "trust model that actually ships (install-cli pins the update source to the installing "
            "commit by default, --update-policy signed verifies the upstream commit signature, both "
            "fail closed, and install-claude-launcher refuses --dangerously-skip-permissions unless "
            "one of those policies is in effect) instead of describing it as a pre-launch target.",
            "A Claude Code plugin marketplace manifest ships at .claude-plugin/marketplace.json, so "
            "`/plugin marketplace add tautlines/tautline` offers the tautline-core and tautline-ops "
            "plugins directly from the repository. The README documents the install flow and the "
            "shell activation/PATH step the quickstart previously omitted.",
            "Public documentation no longer points at maintainer-only paths a reader of this "
            "repository cannot open; those references now name the maintainer repository or its "
            "backlog in prose. The render-adapters deprecation warning for the legacy goalTracker "
            "adapter key drops its pointer to a maintainer-only design document and keeps the "
            "actionable instruction (migrate to backlogProvider). The renderer-ci workflow accepts "
            "workflow_dispatch so its default-branch badge can render a status.",
            "SECURITY.md states the supply-chain posture that actually ships: the commit-level "
            "trust gate on every auto-update re-exec is real and fails closed, but the current "
            "releases carry no checksum manifest and the published release tags are not signed. "
            "cut-release can compute a SHA-256 manifest and can create a signed tag with the right "
            "flags; that is now described as a capability rather than as something an adopter can "
            "verify against today.",
            "SECURITY.md and TERMS.md note that --update-policy signed applies to an upstream you "
            "control and sign. The canonical upstream does not sign its commits yet, so a signed "
            "policy pointed at it refuses every update (fail-closed, with no upstream fix available "
            "to the adopter); pinned, the install default, remains the working lever against it. "
            "Signing the canonical upstream is listed as a roadmap item.",
            "ROADMAP.md speaks of the public repository in the present tense, backlinks its work "
            "items to their public issues, and carries a Recently shipped section that is current "
            "through the 0.9.x line.",
        ]
        rollback_notes = [
            "Documentation, workflow-trigger, and plugin-manifest changes only: pinning back to "
            "0.9.4 restores the previous prose and removes the marketplace manifest; no runtime or "
            "configuration surface changes either way.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe: it carries no executable behavior change.",
        ]
    elif parsed_version == version_tuple("0.9.4"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Public-surface scrub: the changelog and the startup-remediation reference no longer "
            "carry private-tracker issue numbers, incident dates, or internal backlog/archive paths; "
            "the affected passages describe the same behavior and follow-up tracking in "
            "maintainer-neutral terms. A stray internal codename was dropped from a .gitignore "
            "comment.",
            "The MakerKit community example is fully de-identified: the internal example codename is "
            "gone from its filename and headings, and exact kit/dependency version pins are replaced "
            "with relative phrasing. The Stripe and Better Auth teaching patterns are unchanged.",
            "The internal release-update delivery ledger is excluded from the public release export. "
            "The release-update accountability gate now evaluates against the maintainer's source "
            "repository instead of the exported tree, so public-release-export stays gated on "
            "release-update delivery rather than tripping on the ledger's absence from the export.",
        ]
        rollback_notes = [
            "Documentation and example-content changes only: pinning back to 0.9.3 restores the "
            "previous prose; no configuration or runtime surface changes either way.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe: the only executable change is a new optional keyword argument "
            "with a backward-compatible default and an export-exclusion list entry.",
        ]
    elif parsed_version == version_tuple("0.9.3"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Brand copy sweep: live reader-facing surfaces (README, CONTRIBUTING, GOVERNANCE, "
            "SECURITY, TERMS, PRIVACY, ROADMAP, docs/reference, docs/governance, docs/product, "
            "examples) teach the tautline CLI name, the tautlines/tautline-dev repository, and the "
            "~/.config/tautline/tautline.env config surface; a tracked guard test bans the retired "
            "'minervit methodology' brand on those surfaces. 'Minervit' remains as the maintainer "
            "name, and real on-disk artifact names keep their current spellings until the fallback "
            "removal release.",
            "Commands, adapter schema, hooks, and runtime behavior are unchanged (documentation and "
            "test-expectation changes only).",
        ]
        rollback_notes = [
            "Documentation-only: pinning back to 0.9.2 restores the previous prose; no configuration "
            "or runtime surface changes either way.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe: no executable surface changes.",
        ]
    elif parsed_version == version_tuple("0.9.2"):
        wip_safe = True
        required_migrations = []
        optional_migrations = [
            {
                "id": "reinstall-cli-for-tautline-config",
                "surface": "config",
                "description": (
                    "The machine config surface moves to ~/.config/tautline/tautline.env (secrets: "
                    "~/.config/tautline/secrets.zsh; re-exec tokens: ~/.config/tautline/reexec-tokens). "
                    "install-cli writes the new file, migrates preserved values from the legacy "
                    "~/.config/minervit/methodology.env, and keeps that legacy path as a byte-identical "
                    "compatibility mirror so pre-0.9.2 launchers/hooks keep resolving. update-repin keeps "
                    "both surfaces' pins in step. Re-running install-cli + install-claude-launcher adopts "
                    "the new surface; nothing breaks without it (legacy fallbacks remain until "
                    "METH-FU-TAUTLINE-FALLBACK-REMOVAL)."
                ),
                "command": "tautline install-cli && tautline install-claude-launcher --force",
            },
        ]
        behavior_changes = [
            "Config surface rebrand: the CLI reads ~/.config/tautline/tautline.env first with the legacy "
            "~/.config/minervit/methodology.env as fallback; install-cli emits TAUTLINE_METHODOLOGY_REPO "
            "and TAUTLINE_CLAUDE_AUTOCOMPACT_PCT aliases and writes a byte-identical legacy mirror; "
            "emitted shims, launchers, and git hooks source the tautline path first and honor "
            "TAUTLINE_METHODOLOGY_REPO. Trust configuration (update policy/pins) keeps its 0.9.1 "
            "names and semantics; TAUTLINE trust aliases and pin-set preservation ship separately as "
            "a focused security change.",
            "Lane and codex child environments dual-write TAUTLINE_* aliases for every MINERVIT_* "
            "variable so mixed-version process trees agree during the transition.",
            "The held-update banners defer to the active trust policy's remedy in the hold detail "
            "(deferred R2 P2 from 0.9.1, now landed).",
            "Commands, adapter schema, and product-lane gates are otherwise unchanged.",
        ]
        rollback_notes = [
            "Pinning back to 0.9.1 keeps working: the legacy config mirror is byte-identical, so "
            "pre-0.9.2 code resolves the same values from the legacy path.",
            "No adapter migration is required for product lanes.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe: resolution order adds a preferred path without removing any "
            "legacy surface.",
        ]
    elif parsed_version == version_tuple("0.9.1"):
        wip_safe = True
        required_migrations = []
        optional_migrations = [
            {
                "id": "repoint-origin-to-tautline-dev",
                "surface": "checkout",
                "description": (
                    "The framework dev repository moved to tautlines/tautline-dev (renamed + transferred "
                    "from minervit/minervit-ai-delivery-methodology). GitHub serves redirects, so existing "
                    "checkouts keep working; repoint origin when convenient."
                ),
                "command": "git remote set-url origin https://github.com/tautlines/tautline-dev.git",
            },
            {
                "id": "reinstall-claude-launcher-gate-repair",
                "surface": "launcher",
                "description": (
                    "Regenerate the generated Claude launcher to pick up the no-dead-ends contract: every "
                    "launch-gate failure prints its exact remedy, and an interactive launch starts a Claude "
                    "repair session instead of stranding the operator (disable with "
                    "TAUTLINE_NO_REPAIR_SESSION=1)."
                ),
                "command": "tautline install-claude-launcher --force",
            },
        ]
        behavior_changes = [
            "Trust-pin holds no longer block launch (no-dead-ends): when an update is available but the "
            "upstream head is not in the pinned allowlist, sync-methodology reports `held` (exit 0) and the "
            "launch continues on the trusted retained checkout, with the update-repin remedy printed "
            "alongside. Held requires the RETAINED head itself to pass the active trust policy; an "
            "empty/replaced allowlist or an unsigned local commit stays fail-closed. The checkout still "
            "never advances to unverified code, and re-exec of unverified code still refuses; only the "
            "launch-blocking behavior changed. A wedged/dirty checkout still fails.",
            "Generated Claude launchers print an exact executable remedy with every gate failure and, when "
            "interactive, start a Claude repair session for the failed gate (methodology auto-rescue, "
            "methodology sync, lane-start, methodology-status integrity) instead of a bare refusal. "
            "TAUTLINE_NO_REPAIR_SESSION=1 (or MINERVIT_NO_REPAIR_SESSION=1) prints the remedy and exits 1.",
            "Rebrand identity: METHODOLOGY_REPO_SLUG is now tautlines/tautline-dev; the legacy "
            "minervit/minervit-ai-delivery-methodology slug remains accepted everywhere the framework "
            "identifies its own repo (self-adapter repo field, inferred origin remote, adapter-vs-remote "
            "match), so unconverted checkouts and adapters keep starting during the transition.",
            "Commands, adapter schema, hooks, and product-lane runtime behavior are otherwise unchanged.",
        ]
        rollback_notes = [
            "Pinning back to 0.9.0 restores fail-closed launch on trust-pin holds and the old launcher "
            "refusal text; both slugs resolve to the same repository either way.",
            "No adapter migration is required for product lanes; only the framework's own self-adapter "
            "recorded the new repo slug.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe: launch gating becomes strictly more permissive (held continues on "
            "already-trusted code) and the slug change is legacy-compatible.",
        ]
    elif parsed_version == version_tuple("0.9.0"):
        wip_safe = False
        required_migrations = [
            {
                "id": "migrate-off-narrative-journal-publication",
                "surface": "session-journal",
                "description": (
                    "Narrative session-journal publication is disabled in 0.9.0: publish-session-journal "
                    "and publish-pending-session-journals refuse in every mode (they narrate adopter product "
                    "work and can never be proven safe to publish). Journals stay LOCAL "
                    "(prepare-session-journal + validate-session-journal under the lane's gitignored "
                    ".ai-runs/). Adopters who relied on remote narrative publication must stop invoking those "
                    "commands; to contribute sanitized upstream signal instead, opt in with "
                    '"instrumentation": {"enabled": true} and run publish-instrumentation-record. This repo\'s '
                    "own self-adapter framework-dev journals stop publishing and stay local."
                ),
                "command": "tautline publish-instrumentation-record --target .",
            },
        ]
        optional_migrations = []
        behavior_changes = [
            "Narrative session-journal publication is DISABLED (security behavior change, deprecation "
            "security exception): publish-session-journal (--commit --push, bare --file, and "
            "--allow-release-checkout-write) and publish-pending-session-journals refuse for every adapter "
            "in every mode, naming publish-instrumentation-record as the replacement. Both commands are "
            "deprecated in the public contract (removal >=1.0.0). Narrative journals remain local-only "
            "evidence; no new narrative content can reach any remote from any adapter.",
            "New sanitized instrumentation record: publish-instrumentation-record recomputes a "
            "closed-vocabulary record (enumerated event codes plus numbers, zero product-information "
            "capacity) from the local observability event log and publishes it to the constant "
            "tautline-telemetry-archive branch via a hardened push path (pinned adopter-neutral commit "
            "identity/date, whole-branch fail-closed hygiene, closed-set commit-object parse, byte-compare "
            "readback, branch-tip ancestry guard, recompute-at-publish so a tampered preview cannot "
            "influence what publishes). prepare-instrumentation-record and validate-instrumentation-record "
            "round out the surface. All three commands are experimental in the public contract.",
            "New additive adapter key instrumentation.{enabled(default false), cadence(default milestone)} "
            "(experimental). enabled alone permits publishing; cadence gates only boundary prompting. "
            "instrumentation.enabled requires observabilityEvents.enabled (validated as a configuration "
            "error); unknown instrumentation.* subkeys are rejected (remote metadata is a fixed constant, "
            "so there are no branch/archiveDir knobs). Default lanes render no net new guidance and are "
            "never prompted.",
            "The generated methodology/instrumentation-schema.json artifact is registered as public "
            "contract surface, so any record-shape or event-vocabulary change is a reviewed public-surface "
            "change. The deprecation policy gains an explicit security-exception clause: a stable surface "
            "confirmed to expose adopter data to a remote may be hard-disabled ahead of its deprecation "
            "window with a required migration note and named replacement; removal still follows the "
            "major-release window.",
        ]
        rollback_notes = [
            "Pinning back to 0.8.9 restores narrative session-journal publication end to end. Note that "
            "any narrative content re-published after rollback again carries the pre-0.9.0 leak exposure "
            "the security change was introduced to eliminate.",
            "The instrumentation adapter key and commands are additive and default-off; a lane that never "
            "set instrumentation.enabled: true is never prompted and publishes nothing new. One caveat: a "
            "lane with observabilityEvents.enabled gains a per-lane lane_id and a monotonic seq on each "
            "local event-log entry regardless of instrumentation.enabled, so the local event log is not "
            "byte-for-byte identical to 0.8.9 -- the fields are local-only and never leave the machine "
            "unless the lane explicitly opts into publishing.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is not WIP-safe: it changes security-sensitive publishing behavior (a previously "
            "stable publish surface now refuses), so non-interactive callers relying on narrative "
            "publication must migrate before adopting it during active automated work.",
        ]
    elif parsed_version == version_tuple("0.8.9"):
        wip_safe = False
        required_migrations = [
            {
                "id": "reinstall-claude-launcher",
                "surface": "launcher",
                "description": (
                    "Regenerate the generated Claude launcher so it three-way-dispatches on the new "
                    "methodology-status --fail-on-drift exit contract (0 clean / 1 integrity / 2 debt-only) "
                    "and enters startup remediation mode on debt-only failures instead of refusing to start. "
                    "After reinstalling (or on rollback), run `type -a <launcher-name>` to confirm no shell "
                    "function or alias shadows the generated launcher script."
                ),
                "command": "tautline install-claude-launcher --force",
            },
        ]
        optional_migrations = []
        behavior_changes = [
            "methodology-status --fail-on-drift now returns a three-way exit contract: 0 clean, 1 integrity "
            "(adapter drift the render pipeline could not self-heal, or an unsupported runtime that cannot "
            "run proof gates; the launcher still refuses), 2 debt-only (every other gate list -- agent-fixable "
            "lane debt). A truthful summary line prints last when nonzero: "
            "`methodology_status_blocking: <integrity|debt> - <comma-separated gate names>`. "
            "Third-party callers that treated exit code 1 as the only failure signal must treat any "
            "nonzero exit (compare != 0) as failure instead; nonzero-still-means-stop is unchanged, and "
            "--strict semantics (never returns 2, including --strict --fail-on-drift) are exactly "
            "preserved.",
            "The generated launcher's status invocation gains --enter-remediation-on-debt (internal, "
            "launcher-only): on a debt-only outcome it execs Claude with a remediation prompt instead of the "
            "goal-kickoff prompt, and SUPPRESSES user-provided launcher arguments on that path -- only the "
            "configured claude args plus the remediation prompt are passed, so the remediation instruction "
            "is not competing with a stale prompt. A clean (exit 0) launch keeps full argument pass-through "
            "unchanged. lane-start also gains an internal --defer-debt-preflights flag, passed only by the "
            "generated launcher; without it lane-start behavior is byte-for-byte unchanged.",
            "Git pre-push hooks regenerate automatically at the next lane-start to add the coordination-only "
            "push allowance's stdin handshake (MINERVIT_PREPUSH_RECORDS_FILE); no manual action is required, "
            "but a hook that has not yet regenerated simply keeps today's HEAD-based evidence behavior until "
            "it does.",
            "Lane-coordination staleness and strict-mode untracked/uncommitted checks are re-scoped to the "
            "current lane's own status file; other lanes' stale, untracked, or dirty status files are now "
            "informational only (lane_coordination_stale_other_lanes / lane_coordination_foreign_status_git) "
            "and never block startup. The shared cross-lane contract and lane board checks are unchanged.",
        ]
        rollback_notes = [
            "Keep the previous adapter JSON and generated .minervit-ai-delivery.json/.tautline.json in git "
            "history; no adapter schema or generated-adapter render output changes in this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is not WIP-safe: exit 2 is a new nonzero outcome from methodology-status "
            "--fail-on-drift, and non-interactive callers that treated exit code 1 as the only failure "
            "signal must treat any nonzero exit (compare != 0) as failure before relying on this release "
            "during active automated work.",
            "Pinning back to 0.8.8 (the previous release) restores the two-way exit contract end-to-end "
            "without undoing the 0.8.7/0.8.8 session-journal and shim fixes; reinstall the launcher "
            "again after rollback (tautline install-claude-launcher --force) and re-check with "
            "`type -a <launcher-name>` so no shadowing shell function silently keeps the newer launcher live.",
        ]
    elif parsed_version == version_tuple("0.8.8"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "publish-pending-session-journals refuses to publish when sessionJournal.enabled is false: pending LOCAL journals previously still committed and pushed to the framework checkout's journal branch after the adopter disabled the feature, defeating the 0.8.7 opt-in guarantee; publish-session-journal --file remains as an explicit operator override",
            "Generated shims, Claude launchers, and git hooks suspend set -eu while sourcing methodology.env and the user's secrets file: an unset-variable reference there (cron, hooks, CI environments) previously killed every CLI invocation with 'unbound variable'; re-run tautline install-cli to regenerate existing installs",
            "Commands, adapter schema, and other lane runtime behavior are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe: it only removes an unwanted publication path; nothing depends on pending journals auto-publishing while disabled.",
            "Pinning back to 0.8.7 restores the leak: pending local journals publish at startup even for adapters that disabled session journals.",
        ]
    elif parsed_version == version_tuple("0.8.7"):
        wip_safe = True
        required_migrations = []
        optional_migrations = [
            {
                "id": "declare-session-journal-opt-in",
                "description": "Session journals are now opt-in: adopters who relied on the old default-on collection must add \"sessionJournal\": {\"enabled\": true} to their source adapter and re-render (tautline render-adapters --project <source adapter> --target <repo> --write). Declaring {\"enabled\": false} records the opt-out and silences the startup hint.",
            }
        ]
        behavior_changes = [
            "Session journals default to disabled: a journal narrates the adopter's product work and publishes to the framework checkout's journal branch, so it is never collected without an explicit sessionJournal.enabled=true declaration in the source adapter",
            "lane-start and methodology-status print a one-line session_journal_optin hint while the source adapter is silent about sessionJournal; an explicit enabled=false declaration silences it; docs/reference/session-journals.md documents contents, destination, and opt-in",
            "Commands, adapter schema, hooks, and other lane runtime behavior are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required to keep running; adapters that already declare sessionJournal keep their exact behavior.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe: lanes without a sessionJournal declaration simply stop creating NEW journals mid-work (already-written pending journals still publish); no gate depends on journal creation.",
            "Pinning back to 0.8.6 restores default-on collection for undeclared adapters.",
        ]
    elif parsed_version == version_tuple("0.8.6"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Text-mode subprocess captures (gh, git, ps, tasklist, review wrappers) decode as UTF-8 with errors=replace instead of the locale codec: on Windows the charmap codec crashed the pre-push hook with UnicodeDecodeError + NoneType.strip when gh output contained non-ASCII bytes (issue #186); run_command also tolerates a missing stdout stream",
            "Commands, adapter schema, hooks, lane runtime behavior, and generated adapters are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe: it only hardens subprocess output decoding; on UTF-8 locales the decoded text is byte-identical to before.",
            "Pinning back to 0.8.5 restores locale-codec decoding; Windows pushes will crash again when gh output contains non-ASCII bytes.",
        ]
    elif parsed_version == version_tuple("0.8.5"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "plan-finalization-precheck accepts pre-0.8.0 evidence blocks again: the template-generated Precheck line embeds the CLI name, so byte-exact comparison rejected every legacy-reviewed plan after the rename; exactly that command prefix is normalized and all other evidence drift still fails",
            "Generated product CLAUDE.md adapters now render the Codex T1 review command WITH the adapter's review wrapper after `--` (previously the line omitted it and following it verbatim errored with 'codex-run requires a command after --'); re-render generated adapters to pick this up",
            "Commands, adapter schema, hooks, and lane runtime behavior are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; re-rendering to pick up the corrected Claude review line is recommended but optional.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe: it relaxes one legacy-evidence comparison and corrects generated guidance text.",
            "Pinning back to 0.8.4 restores the byte-exact evidence comparison; legacy-reviewed plans will fail precheck again until re-finalized.",
        ]
    elif parsed_version == version_tuple("0.8.4"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "The bin/minervit-methodology compat shim is Python again: pre-0.8.0 installs auto-update by re-exec'ing that path with the Python interpreter, so the 0.8.0 bash shim crashed every 0.7.x upgrade mid-flight with a SyntaxError (the update itself applied; only the re-exec failed). The shim now execs bin/tautline from Python and works under both kernel exec and python3 invocation",
            "Plan-review manifests recorded by the legacy CLI name (minervit-methodology run-plan-review / finalize-plan-review) are trusted again by plan-finalization-precheck: the rename left them untrusted, which rejected every pre-0.8.0 reviewed plan in downstream product repos; record-plan-review imports remain untrusted under both names",
            "Commands, adapter schema, hooks, lane runtime behavior, and generated adapters are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it changes no runtime surface: only the compat shim implementation changes.",
            "Pinning back to 0.8.3 restores the bash shim; 0.7.x installs upgrading through it will hit the transient re-exec SyntaxError again (rerun the launcher once to recover).",
        ]
    elif parsed_version == version_tuple("0.8.3"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Dependency refresh: dev toolchain pins (pytest 9.1.1, mypy 2.2.0, ruff 0.15.21), SHA-pinned CI actions (checkout v7.0.0, setup-python v6.3.0, setup-node v6.4.0), and iteration-review renderer-kit packages (remotion 4.0.487, react 19.2.7, zod 4.4.3, typescript 7.0.2, vitest 4.1.10)",
            "public-release-export now excludes .github/dependabot.yml: the public repository is a force-pushed mirror, so Dependabot PRs opened there can never merge and always fail the release-change contract; dependency intake stays in the private repo",
            "The pinned/signed auto-update refusal now includes recovery guidance (review the incoming commits, then advance the trusted pin with update-repin); previously a blocked lane start printed the refusal with no next step",
            "Commands, adapter schema, hooks, lane runtime behavior, and generated adapters are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it changes no runtime surface: only dev dependencies, CI action pins, renderer-kit packages, and the export file list change.",
            "Pinning back to 0.8.2 restores the previous pins and re-exports the Dependabot config; nothing functional differs.",
        ]
    elif parsed_version == version_tuple("0.8.2"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Public-export test context: the framework self-adapter tests that read the export-excluded .tautline/adapter.json and .tautline.json now carry the private_repo_only skip marker, so the public tautlines/tautline CI runs green (they failed on the first public push because three tests lost their skip coverage in the marker rename)",
            "Commands, adapter schema, hooks, lane runtime behavior, and generated adapters are unchanged; the release only adds test skip markers",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it changes no runtime surface: only test skip markers change.",
            "Pinning back to 0.8.1 restores the previous test markers; nothing functional differs.",
        ]
    elif parsed_version == version_tuple("0.8.1"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Public repository identity: README badges/clone URL, issue-template links, plugin manifest homepage/repository/policy URLs, and reference-doc clone instructions point at github.com/tautlines/tautline (the public home); scaffolded adapters' $schema URL points at the tautlines/tautline raw path so it resolves for public users",
            "Commands, adapter schema content, hooks, lane runtime behavior, and generated adapters are unchanged; only public-facing URLs and the scaffold $schema link change",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it changes no runtime surface: only public-facing URLs and the scaffold $schema link change.",
            "Pinning back to 0.8.0 restores the previous URLs; nothing functional differs.",
        ]
    elif parsed_version == version_tuple("0.8.0"):
        wip_safe = False
        required_migrations = []
        optional_migrations = [
            {
                "id": "re-render-adapters-for-tautline",
                "description": (
                    "Re-render each product's generated adapter against 0.8.0 "
                    "(tautline render-adapters --project <source adapter> --target <repo> --write) so rendered "
                    "commands emit the tautline CLI name and the marker moves to .tautline.json; commit the rename "
                    "(the legacy .minervit-ai-delivery.json marker and minervit-methodology shim keep working until "
                    "the post-launch fallback removal, so this can be scheduled per product)."
                ),
            },
            {
                "id": "reinstall-plugins-under-tautline-names",
                "description": (
                    "Re-register AI-runtime plugin installs under the new plugin names: "
                    "minervit-ai-delivery-methodology -> tautline-core, minervit-delivery-ops -> tautline-ops. "
                    "Runtime configs that reference the old plugin directory paths or declared names will not "
                    "resolve after updating the framework checkout."
                ),
            },
        ]
        behavior_changes = [
            "The framework is renamed to Tautline (tagline: the governor for AI coding agents); public docs, README, and plugin manifests carry the new name",
            "bin/tautline is the canonical CLI; bin/minervit-methodology remains as a one-line exec shim so existing lanes, hooks, and installed user shims keep working",
            "TAUTLINE_-prefixed environment variables are honored everywhere with MINERVIT_ names as fallback (resolve_env); no existing environment needs to change",
            "Generated lane markers move to .tautline.json and repo-local source adapters to .tautline/adapter.json; every read path resolves the canonical name first and falls back to the legacy .minervit-ai-delivery.json / .minervit/adapter.json names",
            "Rendered adapters and help text emit tautline <subcommand>; generated lane wrappers and the installed user shim resolve tautline first and fall back to minervit-methodology (including pinned older checkouts)",
            "Plugins are renamed: minervit-ai-delivery-methodology -> tautline-core, minervit-delivery-ops -> tautline-ops (directories and declared names)",
            "A public ROADMAP.md tracks product direction (package split, pipx install, guided onboarding, update prompts) in the open",
        ]
        rollback_notes = [
            "No adapter migration is required to keep running; generated adapters from earlier releases keep working through the CLI shim, env fallback, and marker fallback.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is not WIP-safe for automatic active-lane upgrades because the plugin directories and declared plugin names change: an AI-runtime session whose plugin registration references the old names loses those skills until plugins are re-registered (see the reinstall-plugins-under-tautline-names migration). Lane CLI invocation, env vars, and markers are unaffected mid-work thanks to the compatibility fallbacks.",
            "Pinning back to 0.7.1 restores the pre-rename names end-to-end; nothing in 0.8.0 writes state that 0.7.1 cannot read (the canonical marker written by 0.8.0 renders would be ignored by 0.7.1, which still reads the legacy marker, so re-render after pinning back).",
        ]
    elif parsed_version == version_tuple("0.7.1"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Channel reconciliation: the 0.7.0 launch boundary fixes that shipped on the stable channel are ported back to experimental so both channels carry identical content (private-codename scrub of export-included surfaces; the framework's own self-adapter files .minervit-ai-delivery.json and .minervit/ are excluded from the public export)",
            "Commands, adapter schema, hooks, lane runtime behavior, and generated adapters are unchanged; the release only rewords export-included bookkeeping text and extends the public-export exclusion list",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it changes no runtime surface: it only rewords export-included bookkeeping text and extends the public-export exclusion list.",
            "Pinning back to 0.7.0 restores identical runtime behavior; the stable channel already carries this release's content.",
        ]
    elif parsed_version == version_tuple("0.7.0"):
        wip_safe = False
        required_migrations = []
        optional_migrations = [
            {
                "id": "copy-makerkit-implementation-community-skill",
                "description": (
                    "Adopters who invoke the makerkit-implementation skill from their AI runtime "
                    "should copy examples/community-skills/makerkit-implementation/ from the "
                    "methodology checkout into their own skills directory: at 0.7.0 this "
                    "stack-specific worked example moves out of the packaged plugins into the "
                    "unpackaged examples/community-skills/ tree so the portable core stays "
                    "stack-agnostic, and it is no longer distributed with either plugin. CLI "
                    "commands continue to resolve their assets from the methodology checkout "
                    "regardless of which plugins are enabled."
                ),
            }
        ]
        behavior_changes = [
            "Initial public (open-core) release; the public-export boundary, changelog, and product docs are cut to their launch shape",
            "Auto-update trust hardening (security hardening): fresh installs default to a pinned update policy (advance via update-repin after review); --dangerously-skip-permissions refuses to run unless the effective policy is pinned or signed (checked at install and at launch); re-exec tokens move to a 0600 file in a 0700 dir under ~/.config/minervit; adapter-configured paths are contained to the project root (schema pattern + runtime backstop, documented scratchPaths/laneCoordination allowlist)",
            "Governance and OSS contribution scaffolding for the public launch (governance pack): AI contribution policy, PR template, issue forms, GOVERNANCE.md, CONTRIBUTING additions, and a de-placeholdered CODEOWNERS",
            "Launch documentation (README and demo assets): README rewrite, docs/README.md landing page, terminal demo GIF + vhs tape, and a social-preview generator (dev-only pillow dependency)",
            "docs/product/ carries only the two public product docs (positioning, support/SLA model); the other internal product-strategy docs move under the export-excluded docs/productization/product-internal/",
            "CHANGELOG.md and the methodology-plugin changelog are squashed to a single 0.7.0 launch entry; pre-launch history is retained privately in the export-excluded archive",
            "The stack-specific makerkit-implementation example skill is no longer packaged with either plugin: it moves from the delivery-ops plugin to the unpackaged examples/community-skills/makerkit-implementation/ tree, keeping the portable core stack-agnostic; adopters who use it should copy it into their own skills directory (see the copy-makerkit-implementation-community-skill optional migration)",
            "Commands, adapter schema, and generated-file behavior are unchanged for existing adopters; the pinned default and skip-permissions interlock apply to fresh installs and launcher regeneration only, and the rest of the launch reshapes documentation and packaging",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Because this release removes a packaged AI runtime skill surface (makerkit-implementation leaves the delivery-ops plugin for the unpackaged examples/community-skills/ tree, the same reasoning that made 0.6.259 not WIP-safe), it is not WIP-safe for automatic active-lane upgrades: a lane mid-work on a MakerKit task would lose the skill until it copies examples/community-skills/makerkit-implementation/ into its own skills directory (the copy-makerkit-implementation-community-skill optional migration). The rest of the launch changes no runtime behavior for existing installs: the warn fallback for installs without a policy env is unchanged, and the pinned default and skip-permissions interlock apply to fresh installs and launcher regeneration only.",
            "Pinning back to 0.6.268 (the immediately prior release) restores the pre-launch documentation layout, the full changelog history, and makerkit-implementation in the delivery-ops plugin while keeping the 0.6.268 security hardening (pinned-by-default policy, skip-permissions interlock, hardened re-exec token path, adapter path containment); only roll all the way back to 0.6.265 if the security hardening itself must be reverted.",
        ]
    elif parsed_version == version_tuple("0.6.268"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Auto-update trust hardening: new installs default to a pinned update policy (advance via update-repin after review); --dangerously-skip-permissions refuses to run unless the effective policy is pinned or signed (checked at install and at launch)",
            "Re-exec tokens move to a 0600 file in a 0700 dir under ~/.config/minervit; adapter-configured paths are contained to the project root (schema pattern + runtime backstop, documented scratchPaths/laneCoordination allowlist)",
            "SECURITY.md rewritten to match shipped behavior; legacy installs without a policy env still fall back to warn",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because existing installs' runtime fallback behavior (warn when no policy env is set) is unchanged; the pinned default and skip-permissions interlock apply to fresh installs and to launcher regeneration.",
            "Pinning back to 0.6.267 removes the pinned-by-default policy, the skip-permissions interlock, the hardened re-exec token path, and the adapter path containment while changing no runtime behavior for installs that do not opt in.",
        ]
    elif parsed_version == version_tuple("0.6.267"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Launch README rewrite (pitch, quickstart with clone step, comparison table, FAQ), docs/README.md landing page, real terminal demo GIF + vhs tape, social-preview generator (PIL, dev-only pillow dependency)",
            "Documentation and assets only; commands, hooks, adapters, schemas, and runtime behavior are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it only adds documentation and static assets; no runtime behavior or schema changed.",
            "Pinning back to 0.6.266 removes the README rewrite, landing page, demo GIF, and social-preview generator while changing no runtime behavior.",
        ]
    elif parsed_version == version_tuple("0.6.266"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Governance and OSS contribution scaffolding is added for the public launch: .github/AI_CONTRIBUTION_POLICY.md, .github/pull_request_template.md, GitHub issue forms (bug_report.yml, feature_request.yml, config.yml), GOVERNANCE.md, CONTRIBUTING.md additions, and a de-placeholdered .github/CODEOWNERS",
            "Documentation and governance content only; commands, hooks, adapters, generated files, schemas, and runtime behavior are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it only adds governance/contribution documentation; no runtime behavior or schema changed.",
            "Pinning back to 0.6.265 removes the governance scaffolding files while changing no runtime behavior.",
        ]
    elif parsed_version == version_tuple("0.6.265"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Private-repo-context tests skip when the public-release-export marker file is present, so a public export's CI runs green on day one",
            "Public boundary scans filter to files that exist, keeping them meaningful over the export's reduced tree",
            "Commands, hooks, adapters, generated files, and runtime behavior are unchanged; test-only release",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it changes test collection context only.",
            "Pinning back to 0.6.264 restores the previous test behavior in export trees.",
        ]
    elif parsed_version == version_tuple("0.6.264"):
        wip_safe = False
        required_migrations = []
        optional_migrations = [
            {
                "id": "framework-intake-replaces-methodology-regression-rca",
                "description": "The methodology-regression-rca compatibility alias skill is removed; use framework-intake for RCA and feature-request flows. Update any adapter, automation, or documentation that invokes the old skill name.",
            }
        ]
        behavior_changes = [
            "The methodology-regression-rca compatibility alias skill is removed ahead of the first public tag; framework-intake owns regression RCA and feature-request intake",
            "The public contract no longer lists the deprecated methodology-regression-rca skill entry",
            "RCA artifact filenames, validate-rca-artifact, publish-rca-artifact, and all intake behavior are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Not marked WIP-safe: lanes still invoking the methodology-regression-rca skill name must switch to framework-intake.",
            "Pinning back to 0.6.263 restores the compatibility alias skill.",
        ]
    elif parsed_version == version_tuple("0.6.263"):
        wip_safe = False
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Extracted helpers now live only in src/minervit_methodology: the duplicated copied-bin fallback helper bodies (_GuardFallback and the public-release standalone fallbacks) are removed and every caller routes through the module accessors",
            "A copied bin/minervit-methodology launcher without a sibling src/minervit_methodology package now fails loudly with a guided SystemExit naming the missing package for non-hook commands; -hook commands keep failing open so a missing package never wedges a lane",
            "A regression test pins the guided-SystemExit message contract against the real module accessors and proves a real -hook invocation fails open without src/",
            "Behavior with the full checkout (the only supported install) is unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Not marked WIP-safe: copied-bin launchers without src/ lose fallback behavior by design in this release.",
            "Pinning back to 0.6.262 restores the copied-bin fallback helper bodies.",
        ]
    elif parsed_version == version_tuple("0.6.262"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "The execution-packet-work-loop skill gains a parallel-execution reference documenting the advisory wave method: per-task file scopes and ambiguity classes, worktree lanes, a serial integration conveyor, and model routing across lanes",
            "Advisory pointers added in the execution-packet-work-loop SKILL.md, its packet-spec policy, and the review-before-push plan-review policy; nothing new blocks — the guidance is advisory by definition",
            "Commands, hooks, adapters, generated files, release tracks, and enforcement behavior are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it adds advisory skill documentation only.",
            "Pinning back to 0.6.261 removes the parallel-execution reference while changing no runtime behavior.",
        ]
    elif parsed_version == version_tuple("0.6.261"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Removed the dead RELEASE_NOTES_ARCHIVE_BRANCH constant from bin/minervit-methodology; it was unused (release-notes archiving is not automated, and no code referenced it)",
            "docs/reference/operations/release-engineering.md gains a Release Notes Archive section documenting that appending narrative entries to docs/releases/minervit-ai-delivery-methodology.md is a manual maintainer step, not an automated part of the release flow",
            "No adapter JSON content, schema, or runtime behavior changed",
            "lane startup, hooks, adapters, generated files, product worktrees, release tracks, and AI-facing policy are otherwise unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it only removes a dead code constant and adds documentation; no runtime behavior or schema changed.",
            "Pinning back to 0.6.260 restores the RELEASE_NOTES_ARCHIVE_BRANCH constant (still unused) and drops the Release Notes Archive documentation section.",
        ]
    elif parsed_version == version_tuple("0.6.260"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "render_adapter() (bin/minervit-methodology) has redundant/filler prose deduped and tightened across the Planning Artifacts, Review Before Push, Merge Queue, Background Commands, Autonomy And Planning/Status, Delivery Summaries, Execution Packet Work Loop, Context Continuity, and Technical Stack Policy sections, restoring headroom under the rendered-adapter byte budget for the reference adapter",
            "No adapter JSON content changed and every REQUIRED_RENDERED_ADAPTER_MARKERS entry in tests/test_generated_adapter_contract.py remains present verbatim",
            "tests/test_rendered_adapter_budget.py now reports current/limit/headroom byte counts on assertion failure instead of a bare byte-count assert",
            "lane startup, hooks, adapters, generated files, product worktrees, release tracks, and AI-facing policy are otherwise unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it only tightens rendered-adapter prose and a test failure message, not the adapter schema or runtime behavior.",
            "Pinning back to 0.6.259 restores the previous (slightly more verbose) render_adapter() prose; rendered CLAUDE.md/AGENTS.md output would grow back toward the byte ceiling but remains within it either way.",
        ]
    elif parsed_version == version_tuple("0.6.259"):
        wip_safe = False
        required_migrations = []
        optional_migrations = [
            {
                "id": "install-minervit-delivery-ops-plugin-for-makerkit",
                "description": (
                    "Lanes that invoke the makerkit-implementation skill from their AI runtime "
                    "should install or enable the minervit-delivery-ops plugin (mirroring the "
                    "0.6.150 core-to-ops split); the skill is no longer packaged with the core "
                    "methodology plugin. CLI commands continue to resolve their assets from the "
                    "methodology checkout regardless of which plugins are enabled."
                ),
            }
        ]
        behavior_changes = [
            "The makerkit-implementation skill moved from the core methodology plugin (plugins/minervit-ai-delivery-methodology/skills/examples/makerkit-implementation/) to the delivery-ops plugin (plugins/minervit-delivery-ops/skills/makerkit-implementation/), with git history preserved via git mv; the core plugin's examples/README.md table row for it was removed",
            "tests/test_skill_split_public_quality.py repoints MAKERKIT_IMPLEMENTATION_SKILL and MAKERKIT_IMPLEMENTATION_REFERENCE at the new ops-plugin location, adds drizzle-kit to CORE_OPS_PROVIDER_TERMS, and drops the examples/ exemption from the core-plugin forbidden-term scan so it now covers the full core skill tree",
            "the internal productization refactor plan has the makerkit skill-split recommendation's four steps ticked, including the product-chat disposition item (no product-chat/product-chat-notes skill ever existed in this repo's history; publish-product-note has always been the shipping CLI surface)",
            "lane startup, hooks, adapters, generated files, product worktrees, release tracks, and AI-facing policy are otherwise unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Because this release removes an AI runtime skill surface from the core plugin (mirroring 0.6.150), it is not WIP-safe for automatic active-lane upgrades: a lane with only the core plugin installed that is mid-work on a MakerKit task would lose access to makerkit-implementation until it also installs/enables minervit-delivery-ops.",
            "Pinning back to 0.6.258 restores makerkit-implementation under the core plugin's examples/ subtree; any downstream skill-path references to the ops-plugin location would need to be repointed back.",
        ]
    elif parsed_version == version_tuple("0.6.258"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Verified the methodology repo's own self-adapter gates (lane-start, methodology-status --fail-on-drift) already pass against the tracked .minervit/adapter.json; no adapter schema violation existed (self-adapter verification task)",
            "A prior in-flight pass on this task had incorrectly removed workProfiles.prePushBase, believing it invalid; release-time Codex review and independent schema validation confirmed the key is valid (methodology/adapter-schema.json) and load-bearing for non-development work-profile pre-push base resolution on this repo's origin/experimental release track, so it was restored before shipping",
            "the internal productization refactor plan has the self-adapter verification checkbox ticked and its tracked finding marked closed, with the corrected verification narrative",
            "lane startup, hooks, adapters, generated files, product worktrees, release tracks, and AI-facing policy are otherwise unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required for downstream products; this release only corrects the methodology repo's own self-adapter documentation/verification trail.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because .minervit/adapter.json ends this release with the same functional workProfiles.prePushBase value it had before the self-adapter verification task began; only release metadata and docs changed net.",
            "Pinning back to 0.6.257 is safe; the self-adapter's functional state (including workProfiles.prePushBase) is unchanged between 0.6.257 and 0.6.258.",
        ]
    elif parsed_version == version_tuple("0.6.257"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "the internal validate.sh disposition plan gains a Spot-Audit section recording, for each of the 5 named invariant groups, a deliberately-broken probe and the pytest result",
            "The policy-phrase SSOT row's test citation is corrected from tests/test_policy_modules.py (which does not read methodology/policy-phrases.json and did not catch the break) to tests/test_policy_phrases_ssot.py (which does)",
            "the internal productization refactor plan has the validate.sh spot-audit checkbox ticked with a completion note",
            "lane startup, hooks, adapters, generated files, product worktrees, release tracks, and AI-facing policy are otherwise unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it only changes planning/disposition documentation text, not runtime behavior.",
            "Pinning back to 0.6.256 restores the previous disposition-doc citation for the policy-phrase SSOT row.",
        ]
    elif parsed_version == version_tuple("0.6.256"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "methodology/policy/21-lane-lifecycle.md (and the canonical-rules.md it renders into) no longer names MINERVIT_METHODOLOGY_RELEASE_GOOGLE_CHAT_WEBHOOK; the release-announcement rule is now role-neutral (announce through the ops plugin)",
            "docs/reference/operations/release-engineering.md gains a Release Announcements section documenting the webhook environment variable and its fallbacks that moved out of canonical policy",
            "tests/test_canonical_rules.py no longer pins MINERVIT_METHODOLOGY_RELEASE_GOOGLE_CHAT_WEBHOOK as a required startup/release/authority marker",
            "lane startup, hooks, adapters, generated files, product worktrees, release tracks, and AI-facing policy are otherwise unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it only changes documentation text and a test assertion, not runtime behavior.",
            "Pinning back to 0.6.255 restores the previous canonical-policy wording that named the webhook environment variable directly.",
        ]
    elif parsed_version == version_tuple("0.6.255"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "CLAUDE.md, CONTRIBUTING.md, and the 21-lane-lifecycle.md policy source no longer instruct contributors to run scripts/validate.sh as a second required gate alongside scripts/test.sh",
            "validate.sh is documented as the frozen 5-line alias of test.sh it already is, kept only for legacy automation",
            "tests/test_validate_freeze.py assertions updated to match the single-gate documentation wording",
            "lane startup, hooks, adapters, generated files, product worktrees, release tracks, and AI-facing policy are otherwise unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it only changes documentation text and a test assertion, not runtime behavior.",
            "Pinning back to 0.6.254 restores the previous dual-gate documentation wording.",
        ]
    elif parsed_version == version_tuple("0.6.254"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Contact and identifier references on shippable surfaces (CODE_OF_CONDUCT.md, the adapter JSON Schema $id, and the bootstrap legacy allowlist JSON Schema $id) now use minervit.com as the single canonical contact domain",
            "A new test_public_boundary_scan.py check (test_shippable_surfaces_unify_contact_domain_to_minervit_com) enforces the domain unification on generic methodology surfaces going forward",
            "lane startup, hooks, adapters, generated files, product worktrees, release tracks, and AI-facing policy are otherwise unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it only changes documentation-facing contact/identifier text and an enforcement test, not runtime behavior.",
            "Pinning back to 0.6.253 restores the previous non-canonical contact domain references on these shippable surfaces.",
        ]
    elif parsed_version == version_tuple("0.6.253"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "14 Stop-hook checks in response_guard_errors (13 previously identified plus stop.autonomous_yield_too_terse, found during independent verification) are re-tagged from state to phrase mechanism in guard-events.jsonl telemetry; no predicate logic changed",
            "response_guard_errors's phraseChecks default flips from blocking to advisory so these prose heuristics no longer hard-block Stop by default; the genuinely state-based gates (response_guard_state_stop_errors, response_guard_goal_boundary_errors) are untouched and still hard-block",
            "stop.recovery_cancellation_without_explicit_stop reads only transcript text, so it is also re-tagged phrase and now honors phraseChecks (advisory by default; still blocks when phraseChecks is blocking)",
            "The standalone response-guard CLI subcommand now resolves responseGuard.phraseChecks from the adapter the same way the Stop hook does, with a new --phrase-checks {advisory,blocking,off} override flag",
            "Fixes a wiring bug where stop.boundary_summary errors bypassed log_guard's return value and always hard-blocked regardless of mode",
            "lane startup, hooks, adapters, generated files, product worktrees, release tracks, and AI-facing policy are otherwise unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because operators who rely on legacy hard-blocking behavior can pass --phrase-checks blocking or set responseGuard.phraseChecks: blocking in their adapter to restore it.",
            "Pinning back to 0.6.252 restores the previous state/phrase mechanism tagging and the blocking-by-default phraseChecks behavior.",
        ]
    elif parsed_version == version_tuple("0.6.252"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Phase 5.1 CLI package split continues: guard phrase vocabulary collection and policy phrase projection rendering move from bin/minervit-methodology into src/minervit_methodology/policy.py",
            "bin/minervit-methodology keeps policy_phrase_constants, render_policy_phrases_json, and render_policy_phrases_reference compatibility wrappers",
            "dump-policy-phrases, generated methodology/policy-phrases.json, generated methodology/policy-phrases-reference.md, output, exit codes, and file formats are unchanged",
            "lane startup, hooks, adapters, generated files, product worktrees, release tracks, and AI-facing policy are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it preserves existing policy phrase projection behavior behind compatibility wrappers.",
            "Pinning back to 0.6.251 restores the previous in-bin policy phrase helper implementation while keeping the same command behavior.",
        ]
    elif parsed_version == version_tuple("0.6.251"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "The productization refactor plan document is revised after an adversarial progress review: verified per-phase status, a findings register, refutations of the original plan, an execution-ready remaining-work backlog with per-task model routing, and an OSS launch checklist",
            "Documentation only: commands, hooks, adapters, generated files, release tracks, and AI-facing policy are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it is documentation-only and changes no runtime behavior.",
            "Pinning back to 0.6.250 restores the previous plan document text.",
        ]
    elif parsed_version == version_tuple("0.6.250"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Phase 5.1 CLI package split continues: blocker declaration validation, blocker record construction, blocker status output, and pre-push blocker state result helpers move from bin/minervit-methodology into src/minervit_methodology/guards.py",
            "bin/minervit-methodology keeps blocker_declare_error, blocker_declare_record, blocker_status_result, and guard_check_blocker_state_result compatibility wrappers",
            "bin/minervit-methodology keeps stdlib-only fallback implementations for guard helpers so copied CLI launchers without src/minervit_methodology, or launchers paired with a stale helper package, keep blocker command and response-guard behavior instead of raising ImportError or AttributeError",
            "blocker-declare, blocker-status, blocker-clear, guard-check, response-guard-hook, output, exit codes, and file formats are unchanged",
            "lane startup, hooks, adapters, generated files, product worktrees, release tracks, and AI-facing policy are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it preserves existing blocker command and guard-check behavior behind compatibility wrappers plus copied-bin/stale-package fallbacks.",
            "Pinning back to 0.6.249 restores the previous in-bin blocker command helper implementation while keeping the same command behavior.",
        ]
    elif parsed_version == version_tuple("0.6.249"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Phase 5.1 CLI package split continues: public-release private adapter discovery, private-term parsing, term-source reporting, and private terms file export safety checks move from bin/minervit-methodology into src/minervit_methodology/public_release.py",
            "bin/minervit-methodology keeps compatibility wrappers and standalone fallback implementations for copied CLI launchers that cannot import src/minervit_methodology",
            "public-release-check and public-release-export output, exit codes, private-term scanning, dirty/untracked path filtering, and clean export behavior are unchanged",
            "lane startup, hooks, adapters, product worktrees, release tracks, and AI-facing policy are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it preserves existing public-release behavior behind compatibility wrappers and standalone fallback helpers.",
            "Pinning back to 0.6.248 restores the previous in-bin public-release private-term helper implementation while keeping the same command behavior.",
        ]
    elif parsed_version == version_tuple("0.6.248"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Phase 5.1 CLI package split continues: adapter render-budget defaults, override merging, and generated Markdown size checks move from bin/minervit-methodology into src/minervit_methodology/adapter.py",
            "bin/minervit-methodology keeps render_budget_for and render_budget_errors compatibility wrappers",
            "render-adapters budget warnings/errors, output, exit codes, generated files, and adapter formats are unchanged",
            "lane startup, hooks, adapters, product worktrees, release tracks, and AI-facing policy are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it preserves existing render-budget behavior behind compatibility wrappers.",
            "Pinning back to 0.6.247 restores the previous in-bin render-budget helper implementation while keeping the same command behavior.",
        ]
    elif parsed_version == version_tuple("0.6.247"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Phase 5.1 CLI package split continues: adapter schema loading, stdlib JSON-schema validation, adapter JSON decode hints, and schema error rendering move from bin/minervit-methodology into src/minervit_methodology/adapter.py",
            "bin/minervit-methodology keeps adapter helper compatibility wrappers for _adapter_schema, _schema_type_matches, schema_validation_errors, project_json_decode_error, and validate_adapter_schema",
            "validate-adapter, render-adapters, lane startup, adapter drift checks, output, exit codes, and file formats are unchanged",
            "lane startup, hooks, adapters, generated files, product worktrees, release tracks, and AI-facing policy are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it preserves existing adapter schema behavior behind compatibility wrappers.",
            "Pinning back to 0.6.246 restores the previous in-bin adapter schema helper implementation while keeping the same command behavior.",
        ]
    elif parsed_version == version_tuple("0.6.246"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Phase 5.1 CLI package split continues: GitHub provider cache, lock, telemetry, retry, and rate-budget primitives move from bin/minervit-methodology into src/minervit_methodology/ghutil.py",
            "bin/minervit-methodology keeps GitHub helper compatibility wrappers plus the existing live command/provider-client flow",
            "GitHub request locking, operation coalescing, shared snapshot cache, pending retry, budget status, output, exit codes, and file formats are unchanged",
            "lane startup, hooks, adapters, generated files, product worktrees, release tracks, and AI-facing policy are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it preserves existing GitHub provider behavior behind compatibility wrappers.",
            "Pinning back to 0.6.245 restores the previous in-bin GitHub helper implementation while keeping the same command behavior.",
        ]
    elif parsed_version == version_tuple("0.6.245"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Phase 5.1 CLI package split continues: lane-local blocker record path, checked-list parsing, JSON read, and freshness helpers move from bin/minervit-methodology into src/minervit_methodology/guards.py",
            "bin/minervit-methodology keeps blocker_record_path, parse_blocker_checked, read_blocker_record, and blocker_freshness compatibility wrappers",
            "blocker-declare, blocker-status, blocker-clear, response-guard-hook, and guard-check output, exit codes, and file formats are unchanged",
            "lane startup, hooks, adapters, generated files, product worktrees, release tracks, and AI-facing policy are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it preserves existing blocker-state behavior behind compatibility wrappers.",
            "Pinning back to 0.6.244 restores the previous in-bin blocker-state helper implementation while keeping the same command behavior.",
        ]
    elif parsed_version == version_tuple("0.6.244"):
        wip_safe = True
        if not products_tested:
            products_tested = ["methodology-framework", PUBLIC_REFERENCE_ADAPTER_SLUG]
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "The methodology repository now has a repo-local, public-safe source adapter at .minervit/adapter.json",
            "The committed .minervit-ai-delivery.json is generated from that source adapter so lane-start and methodology-status no longer fail with 'No project adapter found' in the framework repo",
            "lane-start in the methodology repo preserves handwritten AGENTS.md and CLAUDE.md and regenerates only the lane JSON unless a reviewed migration intentionally moves those bootstrap files to generated sources",
            "The methodology repo's generated lane JSON uses a stable self-referential methodologyCommit marker so render-adapters --check is not invalidated by the commit that contains the generated file",
            "The methodology self-adapter declares provider-neutral framework development policy, WSL2 as the supported Windows path, no production deploy target, and scripts/test.sh plus scripts/validate.sh as local release gates",
            "render-adapters now emits the deprecated goalTracker warning only when the raw source adapter declares goalTracker, avoiding false warnings from compatibility defaults",
            "Generated Git hooks prefer the methodology CLI path that generated the hook before falling back to MINERVIT_METHODOLOGY_REPO, preventing stale global checkouts from shadowing newer hook commands",
            "Strict review evidence no longer accepts adapter wrapper bases that point at local refs such as main; scoped review bases must be remote refs such as origin/main or origin/experimental",
            "GitHub request and operation locks warn and continue when the local cache path is unavailable instead of crashing startup/status commands",
            "Branch-liveness checks treat experimental as a framework base branch for release-channel work",
            "Existing product/client adapters, generated files, release-track pins, update behavior, hooks, and product lane startup behavior are unchanged",
        ]
        rollback_notes = [
            "No client adapter migration is required; existing product/client lanes stay on their current stable or experimental pins.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it adds methodology-repo self-hosting adapter config, narrows false deprecation/review-base behavior, and makes GitHub lock failures non-crashing.",
            "Pinning back to 0.6.243 restores the previous framework repo behavior, where methodology-status/lane-start require an external adapter before the repo can self-host gates.",
        ]
    elif parsed_version == version_tuple("0.6.243"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Phase 5.1 CLI package split continues: public-release export marker, path-filter, issue-deduplication, and account-like token helper primitives move from bin/minervit-methodology into src/minervit_methodology/public_release.py",
            "bin/minervit-methodology keeps compatibility wrappers for public-release helper primitives while passing the CLI constants explicitly",
            "A standalone copied-bin fallback keeps public-release helper behavior available when src/minervit_methodology is absent; future slices should remove this duplicate after standalone launchers ship package code",
            "public-release-check and public-release-export output, private-term scanning, dirty/untracked path filtering, and clean export behavior are unchanged",
            "lane startup, hooks, adapters, generated files, product worktrees, release tracks, and AI-facing policy are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it preserves existing public-release check/export behavior behind compatibility wrappers.",
            "Pinning back to 0.6.242 restores the previous in-bin public-release helper implementation while keeping the same command behavior.",
        ]
    elif parsed_version == version_tuple("0.6.242"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Phase 5.1 CLI package split continues: milestone-update content hash, marker-record/write, and marker dedupe helpers move from bin/minervit-methodology into src/minervit_methodology/chat.py",
            "publish-milestone-update keeps the same dedupe identity, delivery marker JSON fields, marker formatting, and Chat posting behavior",
            "Corrupt milestone-update delivery markers containing valid non-object JSON are treated as missing markers instead of crashing during dedupe",
            "lane startup, hooks, adapters, generated files, product worktrees, release tracks, and AI-facing policy are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it preserves existing milestone-update marker and dedupe behavior.",
            "Pinning back to 0.6.241 restores the previous in-bin milestone marker helper implementation while keeping the same command behavior.",
        ]
    elif parsed_version == version_tuple("0.6.241"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Phase 5.1 CLI package split continues: milestone-update and product-note content-input plus webhook URL helpers move from bin/minervit-methodology into src/minervit_methodology/chat.py",
            "bin/minervit-methodology keeps milestone_update_content_from_args and product_note_content_from_args compatibility wrappers",
            "publish-milestone-update and publish-product-note input validation, dry-run webhook fallback, webhook error messages, and Chat posting behavior are unchanged",
            "lane startup, hooks, adapters, generated files, product worktrees, release tracks, and AI-facing policy are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it preserves existing milestone-update and product-note input/webhook behavior.",
            "Pinning back to 0.6.240 restores the previous in-bin Chat input/webhook helper implementation while keeping the same command behavior.",
        ]
    elif parsed_version == version_tuple("0.6.240"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Phase 5.1 CLI package split continues: release-update version selection plus status summary/text rendering helpers move from bin/minervit-methodology into src/minervit_methodology/releases.py",
            "bin/minervit-methodology keeps the release_update_versions compatibility wrapper",
            "publish-release-update --version/--last selection, release-update-status JSON/text output, and release-update delivery accounting are unchanged",
            "lane startup, hooks, adapters, generated files, product worktrees, release tracks, and AI-facing policy are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it preserves existing release-update selection, status rendering, and accounting behavior.",
            "Pinning back to 0.6.239 restores the previous in-bin release-update selection/status implementation while keeping the same command behavior.",
        ]
    elif parsed_version == version_tuple("0.6.239"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Phase 5.1 CLI package split continues: deploy-ready notification extra validation, identity hash, webhook URL, and marker-record/write helpers move from bin/minervit-methodology into src/minervit_methodology/deploy.py",
            "publish-deploy-ready-update keeps the same validation messages, dedupe identity basis, dry-run webhook fallback, and marker JSON fields",
            "deployment-notification command output, adapter behavior, and deployment-notification gate decisions are unchanged",
            "lane startup, hooks, adapters, generated files, product worktrees, release tracks, and AI-facing policy are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it preserves existing deploy-ready validation, dedupe, webhook, and marker behavior.",
            "Pinning back to 0.6.238 restores the previous in-bin deploy-ready helper implementation while keeping the same command behavior.",
        ]
    elif parsed_version == version_tuple("0.6.238"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Phase 5.1 CLI package split continues: deployment-notification marker-path, latest-marker, CI-detection, source selection, and content-input helpers move from bin/minervit-methodology into src/minervit_methodology/deploy.py",
            "bin/minervit-methodology keeps deployment_notification_marker_path, deployment_notification_latest_marker, deployment_notification_ci_detected, deployment_notification_source, and deployment_notification_content_from_args compatibility wrappers",
            "deploy-ready notification dedupe, latest-marker status output, pipeline/agent source behavior, and deployment-notification gate decisions are unchanged",
            "lane startup, hooks, adapters, generated files, product worktrees, release tracks, and AI-facing policy are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it preserves existing deployment-notification marker/source/content behavior behind compatibility wrappers.",
            "Pinning back to 0.6.237 restores the previous in-bin deployment-notification marker/source/content helper implementation while keeping the same command behavior.",
        ]
    elif parsed_version == version_tuple("0.6.237"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Phase 5.1 CLI package split continues: deployment-notification config lookup, effective webhook resolution, and deploy-pipeline evidence checks move from bin/minervit-methodology into src/minervit_methodology/deploy.py",
            "bin/minervit-methodology keeps deployment_notification_config, deployment_notification_effective_webhook_env, and deployment_notification_pipeline_issues compatibility wrappers",
            "deployment-notification-status output, deploy-pipeline evidence checks, and deployment-notification gate decisions are unchanged",
            "lane startup, hooks, adapters, generated files, product worktrees, release tracks, and AI-facing policy are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it preserves existing deployment-notification behavior behind compatibility wrappers.",
            "Pinning back to 0.6.236 restores the previous in-bin deployment-notification helper implementation while keeping the same command behavior.",
        ]
    elif parsed_version == version_tuple("0.6.236"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Phase 5.1 CLI package split continues: deploy-health run-file loading, GitHub Actions query handling, and per-target issue orchestration move from bin/minervit-methodology into src/minervit_methodology/deploy.py",
            "bin/minervit-methodology keeps deploy_health_runs_from_file, deploy_health_github_actions_runs, and deploy_health_issues compatibility wrappers",
            "deploy-health command output, offline --runs-file behavior, GitHub Actions command arguments, and deployment gate decisions are unchanged",
            "lane startup, hooks, adapters, generated files, product worktrees, release tracks, and AI-facing policy are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it preserves existing deploy-health behavior behind compatibility wrappers.",
            "Pinning back to 0.6.235 restores the previous in-bin deploy-health I/O helper implementation while keeping the same command behavior.",
        ]
    elif parsed_version == version_tuple("0.6.235"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Phase 5.1 CLI package split continues: pure deploy-health configuration and run-analysis helpers move from bin/minervit-methodology into src/minervit_methodology/deploy.py",
            "bin/minervit-methodology keeps normalize_deploy_health_config, deploy_health_configured_targets, deploy_health_parse_time, deploy_health_sorted_runs, deploy_health_completed_runs, deploy_health_run_label, and deploy_health_issues_for_runs compatibility wrappers",
            "deploy-health command output, offline --runs-file behavior, GitHub Actions query behavior, and deployment gate decisions are unchanged",
            "lane startup, hooks, adapters, generated files, product worktrees, release tracks, and AI-facing policy are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it preserves existing deploy-health behavior behind compatibility wrappers.",
            "Pinning back to 0.6.234 restores the previous in-bin deploy-health helper implementation while keeping the same command behavior.",
        ]
    elif parsed_version == version_tuple("0.6.234"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Phase 5.1 CLI package split continues: Google Chat content validation helpers move from bin/minervit-methodology into src/minervit_methodology/chat.py",
            "bin/minervit-methodology keeps validate_milestone_update_content, validate_product_note, and validate_deployment_notification_content compatibility wrappers",
            "publish-milestone-update, publish-product-note, and publish-deploy-ready-update validation messages and blocking behavior are unchanged",
            "lane startup, hooks, adapters, generated files, product worktrees, release tracks, and AI-facing policy are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it preserves existing notification validation behavior behind compatibility wrappers.",
            "Pinning back to 0.6.233 restores the previous in-bin validation implementation while keeping the same command behavior.",
        ]
    elif parsed_version == version_tuple("0.6.233"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Phase 5.1 CLI package split continues: Google Chat payload helpers move from bin/minervit-methodology into src/minervit_methodology/chat.py",
            "bin/minervit-methodology keeps milestone_update_sections, milestone_update_card_text, milestone_update_payload, product_note_payload, deployment_notification_payload, and google_chat_card_only compatibility wrappers",
            "publish-milestone-update, publish-product-note, publish-deploy-ready-update, and publish-release-update card-only behavior are unchanged",
            "lane startup, hooks, adapters, generated files, product worktrees, release tracks, and AI-facing policy are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it preserves existing Google Chat payload behavior behind compatibility wrappers.",
            "Pinning back to 0.6.232 restores the previous in-bin payload implementation while keeping the same command behavior.",
        ]
    elif parsed_version == version_tuple("0.6.232"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Phase 5.1 CLI package split continues: release-update rendering helpers move from bin/minervit-methodology into src/minervit_methodology/releases.py",
            "bin/minervit-methodology keeps release_update_block, release_update_lines, and release_update_payload compatibility wrappers",
            "publish-release-update dry-run text, Google Chat card payloads, release metadata lookup, and release-update publishing behavior are unchanged",
            "lane startup, hooks, adapters, generated files, product worktrees, release tracks, and AI-facing policy are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it preserves existing release-update behavior behind compatibility wrappers.",
            "Pinning back to 0.6.231 restores the previous in-bin rendering implementation while keeping the same command behavior.",
        ]
    elif parsed_version == version_tuple("0.6.231"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Phase 5.1 CLI package split continues: package name availability helper logic moves from bin/minervit-methodology into src/minervit_methodology/names.py",
            "bin/minervit-methodology keeps the name_availability compatibility wrapper and passes the CLI's urlopen, Request, and redaction seams explicitly",
            "check-name-availability continues to check npm and PyPI package names, report 404 as available, report 200 as taken, and degrade network errors to unknown",
            "lane startup, hooks, adapters, generated files, product worktrees, release tracks, and AI-facing policy are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it preserves existing name-availability behavior behind a compatibility wrapper.",
            "Pinning back to 0.6.230 restores the previous in-bin implementation while keeping the same command behavior.",
        ]
    elif parsed_version == version_tuple("0.6.230"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Phase 5.1 CLI package split continues: installed environment-file helper primitives move from bin/minervit-methodology into src/minervit_methodology/util.py",
            "bin/minervit-methodology keeps compatibility wrappers for user_config_env_value and env_value_with_user_config_fallback while passing the CLI-configured env paths explicitly",
            "publish-release-update webhook lookup continues to check process environment, installed methodology.env, and user secrets in the same order",
            "lane startup, hooks, adapters, generated files, product worktrees, release tracks, and AI-facing policy are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it preserves existing installed-env lookup behavior behind compatibility wrappers.",
            "Pinning back to 0.6.229 restores the previous in-bin implementation while keeping the same command behavior.",
        ]
    elif parsed_version == version_tuple("0.6.229"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Phase 5.1 CLI package split continues: release-update accounting helpers move from bin/minervit-methodology into src/minervit_methodology/releases.py",
            "bin/minervit-methodology keeps lazy compatibility wrappers with verbatim signatures for release_update_version_key, release_update_delivery_load, release_update_record_delivery, release_update_overdue_versions, and related accounting helpers",
            "release-update delivery ledgers, documented suspension records, public-release checks, and publish-release-update command behavior are unchanged",
            "lane startup, hooks, adapters, generated files, product worktrees, release tracks, and AI-facing policy are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it preserves existing release-update accounting behavior behind compatibility wrappers.",
            "Pinning back to 0.6.228 restores the previous in-bin implementation while keeping the same command behavior.",
        ]
    elif parsed_version == version_tuple("0.6.228"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Phase 5.1 CLI package split continues: opt-in telemetry and guard-event helper primitives move from bin/minervit-methodology into src/minervit_methodology/telemetry.py",
            "bin/minervit-methodology keeps lazy compatibility wrappers with verbatim signatures for telemetry_enabled, telemetry_path, record_gate_telemetry, guard_log_event, parse_guard_report_since, and guard_event_after_since",
            "cut-release checksums src/minervit_methodology/telemetry.py so the immutable release boundary covers the newly extracted implementation",
            "lane startup, hooks, adapters, generated files, product worktrees, telemetry behavior, guard decisions, and AI-facing policy are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it preserves existing telemetry and guard-event behavior behind compatibility wrappers.",
            "Pinning back to 0.6.227 restores the previous in-bin implementation while keeping the same command behavior.",
        ]
    elif parsed_version == version_tuple("0.6.227"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Phase 5.1 CLI package split continues: work-profile file classification helpers move from bin/minervit-methodology into src/minervit_methodology/profiles.py",
            "bin/minervit-methodology keeps lazy compatibility wrappers with verbatim signatures for path_matches_work_profile_pattern, git_blob_sizes, work_profile_committed_file_metadata, and work_profile_file_issues",
            "cut-release checksums src/minervit_methodology/profiles.py so the immutable release boundary covers the newly extracted implementation",
            "lane startup, hooks, adapters, generated files, product worktrees, work-profile runtime behavior, and AI-facing policy are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it preserves existing work-profile behavior behind compatibility wrappers.",
            "Pinning back to 0.6.226 restores the previous in-bin implementation while keeping the same command behavior.",
        ]
    elif parsed_version == version_tuple("0.6.226"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "scripts/validate.sh is now a thin compatibility wrapper around scripts/test.sh",
            "the legacy shell assertion body has been removed after every substantive assertion group was ported to pytest or marked obsolete",
            "the validate workflow now installs the pinned dev toolchain before invoking the wrapper so CI continues to exercise the compatibility command",
            "lane startup, hooks, adapters, generated files, product worktrees, AI-facing policy, and runtime CLI behavior are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it removes duplicate shell validation after equivalent pytest coverage is already present.",
            "Pinning back to 0.6.225 restores the previous large validate.sh body if a contributor environment unexpectedly depends on shell-only validation internals.",
        ]
    elif parsed_version == version_tuple("0.6.225"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "plan-review and plan-finalization shell fixtures are now covered by focused subprocess pytest coverage",
            "forbidden role handling, quoted source role allowances, user-supplied review argument rejection, plan-only run metadata, stale review markers, finalize metadata validation, diagnostic record-plan-review distrust, classified-finding count checks, round caps, log blocker detection, addressed Important findings, and Codex fast-mode injection/opt-out are pinned outside validate.sh",
            "the validate.sh retirement disposition now records row 7384-7870 as pytest-backed",
            "lane startup, hooks, adapters, generated files, product worktrees, plan-review runtime behavior, and AI-facing policy are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it only adds pytest coverage and disposition evidence for existing plan-review and plan-finalization behavior.",
            "Pinning back to 0.6.224 restores the previous test/disposition coverage while keeping runtime behavior unchanged.",
        ]
    elif parsed_version == version_tuple("0.6.224"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "lane-start planning shell fixtures are now covered by focused subprocess pytest coverage",
            "planning source/template drift, relevant scratch-plan migration, parallel scratch-plan migration contention, generated-adapter drift repair, lane-env file/status output, lane-specific port derivation, and lane-run env loading are pinned outside validate.sh",
            "the validate.sh retirement disposition now records row 6441-6637 as pytest-backed together with existing document-context coverage",
            "lane startup, hooks, adapters, generated files, product worktrees, and runtime behavior are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it only adds pytest coverage and disposition evidence for existing lane-start planning, drift, and lane-env behavior.",
            "Pinning back to 0.6.223 restores the previous test/disposition coverage while keeping runtime behavior unchanged.",
        ]
    elif parsed_version == version_tuple("0.6.223"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "event and usage accounting shell fixtures are now covered by focused subprocess pytest coverage",
            "event-log machine-local state paths, event audit terminal-event enforcement, rotated event reads, event secret/length rejection, usage record/import/dedupe/report/rotation, and usage secret-ref rejection are pinned outside validate.sh",
            "deployment notification and Product Milestones update shell fixtures are now covered by focused subprocess pytest coverage",
            "deployment webhook readiness, deploy-ready dry-run publication, Google Chat webhook URL rejection, deploy pipeline evidence/command guards, milestone update canonical slugging, required headings, and secret webhook rejection are pinned outside validate.sh",
            "the validate.sh retirement disposition now records row 2707-3315 as pytest-backed",
            "lane startup, hooks, adapters, generated files, product worktrees, and runtime behavior are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it only adds pytest coverage and disposition evidence for existing event, usage, deployment notification, and milestone update behavior.",
            "Pinning back to 0.6.222 restores the previous test/disposition coverage while keeping runtime behavior unchanged.",
        ]
    elif parsed_version == version_tuple("0.6.222"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "session journal validation and publish shell fixtures are now covered by focused subprocess pytest coverage",
            "prepare/validate/publish idempotency, Graphify runtime evidence, secret-looking value rejection, raw transcript rejection, process-authority rejection, and local release-checkout write guarding are pinned outside validate.sh",
            "the validate.sh retirement disposition now records the 2294-2706 session-journal remainder as pytest-backed, closing that row",
            "lane startup, hooks, adapters, generated files, product worktrees, and session journal runtime behavior are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it only adds pytest coverage and disposition evidence for existing session-journal behavior.",
            "Pinning back to 0.6.221 restores the previous test/disposition coverage while keeping runtime behavior unchanged.",
        ]
    elif parsed_version == version_tuple("0.6.221"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "milestone ledger and tactical queue shell fixtures are now covered by focused subprocess pytest coverage",
            "milestone continuation, PR queued/merged/abandoned transitions, true-blocker output, queue heading parsing, missed queued-event recovery, stateful refresh drift, retargeting, watchdog non-authority, work-loop next-action, and generic implementation-plan rejection are pinned outside validate.sh",
            "the validate.sh retirement disposition now records milestone ledger and queue coverage as pytest-backed, narrowing remaining Phase 5.2 port work",
            "lane startup, hooks, adapters, generated files, and product worktrees are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it only adds pytest coverage and disposition evidence for existing milestone ledger behavior.",
            "Pinning back to 0.6.220 restores the previous test/disposition coverage while keeping runtime behavior unchanged.",
        ]
    elif parsed_version == version_tuple("0.6.220"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "sync-methodology shell git-remote rescue fixtures are now covered by focused subprocess pytest coverage",
            "clean non-release branch rescue, divergent main repair, dirty stale fail-closed paths, dirty stale auto-rescue, stable/manual lane-start skip behavior, and dirty-current explicit rescue behavior are pinned outside validate.sh",
            "the validate.sh retirement disposition now records sync/rescue CLI coverage as pytest-backed, narrowing remaining Phase 5.2 port work",
            "lane startup, hooks, adapters, generated files, and product worktrees are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it only adds pytest coverage and disposition evidence for existing sync/rescue behavior.",
            "Pinning back to 0.6.219 restores the previous test/disposition coverage while keeping runtime behavior unchanged.",
        ]
    elif parsed_version == version_tuple("0.6.219"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Graphify shell fixtures are now covered by focused subprocess pytest coverage",
            "Graphify missing-output, stale-output, current-output, tracked-output, lane-start gitignore, and installed hook behavior are pinned outside validate.sh",
            "the validate.sh retirement disposition now records Graphify CLI and hook coverage as pytest-backed, narrowing remaining Phase 5.2 port work",
            "lane startup, hooks, adapters, generated files, and product worktrees are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it only adds pytest coverage and disposition evidence for existing Graphify behavior.",
            "Pinning back to 0.6.218 restores the previous test/disposition coverage while keeping runtime behavior unchanged.",
        ]
    elif parsed_version == version_tuple("0.6.218"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "lane-coordination shell fixtures are now covered by focused subprocess pytest coverage",
            "missing coordination artifacts, bootstrap creation, lane status note updates, untracked artifacts, lane-start bootstrap, and pushed/unpushed current-lane status are pinned outside validate.sh",
            "the validate.sh retirement disposition now records lane-coordination CLI coverage as pytest-backed, narrowing remaining Phase 5.2 port work",
            "lane startup, hooks, adapters, generated files, and product worktrees are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it only adds pytest coverage and disposition evidence for existing lane-coordination behavior.",
            "Pinning back to 0.6.217 restores the previous test/disposition coverage while keeping runtime behavior unchanged.",
        ]
    elif parsed_version == version_tuple("0.6.217"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "strict document-context adapter enforcement shell fixtures are now covered by focused subprocess pytest coverage",
            "methodology lock, relock archive, invalid-lock reporting, lane-start lock visibility, and unlock behavior are pinned outside validate.sh",
            "the validate.sh retirement disposition now records strict document-context and methodology-lock CLI coverage as pytest-backed, narrowing remaining Phase 5.2 port work",
            "lane startup, hooks, adapters, generated files, and product worktrees are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it only adds pytest coverage and disposition evidence for existing document-context and methodology-lock behavior.",
            "Pinning back to 0.6.216 restores the previous test/disposition coverage while keeping runtime behavior unchanged.",
        ]
    elif parsed_version == version_tuple("0.6.216"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "work-loop --dry-run execution-packet shell fixtures are now covered by focused subprocess pytest coverage",
            "prepare-continuity handoff output, portable CLI fallback wording, startup-gate ordering, and stdout redaction are pinned outside validate.sh",
            "the validate.sh retirement disposition now records work-loop and continuity CLI coverage as pytest-backed, narrowing remaining Phase 5.2 port work",
            "lane startup, hooks, adapters, generated files, and product worktrees are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it only adds pytest coverage and disposition evidence for existing work-loop and continuity behavior.",
            "Pinning back to 0.6.215 restores the previous test/disposition coverage while keeping runtime behavior unchanged.",
        ]
    elif parsed_version == version_tuple("0.6.215"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "document-context context-status and context-bootstrap shell fixtures are now covered by focused subprocess pytest coverage",
            "missing context indexes, strict context-status failure, context index/archive bootstrap, Needs Classification entries, archive-header repair, and final strict clean status are pinned outside validate.sh",
            "the validate.sh retirement disposition now records document-context CLI coverage as pytest-backed, narrowing remaining Phase 5.2 port work",
            "lane startup, hooks, adapters, generated files, and product worktrees are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it only adds pytest coverage and disposition evidence for existing document-context behavior.",
            "Pinning back to 0.6.214 restores the previous test/disposition coverage while keeping runtime behavior unchanged.",
        ]
    elif parsed_version == version_tuple("0.6.214"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "generated-adapter overwrite protection from the legacy validate.sh shell fixture is now covered by focused pytest regression tests",
            "render-adapters --write refusal for hand-written CLAUDE.md/AGENTS.md, --json-only preservation, and protected full --check output are pinned outside validate.sh",
            "the validate.sh retirement disposition now records the generated-adapter protection fixture as pytest-covered, narrowing remaining Phase 5.2 port work",
            "lane startup, hooks, adapters, generated files, and product worktrees are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it only adds pytest coverage and disposition evidence for existing render-adapters behavior.",
            "Pinning back to 0.6.213 restores the previous test/disposition coverage while keeping runtime behavior unchanged.",
        ]
    elif parsed_version == version_tuple("0.6.213"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "public-release-check and public-release-export can now read private product/client scan terms from an explicit untracked --private-terms-file",
            "public-release-export still fails closed when no private terms are configured unless --allow-empty-private-terms is intentionally passed",
            "public-release-export refuses --private-terms-file paths inside the methodology repository or export destination so private denylists cannot be copied into public candidates",
            "public-release-export reports only private-term source and count, and the export marker records no private term values",
            "lane startup, hooks, adapters, generated files, and product worktrees are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it only changes maintainer public-release tooling and documentation.",
            f"Pinning back to 0.6.212 restores env-only {PUBLIC_RELEASE_PRIVATE_TERMS_ENV} configuration for public release private-term scans.",
        ]
    elif parsed_version == version_tuple("0.6.212"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "release-update delivery ledgers can now record documented suspension decisions with timestamp, source, scope, and reason",
            "release-update-status and public-release-check count valid suspension records as release accountability evidence while preserving fail-closed behavior for missing or malformed evidence",
            "the productization-refactor release-card suspension window is recorded so public release checks no longer require unwanted retroactive Google Chat posts",
            "lane startup, hooks, adapters, generated files, and product worktrees are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it only changes maintainer release-accountability checks and the release-update ledger.",
            "Pinning back to 0.6.211 restores the prior release-update public-release gate, which requires Google Chat delivery markers and ignores documented suspension evidence.",
        ]
    elif parsed_version == version_tuple("0.6.211"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "shippable public docs no longer expose internal productization finding IDs or private audit-file references",
            "root legal, security, privacy, contributing, changelog, reference, and approved public product docs now use public rationale instead of internal audit provenance",
            "public-boundary regression coverage now rejects internal finding IDs and private audit-document filenames in shippable public text",
            "lane startup, hooks, adapters, generated files, and product worktrees are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it only changes public documentation hygiene, release metadata, and tests.",
            "Pinning back to 0.6.210 restores the prior public docs, including internal audit references that are unsuitable for public release exports.",
        ]
    elif parsed_version == version_tuple("0.6.210"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "public-release-check now blocks repositories whose fetched target remote shares the local root commit, making same-history public publishing fail closed",
            "same-history detection uses local remote-tracking refs instead of network calls so public-release-check remains deterministic and CI-safe",
            "public release diagnostics now report same-history-remote with the matching remote name and root commit prefix, pointing maintainers to public-release-export fresh-history publishing",
            "clean public-release-export repositories remain unaffected because their fresh root commit does not match the private source history",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it only tightens maintainer public-release validation and does not affect lane startup, hooks, adapters, or product worktrees.",
            "Pinning back to 0.6.209 removes the same-history remote diagnostic, so same-history public publishing relies only on private-path history scans.",
        ]
    elif parsed_version == version_tuple("0.6.209"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "public-release-export now excludes internal planning, backlog, and productization docs from clean public repository candidates",
            "docs/product public export keeps only telemetry-consent.md, sbom-and-dependencies.md, trademark-name-clearance.md, and a11y-i18n.md",
            "dirty tracked and untracked files under excluded internal public-export paths no longer block clean export creation because those paths are not exported",
            "public-release-export regression coverage now proves internal docs are absent from the candidate while approved public product docs remain",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it only changes maintainer public-export packaging and does not affect lane startup, hooks, adapters, or product worktrees.",
            "Pinning back to 0.6.208 restores the previous clean-export file set, which could include internal planning/backlog/productization docs.",
        ]
    elif parsed_version == version_tuple("0.6.208"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "public-release-export now validates release-update delivery markers against the exported repository root instead of skipping them for non-source destinations",
            "public-release-export --write reports public_release_export_check: blocked whenever the exported repository's own public-release-check would block on missing release-update delivery evidence",
            "release-update public-release helper functions accept an explicit repo root so clean-history export candidates and in-place source checks use the same release ledger, VERSION, and changelog inputs",
            "release-update delivery ledgers with the wrong schema now degrade to an empty delivered set so malformed public candidates fail closed",
            "minimal test fixture repositories without VERSION metadata remain unaffected; real release candidates with VERSION metadata keep the release-update public-release gate",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it only tightens maintainer public-export validation and does not affect lane startup, hooks, adapters, or product worktrees.",
            "Pinning back to 0.6.207 restores the previous source-side export check behavior, where an export could say ok while the exported repo's own public-release-check blocked on release-update markers.",
        ]
    elif parsed_version == version_tuple("0.6.207"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Phase 5.1 CLI package split continues: pure response-guard scan helpers move from bin/minervit-methodology into src/minervit_methodology/guards.py",
            "bin/minervit-methodology keeps lazy compatibility wrappers with verbatim signatures for text_contains_any, response_guard_policy_scan_text, response_guard_monitor_scan_text, and response_guard_context_scan_text",
            "hook dispatch now fails open for the guided missing-src package SystemExit raised by extracted helper wrappers, preserving the hook fail-open contract for unsupported standalone bin copies",
            "cut-release checksums src/minervit_methodology/guards.py so the immutable release boundary covers the newly extracted implementation",
            "generated adapters, adapter schema, and supported full-checkout command output behavior are unchanged; this is a mechanical extraction for supported installs",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it only moves pure scan helper implementation behind compatibility wrappers without changing guard behavior.",
            "Pinning back to 0.6.206 restores the response-guard scan helper functions inside bin/minervit-methodology; unsupported standalone bin copies regain in-bin response scan helpers but lose the new missing-src hook fail-open handling.",
        ]
    elif parsed_version == version_tuple("0.6.206"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Phase 5.1 CLI package split continues: path, Markdown, and adapter-config helper primitives move from bin/minervit-methodology into src/minervit_methodology/paths.py",
            "bin/minervit-methodology keeps lazy compatibility wrappers with verbatim signatures for configured_path, goal_run_path, milestone_run_path, markdown_section, goal_tracker_config, and backlog_provider_config",
            "cut-release checksums src/minervit_methodology/paths.py so the immutable release boundary covers the newly extracted implementation",
            "runtime hooks, generated adapters, adapter schema, and command output behavior are unchanged; this is a mechanical extraction only",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it only moves leaf helper implementation behind compatibility wrappers without changing lane runtime behavior.",
            "Pinning back to 0.6.205 restores the path/config helper functions inside bin/minervit-methodology but does not change lane runtime behavior.",
        ]
    elif parsed_version == version_tuple("0.6.205"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Phase 5.1 CLI package split continues: leaf Git helpers (repo slug inference, worktree detection, run_git, current branch, upstream branch) move from bin/minervit-methodology into src/minervit_methodology/gitutil.py",
            "bin/minervit-methodology keeps lazy compatibility wrappers with verbatim signatures; current_branch_name and git_branch_upstream still route through the bin-level run_git seam so existing monkeypatches and lane behavior are unchanged",
            "standalone copies of bin/minervit-methodology without src/ remain release-helpers-degraded only: Git helper wrappers now fail with a guided error naming the missing package instead of a traceback",
            "cut-release checksums src/minervit_methodology/gitutil.py so the immutable release boundary keeps covering all distributed implementation",
            "runtime hooks, generated adapters, adapter schema, and command output behavior are unchanged; this is a mechanical extraction only",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it only moves leaf Git helper implementation behind compatibility wrappers without changing lane runtime behavior.",
            "Pinning back to 0.6.204 restores the Git helper functions inside bin/minervit-methodology but does not change lane runtime behavior.",
        ]
    elif parsed_version == version_tuple("0.6.204"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Phase 5.1 CLI package split continues: pure stdlib utility helpers (slug, path, time, version, redaction, atomic-write helpers) move from bin/minervit-methodology into src/minervit_methodology/util.py",
            "bin/minervit-methodology keeps lazy compatibility wrappers with verbatim signatures so all in-checkout behavior is unchanged",
            "standalone copies of bin/minervit-methodology without src/ remain release-helpers-degraded only: utility helpers now also require src/ and fail with a guided error instead of a traceback, while the hook fail-open handler stays fail-open everywhere and degrades to exception-type-only messages when redaction is unavailable",
            "validate.sh sync fixtures now copy src/minervit_methodology alongside bin so copied-checkout flows keep exercising the real implementation",
            "cut-release checksums src/minervit_methodology/util.py so the immutable release boundary keeps covering all distributed implementation",
            "runtime hooks, generated adapters, adapter schema, and command output behavior are unchanged; this is a mechanical extraction only",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it only moves pure utility helper implementation behind compatibility wrappers without changing lane runtime behavior.",
            "Pinning back to 0.6.203 restores the utility helper functions inside bin/minervit-methodology but does not change lane runtime behavior.",
        ]
    elif parsed_version == version_tuple("0.6.203"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Phase 5.1 CLI package split starts with a repo-local src/minervit_methodology package",
            "release migration report path/loading and changelog parsing helpers move from bin/minervit-methodology into src/minervit_methodology/releases.py",
            "bin/minervit-methodology keeps lazy compatibility wrappers so copied standalone CLI fixtures and unrelated commands do not require src to be present",
            "standalone copies of bin/minervit-methodology without src/ read release migration reports as unavailable (matching 0.6.202) and get a guided error instead of a traceback from other release metadata helpers",
            "scripts/test.sh now lints and type-checks src/ alongside bin/minervit-methodology so extracted package code stays covered",
            "cut-release now checksums the extracted src/minervit_methodology files so the immutable release boundary keeps covering all distributed implementation",
            "runtime hooks, generated adapters, adapter schema, and release-update output behavior are unchanged; this is a mechanical extraction only",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it only moves release helper implementation behind compatibility wrappers without changing lane runtime behavior.",
            "Pinning back to 0.6.202 restores the release helper functions inside bin/minervit-methodology but does not change lane runtime behavior.",
        ]
    elif parsed_version == version_tuple("0.6.202"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Phase 5 release-log hygiene moves the long narrative release log off the main branch into the dedicated release-notes archive branch",
            "the main-branch release notes file is now a small current-version stub pointing to CHANGELOG.md and the archive branch",
            "release-update summaries no longer require the archived narrative log and instead use concise changelog entries plus release migration report compatibility notes",
            "runtime hooks, generated adapters, and adapter schema are unchanged; the only CLI behavior change is release-update summary sourcing",
            "scripts/validate.sh remains frozen by line budget; its single release-note grep now verifies the stub/archive pointer instead of a narrative body phrase",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it changes maintainer release documentation plumbing and release-update summary sourcing only, without changing runtime hook behavior.",
            "Pinning back to 0.6.201 restores the full narrative release log on the main branch but does not change lane runtime behavior.",
        ]
    elif parsed_version == version_tuple("0.6.201"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Phase 4 productization budgets now have executable regression tests for the rendered reference adapter CLAUDE.md 16000-byte ceiling",
            "agent-facing policy modules, generated policy projections, and plugin skill Markdown now have a regression test that blocks incident-style ISO dates",
            "proof-of-done policy remains explicit that @pending tests, skipped tests, disabled tests, quarantined tests, and wrong-target tests are gaps rather than completion proof",
            "runtime hooks, generated adapters, adapter schema, and operational CLI behavior are unchanged; this release only adds tests and release metadata",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it adds regression tests and release metadata only, without changing runtime hook behavior.",
            "Pinning back to 0.6.200 removes the new regression tests but does not change lane runtime behavior.",
        ]
    elif parsed_version == version_tuple("0.6.200"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical policy modules are further thinned as part of Phase 4 productization, reducing generated canonical-rules.md below 60000 bytes",
            "lane lifecycle, planning, backlog provider, board currency, review, merge and CI, background work, current status, autonomy, bootstrap, context rotation, document context, and continuity modules now carry tighter word-count ratchets",
            "required compatibility markers remain intact, including startup gates, latest-code recovery, planning proof, board currency, merge queue, monitor supervision, and proof-of-done wording that excludes literal @pending tests from completion proof",
            "runtime hooks, generated adapters, adapter schema, and operational CLI behavior are unchanged; CLI code only declares release metadata for this version",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it changes policy-module wording, generated canonical docs, release metadata, and tests only, without changing runtime hook behavior.",
            "Pinning back to 0.6.199 restores the previous longer canonical policy wording but does not change lane runtime behavior.",
        ]
    elif parsed_version == version_tuple("0.6.199"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Phase 3 open-core boundary is tightened: core plugin references and canonical policy no longer carry provider-specific delivery-ops details such as chat providers, object storage/CDN, local tailing tools, or stack-specific migration repair",
            "ops-owned delivery communications, deployment notifications, milestone updates, product notes, event logs, usage evidence, session journals, and migration-collision procedure remain in the delivery-ops plugin and detailed ops references",
            "generated canonical-rules.md is regenerated from source modules and reduced to 66000 bytes while preserving required startup, review, board, monitor, proof, and release-track markers",
            "runtime hooks, generated adapters, adapter schema, and operational CLI behavior are unchanged; CLI code only declares release metadata for this version",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it changes policy/reference wording, release metadata, and tests only, without changing runtime hook behavior.",
            "Pinning back to 0.6.198 restores the previous core references with more provider-specific delivery-ops detail but does not change lane runtime behavior.",
        ]
    elif parsed_version == version_tuple("0.6.198"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "proof-of-done wording now names literal @pending tests in thin skill entrypoints, canonical policy, and detailed references",
            "goal orchestration, risk-tier autonomy, execution-packet, delivery-summary, TDD, and behavior-spec guidance consistently exclude pending, skipped, disabled, quarantined, and wrong-target tests from completion proof",
            "required compatibility markers remain intact, including startup gates, latest-code recovery, planning proof, board currency, merge queue, monitor supervision, and strict proof-of-done wording",
            "runtime hooks, generated adapters, adapter schema, and operational CLI behavior are unchanged; CLI code only declares release metadata for this version",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it changes policy wording, skill wording, release metadata, and tests only, without changing runtime hook behavior.",
            "Pinning back to 0.6.197 restores the previous shorthand proof wording but does not change lane runtime behavior.",
        ]
    elif parsed_version == version_tuple("0.6.197"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical policy modules are further thinned as part of Phase 4 productization, reducing generated canonical-rules.md below 68000 bytes",
            "governance, autonomy, status, delivery summaries, goal orchestration, backlog provider, board currency, planning, technical stack defaults, merge and CI, background work, lane lifecycle, unmanaged bootstrap, and context continuity policy modules now carry tighter word-count ratchets",
            "required compatibility markers remain intact, including startup gates, latest-code recovery, planning proof, board currency, merge queue, monitor supervision, and proof-of-done wording that excludes pending tests from completion proof",
            "canonical-rules.md is regenerated from source modules; runtime hooks, generated adapters, adapter schema, and operational CLI behavior are unchanged, with CLI code touched only to declare this release report",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it changes policy-module wording, generated canonical docs, release metadata, and tests only, without changing runtime hook behavior.",
            "Pinning back to 0.6.196 restores the previous longer canonical policy wording but does not change lane runtime behavior.",
        ]
    elif parsed_version == version_tuple("0.6.196"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical policy modules are further thinned as part of Phase 4 productization, reducing generated canonical-rules.md below 68500 bytes",
            "context rotation, technical stack defaults, TDD and behavior specs, merge and CI, background work, lane lifecycle, unmanaged bootstrap, and document context policy modules now carry tighter word-count ratchets",
            "required compatibility markers remain intact, including startup gates, adapter bootstrap, context rotation, document context, monitor supervision, merge queue, and proof-of-done wording that excludes pending tests from completion proof",
            "canonical-rules.md is regenerated from source modules; runtime hooks, generated adapters, adapter schema, and operational CLI behavior are unchanged, with CLI code touched only to declare this release report",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it changes policy-module wording, generated canonical docs, release metadata, and tests only, without changing runtime hook behavior.",
            "Pinning back to 0.6.195 restores the previous longer canonical policy wording but does not change lane runtime behavior.",
        ]
    elif parsed_version == version_tuple("0.6.195"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical policy modules are further thinned as part of Phase 4 productization, reducing generated canonical-rules.md below 69000 bytes",
            "autonomy/status, delivery summary, goal orchestration, review-before-push, backlog provider, planning, merge and CI, background work, lane lifecycle, and context continuity policy modules now carry tighter word-count ratchets",
            "required compatibility markers remain intact, including startup gates, latest-code recovery, planning proof, board currency, merge queue, monitor supervision, and proof-of-done wording that excludes pending tests from completion proof",
            "canonical-rules.md is regenerated from source modules; runtime hooks, generated adapters, adapter schema, and CLI behavior are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it changes policy-module wording, generated canonical docs, release metadata, and tests only, without changing runtime hook behavior.",
            "Pinning back to 0.6.194 restores the previous longer canonical policy wording but does not change lane runtime behavior.",
        ]
    elif parsed_version == version_tuple("0.6.194"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical policy modules are further thinned as part of Phase 4 productization, reducing generated canonical-rules.md below 70000 bytes",
            "autonomy, delivery summary, goal orchestration, backlog provider, planning, merge and CI, background work, lane lifecycle, unmanaged bootstrap, and context continuity policy modules now carry tighter word-count ratchets",
            "required compatibility markers remain intact, including startup gates, latest-code recovery, planning proof, board currency, merge queue, monitor supervision, and proof-of-done wording that excludes pending tests from completion proof",
            "canonical-rules.md is regenerated from source modules; runtime hooks, generated adapters, adapter schema, and CLI behavior are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it changes policy-module wording, generated canonical docs, release metadata, and tests only, without changing runtime hook behavior.",
            "Pinning back to 0.6.193 restores the previous longer canonical policy wording but does not change lane runtime behavior.",
        ]
    elif parsed_version == version_tuple("0.6.193"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical policy modules are further thinned as part of Phase 4 productization, reducing generated canonical-rules.md below 71500 bytes",
            "autonomy, current status, board currency, planning, background work, lane lifecycle, unmanaged bootstrap, and context continuity policy modules now carry tighter word-count ratchets",
            "required compatibility markers remain intact, including startup gates, latest-code recovery, planning proof, board currency, merge queue, monitor supervision, and proof-of-done wording that excludes pending tests from completion proof",
            "canonical-rules.md is regenerated from source modules; runtime hooks, generated adapters, adapter schema, and CLI behavior are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it changes policy-module wording, generated canonical docs, release metadata, and tests only, without changing runtime hook behavior.",
            "Pinning back to 0.6.192 restores the previous longer canonical policy wording but does not change lane runtime behavior.",
        ]
    elif parsed_version == version_tuple("0.6.192"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical policy modules are further thinned as part of Phase 4 productization, reducing generated canonical-rules.md below 72350 bytes",
            "delivery summary, goal orchestration, backlog provider, merge and CI, background work, lane lifecycle, and context continuity policy modules now carry tighter word-count ratchets",
            "required compatibility markers remain intact, including startup gates, continuity ordering, board currency, merge queue, monitor supervision, and proof-of-done wording that excludes pending tests from completion proof",
            "canonical-rules.md is regenerated from source modules; runtime hooks, generated adapters, adapter schema, and CLI behavior are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it changes policy-module wording, generated canonical docs, release metadata, and tests only, without changing runtime hook behavior.",
            "Pinning back to 0.6.191 restores the previous longer canonical policy wording but does not change lane runtime behavior.",
        ]
    elif parsed_version == version_tuple("0.6.191"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical policy modules are further thinned as part of Phase 4 productization, reducing generated canonical-rules.md below 73500 bytes",
            "planning, goal orchestration, backlog provider, board currency, delivery summary, merge and CI, background work, lane lifecycle, and context continuity policy modules now carry tighter word-count ratchets",
            "required compatibility markers remain intact, including startup gates, continuity ordering, board currency, review evidence, monitor supervision, and proof-of-done wording that excludes pending tests from completion proof",
            "canonical-rules.md is regenerated from source modules; runtime hooks, generated adapters, adapter schema, and CLI behavior are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it changes policy-module wording, generated canonical docs, release metadata, and tests only, without changing runtime hook behavior.",
            "Pinning back to 0.6.190 restores the previous longer canonical policy wording but does not change lane runtime behavior.",
        ]
    elif parsed_version == version_tuple("0.6.190"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical policy modules are further thinned as part of Phase 4 productization, reducing generated canonical-rules.md below 75000 bytes",
            "rejected-tool recovery, verified human instructions, cross-lane coordination, TDD and behavior specs, demo and staging deployment, document context, and session-journal policy modules now carry concise word-count ratchets",
            "required compatibility markers remain intact, including proof-of-done wording that excludes @pending tests from completion proof",
            "canonical-rules.md is regenerated from source modules; runtime hooks, generated adapters, adapter schema, and CLI behavior are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it changes policy-module wording, generated canonical docs, release metadata, and tests only, without changing runtime hook behavior.",
            "Pinning back to 0.6.189 restores the previous longer canonical policy wording but does not change lane runtime behavior.",
        ]
    elif parsed_version == version_tuple("0.6.189"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical policy modules are further thinned as part of Phase 4 productization, reducing generated canonical-rules.md below 78000 bytes",
            "proof-of-done policy now explicitly excludes @pending tests from completion proof in the generated canonical contract",
            "unmanaged bootstrap wording now names the existing init wrapper while preserving the mandatory adapter-bootstrap interview and scaffold gates",
            "concise policy modules now carry tighter word-count ratchets for governance, autonomy/status, status truth, delivery summaries, goal orchestration, backlog provider, board currency, context rotation, planning, technical stack, review, merge/CI, background work, lane lifecycle, bootstrap, and continuity",
            "detailed workflow authority remains in the existing skill/reference files; runtime hooks, generated adapters, adapter schema, and CLI behavior are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it changes policy-module wording, generated canonical docs, and tests only, without changing runtime hook behavior.",
            "Pinning back to 0.6.188 restores the previous longer canonical policy wording but does not change lane runtime behavior.",
        ]
    elif parsed_version == version_tuple("0.6.188"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical planning policy now requires a proof-of-done standard before implementation starts",
            "delivery-summary policy now requires completion claims to name what actually ran, what it covered, and what planned proof did not run",
            "TDD and behavior-spec policy explicitly states that pending, skipped, disabled, quarantined, or wrong-target tests are not proof of completion",
            "risk-tier, execution-packet, delivery-summary, and behavior-spec references now preserve the detailed proof-of-done procedure",
            "canonical-rules.md is regenerated from source modules; policy phrase generation was checked with no content change; runtime hooks, generated adapters, and CLI behavior are unchanged",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it changes policy/reference guidance and tests only, without changing runtime hook behavior.",
            "Pinning back to 0.6.187 removes the new proof-of-done policy wording but does not change lane runtime behavior.",
        ]
    elif parsed_version == version_tuple("0.6.187"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "tests/test_plan_finalization_hook.py ports scratch-plan ExitPlanMode hook fixtures out of scripts/validate.sh",
            "the pytest port exercises content-hash scratch resolution, transcript-referenced source plans, invalid exemption blocking, configured scratch escape, non-configured scratch blocking, and archive-reference exclusion",
            "the validate.sh disposition inventory now marks the scratch-plan source-of-truth resolution block covered by pytest",
            "scripts/validate.sh, lane runtime behavior, hooks, generated adapters, and canonical policy are unchanged; only test coverage, disposition documentation, and release metadata are added",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it adds pytest coverage and documentation only, without changing runtime or AI-facing policy behavior.",
            "Pinning back to 0.6.186 removes the new pytest port but does not change lane runtime behavior.",
        ]
    elif parsed_version == version_tuple("0.6.186"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "tests/test_release_change_contract.py ports reusable framework-change VERSION bump enforcement out of scripts/validate.sh",
            "the pytest port pins adapter-only and evidence-only exemption classification so project adapter config and durable methodology evidence can avoid unnecessary framework releases",
            "Python CI now uses full-history checkout so pytest can resolve pull-request base refs for release-change enforcement",
            "the validate.sh disposition inventory now marks the VERSION bump enforcement block covered by pytest",
            "scripts/validate.sh, lane runtime behavior, hooks, generated adapters, and canonical policy are unchanged; only test coverage, CI checkout depth, and release metadata are added",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it adds pytest coverage and documentation only, without changing runtime or AI-facing policy behavior.",
            "Pinning back to 0.6.185 removes the new pytest port but does not change lane runtime behavior.",
        ]
    elif parsed_version == version_tuple("0.6.185"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "tests/test_goal_orchestration_cli.py ports the high-risk goal orchestration CLI assertions out of scripts/validate.sh",
            "the validate.sh disposition inventory now marks the goal-orchestration shell block covered by pytest",
            "the pytest port covers goal-start, milestone proof and marker gates, goal-condition, goal-complete iteration-review delivery, active-goal review slug checks, next-goal selection, no-operator-dependency boundaries, and blocked/deferred transitions",
            "scripts/validate.sh, lane runtime behavior, hooks, generated adapters, and canonical policy are unchanged; only test coverage and release metadata are added",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it adds pytest coverage and documentation only, without changing runtime or AI-facing policy behavior.",
            "Pinning back to 0.6.184 removes the new pytest port but does not change lane runtime behavior.",
        ]
    elif parsed_version == version_tuple("0.6.184"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "the internal validate.sh disposition plan (validate-sh-disposition.md) inventories the frozen scripts/validate.sh assertion groups for Phase 5 retirement",
            "the inventory classifies validator blocks as already-covered-by <pytest file>, port, or obsolete before any shell coverage is deleted",
            "scripts/validate.sh, lane runtime behavior, hooks, generated adapters, and canonical policy are unchanged; only release-migration-report metadata is added",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it adds only a planning artifact plus release metadata and does not change runtime or AI-facing policy behavior.",
            "Pinning back to 0.6.183 removes the validator disposition inventory but does not change lane runtime behavior.",
        ]
    elif parsed_version == version_tuple("0.6.183"):
        wip_safe = False
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical Context Continuity policy is ratcheted to a 590-word cap while preserving filesystem handoff, no-copy-paste, not-a-stop, final-handoff, startup-gate ordering, portable CLI fallback, ledger-precedence, planning-on-resume, continuity-hold, and lane-local-state obligations",
            "detailed handoff, rejected-tool, portable-CLI, rotation-threshold, journal, ledger precedence, planning-on-resume, Markdown context-loading, archive, and index-hygiene procedure remains in the owning reference docs and skills",
            "canonical-rules.md is regenerated with the smaller context-continuity module to reduce the compatibility prompt surface",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Because canonical AI-facing context-continuity text changed materially, this release is not WIP-safe for automatic active-lane upgrades.",
            "Pinning back to 0.6.182 restores the previous longer Context Continuity policy prose.",
        ]
    elif parsed_version == version_tuple("0.6.182"):
        wip_safe = False
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical Backlog Provider Intake And Grooming policy is ratcheted to a 580-word cap while preserving provider authority, sanctioned command entrypoints, human-owned board schema, board Status currency, stakeholder questions, migration/export boundaries, board ordering, provider-item completion authority, and grooming DoR obligations",
            "detailed provider workflow, board status, subtask reconciliation, stakeholder-question, migration/export, board-order, epic-scope, and grooming procedure remains in the owning reference docs and skills",
            "canonical-rules.md is regenerated with the smaller backlog-provider module to reduce the compatibility prompt surface",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Because canonical AI-facing backlog-provider text changed materially, this release is not WIP-safe for automatic active-lane upgrades.",
            "Pinning back to 0.6.181 restores the previous longer Backlog Provider Intake And Grooming policy prose.",
        ]
    elif parsed_version == version_tuple("0.6.181"):
        wip_safe = False
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical Goal Orchestration policy is ratcheted to a 590-word cap while preserving hierarchy, goal-plan contract, provider-item completion authority, next-goal startup output, goal ledger lifecycle, completion proof, stale-ledger, Claude /goal, sourceAdapterSha256, and dependency-surfacing obligations",
            "detailed provider workflow, lane ledger commands, /goal procedure, context rotation, completion proof, and end-of-goal closeout remain in the goal-orchestration reference",
            "canonical-rules.md is regenerated with the smaller goal-orchestration module to reduce the compatibility prompt surface",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Because canonical AI-facing goal-orchestration text changed materially, this release is not WIP-safe for automatic active-lane upgrades.",
            "Pinning back to 0.6.180 restores the previous longer Goal Orchestration policy prose.",
        ]
    elif parsed_version == version_tuple("0.6.180"):
        wip_safe = False
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical Background Work policy is ratcheted further to a 570-word cap while preserving monitor evidence, active supervision, passive-stop, stale/hung, provider-recovery, queued-PR, terminal-state, and response-guard obligations",
            "canonical Context Continuity policy is ratcheted further to a 610-word cap while preserving filesystem handoff, no-copy-paste, not-a-stop, final-handoff, startup-gate, ledger-precedence, planning-on-resume, and continuity-hold obligations",
            "detailed monitor, stale/hung, provider-recovery, queue, handoff, portable-CLI, rotation-threshold, journal, and planning-on-resume procedure remains in the owning skill references",
            "canonical-rules.md is regenerated with the smaller background/continuity modules to reduce the compatibility prompt surface",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Because canonical AI-facing background-work and context-continuity text changed materially, this release is not WIP-safe for automatic active-lane upgrades.",
            "Pinning back to 0.6.179 restores the previous longer Background Work and Context Continuity policy prose.",
        ]
    elif parsed_version == version_tuple("0.6.179"):
        wip_safe = False
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical Backlog Provider Intake And Grooming policy is ratcheted to a 650-word cap while preserving provider authority, board schema ownership, stakeholder questions, migration/export, customer-facing scope, board order, epic scope, business framing, provider-item completion, and grooming DoR obligations",
            "canonical Board Currency And Subtask Status policy is ratcheted to a 585-word cap while preserving lifecycle status sync, active/done transitions, scope-aware blocking, planning-start active claims, board-backed native subtask status, parent-done blocking, done verification evidence, and customer-facing bug board placement obligations",
            "detailed backlog migration, board item write tiers, board schema adoption, epic grooming, provider work-order, and subtask reconciliation procedure remain in the owning references",
            "canonical-rules.md is regenerated with the smaller backlog/board modules to reduce the compatibility prompt surface",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Because canonical AI-facing backlog-provider and board-currency text changed materially, this release is not WIP-safe for automatic active-lane upgrades.",
            "Pinning back to 0.6.178 restores the previous longer Backlog Provider Intake And Grooming and Board Currency And Subtask Status policy prose.",
        ]
    elif parsed_version == version_tuple("0.6.178"):
        wip_safe = False
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical Lane Lifecycle policy is ratcheted to a 550-word cap while preserving startup, CLI resolution, work-profile, release-track, WIP protection, status, and version obligations",
            "canonical Background Work policy is ratcheted to a 610-word cap while preserving monitor evidence, active supervision, passive-stop, stale/hung, provider-recovery, queued-PR, and response-guard obligations",
            "detailed lane startup, latest-code, WSL2, monitor, stale/hung, provider-recovery, early-warning smoke, and final-preflight procedure remain in the lane-lifecycle and background-task-monitoring references",
            "canonical-rules.md is regenerated with the smaller lifecycle/background modules to reduce the compatibility prompt surface",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Because canonical AI-facing lane lifecycle and background-work text changed materially, this release is not WIP-safe for automatic active-lane upgrades.",
            "Pinning back to 0.6.177 restores the previous longer Lane Lifecycle and Background Work policy prose.",
        ]
    elif parsed_version == version_tuple("0.6.177"):
        wip_safe = False
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical Unmanaged Project Bootstrap policy is ratcheted to a 470-word cap while preserving mandatory adapter interview, no-generic-adapter, bootstrapEvidence, repo-local adapter, local/no-remote, and generated-adapter provenance obligations",
            "detailed unmanaged-repo onboarding and adapter interview procedure remain in the project-bootstrap skill reference and operations docs",
            "canonical-rules.md is regenerated with the smaller bootstrap module to reduce the compatibility prompt surface",
            "tests now pin unmanaged project bootstrap policy size so detailed interview walkthroughs cannot silently return to canonical policy",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Because canonical AI-facing unmanaged-project bootstrap text changed materially, this release is not WIP-safe for automatic active-lane upgrades.",
            "Pinning back to 0.6.176 restores the previous longer Unmanaged Project Bootstrap policy prose.",
        ]
    elif parsed_version == version_tuple("0.6.176"):
        wip_safe = False
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical Technical Stack And Platform Defaults policy is ratcheted to a 425-word contract while preserving adapter-owned provider approval, AWS CLI credential, GitHub rate-limit, scratch-plan, and plan-review evidence obligations",
            "detailed stack-risk and scratch-plan procedure remain in the risk-tier, lane-lifecycle, review-before-push, and project-bootstrap references",
            "canonical-rules.md is regenerated with the smaller platform/defaults module to reduce the compatibility prompt surface",
            "tests now pin technical stack policy size so provider-default procedure cannot silently return to canonical policy",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Because canonical AI-facing platform/defaults text changed materially, this release is not WIP-safe for automatic active-lane upgrades.",
            "Pinning back to 0.6.175 restores the previous longer Technical Stack And Platform Defaults policy prose.",
        ]
    elif parsed_version == version_tuple("0.6.175"):
        wip_safe = False
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical Methodology Repository Governance policy is ratcheted to a 575-word contract while preserving release, archive, delivery, deployment, proposal, main-branch, and product-isolated CLI obligations",
            "detailed release engineering, delivery communications, deployment, milestone update, iteration review, and product chat procedures remain in docs/reference/operations and owning skills",
            "canonical-rules.md is regenerated with the smaller governance module to reduce the compatibility prompt surface",
            "tests now pin governance policy size lower so detailed delivery/release procedure cannot silently return to canonical policy",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Because canonical AI-facing methodology governance text changed materially, this release is not WIP-safe for automatic active-lane upgrades.",
            "Pinning back to 0.6.174 restores the previous longer Methodology Repository Governance policy prose.",
        ]
    elif parsed_version == version_tuple("0.6.174"):
        wip_safe = True
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "set-framework-channel now has an explicit --source adapter mode that updates repo-local .minervit/adapter.json _framework.channel instead of requiring hand-edited JSON",
            "adapter channel writes validate the adapter before accepting output and refuse generated lane adapters or adapter paths outside the target repo",
            "repo-local adapter channel writes re-render .minervit-ai-delivery.json with --json-only so the lane's effective _framework.channel changes immediately",
            "the default set-framework-channel behavior remains the existing lane-local .minervit/pin.json override for backward compatibility",
        ]
        rollback_notes = [
            "No adapter migration is required; existing pin-file channel selection keeps working unchanged.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because the new adapter-write path is opt-in and existing startup/update defaults are unchanged.",
            "Pinning back to 0.6.173 restores the previous pin-file-only set-framework-channel behavior.",
        ]
    elif parsed_version == version_tuple("0.6.173"):
        wip_safe = False
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical Autonomy And Status policy is ratcheted to a 555-word contract while preserving forward-motion, exact-blocker, required-process, anti-evasion, no-work-in-flight, rejected-tool, status-language, skill-resolution, and RCA markers",
            "detailed forward-motion, anti-work-evasion, rejected-tool recovery, Unknown-skill recovery, and RCA procedure remains in docs/reference/operations/autonomy-guardrails.md and owning skills",
            "canonical-rules.md is regenerated with the smaller Autonomy And Status module while preserving non-optional work progression and operator-facing status obligations",
            "tests now pin Autonomy And Status policy size so detailed anti-evasion procedure cannot silently return to canonical policy",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Because canonical AI-facing autonomy/status policy text changed materially, this release is not WIP-safe for automatic active-lane upgrades.",
            "Pinning back to 0.6.172 restores the previous longer Autonomy And Status policy prose.",
        ]
    elif parsed_version == version_tuple("0.6.172"):
        wip_safe = False
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical Context Rotation policy is ratcheted to a 500-word contract while preserving thresholds, Claude auto-compact, measured-context, safe-boundary, fallback, and resume markers",
            "detailed context-rotation procedure remains in the goal-execution, context-continuity, and goal-orchestration references",
            "canonical-rules.md is regenerated with the smaller Context Rotation module while preserving non-optional rotation, no productive-limit stops, and no unverified-shipping alternatives",
            "tests now pin Context Rotation policy size so detailed rotation procedure cannot silently return to canonical policy",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Because canonical AI-facing context-rotation policy text changed materially, this release is not WIP-safe for automatic active-lane upgrades.",
            "Pinning back to 0.6.171 restores the previous longer Context Rotation policy prose.",
        ]
    elif parsed_version == version_tuple("0.6.171"):
        wip_safe = False
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical Lane Lifecycle policy is ratcheted to a 790-word contract while preserving startup, CLI fallback, release-track pin, WIP update, work-profile, liveness, and plugin-version markers",
            "detailed startup, sync, lock, profile, latest-code, liveness, runtime, generated-adapter cleanup, Windows/WSL2, and no-work-in-flight procedure remains in the lane-lifecycle skill reference",
            "canonical-rules.md is regenerated with the smaller Lane Lifecycle module while preserving stable-client update protections and non-bricking startup recovery obligations",
            "tests now pin Lane Lifecycle policy size lower so detailed startup procedure cannot silently return to canonical policy",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Because canonical AI-facing startup policy text changed materially, this release is not WIP-safe for automatic active-lane upgrades.",
            "Pinning back to 0.6.170 restores the previous longer Lane Lifecycle policy prose.",
        ]
    elif parsed_version == version_tuple("0.6.170"):
        wip_safe = False
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical Current Status Truth policy is ratcheted to a 550-word contract while preserving latest-code baseline, stale-local-lane, in-band escape, adapter-drift, and freshness self-heal markers",
            "detailed latest-code, remote evidence, stale-lane, local-only, hook self-heal, and generated-adapter drift procedure remains in docs/reference/operations/status-continuity.md",
            "canonical-rules.md is regenerated with the smaller Current Status Truth module while preserving current-status answers from fetched evidence and never-wedge guard obligations",
            "tests now pin Current Status Truth policy size so detailed status procedure cannot silently return to canonical policy",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Because canonical AI-facing policy text changed materially, this release is not WIP-safe for automatic active-lane upgrades.",
            "Pinning back to 0.6.169 restores the previous longer Current Status Truth policy prose.",
        ]
    elif parsed_version == version_tuple("0.6.169"):
        wip_safe = False
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical Background Work policy is ratcheted to a 640-word contract while preserving monitor, response-guard, stale/hung, provider-recovery, queued-PR, and terminal-state markers",
            "detailed monitoring, stale/hung recovery, provider retry, early-warning smoke, final-preflight, queued-PR, and response-guard procedure remains in background-task-monitoring, with related merge, review, and delivery procedure in the owning references",
            "canonical-rules.md is regenerated with the smaller Background Work module while preserving active supervision, ScheduleWakeup, passive-monitor-stop, verified PID, recovery-loop, clean queued PR, and Claude response-guard obligations",
            "tests now pin Background Work policy size lower so detailed monitoring procedure cannot silently return to canonical policy",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Because canonical AI-facing policy text changed materially, this release is not WIP-safe for automatic active-lane upgrades.",
            "Pinning back to 0.6.168 restores the previous longer Background Work policy prose.",
        ]
    elif parsed_version == version_tuple("0.6.168"):
        wip_safe = False
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical Context Continuity policy is now a concise contract capped at 650 words",
            "detailed handoff, rejected-tool, portable-CLI, rotation-threshold, journal, ledger precedence, planning-on-resume, Markdown context-loading, archive, and index-hygiene procedure remains in context-continuity, goal-orchestration, execution-packet, session-journal, and risk-tier references",
            "canonical-rules.md is regenerated with the smaller context-continuity module while preserving filesystem handoff, no-stop-after-handoff, workflow-boundary refresh, startup-gate ordering, ledger precedence, and continuity-hold obligations",
            "tests now pin context-continuity policy size so detailed procedure cannot silently return to canonical policy",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Because canonical AI-facing policy text changed materially, this release is not WIP-safe for automatic active-lane upgrades.",
            "Pinning back to 0.6.167 restores the previous longer context-continuity policy prose.",
        ]
    elif parsed_version == version_tuple("0.6.167"):
        wip_safe = False
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical Merge And CI policy is now a concise contract capped at 525 words",
            "detailed merge queue, auto-merge, exceptional monitor, inactive-branch, break-glass, post-queue, final-preflight, provider-recovery, and batch-update procedure remains in the merge-queue-monitoring, background-task-monitoring, and delivery-summary references",
            "canonical-rules.md is regenerated with the smaller merge/CI module while preserving local gate, no-admin-merge, queued-PR, inactive-branch, authoritative-queue-signal, passive-monitor, provider-recovery, P0 interrupt, and batching obligations",
            "tests now pin merge/CI policy size so detailed procedure cannot silently return to canonical policy",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Because canonical AI-facing policy text changed materially, this release is not WIP-safe for automatic active-lane upgrades.",
            "Pinning back to 0.6.166 restores the previous longer merge/CI policy prose.",
        ]
    elif parsed_version == version_tuple("0.6.166"):
        wip_safe = False
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical Backlog Provider Intake And Grooming policy is now a concise contract capped at 750 words",
            "detailed provider selection, board status, stakeholder questions, migration/export, board order, epic scope, and grooming procedure remains in backlog-provider, goal-orchestration, board-item, board-adoption, and backlog-grooming references",
            "canonical-rules.md is regenerated with the smaller backlog-provider module while preserving provider authority, board schema ownership, board currency, customer-facing export, board order, epic scope, provider-item completion, and grooming Definition of Ready obligations",
            "tests now pin backlog-provider policy size so detailed procedure cannot silently return to canonical policy",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Because canonical AI-facing policy text changed materially, this release is not WIP-safe for automatic active-lane upgrades.",
            "Pinning back to 0.6.165 restores the previous longer backlog-provider policy prose.",
        ]
    elif parsed_version == version_tuple("0.6.165"):
        wip_safe = False
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "risk-tier plan-review finalization guidance is now a numbered procedure instead of one dense paragraph",
            "the procedure preserves T2/T3 review, exemption, finalize-plan-review, plan-finalization-precheck, and no-bypass obligations",
            "tests now pin the risk-tier plan-review finalization section as a bounded numbered procedure",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Because AI-facing reference policy text changed materially, this release is not WIP-safe for automatic active-lane upgrades.",
            "Pinning back to 0.6.164 restores the previous risk-tier plan-review reference prose.",
        ]
    elif parsed_version == version_tuple("0.6.164"):
        wip_safe = False
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical Lane Lifecycle policy is now a concise contract capped at 850 words",
            "detailed startup, update, auto-rescue, work-profile, hook, latest-code, lock, and lane-local-state procedure remains in the lane-lifecycle and adapter-lane-lifecycle references",
            "canonical-rules.md is regenerated with the smaller lane lifecycle module while preserving startup gates, stable-channel defaults, WIP-safe update controls, hook installation, version reporting, release-note, and hand-written adapter-file protections",
            "tests now pin lane lifecycle policy size so detailed procedure cannot silently return to canonical policy",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Because canonical AI-facing policy text changed materially, this release is not WIP-safe for automatic active-lane upgrades.",
            "Pinning back to 0.6.163 restores the previous longer lane lifecycle policy prose.",
        ]
    elif parsed_version == version_tuple("0.6.163"):
        wip_safe = False
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical Board Currency And Subtask Status policy is now a concise contract capped at 625 words",
            "detailed board-status, current-work scope, subtask reconciliation, done-evidence, and customer-facing bug placement procedure remains in the backlog-provider workflow and goal-orchestration references",
            "canonical-rules.md is regenerated with the smaller board module while preserving scope-aware gating, parent/subtask status, active-claim, done-evidence, and customer-facing bug placement obligations",
            "tests now pin board-currency policy size so detailed procedure cannot silently return to canonical policy",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Because canonical AI-facing policy text changed materially, this release is not WIP-safe for automatic active-lane upgrades.",
            "Pinning back to 0.6.162 restores the previous longer board-currency policy prose.",
        ]
    elif parsed_version == version_tuple("0.6.162"):
        wip_safe = False
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical Goal Orchestration policy is now a concise contract capped at 650 words",
            "detailed goal lifecycle, provider-board, Claude /goal, context-rotation, dependency, and completion-proof procedure remains in the goal-orchestration policy reference",
            "canonical-rules.md is regenerated with the smaller goal module while preserving goal ledger, provider-item, board-status, adapter-drift, and no-stop-at-boundary obligations",
            "tests now pin goal policy size so detailed procedure cannot silently return to canonical policy",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Because canonical AI-facing policy text changed materially, this release is not WIP-safe for automatic active-lane upgrades.",
            "Pinning back to 0.6.161 restores the previous longer goal orchestration policy prose.",
        ]
    elif parsed_version == version_tuple("0.6.161"):
        wip_safe = False
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical Methodology Repository Governance policy is now a concise contract capped at 750 words",
            "iteration-review, deployment notification, milestone update, and product chat walkthrough detail now lives in delivery communication and ops skill references",
            "canonical-rules.md is regenerated with the smaller governance module while preserving release, archive, delivery, deployment, and client-isolated CLI obligations",
            "tests now pin governance policy size and assert communication/delivery detail lives in the owning references instead of requiring it in canonical policy",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Because canonical AI-facing policy text changed materially, this release is not WIP-safe for automatic active-lane upgrades.",
            "Pinning back to 0.6.160 restores the previous longer governance policy prose.",
        ]
    elif parsed_version == version_tuple("0.6.160"):
        wip_safe = False
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "public example generated CLAUDE.md and AGENTS.md are now capped at 16000 bytes as part of Phase 4 generated-adapter budget cleanup",
            "generated adapters keep startup, channel, WIP, review, local-gate, merge, background, and Windows/WSL2 command anchors while moving repeated walkthrough prose back to canonical policy and skills",
            "generated adapters now surface the first-class set-framework-channel stable|experimental command so lanes can select a release track without hand-editing pins",
            "the generated-adapter contract test now enforces the compact public surface and blocks removed mini-manual sections from returning",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters change only when a lane deliberately re-renders them.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Because generated AI prompt surfaces changed materially, this release is not WIP-safe for automatic active-lane upgrades.",
            "Pinning back to 0.6.159 restores the previous longer generated adapter prose.",
        ]
    elif parsed_version == version_tuple("0.6.159"):
        wip_safe = False
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "RCA artifact validation now requires the compact What happened, Evidence, Root cause, Proposed control, and Validation sections",
            "RCA validation no longer enforces apology/tone blocklists, strict heading order, Methodology Runtime fields, or canned Fix Proposal opener outcomes",
            "board-structure guard and backlog-provider policy text now state the rule and fix path without private incident/date narrative",
            "policy and skill Markdown tests now block incident-date strings from shippable methodology surfaces and generated adapter projections",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Because RCA artifact validation and AI-facing reference guidance changed, this release is not WIP-safe for automatic active-lane upgrades.",
            "Pinning back to 0.6.158 restores the previous long RCA artifact validator and reference guidance.",
        ]
    elif parsed_version == version_tuple("0.6.158"):
        wip_safe = False
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "core methodology SKILL.md entrypoints are capped at 45 lines each on the experimental channel",
            "core methodology SKILL.md entrypoints now total no more than 1200 lines",
            "detailed workflow procedure remains in packaged references/*.md files rather than the routine skill entrypoints",
            "public quality tests now pin thinned entrypoints separately from detailed policy references",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters do not need to change for this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Because AI runtime skill prompt surfaces changed materially, this release is not WIP-safe for automatic active-lane upgrades.",
            "Pinning back to 0.6.157 restores the previous longer core skill entrypoints.",
        ]
    elif parsed_version == version_tuple("0.6.157"):
        wip_safe = False
        deprecated_surfaces = [
            {
                "section": "skills",
                "name": "native-review-checklist",
                "status": "experimental-removal",
                "replacement": "review-before-push",
                "note": (
                    "This stable-status skill surface is removed only on the experimental "
                    "track in 0.6.157. Stable-channel promotion must preserve the normal "
                    "deprecation/removal policy or keep a compatibility alias."
                ),
            },
            {
                "section": "skills",
                "name": "plan-review-loop",
                "status": "experimental-removal",
                "replacement": "review-before-push",
                "note": (
                    "This stable-status skill surface is removed only on the experimental "
                    "track in 0.6.157. Stable-channel promotion must preserve the normal "
                    "deprecation/removal policy or keep a compatibility alias."
                ),
            },
        ]
        required_migrations = []
        optional_migrations = [
            {
                "id": "replace-native-review-checklist-skill-reference",
                "description": (
                    "AI runtime prompts, docs, or memories that explicitly invoke the "
                    "native-review-checklist skill should invoke review-before-push instead. "
                    "The native Stage 1 sweep procedure now lives in the review-before-push reference."
                ),
            },
            {
                "id": "replace-plan-review-loop-skill-reference",
                "description": (
                    "AI runtime prompts, docs, or memories that explicitly invoke the "
                    "plan-review-loop skill should invoke review-before-push instead. "
                    "The T2/T3 plan review procedure now lives in the review-before-push reference."
                ),
            },
        ]
        behavior_changes = [
            "native-review-checklist is no longer a packaged skill on the experimental channel",
            "plan-review-loop is no longer a packaged skill on the experimental channel",
            "review-before-push now owns risk-tier review selection, T2/T3 plan review, native Stage 1 sweep, and cross-model implementation review workflows",
            "public contract skill metadata removes the absorbed review skills and points review workflow users to review-before-push",
            "generated adapter text and setup-runtime docs no longer advertise plan-review-loop as a separate Codex skill",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters only change their detailed-policy skill pointer when rerendered.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Because AI runtime skill surfaces are removed, this release is not WIP-safe for automatic active-lane upgrades.",
            "Pinning back to 0.6.156 restores native-review-checklist and plan-review-loop as separate packaged skills.",
        ]
    elif parsed_version == version_tuple("0.6.156"):
        wip_safe = False
        deprecated_surfaces = [
            {
                "section": "skills",
                "name": "methodology-feature-request",
                "status": "experimental-removal",
                "replacement": "methodology-regression-rca",
                "note": (
                    "This stable-status skill surface is removed only on the experimental "
                    "track in 0.6.156. Stable-channel promotion must preserve the normal "
                    "deprecation/removal policy or keep a compatibility alias."
                ),
            }
        ]
        required_migrations = []
        optional_migrations = [
            {
                "id": "replace-methodology-feature-request-skill-reference",
                "description": (
                    "AI runtime prompts, docs, or memories that explicitly invoke the "
                    "methodology-feature-request skill should invoke methodology-regression-rca "
                    "for framework intake instead. Feature-request validation and publication "
                    "commands remain available."
                ),
            }
        ]
        behavior_changes = [
            "methodology-feature-request is no longer a packaged skill on the experimental channel",
            "methodology-regression-rca now owns framework intake for both regression RCAs and net-new methodology feature requests",
            "feature-request artifact validation and publication CLI commands remain available",
            "public contract skill metadata removes methodology-feature-request and points feature-request users to methodology-regression-rca",
            "setup-runtime docs no longer advertise methodology-feature-request as a separate Codex skill",
        ]
        rollback_notes = [
            "No adapter migration is required and no generated adapter files change in this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Because an AI runtime skill surface is removed, this release is not WIP-safe for automatic active-lane upgrades.",
            "Pinning back to 0.6.155 restores methodology-feature-request as a separate packaged skill.",
        ]
    elif parsed_version == version_tuple("0.6.155"):
        wip_safe = False
        deprecated_surfaces = [
            {
                "section": "skills",
                "name": "document-context-budget",
                "status": "experimental-removal",
                "replacement": "context-continuity",
                "note": (
                    "This stable-status skill surface is removed only on the experimental "
                    "track in 0.6.155. Stable-channel promotion must preserve the normal "
                    "deprecation/removal policy or keep a compatibility alias."
                ),
            }
        ]
        required_migrations = []
        optional_migrations = [
            {
                "id": "replace-document-context-budget-skill-reference",
                "description": (
                    "AI runtime prompts, docs, or memories that explicitly invoke the "
                    "document-context-budget skill should invoke context-continuity instead. "
                    "The document context budget procedure now lives in the context continuity reference."
                ),
            }
        ]
        behavior_changes = [
            "document-context-budget is no longer a packaged skill on the experimental channel",
            "context-continuity now owns Markdown context loading, bounded audits, archive classification, index hygiene, handoffs, and rotation",
            "public contract skill metadata removes document-context-budget and points document-context users to context-continuity",
            "generated adapter text points detailed document-context policy to context-continuity",
            "setup-runtime docs no longer advertise document-context-budget as a separate Codex skill",
        ]
        rollback_notes = [
            "No adapter migration is required; generated adapters only change their detailed-policy skill pointer when rerendered.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Because an AI runtime skill surface is removed, this release is not WIP-safe for automatic active-lane upgrades.",
            "Pinning back to 0.6.154 restores document-context-budget as a separate packaged skill.",
        ]
    elif parsed_version == version_tuple("0.6.154"):
        wip_safe = False
        deprecated_surfaces = [
            {
                "section": "skills",
                "name": "operator-visibility",
                "status": "experimental-removal",
                "replacement": "delivery-summary",
                "note": (
                    "This stable-status skill surface is removed only on the experimental "
                    "track in 0.6.154. Stable-channel promotion must preserve the normal "
                    "deprecation/removal policy or keep a compatibility alias."
                ),
            }
        ]
        required_migrations = []
        optional_migrations = [
            {
                "id": "replace-operator-visibility-skill-reference",
                "description": (
                    "AI runtime prompts, docs, or memories that explicitly invoke the "
                    "operator-visibility skill should invoke delivery-summary instead. "
                    "The operator progress cadence now lives in the delivery summary reference."
                ),
            }
        ]
        behavior_changes = [
            "operator-visibility is no longer a packaged skill on the experimental channel",
            "delivery-summary now owns in-progress operator update cadence plus completed-work summaries",
            "public contract skill metadata removes operator-visibility and points progress/status users to delivery-summary",
            "setup-runtime docs no longer advertise operator-visibility as a separate Codex skill",
        ]
        rollback_notes = [
            "No adapter migration is required and no generated adapter files change in this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Because an AI runtime skill surface is removed, this release is not WIP-safe for automatic active-lane upgrades.",
            "Pinning back to 0.6.153 restores operator-visibility as a separate packaged skill.",
        ]
    elif parsed_version == version_tuple("0.6.153"):
        wip_safe = False
        deprecated_surfaces = [
            {
                "section": "skills",
                "name": "early-warning-smoke",
                "status": "experimental-removal",
                "replacement": "background-task-monitoring",
                "note": (
                    "This stable-status skill surface is removed only on the experimental "
                    "track in 0.6.153. Stable-channel promotion must preserve the normal "
                    "deprecation/removal policy or keep a compatibility alias."
                ),
            }
        ]
        required_migrations = []
        optional_migrations = [
            {
                "id": "replace-early-warning-smoke-skill-reference",
                "description": (
                    "AI runtime prompts, docs, or memories that explicitly invoke the "
                    "early-warning-smoke skill should invoke background-task-monitoring instead. "
                    "The early-warning smoke procedure now lives in the background monitoring reference."
                ),
            }
        ]
        behavior_changes = [
            "early-warning-smoke is no longer a packaged skill on the experimental channel",
            "background-task-monitoring now owns early-warning smoke trigger, monitor reuse, stale/failure, queued-PR, and final-preflight planning policy",
            "public contract skill metadata removes early-warning-smoke and points monitoring users to background-task-monitoring",
            "setup-runtime docs no longer advertise early-warning-smoke as a separate Codex skill",
        ]
        rollback_notes = [
            "No adapter migration is required and no generated adapter files change in this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Because an AI runtime skill surface is removed, this release is not WIP-safe for automatic active-lane upgrades.",
            "Pinning back to 0.6.152 restores early-warning-smoke as a separate packaged skill.",
        ]
    elif parsed_version == version_tuple("0.6.152"):
        wip_safe = True
        deprecated_surfaces = []
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "scripts/validate.sh no longer requires the current methodology release-update Google Chat delivery marker",
            "public-release-check now owns release-update delivery accountability for shipped and current releases",
            "release-update-status keeps reporting overdue releases, while its current-release failure mode is hidden from normal CLI help and reserved for maintainer release checks",
            "core validation can run in checkouts with no Minervit announcement webhook configured",
        ]
        rollback_notes = [
            "No adapter migration is required and no generated adapter files change in this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "This release is WIP-safe because it removes an infrastructure-only validation dependency without changing project adapters, generated files, hooks, or lane startup behavior.",
            "Pinning back to 0.6.151 restores validation-time release-update marker enforcement.",
        ]
    elif parsed_version == version_tuple("0.6.151"):
        wip_safe = False
        deprecated_surfaces = []
        required_migrations = []
        optional_migrations = [
            {
                "id": "run-iteration-review-renderer-setup",
                "description": (
                    "Teams that render iteration-review recap videos should run "
                    "`minervit-methodology iteration-review-renderer-setup` once per machine. "
                    "The command copies renderer source into ~/.local/state/minervit/renderer-kit/ "
                    "and installs npm dependencies there instead of inside the plugin tree."
                ),
            }
        ]
        behavior_changes = [
            "iteration-review renderer dependencies now install into ~/.local/state/minervit/renderer-kit instead of the plugin tree",
            "iteration-review-status reports both renderer source and renderer cache paths without running npm or blocking startup when the cache is cold",
            "iteration-review renderer docs, examples, and skill policy now forbid plugin-tree node_modules and generated media",
            "renderer hygiene tests assert no plugin node_modules are tracked or present and the setup cache remains outside the methodology repo",
        ]
        rollback_notes = [
            "No adapter migration is required and no generated adapter files change in this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Because iteration-review video setup changes where npm dependencies live, this release is not WIP-safe for automatic active-lane upgrades.",
            "Pinning back to 0.6.150 restores the previous renderer instructions; remove ~/.local/state/minervit/renderer-kit if you want to discard the user-state cache.",
        ]
    elif parsed_version == version_tuple("0.6.150"):
        wip_safe = False
        deprecated_surfaces = []
        required_migrations = []
        optional_migrations = [
            {
                "id": "install-minervit-delivery-ops-plugin",
                "description": (
                    "Users who invoke iteration-review, milestone-update, session-journal, "
                    "usage-accounting, event-observability, or database-migration-collision skills "
                    "from their AI runtime should install or enable the new minervit-delivery-ops plugin. "
                    "CLI commands continue to resolve their assets from the methodology checkout."
                ),
            }
        ]
        behavior_changes = [
            "delivery operations skills move from the core methodology plugin into the optional minervit-delivery-ops plugin",
            "the core plugin keeps development process skills, hooks, startup gates, review gates, and adapter lifecycle behavior",
            "the public contract manifest now scans both packaged skill roots so moved skills remain declared stable surfaces",
            "iteration-review renderer assets now live under plugins/minervit-delivery-ops/skills/iteration-review/renderer-kit",
        ]
        rollback_notes = [
            "No adapter migration is required and no generated adapter files change in this release.",
            "Stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Because plugin packaging and renderer paths change, this release is not WIP-safe for automatic active-lane upgrades.",
            "If an AI runtime cannot find an ops skill after upgrade, install or enable minervit-delivery-ops; pinning back to 0.6.149 restores the previous single-plugin package layout.",
        ]
    elif parsed_version == version_tuple("0.6.149"):
        wip_safe = False
        deprecated_surfaces = []
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "Stop And Deferral Red Flags policy now states the state-based stop principle instead of carrying the long phrase-lawyer example list",
            "the policy module points agents to blocker-declare for true blockers and identifies phrase matches as advisory telemetry during transition",
            "tests ratchet methodology/policy/06a-stop-and-deferral-red-flags.md to 350 words and at most five forbidden-example lines",
        ]
        rollback_notes = [
            "No adapter migration is required; stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Generated canonical policy changes only after rerendering from the policy source; existing pinned stable lanes keep their current generated rules until safe upgrade.",
            "Because agent-facing policy wording changes, this release is not WIP-safe for automatic active-lane upgrades.",
        ]
    elif parsed_version == version_tuple("0.6.148"):
        wip_safe = False
        deprecated_surfaces = []
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "guard-check --boundary prepush composes review-evidence, board-currency, CI-test, and blocker-state gates for runtimes without a Claude Stop hook",
            "generated Git pre-push hooks now call guard-check instead of duplicating runtime-specific gate commands",
            "pre-push guard-check blocks stale .ai-work/BLOCKER.json records so old blocker state cannot quietly coexist with a push boundary",
        ]
        rollback_notes = [
            "No adapter migration is required; stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Run lane-start or install-hooks at a safe boundary to regenerate Git hooks with guard-check.",
            "Run blocker-clear or blocker-declare with current evidence if a stale BLOCKER.json blocks a push boundary.",
            "Because pre-push hook behavior changes, this release is not WIP-safe for automatic active-lane upgrades.",
        ]
    elif parsed_version == version_tuple("0.6.147"):
        wip_safe = False
        deprecated_surfaces = []
        required_migrations = []
        optional_migrations = [
            {
                "id": "responseGuard.phraseChecks",
                "description": (
                    "Adapters that need the old phrase-only Stop-hook blocking during transition may "
                    "set responseGuard.phraseChecks=blocking. Omitted adapters use advisory phrase telemetry."
                ),
            }
        ]
        behavior_changes = [
            "Claude Stop-hook blocking now starts from lane state: active goals require continuing authorized work, a fresh true-blocker record, a terminal goal ledger, or documented context rotation",
            "phrase-only Stop guard checks are advisory telemetry by default and write .ai-runs/guard-events.jsonl without blocking",
            "responseGuard.phraseChecks is an internal adapter compatibility key with advisory, blocking, and off modes",
            "blocked Stop-hook recovery text now names the three legal exits instead of listing incident-specific recovery prose",
        ]
        rollback_notes = [
            "No adapter migration is required; stable clients remain on the stable channel unless they explicitly opt into experimental.",
            "Set responseGuard.phraseChecks=blocking to restore legacy phrase-blocking behavior for a specific adapter during transition.",
            "Run blocker-clear or delete .ai-work/BLOCKER.json to discard stale local blocker state; use blocker-declare to create a fresh legal stop exit.",
            "Because Stop-hook behavior changes, this release is not WIP-safe for automatic active-lane upgrades.",
        ]
    elif parsed_version == version_tuple("0.6.146"):
        wip_safe = True
        deprecated_surfaces = []
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "blocker-declare writes a lane-local .ai-work/BLOCKER.json true-blocker record with kind, reason, optional artifact, and active goal_id when present",
            "blocker-status reports blocker freshness and marks records stale when they are older than 30 minutes or no longer match the active goal ledger",
            "blocker-clear removes the lane-local blocker record",
            "no-safe-parallel-work blockers now require --checked evidence naming diff review, allowed validation, docs/evidence updates, next queue item, and monitor follow-up",
        ]
        rollback_notes = [
            "Existing products and clients require no migration; these commands are additive and the Stop guard behavior is unchanged in this release.",
            "Delete .ai-work/BLOCKER.json or run blocker-clear if a lane needs to discard a local blocker record.",
            "Revert to the previous pinned stable release if a product temporarily cannot tolerate the new blocker command surface.",
        ]
    elif parsed_version == version_tuple("0.6.145"):
        wip_safe = True
        deprecated_surfaces = []
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "minervit-methodology init now provides the unmanaged-repo front door: write the bootstrap interview, print unresolved setup questions, and continue after answers",
            "init reuses an existing bootstrap interview instead of overwriting partial answers, and init --continue refuses to overwrite existing source adapters or unmanaged CLAUDE.md/AGENTS.md instruction files",
            "init --continue writes .minervit/adapter.json, renders generated adapter files, and runs lane-start only for unmanaged repos; managed repos no-op instead of being overwritten",
            "lane-start missing-adapter recovery now points to one command, minervit-methodology init --target .",
            "README.md is now a concise public onboarding entrypoint, with public contract and release-export commands moved to docs/reference/operations/release-engineering.md",
        ]
        rollback_notes = [
            "Existing managed products and clients require no migration; their current generated adapters and framework pins keep working.",
            "If a new adopter is not ready to complete init, delete the lane-local .ai-work/ADAPTER_BOOTSTRAP_INTERVIEW.md artifact and leave the repo unmanaged until setup resumes.",
            "Revert to the previous pinned stable release if a product temporarily cannot tolerate the new onboarding command surface or README shape.",
        ]
    elif parsed_version == version_tuple("0.6.144"):
        wip_safe = True
        deprecated_surfaces = []
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "stop-response, Bash, and ExitPlanMode guards now append fail-open lane-local JSONL guard-fire events under .ai-runs/guard-events.jsonl",
            "guard-report summarizes per-check evaluated/fired counts with text or JSON output so productization cleanup can be driven by observed false-block and fire-rate data",
            "the productization refactor plan is now tracked internally as the ordered Phase 0 through Phase 6 source-of-truth plan",
        ]
        rollback_notes = [
            "Delete .ai-runs/guard-events.jsonl if a lane wants to discard local telemetry evidence; the file is lane-local and not required for operation.",
            "Revert to the previous pinned stable release if a product temporarily cannot tolerate lane-local guard telemetry files.",
            "No adapter migration is required; guard decisions and hook block/pass behavior are unchanged.",
        ]
    elif parsed_version == version_tuple("0.6.143"):
        wip_safe = True
        deprecated_surfaces = []
        required_migrations = []
        optional_migrations = [
            {
                "id": "set-framework-channel",
                "description": (
                    f"Use `minervit-methodology set-framework-channel --target . stable|experimental` "
                    f"to write {FRAMEWORK_PIN_FILE} for a lane/adopter checkout."
                ),
            },
            {
                "id": "direct-main-non-dev-profile",
                "description": (
                    "Repos with an explicit direct-main docs/support workflow may set "
                    "workProfiles.profiles.<profile>.pushPolicy=direct-main; the default remains pr-branch-only."
                ),
            },
        ]
        behavior_changes = [
            "set-framework-channel provides a first-class way for lanes to select stable or experimental framework channel through .minervit/pin.json",
            "stable channel updates target the stable release branch while experimental channel updates target origin/experimental instead of silently following stable main",
            "methodology reset/auto-rescue preserves tracked private source adapters removed from the public tree and restores them as ignored local files after reset",
            "non-dev docs/support profiles may opt into direct-main pushes with pushPolicy=direct-main while the default remains pr-branch-only",
        ]
        rollback_notes = [
            "Delete .minervit/pin.json or run set-framework-channel stable to return a lane to the stable channel.",
            "Remove pushPolicy=direct-main from workProfiles to restore PR-branch-only non-dev push behavior.",
            "Private adapters restored under adapters/projects/*.json remain ignored local files; migrate them to .minervit/adapter.json at a safe boundary.",
        ]
    elif parsed_version == version_tuple("0.6.142"):
        wip_safe = True
        deprecated_surfaces = []
        required_migrations = []
        optional_migrations = [
            {
                "id": "declare-wsl2-windows-runtime",
                "description": (
                    "Adapters with Windows contributors may add developmentEnvironment.supportedRuntimes "
                    "and mark native Windows unsupported with WSL2 as the supported Windows runtime."
                ),
            }
        ]
        behavior_changes = [
            "adapters can now declare supported lane runtimes, including WSL2 as the supported Windows path when native win32 shells are not release-evidence runtimes",
            "lane-start and methodology-status now print the detected development runtime and fail early on explicit native-Windows-unsupported adapters instead of launching incompatible gates",
            "generated adapters now include a Development Environment section with WSL2 and line-ending guidance for Windows contributors",
        ]
        rollback_notes = [
            "Remove the optional developmentEnvironment block or revert to the previous pinned stable release if a repo needs to temporarily avoid runtime enforcement.",
            "Existing adapters without developmentEnvironment remain compatible and do not fail startup solely because this release is installed.",
        ]
    elif parsed_version == version_tuple("0.6.141"):
        wip_safe = True
        deprecated_surfaces = []
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical Background Work policy is now a concise stable contract while detailed monitor, polling, stale/hung, provider-recovery, and queued-PR procedure lives in the background-task-monitoring skill/reference",
            "canonical-rules.md is regenerated with the smaller Background Work module while preserving passive-monitor-stop, response-guard, ScheduleWakeup, stale/hung, and terminal-state markers",
            "tests now pin the Background Work policy module at 690 words so detailed monitoring procedure does not silently return to canonical policy",
        ]
        rollback_notes = [
            "Revert to the previous pinned stable release if a lane depends on canonical-rules.md containing the longer Background Work procedure prose.",
            "No adapter migration is required; this changes policy organization and generated compatibility text only.",
        ]
    elif parsed_version == version_tuple("0.6.140"):
        wip_safe = True
        deprecated_surfaces = []
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "sanctioned active-status updates for board-backed native subtasks now also claim the native parent issue active",
            "terminal parent issues are not reopened when a child/subtask active update is reconciled",
            "board-currency policy, board-item-update skill guidance, and backlog-provider operations docs now pin the reciprocal parent/subtask active-status invariant",
        ]
        rollback_notes = [
            "Revert to the previous pinned stable release if a product temporarily cannot tolerate automatic parent active-status reconciliation.",
            "No adapter migration is required; this changes status reconciliation behavior and public policy text only.",
        ]
    elif parsed_version == version_tuple("0.6.139"):
        wip_safe = True
        deprecated_surfaces = []
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical Context Continuity policy is now a shorter stable contract while detailed handoff/resume procedure lives in the context-continuity skill and reference",
            "tests now pin the context continuity policy module at 850 words so detailed startup/handoff procedure does not silently return to canonical policy",
            "canonical-rules.md is regenerated with the smaller context continuity module while preserving startup-gate ordering markers",
        ]
        rollback_notes = [
            "Revert to the previous pinned stable release if a lane depends on the longer canonical Context Continuity prose.",
            "No adapter migration is required; this changes policy organization and compatibility text only.",
        ]
    elif parsed_version == version_tuple("0.6.138"):
        wip_safe = True
        deprecated_surfaces = []
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical Delivery Summaries policy is now a concise stable contract while detailed closeout procedure lives in the delivery-summary and continuity skills",
            "generated adapters render a shorter Delivery Summaries section that points to the delivery-summary skill for detailed procedure",
            "tests now pin the delivery summary policy module at 525 words so detailed closeout prose does not silently return to canonical policy",
        ]
        rollback_notes = [
            "Revert to the previous pinned stable release if a lane depends on generated adapters containing the longer delivery-summary prose.",
            "No adapter migration is required; this changes policy organization and generated compatibility text only.",
        ]
    elif parsed_version == version_tuple("0.6.137"):
        wip_safe = True
        deprecated_surfaces = []
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical Drive, Do Not Defer, Anti-Work-Evasion, and Plain-Language Status policy modules are merged into the concise Autonomy And Status contract",
            "generated adapters now render one Autonomy And Status section instead of three separate autonomy/status sections",
            "tests now pin the combined policy module at 600 words so detailed evasion/status taxonomy does not silently return to canonical policy",
        ]
        rollback_notes = [
            "Revert to the previous pinned stable release if a lane depends on generated adapters containing separate Drive, Anti-Work-Evasion, or Plain-Language Status headings.",
            "No adapter migration is required; this changes policy organization and generated compatibility text only.",
        ]
    elif parsed_version == version_tuple("0.6.136"):
        wip_safe = True
        deprecated_surfaces = []
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "canonical Review Before Push policy is now a concise 500-word-capped contract while detailed review procedure lives in the review-before-push and native-review-checklist skills",
            "canonical-rules.md is smaller by roughly one thousand words without changing review gate behavior",
            "tests now pin the review policy size ratchet and detailed skill ownership so duplicated review prose does not silently return",
        ]
        rollback_notes = [
            "Revert to the previous pinned stable release if a lane depends on canonical-rules.md containing the old detailed review procedure prose.",
            "No adapter migration is required; this changes policy organization and generated compatibility text only.",
        ]
    elif parsed_version == version_tuple("0.6.135"):
        wip_safe = True
        deprecated_surfaces = []
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "latest-code hook no longer blocks read-only Claude tool calls when the baseline is stale or missing",
            "install-hooks rewrites legacy latest-code matcher=* entries into state-changing tool matchers for Bash, Edit, MultiEdit, Write, NotebookEdit, ExitPlanMode, and Task",
            "latest-code stale-baseline guidance now tells lanes to refresh before edits, plan finalization, commits, pushes, or deep/current-status analysis instead of blocking ordinary inspection",
        ]
        rollback_notes = [
            "Revert to the previous pinned stable release if a product depends on latest-code blocking every read-only tool call.",
            "No adapter migration is required; rerun lane-start or install-hooks at a safe boundary to rewrite old Claude settings away from matcher=*.",
        ]
    elif parsed_version == version_tuple("0.6.134"):
        wip_safe = True
        deprecated_surfaces = []
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "init-project-adapter now writes <target>/.minervit/adapter.json by default instead of methodology_repo/adapters/projects/<project>.json",
            "repo-local init scaffolds use a stable public adapter-schema URL instead of embedding a local methodology checkout path",
            "project-bootstrap policy, skills, and operations docs now teach adopter-owned .minervit/adapter.json as the default source adapter path",
        ]
        rollback_notes = [
            "Pass --output <methodology_repo>/adapters/projects/<project>.json explicitly if a framework maintainer needs the legacy scaffold location.",
            "No existing product/client adapter migration is required; this changes only new init-project-adapter defaults.",
        ]
    elif parsed_version == version_tuple("0.6.133"):
        wip_safe = True
        deprecated_surfaces = []
        required_migrations = []
        optional_migrations = [
            {
                "id": "rerender-repo-local-generated-adapter",
                "surface": "generated-adapter",
                "description": (
                    "Only adopter repos that already migrated their source adapter to .minervit/adapter.json "
                    "and then ran lane-start on 0.6.123-0.6.132 may have an absolute _generated.sourceAdapter. "
                    "Re-render at a safe boundary to restore the portable .minervit/adapter.json path."
                ),
                "command": f"minervit-methodology render-adapters --project {REPO_LOCAL_ADAPTER_FILE} --target <repo> --write --json-only",
            }
        ]
        behavior_changes = [
            "lane-start preserves repo-local .minervit/adapter.json in generated adapters instead of rewriting it to an absolute filesystem path",
            "repo-local adapter migration now has an end-to-end regression covering migrate-adapter, render-adapters, lane-start, sourceAdapter, and sourceAdapterSha256",
            "METH-FU-ADAPTER-IN-ADOPTER-REPO is marked implemented first pass for migration/render/lane-start; zero-config init remains separate follow-up work",
        ]
        rollback_notes = [
            "Revert to the previous pinned stable release if an adopter workflow depends on absolute _generated.sourceAdapter paths for repo-local adapters.",
            "No required product adapter migration is introduced; affected repo-local adopters can re-render generated adapters at a safe boundary.",
        ]
    elif parsed_version == version_tuple("0.6.132"):
        wip_safe = True
        deprecated_surfaces = []
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "scripts/test.sh now enforces a static external-process budget for scripts/validate.sh through tests/test_validate_freeze.py",
            "validate.sh freeze tracking now covers subprocess-site growth as well as line-count growth",
        ]
        rollback_notes = [
            "Revert to the previous pinned stable release if a methodology contributor workflow depends on adding new validate.sh shell/grep subprocess sites.",
            "No product adapter migration is required.",
        ]
    elif parsed_version == version_tuple("0.6.131"):
        wip_safe = True
        deprecated_surfaces = []
        required_migrations = []
        optional_migrations = [
            {
                "id": "regenerate-bug-intake-skill-reference",
                "surface": "generated-adapter",
                "description": (
                    "No generated-adapter change is expected from this release; regenerate only "
                    "if the product is already refreshing adapters at a safe boundary."
                ),
                "command": "minervit-methodology render-adapters --project <adapter> --target <repo> --write",
            }
        ]
        behavior_changes = [
            "adapter-aware bug intake is now active documented policy through the bug-intake-triage skill",
            "bug filing/planning should follow adapter bugBacklog source-of-truth, mirror, severity, and required-field policy instead of asking the operator to choose a tracker",
        ]
        rollback_notes = [
            "Revert to the previous pinned stable release if a product depends on pre-skill bug intake wording.",
            "No automatic adapter migration should run during active implementation; existing bugBacklog adapter fields remain compatible.",
        ]
    elif parsed_version == version_tuple("0.6.130"):
        wip_safe = False
        deprecated_surfaces = []
        required_migrations = []
        optional_migrations = [
            {
                "id": "regenerate-throughput-adapter-text",
                "surface": "generated-adapter",
                "description": (
                    "Regenerate generated adapters at a safe boundary to pick up the new throughput, "
                    "risk-tier, early-warning smoke, and concise status guidance."
                ),
                "command": "minervit-methodology render-adapters --project <adapter> --target <repo> --write",
            }
        ]
        behavior_changes = [
            "review round budgets are T0=0, T1=1, and T2/T3=2 by default",
            "codex-run rejects T0 implementation review and skips Stage 1 artifact requirements for T1 while accepting valid legacy T1 evidence",
            "legacy adapters with positive review.roundBudgets.T0 warn and coerce to 0 instead of failing startup",
            "plan review is capped at two rounds by default; R3 requires explicit structural Critical evidence",
            "early-warning smoke is conditional instead of a standing startup gate",
            "generated adapters and response guards no longer require boundary-summary headings for ordinary status",
            "Claude auto-compact guidance moves to 85 percent and model-effort policy is tier-aware",
        ]
        rollback_notes = [
            "Revert to the previous pinned stable release if a product depends on the prior broader review-round or standing-smoke behavior.",
            "No automatic adapter migration should run during active implementation; regenerate generated adapters at the next safe lane boundary.",
            "If a legacy adapter warns about a positive T0 budget, update it to 0 or remove the key at a safe boundary.",
        ]
    elif parsed_version == version_tuple("0.6.127"):
        wip_safe = False
        deprecated_surfaces = []
        required_migrations = [
            {
                "id": "t0-review-budget-zero",
                "surface": "adapter",
                "description": (
                    "Adapters that explicitly set review.roundBudgets.T0 to a positive value must "
                    "set it to 0 or remove the key. T0 work is documentation/config-only and no "
                    "longer supports an implementation review round."
                ),
                "command": (
                    "edit adapter review.roundBudgets.T0 to 0, then run "
                    "minervit-methodology render-adapters --project <adapter> --target <repo> --write"
                ),
            }
        ]
        optional_migrations = [
            {
                "id": "regenerate-throughput-adapter-text",
                "surface": "generated-adapter",
                "description": (
                    "Regenerate generated adapters at a safe boundary to pick up the new throughput, "
                    "risk-tier, early-warning smoke, and concise status guidance."
                ),
                "command": "minervit-methodology render-adapters --project <adapter> --target <repo> --write",
            }
        ]
        behavior_changes = [
            "review round budgets are T0=0, T1=1, and T2/T3=2 by default",
            "codex-run rejects T0 implementation review and skips Stage 1 artifact requirements for T1 while accepting valid legacy T1 evidence",
            "plan review is capped at two rounds by default; R3 requires explicit structural Critical evidence",
            "early-warning smoke is conditional instead of a standing startup gate",
            "generated adapters and response guards no longer require boundary-summary headings for ordinary status",
            "Claude auto-compact guidance moves to 85 percent and model-effort policy is tier-aware",
        ]
        rollback_notes = [
            "Revert to 0.6.126 if a product depends on the prior broader review-round or standing-smoke behavior.",
            "If an adapter explicitly used a positive T0 review budget, keep that adapter pinned below 0.6.127 until the budget is changed to 0 or removed.",
            "No automatic adapter migration should run during active implementation; regenerate generated adapters at the next safe lane boundary.",
        ]
    elif parsed_version == version_tuple("0.6.126"):
        wip_safe = True
        deprecated_surfaces = []
        required_migrations = []
        optional_migrations = []
        behavior_changes = [
            "claude-review records a structured claude_transport_failure for nonzero Claude CLI exits",
            "Claude CLI signal exits such as 143 include signal metadata and an explicit recovery next action",
            "failed Claude transport runs do not write review_result and return a transport-failure exit code, normalizing direct signal returns to 128+signal",
        ]
        rollback_notes = [
            "Revert to 0.6.125 if downstream tooling cannot tolerate the additional claude_transport_failure artifact field.",
            "No adapter rollback is required because no adapter schema or generated adapter behavior changed.",
        ]
    elif version_tuple(version) >= version_tuple("0.6.125"):
        required_migrations = [
            {
                "id": "adapter-framework-pin",
                "surface": "adapter",
                "description": (
                    "Required for adopters upgrading from pre-pin releases. Already-pinned adapters "
                    "have no new required 0.6.125 migration."
                ),
                "command": "minervit-methodology migrate-adapter <adapter> --write",
            }
        ]
        optional_migrations = [
            {
                "id": "goalTracker-to-backlogProvider",
                "surface": "adapter",
                "description": "Remove deprecated goalTracker after backlogProvider is present.",
                "command": "minervit-methodology migrate-adapter <adapter> --write",
            },
            {
                "id": "repo-local-adapter",
                "surface": "adapter",
                "description": f"Move product source adapters from methodology adapters/projects/*.json to {REPO_LOCAL_ADAPTER_FILE} in each adopter repo.",
                "command": "minervit-methodology migrate-adapter <adapter> --adopter-target <repo> --write",
            },
            {
                "id": "adapter-work-profiles-policy",
                "surface": "adapter",
                "description": (
                    "Regenerate adapters or add workProfiles only when a repo needs custom "
                    "non-dev docs/assets path, extension, push, or size policy. Old configs "
                    "default to development."
                ),
                "command": "minervit-methodology render-adapters --project <adapter> --target <repo> --write",
            }
        ]
        behavior_changes = [
            "lane-start accepts --profile development|product-docs|support-docs and writes an explicit local work-profile lock",
            "missing or old profile config defaults to development with existing startup, git, review, test, board, and code gates",
            "Git pre-commit/pre-push hooks run work-profile-check before development gates",
            "non-dev profiles can skip implementation-only gates only for adapter-approved docs/assets diffs on non-base branches",
            "non-dev profiles block code, config, generated artifacts, secrets, symlinks, oversized files, and direct base-branch pushes",
            "methodology-status reports the active work profile and how to return to development",
        ]
        rollback_notes = [
            "Keep the previous adapter JSON and generated .minervit-ai-delivery.json in git history.",
            "Revert the adapter migration commit and re-render from the prior pinned framework version.",
            "Leave lane-local methodology locks in place for active work until the rollback is verified.",
        ]
    else:
        required_migrations = [
            {
                "id": "adapter-framework-pin",
                "surface": "adapter",
                "description": "Adapters should declare _framework or adopter repos should declare .minervit/pin.json.",
                "command": "minervit-methodology migrate-adapter <adapter> --write",
            }
        ]
        optional_migrations = [
            {
                "id": "goalTracker-to-backlogProvider",
                "surface": "adapter",
                "description": "Remove deprecated goalTracker after backlogProvider is present.",
                "command": "minervit-methodology migrate-adapter <adapter> --write",
            },
            {
                "id": "repo-local-adapter",
                "surface": "adapter",
                "description": f"Move product source adapters from methodology adapters/projects/*.json to {REPO_LOCAL_ADAPTER_FILE} in each adopter repo.",
                "command": "minervit-methodology migrate-adapter <adapter> --adopter-target <repo> --write",
            },
        ]
        behavior_changes = [
            "lane-start honors stable/experimental framework pins before auto-update",
            "stable channel refuses raw main updates without signed or pinned trust",
            "active goal/milestone/execution/review/branch work blocks automatic minor/major framework updates",
        ]
        rollback_notes = [
            "Keep the previous adapter JSON and generated .minervit-ai-delivery.json in git history.",
            "Revert the adapter migration commit and re-render from the prior pinned framework version.",
            "Leave lane-local methodology locks in place for active work until the rollback is verified.",
        ]
    return {
        "schema": RELEASE_MIGRATION_REPORT_SCHEMA,
        "version": version,
        "wipSafe": bool(wip_safe),
        "requiredMigrations": required_migrations,
        "optionalMigrations": optional_migrations,
        "deprecatedSurfaces": deprecated_surfaces,
        "behaviorChanges": behavior_changes,
        "productsTested": products_tested or [],
        "rollbackNotes": rollback_notes,
    }


def release_migration_report_text(
    version: str | None = None,
    wip_safe: bool = False,
    products_tested: list[str] | None = None,
) -> str:
    return json.dumps(
        release_migration_report_data(version=version, wip_safe=wip_safe, products_tested=products_tested),
        indent=2,
        sort_keys=True,
    ) + "\n"


def release_migration_report(args: argparse.Namespace) -> int:
    version = args.version or methodology_version()
    output = (args.output or release_migration_report_path(version)).resolve()
    products_tested = args.product_tested or []
    if args.check and not args.write and not args.product_tested and output.exists():
        try:
            existing = json.loads(output.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError):
            existing = {}
        if isinstance(existing, dict) and isinstance(existing.get("productsTested"), list):
            products_tested = [str(item) for item in existing["productsTested"]]
    text = release_migration_report_text(version=version, wip_safe=args.wip_safe, products_tested=products_tested)
    if args.write:
        write_text_atomic(output, text)
        print(f"release_migration_report_written: {output}")
    if args.check:
        try:
            current = output.read_text(encoding="utf-8")
        except OSError as exc:
            print(f"release_migration_report_check: missing/unreadable {output}: {exc}", file=sys.stderr)
            return 1
        if current != text:
            print(f"release_migration_report_check: stale {output}", file=sys.stderr)
            return 1
        print(f"release_migration_report_check: ok {output}")
    if args.print_report or not (args.write or args.check):
        print(text, end="")
    return 0


def audit(args: argparse.Namespace) -> int:
    _ = load_project(args.project)
    target = args.target.resolve()
    files = [
        "CLAUDE.md",
        "AGENTS.md",
        ".clauderules",
        ".cursorrules",
        "scripts/codex-review-prompt.md",
        "docs/ai-pr-workflow.md",
        "docs/ops/codex-claude-operating-charter-v1.md",
        "docs/ops/claude-continuous-execution-model-v1.md",
    ]
    checks: list[tuple[str, re.Pattern[str], str]] = [
        ("routine-admin-merge", re.compile(r"gh pr merge .+--admin"), "Routine admin merge must be removed."),
        ("watch-until-merged", re.compile(r"watch the queue|until .*MERGED|block .*until .*merge", re.I), "Use queue-and-move-on policy for clean queued PRs."),
        ("wrong-merge-queue-signal", re.compile(r"(poll|watch|wait|wakeup|reschedul|still reports|reports AWAITING_CHECKS).{0,120}(mergeQueueEntry|estimatedTimeToMerge|AWAITING_CHECKS)|(mergeQueueEntry|estimatedTimeToMerge).{0,120}(poll|watch|wait|wakeup|reschedul|still reports|reports AWAITING_CHECKS)", re.I), "Exceptional merge checks must use PR state as the terminal signal."),
        ("merge-known-blockers", re.compile(r"(round|pass)\s*\d+.*(document|defer|deferred).*(merge|ship)|3 rounds maximum", re.I | re.S), "Round caps must not permit Critical/P1 defects."),
        ("local-readiness-fetch", re.compile(r"git\s+fetch\s+origin|Use only .+Lane\d+ workspace", re.I), "Readiness automation must be GitHub-only."),
        ("stale-lane", re.compile(r"(?:[A-Za-z][A-Za-z0-9_-]*-)?Codex-Lane\d+|[A-Za-z][A-Za-z0-9_-]*-Lane[2-9]\d*|Lane[- ]?[2-9]\d* workspace", re.I), "Stale lane reference."),
    ]
    findings: list[str] = []
    for rel in files:
        path = target / rel
        if not path.exists():
            continue
        text = path.read_text(encoding="utf-8", errors="replace")
        for name, pattern, message in checks:
            if pattern.search(text):
                findings.append(f"{name}: {path} - {message}")
    if findings:
        print("Process drift findings:")
        for finding in findings:
            print(f"- {finding}")
        return 1
    print("No known process drift found.")
    return 0


def gh_api(path: str) -> dict:
    proc = subprocess.run(
        ["gh", "api", path],
        check=True,
        text=True,
        encoding="utf-8",
        errors="replace",
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
    )
    return json.loads(proc.stdout)


def gh_content(repo: str, path: str, ref: str) -> str | None:
    try:
        data = gh_api(f"repos/{repo}/contents/{path}?ref={ref}")
    except subprocess.CalledProcessError:
        return None
    content = data.get("content")
    if not content:
        return None
    return base64.b64decode(content).decode("utf-8", errors="replace")


def gh_api_opt(path: str) -> dict | None:
    """gh_api that returns None instead of raising -- for endpoints that legitimately 404/403 (e.g.
    branch protection on an unprotected branch, or without admin scope)."""
    try:
        return gh_api(path)
    except (subprocess.CalledProcessError, json.JSONDecodeError):
        return None


def branch_protection_issues(protection: dict | None, required_checks: list[str]) -> list[str]:
    """Detection baseline (rec #9, P5): a customer-facing main must enforce green required checks and
    re-run them on stale branches (strict). No protection => CI checks are advisory, exactly the #448
    'no branch protection on main' gap."""
    if not protection:
        return ["branch protection is absent on the default branch: required CI checks are advisory, not enforced (P5)"]
    issues: list[str] = []
    rsc = protection.get("required_status_checks")
    if not rsc:
        issues.append("branch protection declares no required_status_checks: a red check cannot block merge")
        return issues
    if not rsc.get("strict", False):
        issues.append("branch protection required_status_checks.strict is false: a stale branch can merge without re-running checks")
    contexts: set[str] = {str(c) for c in (rsc.get("contexts") or [])}
    for check in rsc.get("checks") or []:
        if isinstance(check, dict) and check.get("context"):
            contexts.add(str(check["context"]))
    missing = [c for c in required_checks if c not in contexts]
    if missing:
        issues.append(f"branch protection is missing required checks: {', '.join(missing)}")
    return issues


def canary_conclusion_issue(runs: list[dict], canary_name: str, sha: str) -> str | None:
    """The synthetic canary's LAST conclusion must be success ON THE DEPLOYED SHA -- an outcome signal,
    not just that a canary workflow exists. actions/runs returns newest first."""
    if not canary_name:
        return None
    target = canary_name.strip().lower()
    short = sha[:12]
    candidates = [
        run for run in runs
        if str(run.get("name", "")).strip().lower() == target
        and (str(run.get("head_sha", "")) == sha or str(run.get("head_sha", "")).startswith(short))
    ]
    if not candidates:
        return f"synthetic canary '{canary_name}' has no run on the deployed SHA {short}: detection is unproven on what shipped"
    conclusion = str(candidates[0].get("conclusion"))
    if conclusion != "success":
        return f"synthetic canary '{canary_name}' last conclusion on {short} is {conclusion}, not success"
    return None


READINESS_ALARM_PATTERN = re.compile(r"error[_ -]?id|\balarm\b|\balert\b|on[_ -]?call|pagerduty|opsgenie|sentry|cloudwatch alarm", re.IGNORECASE)


def readiness_alarm_content_issues(contents: dict) -> list[str]:
    """An errorId-alarm content check (rec #9, P5): each declared alarm source must actually wire an
    errorId/alarm, so detection is not outsourced to a human happening to look."""
    issues: list[str] = []
    for path, content in contents.items():
        if content is None:
            issues.append(f"alarm source missing: {path}")
        elif not READINESS_ALARM_PATTERN.search(content):
            issues.append(f"alarm source {path} has no errorId/alarm wiring: detection is outsourced to humans (P5)")
    return issues


def readiness_review(args: argparse.Namespace) -> int:
    readiness: dict = {}
    if args.project:
        data = load_project(args.project)
        repo = data["repo"]
        readiness = data.get("readiness", {}) or {}
        sources = readiness.get("sources", [])
    elif args.repo:
        repo = args.repo
        sources = args.source or []
    else:
        raise SystemExit("readiness-review requires --project or --repo")
    if not sources:
        raise SystemExit("readiness-review requires readiness.sources in the project adapter or at least one --source")
    repo_data = gh_api(f"repos/{repo}")
    branch = repo_data["default_branch"]
    branch_data = gh_api(f"repos/{repo}/branches/{branch}")
    sha = branch_data["commit"]["sha"]
    print(f"repo: {repo}")
    print(f"default_branch: {branch}")
    print(f"sha: {sha}")
    print("sources:")
    for path in sources:
        content = gh_content(repo, path, sha)
        status = "present" if content is not None else "missing"
        print(f"- {path}: {status}")
        if content:
            first_heading = next((line for line in content.splitlines() if line.startswith("#")), "")
            if first_heading:
                print(f"  heading: {first_heading}")
    runs = gh_api(f"repos/{repo}/actions/runs?branch={branch}&per_page=20")
    workflow_runs = runs.get("workflow_runs", [])
    print("recent_workflow_runs:")
    for run in workflow_runs[:5]:
        print(f"- {run['name']}: {run['status']} / {run.get('conclusion')} @ {run['head_sha'][:12]}")

    # Detection-baseline gate (rec #9): fail-closed for a customer-facing target when opted in
    # (readiness.enforcement=block) or when --strict is passed. Default stays informational (return 0).
    enforcement = str(readiness.get("enforcement", "off")).strip().lower()
    if not (args.strict or enforcement == "block"):
        return 0
    required_checks = [str(c) for c in (readiness.get("requiredChecks") or [])]
    canary_name = str(readiness.get("canaryWorkflow", "")).strip()
    alarm_sources = [str(p) for p in (readiness.get("alarmSources") or [])]
    issues: list[str] = []
    issues.extend(branch_protection_issues(gh_api_opt(f"repos/{repo}/branches/{branch}/protection"), required_checks))
    canary_issue = canary_conclusion_issue(workflow_runs, canary_name, sha)
    if canary_issue:
        issues.append(canary_issue)
    if alarm_sources:
        issues.extend(readiness_alarm_content_issues({path: gh_content(repo, path, sha) for path in alarm_sources}))
    print("detection_baseline:")
    for issue in issues:
        print(f"readiness_issue: {issue}", file=sys.stderr)
    if issues:
        print("readiness_review: FAIL (detection baseline not met)", file=sys.stderr)
        return 1
    print("readiness_review: pass (detection baseline met)")
    return 0


def process_identity(pid: int) -> str | None:
    if pid < 1:
        return None
    try:
        proc = subprocess.run(
            ["ps", "-p", str(pid), "-o", "lstart="],
            text=True,
            encoding="utf-8",
            errors="replace",
            stdout=subprocess.PIPE,
            stderr=subprocess.DEVNULL,
            check=False,
        )
    except OSError:
        return None
    if proc.returncode != 0 or not proc.stdout.strip():
        return None
    return " ".join(proc.stdout.split())


def count_live_background_runs(log_dir: Path) -> int:
    """Count minervit background runs still alive in a log dir (by their .pid files).

    Used by the fan-out runaway guard. A stale .pid (dead process) is not counted.
    """
    if not log_dir.is_dir():
        return 0
    live = 0
    for pid_file in log_dir.glob("*.pid"):
        if pid_file.name.endswith(".watchdog.pid"):
            continue
        try:
            pid = int(pid_file.read_text(encoding="utf-8").strip())
        except (OSError, ValueError):
            continue
        try:
            os.kill(pid, 0)  # signal 0: liveness probe, sends nothing
        except OSError:
            continue
        live += 1
    return live


def background_run(args: argparse.Namespace) -> int:
    if not args.command:
        raise SystemExit("background-run requires a command after --")
    env = os.environ.copy()
    codex_fast_mode_state = "unmanaged"
    adapter_data: dict | None = None
    adapter_root = find_adapter_root(Path.cwd())
    if adapter_root:
        adapter_data = load_project(adapter_marker_path(adapter_root))
        env = codex_fast_mode_env(adapter_data, adapter_root, env)
        codex_fast_mode_state = codex_fast_mode_status(adapter_data)
    timeout_seconds = int(args.timeout_seconds or 0)
    if timeout_seconds < 0:
        raise SystemExit("--timeout-seconds must be zero or positive")
    if timeout_seconds and not hasattr(os, "killpg"):
        raise SystemExit("--timeout-seconds requires POSIX process-group support")
    log = args.log.resolve()
    log.parent.mkdir(parents=True, exist_ok=True)
    # MISSING-cost (productization): fan-out runaway guard. Opt-in via the adapter's
    # costPreferences.fanOutCap; absent => no change. Too many concurrent agent/background runs
    # drive unbounded model spend, so refuse to exceed the cap (override with the env escape).
    fanout_cap = (adapter_data or {}).get("costPreferences", {}).get("fanOutCap")
    if (
        isinstance(fanout_cap, int)
        and fanout_cap > 0
        and util_module().resolve_env("MINERVIT_METHODOLOGY_ALLOW_FANOUT") != "1"
    ):
        live = count_live_background_runs(log.parent)
        if live >= fanout_cap:
            raise SystemExit(
                f"background-run refused: {live} live background run(s) already active in {log.parent} "
                f">= costPreferences.fanOutCap ({fanout_cap}). Fan-out runaway guard (MISSING-cost): "
                "concurrent agent runs drive unbounded model spend. Wait for runs to finish, raise "
                "costPreferences.fanOutCap, or set MINERVIT_METHODOLOGY_ALLOW_FANOUT=1 to override."
            )
    started_at = time.strftime("%Y-%m-%dT%H:%M:%S")
    with log.open("ab") as f:
        f.write(f"\n[minervit] starting at {started_at}: {redact_secrets(' '.join(args.command))}\n".encode())
        if timeout_seconds:
            f.write(f"[minervit] timeout_seconds: {timeout_seconds}\n".encode())
        proc = subprocess.Popen(args.command, stdout=f, stderr=subprocess.STDOUT, env=env, start_new_session=True)
    pid_identity = process_identity(proc.pid)
    pid_file = log.with_name(f"{log.name}.pid")
    pid_file.write_text(f"{proc.pid}\n", encoding="utf-8")
    watchdog_pid: int | None = None
    watchdog_pid_file: Path | None = None
    if timeout_seconds:
        watchdog_pid_file = log.with_name(f"{log.name}.watchdog.pid")
        watchdog_code = r"""
import os
import signal
import sys
import time
from pathlib import Path

pid = int(sys.argv[1])
timeout_seconds = int(sys.argv[2])
log_path = Path(sys.argv[3])
expected_identity = sys.argv[4]

def process_identity(pid):
    try:
        proc = __import__("subprocess").run(
            ["ps", "-p", str(pid), "-o", "lstart="],
            text=True,
            stdout=__import__("subprocess").PIPE,
            stderr=__import__("subprocess").DEVNULL,
            check=False,
        )
    except OSError:
        return None
    if proc.returncode != 0 or not proc.stdout.strip():
        return None
    return " ".join(proc.stdout.split())

time.sleep(timeout_seconds)
try:
    os.kill(pid, 0)
except ProcessLookupError:
    raise SystemExit(0)
except OSError:
    raise SystemExit(0)
current_identity = process_identity(pid)
if not expected_identity or not current_identity or current_identity != expected_identity:
    with log_path.open("ab") as handle:
        handle.write(
            (
                f"\n[minervit] timeout after {timeout_seconds}s; not terminating process group {pid} "
                f"because process identity could not be verified\n"
            ).encode()
        )
    raise SystemExit(0)
with log_path.open("ab") as handle:
    handle.write(f"\n[minervit] timeout after {timeout_seconds}s; terminating process group {pid}\n".encode())
try:
    os.killpg(pid, signal.SIGTERM)
except ProcessLookupError:
    raise SystemExit(0)
except OSError as exc:
    with log_path.open("ab") as handle:
        handle.write(f"[minervit] timeout SIGTERM failed: {exc}\n".encode())
time.sleep(5)
try:
    os.kill(pid, 0)
except ProcessLookupError:
    raise SystemExit(0)
except OSError:
    raise SystemExit(0)
try:
    os.killpg(pid, signal.SIGKILL)
    with log_path.open("ab") as handle:
        handle.write(f"[minervit] timeout SIGKILL sent to process group {pid}\n".encode())
except ProcessLookupError:
    pass
except OSError as exc:
    with log_path.open("ab") as handle:
        handle.write(f"[minervit] timeout SIGKILL failed: {exc}\n".encode())
"""
        watchdog = subprocess.Popen(
            [sys.executable, "-c", watchdog_code, str(proc.pid), str(timeout_seconds), str(log), pid_identity or ""],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            start_new_session=True,
        )
        watchdog_pid = watchdog.pid
        watchdog_pid_file.write_text(f"{watchdog_pid}\n", encoding="utf-8")
    meta_file = log.with_name(f"{log.name}.meta.json")
    meta_file.write_text(
        json.dumps(
            {
                "command": args.command,
                "cwd": os.getcwd(),
                "startedAt": started_at,
                "pid": proc.pid,
                "pidIdentity": pid_identity,
                "pidFile": str(pid_file),
                "log": str(log),
                "timeoutSeconds": timeout_seconds,
                "codexFastMode": codex_fast_mode_state,
                "watchdogPid": watchdog_pid,
                "watchdogPidFile": str(watchdog_pid_file) if watchdog_pid_file else None,
            },
            indent=2,
        )
        + "\n",
        encoding="utf-8",
    )
    print(f"pid: {proc.pid}")
    print(f"pid_file: {pid_file}")
    if timeout_seconds:
        print(f"timeout_seconds: {timeout_seconds}")
        print(f"watchdog_pid: {watchdog_pid}")
        print(f"watchdog_pid_file: {watchdog_pid_file}")
    print(f"meta_file: {meta_file}")
    print(f"log: {log}")
    print(f"monitor: tail -F {log}")
    return 0


def pid_is_live(pid: int) -> bool:
    if pid < 1:
        return False
    try:
        os.kill(pid, 0)
        return True
    except PermissionError:
        return True
    except ProcessLookupError:
        return False
    except OSError:
        return False


def read_pid_file(path: Path) -> int | None:
    try:
        text = path.read_text(encoding="utf-8").strip()
    except OSError:
        return None
    if not text:
        return None
    try:
        pid = int(text.splitlines()[0].strip())
    except ValueError:
        return None
    return pid if pid > 0 else None


def monitor_status(args: argparse.Namespace) -> int:
    target = args.target.resolve()
    log = cli_path(target, str(args.log)).resolve(strict=False)
    max_stale_seconds = int(args.max_stale_seconds)
    if max_stale_seconds < 1:
        raise SystemExit("--max-stale-seconds must be positive")

    pid: int | None = args.pid
    if pid is not None and pid < 1:
        raise SystemExit("--pid must be positive")
    pid_source = "argument" if pid is not None else "none"
    meta_path = log.with_name(f"{log.name}.meta.json")
    meta: dict = {}
    if meta_path.exists():
        try:
            loaded_meta = json.loads(meta_path.read_text(encoding="utf-8"))
            if isinstance(loaded_meta, dict):
                meta = loaded_meta
        except (OSError, json.JSONDecodeError):
            meta = {}
    pid_file: Path | None = None
    if pid is None:
        if args.pid_file:
            pid_file = cli_path(target, str(args.pid_file)).resolve(strict=False)
        elif meta.get("pidFile"):
            pid_file = Path(str(meta["pidFile"]))
        else:
            candidate = log.with_name(f"{log.name}.pid")
            pid_file = candidate if candidate.exists() else None
        if pid_file is not None:
            pid = read_pid_file(pid_file)
            pid_source = str(pid_file) if pid is not None else f"{pid_file} (unreadable)"
    pid_state = "unknown"
    pid_identity_state = "not_checked"
    expected_pid_identity = None
    if pid is not None and meta.get("pid") == pid:
        expected_pid_identity = meta.get("pidIdentity")
    if pid is not None:
        if not pid_is_live(pid):
            pid_state = "dead"
        else:
            current_identity = process_identity(pid)
            if expected_pid_identity:
                if current_identity == expected_pid_identity:
                    pid_state = "live"
                    pid_identity_state = "verified"
                elif current_identity:
                    pid_state = "mismatched"
                    pid_identity_state = "mismatched"
                else:
                    pid_state = "unverified"
                    pid_identity_state = "unavailable"
            else:
                pid_state = "live"
                pid_identity_state = "unavailable"
    timeout_seconds_meta = int(meta.get("timeoutSeconds") or 0)
    watchdog_pid = meta.get("watchdogPid")
    watchdog_state = "not_configured"
    if timeout_seconds_meta:
        if isinstance(watchdog_pid, int) and watchdog_pid > 0:
            watchdog_state = "live" if pid_is_live(watchdog_pid) else "dead"
        else:
            watchdog_state = "missing"

    if not log.exists():
        print("monitor_state: missing")
        print(f"monitor_log: {log}")
        print(f"monitor_pid: {pid if pid is not None else 'unknown'}")
        print(f"monitor_pid_state: {pid_state}")
        print(f"monitor_pid_identity_state: {pid_identity_state}")
        print(f"monitor_timeout_seconds: {timeout_seconds_meta}")
        print(f"monitor_watchdog_pid: {watchdog_pid if watchdog_pid else 'none'}")
        print(f"monitor_watchdog_state: {watchdog_state}")
        print("monitor_reason: log file does not exist")
        print("monitor_next_action: treat as failed/stale monitor; inspect launch evidence and rerun or recover the command")
        return 1 if args.strict else 0

    stat = log.stat()
    now = time.time()
    age_seconds = max(0, int(now - stat.st_mtime))
    modified_at = datetime.fromtimestamp(stat.st_mtime).astimezone().isoformat(timespec="seconds")

    tail_text = ""
    try:
        with log.open("rb") as handle:
            handle.seek(0, os.SEEK_END)
            size = handle.tell()
            handle.seek(max(0, size - 8192), os.SEEK_SET)
            tail_text = handle.read().decode("utf-8", errors="replace")
    except OSError:
        tail_text = ""

    success_marker = args.success_marker or ""
    failure_marker = args.failure_marker or ""
    has_success_marker = bool(success_marker and success_marker in tail_text)
    has_failure_marker = bool(failure_marker and failure_marker in tail_text)

    if has_failure_marker:
        state = "failed"
        reason = f"failure marker found: {failure_marker}"
        next_action = "treat as failed monitor; inspect the log and repair before continuing dependent work"
    elif has_success_marker:
        state = "success"
        reason = f"success marker found: {success_marker}"
        next_action = "consume the result and continue with the next authorized action"
    elif pid_state == "mismatched":
        state = "stale"
        reason = "PID identity no longer matches the launched process; possible PID reuse"
        next_action = "treat as stale/hung; do not report this process as running"
    elif pid_state == "unverified":
        state = "stale"
        reason = "PID is live but process identity could not be verified"
        next_action = "treat as stale/hung until liveness is verified with process identity evidence"
    elif pid_state == "live" and timeout_seconds_meta and watchdog_state in {"dead", "missing"}:
        state = "stale"
        reason = "timeout watchdog is not live while the monitored process is still running"
        next_action = "treat as stale/hung timeout enforcement; inspect the log and recover or rerun with a live watchdog"
    elif age_seconds > max_stale_seconds:
        state = "stale"
        if pid_state == "live":
            reason = f"log has not changed for {age_seconds}s while PID is still live"
        elif pid_state == "dead":
            reason = f"log has not changed for {age_seconds}s and PID is dead"
        else:
            reason = f"log has not changed for {age_seconds}s and no live process was verified"
        next_action = "treat as stale/hung; inspect the log tail and rerun, kill/retry, or recover without asking whether to wait"
    elif pid_state == "dead":
        state = "stale"
        reason = "PID is dead and no terminal success marker was observed"
        next_action = "treat as stopped without verified success; inspect the log and recover or rerun"
    elif pid_state == "unknown":
        state = "unverified"
        reason = f"log updated {age_seconds}s ago but no PID/process identity was verified"
        next_action = "verify the PID or pid file before reporting the command as running"
    else:
        state = "running"
        if pid_state == "live":
            reason = f"PID is live and log updated {age_seconds}s ago"
        else:
            reason = f"log updated {age_seconds}s ago; PID not verified"
        next_action = "continue parallel-safe work or poll again before the stale threshold"

    print(f"monitor_state: {state}")
    print(f"monitor_log: {log}")
    print(f"monitor_log_size_bytes: {stat.st_size}")
    print(f"monitor_log_modified_at: {modified_at}")
    print(f"monitor_seconds_since_log_update: {age_seconds}")
    print(f"monitor_max_stale_seconds: {max_stale_seconds}")
    print(f"monitor_pid: {pid if pid is not None else 'unknown'}")
    print(f"monitor_pid_source: {pid_source}")
    print(f"monitor_pid_state: {pid_state}")
    print(f"monitor_pid_identity_state: {pid_identity_state}")
    print(f"monitor_timeout_seconds: {timeout_seconds_meta}")
    print(f"monitor_watchdog_pid: {watchdog_pid if watchdog_pid else 'none'}")
    print(f"monitor_watchdog_state: {watchdog_state}")
    print(f"monitor_reason: {reason}")
    print(f"monitor_next_action: {next_action}")

    bad_states = {"missing", "stale", "failed", "unverified"}
    return 1 if args.strict and state in bad_states else 0


def run_git(target: Path, args: list[str]) -> str:
    return gitutil_module().run_git(target, args)


def methodology_rescue_ref_adapter_changes(repo: Path | None = None) -> list[tuple[str, list[str]]]:
    """Local `minervit-local-rescue/*` refs in the methodology repo that carry canonical adapter
    changes (`adapters/projects/*.json`) not present on the base branch. Lane auto-sync can
    silently move a canonical adapter fix onto a rescue ref; a later lane re-render then
    reproduces the pre-fix adapter (RCA O17). Returns (ref, [adapter_paths]) per affected ref.

    Rescue refs only ever exist in the CANONICAL checkout (rescue is a write path), so a snapshot
    exec root would leave this detector permanently blind and reporting "clean"."""
    repo = repo or canonical_methodology_repo()
    listing = run_git(repo, ["for-each-ref", "--format=%(refname:short)", "refs/heads/minervit-local-rescue"])
    if listing in ("", "unavailable"):
        return []
    base = (
        "origin/main"
        if run_git(repo, ["rev-parse", "--verify", "--quiet", "origin/main"]) not in ("", "unavailable")
        else "main"
    )
    results: list[tuple[str, list[str]]] = []
    for ref in [line.strip() for line in listing.splitlines() if line.strip()]:
        # A rescue ref that is an ANCESTOR of base (old main, merely behind -- e.g. a non-main
        # auto-rescue snapshot of a main that has since advanced) carries nothing un-landed: base
        # already contains it. Skip it. Two-dot tree diff alone would still flag it because base
        # advanced the same adapter files, producing a phantom "stranded fix" hint that has
        # confused operators and contributed to a lane wedge (RCA: rescue-ref detector ancestor
        # false-positive). Only code==0 (definitely an ancestor) skips; an error falls through to
        # report so a genuinely-divergent rescue ref is never silently dropped.
        if run_command(["git", "-C", str(repo), "merge-base", "--is-ancestor", ref, base])[0] == 0:
            continue
        # Two-dot (direct tree compare base vs ref), not three-dot (from merge-base): a rescue
        # adapter change already re-landed on base via cherry-pick/squash has matching content and
        # must not be reported as stranded (Codex P3).
        changed = run_git(repo, ["diff", "--name-only", f"{base}..{ref}", "--", "adapters/projects"])
        if changed in ("", "unavailable"):
            continue
        paths = [p.strip() for p in changed.splitlines() if p.strip()]
        if paths:
            results.append((ref, paths))
    return results


def print_methodology_rescue_ref_warnings(repo: Path | None = None) -> list[tuple[str, list[str]]]:
    """Print a blocking-style warning for each rescue ref that holds canonical adapter changes,
    naming the ref, the affected adapter paths, and a re-land command. Returns the findings."""
    repo = repo or canonical_methodology_repo()
    findings = methodology_rescue_ref_adapter_changes(repo)
    for ref, paths in findings:
        print(
            f"methodology_rescue_ref_adapter_drift: rescue ref {ref} holds canonical adapter changes "
            f"in {', '.join(paths)} that are not on the base branch. A re-render will reproduce the "
            f"pre-rescue adapter. Re-land them with `git -C {repo} cherry-pick`/PR from {ref} before "
            "rendering, or delete the rescue ref with an explicit decision."
        )
    return findings


def github_cache_root() -> Path:
    return ghutil_module().github_cache_root()


def github_provider_state_root() -> Path:
    return ghutil_module().github_provider_state_root()


def github_lock_path() -> Path:
    return ghutil_module().github_lock_path()


def github_operation_lock_path(args: list[str]) -> Path:
    return ghutil_module().github_operation_lock_path(args)


def github_lock_timeout_seconds() -> float:
    return ghutil_module().github_lock_timeout_seconds()


def github_lock_stale_seconds() -> float:
    return ghutil_module().github_lock_stale_seconds()


def github_process_exists(pid: int) -> bool:
    return ghutil_module().github_process_exists(pid)


def github_windows_process_exists(pid: int) -> bool:
    return ghutil_module().github_windows_process_exists(pid)


def github_lock_owner(lock: Path) -> dict:
    return ghutil_module().github_lock_owner(lock)


def github_lock_snapshot(lock: Path) -> dict:
    return ghutil_module().github_lock_snapshot(lock)


def github_lock_stale_reason(lock: Path) -> str:
    return ghutil_module().github_lock_stale_reason(lock, process_exists=github_process_exists)


def github_lock_summary(lock: Path) -> str:
    return ghutil_module().github_lock_summary(lock)


def github_remove_lock(lock: Path, reason: str) -> bool:
    return ghutil_module().github_remove_lock(lock, reason)


def github_lock_token(command: list[str]) -> str:
    return ghutil_module().github_lock_token(command)


def github_write_lock_owner(lock: Path, token: str, command: list[str], label: str) -> None:
    ghutil_module().github_write_lock_owner(lock, token, command, label)


def github_acquire_lock(lock: Path, command: list[str], label: str) -> str:
    return ghutil_module().github_acquire_lock(lock, command, label)


def github_release_lock(lock: Path, token: str) -> None:
    ghutil_module().github_release_lock(lock, token)


@contextmanager
def github_command_lock(command: list[str]):
    """Machine-local serialization for GitHub CLI calls so concurrent lanes do not stampede limits."""
    if not command or command[0] != "gh" or util_module().resolve_env("MINERVIT_GITHUB_SERIALIZE", "1") == "0":
        yield
        return
    with ghutil_module().github_command_lock(command):
        yield


@contextmanager
def github_operation_lock(args: list[str]):
    """Operation-level coalescing lock: one process populates a shared snapshot, followers re-read it."""
    with ghutil_module().github_operation_lock(args):
        yield


def run_command(
    command: list[str],
    cwd: Path | None = None,
    timeout: int | None = None,
    env: dict[str, str] | None = None,
) -> tuple[int, str, str]:
    try:
        with github_command_lock(command):
            proc = subprocess.run(
                command,
                cwd=str(cwd) if cwd else None,
                text=True,
                encoding="utf-8",
                errors="replace",
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                check=False,
                timeout=timeout,
                env=env,
            )
    except TimeoutError as exc:
        return 124, "", str(exc)
    except subprocess.TimeoutExpired as exc:
        stdout = exc.stdout.strip() if isinstance(exc.stdout, str) else ""
        stderr = exc.stderr.strip() if isinstance(exc.stderr, str) else ""
        detail = stderr or stdout or f"command timed out after {timeout}s"
        return 124, stdout, detail
    return proc.returncode, (proc.stdout or "").strip(), (proc.stderr or "").strip()


def command_json(command: list[str], cwd: Path | None = None, timeout: int | None = None) -> tuple[int, object | None, str]:
    code, out, err = run_command(command, cwd, timeout=timeout)
    if code != 0:
        return code, None, err or out
    try:
        return 0, json.loads(out or "null"), ""
    except json.JSONDecodeError as exc:
        return 1, None, f"invalid JSON from {' '.join(command[:3])}: {exc}"


def github_cache_ttl_seconds() -> int:
    return ghutil_module().github_cache_ttl_seconds()


def github_low_watermark() -> int:
    return ghutil_module().github_low_watermark()


def github_cache_read_mode() -> str:
    return ghutil_module().github_cache_read_mode()


def github_shared_snapshot_seconds() -> int:
    return ghutil_module().github_shared_snapshot_seconds()


def github_pending_retry_min_seconds() -> int:
    return ghutil_module().github_pending_retry_min_seconds()


def github_pending_retry_max_seconds() -> int:
    return ghutil_module().github_pending_retry_max_seconds()


def github_cache_key(args: list[str]) -> str:
    return ghutil_module().github_cache_key(args)


def github_cache_path(args: list[str]) -> Path:
    return ghutil_module().github_cache_path(args)


def github_pending_retry_path(args: list[str]) -> Path:
    return ghutil_module().github_pending_retry_path(args)


def github_telemetry_path() -> Path:
    return ghutil_module().github_telemetry_path()


def github_graphql_query_text(args: list[str]) -> str:
    return ghutil_module().github_graphql_query_text(args)


def github_args_are_graphql_read(args: list[str]) -> bool:
    return ghutil_module().github_args_are_graphql_read(args)


def github_args_are_cacheable_read(args: list[str]) -> bool:
    return ghutil_module().github_args_are_cacheable_read(args)


def github_args_are_heavy_graphql(args: list[str]) -> bool:
    return ghutil_module().github_args_are_heavy_graphql(args)


def github_operation_name(args: list[str]) -> str:
    return ghutil_module().github_operation_name(args)


def github_rate_resource_for_args(args: list[str]) -> str:
    return ghutil_module().github_rate_resource_for_args(args)


def github_cache_read(args: list[str], max_age: int | None = None) -> tuple[object | None, dict | None]:
    return ghutil_module().github_cache_read(args, max_age=max_age)


def github_cache_write(args: list[str], payload: object) -> None:
    ghutil_module().github_cache_write(args, payload)


def github_provider_hostname() -> str:
    return ghutil_module().github_provider_hostname()


def github_record_provider_call(
    *,
    operation: str,
    args: list[str],
    target: Path,
    resource: str,
    outcome: str,
    cache: str = "none",
    remaining: int | None = None,
    reset: str = "",
    safe_next_poll: str = "",
    error: str = "",
) -> None:
    ghutil_module().github_record_provider_call(
        operation=operation,
        args=args,
        target=target,
        resource=resource,
        outcome=outcome,
        cache=cache,
        remaining=remaining,
        reset=reset,
        safe_next_poll=safe_next_poll,
        error=error,
    )


def github_recent_provider_calls(limit: int = GITHUB_RECENT_CALL_LIMIT) -> list[dict]:
    return ghutil_module().github_recent_provider_calls(limit)


def github_pending_retry_read(args: list[str]) -> dict | None:
    return ghutil_module().github_pending_retry_read(args)


def github_pending_retry_clear(args: list[str]) -> None:
    ghutil_module().github_pending_retry_clear(args)


def github_pending_retry_write(operation: str, args: list[str], reason: str, snapshot: dict) -> dict:
    return ghutil_module().github_pending_retry_write(operation, args, reason, snapshot)


def github_pending_retry_records() -> list[dict]:
    return ghutil_module().github_pending_retry_records()


def github_cache_invalidate_project() -> int:
    return ghutil_module().github_cache_invalidate_project()


def github_rate_limit_snapshot(target: Path, max_age: int = GITHUB_RATE_LIMIT_CHECK_TTL_SECONDS) -> dict:
    args = ["api", "rate_limit"]
    cached, _raw = github_cache_read(args, max_age=max_age)
    if isinstance(cached, dict):
        github_record_provider_call(
            operation="rate_limit",
            args=args,
            target=target,
            resource="core",
            outcome="cache-hit",
            cache="rate-limit",
        )
        return cached
    code, payload, error = command_json(["gh", *args], target, timeout=15)
    if code != 0 or not isinstance(payload, dict):
        github_record_provider_call(
            operation="rate_limit",
            args=args,
            target=target,
            resource="core",
            outcome="error",
            error=error or "gh api rate_limit failed",
        )
        return {"error": error or "gh api rate_limit failed"}
    github_cache_write(args, payload)
    github_record_provider_call(
        operation="rate_limit",
        args=args,
        target=target,
        resource="core",
        outcome="live",
        cache="write",
    )
    return payload


def github_rate_resource(snapshot: dict, name: str) -> dict:
    return ghutil_module().github_rate_resource(snapshot, name)


def github_graphql_remaining(snapshot: dict) -> int | None:
    return ghutil_module().github_graphql_remaining(snapshot)


def github_graphql_reset_text(snapshot: dict) -> str:
    return ghutil_module().github_graphql_reset_text(snapshot)


def github_budget_allows_graphql(args: list[str], target: Path) -> tuple[bool, str, dict]:
    if util_module().resolve_env("MINERVIT_GITHUB_RATE_GUARD", "1") == "0" or not github_args_are_heavy_graphql(args):
        return True, "", {}
    snapshot = github_rate_limit_snapshot(target)
    if snapshot.get("error"):
        return True, str(snapshot["error"]), snapshot
    remaining = github_graphql_remaining(snapshot)
    if remaining is None:
        return True, "", snapshot
    low = github_low_watermark()
    if remaining <= low:
        return False, f"GitHub GraphQL budget low: remaining={remaining} low_watermark={low} reset={github_graphql_reset_text(snapshot)}", snapshot
    return True, "", snapshot


def github_shared_snapshot_read(args: list[str]) -> object | None:
    return ghutil_module().github_shared_snapshot_read(args)


def github_provider_client_run(
    operation: str,
    args: list[str],
    target: Path,
    *,
    timeout: int = 30,
    resource: str = "",
) -> tuple[int, str, str]:
    rate_resource = resource or github_rate_resource_for_args(args)
    code, out, err = run_command(["gh", *args], cwd=target, timeout=timeout)
    github_record_provider_call(
        operation=operation,
        args=args,
        target=target,
        resource=rate_resource,
        outcome="live" if code == 0 else "error",
        error=err or out if code != 0 else "",
    )
    return code, out, err


def github_provider_client_json(
    operation: str,
    args: list[str],
    target: Path,
    *,
    timeout: int = 30,
    cacheable: bool = False,
    resource: str = "",
) -> object:
    rate_resource = resource or github_rate_resource_for_args(args)
    heavy_graphql = github_args_are_heavy_graphql(args)
    if cacheable and github_cache_read_mode() == "always":
        cached, _raw = github_cache_read(args)
        if cached is not None:
            github_record_provider_call(
                operation=operation,
                args=args,
                target=target,
                resource=rate_resource,
                outcome="cache-hit",
                cache="always",
            )
            return cached
    if cacheable and heavy_graphql:
        cached = github_shared_snapshot_read(args)
        if cached is not None:
            github_record_provider_call(
                operation=operation,
                args=args,
                target=target,
                resource=rate_resource,
                outcome="cache-hit",
                cache="shared-snapshot",
            )
            return cached
    with github_operation_lock(args):
        if cacheable and github_cache_read_mode() == "always":
            cached, _raw = github_cache_read(args)
            if cached is not None:
                github_record_provider_call(
                    operation=operation,
                    args=args,
                    target=target,
                    resource=rate_resource,
                    outcome="cache-hit",
                    cache="always-after-lock",
                )
                return cached
        if cacheable and heavy_graphql:
            cached = github_shared_snapshot_read(args)
            if cached is not None:
                github_record_provider_call(
                    operation=operation,
                    args=args,
                    target=target,
                    resource=rate_resource,
                    outcome="cache-hit",
                    cache="shared-snapshot-after-lock",
                )
                return cached
            pending = github_pending_retry_read(args)
            if pending:
                cached, _raw = github_cache_read(args)
                if cached is not None:
                    github_record_provider_call(
                        operation=operation,
                        args=args,
                        target=target,
                        resource=rate_resource,
                        outcome="cache-hit",
                        cache="pending-low-budget-fallback",
                        safe_next_poll=str(pending.get("safeNextPoll") or ""),
                    )
                    return cached
                reason = (
                    f"GitHub GraphQL budget low: pending_retry safe_next_poll={pending.get('safeNextPoll', 'unknown')}"
                )
                github_record_provider_call(
                    operation=operation,
                    args=args,
                    target=target,
                    resource=rate_resource,
                    outcome="blocked",
                    safe_next_poll=str(pending.get("safeNextPoll") or ""),
                    error=reason,
                )
                raise SystemExit(f"{reason}; no fresh cached GitHub response is available")
        allowed, reason, snapshot = github_budget_allows_graphql(args, target)
        if not allowed:
            if cacheable:
                cached, _raw = github_cache_read(args)
                if cached is not None:
                    github_record_provider_call(
                        operation=operation,
                        args=args,
                        target=target,
                        resource=rate_resource,
                        outcome="cache-hit",
                        cache="low-budget-fallback",
                        remaining=github_graphql_remaining(snapshot),
                        reset=github_graphql_reset_text(snapshot),
                    )
                    return cached
            pending = github_pending_retry_write(operation, args, reason, snapshot)
            github_record_provider_call(
                operation=operation,
                args=args,
                target=target,
                resource=rate_resource,
                outcome="blocked",
                remaining=github_graphql_remaining(snapshot),
                reset=github_graphql_reset_text(snapshot),
                safe_next_poll=str(pending.get("safeNextPoll") or ""),
                error=reason,
            )
            raise SystemExit(f"{reason}; safe_next_poll={pending.get('safeNextPoll', 'unknown')}; no fresh cached GitHub response is available")
        code, payload, error = command_json(["gh", *args], target, timeout=timeout)
        if code != 0:
            github_record_provider_call(
                operation=operation,
                args=args,
                target=target,
                resource=rate_resource,
                outcome="error",
                error=error or f"gh {' '.join(args)} failed",
            )
            raise SystemExit(error or f"gh {' '.join(args)} failed")
        if cacheable:
            github_cache_write(args, payload)
        github_pending_retry_clear(args)
        remaining = github_graphql_remaining(snapshot) if snapshot else None
        reset = github_graphql_reset_text(snapshot) if snapshot else ""
        github_record_provider_call(
            operation=operation,
            args=args,
            target=target,
            resource=rate_resource,
            outcome="live",
            cache="write" if cacheable else "none",
            remaining=remaining,
            reset=reset,
        )
        return payload


def github_cache_summary() -> dict:
    root = github_cache_root()
    files = list(root.glob("*.json")) if root.is_dir() else []
    now = time.time()
    fresh = 0
    stale = 0
    for path in files:
        try:
            raw = json.loads(path.read_text(encoding="utf-8"))
            age = now - float(raw.get("stored_at") or 0)
        except (OSError, json.JSONDecodeError, ValueError):
            stale += 1
            continue
        if age <= github_cache_ttl_seconds():
            fresh += 1
        else:
            stale += 1
    return {"dir": str(root), "entries": len(files), "fresh": fresh, "stale": stale}


def github_budget_status(args: argparse.Namespace) -> int:
    target = args.target if args.target else Path(".")
    snapshot = github_rate_limit_snapshot(target, max_age=0 if getattr(args, "refresh", False) else GITHUB_RATE_LIMIT_CHECK_TTL_SECONDS)
    if snapshot.get("error"):
        print(f"github_budget_status: unavailable - {snapshot['error']}", file=sys.stderr)
        return 1
    low = github_low_watermark()
    print(f"github_budget_status: ok low_watermark={low}")
    for name in ["graphql", "core", "search"]:
        resource = github_rate_resource(snapshot, name)
        if not resource:
            continue
        remaining = resource.get("remaining", "unknown")
        limit = resource.get("limit", "unknown")
        used = resource.get("used", "unknown")
        reset = "unknown"
        try:
            reset = datetime.fromtimestamp(int(resource.get("reset")), tz=timezone.utc).isoformat()
        except (TypeError, ValueError, OSError):
            pass
        state = "low" if name == "graphql" and isinstance(remaining, int) and remaining <= low else "ok"
        print(f"github_budget_{name}: remaining={remaining} used={used} limit={limit} reset={reset} state={state}")
    cache = github_cache_summary()
    print(
        f"github_cache: dir={cache['dir']} entries={cache['entries']} "
        f"fresh={cache['fresh']} stale={cache['stale']} ttl={github_cache_ttl_seconds()}"
    )
    pending = github_pending_retry_records()
    recent = github_recent_provider_calls()
    safe_next = str(pending[0].get("safeNextPoll") or "none") if pending else "none"
    print(
        f"github_provider_health: telemetry={github_telemetry_path()} "
        f"pending_migrations=0 pending_retries={len(pending)} "
        f"recent_callers={len(recent)} safe_next_poll={safe_next}"
    )
    for retry in pending[:5]:
        print(
            "github_provider_pending_retry: "
            f"operation={retry.get('operation', 'unknown')} attempts={retry.get('attempts', 'unknown')} "
            f"safe_next_poll={retry.get('safeNextPoll', 'unknown')} reset={retry.get('reset', 'unknown')} "
            f"reason={str(retry.get('reason', '')).replace(chr(10), ' ')[:160]}"
        )
    for call in recent[-5:]:
        print(
            "github_provider_recent_call: "
            f"at={call.get('at', 'unknown')} operation={call.get('operation', 'unknown')} "
            f"resource={call.get('resource', 'unknown')} outcome={call.get('outcome', 'unknown')} "
            f"cache={call.get('cache', 'none')} host={call.get('host', 'unknown')} pid={call.get('pid', 'unknown')}"
        )
    return 0


def write_text_atomic(path: Path, content: str) -> None:
    return util_module().write_text_atomic(path, content)


def write_text_executable(path: Path, content: str) -> None:
    return util_module().write_text_executable(path, content)


def plugin_manifest() -> dict:
    try:
        manifest = json.loads(PLUGIN_MANIFEST.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        raise SystemExit(f"plugin_manifest_error: {PLUGIN_MANIFEST}: {exc}") from exc
    if not isinstance(manifest, dict):
        raise SystemExit(f"plugin_manifest_error: {PLUGIN_MANIFEST}: expected JSON object")
    for field in ("name", "version"):
        value = manifest.get(field)
        if not isinstance(value, str) or not value.strip():
            raise SystemExit(f"plugin_manifest_error: {PLUGIN_MANIFEST}: missing {field}")
    return manifest


def plugin_name() -> str:
    return plugin_manifest()["name"]


def methodology_version() -> str:
    try:
        version = VERSION_FILE.read_text(encoding="utf-8").strip()
    except FileNotFoundError as exc:
        raise SystemExit(f"missing canonical VERSION file: {VERSION_FILE}") from exc
    if not re.fullmatch(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)", version):
        raise SystemExit(f"invalid canonical VERSION value: {version}")
    return version


def plugin_version() -> str:
    version = methodology_version()
    manifest_version = plugin_manifest()["version"]
    if manifest_version != version:
        raise SystemExit(
            f"version mismatch: VERSION is {version}, plugin manifest is {manifest_version}. "
            "Update VERSION first, then synchronize the plugin manifest, changelog, and release notes."
        )
    return version


RELEASE_ARTIFACT_PATHS = [
    "bin/tautline",
    "bin/minervit-methodology",
    "src/minervit_methodology/__init__.py",
    "src/minervit_methodology/adapter.py",
    "src/minervit_methodology/chat.py",
    "src/minervit_methodology/deploy.py",
    "src/minervit_methodology/gitutil.py",
    "src/minervit_methodology/ghutil.py",
    "src/minervit_methodology/guards.py",
    "src/minervit_methodology/names.py",
    "src/minervit_methodology/paths.py",
    "src/minervit_methodology/policy.py",
    "src/minervit_methodology/profiles.py",
    "src/minervit_methodology/public_release.py",
    "src/minervit_methodology/releases.py",
    "src/minervit_methodology/telemetry.py",
    "src/minervit_methodology/util.py",
    "methodology/canonical-rules.md",
    "methodology/adapter-schema.json",
    "methodology/policy-phrases.json",
    "plugins/tautline-core/.codex-plugin/plugin.json",
]


def cut_release(args: argparse.Namespace) -> int:
    """`cut-release`: build the immutable release boundary for the current VERSION (prod-distribution-1/3).

    Validates VERSION == plugin manifest (fail-closed), computes SHA256 checksums of the distributed
    artifacts, and writes a checksums manifest under docs/releases/. By default it does NOT mutate git
    refs -- it prints the exact signed-tag command for the operator to run deliberately (composes with
    the auto-update trust gate, which can pin/verify that tag). Pass --create-tag to create the
    annotated (or, with --sign, signed) tag locally; pushing is always left to the operator.
    """
    require_dev_checkout("cut-release")
    version = plugin_version()
    tag = f"v{version}"
    checksum_lines: list[str] = []
    for rel in RELEASE_ARTIFACT_PATHS:
        path = REPO_ROOT / rel
        if not path.exists():
            raise SystemExit(f"cut-release: missing release artifact {rel}")
        checksum_lines.append(f"{file_sha256(path)}  {rel}")
    checksums = "\n".join(checksum_lines) + "\n"
    checksum_path = REPO_ROOT / "docs" / "releases" / f"checksums-{tag}.txt"
    dirty = run_git(REPO_ROOT, ["status", "--porcelain"])
    if dirty and dirty != "unavailable":
        print("cut_release_warning: working tree is dirty; a release tag should point at a clean commit", file=sys.stderr)
    existing = run_git(REPO_ROOT, ["tag", "--list", tag])
    print(f"cut_release_version: {version}")
    print(f"cut_release_tag: {tag} ({'exists' if existing == tag else 'new'})")
    print(f"cut_release_checksums: {checksum_path}")
    for line in checksum_lines:
        print(f"  {line}")
    if getattr(args, "dry_run", False):
        print("cut_release_dry_run: no files written, no tag created")
        return 0
    checksum_path.parent.mkdir(parents=True, exist_ok=True)
    checksum_path.write_text(checksums, encoding="utf-8")
    print(f"cut_release_written: {checksum_path}")
    tag_flag = "-s" if getattr(args, "sign", False) else "-a"
    tag_cmd = f"git tag {tag_flag} {tag} -m 'tautline {version}'"
    if getattr(args, "create_tag", False):
        if existing == tag and not getattr(args, "force", False):
            raise SystemExit(f"cut-release: tag {tag} already exists; bump VERSION or pass --force")
        cmd = ["git", "-C", str(REPO_ROOT), "tag", tag_flag, tag, "-m", f"tautline {version}"]
        if getattr(args, "force", False):
            cmd.insert(3, "-f")
        code, _out, err = run_command(cmd)
        if code != 0:
            raise SystemExit(f"cut-release: git tag failed: {err.strip()}")
        print(f"cut_release_tagged: {tag} (push deliberately: git push origin {tag})")
    else:
        print(f"cut_release_next: commit the checksums, then create the tag: {tag_cmd}")
        print(f"cut_release_next: then push deliberately: git push origin {tag}")
    return 0


def version_tuple(version: str) -> tuple[int, ...]:
    return util_module().version_tuple(version)


def public_contract_status_for_command(name: str) -> dict:
    if name in DEPRECATED_COMMAND_REPLACEMENTS:
        return {
            "status": "deprecated",
            "replacement": DEPRECATED_COMMAND_REPLACEMENTS[name],
            "removeAfter": "1.0.0",
        }
    if name.endswith("-hook"):
        return {"status": "internal"}
    if name in {
        "backlog-provider-active-check",
        "dump-policy-phrases",
        "dump-instrumentation-schema",
        "publish-release-update",
        "release-update-status",
    }:
        return {"status": "internal"}
    if name in {
        "blocker-clear",
        "blocker-declare",
        "blocker-status",
        "claude-review-status",
        "guard-check",
        # maintainer-mode (0.9.17): the machine-scoped standdown verb ships experimental --
        # the same introduce-as-experimental posture the snapshot verbs took -- so its output
        # and flags can settle across the sibling 0.9.18 launcher-regen escalation (which
        # rewrites the arming advisory into a policy-tiered refusal) before the stable semver
        # policy binds them.
        "maintainer-mode",
        "migrate-adapter",
        "prepare-instrumentation-record",
        "publish-instrumentation-record",
        "release-migration-report",
        "public-contract",
        "public-release-check",
        "public-release-export",
        "registry-package",
        "release-drift-check",
        "release-tail",
        # The snapshot store is introduced ahead of its release -> repin -> install-cli cutover and
        # nothing in the fleet executes snapshots yet. Shipping the verbs as `stable` would bind
        # their output and flags to the stable semver policy before a single machine has exercised
        # them; experimental is the same posture the 0.9.0 instrumentation verbs shipped under.
        "snapshot-pin",
        "snapshot-prune",
        "snapshot-status",
        "validate-instrumentation-record",
    }:
        return {"status": "experimental"}
    return {"status": "stable"}


def public_contract_status_for_adapter_key(name: str, schema: dict) -> dict:
    if name == "goalTracker":
        return {"status": "deprecated", "replacement": "backlogProvider", "removeAfter": "1.0.0"}
    if name == "_generated":
        return {"status": "internal"}
    if name == "responseGuard":
        return {"status": "internal"}
    if name == "_framework":
        return {"status": "stable"}
    if name == "instrumentation":
        # 0.9.0: sanitized-instrumentation adapter key introduced as experimental alongside its
        # experimental CLI commands; the record schema/vocabulary is a reviewed public surface.
        return {"status": "experimental"}
    prop = (schema.get("properties") or {}).get(name) or {}
    if isinstance(prop, dict) and prop.get("deprecated") is True:
        return {"status": "deprecated"}
    return {"status": "stable"}


def public_contract_status_for_skill(name: str, rel: str) -> dict:
    if rel.startswith("examples/") or rel == "examples":
        return {"status": "experimental"}
    # makerkit-implementation moved to examples/community-skills/ (structure: move
    # stack-specific makerkit example out of core plugin) and is no longer scanned
    # under either plugin's skills/ root, so its status special-case is retired.
    if name in {"rules-audit"}:
        return {
            "status": "deprecated",
            "replacement": "framework-intake",
            "removeAfter": "1.0.0",
        }
    return {"status": "stable"}


def load_policy_modules_manifest(path: Path = POLICY_MODULES_MANIFEST) -> dict:
    try:
        manifest = json.loads(path.read_text(encoding="utf-8"))
    except FileNotFoundError as exc:
        raise SystemExit(f"policy module manifest missing: {path}") from exc
    except json.JSONDecodeError as exc:
        raise SystemExit(f"policy module manifest is not valid JSON: {path}: {exc}") from exc
    if not isinstance(manifest, dict):
        raise SystemExit(f"policy module manifest must be an object: {path}")
    if manifest.get("schema") != POLICY_MODULES_SCHEMA:
        raise SystemExit(f"policy module manifest schema must be {POLICY_MODULES_SCHEMA}: {path}")
    modules = manifest.get("modules")
    if not isinstance(modules, list) or not modules:
        raise SystemExit(f"policy module manifest must contain a non-empty modules list: {path}")
    for module in modules:
        if not isinstance(module, str) or not module.strip() or module.startswith("/") or ".." in Path(module).parts:
            raise SystemExit(f"policy module manifest contains invalid module path: {module!r}")
    listed = set(modules)
    disk_modules = {path.name for path in POLICY_MODULES_DIR.glob("*.md")}
    missing = sorted(listed - disk_modules)
    extra = sorted(disk_modules - listed)
    if missing:
        raise SystemExit(f"policy module manifest references missing module(s): {', '.join(missing)}")
    if extra:
        raise SystemExit(f"policy module manifest omits module file(s): {', '.join(extra)}")
    return manifest


def policy_module_paths(manifest: dict | None = None) -> list[Path]:
    manifest = manifest or load_policy_modules_manifest()
    return [(POLICY_MODULES_DIR / module).resolve() for module in manifest["modules"]]


def canonical_policy_text_from_modules(manifest: dict | None = None) -> str:
    parts = []
    for path in policy_module_paths(manifest):
        try:
            text = path.read_text(encoding="utf-8").rstrip()
        except FileNotFoundError as exc:
            raise SystemExit(f"policy module missing: {path}") from exc
        if not text:
            raise SystemExit(f"policy module is empty: {path}")
        parts.append(text)
    return "\n\n".join(parts) + "\n"


def canonical_policy(args: argparse.Namespace) -> int:
    text = canonical_policy_text_from_modules()
    output = (args.output or CANONICAL_RULES).resolve()
    if args.write:
        write_text_atomic(output, text)
        print(f"canonical_policy_written: {output}")
    if args.check:
        try:
            current = output.read_text(encoding="utf-8")
        except OSError as exc:
            print(f"canonical_policy_check: missing/unreadable {output}: {exc}", file=sys.stderr)
            return 1
        if current != text:
            print(f"canonical_policy_check: stale {output}", file=sys.stderr)
            return 1
        print(f"canonical_policy_check: ok {output}")
    if args.print_policy or not (args.write or args.check):
        print(text, end="")
    return 0


def registered_subcommand_names() -> list[str]:
    """Every subcommand name registered on the real argparse registry, sourced by scanning this
    file's own `.add_parser(...)` call sites -- the same mechanism the public-contract manifest
    uses (see test_public_contract_freshness.py). This is the completeness authority for the
    0.8.9 startup-remediation guard's ALLOWED/BLOCKED partition (see
    STARTUP_REMEDIATION_ALLOWED_COMMANDS): the registry partition test walks this list, not a
    hand-typed copy, so an added/renamed subcommand fails that test until classified.
    """
    source = Path(__file__).read_text(encoding="utf-8")
    return sorted(set(re.findall(r"\.add_parser\(\s*['\"]([^'\"]+)['\"]", source)))

# T1 (0.9.0 sanitized instrumentation): the operator-approved v1 event vocabulary (T0, sha256
# fe966b2a...). A TUPLE, not a list -- an UPPER_CASE list-of-str constant would auto-enter the
# policy-phrases SSOT (policy_phrase_constants() above) and break test_policy_phrases_ssot.py. This
# is the single source of truth: the generated schema's `events[].code` enum and the validator both
# derive from it, so an enum edit here is the ONLY place a new event code can be introduced.
INSTRUMENTATION_EVENT_CODES: tuple[str, ...] = (
    "startup",
    "preflight",
    "planning_review_gate",
    "plan_review_round",
    "plan_review_clean",
    "plan_review_blocked",
    "implementation_review_round",
    "implementation_review_clean",
    "implementation_review_blocked",
    "gate_block",
    "autonomy_stop",
    "human_question",
    "blocker_declared",
    "blocker_cleared",
    "rca",
    "continuity_written",
    "context_rotation",
    "goal_transition",
    "milestone_transition",
    "pr_opened",
    "pr_queue_merge",
    "pr_merged",
    "ci_wait",
    "merge_queue_wait",
    "task_started",
    "task_completed",
    "session_end",
)

INSTRUMENTATION_SCHEMA_VERSION = "tautline-instrumentation/v1"
INSTRUMENTATION_SCHEMA_FILE = REPO_ROOT / "methodology" / "instrumentation-schema.json"
# UTC-only RFC3339, e.g. "2026-07-10T12:00:00+00:00" or with fractional seconds. Pattern-
# constrained in the schema itself (not JSON-Schema "format", which consumers may not assert) and
# deliberately rejects any non-"+00:00" offset -- the record is always emitted in UTC.
INSTRUMENTATION_TIMESTAMP_PATTERN = r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?\+00:00$"
INSTRUMENTATION_LANE_ID_PATTERN = r"^[0-9a-f]{16}$"
INSTRUMENTATION_VERSION_PATTERN = r"^\d+\.\d+\.\d+$"


def instrumentation_schema_dict() -> dict:
    """The in-code field rules (single source of truth) for `tautline-instrumentation/v1`.

    `methodology/instrumentation-schema.json` (dump-instrumentation-schema) and
    instrumentation_record_errors() both derive from this ONE dict via the stdlib-only
    schema_validation_errors() engine (the same engine the adapter schema already uses), so the
    committed schema artifact and the validator's behavior cannot drift by construction. The
    additionalProperties:false at every object level plus const/enum/pattern on every string
    property is what gives the schema its no-freeform-capacity property; the cross-field rule
    (window_gap:false requires at least one event) is expressed here as if/then so it's enforced by
    the same single definition rather than as separate hand-written logic.
    """
    return {
        "type": "object",
        "additionalProperties": False,
        "required": [
            "schema",
            "emitted_at",
            "lane_id",
            "plugin_version",
            "window_seconds",
            "window_gap",
            "end_seq",
            "events",
        ],
        "properties": {
            "schema": {"type": "string", "const": INSTRUMENTATION_SCHEMA_VERSION},
            "emitted_at": {"type": "string", "pattern": INSTRUMENTATION_TIMESTAMP_PATTERN},
            "lane_id": {"type": "string", "pattern": INSTRUMENTATION_LANE_ID_PATTERN},
            "plugin_version": {"type": "string", "pattern": INSTRUMENTATION_VERSION_PATTERN},
            "window_seconds": {"type": "number", "minimum": 0},
            "window_gap": {"type": "boolean"},
            "end_seq": {"type": "integer", "minimum": 0},
            "events": {
                "type": "array",
                "items": {
                    "type": "object",
                    "additionalProperties": False,
                    "required": ["code", "count"],
                    "properties": {
                        "code": {"type": "string", "enum": list(INSTRUMENTATION_EVENT_CODES)},
                        "count": {"type": "integer", "minimum": 1},
                        "total_seconds": {"type": "number", "minimum": 0},
                        "ordinal": {"type": "integer", "minimum": 0},
                        "round": {"type": "integer", "minimum": 0},
                    },
                },
            },
        },
        # Cross-field rule: a non-gap record (window_gap: false) must contain at least one event.
        # An empty `events` is reserved for gap records -- otherwise a forged empty non-gap blob
        # with a high bound end_seq could pass hygiene and suppress local events with no gap
        # indication (see the plan's Record schema section).
        "if": {"properties": {"window_gap": {"const": False}}},
        "then": {"properties": {"events": {"minItems": 1}}},
    }


def render_instrumentation_schema() -> str:
    return json.dumps(instrumentation_schema_dict(), indent=2, sort_keys=True) + "\n"


def instrumentation_record_errors(record: object) -> list[str]:
    """The ONE authority for instrumentation-record validity (T1, pure/no I/O).

    Delegates to the same stdlib-only schema_validation_errors() engine the adapter schema uses,
    against instrumentation_schema_dict() -- the identical in-code source that
    dump-instrumentation-schema projects into methodology/instrumentation-schema.json. Callers
    (validate-instrumentation-record, publish-instrumentation-record; both T-later) fail closed on
    any non-empty result.
    """
    errors = schema_validation_errors(record, instrumentation_schema_dict())
    # Defense in depth against the stdlib validator's re.search anchoring: Python's `$` matches just
    # before a trailing newline, so an otherwise pattern-conforming string ending in "\n" (e.g. a
    # tainted "...+00:00\n" emitted_at) would slip past an anchored `pattern`. Every string in a v1
    # record is a machine-generated const/enum/pattern token with zero legitimate control
    # characters, so reject any -- this keeps the "closed token on every string field" guarantee
    # categorical and stops a newline-tainted emitted_at from reaching datetime.fromisoformat in the
    # downstream path-binding hygiene walk.
    errors.extend(instrumentation_value_hygiene_errors(record))
    return errors


def _json_pairs_reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict:
    """`json.loads(object_pairs_hook=...)` callback that raises ValueError on a repeated object key
    instead of silently keeping the last value (the duplicate-key smuggling representation)."""
    result: dict[str, object] = {}
    for key, value in pairs:
        if key in result:
            raise ValueError(f"duplicate object key {key!r}")
        result[key] = value
    return result


_INSTRUMENTATION_CONTROL_CHAR_RE = re.compile(r"[\x00-\x1f\x7f]")
_INSTRUMENTATION_INFINITIES = (float("inf"), float("-inf"))


def instrumentation_value_hygiene_errors(instance: object, path: str = "$") -> list[str]:
    """Every reachable scalar must be a closed, standard-JSON value (pure). Strings must be free of
    C0/DEL control characters (so no anchored-pattern newline slack can admit one into an otherwise
    closed token), and numbers must be finite: Python's json parser accepts non-standard NaN/Infinity
    and every `minimum` comparison against NaN is false, so a NaN/Infinity would slip past the schema
    and then canonicalize into non-standard JSON that strict consumers reject."""
    errors: list[str] = []
    if isinstance(instance, str):
        if _INSTRUMENTATION_CONTROL_CHAR_RE.search(instance):
            errors.append(f"{path}: string contains a disallowed control character")
    elif isinstance(instance, float) and (instance != instance or instance in _INSTRUMENTATION_INFINITIES):
        errors.append(f"{path}: number must be finite (NaN/Infinity are not standard JSON)")
    elif isinstance(instance, dict):
        for key, value in instance.items():
            errors.extend(instrumentation_value_hygiene_errors(value, f"{path}.{key}"))
    elif isinstance(instance, list):
        for index, value in enumerate(instance):
            errors.extend(instrumentation_value_hygiene_errors(value, f"{path}[{index}]"))
    return errors


def dump_instrumentation_schema(args: argparse.Namespace) -> int:
    """`dump-instrumentation-schema`: regenerate methodology/instrumentation-schema.json from the
    in-code field rules (the canonical-policy/public-contract idiom). --check verifies the
    committed file is up to date (generated-equals-source) without writing. Internal command: this
    projection is for schema consumers/tooling, not a public adopter-facing surface (see
    public_contract_status_for_command).
    """
    text = render_instrumentation_schema()
    output = (args.output or INSTRUMENTATION_SCHEMA_FILE).resolve()
    if args.write:
        write_text_atomic(output, text)
        print(f"instrumentation_schema_written: {output}")
    if args.check:
        try:
            current = output.read_text(encoding="utf-8")
        except OSError as exc:
            print(f"instrumentation_schema_check: missing/unreadable {output}: {exc}", file=sys.stderr)
            return 1
        if current != text:
            print(f"instrumentation_schema_check: stale {output}", file=sys.stderr)
            return 1
        print(f"instrumentation_schema_check: ok {output}")
    if getattr(args, "print_schema", False) or not (args.write or args.check):
        print(text, end="")
    return 0


# T2 (0.9.0 sanitized instrumentation): telemetry salt + lane_id + per-lane monotonic seq. The
# salt is a machine secret ($HOME/.local/state/tautline/telemetry-salt, 0600, create-on-first-use)
# that must NEVER be published or logged; lane_id = sha256(salt + resolved lane path).hexdigest()
# [:16] is the only lane discriminator added to the local event log, replacing the previous
# basename-only `lane` field that collides across parallel worktrees sharing a directory name. The
# per-lane seq counter is persisted BESIDE THE SALT (not beside the event log, which two
# same-repo worktrees can share) and is allocated INSIDE the same advisory flock
# append_event_payload() already takes around the event log's rotate+append, so two simultaneous
# try_write_event calls for the same lane can never duplicate or skip a seq. The counter's
# persisted state also carries the high-water timestamp of the last allocated event -- the
# deterministic source T3's aggregator will use for a complete-loss gap record's emitted_at/
# filename stamp when no mapped event survives to derive it from.
def telemetry_state_dir() -> Path:
    return Path.home() / ".local" / "state" / "tautline"


def telemetry_salt_path() -> Path:
    return telemetry_state_dir() / "telemetry-salt"


def telemetry_salt() -> bytes:
    """32 random bytes, created on first use at telemetry_salt_path() (0600) and never rotated.

    Creation uses O_CREAT|O_EXCL (never replace): if two processes hit first-use simultaneously,
    exactly one creates the file and the loser reads the winner's salt, so every caller on the
    machine converges on ONE salt (a replace-based write would let the loser keep a salt that no
    longer exists on disk, silently forking that lane's lane_id). The fd is opened 0600 at
    creation, so the secret is never even briefly readable by group/other."""
    path = telemetry_salt_path()
    for _attempt in range(2):
        try:
            existing = path.read_bytes()
        except FileNotFoundError:
            existing = b""
        if len(existing) == 32:
            return existing
        if existing:
            # Corrupt/truncated salt (crash mid-first-write): discard and recreate. lane_ids
            # derived from a corrupt salt were never valid, so regeneration is safe recovery.
            try:
                path.unlink()
            except OSError:
                pass
        path.parent.mkdir(parents=True, exist_ok=True)
        try:
            fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
        except FileExistsError:
            continue  # another process won the create race; loop back and read its salt
        with os.fdopen(fd, "wb") as handle:
            handle.write(os.urandom(32))
        return path.read_bytes()
    raise SystemExit(f"could not create or read telemetry salt: {path}")


def instrumentation_lane_id(target: Path) -> str:
    """sha256(salt + resolved lane path).hexdigest()[:16] -- stable per machine+lane, unlinkable to
    any product without the salt, and distinct across two worktrees that share a directory
    basename (the resolved absolute path, not the basename, is what's hashed)."""
    resolved = str(Path(target).resolve())
    digest = hashlib.sha256(telemetry_salt() + resolved.encode("utf-8")).hexdigest()
    return digest[:16]


def instrumentation_seq_state_path(lane_id: str) -> Path:
    return telemetry_state_dir() / "lane-seq" / f"{lane_id}.json"


def read_instrumentation_seq_state(lane_id: str) -> dict:
    """{"seq": <highest allocated seq for this lane, 0 if none>, "high_water_ts": <str|None>}."""
    path = instrumentation_seq_state_path(lane_id)
    try:
        raw = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        raw = None
    if not isinstance(raw, dict):
        return {"seq": 0, "high_water_ts": None}
    try:
        seq = max(int(raw.get("seq", 0)), 0)
    except (TypeError, ValueError):
        seq = 0
    high_water_ts = raw.get("high_water_ts")
    if not isinstance(high_water_ts, str) or not high_water_ts:
        high_water_ts = None
    return {"seq": seq, "high_water_ts": high_water_ts}


def instrumentation_event_log_max_seq(lane_id: str, jsonl_path: Path, retained_rotations: int) -> int:
    """Highest `seq` recorded for `lane_id` across the local event log plus its retained rotations, 0
    when none. Used only to recover the per-lane counter after its persisted state is lost."""
    max_seq = 0
    for path in event_jsonl_read_paths(jsonl_path, retained_rotations):
        try:
            text = path.read_text(encoding="utf-8", errors="replace")
        except OSError:
            continue
        for line in text.splitlines():
            try:
                record = json.loads(line)
            except (json.JSONDecodeError, ValueError):
                continue
            if isinstance(record, dict) and record.get("lane_id") == lane_id:
                seq = record.get("seq")
                if isinstance(seq, int) and not isinstance(seq, bool) and seq > max_seq:
                    max_seq = seq
    return max_seq


def allocate_instrumentation_seq(lane_id: str, ts: str, *, jsonl_path: Path | None = None, retained_rotations: int = 0) -> int:
    """Allocate the next seq for `lane_id` and persist it together with the high-water timestamp.

    CALLER CONTRACT: must already hold the shared event-log advisory lock for this lane (the same
    lock append_event_payload() takes around rotate+append) -- this function does its own
    read-modify-write with no locking of its own, so calling it unlocked would let two concurrent
    writers both read the same prior seq and allocate a duplicate.

    Rollback/loss recovery: if the persisted counter is missing (seq == 0) -- the state file was
    deleted, truncated, or the salt survived a state reset -- seed the counter above the highest seq
    already in the local event log before allocating. Otherwise a reset would restart at 1 and reissue
    seqs a published window's remote_max already covers, silently filtering those events out forever.
    The scan runs only in the seq == 0 case (genuine first event OR post-loss), so it costs one log
    read once, not per event. (Residual: a partial rollback to a lower NON-zero seq is not recovered
    here -- documented in docs/backlog METH-FU-INSTRUMENTATION-TELEMETRY-ACCURACY.)"""
    state = read_instrumentation_seq_state(lane_id)
    baseline = state["seq"]
    if baseline == 0 and jsonl_path is not None:
        baseline = instrumentation_event_log_max_seq(lane_id, jsonl_path, retained_rotations)
    seq = baseline + 1
    path = instrumentation_seq_state_path(lane_id)
    write_text_atomic(path, json.dumps({"seq": seq, "high_water_ts": ts}, sort_keys=True) + "\n")
    return seq


# T3 (0.9.0 sanitized instrumentation): the sanitizing aggregator (rotation-aware, lane-strict).
#
# Event vocabulary v1 classification. Every local events.jsonl `event` name a real producer in this
# file can emit (verified by tests/test_instrumentation_producer_coverage.py's ast-based
# producer-coverage gate) must appear in exactly one of:
#   - instrumentation_event_map(): a straight name -> code mapping for plain counter-style
#     producers (one occurrence == one count, no lifecycle pairing/duration).
#   - INSTRUMENTATION_PAIRED_LIFECYCLE_PRODUCERS: (started, finished, failed, code) triples for
#     lifecycle producers the reducer pairs into ONE closed unit with total_seconds = terminal-start.
#   - INSTRUMENTATION_FINALIZE_VERDICT_PRODUCERS: (finalize_name, clean_code, blocked_code) triples
#     for finalize events that choose a code by READING the structured `verdict` field T2 added
#     (never parsing freeform text, never copying the payload value itself into the record).
#   - INSTRUMENTATION_IGNORED_EVENTS: real producers deliberately excluded from v1 telemetry, each
#     with a recorded reason (test-time documentation; the aggregator itself does not need this
#     table at runtime -- an ignored name and a merely-unclassified/custom runtime `log-event` name
#     behave identically at runtime: both contribute nothing and are dropped, never guessed).
#
# All three runtime structures are dicts/tuples (never an UPPER_CASE list-of-str constant, which
# would auto-enter the policy-phrases SSOT and break test_policy_phrases_ssot.py).
def _goal_advance_event_map() -> dict[str, str]:
    return {f"goal_advance_{event.replace('-', '_')}": "goal_transition" for event in GOAL_ADVANCE_EVENTS}


def _milestone_advance_event_map() -> dict[str, str]:
    return {f"milestone_advance_{event.replace('-', '_')}": "milestone_transition" for event in MILESTONE_ADVANCE_EVENTS}


def instrumentation_event_map() -> dict[str, str]:
    """local events.jsonl `event` name -> INSTRUMENTATION_EVENT_CODES code, for producers that are
    plain counters (no started/finished pairing, no verdict reduction). Built from the frozen v1
    enum plus the live GOAL_ADVANCE_EVENTS/MILESTONE_ADVANCE_EVENTS --event choice sets, so a new
    choice added to either later is automatically covered here (and by the producer-coverage test)
    without a manual edit to this function."""
    mapping = {
        # Real in-code producers.
        "startup": "startup",
        "continuity_written": "continuity_written",
        "context_rotation": "context_rotation",
        "methodology_status_failed": "gate_block",
        # Well-known boundary names the `log-event` command documents (see the requiredBoundaryEvents
        # adapter default and the event-observability skill) -- not emitted automatically anywhere in
        # bin/tautline today, but a stable, deliberate part of the v1 vocabulary so an adopter/agent
        # invoking `log-event --event <code-name> ...` for one of these well-known names is classified
        # rather than treated as an arbitrary unclassified custom name.
        "preflight": "preflight",
        "planning_review_gate": "planning_review_gate",
        "gate_block": "gate_block",
        "autonomy_stop": "autonomy_stop",
        "human_question": "human_question",
        "blocker_declared": "blocker_declared",
        "blocker_cleared": "blocker_cleared",
        "rca": "rca",
        "pr_opened": "pr_opened",
        "pr_queue_merge": "pr_queue_merge",
        "pr_merged": "pr_merged",
        "ci_wait": "ci_wait",
        "merge_queue_wait": "merge_queue_wait",
        "task_started": "task_started",
        "task_completed": "task_completed",
        "session_end": "session_end",
        "goal_transition": "goal_transition",
        "milestone_transition": "milestone_transition",
    }
    mapping.update(_goal_advance_event_map())
    mapping.update(_milestone_advance_event_map())
    return mapping


# (start_name, finished_name, failed_name, code): the reducer opens a unit on `start_name` and
# closes it on either terminal name, contributing total_seconds = terminal_ts - start_ts. An
# unpaired start (crash mid-round, or a second start before any terminal event) counts once with no
# duration.
INSTRUMENTATION_PAIRED_LIFECYCLE_PRODUCERS: tuple[tuple[str, str, str, str], ...] = (
    ("plan_review_started", "plan_review_finished", "plan_review_failed", "plan_review_round"),
    ("implementation_review_started", "implementation_review_finished", "implementation_review_failed", "implementation_review_round"),
)

# (finalize_name, clean_code, blocked_code): verdict "clean" or "clean-with-deferrals" (deferrals are
# backlogged findings, not blockage) contributes clean_code; "blocked" contributes blocked_code. A
# missing/unrecognized verdict value (corrupt or hostile payload) contributes nothing -- dropped,
# never guessed.
INSTRUMENTATION_FINALIZE_VERDICT_PRODUCERS: tuple[tuple[str, str, str], ...] = (
    ("plan_review_finalized", "plan_review_clean", "plan_review_blocked"),
    ("implementation_review_finalized", "implementation_review_clean", "implementation_review_blocked"),
)

INSTRUMENTATION_FINALIZE_CLEAN_VERDICTS = frozenset({"clean", "clean-with-deferrals"})

# Real producer names deliberately excluded from v1 telemetry, with the reason recorded (checked by
# the producer-coverage test). A dict (not a list), so it never enters the policy-phrases SSOT.
INSTRUMENTATION_IGNORED_EVENTS: dict[str, str] = {
    "methodology_status_passed": (
        "routine passing status heartbeat emitted on nearly every methodology-status invocation; "
        "too frequent/noisy for v1 lifecycle telemetry and not part of the operator-approved vocabulary"
    ),
    "iteration_review_page_generated": "product delivery-page artifact event, not an agent-lifecycle boundary; not part of the operator-approved v1 vocabulary",
    "iteration_review_partial_delivery": "product delivery narrative event, not part of the operator-approved v1 vocabulary",
    "iteration_review_published": "product delivery publish event, not part of the operator-approved v1 vocabulary",
    "milestone_update_published": "product-facing status update publish event, not part of the operator-approved v1 vocabulary",
    "product_note_published": "product-facing narrative publish event, not part of the operator-approved v1 vocabulary; must never be classified into telemetry",
    "deployment_ready_notification_published": "deployment notification event, not part of the operator-approved v1 vocabulary",
    "session_journal_written": (
        "narrative session-journal artifact; remote publication of narrative journals is disabled in "
        "this release (see the narrative-journals design section) and must never feed the sanctioned "
        "instrumentation surface"
    ),
    "startup_remediation_entered": (
        "startup-remediation lifecycle event added in 0.8.9 (bin/tautline methodology_status marker "
        "write path); postdates the operator-approved v1 vocabulary (T0, sha fe966b2a), which has no "
        "startup-remediation code, so it stays local-only until a future vocabulary revision re-runs "
        "the T0 approval gate -- not silently mapped to an approximate existing code"
    ),
    "startup_remediation_cleared": (
        "paired clear for startup_remediation_entered (bin/tautline methodology_status marker clear "
        "path); same rationale -- outside the T0-approved v1 vocabulary, local-only pending a future "
        "vocabulary revision that re-runs T0 approval"
    ),
    "goal_run_active": "goal-ledger heartbeat emitted by goal-start; goal_advance_startup already covers the startup boundary for this family",
    "milestone_run_active": "milestone-ledger heartbeat emitted by milestone-start; milestone_advance_startup already covers the startup boundary for this family",
    "blocker": "requiredBoundaryEvents family label only, never a literal local event name; concrete producers use blocker_declared/blocker_cleared directly",
    "continuity": "requiredBoundaryEvents family label only, never a literal local event name; concrete producer uses continuity_written directly",
    "pr_queue": "requiredBoundaryEvents/audit family alias for pr_queued; the classified producer name is pr_queue_merge",
}


def instrumentation_render_timestamp(moment: datetime) -> str:
    """Render an aware datetime in the exact `+00:00`-suffixed UTC form
    INSTRUMENTATION_TIMESTAMP_PATTERN requires (events.jsonl's own `ts` field uses a `Z` suffix,
    which the schema deliberately does not accept -- see INSTRUMENTATION_TIMESTAMP_PATTERN)."""
    return moment.astimezone(timezone.utc).isoformat(timespec="seconds")


# Constant remote-metadata surface (T5): the archive branch, path grammar, and commit-message
# template are FIXED -- never adapter/flag-configurable -- so no freeform adopter string can reach
# the remote through git metadata (see the plan's "Remote metadata is constant" section).
INSTRUMENTATION_ARCHIVE_BRANCH = "tautline-telemetry-archive"
# telemetry/<16hex lane_id>/<colon-free seconds-precision UTC stamp>-<end_seq>.json. Colon-free so
# the branch checks out on Windows/NTFS; the -<end_seq> suffix (not sub-second time) disambiguates
# same-second publishes. Bound to the record's own fields by hygiene -- a blob cannot be moved under
# another lane/stamp/seq to forge a remote_max.
INSTRUMENTATION_ARCHIVE_PATH_PATTERN = r"^telemetry/[0-9a-f]{16}/\d{8}T\d{6}Z-\d+\.json$"


def instrumentation_archive_stamp(emitted_at: str) -> str:
    """The colon-free seconds-precision UTC filename stamp (YYYYMMDDTHHMMSSZ) derived from a
    validated record's `emitted_at`. Fractional seconds (which the schema permits) are truncated --
    the `-<end_seq>` suffix, not sub-second time, keeps same-second publishes distinct."""
    moment = datetime.fromisoformat(emitted_at).astimezone(timezone.utc)
    return moment.strftime("%Y%m%dT%H%M%SZ")


def instrumentation_archive_relpath(record: dict) -> str:
    """Derive the archive path from a VALIDATED record's own fields ONLY (lane_id, emitted_at,
    end_seq). Callers pass records that already passed instrumentation_record_errors(); this never
    interpolates any adopter-supplied or freeform string."""
    return f"telemetry/{record['lane_id']}/{instrumentation_archive_stamp(record['emitted_at'])}-{record['end_seq']}.json"


def instrumentation_archive_path_matches_record(relpath: str, record: dict) -> bool:
    """Hygiene path-binding keystone: a blob's FULL repo-relative path (directory lane_id, filename
    stamp derived from emitted_at, and -<end_seq> suffix) must equal what the blob's own validated
    fields derive. Because the path is fully a function of the record, binding reduces to recomputing
    the expected path and comparing -- a conforming blob moved under another lane, or renamed to a
    different stamp/seq, no longer matches and cannot forge that lane's remote_max."""
    return relpath == instrumentation_archive_relpath(record)


# Pinned remote commit metadata (T5): every telemetry commit is authored/committed by a FIXED
# identity with a FIXED message and a date derived only from the validated record -- the adopter's
# own git config would otherwise embed company/project strings in the identity, and default commit
# dates embed the local timezone. Constants, never adapter/flag-configurable.
INSTRUMENTATION_COMMIT_AUTHOR_NAME = "tautline-telemetry"
INSTRUMENTATION_COMMIT_AUTHOR_EMAIL = "telemetry@tautline.invalid"
INSTRUMENTATION_COMMIT_MESSAGE = "telemetry: instrumentation record"
# Schema versions THIS publisher understands. A blob labeled with any other version is rejected
# fail-closed (upgrade-your-framework) -- a future v2 could carry freeform data past a grammar-only
# check, so an older publisher must never trust it on syntax alone.
INSTRUMENTATION_KNOWN_SCHEMA_VERSIONS = frozenset({INSTRUMENTATION_SCHEMA_VERSION})
# Verifier version: bump when a hygiene RULE changes so the content-addressed hygiene cache re-walks
# already-seen commits under the new rules rather than trusting a stale pass.
INSTRUMENTATION_HYGIENE_VERIFIER_VERSION = "1"


def instrumentation_blob_hygiene_errors(relpath: str, text: str) -> list[str]:
    """Fail-closed hygiene for ONE archived blob at `relpath` (pure, no I/O).

    Order matters: (1) parseable JSON object; (2) a schema version THIS publisher knows -- an
    unknown version is rejected fail-closed with an upgrade instruction, never validated on grammar;
    (3) a fully conforming `tautline-instrumentation/v1` record (additionalProperties:false at every
    level rejects any freeform field); (4) the blob's FULL path bound to its own validated fields, so
    a conforming blob cannot be moved under another lane or renamed to a different stamp/seq to forge
    that lane's remote_max. Every archived blob on the branch -- not just this lane's -- must pass."""
    try:
        record = json.loads(text)
    except (json.JSONDecodeError, ValueError):
        return [f"nonconforming blob (invalid JSON) at {relpath}"]
    if isinstance(record, dict):
        version = record.get("schema")
        if isinstance(version, str) and version not in INSTRUMENTATION_KNOWN_SCHEMA_VERSIONS:
            return [
                f"unknown schema version {version!r} at {relpath}: this framework checkout cannot "
                "verify it -- upgrade this machine's tautline framework before publishing telemetry"
            ]
    errors = [f"{error} (at {relpath})" for error in instrumentation_record_errors(record)]
    if errors:
        return errors
    # instrumentation_record_errors() above already rejects a control-char-tainted emitted_at, so a
    # validated record parses cleanly here. Still catch ValueError as a belt: a validator/pattern
    # drift must degrade to a fail-closed hygiene rejection, never an uncaught crash that turns the
    # whole-branch walk into a telemetry DoS on a poisoned pre-existing blob.
    try:
        path_bound = instrumentation_archive_path_matches_record(relpath, record)
    except ValueError as exc:
        return [f"nonconforming blob (emitted_at unparseable for path binding) at {relpath}: {exc}"]
    if not path_bound:
        return [
            f"path not bound to record fields at {relpath}: a conforming blob at a path its own "
            "lane_id/stamp/end_seq do not derive is a move/rename forgery attempt"
        ]
    # Byte-canonical gate: the raw blob must be EXACTLY the sorted-key canonical serialization of the
    # parsed record. json.loads silently keeps only the last of any duplicate key, so a blob like
    # {"plugin_version": "<product text>", "plugin_version": "0.9.0"} parses to a clean record that
    # passes every check above while the discarded product text still rides in the raw bytes on the
    # branch. Requiring byte-canonical form (which every publisher-written blob already is) rejects
    # duplicate keys, key reordering, and trailing bytes -- nothing but our own canonical records is
    # trusted, so no freeform text can be smuggled past the schema check.
    if text != instrumentation_record_blob_text(record):
        return [
            f"non-canonical blob at {relpath}: raw bytes are not the exact sorted-key canonical "
            "serialization of the parsed record (duplicate keys, reordering, or trailing bytes can "
            "smuggle product text past json.loads key de-duplication)"
        ]
    return []


def instrumentation_commit_epoch(emitted_at: str) -> int:
    """The integer UNIX epoch for a validated record's `emitted_at` -- the pinned author/committer
    date. emitted_at is always UTC (`+00:00`), so this is timezone-unambiguous."""
    return int(datetime.fromisoformat(emitted_at).timestamp())


# Identity+date line grammar: "<name> <<email>> <epoch> <tz>" (git's raw author/committer form).
_INSTRUMENTATION_IDENTITY_RE = re.compile(r"^(?P<name>.*) <(?P<email>[^>]*)> (?P<epoch>\d+) (?P<tz>[+-]\d{4})$")


def instrumentation_expected_commit_identity_line(emitted_at: str) -> str:
    """The exact author/committer identity+date line a pinned telemetry commit must carry:
    `<name> <<email>> <epoch> +0000`. Both author and committer lines must equal this."""
    return (
        f"{INSTRUMENTATION_COMMIT_AUTHOR_NAME} <{INSTRUMENTATION_COMMIT_AUTHOR_EMAIL}> "
        f"{instrumentation_commit_epoch(emitted_at)} +0000"
    )


def _instrumentation_identity_errors(role: str, value: str, expected_epoch: int | None) -> list[str]:
    match = _INSTRUMENTATION_IDENTITY_RE.match(value)
    if match is None:
        return [f"commit {role} is not a well-formed identity+date line: {value!r}"]
    errors: list[str] = []
    if match.group("name") != INSTRUMENTATION_COMMIT_AUTHOR_NAME or match.group("email") != INSTRUMENTATION_COMMIT_AUTHOR_EMAIL:
        errors.append(f"commit {role} is not the pinned telemetry identity: {value!r}")
    if match.group("tz") != "+0000":
        # Default commit dates embed the local timezone; the publisher pins +0000 so no timezone
        # (a coarse location signal) reaches the remote.
        errors.append(f"commit {role} timezone is not the pinned +0000: {value!r}")
    if expected_epoch is not None and match.group("epoch") != str(expected_epoch):
        errors.append(f"commit {role} date is not the pinned record epoch {expected_epoch}: {value!r}")
    return errors


def instrumentation_commit_object_errors(raw: str, *, emitted_at: str | None) -> list[str]:
    """Closed-set parse of a `git cat-file commit` object (pure). A conforming telemetry commit must
    contain ONLY: a `tree` line (40-hex), zero or more `parent` lines (40-hex each), an `author` and
    a `committer` line each carrying the pinned identity + `+0000` timezone, a blank separator, and
    the constant message -- nothing else. Any other header (notably `gpgsig`, which a user's signing
    config would add and which is identity-bearing), a non-constant identity, a non-`+0000`
    timezone, or a non-constant message is a violation.

    `emitted_at` bind mode: when given (the freshly built commit, re-checked per push retry), the
    author/committer epoch MUST equal that record's emitted_at epoch. When None (the whole-branch
    hygiene walk, where each historical commit legitimately carries its own record's epoch), the
    epoch is only required to be a plausible integer with the pinned identity and +0000 -- an
    arbitrary epoch leaks no adopter string, so binding it to one record would over-constrain the
    walk."""
    expected_epoch = instrumentation_commit_epoch(emitted_at) if emitted_at is not None else None
    errors: list[str] = []
    normalized = raw.replace("\r\n", "\n")
    header_block, sep, message_block = normalized.partition("\n\n")
    if not sep:
        return ["commit object has no header/message separator"]
    seen_tree = False
    seen_author = False
    seen_committer = False
    for line in header_block.split("\n"):
        key, _space, value = line.partition(" ")
        if key == "tree":
            seen_tree = True
            if not re.fullmatch(r"[0-9a-f]{40}", value):
                errors.append(f"commit tree is not a 40-hex object id: {value!r}")
        elif key == "parent":
            if not re.fullmatch(r"[0-9a-f]{40}", value):
                errors.append(f"commit parent is not a 40-hex object id: {value!r}")
        elif key == "author":
            seen_author = True
            errors.extend(_instrumentation_identity_errors("author", value, expected_epoch))
        elif key == "committer":
            seen_committer = True
            errors.extend(_instrumentation_identity_errors("committer", value, expected_epoch))
        else:
            errors.append(f"disallowed commit header {key!r}: telemetry commits carry only tree/parent/author/committer")
    if not seen_tree:
        errors.append("commit object is missing its tree header")
    if not seen_author:
        errors.append("commit object is missing its author header")
    if not seen_committer:
        errors.append("commit object is missing its committer header")
    message = message_block[:-1] if message_block.endswith("\n") else message_block
    if message != INSTRUMENTATION_COMMIT_MESSAGE:
        errors.append(f"commit message is not the constant telemetry template: {message!r}")
    return errors


def instrumentation_record_from_events(
    *,
    lane_id: str,
    remote_max: int,
    jsonl_path: Path,
    retained_rotations: int,
    plugin_version: str,
) -> dict | None:
    """Pure aggregation core (T3): scan `jsonl_path` plus its retained rotations, filter strictly to
    `lane_id == this lane AND seq > remote_max`, classify and reduce surviving events into v1 codes,
    detect `window_gap`, and build a schema-valid `tautline-instrumentation/v1` record -- or return
    None when there is nothing to publish (no mapped events AND no gap: the idle/ignored-only no-op
    case from the window-semantics design).

    `remote_max` and `plugin_version` are supplied by the caller -- this function never talks to
    git/network (T5's publisher fetches the real remote_max off the archive branch) and never reads
    the wall clock, so the same on-disk snapshot with the same remote_max always yields a
    byte-identical record: every timestamp comes from the scanned events' own `ts` fields or the
    per-lane seq counter's persisted high-water timestamp (read_instrumentation_seq_state()).

    Untagged pre-0.9.0 events (no `lane_id` field) and events for any other lane are dropped, never
    guessed. Unmapped/ignored/custom (`log-event --event <anything-else>`) names are dropped, never
    passed through -- this is the sanitization boundary between the freeform local event log and the
    closed v1 vocabulary.
    """
    simple_map = instrumentation_event_map()
    paired_by_start = {start: (start, finished, failed, code) for start, finished, failed, code in INSTRUMENTATION_PAIRED_LIFECYCLE_PRODUCERS}
    paired_by_terminal: dict[str, tuple[str, str, str, str]] = {}
    for start, finished, failed, code in INSTRUMENTATION_PAIRED_LIFECYCLE_PRODUCERS:
        paired_by_terminal[finished] = (start, finished, failed, code)
        paired_by_terminal[failed] = (start, finished, failed, code)
    finalize_by_name = {name: (name, clean_code, blocked_code) for name, clean_code, blocked_code in INSTRUMENTATION_FINALIZE_VERDICT_PRODUCERS}

    raw_records: list[dict] = []
    for path in event_jsonl_read_paths(jsonl_path, retained_rotations):
        try:
            text = path.read_text(encoding="utf-8", errors="replace")
        except OSError:
            continue
        for line in text.splitlines():
            line = line.strip()
            if not line:
                continue
            try:
                parsed = json.loads(line)
            except json.JSONDecodeError:
                continue
            if isinstance(parsed, dict):
                raw_records.append(parsed)

    surviving: list[dict] = []
    for record in raw_records:
        if record.get("lane_id") != lane_id:
            continue
        seq = record.get("seq")
        if not isinstance(seq, int) or isinstance(seq, bool):
            continue
        if seq <= remote_max:
            continue
        surviving.append(record)
    surviving.sort(key=lambda record: record["seq"])
    survived_seqs = [record["seq"] for record in surviving]

    seq_state = read_instrumentation_seq_state(lane_id)
    high_water_seq = seq_state["seq"]
    high_water_ts_raw = seq_state["high_water_ts"]

    gap = False
    if survived_seqs:
        if survived_seqs[0] > remote_max + 1:
            gap = True
        else:
            for previous, current in zip(survived_seqs, survived_seqs[1:], strict=False):
                if current != previous + 1:
                    gap = True
                    break
    elif high_water_seq > remote_max:
        gap = True

    counts: dict[str, int] = {}
    durations: dict[str, float] = {}
    has_duration: set[str] = set()
    mapped_timestamps: list[datetime] = []
    open_start: dict[str, datetime] = {}

    def _classify(code: str) -> None:
        counts[code] = counts.get(code, 0) + 1

    for record in surviving:
        name = record.get("event")
        if not isinstance(name, str):
            continue
        if name in simple_map:
            ts = parse_event_ts(str(record.get("ts", "")))
            if ts is None:
                continue
            _classify(simple_map[name])
            mapped_timestamps.append(ts)
            continue
        if name in paired_by_start:
            ts = parse_event_ts(str(record.get("ts", "")))
            if ts is None:
                continue
            _, _finished, _failed, code = paired_by_start[name]
            if code in open_start:
                # a previous unclosed start for this family is unpaired -- close it now, no duration
                _classify(code)
            open_start[code] = ts
            mapped_timestamps.append(ts)
            continue
        terminal = paired_by_terminal.get(name)
        if terminal is not None:
            ts = parse_event_ts(str(record.get("ts", "")))
            if ts is None:
                continue
            _, _finished, _failed, code = terminal
            mapped_timestamps.append(ts)
            opened = open_start.pop(code, None)
            _classify(code)
            if opened is not None:
                seconds = max(0.0, (ts - opened).total_seconds())
                durations[code] = durations.get(code, 0.0) + seconds
                has_duration.add(code)
            continue
        finalize_entry = finalize_by_name.get(name)
        if finalize_entry is not None:
            _, clean_code, blocked_code = finalize_entry
            refs = record.get("refs")
            verdict = refs.get("verdict") if isinstance(refs, dict) else None
            if not isinstance(verdict, str):
                # hostile/corrupt line: refs.verdict can be ANY JSON type, and frozenset
                # membership hashes the operand (an unhashable list/dict would raise TypeError).
                # Non-string verdicts are unclassifiable -- dropped, never guessed.
                continue
            target_code: str | None = None
            if verdict in INSTRUMENTATION_FINALIZE_CLEAN_VERDICTS:
                target_code = clean_code
            elif verdict == "blocked":
                target_code = blocked_code
            if target_code is None:
                continue  # missing/unrecognized verdict: drop silently, never guess
            ts = parse_event_ts(str(record.get("ts", "")))
            if ts is None:
                continue
            _classify(target_code)
            mapped_timestamps.append(ts)
            continue
        # unmapped/ignored/custom `log-event` name: contributes nothing, dropped, never guessed.

    # any start still open at the end of the window never closed -- unpaired, count once, no duration
    for code in open_start:
        _classify(code)

    if not counts and not gap:
        return None

    events_out = []
    for code in sorted(counts):
        entry: dict[str, object] = {"code": code, "count": counts[code]}
        if code in has_duration:
            entry["total_seconds"] = durations[code]
        events_out.append(entry)

    end_seq = max(survived_seqs) if survived_seqs else max(high_water_seq, remote_max)

    if mapped_timestamps:
        # max/min over mapped timestamps (not first/last in seq order) is deliberate: under clock
        # skew a later-seq event can carry an earlier ts, and this keeps emitted_at/window_seconds
        # well-defined and window_seconds never negative.
        emitted_dt = max(mapped_timestamps)
        window_seconds: float = 0.0 if gap else (max(mapped_timestamps) - min(mapped_timestamps)).total_seconds()
    else:
        # RuntimeError (not SystemExit): this is a library-grade pure function -- T5's publisher
        # calls it in-process and must be able to catch/report failures on its own terms.
        if not high_water_ts_raw:
            raise RuntimeError(
                "instrumentation aggregator: a gap with no mapped events requires the persisted "
                "seq-counter high-water timestamp, but none is recorded for this lane"
            )
        parsed_high_water = parse_event_ts(high_water_ts_raw)
        if parsed_high_water is None:
            raise RuntimeError(f"instrumentation aggregator: unparseable persisted high-water timestamp: {high_water_ts_raw!r}")
        emitted_dt = parsed_high_water
        window_seconds = 0.0

    return {
        "schema": INSTRUMENTATION_SCHEMA_VERSION,
        "emitted_at": instrumentation_render_timestamp(emitted_dt),
        "lane_id": lane_id,
        "plugin_version": plugin_version,
        "window_seconds": window_seconds,
        "window_gap": gap,
        "end_seq": end_seq,
        "events": events_out,
    }


# T4 (0.9.0 sanitized instrumentation): prepare/validate CLI commands.
#
# `prepare-instrumentation-record` writes a SINGLE overwritten preview file under the lane's own
# `.ai-runs/instrumentation/` -- never a timestamped/accumulating one like session journals,
# because the window-semantics design deliberately keeps NO local window/attempt state to key a
# second preview off of. The preview always aggregates from remote_max=0 (the full retained local
# history): fetching the REAL remote_max off the hygiene-verified archive branch is
# publish-instrumentation-record's job (T5, not built yet), and the preview is a human-inspection
# artifact the publisher never reads at publish time (see the design's "Publish-time
# recomputation" section) -- it exists to let an operator see what the next publish would contain,
# not to influence it.
def instrumentation_preview_dir(data: dict, target: Path) -> Path:
    return target / data["laneState"]["runsDir"] / "instrumentation"


def instrumentation_preview_path(data: dict, target: Path) -> Path:
    return instrumentation_preview_dir(data, target) / "preview.json"


def path_is_git_ignored(target: Path, path: Path) -> bool:
    """True when `path` is excluded by some ignore rule (a `.gitignore` file or
    `$GIT_DIR/info/exclude`) inside the `target` worktree, via `git check-ignore -q`.

    run_git() maps ANY nonzero exit -- not ignored, not a git repository, path outside the
    worktree, ... -- to its "unavailable" sentinel; this treats that sentinel as NOT ignored, the
    fail-closed direction the write-time gate below requires (an ambiguous/unavailable answer must
    never be read as "safe to write")."""
    return run_git(target, ["check-ignore", "-q", "--", str(path)]) != "unavailable"


def prepare_instrumentation_record(args: argparse.Namespace) -> int:
    """`prepare-instrumentation-record`: write the current-window preview under the lane's
    `.ai-runs/instrumentation/` (see the module comment above for why it is always a single
    overwritten file computed from remote_max=0). Validates the aggregated record with T1's
    `instrumentation_record_errors()` and refuses to write anything invalid, and refuses to write
    at all when the preview path is not actually git-ignored in this worktree -- `.ai-runs/` lives
    inside the product worktree, so an un-ignored preview file is one broad `git add -A` away from
    reaching the product's own remote (the same threat the narrative-journals write-time gate
    addresses, applied here to instrumentation state)."""
    data, _project_path, target = lane_project(args)
    ensure_lane_state(data, target)
    lane_id = instrumentation_lane_id(target)
    _human, jsonl, _lock = observability_event_paths(data, target)
    retained = int(data["observabilityEvents"]["retainedRotations"])
    record = instrumentation_record_from_events(
        lane_id=lane_id,
        remote_max=0,
        jsonl_path=jsonl,
        retained_rotations=retained,
        plugin_version=plugin_version(),
    )
    if record is None:
        print("instrumentation_prepare: nothing to prepare (no mapped events, no gap)")
        return 0
    errors = instrumentation_record_errors(record)
    if errors:
        for error in errors:
            print(f"instrumentation_prepare_validation_error: {error}", file=sys.stderr)
        return 1
    preview_path = instrumentation_preview_path(data, target)
    if not path_is_git_ignored(target, preview_path):
        runs_dir = data["laneState"]["runsDir"]
        print(
            f"instrumentation_prepare: refusing to write {path_display(target, preview_path)}: "
            f"not git-ignored in this worktree. Add {runs_dir}/ to .gitignore (or restore "
            "laneState.gitIgnore: true in the adapter) before preparing instrumentation previews.",
            file=sys.stderr,
        )
        return 1
    preview_path.parent.mkdir(parents=True, exist_ok=True)
    write_text_atomic(preview_path, json.dumps(record, indent=2, sort_keys=True) + "\n")
    print(f"instrumentation_prepare: wrote {preview_path}")
    print(f"instrumentation_prepare_valid: {preview_path}")
    return 0


def validate_instrumentation_record(args: argparse.Namespace) -> int:
    """`validate-instrumentation-record`: standalone fail-closed validator. Exits 1 with reasons on
    stderr for any read/parse/schema error; exit 0 for a conforming `tautline-instrumentation/v1`
    record."""
    try:
        text = args.file.read_text(encoding="utf-8")
    except OSError as exc:
        print(f"instrumentation_record_unreadable: {exc}", file=sys.stderr)
        return 1
    try:
        # Reject duplicate object keys: json.loads silently keeps only the last value, so a file like
        # {"plugin_version": "<product text>", "plugin_version": "0.9.0"} would parse to a clean record
        # while the raw bytes still carry the discarded product text. The archive hygiene walk already
        # rejects this via its byte-canonical gate; the advertised standalone validator must be just as
        # fail-closed on the raw representation.
        record = json.loads(text, object_pairs_hook=_json_pairs_reject_duplicate_keys)
    except (json.JSONDecodeError, ValueError) as exc:
        print(f"instrumentation_record_invalid_json: {exc}", file=sys.stderr)
        return 1
    errors = instrumentation_record_errors(record)
    if errors:
        for error in errors:
            print(f"instrumentation_record_validation_error: {error}", file=sys.stderr)
        return 1
    print(f"instrumentation_record_valid: {args.file}")
    return 0


# T5 (0.9.0 sanitized instrumentation): the publisher. `publish-instrumentation-record` recomputes
# the record from the local event log at publish time (never trusting the prepared preview), and
# publishes ONLY the recomputed record to the CONSTANT `tautline-telemetry-archive` branch of the
# framework checkout's origin via a fully hardened push path: fetch + whole-branch fail-closed
# hygiene, branch-tip ancestry guard, pinned-env commit (constant identity/date/message, signing +
# hooks + attribute-filters disabled), closed-set commit-object parse, byte-compare readback of the
# committed blob, then a fetch/rebase/retry loop that RE-CREATES and RE-PARSES the commit under the
# pinned env on every attempt. No local window/marker/attempt state is advanced -- idempotency is
# derived entirely from the remote's highest bound `end_seq` (remote_max).
def telemetry_remote_url() -> str | None:
    """The telemetry archive remote: `remote.origin.url` of the framework checkout. Resolved from
    the framework checkout path, which is REPO_ROOT in production; TAUTLINE_TELEMETRY_REPO overrides
    it for hermetic tests (the same internal-seam idea as MINERVIT_METHODOLOGY_REPO). This only
    selects WHERE the sanitized record goes -- never what it contains, which has zero freeform
    capacity by construction -- so the override is not a leakage vector. Returns None when no
    origin is configured."""
    # The canonical checkout, not the exec root: a snapshot has no `.git` and therefore no origin,
    # which would silently disable telemetry archiving on every converted machine.
    override = util_module().resolve_env("TAUTLINE_TELEMETRY_REPO").strip()
    repo = Path(override) if override else canonical_methodology_repo()
    url = run_git(repo, ["config", "--get", "remote.origin.url"])
    return None if url in ("", "unavailable") else url


def instrumentation_record_blob_text(record: dict) -> str:
    """The single canonical on-disk form of a record blob (pretty, key-sorted, trailing newline).
    Used identically to write the blob, to byte-compare the committed blob on readback, and by the
    hygiene walk -- one representation so a readback can byte-match what was written. allow_nan=False
    so a non-finite number raises here rather than serializing the non-standard NaN/Infinity tokens
    (validation already rejects them; this is belt-and-suspenders on the write path)."""
    return json.dumps(record, indent=2, sort_keys=True, allow_nan=False) + "\n"


def git_object_bytes(repo: Path, spec: str) -> bytes | None:
    """Raw bytes of a git object (`git cat-file blob <spec>`), NOT whitespace-stripped -- run_git/
    run_command strip trailing newlines, which would break an exact byte comparison. Returns None on
    any error."""
    proc = subprocess.run(
        ["git", "-C", str(repo), "cat-file", "blob", spec],
        stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False,
    )
    return proc.stdout if proc.returncode == 0 else None


def instrumentation_hygiene_cache_path() -> Path:
    return telemetry_state_dir() / "archive-hygiene-cache.json"


def read_instrumentation_hygiene_cache() -> set[str]:
    """Content-addressed set of commit SHAs already verified clean under the CURRENT verifier
    version. A verifier-version bump invalidates the whole cache so a rule change re-walks history
    rather than trusting a stale pass."""
    try:
        raw = json.loads(instrumentation_hygiene_cache_path().read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return set()
    if not isinstance(raw, dict) or raw.get("verifier") != INSTRUMENTATION_HYGIENE_VERIFIER_VERSION:
        return set()
    verified = raw.get("verified")
    return set(verified) if isinstance(verified, list) else set()


def write_instrumentation_hygiene_cache(verified: set[str]) -> None:
    write_text_atomic(
        instrumentation_hygiene_cache_path(),
        json.dumps({"verifier": INSTRUMENTATION_HYGIENE_VERIFIER_VERSION, "verified": sorted(verified)}, sort_keys=True) + "\n",
    )


def instrumentation_branch_hygiene_errors(repo: Path, ref: str) -> list[str]:
    """Whole-branch, fail-closed hygiene run BEFORE trusting anything on the fetched archive branch.
    Every REACHABLE commit's metadata is closed-set-parsed (pinned identity/tz/message, hex ids, no
    stray headers), and every blob in every reachable commit's FULL tree is validated as a
    known-version conforming record bound to its path -- so a leaked-then-deleted blob still riding
    in history, an unknown schema version, or a moved/renamed blob is caught anywhere in the branch,
    not just at the tip. Content-addressed by commit SHA under a verifier version so re-runs are
    cheap without ever trusting a stale pass across rule changes."""
    code, rev_list, err = run_command(["git", "rev-list", ref], cwd=repo)
    if code != 0:
        return [f"archive hygiene: cannot enumerate commits on {ref}: {err or 'git rev-list failed'}"]
    commits = [line.strip() for line in rev_list.splitlines() if line.strip()]
    cache = read_instrumentation_hygiene_cache()
    errors: list[str] = []
    newly_verified: set[str] = set()
    for sha in commits:
        if sha in cache:
            continue
        code, raw_commit, err = run_command(["git", "cat-file", "commit", sha], cwd=repo)
        if code != 0:
            errors.append(f"archive hygiene: cannot read commit {sha}: {err or 'git cat-file failed'}")
            continue
        commit_errors = instrumentation_commit_object_errors(raw_commit, emitted_at=None)
        errors.extend(f"{e} (commit {sha})" for e in commit_errors)
        code, tree_listing, err = run_command(["git", "ls-tree", "-r", "--name-only", sha], cwd=repo)
        if code != 0:
            errors.append(f"archive hygiene: cannot list tree of {sha}: {err or 'git ls-tree failed'}")
            continue
        commit_blob_errors: list[str] = []
        for path in (line.strip() for line in tree_listing.splitlines() if line.strip()):
            blob = git_object_bytes(repo, f"{sha}:{path}")
            if blob is None:
                commit_blob_errors.append(f"archive hygiene: cannot read blob {sha}:{path}")
                continue
            commit_blob_errors.extend(instrumentation_blob_hygiene_errors(path, blob.decode("utf-8", errors="replace")))
        errors.extend(f"{e} (commit {sha})" for e in commit_blob_errors)
        if not commit_errors and not commit_blob_errors:
            newly_verified.add(sha)
    if not errors and newly_verified:
        write_instrumentation_hygiene_cache(cache | newly_verified)
    return errors


def instrumentation_remote_max_for_lane(repo: Path, ref: str, lane_id: str) -> int | None:
    """This lane's highest bound `end_seq` across EVERY reachable commit on the (already
    hygiene-verified) archive branch, 0 when the lane has none. THE lower bound for the next window --
    there is no local marker. Returns None (FAIL CLOSED) on any git failure.

    Computed over the whole reachable history, not just the tip tree: a normal descendant commit that
    DELETES this lane's telemetry blobs (keeping pinned metadata and a conforming remaining tree) would
    otherwise pass ancestry + hygiene yet drop the tip-only max, letting the next publish re-aggregate
    already-published windows or emit a spurious gap. Telemetry is append-only by intent, so the true
    bound is the max end_seq of any record still reachable in history. Blob objects are deduplicated by
    object id, so the same blob shared across commits is read once.

    A transient rev-list/ls-tree/blob-read/parse failure returns None so the caller ABORTS the
    publish: silently returning 0 would treat every retained event as unpublished and republish
    already-published counts. Every telemetry blob here was already validated by the whole-branch
    hygiene walk that ran first, so a read/parse failure at this point is anomalous, not routine."""
    code, rev_list, _err = run_command(["git", "rev-list", ref], cwd=repo)
    if code != 0:
        return None
    prefix = f"telemetry/{lane_id}/"
    remote_max = 0
    seen_objects: set[str] = set()
    for sha in (line.strip() for line in rev_list.splitlines() if line.strip()):
        code, listing, _err = run_command(["git", "ls-tree", "-r", sha], cwd=repo)
        if code != 0:
            return None
        for line in listing.splitlines():
            meta, _tab, path = line.partition("\t")
            if not path.startswith(prefix):
                continue
            fields = meta.split()
            if len(fields) < 3:
                continue
            object_id = fields[2]
            if object_id in seen_objects:
                continue
            seen_objects.add(object_id)
            blob = git_object_bytes(repo, object_id)
            if blob is None:
                return None
            try:
                record = json.loads(blob.decode("utf-8", errors="replace"))
            except (json.JSONDecodeError, ValueError):
                return None
            end_seq = record.get("end_seq") if isinstance(record, dict) else None
            if isinstance(end_seq, int) and not isinstance(end_seq, bool) and end_seq > remote_max:
                remote_max = end_seq
    return remote_max


def instrumentation_ancestry_state_path(remote_url: str) -> Path:
    key = hashlib.sha256(f"{remote_url}\n{INSTRUMENTATION_ARCHIVE_BRANCH}".encode("utf-8")).hexdigest()[:16]
    return telemetry_state_dir() / "archive-tip" / f"{key}.json"


def read_instrumentation_ancestry_tip(remote_url: str) -> str | None:
    try:
        raw = json.loads(instrumentation_ancestry_state_path(remote_url).read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return None
    tip = raw.get("tip") if isinstance(raw, dict) else None
    return tip if isinstance(tip, str) and tip else None


def write_instrumentation_ancestry_tip(remote_url: str, tip: str) -> None:
    write_text_atomic(instrumentation_ancestry_state_path(remote_url), json.dumps({"tip": tip}, sort_keys=True) + "\n")


def instrumentation_ancestry_guard_error(repo: Path, remote_url: str, fetched_tip: str) -> str | None:
    """Branch-tip ancestry guard (per-checkout): the fetched tip must be a DESCENDANT of the last
    tip this checkout saw, so a force-pushed rewind that would cement deletion of ANY lane's
    telemetry hard-errors. Scoped honestly -- a fresh checkout with no baseline adopts the current
    tip as its first-seen baseline (it cannot detect rewinds that predate it; remote branch
    protection is the operator-level defense for that window)."""
    last_seen = read_instrumentation_ancestry_tip(remote_url)
    if last_seen is None or last_seen == fetched_tip:
        return None
    code, _out, _err = run_command(["git", "merge-base", "--is-ancestor", last_seen, fetched_tip], cwd=repo)
    if code == 0:
        return None
    return (
        f"archive branch-tip ancestry violation: the last-seen tip {last_seen[:12]} is not an "
        f"ancestor of the fetched tip {fetched_tip[:12]} -- the {INSTRUMENTATION_ARCHIVE_BRANCH} "
        "branch was force-pushed/rewound. Refusing to publish onto a rewound telemetry history; "
        "restore the branch (or clear the local baseline after an operator-confirmed reset)."
    )


def _instrumentation_commit_and_push(
    *,
    clone: Path,
    relpath: str,
    record: dict,
    empty_hooks: Path,
    empty_attrs: Path,
    remote_url: str,
) -> tuple[str, str]:
    """Write the blob, commit under the pinned env, closed-set-parse the commit object, byte-compare
    the committed blob, then push. Returns (status, detail): "pushed"/<tip sha>, "rejected"/<err>,
    or "error"/<message>. Called once per retry attempt so every attempt re-creates AND re-parses
    the commit under the pinned env."""
    blob_text = instrumentation_record_blob_text(record)
    dest = clone / relpath
    dest.parent.mkdir(parents=True, exist_ok=True)
    dest.write_text(blob_text, encoding="utf-8")
    # Isolation flags applied to EVERY git write below, not just the commit: a user's global/system
    # gitattributes clean filter on `*.json` runs at `git add` time (staging), and a global
    # core.hooksPath pre-push hook runs at `git push` time. Scoping these overrides to `git commit`
    # alone would let a rewriting clean filter corrupt the staged blob (only caught later by the
    # byte-compare readback, aborting every publish) and let a rejecting/environment-assuming pre-push
    # hook fail every publish. The empty hooksPath/attributesFile neutralize both; gpgsign=false is
    # harmless on add/push and keeps the flag set uniform.
    isolation_flags = [
        "-c", "commit.gpgsign=false",
        "-c", f"core.hooksPath={empty_hooks}",
        "-c", f"core.attributesFile={empty_attrs}",
    ]
    code, _out, err = run_command(["git", *isolation_flags, "add", "--", relpath], cwd=clone)
    if code != 0:
        return "error", f"git add failed: {err}"
    epoch_date = f"{instrumentation_commit_epoch(record['emitted_at'])} +0000"
    commit_env = {
        **os.environ,
        "GIT_AUTHOR_NAME": INSTRUMENTATION_COMMIT_AUTHOR_NAME,
        "GIT_AUTHOR_EMAIL": INSTRUMENTATION_COMMIT_AUTHOR_EMAIL,
        "GIT_COMMITTER_NAME": INSTRUMENTATION_COMMIT_AUTHOR_NAME,
        "GIT_COMMITTER_EMAIL": INSTRUMENTATION_COMMIT_AUTHOR_EMAIL,
        "GIT_AUTHOR_DATE": epoch_date,
        "GIT_COMMITTER_DATE": epoch_date,
    }
    commit_cmd = [
        "git",
        *isolation_flags,
        "commit", "--no-verify", "--no-gpg-sign", "-q", "-m", INSTRUMENTATION_COMMIT_MESSAGE, "--", relpath,
    ]
    code, out, err = run_command(commit_cmd, cwd=clone, env=commit_env)
    if code != 0:
        return "error", f"git commit failed: {err or out}"
    code, raw_commit, err = run_command(["git", "cat-file", "commit", "HEAD"], cwd=clone)
    if code != 0:
        return "error", f"cannot read committed object: {err}"
    commit_errors = instrumentation_commit_object_errors(raw_commit, emitted_at=record["emitted_at"])
    if commit_errors:
        return "error", "commit object failed the closed-set parse: " + "; ".join(commit_errors)
    readback = git_object_bytes(clone, f"HEAD:{relpath}")
    if readback != blob_text.encode("utf-8"):
        return "error", (
            "committed blob does not byte-match the validated record (a git clean filter or hook "
            "may have rewritten it) -- refusing to push"
        )
    code, out, err = run_command(["git", *isolation_flags, "push", "origin", f"HEAD:refs/heads/{INSTRUMENTATION_ARCHIVE_BRANCH}"], cwd=clone)
    if code != 0:
        detail = err or out or "git push failed"
        lowered = detail.lower()
        if "rejected" in lowered or "non-fast-forward" in lowered or "fetch first" in lowered:
            return "rejected", detail
        return "error", f"git push failed: {detail}"
    tip = run_git(clone, ["rev-parse", "HEAD"])
    return "pushed", tip


def publish_instrumentation_record(args: argparse.Namespace) -> int:
    """`publish-instrumentation-record`: see the T5 module comment above. Gated on
    instrumentation.enabled; recomputes at publish time; advances no local window state."""
    data, _project_path, target = lane_project(args)
    ensure_lane_state(data, target)
    if not data.get("instrumentation", {}).get("enabled"):
        print(
            "instrumentation: disabled by adapter; sanitized telemetry stays unpublished "
            "(set instrumentation.enabled: true in the adapter to opt this lane in)",
            file=sys.stderr,
        )
        return 1
    if not data["observabilityEvents"].get("enabled"):
        print("instrumentation: observabilityEvents.enabled is false; nothing to aggregate", file=sys.stderr)
        return 1
    remote_url = telemetry_remote_url()
    if not remote_url:
        print("instrumentation: no telemetry remote (framework checkout has no remote.origin.url)", file=sys.stderr)
        return 1

    lane_id = instrumentation_lane_id(target)
    _human, jsonl, event_lock = observability_event_paths(data, target)
    retained = int(data["observabilityEvents"]["retainedRotations"])
    version = plugin_version()

    state_dir = telemetry_state_dir()
    (state_dir / "lane-publish").mkdir(parents=True, exist_ok=True)
    archive_lock_path = state_dir / "archive.lock"
    lane_lock_path = state_dir / "lane-publish" / f"{lane_id}.lock"
    # Checkout-scoped archive lock (all lanes on this machine serialize against the shared branch)
    # plus a lane-local publish lock (one publisher per lane). Event appends are never blocked by
    # these; cross-machine races are handled by the retry loop below.
    with open(archive_lock_path, "w") as archive_lock, open(lane_lock_path, "w") as lane_lock:
        with advisory_flock(archive_lock), advisory_flock(lane_lock):
            with tempfile.TemporaryDirectory(prefix="tautline-telemetry-publish-") as tmp:
                clone = Path(tmp) / "archive"
                empty_hooks = Path(tmp) / "empty-hooks"
                empty_hooks.mkdir()
                empty_attrs = Path(tmp) / "empty-attributes"
                empty_attrs.write_text("", encoding="utf-8")

                branch_exists = remote_branch_exists(remote_url, INSTRUMENTATION_ARCHIVE_BRANCH)
                if branch_exists:
                    code, _out, err = run_command(
                        ["git", "clone", "--branch", INSTRUMENTATION_ARCHIVE_BRANCH, "--single-branch", remote_url, str(clone)]
                    )
                    if code != 0:
                        print(f"instrumentation: git clone failed: {err}", file=sys.stderr)
                        return 1
                    hygiene = instrumentation_branch_hygiene_errors(clone, "HEAD")
                    if hygiene:
                        for error in hygiene:
                            print(f"instrumentation_archive_hygiene: {error}", file=sys.stderr)
                        print(
                            "instrumentation: the fetched archive branch failed fail-closed hygiene; refusing to "
                            "publish. Back up and recreate the branch as a sanitized orphan from a validated tip.",
                            file=sys.stderr,
                        )
                        return 1
                    fetched_tip = run_git(clone, ["rev-parse", "HEAD"])
                    ancestry_error = instrumentation_ancestry_guard_error(clone, remote_url, fetched_tip)
                    if ancestry_error:
                        print(f"instrumentation_archive_hygiene: {ancestry_error}", file=sys.stderr)
                        return 1
                    write_instrumentation_ancestry_tip(remote_url, fetched_tip)
                    remote_max = instrumentation_remote_max_for_lane(clone, "HEAD", lane_id)
                    if remote_max is None:
                        print("instrumentation: could not derive the remote sequence bound (git error); refusing to publish rather than risk republishing already-published windows", file=sys.stderr)
                        return 1
                else:
                    code, _out, err = run_command(["git", "clone", remote_url, str(clone)])
                    if code != 0:
                        print(f"instrumentation: git clone failed: {err}", file=sys.stderr)
                        return 1
                    code, _out, _err = run_command(["git", "checkout", "--orphan", INSTRUMENTATION_ARCHIVE_BRANCH], cwd=clone)
                    if code != 0:
                        print("instrumentation: could not bootstrap orphan archive branch", file=sys.stderr)
                        return 1
                    # Clear the index/worktree inherited from the cloned default branch. The commit
                    # below is pathspec-limited to `relpath`, so only the telemetry blob is ever
                    # committed regardless; clearing keeps the orphan's index free of framework
                    # source as belt-and-suspenders (best-effort: the pathspec limit is the guarantee).
                    run_command(["git", "rm", "-rf", "."], cwd=clone)
                    remote_max = 0

                attempts = 5
                for _attempt in range(attempts):
                    # Aggregate under the SAME event-log advisory lock a writer holds around its
                    # atomic seq allocate + JSONL append. Without it, a publish that interleaves
                    # between another process's seq allocation (which advances the persisted
                    # high-water) and its line append could read a high-water of N with event N not
                    # yet on disk, publish a gap ending at N, and -- once N is appended -- filter it
                    # out forever via remote_max, permanently losing that event.
                    with event_lock.open("a", encoding="utf-8") as event_lock_file, advisory_flock(event_lock_file):
                        record = instrumentation_record_from_events(
                            lane_id=lane_id, remote_max=remote_max, jsonl_path=jsonl,
                            retained_rotations=retained, plugin_version=version,
                        )
                    if record is None:
                        print("instrumentation: nothing to publish (no mapped events, no gap)")
                        return 0
                    errors = instrumentation_record_errors(record)
                    if errors:
                        for error in errors:
                            print(f"instrumentation_record_validation_error: {error}", file=sys.stderr)
                        return 1
                    relpath = instrumentation_archive_relpath(record)
                    status, detail = _instrumentation_commit_and_push(
                        clone=clone, relpath=relpath, record=record,
                        empty_hooks=empty_hooks, empty_attrs=empty_attrs, remote_url=remote_url,
                    )
                    if status == "pushed":
                        write_instrumentation_ancestry_tip(remote_url, detail)
                        print(f"instrumentation_published: {INSTRUMENTATION_ARCHIVE_BRANCH}:{relpath}")
                        print(f"instrumentation_end_seq: {record['end_seq']}")
                        return 0
                    if status == "error":
                        print(f"instrumentation: {detail}", file=sys.stderr)
                        return 1
                    # status == "rejected": the remote advanced. Re-fetch, re-verify, recompute the
                    # window against the new remote_max, and rebuild+re-parse the commit onto the new
                    # tip. If the concurrent publisher already covered our window, it's a no-op.
                    code, _out, err = run_command(
                        ["git", "fetch", "origin", f"{INSTRUMENTATION_ARCHIVE_BRANCH}:refs/remotes/origin/{INSTRUMENTATION_ARCHIVE_BRANCH}"],
                        cwd=clone,
                    )
                    if code != 0:
                        print(f"instrumentation: git fetch after push rejection failed: {err}", file=sys.stderr)
                        return 1
                    code, _out, _err = run_command(
                        ["git", "checkout", "-B", INSTRUMENTATION_ARCHIVE_BRANCH, f"origin/{INSTRUMENTATION_ARCHIVE_BRANCH}"], cwd=clone
                    )
                    if code != 0:
                        print("instrumentation: could not reset to the fetched archive tip after rejection", file=sys.stderr)
                        return 1
                    run_command(["git", "reset", "--hard", f"origin/{INSTRUMENTATION_ARCHIVE_BRANCH}"], cwd=clone)
                    hygiene = instrumentation_branch_hygiene_errors(clone, "HEAD")
                    if hygiene:
                        for error in hygiene:
                            print(f"instrumentation_archive_hygiene: {error}", file=sys.stderr)
                        return 1
                    fetched_tip = run_git(clone, ["rev-parse", "HEAD"])
                    ancestry_error = instrumentation_ancestry_guard_error(clone, remote_url, fetched_tip)
                    if ancestry_error:
                        print(f"instrumentation_archive_hygiene: {ancestry_error}", file=sys.stderr)
                        return 1
                    write_instrumentation_ancestry_tip(remote_url, fetched_tip)
                    remote_max = instrumentation_remote_max_for_lane(clone, "HEAD", lane_id)
                    if remote_max is None:
                        print("instrumentation: could not derive the remote sequence bound (git error) after rejection; refusing to publish rather than risk republishing already-published windows", file=sys.stderr)
                        return 1
                print(f"instrumentation: exhausted {attempts} publish attempts against a concurrently-advancing remote", file=sys.stderr)
                return 1


def public_contract_manifest_data() -> dict:
    command_names = registered_subcommand_names()
    schema = _adapter_schema() or {}
    adapter_keys = sorted((schema.get("properties") or {}).keys())
    skill_roots = [
        REPO_ROOT / "plugins" / "tautline-core" / "skills",
        REPO_ROOT / "plugins" / "tautline-ops" / "skills",
    ]
    skills = []
    for skill_root in skill_roots:
        if not skill_root.exists():
            continue
        for skill in sorted(skill_root.rglob("SKILL.md")):
            rel = skill.relative_to(skill_root).parent.as_posix()
            name = rel if rel != "." else skill.parent.name
            skills.append(
                {
                    "name": name,
                    **public_contract_status_for_skill(name, rel),
                    "path": skill.relative_to(REPO_ROOT).as_posix(),
                }
            )
    policy_modules = []
    for path in sorted((REPO_ROOT / "methodology").rglob("*")):
        if path.name.startswith(".") or path.is_dir():
            continue
        rel_path = path.relative_to(REPO_ROOT).as_posix()
        if path.name == "public-contract-manifest.json":
            status = "stable"
        elif path == POLICY_MODULES_MANIFEST:
            status = "internal"
        elif path.parent == POLICY_MODULES_DIR:
            status = "stable"
        elif path.name in {"canonical-rules.md", "adapter-schema.json"}:
            status = "stable"
        elif path.name.startswith("policy-phrases"):
            status = "internal"
        else:
            status = "experimental"
        policy_modules.append({"name": rel_path, "status": status, "path": rel_path})
    generated_files = [
        {"name": target["outputFile"], "status": "stable", "runtime": target["runtime"]}
        for target in sorted(RUNTIME_TARGETS, key=lambda item: item["outputFile"])
    ]
    generated_files.extend(
        [
            {"name": LANE_ADAPTER_FILE, "status": "stable"},
            {"name": REPO_LOCAL_ADAPTER_FILE, "status": "stable"},
            {"name": FRAMEWORK_PIN_FILE, "status": "stable"},
        ]
    )
    return {
        "schema": PUBLIC_CONTRACT_MANIFEST_SCHEMA,
        "version": methodology_version(),
        "semverPolicy": {
            "patch": "stable bugfixes only; WIP-safe by default when release report declares wipSafe=true",
            "minor": "additive or deprecating only; no removals",
            "major": "breaking removals only after deprecation window and migration path",
            "securityException": "a stable surface CONFIRMED to expose adopter data to a remote MAY be hard-disabled ahead of the normal deprecation window in any release, with a REQUIRED migration note and a named replacement; the surface's REMOVAL still follows the normal major-release window",
        },
        "commands": [
            {"name": name, **public_contract_status_for_command(name)}
            for name in command_names
        ],
        "adapterKeys": [
            {"name": name, **public_contract_status_for_adapter_key(name, schema)}
            for name in adapter_keys
        ],
        "generatedFiles": generated_files,
        "skills": skills,
        "policyModules": policy_modules,
    }


def public_contract_manifest_text() -> str:
    return json.dumps(public_contract_manifest_data(), indent=2, sort_keys=True) + "\n"


def public_contract(args: argparse.Namespace) -> int:
    text = public_contract_manifest_text()
    output = (args.output or PUBLIC_CONTRACT_MANIFEST).resolve()
    if args.write:
        write_text_atomic(output, text)
        print(f"public_contract_manifest_written: {output}")
    if args.check:
        try:
            current = output.read_text(encoding="utf-8")
        except OSError as exc:
            print(f"public_contract_manifest_check: missing/unreadable {output}: {exc}", file=sys.stderr)
            return 1
        if current != text:
            print(f"public_contract_manifest_check: stale {output}", file=sys.stderr)
            return 1
        print(f"public_contract_manifest_check: ok {output}")
    if args.print_manifest or not (args.write or args.check):
        print(text, end="")
    return 0


def public_release_candidate_files(repo_root: Path = REPO_ROOT) -> list[Path]:
    code, out, _err = run_command(
        ["git", "-C", str(repo_root), "ls-files", "-z", "--cached", "--others", "--exclude-standard"]
    )
    if code == 0 and out:
        files = []
        for raw in out.split("\0"):
            if not raw:
                continue
            path = (repo_root / raw).resolve()
            if path.is_file():
                files.append(path)
        return sorted(set(files))
    excluded = {".git", ".venv", "__pycache__", ".pytest_cache", "node_modules", "graphify-out"}
    files = []
    for path in repo_root.rglob("*"):
        if any(part in excluded for part in path.relative_to(repo_root).parts):
            continue
        if path.is_file():
            files.append(path)
    return sorted(files)


def public_release_private_adapters(repo_root: Path = REPO_ROOT) -> list[dict]:
    return public_release_module().private_adapter_entries(
        repo_root / "adapters" / "projects",
        allowed_source_adapters=PUBLIC_RELEASE_ALLOWED_SOURCE_ADAPTERS,
    )


def public_release_private_terms_file_terms(private_terms_file: Path | None = None) -> list[str]:
    return public_release_module().private_terms_file_terms(private_terms_file)


def public_release_configured_private_terms(private_terms_file: Path | None = None) -> list[str]:
    return public_release_module().configured_private_terms(
        env_value=util_module().resolve_env(PUBLIC_RELEASE_PRIVATE_TERMS_ENV),
        private_terms_file=private_terms_file,
    )


def public_release_private_terms(private_adapters: list[dict], private_terms_file: Path | None = None) -> list[str]:
    return public_release_module().private_terms(
        private_adapters,
        configured_terms=public_release_configured_private_terms(private_terms_file),
        reference_adapter_slug=PUBLIC_REFERENCE_ADAPTER_SLUG,
    )


def public_release_private_terms_source(private_terms_file: Path | None = None) -> str:
    return public_release_module().private_terms_source(
        env_value=util_module().resolve_env(PUBLIC_RELEASE_PRIVATE_TERMS_ENV),
        private_terms_file=private_terms_file,
    )


def public_release_private_terms_file_allowed_for_export(
    private_terms_file: Path | None,
    source_root: Path,
    destination: Path,
) -> tuple[bool, str]:
    return public_release_module().private_terms_file_allowed_for_export(
        private_terms_file,
        source_root,
        destination,
    )


def public_release_private_adapter_history_paths(repo_root: Path = REPO_ROOT) -> list[str]:
    code, out, _err = run_command(
        ["git", "-C", str(repo_root), "log", "--all", "--name-only", "--format="],
        timeout=30,
    )
    if code != 0 or not out:
        return []
    pattern = re.compile(r"^" + PUBLIC_RELEASE_PRIVATE_ADAPTER_PATH_RE + r"$")
    paths = set()
    for line in out.splitlines():
        rel = line.strip()
        if rel and pattern.match(rel):
            paths.add(rel)
    return sorted(paths)


def public_release_allowed_account_like_token(token: str, line: str) -> bool:
    return public_release_module().allowed_account_like_token(
        token,
        line,
        placeholder_account_ids=PUBLIC_RELEASE_PLACEHOLDER_ACCOUNT_IDS,
    )


def public_release_git_history_trust_issues(repo_root: Path = REPO_ROOT) -> list[tuple[str, Path, str]]:
    code, out, err = run_command(
        ["git", "-C", str(repo_root), "rev-parse", "--is-inside-work-tree"],
        timeout=10,
    )
    if code != 0 or out.strip().lower() != "true":
        detail = err or out or "not a git work tree"
        return [
            (
                "git-history-unavailable",
                repo_root,
                f"cannot verify git history for public release ({detail}); run the release gate from a complete git checkout",
            )
        ]
    code, out, err = run_command(
        ["git", "-C", str(repo_root), "rev-parse", "--is-shallow-repository"],
        timeout=10,
    )
    if code != 0:
        detail = err or out or "shallow status unavailable"
        return [
            (
                "git-history-unavailable",
                repo_root,
                f"cannot verify whether git history is complete ({detail}); run the release gate from a complete git checkout",
            )
        ]
    if out.strip().lower() == "true":
        return [
            (
                "git-history-shallow",
                repo_root,
                "git checkout is shallow; fetch full history before running the public release gate",
            )
        ]
    return []


def public_release_root_commits(repo_root: Path, ref: str = "HEAD") -> set[str]:
    code, out, _err = run_command(
        ["git", "-C", str(repo_root), "rev-list", "--max-parents=0", ref],
        timeout=10,
    )
    if code != 0:
        return set()
    return {line.strip() for line in out.splitlines() if re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", line.strip())}


def public_release_same_history_remote_issues(repo_root: Path = REPO_ROOT) -> list[tuple[str, Path, str]]:
    local_roots = public_release_root_commits(repo_root)
    if not local_roots:
        return []
    code, out, _err = run_command(
        ["git", "-C", str(repo_root), "for-each-ref", "--format=%(refname)", "refs/remotes"],
        timeout=10,
    )
    if code != 0:
        return []
    issues: list[tuple[str, Path, str]] = []
    seen_remotes: set[str] = set()
    for raw in out.splitlines():
        ref = raw.strip()
        if not ref or ref.endswith("/HEAD"):
            continue
        parts = ref.split("/")
        if len(parts) < 4:
            continue
        remote = parts[2]
        if remote in seen_remotes:
            continue
        remote_roots = public_release_root_commits(repo_root, ref)
        shared = sorted(local_roots & remote_roots)
        if not shared:
            continue
        seen_remotes.add(remote)
        issues.append(
            (
                "same-history-remote",
                repo_root,
                f"target remote '{remote}' shares this repository's root commit {shared[0][:12]}; publish from a clean public-release-export repository instead of a same-history remote",
            )
        )
    return issues


def public_release_add_issue(
    issues: list[dict],
    seen: set[tuple[str, str, int | None, str]],
    code: str,
    path: Path,
    message: str,
    repo_root: Path = REPO_ROOT,
    line: int | None = None,
) -> None:
    public_release_module().add_issue(
        issues,
        seen,
        code,
        path,
        message,
        repo_root=repo_root,
        max_issues_per_rule=PUBLIC_RELEASE_MAX_ISSUES_PER_RULE,
        line=line,
    )


def public_release_issues(
    repo_root: Path = REPO_ROOT,
    private_terms_file: Path | None = None,
    release_update_root: Path | None = None,
) -> list[dict]:
    """Return public-release blockers found under ``repo_root``.

    ``release_update_root`` is used only to evaluate the release-update
    accountability gate ("has this release's update been delivered?"), a
    maintainer-side check. The delivery ledger
    (``docs/releases/release-update-delivery.json``) is export-excluded, so
    it never exists in an exported tree, and the gate is meaningless there.
    Resolution order:

    - If ``release_update_root`` is given explicitly, use it (the export
      path passes the source repo that owns the ledger).
    - Else, if ``repo_root`` itself is a genuine export candidate — it
      carries a valid ``PUBLIC_RELEASE_EXPORT_MARKER`` AND the
      export-excluded ledger is actually absent from that tree — skip the
      release-update issues entirely — a check run *inside* an export has
      no ledger to consult and must not raise spurious, unsatisfiable
      ``release-update-*`` blockers. Both conditions matter: a normal
      maintainer checkout that happens to contain a stray marker file
      still has its ledger, so it is not an export candidate and the gate
      must stay active.
    - Else, fall back to ``repo_root`` (the normal maintainer-repo
      ``public-release-check`` path, where the gate must still fire).
    """
    issues: list[dict] = []
    seen: set[tuple[str, str, int | None, str]] = set()
    private_adapters = public_release_private_adapters(repo_root)
    for entry in private_adapters:
        public_release_add_issue(
            issues,
            seen,
            "private-source-adapter",
            entry["path"],
            f"real product adapter '{entry['project']}' is tracked in the framework repo; move it to {REPO_LOCAL_ADAPTER_FILE} in the adopter repo before public release",
            repo_root,
        )
    for code, path, message in public_release_git_history_trust_issues(repo_root):
        public_release_add_issue(
            issues,
            seen,
            code,
            path,
            message,
            repo_root,
        )
    for code, path, message in public_release_same_history_remote_issues(repo_root):
        public_release_add_issue(
            issues,
            seen,
            code,
            path,
            message,
            repo_root,
        )
    for rel in public_release_private_adapter_history_paths(repo_root):
        public_release_add_issue(
            issues,
            seen,
            "private-adapter-history",
            repo_root / rel,
            "private adapter path remains in git history; rewrite history before flipping this repository public or publish from a clean public repo",
            repo_root,
        )
    # The delivery ledger is export-excluded, so an export tree never carries it.
    # Evaluate release-update accountability against the repository that owns the
    # ledger (the export source), not the scanned tree; otherwise every release
    # reads as undelivered and every export is blocked. When no explicit source
    # is given and the scanned tree is a *genuine* export candidate, the ledger-
    # derived gate is meaningless (it deliberately has no ledger) — skip it rather
    # than falling back to the ledger-less export tree, which would raise the same
    # unsatisfiable blockers a second time (e.g. `public-release-check` run from
    # inside a generated export).
    #
    # Marker presence alone is not proof of that: a normal maintainer checkout
    # can contain a stray, untracked, or leftover marker file without being an
    # export at all, and its ledger is still right there. So require both: a
    # marker that actually validates as an export marker (schema-checked, not
    # just any file with the right name) AND the ledger genuinely absent from
    # this tree. A real export satisfies both (the export writes the marker and
    # excludes the ledger); a stray marker in a normal checkout satisfies only
    # the first, so the gate correctly stays active.
    delivery_root: Path | None
    if release_update_root is not None:
        delivery_root = release_update_root
    elif public_release_export_destination_is_prior_export(repo_root) and not (
        repo_root / RELEASE_UPDATE_DELIVERY_EXPORT_RELATIVE_PATH
    ).exists():
        delivery_root = None
    else:
        delivery_root = repo_root
    if delivery_root is not None:
        for code, path, message in release_update_public_release_issues(delivery_root):
            public_release_add_issue(issues, seen, code, path, message, delivery_root)

    private_terms = public_release_private_terms(private_adapters, private_terms_file)
    private_path_pattern = re.compile(PUBLIC_RELEASE_PRIVATE_ADAPTER_PATH_RE)
    account_id_pattern = re.compile(r"(?<!\d)\d{12}(?!\d)")
    url_pattern = re.compile(r"https?://[^\s\"'<>]+")
    private_term_pattern = (
        re.compile(r"\b(" + "|".join(re.escape(term) for term in private_terms) + r")\b", re.I)
        if private_terms
        else None
    )

    for path in public_release_candidate_files(repo_root):
        try:
            rel = path.resolve().relative_to(repo_root.resolve()).as_posix()
        except ValueError:
            rel = path.as_posix()
        try:
            text = path.read_text(encoding="utf-8")
        except (OSError, UnicodeDecodeError):
            continue
        is_private_adapter = rel.startswith("adapters/projects/") and path.name not in PUBLIC_RELEASE_ALLOWED_SOURCE_ADAPTERS
        for lineno, line in enumerate(text.splitlines(), 1):
            # Digest fragments (a 12-digit run that is interior to a sha/git-object hex
            # token) are filtered at the token level by allowed_account_like_token(), so
            # every path that quotes a digest -- .plan-reviews/, .impl-reviews/, plan
            # evidence headers, changelogs, checksum manifests -- is covered at once, and
            # a real account id sharing a line with a digest is still reported.
            for account_id_match in account_id_pattern.finditer(line):
                account_id = account_id_match.group(0)
                if public_release_allowed_account_like_token(account_id, line):
                    continue
                public_release_add_issue(
                    issues,
                    seen,
                    "account-id",
                    path,
                    "12-digit account-like identifier must not ship in public repo",
                    repo_root,
                    lineno,
                )
                break
            if private_path_pattern.search(line):
                public_release_add_issue(
                    issues,
                    seen,
                    "private-adapter-path",
                    path,
                    "public surface references a non-example adapter path",
                    repo_root,
                    lineno,
                )
            if is_private_adapter and url_pattern.search(line):
                public_release_add_issue(
                    issues,
                    seen,
                    "live-host-in-private-adapter",
                    path,
                    "private adapter contains a URL or host-like endpoint; move adapter private before public release",
                    repo_root,
                    lineno,
                )
            if private_term_pattern and not rel.startswith("adapters/projects/") and private_term_pattern.search(line):
                public_release_add_issue(
                    issues,
                    seen,
                    "private-product-reference",
                    path,
                    "public surface references a real product/client adapter name",
                    repo_root,
                    lineno,
                )
    return issues


def public_release_check(args: argparse.Namespace) -> int:
    require_dev_checkout("public-release-check")
    private_terms_file = getattr(args, "private_terms_file", None)
    issues = public_release_issues(private_terms_file=private_terms_file)
    payload = {"ok": not issues, "issues": issues}
    if args.as_json:
        print(json.dumps(payload, indent=2, sort_keys=True))
    elif issues:
        print("public_release_check: blocked")
        for issue in issues:
            location = issue["path"]
            if "line" in issue:
                location = f"{location}:{issue['line']}"
            print(f"- {issue['code']} {location}: {issue['message']}")
        print(
            "public_release_next: migrate real source adapters with "
            f"`tautline migrate-adapter <adapter> --adopter-target <repo> --write`, "
            "re-render active lanes from repo-local adapters, then remove private adapter files and references before open source release."
        )
    else:
        print("public_release_check: ok")
    return 1 if issues else 0


def public_release_export_path_included(rel: Path) -> bool:
    return public_release_module().export_path_included(
        rel,
        allowed_product_docs=PUBLIC_RELEASE_EXPORT_ALLOWED_PRODUCT_DOCS,
        excluded_prefixes=PUBLIC_RELEASE_EXPORT_EXCLUDED_PREFIXES,
    )


def public_release_export_files(repo_root: Path = REPO_ROOT, include_untracked: bool = False) -> list[tuple[Path, Path]]:
    root = repo_root.resolve()
    files: list[tuple[Path, Path]] = []
    command = ["git", "-C", str(root), "ls-files", "-z", "--cached"]
    if include_untracked:
        command.extend(["--others", "--exclude-standard"])
    code, out, _err = run_command(command)
    if code != 0:
        raise SystemExit("public-release-export: destination source must be a git worktree")
    for raw in out.split("\0"):
        if not raw:
            continue
        rel = Path(raw)
        if rel.is_absolute() or ".." in rel.parts or (rel.parts and rel.parts[0] == ".git"):
            raise SystemExit(f"public-release-export: unsafe tracked path: {raw}")
        if not public_release_export_path_included(rel):
            continue
        source_path = root / rel
        if source_path.is_symlink():
            link_target = os.readlink(source_path)
            if os.path.isabs(link_target):
                raise SystemExit(f"public-release-export: absolute symlink targets are not exported: {rel.as_posix()}")
            logical_target = Path(posixpath.normpath(posixpath.join(rel.parent.as_posix(), link_target)))
            if logical_target.is_absolute() or ".." in logical_target.parts:
                raise SystemExit(
                    f"public-release-export: symlink target escapes or is missing: {rel.as_posix()} -> {link_target}"
                )
            try:
                resolved_target = source_path.resolve(strict=True)
                resolved_target.relative_to(root)
            except (OSError, RuntimeError, ValueError) as exc:
                raise SystemExit(
                    f"public-release-export: symlink target escapes or is missing: {rel.as_posix()} -> {link_target}"
                ) from exc
            files.append((source_path, rel))
            continue
        path = source_path.resolve()
        if not path.is_file():
            continue
        try:
            path.relative_to(root)
        except ValueError:
            raise SystemExit(f"public-release-export: tracked path resolves outside repository: {raw}")
        files.append((path, rel))
    return sorted(files, key=lambda item: item[1].as_posix())


def public_release_dirty_state_path_included(rel: Path) -> bool:
    """Whether ``rel`` should count toward the export's dirty/untracked gates.

    This is deliberately NOT the same predicate as
    ``public_release_export_path_included()``: "excluded from the exported
    tree" (don't ship it) and "excluded from the dirty-state gate" (don't
    refuse the export over its working-tree state) are two different
    concerns. Almost every export-excluded path is also fine to leave dirty
    or untracked (see
    ``test_public_release_export_ignores_dirty_and_untracked_excluded_internal_docs``).
    The one exception is the release-update delivery ledger: it is
    export-excluded (the export tree must not carry an internal ops record),
    but ``public_release_issues()`` re-roots the release-update gate to read
    that ledger from the SOURCE working tree via ``release_update_root``. If
    an uncommitted ledger change didn't count as dirty, someone could run
    `publish-release-update`, leave it uncommitted, and still pass the
    export's release-update gate on the strength of content that was never
    committed. Counting the ledger here restores the pre-export-exclusion
    invariant: a dirty or untracked ledger makes the export refuse.
    """
    if public_release_export_path_included(rel):
        return True
    return rel.as_posix() == RELEASE_UPDATE_DELIVERY_EXPORT_RELATIVE_PATH


def public_release_untracked_export_paths(repo_root: Path = REPO_ROOT) -> list[str]:
    code, out, err = run_command(["git", "-C", str(repo_root), "ls-files", "-z", "--others", "--exclude-standard"])
    if code != 0:
        detail = err or out or "git ls-files failed"
        raise SystemExit(f"public-release-export: cannot inspect untracked files: {detail}")
    return sorted(raw for raw in out.split("\0") if raw and public_release_dirty_state_path_included(Path(raw)))


def public_release_dirty_tracked_paths(repo_root: Path = REPO_ROOT) -> list[str]:
    paths: set[str] = set()
    for args in (
        ["diff", "--name-only"],
        ["diff", "--cached", "--name-only"],
    ):
        code, out, err = run_command(["git", "-C", str(repo_root), *args])
        if code != 0:
            detail = err or out or "git diff failed"
            raise SystemExit(f"public-release-export: cannot inspect dirty tracked files: {detail}")
        paths.update(
            line.strip()
            for line in out.splitlines()
            if line.strip() and public_release_dirty_state_path_included(Path(line.strip()))
        )
    return sorted(paths)


def public_release_export_marker_text(
    private_terms_source: str = "none",
    private_terms_count: int = 0,
) -> str:
    return public_release_module().export_marker_text(
        version=plugin_version(),
        marker_schema=PUBLIC_RELEASE_EXPORT_MARKER_SCHEMA,
        private_terms_source=private_terms_source,
        private_terms_count=private_terms_count,
    )


def public_release_export_destination_is_prior_export(destination: Path) -> bool:
    return public_release_module().export_destination_is_prior_export(
        destination,
        marker_name=PUBLIC_RELEASE_EXPORT_MARKER,
        marker_schema=PUBLIC_RELEASE_EXPORT_MARKER_SCHEMA,
    )


def init_public_release_export_repo(destination: Path) -> str:
    commands = [
        ["git", "init", "-q"],
        ["git", "config", "user.email", "public-release-export@example.invalid"],
        ["git", "config", "user.name", "Minervit Public Release Export"],
        ["git", "add", "-A"],
        [
            "git",
            "-c",
            "commit.gpgsign=false",
            "-c",
            "core.hooksPath=/dev/null",
            "commit",
            "--no-verify",
            "-q",
            "-m",
            f"Initial public release candidate {plugin_version()}",
        ],
    ]
    for command in commands:
        code, out, err = run_command(command, cwd=destination, timeout=60)
        if code != 0:
            detail = err or out or "unknown git error"
            raise SystemExit(f"public-release-export: {' '.join(command)} failed: {detail}")
    return run_git(destination, ["rev-parse", "--short", "HEAD"])


def public_release_export_repository(
    source_root: Path,
    destination: Path,
    force: bool = False,
    include_untracked: bool = False,
    include_working_tree: bool = False,
    private_terms_file: Path | None = None,
) -> dict:
    source = source_root.resolve()
    dest = destination.resolve()
    if dest == source or source in dest.parents or dest in source.parents:
        raise SystemExit("public-release-export: destination must be outside the methodology repository")
    dirty_tracked = public_release_dirty_tracked_paths(source)
    if dirty_tracked and not include_working_tree:
        raise SystemExit(
            "public-release-export: dirty tracked files are not exported by default; "
            "commit them or pass --include-working-tree only for an in-progress validation export"
        )
    # The release-update gate below is re-rooted to read the delivery ledger from
    # `source` (release_update_root=source) rather than the export tree, because the
    # ledger is export-excluded. public_release_dirty_tracked_paths() only inspects
    # `git diff`, which never reports untracked files, so an untracked ledger sails
    # past that check even though it is just as uncommitted as a dirty tracked one.
    # Refuse it here, narrowly -- only the ledger, not arbitrary untracked files; the
    # CLI wrapper already owns the general untracked-file policy for everything else.
    if not include_untracked and RELEASE_UPDATE_DELIVERY_EXPORT_RELATIVE_PATH in public_release_untracked_export_paths(
        source
    ):
        raise SystemExit(
            f"public-release-export: {RELEASE_UPDATE_DELIVERY_EXPORT_RELATIVE_PATH} is untracked in the source "
            "repository; commit it before export, or pass --include-untracked only for an in-progress validation export"
        )
    dest_exists = dest.exists()
    dest_has_entries = dest_exists and any(dest.iterdir())
    if dest_has_entries and not force:
        raise SystemExit(f"public-release-export: destination is not empty; pass --force to replace it: {dest}")
    if dest_has_entries and force and not public_release_export_destination_is_prior_export(dest):
        raise SystemExit(
            "public-release-export: --force only replaces a prior public-release-export destination "
            f"containing {PUBLIC_RELEASE_EXPORT_MARKER}; choose an empty directory or remove it manually"
        )
    files = public_release_export_files(source, include_untracked=include_untracked)
    if dest.exists():
        shutil.rmtree(dest)
    dest.mkdir(parents=True, exist_ok=True)
    for source_file, rel in files:
        target_file = dest / rel
        target_file.parent.mkdir(parents=True, exist_ok=True)
        if source_file.is_symlink():
            target_file.symlink_to(os.readlink(source_file))
        else:
            shutil.copy2(source_file, target_file)
    private_terms = public_release_private_terms(public_release_private_adapters(source), private_terms_file)
    (dest / PUBLIC_RELEASE_EXPORT_MARKER).write_text(
        public_release_export_marker_text(
            private_terms_source=public_release_private_terms_source(private_terms_file),
            private_terms_count=len(private_terms),
        ),
        encoding="utf-8",
    )
    commit = init_public_release_export_repo(dest)
    issues = public_release_issues(dest, private_terms_file=private_terms_file, release_update_root=source)
    return {"files": len(files), "commit": commit, "issues": issues}


def public_release_export(args: argparse.Namespace) -> int:
    require_dev_checkout("public-release-export")
    private_terms_file = getattr(args, "private_terms_file", None)
    destination = args.destination.resolve()
    terms_file_ok, terms_file_error = public_release_private_terms_file_allowed_for_export(
        private_terms_file,
        REPO_ROOT,
        destination,
    )
    if not terms_file_ok:
        print(f"public_release_export_error: {terms_file_error}", file=sys.stderr)
        return 2
    files = public_release_export_files(REPO_ROOT, include_untracked=args.include_untracked)
    private_terms = public_release_private_terms(public_release_private_adapters(REPO_ROOT), private_terms_file)
    dirty_tracked = public_release_dirty_tracked_paths(REPO_ROOT)
    untracked = public_release_untracked_export_paths(REPO_ROOT)
    print(f"public_release_export_mode: {'write' if args.write else 'dry-run'}")
    print(f"public_release_export_source: {REPO_ROOT}")
    print(f"public_release_export_destination: {destination}")
    print(f"public_release_export_files: {len(files)}")
    print(f"public_release_export_private_terms_source: {public_release_private_terms_source(private_terms_file)}")
    print(f"public_release_export_private_terms_count: {len(private_terms)}")
    print(f"public_release_export_dirty_tracked_files: {len(dirty_tracked)}")
    for rel in dirty_tracked[:10]:
        print(f"public_release_export_dirty_tracked_sample: {rel}")
    print(f"public_release_export_untracked_files: {len(untracked)}")
    for rel in untracked[:10]:
        print(f"public_release_export_untracked_sample: {rel}")
    if dirty_tracked and not args.include_working_tree:
        print(
            "public_release_export_error: dirty tracked files are not exported by default; "
            "commit them or pass --include-working-tree only for an in-progress validation export",
            file=sys.stderr,
        )
        return 2
    if untracked and not args.include_untracked:
        print(
            "public_release_export_error: untracked files are not exported by default; "
            "commit them, ignore them, or pass --include-untracked only for an in-progress validation export",
            file=sys.stderr,
        )
        return 2
    if not private_terms and not args.allow_empty_private_terms:
        print(
            "public_release_export_error: no private product/client terms configured; "
            f"set {PUBLIC_RELEASE_PRIVATE_TERMS_ENV}, pass --private-terms-file, "
            "or pass --allow-empty-private-terms only for repos with no private terms",
            file=sys.stderr,
        )
        return 2
    if not args.write:
        print("public_release_export_written: no")
        print("public_release_export_next: rerun with --write to create a clean git repository candidate")
        return 0
    result = public_release_export_repository(
        REPO_ROOT,
        destination,
        force=args.force,
        include_untracked=args.include_untracked,
        include_working_tree=args.include_working_tree,
        private_terms_file=private_terms_file,
    )
    print(f"public_release_export_written: {destination}")
    print(f"public_release_export_commit: {result['commit']}")
    issues = result["issues"]
    if issues:
        print("public_release_export_check: blocked")
        for issue in issues:
            location = issue["path"]
            if "line" in issue:
                location = f"{location}:{issue['line']}"
            print(f"- {issue['code']} {location}: {issue['message']}")
        print(
            "public_release_export_warning: delete the entire blocked candidate directory, "
            f"including its .git history, before sharing it: {destination}"
        )
        return 1
    print("public_release_export_check: ok")
    print("public_release_export_next: push this clean repository to the public remote only after release approval")
    return 0


# ---------------------------------------------------------------------------
# Release tail: registry packages, mirror publish, drift detection.
#
# The registry determines the payload. The npm package is a NAMESPACE POINTER:
# it reserves the name and points at the repository without installing the CLI.
# The PyPI package is REAL: a thin wrapper package plus the full committed tree
# embedded under src/tautline/_dist/, stamped with a build-time snapshot
# manifest. There is deliberately no --payload flag -- after 0.10.0 a pointer
# PyPI build has no legitimate caller, and a flag would be a way to publish the
# wrong thing. All registry copy is generated from here -- because the last
# hand-written copy promised "a native package install lands with the 0.9.0
# package split", which was untrue when it was published and stayed on both
# registries for months. Generated copy cannot drift from the truth by neglect.
# ---------------------------------------------------------------------------
REGISTRY_PACKAGE_NAME = "tautline"
# GitHub's canonical owner/repo, in GitHub's own casing. The OIDC claim used by npm's
# trusted publishing carries this exact string, and npm compares case-sensitively.
REGISTRY_PACKAGE_REPO_SLUG = "Tautlines/tautline"
REGISTRY_PACKAGE_REGISTRIES = ("npm", "pypi")
# The description forks per registry because the PAYLOADS differ: the PyPI package
# installs the real CLI, while the npm package remains a namespace pointer whose
# truthful job is to point at pipx. One shared sentence would lie on one side.
# (dict of str, not a list of str: stays out of the policy-phrases SSOT collector)
_REGISTRY_PACKAGE_DESCRIPTION_LEAD = (
    "Tautline - the governor for AI coding agents. Enforced completion gates, "
    "per-project adapters, and lifecycle guards for AI-driven delivery."
)
REGISTRY_PACKAGE_DESCRIPTIONS = {
    "npm": (
        _REGISTRY_PACKAGE_DESCRIPTION_LEAD
        + " This package is a namespace pointer; install the CLI with pipx install tautline."
    ),
    "pypi": _REGISTRY_PACKAGE_DESCRIPTION_LEAD,
}


def registry_package_readme(registry: str) -> str:
    """The per-registry README.

    Every claim here must be true today. No roadmap promise, no future-tense
    install instruction -- only what actually works right now, which DIFFERS by
    registry since the real PyPI payload landed: the PyPI package installs the
    real CLI, while the npm package remains a namespace pointer whose truthful
    job is to point at pipx.
    """
    repo_url = f"https://github.com/tautlines/{REGISTRY_PACKAGE_NAME}"
    header = (
        "# Tautline\n"
        "\n"
        "The governor for AI coding agents.\n"
        "\n"
    )
    links = (
        "## Links\n"
        "\n"
        f"- Source: {repo_url}\n"
        f"- Roadmap: {repo_url}/blob/main/ROADMAP.md\n"
        f"- Issues: {repo_url}/issues\n"
        "- License: MIT\n"
    )
    if registry == "npm":
        body = (
            "## What this package is\n"
            "\n"
            "This package is a **namespace pointer**. It reserves the name and points at\n"
            "the real distribution; it does not install the CLI.\n"
            "\n"
            "## Install\n"
            "\n"
            "The CLI ships on PyPI:\n"
            "\n"
            "```\n"
            f"pipx install {REGISTRY_PACKAGE_NAME}\n"
            "```\n"
            "\n"
            f"Source and documentation: {repo_url}\n"
            "\n"
        )
    elif registry == "pypi":
        body = (
            "## Install\n"
            "\n"
            "Requires Python 3.10+.\n"
            "\n"
            "```\n"
            f"pipx install {REGISTRY_PACKAGE_NAME}\n"
            "```\n"
            "\n"
            "Or into an existing virtualenv:\n"
            "\n"
            "```\n"
            f"pip install {REGISTRY_PACKAGE_NAME}\n"
            "```\n"
            "\n"
            "Updates arrive through the same channel: `pipx upgrade tautline` or\n"
            "`pip install -U tautline`.\n"
            "\n"
            "## Full runtime from a checkout\n"
            "\n"
            "The installed package is a stamped snapshot of one release; it updates only\n"
            "when you update it. For the auto-updating runtime, use the checkout mode:\n"
            "\n"
            "```\n"
            f"git clone {repo_url}\n"
            f"cd {REGISTRY_PACKAGE_NAME}\n"
            "bin/tautline install-cli\n"
            "```\n"
            "\n"
        )
    else:
        raise SystemExit(
            f"registry-package: unknown registry {registry!r}; "
            f"expected one of {', '.join(REGISTRY_PACKAGE_REGISTRIES)}"
        )
    return header + body + links


def registry_package_license() -> str:
    return (
        "MIT License\n\nCopyright (c) 2026 Minervit\n\n"
        "See https://github.com/tautlines/tautline/blob/main/LICENSE\n"
    )


def registry_package_npm_manifest(version: str) -> str:
    # npm's trusted publishing compares `repository.url` against the OIDC claim, which
    # carries GitHub's canonical `owner/repo` — and npm documents the comparison as
    # case-sensitive. The canonical owner is `Tautlines`, not `tautlines`; publishing
    # 0.9.8 with the lowercase spelling failed with ENEEDAUTH because the OIDC exchange
    # returned no token and npm fell back to asking for a login. Use REGISTRY_PACKAGE_REPO_SLUG.
    repo_url = f"https://github.com/{REGISTRY_PACKAGE_REPO_SLUG}"
    manifest = {
        "name": REGISTRY_PACKAGE_NAME,
        "version": version,
        "description": REGISTRY_PACKAGE_DESCRIPTIONS["npm"],
        "homepage": repo_url,
        "repository": {"type": "git", "url": f"git+{repo_url}.git"},
        "bugs": f"{repo_url}/issues",
        "license": "MIT",
        "keywords": ["ai", "agents", "claude", "codex", "governance", "delivery", "framework"],
    }
    return json.dumps(manifest, indent=2, sort_keys=True) + "\n"


def registry_package_pyproject(version: str) -> str:
    """pyproject.toml for the REAL PyPI package (wrapper + embedded tree).

    Both console scripts are load-bearing: today's plugin hooks and older rendered
    adapters invoke the legacy `minervit-methodology` name. `python -m build` builds
    the wheel FROM the sdist, so the embedded `_dist/` tree is force-included in BOTH
    build targets -- hatchling's default file selection honors ignore rules, and a
    dotfile silently missing from the sdist would silently vanish from the wheel.
    """
    # PyPI does not compare these case-sensitively, but they are the same registry
    # package metadata family as registry_package_npm_manifest()'s repository.url, so
    # they carry GitHub's canonical casing too rather than drifting from the truth.
    repo_url = f"https://github.com/{REGISTRY_PACKAGE_REPO_SLUG}"
    return (
        "[build-system]\n"
        'requires = ["hatchling"]\n'
        'build-backend = "hatchling.build"\n'
        "\n"
        "[project]\n"
        f'name = "{REGISTRY_PACKAGE_NAME}"\n'
        f'version = "{version}"\n'
        f'description = "{REGISTRY_PACKAGE_DESCRIPTIONS["pypi"]}"\n'
        'readme = "README.md"\n'
        'license = {text = "MIT"}\n'
        'requires-python = ">=3.10"\n'
        "\n"
        "[project.scripts]\n"
        f'{CLI_NAME} = "tautline:main"\n'
        f'{LEGACY_CLI_NAME} = "tautline:main"\n'
        "\n"
        "[project.urls]\n"
        f'Homepage = "{repo_url}"\n'
        f'Repository = "{repo_url}"\n'
        f'Issues = "{repo_url}/issues"\n'
        "\n"
        "# The embedded tree (700+ non-Python files incl. dotfiles) must survive BOTH\n"
        "# build targets: python -m build builds the wheel from the sdist, so a file\n"
        "# dropped from the sdist silently vanishes from the wheel. force-include\n"
        "# bypasses hatchling's default (ignore-rule-aware) file selection entirely.\n"
        "# Each target also EXCLUDES _dist from default selection: default selection\n"
        "# matches the tree too, and the wheel archive refuses the duplicate paths\n"
        '# ("A second file is being added to the wheel archive at the same path"),\n'
        "# so force-include must be the tree's single owner.\n"
        "[tool.hatch.build.targets.sdist]\n"
        'include = ["src/tautline", "README.md", "LICENSE", "pyproject.toml"]\n'
        'exclude = ["src/tautline/_dist"]\n'
        "\n"
        "[tool.hatch.build.targets.sdist.force-include]\n"
        '"src/tautline/_dist" = "src/tautline/_dist"\n'
        "\n"
        "[tool.hatch.build.targets.wheel]\n"
        'packages = ["src/tautline"]\n'
        'exclude = ["src/tautline/_dist"]\n'
        "\n"
        "[tool.hatch.build.targets.wheel.force-include]\n"
        '"src/tautline/_dist" = "tautline/_dist"\n'
    )


def registry_package_wrapper_init(version: str) -> str:
    """src/tautline/__init__.py for the real PyPI package: the thin runpy wrapper.

    The full committed tree ships as package data under `_dist/`; main() executes the
    embedded single-file CLI in-process. The CLI's own
    `if __name__ == "__main__": raise SystemExit(main())` guard supplies the exit code,
    so the console scripts behave exactly like the checkout CLI with no protocol
    between the two files.
    """
    return (
        '"""Tautline: thin wrapper around the embedded single-file CLI under _dist/."""\n'
        "\n"
        "import os\n"
        "import runpy\n"
        "import sys\n"
        "\n"
        f'__version__ = "{version}"\n'
        "\n"
        "\n"
        "def main() -> int:\n"
        '    """Run the embedded CLI in-process; its __main__ guard raises SystemExit."""\n'
        "    cli = os.path.join(\n"
        f'        os.path.dirname(os.path.abspath(__file__)), "_dist", "bin", "{CLI_NAME}"\n'
        "    )\n"
        "    sys.argv[0] = cli\n"
        '    runpy.run_path(cli, run_name="__main__")\n'
        "    return 0\n"
    )


def registry_package_files(registry: str, version: str) -> dict[str, str]:
    """The small generated text files of a registry package, by relative path.

    For npm this is the ENTIRE pointer package. For pypi it is the wrapper shell only:
    the embedded `_dist/` tree and its snapshot manifest are materialized by
    registry_package() itself, because they come from `git archive`, not from strings.
    """
    if registry not in REGISTRY_PACKAGE_REGISTRIES:
        raise SystemExit(
            f"registry-package: unknown registry {registry!r}; "
            f"expected one of {', '.join(REGISTRY_PACKAGE_REGISTRIES)}"
        )
    if not re.fullmatch(r"\d+\.\d+\.\d+", version or ""):
        raise SystemExit(f"registry-package: version must be X.Y.Z, got {version!r}")
    readme = registry_package_readme(registry)
    if registry == "npm":
        return {
            "package.json": registry_package_npm_manifest(version),
            "README.md": readme,
            "LICENSE": registry_package_license(),
        }
    return {
        "pyproject.toml": registry_package_pyproject(version),
        "README.md": readme,
        "LICENSE": registry_package_license(),
        "src/tautline/__init__.py": registry_package_wrapper_init(version),
    }


# Where the pypi payload embeds the committed tree, relative to the package root.
REGISTRY_PACKAGE_DIST_DIR = "src/tautline/_dist"


def registry_package_tree_files(repo_root: Path = REPO_ROOT) -> list[str]:
    """Committed-tree paths (HEAD) the pypi payload embeds under `_dist/`.

    Filtered by the public-release export exclusion rule. SCOPE THE SANITIZATION CLAIM
    HONESTLY: the PUBLISHED wheel's sanitization guarantee comes entirely from the
    publish rail building at the sanitized public-mirror tag checkout, where this
    filter is a no-op. On a private dev tree (the CI smoke, local scratch builds) the
    prefix filter is defense-in-depth only -- it is NOT the full public sanitizer, so
    dev-tree wheels are never claimed sanitized and no publish path consumes them.
    """
    code, out, err = run_command(
        ["git", "-C", str(repo_root), "ls-tree", "-r", "--name-only", "-z", "HEAD"]
    )
    if code != 0:
        raise SystemExit(
            "registry-package: cannot list the committed tree: "
            f"{err or out or 'git ls-tree failed'}"
        )
    names = [name for name in out.split("\0") if name]
    return [name for name in names if public_release_export_path_included(Path(name))]


def registry_package_export_dist(
    dist_dir: Path, tree_files: list[str], repo_root: Path = REPO_ROOT
) -> None:
    """Materialize the embedded `_dist/` tree from `git archive HEAD` (committed content only)."""
    keep = set(tree_files)
    with tempfile.TemporaryDirectory(prefix="registry-package-") as tmp:
        tar_path = Path(tmp) / "tree.tar"
        code, _stdout, stderr = run_command(
            ["git", "-C", str(repo_root), "archive", "--format=tar", "-o", str(tar_path), "HEAD"]
        )
        if code != 0:
            raise SystemExit(f"registry-package: git archive failed: {stderr or 'unknown error'}")
        if dist_dir.exists():
            # Codex R1 P2: a reused --destination must not overlay stale files into the
            # embedded tree — a file deleted from (or newly excluded by) the committed tree
            # would otherwise survive from an earlier build, and force-include ships the
            # WHOLE directory into the wheel. The export owns `_dist/` and starts empty.
            shutil.rmtree(dist_dir)
        dist_dir.mkdir(parents=True, exist_ok=True)
        try:
            with tarfile.open(tar_path) as archive:
                _reject_unsafe_tar_members(archive)
                members = [m for m in archive.getmembers() if m.isfile() and m.name in keep]
                if sys.version_info >= (3, 12):
                    archive.extractall(dist_dir, members=members, filter="data")
                else:
                    archive.extractall(dist_dir, members=members)  # members validated above
        except tarfile.TarError as exc:
            raise SystemExit(f"registry-package: tree extraction failed: {exc}") from exc
    cli_path = dist_dir / "bin" / CLI_NAME
    if not cli_path.is_file() or not (dist_dir / "VERSION").is_file():
        raise SystemExit(
            f"registry-package: exported tree is missing bin/{CLI_NAME} or VERSION; "
            "refusing to package it"
        )
    cli_path.chmod(0o755)


def registry_package_snapshot_manifest(version: str, channel: str, commit: str) -> str:
    """The wheel's build-time `.snapshot-meta.json` (schema tautline-snapshot/v1).

    Exactly the manifest shape snapshot_manifest() already trusts (extra keys are
    tolerated), plus `installKind: "package"` for package-mode behavior. Deliberately
    NO `canonicalRepo` key and no other absolute path: canonical_methodology_repo()
    treats a manifest canonicalRepo as a resolution candidate, and a baked
    build-machine path must never win canonical resolution (nor survive the public
    boundary scan).
    """
    manifest = {
        "schema": SNAPSHOT_MANIFEST_SCHEMA,
        "commit": commit,
        "shortCommit": commit[:12],
        "version": version,
        "channel": channel,
        "materializedAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
        "installKind": "package",
    }
    return json.dumps(manifest, indent=2, sort_keys=True) + "\n"


def registry_package(args: argparse.Namespace) -> int:
    files = registry_package_files(args.registry, args.version)
    destination = args.destination.resolve()
    tree_files: list[str] = []
    head_commit = ""
    if args.registry == "pypi":
        # The real payload reads the tree AS a git repository (archive + commit stamp),
        # so refuse anywhere git cannot answer -- same posture as require_dev_checkout.
        require_dev_checkout("registry-package")
        # running_methodology_commit() is the ONE sanctioned commit reader (the structural
        # guard in tests/test_canonical_repo_resolution.py pins that class-wide, allowlist
        # deliberately empty). require_dev_checkout has just refused snapshot manifests and
        # non-git exec roots, so here the reader IS `rev-parse HEAD` of the checkout we
        # archive -- and its "unavailable" sentinel still fail-closes on the check below.
        head_commit = running_methodology_commit()
        if not re.fullmatch(r"[0-9a-f]{40}", head_commit):
            raise SystemExit(
                "registry-package: cannot resolve HEAD in this checkout; "
                "the pypi payload embeds the committed tree and needs a commit to stamp"
            )
        code, tree_version, err = run_command(
            ["git", "-C", str(REPO_ROOT), "show", "HEAD:VERSION"]
        )
        if code != 0:
            raise SystemExit(
                "registry-package: cannot read VERSION from the committed tree: "
                f"{err or 'git show failed'}"
            )
        tree_version = tree_version.strip()
        if tree_version != args.version:
            # Same fail-closed shape as the publish workflow's tag-vs-VERSION verify:
            # the embedded tree must carry exactly the version it is published as.
            raise SystemExit(
                f"registry-package: --version {args.version} disagrees with the archived "
                f"tree's VERSION {tree_version}; commit the version you intend to package"
            )
        tree_files = registry_package_tree_files()
    total = len(files) + (len(tree_files) + 1 if args.registry == "pypi" else 0)
    print(f"registry_package_registry: {args.registry}")
    print(f"registry_package_name: {REGISTRY_PACKAGE_NAME}")
    print(f"registry_package_version: {args.version}")
    if args.registry == "pypi":
        print(f"registry_package_channel: {args.channel}")
        print(f"registry_package_commit: {head_commit}")
    print(f"registry_package_destination: {destination}")
    print(f"registry_package_files: {total}")
    if not args.write:
        print("registry_package_written: no")
        print("registry_package_next: rerun with --write to materialize the package tree")
        return 0
    for rel, content in files.items():
        path = destination / rel
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(content, encoding="utf-8")
    if args.registry == "pypi":
        dist_dir = destination / Path(REGISTRY_PACKAGE_DIST_DIR)
        registry_package_export_dist(dist_dir, tree_files)
        (dist_dir / SNAPSHOT_MANIFEST_NAME).write_text(
            registry_package_snapshot_manifest(args.version, args.channel, head_commit),
            encoding="utf-8",
        )
    print(f"registry_package_written: {destination}")
    return 0


# ---------------------------------------------------------------------------
# The release tail: export -> mirror commit -> tag -> Release -> registries.
#
# Two facts shape every line below.
#
# 1. The export is a FRESH repository with a root commit
#    (public_release_export_repository), so it shares no ancestor with the public
#    mirror and CANNOT be fast-forwarded onto it. Pushing it would demand a
#    force-push and would orphan every existing clone. The mirror commit is
#    therefore built by cloning the mirror, overlaying the export tree while
#    preserving `.git/`, and committing on top of the mirror's current HEAD.
#    The mirror's history is never rewritten and is never force-pushed.
#
# 2. Run from this checkout, bare `gh` resolves to the DEV repository. A `gh`
#    call without `--repo` would create the Release in the wrong repo, where no
#    publish workflow exists -- a silent no-op. Every `gh` call in the tail goes
#    through release_tail_gh(), which pins --repo and refuses a caller-supplied
#    one, so the pinning cannot be forgotten at a new call site.
# ---------------------------------------------------------------------------
PUBLIC_MIRROR_REPO = "tautlines/tautline"
PUBLIC_MIRROR_REMOTE = "https://github.com/tautlines/tautline.git"
PUBLIC_MIRROR_BRANCH = "main"
RELEASE_TAIL_PUBLISH_WORKFLOWS = {"npm": "publish-npm.yml", "pypi": "publish-pypi.yml"}
RELEASE_TAIL_RUN_FIELDS = "conclusion,databaseId,event,headBranch,headSha,status,url"
# Release notes are a permanent public surface: once published they are indexed,
# mirrored, and quoted. A leak cannot be taken back, so refuse to publish notes
# carrying an internal tracker id, an internal doc path, or a local absolute path.
RELEASE_TAIL_LEAK_TERMS = ("METH-FU", "docs/backlog/", "docs/productization/", "/Users/")
RELEASE_REGISTRY_URLS = {
    "npm": f"https://registry.npmjs.org/{REGISTRY_PACKAGE_NAME}",
    "pypi": f"https://pypi.org/pypi/{REGISTRY_PACKAGE_NAME}/json",
}


class ReleaseTailError(Exception):
    """A fail-closed refusal. release_tail() turns it into a message + non-zero exit."""


def release_http_json(url: str, timeout: int = 20) -> dict | None:
    """GET a registry JSON document. None means 'absent' (404).

    Any other failure propagates: an unreadable registry is NOT evidence that a
    version is absent, and the caller must fail closed rather than guess.
    """
    request = Request(url, headers={"User-Agent": "tautline-release-tail"})
    try:
        # Fixed https registry URLs only; never user-supplied.
        with urlopen(request, timeout=timeout) as response:
            return json.loads(response.read().decode("utf-8"))
    except HTTPError as exc:
        if exc.code == 404:
            return None
        raise


def registry_latest_version(registry: str) -> str:
    payload = release_http_json(RELEASE_REGISTRY_URLS[registry])
    if payload is None:
        return "absent"
    if registry == "npm":
        return str((payload.get("dist-tags") or {}).get("latest") or "absent")
    return str((payload.get("info") or {}).get("version") or "absent")


def registry_has_version(registry: str, version: str) -> bool:
    payload = release_http_json(RELEASE_REGISTRY_URLS[registry])
    if payload is None:
        return False
    key = "versions" if registry == "npm" else "releases"
    return version in (payload.get(key) or {})


def release_drift_surfaces() -> tuple[dict[str, str], list[str]]:
    surfaces = {"repo": plugin_version()}
    unreadable: list[str] = []
    for registry in ("npm", "pypi"):
        try:
            surfaces[registry] = registry_latest_version(registry)
        except (HTTPError, URLError, OSError, ValueError) as exc:
            # Fail closed: report it as a surface that does not agree, never as agreement.
            surfaces[registry] = "unreadable"
            unreadable.append(f"{registry}: {exc}")
    return surfaces, unreadable


def release_drift_check(args: argparse.Namespace) -> int:
    """Report every release surface and its version, then the verdict.

    The failure this exists to catch: for months the repo said 0.9.1 while npm and
    PyPI both sat at 0.8.2, and nothing looked.
    """
    surfaces, unreadable = release_drift_surfaces()
    repo = surfaces["repo"]
    drifted = sorted(name for name in ("npm", "pypi") if surfaces[name] != repo)
    ok = not drifted
    if getattr(args, "as_json", False):
        print(
            json.dumps(
                {
                    "ok": ok,
                    "surfaces": surfaces,
                    "drifted": drifted,
                    "unreadable": unreadable,
                },
                indent=2,
                sort_keys=True,
            )
        )
        return 0 if ok else 1
    for name in ("repo", "npm", "pypi"):
        print(f"release_drift_{name}: {surfaces[name]}")
    for detail in unreadable:
        print(f"release_drift_unreadable: {detail}")
    if ok:
        print("release_drift_verdict: aligned")
        return 0
    disagreement = ", ".join(f"{name}={surfaces[name]}" for name in drifted)
    print(f"release_drift_verdict: drift (repo={repo}; {disagreement})")
    print(
        "release_drift_next: run `tautline release-tail` to publish the current "
        "version to every stale surface"
    )
    return 1


def release_tail_leaks(text: str) -> list[str]:
    return sorted({term for term in RELEASE_TAIL_LEAK_TERMS if term in text})


def release_tail_notes(version: str) -> str:
    block = changelog_release_block(version)
    if block is None:
        return ""
    body = str(block.get("body", "")).strip()
    changelog = f"https://github.com/{PUBLIC_MIRROR_REPO}/blob/main/CHANGELOG.md"
    return f"{body}\n\nFull changelog: {changelog}\n"


def release_tail_preflight(version: str) -> list[str]:
    errors: list[str] = []
    blocks = changelog_release_blocks()
    newest = str(blocks[0].get("version", "")) if blocks else ""
    if newest != version:
        errors.append(
            f"VERSION is {version} but the newest CHANGELOG section is "
            f"{newest or 'missing'}; the release notes would describe the wrong release"
        )
    if changelog_release_block(version) is None:
        errors.append(f"CHANGELOG has no section for {version}")
    else:
        leaks = release_tail_leaks(release_tail_notes(version))
        if leaks:
            errors.append(
                "release notes contain terms that must never be published: "
                + ", ".join(leaks)
            )
    return errors


def release_tail_gh(args: list[str], timeout: int = 120) -> tuple[int, str, str]:
    """The ONLY way the tail talks to gh. --repo is pinned here, not at call sites."""
    if "--repo" in args:
        raise ReleaseTailError("release_tail_gh() pins --repo; call sites must not pass it")
    return run_command(["gh", *args, "--repo", PUBLIC_MIRROR_REPO], timeout=timeout)


def release_tail_git(clone: Path, args: list[str], timeout: int = 600) -> tuple[int, str, str]:
    return run_command(["git", "-C", str(clone), *args], timeout=timeout)


def release_tail_overlay(export_dir: Path, clone: Path) -> None:
    """Overlay the export tree onto the mirror clone, preserving the mirror's `.git/`.

    Clearing the clone first is what makes export-EXCLUDED paths become real
    deletions on the mirror instead of lingering forever. Skipping the export's own
    `.git/` is what keeps the mirror's history intact: the export carries a fresh
    root commit that shares no ancestor with the mirror, and letting it overwrite
    the mirror's history is precisely the force-push this design refuses to do.
    """
    for entry in clone.iterdir():
        if entry.name == ".git":
            continue
        if entry.is_dir() and not entry.is_symlink():
            shutil.rmtree(entry)
        else:
            entry.unlink()
    for entry in sorted(export_dir.iterdir()):
        if entry.name == ".git":
            continue
        target = clone / entry.name
        if entry.is_dir() and not entry.is_symlink():
            shutil.copytree(entry, target, symlinks=True)
        else:
            shutil.copy2(entry, target, follow_symlinks=False)


def release_tail_mirror_push(version: str, export_dir: Path, clone: Path) -> tuple[str, bool]:
    """Commit the export tree onto the mirror's current HEAD and fast-forward push.

    Returns (sha, changed). `changed` is False when the mirror already carries this
    exact tree -- a resumed run must not add an empty duplicate commit.
    """
    code, out, err = run_command(
        ["git", "clone", "--quiet", PUBLIC_MIRROR_REMOTE, str(clone)], timeout=900
    )
    if code != 0:
        raise ReleaseTailError(f"cannot clone the public mirror: {err or out}")
    code, previous_head, err = release_tail_git(clone, ["rev-parse", "HEAD"])
    if code != 0:
        raise ReleaseTailError(f"cannot read the mirror's HEAD: {err}")
    print(f"release_tail_mirror_previous_head: {previous_head}")

    release_tail_overlay(export_dir, clone)
    release_tail_git(clone, ["config", "user.email", "release-tail@tautline.invalid"])
    release_tail_git(clone, ["config", "user.name", "Tautline Release Tail"])
    code, out, err = release_tail_git(clone, ["add", "-A"])
    if code != 0:
        raise ReleaseTailError(f"cannot stage the export tree: {err or out}")
    code, status, err = release_tail_git(clone, ["status", "--porcelain"])
    if code != 0:
        raise ReleaseTailError(f"cannot inspect the mirror clone: {err}")
    if not status.strip():
        print("release_tail_mirror: current")
        return previous_head, False

    code, out, err = release_tail_git(
        clone,
        [
            "-c",
            "commit.gpgsign=false",
            "-c",
            "core.hooksPath=/dev/null",
            "commit",
            "--no-verify",
            "-q",
            "-m",
            f"Tautline {version}",
        ],
    )
    if code != 0:
        raise ReleaseTailError(f"cannot commit onto the mirror: {err or out}")
    code, sha, err = release_tail_git(clone, ["rev-parse", "HEAD"])
    if code != 0:
        raise ReleaseTailError(f"cannot read the new mirror commit: {err}")
    # Fast-forward only. No --force, no --force-with-lease, no `+` refspec: the
    # mirror's history and everyone's clones survive a release.
    code, out, err = release_tail_git(
        clone, ["push", "origin", f"HEAD:refs/heads/{PUBLIC_MIRROR_BRANCH}"]
    )
    if code != 0:
        raise ReleaseTailError(f"cannot fast-forward the mirror: {err or out}")
    print("release_tail_mirror: pushed")
    return sha, True


def release_tail_tag_commit(out: str, tag: str) -> str:
    """The COMMIT a tag ref resolves to, peeling an annotated tag if it is one.

    `git tag -a` (what this command creates below) makes an ANNOTATED tag, whose
    ref points at a tag OBJECT, not at a commit. Comparing that object's sha to a
    commit sha never matches, so the "is this tag already where we want it?" check
    would refuse every tag the tail itself pushed -- breaking the documented resume
    path. Asking for the peeled ref (`refs/tags/<tag>^{}`) yields the commit; a
    lightweight tag has no peeled entry and already points at the commit.
    """
    refs = {}
    for line in out.splitlines():
        parts = line.split("\t")
        if len(parts) == 2 and parts[0].strip() and parts[1].strip():
            refs[parts[1].strip()] = parts[0].strip()
    peeled = refs.get(f"refs/tags/{tag}^{{}}", "")
    return peeled or refs.get(f"refs/tags/{tag}", "")


def release_tail_ensure_tag(version: str, sha: str, clone: Path) -> str:
    tag = f"v{version}"
    code, out, err = release_tail_git(
        clone,
        ["ls-remote", "--tags", "origin", f"refs/tags/{tag}", f"refs/tags/{tag}^{{}}"],
    )
    if code != 0:
        raise ReleaseTailError(f"cannot list the mirror's tags: {err or out}")
    # Compare COMMIT to COMMIT. The guard itself is untouched: a tag that genuinely
    # points at a different commit still refuses.
    existing = release_tail_tag_commit(out, tag)
    if existing:
        if existing != sha:
            raise ReleaseTailError(
                f"tag {tag} already exists on the mirror at {existing}, not {sha}; "
                "refusing to move a published tag"
            )
        print(f"release_tail_tag: current {tag}")
        return "current"
    code, out, err = release_tail_git(clone, ["tag", "-a", tag, "-m", f"Tautline {version}", sha])
    if code != 0:
        raise ReleaseTailError(f"cannot create tag {tag}: {err or out}")
    code, out, err = release_tail_git(clone, ["push", "origin", f"refs/tags/{tag}"])
    if code != 0:
        raise ReleaseTailError(f"cannot push tag {tag}: {err or out}")
    print(f"release_tail_tag: created {tag}")
    return "created"


def release_tail_release_state(tag: str) -> dict | None:
    code, out, _err = release_tail_gh(
        ["release", "view", tag, "--json", "isDraft,tagName,targetCommitish,url"]
    )
    if code != 0:
        return None
    try:
        state = json.loads(out or "null")
    except json.JSONDecodeError:
        return None
    return state if isinstance(state, dict) else None


def release_tail_ensure_release(
    version: str, tag: str, sha: str, notes_path: Path, skip_registry: bool
) -> str:
    """Create/publish the Release. Returns 'drafted' | 'published' | 'existing'.

    A DRAFT emits no `published` event, so no publish workflow fires. That is what
    makes the one-time bootstrap safe: --skip-registry leaves a real Release for the
    version, the operator configures Trusted Publishing against the workflows that
    now exist on the mirror, and a later run PUBLISHES THAT SAME DRAFT -- firing the
    event exactly once. Creating a second Release instead would double-publish.
    """
    state = release_tail_release_state(tag)
    if state is None:
        command = [
            "release",
            "create",
            tag,
            "--target",
            sha,
            "--title",
            f"Tautline {version}",
            "--notes-file",
            str(notes_path),
        ]
        if skip_registry:
            command.append("--draft")
        code, out, err = release_tail_gh(command)
        if code != 0:
            raise ReleaseTailError(f"cannot create the Release: {err or out}")
        if skip_registry:
            print("release_tail_release: drafted")
            return "drafted"
        print("release_tail_release: published")
        return "published"
    if state.get("isDraft"):
        if skip_registry:
            print("release_tail_release: drafted")
            return "drafted"
        code, out, err = release_tail_gh(["release", "edit", tag, "--draft=false"])
        if code != 0:
            raise ReleaseTailError(f"cannot publish the draft Release: {err or out}")
        print("release_tail_release: published")
        return "published"
    print("release_tail_release: existing")
    return "existing"


def release_tail_matching_run(workflow: str, sha: str) -> dict | None:
    """The run of `workflow` for THIS release, matched on the exact head SHA.

    A stale successful run of the same workflow (a previous release) must never be
    read as this release's success, so the match is on the SHA being released and
    nothing weaker. No matching run yet means STILL WAITING -- never success.
    """
    code, out, err = release_tail_gh(
        ["run", "list", "--workflow", workflow, "--json", RELEASE_TAIL_RUN_FIELDS, "--limit", "30"]
    )
    if code != 0:
        raise ReleaseTailError(f"cannot list runs for {workflow}: {err or out}")
    try:
        runs = json.loads(out or "[]")
    except json.JSONDecodeError as exc:
        raise ReleaseTailError(f"invalid run JSON for {workflow}: {exc}") from exc
    for run in runs if isinstance(runs, list) else []:
        if run.get("headSha") == sha:
            return run
    return None


def release_tail_wait_for_runs(
    workflows: list[str], sha: str, timeout: int, poll_interval: int
) -> None:
    deadline = time.monotonic() + timeout
    pending = set(workflows)
    failed: list[str] = []
    while pending:
        for workflow in sorted(pending):
            run = release_tail_matching_run(workflow, sha)
            if run is None or run.get("status") != "completed":
                continue  # no run at this SHA yet, or still running: not success
            pending.discard(workflow)
            conclusion = str(run.get("conclusion") or "unknown")
            print(f"release_tail_run: {workflow} conclusion={conclusion} url={run.get('url')}")
            if conclusion != "success":
                failed.append(workflow)
        if not pending:
            break
        if time.monotonic() >= deadline:
            raise ReleaseTailError(
                f"timed out after {timeout}s waiting for publish runs at {sha}: "
                + ", ".join(sorted(pending))
            )
        time.sleep(poll_interval)
    if failed:
        raise ReleaseTailError("publish workflow failed: " + ", ".join(sorted(failed)))


def release_tail_publish_registries(
    version: str, tag: str, sha: str, fired: bool, timeout: int, poll_interval: int
) -> None:
    """Ensure both registries hold `version`, then wait for the runs that do it.

    When the Release was just published the event already fired both workflows. On a
    resumed run the Release is already published, so no event fires -- the missing
    registry's workflow is dispatched explicitly, pinned to the version and the tag.
    Either way the wait matches runs by the exact SHA.
    """
    awaited: list[str] = []
    for registry, workflow in sorted(RELEASE_TAIL_PUBLISH_WORKFLOWS.items()):
        try:
            published = registry_has_version(registry, version)
        except (HTTPError, URLError, OSError, ValueError) as exc:
            raise ReleaseTailError(f"cannot read {registry} before publishing: {exc}") from exc
        if published:
            print(f"release_tail_{registry}: {version} (already published)")
            continue
        if not fired:
            code, out, err = release_tail_gh(
                ["workflow", "run", workflow, "--ref", tag, "-f", f"version={version}"]
            )
            if code != 0:
                raise ReleaseTailError(f"cannot dispatch {workflow}: {err or out}")
            print(f"release_tail_{registry}_dispatch: {workflow} version={version} ref={tag}")
        awaited.append(workflow)
    if awaited:
        print(f"release_tail_waiting: {', '.join(awaited)} at {sha}")
        release_tail_wait_for_runs(awaited, sha, timeout, poll_interval)


def release_tail_print_plan(version: str, tag: str) -> None:
    print(f"release_tail_plan_export: public-release-export of {REPO_ROOT} (gate must be ok)")
    print(
        f"release_tail_plan_mirror: clone {PUBLIC_MIRROR_REPO}, overlay the export onto its "
        f"current HEAD, commit, fast-forward push to {PUBLIC_MIRROR_BRANCH} (never --force)"
    )
    print(f"release_tail_plan_tag: create and push {tag} at the pushed commit")
    print(
        f"release_tail_plan_release: gh release create {tag} --repo {PUBLIC_MIRROR_REPO} "
        "--target <pushed sha> (--draft with --skip-registry)"
    )
    print(
        "release_tail_plan_publish: the published Release fires "
        f"{', '.join(sorted(RELEASE_TAIL_PUBLISH_WORKFLOWS.values()))} on {PUBLIC_MIRROR_REPO}; "
        "wait for the runs at that exact SHA"
    )
    print("release_tail_plan_drift: release-drift-check must report repo == npm == pypi")


def release_tail_run(args: argparse.Namespace) -> int:
    version = plugin_version()
    tag = f"v{version}"
    print(f"release_tail_mode: {'dry-run' if args.dry_run else 'write'}")
    print(f"release_tail_version: {version}")
    print(f"release_tail_repo: {PUBLIC_MIRROR_REPO}")

    errors = release_tail_preflight(version)
    if errors:
        raise ReleaseTailError("; ".join(errors))
    print("release_tail_preflight: ok")

    if args.dry_run:
        release_tail_print_plan(version, tag)
        print("release_tail_written: no")
        print("release_tail_next: rerun without --dry-run to execute the tail")
        return 0

    private_terms = public_release_private_terms(
        public_release_private_adapters(REPO_ROOT), args.private_terms_file
    )
    if not private_terms and not args.allow_empty_private_terms:
        raise ReleaseTailError(
            "no private product/client terms configured; set "
            f"{PUBLIC_RELEASE_PRIVATE_TERMS_ENV}, pass --private-terms-file, or pass "
            "--allow-empty-private-terms only for repos with no private terms"
        )

    with tempfile.TemporaryDirectory(prefix="tautline-release-tail-") as scratch:
        root = Path(scratch)
        export_dir = root / "export"
        clone_dir = root / "mirror"

        result = public_release_export_repository(
            REPO_ROOT,
            export_dir,
            force=True,
            private_terms_file=args.private_terms_file,
        )
        issues = result.get("issues") or []
        if issues:
            details = "; ".join(f"{i.get('code')} {i.get('path')}" for i in issues[:5])
            raise ReleaseTailError(
                f"the public-release export gate is not ok ({len(issues)} issue(s)): {details}"
            )
        print(f"release_tail_export_files: {result.get('files')}")
        print("release_tail_export_check: ok")

        sha, _changed = release_tail_mirror_push(version, export_dir, clone_dir)
        print(f"release_tail_mirror_sha: {sha}")
        release_tail_ensure_tag(version, sha, clone_dir)

        notes_path = root / "release-notes.md"
        notes_path.write_text(release_tail_notes(version), encoding="utf-8")
        state = release_tail_ensure_release(version, tag, sha, notes_path, args.skip_registry)

        if args.skip_registry:
            print("release_tail_registries: skipped")
            print(
                "release_tail_next: configure Trusted Publishing on npm and PyPI against "
                f"{PUBLIC_MIRROR_REPO} + "
                f"{', '.join(sorted(RELEASE_TAIL_PUBLISH_WORKFLOWS.values()))}, then rerun "
                "`tautline release-tail` to publish the draft Release"
            )
            return 0

        # 'published' means the event just fired both workflows. 'existing' means the
        # Release was already published, so nothing fired and the missing registry's
        # workflow must be dispatched explicitly.
        release_tail_publish_registries(
            version, tag, sha, state == "published", args.timeout, args.poll_interval
        )

    drift = release_drift_check(argparse.Namespace(as_json=False))
    if drift != 0:
        raise ReleaseTailError(
            f"the publish workflows finished but the registries do not hold {version}; "
            "see release_drift_verdict above"
        )
    print("release_tail_written: yes")
    return 0


def release_tail(args: argparse.Namespace) -> int:
    try:
        return release_tail_run(args)
    except ReleaseTailError as exc:
        print(f"release_tail_error: {exc}", file=sys.stderr)
        return 2
    except SystemExit as exc:  # deeper CLI helpers fail closed with SystemExit
        print(f"release_tail_error: {exc}", file=sys.stderr)
        return 2


def ensure_git_excludes(target: Path, patterns: list[str], label: str = "Minervit lane-local state") -> None:
    patterns = [pattern for pattern in dict.fromkeys(patterns) if pattern]
    git_dir = run_git(target, ["rev-parse", "--git-dir"])
    if not git_dir or git_dir == "unavailable":
        return
    git_path = Path(git_dir)
    if not git_path.is_absolute():
        git_path = target / git_path
    exclude = git_path / "info" / "exclude"
    exclude.parent.mkdir(parents=True, exist_ok=True)
    existing = exclude.read_text(encoding="utf-8") if exclude.exists() else ""
    lines = {line.strip() for line in existing.splitlines()}
    missing = [pattern for pattern in patterns if pattern not in lines]
    if missing:
        with exclude.open("a", encoding="utf-8") as f:
            if existing and not existing.endswith("\n"):
                f.write("\n")
            f.write(f"\n# {label}\n")
            for pattern in missing:
                f.write(f"{pattern}\n")


def ensure_root_gitignore_patterns(target: Path, patterns: list[str], label: str = "Minervit generated output") -> list[str]:
    patterns = [pattern for pattern in dict.fromkeys(patterns) if pattern]
    if not patterns:
        return []
    git_dir = run_git(target, ["rev-parse", "--show-toplevel"])
    if not git_dir or git_dir == "unavailable":
        return []
    root = Path(git_dir)
    gitignore = root / ".gitignore"
    existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else ""
    lines = {line.strip() for line in existing.splitlines()}
    missing = [pattern for pattern in patterns if pattern not in lines]
    if not missing:
        return []
    with gitignore.open("a", encoding="utf-8") as f:
        if existing and not existing.endswith("\n"):
            f.write("\n")
        if existing.strip():
            f.write("\n")
        f.write(f"# {label}\n")
        for pattern in missing:
            f.write(f"{pattern}\n")
    return [str(gitignore)]


def first_dir_pattern(path_text: str) -> str:
    return util_module().first_dir_pattern(path_text)


def slugify(text: str, fallback: str = "lane") -> str:
    return util_module().slugify(text, fallback)


def lane_slot(target: Path) -> int:
    name = target.name.lower()
    match = re.search(r"(?:lane|lane[-_ ])(\d+)", name)
    if match:
        return max(int(match.group(1)) - 1, 0)
    digest = hashlib.sha256(str(target.resolve()).encode("utf-8")).hexdigest()
    return 50 + (int(digest[:8], 16) % 400)


def lane_slug(data: dict, target: Path) -> str:
    project_slug = slugify(data["project"], "project")
    target_slug = slugify(target.name, "lane")
    if target_slug.startswith(project_slug):
        return target_slug
    return f"{project_slug}-{target_slug}"


def lane_env_path(data: dict, target: Path) -> Path:
    return target / data["localResourceIsolation"]["envFile"]


def lane_env_values(data: dict, target: Path) -> dict[str, str]:
    isolation = data["localResourceIsolation"]
    slot = lane_slot(target)
    stride = int(isolation.get("portStride", 10))
    slug = lane_slug(data, target)
    env: dict[str, str] = {
        "MINERVIT_LANE_RESOURCE_ISOLATION": "1",
        "MINERVIT_LANE_SLOT": str(slot),
        "MINERVIT_LANE_SLUG": slug,
        "COMPOSE_PROJECT_NAME": slugify(f"{isolation['composeProjectPrefix']}-{slug}", "minervit-lane"),
    }
    for item in isolation.get("portVariables", []):
        name = str(item["name"])
        port = int(item["base"]) + (slot * stride)
        if port > 65535:
            raise SystemExit(f"computed port for {name} is outside TCP range: {port}")
        env[name] = str(port)
    for name, template in isolation.get("envTemplates", {}).items():
        try:
            env[str(name)] = str(template).format(**env)
        except KeyError as exc:
            raise SystemExit(f"localResourceIsolation.envTemplates.{name} references unknown env var: {exc}") from exc
    return brand_env_pairs(env)


def render_lane_env(data: dict, target: Path) -> str:
    lines = [
        "# Generated by tautline lane-start.",
        "# Lane-local resource isolation for commands that use local services.",
        "# Source this file or run commands through `tautline lane-run --target . -- ...`.",
    ]
    for key, value in lane_env_values(data, target).items():
        lines.append(f"export {key}={shlex.quote(value)}")
    return "\n".join(lines) + "\n"


def write_lane_env(data: dict, target: Path) -> Path:
    path = lane_env_path(data, target)
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(render_lane_env(data, target), encoding="utf-8")
    return path


def lane_ignore_patterns(data: dict) -> list[str]:
    patterns: list[str] = []
    continuity = data["continuity"]
    lane_state = data["laneState"]
    session_journal = data["sessionJournal"]
    if continuity.get("gitIgnore", False):
        patterns.append(first_dir_pattern(continuity["path"]))
    if lane_state.get("gitIgnore", False):
        patterns.append(first_dir_pattern(lane_state["runsDir"]))
        patterns.append(first_dir_pattern(lane_state["executionPacket"]))
        patterns.append(first_dir_pattern(lane_state["milestoneRun"]))
        patterns.append(first_dir_pattern(lane_state["goalRun"]))
        patterns.append(lane_state["lockPath"])
        patterns.append(data["workProfiles"]["profileLockPath"])
    if session_journal.get("gitIgnoreLocal", True):
        patterns.append(first_dir_pattern(lane_state["runsDir"]))
    graphify = data.get("graphify", DEFAULT_GRAPHIFY)
    if graphify.get("enabled", True) and graphify.get("gitIgnoreOutput", True):
        patterns.append(f"{str(graphify.get('outputDir', DEFAULT_GRAPHIFY['outputDir'])).strip().rstrip('/')}/")
    return [pattern for pattern in dict.fromkeys(patterns) if pattern]


def graphify_ignore_pattern(data: dict) -> str:
    output_dir = str(data.get("graphify", DEFAULT_GRAPHIFY).get("outputDir", DEFAULT_GRAPHIFY["outputDir"])).strip().rstrip("/")
    return f"{output_dir}/"


def ensure_graphify_ignored(data: dict, target: Path) -> list[str]:
    graphify = data.get("graphify", DEFAULT_GRAPHIFY)
    if not graphify.get("enabled", True) or not graphify.get("gitIgnoreOutput", True):
        return []
    pattern = graphify_ignore_pattern(data)
    ensure_git_excludes(target, [pattern], label="Minervit generated Graphify output")
    return ensure_root_gitignore_patterns(target, [pattern], label="Minervit generated Graphify output")


def gitignore_has_pattern(target: Path, pattern: str) -> bool:
    git_dir = run_git(target, ["rev-parse", "--show-toplevel"])
    if not git_dir or git_dir == "unavailable":
        return False
    gitignore = Path(git_dir) / ".gitignore"
    if not gitignore.exists():
        return False
    return pattern in {line.strip() for line in gitignore.read_text(encoding="utf-8").splitlines()}


def graphify_latest_output_mtime(status_paths: list[Path]) -> tuple[float | None, Path | None]:
    latest_mtime: float | None = None
    latest_path: Path | None = None
    seen: set[Path] = set()
    for path in status_paths:
        if not path.exists():
            continue
        candidates = [path]
        if path.is_dir():
            candidates = [candidate for candidate in path.rglob("*") if candidate.is_file()]
        for candidate in candidates:
            if candidate in seen or not candidate.exists() or not candidate.is_file():
                continue
            seen.add(candidate)
            try:
                mtime = candidate.stat().st_mtime
            except OSError:
                continue
            if latest_mtime is None or mtime > latest_mtime:
                latest_mtime = mtime
                latest_path = candidate
    return latest_mtime, latest_path


def graphify_project_files(target: Path, output_dir: str) -> list[Path]:
    git_root_raw = run_git(target, ["rev-parse", "--show-toplevel"])
    if not git_root_raw or git_root_raw == "unavailable":
        return []
    git_root = Path(git_root_raw)
    code, stdout, _stderr = run_command(
        ["git", "-C", str(git_root), "ls-files", "-z", "--cached", "--others", "--exclude-standard"],
        timeout=30,
    )
    if code != 0:
        return []
    output_parts = Path(output_dir.strip().rstrip("/")).parts
    files: list[Path] = []
    for raw in stdout.split("\0"):
        rel = raw.strip()
        if not rel:
            continue
        rel_parts = Path(rel).parts
        if output_parts and rel_parts[: len(output_parts)] == output_parts:
            continue
        path = git_root / rel
        if path.exists() and path.is_file():
            files.append(path)
    return files


def graphify_freshness_record(data: dict, target: Path) -> dict:
    graphify = data.get("graphify", DEFAULT_GRAPHIFY)
    output_dir = str(graphify.get("outputDir", DEFAULT_GRAPHIFY["outputDir"])).strip().rstrip("/")
    output_path = configured_path(target, output_dir)
    report_path = configured_path(target, graphify.get("reportPath", DEFAULT_GRAPHIFY["reportPath"]))
    graph_path = configured_path(target, graphify.get("graphPath", DEFAULT_GRAPHIFY["graphPath"]))
    latest_graph_mtime, latest_graph_path = graphify_latest_output_mtime([output_path, report_path, graph_path])
    output_present = output_path.exists() or report_path.exists() or graph_path.exists()
    enforcement = str(graphify.get("freshnessEnforcement", DEFAULT_GRAPHIFY["freshnessEnforcement"]))
    if not graphify.get("enabled", True):
        return {
            "state": "disabled",
            "enforcement": enforcement,
            "latestGraphMtime": None,
            "latestGraphPath": None,
            "newerFiles": [],
            "issue": "",
        }
    if enforcement == "disabled":
        return {
            "state": "disabled",
            "enforcement": enforcement,
            "latestGraphMtime": latest_graph_mtime,
            "latestGraphPath": latest_graph_path,
            "newerFiles": [],
            "issue": "",
        }
    if not output_present or latest_graph_mtime is None:
        return {
            "state": "missing-output",
            "enforcement": enforcement,
            "latestGraphMtime": None,
            "latestGraphPath": None,
            "newerFiles": [],
            "issue": "",
        }
    newer_files: list[Path] = []
    for path in graphify_project_files(target, output_dir):
        try:
            if path.stat().st_mtime > latest_graph_mtime + 0.001:
                newer_files.append(path)
        except OSError:
            continue
    newer_files = sorted(newer_files, key=lambda item: item.stat().st_mtime if item.exists() else 0, reverse=True)
    if newer_files:
        sample = compact_path_list(newer_files[:5], target)
        return {
            "state": "stale",
            "enforcement": enforcement,
            "latestGraphMtime": latest_graph_mtime,
            "latestGraphPath": latest_graph_path,
            "newerFiles": newer_files,
            "issue": f"stale Graphify output: run `graphify . --update`; newer project files include {sample}",
        }
    return {
        "state": "current",
        "enforcement": enforcement,
        "latestGraphMtime": latest_graph_mtime,
        "latestGraphPath": latest_graph_path,
        "newerFiles": [],
        "issue": "",
    }


def format_mtime(value: float | None) -> str:
    return util_module().format_mtime(value)


def graphify_status_record(data: dict, target: Path) -> dict:
    graphify = data.get("graphify", DEFAULT_GRAPHIFY)
    output_dir = str(graphify.get("outputDir", DEFAULT_GRAPHIFY["outputDir"])).strip().rstrip("/")
    pattern = graphify_ignore_pattern(data)
    cli_path = shutil.which("graphify")
    report_path = configured_path(target, graphify.get("reportPath", DEFAULT_GRAPHIFY["reportPath"]))
    graph_path = configured_path(target, graphify.get("graphPath", DEFAULT_GRAPHIFY["graphPath"]))
    output_path = configured_path(target, output_dir)
    tracked_raw = run_git(target, ["ls-files", "--", output_dir])
    tracked = [] if tracked_raw in {"", "unavailable"} else [line for line in tracked_raw.splitlines() if line.strip()]
    gitignore_ok = gitignore_has_pattern(target, pattern)
    issues: list[str] = []
    if graphify.get("enabled", True):
        if tracked:
            issues.append(f"tracked Graphify output must be removed from git: {', '.join(tracked[:5])}{' ...' if len(tracked) > 5 else ''}")
        if graphify.get("gitIgnoreOutput", True) and not gitignore_ok:
            issues.append(f"{pattern} missing from .gitignore")
    freshness = graphify_freshness_record(data, target)
    if graphify.get("enabled", True) and freshness["state"] == "stale" and freshness["enforcement"] == "strict-if-present":
        issues.append(freshness["issue"])
    return {
        "enabled": bool(graphify.get("enabled", True)),
        "preferWhenPresent": bool(graphify.get("preferWhenPresent", True)),
        "cliPath": cli_path or "",
        "outputDir": output_dir,
        "outputPath": output_path,
        "outputPresent": output_path.exists(),
        "reportPath": report_path,
        "reportPresent": report_path.exists(),
        "graphPath": graph_path,
        "graphPresent": graph_path.exists(),
        "gitignorePattern": pattern,
        "gitignoreOk": gitignore_ok,
        "trackedOutput": tracked,
        "freshness": freshness,
        "issues": issues,
    }


def print_graphify_status(data: dict, target: Path) -> list[str]:
    status = graphify_status_record(data, target)
    print(
        "graphify: "
        f"enabled={str(status['enabled']).lower()} "
        f"cli={'present' if status['cliPath'] else 'missing'} "
        f"output={'present' if status['outputPresent'] else 'missing'} "
        f"report={'present' if status['reportPresent'] else 'missing'} "
        f"graph={'present' if status['graphPresent'] else 'missing'}"
    )
    print(f"graphify_output: {status['outputPath']}")
    print(f"graphify_gitignore: {'ok' if status['gitignoreOk'] else 'missing'} {status['gitignorePattern']}")
    freshness = status["freshness"]
    print(f"graphify_freshness: {freshness['state']} enforcement={freshness['enforcement']}")
    print(f"graphify_freshness_latest: {format_mtime(freshness['latestGraphMtime'])} {freshness['latestGraphPath'] or ''}")
    if freshness["newerFiles"]:
        print(f"graphify_freshness_newer_files: {compact_path_list(freshness['newerFiles'][:10], target)}")
    print(
        "graphify_instruction: "
        "If output exists, it must be current before use/commit/push; use Graphify report/query/path/explain before broad grep to save tokens."
    )
    for issue in status["issues"]:
        print(f"graphify_issue: {issue}")
    if status["enabled"] and freshness["state"] == "stale":
        print("graphify_next: run `graphify . --update` before commit, push, or Graphify-backed decisions")
    elif status["enabled"] and not status["cliPath"]:
        print("graphify_next: install with `tautline graphify-install --target .` when graph navigation is needed")
    elif status["enabled"] and not (status["reportPresent"] or status["graphPresent"]):
        print("graphify_next: build with `graphify .` or update with `graphify . --update` when codebase graph context is needed")
    else:
        print("graphify_next: use Graphify first for architecture/navigation questions")
    return list(status["issues"])


def ensure_lane_state(data: dict, target: Path) -> None:
    lane_state = data["laneState"]
    continuity = data["continuity"]
    (target / lane_state["runsDir"]).mkdir(parents=True, exist_ok=True)
    if data["sessionJournal"].get("enabled", True):
        (target / lane_state["runsDir"] / "session-journals").mkdir(parents=True, exist_ok=True)
    (target / Path(lane_state["executionPacket"]).parent).mkdir(parents=True, exist_ok=True)
    (target / Path(lane_state["milestoneRun"]).parent).mkdir(parents=True, exist_ok=True)
    (target / Path(lane_state["goalRun"]).parent).mkdir(parents=True, exist_ok=True)
    (target / Path(continuity["path"]).parent).mkdir(parents=True, exist_ok=True)
    if continuity.get("gitIgnore", False) or lane_state.get("gitIgnore", False):
        ensure_git_excludes(target, lane_ignore_patterns(data))
    ensure_graphify_ignored(data, target)


def lane_session_path(data: dict, target: Path) -> Path:
    return target / data["laneState"]["runsDir"] / LANE_SESSION_FILE


def record_lane_session_plugin_version(data: dict, target: Path) -> tuple[str, str | None]:
    """Record the framework version/commit at lane start.

    Returns (current_version, drift_from) where drift_from is a previously-recorded
    pluginVersionAtStart that differs from the current version (a mid-session plugin advance),
    else None. Resets the recorded baseline to the current version so the drift is reported once
    per advance (RCA O7).
    """
    current = plugin_version()
    path = lane_session_path(data, target)
    previous = None
    if path.exists():
        try:
            previous = json.loads(path.read_text(encoding="utf-8")).get("pluginVersionAtStart")
        except (OSError, json.JSONDecodeError):
            previous = None
    drift_from = previous if (previous and previous != current) else None
    path.parent.mkdir(parents=True, exist_ok=True)
    pin, pin_source = effective_framework_pin(data, target)
    path.write_text(
        json.dumps(
            {
                "schema": "minervit-lane-session/v2",
                "pluginVersionAtStart": current,
                "methodologyVersionAtStart": methodology_version(),
                "methodologyCommitAtStart": running_methodology_commit(),
                "frameworkPinAtStart": pin,
                "frameworkPinSource": pin_source,
                "recordedAt": now_iso(),
            },
            indent=2,
            sort_keys=True,
        )
        + "\n",
        encoding="utf-8",
    )
    return current, drift_from


def lane_session_plugin_drift(data: dict, target: Path) -> str | None:
    """Read-only: the current plugin version differs from the pluginVersionAtStart recorded
    for this lane session. Surfaces a mid-session contract change to status/review-evidence
    without resetting the baseline."""
    path = lane_session_path(data, target)
    if not path.exists():
        return None
    try:
        recorded = json.loads(path.read_text(encoding="utf-8")).get("pluginVersionAtStart")
    except (OSError, json.JSONDecodeError):
        return None
    current = plugin_version()
    return recorded if (recorded and recorded != current) else None


def work_profile_lock_path(data: dict, target: Path) -> Path:
    return configured_path(target, data["workProfiles"]["profileLockPath"])


def read_work_profile_lock(data: dict, target: Path) -> tuple[str, str, list[str]]:
    cfg = data["workProfiles"]
    if not cfg.get("enabled", True):
        return "development", "disabled", []
    path = work_profile_lock_path(data, target)
    if not path.exists():
        return "development", "default", []
    try:
        payload = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        return "development", f"invalid {path}", [f"work profile lock is unreadable: {path}: {exc}"]
    if not isinstance(payload, dict):
        return "development", f"invalid {path}", [f"work profile lock must be a JSON object: {path}"]
    if payload.get("schema") != WORK_PROFILE_SCHEMA:
        return "development", f"invalid {path}", [f"work profile lock has unsupported schema: {path}"]
    profile = str(payload.get("profile", "")).strip()
    if profile not in WORK_PROFILE_CHOICES:
        return "development", f"invalid {path}", [f"work profile lock names unsupported profile {profile!r}: {path}"]
    return profile, str(path), []


def effective_work_profile(data: dict, target: Path) -> tuple[str, dict, str, list[str]]:
    profile, source, issues = read_work_profile_lock(data, target)
    cfg = data["workProfiles"]
    profile_cfg = cfg["profiles"].get(profile) or cfg["profiles"]["development"]
    return profile, profile_cfg, source, issues


def write_work_profile_lock(data: dict, target: Path, profile: str) -> Path:
    if profile not in WORK_PROFILE_CHOICES:
        raise SystemExit(f"work profile must be one of: {', '.join(sorted(WORK_PROFILE_CHOICES))}")
    cfg = data["workProfiles"]
    if not cfg.get("enabled", True) and profile != "development":
        raise SystemExit("Project adapter workProfiles.enabled=false; only development profile is allowed")
    path = work_profile_lock_path(data, target)
    path.parent.mkdir(parents=True, exist_ok=True)
    record = {
        "schema": WORK_PROFILE_SCHEMA,
        "profile": profile,
        "createdAt": now_iso(),
        "pluginVersion": plugin_version(),
        "methodologyCommit": running_methodology_commit(),
    }
    tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
    tmp.write_text(json.dumps(record, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    tmp.replace(path)
    return path


def work_profile_status_line(data: dict, target: Path) -> str:
    profile, profile_cfg, source, issues = effective_work_profile(data, target)
    details = (
        "development gates required"
        if profile == "development"
        else f"non-code docs/assets only, pushPolicy={profile_cfg['pushPolicy']}"
    )
    suffix = f" issues={len(issues)}" if issues else ""
    return f"work_profile: profile={profile} source={source} {details}{suffix}"


def path_matches_work_profile_pattern(rel_path: str, pattern: str) -> bool:
    return profiles_module().path_matches_work_profile_pattern(rel_path, pattern)


def changed_files_for_work_profile_event(target: Path, event: str, base_ref: str | None = None) -> tuple[list[str] | None, list[str]]:
    if run_git(target, ["rev-parse", "--is-inside-work-tree"]) != "true":
        return None, ["target is not a Git worktree"]
    if event == "pre-commit":
        command = ["git", "-C", str(target), "diff", "--cached", "--name-only", "-z", "--diff-filter=ACMRTD"]
    elif event == "pre-push":
        base_name, base_or_error = implementation_review_base(target, base_ref)
        if base_name is None:
            return None, [base_or_error or "could not determine push diff base"]
        command = ["git", "-C", str(target), "diff", "--name-only", "-z", "--diff-filter=ACMRTD", f"{base_or_error}...HEAD"]
    else:
        command = ["git", "-C", str(target), "diff", "--name-only", "-z", "--diff-filter=ACMRTD"]
    proc = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
    if proc.returncode != 0:
        detail = proc.stderr.decode("utf-8", errors="replace").strip() or "git diff failed"
        return None, [detail]
    names = [
        item.decode("utf-8", errors="replace")
        for item in proc.stdout.split(b"\0")
        if item
    ]
    return sorted(dict.fromkeys(names)), []


def git_blob_sizes(target: Path, object_ids: list[str]) -> dict[str, int]:
    return profiles_module().git_blob_sizes(target, object_ids)


def work_profile_committed_file_metadata(target: Path, event: str, paths: list[str]) -> dict[str, dict]:
    return profiles_module().work_profile_committed_file_metadata(target, event, paths)


def work_profile_file_issues(
    data: dict,
    target: Path,
    profile: str,
    profile_cfg: dict,
    paths: list[str],
    *,
    event: str = "status",
    file_metadata: dict[str, dict] | None = None,
) -> list[str]:
    return profiles_module().work_profile_file_issues(
        data,
        target,
        profile,
        profile_cfg,
        paths,
        event=event,
        file_metadata=file_metadata,
    )


def work_profile_check(args: argparse.Namespace) -> int:
    data, _project_path, target = lane_project(args)
    profile, profile_cfg, _source, lock_issues = effective_work_profile(data, target)
    print(work_profile_status_line(data, target))
    for issue in lock_issues:
        print(f"work_profile_issue: {issue}", file=sys.stderr)
    if profile == "development":
        print("work_profile_gate_decision: development")
        print("work_profile_instruction: all development startup/git/review/test/board gates remain required")
        return 0
    branch = run_git(target, ["branch", "--show-current"]) or "detached HEAD"
    print(f"work_profile_branch: {branch}")
    if args.event == "pre-push" and branch in adapter_base_branch_names(data, target) and profile_cfg.get("pushPolicy") != "direct-main":
        print(
            f"work_profile_error: {profile} cannot push directly from base branch {branch}; "
            "create a feature branch/PR branch, set workProfiles.profiles.<profile>.pushPolicy=direct-main "
            "for an adapter-approved direct-main repo, or switch to development with full gates",
            file=sys.stderr,
        )
        return 1
    base_ref = getattr(args, "base", None)
    if args.event == "pre-push" and not base_ref:
        base_ref = data["workProfiles"].get("prePushBase")
    changed, diff_errors = changed_files_for_work_profile_event(target, args.event, base_ref)
    if changed is None:
        for error in diff_errors:
            print(f"work_profile_error: {error}", file=sys.stderr)
        return 1
    print(f"work_profile_changed_files: {len(changed)}")
    for rel_path in changed[:25]:
        print(f"work_profile_changed_file: {rel_path}")
    if len(changed) > 25:
        print(f"work_profile_changed_file: ... {len(changed) - 25} more")
    if not changed:
        print("work_profile_gate_decision: non_dev_no_diff")
        print("work_profile_instruction: no changed files detected for this git event")
        return 0
    file_metadata = work_profile_committed_file_metadata(target, args.event, changed)
    issues = work_profile_file_issues(
        data,
        target,
        profile,
        profile_cfg,
        changed,
        event=args.event,
        file_metadata=file_metadata,
    )
    if issues:
        for issue in issues[:25]:
            print(f"work_profile_issue: {issue}", file=sys.stderr)
        if len(issues) > 25:
            print(f"work_profile_issue: ... {len(issues) - 25} more", file=sys.stderr)
        print(
            "work_profile_next_action: move docs/assets into allowed paths, remove code/config/generated changes, "
            "or rerun lane-start with --profile development and complete full development gates",
            file=sys.stderr,
        )
        return 1
    print("work_profile_gate_decision: non_dev_docs_only")
    print(
        "work_profile_warning: non-dev profile is only for docs, requirements, wireframes, support notes, "
        "screenshots, and approved assets. Do not push code, config, migrations, workflow files, generated "
        "adapters, secrets, or dependency changes from this profile."
    )
    return 0


def lane_project(args: argparse.Namespace) -> tuple[dict, Path, Path]:
    target = args.target.resolve()
    if getattr(args, "project", None):
        project_path = Path(args.project).resolve()
        require_source_adapter_for_target(project_path, target, "lane command")
    else:
        generated_path = adapter_marker_path(target)
        if not generated_path.exists():
            raise SystemExit(NO_ADAPTER_MESSAGE)
        generated = load_project(generated_path)
        generated_meta = generated.get("_generated")
        if not isinstance(generated_meta, dict) or not generated_meta.get("sourceAdapter"):
            raise SystemExit(
                "Lane adapter is not generated from a methodology source adapter. Bootstrap or render from "
                "<methodology_repo>/adapters/projects/<project>.json instead of hand-writing .minervit-ai-delivery.json."
            )
        source_adapter_value = str(generated_meta["sourceAdapter"]).replace("\\", "/")
        source_path = source_adapter_path(source_adapter_value)
        if not Path(source_adapter_value).is_absolute():
            target_candidate = target / source_adapter_value
            if target_candidate.exists():
                source_path = target_candidate
            elif source_adapter_value in (REPO_LOCAL_ADAPTER_FILE, LEGACY_REPO_LOCAL_ADAPTER_FILE) and not source_path.exists():
                # The marker may record the other marker-era name (.tautline/ vs .minervit/);
                # resolve to whichever repo-local adapter actually exists in the target.
                fallback = repo_local_source_adapter_path(target)
                if fallback.exists():
                    source_path = fallback
        if not source_path.exists():
            raise SystemExit(
                f"Generated lane adapter sourceAdapter is missing: {source_path}. The per-product source adapter is "
                "not present in the methodology checkout (it may have been removed/relocated). To recover: restore it "
                f"under <methodology_repo>/adapters/projects/<project>.json, place it at {REPO_LOCAL_ADAPTER_FILE} "
                "inside the target repo, OR place it in a private adapters directory and set "
                "MINERVIT_METHODOLOGY_ADAPTER_ROOT to that directory; then re-render this lane "
                "(`tautline render-adapters --project <adapter> --target . --write`). Do not hand-edit "
                ".minervit-ai-delivery.json."
            )
        if not source_adapter_allowed_for_target(source_path, target):
            raise SystemExit(
                "Generated lane adapter sourceAdapter does not point at a trusted source adapter. Re-render this lane "
                f"from <methodology_repo>/adapters/projects/*.json, MINERVIT_METHODOLOGY_ADAPTER_ROOT, or "
                f"{REPO_LOCAL_ADAPTER_FILE} inside the target repo."
            )
        expected_source_sha = generated_meta.get("sourceAdapterSha256")
        if not isinstance(expected_source_sha, str) or not re.fullmatch(r"[0-9a-f]{64}", expected_source_sha):
            raise SystemExit(
                "Generated lane adapter is missing _generated.sourceAdapterSha256. Re-render this lane from the "
                "canonical source adapter before startup."
            )
        actual_source_sha = file_sha256(source_path)
        if expected_source_sha != actual_source_sha:
            rescue_findings = methodology_rescue_ref_adapter_changes()
            rescue_hint = ""
            if rescue_findings:
                refs = "; ".join(f"{ref} ({', '.join(paths)})" for ref, paths in rescue_findings)
                rescue_hint = (
                    " A canonical adapter change is sitting on a local rescue ref and is not on the base branch: "
                    f"{refs}. Re-land those commits (cherry-pick/PR) before re-rendering, or the re-render will "
                    "reproduce the pre-rescue adapter."
                )
            raise SystemExit(
                "Generated lane adapter sourceAdapterSha256 does not match the methodology source adapter. "
                "Diagnose before re-rendering: (1) diff the canonical source adapter against the lane's last "
                "committed adapter; (2) check whether a canonical adapter fix was moved to a "
                "`minervit-local-rescue/*` ref by auto-sync; (3) only then re-render this lane from the "
                f"canonical source adapter.{rescue_hint}"
            )
        project_path = source_path
    if not project_path.exists():
        raise SystemExit(NO_ADAPTER_MESSAGE)
    data = load_project(project_path)
    validate_adapter_repo_matches_target(data, target, require_target_identity=not getattr(args, "project", None))
    return data, project_path, target


def lock_path(data: dict, target: Path) -> Path:
    return target / data["laneState"]["lockPath"]


def read_lock(data: dict, target: Path) -> dict | None:
    path = lock_path(data, target)
    if not path.exists():
        return None
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except json.JSONDecodeError:
        return {"schema": "minervit-methodology-lock/v1", "invalid": True, "path": str(path)}


def lock_archive_path(data: dict, target: Path, suffix: str = "lock") -> Path:
    archive_dir = target / data["laneState"]["runsDir"] / "methodology-locks"
    archive_dir.mkdir(parents=True, exist_ok=True)
    stamp = time.strftime("%Y%m%d-%H%M%S")
    archived = archive_dir / f"{stamp}-{suffix}.json"
    counter = 1
    while archived.exists():
        archived = archive_dir / f"{stamp}-{suffix}-{counter}.json"
        counter += 1
    return archived


def archive_lock_file(data: dict, target: Path, suffix: str = "lock") -> Path | None:
    path = lock_path(data, target)
    if not path.exists():
        return None
    archived = lock_archive_path(data, target, suffix)
    path.replace(archived)
    return archived


def adapter_drift(data: dict, project_path: Path, target: Path) -> list[str]:
    project_arg, source_path_override = expected_files_render_context(data, project_path, target)
    expected = expected_files(data, project_arg, source_path_override, target)
    drift: list[str] = []
    for rel, content in expected.items():
        dest = target / rel
        try:
            current = dest.read_text(encoding="utf-8") if dest.exists() else None
        except OSError as exc:
            drift.append(f"{rel} (unreadable: {exc})")
            continue
        if rel in GENERATED_MARKDOWN_FILES and current is not None and not is_methodology_generated_markdown(dest):
            continue
        if rel == LANE_ADAPTER_FILE and current is not None:
            # Provenance-stamp equivalence: identity stamps are not content, so a wheel-stamped
            # and a checkout-stamped adapter with identical content are both clean.
            if current != adapter_stamp_equivalent_content(content, current):
                on_disk_stamps = adapter_provenance_stamps(current)
                if (
                    on_disk_stamps is not None
                    and on_disk_stamps != adapter_provenance_stamps(content)
                ):
                    # True drift ACROSS builds: re-rendering here just flips the file to this
                    # runtime's render — the designed resolution is aligning the runtimes.
                    drift.append(f"{rel} ({ADAPTER_TRUE_DRIFT_ALIGNMENT_HINT})")
                else:
                    drift.append(rel)
            continue
        if current != content:
            drift.append(rel)
    return drift


def protected_adapter_markdown(data: dict, project_path: Path, target: Path) -> list[str]:
    project_arg, source_path_override = expected_files_render_context(data, project_path, target)
    expected = expected_files(data, project_arg, source_path_override, target)
    return [str(path) for path in protected_handwritten_markdown(expected, target)]


def configured_path(target: Path, path_text: str, *, allow_outside: bool = False) -> Path:
    return paths_module().configured_path(target, path_text, allow_outside=allow_outside)


def cli_path(target: Path, path_text: str) -> Path:
    # Non-contained sibling of configured_path for operator CLI args and framework-internal computed
    # paths (sec-config-path-1 containment applies to adapter-config values only). See paths.cli_path.
    return paths_module().cli_path(target, path_text)


def planning_path_issues(data: dict, target: Path) -> list[str]:
    planning = data["planningArtifacts"]
    checks = [
        ("source-of-truth path missing", configured_path(target, planning["sourceOfTruth"])),
        ("template path missing", configured_path(target, planning["template"])),
    ]
    return [f"{message}: {path}" for message, path in checks if not path.exists()]


def scratch_plan_candidates(data: dict, target: Path) -> list[Path]:
    candidates: list[Path] = []
    for path_text in data["planningArtifacts"].get("scratchPaths", []):
        # scratchPaths is an allowlisted external field (framework default is ~/.claude/plans/).
        path = configured_path(target, path_text, allow_outside=True)
        try:
            if path.is_file():
                candidates.append(path)
            elif path.is_dir():
                candidates.extend(sorted(p for p in path.glob("*.md") if p.is_file()))
        except OSError:
            continue
    return candidates


def configured_scratch_roots(data: dict, target: Path) -> list[Path]:
    return [
        configured_path(target, path_text, allow_outside=True).resolve(strict=False)
        for path_text in data["planningArtifacts"].get("scratchPaths", [])
        if str(path_text).strip()
    ]


def path_is_configured_scratch_plan(data: dict, target: Path, path: Path) -> bool:
    plan_path = path.resolve(strict=False)
    return any(plan_path == root or path_is_under(plan_path, root) for root in configured_scratch_roots(data, target))


def relevant_scratch_plan_candidates(data: dict, candidates: list[Path]) -> list[Path]:
    planning = data["planningArtifacts"]
    source = str(planning.get("sourceOfTruth", "")).strip().rstrip("/")
    source_parent = str(Path(source).parent) if source else ""
    if source_parent == ".":
        source_parent = ""
    markers = [
        source.lower(),
        source_parent.lower(),
        str(planning.get("template", "")).lower(),
        str(data.get("repo", "")).lower(),
    ]
    markers = [marker for marker in markers if marker]
    relevant: list[Path] = []
    for path in candidates:
        try:
            sample = path.read_text(encoding="utf-8", errors="replace")[:32768].lower()
        except OSError:
            sample = ""
        haystack = f"{path.name.lower()}\n{sample}"
        if any(marker in haystack for marker in markers):
            relevant.append(path)
    return relevant


def scratch_status_line(label: str, candidates: list[Path]) -> str:
    if not candidates:
        return f"{label}: none"
    preview = ", ".join(str(path) for path in candidates[:5])
    suffix = "" if len(candidates) <= 5 else f", ... +{len(candidates) - 5} more"
    return f"{label}: {len(candidates)} candidate(s) - {preview}{suffix}"


def path_display(target: Path, path: Path) -> str:
    return util_module().path_display(target, path)


def file_sha256(path: Path) -> str:
    return util_module().file_sha256(path)


def replace_markdown_section(text: str, heading: str, replacement_body: str) -> str:
    section = f"{heading}\n\n{replacement_body.strip()}\n"
    pattern = re.compile(rf"^{re.escape(heading)}\s*$", re.MULTILINE)
    match = pattern.search(text)
    if not match:
        return text.rstrip() + "\n\n" + section
    next_match = re.search(r"^##\s+", text[match.end() :], re.MULTILINE)
    end = match.end() + next_match.start() if next_match else len(text)
    return text[: match.start()] + section.rstrip() + "\n\n" + text[end:].lstrip()


def strip_plan_review_evidence(text: str) -> str:
    pattern = re.compile(rf"^{re.escape(PLAN_REVIEW_EVIDENCE_HEADING)}\s*$", re.MULTILINE)
    match = pattern.search(text)
    if match:
        remainder = text[match.end() :]
        # The end marker bounds the section exactly; plans whose body after the
        # title is not `## `-headed would otherwise lose their preamble here.
        marker_match = re.search(rf"^{re.escape(PLAN_REVIEW_EVIDENCE_END_MARKER)}\s*$", remainder, re.MULTILINE)
        if marker_match:
            end = match.end() + marker_match.end()
        else:
            next_match = re.search(r"^##\s+", remainder, re.MULTILINE)
            end = match.end() + next_match.start() if next_match else len(text)
        head = text[: match.start()].rstrip("\n")
        tail = text[end:].lstrip("\n")
        if head and tail:
            text = head + "\n\n" + tail
        else:
            text = head or tail
    return text.strip() + "\n"


def upsert_plan_review_evidence(text: str, replacement_body: str) -> str:
    base = strip_plan_review_evidence(text)
    section = f"{PLAN_REVIEW_EVIDENCE_HEADING}\n\n{replacement_body.strip()}\n\n{PLAN_REVIEW_EVIDENCE_END_MARKER}\n"
    lines = base.splitlines()
    insert_at = 0
    if lines and lines[0].startswith("# "):
        insert_at = 1
    before = "\n".join(lines[:insert_at]).strip()
    after = "\n".join(lines[insert_at:]).strip()
    if before and after:
        return before + "\n\n" + section + "\n" + after + "\n"
    if before:
        return before + "\n\n" + section
    if after:
        return section + "\n" + after + "\n"
    return section


def terminate_process_group(proc: subprocess.Popen, log_handle, reason: str) -> None:
    log_handle.write(f"\n[minervit] {reason}; terminating process group {proc.pid}\n")
    log_handle.flush()
    if hasattr(os, "killpg"):
        try:
            os.killpg(proc.pid, signal.SIGTERM)
        except ProcessLookupError:
            return
        except OSError as exc:
            log_handle.write(f"[minervit] SIGTERM failed: {exc}\n")
            log_handle.flush()
        time.sleep(3)
        if proc.poll() is None:
            try:
                os.killpg(proc.pid, signal.SIGKILL)
                log_handle.write(f"[minervit] SIGKILL sent to process group {proc.pid}\n")
                log_handle.flush()
            except ProcessLookupError:
                pass
            except OSError as exc:
                log_handle.write(f"[minervit] SIGKILL failed: {exc}\n")
                log_handle.flush()
    else:
        proc.terminate()
        try:
            proc.wait(timeout=3)
        except subprocess.TimeoutExpired:
            proc.kill()


def run_process_with_no_output_watchdog(
    command: list[str],
    *,
    cwd: Path,
    env: dict[str, str],
    log_handle,
    log_path: Path,
    no_output_timeout_seconds: int,
) -> tuple[int, Path | None]:
    if no_output_timeout_seconds < 0:
        raise SystemExit("--no-output-timeout-seconds must be zero or positive")
    try:
        proc = subprocess.Popen(
            command,
            cwd=str(cwd),
            env=env,
            text=True,
            stdout=log_handle,
            stderr=subprocess.STDOUT,
            start_new_session=True,
        )
    except OSError as exc:
        log_handle.write(f"\nplan_review_run_error: {exc}\n")
        return 127, None
    last_size = log_path.stat().st_size if log_path.exists() else 0
    last_growth = time.monotonic()
    poll_seconds = 1.0 if no_output_timeout_seconds else 5.0
    stale_marker: Path | None = None
    while True:
        exit_code = proc.poll()
        if exit_code is not None:
            return exit_code, stale_marker
        time.sleep(poll_seconds)
        try:
            current_size = log_path.stat().st_size
        except OSError:
            current_size = last_size
        if current_size > last_size:
            last_size = current_size
            last_growth = time.monotonic()
            continue
        if no_output_timeout_seconds and time.monotonic() - last_growth >= no_output_timeout_seconds:
            reason = f"plan review produced no new output for {no_output_timeout_seconds}s"
            stale_marker = log_path.with_name(f"{log_path.name}.stale.json")
            stale_marker.write_text(
                json.dumps(
                    {
                        "schema": "minervit-plan-review-stale/v1",
                        "reason": "stale_no_output",
                        "noOutputTimeoutSeconds": no_output_timeout_seconds,
                        "log": str(log_path),
                        "detectedAt": now_iso(),
                        "pid": proc.pid,
                    },
                    indent=2,
                    sort_keys=True,
                )
                + "\n",
                encoding="utf-8",
            )
            terminate_process_group(proc, log_handle, reason)
            try:
                proc.wait(timeout=5)
            except subprocess.TimeoutExpired:
                pass
            return 124, stale_marker


def plan_review_text(path: Path) -> str:
    text = path.read_text(encoding="utf-8")
    return strip_plan_review_evidence(text)


def plan_content_sha256(path: Path) -> str:
    return hashlib.sha256(plan_review_text(path).encode("utf-8")).hexdigest()


def plan_content_sha256_from_text(text: str) -> str:
    return hashlib.sha256(strip_plan_review_evidence(text).encode("utf-8")).hexdigest()


def markdown_fence_for_embedded_text(text: str) -> str:
    longest = max((len(match.group(0)) for match in re.finditer(r"`+", text)), default=0)
    return "`" * max(3, longest + 1)


def planning_source_root(data: dict, target: Path) -> Path:
    return configured_path(target, data["planningArtifacts"]["sourceOfTruth"])


def plan_review_manifest_path(data: dict, target: Path, plan_path: Path) -> Path:
    slug = slugify(plan_path.stem, "plan")
    return planning_source_root(data, target) / ".plan-reviews" / f"{slug}.json"


def plan_review_import_manifest_path(data: dict, target: Path, plan_path: Path) -> Path:
    slug = slugify(plan_path.stem, "plan")
    return planning_source_root(data, target) / ".plan-reviews" / f"{slug}.imported.json"


def path_relative_to_target(target: Path, path: Path) -> str:
    return util_module().path_relative_to_target(target, path)


def resolve_plan_path(target: Path, plan: Path) -> Path:
    plan = plan.expanduser()
    return plan if plan.is_absolute() else target / plan


def validate_plan_substance(path: Path) -> list[str]:
    errors: list[str] = []
    text = plan_review_text(path)
    lower = text.lower()
    if len(text.strip()) < 800:
        errors.append("plan is too short to be decision-complete")
    missing = [
        label
        for label, patterns in PLAN_SUBSTANTIVE_REQUIREMENTS.items()
        if not any(re.search(pattern, lower, re.MULTILINE) for pattern in patterns)
    ]
    if missing:
        errors.append("plan missing substantive sections: " + ", ".join(missing))
    stub_lines = [
        line.strip()
        for line in text.splitlines()
        if re.search(r"\b(?:todo|tbd|placeholder|fill this in|stub)\b", line, re.IGNORECASE)
    ]
    if stub_lines:
        errors.append("plan contains stub/TODO markers")
    return errors


# What counts as an acceptance criterion NAMING an executable test/scenario id. canonical-rules.md
# already declares "missing named test ... is a stub"; rec #13 gives it teeth at plan finalization.
# A tuple (not a list) so it is excluded from the policy-phrase SSOT export -- these are regex
# patterns, not human phrase vocabulary.
PLAN_AC_TEST_ID_PATTERNS = (
    r"\btest_[A-Za-z0-9_]+",                       # pytest / go / rust test_*
    r"\b[A-Za-z0-9_]+_test\b",                     # *_test
    r"[\w./-]+\.(?:spec|test)\.[A-Za-z0-9]+\b",    # foo.spec.ts / bar.test.js
    r"[\w./-]+\.feature\b",                        # gherkin .feature file
    r"\bScenario\s*:",                             # Scenario: <name>
    r"\bscenario\s+[\"'][^\"']+[\"']",             # scenario "name"
    r"\bit\(\s*[\"']",                             # it("...")
    r"\b(?:tests?|specs?|scenarios?|verified by|covered by)\s*:\s*\S+",  # explicit annotation w/ content
)


def normalize_plan_acceptance(data: dict) -> dict:
    raw = data.get("planAcceptance")
    if raw is not None and not isinstance(raw, dict):
        raise SystemExit("Project adapter planAcceptance must be an object")
    cfg = {**DEFAULT_PLAN_ACCEPTANCE, **(raw or {})}
    if cfg["enforcement"] not in {"off", "warn", "block"}:
        raise SystemExit("Project adapter planAcceptance.enforcement must be off, warn, or block")
    headings = cfg.get("acHeadings")
    if not isinstance(headings, list) or not all(isinstance(h, str) for h in headings):
        raise SystemExit("Project adapter planAcceptance.acHeadings must be a list of strings")
    cfg["acHeadings"] = [h.strip().lower() for h in headings if h.strip()] or list(DEFAULT_PLAN_ACCEPTANCE["acHeadings"])
    return cfg


def _extract_ac_section(plan_text: str, ac_headings: list[str]) -> str:
    """Return the text under the first matching acceptance-criteria heading, up to the next heading."""
    lines = plan_text.splitlines()
    start = None
    for i, line in enumerate(lines):
        m = re.match(r"^(#{1,6})\s+(.*)$", line)
        if m and m.group(2).strip().lower().rstrip(":") in ac_headings:
            start = i + 1
            break
    if start is None:
        return ""
    body: list[str] = []
    for line in lines[start:]:
        if re.match(r"^#{1,6}\s+", line):
            break
        body.append(line)
    return "\n".join(body)


def _split_ac_items(section_text: str) -> list[str]:
    """Group each top-level list bullet (and its nested/continuation lines) into one AC block."""
    items: list[str] = []
    current: list[str] | None = None
    for line in section_text.splitlines():
        if re.match(r"^\s{0,3}(?:[-*+]|\d+[.)])\s+", line):
            if current is not None:
                items.append("\n".join(current))
            current = [line]
        elif current is not None and (line.strip() == "" or line.startswith((" ", "\t"))):
            current.append(line)
        elif current is not None:
            items.append("\n".join(current))
            current = None
    if current is not None:
        items.append("\n".join(current))
    return [item for item in items if item.strip()]


def plan_acceptance_binding_issues(plan_text: str, cfg: dict, pending_tags: list[str]) -> list[str]:
    """Every acceptance criterion must name an executable, non-@pending test/scenario id (rec #13).
    Definition-of-Ready with teeth: stops an untestable AC, or one parked behind @pending, being baked
    in at plan finalization (FM1/FM2)."""
    headings = [str(h).strip().lower() for h in cfg.get("acHeadings", DEFAULT_PLAN_ACCEPTANCE["acHeadings"]) if str(h).strip()]
    section = _extract_ac_section(plan_text, headings or DEFAULT_PLAN_ACCEPTANCE["acHeadings"])
    if not section.strip():
        return []
    tags = [str(t).strip() for t in pending_tags if str(t).strip()] or list(DEFAULT_BEHAVIOR_PENDING_TAGS)
    items = _split_ac_items(section)
    if not items:
        # A non-empty AC section with no parseable list bullets (prose paragraphs or a table) must not
        # silently pass -- that would let an author dodge the binding by avoiding a checklist.
        return [
            "acceptance-criteria section has no parseable list criteria: write each criterion as a "
            "checklist bullet that names an executable test/scenario id (test_*/*.feature/Scenario:/Test:)"
        ]
    issues: list[str] = []
    for item in items:
        summary = " ".join(item.split())[:80]
        low = item.lower()
        if any(tag.lower() in low for tag in tags):
            issues.append(
                f"acceptance criterion is bound to an inactive ({'/'.join(tags)}) scenario -- a "
                f"Definition-of-Ready AC needs live coverage: {summary}"
            )
            continue
        if not any(re.search(pat, item, re.IGNORECASE) for pat in PLAN_AC_TEST_ID_PATTERNS):
            issues.append(
                "acceptance criterion names no executable test/scenario id (name a test_*/*.feature/"
                f"Scenario:/Test: id): {summary}"
            )
    return issues


def _plan_pending_tags(data: dict) -> list[str]:
    behavior = data.get("behaviorSpecs", {}) if isinstance(data.get("behaviorSpecs"), dict) else {}
    return [str(t).strip() for t in behavior.get("pendingTags", DEFAULT_BEHAVIOR_PENDING_TAGS) if str(t).strip()]


def finding_counts(findings: list[dict]) -> tuple[int, int]:
    critical = 0
    p1 = 0
    for item in findings:
        severity = str(item.get("severity", item.get("priority", ""))).lower()
        status = str(item.get("status", "")).lower()
        if status in plan_review_resolved_statuses():
            continue
        if severity in {"critical", "c1", "p0"}:
            critical += 1
        elif severity in {"p1", "important", "high"}:
            p1 += 1
    return critical, p1


def plan_review_resolved_statuses() -> set[str]:
    return {"fixed", "resolved", "addressed", "deferred", "closed", "false-positive", "false positive", "not-applicable", "not applicable"}


def classified_findings_have_resolved_blocker_evidence(findings: list[dict]) -> bool:
    for item in findings:
        severity = str(item.get("severity", item.get("priority", ""))).lower()
        status = str(item.get("status", "")).lower()
        if severity in {"critical", "c1", "p0", "p1", "important", "high"} and status in plan_review_resolved_statuses():
            return True
    return False


def plan_review_round_number(review_round: str) -> int | None:
    match = re.search(r"\d+", str(review_round))
    if not match:
        return None
    try:
        return int(match.group(0))
    except ValueError:
        return None


def plan_review_exception_note(*candidates: str | None) -> str:
    """First candidate substantial enough to be a RECORDED exception, else ''.

    A rubber stamp is not an exception: the note must actually name why the extra round is needed.
    --structural-critical-evidence is accepted as one such reason among others.
    """
    for candidate in candidates:
        text = str(candidate or "").strip()
        if len(text) >= PLAN_REVIEW_EXCEPTION_NOTE_MIN_CHARS:
            return text
    return ""


def plan_review_hard_cap_refusal(round_number: int, *, unresolved_blockers: bool) -> str:
    """Past the hard cap, refusal is unconditional; only the message varies with state."""
    cap = PLAN_REVIEW_HARD_CAP_ROUNDS
    lead = (
        f"plan review round {round_number} exceeds the hard cap of {cap} rounds; the cap is "
        "absolute and no exception note, structural Critical, or operator authorization can "
        "raise it"
    )
    if unresolved_blockers:
        return (
            f"{lead}; the split is mandatory: decompose this plan into smaller source-of-truth "
            "plans, review each of those, and continue; do not ask the operator to choose a path"
        )
    return (
        f"{lead}; no unresolved blockers remain, so finalize the existing review evidence with "
        "finalize-plan-review and plan-finalization-precheck instead of launching another round; "
        "do not ask the operator to choose a path"
    )


def plan_review_round_cap_errors(
    *,
    round_number: int | None,
    allow_r3_structural_critical: bool = False,
    structural_critical_evidence: str | None = None,
    exception_reason: str = "",
    unresolved_blockers: bool = True,
) -> list[str]:
    del allow_r3_structural_critical  # subsumed by the general rounds 3-4 exception
    target = PLAN_REVIEW_TARGET_ROUNDS
    cap = PLAN_REVIEW_HARD_CAP_ROUNDS
    if round_number is None or round_number < 1:
        return [f"--round must include a numeric review round 1-{cap}, such as R1 or R2"]
    if round_number <= target:
        return []
    if round_number > cap:
        return [plan_review_hard_cap_refusal(round_number, unresolved_blockers=unresolved_blockers)]
    if plan_review_exception_note(exception_reason, structural_critical_evidence):
        return []
    return [
        f"plan review round {round_number} is past the {target}-round convergence target, so it "
        "is self-authorized only with a recorded exception: pass --exception-note '<reason>' "
        f"(at least {PLAN_REVIEW_EXCEPTION_NOTE_MIN_CHARS} characters) naming why this round is "
        f"needed. Rounds {target + 1}-{cap} never require operator authorization; take the round "
        "and record the reason, or transfer unresolved findings into the implementation review "
        "focus list"
    ]


def plan_review_blocker_total(critical_count: int, p1_count: int) -> int:
    return max(0, int(critical_count)) + max(0, int(p1_count))


def previous_plan_review_blocker_total(manifest_path: Path) -> int | None:
    if not manifest_path.exists() or not manifest_path.is_file():
        return None
    try:
        manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
        return plan_review_blocker_total(
            int(manifest.get("unresolved_critical_count", 0)),
            int(manifest.get("unresolved_p1_count", 0)),
        )
    except (OSError, json.JSONDecodeError, TypeError, ValueError):
        return None


def find_adapter_root(cwd: Path) -> Path | None:
    for candidate in [cwd, *cwd.parents]:
        if (candidate / LANE_ADAPTER_FILE).exists():
            return candidate
        if (candidate / LEGACY_LANE_ADAPTER_FILE).exists():
            return candidate
    return None


def adapter_marker_path(root: Path) -> Path:
    canonical = root / LANE_ADAPTER_FILE
    if canonical.exists():
        return canonical
    legacy = root / LEGACY_LANE_ADAPTER_FILE
    if legacy.exists():
        return legacy
    return canonical  # default write target


def codex_fast_mode_enabled(data: dict) -> bool:
    return bool(data.get("review", {}).get("codexFastMode", DEFAULT_REVIEW["codexFastMode"]))


def path_without_minervit_codex_shims(path_value: str) -> str:
    parts = [
        part
        for part in path_value.split(os.pathsep)
        if part and Path(part).name != "codex-fast-mode-bin"
    ]
    return os.pathsep.join(parts)


def resolve_real_codex(env: dict[str, str]) -> str | None:
    # Through the resolver, not straight off the mapping: `env` is dict(base_env or os.environ) --
    # the operator's environment by another name -- so a direct read left TAUTLINE_REAL_CODEX, the
    # rebranded spelling, silently dead. Setting it produced no shim and no error; codex was
    # resolved off PATH as if nothing had been configured.
    configured = util_module().resolve_env("MINERVIT_REAL_CODEX", environ=env).strip()
    if configured:
        return configured
    path_value = path_without_minervit_codex_shims(env.get("PATH", os.defpath))
    return shutil.which("codex", path=path_value)


def codex_fast_mode_shim_content(real_codex: str) -> str:
    return (
        "#!/usr/bin/env bash\n"
        "# Generated by minervit-methodology. Forces Codex CLI fast mode for adapter-launched commands.\n"
        "set -euo pipefail\n"
        f"REAL_CODEX={shlex.quote(real_codex)}\n"
        "case \"${MINERVIT_CODEX_FAST_MODE:-1}\" in\n"
        "  0|false|False|FALSE|off|OFF|no|NO)\n"
        "    exec \"$REAL_CODEX\" \"$@\"\n"
        "    ;;\n"
        "esac\n"
        "exec \"$REAL_CODEX\" --enable fast_mode \"$@\"\n"
    )


def codex_fast_mode_env(data: dict, target: Path, base_env: dict[str, str] | None = None) -> dict[str, str]:
    env = dict(base_env or os.environ)
    enabled = codex_fast_mode_enabled(data)
    env["MINERVIT_CODEX_FAST_MODE"] = "1" if enabled else "0"
    if not enabled:
        return brand_env_pairs(env)
    real_codex = resolve_real_codex(env)
    if not real_codex:
        return brand_env_pairs(env)
    shim_dir = configured_path(target, data["laneState"]["runsDir"]) / "codex-fast-mode-bin"
    shim_dir.mkdir(parents=True, exist_ok=True)
    shim_path = shim_dir / "codex"
    content = codex_fast_mode_shim_content(real_codex)
    if not shim_path.exists() or shim_path.read_text(encoding="utf-8", errors="replace") != content:
        write_text_executable(shim_path, content)
    env["MINERVIT_REAL_CODEX"] = real_codex
    env["MINERVIT_CODEX_FAST_MODE_SHIM"] = str(shim_path)
    path_parts = [str(shim_dir)]
    existing_path = env.get("PATH", os.defpath)
    path_parts.extend(part for part in existing_path.split(os.pathsep) if part and part != str(shim_dir))
    env["PATH"] = os.pathsep.join(path_parts)
    return brand_env_pairs(env)


def codex_fast_mode_status(data: dict) -> str:
    return "enabled" if codex_fast_mode_enabled(data) else "disabled"


def find_plan_by_content_hash(data: dict, target: Path, content_hash: str) -> Path | None:
    root = planning_source_root(data, target)
    matches: list[Path] = []
    if not root.exists():
        return None
    for path in sorted(root.rglob("*.md")):
        if ".plan-reviews" in path.parts:
            continue
        try:
            if plan_content_sha256(path) == content_hash:
                matches.append(path)
        except OSError:
            continue
    return matches[0] if len(matches) == 1 else None


def find_source_plan_by_name(data: dict, target: Path, plan_name: str) -> Path | None:
    root = planning_source_root(data, target)
    matches: list[Path] = []
    if not root.exists():
        return None
    for path in sorted(root.rglob(plan_name)):
        if path.is_file() and ".plan-reviews" not in path.parts and source_plan_is_active_candidate(target, path):
            matches.append(path)
    return matches[0] if len(matches) == 1 else None


def source_plan_is_active_candidate(target: Path, path: Path) -> bool:
    try:
        rel = path.resolve(strict=False).relative_to(target.resolve(strict=False))
    except ValueError:
        return False
    inactive_parts = {"archive", "archives", "archived", "superseded", "obsolete", "old"}
    return not any(part.lower() in inactive_parts for part in rel.parts)


def text_references_path(text: str, reference: str) -> bool:
    reference = reference.strip().lower()
    if not reference or "/" not in reference:
        return False
    escaped = re.escape(reference)
    return bool(re.search(rf"(?<![A-Za-z0-9_./-]){escaped}(?![A-Za-z0-9_/-])", text.lower()))


def find_source_plan_by_reference(data: dict, target: Path, text: str) -> Path | None:
    root = planning_source_root(data, target)
    if not root.exists() or not text.strip():
        return None
    haystack = text.lower()
    matches: list[Path] = []
    for path in sorted(root.rglob("*.md")):
        if not path.is_file() or ".plan-reviews" in path.parts:
            continue
        if not source_plan_is_active_candidate(target, path):
            continue
        try:
            rel = path_relative_to_target(target, path)
        except ValueError:
            continue
        references = {str(path), rel, path.as_posix()}
        if any(text_references_path(haystack, reference) for reference in references):
            matches.append(path)
    return matches[0] if len(matches) == 1 else None


def resolve_exit_plan_mode_plan(
    data: dict,
    target: Path,
    plan_file: str | None,
    plan_text: object,
    extra_reference_text: str = "",
) -> tuple[Path | None, str | None]:
    if plan_file:
        raw_plan = Path(plan_file)
        plan_path = resolve_plan_path(target, raw_plan).resolve(strict=False)
        if path_is_under(plan_path, planning_source_root(data, target)):
            return plan_path, None
        reference_texts: list[str] = []
        if isinstance(plan_text, str) and plan_text.strip():
            reference_texts.append(plan_text)
            matched_plan = find_plan_by_content_hash(data, target, plan_content_sha256_from_text(plan_text))
            if matched_plan is not None:
                return matched_plan, (
                    f"ExitPlanMode provided scratch path {plan_path}; "
                    f"using matching source-of-truth plan {matched_plan}."
                )
        scratch_readable = False
        try:
            scratch_readable = (
                plan_path.suffix.lower() == ".md"
                and plan_path.exists()
                and plan_path.is_file()
                and plan_path.stat().st_size <= 1_000_000
            )
        except OSError:
            scratch_readable = False
        if scratch_readable:
            try:
                scratch_text = plan_path.read_text(encoding="utf-8", errors="replace")
                if scratch_text.strip():
                    matched_plan = find_plan_by_content_hash(data, target, plan_content_sha256_from_text(scratch_text))
                    if matched_plan is not None:
                        return matched_plan, (
                            f"ExitPlanMode provided scratch path {plan_path}; "
                            f"using matching source-of-truth plan {matched_plan}."
                        )
            except OSError:
                pass
        # A plan under the adapter's configured scratchPaths is scratch by construction: it must
        # reach the caller's plan-mode escape, never be hijacked to an unrelated repo plan just
        # because the recent transcript happened to name exactly one plan path. Content-hash
        # matching stays ahead of this so a scratch COPY of a real repo plan still binds to it.
        if path_is_configured_scratch_plan(data, target, plan_path):
            return plan_path, None
        if extra_reference_text.strip():
            reference_texts.append(extra_reference_text)
        referenced_plan = find_source_plan_by_reference(data, target, "\n".join(reference_texts))
        if referenced_plan is not None:
            return referenced_plan, (
                f"ExitPlanMode provided scratch path {plan_path}; "
                f"using referenced source-of-truth plan {referenced_plan}."
            )
        matched_plan = find_source_plan_by_name(data, target, raw_plan.name)
        if matched_plan is not None:
            return matched_plan, (
                f"ExitPlanMode provided scratch path {plan_path}; "
                f"using source-of-truth plan with the same filename {matched_plan}."
            )
        return plan_path, None

    if not isinstance(plan_text, str) or not plan_text.strip():
        return None, None
    matched_plan = find_plan_by_content_hash(data, target, plan_content_sha256_from_text(plan_text))
    if matched_plan is None and extra_reference_text.strip():
        matched_plan = find_source_plan_by_reference(data, target, plan_text + "\n" + extra_reference_text)
        if matched_plan is not None:
            return matched_plan, f"ExitPlanMode provided plan text; using referenced source-of-truth plan {matched_plan}."
    return matched_plan, None


def derived_document_context(data: dict) -> dict:
    explicit = data.get("documentContext", {})
    planning_source = str(data["planningArtifacts"]["sourceOfTruth"]).rstrip("/")
    source_path = Path(planning_source)
    default_index = str(source_path / "_index.md")
    default_historical = str(source_path / "archive")
    ignored = list(COMMON_IGNORED_DOC_PATHS)
    ignored.append(first_dir_pattern(data["laneState"]["runsDir"]))
    ignored.append(first_dir_pattern(data["laneState"]["executionPacket"]))
    ignored.append(first_dir_pattern(data["continuity"]["path"]))
    ignored.append(data["continuity"]["archiveDir"])
    if data.get("laneCoordination", {}).get("enabled", False):
        ignored.append(str(data["laneCoordination"]["root"]))
    ignored.extend(data["planningArtifacts"].get("scratchPaths", []))
    ignored.extend(explicit.get("ignoredDocPaths", []))
    return {
        "enforcement": explicit.get("enforcement", DEFAULT_DOCUMENT_CONTEXT["enforcement"]),
        "contextIndexPaths": explicit.get("contextIndexPaths") or [default_index],
        "trackedDocRoots": explicit.get("trackedDocRoots") or [planning_source],
        "historicalPaths": explicit.get("historicalPaths") or [default_historical],
        "ignoredDocPaths": [path for path in dict.fromkeys(ignored) if path],
        "maxIndexBytes": int(explicit.get("maxIndexBytes", DEFAULT_DOCUMENT_CONTEXT["maxIndexBytes"])),
        "maxIndexLines": int(explicit.get("maxIndexLines", DEFAULT_DOCUMENT_CONTEXT["maxIndexLines"])),
    }


def path_is_under(path: Path, root: Path) -> bool:
    return util_module().path_is_under(path, root)


def configured_paths(target: Path, values: list[str], *, allow_outside: bool = False) -> list[Path]:
    return [configured_path(target, value, allow_outside=allow_outside) for value in values if str(value).strip()]


def lane_coordination_config(data: dict) -> dict:
    return data["laneCoordination"]


def lane_coordination_paths(data: dict, target: Path) -> dict[str, Path]:
    cfg = lane_coordination_config(data)
    # laneCoordination paths are an allowlisted external group: the coordination root can live
    # outside any single project (framework default is ~/Projects/...), and the contract/board/
    # status dir hang off it.
    return {
        "root": configured_path(target, cfg["root"], allow_outside=True),
        "contract": configured_path(target, cfg["contractPath"], allow_outside=True),
        "board": configured_path(target, cfg["boardPath"], allow_outside=True),
        "laneStatusDir": configured_path(target, cfg["laneStatusDir"], allow_outside=True),
    }


def current_branch_name(target: Path) -> str:
    branch = run_git(target, ["branch", "--show-current"])
    return "" if branch == "unavailable" else branch.strip()


def default_lane_coordination_name(target: Path) -> str:
    branch = current_branch_name(target)
    return slugify(branch or target.name, "lane")


def lane_coordination_status_files(data: dict, target: Path) -> list[Path]:
    status_dir = lane_coordination_paths(data, target)["laneStatusDir"]
    try:
        return sorted(path for path in status_dir.glob("*.md") if path.is_file())
    except OSError:
        return []


def git_relative_path(target: Path, path: Path) -> str:
    return path_relative_to_target(target, path).replace("\\", "/")


def git_path_tracked(target: Path, path: Path) -> bool:
    rel = git_relative_path(target, path)
    code, _stdout, _stderr = run_command(["git", "-C", str(target), "ls-files", "--error-unmatch", "--", rel], timeout=10)
    return code == 0


def git_path_clean(target: Path, path: Path) -> bool:
    rel = git_relative_path(target, path)
    code, stdout, _stderr = run_command(["git", "-C", str(target), "status", "--porcelain", "--", rel], timeout=10)
    return code == 0 and not stdout.strip()


def git_path_at_ref(target: Path, path: Path, ref: str) -> bool:
    rel = git_relative_path(target, path)
    code, _stdout, _stderr = run_command(["git", "-C", str(target), "cat-file", "-e", f"{ref}:{rel}"], timeout=10)
    return code == 0


def git_branch_upstream(target: Path) -> str:
    upstream = run_git(target, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"])
    return "" if upstream == "unavailable" else upstream.strip()


def git_head_pushed_to_upstream(target: Path) -> bool:
    upstream = git_branch_upstream(target)
    if not upstream:
        return False
    code, _stdout, _stderr = run_command(["git", "-C", str(target), "merge-base", "--is-ancestor", "HEAD", upstream], timeout=10)
    return code == 0


def git_shared_base_refs(target: Path) -> list[str]:
    refs = []
    for ref in ["origin/main", "origin/master", "upstream/main", "upstream/master", "main", "master"]:
        if git_ref_exists(target, ref):
            refs.append(ref)
    return refs


def current_branch_is_shared_base(target: Path) -> bool:
    return current_branch_name(target) in {"main", "master"}


def lane_coordination_current_status_files(data: dict, target: Path) -> list[Path]:
    branch = current_branch_name(target)
    if not branch:
        return []
    expected_name = f"{slugify(branch, 'lane')}.md"
    matches: list[Path] = []
    for path in lane_coordination_status_files(data, target):
        if path.name == expected_name:
            matches.append(path)
            continue
        try:
            text = path.read_text(encoding="utf-8", errors="replace")
        except OSError:
            continue
        if re.search(rf"^Branch:\s*{re.escape(branch)}\s*$", text, re.MULTILINE):
            matches.append(path)
    return sorted(set(matches))


def lane_coordination_git_issues(data: dict, target: Path) -> list[str]:
    cfg = lane_coordination_config(data)
    if not cfg["enabled"]:
        return []
    if run_git(target, ["rev-parse", "--is-inside-work-tree"]) != "true":
        return ["strict coordination requires a Git worktree so artifacts can be tracked and shared"]
    paths = lane_coordination_paths(data, target)
    issues: list[str] = []
    shared_refs = git_shared_base_refs(target)
    head_is_pushed = git_head_pushed_to_upstream(target)
    for label, path in [("coordination contract", paths["contract"]), ("lane board", paths["board"])]:
        if not path.exists():
            continue
        if not git_path_tracked(target, path):
            issues.append(f"{label} is untracked: {path}")
            continue
        if not git_path_clean(target, path):
            issues.append(f"{label} has uncommitted changes: {path}")
            continue
        if not any(git_path_at_ref(target, path, ref) for ref in shared_refs) and not head_is_pushed:
            issues.append(f"{label} is not on the shared base or pushed branch: {path}")
    # Re-scoped to current-lane status files only: another (dead/idle) lane's untracked or dirty
    # status file must never block this lane's startup. Foreign untracked/dirty status files are
    # surfaced as an informational line only, via lane_coordination_foreign_status_git_notes.
    for path in lane_coordination_current_status_files(data, target):
        if not git_path_tracked(target, path):
            issues.append(f"lane status is untracked: {path}")
        elif not git_path_clean(target, path):
            issues.append(f"lane status has uncommitted changes: {path}")
    if not current_branch_is_shared_base(target):
        current_status = lane_coordination_current_status_files(data, target)
        if not current_status:
            expected = lane_coordination_paths(data, target)["laneStatusDir"] / f"{slugify(current_branch_name(target), 'lane')}.md"
            issues.append(f"missing current lane status for branch {current_branch_name(target)}: expected {expected} or a status file with Branch: {current_branch_name(target)}")
        elif not head_is_pushed:
            for path in current_status:
                issues.append(f"current lane status is not pushed to the branch upstream: {path}")
    return issues


def lane_coordination_foreign_status_git_notes(data: dict, target: Path) -> list[str]:
    """Informational notes for OTHER lanes' untracked/dirty status files (never issues: a dead
    lane's stray note must not block this lane's startup)."""
    cfg = lane_coordination_config(data)
    if not cfg["enabled"]:
        return []
    if run_git(target, ["rev-parse", "--is-inside-work-tree"]) != "true":
        return []
    current = set(lane_coordination_current_status_files(data, target))
    notes: list[str] = []
    for path in lane_coordination_status_files(data, target):
        if path in current:
            continue
        if not git_path_tracked(target, path):
            notes.append(f"{path} (untracked)")
        elif not git_path_clean(target, path):
            notes.append(f"{path} (uncommitted changes)")
    return notes


def lane_coordination_stale_partition(data: dict, target: Path) -> tuple[list[Path], list[Path]]:
    """Partition stale status files (by mtime vs maxStatusAgeHours) into (current-lane, other-lane)."""
    cfg = lane_coordination_config(data)
    max_age_seconds = int(cfg["maxStatusAgeHours"]) * 3600
    now = time.time()
    current = set(lane_coordination_current_status_files(data, target))
    current_stale: list[Path] = []
    other_stale: list[Path] = []
    for path in lane_coordination_status_files(data, target):
        try:
            age = now - path.stat().st_mtime
        except OSError:
            continue
        if age <= max_age_seconds:
            continue
        (current_stale if path in current else other_stale).append(path)
    return current_stale, other_stale


def lane_coordination_stale_files(data: dict, target: Path) -> list[Path]:
    # Re-scoped to current-lane status files only: other/dead lanes' rotting status files must
    # never block this lane's startup. See lane_coordination_stale_other_lane_files for the
    # informational (non-issue) counterpart.
    current_stale, _other_stale = lane_coordination_stale_partition(data, target)
    return current_stale


def lane_coordination_stale_other_lane_files(data: dict, target: Path) -> list[Path]:
    _current_stale, other_stale = lane_coordination_stale_partition(data, target)
    return other_stale


def lane_coordination_issues(data: dict, target: Path, *, strict: bool | None = None) -> list[str]:
    cfg = lane_coordination_config(data)
    if not cfg["enabled"]:
        return []
    effective_strict = cfg["enforcement"] == "strict" if strict is None else strict
    paths = lane_coordination_paths(data, target)
    issues = []
    if not paths["contract"].is_file():
        issues.append(f"missing coordination contract: {paths['contract']}")
    if not paths["board"].is_file():
        issues.append(f"missing lane board: {paths['board']}")
    if not paths["laneStatusDir"].is_dir():
        issues.append(f"missing lane status dir: {paths['laneStatusDir']}")
    for path in lane_coordination_stale_files(data, target):
        issues.append(f"stale lane status: {path}")
    if effective_strict:
        issues.extend(lane_coordination_git_issues(data, target))
    return issues


def print_lane_coordination_status(data: dict, target: Path, *, strict: bool | None = None) -> list[str]:
    cfg = lane_coordination_config(data)
    paths = lane_coordination_paths(data, target)
    status_files = lane_coordination_status_files(data, target)
    current_status_files = lane_coordination_current_status_files(data, target)
    stale_files = lane_coordination_stale_files(data, target)
    stale_other_lane_files = lane_coordination_stale_other_lane_files(data, target)
    foreign_status_git_notes = lane_coordination_foreign_status_git_notes(data, target)
    issues = lane_coordination_issues(data, target, strict=strict)
    print(f"lane_coordination: enabled={str(cfg['enabled']).lower()}")
    print(f"lane_coordination_enforcement: {cfg['enforcement']}")
    print(f"lane_coordination_root: {paths['root']}")
    print(f"lane_coordination_contract: {'present' if paths['contract'].is_file() else 'missing'} {paths['contract']}")
    print(f"lane_coordination_board: {'present' if paths['board'].is_file() else 'missing'} {paths['board']}")
    print(f"lane_coordination_status_dir: {'present' if paths['laneStatusDir'].is_dir() else 'missing'} {paths['laneStatusDir']}")
    print(f"lane_coordination_status_files: {compact_path_list(status_files, target)}")
    print(f"lane_coordination_current_status_files: {compact_path_list(current_status_files, target)}")
    print(f"lane_coordination_stale_status_files: {compact_path_list(stale_files, target)}")
    # Other lanes' staleness/untracked/dirty status is informational only, never a startup-blocking
    # issue: a dead or idle lane's rotting status file must not block this lane's startup.
    print(f"lane_coordination_stale_other_lanes: {compact_path_list(stale_other_lane_files, target)}")
    if foreign_status_git_notes:
        print(f"lane_coordination_foreign_status_git: {len(foreign_status_git_notes)} - {', '.join(foreign_status_git_notes)}")
    else:
        print("lane_coordination_foreign_status_git: none")
    print(
        "lane_coordination_instruction: Use tracked repo coordination artifacts for shared decisions; commit and push this lane's status file "
        "with active goal/milestone and owned files before multi-lane work; land shared contract/interface changes before large dependent PRs."
    )
    for issue in issues:
        print(f"lane_coordination_issue: {issue}")
    return issues


def ensure_lane_coordination_artifacts(data: dict, target: Path) -> list[Path]:
    cfg = lane_coordination_config(data)
    if not cfg["enabled"]:
        return []
    paths = lane_coordination_paths(data, target)
    created = []
    if not paths["laneStatusDir"].is_dir():
        paths["laneStatusDir"].mkdir(parents=True, exist_ok=True)
        created.append(paths["laneStatusDir"])
    for path, content in [
        (paths["contract"], lane_coordination_contract_template(data, target)),
        (paths["board"], lane_coordination_board_template(data, target)),
    ]:
        if not path.exists():
            path.parent.mkdir(parents=True, exist_ok=True)
            path.write_text(content, encoding="utf-8")
            created.append(path)
    return created


def lane_coordination_contract_template(data: dict, target: Path) -> str:
    return (
        "# Cross-Lane Coordination Contract\n\n"
        "This tracked document is the source of truth for shared decisions across simultaneous AI development lanes. "
        "Use it to prevent lanes from inventing incompatible routes, actors, schemas, APIs, state machines, or ownership boundaries.\n\n"
        "## Shared Decisions\n\n"
        "- TBD\n\n"
        "## Ownership Boundaries\n\n"
        "- TBD\n\n"
        "## Interfaces Other Lanes Consume\n\n"
        "- TBD\n\n"
        "## Contract Change Rule\n\n"
        "If a lane discovers it needs to change something another lane owns or consumes, it must update this contract, "
        "open a small shared contract/interface PR, or explicitly take ownership and mark dependent lanes here before implementation drifts.\n\n"
        "## Open Cross-Lane Questions\n\n"
        "- TBD\n"
    )


def lane_coordination_board_template(data: dict, target: Path) -> str:
    return (
        "# Cross-Lane Lane Board\n\n"
        "This tracked board points to per-lane status files. Each lane owns its own status file under the lane status directory. "
        "Keep shared decisions in the coordination contract, not in chat or local memory.\n\n"
        "## Lane Status Files\n\n"
        "<!-- lane-status:start -->\n"
        "<!-- lane-status:end -->\n\n"
        "## Merge And Contract Notes\n\n"
        "- Land small shared contract/interface PRs before large dependent implementation PRs.\n"
        "- When a lane depends on another lane, link the provider lane status file and PR/commit here.\n"
    )


def lane_coordination_note_template(
    *,
    project: str,
    lane: str,
    branch: str,
    goal: str,
    current: str,
    depends_on: str,
    provides: str,
    blockers: str,
    pr: str,
) -> str:
    return (
        f"# Lane Status: {lane}\n\n"
        f"Project: {project}\n"
        f"Lane: {lane}\n"
        f"Branch: {branch or 'none'}\n"
        f"Updated: {now_iso()}\n\n"
        "## Current Goal\n\n"
        f"{goal or 'none recorded'}\n\n"
        "## Currently Changing\n\n"
        f"{current or 'none recorded'}\n\n"
        "## Depends On Other Lanes\n\n"
        f"{depends_on or 'none recorded'}\n\n"
        "## Provides To Other Lanes\n\n"
        f"{provides or 'none recorded'}\n\n"
        "## Blockers\n\n"
        f"{blockers or 'none recorded'}\n\n"
        "## Latest PR Or Commit\n\n"
        f"{pr or 'none recorded'}\n"
    )


def update_lane_coordination_board(
    data: dict,
    target: Path,
    *,
    lane: str,
    status_path: Path,
    goal: str,
    current: str,
    branch: str,
    pr: str,
) -> None:
    board = lane_coordination_paths(data, target)["board"]
    if not board.exists():
        board.parent.mkdir(parents=True, exist_ok=True)
        board.write_text(lane_coordination_board_template(data, target), encoding="utf-8")
    text = board.read_text(encoding="utf-8", errors="replace")
    start = "<!-- lane-status:start -->"
    end = "<!-- lane-status:end -->"
    if start not in text or end not in text:
        text = text.rstrip() + f"\n\n## Lane Status Files\n\n{start}\n{end}\n"
    rel = path_display(target, status_path)
    line = (
        f"- `{lane}` - `{branch or 'no-branch'}` - {goal or current or 'status updated'}; "
        f"status: `{rel}`; PR/commit: {pr or 'none'}; updated: {now_iso()}"
    )
    pattern = re.compile(rf"^- `{re.escape(lane)}` - .*$", re.MULTILINE)
    before, rest = text.split(start, 1)
    body, after = rest.split(end, 1)
    lines = [item for item in body.splitlines() if item.strip()]
    replaced = False
    updated_lines = []
    for existing in lines:
        if pattern.match(existing):
            updated_lines.append(line)
            replaced = True
        else:
            updated_lines.append(existing)
    if not replaced:
        updated_lines.append(line)
    body_text = "\n".join(updated_lines)
    board.write_text(before + start + ("\n" + body_text if body_text else "") + "\n" + end + after, encoding="utf-8")


def lane_coordination_bootstrap(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    cfg = lane_coordination_config(data)
    if not cfg["enabled"]:
        print("lane_coordination_bootstrap: disabled by adapter")
        return 0
    paths = lane_coordination_paths(data, target)
    print(f"lane_coordination_root: {paths['root']}")
    writes = [
        (paths["contract"], lane_coordination_contract_template(data, target)),
        (paths["board"], lane_coordination_board_template(data, target)),
    ]
    if args.write:
        paths["laneStatusDir"].mkdir(parents=True, exist_ok=True)
        print(f"lane_coordination_status_dir: present {paths['laneStatusDir']}")
    else:
        print(f"lane_coordination_status_dir: would-create {paths['laneStatusDir']}")
    for path, content in writes:
        if path.exists():
            print(f"lane_coordination_file: present {path}")
            continue
        if args.write:
            path.parent.mkdir(parents=True, exist_ok=True)
            path.write_text(content, encoding="utf-8")
            print(f"wrote {path}")
        else:
            print(f"lane_coordination_file: missing {path}")
    if not args.write:
        print("lane_coordination_bootstrap_next_action: rerun with --write to create missing coordination artifacts")
    return 0


def lane_coordination_status(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    effective_strict = args.strict or data["laneCoordination"]["enforcement"] == "strict"
    issues = print_lane_coordination_status(data, target, strict=effective_strict)
    if issues:
        print("lane_coordination_status: issues")
        return 1 if effective_strict else 0
    print("lane_coordination_status: ok")
    return 0


def lane_coordination_note(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    cfg = lane_coordination_config(data)
    if not cfg["enabled"]:
        print("lane_coordination_note: disabled by adapter")
        return 0
    lane = slugify(args.lane or default_lane_coordination_name(target), "lane")
    branch = args.branch or current_branch_name(target)
    paths = lane_coordination_paths(data, target)
    status_path = paths["laneStatusDir"] / f"{lane}.md"
    content = lane_coordination_note_template(
        project=data["project"],
        lane=lane,
        branch=branch,
        goal=args.goal or "",
        current=args.current or "",
        depends_on=args.depends_on or "",
        provides=args.provides or "",
        blockers=args.blockers or "",
        pr=args.pr or "",
    )
    print(f"lane_coordination_note_path: {status_path}")
    if not args.write:
        print("lane_coordination_note_write: dry-run")
        print(content)
        return 0
    paths["laneStatusDir"].mkdir(parents=True, exist_ok=True)
    status_path.write_text(content, encoding="utf-8")
    update_lane_coordination_board(
        data,
        target,
        lane=lane,
        status_path=status_path,
        goal=args.goal or "",
        current=args.current or "",
        branch=branch,
        pr=args.pr or "",
    )
    print(f"wrote {status_path}")
    print(f"updated {paths['board']}")
    return 0


def is_ignored_doc_path(path: Path, ignored_paths: list[Path]) -> bool:
    for ignored in ignored_paths:
        if path == ignored or path_is_under(path, ignored):
            return True
    return False


def markdown_files_under(data: dict, target: Path, roots: list[Path]) -> list[Path]:
    files: set[Path] = set()
    # ignoredDocPaths folds in scratchPaths (framework default ~/.claude/plans/), so this scan list
    # is an allowlisted external-capable group.
    ignored_paths = configured_paths(target, derived_document_context(data)["ignoredDocPaths"], allow_outside=True)
    for root in roots:
        try:
            if root.is_file() and root.suffix.lower() == ".md":
                files.add(root)
            elif root.is_dir():
                files.update(path for path in root.rglob("*.md") if path.is_file())
        except OSError:
            continue
    return sorted(path for path in files if not is_ignored_doc_path(path, ignored_paths))


def document_context_index_template(data: dict, target: Path, index_path: Path) -> str:
    ctx = derived_document_context(data)
    readiness = data["readiness"].get("sources", [])
    read_first = [
        "`CLAUDE.md` - generated Claude adapter.",
        "`AGENTS.md` - generated Codex adapter.",
        "`.minervit-ai-delivery.json` - generated project adapter.",
    ]
    read_first.extend(f"`{source}` - adapter readiness source." for source in readiness)
    historical = [
        f"`{path}` - historical evidence only; do not load routinely."
        for path in ctx["historicalPaths"]
    ]
    ignored = [f"`{path}`" for path in ctx["ignoredDocPaths"]]
    return (
        "# Document Context Index\n\n"
        "This index controls routine Markdown context loading. Read this file before loading project docs. "
        "Do not broad-load Markdown trees.\n\n"
        "## Read First\n"
        + "\n".join(f"- {item}" for item in read_first)
        + "\n\n## Active Work\n\n"
        "<!-- Add current execution plans/specs here. -->\n\n"
        "## Ready Next\n\n"
        "<!-- Add approved ready-for-development work here. -->\n\n"
        "## Historical Evidence Only\n"
        + "\n".join(f"- {item}" for item in historical)
        + "\n\n## Do Not Load Routinely\n"
        + "\n".join(f"- {item}" for item in ignored)
        + "\n\n## Needs Classification\n\n"
        "<!-- context-bootstrap --classify adds candidates here. Move them to Active Work, Ready Next, or Historical Evidence Only before strict enforcement. -->\n"
    )


def parse_index_sections(text: str) -> dict[str, set[str]]:
    sections: dict[str, set[str]] = {section: set() for section in DOCUMENT_CONTEXT_SECTIONS}
    current: str | None = None
    for line in text.splitlines():
        if line.startswith("## "):
            title = line[3:].strip()
            current = title if title in sections else None
            continue
        if current and line.lstrip().startswith("-"):
            matches = re.findall(r"`([^`]+)`", line)
            if matches:
                sections[current].update(match.strip().strip("/") for match in matches if match.strip())
    return sections


def read_document_index_entries(index_paths: list[Path]) -> dict[str, set[str]]:
    combined: dict[str, set[str]] = {section: set() for section in DOCUMENT_CONTEXT_SECTIONS}
    for path in index_paths:
        if not path.exists():
            continue
        try:
            parsed = parse_index_sections(path.read_text(encoding="utf-8", errors="replace"))
        except OSError:
            continue
        for section, entries in parsed.items():
            combined[section].update(entries)
    return combined


def archive_header_ok(path: Path) -> bool:
    try:
        lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
    except OSError:
        return False
    start = 0
    if lines and lines[0].strip() == "---":
        for index, line in enumerate(lines[1:], start=1):
            if line.strip() == "---":
                start = index + 1
                break
    window = lines[start : start + 20]
    return any(line.strip().startswith(ARCHIVE_HEADER_SENTENCE.split("<index path>")[0].strip()) for line in window)


def insert_archive_header(path: Path, index_ref: str) -> bool:
    try:
        text = path.read_text(encoding="utf-8")
    except OSError:
        return False
    if archive_header_ok(path):
        return False
    lines = text.splitlines()
    header = ARCHIVE_HEADER_SENTENCE.replace("<index path>", index_ref)
    insert_at = 0
    if lines and lines[0].strip() == "---":
        for index, line in enumerate(lines[1:], start=1):
            if line.strip() == "---":
                insert_at = index + 1
                break
    elif lines:
        for index, line in enumerate(lines):
            if line.startswith("#"):
                insert_at = index
                break
    updated = lines[:insert_at] + ["", header, ""] + lines[insert_at:]
    path.write_text("\n".join(updated).lstrip("\n") + "\n", encoding="utf-8")
    return True


def document_context_status_data(data: dict, target: Path) -> dict:
    ctx = derived_document_context(data)
    # contextIndexPaths/trackedDocRoots/historicalPaths default to in-project paths and are not
    # documented in adapter-schema.json as containment-exempt, so they get the normal containment
    # backstop. (ignoredDocPaths, folded in via markdown_files_under, legitimately includes external
    # locations like the shared scratch dir and stays allow_outside=True there.)
    index_paths = configured_paths(target, ctx["contextIndexPaths"])
    tracked_roots = configured_paths(target, ctx["trackedDocRoots"])
    historical_roots = configured_paths(target, ctx["historicalPaths"])
    index_set = {path.resolve(strict=False) for path in index_paths}
    entries = read_document_index_entries(index_paths)
    classified = set()
    for section in ["Read First", "Active Work", "Ready Next", "Historical Evidence Only", "Do Not Load Routinely"]:
        classified.update(entries[section])
    historical_files = markdown_files_under(data, target, historical_roots)
    tracked_files = markdown_files_under(data, target, tracked_roots)
    unclassified: list[Path] = []
    for path in tracked_files:
        if path.resolve(strict=False) in index_set:
            continue
        if any(path_is_under(path, root) for root in historical_roots):
            continue
        rel = path_display(target, path).strip("/")
        if rel not in classified:
            unclassified.append(path)
    oversized_indexes: list[str] = []
    missing_indexes = [path for path in index_paths if not path.exists()]
    for path in index_paths:
        if not path.exists():
            continue
        try:
            raw = path.read_bytes()
            line_count = len(raw.decode("utf-8", errors="replace").splitlines())
        except OSError:
            oversized_indexes.append(f"{path} (unreadable)")
            continue
        if len(raw) > ctx["maxIndexBytes"] or line_count > ctx["maxIndexLines"]:
            oversized_indexes.append(f"{path} ({len(raw)} bytes, {line_count} lines)")
    archive_header_gaps = [path for path in historical_files if not archive_header_ok(path)]
    needs_classification = sorted(entries["Needs Classification"])
    issues: list[str] = []
    issues.extend(f"missing context index: {path}" for path in missing_indexes)
    issues.extend(f"oversized context index: {item}" for item in oversized_indexes)
    issues.extend(f"unclassified tracked markdown: {path}" for path in unclassified)
    issues.extend(f"needs classification index entry: {entry}" for entry in needs_classification)
    issues.extend(f"archive header missing: {path}" for path in archive_header_gaps)
    return {
        "config": ctx,
        "index_paths": index_paths,
        "tracked_roots": tracked_roots,
        "historical_roots": historical_roots,
        "missing_indexes": missing_indexes,
        "oversized_indexes": oversized_indexes,
        "unclassified": unclassified,
        "needs_classification": needs_classification,
        "archive_header_gaps": archive_header_gaps,
        "entries": entries,
        "historical_files": historical_files,
        "issues": issues,
    }


def compact_path_list(paths: list[Path], target: Path, limit: int = 5) -> str:
    return util_module().compact_path_list(paths, target, limit)


def print_document_context_status(data: dict, target: Path) -> list[str]:
    status = document_context_status_data(data, target)
    ctx = status["config"]
    print(f"document_context: enforcement={ctx['enforcement']}")
    print("document_context_instruction: Read indexes first; do not broad-load Markdown.")
    print(f"document_context_indexes: {compact_path_list(status['index_paths'], target)}")
    print(f"document_context_missing_indexes: {compact_path_list(status['missing_indexes'], target)}")
    print(f"document_context_tracked_roots: {compact_path_list(status['tracked_roots'], target)}")
    print(f"document_context_historical_paths: {compact_path_list(status['historical_roots'], target)}")
    print(f"document_context_unclassified: {compact_path_list(status['unclassified'], target)}")
    print(f"document_context_needs_classification: {len(status['needs_classification'])}")
    print(f"document_context_archive_header_gaps: {compact_path_list(status['archive_header_gaps'], target)}")
    print(f"document_context_oversized_indexes: {', '.join(status['oversized_indexes']) if status['oversized_indexes'] else 'none'}")
    return status["issues"]


def upsert_section_entries(text: str, section: str, entries: list[str]) -> str:
    entries = [entry for entry in entries if entry and entry not in text]
    if not entries:
        return text if text.endswith("\n") else text + "\n"
    heading = f"## {section}"
    lines = text.splitlines()
    try:
        start = next(index for index, line in enumerate(lines) if line.strip() == heading)
    except StopIteration:
        return text.rstrip() + f"\n\n{heading}\n" + "\n".join(entries) + "\n"
    end = len(lines)
    for index in range(start + 1, len(lines)):
        if lines[index].startswith("## "):
            end = index
            break
    updated = lines[:end] + entries + lines[end:]
    return "\n".join(updated).rstrip() + "\n"


def unique_destination(directory: Path, name: str) -> Path:
    dest = directory / name
    if not dest.exists():
        return dest
    stem = dest.stem
    suffix = dest.suffix
    counter = 1
    while True:
        candidate = directory / f"{stem}-{counter}{suffix}"
        if not candidate.exists():
            return candidate
        counter += 1


def scratch_migration_lock_path(data: dict, target: Path) -> Path:
    scratch_paths = [
        str(configured_path(target, path_text, allow_outside=True).resolve(strict=False))
        for path_text in data["planningArtifacts"].get("scratchPaths", [])
    ]
    digest = hashlib.sha256("\0".join(sorted(scratch_paths)).encode("utf-8")).hexdigest()[:16]
    return Path.home() / f".minervit-methodology-scratch-migration-{digest}.lock"


@contextmanager
def directory_lock(lock_dir: Path):
    acquired = False
    deadline = time.time() + 30
    while True:
        try:
            lock_dir.mkdir(mode=0o700)
            acquired = True
            break
        except FileExistsError:
            if time.time() >= deadline:
                print(f"planning_migration_warning: lock busy after 30s; proceeding without lock {lock_dir}", file=sys.stderr)
                break
            time.sleep(0.1)
        except OSError as exc:
            print(f"planning_migration_warning: could not create fallback lock {lock_dir}: {exc}", file=sys.stderr)
            break
    try:
        yield
    finally:
        if acquired:
            try:
                lock_dir.rmdir()
            except OSError:
                pass


@contextmanager
def scratch_migration_lock(lock_path: Path):
    fallback_dir = lock_path.with_name(f"{lock_path.name}.d")
    try:
        lock_file = lock_path.open("a+", encoding="utf-8")
    except OSError as exc:
        print(f"planning_migration_warning: could not open lock {lock_path}: {exc}; using fallback lock", file=sys.stderr)
        with directory_lock(fallback_dir):
            yield
        return
    try:
        import fcntl  # type: ignore[import-not-found]
    except ImportError:
        lock_file.close()
        with directory_lock(fallback_dir):
            yield
        return
    try:
        try:
            fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
        except OSError as exc:
            print(f"planning_migration_warning: could not acquire lock {lock_path}: {exc}; using fallback lock", file=sys.stderr)
            with directory_lock(fallback_dir):
                yield
            return
        yield
    finally:
        try:
            fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
        except OSError:
            pass
        lock_file.close()


def migrate_relevant_scratch_plans(data: dict, target: Path) -> tuple[list[tuple[Path, Path]], list[tuple[Path, Path, str]]]:
    with scratch_migration_lock(scratch_migration_lock_path(data, target)):
        candidates = relevant_scratch_plan_candidates(data, scratch_plan_candidates(data, target))
        if not candidates:
            return [], []
        source_dir = configured_path(target, data["planningArtifacts"]["sourceOfTruth"])
        source_dir.mkdir(parents=True, exist_ok=True)
        migrated: list[tuple[Path, Path]] = []
        errors: list[tuple[Path, Path, str]] = []
        for source in candidates:
            if not source.exists():
                continue
            dest = unique_destination(source_dir, source.name)
            try:
                shutil.move(str(source), str(dest))
            except FileNotFoundError:
                continue
            except OSError as exc:
                errors.append((source, dest, str(exc)))
                continue
            migrated.append((source, dest))
        return migrated, errors


def write_generated_files(data: dict, project_path: Path, target: Path) -> tuple[list[Path], list[Path]]:
    written: list[Path] = []
    skipped: list[Path] = []
    if is_repo_local_source_adapter(project_path, target):
        project_arg = project_path.resolve().relative_to(target.resolve()).as_posix()
        source_path_override = project_path
    else:
        project_arg = project_source_arg(data, project_path)
        source_path_override = None
    for rel, content in expected_files(data, project_arg, source_path_override, target).items():
        dest = target / rel
        if rel in GENERATED_MARKDOWN_FILES and dest.exists() and not is_methodology_generated_markdown(dest):
            skipped.append(dest)
            continue
        if rel == LANE_ADAPTER_FILE:
            try:
                current = dest.read_text(encoding="utf-8") if dest.exists() else None
            except OSError:
                current = None
            if (
                current is not None
                and current == adapter_stamp_equivalent_content(content, current)
            ):
                # Provenance-stamp-only difference: leave the file untouched (bytes AND mtime) so
                # lane-start stops churning the adapter on every session and a wheel-stamped and
                # a checkout-stamped render stop rewriting each other.
                continue
        dest.write_text(content, encoding="utf-8")
        written.append(dest)
    return written, skipped


def methodology_upstream() -> tuple[str, str, str] | None:
    args = ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]
    upstream = run_git(canonical_methodology_repo(), args)
    if upstream == "unavailable" or "/" not in upstream:
        return None
    remote, branch = upstream.split("/", 1)
    return remote, branch, upstream


def methodology_release_upstream(channel: str = "stable") -> tuple[str, str, str] | None:
    branch_name = framework_channel_branch(channel)
    if run_git(canonical_methodology_repo(), ["remote", "get-url", "origin"]) != "unavailable":
        return "origin", branch_name, f"origin/{branch_name}"
    upstream = methodology_upstream()
    if upstream and upstream[1] == branch_name:
        return upstream
    return None


METHODOLOGY_UPDATE_POLICY_ENV = "MINERVIT_METHODOLOGY_UPDATE_POLICY"
METHODOLOGY_UPDATE_PINS_ENV = "MINERVIT_METHODOLOGY_UPDATE_PINS"
METHODOLOGY_UPDATE_SIGNERS_ENV = "MINERVIT_METHODOLOGY_UPDATE_SIGNERS"
METHODOLOGY_UPDATE_PINS_FILE = Path.home() / ".config" / "minervit" / "methodology.update-pins"


def methodology_update_policy() -> str:
    policy = util_module().resolve_env(METHODOLOGY_UPDATE_POLICY_ENV).strip().lower()
    if policy in ("signed", "pinned", "unverified", "warn"):
        return policy
    return "warn"  # backward-compatible default; public distribution should set signed|pinned


def effective_methodology_update_policy(config_env: Path | None = None) -> str:
    """Effective policy for install-time gating: live env wins, else the installed methodology.env
    value (which the launcher sources at runtime), else the backward-compatible 'warn' default.

    Used by the --dangerously-skip-permissions interlock so launcher install sees the same policy the
    launcher will run under, even before the operator re-sources methodology.env.
    """
    env_policy = util_module().resolve_env(METHODOLOGY_UPDATE_POLICY_ENV).strip().lower()
    if env_policy in ("signed", "pinned", "unverified", "warn"):
        return env_policy
    if config_env is None:
        config_env = resolve_user_config_env()
    file_policy = util_module().user_config_env_value(METHODOLOGY_UPDATE_POLICY_ENV, config_env).strip().lower()
    if file_policy in ("signed", "pinned", "unverified", "warn"):
        return file_policy
    return "warn"


def _methodology_update_pins() -> set[str]:
    raw_pins = util_module().resolve_env(METHODOLOGY_UPDATE_PINS_ENV)
    pins: set[str] = {p for p in re.split(r"[,\s]+", raw_pins) if p}
    try:
        for line in METHODOLOGY_UPDATE_PINS_FILE.read_text(encoding="utf-8").splitlines():
            entry = line.strip()
            if entry and not entry.startswith("#"):
                pins.add(entry)
    except OSError:
        pass
    return pins


def verify_upstream_trust(new_head: str) -> tuple[bool, str]:
    """Decide whether code at new_head may be re-exec'd after an auto-update (sec-trust-exec-1).

    The auto-update fast-forwards then os.execve's the NEW code. Without a trust check, one upstream
    compromise = fleet-wide RCE for every adopter pointed at a shared upstream. Policy is read from
    MINERVIT_METHODOLOGY_UPDATE_POLICY:
      - signed:     require a valid signature on new_head (git verify-commit); optional signer
                    allowlist via MINERVIT_METHODOLOGY_UPDATE_SIGNERS.
      - pinned:     require new_head to match a pinned commit/tag (MINERVIT_METHODOLOGY_UPDATE_PINS
                    or ~/.config/minervit/methodology.update-pins).
      - unverified: explicit opt-out for a single trusted operator on a trusted machine.
      - warn (default): allow, but emit a loud warning recommending a strict policy. This keeps the
                    existing single-operator workflow working while making the gate available and
                    enforceable; public distribution MUST set signed or pinned (see SECURITY.md).
    Returns (allowed, message); the caller refuses the re-exec when allowed is False.
    """
    policy = methodology_update_policy()
    if policy == "unverified":
        return True, ""
    # Trust is decided against the CANONICAL checkout: the candidate commit was fetched into it,
    # and a snapshot exec root holds no objects to resolve a pin or verify a signature against.
    canonical = canonical_methodology_repo()
    if policy == "pinned":
        pins = _methodology_update_pins()
        if not pins:
            return False, (
                "update policy 'pinned' but no pins configured; set "
                f"{METHODOLOGY_UPDATE_PINS_ENV} or {METHODOLOGY_UPDATE_PINS_FILE}"
            )
        if new_head in pins:
            return True, ""
        for pin in pins:
            if run_git(canonical, ["rev-parse", "--verify", f"{pin}^{{commit}}"]) == new_head:
                return True, ""
        return False, f"new upstream commit {new_head[:12]} is not in the pinned allowlist"
    if policy == "signed":
        code, _out, _err = run_command(["git", "-C", str(canonical), "verify-commit", "--raw", new_head])
        if code != 0:
            return False, f"new upstream commit {new_head[:12]} is not validly signed (git verify-commit failed)"
        raw_signers = util_module().resolve_env(METHODOLOGY_UPDATE_SIGNERS_ENV)
        signers = [s for s in re.split(r"[,\s]+", raw_signers) if s]
        if signers:
            _code2, _out2, info = run_command(["git", "-C", str(canonical), "verify-commit", "--raw", new_head])
            if not any(signer in (info or "") for signer in signers):
                return False, f"new upstream commit {new_head[:12]} signer not in allowlist {signers}"
        return True, ""
    return True, (
        f"WARNING: re-executing updated methodology at {new_head[:12]} WITHOUT upstream trust "
        f"verification. Set {METHODOLOGY_UPDATE_POLICY_ENV}=signed or =pinned to verify the upstream "
        "before re-exec (strongly recommended for any shared upstream)."
    )


def gate_methodology_upstream_advance(candidate_head: str) -> str | None:
    """Return None when the checkout may advance to candidate_head, else a refusal detail (sec-trust-exec-1).

    Verify-before-advance: callers fetch the upstream commit WITHOUT moving the checkout, call this against
    the FETCHED sha, and only reset/merge/switch when it returns None. Advancing first and verifying after
    is the ungated-RCE bug this closes -- a bare `git pull --ff-only` (or an eager reset/switch) moves the
    on-disk bin/minervit-methodology to unverified code, and the next shim invocation then executes it even
    though this process refuses to re-exec it. Fail-closed = the working tree never advances to code the
    trust policy would reject. (The warn-policy advisory is emitted by the post-advance re-exec gate, so
    this helper stays silent on the allow path to avoid a duplicate warning.)
    """
    allowed, trust_msg = verify_upstream_trust(candidate_head)
    if not allowed:
        # Recovery guidance must match the failing policy: update-repin only rewrites the
        # pinned allowlist and is ignored by the signed policy, so routing a signed-policy
        # failure to repin would leave the operator blocked after following the advice.
        if methodology_update_policy() == "signed":
            recovery = (
                "ensure the new upstream head carries a valid signature from a trusted key "
                f"(and that the signer is listed in {METHODOLOGY_UPDATE_SIGNERS_ENV} when a signer "
                "allowlist is set); see SECURITY.md"
            )
        else:
            recovery = (
                "review the incoming commits (git log <current>..origin/<branch> in the methodology checkout), then "
                f"advance the trusted pin with `{CLI_NAME} update-repin --channel <stable|experimental>`; see SECURITY.md"
            )
        return (
            f"refusing to advance methodology checkout to unverified upstream {candidate_head[:12]}: {trust_msg}; "
            + recovery
        )
    return None


def methodology_reexec_token_dir() -> Path:
    # Cross-version note: the consumer receives the token's ABSOLUTE path via
    # METHODOLOGY_REEXEC_TOKEN_ENV, so relocating the create dir is handoff-safe in both
    # directions during the rebrand transition.
    return Path.home() / ".config" / "tautline" / "reexec-tokens"


def create_methodology_reexec_token(expected_head: str) -> str:
    """Write a single-use re-exec handoff token (sec-trust-exec-4).

    The token lives in a private 0700 directory under ~/.config/minervit with a 0600,
    unpredictable mkstemp name — not the shared, world-writable system tempdir where another
    local user could pre-create, read, or race the file.
    """
    token_dir = methodology_reexec_token_dir()
    token_dir.mkdir(parents=True, exist_ok=True)
    try:
        token_dir.chmod(0o700)
    except OSError:
        pass
    fd, token_name = tempfile.mkstemp(dir=str(token_dir), prefix=f"reexec-{expected_head[:12]}-", suffix=".json")
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as handle:
            json.dump({"expected_head": expected_head, "created_at": time.time(), "pid": os.getpid()}, handle)
    except BaseException:
        try:
            os.unlink(token_name)
        except OSError:
            pass
        raise
    try:
        Path(token_name).chmod(0o600)  # mkstemp already restricts, but be explicit
    except OSError:
        pass
    return token_name


def consume_methodology_reexec_token() -> bool | None:
    token = os.environ.get(METHODOLOGY_REEXEC_TOKEN_ENV)
    if not token:
        return None
    token_path = Path(token)
    try:
        token_stat = token_path.stat()
    except OSError:
        return False
    # Reject a token planted by another local user: only trust one this uid owns.
    if hasattr(os, "getuid") and token_stat.st_uid != os.getuid():
        try:
            token_path.unlink()
        except OSError:
            pass
        return False
    try:
        payload = json.loads(token_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return False
    finally:
        try:
            token_path.unlink()
        except OSError:
            pass
    if not isinstance(payload, dict):
        return False
    expected_head = payload.get("expected_head")
    created_at = payload.get("created_at")
    if not isinstance(expected_head, str) or not isinstance(created_at, (int, float)):
        return False
    if time.time() - float(created_at) > METHODOLOGY_REEXEC_TOKEN_MAX_AGE_SECONDS:
        return False
    if not expected_head:
        return False
    manifest = snapshot_manifest()
    if manifest is not None:
        # A snapshot is an exported tree with no `.git`: `rev-parse` returns run_git's
        # "unavailable" sentinel, so the git check below could never match and EVERY post-advance
        # re-exec would be rejected as an invalid token. The manifest carries the truthful commit,
        # and an immutable tree cannot be dirty, so the porcelain check is vacuous here.
        return str(manifest["commit"]) == expected_head
    canonical = canonical_methodology_repo()
    current_head = run_git(canonical, ["rev-parse", "HEAD"])
    dirty = run_git(canonical, ["status", "--porcelain"])
    return bool(current_head == expected_head and dirty == "")


def methodology_rescue_state_dir() -> Path:
    configured = util_module().resolve_env(METHODOLOGY_RESCUE_STATE_ENV)
    if configured:
        return Path(configured).expanduser()
    return Path.home() / ".local" / "state" / "minervit" / "methodology-rescue"


def move_untracked_methodology_files(rescue_branch: str) -> tuple[Path | None, str | None]:
    canonical = canonical_methodology_repo()
    code, stdout, stderr = run_command(["git", "-C", str(canonical), "ls-files", "--others", "--exclude-standard", "-z"])
    if code != 0:
        return None, stderr or stdout or "could not list untracked files"
    paths = [part for part in stdout.split("\0") if part]
    if not paths:
        return None, None
    safe_branch = re.sub(r"[^A-Za-z0-9_.-]+", "_", rescue_branch)
    rescue_dir = methodology_rescue_state_dir() / safe_branch / "untracked"
    moved: list[tuple[Path, Path]] = []
    for rel in paths:
        source = canonical / rel
        if not source.exists():
            continue
        dest = rescue_dir / rel
        try:
            dest.parent.mkdir(parents=True, exist_ok=True)
            shutil.move(str(source), str(dest))
            moved.append((source, dest))
        except OSError as exc:
            rollback_errors: list[str] = []
            for original, moved_dest in reversed(moved):
                try:
                    original.parent.mkdir(parents=True, exist_ok=True)
                    shutil.move(str(moved_dest), str(original))
                except OSError as rollback_exc:
                    rollback_errors.append(f"{moved_dest} -> {original}: {rollback_exc}")
            rollback_detail = f"; rollback errors: {'; '.join(rollback_errors)}" if rollback_errors else ""
            return rescue_dir, f"could not preserve untracked file {rel}: {exc}{rollback_detail}"
    return rescue_dir, None


def tracked_private_source_adapter_paths(repo: Path | None = None) -> list[str]:
    repo = repo or canonical_methodology_repo()
    code, stdout, _stderr = run_command(["git", "-C", str(repo), "ls-files", "-z", "--", "adapters/projects/*.json"])
    if code != 0:
        return []
    paths = []
    for rel in [part for part in stdout.split("\0") if part]:
        path = Path(rel)
        if (
            path.as_posix().startswith("adapters/projects/")
            and not path.name.startswith(".")
            and path.name not in PUBLIC_RELEASE_ALLOWED_SOURCE_ADAPTERS
        ):
            paths.append(path.as_posix())
    return sorted(dict.fromkeys(paths))


def copy_private_source_adapters_for_reset(
    repo: Path | None = None,
    *,
    reason: str = "release-reset",
) -> tuple[list[tuple[str, Path]], str]:
    repo = repo or canonical_methodology_repo()
    paths = tracked_private_source_adapter_paths(repo)
    if not paths:
        return [], ""
    stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    safe_reason = re.sub(r"[^A-Za-z0-9_.-]+", "-", reason).strip("-") or "release-reset"
    rescue_dir = methodology_rescue_state_dir() / "private-adapters" / f"{stamp}-{os.getpid()}-{safe_reason}"
    copied: list[tuple[str, Path]] = []
    try:
        for rel in paths:
            source = repo / rel
            if not source.exists() or not source.is_file():
                continue
            dest = rescue_dir / rel
            dest.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(source, dest)
            copied.append((rel, dest))
    except OSError as exc:
        return [], f"private adapter preservation failed before reset: {exc}"
    if not copied:
        return [], ""
    return copied, f"; preserved private source adapters at {rescue_dir}"


def restore_private_source_adapters_after_reset(
    copied: list[tuple[str, Path]],
    repo: Path | None = None,
) -> tuple[bool, str]:
    repo = repo or canonical_methodology_repo()
    if not copied:
        return True, ""
    restored: list[str] = []
    for rel, source in copied:
        dest = repo / rel
        if dest.exists():
            continue
        try:
            dest.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(source, dest)
            restored.append(rel)
        except OSError as exc:
            return False, f"; failed to restore private source adapter {rel} from {source}: {exc}"
    if not restored:
        return True, ""
    return True, f"; restored private source adapters: {', '.join(restored)}"


def methodology_git_path(path_name: str) -> Path | None:
    canonical = canonical_methodology_repo()
    git_path = run_git(canonical, ["rev-parse", "--git-path", path_name])
    if not git_path or git_path == "unavailable":
        return None
    path = Path(git_path)
    return path if path.is_absolute() else canonical / path


def methodology_release_main_pre_commit_hook() -> str:
    return "\n".join(
        [
            "#!/bin/sh",
            "# Generated by minervit-methodology. Protects release main from local commits.",
            "set -u",
            f'case "${{{METHODOLOGY_MAIN_COMMIT_OVERRIDE_ENV}:-}}" in',
            "  1|true|TRUE|yes|YES)",
            "    exit 0",
            "    ;;",
            "esac",
            'branch="$(git branch --show-current 2>/dev/null || true)"',
            'if [ "$branch" = "main" ]; then',
            "  printf '%s\\n' 'Minervit methodology release main is protected from local commits.' >&2",
            "  printf '%s\\n' 'Create a feature branch and merge through GitHub PR instead.' >&2",
            f"  printf '%s\\n' 'Break-glass only: set {METHODOLOGY_MAIN_COMMIT_OVERRIDE_ENV}=1 for this single commit.' >&2",
            "  printf '%s\\n' 'If local main already diverged, run: bin/tautline repair-methodology-main' >&2",
            "  exit 1",
            "fi",
            "exit 0",
            "",
        ]
    )


def install_methodology_release_guards() -> str:
    if running_from_installed_package():
        # Same standdown, same rationale as update_methodology_repo: this writer sets
        # `git config --local pull.ff only` and installs hooks INTO the canonical checkout,
        # which in package mode is at best a leftover the wheel never executes (PP-R1-P1-1).
        return "skipped - installed package runtime; no methodology checkout to guard"
    canonical = canonical_methodology_repo()
    if run_git(canonical, ["rev-parse", "--is-inside-work-tree"]) != "true":
        return "skipped - methodology checkout is not a Git worktree"
    code, _stdout, stderr = run_command(["git", "-C", str(canonical), "config", "--local", "pull.ff", "only"])
    if code != 0:
        return f"failed - could not set pull.ff=only: {stderr or 'git config failed'}"
    hook_path = methodology_git_path("hooks/pre-commit")
    if hook_path is None:
        return "skipped - could not resolve git hooks path"
    hook_path.parent.mkdir(parents=True, exist_ok=True)
    desired = methodology_release_main_pre_commit_hook()
    head = run_git(canonical, ["rev-parse", "HEAD"])
    if hook_path.exists():
        existing = hook_path.read_text(encoding="utf-8", errors="replace")
        if existing == desired:
            return f"already ok - {hook_path}"
        if "Generated by minervit-methodology. Protects release main from local commits." not in existing:
            stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
            preserved = hook_path.with_name(f"{hook_path.name}.pre-minervit-{stamp}")
            shutil.move(str(hook_path), str(preserved))
            hook_path.write_text(desired, encoding="utf-8")
            hook_path.chmod(0o755)
            detail = f"installed - {hook_path}; preserved previous hook at {preserved}"
            append_methodology_write_journal(
                "install-methodology-release-guards", head, head, "ok", detail=detail
            )
            return detail
    hook_path.write_text(desired, encoding="utf-8")
    hook_path.chmod(0o755)
    detail = f"installed - {hook_path}"
    # Journaled only when it CHANGED something: this runs on every launch, and an "already ok"
    # entry per lane per launch would bury the mutations the journal exists to attribute.
    append_methodology_write_journal(
        "install-methodology-release-guards", head, head, "ok", detail=detail
    )
    return detail


def preserve_release_branch_pointer(branch_name: str, upstream_ref: str, reason: str) -> tuple[bool, str]:
    """Preserve the prior release branch on a rescue ref before a reset --hard to upstream.

    Returns (preserved_ok, detail). `preserved_ok` is False ONLY when a preservation that was
    genuinely required (local release branch carries unique commits) could not be created -- the caller must
    then abort before resetting so divergent work is never dropped. Nothing-to-preserve (main equals
    or is an ancestor of upstream) returns (True, "")."""
    canonical = canonical_methodology_repo()
    branch_ref = f"refs/heads/{branch_name}"
    main_sha = run_git(canonical, ["rev-parse", branch_ref])
    upstream_sha = run_git(canonical, ["rev-parse", upstream_ref])
    if main_sha in ("unavailable", upstream_sha):
        return True, ""
    # Preserve only when local main carries commits the upstream does not. A main that is merely
    # behind/fast-forwardable to upstream (an ancestor of it) has nothing unique to rescue, so a
    # reset --hard loses nothing and a rescue ref would be pure pollution -- it later trips the
    # rescue-ref drift detector and, on an intentionally-non-main checkout, contributed to a lane
    # wedge (RCA: non-main auto-rescue phantom ref). Only code==0 (definitely an ancestor) skips;
    # an error (code not in {0,1}) falls through to preserve so divergent work is never dropped.
    ancestor_code, _stdout, _stderr = run_command(
        ["git", "-C", str(canonical), "merge-base", "--is-ancestor", branch_ref, upstream_ref]
    )
    if ancestor_code == 0:
        return True, ""
    short_main = run_git(canonical, ["rev-parse", "--short", branch_ref])
    stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    safe_reason = re.sub(r"[^A-Za-z0-9_.-]+", "-", reason).strip("-") or branch_name
    rescue_branch = f"minervit-local-rescue/{stamp}-{os.getpid()}-{safe_reason}-{short_main}"
    code, stdout, stderr = run_command(["git", "-C", str(canonical), "branch", rescue_branch, branch_ref])
    if code != 0:
        return False, f"; warning: could not preserve prior {branch_name} before reset: {stderr or stdout or 'command failed'}"
    return True, f"; preserved prior {branch_name} at {rescue_branch}"


def preserve_release_main_pointer(upstream_ref: str, reason: str) -> tuple[bool, str]:
    return preserve_release_branch_pointer("main", upstream_ref, reason)


def switch_release_branch(
    upstream_ref: str,
    branch_name: str,
    reason: str,
    preserve_existing_main: bool = True,
) -> tuple[bool, str]:
    canonical = canonical_methodology_repo()
    preserved_detail = ""
    if preserve_existing_main:
        # Fail closed: if a required preservation could not be created, abort before reset --hard so
        # divergent local main commits are never dropped (Codex P2: preservation was fail-open).
        preserved_ok, preserved_detail = preserve_release_branch_pointer(branch_name, upstream_ref, reason)
        if not preserved_ok:
            return False, f"auto-rescue aborted before reset because prior release {branch_name} could not be preserved{preserved_detail}"
    copied_private_adapters, private_adapter_detail = copy_private_source_adapters_for_reset(reason=reason)
    if private_adapter_detail.startswith("private adapter preservation failed"):
        return False, private_adapter_detail
    code, stdout, stderr = run_command(["git", "-C", str(canonical), "switch", branch_name])
    if code != 0:
        code, stdout, stderr = run_command(["git", "-C", str(canonical), "switch", "-c", branch_name, upstream_ref])
        if code != 0:
            return False, f"auto-rescue failed during return to release {branch_name}: {stderr or stdout or 'command failed'}"
    code, stdout, stderr = run_command(["git", "-C", str(canonical), "reset", "--hard", upstream_ref])
    if code != 0:
        return False, f"auto-rescue failed during reset release {branch_name} to upstream: {stderr or stdout or 'command failed'}"
    restored_ok, restore_detail = restore_private_source_adapters_after_reset(copied_private_adapters)
    if not restored_ok:
        return False, f"auto-rescue reset release {branch_name} but private adapter restore failed{restore_detail}{private_adapter_detail}"
    return True, preserved_detail + private_adapter_detail + restore_detail


def switch_release_main(upstream_ref: str, reason: str, preserve_existing_main: bool = True) -> tuple[bool, str]:
    return switch_release_branch(upstream_ref, "main", reason, preserve_existing_main)


def methodology_main_divergence(upstream_ref: str, branch_name: str = "main") -> tuple[int, int] | None:
    canonical = canonical_methodology_repo()
    code, stdout, _stderr = run_command(
        ["git", "-C", str(canonical), "rev-list", "--left-right", "--count", f"refs/heads/{branch_name}...{upstream_ref}"]
    )
    if code != 0:
        return None
    parts = stdout.split()
    if len(parts) != 2:
        return None
    try:
        return int(parts[0]), int(parts[1])
    except ValueError:
        return None


def repair_methodology_release_main_checkout(channel: str = "stable") -> tuple[bool, str]:
    canonical = canonical_methodology_repo()
    release_branch = framework_channel_branch(channel)
    upstream = methodology_release_upstream(channel)
    if upstream is None:
        return False, f"cannot repair methodology {release_branch} because release origin/{release_branch} is not configured"
    remote, branch, upstream_ref = upstream
    code, stdout, stderr = run_command(["git", "-C", str(canonical), "fetch", remote, branch])
    if code != 0:
        return False, f"cannot repair methodology {release_branch} because release fetch failed: {stderr or stdout or 'command failed'}"
    if run_git(canonical, ["rev-parse", upstream_ref]) == "unavailable":
        return False, f"cannot repair methodology {release_branch} because release ref is unavailable: {upstream_ref}"
    dirty = run_git(canonical, ["status", "--porcelain"])
    if dirty == "unavailable":
        return False, f"cannot repair methodology {release_branch} because git status is unavailable"
    if dirty:
        rescued, detail, _rescue_branch = rescue_methodology_local_changes(upstream)
        return rescued, detail
    branch_name = run_git(canonical, ["branch", "--show-current"])
    if branch_name not in (release_branch, "unavailable"):
        rescued, detail, _rescue_branch = rescue_methodology_non_main_checkout(branch_name or "detached HEAD", channel)
        return rescued, detail
    divergence = methodology_main_divergence(upstream_ref, release_branch)
    if divergence is None:
        return False, f"cannot repair methodology {release_branch} because local-vs-origin divergence could not be measured"
    ahead, behind = divergence
    if ahead == 0 and behind == 0:
        guard = install_methodology_release_guards()
        return True, f"release main already matches {upstream_ref}; {guard}"
    old_head = run_git(canonical, ["rev-parse", "refs/heads/main"])
    # Verify-before-advance (sec-trust-exec-1): refuse before the switch/reset so a denied upstream
    # never advances the release main to unverified code.
    refusal = gate_methodology_upstream_advance(run_git(canonical, ["rev-parse", upstream_ref]))
    if refusal is not None:
        append_methodology_write_journal(
            "repair-methodology-main", old_head, old_head, "held", detail=refusal
        )
        return False, refusal
    switched, switch_detail = switch_release_branch(upstream_ref, release_branch, "divergent-main", preserve_existing_main=ahead > 0)
    if not switched:
        append_methodology_write_journal(
            "repair-methodology-main", old_head, old_head, "failed", detail=switch_detail
        )
        return False, switch_detail
    new_head = run_git(canonical, ["rev-parse", "HEAD"])
    guard = install_methodology_release_guards()
    if ahead > 0:
        detail = (
            f"rescued divergent methodology main ahead={ahead} behind={behind}{switch_detail}; "
            f"reset {old_head[:12]} -> {new_head[:12]}; {guard}"
        )
    else:
        detail = (
            f"fast-forwarded methodology main behind={behind}; "
            f"reset {old_head[:12]} -> {new_head[:12]}; {guard}"
        )
    append_methodology_write_journal(
        "repair-methodology-main", old_head, new_head, "ok", detail=detail
    )
    return True, detail


def rescue_methodology_local_changes(upstream: tuple[str, str, str] | None = None) -> tuple[bool, str, str]:
    canonical = canonical_methodology_repo()
    upstream = upstream or methodology_upstream()
    if upstream is None:
        return False, "cannot auto-rescue local changes because no supported upstream is configured", ""
    remote, branch, upstream_ref = upstream
    code, stdout, stderr = run_command(["git", "-C", str(canonical), "fetch", remote, branch])
    if code != 0:
        return False, f"cannot auto-rescue local changes because upstream fetch failed: {stderr or stdout or 'command failed'}", ""
    upstream_sha = run_git(canonical, ["rev-parse", upstream_ref])
    if upstream_sha == "unavailable":
        return False, f"cannot auto-rescue local changes because upstream ref is unavailable: {upstream_ref}", ""
    # Verify-before-advance (sec-trust-exec-1): the fetch above did not move the checkout; refuse now,
    # before any rescue branch / commit / reset, so a denied upstream never advances the working tree.
    refusal = gate_methodology_upstream_advance(upstream_sha)
    if refusal is not None:
        return False, refusal, ""
    original_branch = run_git(canonical, ["branch", "--show-current"])
    old_head = run_git(canonical, ["rev-parse", "HEAD"])
    short_head = run_git(canonical, ["rev-parse", "--short", "HEAD"])
    stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    rescue_branch = f"minervit-local-rescue/{stamp}-{os.getpid()}-{short_head}"

    def _failed(detail: str) -> tuple[bool, str, str]:
        append_methodology_write_journal(
            "rescue-methodology-local-changes", old_head, old_head, "failed", detail=detail
        )
        return False, detail, rescue_branch

    code, stdout, stderr = run_command(["git", "-C", str(canonical), "switch", "-c", rescue_branch])
    if code != 0:
        return _failed(f"auto-rescue failed during create rescue branch: {stderr or stdout or 'command failed'}")
    code, stdout, stderr = run_command(["git", "-C", str(canonical), "add", "-u"])
    if code != 0:
        return _failed(f"auto-rescue failed during stage tracked methodology changes: {stderr or stdout or 'command failed'}")
    staged_code, _staged_stdout, staged_stderr = run_command(["git", "-C", str(canonical), "diff", "--cached", "--quiet"])
    if staged_code not in (0, 1):
        return _failed(f"auto-rescue failed during staged change check: {staged_stderr or 'command failed'}")
    if staged_code == 1:
        commit_command = [
            "git",
            "-C",
            str(canonical),
            "-c",
            "user.name=Minervit Methodology",
            "-c",
            "user.email=methodology@minervit.local",
            "commit",
            "-m",
            "chore: preserve local methodology changes before auto-sync",
        ]
        code, stdout, stderr = run_command(commit_command)
        if code != 0:
            run_command(["git", "-C", str(canonical), "reset"])
            return _failed(f"auto-rescue failed during commit rescue branch: {stderr or stdout or 'command failed'}")
    untracked_dir, untracked_error = move_untracked_methodology_files(rescue_branch)
    if untracked_error:
        return _failed(f"auto-rescue failed while preserving untracked files: {untracked_error}")
    switched, switch_detail = switch_release_main(upstream_ref, "local-changes", preserve_existing_main=original_branch != "main")
    if not switched:
        return _failed(switch_detail)
    new_head = run_git(canonical, ["rev-parse", "HEAD"])
    untracked_detail = f"; untracked files moved to {untracked_dir}" if untracked_dir else ""
    if old_head != "unavailable" and new_head != "unavailable":
        detail = f"rescued local changes to {rescue_branch}{untracked_detail}{switch_detail}; updated {old_head[:12]} -> {new_head[:12]}"
    else:
        detail = f"rescued local changes to {rescue_branch}{untracked_detail}{switch_detail}; reset main to {upstream_ref}"
    append_methodology_write_journal(
        "rescue-methodology-local-changes", old_head, new_head, "ok", detail=detail
    )
    return True, detail, rescue_branch


def rescue_methodology_non_main_checkout(branch_display: str, channel: str = "stable") -> tuple[bool, str, str]:
    canonical = canonical_methodology_repo()
    release_branch = framework_channel_branch(channel)
    upstream = methodology_release_upstream(channel)
    if upstream is None:
        return False, f"cannot auto-rescue non-main methodology checkout because release origin/{release_branch} is not configured", ""
    remote, branch, upstream_ref = upstream
    code, stdout, stderr = run_command(["git", "-C", str(canonical), "fetch", remote, branch])
    if code != 0:
        return False, f"cannot auto-rescue non-main methodology checkout because release fetch failed: {stderr or stdout or 'command failed'}", ""
    upstream_sha = run_git(canonical, ["rev-parse", upstream_ref])
    if upstream_sha == "unavailable":
        return False, f"cannot auto-rescue non-main methodology checkout because release ref is unavailable: {upstream_ref}", ""
    dirty = run_git(canonical, ["status", "--porcelain"])
    if dirty == "unavailable":
        return False, "cannot auto-rescue non-main methodology checkout because status is unavailable", ""
    if dirty:
        rescued, detail, rescue_branch = rescue_methodology_local_changes(upstream)
        if not rescued:
            return False, detail, rescue_branch
        return True, f"non-main checkout {branch_display}; {detail}", rescue_branch
    old_head = run_git(canonical, ["rev-parse", "HEAD"])
    # Verify-before-advance (sec-trust-exec-1): refuse before the switch/reset so a denied upstream
    # never advances a clean non-release checkout to unverified code.
    refusal = gate_methodology_upstream_advance(upstream_sha)
    if refusal is not None:
        return False, refusal, ""
    switched, switch_detail = switch_release_branch(upstream_ref, release_branch, "non-main")
    if not switched:
        append_methodology_write_journal(
            "rescue-methodology-non-main", old_head, old_head, "failed", detail=switch_detail
        )
        return False, switch_detail, ""
    new_head = run_git(canonical, ["rev-parse", "HEAD"])
    if old_head != "unavailable" and new_head != "unavailable":
        detail = (
            f"switched clean methodology checkout from non-release branch {branch_display} ({old_head[:12]}) "
            f"to release {release_branch} {new_head[:12]}; branch preserved{switch_detail}"
        )
    else:
        detail = (
            f"switched clean methodology checkout from non-release branch {branch_display} "
            f"to release {release_branch}{switch_detail}"
        )
    append_methodology_write_journal(
        "rescue-methodology-non-main", old_head, new_head, "ok", detail=detail
    )
    return True, detail, ""


def invoked_from_methodology_repo() -> bool:
    """True when the cwd is inside EITHER methodology root (containment must cover both).

    This suppresses project-startup auto-rescue when a lane is already standing in the framework
    itself. Post-migration those are two directories -- the snapshot we execute and the canonical
    checkout sync mutates -- and missing either one would let an operator working inside one of
    them get their own checkout auto-rescued out from under them.
    """
    try:
        cwd = Path.cwd().resolve(strict=False)
    except OSError:
        return False
    return path_is_under(cwd, REPO_ROOT) or path_is_under(cwd, canonical_methodology_repo())


def methodology_repo_slugs() -> set[str]:
    return {METHODOLOGY_REPO_SLUG.lower(), *(slug.lower() for slug in LEGACY_METHODOLOGY_REPO_SLUGS)}


def adapter_is_methodology_repo(data: dict) -> bool:
    return str(data.get("repo", "")).strip().lower() in methodology_repo_slugs()


def should_auto_rescue_methodology_for_project_startup() -> bool:
    if util_module().resolve_env("MINERVIT_METHODOLOGY_DISABLE_AUTO_RESCUE") == "1":
        return False
    return not invoked_from_methodology_repo()


def methodology_checkout_hygiene_warning(allow_non_main: bool = False) -> str:
    canonical = canonical_methodology_repo()
    if run_git(canonical, ["rev-parse", "--is-inside-work-tree"]) != "true":
        return ""
    branch = run_git(canonical, ["branch", "--show-current"])
    if (
        branch not in ("main", "unavailable")
        and not allow_non_main
        and util_module().resolve_env("MINERVIT_METHODOLOGY_ALLOW_NON_MAIN") != "1"
    ):
        branch_display = branch or "detached HEAD"
        return (
            f"methodology checkout is on {branch_display}, not main; "
            "manual or pinned framework updates will not auto-rescue this checkout"
        )
    dirty = run_git(canonical, ["status", "--porcelain"])
    if dirty not in ("", "unavailable"):
        return (
            "methodology checkout has local changes; "
            "manual or pinned framework updates will not auto-rescue this checkout"
        )
    return ""


# --- The immutable snapshot store ----------------------------------------------------------
# Lanes must not execute a tree that `sync-methodology` is concurrently rewriting. So sync mutates
# only the canonical checkout, and each trust-verified commit is EXPORTED into
# <store>/<short12-sha>/ -- a tree with no .git, made physically read-only -- behind an atomically
# swapped `current` symlink. Three invariants hold the design up:
#
#   immutable   a published snapshot is 0444/0555 files and 0555 dirs. Immutability is enforced by
#               the filesystem, not by discipline: any write path we failed to retarget at the
#               canonical checkout fails LOUDLY here instead of silently corrupting the tree that
#               other lanes are mid-execution on.
#   atomic      a snapshot is visible complete or not at all (stage in .tmp/, publish by rename),
#               and every failure path leaves the store exactly as it found it.
#   safe prune  retention never deletes a tree something is still executing: the current target,
#               a live pin, a recently-current snapshot, or the newest `keep`.
#
# PERMISSIONS AND rename(2) -- the trap that shapes the code below. Once a tree is 0555 you can
# neither move it to a new parent (rename must update its `..` entry, which needs write permission
# on the directory being moved: EACCES) nor delete it (unlinking a child needs write permission on
# its 0555 parent). shutil.rmtree(..., ignore_errors=True) fails at that SILENTLY. So: the
# read-only pass deliberately leaves the tree's ROOT writable and the caller locks it after the
# publish rename; prune restores the root's write bit before its rename; and every removal of a
# tree that may already be read-only goes through _rmtree_force. Nothing here may use bare rmtree.

SNAPSHOT_PIN_SCHEMA = "tautline-snapshot-pin/v1"
SNAPSHOT_KEEP_ENV = "MINERVIT_METHODOLOGY_SNAPSHOT_KEEP"
SNAPSHOT_PIN_TTL_HOURS_ENV = "MINERVIT_METHODOLOGY_SNAPSHOT_PIN_TTL_HOURS"
SNAPSHOT_DEFAULT_KEEP = 3
SNAPSHOT_DEFAULT_PIN_TTL_HOURS = 72
_SNAPSHOT_DIR_PATTERN = re.compile(r"[0-9a-f]{12}")


def _make_snapshot_read_only(root: Path) -> None:
    """Files 0444 (0555 when executable), directories 0555, applied bottom-up.

    Bottom-up matters: locking a directory before its contents would leave the file pass unable to
    walk into it.

    The ROOT directory's own mode is deliberately LEFT ALONE, and that is not an oversight. A
    directory that moves to a new parent has its `..` entry rewritten by rename(2), which requires
    write permission ON THE DIRECTORY BEING MOVED -- so a 0555 staging root cannot be published
    into the store at all (EACCES; verified on darwin, documented on linux). Callers lock the root
    themselves once the tree is in its final place: materialize after the publish rename, prune
    (transitively) never, because it is tearing the tree down.
    """
    for dirpath, _dirnames, filenames in os.walk(root, topdown=False):
        for name in filenames:
            path = Path(dirpath) / name
            path.chmod(0o555 if path.stat().st_mode & 0o111 else 0o444)
        if Path(dirpath) != root:
            Path(dirpath).chmod(0o555)


def _rmtree_force(path: Path) -> None:
    """Delete a tree that may be read-only: restore the write bits top-down, then remove.

    The ONLY sanctioned way to remove a snapshot or a staging tree. `shutil.rmtree` alone cannot
    unlink a child of a 0555 directory, and with ignore_errors=True it does not even complain --
    it just leaves the tree behind, so a leak looks exactly like a clean exit.
    """
    for dirpath, _dirnames, filenames in os.walk(path):
        try:
            Path(dirpath).chmod(0o755)
        except OSError:
            continue
        for name in filenames:
            try:
                (Path(dirpath) / name).chmod(0o644)
            except OSError:
                pass
    shutil.rmtree(path, ignore_errors=True)


def _reject_unsafe_tar_members(archive: tarfile.TarFile) -> None:
    """Refuse anything that could write outside the staging tree, or point out of it afterwards.

    Links are rejected outright. The 3.12 `data` filter CONTAINS link targets; this check has to
    hold on 3.10/3.11 too, where nothing contains them, and a link whose target escapes the tree
    would let the read-only chmod pass -- or a later exec -- follow it out of the store. The
    release tree ships no links at all (`git ls-files -s` has no 120000 entries), so rejecting them
    costs nothing and closes the class.
    """
    for member in archive.getmembers():
        if member.islnk() or member.issym():
            raise tarfile.TarError(f"refusing link archive member: {member.name}")
        if member.name.startswith("/") or ".." in Path(member.name).parts:
            raise tarfile.TarError(f"refusing unsafe archive member: {member.name}")


def _safe_extract_methodology_tar(tar_path: Path, staging: Path) -> None:
    """Containment-checked extraction of a `git archive` export.

    extractall(filter="data") needs Python 3.12+ and this repo supports 3.10, so the member check
    above runs on EVERY interpreter and the filter is layered on top where it exists.
    """
    with tarfile.open(tar_path) as archive:
        _reject_unsafe_tar_members(archive)
        if sys.version_info >= (3, 12):
            archive.extractall(staging, filter="data")
        else:
            archive.extractall(staging)  # members validated above


_SNAPSHOT_STORE_WARNINGS: set[str] = set()


def warn_snapshot_store_unreadable(operation: str, exc: OSError) -> None:
    """Announce a store read that could not complete -- once per distinct failure per process.

    Reading the store must NEVER raise past its helper. The store is an optimization, never a
    dependency: "I cannot even look at it" has to answer the same as "there is nothing there" --
    no usable current, run the canonical checkout -- because a launch that dies on a chmod, an ACL,
    a stale mount or a full disk is the one failure mode the store was built to be incapable of.
    (A raise here does not merely fail a command: under the launcher's no-dead-ends policy a failed
    sync gate escalates to a repair session, so an unreadable store would drive the whole fleet into
    repair instead of quietly degrading.)

    But it must not degrade SILENTLY either, or a store that has been broken for weeks looks exactly
    like a machine that never enabled one. Deduplicated because a single sync reads the store
    several times over (gate skip, heal, stamp, pin refresh) and four copies of one line say nothing
    the first does not.
    """
    detail = f"methodology_snapshot: store unreadable - {operation}: {exc}"
    if detail in _SNAPSHOT_STORE_WARNINGS:
        return
    _SNAPSHOT_STORE_WARNINGS.add(detail)
    print(detail, flush=True)


def _snapshot_dir_manifest(dest: Path) -> dict | None:
    """The manifest of a snapshot directory in the store (vs. snapshot_manifest(), which reads the
    one we are EXECUTING). A dir with no readable, schema-matching manifest is not a snapshot: it
    is a partial publish or a foreign directory, and must never be reused or trusted."""
    try:
        data = json.loads((dest / SNAPSHOT_MANIFEST_NAME).read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return None
    if not isinstance(data, dict) or data.get("schema") != SNAPSHOT_MANIFEST_SCHEMA:
        return None
    return data


def _snapshot_is_executable(snapshot_dir: Path) -> bool:
    """Can anything actually RUN this snapshot?

    The one predicate that decides whether a snapshot is usable, and it is not the manifest. The
    shim asks `[ -x "$SNAPSHOT_CURRENT/bin/tautline" ]`, the launcher asks the same of
    `$SNAPSHOT_STORE/current/bin/tautline`, and both fall through to the canonical checkout when the
    answer is no. Python has to ask exactly that question, of exactly that path, or the two halves
    of one machine disagree about whether the store works: a manifest is an INDEX ENTRY, and a
    `current` whose .snapshot-meta.json is pristine while its CLI has been deleted is not a healthy
    snapshot, it is a trap that every shim on the machine execs into and every shim falls out of.
    """
    try:
        cli_path = snapshot_dir / "bin" / CLI_NAME
        return cli_path.is_file() and os.access(cli_path, os.X_OK)
    except OSError:
        return False


def _retire_store_snapshot_dir(snap: Path) -> bool:
    """Take a published snapshot out of the store and destroy it. True when it is gone.

    Retiring is the ATOMIC step (a rename out of the store, under the caller's store lock); the
    tree removal that follows is bookkeeping. A published root is 0555 and rename(2) needs write
    permission on a directory it re-parents, so the root must be unsealed first or every retirement
    silently no-ops with EACCES -- and _rmtree_force, never rmtree, is the only thing that can
    remove the 0555 tree once it has been moved aside.
    """
    store = snap.parent
    doomed = store / ".tmp" / f"deleting-{snap.name}-{os.getpid()}"
    try:
        (store / ".tmp").mkdir(parents=True, exist_ok=True)
        snap.chmod(0o755)
        os.rename(snap, doomed)
    except OSError:
        return False
    _rmtree_force(doomed)
    return True


def _store_snapshot_dirs(store: Path) -> list[Path]:
    """Published snapshots, oldest -> newest.

    materializedAt is second-granularity, so mtime breaks ties and the (stable, arbitrary) name
    breaks the rest. Anything without a valid manifest is not listed -- it is not a snapshot.

    An unreadable store lists as EMPTY, never as an exception: same contract as
    methodology_snapshot_current_dir(). The guard has to span the stat()s as well as the iterdir(),
    and not just the iterdir() it once covered -- listing a directory needs only READ permission
    while stat-ing what is in it needs SEARCH, so a store with one but not the other enumerated
    happily and then raised EACCES on the first sort key.
    """
    keyed: list[tuple[str, float, str, Path]] = []
    try:
        if not store.is_dir():
            return []
        for path in store.iterdir():
            if not _SNAPSHOT_DIR_PATTERN.fullmatch(path.name):
                continue
            manifest = _snapshot_dir_manifest(path)
            if manifest is None:
                continue
            # str(): a hand-corrupted manifest with a non-string materializedAt would otherwise
            # take out the whole sort with a TypeError -- another store read crashing the CLI.
            keyed.append(
                (str(manifest.get("materializedAt", "")), path.stat().st_mtime, path.name, path)
            )
    except OSError as exc:
        warn_snapshot_store_unreadable("cannot list snapshots", exc)
        return []
    keyed.sort(key=lambda item: item[:3])
    return [item[3] for item in keyed]


def materialize_methodology_snapshot(
    repo: Path, commit: str, channel: str
) -> tuple[Path | None, str]:
    """Export <commit> of <repo> into an immutable store directory. Atomic publish, idempotent.

    Returns (snapshot_dir, detail) or (None, why). NEVER raises: a broken/unwritable store must
    degrade to "keep running the code we already have", never fail an operator's session start.
    """
    store = methodology_snapshot_store_root()
    # --verify ... ^{commit}, not a bare rev-parse: a bare `rev-parse <40-hex>` happily echoes back
    # a sha that names no object in this repo, and we would go on to name a store directory after a
    # commit that does not exist and only discover it when `git archive` failed.
    full_sha = run_git(repo, ["rev-parse", "--verify", "--quiet", f"{commit}^{{commit}}"])
    if full_sha in ("", "unavailable") or not re.fullmatch(r"[0-9a-f]{40}", full_sha):
        return None, f"could not resolve {commit} in {repo}"
    dest = store / full_sha[:12]
    staging = store / ".tmp" / f"mat-{full_sha[:12]}-{os.getpid()}"
    tar_path = store / ".tmp" / f"mat-{full_sha[:12]}-{os.getpid()}.tar"
    try:
        (store / ".tmp").mkdir(parents=True, exist_ok=True)
        lock_path = store / ".store-lock"
        with lock_path.open("a", encoding="utf-8") as lock_file, advisory_flock(lock_file):
            existing = _snapshot_dir_manifest(dest)
            if existing and existing.get("commit") == full_sha:
                if _snapshot_is_executable(dest):
                    return dest, f"reused existing snapshot {full_sha[:12]}"
                # The manifest matches but the tree cannot be run. Reusing it would hand the caller
                # back the very trap it asked us to repair -- heal would swap `current` onto a
                # snapshot with no CLI and report success. Retire it and publish a whole one.
                if not _retire_store_snapshot_dir(dest):
                    return None, (
                        f"snapshot {full_sha[:12]} is missing bin/{CLI_NAME} and could not be "
                        "replaced"
                    )
            if staging.exists():
                # A crash between the read-only pass and the publish rename leaves a 0555 tree
                # under this exact name (same sha, and pids recycle). Plain rmtree cannot remove
                # it, so materialization would be wedged for as long as the leak survives.
                _rmtree_force(staging)
            staging.mkdir(parents=True)
            # The unlink has to cover the ARCHIVE too, not just the extraction: `git archive -o`
            # creates its output file before it writes, so a failure partway (disk-full is the
            # realistic one) leaves a partial tar behind. prune never walks .tmp -- it only visits
            # manifest-bearing snapshot dirs, pins and history -- so a tar orphaned here is orphaned
            # permanently, and the disk-full case would leak another one on every retry.
            try:
                code, _stdout, stderr = run_command(
                    [
                        "git", "-C", str(repo), "archive", "--format=tar",
                        "-o", str(tar_path), full_sha,
                    ]
                )
                if code != 0:
                    _rmtree_force(staging)
                    return None, f"git archive failed: {stderr or 'unknown error'}"
                try:
                    _safe_extract_methodology_tar(tar_path, staging)
                except (OSError, tarfile.TarError) as exc:
                    _rmtree_force(staging)
                    return None, f"snapshot extraction failed: {exc}"
            finally:
                tar_path.unlink(missing_ok=True)
            if not (staging / "bin" / CLI_NAME).is_file() or not (staging / "VERSION").is_file():
                _rmtree_force(staging)
                return None, (
                    f"exported tree is missing bin/{CLI_NAME} or VERSION; refusing to publish"
                )
            (staging / "bin" / CLI_NAME).chmod(0o755)
            manifest = {
                "schema": SNAPSHOT_MANIFEST_SCHEMA,
                "commit": full_sha,
                "shortCommit": full_sha[:12],
                "version": (staging / "VERSION").read_text(encoding="utf-8").strip(),
                "channel": channel,
                "materializedAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
                "canonicalRepo": str(repo.resolve()),
                "materializedBy": {"pid": os.getpid(), "pluginVersion": methodology_version()},
            }
            util_module().write_text_atomic(
                staging / SNAPSHOT_MANIFEST_NAME, json.dumps(manifest, indent=2) + "\n"
            )
            _make_snapshot_read_only(staging)
            try:
                os.rename(staging, dest)
            except OSError as exc:
                # The staging tree is READ-ONLY by now -- _rmtree_force, never rmtree, or this
                # leaks a 0555 tree per attempt and no one ever finds out.
                _rmtree_force(staging)
                winner = _snapshot_dir_manifest(dest)
                if winner and winner.get("commit") == full_sha:
                    return dest, (
                        f"reused snapshot {full_sha[:12]} published by a concurrent process"
                    )
                return None, f"could not publish snapshot {full_sha[:12]}: {exc}"
            try:
                dest.chmod(0o555)  # the last write bit: the tree is in place, seal its root
            except OSError:
                pass
    except OSError as exc:
        _rmtree_force(staging)
        return None, f"snapshot store unavailable: {exc}"
    append_methodology_write_journal(
        "snapshot.materialize", full_sha, full_sha, "ok", detail=str(dest)
    )
    return dest, f"materialized {full_sha[:12]}"


def swap_methodology_snapshot_current(snapshot_dir: Path) -> tuple[bool, str]:
    """Point <store>/current at a snapshot atomically (symlink + os.replace).

    os.replace on a symlink swaps the LINK, never its target, so no lane ever observes a missing
    or half-written `current`: it sees the old snapshot, then the new one.
    """
    store = snapshot_dir.parent
    tmp_link = store / f".current-tmp-{os.getpid()}"
    try:
        if tmp_link.is_symlink() or tmp_link.exists():
            tmp_link.unlink()
        os.symlink(snapshot_dir.name, tmp_link)  # relative: the store stays relocatable
        os.replace(tmp_link, store / "current")
    except OSError as exc:
        try:
            tmp_link.unlink(missing_ok=True)
        except OSError:
            pass
        return False, f"could not swap current symlink: {exc}"
    # The swap history is what protects a snapshot that is no longer current but is still being
    # executed by a session that adopted it while it was (see prune's TTL window).
    history_path = store / "history.jsonl"
    try:
        with history_path.open("a", encoding="utf-8") as handle, advisory_flock(handle):
            handle.write(
                json.dumps(
                    {
                        "ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
                        "snapshot": snapshot_dir.name,
                    }
                )
                + "\n"
            )
    except OSError:
        pass
    append_methodology_write_journal("snapshot.swap-current", "", snapshot_dir.name, "ok")
    return True, f"current -> {snapshot_dir.name}"


def methodology_snapshot_pin_path(lane_path: Path) -> Path:
    return methodology_snapshot_store_root() / "pins" / f"{instrumentation_lane_id(lane_path)}.pin"


def write_methodology_snapshot_pin(lane_path: Path, snapshot_dir: Path) -> Path:
    """Declare "this lane is executing this snapshot; do not delete it".

    The pin's MTIME is its heartbeat -- prune ages pins out past the TTL, so a lane that dies
    without cleaning up cannot protect a snapshot forever.
    """
    pin_path = methodology_snapshot_pin_path(lane_path)
    pin_path.parent.mkdir(parents=True, exist_ok=True)
    util_module().write_text_atomic(
        pin_path,
        json.dumps(
            {
                "schema": SNAPSHOT_PIN_SCHEMA,
                "snapshot": snapshot_dir.name,
                "lanePath": str(lane_path),
                "pid": os.getpid(),
                "createdAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
            },
            indent=2,
        )
        + "\n",
    )
    return pin_path


def snapshot_pin_ttl_seconds() -> int:
    raw = util_module().resolve_env(SNAPSHOT_PIN_TTL_HOURS_ENV)
    hours = int(raw) if raw.isdigit() else SNAPSHOT_DEFAULT_PIN_TTL_HOURS
    return hours * 3600


def prune_methodology_snapshots(keep: int | None = None) -> list[str]:
    """Delete superseded snapshots. Returns the names actually removed.

    A snapshot is protected when ANY of these hold, because each means something may still be
    executing it: it is among the newest `keep`; it is the current target; a lane holds a pin on it
    younger than the TTL; or it was current within the TTL window (a session that adopted it is
    bounded by the same TTL). Everything else is garbage.
    """
    store = methodology_snapshot_store_root()
    if keep is None:
        raw_keep = util_module().resolve_env(SNAPSHOT_KEEP_ENV)
        keep = int(raw_keep) if raw_keep.isdigit() else SNAPSHOT_DEFAULT_KEEP
    ttl_seconds = snapshot_pin_ttl_seconds()
    pruned: list[str] = []
    lock_path = store / ".store-lock"
    try:
        # Inside the guard: is_dir() stats the store, and on an unreadable one that raises rather
        # than answering False. Prune is called from the heal/advance paths, so an escape here
        # fails a launch just as surely as one out of methodology_snapshot_current_dir().
        if not store.is_dir():
            return []
        with lock_path.open("a", encoding="utf-8") as lock_file, advisory_flock(lock_file):
            snaps = _store_snapshot_dirs(store)
            protected = {path.name for path in snaps[-keep:]} if keep > 0 else set()
            current = store / "current"
            if current.is_symlink():
                protected.add(current.resolve(strict=False).name)
            pins_dir = store / "pins"
            now = time.time()
            if pins_dir.is_dir():
                for pin in pins_dir.glob("*.pin"):
                    try:
                        if now - pin.stat().st_mtime > ttl_seconds:
                            pin.unlink()
                            continue
                        pin_data = json.loads(pin.read_text(encoding="utf-8"))
                        protected.add(str(pin_data.get("snapshot", "")))
                    except (OSError, json.JSONDecodeError):
                        continue
            history = store / "history.jsonl"
            if history.is_file():
                cutoff = datetime.now(timezone.utc) - timedelta(seconds=ttl_seconds)
                try:
                    lines = history.read_text(encoding="utf-8").splitlines()
                except OSError:
                    lines = []
                for line in lines:
                    try:
                        entry = json.loads(line)
                        when = datetime.strptime(entry["ts"], "%Y-%m-%dT%H:%M:%SZ").replace(
                            tzinfo=timezone.utc
                        )
                    except (json.JSONDecodeError, KeyError, TypeError, ValueError):
                        continue
                    if when >= cutoff:
                        protected.add(str(entry.get("snapshot", "")))
            (store / ".tmp").mkdir(exist_ok=True)
            for snap in snaps:
                if snap.name in protected:
                    continue
                if _retire_store_snapshot_dir(snap):
                    pruned.append(snap.name)
    except OSError as exc:
        warn_snapshot_store_unreadable("prune incomplete", exc)
        return pruned
    if pruned:
        append_methodology_write_journal("snapshot.prune", "", "", "ok", detail=",".join(pruned))
    return pruned


def methodology_snapshot_current_dir() -> Path | None:
    """The snapshot <store>/current points at, or None when absent/dangling/not a snapshot.

    An UNREADABLE store answers None too, loudly. `is_symlink()` stats the path, and a store
    directory that cannot be opened at all (chmod, ACL, dead mount) makes that stat fail with
    EACCES -- which pathlib propagates on some interpreter/platform combinations and swallows on
    others. Every caller here treats None as "no usable snapshot, run the canonical checkout", so
    letting the error escape on the platforms that raise turned the store's one safety promise
    inside out: instead of degrading, an unreadable store BLOCKED the launch.
    """
    current = methodology_snapshot_store_root() / "current"
    try:
        if not current.is_symlink():
            return None
        resolved = current.resolve(strict=False)
    except OSError as exc:
        warn_snapshot_store_unreadable("no usable current, using canonical checkout", exc)
        return None
    return resolved if _snapshot_dir_manifest(resolved) else None


def _published_store_snapshot(candidate: Path) -> Path | None:
    """`candidate` as a snapshot PUBLISHED IN THIS STORE, or None.

    A pin records a snapshot by NAME and prune protects store entries by name, so a pin is only
    protection when the name it carries is one this store can actually collect. A path that is not
    a manifest-bearing <sha12> directory directly under the store root -- a pruned exec root, a
    checkout, a foreign or stale export -- must therefore never reach a pin: it would write a pin
    that guards nothing, which is strictly worse than falling back to `current`.
    """
    store = methodology_snapshot_store_root()
    try:
        resolved = candidate.expanduser().resolve(strict=False)
        if resolved.parent != store.resolve(strict=False):
            return None
        if not _SNAPSHOT_DIR_PATTERN.fullmatch(resolved.name):
            return None
    except OSError as exc:
        warn_snapshot_store_unreadable("cannot resolve the session exec root", exc)
        return None
    return resolved if _snapshot_dir_manifest(resolved) else None


def session_snapshot_exec_root() -> Path | None:
    """The snapshot THIS SESSION is executing, or None when it is not executing one.

    NOT `current`. Session-stickiness is the store's whole point: a session resolves one exec root
    (the launcher does it with `pwd -P` and exports it; the shim re-execs into it) and runs that
    tree for its entire life, precisely so another lane's swap cannot hop it between versions
    mid-flight. `current` is the opposite of that -- a symlink any lane may move at any instant.

    Two sources, in the order they are authoritative:

    1. The running tree itself. When this process IS a snapshot, its own manifest is not an
       assertion about the session, it is the session: no env, no symlink, nothing to race.
    2. The exported exec root. This covers the one case (1) cannot see: an operator-preset
       MINERVIT_METHODOLOGY_CLI keeps the launcher's own CLI on the canonical checkout while every
       hook in the session still execs the exported snapshot -- so the session runs a snapshot that
       this process is not.

    Both are validated against the store, so a stale or pruned root degrades to `current` rather
    than writing a pin that protects nothing.
    """
    if running_from_snapshot():
        own = _published_store_snapshot(REPO_ROOT)
        if own is not None:
            return own
    # Read exactly as the shim and the launcher read it -- the MINERVIT_ spelling, no alias. This
    # is not an operator setting but the session's own inherited exec state, and resolve_env would
    # let a stale TAUTLINE_-spelled shell variable outrank it: Python would then pin one snapshot
    # while the shell half of the same session execs another, which is the very split this
    # function exists to close. tests/test_env_reads_use_resolver.py holds the exemption.
    exported = os.environ.get(METHODOLOGY_EXEC_ROOT_ENV, "").strip()
    if exported:
        return _published_store_snapshot(Path(exported))
    return None


def snapshot_status(args: argparse.Namespace) -> int:
    store = methodology_snapshot_store_root()
    print(f"snapshot_store_root: {store}")
    print(f"snapshot_store_enabled: {'true' if methodology_snapshot_store_enabled() else 'false'}")
    if methodology_snapshot_exec_disabled():
        # Otherwise `enabled: false` on a machine whose store key IS configured reads as a bug.
        # Name the lever that turned it off, so the way back is obvious.
        print(f"snapshot_exec_disabled: true ({METHODOLOGY_DISABLE_SNAPSHOT_EXEC_ENV}=1)")
    current = methodology_snapshot_current_dir()
    print(f"snapshot_store_current: {current.name if current else 'none'}")
    snaps = _store_snapshot_dirs(store)
    print(f"snapshot_store_count: {len(snaps)}")
    for snap in snaps:
        manifest = _snapshot_dir_manifest(snap) or {}
        print(
            f"snapshot: {snap.name} version={manifest.get('version', 'unknown')} "
            f"channel={manifest.get('channel', 'unknown')} "
            f"materialized={manifest.get('materializedAt', 'unknown')}"
        )
    pins_dir = store / "pins"
    ttl_seconds = snapshot_pin_ttl_seconds()
    now = time.time()
    try:
        # is_dir() and glob() both stat, so both raise on an unreadable store. `snapshot-status` is
        # the command an operator reaches for BECAUSE the store looks wrong; it has to survive a
        # store that is wrong and report it, not traceback on the way to saying so.
        pins = sorted(pins_dir.glob("*.pin")) if pins_dir.is_dir() else []
    except OSError as exc:
        warn_snapshot_store_unreadable("cannot list pins", exc)
        pins = []
    for pin in pins:
        try:
            data = json.loads(pin.read_text(encoding="utf-8"))
            age = int(now - pin.stat().st_mtime)
        except (OSError, json.JSONDecodeError):
            continue
        state = "stale" if age > ttl_seconds else "live"
        print(
            f"snapshot_pin: {pin.stem} -> {data.get('snapshot', 'unknown')} "
            f"age={age // 3600}h state={state} lane={data.get('lanePath', 'unknown')}"
        )
    return 0


def snapshot_prune(args: argparse.Namespace) -> int:
    keep = getattr(args, "keep", None)
    pruned = prune_methodology_snapshots(keep=keep)
    print(f"snapshot_store_root: {methodology_snapshot_store_root()}")
    print(f"snapshot_pruned: {', '.join(pruned) if pruned else 'none'}")
    return 0


def snapshot_pin_target() -> Path | None:
    """The snapshot a pin for THIS session must name.

    The session's own exec root when it has one, and only then `current`. Pinning `current`
    unconditionally is a race against the very stickiness the store provides: the launcher resolves
    the exec root and calls `snapshot-pin` a few lines later, and any lane that swaps `current` in
    that window makes this lane declare a snapshot it is not running -- leaving prune free to
    collect the one it IS running, which is the single deletion the pin exists to prevent.

    `current` remains the fallback, not the rule: a pre-cutover launcher, a bare `tautline
    snapshot-pin` on a machine whose store is only just enabled, and any session executing the
    canonical checkout all legitimately have no exec root, and the snapshot they would adopt on
    their next launch is exactly `current`.
    """
    return session_snapshot_exec_root() or methodology_snapshot_current_dir()


def snapshot_pin(args: argparse.Namespace) -> int:
    """Pin the lane to the snapshot it is executing.

    Called by the launcher on EVERY start, so it exits 0 no matter what: a disabled store, an empty
    store, or a store mid-bootstrap is a note, never a failed session start.
    """
    lane = Path(getattr(args, "target", None) or ".").expanduser().resolve(strict=False)
    if not methodology_snapshot_store_enabled():
        print("snapshot_pin: skipped - snapshot store not enabled")
        return 0
    target = snapshot_pin_target()
    if target is None:
        print(f"snapshot_pin: skipped - no current snapshot in {methodology_snapshot_store_root()}")
        return 0
    try:
        pin_path = write_methodology_snapshot_pin(lane, target)
    except OSError as exc:
        print(f"snapshot_pin: skipped - could not write pin: {exc}")
        return 0
    print(f"snapshot_pin: {pin_path}")
    print(f"snapshot_pin_target: {target.name}")
    print(f"snapshot_pin_lane: {lane}")
    return 0


def heal_methodology_snapshot_current(channel: str = "stable") -> None:
    """Make `<store>/current` name the canonical HEAD: rebuild it when missing or dangling, and
    republish it when it is STALE.

    Without this the store would only ever gain a `current` on a trust-gated ADVANCE, which may be
    days away: a freshly-enabled store (or one whose current target was deleted) would leave every
    session falling back to the canonical checkout while the store sat there empty. Best-effort by
    construction -- the store is an optimization, never a dependency, so a failure prints and the
    sync carries on.

    Staleness is reconciled here because this is the ONLY publisher that runs when no advance
    fires, and every way the store gets left behind ends in exactly that state: a publish that
    failed mid-advance, an operator taking the rollback lever in one shell (that lane fast-forwards
    the canonical checkout and, executing the canonical tree, publishes nothing), or a manual git
    advance. In all three the checkout moves and `current` does not -- and because the next lane's
    sync then finds the checkout already up to date, no advance fires and _reexec_updated_methodology
    (the only other publisher) is never re-entered. Returning early on a `current` that merely
    EXISTS therefore froze the store one head behind for good, and every session on the machine kept
    executing the stale snapshot while sync reported ok.

    Trust is NOT re-litigated here: the canonical checkout only reaches a new head through the
    trust-gated advance path (or the operator's own hands), so its HEAD is already the machine's
    decision about what to run. This mirrors what heal has always done for a missing `current`.
    """
    if not methodology_snapshot_store_enabled():
        return
    canonical = canonical_methodology_repo()
    head = run_git(canonical, ["rev-parse", "HEAD"])
    if head == "unavailable":
        print(f"methodology_snapshot: heal skipped - HEAD unavailable in {canonical}")
        return
    current = methodology_snapshot_current_dir()
    at_head = current is not None and (
        (_snapshot_dir_manifest(current) or {}).get("commit") == head
    )
    # Healthy means RUNNABLE, not merely manifest-consistent. A `current` whose manifest names the
    # canonical head while its bin/tautline has been deleted passes every commit comparison in this
    # file and fails the only test that decides anything: the `-x` the shim and the launcher apply
    # before they exec it. Judged healthy, it is never repaired -- so every session on the machine
    # silently falls back to the canonical checkout, forever, while sync reports ok.
    if at_head and _snapshot_is_executable(current):
        return  # already the canonical head, and runnable: no republish, no swap, no prune churn
    # A `current` that is present but stale (or present but gutted) is republished, not left alone.
    # Distinguish that from an empty store in the report: "healed" is a store that had nothing to
    # run, "republished" is one that was quietly serving a head nobody can execute.
    action = "republished current" if current is not None else "healed current"
    snapshot_dir, snap_detail = materialize_methodology_snapshot(canonical, head, channel)
    if snapshot_dir is None:
        print(f"methodology_snapshot: failed - {snap_detail}")
        return
    swapped, swap_detail = swap_methodology_snapshot_current(snapshot_dir)
    if not swapped:
        # Report what actually happened. This function IS the recovery mechanism for a store whose
        # `current` is missing, dangling or stale, so announcing success over a failed swap would
        # leave the operator's only signal that the store is still broken reading as a repair -- a
        # silent failure in the very machinery that exists to make store failures loud.
        print(f"methodology_snapshot: heal failed - {snap_detail}; {swap_detail}")
        return
    print(f"methodology_snapshot: {action} - {snap_detail}; {swap_detail}")
    prune_methodology_snapshots()


# --- Launcher sync gate -------------------------------------------------------------------
# Every lane start runs sync-methodology. Ungated, N lanes launching together all fetch, all
# advance the one canonical checkout, and all race to publish the same snapshot into the same
# store. The gate makes the first lane do the work and the rest observe its result: a flock
# serializes them, and a freshness stamp lets the ones that wake up behind it skip a sync that
# demonstrably just happened.
METHODOLOGY_SYNC_FRESHNESS_ENV = "MINERVIT_METHODOLOGY_SYNC_FRESHNESS_MINUTES"
# Exec-handoff pair, read with os.environ.get and NEVER through resolve_env: a TAUTLINE_ alias for
# either would let a stale shell variable outrank what this process exported into its own
# environment microseconds earlier -- and for the lock fd, "outrank" means flocking a foreign file
# descriptor number. tests/test_env_reads_use_resolver.py holds the exemption and the prohibition.
METHODOLOGY_SYNC_LOCK_FD_ENV = "MINERVIT_METHODOLOGY_SYNC_LOCK_FD"
METHODOLOGY_SYNC_GATE_ENV = "MINERVIT_METHODOLOGY_SYNC_GATE"
METHODOLOGY_SYNC_STAMP_SCHEMA = "tautline-methodology-sync-stamp/v1"
METHODOLOGY_SYNC_DEFAULT_FRESHNESS_MINUTES = 10


def methodology_sync_lock_path() -> Path:
    return methodology_state_dir() / "methodology-sync.lock"


def methodology_sync_stamp_path() -> Path:
    return methodology_state_dir() / "methodology-sync.stamp"


def methodology_sync_freshness_seconds() -> int:
    """The window in which another lane's sync counts as this lane's sync. 0 disables the skip."""
    raw = util_module().resolve_env(METHODOLOGY_SYNC_FRESHNESS_ENV).strip()
    minutes = int(raw) if raw.isdigit() else METHODOLOGY_SYNC_DEFAULT_FRESHNESS_MINUTES
    return max(minutes, 0) * 60


def write_methodology_sync_stamp(head: str, snapshot: str, status: str) -> None:
    """Record that the methodology is synced as of NOW, to this head and this snapshot.

    Best-effort by construction: the stamp is an optimization, never a correctness input. A lane
    that cannot write it costs the next lane one redundant fetch, which is the pre-gate behavior.
    """
    try:
        path = methodology_sync_stamp_path()
        path.parent.mkdir(parents=True, exist_ok=True)  # fresh HOME: the state dir may not exist
        util_module().write_text_atomic(
            path,
            json.dumps(
                {
                    "schema": METHODOLOGY_SYNC_STAMP_SCHEMA,
                    "syncedAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
                    "head": head,
                    "snapshot": snapshot,
                    "status": status,
                    "pid": os.getpid(),
                },
                indent=2,
            )
            + "\n",
        )
    except OSError:
        pass


def read_methodology_sync_stamp() -> tuple[dict, float] | None:
    """The stamp plus its age in seconds, or None when absent, unreadable, or foreign.

    Age comes from the file's mtime, not from `syncedAt`: write_text_atomic renames a fresh temp
    file into place, so the mtime IS the write instant, and it cannot be faked by a stale clock in
    the JSON the way a hand-edited timestamp could.
    """
    path = methodology_sync_stamp_path()
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
        age = time.time() - path.stat().st_mtime
    except (OSError, json.JSONDecodeError):
        return None
    if not isinstance(data, dict) or data.get("schema") != METHODOLOGY_SYNC_STAMP_SCHEMA:
        return None
    return data, max(age, 0.0)


def launcher_gate_skip_reason() -> str | None:
    """Why this gated sync may trust a sibling lane's recent sync, or None to do the work.

    Every condition below is a bug that was worth a test:
      * a re-exec token VETOES the skip. A post-advance process arrives holding a stamp its own
        predecessor wrote seconds ago, and its entire purpose is the post-sync body the predecessor
        could not run (token consumption, release guards, heal, pin refresh, version reporting).
        Skipping there would silently drop all of it;
      * a fresh stamp over a store whose `current` is missing, dangling, or gutted must NOT skip,
        or the heal that would rebuild it is starved for a whole freshness window and every session
        launched in that window quietly falls back to the canonical checkout;
      * the stamp must name the snapshot `current` actually points at -- otherwise it describes a
        store state that no longer exists.
    """
    window = methodology_sync_freshness_seconds()
    if window <= 0:
        return None
    if os.environ.get(METHODOLOGY_REEXEC_TOKEN_ENV):
        return None
    stamp = read_methodology_sync_stamp()
    if stamp is None:
        return None
    data, age = stamp
    if age >= window or str(data.get("status", "")) == "failed":
        return None
    if methodology_snapshot_store_enabled():
        current = methodology_snapshot_current_dir()
        if current is None:
            return None
        if str(data.get("snapshot", "")) != current.name:
            return None
        # The same runnability predicate heal now judges health by. Declining to skip is only half
        # the remedy: it exists to HAND a gutted `current` to heal, which must then rebuild it.
        if not _snapshot_is_executable(current):
            return None
    return f"synced {int(age)}s ago by another lane"


@contextmanager
def launcher_sync_gate():
    """Hold one lock across the WHOLE gated body -- including across os.execve.

    CPython opens descriptors close-on-exec, so a gate that flocked a plain file handle would
    silently RELEASE its lock at the exact moment a trust-gated advance re-execs: the successor
    would do its checkout surgery with the gate wide open and a lane launching right then would
    walk straight in. os.set_inheritable keeps the open file description -- and therefore the flock
    on it -- alive across the exec, and the fd NUMBER travels in the environment so the successor
    ADOPTS that same lock. It must not reopen: the inherited fd still holds the lock, so a second
    open + flock would deadlock the successor against itself, forever.
    """
    lock_file = None
    inherited = os.environ.get(METHODOLOGY_SYNC_LOCK_FD_ENV, "").strip()
    if inherited.isdigit():
        lock_file = _adopt_launcher_gate_lock(int(inherited))
    if lock_file is None:
        path = methodology_sync_lock_path()
        path.parent.mkdir(parents=True, exist_ok=True)  # fresh HOME: the state dir may not exist
        lock_file = path.open("a", encoding="utf-8")
    os.set_inheritable(lock_file.fileno(), True)
    os.environ[METHODOLOGY_SYNC_LOCK_FD_ENV] = str(lock_file.fileno())
    os.environ[METHODOLOGY_SYNC_GATE_ENV] = "1"
    try:
        with advisory_flock(lock_file):
            yield
    finally:
        # Not reached on the re-exec path: os.execve replaces this process mid-body and the lock
        # deliberately rides the inherited fd into the successor, which closes the gate for us.
        os.environ.pop(METHODOLOGY_SYNC_LOCK_FD_ENV, None)
        os.environ.pop(METHODOLOGY_SYNC_GATE_ENV, None)
        try:
            lock_file.close()
        except OSError:
            pass


def _adopt_launcher_gate_lock(fd: int):
    """Wrap the inherited gate fd, or None when that number is not our lock file.

    The number alone proves nothing -- a foreign environment (or a stale export) could name a
    descriptor this process opened for something else entirely, and flocking THAT would lock a file
    nobody meant to lock. os.fstat identifies what actually sits on the number, and the (dev, ino)
    comparison confirms it is the very lock file we would otherwise have opened.
    """
    try:
        fd_stat = os.fstat(fd)
        lock_stat = methodology_sync_lock_path().stat()
    except OSError:
        return None
    if (fd_stat.st_dev, fd_stat.st_ino) != (lock_stat.st_dev, lock_stat.st_ino):
        return None
    try:
        return os.fdopen(fd, "a", encoding="utf-8")
    except OSError:
        return None


def refresh_methodology_snapshot_pin(lane: Path) -> None:
    """Re-stamp an EXISTING pin for this lane. Never creates one.

    Pins are created by the launcher's `snapshot-pin`; this keeps a long-lived lane that relaunches
    from having its pin aged out by prune's TTL while it is still executing the snapshot. Creating
    one here instead would let any incidental gated sync (a hook, a status check) claim a pin for a
    directory that is not really a lane, and pin-protected snapshots would never be collectable.

    Re-stamps snapshot_pin_target(), not `current`, for the same reason `snapshot-pin` does -- and
    it matters MORE here. This is the heartbeat that keeps a session's pin alive past the TTL, so a
    version that re-stamped `current` would, on the first sync after another lane advanced the
    store, quietly move the lane's only protection off the snapshot it is executing and onto one it
    is not. That is worse than never refreshing: the free protection a once-current snapshot enjoys
    inside the TTL window then expires with nothing behind it, and prune deletes the running tree.
    """
    if not methodology_snapshot_store_enabled():
        return
    target = snapshot_pin_target()
    if target is None:
        return
    try:
        if not methodology_snapshot_pin_path(lane).is_file():
            return
        write_methodology_snapshot_pin(lane, target)
    except OSError:
        pass


def _reexec_updated_methodology(old_head: str, new_head: str, detail: str, channel: str) -> str:
    """Journal the advance, publish it as a snapshot, and re-exec the NEW code. Trust is already
    verified by the caller -- every call site sits behind verify_upstream_trust(new_head).

    Returns ONLY on exec failure, with the detail the caller should report as `failed`.

    `old_head` is the CANONICAL checkout's pre-advance head, supplied by the caller -- never the
    running snapshot's commit, which may be many advances behind and would make the journal lie
    about what this run actually mutated.
    """
    canonical = canonical_methodology_repo()
    # Journal BEFORE the exec: os.execve replaces this process, so nothing after it ever runs.
    # (The rescue/repair helpers separately journal their own git surgery; this entry records the
    # ADOPTION -- which head this lane is about to start executing.)
    append_methodology_write_journal(
        "sync-methodology.advance", old_head, new_head, "ok", detail=detail
    )
    own_root = Path(__file__).resolve().parents[1]
    exec_root = own_root
    if methodology_snapshot_store_enabled():
        adopted: Path | None = None
        snapshot_dir, snap_detail = materialize_methodology_snapshot(canonical, new_head, channel)
        if snapshot_dir is None:
            print(f"methodology_snapshot: failed - {snap_detail}", flush=True)
        else:
            swapped, swap_detail = swap_methodology_snapshot_current(snapshot_dir)
            print(f"methodology_snapshot: {snap_detail}; {swap_detail}", flush=True)
            if swapped:
                adopted = snapshot_dir
        if adopted is not None:
            exec_root = adopted
            prune_methodology_snapshots()
        elif running_from_snapshot():
            # We are executing a snapshot of the OLD head and the store could not publish the new
            # one. Re-execing ourselves would hand this tree a token bound to new_head while its
            # manifest still says old_head -- consume_methodology_reexec_token() would reject it and
            # the launch would die with "invalid or expired". The canonical checkout is at new_head,
            # clean, and trust-verified: exec that instead and keep the session alive.
            exec_root = canonical
            print(
                f"methodology_snapshot: falling back to canonical checkout {canonical} "
                "for this session",
                flush=True,
            )
    elif running_from_snapshot():
        # Snapshot mode is OFF (the rollback lever, or the store key removed) while THIS process is
        # itself executing a snapshot -- an operator pulling the lever mid-session, or a direct call
        # to <store>/current/bin/tautline. Re-execing own_root walks into the same stale-token trap
        # as above: our manifest says old_head and the token is bound to new_head. Take the same
        # exit, which is also what the lever asks for -- execute the canonical checkout.
        exec_root = canonical
        print(
            f"methodology_snapshot: snapshot execution disabled; executing canonical checkout "
            f"{canonical} for this session",
            flush=True,
        )
    if os.environ.get(METHODOLOGY_SYNC_GATE_ENV) == "1":
        # Inside a launcher gate, os.execve is the ONLY exit from the gated body -- nothing after
        # it runs. Stamp here or the gate window re-execs stampless, and every sibling lane queued
        # on the flock repeats the fetch this run just finished. The snapshot recorded is whatever
        # `current` resolves to now: the one just swapped in on the happy path, the unchanged
        # previous one when the store could not publish (a truthful stamp the next lane can match).
        current = methodology_snapshot_current_dir()
        write_methodology_sync_stamp(new_head, current.name if current else "", "ok")
    # Path(__file__) rather than own_root/bin/<cli> on the unchanged path: the exec target stays
    # byte-for-byte what it was before the store existed (a copied-out CLI need not sit in bin/).
    exec_cli = Path(__file__).resolve() if exec_root == own_root else exec_root / "bin" / CLI_NAME
    print(f"methodology_update: updated - {detail}; re-running current command", flush=True)
    env = os.environ.copy()
    env[METHODOLOGY_REEXEC_TOKEN_ENV] = create_methodology_reexec_token(new_head)
    if exec_root != own_root:
        env[METHODOLOGY_EXEC_ROOT_ENV] = str(exec_root)
    try:
        os.execve(sys.executable, [sys.executable, str(exec_cli), *sys.argv[1:]], env)
    except OSError as exc:
        return f"{detail}; could not re-run after update: {exc}"
    raise AssertionError("unreachable after execve")  # pragma: no cover


def update_methodology_repo(
    skip_update: bool,
    allow_non_main: bool = False,
    auto_rescue_local_changes: bool = False,
    auto_rescue_stale_only: bool = False,
    channel: str = "stable",
) -> tuple[str, str]:
    """Sync the canonical checkout to its trusted release upstream.

    Journaling note: entries are appended by the code paths that actually WRITE to the canonical
    repo (the fast-forward below, the rescue/repair helpers, the release guards, update-repin) --
    never by the read-only refusals. The journal must stay a pure observer of mutations: it lives
    under $HOME, and an unconditional write here would leave a file behind on paths that touched
    nothing, which is both misleading forensics and a real side effect (it dirties any checkout
    that happens to contain $HOME, sending the very next sync down the local-changes rescue path).
    """
    if running_from_installed_package():
        # Package standdown (PP-R1-P1-1), evaluated BEFORE canonical-repo resolution so a
        # still-configured valid checkout can never reintroduce checkout mutation: pip updates
        # the RUNNING code; fetching/ff-merging a leftover checkout would change nothing this
        # wheel executes and would violate the auto-update non-goal. Read-only refusal: the
        # journal stays silent (see the docstring rule above).
        return "skipped", (
            "this Tautline runs from an installed package; checkout sync stands down - "
            f"update with {PACKAGE_INSTALL_UPDATE_HINT}"
        )
    canonical = canonical_methodology_repo()
    if skip_update:
        return "skipped", "--skip-update requested"
    reexec_token = consume_methodology_reexec_token()
    if reexec_token is True:
        return "skipped", "already updated in this lane-start process"
    if reexec_token is False:
        return "failed", f"invalid or expired {METHODOLOGY_REEXEC_TOKEN_ENV}; refusing to guess whether methodology sync already ran"
    if maintainer_mode_armed():
        # Maintainer standdown, placed AFTER --skip-update and the re-exec token (their contracts
        # keep existing precedence) and BEFORE any branch check, fetch, rescue, or ff-merge. This
        # is the single choke point every caller funnels through -- launcher-gate sync, manual
        # sync, lane-target sync -- so returning here is what makes the mode total. The non-failed
        # status is load-bearing: heal_methodology_snapshot_current still republishes canonical
        # HEAD into <store>/current (committed maintainer work reaches the very next launch) and
        # the freshness stamp is still written. Read-only refusal: the journal stays silent (see
        # the docstring rule above).
        return "skipped", (
            "maintainer mode - update gates stand down; checkout left untouched "
            "(disable with `tautline maintainer-mode off`)"
        )
    if run_git(canonical, ["rev-parse", "--is-inside-work-tree"]) != "true":
        return "skipped", "methodology checkout is not a Git worktree"
    release_branch = framework_channel_branch(channel)
    branch = run_git(canonical, ["branch", "--show-current"])
    if branch not in (release_branch, "unavailable") and not allow_non_main and util_module().resolve_env("MINERVIT_METHODOLOGY_ALLOW_NON_MAIN") != "1":
        branch_display = branch or "detached HEAD"
        if auto_rescue_local_changes:
            # Capture the pre-rescue canonical head BEFORE the rescue mutates the checkout: the
            # journal must record what this run actually moved, and after the reset it is gone.
            pre_rescue_head = run_git(canonical, ["rev-parse", "HEAD"])
            if channel == "stable":
                rescued, detail, _rescue_branch = rescue_methodology_non_main_checkout(branch_display)
            else:
                rescued, detail, _rescue_branch = rescue_methodology_non_main_checkout(branch_display, channel)
            if not rescued:
                return "failed", detail
            # The rescue reset HEAD to freshly-fetched upstream; verify trust BEFORE re-exec'ing it,
            # exactly as the pull/repair paths below do. Without this the default auto-rescue path is
            # an ungated RCE (one compromised upstream = fleet-wide code execution on adopter startup).
            rescued_head = run_git(canonical, ["rev-parse", "HEAD"])
            allowed, trust_msg = verify_upstream_trust(rescued_head)
            if trust_msg:
                print(f"methodology_update_trust: {trust_msg}", file=sys.stderr, flush=True)
            if not allowed:
                return "failed", f"{detail}; refusing to re-exec rescued methodology {rescued_head[:12]}: {trust_msg}"
            return "failed", _reexec_updated_methodology(pre_rescue_head, rescued_head, detail, channel)
        return (
            "failed",
            f"methodology checkout is on {branch_display}, not {release_branch}; refusing to sync from a non-release branch. "
            f"Switch the methodology checkout to {release_branch}, or set MINERVIT_METHODOLOGY_ALLOW_NON_MAIN=1 only for intentional methodology development.",
        )
    dirty = run_git(canonical, ["status", "--porcelain"])
    if dirty == "unavailable":
        return "skipped", "methodology checkout status unavailable"
    if dirty:
        remote_status = ""
        if auto_rescue_local_changes:
            if auto_rescue_stale_only:
                remote_status = remote_methodology_status(False)
                if remote_status == "up to date":
                    return "skipped", "methodology checkout has local changes but remote is up to date"
                if not remote_status.startswith("remote differs "):
                    return (
                        "failed",
                        "methodology checkout has local changes and "
                        f"{remote_status}; refusing to auto-rescue without confirmed newer remote methodology. "
                        f"Resolve local changes in {canonical}, then rerun `tautline sync-methodology`.",
                    )
            # Pre-rescue canonical head, captured before the rescue branch/reset moves it.
            pre_rescue_head = run_git(canonical, ["rev-parse", "HEAD"])
            rescued, detail, _rescue_branch = rescue_methodology_local_changes(methodology_release_upstream(channel))
            if not rescued:
                return "failed", detail
            # The rescue reset HEAD to freshly-fetched upstream; verify trust BEFORE re-exec'ing it,
            # exactly as the pull/repair paths below do. Without this the default auto-rescue path is
            # an ungated RCE (one compromised upstream = fleet-wide code execution on adopter startup).
            rescued_head = run_git(canonical, ["rev-parse", "HEAD"])
            allowed, trust_msg = verify_upstream_trust(rescued_head)
            if trust_msg:
                print(f"methodology_update_trust: {trust_msg}", file=sys.stderr, flush=True)
            if not allowed:
                return "failed", f"{detail}; refusing to re-exec rescued methodology {rescued_head[:12]}: {trust_msg}"
            return "failed", _reexec_updated_methodology(pre_rescue_head, rescued_head, detail, channel)
        if not remote_status:
            remote_status = remote_methodology_status(False)
        if remote_status == "up to date":
            return "skipped", "methodology checkout has local changes but remote is up to date"
        return (
            "failed",
            "methodology checkout has local changes and "
            f"{remote_status}; refusing to start with stale methodology. "
            f"Resolve local changes in {canonical}, then rerun `tautline sync-methodology`.",
        )

    old_head = run_git(canonical, ["rev-parse", "HEAD"])
    # Verify-before-advance (sec-trust-exec-1): fetch the release upstream WITHOUT moving the checkout,
    # verify the FETCHED commit against the trust policy, and only then fast-forward. A bare
    # `git pull --ff-only` advances the on-disk bin/minervit-methodology BEFORE verification, so a denied
    # upstream would still be executed by the next shim invocation. On a denied upstream we return failed
    # with the checkout untouched.
    upstream = methodology_release_upstream(channel)
    if upstream is not None:
        remote, upstream_branch, upstream_ref = upstream
        code, stdout, stderr = run_command(["git", "-C", str(canonical), "fetch", remote, upstream_branch])
        if code == 0:
            upstream_sha = run_git(canonical, ["rev-parse", upstream_ref])
            if upstream_sha == "unavailable":
                code, stdout, stderr = 1, "", f"could not resolve fetched upstream ref {upstream_ref}"
            else:
                if upstream_sha != old_head:
                    refusal = gate_methodology_upstream_advance(upstream_sha)
                    if refusal is not None:
                        # HELD, not failed: the checkout never advanced, so it still runs code the
                        # active trust policy accepts -- a safe launch state. Blocking startup here
                        # left operators dead in the water with no remedy (operator directive
                        # 2026-07-11: the remedy must always ship alongside the error). Held is
                        # only safe when the RETAINED head itself passes the trust policy (Codex
                        # 0.9.1 R1 P1): an empty/replaced allowlist or an unsigned local commit
                        # must stay fail-closed, not launch under a "trusted" label.
                        retained_allowed, _retained_msg = verify_upstream_trust(old_head)
                        if retained_allowed:
                            return "held", (
                                f"update available but held by the trust policy; continuing on the "
                                f"trusted retained checkout {old_head[:12]}. {refusal}"
                            )
                        return "failed", (
                            f"update refused and the retained checkout {old_head[:12]} does not pass "
                            f"the active trust policy either; {refusal}"
                        )
                code, stdout, stderr = run_command(["git", "-C", str(canonical), "merge", "--ff-only", upstream_sha])
    else:
        # No resolvable release upstream (e.g. no origin remote): fall back to the tracking-configured
        # pull. The post-advance re-exec gate below still refuses to re-exec an unverified HEAD.
        code, stdout, stderr = run_command(["git", "-C", str(canonical), "pull", "--ff-only"])
    if code != 0:
        if auto_rescue_local_changes:
            repaired, detail = repair_methodology_release_main_checkout(channel)
            if repaired:
                repaired_head = run_git(canonical, ["rev-parse", "HEAD"])
                allowed, trust_msg = verify_upstream_trust(repaired_head)
                if trust_msg:
                    print(f"methodology_update_trust: {trust_msg}", file=sys.stderr, flush=True)
                if not allowed:
                    return "failed", f"{detail}; refusing to re-exec repaired methodology {repaired_head[:12]}: {trust_msg}"
                # old_head predates the failed ff (which never moves HEAD), so it IS the pre-repair
                # canonical head the repair then reset away from.
                return "failed", _reexec_updated_methodology(old_head, repaired_head, detail, channel)
        return "failed", stderr or stdout or "git pull --ff-only failed"
    new_head = run_git(canonical, ["rev-parse", "HEAD"])
    if old_head != "unavailable" and new_head != "unavailable" and old_head != new_head:
        allowed, trust_msg = verify_upstream_trust(new_head)
        if trust_msg:
            print(f"methodology_update_trust: {trust_msg}", file=sys.stderr, flush=True)
        if not allowed:
            return "failed", f"refusing to re-exec updated methodology {new_head[:12]}: {trust_msg}"
        return "failed", _reexec_updated_methodology(
            old_head,
            new_head,
            f"fast-forwarded {old_head[:12]} -> {new_head[:12]}",
            channel,
        )
    return "ok", stdout or "already up to date"


def _lane_start_hook_write(defer_debt_preflights: bool, gate: str, label: str, phrase: str, writer, *writer_args) -> None:
    """One lane-start preflight that writes durable Claude settings state and maps to a DEBT-
    classified methodology-status gate (`hook` for the Claude *-hook settings writes, `autocompact`
    for the autocompact settings write -- see METHODOLOGY_STATUS_GATE_DISPLAY_NAMES). `writer(*writer_args)`
    performs the write and returns `(path, unchanged)`; the success line ("already <phrase>" vs
    "installed") prints identically whether or not --defer-debt-preflights is set.

    Without the flag this calls the write with no try/except, so an uncaught write failure
    propagates exactly as it did on the pre-0.8.9 baseline (characterization pin: unflagged
    lane-start is byte-for-byte unchanged). With the flag, a write failure degrades to one
    `lane_start_warn:` line instead of crashing lane-start, deferring the pass/fail decision to
    methodology-status --fail-on-drift's own classification of the gate.
    """

    def _write_and_print() -> None:
        path, unchanged = writer(*writer_args)
        print(f"{label}: {'already ' + phrase if unchanged else 'installed'} {path}")

    if not defer_debt_preflights:
        _write_and_print()
        return
    try:
        _write_and_print()
    except (OSError, json.JSONDecodeError) as exc:
        # json.JSONDecodeError: load_claude_settings raises it for an existing-but-malformed
        # settings.json -- same debt family as an OSError write failure (Codex T7 R1 P2).
        print(f"lane_start_warn: {gate} - {exc}")


def lane_start(args: argparse.Namespace) -> int:
    # getattr, not args.defer_debt_preflights: internal callers build synthetic Namespaces
    # (init_methodology_project's onboarding lane-start) that predate the flag; absent means the
    # unflagged, byte-for-byte-baseline behavior.
    defer_debt_preflights = getattr(args, "defer_debt_preflights", False)
    data, project_path, target = lane_project(args)
    ensure_lane_state(data, target)
    if getattr(args, "profile", None):
        profile_lock = write_work_profile_lock(data, target, args.profile)
        print(f"work_profile_lock: wrote {profile_lock}")
    print(work_profile_status_line(data, target))
    _profile, _profile_cfg, _profile_source, profile_issues = effective_work_profile(data, target)
    for issue in profile_issues:
        print(f"work_profile_issue: {issue}")
    framework_pin, framework_pin_source = effective_framework_pin(data, target)
    framework_decision = framework_update_decision(framework_pin, data, target, args.skip_update)
    print(framework_pin_status_line(framework_pin, framework_pin_source))
    print(
        "framework_update_available: "
        f"version={framework_decision.get('availableVersion', plugin_version())} "
        f"change={framework_decision.get('changeKind', 'unknown')}"
    )
    if framework_decision["action"] != "update":
        print(f"framework_remote_status: {remote_methodology_status(False)}")
        hygiene_warning = methodology_checkout_hygiene_warning(getattr(args, "allow_non_main", False))
        if hygiene_warning:
            print(f"framework_checkout_warning: {hygiene_warning}")
    for reason in framework_decision.get("wipReasons", []):
        print(f"framework_wip: {reason}")
    locked = read_lock(data, target)
    if locked and locked.get("invalid"):
        print(f"methodology_update: locked by invalid lock file; run unlock-methodology to archive it - {locked.get('path')}")
    elif locked:
        print(f"methodology_update: locked at {locked.get('commit', 'unknown')} ({locked.get('reason', 'no reason')})")
    else:
        if framework_decision["action"] == "update":
            auto_rescue = should_auto_rescue_methodology_for_project_startup()
            status, detail = update_methodology_repo(
                args.skip_update,
                getattr(args, "allow_non_main", False),
                auto_rescue,
                auto_rescue_stale_only=auto_rescue,
                channel=framework_pin["channel"],
            )
        else:
            status, detail = "skipped", framework_decision["reason"]
        print(f"methodology_update: {status} - {detail}")
        if status == "failed":
            print("startup: methodology update must be resolved before lane startup can continue")
            return 1
        if status == "held":
            # The hold detail printed above carries the ACTIVE policy's remedy (pinned -> update-repin,
            # signed -> signature repair); do not restate a policy-specific command here (Codex R2 P2).
            print(
                "methodology_update_held: startup continues on the trusted retained checkout; "
                "to advance, follow the remedy in the hold message above"
            )
        print(f"methodology_release_guard: {install_methodology_release_guards()}")
    print(f"plugin_version: {plugin_version()}")
    print(f"methodology_commit: {running_methodology_commit(short=True)}")
    development_environment_issues = print_development_environment_status(data)
    if development_environment_issues:
        print("startup: development environment must be resolved before lane gates run")
        return 1
    session_version, session_drift = record_lane_session_plugin_version(data, target)
    print(f"lane_session_plugin_version_at_start: {session_version}")
    if session_drift:
        print(
            f"plugin_version_drift: methodology plugin advanced {session_drift} -> {session_version} "
            "since the prior lane start; re-render the adapter (`render-adapters --json-only --write`) and "
            "document any review-evidence contract change before relying on pre-advance manifests"
        )
    rescue_findings = print_methodology_rescue_ref_warnings()
    if not rescue_findings:
        print("methodology_rescue_ref_adapter_drift: clean")

    validate_bootstrap_evidence_for_target(data, project_path, target)
    ensure_lane_state(data, target)
    written, skipped = write_generated_files(data, project_path, target)
    for path in written:
        print(f"wrote {path}")
    for path in skipped:
        print(
            f"generated_markdown_protected: skipped {path} "
            "(non-generated; use `render-adapters --write --json-only` for config-only updates or migrate before full render)"
        )
    env_path = write_lane_env(data, target)
    print(f"lane_env: wrote {env_path}")
    env_values = lane_env_values(data, target)
    port_summary = ", ".join(
        f"{key}={value}" for key, value in env_values.items() if key.endswith("_PORT")
    )
    if port_summary:
        print(f"lane_env_ports: {port_summary}")
    print(f"lane_env_compose_project: {env_values['COMPOSE_PROJECT_NAME']}")

    migrated, migration_errors = migrate_relevant_scratch_plans(data, target)
    for source, dest in migrated:
        print(f"planning_migration: moved {source} -> {dest}")
    for source, dest, error in migration_errors:
        print(f"planning_migration_error: could not move {source} -> {dest}: {error}")

    continuity_path = target / data["continuity"]["path"]
    packet_path = target / data["laneState"]["executionPacket"]
    goal_path = goal_run_path(data, target)
    milestone_path = milestone_run_path(data, target)
    goal_source_path = configured_path(target, data["goalArtifacts"]["sourceOfTruth"])
    goal_template_path = configured_path(target, data["goalArtifacts"]["template"])
    template_path = configured_path(target, data["planningArtifacts"]["template"])
    source_path = configured_path(target, data["planningArtifacts"]["sourceOfTruth"])
    scratch_candidates = scratch_plan_candidates(data, target)
    relevant_scratch = relevant_scratch_plan_candidates(data, scratch_candidates)
    print(f"continuity: {'present' if continuity_path.exists() else 'missing'} {continuity_path}")
    print(f"goal_run: {'present' if goal_path.exists() else 'missing'} {goal_path}")
    print(f"goal_source_of_truth: {'present' if goal_source_path.exists() else 'missing'} {goal_source_path}")
    print(f"goal_template: {'present' if goal_template_path.exists() else 'missing'} {goal_template_path}")
    print(backlog_provider_status_summary(data))
    print(goal_tracker_status_summary(data))
    print(stakeholder_questions_status_summary(data))
    try:
        latest_baseline, _latest_warnings = latest_code_baseline(data, target, write=True)
        print_latest_code_baseline(data, target, latest_baseline, state=f"wrote {latest_code_status_path(data, target)}")
    except SystemExit as exc:
        if defer_debt_preflights:
            print(f"lane_start_warn: latest_code - {exc}")
        else:
            print(f"latest_code: failed - {exc}")
            print("startup: latest-code baseline should be resolved before status, deep analysis, planning, implementation, review, or subagent work; read-only diagnosis remains allowed")
            return 1
    except OSError as exc:
        # Codex T7 R1 P2: a statusFile create/write failure (read-only .ai-work, path collision)
        # is the same latest_code debt family as a SystemExit resolve failure. Unflagged behavior
        # stays byte-for-byte baseline: the OSError propagates exactly as pre-0.8.9.
        if not defer_debt_preflights:
            raise
        print(f"lane_start_warn: latest_code - {exc}")
    if goal_path.exists():
        try:
            goal_run = load_goal_run(data, target)
            print(f"goal_status: {goal_run.get('status')} percent_complete={goal_run.get('percentComplete', goal_percent_complete(goal_run))}")
            print(f"goal_next_action: {goal_next_action_record(goal_run)['instruction']}")
            if goal_run.get("status") == "complete":
                source_goal_path = goal_run_source_path(data, target, goal_run)
                excluded = {source_goal_path} if source_goal_path else set()
                print_next_goal_candidate(data, target, exclude_paths=excluded)
        except SystemExit as exc:
            print(f"goal_status: invalid - {exc}")
    else:
        print_next_goal_candidate(data, target)
    print(f"execution_packet: {'present' if packet_path.exists() else 'missing'} {packet_path}")
    print(f"milestone_run: {'present' if milestone_path.exists() else 'missing'} {milestone_path}")
    print(f"planning_source_of_truth: {'present' if source_path.exists() else 'missing'} {source_path}")
    print(f"planning_template: {'present' if template_path.exists() else 'missing'} {template_path}")
    print(scratch_status_line("planning_scratch_candidates", scratch_candidates))
    print(scratch_status_line("planning_relevant_scratch_plans", relevant_scratch))
    if relevant_scratch:
        print("startup: migrate relevant scratch plans into the source-of-truth path before any new planning work")
    for path in ensure_lane_coordination_artifacts(data, target):
        print(f"lane_coordination_bootstrap: created {path}")
    print_lane_coordination_status(data, target)
    print_document_context_status(data, target)
    print_graphify_status(data, target)
    print_behavior_spec_status(data, target)
    ci_test_gate_startup_issues = print_ci_test_gate_status(data, target)
    if ci_test_gate_startup_issues and ci_test_gate_config(data)["enforcement"] == "block":
        print(
            "startup: this repo has tests but CI does not run them on every PR/push; wire a CI test gate "
            "(see ci-health-check) before relying on local/agent-invoked gates — methodology-status --fail-on-drift and the pre-push hook will block until it exists"
        )
    pending_journals = pending_session_journals(data, target)
    session_journal = data["sessionJournal"]
    milestone_continuation = data["milestoneContinuation"]
    print(f"session_journal: enabled={str(session_journal['enabled']).lower()} cadence={session_journal['cadence']} branch={session_journal['branch']}")
    optin_hint = session_journal_optin_hint(data, project_path, target)
    if optin_hint:
        print(optin_hint)
    print(f"session_journal_local: {session_journal_local_dir(data, target)}")
    print(f"session_journal_pending: {compact_path_list(pending_journals, target)}")
    print_event_log_status(data, target)
    print_instrumentation_status(data)
    print_usage_accounting_status(data, target)
    print_iteration_review_status(data, target)
    print_milestone_update_status(data, target)
    print_product_chat_status(data)
    print_deployment_notification_status(data, target)
    # A journal in a NON-git-ignored path (an upgraded lane whose runs dir was never ignored, or a copy
    # left by the pre-0.9.0 publication path) can still ride a broad `git add` to a product remote
    # despite narrative publication being disabled. This scan runs INDEPENDENTLY of pending_journals
    # (see session_journal_leak_warning): a marked copy is just as committable.
    leak_warning = session_journal_leak_warning(data, target)
    if leak_warning:
        print(leak_warning)
    if pending_journals:
        print(
            "startup: pending local session journals are evidence only and stay on this machine "
            "(narrative publication is disabled as of 0.9.0). To contribute sanitized upstream "
            "signal, enable instrumentation and run `tautline publish-instrumentation-record --target .`."
        )
    print_context_rotation_status(data)
    print_claude_autocompact_status()
    _lane_start_hook_write(
        defer_debt_preflights,
        "autocompact",
        "claude_autocompact_settings",
        "ok",
        write_claude_autocompact_settings,
        Path.home() / ".claude" / "settings.json",
    )
    print(
        "milestone_watchdog: "
        f"enabled={str(milestone_continuation['watchdogEnabled']).lower()} cadence={milestone_continuation['watchdogCadenceMinutes']}m"
    )
    print(f"plan_finalization_gate: required before approval, execution packet, ready-for-development marking, plan-only PR, ExitPlanMode, or implementation start")
    print(f"plan_review_manifests: {planning_source_root(data, target) / '.plan-reviews'}")
    _lane_start_hook_write(
        defer_debt_preflights,
        "hook",
        "claude_plan_finalization_hook",
        "present",
        write_claude_plan_finalization_hook,
        Path.home() / ".claude" / "settings.json",
        "tautline plan-finalization-hook",
    )
    _lane_start_hook_write(
        defer_debt_preflights,
        "hook",
        "claude_branch_liveness_hook",
        "present",
        write_claude_branch_liveness_hook,
        Path.home() / ".claude" / "settings.json",
        "tautline branch-liveness-hook",
    )
    _lane_start_hook_write(
        defer_debt_preflights,
        "hook",
        "claude_response_guard_hook",
        "present",
        write_claude_response_guard_hook,
        Path.home() / ".claude" / "settings.json",
        "tautline response-guard-hook",
    )
    _lane_start_hook_write(
        defer_debt_preflights,
        "hook",
        "claude_tool_rejection_hook",
        "present",
        write_claude_tool_rejection_hook,
        Path.home() / ".claude" / "settings.json",
        "tautline tool-rejection-hook",
    )
    _lane_start_hook_write(
        defer_debt_preflights,
        "hook",
        "claude_background_command_hook",
        "present",
        write_claude_background_command_hook,
        Path.home() / ".claude" / "settings.json",
        "tautline background-command-hook",
    )
    _lane_start_hook_write(
        defer_debt_preflights,
        "hook",
        "claude_latest_code_hook",
        "present",
        write_claude_latest_code_hook,
        Path.home() / ".claude" / "settings.json",
        "tautline latest-code-hook",
    )
    _lane_start_hook_write(
        defer_debt_preflights,
        "hook",
        "claude_context_rotation_heartbeat_hook",
        "present",
        write_claude_context_rotation_heartbeat_hook,
        Path.home() / ".claude" / "settings.json",
        "tautline context-rotation-heartbeat-hook",
    )
    git_hook_results, git_hook_errors = write_git_branch_liveness_hooks(target)
    for item in git_hook_results:
        print(f"git_branch_liveness_hook: {item}")
    for error in git_hook_errors:
        print(f"git_branch_liveness_hook: {error}")
    print("startup: run required project gates, publish pending journals, then use goal-next before milestone-next/execution-packet work")
    try_write_event(
        data,
        target,
        event="startup",
        severity="ok",
        plain="Lane startup refreshed the lane adapter config, generated Markdown when safe, lane environment, hooks, and visible process state.",
        next_action="Run methodology-status --fail-on-drift, publish pending journals if listed, then continue through goal-next or milestone-next.",
    )
    return 0


def context_bootstrap(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    ensure_lane_state(data, target)
    status = document_context_status_data(data, target)
    wrote = False
    for index_path in status["index_paths"]:
        if index_path.exists():
            print(f"context_index: present {index_path}")
            continue
        print(f"context_index: missing {index_path}")
        if args.write:
            index_path.parent.mkdir(parents=True, exist_ok=True)
            index_path.write_text(document_context_index_template(data, target, index_path), encoding="utf-8")
            print(f"wrote {index_path}")
            wrote = True
    for historical_path in status["historical_roots"]:
        if historical_path.exists():
            print(f"context_archive: present {historical_path}")
            continue
        print(f"context_archive: missing {historical_path}")
        if args.write:
            historical_path.mkdir(parents=True, exist_ok=True)
            print(f"created {historical_path}")
            wrote = True

    if args.classify:
        status = document_context_status_data(data, target)
        candidate_entries = [
            f"- [ ] `{path_display(target, path)}` - classify as Active Work, Ready Next, or Historical Evidence Only."
            for path in status["unclassified"]
        ]
        if candidate_entries:
            print(f"context_classify: {len(candidate_entries)} candidate(s)")
        for index_path in status["index_paths"]:
            if not index_path.exists() or not candidate_entries:
                continue
            if args.write:
                text = index_path.read_text(encoding="utf-8", errors="replace")
                index_path.write_text(upsert_section_entries(text, "Needs Classification", candidate_entries), encoding="utf-8")
                print(f"updated {index_path}")
                wrote = True

    if args.add_archive_headers:
        status = document_context_status_data(data, target)
        header_count = 0
        missing_count = len(status["archive_header_gaps"])
        index_ref = path_display(target, status["index_paths"][0]) if status["index_paths"] else "the context index"
        for path in status["archive_header_gaps"]:
            print(f"context_archive_header: missing {path}")
            if args.write and insert_archive_header(path, index_ref):
                print(f"updated {path}")
                header_count += 1
                wrote = True
        print(f"context_archive_headers_missing: {missing_count}")
        if args.write:
            print(f"context_archive_headers_written: {header_count}")

    if not args.write:
        print("context_bootstrap: dry-run; pass --write to create or update files")
    elif not wrote:
        print("context_bootstrap: no changes")
    return 0


def context_status(args: argparse.Namespace) -> int:
    data, project_path, target = lane_project(args)
    drift = adapter_drift(data, project_path, target)
    issues = print_document_context_status(data, target)
    if drift:
        print(f"document_context_adapter_drift: {', '.join(drift)}")
    else:
        print("document_context_adapter_drift: clean")
    effective_strict = args.strict or data["documentContext"]["enforcement"] == "strict"
    if issues:
        for issue in issues:
            print(f"document_context_issue: {issue}")
    print(f"document_context_effective_enforcement: {'strict' if effective_strict else 'warn'}")
    if effective_strict and (issues or drift):
        return 1
    if args.fail_on_drift and drift:
        return 1
    return 0


def print_context_rotation_status(data: dict) -> None:
    rotation = data["contextRotation"]
    print(
        "context_rotation: "
        f"enabled={str(rotation['enabled']).lower()} "
        f"soft={rotation['softPercent']} hard={rotation['hardPercent']} "
        f"heartbeat={rotation['heartbeatMinutes']}m boundary={rotation['heartbeatBoundary']} "
        f"safe_boundaries={','.join(rotation['safeBoundaries'])}"
    )
    print(
        "context_rotation_instruction: At PR/milestone/goal boundaries and long `/goal` heartbeats, visible context "
        "at or above soft threshold means refresh continuity/session journal, compact/restart, then resume the active "
        "goal; hard threshold requires rotation at the next safe boundary without further deferral. If the agent cannot "
        "invoke the host compaction command directly, write the continuity/session-journal evidence and state the exact "
        "fresh-session startup action without asking the human operator whether to compact or continue."
    )


def claude_autocompact_env_status() -> tuple[str, str, str, bool]:
    desired = util_module().resolve_env("MINERVIT_CLAUDE_AUTOCOMPACT_PCT", str(DEFAULT_CLAUDE_AUTOCOMPACT_PERCENT)).strip()
    actual = os.environ.get("CLAUDE_AUTOCOMPACT_PCT_OVERRIDE", "").strip()
    status = "ok" if actual == desired and actual else "missing" if not actual else "drift"
    required = util_module().resolve_env("MINERVIT_CLAUDE_AUTOCOMPACT_REQUIRED", "").strip().lower() in {"1", "true", "yes", "on"} or os.environ.get("CLAUDECODE") == "1"
    return desired, actual, status, required


def claude_autocompact_failure_messages(
    *,
    autocompact_required: bool,
    autocompact_status: str,
    autocompact_actual: str,
    autocompact_settings_ok: bool,
    autocompact_settings_status: str,
) -> list[str]:
    """The autocompact_failures debt-gate issue text. Its own pure function (rather than inline
    literals in methodology_status) so the startup-remediation recovery-action coverage test can
    scan exactly this string instead of methodology_status's entire body."""
    messages: list[str] = []
    if autocompact_required and autocompact_status != "ok":
        messages.append(
            "claude auto-compact env is "
            f"{autocompact_status}: desired={DEFAULT_CLAUDE_AUTOCOMPACT_PERCENT} actual={autocompact_actual or 'missing'}; "
            "start through the managed launcher or reinstall it with `tautline install-claude-launcher --force`"
        )
    if autocompact_required and not autocompact_settings_ok:
        messages.append(
            "claude auto-compact durable settings are not installed: "
            f"{autocompact_settings_status}; run `tautline lane-start --target .` or "
            "`tautline install-claude-launcher --force`"
        )
    return messages


def print_claude_autocompact_status() -> str:
    desired, actual, status, required = claude_autocompact_env_status()
    print(f"claude_autocompact_pct_override: desired={desired} actual={actual or 'missing'} status={status}")
    print(f"claude_autocompact_required: {str(required).lower()}")
    if status != "ok":
        print(
            "claude_autocompact_instruction: managed launchers set "
            f"CLAUDE_AUTOCOMPACT_PCT_OVERRIDE={DEFAULT_CLAUDE_AUTOCOMPACT_PERCENT}; reinstall with "
            "`tautline install-claude-launcher --force` or start through the managed launcher."
        )
    return status


def context_rotation_decision(
    rotation: dict, percent: "int | None", safe_boundary: bool, source: "str | None"
) -> dict:
    """Decide the rotation action/urgency, capping a non-host-sourced percent at advisory.

    Provenance gate (RCA 20260615T181937Z): only a percent the host literally measured
    (source == 'host') may produce a 'mandatory' verdict. A self-estimate -- or a percent
    supplied with no --context-percent-source -- is treated as an estimate and capped to
    'recommended' (advisory) so a fabricated high number can never sanction a mandatory stop.
    The cap touches only the hard branch; soft-band, below-soft, None, and disabled paths are
    byte-identical regardless of source.
    """
    # Resolve the source label: omitted-with-a-percent resolves to 'estimate' (the safe
    # default); None percent has no source to report.
    if percent is None:
        resolved_source = "none"
    else:
        resolved_source = "host" if source == "host" else "estimate"
    source_is_host = resolved_source == "host"
    capped = False
    cap_reason = ""
    if not rotation["enabled"]:
        action = "continue"
        urgency = "disabled"
        reason = "context rotation disabled by adapter"
    elif percent is None:
        action = "check-visible-context"
        urgency = "unknown"
        reason = (
            "visible context percent was not supplied; inspect the host context indicator at the "
            "boundary. A self-estimated percentage is advisory only and is never a mandatory-rotation "
            "trigger or a stop reason; if the host exposes no percentage, continue to the next genuine "
            "safe boundary"
        )
    elif percent >= rotation["hardPercent"] and source_is_host:
        action = "rotate-now" if safe_boundary else "rotate-mandatory-at-next-safe-boundary"
        urgency = "mandatory"
        reason = f"context {percent}% is at or above hard threshold {rotation['hardPercent']}%"
    elif percent >= rotation["softPercent"]:
        # Both the genuine soft band and a capped (non-host) hard-band percent land here as
        # advisory. The cap flag/reason distinguish the two for machine-visible auditing.
        action = "rotate-now" if safe_boundary else "rotate-at-next-safe-boundary"
        urgency = "recommended"
        if percent >= rotation["hardPercent"]:
            capped = True
            cap_reason = (
                f"context {percent}% is at or above hard threshold {rotation['hardPercent']}%, but the "
                "percent source is an estimate, not a host-exposed counter, so rotation is capped to "
                "advisory (recommended) and is not mandatory"
            )
            reason = cap_reason
        else:
            reason = f"context {percent}% is at or above soft threshold {rotation['softPercent']}%"
    else:
        action = "continue"
        urgency = "none"
        reason = f"context {percent}% is below soft threshold {rotation['softPercent']}%"
    return {
        "action": action,
        "urgency": urgency,
        "reason": reason,
        "source": resolved_source,
        "capped": capped,
        "cap_reason": cap_reason,
    }


def context_rotation_check(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    ensure_lane_state(data, target)
    rotation = data["contextRotation"]
    boundary = str(args.boundary).strip()
    percent = args.context_percent
    source_raw = args.context_percent_source
    if percent is not None and (percent < 0 or percent > 100):
        print("context_rotation_error: --context-percent must be between 0 and 100", file=sys.stderr)
        return 1
    safe_boundary = boundary in set(rotation["safeBoundaries"])
    decision = context_rotation_decision(rotation, percent, safe_boundary, source_raw)
    action = decision["action"]
    urgency = decision["urgency"]
    reason = decision["reason"]
    print_context_rotation_status(data)
    print(f"context_rotation_boundary: {boundary}")
    print(f"context_rotation_safe_boundary: {str(safe_boundary).lower()}")
    print(f"context_rotation_percent: {percent if percent is not None else 'unknown'}")
    print(f"context_rotation_percent_source: {decision['source']}")
    print(f"context_rotation_action: {action}")
    print(f"context_rotation_urgency: {urgency}")
    print(f"context_rotation_reason: {reason}")
    print(f"context_rotation_capped: {str(decision['capped']).lower()}")
    if action.startswith("rotate"):
        print(f"context_rotation_required_handoff: {target / data['continuity']['path']}")
        print(f"context_rotation_required_goal_ledger: {goal_run_path(data, target)}")
        print(
            "context_rotation_steps: refresh continuity; write/publish or pend session journal; update goal/milestone ledgers; "
            "compact or restart through the host when available; for Claude, invoke `/compact` or the strongest supported "
            "compact/restart path instead of yielding; resume with lane-start, methodology-status, pending journals, "
            "goal-condition or goal-next, then milestone-next"
        )
        if urgency == "mandatory":
            print(
                "context_rotation_host_fallback: if this agent turn cannot invoke `/compact` directly, this is a mandatory "
                "context rotation boundary: write the handoff/journal evidence and state the exact fresh-session startup action; "
                "do not ask the human operator whether to compact or continue"
            )
        else:
            print(
                "context_rotation_host_fallback: this is an advisory rotation, not a mandatory boundary; rotate at the "
                "safe boundary when convenient and continue authorized work — do not stop on an advisory or estimated signal"
            )
        print("context_rotation_stop_policy: context pressure is not goal completion and not a terminal blocker when compact/restart is available")
    elif action == "continue":
        print("context_rotation_steps: continue authorized work; rotate at a later safe boundary if threshold is reached")
    else:
        print("context_rotation_steps: inspect the visible context percentage; if threshold is reached, rotate at the safe boundary without asking")
    return 0


def plan_review_evidence_body(manifest: dict, manifest_rel: str) -> str:
    findings = manifest.get("classified_findings", [])
    finding_count = len(findings) if isinstance(findings, list) else 0
    # A past-target round is only legitimate because a reason was RECORDED, so the reason belongs on
    # the surface humans actually read -- the plan's committed evidence block, which is what shows up
    # in review. Without it a reader sees round R3/R4 with no justification and has to open the JSON.
    # Rendered only when present, so a plan that converged within the target stays byte-identical.
    exception_note = " ".join(str(manifest.get("exception_note") or "").split())
    exception_line = f"- Exception: `{exception_note}`\n" if exception_note else ""
    return (
        f"- Manifest: `{manifest_rel}`\n"
        f"- Recorded By: `{manifest['recorded_by']}`\n"
        f"- Plan SHA256: `{manifest['plan_content_sha256']}`\n"
        f"- Reviewer: `{manifest['reviewer']}` / `{manifest.get('reviewer_model', 'unknown')}` / round `{manifest['round']}`\n"
        f"{exception_line}"
        f"- Review command: `{manifest['review_command']}`\n"
        f"- Log: `{manifest['log_path']}`\n"
        f"- Log SHA256: `{manifest['log_sha256']}`\n"
        f"- Run Metadata: `{manifest.get('review_run_meta_path', 'missing')}`\n"
        f"- Run Metadata SHA256: `{manifest.get('review_run_meta_sha256', 'missing')}`\n"
        f"- Verdict: `{manifest['verdict']}`\n"
        f"- Unresolved Critical/P1: `{manifest['unresolved_critical_count']}` / `{manifest['unresolved_p1_count']}`\n"
        f"- Classified findings: `{finding_count}`\n"
        f"- Precheck: `tautline plan-finalization-precheck --target . --plan {manifest['plan_path']}`\n"
    )


def plan_review_evidence_section_matches(evidence_section: str, expected_evidence: str) -> bool:
    """Compare a plan's committed evidence block against the regenerated expectation.

    Pre-0.8.0 evidence blocks embed the legacy CLI name in the template-generated
    Precheck line (every other line is manifest-derived and self-consistent), so a
    byte-exact comparison rejected every legitimately-reviewed legacy plan after
    the rename. Normalize exactly that command prefix; all other drift still fails.
    """
    normalized = evidence_section.strip().replace(
        f"`{LEGACY_CLI_NAME} plan-finalization-precheck ",
        f"`{CLI_NAME} plan-finalization-precheck ",
    )
    # The section-end marker bounds the block; generic markdown extraction
    # runs to the next `## ` heading, which for preamble-style plans pulls
    # trailing non-evidence content into the section — truncate at the
    # marker so only the manifest-derived body is compared.
    normalized = normalized.split(PLAN_REVIEW_EVIDENCE_END_MARKER, 1)[0].strip()
    return normalized == expected_evidence


def parse_classified_findings(value: str | None) -> list[dict]:
    if not value:
        return []
    try:
        parsed = json.loads(value)
    except json.JSONDecodeError as exc:
        raise SystemExit(f"--classified-findings-json must be JSON: {exc}") from exc
    if not isinstance(parsed, list):
        raise SystemExit("--classified-findings-json must be a JSON array")
    for index, item in enumerate(parsed):
        if not isinstance(item, dict):
            raise SystemExit(f"--classified-findings-json item {index} must be an object")
    return parsed


def codex_plan_wrapper(data: dict) -> str:
    return str(data["review"].get("codexPlanWrapper") or data["review"]["codexWrapper"]).strip()


def codex_plan_review_command(args: argparse.Namespace) -> int:
    target = args.target.resolve()
    extra_args = list(args.review_args or [])
    if extra_args and extra_args[0] == "--":
        extra_args = extra_args[1:]
    plan_arg = getattr(args, "plan", None)
    cleaned_extra_args: list[str] = []
    index = 0
    while index < len(extra_args):
        token = extra_args[index]
        if token == "--plan":
            if index + 1 >= len(extra_args):
                print("codex_plan_review_error: --plan requires a value", file=sys.stderr)
                return 1
            if plan_arg is not None:
                print("codex_plan_review_error: --plan was provided more than once", file=sys.stderr)
                return 1
            plan_arg = Path(extra_args[index + 1])
            index += 2
            continue
        if token.startswith("--plan="):
            if plan_arg is not None:
                print("codex_plan_review_error: --plan was provided more than once", file=sys.stderr)
                return 1
            plan_arg = Path(token.split("=", 1)[1])
            index += 1
            continue
        cleaned_extra_args.append(token)
        index += 1
    if plan_arg is None:
        print("codex_plan_review_error: --plan is required", file=sys.stderr)
        return 1
    plan_path = resolve_plan_path(target, plan_arg).resolve(strict=False)
    if not plan_path.exists() or not plan_path.is_file():
        print(f"codex_plan_review_error: plan missing: {plan_path}", file=sys.stderr)
        return 1
    plan_rel = path_relative_to_target(target, plan_path)
    try:
        plan_text = plan_review_text(plan_path)
    except OSError as exc:
        print(f"codex_plan_review_error: failed to read plan {plan_path}: {exc}", file=sys.stderr)
        return 1
    command = ["codex", "review"]
    if args.base:
        command.extend(["--base", args.base])
    if args.title:
        command.extend(["--title", args.title])
    extra_instruction = ""
    if cleaned_extra_args:
        extra_instruction = (
            "\nAdditional reviewer instructions:\n"
            + " ".join(shlex.quote(arg) for arg in cleaned_extra_args)
            + "\n"
        )
    plan_fence = markdown_fence_for_embedded_text(plan_text)
    prompt = (
        "Review this Minervit source-of-truth plan only. Treat the plan content below as the review subject. "
        "This wrapper has already bound the plan deterministically for the native Codex review command.\n\n"
        f"Plan path: {plan_rel}\n"
        f"Plan content SHA-256: {plan_content_sha256_from_text(plan_text)}\n"
        f"{extra_instruction}\n"
        "Start your response with exactly `## Findings`, then return findings by severity "
        "(Critical, P1, P2, P3, Nit). Focus on feasibility, missing migration "
        "steps, compatibility risk, test gaps, and contradictions with the methodology.\n\n"
        "Plan content:\n"
        f"{plan_fence}markdown\n"
        f"{plan_text}\n"
        f"{plan_fence}\n"
    )
    command.append(prompt)
    try:
        result = subprocess.run(command, cwd=str(target), text=True, check=False)
    except OSError as exc:
        print(f"codex_plan_review_error: failed to launch codex review: {exc}", file=sys.stderr)
        return 127
    return result.returncode


def review_command_errors(data: dict, review_command: str, plan_rel: str, plan_hash: str) -> list[str]:
    errors: list[str] = []
    command = review_command.strip()
    if not command:
        return ["review-command must be non-empty"]
    try:
        command_tokens = shlex.split(command)
    except ValueError as exc:
        return [f"review-command is not shell-parseable: {exc}"]
    if not command_tokens:
        return ["review-command must contain adapter review.codexPlanWrapper"]
    command_head = Path(command_tokens[0]).name
    if command_head in PLAN_REVIEW_FORBIDDEN_COMMAND_HEADS:
        errors.append(f"review-command cannot be a shell/mock command: {command_tokens[0]}")
    for marker in PLAN_REVIEW_FORBIDDEN_SHELL_MARKERS:
        if marker in command:
            errors.append(f"review-command cannot contain shell control marker: {marker}")
            break
    codex_wrapper = codex_plan_wrapper(data)
    try:
        wrapper_tokens = shlex.split(codex_wrapper)
    except ValueError as exc:
        errors.append(f"adapter review.codexPlanWrapper is not shell-parseable: {exc}")
        wrapper_tokens = []
    if not wrapper_tokens:
        errors.append("adapter review.codexPlanWrapper is empty")
    elif command_tokens[: len(wrapper_tokens)] != wrapper_tokens:
        errors.append("review-command must start with adapter review.codexPlanWrapper")
    if not review_tokens_bind_plan(command_tokens, plan_rel, plan_hash):
        errors.append("review-command must include a structured --plan binding to the reviewed plan path or plan content hash")
    return errors


def review_tokens_bind_plan(tokens: list[str], plan_rel: str, plan_hash: str) -> bool:
    allowed = {plan_rel, plan_hash}
    for index, token in enumerate(tokens):
        if token == "--plan" and index + 1 < len(tokens) and tokens[index + 1] in allowed:
            return True
        if token.startswith("--plan=") and token.split("=", 1)[1] in allowed:
            return True
    return False


def review_output_text(log_text: str) -> str:
    if PLAN_REVIEW_OUTPUT_START not in log_text:
        return log_text
    output = log_text.split(PLAN_REVIEW_OUTPUT_START, 1)[1]
    if PLAN_REVIEW_OUTPUT_END in output:
        output = output.split(PLAN_REVIEW_OUTPUT_END, 1)[0]
    return output


def review_findings_section(log_text: str) -> str:
    # The codex CLI echoes the user prompt into stdout inside the
    # --- review output --- block, so review_output_text returns both the
    # prompt-template echo and the assistant response. The prompt template
    # legitimately uses "Critical / P1 / Important" in its instructional
    # prose (and in its example output), which trips the verdict scan
    # below. Per the prompt-template contract every clean codex review
    # response uses a `## Findings` heading; the prompt template's own
    # example output also contains that heading, so the LAST occurrence
    # inside the captured output is the final assistant response — which
    # is what the validator must scope to. Empty string is returned when
    # no `## Findings` heading is present (codex deviated from the
    # template, or this is an import log without the wrapper-driven
    # response), letting callers fall back.
    output = review_output_text(log_text)
    matches = list(re.finditer(r"(?im)^##[ \t]+Findings[ \t]*\r?$", output))
    if not matches:
        return ""
    return output[matches[-1].end() :]


def review_log_errors(log_text: str, review_command: str, plan_rel: str, plan_hash: str) -> list[str]:
    errors: list[str] = []
    output_text = review_output_text(log_text)
    if plan_rel not in output_text and plan_hash not in output_text:
        errors.append("review output must reference the reviewed plan path or plan content hash")
    if review_command not in log_text:
        errors.append("review log must contain the exact review-command that produced it")
    return errors


def review_log_verdict_errors(
    log_text: str,
    verdict: str,
    unresolved_critical_count: int,
    unresolved_p1_count: int,
    classified_findings: list[dict] | None = None,
) -> list[str]:
    errors: list[str] = []
    # Scope to the assistant's `## Findings` section so we do not flag
    # the prompt-template's instructional wording (which the codex CLI
    # echoes into stdout). If the response does not include that heading,
    # fall back to the captured review-output block — still narrower than
    # the raw log so we skip the run-log header and metadata noise.
    findings_text = review_findings_section(log_text)
    scope_text = findings_text or review_output_text(log_text)
    lower = scope_text.lower()
    if verdict == "clean" and re.search(r"\bverdict\s*:\s*(?:blocked|failed?|needs changes|changes requested)\b", lower):
        errors.append("review log verdict conflicts with recorded clean verdict")
    if unresolved_critical_count == 0 and unresolved_p1_count == 0:
        suspicious_lines = []
        scope_lines = scope_text.splitlines()
        for index, line in enumerate(scope_lines):
            line_lower = line.lower()
            if not re.search(r"\b(?:critical|c1|p1|important)\b", line_lower):
                continue
            # A bare severity section HEADER is structure ONLY when its section is EMPTY --
            # rendered as '### P1' + '- None.' (or equivalent) on the NEXT line, immediately
            # followed by another heading, or trailing with no body. A NON-EMPTY section is
            # blocker evidence even when its bullets do not repeat the severity word
            # (Codex T7 R4 P1: the unconditional skip let '### P1' + an unlabeled finding
            # bullet finalize clean).
            if re.match(r"^#{1,6}\s*(?:critical|c1|p0|p1|important|high)\s*$", line_lower.strip()):
                next_content = next((item.strip() for item in scope_lines[index + 1:] if item.strip()), "")
                if (
                    not next_content
                    or next_content.startswith("#")
                    or re.match(r"^[-*+]?\s*(?:none|n/?a|no findings|0)\s*[.!]?$", next_content.lower())
                ):
                    continue
                suspicious_lines.append(line.strip())
                continue
            if re.search(r"\b(?:no|zero|0)\b.*\b(?:critical|c1|p1|important)\b", line_lower):
                continue
            if re.search(r"\b(?:critical|c1|p1|important)\b.*\b(?:none|zero|0)\b", line_lower):
                continue
            suspicious_lines.append(line.strip())
        if suspicious_lines:
            if not classified_findings_have_resolved_blocker_evidence(classified_findings or []):
                errors.append("review log mentions Critical/P1/Important findings while recorded unresolved counts are zero")
    return errors


def plan_review_exemption_errors(data: dict, plan_path: Path) -> list[str] | None:
    text = plan_path.read_text(encoding="utf-8", errors="replace")
    section = markdown_section(text, PLAN_REVIEW_EXEMPTION_HEADING)
    if not section:
        return None
    lower = section.lower()
    configured_exemptions = [str(item).strip() for item in data["planningArtifacts"].get("reviewExemptions", [])]
    if not configured_exemptions:
        return ["Plan Review Exemption section is present, but the adapter declares no review exemptions"]
    labels = [item.split(":", 1)[0].strip().lower() for item in configured_exemptions]
    errors: list[str] = []
    if not any(label and label in lower for label in labels):
        errors.append("Plan Review Exemption must name an adapter-declared exemption label")
    required_claims = [
        ("no product/runtime behavior change", r"\b(no|not|without)\b.*\b(product|runtime|behavior|code|schema|route|ui|infra|deployment|security|data|test harness)\b"),
        ("implementation review still required", r"\b(implementation|diff|stage)\b.*\b(review|gate|validation|test)\b"),
    ]
    for label, pattern in required_claims:
        if not re.search(pattern, lower, re.S):
            errors.append(f"Plan Review Exemption must state {label}")
    return errors


def behavior_feature_files(data: dict, target: Path) -> list[Path]:
    behavior = data.get("behaviorSpecs", {})
    files: set[Path] = set()
    for raw_pattern in behavior.get("paths", []):
        pattern = str(raw_pattern).strip()
        if not pattern:
            continue
        normalized = pattern.replace("\\", "/")
        if ".feature" in normalized or "/features" in f"/{normalized}" or normalized.endswith("features/**"):
            for candidate in target.glob(normalized):
                if candidate.is_file() and candidate.suffix == ".feature":
                    files.add(candidate)
                elif candidate.is_dir():
                    files.update(path for path in candidate.rglob("*.feature") if path.is_file())
    return sorted(files)


def critical_journey_feature_files(journey: dict, target: Path) -> list[Path]:
    """Resolve a critical journey's featurePaths (file / dir / glob) to .feature files under target."""
    files: set[Path] = set()
    for raw in journey.get("featurePaths", []):
        pattern = str(raw).strip().replace("\\", "/")
        if not pattern:
            continue
        for candidate in target.glob(pattern):
            if candidate.is_file() and candidate.suffix == ".feature":
                files.add(candidate)
            elif candidate.is_dir():
                files.update(path for path in candidate.rglob("*.feature") if path.is_file())
    return sorted(files)


def normalize_critical_journeys(data: dict) -> list[dict]:
    """Validate the optional criticalJourneys block: each is {name, featurePaths[]}. Type-checks; an
    absent block is simply []."""
    raw = data.get("criticalJourneys")
    if raw is None:
        return []
    if not isinstance(raw, list):
        raise SystemExit("Project adapter criticalJourneys must be an array")
    norm: list[dict] = []
    for entry in raw:
        if not isinstance(entry, dict):
            raise SystemExit("Project adapter criticalJourneys[] entries must be objects")
        name = entry.get("name")
        if not isinstance(name, str) or not name.strip():
            raise SystemExit("Project adapter criticalJourneys[].name must be a non-blank string")
        paths = entry.get("featurePaths", [])
        if not isinstance(paths, list) or any(not isinstance(p, str) or not p.strip() for p in paths):
            raise SystemExit("Project adapter criticalJourneys[].featurePaths must be an array of non-blank strings")
        norm.append({**entry, "name": name.strip(), "featurePaths": [p.strip() for p in paths]})
    return norm


def critical_journey_pending_issues(data: dict, target: Path) -> list[str]:
    """A scenario whose feature maps to a declared critical journey must never be @pending (RC5).
    Returns a blocking issue per such inactive scenario."""
    journeys = data.get("criticalJourneys") or []
    if not isinstance(journeys, list) or not journeys:
        return []
    behavior = data.get("behaviorSpecs") or {}
    pending_tags = [str(t).strip() for t in behavior.get("pendingTags", DEFAULT_BEHAVIOR_PENDING_TAGS) if str(t).strip()] or list(DEFAULT_BEHAVIOR_PENDING_TAGS)
    issues: list[str] = []
    for journey in journeys:
        if not isinstance(journey, dict):
            continue
        name = str(journey.get("name") or "unnamed").strip() or "unnamed"
        features = critical_journey_feature_files(journey, target)
        if not features:
            issues.append(
                f"critical journey `{name}` resolves to zero feature files (featurePaths is empty or "
                "matches nothing): a declared critical journey must have live scenario coverage, so an "
                "absent feature is a coverage gap -- write the feature or remove the journey"
            )
            continue
        for feature in features:
            try:
                pending = behavior_pending_scenarios(feature, pending_tags)
            except OSError:
                continue
            for scenario in pending:
                issues.append(
                    f"critical journey `{name}` has an inactive (@pending) scenario "
                    f"'{scenario.get('name')}' at {feature.relative_to(target)}:{scenario.get('line')}: a "
                    "declared critical-journey scenario must have live coverage, never @pending"
                )
    return issues


def print_critical_journey_status(data: dict, target: Path) -> list[str]:
    journeys = data.get("criticalJourneys") or []
    issues = critical_journey_pending_issues(data, target)
    print(f"critical_journeys: count={len(journeys)} status={'drift' if issues else 'ok'}")
    for issue in issues:
        print(f"critical_journey_issue: {issue}")
    return issues


def behavior_pending_scenarios(path: Path, pending_tags: list[str]) -> list[dict]:
    pending = {tag.lower() for tag in pending_tags}
    lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
    feature_pending = False
    tag_block: list[tuple[int, str]] = []
    scenarios: list[dict] = []
    for index, line in enumerate(lines):
        stripped = line.strip()
        if not stripped:
            continue
        if stripped.startswith("@"):
            tag_block.append((index + 1, stripped))
            continue
        lower = stripped.lower()
        if lower.startswith("feature:"):
            feature_pending = any(tag.lower() in pending for _line_no, tag_line in tag_block for tag in tag_line.split())
            tag_block = []
            continue
        if lower.startswith("scenario:") or lower.startswith("scenario outline:"):
            scenario_pending = feature_pending or any(
                tag.lower() in pending
                for _line_no, tag_line in tag_block
                for tag in tag_line.split()
            )
            if scenario_pending:
                name = stripped.split(":", 1)[1].strip() if ":" in stripped else stripped
                tag_line_numbers = [line_no for line_no, _tag_line in tag_block] or [index + 1]
                context_start = max(0, min(tag_line_numbers) - 3)
                context_end = min(len(lines), index + 6)
                context = "\n".join(lines[context_start:context_end])
                scenarios.append(
                    {
                        "path": path,
                        "line": index + 1,
                        "name": name,
                        "context": context,
                        # 1-based, inclusive span from the first @pending tag through the body
                        # window the metadata check inspects. Diff-scoping (RCA 20260615T071304Z)
                        # tests membership against this span, not just the Scenario: line, so a diff
                        # that pends an existing scenario (adds only the tag above it) or edits the
                        # inactive scenario's body still counts as introduced-by-this-diff.
                        "spanStart": min(tag_line_numbers),
                        "spanEnd": context_end,
                    }
                )
            tag_block = []
            continue
        tag_block = []
    return scenarios


def behavior_harness_script_text(target: Path, command: str) -> str:
    try:
        parts = shlex.split(command)
    except ValueError:
        parts = command.split()
    if not parts:
        return ""
    candidates: list[Path] = []
    for part in parts:
        if part.startswith("-") or "=" in part:
            continue
        try:
            candidate = configured_path(target, part).resolve(strict=False)
        except ValueError:
            # The harness `command` is command TEXT, not an adapter path field: a token that
            # escapes the project root (e.g. the `/bin/sh` in `/bin/sh scripts/acceptance.sh`)
            # is simply not a project script to read for proof. Skip it and keep probing the
            # remaining tokens; sec-config-path-1 containment stays strict for real path fields.
            continue
        if candidate.exists() and candidate.is_file():
            candidates.append(candidate)
            break
    text_parts: list[str] = [command]
    for candidate in candidates:
        try:
            text_parts.append(candidate.read_text(encoding="utf-8", errors="replace"))
        except OSError:
            pass
    return "\n".join(text_parts)


def behavior_spec_status_record(data: dict, target: Path) -> dict:
    behavior = data.get("behaviorSpecs", {})
    feature_files = behavior_feature_files(data, target)
    pending_tags = [str(tag).strip() for tag in behavior.get("pendingTags", DEFAULT_BEHAVIOR_PENDING_TAGS) if str(tag).strip()]
    pending_scenarios: list[dict] = []
    for feature_file in feature_files:
        pending_scenarios.extend(behavior_pending_scenarios(feature_file, pending_tags))

    issues: list[str] = []
    max_pending = int(behavior.get("maxPendingScenarios", 0))
    if len(pending_scenarios) > max_pending:
        issues.append(
            f"inactive acceptance scenarios exceed limit: {len(pending_scenarios)} > {max_pending}; "
            "remove pending tags or record owner, reason, and un-pend trigger"
        )
    if behavior.get("pendingRequiresOwnerAndTrigger", True):
        for scenario in pending_scenarios:
            context = str(scenario["context"]).lower()
            missing: list[str] = []
            if "owner:" not in context:
                missing.append("owner")
            if "un-pend" not in context and "unpend" not in context and "trigger:" not in context and "remove @pending when" not in context:
                missing.append("un-pend trigger")
            if missing:
                rel = path_relative_to_target(target, scenario["path"])
                issues.append(
                    f"inactive scenario missing {', '.join(missing)}: {rel}:{scenario['line']} {scenario['name']}"
                )

    harnesses = behavior.get("acceptanceHarnesses", [])
    harness_records: list[dict] = []
    if behavior.get("required") and feature_files and not harnesses:
        issues.append("behaviorSpecs.acceptanceHarnesses missing while behavior specs are required and feature files exist")
    for index, harness in enumerate(harnesses, start=1):
        name = str(harness.get("name", f"harness-{index}")).strip()
        command = str(harness.get("command", "")).strip()
        app_path_text = str(harness.get("appPath", "")).strip()
        app_path = configured_path(target, app_path_text).resolve(strict=False)
        feature_patterns = [str(value).strip() for value in harness.get("featurePaths", []) if str(value).strip()]
        target_proof = [str(value).strip() for value in harness.get("targetProof", []) if str(value).strip()]
        proof_needles = target_proof or [app_path_text]
        haystack = behavior_harness_script_text(target, command)
        harness_feature_files: set[Path] = set()
        for pattern in feature_patterns:
            for candidate in target.glob(pattern):
                if candidate.is_file() and candidate.suffix == ".feature":
                    harness_feature_files.add(candidate)
                elif candidate.is_dir():
                    harness_feature_files.update(path for path in candidate.rglob("*.feature") if path.is_file())
        if app_path_text and not app_path.exists():
            issues.append(f"acceptance harness `{name}` appPath missing: {app_path_text}")
        if command and not behavior_harness_script_text(target, command):
            issues.append(f"acceptance harness `{name}` command has no readable proof surface: {command}")
        missing_proofs = [needle for needle in proof_needles if needle and needle not in haystack]
        if missing_proofs:
            issues.append(
                f"acceptance harness `{name}` does not prove target app alignment; missing proof: {', '.join(missing_proofs)}"
            )
        if feature_patterns and not harness_feature_files:
            issues.append(f"acceptance harness `{name}` featurePaths matched no .feature files")
        harness_records.append(
            {
                "name": name,
                "command": command,
                "appPath": app_path,
                "featureFiles": sorted(harness_feature_files),
                "proofOk": not missing_proofs,
            }
        )

    return {
        "required": bool(behavior.get("required")),
        "enforcement": str(behavior.get("inactiveAcceptanceEnforcement", "strict" if behavior.get("required") else "warn")),
        "pendingTags": pending_tags,
        "featureFiles": feature_files,
        "pendingScenarios": pending_scenarios,
        "harnesses": harness_records,
        "issues": issues,
    }


def print_behavior_spec_status(data: dict, target: Path) -> list[str]:
    status = behavior_spec_status_record(data, target)
    print(
        "behavior_specs: "
        f"required={str(status['required']).lower()} "
        f"enforcement={status['enforcement']} "
        f"feature_files={len(status['featureFiles'])} "
        f"inactive_scenarios={len(status['pendingScenarios'])} "
        f"harnesses={len(status['harnesses'])}"
    )
    print(f"behavior_specs_pending_tags: {','.join(status['pendingTags']) if status['pendingTags'] else 'none'}")
    print(f"behavior_specs_feature_files: {compact_path_list(status['featureFiles'], target)}")
    if status["pendingScenarios"]:
        sample = [
            Path(f"{path_relative_to_target(target, item['path'])}:{item['line']}")
            for item in status["pendingScenarios"][:5]
        ]
        print(f"behavior_specs_inactive_sample: {', '.join(str(item) for item in sample)}")
    for harness in status["harnesses"]:
        print(
            "behavior_specs_harness: "
            f"{harness['name']} app={path_relative_to_target(target, harness['appPath'])} "
            f"features={len(harness['featureFiles'])} proof={'ok' if harness['proofOk'] else 'missing'} "
            f"command={harness['command']}"
        )
    print(
        "behavior_specs_instruction: Executable behavior specs must run against the app package being changed; "
        "inactive acceptance scenarios require owner, reason, and un-pend trigger and cannot be normalized as covered."
    )
    for issue in status["issues"]:
        print(f"behavior_specs_issue: {issue}")
    return list(status["issues"])


def behavior_spec_status(args: argparse.Namespace) -> int:
    data, _project_path, target = lane_project(args)
    issues = print_behavior_spec_status(data, target)
    status = behavior_spec_status_record(data, target)
    effective_strict = bool(args.strict) or status["enforcement"] == "strict"
    print(f"behavior_specs_effective_enforcement: {'strict' if effective_strict else 'warn'}")
    return 1 if effective_strict and issues else 0


def behavior_source_material_citation_errors(data: dict, plan_text: str) -> list[str]:
    behavior = data.get("behaviorSpecs", {})
    source_materials = [
        str(value).strip()
        for value in behavior.get("sourceMaterials", [])
        if str(value).strip()
    ]
    if not source_materials:
        return []

    errors: list[str] = []
    section = markdown_section(plan_text, "## Behavior Source Materials")
    if not section:
        if BEHAVIOR_SOURCE_EXEMPTION_MARKER in plan_text:
            errors.append(
                f"behavior source material exemption `{BEHAVIOR_SOURCE_EXEMPTION_MARKER}` must appear inside "
                "`## Behavior Source Materials`"
            )
        errors.append(
            "behavior source material section missing: add `## Behavior Source Materials` and account for every "
            "behaviorSpecs.sourceMaterials entry as reviewed/adapted or not applicable with a reason"
        )
        return errors

    if plan_text.count(BEHAVIOR_SOURCE_EXEMPTION_MARKER) > section.count(BEHAVIOR_SOURCE_EXEMPTION_MARKER):
        errors.append(
            f"behavior source material exemption `{BEHAVIOR_SOURCE_EXEMPTION_MARKER}` must appear only inside "
            "`## Behavior Source Materials`"
        )

    exempted, exemption_error = behavior_source_material_exemption_state(section, BEHAVIOR_SOURCE_EXEMPTION_MARKER)
    if exempted:
        normalized_section = section.replace("\\", "/")
        cited_sources = [
            source
            for source in source_materials
            if source.replace("\\", "/") in normalized_section
        ]
        if cited_sources:
            errors.append(
                "behavior source material exemption conflicts with cited source entries: "
                + ", ".join(cited_sources)
            )
        return errors
    if exemption_error:
        errors.append(exemption_error)

    normalized_lines = [(line, line.replace("\\", "/")) for line in section.splitlines()]
    adaptation_pattern = re.compile(
        r"\b(?:reviewed|adapted|imported|normalized|split|preserved|source-derived)\b",
        re.IGNORECASE,
    )
    source_recreation_pattern = re.compile(
        r"\b(?:from scratch|recreated?|ignored|did not use|not used|without using|not reviewed)\b",
        re.IGNORECASE,
    )
    not_applicable_terms = ["not applicable", "does not apply", "not relevant", "out of scope"]
    for source in source_materials:
        normalized_source = source.replace("\\", "/")
        matching_lines = [line for line, normalized in normalized_lines if normalized_source in normalized]
        if not matching_lines:
            errors.append(f"behavior source material not accounted for: {source}")
            continue
        line = matching_lines[0].strip()
        lower_line = line.lower()
        if any(term in lower_line for term in not_applicable_terms):
            if not behavior_not_applicable_line_has_reason(line, source, not_applicable_terms):
                errors.append(f"behavior source material not-applicable entry lacks a reason: {source}")
            continue
        if source_recreation_pattern.search(line):
            errors.append(f"behavior source material entry says source was not used: {source}")
            continue
        if not adaptation_pattern.search(line):
            errors.append(
                "behavior source material entry must state reviewed/adapted/imported/normalized/split/preserved "
                f"or not applicable with a reason: {source}"
            )
    return errors


def behavior_not_applicable_line_has_reason(line: str, source: str, terms: list[str]) -> bool:
    normalized_line = line.replace("\\", "/")
    normalized_source = source.replace("\\", "/")
    source_index = normalized_line.lower().find(normalized_source.lower())
    after_source = normalized_line[source_index + len(normalized_source) :] if source_index >= 0 else normalized_line
    term_pattern = re.compile(r"\b(?:" + "|".join(re.escape(term) for term in terms) + r")\b", re.IGNORECASE)
    match = term_pattern.search(after_source)
    if not match:
        return False
    reason = after_source[match.end() :].strip(" :-.")
    if not reason or "<reason>" in reason.lower() or reason.lower() in {"n/a", "na", "todo", "tbd"}:
        return False
    return len(reason) >= 8


def behavior_source_material_exemption_state(plan_text: str, marker: str) -> tuple[bool, str | None]:
    if not marker:
        return False, None
    for line in plan_text.splitlines():
        if marker not in line:
            continue
        reason = line.split(marker, 1)[1].strip()
        if not reason or "<reason>" in reason.lower() or reason.lower() in {"n/a", "na", "todo", "tbd"}:
            return False, f"behavior source material exemption `{marker}` must include a real reason, not the placeholder"
        if len(reason) < 8:
            return False, f"behavior source material exemption `{marker}` reason is too short"
        return True, None
    return False, None


def behavior_role_vocabulary_errors(data: dict, plan_text: str) -> list[str]:
    behavior = data.get("behaviorSpecs", {})
    role_vocabulary = behavior.get("roleVocabulary", {})
    forbidden = [
        str(value).strip()
        for value in role_vocabulary.get("forbidden", [])
        if str(value).strip()
    ]
    if not forbidden:
        return []
    scan_text = markdown_without_quoted_blocks(plan_text)
    errors: list[str] = []
    for term in forbidden:
        if re.search(rf"(?<![\w`/-]){re.escape(term)}(?![\w`/-])", scan_text, re.IGNORECASE):
            errors.append(f"forbidden behavior role term present in plan: {term}")
    return errors


def markdown_without_quoted_blocks(text: str) -> str:
    kept_lines: list[str] = []
    in_fence = False
    for line in text.splitlines():
        stripped = line.strip()
        if stripped.startswith("```") or stripped.startswith("~~~"):
            in_fence = not in_fence
            continue
        if in_fence or line.lstrip().startswith(">"):
            continue
        kept_lines.append(re.sub(r"`[^`]*`", "", line))
    return "\n".join(kept_lines)


def plan_review_run_meta_path(log_path: Path) -> Path:
    return log_path.with_name(f"{log_path.name}.meta.json")


def validate_plan_review_run_meta(
    meta: dict,
    target: Path,
    plan_rel: str,
    plan_hash: str,
    review_command: str,
    log_path: Path,
    wrapper_exit_code: int,
) -> list[str]:
    errors: list[str] = []
    required_fields = [
        "schema",
        "plan_path",
        "plan_content_sha256",
        "review_scope",
        "code_diff_review",
        "review_command",
        "log_path",
        "log_sha256",
        "wrapper_exit_code",
        "started_at",
        "finished_at",
    ]
    for field in required_fields:
        if field not in meta:
            errors.append(f"plan review run metadata missing field: {field}")
    if errors:
        return errors
    if meta["schema"] != PLAN_REVIEW_RUN_SCHEMA:
        errors.append(f"plan review run metadata schema mismatch: {meta['schema']}")
    if meta["plan_path"] != plan_rel:
        errors.append(f"plan review run metadata plan_path mismatch: {meta['plan_path']} != {plan_rel}")
    if meta["plan_content_sha256"] != plan_hash:
        errors.append("plan review run metadata is stale: plan_content_sha256 does not match current plan content")
    if meta["review_scope"] != "plan-only":
        errors.append("plan review run metadata review_scope must be plan-only")
    if meta["code_diff_review"] is not False:
        errors.append("plan review run metadata code_diff_review must be false")
    if meta["review_command"] != review_command:
        errors.append("plan review run metadata review_command mismatch")
    try:
        meta_exit_code = int(meta["wrapper_exit_code"])
    except (TypeError, ValueError):
        meta_exit_code = -999999
        errors.append("plan review run metadata wrapper_exit_code must be an integer")
    if meta_exit_code != wrapper_exit_code:
        errors.append("plan review run metadata wrapper_exit_code mismatch")
    meta_log_path = cli_path(target, str(meta["log_path"])).resolve(strict=False)
    if meta_log_path != log_path.resolve(strict=False):
        errors.append(f"plan review run metadata log_path mismatch: {meta['log_path']}")
    elif not log_path.exists() or not log_path.is_file():
        errors.append(f"plan review run metadata log missing: {log_path}")
    else:
        log_hash = file_sha256(log_path)
        if meta["log_sha256"] != log_hash:
            errors.append("plan review run metadata log_sha256 mismatch")
    return errors


def load_plan_review_run_meta(log_path: Path) -> tuple[dict | None, Path, list[str]]:
    meta_path = plan_review_run_meta_path(log_path)
    if not meta_path.exists():
        return None, meta_path, [f"plan review run metadata missing: {meta_path}; run tautline run-plan-review"]
    try:
        return json.loads(meta_path.read_text(encoding="utf-8")), meta_path, []
    except json.JSONDecodeError as exc:
        return None, meta_path, [f"plan review run metadata invalid JSON: {exc}"]


def load_plan_review_manifest(manifest_path: Path) -> dict | None:
    if not manifest_path.exists() or not manifest_path.is_file():
        return None
    try:
        manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return None
    return manifest if isinstance(manifest, dict) else None


def plan_review_successful_run_meta_records(data: dict, target: Path, plan_rel: str) -> list[tuple[Path, dict]]:
    runs_dir = configured_path(target, data["laneState"]["runsDir"]) / "plan-review"
    records: list[tuple[Path, dict]] = []
    if not runs_dir.exists() or not runs_dir.is_dir():
        return records
    for meta_path in sorted(runs_dir.glob("*.meta.json")):
        try:
            meta = json.loads(meta_path.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError):
            continue
        if not isinstance(meta, dict):
            continue
        if meta.get("schema") != PLAN_REVIEW_RUN_SCHEMA:
            continue
        if str(meta.get("plan_path") or "") != plan_rel:
            continue
        try:
            wrapper_exit_code = int(meta.get("wrapper_exit_code"))
        except (TypeError, ValueError):
            continue
        if wrapper_exit_code != 0:
            continue
        records.append((meta_path, meta))
    return records


def plan_review_manifest_is_clean_for_hash(manifest: dict | None, plan_hash: str) -> bool:
    if not manifest:
        return False
    verdict = str(manifest.get("verdict") or "").strip().lower()
    if verdict not in PLAN_REVIEW_ALLOWED_VERDICTS:
        return False
    if str(manifest.get("plan_content_sha256") or "") != plan_hash:
        return False
    try:
        critical = int(manifest.get("unresolved_critical_count", 0))
        p1 = int(manifest.get("unresolved_p1_count", 0))
    except (TypeError, ValueError):
        return False
    return critical == 0 and p1 == 0


def plan_review_bound_evidence_is_unfinalizable(manifest: dict | None, plan_hash: str) -> bool:
    """True when the bound evidence cannot be finalized against the CURRENT plan.

    This is the predicate the hard-cap REMEDY must be chosen by. Blocker counts alone are not
    enough: plan-finalization-precheck rejects a manifest whose plan_content_sha256 has moved, so
    evidence that is clean but STALE cannot be finalized either. Choosing on counts alone would
    tell the operator to "finalize the existing review evidence" in a state where finalization
    provably fails and the cap forbids another round -- a dead end, which is exactly what this
    ladder exists to remove. Stale-clean evidence therefore takes the mandatory split, like any
    other state with no usable evidence for the plan in hand.
    """
    return not plan_review_manifest_is_clean_for_hash(manifest, plan_hash)


def plan_review_run_meta_matches_manifest(target: Path, meta_path: Path, manifest: dict | None) -> bool:
    if not manifest:
        return False
    recorded = str(manifest.get("review_run_meta_path") or "").strip()
    if not recorded:
        return False
    try:
        recorded_path = cli_path(target, recorded).resolve(strict=False)
    except (OSError, RuntimeError):
        return False
    return recorded_path == meta_path.resolve(strict=False)


def plan_review_run_meta_superseded_by_manifest(meta: dict, manifest: dict | None) -> bool:
    """True when the bound manifest already accounts for this run, so the run is NOT stranded.

    The manifest is a single-round file: it binds exactly ONE run-meta, so every earlier round's
    meta is orphaned the moment a later round finalizes. An orphan is not a voided run -- it was
    reviewed, finalized, and superseded. Only a run at or past the bound round can be a genuinely
    unfinalizable dead end.

    Fails CLOSED: when strandedness cannot be proven (unparseable round or timestamp), this returns
    True, so the round falls back to requiring an explicit --exception-note. Refusing to
    self-authorize is always recoverable -- the operator records a real reason and takes the round.
    Self-authorizing on a fabricated reason is not: it is written verbatim into the hash-bound
    manifest as a false attestation.
    """
    if not manifest:
        return False
    bound_round = plan_review_round_number(str(manifest.get("round") or ""))
    meta_round = plan_review_round_number(str(meta.get("round") or ""))
    if bound_round is None or meta_round is None:
        return True
    if meta_round != bound_round:
        return meta_round < bound_round
    # Same round as the bound run but a different run-meta (the round was re-run): the finalization
    # supersedes any run that finished before the manifest was recorded.
    finished_at = plan_review_parse_timestamp(meta.get("finished_at"))
    recorded_at = plan_review_parse_timestamp(manifest.get("timestamp"))
    if finished_at is None or recorded_at is None:
        return True
    return finished_at <= recorded_at


def plan_review_parse_timestamp(value: object) -> datetime | None:
    """Offset-aware timestamp, else None. Naive values are rejected: they are not comparable."""
    try:
        parsed = datetime.fromisoformat(str(value or "").strip())
    except (TypeError, ValueError):
        return None
    if parsed.tzinfo is None:
        return None
    return parsed


def plan_review_manifest_has_unresolved_blockers(manifest: dict | None) -> bool:
    """True when the bound evidence is missing, blocked, or still carries Critical/P1 findings."""
    if not manifest:
        return True
    if str(manifest.get("verdict") or "").strip().lower() not in PLAN_REVIEW_ALLOWED_VERDICTS:
        return True
    try:
        critical = int(manifest.get("unresolved_critical_count", 0))
        p1 = int(manifest.get("unresolved_p1_count", 0))
    except (TypeError, ValueError):
        return True
    return plan_review_blocker_total(critical, p1) > 0


def plan_review_self_authorized_reason(
    data: dict,
    target: Path,
    plan_path: Path,
    plan_rel: str,
    plan_hash: str,
) -> str:
    """Reason a past-target round self-authorizes without an explicit note, else ''.

    (a) convergence round: the bound manifest is blocked and the plan has changed since it was
        bound -- the fixes landed, so the evidence must be rebound against the fixed plan.
    (b) rebinding round: a successful run was voided by a plan edit before it could be finalized,
        so it can never be finalized and refusing here would be a dead end.

    A run that was finalized and later SUPERSEDED by another round is neither: the manifest binds
    one round at a time, so earlier rounds' metas are orphaned by design. Counting those as voided
    runs would make (b) self-satisfying -- after any clean convergence, one plan edit would
    self-authorize rounds 3-4 with a reason no human ever gave. When neither state is proven this
    returns '', and the round requires an explicit --exception-note.
    """
    manifest = load_plan_review_manifest(plan_review_manifest_path(data, target, plan_path))
    if manifest:
        bound_hash = str(manifest.get("plan_content_sha256") or "")
        moved = bool(bound_hash) and bound_hash != plan_hash
        if moved and plan_review_manifest_has_unresolved_blockers(manifest):
            return "blockers addressed after final bound round"
    for meta_path, meta in plan_review_successful_run_meta_records(data, target, plan_rel):
        if str(meta.get("plan_content_sha256") or "") == plan_hash:
            continue
        if plan_review_run_meta_matches_manifest(target, meta_path, manifest):
            continue
        if plan_review_run_meta_superseded_by_manifest(meta, manifest):
            continue
        return "rebinding round after a voided run"
    return ""


def plan_review_runtime_cap_errors(
    data: dict,
    target: Path,
    plan_path: Path,
    plan_rel: str,
    plan_hash: str,
    review_round: str,
    round_number: int,
    allow_r3_structural_critical: bool = False,
    exception_reason: str = "",
) -> list[str]:
    errors: list[str] = []
    manifest = load_plan_review_manifest(plan_review_manifest_path(data, target, plan_path))
    successful_records = plan_review_successful_run_meta_records(data, target, plan_rel)
    if round_number > PLAN_REVIEW_HARD_CAP_ROUNDS:
        # Belt and braces: the round-cap gate already refused, but no caller may reach a launch
        # past the hard cap through this path either.
        return [
            plan_review_hard_cap_refusal(
                round_number,
                unresolved_blockers=plan_review_bound_evidence_is_unfinalizable(manifest, plan_hash),
            )
        ]
    self_authorized = bool(exception_reason) or allow_r3_structural_critical
    if (
        len(successful_records) >= PLAN_REVIEW_TARGET_ROUNDS
        and not self_authorized
        and not plan_review_manifest_is_clean_for_hash(manifest, plan_hash)
    ):
        errors.append(
            f"plan review has already launched {len(successful_records)} successful Codex round(s) "
            f"for this source plan; the {PLAN_REVIEW_TARGET_ROUNDS}-round convergence target is "
            "exhausted and no convergence exception applies (no blockers were fixed since the "
            "bound round and there is no voided run to rebind), so carry unresolved findings into "
            "the implementation review focus list instead of spawning another plan-review loop"
        )
    for meta_path, meta in successful_records:
        if str(meta.get("round") or "") != str(review_round):
            continue
        if str(meta.get("plan_content_sha256") or "") != plan_hash:
            # The plan moved after this run, so the run is voided: it can never be finalized and
            # must not be held up as the reason to refuse a rebinding round.
            continue
        if not plan_review_run_meta_matches_manifest(target, meta_path, manifest):
            errors.append(
                f"existing successful Codex run for {review_round} is not finalized: {path_relative_to_target(target, meta_path)}; "
                "finalize that run instead of launching another one"
            )
    return errors


def write_plan_review_manifest(
    data: dict,
    target: Path,
    plan_path: Path,
    plan_rel: str,
    plan_hash: str,
    log_path: Path,
    review_command: str,
    reviewer: str,
    model: str,
    review_round: str,
    wrapper_exit_code: int,
    verdict: str,
    unresolved_critical_count: int,
    unresolved_p1_count: int,
    findings: list[dict],
    recorded_by: str,
    run_meta_path: Path | None = None,
    exception_note: str = "",
) -> tuple[Path, dict]:
    manifest_path = plan_review_manifest_path(data, target, plan_path)
    manifest_path.parent.mkdir(parents=True, exist_ok=True)
    manifest = {
        "schema": PLAN_REVIEW_SCHEMA,
        "classifier_version": PLAN_REVIEW_CLASSIFIER_VERSION,
        "recorded_by": recorded_by,
        "project": data["project"],
        "plan_path": plan_rel,
        "plan_content_sha256": plan_hash,
        "review_command": review_command,
        "log_path": path_relative_to_target(target, log_path),
        "log_sha256": file_sha256(log_path),
        "reviewer": reviewer,
        "reviewer_model": model,
        "round": review_round,
        "wrapper_exit_code": wrapper_exit_code,
        "verdict": verdict,
        "unresolved_critical_count": unresolved_critical_count,
        "unresolved_p1_count": unresolved_p1_count,
        "classified_findings": findings,
        "timestamp": now_iso(),
    }
    if run_meta_path is not None:
        manifest["review_run_meta_path"] = path_relative_to_target(target, run_meta_path)
        manifest["review_run_meta_sha256"] = file_sha256(run_meta_path)
    # Only past-target rounds carry a recorded exception, so a plan that converges within the
    # target rounds keeps a byte-identical manifest.
    if exception_note:
        manifest["exception_note"] = exception_note

    manifest_rel = path_relative_to_target(target, manifest_path)
    body = plan_review_evidence_body(manifest, manifest_rel)
    plan_text = plan_path.read_text(encoding="utf-8")
    updated = upsert_plan_review_evidence(plan_text, body)
    final_plan_hash = plan_content_sha256_from_text(updated)
    if final_plan_hash != plan_hash:
        raise SystemExit(
            "plan_review_write_error: Cross-Model Review Evidence update would change normalized plan content hash; "
            "no manifest or plan evidence was written. Restore the plan from git if needed and rerun review against a stable plan."
        )
    if updated != plan_text:
        plan_path.write_text(updated, encoding="utf-8")
    manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    return manifest_path, manifest


def run_plan_review(args: argparse.Namespace) -> int:
    data, _project_path, target = lane_project(args)
    plan_path = resolve_plan_path(target, args.plan).resolve(strict=False)
    source_root = planning_source_root(data, target)
    if not plan_path.exists() or not plan_path.is_file():
        print(f"plan_review_run_error: plan missing: {plan_path}", file=sys.stderr)
        return 1
    if not path_is_under(plan_path, source_root):
        print(f"plan_review_run_error: plan outside source-of-truth path: {plan_path} not under {source_root}", file=sys.stderr)
        return 1
    plan_rel = path_relative_to_target(target, plan_path)
    plan_hash = plan_content_sha256(plan_path)
    round_number = plan_review_round_number(args.review_round)
    exception_note = plan_review_exception_note(
        getattr(args, "exception_note", None),
        getattr(args, "structural_critical_evidence", None),
    )
    detected_reason = plan_review_self_authorized_reason(
        data, target, plan_path, plan_rel, plan_hash
    )
    # A run self-authorizes on a recorded note OR on a detected convergence/rebinding state; the
    # note only becomes mandatory when the round is finalized into the manifest.
    exception_reason = exception_note or detected_reason
    manifest_path = plan_review_manifest_path(data, target, plan_path)
    existing_manifest = load_plan_review_manifest(manifest_path)
    round_cap_errors = plan_review_round_cap_errors(
        round_number=round_number,
        structural_critical_evidence=getattr(args, "structural_critical_evidence", None),
        exception_reason=exception_reason,
        unresolved_blockers=plan_review_bound_evidence_is_unfinalizable(existing_manifest, plan_hash),
    )
    if round_cap_errors:
        for error in round_cap_errors:
            print(f"plan_review_run_error: {error}", file=sys.stderr)
        return 1
    finalize_requested = any(
        value is not None
        for value in [args.verdict, args.unresolved_critical_count, args.unresolved_p1_count, args.classified_findings_json]
    )
    if finalize_requested and (
        args.verdict is None
        or args.unresolved_critical_count is None
        or args.unresolved_p1_count is None
    ):
        print(
            "plan_review_run_error: inline finalization requires --verdict, --unresolved-critical-count, and --unresolved-p1-count; "
            "otherwise run without those flags, inspect the log, then call finalize-plan-review",
            file=sys.stderr,
        )
        return 1
    if (
        finalize_requested
        and round_number is not None
        and round_number > PLAN_REVIEW_TARGET_ROUNDS
        and not exception_note
    ):
        # Refuse BEFORE spending a Codex round: inline finalization writes the manifest, so it is
        # bound by the same recorded-exception rule as finalize-plan-review.
        print(
            f"plan_review_run_error: inline finalization of round {round_number} is past the "
            f"{PLAN_REVIEW_TARGET_ROUNDS}-round convergence target, so it requires a recorded "
            "exception: pass --exception-note '<reason>' (at least "
            f"{PLAN_REVIEW_EXCEPTION_NOTE_MIN_CHARS} characters)",
            file=sys.stderr,
        )
        return 1
    try:
        command_tokens = shlex.split(codex_plan_wrapper(data))
    except ValueError as exc:
        print(f"plan_review_run_error: adapter review.codexPlanWrapper is not shell-parseable: {exc}", file=sys.stderr)
        return 1
    if not command_tokens:
        print("plan_review_run_error: adapter review.codexPlanWrapper is empty", file=sys.stderr)
        return 1
    review_args = list(args.review_args or [])
    if review_args and review_args[0] == "--":
        review_args = review_args[1:]
    for index, token in enumerate(review_args):
        if token == "--plan" or token.startswith("--plan="):
            print("plan_review_run_error: review args cannot supply --plan; run-plan-review binds the source-of-truth plan itself", file=sys.stderr)
            return 1
        if index > 0 and review_args[index - 1] == "--plan":
            print("plan_review_run_error: review args cannot supply --plan; run-plan-review binds the source-of-truth plan itself", file=sys.stderr)
            return 1
    review_args.extend(["--plan", plan_rel])
    full_command = [*command_tokens, *review_args]
    review_command = " ".join(shlex.quote(part) for part in full_command)
    command_errors = review_command_errors(data, review_command, plan_rel, plan_hash)
    if command_errors:
        for error in command_errors:
            print(f"plan_review_run_error: {error}", file=sys.stderr)
        return 1
    runtime_cap_errors = plan_review_runtime_cap_errors(
        data=data,
        target=target,
        plan_path=plan_path,
        plan_rel=plan_rel,
        plan_hash=plan_hash,
        review_round=args.review_round,
        round_number=round_number,
        exception_reason=exception_reason,
    )
    if runtime_cap_errors:
        for error in runtime_cap_errors:
            print(f"plan_review_run_error: {error}", file=sys.stderr)
        return 1
    if round_number is not None and round_number > PLAN_REVIEW_TARGET_ROUNDS:
        print(
            f"plan_review_exception: convergence round {round_number} of "
            f"{PLAN_REVIEW_HARD_CAP_ROUNDS} - {exception_reason}"
        )
    codex_env = codex_fast_mode_env(data, target, os.environ.copy())

    runs_dir = configured_path(target, data["laneState"]["runsDir"]) / "plan-review"
    runs_dir.mkdir(parents=True, exist_ok=True)
    stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    log_path = runs_dir / f"{stamp}-{slugify(plan_path.stem, 'plan')}-{slugify(args.review_round, 'round')}.log"
    started_at = now_iso()
    try_write_event(
        data,
        target,
        event="plan_review_started",
        severity="info",
        plain=f"Codex plan review {args.review_round} started for {plan_rel}.",
        next_action="Wait for this single review round to finish, then classify the log with finalize-plan-review.",
        refs={"plan": plan_rel, "log": path_display(target, log_path)},
    )
    with log_path.open("w", encoding="utf-8") as log:
        log.write("MINERVIT_PLAN_REVIEW_RUN_V1\n")
        log.write(f"plan_path: {plan_rel}\n")
        log.write(f"plan_content_sha256: {plan_hash}\n")
        log.write("review_scope: plan-only\n")
        log.write("code_diff_review: no\n")
        log.write("native_review_required: yes\n")
        log.write("native_review_requirement: authoring-model native/Superpowers review on current plan before R1 only\n")
        if round_number is not None and round_number > PLAN_REVIEW_TARGET_ROUNDS:
            log.write("plan_review_exception: yes\n")
            log.write(f"plan_review_exception_reason: {exception_reason}\n")
        log.write(f"codex_fast_mode: {codex_fast_mode_status(data)}\n")
        log.write(f"review_command: {review_command}\n")
        log.write(f"started_at: {started_at}\n")
        log.write(f"no_output_timeout_seconds: {args.no_output_timeout_seconds}\n")
        log.write(f"{PLAN_REVIEW_OUTPUT_START}\n")
        log.flush()
        wrapper_exit_code, stale_marker_path = run_process_with_no_output_watchdog(
            full_command,
            cwd=target,
            env=codex_env,
            log_handle=log,
            log_path=log_path,
            no_output_timeout_seconds=int(args.no_output_timeout_seconds),
        )
        finished_at = now_iso()
        log.write(f"\n{PLAN_REVIEW_OUTPUT_END}\n")
        log.write(f"finished_at: {finished_at}\n")
        log.write(f"wrapper_exit_code: {wrapper_exit_code}\n")
        if stale_marker_path:
            log.write(f"stale_marker: {stale_marker_path}\n")

    meta_path = plan_review_run_meta_path(log_path)
    meta = {
        "schema": PLAN_REVIEW_RUN_SCHEMA,
        "plan_path": plan_rel,
        "plan_content_sha256": plan_hash,
        "review_scope": "plan-only",
        "code_diff_review": False,
        "native_review_required": True,
        "native_review_requirement": "authoring-model native/Superpowers review on current plan before R1 only",
        "exception_round": round_number is not None and round_number > PLAN_REVIEW_TARGET_ROUNDS,
        "exception_reason": exception_reason,
        "codex_fast_mode": codex_fast_mode_enabled(data),
        "review_command": review_command,
        "log_path": path_relative_to_target(target, log_path),
        "log_sha256": file_sha256(log_path),
        "wrapper_exit_code": wrapper_exit_code,
        "no_output_timeout_seconds": int(args.no_output_timeout_seconds),
        "stale_marker_path": path_relative_to_target(target, stale_marker_path) if stale_marker_path else "",
        "round": args.review_round,
        "reviewer": "codex",
        "reviewer_model": args.model,
        "started_at": started_at,
        "finished_at": finished_at,
    }
    meta_path.write_text(json.dumps(meta, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    review_failed_stale = wrapper_exit_code == 124 and stale_marker_path is not None
    review_finish_next_action = (
        "Recover the stale review process and rerun this configured review round; do not classify the frozen log as review evidence."
        if review_failed_stale
        else "Inspect and classify the printed log once; do not rerun Codex just to bind the manifest."
    )
    try_write_event(
        data,
        target,
        event="plan_review_finished" if wrapper_exit_code == 0 else "plan_review_failed",
        severity="ok" if wrapper_exit_code == 0 else "fail",
        plain=f"Codex plan review {args.review_round} finished for {plan_rel} with exit code {wrapper_exit_code}.",
        next_action=review_finish_next_action,
        refs={"plan": plan_rel, "log": path_display(target, log_path), "meta": path_display(target, meta_path)},
    )
    if not finalize_requested:
        print(f"plan_review_run_log: {log_path}")
        print(f"plan_review_run_meta: {meta_path}")
        print("plan_review_scope: plan-only")
        print("plan_review_code_diff_review: no")
        print("plan_review_native_review_required: one authoring-model native/Superpowers self-check before R1 only")
        print(f"plan_review_codex_fast_mode: {codex_fast_mode_status(data)}")
        print(f"plan_review_run_command: {review_command}")
        print(f"plan_review_run_exit_code: {wrapper_exit_code}")
        print(f"plan_review_no_output_timeout_seconds: {int(args.no_output_timeout_seconds)}")
        if stale_marker_path:
            print(f"plan_review_stale_marker: {stale_marker_path}")
        if review_failed_stale:
            print(
                "plan_review_next_action: review produced no fresh output before the watchdog fired; recover or rerun "
                "the configured review round and do not classify this frozen log as evidence"
            )
        else:
            # Past the target the finalize that follows REQUIRES a recorded note, so the printed
            # guidance must carry the flag: an agent following the tool must never hit the error.
            note_hint = shlex.quote(exception_reason or "<reason this round was needed>")
            exception_flag = (
                f" --exception-note {note_hint}"
                if round_number is not None and round_number > PLAN_REVIEW_TARGET_ROUNDS
                else ""
            )
            print(
                "plan_review_next_action: inspect this log once, classify findings, then run "
                f"`tautline finalize-plan-review --target {guard_target_argument(target)} "
                f"--plan {shlex.quote(plan_rel)} "
                f"--log {shlex.quote(path_relative_to_target(target, log_path))} --round {shlex.quote(args.review_round)} "
                "--verdict <clean|clean-with-deferrals|blocked> --unresolved-critical-count <n> "
                f"--unresolved-p1-count <n>{exception_flag}`; "
                "do not rerun Codex just to bind the manifest"
            )
        return wrapper_exit_code
    return finalize_trusted_plan_review(
        data=data,
        target=target,
        plan_path=plan_path,
        log_path=log_path,
        review_round=args.review_round,
        model=args.model,
        verdict=args.verdict,
        unresolved_critical_count=args.unresolved_critical_count,
        unresolved_p1_count=args.unresolved_p1_count,
        classified_findings_json=args.classified_findings_json,
        recorded_by="tautline run-plan-review",
        error_prefix="plan_review_run_error",
        structural_critical_evidence=getattr(args, "structural_critical_evidence", None),
        exception_note=getattr(args, "exception_note", None),
    )


def finalize_trusted_plan_review(
    *,
    data: dict,
    target: Path,
    plan_path: Path,
    log_path: Path,
    review_round: str,
    model: str,
    verdict: str,
    unresolved_critical_count: int,
    unresolved_p1_count: int,
    classified_findings_json: str | None,
    recorded_by: str,
    error_prefix: str,
    structural_critical_evidence: str | None = None,
    exception_note: str | None = None,
) -> int:
    source_root = planning_source_root(data, target)
    errors: list[str] = []
    if not plan_path.exists() or not plan_path.is_file():
        errors.append(f"plan missing: {plan_path}")
    elif not path_is_under(plan_path, source_root):
        errors.append(f"plan outside source-of-truth path: {plan_path} not under {source_root}")
    if not log_path.exists() or not log_path.is_file():
        errors.append(f"plan review log missing: {log_path}")
    elif not path_is_under(log_path, target):
        errors.append(f"plan review log must live under the target lane: {log_path}")
    if errors:
        for error in errors:
            print(f"{error_prefix}: {error}", file=sys.stderr)
        return 1

    plan_rel = path_relative_to_target(target, plan_path)
    plan_hash = plan_content_sha256(plan_path)
    round_number = plan_review_round_number(review_round)
    # Finalizing WRITES the manifest, so a past-target round must carry a recorded note here: a
    # detected convergence state authorizes the run, but only an explicit reason is recordable.
    recorded_note = plan_review_exception_note(exception_note, structural_critical_evidence)
    # The past-cap REMEDY is a property of the BOUND manifest, never of the round being submitted:
    # the round the cap refuses is never written, so its counts say nothing about what the operator
    # can actually finalize. Choosing on the submitted counts would print "finalize the existing
    # review evidence" whenever THIS round happens to be clean -- even when the bound evidence is
    # stale and plan-finalization-precheck provably rejects it. Refusal itself stays unconditional.
    round_cap_errors = plan_review_round_cap_errors(
        round_number=round_number,
        exception_reason=recorded_note,
        unresolved_blockers=plan_review_bound_evidence_is_unfinalizable(
            load_plan_review_manifest(plan_review_manifest_path(data, target, plan_path)),
            plan_hash,
        ),
    )
    if round_cap_errors:
        for error in round_cap_errors:
            print(f"{error_prefix}: {error}", file=sys.stderr)
        return 1

    findings = parse_classified_findings(classified_findings_json)
    derived_critical, derived_p1 = finding_counts(findings)
    if derived_critical != unresolved_critical_count or derived_p1 != unresolved_p1_count:
        print(f"{error_prefix}: unresolved Critical/P1 counts must match classified-findings-json", file=sys.stderr)
        return 1

    run_meta, run_meta_path, run_meta_load_errors = load_plan_review_run_meta(log_path)
    if run_meta_load_errors:
        for error in run_meta_load_errors:
            print(f"{error_prefix}: {error}", file=sys.stderr)
        return 1
    assert run_meta is not None
    for required_field in ["review_command", "wrapper_exit_code", "review_scope", "code_diff_review"]:
        if required_field not in run_meta:
            print(f"{error_prefix}: plan review run metadata missing field: {required_field}", file=sys.stderr)
            return 1
    review_command = str(run_meta["review_command"])
    try:
        wrapper_exit_code = int(run_meta["wrapper_exit_code"])
    except (TypeError, ValueError):
        print(f"{error_prefix}: plan review run metadata wrapper_exit_code must be an integer", file=sys.stderr)
        return 1
    if run_meta.get("review_scope") != "plan-only" or run_meta.get("code_diff_review") is not False:
        print(f"{error_prefix}: plan review run metadata must prove plan-only scope and code_diff_review=false", file=sys.stderr)
        return 1
    run_meta_errors = validate_plan_review_run_meta(
        run_meta,
        target,
        plan_rel,
        plan_hash,
        review_command,
        log_path,
        wrapper_exit_code,
    )
    if run_meta_errors:
        for error in run_meta_errors:
            print(f"{error_prefix}: {error}", file=sys.stderr)
        return 1

    command_errors = review_command_errors(data, review_command, plan_rel, plan_hash)
    if command_errors:
        for error in command_errors:
            print(f"{error_prefix}: {error}", file=sys.stderr)
        return 1
    log_text = log_path.read_text(encoding="utf-8", errors="replace")
    log_errors = review_log_errors(log_text, review_command, plan_rel, plan_hash)
    log_errors.extend(
        review_log_verdict_errors(
            log_text,
            verdict,
            unresolved_critical_count,
            unresolved_p1_count,
            findings,
        )
    )
    if log_errors:
        for error in log_errors:
            print(f"{error_prefix}: {error}", file=sys.stderr)
        return 1

    current_blockers = plan_review_blocker_total(unresolved_critical_count, unresolved_p1_count)
    existing_manifest_path = plan_review_manifest_path(data, target, plan_path)
    existing_manifest_present = existing_manifest_path.exists()
    previous_blockers = previous_plan_review_blocker_total(existing_manifest_path)
    convergence_errors: list[str] = []
    if current_blockers > 0:
        if round_number >= PLAN_REVIEW_HARD_CAP_ROUNDS:
            convergence_errors.append(
                f"plan review reached the hard cap of {PLAN_REVIEW_HARD_CAP_ROUNDS} rounds with "
                "unresolved Critical/P1 blockers; the split is mandatory: decompose this plan into "
                "smaller source-of-truth plans, review each of those, and continue; do not ask the "
                "operator to choose a path and do not launch another round"
            )
        elif round_number >= PLAN_REVIEW_DECOMPOSE_IF_STALLED_ROUND and not existing_manifest_present:
            convergence_errors.append(
                "plan review round 3 or later has unresolved Critical/P1 blockers but no previous manifest to prove convergence; split the work unless an actual prior review manifest was lost and can be restored from its original log; do not ask for a path choice"
            )
        elif round_number >= PLAN_REVIEW_DECOMPOSE_IF_STALLED_ROUND and previous_blockers is None:
            convergence_errors.append(
                "plan review round 3 or later cannot read previous blocker evidence; repair the manifest or split the work into smaller source-of-truth plans; do not ask for a path choice"
            )
        elif round_number >= PLAN_REVIEW_DECOMPOSE_IF_STALLED_ROUND and current_blockers >= previous_blockers:
            convergence_errors.append(
                "plan review has not reduced unresolved Critical/P1 blockers by round 3; do not ask for a path choice; split the work into smaller source-of-truth plans, review those plans successfully, and continue"
            )

    manifest_path, _manifest = write_plan_review_manifest(
        data=data,
        target=target,
        plan_path=plan_path,
        plan_rel=plan_rel,
        plan_hash=plan_hash,
        log_path=log_path,
        review_command=review_command,
        reviewer="codex",
        model=model,
        review_round=review_round,
        wrapper_exit_code=wrapper_exit_code,
        verdict=verdict,
        unresolved_critical_count=unresolved_critical_count,
        unresolved_p1_count=unresolved_p1_count,
        findings=findings,
        recorded_by=recorded_by,
        run_meta_path=run_meta_path,
        exception_note=recorded_note,
    )
    print(f"plan_review_manifest: {manifest_path}")
    print(f"plan_review_run_log: {log_path}")
    print(f"plan_review_run_meta: {run_meta_path}")
    print(f"plan_review_run_command: {review_command}")
    next_round = (round_number or 0) + 1
    if convergence_errors:
        next_action = (
            "decompose this plan into smaller source-of-truth plans and review those, or transfer "
            "non-blocking findings into implementation review focus"
        )
    elif unresolved_critical_count or unresolved_p1_count:
        next_action = (
            f"fix the Critical/P1 blockers and self-authorize round {next_round} of "
            f"{PLAN_REVIEW_HARD_CAP_ROUNDS} with --exception-note (no operator authorization "
            "needed), or transfer non-blocking findings into implementation review focus"
            if next_round > PLAN_REVIEW_TARGET_ROUNDS
            else (
                f"fix the Critical/P1 blockers and rerun round {next_round} of "
                f"{PLAN_REVIEW_TARGET_ROUNDS}"
            )
        )
    else:
        next_action = "run plan-finalization-precheck and finalize only if it passes"
    # Round status renders against the TARGET on the normal path, so a plan that converges within
    # two rounds prints exactly what it printed before the ladder existed. The hard cap governs
    # refusal, not display.
    within_target = (round_number or 0) <= PLAN_REVIEW_TARGET_ROUNDS
    round_scale = PLAN_REVIEW_TARGET_ROUNDS if within_target else PLAN_REVIEW_HARD_CAP_ROUNDS
    print(
        "plan_review_round_status: "
        f"round {round_number} of {round_scale}"
        f"{' (recorded convergence exception)' if recorded_note else ''}; "
        f"verdict={verdict}; "
        f"unresolved_critical={unresolved_critical_count}; "
        f"unresolved_p1={unresolved_p1_count}; "
        f"next_action={next_action}"
    )
    severity = "ok" if not convergence_errors and not current_blockers and wrapper_exit_code == 0 else "block"
    try_write_event(
        data,
        target,
        event="plan_review_finalized",
        severity=severity,
        plain=f"Plan review evidence was finalized for {plan_rel}: {verdict}, Critical={unresolved_critical_count}, P1={unresolved_p1_count}.",
        next_action=next_action,
        refs={
            "plan": plan_rel,
            "manifest": path_display(target, manifest_path),
            "log": path_display(target, log_path),
            # T2: structured fields the T3 reducer will read to choose plan_review_clean vs.
            # plan_review_blocked -- never freeform text, always one of PLAN_REVIEW_ALL_VERDICTS.
            "verdict": verdict,
            "unresolved_critical_count": str(unresolved_critical_count),
            "unresolved_p1_count": str(unresolved_p1_count),
        },
    )
    if convergence_errors:
        for error in convergence_errors:
            print(f"plan_review_convergence_error: {error}", file=sys.stderr)
        return 2
    return wrapper_exit_code


def finalize_plan_review(args: argparse.Namespace) -> int:
    data, _project_path, target = lane_project(args)
    plan_path = resolve_plan_path(target, args.plan).resolve(strict=False)
    log_path = (args.log.expanduser() if args.log.is_absolute() else target / args.log).resolve(strict=False)
    return finalize_trusted_plan_review(
        data=data,
        target=target,
        plan_path=plan_path,
        log_path=log_path,
        review_round=args.review_round,
        model=args.model,
        verdict=args.verdict,
        unresolved_critical_count=args.unresolved_critical_count,
        unresolved_p1_count=args.unresolved_p1_count,
        classified_findings_json=args.classified_findings_json,
        recorded_by="tautline finalize-plan-review",
        error_prefix="plan_review_finalize_error",
        structural_critical_evidence=getattr(args, "structural_critical_evidence", None),
        exception_note=getattr(args, "exception_note", None),
    )


def record_plan_review(args: argparse.Namespace) -> int:
    data, _project_path, target = lane_project(args)
    plan_path = resolve_plan_path(target, args.plan).resolve(strict=False)
    source_root = planning_source_root(data, target)
    errors: list[str] = []
    if not plan_path.exists() or not plan_path.is_file():
        errors.append(f"plan missing: {plan_path}")
    elif not path_is_under(plan_path, source_root):
        errors.append(f"plan outside source-of-truth path: {plan_path} not under {source_root}")
    if errors:
        for error in errors:
            print(f"plan_review_record_error: {error}", file=sys.stderr)
        return 1

    log_path = (args.log.expanduser() if args.log.is_absolute() else target / args.log).resolve(strict=False)
    if not log_path.exists() or not log_path.is_file():
        print(f"plan_review_record_error: log missing: {log_path}", file=sys.stderr)
        return 1
    if not path_is_under(log_path, target):
        print(
            f"plan_review_record_error: log must live under the target lane so the manifest can use a portable relative path: {log_path}",
            file=sys.stderr,
        )
        return 1
    if log_path.stat().st_mtime_ns < plan_path.stat().st_mtime_ns:
        print("plan_review_record_error: review log is older than the current plan; rerun review against the current plan", file=sys.stderr)
        return 1

    plan_rel = path_relative_to_target(target, plan_path)
    plan_hash = plan_content_sha256(plan_path)

    # record-plan-review writes the plan-review manifest schema too, so the convergence ladder
    # binds it identically: a secondary writer must never be a way around the recorded exception.
    # The past-cap remedy is read off the BOUND manifest for the same reason it is in the other two
    # writers -- the refused round is never written, so its counts cannot say whether the operator
    # has finalizable evidence in hand. Refusal itself remains unconditional.
    recorded_note = plan_review_exception_note(getattr(args, "exception_note", None))
    round_cap_errors = plan_review_round_cap_errors(
        round_number=plan_review_round_number(args.review_round),
        exception_reason=recorded_note,
        unresolved_blockers=plan_review_bound_evidence_is_unfinalizable(
            load_plan_review_manifest(plan_review_manifest_path(data, target, plan_path)),
            plan_hash,
        ),
    )
    if round_cap_errors:
        for error in round_cap_errors:
            print(f"plan_review_record_error: {error}", file=sys.stderr)
        return 1

    findings = parse_classified_findings(args.classified_findings_json)
    review_command = args.review_command or codex_plan_wrapper(data)
    if args.reviewer.lower() != "codex":
        print("plan_review_record_error: reviewer must be codex for cross-model plan finalization evidence", file=sys.stderr)
        return 1
    command_errors = review_command_errors(data, review_command, plan_rel, plan_hash)
    if command_errors:
        for error in command_errors:
            print(f"plan_review_record_error: {error}", file=sys.stderr)
        return 1
    derived_critical, derived_p1 = finding_counts(findings)
    if derived_critical != args.unresolved_critical_count or derived_p1 != args.unresolved_p1_count:
        print(
            "plan_review_record_error: unresolved Critical/P1 counts must match classified-findings-json",
            file=sys.stderr,
        )
        return 1
    log_text = log_path.read_text(encoding="utf-8", errors="replace")
    log_errors = review_log_errors(log_text, review_command, plan_rel, plan_hash)
    log_errors.extend(
        review_log_verdict_errors(
            log_text,
            args.verdict,
            args.unresolved_critical_count,
            args.unresolved_p1_count,
            findings,
        )
    )
    if log_errors:
        for error in log_errors:
            print(f"plan_review_record_error: {error}", file=sys.stderr)
        return 1
    run_meta, run_meta_path, run_meta_load_errors = load_plan_review_run_meta(log_path)
    if run_meta_load_errors:
        for error in run_meta_load_errors:
            print(f"plan_review_record_error: {error}", file=sys.stderr)
        return 1
    assert run_meta is not None
    run_meta_errors = validate_plan_review_run_meta(
        run_meta,
        target,
        plan_rel,
        plan_hash,
        review_command,
        log_path,
        args.wrapper_exit_code,
    )
    if run_meta_errors:
        for error in run_meta_errors:
            print(f"plan_review_record_error: {error}", file=sys.stderr)
        return 1
    manifest_path = plan_review_import_manifest_path(data, target, plan_path)
    manifest_path.parent.mkdir(parents=True, exist_ok=True)
    manifest = {
        "schema": PLAN_REVIEW_SCHEMA,
        "classifier_version": PLAN_REVIEW_CLASSIFIER_VERSION,
        "recorded_by": "tautline record-plan-review",
        "project": data["project"],
        "plan_path": plan_rel,
        "plan_content_sha256": plan_hash,
        "review_command": review_command,
        "log_path": path_relative_to_target(target, log_path),
        "log_sha256": file_sha256(log_path),
        "review_run_meta_path": path_relative_to_target(target, run_meta_path),
        "review_run_meta_sha256": file_sha256(run_meta_path),
        "reviewer": args.reviewer,
        "reviewer_model": args.model,
        "round": args.review_round,
        "wrapper_exit_code": args.wrapper_exit_code,
        "verdict": args.verdict,
        "unresolved_critical_count": args.unresolved_critical_count,
        "unresolved_p1_count": args.unresolved_p1_count,
        "classified_findings": findings,
        "timestamp": now_iso(),
    }
    if recorded_note:
        manifest["exception_note"] = recorded_note
    manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")

    print(f"plan_review_manifest: {manifest_path}")
    print(f"plan_review_plan: {plan_path}")
    print(f"plan_review_verdict: {args.verdict}")
    print("plan_review_record_warning: diagnostic import only; wrote .imported.json and did not update source plan evidence")
    print("plan_review_record_warning: plan-finalization-precheck trusts only the canonical run-plan-review manifest")
    return 0


def plan_finalization_precheck_errors(data: dict, target: Path, plan_arg: Path) -> tuple[list[str], Path | None, Path | None]:
    errors: list[str] = []
    plan_path = resolve_plan_path(target, plan_arg).resolve(strict=False)
    source_root = planning_source_root(data, target)
    if not plan_path.exists() or not plan_path.is_file():
        return [f"plan missing: {plan_path}"], plan_path, None
    if not path_is_under(plan_path, source_root):
        errors.append(f"plan outside source-of-truth path: {plan_path} not under {source_root}")
    exemption_errors = plan_review_exemption_errors(data, plan_path)
    if exemption_errors is not None:
        errors.extend(exemption_errors)
        return errors, plan_path, None
    errors.extend(validate_plan_substance(plan_path))
    raw_plan_text = plan_path.read_text(encoding="utf-8", errors="replace")
    errors.extend(behavior_source_material_citation_errors(data, raw_plan_text))
    errors.extend(behavior_role_vocabulary_errors(data, raw_plan_text))
    ac_cfg = data.get("planAcceptance") or DEFAULT_PLAN_ACCEPTANCE
    if ac_cfg.get("enforcement") == "block":
        errors.extend(plan_acceptance_binding_issues(raw_plan_text, ac_cfg, _plan_pending_tags(data)))

    manifest_path = plan_review_manifest_path(data, target, plan_path)
    if not manifest_path.exists():
        errors.append(f"plan review manifest missing: {manifest_path}")
        return errors, plan_path, manifest_path
    try:
        manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        errors.append(f"plan review manifest invalid JSON: {exc}")
        return errors, plan_path, manifest_path

    required_fields = [
        "schema",
        "classifier_version",
        "recorded_by",
        "plan_path",
        "plan_content_sha256",
        "review_command",
        "log_path",
        "log_sha256",
        "review_run_meta_path",
        "review_run_meta_sha256",
        "reviewer",
        "reviewer_model",
        "round",
        "wrapper_exit_code",
        "verdict",
        "unresolved_critical_count",
        "unresolved_p1_count",
        "classified_findings",
        "timestamp",
    ]
    for field in required_fields:
        if field not in manifest:
            errors.append(f"plan review manifest missing field: {field}")
    if errors:
        return errors, plan_path, manifest_path

    expected_plan_rel = path_relative_to_target(target, plan_path)
    if manifest["schema"] != PLAN_REVIEW_SCHEMA:
        errors.append(f"plan review manifest schema mismatch: {manifest['schema']}")
    if manifest["classifier_version"] != PLAN_REVIEW_CLASSIFIER_VERSION:
        errors.append(
            f"plan review manifest classifier_version is stale: {manifest['classifier_version']} != {PLAN_REVIEW_CLASSIFIER_VERSION}"
        )
    if manifest["plan_path"] != expected_plan_rel:
        errors.append(f"plan review manifest plan_path mismatch: {manifest['plan_path']} != {expected_plan_rel}")
    current_plan_hash = plan_content_sha256(plan_path)
    if manifest["plan_content_sha256"] != current_plan_hash:
        errors.append("plan review manifest is stale: plan_content_sha256 does not match current plan content")
    if manifest["recorded_by"] not in PLAN_REVIEW_TRUSTED_RECORDERS:
        errors.append(f"plan review manifest recorded_by is not trusted: {manifest['recorded_by']}")
    try:
        wrapper_exit_code = int(manifest["wrapper_exit_code"])
    except (TypeError, ValueError):
        wrapper_exit_code = -1
    if wrapper_exit_code != 0:
        errors.append(f"plan review wrapper_exit_code is not clean: {manifest['wrapper_exit_code']}")
    if manifest["verdict"] not in PLAN_REVIEW_ALLOWED_VERDICTS:
        errors.append(f"plan review verdict does not allow finalization: {manifest['verdict']}")
    if str(manifest.get("reviewer", "")).lower() != "codex":
        errors.append("plan review manifest must be from the configured Codex cross-model reviewer")
    unresolved_counts: dict[str, int | None] = {}
    for key in ["unresolved_critical_count", "unresolved_p1_count"]:
        try:
            count = int(manifest[key])
        except (TypeError, ValueError):
            errors.append(f"plan review manifest {key} must be an integer")
            unresolved_counts[key] = None
            continue
        unresolved_counts[key] = count
        if count != 0:
            errors.append(f"plan review manifest {key} must be 0 before finalization")
    if not isinstance(manifest["classified_findings"], list):
        errors.append("plan review manifest classified_findings must be an array")
    else:
        derived_critical, derived_p1 = finding_counts(manifest["classified_findings"])
        if unresolved_counts.get("unresolved_critical_count") != derived_critical:
            errors.append("plan review manifest unresolved_critical_count does not match classified_findings")
        if unresolved_counts.get("unresolved_p1_count") != derived_p1:
            errors.append("plan review manifest unresolved_p1_count does not match classified_findings")
        if manifest["verdict"] == "clean-with-deferrals" and not manifest["classified_findings"]:
            errors.append("clean-with-deferrals requires classified_findings evidence")

    review_command = str(manifest["review_command"])
    errors.extend(review_command_errors(data, review_command, expected_plan_rel, current_plan_hash))

    log_path = cli_path(target, str(manifest["log_path"])).resolve(strict=False)
    if not path_is_under(log_path, target):
        errors.append(f"plan review log path is not target-relative: {manifest['log_path']}")
    elif not log_path.exists() or not log_path.is_file():
        errors.append(f"plan review log missing: {log_path}")
    else:
        log_hash = file_sha256(log_path)
        if log_hash != manifest["log_sha256"]:
            errors.append("plan review log hash mismatch")
        log_text = log_path.read_text(encoding="utf-8", errors="replace")
        errors.extend(review_log_errors(log_text, review_command, expected_plan_rel, current_plan_hash))
        if unresolved_counts.get("unresolved_critical_count") is not None and unresolved_counts.get("unresolved_p1_count") is not None:
            errors.extend(
                review_log_verdict_errors(
                    log_text,
                    str(manifest["verdict"]),
                    int(unresolved_counts["unresolved_critical_count"]),
                    int(unresolved_counts["unresolved_p1_count"]),
                    manifest["classified_findings"] if isinstance(manifest.get("classified_findings"), list) else [],
                )
            )

    run_meta_path = cli_path(target, str(manifest["review_run_meta_path"])).resolve(strict=False)
    if not path_is_under(run_meta_path, target):
        errors.append(f"plan review run metadata path is not target-relative: {manifest['review_run_meta_path']}")
    elif not run_meta_path.exists() or not run_meta_path.is_file():
        errors.append(f"plan review run metadata missing: {run_meta_path}")
    else:
        run_meta_hash = file_sha256(run_meta_path)
        if run_meta_hash != manifest["review_run_meta_sha256"]:
            errors.append("plan review run metadata hash mismatch")
        try:
            run_meta = json.loads(run_meta_path.read_text(encoding="utf-8"))
        except json.JSONDecodeError as exc:
            errors.append(f"plan review run metadata invalid JSON: {exc}")
        else:
            errors.extend(
                validate_plan_review_run_meta(
                    run_meta,
                    target,
                    expected_plan_rel,
                    current_plan_hash,
                    review_command,
                    log_path,
                    wrapper_exit_code,
                )
            )

    manifest_rel = path_relative_to_target(target, manifest_path)
    expected_evidence = plan_review_evidence_body(manifest, manifest_rel).strip()
    evidence_section = markdown_section(raw_plan_text, PLAN_REVIEW_EVIDENCE_HEADING)
    if not evidence_section:
        errors.append("plan is missing Cross-Model Review Evidence section")
    elif not plan_review_evidence_section_matches(evidence_section, expected_evidence):
        errors.append("plan Cross-Model Review Evidence section does not match the current manifest")

    return errors, plan_path, manifest_path


def guard_target_argument(target: Path) -> str:
    """Shell-quoted absolute target for command examples printed by the guards.

    `--target .` is ambiguous on a multi-agent machine: it names whatever checkout the agent
    happens to be sitting in, which may be a worktree owned by another lane. Every guard remedy
    renders the resolved target instead.
    """
    return shlex.quote(str(target.resolve(strict=False)))


def plan_review_recovery_instruction(data: dict, target: Path, plan_path: Path | None) -> str:
    arg = guard_target_argument(target)
    if plan_path is None:
        return (
            f"run `tautline run-plan-review --target {arg} --plan <source-of-truth-plan>`, "
            "inspect/classify that one trusted log, run "
            f"`tautline finalize-plan-review --target {arg} --plan <source-of-truth-plan> "
            "--log <printed-log> --verdict <clean|clean-with-deferrals|blocked> "
            "--unresolved-critical-count <n> --unresolved-p1-count <n>`, then rerun "
            f"`tautline plan-finalization-precheck --target {arg} --plan <source-of-truth-plan>`"
        )
    try:
        plan_rel = path_relative_to_target(target, plan_path)
    except ValueError:
        plan_rel = str(plan_path)
    plan_arg = shlex.quote(plan_rel)
    review_command = f"tautline run-plan-review --target {arg} --plan {plan_arg}"
    precheck_command = f"tautline plan-finalization-precheck --target {arg} --plan {plan_arg}"
    return (
        f"run `{review_command}` once for the review round. Inspect/classify the printed log, "
        "then run "
        f"`tautline finalize-plan-review --target {arg} --plan {plan_arg} --log <printed-log> "
        "--verdict <clean|clean-with-deferrals|blocked> "
        "--unresolved-critical-count <n> --unresolved-p1-count <n>`. If Codex reports Critical/P1, "
        f"fix the plan and rerun the next review round. Then run `{precheck_command}`. Do not "
        "rerun Codex just to bind the manifest. Do not run `record-plan-review`, hand-edit "
        "`.plan-reviews`, disable hooks, skip ExitPlanMode, or ask the human operator to choose a "
        "bypass."
    )


def plan_finalization_precheck(args: argparse.Namespace) -> int:
    data, _project_path, target = lane_project(args)
    errors, plan_path, manifest_path = plan_finalization_precheck_errors(data, target, args.plan)
    ac_cfg = data.get("planAcceptance") or DEFAULT_PLAN_ACCEPTANCE
    if ac_cfg.get("enforcement") == "warn" and plan_path and plan_path.exists():
        for issue in plan_acceptance_binding_issues(
            plan_path.read_text(encoding="utf-8", errors="replace"), ac_cfg, _plan_pending_tags(data)
        ):
            print(f"plan_finalization_precheck_warning: {issue}", file=sys.stderr)
    if errors:
        for error in errors:
            print(f"plan_finalization_precheck_error: {error}", file=sys.stderr)
        if plan_path:
            print(f"plan_finalization_plan: {plan_path}", file=sys.stderr)
        if manifest_path:
            print(f"plan_finalization_manifest: {manifest_path}", file=sys.stderr)
        print(
            "plan_finalization_next_action: " + plan_review_recovery_instruction(data, target, plan_path),
            file=sys.stderr,
        )
        return 1
    print("plan_finalization_precheck: pass")
    print(f"plan_finalization_plan: {plan_path}")
    if manifest_path is not None:
        print(f"plan_finalization_manifest: {manifest_path}")
    else:
        print("plan_finalization_manifest: review-exempt")
    return 0


def hook_decision(reason: str) -> None:
    print(json.dumps({"decision": "block", "reason": reason}, indent=2))


def hook_additional_context(event_name: str, context: str) -> None:
    print(
        json.dumps(
            {
                "hookSpecificOutput": {
                    "hookEventName": event_name,
                    "additionalContext": context,
                }
            },
            indent=2,
        )
    )


def text_contains_any(text: str, phrases: list[str]) -> bool:
    return guards_module().text_contains_any(text, phrases)


def response_is_rca_shaped(text: str) -> bool:
    lower = text.lower()
    if "# rca" in lower or "# methodology regression rca" in lower:
        return True
    if re.search(r"(^|\n)\s*(rca|root cause)\s*[:\-]", lower):
        return True
    if "rule violated" in lower and ("what i did wrong" in lower or "root cause" in lower or "why i" in lower):
        return True
    headings = ["## incident summary", "## root cause", "## violated rule", "## fix proposal"]
    return sum(1 for heading in headings if heading in lower) >= 2


def response_guard_policy_scan_text(text: str) -> str:
    return guards_module().response_guard_policy_scan_text(text)


def response_guard_monitor_scan_text(text: str) -> str:
    return guards_module().response_guard_monitor_scan_text(text)


def response_guard_context_scan_text(text: str) -> str:
    return guards_module().response_guard_context_scan_text(text)


def response_has_forbidden_opt_in(text: str, *, human_discussion_request: bool = False) -> bool:
    line_scan = re.sub(r"```.*?```", " ", text, flags=re.S)
    line_scan = re.sub(r"`[^`\n]*`", " ", line_scan).lower()
    for line in line_scan.splitlines():
        if line.lstrip().startswith(">"):
            continue
        if re.search(r"^\s*say\s+['\"]?keep going['\"]?\b", line):
            return True
    scan_text = response_guard_policy_scan_text(text)
    action_verbs = "|".join(re.escape(verb) for verb in RESPONSE_GUARD_OPT_IN_ACTION_VERBS)
    discussion_action_verbs = "|".join(re.escape(verb) for verb in RESPONSE_GUARD_DISCUSSION_FORBIDDEN_ACTION_VERBS)
    has_discussion_permission_menu = bool(
        re.search(r"\bpath\s*1\b", scan_text) and re.search(r"\bpath\s*[23]\b", scan_text)
        or re.search(r"\boption\s*1\b", scan_text) and re.search(r"\boption\s*[23]\b", scan_text)
        or re.search(r"\(\s*a\s*\)", scan_text) and re.search(r"\(\s*b\s*\)", scan_text)
        or text_contains_any(scan_text, ["which option", "which path", "which path should", "which option should"])
        or re.search(rf"\b(?:want|do you want|would you like)\s+me\s+to\s+(?:{discussion_action_verbs})\b", scan_text)
        or re.search(rf"\b(?:should|shall|can|may)\s+(?:i|we)\s+(?:{discussion_action_verbs})\b", scan_text)
    )
    has_numbered_menu = bool(
        re.search(r"(?m)^\s*1[.)]\s+\S", line_scan) and re.search(r"(?m)^\s*[23][.)]\s+\S", line_scan)
    )
    has_unverified_delivery_offer = bool(
        re.search(
            r"\b(?:ship|push|merge|deploy|release|write|implement)\b.{0,120}\bunverified(?:-locally)?\b",
            scan_text,
        )
        or re.search(
            r"\bunverified(?:-locally)?\b.{0,120}\b(?:ship|push|merge|deploy|release|write|implement)\b",
            scan_text,
        )
    )
    if has_unverified_delivery_offer:
        return True
    if has_numbered_menu and text_contains_any(
        scan_text,
        RESPONSE_GUARD_DECISION_MENU_CONTEXT_MARKERS
        + RESPONSE_GUARD_PLAN_CAP_CONTEXT_MARKERS
        + RESPONSE_GUARD_CONTEXT_PRESSURE_MARKERS
        + RESPONSE_GUARD_CONTEXT_STOP_MARKERS,
    ):
        return True
    if text_contains_any(scan_text, RESPONSE_GUARD_OPT_IN_DIRECT_PHRASES):
        if human_discussion_request:
            discussion_safe = (
                text_contains_any(scan_text, RESPONSE_GUARD_DISCUSSION_YIELD_MARKERS)
                and not has_discussion_permission_menu
                and not has_numbered_menu
            )
            if discussion_safe:
                return False
        return True
    if re.search(r"\bpath\s*1\b", scan_text) and re.search(r"\bpath\s*[23]\b", scan_text):
        if text_contains_any(scan_text, RESPONSE_GUARD_DECISION_MENU_CONTEXT_MARKERS):
            return True
    if re.search(r"\boption\s*1\b", scan_text) and re.search(r"\boption\s*[23]\b", scan_text):
        if text_contains_any(scan_text, RESPONSE_GUARD_DECISION_MENU_CONTEXT_MARKERS + RESPONSE_GUARD_PLAN_CAP_CONTEXT_MARKERS):
            return True
    if text_contains_any(scan_text, ["which option", "which path"]):
        if text_contains_any(scan_text, RESPONSE_GUARD_DECISION_MENU_CONTEXT_MARKERS + RESPONSE_GUARD_PLAN_CAP_CONTEXT_MARKERS):
            return True
    if re.search(r"\(\s*a\s*\)", scan_text) and re.search(r"\(\s*b\s*\)", scan_text):
        if text_contains_any(scan_text, RESPONSE_GUARD_DECISION_MENU_CONTEXT_MARKERS + RESPONSE_GUARD_PLAN_CAP_CONTEXT_MARKERS):
            return True
    if re.search(r"\bwhich\?\s*$", scan_text.strip()) and text_contains_any(scan_text, RESPONSE_GUARD_PLAN_CAP_CONTEXT_MARKERS):
        return True
    if re.search(rf"\b(?:should|shall)\s+i\s+(?:{action_verbs})\b", scan_text):
        return True
    return False


def response_has_anthropomorphic_capacity_deferral(text: str) -> bool:
    scan_text = response_guard_policy_scan_text(text)
    if not RESPONSE_GUARD_ANTHROPOMORPHIC_CAPACITY_RE.search(scan_text):
        return False
    if text_contains_any(scan_text, RESPONSE_GUARD_ANTHROPOMORPHIC_DEFERRAL_MARKERS):
        return True
    return bool(
        re.search(
            r"\b(?:i'?m|i am|i was|we are|we're|this session is|the session is)\s+(?:too\s+)?(?:tired|fatigued|half[- ]asleep)\b",
            scan_text,
        )
    )


def response_looks_like_human_discussion_framing(text: str) -> bool:
    scan_text = response_guard_policy_scan_text(text)
    return (
        text_contains_any(scan_text, RESPONSE_GUARD_ASSISTANT_DISCUSSION_FRAMING_MARKERS)
        and text_contains_any(scan_text, RESPONSE_GUARD_DISCUSSION_YIELD_MARKERS)
    )


def response_has_status_report_as_stop(text: str) -> bool:
    scan_text = response_guard_policy_scan_text(text)
    has_next_action_gap = text_contains_any(scan_text, RESPONSE_GUARD_STATUS_REPORT_NEXT_ACTION_MARKERS) or bool(
        re.search(r"\bneeds\b.{0,100}\b(?:draft|drafting|plan|planning|spec|source-of-truth)\b", scan_text)
        or re.search(r"\b(?:missing|lacks?)\b.{0,100}\b(?:plan|planning|spec|execution spec|source-of-truth)\b", scan_text)
    )
    if not has_next_action_gap:
        return False
    if text_contains_any(scan_text, RESPONSE_GUARD_FORWARD_MOTION_MARKERS):
        return False
    return True


def response_is_terminal_stop_context(text: str) -> bool:
    scan_text = response_guard_policy_scan_text(text)
    marker_count = sum(1 for marker in RESPONSE_GUARD_TERMINAL_STOP_MARKERS if marker in scan_text)
    return marker_count >= 2


def response_has_terminal_continuity_omission(text: str) -> bool:
    scan_text = response_guard_policy_scan_text(text)
    if not response_is_terminal_stop_context(text):
        return False
    has_continuity = text_contains_any(scan_text, RESPONSE_GUARD_CONTINUITY_EVIDENCE_MARKERS)
    has_journal = text_contains_any(scan_text, RESPONSE_GUARD_SESSION_JOURNAL_EVIDENCE_MARKERS)
    return not (has_continuity and has_journal)


def response_has_wrong_merge_queue_signal(text: str) -> bool:
    scan_text = response_guard_policy_scan_text(text)
    raw_text = text.lower()
    if not text_contains_any(scan_text, RESPONSE_GUARD_WRONG_MERGE_QUEUE_SIGNAL_MARKERS):
        return False
    if not text_contains_any(scan_text, ["merge", "queue", "pr "]):
        return False
    if text_contains_any(raw_text, ["gh pr view", "--json state", " pr state", "pullrequest.state", "pull request state"]):
        return False
    if text_contains_any(raw_text, RESPONSE_GUARD_WRONG_MERGE_QUEUE_POLICY_MARKERS) and not text_contains_any(
        scan_text, RESPONSE_GUARD_WRONG_MERGE_QUEUE_ACTIVE_CONTEXT_MARKERS
    ):
        return False
    if not text_contains_any(scan_text, RESPONSE_GUARD_WRONG_MERGE_QUEUE_CONTEXT_MARKERS):
        return False
    return True


def response_has_unverified_monitor_alive_claim(scan_text: str) -> bool:
    """A 'monitor/process is alive' assertion with no freshness evidence. Process liveness
    (pgrep/PID exists) does not prove progress: a wedged process is still alive. The claim
    must cite monitor-status output, a log-size delta/growth since the prior poll, or a
    freshness timestamp comparison."""
    if not text_contains_any(scan_text, RESPONSE_GUARD_MONITOR_ALIVE_CLAIM_MARKERS):
        return False
    return not text_contains_any(scan_text, RESPONSE_GUARD_MONITOR_FRESHNESS_EVIDENCE_MARKERS)


def response_has_rotation_action_or_fallback(scan_text: str) -> bool:
    """True when the response is actually rotating: an un-negated /compact-style rotation action,
    or the host-fallback path (continuity + journal + exact fresh-session startup action). A
    response that is rotating is not deferring, so the deferral checks must not flag it."""
    has_negative_compaction = text_contains_any(scan_text, RESPONSE_GUARD_CONTEXT_NEGATIVE_ACTION_MARKERS)
    # A negated rotation claim ("cannot run /compact") must NOT count as rotating. The negation
    # guard applies to the marker list too, not only the regex, because "cannot run /compact"
    # contains the positive "run /compact" marker as a substring (Codex P1).
    has_rotation_action = (
        text_contains_any(scan_text, RESPONSE_GUARD_CONTEXT_ROTATION_ACTION_MARKERS)
        or bool(RESPONSE_GUARD_CONTEXT_ROTATION_ACTION_RE.search(scan_text))
    ) and not has_negative_compaction
    has_continuity = text_contains_any(scan_text, RESPONSE_GUARD_CONTINUITY_EVIDENCE_MARKERS)
    has_journal = text_contains_any(scan_text, RESPONSE_GUARD_SESSION_JOURNAL_EVIDENCE_MARKERS)
    has_host_fallback = (
        has_continuity
        and has_journal
        and text_contains_any(scan_text, RESPONSE_GUARD_CONTEXT_ROTATION_FALLBACK_MARKERS)
    )
    return has_rotation_action or has_host_fallback


def response_has_host_fallback_rotation(scan_text: str) -> bool:
    """The host-fallback rotation path: continuity + session-journal evidence + a fresh-session
    fallback marker. This path inherently resumes (it states the exact fresh-session startup
    action), so it counts as a genuine rotation+resume even without a /compact action."""
    return (
        text_contains_any(scan_text, RESPONSE_GUARD_CONTINUITY_EVIDENCE_MARKERS)
        and text_contains_any(scan_text, RESPONSE_GUARD_SESSION_JOURNAL_EVIDENCE_MARKERS)
        and text_contains_any(scan_text, RESPONSE_GUARD_CONTEXT_ROTATION_FALLBACK_MARKERS)
    )


def response_has_context_rotation_stop(text: str) -> bool:
    scan_text = response_guard_context_scan_text(text)
    has_pressure = text_contains_any(scan_text, RESPONSE_GUARD_CONTEXT_PRESSURE_MARKERS) or bool(
        re.search(r"\b(?:[6-9]\d|100)\s*%(?!\w).{0,120}\bcontext\b", scan_text)
        or re.search(r"\bcontext\b.{0,120}\b(?:[6-9]\d|100)\s*%(?!\w)", scan_text)
    )
    if not has_pressure:
        return False
    if text_contains_any(scan_text, RESPONSE_GUARD_CONTEXT_HUMAN_PROMPT_MARKERS):
        return True
    has_stop = text_contains_any(scan_text, RESPONSE_GUARD_CONTEXT_STOP_MARKERS) or bool(
        re.search(r"\b(?:hard|physical|true)\s+blocker\b.{0,160}\bcontext\b", scan_text)
        or re.search(r"\bcontext\b.{0,160}\b(?:hard|physical|true)\s+blocker\b", scan_text)
    )
    if not has_stop:
        return False
    has_continuity = text_contains_any(scan_text, RESPONSE_GUARD_CONTINUITY_EVIDENCE_MARKERS)
    has_journal = text_contains_any(scan_text, RESPONSE_GUARD_SESSION_JOURNAL_EVIDENCE_MARKERS)
    has_host_fallback = (
        has_continuity
        and has_journal
        and text_contains_any(scan_text, RESPONSE_GUARD_CONTEXT_ROTATION_FALLBACK_MARKERS)
    )
    has_negative_compaction = text_contains_any(scan_text, RESPONSE_GUARD_CONTEXT_NEGATIVE_ACTION_MARKERS)
    has_rotation_action = text_contains_any(scan_text, RESPONSE_GUARD_CONTEXT_ROTATION_ACTION_MARKERS) or (
        bool(RESPONSE_GUARD_CONTEXT_ROTATION_ACTION_RE.search(scan_text)) and not has_negative_compaction
    )
    if has_negative_compaction and not has_host_fallback:
        return True
    return not (has_rotation_action or has_host_fallback)


def response_percent_is_progress(scan_text: str, match: re.Match[str]) -> bool:
    """True when the percentage is TIGHTLY bound to a progress quantity ('80% complete',
    '80% of the goal', 'goal at 80%'), not the context level. Tight binding to the number
    keeps stop/deferral words elsewhere in the clause ('cannot complete', 'defer the task')
    from misclassifying a genuine context percentage."""
    after = scan_text[match.end() : match.end() + 30]
    before = scan_text[max(0, match.start() - 25) : match.start()]
    if re.match(r"\s*(?:complete|completed|done|finished|coverage|covered)\b", after):
        return True
    # "80% goal complete", "80% milestone done", "40% test coverage" — progress noun between
    # the number and the verb.
    if re.match(
        r"\s*(?:goal|milestone|tasks?|tests?|coverage|progress|backlog|build|review|features?)\b",
        after,
    ):
        return True
    if re.match(
        r"\s*of\s+(?:the\s+|my\s+|our\s+)?(?:goal|milestone|backlog|tests?|work|plan|tasks?|features?)\b",
        after,
    ):
        return True
    if re.search(
        r"\b(?:goal|milestone|tasks?|tests?|coverage|progress|backlog|build|review|features?)"
        r"\s+(?:is\s+|at\s+|now\s+|sits\s+at\s+)?$",
        before,
    ):
        return True
    return False


def response_context_percent_values(scan_text: str) -> list[int]:
    """Percentages that describe the CONTEXT level. A percentage counts only when a context
    word/alias is adjacent (either side) and it is not tightly bound to a progress quantity.
    So 'context at 28%', 'context window at 72%', '28% of the context', and 'conversation
    window at 78%' count; 'context exhaustion, 80% complete' and 'goal 80%' do not."""
    values: list[int] = []
    for match in re.finditer(r"(\d{1,3})\s*%", scan_text):
        if response_percent_is_progress(scan_text, match):
            continue
        before = scan_text[max(0, match.start() - 30) : match.start()]
        after = scan_text[match.end() : match.end() + 30]
        if any(anchor in before for anchor in RESPONSE_GUARD_CONTEXT_PERCENT_ANCHORS) or any(
            anchor in after for anchor in RESPONSE_GUARD_CONTEXT_PERCENT_ANCHORS
        ):
            values.append(int(match.group(1)))
    return values


def response_context_percent_is_estimated(scan_text: str, match: re.Match[str]) -> bool:
    """True when the percentage is a self-estimate, not a host measurement: a hedge marker
    ('roughly', 'about', '~', 'i estimate', ...) within the same ±30 window
    response_context_percent_values uses, OR a trailing '+'/'ish' hedge on the number itself
    ('88%+', '80%ish')."""
    before = scan_text[max(0, match.start() - 30) : match.start()]
    after = scan_text[match.end() : match.end() + 30]
    if any(marker in before or marker in after for marker in RESPONSE_GUARD_CONTEXT_PERCENT_ESTIMATE_MARKERS):
        return True
    # Trailing "+" or "ish"/"-ish" directly on the number is a hedge ("88%+", "80%-ish").
    return bool(re.match(r"\s*(?:\+|-?ish)", scan_text[match.end() : match.end() + 4]))


def response_context_percent_is_host_sourced(scan_text: str, match: re.Match[str]) -> bool:
    """True when an UN-NEGATED host-counter provenance marker ('host counter', 'context meter',
    '(measured)', ...) sits within ±40 chars of the percentage. This is the ONLY way a percent
    earns 'host-measured' status for the unprovenanced-stop gate; the window keeps provenance
    bound to the specific number, so far-away boilerplate cannot launder a fabricated figure.

    Negation-aware: a host marker immediately preceded (same clause) by a negator -- "no host
    counter is visible; 88%" -- does NOT count, so denying the host has a reading cannot read as
    host-sourced. At least one un-negated host-marker occurrence in the window is required.

    The window stays at ±40 (not widened): widening re-opens the decoy-laundering bypass where a
    parenthetical "(... host counter showed 20%)" launders an adjacent fabricated 88%. The
    hedge-override (host AND-NOT-estimated) is the real laundering defense, not the window size."""
    lo = max(0, match.start() - 40)
    hi = min(len(scan_text), match.end() + 40)
    for marker in RESPONSE_GUARD_CONTEXT_PERCENT_HOST_SOURCE_MARKERS:
        idx = scan_text.find(marker, lo, hi)
        while idx != -1:
            # Negation lookbehind reads from the full text at the marker's absolute position, NOT
            # the ±40 provenance window, so a negator is not truncated when a long anchor pushes
            # the marker to the window edge ("no actual host counter; context window at 88%").
            preceding = scan_text[max(0, idx - 24) : idx]
            negated = bool(
                RESPONSE_GUARD_AFFIRMATIVE_IMMEDIATE_NEGATOR_RE.search(preceding)
                or RESPONSE_GUARD_AFFIRMATIVE_PHRASE_NEGATOR_RE.search(preceding)
                or RESPONSE_GUARD_CONTEXT_HOST_NEGATOR_RE.search(preceding)
            )
            if not negated:
                return True
            idx = scan_text.find(marker, idx + 1, hi)
    return False


RESPONSE_GUARD_AFFIRMATIVE_IMMEDIATE_NEGATOR_RE = re.compile(
    r"\b(?:not|no|never|without|isn't|won't|aren't|will not|don't|am not|are not)\s*$"
)
# A host-counter marker preceded — within the same clause — by a negator plus optional
# article/adjective filler ("no visible host counter", "without a host counter", "no actual
# host counter", "doesn't expose a host counter") is a DENIAL that the host has a reading, not a
# host source. The `[^.!?;]{0,24}$` tail keeps the negator inside the marker's clause so an
# earlier, unrelated negation does not falsely negate a genuine host reading.
RESPONSE_GUARD_CONTEXT_HOST_NEGATOR_RE = re.compile(
    r"\b(?:no|not|never|without|cannot|can't|isn't|aren't|won't|don't|doesn't|didn't|"
    r"lacks?|lacking|missing|absent|n't)\b[^.!?;]{0,24}$"
)
# Phrase-scoped negation: a negator separated from the marker only by intent/filler
# words ("not going to start a fresh session", "not planning to hand off"). The
# `[^.!?;]*$` tail keeps the negator inside the same clause as the marker so an
# earlier, unrelated negation in a prior sentence does not falsely negate it.
RESPONSE_GUARD_AFFIRMATIVE_PHRASE_NEGATOR_RE = re.compile(
    r"\b(?:not|never|don't|won't|isn't|aren't|am not|are not|will not|no)\s+"
    r"(?:going|planning|plan|gonna|about|intend|intending|meaning|trying|attempting|"
    r"starting|aiming)\b[^.!?;]*$"
)


def response_has_affirmative_stop_or_defer(scan_text: str) -> bool:
    """True when a stop/deferral marker appears UN-negated. Catches both immediate
    negators ('I am not deferring', 'no handoff; continuing now') and phrase-scoped ones
    ('not going to start a fresh session', 'not planning to hand off')."""
    for marker in RESPONSE_GUARD_CONTEXT_STOP_MARKERS + RESPONSE_GUARD_CONTEXT_DEFERRAL_MARKERS:
        idx = scan_text.find(marker)
        while idx != -1:
            preceding = scan_text[max(0, idx - 48) : idx]
            negated = bool(
                RESPONSE_GUARD_AFFIRMATIVE_IMMEDIATE_NEGATOR_RE.search(preceding)
                or RESPONSE_GUARD_AFFIRMATIVE_PHRASE_NEGATOR_RE.search(preceding)
            )
            if not negated:
                return True
            idx = scan_text.find(marker, idx + 1)
    return False


def response_has_unmeasured_context_deferral(text: str) -> bool:
    """A context-based stop/deferral of authorized work must cite a measured visible context
    percentage at or above the soft threshold. Fires when the response carries a context
    signal (a context-word pressure marker OR a percentage bound to the context level) AND
    frames an un-negated stop OR deferral, yet cites no context percentage, or any cited
    context percentage is below the soft threshold (so a high unrelated progress percentage
    cannot mask a low context claim, and a bare sub-threshold context percentage cannot slip
    through without pressure wording)."""
    scan_text = response_guard_context_scan_text(text)
    percents = response_context_percent_values(scan_text)
    has_context_signal = bool(percents) or text_contains_any(
        scan_text, RESPONSE_GUARD_CONTEXT_PRESSURE_WORD_MARKERS
    )
    if not has_context_signal:
        return False
    if not response_has_affirmative_stop_or_defer(scan_text):
        return False
    # A response that is actually rotating (running /compact or the host-fallback path) is not
    # deferring, even if its rotation language matches a stop marker ("resume after compaction").
    if response_has_rotation_action_or_fallback(scan_text):
        return False
    # Explicitly noting the host does not expose a percentage is an accepted escape.
    if text_contains_any(scan_text, RESPONSE_GUARD_CONTEXT_UNMEASURED_ESCAPE_MARKERS):
        return False
    if not percents:
        return True
    return any(percent < RESPONSE_GUARD_CONTEXT_SOFT_THRESHOLD_PERCENT for percent in percents)


def response_has_defer_mandatory_work_without_rotation(text: str) -> bool:
    """Even with a valid measured context percentage at or above the soft threshold,
    deferring or stopping authorized work WITHOUT executing a rotation+resume (or the
    host-fallback path) is handoff theater. Complements
    response_has_unmeasured_context_deferral (sub-threshold / no-number claims) and
    response_has_context_rotation_stop (stop markers plus pressure wording): this fires on
    a high, valid measurement paired with an un-negated stop/deferral and no rotation
    action."""
    scan_text = response_guard_context_scan_text(text)
    percents = response_context_percent_values(scan_text)
    if not any(percent >= RESPONSE_GUARD_CONTEXT_SOFT_THRESHOLD_PERCENT for percent in percents):
        return False
    if not response_has_affirmative_stop_or_defer(scan_text):
        return False
    return not response_has_rotation_action_or_fallback(scan_text)


# Open-class "ask the operator whether to keep working" detector. Requires a defer-to-operator
# operator phrase AND a continuation/rotation object within the same clause, so a benign offer
# ("let me know if you want to adjust scope") that names no keep-going object does NOT match.
# scan_text is already lowered by response_guard_context_scan_text.
RESPONSE_GUARD_ASKS_TO_KEEP_GOING_RE = re.compile(
    r"(?:tell me|let me know|your call|up to you|you decide|whether|"
    r"would you (?:like|prefer)|how (?:would|do) you)"
    r"\b[^.!?;]{0,40}\b"
    r"(?:keep going|keep working|continue|carry on|proceed|compact|rotate|stop|pause|wait)"
)


def response_asks_to_keep_going(scan_text: str) -> bool:
    """True when the response asks the operator whether to keep working on authorized work -- the
    headline RCA vector has no stop marker, only a permission ask. Matches any remaining precise
    HUMAN_PROMPT marker OR the open-class operator-asks-whether-to-keep-working regex, which
    REQUIRES a continuation/rotation object so a benign "let me know if you want to adjust scope"
    (no keep-going object) does not match."""
    if text_contains_any(scan_text, RESPONSE_GUARD_CONTEXT_HUMAN_PROMPT_MARKERS):
        return True
    return bool(RESPONSE_GUARD_ASKS_TO_KEEP_GOING_RE.search(scan_text))


def _response_context_stop_anchor_positions(scan_text: str) -> list[int]:
    """Character positions of un-negated stop/defer markers plus permission/keep-going-ask anchors
    -- the markers that signal authorized work is being stopped/handed off/deferred or that the
    operator is being asked whether to keep going. Provenance is bound to the percent nearest one
    of these, not globally, so a decoy host-sourced number elsewhere cannot launder a fabricated
    stop figure (RCA decoy bypass)."""
    positions: list[int] = []
    for marker in (
        RESPONSE_GUARD_CONTEXT_STOP_MARKERS
        + RESPONSE_GUARD_CONTEXT_DEFERRAL_MARKERS
    ):
        idx = scan_text.find(marker)
        while idx != -1:
            preceding = scan_text[max(0, idx - 48) : idx]
            negated = bool(
                RESPONSE_GUARD_AFFIRMATIVE_IMMEDIATE_NEGATOR_RE.search(preceding)
                or RESPONSE_GUARD_AFFIRMATIVE_PHRASE_NEGATOR_RE.search(preceding)
            )
            if not negated:
                positions.append(idx)
            idx = scan_text.find(marker, idx + 1)
    for asks in RESPONSE_GUARD_ASKS_TO_KEEP_GOING_RE.finditer(scan_text):
        positions.append(asks.start())
    for marker in RESPONSE_GUARD_CONTEXT_HUMAN_PROMPT_MARKERS:
        idx = scan_text.find(marker)
        while idx != -1:
            positions.append(idx)
            idx = scan_text.find(marker, idx + 1)
    return positions


def response_has_unprovenanced_context_stop(text: str) -> bool:
    """Block a stop/handoff/defer of authorized work whose only justification is a NON-host-sourced
    (estimated or bare) context percentage -- even if the response mentions /compact.

    RCA 20260615T181937Z: an agent fabricated "context at 88%+", paired it with a token /compact
    mention and a permission prompt ("tell me to keep going"), and stopped authorized work. The
    discriminator is PROVENANCE, not adjacency: an int next to "context" is not automatically a
    host measurement.

    Fires when, with a context signal present:
      1. authorized work is being stopped/deferred (un-negated stop/defer) OR the operator is being
         asked whether to keep going (a permission/human-prompt marker -- the headline vector has
         no stop marker); AND
      2. a context percent that is ESTIMATED-OR-BARE sits adjacent to one of those stop/permission
         markers (provenance is bound to the stop-adjacent percent, so a decoy host-sourced number
         elsewhere does not exonerate); AND
      3. the unmeasured-escape ("host does not expose a percent") is NOT present.

    Resume carve-out: a genuine in-turn rotation+resume (response_has_rotation_action_or_fallback)
    that does NOT ask permission and does NOT defer to LATER passes -- this keeps the bare
    /compact-resume fixture (503bf34) and the host-fallback handoff fixture green. The carve-out's
    deferral check is scoped to DEFER-TO-LATER temporal markers only, so the housekeeping word
    "handoff" does not defeat it."""
    scan_text = response_guard_context_scan_text(text)
    # Trigger: an un-negated stop/defer OR a permission/keep-going ask (open-class detector).
    has_stop_or_defer = response_has_affirmative_stop_or_defer(scan_text)
    has_human_prompt = response_asks_to_keep_going(scan_text)
    if not (has_stop_or_defer or has_human_prompt):
        return False
    # NOTE: a generic "host does not expose a percent" escape is NOT honored here -- a fabricated,
    # stop-adjacent percent must still block even when the response also claims the host has no
    # reading (Codex RCA). Genuine "no percent, continuing" cases pass because they carry no
    # stop-adjacent context percent and therefore never reach the block below.
    anchors = _response_context_stop_anchor_positions(scan_text)
    if not anchors:
        return False
    # Classify each context percent and check whether an estimated-or-bare one is stop-adjacent.
    stop_adjacent_unprovenanced = False
    for match in re.finditer(r"(\d{1,3})\s*%", scan_text):
        if response_percent_is_progress(scan_text, match):
            continue
        before = scan_text[max(0, match.start() - 30) : match.start()]
        after = scan_text[match.end() : match.end() + 30]
        # A percent is a context reading if it sits next to a context anchor OR next to a
        # host-source marker -- a "host counter shows ~88%" reading IS a context reading even when
        # the word "context" is absent, so a fabricated host claim cannot dodge the gate by
        # omitting "context".
        is_context = (
            any(anchor in before for anchor in RESPONSE_GUARD_CONTEXT_PERCENT_ANCHORS)
            or any(anchor in after for anchor in RESPONSE_GUARD_CONTEXT_PERCENT_ANCHORS)
            or response_context_percent_is_host_sourced(scan_text, match)
        )
        if not is_context:
            continue
        # A percent earns the host-sourced skip ONLY when it is host-sourced AND NOT hedged: an
        # estimate marker ("~88%", "roughly 88%") next to a host word is still a self-estimate, so
        # "host counter shows ~88%" cannot launder a fabricated number.
        if response_context_percent_is_host_sourced(scan_text, match) and not (
            response_context_percent_is_estimated(scan_text, match)
        ):
            continue
        # estimated OR bare (neither hedge nor host marker == bare, still unprovenanced)
        near_anchor = any(abs(match.start() - pos) <= 120 for pos in anchors)
        if near_anchor:
            stop_adjacent_unprovenanced = True
            break
    if not stop_adjacent_unprovenanced:
        return False
    # Resume carve-out: a genuine in-turn rotation+resume that does not ask permission and does not
    # defer to LATER passes. A genuine rotation is the host-fallback path (which inherently resumes)
    # OR a /compact-style action paired with an explicit resume signal -- a bare "/compact and
    # handing off" with no resume does NOT qualify. Scoped to DEFER-TO-LATER markers only so the
    # housekeeping word "handoff" does not defeat a real host-fallback handoff.
    has_genuine_resume = response_has_host_fallback_rotation(scan_text) or (
        response_has_rotation_action_or_fallback(scan_text)
        and text_contains_any(scan_text, RESPONSE_GUARD_CONTEXT_RESUME_MARKERS)
    )
    if (
        has_genuine_resume
        and not has_human_prompt
        and not text_contains_any(scan_text, RESPONSE_GUARD_CONTEXT_DEFER_TO_LATER_MARKERS)
    ):
        return False
    return True


def response_has_iteration_review_media_deferral(text: str) -> bool:
    scan_text = response_guard_policy_scan_text(text)
    if not text_contains_any(scan_text, RESPONSE_GUARD_MEDIA_DEFERRAL_MARKERS):
        return False
    return text_contains_any(scan_text, RESPONSE_GUARD_ITERATION_REVIEW_MEDIA_CONTEXT_MARKERS)


BOUNDARY_SUMMARY_RECOGNIZED_HEADINGS = [
    "## Plain English",
    "## Progress",
    "## Next",
    "## Technical Details",
]

RESPONSE_GUARD_BOUNDARY_EVENT_MARKERS = [
    "delivery summary",
    "session summary",
    "milestone summary",
    "goal summary",
    "handoff for review",
    "handoff-for-review",
    "queued for merge",
    "merge queued",
    "pr queued",
    "pr merged",
    "merged to main",
    "landed on main",
    "release published",
    "deployment completed",
    "deploy completed",
    "session complete",
    "milestone complete",
    "goal complete",
    "work delivered",
]


def response_has_boundary_summary_marker(text: str) -> bool:
    return any(re.search(rf"(?m)^{re.escape(heading)}\s*$", text) for heading in BOUNDARY_SUMMARY_RECOGNIZED_HEADINGS)


def response_looks_like_boundary_event(text: str) -> bool:
    scan_text = response_guard_policy_scan_text(text)
    if response_has_boundary_summary_marker(text):
        return True
    if text_contains_any(scan_text, RESPONSE_GUARD_BOUNDARY_EVENT_MARKERS):
        return True
    if re.search(r"\bpr\s*#\d+\b", scan_text) and text_contains_any(
        scan_text,
        [
            "merged",
            "queued",
            "landed",
            "pushed",
            "opened",
            "created",
            "closed",
            "ready to push",
            "ready for review",
            "auto-merge enabled",
        ],
    ):
        return True
    return False


def response_boundary_summary_errors(text: str) -> list[str]:
    errors: list[str] = []
    raw_scan = text.lower()
    if "/goal" in raw_scan and not text_contains_any(raw_scan, ["proof", "evidence", "validation"]):
        errors.append("boundary summary that names Claude /goal must surface proof, evidence, or validation")
    return errors


def response_non_whitespace_len(text: str) -> int:
    return len(re.sub(r"\s+", "", text or ""))


def response_is_terse_no_information(text: str) -> bool:
    stripped = re.sub(r"\s+", " ", (text or "").strip()).lower()
    if response_non_whitespace_len(stripped) > 50:
        return False
    return text_contains_any(
        stripped,
        [
            "quiet",
            "standing by",
            "yielding",
            "waiting",
            "holding",
            "pending",
            "awaiting feedback",
            "backing off",
            "idle",
            "paused",
            "no change",
            "unchanged",
            "same",
            "blocked",
        ],
    )


def response_looks_like_autonomous_yield(text: str) -> bool:
    if response_has_boundary_summary_marker(text):
        return False
    scan_text = response_guard_monitor_scan_text(text)
    policy_text = response_guard_policy_scan_text(text)
    has_forward_motion = text_contains_any(scan_text, RESPONSE_GUARD_FORWARD_MOTION_MARKERS)
    passive_without_forward_motion = text_contains_any(
        scan_text, RESPONSE_GUARD_PASSIVE_MONITOR_PHRASES
    ) and not has_forward_motion
    return (
        response_is_terse_no_information(text)
        or passive_without_forward_motion
        or (text_contains_any(scan_text, RESPONSE_GUARD_AUTONOMOUS_YIELD_MARKERS) and not has_forward_motion)
        or ("monitor" in scan_text and ("yield" in scan_text or "until then" in scan_text) and not has_forward_motion)
        or (
            "pr #" in policy_text
            and text_contains_any(
                policy_text,
                [
                    "unchanged",
                    "blocked",
                    "waiting",
                    "hold",
                    "failing",
                    "failed",
                    "needs",
                    "awaits",
                    "awaiting",
                    "sign-off",
                    "recheck",
                    "backing off",
                ],
            )
            and not has_forward_motion
        )
    )


def response_autonomous_yield_fields(text: str) -> dict[str, str]:
    field_pattern = re.compile(
        r"^\s*(?:[-*]\s*)?(?:#+\s*)?(plain\s+english|progress|next[-\s]poll|next|technical\s+details|state|did\s+not\s+advance|blocker)\s*:?\s*(.*)$",
        re.I,
    )
    fields: dict[str, list[str]] = {}
    current: str | None = None
    for raw_line in (text or "").splitlines():
        line = raw_line.strip()
        if not line or line.startswith("```") or line.startswith(">"):
            continue
        match = field_pattern.match(raw_line)
        if match:
            key = re.sub(r"\s+", " ", match.group(1).lower()).replace("next poll", "next-poll")
            current = key
            fields.setdefault(key, [])
            if match.group(2).strip():
                fields[key].append(match.group(2).strip())
            continue
        if current and line:
            fields[current].append(line)
    return {key: " ".join(value).strip().lower() for key, value in fields.items()}


RESPONSE_GUARD_TRANSIENT_EXTERNAL_MARKERS = [
    "anthropic",
    "claude api",
    "anthropic api",
    "api error",
    "apierror",
    "api_error",
    "api overload",
    "overloaded",
    "overloaded_error",
    "server issue",
    "server issues",
    "server error",
    "internal server error",
    "bad gateway",
    "gateway timeout",
    "provider outage",
    "provider unavailable",
    "service degraded",
    "too many requests",
    "rate limit",
    "rate-limit",
    "rate limited",
    "rate-limited",
    "exceeded retry limit",
    "service unavailable",
    "temporarily unavailable",
    "transient",
    "timeout",
    "timed out",
    "websocket",
    "connection reset",
    "network error",
    "try again",
]
RESPONSE_GUARD_TRANSIENT_EXTERNAL_STATUS_RE = re.compile(
    r"\b(?:http|api|status|error|response|returned|returns|codex|anthropic|claude|github|provider|service|server|rate[- ]limit|too many requests|overload(?:ed)?)\b.{0,40}\b(?:429|500|502|503|504|529)\b"
    r"|\b(?:429|500|502|503|504|529)\b.{0,40}\b(?:http|api|status|error|too many requests|rate[- ]limit|overload(?:ed)?|service unavailable|server|provider)\b",
    re.I | re.S,
)
RESPONSE_GUARD_PROVIDER_OUTAGE_STOP_MARKERS = [
    "can't continue",
    "cannot continue",
    "unable to continue",
    "can't proceed",
    "cannot proceed",
    "blocked until",
    "blocked by",
    "stopping",
    "stop here",
    "stopped",
    "try later",
    "try again later",
    "when it recovers",
    "when the service recovers",
    "when anthropic recovers",
    "when claude recovers",
    "when api recovers",
]
RESPONSE_GUARD_ARMED_RETRY_MARKERS = [
    "schedulewakeup armed",
    "schedule wakeup armed",
    "schedulewakeup scheduled",
    "schedule wakeup scheduled",
    "self-wakeup armed",
    "self wakeup armed",
    "wakeup armed",
    "heartbeat armed",
    "timer armed",
    "cron scheduled",
    "automation created",
    "retry already running",
    "retry is running",
    "retry loop armed",
    "provider recovery loop",
    "provider recovery armed",
    "recovery loop armed",
    "next retry",
    "retry at",
    "retrying at",
    "next check due",
    "next provider check",
    "poll cadence",
    "active poll already underway",
    "rerun already started",
]
RESPONSE_GUARD_RECOVERY_CANCELLATION_MARKERS = [
    "canceling the scheduled auto-retry",
    "cancelling the scheduled auto-retry",
    "cancelled the scheduled auto-retry",
    "canceled the scheduled auto-retry",
    "canceling scheduled auto-retry",
    "cancelling scheduled auto-retry",
    "cancelled scheduled auto-retry",
    "canceled scheduled auto-retry",
    "canceling the auto-retry",
    "cancelling the auto-retry",
    "cancelled the auto-retry",
    "canceled the auto-retry",
    "canceling auto-retry",
    "cancelling auto-retry",
    "cancelled auto-retry",
    "canceled auto-retry",
    "canceling the retry",
    "cancelling the retry",
    "cancelled the retry",
    "canceled the retry",
    "canceling scheduled wakeup",
    "cancelling scheduled wakeup",
    "cancelled scheduled wakeup",
    "canceled scheduled wakeup",
    "canceling the scheduled wakeup",
    "cancelling the scheduled wakeup",
    "cancelled the scheduled wakeup",
    "canceled the scheduled wakeup",
    "canceling the recovery loop",
    "cancelling the recovery loop",
    "cancelled the recovery loop",
    "canceled the recovery loop",
    "canceling provider recovery",
    "cancelling provider recovery",
    "cancelled provider recovery",
    "canceled provider recovery",
    "stopping the autonomous loop",
    "stopped the autonomous loop",
    "no autonomous loop",
    "no scheduled wakeups",
    "nothing will re-trigger",
    "nothing will retrigger",
]
RESPONSE_GUARD_EXPLICIT_STOP_REQUEST_MARKERS = [
    "/goal clear",
    "abort the goal",
    "abort this goal",
    "cancel all retries",
    "cancel retries",
    "cancel retry",
    "cancel the auto-retry",
    "cancel the scheduled retry",
    "cancel the scheduled wakeup",
    "cancel the wakeup",
    "cancel the recovery loop",
    "cancel provider recovery",
    "disable auto-retry",
    "disable retries",
    "do not continue",
    "don't continue",
    "halt operations",
    "pause the lane",
    "pause work",
    "stop autonomous",
    "stop completely",
    "stop now",
    "stop the autonomous loop",
    "stop the lane",
    "stop the loop",
    "stop the recovery loop",
    "stop working",
    "turn off auto-retry",
    "turn off retries",
]
RESPONSE_GUARD_EXPLICIT_STOP_REQUEST_RE = re.compile(
    r"^\s*(?:stop|pause|cancel|abort|halt|stand down)\s*[.!?]*\s*$", re.I
)


def response_has_transient_external_marker(text: str) -> bool:
    scan_text = response_guard_policy_scan_text(text)
    return text_contains_any(scan_text, RESPONSE_GUARD_TRANSIENT_EXTERNAL_MARKERS) or bool(
        RESPONSE_GUARD_TRANSIENT_EXTERNAL_STATUS_RE.search(scan_text)
    )


def response_has_transient_external_yield_without_armed_retry(text: str) -> bool:
    fields = response_autonomous_yield_fields(text)
    if not fields:
        return False
    relevant = " ".join(
        fields.get(field, "")
        for field in ["plain english", "progress", "next", "technical details", "state", "blocker", "next-poll"]
    )
    if not response_has_transient_external_marker(relevant):
        return False
    if not text_contains_any(response_guard_policy_scan_text(text), RESPONSE_GUARD_ARMED_RETRY_MARKERS):
        return True
    return False


def response_has_provider_outage_stop_without_recovery(text: str) -> bool:
    scan_text = response_guard_policy_scan_text(text)
    if not response_has_transient_external_marker(scan_text):
        return False
    if text_contains_any(scan_text, RESPONSE_GUARD_ARMED_RETRY_MARKERS):
        return False
    return text_contains_any(scan_text, RESPONSE_GUARD_PROVIDER_OUTAGE_STOP_MARKERS) or response_looks_like_autonomous_yield(text)


RESPONSE_GUARD_FLAKY_DISMISSAL_MARKERS = [
    "just flaky", "just a flaky test", "probably flaky", "must be flaky", "it's flaky", "its flaky",
    "likely flaky", "seems flaky", "that's just a flaky", "flaky, moving on", "flaky so moving on",
    "flaky and moved on", "flaky and moving on", "skip the flaky", "skipping the flaky",
    "skipped the flaky", "skip that flaky", "ignore the flaky", "ignoring the flaky",
    "ignored the flaky", "re-ran until it passed", "re-ran until green", "reran until green",
    "reran until it passed", "ran it again and it passed", "passed on retry", "passed on re-run",
    "passed on rerun", "green on retry", "retried until green", "retried and it passed",
    "rerun to green", "re-run to green", "re-running until green",
]
RESPONSE_GUARD_FLAKE_HANDLING_MARKERS = [
    "quarantine", "quarantined", "root cause", "root-cause", "root caused", "root-caused",
    "regression test",
]


def response_has_flaky_rationalization_stop(text: str) -> bool:
    """A failing test dismissed as 'flaky' (re-ran until green / skipped / ignored) is a bug, not a
    completion. Block ending the turn on that rationalization UNLESS the response shows real handling:
    a root-cause fix, a regression test, or a quarantine with a tracked owner+due issue."""
    scan_text = response_guard_policy_scan_text(text)
    if not text_contains_any(scan_text, RESPONSE_GUARD_FLAKY_DISMISSAL_MARKERS):
        return False
    if text_contains_any(scan_text, RESPONSE_GUARD_FLAKE_HANDLING_MARKERS):
        return False
    if re.search(r"#\d+\b", scan_text):  # a tracked issue/PR reference = real handling
        return False
    if "owner" in scan_text and "due" in scan_text:  # an owner+due quarantine entry
        return False
    return True


def response_has_recovery_cancellation(text: str) -> bool:
    return text_contains_any(response_guard_policy_scan_text(text), RESPONSE_GUARD_RECOVERY_CANCELLATION_MARKERS)


def user_explicitly_stopped_lane(text: str) -> bool:
    scan_text = response_guard_policy_scan_text(text)
    if RESPONSE_GUARD_EXPLICIT_STOP_REQUEST_RE.fullmatch(scan_text.strip()):
        return True
    return text_contains_any(scan_text, RESPONSE_GUARD_EXPLICIT_STOP_REQUEST_MARKERS)


def response_has_recovery_cancellation_without_explicit_stop(response_text: str, user_text: str) -> bool:
    if not response_has_recovery_cancellation(response_text):
        return False
    return not user_explicitly_stopped_lane(user_text)


def response_has_specific_anchor(text: str) -> bool:
    return bool(
        re.search(r"#\d+\b", text)
        or re.search(r"\bpid\s+\d+\b", text)
        or re.search(r"\b20\d{2}-\d{2}-\d{2}(?:t|\b)", text)
        or re.search(r"(?:^|\s)[./]?[A-Za-z0-9_.-]+/[A-Za-z0-9_./-]+", text)
        or re.search(r"\b[A-Za-z0-9_.-]+\.(?:md|log|json|txt|yaml|yml)\b", text)
    )


def response_autonomous_yield_schema_errors(text: str) -> list[str]:
    errors: list[str] = []
    return errors


def response_has_blocked_pr_yield_without_context(text: str) -> bool:
    if response_autonomous_yield_fields(text):
        return False
    policy_text = response_guard_policy_scan_text(text)
    if "pr #" not in policy_text:
        return False
    if text_contains_any(response_guard_monitor_scan_text(text), RESPONSE_GUARD_FORWARD_MOTION_MARKERS):
        return False
    return text_contains_any(
        policy_text,
        [
            "unchanged",
            "blocked",
            "waiting",
            "hold",
            "failing",
            "failed",
            "needs",
            "awaits",
            "awaiting",
            "sign-off",
            "recheck",
            "heartbeat",
            "standing by",
        ],
    )


RESPONSE_GUARD_PHRASE_CHECK_MODES = {"advisory", "blocking", "off"}


def normalize_response_guard_phrase_checks(value: object) -> str:
    mode = str(value or "advisory").strip().lower()
    if mode not in RESPONSE_GUARD_PHRASE_CHECK_MODES:
        return "advisory"
    return mode


def response_guard_config_for_root(adapter_root: Path | None) -> dict:
    if adapter_root is None:
        return {"phraseChecks": "advisory"}
    try:
        data = load_project(adapter_marker_path(adapter_root))
    except FileNotFoundError:
        return {"phraseChecks": "advisory"}
    except (Exception, SystemExit):
        # Fail-safe decision: an adapter that IS present but fails to load/parse is a broken
        # lane state, not an absent one -- fail toward the SAFE (blocking) default rather
        # than silently loosening enforcement, mirroring the goal-boundary fail-safe above.
        return {"phraseChecks": "blocking"}
    raw = data.get("responseGuard")
    if not isinstance(raw, dict):
        raw = {}
    return {"phraseChecks": normalize_response_guard_phrase_checks(raw.get("phraseChecks"))}


def response_guard_errors(
    response_text: str,
    *,
    active_monitor: bool = False,
    autonomous_yield: bool = False,
    rca_request: bool = False,
    after_tool_rejection: bool = False,
    derivable_next_action_prompt: bool = False,
    terminal_stop: bool = False,
    boundary_summary: bool = False,
    human_discussion_request: bool = False,
    guard_adapter_root: Path | None = None,
    phrase_checks: str = "advisory",
    advisory_only: bool = False,
) -> list[str]:
    errors: list[str] = []
    lower = response_text.lower()
    scan_text = response_guard_monitor_scan_text(response_text)
    autonomous_errors: list[str] = []
    phrase_checks = normalize_response_guard_phrase_checks(phrase_checks)

    def log_guard(check_id: str, fired: bool, mechanism: str = "state", detail: object = "") -> bool:
        if mechanism == "phrase" and phrase_checks == "off":
            return False
        if guard_adapter_root is not None:
            guard_log_event(guard_adapter_root, check_id, fired, mechanism, detail)
        if advisory_only:
            return False
        if mechanism == "phrase" and phrase_checks != "blocking":
            return False
        return fired

    if autonomous_yield:
        terse_yield = response_non_whitespace_len(response_text) < RESPONSE_GUARD_AUTONOMOUS_YIELD_MIN_CHARS
        if log_guard("stop.autonomous_yield_too_terse", terse_yield, "phrase", "autonomous yield length"):
            autonomous_errors.append(
                "autonomous-loop yield too terse: one-word or ultra-short responses such as `Quiet.` are forbidden; name the state, blocker, and next retry/action in plain language"
            )
        blocked_pr_yield = response_has_blocked_pr_yield_without_context(response_text)
        if log_guard("stop.blocked_pr_yield_without_context", blocked_pr_yield, "phrase", "blocked PR yield"):
            autonomous_errors.append(
                "autonomous-loop yield incomplete: blocked PR/check/signoff standby requires plain state, blocker, pending work inspection, and next retry/action context"
            )
    autonomous_yield_valid = autonomous_yield and not autonomous_errors
    if active_monitor:
        passive_monitor_phrase = text_contains_any(scan_text, RESPONSE_GUARD_PASSIVE_MONITOR_PHRASES)
        if log_guard("stop.passive_monitor_phrase", passive_monitor_phrase and not text_contains_any(scan_text, RESPONSE_GUARD_FORWARD_MOTION_MARKERS), "phrase", "passive monitor phrase"):
            if passive_monitor_phrase:
                errors.append(
                    "passive monitor stop: monitor/check/review wording must include same-turn work already underway or an active poll with next poll due; a wakeup or notification alone is not forward motion"
                )
        monitor_yield = "monitor" in scan_text and ("yield" in scan_text or "until then" in scan_text) and not text_contains_any(scan_text, RESPONSE_GUARD_FORWARD_MOTION_MARKERS)
        if log_guard("stop.monitor_yield_without_forward_motion", monitor_yield, "phrase", "monitor yield"):
            errors.append("passive monitor stop: do not yield after arming a monitor; start parallel-safe work or actively poll")
        unverified_alive = response_has_unverified_monitor_alive_claim(scan_text)
        if log_guard("stop.monitor_alive_without_freshness", unverified_alive, "phrase", "monitor alive claim"):
            errors.append(
                "monitor alive claim lacking freshness evidence: pgrep/process liveness alone is not sufficient (a wedged process is still alive); cite monitor-status output, a log-size delta/growth since the prior poll, or a freshness timestamp comparison"
            )
    errors.extend(autonomous_errors)
    if rca_request or response_is_rca_shaped(response_text):
        has_lane_rca = ".ai-runs/" in lower and "methodology-regression-rca.md" in lower
        has_archive_rca = "methodology-rca-archive" in lower and (
            "docs/backlog/methodology-regressions/" in lower or "methodology-regressions/" in lower
        )
        chat_only_rca = response_is_rca_shaped(response_text) and not (has_lane_rca and has_archive_rca)
        if log_guard("stop.chat_only_rca", chat_only_rca, "phrase", "RCA-shaped response"):
            errors.append(
                "chat-only RCA: RCA-shaped responses must reference the lane-local RCA artifact and pushed methodology RCA archive copy"
            )
        rca_deferral = bool(rca_request and re.search(r"\bwant me to\s+(?:create|write|publish|run|do|proceed|continue)\b", response_guard_policy_scan_text(response_text)))
        if log_guard("stop.rca_request_deferral", rca_deferral, "phrase", "RCA request deferral"):
            errors.append("RCA request deferral: do not ask whether to create or publish the RCA artifact")
    forbidden_opt_in = response_has_forbidden_opt_in(response_text, human_discussion_request=human_discussion_request)
    if log_guard("stop.forbidden_opt_in", forbidden_opt_in, "phrase", "opt-in/standby"):
        errors.append(
            "forbidden opt-in/standby language: rewrite as the action taken or one exact true-blocker question; session-scope recovery actions like killing/retrying an agent-launched wedged process are safe actions, not permission prompts"
        )
    anthropomorphic_capacity = response_has_anthropomorphic_capacity_deferral(response_text)
    if log_guard("stop.anthropomorphic_capacity_deferral", anthropomorphic_capacity, "phrase", "capacity deferral"):
        errors.append(
            "anthropomorphic capacity deferral: fatigue, sleep, fresh-eyes, and time-of-day explanations are not true blockers; name the concrete host, context, credential, gate, or tool limit and the required recovery action"
        )
    status_report_stop = derivable_next_action_prompt and response_has_status_report_as_stop(response_text)
    if log_guard("stop.status_report_as_stop", status_report_stop, "phrase", "derivable next action"):
        errors.append(
            "status-report-as-stop: a milestone/status prompt with a derivable planning or execution gap must start the source-of-truth plan, review workflow, or next action in the same turn"
        )
    terminal_continuity_omission = terminal_stop and response_has_terminal_continuity_omission(response_text)
    if log_guard("stop.terminal_continuity_omission", terminal_continuity_omission, "phrase", "terminal stop"):
        errors.append(
            "terminal continuity omission: workflow/session close summaries must refresh continuity and prepare/publish or pend the session journal before yielding"
        )
    wrong_merge_queue = response_has_wrong_merge_queue_signal(response_text)
    if log_guard("stop.wrong_merge_queue_signal", wrong_merge_queue, "phrase", "merge queue signal"):
        errors.append(
            "wrong merge-queue signal: clean queued PRs are queue-and-move-on; exceptional queue checks must use PR state as the terminal signal, not mergeQueueEntry.estimatedTimeToMerge"
        )
    flaky_rationalization = response_has_flaky_rationalization_stop(response_text)
    if log_guard("stop.flaky_rationalization", flaky_rationalization, "phrase", "flaky rationalization"):
        errors.append(
            "flaky-test rationalization stop: dismissing a failing test as flaky, re-running until it "
            "passes, or skipping it is not a valid completion; a flaky test is a bug -- root-cause and "
            "fix it, or quarantine it with a tracked owner+due recurrence-gate issue, before yielding"
        )
    rotation_stop = response_has_context_rotation_stop(response_text)
    if log_guard("stop.context_rotation", rotation_stop, "phrase", "context rotation"):
        errors.append(
            "context-rotation stop: context exhaustion is mandatory maintenance, not a terminal blocker and not a permission prompt; refresh continuity/session journal, invoke `/compact` or the strongest host compact/restart path when available, or if the agent cannot invoke it directly, state the exact fresh-session startup action without asking the human operator whether to compact or continue"
        )
    unmeasured_context = response_has_unmeasured_context_deferral(response_text)
    if log_guard("stop.unmeasured_context_deferral", unmeasured_context, "phrase", "context deferral"):
        errors.append(
            "unmeasured context deferral: a context-based stop/deferral of authorized work must cite the measured visible context percentage at or above the soft threshold; claiming context limits below the soft threshold, or without a measured percentage, is context-exhaustion theater, not a true blocker. State the measured percent (or 'host does not expose percent') and continue or rotate accordingly"
        )
    unprovenanced_context = response_has_unprovenanced_context_stop(response_text)
    if log_guard("stop.unprovenanced_context_stop", unprovenanced_context, "phrase", "context provenance"):
        errors.append(
            "unprovenanced context-percent stop: a context percentage used to stop, hand off, or defer authorized work must come from the host's own context counter, not a self-estimate. A hedged or bare number like 'roughly 80%' or 'context at 88%+' is a guess, and mentioning `/compact` does not turn a guess into a real rotation. Either cite the host-reported context percentage (e.g. 'host counter: 80%') and actually rotate and resume in this same turn, or state that the host does not expose a percentage and continue the work to its boundary. Do not ask the operator whether to keep going"
        )
    defer_without_rotation = not rotation_stop and response_has_defer_mandatory_work_without_rotation(response_text)
    if log_guard("stop.defer_mandatory_work_without_rotation", defer_without_rotation, "phrase", "context rotation"):
        errors.append(
            "defer-mandatory-work-without-rotation: a measured context percentage at or above the soft threshold is not permission to defer or hand off authorized work; you must actually rotate and resume (refresh continuity/session journal, invoke `/compact` or the strongest host compact/restart path, or state the exact fresh-session startup action) in the same turn. Deferring a valid-measurement context with no rotation action is handoff theater"
        )
    iteration_review_deferral = response_has_iteration_review_media_deferral(response_text)
    if log_guard("stop.iteration_review_media_deferral", iteration_review_deferral, "phrase", "iteration review media"):
        errors.append(
            "iteration-review media deferral: adapter-enabled recap video, poster, page media, S3/CloudFront upload, and Chat publish are agent-owned delivery work; rotate/resume or name the exact missing renderer, credential, hosting, or webhook setup blocker after checking the adapter path"
        )
    if boundary_summary or response_looks_like_boundary_event(response_text):
        boundary_errors = response_boundary_summary_errors(response_text)
        # Phrase/state taxonomy fix: gate the extend on log_guard's return value like every
        # other check here -- previously this ran unconditionally, so retagging the mechanism
        # to "phrase" had no effect and the check kept hard-blocking even in advisory mode.
        if log_guard("stop.boundary_summary", bool(boundary_errors), "phrase", "boundary summary"):
            errors.extend(boundary_errors)
    transient_external_yield = response_has_transient_external_yield_without_armed_retry(response_text)
    if log_guard("stop.transient_external_dependency_yield", transient_external_yield, "phrase", "external dependency"):
        errors.append(
            "transient external dependency yield: rate limits, 429/503s, timeouts, and try-again failures require an armed ScheduleWakeup/host self-wakeup or active retry before yielding"
        )
    provider_outage_stop = response_has_provider_outage_stop_without_recovery(response_text)
    if log_guard("stop.provider_outage_without_recovery", provider_outage_stop, "phrase", "provider outage"):
        errors.append(
            "provider outage stop: Anthropic/Claude/Codex/GitHub/API outages, API errors, overloads, 5xx/529s, and transient provider failures require a provider recovery loop with ScheduleWakeup/host self-wakeup or active retry cadence before yielding"
        )
    if after_tool_rejection:
        acknowledged = text_contains_any(lower, RESPONSE_GUARD_REJECTION_ACK_MARKERS)
        false_activity = text_contains_any(lower, RESPONSE_GUARD_FALSE_ACTIVITY_MARKERS)
        if log_guard("stop.tool_rejection_not_acknowledged", not acknowledged, "phrase", "tool rejection"):
            errors.append(
                "tool rejection not acknowledged: the next response after a rejected/cancelled/denied tool must name the rejection before status or next action"
            )
        if log_guard("stop.false_activity_after_tool_rejection", false_activity and not acknowledged, "phrase", "false activity"):
            errors.append(
                "false activity status: do not answer a status ping with about-to/queued/yes framing when the last tool was rejected"
            )
    return errors


def response_guard(args: argparse.Namespace) -> int:
    if args.content_file:
        response_text = args.content_file.read_text(encoding="utf-8", errors="replace")
    elif args.stdin:
        response_text = sys.stdin.read()
    else:
        print("response_guard_error: pass --stdin or --content-file", file=sys.stderr)
        return 1
    scan_text = response_guard_monitor_scan_text(response_text)
    autonomous_yield = args.autonomous_yield or response_looks_like_autonomous_yield(response_text)
    active_monitor = args.active_monitor or text_contains_any(scan_text, RESPONSE_GUARD_PASSIVE_MONITOR_PHRASES) or (
        "monitor" in scan_text and ("yield" in scan_text or "until then" in scan_text)
    )
    guard_adapter_root = find_adapter_root(args.target.expanduser().resolve(strict=False)) if args.target else None
    phrase_checks = args.phrase_checks or response_guard_config_for_root(guard_adapter_root)["phraseChecks"]
    errors = response_guard_errors(
        response_text,
        active_monitor=active_monitor,
        autonomous_yield=autonomous_yield,
        rca_request=args.rca_request,
        after_tool_rejection=args.after_tool_rejection,
        derivable_next_action_prompt=args.derivable_next_action_prompt,
        terminal_stop=args.terminal_stop,
        boundary_summary=args.boundary_summary or response_has_boundary_summary_marker(response_text),
        human_discussion_request=args.human_discussion_request,
        guard_adapter_root=guard_adapter_root,
        phrase_checks=phrase_checks,
    )
    if errors:
        for error in errors:
            print(f"response_guard_error: {error}", file=sys.stderr)
        return 1
    print("response_guard: pass")
    return 0


def flatten_hook_text(value: object) -> str:
    parts: list[str] = []

    def walk(item: object) -> None:
        if item is None:
            return
        if isinstance(item, str):
            parts.append(item)
            return
        if isinstance(item, (int, float, bool)):
            parts.append(str(item))
            return
        if isinstance(item, list):
            for child in item:
                walk(child)
            return
        if isinstance(item, dict):
            for key in ["text", "content", "message", "result", "error", "stdout", "stderr"]:
                if key in item:
                    walk(item[key])

    walk(value)
    return "\n".join(part for part in parts if part)


def transcript_role_and_text(record: dict) -> tuple[str | None, str]:
    role = record.get("role")
    if not role and isinstance(record.get("message"), dict):
        role = record["message"].get("role")
    if not role and record.get("type") in {"user", "assistant"}:
        role = str(record.get("type"))
    return (str(role) if role else None, flatten_hook_text(record))


def transcript_context(path: Path | None) -> tuple[str, str, str]:
    if path is None or not path.exists():
        return "", "", ""
    last_user = ""
    last_assistant = ""
    recent: list[str] = []
    try:
        lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
    except OSError:
        return "", "", ""
    for line in lines[-80:]:
        if not line.strip():
            continue
        try:
            record = json.loads(line)
        except json.JSONDecodeError:
            continue
        role, text = transcript_role_and_text(record)
        if text:
            recent.append(text)
        if role == "user" and text:
            last_user = text
        elif role == "assistant" and text:
            last_assistant = text
    return last_user, last_assistant, "\n".join(recent[-20:])


GOAL_COMPLETION_CLAIM_MARKERS = [
    "goal complete",
    "goal is complete",
    "goal completed",
    "completed the goal",
    "goal delivered",
    "goal is delivered",
    "delivered the goal",
    "goal shipped",
    "goal is shipped",
    "goal done",
    "goal close-out complete",
    "goal closeout complete",
]


GOAL_COMPLETION_CLAIM_RE = re.compile(
    r"\bgoal\b[^.?!]{0,24}\b(?:complete|completed|done|delivered|shipped|closed)\b"
    r"|\b(?:complete[d]?|delivered|shipped|closed|finished)\b[^.?!]{0,12}\bthe goal\b",
)


def goal_boundary_claim_error(iteration_cfg: dict, goal_run: dict | None, text: str) -> str | None:
    """Pure core of the RCA O12 check: a goal-completion claim while the goal ledger is not in
    the complete state. Returns the error string or None. Fires only when iterationReview covers
    goal boundaries and an active (non-complete) goal-run ledger exists."""
    scan = response_guard_policy_scan_text(text)
    if not (text_contains_any(scan, GOAL_COMPLETION_CLAIM_MARKERS) or GOAL_COMPLETION_CLAIM_RE.search(scan)):
        return None
    if not isinstance(iteration_cfg, dict) or not iteration_cfg.get("enabled"):
        return None
    if "goal" not in (iteration_cfg.get("granularities") or []):
        return None
    if not isinstance(goal_run, dict) or goal_run.get("schema") != GOAL_RUN_SCHEMA:
        return None
    if str(goal_run.get("status") or "").lower() == "complete":
        return None
    return (
        "goal-completion claim without goal-advance: a stop summary that says the goal is complete or "
        "delivered must first run `goal-advance --event goal-complete --iteration-review-record <record>` "
        "(which runs the iteration-review delivery check); the goal ledger is not in the complete state"
    )


def response_guard_goal_boundary_errors(adapter_root: Path, text: str) -> list[str]:
    """RCA O12: load the lane adapter + goal ledger and apply goal_boundary_claim_error so a stop
    that declares the goal complete/delivered without running goal-advance is blocked."""
    # Prefilter (avoid loading the adapter when there is no completion claim) must match BOTH the
    # marker list AND the regex, or regex-only claims like "the goal has shipped" never reach
    # goal_boundary_claim_error (Codex P1).
    prefilter_scan = response_guard_policy_scan_text(text)
    if not (text_contains_any(prefilter_scan, GOAL_COMPLETION_CLAIM_MARKERS) or GOAL_COMPLETION_CLAIM_RE.search(prefilter_scan)):
        return []
    adapter_path = adapter_marker_path(adapter_root)
    if not adapter_path.exists():
        return []  # no lane adapter -> no goal-boundary context to enforce
    try:
        data = load_project(adapter_path)
    except (Exception, SystemExit):
        # arch-errors-8: a goal-completion claim is present but the lane adapter is present-yet-
        # unloadable. Fail toward the SAFE (blocking) state instead of silently allowing an
        # unverifiable "goal complete" claim through.
        return [
            "Goal-completion claim detected but the lane adapter could not be loaded to verify "
            "goal-advance ran. Repair the adapter (tautline validate-adapter / "
            "render-adapters), then re-state completion."
        ]
    goal_run_raw = data.get("laneState", {}).get("goalRun", ".ai-work/GOAL_RUN.json")
    path = Path(str(goal_run_raw)).expanduser()
    if not path.is_absolute():
        path = adapter_root / path
    goal_run: dict | None = None
    if path.exists():
        try:
            goal_run = json.loads(path.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError):
            goal_run = None
    error = goal_boundary_claim_error(data.get("iterationReview", {}), goal_run, text)
    return [error] if error else []


def response_guard_has_active_goal(adapter_root: Path) -> bool:
    try:
        data = load_project(adapter_marker_path(adapter_root))
        goal_run_raw = data.get("laneState", {}).get("goalRun", ".ai-work/GOAL_RUN.json")
    except Exception:
        goal_run_raw = ".ai-work/GOAL_RUN.json"
    goal_run_path = Path(str(goal_run_raw)).expanduser()
    if not goal_run_path.is_absolute():
        goal_run_path = adapter_root / goal_run_path
    if not goal_run_path.exists():
        return False
    try:
        run = json.loads(goal_run_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return True
    if run.get("schema") != GOAL_RUN_SCHEMA:
        return True
    return str(run.get("status") or "").lower() not in GOAL_TERMINAL_STATUSES


def response_guard_has_live_goal_session(payload: dict, recent_text: str, last_assistant: str) -> bool:
    for key in ("minervit_active_goal", "active_goal", "goal_active"):
        value = payload.get(key)
        if value is True or str(value).lower() in {"1", "true", "yes", "active"}:
            return True
    scan_text = response_guard_context_scan_text(recent_text + "\n" + last_assistant)
    return text_contains_any(scan_text, RESPONSE_GUARD_LIVE_GOAL_SESSION_MARKERS)


def response_guard_should_evaluate_stop(payload: dict, recent_text: str, last_assistant: str) -> bool:
    if response_guard_has_live_goal_session(payload, recent_text, last_assistant):
        return True
    # Long Claude /goal sessions sometimes drop the transcript breadcrumb that says
    # "Goal not yet met" before the Stop hook fires. If an active ledger exists and
    # the assistant's own response is a context-rotation stop, enforce the guard
    # instead of letting the lane ask the human to compact/restart.
    return response_has_context_rotation_stop(last_assistant)


def response_guard_has_documented_rotation_exit(text: str) -> bool:
    scan = response_guard_context_scan_text(text)
    has_pressure = text_contains_any(scan, RESPONSE_GUARD_CONTEXT_PRESSURE_MARKERS) or bool(
        re.search(r"\b(?:[6-9]\d|100)\s*%(?!\w).{0,120}\bcontext\b", scan)
        or re.search(r"\bcontext\b.{0,120}\b(?:[6-9]\d|100)\s*%(?!\w)", scan)
    )
    if not has_pressure:
        return False
    return text_contains_any(
        scan,
        [
            "fresh-session startup",
            "fresh session startup",
            "exact fresh-session startup action",
            "exact next startup action",
            "resume the active goal after startup gates",
            "resume the same active goal after startup gates",
        ],
    )


def response_guard_state_stop_errors(
    adapter_root: Path,
    last_assistant: str,
    *,
    explicit_user_stop: bool = False,
    human_discussion_request: bool = False,
) -> list[str]:
    if not response_guard_has_active_goal(adapter_root):
        return []
    if explicit_user_stop or human_discussion_request:
        return []
    if response_guard_has_documented_rotation_exit(last_assistant):
        return []
    if response_has_context_rotation_stop(last_assistant):
        return [
            "context-rotation stop: active-goal context rotation must use the documented continuity/startup path; "
            "continue the next authorized action, run "
            "`tautline blocker-declare --kind <k> --reason <r>` with current evidence, "
            "or rotate context through the documented continuity/startup path"
        ]
    record, read_error = read_blocker_record(adapter_root)
    if record is not None:
        fresh, reason = blocker_freshness(adapter_root, record)
        if fresh:
            return []
        return [
            "active-goal stop has a non-fresh blocker record "
            f"({reason}); continue the next authorized action, run "
            "`tautline blocker-declare --kind <k> --reason <r>` with current evidence, "
            "or rotate context through the documented continuity/startup path"
        ]
    detail = read_error or "missing"
    return [
        "active-goal stop requires a legal exit "
        f"({detail} blocker): continue the next authorized action, run "
        "`tautline blocker-declare --kind <k> --reason <r>`, "
        "or rotate context through the documented continuity/startup path"
    ]


def response_guard_hook(args: argparse.Namespace) -> int:
    try:
        payload = json.load(sys.stdin)
    except json.JSONDecodeError:
        return 0
    if payload.get("hook_event_name") not in {None, "Stop"}:
        return 0
    if payload.get("stop_hook_active"):
        return 0
    cwd = Path(payload.get("cwd") or ".").expanduser().resolve(strict=False)
    adapter_root = find_adapter_root(cwd)
    if adapter_root is None:
        return 0
    last_assistant = flatten_hook_text(payload.get("last_assistant_message"))
    transcript_path_raw = payload.get("transcript_path")
    transcript_path = Path(transcript_path_raw).expanduser() if isinstance(transcript_path_raw, str) else None
    last_user, transcript_assistant, recent_text = transcript_context(transcript_path)
    payload_last_user = flatten_hook_text(payload.get("last_user_message") or payload.get("last_user"))
    if payload_last_user:
        last_user = payload_last_user
    if not last_assistant:
        last_assistant = transcript_assistant
    if not response_guard_has_active_goal(adapter_root):
        return 0
    if not response_guard_should_evaluate_stop(payload, recent_text, last_assistant):
        return 0
    last_user_lower = last_user.lower()
    assistant_lower = last_assistant.lower()
    assistant_scan = response_guard_monitor_scan_text(last_assistant)
    recent_lower = recent_text.lower()
    active_monitor = (
        text_contains_any(assistant_scan, RESPONSE_GUARD_PASSIVE_MONITOR_PHRASES)
        or ("monitor" in assistant_scan and ("yield" in assistant_scan or "until then" in assistant_scan))
    )
    autonomous_yield = response_looks_like_autonomous_yield(last_assistant)
    rca_request = text_contains_any(last_user_lower, RESPONSE_GUARD_RCA_TRIGGERS)
    last_user_discussion_request = text_contains_any(last_user_lower, RESPONSE_GUARD_HUMAN_DISCUSSION_REQUEST_MARKERS)
    human_discussion_request = last_user_discussion_request or (
        not last_user_lower.strip() and response_looks_like_human_discussion_framing(last_assistant)
    )
    after_tool_rejection = text_contains_any(recent_lower, RESPONSE_GUARD_TOOL_REJECTION_MARKERS) and (
        text_contains_any(last_user_lower, RESPONSE_GUARD_STATUS_PROMPT_MARKERS)
        or text_contains_any(assistant_lower, RESPONSE_GUARD_FALSE_ACTIVITY_MARKERS)
    ) and not human_discussion_request
    derivable_next_action_prompt = text_contains_any(last_user_lower, RESPONSE_GUARD_DERIVABLE_NEXT_PROMPT_MARKERS)
    terminal_stop = response_is_terminal_stop_context(last_assistant)
    config = response_guard_config_for_root(adapter_root)
    phrase_checks = config["phraseChecks"]
    explicit_user_stop = user_explicitly_stopped_lane(last_user)
    state_errors = response_guard_state_stop_errors(
        adapter_root,
        last_assistant,
        explicit_user_stop=explicit_user_stop,
        human_discussion_request=human_discussion_request,
    )
    legacy_errors = response_guard_errors(
        last_assistant,
        active_monitor=active_monitor,
        autonomous_yield=autonomous_yield,
        rca_request=rca_request,
        after_tool_rejection=after_tool_rejection,
        derivable_next_action_prompt=derivable_next_action_prompt,
        terminal_stop=terminal_stop,
        boundary_summary=response_has_boundary_summary_marker(last_assistant),
        human_discussion_request=human_discussion_request,
        guard_adapter_root=adapter_root,
        phrase_checks=phrase_checks,
    )
    errors = state_errors + legacy_errors
    recovery_cancellation = response_has_recovery_cancellation_without_explicit_stop(last_assistant, last_user)
    # Phrase/state taxonomy follow-up: response_has_recovery_cancellation_without_explicit_stop reads ONLY
    # response_text and user_text (both transcript text) -- no lane artifact (GOAL_RUN.json,
    # BLOCKER.json, monitor status, rotation record, adapter, git/board state) -- so per the
    # binding E1 classification rule this is mechanism="phrase", not "state". A bare relabel is
    # not sufficient here: unlike response_guard_errors's calls, this call site does not go
    # through the local log_guard() closure that gates phrase checks on phraseChecks, so the
    # append is gated explicitly below, mirroring log_guard's phrase semantics: skip logging
    # entirely when phraseChecks is "off", and only block the stop when phraseChecks is
    # "blocking" ("advisory" still logs the event for guard-report but never blocks).
    if phrase_checks != "off":
        guard_log_event(adapter_root, "stop.recovery_cancellation_without_explicit_stop", recovery_cancellation, "phrase", "provider recovery")
    if recovery_cancellation and phrase_checks == "blocking":
        errors.append(
            "provider recovery cancellation without explicit stop: profanity, frustration, or an angry interjection is not a stop/cancel command; keep the provider recovery loop, scheduled retry, or autonomous goal loop armed unless the human explicitly says stop, cancel, pause, abort, or /goal clear"
        )
    goal_boundary_errors = response_guard_goal_boundary_errors(adapter_root, last_assistant)
    guard_log_event(adapter_root, "stop.goal_boundary_claim", bool(goal_boundary_errors), "state", "goal boundary")
    errors.extend(goal_boundary_errors)
    if errors:
        record_gate_telemetry("response-guard-stop", "block", error_count=len(errors))
        hook_decision(
            "Minervit response guard blocked this stop. "
            + " ".join(errors)
            + " Legal exits are: continue the next authorized action in this same turn; declare a true blocker with `tautline blocker-declare --kind <k> --reason <r>`; or rotate context through the documented continuity/startup path."
        )
    return 0


def tool_rejection_hook(args: argparse.Namespace) -> int:
    try:
        payload = json.load(sys.stdin)
    except json.JSONDecodeError:
        return 0
    event_name = str(payload.get("hook_event_name") or "PostToolUseFailure")
    text = flatten_hook_text(payload)
    if not text_contains_any(text, RESPONSE_GUARD_TOOL_REJECTION_MARKERS):
        return 0
    hook_additional_context(
        event_name,
        "Minervit methodology: the last tool call was rejected, denied, cancelled, or blocked. "
        "The next response must acknowledge that exact state first, must not claim active work is underway, and must either choose a different concrete action, do non-conflicting work, or ask one exact true-blocker question.",
    )
    return 0


# Board-as-source-of-truth safety rule #1 (2026-06-24 incident): a board is human-owned and
# read-authoritative; no agent or code path may mutate its STRUCTURE. These patterns match the
# board-structure mutations -- single-select option / field create-delete-edit (the exact
# `updateProjectV2Field` option-set rebuild that orphaned every card's Status), and item reorder.
# Per-item VALUE edits (`gh project item-edit`) and reads (`item-list`/`field-list`) are NOT here:
# value writes are the opt-in write path governed separately. A tuple (not a list) so this regex
# config stays out of the auto-collected policy-phrases SSOT.
# The line is CONTENT vs SCHEMA (operator clarification 2026-06-25): agents do normal developer work
# on board ITEMS -- move cards, change Status/field values, edit/comment issues, track milestones
# (`gh project item-*`, `gh issue *`, and the `updateProjectV2ItemFieldValue`/`addProjectV2ItemById`
# value mutations stay ALLOWED). Only board SCHEMA/structure is forbidden: fields, single-select
# options, item reorder, board-level create/delete/edit/copy/close, and views/workflow. The negative
# lookaheads on the board-level mutations are deliberate so the allowed item-value variants
# (updateProjectV2ItemFieldValue, deleteProjectV2Item, ...) are NOT caught.
BOARD_STRUCTURE_MUTATION_PATTERNS = (
    r"\bgh\s+project\s+field-(?:create|delete|edit)\b",
    r"\bgh\s+project\s+(?:delete|edit|copy|close|create)\b",
    r"updateprojectv2field",
    r"createprojectv2field",
    r"deleteprojectv2field",
    r"updateprojectv2itemposition",
    r"updateprojectv2(?![a-z])",
    r"createprojectv2(?![a-z])",
    r"deleteprojectv2(?![a-z])",
    r"copyprojectv2(?![a-z])",
    r"(?:create|update|delete)projectv2view",
    # A GraphQL query loaded from a file/stdin (`query=@file`, `query=@-`) can hide a schema mutation
    # from this guard, so it cannot be inspected -- block it; agents inline board queries.
    r"\bgh\s+api\s+graphql\b[^\n]*query=@",
)


def command_mutates_board_structure(command: str) -> bool:
    if not command:
        return False
    scan = (
        command.replace("‘", "'")
        .replace("’", "'")
        .replace("“", '"')
        .replace("”", '"')
        .lower()
    )
    return any(re.search(pattern, scan) for pattern in BOARD_STRUCTURE_MUTATION_PATTERNS)


# Tier-2 item-content protection (board-item-updates skill): an issue's title/summary, body (which
# holds the acceptance criteria), labels, and milestone are stakeholder-authored. An agent edits them
# only when the product's backlogProvider.allowedItemWrites opts in (default-deny; the first adopter leaves it
# default). Comments, Status writes, the ## Milestone Progress checklist, and self-assign are the
# allowed collaboration surface and are NOT here. The `[^|&;\n]*` scoping binds a forbidden flag to
# its own `gh issue edit` segment, so a chained `gh issue comment --body ... && gh issue edit
# --add-assignee` is not false-flagged.
ITEM_CONTENT_WRITE_FLAG_PATTERNS = (
    (r"\bgh\s+issue\s+edit\b[^|&;\n]*--title\b", "title"),
    (r"\bgh\s+issue\s+edit\b[^|&;\n]*--body(?:-file)?\b", "body"),
    (r"\bgh\s+issue\s+edit\b[^|&;\n]*--(?:add|remove)-label\b", "labels"),
    (r"\bgh\s+issue\s+edit\b[^|&;\n]*--milestone\b", "milestone"),
)
DEFAULT_ALLOWED_ITEM_WRITES = {"title": False, "body": False, "labels": False, "milestone": False}


def item_content_write_kinds(command: str) -> set:
    """The stakeholder-authored item fields a command would change (title/body/labels/milestone), or
    an empty set for allowed collaboration work. A GraphQL updateIssue can set title and body, so it
    reports both."""
    if not command:
        return set()
    scan = (
        command.replace("‘", "'").replace("’", "'").replace("“", '"').replace("”", '"').lower()
    )
    kinds = set()
    for pattern, kind in ITEM_CONTENT_WRITE_FLAG_PATTERNS:
        if re.search(pattern, scan):
            kinds.add(kind)
    if re.search(r"\bgh\s+api\s+graphql\b[^|&;\n]*updateissue\b", scan):
        kinds.update({"title", "body"})
    return kinds


def allowed_item_writes(data: dict) -> dict:
    """Resolve backlogProvider.allowedItemWrites with a default-deny baseline. Fail-closed: a
    non-boolean override is denied, never truthy-coerced."""
    raw: dict = {}
    provider = data.get("backlogProvider") if isinstance(data, dict) else None
    if isinstance(provider, dict) and isinstance(provider.get("allowedItemWrites"), dict):
        raw = provider["allowedItemWrites"]
    return {key: (raw.get(key) is True) for key in DEFAULT_ALLOWED_ITEM_WRITES}


def background_command_uses_fake_monitor(command: str) -> bool:
    scan = (
        command.replace("\u2018", "'")
        .replace("\u2019", "'")
        .replace("\u201c", '"')
        .replace("\u201d", '"')
        .lower()
    )
    return any(re.search(pattern, scan) for pattern in BACKGROUND_COMMAND_FAKE_MONITOR_PATTERNS)


def background_command_hook(args: argparse.Namespace) -> int:
    try:
        payload = json.load(sys.stdin)
    except json.JSONDecodeError:
        return 0
    if payload.get("tool_name") != "Bash":
        return 0
    cwd = Path(payload.get("cwd") or ".").expanduser().resolve(strict=False)
    adapter_root = find_adapter_root(cwd)
    if adapter_root is None:
        return 0
    tool_input = payload.get("tool_input") or {}
    command = ""
    if isinstance(tool_input, dict):
        command = str(tool_input.get("command") or tool_input.get("cmd") or "")
        if not command:
            command = flatten_hook_text(tool_input)
    else:
        command = flatten_hook_text(tool_input)
    board_structure = bool(command and command_mutates_board_structure(command))
    guard_log_event(adapter_root, "bash.board_structure", board_structure, "regex-command", command)
    if board_structure:
        hook_decision(
            "Board structure/schema is human-owned. Blocked: this command class can invalidate every "
            "card's status. Fix path: backlog-board-examine, then backlog-board-adopt --apply with "
            "operator confirmation."
        )
        return 0
    item_kinds = item_content_write_kinds(command) if command else set()
    item_content_blocked = False
    if item_kinds:
        try:
            adapter_data = json.loads(adapter_marker_path(adapter_root).read_text(encoding="utf-8"))
        except Exception:
            adapter_data = {}  # fail-closed: unreadable adapter -> all item-content writes denied
        allowed = allowed_item_writes(adapter_data)
        blocked = sorted(kind for kind in item_kinds if not allowed.get(kind))
        item_content_blocked = bool(blocked)
        guard_log_event(adapter_root, "bash.item_content_write", item_content_blocked, "regex-command", ",".join(blocked) or command)
        if blocked:
            hook_decision(
                "Bash command blocked by Minervit board-item guard. Editing an issue's "
                f"{', '.join(blocked)} is stakeholder-authored content (board-item-updates skill, Tier 2). "
                "Comment and ask instead (gh issue comment / stakeholder-questions), or set "
                "backlogProvider.allowedItemWrites.<field>=true in the adapter to opt this product in. "
                "Allowed item work needs no permission: Status via backlog-provider-update, comments, "
                "the ## Milestone Progress checklist, and self-assign."
            )
            return 0
    elif command:
        guard_log_event(adapter_root, "bash.item_content_write", False, "regex-command", command)
    fake_monitor = bool(command and background_command_uses_fake_monitor(command))
    guard_log_event(adapter_root, "bash.fake_monitor", fake_monitor, "regex-command", command)
    if not fake_monitor:
        return 0
    hook_decision(
        "Bash command blocked by Minervit background-monitor guard. "
        "A shell `until`/`while`/`sleep`/`wait`/`tail -F | grep` loop is not active supervision. "
        "Use the adapter command or `tautline background-run` with timeout/log/PID metadata, "
        "`monitor-status` active polling, and `ScheduleWakeup`/host-equivalent heartbeat when the work may outlast the turn."
    )
    return 0


# Allowlisted recovery commands are frequently wrapped in benign output handling -- an agent
# appends `2>&1` or `| head -60` to capture or limit output. These suffixes are inert (they merge
# or discard streams, or page stdin), so they are stripped before the injection-hardened metachar
# check below. The pager grammar is deliberately narrow: head/tail take ONLY a numeric line count
# (`-N`, `-nN`, `-n N`) and NEVER a filename -- `head <file>` ignores stdin and reads that file,
# which is exactly the local-file read the guard exists to deny. `cat` is allowed bare only.
# Whitespace is restricted to spaces/tabs so a newline/CR can never be consumed here before it
# reaches the metachar check. Anything that writes a real file, names a path, follows (`-f`), or
# chains/executes does NOT match, so a metachar survives the strip and still trips the guard
# (RCA: guard-wedge-no-escape, benign-output-suffix).
_BENIGN_OUTPUT_SUFFIX_RE = re.compile(
    r"[ \t]*(?:"
    r"2>&1|2>/dev/null|&>/dev/null|>/dev/null"  # merge or discard streams; no real file written
    r"|\|[ \t]*(?:cat|(?:head|tail)(?:[ \t]+-n?[ \t]*[0-9]+)?)"  # pager on stdin; optional numeric line count, never a filename
    r")[ \t]*\Z"
)


def strip_benign_output_suffix(command: str) -> str:
    previous = ""
    stripped = command
    while stripped != previous:
        previous = stripped
        stripped = _BENIGN_OUTPUT_SUFFIX_RE.sub("", stripped)
    return stripped


# A single `cd <metachar-free path>` joined with && or ; is a benign way to enter the lane root
# before an allowlisted recovery command (agents reflexively prefix it: "cd <lane>; minervit-...").
# The path charset excludes every shell metacharacter -- crucially `(` `)` and backtick -- so command
# substitution can never ride along; `$VAR` is allowed because parameter expansion is not re-parsed
# into command separators, so `cd $VAR` cannot inject a command regardless of $VAR's value.
_CD_RECOVERY_PREFIX_RE = re.compile(
    r"""^[ \t]*cd[ \t]+(?:"[\w./~@:+$= -]*"|'[\w./~@:+$= -]*'|[\w./~@:+$=-]+)[ \t]*(?:&&|;)[ \t]*"""
)
# Sourcing the known methodology.env to put the CLI on PATH, joined with && or ;, is the framework's
# own prescribed CLI-resolution step (lane-lifecycle / context-continuity skills). Only that specific
# path is accepted, so an arbitrary file can never be sourced through this guard.
_SOURCE_ENV_RECOVERY_PREFIX_RE = re.compile(
    r"""^[ \t]*(?:source|\.)[ \t]+["']?(?:\$HOME|\$\{HOME\}|~)/\.config/(?:tautline/tautline|minervit/methodology)\.env["']?[ \t]*(?:&&|;)[ \t]*"""
)
# Collapse ONLY the trusted env-var launcher path -- $MINERVIT_METHODOLOGY_REPO/bin/minervit-methodology,
# the exact form the skills prescribe when the CLI is not on PATH -- to the bare command so the
# allowlist matches it. A relative or arbitrary absolute path is deliberately NOT normalized: the
# shell runs whatever the path resolves to, so `./minervit-methodology` (a file planted in the lane),
# `evil/minervit-methodology`, `*/minervit-methodology`, `~/x/minervit-methodology`, and
# `/tmp/evil/bin/minervit-methodology` must stay blocked (Codex P1: launcher-path bypass). The
# environment variable's value is set by the trusted framework env, never by the command string.
_LAUNCHER_PATH_RE = re.compile(
    r"^(?:\$MINERVIT_METHODOLOGY_REPO|\$\{MINERVIT_METHODOLOGY_REPO\}|\$TAUTLINE_METHODOLOGY_REPO|\$\{TAUTLINE_METHODOLOGY_REPO\})/bin/(?:tautline|minervit-methodology)(?=[ \t])"
)


def strip_benign_recovery_prefix(command: str) -> str:
    """Strip leading benign recovery prefixes -- a single `cd <safe path>` into the lane and sourcing
    the known methodology.env to resolve the CLI -- joined with && or ;. Bounded loop. The remainder
    is re-checked against the strict metachar reject + allowlist, so a command smuggled after the
    prefix (e.g. `cd /x && cat secret`) is still blocked."""
    for _ in range(4):
        stripped = _SOURCE_ENV_RECOVERY_PREFIX_RE.sub("", command, count=1)
        stripped = _CD_RECOVERY_PREFIX_RE.sub("", stripped, count=1)
        if stripped == command:
            return command
        command = stripped
    return command


def latest_code_command_allowed(command: str) -> bool:
    # Strip inert output handling (see _BENIGN_OUTPUT_SUFFIX_RE) and benign leading recovery prefixes
    # (cd into the lane, source the known methodology.env -- the framework's own CLI-resolution step)
    # so the real-world recovery forms can release the guard, then reject shell chaining/injection/
    # redirection so an allowed prefix cannot smuggle a blocked command past it (e.g.
    # "cat src.py # tautline render-adapters" or "cd /x && cat secret"). The allowlist
    # then matches by prefix on what remains, not anywhere in it (Codex P1: substring bypass).
    command = strip_benign_recovery_prefix(strip_benign_output_suffix(command))
    if any(ch in command for ch in (";", "|", "&", "`", "\n", "\r", ">", "<", "#")) or "$(" in command:
        return False
    # Collapse the trusted env-var launcher path ($MINERVIT_METHODOLOGY_REPO/bin/minervit-methodology
    # only -- see _LAUNCHER_PATH_RE) to the bare CLI so that CLI-not-on-PATH recovery form matches the
    # on-PATH allowlist. A relative or arbitrary absolute path is not collapsed and stays blocked.
    scan = _LAUNCHER_PATH_RE.sub("tautline", command.strip()).lower()
    # Legacy rendered adapters still instruct `minervit-methodology <cmd>`; normalize the bare
    # legacy CLI name to the canonical one so both spellings match one allowlist.
    if scan.startswith("minervit-methodology "):
        scan = "tautline " + scan[len("minervit-methodology "):]
    allowed = [
        "tautline latest-code-status",
        "tautline lane-start",
        "tautline methodology-status",
        "tautline remote-main-status",
        # render-adapters is the canonical recovery from adapter sourceAdapterSha256 drift. It must
        # run in-band: otherwise an adapter drift wedges the lane (latest-code-status cannot pass
        # lane_project, render is blocked by this guard, no escape) (RCA: guard-wedge-no-escape).
        "tautline render-adapters",
        "git fetch",
        "git remote",
        "git branch",
        "git status",
        "git rev-parse",
        "git log",
        "gh pr list",
        "gh pr view",
        # read-only probes the skills tell agents to run to resolve the CLI when it is not on PATH
        "command -v tautline",
        "which tautline",
        "type tautline",
        "command -v minervit-methodology",
        "which minervit-methodology",
        "type minervit-methodology",
    ]
    return any(scan.startswith(item) for item in allowed)


LATEST_CODE_STATE_CHANGING_TOOL_MATCHERS = (
    "Bash",
    "Edit",
    "MultiEdit",
    "Write",
    "NotebookEdit",
    "ExitPlanMode",
    "Task",
)

LATEST_CODE_STATE_CHANGING_BASH_PATTERNS = (
    r"\bmake\s+(?:.*\b)?(?:deploy|release|publish|migrate|migration|seed|write|apply)\b",
    r"\b(?:terraform|pulumi|cdk)\s+(?:apply|destroy|deploy|up)\b",
)


def latest_code_redirection_writes_real_file(command: str) -> bool:
    for match in re.finditer(r"(?<![<])(?:[0-9]+)?(?:>>?|&>)[ \t]*([^ \t;&|]+)", command):
        target = match.group(1)
        if target.startswith("&") or target == "/dev/null":
            continue
        return True
    return False


def latest_code_shell_tokens(command: str) -> list[str]:
    try:
        return shlex.split(command, comments=False, posix=True)
    except ValueError:
        return []


def shell_token_basename(token: str) -> str:
    return token.rsplit("/", 1)[-1]


def skip_cli_global_options(tokens: list[str], index: int, value_options: set[str]) -> int:
    while index < len(tokens):
        token = tokens[index]
        if token in {"&&", "||", ";", "|"}:
            index += 1
            continue
        if not token.startswith("-") or token == "-":
            return index
        option = token.split("=", 1)[0]
        index += 1
        if "=" not in token and option in value_options and index < len(tokens):
            index += 1
    return index


def latest_code_tokens_include_state_change(tokens: list[str]) -> bool:
    git_mutating = {
        "add",
        "am",
        "apply",
        "checkout",
        "cherry-pick",
        "clean",
        "commit",
        "merge",
        "mv",
        "pull",
        "push",
        "rebase",
        "reset",
        "restore",
        "revert",
        "rm",
        "stash",
        "switch",
        "tag",
    }
    gh_mutating = {"close", "comment", "create", "delete", "edit", "merge", "ready", "reopen", "transfer", "update"}
    file_mutating = {"rm", "mv", "cp", "mkdir", "touch", "chmod", "chown", "ln", "tee"}
    package_mutating = {"add", "install", "i", "remove", "uninstall", "update", "ci"}
    for index, token in enumerate(tokens):
        name = shell_token_basename(token)
        if name == "git":
            subcommand_index = skip_cli_global_options(
                tokens,
                index + 1,
                {"-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env", "--exec-path"},
            )
            subcommand = tokens[subcommand_index] if subcommand_index < len(tokens) else ""
            if subcommand in git_mutating:
                return True
            if subcommand == "branch" and any(arg in {"-d", "-D", "-m", "-M"} for arg in tokens[subcommand_index + 1 :]):
                return True
        if name == "gh":
            subcommand_index = skip_cli_global_options(
                tokens,
                index + 1,
                {"-r", "--repo", "--hostname", "--jq", "--template"},
            )
            subcommand = tokens[subcommand_index] if subcommand_index < len(tokens) else ""
            remaining = tokens[subcommand_index + 1 :]
            if subcommand in {"issue", "pr", "project", "release"} and any(
                arg in gh_mutating or arg.startswith(("field-", "item-")) for arg in remaining
            ):
                return True
            if subcommand == "api":
                upper_remaining = [arg.upper() for arg in remaining]
                if "MUTATION" in " ".join(upper_remaining):
                    return True
                for arg_index, arg in enumerate(upper_remaining):
                    if arg in {"-X", "--METHOD"} and arg_index + 1 < len(upper_remaining):
                        if upper_remaining[arg_index + 1] in {"POST", "PUT", "PATCH", "DELETE"}:
                            return True
                    if arg.startswith(("-X", "--METHOD=")) and any(
                        method in arg for method in ("POST", "PUT", "PATCH", "DELETE")
                    ):
                        return True
        if name in file_mutating:
            return True
        if name == "sed" and any(arg == "-i" or arg.startswith("-i") for arg in tokens[index + 1 :]):
            return True
        if name in {"npm", "pnpm", "yarn", "bun"} and index + 1 < len(tokens) and tokens[index + 1] in package_mutating:
            return True
        if name in {"pip", "pip3"} and index + 1 < len(tokens) and tokens[index + 1] == "install":
            return True
        if name in {"python", "python3"} and tokens[index + 1 : index + 4] == ["-m", "pip", "install"]:
            return True
        if name in {"uv", "poetry"} and index + 1 < len(tokens) and tokens[index + 1] in {
            "add",
            "install",
            "remove",
            "sync",
            "update",
        }:
            return True
    return False


def latest_code_bash_requires_fresh_baseline(command: str) -> bool:
    if not command:
        return False
    command = strip_benign_recovery_prefix(strip_benign_output_suffix(command))
    if latest_code_command_allowed(command):
        return False
    scan = (
        command.replace("‘", "'")
        .replace("’", "'")
        .replace("“", '"')
        .replace("”", '"')
        .lower()
    )
    if latest_code_redirection_writes_real_file(scan):
        return True
    if latest_code_tokens_include_state_change(latest_code_shell_tokens(scan)):
        return True
    return any(re.search(pattern, scan) for pattern in LATEST_CODE_STATE_CHANGING_BASH_PATTERNS)


def latest_code_tool_requires_fresh_baseline(payload: dict) -> bool:
    tool_name = str(payload.get("tool_name") or "")
    if tool_name in {"Edit", "MultiEdit", "Write", "NotebookEdit", "ExitPlanMode", "Task"}:
        return True
    if tool_name != "Bash":
        return False
    tool_input = payload.get("tool_input") or {}
    if isinstance(tool_input, dict):
        command = str(tool_input.get("command") or tool_input.get("cmd") or "")
        if not command:
            command = flatten_hook_text(tool_input)
    else:
        command = flatten_hook_text(tool_input)
    return latest_code_bash_requires_fresh_baseline(command)


LATEST_CODE_AUTOREFRESH_BACKOFF_SECONDS = 90


def latest_code_autorefresh_marker_path(data: dict, target: Path) -> Path:
    return latest_code_status_path(data, target).parent / ".latest-code-autorefresh.json"


def latest_code_autorefresh_note(baseline: dict, warnings: object = None) -> str | None:
    """A concise heads-up when the auto-refresh surfaced work ahead of base or the fetch did not
    complete, so the agent keeps the awareness the guard was built to create -- without the block.
    Silent when the baseline refreshed cleanly with nothing ahead. Emits only integer counts (never
    branch names, URLs, or warning text, which can carry remote tokens)."""
    ahead = baseline.get("remoteBranchesAheadOfBase") or []
    prs = baseline.get("openPrs") or []
    fetch_incomplete = bool(warnings)
    if not ahead and not prs and not fetch_incomplete:
        return None
    parts: list[str] = []
    if ahead:
        parts.append(f"{len(ahead)} remote branch(es) ahead of base")
    if prs:
        parts.append(f"{len(prs)} open PR(s)")
    note = "Minervit latest-code: the freshness window had lapsed and the baseline was auto-refreshed."
    if parts:
        note += (
            " " + " and ".join(parts)
            + " exist -- do not assume local HEAD or origin/main is current; review them before deep work."
        )
    if fetch_incomplete:
        note += (
            " (git fetch did not fully complete; the baseline reflects the last-known remote state --"
            " verify connectivity before relying on it.)"
        )
    return note


def latest_code_autorefresh(data: dict, target: Path) -> tuple[bool, str | None]:
    """Self-heal a stale latest-code baseline from inside the guard: run the same fetch the guard
    would otherwise demand and rewrite the baseline, so a benign tool call right after the freshness
    window lapses is not blocked. Returns (refreshed, note). On any failure (offline, no remote, not
    a worktree, drift the write cannot resolve) returns (False, None) so the caller falls back to the
    allowlist + block path. A short backoff throttles re-attempts so a genuinely offline lane does not
    refetch on every blocked tool call."""
    # Fail closed on provenance + repo identity exactly like the command path (latest-code-status ->
    # lane_project -> latest_code_degraded_lane_data): only ever write a releasing baseline from a
    # genuine generated lane adapter whose repo matches THIS target. A copied or wrong-repo adapter
    # would otherwise report another repo's PR surface and unblock the guard on a lie (Codex P1).
    safe_data = latest_code_degraded_lane_data(target)
    if safe_data is None:
        return False, None
    marker = latest_code_autorefresh_marker_path(safe_data, target)
    now = datetime.now(timezone.utc)
    try:
        last = json.loads(marker.read_text(encoding="utf-8")).get("lastAttempt", "")
        last_at = datetime.fromisoformat(str(last).replace("Z", "+00:00"))
        if (now - last_at).total_seconds() < LATEST_CODE_AUTOREFRESH_BACKOFF_SECONDS:
            return False, None
    except (OSError, ValueError, TypeError, AttributeError, json.JSONDecodeError):
        pass
    try:
        marker.parent.mkdir(parents=True, exist_ok=True)
        marker.write_text(json.dumps({"lastAttempt": now.isoformat()}) + "\n", encoding="utf-8")
    except OSError:
        pass
    try:
        baseline, warnings = latest_code_baseline(safe_data, target, write=True)
    except (Exception, SystemExit):
        return False, None
    ok, _state, _refreshed = latest_code_baseline_state(safe_data, target)
    if not ok:
        return False, None
    return True, latest_code_autorefresh_note(baseline, warnings)


def latest_code_hook(args: argparse.Namespace) -> int:
    try:
        payload = json.load(sys.stdin)
    except json.JSONDecodeError:
        return 0
    tool_name = str(payload.get("tool_name") or "")
    cwd = Path(payload.get("cwd") or ".").expanduser().resolve(strict=False)
    target = find_adapter_root(cwd)
    if target is None:
        return 0
    try:
        data = load_project(adapter_marker_path(target))
    except SystemExit:
        return 0
    if not latest_code_config(data)["enabled"]:
        return 0
    ok, state, _baseline = latest_code_baseline_state(data, target)
    if ok:
        return 0
    if not latest_code_tool_requires_fresh_baseline(payload):
        hook_additional_context(
            "PreToolUse",
            "Minervit latest-code: baseline is stale or missing, but this tool call is read-only or not repo-state-changing, so it is allowed. Run `tautline latest-code-status --target . --write` before edits, plan finalization, commits, pushes, or other state-changing work.",
        )
        return 0
    # Auto-refresh self-heal: do the fetch the guard would otherwise demand so a benign tool call
    # right after the freshness window lapses is not blocked. Only when the refresh cannot self-heal
    # (offline, no remote, unresolved drift) do we fall back to the allowlist + block path below.
    refreshed, note = latest_code_autorefresh(data, target)
    if refreshed:
        if note:
            hook_additional_context("PreToolUse", note)
        return 0
    if tool_name == "Bash":
        tool_input = payload.get("tool_input") or {}
        if isinstance(tool_input, dict):
            command = str(tool_input.get("command") or tool_input.get("cmd") or "")
            if not command:
                command = flatten_hook_text(tool_input)
        else:
            command = flatten_hook_text(tool_input)
        if latest_code_command_allowed(command):
            return 0
    hook_decision(
        "Tool blocked by Minervit latest-code guard. "
        f"Latest-code baseline is {state}. Before edits, plan finalization, commits, pushes, or other state-changing work, run "
        "`tautline latest-code-status --target . --write` from the lane root. "
        "This fetches remote base plus ahead remote branches/open PRs so a deployed branch cannot be missed. "
        "If that reports latest_code_adapter_drift, re-render in-band with "
        "`tautline render-adapters --project <methodology_repo>/adapters/projects/<project>.json --target . --write --json-only`, "
        "then re-run latest-code-status. Both commands are always permitted under this guard."
    )
    return 0


def context_rotation_heartbeat_state_path(data: dict, target: Path) -> Path:
    return goal_run_path(data, target).parent / "context-rotation-heartbeat.json"


def context_rotation_heartbeat_hook(args: argparse.Namespace) -> int:
    try:
        return context_rotation_heartbeat_hook_inner(args)
    except (Exception, SystemExit):
        return 0


def context_rotation_heartbeat_hook_inner(args: argparse.Namespace) -> int:
    try:
        payload = json.load(sys.stdin)
    except json.JSONDecodeError:
        return 0
    event_name = str(payload.get("hook_event_name") or "PostToolUse")
    if event_name != "PostToolUse":
        return 0
    cwd = Path(payload.get("cwd") or ".").expanduser().resolve(strict=False)
    target = find_adapter_root(cwd)
    if target is None:
        return 0
    try:
        data = load_project(adapter_marker_path(target))
    except SystemExit:
        return 0
    if not data["contextRotation"].get("enabled", True):
        return 0
    if not goal_run_path(data, target).exists():
        return 0
    heartbeat_path = context_rotation_heartbeat_state_path(data, target)
    now = int(time.time())
    interval = int(data["contextRotation"]["heartbeatMinutes"]) * 60
    try:
        previous = json.loads(heartbeat_path.read_text(encoding="utf-8")) if heartbeat_path.exists() else {}
        last = int(previous.get("lastHeartbeatEpoch", 0))
    except (OSError, ValueError, TypeError, json.JSONDecodeError):
        last = 0
    if last and now - last < interval:
        return 0
    heartbeat_path.parent.mkdir(parents=True, exist_ok=True)
    heartbeat_path.write_text(json.dumps({"lastHeartbeatEpoch": now}, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    boundary = data["contextRotation"]["heartbeatBoundary"]
    soft = data["contextRotation"]["softPercent"]
    hard = data["contextRotation"]["hardPercent"]
    hook_additional_context(
        event_name,
        "Minervit context heartbeat for long `/goal` work: inspect the visible context percentage now if the host "
        f"shows one. If it is at or above {soft}%, reach the next safe checkpoint and run "
        f"`tautline context-rotation-check --target . --boundary {boundary} --context-percent <visible-percent> --context-percent-source estimate`. "
        "Pass --context-percent-source host only if the host literally exposes a context-window counter; a host counter is the only source that can make rotation mandatory, so an estimate is the safe default. "
        f"At or above {hard}% with a host-sourced percent this is mandatory at the next safe boundary; an estimate stays advisory. If the host does not expose a percent or "
        "the current edit/review/preflight is unsafe to interrupt, continue only until the next safe checkpoint; do not "
        "rotate or stop on a self-estimated or guessed percentage, because an unmeasured context guess is advisory only "
        "and is never a mandatory-rotation trigger or a stop reason. At that safe checkpoint, then "
        "refresh continuity/session journal, invoke `/compact` or the strongest host compact/restart path when available, "
        "and resume the same goal. If this agent turn cannot invoke `/compact` directly, write the handoff/journal evidence "
        "and state the exact fresh-session startup action without asking the human operator whether to compact or continue. "
        "Do not describe context exhaustion as a terminal blocker or wait until final `/goal` completion to handle context pressure.",
    )
    return 0


GOAL_LEDGER_SUBSTANTIAL_MARKERS = [
    "multi-milestone",
    "multiple milestones",
    "across milestones",
    "multi-pr",
    "multiple prs",
    "several prs",
    "multi-day",
    "overnight",
    "build, ship",
    "build/ship",
    "ship and finish",
    "goal plan",
]


def plan_finalization_hook(args: argparse.Namespace) -> int:
    try:
        payload = json.load(sys.stdin)
    except json.JSONDecodeError:
        return 0
    if payload.get("tool_name") != "ExitPlanMode":
        return 0
    cwd = Path(payload.get("cwd") or ".").expanduser().resolve(strict=False)
    target = find_adapter_root(cwd)
    if target is None:
        return 0
    def log_plan_guard(fired: bool, detail: object = "") -> None:
        guard_log_event(target, "plan.finalization", fired, "state", detail)

    adapter_path = adapter_marker_path(target)
    tool_input = payload.get("tool_input") or {}
    plan_file = tool_input.get("planFilePath") or tool_input.get("plan_file_path") or tool_input.get("path")
    plan_text = tool_input.get("plan")
    transcript_path_raw = payload.get("transcript_path")
    transcript_path = Path(transcript_path_raw).expanduser() if isinstance(transcript_path_raw, str) else None
    last_user, last_assistant, recent_text = transcript_context(transcript_path)
    extra_reference_text = "\n".join([last_user, last_assistant, recent_text])
    try:
        data = load_project(adapter_path)
    except SystemExit as exc:
        log_plan_guard(True, "adapter load failure")
        hook_decision(f"ExitPlanMode blocked: methodology adapter could not be loaded: {exc}")
        return 0
    _plan_path, resolution_note = resolve_exit_plan_mode_plan(
        data,
        target,
        str(plan_file) if plan_file else None,
        plan_text,
        extra_reference_text,
    )
    if _plan_path is None:
        log_plan_guard(True, "unresolved source-of-truth plan")
        if plan_file:
            hook_decision(
                "ExitPlanMode blocked: the scratch plan path does not map to exactly one source-of-truth plan. "
                "Move or update the plan in the adapter source-of-truth path, run run-plan-review and "
                "plan-finalization-precheck, then retry."
            )
        else:
            hook_decision(
                "ExitPlanMode blocked: Claude did not provide a plan path or plan content that matches exactly one "
                "source-of-truth plan. Write the plan into the source-of-truth path, run run-plan-review and "
                "plan-finalization-precheck, then retry."
            )
        return 0
    source_root = planning_source_root(data, target)
    if plan_file and not path_is_under(_plan_path, source_root) and path_is_configured_scratch_plan(data, target, _plan_path):
        log_plan_guard(False, "scratch plan escape context")
        arg = guard_target_argument(target)
        hook_additional_context(
            "PreToolUse",
            "Minervit plan-mode scratch escape: ExitPlanMode may proceed only to leave Claude's "
            f"read-only plan mode. The supplied plan path is scratch-only ({_plan_path}), not "
            "source-of-truth. Immediately after exit, do not ask the human operator to choose a "
            "bypass and do not start implementation. Create or migrate the plan under "
            f"{source_root}, include the required substantive sections, run "
            f"`tautline run-plan-review --target {arg} --plan <source-of-truth-plan>`, classify "
            f"the existing log with `tautline finalize-plan-review --target {arg} --plan "
            "<source-of-truth-plan> --log <printed-log> --verdict "
            "<clean|clean-with-deferrals|blocked> --unresolved-critical-count <n> "
            "--unresolved-p1-count <n>`, then require "
            f"`tautline plan-finalization-precheck --target {arg} --plan <source-of-truth-plan>` "
            "to pass before approval prompts, execution packets, ready marks, or implementation. "
            "Treat this as unfinished plan finalization, not plan approval.",
        )
        return 0
    errors, _plan_path, _manifest_path = plan_finalization_precheck_errors(data, target, _plan_path)
    if errors:
        log_plan_guard(True, "; ".join(errors[:2]))
        prefix = f"{resolution_note} " if resolution_note else ""
        hook_decision(
            prefix
            + "ExitPlanMode blocked by Minervit methodology. "
            + plan_review_recovery_instruction(data, target, _plan_path)
            + ". "
            + "Errors: "
            + "; ".join(errors[:8])
        )
        return 0
    # RCA O14: substantial multi-milestone/multi-PR/overnight work must have a goal-orchestration
    # ledger before implementation-start, or iteration-review and milestone tracking are bypassed.
    # Non-blocking nudge at plan finalization (the implementation-start gate) when the plan reads as
    # substantial and no ledger exists.
    goal_path = goal_run_path(data, target)
    scope_text = (str(plan_text or "") + " " + extra_reference_text).lower()
    if not goal_path.exists() and text_contains_any(scope_text, GOAL_LEDGER_SUBSTANTIAL_MARKERS):
        hook_additional_context(
            "PreToolUse",
            "Minervit goal-ledger check: this plan reads as substantial (multi-milestone/multi-PR/"
            f"overnight/build-ship-finish) but no goal-orchestration ledger exists at {goal_path}. "
            "Before implementation-start, run "
            f"`tautline goal-start --target {guard_target_argument(target)} "
            "--goal <source-of-truth-goal-plan>` so milestone and iteration-review orchestration "
            "is tracked. If this is a single tactical PR, proceed.",
        )
    log_plan_guard(False, "pass")
    return 0


def branch_liveness_hook(args: argparse.Namespace) -> int:
    try:
        payload = json.load(sys.stdin)
    except json.JSONDecodeError:
        return 0
    if payload.get("tool_name") != "Task":
        return 0
    cwd = Path(payload.get("cwd") or ".").expanduser().resolve(strict=False)
    command = [sys.executable, str(Path(__file__).resolve()), "branch-liveness-check", "--target", str(cwd), "--strict"]
    code, stdout, stderr = run_command(command, timeout=20)
    if code != 0:
        detail = (stderr or stdout or "branch liveness check failed").strip()
        hook_decision(
            "Task blocked by Minervit branch-liveness guard. "
            + "The current branch is not proven active; sync main or move to a live PR branch before dispatching tactical subagents. "
            + detail[:1200]
        )
    return 0


def settings_hook_installed(settings: dict) -> bool:
    hooks = settings.get("hooks", {}).get("PreToolUse", [])
    return any("plan-finalization-hook" in json.dumps(item) for item in hooks)


def settings_branch_liveness_hook_installed(settings: dict) -> bool:
    hooks = settings.get("hooks", {}).get("PreToolUse", [])
    return any("branch-liveness-hook" in json.dumps(item) for item in hooks)


def settings_response_guard_hook_installed(settings: dict) -> bool:
    hooks = settings.get("hooks", {}).get("Stop", [])
    return any("response-guard-hook" in json.dumps(item) for item in hooks)


def settings_tool_rejection_hook_installed(settings: dict) -> bool:
    hooks = settings.get("hooks", {}).get("PostToolUseFailure", [])
    return any("tool-rejection-hook" in json.dumps(item) for item in hooks)


def settings_background_command_hook_installed(settings: dict) -> bool:
    hooks = settings.get("hooks", {}).get("PreToolUse", [])
    return any("background-command-hook" in json.dumps(item) for item in hooks)


def settings_latest_code_hook_installed(settings: dict) -> bool:
    hooks = settings.get("hooks", {}).get("PreToolUse", [])
    return any("latest-code-hook" in json.dumps(item) for item in hooks)


def latest_code_hook_entry_is_current(entry: dict) -> bool:
    if not isinstance(entry, dict):
        return False
    if entry.get("matcher") not in LATEST_CODE_STATE_CHANGING_TOOL_MATCHERS:
        return False
    return "latest-code-hook" in json.dumps(entry)


def settings_latest_code_hook_current(settings: dict) -> bool:
    hooks = settings.get("hooks", {}).get("PreToolUse", [])
    matchers = {
        str(entry.get("matcher"))
        for entry in hooks
        if latest_code_hook_entry_is_current(entry)
    }
    stale_latest_code_entries = [
        entry
        for entry in hooks
        if "latest-code-hook" in json.dumps(entry) and not latest_code_hook_entry_is_current(entry)
    ]
    return set(LATEST_CODE_STATE_CHANGING_TOOL_MATCHERS).issubset(matchers) and not stale_latest_code_entries


def settings_context_rotation_heartbeat_hook_installed(settings: dict) -> bool:
    hooks = settings.get("hooks", {}).get("PostToolUse", [])
    return any("context-rotation-heartbeat-hook" in json.dumps(item) for item in hooks)


def claude_hook_state(target: Path) -> tuple[bool, str]:
    candidates = [Path.home() / ".claude" / "settings.json", target / ".claude" / "settings.json"]
    for path in candidates:
        if not path.exists():
            continue
        try:
            settings = json.loads(path.read_text(encoding="utf-8"))
        except json.JSONDecodeError:
            continue
        if settings_hook_installed(settings):
            return True, f"installed {path}"
    return False, "missing required hook - run `tautline install-hooks` for Claude ExitPlanMode hook"


def claude_branch_liveness_hook_state(target: Path) -> tuple[bool, str]:
    candidates = [Path.home() / ".claude" / "settings.json", target / ".claude" / "settings.json"]
    for path in candidates:
        if not path.exists():
            continue
        try:
            settings = json.loads(path.read_text(encoding="utf-8"))
        except json.JSONDecodeError:
            continue
        if settings_branch_liveness_hook_installed(settings):
            return True, f"installed {path}"
    return False, "missing required hook - run `tautline install-hooks --target .` for Claude Task branch-liveness hook"


def claude_response_guard_hook_state(target: Path) -> tuple[bool, str]:
    candidates = [Path.home() / ".claude" / "settings.json", target / ".claude" / "settings.json"]
    for path in candidates:
        if not path.exists():
            continue
        try:
            settings = json.loads(path.read_text(encoding="utf-8"))
        except json.JSONDecodeError:
            continue
        if settings_response_guard_hook_installed(settings):
            return True, f"installed {path}"
    return False, "missing required hook - run `tautline install-hooks --target .` for Claude Stop response guard hook"


def claude_tool_rejection_hook_state(target: Path) -> tuple[bool, str]:
    candidates = [Path.home() / ".claude" / "settings.json", target / ".claude" / "settings.json"]
    for path in candidates:
        if not path.exists():
            continue
        try:
            settings = json.loads(path.read_text(encoding="utf-8"))
        except json.JSONDecodeError:
            continue
        if settings_tool_rejection_hook_installed(settings):
            return True, f"installed {path}"
    return False, "missing required hook - run `tautline install-hooks --target .` for Claude tool-rejection context hook"


def claude_background_command_hook_state(target: Path) -> tuple[bool, str]:
    candidates = [Path.home() / ".claude" / "settings.json", target / ".claude" / "settings.json"]
    for path in candidates:
        if not path.exists():
            continue
        try:
            settings = json.loads(path.read_text(encoding="utf-8"))
        except json.JSONDecodeError:
            continue
        if settings_background_command_hook_installed(settings):
            return True, f"installed {path}"
    return False, "missing required hook - run `tautline install-hooks --target .` for Claude Bash background-command guard"


def claude_latest_code_hook_state(target: Path) -> tuple[bool, str]:
    candidates = [Path.home() / ".claude" / "settings.json", target / ".claude" / "settings.json"]
    for path in candidates:
        if not path.exists():
            continue
        try:
            settings = json.loads(path.read_text(encoding="utf-8"))
        except json.JSONDecodeError:
            continue
        if settings_latest_code_hook_installed(settings):
            return True, f"installed {path}"
    return False, "missing required hook - run `tautline install-hooks --target .` for Claude latest-code guard"


def claude_context_rotation_heartbeat_hook_state(target: Path) -> tuple[bool, str]:
    candidates = [Path.home() / ".claude" / "settings.json", target / ".claude" / "settings.json"]
    for path in candidates:
        if not path.exists():
            continue
        try:
            settings = json.loads(path.read_text(encoding="utf-8"))
        except json.JSONDecodeError:
            continue
        if settings_context_rotation_heartbeat_hook_installed(settings):
            return True, f"installed {path}"
    return False, "missing required hook - run `tautline install-hooks --target .` for Claude context-rotation heartbeat hook"


def claude_hook_status(target: Path) -> str:
    return claude_hook_state(target)[1]


def load_claude_settings(settings_path: Path) -> dict:
    settings_path = settings_path.expanduser()
    for attempt in range(5):
        if not settings_path.exists():
            return {}
        try:
            raw = settings_path.read_text(encoding="utf-8")
            if not raw.strip():
                raise json.JSONDecodeError("empty settings file", raw, 0)
            return json.loads(raw)
        except (OSError, json.JSONDecodeError):
            if attempt == 4:
                raise
            time.sleep(0.05)
    return {}


def write_claude_settings(settings_path: Path, settings: dict) -> None:
    settings_path = settings_path.expanduser()
    settings_path.parent.mkdir(parents=True, exist_ok=True)
    payload = json.dumps(settings, indent=2, sort_keys=True) + "\n"
    tmp_path = settings_path.with_name(f".{settings_path.name}.{os.getpid()}.{time.time_ns()}.tmp")
    try:
        tmp_path.write_text(payload, encoding="utf-8")
        os.replace(tmp_path, settings_path)
    finally:
        if tmp_path.exists():
            tmp_path.unlink()


def write_claude_autocompact_settings(settings_path: Path) -> tuple[Path, bool]:
    settings_path = settings_path.expanduser()
    settings = load_claude_settings(settings_path)
    settings.setdefault("env", {})
    changed = False
    for key, value in CLAUDE_AUTOCOMPACT_ENV_KEYS.items():
        if settings["env"].get(key) != value:
            settings["env"][key] = value
            changed = True
    if changed or not settings_path.exists():
        write_claude_settings(settings_path, settings)
    return settings_path, not changed


def claude_autocompact_settings_state(settings_path: Path) -> tuple[bool, str]:
    settings_path = settings_path.expanduser()
    if not settings_path.exists():
        return False, f"missing durable Claude settings env at {settings_path}"
    try:
        settings = load_claude_settings(settings_path)
    except (OSError, json.JSONDecodeError) as exc:
        return False, f"unreadable durable Claude settings env at {settings_path}: {exc}"
    env = settings.get("env")
    if not isinstance(env, dict):
        return False, f"missing durable Claude settings env block at {settings_path}"
    mismatches = [
        f"{key}={env.get(key) or 'missing'}"
        for key, value in CLAUDE_AUTOCOMPACT_ENV_KEYS.items()
        if env.get(key) != value
    ]
    if mismatches:
        return False, f"drifted durable Claude settings env at {settings_path}: {', '.join(mismatches)}"
    return True, f"ok {settings_path}"


def write_claude_plan_finalization_hook(settings_path: Path, command: str) -> tuple[Path, bool]:
    settings_path = settings_path.expanduser()
    settings = load_claude_settings(settings_path)
    settings.setdefault("hooks", {})
    settings["hooks"].setdefault("PreToolUse", [])
    already_present = settings_hook_installed(settings)
    if not already_present:
        settings["hooks"]["PreToolUse"].append(
            {
                "matcher": "ExitPlanMode",
                "hooks": [{"type": "command", "command": command}],
            }
        )
    write_claude_settings(settings_path, settings)
    return settings_path, already_present


def write_claude_branch_liveness_hook(settings_path: Path, command: str) -> tuple[Path, bool]:
    settings_path = settings_path.expanduser()
    settings = load_claude_settings(settings_path)
    settings.setdefault("hooks", {})
    settings["hooks"].setdefault("PreToolUse", [])
    already_present = settings_branch_liveness_hook_installed(settings)
    if not already_present:
        settings["hooks"]["PreToolUse"].append(
            {
                "matcher": "Task",
                "hooks": [{"type": "command", "command": command}],
            }
        )
    write_claude_settings(settings_path, settings)
    return settings_path, already_present


def write_claude_response_guard_hook(settings_path: Path, command: str) -> tuple[Path, bool]:
    settings_path = settings_path.expanduser()
    settings = load_claude_settings(settings_path)
    settings.setdefault("hooks", {})
    settings["hooks"].setdefault("Stop", [])
    already_present = settings_response_guard_hook_installed(settings)
    if not already_present:
        settings["hooks"]["Stop"].append({"hooks": [{"type": "command", "command": command}]})
    write_claude_settings(settings_path, settings)
    return settings_path, already_present


def write_claude_tool_rejection_hook(settings_path: Path, command: str) -> tuple[Path, bool]:
    settings_path = settings_path.expanduser()
    settings = load_claude_settings(settings_path)
    settings.setdefault("hooks", {})
    settings["hooks"].setdefault("PostToolUseFailure", [])
    already_present = settings_tool_rejection_hook_installed(settings)
    if not already_present:
        settings["hooks"]["PostToolUseFailure"].append(
            {
                "matcher": "*",
                "hooks": [{"type": "command", "command": command}],
            }
        )
    write_claude_settings(settings_path, settings)
    return settings_path, already_present


def write_claude_background_command_hook(settings_path: Path, command: str) -> tuple[Path, bool]:
    settings_path = settings_path.expanduser()
    settings = load_claude_settings(settings_path)
    settings.setdefault("hooks", {})
    settings["hooks"].setdefault("PreToolUse", [])
    already_present = settings_background_command_hook_installed(settings)
    if not already_present:
        settings["hooks"]["PreToolUse"].append(
            {
                "matcher": "Bash",
                "hooks": [{"type": "command", "command": command}],
            }
        )
    write_claude_settings(settings_path, settings)
    return settings_path, already_present


def write_claude_latest_code_hook(settings_path: Path, command: str) -> tuple[Path, bool]:
    settings_path = settings_path.expanduser()
    settings = load_claude_settings(settings_path)
    settings.setdefault("hooks", {})
    settings["hooks"].setdefault("PreToolUse", [])
    already_current = settings_latest_code_hook_current(settings)
    if not already_current:
        rewritten_hooks: list[dict] = []
        for entry in settings["hooks"]["PreToolUse"]:
            if "latest-code-hook" not in json.dumps(entry):
                rewritten_hooks.append(entry)
                continue
            hooks = [
                hook
                for hook in entry.get("hooks", [])
                if "latest-code-hook" not in json.dumps(hook)
            ]
            if hooks:
                kept = dict(entry)
                kept["hooks"] = hooks
                rewritten_hooks.append(kept)
        existing = {
            (str(entry.get("matcher")), json.dumps(entry.get("hooks", []), sort_keys=True))
            for entry in rewritten_hooks
        }
        for matcher in LATEST_CODE_STATE_CHANGING_TOOL_MATCHERS:
            hook_entry = {
                "matcher": matcher,
                "hooks": [{"type": "command", "command": command}],
            }
            key = (matcher, json.dumps(hook_entry["hooks"], sort_keys=True))
            if key not in existing:
                rewritten_hooks.append(hook_entry)
        settings["hooks"]["PreToolUse"] = rewritten_hooks
        write_claude_settings(settings_path, settings)
    return settings_path, already_current


def write_claude_context_rotation_heartbeat_hook(settings_path: Path, command: str) -> tuple[Path, bool]:
    settings_path = settings_path.expanduser()
    settings = load_claude_settings(settings_path)
    settings.setdefault("hooks", {})
    settings["hooks"].setdefault("PostToolUse", [])
    already_present = settings_context_rotation_heartbeat_hook_installed(settings)
    if not already_present:
        settings["hooks"]["PostToolUse"].append(
            {
                "matcher": "*",
                "hooks": [{"type": "command", "command": command}],
            }
        )
    write_claude_settings(settings_path, settings)
    return settings_path, already_present


def git_hooks_dir(target: Path) -> tuple[Path | None, str | None]:
    code, stdout, stderr = run_command(["git", "-C", str(target), "rev-parse", "--git-path", "hooks"], timeout=5)
    if code != 0 or not stdout:
        return None, stderr or stdout or "not a Git worktree"
    hooks_dir = Path(stdout)
    if not hooks_dir.is_absolute():
        hooks_dir = target / hooks_dir
    return hooks_dir, None


def git_branch_liveness_hook_content(backup_path: Path, hook_name: str) -> str:
    script_path = shlex.quote(str(Path(__file__).resolve()))
    backup = shlex.quote(str(backup_path))
    hook_event = shlex.quote(hook_name)
    review_evidence_call = ""
    stdin_capture_call = ""
    if hook_name == "pre-push":
        review_evidence_call = """
run_minervit_guard_check() {
  run_minervit_command 'runtime guard hook' guard-check --target . --boundary prepush
}

run_minervit_guard_check || exit $?
"""
        # T5: capture the pre-push ref-update records from stdin to a temp file ONCE and export
        # its path so the checks below can read it (the coordination-only push allowance), then
        # replay the exact original records on stdin to the wrapped backup hook at the bottom --
        # with the variable explicitly unset for that invocation so a wrapped older/custom hook
        # that itself calls this CLI cannot unexpectedly activate the new range logic.
        stdin_capture_call = """
MINERVIT_PREPUSH_RECORDS_FILE="$(mktemp "${TMPDIR:-/tmp}/minervit-prepush-records.XXXXXX")" || exit 1
cat > "$MINERVIT_PREPUSH_RECORDS_FILE"
export MINERVIT_PREPUSH_RECORDS_FILE
trap 'rm -f "$MINERVIT_PREPUSH_RECORDS_FILE"' EXIT
"""
    return f"""#!/bin/sh
# {GIT_BRANCH_LIVENESS_HOOK_MARKER}
set -u
{stdin_capture_call}
MINERVIT_WORK_PROFILE_SKIP_DEV_GATES=0

resolve_minervit_cli() {{
  if [ -f "$HOME/.config/tautline/tautline.env" ]; then
    # User secrets sourcing must not kill the hook under set -u.
    set +u
    . "$HOME/.config/tautline/tautline.env"
    set -u
  elif [ -f "$HOME/.config/minervit/methodology.env" ]; then
    set +u
    . "$HOME/.config/minervit/methodology.env"
    set -u
  fi
  if [ -x {script_path} ]; then
    printf '%s\\n' {script_path}
    return 0
  fi
  if [ "${{TAUTLINE_METHODOLOGY_REPO:-}}" != "" ] && [ -x "$TAUTLINE_METHODOLOGY_REPO/bin/tautline" ]; then
    printf '%s\\n' "$TAUTLINE_METHODOLOGY_REPO/bin/tautline"
    return 0
  fi
  if [ "${{MINERVIT_METHODOLOGY_REPO:-}}" != "" ] && [ -x "$MINERVIT_METHODOLOGY_REPO/bin/tautline" ]; then
    printf '%s\\n' "$MINERVIT_METHODOLOGY_REPO/bin/tautline"
    return 0
  fi
  if [ "${{MINERVIT_METHODOLOGY_REPO:-}}" != "" ] && [ -x "$MINERVIT_METHODOLOGY_REPO/bin/minervit-methodology" ]; then
    printf '%s\\n' "$MINERVIT_METHODOLOGY_REPO/bin/minervit-methodology"
    return 0
  fi
  if command -v tautline >/dev/null 2>&1; then
    command -v tautline
    return 0
  fi
  if command -v minervit-methodology >/dev/null 2>&1; then
    command -v minervit-methodology
    return 0
  fi
  return 1
}}

run_minervit_command() {{
  minervit_hook_label="$1"
  shift
  minervit_cli_path="$(resolve_minervit_cli)" || {{
    printf '%s\\n' "$minervit_hook_label: tautline CLI not found; refusing commit/push" >&2
    return 1
  }}
  "$minervit_cli_path" "$@"
}}

run_minervit_work_profile_check() {{
  run_minervit_command 'work profile hook' work-profile-check --target . --event {hook_event}
}}

run_minervit_graphify_freshness() {{
  run_minervit_command 'graphify freshness hook' graphify-status --target . --strict
}}

run_minervit_branch_liveness() {{
  run_minervit_command 'branch liveness hook' branch-liveness-check --target . --strict
}}

run_minervit_board_active() {{
  run_minervit_command 'board active-status hook' backlog-provider-active-check --target .
}}

work_profile_output="$(run_minervit_work_profile_check)"
work_profile_rc=$?
printf '%s\\n' "$work_profile_output"
if [ "$work_profile_rc" -ne 0 ]; then
  exit "$work_profile_rc"
fi
case "$work_profile_output" in
  *"work_profile_gate_decision: non_dev_docs_only"*|*"work_profile_gate_decision: non_dev_no_diff"*)
    MINERVIT_WORK_PROFILE_SKIP_DEV_GATES=1
    ;;
esac
run_minervit_branch_liveness || exit $?
if [ "$MINERVIT_WORK_PROFILE_SKIP_DEV_GATES" = "1" ]; then
  printf '%s\\n' 'work_profile_hook: skipping development-only Graphify, board, review-evidence, and CI gates for allowed non-dev docs/assets diff'
else
  run_minervit_graphify_freshness || exit $?
  run_minervit_board_active || exit $?
  {review_evidence_call}
fi
if [ -x {backup} ]; then
  if [ -n "${{MINERVIT_PREPUSH_RECORDS_FILE:-}}" ]; then
    minervit_prepush_records_replay="$MINERVIT_PREPUSH_RECORDS_FILE"
    (
      unset MINERVIT_PREPUSH_RECORDS_FILE
      {backup} "$@" < "$minervit_prepush_records_replay"
    )
    exit $?
  fi
  {backup} "$@"
  exit $?
fi
exit 0
"""


def write_git_branch_liveness_hooks(target: Path) -> tuple[list[str], list[str]]:
    if run_git(target, ["rev-parse", "--is-inside-work-tree"]) != "true":
        return [], ["skipped - not a Git worktree"]
    hooks_dir, error = git_hooks_dir(target)
    if hooks_dir is None:
        return [], [f"could not resolve Git hooks directory: {error}"]
    hooks_dir.mkdir(parents=True, exist_ok=True)
    installed: list[str] = []
    errors: list[str] = []
    for hook_name in GIT_BRANCH_LIVENESS_HOOKS:
        hook_path = hooks_dir / hook_name
        backup_path = hooks_dir / f"{hook_name}.before-minervit"
        try:
            existing = hook_path.read_text(encoding="utf-8", errors="replace") if hook_path.exists() else ""
            if existing and GIT_BRANCH_LIVENESS_HOOK_MARKER not in existing:
                backup_path = unique_destination(hooks_dir, f"{hook_name}.before-minervit")
                shutil.move(str(hook_path), str(backup_path))
                installed.append(f"wrapped existing {hook_name} at {backup_path}")
            hook_path.write_text(git_branch_liveness_hook_content(backup_path, hook_name), encoding="utf-8")
            hook_path.chmod(0o755)
            if not any(item.startswith(f"wrapped existing {hook_name}") for item in installed):
                installed.append(f"installed {hook_path}")
        except OSError as exc:
            errors.append(f"{hook_name}: {exc}")
    return installed, errors


def git_branch_liveness_hook_state(target: Path) -> tuple[bool, str]:
    if run_git(target, ["rev-parse", "--is-inside-work-tree"]) != "true":
        return True, "skipped - not a Git worktree"
    hooks_dir, error = git_hooks_dir(target)
    if hooks_dir is None:
        return False, f"missing required Git branch-liveness hooks - {error}"
    missing: list[str] = []
    for hook_name in GIT_BRANCH_LIVENESS_HOOKS:
        hook_path = hooks_dir / hook_name
        try:
            text = hook_path.read_text(encoding="utf-8", errors="replace")
        except OSError:
            missing.append(hook_name)
            continue
        if GIT_BRANCH_LIVENESS_HOOK_MARKER not in text:
            missing.append(hook_name)
        elif "work-profile-check --target . --event" not in text:
            missing.append(f"{hook_name} work-profile-check")
        elif "graphify-status --target . --strict" not in text:
            missing.append(f"{hook_name} graphify-status")
        elif "backlog-provider-active-check" not in text:
            missing.append(f"{hook_name} backlog-provider-active-check")
        elif hook_name == "pre-push" and "guard-check --target . --boundary prepush" not in text:
            missing.append(f"{hook_name} guard-check")
    if missing:
        return False, f"missing required Git branch-liveness hooks: {', '.join(missing)} - run `tautline install-hooks --target .` or `lane-start`"
    return True, f"installed {hooks_dir}"


def install_hooks(args: argparse.Namespace) -> int:
    autocompact_settings_path, autocompact_settings_ok = write_claude_autocompact_settings(args.settings)
    print(f"claude_autocompact_settings: {'already ok' if autocompact_settings_ok else 'installed'} {autocompact_settings_path}")
    settings_path, already_present = write_claude_plan_finalization_hook(args.settings, args.command)
    print(f"claude_hooks_settings: {settings_path}")
    print(f"claude_plan_finalization_hook: {'already present' if already_present else 'installed'}")
    print(f"claude_plan_finalization_hook_command: {args.command}")
    branch_settings_path, branch_already_present = write_claude_branch_liveness_hook(args.settings, args.branch_liveness_command)
    print(f"claude_branch_liveness_hook: {'already present' if branch_already_present else 'installed'}")
    print(f"claude_branch_liveness_hook_command: {args.branch_liveness_command}")
    response_settings_path, response_already_present = write_claude_response_guard_hook(args.settings, args.response_guard_command)
    print(f"claude_response_guard_hook: {'already present' if response_already_present else 'installed'}")
    print(f"claude_response_guard_hook_command: {args.response_guard_command}")
    tool_rejection_settings_path, tool_rejection_already_present = write_claude_tool_rejection_hook(args.settings, args.tool_rejection_command)
    print(f"claude_tool_rejection_hook: {'already present' if tool_rejection_already_present else 'installed'}")
    print(f"claude_tool_rejection_hook_command: {args.tool_rejection_command}")
    background_command_settings_path, background_command_already_present = write_claude_background_command_hook(
        args.settings, args.background_command_command
    )
    print(f"claude_background_command_hook: {'already present' if background_command_already_present else 'installed'}")
    print(f"claude_background_command_hook_command: {args.background_command_command}")
    latest_code_settings_path, latest_code_already_present = write_claude_latest_code_hook(
        args.settings, args.latest_code_command
    )
    print(f"claude_latest_code_hook: {'already present' if latest_code_already_present else 'installed'}")
    print(f"claude_latest_code_hook_command: {args.latest_code_command}")
    context_heartbeat_settings_path, context_heartbeat_already_present = write_claude_context_rotation_heartbeat_hook(
        args.settings, args.context_rotation_heartbeat_command
    )
    print(f"claude_context_rotation_heartbeat_hook: {'already present' if context_heartbeat_already_present else 'installed'}")
    print(f"claude_context_rotation_heartbeat_hook_command: {args.context_rotation_heartbeat_command}")
    if args.target is not None:
        installed, errors = write_git_branch_liveness_hooks(args.target.expanduser().resolve(strict=False))
        for item in installed:
            print(f"git_branch_liveness_hook: {item}")
        for error in errors:
            print(f"git_branch_liveness_hook_error: {error}", file=sys.stderr)
        if errors and not all(error.startswith("skipped -") for error in errors):
            return 1
    else:
        print("git_branch_liveness_hook: skipped - pass --target <lane_path> to install Git hooks")
    return 0


def lock_methodology(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    ensure_lane_state(data, target)
    path = lock_path(data, target)
    previous = path.read_text(encoding="utf-8") if path.exists() else None
    record = {
        "schema": "minervit-methodology-lock/v1",
        "createdAt": now_iso(),
        "commit": running_methodology_commit(),
        "shortCommit": running_methodology_commit(short=True),
        "pluginVersion": plugin_version(),
        "reason": args.reason,
    }
    tmp = path.with_name(f".{path.name}.tmp")
    tmp.write_text(json.dumps(record, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    if previous is not None:
        archived = lock_archive_path(data, target, "superseded-lock")
        archive_tmp = archived.with_name(f".{archived.name}.tmp")
        archive_tmp.write_text(previous, encoding="utf-8")
        archive_tmp.replace(archived)
        print(f"archived previous methodology lock at {archived}")
    tmp.replace(path)
    print(f"locked methodology at {record['shortCommit']}: {path}")
    return 0


def unlock_methodology(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    ensure_lane_state(data, target)
    path = lock_path(data, target)
    if not path.exists():
        print(f"methodology lock not present: {path}")
        return 0
    archived = archive_lock_file(data, target)
    if archived is None:
        print(f"methodology lock disappeared before it could be archived: {path}")
        return 1
    print(f"unlocked methodology; archived prior lock at {archived}")
    return 0


def remote_methodology_status(no_remote: bool) -> str:
    # "Is the checkout sync manages behind its remote?" -- a question about the CANONICAL repo.
    # Asked of a snapshot it would answer "not a Git checkout" forever, and the stale-only
    # auto-rescue gate keys on this string.
    canonical = canonical_methodology_repo()
    local = run_git(canonical, ["rev-parse", "HEAD"])
    upstream = run_git(canonical, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
    if local == "unavailable":
        return "unavailable: not a Git checkout"
    if upstream == "unavailable":
        return "unavailable: no upstream configured"
    cached = run_git(canonical, ["rev-parse", "@{u}"])
    if no_remote:
        return "matches cached upstream" if cached == local else f"differs from cached upstream {cached[:12]}"
    if "/" not in upstream:
        return f"unavailable: unsupported upstream {upstream}"
    remote, branch = upstream.split("/", 1)
    code, stdout, stderr = run_command(["git", "-C", str(canonical), "ls-remote", remote, f"refs/heads/{branch}"])
    if code != 0 or not stdout:
        return f"unavailable: {stderr or stdout or 'remote query failed'}"
    remote_sha = stdout.split()[0]
    return "up to date" if remote_sha == local else f"remote differs {remote_sha[:12]}"


def methodology_canonical_commit_line() -> str:
    """HEAD of the checkout sync manages -- which is NOT the code this process is running.

    `methodology_commit` answers "what am I running" (the snapshot), this answers "what will I run
    after the next launch swaps the snapshot in". They are the same sha on a dev checkout, and they
    differ for exactly as long as a lane keeps executing a snapshot that sync has already advanced
    past; without both lines that lag is invisible and reads as "sync did nothing".

    Because the lines are read by COMPARING them, both must abbreviate the same way: the same
    fixed 12-char prefix running_methodology_commit(short=True) uses, never git's `rev-parse
    --short` (whose length is repo-state-dependent -- 8 chars in this repo, 7 in a fresh clone).
    Mixing the two forms makes an in-sync lane report two different-looking shas and read as
    permanent snapshot lag. run_git's "unavailable" sentinel is 11 chars and survives the slice.
    """
    canonical_head = run_git(canonical_methodology_repo(), ["rev-parse", "HEAD"])
    return f"methodology_canonical_commit: {canonical_head[:12]}"


def methodology_exec_root_line() -> str:
    """Where the running code lives, and whether it is immutable.

    The short form comes from the reporter, never from a manifest key: snapshot_manifest() accepts
    any manifest carrying `schema` + `commit`, so reading a `shortCommit` field directly would raise
    on a manifest the reader legitimately accepted.
    """
    origin = (
        f"snapshot {running_methodology_commit(short=True)}"
        if running_from_snapshot()
        else "canonical checkout"
    )
    return f"methodology_exec_root: {REPO_ROOT} ({origin})"


def normalize_repo_path_arg(path_arg: str) -> str:
    path = path_arg.strip()
    if not path:
        raise SystemExit("--path values must be non-blank")
    path = path[2:] if path.startswith("./") else path
    if path.startswith("/") or path == ".." or path.startswith("../") or "/../" in path:
        raise SystemExit(f"--path must be relative to the target repo: {path_arg}")
    return path


def latest_code_config(data: dict) -> dict:
    return data["latestCode"]


def latest_code_status_path(data: dict, target: Path) -> Path:
    return configured_path(target, latest_code_config(data)["statusFile"])


def latest_code_fetch(target: Path, cfg: dict) -> list[str]:
    warnings: list[str] = []
    remote = cfg["remote"]
    base = cfg["base"]
    if cfg.get("fetchAll"):
        code, stdout, stderr = run_command(["git", "-C", str(target), "fetch", "--all", "--prune", "--quiet"], timeout=120)
        if code != 0:
            warnings.append(f"git fetch --all --prune failed: {stderr or stdout or 'unknown error'}")
    code, stdout, stderr = run_command(["git", "-C", str(target), "fetch", remote, base, "--quiet"], timeout=60)
    if code != 0:
        warnings.append(f"git fetch {remote} {base} failed: {stderr or stdout or 'unknown error'}")
    return warnings


def git_ref_short(target: Path, ref: str) -> str:
    return run_git(target, ["rev-parse", "--short", ref])


def git_ahead_behind(target: Path, left: str, right: str) -> tuple[str, str]:
    ahead_behind = run_git(target, ["rev-list", "--left-right", "--count", f"{left}...{right}"])
    if ahead_behind == "unavailable":
        return "unknown", "unknown"
    parts = ahead_behind.split()
    if len(parts) != 2:
        return "unknown", "unknown"
    return parts[0], parts[1]


def latest_code_remote_branches(target: Path, base_ref: str, cfg: dict) -> list[dict]:
    if not cfg.get("includeRemoteBranches"):
        return []
    max_branches = int(cfg.get("maxAheadBranches", DEFAULT_LATEST_CODE["maxAheadBranches"]))
    if max_branches == 0:
        return []
    refs = run_git(
        target,
        [
            "for-each-ref",
            "--format=%(refname:short) %(objectname:short) %(committerdate:iso-strict)",
            "refs/remotes",
        ],
    )
    if refs == "unavailable":
        return []
    base_remote = f"{cfg['remote']}/{cfg['base']}"
    branches: list[dict] = []
    for line in refs.splitlines():
        parts = line.split(maxsplit=2)
        if len(parts) < 2:
            continue
        ref = parts[0]
        if ref.endswith("/HEAD") or ref == base_remote:
            continue
        ahead, behind = git_ahead_behind(target, ref, base_ref)
        try:
            ahead_i = int(ahead)
        except ValueError:
            ahead_i = 0
        if ahead_i <= 0:
            continue
        branches.append(
            {
                "ref": ref,
                "commit": parts[1],
                "aheadOfBase": ahead,
                "behindBase": behind,
                "updatedAt": parts[2] if len(parts) > 2 else "",
            }
        )
    branches.sort(key=lambda item: (int(item["aheadOfBase"]) if str(item["aheadOfBase"]).isdigit() else 0, item.get("updatedAt", "")), reverse=True)
    return branches[:max_branches]


def latest_code_open_prs(data: dict, target: Path, cfg: dict) -> list[dict]:
    if not cfg.get("includeOpenPrs"):
        return []
    if shutil.which("gh") is None:
        return []
    repo = str(data.get("repo", "")).strip()
    command = [
        "gh",
        "pr",
        "list",
        "--state",
        "open",
        "--limit",
        "50",
        "--json",
        "number,title,url,headRefName,baseRefName,isDraft,mergeStateStatus,updatedAt",
    ]
    if re.fullmatch(r"[^/\s]+/[^/\s]+", repo):
        command.extend(["--repo", repo])
    code, payload, err = command_json(command, cwd=target, timeout=30)
    if code != 0 or not isinstance(payload, list):
        return []
    return [item for item in payload if isinstance(item, dict)]


def latest_code_baseline(data: dict, target: Path, *, write: bool) -> tuple[dict, list[str]]:
    cfg = latest_code_config(data)
    warnings: list[str] = []
    if not cfg["enabled"]:
        baseline = {
            "schema": "minervit-latest-code-baseline/v1",
            "enabled": False,
            "recordedAt": datetime.now(timezone.utc).isoformat(),
            "project": data["project"],
        }
        return baseline, warnings
    if run_git(target, ["rev-parse", "--is-inside-work-tree"]) != "true":
        raise SystemExit(f"latest_code_status: failed - target is not a Git worktree: {target}")
    warnings.extend(latest_code_fetch(target, cfg))
    base_ref = f"{cfg['remote']}/{cfg['base']}"
    base_commit = git_ref_short(target, base_ref)
    if base_commit == "unavailable":
        raise SystemExit(f"latest_code_status: failed - {base_ref} is unavailable after fetch")
    local_commit = git_ref_short(target, "HEAD")
    local_branch = run_git(target, ["branch", "--show-current"]) or "detached HEAD"
    local_ahead, local_behind = git_ahead_behind(target, "HEAD", base_ref)
    dirty = run_git(target, ["status", "--porcelain"])
    remote_branches = latest_code_remote_branches(target, base_ref, cfg)
    open_prs = latest_code_open_prs(data, target, cfg)
    baseline = {
        "schema": "minervit-latest-code-baseline/v1",
        "enabled": True,
        "recordedAt": datetime.now(timezone.utc).isoformat(),
        "project": data["project"],
        "repo": data.get("repo", ""),
        "remote": cfg["remote"],
        "base": cfg["base"],
        "baseRef": base_ref,
        "baseCommit": base_commit,
        "localBranch": local_branch,
        "localCommit": local_commit,
        "localAheadBase": local_ahead,
        "localBehindBase": local_behind,
        "localDirty": False if dirty == "" else True if dirty != "unavailable" else "unknown",
        "remoteBranchesAheadOfBase": remote_branches,
        "openPrs": open_prs,
        "warnings": warnings,
        "rule": cfg["rule"],
    }
    if write:
        path = latest_code_status_path(data, target)
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(json.dumps(baseline, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    return baseline, warnings


def latest_code_instruction_line() -> str:
    """The latest_code_failures debt-gate remediation hint methodology_status prints when no
    cached baseline exists yet. Its own pure function (rather than an inline literal) so the
    startup-remediation recovery-action coverage test can scan exactly this string instead of
    methodology_status's entire body."""
    return (
        "latest_code_instruction: run `tautline latest-code-status --target . --write` before "
        "current-status answers, deep analysis, planning, implementation, review, or subagent "
        "work; read-only diagnosis remains allowed."
    )


def latest_code_baseline_state(data: dict, target: Path) -> tuple[bool, str, dict | None]:
    cfg = latest_code_config(data)
    if not cfg["enabled"]:
        return True, "disabled by adapter", None
    path = latest_code_status_path(data, target)
    if not path.exists():
        return False, f"missing {path}", None
    try:
        baseline = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        return False, f"invalid {path}: {exc}", None
    recorded = str(baseline.get("recordedAt") or "")
    try:
        recorded_at = datetime.fromisoformat(recorded.replace("Z", "+00:00"))
    except ValueError:
        return False, f"invalid recordedAt in {path}: {recorded or 'missing'}", baseline
    age_seconds = max(0, int((datetime.now(timezone.utc) - recorded_at).total_seconds()))
    max_age_seconds = int(cfg["maxAgeMinutes"]) * 60
    if age_seconds > max_age_seconds:
        return False, f"stale {path}: age={age_seconds}s max={max_age_seconds}s", baseline
    base_ref = f"{cfg['remote']}/{cfg['base']}"
    if baseline.get("baseRef") != base_ref:
        return False, f"wrong base in {path}: {baseline.get('baseRef')} != {base_ref}", baseline
    return True, f"fresh {path}: age={age_seconds}s max={max_age_seconds}s", baseline


def print_latest_code_baseline(data: dict, target: Path, baseline: dict, state: str | None = None) -> None:
    print(f"latest_code: enabled={str(bool(baseline.get('enabled'))).lower()}")
    if state:
        print(f"latest_code_baseline_state: {state}")
    print(f"latest_code_recorded_at: {baseline.get('recordedAt', 'unknown')}")
    print(f"latest_code_base_ref: {baseline.get('baseRef', 'unknown')}")
    print(f"latest_code_base_commit: {baseline.get('baseCommit', 'unknown')}")
    print(f"latest_code_local_branch: {baseline.get('localBranch', 'unknown')}")
    print(f"latest_code_local_commit: {baseline.get('localCommit', 'unknown')}")
    print(
        "latest_code_local_vs_base: "
        f"ahead={baseline.get('localAheadBase', 'unknown')} behind={baseline.get('localBehindBase', 'unknown')}"
    )
    print(f"latest_code_local_dirty: {baseline.get('localDirty', 'unknown')}")
    ahead = baseline.get("remoteBranchesAheadOfBase") or []
    print(f"latest_code_remote_branches_ahead: {len(ahead)}")
    for branch in ahead[:5]:
        print(
            "latest_code_remote_branch_ahead: "
            f"{branch.get('ref')} ahead={branch.get('aheadOfBase')} behind={branch.get('behindBase')} commit={branch.get('commit')}"
        )
    if len(ahead) > 5:
        print(f"latest_code_remote_branch_ahead_more: {len(ahead) - 5}")
    open_prs = baseline.get("openPrs") or []
    print(f"latest_code_open_prs: {len(open_prs)}")
    for pr in open_prs[:5]:
        print(
            "latest_code_open_pr: "
            f"#{pr.get('number')} {pr.get('headRefName')} -> {pr.get('baseRefName')} "
            f"{pr.get('mergeStateStatus', 'unknown')} {pr.get('url', '')}"
        )
    if len(open_prs) > 5:
        print(f"latest_code_open_pr_more: {len(open_prs) - 5}")
    for warning in baseline.get("warnings") or []:
        print(f"latest_code_warning: {warning}")
    if ahead:
        print(
            "latest_code_instruction: remote branches are ahead of base; before deep analysis or state-changing work, decide whether the live/stakeholder surface is base or one of those branch refs."
        )
    else:
        print("latest_code_instruction: latest baseline is fetched; use base/GitHub/deploy evidence before trusting local lane files.")


def latest_code_degraded_lane_data(target: Path) -> dict | None:
    """Load the generated lane adapter directly for latest-code currency when the canonical
    source-adapter checks in lane_project fail (sourceAdapterSha256 drift, a rescue-ref hint, a
    missing/relocated source adapter, or a repo-identity mismatch). The latest-code baseline only
    records remote git state (base ref, ahead branches, open PRs) and never depends on the source
    adapter, so an adapter drift must NOT be able to wedge the lane: the latest-code guard blocks
    every tool until this baseline exists, so writing it has to remain possible even mid-drift.
    Returns the generated adapter data, or None when no usable generated adapter exists (the caller
    then re-raises the real lane_project error). RCA: latest-code guard wedge with no in-band escape."""
    generated_path = adapter_marker_path(target)
    if not generated_path.exists():
        return None
    try:
        data = load_project(generated_path)
    except SystemExit:
        return None
    # Require real generated provenance, not just any `_generated` object: a hand-written or
    # malformed adapter must NOT be silently accepted as a recovery fallback. Mirror lane_project's
    # type checks exactly -- a non-string sourceAdapter/sha (e.g. a 64-digit JSON number) must not
    # slip through str() coercion (Codex P2).
    generated_meta = data.get("_generated")
    if not isinstance(generated_meta, dict):
        return None
    source_adapter = generated_meta.get("sourceAdapter")
    if not isinstance(source_adapter, str) or not source_adapter.strip():
        return None
    # Degrade only for genuine source-adapter DRIFT (a present, well-formed sha that no longer
    # matches the source), never for a structurally-broken adapter: lane_project rejects a
    # missing/malformed sourceAdapterSha256 outright, and so must this fallback (Codex P2).
    sha = generated_meta.get("sourceAdapterSha256")
    if not isinstance(sha, str) or not re.fullmatch(r"[0-9a-f]{64}", sha):
        return None
    # Stay fail-closed on repo identity: degrade only for source-adapter drift, never for a copied
    # adapter pointed at the wrong target. A blank/unnormalizable `repo` cannot prove the adapter
    # belongs to this target (validate_adapter_repo_matches_target would short-circuit as success),
    # so refuse to degrade unless the repo is normalizable AND matches the target remote -- otherwise
    # the baseline would report another repo's PR surface and unblock the guard on a lie (Codex P1).
    if not normalize_repo_slug(str(data.get("repo") or "")):
        return None
    try:
        validate_adapter_repo_matches_target(data, target, require_target_identity=True)
    except SystemExit:
        return None
    return data


def latest_code_status(args: argparse.Namespace) -> int:
    target = args.target.resolve()
    drift_warning = ""
    try:
        data, _project_path, target = lane_project(args)
    except SystemExit as exc:
        # Degraded recovery applies ONLY to the default generated-lane path. An explicit
        # --project failure (wrong/ad-hoc adapter) must surface, not be masked by the lane's own
        # generated adapter writing a baseline and exiting 0 (Codex P2).
        if getattr(args, "project", None):
            raise
        degraded = latest_code_degraded_lane_data(target)
        if degraded is None:
            raise
        data = degraded
        drift_warning = (
            f"{exc} -- baseline written from the generated lane adapter so the latest-code guard can "
            "release; this does not fix the drift. Re-render in-band: "
            "tautline render-adapters --project <methodology_repo>/adapters/projects/"
            "<project>.json --target . --write --json-only, then re-run latest-code-status."
        )
    return latest_code_status_report(args, data, target, drift_warning)


def latest_code_status_report(args: argparse.Namespace, data: dict, target: Path, drift_warning: str = "") -> int:
    if drift_warning:
        print(f"latest_code_adapter_drift: {drift_warning}")
    if not latest_code_config(data)["enabled"]:
        baseline, _warnings = latest_code_baseline(data, target, write=False)
        print_latest_code_baseline(data, target, baseline, state="disabled by adapter")
        # Drift is a failure signal under --strict even when latest-code itself is disabled (Codex P3).
        return 1 if (args.strict and drift_warning) else 0
    baseline, warnings = latest_code_baseline(data, target, write=args.write)
    state = f"wrote {latest_code_status_path(data, target)}" if args.write else "not-written"
    print_latest_code_baseline(data, target, baseline, state=state)
    if args.strict and (warnings or drift_warning):
        return 1
    return 0


def remote_main_status(args: argparse.Namespace) -> int:
    target = args.target.expanduser().resolve()
    remote = args.remote
    base = args.base
    remote_ref = f"{remote}/{base}"
    if run_git(target, ["rev-parse", "--is-inside-work-tree"]) != "true":
        print(f"remote_main_status: failed - target is not a Git worktree: {target}", file=sys.stderr)
        return 1
    code, stdout, stderr = run_command(["git", "-C", str(target), "fetch", remote, base, "--quiet"])
    if code != 0:
        print(f"remote_main_status: failed - git fetch {remote} {base} failed: {stderr or stdout or 'unknown error'}", file=sys.stderr)
        return 1
    remote_commit = run_git(target, ["rev-parse", "--short", remote_ref])
    if remote_commit == "unavailable":
        print(f"remote_main_status: failed - {remote_ref} is unavailable after fetch", file=sys.stderr)
        return 1
    local_commit = run_git(target, ["rev-parse", "--short", "HEAD"])
    local_branch = run_git(target, ["branch", "--show-current"]) or "detached HEAD"
    dirty = run_git(target, ["status", "--porcelain"])
    ahead_behind = run_git(target, ["rev-list", "--left-right", "--count", f"HEAD...{remote_ref}"])
    ahead = "unknown"
    behind = "unknown"
    if ahead_behind != "unavailable":
        parts = ahead_behind.split()
        if len(parts) == 2:
            ahead, behind = parts
    print("remote_main_status: fetched")
    print(f"status_source: {remote_ref}")
    print(f"remote_main_ref: {remote_ref}")
    print(f"remote_main_commit: {remote_commit}")
    print(f"local_branch: {local_branch}")
    print(f"local_commit: {local_commit}")
    print(f"local_vs_remote_main: ahead={ahead} behind={behind}")
    print(f"local_dirty: {'unknown' if dirty == 'unavailable' else ('yes' if dirty else 'no')}")
    for path_arg in args.path or []:
        rel = normalize_repo_path_arg(path_arg)
        code, _stdout, _stderr = run_command(["git", "-C", str(target), "cat-file", "-e", f"{remote_ref}:{rel}"])
        print(f"remote_main_path: {'present' if code == 0 else 'missing'} {rel}")
    print(
        "remote_main_instruction: answer cross-lane/current project status from "
        f"{remote_ref} or GitHub evidence first; do not trust a stale local lane when behind>0."
    )
    return 0


# Failure classification for `methodology-status --fail-on-drift` (0.8.9 startup remediation,
# see docs/reference/startup-remediation.md). INTEGRITY gates mean the agent cannot safely start
# or cannot prove anything -- the adapter pipeline itself is broken (drift) or the runtime cannot
# run proof gates at all (development_environment). Those always exit 1: "launcher still
# refuses". Every other gate list is agent-fixable DEBT: under bare --fail-on-drift (no
# --strict) a debt-only failure exits 2 (remediation mode) instead of 1. Declared as tuples, not
# UPPER_CASE lists of str, so they are excluded from the policy-phrases SSOT auto-collector.
METHODOLOGY_STATUS_INTEGRITY_GATES: tuple[str, ...] = (
    "drift",
    "development_environment_failures",
)
METHODOLOGY_STATUS_DEBT_GATES: tuple[str, ...] = (
    "planning_failures",
    "hook_failures",
    "goal_failures",
    "goal_tracker_failures",
    "milestone_failures",
    "lane_coordination_failures",
    "iteration_review_failures",
    "milestone_update_failures",
    "product_chat_failures",
    "deployment_notification_failures",
    "ui_evidence_failures",
    "stakeholder_question_failures",
    "latest_code_failures",
    "graphify_failures",
    "behavior_spec_failures",
    "ci_test_gate_failures",
    "runtime_secret_failures",
    "critical_journey_failures",
    "migration_safety_failures",
    "health_contract_failures",
    "side_effect_proof_failures",
    "remediation_failures",
    "autocompact_failures",
    "context_failures",
)
# Synthetic integrity gates are display/summary identifiers with no backing `..._failures` list
# -- they are never part of the will_fail list partition above. remediation_marker is reserved
# here for T3 (the remediation-marker persistence guarantee that ships later in 0.8.9); T1 only
# builds the name-map/summary-line machinery so that gate can be added without another surface
# change.
METHODOLOGY_STATUS_SYNTHETIC_INTEGRITY_GATES: tuple[str, ...] = (
    "remediation_marker",
)


def _methodology_status_gate_display_name(gate_list_name: str) -> str:
    """The canonical, cross-surface gate identifier: the list-variable name with any `_failures`
    suffix stripped. This mapped name -- never the list-variable name -- is what may appear on
    any surface (summary line, event refs.gates, and later the remediation marker/recovery map).
    """
    suffix = "_failures"
    return gate_list_name[: -len(suffix)] if gate_list_name.endswith(suffix) else gate_list_name


METHODOLOGY_STATUS_GATE_DISPLAY_NAMES: dict[str, str] = {
    name: _methodology_status_gate_display_name(name)
    for name in (
        *METHODOLOGY_STATUS_INTEGRITY_GATES,
        *METHODOLOGY_STATUS_DEBT_GATES,
        *METHODOLOGY_STATUS_SYNTHETIC_INTEGRITY_GATES,
    )
}


def methodology_status_gate_conditions(
    args: argparse.Namespace,
    *,
    drift: list[str],
    planning_failures: list[str],
    hook_failures: list[str],
    goal_failures: list[str],
    goal_tracker_failures: list[str],
    milestone_failures: list[str],
    lane_coordination_failures: list[str],
    iteration_review_failures: list[str],
    milestone_update_failures: list[str],
    product_chat_failures: list[str],
    deployment_notification_failures: list[str],
    ui_evidence_failures: list[str],
    stakeholder_question_failures: list[str],
    latest_code_failures: list[str],
    graphify_failures: list[str],
    behavior_spec_failures: list[str],
    ci_test_gate_failures: list[str],
    runtime_secret_failures: list[str],
    critical_journey_failures: list[str],
    migration_safety_failures: list[str],
    health_contract_failures: list[str],
    side_effect_proof_failures: list[str],
    remediation_failures: list[str],
    autocompact_failures: list[str],
    development_environment_failures: list[str],
    context_failures: list[str],
) -> dict[str, bool]:
    """Whether each gate list currently blocks `methodology-status`, one boolean per list name.

    These conditions are characterization pins: they reproduce, gate by gate, the EXACT
    strict-vs-fail-on-drift return conditions the pre-0.8.9 per-list `return 1` block used
    (frozen by the baseline regression tests in
    tests/test_methodology_status_classification.py). The conditions are NOT uniform -- some
    gates block under --fail-on-drift alone regardless of --strict, some block whenever
    (--strict or --fail-on-drift), and a few historically required BOTH flags together (their
    issue list only populates under --strict, and their old return line additionally required
    --fail-on-drift). Do not "simplify" these to a single shared formula; the differences are
    load-bearing and each is pinned by its own baseline-captured test.
    """
    strict_or_fail = args.strict or args.fail_on_drift
    return {
        "drift": bool(drift) and args.fail_on_drift,
        "development_environment_failures": bool(development_environment_failures) and strict_or_fail,
        "planning_failures": bool(planning_failures) and args.fail_on_drift,
        "hook_failures": bool(hook_failures) and args.fail_on_drift,
        "goal_failures": bool(goal_failures) and args.fail_on_drift,
        "goal_tracker_failures": bool(goal_tracker_failures) and strict_or_fail,
        "milestone_failures": bool(milestone_failures) and args.fail_on_drift,
        "lane_coordination_failures": bool(lane_coordination_failures) and strict_or_fail,
        "iteration_review_failures": bool(iteration_review_failures) and args.fail_on_drift,
        "milestone_update_failures": bool(milestone_update_failures) and args.fail_on_drift,
        "product_chat_failures": bool(product_chat_failures) and args.fail_on_drift,
        "deployment_notification_failures": bool(deployment_notification_failures) and args.fail_on_drift,
        "ui_evidence_failures": bool(ui_evidence_failures) and args.fail_on_drift,
        "stakeholder_question_failures": bool(stakeholder_question_failures) and strict_or_fail,
        "latest_code_failures": bool(latest_code_failures) and args.fail_on_drift,
        "graphify_failures": bool(graphify_failures) and args.fail_on_drift,
        "behavior_spec_failures": bool(behavior_spec_failures) and strict_or_fail,
        "ci_test_gate_failures": bool(ci_test_gate_failures) and strict_or_fail,
        "runtime_secret_failures": bool(runtime_secret_failures) and args.fail_on_drift,
        "critical_journey_failures": bool(critical_journey_failures) and args.fail_on_drift,
        "migration_safety_failures": bool(migration_safety_failures) and args.fail_on_drift,
        "health_contract_failures": bool(health_contract_failures) and args.fail_on_drift,
        "side_effect_proof_failures": bool(side_effect_proof_failures) and args.fail_on_drift,
        "remediation_failures": bool(remediation_failures) and args.fail_on_drift,
        "autocompact_failures": bool(autocompact_failures) and args.fail_on_drift,
        "context_failures": bool(context_failures) and strict_or_fail,
    }


STARTUP_REMEDIATION_MARKER_SCHEMA = "tautline-startup-remediation/v1"
STARTUP_REMEDIATION_MARKER_RELATIVE_PATH = Path(".ai-work") / "STARTUP_REMEDIATION.json"

# 0.8.9 startup remediation (docs/reference/startup-remediation.md): the central dispatch guard's
# ALLOWED/BLOCKED partition of the real argparse command registry (see
# registered_subcommand_names()). Every registered subcommand must be classified into EXACTLY one
# of these two tuples -- tuples, not UPPER_CASE lists of str, so they stay out of the
# policy-phrases SSOT collector -- enforced by the registry partition test. Default posture for
# anything project-mutating is BLOCKED; ALLOWED is what diagnosing/clearing gate debt,
# committing/pushing the fixes, or declaring a blocker requires. Two mandatory membership rules
# (enforced by dedicated tests, not just this comment): (1) every command invoked by the
# generated git hooks and Claude hooks is ALLOWED; (2) every command referenced in a debt gate's
# printed issue/recovery text is reachable -- globally ALLOWED here, or listed in that gate's
# STARTUP_REMEDIATION_GATE_RECOVERY_COMMANDS entry (project-work mutators like goal/milestone
# repair commands take the recovery-map route, never global promotion).
STARTUP_REMEDIATION_ALLOWED_COMMANDS: tuple[str, ...] = (
    # -- explicitly named by design: the remediation rerun loop and its exits --
    "methodology-status",
    "lane-start",
    "sync-methodology",
    # snapshot-pin is invoked by the generated launcher on every startup (it is what stops
    # retention from deleting the snapshot this session is executing); snapshot-status is a
    # read-only diagnostic. snapshot-prune deletes trees, has no startup role, and is BLOCKED.
    "snapshot-pin",
    "snapshot-status",
    "install-claude-launcher",
    "log-event",
    "blocker-declare",
    "blocker-clear",
    "blocker-status",
    # -- the review-evidence pipeline (commit/push the fixes with evidence) --
    "record-stage1-sweep",
    "codex-run",
    "finalize-implementation-review",
    "review-evidence-check",
    "claude-review",
    "claude-review-status",
    # -- graphify install/status --
    "graphify-install",
    "graphify-status",
    # -- session journal validate/prepare (local-only; publish is a separate, blocked surface) --
    "prepare-session-journal",
    "validate-session-journal",
    # -- instrumentation prepare/validate (0.9.0): local-only preview + read-only validator;
    # publish-instrumentation-record is a separate, blocked surface (added in T5) --
    "prepare-instrumentation-record",
    "validate-instrumentation-record",
    # -- canonical-policy/dump-* (flag-aware: --write is blocked; see
    # STARTUP_REMEDIATION_WRITE_FLAG_GATED_COMMANDS) and validate-style read-only commands --
    "canonical-policy",
    "dump-policy-phrases",
    "dump-instrumentation-schema",
    "validate-adapter",
    "validate-feature-request-artifact",
    "validate-grooming",
    "validate-iteration-review",
    "validate-rca-artifact",
    # -- help/version --
    "version",
    # -- hook-invoked (mandatory rule 1): every command the generated git hooks or Claude hooks
    # call, enumerated from the hook templates (git_branch_liveness_hook_content, guard_check,
    # and the install_claude_*_hook writers) --
    "work-profile-check",
    "branch-liveness-check",
    "guard-check",
    "backlog-provider-active-check",
    "plan-finalization-hook",
    "branch-liveness-hook",
    "response-guard-hook",
    "tool-rejection-hook",
    "background-command-hook",
    "latest-code-hook",
    "context-rotation-heartbeat-hook",
    # -- debt-gate remediation text (mandatory rule 2): commands methodology-status's own printed
    # debt-gate output names, that are not themselves goal/milestone/board mutators --
    "install-hooks",  # hook_failures: "missing required hook - run `tautline install-hooks`"
    "latest-code-status",  # latest_code_failures: "run `tautline latest-code-status --write`"
    "publish-product-note",  # product_chat: "post quick human-requested notes with `tautline publish-product-note`"
    "ui-evidence-submit",  # the fix action for ui_evidence_failures debt (verification evidence, not goal/milestone content)
    "publish-deploy-ready-update",  # deployment_notification's own printed rule names this as the required caller
    # -- coordination process metadata (not project source/goal content); repairs
    # lane_coordination_failures the way T2 intends ("your own note is fresh enough") --
    "lane-coordination-note",
    "lane-coordination-status",
    # -- read-only diagnostics: inspection/reporting only, no project-state mutation, needed to
    # diagnose which gate is failing and why --
    "audit",
    "behavior-spec-status",
    "backlog-provider-status",
    "backlog-provider-board-check",
    "goal-status",
    "goal-kickoff-prompt",
    "goal-tracker-status",
    "milestone-status",
    "milestone-watchdog",
    "iteration-review-status",
    "iteration-review-delivery-check",
    "deployment-notification-status",
    "deployment-notification-pipeline-snippet",
    "stakeholder-question-status",
    "milestone-update-status",
    "release-update-status",
    "github-budget-status",
    "remote-main-status",
    "context-status",
    "context-rotation-check",
    "diff-coverage-check",
    "flaky-quarantine-check",
    "ci-health-check",
    "red-green-check",
    "deploy-health",
    "guard-report",
    "readiness-review",
    "response-guard",
    "telemetry",
    "pilot-report",
    "check-name-availability",
    # read-only: reports the repo/npm/PyPI versions and the drift verdict, mutating
    # nothing. Its mutating counterpart, release-tail, is BLOCKED.
    "release-drift-check",
    "event-log-path",
    "event-tail",
    "event-audit",
    "event-viewer",
    "event-rotate",
    "usage-report",
    "usage-log-path",
    "usage-import-claude",
    "usage-record",
    "usage-rotate",
    "monitor-status",
    # -- execution utilities the Claude background-command-hook's own block message points agents
    # at ("Use the adapter command or `tautline background-run` ... `monitor-status` active
    # polling"); blocking these would make the hook's own guidance unfollowable mid-remediation --
    "background-run",
)
STARTUP_REMEDIATION_BLOCKED_COMMANDS: tuple[str, ...] = (
    # -- goal/milestone project-work mutators: the core of what remediation gates. Some are
    # reachable only via STARTUP_REMEDIATION_GATE_RECOVERY_COMMANDS while the marker's recorded
    # gates cover them; goal-advance/milestone-advance have no recovery-map entry at all --
    "goal-start",
    "goal-next",
    "goal-condition",
    "goal-advance",
    "milestone-start",
    "milestone-next",
    "milestone-advance",
    "work-loop",
    "lane-run",
    "backlog-provider-next",
    "backlog-provider-sync",
    "backlog-provider-update",
    "backlog-board-examine",
    "backlog-board-adopt",
    "goal-tracker-next",
    "goal-tracker-sync",
    "goal-tracker-update",
    "backlog-provider-export",
    "backlog-provider-migration-interview",
    "stakeholder-question-ask",
    # -- publish-*: external announcements of project-work progress; a remediation session should
    # not be declaring milestones/features done while debt is outstanding (publish-deploy-ready-
    # update and publish-product-note are ALLOWED above -- required by their own debt gates'
    # printed remediation text, and process/status metadata rather than project-work content) --
    "publish-feature-request-artifact",
    "publish-instrumentation-record",
    "publish-iteration-review",
    "publish-milestone-update",
    "publish-pending-session-journals",
    "publish-rca-artifact",
    "publish-release-update",
    "publish-session-journal",
    # -- project/adapter/repo setup and mutation, not remediation --
    "adapter-bootstrap-questions",
    "context-bootstrap",
    "cut-release",
    "emit-iteration-review-workflow",
    "finalize-plan-review",
    "generate-iteration-review-page",
    "init",
    "init-project-adapter",
    "install-cli",
    "uninstall-cli",
    "iteration-review-renderer-setup",
    "lane-coordination-bootstrap",
    "lock-methodology",
    "unlock-methodology",
    # maintainer-mode mutates the machine-wide update-gate state (arming stands every
    # methodology update gate down; disarming restores them) -- the same machine-mutator
    # posture as set-framework-channel / update-repin / install-cli. Diagnosis during
    # remediation stays available through the ALLOWED methodology-status, whose report
    # block prints the three-state maintainer_mode line.
    "maintainer-mode",
    "plan-finalization-precheck",
    "prepare-continuity",
    "purge-archive",
    "record-plan-review",
    "release-migration-report",
    "render-adapters",
    "repair-methodology-main",
    "run-plan-review",
    "scaffold-test-harness",
    "set-framework-channel",
    # deletes snapshot trees from the shared store; nothing a remediation session needs, and the
    # read-only half (snapshot-status) stays ALLOWED
    "snapshot-prune",
    "update-repin",
    "codex-plan-review",
    "migrate-adapter",
    "public-contract",
    "public-release-check",
    "public-release-export",
    # -- the release tail: publishes the mirror and two registries. A registry publish
    # is irreversible, so shipping a release is the last thing a repo in startup
    # remediation should be able to do. release-drift-check is the read-only half and
    # stays ALLOWED, like release-update-status --
    "registry-package",
    "release-tail",
)
# CANONICAL gate display name (the same identifiers METHODOLOGY_STATUS_GATE_DISPLAY_NAMES emits,
# and the marker's own `gates` array records -- a cross-surface test asserts byte-equality) ->
# the otherwise-BLOCKED project-work mutators that gate's remediation requires. A blocked command
# is permitted while the marker's recorded `gates` list contains a key mapping to it. Only gates
# whose remediation genuinely requires an otherwise-blocked mutator get an entry; goal-advance and
# milestone-advance are deliberately absent everywhere (graduation, not remediation).
STARTUP_REMEDIATION_GATE_RECOVERY_COMMANDS: dict[str, tuple[str, ...]] = {
    "goal": ("goal-start", "goal-next", "goal-condition", "backlog-provider-next", "backlog-provider-sync"),
    "goal_tracker": ("backlog-board-examine", "backlog-board-adopt", "backlog-provider-update", "goal-tracker-update"),
    "milestone": ("milestone-start", "milestone-next"),
    # context-bootstrap --write is the sanctioned repair for context debt (missing index/archive
    # dirs/classification/headers); without this entry a context-debt remediation session cannot
    # clear its own gate through the CLI (Codex T7 R2 P2).
    "context": ("context-bootstrap",),
}
# canonical-policy/dump-* are flag-aware (V14R1-P2-2): read/check modes stay ALLOWED, --write is
# treated as BLOCKED (recovery-map rules still apply if a future gate ever needs one).
STARTUP_REMEDIATION_WRITE_FLAG_GATED_COMMANDS: tuple[str, ...] = ("canonical-policy", "dump-policy-phrases", "dump-instrumentation-schema")


def startup_remediation_marker_path(target: Path) -> Path:
    return target / STARTUP_REMEDIATION_MARKER_RELATIVE_PATH


def _startup_remediation_marker_data(marker_path: Path) -> dict:
    """Best-effort parse; a missing/corrupted marker (truncated JSON, non-UTF-8 bytes, non-dict)
    reads as `{}` so callers fail closed (no recovery-map gates recognized) instead of crashing
    (UnicodeDecodeError coverage: Codex T7 R2 P2)."""
    try:
        data = json.loads(marker_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError, UnicodeDecodeError):
        return {}
    return data if isinstance(data, dict) else {}


def _startup_remediation_marker_gates(marker_path: Path) -> list[str]:
    gates = _startup_remediation_marker_data(marker_path).get("gates")
    return [gate for gate in gates if isinstance(gate, str)] if isinstance(gates, list) else []


def clear_startup_remediation_marker(target: Path) -> bool:
    """Delete the marker if present. Returns False ONLY when a marker exists and deletion fails
    (permissions/ownership) -- the caller treats that as unclearable (synthetic integrity gate),
    never as silent success, so the guard can never keep refusing while status claims clean.
    missing_ok (single unlink), not exists()-then-unlink(): a concurrent clean run's delete
    landing between the two calls must count as cleared, not raise FileNotFoundError into a
    spurious "unclearable" integrity exit 1 for an already-clean lane."""
    try:
        startup_remediation_marker_path(target).unlink(missing_ok=True)
    except OSError:
        return False
    return True


def write_startup_remediation_marker(target: Path, *, gates: list[str], issues: list[str], create: bool) -> bool:
    """Atomically create (`create=True`, the `--enter-remediation-on-debt` path) or refresh
    (`create=False`, any plain debt-only `--fail-on-drift` run) the marker in place. A plain
    refresh is a no-op success when no marker exists (callers must never create one on the plain
    path). Returns False on any write failure, including an absent/unwritable `.ai-work` parent
    that cannot be created (write_text_atomic's own mkdir(parents=True) surfaces that as OSError).
    """
    marker_path = startup_remediation_marker_path(target)
    if not create and not marker_path.exists():
        return True
    existing = _startup_remediation_marker_data(marker_path)
    entered_at = existing.get("entered_at") if isinstance(existing.get("entered_at"), str) else utc_event_timestamp()
    payload = {
        "schema": STARTUP_REMEDIATION_MARKER_SCHEMA,
        "entered_at": entered_at,
        "gates": gates,
        "issues": issues,
        "status_exit": 2,
    }
    try:
        write_text_atomic(marker_path, json.dumps(payload, indent=2, sort_keys=True) + "\n")
    except OSError:
        return False
    return True


def startup_remediation_guard_refusal(cmd_name: str, args: argparse.Namespace) -> str | None:
    """The central dispatch-level remediation guard (0.8.9 startup remediation). Returns the
    refusal message when `cmd_name` must be blocked while the resolved target's remediation
    marker is present, else None. Enforcement scope is stated honestly in
    docs/reference/startup-remediation.md: this gates `tautline` CLI entry points (the
    framework's project-work choke points), not an agent's direct file edits or arbitrary shell.
    """
    if cmd_name in STARTUP_REMEDIATION_ALLOWED_COMMANDS and cmd_name not in STARTUP_REMEDIATION_WRITE_FLAG_GATED_COMMANDS:
        return None
    target_arg = getattr(args, "target", None)
    target = (target_arg if target_arg is not None else Path(".")).resolve()
    marker_path = startup_remediation_marker_path(target)
    if not marker_path.exists():
        return None
    if cmd_name in STARTUP_REMEDIATION_WRITE_FLAG_GATED_COMMANDS and not getattr(args, "write", False):
        return None
    marker_gates = _startup_remediation_marker_gates(marker_path)
    recovery_commands = {
        command
        for gate in marker_gates
        for command in STARTUP_REMEDIATION_GATE_RECOVERY_COMMANDS.get(gate, ())
    }
    if cmd_name in recovery_commands:
        return None
    return (
        f"startup remediation active: fix the issues in {marker_path} (rerun 'tautline "
        f"methodology-status --target {target} --fail-on-drift' until it exits 0) before project work"
    )


def methodology_status(args: argparse.Namespace) -> int:
    data, project_path, target = lane_project(args)
    ensure_lane_state(data, target)
    locked = read_lock(data, target)
    drift = adapter_drift(data, project_path, target)
    protected_markdown = protected_adapter_markdown(data, project_path, target)
    print(f"project: {data['project']}")
    print(f"target: {target}")
    print(f"methodology_repo: {REPO_ROOT}")
    print(f"plugin: {plugin_name()}")
    print(f"plugin_version: {plugin_version()}")
    print(f"methodology_commit: {running_methodology_commit(short=True)}")
    print(methodology_canonical_commit_line())
    print(methodology_exec_root_line())
    # Next to the exec-root line: both answer "what is this machine actually running, and why".
    # methodology-status is startup-remediation-ALLOWED, so an armed (or misconfigured) machine
    # stays diagnosable even while the mutating maintainer-mode verb is blocked.
    print(maintainer_mode_status_line())
    print(f"remote_status: {remote_methodology_status(args.no_remote)}")
    print(work_profile_status_line(data, target))
    _profile, _profile_cfg, _profile_source, profile_issues = effective_work_profile(data, target)
    for issue in profile_issues:
        print(f"work_profile_issue: {issue}")
    development_environment_issues = print_development_environment_status(data)
    development_environment_failures = development_environment_issues if (args.strict or args.fail_on_drift) else []
    framework_pin, framework_pin_source = effective_framework_pin(data, target)
    framework_decision = framework_update_decision(framework_pin, data, target, skip_update=False)
    print(framework_pin_status_line(framework_pin, framework_pin_source))
    print(
        "framework_update_available: "
        f"version={framework_decision.get('availableVersion', plugin_version())} "
        f"change={framework_decision.get('changeKind', 'unknown')}"
    )
    print(f"framework_update_policy_decision: {framework_decision['action']} - {framework_decision['reason']}")
    if framework_decision["action"] != "update":
        hygiene_warning = methodology_checkout_hygiene_warning()
        if hygiene_warning:
            print(f"framework_checkout_warning: {hygiene_warning}")
    pending_report = load_release_migration_report(framework_decision.get("availableVersion", plugin_version()))
    if pending_report:
        required_migrations = pending_report.get("requiredMigrations") or []
        optional_migrations = pending_report.get("optionalMigrations") or []
        print(
            "framework_pending_migrations: "
            f"required={len(required_migrations)} optional={len(optional_migrations)} "
            f"report={release_migration_report_path(framework_decision.get('availableVersion', plugin_version()))}"
        )
    else:
        print("framework_pending_migrations: report unavailable")
    for reason in framework_decision.get("wipReasons", []):
        print(f"framework_wip: {reason}")
    if locked and locked.get("invalid"):
        print(f"lock: invalid - {locked.get('path')}")
    elif locked:
        print(f"lock: locked {locked.get('shortCommit') or locked.get('commit', 'unknown')} - {locked.get('reason', 'no reason')}")
    else:
        print("lock: unlocked")
    print(f"adapter_drift: {'clean' if not drift else ', '.join(drift)}")
    print(f"adapter_markdown_protected: {'none' if not protected_markdown else ', '.join(protected_markdown)}")
    plugin_drift_from = lane_session_plugin_drift(data, target)
    print(
        "plugin_version_drift: clean"
        if not plugin_drift_from
        else f"plugin_version_drift: {plugin_drift_from} -> {plugin_version()} since lane start; re-render the adapter and confirm the review-evidence contract"
    )
    if not print_methodology_rescue_ref_warnings():
        print("methodology_rescue_ref_adapter_drift: clean")
    print(f"continuity: {target / data['continuity']['path']}")
    print(f"goal_run: {goal_run_path(data, target)}")
    print(f"execution_packet: {target / data['laneState']['executionPacket']}")
    print(f"milestone_run: {milestone_run_path(data, target)}")
    env_path = lane_env_path(data, target)
    env_values = lane_env_values(data, target)
    port_summary = ", ".join(
        f"{key}={value}" for key, value in env_values.items() if key.endswith("_PORT")
    )
    print(f"lane_env: {'present' if env_path.exists() else 'missing'} {env_path}")
    print(f"lane_env_compose_project: {env_values['COMPOSE_PROJECT_NAME']}")
    if port_summary:
        print(f"lane_env_ports: {port_summary}")
    template_path = configured_path(target, data["planningArtifacts"]["template"])
    source_path = configured_path(target, data["planningArtifacts"]["sourceOfTruth"])
    planning_issues = planning_path_issues(data, target)
    scratch_candidates = scratch_plan_candidates(data, target)
    relevant_scratch = relevant_scratch_plan_candidates(data, scratch_candidates)
    print(f"planning_source_of_truth: {'present' if source_path.exists() else 'missing'} {source_path}")
    print(f"planning_template: {'present' if template_path.exists() else 'missing'} {template_path}")
    goal_source_path = configured_path(target, data["goalArtifacts"]["sourceOfTruth"])
    goal_template_path = configured_path(target, data["goalArtifacts"]["template"])
    print(f"goal_source_of_truth: {'present' if goal_source_path.exists() else 'missing'} {goal_source_path}")
    print(f"goal_template: {'present' if goal_template_path.exists() else 'missing'} {goal_template_path}")
    print(backlog_provider_status_summary(data))
    print(goal_tracker_status_summary(data))
    print(stakeholder_questions_status_summary(data))
    latest_code_ok, latest_code_state, latest_code_cached = latest_code_baseline_state(data, target)
    if latest_code_cached is not None:
        print_latest_code_baseline(data, target, latest_code_cached, state=latest_code_state)
    else:
        print(f"latest_code: enabled={str(data['latestCode']['enabled']).lower()}")
        print(f"latest_code_baseline_state: {latest_code_state}")
        print(latest_code_instruction_line())
    print(f"planning_adapter_paths: {'clean' if not planning_issues else '; '.join(planning_issues)}")
    print(scratch_status_line("planning_scratch_candidates", scratch_candidates))
    print(scratch_status_line("planning_relevant_scratch_plans", relevant_scratch))
    lane_coordination_issues = print_lane_coordination_status(data, target)
    document_context_issues = print_document_context_status(data, target)
    graphify_issues = print_graphify_status(data, target)
    behavior_spec_issues = print_behavior_spec_status(data, target)
    pending_journals = pending_session_journals(data, target)
    session_journal = data["sessionJournal"]
    milestone_continuation = data["milestoneContinuation"]
    print(f"session_journal: enabled={str(session_journal['enabled']).lower()} cadence={session_journal['cadence']} branch={session_journal['branch']}")
    optin_hint = session_journal_optin_hint(data, project_path, target)
    if optin_hint:
        print(optin_hint)
    print(f"session_journal_local: {session_journal_local_dir(data, target)}")
    print(f"session_journal_pending: {compact_path_list(pending_journals, target)}")
    print_event_log_status(data, target)
    print_instrumentation_status(data)
    print_usage_accounting_status(data, target)
    iteration_review_issues = print_iteration_review_status(data, target)
    milestone_update_issues = print_milestone_update_status(data, target)
    product_chat_issues = print_product_chat_status(data)
    deployment_notification_issues = print_deployment_notification_status(data, target)
    ui_evidence_issues = print_ui_evidence_status(data, target)
    stakeholder_question_issues = stakeholder_questions_auth_issues(data, target)
    for issue in stakeholder_question_issues:
        print(f"stakeholder_question_issue: {issue}")
    print_context_rotation_status(data)
    autocompact_status = print_claude_autocompact_status()
    autocompact_settings_ok, autocompact_settings_status = claude_autocompact_settings_state(Path.home() / ".claude" / "settings.json")
    print(f"claude_autocompact_settings: {autocompact_settings_status}")
    if goal_run_path(data, target).exists():
        active_goal_run = load_goal_run(data, target)
        goal_action = goal_next_action_record(active_goal_run)
        goal_failures = goal_run_integrity_issues(active_goal_run)
        print(f"goal_status: {active_goal_run.get('status')} percent_complete={active_goal_run.get('percentComplete', goal_percent_complete(active_goal_run))}")
        print(f"goal_next_action: {goal_action['instruction']}")
        for issue in goal_failures:
            print(f"goal_issue: {issue}")
        if active_goal_run.get("status") == "complete":
            source_goal_path = goal_run_source_path(data, target, active_goal_run)
            excluded = {source_goal_path} if source_goal_path else set()
            print_next_goal_candidate(data, target, exclude_paths=excluded)
    else:
        active_goal_run = None
        goal_failures = []
        print("goal_status: missing")
        print_next_goal_candidate(data, target)
    # RCA: board-currency reconciliation runs whenever the provider is enabled, NOT only when an
    # active goal ledger exists, so features/bugs and between-goals states keep the stakeholder
    # board current. Drift is blocking; provider-unavailable (offline/no auth) is a warning.
    board_drift, board_unavailable, board_warnings = provider_board_currency_issues(data, target, active_goal_run)
    for issue in board_unavailable:
        print(f"goal_tracker_warn: {issue}")
    # RCA Jun-17: broad active-goal-ledger reconciliation is a non-blocking warning when it is not
    # the current work item. Verified active-work-not-active drift is part of board_drift above and
    # fails under --strict/--fail-on-drift.
    for issue in board_warnings:
        print(f"goal_tracker_warn: {issue}")
    for issue in board_drift:
        print(f"goal_tracker_issue: {issue}")
    goal_tracker_failures = board_drift
    # Non-blocking backfill: surface existing board items missing the business-justification lead.
    print_provider_board_business_lead_warnings(data, target)
    # RCA: a repo with tests must run them in CI on PR/push; otherwise regressions ship silently
    # behind green local/agent-invoked gates. Blocking when ciTestGate.enforcement == block.
    ci_test_gate_issue_list = print_ci_test_gate_status(data, target)
    runtime_secret_issue_list = print_runtime_secret_status(data, target)
    critical_journey_issue_list = print_critical_journey_status(data, target)
    migration_safety_issue_list = print_migration_safety_status(data, target)
    health_contract_issue_list = print_health_contract_status(data, target)
    side_effect_proof_issue_list = print_side_effect_proof_status(data, target)
    remediation_issue_list = print_remediation_status(data, target)
    # Operator decision: warn elsewhere, block where the adapter opts in. ci-test-gate is a hard
    # failure ONLY in block mode -- --strict must NOT escalate a warn-mode lane into a failure, or
    # every non-opted-in repo without a CI test gate would fail methodology-status --strict.
    ci_test_gate_failures = ci_test_gate_issue_list if ci_test_gate_config(data)["enforcement"] == "block" else []
    runtime_secret_failures = runtime_secret_issue_list if runtime_config(data)["enforcement"] == "block" else []
    critical_journey_failures = critical_journey_issue_list if (args.strict or args.fail_on_drift) else []
    migration_safety_failures = migration_safety_issue_list if (data.get("migrationSafety") or {}).get("enforcement") == "block" else []
    health_contract_failures = health_contract_issue_list if (data.get("healthContract") or {}).get("enforcement") == "block" else []
    side_effect_proof_failures = side_effect_proof_issue_list if (data.get("sideEffectProof") or {}).get("enforcement") == "block" else []
    remediation_failures = remediation_issue_list if ((data.get("remediation") or {}).get("enforcement") == "block" or args.fail_on_drift) else []
    if milestone_run_path(data, target).exists():
        run = load_milestone_run(data, target)
        action = milestone_next_action_record(run)
        milestone_failures = milestone_run_integrity_issues(run)
        print(f"milestone_status: {run.get('status')} percent_complete={run.get('percentComplete', milestone_percent_complete(run))}")
        print(f"milestone_next_action: {action['instruction']}")
        for issue in milestone_failures:
            print(f"milestone_issue: {issue}")
    else:
        milestone_failures = []
        print("milestone_status: missing")
    print(
        "milestone_watchdog: "
        f"enabled={str(milestone_continuation['watchdogEnabled']).lower()} cadence={milestone_continuation['watchdogCadenceMinutes']}m"
    )
    print("plan_finalization_gate: required")
    print(f"plan_review_manifests: {planning_source_root(data, target) / '.plan-reviews'}")
    print(f"review_evidence_pre_push: {'enabled' if implementation_review_enabled(data) else 'disabled'}")
    print(f"review_evidence_dir: {implementation_review_evidence_dir(target)}")
    hook_installed, hook_status = claude_hook_state(target)
    print(f"claude_plan_finalization_hook: {hook_status}")
    branch_hook_installed, branch_hook_status = claude_branch_liveness_hook_state(target)
    print(f"claude_branch_liveness_hook: {branch_hook_status}")
    response_hook_installed, response_hook_status = claude_response_guard_hook_state(target)
    print(f"claude_response_guard_hook: {response_hook_status}")
    tool_rejection_hook_installed, tool_rejection_hook_status = claude_tool_rejection_hook_state(target)
    print(f"claude_tool_rejection_hook: {tool_rejection_hook_status}")
    background_command_hook_installed, background_command_hook_status = claude_background_command_hook_state(target)
    print(f"claude_background_command_hook: {background_command_hook_status}")
    latest_code_hook_installed, latest_code_hook_status = claude_latest_code_hook_state(target)
    print(f"claude_latest_code_hook: {latest_code_hook_status}")
    context_heartbeat_hook_installed, context_heartbeat_hook_status = claude_context_rotation_heartbeat_hook_state(target)
    print(f"claude_context_rotation_heartbeat_hook: {context_heartbeat_hook_status}")
    git_hook_installed, git_hook_status = git_branch_liveness_hook_state(target)
    print(f"git_branch_liveness_hooks: {git_hook_status}")
    planning_failures = planning_issues + [f"relevant scratch plan requires migration: {path}" for path in relevant_scratch]
    hook_failures = []
    if not hook_installed:
        hook_failures.append(hook_status)
    if not branch_hook_installed:
        hook_failures.append(branch_hook_status)
    if not response_hook_installed:
        hook_failures.append(response_hook_status)
    if not tool_rejection_hook_installed:
        hook_failures.append(tool_rejection_hook_status)
    if not background_command_hook_installed:
        hook_failures.append(background_command_hook_status)
    if not latest_code_hook_installed:
        hook_failures.append(latest_code_hook_status)
    if not context_heartbeat_hook_installed:
        hook_failures.append(context_heartbeat_hook_status)
    if not git_hook_installed:
        hook_failures.append(git_hook_status)
    document_context_strict = args.strict or data["documentContext"]["enforcement"] == "strict"
    context_failures = document_context_issues if document_context_strict else []
    lane_coordination_strict = args.strict or data["laneCoordination"]["enforcement"] == "strict"
    lane_coordination_failures = lane_coordination_issues if lane_coordination_strict else []
    iteration_review_failures = iteration_review_issues if args.strict else []
    milestone_update_failures = milestone_update_issues if (args.strict or args.fail_on_drift) else []
    product_chat_failures = product_chat_issues if args.strict else []
    deployment_notification_failures = deployment_notification_issues if args.strict else []
    ui_evidence_failures = ui_evidence_issues if args.strict else []
    stakeholder_question_failures = stakeholder_question_issues if args.strict else []
    latest_code_failures = [] if latest_code_ok else [latest_code_state]
    graphify_failures = graphify_issues if (args.strict or args.fail_on_drift) else []
    behavior_spec_status = behavior_spec_status_record(data, target)
    behavior_spec_strict = args.strict or behavior_spec_status["enforcement"] == "strict"
    behavior_spec_failures = behavior_spec_issues if behavior_spec_strict else []
    _autocompact_desired, autocompact_actual, _autocompact_status, autocompact_required = claude_autocompact_env_status()
    autocompact_failures = claude_autocompact_failure_messages(
        autocompact_required=autocompact_required,
        autocompact_status=autocompact_status,
        autocompact_actual=autocompact_actual,
        autocompact_settings_ok=autocompact_settings_ok,
        autocompact_settings_status=autocompact_settings_status,
    )
    for issue in latest_code_failures:
        print(f"latest_code_issue: {issue}")
    for issue in autocompact_failures:
        print(f"claude_autocompact_issue: {issue}")
    gate_issue_lists = {
        "drift": drift,
        "planning_failures": planning_failures,
        "hook_failures": hook_failures,
        "goal_failures": goal_failures,
        "goal_tracker_failures": goal_tracker_failures,
        "milestone_failures": milestone_failures,
        "lane_coordination_failures": lane_coordination_failures,
        "iteration_review_failures": iteration_review_failures,
        "milestone_update_failures": milestone_update_failures,
        "product_chat_failures": product_chat_failures,
        "deployment_notification_failures": deployment_notification_failures,
        "ui_evidence_failures": ui_evidence_failures,
        "stakeholder_question_failures": stakeholder_question_failures,
        "latest_code_failures": latest_code_failures,
        "graphify_failures": graphify_failures,
        "behavior_spec_failures": behavior_spec_failures,
        "ci_test_gate_failures": ci_test_gate_failures,
        "runtime_secret_failures": runtime_secret_failures,
        "critical_journey_failures": critical_journey_failures,
        "migration_safety_failures": migration_safety_failures,
        "health_contract_failures": health_contract_failures,
        "side_effect_proof_failures": side_effect_proof_failures,
        "remediation_failures": remediation_failures,
        "autocompact_failures": autocompact_failures,
        "development_environment_failures": development_environment_failures,
        "context_failures": context_failures,
    }
    gate_conditions = methodology_status_gate_conditions(args, **gate_issue_lists)
    # Truthful three-way exit contract (0.8.9 startup remediation). Integrity-first ordering so
    # remaining debt is never hidden behind an integrity failure in the summary/refs.
    integrity_gates = [
        METHODOLOGY_STATUS_GATE_DISPLAY_NAMES[name]
        for name in METHODOLOGY_STATUS_INTEGRITY_GATES
        if gate_conditions[name]
    ]
    debt_gates = [
        METHODOLOGY_STATUS_GATE_DISPLAY_NAMES[name]
        for name in METHODOLOGY_STATUS_DEBT_GATES
        if gate_conditions[name]
    ]
    blocking_gates = integrity_gates + debt_gates
    exit_code = 0 if not blocking_gates else (1 if (args.strict or integrity_gates) else 2)

    # --- Remediation marker lifecycle (0.8.9 startup remediation) ----------------------------
    # Marker create/refresh/clear is gated ONLY on --fail-on-drift: `--strict` alone and plain
    # `methodology-status` never touch it (integrity-exit-1 paths -- including
    # `--strict --fail-on-drift`, which can never land on exit_code == 2 -- also never touch it;
    # only the bare-fail-on-drift debt-only exit_code == 2 path creates/refreshes, and any
    # fail-on-drift exit 0 clears).
    remediation_marker_warn: str | None = None
    if args.fail_on_drift:
        if exit_code == 0:
            marker_existed = startup_remediation_marker_path(target).exists()
            if not clear_startup_remediation_marker(target):
                # Unclearable (permissions/ownership): fail closed as a synthetic INTEGRITY gate
                # rather than silently reporting clean while the guard keeps refusing (V14R1-P3
                # stale-marker-stranding guarantee).
                blocking_gates = ["remediation_marker"]
                integrity_gates = ["remediation_marker"]
                debt_gates = []
                exit_code = 1
            elif marker_existed:
                # Only a genuine transition (a marker actually existed and was removed) is worth
                # an event -- otherwise every routine clean --fail-on-drift run in CI/hooks would
                # emit a "cleared" event for a marker that never existed.
                try_write_event(
                    data,
                    target,
                    event="startup_remediation_cleared",
                    severity="ok",
                    plain="Startup remediation marker cleared; methodology-status is clean.",
                    next_action="Continue authorized work through goal-next, milestone-next, or the active execution packet.",
                )
        elif exit_code == 2:
            marker_issues = [
                issue
                for name in METHODOLOGY_STATUS_DEBT_GATES
                if gate_conditions[name]
                for issue in gate_issue_lists[name]
            ]
            if args.enter_remediation_on_debt:
                if write_startup_remediation_marker(target, gates=debt_gates, issues=marker_issues, create=True):
                    try_write_event(
                        data,
                        target,
                        event="startup_remediation_entered",
                        severity="fail",
                        plain="Startup remediation marker written; debt-only methodology-status failure.",
                        next_action="Fix the printed methodology-status issues, then rerun methodology-status --fail-on-drift until it exits 0.",
                        refs={"gates": ", ".join(debt_gates)},
                    )
                else:
                    # V14R1-P2-1 / V14R1-P3-1: the synthetic gate joins EVERY failing debt gate,
                    # integrity first, on both the summary line and the event refs.
                    blocking_gates = ["remediation_marker", *debt_gates]
                    integrity_gates = ["remediation_marker"]
                    exit_code = 1
            elif startup_remediation_marker_path(target).exists():
                if not write_startup_remediation_marker(target, gates=debt_gates, issues=marker_issues, create=False):
                    remediation_marker_warn = f"could not refresh {startup_remediation_marker_path(target)}"

    classification = "integrity" if integrity_gates else ("debt" if debt_gates else None)
    will_fail = bool(blocking_gates)
    event_refs = {"classification": classification, "gates": ", ".join(blocking_gates)} if classification else {}
    try_write_event(
        data,
        target,
        event="methodology_status_failed" if will_fail else "methodology_status_passed",
        severity="fail" if will_fail else "ok",
        plain="Methodology status found blocking drift or lane-state issues." if will_fail else "Methodology status passed with adapter, hook, context, goal, and milestone state visible.",
        next_action="Fix the printed methodology-status issues before project work." if will_fail else "Continue authorized work through goal-next, milestone-next, or the active execution packet.",
        refs=event_refs,
    )
    if remediation_marker_warn:
        print(f"remediation_marker_warn: {remediation_marker_warn}")
    if not blocking_gates:
        return 0
    print(f"methodology_status_blocking: {classification} - {', '.join(blocking_gates)}")
    return exit_code


def graphify_status(args: argparse.Namespace) -> int:
    data, _project_path, target = lane_project(args)
    ensure_graphify_ignored(data, target)
    issues = print_graphify_status(data, target)
    if issues and args.strict:
        return 1
    return 0


def graphify_install(args: argparse.Namespace) -> int:
    data, _project_path, target = lane_project(args)
    ensure_lane_state(data, target)
    graphify = data["graphify"]
    if not graphify.get("enabled", True):
        print("graphify_install: skipped - graphify is disabled in this project adapter")
        print_graphify_status(data, target)
        return 0

    before = graphify_status_record(data, target)
    if before["cliPath"] and not args.force:
        print(f"graphify_install: cli already present {before['cliPath']}")
    else:
        install_command = graphify["installCommand"]
        print(f"graphify_install_command: {install_command}")
        code, out, err = run_command(["sh", "-lc", install_command], cwd=target)
        if out.strip():
            print(out.strip())
        if err.strip():
            print(err.strip(), file=sys.stderr)
        if code != 0:
            print(f"graphify_install_error: install command exited {code}", file=sys.stderr)
            return code

    cli_path = shutil.which("graphify")
    if not cli_path:
        print("graphify_install_error: graphify CLI still missing after install command", file=sys.stderr)
        return 1
    print(f"graphify_cli: {cli_path}")

    if not args.skip_setup:
        setup_command = graphify["setupCommand"]
        print(f"graphify_setup_command: {setup_command}")
        code, out, err = run_command(["sh", "-lc", setup_command], cwd=target)
        if out.strip():
            print(out.strip())
        if err.strip():
            print(err.strip(), file=sys.stderr)
        if code != 0:
            print(f"graphify_setup_error: setup command exited {code}", file=sys.stderr)
            return code
    else:
        print("graphify_setup: skipped --skip-setup")

    if args.build:
        build_command = graphify["updateCommand"] if args.update else graphify["buildCommand"]
        print(f"graphify_build_command: {build_command}")
        code, out, err = run_command(["sh", "-lc", build_command], cwd=target)
        if out.strip():
            print(out.strip())
        if err.strip():
            print(err.strip(), file=sys.stderr)
        if code != 0:
            print(f"graphify_build_error: build command exited {code}", file=sys.stderr)
            return code

    project_installer_note = (
        "graphify_project_installers: skipped - do not run `graphify claude install` or `graphify codex install` "
        "unless graphify.allowProjectInstaller is true and the human explicitly wants Graphify to manage assistant files"
    )
    if graphify.get("allowProjectInstaller", False):
        project_installer_note = (
            "graphify_project_installers: adapter allows explicit project installers, but this command still does not "
            "run them automatically; ask for the exact installer command before overwriting assistant files"
        )
    print(project_installer_note)
    print_graphify_status(data, target)
    return 0


def version(args: argparse.Namespace) -> int:
    print(f"plugin: {plugin_name()}")
    print(f"plugin_version: {plugin_version()}")
    print("version_source: VERSION")
    print(f"methodology_repo: {REPO_ROOT}")
    print(f"methodology_commit: {running_methodology_commit()}")
    print(f"methodology_commit_short: {running_methodology_commit(short=True)}")
    print(methodology_canonical_commit_line())
    print(methodology_exec_root_line())
    if running_from_installed_package():
        # Snapshot-store machines get NEITHER line: they update via repin, and telling them to
        # pip install would be actively wrong (test_snapshot_store_snapshot_gets_no_pip_hint).
        print("install_kind: package")
        print(f"update_hint: {PACKAGE_INSTALL_UPDATE_HINT}")
    print(f"remote_status: {remote_methodology_status(args.no_remote)}")
    return 0


def preserved_user_config_exports(config_env: Path) -> list[str]:
    if not config_env.exists():
        return []
    preserved: list[str] = []
    try:
        lines = config_env.read_text(encoding="utf-8", errors="replace").splitlines()
    except OSError:
        return []
    for line in lines:
        stripped = line.strip()
        match = re.match(r"^export\s+([A-Za-z_][A-Za-z0-9_]*)=", stripped)
        if not match:
            continue
        if match.group(1) in MANAGED_USER_CONFIG_ENV_KEYS:
            continue
        if stripped not in preserved:
            preserved.append(stripped)
    return preserved


# --- The install-cli -> launcher-reinstall window ------------------------------------------
# install-cli turns snapshot execution ON. Only `install-claude-launcher --force` starts EXPORTING
# a session-pinned MINERVIT_METHODOLOGY_EXEC_ROOT. In between, a session started by a pre-cutover
# launcher runs UNPINNED against `<store>/current` -- and another lane's sync can swap that symlink
# underneath it, so its hooks can hop Tautline versions between invocations. That window is
# invisible unless we say so, loudly, in both commands that can observe it.
LAUNCHER_TEMPLATE_VERSION = 2
LAUNCHER_TEMPLATE_MARKER = f"# tautline-launcher-template: {LAUNCHER_TEMPLATE_VERSION}"
LAUNCHER_GENERATED_MARKER = "Generated by tautline install-claude-launcher."
LAUNCHER_CUTOVER_COMMAND = f"{CLI_NAME} install-claude-launcher --force"


INSTALLED_LAUNCHERS_SCHEMA = "tautline-installed-launchers/v1"


# A generated launcher is a few KB of shell. The cap is what separates "read a candidate" from
# "decode whatever the operator happens to have installed": stale_claude_launchers() scans a real
# bin dir -- compiled binaries, multi-megabyte single-file tools -- on EVERY lane start.
LAUNCHER_SCAN_MAX_BYTES = 256 * 1024


def _launcher_text(path: Path) -> str | None:
    """The text of a file that could plausibly BE a launcher, or None.

    Reads bytes, not text, and reads at most the cap: a launcher is a small regular text file, so
    anything that is not one is rejected without decoding it. Binary is detected the way every
    other tool does it -- a NUL byte in the content -- because read_text(errors="replace") happily
    turns a 40MB executable into a 40MB string and only then fails to find the marker in it.
    """
    try:
        if not path.is_file():  # a directory, a socket, a dangling symlink: not a launcher
            return None
        with path.open("rb") as handle:
            head = handle.read(LAUNCHER_SCAN_MAX_BYTES + 1)
    except OSError:  # missing, unreadable: not a launcher we can judge
        return None
    if len(head) > LAUNCHER_SCAN_MAX_BYTES or b"\0" in head:
        return None
    return head.decode("utf-8", errors="replace")


def stale_claude_launchers(*bin_dirs: Path) -> list[Path]:
    """Installed Claude launchers generated before the snapshot-store cutover.

    Identified by what they ARE (our generated header) minus what they must become (the template
    marker), so a launcher the operator regenerates drops off the list without any extra state.
    """
    stale: list[Path] = []
    seen: set[Path] = set()
    for bin_dir in bin_dirs:
        try:
            entries = sorted(bin_dir.iterdir())
        except OSError:
            continue
        for entry in entries:
            resolved = entry.resolve(strict=False)
            if resolved in seen:
                continue
            seen.add(resolved)
            text = _launcher_text(entry)
            if text is None:
                continue
            if LAUNCHER_GENERATED_MARKER in text and LAUNCHER_TEMPLATE_MARKER not in text:
                stale.append(entry)
    return stale


def installed_launchers_path() -> Path:
    return methodology_state_dir() / "installed-launchers.json"


def read_installed_launchers() -> list[Path] | None:
    """Recorded launcher paths, or None when the recorder has never written a usable record.

    None and [] are DIFFERENT answers, and the difference is the whole point: every launcher
    installed before this release predates the recorder, so "no record" means "there may be
    pre-cutover launchers on this machine that I cannot see", while [] means the recorder ran and
    genuinely knows of none. Collapsing the two would let exactly the launchers we are hunting --
    the old ones, in whatever bin dir the operator chose -- escape detection in silence.
    """
    try:
        data = json.loads(installed_launchers_path().read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return None
    entries = data.get("launchers") if isinstance(data, dict) else None
    if not isinstance(entries, list):
        return None
    return [Path(str(entry)) for entry in entries if entry]


def record_installed_launcher(launcher: Path) -> Path | None:
    """Record an installed launcher so sync can find launchers OUTSIDE the default bin dir.

    stale_claude_launchers() can only scan directories it is told about, and `--bin-dir` lets the
    operator install anywhere. Best-effort: an unwritable state dir must never fail an install.
    """
    recorded = read_installed_launchers() or []
    known = {str(path) for path in recorded if path.exists()}
    known.add(str(launcher))
    path = installed_launchers_path()
    record = {"schema": INSTALLED_LAUNCHERS_SCHEMA, "launchers": sorted(known)}
    try:
        path.parent.mkdir(parents=True, exist_ok=True)
        util_module().write_text_atomic(path, json.dumps(record, indent=2) + "\n")
    except OSError:
        return None
    return path


def launchers_missing_maintainer_standdown(*bin_dirs: Path) -> list[Path]:
    """Installed generated launchers whose text lacks the maintainer-mode shell guard token.

    CONTENT-KEYED ON PURPOSE. LAUNCHER_TEMPLATE_VERSION deliberately did not bump for the T5
    auto-rescue guard (a fleet-wide regeneration nag for a maintainer-only feature would be
    disproportionate), so stale_claude_launchers -- which keys on the ABSENCE of the current
    template marker -- cannot see a current-marker launcher generated before the guard existed.
    The guard token (the TAUTLINE_ spelling of the mode key, which T5's parse lines carry) is
    the only text that separates a launcher that can stand its auto-rescue down from one that
    cannot. Scans the given bin dirs (default: the standard launcher dir) exactly like
    stale_claude_launchers, PLUS the recorded launcher paths -- `--bin-dir` lets the operator
    install anywhere, and the recorder is how launchers outside the default dir are found.
    """
    guard_token = _maintainer_mode_export_names()[0]
    candidates: list[Path] = []
    for bin_dir in bin_dirs or (USER_BIN_DIR,):
        try:
            candidates.extend(sorted(bin_dir.iterdir()))
        except OSError:
            continue
    candidates.extend(read_installed_launchers() or [])
    missing: list[Path] = []
    seen: set[Path] = set()
    for entry in candidates:
        resolved = entry.resolve(strict=False)
        if resolved in seen:
            continue
        seen.add(resolved)
        text = _launcher_text(entry)
        if text is None:
            continue
        if LAUNCHER_GENERATED_MARKER in text and guard_token not in text:
            missing.append(entry)
    return missing


def launcher_template_status_line() -> str | None:
    """What sync says about the launchers on this machine, or None when there is nothing to say.

    Only under snapshot mode: before the cutover there is no session exec root to be missing, so a
    v1 launcher is not stale, it is simply current.
    """
    if not methodology_snapshot_store_enabled():
        return None
    recorded = read_installed_launchers()
    if recorded is None:
        return (
            f"launcher_template: unknown - rerun {CLI_NAME} install-claude-launcher "
            "to record and upgrade launchers"
        )
    for path in recorded:
        text = _launcher_text(path)
        if text is not None and LAUNCHER_TEMPLATE_MARKER not in text:
            return f"launcher_template: stale - rerun {CLI_NAME} install-claude-launcher"
    return None


def launcher_cutover_warning_lines(stale: list[Path]) -> list[str]:
    bar = "!" * 76
    lines = [
        bar,
        "!! REQUIRED NEXT STEP -- this machine is HALF-CONVERTED to snapshot execution.",
        "!!",
        f"!!     {LAUNCHER_CUTOVER_COMMAND}",
        "!!",
        "!! Snapshot execution is ON, but a launcher generated before this cutover does not",
        "!! export a session exec root. Every session it starts runs UNPINNED against",
        "!! <store>/current, so another lane's sync can swap that symlink mid-session and the",
        "!! session's hooks will hop Tautline versions between invocations.",
    ]
    if stale:
        lines.append("!! Pre-cutover launchers found (regenerate every one of them):")
        lines.extend(f"!!   - {path}" for path in stale)
    else:
        lines.append("!! No pre-cutover launcher was found; regenerate any you installed by hand.")
    lines.append(bar)
    return lines


def warn_unpinned_launcher_window(*bin_dirs: Path) -> list[Path]:
    """Print the cutover banner when a pre-cutover launcher can still start unpinned sessions."""
    stale = stale_claude_launchers(*(bin_dirs or (USER_BIN_DIR,)))
    if not stale:
        return []
    for line in launcher_cutover_warning_lines(stale):
        print(line, file=sys.stderr)
    return stale


def install_cli(args: argparse.Namespace) -> int:
    bin_dir = args.bin_dir.expanduser().resolve()
    config_env = args.config_env.expanduser().resolve()
    shim = bin_dir / CLI_NAME
    legacy_shim = bin_dir / LEGACY_CLI_NAME
    # EVERY piece of state install-cli writes describes the CANONICAL checkout, never REPO_ROOT.
    # After the cutover install-cli itself normally runs FROM the immutable snapshot: recording
    # REPO_ROOT there would point methodology.env at a tree with no `.git` and no origin, pin the
    # trust allowlist at "unavailable", and leave nothing able to rebuild the store.
    canonical = canonical_methodology_repo()
    store_root = methodology_snapshot_store_root()
    canonical_head = run_git(canonical, ["rev-parse", "HEAD"])
    # sec-trust-exec-1: a fresh install pins the auto-update trust policy to the commit it installed
    # from, so the default new-install experience fails closed on an unexpected upstream instead of
    # re-exec'ing it. `update-repin` advances the pin after the operator reviews upstream.
    update_policy = getattr(args, "update_policy", "pinned") or "pinned"
    install_head = canonical_head if update_policy == "pinned" else ""
    if getattr(args, "dry_run", False):
        # prod-onboarding-4: show exactly what install-cli would mutate before it touches the machine.
        print("install_cli_dry_run: planned actions (no changes made):")
        print(f"  mkdir -p {bin_dir}")
        print(f"  mkdir -p {config_env.parent} (chmod 0700)")
        print(f"  write {config_env} (chmod 0600) setting MINERVIT_METHODOLOGY_REPO={canonical}")
        print(f"  set {METHODOLOGY_CANONICAL_REPO_ENV}={canonical} (both alias spellings)")
        print(f"  set {METHODOLOGY_SNAPSHOT_STORE_ENV}={store_root} (both alias spellings)")
        if config_env == USER_CONFIG_ENV.expanduser().resolve(strict=False):
            print(f"  write byte-identical legacy compatibility mirror {LEGACY_USER_CONFIG_ENV} (chmod 0600)")
        if update_policy == "pinned":
            print(f"  set {METHODOLOGY_UPDATE_POLICY_ENV}=pinned pinned at HEAD {install_head[:12]} (advance with update-repin)")
        else:
            print(f"  set {METHODOLOGY_UPDATE_POLICY_ENV}={update_policy} (no pin written)")
        print(f"  make {config_env} source optional local secrets from {USER_SECRETS_ENV}")
        print(f"  write executable shim {shim}")
        print(f"  materialize snapshot of {canonical} HEAD {canonical_head[:12]} into {store_root}")
        print(f"  point {store_root}/current at that snapshot (shims execute it from then on)")
        print(f"  ensure {Path.home() / '.claude' / 'settings.json'} autocompact settings")
        print(f"  install methodology release guards under {canonical}")
        print(f"  then, REQUIRED: {LAUNCHER_CUTOVER_COMMAND}")
        print("  undo a real install with: tautline uninstall-cli")
        return 0
    bin_dir.mkdir(parents=True, exist_ok=True)
    # sec-secrets-2: methodology.env can hold preserved local secrets (webhook URLs/tokens). Create
    # the parent 0700 and warn if an existing file is group/other-readable, so secrets are not left
    # world-readable on a shared machine.
    config_env.parent.mkdir(parents=True, exist_ok=True)
    try:
        config_env.parent.chmod(0o700)
    except OSError:
        pass
    if config_env.exists():
        try:
            existing_mode = config_env.stat().st_mode
            if existing_mode & 0o077:
                print(
                    f"install_cli: warning - {config_env} was group/other-readable "
                    f"({existing_mode & 0o777:o}); tightening to 0600",
                    file=sys.stderr,
                )
        except OSError:
            pass
    # Rebrand transition: preserve user exports (webhook secrets etc.) from BOTH config surfaces.
    # Merge by VARIABLE NAME with the preferred file winning (Codex 0.9.2 R1 P2): exact-line dedup
    # would append a stale legacy assignment after the new one, and shell sourcing would then make
    # the stale value effective.
    # Normalized default-path comparison (Codex 0.9.2 R1 P2): config_env is resolved but the
    # constant is not; a symlinked HOME (macOS /var -> /private/var) must not skip the mirror.
    default_config_env = USER_CONFIG_ENV.expanduser().resolve(strict=False)
    preserved_exports = preserved_user_config_exports(config_env)
    if config_env == default_config_env:
        seen_names = {
            match.group(1)
            for entry in preserved_exports
            if (match := re.match(r"^export\s+([A-Za-z_][A-Za-z0-9_]*)=", entry))
        }
        for entry in preserved_user_config_exports(LEGACY_USER_CONFIG_ENV):
            match = re.match(r"^export\s+([A-Za-z_][A-Za-z0-9_]*)=", entry)
            if match and match.group(1) in seen_names:
                continue
            if match:
                seen_names.add(match.group(1))
            if entry not in preserved_exports:
                preserved_exports.append(entry)
    preserved_source = config_env if config_env.is_file() else (
        LEGACY_USER_CONFIG_ENV if LEGACY_USER_CONFIG_ENV.is_file() else config_env
    )

    # Pin behavior unchanged from 0.9.1: install pins at the installing HEAD; run update-repin
    # AFTER install-cli to widen trust (the machine-conversion runbook order). Pin-set
    # preservation ships separately as its own focused security change.
    pins_value = install_head

    env_lines = [
        "# Generated by tautline install-cli.",
        f"export TAUTLINE_METHODOLOGY_REPO={shlex.quote(str(canonical))}",
        f"export MINERVIT_METHODOLOGY_REPO={shlex.quote(str(canonical))}",
        # The snapshot-store cutover, in both alias spellings so resolve_env's TAUTLINE_-first
        # preference and every legacy MINERVIT_-only reader agree about one machine.
        f"export TAUTLINE_METHODOLOGY_CANONICAL_REPO={shlex.quote(str(canonical))}",
        f"export MINERVIT_METHODOLOGY_CANONICAL_REPO={shlex.quote(str(canonical))}",
        f"export TAUTLINE_METHODOLOGY_SNAPSHOT_STORE={shlex.quote(str(store_root))}",
        f"export MINERVIT_METHODOLOGY_SNAPSHOT_STORE={shlex.quote(str(store_root))}",
        f"TAUTLINE_SECRETS_ENV={shlex.quote(str(USER_SECRETS_ENV))}",
        f"MINERVIT_SECRETS_ENV={shlex.quote(str(LEGACY_USER_SECRETS_ENV))}",
        'if [ -f "$TAUTLINE_SECRETS_ENV" ]; then',
        '  . "$TAUTLINE_SECRETS_ENV"',
        'elif [ -f "$MINERVIT_SECRETS_ENV" ]; then',
        '  . "$MINERVIT_SECRETS_ENV"',
        "fi",
        f"export MINERVIT_CLAUDE_AUTOCOMPACT_PCT=\"${{TAUTLINE_CLAUDE_AUTOCOMPACT_PCT:-${{MINERVIT_CLAUDE_AUTOCOMPACT_PCT:-{DEFAULT_CLAUDE_AUTOCOMPACT_PERCENT}}}}}\"",
        'export TAUTLINE_CLAUDE_AUTOCOMPACT_PCT="$MINERVIT_CLAUDE_AUTOCOMPACT_PCT"',
        'export CLAUDE_AUTOCOMPACT_PCT_OVERRIDE="$MINERVIT_CLAUDE_AUTOCOMPACT_PCT"',
        f"export PATH={shlex.quote(str(bin_dir))}:$PATH",
        f"export {METHODOLOGY_UPDATE_POLICY_ENV}={update_policy}",
    ]
    if update_policy == "pinned" and install_head and install_head != "unavailable":
        env_lines.append(f"export {METHODOLOGY_UPDATE_PINS_ENV}={shlex.quote(pins_value)}")
    if preserved_exports:
        env_lines.extend(["", "# Preserved local user/project environment. Do not commit these values.", *preserved_exports])
    env_lines.append("")
    config_env.write_text("\n".join(env_lines), encoding="utf-8")
    try:
        config_env.chmod(0o600)  # sec-secrets-2: never leave webhook secrets world-readable
    except OSError:
        pass
    # Compatibility mirror: pre-0.9.2 launchers/hooks hardcode the legacy path; keep it identical
    # until METH-FU-TAUTLINE-FALLBACK-REMOVAL deletes it. Only mirrored for the default location.
    if config_env == default_config_env:
        LEGACY_USER_CONFIG_ENV.parent.mkdir(parents=True, exist_ok=True)
        try:
            LEGACY_USER_CONFIG_ENV.parent.chmod(0o700)
        except OSError:
            pass
        LEGACY_USER_CONFIG_ENV.write_text("\n".join(env_lines), encoding="utf-8")
        try:
            LEGACY_USER_CONFIG_ENV.chmod(0o600)
        except OSError:
            pass

    shim_content = "\n".join(
        [
            "#!/bin/sh",
            "set -eu",
            # (0) The rollback lever, ABOVE every snapshot exec path. Live env only, exactly as
            # documented (release-engineering.md#rollback): the config env file must never be able
            # to redirect execution, and this is the one switch an operator reaches for when the
            # store has published a bad snapshot.
            'LIVE_SNAPSHOT_DISABLED="${TAUTLINE_METHODOLOGY_DISABLE_SNAPSHOT_EXEC:-${MINERVIT_METHODOLOGY_DISABLE_SNAPSHOT_EXEC:-}}"',
            # (i) Before anything else, both live-env-only exec checks.
            # The session-sticky exec root: a launcher (or a post-update re-exec) resolved ONE
            # snapshot for this session's life and exported it, so every hook subprocess executes
            # that same tree even after another lane swaps `current`. A pruned root simply fails
            # the -x test and we degrade to `current` below -- a version hop, never a breakage.
            'TL_EXEC_ROOT="${MINERVIT_METHODOLOGY_EXEC_ROOT:-}"',
            # ...but an exported root is still a SNAPSHOT root, so the lever has to strip it before
            # it is honoured. Checked here, not further down: a session (or any child of one) that
            # already carries an exec root would otherwise exec straight back into the very snapshot
            # the operator set the lever to escape, and re-export the root on the way, holding the
            # whole process tree inside the store. Unset, not just blanked, so children see it gone.
            'if [ "$LIVE_SNAPSHOT_DISABLED" = "1" ]; then',
            '  TL_EXEC_ROOT=""',
            "  unset MINERVIT_METHODOLOGY_EXEC_ROOT || true",
            "fi",
            'if [ -n "$TL_EXEC_ROOT" ] && [ -x "$TL_EXEC_ROOT/bin/tautline" ]; then',
            '  exec "$TL_EXEC_ROOT/bin/tautline" "$@"',
            "fi",
            # Deliberate dev exec-redirection. This is what MINERVIT_METHODOLOGY_REPO used to do by
            # accident: it is EXPORTED by every v1 launcher, so honoring it as an exec override
            # would send stale-v1 hook sessions to the mutable canonical checkout -- the exact
            # hazard the snapshot store removes. Redirection now needs this explicit variable,
            # which no launcher exports and which install-cli never writes to methodology.env.
            # Checked before sourcing: sec-trust-exec-3 (a file must never redirect execution).
            'TL_EXEC_OVERRIDE="${MINERVIT_METHODOLOGY_EXEC_OVERRIDE:-}"',
            'if [ -n "$TL_EXEC_OVERRIDE" ] && [ -x "$TL_EXEC_OVERRIDE/bin/tautline" ]; then',
            '  exec "$TL_EXEC_OVERRIDE/bin/tautline" "$@"',
            "fi",
            f"CONFIG_ENV={shlex.quote(str(config_env))}",
            'MINERVIT_METHODOLOGY_REPO_PRESET="${TAUTLINE_METHODOLOGY_REPO:-${MINERVIT_METHODOLOGY_REPO:-}}"',
            'MINERVIT_METHODOLOGY_REPO_ENV=""',
            # (ii) Capture the LIVE store setting BEFORE sourcing: afterwards the shell cannot
            # tell an operator override from a file-sourced value, and sec-trust-exec-3 requires
            # live env to beat every file-based source (a stale file TAUTLINE_ alias must never
            # shadow a live MINERVIT_ override). LIVE_SNAPSHOT_DISABLED is captured at (0) for the
            # same reason -- it has to be known before the exec-root fast path above.
            'LIVE_SNAPSHOT_STORE="${TAUTLINE_METHODOLOGY_SNAPSHOT_STORE:-${MINERVIT_METHODOLOGY_SNAPSHOT_STORE:-}}"',
            f"BAKED_SNAPSHOT_STORE={shlex.quote(str(store_root))}",
            # methodology.env sources the user's private secrets file; an unset-variable
            # reference there must degrade to missing config, not kill every CLI run in
            # minimal environments (cron, hooks, CI) via this shim's set -eu.
            'if [ -f "$CONFIG_ENV" ]; then',
            "  set +eu",
            '  . "$CONFIG_ENV"',
            "  set -eu",
            '  MINERVIT_METHODOLOGY_REPO_ENV="${TAUTLINE_METHODOLOGY_REPO:-${MINERVIT_METHODOLOGY_REPO:-}}"',
            'elif [ -f "$HOME/.config/minervit/methodology.env" ]; then',
            "  set +eu",
            '  . "$HOME/.config/minervit/methodology.env"',
            "  set -eu",
            '  MINERVIT_METHODOLOGY_REPO_ENV="${MINERVIT_METHODOLOGY_REPO:-}"',
            "fi",
            "resolve_minervit_methodology_repo() {",
            "  # sec-trust-exec-3: prefer operator-controlled sources (live env preset, then the",
            "  # methodology.env value) over cwd discovery, which an attacker could hijack by planting",
            "  # a minervit-ai-delivery-methodology directory in an ancestor of the working directory.",
            '  if [ -n "$MINERVIT_METHODOLOGY_REPO_PRESET" ] && { [ -x "$MINERVIT_METHODOLOGY_REPO_PRESET/bin/tautline" ] || [ -x "$MINERVIT_METHODOLOGY_REPO_PRESET/bin/minervit-methodology" ]; }; then',
            '    printf "%s\\n" "$MINERVIT_METHODOLOGY_REPO_PRESET"',
            "    return 0",
            "  fi",
            '  if [ -n "$MINERVIT_METHODOLOGY_REPO_ENV" ] && { [ -x "$MINERVIT_METHODOLOGY_REPO_ENV/bin/tautline" ] || [ -x "$MINERVIT_METHODOLOGY_REPO_ENV/bin/minervit-methodology" ]; }; then',
            '    printf "%s\\n" "$MINERVIT_METHODOLOGY_REPO_ENV"',
            "    return 0",
            "  fi",
            '  search_dir="$(pwd -P 2>/dev/null || pwd)"',
            '  while [ -n "$search_dir" ]; do',
            '    candidate="$search_dir/tautline"',
            '    if [ ! -x "$candidate/bin/tautline" ] && [ ! -x "$candidate/bin/minervit-methodology" ]; then',
            '      candidate="$search_dir/minervit-ai-delivery-methodology"',
            "    fi",
            '    if [ -x "$candidate/bin/tautline" ] || [ -x "$candidate/bin/minervit-methodology" ]; then',
            '      printf "%s\\n" "minervit-methodology: WARNING resolved via cwd discovery ($candidate); set MINERVIT_METHODOLOGY_REPO to a trusted checkout to avoid adopting an untrusted directory." >&2',
            '      printf "%s\\n" "$candidate"',
            "      return 0",
            "    fi",
            '    [ "$search_dir" != "/" ] || break',
            '    next_dir="$(dirname "$search_dir")"',
            '    [ "$next_dir" != "$search_dir" ] || break',
            '    search_dir="$next_dir"',
            "  done",
            f"  printf '%s\\n' {shlex.quote(str(canonical))}",
            "}",
            # MINERVIT_METHODOLOGY_REPO keeps its ONE remaining meaning: the canonical checkout
            # sync manages. Still resolved, still exported -- Python reads it to find the repo it
            # fetches/merges/archives. It is simply no longer an exec target ahead of the store.
            'MINERVIT_METHODOLOGY_REPO="$(resolve_minervit_methodology_repo)"',
            "export MINERVIT_METHODOLOGY_REPO",
            'TAUTLINE_METHODOLOGY_REPO="$MINERVIT_METHODOLOGY_REPO"',
            "export TAUTLINE_METHODOLOGY_REPO",
            # (iii) Store resolution AFTER sourcing (methodology.env may set the store), with the
            # live values captured above still winning. TAUTLINE_ first, matching resolve_env.
            'SNAPSHOT_STORE="${LIVE_SNAPSHOT_STORE:-${TAUTLINE_METHODOLOGY_SNAPSHOT_STORE:-${MINERVIT_METHODOLOGY_SNAPSHOT_STORE:-$BAKED_SNAPSHOT_STORE}}}"',
            'SNAPSHOT_DISABLED="${LIVE_SNAPSHOT_DISABLED:-${TAUTLINE_METHODOLOGY_DISABLE_SNAPSHOT_EXEC:-${MINERVIT_METHODOLOGY_DISABLE_SNAPSHOT_EXEC:-0}}}"',
            'SNAPSHOT_CURRENT="$SNAPSHOT_STORE/current"',
            'if [ "$SNAPSHOT_DISABLED" != "1" ] && [ -x "$SNAPSHOT_CURRENT/bin/tautline" ]; then',
            # pwd -P, not the symlink path: children must inherit the RESOLVED snapshot, or a
            # concurrent swap of `current` would silently move them to another version.
            '  MINERVIT_METHODOLOGY_EXEC_ROOT="$(cd "$SNAPSHOT_CURRENT" 2>/dev/null && pwd -P || true)"',
            '  if [ -n "$MINERVIT_METHODOLOGY_EXEC_ROOT" ]; then',
            "    export MINERVIT_METHODOLOGY_EXEC_ROOT",
            '    exec "$MINERVIT_METHODOLOGY_EXEC_ROOT/bin/tautline" "$@"',
            "  fi",
            'elif [ "$SNAPSHOT_DISABLED" != "1" ]; then',
            # No store-dir-exists condition on purpose: a post-cutover shim whose `current` is not
            # executable -- including a store deleted outright -- must warn and fall through to the
            # canonical checkout. A pre-cutover shim never carries this block, so nothing warns
            # before the machine is converted.
            "  SNAPSHOT_WARN='minervit-methodology: snapshot store current link'",
            '  SNAPSHOT_WARN="$SNAPSHOT_WARN missing or broken; executing canonical checkout"',
            "  SNAPSHOT_WARN=\"$SNAPSHOT_WARN (run 'tautline sync-methodology' to rebuild)\"",
            "  printf '%s\\n' \"$SNAPSHOT_WARN\" >&2",
            "fi",
            'if [ -x "$MINERVIT_METHODOLOGY_REPO/bin/tautline" ]; then',
            '  exec "$MINERVIT_METHODOLOGY_REPO/bin/tautline" "$@"',
            "fi",
            'exec "$MINERVIT_METHODOLOGY_REPO/bin/minervit-methodology" "$@"',
            "",
        ]
    )
    write_text_executable(shim, shim_content)
    write_text_executable(legacy_shim, shim_content)
    # The shims now execute `<store>/current`, so the store must exist before the next command
    # runs. Best-effort by construction: a store we cannot build degrades to "the shim warns and
    # executes the canonical checkout", which is exactly the pre-cutover behavior, so a broken
    # store must never fail the install that would let sync-methodology rebuild it.
    snapshot_dir, snapshot_detail = materialize_methodology_snapshot(canonical, "HEAD", "stable")
    if snapshot_dir is None:
        print(f"methodology_snapshot: unavailable - {snapshot_detail}", file=sys.stderr)
    else:
        swapped, swap_detail = swap_methodology_snapshot_current(snapshot_dir)
        if not swapped:
            print(f"methodology_snapshot: unavailable - {swap_detail}", file=sys.stderr)
            snapshot_dir = None
    settings_path, settings_ok = write_claude_autocompact_settings(Path.home() / ".claude" / "settings.json")
    release_guard = install_methodology_release_guards()

    print(f"installed_cli: {shim}")
    print(f"installed_cli_legacy_alias: {legacy_shim}")
    print(f"methodology_env: {config_env}")
    print(f"methodology_repo: {canonical}")
    print(f"methodology_snapshot_store: {store_root}")
    if snapshot_dir is None:
        print(
            "methodology_snapshot: unavailable - shims will execute the canonical checkout "
            "until `tautline sync-methodology` rebuilds the store"
        )
    else:
        print(f"methodology_snapshot: {snapshot_detail}")
        print(f"methodology_snapshot_current: {store_root / 'current'} -> {snapshot_dir.name}")
    if update_policy == "pinned":
        print(f"update_policy: pinned at {install_head[:12]} (advance with `tautline update-repin` after reviewing upstream)")
    else:
        print(f"update_policy: {update_policy}")
    print(f"methodology_release_guard: {release_guard}")
    print(f"claude_autocompact_settings: {'already ok' if settings_ok else 'installed'} {settings_path}")
    print(f"shell_setup: source {config_env}")
    # LAST, and on both streams: this install just opened the unpinned-launcher window, and an
    # operator who stops reading here starts version-hopping sessions without ever being told.
    # Unconditional -- install-cli ALWAYS opens the window, whether or not a launcher exists yet.
    for line in launcher_cutover_warning_lines(stale_claude_launchers(bin_dir, USER_BIN_DIR)):
        print(line, file=sys.stderr)
    print(f"next_step_required: {LAUNCHER_CUTOVER_COMMAND}")
    return 0


def uninstall_cli(args: argparse.Namespace) -> int:
    """prod-onboarding-4: reverse install-cli (remove the CLI shim and methodology.env).

    The autocompact settings written into ~/.claude/settings.json and any installed git pre-push
    hook are intentionally NOT auto-reverted (they may be hand-edited / shared); they are reported so
    the operator can remove them deliberately.
    """
    bin_dir = args.bin_dir.expanduser().resolve()
    config_env = (args.config_env or resolve_user_config_env()).expanduser().resolve()
    shim = bin_dir / CLI_NAME
    legacy_shim = bin_dir / LEGACY_CLI_NAME
    dry_run = getattr(args, "dry_run", False)
    removed: list[str] = []
    keep_env = getattr(args, "keep_env", False)
    removal_targets = [(shim, False), (legacy_shim, False), (config_env, keep_env)]
    # A default install writes BOTH config surfaces (tautline.env + the legacy mirror, which can
    # hold preserved secrets); a default uninstall must reverse both (Codex 0.9.2 R1 P2).
    if config_env == USER_CONFIG_ENV.expanduser().resolve(strict=False) and LEGACY_USER_CONFIG_ENV.expanduser().resolve(strict=False) != config_env:
        removal_targets.append((LEGACY_USER_CONFIG_ENV, keep_env))
    for path, keep in removal_targets:
        if keep:
            print(f"uninstall_cli: keeping {path}")
            continue
        if path.exists() or path.is_symlink():
            if dry_run:
                print(f"uninstall_cli_dry_run: would remove {path}")
            else:
                path.unlink()
                removed.append(str(path))
    store = methodology_snapshot_store_root()
    if not dry_run:
        for entry in removed:
            print(f"uninstall_cli_removed: {entry}")
        if not removed:
            print("uninstall_cli: nothing to remove (already uninstalled)")
        print(
            "uninstall_cli_note: ~/.claude/settings.json autocompact settings and any installed git "
            "pre-push hook were left in place; remove them manually if desired."
        )
        # A live session may still be executing a snapshot out of this store, so uninstall never
        # deletes it. The removal command is spelled out because a published snapshot is 0555/0444
        # by design: a bare `rm -rf` is DENIED, and an operator handed one would think it failed.
        print(
            f"uninstall_cli_note: the snapshot store at {store} was RETAINED (sessions may still "
            f"be executing it); remove it with: chmod -R u+w {store} && rm -rf {store}"
        )
    else:
        print(f"uninstall_cli_dry_run: would retain the snapshot store at {store}")
    return 0


def repin_methodology_update(repo: Path, config_env: Path, channel: str = "stable") -> tuple[str, str, str]:
    """Advance the pinned auto-update allowlist to current origin/<branch> HEAD (sec-trust-exec-1).

    Run after the operator has reviewed the incoming upstream commits. Fetches origin, rewrites the
    MINERVIT_METHODOLOGY_UPDATE_PINS line in methodology.env, and returns
    (previous_pin, new_pin, trusted_range) so the caller can report exactly what is now trusted.
    """
    branch = framework_channel_branch(channel)
    # Fail closed on fetch failure (Codex R2 P2): a cached origin/<branch> remote-tracking ref
    # survives an offline/auth-fail/missing-branch fetch, so falling through would silently repin
    # a STALE sha and report it as current upstream. Never trust the cached ref without a fresh fetch.
    code, stdout, stderr = run_command(["git", "-C", str(repo), "fetch", "origin", branch])
    if code != 0:
        raise SystemExit(
            f"update-repin: git fetch origin {branch} failed in {repo}: "
            f"{stderr or stdout or 'command failed'}; refusing to repin from a stale cached "
            f"origin/{branch} ref -- fix connectivity/auth and rerun"
        )
    new_head = run_git(repo, ["rev-parse", f"origin/{branch}"])
    if not new_head or new_head == "unavailable":
        raise SystemExit(
            f"update-repin: could not resolve origin/{branch} in {repo}; fetch the methodology upstream first"
        )
    previous = util_module().user_config_env_value(METHODOLOGY_UPDATE_PINS_ENV, config_env).strip()
    # Journal the outcome the write ACTUALLY produced, after the write. The rewrite below is a real
    # write to a real file and can still fail; an entry appended before it would claim a repin that
    # never landed. This entry records a mutation of the TRUST ALLOWLIST -- the set of upstream
    # commits this machine will execute -- so it is exactly the line an incident reads to answer
    # "who moved this machine's trust, and to what?". It must never answer that question wrongly.
    try:
        _rewrite_config_env_export(config_env, METHODOLOGY_UPDATE_PINS_ENV, new_head)
    except OSError as exc:
        append_methodology_write_journal(
            "update-repin",
            previous,
            new_head,
            "failed",
            detail=f"could not rewrite trust allowlist in {config_env}: {exc}",
        )
        raise SystemExit(
            f"update-repin: could not rewrite the trust allowlist in {config_env}: {exc}; "
            f"the machine still trusts {previous[:12] or 'its previous pin'}"
        )
    append_methodology_write_journal(
        "update-repin", previous, new_head, "ok", detail=f"repinned trust allowlist in {config_env}"
    )
    # Rebrand transition: keep the other DEFAULT config surface's pin in step so pre-0.9.2
    # launchers (legacy path) and 0.9.2+ launchers (tautline path) never disagree about what is
    # trusted. Only when config_env IS one of the default pair (Codex 0.9.2 branch R1 P1): a
    # custom --config-env must never mutate unrelated installed trust files.
    config_env_resolved = config_env.expanduser().resolve(strict=False)
    default_pair = {
        USER_CONFIG_ENV.expanduser().resolve(strict=False): LEGACY_USER_CONFIG_ENV,
        LEGACY_USER_CONFIG_ENV.expanduser().resolve(strict=False): USER_CONFIG_ENV,
    }
    mirror = default_pair.get(config_env_resolved)
    if mirror is not None and mirror.is_file():
        _rewrite_config_env_export(mirror, METHODOLOGY_UPDATE_PINS_ENV, new_head)
    trusted_range = f"{previous[:12]}..{new_head[:12]}" if previous else new_head[:12]
    return previous, new_head, trusted_range


def _rewrite_config_env_export(config_env: Path, name: str, value: str) -> None:
    """Replace (or append) a single `export NAME=value` line in an installed env file in place."""
    new_line = f"export {name}={value}"
    try:
        lines = config_env.read_text(encoding="utf-8").splitlines()
    except OSError:
        lines = []
    prefix = f"export {name}="
    replaced = False
    out: list[str] = []
    for line in lines:
        if line.strip().startswith(prefix):
            out.append(new_line)
            replaced = True
        else:
            out.append(line)
    if not replaced:
        out.append(new_line)
    config_env.parent.mkdir(parents=True, exist_ok=True)
    config_env.write_text("\n".join(out) + "\n", encoding="utf-8")
    try:
        config_env.chmod(0o600)
    except OSError:
        pass


def update_repin(args: argparse.Namespace) -> int:
    if running_from_installed_package():
        # BEFORE canonical resolution and BEFORE any fetch (PP-R1-P1-1): repin advances the trust
        # allowlist for the checkout sync manages, and a package install has no such checkout --
        # even a leftover configured one is dead state this wheel never executes. The old path
        # let the fetch fail and diagnosed "fix connectivity/auth and rerun", which misnames the
        # cause; that message stays for real fetch failures on real checkouts.
        raise SystemExit(
            "update-repin: this Tautline runs from an installed package; there is no methodology "
            "checkout to repin (see install-cli for the checkout runtime) - update with "
            f"{PACKAGE_INSTALL_UPDATE_HINT}"
        )
    config_env = (args.config_env or resolve_user_config_env()).expanduser().resolve()
    channel = getattr(args, "channel", "stable") or "stable"
    # Repin fetches and rev-parses the checkout sync will advance: the canonical one. From a
    # snapshot the exec root has no origin, so this would repin against "unavailable".
    canonical = canonical_methodology_repo()
    previous, new_head, trusted_range = repin_methodology_update(canonical, config_env, channel)
    print(f"update_repin_config_env: {config_env}")
    if previous:
        print(f"update_repin_previous_pin: {previous}")
    print(f"update_repin_new_pin: {new_head}")
    print(f"update_repin_trusted_range: {trusted_range}")
    return 0


# sec-trust-exec-1: a skip-permissions launcher re-checks the effective update policy at launch,
# because it can change after install (methodology.env edit / removed pin). It refuses to start
# unless the upstream is verifiable (pinned or signed). Tuple, not list, to stay out of the
# policy-phrases SSOT.
_SKIP_PERMISSIONS_LAUNCH_GUARD = (
    'MINERVIT_METHODOLOGY_UPDATE_POLICY_EFFECTIVE="${MINERVIT_METHODOLOGY_UPDATE_POLICY:-warn}"',
    'case "$MINERVIT_METHODOLOGY_UPDATE_POLICY_EFFECTIVE" in',
    "  pinned|signed) ;;",
    "  *)",
    '    printf "%s\\n" "minervit-claude: refusing --dangerously-skip-permissions while methodology update policy is $MINERVIT_METHODOLOGY_UPDATE_POLICY_EFFECTIVE (need pinned or signed; see SECURITY.md)" >&2',
    "    exit 1",
    "    ;;",
    "esac",
)


def claude_launcher_content(dangerously_skip_permissions: bool) -> str:
    claude_args = " --dangerously-skip-permissions" if dangerously_skip_permissions else ""
    return "\n".join(
        [
            "#!/bin/sh",
            f"# {LAUNCHER_GENERATED_MARKER}",
            # The cutover marker. sync-methodology and install-cli look for exactly this line to
            # decide whether a launcher on this machine still starts sessions that are UNPINNED
            # against <store>/current; do not reword it without updating LAUNCHER_TEMPLATE_MARKER.
            LAUNCHER_TEMPLATE_MARKER,
            "set -u",
            'MINERVIT_METHODOLOGY_REPO_PRESET="${TAUTLINE_METHODOLOGY_REPO:-${MINERVIT_METHODOLOGY_REPO:-}}"',
            'MINERVIT_METHODOLOGY_REPO_ENV=""',
            # Captured BEFORE sourcing, exactly as the shim does: afterwards the shell cannot tell
            # a live operator override from a file-sourced value, and sec-trust-exec-3 requires live
            # env to beat every file-based source.
            'LIVE_SNAPSHOT_STORE="${TAUTLINE_METHODOLOGY_SNAPSHOT_STORE:-${MINERVIT_METHODOLOGY_SNAPSHOT_STORE:-}}"',
            'LIVE_SNAPSHOT_DISABLED="${TAUTLINE_METHODOLOGY_DISABLE_SNAPSHOT_EXEC:-${MINERVIT_METHODOLOGY_DISABLE_SNAPSHOT_EXEC:-}}"',
            # Maintainer-mode standdown for the shell auto-rescue: PARSE the mode key out of the
            # same config env file this launcher is about to source -- parsing, not sourcing, so
            # the guard sees exactly the direct simple exports user_config_env_value sees on the
            # Python side and the two halves of one machine can never disagree about the armed
            # state (MS-R1-P1-2). Inherited live values and values exported by sourced secrets
            # files are invisible here BY CONSTRUCTION: file-only arming reaches the shell too,
            # and only `maintainer-mode off` (which strips the file) can disarm it. TAUTLINE_
            # spelling first with blank falling through, mirroring maintainer_mode_config_value;
            # `tail -n 1` because the maintainer-mode verb writes idempotently -- no duplicates
            # exist on the supported path, and for direct exports the last line is what sourcing
            # would have left. These lines carry the TAUTLINE_METHODOLOGY_MAINTAINER_MODE token
            # on purpose: T4's content-keyed launcher detection recognizes regenerated launchers
            # by it, with no LAUNCHER_TEMPLATE_VERSION bump (the sibling 0.9.18 plan escalates
            # launchers that predate this guard). Placement inside the pre-sourcing capture
            # block is a natural home, not load-bearing: nothing here reads live variables.
            'MAINTAINER_MODE_CONFIG="$HOME/.config/tautline/tautline.env"',
            '[ -f "$MAINTAINER_MODE_CONFIG" ] || '
            'MAINTAINER_MODE_CONFIG="$HOME/.config/minervit/methodology.env"',
            "LAUNCHER_MAINTAINER_MODE="
            '"$(sed -n \'s/^export TAUTLINE_METHODOLOGY_MAINTAINER_MODE=//p\' '
            '"$MAINTAINER_MODE_CONFIG" 2>/dev/null | tail -n 1)"',
            '[ -n "$LAUNCHER_MAINTAINER_MODE" ] || LAUNCHER_MAINTAINER_MODE='
            '"$(sed -n \'s/^export MINERVIT_METHODOLOGY_MAINTAINER_MODE=//p\' '
            '"$MAINTAINER_MODE_CONFIG" 2>/dev/null | tail -n 1)"',
            # See the shim: user secrets sourcing must not kill the launcher under set -u.
            'if [ -f "$HOME/.config/tautline/tautline.env" ]; then',
            "  set +u",
            '  . "$HOME/.config/tautline/tautline.env"',
            "  set -u",
            '  MINERVIT_METHODOLOGY_REPO_ENV="${TAUTLINE_METHODOLOGY_REPO:-${MINERVIT_METHODOLOGY_REPO:-}}"',
            'elif [ -f "$HOME/.config/minervit/methodology.env" ]; then',
            "  set +u",
            '  . "$HOME/.config/minervit/methodology.env"',
            "  set -u",
            '  MINERVIT_METHODOLOGY_REPO_ENV="${MINERVIT_METHODOLOGY_REPO:-}"',
            "fi",
            # The store is resolved AFTER sourcing (methodology.env carries the managed key), with
            # the live values captured above still winning. No baked default: an EXPLICIT managed
            # key is what turns snapshot mode on -- the rule methodology_snapshot_store_enabled()
            # applies in Python -- so a stray store directory can never start executing snapshots.
            'SNAPSHOT_STORE="${LIVE_SNAPSHOT_STORE:-${TAUTLINE_METHODOLOGY_SNAPSHOT_STORE:-${MINERVIT_METHODOLOGY_SNAPSHOT_STORE:-}}}"',
            'SNAPSHOT_DISABLED="${LIVE_SNAPSHOT_DISABLED:-${TAUTLINE_METHODOLOGY_DISABLE_SNAPSHOT_EXEC:-${MINERVIT_METHODOLOGY_DISABLE_SNAPSHOT_EXEC:-0}}}"',
            'if [ "$SNAPSHOT_DISABLED" = "1" ]; then',
            '  SNAPSHOT_STORE=""',
            "fi",
            # A launch starts a NEW session: it must pin to the snapshot `current` names now, never
            # to one inherited from the shell that spawned it (which prune may already have taken).
            "unset MINERVIT_METHODOLOGY_EXEC_ROOT || true",
            *(_SKIP_PERMISSIONS_LAUNCH_GUARD if dangerously_skip_permissions else ()),
            "launcher_resolve_methodology_repo() {",
            "  # sec-trust-exec-3: operator-controlled sources beat hijackable cwd discovery.",
            '  if [ -n "$MINERVIT_METHODOLOGY_REPO_PRESET" ] && { [ -x "$MINERVIT_METHODOLOGY_REPO_PRESET/bin/tautline" ] || [ -x "$MINERVIT_METHODOLOGY_REPO_PRESET/bin/minervit-methodology" ]; }; then',
            '    printf "%s\\n" "$MINERVIT_METHODOLOGY_REPO_PRESET"',
            "    return 0",
            "  fi",
            '  if [ -n "$MINERVIT_METHODOLOGY_REPO_ENV" ] && { [ -x "$MINERVIT_METHODOLOGY_REPO_ENV/bin/tautline" ] || [ -x "$MINERVIT_METHODOLOGY_REPO_ENV/bin/minervit-methodology" ]; }; then',
            '    printf "%s\\n" "$MINERVIT_METHODOLOGY_REPO_ENV"',
            "    return 0",
            "  fi",
            '  search_dir="$(pwd -P 2>/dev/null || pwd)"',
            '  while [ -n "$search_dir" ]; do',
            '    candidate="$search_dir/tautline"',
            '    if [ ! -x "$candidate/bin/tautline" ] && [ ! -x "$candidate/bin/minervit-methodology" ]; then',
            '      candidate="$search_dir/minervit-ai-delivery-methodology"',
            "    fi",
            '    if [ -x "$candidate/bin/tautline" ] || [ -x "$candidate/bin/minervit-methodology" ]; then',
            '      printf "%s\\n" "minervit-methodology: WARNING resolved via cwd discovery ($candidate); set MINERVIT_METHODOLOGY_REPO to a trusted checkout." >&2',
            '      printf "%s\\n" "$candidate"',
            "      return 0",
            "    fi",
            '    [ "$search_dir" != "/" ] || break',
            '    next_dir="$(dirname "$search_dir")"',
            '    [ "$next_dir" != "$search_dir" ] || break',
            '    search_dir="$next_dir"',
            "  done",
            "  printf '%s\\n' ''",
            "}",
            'MINERVIT_METHODOLOGY_REPO="$(launcher_resolve_methodology_repo)"',
            "export MINERVIT_METHODOLOGY_REPO",
            "# Normalize BOTH families to the gated checkout (Codex 0.9.2 R1 P1): the sourced env file",
            "# exported its own TAUTLINE_METHODOLOGY_REPO; children prefer TAUTLINE_*, so a stale value",
            "# here would gate one checkout and execute another.",
            'TAUTLINE_METHODOLOGY_REPO="$MINERVIT_METHODOLOGY_REPO"',
            "export TAUTLINE_METHODOLOGY_REPO",
            f'MINERVIT_CLAUDE_AUTOCOMPACT_PCT="${{MINERVIT_CLAUDE_AUTOCOMPACT_PCT:-{DEFAULT_CLAUDE_AUTOCOMPACT_PERCENT}}}"',
            'case "$MINERVIT_CLAUDE_AUTOCOMPACT_PCT" in',
            "  ''|*[!0-9]*)",
            f"    MINERVIT_CLAUDE_AUTOCOMPACT_PCT={DEFAULT_CLAUDE_AUTOCOMPACT_PERCENT}",
            "    ;;",
            "esac",
            'TAUTLINE_CLAUDE_AUTOCOMPACT_PCT="$MINERVIT_CLAUDE_AUTOCOMPACT_PCT"',
            "export TAUTLINE_CLAUDE_AUTOCOMPACT_PCT",
            'if [ "$MINERVIT_CLAUDE_AUTOCOMPACT_PCT" -lt 1 ] || [ "$MINERVIT_CLAUDE_AUTOCOMPACT_PCT" -gt 100 ]; then',
            f"  MINERVIT_CLAUDE_AUTOCOMPACT_PCT={DEFAULT_CLAUDE_AUTOCOMPACT_PERCENT}",
            "fi",
            'CLAUDE_AUTOCOMPACT_PCT_OVERRIDE="$MINERVIT_CLAUDE_AUTOCOMPACT_PCT"',
            "export CLAUDE_AUTOCOMPACT_PCT_OVERRIDE",
            "MINERVIT_CLAUDE_AUTOCOMPACT_REQUIRED=1",
            "export MINERVIT_CLAUDE_AUTOCOMPACT_REQUIRED",
            'printf \'%s\\n\' "claude_autocompact_pct_override: $CLAUDE_AUTOCOMPACT_PCT_OVERRIDE"',
            'METHODOLOGY_CLI_PRESET="${MINERVIT_METHODOLOGY_CLI:-}"',
            'METHODOLOGY_CLI="$METHODOLOGY_CLI_PRESET"',
            # The snapshot outranks PATH and the checkout: a launch must run the code the store
            # publishes, not whatever the operator's PATH or a dirty checkout happens to hold.
            'if [ -z "$METHODOLOGY_CLI" ] && [ -n "$SNAPSHOT_STORE" ] && [ -x "$SNAPSHOT_STORE/current/bin/tautline" ]; then',
            '  METHODOLOGY_CLI="$SNAPSHOT_STORE/current/bin/tautline"',
            "fi",
            'if [ -z "$METHODOLOGY_CLI" ]; then',
            '  if command -v tautline >/dev/null 2>&1; then',
            '    METHODOLOGY_CLI="$(command -v tautline)"',
            '  elif command -v minervit-methodology >/dev/null 2>&1; then',
            '    METHODOLOGY_CLI="$(command -v minervit-methodology)"',
            '  elif [ -n "${MINERVIT_METHODOLOGY_REPO:-}" ]; then',
            '    if [ ! -x "$MINERVIT_METHODOLOGY_REPO/bin/tautline" ] && [ ! -x "$MINERVIT_METHODOLOGY_REPO/bin/minervit-methodology" ]; then',
            "      printf '%s\\n' 'methodology env is missing MINERVIT_METHODOLOGY_REPO; rerun <methodology_repo>/bin/tautline install-cli' >&2",
            "      exit 1",
            "    fi",
            '    if [ -x "$MINERVIT_METHODOLOGY_REPO/bin/tautline" ]; then',
            '      METHODOLOGY_CLI="$MINERVIT_METHODOLOGY_REPO/bin/tautline"',
            "    else",
            '      METHODOLOGY_CLI="$MINERVIT_METHODOLOGY_REPO/bin/minervit-methodology"',
            "    fi",
            "  else",
            "    printf '%s\\n' 'methodology CLI not installed; run <methodology_repo>/bin/minervit-methodology install-cli' >&2",
            "    exit 1",
            "  fi",
            "fi",
            # SEVERED (task B9): the canonical checkout is whatever
            # launcher_resolve_methodology_repo returned -- live env preset, then methodology.env,
            # then cwd discovery, then EMPTY. It is never derived from where the CLI happens to
            # live, and never probed out of `version --no-remote`. Under the store the CLI lives in
            # an IMMUTABLE snapshot: both severed paths would name that snapshot as the "repo", and
            # the launcher would then hand a tree with no .git and no origin to the shell
            # auto-rescue and to sync as the thing to fetch/reset. An empty METHODOLOGY_REPO is a
            # legitimate answer here: the auto-rescue no-ops on it, and sync-methodology prints its
            # own remedy naming the missing config.
            'METHODOLOGY_REPO="${MINERVIT_METHODOLOGY_REPO:-}"',
            "METHODOLOGY_INSIDE_REPO=0",
            'if [ -n "$METHODOLOGY_REPO" ] && [ -d "$METHODOLOGY_REPO/.git" ]; then',
            '  repo_real_for_guard="$(cd "$METHODOLOGY_REPO" 2>/dev/null && pwd -P || true)"',
            '  cwd_real_for_guard="$(pwd -P 2>/dev/null || pwd)"',
            '  if [ -n "$repo_real_for_guard" ]; then',
            '    case "$cwd_real_for_guard/" in "$repo_real_for_guard"/*) METHODOLOGY_INSIDE_REPO=1 ;; esac',
            "  fi",
            "fi",
            "launcher_auto_rescue_methodology_repo() {",
            "  repo=$1",
            '  [ -n "$repo" ] || return 0',
            '  [ -d "$repo/.git" ] || return 0',
            '  [ "${MINERVIT_METHODOLOGY_DISABLE_AUTO_RESCUE:-0}" != "1" ] || return 0',
            # Maintainer mode (parsed from the config file at the capture block above, never the
            # ambient environment): this shell rescue is an update gate, and armed update gates
            # stand down before any git mutation -- the same single-choke-point answer
            # update_methodology_repo gives on the Python side.
            '  [ "$LAUNCHER_MAINTAINER_MODE" != "1" ] || return 0',
            '  repo_real="$(cd "$repo" 2>/dev/null && pwd -P || true)"',
            '  [ -n "$repo_real" ] || return 0',
            '  [ "${METHODOLOGY_INSIDE_REPO:-0}" != "1" ] || return 0',
            # sec-trust-exec-1 (Codex P1): this shell rescue advances the checkout (checkout/reset
            # --hard origin/main) with NO pin/signature check -- the same advance-before-verify
            # defect update_methodology_repo had. Under a verifying policy (pinned/signed) recovery
            # is owned by the trust-gated Python rescue inside sync-methodology, which verifies the
            # fetched upstream BEFORE moving the checkout and fails closed on refusal; the shell
            # path must never advance the checkout ungated. Legacy warn/unverified installs keep
            # the existing shell rescue. Do not duplicate trust logic in shell.
            '  launcher_update_policy="${MINERVIT_METHODOLOGY_UPDATE_POLICY:-warn}"',
            '  case "$launcher_update_policy" in',
            "    pinned|signed)",
            '      launcher_dirty_probe="$(git -C "$repo_real" status --porcelain 2>/dev/null || true)"',
            '      launcher_branch_probe="$(git -C "$repo_real" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"',
            '      if [ -n "$launcher_dirty_probe" ] || [ "$launcher_branch_probe" != "main" ]; then',
            '        printf \'%s\\n\' "methodology_update: launcher shell auto-rescue skipped under update policy $launcher_update_policy; sync-methodology performs the trust-gated rescue (verify-before-advance). If sync fails, review upstream and run update-repin, or run repair-methodology-main manually." >&2',
            "      fi",
            "      return 0",
            "      ;;",
            "  esac",
            '  git -C "$repo_real" fetch origin main --quiet >/dev/null 2>&1 || return 0',
            '  remote_head="$(git -C "$repo_real" rev-parse origin/main 2>/dev/null || true)"',
            '  current_head="$(git -C "$repo_real" rev-parse HEAD 2>/dev/null || true)"',
            '  [ -n "$remote_head" ] && [ -n "$current_head" ] || return 0',
            '  dirty="$(git -C "$repo_real" status --porcelain 2>/dev/null || true)"',
            '  current_branch="$(git -C "$repo_real" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"',
            "  LAUNCHER_MAIN_PRESERVE_DETAIL=",
            "  LAUNCHER_PRIVATE_ADAPTER_DETAIL=",
            "  LAUNCHER_PRIVATE_ADAPTER_DIR=",
            "  launcher_preserve_main_before_reset() {",
            '    main_head="$(git -C "$repo_real" rev-parse refs/heads/main 2>/dev/null || true)"',
            '    [ -n "$main_head" ] || return 0',
            '    [ "$main_head" != "$remote_head" ] || return 0',
            "    # Preserve only when local main has commits origin/main does not. A main merely behind",
            "    # (an ancestor of origin/main) has nothing to rescue, so creating a rescue ref is pure",
            "    # pollution that trips rescue-ref drift detection (RCA: non-main auto-rescue phantom ref).",
            '    if git -C "$repo_real" merge-base --is-ancestor refs/heads/main "$remote_head" >/dev/null 2>&1; then',
            "      return 0",
            "    fi",
            '    main_short="$(git -C "$repo_real" rev-parse --short refs/heads/main 2>/dev/null || printf main)"',
            '    main_stamp="$(date -u +%Y%m%dT%H%M%SZ)"',
            '    main_rescue_branch="minervit-local-rescue/${main_stamp}-$$-launcher-main-${main_short}"',
            '    git -C "$repo_real" branch "$main_rescue_branch" refs/heads/main >/dev/null 2>&1 || {',
            "      printf '%s\\n' \"methodology_update: failed - launcher could not preserve prior methodology main in $repo_real\" >&2",
            "      return 1",
            "    }",
            '    LAUNCHER_MAIN_PRESERVE_DETAIL="; preserved prior main at $main_rescue_branch"',
            "  }",
            "  launcher_preserve_private_adapters_before_reset() {",
            '    public_example_adapter="example""-saas"',
            '    private_paths="$(git -C "$repo_real" ls-files adapters/projects/*.json 2>/dev/null | grep -Ev "^adapters/projects/(${public_example_adapter}|\\.bootstrap-legacy-allowlist)\\.json$" || true)"',
            '    [ -n "$private_paths" ] || return 0',
            '    rescue_root="${MINERVIT_METHODOLOGY_RESCUE_STATE_DIR:-$HOME/.local/state/minervit/methodology-rescue}"',
            '    adapter_stamp="$(date -u +%Y%m%dT%H%M%SZ)"',
            '    LAUNCHER_PRIVATE_ADAPTER_DIR="$rescue_root/private-adapters/${adapter_stamp}-$$-launcher"',
            '    printf "%s\\n" "$private_paths" | while IFS= read -r rel; do',
            '      [ -n "$rel" ] || continue',
            '      [ -f "$repo_real/$rel" ] || continue',
            '      mkdir -p "$LAUNCHER_PRIVATE_ADAPTER_DIR/$(dirname "$rel")" || exit 1',
            '      cp -p "$repo_real/$rel" "$LAUNCHER_PRIVATE_ADAPTER_DIR/$rel" || exit 1',
            "    done || {",
            "      printf '%s\\n' \"methodology_update: failed - launcher could not preserve private source adapters in $repo_real\" >&2",
            "      return 1",
            "    }",
            '    LAUNCHER_PRIVATE_ADAPTER_DETAIL="; preserved private source adapters at $LAUNCHER_PRIVATE_ADAPTER_DIR"',
            "  }",
            "  launcher_restore_private_adapters_after_reset() {",
            '    [ -n "$LAUNCHER_PRIVATE_ADAPTER_DIR" ] || return 0',
            '    [ -d "$LAUNCHER_PRIVATE_ADAPTER_DIR/adapters/projects" ] || return 0',
            '    find "$LAUNCHER_PRIVATE_ADAPTER_DIR/adapters/projects" -type f -name "*.json" | while IFS= read -r source; do',
            '      rel="${source#$LAUNCHER_PRIVATE_ADAPTER_DIR/}"',
            '      [ -n "$rel" ] || continue',
            '      [ ! -e "$repo_real/$rel" ] || continue',
            '      mkdir -p "$repo_real/$(dirname "$rel")" || exit 1',
            '      cp -p "$source" "$repo_real/$rel" || exit 1',
            "    done || {",
            "      printf '%s\\n' \"methodology_update: failed - launcher could not restore private source adapters in $repo_real from $LAUNCHER_PRIVATE_ADAPTER_DIR\" >&2",
            "      return 1",
            "    }",
            "  }",
            '  if [ -z "$dirty" ] && [ "$current_branch" != "main" ] && [ "$current_branch" != "HEAD" ]; then',
            "    launcher_preserve_main_before_reset || return 1",
            "    launcher_preserve_private_adapters_before_reset || return 1",
            '    git -C "$repo_real" checkout main >/dev/null 2>&1 || git -C "$repo_real" checkout -b main origin/main >/dev/null 2>&1 || {',
            "      printf '%s\\n' \"methodology_update: failed - launcher could not switch methodology checkout to main in $repo_real\" >&2",
            "      return 1",
            "    }",
            '    git -C "$repo_real" reset --hard origin/main >/dev/null 2>&1 || {',
            "      printf '%s\\n' \"methodology_update: failed - launcher could not reset methodology main to origin/main in $repo_real\" >&2",
            "      return 1",
            "    }",
            "    launcher_restore_private_adapters_after_reset || return 1",
            "    printf '%s\\n' \"methodology_update: launcher_auto_rescue - switched clean methodology checkout from non-release branch $current_branch to main at origin/main$LAUNCHER_MAIN_PRESERVE_DETAIL$LAUNCHER_PRIVATE_ADAPTER_DETAIL\"",
            "    return 0",
            "  fi",
            '  [ "$current_head" != "$remote_head" ] || return 0',
            '  needs_rescue=0',
            '  [ -n "$dirty" ] && needs_rescue=1',
            '  [ "$current_branch" != "main" ] && needs_rescue=1',
            '  if ! git -C "$repo_real" merge-base --is-ancestor "$current_head" "$remote_head" >/dev/null 2>&1; then',
            '    needs_rescue=1',
            "  fi",
            '  [ "$needs_rescue" = "1" ] || return 0',
            '  stamp="$(date -u +%Y%m%dT%H%M%SZ)"',
            '  rescue_branch="minervit-local-rescue/${stamp}-$$-launcher"',
            '  if ! git -C "$repo_real" checkout -b "$rescue_branch" >/dev/null 2>&1; then',
            "    printf '%s\\n' \"methodology_update: failed - launcher could not create rescue branch in $repo_real\" >&2",
            "    return 1",
            "  fi",
            '  git -C "$repo_real" add -A >/dev/null 2>&1 || {',
            "    printf '%s\\n' \"methodology_update: failed - launcher could not stage local methodology changes in $repo_real\" >&2",
            "    return 1",
            "  }",
            '  if ! git -C "$repo_real" diff --cached --quiet --exit-code >/dev/null 2>&1; then',
            '    git -C "$repo_real" -c user.name="Minervit Launcher" -c user.email="launcher@minervit.invalid" commit -m "rescue local methodology changes before launcher sync" >/dev/null 2>&1 || {',
            "      printf '%s\\n' \"methodology_update: failed - launcher could not commit rescued local methodology changes in $repo_real; changes are staged on $rescue_branch\" >&2",
            "      return 1",
            "    }",
            "  fi",
            "  launcher_preserve_main_before_reset || return 1",
            "  launcher_preserve_private_adapters_before_reset || return 1",
            '  git -C "$repo_real" checkout main >/dev/null 2>&1 || git -C "$repo_real" checkout -b main origin/main >/dev/null 2>&1 || {',
            "    printf '%s\\n' \"methodology_update: failed - launcher could not switch methodology checkout to main in $repo_real\" >&2",
            "    return 1",
            "  }",
            '  git -C "$repo_real" reset --hard origin/main >/dev/null 2>&1 || {',
            "    printf '%s\\n' \"methodology_update: failed - launcher could not reset methodology main to origin/main in $repo_real\" >&2",
            "    return 1",
            "  }",
            "  launcher_restore_private_adapters_after_reset || return 1",
            "  printf '%s\\n' \"methodology_update: launcher_auto_rescue - preserved local methodology checkout on $rescue_branch and reset main to origin/main$LAUNCHER_MAIN_PRESERVE_DETAIL$LAUNCHER_PRIVATE_ADAPTER_DETAIL\"",
            "}",
            "# No dead ends (operator directive 2026-07-11): every gate failure prints its exact",
            "# remedy, and an interactive launch starts a Claude repair session instead of stranding",
            "# the operator. Set TAUTLINE_NO_REPAIR_SESSION=1 (or MINERVIT_NO_REPAIR_SESSION=1) to",
            "# print the remedy and exit 1 without the repair session.",
            "launcher_gate_repair() {",
            '  stage="$1"',
            '  remedy="$2"',
            "  printf '%s\\n' \"launch gate failed: $stage\" >&2",
            "  printf '%s\\n' \"remedy: $remedy\" >&2",
            '  no_repair="${TAUTLINE_NO_REPAIR_SESSION:-${MINERVIT_NO_REPAIR_SESSION:-0}}"',
            '  if [ -t 0 ] && [ -t 1 ] && [ "$no_repair" != "1" ]; then',
            "    printf '%s\\n' 'starting a Claude repair session (set TAUTLINE_NO_REPAIR_SESSION=1 to disable)...' >&2",
            f'    exec claude{claude_args} "Launch gate \'$stage\' failed on this machine. Fix it now: $remedy Re-run the failed command until it exits 0, then tell the operator to relaunch. Do not bypass gates: --no-verify pushes and gate relaxation are operator break-glass decisions - if one is genuinely required, declare a blocker (blocker-declare) and stop with a report."',
            "  fi",
            "  exit 1",
            "}",
            "# The remedy defers to sync-methodology's own failure-specific guidance (pin holds name",
            "# update-repin, signed-policy rejections name signature repair per SECURITY.md, rescue",
            "# failures name the wedged state) -- a static repin hint would mislead non-pin failures",
            "# and could loop the repair session (Codex 0.9.1 R1 P2).",
            'SYNC_REMEDY="in $METHODOLOGY_REPO run \'tautline sync-methodology --auto-rescue-local-changes\' and follow the failure-specific remedy it prints alongside the error; if the same remedy fails twice, stop and declare a blocker with the exact output instead of retrying."',
            'if [ -z "$METHODOLOGY_CLI_PRESET" ]; then',
            'launcher_auto_rescue_methodology_repo "$METHODOLOGY_REPO" || launcher_gate_repair "methodology auto-rescue" "$SYNC_REMEDY"',
            "fi",
            # --launcher-gate on EVERY variant: a launch storm (one lane per terminal tab) would
            # otherwise run one fetch/merge/materialize per lane against a single canonical
            # checkout. The gate serializes them on a flock and skips the sync outright inside the
            # freshness window.
            'if [ "${METHODOLOGY_INSIDE_REPO:-0}" = "1" ]; then',
            '  "$METHODOLOGY_CLI" sync-methodology --launcher-gate || launcher_gate_repair "methodology sync" "$SYNC_REMEDY"',
            'elif [ "${MINERVIT_METHODOLOGY_DISABLE_AUTO_RESCUE:-0}" = "1" ]; then',
            '  "$METHODOLOGY_CLI" sync-methodology --no-auto-rescue-local-changes --launcher-gate || launcher_gate_repair "methodology sync" "$SYNC_REMEDY"',
            "else",
            '  "$METHODOLOGY_CLI" sync-methodology --auto-rescue-local-changes --launcher-gate || launcher_gate_repair "methodology sync" "$SYNC_REMEDY"',
            "fi",
            # AFTER the sync, because the sync is what publishes a new snapshot and swaps `current`.
            # Resolve it ONCE (pwd -P, not the symlink path) and export it: from here on this
            # session -- the launcher's own CLI calls, Claude, and every hook Claude spawns -- is
            # PINNED to this exact <sha12>. Following the symlink instead would let another lane's
            # sync hop this session between Tautline versions between two hook invocations.
            'SNAPSHOT_EXEC_ROOT=""',
            'if [ -n "$SNAPSHOT_STORE" ] && [ -x "$SNAPSHOT_STORE/current/bin/tautline" ]; then',
            '  SNAPSHOT_EXEC_ROOT="$(cd "$SNAPSHOT_STORE/current" 2>/dev/null && pwd -P || true)"',
            "fi",
            'if [ -n "$SNAPSHOT_EXEC_ROOT" ]; then',
            '  MINERVIT_METHODOLOGY_EXEC_ROOT="$SNAPSHOT_EXEC_ROOT"',
            "  export MINERVIT_METHODOLOGY_EXEC_ROOT",
            # An explicit MINERVIT_METHODOLOGY_CLI stays the CLI: it is the operator's deliberate
            # "run THIS binary" override, and the store must not quietly outrank it.
            '  if [ -z "$METHODOLOGY_CLI_PRESET" ]; then',
            '    METHODOLOGY_CLI="$MINERVIT_METHODOLOGY_EXEC_ROOT/bin/tautline"',
            "  fi",
            'elif [ -n "$SNAPSHOT_STORE" ]; then',
            # Snapshot mode is ON but the store cannot serve this session (deleted, pruned, or a
            # dangling `current`). Say so once, export nothing, and run the canonical checkout: a
            # silent fallback here is how a fleet ends up half-executing snapshots without knowing.
            "  SNAPSHOT_WARN='methodology_snapshot: store missing or broken; executing from'",
            '  SNAPSHOT_WARN="$SNAPSHOT_WARN canonical checkout $METHODOLOGY_REPO"',
            "  SNAPSHOT_WARN=\"$SNAPSHOT_WARN (run 'tautline sync-methodology' to rebuild)\"",
            "  printf '%s\\n' \"$SNAPSHOT_WARN\" >&2",
            "fi",
            'LANE_TARGET=""',
            'SEARCH_DIR="$(pwd -P 2>/dev/null || pwd)"',
            'while [ -n "$SEARCH_DIR" ]; do',
            '  if [ -f "$SEARCH_DIR/.tautline.json" ] || [ -f "$SEARCH_DIR/.minervit-ai-delivery.json" ]; then',
            '    LANE_TARGET="$SEARCH_DIR"',
            "    break",
            "  fi",
            '  if [ "$SEARCH_DIR" = "/" ]; then',
            "    break",
            "  fi",
            '  NEXT_DIR="$(dirname "$SEARCH_DIR")"',
            '  if [ "$NEXT_DIR" = "$SEARCH_DIR" ]; then',
            "    break",
            "  fi",
            '  SEARCH_DIR="$NEXT_DIR"',
            "done",
            # Declare "this lane is executing that snapshot" BEFORE any real work starts, so
            # retention cannot collect the code this session is running. Non-fatal by construction:
            # a disabled store, an empty store, or an unwritable pins dir is a note, never a failed
            # launch (snapshot-pin exits 0 in all of those cases; `|| true` covers the rest).
            '"$METHODOLOGY_CLI" snapshot-pin --target "${LANE_TARGET:-$PWD}" || true',
            'if [ -n "$LANE_TARGET" ]; then',
            '  "$METHODOLOGY_CLI" lane-start --target "$LANE_TARGET" --defer-debt-preflights || launcher_gate_repair "lane-start" "run \'tautline lane-start --target $LANE_TARGET --defer-debt-preflights\', read the printed failure, and fix exactly what it names."',
            '  "$METHODOLOGY_CLI" methodology-status --target "$LANE_TARGET" --fail-on-drift --enter-remediation-on-debt',
            '  METHODOLOGY_STATUS_EXIT=$?',
            '  if [ "$METHODOLOGY_STATUS_EXIT" = "2" ]; then',
            "    REMEDIATION_PROMPT=\"Startup remediation required for this lane. Run 'tautline methodology-status --target $LANE_TARGET --fail-on-drift', fix every printed issue, and rerun until it exits 0 before any project work. Do not bypass gates: --no-verify pushes and gate relaxation are operator break-glass decisions - if one is genuinely required, declare a blocker (blocker-declare) and stop with a report.\"",
            f'    exec claude{claude_args} "$REMEDIATION_PROMPT"',
            '  elif [ "$METHODOLOGY_STATUS_EXIT" != "0" ]; then',
            '    launcher_gate_repair "methodology-status integrity" "run \'tautline methodology-status --target $LANE_TARGET --fail-on-drift\', read the methodology_status_blocking line, and fix exactly what it names."',
            "  fi",
            "fi",
            'GOAL_TARGET="${LANE_TARGET:-.}"',
            'if [ "${MINERVIT_SHOW_GOAL_PROMPT:-1}" != "0" ] && [ -t 0 ] && [ -t 1 ]; then',
            "  printf '\\n'",
            '  if "$METHODOLOGY_CLI" goal-kickoff-prompt --target "$GOAL_TARGET"; then',
            "    printf '\\n%s' 'Copy the prompt above if you want goal-led startup, then press Return to start Claude...'",
            "    IFS= read -r _",
            "  else",
            "    printf '%s\\n' 'goal kickoff prompt failed; starting Claude anyway after sync' >&2",
            "  fi",
            "  printf '\\n'",
            "fi",
            f'exec claude{claude_args} "$@"',
            "",
        ]
    )


def install_claude_launcher(args: argparse.Namespace) -> int:
    forbidden_names = {"", ".", "..", "claude", "tautline", "minervit-methodology"}
    if "/" in args.name or args.name in forbidden_names:
        raise SystemExit("launcher name must be a command name such as tautline-claude or yolo, not a path or reserved command")
    # sec-trust-exec-1: --dangerously-skip-permissions widens the blast radius of any upstream
    # compromise, so it is only allowed against a verifiable upstream (pinned or signed). Refuse to
    # bake it into a launcher while the effective policy is warn/unverified.
    if args.dangerously_skip_permissions:
        policy = effective_methodology_update_policy()
        if policy not in ("pinned", "signed"):
            raise SystemExit(
                f"install-claude-launcher: refusing --dangerously-skip-permissions while methodology update policy "
                f"is '{policy}'. Set {METHODOLOGY_UPDATE_POLICY_ENV}=pinned or =signed (install-cli pins by default) "
                "before enabling skip-permissions. See SECURITY.md."
            )
    bin_dir = args.bin_dir.expanduser().resolve()
    bin_dir.mkdir(parents=True, exist_ok=True)
    launcher = bin_dir / args.name
    if launcher.exists() and not args.force:
        existing = launcher.read_text(encoding="utf-8", errors="replace")
        if "Generated by tautline install-claude-launcher." not in existing:
            raise SystemExit(f"launcher exists and is not methodology-generated: {launcher}. Use --force to replace it.")
    write_text_executable(launcher, claude_launcher_content(args.dangerously_skip_permissions))
    record = record_installed_launcher(launcher)
    settings_path, settings_ok = write_claude_autocompact_settings(Path.home() / ".claude" / "settings.json")
    release_guard = install_methodology_release_guards()
    print(f"installed_claude_launcher: {launcher}")
    print(f"launcher_command: {args.name}")
    print(f"launcher_template_version: {LAUNCHER_TEMPLATE_VERSION}")
    if record is None:
        print(f"launcher_record: unavailable - could not write {installed_launchers_path()}")
    else:
        print(f"launcher_record: {record}")
    print("launcher_goal_prompt: enabled unless MINERVIT_SHOW_GOAL_PROMPT=0")
    print("launcher_lane_start: enabled when .minervit-ai-delivery.json is found in the current directory or an ancestor")
    print(f"launcher_claude_autocompact_pct_override: {DEFAULT_CLAUDE_AUTOCOMPACT_PERCENT}")
    print(f"launcher_claude_autocompact_settings: {'already ok' if settings_ok else 'installed'} {settings_path}")
    print(f"methodology_release_guard: {release_guard}")
    print(f"launcher_permissions: {'dangerously-skip-permissions' if args.dangerously_skip_permissions else 'default claude permissions'}")
    print("shell_setup: ensure the launcher directory is on PATH; install-cli writes this to methodology.env")
    return 0


def sync_methodology(args: argparse.Namespace) -> int:
    """Sync the methodology checkout; under --launcher-gate, do it once per freshness window.

    The gate wraps the ENTIRE body -- update, guards, heal, stamp, pin -- because every one of
    those touches state shared by all lanes on this machine.
    """
    if maintainer_mode_armed():
        # BEFORE the launcher-gate skip check, so EVERY launch shows it -- the mode must never
        # be ambient (and the armed path never takes that skip anyway, per the MS-R1-P1-1 guard
        # below). stderr, like the cutover banner: loud advisories go to stderr while the
        # machine-readable stdout report keeps its stock shape.
        for line in maintainer_mode_banner_lines():
            print(line, file=sys.stderr)
    elif maintainer_mode_configured():
        # The key is set but there is nothing to manage: say so once, then proceed stock -- a
        # silently-inert key would leave the operator believing the machine is armed.
        print(maintainer_mode_status_line(), file=sys.stderr)
    # Every sync is a lane start, so this is where the half-converted machine is observable: the
    # store is enabled (install-cli ran) but a pre-cutover launcher is still starting sessions
    # that are UNPINNED against `current`. Warn on every sync until the operator closes it.
    if methodology_snapshot_store_enabled():
        warn_unpinned_launcher_window(USER_BIN_DIR)
    # The recorded-launcher check catches what the USER_BIN_DIR scan above cannot: a launcher the
    # operator installed with --bin-dir somewhere else, and -- via the deliberate None/[] split --
    # a machine whose launchers all predate the recorder entirely.
    launcher_status = launcher_template_status_line()
    if launcher_status:
        print(launcher_status)
    if not getattr(args, "launcher_gate", False):
        return _sync_methodology_body(args)[0]
    lane = Path(getattr(args, "target", None) or ".").expanduser().resolve(strict=False)
    with launcher_sync_gate():
        # The freshness skip exists to avoid the expensive network sync; the ARMED body is offline
        # and cheap, and taking the skip would bypass heal -- a commit landed inside the freshness
        # window would miss the next launch (MS-R1-P1-1). So an armed launch always runs the full
        # standdown body. The stamp is still WRITTEN below when armed, so `maintainer-mode off`
        # leaves stock skip behavior exactly as a stock sync would have left it.
        skip = None if maintainer_mode_armed() else launcher_gate_skip_reason()
        if skip is not None:
            print(f"methodology_update: skipped - {skip}")
            print(f"plugin_version: {plugin_version()}")
            print(f"methodology_commit: {running_methodology_commit(short=True)}")
            print(methodology_canonical_commit_line())
            print(methodology_exec_root_line())
            if not args.no_remote:
                print(f"remote_status: {remote_methodology_status(False)}")
            # Still a live lane start: the pin is a heartbeat, and skipping the sync must not let
            # prune collect the snapshot this session is about to execute.
            refresh_methodology_snapshot_pin(lane)
            return 0
        code, status = _sync_methodology_body(args)
        if status is not None and status != "failed":
            # A failed sync must never tell the next lane the methodology is fresh. Neither must a
            # framework-pin refusal (status None): that path never touched the methodology repo at
            # all, and stamping it would let one lane's WIP hold starve every other lane's sync.
            current = methodology_snapshot_current_dir()
            write_methodology_sync_stamp(
                run_git(canonical_methodology_repo(), ["rev-parse", "HEAD"]),
                current.name if current else "",
                status,
            )
        refresh_methodology_snapshot_pin(lane)
        return code


def _sync_methodology_body(args: argparse.Namespace) -> tuple[int, str | None]:
    """The sync itself. Returns the exit code and the update status.

    The status is None when the framework pin declined the update before the methodology repo was
    touched at all -- the caller must not stamp that as a sync.
    """
    framework_pin = normalize_framework_pin(None)
    if getattr(args, "target", None) is not None:
        data, _project_path, target = lane_project(args)
        framework_pin, framework_pin_source = effective_framework_pin(data, target)
        framework_decision = framework_update_decision(
            framework_pin,
            data,
            target,
            args.skip_update,
            manual_request=True,
        )
        print(framework_pin_status_line(framework_pin, framework_pin_source))
        print(
            "framework_update_available: "
            f"version={framework_decision.get('availableVersion', plugin_version())} "
            f"change={framework_decision.get('changeKind', 'unknown')}"
        )
        for reason in framework_decision.get("wipReasons", []):
            print(f"framework_wip: {reason}")
        if framework_decision["action"] != "update":
            print(f"methodology_update: skipped - {framework_decision['reason']}")
            print(f"plugin_version: {plugin_version()}")
            print(f"methodology_commit: {running_methodology_commit(short=True)}")
            print(methodology_canonical_commit_line())
            print(methodology_exec_root_line())
            if not args.no_remote:
                if maintainer_mode_armed():
                    # Remote-probe standdown -- same rationale as the sync tail below.
                    print("remote_status: skipped - maintainer mode")
                else:
                    print(f"remote_status: {remote_methodology_status(False)}")
            return 0, None
    explicit_auto_rescue = getattr(args, "auto_rescue_local_changes", False)
    default_auto_rescue = False
    if not getattr(args, "no_auto_rescue_local_changes", False):
        default_auto_rescue = should_auto_rescue_methodology_for_project_startup()
    auto_rescue = explicit_auto_rescue or default_auto_rescue
    status, detail = update_methodology_repo(
        args.skip_update,
        getattr(args, "allow_non_main", False),
        auto_rescue,
        auto_rescue_stale_only=default_auto_rescue and not explicit_auto_rescue,
        channel=framework_pin["channel"],
    )
    print(f"methodology_update: {status} - {detail}")
    if status == "held":
        # The hold detail printed above carries the ACTIVE policy's remedy (pinned -> update-repin,
        # signed -> signature repair); do not restate a policy-specific command here (Codex R2 P2).
        print(
            "methodology_update_held: launch continues on the trusted retained checkout; "
            "to advance, follow the remedy in the hold message above"
        )
    if status != "failed":
        # A trust-gated advance never returns here (it re-execs), so reaching this line means no
        # advance happened this run -- exactly when a missing/dangling `current` would otherwise
        # persist until the next upstream commit. Bootstrap and self-repair both land here.
        heal_methodology_snapshot_current(framework_pin["channel"])
    print(f"plugin_version: {plugin_version()}")
    print(f"methodology_commit: {running_methodology_commit(short=True)}")
    # sync mutates the CANONICAL checkout while this process keeps running the snapshot it was
    # launched from: report both, or an advance looks like a no-op until the next launch swaps in.
    print(methodology_canonical_commit_line())
    print(methodology_exec_root_line())
    if status != "failed":
        print(f"methodology_release_guard: {install_methodology_release_guards()}")
    if not args.no_remote:
        if maintainer_mode_armed():
            # Launch-time remote-probe standdown: a launch must not depend on the network while
            # update gates are off (the probe ls-remotes the origin), and the literal line keeps
            # armed output deterministic. The launcher-gate stamp-skip branch needs no probe
            # treatment -- it is unreachable when armed (sync_methodology's MS-R1-P1-1 guard).
            print("remote_status: skipped - maintainer mode")
        else:
            print(f"remote_status: {remote_methodology_status(False)}")
    return (0 if status != "failed" else 1), status


def repair_methodology_main(args: argparse.Namespace) -> int:
    repaired, detail = repair_methodology_release_main_checkout()
    print(f"methodology_main_repair: {'ok' if repaired else 'failed'} - {detail}")
    print(f"plugin_version: {plugin_version()}")
    print(f"methodology_commit: {running_methodology_commit(short=True)}")
    if repaired:
        print(f"methodology_release_guard: {install_methodology_release_guards()}")
    if not args.no_remote:
        print(f"remote_status: {remote_methodology_status(False)}")
    return 0 if repaired else 1


def implementation_review_enabled(data: dict) -> bool:
    return bool(data.get("review", {}).get("prePushReviewEvidence", DEFAULT_REVIEW["prePushReviewEvidence"]))


def implementation_review_evidence_dir(target: Path) -> Path:
    return target / IMPLEMENTATION_REVIEW_DIR


def implementation_stage1_sweep_dir(target: Path) -> Path:
    return implementation_review_evidence_dir(target) / "stage1-sweeps"


def implementation_review_ledger_dir(data: dict, target: Path) -> Path:
    return planning_source_root(data, target) / ".impl-reviews"


def implementation_review_ledger_path(data: dict, target: Path, state: dict) -> Path:
    return implementation_review_ledger_dir(data, target) / f"{slugify(state['branch'], 'branch')}.json"


def implementation_review_round_number(round_marker: str | None) -> int | None:
    if not round_marker:
        return None
    match = re.search(r"(\d+)", str(round_marker))
    if not match:
        return None
    try:
        return int(match.group(1))
    except ValueError:
        return None


def implementation_review_round_budget(data: dict, risk_tier: str | None) -> int | None:
    if not risk_tier:
        return None
    budgets = data.get("review", {}).get("roundBudgets", DEFAULT_REVIEW["roundBudgets"])
    try:
        return int(budgets[str(risk_tier)])
    except (KeyError, TypeError, ValueError):
        return None


def implementation_stage1_required_for_risk_tier(risk_tier: str | None) -> bool:
    """Fail closed for old wrappers: only an explicit T1 review skips Stage 1 sweep evidence."""
    return str(risk_tier or "").strip() not in {"T1"}


def command_string(command: list[str]) -> str:
    return util_module().command_string(command)


def review_wrapper_parts(data: dict) -> list[str]:
    wrapper = str(data.get("review", {}).get("codexWrapper", "")).strip()
    if not wrapper or wrapper.startswith(BOOTSTRAP_REQUIRED_PREFIX):
        return []
    try:
        return shlex.split(wrapper)
    except ValueError:
        return wrapper.split()


def command_matches_codex_wrapper(data: dict, command: list[str]) -> bool:
    wrapper = review_wrapper_parts(data)
    return bool(wrapper) and command[: len(wrapper)] == wrapper


def review_wrapper_base(data: dict) -> str | None:
    parts = review_wrapper_parts(data)
    for index, part in enumerate(parts):
        if part == "--base" and index + 1 < len(parts):
            value = parts[index + 1].strip()
            return value or None
        if part.startswith("--base="):
            value = part.split("=", 1)[1].strip()
            return value or None
    return None


def implementation_review_effective_base(data: dict, explicit_base: str | None = None) -> str | None:
    return str(explicit_base).strip() if explicit_base else review_wrapper_base(data)


def implementation_review_allowed_override_base(ref: str) -> bool:
    value = str(ref or "").strip()
    return bool(value in IMPLEMENTATION_REVIEW_ORIGIN_BASES or re.fullmatch(r"(?:origin|upstream)/[A-Za-z0-9._/-]+", value))


def implementation_review_allowed_base_refs(data: dict, explicit_base: str | None = None) -> set[str]:
    allowed = set(IMPLEMENTATION_REVIEW_ORIGIN_BASES)
    wrapper_base = review_wrapper_base(data)
    if wrapper_base and implementation_review_allowed_override_base(wrapper_base):
        allowed.add(wrapper_base)
    if explicit_base and implementation_review_allowed_override_base(str(explicit_base)):
        allowed.add(str(explicit_base).strip())
    return allowed


def implementation_review_hint_base(allowed_bases: set[str], effective_base: str | None = None) -> str:
    candidate = str(effective_base or "").strip()
    if candidate in allowed_bases and implementation_review_allowed_override_base(candidate):
        return candidate
    for preferred in ("origin/main", "origin/experimental", "upstream/main"):
        if preferred in allowed_bases:
            return preferred
    return sorted(allowed_bases)[0] if allowed_bases else "origin/main"


CLAUDE_REVIEW_PACKET_WARN_BYTES = 30000


def claude_review_git_status(target: Path) -> list[tuple[str, str]]:
    code, _stdout, _stderr = run_command(["git", "-C", str(target), "rev-parse", "--is-inside-work-tree"], timeout=10)
    if code != 0:
        return []
    code, stdout, _stderr = run_command(
        ["git", "-C", str(target), "status", "--porcelain=v1", "--untracked-files=all"],
        timeout=10,
    )
    if code != 0:
        return []
    entries: list[tuple[str, str]] = []
    for line in stdout.splitlines():
        if not line:
            continue
        status = line[:2]
        path = line[3:] if len(line) > 3 else ""
        if " -> " in path:
            path = path.split(" -> ", 1)[1]
        if path:
            entries.append((status, path))
    return entries


def claude_review_staged_name_status(target: Path) -> list[tuple[str, str]]:
    code, stdout, _stderr = run_command(["git", "-C", str(target), "diff", "--cached", "--name-status"], timeout=10)
    if code != 0:
        return []
    entries: list[tuple[str, str]] = []
    for line in stdout.splitlines():
        parts = line.split("\t")
        if len(parts) >= 2:
            entries.append((parts[0], parts[-1]))
    return entries


def claude_review_staged_blob_text(target: Path, path: str) -> str | None:
    code, stdout, _stderr = run_command(["git", "-C", str(target), "show", f":{path}"], timeout=10)
    if code != 0:
        return None
    return stdout


def claude_review_added_diff_content_lines(blob_text: str) -> list[str]:
    lines: list[str] = []
    for line in blob_text.replace("\r\n", "\n").replace("\r", "\n").splitlines():
        if not line.strip():
            continue
        lines.append(line)
    return lines


def claude_review_packet_diff_block(packet_text: str, path: str) -> str:
    normalized = packet_text.replace("\r\n", "\n").replace("\r", "\n")
    diff_header = f"diff --git a/{path} b/{path}"
    start = normalized.find(diff_header)
    if start < 0:
        return ""
    next_start = normalized.find("\ndiff --git ", start + len(diff_header))
    if next_start < 0:
        return normalized[start:]
    return normalized[start:next_start]


def claude_review_packet_has_diff_header(packet_text: str, path: str) -> bool:
    normalized = packet_text.replace("\r\n", "\n").replace("\r", "\n")
    escaped = re.escape(path)
    return bool(
        re.search(rf"^diff --git a/{escaped} b/.+$", normalized, re.MULTILINE)
        or re.search(rf"^diff --git a/.+ b/{escaped}$", normalized, re.MULTILINE)
    )


def claude_review_is_reference_policy_path(path: str) -> bool:
    normalized = path.replace("\\", "/")
    return normalized.endswith(".md") and ("/references/" in normalized or normalized.startswith("references/"))


def claude_review_packet_mentions_stage1_native(packet_text: str) -> bool:
    return bool(re.search(r"stage\s*1[^\n]{0,80}native\s+review", packet_text, re.IGNORECASE))


def claude_review_packet_mentions_stage1_sweep(packet_text: str) -> bool:
    return bool(re.search(r"stage\s*1[^\n]{0,80}sweep", packet_text, re.IGNORECASE))


def claude_review_packet_mentions_verification(packet_text: str) -> bool:
    return bool(re.search(r"^##\s+.*(?:verification|tests?|validation)", packet_text, re.IGNORECASE | re.MULTILINE))


def claude_review_staged_launcher_diff(target: Path) -> str:
    code, stdout, _stderr = run_command(
        ["git", "-C", str(target), "diff", "--cached", "--", "bin/minervit-methodology", "tests/test_claude_review_launcher.py"],
        timeout=10,
    )
    if code != 0:
        return ""
    return stdout


def claude_review_staged_launcher_change_requires_live_probe(target: Path) -> bool:
    diff_text = claude_review_staged_launcher_diff(target)
    if not diff_text:
        return False
    return any(token in diff_text for token in ["claude_review", "claude-review", "CLAUDE_REVIEW"])


def claude_review_packet_has_live_probe(packet_text: str) -> bool:
    return "claude_review_output:" in packet_text and "claude_review_verdict: no_blockers" in packet_text


def claude_review_packet_preflight(target: Path, packet_text: str) -> tuple[list[str], list[str]]:
    errors: list[str] = []
    warnings: list[str] = []
    packet_bytes = len(packet_text.encode("utf-8"))
    if packet_bytes > CLAUDE_REVIEW_PACKET_WARN_BYTES:
        warnings.append(
            f"packet is {packet_bytes} bytes; split the review or use tighter exact excerpts if Claude times out"
        )

    status_entries = claude_review_git_status(target)
    if not status_entries:
        return errors, warnings

    staged_entries = claude_review_staged_name_status(target)
    if staged_entries and "## Git Status" not in packet_text:
        errors.append("packet must include a `## Git Status` section for a Git-backed review")
    if staged_entries and "diff --git " not in packet_text and "git diff --cached" not in packet_text:
        errors.append("packet must include staged/outgoing diff evidence before invoking Claude")
    if staged_entries and not claude_review_packet_mentions_stage1_native(packet_text):
        errors.append("packet must include Stage 1 native review evidence for the exact staged diff")
    if staged_entries and not claude_review_packet_mentions_stage1_sweep(packet_text):
        errors.append("packet must include Stage 1 sweep evidence for the exact staged diff")
    if staged_entries and not claude_review_packet_mentions_verification(packet_text):
        errors.append("packet must include test or validation evidence before invoking Claude")
    for _status, path in staged_entries:
        if not claude_review_packet_has_diff_header(packet_text, path):
            errors.append(f"staged file `{path}` needs its diff header in the review packet")
    if staged_entries and claude_review_staged_launcher_change_requires_live_probe(target):
        if not claude_review_packet_has_live_probe(packet_text):
            errors.append(
                "Claude-review launcher changes must include a live Claude review probe "
                "(`claude_review_output:` and `claude_review_verdict: no_blockers`) in the packet"
            )

    cited_reference_paths = {
        match.group(0).strip("`'\"),.;:")
        for match in re.finditer(r"[\w./-]*references/[\w./-]+\.md", packet_text)
    }
    for status, path in status_entries:
        if status == "??" and claude_review_is_reference_policy_path(path) and (
            path in packet_text or any(path.endswith(cited) for cited in cited_reference_paths)
        ):
            errors.append(
                f"packet cites untracked reference file `{path}`; stage it or remove it from the reviewed change"
            )

    for status, path in staged_entries:
        if not status.startswith("A") or not claude_review_is_reference_policy_path(path):
            continue
        diff_header = f"diff --git a/{path} b/{path}"
        if path not in packet_text:
            errors.append(f"staged added reference file `{path}` is absent from the review packet")
        elif diff_header not in packet_text:
            errors.append(
                f"staged added reference file `{path}` needs staged added-file diff evidence in the packet"
            )
        else:
            blob_text = claude_review_staged_blob_text(target, path)
            if blob_text is None:
                errors.append(f"could not read staged added reference file `{path}` from the Git index")
                continue
            diff_block = claude_review_packet_diff_block(packet_text, path)
            required_lines = claude_review_added_diff_content_lines(blob_text)
            diff_lines = set(diff_block.splitlines())
            missing_lines = [line for line in required_lines if f"+{line}" not in diff_lines]
            if missing_lines:
                errors.append(
                    f"staged added reference file `{path}` needs staged added-file diff content in the packet"
                )
    return errors, warnings


def claude_review_prompt(packet_text: str, extra_instruction: str = "") -> str:
    boundary = "MINERVIT_REVIEW_PACKET_" + hashlib.sha256(packet_text.encode("utf-8")).hexdigest()[:16]
    result_shape = {
        "verdict": "no_blockers",
        "blockers": [],
        "non_blocking": [],
        "reviewed_evidence": "one sentence naming the packet evidence reviewed",
    }
    extra = f"\nAdditional review instruction:\n{extra_instruction.strip()}\n" if extra_instruction.strip() else ""
    return (
        "You are the Stage 2 Claude reviewer for a Minervit implementation/process change.\n"
        "Do not use tools. Do not ask to run tools. Do not propose shell commands.\n"
        "Review only the evidence packet below. If the packet is missing evidence needed to decide "
        "whether a Critical or P1 blocker exists, return exactly one blocker naming that missing evidence.\n"
        "Report only Critical or P1 blockers in blockers. Put lower-severity observations in non_blocking.\n"
        "Return valid JSON only, with this shape:\n"
        f"{json.dumps(result_shape, sort_keys=True)}\n"
        f"{extra}"
        f"\n--- BEGIN {boundary} ---\n"
        f"{packet_text}\n"
        f"--- END {boundary} ---\n"
    )


def claude_review_json_schema() -> dict:
    return {
        "type": "object",
        "additionalProperties": False,
        "required": ["verdict", "blockers", "non_blocking", "reviewed_evidence"],
        "properties": {
            "verdict": {"type": "string", "enum": sorted(CLAUDE_REVIEW_ALLOWED_VERDICTS)},
            "blockers": {"type": "array", "items": {"type": "string"}},
            "non_blocking": {"type": "array", "items": {"type": "string"}},
            "reviewed_evidence": {"type": "string"},
        },
    }


def parse_claude_review_result(result_value) -> tuple[dict | None, str | None]:
    if isinstance(result_value, dict):
        parsed = result_value
    else:
        text = str(result_value or "").strip()
        if text.startswith("```"):
            text = re.sub(r"^```(?:json)?\s*", "", text)
            text = re.sub(r"\s*```$", "", text)
        try:
            parsed = json.loads(text)
        except json.JSONDecodeError as exc:
            return None, f"Claude result was not valid JSON: {exc}"
    if not isinstance(parsed, dict):
        return None, "Claude result JSON must be an object"
    verdict = parsed.get("verdict")
    if verdict not in CLAUDE_REVIEW_ALLOWED_VERDICTS:
        return None, f"Claude result verdict must be one of {sorted(CLAUDE_REVIEW_ALLOWED_VERDICTS)}, got {verdict!r}"
    blockers = parsed.get("blockers")
    if not isinstance(blockers, list):
        return None, "Claude result blockers must be a list"
    if not all(isinstance(item, str) for item in blockers):
        return None, "Claude result blockers must contain only strings"
    non_blocking = parsed.get("non_blocking", [])
    if not isinstance(non_blocking, list):
        return None, "Claude result non_blocking must be a list when present"
    if not all(isinstance(item, str) for item in non_blocking):
        return None, "Claude result non_blocking must contain only strings"
    reviewed_evidence = parsed.get("reviewed_evidence")
    if not isinstance(reviewed_evidence, str) or not reviewed_evidence.strip():
        return None, "Claude result reviewed_evidence must be a non-empty string"
    if verdict == "blocked" and not blockers:
        return None, "Claude result verdict is blocked but blockers is empty"
    if verdict == "no_blockers" and blockers:
        return None, "Claude result verdict is no_blockers but blockers is non-empty"
    return parsed, None


def claude_review_exit_signal(returncode: int) -> dict | None:
    signal_number: int | None = None
    if returncode < 0:
        signal_number = -returncode
    elif returncode > 128:
        signal_number = returncode - 128
    if signal_number is None:
        return None
    try:
        signal_name = signal.Signals(signal_number).name
    except ValueError:
        signal_name = None
    return {"number": signal_number, "name": signal_name}


def claude_review_transport_failure(returncode: int, stdout: str, stderr: str) -> dict:
    exit_signal = claude_review_exit_signal(returncode)
    stdout_empty = not stdout.strip()
    stderr_empty = not stderr.strip()
    if exit_signal:
        signal_label = exit_signal["name"] or f"signal {exit_signal['number']}"
        summary = f"Claude CLI terminated by {signal_label} before completing review"
    else:
        summary = f"Claude CLI exited {returncode} before completing review"
    if stdout_empty:
        summary += "; produced no stdout JSON"
    if stderr_empty:
        summary += "; stderr was empty"
    wrapper_exit_code = 128 + exit_signal["number"] if returncode < 0 and exit_signal else returncode
    return {
        "kind": "claude_cli_transport_failure",
        "exit_code": returncode,
        "wrapper_exit_code": wrapper_exit_code,
        "signal": exit_signal,
        "stdout_empty": stdout_empty,
        "stderr_empty": stderr_empty,
        "summary": summary,
        "next_action": (
            "This is not a review verdict. Rerun the exact same packet once; "
            "if the transport failure repeats, inspect Claude CLI availability, usage/session state, "
            "and host process termination before attempting to classify the review."
        ),
    }


def claude_review(args: argparse.Namespace) -> int:
    target = args.target.resolve()
    packet_path = cli_path(target, str(args.packet))
    try:
        packet_text = packet_path.read_text(encoding="utf-8")
    except OSError as exc:
        print(f"claude_review_error: cannot read packet {packet_path}: {exc}", file=sys.stderr)
        return 1
    if not packet_text.strip():
        print(f"claude_review_error: packet is empty: {packet_path}", file=sys.stderr)
        return 1
    output_path = cli_path(target, str(args.output)) if args.output else None
    if output_path is None:
        stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
        output_path = target / CLAUDE_REVIEW_DIR / f"{stamp}-claude-review.json"
    output_path.parent.mkdir(parents=True, exist_ok=True)
    prompt = claude_review_prompt(packet_text, getattr(args, "instruction", "") or "")
    command = [
        "claude",
        "--safe-mode",
        "--prompt-suggestions",
        "false",
        "--no-session-persistence",
        "--tools",
        "",
        "--output-format",
        "json",
        "--json-schema",
        json.dumps(claude_review_json_schema(), sort_keys=True),
    ]
    if getattr(args, "model", None):
        command.extend(["--model", args.model])
    command.extend(["-p", prompt])
    started_at = now_iso()
    record: dict = {
        "schema": CLAUDE_REVIEW_SCHEMA,
        "packet_path": path_display(target, packet_path),
        "packet_sha256": hashlib.sha256(packet_text.encode("utf-8")).hexdigest(),
        "launcher": "tautline claude-review",
        "claude_command": command_string(command[:-1] + ["<review-packet-prompt>"]),
        "instruction": getattr(args, "instruction", "") or "",
        "effective_prompt_sha256": hashlib.sha256(prompt.encode("utf-8")).hexdigest(),
        "started_at": started_at,
        "timeout_seconds": args.timeout_seconds,
        "tool_policy": "claude --safe-mode with --tools '' and evidence-only prompt",
    }
    skip_packet_preflight = bool(getattr(args, "skip_packet_preflight", False))
    if skip_packet_preflight:
        preflight_errors, preflight_warnings = [], []
    else:
        preflight_errors, preflight_warnings = claude_review_packet_preflight(target, packet_text)
    record["packet_preflight"] = {
        "errors": preflight_errors,
        "warnings": preflight_warnings,
        "skipped": skip_packet_preflight,
    }
    for warning in preflight_warnings:
        print(f"claude_review_packet_warning: {warning}", file=sys.stderr)
    if preflight_errors:
        record["wrapper_exit_code"] = 1
        record["stderr"] = "\n".join(f"claude_review_packet_error: {error}" for error in preflight_errors)
        record["finished_at"] = now_iso()
        write_text_atomic(output_path, json.dumps(record, indent=2, sort_keys=True) + "\n")
        print(f"claude_review_output: {path_display(target, output_path)}")
        for error in preflight_errors:
            print(f"claude_review_packet_error: {error}", file=sys.stderr)
        return 1
    try:
        proc = subprocess.run(
            command,
            cwd=str(target),
            text=True,
            encoding="utf-8",
            errors="replace",
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            check=False,
            timeout=args.timeout_seconds,
        )
        record["claude_cli_exit_code"] = proc.returncode
        record["stdout"] = proc.stdout
        record["stderr"] = proc.stderr
    except FileNotFoundError as exc:
        record["wrapper_exit_code"] = 127
        record["stderr"] = str(exc)
        record["finished_at"] = now_iso()
        write_text_atomic(output_path, json.dumps(record, indent=2, sort_keys=True) + "\n")
        print(f"claude_review_output: {path_display(target, output_path)}")
        print("claude_review_error: claude executable not found", file=sys.stderr)
        return 127
    except subprocess.TimeoutExpired as exc:
        stdout = exc.stdout if isinstance(exc.stdout, str) else ""
        stderr = exc.stderr if isinstance(exc.stderr, str) else ""
        record["wrapper_exit_code"] = 124
        record["stdout"] = stdout
        record["stderr"] = stderr or f"Claude review timed out after {args.timeout_seconds}s"
        record["finished_at"] = now_iso()
        write_text_atomic(output_path, json.dumps(record, indent=2, sort_keys=True) + "\n")
        print(f"claude_review_output: {path_display(target, output_path)}")
        print(record["stderr"], file=sys.stderr)
        return 124
    record["finished_at"] = now_iso()
    outer: dict | None = None
    if proc.stdout.strip():
        try:
            outer = json.loads(proc.stdout)
        except json.JSONDecodeError as exc:
            record["parse_error"] = f"Claude CLI stdout was not valid JSON: {exc}"
    if proc.returncode != 0:
        transport_failure = claude_review_transport_failure(proc.returncode, proc.stdout, proc.stderr)
        record["claude_transport_failure"] = transport_failure
        if outer is not None:
            record["claude_cli_result"] = outer
        record["wrapper_exit_code"] = transport_failure["wrapper_exit_code"]
        write_text_atomic(output_path, json.dumps(record, indent=2, sort_keys=True) + "\n")
        print(f"claude_review_output: {path_display(target, output_path)}")
        print(f"claude_review_error: {transport_failure['summary']}", file=sys.stderr)
        print(f"claude_review_next_action: {transport_failure['next_action']}", file=sys.stderr)
        return transport_failure["wrapper_exit_code"]
    if outer is None:
        record["wrapper_exit_code"] = 1
        write_text_atomic(output_path, json.dumps(record, indent=2, sort_keys=True) + "\n")
        print(f"claude_review_output: {path_display(target, output_path)}")
        print(record.get("parse_error", "claude_review_error: missing Claude CLI stdout"), file=sys.stderr)
        return 1
    record["claude_cli_result"] = outer
    if outer.get("is_error") not in (None, False):
        launcher_exit_code = 1
        record["wrapper_exit_code"] = launcher_exit_code
        write_text_atomic(output_path, json.dumps(record, indent=2, sort_keys=True) + "\n")
        print(f"claude_review_output: {path_display(target, output_path)}")
        print(f"claude_review_error: Claude CLI exited {proc.returncode}", file=sys.stderr)
        return launcher_exit_code
    parsed, parse_error = parse_claude_review_result(outer.get("result", ""))
    if parse_error:
        record["parse_error"] = parse_error
        record["wrapper_exit_code"] = 1
        write_text_atomic(output_path, json.dumps(record, indent=2, sort_keys=True) + "\n")
        print(f"claude_review_output: {path_display(target, output_path)}")
        print(f"claude_review_error: {parse_error}", file=sys.stderr)
        return 1
    assert parsed is not None
    record["review_result"] = parsed
    fail_on_blockers = bool(getattr(args, "fail_on_blockers", True))
    record["fail_on_blockers"] = fail_on_blockers
    launcher_exit_code = 2 if parsed["verdict"] == "blocked" and fail_on_blockers else 0
    record["wrapper_exit_code"] = launcher_exit_code
    write_text_atomic(output_path, json.dumps(record, indent=2, sort_keys=True) + "\n")
    print(f"claude_review_output: {path_display(target, output_path)}")
    print(f"claude_review_verdict: {parsed['verdict']}")
    if parsed["verdict"] == "blocked":
        print("claude_review_next_action: fix the blocker, rerun Stage 1 native review on the updated diff, then rerun Stage 2 Claude review.")
        return launcher_exit_code
    print("claude_review_next_action: classify the result with the rest of the review evidence and continue the push gate.")
    return 0


def claude_review_record_kind(record: dict) -> str:
    preflight = record.get("packet_preflight")
    if isinstance(preflight, dict) and preflight.get("errors"):
        return "packet_preflight_error"
    if record.get("claude_transport_failure"):
        return "transport_failure"
    if record.get("wrapper_exit_code") == 124:
        return "timeout"
    if record.get("parse_error"):
        return "parse_error"
    review_result = record.get("review_result")
    if isinstance(review_result, dict):
        verdict = review_result.get("verdict")
        if verdict == "blocked":
            return "completed_blocked_review"
        if verdict == "no_blockers":
            return "completed_clean_review"
        return "completed_unknown_review"
    claude_exit = record.get("claude_cli_exit_code")
    if isinstance(claude_exit, int) and claude_exit != 0:
        return "legacy_transport_failure"
    wrapper_exit = record.get("wrapper_exit_code")
    if isinstance(wrapper_exit, int) and wrapper_exit != 0:
        return "wrapper_error"
    return "unknown"


def claude_review_record_summary(path: Path, record: dict, target: Path) -> dict:
    review_result = record.get("review_result") if isinstance(record.get("review_result"), dict) else {}
    blockers = review_result.get("blockers") if isinstance(review_result, dict) else []
    if not isinstance(blockers, list):
        blockers = []
    return {
        "path": path_display(target, path),
        "kind": claude_review_record_kind(record),
        "wrapper_exit_code": record.get("wrapper_exit_code", ""),
        "claude_cli_exit_code": record.get("claude_cli_exit_code", ""),
        "verdict": review_result.get("verdict", ""),
        "blocker_count": len(blockers),
        "transport_summary": (
            record.get("claude_transport_failure", {}).get("summary", "")
            if isinstance(record.get("claude_transport_failure"), dict)
            else ""
        ),
        "parse_error": record.get("parse_error", ""),
    }


def claude_review_status(args: argparse.Namespace) -> int:
    target = args.target.resolve()
    review_dir = cli_path(target, str(args.review_dir)) if args.review_dir else target / CLAUDE_REVIEW_DIR
    raw_limit = getattr(args, "limit", 10)
    limit = 10 if raw_limit is None else int(raw_limit)
    if limit <= 0:
        raise SystemExit("--limit must be positive")

    cli_code, cli_stdout, cli_stderr = run_command(["claude", "--version"], cwd=target, timeout=10)
    claude_cli_status = "ok" if cli_code == 0 else "unavailable"
    claude_cli_version = cli_stdout.strip().splitlines()[0] if cli_stdout.strip() else ""
    if not claude_cli_version and cli_stderr.strip():
        claude_cli_version = cli_stderr.strip().splitlines()[0]

    artifacts: list[Path] = []
    if review_dir.exists():
        artifacts = sorted(
            (path for path in review_dir.glob("*.json") if path.is_file()),
            key=lambda path: path.stat().st_mtime,
            reverse=True,
        )

    summaries: list[dict] = []
    for artifact in artifacts[:limit]:
        try:
            payload = json.loads(artifact.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError) as exc:
            summaries.append(
                {
                    "path": path_display(target, artifact),
                    "kind": "artifact_read_error",
                    "wrapper_exit_code": "",
                    "claude_cli_exit_code": "",
                    "verdict": "",
                    "blocker_count": 0,
                    "transport_summary": "",
                    "parse_error": str(exc),
                }
            )
            continue
        if not isinstance(payload, dict):
            summaries.append(
                {
                    "path": path_display(target, artifact),
                    "kind": "artifact_read_error",
                    "wrapper_exit_code": "",
                    "claude_cli_exit_code": "",
                    "verdict": "",
                    "blocker_count": 0,
                    "transport_summary": "",
                    "parse_error": "artifact JSON is not an object",
                }
            )
            continue
        summaries.append(claude_review_record_summary(artifact, payload, target))

    latest = summaries[0] if summaries else None
    if latest is None:
        overall = "missing_artifacts"
        exit_code = 1
    elif latest["kind"] == "completed_clean_review" and claude_cli_status == "ok":
        overall = "ok"
        exit_code = 0
    elif latest["kind"] == "completed_blocked_review":
        overall = "blocked"
        exit_code = 2
    else:
        overall = "degraded"
        exit_code = 1

    print(f"claude_review_status: {overall}")
    print(f"claude_cli_status: {claude_cli_status}")
    if claude_cli_version:
        print(f"claude_cli_version: {claude_cli_version}")
    print(f"review_artifacts_dir: {path_display(target, review_dir)}")
    print(f"review_artifacts_count: {len(artifacts)}")
    if latest:
        print(f"latest_kind: {latest['kind']}")
        print(f"latest_path: {latest['path']}")
        if latest["verdict"]:
            print(f"latest_verdict: {latest['verdict']}")
        print(f"latest_blocker_count: {latest['blocker_count']}")
    if summaries:
        kind_counts: dict[str, int] = {}
        for summary in summaries:
            kind = str(summary["kind"])
            kind_counts[kind] = kind_counts.get(kind, 0) + 1
        print(f"recent_artifacts_summarized: {len(summaries)}")
        print(f"recent_clean_reviews: {kind_counts.get('completed_clean_review', 0)}")
        print(f"recent_blocked_reviews: {kind_counts.get('completed_blocked_review', 0)}")
        recent_degraded = sum(
            count
            for kind, count in kind_counts.items()
            if kind
            not in {
                "completed_clean_review",
                "completed_blocked_review",
            }
        )
        print(f"recent_degraded_reviews: {recent_degraded}")
    for summary in summaries:
        detail = (
            f"artifact: {summary['path']} "
            f"kind={summary['kind']} "
            f"wrapper_exit={summary['wrapper_exit_code']} "
            f"claude_exit={summary['claude_cli_exit_code']} "
            f"verdict={summary['verdict']} "
            f"blockers={summary['blocker_count']}"
        )
        if summary["transport_summary"]:
            detail += f" transport={summary['transport_summary']}"
        if summary["parse_error"]:
            detail += f" parse_error={summary['parse_error']}"
        print(detail)
    return exit_code


def git_ref_exists(target: Path, ref: str) -> bool:
    code, _stdout, _stderr = run_command(["git", "-C", str(target), "rev-parse", "--verify", "--quiet", ref], timeout=10)
    return code == 0


def implementation_review_base(target: Path, explicit_base: str | None = None) -> tuple[str | None, str | None]:
    if explicit_base:
        if not git_ref_exists(target, explicit_base):
            return None, f"configured implementation review base is unavailable: {explicit_base}"
        code, stdout, stderr = run_command(["git", "-C", str(target), "merge-base", "HEAD", explicit_base], timeout=10)
        if code == 0 and stdout:
            return explicit_base, stdout.strip()
        return None, stderr or stdout or f"could not merge-base HEAD with {explicit_base}"
    candidates: list[str] = []
    candidates.extend(["origin/main", "origin/master", "upstream/main", "upstream/master", "main", "master"])
    upstream = run_git(target, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"])
    if upstream != "unavailable":
        candidates.append(upstream)
    for ref in candidates:
        if not ref or not git_ref_exists(target, ref):
            continue
        code, stdout, stderr = run_command(["git", "-C", str(target), "merge-base", "HEAD", ref], timeout=10)
        if code == 0 and stdout:
            return ref, stdout.strip()
    return None, "could not determine a base ref for implementation review evidence; fetch origin/main or pass --base"


def git_diff_bytes(target: Path, base_sha: str, head_ref: str = "HEAD") -> bytes:
    proc = subprocess.run(
        [
            "git",
            "-C",
            str(target),
            "diff",
            "--binary",
            f"{base_sha}...{head_ref}",
            "--",
            ".",
            ":(exclude)**/.impl-reviews/*.json",
        ],
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        check=False,
    )
    if proc.returncode != 0:
        detail = proc.stderr.decode("utf-8", errors="replace").strip() or "git diff failed"
        raise RuntimeError(detail)
    return proc.stdout


def implementation_review_state(target: Path, base_ref: str | None = None) -> tuple[dict | None, list[str]]:
    errors: list[str] = []
    if run_git(target, ["rev-parse", "--is-inside-work-tree"]) != "true":
        return None, ["target is not a Git worktree"]
    branch = run_git(target, ["branch", "--show-current"]) or "detached-head"
    head_sha = run_git(target, ["rev-parse", "HEAD"])
    if head_sha == "unavailable":
        return None, ["could not determine HEAD"]
    base_name, base_or_error = implementation_review_base(target, base_ref)
    if base_name is None:
        return None, [base_or_error or "could not determine review base"]
    try:
        diff = git_diff_bytes(target, base_or_error, "HEAD")
    except RuntimeError as exc:
        return None, [str(exc)]
    return {
        "branch": branch,
        "head_sha": head_sha,
        "base_ref": base_name,
        "base_sha": base_or_error,
        "diff_sha256": hashlib.sha256(diff).hexdigest(),
        "diff_bytes": len(diff),
    }, errors


def write_implementation_review_evidence(
    data: dict,
    target: Path,
    command: list[str],
    log_path: Path,
    wrapper_exit_code: int,
    started_at: str,
    finished_at: str,
    native_review_note: str,
    stage1_sweep_path: Path,
    risk_tier: str | None = None,
    review_round: str | None = None,
) -> Path | None:
    if wrapper_exit_code != 0 or not command_matches_codex_wrapper(data, command):
        return None
    state, errors = implementation_review_state(target, implementation_review_effective_base(data))
    if state is None:
        print(f"review_evidence_warning: {'; '.join(errors)}", file=sys.stderr)
        return None
    stage1_required = implementation_stage1_required_for_risk_tier(risk_tier)
    evidence_dir = implementation_review_evidence_dir(target)
    evidence_dir.mkdir(parents=True, exist_ok=True)
    stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    manifest_path = evidence_dir / f"{stamp}-{slugify(state['branch'], 'branch')}-{IMPLEMENTATION_REVIEW_STAGE}.json"
    manifest = {
        "schema": IMPLEMENTATION_REVIEW_SCHEMA,
        "stage": IMPLEMENTATION_REVIEW_STAGE,
        "reviewer": "codex",
        "recorded_by": "tautline codex-run",
        "project": data.get("project"),
        "repo": data.get("repo"),
        "target": str(target),
        "branch": state["branch"],
        "base_ref": state["base_ref"],
        "base_sha": state["base_sha"],
        "head_sha": state["head_sha"],
        "diff_sha256": state["diff_sha256"],
        "diff_bytes": state["diff_bytes"],
        "review_command": command_string(command),
        "review_wrapper": str(data.get("review", {}).get("codexWrapper", "")).strip(),
        "risk_tier": risk_tier or "",
        "review_round": review_round or "",
        "native_review_required": stage1_required,
        "native_review_requirement": (
            "Stage 1 native/Superpowers review on current assembled diff before this Stage 2 Codex round"
            if stage1_required
            else "not required for explicit T1 implementation review"
        ),
        "native_review_note": native_review_note,
        "stage1_sweep_required": stage1_required,
        "log_path": path_relative_to_target(target, log_path),
        "log_sha256": file_sha256(log_path),
        "wrapper_exit_code": wrapper_exit_code,
        "classification_status": "unclassified",
        "started_at": started_at,
        "finished_at": finished_at,
        "plugin_version": plugin_version(),
    }
    if stage1_required:
        manifest["stage1_sweep_path"] = path_relative_to_target(target, stage1_sweep_path)
        manifest["stage1_sweep_sha256"] = file_sha256(stage1_sweep_path)
    manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    return manifest_path


def implementation_review_ledger_payload(target: Path, state: dict, manifest_path: Path, manifest: dict) -> dict:
    fields = [
        "project",
        "repo",
        "branch",
        "base_ref",
        "base_sha",
        "head_sha",
        "diff_sha256",
        "diff_bytes",
        "reviewer",
        "review_command",
        "review_wrapper",
        "risk_tier",
        "review_round",
        "native_review_required",
        "native_review_requirement",
        "native_review_note",
        "stage1_sweep_required",
        "stage1_sweep_path",
        "stage1_sweep_sha256",
        "log_path",
        "log_sha256",
        "wrapper_exit_code",
        "classification_status",
        "verdict",
        "unresolved_critical_count",
        "unresolved_p1_count",
        "classified_findings",
        "classified_at",
        "classification_recorded_by",
        "started_at",
        "finished_at",
    ]
    payload = {field: manifest.get(field) for field in fields if field in manifest}
    payload.update(
        {
            "schema": IMPLEMENTATION_REVIEW_LEDGER_SCHEMA,
            "recorded_by": "tautline finalize-implementation-review",
            "source_manifest_path": path_relative_to_target(target, manifest_path),
            "source_manifest_sha256": file_sha256(manifest_path),
            "ledger_scope": "tracked implementation review findings for the current outgoing diff",
            "review_scope": f"{state['base_ref']}...HEAD",
            "updated_at": now_iso(),
        }
    )
    return payload


def write_implementation_review_ledger(data: dict, target: Path, state: dict, manifest_path: Path, manifest: dict) -> Path:
    ledger_dir = implementation_review_ledger_dir(data, target)
    ledger_dir.mkdir(parents=True, exist_ok=True)
    ledger_path = implementation_review_ledger_path(data, target, state)
    payload = implementation_review_ledger_payload(target, state, manifest_path, manifest)
    tmp = ledger_path.with_name(f".{ledger_path.name}.tmp")
    tmp.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    tmp.replace(ledger_path)
    return ledger_path


def implementation_review_ledger_errors(data: dict, target: Path, state: dict, manifest_path: Path | None = None) -> list[str]:
    ledger_path = implementation_review_ledger_path(data, target, state)
    if not ledger_path.is_file():
        return [f"tracked implementation review ledger missing: {path_display(target, ledger_path)}"]
    try:
        ledger = json.loads(ledger_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        return [f"tracked implementation review ledger unreadable: {exc}"]
    errors: list[str] = []
    if ledger.get("schema") != IMPLEMENTATION_REVIEW_LEDGER_SCHEMA:
        errors.append(f"tracked implementation review ledger schema mismatch: {ledger.get('schema')}")
    if ledger.get("recorded_by") != "tautline finalize-implementation-review":
        errors.append("tracked implementation review ledger recorded_by must be tautline finalize-implementation-review")
    for field in ("branch", "base_sha", "diff_sha256"):
        if ledger.get(field) != state[field]:
            errors.append(f"tracked implementation review ledger {field} does not match current outgoing diff")
    if ledger.get("reviewer") != "codex":
        errors.append("tracked implementation review ledger reviewer must be codex")
    if ledger.get("verdict") not in IMPLEMENTATION_REVIEW_ALLOWED_VERDICTS:
        errors.append("tracked implementation review ledger verdict must be clean or clean-with-deferrals")
    try:
        unresolved_critical = int(ledger.get("unresolved_critical_count", -1))
        unresolved_p1 = int(ledger.get("unresolved_p1_count", -1))
    except (TypeError, ValueError):
        unresolved_critical = -1
        unresolved_p1 = -1
    if unresolved_critical != 0 or unresolved_p1 != 0:
        errors.append("tracked implementation review ledger unresolved Critical/P1 counts must both be zero")
    if manifest_path is not None:
        try:
            source_manifest = cli_path(target, str(ledger.get("source_manifest_path", "")))
        except Exception:
            source_manifest = Path("")
        if source_manifest.resolve(strict=False) != manifest_path.resolve(strict=False):
            errors.append("tracked implementation review ledger source_manifest_path does not match selected manifest")
        elif source_manifest.is_file() and ledger.get("source_manifest_sha256") != file_sha256(source_manifest):
            errors.append("tracked implementation review ledger source_manifest_sha256 does not match selected manifest")
    if not git_path_tracked(target, ledger_path):
        errors.append("tracked implementation review ledger is not staged/tracked in Git")
    if not git_path_clean(target, ledger_path):
        errors.append("tracked implementation review ledger has uncommitted changes")
    return errors


def native_review_note_errors(note: str) -> list[str]:
    normalized = note.strip().lower()
    errors: list[str] = []
    if len(note.strip()) < 40:
        errors.append("native_review_note must be at least 40 characters")
    if normalized in {"todo", "tbd", "placeholder", "native review", "all looks fine", "review complete", "no issues found", "clean pass done", "lgtm, no issues"}:
        errors.append("native_review_note must not be a generic placeholder")
    if "<" in note or ">" in note:
        errors.append("native_review_note must not be a placeholder token")
    if not any(term in normalized for term in ("native", "superpowers", "self-review", "stage 1")):
        errors.append("native_review_note must mention the native/Superpowers/Stage 1 review")
    if not any(term in normalized for term in ("assembled diff", "current diff", "outgoing diff")):
        errors.append("native_review_note must mention the current assembled diff")
    if not any(term in normalized for term in ("no blockers", "blockers fixed", "clean", "findings", "p1", "critical")):
        errors.append("native_review_note must state blocker/finding outcome")
    for pattern in SESSION_JOURNAL_SECRET_PATTERNS:
        if re.search(pattern, note):
            errors.append("native_review_note contains a secret-looking value")
            break
    return errors


def load_stage1_classes(args: argparse.Namespace) -> tuple[list[dict], list[str]]:
    errors: list[str] = []
    classes: list[dict] = []
    if getattr(args, "classes_json", None):
        try:
            loaded = json.loads(args.classes_json.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError) as exc:
            return [], [f"stage1 classes JSON invalid: {exc}"]
        if not isinstance(loaded, list):
            return [], ["stage1 classes JSON must be an array"]
        for index, item in enumerate(loaded, start=1):
            if not isinstance(item, dict):
                errors.append(f"class {index} must be an object")
                continue
            classes.append(item)
    for raw in getattr(args, "sweep_class", None) or []:
        text = raw.strip()
        if ":" in text:
            name, members = text.split(":", 1)
        else:
            name, members = text, text
        classes.append({"class": name.strip(), "members_checked": members.strip(), "status": "clean"})
    return classes, errors


def derived_artifact_staleness(entries: object, changed_files: object) -> list[str]:
    """Stale-mirror machine check for the published Stage 1 derived-artifact freshness gate.

    For each declared derived artifact, if any of its source globs matched a file changed in the
    reviewed diff but the committed artifact itself was NOT changed in that same diff, the mirror is
    stale -- the sources moved and the derived output did not. Returns one finding per stale artifact.

    Hermetic: it inspects only the reviewed changed-file set (never runs a build). It cannot be
    cleared by the Stage 1 sweep's self-listed classes -- only actually regenerating the artifact in
    the diff, or an explicit recorded `--derived-artifact-current` override, clears it. fnmatch '*'
    spans '/', so a source glob conservatively catches nested files.
    """
    changed = {str(f).strip() for f in (changed_files or []) if str(f).strip()}
    findings: list[str] = []
    for entry in entries or []:
        if not isinstance(entry, dict):
            continue
        artifact = str(entry.get("artifact", "")).strip()
        if not artifact:
            continue
        sources = entry.get("sources") or []
        changed_sources = sorted(
            f
            for f in changed
            if f != artifact and any(fnmatch.fnmatch(f, str(glob)) for glob in sources)
        )
        if changed_sources and artifact not in changed:
            regenerate = str(entry.get("regenerate", "")).strip()
            hint = f"regenerate with `{regenerate}` and commit it" if regenerate else "regenerate it and commit"
            shown = ", ".join(changed_sources[:5]) + (" …" if len(changed_sources) > 5 else "")
            findings.append(
                f"{artifact}: derived from sources changed in this diff ({shown}) but the committed "
                f"artifact was not regenerated -- {hint}"
            )
    return findings


def normalize_repo_path(path: str) -> str:
    """Repo-root-relative, forward-slash form matching what `git diff --name-only` emits: drop a
    leading './' and collapse '.'/'//' so a declared artifact/source path lines up with the diff."""
    return os.path.normpath(path).replace(os.sep, "/")


def normalize_derived_artifacts(raw: object) -> list[dict]:
    """Validate + normalize the adapter `derivedArtifacts` declaration (fail-closed). Each entry
    binds a committed derived artifact to the source globs it is generated from."""
    if raw is None:
        return []
    if not isinstance(raw, list):
        raise SystemExit("Project adapter derivedArtifacts must be a list")
    normalized: list[dict] = []
    for index, entry in enumerate(raw):
        if not isinstance(entry, dict):
            raise SystemExit(f"Project adapter derivedArtifacts[{index}] must be an object")
        artifact = str(entry.get("artifact", "")).strip()
        if not artifact:
            raise SystemExit(f"Project adapter derivedArtifacts[{index}] missing artifact")
        sources = entry.get("sources")
        if not isinstance(sources, list) or not sources or not all(
            isinstance(s, str) and s.strip() for s in sources
        ):
            raise SystemExit(
                f"Project adapter derivedArtifacts[{index}].sources must be a non-empty list of glob strings"
            )
        clean_sources = []
        for source in sources:
            source = source.strip()
            if "**" in source:
                raise SystemExit(
                    f"Project adapter derivedArtifacts[{index}].sources entry {source!r} uses '**', which "
                    "fnmatch does not match recursively; use '*' (it already spans '/')"
                )
            clean_sources.append(normalize_repo_path(source))
        normalized.append(
            {
                "artifact": normalize_repo_path(artifact),
                "sources": clean_sources,
                "regenerate": str(entry.get("regenerate", "")).strip(),
            }
        )
    return normalized


def derived_artifact_gate_errors(
    entries: object, changed_files: object, acknowledged_current: object = None
) -> list[str]:
    """Stale-mirror findings that must BLOCK Stage 1, after removing artifacts the operator
    explicitly acknowledged as current via `--derived-artifact-current` (regenerated with
    byte-identical output -- a narrow, recorded override for the rare no-output-change case)."""
    acknowledged = {str(p).strip() for p in (acknowledged_current or []) if str(p).strip()}
    active = [
        entry
        for entry in (entries or [])
        if isinstance(entry, dict) and str(entry.get("artifact", "")).strip() not in acknowledged
    ]
    return derived_artifact_staleness(active, changed_files)


def review_scope_changed_files(target: Path, base_sha: str) -> list[str]:
    """Target-relative paths changed in the reviewed outgoing diff (base...HEAD).

    `--relative` plus the `-- . :(exclude)**/.impl-reviews/*.json` pathspec scope output to the
    target subtree and emit paths relative to `target`, the same three-dot scope the Stage 1
    sweep pins via git_diff_bytes. This keeps the changed-file set aligned with the
    target-relative `derivedArtifacts` artifact/sources paths (resolved against `target` and
    normalized via normalize_repo_path); for a repo-root target (`--target .`) it is identical
    to the unscoped repo-relative listing. Without `--relative`, a sub-directory target would
    report repo-root-relative paths that never match a target-relative source glob, silently
    passing a stale artifact."""
    # -z + core.quotePath=false keep non-ASCII paths verbatim; git's default octal-escaping would
    # make a unicode-named source never match its declared glob, silently passing a stale artifact.
    out = run_git(
        target,
        [
            "-c",
            "core.quotePath=false",
            "diff",
            "-z",
            "--relative",
            "--name-only",
            f"{base_sha}...HEAD",
            "--",
            ".",
            ":(exclude)**/.impl-reviews/*.json",
        ],
    )
    if out in ("", "unavailable"):
        return []
    return [path for path in out.split("\0") if path]


def stage1_sweep_class_errors(classes: object) -> list[str]:
    errors: list[str] = []
    if not isinstance(classes, list) or not classes:
        return ["stage1 sweep must include at least one checked defect class"]
    clean_or_fixed = 0
    allowed_status = {"clean", "fixed", "not-applicable"}
    for index, item in enumerate(classes, start=1):
        if not isinstance(item, dict):
            errors.append(f"class {index} must be an object")
            continue
        class_name = str(item.get("class", "")).strip()
        members = str(item.get("members_checked", "")).strip()
        status = str(item.get("status", "")).strip()
        if len(class_name) < 3:
            errors.append(f"class {index} missing class name")
        if len(members) < 20:
            errors.append(f"class {index} must describe the members/files/sinks checked")
        if status not in allowed_status:
            errors.append(f"class {index} status must be one of {sorted(allowed_status)}")
        if status in {"clean", "fixed"}:
            clean_or_fixed += 1
    if clean_or_fixed == 0:
        errors.append("stage1 sweep must include at least one clean or fixed defect class")
    return errors


def stage1_sweep_errors(target: Path, state: dict, sweep_path: Path) -> list[str]:
    try:
        sweep = json.loads(sweep_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        return [f"stage1_sweep unreadable: {exc}"]
    required = [
        "schema",
        "stage",
        "recorded_by",
        "branch",
        "base_sha",
        "head_sha",
        "diff_sha256",
        "native_review_note",
        "classes",
        "unresolved_critical_count",
        "unresolved_p1_count",
        "recorded_at",
    ]
    errors: list[str] = []
    for field in required:
        if field not in sweep:
            errors.append(f"stage1_sweep missing field: {field}")
    if errors:
        return errors
    if sweep.get("schema") != IMPLEMENTATION_STAGE1_SWEEP_SCHEMA:
        errors.append(f"stage1_sweep schema mismatch: {sweep.get('schema')}")
    if sweep.get("stage") != IMPLEMENTATION_STAGE1_SWEEP_STAGE:
        errors.append(f"stage1_sweep stage mismatch: {sweep.get('stage')}")
    if sweep.get("recorded_by") != "tautline record-stage1-sweep":
        errors.append("stage1_sweep recorded_by must be tautline record-stage1-sweep")
    if sweep.get("base_sha") != state["base_sha"]:
        errors.append("stage1_sweep base_sha does not match current review base")
    if sweep.get("diff_sha256") != state["diff_sha256"]:
        errors.append("stage1_sweep diff_sha256 does not match current outgoing diff")
    note_errors = native_review_note_errors(str(sweep.get("native_review_note", "")))
    if note_errors:
        errors.append("stage1_sweep native_review_note invalid: " + "; ".join(note_errors))
    errors.extend(stage1_sweep_class_errors(sweep.get("classes")))
    try:
        unresolved_critical = int(sweep.get("unresolved_critical_count", -1))
        unresolved_p1 = int(sweep.get("unresolved_p1_count", -1))
    except (TypeError, ValueError):
        unresolved_critical = -1
        unresolved_p1 = -1
    if unresolved_critical != 0 or unresolved_p1 != 0:
        errors.append("stage1_sweep unresolved Critical/P1 counts must both be zero before Codex Stage 2")
    try:
        sweep_path.relative_to(target)
    except ValueError:
        errors.append("stage1_sweep must live inside the lane target")
    return errors


def record_stage1_sweep(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    state, state_errors = implementation_review_state(target, implementation_review_effective_base(data, args.base))
    if state is None:
        print(f"stage1_sweep_error: {'; '.join(state_errors)}", file=sys.stderr)
        return 1
    if state["diff_bytes"] == 0:
        print("stage1_sweep_error: no outgoing diff relative to base; no implementation review sweep required", file=sys.stderr)
        return 1
    note = str(args.native_review_note or "").strip()
    note_errors = native_review_note_errors(note)
    if note_errors:
        print("stage1_sweep_error: --native-review-note invalid: " + "; ".join(note_errors), file=sys.stderr)
        return 1
    classes, class_load_errors = load_stage1_classes(args)
    class_errors = class_load_errors + stage1_sweep_class_errors(classes)
    if class_errors:
        for error in class_errors:
            print(f"stage1_sweep_error: {error}", file=sys.stderr)
        return 1
    declared_artifacts = {entry["artifact"] for entry in (data.get("derivedArtifacts") or [])}
    acknowledged_current = [
        normalize_repo_path(str(p).strip())
        for p in (getattr(args, "derived_artifact_current", None) or [])
        if str(p).strip()
    ]
    for ack in acknowledged_current:
        if ack not in declared_artifacts:
            print(
                f"stage1_sweep_error: --derived-artifact-current {ack} is not a declared derivedArtifacts artifact",
                file=sys.stderr,
            )
            return 1
        if not (target / ack).exists():
            print(
                f"stage1_sweep_error: --derived-artifact-current {ack} does not exist on disk; regenerate and commit it",
                file=sys.stderr,
            )
            return 1
    changed_files = review_scope_changed_files(target, state["base_sha"])
    derived_errors = derived_artifact_gate_errors(data.get("derivedArtifacts"), changed_files, acknowledged_current)
    if derived_errors:
        for error in derived_errors:
            print(f"stage1_sweep_error: derived artifact stale - {error}", file=sys.stderr)
        print(
            "stage1_sweep_error: a committed derived artifact is out of date with sources changed in this "
            "diff (the hard Stage 1 derived-artifact freshness gate). Regenerate it and include it in the "
            "diff, or pass --derived-artifact-current <path> if regeneration produced no change.",
            file=sys.stderr,
        )
        return 1
    sweep_dir = implementation_stage1_sweep_dir(target)
    sweep_dir.mkdir(parents=True, exist_ok=True)
    stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    sweep_path = sweep_dir / f"{stamp}-{slugify(state['branch'], 'branch')}-{IMPLEMENTATION_STAGE1_SWEEP_STAGE}.json"
    sweep = {
        "schema": IMPLEMENTATION_STAGE1_SWEEP_SCHEMA,
        "stage": IMPLEMENTATION_STAGE1_SWEEP_STAGE,
        "recorded_by": "tautline record-stage1-sweep",
        "project": data.get("project"),
        "repo": data.get("repo"),
        "target": str(target),
        "branch": state["branch"],
        "base_ref": state["base_ref"],
        "base_sha": state["base_sha"],
        "head_sha": state["head_sha"],
        "diff_sha256": state["diff_sha256"],
        "diff_bytes": state["diff_bytes"],
        "native_review_note": note,
        "classes": classes,
        "derived_artifacts_checked": [entry["artifact"] for entry in (data.get("derivedArtifacts") or [])],
        "derived_artifacts_acknowledged_current": sorted(acknowledged_current),
        "unresolved_critical_count": 0,
        "unresolved_p1_count": 0,
        "recorded_at": now_iso(),
        "plugin_version": plugin_version(),
    }
    sweep_path.write_text(json.dumps(sweep, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    print(f"stage1_sweep_recorded: {path_display(target, sweep_path)}")
    print(f"stage1_sweep_diff_sha256: {state['diff_sha256']}")
    print(f"stage1_sweep_classes: {len(classes)}")
    print("stage1_sweep_result: codex-stage2-eligible")
    return 0


def implementation_review_manifest_errors(
    data: dict,
    target: Path,
    state: dict,
    manifest_path: Path,
    require_classification: bool = True,
) -> list[str]:
    errors: list[str] = []
    try:
        manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        return [f"{path_display(target, manifest_path)} unreadable: {exc}"]
    # A JSON file in the evidence dir that is not an object is not a review manifest
    # (RCA Jun-17: a stray classified-findings array crashed the scan). Skip it with an
    # error string so matching_implementation_review_evidence moves on instead of raising.
    if not isinstance(manifest, dict):
        return [f"{path_display(target, manifest_path)} is not a review manifest object (skipped)"]
    # Migration tolerance (RCA O7): a manifest RECORDED by a plugin older than the version
    # that introduced the mandatory Stage 1 sweep is a legacy manifest. A mid-session plugin
    # auto-advance must not retroactively fail it. Tolerance applies only when plugin_version
    # is recorded AND older than the requirement; an absent plugin_version stays strict so a
    # forged/corrupt manifest cannot dodge the sweep by omitting the field.
    manifest_plugin_version = str(manifest.get("plugin_version", "")).strip()
    legacy_pre_stage1 = bool(manifest_plugin_version) and version_tuple(manifest_plugin_version) < version_tuple(
        STAGE1_SWEEP_REQUIREMENT_PLUGIN_VERSION
    )
    required = [
        "schema",
        "stage",
        "reviewer",
        "recorded_by",
        "branch",
        "base_sha",
        "head_sha",
        "diff_sha256",
        "review_command",
        "review_wrapper",
        "native_review_required",
        "native_review_requirement",
        "log_path",
        "log_sha256",
        "wrapper_exit_code",
        "finished_at",
    ]
    if implementation_stage1_required_for_risk_tier(str(manifest.get("risk_tier") or "")):
        required.append("native_review_note")
    if not legacy_pre_stage1:
        required.append("stage1_sweep_required")
        if implementation_stage1_required_for_risk_tier(str(manifest.get("risk_tier") or "")):
            required.extend(["stage1_sweep_path", "stage1_sweep_sha256"])
    for field in required:
        if field not in manifest:
            errors.append(f"manifest missing field: {field}")
    if errors:
        return errors
    if manifest["schema"] != IMPLEMENTATION_REVIEW_SCHEMA:
        errors.append(f"schema mismatch: {manifest['schema']}")
    if manifest["stage"] != IMPLEMENTATION_REVIEW_STAGE:
        errors.append(f"stage mismatch: {manifest['stage']}")
    if manifest["reviewer"] != "codex":
        errors.append("reviewer must be codex")
    if manifest["recorded_by"] != "tautline codex-run":
        errors.append("recorded_by must be tautline codex-run")
    if int(manifest.get("wrapper_exit_code", 1)) != 0:
        errors.append("wrapper_exit_code must be 0")
    if manifest["diff_sha256"] != state["diff_sha256"]:
        errors.append("diff_sha256 does not match current outgoing diff")
    try:
        manifest_command = shlex.split(str(manifest["review_command"]))
    except ValueError:
        manifest_command = []
    if not command_matches_codex_wrapper(data, manifest_command):
        errors.append("review_command does not start with adapter review.codexWrapper")
    if str(manifest["review_wrapper"]).strip() != str(data.get("review", {}).get("codexWrapper", "")).strip():
        errors.append("review_wrapper does not match current adapter review.codexWrapper")
    stage1_required = implementation_stage1_required_for_risk_tier(str(manifest.get("risk_tier") or ""))
    explicit_t1_with_stage1_superset = (
        not stage1_required
        and str(manifest.get("risk_tier") or "").strip().upper() == "T1"
        and manifest.get("native_review_required") is True
        and manifest.get("stage1_sweep_required") is True
    )
    explicit_t1_legacy_native_review = (
        legacy_pre_stage1
        and not stage1_required
        and str(manifest.get("risk_tier") or "").strip().upper() == "T1"
        and manifest.get("native_review_required") is True
    )
    if stage1_required or explicit_t1_with_stage1_superset or explicit_t1_legacy_native_review:
        if manifest.get("native_review_required") is not True:
            errors.append("native_review_required must be true for T2/T3 or unspecified Stage 2 implementation review evidence")
        if (
            not explicit_t1_legacy_native_review
            and "native/Superpowers review on current assembled diff" not in str(manifest.get("native_review_requirement", ""))
        ):
            errors.append("native_review_requirement must require native/Superpowers review on current assembled diff")
        if not explicit_t1_legacy_native_review or str(manifest.get("native_review_note", "")).strip():
            note_errors = native_review_note_errors(str(manifest.get("native_review_note", "")))
            if note_errors:
                errors.append("native_review_note must summarize the completed native review for this assembled diff: " + "; ".join(note_errors))
    elif manifest.get("native_review_required") is not False:
        errors.append("native_review_required must be false for explicit T1 implementation review evidence")
    if legacy_pre_stage1:
        print(
            f"review_evidence_warn: {path_display(target, manifest_path)} recorded by plugin "
            f"{manifest_plugin_version} (before Stage 1 sweep became mandatory in "
            f"{STAGE1_SWEEP_REQUIREMENT_PLUGIN_VERSION}); accepting as legacy manifest. Re-record with "
            "`record-stage1-sweep` + `codex-run` to satisfy the current contract.",
            file=sys.stderr,
        )
    else:
        effective_stage1_required = stage1_required or explicit_t1_with_stage1_superset
        if manifest.get("stage1_sweep_required") is not effective_stage1_required:
            errors.append("stage1_sweep_required does not match the manifest risk tier")
        if effective_stage1_required:
            stage1_sweep_path = cli_path(target, str(manifest.get("stage1_sweep_path", "")))
            if not stage1_sweep_path.is_file():
                errors.append(f"stage1_sweep missing: {manifest.get('stage1_sweep_path')}")
            else:
                if file_sha256(stage1_sweep_path) != manifest.get("stage1_sweep_sha256"):
                    errors.append("stage1_sweep_sha256 does not match current sweep file")
                errors.extend(stage1_sweep_errors(target, state, stage1_sweep_path))
        else:
            if str(manifest.get("stage1_sweep_path") or "").strip():
                errors.append("stage1_sweep_path must be omitted for explicit T1 implementation review evidence")
    log_path = cli_path(target, str(manifest["log_path"]))
    if not log_path.is_file():
        errors.append(f"log missing: {manifest['log_path']}")
    elif file_sha256(log_path) != manifest["log_sha256"]:
        errors.append("log_sha256 does not match current log file")
    if require_classification:
        status = str(manifest.get("classification_status", "unclassified"))
        verdict = str(manifest.get("verdict", ""))
        if status not in IMPLEMENTATION_REVIEW_ALLOWED_VERDICTS:
            errors.append("classification_status must be clean or clean-with-deferrals")
        if verdict not in IMPLEMENTATION_REVIEW_ALLOWED_VERDICTS:
            errors.append("verdict must be clean or clean-with-deferrals")
        try:
            unresolved_critical = int(manifest.get("unresolved_critical_count", -1))
            unresolved_p1 = int(manifest.get("unresolved_p1_count", -1))
        except (TypeError, ValueError):
            unresolved_critical = -1
            unresolved_p1 = -1
        if unresolved_critical != 0 or unresolved_p1 != 0:
            errors.append("unresolved Critical/P1 counts must both be zero")
        if manifest.get("classification_recorded_by") != "tautline finalize-implementation-review":
            errors.append("classification_recorded_by must be tautline finalize-implementation-review")
    return errors


def matching_implementation_review_evidence(data: dict, target: Path, state: dict) -> tuple[Path | None, list[str]]:
    evidence_dir = implementation_review_evidence_dir(target)
    if not evidence_dir.is_dir():
        return None, [f"review evidence directory missing: {path_display(target, evidence_dir)}"]
    errors: list[str] = []
    for manifest_path in sorted(evidence_dir.glob("*.json"), reverse=True):
        manifest_errors = implementation_review_manifest_errors(data, target, state, manifest_path)
        if not manifest_errors:
            return manifest_path, []
        errors.append(f"{path_display(target, manifest_path)}: {'; '.join(manifest_errors)}")
    return None, errors or [f"no review evidence manifests found in {path_display(target, evidence_dir)}"]


def finalize_implementation_review(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    manifest_path = cli_path(target, str(args.manifest))
    if not manifest_path.is_file():
        print(f"implementation_review_finalize_error: manifest missing: {path_display(target, manifest_path)}", file=sys.stderr)
        return 1
    state, state_errors = implementation_review_state(target, implementation_review_effective_base(data, args.base))
    if state is None:
        print(f"implementation_review_finalize_error: {'; '.join(state_errors)}", file=sys.stderr)
        return 1
    base_errors = implementation_review_manifest_errors(data, target, state, manifest_path, require_classification=False)
    if base_errors:
        for error in base_errors:
            print(f"implementation_review_finalize_error: {error}", file=sys.stderr)
        return 1
    if args.verdict not in IMPLEMENTATION_REVIEW_ALL_VERDICTS:
        print(f"implementation_review_finalize_error: invalid verdict {args.verdict}", file=sys.stderr)
        return 1
    findings: list[dict] = []
    if args.classified_findings_json:
        findings_path = cli_path(target, str(args.classified_findings_json))
        try:
            loaded = json.loads(findings_path.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError) as exc:
            print(f"implementation_review_finalize_error: classified findings JSON invalid: {exc}", file=sys.stderr)
            return 1
        if not isinstance(loaded, list):
            print("implementation_review_finalize_error: classified findings JSON must be an array", file=sys.stderr)
            return 1
        findings = loaded
    manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
    manifest.update(
        {
            "classification_status": args.verdict,
            "verdict": args.verdict,
            "unresolved_critical_count": args.unresolved_critical_count,
            "unresolved_p1_count": args.unresolved_p1_count,
            "classified_findings": findings,
            "classified_at": now_iso(),
            "classification_recorded_by": "tautline finalize-implementation-review",
        }
    )
    tmp = manifest_path.with_name(f".{manifest_path.name}.tmp")
    tmp.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    tmp.replace(manifest_path)
    ledger_path = write_implementation_review_ledger(data, target, state, manifest_path, manifest)
    print(f"implementation_review_manifest: {path_display(target, manifest_path)}")
    print(f"implementation_review_ledger: {path_display(target, ledger_path)}")
    print(f"implementation_review_verdict: {args.verdict}")
    print(f"implementation_review_unresolved_critical_count: {args.unresolved_critical_count}")
    print(f"implementation_review_unresolved_p1_count: {args.unresolved_p1_count}")
    if args.verdict not in IMPLEMENTATION_REVIEW_ALLOWED_VERDICTS or args.unresolved_critical_count or args.unresolved_p1_count:
        print("implementation_review_result: blocked")
        try_write_event(
            data,
            target,
            event="implementation_review_finalized",
            severity="block",
            plain=f"Implementation review evidence was finalized as {args.verdict} with Critical={args.unresolved_critical_count}, P1={args.unresolved_p1_count}.",
            next_action="Fix unresolved Critical/P1 findings before push.",
            refs={
                "manifest": path_display(target, manifest_path),
                "ledger": path_display(target, ledger_path),
                # T2: structured fields the T3 reducer will read to choose
                # implementation_review_clean vs. implementation_review_blocked -- never freeform
                # text, always one of IMPLEMENTATION_REVIEW_ALL_VERDICTS.
                "verdict": args.verdict,
                "unresolved_critical_count": str(args.unresolved_critical_count),
                "unresolved_p1_count": str(args.unresolved_p1_count),
            },
        )
        return 1
    print("implementation_review_result: push-eligible")
    try_write_event(
        data,
        target,
        event="implementation_review_finalized",
        severity="ok",
        plain=f"Implementation review evidence was finalized as {args.verdict} with zero unresolved Critical/P1 findings.",
        next_action="Commit the tracked implementation review ledger, then run review-evidence-check --strict and continue the push or PR flow if it passes.",
        refs={
            "manifest": path_display(target, manifest_path),
            "ledger": path_display(target, ledger_path),
            # T2: structured fields the T3 reducer will read to choose
            # implementation_review_clean vs. implementation_review_blocked -- never freeform
            # text, always one of IMPLEMENTATION_REVIEW_ALL_VERDICTS.
            "verdict": args.verdict,
            "unresolved_critical_count": str(args.unresolved_critical_count),
            "unresolved_p1_count": str(args.unresolved_p1_count),
        },
    )
    return 0


_PREPUSH_ZERO_OID_RE = re.compile(r"^0+$")


def review_evidence_prepush_records_file() -> Path | None:
    """The regenerated pre-push hook template exports this path after capturing the hook's stdin
    ref-update records to a temp file (T5). A NOT-YET-REGENERATED installed hook never sets it, so
    the coordination allowance below reads nothing from stdin and the gate behaves exactly as
    before this release -- the backward-compatible handshake the design requires."""
    raw = util_module().resolve_env("MINERVIT_PREPUSH_RECORDS_FILE", "").strip()
    return Path(raw) if raw else None


def parse_prepush_ref_records(text: str) -> list[tuple[str, str, str, str]] | None:
    """Parse `<local ref> <local sha1> <remote ref> <remote sha1>` lines (the documented
    pre-push hook stdin format). Any malformed line invalidates the whole batch (fail closed)."""
    records: list[tuple[str, str, str, str]] = []
    for line in text.splitlines():
        if not line.strip():
            continue
        parts = line.split()
        if len(parts) != 4:
            return None
        records.append((parts[0], parts[1], parts[2], parts[3]))
    return records


def review_evidence_coordination_effective_base_candidate(data: dict, target: Path) -> str | None:
    """The adapter's configured review/pre-push base ref (same config review_wrapper_base reads
    for the ordinary evidence gate) when set, else the first existing shared-base ref."""
    configured = review_wrapper_base(data)
    if configured:
        return configured
    shared = git_shared_base_refs(target)
    return shared[0] if shared else None


def review_evidence_coordination_range_base(target: Path, data: dict, local_sha: str, remote_sha: str) -> str | None:
    """`<remote-tip>..<local-head>` when the remote ref already exists; for a brand-new remote ref
    (all-zero sha), merge-base(local-head, effective review base) -- NEVER full history. Returns
    None (no allowance) when no base is resolvable."""
    if _PREPUSH_ZERO_OID_RE.fullmatch(remote_sha.strip()):
        candidate = review_evidence_coordination_effective_base_candidate(data, target)
        if not candidate:
            return None
        code, stdout, _stderr = run_command(["git", "-C", str(target), "merge-base", local_sha, candidate], timeout=10)
        if code == 0 and stdout.strip():
            return stdout.strip()
        return None
    return remote_sha


def review_evidence_coordination_changed_paths(target: Path, base: str, head: str) -> list[str] | None:
    """Rename detection disabled: a source file renamed into a coordination path must surface its
    old (deleted) path so it is gated, not laundered as a coordination-only change."""
    code, stdout, _stderr = run_command(
        ["git", "-C", str(target), "diff", "--name-only", "--no-renames", base, head], timeout=20
    )
    if code != 0:
        return None
    return [line for line in stdout.splitlines() if line.strip()]


def review_evidence_coordination_artifact_roots(data: dict, target: Path) -> dict[str, Path] | None:
    """Resolve the two allowlisted artifact paths (contract/board) and the status-dir tree.
    Root/path resolution failure fails closed (no allowance) rather than raising into the gate."""
    try:
        paths = lane_coordination_paths(data, target)
        return {
            "contract": paths["contract"].resolve(strict=False),
            "board": paths["board"].resolve(strict=False),
            "laneStatusDir": paths["laneStatusDir"].resolve(strict=False),
        }
    except Exception:
        return None


def review_evidence_coordination_path_is_artifact(target: Path, rel_path: str, roots: dict[str, Path]) -> bool:
    """Classify the Git path itself, lexically (Codex T7 R1 P2): resolve() on the candidate
    would follow a symlink placed at rel_path and let a non-allowlisted source path masquerade
    as a coordination artifact. Only the target root is canonicalized (host /tmp indirection);
    the candidate's own components are joined lexically and never resolved. A coordination
    artifact reached through a symlinked CONFIGURED path therefore no longer matches -- that
    direction fails closed into today's ordinary gating."""
    try:
        candidate = Path(os.path.normpath(target.resolve(strict=False) / rel_path))
    except OSError:
        return False
    if candidate in (roots["contract"], roots["board"]):
        return True
    if candidate.suffix != ".md":
        return False
    try:
        # Purely lexical containment (Path.relative_to), NOT path_is_under: that helper
        # resolves the candidate, which follows an on-disk symlink and reopens the bypass.
        candidate.relative_to(roots["laneStatusDir"])
        return True
    except ValueError:
        return False


def review_evidence_coordination_allowance(data: dict, target: Path) -> bool:
    """T5: the pre-push evidence check passes without evidence manifests when stdin (relayed via
    MINERVIT_PREPUSH_RECORDS_FILE by the regenerated pre-push hook) contains EXACTLY ONE ref-update
    record whose whole range touches only coordination artifacts. Fail closed on any multi-ref
    push, missing/unreadable records, unresolved base, or path-resolution failure -- ambiguity
    always resolves to "no allowance, today's gating" because this is a push gate."""
    records_file = review_evidence_prepush_records_file()
    if records_file is None:
        return False
    try:
        text = records_file.read_text(encoding="utf-8")
    except OSError:
        return False
    records = parse_prepush_ref_records(text)
    if not records or len(records) != 1:
        return False
    _local_ref, local_sha, _remote_ref, remote_sha = records[0]
    if _PREPUSH_ZERO_OID_RE.fullmatch(local_sha.strip()):
        return False
    base = review_evidence_coordination_range_base(target, data, local_sha, remote_sha)
    if not base:
        return False
    changed = review_evidence_coordination_changed_paths(target, base, local_sha)
    if changed is None:
        return False
    roots = review_evidence_coordination_artifact_roots(data, target)
    if roots is None:
        return False
    return all(review_evidence_coordination_path_is_artifact(target, path, roots) for path in changed)


def review_evidence_check(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    drift_from = lane_session_plugin_drift(data, target)
    if drift_from:
        print(
            f"review_evidence_warn: methodology plugin advanced {drift_from} -> {plugin_version()} mid-session; "
            "re-render the adapter and confirm the review-evidence contract before relying on pre-advance manifests",
            file=sys.stderr,
        )
    if not implementation_review_enabled(data):
        print("review_evidence_check: disabled by adapter review.prePushReviewEvidence=false")
        return 0
    if review_evidence_coordination_allowance(data, target):
        print("review_evidence_check: pass - coordination-only outgoing range (pre-push coordination allowance)")
        return 0
    effective_base = implementation_review_effective_base(data, args.base)
    state, errors = implementation_review_state(target, effective_base)
    if state is None:
        print(f"review_evidence_check: failed - {'; '.join(errors)}", file=sys.stderr)
        return 1 if args.strict else 0
    if state["diff_bytes"] == 0:
        print("review_evidence_check: no outgoing diff relative to base; no implementation review evidence required")
        print(f"review_evidence_base: {state['base_ref']} {state['base_sha'][:12]}")
        return 0
    # RCA O10: enforce origin/main...HEAD scope (only once there is an actual outgoing diff to
    # review). --scope-strict always enforces. --strict also enforces when an origin base exists
    # OR an origin/upstream remote is configured (so an unfetched remote base is a
    # fetch-and-rebase error, not a silent stale-local-base pass — Codex P2). It degrades to a
    # warning only on genuinely remote-less repos.
    allowed_bases = implementation_review_allowed_base_refs(data, args.base)
    origin_base_exists = any(git_ref_exists(target, ref) for ref in allowed_bases)
    configured_remotes = run_git(target, ["remote"])
    origin_remote_configured = configured_remotes not in ("", "unavailable") and any(
        r in configured_remotes.split() for r in ("origin", "upstream")
    )
    scope_strict = getattr(args, "scope_strict", False)
    if state["base_ref"] not in allowed_bases:
        if scope_strict or (args.strict and (origin_base_exists or origin_remote_configured)):
            hint_base = implementation_review_hint_base(allowed_bases, effective_base)
            hint = (
                f"Fetch the configured base and rebase, or pass --base {hint_base}."
                if origin_base_exists
                else "An origin/upstream remote is configured but its base ref is unfetched; run `git fetch origin main` then rebase."
            )
            print(
                "review_evidence_check: failed - review base must be configured remote base...HEAD (one of "
                f"{sorted(allowed_bases)}); resolved base is {state['base_ref']!r}. {hint}",
                file=sys.stderr,
            )
            return 1
        if args.strict:
            print(
                f"review_evidence_warn: no origin base or remote found; reviewing against {state['base_ref']!r} (scope not origin-anchored)",
                file=sys.stderr,
            )
    manifest_path, manifest_errors = matching_implementation_review_evidence(data, target, state)
    if manifest_path:
        ledger_errors = implementation_review_ledger_errors(data, target, state, manifest_path)
        if ledger_errors:
            print("review_evidence_check: failed - current outgoing diff has no matching tracked implementation review ledger", file=sys.stderr)
            for error in ledger_errors[:5]:
                print(f"review_evidence_issue: {error}", file=sys.stderr)
            print(
                "review_evidence_next_action: rerun `tautline finalize-implementation-review --target . --manifest <manifest> "
                "--verdict <clean|clean-with-deferrals|blocked> --unresolved-critical-count <n> --unresolved-p1-count <n>`, "
                "then include the tracked `.impl-reviews/` ledger file in the PR commit before push.",
                file=sys.stderr,
            )
            return 1 if args.strict else 0
        print("review_evidence_check: pass")
        print(f"review_evidence_manifest: {path_display(target, manifest_path)}")
        print(f"review_evidence_ledger: {path_display(target, implementation_review_ledger_path(data, target, state))}")
        print(f"review_evidence_base: {state['base_ref']} {state['base_sha'][:12]}")
        print(f"review_evidence_head: {state['head_sha'][:12]}")
        return 0
    print("review_evidence_check: failed - current outgoing diff has no matching Stage 2 Codex review evidence", file=sys.stderr)
    for error in manifest_errors[:5]:
        print(f"review_evidence_issue: {error}", file=sys.stderr)
    print(
        "review_evidence_next_action: for explicit T1 work, run one Codex review with "
        "`tautline codex-run --target . --risk-tier T1 --review-round R1 -- <adapter review.codexWrapper>`; "
        "for T2/T3 or unspecified risk, first run native/Superpowers review and `record-stage1-sweep`, then run "
        "`tautline codex-run --target . --risk-tier T2 --review-round R1 --native-review-note '<native review result>' "
        "--stage1-sweep <sweep> -- <adapter review.codexWrapper>`. Inspect/classify the log, run "
        "`tautline finalize-implementation-review --target . --manifest <manifest> --verdict <clean|clean-with-deferrals|blocked> "
        "--unresolved-critical-count <n> --unresolved-p1-count <n>`, fix Critical/P1 findings, then retry push.",
        file=sys.stderr,
    )
    return 1 if args.strict else 0


def lane_run(args: argparse.Namespace) -> int:
    if not args.command:
        raise SystemExit("lane-run requires a command after --")
    data, _, target = lane_project(args)
    ensure_lane_state(data, target)
    env_path = write_lane_env(data, target)
    env = os.environ.copy()
    env.update(lane_env_values(data, target))
    env = codex_fast_mode_env(data, target, env)
    print(f"lane_env: {env_path}", flush=True)
    print(f"codex_fast_mode: {codex_fast_mode_status(data)}", flush=True)
    print(f"lane_run: {' '.join(shlex.quote(part) for part in args.command)}", flush=True)
    proc = subprocess.run(args.command, cwd=str(target), env=env, check=False)
    return proc.returncode


IMPLEMENTATION_REVIEW_ORIGIN_BASES = {"origin/main", "origin/master", "upstream/main", "upstream/master"}


def finalized_clean_implementation_rounds(target: Path, branch: str, diff_sha256: str | None = None) -> int:
    """Count finalized Stage 2 manifests for this branch/current diff that classified clean with
    zero unresolved Critical/P1 findings.

    The budget is scoped to the current work product (`diff_sha256`) when available, so a direct-to-
    main lane with many historical clean rounds cannot suppress the first Codex review for a new
    outgoing diff.
    """
    evidence_dir = implementation_review_evidence_dir(target)
    if not evidence_dir.is_dir():
        return 0
    branch_slug = slugify(branch, "branch")
    count = 0
    for path in sorted(evidence_dir.glob(f"*-{branch_slug}-{IMPLEMENTATION_REVIEW_STAGE}.json")):
        try:
            manifest = json.loads(path.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError):
            continue
        if manifest.get("schema") != IMPLEMENTATION_REVIEW_SCHEMA:
            continue
        if diff_sha256 and manifest.get("diff_sha256") != diff_sha256:
            continue
        if str(manifest.get("verdict", "")) not in IMPLEMENTATION_REVIEW_ALLOWED_VERDICTS:
            continue
        if str(manifest.get("classification_status", "")) not in IMPLEMENTATION_REVIEW_ALLOWED_VERDICTS:
            continue
        try:
            if int(manifest.get("unresolved_critical_count", -1)) == 0 and int(manifest.get("unresolved_p1_count", -1)) == 0:
                count += 1
        except (TypeError, ValueError):
            continue
    return count


def implementation_diff_touches_behavior_surface(data: dict, target: Path, base_sha: str) -> bool:
    """True when the outgoing diff changes a .feature file or a configured acceptance-harness
    appPath (RCA O1: only gate Codex rounds on behavior-spec health when the diff actually
    touches the behavior surface, not for doc-only diffs)."""
    out = run_git(target, ["diff", "--name-only", f"{base_sha}...HEAD"])
    if out in ("", "unavailable"):
        return False
    changed = [line.strip() for line in out.splitlines() if line.strip()]
    if any(name.endswith(".feature") for name in changed):
        return True
    app_prefixes: list[str] = []
    for harness in data.get("behaviorSpecs", {}).get("acceptanceHarnesses", []):
        app_path_text = str(harness.get("appPath", "")).strip().strip("/")
        if app_path_text:
            app_prefixes.append(app_path_text)
    return any(name == prefix or name.startswith(prefix + "/") for name in changed for prefix in app_prefixes)


def git_added_line_ranges(target: Path, base_sha: str) -> dict[str, list[tuple[int, int]]]:
    """Map each changed file to the HEAD-side line ranges this outgoing diff adds or
    modifies, so behavior checks can scope to the lines the diff actually introduced
    (RCA 20260615T071304Z: a behavior-touching PR must not be blocked by pre-existing
    repo-wide inactive-scenario debt it never touched).

    `--relative` keys the ranges by target-relative path so they line up with
    path_relative_to_target() in the caller; without it a sub-directory target would key by
    repo-root-relative path, the lookup would miss, and a non-compliant scenario the diff
    itself introduced would be mis-classified as pre-existing debt (fail-open). For a
    repo-root target this is identical to the unscoped diff."""
    out = run_git(target, ["diff", "--relative", "--unified=0", f"{base_sha}...HEAD"])
    ranges: dict[str, list[tuple[int, int]]] = {}
    if out in ("", "unavailable"):
        return ranges
    current: str | None = None
    for line in out.splitlines():
        if line.startswith("+++ "):
            name = line[4:].strip()
            if name.startswith("b/"):
                current = name[2:]
            elif name == "/dev/null":
                current = None
            else:
                current = name
        elif line.startswith("@@") and current:
            match = re.search(r"\+(\d+)(?:,(\d+))?", line)
            if match:
                start = int(match.group(1))
                count = int(match.group(2)) if match.group(2) is not None else 1
                if count > 0:
                    ranges.setdefault(current, []).append((start, start + count - 1))
    return ranges


def scenario_in_added_ranges(scenario: dict, added_ranges: list[tuple[int, int]]) -> bool:
    """True when this outgoing diff's HEAD-side added ranges intersect the inactive scenario's
    span (its first @pending tag through its body window), so the diff-scoped behavior-spec gate
    treats a scenario as introduced/modified by this diff when the diff touched the tag, the
    Scenario: line, or the body — not only the single Scenario: line (which a tag-only pend or an
    in-body edit would miss, falling through to the non-blocking pre-existing-debt bucket)."""
    start = int(scenario.get("spanStart", scenario["line"]))
    end = int(scenario.get("spanEnd", scenario["line"]))
    if end < start:
        start, end = end, start
    return any(lo <= end and start <= hi for lo, hi in added_ranges)


def codex_run(args: argparse.Namespace) -> int:
    if not args.command:
        raise SystemExit("codex-run requires a command after --")
    data, _, target = lane_project(args)
    env = codex_fast_mode_env(data, target, os.environ.copy())
    print(f"codex_fast_mode: {codex_fast_mode_status(data)}", flush=True)
    print(f"codex_run: {command_string(args.command)}", flush=True)
    should_record = implementation_review_enabled(data) and command_matches_codex_wrapper(data, args.command)
    risk_tier = str(getattr(args, "risk_tier", "") or "").strip()
    review_round = str(getattr(args, "review_round", "") or "").strip()
    if should_record and risk_tier == "T0":
        print(
            "codex_run_error: T0 work does not use cross-model implementation review; run self-review plus required tests/preflight instead.",
            file=sys.stderr,
        )
        return 1
    if should_record and risk_tier and review_round:
        round_number = implementation_review_round_number(review_round)
        budget = implementation_review_round_budget(data, risk_tier)
        if round_number is None:
            print(f"codex_run_error: --review-round must include a numeric round marker such as R1, got {review_round!r}", file=sys.stderr)
            return 1
        if budget is None:
            print(f"codex_run_error: no implementation review round budget configured for risk tier {risk_tier}", file=sys.stderr)
            return 1
        if round_number > budget:
            print(
                f"codex_run_error: implementation review round {review_round} exceeds adapter budget {budget} for {risk_tier}; "
                "split/defer/escalate instead of spending more Codex rounds on this PR.",
                file=sys.stderr,
            )
            return 1
    if should_record:
        branch = run_git(target, ["branch", "--show-current"]) or "detached-head"
        effective_base = implementation_review_effective_base(data)
        state_for_budget, _state_budget_errors = implementation_review_state(target, effective_base)
        diff_sha256_for_budget = str(state_for_budget.get("diff_sha256") or "") if state_for_budget else ""
        clean_rounds = finalized_clean_implementation_rounds(target, branch, diff_sha256_for_budget or None)
        if clean_rounds >= 2:
            extra_round_reason = str(getattr(args, "extra_round_reason", "") or "").strip()
            if not getattr(args, "allow_extra_rounds", False):
                print(
                    f"codex_run_error: {clean_rounds} implementation-review rounds already finalized clean with zero "
                    "unresolved Critical/P1 findings for the current outgoing diff. Stop spending Codex rounds on non-blocking findings: "
                    "run `finalize-implementation-review --verdict clean-with-deferrals` (routing P2/P3/Nit to the backlog), "
                    "or record a documented exhaustive same-class native sweep before another round. Pass --allow-extra-rounds "
                    '--extra-round-reason "<genuine new defect class>" to override.',
                    file=sys.stderr,
                )
                return 1
            if len(extra_round_reason) < 12:
                print(
                    'codex_run_error: --allow-extra-rounds requires --extra-round-reason "<reason>" naming the genuine '
                    "new defect class (at least 12 characters); a placeholder is not accepted.",
                    file=sys.stderr,
                )
                return 1
            print(f"codex_run_extra_round_reason: {extra_round_reason}", flush=True)
    native_review_note = str(getattr(args, "native_review_note", "") or "").strip()
    stage1_required = should_record and implementation_stage1_required_for_risk_tier(risk_tier)
    if stage1_required and not native_review_note:
        print(
            "codex_run_error: implementation Codex review requires --native-review-note before --. "
            "Run Stage 1 native/Superpowers review on the current assembled diff, fix blockers, then retry.",
            file=sys.stderr,
        )
        return 1
    if should_record and native_review_note:
        note_errors = native_review_note_errors(native_review_note)
        if note_errors:
            print(
                "codex_run_error: --native-review-note must summarize the completed native review for this assembled diff: "
                + "; ".join(note_errors),
                file=sys.stderr,
            )
            return 1
    if should_record:
        effective_base = implementation_review_effective_base(data)
        state, state_errors = implementation_review_state(target, effective_base)
        if state is None:
            print(f"codex_run_error: {'; '.join(state_errors)}", file=sys.stderr)
            return 1
    if stage1_required:
        if not getattr(args, "stage1_sweep", None):
            print(
                "codex_run_error: implementation Codex review requires --stage1-sweep <artifact> before --. "
                "Run `tautline record-stage1-sweep --target . --native-review-note '<native review result>' --class '<defect class>: <members checked>'` on the current assembled diff, then retry.",
                file=sys.stderr,
            )
            return 1
        stage1_sweep_path = cli_path(target, str(args.stage1_sweep))
        sweep_errors = stage1_sweep_errors(target, state, stage1_sweep_path)
        if sweep_errors:
            print("codex_run_error: --stage1-sweep is not valid for the current assembled diff: " + "; ".join(sweep_errors), file=sys.stderr)
            return 1
    else:
        stage1_sweep_path = Path("")
    if should_record:
        allowed_bases = implementation_review_allowed_base_refs(data)
        if getattr(args, "scope_strict", False) and state["base_ref"] not in allowed_bases:
            # RCA O10: a scoped review must compare the configured remote base...HEAD, not a stale local base.
            print(
                f"codex_run_error: --scope-strict requires the review base to be one of "
                f"{sorted(allowed_bases)}; resolved base is {state['base_ref']!r}. "
                f"Fetch the configured base and rebase, or pass --base {implementation_review_hint_base(allowed_bases, effective_base)}.",
                file=sys.stderr,
            )
            return 1
        behavior = data.get("behaviorSpecs", {})
        if behavior.get("required") and str(behavior.get("inactiveAcceptanceEnforcement", "strict")) == "strict":
            if implementation_diff_touches_behavior_surface(data, target, state["base_sha"]):
                spec_status = behavior_spec_status_record(data, target)
                # RCA 20260615T071304Z (gate-regression fix): diff-scope the behavior-spec
                # block. A behavior-touching PR must NOT be blocked by PRE-EXISTING repo-wide
                # inactive-scenario debt it did not introduce. Block only on (a) inactive
                # scenarios THIS diff adds/modifies that lack owner+un-pend metadata, and
                # (b) structural/harness issues; surface pre-existing inactive-scenario debt
                # as a non-blocking warning so it stays visible without gridlocking review.
                added_ranges = git_added_line_ranges(target, state["base_sha"])

                def _scenario_in_diff(scenario: dict) -> bool:
                    rel = path_relative_to_target(target, scenario["path"])
                    return scenario_in_added_ranges(scenario, added_ranges.get(rel, []))

                blocking_scenarios: list[str] = []
                if behavior.get("pendingRequiresOwnerAndTrigger", True):
                    for scenario in spec_status["pendingScenarios"]:
                        if not _scenario_in_diff(scenario):
                            continue
                        context = str(scenario["context"]).lower()
                        missing: list[str] = []
                        if "owner:" not in context:
                            missing.append("owner")
                        if "un-pend" not in context and "unpend" not in context and "trigger:" not in context and "remove @pending when" not in context:
                            missing.append("un-pend trigger")
                        if missing:
                            rel = path_relative_to_target(target, scenario["path"])
                            blocking_scenarios.append(
                                f"inactive scenario missing {', '.join(missing)}: {rel}:{scenario['line']} {scenario['name']}"
                            )
                # Structural/harness issues are config defects (not pre-existing scenario debt) — keep blocking.
                # The repo-wide "inactive acceptance scenarios exceed limit" count also starts with
                # "inactive " and is intentionally treated as non-blocking debt here: it is a standing
                # repo-count concern, not a defect this specific diff introduces.
                structural_issues = [issue for issue in spec_status["issues"] if not issue.startswith("inactive ")]
                blocking_issues = blocking_scenarios + structural_issues
                if blocking_issues:
                    # RCA O1: do not spend a Codex round on a diff that itself introduces missing/inactive
                    # behavior specs on the surface it changes, or breaks the acceptance harness.
                    print(
                        "codex_run_error: behavior-spec-status reports issues this diff introduces on the behavior "
                        "surface (.feature or acceptance-harness appPath). Fix the specs before the Codex round: "
                        + "; ".join(blocking_issues[:5]),
                        file=sys.stderr,
                    )
                    return 1
                preexisting_issues = [issue for issue in spec_status["issues"] if issue.startswith("inactive ")]
                if preexisting_issues:
                    print(
                        f"codex_run_warning: {len(preexisting_issues)} pre-existing behavior-spec inactive-scenario "
                        "issue(s) NOT introduced by this diff (not blocking; tracked as repo debt): "
                        + "; ".join(preexisting_issues[:2]),
                        file=sys.stderr,
                    )
    else:
        stage1_sweep_path = Path("")
    log_path: Path | None = None
    started_at = now_iso()
    if should_record:
        evidence_dir = implementation_review_evidence_dir(target)
        evidence_dir.mkdir(parents=True, exist_ok=True)
        stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
        branch = run_git(target, ["branch", "--show-current"]) or "detached-head"
        log_path = evidence_dir / f"{stamp}-{slugify(branch, 'branch')}-{IMPLEMENTATION_REVIEW_STAGE}.log"
        print(f"review_evidence_log: {path_display(target, log_path)}", flush=True)
        if stage1_required:
            print("implementation_review_native_review_required: Stage 1 native/Superpowers review on current assembled diff before this Stage 2 Codex round", flush=True)
            print(f"implementation_review_native_review_note: {native_review_note}", flush=True)
            print(f"implementation_review_stage1_sweep: {path_display(target, stage1_sweep_path)}", flush=True)
        else:
            print("implementation_review_native_review_required: no (T1 keeps one cross-model implementation review without Stage 1 sweep evidence)", flush=True)
        try_write_event(
            data,
            target,
            event="implementation_review_started",
            severity="info",
            plain="Codex implementation review started for the current assembled diff.",
            next_action="Wait for the review to finish, then classify the evidence manifest before push.",
            refs={"log": path_display(target, log_path)},
        )
    if log_path is None:
        proc = subprocess.run(args.command, cwd=str(target), env=env, check=False)
        return proc.returncode
    with log_path.open("w", encoding="utf-8", errors="replace") as handle:
        handle.write("MINERVIT_IMPLEMENTATION_REVIEW_RUN_V1\n")
        handle.write("review_scope: implementation-diff\n")
        handle.write(f"native_review_required: {'yes' if stage1_required else 'no'}\n")
        handle.write(
            "native_review_requirement: "
            + (
                "Stage 1 native/Superpowers review on current assembled diff before this Stage 2 Codex round"
                if stage1_required
                else "not required for explicit T1 implementation review"
            )
            + "\n"
        )
        if native_review_note:
            handle.write(f"native_review_note: {native_review_note}\n")
        if risk_tier:
            handle.write(f"risk_tier: {risk_tier}\n")
        if review_round:
            handle.write(f"review_round: {review_round}\n")
        if stage1_required:
            handle.write(f"stage1_sweep: {path_display(target, stage1_sweep_path)}\n")
        handle.write("--- review output ---\n")
        handle.flush()
        proc = subprocess.Popen(
            args.command,
            cwd=str(target),
            env=env,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
            encoding="utf-8",
            errors="replace",
            bufsize=1,
        )
        assert proc.stdout is not None
        for line in proc.stdout:
            handle.write(line)
            handle.flush()
            print(line, end="", flush=True)
        returncode = proc.wait()
    finished_at = now_iso()
    manifest = write_implementation_review_evidence(
        data,
        target,
        args.command,
        log_path,
        returncode,
        started_at,
        finished_at,
        native_review_note,
        stage1_sweep_path,
        risk_tier or None,
        review_round or None,
    )
    if manifest is not None:
        print(f"review_evidence_manifest: {path_display(target, manifest)}", flush=True)
        try_write_event(
            data,
            target,
            event="implementation_review_finished" if returncode == 0 else "implementation_review_failed",
            severity="ok" if returncode == 0 else "fail",
            plain=f"Codex implementation review finished with exit code {returncode}.",
            next_action="Inspect and classify the review log with finalize-implementation-review before push.",
            refs={"log": path_display(target, log_path), "manifest": path_display(target, manifest)},
        )
        print(
            "review_evidence_next_action: inspect/classify the log, then run "
            f"`tautline finalize-implementation-review --target . --manifest {shlex.quote(path_display(target, manifest))} "
            "--verdict <clean|clean-with-deferrals|blocked> --unresolved-critical-count <n> --unresolved-p1-count <n>`",
            flush=True,
        )
    elif returncode == 0 and should_record:
        print("review_evidence_warning: review completed but evidence manifest was not written", file=sys.stderr, flush=True)
    return returncode



def github_repo_identity(target: Path) -> tuple[str | None, str | None, str | None]:
    code, payload, err = command_json(["gh", "repo", "view", "--json", "owner,name"], target, timeout=15)
    if code != 0 or not isinstance(payload, dict):
        return None, None, err or "gh repo view failed"
    owner = payload.get("owner")
    owner_login = owner.get("login") if isinstance(owner, dict) else owner
    name = payload.get("name")
    if not isinstance(owner_login, str) or not isinstance(name, str):
        return None, None, "gh repo view did not return owner/name"
    return owner_login, name, None


def github_remote_present(target: Path) -> bool:
    remotes = run_git(target, ["remote", "-v"])
    if remotes == "unavailable":
        return False
    return "github.com" in remotes.lower()


def github_pr_queue_state(target: Path, owner: str, name: str, number: int) -> tuple[bool, str, str | None]:
    query = """
query($owner: String!, $name: String!, $number: Int!) {
  repository(owner: $owner, name: $name) {
    pullRequest(number: $number) {
      isInMergeQueue
      mergeQueueEntry { state }
    }
  }
}
"""
    code, payload, err = command_json(
        [
            "gh",
            "api",
            "graphql",
            "-f",
            f"query={query}",
            "-F",
            f"owner={owner}",
            "-F",
            f"name={name}",
            "-F",
            f"number={number}",
        ],
        target,
        timeout=15,
    )
    if code != 0 or not isinstance(payload, dict):
        return False, "", err or "gh api graphql failed"
    errors = payload.get("errors")
    if errors:
        return False, "", f"GitHub GraphQL errors: {json.dumps(errors)[:500]}"
    data = payload.get("data")
    if not isinstance(data, dict):
        return False, "", "GraphQL response did not include data"
    repository = data.get("repository")
    if not isinstance(repository, dict):
        return False, "", "GraphQL response did not include repository"
    pr = repository.get("pullRequest")
    if not isinstance(pr, dict):
        return False, "", "GraphQL response did not include pullRequest"
    entry = pr.get("mergeQueueEntry")
    entry_state = entry.get("state") if isinstance(entry, dict) else ""
    return bool(pr.get("isInMergeQueue")), str(entry_state or ""), None


PR_HARD_FAILURE_CONCLUSIONS = {"FAILURE", "TIMED_OUT", "STARTUP_FAILURE", "CANCELLED"}
PR_FAILURE_STATES = {"FAILURE", "ERROR"}


def pr_failed_required_check(pr: dict, required_check_name: str) -> str | None:
    """Pure: given a PR's statusCheckRollup, return a failure reason iff the adapter's required CI
    check CONCLUDED a hard failure, else None. Opt-in -- an empty required_check_name never blocks, so
    the gate cannot over-block a lane that has not configured it. Provider-agnostic: a GitHub Actions
    CheckRun (conclusion) and a Jenkins/other commit StatusContext (state) are both honored.
    in-progress / queued / pending / neutral / skipped / missing are NOT-yet-provable and never block
    (FM3: only a concluded red required check stops the branch)."""
    want = (required_check_name or "").strip().lower()
    if not want:
        return None
    rollup = pr.get("statusCheckRollup")
    if not isinstance(rollup, list):
        return None
    for check in rollup:
        if not isinstance(check, dict):
            continue
        name = str(check.get("name") or check.get("context") or "").strip()
        if name.lower() != want:
            continue
        conclusion = str(check.get("conclusion") or "").upper()
        status = str(check.get("status") or "").upper()
        ctx_state = str(check.get("state") or "").upper()
        if conclusion in PR_HARD_FAILURE_CONCLUSIONS:
            return f"check '{name}' concluded {conclusion}"
        # Legacy commit status (e.g. Jenkins): no conclusion/status -- a state of FAILURE/ERROR is red.
        if not conclusion and not status and ctx_state in PR_FAILURE_STATES:
            return f"status check '{name}' is {ctx_state}"
    return None


def branch_liveness_required_check(target: Path) -> str:
    """Best-effort: the rendered adapter's ciTestGate.requiredCheckName ('' if unavailable). Reads
    the lane marker via adapter_marker_path so a missing/malformed adapter never wedges branch_liveness."""
    try:
        data = json.loads(adapter_marker_path(target).read_text(encoding="utf-8"))
    except (OSError, ValueError):
        return ""
    cfg = data.get("ciTestGate") if isinstance(data, dict) else None
    name = cfg.get("requiredCheckName") if isinstance(cfg, dict) else None
    return name.strip() if isinstance(name, str) else ""


def branch_liveness_adapter_data(target: Path, project_arg: Path | None = None) -> dict:
    """Best-effort adapter read for branch-liveness branch classification.

    This must not use lane_project(): branch-liveness is a protective hook and should not wedge on
    unrelated adapter validation. Missing or malformed adapter data falls back to the strict default
    branch set.
    """
    candidates: list[Path] = []
    if project_arg is not None:
        project_path = project_arg.expanduser()
        if not project_path.is_absolute():
            project_path = target / project_path
        candidates.append(project_path)
    candidates.append(adapter_marker_path(target))
    candidates.append(repo_local_source_adapter_path(target))
    for path in candidates:
        try:
            payload = json.loads(path.read_text(encoding="utf-8"))
        except (OSError, ValueError):
            continue
        if isinstance(payload, dict):
            return payload
    return {}


def adapter_target_is_methodology_repo(data: dict, target: Path | None = None) -> bool:
    if not adapter_is_methodology_repo(data):
        return False
    if target is None or not target_is_git_worktree(target):
        return False
    inferred = normalize_repo_slug(infer_repo_slug(target))
    return inferred in {normalize_repo_slug(slug) for slug in methodology_repo_slugs()}


def adapter_base_branch_names(data: dict, target: Path | None = None) -> set[str]:
    names = set(BASE_BRANCH_NAMES)
    if adapter_target_is_methodology_repo(data, target):
        names.add("experimental")
    return names


def branch_liveness_base_branch_names(target: Path, project_arg: Path | None = None) -> set[str]:
    data = branch_liveness_adapter_data(target, project_arg)
    return adapter_base_branch_names(data, target)


def branch_liveness_check(args: argparse.Namespace) -> int:
    target = args.target.expanduser().resolve(strict=False)
    strict = bool(getattr(args, "strict", False))
    branch = run_git(target, ["branch", "--show-current"])
    if not branch or branch == "unavailable":
        message = "not on a named git branch"
        if strict:
            print(f"branch_liveness_error: {message}", file=sys.stderr)
            return 1
        print(f"branch_liveness: skipped - {message}")
        return 0
    print(f"branch_liveness_branch: {branch}")
    if branch in branch_liveness_base_branch_names(target, getattr(args, "project", None)):
        print("branch_liveness: pass - base branch")
        return 0
    if not github_remote_present(target):
        print("branch_liveness: pass - no GitHub remote detected")
        return 0
    if shutil.which("gh") is None:
        if strict:
            print("branch_liveness_error: GitHub CLI not available and current non-base branch has a GitHub remote", file=sys.stderr)
            return 1
        print("branch_liveness: skipped - GitHub CLI not available")
        return 0

    fields = "number,title,url,state,mergedAt,closedAt,headRefName,baseRefName,mergeStateStatus,autoMergeRequest,statusCheckRollup"
    code, payload, err = command_json(
        ["gh", "pr", "list", "--head", branch, "--state", "all", "--limit", "20", "--json", fields],
        target,
        timeout=15,
    )
    if code != 0:
        if not github_remote_present(target):
            print("branch_liveness: pass - non-GitHub remote")
            return 0
        print(f"branch_liveness_error: could not inspect PRs for current branch: {err}", file=sys.stderr)
        return 1
    if not isinstance(payload, list):
        print("branch_liveness_error: gh pr list returned unexpected JSON", file=sys.stderr)
        return 1
    if not payload:
        print("branch_liveness: pass - no PR found for current branch")
        return 0

    owner, repo_name, repo_err = github_repo_identity(target)
    required_check = branch_liveness_required_check(target)
    active_found = False
    hard_failures: list[str] = []
    inactive_failures: list[str] = []
    for pr in payload:
        if not isinstance(pr, dict):
            continue
        number = pr.get("number")
        state = str(pr.get("state") or "").upper()
        url = pr.get("url") or f"PR #{number}"
        merged_at = pr.get("mergedAt")
        closed_at = pr.get("closedAt")
        merge_state = str(pr.get("mergeStateStatus") or "").upper()
        auto_merge = pr.get("autoMergeRequest") is not None
        queued = False
        queue_state = ""
        queue_error = None
        if state not in {"MERGED", "CLOSED"} and not merged_at and not closed_at:
            if owner and repo_name and isinstance(number, int):
                queued, queue_state, queue_error = github_pr_queue_state(target, owner, repo_name, number)
            else:
                queue_error = repo_err or "could not resolve GitHub repository identity"
        print(
            "branch_liveness_pr: "
            + f"#{number} state={state or 'UNKNOWN'} merge_state={merge_state or 'UNKNOWN'} "
            + f"queued={str(queued).lower()} queue_state={queue_state or 'none'} "
            + f"auto_merge={str(auto_merge).lower()} url={url}"
        )
        if state == "MERGED" or merged_at:
            inactive_failures.append(f"PR #{number} is merged; current branch is dead")
        elif state == "CLOSED" or closed_at:
            inactive_failures.append(f"PR #{number} is closed; current branch is dead")
        elif queue_error:
            hard_failures.append(f"PR #{number} queue state could not be verified: {queue_error}")
        elif queued or queue_state or merge_state == "QUEUED" or auto_merge:
            hard_failures.append(f"PR #{number} is queued/auto-merge enabled; current branch is no longer active work")
        elif state == "OPEN":
            active_found = True
            check_failure = pr_failed_required_check(pr, required_check)
            if check_failure:
                hard_failures.append(
                    f"PR #{number} is not green: {check_failure}; a not-green PR must not be treated as "
                    "active work -- fix the red required check before continuing on this branch"
                )

    failures = hard_failures or ([] if active_found else inactive_failures)
    if failures:
        for failure in failures:
            print(f"branch_liveness_error: {failure}", file=sys.stderr)
        print(
            "branch_liveness_next_action: stop committing/reviewing/pushing this branch; sync main and continue from the source-of-truth next work",
            file=sys.stderr,
        )
        return 1
    print("branch_liveness: pass - current branch PR remains active")
    return 0


def default_continuity_content() -> str:
    return """## Current State
- TODO

## Decisions Made
- TODO

## Files Changed
- TODO

## Validation
- TODO

## Open Risks Or Blockers
- TODO

## Next Action
- TODO
"""


def write_continuity_handoff(data: dict, target: Path, content: str) -> Path:
    continuity = data["continuity"]
    handoff = target / continuity["path"]
    archive_dir = target / continuity["archiveDir"]

    if handoff.exists() and handoff.read_text(encoding="utf-8").strip():
        archive_dir.mkdir(parents=True, exist_ok=True)
        stamp = time.strftime("%Y%m%d-%H%M%S")
        handoff.replace(archive_dir / f"{stamp}-NEXT_SESSION.md")

    ensure_lane_state(data, target)

    branch = run_git(target, ["branch", "--show-current"])
    head = run_git(target, ["rev-parse", "--short", "HEAD"])
    status = run_git(target, ["status", "--short", "--branch"])
    last_commit = run_git(target, ["log", "-1", "--oneline"])

    if not content:
        content = default_continuity_content()

    handoff.parent.mkdir(parents=True, exist_ok=True)
    rendered = (
        f"# Continuity Handoff - {data['project']}\n\n"
        "> Lane-local handoff for the next AI session. Read this before starting new work.\n\n"
        "## Metadata\n"
        f"- Project: {data['project']}\n"
        f"- Repo: {data['repo']}\n"
        f"- Target: {target}\n"
        f"- Methodology CLI resolver: `tautline` from `PATH`, or `${{TAUTLINE_METHODOLOGY_REPO:-$MINERVIT_METHODOLOGY_REPO}}/bin/tautline` after sourcing `$HOME/.config/tautline/tautline.env` (legacy minervit paths still resolve).\n"
        f"- Generated: {now_iso()}\n"
        f"- Branch: {branch}\n"
        f"- HEAD: {head}\n"
        f"- Last commit: {last_commit}\n\n"
        "## Git Status At Handoff\n\n"
        "```text\n"
        f"{status}\n"
        "```\n\n"
        "## Required Startup Gates\n"
        f"- This handoff is the continuity source, but it does not replace startup gates.\n"
        f"- Read this section, run the gates below in listed order, then return to `## Handoff Content`. Do not execute from Handoff Content before the gates pass.\n"
        f"- Do not ask clarifying questions, report substantive status, or start analysis from Handoff Content before the methodology gates pass. The only exception is naming a true blocker that prevents running the methodology CLI.\n"
        f"- Resolve the methodology CLI before running gates: use `tautline` (legacy `minervit-methodology`) from `PATH`, or if it is missing from `PATH` or exits 127/command-not-found, source `$HOME/.config/tautline/tautline.env` (or the legacy minervit env) when present or use `${{TAUTLINE_METHODOLOGY_REPO:-$MINERVIT_METHODOLOGY_REPO}}/bin/tautline`, then rerun the same gate. A missing `PATH` entry is not a failed methodology gate, not permission to substitute ad hoc checks, and not a reason to ask for a person-specific path.\n"
        f"- If both the `PATH` command and portable checkout fallback are unavailable, name the exact missing CLI/checkout true blocker before using Handoff Content.\n"
        f"- Methodology gate 1: run `tautline lane-start --target .`, or the resolved portable CLI equivalent if the `PATH` command is missing.\n"
        f"- Methodology gate 2: run `tautline methodology-status --target . --fail-on-drift`, or the resolved portable CLI equivalent if the `PATH` command is missing.\n"
        f"- Do not replace the methodology status gate with ad hoc `git status`, `git log`, CI checks, or local test commands.\n"
        f"- If either methodology gate is skipped or fails, stop before Handoff Content, resolve the gate failure, rerun the gate, and proceed only after it exits clean. If it cannot be resolved safely, name the exact true blocker.\n"
        f"- After the methodology status gate is clean, resume from this handoff: `{continuity['path']}`.\n"
        f"- Project gate after methodology status: `{data['commands']['mainStatus']}`.\n"
        f"- Project gate after methodology status: `{data['commands']['openPrCheck']}`.\n"
        f"- Project gate after methodology status: `{data['commands']['mergeConflictCheck']}`.\n\n"
        "## Handoff Content\n\n"
        f"{content.rstrip()}\n"
    )
    handoff.write_text(rendered, encoding="utf-8")
    return handoff


def prepare_continuity(args: argparse.Namespace) -> int:
    data = load_project(args.project)
    target = args.target.resolve()
    continuity = data["continuity"]

    content = sys.stdin.read().strip() if args.stdin else ""
    if args.content_file:
        content = args.content_file.read_text(encoding="utf-8").strip()

    handoff = write_continuity_handoff(data, target, content)
    print(f"wrote {handoff}")
    if continuity.get("gitIgnore", False):
        print(f"ignored {first_dir_pattern(continuity['path'])} via .git/info/exclude")
    try_write_event(
        data,
        target,
        event="continuity_written",
        severity="ok",
        plain=f"Continuity handoff was refreshed at {path_display(target, handoff)}.",
        next_action="Resume from the handoff next action after startup gates.",
    )
    return 0


def session_journal_local_dir(data: dict, target: Path) -> Path:
    return target / data["laneState"]["runsDir"] / "session-journals"


def session_journal_declared(data: dict, project_path: Path, target: Path) -> bool:
    """True when the SOURCE adapter takes a position on sessionJournal.enabled.

    Generated .tautline.json markers embed the merged sessionJournal block, so the
    marker itself always "declares" it; the adopter's actual choice lives in the
    _generated.sourceAdapter file. Unreadable/missing sources count as declared so a
    broken lane never nags."""
    source_rel = str((data.get("_generated") or {}).get("sourceAdapter") or "")
    source_path = (target / source_rel) if source_rel else project_path
    try:
        raw = json.loads(source_path.read_text(encoding="utf-8"))
    except (OSError, ValueError):
        return True
    block = raw.get("sessionJournal")
    return isinstance(block, dict) and "enabled" in block


def session_journal_optin_hint(data: dict, project_path: Path, target: Path) -> str:
    if data["sessionJournal"].get("enabled") or session_journal_declared(data, project_path, target):
        return ""
    return (
        "session_journal_optin: want to help make Tautline better? Session journals capture "
        "framework-improvement signals at milestone boundaries (off by default; kept local only — "
        "narrative journals can no longer be published to any remote as of 0.9.0). The safe way to "
        'contribute upstream is sanitized instrumentation: add "instrumentation": {"enabled": true} '
        "(a closed-vocabulary record with zero product-information capacity) and use "
        "`publish-instrumentation-record`. Learn more: docs/reference/instrumentation.md"
    )


def session_journal_published_marker(path: Path) -> Path:
    return path.with_name(f"{path.name}.published.json")


def pending_session_journals(data: dict, target: Path) -> list[Path]:
    journal_dir = session_journal_local_dir(data, target)
    if not journal_dir.exists():
        return []
    return sorted(
        path
        for path in journal_dir.glob("*-session-journal.md")
        if path.is_file() and not session_journal_published_marker(path).exists()
    )


def unignored_committable_session_journals(data: dict, target: Path) -> list[Path]:
    """EVERY session-journal file in the local journal dir sitting in a NON-git-ignored path (pure of
    side effects) -- published-marked copies included, not only unpublished/pending ones, since a
    marker does not stop `git add` from staging the product narrative. These are product-narrative
    files a broad `git add` could commit to a remote despite narrative publication being disabled
    (an upgraded lane whose runs dir was never ignored, or a copy left by the pre-0.9.0 publication
    path). Startup surfaces them with removal/ignore guidance."""
    journal_dir = session_journal_local_dir(data, target)
    if not journal_dir.exists():
        return []
    return sorted(
        path
        for path in journal_dir.glob("*-session-journal.md")
        if path.is_file() and not path_is_git_ignored(target, path)
    )


def session_journal_leak_warning(data: dict, target: Path) -> str | None:
    """Startup leak-warning text (or None) for session-journal copies in a NON-git-ignored path. Pure:
    returns the message, never prints. Runs INDEPENDENTLY of the pending (unmarked) list -- a
    PUBLISHED-marked journal is cleared from pending_session_journals yet is just as committable by a
    broad `git add`, so the leak scan must never be gated on a non-empty pending list (that gate was
    the whole leak-detection gap: a lane whose only leakable copy is already marked warned nothing)."""
    unignored = unignored_committable_session_journals(data, target)
    if not unignored:
        return None
    return (
        "startup: WARNING these session journals are NOT git-ignored and could be "
        "committed to a remote by a broad `git add` -- narrative publication is disabled (0.9.0), "
        f"but these product-narrative copies remain a leak risk: {compact_path_list(unignored, target)}. "
        "Remove them, or add the runs dir to .gitignore (restore laneState.gitIgnore / "
        "sessionJournal.gitIgnoreLocal: true), before continuing."
    )


def event_repo_slug(data: dict) -> str:
    repo = str(data.get("repo") or data.get("project") or "repo")
    return slugify(repo, fallback="repo")


def observability_state_dir(data: dict) -> Path:
    configured = str(data["observabilityEvents"]["stateDir"])
    expanded = os.path.expandvars(configured.replace("$HOME", str(Path.home())))
    return Path(expanded).expanduser()


def observability_event_paths(data: dict, target: Path) -> tuple[Path, Path, Path]:
    observability = data["observabilityEvents"]
    directory = observability_state_dir(data) / event_repo_slug(data)
    human = directory / observability["humanLog"]
    jsonl = directory / observability["jsonlLog"]
    lock = directory / ".events.lock"
    return human, jsonl, lock


def print_event_log_status(data: dict, target: Path) -> None:
    human, jsonl, _lock = observability_event_paths(data, target)
    enabled = str(data["observabilityEvents"].get("enabled", True)).lower()
    print(f"event_log: enabled={enabled}")
    print(f"event_log_human: {human}")
    print(f"event_log_jsonl: {jsonl}")


def print_instrumentation_status(data: dict) -> None:
    """One status line for the sanitized-instrumentation record (0.9.0): the effective
    enabled+cadence combination. `enabled` alone permits publishing; `cadence` gates only whether
    boundary guidance prompts for emission (a disabled lane is never prompted)."""
    instrumentation = data.get("instrumentation", DEFAULT_INSTRUMENTATION)
    enabled = str(instrumentation.get("enabled", False)).lower()
    print(f"instrumentation: enabled={enabled} cadence={instrumentation.get('cadence', 'milestone')}")


def iteration_review_config(data: dict) -> dict:
    return data["iterationReview"]


def iteration_review_renderer_source_dir() -> Path:
    return REPO_ROOT / "plugins/tautline-ops/skills/iteration-review/renderer-kit"


def iteration_review_renderer_dir() -> Path:
    return iteration_review_renderer_source_dir()


def iteration_review_renderer_state_dir() -> Path:
    configured = util_module().resolve_env("MINERVIT_RENDERER_KIT_STATE_DIR", "").strip()
    if configured:
        expanded = os.path.expandvars(configured.replace("$HOME", str(Path.home())))
        return Path(expanded).expanduser()
    return Path.home() / ".local/state/minervit/renderer-kit"


def iteration_review_renderer_ignored_names() -> tuple[str, ...]:
    return (
        "node_modules",
        "out",
        "dist",
        "build",
        ".next",
        ".turbo",
        ".cache",
        "coverage",
        "*.mp4",
        "*.mov",
        "*.webm",
        "*.png",
        "*.jpg",
        "*.jpeg",
        "*.gif",
        "*.webp",
    )


def prepare_iteration_review_renderer_tree(source: Path, install_dir: Path) -> None:
    if not source.exists():
        raise SystemExit(f"iteration review renderer source is missing: {source}")
    # Containment must cover BOTH roots (B3-cat): installing npm dependencies into the canonical
    # checkout dirties the tree sync/rescue manages (rescue churn for every lane), and installing
    # into the exec root writes into an immutable snapshot. Neither is a methodology-repo path an
    # operator ever wants a node_modules tree in.
    if any(path_is_under(install_dir, root) for root in methodology_data_roots()):
        raise SystemExit(
            f"iteration review renderer install dir must be outside the methodology repo: {install_dir}"
        )
    install_dir.parent.mkdir(parents=True, exist_ok=True)
    shutil.copytree(
        source,
        install_dir,
        dirs_exist_ok=True,
        ignore=shutil.ignore_patterns(*iteration_review_renderer_ignored_names()),
    )


def setup_iteration_review_renderer(args: argparse.Namespace) -> int:
    source = iteration_review_renderer_source_dir()
    install_dir = iteration_review_renderer_state_dir()
    prepare_iteration_review_renderer_tree(source, install_dir)
    print(f"iteration_review_renderer_source: {source}")
    print(f"iteration_review_renderer_cache: {install_dir}")
    print("iteration_review_renderer_sync: ok")
    if args.no_install:
        print("iteration_review_renderer_install: skipped")
        print(f"iteration_review_renderer_next: cd {shlex.quote(str(install_dir))} && npm ci")
        return 0
    npm = shutil.which("npm")
    if not npm:
        print("iteration_review_renderer_install: blocked")
        print("iteration_review_renderer_issue: npm is not on PATH", file=sys.stderr)
        return 1
    install_command = [npm, "ci"] if (install_dir / "package-lock.json").exists() else [npm, "install"]
    code, out, err = run_command(install_command, install_dir, timeout=args.timeout_seconds)
    if code != 0:
        print("iteration_review_renderer_install: failed")
        if out:
            print(out, file=sys.stderr)
        if err:
            print(err, file=sys.stderr)
        return code
    print("iteration_review_renderer_install: ok")
    print(
        "iteration_review_renderer_render_example: "
        f"cd {shlex.quote(str(install_dir))} && npx remotion render src/index.ts IterationReview out/sample-iteration-review.mp4"
    )
    return 0


def iteration_review_record_dir(data: dict, target: Path) -> Path:
    return configured_path(target, data["iterationReview"]["recordDir"])


def print_iteration_review_status(data: dict, target: Path) -> list[str]:
    cfg = iteration_review_config(data)
    outputs = ", ".join(key for key, enabled in cfg["outputs"].items() if enabled) or "none"
    issues: list[str] = []
    renderer = iteration_review_renderer_source_dir()
    renderer_cache = iteration_review_renderer_state_dir()
    if cfg["enabled"] and cfg["outputs"].get("video") and not renderer.exists():
        issues.append(f"renderer kit missing: {renderer}")
    hosting_ready = bool(cfg["hosting"].get("bucket")) and bool(cfg["hosting"].get("baseUrl"))
    if cfg["enabled"] and cfg["outputs"].get("video") and not cfg["hosting"].get("bucket"):
        issues.append("video output enabled but iterationReview.hosting.bucket is blank")
    if cfg["enabled"] and cfg["outputs"].get("page") and not hosting_ready:
        issues.append(
            "iterationReview media hosting is not configured; page-only output may be text-only, "
            "but any screenshots, posters, clips, embedded video, or other page-referenced media require "
            "adapter-approved S3/CloudFront hosting"
        )
    if cfg["enabled"] and cfg["delivery"].get("enabled") and not cfg["delivery"].get("webhookEnv"):
        issues.append("iterationReview.delivery.enabled is true but webhookEnv is blank")
    print(f"iteration_review: enabled={str(cfg['enabled']).lower()}")
    print(f"iteration_review_targets: {', '.join(cfg['granularities'])}")
    print(f"iteration_review_outputs: {outputs}")
    print(f"iteration_review_record_dir: {iteration_review_record_dir(data, target)}")
    print(f"iteration_review_boundary_announce: {str(cfg['boundary']['announce']).lower()}")
    print(f"iteration_review_boundary_auto_run: {str(cfg['boundary']['autoRunAtBoundary']).lower()}")
    if cfg["enabled"] and "milestone" in cfg["granularities"]:
        print("iteration_review_milestone_completion_rule: customer-facing milestone reviews are explicitly enabled; otherwise milestone-complete uses milestoneUpdate")
    print(f"iteration_review_delivery: enabled={str(cfg['delivery']['enabled']).lower()} provider={cfg['delivery']['provider']} trigger={cfg['delivery']['trigger']}")
    print(f"iteration_review_delivery_webhook_env: {cfg['delivery'].get('webhookEnv') or 'not-configured'}")
    music = cfg["video"]["music"]
    print(
        "iteration_review_video_music: "
        f"enabled={str(music['enabled']).lower()} source={music['source']} "
        f"defaultUrl={music.get('defaultUrl') or 'not-configured'} volume={music['volume']:.2f}"
    )
    print(f"iteration_review_renderer_source: {renderer}")
    print(f"iteration_review_renderer_cache: {renderer_cache}")
    print(f"iteration_review_renderer_cache_ready: {str((renderer_cache / 'node_modules').exists()).lower()}")
    print(f"iteration_review_hosting: {cfg['hosting']['store']}+{cfg['hosting']['cdn']} bucket={cfg['hosting'].get('bucket') or 'not-configured'}")
    print("iteration_review_media_rule: outputs.video controls recap rendering only; setting it false does not waive hosting for page-referenced media")
    print("iteration_review_layout_rule: write each review in its own folder as <recordDir>/<review-id>/goal-review.json plus index.html")
    print("iteration_review_publish_rule: after the review lands on main, publish index.html to S3/CloudFront and post that URL to Google Chat when delivery is enabled")
    if cfg["enabled"] and cfg["boundary"]["announce"]:
        print("iteration_review_boundary_notice: non-blocking availability line only; never ask or pause")
    for issue in issues:
        print(f"iteration_review_issue: {issue}")
    return issues


def iteration_review_status(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    issues = print_iteration_review_status(data, target)
    cfg = iteration_review_config(data)
    # RCA O8: the webhook may be configured but not wired into the environment, so after-merge
    # delivery silently never posts. Surface this only in the dedicated `iteration-review-status
    # --strict` startup check (not in methodology-status, where an unset webhook is normal).
    delivery_webhook_env = str(cfg["delivery"].get("webhookEnv") or "").strip()
    if (
        args.strict
        and cfg["enabled"]
        and cfg["delivery"].get("enabled")
        and delivery_webhook_env
        and not util_module().resolve_env(delivery_webhook_env).strip()
    ):
        print(
            f"iteration_review_issue: iterationReview.delivery webhook env var {delivery_webhook_env} is unset/empty in this environment"
        )
        issues = list(issues) + ["delivery webhook env unset"]
    if issues:
        print("iteration_review_status: blocked")
        return 1 if args.strict else 0
    print("iteration_review_status: ok")
    if data["iterationReview"]["enabled"]:
        print("iteration_review_next_action: run `tautline validate-iteration-review --target . --file <record.json>` after composing a customer-facing GoalReview record")
    else:
        print("iteration_review_next_action: disabled by adapter; enable iterationReview before generating reviews")
    return 0


def iteration_review_required_string(record: dict, path: str, errors: list[str]) -> str:
    value = record
    for part in path.split("."):
        if isinstance(value, dict) and part in value:
            value = value[part]
        else:
            errors.append(f"missing required field: {path}")
            return ""
    if not isinstance(value, str) or not value.strip():
        errors.append(f"field must be a non-blank string: {path}")
        return ""
    return value.strip()


def iteration_review_optional_string(record: dict, path: str, errors: list[str]) -> None:
    value = record
    for part in path.split("."):
        if isinstance(value, dict) and part in value:
            value = value[part]
        else:
            return
    if value is not None and (not isinstance(value, str) or not value.strip()):
        errors.append(f"optional field must be a non-blank string when present: {path}")


def iteration_review_validate_string_list(value: object, path: str, errors: list[str], *, min_items: int = 0, max_items: int | None = None) -> None:
    if not isinstance(value, list):
        errors.append(f"field must be an array: {path}")
        return
    if len(value) < min_items:
        errors.append(f"field must contain at least {min_items} item(s): {path}")
    if max_items is not None and len(value) > max_items:
        errors.append(f"field must contain at most {max_items} item(s): {path}")
    for index, item in enumerate(value):
        if not isinstance(item, str) or not item.strip():
            errors.append(f"field item must be a non-blank string: {path}[{index}]")


ITERATION_REVIEW_CUSTOMER_FIELD_LIMITS = {
    "product": 48,
    "goalTitle": 86,
    "kicker": 72,
    "why": 520,
    "milestones[].title": 120,
    "milestones[].pr": 80,
    "highlight.heading": 90,
    "highlight.items[]": 150,
    "quality.tests": 140,
    "quality.types": 140,
    "quality.review": 140,
    "quality.discipline": 140,
    "next[].title": 86,
    "next[].blurb": 220,
    "nextWhy": 360,
    "outro.headline": 90,
    "outro.subhead": 190,
    "usage[].caption": 150,
    "links[].label": 90,
}


ITERATION_REVIEW_CUSTOMER_JARGON_PATTERNS = [
    (
        re.compile(r"\b(?:Codex|Claude|Gherkin|pytest|vitest|typecheck|preflight|merge queue|review round|Stage[- ]?\d|R\d|L[0-3])\b", re.I),
        "internal review/test process",
    ),
    (
        re.compile(r"\b(?:test[- ]driven|automated tests?|unit tests?|integration tests?|e2e|playwright|type[- ]?checked|type checks?|green tests?|review evidence|no blocking issues|P[0-3])\b", re.I),
        "internal review/test process",
    ),
    (
        re.compile(r"\b(?:behavior contracts?|behavior specs?|business contracts?|acceptance criteria|source[- ]of[- ]truth|requirements? traceability|scenario coverage|BDD|spec(?:ification)?s?)\b", re.I),
        "internal planning/spec process",
    ),
    (
        re.compile(r"\b(?:wrote|authored|drafted|reviewed|validated)\b.{0,80}\b(?:before building|before implementation|contract|spec|scenario|test)\b", re.I),
        "internal planning/spec process",
    ),
    (
        re.compile(r"\b(?:PR\s*(?:#|-)?\d+|pull request|commit|SHA|branch|repo|mainline|squash|auto-merge|merge-group)\b", re.I),
        "repository workflow detail",
    ),
    (
        re.compile(r"\b(?:dataclass|Postgres|frontend|backend|schema|route|endpoint|API route|query|resolver|invariant|tenant isolation)\b", re.I),
        "implementation jargon",
    ),
    (
        re.compile(r"\b(?:persisted|atomic|idempotent|tokenized|server-only|off-by-default|feature flag|migration|liveness probe)\b", re.I),
        "implementation jargon",
    ),
    (
        re.compile(r"\b(?:GET|POST|PUT|PATCH|DELETE)\s+/[A-Za-z0-9_./{}-]*"),
        "HTTP route detail",
    ),
    (
        re.compile(r"\b[A-Za-z0-9_.-]+/[A-Za-z0-9_./{}-]+\b"),
        "file or route path",
    ),
    (
        re.compile(r"\b[a-z][a-z0-9]+_[a-z0-9_]+\b"),
        "code identifier",
    ),
    (
        re.compile(r"(?:__|::|<=>|==|⇔)"),
        "code/operator notation",
    ),
]


def iteration_review_customer_copy_check(path: str, value: str, errors: list[str], *, limit_key: str | None = None) -> None:
    text = re.sub(r"\s+", " ", value.strip())
    key = limit_key or path
    max_chars = ITERATION_REVIEW_CUSTOMER_FIELD_LIMITS.get(key)
    if max_chars is not None and len(text) > max_chars:
        errors.append(f"{path} exceeds {max_chars} characters; move detail to technical")
    for pattern, label in ITERATION_REVIEW_CUSTOMER_JARGON_PATTERNS:
        if pattern.search(text):
            errors.append(f"{path} contains {label}; keep customer-facing copy non-technical and move this to technical")
            break


def iteration_review_customer_link_check(index: int, link: dict, errors: list[str]) -> None:
    label = link.get("label")
    if isinstance(label, str) and label.strip():
        iteration_review_customer_copy_check(f"links[{index}].label", label, errors, limit_key="links[].label")
    url = str(link.get("url") or "").strip()
    if re.search(r"(?:github|gitlab|bitbucket)\.com/.+(?:/pull/|/commit/|/compare/|/tree/|/blob/)", url, re.I):
        errors.append(f"links[{index}].url points to repository workflow detail; move PR/repo links to technical")


def iteration_review_validate_customer_copy(record: dict, errors: list[str]) -> None:
    for field in ["product", "goalTitle", "kicker", "why", "nextWhy"]:
        value = record.get(field)
        if isinstance(value, str) and value.strip():
            iteration_review_customer_copy_check(field, value, errors)
    milestones = record.get("milestones")
    if isinstance(milestones, list):
        for index, milestone in enumerate(milestones):
            if not isinstance(milestone, dict):
                continue
            value = milestone.get("title")
            if isinstance(value, str) and value.strip():
                iteration_review_customer_copy_check(f"milestones[{index}].title", value, errors, limit_key="milestones[].title")
            proof = milestone.get("pr")
            if isinstance(proof, str) and proof.strip():
                iteration_review_customer_copy_check(f"milestones[{index}].pr", proof, errors, limit_key="milestones[].pr")
    highlight = record.get("highlight")
    if isinstance(highlight, dict):
        value = highlight.get("heading")
        if isinstance(value, str) and value.strip():
            iteration_review_customer_copy_check("highlight.heading", value, errors)
        items = highlight.get("items")
        if isinstance(items, list):
            for index, item in enumerate(items):
                if isinstance(item, str) and item.strip():
                    iteration_review_customer_copy_check(f"highlight.items[{index}]", item, errors, limit_key="highlight.items[]")
    usage = record.get("usage")
    if isinstance(usage, list):
        for index, shot in enumerate(usage):
            if not isinstance(shot, dict):
                continue
            value = shot.get("caption")
            if isinstance(value, str) and value.strip():
                iteration_review_customer_copy_check(f"usage[{index}].caption", value, errors, limit_key="usage[].caption")
    quality = record.get("quality")
    if isinstance(quality, dict):
        for field in ["tests", "types", "review", "discipline"]:
            value = quality.get(field)
            if isinstance(value, str) and value.strip():
                iteration_review_customer_copy_check(f"quality.{field}", value, errors)
    next_items = record.get("next")
    if isinstance(next_items, list):
        for index, item in enumerate(next_items):
            if not isinstance(item, dict):
                continue
            for field in ["title", "blurb"]:
                value = item.get(field)
                if isinstance(value, str) and value.strip():
                    iteration_review_customer_copy_check(f"next[{index}].{field}", value, errors, limit_key=f"next[].{field}")
    outro = record.get("outro")
    if isinstance(outro, dict):
        for field in ["headline", "subhead"]:
            value = outro.get(field)
            if isinstance(value, str) and value.strip():
                iteration_review_customer_copy_check(f"outro.{field}", value, errors)
    links = record.get("links")
    if isinstance(links, list):
        for index, link in enumerate(links):
            if isinstance(link, dict):
                iteration_review_customer_link_check(index, link, errors)


def validate_iteration_review_record(record: object) -> list[str]:
    errors: list[str] = []
    if not isinstance(record, dict):
        return ["GoalReview must be a JSON object"]
    for field in ["product", "goalTitle", "kicker", "why", "nextWhy"]:
        iteration_review_required_string(record, field, errors)
    for field in ["date", "status", "videoUrl", "posterUrl", "musicUrl", "musicCredit"]:
        iteration_review_optional_string(record, field, errors)
    if "musicVolume" in record:
        try:
            music_volume = float(record["musicVolume"])
        except (TypeError, ValueError):
            errors.append("musicVolume must be a number between 0 and 1 when present")
        else:
            if music_volume < 0 or music_volume > 1:
                errors.append("musicVolume must be between 0 and 1")

    milestones = record.get("milestones")
    if not isinstance(milestones, list) or not milestones:
        errors.append("milestones must be a non-empty array")
    else:
        for index, milestone in enumerate(milestones):
            if not isinstance(milestone, dict):
                errors.append(f"milestones[{index}] must be an object")
                continue
            for field in ["id", "title", "pr"]:
                iteration_review_required_string(milestone, field, errors)
            if milestone.get("status") != "complete":
                errors.append(f"milestones[{index}].status must be complete")

    highlight = record.get("highlight")
    if not isinstance(highlight, dict):
        errors.append("highlight must be an object")
    else:
        iteration_review_required_string(highlight, "heading", errors)
        iteration_review_validate_string_list(highlight.get("items"), "highlight.items", errors, min_items=1, max_items=8)
        iteration_review_optional_string(highlight, "image", errors)

    usage = record.get("usage")
    if usage is not None:
        if not isinstance(usage, list):
            errors.append("usage must be an array when present")
        elif len(usage) > 4:
            errors.append("usage must contain at most 4 item(s)")
        else:
            for index, shot in enumerate(usage):
                if not isinstance(shot, dict):
                    errors.append(f"usage[{index}] must be an object")
                    continue
                iteration_review_required_string(shot, "src", errors)
                iteration_review_optional_string(shot, "caption", errors)

    quality = record.get("quality")
    if not isinstance(quality, dict):
        errors.append("quality must be an object")
    else:
        for field in ["tests", "types", "review", "discipline"]:
            iteration_review_required_string(quality, field, errors)

    next_items = record.get("next")
    if not isinstance(next_items, list) or not next_items:
        errors.append("next must be a non-empty array")
    else:
        for index, item in enumerate(next_items):
            if not isinstance(item, dict):
                errors.append(f"next[{index}] must be an object")
                continue
            for field in ["title", "blurb"]:
                iteration_review_required_string(item, field, errors)

    outro = record.get("outro")
    if not isinstance(outro, dict):
        errors.append("outro must be an object")
    else:
        for field in ["headline", "subhead"]:
            iteration_review_required_string(outro, field, errors)

    links = record.get("links")
    if links is not None:
        if not isinstance(links, list):
            errors.append("links must be an array when present")
        else:
            for index, link in enumerate(links):
                if not isinstance(link, dict):
                    errors.append(f"links[{index}] must be an object")
                    continue
                for field in ["label", "url"]:
                    iteration_review_required_string(link, field, errors)

    technical = record.get("technical")
    if technical is not None:
        if not isinstance(technical, dict):
            errors.append("technical must be an object when present")
        else:
            iteration_review_required_string(technical, "summary", errors)
            iteration_review_validate_string_list(technical.get("changes"), "technical.changes", errors)
            iteration_review_validate_string_list(technical.get("evidence"), "technical.evidence", errors)
            prs = technical.get("prs")
            if not isinstance(prs, list):
                errors.append("technical.prs must be an array")
            else:
                for index, pr in enumerate(prs):
                    if not isinstance(pr, dict):
                        errors.append(f"technical.prs[{index}] must be an object")
                        continue
                    for field in ["id", "title"]:
                        iteration_review_required_string(pr, field, errors)
    iteration_review_validate_customer_copy(record, errors)
    return errors


def read_iteration_review_record(path: Path) -> dict:
    try:
        payload = json.loads(path.read_text(encoding="utf-8"))
    except OSError as exc:
        raise SystemExit(f"iteration review record unreadable: {path}: {exc}") from exc
    except json.JSONDecodeError as exc:
        raise SystemExit(f"iteration review record invalid JSON: {path}: {exc}") from exc
    errors = validate_iteration_review_record(payload)
    if errors:
        for error in errors:
            print(f"iteration_review_validation_error: {error}", file=sys.stderr)
        raise SystemExit(1)
    return payload


def validate_iteration_review(args: argparse.Namespace) -> int:
    record = read_iteration_review_record(args.file)
    cfg: dict | None = None
    target: Path | None = None
    if getattr(args, "project", None) is not None or getattr(args, "target", None) is not None:
        data, _, resolved_target = lane_project(args)
        target = resolved_target
        cfg = iteration_review_config(data)
        issues = iteration_review_output_contract_issues(record, cfg)
        try:
            review_slug = iteration_review_review_slug(data, target, args.file)
        except SystemExit:
            review_slug = ""
        if review_slug:
            canonical_issue = iteration_review_canonical_slug_issue(data, target, review_slug)
            if canonical_issue:
                issues.append(canonical_issue)
        if issues:
            for issue in issues:
                print(f"iteration_review_validation_error: {issue}", file=sys.stderr)
            return 1
    print(f"iteration_review_valid: {args.file}")
    print(f"iteration_review_product: {record['product']}")
    print(f"iteration_review_goal_title: {record['goalTitle']}")
    if cfg is not None:
        print(f"iteration_review_required_outputs_satisfied: {iteration_review_required_outputs_satisfied(record, cfg)}")
    return 0


def iteration_review_hosting_base_url(cfg: dict) -> str:
    return str(cfg["hosting"].get("baseUrl") or "").strip().rstrip("/")


def iteration_review_url_is_hosted(cfg: dict, url: str) -> bool:
    value = str(url or "").strip()
    base_url = iteration_review_hosting_base_url(cfg)
    if not value or not base_url:
        return False
    return value == base_url or value.startswith(f"{base_url}/")


def iteration_review_media_references(record: dict) -> list[tuple[str, str]]:
    refs: list[tuple[str, str]] = []
    for key in ["videoUrl", "posterUrl"]:
        value = str(record.get(key) or "").strip()
        if value:
            refs.append((key, value))
    highlight = record.get("highlight")
    if isinstance(highlight, dict):
        value = str(highlight.get("image") or "").strip()
        if value:
            refs.append(("highlight.image", value))
    usage = record.get("usage")
    if isinstance(usage, list):
        for index, item in enumerate(usage):
            if not isinstance(item, dict):
                continue
            value = str(item.get("src") or "").strip()
            if value:
                refs.append((f"usage[{index}].src", value))
    return refs


def iteration_review_output_contract_issues(record: dict, cfg: dict, page_path: Path | None = None) -> list[str]:
    issues: list[str] = []
    if cfg["outputs"].get("page") and page_path is not None and not page_path.exists():
        issues.append(f"iterationReview.outputs.page requires generated index.html before publish: {page_path}")
    video_url = str(record.get("videoUrl") or "").strip()
    if cfg["outputs"].get("video"):
        if not video_url:
            issues.append("iterationReview.outputs.video requires a hosted videoUrl before validate/publish; render and upload the recap video before publishing the stakeholder review")
        elif not iteration_review_url_is_hosted(cfg, video_url):
            issues.append("iterationReview.outputs.video requires videoUrl to use adapter iterationReview.hosting.baseUrl")
        if page_path is not None and page_path.exists():
            try:
                page_text = page_path.read_text(encoding="utf-8")
            except OSError:
                page_text = ""
            if "Video appears here when this review includes a hosted recap" in page_text or 'class="empty-video"' in page_text:
                issues.append("iterationReview.outputs.video requires generated index.html to embed the hosted videoUrl; regenerate the page after adding videoUrl")
            elif video_url and video_url not in page_text:
                issues.append("iterationReview.outputs.video requires generated index.html to reference the hosted videoUrl; regenerate the page after adding videoUrl")
    for path, url in iteration_review_media_references(record):
        if not iteration_review_url_is_hosted(cfg, url):
            issues.append(f"{path} must use adapter-approved S3/CloudFront base URL: {url}")
    return issues


def iteration_review_required_outputs_satisfied(record: dict, cfg: dict, page_path: Path | None = None) -> str:
    page_ok = (not cfg["outputs"].get("page")) or page_path is None or page_path.exists()
    video_url = str(record.get("videoUrl") or "").strip()
    video_ok = (not cfg["outputs"].get("video")) or (bool(video_url) and iteration_review_url_is_hosted(cfg, video_url))
    return f"page={str(page_ok).lower()} video={str(video_ok).lower()}"


ITERATION_REVIEW_OPERATOR_APPROVAL_TOKEN = "OPERATOR_APPROVED_PARTIAL_ITERATION_REVIEW"


def iteration_review_operator_approval_ok(args: argparse.Namespace) -> bool:
    return str(getattr(args, "operator_approval_token", "") or "").strip() == ITERATION_REVIEW_OPERATOR_APPROVAL_TOKEN


def require_iteration_review_operator_approval(args: argparse.Namespace, reason: str) -> None:
    if iteration_review_operator_approval_ok(args):
        return
    raise SystemExit(
        f"{reason} requires explicit human operator approval. "
        f"Re-run with --operator-approval-token {ITERATION_REVIEW_OPERATOR_APPROVAL_TOKEN} only after the operator approves this partial iteration-review delivery."
    )


def iteration_review_builtin_music_credit(url: str) -> str:
    if url.strip().lower() == "builtin:minervit-midnight-pulse":
        return "Minervit Midnight Pulse"
    return ""


def iteration_review_apply_media_defaults(record: dict, cfg: dict) -> tuple[dict, list[str]]:
    updated = dict(record)
    applied: list[str] = []
    music = cfg["video"]["music"]
    default_url = str(music.get("defaultUrl") or "").strip()
    if (
        cfg["outputs"].get("video")
        and music.get("enabled")
        and default_url
        and not str(updated.get("musicUrl") or "").strip()
    ):
        updated["musicUrl"] = default_url
        applied.append("musicUrl")
        if "musicVolume" not in updated:
            updated["musicVolume"] = music["volume"]
            applied.append("musicVolume")
        if not str(updated.get("musicCredit") or "").strip():
            credit = iteration_review_builtin_music_credit(default_url)
            if credit:
                updated["musicCredit"] = credit
                applied.append("musicCredit")
    return updated, applied


def html_list(items: list[str]) -> str:
    return "\n".join(f"<li>{escape(str(item))}</li>" for item in items)


def iteration_review_poster_lines(value: str, *, max_chars: int, max_lines: int) -> list[str]:
    words = re.sub(r"\s+", " ", value.strip()).split()
    lines: list[str] = []
    current = ""
    for word in words:
        candidate = f"{current} {word}".strip()
        if current and len(candidate) > max_chars:
            lines.append(current)
            current = word
            if len(lines) == max_lines:
                break
        else:
            current = candidate
    if current and len(lines) < max_lines:
        lines.append(current)
    if len(lines) == max_lines and len(" ".join(words)) > len(" ".join(lines)):
        lines[-1] = lines[-1].rstrip(" .") + "..."
    return lines or ["Iteration review"]


def iteration_review_generated_poster_data_uri(record: dict) -> str:
    title_lines = iteration_review_poster_lines(str(record.get("goalTitle") or "Iteration Review"), max_chars=28, max_lines=3)
    heading = str((record.get("highlight") or {}).get("heading") or record.get("why") or "See what shipped.").strip()
    heading_lines = iteration_review_poster_lines(heading, max_chars=52, max_lines=2)
    status = str(record.get("status") or "Complete").strip()
    date = str(record.get("date") or "").strip()
    meta = f"{status} - {date}" if date else status
    kicker = str(record.get("kicker") or f"{record.get('product', 'Minervit')} iteration review").strip()
    title_tspans = "\n".join(
        f'<tspan x="96" dy="{0 if index == 0 else 74}">{escape(line)}</tspan>'
        for index, line in enumerate(title_lines)
    )
    heading_tspans = "\n".join(
        f'<tspan x="96" dy="{0 if index == 0 else 34}">{escape(line)}</tspan>'
        for index, line in enumerate(heading_lines)
    )
    svg = f"""<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
  <defs>
    <linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
      <stop offset="0" stop-color="#f7f9fc"/>
      <stop offset="0.58" stop-color="#f6f4ef"/>
      <stop offset="1" stop-color="#e8f7f1"/>
    </linearGradient>
    <linearGradient id="stripe" x1="0" x2="1">
      <stop offset="0" stop-color="#2f6fed"/>
      <stop offset="0.52" stop-color="#10a37f"/>
      <stop offset="1" stop-color="#f59e0b"/>
    </linearGradient>
    <filter id="shadow" x="-10%" y="-10%" width="120%" height="120%">
      <feDropShadow dx="0" dy="24" stdDeviation="28" flood-color="#162233" flood-opacity=".16"/>
    </filter>
  </defs>
  <rect width="1280" height="720" fill="url(#bg)"/>
  <path d="M0 104H1280M0 272H1280M0 440H1280M0 608H1280" stroke="#2f6fed" stroke-opacity=".07"/>
  <rect x="54" y="54" width="1172" height="612" rx="34" fill="#ffffff" stroke="#d8e2ef" filter="url(#shadow)"/>
  <rect x="54" y="54" width="1172" height="10" rx="5" fill="url(#stripe)"/>
  <text x="96" y="126" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" font-size="25" font-weight="800" fill="#2f6fed">{escape(kicker.upper())}</text>
  <text x="96" y="228" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" font-size="68" font-weight="850" fill="#172033">{title_tspans}</text>
  <text x="96" y="470" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" font-size="30" font-weight="500" fill="#5d6b7c">{heading_tspans}</text>
  <g transform="translate(96 556)">
    <rect width="286" height="64" rx="16" fill="#172033"/>
    <circle cx="33" cy="32" r="19" fill="#ffffff"/>
    <polygon points="29,22 29,42 45,32" fill="#2f6fed"/>
    <text x="67" y="41" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" font-size="26" font-weight="800" fill="#ffffff">Watch recap</text>
  </g>
  <text x="1004" y="604" text-anchor="end" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" font-size="23" font-weight="800" fill="#10a37f">{escape(meta)}</text>
  <text x="1004" y="634" text-anchor="end" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" font-size="18" fill="#8a96a8">Iteration Review</text>
  <circle cx="1095" cy="590" r="54" fill="#eef5ff" stroke="#d8e2ef"/>
  <polygon points="1081,562 1081,618 1126,590" fill="#2f6fed"/>
</svg>"""
    return "data:image/svg+xml;charset=utf-8," + quote(svg, safe=":/,;=-_.!~*'()")


def iteration_review_page_html(record: dict) -> str:
    title = f"{record['product']} - {record['goalTitle']}"
    status = record.get("status") or "Complete"
    date = record.get("date") or ""
    video_url = record.get("videoUrl") or ""
    poster_url = record.get("posterUrl") or ""
    poster_src = poster_url or (iteration_review_generated_poster_data_uri(record) if video_url else "")
    poster_attr = f' poster="{escape(poster_src, quote=True)}"' if poster_src else ""
    video_block = (
        f'<video controls preload="metadata"{poster_attr} src="{escape(video_url, quote=True)}"></video>'
        if video_url
        else '<div class="empty-video"><strong>Review page ready</strong><span>Video appears here when this review includes a hosted recap.</span></div>'
    )
    music_credit = str(record.get("musicCredit") or "").strip()
    music_block = f'<p class="music-credit">Soundtrack: {escape(music_credit)}</p>' if music_credit else ""
    milestones = "\n".join(
        f"<li><strong>{escape(item['id'])}</strong> {escape(item['title'])}<span>{escape(item['pr'])}</span></li>"
        for item in record["milestones"]
    )
    next_cards = "\n".join(
        f"<article><h3>{escape(item['title'])}</h3><p>{escape(item['blurb'])}</p></article>"
        for item in record["next"]
    )
    quality = record["quality"]
    quality_items = "".join(
        f"<li>{escape(item)}</li>" for item in [quality["tests"], quality["types"], quality["review"], quality["discipline"]]
    )
    links = "\n".join(
        f'<a href="{escape(link["url"], quote=True)}">{escape(link["label"])}</a>'
        for link in record.get("links", [])
    )
    links_block = f'<nav class="links">{links}</nav>' if links else ""
    technical = record.get("technical") or {}
    technical_block = ""
    if technical:
        pr_rows = "\n".join(
            f"<li>{escape(pr['id'])}: {escape(pr['title'])}</li>"
            for pr in technical.get("prs", [])
        )
        technical_block = f"""
    <details>
      <summary>Technical appendix</summary>
      <p>{escape(technical.get("summary", ""))}</p>
      <h3>Changes</h3>
      <ul>{html_list(technical.get("changes", []))}</ul>
      <h3>Evidence</h3>
      <ul>{html_list(technical.get("evidence", []))}</ul>
      <h3>PRs</h3>
      <ul>{pr_rows}</ul>
    </details>"""
    return f"""<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{escape(title)}</title>
  <style>
    :root {{ color-scheme: light; --ink: #172033; --muted: #5d6b7c; --faint: #8a96a8; --line: #d8e2ef; --accent: #2f6fed; --accent-2: #10a37f; --accent-3: #f59e0b; --bg: #f6f4ef; --bg-soft: #eaf2ff; --panel: #ffffff; --panel-2: #f8fafc; --shadow: rgba(22,34,51,.12); }}
    * {{ box-sizing: border-box; }}
    body {{ margin: 0; font: 16px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: var(--ink); background: linear-gradient(180deg, #f7f9fc 0, var(--bg) 54%, #eef7f4 100%); min-height: 100vh; }}
    body:before {{ content: ""; position: fixed; inset: 0; pointer-events: none; background: repeating-linear-gradient(90deg, rgba(47,111,237,.045) 0 1px, transparent 1px 120px); }}
    main {{ width: min(1160px, calc(100% - 44px)); margin: 0 auto; padding: 42px 0 76px; position: relative; }}
    header {{ color: var(--ink); background: linear-gradient(135deg, #ffffff 0%, #eef5ff 52%, #fff3df 100%); border: 1px solid var(--line); border-radius: 8px; padding: clamp(28px, 6vw, 58px); margin-bottom: 22px; box-shadow: 0 24px 70px var(--shadow); position: relative; overflow: hidden; }}
    header:before {{ content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, var(--accent), var(--accent-2), var(--accent-3)); height: 7px; }}
    header > * {{ position: relative; }}
    .kicker {{ color: var(--accent); font-weight: 800; text-transform: uppercase; font-size: 13px; letter-spacing: 0; }}
    h1 {{ font-size: clamp(34px, 6vw, 66px); line-height: 1.02; margin: 10px 0 14px; letter-spacing: 0; max-width: 880px; }}
    h2 {{ margin: 44px 0 14px; font-size: clamp(24px, 3vw, 32px); letter-spacing: 0; }}
    h3 {{ margin: 0 0 8px; font-size: 19px; letter-spacing: 0; }}
    p {{ max-width: 810px; }}
    header p {{ color: var(--muted); font-size: 18px; }}
    .meta {{ display: inline-flex; align-items: center; width: fit-content; color: #ffffff; background: var(--accent); border-radius: 8px; padding: 7px 11px; font-weight: 800; }}
    .video-shell {{ margin: 24px 0 10px; padding: 10px; border: 1px solid var(--line); border-radius: 8px; background: #ffffff; box-shadow: 0 20px 58px var(--shadow); }}
    video, .empty-video {{ width: 100%; aspect-ratio: 16 / 9; border: 1px solid var(--line); background: linear-gradient(135deg, #f8fafc, #eaf2ff); display: grid; place-items: center; border-radius: 8px; color: var(--ink); }}
    .empty-video span {{ color: var(--muted); display: block; margin-top: 4px; }}
    .music-credit {{ max-width: none; margin: 9px 2px 0; color: var(--faint); font-size: 13px; }}
    .grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 16px; }}
    article, details {{ border: 1px solid var(--line); border-radius: 8px; padding: 20px; background: var(--panel); box-shadow: 0 14px 40px rgba(22,34,51,.10); }}
    .value {{ display: grid; grid-template-columns: minmax(0, .9fr) minmax(280px, 1.1fr); gap: 18px; align-items: start; }}
    .value ul, .quality {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 12px; padding: 0; list-style: none; }}
    .value li, .quality li {{ border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; background: var(--panel-2); }}
    .value li {{ border-left: 3px solid var(--accent); }}
    .quality li {{ border-left: 3px solid var(--accent-2); }}
    .milestones {{ padding: 0; list-style: none; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; background: #ffffff; box-shadow: 0 14px 40px rgba(22,34,51,.08); }}
    .milestones li {{ display: flex; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--line); padding: 15px 16px; }}
    .milestones li:last-child {{ border-bottom: 0; }}
    .milestones strong {{ color: var(--accent); margin-right: 8px; }}
    li span {{ color: var(--muted); }}
    summary {{ cursor: pointer; font-weight: 800; color: var(--accent); }}
    details p, details li {{ color: var(--muted); }}
    details[open] {{ background: #fbfdff; }}
    .links {{ display: flex; gap: 10px; flex-wrap: wrap; margin-top: 20px; }}
    .links a {{ color: var(--accent); background: #ffffff; border: 1px solid var(--line); border-radius: 8px; padding: 8px 12px; text-decoration: none; font-weight: 800; }}
    .links a:hover {{ background: #eef5ff; border-color: rgba(47,111,237,.30); }}
    footer {{ margin-top: 48px; padding-top: 24px; border-top: 1px solid rgba(47,111,237,.24); }}
    footer p {{ color: var(--muted); }}
    @media (max-width: 780px) {{ main {{ width: min(100% - 28px, 1160px); padding-top: 24px; }} .value {{ grid-template-columns: 1fr; }} .milestones li {{ display: block; }} .milestones span {{ display: block; margin-top: 4px; }} }}
  </style>
</head>
<body>
<main>
  <header>
    <div class="kicker">{escape(record['kicker'])}</div>
    <h1>{escape(record['goalTitle'])}</h1>
    <p>{escape(record['why'])}</p>
    <p class="meta">{escape(status)}{f" · {escape(date)}" if date else ""}</p>
    {links_block}
  </header>

  <section class="video-shell" aria-label="Review recap">
    {video_block}
    {music_block}
  </section>

  <section>
    <div class="value">
      <div>
        <h2>{escape(record['highlight']['heading'])}</h2>
        <p>{escape(record['nextWhy'])}</p>
      </div>
      <ul>{html_list(record['highlight']['items'])}</ul>
    </div>
  </section>

  <section>
    <h2>What users get</h2>
    <ul class="milestones">{milestones}</ul>
  </section>

  <section>
    <h2>Why you can trust it</h2>
    <ul class="quality">{quality_items}</ul>
  </section>

  {technical_block}

  <section>
    <h2>What's next</h2>
    <div class="grid">{next_cards}</div>
  </section>

  <footer>
    <h2>{escape(record['outro']['headline'])}</h2>
    <p>{escape(record['outro']['subhead'])}</p>
  </footer>
</main>
</body>
</html>
"""


def iteration_review_default_page_path(record_path: Path) -> Path:
    if record_path.name == "goal-review.json":
        return record_path.parent / "index.html"
    return record_path.with_suffix(".html")


def iteration_review_review_slug(data: dict, target: Path, record_path: Path) -> str:
    record_dir = iteration_review_record_dir(data, target).resolve(strict=False)
    resolved = record_path.resolve(strict=False)
    if not path_is_under(resolved, record_dir):
        raise SystemExit(f"iteration review record must stay under iterationReview.recordDir: {resolved}")
    if resolved.name != "goal-review.json" or resolved.parent == record_dir:
        raise SystemExit(
            "publish-iteration-review requires the per-review folder layout: "
            "<recordDir>/<review-id>/goal-review.json with index.html beside it"
        )
    return slugify(resolved.parent.name, fallback="iteration-review")


def active_goal_id_for_delivery(data: dict, target: Path) -> str:
    path = goal_run_path(data, target)
    if not path.exists():
        return ""
    try:
        run = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return ""
    if run.get("schema") != GOAL_RUN_SCHEMA:
        return ""
    return str(run.get("goalId") or "").strip()


def iteration_review_canonical_slug_issue(data: dict, target: Path, review_slug: str) -> str | None:
    goal_id = active_goal_id_for_delivery(data, target)
    if not goal_id:
        return None
    expected = slugify(goal_id, fallback="goal")
    if review_slug == expected:
        return None
    return (
        "iteration review record folder slug does not match the active goal_id; "
        f"rename the review folder to {expected} before validate/publish so Chat delivery is keyed canonically "
        f"(record slug {review_slug}, goal_id {goal_id})"
    )


def iteration_review_s3_prefix(data: dict, cfg: dict, review_slug: str) -> str:
    hosting = cfg["hosting"]
    key_pattern = str(hosting["keyPattern"]).strip()
    project_slug = slugify(str(data.get("project") or data.get("repo") or "project"), fallback="project")
    key = (
        key_pattern.replace("<project>", project_slug)
        .replace("<target-id>", review_slug)
        .replace("<media-file>", "index.html")
    )
    key = key.strip("/")
    if not key:
        raise SystemExit("iterationReview.hosting.keyPattern resolved to an empty S3 key")
    filename = posixpath.basename(key)
    if "." in filename:
        return posixpath.dirname(key).strip("/")
    return key.rstrip("/")


def iteration_review_public_url(cfg: dict, prefix: str, filename: str) -> str:
    base_url = str(cfg["hosting"].get("baseUrl", "")).strip().rstrip("/")
    if not base_url:
        raise SystemExit("iterationReview.hosting.baseUrl must be configured before publishing iteration reviews")
    return f"{base_url}/{prefix.strip('/')}/{filename}"


def iteration_review_delivery_marker_path(data: dict, target: Path, review_slug: str) -> Path:
    delivery = data["iterationReview"]["delivery"]
    return configured_path(target, delivery["sentStateDir"]) / f"{review_slug}.json"


def iteration_review_marker_unreasoned_repeats(marker_path: Path) -> int:
    """Count repeat Chat posts after the first that lack a repeatReason (RCA O18). A marker with
    such repeats is a duplicate-card defect, not a valid delivery; both the delivery check and the
    goal-complete gate use this so neither can be bypassed (Codex P2)."""
    if not marker_path.exists():
        return 0
    try:
        marker = json.loads(marker_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return 0
    history = marker.get("postHistory") if isinstance(marker.get("postHistory"), list) else []
    return sum(
        1
        for entry in history[1:]
        if isinstance(entry, dict) and not entry.get("skipped") and not str(entry.get("repeatReason") or "").strip()
    )


def iteration_review_delivery_marker_matches(
    marker_path: Path,
    *,
    record_hash: str,
    page_hash: str,
    page_url: str,
    accept_skipped: bool = False,
) -> bool:
    if not marker_path.exists():
        return False
    try:
        marker = json.loads(marker_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return False
    if marker.get("skipped"):
        # An operator-approved skip with a non-blank recorded reason, matching the current
        # artifacts (record/page hashes AND page URL), satisfies the delivery gate (RCA O11: a
        # skipped delivery must be durably recorded with a reason, not asserted — Codex P2).
        return bool(
            accept_skipped
            and marker.get("operatorApproved")
            and str(marker.get("skipReason") or "").strip()
            and marker.get("recordSha256") == record_hash
            and marker.get("pageSha256") == page_hash
            and marker.get("pageUrl") == page_url
        )
    return (
        marker.get("recordSha256") == record_hash
        and marker.get("pageSha256") == page_hash
        and marker.get("pageUrl") == page_url
    )


def write_iteration_review_delivery_marker(
    marker_path: Path,
    *,
    review_slug: str,
    record_hash: str,
    page_hash: str,
    record_url: str,
    page_url: str,
    provider: str,
    webhook_env: str,
    skipped: bool = False,
    skip_reason: str | None = None,
    operator_approved: bool = False,
    repeat_reason: str | None = None,
) -> None:
    """Write/update the delivery marker, appending to a postHistory audit array so repeated
    posts (RCA O18) and operator-approved skips (RCA O11) are durably recorded rather than
    silently overwritten."""
    existing: dict = {}
    if marker_path.exists():
        try:
            existing = json.loads(marker_path.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError):
            existing = {}
    stamp = utc_event_timestamp()
    entry: dict = {"at": stamp, "recordSha256": record_hash, "pageSha256": page_hash, "pageUrl": page_url, "skipped": skipped}
    if repeat_reason:
        entry["repeatReason"] = repeat_reason
    if skip_reason:
        entry["skipReason"] = skip_reason
    post_history = list(existing.get("postHistory", []))
    post_history.append(entry)
    marker: dict = {
        "schema": "minervit-iteration-review-delivery/v1",
        "reviewSlug": review_slug,
        "recordSha256": record_hash,
        "pageSha256": page_hash,
        "recordUrl": record_url,
        "pageUrl": page_url,
        "provider": provider,
        "webhookEnv": webhook_env,
        "skipped": skipped,
        "postedAt": None if skipped else stamp,
        "postHistory": post_history,
    }
    if skipped:
        marker["skipReason"] = skip_reason or "operator-requested"
        marker["waivedAt"] = stamp
        marker["operatorApproved"] = operator_approved
    marker_path.parent.mkdir(parents=True, exist_ok=True)
    marker_path.write_text(json.dumps(marker, indent=2, sort_keys=True) + "\n", encoding="utf-8")


def iteration_review_upload_file(local_path: Path, bucket: str, key: str, content_type: str, *, dry_run: bool) -> None:
    destination = f"s3://{bucket}/{key}"
    if dry_run:
        print(f"iteration_review_upload_dry_run: {local_path} -> {destination}")
        return
    command = [
        "aws",
        "s3",
        "cp",
        str(local_path),
        destination,
        "--content-type",
        content_type,
        "--cache-control",
        "no-cache",
    ]
    code, stdout, stderr = run_command(command, timeout=180)
    if code != 0:
        raise SystemExit(f"iteration review S3 upload failed for {destination}: {stderr or stdout}")


def enforce_private_cdn_access(cfg: dict, bucket: str, *, dry_run: bool) -> None:
    """sec-privacy-3: when iterationReview.hosting.access is 'private-cdn', verify at UPLOAD time that
    the bucket blocks public access -- the field was validated but never enforced. Warn-only: a
    missing public-access-block or insufficient perms must not wedge delivery, but a misconfigured
    public bucket is surfaced loudly so a 'private' review is not silently world-readable.
    """
    access = str(cfg.get("hosting", {}).get("access") or "").strip()
    if access != "private-cdn" or dry_run:
        return
    code, stdout, _stderr = run_command(
        ["aws", "s3api", "get-public-access-block", "--bucket", bucket], timeout=30
    )
    if code != 0:
        print(
            f"iteration_review_access_warning: could not verify public-access-block on {bucket} "
            "(access=private-cdn); ensure S3 Block Public Access is on and the bucket serves only via "
            "CloudFront OAC",
            file=sys.stderr,
        )
        return
    blocked = all(
        f'"{flag}": true' in stdout
        for flag in ("BlockPublicAcls", "IgnorePublicAcls", "BlockPublicPolicy", "RestrictPublicBuckets")
    )
    if blocked:
        print("iteration_review_access: private-cdn verified (bucket blocks public access)")
    else:
        print(
            f"iteration_review_access_warning: bucket {bucket} does not fully block public access but "
            "iterationReview.hosting.access=private-cdn; private review artifacts may be publicly "
            "reachable. Enable S3 Block Public Access + CloudFront OAC.",
            file=sys.stderr,
        )


def iteration_review_chat_payload(record: dict, page_url: str) -> dict:
    video_url = str(record.get("videoUrl") or "").strip()
    lines = [
        f"{record['product']} iteration review: {record['goalTitle']}",
        "",
        str(record["why"]).strip(),
        "",
        f"Review page: {page_url}",
    ]
    if video_url:
        lines.append(f"Video: {video_url}")
    return {"text": "\n".join(lines)}


def post_iteration_review_google_chat(webhook_url: str, payload: dict, *, dry_run: bool) -> None:
    if dry_run:
        print("iteration_review_chat_post_dry_run: google-chat-webhook")
        return
    encoded = json.dumps(google_chat_card_only(payload)).encode("utf-8")
    request = Request(
        webhook_url,
        data=encoded,
        headers={"Content-Type": "application/json; charset=utf-8"},
        method="POST",
    )
    try:
        with urlopen(request, timeout=15) as response:
            response.read()
    except (HTTPError, URLError, TimeoutError, OSError) as exc:
        raise SystemExit(redact_secrets(f"iteration review Google Chat post failed: {exc}")) from exc


def generate_iteration_review_page(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    cfg = iteration_review_config(data)
    if not cfg["enabled"]:
        raise SystemExit("iterationReview is disabled in this project adapter")
    if not cfg["outputs"].get("page"):
        raise SystemExit("iterationReview.outputs.page is disabled in this project adapter")
    record_path = cli_path(target, str(args.record)).resolve(strict=False)
    record = read_iteration_review_record(record_path)
    record, applied_defaults = iteration_review_apply_media_defaults(record, cfg)
    output = args.output
    if output:
        output_path = cli_path(target, str(output)).resolve(strict=False)
    else:
        output_path = iteration_review_default_page_path(record_path)
    if not path_is_under(output_path, iteration_review_record_dir(data, target)):
        raise SystemExit(
            f"iteration review page output must stay under iterationReview.recordDir: {output_path}"
        )
    html = iteration_review_page_html(record)
    if args.write:
        if applied_defaults:
            record_path.write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8")
            print(f"iteration_review_record_defaults_applied: {', '.join(applied_defaults)}")
        output_path.parent.mkdir(parents=True, exist_ok=True)
        output_path.write_text(html, encoding="utf-8")
        print(f"iteration_review_page_written: {output_path}")
        try_write_event(
            data,
            target,
            event="iteration_review_page_generated",
            severity="ok",
            plain=f"Iteration review page was generated for {record['goalTitle']}.",
            next_action="Commit the text record/page only; upload generated media through adapter-approved hosting.",
            refs={"record": path_display(target, record_path), "page": path_display(target, output_path)},
        )
    else:
        print(html, end="")
    return 0


def iteration_review_after_merge_workflow_yaml(data: dict) -> str:
    """A ready-to-wire GitHub Actions workflow that publishes the iteration review after the
    review record lands on the base branch (RCA O8: after-merge delivery had no automation, so
    it silently never posted). The operator wires the webhook env var as a repo secret."""
    cfg = iteration_review_config(data)
    webhook_env = str(cfg["delivery"].get("webhookEnv") or "GOOGLE_CHAT_WEBHOOK").strip()
    record_dir = str(data["iterationReview"].get("recordDir") or "docs/iteration-reviews").strip().rstrip("/")
    return "\n".join(
        [
            "name: Publish Iteration Review (after merge)",
            "# Generated by `tautline emit-iteration-review-workflow`.",
            f"# Wire {webhook_env} as a repository secret and set the goal-review record path below.",
            "on:",
            "  push:",
            "    branches: [main]",
            "    paths:",
            f'      - "{record_dir}/**"',
            "permissions:",
            "  contents: read",
            "  id-token: write",
            "jobs:",
            "  publish-iteration-review:",
            "    runs-on: ubuntu-latest",
            "    steps:",
            "      - uses: actions/checkout@v4",
            "      - name: Publish iteration review",
            "        env:",
            f"          {webhook_env}: ${{{{ secrets.{webhook_env} }}}}",
            "        run: |",
            "          tautline publish-iteration-review \\",
            "            --target . \\",
            f"            --record {record_dir}/<goal-id>/goal-review.json",
            "",
        ]
    )


def emit_iteration_review_workflow(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    cfg = iteration_review_config(data)
    if not cfg["enabled"] or not cfg["delivery"].get("enabled"):
        raise SystemExit("iterationReview.delivery is disabled in this project adapter")
    if cfg["delivery"].get("trigger") != "after-merge":
        raise SystemExit(
            "emit-iteration-review-workflow applies to iterationReview.delivery.trigger=after-merge; "
            f"current trigger is {cfg['delivery'].get('trigger')!r}"
        )
    yaml = iteration_review_after_merge_workflow_yaml(data)
    if args.write:
        out = target / ".github" / "workflows" / "iterate-review-after-merge.yml"
        out.parent.mkdir(parents=True, exist_ok=True)
        out.write_text(yaml, encoding="utf-8")
        print(f"iteration_review_workflow_written: {out}")
    else:
        print(yaml, end="")
    return 0


def publish_iteration_review(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    cfg = iteration_review_config(data)
    if not cfg["enabled"]:
        raise SystemExit("iterationReview is disabled in this project adapter")
    if cfg["delivery"].get("enabled") and not cfg["outputs"].get("page"):
        raise SystemExit("iterationReview.delivery requires iterationReview.outputs.page")
    record_path = cli_path(target, str(args.record)).resolve(strict=False)
    record = read_iteration_review_record(record_path)
    review_slug = iteration_review_review_slug(data, target, record_path)
    canonical_issue = iteration_review_canonical_slug_issue(data, target, review_slug)
    if canonical_issue:
        print(f"iteration_review_publish_error: {canonical_issue}", file=sys.stderr)
        return 1
    page_path = iteration_review_default_page_path(record_path)
    if not page_path.exists():
        raise SystemExit(f"iteration review page is missing: {page_path}; run generate-iteration-review-page --write first")
    output_issues = iteration_review_output_contract_issues(record, cfg, page_path)
    partial_video_issue = "iterationReview.outputs.video requires a hosted videoUrl"
    forceable_issues = [issue for issue in output_issues if issue.startswith(partial_video_issue)]
    fatal_issues = [issue for issue in output_issues if issue not in forceable_issues]
    if forceable_issues and args.force_partial_video:
        print(
            "iteration_review_publish_error: --force-partial-video is no longer allowed when iterationReview.outputs.video=true; render and upload the recap video or change the project adapter to outputs.video=false for text-only reviews",
            file=sys.stderr,
        )
        return 1
    if output_issues:
        for issue in output_issues:
            print(f"iteration_review_publish_error: {issue}", file=sys.stderr)
        return 1
    branch = run_git(target, ["branch", "--show-current"])
    if cfg["delivery"].get("trigger") == "after-merge" and not args.allow_unmerged and not args.dry_run and branch not in {"main", "master"}:
        raise SystemExit(
            "publish-iteration-review is configured for after-merge delivery; switch to the merged main/master "
            "checkout first, or pass --allow-unmerged only for an intentional dry run/test"
        )
    hosting = cfg["hosting"]
    bucket = str(hosting.get("bucket", "")).strip()
    if not bucket:
        raise SystemExit("iterationReview.hosting.bucket must be configured before publishing iteration reviews")
    prefix = iteration_review_s3_prefix(data, cfg, review_slug)
    record_key = f"{prefix}/goal-review.json"
    page_key = f"{prefix}/index.html"
    page_url = iteration_review_public_url(cfg, prefix, "index.html")
    record_url = iteration_review_public_url(cfg, prefix, "goal-review.json")
    record_hash = file_sha256(record_path)
    page_hash = file_sha256(page_path)
    delivery_enabled = bool(cfg["delivery"].get("enabled"))
    marker_path = iteration_review_delivery_marker_path(data, target, review_slug)
    skip_chat_reason = str(getattr(args, "skip_chat_reason", "") or "").strip()
    repeat_chat_reason = str(getattr(args, "repeat_chat_reason", "") or "").strip()
    if delivery_enabled and args.skip_chat:
        require_iteration_review_operator_approval(args, "--skip-chat with iterationReview.delivery.enabled=true")
        if not skip_chat_reason:
            print(
                'iteration_review_publish_error: --skip-chat with delivery enabled requires --skip-chat-reason "<reason>"',
                file=sys.stderr,
            )
            return 1
    should_post_chat = delivery_enabled and not args.skip_chat
    if should_post_chat and cfg["delivery"].get("dedupe"):
        if not args.force:
            if iteration_review_delivery_marker_matches(marker_path, record_hash=record_hash, page_hash=page_hash, page_url=page_url):
                print(f"iteration_review_publish_review: {review_slug}")
                print(f"iteration_review_publish_page_url: {page_url}")
                print("iteration_review_chat_posted: already_sent")
                return 0
        elif marker_path.exists():
            # RCA O18: --force must not silently re-post. An existing marker requires an
            # operator-approved, recorded reason for the repeat Chat post.
            require_iteration_review_operator_approval(args, "--force re-post of an already-delivered iteration review")
            if not repeat_chat_reason:
                print(
                    'iteration_review_publish_error: --force re-post requires --repeat-chat-reason "<reason>" '
                    "when a delivery marker already exists",
                    file=sys.stderr,
                )
                return 1
    enforce_private_cdn_access(cfg, bucket, dry_run=args.dry_run)
    iteration_review_upload_file(record_path, bucket, record_key, "application/json; charset=utf-8", dry_run=args.dry_run)
    iteration_review_upload_file(page_path, bucket, page_key, "text/html; charset=utf-8", dry_run=args.dry_run)
    chat_status = "skipped"
    if should_post_chat:
        webhook_env = str(cfg["delivery"].get("webhookEnv") or "")
        webhook_url = util_module().resolve_env(webhook_env).strip()
        if not webhook_url and args.dry_run:
            webhook_url = "https://example.invalid/google-chat-webhook"
        if not webhook_url:
            raise SystemExit(f"iteration review Google Chat webhook env var is missing: {webhook_env}")
        if not webhook_url.startswith("https://"):
            raise SystemExit(f"iteration review Google Chat webhook env var must contain an https URL: {webhook_env}")
        post_iteration_review_google_chat(webhook_url, iteration_review_chat_payload(record, page_url), dry_run=args.dry_run)
        chat_status = "dry_run" if args.dry_run else "ok"
        if not args.dry_run:
            write_iteration_review_delivery_marker(
                marker_path,
                review_slug=review_slug,
                record_hash=record_hash,
                page_hash=page_hash,
                record_url=record_url,
                page_url=page_url,
                provider=cfg["delivery"]["provider"],
                webhook_env=webhook_env,
                repeat_reason=repeat_chat_reason or None,
            )
    elif delivery_enabled and args.skip_chat and not args.dry_run:
        # RCA O11: record the operator-approved skip durably so the delivery check and
        # goal-complete gate can see an intentional, reasoned skip instead of a silent gap.
        write_iteration_review_delivery_marker(
            marker_path,
            review_slug=review_slug,
            record_hash=record_hash,
            page_hash=page_hash,
            record_url=record_url,
            page_url=page_url,
            provider=str(cfg["delivery"].get("provider") or ""),
            webhook_env=str(cfg["delivery"].get("webhookEnv") or ""),
            skipped=True,
            skip_reason=skip_chat_reason,
            operator_approved=iteration_review_operator_approval_ok(args),
        )
    print(f"iteration_review_publish_review: {review_slug}")
    print(f"iteration_review_publish_record_url: {record_url}")
    print(f"iteration_review_publish_page_url: {page_url}")
    print(f"iteration_review_publish_s3_record: s3://{bucket}/{record_key}")
    print(f"iteration_review_publish_s3_page: s3://{bucket}/{page_key}")
    print(f"iteration_review_required_outputs_satisfied: {iteration_review_required_outputs_satisfied(record, cfg, page_path)}")
    print(f"iteration_review_chat_posted: {chat_status}")
    if forceable_issues or args.skip_chat:
        partial_reasons = []
        if forceable_issues:
            partial_reasons.append("video_missing")
        if args.skip_chat:
            partial_reasons.append("chat_skipped")
        print(f"iteration_review_partial_delivery: {','.join(partial_reasons)}")
        try_write_event(
            data,
            target,
            event="iteration_review_partial_delivery",
            severity="warn",
            plain=f"Iteration review for {record['goalTitle']} was published with an approved delivery exception.",
            next_action="Complete the missing required review delivery outputs before declaring the goal close-out done.",
            refs={"record": path_display(target, record_path), "page": page_url, "partial": ",".join(partial_reasons)},
        )
    try_write_event(
        data,
        target,
        event="iteration_review_published",
        severity="ok",
        plain=f"Iteration review for {record['goalTitle']} was published for stakeholder viewing.",
        next_action="Continue authorized goal or milestone work; do not wait for chat acknowledgement.",
        refs={"record": path_display(target, record_path), "page": page_url},
    )
    return 0


def iteration_review_delivery_check(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    cfg = iteration_review_config(data)
    if not cfg["enabled"]:
        raise SystemExit("iterationReview is disabled in this project adapter")
    record_path = cli_path(target, str(args.record)).resolve(strict=False)
    record = read_iteration_review_record(record_path)
    review_slug = iteration_review_review_slug(data, target, record_path)
    canonical_issue = iteration_review_canonical_slug_issue(data, target, review_slug)
    if canonical_issue:
        print(f"iteration_review_delivery_check_error: {canonical_issue}", file=sys.stderr)
        return 1
    page_path = iteration_review_default_page_path(record_path)
    output_issues = iteration_review_output_contract_issues(record, cfg, page_path)
    for issue in output_issues:
        print(f"iteration_review_delivery_check_error: {issue}", file=sys.stderr)
    if output_issues:
        return 1
    prefix = iteration_review_s3_prefix(data, cfg, review_slug)
    page_url = iteration_review_public_url(cfg, prefix, "index.html")
    marker_path = iteration_review_delivery_marker_path(data, target, review_slug)
    marker_ok = True
    marker_data: dict = {}
    if cfg["delivery"].get("enabled"):
        marker_ok = iteration_review_delivery_marker_matches(
            marker_path,
            record_hash=file_sha256(record_path),
            page_hash=file_sha256(page_path),
            page_url=page_url,
            accept_skipped=True,
        )
        if marker_path.exists():
            try:
                marker_data = json.loads(marker_path.read_text(encoding="utf-8"))
            except (OSError, json.JSONDecodeError):
                marker_data = {}
        if not marker_ok:
            print(
                f"iteration_review_delivery_check_error: delivery marker missing or stale for Google Chat post: {marker_path}",
                file=sys.stderr,
            )
        # RCA O18: every repeat post after the first must record a reason; an un-reasoned repeat
        # is a duplicate-card defect, not a valid delivery.
        unreasoned_repeats = iteration_review_marker_unreasoned_repeats(marker_path)
        if unreasoned_repeats:
            print(
                f"iteration_review_delivery_check_error: {unreasoned_repeats} repeat Chat post(s) after the first "
                "lack a --repeat-chat-reason; each repeat must record a reason",
                file=sys.stderr,
            )
            marker_ok = False
    if output_issues or not marker_ok:
        return 1
    print("iteration_review_delivery_check: ok")
    print(f"iteration_review_delivery_review: {review_slug}")
    print(f"iteration_review_required_outputs_satisfied: {iteration_review_required_outputs_satisfied(record, cfg, page_path)}")
    if cfg["delivery"].get("enabled"):
        print(f"iteration_review_delivery_marker: {marker_path}")
        post_history = marker_data.get("postHistory") if isinstance(marker_data.get("postHistory"), list) else []
        print(f"iteration_review_delivery_skipped: {str(bool(marker_data.get('skipped'))).lower()}")
        print(f"iteration_review_delivery_post_count: {len(post_history)}")
        if len(post_history) > 1:
            print(
                f"iteration_review_delivery_repeat_warning: posted {len(post_history)} times; "
                "each repeat after the first must carry a --repeat-chat-reason"
            )
    return 0


def iteration_review_goal_completion_blocker(
    data: dict,
    target: Path,
    record_arg: Path | None,
    *,
    goal_id: str | None = None,
) -> str | None:
    cfg = iteration_review_config(data)
    if not cfg["enabled"] or "goal" not in cfg.get("granularities", []):
        return None
    if record_arg is None:
        return (
            "goal-complete requires --iteration-review-record <recordDir>/<goal-id>/goal-review.json "
            "because iterationReview is enabled for goal boundaries. Run validate/generate/publish and "
            "iteration-review-delivery-check first."
        )
    record_path = cli_path(target, str(record_arg)).resolve(strict=False)
    record = read_iteration_review_record(record_path)
    review_slug = iteration_review_review_slug(data, target, record_path)
    if goal_id and review_slug != slugify(goal_id, fallback="goal"):
        return (
            "goal-complete iteration review record does not match active goal: "
            f"record slug {review_slug}, goal_id {goal_id}"
        )
    page_path = iteration_review_default_page_path(record_path)
    output_issues = iteration_review_output_contract_issues(record, cfg, page_path)
    if output_issues:
        return "goal-complete iteration review delivery check failed: " + "; ".join(output_issues)
    prefix = iteration_review_s3_prefix(data, cfg, review_slug)
    page_url = iteration_review_public_url(cfg, prefix, "index.html")
    marker_path = iteration_review_delivery_marker_path(data, target, review_slug)
    if cfg["delivery"].get("enabled") and not iteration_review_delivery_marker_matches(
        marker_path,
        record_hash=file_sha256(record_path),
        page_hash=file_sha256(page_path),
        page_url=page_url,
        accept_skipped=True,
    ):
        return (
            "goal-complete requires iteration-review-delivery-check to pass; delivery marker missing "
            f"or stale for Google Chat post: {marker_path}"
        )
    if cfg["delivery"].get("enabled"):
        unreasoned_repeats = iteration_review_marker_unreasoned_repeats(marker_path)
        if unreasoned_repeats:
            # Codex P2: goal-complete must apply the same repeat-post validation as
            # iteration-review-delivery-check, or an un-reasoned duplicate-card marker closes the goal.
            return (
                f"goal-complete blocked: {unreasoned_repeats} repeat Chat post(s) after the first lack a "
                "--repeat-chat-reason; each repeat must record a reason before the goal can close"
            )
    return None


MILESTONE_UPDATE_REQUIRED_HEADINGS = [
    "## Plain English",
    "## Progress",
    "## What Changed",
    "## Validation",
    "## Next",
    "## Technical Details",
]


def milestone_update_config(data: dict) -> dict:
    return data["milestoneUpdate"]


def milestone_update_marker_path(data: dict, target: Path, milestone_slug: str) -> Path:
    cfg = milestone_update_config(data)
    return configured_path(target, cfg["sentStateDir"]) / f"{milestone_slug}.json"


def canonical_milestone_update_name(data: dict, target: Path, milestone_name: str) -> str:
    raw = str(milestone_name or "").strip()
    if not raw:
        return raw
    path = goal_run_path(data, target)
    if not path.exists():
        return raw
    try:
        run = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return raw
    if run.get("schema") != GOAL_RUN_SCHEMA:
        return raw
    active = run.get("activeMilestone")
    milestones = run.get("milestones") if isinstance(run.get("milestones"), list) else []
    if not isinstance(active, int) or active < 0 or active >= len(milestones):
        return raw
    milestone = milestones[active]
    if not isinstance(milestone, dict):
        return raw
    title = str(milestone.get("title") or "").strip()
    index = milestone.get("index")
    milestone_id = str(milestone.get("id") or "").strip()
    aliases = {slugify(title, fallback="milestone"), slugify(milestone_id, fallback="milestone")}
    if index is not None:
        aliases.update(
            {
                slugify(f"M{index}", fallback="milestone"),
                slugify(str(index), fallback="milestone"),
                slugify(f"milestone-{index}", fallback="milestone"),
                slugify(f"M{index} - {title}", fallback="milestone"),
                slugify(f"{index} - {title}", fallback="milestone"),
                slugify(f"milestone-{index}-{title}", fallback="milestone"),
            }
        )
    if slugify(raw, fallback="milestone") in aliases:
        return title or milestone_id or raw
    return raw


def milestone_update_marker_candidates(data: dict, target: Path, milestone: dict) -> list[Path]:
    title = str(milestone.get("title") or "").strip()
    index = milestone.get("index")
    names = [title]
    if index is not None and title:
        names.extend([f"M{index}", f"milestone-{index}", f"M{index} - {title}", f"{index} - {title}", f"milestone-{index}-{title}"])
    return [
        milestone_update_marker_path(data, target, slugify(name, fallback="milestone"))
        for name in dict.fromkeys(names)
        if name
    ]


def milestone_update_completion_blocker(data: dict, target: Path, milestone: dict) -> str | None:
    cfg = milestone_update_config(data)
    if not cfg["enabled"]:
        return None
    marker_candidates = milestone_update_marker_candidates(data, target, milestone)
    if any(path.exists() for path in marker_candidates):
        return None
    webhook_env = str(cfg.get("webhookEnv") or "")
    if not webhook_env:
        return "milestoneUpdate is enabled but webhookEnv is blank; fix the project adapter before completing the milestone"
    if not util_module().resolve_env(webhook_env).strip():
        return (
            f"milestone update Google Chat webhook env var is missing: {webhook_env}. "
            "Set it in the lane environment before marking the milestone complete."
        )
    title = str(milestone.get("title") or f"milestone {milestone.get('index')}").strip()
    return (
        "milestone update has not been published for this completed milestone. "
        "Before `goal-advance --event milestone-complete`, run "
        f"`tautline publish-milestone-update --target . --milestone {shlex.quote(title)} --stdin` "
        "with the required headings: ## Plain English, ## Progress, ## What Changed, ## Validation, ## Next, ## Technical Details."
    )


def print_milestone_update_status(data: dict, target: Path) -> list[str]:
    cfg = milestone_update_config(data)
    issues: list[str] = []
    webhook_env = str(cfg.get("webhookEnv") or "")
    webhook_status = "disabled"
    if cfg["enabled"] and not webhook_env:
        issues.append("milestoneUpdate.enabled is true but webhookEnv is blank")
    elif cfg["enabled"]:
        webhook_status = "set" if util_module().resolve_env(webhook_env).strip() else "missing"
        if webhook_status == "missing":
            issues.append(f"milestoneUpdate webhook env var is missing: {webhook_env}")
    print(f"milestone_update: enabled={str(cfg['enabled']).lower()}")
    print(f"milestone_update_trigger: {cfg['trigger']}")
    print(f"milestone_update_provider: {cfg['provider']}")
    print(f"milestone_update_chat_space: {cfg.get('chatSpace') or 'not-configured'}")
    print(f"milestone_update_webhook_env: {cfg.get('webhookEnv') or 'not-configured'}")
    print(f"milestone_update_webhook_value: {webhook_status}")
    print(f"milestone_update_sent_state_dir: {configured_path(target, cfg['sentStateDir'])}")
    print(f"milestone_update_max_chars: {cfg['maxChars']}")
    if cfg["enabled"]:
        print("milestone_update_rule: milestone-complete requires a text-only internal Product Milestones update; no video/page is required")
    for issue in issues:
        print(f"milestone_update_issue: {issue}")
    return issues


def milestone_update_status(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    issues = print_milestone_update_status(data, target)
    if issues:
        print("milestone_update_status: blocked")
        return 1 if args.strict else 0
    print("milestone_update_status: ok")
    if data["milestoneUpdate"]["enabled"]:
        print("milestone_update_next_action: run `tautline publish-milestone-update --target . --milestone <id> --stdin` at milestone completion")
    else:
        print("milestone_update_next_action: disabled by adapter")
    return 0


def milestone_update_content_from_args(args: argparse.Namespace) -> str:
    return chat_module().google_chat_content_from_args(
        args,
        command="publish-milestone-update",
        label="milestone update",
    )


def validate_milestone_update_content(content: str, cfg: dict) -> list[str]:
    return chat_module().validate_milestone_update_content(
        content,
        cfg,
        MILESTONE_UPDATE_REQUIRED_HEADINGS,
        SESSION_JOURNAL_SECRET_PATTERNS,
    )


def milestone_update_sections(content: str) -> dict[str, str]:
    return chat_module().milestone_update_sections(content)


def milestone_update_card_text(value: str) -> str:
    return chat_module().milestone_update_card_text(value)


def milestone_update_payload(data: dict, milestone: str, content: str) -> dict:
    return chat_module().milestone_update_payload(
        data,
        milestone,
        content,
        MILESTONE_UPDATE_REQUIRED_HEADINGS,
        slugify_func=slugify,
    )


def release_update_version_key(version: str) -> tuple:
    return release_update_call("release_update_version_key", version)


def release_update_delivery_file(repo_root: Path = REPO_ROOT) -> Path:
    return release_update_call("release_update_delivery_file", repo_root)


def release_update_version_file(repo_root: Path = REPO_ROOT) -> Path:
    return release_update_call("release_update_version_file", repo_root)


def release_update_delivery_load(repo_root: Path = REPO_ROOT) -> dict:
    """Load the committed release-update delivery ledger, tolerating a missing/corrupt file."""
    return release_update_call("release_update_delivery_load", repo_root)


def release_update_delivered_versions(repo_root: Path = REPO_ROOT) -> set[str]:
    return release_update_call("release_update_delivered_versions", repo_root)


def release_update_suspended_versions(repo_root: Path = REPO_ROOT) -> set[str]:
    """Versions with documented release-update suspension evidence.

    Suspension is not a silent bypass: each entry needs an operator/source, a
    scope, a reason, and a timestamp before it can satisfy release accountability.
    """
    return release_update_call("release_update_suspended_versions", repo_root)


def release_update_accounted_versions(repo_root: Path = REPO_ROOT) -> set[str]:
    return release_update_call("release_update_accounted_versions", repo_root)


def user_config_env_value(name: str, config_env: Path | None = None) -> str:
    """Read one simple NAME=value entry from the installed config env without sourcing shell."""
    config_env = config_env or resolve_user_config_env()
    return util_module().user_config_env_value(name, config_env)


def env_value_with_user_config_fallback(name: str) -> str:
    config_env = resolve_user_config_env()
    secrets_env = USER_SECRETS_ENV if USER_SECRETS_ENV.is_file() else LEGACY_USER_SECRETS_ENV
    return util_module().env_value_with_user_config_fallback(name, config_env, secrets_env)


def release_update_record_delivery(version: str, *, via: str = "publish-release-update") -> None:
    """Record that a release-update card for `version` was delivered to Google Chat."""
    release_update_call("release_update_record_delivery", version, via=via)


def release_update_overdue(versions: list[str], delivered: set[str], current: str) -> list[str]:
    """Released versions already on the base branch (older than `current`) with no delivery marker.

    The current VERSION is excluded because its card is posted after merge; future versions are
    excluded. Pure function so validate.sh and the CLI status command share one definition.
    """
    return release_update_call("release_update_overdue", versions, delivered, current)


def release_update_current_version(repo_root: Path = REPO_ROOT) -> str:
    return release_update_call("release_update_current_version", repo_root)


def release_update_overdue_versions(repo_root: Path = REPO_ROOT) -> list[str]:
    return release_update_call("release_update_overdue_versions", repo_root)


def release_update_public_release_issues(repo_root: Path = REPO_ROOT) -> list[tuple[str, Path, str]]:
    return release_update_call("release_update_public_release_issues", repo_root)


def release_update_status(args: argparse.Namespace) -> int:
    current = plugin_version()
    delivered = release_update_delivered_versions()
    suspended = release_update_suspended_versions()
    overdue = release_update_overdue_versions()
    require_current = bool(getattr(args, "require_current", False))
    summary = release_update_call(
        "release_update_status_summary",
        current=current,
        delivered=delivered,
        suspended=suspended,
        overdue=overdue,
        require_current=require_current,
    )
    current_required_missing = bool(summary["current_required_missing"])
    if getattr(args, "as_json", False):
        print(json.dumps(summary, indent=2))
    else:
        print("\n".join(release_update_call("release_update_status_lines", summary)))
    return 1 if overdue or current_required_missing else 0


def release_update_block(version: str) -> dict:
    return release_update_call("release_update_block", version)


def release_update_lines(version: str) -> list[str]:
    """Plain-text rendering of a release update (used for --dry-run output and logs)."""
    return release_update_call("release_update_lines", version)


def release_update_payload(version: str) -> dict:
    return release_update_call(
        "release_update_payload",
        version,
        card_text=milestone_update_card_text,
        slugify_func=slugify,
    )


def release_update_versions(args: argparse.Namespace) -> list[str]:
    return release_update_call(
        "release_update_versions",
        version=args.version,
        last=args.last,
        current_version=plugin_version() if not args.version and not args.last else "",
        blocks=changelog_release_blocks(),
    )


def publish_release_update(args: argparse.Namespace) -> int:
    # Guarded even for --dry-run: the verb's whole contract is "post, then RECORD the delivery in
    # the committed ledger", so a snapshot exec root is never a place it can complete. Refusing the
    # rehearsal too keeps the operator from being told the run is fine, then blocked for real.
    require_dev_checkout("publish-release-update")
    versions = release_update_versions(args)
    webhook_url = str(args.webhook_url or "").strip() or env_value_with_user_config_fallback(args.webhook_env)
    if not webhook_url and not args.dry_run:
        raise SystemExit(f"release update Google Chat webhook env var is missing: {args.webhook_env}")
    if webhook_url and not webhook_url.startswith("https://"):
        raise SystemExit("release update Google Chat webhook URL must start with https://")
    one_off_webhook = bool(str(args.webhook_url or "").strip())
    for version in versions:
        payload = release_update_payload(version)
        print(f"release_update_version: {version}")
        if args.dry_run:
            print("\n".join(release_update_lines(version)))
        post_google_chat_webhook("release_update", webhook_url or "https://example.invalid/google-chat-webhook", payload, dry_run=args.dry_run)
        print(f"release_update_chat_posted: {'dry_run' if args.dry_run else 'ok'}")
        # Record delivery only for a real post through the configured channel. A one-off
        # --webhook-url is a manual/test override (it may target a test space) and a --dry-run
        # never reached Chat, so neither updates the delivery ledger the validate gate trusts.
        if not args.dry_run and not one_off_webhook:
            release_update_record_delivery(version)
            print(f"release_update_delivery_recorded: {version}")
    return 0


def google_chat_card_only(payload: dict) -> dict:
    """Enforce card-only Google Chat messages.

    Google Chat renders a top-level "text" field IN ADDITION TO a "cardsV2" card, which
    duplicates the whole message (the nice card plus the raw markdown). Whenever a payload
    carries a card, send only the card. Every webhook post flows through this choke point,
    so no posting path can reintroduce the duplicate-text regression.
    """
    return chat_module().google_chat_card_only(payload)


def post_google_chat_webhook(label: str, webhook_url: str, payload: dict, *, dry_run: bool) -> None:
    if dry_run:
        print(f"{label}_chat_post_dry_run: google-chat-webhook")
        return
    encoded = json.dumps(google_chat_card_only(payload)).encode("utf-8")
    request = Request(
        webhook_url,
        data=encoded,
        headers={"Content-Type": "application/json; charset=utf-8"},
        method="POST",
    )
    try:
        with urlopen(request, timeout=15) as response:
            response.read()
    except (HTTPError, URLError, TimeoutError, OSError) as exc:
        raise SystemExit(redact_secrets(f"{label} Google Chat post failed: {exc}")) from exc


def publish_milestone_update(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    cfg = milestone_update_config(data)
    if not cfg["enabled"]:
        raise SystemExit("milestoneUpdate is disabled in this project adapter")
    milestone = str(args.milestone or "").strip()
    if not milestone:
        raise SystemExit("--milestone is required")
    milestone = canonical_milestone_update_name(data, target, milestone)
    content = milestone_update_content_from_args(args)
    errors = validate_milestone_update_content(content, cfg)
    if errors:
        for error in errors:
            print(f"milestone_update_validation_error: {error}", file=sys.stderr)
        return 1
    milestone_slug = slugify(milestone, fallback="milestone")
    content_hash = chat_module().milestone_update_content_hash(content)
    marker_path = milestone_update_marker_path(data, target, milestone_slug)
    if chat_module().milestone_update_already_sent(marker_path, content_hash, dedupe=cfg["dedupe"], force=args.force):
        print(f"milestone_update: {milestone_slug}")
        print("milestone_update_chat_posted: already_sent")
        return 0
    webhook_env = str(cfg.get("webhookEnv") or "")
    webhook_url = chat_module().google_chat_webhook_url("milestone update", webhook_env, dry_run=args.dry_run, environ=os.environ)
    post_google_chat_webhook("milestone_update", webhook_url, milestone_update_payload(data, milestone, content), dry_run=args.dry_run)
    if not args.dry_run:
        chat_module().milestone_update_write_marker(
            marker_path,
            milestone=milestone,
            content_hash=content_hash,
            provider=cfg["provider"],
            webhook_env=webhook_env,
            chat_space=cfg.get("chatSpace", ""),
            posted_at=utc_event_timestamp(),
        )
    print(f"milestone_update: {milestone_slug}")
    print(f"milestone_update_chat_space: {cfg.get('chatSpace')}")
    print(f"milestone_update_chat_posted: {'dry_run' if args.dry_run else 'ok'}")
    try_write_event(
        data,
        target,
        event="milestone_update_published",
        severity="ok",
        plain=f"Internal milestone update was posted for {milestone}.",
        next_action="Continue the next authorized milestone or goal action; do not wait for Chat acknowledgement.",
        refs={"milestone": milestone},
    )
    return 0


def product_chat_config(data: dict) -> dict:
    return data["productChat"]


def print_product_chat_status(data: dict) -> list[str]:
    cfg = product_chat_config(data)
    issues: list[str] = []
    if cfg["enabled"] and not cfg.get("webhookEnv"):
        issues.append("productChat.enabled is true but webhookEnv is blank")
    print(f"product_chat: enabled={str(cfg['enabled']).lower()}")
    print(f"product_chat_provider: {cfg['provider']}")
    print(f"product_chat_space: {cfg.get('chatSpace') or 'not-configured'}")
    print(f"product_chat_webhook_env: {cfg.get('webhookEnv') or 'not-configured'}")
    print(f"product_chat_max_chars: {cfg['maxChars']}")
    if cfg["enabled"]:
        print("product_chat_rule: agents may post quick human-requested notes with `tautline publish-product-note --target . --title <title> --stdin`")
    for issue in issues:
        print(f"product_chat_issue: {issue}")
    return issues


def product_note_content_from_args(args: argparse.Namespace) -> str:
    return chat_module().google_chat_content_from_args(
        args,
        command="publish-product-note",
        label="product note",
    )


def validate_product_note(title: str, content: str, cfg: dict) -> list[str]:
    return chat_module().validate_product_note(title, content, cfg, SESSION_JOURNAL_SECRET_PATTERNS)


def product_note_payload(data: dict, title: str, content: str) -> dict:
    return chat_module().product_note_payload(data, title, content, slugify_func=slugify)


def publish_product_note(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    cfg = product_chat_config(data)
    if not cfg["enabled"]:
        raise SystemExit("productChat is disabled in this project adapter")
    title = str(args.title or "").strip()
    content = product_note_content_from_args(args)
    errors = validate_product_note(title, content, cfg)
    if errors:
        for error in errors:
            print(f"product_note_validation_error: {error}", file=sys.stderr)
        return 1
    webhook_env = str(cfg.get("webhookEnv") or "")
    webhook_url = chat_module().google_chat_webhook_url("product note", webhook_env, dry_run=args.dry_run, environ=os.environ)
    post_google_chat_webhook("product_note", webhook_url, product_note_payload(data, title, content), dry_run=args.dry_run)
    print(f"product_note: {slugify(title, fallback='note')}")
    print(f"product_note_chat_space: {cfg.get('chatSpace')}")
    print(f"product_note_chat_posted: {'dry_run' if args.dry_run else 'ok'}")
    try_write_event(
        data,
        target,
        event="product_note_published",
        severity="ok",
        plain=f"Product note posted: {title}.",
        next_action="Continue the current authorized work; do not wait for Chat acknowledgement.",
        refs={"title": title},
    )
    return 0


def deploy_health_configured_targets(data: dict) -> list[dict]:
    return deploy_module().deploy_health_configured_targets(data)


def deploy_health_parse_time(value: str) -> datetime | None:
    return deploy_module().deploy_health_parse_time(value)


def deploy_health_sorted_runs(runs: list[dict]) -> list[dict]:
    return deploy_module().deploy_health_sorted_runs(runs)


def deploy_health_completed_runs(runs: list[dict]) -> list[dict]:
    return deploy_module().deploy_health_completed_runs(runs)


def deploy_health_run_label(run: dict) -> str:
    return deploy_module().deploy_health_run_label(run)


def deploy_health_issues_for_runs(target_name: str, cfg: dict, runs: list[dict], *, now: datetime | None = None) -> list[str]:
    return deploy_module().deploy_health_issues_for_runs(
        target_name,
        cfg,
        runs,
        defaults=DEFAULT_DEPLOY_HEALTH,
        failure_conclusions=DEPLOY_HEALTH_FAILURE_CONCLUSIONS,
        now=now,
    )


def deploy_health_runs_from_file(path: Path, cfg: dict) -> list[dict] | None:
    return deploy_module().deploy_health_runs_from_file(path, cfg)


def deploy_health_github_actions_runs(target: Path, cfg: dict) -> tuple[list[dict] | None, str | None]:
    return deploy_module().deploy_health_github_actions_runs(target, cfg, command_runner=run_command)


def deploy_health_issues(data: dict, target: Path, *, runs_file: Path | None = None) -> list[str]:
    return deploy_module().deploy_health_issues(
        data,
        target,
        runs_file=runs_file,
        configured_targets_func=deploy_health_configured_targets,
        runs_from_file_func=deploy_health_runs_from_file,
        github_actions_runs_func=deploy_health_github_actions_runs,
        issues_for_runs_func=deploy_health_issues_for_runs,
    )


def deploy_health(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    configured = deploy_health_configured_targets(data)
    if not configured:
        print("deploy_health: no-configured-targets")
        print("deploy_health_status: ok")
        return 0
    print(f"deploy_health_targets: {len(configured)}")
    for deployment in configured:
        cfg = deployment["deployHealth"]
        print(
            "deploy_health_target: "
            f"{deployment['name']} provider={cfg['provider']} workflow={cfg['workflow']} branch={cfg['branch']} "
            f"max_consecutive_failures={cfg['maxConsecutiveFailures']} max_success_age_hours={cfg['maxSuccessAgeHours']}"
        )
    runs_file = cli_path(target, str(args.runs_file)).resolve(strict=False) if args.runs_file else None
    issues = deploy_health_issues(data, target, runs_file=runs_file)
    for issue in issues:
        print(f"deploy_health_issue: {issue}")
    if issues:
        print("deploy_health_status: blocked")
        return 1
    print("deploy_health_status: ok")
    return 0


def deployment_notification_config(data: dict) -> dict:
    return deploy_module().deployment_notification_config(data)


def deployment_notification_effective_webhook_env(data: dict) -> str:
    return deploy_module().deployment_notification_effective_webhook_env(
        data,
        config_func=deployment_notification_config,
    )


def deployment_notification_pipeline_issues(data: dict, target: Path) -> list[str]:
    return deploy_module().deployment_notification_pipeline_issues(
        data,
        target,
        config_func=deployment_notification_config,
        configured_path_func=configured_path,
    )


def print_deployment_notification_status(data: dict, target: Path) -> list[str]:
    cfg = deployment_notification_config(data)
    issues: list[str] = []
    webhook_env = deployment_notification_effective_webhook_env(data)
    webhook_status = "disabled"
    if cfg["enabled"] and not webhook_env:
        issues.append("deploymentNotification.enabled is true but no webhookEnv is configured or reusable from iterationReview.delivery")
    elif cfg["enabled"]:
        webhook_status = "set" if util_module().resolve_env(webhook_env).strip() else "missing"
        if webhook_status == "missing":
            issues.append(f"deployment notification Google Chat webhook env var is missing: {webhook_env}")
    pipeline = cfg.get("pipeline") or {}
    pipeline_issues = deployment_notification_pipeline_issues(data, target)
    issues.extend(pipeline_issues)
    pipeline_required = bool(pipeline.get("required"))
    if not cfg["enabled"]:
        pipeline_status = "disabled"
    elif not pipeline_required:
        pipeline_status = "not-required"
    elif pipeline_issues:
        pipeline_status = "missing"
    else:
        pipeline_status = "configured"
    print(f"deployment_notification: enabled={str(cfg['enabled']).lower()}")
    print(f"deployment_notification_trigger: {cfg['trigger']}")
    print(f"deployment_notification_provider: {cfg['provider']}")
    print(f"deployment_notification_chat_space: {cfg.get('chatSpace') or 'not-configured'}")
    print(f"deployment_notification_webhook_env: {webhook_env or 'not-configured'}")
    print(f"deployment_notification_webhook_value: {webhook_status}")
    print(f"deployment_notification_reuse_iteration_review_webhook: {str(cfg.get('reuseIterationReviewWebhook')).lower()}")
    print(f"deployment_notification_sent_state_dir: {configured_path(target, cfg['sentStateDir'])}")
    print(f"deployment_notification_max_chars: {cfg['maxChars']}")
    print(f"deployment_notification_pipeline_required: {str(pipeline_required).lower()}")
    print(f"deployment_notification_pipeline_mode: {pipeline.get('mode', 'not-configured')}")
    print(f"deployment_notification_pipeline_health_check_before_notify: {str(bool(pipeline.get('healthCheckBeforeNotify'))).lower()}")
    print(f"deployment_notification_pipeline_required_command: {pipeline.get('requiredCommand') or 'not-configured'}")
    evidence_paths = [configured_path(target, item) for item in (pipeline.get("evidencePaths") or [])]
    print(f"deployment_notification_pipeline_evidence_paths: {compact_path_list(evidence_paths, target)}")
    print(f"deployment_notification_pipeline_status: {pipeline_status}")
    latest_marker = deployment_notification_latest_marker(data, target)
    if latest_marker:
        marker_path = latest_marker.get("_path")
        print(f"deployment_notification_latest_marker: {path_display(target, Path(marker_path)) if marker_path else 'unknown'}")
        print(f"deployment_notification_latest_marker_source: {latest_marker.get('source', 'unknown')}")
        print(f"deployment_notification_latest_marker_posted_at: {latest_marker.get('postedAt', 'unknown')}")
    else:
        print("deployment_notification_latest_marker: none")
        print("deployment_notification_latest_marker_source: none")
    if cfg["enabled"]:
        print("deployment_notification_rule: the deploy/build pipeline must call publish-deploy-ready-update after live-site health checks; agent/manual posts are fallback only")
    for issue in issues:
        print(f"deployment_notification_issue: {issue}")
    return issues


def deployment_notification_status(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    issues = print_deployment_notification_status(data, target)
    if issues:
        print("deployment_notification_status: blocked")
        return 1 if args.strict else 0
    print("deployment_notification_status: ok")
    if data["deploymentNotification"]["enabled"]:
        print("deployment_notification_next_action: add the pipeline snippet to the real deploy workflow, then call publish-deploy-ready-update only after health checks pass")
    else:
        print("deployment_notification_next_action: disabled by adapter")
    return 0


def deployment_notification_pipeline_snippet(args: argparse.Namespace) -> int:
    data, _, _target = lane_project(args)
    cfg = deployment_notification_config(data)
    pipeline = cfg.get("pipeline") or {}
    env_var = str(args.environment_var or "DEPLOY_ENVIRONMENT").strip()
    url_var = str(args.url_var or "DEPLOY_READY_URL").strip()
    review_var = str(args.iteration_review_url_var or "ITERATION_REVIEW_URL").strip()
    if not re.fullmatch(r"[A-Z_][A-Z0-9_]*", env_var):
        raise SystemExit("--environment-var must be an environment variable name")
    if not re.fullmatch(r"[A-Z_][A-Z0-9_]*", url_var):
        raise SystemExit("--url-var must be an environment variable name")
    if review_var and not re.fullmatch(r"[A-Z_][A-Z0-9_]*", review_var):
        raise SystemExit("--iteration-review-url-var must be an environment variable name")
    print("deployment_notification_pipeline_snippet:")
    print("# Put this in the deploy/build workflow after deploy completion and live-site health checks.")
    print("# This is the reliable path; an AI session posting after the fact is only a fallback.")
    print(f"# Adapter pipeline required: {str(bool(pipeline.get('required'))).lower()}")
    print("tautline publish-deploy-ready-update \\")
    print('  --target . \\')
    print(f'  --environment "${{{env_var}:-dev}}" \\')
    print(f'  --url "${{{url_var}:?set live URL after health checks pass}}" \\')
    if review_var:
        print(f'  --iteration-review-url "${{{review_var}:-}}" \\')
    print('  --commit "${GITHUB_SHA:-$(git rev-parse --short=12 HEAD)}" \\')
    print('  --source pipeline \\')
    print('  --summary "Deploy health checks passed; the live site is ready for review."')
    return 0


def deployment_notification_content_from_args(args: argparse.Namespace) -> str:
    return deploy_module().deployment_notification_content_from_args(args)


def validate_deployment_notification_content(environment: str, url: str, content: str, cfg: dict) -> list[str]:
    return chat_module().validate_deployment_notification_content(
        environment,
        url,
        content,
        cfg,
        SESSION_JOURNAL_SECRET_PATTERNS,
    )


def deployment_notification_marker_path(data: dict, target: Path, marker_slug: str) -> Path:
    return deploy_module().deployment_notification_marker_path(
        data,
        target,
        marker_slug,
        config_func=deployment_notification_config,
        configured_path_func=configured_path,
    )


def deployment_notification_latest_marker(data: dict, target: Path) -> dict | None:
    return deploy_module().deployment_notification_latest_marker(
        data,
        target,
        config_func=deployment_notification_config,
        configured_path_func=configured_path,
    )


def deployment_notification_ci_detected() -> bool:
    return deploy_module().deployment_notification_ci_detected(environ=os.environ)


def deployment_notification_source(requested_source: str, *, dry_run: bool) -> str:
    return deploy_module().deployment_notification_source(
        requested_source,
        dry_run=dry_run,
        ci_detected_func=deployment_notification_ci_detected,
    )


def deployment_notification_payload(
    data: dict,
    *,
    environment: str,
    url: str,
    summary: str,
    iteration_review_url: str,
    commit: str,
) -> dict:
    return chat_module().deployment_notification_payload(
        data,
        environment=environment,
        url=url,
        summary=summary,
        iteration_review_url=iteration_review_url,
        commit=commit,
        slugify_func=slugify,
    )


def publish_deploy_ready_update(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    cfg = deployment_notification_config(data)
    if not cfg["enabled"]:
        raise SystemExit("deploymentNotification is disabled in this project adapter")
    environment = str(args.environment or "").strip()
    url = str(args.url or "").strip()
    iteration_review_url = str(args.iteration_review_url or "").strip()
    commit = str(args.commit or "").strip() or run_git(target, ["rev-parse", "--short=12", "HEAD"])
    source = deployment_notification_source(args.source, dry_run=args.dry_run)
    content = deployment_notification_content_from_args(args)
    errors = validate_deployment_notification_content(environment, url, content, cfg)
    errors.extend(
        deploy_module().deployment_notification_extra_validation_errors(
            iteration_review_url=iteration_review_url,
            commit=commit,
            secret_patterns=SESSION_JOURNAL_SECRET_PATTERNS,
        )
    )
    if errors:
        for error in errors:
            print(f"deployment_notification_validation_error: {error}", file=sys.stderr)
        return 1
    identity_hash = deploy_module().deployment_notification_identity_hash(
        source=source,
        environment=environment,
        url=url,
        iteration_review_url=iteration_review_url,
        commit=commit,
        content=content,
    )
    marker_slug = slugify(f"{environment}-{commit or url}", fallback="deploy-ready")
    marker_path = deployment_notification_marker_path(data, target, marker_slug)
    if cfg["dedupe"] and marker_path.exists() and not args.force:
        try:
            marker = json.loads(marker_path.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError):
            marker = {}
        if marker.get("identitySha256") == identity_hash:
            print(f"deployment_notification: {marker_slug}")
            print("deployment_notification_chat_posted: already_sent")
            return 0
    webhook_env = deployment_notification_effective_webhook_env(data)
    webhook_url = deploy_module().deployment_notification_webhook_url(webhook_env, dry_run=args.dry_run, environ=os.environ)
    payload = deployment_notification_payload(
        data,
        environment=environment,
        url=url,
        summary=content,
        iteration_review_url=iteration_review_url,
        commit=commit,
    )
    post_google_chat_webhook("deployment_notification", webhook_url, payload, dry_run=args.dry_run)
    if not args.dry_run:
        deploy_module().deployment_notification_write_marker(
            marker_path,
            environment=environment,
            url=url,
            iteration_review_url=iteration_review_url,
            commit=commit,
            source=source,
            identity_hash=identity_hash,
            provider=cfg["provider"],
            webhook_env=webhook_env,
            chat_space=cfg.get("chatSpace", ""),
            posted_at=utc_event_timestamp(),
        )
    print(f"deployment_notification: {marker_slug}")
    print(f"deployment_notification_environment: {environment}")
    print(f"deployment_notification_url: {url}")
    if iteration_review_url:
        print(f"deployment_notification_iteration_review_url: {iteration_review_url}")
    print(f"deployment_notification_source: {source}")
    print(f"deployment_notification_chat_space: {cfg.get('chatSpace')}")
    print(f"deployment_notification_webhook_env: {webhook_env}")
    print(f"deployment_notification_chat_posted: {'dry_run' if args.dry_run else 'ok'}")
    try_write_event(
        data,
        target,
        event="deployment_ready_notification_published",
        severity="ok",
        plain=f"{environment} deploy is live and ready to review.",
        next_action="Continue the next authorized goal, milestone, or deployment verification step; do not wait for Chat acknowledgement.",
        refs={"url": url, "iteration_review_url": iteration_review_url, "commit": commit, "source": source},
    )
    return 0


def utc_event_timestamp() -> str:
    return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")


def normalize_event_text(data: dict, target: Path, text: str) -> str:
    normalized = str(text).replace("\n", " ").strip()
    # Both methodology roots and the snapshot store are redacted to one token. Post-cutover, paths
    # that used to be REPO_ROOT surface in three shapes -- the canonical checkout, the snapshot exec
    # root, and sibling snapshots under the store -- and a redactor that only knew the exec root
    # would leak the operator's machine layout into published event text.
    # ORDER IS LOAD-BEARING: the exec root lives INSIDE the store, so the longest, most specific
    # path must be replaced before its parent (replacing the store first would leave
    # "<methodology_repo>/<sha12>" and the exec-root rule could never match).
    replacements = [
        (str(target), "."),
        (str(REPO_ROOT), "<methodology_repo>"),
        (str(canonical_methodology_repo()), "<methodology_repo>"),
        (str(methodology_snapshot_store_root()), "<methodology_repo>"),
        (str(Path.home()), "$HOME"),
        (str(observability_state_dir(data)), "<event_state>"),
    ]
    for needle, replacement in replacements:
        if needle:
            normalized = normalized.replace(needle, replacement)
    normalized = re.sub(r"/Users/[^/\s`]+", "$HOME", normalized)
    normalized = re.sub(r"/private/var/folders/[^\s`]+", "<local-temp>", normalized)
    return re.sub(r"\s+", " ", normalized).strip()


def validate_event_value(value: str, field: str) -> list[str]:
    errors: list[str] = []
    if not value.strip():
        errors.append(f"{field} must be non-blank")
    if len(value) > EVENT_MAX_FIELD_CHARS:
        errors.append(f"{field} exceeds {EVENT_MAX_FIELD_CHARS} characters")
    for pattern in SESSION_JOURNAL_SECRET_PATTERNS:
        if re.search(pattern, value):
            errors.append(f"{field} contains a secret-looking value")
            break
    return errors


def event_refs_from_args(refs: list[str] | None, data: dict, target: Path) -> dict:
    parsed: dict[str, str] = {}
    for item in refs or []:
        if "=" not in item:
            raise SystemExit(f"--ref must use key=value form: {item}")
        key, value = item.split("=", 1)
        key = slugify(key, fallback="ref").replace("-", "_")
        if not key:
            raise SystemExit(f"--ref key must be non-blank: {item}")
        normalized = normalize_event_text(data, target, value)
        errors = validate_event_value(normalized, f"ref {key}")
        if errors:
            raise SystemExit("; ".join(errors))
        parsed[key] = normalized
    return parsed


def current_goal_milestone_context(data: dict, target: Path) -> tuple[str | None, str | None]:
    goal_value: str | None = None
    milestone_value: str | None = None
    try:
        if goal_run_path(data, target).exists():
            goal_run = load_goal_run(data, target)
            goal_value = str(goal_run.get("goalId") or goal_run.get("sourceGoal") or "") or None
            active = goal_run.get("activeMilestone")
            milestones = goal_run.get("milestones") if isinstance(goal_run.get("milestones"), list) else []
            if isinstance(active, int) and 0 <= active < len(milestones):
                milestone = milestones[active]
                if isinstance(milestone, dict):
                    milestone_value = str(milestone.get("id") or milestone.get("title") or "") or None
    except Exception:
        pass
    try:
        if not milestone_value and milestone_run_path(data, target).exists():
            milestone_run = load_milestone_run(data, target)
            milestone_value = str(milestone_run.get("milestoneId") or milestone_run.get("sourcePlan") or "") or None
    except Exception:
        pass
    return goal_value, milestone_value


def build_event_payload(
    data: dict,
    target: Path,
    *,
    event: str,
    severity: str,
    plain: str,
    next_action: str,
    refs: dict | None = None,
    goal: str | None = None,
    milestone: str | None = None,
    pr: str | None = None,
) -> dict:
    event = slugify(event, fallback="event").replace("-", "_")
    severity = severity.lower().strip()
    if severity not in EVENT_SEVERITIES:
        raise SystemExit(f"severity must be one of: {', '.join(sorted(EVENT_SEVERITIES))}")
    plain = normalize_event_text(data, target, plain)
    next_action = normalize_event_text(data, target, next_action)
    errors = validate_event_value(plain, "plain") + validate_event_value(next_action, "next")
    if errors:
        raise SystemExit("; ".join(errors))
    derived_goal, derived_milestone = current_goal_milestone_context(data, target)
    payload = {
        "schema": EVENT_LOG_SCHEMA,
        "ts": utc_event_timestamp(),
        "project": data.get("project"),
        "repo": data.get("repo"),
        "repo_slug": event_repo_slug(data),
        "lane": target.name,
        "branch": run_git(target, ["branch", "--show-current"]),
        "head": run_git(target, ["rev-parse", "--short", "HEAD"]),
        "goal": normalize_event_text(data, target, goal or derived_goal or ""),
        "milestone": normalize_event_text(data, target, milestone or derived_milestone or ""),
        "pr": normalize_event_text(data, target, pr or ""),
        "event": event,
        "severity": severity,
        "plain": plain,
        "next": next_action,
        "refs": refs or {},
        "methodology": {
            "plugin_version": plugin_version(),
            "methodology_commit": running_methodology_commit(short=True),
        },
    }
    encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"))
    if len(encoded) > EVENT_MAX_JSON_CHARS:
        raise SystemExit(f"event payload exceeds {EVENT_MAX_JSON_CHARS} bytes")
    return payload


def event_human_line(payload: dict) -> str:
    stamp = str(payload["ts"]).split("T", 1)[-1].replace("+00:00", "Z")
    severity = str(payload["severity"]).upper()
    event = str(payload["event"])
    lane = str(payload.get("lane") or "-")
    branch = str(payload.get("branch") or "-")
    plain = str(payload["plain"])
    next_action = str(payload["next"])
    return f"{stamp:<9} {severity:<5} {event:<28} {lane} {branch} | {plain} | next: {next_action}"


def rotate_event_file(path: Path, rotate_bytes: int, retained: int) -> None:
    if not path.exists() or path.stat().st_size <= rotate_bytes:
        return
    for index in range(retained - 1, 0, -1):
        source = path.with_name(f"{path.name}.{index}")
        dest = path.with_name(f"{path.name}.{index + 1}")
        if source.exists():
            if dest.exists():
                dest.unlink()
            source.rename(dest)
    first = path.with_name(f"{path.name}.1")
    if first.exists():
        first.unlink()
    path.rename(first)


def secure_event_log_paths(human: Path, jsonl: Path, lock: Path) -> None:
    """Restrict the observability dir (0700) and its event files (0600) to the owner. The event log
    now carries the salted lane_id that ALSO appears in published telemetry, so a world-readable
    (umask 0022 -> 0644) log on a multi-user host would let a co-located OS account correlate a
    remote sanitized record back to this product's local project/repo/branch data -- defeating the
    salt. The 0700 dir alone blocks traversal by others; the 0600 files are defense in depth, applied
    to existing logs and rotations too. Best-effort: permission errors are non-fatal."""
    directory = human.parent
    try:
        directory.chmod(0o700)
    except OSError:
        pass
    for base in (human, jsonl, lock):
        for path in [base, *directory.glob(f"{base.name}.*")]:
            try:
                if path.exists():
                    path.chmod(0o600)
            except OSError:
                pass


def append_event_payload(data: dict, target: Path, payload: dict) -> tuple[Path, Path]:
    if not data["observabilityEvents"].get("enabled", True):
        raise SystemExit("observability events are disabled by adapter")
    human, jsonl, lock = observability_event_paths(data, target)
    human.parent.mkdir(parents=True, exist_ok=True)
    secure_event_log_paths(human, jsonl, lock)
    with lock.open("a", encoding="utf-8") as lock_file, advisory_flock(lock_file):
        # T2: lane_id + seq are allocated INSIDE this same lock so two simultaneous
        # try_write_event calls for this lane can never duplicate or skip a seq (see the T2
        # comment above allocate_instrumentation_seq()).
        lane_id = instrumentation_lane_id(target)
        payload["lane_id"] = lane_id
        rotate_bytes = int(data["observabilityEvents"]["rotateBytes"])
        retained = int(data["observabilityEvents"]["retainedRotations"])
        payload["seq"] = allocate_instrumentation_seq(
            lane_id, str(payload["ts"]), jsonl_path=jsonl, retained_rotations=retained
        )
        rotate_event_file(human, rotate_bytes, retained)
        rotate_event_file(jsonl, rotate_bytes, retained)
        line = event_human_line(payload)
        with jsonl.open("a", encoding="utf-8") as handle:
            handle.write(json.dumps(payload, sort_keys=True) + "\n")
        with human.open("a", encoding="utf-8") as handle:
            handle.write(line + "\n")
        # Re-apply owner-only mode after creating files this call (open("a") creates 0644 under the
        # default umask); the 0700 dir already blocks others, this closes the file-mode window too.
        secure_event_log_paths(human, jsonl, lock)
    return human, jsonl


def write_event(
    data: dict,
    target: Path,
    *,
    event: str,
    severity: str,
    plain: str,
    next_action: str,
    refs: dict | None = None,
    goal: str | None = None,
    milestone: str | None = None,
    pr: str | None = None,
) -> tuple[Path, Path]:
    payload = build_event_payload(
        data,
        target,
        event=event,
        severity=severity,
        plain=plain,
        next_action=next_action,
        refs=refs,
        goal=goal,
        milestone=milestone,
        pr=pr,
    )
    return append_event_payload(data, target, payload)


def try_write_event(data: dict, target: Path, **kwargs: object) -> None:
    if not data.get("observabilityEvents", {}).get("enabled", True):
        return
    try:
        write_event(data, target, **kwargs)  # type: ignore[arg-type]
    except Exception as exc:
        print(f"event_log_warning: {exc}")


def log_event(args: argparse.Namespace) -> int:
    data, _project_path, target = lane_project(args)
    ensure_lane_state(data, target)
    if not data["observabilityEvents"].get("enabled", True):
        print("event_log_skipped: disabled")
        return 0
    refs = event_refs_from_args(args.ref, data, target)
    human, jsonl = write_event(
        data,
        target,
        event=args.event,
        severity=args.severity,
        plain=args.plain,
        next_action=args.next,
        refs=refs,
        goal=args.goal,
        milestone=args.milestone,
        pr=args.pr,
    )
    print(f"event_log: {human}")
    print(f"event_jsonl: {jsonl}")
    return 0


def event_log_path(args: argparse.Namespace) -> int:
    data, _project_path, target = lane_project(args)
    human, jsonl, _lock = observability_event_paths(data, target)
    print(f"event_log: {human}")
    print(f"event_jsonl: {jsonl}")
    print(f"event_tail: tautline event-tail --target {target} --lines 80")
    return 0


def tail_lines(path: Path, lines: int) -> list[str]:
    if not path.exists():
        return []
    return path.read_text(encoding="utf-8", errors="replace").splitlines()[-lines:]


def event_tail(args: argparse.Namespace) -> int:
    data, _project_path, target = lane_project(args)
    if args.lines < 1:
        raise SystemExit("--lines must be positive")
    human, _jsonl, _lock = observability_event_paths(data, target)
    for line in tail_lines(human, args.lines):
        print(line)
    return 0


EVENT_VIEWER_APP = "minervit-event-viewer"
EVENT_VIEWER_DEFAULT_PORT = 18765


def event_viewer_client_host(host: str) -> str:
    normalized = str(host or "").strip()
    if normalized in {"", "0.0.0.0", "::"}:
        return "127.0.0.1"
    if ":" in normalized and not normalized.startswith("["):
        return f"[{normalized}]"
    return normalized


def event_viewer_url(host: str, port: int) -> str:
    return f"http://{event_viewer_client_host(host)}:{port}/"


def event_viewer_health_payload(data: dict, target: Path) -> dict:
    human, jsonl, _lock = observability_event_paths(data, target)
    return {
        "app": EVENT_VIEWER_APP,
        "project": data.get("project"),
        "repo": data.get("repo"),
        "target": str(target),
        "event_log": str(human),
        "event_jsonl": str(jsonl),
    }


def probe_event_viewer(host: str, port: int) -> dict | None:
    base = event_viewer_url(host, port)
    for path in ("api/health", "api/events?limit=1&since=7d"):
        try:
            request = Request(base + path, headers={"Accept": "application/json"})
            with urlopen(request, timeout=1.0) as response:
                payload = json.loads(response.read().decode("utf-8"))
        except (HTTPError, URLError, TimeoutError, OSError, ValueError, json.JSONDecodeError):
            continue
        if payload.get("app") == EVENT_VIEWER_APP:
            payload["url"] = base
            return payload
        if {"event_log", "event_jsonl", "events"}.issubset(payload.keys()):
            payload["app"] = EVENT_VIEWER_APP
            payload["url"] = base
            return payload
    return None


EVENT_VIEWER_HTML = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Minervit Event Viewer</title>
<style>
:root { color-scheme: light dark; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
body { margin: 0; background: Canvas; color: CanvasText; }
header { padding: 12px 16px; border-bottom: 1px solid color-mix(in srgb, CanvasText 18%, transparent); display: flex; gap: 16px; align-items: baseline; flex-wrap: wrap; }
h1 { font-size: 16px; margin: 0; }
#meta { font-size: 12px; opacity: .72; }
main { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(320px, .9fr); height: calc(100vh - 54px); }
#events { overflow: auto; border-right: 1px solid color-mix(in srgb, CanvasText 18%, transparent); }
.row { padding: 8px 12px; border-bottom: 1px solid color-mix(in srgb, CanvasText 10%, transparent); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; white-space: pre-wrap; cursor: pointer; }
.row:hover, .row.selected { background: color-mix(in srgb, Highlight 20%, transparent); }
.ok { border-left: 4px solid #1f9d55; }
.info { border-left: 4px solid #3b82f6; }
.warn { border-left: 4px solid #d97706; }
.block, .fail { border-left: 4px solid #dc2626; }
#detail { overflow: auto; padding: 12px; }
#detail h2 { font-size: 14px; margin: 0 0 8px; }
pre { margin: 0; font-size: 12px; line-height: 1.45; white-space: pre-wrap; word-break: break-word; }
@media (max-width: 900px) { main { grid-template-columns: 1fr; grid-template-rows: 55vh 45vh; } #events { border-right: 0; border-bottom: 1px solid color-mix(in srgb, CanvasText 18%, transparent); } }
</style>
</head>
<body>
<header><h1>Minervit Event Viewer</h1><div id="meta">loading...</div></header>
<main>
  <section id="events" aria-label="Event stream"></section>
  <section id="detail"><h2>Event Detail</h2><pre>Click an event row to inspect its JSON payload.</pre></section>
</main>
<script>
let selectedId = null;
const eventsEl = document.getElementById('events');
const detailEl = document.getElementById('detail');
const metaEl = document.getElementById('meta');
function renderDetail(item) {
  selectedId = item.id;
  const title = document.createElement('h2');
  title.textContent = item.record.event || 'Event Detail';
  const detail = document.createElement('pre');
  detail.textContent = JSON.stringify(item.record, null, 2);
  detailEl.replaceChildren(title, detail);
  document.querySelectorAll('.row').forEach(row => row.classList.toggle('selected', row.dataset.id === selectedId));
}
async function refresh() {
  const response = await fetch('/api/events?limit=250&since=7d', {cache: 'no-store'});
  const payload = await response.json();
  metaEl.textContent = payload.project + ' | ' + payload.repo + ' | ' + payload.event_log + ' | refreshed ' + new Date().toLocaleTimeString();
  const scrollNearBottom = eventsEl.scrollTop + eventsEl.clientHeight >= eventsEl.scrollHeight - 48;
  eventsEl.innerHTML = '';
  payload.events.forEach(item => {
    const row = document.createElement('div');
    row.className = 'row ' + (item.record.severity || 'info');
    row.dataset.id = item.id;
    row.textContent = item.line;
    row.onclick = () => renderDetail(item);
    eventsEl.appendChild(row);
  });
  if (selectedId) {
    const selected = payload.events.find(item => item.id === selectedId);
    if (selected) renderDetail(selected);
  }
  if (scrollNearBottom) eventsEl.scrollTop = eventsEl.scrollHeight;
}
refresh();
setInterval(refresh, 2000);
</script>
</body>
</html>
"""


def event_record_id(record: dict) -> str:
    encoded = json.dumps(record, sort_keys=True, separators=(",", ":")).encode("utf-8", errors="replace")
    return hashlib.sha256(encoded).hexdigest()[:16]


def event_viewer_payload(data: dict, target: Path, since: str, limit: int) -> dict:
    human, jsonl, _lock = observability_event_paths(data, target)
    retained = int(data["observabilityEvents"]["retainedRotations"])
    records = load_event_records(jsonl, parse_duration_seconds(since), retained)[-limit:]
    events = []
    for record in records:
        try:
            line = event_human_line(record)
        except Exception:
            line = f"{record.get('ts', '')} {record.get('event', 'invalid_event')} | {record.get('plain', '')} | next: {record.get('next', '')}"
        events.append({"id": event_record_id(record), "line": line, "record": record})
    return {
        "app": EVENT_VIEWER_APP,
        "project": data.get("project"),
        "repo": data.get("repo"),
        "event_log": str(human),
        "event_jsonl": str(jsonl),
        "events": events,
    }


def event_viewer(args: argparse.Namespace) -> int:
    data, _project_path, target = lane_project(args)
    ensure_lane_state(data, target)
    if args.lines < 1 or args.lines > 1000:
        raise SystemExit("--lines must be between 1 and 1000")
    if args.port < 0 or args.port > 65535:
        raise SystemExit("--port must be between 0 and 65535")
    if args.port_search_limit < 0 or args.port_search_limit > 100:
        raise SystemExit("--port-search-limit must be between 0 and 100")
    human, jsonl, _lock = observability_event_paths(data, target)
    expected_health = event_viewer_health_payload(data, target)

    class EventViewerHandler(BaseHTTPRequestHandler):
        def _host_allowed(self) -> bool:
            host_header = (self.headers.get("Host") or "").split(",", 1)[0].strip().lower()
            if not host_header:
                return False
            if host_header.startswith("["):
                host_name = host_header.split("]", 1)[0] + "]"
            else:
                host_name = host_header.rsplit(":", 1)[0] if ":" in host_header else host_header
            allowed = {"localhost", "127.0.0.1", "::1", "[::1]"}
            bind_host = str(args.host).strip().lower()
            if bind_host and bind_host not in {"0.0.0.0", "::"}:
                allowed.add(bind_host)
            return host_name in allowed

        def _send(self, status: int, content_type: str, body: bytes) -> None:
            self.send_response(status)
            self.send_header("Content-Type", content_type)
            self.send_header("Cache-Control", "no-store")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)

        def do_GET(self) -> None:  # noqa: N802 - stdlib hook name
            if not self._host_allowed():
                self._send(403, "text/plain; charset=utf-8", b"forbidden host\n")
                return
            parsed = urlparse(self.path)
            if parsed.path == "/":
                self._send(200, "text/html; charset=utf-8", EVENT_VIEWER_HTML.encode("utf-8"))
                return
            if parsed.path == "/api/health":
                self._send(200, "application/json; charset=utf-8", json.dumps(expected_health, sort_keys=True).encode("utf-8"))
                return
            if parsed.path == "/api/events":
                query = parse_qs(parsed.query)
                try:
                    limit = max(1, min(1000, int(query.get("limit", [str(args.lines)])[0])))
                except ValueError:
                    limit = args.lines
                since = query.get("since", [args.since])[0]
                try:
                    payload = event_viewer_payload(data, target, since, limit)
                    self._send(200, "application/json; charset=utf-8", json.dumps(payload, sort_keys=True).encode("utf-8"))
                except SystemExit as exc:
                    error = {"error": str(exc)}
                    self._send(400, "application/json; charset=utf-8", json.dumps(error).encode("utf-8"))
                return
            self._send(404, "text/plain; charset=utf-8", b"not found\n")

        def log_message(self, _format: str, *_args: object) -> None:
            return

    server = None
    starting_port = args.port
    max_attempts = 1 if starting_port == 0 else args.port_search_limit + 1
    for offset in range(max_attempts):
        candidate_port = 0 if starting_port == 0 else starting_port + offset
        if candidate_port > 65535:
            break
        try:
            server = ThreadingHTTPServer((args.host, candidate_port), EventViewerHandler)
            if offset:
                print(
                    f"event_viewer_port_busy: {event_viewer_url(args.host, starting_port)} occupied by another service; "
                    f"using {event_viewer_url(args.host, server.server_address[1])}",
                    flush=True,
                )
            break
        except OSError as exc:
            if exc.errno not in {errno.EADDRINUSE, errno.EACCES}:
                raise
            existing = probe_event_viewer(args.host, candidate_port)
            if existing and existing.get("event_jsonl") == str(jsonl):
                print(f"event_viewer_existing: same repo already running at {existing['url']}", flush=True)
                print(f"event_log: {human}", flush=True)
                print(f"event_jsonl: {jsonl}", flush=True)
                if args.open:
                    webbrowser.open(existing["url"])
                return 0
            if offset + 1 >= max_attempts:
                existing_kind = "Minervit viewer for another repo" if existing else "non-Minervit service"
                raise SystemExit(
                    f"event_viewer_port_busy: {event_viewer_url(args.host, candidate_port)} is occupied by a "
                    f"{existing_kind}; rerun with --port <free-port> or raise --port-search-limit."
                ) from exc
            continue
    if server is None:
        raise SystemExit("event_viewer_port_busy: no free port found")
    url = event_viewer_url(args.host, server.server_address[1])
    print(f"event_viewer_url: {url}", flush=True)
    print(f"event_log: {human}", flush=True)
    print(f"event_jsonl: {jsonl}", flush=True)
    print(
        "event_viewer_instruction: open the URL in a browser; rows refresh automatically and click through to JSON detail",
        flush=True,
    )
    if args.open:
        webbrowser.open(url)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\nevent_viewer: stopped")
    finally:
        server.server_close()
    return 0


def parse_duration_seconds(value: str) -> int:
    return util_module().parse_duration_seconds(value)


def parse_event_ts(value: str) -> datetime | None:
    return util_module().parse_event_ts(value)


def event_jsonl_read_paths(jsonl: Path, retained: int) -> list[Path]:
    paths: list[Path] = []
    for index in range(retained, 0, -1):
        rotated = jsonl.with_name(f"{jsonl.name}.{index}")
        if rotated.exists():
            paths.append(rotated)
    if jsonl.exists():
        paths.append(jsonl)
    return paths


def load_event_records(jsonl: Path, since_seconds: int, retained: int = 0) -> list[dict]:
    cutoff = datetime.now(timezone.utc).timestamp() - since_seconds
    records: list[dict] = []
    seen_lines: set[str] = set()
    for path in event_jsonl_read_paths(jsonl, retained):
        for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
            if line in seen_lines:
                continue
            seen_lines.add(line)
            try:
                record = json.loads(line)
            except json.JSONDecodeError:
                records.append({"schema": "invalid", "event": "invalid_json", "ts": utc_event_timestamp(), "plain": "invalid JSONL line", "next": "inspect event log"})
                continue
            ts = parse_event_ts(str(record.get("ts", "")))
            if ts and ts.timestamp() >= cutoff:
                records.append(record)
    return records


def event_base_name(event: str) -> str:
    for suffix in (EVENT_START_SUFFIX, "_start"):
        if event.endswith(suffix):
            return event[: -len(suffix)]
    return event


def event_boundary_family(event: str, severity: str = "") -> str | None:
    if event == "startup" or event.startswith("startup_"):
        return "startup"
    if event.startswith(("plan_review", "planning_review")):
        return "planning_review_gate"
    if event.startswith("preflight_"):
        return "preflight"
    if event in {"pr_queued", "pr_queue", "pr_merged"} or event.startswith(("pr_queue_", "merge_queue_")):
        return "pr_queue_merge"
    if event.startswith("context_rotation"):
        return "context_rotation"
    if event.startswith("goal_advance") or event.startswith("goal_transition"):
        return "goal_transition"
    if event.startswith("milestone_advance") or event.startswith("milestone_transition"):
        return "milestone_transition"
    if "rca" in event:
        return "rca"
    if "continuity" in event:
        return "continuity"
    if severity in {"block", "fail"} or "blocker" in event or event.endswith("_blocked"):
        return "blocker"
    return None


def event_family_enabled(required_events: set[str] | None, family: str | None) -> bool:
    effective_required = set(EVENT_REQUIRED_BOUNDARY_FAMILIES) if required_events is None else required_events
    if family is None:
        return required_events is None
    return family in effective_required


def event_is_terminal_for(base: str, event: str) -> bool:
    if not event.startswith(base):
        return False
    return any(event.endswith(suffix) for suffix in EVENT_TERMINAL_SUFFIXES)


def audit_event_records(records: list[dict], required_events: set[str] | None = None) -> list[str]:
    issues: list[str] = []
    effective_required = set(EVENT_REQUIRED_BOUNDARY_FAMILIES) if required_events is None else required_events
    unknown_required = sorted(effective_required - EVENT_REQUIRED_BOUNDARY_FAMILIES)
    for family in unknown_required:
        issues.append(f"unknown required boundary event family: {family}")
    for index, record in enumerate(records):
        event = str(record.get("event", ""))
        family = event_boundary_family(event, str(record.get("severity", "")))
        next_action = str(record.get("next", "")).strip().lower()
        if record.get("schema") != EVENT_LOG_SCHEMA:
            issues.append(f"invalid schema at event {index}: {event or 'unknown'}")
        if event_family_enabled(effective_required, "blocker") and str(record.get("severity")) in {"block", "fail"} and next_action in {"", "none", "n/a", "unknown"}:
            issues.append(f"{event}: blocker/failure event lacks concrete next action")
        if event.endswith(EVENT_START_SUFFIX) and event_family_enabled(effective_required, family):
            base = event_base_name(event)
            if not any(event_is_terminal_for(base, str(later.get("event", ""))) for later in records[index + 1 :]):
                issues.append(f"{event}: started event lacks a later terminal event")
        if (
            event_family_enabled(effective_required, "pr_queue_merge")
            and event in {"pr_queued", "pr_queue"}
            and not any(str(later.get("event", "")).startswith("milestone_advance") for later in records[index + 1 :])
        ):
            issues.append(f"{event}: PR queued without later milestone-advance event")
        if (
            event_family_enabled(effective_required, "rca")
            and event in {"rca_requested", "methodology_regression_rca_requested"}
            and not any("rca" in str(later.get("event", "")) and str(later.get("event", "")).endswith(("written", "published")) for later in records[index + 1 :])
        ):
            issues.append(f"{event}: RCA requested without later RCA artifact write/publish event")
        if event_family_enabled(effective_required, "continuity") and event in {"session_summary", "terminal_summary", "handoff_for_review", "workflow_complete"}:
            later_events = [str(later.get("event", "")) for later in records[index + 1 :]]
            if not any("continuity" in item and item.endswith(("written", "prepared", "refreshed")) for item in later_events):
                issues.append(f"{event}: terminal summary without later continuity event")
            if not any("session_journal" in item and item.endswith(("written", "published", "pending")) for item in later_events):
                issues.append(f"{event}: terminal summary without later session journal event")
        if event_family_enabled(effective_required, "startup") and event == "startup" and "stale" in f"{record.get('plain', '')} {record.get('next', '')}".lower():
            issues.append("startup: stale methodology evidence recorded")
    return issues


def event_audit(args: argparse.Namespace) -> int:
    data, _project_path, target = lane_project(args)
    _human, jsonl, _lock = observability_event_paths(data, target)
    records = load_event_records(
        jsonl,
        parse_duration_seconds(args.since),
        int(data["observabilityEvents"]["retainedRotations"]),
    )
    required_events = set(data["observabilityEvents"].get("requiredBoundaryEvents", []))
    issues = audit_event_records(records, required_events)
    print(f"event_audit_log: {jsonl}")
    print(f"event_audit_records: {len(records)}")
    print(f"event_audit_required_boundary_events: {', '.join(sorted(required_events)) or 'none'}")
    if issues:
        for issue in issues:
            print(f"event_audit_issue: {issue}")
        return 1 if args.strict else 0
    print("event_audit: clean")
    return 0


def event_rotate(args: argparse.Namespace) -> int:
    data, _project_path, target = lane_project(args)
    human, jsonl, lock = observability_event_paths(data, target)
    human.parent.mkdir(parents=True, exist_ok=True)
    with lock.open("a", encoding="utf-8") as lock_file, advisory_flock(lock_file):
        rotate_bytes = 0 if args.force else int(data["observabilityEvents"]["rotateBytes"])
        retained = int(data["observabilityEvents"]["retainedRotations"])
        rotate_event_file(human, rotate_bytes, retained)
        rotate_event_file(jsonl, rotate_bytes, retained)
    print(f"event_rotate_log: {human}")
    print(f"event_rotate_jsonl: {jsonl}")
    return 0


def usage_state_dir(data: dict) -> Path:
    configured = str(data["usageAccounting"]["stateDir"])
    expanded = os.path.expandvars(configured.replace("$HOME", str(Path.home())))
    return Path(expanded).expanduser()


def usage_accounting_paths(data: dict, target: Path) -> tuple[Path, Path, Path]:
    usage = data["usageAccounting"]
    directory = usage_state_dir(data) / event_repo_slug(data)
    jsonl = directory / usage["jsonlLog"]
    rollup = directory / usage["rollupJson"]
    lock = directory / ".usage.lock"
    return jsonl, rollup, lock


def print_usage_accounting_status(data: dict, target: Path) -> None:
    jsonl, rollup, _lock = usage_accounting_paths(data, target)
    enabled = str(data["usageAccounting"].get("enabled", True)).lower()
    print(f"usage_accounting: enabled={enabled}")
    print(f"usage_jsonl: {jsonl}")
    print(f"usage_rollup: {rollup}")
    print(f"usage_report: tautline usage-report --target {target} --since 7d --by product")


def usage_read_paths(jsonl: Path, retained: int) -> list[Path]:
    return event_jsonl_read_paths(jsonl, retained)


def usage_record_key(record: dict) -> str:
    existing = str(record.get("dedupe_key") or "").strip()
    if existing:
        return existing
    basis = "|".join(
        str(record.get(key, ""))
        for key in ["provider", "model", "session_id", "request_id", "ts", "activity"]
    )
    basis += "|" + json.dumps(record.get("tokens", {}), sort_keys=True)
    basis += "|" + str(record.get("cost_usd", ""))
    return hashlib.sha256(basis.encode("utf-8")).hexdigest()


def load_usage_records(jsonl: Path, since_seconds: int = 0, retained: int = 0) -> list[dict]:
    cutoff = datetime.now(timezone.utc).timestamp() - since_seconds if since_seconds > 0 else None
    records: list[dict] = []
    seen_keys: set[str] = set()
    for path in usage_read_paths(jsonl, retained):
        for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
            try:
                record = json.loads(line)
            except json.JSONDecodeError:
                continue
            if record.get("schema") != USAGE_RECORD_SCHEMA:
                continue
            key = usage_record_key(record)
            if key in seen_keys:
                continue
            seen_keys.add(key)
            ts = parse_event_ts(str(record.get("ts", "")))
            if cutoff is not None and ts and ts.timestamp() < cutoff:
                continue
            records.append(record)
    return records


def usage_tokens(record: dict) -> dict[str, int]:
    tokens = record.get("tokens") if isinstance(record.get("tokens"), dict) else {}
    return {
        "input": int(tokens.get("input", 0) or 0),
        "output": int(tokens.get("output", 0) or 0),
        "cache_creation_input": int(tokens.get("cache_creation_input", 0) or 0),
        "cache_read_input": int(tokens.get("cache_read_input", 0) or 0),
        "total": int(tokens.get("total", 0) or 0),
    }


def normalize_usage_field(data: dict, target: Path, value: str, field: str, allow_blank: bool = False) -> str:
    normalized = normalize_event_text(data, target, value)
    if allow_blank and not normalized:
        return ""
    errors = validate_event_value(normalized, field)
    if errors:
        raise SystemExit("; ".join(errors))
    return normalized


def usage_nonnegative_int(value: int | str | None, field: str) -> int:
    try:
        parsed = int(value or 0)
    except (TypeError, ValueError) as exc:
        raise SystemExit(f"{field} must be a non-negative integer") from exc
    if parsed < 0:
        raise SystemExit(f"{field} must be a non-negative integer")
    return parsed


def usage_float_or_none(value: str | None, field: str) -> float | None:
    if value is None or str(value).strip() == "":
        return None
    try:
        parsed = float(value)
    except ValueError as exc:
        raise SystemExit(f"{field} must be a non-negative decimal value") from exc
    if parsed < 0:
        raise SystemExit(f"{field} must be a non-negative decimal value")
    return parsed


def usage_refs_from_args(refs: list[str] | None, data: dict, target: Path) -> dict:
    return event_refs_from_args(refs, data, target)


def build_usage_payload(
    data: dict,
    target: Path,
    *,
    provider: str,
    model: str,
    activity: str,
    source: str,
    confidence: str,
    input_tokens: int = 0,
    output_tokens: int = 0,
    cache_creation_input_tokens: int = 0,
    cache_read_input_tokens: int = 0,
    total_tokens: int | None = None,
    cost_usd: float | None = None,
    goal: str | None = None,
    milestone: str | None = None,
    pr: str | None = None,
    session_id: str | None = None,
    request_id: str | None = None,
    refs: dict | None = None,
    timestamp: str | None = None,
    dedupe_key: str | None = None,
) -> dict:
    confidence = confidence.strip().lower()
    if confidence not in USAGE_CONFIDENCE_VALUES:
        raise SystemExit(f"confidence must be one of: {', '.join(sorted(USAGE_CONFIDENCE_VALUES))}")
    provider = slugify(normalize_usage_field(data, target, provider, "provider"), fallback="provider")
    activity = slugify(normalize_usage_field(data, target, activity, "activity"), fallback="activity")
    model = normalize_usage_field(data, target, model, "model")
    source = normalize_usage_field(data, target, source, "source")
    derived_goal, derived_milestone = current_goal_milestone_context(data, target)
    token_total = total_tokens
    if token_total is None:
        token_total = input_tokens + output_tokens + cache_creation_input_tokens + cache_read_input_tokens
    payload = {
        "schema": USAGE_RECORD_SCHEMA,
        "ts": timestamp or utc_event_timestamp(),
        "project": data.get("project"),
        "repo": data.get("repo"),
        "repo_slug": event_repo_slug(data),
        "lane": target.name,
        "branch": run_git(target, ["branch", "--show-current"]),
        "head": run_git(target, ["rev-parse", "--short", "HEAD"]),
        "goal": normalize_event_text(data, target, goal or derived_goal or ""),
        "milestone": normalize_event_text(data, target, milestone or derived_milestone or ""),
        "pr": normalize_event_text(data, target, pr or ""),
        "provider": provider,
        "model": model,
        "activity": activity,
        "source": source,
        "confidence": confidence,
        "tokens": {
            "input": input_tokens,
            "output": output_tokens,
            "cache_creation_input": cache_creation_input_tokens,
            "cache_read_input": cache_read_input_tokens,
            "total": token_total,
        },
        "cost_usd": cost_usd,
        "session_id": normalize_event_text(data, target, session_id or ""),
        "request_id": normalize_event_text(data, target, request_id or ""),
        "refs": refs or {},
        "methodology": {
            "plugin_version": plugin_version(),
            "methodology_commit": running_methodology_commit(short=True),
        },
    }
    payload["dedupe_key"] = dedupe_key or usage_record_key(payload)
    encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"))
    if len(encoded) > USAGE_MAX_JSON_CHARS:
        raise SystemExit(f"usage payload exceeds {USAGE_MAX_JSON_CHARS} bytes")
    return payload


def usage_rollup(records: list[dict]) -> dict:
    totals = {
        "records": 0,
        "input_tokens": 0,
        "output_tokens": 0,
        "cache_creation_input_tokens": 0,
        "cache_read_input_tokens": 0,
        "total_tokens": 0,
        "known_cost_usd": 0.0,
        "unknown_cost_records": 0,
    }
    confidence: dict[str, int] = {}
    for record in records:
        tokens = usage_tokens(record)
        totals["records"] += 1
        totals["input_tokens"] += tokens["input"]
        totals["output_tokens"] += tokens["output"]
        totals["cache_creation_input_tokens"] += tokens["cache_creation_input"]
        totals["cache_read_input_tokens"] += tokens["cache_read_input"]
        totals["total_tokens"] += tokens["total"]
        cost = record.get("cost_usd")
        if isinstance(cost, (int, float)):
            totals["known_cost_usd"] += float(cost)
        else:
            totals["unknown_cost_records"] += 1
        confidence[str(record.get("confidence") or "unknown")] = confidence.get(str(record.get("confidence") or "unknown"), 0) + 1
    return {
        "schema": "minervit-usage-rollup/v1",
        "generated_at": utc_event_timestamp(),
        "totals": totals,
        "confidence": confidence,
    }


def write_usage_rollup(data: dict, target: Path) -> Path:
    jsonl, rollup, _lock = usage_accounting_paths(data, target)
    records = load_usage_records(jsonl, 0, int(data["usageAccounting"]["retainedRotations"]))
    rollup.parent.mkdir(parents=True, exist_ok=True)
    rollup.write_text(json.dumps(usage_rollup(records), indent=2, sort_keys=True) + "\n", encoding="utf-8")
    return rollup


def append_usage_payload(data: dict, target: Path, payloads: list[dict]) -> tuple[Path, Path, int]:
    if not data["usageAccounting"].get("enabled", True):
        raise SystemExit("usage accounting is disabled by adapter")
    jsonl, rollup, lock = usage_accounting_paths(data, target)
    jsonl.parent.mkdir(parents=True, exist_ok=True)
    appended = 0
    with lock.open("a", encoding="utf-8") as lock_file, advisory_flock(lock_file):
        rotate_bytes = int(data["usageAccounting"]["rotateBytes"])
        retained = int(data["usageAccounting"]["retainedRotations"])
        rotate_event_file(jsonl, rotate_bytes, retained)
        existing = {usage_record_key(record) for record in load_usage_records(jsonl, 0, retained)}
        with jsonl.open("a", encoding="utf-8") as handle:
            for payload in payloads:
                key = usage_record_key(payload)
                if key in existing:
                    continue
                handle.write(json.dumps(payload, sort_keys=True) + "\n")
                existing.add(key)
                appended += 1
        write_usage_rollup(data, target)
    return jsonl, rollup, appended


def usage_log_path(args: argparse.Namespace) -> int:
    data, _project_path, target = lane_project(args)
    jsonl, rollup, _lock = usage_accounting_paths(data, target)
    print(f"usage_jsonl: {jsonl}")
    print(f"usage_rollup: {rollup}")
    print(f"usage_report: tautline usage-report --target {target} --since 7d --by product")
    return 0


def usage_record(args: argparse.Namespace) -> int:
    data, _project_path, target = lane_project(args)
    if not data["usageAccounting"].get("enabled", True):
        print("usage_accounting_skipped: disabled")
        return 0
    payload = build_usage_payload(
        data,
        target,
        provider=args.provider,
        model=args.model,
        activity=args.activity,
        source=args.source,
        confidence=args.confidence,
        input_tokens=usage_nonnegative_int(args.input_tokens, "input tokens"),
        output_tokens=usage_nonnegative_int(args.output_tokens, "output tokens"),
        cache_creation_input_tokens=usage_nonnegative_int(args.cache_creation_input_tokens, "cache creation input tokens"),
        cache_read_input_tokens=usage_nonnegative_int(args.cache_read_input_tokens, "cache read input tokens"),
        total_tokens=usage_nonnegative_int(args.total_tokens, "total tokens") if args.total_tokens is not None else None,
        cost_usd=usage_float_or_none(args.cost_usd, "cost USD"),
        goal=args.goal,
        milestone=args.milestone,
        pr=args.pr,
        session_id=args.session_id,
        request_id=args.request_id,
        refs=usage_refs_from_args(args.ref, data, target),
    )
    jsonl, rollup, appended = append_usage_payload(data, target, [payload])
    print(f"usage_recorded: {appended}")
    print(f"usage_jsonl: {jsonl}")
    print(f"usage_rollup: {rollup}")
    return 0


def claude_usage_records_from_path(data: dict, target: Path, path: Path, since_seconds: int, activity: str) -> list[dict]:
    cutoff = datetime.now(timezone.utc).timestamp() - since_seconds if since_seconds > 0 else None
    records: list[dict] = []
    for line_number, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), start=1):
        try:
            item = json.loads(line)
        except json.JSONDecodeError:
            continue
        message = item.get("message") if isinstance(item.get("message"), dict) else {}
        usage = message.get("usage") if isinstance(message.get("usage"), dict) else {}
        if not usage:
            continue
        timestamp = str(item.get("timestamp") or item.get("ts") or utc_event_timestamp())
        ts = parse_event_ts(timestamp)
        if cutoff is not None and ts and ts.timestamp() < cutoff:
            continue
        model = str(message.get("model") or item.get("model") or "unknown")
        session_id = str(item.get("sessionId") or item.get("session_id") or path.stem)
        request_id = str(item.get("requestId") or item.get("request_id") or item.get("uuid") or f"{path.name}:{line_number}")
        input_tokens = usage_nonnegative_int(usage.get("input_tokens"), "input tokens")
        output_tokens = usage_nonnegative_int(usage.get("output_tokens"), "output tokens")
        cache_creation = usage_nonnegative_int(usage.get("cache_creation_input_tokens"), "cache creation input tokens")
        cache_read = usage_nonnegative_int(usage.get("cache_read_input_tokens"), "cache read input tokens")
        key_basis = f"claude-jsonl|{path}|{session_id}|{request_id}|{timestamp}|{model}"
        records.append(
            build_usage_payload(
                data,
                target,
                provider="claude",
                model=model,
                activity=activity,
                source="claude-jsonl",
                confidence="exact",
                input_tokens=input_tokens,
                output_tokens=output_tokens,
                cache_creation_input_tokens=cache_creation,
                cache_read_input_tokens=cache_read,
                session_id=session_id,
                request_id=request_id,
                timestamp=timestamp,
                dedupe_key=hashlib.sha256(key_basis.encode("utf-8")).hexdigest(),
                refs={"source_file": normalize_event_text(data, target, str(path))},
            )
        )
    return records


def usage_import_claude(args: argparse.Namespace) -> int:
    data, _project_path, target = lane_project(args)
    if not data["usageAccounting"].get("enabled", True):
        print("usage_accounting_skipped: disabled")
        return 0
    paths = [path.expanduser().resolve() for path in args.path]
    payloads: list[dict] = []
    since_seconds = parse_duration_seconds(args.since)
    for path in paths:
        if not path.exists():
            raise SystemExit(f"usage import path missing: {path}")
        payloads.extend(claude_usage_records_from_path(data, target, path, since_seconds, args.activity))
    jsonl, _rollup, _lock = usage_accounting_paths(data, target)
    existing_keys = {usage_record_key(record) for record in load_usage_records(jsonl, 0, int(data["usageAccounting"]["retainedRotations"]))}
    seen_keys = set(existing_keys)
    new_payloads = []
    for payload in payloads:
        key = usage_record_key(payload)
        if key in seen_keys:
            continue
        seen_keys.add(key)
        new_payloads.append(payload)
    skipped = len(payloads) - len(new_payloads)
    jsonl, rollup, appended = append_usage_payload(data, target, new_payloads)
    print(f"usage_imported: {appended}")
    print(f"usage_skipped_duplicate: {skipped}")
    print(f"usage_jsonl: {jsonl}")
    print(f"usage_rollup: {rollup}")
    return 0


def usage_report_key(record: dict, by: str) -> str:
    if by == "product":
        return str(record.get("project") or record.get("repo_slug") or "unknown")
    if by == "day":
        return str(record.get("ts", ""))[:10] or "unknown"
    return str(record.get(by) or "unknown")


def usage_report(args: argparse.Namespace) -> int:
    data, _project_path, target = lane_project(args)
    jsonl, rollup, _lock = usage_accounting_paths(data, target)
    records = load_usage_records(
        jsonl,
        parse_duration_seconds(args.since),
        int(data["usageAccounting"]["retainedRotations"]),
    )
    grouped: dict[str, list[dict]] = {}
    for record in records:
        grouped.setdefault(usage_report_key(record, args.by), []).append(record)
    total = usage_rollup(records)["totals"]
    print(f"usage_report_log: {jsonl}")
    print(f"usage_report_rollup: {rollup}")
    print(f"usage_report_since: {args.since}")
    print(f"usage_report_by: {args.by}")
    print(f"usage_report_records: {total['records']}")
    print(f"usage_report_total_tokens: {total['total_tokens']}")
    print(f"usage_report_known_cost_usd: {total['known_cost_usd']:.6f}")
    print(f"usage_report_unknown_cost_records: {total['unknown_cost_records']}")
    for key in sorted(grouped):
        subtotal = usage_rollup(grouped[key])["totals"]
        print(
            f"usage_report_item: {key} records={subtotal['records']} "
            f"total_tokens={subtotal['total_tokens']} input={subtotal['input_tokens']} "
            f"output={subtotal['output_tokens']} cache_creation={subtotal['cache_creation_input_tokens']} "
            f"cache_read={subtotal['cache_read_input_tokens']} known_cost_usd={subtotal['known_cost_usd']:.6f} "
            f"unknown_cost_records={subtotal['unknown_cost_records']}"
        )
    return 0


def event_rotate_usage_files(data: dict, target: Path, force: bool = False) -> tuple[Path, Path]:
    jsonl, rollup, lock = usage_accounting_paths(data, target)
    jsonl.parent.mkdir(parents=True, exist_ok=True)
    with lock.open("a", encoding="utf-8") as lock_file, advisory_flock(lock_file):
        rotate_bytes = 0 if force else int(data["usageAccounting"]["rotateBytes"])
        retained = int(data["usageAccounting"]["retainedRotations"])
        rotate_event_file(jsonl, rotate_bytes, retained)
        write_usage_rollup(data, target)
    return jsonl, rollup


def usage_rotate(args: argparse.Namespace) -> int:
    data, _project_path, target = lane_project(args)
    jsonl, rollup = event_rotate_usage_files(data, target, args.force)
    print(f"usage_rotate_jsonl: {jsonl}")
    print(f"usage_rotate_rollup: {rollup}")
    return 0


def session_journal_default_content() -> str:
    return """## Starting Context
- TODO

## Work Delivered Or Advanced
- TODO

## Planning And Review Gates
- TODO

## Human Interruptions Or Questions
- None.

## Delays, Waits, Or Autonomy Breakdowns
- None.

## Validation And PR State
- TODO

## Continuity Outcome
- TODO

## Methodology Improvement Signals
- None.
"""


def graphify_session_journal_runtime_lines(data: dict, target: Path) -> list[str]:
    status = graphify_status_record(data, target)
    freshness = status["freshness"]
    latest_path = freshness.get("latestGraphPath")
    latest_value = (
        f"{format_mtime(freshness['latestGraphMtime'])} {path_display(target, latest_path)}"
        if latest_path
        else "none"
    )
    newer_files = freshness.get("newerFiles") or []
    issues = status.get("issues", [])
    return [
        f"- graphify_enabled: {str(status['enabled']).lower()}",
        f"- graphify_prefer_when_present: {str(status['preferWhenPresent']).lower()}",
        f"- graphify_output: {status['outputDir']}",
        f"- graphify_freshness: {freshness['state']}",
        f"- graphify_freshness_enforcement: {freshness['enforcement']}",
        f"- graphify_latest: {latest_value}",
        f"- graphify_newer_files: {compact_path_list(newer_files[:10], target) if newer_files else 'none'}",
        f"- graphify_status_gate: {'pass' if not issues else 'fail'}",
        f"- graphify_issues: {'; '.join(issues) if issues else 'none'}",
    ]


def usage_session_journal_runtime_lines(data: dict, target: Path) -> list[str]:
    jsonl, rollup, _lock = usage_accounting_paths(data, target)
    jsonl_display = f"<usage_state>/{event_repo_slug(data)}/{data['usageAccounting']['jsonlLog']}"
    rollup_display = f"<usage_state>/{event_repo_slug(data)}/{data['usageAccounting']['rollupJson']}"
    if not data["usageAccounting"].get("enabled", True):
        return [
            "- usage_accounting_enabled: false",
            f"- usage_jsonl: {jsonl_display}",
            f"- usage_rollup: {rollup_display}",
        ]
    records = load_usage_records(jsonl, parse_duration_seconds("7d"), int(data["usageAccounting"]["retainedRotations"]))
    totals = usage_rollup(records)["totals"]
    confidence = usage_rollup(records)["confidence"]
    confidence_summary = ", ".join(f"{key}={confidence[key]}" for key in sorted(confidence)) or "none"
    return [
        "- usage_accounting_enabled: true",
        f"- usage_jsonl: {jsonl_display}",
        f"- usage_rollup: {rollup_display}",
        f"- usage_7d_records: {totals['records']}",
        f"- usage_7d_total_tokens: {totals['total_tokens']}",
        f"- usage_7d_known_cost_usd: {totals['known_cost_usd']:.6f}",
        f"- usage_7d_unknown_cost_records: {totals['unknown_cost_records']}",
        f"- usage_7d_confidence: {confidence_summary}",
    ]


def session_journal_runtime(data: dict, project_path: Path, target: Path) -> str:
    drift = adapter_drift(data, project_path, target)
    locked = read_lock(data, target)
    lock_state = "invalid" if locked and locked.get("invalid") else "locked" if locked else "unlocked"
    branch = run_git(target, ["branch", "--show-current"])
    head = run_git(target, ["rev-parse", "--short", "HEAD"])
    status = run_git(target, ["status", "--short", "--branch"])
    last_status_line = status.splitlines()[0] if status and status != "unavailable" else status
    session_journal = data["sessionJournal"]
    return (
        "## Session Runtime\n"
        f"- schema: {SESSION_JOURNAL_SCHEMA}\n"
        f"- generated_at: {now_iso()}\n"
        "- evidence_scope: evidence only; not process authority, product docs, continuity handoff, or normal startup context\n"
        f"- project: {data['project']}\n"
        f"- repo: {data['repo']}\n"
        f"- lane: {target.name}\n"
        f"- branch: {branch}\n"
        f"- head: {head}\n"
        f"- git_status: {last_status_line}\n"
        f"- plugin_version: {plugin_version()}\n"
        f"- methodology_commit: {running_methodology_commit()}\n"
        f"- methodology_commit_short: {running_methodology_commit(short=True)}\n"
        f"- remote_status: {remote_methodology_status(True)}\n"
        f"- adapter_drift: {'clean' if not drift else ', '.join(drift)}\n"
        f"- lock: {lock_state}\n"
        f"- continuity_path: {data['continuity']['path']}\n"
        f"- execution_packet: {data['laneState']['executionPacket']}\n"
        f"- session_journal_cadence: {session_journal['cadence']}\n"
        f"- session_journal_archive_branch: {session_journal['branch']}\n"
        + "\n".join(graphify_session_journal_runtime_lines(data, target))
        + "\n"
        + "\n".join(usage_session_journal_runtime_lines(data, target))
        + "\n"
        + f"- active_prs: summarize current PR state in `## Validation And PR State`; the journal wrapper does not run project gates.\n"
        + "\n"
    )


def prepare_session_journal(args: argparse.Namespace) -> int:
    data, project_path, target = lane_project(args)
    ensure_lane_state(data, target)
    if not data["sessionJournal"].get("enabled", True):
        print("session_journal: disabled by adapter")
        return 0
    content = sys.stdin.read().strip() if args.stdin else ""
    if args.content_file:
        content = args.content_file.read_text(encoding="utf-8").strip()
    if not content:
        content = session_journal_default_content().strip()
    if "## Session Runtime" in content:
        rendered = content.rstrip() + "\n"
    else:
        rendered = session_journal_runtime(data, project_path, target) + content.rstrip() + "\n"
    stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    journal_dir = session_journal_local_dir(data, target)
    # Local-only guarantee (0.9.0): a session journal is a product narrative that must never reach a
    # remote. Refuse to write it to a non-git-ignored path -- exactly as the instrumentation preview
    # does -- so a later broad `git add -A` can never stage it onto the product remote. Only a
    # misconfigured lane (laneState.gitIgnore or sessionJournal.gitIgnoreLocal turned off, or the runs
    # dir removed from .gitignore) reaches this refusal.
    if not path_is_git_ignored(target, journal_dir):
        runs_dir = data["laneState"]["runsDir"]
        print(
            f"session_journal: refusing to write under {path_display(target, journal_dir)}: not "
            f"git-ignored in this worktree. Session journals are LOCAL evidence only and must never "
            f"be committable. Add {runs_dir}/ to .gitignore (or restore laneState.gitIgnore: true / "
            "sessionJournal.gitIgnoreLocal: true in the adapter) before preparing journals.",
            file=sys.stderr,
        )
        return 1
    journal_dir.mkdir(parents=True, exist_ok=True)
    journal_path = unique_destination(journal_dir, f"{stamp}-session-journal.md")
    journal_path.write_text(rendered, encoding="utf-8")
    errors = validate_session_journal_file(journal_path, int(data["sessionJournal"]["maxBytes"]))
    print(f"session_journal: wrote {journal_path}")
    if errors:
        for error in errors:
            print(f"session_journal_validation_error: {error}", file=sys.stderr)
        return 1
    print(f"session_journal_valid: {journal_path}")
    try_write_event(
        data,
        target,
        event="session_journal_written",
        severity="ok",
        plain=f"Session journal was written and validated at {path_display(target, journal_path)}.",
        next_action="Local evidence only (narrative publication is disabled as of 0.9.0); it stays under the gitignored runs dir. To contribute sanitized upstream signal, run publish-instrumentation-record.",
    )
    return 0


def execution_queue_items(packet_text: str) -> list[str]:
    items: list[str] = []
    in_queue = False
    queue_headings = {
        "queue",
        "ordered tactical queue",
        "tactical queue",
        "execution queue",
    }
    for line in packet_text.splitlines():
        stripped = line.strip()
        if stripped.startswith("#"):
            heading = stripped.lstrip("#").strip().lower()
            in_queue = heading in queue_headings
            continue
        if not in_queue:
            continue
        if line[:1].isspace():
            continue
        if re.match(r"^[-*]\s+\[[xX]\]\s+", stripped):
            continue
        match = re.match(r"^[-*]\s+(?:\[\s\]\s*)?(.+?)\s*$", stripped)
        if match:
            items.append(match.group(1))
    return items


GOAL_RUN_SCHEMA = "minervit-goal-run/v1"
GOAL_TERMINAL_STATUSES = {"complete", "deferred"}
GOAL_ACTIVE_STATUSES = {"pending", "in_progress"}
BLOCKER_SCHEMA = "minervit-blocker/v1"
BLOCKER_PATH = ".ai-work/BLOCKER.json"
BLOCKER_FRESH_SECONDS = 30 * 60
BLOCKER_KINDS = {
    "credentials",
    "external-access",
    "approval-needed",
    "failing-gate-no-safe-fix",
    "scope-change",
    "operator-stop-request",
    "no-safe-parallel-work",
}
NO_SAFE_PARALLEL_WORK_CHECKS = {
    "diff review",
    "allowed validation",
    "docs/evidence updates",
    "next queue item",
    "monitor follow-up",
}
GOAL_ADVANCE_EVENTS = {
    "startup",
    "milestone-started",
    "milestone-complete",
    "milestone-deferred",
    "milestone-blocked",
    "goal-complete",
}


def blocker_record_path(target: Path) -> Path:
    return guards_module().blocker_record_path(target)


def blocker_path_display(target: Path, path: Path) -> str:
    try:
        return str(path.relative_to(target))
    except ValueError:
        return str(path)


def write_blocker_record_atomic(path: Path, content: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_name(f".{path.name}.tmp-{os.getpid()}")
    tmp.write_text(content, encoding="utf-8")
    os.replace(tmp, path)


def goal_run_path_for_target(target: Path) -> Path:
    try:
        data = json.loads(adapter_marker_path(target).read_text(encoding="utf-8"))
        lane_state = data.get("laneState", {}) if isinstance(data, dict) else {}
        if isinstance(lane_state, dict):
            goal_run_raw = lane_state.get("goalRun", ".ai-work/GOAL_RUN.json")
        else:
            goal_run_raw = ".ai-work/GOAL_RUN.json"
    except (OSError, UnicodeError, json.JSONDecodeError):
        goal_run_raw = ".ai-work/GOAL_RUN.json"
    path = Path(str(goal_run_raw)).expanduser()
    if not path.is_absolute():
        path = target / path
    return path


def active_goal_id_for_target(target: Path) -> str | None:
    path = goal_run_path_for_target(target)
    try:
        run = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, UnicodeError, json.JSONDecodeError):
        return None
    if not isinstance(run, dict):
        return None
    if run.get("schema") != GOAL_RUN_SCHEMA:
        return None
    if str(run.get("status") or "").lower() in GOAL_TERMINAL_STATUSES:
        return None
    goal_id = str(run.get("goalId") or "").strip()
    return goal_id or None


def parse_blocker_checked(value: str) -> list[str]:
    return guards_module().parse_blocker_checked(value)


def blocker_declare_error(kind: str, reason: str, checked: list[str]) -> str | None:
    return guards_module().blocker_declare_error(kind, reason, checked)


def blocker_declare_record(
    *,
    kind: str,
    reason: str,
    artifact: str | None,
    goal_id: str | None,
    checked: list[str],
    timestamp: str | None = None,
) -> dict:
    return guards_module().blocker_declare_record(
        kind=kind,
        reason=reason,
        artifact=artifact,
        goal_id=goal_id,
        checked=checked,
        timestamp=timestamp,
    )


def blocker_freshness(target: Path, record: dict, *, now: float | None = None) -> tuple[bool, str]:
    return guards_module().blocker_freshness(
        target,
        record,
        active_goal_id=active_goal_id_for_target(target),
        now=now,
    )


def read_blocker_record(target: Path) -> tuple[dict | None, str | None]:
    return guards_module().read_blocker_record(target)


def blocker_status_result(target: Path) -> tuple[int, list[str]]:
    record, error = read_blocker_record(target)
    if record is None:
        lines = ["blocker_status: missing"]
        if error:
            lines.append(f"stale_reason: {error}")
        return 1, lines
    return guards_module().blocker_status_result(target, active_goal_id=active_goal_id_for_target(target))


def guard_check_blocker_state_result(target: Path) -> tuple[int, list[str], list[str]]:
    record, error = read_blocker_record(target)
    if record is None:
        if error == "missing":
            return 0, ["guard_check_blocker: none"], []
        return (
            1,
            [],
            [
                f"guard_check_blocker_issue: {error}",
                "guard_check: blocked - blocker record is unreadable; clear or redeclare it before push",
            ],
        )
    return guards_module().guard_check_blocker_state_result(
        target,
        active_goal_id=active_goal_id_for_target(target),
    )


def blocker_declare(args: argparse.Namespace) -> int:
    target = args.target.expanduser().resolve(strict=False)
    kind = str(args.kind or "").strip()
    reason = str(args.reason or "").strip()
    checked = parse_blocker_checked(getattr(args, "checked", "") or "")
    error = blocker_declare_error(kind, reason, checked)
    if error:
        print(error, file=sys.stderr)
        return 1
    artifact = str(args.artifact) if getattr(args, "artifact", None) else None
    record = blocker_declare_record(
        kind=kind,
        reason=reason,
        artifact=artifact,
        goal_id=active_goal_id_for_target(target),
        checked=checked,
        timestamp=utc_event_timestamp(),
    )
    path = blocker_record_path(target)
    write_blocker_record_atomic(path, json.dumps(record, indent=2, sort_keys=True) + "\n")
    print(f"blocker_declared: {kind}")
    print(f"blocker_path: {blocker_path_display(target, path)}")
    print(f"goal_id: {record['goal_id'] or 'none'}")
    return 0


def blocker_status(args: argparse.Namespace) -> int:
    target = args.target.expanduser().resolve(strict=False)
    rc, lines = blocker_status_result(target)
    print("\n".join(lines))
    return rc


def blocker_clear(args: argparse.Namespace) -> int:
    target = args.target.expanduser().resolve(strict=False)
    path = blocker_record_path(target)
    try:
        path.unlink()
        print(f"blocker_cleared: {blocker_path_display(target, path)}")
    except FileNotFoundError:
        print("blocker_cleared: missing")
    return 0


def guard_check_blocker_state(target: Path) -> int:
    rc, stdout_lines, stderr_lines = guard_check_blocker_state_result(target)
    if stdout_lines:
        print("\n".join(stdout_lines))
    if stderr_lines:
        print("\n".join(stderr_lines), file=sys.stderr)
    return rc


def guard_check_call(label: str, func, args: argparse.Namespace) -> int:
    print(f"guard_check_running: {label}")
    result = func(args)
    rc = int(result or 0)
    if rc:
        print(f"guard_check_failed: {label}", file=sys.stderr)
    return rc


def guard_check(args: argparse.Namespace) -> int:
    target = args.target.expanduser().resolve(strict=False)
    boundary = str(args.boundary or "").strip()
    print(f"guard_check_boundary: {boundary}")

    blocker_rc = guard_check_blocker_state(target)
    if blocker_rc:
        return blocker_rc

    if boundary != "prepush":
        print("guard_check: ok")
        return 0

    project = getattr(args, "project", None)
    checks = [
        (
            "review evidence",
            review_evidence_check,
            argparse.Namespace(project=project, target=target, strict=True, scope_strict=False, base=None),
        ),
        (
            "board currency",
            backlog_provider_board_check,
            argparse.Namespace(project=project, target=target, strict=False),
        ),
        (
            "ci test gate",
            ci_health_check,
            argparse.Namespace(project=project, target=target, strict=False),
        ),
    ]
    for label, func, check_args in checks:
        rc = guard_check_call(label, func, check_args)
        if rc:
            return rc
    print("guard_check: ok")
    return 0


def goal_run_path(data: dict, target: Path) -> Path:
    return paths_module().goal_run_path(data, target)


def goal_source_root(data: dict, target: Path) -> Path:
    return configured_path(target, data["goalArtifacts"]["sourceOfTruth"])


def goal_plan_bullets(plan_text: str) -> list[str]:
    items: list[str] = []
    in_milestones = False
    for line in plan_text.splitlines():
        stripped = line.strip()
        if stripped.startswith("#"):
            heading = stripped.lstrip("#").strip().lower()
            in_milestones = heading in {"milestones", "ordered milestones", "milestone queue"}
            continue
        if not in_milestones:
            continue
        if line[:1].isspace():
            continue
        if re.match(r"^[-*]\s+\[[xX]\]\s+", stripped):
            continue
        match = re.match(r"^[-*]\s+(?:\[\s\]\s*)?(.+?)\s*$", stripped)
        if match:
            items.append(match.group(1))
    return items


def goal_operator_dependency_entries(goal_path: Path) -> list[str]:
    text = goal_path.read_text(encoding="utf-8")
    entries: list[str] = []
    in_relevant_section = False
    relevant_headings = {
        "dependencies",
        "risks",
        "risk",
        "true blockers",
        "true-blocker criteria",
        "blockers",
        "open decisions",
    }
    dependency_markers = re.compile(
        r"\b(?:operator(?:[-\s](?:provided|input|dependency|approval))?|human(?:[-\s](?:operator|input|approval))?|approval|credential|credentials|secret|secrets|manual|external access|true blocker|blocked by operator|operator-provided)\b",
        re.IGNORECASE,
    )
    for raw_line in text.splitlines():
        stripped = raw_line.strip()
        if stripped.startswith("#"):
            heading = stripped.lstrip("#").strip().lower()
            in_relevant_section = heading in relevant_headings or any(
                marker in heading for marker in ["risk", "dependenc", "blocker", "open decision"]
            )
            continue
        if not in_relevant_section or not stripped:
            continue
        if dependency_markers.search(stripped):
            cleaned = re.sub(r"^[-*]\s+(?:\[[ xX]\]\s*)?", "", stripped)
            entries.append(re.sub(r"\s+", " ", cleaned).strip("| "))
    return list(dict.fromkeys(entries))


def goal_dependency_matches_milestone(dependency: str, milestone: dict) -> bool:
    text = dependency.lower()
    index = milestone.get("index")
    if index is not None and re.search(rf"\b(?:m|milestone\s*){re.escape(str(index))}\b", text):
        return True
    title = str(milestone.get("title", "")).strip().lower()
    if title and title in text:
        return True
    title_tokens = [token for token in re.split(r"[^a-z0-9]+", title) if len(token) >= 4]
    if len(title_tokens) >= 2 and sum(1 for token in title_tokens if token in text) >= 2:
        return True
    return False


def goal_dependencies_for_milestone(run: dict, milestone: dict) -> list[str]:
    dependencies = run.get("knownOperatorDependencies") or []
    return [
        str(dependency)
        for dependency in dependencies
        if goal_dependency_matches_milestone(str(dependency), milestone)
    ]


def goal_instruction_with_dependencies(base: str, run: dict, milestone: dict) -> str:
    dependencies = goal_dependencies_for_milestone(run, milestone)
    if not dependencies:
        return base
    shown = dependencies[:3]
    omitted = len(dependencies) - len(shown)
    dependency_text = "; ".join(shown) + (f"; +{omitted} more" if omitted > 0 else "")
    return (
        f"{base}. Known operator-input dependency before plan finalization: {dependency_text}. "
        "If unsatisfied, defer with `goal-advance --event milestone-deferred`; block only when the dependency "
        "is confirmed unavailable or a hard true blocker before spending plan-review rounds."
    )


def goal_plan_is_multi_session(goal_path: Path) -> bool:
    text = goal_path.read_text(encoding="utf-8", errors="replace")
    return bool(
        re.search(
            r"\b(?:multi[-\s]?session|multiple sessions|multi[-\s]?day|overnight|more than one session)\b",
            text,
            re.IGNORECASE,
        )
    )


def resolve_goal_plan_path(data: dict, target: Path, goal_arg: Path) -> Path:
    goal_path = goal_arg.expanduser()
    if not goal_path.is_absolute():
        goal_path = target / goal_path
    goal_path = goal_path.resolve()
    if not goal_path.exists():
        raise SystemExit(f"source-of-truth goal plan missing: {goal_path}")
    source_root = goal_source_root(data, target).resolve()
    try:
        goal_path.relative_to(source_root)
    except ValueError as exc:
        raise SystemExit(f"goal plan is outside goal source of truth: {goal_path}") from exc
    return goal_path


def goal_milestones_from_plan(goal_path: Path) -> list[str]:
    text = goal_path.read_text(encoding="utf-8")
    milestones = goal_plan_bullets(text)
    if not milestones:
        raise SystemExit(
            "goal plan has no ordered milestones; add a `## Milestones` section before starting the goal run"
        )
    return milestones


def goal_plan_title(path: Path) -> str:
    try:
        text = path.read_text(encoding="utf-8", errors="replace")
    except OSError:
        return path.stem
    for line in text.splitlines():
        match = re.match(r"^#\s+(.+?)\s*$", line)
        if match:
            return re.sub(r"\s+", " ", match.group(1)).strip()
    return path.stem.replace("-", " ").strip() or path.stem


def compact_one_line(text: str, limit: int = 240) -> str:
    cleaned = re.sub(r"\s+", " ", text or "").strip(" -*\t|")
    cleaned = re.sub(r"^\[[ xX]\]\s*", "", cleaned).strip()
    if len(cleaned) <= limit:
        return cleaned
    return cleaned[: max(0, limit - 1)].rstrip(" .,;:-") + "..."


def goal_plan_short_description(path: Path) -> str:
    title = goal_plan_title(path)
    try:
        text = path.read_text(encoding="utf-8", errors="replace")
    except OSError:
        return compact_one_line(title)
    preferred = {
        "summary",
        "desired outcome",
        "outcome",
        "goal",
        "goal summary",
        "why it matters",
        "benefit",
        "business benefit",
        "user benefit",
        "operator benefit",
    }
    fallback: list[str] = []
    current_heading = ""
    in_fence = False
    for raw_line in text.splitlines():
        line = raw_line.strip()
        if line.startswith("```"):
            in_fence = not in_fence
            continue
        if in_fence or not line or line.startswith("<!--"):
            continue
        if line.startswith("#"):
            current_heading = line.lstrip("#").strip().lower()
            continue
        if re.match(r"^[-*]\s+\[[ xX]\]\s+", line):
            continue
        cleaned = re.sub(r"^[-*]\s+", "", line).strip()
        if not cleaned:
            continue
        if current_heading in preferred:
            return compact_one_line(cleaned)
        if not fallback:
            fallback.append(cleaned)
    return compact_one_line(fallback[0] if fallback else title)


def goal_candidate_claude_prompt(
    data: dict,
    target: Path,
    *,
    title: str,
    description: str,
    status: str,
    goal_path: Path | None = None,
    item_ref: str | None = None,
) -> str:
    target_arg = shlex.quote(str(target))
    if goal_path is not None:
        plan = path_display(target, goal_path)
        goal_id = slugify(goal_path.stem, "goal")
        if status == "execution-ready":
            return (
                f"/goal Complete goal `{goal_id}` from `{plan}`: {description}. Run `tautline "
                f"goal-start --target {target_arg} --goal {shlex.quote(plan)}`, then `tautline "
                f"goal-condition --target {target_arg}`, follow the generated condition, and continue tactical "
                "PR planning and implementation until the goal is complete."
            )
        return (
            f"/goal Make goal `{goal_id}` execution-ready and complete it from `{plan}`: {description}. Run the "
            "required source-of-truth goal review/precheck first, then start the goal ledger, generate the "
            "goal condition, and continue tactical PR planning and implementation until the goal is complete."
        )
    provider_ref = item_ref or title
    if provider_item_completion_enabled(data):
        return (
            f"/goal Complete tracked work item `{slugify(title, 'item')}` from backlog provider "
            f"item `{provider_ref}`: {description}. Sync it into a repo source-of-truth execution plan, run required "
            "review/precheck, keep the GitHub issue and board status current, break large work into milestones when useful, "
            "and continue tactical PR planning and implementation until the issue's acceptance criteria are complete."
        )
    return (
        f"/goal Sync, review, and complete next Minervit goal `{slugify(title, 'goal')}` from backlog provider "
        f"item `{provider_ref}`: {description}. Sync it into a repo source-of-truth goal plan, run required "
        "review/precheck, start the goal ledger, generate the goal condition, and continue tactical PR planning "
        "and implementation until the goal is complete."
    )


def goal_plan_is_candidate(data: dict, target: Path, path: Path) -> bool:
    if not path.is_file() or path.suffix.lower() != ".md":
        return False
    rel_parts = path.resolve(strict=False).relative_to(goal_source_root(data, target).resolve(strict=False)).parts
    if any(part.startswith(".") or part.lower() in {"archive", "archives", "historical"} for part in rel_parts[:-1]):
        return False
    name = path.name.lower()
    template_name = Path(str(data["goalArtifacts"].get("template", ""))).name.lower()
    return name not in {"_index.md", "_template.md", "readme.md"} and name != template_name


def goal_plan_index_paths(data: dict, target: Path, root: Path) -> list[Path]:
    index_path = root / "_index.md"
    if not index_path.exists():
        return []
    entries = read_document_index_entries([index_path])
    ordered: list[Path] = []
    for section in ["Active Work", "Ready Next", "Read First"]:
        for entry in sorted(entries.get(section, set())):
            candidate = configured_path(target, entry)
            if not path_is_under(candidate, root):
                candidate = root / entry
            if candidate.exists() and path_is_under(candidate, root) and goal_plan_is_candidate(data, target, candidate):
                ordered.append(candidate)
    return ordered


def repo_goal_plan_candidates(data: dict, target: Path) -> list[Path]:
    root = goal_source_root(data, target)
    if not root.exists():
        return []
    ordered: list[Path] = []
    seen: set[Path] = set()
    for path in goal_plan_index_paths(data, target, root):
        resolved = path.resolve(strict=False)
        if resolved not in seen:
            ordered.append(path)
            seen.add(resolved)
    remaining = [
        path
        for path in root.rglob("*.md")
        if goal_plan_is_candidate(data, target, path) and path.resolve(strict=False) not in seen
    ]
    remaining.sort(key=lambda path: (path.stat().st_mtime if path.exists() else 0, path.name), reverse=True)
    ordered.extend(remaining)
    return ordered


def goal_plan_readiness(data: dict, target: Path, path: Path) -> tuple[str, list[str]]:
    try:
        errors, _plan_path, _manifest_path = plan_finalization_precheck_errors(data, target, path)
    except SystemExit as exc:
        errors = [str(exc)]
    if not errors:
        return "execution-ready", []
    lowered = " | ".join(errors).lower()
    if "manifest missing" in lowered or "manifest is stale" in lowered or "review" in lowered:
        return "needs-review", errors
    if "substantive" in lowered or "no ordered milestones" in lowered or "stub" in lowered:
        return "needs-planning", errors
    return "blocked", errors


def repo_next_goal_candidate(data: dict, target: Path, exclude_paths: set[Path] | None = None) -> dict | None:
    candidates = repo_goal_plan_candidates(data, target)
    if not candidates:
        return None
    excluded = {path.resolve(strict=False) for path in (exclude_paths or set())}
    records = []
    rank = {"execution-ready": 0, "needs-review": 1, "needs-planning": 2, "blocked": 3}
    for order, path in enumerate(candidates):
        if path.resolve(strict=False) in excluded:
            continue
        status, errors = goal_plan_readiness(data, target, path)
        records.append(
            {
                "path": path,
                "title": goal_plan_title(path),
                "description": goal_plan_short_description(path),
                "status": status,
                "errors": errors,
                "rank": rank.get(status, 9),
                "order": order,
            }
        )
    if not records:
        return None
    return sorted(records, key=lambda record: (record["rank"], record["order"]))[0]


def goal_run_source_path(data: dict, target: Path, run: dict) -> Path | None:
    source = str(run.get("sourceGoal") or "").strip()
    if not source:
        return None
    return cli_path(target, source).resolve(strict=False)


def print_next_goal_candidate(data: dict, target: Path, exclude_paths: set[Path] | None = None) -> None:
    provider_item_mode = provider_item_completion_enabled(data)
    prefix = "next_work_item" if provider_item_mode else "next_goal"
    print(f"{prefix}_active: false")
    provider = backlog_provider_config(data)
    if provider["enabled"]:
        print(f"{prefix}_source: backlog-provider")
        try:
            issues = goal_tracker_status_issues(data, target)
            if issues:
                print(f"{prefix}_name: unavailable")
                for issue in issues:
                    print(f"{prefix}_blocker: {issue}")
                item_type_hint = "" if provider_item_mode else " --type goal"
                print(f"{prefix}_next_action: fix the backlog provider blocker, then run `tautline backlog-provider-next --target .{item_type_hint}`")
                return
            item_type = "" if provider_item_mode else ("goal" if provider.get("typeField") and "goal" in provider.get("itemTypes", []) else "")
            item = goal_tracker_select_next_item(data, target, item_type=item_type)
        except SystemExit as exc:
            print(f"{prefix}_name: unavailable")
            print(f"{prefix}_blocker: {exc}")
            item_type_hint = "" if provider_item_mode else " --type goal"
            print(f"{prefix}_next_action: fix the backlog provider blocker, then run `tautline backlog-provider-next --target .{item_type_hint}`")
            return
        if item is not None:
            item_ref = goal_tracker_item_id(item) or goal_tracker_item_url(item) or goal_tracker_item_title(item)
            title = goal_tracker_item_title(item)
            description = compact_one_line(str(item.get("body") or item.get("description") or title))
            print(f"{prefix}_name: {title}")
            print(f"{prefix}_short_description: {description}")
            print(f"{prefix}_item_id: {goal_tracker_item_id(item) or 'none'}")
            print(f"{prefix}_url: {goal_tracker_item_url(item) or 'none'}")
            if provider_item_mode:
                print(f"{prefix}_status: provider-ready; issue/work item is the completion unit and requires repo sync plus reviewed source-of-truth execution plan before implementation")
            else:
                print(f"{prefix}_status: provider-ready; requires repo sync and reviewed source-of-truth goal plan before execution")
            print(
                f"{prefix}_claude_prompt: "
                + goal_candidate_claude_prompt(
                    data,
                    target,
                    title=title,
                    description=description,
                    status="provider-ready",
                    item_ref=item_ref,
                )
            )
            if provider_item_mode:
                print(
                    f"{prefix}_next_action: run `tautline backlog-provider-sync --target . "
                    f"--item {shlex.quote(item_ref)} --write` to claim the board item active, then review the repo execution plan"
                )
            else:
                print(
                    f"{prefix}_next_action: run `tautline backlog-provider-sync --target . "
                    f"--item {shlex.quote(item_ref)} --write` to claim the board item active, then review the repo goal plan and run `goal-start`"
                )
            return
        print(f"{prefix}_provider_candidate: none")
        if provider_item_mode:
            print(f"{prefix}_name: none")
            print(f"{prefix}_short_description: none")
            print(f"{prefix}_next_action: no ready provider item matched the adapter query/status/type mapping; fix board readiness/scope or ask for a named item")
            return
    record = repo_next_goal_candidate(data, target, exclude_paths=exclude_paths)
    if record is None:
        print("next_goal_source: repo-goal-plans")
        print("next_goal_name: none")
        print("next_goal_short_description: none")
        print(
            "next_goal_claude_prompt: /goal Identify, create, review, and complete the next Minervit goal. "
            "Inspect the adapter, backlog provider, source-of-truth goal folder, readiness markers, and continuity "
            "handoff; create or update the highest-priority goal plan, run required review/precheck, start the "
            "goal ledger, generate the goal condition, and execute until complete."
        )
        print("next_goal_next_action: create or update the highest-priority source-of-truth goal plan before tactical work")
        return
    print("next_goal_source: repo-goal-plans")
    print(f"next_goal_name: {record['title']}")
    print(f"next_goal_short_description: {record['description']}")
    print(f"next_goal_plan: {path_display(target, record['path'])}")
    print(f"next_goal_status: {record['status']}")
    print(
        "next_goal_claude_prompt: "
        + goal_candidate_claude_prompt(
            data,
            target,
            title=record["title"],
            description=record["description"],
            status=record["status"],
            goal_path=record["path"],
        )
    )
    for issue in record["errors"][:3]:
        print(f"next_goal_issue: {issue}")
    if len(record["errors"]) > 3:
        print(f"next_goal_issues_omitted: {len(record['errors']) - 3}")
    if record["status"] == "execution-ready":
        print(
            "next_goal_next_action: run `tautline goal-start --target . "
            f"--goal {shlex.quote(path_display(target, record['path']))}`, then `goal-condition` and start `/goal`"
        )
    elif record["status"] == "needs-review":
        print("next_goal_next_action: run the required cross-model review/precheck for this goal plan before `goal-start`")
    elif record["status"] == "needs-planning":
        print("next_goal_next_action: finish the source-of-truth goal plan sections, then run review/precheck before `goal-start`")
    else:
        print("next_goal_next_action: resolve the printed blocker or select/create a different source-of-truth goal plan")


def goal_percent_complete(run: dict) -> int:
    milestones = run.get("milestones", [])
    if not milestones:
        return 0
    done = sum(1 for item in milestones if item.get("status") in GOAL_TERMINAL_STATUSES)
    return int(round((done / len(milestones)) * 100))


def goal_next_action_record(run: dict) -> dict:
    if run.get("status") == "complete":
        return {
            "type": "goal_complete",
            "milestoneIndex": None,
            "milestoneTitle": None,
            "instruction": "Goal is complete; refresh continuity and session journal evidence, inspect the adapter backlog/readiness markers, then start the next source-of-truth goal or highest-priority item unless a true blocker exists.",
        }
    milestones = run.get("milestones", [])
    for milestone in milestones:
        if milestone.get("status") == "in_progress":
            return {
                "type": "continue_milestone",
                "milestoneIndex": milestone["index"],
                "milestoneTitle": milestone["title"],
                "instruction": goal_instruction_with_dependencies(
                    f"Continue milestone {milestone['index']}: {milestone['title']}",
                    run,
                    milestone,
                ),
            }
    for milestone in milestones:
        if milestone.get("status") == "pending":
            return {
                "type": "start_milestone",
                "milestoneIndex": milestone["index"],
                "milestoneTitle": milestone["title"],
                "instruction": goal_instruction_with_dependencies(
                    f"Start milestone {milestone['index']}: {milestone['title']}; create/update the source-of-truth milestone or PR plan, run Codex review/precheck, then implement.",
                    run,
                    milestone,
                ),
            }
    blocked = [milestone for milestone in milestones if milestone.get("status") == "blocked"]
    if blocked:
        milestone = blocked[0]
        blockers = "; ".join(milestone.get("blockers") or ["blocked with no reason recorded"])
        return {
            "type": "true_blocker",
            "milestoneIndex": milestone["index"],
            "milestoneTitle": milestone["title"],
            "instruction": f"True blocker on milestone {milestone['index']}: {blockers}",
        }
    return {
        "type": "ready_for_goal_completion",
        "milestoneIndex": None,
        "milestoneTitle": None,
        "instruction": "All goal milestones are terminal; run goal-advance --event goal-complete with validation proof and --iteration-review-record when iterationReview covers goal boundaries, refresh continuity, publish the session journal, and produce a boundary summary.",
    }


def goal_status_from_action(action: dict, run_status: str | None = None) -> str:
    if run_status == "complete" or action["type"] == "goal_complete":
        return "complete"
    if action["type"] == "true_blocker":
        return "blocked"
    if action["type"] == "ready_for_goal_completion":
        return "ready_for_completion"
    return "active"


def goal_run_integrity_issues(run: dict) -> list[str]:
    issues: list[str] = []
    for milestone in run.get("milestones", []):
        if milestone.get("status") == "blocked" and not milestone.get("blockers"):
            issues.append(f"milestone {milestone.get('index')} is blocked but has no blocker reason")
        if milestone.get("status") == "complete" and not (milestone.get("validationEvidence") or milestone.get("milestoneRun")):
            issues.append(f"milestone {milestone.get('index')} is complete but lacks validation evidence or linked milestone ledger")
        if milestone.get("status") == "deferred" and not (milestone.get("deferralReasons") or milestone.get("validationEvidence")):
            issues.append(f"milestone {milestone.get('index')} is deferred but lacks a deferral reason")
    if run.get("status") == "complete" and not run.get("validationProof"):
        issues.append("goal is complete but validationProof is empty")
    return issues


def load_goal_run(data: dict, target: Path) -> dict:
    path = goal_run_path(data, target)
    if not path.exists():
        raise SystemExit(f"goal run missing: {path}. Start one with goal-start --target . --goal <source-of-truth-goal-plan>.")
    try:
        run = json.loads(path.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        raise SystemExit(f"goal run invalid JSON: {path}: {exc}") from exc
    if run.get("schema") != GOAL_RUN_SCHEMA:
        raise SystemExit(f"goal run has unsupported schema: {run.get('schema')}")
    action = goal_next_action_record(run)
    run["percentComplete"] = goal_percent_complete(run)
    run["nextAction"] = action
    run["status"] = goal_status_from_action(action, run.get("status"))
    return run


def save_goal_run(data: dict, target: Path, run: dict) -> Path:
    path = goal_run_path(data, target)
    path.parent.mkdir(parents=True, exist_ok=True)
    run["updatedAt"] = now_iso()
    action = goal_next_action_record(run)
    run["percentComplete"] = goal_percent_complete(run)
    run["nextAction"] = action
    run["status"] = goal_status_from_action(action, run.get("status"))
    path.write_text(json.dumps(run, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    return path


def initial_goal_run(data: dict, target: Path, goal_path: Path) -> dict:
    milestones = goal_milestones_from_plan(goal_path)
    now = now_iso()
    return {
        "schema": GOAL_RUN_SCHEMA,
        "createdAt": now,
        "updatedAt": now,
        "project": data["project"],
        "repo": data["repo"],
        "goalId": slugify(goal_path.stem, "goal"),
        "sourceGoal": path_relative_to_target(target, goal_path),
        "currentMilestoneIndex": 1,
        "status": "active",
        "percentComplete": 0,
        "validationProof": [],
        "multiSession": goal_plan_is_multi_session(goal_path),
        "knownOperatorDependencies": goal_operator_dependency_entries(goal_path),
        "milestones": [
            {
                "index": index + 1,
                "title": title,
                "status": "in_progress" if index == 0 else "pending",
                "sourcePlan": None,
                "milestoneRun": None,
                "prRefs": [],
                "validationEvidence": [],
                "blockers": [],
                "updatedAt": now,
            }
            for index, title in enumerate(milestones)
        ],
        "events": [
            {"timestamp": now, "event": "goal-start", "detail": f"started from {path_relative_to_target(target, goal_path)}"}
        ],
        "nextAction": {},
    }


def refresh_goal_run(data: dict, target: Path, run: dict, goal_path: Path) -> dict:
    source_goal = path_relative_to_target(target, goal_path)
    existing_source_goal = run.get("sourceGoal")
    if existing_source_goal and existing_source_goal != source_goal:
        raise SystemExit(
            "goal refresh would retarget an existing ledger from "
            f"{existing_source_goal} to {source_goal}. Finish/archive the current goal run or start a new goal run intentionally."
        )
    titles = goal_milestones_from_plan(goal_path)
    existing = {str(item.get("title", "")).strip().lower(): item for item in run.get("milestones", [])}
    now = now_iso()
    refreshed = []
    refreshed_keys = set()
    for index, title in enumerate(titles, 1):
        key = title.strip().lower()
        refreshed_keys.add(key)
        previous = existing.get(key)
        if previous:
            milestone = {**previous, "index": index, "title": title}
        else:
            milestone = {
                "index": index,
                "title": title,
                "status": "pending",
                "sourcePlan": None,
                "milestoneRun": None,
                "prRefs": [],
                "validationEvidence": [],
                "blockers": [],
                "updatedAt": now,
            }
        refreshed.append(milestone)
    dropped = []
    dropped_pending = []
    for key, milestone in existing.items():
        if key in refreshed_keys:
            continue
        has_state = (
            milestone.get("status") in (GOAL_TERMINAL_STATUSES | {"in_progress", "blocked"})
            or bool(milestone.get("sourcePlan"))
            or bool(milestone.get("milestoneRun"))
            or bool(milestone.get("prRefs"))
            or bool(milestone.get("blockers"))
            or bool(milestone.get("validationEvidence"))
        )
        if has_state:
            dropped.append(f"{milestone.get('index')}: {milestone.get('title')} ({milestone.get('status')})")
        else:
            dropped_pending.append(f"{milestone.get('index')}: {milestone.get('title')}")
    if dropped:
        raise SystemExit(
            "goal refresh would drop existing milestone state: "
            + "; ".join(dropped)
            + ". Preserve the milestone title, finish/archive the current goal run, or start a new goal run intentionally."
        )
    run.update(
        {
            "project": data["project"],
            "repo": data["repo"],
            "goalId": slugify(goal_path.stem, "goal"),
            "sourceGoal": source_goal,
            "multiSession": goal_plan_is_multi_session(goal_path),
            "knownOperatorDependencies": goal_operator_dependency_entries(goal_path),
            "milestones": refreshed,
        }
    )
    if not any(item.get("status") == "in_progress" for item in refreshed):
        for item in refreshed:
            if item.get("status") in GOAL_ACTIVE_STATUSES:
                item["status"] = "in_progress"
                item["updatedAt"] = now
                run["currentMilestoneIndex"] = item["index"]
                break
    run.setdefault("events", []).append(
        {"timestamp": now, "event": "goal-refresh", "detail": f"refreshed from {path_relative_to_target(target, goal_path)}"}
    )
    for dropped_item in dropped_pending:
        run.setdefault("events", []).append(
            {
                "timestamp": now,
                "event": "goal-refresh-dropped-pending",
                "detail": f"dropped pending milestone with no state: {dropped_item}",
            }
        )
    return run


def print_goal_run(run: dict, path: Path) -> None:
    action = goal_next_action_record(run)
    current = next((item for item in run.get("milestones", []) if item.get("status") == "in_progress"), None)
    pending = next((item for item in run.get("milestones", []) if item.get("status") == "pending"), None)
    print(f"goal_run: {path}")
    print(f"goal_id: {run.get('goalId')}")
    print(f"source_goal: {run.get('sourceGoal')}")
    print(f"status: {run.get('status')}")
    print(f"percent_complete: {run.get('percentComplete', goal_percent_complete(run))}")
    print(f"multi_session: {str(bool(run.get('multiSession'))).lower()}")
    print(f"current_milestone: {current['index']} - {current['title']}" if current else "current_milestone: none")
    print(f"next_pending_milestone: {pending['index']} - {pending['title']}" if pending else "next_pending_milestone: none")
    dependencies = run.get("knownOperatorDependencies") or []
    print(f"known_operator_dependencies: {len(dependencies)}")
    for dependency in dependencies[:5]:
        print(f"known_operator_dependency: {dependency}")
    if len(dependencies) > 5:
        print(f"known_operator_dependencies_omitted: {len(dependencies) - 5}")
    if current:
        current_dependencies = goal_dependencies_for_milestone(run, current)
        print(f"current_milestone_operator_dependencies: {len(current_dependencies)}")
        for dependency in current_dependencies[:5]:
            print(f"current_milestone_operator_dependency: {dependency}")
        if len(current_dependencies) > 5:
            print(f"current_milestone_operator_dependencies_omitted: {len(current_dependencies) - 5}")
    print(f"next_action_type: {action['type']}")
    print(f"next_action: {action['instruction']}")


def goal_start(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    ensure_lane_state(data, target)
    goal_path = resolve_goal_plan_path(data, target, args.goal)
    path = goal_run_path(data, target)
    if path.exists():
        run = refresh_goal_run(data, target, load_goal_run(data, target), goal_path)
    else:
        run = initial_goal_run(data, target, goal_path)
    goal_tracker_sync_transition(data, target, run, "goal-start")
    save_goal_run(data, target, run)
    saved = load_goal_run(data, target)
    print_goal_run(saved, path)
    try_write_event(
        data,
        target,
        event="goal_run_active",
        severity="ok",
        plain=f"Goal ledger is active for {saved.get('goalId') or path_display(target, goal_path)}.",
        next_action=goal_next_action_record(saved)["instruction"],
        refs={"goal_plan": path_display(target, goal_path), "goal_run": path_display(target, path)},
    )
    return 0


def goal_status(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    ensure_lane_state(data, target)
    path = goal_run_path(data, target)
    run = load_goal_run(data, target)
    print_goal_run(run, path)
    for issue in goal_tracker_drift_issues(data, target, run):
        print(f"goal_tracker_issue: {issue}")
    return 0


def selected_goal_milestone(run: dict, milestone_index: int | None) -> dict:
    milestones = run.get("milestones", [])
    if milestone_index is not None:
        for milestone in milestones:
            if milestone.get("index") == milestone_index:
                return milestone
        raise SystemExit(f"goal milestone not found: {milestone_index}")
    current_index = run.get("currentMilestoneIndex")
    for milestone in milestones:
        if milestone.get("index") == current_index:
            return milestone
    for milestone in milestones:
        if milestone.get("status") == "in_progress":
            return milestone
    for milestone in milestones:
        if milestone.get("status") == "pending":
            return milestone
    raise SystemExit("goal run has no selectable milestone")


def promote_next_goal_milestone(run: dict, now: str) -> None:
    for milestone in run.get("milestones", []):
        if milestone.get("status") == "pending":
            milestone["status"] = "in_progress"
            milestone["updatedAt"] = now
            run["currentMilestoneIndex"] = milestone["index"]
            return


def goal_advance(args: argparse.Namespace) -> int:
    if args.event not in GOAL_ADVANCE_EVENTS:
        raise SystemExit(f"unsupported goal event: {args.event}")
    if args.event == "milestone-blocked" and not args.reason:
        raise SystemExit("--reason is required for milestone-blocked")
    if args.event == "milestone-deferred" and not args.reason:
        raise SystemExit("--reason is required for milestone-deferred")
    data, _, target = lane_project(args)
    ensure_lane_state(data, target)
    path = goal_run_path(data, target)
    run = load_goal_run(data, target)
    ui_blocker, ui_manifest_path, ui_manifest = ui_evidence_completion_blocker(
        data,
        target,
        args.event,
        args.ui_evidence_manifest,
    )
    if ui_blocker:
        raise SystemExit(ui_blocker)
    now = now_iso()
    event_detail = args.detail or ""
    transitioned_milestone = None
    if args.event == "startup":
        if not any(item.get("status") == "in_progress" for item in run.get("milestones", [])):
            promote_next_goal_milestone(run, now)
            event_detail = event_detail or "resumed next pending milestone"
    elif args.event == "goal-complete":
        open_items = [
            f"{item.get('index')}: {item.get('title')} ({item.get('status')})"
            for item in run.get("milestones", [])
            if item.get("status") not in GOAL_TERMINAL_STATUSES
        ]
        if open_items:
            raise SystemExit("goal cannot complete while milestones are non-terminal: " + "; ".join(open_items))
        if not args.detail:
            raise SystemExit("--detail is required for goal-complete and must summarize validation proof")
        iteration_review_blocker = iteration_review_goal_completion_blocker(
            data,
            target,
            args.iteration_review_record,
            goal_id=str(run.get("goalId") or ""),
        )
        if iteration_review_blocker:
            raise SystemExit(iteration_review_blocker)
        if args.iteration_review_record:
            run["iterationReviewRecord"] = path_relative_to_target(
                target,
                cli_path(target, str(args.iteration_review_record)).resolve(strict=False),
            )
        run.setdefault("validationProof", []).append(args.detail)
        run["status"] = "complete"
        event_detail = args.detail
    else:
        milestone = selected_goal_milestone(run, args.milestone_index)
        transitioned_milestone = milestone
        if args.milestone_plan:
            milestone["sourcePlan"] = path_relative_to_target(target, cli_path(target, args.milestone_plan))
        if args.milestone_run:
            milestone["milestoneRun"] = path_relative_to_target(target, cli_path(target, args.milestone_run))
        if args.pr:
            refs = milestone.setdefault("prRefs", [])
            if str(args.pr) not in refs:
                refs.append(str(args.pr))
        if args.event == "milestone-started":
            milestone["status"] = "in_progress"
            run["currentMilestoneIndex"] = milestone["index"]
            event_detail = event_detail or f"started milestone {milestone['index']}"
        elif args.event == "milestone-complete":
            if not (
                args.detail
                or args.milestone_run
                or milestone.get("milestoneRun")
                or milestone.get("validationEvidence")
            ):
                raise SystemExit("--detail or --milestone-run is required for milestone-complete")
            update_blocker = milestone_update_completion_blocker(data, target, milestone)
            if update_blocker:
                raise SystemExit(update_blocker)
            milestone["status"] = "complete"
            if args.detail:
                milestone.setdefault("validationEvidence", []).append(args.detail)
            event_detail = event_detail or f"completed milestone {milestone['index']}"
            promote_next_goal_milestone(run, now)
        elif args.event == "milestone-deferred":
            milestone["status"] = "deferred"
            milestone.setdefault("deferralReasons", []).append(args.reason)
            event_detail = event_detail or args.reason
            promote_next_goal_milestone(run, now)
        elif args.event == "milestone-blocked":
            milestone["status"] = "blocked"
            milestone.setdefault("blockers", []).append(args.reason)
            run["currentMilestoneIndex"] = milestone["index"]
            event_detail = event_detail or args.reason
        milestone["updatedAt"] = now
    ui_evidence_comment_output: list[str] = []
    ui_comments = data["uiEvidence"]["issueComments"]
    ui_comment_required = (
        data["uiEvidence"]["enabled"]
        and ui_comments["enabled"]
        and ui_comments["required"]
        and args.event in ui_comments["triggerEvents"]
    )
    if ui_comment_required:
        ui_evidence_comment_output, ui_comment_ok = sync_goal_issue_ui_evidence(
            data,
            target,
            run,
            args.event,
            ui_manifest_path,
            ui_manifest,
        )
        if not ui_comment_ok:
            raise SystemExit("; ".join(ui_evidence_comment_output) or "ui evidence issue comment failed")
    done_evidence = goal_tracker_done_evidence_for_transition(args.event, args.detail or "", transitioned_milestone)
    goal_tracker_sync_transition(data, target, run, args.event, transitioned_milestone, done_evidence=done_evidence)
    run.setdefault("events", []).append({"timestamp": now, "event": args.event, "detail": event_detail})
    save_goal_run(data, target, run)
    saved = load_goal_run(data, target)
    print_goal_run(saved, path)
    for line in sync_goal_issue_milestone_progress(data, target, saved):
        print(line)
    if not ui_comment_required:
        ui_evidence_comment_output, _ui_comment_ok = sync_goal_issue_ui_evidence(
            data,
            target,
            saved,
            args.event,
            ui_manifest_path,
            ui_manifest,
        )
    for line in ui_evidence_comment_output:
        print(line)
    if args.event == "goal-complete":
        source_goal_path = goal_run_source_path(data, target, saved)
        excluded = {source_goal_path} if source_goal_path else set()
        print_next_goal_candidate(data, target, exclude_paths=excluded)
    next_action = goal_next_action_record(saved)["instruction"]
    try_write_event(
        data,
        target,
        event=f"goal_advance_{args.event.replace('-', '_')}",
        severity="block" if args.event == "milestone-blocked" else "ok",
        plain=f"Goal transition recorded: {args.event}. {event_detail}",
        next_action=next_action,
        pr=args.pr,
        refs={"goal_run": path_display(target, path)},
    )
    return 0


def goal_next(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    ensure_lane_state(data, target)
    path = goal_run_path(data, target)
    run = load_goal_run(data, target)
    run["nextAction"] = goal_next_action_record(run)
    run["percentComplete"] = goal_percent_complete(run)
    print_goal_run(run, path)
    return 0


def goal_condition(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    ensure_lane_state(data, target)
    run = load_goal_run(data, target)
    path = goal_run_path(data, target)
    rotation = data["contextRotation"]
    boundary_clause = ""
    if run.get("multiSession") or run.get("knownOperatorDependencies"):
        boundary_clause = (
            " If the goal plan is explicitly multi-session or has known operator-input blockers, the current "
            "session may also end after an authorized increment is delivered, continuity is refreshed, journal work "
            "is handled or queued, and the boundary is a true blocker or genuine scope boundary with goal-ledger "
            "evidence. Context exhaustion by itself does not satisfy the goal; it requires continuity, journal handling, "
            "host compact/restart, and resumption of this same goal."
        )
    ui_clause = ""
    if data["uiEvidence"]["enabled"] and data["uiEvidence"]["enforcement"] == "block":
        ui_clause = (
            " configured UI evidence is satisfied with a Playwright screenshot manifest passed to "
            "`goal-advance --ui-evidence-manifest` or discovered in the adapter evidence directory, and the GitHub issue proof comment succeeds when the adapter requires it,"
        )
    condition = (
        f"{data['goalExecution']['preferredClaudeCommand']} Complete goal `{run.get('goalId')}` from "
        f"`{run.get('sourceGoal')}`. Completion means every milestone in `{data['laneState']['goalRun']}` "
        "is complete or policy-deferred, required validation proof is surfaced in this conversation, no open Critical/P1 "
        "blockers remain, required iteration-review delivery has passed when the adapter enables goal reviews,"
        f"{ui_clause} no active PR branch is being edited after queue/merge/close, continuity and session journal "
        "evidence are refreshed, and `tautline goal-status --target .` reports goal complete or ready for "
        f"goal completion.{boundary_clause} Use `tautline goal-next --target .` before milestone/PR work; if Claude `/goal` is unavailable "
        "or cleared, continue with the same goal ledger instead of stopping. During long `/goal` work, after each "
        f"meaningful subtask and at least every {rotation['heartbeatMinutes']} minutes, inspect the visible context "
        f"indicator and run `tautline context-rotation-check --target . --boundary {rotation['heartbeatBoundary']} "
        "--context-percent <visible-percent> --context-percent-source estimate` when the host exposes a percent (pass "
        "--context-percent-source host only if the host literally exposes a context-window counter; an estimate can "
        "recommend rotation but never make it mandatory); when the CLI says recommended or "
        "mandatory, refresh continuity and journal evidence, invoke `/compact` or the strongest host compact/restart "
        "path when available, or if this agent turn cannot invoke `/compact` directly, state the exact fresh-session "
        "startup action without opt-in language; then resume this same goal instead of stopping."
    )
    print(f"goal_run: {path}")
    print("goal_condition_scope: claude-lanes; Codex and non-Claude lanes should use goal-next and goal-status directly")
    print(f"goal_condition: {condition}")
    print("proof_commands:")
    print("- tautline goal-status --target .")
    print("- tautline milestone-status --target .")
    print("- tautline methodology-status --target . --fail-on-drift")
    if data["uiEvidence"]["enabled"]:
        print("- tautline goal-advance --target . --event <milestone-complete|goal-complete> --ui-evidence-manifest <manifest>")
    return 0


def goal_kickoff_prompt(args: argparse.Namespace) -> int:
    target = shlex.quote(str(args.target))
    try:
        data, _, resolved_target = lane_project(args)
    except SystemExit as exc:
        if str(exc) != NO_ADAPTER_MESSAGE:
            print(str(exc), file=sys.stderr)
            return 1
        print(CLAUDE_GOAL_KICKOFF_PROMPT_TEMPLATE.format(target=target))
        return 0
    goal_path = goal_run_path(data, resolved_target)
    if goal_path.exists():
        try:
            run = load_goal_run(data, resolved_target)
        except SystemExit as exc:
            print(f"goal_kickoff_mode: invalid-goal-ledger")
            print(f"goal_kickoff_blocker: {exc}")
            print(CLAUDE_GOAL_KICKOFF_PROMPT_TEMPLATE.format(target=target))
            return 0
        if run.get("status") != "complete":
            print("goal_kickoff_mode: active-goal")
            goal_condition(args)
            return 0
        print("goal_kickoff_mode: completed-goal-next")
        source_goal_path = goal_run_source_path(data, resolved_target, run)
        excluded = {source_goal_path} if source_goal_path else set()
        print_next_goal_candidate(data, resolved_target, exclude_paths=excluded)
        return 0
    print("goal_kickoff_mode: no-active-goal")
    print_next_goal_candidate(data, resolved_target)
    return 0


def goal_tracker_config(data: dict) -> dict:
    return paths_module().goal_tracker_config(data)


def backlog_provider_config(data: dict) -> dict:
    return paths_module().backlog_provider_config(data)


def backlog_provider_completion_unit(data: dict) -> str:
    provider = data.get("backlogProvider") if isinstance(data, dict) else {}
    if not isinstance(provider, dict) or not provider.get("enabled"):
        return "goal"
    value = str(provider.get("completionUnit") or "goal").strip().lower()
    return value if value in {"goal", "provider-item"} else "goal"


def provider_item_completion_enabled(data: dict) -> bool:
    return backlog_provider_completion_unit(data) == "provider-item"


def board_schema_fingerprint(fields: list) -> list:
    """Canonical, display-order-independent fingerprint of a board's schema (Slice B mismatch
    detection). Field display order is cosmetic and is sorted away; single-select OPTION order is the
    workflow column order and is preserved, so reordering columns is a meaningful schema change."""
    canon = []
    for field in fields or []:
        if not isinstance(field, dict):
            continue
        name = str(field.get("name") or "")
        ftype = str(field.get("type") or "")
        options = [str(opt.get("name") or "") for opt in (field.get("options") or []) if isinstance(opt, dict)]
        canon.append([name, ftype, options])
    canon.sort(key=lambda entry: (entry[0], entry[1]))
    return canon


def board_schema_hash(fields: list) -> str:
    payload = json.dumps(board_schema_fingerprint(fields), separators=(",", ":"))
    return hashlib.sha256(payload.encode("utf-8")).hexdigest()


def board_schema_changed(stored_hash, fields: list) -> bool:
    """A never-adopted board (no stored hash) or a board whose live schema diverges from the stored
    hash is a mismatch -> the agent re-proposes an aligned adapter for operator confirmation."""
    if not stored_hash:
        return True
    return str(stored_hash) != board_schema_hash(fields)


def backlog_provider_status_summary(data: dict) -> str:
    provider = backlog_provider_config(data)
    if not provider["enabled"]:
        return "backlog_provider: enabled=false"
    query = f" query={provider['scopeQuery']}" if provider.get("scopeQuery") else ""
    types = ",".join(provider.get("itemTypes", [])) or "none"
    type_field = f" type_field={provider['typeField']}" if provider.get("typeField") else ""
    return (
        "backlog_provider: enabled=true provider=github-projects "
        f"project={provider['owner']}/{provider['projectNumber']} item_types={types} "
        f"completion_unit={backlog_provider_completion_unit(data)} status_field={provider['statusField']}{type_field}{query}"
    )


def goal_tracker_status_summary(data: dict) -> str:
    tracker = goal_tracker_config(data)
    if not tracker["enabled"]:
        return "goal_tracker: enabled=false"
    query = f" query={tracker['scopeQuery']}" if tracker.get("scopeQuery") else ""
    return (
        "goal_tracker: enabled=true provider=github-projects "
        f"project={tracker['owner']}/{tracker['projectNumber']} status_field={tracker['statusField']}{query}"
    )


def goal_tracker_allowed_statuses(tracker: dict) -> list[str]:
    return (
        list(tracker["readyStatuses"])
        + list(tracker["activeStatuses"])
        + list(tracker["doneStatuses"])
        + list(tracker["blockedStatuses"])
    )


def normalized_field_key(value: str) -> str:
    return re.sub(r"[^a-z0-9]+", "", value.lower())


def goal_tracker_value_text(value: object) -> str:
    if value is None:
        return ""
    if isinstance(value, str):
        return value.strip()
    if isinstance(value, (int, float, bool)):
        return str(value)
    if isinstance(value, dict):
        for key in ["name", "title", "text", "value", "number", "date", "id"]:
            if key in value:
                text = goal_tracker_value_text(value[key])
                if text:
                    return text
        if "option" in value:
            return goal_tracker_value_text(value["option"])
    if isinstance(value, list):
        parts = [goal_tracker_value_text(item) for item in value]
        return ", ".join(part for part in parts if part)
    return str(value).strip()


def goal_tracker_item_field(item: dict, field_name: str) -> str:
    wanted = normalized_field_key(field_name)
    if not wanted:
        return ""
    for key, value in item.items():
        if normalized_field_key(str(key)) == wanted:
            text = goal_tracker_value_text(value)
            if text:
                return text
    for container_name in ["fieldValues", "fields", "projectFields"]:
        container = item.get(container_name)
        if isinstance(container, dict):
            for key, value in container.items():
                if normalized_field_key(str(key)) == wanted:
                    text = goal_tracker_value_text(value)
                    if text:
                        return text
        elif isinstance(container, list):
            for entry in container:
                if not isinstance(entry, dict):
                    continue
                name = (
                    entry.get("name")
                    or entry.get("fieldName")
                    or (entry.get("field") or {}).get("name")
                    or (entry.get("projectField") or {}).get("name")
                )
                if name and normalized_field_key(str(name)) == wanted:
                    for value_key in ["value", "text", "number", "date", "option"]:
                        if value_key in entry and entry[value_key] not in (None, ""):
                            text = goal_tracker_value_text(entry[value_key])
                            if text:
                                return text
    return ""


def goal_tracker_item_title(item: dict) -> str:
    return (
        goal_tracker_value_text(item.get("title"))
        or goal_tracker_value_text((item.get("content") or {}).get("title") if isinstance(item.get("content"), dict) else "")
        or goal_tracker_value_text(item.get("id"))
        or "Untitled GitHub Project item"
    )


def goal_tracker_item_url(item: dict) -> str:
    return (
        goal_tracker_value_text(item.get("url"))
        or goal_tracker_value_text((item.get("content") or {}).get("url") if isinstance(item.get("content"), dict) else "")
    )


def goal_tracker_item_content_url(item: dict) -> str:
    content = item.get("content") if isinstance(item.get("content"), dict) else {}
    return goal_tracker_value_text(content.get("url"))


def goal_tracker_item_body(item: dict) -> str:
    content = item.get("content") if isinstance(item.get("content"), dict) else {}
    return (
        goal_tracker_value_text(item.get("body"))
        or goal_tracker_value_text(content.get("body"))
        or goal_tracker_value_text(content.get("bodyText"))
        or goal_tracker_value_text(item.get("description"))
    )


def goal_tracker_item_id(item: dict) -> str:
    return goal_tracker_value_text(item.get("id") or item.get("itemId") or item.get("databaseId"))


def goal_tracker_item_matches(item: dict, item_ref: str) -> bool:
    needle = item_ref.strip()
    if not needle:
        return False
    values = {
        goal_tracker_item_id(item),
        goal_tracker_item_url(item),
        goal_tracker_item_title(item),
        goal_tracker_value_text((item.get("content") or {}).get("url") if isinstance(item.get("content"), dict) else ""),
    }
    return needle in values or any(value and needle.endswith(value) for value in values)


def goal_tracker_priority_rank(value: str) -> int:
    text = value.strip().lower()
    if not text:
        return 999
    match = re.search(r"\bp\s*([0-9]+)\b", text)
    if match:
        return int(match.group(1))
    if re.fullmatch(r"[0-9]+(?:\.[0-9]+)?", text):
        return int(float(text))
    ranks = {
        "critical": 0,
        "urgent": 0,
        "highest": 0,
        "high": 10,
        "medium": 50,
        "normal": 50,
        "low": 80,
        "lowest": 90,
    }
    return ranks.get(text, 500)


def goal_tracker_gh_json(args: list[str], target: Path, timeout: int = 30) -> object:
    return github_provider_client_json(
        github_operation_name(args),
        args,
        target,
        timeout=timeout,
        cacheable=github_args_are_cacheable_read(args),
    )


def github_rest_json(endpoint: str, target: Path, extra_args: list[str] | None = None, timeout: int = 30) -> object:
    command = ["api", endpoint]
    command.extend(extra_args or [])
    return github_provider_client_json(github_operation_name(command), command, target, timeout=timeout, resource="core")


def github_rest_list_paginated(endpoint: str, target: Path, *, limit: int = 100, timeout: int = 30) -> list[dict]:
    if limit <= 0:
        return []
    per_page = 100
    separator = "&" if "?" in endpoint else "?"
    results: list[dict] = []
    page = 1
    max_pages = max(1, (limit + per_page - 1) // per_page + 5)
    while len(results) < limit and page <= max_pages:
        payload = github_rest_json(f"{endpoint}{separator}per_page={per_page}&page={page}", target, timeout=timeout)
        if not isinstance(payload, list):
            raise SystemExit(f"gh api {endpoint} returned unexpected JSON; expected an array")
        results.extend(item for item in payload if isinstance(item, dict))
        if len(payload) < per_page:
            break
        page += 1
    return results[:limit]


def github_issue_list_rest(repo: str, target: Path, *, state: str = "open", label: str = "", limit: int = 100) -> list[dict]:
    requested = max(0, int(limit or 100))
    if requested <= 0:
        return []
    endpoint = f"repos/{repo}/issues?state={quote(state)}"
    if label:
        endpoint += f"&labels={quote(label)}"
    payload = github_rest_list_paginated(endpoint, target, limit=requested + 100)
    issues = [item for item in payload if not item.get("pull_request")]
    return issues[:requested]


def github_issue_view_rest(repo: str, target: Path, issue_number: str) -> dict:
    issue = github_rest_json(f"repos/{repo}/issues/{issue_number}", target)
    comments = github_rest_list_paginated(f"repos/{repo}/issues/{issue_number}/comments", target, limit=500)
    if not isinstance(issue, dict):
        raise SystemExit("gh api issue view returned unexpected JSON")
    return {
        "number": issue.get("number"),
        "title": issue.get("title"),
        "url": issue.get("html_url") or issue.get("url"),
        "labels": issue.get("labels") if isinstance(issue.get("labels"), list) else [],
        "comments": [
            {
                "body": comment.get("body"),
                "url": comment.get("html_url") or comment.get("url"),
                "htmlUrl": comment.get("html_url") or comment.get("url"),
            }
            for comment in comments
            if isinstance(comment, dict)
        ],
    }


def github_issue_comment_rest(repo: str, target: Path, issue_number: str, body: str) -> str:
    with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as tmp:
        json.dump({"body": body}, tmp)
        tmp_path = Path(tmp.name)
    try:
        payload = github_rest_json(
            f"repos/{repo}/issues/{issue_number}/comments",
            target,
            extra_args=["--method", "POST", "--input", str(tmp_path)],
            timeout=30,
        )
    finally:
        try:
            tmp_path.unlink()
        except OSError:
            pass
    if not isinstance(payload, dict):
        return ""
    return str(payload.get("html_url") or payload.get("url") or "")


def goal_tracker_auth_issues(target: Path) -> list[str]:
    if shutil.which("gh") is None:
        return ["gh CLI missing; install GitHub CLI before using goalTracker provider github-projects"]
    code, out, err = github_provider_client_run("auth.status", ["auth", "status"], target, timeout=20, resource="core")
    text = "\n".join(part for part in [out, err] if part)
    if code != 0:
        return [f"gh auth status failed: {text or 'not authenticated'}"]
    scope_lines = [
        line
        for line in text.splitlines()
        if "token scopes" in line.lower() or re.search(r"\bscopes:\s*", line, flags=re.IGNORECASE)
    ]
    if not scope_lines:
        return ["gh auth status did not confirm `project` scope; run `gh auth refresh -s project`"]
    scope_tokens = {
        token.lower()
        for line in scope_lines
        for token in re.findall(r"[A-Za-z][A-Za-z0-9:_-]*", line)
    }
    if "project" not in scope_tokens:
        return ["gh auth token missing `project` scope; run `gh auth refresh -s project`"]
    return []


def goal_tracker_gh_query_issues(tracker: dict, target: Path) -> list[str]:
    if not tracker.get("scopeQuery"):
        return []
    code, out, err = run_command(["gh", "project", "item-list", "--help"], cwd=target, timeout=20)
    help_text = "\n".join(part for part in [out, err] if part)
    if code != 0:
        return [f"gh project item-list --help failed while validating goalTracker.scopeQuery: {help_text or 'no help output'}"]
    if "--query" not in help_text:
        return ["gh project item-list does not support `--query`; upgrade GitHub CLI or clear goalTracker.scopeQuery"]
    return []


def goal_tracker_project_fields(data: dict, target: Path) -> list[dict]:
    tracker = goal_tracker_config(data)
    payload = goal_tracker_gh_json(
        [
            "project",
            "field-list",
            str(tracker["projectNumber"]),
            "--owner",
            tracker["owner"],
            "--format",
            "json",
            "--limit",
            "100",
        ],
        target,
    )
    if not isinstance(payload, dict) or not isinstance(payload.get("fields"), list):
        raise SystemExit("gh project field-list returned unexpected JSON; expected {fields:[...]}")
    return [field for field in payload["fields"] if isinstance(field, dict)]


def goal_tracker_project_view(data: dict, target: Path) -> dict:
    tracker = goal_tracker_config(data)
    payload = goal_tracker_gh_json(
        [
            "project",
            "view",
            str(tracker["projectNumber"]),
            "--owner",
            tracker["owner"],
            "--format",
            "json",
        ],
        target,
    )
    if not isinstance(payload, dict):
        raise SystemExit("gh project view returned unexpected JSON")
    return payload


def goal_tracker_field_by_name(fields: list[dict], name: str) -> dict | None:
    wanted = normalized_field_key(name)
    for field in fields:
        if normalized_field_key(str(field.get("name", ""))) == wanted:
            return field
    return None


def goal_tracker_field_options(field: dict | None) -> list[dict]:
    if not field:
        return []
    options = field.get("options") or field.get("singleSelectOptions") or []
    return [option for option in options if isinstance(option, dict)]


def goal_tracker_option_by_name(field: dict, value: str) -> dict | None:
    wanted = value.strip().lower()
    for option in goal_tracker_field_options(field):
        if str(option.get("name", "")).strip().lower() == wanted:
            return option
    return None


def goal_tracker_status_issues(data: dict, target: Path) -> list[str]:
    tracker = goal_tracker_config(data)
    if not tracker["enabled"]:
        return []
    issues = goal_tracker_auth_issues(target)
    if issues:
        return issues
    issues.extend(goal_tracker_gh_query_issues(tracker, target))
    if issues:
        return issues
    try:
        fields = goal_tracker_project_fields(data, target)
    except SystemExit as exc:
        return [str(exc)]
    status_field = goal_tracker_field_by_name(fields, tracker["statusField"])
    if not status_field:
        issues.append(f"GitHub Project status field missing: {tracker['statusField']}")
    return issues


def board_schema_mismatch(fields: list, tracker: dict, type_field: str = "", epic_field: str = "", order_field: str = "") -> list[str]:
    """Pure: the adapter-declared statuses / optional fields the board does NOT have. This is a SCHEMA
    MISMATCH that routes to board-adopt (the adapter conforms to the board, never the reverse), NOT a
    hard error -- so a taxonomy drift can no longer fail the sanctioned status-write path closed and
    pressure a raw-gh bypass (operator decision + private lane RCA, 2026-06-25; a declared status
    the board lacks must not block writing a status it DOES have). Assumes the Status field is present
    -- auth and Status-field-absence are hard issues from goal_tracker_status_issues."""
    status_field = goal_tracker_field_by_name(fields, tracker.get("statusField", ""))
    if not status_field:
        return []
    mismatch: list[str] = []
    options = {str(option.get("name", "")).strip().lower() for option in goal_tracker_field_options(status_field)}
    if options:
        for status in goal_tracker_allowed_statuses(tracker):
            if status.lower() not in options:
                mismatch.append(f"GitHub Project status value missing from {tracker['statusField']}: {status}")
    for optional_key in ["priorityField", "milestoneField"]:
        field_name = tracker.get(optional_key)
        if field_name and not goal_tracker_field_by_name(fields, field_name):
            mismatch.append(f"GitHub Project {optional_key} missing: {field_name}")
    if type_field and not goal_tracker_field_by_name(fields, type_field):
        mismatch.append(f"GitHub Project typeField missing: {type_field}")
    if epic_field and not goal_tracker_field_by_name(fields, epic_field):
        mismatch.append(f"GitHub Project epicField missing: {epic_field}")
    if order_field and not goal_tracker_field_by_name(fields, order_field):
        mismatch.append(f"GitHub Project orderField missing: {order_field}")
    return mismatch


_BOARD_BUILTIN_FIELDS = {
    "title", "assignees", "labels", "linked pull requests", "milestone", "repository",
    "reviewers", "parent issue", "sub-issues progress", "created", "updated", "closed",
}


def board_profile(fields: list) -> dict:
    """Pure read-model of a board's schema for adoption: status columns (in order), all single-select
    fields, candidate priority/order signals, and custom (non-builtin) fields."""
    status_field = None
    columns: list[str] = []
    single_selects: dict[str, list[str]] = {}
    custom: list[str] = []
    priority_c: list[str] = []
    order_c: list[str] = []
    for field in fields or []:
        if not isinstance(field, dict):
            continue
        name = str(field.get("name") or "")
        low = name.strip().lower()
        opts = [str(o.get("name") or "") for o in (field.get("options") or []) if isinstance(o, dict)]
        if "singleselect" in str(field.get("type") or "").lower():
            single_selects[name] = opts
            if low == "status" and status_field is None:
                status_field, columns = name, opts
        if low not in _BOARD_BUILTIN_FIELDS:
            custom.append(name)
        if any(k in low for k in ("priority",)):
            priority_c.append(name)
        if any(k in low for k in ("order", "rank")):
            order_c.append(name)
    return {
        "statusField": status_field,
        "columns": columns,
        "singleSelects": single_selects,
        "priorityCandidates": priority_c,
        "orderCandidates": order_c,
        "customFields": custom,
        "schemaHash": board_schema_hash(fields or []),
    }


# Column-role keyword map: tuples of (match-keywords, role-label).  Tuple (not list) to avoid
# the policy-phrases SSOT auto-collection that fires on UPPER_CASE list-of-str constants.
_COLUMN_ROLE_KEYWORDS: tuple = (
    (("ready", "next"), "ready"),
    (("in progress", "active", "doing"), "active"),
    (("done", "closed", "shipped"), "done"),
    (("blocked",), "blocked"),
)


def _classify_column_role(name: str) -> str | None:
    """Return the role (ready/active/done/blocked) for a column name, or None if unrecognised."""
    low = name.strip().lower()
    for keywords, label in _COLUMN_ROLE_KEYWORDS:
        if low in keywords:
            return label
    return None


def board_assumptions(profile: dict) -> dict:
    """Pure renderer: given a board_profile dict, return plain-English assumptions (auto-classified
    parts) and the unknowns the operator must answer before an adopt can proceed."""
    statements: list[str] = []
    unknowns: list[str] = []

    status_field: str | None = profile.get("statusField")
    columns: list[str] = profile.get("columns") or []
    priority_c: list[str] = profile.get("priorityCandidates") or []
    order_c: list[str] = profile.get("orderCandidates") or []
    single_selects: dict[str, list[str]] = profile.get("singleSelects") or {}
    custom_fields: list[str] = profile.get("customFields") or []

    # 1. Column-role classification (case-insensitive exact match against known roles).
    for col in columns:
        role = _classify_column_role(col)
        if role:
            statements.append(f"Column '{col}' maps to role '{role}'.")
        else:
            unknowns.append(
                f"Which role does column '{col}' play (ready / active / done / blocked / backlog-icebox)?"
            )

    # 2. Priority/order signal — ask when there is at least one candidate but NOT exactly one
    #    priority field (ambiguous: 0 priority + ≥1 order, or >1 priority).
    if (len(priority_c) + len(order_c)) >= 1 and len(priority_c) != 1:
        unknowns.append(
            "How is the next item's priority/order decided (manual board order, or which field)?"
        )
    elif len(priority_c) == 1:
        statements.append(f"Priority is read from the '{priority_c[0]}' field.")
    elif order_c:
        statements.append(f"Item order is read from the '{order_c[0]}' field.")

    # 3. Custom single-select fields not yet classified (status field excluded — it IS classified).
    for field_name in custom_fields:
        if field_name == status_field:
            continue
        if field_name in single_selects:
            unknowns.append(f"How should the agent use custom field '{field_name}'?")

    # Guarantee statements is non-empty (required by spec even when all columns are unknown).
    if not statements:
        statements.append("No columns could be auto-classified; operator input required for all roles.")

    return {"statements": statements, "unknowns": unknowns}


def board_proposed_backlog_provider(
    profile: dict,
    current: dict,
    answers: dict | None = None,
) -> dict:
    """Propose a backlogProvider adapter block aligned to the board profile.

    Starts from a copy of current (preserving unrelated keys such as owner, projectNumber,
    itemTypes, linkPolicy) then overwrites the board-derived keys.  The schemaHash is carried
    forward from profile (board_profile stores it at construction time).
    """
    prop = dict(current)
    columns: list[str] = profile.get("columns") or []
    answers = answers or {}

    prop["statusField"] = profile.get("statusField")

    # Classify each column by role; collect into per-role lists (preserving column order).
    active: list[str] = []
    done: list[str] = []
    blocked: list[str] = []
    ready_auto: list[str] = []
    for col in columns:
        role = _classify_column_role(col)
        if role == "active":
            active.append(col)
        elif role == "done":
            done.append(col)
        elif role == "blocked":
            blocked.append(col)
        elif role == "ready":
            ready_auto.append(col)

    prop["activeStatuses"] = active
    prop["doneStatuses"] = done
    prop["blockedStatuses"] = blocked
    # readyStatuses: operator answer takes precedence; fall back to auto-classified.
    if "ready_column" in answers:
        prop["readyStatuses"] = [answers["ready_column"]]
    else:
        prop["readyStatuses"] = ready_auto

    # order/priority: set only when unambiguous; otherwise preserve whatever current carries.
    order_c: list[str] = profile.get("orderCandidates") or []
    priority_c: list[str] = profile.get("priorityCandidates") or []
    if order_c:
        prop["orderField"] = order_c[0]
    if len(priority_c) == 1:
        prop["priorityField"] = priority_c[0]

    # schemaHash carried from profile so raw fields are not re-fetched here.
    prop["schemaHash"] = profile.get("schemaHash")

    return prop


def board_answer_key(question: str) -> str:
    """Return the canonical answer-key for a board_assumptions unknown question.

    Priority: column role check first, then custom-field, then priority/order,
    then a slug fallback.  This order matters because role/custom questions
    contain neither 'priority' nor 'order'.
    """
    if "column '" in question:
        # Extract the name between "column '" and the next "'"
        start = question.index("column '") + len("column '")
        end = question.index("'", start)
        return f"role:{question[start:end]}"
    if "custom field '" in question:
        start = question.index("custom field '") + len("custom field '")
        end = question.index("'", start)
        return f"custom:{question[start:end]}"
    if "priorit" in question.lower() or "order" in question.lower():
        return "priority_order"
    # Slug fallback: lowercase, non-alphanumerics → underscore, truncate
    slug = re.sub(r"[^a-z0-9]+", "_", question.lower()).strip("_")
    return slug[:64]


def board_adopt_answers(raw: dict) -> dict:
    """Translate operator raw answers into the shape board_proposed_backlog_provider expects.

    Returns a dict containing everything in *raw*, plus ``ready_column`` set to
    the column name from any key ``role:<name>`` whose value (case-insensitive,
    stripped) equals ``"ready"``.  If no such key exists but *raw* already has
    ``ready_column``, that value is kept.
    """
    result = dict(raw)
    for key, val in raw.items():
        if key.startswith("role:") and str(val).strip().lower() == "ready":
            result["ready_column"] = key[len("role:"):]
            break
    return result


def goal_tracker_schema_mismatch_issues(data: dict, target: Path) -> list[str]:
    """Adapter-declared statuses/fields the board lacks, for surfacing as NON-blocking warnings that
    route to board-adopt. Empty when there is a hard issue (auth / Status field absent) -- those block
    on their own via goal_tracker_status_issues."""
    tracker = goal_tracker_config(data)
    if not tracker["enabled"]:
        return []
    if goal_tracker_status_issues(data, target):
        return []
    try:
        fields = goal_tracker_project_fields(data, target)
    except SystemExit:
        return []
    return board_schema_mismatch(
        fields,
        tracker,
        type_field=backlog_provider_config(data).get("typeField", ""),
        epic_field=board_epic_field(data),
        order_field=board_order_field(data),
    )


def goal_tracker_items(data: dict, target: Path, limit: int = 100) -> list[dict]:
    tracker = goal_tracker_config(data)
    return goal_tracker_items_for_scope(data, target, limit=limit, use_scope_query=True)


def goal_tracker_items_for_scope(data: dict, target: Path, limit: int = 100, use_scope_query: bool = True) -> list[dict]:
    tracker = goal_tracker_config(data)
    command = [
        "project",
        "item-list",
        str(tracker["projectNumber"]),
        "--owner",
        tracker["owner"],
        "--format",
        "json",
        "--limit",
        str(limit),
    ]
    if use_scope_query and tracker["scopeQuery"]:
        command.extend(["--query", tracker["scopeQuery"]])
    payload = goal_tracker_gh_json(command, target)
    if not isinstance(payload, dict) or not isinstance(payload.get("items"), list):
        raise SystemExit("gh project item-list returned unexpected JSON; expected {items:[...]}")
    return [item for item in payload["items"] if isinstance(item, dict)]


def board_work_order(data: dict) -> str:
    """Whether the next workable item is chosen by the board's physical top-to-bottom ordering
    ('board', the default) or by the priority field ('priority'). When provider/goalTracker is
    backed by GitHub Projects, the human's manual ordering of the board IS the precise order of
    work — the priority tag does not override it unless the product explicitly opts into
    'priority'."""
    provider = backlog_provider_config(data)
    tracker = goal_tracker_config(data)
    raw = str((provider.get("workOrder") if provider.get("enabled") else tracker.get("workOrder")) or "board").strip().lower()
    return raw if raw in {"board", "priority"} else "board"


def goal_tracker_next_sort_key(work_order: str, status_index: int, priority_rank: int, order_value: float, position: int) -> tuple:
    """Pure: 'board' work order selects by the operator's board order (an explicit numeric order
    field when configured, else the order GitHub returns the project's items), with list position
    as the stable tiebreak; 'priority' selects by ready-status group then priority tag then
    position."""
    if work_order == "board":
        return (order_value, position)
    return (status_index, priority_rank, position)


def board_epic_field(data: dict) -> str:
    provider = backlog_provider_config(data)
    tracker = goal_tracker_config(data)
    return str((provider.get("epicField") if provider.get("enabled") else tracker.get("epicField")) or "").strip()


def board_feature_series(data: dict) -> dict:
    """The configured feature-numbering series {field, pattern} (or {} when unset). Follows the
    same provider-if-enabled-else-tracker selection as board_epic_field. 'field' names the board
    field holding a feature number; 'pattern' (optional) constrains valid numbers."""
    provider = backlog_provider_config(data)
    tracker = goal_tracker_config(data)
    series = provider.get("featureSeries") if provider.get("enabled") else tracker.get("featureSeries")
    return series if isinstance(series, dict) else {}


def board_order_field(data: dict) -> str:
    provider = backlog_provider_config(data)
    tracker = goal_tracker_config(data)
    return str((provider.get("orderField") if provider.get("enabled") else tracker.get("orderField")) or "").strip()


def board_order_value(text: str) -> float:
    """Parse an explicit board order field value to a sortable number. Blank/non-numeric sorts
    last so unranked items fall below explicitly-ordered ones."""
    match = re.search(r"-?\d+(?:\.\d+)?", str(text or ""))
    return float(match.group(0)) if match else float("inf")


def goal_tracker_board_position_map(data: dict, target: Path) -> dict[str, int]:
    """Authoritative board (drag) order. GitHub exposes the manual board position ONLY through the
    items connection `orderBy: {field: POSITION}` — there is no readable position field on a
    ProjectV2Item, and `gh project item-list` does not request POSITION ordering. So query it
    directly via GraphQL and return {item_id: board_index}. Empty dict when unavailable (callers
    fall back to the item-list order)."""
    project = goal_tracker_project_view(data, target)
    project_id = goal_tracker_value_text(project.get("id"))
    if not project_id:
        return {}
    query = (
        "query($id:ID!){node(id:$id){... on ProjectV2{items(first:100,"
        "orderBy:{field:POSITION,direction:ASC}){nodes{id}}}}}"
    )
    try:
        payload = goal_tracker_gh_json(["api", "graphql", "-f", f"query={query}", "-f", f"id={project_id}"], target)
    except SystemExit:
        return {}
    container = payload.get("data", payload) if isinstance(payload, dict) else {}
    node = container.get("node") if isinstance(container, dict) else None
    nodes = (((node or {}).get("items") or {}).get("nodes")) if isinstance(node, dict) else None
    if not isinstance(nodes, list):
        return {}
    return {
        goal_tracker_value_text(entry.get("id")): index
        for index, entry in enumerate(nodes)
        if isinstance(entry, dict) and goal_tracker_value_text(entry.get("id"))
    }


def lane_assigned_epics(args: argparse.Namespace) -> list[str]:
    """Epics this lane is assigned to work, from --epic flags (repeatable) or the
    MINERVIT_LANE_EPICS env var (comma-separated). Empty means no epic is assigned, in which case
    selection falls back to the top of the board and says so."""
    raw: list[str] = []
    for value in getattr(args, "epic", None) or []:
        raw.extend(str(value).split(","))
    if not any(part.strip() for part in raw):
        raw.extend(util_module().resolve_env("MINERVIT_LANE_EPICS", "").split(","))
    seen: set[str] = set()
    ordered: list[str] = []
    for part in raw:
        name = part.strip()
        if name and name.lower() not in seen:
            seen.add(name.lower())
            ordered.append(name)
    return ordered


def print_board_selection_mode(data: dict, args: argparse.Namespace, prefix: str) -> list[str]:
    """Print how the next item is chosen (board vs priority order) and which epics this lane is
    assigned. With no epic assigned, the framework states that and defaults to the top of the
    board stack."""
    epics = lane_assigned_epics(args)
    work_order = board_work_order(data)
    order_field = board_order_field(data)
    if work_order == "board":
        source = f"orderField={order_field}" if order_field else "github-item-order (arrange the project's item order)"
        print(f"{prefix}_work_order: board ({source})")
    else:
        print(f"{prefix}_work_order: {work_order}")
    if epics:
        print(f"{prefix}_assigned_epics: {', '.join(epics)} (selecting the top board item within these epics)")
        print(
            f"{prefix}_epic_scope: board items outside the assigned epic(s) are out of scope - do not consider, "
            "recommend, rank, or pull them, even when ordering is set aside, without explicit operator direction "
            "that names the epic or lifts the scope"
        )
    else:
        print(f"{prefix}_assigned_epics: none - no epic assigned; defaulting to the top of the board stack")
    return epics


def goal_tracker_select_next_item(data: dict, target: Path, item_type: str = "", epics: list[str] | None = None) -> dict | None:
    tracker = goal_tracker_config(data)
    provider = backlog_provider_config(data)
    work_order = board_work_order(data)
    item_type = item_type.strip().lower()
    if item_type and item_type not in provider.get("itemTypes", []):
        raise SystemExit(f"backlogProvider.itemTypes does not allow item type: {item_type}")
    if item_type and not provider.get("typeField"):
        raise SystemExit("backlogProvider.typeField must be configured to filter GitHub Project items by type")
    epic_set = {epic.strip().lower() for epic in (epics or []) if epic.strip()}
    epic_field = board_epic_field(data)
    if epic_set and not epic_field:
        raise SystemExit("an epic is assigned to this lane but backlogProvider.epicField is not configured; set epicField or clear the lane epic assignment")
    order_field = board_order_field(data)
    # Board work order without an explicit orderField uses GitHub's authoritative POSITION order
    # (the operator's drag-order), not the undocumented item-list order.
    position_map = goal_tracker_board_position_map(data, target) if (work_order == "board" and not order_field) else {}
    ready = {status.lower() for status in tracker["readyStatuses"]}
    status_order = {status.lower(): index for index, status in enumerate(tracker["readyStatuses"])}
    items = []
    for position, item in enumerate(goal_tracker_items(data, target)):
        status = goal_tracker_item_field(item, tracker["statusField"])
        if status.lower() not in ready:
            continue
        if epic_set:
            item_epic = goal_tracker_item_field(item, epic_field).strip().lower()
            if item_epic not in epic_set:
                continue
        if item_type:
            actual_type = goal_tracker_item_field(item, provider["typeField"]).strip().lower()
            if actual_type != item_type:
                continue
        priority = goal_tracker_item_field(item, tracker["priorityField"]) if tracker["priorityField"] else ""
        if order_field:
            order_value = board_order_value(goal_tracker_item_field(item, order_field))
        elif position_map:
            order_value = float(position_map.get(goal_tracker_item_id(item), len(position_map) + position))
        else:
            order_value = float(position)
        items.append(
            (
                status_order.get(status.lower(), 999),
                goal_tracker_priority_rank(priority),
                order_value,
                position,
                item,
            )
        )
    if not items:
        return None
    return sorted(items, key=lambda row: goal_tracker_next_sort_key(work_order, row[0], row[1], row[2], row[3]))[0][4]


def print_goal_tracker_item(data: dict, item: dict, prefix: str = "goal_tracker_next") -> None:
    tracker = goal_tracker_config(data)
    provider = backlog_provider_config(data)
    status = goal_tracker_item_field(item, tracker["statusField"])
    priority = goal_tracker_item_field(item, tracker["priorityField"]) if tracker["priorityField"] else ""
    milestone = goal_tracker_item_field(item, tracker["milestoneField"]) if tracker["milestoneField"] else ""
    item_type = goal_tracker_item_field(item, provider["typeField"]) if provider.get("typeField") else ""
    print(f"{prefix}_item_id: {goal_tracker_item_id(item)}")
    print(f"{prefix}_title: {goal_tracker_item_title(item)}")
    print(f"{prefix}_url: {goal_tracker_item_url(item) or 'none'}")
    print(f"{prefix}_type: {item_type or 'none'}")
    print(f"{prefix}_status: {status or 'none'}")
    print(f"{prefix}_priority: {priority or 'none'}")
    print(f"{prefix}_milestone: {milestone or 'none'}")


def resolve_goal_tracker_item(data: dict, target: Path, item_ref: str) -> dict:
    for item in goal_tracker_items(data, target):
        if goal_tracker_item_matches(item, item_ref):
            return item
    if goal_tracker_config(data).get("scopeQuery"):
        for item in goal_tracker_items_for_scope(data, target, use_scope_query=False):
            if goal_tracker_item_matches(item, item_ref):
                return item
    raise SystemExit(f"GitHub Project item not found in configured query/list: {item_ref}")


def goal_tracker_plan_path(data: dict, target: Path, item: dict) -> Path:
    title = goal_tracker_item_title(item)
    item_id = slugify(goal_tracker_item_id(item), "item")
    default_slug = "github-project-item" if provider_item_completion_enabled(data) else "github-project-goal"
    slug = slugify(title, default_slug)
    suffix = f"-{item_id}" if item_id and item_id != "item" and item_id not in slug else ""
    return goal_source_root(data, target) / f"{slug}{suffix}.md"


def goal_tracker_source_body(data: dict, item: dict) -> str:
    tracker = goal_tracker_config(data)
    status = goal_tracker_item_field(item, tracker["statusField"])
    priority = goal_tracker_item_field(item, tracker["priorityField"]) if tracker["priorityField"] else ""
    milestone = goal_tracker_item_field(item, tracker["milestoneField"]) if tracker["milestoneField"] else ""
    lines = [
        "- provider: github-projects",
        f"- owner: {tracker['owner']}",
        f"- project_number: {tracker['projectNumber']}",
        f"- project_item_id: {goal_tracker_item_id(item)}",
        f"- project_item_url: {goal_tracker_item_url(item) or 'none'}",
        f"- status: {status or 'none'}",
        f"- priority: {priority or 'none'}",
        f"- milestone: {milestone or 'none'}",
        f"- authoritative_for: {', '.join(tracker['authoritativeFor'])}",
        f"- link_policy: {tracker['linkPolicy']}",
        f"- synced_at: {now_iso()}",
    ]
    return "\n".join(lines)


def goal_tracker_project_guidance_body(item: dict) -> str:
    body = goal_tracker_item_body(item)
    if body:
        return body.strip()
    return "No item body or guidance was returned by GitHub Projects. Add business/user guidance before treating this goal as execution-ready."


def goal_tracker_new_plan_text(data: dict, item: dict) -> str:
    title = goal_tracker_item_title(item)
    tracker = goal_tracker_config(data)
    milestone = goal_tracker_item_field(item, tracker["milestoneField"]) if tracker["milestoneField"] else ""
    first_milestone = milestone or title
    if provider_item_completion_enabled(data):
        return (
            f"# {title}\n\n"
            "## GitHub Project Source\n\n"
            f"{goal_tracker_source_body(data, item)}\n\n"
            "## What This Delivers\n\n"
            f"Deliver the outcome described by the linked GitHub issue/Project item: {title}.\n\n"
            "## Why It Matters\n\n"
            "Preserve or translate the stakeholder-facing value from the GitHub issue before execution.\n\n"
            "## Acceptance Criteria\n\n"
            "Copy or refine the issue acceptance criteria during reviewed planning before tactical implementation starts.\n\n"
            "## Non-Goals\n\n"
            "- Add non-goals during reviewed planning.\n\n"
            "## Milestones\n\n"
            f"- [ ] {first_milestone}\n\n"
            "## Dependencies And Safe Parallelism\n\n"
            "- GitHub Projects controls priority, status, and completion state for this work item.\n"
            "- Repo source-of-truth plans control implementation detail and review evidence.\n\n"
            "## Risks And True Blockers\n\n"
            "- Unknowns from the GitHub issue/Project item must be resolved or marked as true blockers before execution.\n\n"
            "## Review Gates\n\n"
            "- Cross-model plan review is required before execution unless the adapter declares a valid exemption.\n"
            "- Tactical PR plans still require the normal plan-review and implementation-review gates.\n\n"
            "## Verification\n\n"
            "- The GitHub issue/Project item remains current through planning, PR, milestone, blocked, and closeout boundaries.\n"
            "- Required validation evidence and UI evidence are posted to the linked GitHub issue when configured.\n"
            "- Required product/project validation evidence proves the acceptance criteria.\n\n"
            "## Completion Criteria\n\n"
            "- The linked GitHub issue/work item acceptance criteria are complete.\n"
            "- No Critical/P1 blockers remain.\n"
            "- The GitHub Project item, issue comments/body, and repo continuity/session evidence agree on final state.\n\n"
            "## Project Guidance\n\n"
            f"{goal_tracker_project_guidance_body(item)}\n"
        )
    return (
        f"# {title}\n\n"
        "## GitHub Project Source\n\n"
        f"{goal_tracker_source_body(data, item)}\n\n"
        "## Desired Outcome\n\n"
        f"Deliver the outcome described by the linked GitHub Project item: {title}.\n\n"
        "## User / Business Benefit\n\n"
        "Translate the GitHub Project item guidance into a concrete user, operator, business, or delivery benefit before execution.\n\n"
        "## Success Condition\n\n"
        "Define the measurable success condition during reviewed goal planning before tactical implementation starts.\n\n"
        "## Non-Goals\n\n"
        "- Add non-goals during reviewed goal planning.\n\n"
        "## Milestones\n\n"
        f"- [ ] {first_milestone}\n\n"
        "## Dependencies And Safe Parallelism\n\n"
        "- GitHub Projects controls big-picture priority and status for this goal.\n"
        "- Repo source-of-truth goal and tactical PR plans control implementation details.\n\n"
        "## Risks And True Blockers\n\n"
        "- Unknowns from the GitHub Project item must be resolved or marked as true blockers before execution.\n\n"
        "## Review Gates\n\n"
        "- Cross-model goal-plan review is required before execution unless the adapter declares a valid exemption.\n"
        "- Tactical PR plans still require the normal plan-review and implementation-review gates.\n\n"
        "## Validation Proof Required For Completion\n\n"
        "- Goal ledger reports complete or ready for completion.\n"
        "- Required product/project validation evidence is attached to the milestone or goal summary.\n"
        "- GitHub Project status is updated through `tautline goal-tracker-update` when appropriate.\n\n"
        "## Completion Criteria\n\n"
        "- All reviewed milestones are complete or policy-deferred.\n"
        "- No Critical/P1 blockers remain.\n"
        "- The GitHub Project item and repo continuity/session evidence agree on final state.\n\n"
        "## Project Guidance\n\n"
        f"{goal_tracker_project_guidance_body(item)}\n"
    )


def goal_tracker_sync_text(data: dict, target: Path, item: dict, path: Path) -> str:
    if not path.exists():
        return goal_tracker_new_plan_text(data, item)
    text = path.read_text(encoding="utf-8", errors="replace")
    if not text.lstrip().startswith("#"):
        text = f"# {goal_tracker_item_title(item)}\n\n{text.strip()}\n"
    text = replace_markdown_section(text, "## GitHub Project Source", goal_tracker_source_body(data, item))
    text = replace_markdown_section(text, "## Project Guidance", goal_tracker_project_guidance_body(item))
    return text


def goal_tracker_status(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    tracker = goal_tracker_config(data)
    print(goal_tracker_status_summary(data))
    if not tracker["enabled"]:
        print("goal_tracker_status: disabled")
        return 0
    issues = goal_tracker_status_issues(data, target)
    if issues:
        for issue in issues:
            print(f"goal_tracker_issue: {issue}")
        print("goal_tracker_status: blocked")
        return 1
    fields = goal_tracker_project_fields(data, target)
    print(f"goal_tracker_status: ok")
    print(f"goal_tracker_fields: {', '.join(str(field.get('name')) for field in fields if field.get('name'))}")
    print("goal_tracker_next_action: run `tautline goal-tracker-next --target .` when no active goal ledger exists")
    return 0


def goal_tracker_next(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    tracker = goal_tracker_config(data)
    if not tracker["enabled"]:
        print("goal_tracker_next: disabled")
        return 0
    issues = goal_tracker_status_issues(data, target)
    if issues:
        for issue in issues:
            print(f"goal_tracker_issue: {issue}", file=sys.stderr)
        return 1
    epics = print_board_selection_mode(data, args, "goal_tracker_next")
    item = goal_tracker_select_next_item(data, target, epics=epics)
    if item is None:
        print("goal_tracker_next: none")
        scope = f" within assigned epics: {', '.join(epics)}" if epics else ""
        print(f"goal_tracker_next_action: no ready GitHub Project goal item matched the adapter query/status mapping{scope}")
        return 1
    print("goal_tracker_next: found")
    print_goal_tracker_item(data, item)
    print(
        "goal_tracker_next_action: run `tautline goal-tracker-sync --target . "
        f"--item {shlex.quote(goal_tracker_item_id(item) or goal_tracker_item_url(item) or goal_tracker_item_title(item))} --write`, "
        "to claim the board item active, then review the repo goal plan before goal-start"
    )
    return 0


def goal_tracker_sync_claim_active(
    data: dict,
    target: Path,
    item_ref: str,
    item: dict,
    prefix: str,
    write: bool,
) -> None:
    tracker = goal_tracker_config(data)
    active_status = goal_tracker_primary_status(tracker, "activeStatuses")
    print(f"{prefix}_active_status: {active_status}")
    if not write:
        print(f"{prefix}_active_claim: dry_run")
        return
    outcome = goal_tracker_claim_single_item_active(data, target, item_ref, item, prefix, active_status)
    if outcome not in {"ok", "already_active"}:
        return
    goal_tracker_claim_board_backed_subtasks_active(data, target, item, item_ref, prefix, active_status)
    goal_tracker_claim_parent_items_active_for_subtask(data, target, item, item_ref, active_status, context=prefix, prefix=prefix)


def goal_tracker_claim_single_item_active(
    data: dict,
    target: Path,
    item_ref: str,
    item: dict,
    prefix: str,
    active_status: str,
) -> str:
    tracker = goal_tracker_config(data)
    current_status = goal_tracker_item_field(item, tracker["statusField"])
    print(f"{prefix}_active_current_status: {current_status or 'none'}")
    if goal_tracker_status_matches(current_status, list(tracker.get("activeStatuses") or [])):
        print(f"{prefix}_active_claim: already_active")
        print(f"{prefix}_active_item_ref: {item_ref}")
        item_id = goal_tracker_item_id(item)
        if item_id:
            print(f"{prefix}_active_item_id: {item_id}")
        return "already_active"
    terminal_or_blocked = list(tracker.get("doneStatuses") or []) + list(tracker.get("blockedStatuses") or [])
    if goal_tracker_status_matches(current_status, terminal_or_blocked):
        print(f"{prefix}_active_claim: skipped_terminal_status")
        print(f"{prefix}_active_item_ref: {item_ref}")
        return "skipped_terminal_status"
    try:
        content_state = board_item_fetch_state(item, target)
    except SystemExit as exc:
        raise SystemExit(f"cannot verify GitHub Project item state before active claim: {exc}") from exc
    if content_state and content_state != "OPEN":
        print(f"{prefix}_active_claim: skipped_non_open_state")
        print(f"{prefix}_active_item_ref: {item_ref}")
        print(f"{prefix}_active_item_state: {content_state}")
        return "skipped_non_open_state"
    result = goal_tracker_update_status(data, target, item_ref, active_status)
    print(f"{prefix}_active_claim: ok")
    print(f"{prefix}_active_item_ref: {item_ref}")
    print(f"{prefix}_active_item_id: {result['item_id']}")
    if result.get("output"):
        print(f"{prefix}_active_output: {result['output']}")
    return "ok"


def goal_tracker_claim_board_backed_subtasks_active(
    data: dict,
    target: Path,
    parent_item: dict,
    parent_ref: str,
    prefix: str,
    active_status: str,
) -> None:
    subtasks = goal_tracker_board_backed_subtasks(data, target, parent_item, parent_ref, prefix, "active")
    for subtask in subtasks:
        subtask_ref = goal_tracker_subtask_ref(subtask)
        if not subtask_ref:
            print(f"{prefix}_subtask_active_warn: board-backed subtask has no stable item reference")
            continue
        print(f"{prefix}_subtask_active_parent_ref: {parent_ref}")
        goal_tracker_claim_single_item_active(data, target, subtask_ref, subtask, f"{prefix}_subtask", active_status)


def goal_tracker_subtask_ref(item: dict) -> str:
    return (
        goal_tracker_item_id(item)
        or goal_tracker_item_content_url(item)
        or goal_tracker_item_url(item)
        or goal_tracker_item_title(item)
    )


def goal_tracker_board_backed_subtasks(
    data: dict,
    target: Path,
    parent_item: dict,
    parent_ref: str,
    prefix: str,
    action: str,
    *,
    require: bool = False,
) -> list[dict]:
    content = parent_item.get("content") if isinstance(parent_item.get("content"), dict) else {}
    if not goal_tracker_value_text(content.get("id")):
        return []
    try:
        items = goal_tracker_items(data, target, limit=400)
    except SystemExit as exc:
        message = f"board item list unavailable; subtask {action} status not updated: {exc}"
        if require:
            raise SystemExit(f"board-backed subtask {action} status could not be verified for {parent_ref}: {message}") from exc
        print(f"{prefix}_subtask_{action}_warn: {message}")
        return []
    subtasks, unavailable, unmatched = board_backed_subtask_items(parent_item, items, target)
    if unavailable:
        message = f"native sub-issue enumeration unavailable for {parent_ref}; board-backed subtask {action} status not updated"
        if require:
            raise SystemExit(f"board-backed subtask {action} status could not be verified for {parent_ref}: {message}")
        print(f"{prefix}_subtask_{action}_warn: {message}")
        return []
    if unmatched:
        shown = ", ".join(unmatched[:5])
        suffix = " ..." if len(unmatched) > 5 else ""
        message = (
            f"native sub-issue(s) are not board items for {parent_ref}: {shown}{suffix}; "
            f"subtask {action} status cannot be updated"
        )
        if require:
            raise SystemExit(f"board-backed subtask {action} status could not be verified for {parent_ref}: {message}")
        print(f"{prefix}_subtask_{action}_warn: {message}")
    return subtasks


def goal_tracker_preflight_done_subtasks(
    data: dict,
    target: Path,
    parent_item: dict,
    parent_ref: str,
    status: str,
    context: str,
    prefix: str,
) -> list[dict]:
    subtasks = goal_tracker_board_backed_subtasks(
        data, target, parent_item, parent_ref, prefix, "done", require=True
    )
    tracker = goal_tracker_config(data)
    for subtask in subtasks:
        subtask_ref = goal_tracker_subtask_ref(subtask)
        if not subtask_ref:
            raise SystemExit(f"{context} cannot move {parent_ref} to {status}: board-backed subtask has no stable item reference")
        current_status = goal_tracker_item_field(subtask, tracker["statusField"])
        print(f"{prefix}_subtask_done_current_status: {current_status or 'none'}")
        try:
            content_state = board_item_fetch_state(subtask, target)
        except SystemExit as exc:
            raise SystemExit(
                f"{context} cannot move {parent_ref} to {status}: board-backed subtask {subtask_ref} state could not be verified: {exc}"
            ) from exc
        if content_state not in {"CLOSED", "MERGED"}:
            raise SystemExit(
                f"{context} cannot move {parent_ref} to {status}: board-backed subtask {subtask_ref} "
                f"is {content_state or 'unknown'}, not closed/merged; finish or close the subtask before closing the parent"
            )
    return subtasks


def goal_tracker_update_board_backed_subtasks_status(
    data: dict,
    target: Path,
    parent_item: dict,
    parent_ref: str,
    status: str,
    *,
    evidence: str = "",
    context: str = "goal-tracker-update",
    prefix: str = "goal_tracker_update",
    subtasks: list[dict] | None = None,
) -> None:
    tracker = goal_tracker_config(data)
    if subtasks is None:
        subtasks = goal_tracker_board_backed_subtasks(data, target, parent_item, parent_ref, prefix, "status")
    done = goal_tracker_status_matches(status, goal_tracker_done_statuses(data))
    done_statuses = list(tracker.get("doneStatuses") or [])
    blocked_statuses = list(tracker.get("blockedStatuses") or [])
    target_is_active = goal_tracker_status_matches(status, list(tracker.get("activeStatuses") or []))
    target_is_blocked = goal_tracker_status_matches(status, blocked_statuses)
    for subtask in subtasks:
        subtask_ref = goal_tracker_subtask_ref(subtask)
        if not subtask_ref:
            message = "board-backed subtask has no stable item reference"
            if done:
                raise SystemExit(f"{context} could not update done status for {parent_ref}: {message}")
            print(f"{prefix}_subtask_status_warn: {message}")
            continue
        current_status = goal_tracker_item_field(subtask, tracker["statusField"])
        print(f"{prefix}_subtask_status_parent_ref: {parent_ref}")
        print(f"{prefix}_subtask_status_current_status: {current_status or 'none'}")
        if goal_tracker_status_matches(current_status, [status]):
            print(f"{prefix}_subtask_status_update: already_{status}")
            print(f"{prefix}_subtask_status_item_ref: {subtask_ref}")
            continue
        if goal_tracker_status_matches(current_status, done_statuses):
            print(f"{prefix}_subtask_status_update: skipped_terminal_status")
            print(f"{prefix}_subtask_status_item_ref: {subtask_ref}")
            continue
        if target_is_active and goal_tracker_status_matches(current_status, blocked_statuses):
            print(f"{prefix}_subtask_status_update: skipped_terminal_status")
            print(f"{prefix}_subtask_status_item_ref: {subtask_ref}")
            continue
        try:
            content_state = board_item_fetch_state(subtask, target)
        except SystemExit as exc:
            raise SystemExit(f"{context} could not verify board-backed subtask {subtask_ref} state before status update: {exc}") from exc
        if done:
            if content_state not in {"CLOSED", "MERGED"}:
                raise SystemExit(
                    f"{context} cannot move {parent_ref} to {status}: board-backed subtask {subtask_ref} "
                    f"is {content_state or 'unknown'}, not closed/merged; finish or close the subtask before closing the parent"
                )
            comment_url = goal_tracker_post_done_evidence(data, target, subtask, evidence, f"{context} subtask")
            if comment_url:
                print(f"{prefix}_subtask_status_verification_comment: {comment_url}")
        elif content_state and content_state != "OPEN":
            print(f"{prefix}_subtask_status_update: skipped_non_open_state")
            print(f"{prefix}_subtask_status_item_ref: {subtask_ref}")
            print(f"{prefix}_subtask_status_item_state: {content_state}")
            continue
        elif target_is_blocked and not blocked_statuses:
            print(f"{prefix}_subtask_status_update: skipped_no_blocked_status")
            print(f"{prefix}_subtask_status_item_ref: {subtask_ref}")
            continue
        result = goal_tracker_update_status(data, target, subtask_ref, status)
        print(f"{prefix}_subtask_status_update: ok")
        print(f"{prefix}_subtask_status_item_ref: {subtask_ref}")
        print(f"{prefix}_subtask_status_item_id: {result['item_id']}")
        if result.get("output"):
            print(f"{prefix}_subtask_status_output: {result['output']}")


def goal_tracker_claim_parent_items_active_for_subtask(
    data: dict,
    target: Path,
    subtask_item: dict,
    subtask_ref: str,
    active_status: str,
    *,
    context: str = "goal-tracker-update",
    prefix: str = "goal_tracker_update",
) -> None:
    tracker = goal_tracker_config(data)
    subtask_keys = board_item_ref_values(subtask_item)
    if not subtask_keys:
        print(f"{prefix}_parent_active_warn: subtask has no strong issue reference")
        return
    try:
        items = goal_tracker_items(data, target, limit=400)
    except SystemExit as exc:
        print(f"{prefix}_parent_active_warn: board item list unavailable; parent status not updated: {exc}")
        return
    for parent in items:
        parent_ref = goal_tracker_subtask_ref(parent)
        if not parent_ref or parent_ref == subtask_ref:
            continue
        content = parent.get("content") if isinstance(parent.get("content"), dict) else {}
        if not goal_tracker_value_text(content.get("id")):
            continue
        sub_issues = grooming_sub_issues(parent, target)
        if sub_issues == GROOMING_SUBISSUES_UNAVAILABLE:
            continue
        parent_matches_child = False
        for sub_issue in sub_issues:
            if not isinstance(sub_issue, dict):
                continue
            sub_issue_keys = {
                value.strip()
                for value in [
                    goal_tracker_value_text(sub_issue.get("id")),
                    goal_tracker_value_text(sub_issue.get("url")),
                ]
                if value and value.strip()
            }
            if subtask_keys.intersection(sub_issue_keys):
                parent_matches_child = True
                break
        if not parent_matches_child:
            continue
        print(f"{prefix}_parent_active_subtask_ref: {subtask_ref}")
        print(f"{prefix}_parent_active_parent_ref: {parent_ref}")
        outcome = goal_tracker_claim_single_item_active(data, target, parent_ref, parent, f"{prefix}_parent", active_status)
        if outcome in {"ok", "already_active"}:
            continue
        if outcome in {"skipped_terminal_status", "skipped_non_open_state"}:
            print(f"{prefix}_parent_active_skip: {outcome}")
            continue
        current_status = goal_tracker_item_field(parent, tracker["statusField"])
        print(
            f"{prefix}_parent_active_warn: {context} left parent {parent_ref} at "
            f"{current_status or 'none'} because active claim returned {outcome}"
        )


def goal_tracker_claim_plan_active(data: dict, target: Path, plan_path: Path, prefix: str) -> None:
    tracker = goal_tracker_config(data)
    provider = backlog_provider_config(data)
    if not (tracker.get("enabled") or provider.get("enabled")):
        return
    item_ref = goal_tracker_ref_from_plan(plan_path)
    if not item_ref:
        print(f"{prefix}_active_claim: no_project_source")
        return
    issues = goal_tracker_status_issues(data, target)
    if issues:
        raise SystemExit("; ".join(issues))
    item = resolve_goal_tracker_item(data, target, item_ref)
    goal_tracker_sync_claim_active(data, target, item_ref, item, prefix, True)


def goal_tracker_sync(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    tracker = goal_tracker_config(data)
    if not tracker["enabled"]:
        raise SystemExit("goalTracker is disabled in this project adapter")
    issues = goal_tracker_status_issues(data, target)
    if issues:
        raise SystemExit("; ".join(issues))
    item = resolve_goal_tracker_item(data, target, args.item)
    item_ref = goal_tracker_item_id(item) or goal_tracker_item_url(item) or goal_tracker_item_title(item)
    path = goal_tracker_plan_path(data, target, item)
    text = goal_tracker_sync_text(data, target, item, path)
    print("goal_tracker_sync: ready")
    print_goal_tracker_item(data, item, "goal_tracker_sync")
    print(f"goal_tracker_sync_plan: {path_display(target, path)}")
    if args.write:
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(text, encoding="utf-8")
        print(f"goal_tracker_sync_written: {path}")
    else:
        print("goal_tracker_sync_write: false")
    goal_tracker_sync_claim_active(data, target, item_ref, item, "goal_tracker_sync", bool(args.write))
    print("goal_tracker_sync_next_action: run cross-model review for the repo goal plan before goal-start; do not execute directly from the GitHub Project item")
    return 0


def goal_tracker_update(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    evidence = backlog_provider_done_evidence_text(args, target)
    result, comment_url = goal_tracker_update_status_with_done_evidence(
        data,
        target,
        args.item,
        args.status,
        evidence=evidence,
        context="goal-tracker-update",
    )
    if comment_url:
        print(f"goal_tracker_update_verification_comment: {comment_url}")
    print("goal_tracker_update: ok")
    print(f"goal_tracker_update_item_id: {result['item_id']}")
    print(f"goal_tracker_update_status: {result['status']}")
    if result.get("output"):
        print(f"goal_tracker_update_output: {result['output']}")
    return 0


def goal_tracker_update_status(data: dict, target: Path, item_ref: str, status: str) -> dict:
    tracker = goal_tracker_config(data)
    if not tracker["enabled"]:
        raise SystemExit("goalTracker is disabled in this project adapter")
    status = status.strip()
    allowed = {value.lower() for value in goal_tracker_allowed_statuses(tracker)}
    if status.lower() not in allowed:
        raise SystemExit(f"status is not adapter-approved for goalTracker: {status}")
    issues = goal_tracker_status_issues(data, target)
    if issues:
        raise SystemExit("; ".join(issues))
    item = resolve_goal_tracker_item(data, target, item_ref)
    fields = goal_tracker_project_fields(data, target)
    status_field = goal_tracker_field_by_name(fields, tracker["statusField"])
    if not status_field or not status_field.get("id"):
        raise SystemExit(f"GitHub Project status field has no id: {tracker['statusField']}")
    option = goal_tracker_option_by_name(status_field, status)
    if not option or not option.get("id"):
        raise SystemExit(f"GitHub Project status option has no id: {status}")
    project = goal_tracker_project_view(data, target)
    project_id = goal_tracker_value_text(project.get("id"))
    if not project_id:
        raise SystemExit("gh project view did not return a project id")
    item_id = goal_tracker_item_id(item)
    if not item_id:
        raise SystemExit("GitHub Project item has no id")
    code, out, err = run_command(
        [
            "gh",
            "project",
            "item-edit",
            "--id",
            item_id,
            "--project-id",
            project_id,
            "--field-id",
            str(status_field["id"]),
            "--single-select-option-id",
            str(option["id"]),
        ],
        cwd=target,
        timeout=30,
    )
    if code != 0:
        raise SystemExit(err or out or "gh project item-edit failed")
    github_cache_invalidate_project()
    return {"item_id": item_id, "status": status, "output": out}


def goal_tracker_done_statuses(data: dict) -> list[str]:
    provider = backlog_provider_config(data)
    tracker = goal_tracker_config(data)
    return list(provider.get("doneStatuses") or tracker.get("doneStatuses") or [])


def goal_tracker_item_issue_number(item: dict) -> str:
    for url in [goal_tracker_item_content_url(item), goal_tracker_item_url(item)]:
        issue_number = github_issue_number_from_url(url)
        if issue_number:
            return issue_number
    return ""


def goal_tracker_post_done_evidence(data: dict, target: Path, item: dict, evidence: str, context: str) -> str:
    evidence = evidence.strip()
    if not evidence:
        raise SystemExit(
            f"{context} to a done status requires verification evidence: pass --verification-evidence, "
            "--verification-evidence-file, or --verification-evidence-url"
        )
    issue_number = goal_tracker_item_issue_number(item)
    if not issue_number:
        raise SystemExit(f"{context} to a done status requires a linked GitHub issue/PR URL so verification evidence can be posted")
    return stakeholder_issue_post_comment(data, target, issue_number, backlog_provider_done_evidence_comment(evidence)) or "posted"


def goal_tracker_update_status_with_done_evidence(
    data: dict,
    target: Path,
    item_ref: str,
    status: str,
    *,
    evidence: str = "",
    context: str = "goal-tracker-update",
) -> tuple[dict, str]:
    comment_url = ""
    item = resolve_goal_tracker_item(data, target, item_ref)
    done = goal_tracker_status_matches(status, goal_tracker_done_statuses(data))
    tracker = goal_tracker_config(data)
    active = goal_tracker_status_matches(status, list(tracker.get("activeStatuses") or []))
    done_subtasks: list[dict] | None = None
    if done:
        done_subtasks = goal_tracker_preflight_done_subtasks(
            data, target, item, item_ref, status, context, context.replace("-", "_").replace(" ", "_")
        )
        goal_tracker_update_board_backed_subtasks_status(
            data,
            target,
            item,
            item_ref,
            status,
            evidence=evidence,
            context=context,
            prefix=context.replace("-", "_").replace(" ", "_"),
            subtasks=done_subtasks,
        )
        comment_url = goal_tracker_post_done_evidence(data, target, item, evidence, context)
        result = goal_tracker_update_status(data, target, item_ref, status)
    else:
        result = goal_tracker_update_status(data, target, item_ref, status)
        goal_tracker_update_board_backed_subtasks_status(
            data,
            target,
            item,
            item_ref,
            status,
            evidence=evidence,
            context=context,
            prefix=context.replace("-", "_").replace(" ", "_"),
            subtasks=done_subtasks,
        )
        if active:
            goal_tracker_claim_parent_items_active_for_subtask(
                data,
                target,
                item,
                item_ref,
                goal_tracker_primary_status(tracker, "activeStatuses"),
                context=context,
                prefix=context.replace("-", "_").replace(" ", "_"),
            )
    return result, comment_url


def goal_tracker_primary_status(tracker: dict, key: str) -> str:
    values = tracker.get(key) or []
    if not values:
        raise SystemExit(f"goalTracker.{key} must contain at least one status when goalTracker is enabled")
    return str(values[0])


def goal_tracker_status_for_goal_run(data: dict, run: dict) -> str:
    tracker = goal_tracker_config(data)
    if run.get("status") == "complete":
        return goal_tracker_primary_status(tracker, "doneStatuses")
    if any(item.get("status") == "blocked" for item in run.get("milestones", [])):
        return goal_tracker_primary_status(tracker, "blockedStatuses")
    return goal_tracker_primary_status(tracker, "activeStatuses")


def goal_tracker_status_for_milestone(data: dict, milestone: dict) -> str:
    tracker = goal_tracker_config(data)
    status = str(milestone.get("status") or "").strip()
    if status == "complete":
        return goal_tracker_primary_status(tracker, "doneStatuses")
    if status in {"blocked", "deferred"}:
        return goal_tracker_primary_status(tracker, "blockedStatuses")
    if status == "pending":
        return goal_tracker_primary_status(tracker, "readyStatuses")
    return goal_tracker_primary_status(tracker, "activeStatuses")


def goal_tracker_source_values_from_plan(path: Path) -> dict:
    # is_file() (not exists()) so a `.md` path that resolves to a DIRECTORY (e.g. a symlink-to-dir
    # surfaced by `git status`) is skipped, not read -- read_text on a dir raises IsADirectoryError
    # and would crash the pre-push board gate (same crash class as the P1-B gh fix).
    if not path.is_file() or path.suffix.lower() != ".md":
        return {}
    text = path.read_text(encoding="utf-8", errors="replace")
    section = markdown_section(text, "## GitHub Project Source")
    if not section:
        return {}
    values: dict[str, str] = {}
    for line in section.splitlines():
        match = re.match(r"^\s*[-*]\s*([A-Za-z0-9_-]+)\s*:\s*(.*?)\s*$", line)
        if match:
            values[match.group(1)] = match.group(2).strip()
    return values


def goal_tracker_ref_from_plan(path: Path) -> str:
    values = goal_tracker_source_values_from_plan(path)
    for key in ["project_item_id", "project_item_url", "title"]:
        value = values.get(key, "").strip()
        if value and value.lower() != "none":
            return value
    return ""


def goal_tracker_goal_ref(data: dict, target: Path, run: dict) -> str:
    source_goal = goal_run_source_path(data, target, run)
    if not source_goal:
        return ""
    return goal_tracker_ref_from_plan(source_goal)


def goal_tracker_milestone_ref(target: Path, milestone: dict | None) -> str:
    if not milestone:
        return ""
    source_plan = str(milestone.get("sourcePlan") or "").strip()
    if not source_plan:
        return ""
    return goal_tracker_ref_from_plan(cli_path(target, source_plan))


def goal_tracker_print_sync_result(item_ref: str, status: str, result: dict) -> None:
    print("goal_tracker_sync: ok")
    print(f"goal_tracker_sync_item_ref: {item_ref}")
    print(f"goal_tracker_sync_item_id: {result['item_id']}")
    print(f"goal_tracker_sync_status: {status}")


def goal_tracker_done_evidence_for_transition(event: str, detail: str, milestone: dict | None = None) -> str:
    if event == "goal-complete":
        return detail.strip()
    if event != "milestone-complete" or not milestone:
        return ""
    parts: list[str] = []
    detail = detail.strip()
    if detail:
        parts.append(detail)
    for value in milestone.get("validationEvidence", []) if isinstance(milestone.get("validationEvidence"), list) else []:
        text = str(value or "").strip()
        if text and text not in parts:
            parts.append(text)
    milestone_run = str(milestone.get("milestoneRun") or "").strip()
    if milestone_run:
        parts.append(f"Milestone run: {milestone_run}")
    return "\n\n".join(parts).strip()


def goal_tracker_sync_status_update(
    data: dict,
    target: Path,
    item_ref: str,
    status: str,
    event: str,
    done_evidence: str,
) -> tuple[dict, str]:
    return goal_tracker_update_status_with_done_evidence(
        data,
        target,
        item_ref,
        status,
        evidence=done_evidence,
        context=f"goal-tracker sync for {event}",
    )


def goal_tracker_sync_transition(
    data: dict,
    target: Path,
    run: dict,
    event: str,
    milestone: dict | None = None,
    *,
    done_evidence: str = "",
) -> list[str]:
    tracker = goal_tracker_config(data)
    if not tracker["enabled"]:
        return []
    issues: list[str] = []
    goal_ref = goal_tracker_goal_ref(data, target, run)
    if not goal_ref:
        raise SystemExit(
            "goalTracker is enabled but the source goal plan has no GitHub Project Source project_item_id/project_item_url. "
            "Sync the external backlog item into the repo plan with `tautline backlog-provider-sync --target . --item <item> --write` before goal-start/advance."
        )
    milestone_ref = goal_tracker_milestone_ref(target, milestone)
    if event in {"goal-start", "startup"}:
        status = goal_tracker_primary_status(tracker, "activeStatuses")
        result, _ = goal_tracker_sync_status_update(data, target, goal_ref, status, event, "")
        goal_tracker_print_sync_result(goal_ref, status, result)
    elif event == "goal-complete":
        status = goal_tracker_primary_status(tracker, "doneStatuses")
        result, comment_url = goal_tracker_sync_status_update(data, target, goal_ref, status, event, done_evidence)
        if comment_url:
            print(f"goal_tracker_sync_verification_comment: {comment_url}")
        goal_tracker_print_sync_result(goal_ref, status, result)
    elif event.startswith("milestone-"):
        if milestone_ref:
            status = goal_tracker_status_for_milestone(data, milestone or {})
            result, comment_url = goal_tracker_sync_status_update(data, target, milestone_ref, status, event, done_evidence)
            if comment_url:
                print(f"goal_tracker_sync_verification_comment: {comment_url}")
            goal_tracker_print_sync_result(milestone_ref, status, result)
        else:
            status = goal_tracker_status_for_goal_run(data, run)
            result, comment_url = goal_tracker_sync_status_update(data, target, goal_ref, status, event, done_evidence)
            if comment_url:
                print(f"goal_tracker_sync_verification_comment: {comment_url}")
            goal_tracker_print_sync_result(goal_ref, status, result)
            if event == "milestone-complete":
                issues.append(
                    "completed milestone has no GitHub Project Source item mapping; parent goal item stayed current, "
                    "but milestone-level board status/checklist must be reconciled."
                )
    for issue in issues:
        print(f"goal_tracker_sync_issue: {issue}")
    return issues


def goal_tracker_status_matches(actual: str, expected_values: list[str]) -> bool:
    return actual.strip().lower() in {value.strip().lower() for value in expected_values}


def goal_tracker_drift_issues(data: dict, target: Path, run: dict) -> list[str]:
    tracker = goal_tracker_config(data)
    if not tracker["enabled"]:
        return []
    issues = [f"goalTracker provider unavailable: {issue}" for issue in goal_tracker_status_issues(data, target)]
    if issues:
        return issues
    goal_ref = goal_tracker_goal_ref(data, target, run)
    if not goal_ref:
        return [
            "goalTracker is enabled but active goal has no GitHub Project Source project_item_id/project_item_url; "
            "sync a backlog provider item into the source-of-truth goal plan before continuing."
        ]
    try:
        item = resolve_goal_tracker_item(data, target, goal_ref)
    except SystemExit as exc:
        return [f"GitHub Project goal item cannot be resolved for active goal: {exc}"]
    actual = goal_tracker_item_field(item, tracker["statusField"])
    expected_status = goal_tracker_status_for_goal_run(data, run)
    if not goal_tracker_status_matches(actual, [expected_status]):
        issues.append(
            f"GitHub Project goal item status drift: item {goal_ref} is `{actual or 'none'}`, expected `{expected_status}`; "
            f"run `tautline goal-tracker-update --target . --item {shlex.quote(goal_ref)} --status {shlex.quote(expected_status)}`"
        )
    provider = backlog_provider_config(data)
    enforce_milestone_mapping = bool(provider.get("enabled")) and "milestone" in provider.get("itemTypes", [])
    for milestone in run.get("milestones", []):
        milestone_ref = goal_tracker_milestone_ref(target, milestone)
        if milestone_ref:
            try:
                milestone_item = resolve_goal_tracker_item(data, target, milestone_ref)
            except SystemExit as exc:
                issues.append(f"GitHub Project milestone item cannot be resolved for milestone {milestone.get('index')}: {exc}")
                continue
            actual_milestone = goal_tracker_item_field(milestone_item, tracker["statusField"])
            expected_milestone = goal_tracker_status_for_milestone(data, milestone)
            if not goal_tracker_status_matches(actual_milestone, [expected_milestone]):
                issues.append(
                    f"GitHub Project milestone item status drift: milestone {milestone.get('index')} item {milestone_ref} is `{actual_milestone or 'none'}`, expected `{expected_milestone}`; "
                    f"run `tautline goal-tracker-update --target . --item {shlex.quote(milestone_ref)} --status {shlex.quote(expected_milestone)}`"
                )
        elif enforce_milestone_mapping and milestone.get("status") in {"complete", "blocked", "deferred"}:
            issues.append(
                f"milestone {milestone.get('index')} `{milestone.get('title')}` is {milestone.get('status')} but has no GitHub Project Source item mapping; "
                "sync or link a milestone Project item so stakeholder board status reflects completed/deferred/blocked work."
            )
    return issues


def board_item_content_state(item: dict) -> str:
    """The underlying issue/PR state (OPEN/CLOSED/MERGED) for a GitHub Project item, when the
    payload exposes it. Returns '' when no state is present (older gh output) so the generic
    state reconciliation never guesses."""
    content = item.get("content") if isinstance(item.get("content"), dict) else {}
    for source in (item, content):
        if not isinstance(source, dict):
            continue
        for key in ("state", "issueState", "prState", "stateReason"):
            text = goal_tracker_value_text(source.get(key)).strip().upper()
            if text in {"OPEN", "CLOSED", "MERGED"}:
                return text
    for source in (content, item):
        if isinstance(source, dict) and source.get("closed") is True:
            return "CLOSED"
    return ""


def github_issue_or_pr_state_rest(target: Path, url: str) -> str:
    match = re.search(r"github\.com/([^/]+)/([^/]+)/(issues|pull)/([0-9]+)", str(url or ""))
    if not match:
        return ""
    owner, repo, kind, number = match.groups()
    endpoint = f"repos/{owner}/{repo}/{'pulls' if kind == 'pull' else 'issues'}/{number}"
    code, payload, _error = command_json(["gh", "api", endpoint], cwd=target, timeout=15)
    if code != 0 or not isinstance(payload, dict):
        return ""
    state = str(payload.get("state") or "").strip().upper()
    if kind == "pull" and payload.get("merged") is True:
        return "MERGED"
    if state == "CLOSED":
        return "CLOSED"
    if state == "OPEN":
        return "OPEN"
    return ""


def board_item_fetch_state(item: dict, target: Path) -> str:
    """The underlying issue/PR state (OPEN/CLOSED/MERGED) for a project item. `gh project
    item-list --format json` omits issue/PR state (Codex Critical), so when the payload does not
    already carry one we fetch it explicitly with `gh issue/pr view <url> --json state`. Returns
    '' for draft items, items with no issue/PR URL, or when the state cannot be read."""
    inline = board_item_content_state(item)
    if inline:
        return inline
    content = item.get("content") if isinstance(item.get("content"), dict) else {}
    url = goal_tracker_value_text(content.get("url") or item.get("contentUrl")).strip()
    ctype = goal_tracker_value_text(content.get("type")).strip().lower()
    if not url or "draft" in ctype or "/issues/" not in url and "/pull/" not in url:
        return ""
    rest_state = github_issue_or_pr_state_rest(target, url)
    if rest_state:
        return rest_state
    subcommand = "pr" if ("pull" in ctype or "/pull/" in url) else "issue"
    # A real issue/PR whose state cannot be read is NOT treated as clean (Codex P1): the gh failure
    # propagates so the caller records it as drift rather than silently passing the board gate.
    payload = goal_tracker_gh_json([subcommand, "view", url, "--json", "state"], target)
    if isinstance(payload, dict):
        return goal_tracker_value_text(payload.get("state")).strip().upper()
    return ""


def board_item_state_drift(
    title: str,
    url: str,
    board_status: str,
    content_state: str,
    done_statuses: list[str],
) -> str:
    """Pure: a project item's board status must reflect its real issue/PR state — a closed/merged
    item must sit in a done status, and an open item must not. Returns a drift message, or ''
    (also '' when the state is unknown, so we never guess). RCA: keeps features/bugs/tasks, not
    just goals/milestones, current on the stakeholder board."""
    state = (content_state or "").strip().upper()
    if state not in {"OPEN", "CLOSED", "MERGED"}:
        return ""
    bs = (board_status or "").strip().lower()
    done = {s.strip().lower() for s in done_statuses}
    ref = (url or title or "item").strip()
    if state in {"CLOSED", "MERGED"} and bs not in done:
        return (
            f"GitHub Project board item out of date: {ref} is {state.lower()} but its board status is "
            f"`{board_status or 'none'}`, not a done status; move it to a done status so the stakeholder board reflects shipped work"
        )
    if state == "OPEN" and bs in done:
        return (
            f"GitHub Project board item out of date: {ref} board status is `{board_status}` (done) but the issue/PR is still open; "
            "the board overstates completion"
        )
    return ""


def board_item_unconfigured_status_drift(title: str, url: str, board_status: str, configured_statuses: list[str]) -> str:
    """Pure: an in-scope board item must sit in a status the adapter enumerates
    (readyStatuses/activeStatuses/doneStatuses/blockedStatuses). A status outside all of them -- an
    ad-hoc review/QA column such as `In review` -- is drift: lanes must use only configured statuses
    and ship straight to a done status, never stage work in a separate review column. Empty status is
    not flagged (item not yet triaged). RCA: lanes parking items in an unconfigured review column."""
    bs = (board_status or "").strip()
    if not bs:
        return ""
    configured = {s.strip().lower() for s in configured_statuses if s.strip()}
    if bs.lower() in configured:
        return ""
    ref = (url or title or "item").strip()
    return (
        f"GitHub Project board item in an unconfigured status: {ref} board status is `{bs}`, which is not an "
        "adapter-approved status (readyStatuses/activeStatuses/doneStatuses/blockedStatuses). Move it to a configured "
        "status and do not stage work in a separate review/QA column -- ship straight to a done status when the issue/PR lands"
    )


def branch_issue_number(branch: str) -> str:
    """The trailing issue number on a branch name (e.g. `fix/...-334` -> `334`), or ''. A trailing
    ISO date (`hotfix/2026-06-19`) is NOT an issue number (P2), so it returns ''."""
    b = (branch or "").strip()
    if re.search(r"\d{4}-\d{2}-\d{2}$", b):
        return ""
    match = re.search(r"(?:^|[-_/#])(\d+)$", b)
    return match.group(1) if match else ""


def lane_outgoing_issue_refs(target: Path, branch: str, base_branch_names: set[str] | None = None) -> list[str]:
    """Issue numbers the current branch's outgoing work maps to: every issue its OPEN PR(s)
    close (via gh closingIssuesReferences) plus a trailing issue number in the branch name.
    Deduped, order-stable. Empty on a base/numberless branch or when gh cannot be reached --
    the caller degrades gracefully (no credit, not a crash)."""
    refs: list[str] = []
    bases = base_branch_names or BASE_BRANCH_NAMES
    if branch and branch not in bases and shutil.which("gh") is not None:
        code, payload, _ = command_json(
            ["gh", "pr", "list", "--head", branch, "--state", "open", "--limit", "10",
             "--json", "number,closingIssuesReferences"],
            target,
            timeout=15,
        )
        if code == 0 and isinstance(payload, list):
            for pr in payload:
                if not isinstance(pr, dict):
                    continue
                for ref in pr.get("closingIssuesReferences") or []:
                    if not isinstance(ref, dict):
                        continue  # Codex review: a non-dict entry must not crash the gate.
                    num = str(ref.get("number") or "").strip()
                    if num and num not in refs:
                        refs.append(num)
    branch_num = branch_issue_number(branch)
    if branch_num and branch_num not in refs:
        refs.append(branch_num)
    return refs


def lane_changed_plan_refs(target: Path) -> list[str]:
    """Project-item refs declared in the `## GitHub Project Source` section of any working-tree-changed
    markdown plan/spec. RCA Jun-16 #234: a lane drafting a milestone spec is actively working that
    spec's board item before any PR exists. Only `.md` files that actually carry the mapping
    contribute (goal_tracker_ref_from_plan returns '' otherwise), so this is naturally scoped to
    mapped source-of-truth plans. Empty when git is unavailable."""
    changed = run_git(target, ["status", "--porcelain"])
    if not changed or changed == "unavailable":
        return []
    refs: list[str] = []
    for line in changed.splitlines():
        path_part = line[3:].strip() if len(line) > 3 else ""
        if " -> " in path_part:  # porcelain rename "old -> new"
            path_part = path_part.split(" -> ", 1)[1].strip()
        path_part = path_part.strip('"')
        if not path_part.lower().endswith(".md"):
            continue
        try:
            ref = goal_tracker_ref_from_plan(target / path_part)
        except OSError:
            # Never let a weird working-tree path (unreadable/odd `.md`) crash the board gate.
            continue
        if ref and ref not in refs:
            refs.append(ref)
    return refs


def lane_work_scope(data: dict, target: Path) -> dict:
    """Read-only: which board items the lane's current work touches. `ledger_ref` is the active
    goal ledger's mapped project item (or '' when none/unlinked); `outgoing_issue_numbers` are the
    issues the current branch/PR closes; `working_tree_refs` are board items mapped by a
    working-tree-changed source-of-truth plan/spec (RCA Jun-16 #234); `unresolved_ledger` is True
    when a goal ledger exists but carries no project link (RCA Jun-17: an unlinked ledger must not
    hard-block outgoing work)."""
    branch = run_git(target, ["branch", "--show-current"])
    outgoing = lane_outgoing_issue_refs(target, branch, branch_liveness_base_branch_names(target))
    ledger_ref = ""
    unresolved_ledger = False
    if goal_run_path(data, target).exists():
        run = load_goal_run(data, target)
        ledger_ref = goal_tracker_goal_ref(data, target, run)
        unresolved_ledger = not ledger_ref
    return {
        "ledger_ref": ledger_ref,
        "outgoing_issue_numbers": outgoing,
        "working_tree_refs": lane_changed_plan_refs(target),
        "unresolved_ledger": unresolved_ledger,
    }


def board_item_issue_pr_urls(item: dict) -> list[str]:
    """Issue/PR URLs linked to a Project item, preferring content URLs over project-item pane URLs."""
    content = item.get("content") if isinstance(item.get("content"), dict) else {}
    urls: list[str] = []
    for value in [
        goal_tracker_value_text(content.get("url")),
        goal_tracker_value_text(item.get("contentUrl")),
        goal_tracker_item_url(item),
    ]:
        value = value.strip()
        if value and ("/issues/" in value or "/pull/" in value) and value not in urls:
            urls.append(value)
    return urls


def board_item_display_ref(item: dict) -> str:
    """Human-actionable item reference for board-currency messages."""
    urls = board_item_issue_pr_urls(item)
    return urls[0] if urls else (goal_tracker_item_url(item) or goal_tracker_item_title(item))


def board_item_matches_issue_numbers(item: dict, issue_numbers: list[str]) -> bool:
    """True when a project item's issue/PR URL ends in one of the given issue numbers."""
    urls = board_item_issue_pr_urls(item)
    for num in issue_numbers:
        for url in urls:
            if url.rstrip("/").endswith(f"/{num}"):
                return True
    return False


def board_item_matches_strong_ref(item: dict, ref: str) -> bool:
    """True when a board item matches a ledger/plan ref by a STRONG identifier (project node id or
    issue/PR url) ONLY -- never by title. Used on the blocking active-work path so a title-only
    ledger/plan ref (goal_tracker_ref_from_plan falls back to the plan `title:`) cannot over-match a
    DIFFERENT board item via a title-suffix collision and produce a spurious block (P3)."""
    needle = (ref or "").strip()
    if not needle:
        return False
    content = item.get("content") if isinstance(item.get("content"), dict) else {}
    strong = {goal_tracker_item_id(item), goal_tracker_item_url(item), goal_tracker_value_text(content.get("url"))}
    strong = {value for value in strong if value}
    return needle in strong or any(needle.endswith(value) or value.endswith(needle) for value in strong)


def board_item_ref_values(item: dict) -> set[str]:
    """Strong identifiers for a Project item, used for current-work matching without title guesses."""
    content = item.get("content") if isinstance(item.get("content"), dict) else {}
    values = {
        goal_tracker_item_id(item),
        goal_tracker_item_url(item),
        goal_tracker_item_content_url(item),
        goal_tracker_value_text(content.get("id")),
        goal_tracker_value_text(item.get("contentId")),
    }
    return {value.strip() for value in values if value and value.strip()}


def board_item_scope_current_work(item: dict, scope: dict) -> bool:
    """True when the item is verified current lane work from branch/PR, changed plan, or ledger."""
    has_outgoing_refs = bool(scope["outgoing_issue_numbers"])
    has_working_tree_refs = bool(scope.get("working_tree_refs", []))
    is_outgoing_work = board_item_matches_issue_numbers(item, scope["outgoing_issue_numbers"])
    is_changed_plan_work = any(board_item_matches_strong_ref(item, ref) for ref in scope.get("working_tree_refs", []))
    is_ledger_current_work = (
        bool(scope["ledger_ref"])
        and not has_outgoing_refs
        and not has_working_tree_refs
        and board_item_matches_strong_ref(item, scope["ledger_ref"])
    )
    return is_outgoing_work or is_changed_plan_work or is_ledger_current_work


def board_backed_subtask_items(parent_item: dict, items: list[dict], target: Path) -> tuple[list[dict], bool, list[str]]:
    """Native sub-issues of current work that are also visible board items.

    The parent issue's status is not a substitute for subtask status. Matching uses strong native
    issue IDs/URLs only. The unmatched return lets closeout block instead of silently ignoring a
    native subtask that is not represented on the board.
    """
    content = parent_item.get("content") if isinstance(parent_item.get("content"), dict) else {}
    if not goal_tracker_value_text(content.get("id")):
        return [], False, []
    sub_issues = grooming_sub_issues(parent_item, target)
    if sub_issues == GROOMING_SUBISSUES_UNAVAILABLE:
        return [], True, []
    sub_issue_keys: list[tuple[dict, set[str]]] = []
    for sub_issue in sub_issues:
        if not isinstance(sub_issue, dict):
            continue
        keys: set[str] = set()
        for value in [
            goal_tracker_value_text(sub_issue.get("id")),
            goal_tracker_value_text(sub_issue.get("url")),
        ]:
            if value:
                keys.add(value.strip())
        if keys:
            sub_issue_keys.append((sub_issue, keys))
    if not sub_issue_keys:
        return [], False, []
    matches: list[dict] = []
    seen: set[str] = set()
    matched_indexes: set[int] = set()
    for item in items:
        item_keys = board_item_ref_values(item)
        matching_indexes = {
            index for index, (_sub_issue, keys) in enumerate(sub_issue_keys) if item_keys.intersection(keys)
        }
        if not matching_indexes:
            continue
        matched_indexes.update(matching_indexes)
        item_id = goal_tracker_item_id(item) or goal_tracker_item_content_url(item) or goal_tracker_item_url(item)
        if item_id in seen:
            continue
        seen.add(item_id)
        matches.append(item)
    unmatched = [
        goal_tracker_value_text(sub_issue.get("url"))
        or goal_tracker_value_text(sub_issue.get("id"))
        or goal_tracker_value_text(sub_issue.get("title"))
        or "unknown-subtask"
        for index, (sub_issue, _keys) in enumerate(sub_issue_keys)
        if index not in matched_indexes
    ]
    return matches, False, unmatched


def board_item_active_work_drift(
    title: str,
    url: str,
    board_status: str,
    content_state: str,
    is_active_work: bool,
    active_statuses: list[str],
    work_kind: str = "item",
    parent_ref: str = "",
) -> str:
    """Pure: a board item that is the target of active local work (the active goal ledger, an
    open mapped branch/PR) must sit in an adapter active status. An OPEN item still in Backlog/
    Ready while it is actively worked is drift -- the stakeholder board understates progress.
    Returns a drift message or ''. Only speaks to OPEN items and never guesses on unknown state
    (closed/merged is board_item_state_drift's job). RCA Jun-16 #234: real work underway on an
    item the board shows as not-started."""
    if not is_active_work:
        return ""
    if (content_state or "").strip().upper() != "OPEN":
        return ""
    active = {s.strip().lower() for s in active_statuses if s.strip()}
    if (board_status or "").strip().lower() in active:
        return ""
    ref = (url or title or "item").strip()
    primary = active_statuses[0] if active_statuses else "an active status"
    if work_kind == "subtask":
        parent = f" of {parent_ref}" if parent_ref else ""
        return (
            f"GitHub Project board subtask understates progress: {ref} is a board-backed subtask{parent} "
            f"being actively worked but its board status is `{board_status or 'none'}`, not an active status; "
            f"move it to `{primary}` (`tautline backlog-provider-update --target . --item {ref} --status {primary}`)"
        )
    return (
        f"GitHub Project board item understates progress: {ref} is being actively worked but its "
        f"board status is `{board_status or 'none'}`, not an active status; move it to `{primary}` "
        f"(`tautline backlog-provider-update --target . --item {ref} --status {primary}`)"
    )


def provider_board_currency_issues(data: dict, target: Path, goal_run: dict | None) -> tuple[list[str], list[str], list[str]]:
    """Always-on board-currency reconciliation for provider-backed lanes. Returns
    (drift_issues, unavailable_issues, warnings). RCA: runs whenever goalTracker/backlogProvider is
    enabled. `drift_issues` are BLOCKING and cover integrity-critical board currency: every
    in-scope item's closed/merged-vs-done state, adapter-unconfigured statuses, and verified current
    lane work whose board item is not in an active status. `warnings` are NON-blocking
    board-currency nudges for active-goal-ledger reconciliation when it is not the lane's current
    work item. `unavailable_issues` mean the board could
    not be reached (gh missing/unauthed/offline), surfaced as warnings that re-block once
    connectivity returns."""
    tracker = goal_tracker_config(data)
    provider = backlog_provider_config(data)
    if not (tracker.get("enabled") or provider.get("enabled")):
        return [], [], []
    # Only gh-missing / not-authenticated is treated as transient unavailability (offline). Any
    # other deterministic provider problem (query capability, status field/options config) is a
    # blocking config error, so the board gate cannot be bypassed by a misconfigured project (Codex P2).
    unavailable = goal_tracker_auth_issues(target)
    if unavailable:
        return [], unavailable, []
    config_errors = goal_tracker_status_issues(data, target)
    if config_errors:
        return [f"board provider config error: {issue}" for issue in config_errors], unavailable, []
    drift: list[str] = []
    # A taxonomy/schema mismatch (the adapter declares statuses/fields the board lacks) is NON-blocking
    # and routes to board-adopt -- it must never fail the sanctioned status-write path closed, because
    # that fail-closed pressure is exactly what drove the raw-gh board bypass (operator decision +
    # private lane RCA, 2026-06-25). The lane keeps the board's item status accurate against the
    # columns that DO exist; the adapter is realigned to the board, never the board to the adapter.
    warnings: list[str] = [
        f"board schema mismatch — run 'tautline backlog-board-examine', answer the unknowns, then 'backlog-board-adopt --apply'; do NOT change the board: {issue}"
        for issue in goal_tracker_schema_mismatch_issues(data, target)
    ]
    # Resolve the lane's current work once (read-only): the outgoing branch/PR's board items and
    # the active ledger's mapped item. RCA Jun-16 #234 / Jun-17: the gate must reason about the
    # work actually in flight, not an unrelated ledger or a blind whole-project scan.
    scope = lane_work_scope(data, target)
    active_statuses = list(tracker.get("activeStatuses") or [])
    # Auth + config already passed, so a failure to LIST items is a deterministic project error.
    board_item_scan_limit = 400
    try:
        items = goal_tracker_items(data, target, limit=board_item_scan_limit)
    except SystemExit as exc:
        drift.append(f"GitHub Project item list could not be read for board reconciliation: {exc}")
        return drift, unavailable, warnings
    # Truncation hides stale items past the page, defeating "every in-scope board item" (Codex P2).
    if len(items) >= board_item_scan_limit:
        drift.append(
            f"GitHub Project board has at least {board_item_scan_limit} in-scope items; board reconciliation cannot "
            "guarantee currency past that page. Narrow backlogProvider.scopeQuery so the in-scope board fits one page."
        )
    done_statuses = list(tracker.get("doneStatuses") or [])
    status_field = tracker.get("statusField", "")
    # The adapter enumerates every approved workflow status; an item in any other status (e.g. an
    # ad-hoc `In review`/QA column) is drift, independent of issue/PR state and checkable without a
    # fetch. RCA: lanes must ship straight to done, never park items in an unconfigured review column.
    configured_statuses = (
        list(tracker.get("readyStatuses") or [])
        + list(tracker.get("activeStatuses") or [])
        + done_statuses
        + list(tracker.get("blockedStatuses") or [])
    )
    active_work_refs: set[str] = set()
    subtask_parent_by_ref: dict[str, str] = {}
    for item in items:
        if not board_item_scope_current_work(item, scope):
            continue
        active_work_refs.update(board_item_ref_values(item))
        parent_ref = board_item_display_ref(item)
        subtasks, unavailable_subtasks, unmatched_subtasks = board_backed_subtask_items(item, items, target)
        if unavailable_subtasks:
            warnings.append(
                f"GitHub Project subtask status could not be verified for current work item {parent_ref}: "
                "native sub-issue enumeration unavailable"
            )
        if unmatched_subtasks:
            shown = ", ".join(unmatched_subtasks[:5])
            suffix = " ..." if len(unmatched_subtasks) > 5 else ""
            warnings.append(
                f"GitHub Project subtask status could not be tracked for current work item {parent_ref}: "
                f"native sub-issue(s) are not board items: {shown}{suffix}"
            )
        for subtask in subtasks:
            subtask_refs = board_item_ref_values(subtask)
            active_work_refs.update(subtask_refs)
            for subtask_ref in subtask_refs:
                subtask_parent_by_ref[subtask_ref] = parent_ref
    if goal_run is not None:
        # RCA Jun-17 + Codex review: the broad active-goal-ledger reconciliation is a NON-blocking
        # warning when it is not the current work item, so it does not block a push whose own board
        # item is current. The narrower active-work detector below blocks only on verified current
        # lane work.
        warnings.extend(f"active goal ledger: {issue}" for issue in goal_tracker_drift_issues(data, target, goal_run))
    for item in items:
        ref = board_item_display_ref(item)
        board_status = goal_tracker_item_field(item, status_field)
        unconfigured = board_item_unconfigured_status_drift(
            goal_tracker_item_title(item), board_item_display_ref(item), board_status, configured_statuses
        )
        if unconfigured:
            drift.append(unconfigured)
        try:
            state = board_item_fetch_state(item, target)
        except SystemExit as exc:
            # A real issue/PR whose state cannot be read is not assumed clean (Codex P1).
            drift.append(f"GitHub Project board item state could not be verified for {ref}: {exc}")
            continue
        message = board_item_state_drift(
            goal_tracker_item_title(item),
            board_item_display_ref(item),
            board_status,
            state,
            done_statuses,
        )
        if message:
            drift.append(message)
        # An item the lane is actively working must sit in an active status. RCA Jun-16 #234. Strong
        # current-work signals block: the outgoing branch/PR closes it, or a working-tree-changed
        # source-of-truth plan/spec maps to it. The ledger's mapped item also blocks when it is the
        # only current-work signal. When a branch/plan points elsewhere, ledger drift stays a warning
        # so a stale unrelated ledger cannot block a board-current branch. Board-backed native
        # subtasks of verified current work inherit the same status obligation: parent issue status
        # is not a substitute for subtask status.
        item_refs = board_item_ref_values(item)
        is_active_work = bool(item_refs.intersection(active_work_refs))
        subtask_parent_ref = next((subtask_parent_by_ref[ref] for ref in item_refs if ref in subtask_parent_by_ref), "")
        active_work_msg = board_item_active_work_drift(
            goal_tracker_item_title(item),
            board_item_display_ref(item),
            board_status,
            state,
            is_active_work,
            active_statuses,
            "subtask" if subtask_parent_ref else "item",
            subtask_parent_ref,
        )
        if active_work_msg:
            drift.append(active_work_msg)
    return drift, unavailable, warnings


def provider_board_business_lead_gaps(data: dict, target: Path) -> list[str]:
    """Non-blocking backfill: in-scope board items whose body does NOT lead with a plain-language
    business justification (`## What this delivers` / `## Why it matters`). Surfaced as warnings so
    existing rote/technical-only items get fixed over time, while new items are hard-blocked at
    export. Items with no body in the list payload are skipped (cannot judge -> never guess).
    Returns [] when the provider is disabled or unreachable. RCA: stakeholder items must lead with
    what is delivered and why it matters, not pure technical detail."""
    tracker = goal_tracker_config(data)
    provider = backlog_provider_config(data)
    if not (tracker.get("enabled") or provider.get("enabled")):
        return []
    if goal_tracker_auth_issues(target) or goal_tracker_status_issues(data, target):
        return []
    try:
        items = goal_tracker_items(data, target, limit=400)
    except SystemExit:
        return []
    gaps: list[str] = []
    for item in items:
        body = goal_tracker_item_body(item)
        if not body.strip():
            continue
        if backlog_item_missing_business_lead(body):
            gaps.append(goal_tracker_item_url(item) or goal_tracker_item_title(item))
    return gaps


def print_provider_board_business_lead_warnings(data: dict, target: Path) -> list[str]:
    gaps = provider_board_business_lead_gaps(data, target)
    for ref in gaps:
        print(
            f"backlog_provider_board_lead_warn: {ref} does not lead with a business justification "
            "(`## What this delivers` / `## Why it matters`); update it so stakeholders see what it delivers and why it matters"
        )
    return gaps


def lane_unplaced_customer_facing_issues(data: dict, target: Path) -> list[str]:
    """RCA Jun-16 #316/#317: OPEN repo issues that read as customer-facing but are not on the
    Project board. 'Customer-facing' reuses board_export_non_customer_facing_signal (an issue that
    trips a tech-debt/internal marker is ignored) -- the same definition the board uses at export,
    so a bug filed with `gh issue create` (no label needed) is still caught. Returns issue URLs.
    [] on disabled/unreachable provider or unreadable issue list: this is a warning-only path and
    never blocks."""
    tracker = goal_tracker_config(data)
    provider = backlog_provider_config(data)
    if not (tracker.get("enabled") or provider.get("enabled")):
        return []
    if goal_tracker_auth_issues(target) or goal_tracker_status_issues(data, target):
        return []
    try:
        board_items = goal_tracker_items(data, target, limit=400)
    except SystemExit:
        return []
    board_urls = {goal_tracker_item_url(it).rstrip("/") for it in board_items}
    try:
        payload = goal_tracker_gh_json(
            ["issue", "list", "--state", "open", "--limit", "200", "--json", "url,title,body"], target
        )
    except SystemExit:
        # P1-B: a non-zero `gh issue list` (restricted token / rate limit / transient) must not crash
        # the pre-push gate -- this is a warning-only path, so degrade to [] like the board fetch above.
        return []
    if not isinstance(payload, list):
        return []
    unplaced: list[str] = []
    for issue in payload:
        if not isinstance(issue, dict):
            continue
        url = str(issue.get("url") or "").rstrip("/")
        if not url or url in board_urls:
            continue
        if board_export_non_customer_facing_signal(str(issue.get("title") or ""), str(issue.get("body") or "")):
            continue  # tech-debt / internal -> not a board item
        unplaced.append(url)
    return unplaced


def print_unplaced_customer_facing_issue_warnings(data: dict, target: Path) -> list[str]:
    unplaced = lane_unplaced_customer_facing_issues(data, target)
    for url in unplaced:
        print(
            f"backlog_provider_board_unplaced_warn: {url} reads as customer-facing but is not on the "
            "stakeholder board; place it (`backlog-provider-export ...` or `gh project item-add`) with an "
            "Item Type + Status before the delivery boundary",
            file=sys.stderr,
        )
    return unplaced


def backlog_provider_board_check(args: argparse.Namespace) -> int:
    """Explicit board-currency gate for provider-backed lanes (used at boundaries and by the
    pre-push hook). Blocks on board drift. Provider-unavailable (offline/no auth) is a warning by
    default and a failure only under --strict (operator-run verification)."""
    data, _, target = lane_project(args)
    tracker = goal_tracker_config(data)
    provider = backlog_provider_config(data)
    if not (tracker.get("enabled") or provider.get("enabled")):
        print("backlog_provider_board_check: provider not enabled; no board to keep current")
        return 0
    goal_run = load_goal_run(data, target) if goal_run_path(data, target).exists() else None
    drift, unavailable, warnings = provider_board_currency_issues(data, target, goal_run)
    for issue in unavailable:
        print(f"backlog_provider_board_warn: {issue}", file=sys.stderr)
    # RCA Jun-17: broad active-goal-ledger reconciliation remains a non-blocking warning when it is
    # not the current work item. Verified active-work status drift is returned as `drift` above and
    # blocks.
    for issue in warnings:
        print(f"backlog_provider_board_warn: {issue}", file=sys.stderr)
    # Non-blocking backfill: surface existing items missing the business-justification lead so they
    # get fixed over time (new items are hard-blocked at export). Does not fail the gate.
    print_provider_board_business_lead_warnings(data, target)
    # RCA Jun-16 #316/#317: warn (non-blocking) about customer-facing issues missing from the board.
    print_unplaced_customer_facing_issue_warnings(data, target)
    for issue in drift:
        print(f"backlog_provider_board_issue: {issue}", file=sys.stderr)
    if drift:
        print("backlog_provider_board_check: blocked - the stakeholder board is out of date; see backlog_provider_board_issue lines above for the specific item(s) and fix", file=sys.stderr)
        return 1
    if unavailable and getattr(args, "strict", False):
        print("backlog_provider_board_check: blocked - cannot verify the stakeholder board (provider unavailable); authenticate gh project scope", file=sys.stderr)
        return 1
    print("backlog_provider_board_check: ok")
    return 0


def backlog_provider_active_check(args: argparse.Namespace) -> int:
    """Fast hook gate for active local work still showing Ready/Backlog on the stakeholder board."""
    data, _, target = lane_project(args)
    tracker = goal_tracker_config(data)
    provider = backlog_provider_config(data)
    if not (tracker.get("enabled") or provider.get("enabled")):
        print("backlog_provider_active_check: provider not enabled; no board to keep current")
        return 0
    goal_run = load_goal_run(data, target) if goal_run_path(data, target).exists() else None
    drift, unavailable, warnings = provider_board_currency_issues(data, target, goal_run)
    for issue in unavailable:
        print(f"backlog_provider_active_warn: {issue}", file=sys.stderr)
    if unavailable and getattr(args, "strict", False):
        print("backlog_provider_active_check: blocked - cannot verify the stakeholder board (provider unavailable); authenticate gh project scope", file=sys.stderr)
        return 1
    for issue in warnings:
        print(f"backlog_provider_active_warn: {issue}", file=sys.stderr)
    active_drift = [issue for issue in drift if "understates progress" in issue]
    for issue in active_drift:
        print(f"backlog_provider_active_issue: {issue}", file=sys.stderr)
    if active_drift:
        print("backlog_provider_active_check: blocked - active local work is not in an active board status", file=sys.stderr)
        return 1
    print("backlog_provider_active_check: ok")
    return 0


def ci_test_gate_config(data: dict) -> dict:
    return {**DEFAULT_CI_TEST_GATE, **(data.get("ciTestGate") or {})}


def normalize_ci_test_gate(data: dict) -> dict:
    """Validate + normalize the adapter ciTestGate block. Type-checks rather than coerces, so an
    invalid value (a non-dict block, events:[1], requiredWorkflow:null) is rejected, not silently
    normalized into a bogus string (Codex P3)."""
    raw = data.get("ciTestGate")
    if raw is not None and not isinstance(raw, dict):
        raise SystemExit("Project adapter ciTestGate must be an object")
    cfg = {**DEFAULT_CI_TEST_GATE, **(raw or {})}
    if not isinstance(cfg.get("enabled"), bool):
        raise SystemExit("Project adapter ciTestGate.enabled must be boolean")
    if cfg["enforcement"] not in {"warn", "block"}:
        raise SystemExit("Project adapter ciTestGate.enforcement must be warn or block")
    events = cfg.get("events", [])
    if not isinstance(events, list) or not events or any(not isinstance(ev, str) or not ev.strip() for ev in events):
        raise SystemExit("Project adapter ciTestGate.events must be a non-empty array of non-blank strings")
    cfg["events"] = [ev.strip() for ev in events]
    markers = cfg.get("testCommandMarkers", [])
    if not isinstance(markers, list) or any(not isinstance(m, str) or not m.strip() for m in markers):
        raise SystemExit("Project adapter ciTestGate.testCommandMarkers must be an array of non-blank strings")
    cfg["testCommandMarkers"] = [m.strip() for m in markers]
    required_workflow = cfg.get("requiredWorkflow", "")
    if not isinstance(required_workflow, str):
        raise SystemExit("Project adapter ciTestGate.requiredWorkflow must be a string")
    cfg["requiredWorkflow"] = required_workflow.strip()
    if Path(cfg["requiredWorkflow"]).is_absolute():
        raise SystemExit("Project adapter ciTestGate.requiredWorkflow must be a workflow filename, not an absolute path")
    if "expectTests" in cfg and not isinstance(cfg["expectTests"], bool):
        raise SystemExit("Project adapter ciTestGate.expectTests must be boolean")
    if not isinstance(cfg.get("requiredCheckName", ""), str):
        raise SystemExit("Project adapter ciTestGate.requiredCheckName must be a string")
    cfg["requiredCheckName"] = str(cfg.get("requiredCheckName", "")).strip()
    return cfg


def normalize_ui_evidence(data: dict) -> dict:
    raw = data.get("uiEvidence")
    if raw is not None and not isinstance(raw, dict):
        raise SystemExit("Project adapter uiEvidence must be an object")
    cfg = {**DEFAULT_UI_EVIDENCE, **(raw or {})}
    issue_raw = (raw or {}).get("issueComments", {}) if isinstance(raw, dict) else {}
    if issue_raw is not None and not isinstance(issue_raw, dict):
        raise SystemExit("Project adapter uiEvidence.issueComments must be an object")
    cfg["issueComments"] = {**DEFAULT_UI_EVIDENCE["issueComments"], **(issue_raw or {})}

    if not isinstance(cfg.get("enabled"), bool):
        raise SystemExit("Project adapter uiEvidence.enabled must be boolean")
    if cfg["enforcement"] not in {"off", "warn", "block"}:
        raise SystemExit("Project adapter uiEvidence.enforcement must be off, warn, or block")
    if str(cfg.get("tool", "")).strip() != "playwright":
        raise SystemExit("Project adapter uiEvidence.tool must be playwright")
    cfg["tool"] = "playwright"
    for key in ("evidenceDir", "manifestGlob", "captureCommand"):
        if not isinstance(cfg.get(key, ""), str):
            raise SystemExit(f"Project adapter uiEvidence.{key} must be a string")
        cfg[key] = str(cfg.get(key, "")).strip()
    if not cfg["evidenceDir"]:
        raise SystemExit("Project adapter uiEvidence.evidenceDir must be non-blank")
    if Path(cfg["evidenceDir"]).is_absolute() or ".." in Path(cfg["evidenceDir"]).parts:
        raise SystemExit("Project adapter uiEvidence.evidenceDir must stay inside the project")
    if not cfg["manifestGlob"]:
        raise SystemExit("Project adapter uiEvidence.manifestGlob must be non-blank")

    allowed_events = UI_EVIDENCE_EVENTS
    required_events = cfg.get("requiredEvents", [])
    if not isinstance(required_events, list) or any(not isinstance(ev, str) or not ev.strip() for ev in required_events):
        raise SystemExit("Project adapter uiEvidence.requiredEvents must be an array of non-blank strings")
    cfg["requiredEvents"] = [ev.strip() for ev in required_events]
    invalid_required = sorted(set(cfg["requiredEvents"]) - allowed_events)
    if invalid_required:
        raise SystemExit(
            "Project adapter uiEvidence.requiredEvents may contain only "
            "milestone-complete, goal-complete, and work-item-complete"
        )

    comments = cfg["issueComments"]
    if not isinstance(comments.get("enabled"), bool):
        raise SystemExit("Project adapter uiEvidence.issueComments.enabled must be boolean")
    if comments.get("provider") != "github-issues":
        raise SystemExit("Project adapter uiEvidence.issueComments.provider must be github-issues")
    if not isinstance(comments.get("required"), bool):
        raise SystemExit("Project adapter uiEvidence.issueComments.required must be boolean")
    trigger_events = comments.get("triggerEvents", [])
    if not isinstance(trigger_events, list) or any(not isinstance(ev, str) or not ev.strip() for ev in trigger_events):
        raise SystemExit("Project adapter uiEvidence.issueComments.triggerEvents must be an array of non-blank strings")
    comments["triggerEvents"] = [ev.strip() for ev in trigger_events]
    invalid_triggers = sorted(set(comments["triggerEvents"]) - allowed_events)
    if invalid_triggers:
        raise SystemExit(
            "Project adapter uiEvidence.issueComments.triggerEvents may contain only "
            "milestone-complete, goal-complete, and work-item-complete"
        )
    if comments["enabled"] and not comments["triggerEvents"]:
        raise SystemExit("Project adapter uiEvidence.issueComments.triggerEvents must be non-empty when comments are enabled")
    return cfg


def ui_evidence_config(data: dict) -> dict:
    return normalize_ui_evidence(data)


def adapter_command_is_configured(command: str) -> bool:
    """A command the adapter actually defines (not a BOOTSTRAP-REQUIRED placeholder)."""
    text = str(command or "").strip()
    return bool(text) and BOOTSTRAP_REQUIRED_PREFIX not in text


def adapter_test_command_tokens(data: dict) -> list[str]:
    """Commands whose presence signals the project HAS tests (full preflight / test environment).
    Used only for has-tests detection -- NOT as CI-step markers, since testEnvironment is often a
    setup command (e.g. `pnpm install`) that does not run the suite (Codex P1)."""
    commands = data.get("commands") or {}
    tokens: list[str] = []
    for key in ("fullPreflight", "testEnvironment"):
        value = str(commands.get(key, "")).strip()
        if adapter_command_is_configured(value):
            tokens.append(value)
    return tokens


def adapter_test_run_command_tokens(data: dict) -> list[str]:
    """Commands that actually RUN the test suite (full preflight only), usable as CI-step markers.
    Excludes testEnvironment, which is environment setup -- counting it would let an install-only CI
    job falsely satisfy the gate (Codex P1)."""
    commands = data.get("commands") or {}
    value = str(commands.get("fullPreflight", "")).strip()
    return [value] if adapter_command_is_configured(value) else []


CI_TEST_FILE_PRUNE_DIRS = {
    ".git", "node_modules", "vendor", "dist", "build", "out", ".next", ".turbo", ".cache",
    "__pycache__", ".venv", "venv", "target", "coverage", ".pytest_cache", ".mypy_cache",
}

RUNTIME_SECRET_SCAN_EXTENSIONS = (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs")


def runtime_config(data: dict) -> dict:
    return {**DEFAULT_RUNTIME_CONFIG, **(data.get("runtimeConfig") or {})}


def detected_development_runtime() -> str:
    platform = sys.platform
    if platform == "darwin":
        return "darwin"
    if platform.startswith("win"):
        return "win32"
    if platform.startswith("linux"):
        try:
            osrelease = Path("/proc/sys/kernel/osrelease").read_text(encoding="utf-8", errors="ignore").lower()
        except OSError:
            osrelease = ""
        try:
            version = Path("/proc/version").read_text(encoding="utf-8", errors="ignore").lower()
        except OSError:
            version = ""
        if "wsl2" in osrelease or "wsl2" in version:
            return "wsl2"
        if os.environ.get("WSL_DISTRO_NAME") or os.environ.get("WSL_INTEROP") or "microsoft" in osrelease or "microsoft" in version:
            return "wsl1"
        return "linux"
    return platform


def normalize_development_environment(data: dict) -> dict:
    raw = data.get("developmentEnvironment")
    if raw is not None and not isinstance(raw, dict):
        raise SystemExit("Project adapter developmentEnvironment must be an object")
    windows_raw = (raw or {}).get("windows") or {}
    if not isinstance(windows_raw, dict):
        raise SystemExit("Project adapter developmentEnvironment.windows must be an object")
    line_endings_raw = (raw or {}).get("lineEndings") or {}
    if not isinstance(line_endings_raw, dict):
        raise SystemExit("Project adapter developmentEnvironment.lineEndings must be an object")
    cfg = {
        **DEFAULT_DEVELOPMENT_ENVIRONMENT,
        **(raw or {}),
    }
    cfg["windows"] = {
        **DEFAULT_DEVELOPMENT_ENVIRONMENT["windows"],
        **windows_raw,
    }
    cfg["lineEndings"] = {
        **DEFAULT_DEVELOPMENT_ENVIRONMENT["lineEndings"],
        **line_endings_raw,
    }
    runtimes = cfg.get("supportedRuntimes", [])
    if not isinstance(runtimes, list):
        raise SystemExit("Project adapter developmentEnvironment.supportedRuntimes must be an array")
    normalized_runtimes: list[str] = []
    for runtime in runtimes:
        if not isinstance(runtime, str) or not runtime.strip():
            raise SystemExit("Project adapter developmentEnvironment.supportedRuntimes must contain non-blank strings")
        runtime = runtime.strip().lower()
        if runtime not in SUPPORTED_DEVELOPMENT_RUNTIMES:
            raise SystemExit(
                "Project adapter developmentEnvironment.supportedRuntimes may contain only "
                "linux, darwin, wsl1, wsl2, or win32"
            )
        if runtime not in normalized_runtimes:
            normalized_runtimes.append(runtime)
    cfg["supportedRuntimes"] = normalized_runtimes
    windows = cfg["windows"]
    if windows["native"] not in {"unspecified", "unsupported", "supported", "diagnostic-only"}:
        raise SystemExit("Project adapter developmentEnvironment.windows.native must be unspecified, unsupported, supported, or diagnostic-only")
    if windows["supportedRuntime"] not in {"wsl2", "win32"}:
        raise SystemExit("Project adapter developmentEnvironment.windows.supportedRuntime must be wsl2 or win32")
    if windows["native"] in {"unsupported", "diagnostic-only"} and "win32" in cfg["supportedRuntimes"]:
        raise SystemExit(
            "Project adapter developmentEnvironment cannot include win32 in supportedRuntimes "
            f"when windows.native is {windows['native']}"
        )
    if windows["native"] == "supported" and "win32" not in cfg["supportedRuntimes"]:
        raise SystemExit(
            "Project adapter developmentEnvironment must include win32 in supportedRuntimes "
            "when windows.native is supported"
        )
    for key in ("setup", "reason"):
        if not isinstance(windows.get(key, ""), str):
            raise SystemExit(f"Project adapter developmentEnvironment.windows.{key} must be a string")
        windows[key] = windows.get(key, "").strip()
    line_endings = cfg["lineEndings"]
    if line_endings["policy"] not in {"", "lf", "crlf", "mixed"}:
        raise SystemExit("Project adapter developmentEnvironment.lineEndings.policy must be lf, crlf, mixed, or blank")
    if not isinstance(line_endings.get("windowsGuidance", ""), str):
        raise SystemExit("Project adapter developmentEnvironment.lineEndings.windowsGuidance must be a string")
    line_endings["windowsGuidance"] = line_endings.get("windowsGuidance", "").strip()
    return cfg


def development_environment_status(data: dict, runtime: str | None = None) -> dict:
    cfg = normalize_development_environment({"developmentEnvironment": data.get("developmentEnvironment")})
    runtime = runtime or detected_development_runtime()
    supported = cfg.get("supportedRuntimes") or []
    issues: list[str] = []
    warnings: list[str] = []
    if supported and runtime not in supported:
        if runtime == "win32" and cfg["windows"]["native"] == "unsupported":
            setup = cfg["windows"].get("setup") or "Run lane gates from WSL2, not native Windows PowerShell/CMD."
            reason = cfg["windows"].get("reason")
            detail = f"; {reason}" if reason else ""
            issues.append(
                "native Windows is not a supported lane runtime for this adapter; "
                f"{setup}{detail}"
            )
        else:
            issues.append(
                "detected runtime "
                f"{runtime} is not in adapter-supported runtimes: {', '.join(supported)}"
            )
    elif not supported:
        warnings.append("adapter does not declare supported development runtimes; using compatibility mode")
    if runtime == "win32" and cfg["windows"]["native"] == "diagnostic-only":
        warnings.append("native Windows is diagnostic-only for this adapter; use WSL2 for lane gates and release evidence")
    line_policy = cfg.get("lineEndings", {}).get("policy", "")
    line_guidance = cfg.get("lineEndings", {}).get("windowsGuidance", "")
    return {
        "runtime": runtime,
        "supportedRuntimes": supported,
        "windowsNative": cfg["windows"]["native"],
        "windowsSupportedRuntime": cfg["windows"]["supportedRuntime"],
        "lineEndingPolicy": line_policy,
        "lineEndingGuidance": line_guidance,
        "issues": issues,
        "warnings": warnings,
    }


def print_development_environment_status(data: dict) -> list[str]:
    status = development_environment_status(data)
    supported = ",".join(status["supportedRuntimes"]) if status["supportedRuntimes"] else "unspecified"
    print(
        "development_environment: "
        f"runtime={status['runtime']} supported={supported} "
        f"windows_native={status['windowsNative']} windows_runtime={status['windowsSupportedRuntime']}"
    )
    if status["lineEndingPolicy"]:
        print(f"development_environment_line_endings: policy={status['lineEndingPolicy']}")
    if status["lineEndingGuidance"]:
        print(f"development_environment_windows_guidance: {status['lineEndingGuidance']}")
    for warning in status["warnings"]:
        print(f"development_environment_warn: {warning}")
    for issue in status["issues"]:
        print(f"development_environment_issue: {issue}")
    return status["issues"]


def normalize_runtime_config(data: dict) -> dict:
    """Validate + normalize the optional runtimeConfig block (required-runtime-secret registry).
    Type-checks rather than coerces; never SystemExits merely because a customer-facing adapter has no
    registry (that is an advisory surfaced by runtime_secret_fallback_issues, not a load-time wedge)."""
    raw = data.get("runtimeConfig")
    if raw is not None and not isinstance(raw, dict):
        raise SystemExit("Project adapter runtimeConfig must be an object")
    cfg = {**DEFAULT_RUNTIME_CONFIG, **(raw or {})}
    if cfg["enforcement"] not in {"warn", "block"}:
        raise SystemExit("Project adapter runtimeConfig.enforcement must be warn or block")
    secrets = cfg.get("requiredSecrets", [])
    if not isinstance(secrets, list):
        raise SystemExit("Project adapter runtimeConfig.requiredSecrets must be an array")
    norm: list[dict] = []
    for entry in secrets:
        if not isinstance(entry, dict):
            raise SystemExit("Project adapter runtimeConfig.requiredSecrets[] entries must be objects")
        name = entry.get("name")
        if not isinstance(name, str) or not name.strip():
            raise SystemExit("Project adapter runtimeConfig.requiredSecrets[].name must be a non-blank string")
        norm.append({**entry, "name": name.strip()})
    cfg["requiredSecrets"] = norm
    for key in ("bootAssertionCommand", "secretParityCommand"):
        if not isinstance(cfg.get(key, ""), str):
            raise SystemExit(f"Project adapter runtimeConfig.{key} must be a string")
        cfg[key] = str(cfg.get(key, "")).strip()
    return cfg


def required_secret_names(data: dict) -> list[str]:
    return [s["name"] for s in runtime_config(data)["requiredSecrets"] if isinstance(s, dict) and s.get("name")]


def required_secret_fallback_findings(text: str, names: list[str]) -> list[str]:
    """Pure: which registered required-secret names are read with a degrade-to-empty fallback, turning a
    missing secret into a silent empty string instead of a loud failure. Catches the access-with-fallback
    forms (process.env.NAME ?? '' / || '' / ?? "" / ?? ``) and the destructuring default
    (const { NAME = '' } = process.env). Returns the offending names in registry order, deduped."""
    found: list[str] = []
    empty = r"(?:(['\"])\s*\1|`\s*`)"  # '' | "" | `` (template literal)
    for name in names:
        esc = re.escape(name)
        access = rf"process\.env\.{esc}|process\.env\[\s*['\"]{esc}['\"]\s*\]"
        fallback = rf"(?:{access})\s*(?:\?\?|\|\|)\s*{empty}"
        destructure = rf"\{{[^}}]*\b{esc}\s*=\s*{empty}[^}}]*\}}\s*=\s*process\.env"
        if name not in found and (re.search(fallback, text) or re.search(destructure, text)):
            found.append(name)
    return found


def runtime_secret_fallback_issues(data: dict, target: Path) -> list[str]:
    """Issues for the required-runtime-secret registry (FM3/P4, the #448 class): (1) a customer-facing
    adapter (deploymentTargets) that declares NO required-secret registry is advised to declare one;
    (2) any source file that reads a registered required secret with a degrade-to-empty fallback
    (?? '' / || '') -- the idiom that turns a missing secret into a silent 500."""
    issues: list[str] = []
    names = required_secret_names(data)
    if not names:
        if data.get("deploymentTargets"):
            issues.append(
                "runtimeConfig.requiredSecrets is empty for a customer-facing product: declare the secrets that "
                "must be present at runtime so a missing one fails the deploy loudly (boot fail-fast / secret-parity) "
                "instead of silently degrading to an empty string and 500ing (the #448 class)"
            )
        return issues
    scanned = 0
    try:
        for root, dirs, files in os.walk(target):
            dirs[:] = [d for d in dirs if d not in CI_TEST_FILE_PRUNE_DIRS and not d.startswith(".")]
            for filename in files:
                if not filename.endswith(RUNTIME_SECRET_SCAN_EXTENSIONS):
                    continue
                scanned += 1
                if scanned > 20000:
                    return issues
                path = Path(root) / filename
                try:
                    text = path.read_text(encoding="utf-8", errors="replace")
                except OSError:
                    continue
                for name in required_secret_fallback_findings(text, names):
                    issues.append(
                        f"required secret `{name}` is read with a degrade-to-empty fallback (?? '' / || '') in "
                        f"{path.relative_to(target)}: a missing required secret must fail loudly, not silently "
                        "become an empty string"
                    )
    except OSError:
        pass
    return issues


def print_runtime_secret_status(data: dict, target: Path) -> list[str]:
    cfg = runtime_config(data)
    issues = runtime_secret_fallback_issues(data, target)
    print(
        f"runtime_secret_registry: enforcement={cfg['enforcement']} "
        f"required_secrets={len(cfg['requiredSecrets'])} status={'drift' if issues else 'ok'}"
    )
    for issue in issues:
        print(f"runtime_secret_issue: {issue}")
    return issues


DESTRUCTIVE_SQL_PATTERNS = [
    (r"\bdrop\s+table\b", "DROP TABLE"),
    (r"\bdrop\s+column\b", "DROP COLUMN"),
    (r"\bdrop\s+constraint\b", "DROP CONSTRAINT"),
    (r"\btruncate\b", "TRUNCATE"),
    (r"\bset\s+not\s+null\b", "SET NOT NULL"),
]


def destructive_sql_findings(text: str) -> list[str]:
    """Pure: which classes of un-guarded destructive DDL appear in a SQL migration (DROP TABLE/COLUMN/
    CONSTRAINT, TRUNCATE, SET NOT NULL). Line comments are stripped so commented-out SQL is ignored."""
    low = re.sub(r"--[^\n]*", " ", text.lower())
    found: list[str] = []
    for pattern, label in DESTRUCTIVE_SQL_PATTERNS:
        if label not in found and re.search(pattern, low):
            found.append(label)
    return found


def normalize_migration_safety(data: dict) -> dict:
    raw = data.get("migrationSafety")
    if raw is not None and not isinstance(raw, dict):
        raise SystemExit("Project adapter migrationSafety must be an object")
    cfg = {**DEFAULT_MIGRATION_SAFETY, **(raw or {})}
    if cfg["enforcement"] not in {"warn", "block"}:
        raise SystemExit("Project adapter migrationSafety.enforcement must be warn or block")
    for key in ("migrationsPath", "ackMarker"):
        if not isinstance(cfg.get(key, ""), str):
            raise SystemExit(f"Project adapter migrationSafety.{key} must be a string")
        cfg[key] = str(cfg.get(key, "")).strip()
    if not cfg["ackMarker"]:
        cfg["ackMarker"] = DEFAULT_MIGRATION_SAFETY["ackMarker"]
    return cfg


def migration_safety_issues(data: dict, target: Path) -> list[str]:
    """Opt-in (migrationSafety.migrationsPath): flag any .sql migration with un-guarded destructive DDL
    that lacks an expand-contract acknowledgement marker (FM1/data-durability)."""
    cfg = {**DEFAULT_MIGRATION_SAFETY, **(data.get("migrationSafety") or {})}
    path_glob = str(cfg.get("migrationsPath") or "").strip()
    if not path_glob:
        return []
    ack = str(cfg.get("ackMarker") or DEFAULT_MIGRATION_SAFETY["ackMarker"]).strip().lower()
    files: list[Path] = []
    for candidate in sorted(target.glob(path_glob)):
        if candidate.is_file() and candidate.suffix == ".sql":
            files.append(candidate)
        elif candidate.is_dir():
            files.extend(sorted(p for p in candidate.rglob("*.sql") if p.is_file()))
    issues: list[str] = []
    for migration in files:
        try:
            text = migration.read_text(encoding="utf-8", errors="replace")
        except OSError:
            continue
        findings = destructive_sql_findings(text)
        if findings and ack not in text.lower():
            issues.append(
                f"migration {migration.relative_to(target)} has un-guarded destructive DDL "
                f"({', '.join(findings)}) with no expand-contract acknowledgement: make it reversible/"
                f"expand-contract or add a reviewed '{cfg.get('ackMarker') or DEFAULT_MIGRATION_SAFETY['ackMarker']}' marker comment"
            )
    return issues


def print_migration_safety_status(data: dict, target: Path) -> list[str]:
    cfg = {**DEFAULT_MIGRATION_SAFETY, **(data.get("migrationSafety") or {})}
    issues = migration_safety_issues(data, target)
    print(f"migration_safety: enforcement={cfg['enforcement']} path={cfg.get('migrationsPath') or 'none'} status={'drift' if issues else 'ok'}")
    for issue in issues:
        print(f"migration_safety_issue: {issue}")
    return issues


def normalize_coverage_floor(data: dict) -> dict:
    raw = data.get("coverageFloor")
    if raw is not None and not isinstance(raw, dict):
        raise SystemExit("Project adapter coverageFloor must be an object")
    cfg = {**DEFAULT_COVERAGE_FLOOR, **(raw or {})}
    if cfg["enforcement"] not in {"warn", "block"}:
        raise SystemExit("Project adapter coverageFloor.enforcement must be warn or block")
    try:
        cfg["newCodeMinPct"] = int(cfg["newCodeMinPct"])
    except (TypeError, ValueError) as exc:
        raise SystemExit("Project adapter coverageFloor.newCodeMinPct must be an integer 0-100") from exc
    if not 0 <= cfg["newCodeMinPct"] <= 100:
        raise SystemExit("Project adapter coverageFloor.newCodeMinPct must be an integer 0-100")
    for key in ("coverageReport", "diffBase"):
        if not isinstance(cfg.get(key, ""), str):
            raise SystemExit(f"Project adapter coverageFloor.{key} must be a string")
        cfg[key] = str(cfg.get(key, "")).strip() or DEFAULT_COVERAGE_FLOOR[key]
    return cfg


def parse_diff_added_lines(diff_text: str) -> dict[str, set[int]]:
    """Pure: map each file to the set of NEW-side line numbers ADDED by a unified diff. Deletions and
    context lines are ignored, so the gate measures only lines this change introduces (the right
    instrument for new code -- whole-repo fail_under is the coarse backstop, rec #7 Part 1)."""
    added: dict[str, set[int]] = {}
    current: str | None = None
    new_line = 0
    in_hunk = False
    for raw in diff_text.splitlines():
        if raw.startswith("+++ "):
            path = raw[4:].strip()
            if path == "/dev/null":
                current = None
            else:
                # strip a leading b/ (git) and surrounding quotes
                current = re.sub(r"^b/", "", path).strip().strip('"')
                added.setdefault(current, set())
            in_hunk = False
            continue
        if raw.startswith("@@"):
            m = re.search(r"\+(\d+)(?:,(\d+))?", raw)
            new_line = int(m.group(1)) if m else 0
            in_hunk = True
            continue
        if not in_hunk or current is None:
            continue
        if raw.startswith("+"):
            added[current].add(new_line)
            new_line += 1
        elif raw.startswith("-"):
            # deletion: new-side line number does not advance
            continue
        elif raw.startswith("\\"):
            # "\ No newline at end of file"
            continue
        else:
            # context line (leading space) advances the new-side counter
            new_line += 1
    return {f: lines for f, lines in added.items() if lines}


def parse_coverage_json_lines(report: dict) -> dict[str, set[int]]:
    """Pure: executed line numbers per file from a coverage.py JSON report (`coverage json`)."""
    out: dict[str, set[int]] = {}
    for path, info in (report.get("files") or {}).items():
        executed = info.get("executed_lines")
        if executed is None:
            # older coverage shape: derive from summary not available -> treat missing as covered? No.
            executed = []
        out[path] = {int(n) for n in executed}
    return out


def parse_lcov_lines(text: str) -> dict[str, set[int]]:
    """Pure: executed (hit>0) line numbers per file from an LCOV `.info` report (JS/TS toolchains)."""
    out: dict[str, set[int]] = {}
    current: str | None = None
    for raw in text.splitlines():
        line = raw.strip()
        if line.startswith("SF:"):
            current = line[3:].strip()
            out.setdefault(current, set())
        elif line.startswith("DA:") and current is not None:
            body = line[3:]
            parts = body.split(",")
            if len(parts) >= 2:
                try:
                    lineno, hits = int(parts[0]), int(parts[1])
                except ValueError:
                    continue
                if hits > 0:
                    out[current].add(lineno)
        elif line == "end_of_record":
            current = None
    return out


def load_coverage_executed_lines(path: Path) -> dict[str, set[int]]:
    """Auto-detect coverage.py JSON vs LCOV and return executed lines per file."""
    text = path.read_text(encoding="utf-8", errors="replace")
    stripped = text.lstrip()
    if stripped.startswith("{"):
        return parse_coverage_json_lines(json.loads(text))
    return parse_lcov_lines(text)


def _match_coverage_path(diff_path: str, executed: dict[str, set[int]]) -> set[int]:
    """Reconcile a repo-relative diff path with a coverage report key (exact, then suffix match)."""
    if diff_path in executed:
        return executed[diff_path]
    for cov_path, lines in executed.items():
        if cov_path.endswith(diff_path) or diff_path.endswith(cov_path):
            return lines
    return set()


COVERAGE_MEASURABLE_SUFFIXES = (".py", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".go", ".rb", ".java", ".kt", ".cs", ".php", ".rs", ".scala", ".swift")


def diff_coverage_findings(
    added: dict[str, set[int]], executed: dict[str, set[int]], min_pct: int
) -> dict:
    """Pure: intersect added lines with executed lines; report overall new-line coverage pct and the
    per-file uncovered added lines. Only measurable source files (by extension) count, and a file
    absent from the coverage report is treated as 0% covered -- a new untested module must not pass by
    simply not appearing in the report."""
    total = 0
    covered = 0
    per_file: list[dict] = []
    for path in sorted(added):
        if not path.endswith(COVERAGE_MEASURABLE_SUFFIXES):
            continue
        new_lines = added[path]
        cov = _match_coverage_path(path, executed)
        hit = new_lines & cov
        uncovered = sorted(new_lines - cov)
        total += len(new_lines)
        covered += len(hit)
        if uncovered:
            per_file.append({"file": path, "uncovered": uncovered, "added": len(new_lines), "covered": len(hit)})
    pct = 100.0 if total == 0 else round(100.0 * covered / total, 2)
    return {
        "total_new_lines": total,
        "covered_new_lines": covered,
        "pct": pct,
        "below_floor": total > 0 and pct < min_pct,
        "min_pct": min_pct,
        "files": per_file,
    }


def diff_coverage_check(args: argparse.Namespace) -> int:
    """Diff-scoped coverage gate (rec #7 Part 2): a change whose NEW lines are exercised below
    coverageFloor.newCodeMinPct fails. Blocks only when coverageFloor.enforcement=block (warn-mode
    reports and returns 0), so opting in never wedges a lane mid-migration."""
    target = args.target if args.target else Path(".")
    cfg = dict(DEFAULT_COVERAGE_FLOOR)
    if args.project:
        data = load_project(args.project)
        cfg = data["coverageFloor"]
    min_pct = int(args.min_pct if args.min_pct is not None else cfg["newCodeMinPct"])
    base = str(args.base or cfg["diffBase"])
    report_path = Path(args.report) if args.report else (target / str(cfg["coverageReport"]))

    if args.diff_file:
        if not Path(args.diff_file).exists():
            print(f"diff_coverage: diff file {args.diff_file} not found; skipping (not a failure)")
            return 0
        diff_text = Path(args.diff_file).read_text(encoding="utf-8", errors="replace")
    else:
        code, out, _ = run_command(["git", "-C", str(target), "diff", "--unified=0", f"{base}...HEAD"])
        if code != 0:
            # Fall back to a two-dot diff if the merge-base form is unavailable (shallow clone, etc.).
            code, out, _ = run_command(["git", "-C", str(target), "diff", "--unified=0", base])
        if code != 0:
            print(f"diff_coverage: could not compute a diff against {base}; skipping (not a failure)")
            return 0
        diff_text = out

    added = parse_diff_added_lines(diff_text)
    executed: dict[str, set[int]] | None = None
    if report_path.exists():
        try:
            executed = load_coverage_executed_lines(report_path)
        except (OSError, ValueError, json.JSONDecodeError):
            executed = None  # corrupt report -> treat as missing (cannot prove coverage)
    if executed is None:
        msg = f"diff_coverage: coverage report {report_path} not found or unreadable -- run the coverage command first"
        if cfg["enforcement"] == "block" and added:
            print(msg + " (BLOCKING: a customer-facing change must ship measured new-line coverage)")
            return 1
        print(msg + " (warn)")
        return 0
    findings = diff_coverage_findings(added, executed, min_pct)
    print(
        f"diff_coverage: new_lines={findings['total_new_lines']} covered={findings['covered_new_lines']} "
        f"pct={findings['pct']} floor={min_pct} enforcement={cfg['enforcement']} "
        f"status={'below_floor' if findings['below_floor'] else 'ok'}"
    )
    for f in findings["files"]:
        print(f"diff_coverage_uncovered: {f['file']} new={f['added']} covered={f['covered']} uncovered_lines={f['uncovered']}")
    if findings["below_floor"] and cfg["enforcement"] == "block":
        return 1
    return 0


def normalize_flaky_quarantine(data: dict) -> dict:
    raw = data.get("flakyQuarantine")
    if raw is not None and not isinstance(raw, dict):
        raise SystemExit("Project adapter flakyQuarantine must be an object")
    cfg = {**DEFAULT_FLAKY_QUARANTINE, **(raw or {})}
    if cfg["enforcement"] not in {"warn", "block"}:
        raise SystemExit("Project adapter flakyQuarantine.enforcement must be warn or block")
    try:
        cfg["maxMarkers"] = int(cfg["maxMarkers"])
    except (TypeError, ValueError) as exc:
        raise SystemExit("Project adapter flakyQuarantine.maxMarkers must be an integer") from exc
    if not isinstance(cfg.get("scanPath", ""), str):
        raise SystemExit("Project adapter flakyQuarantine.scanPath must be a string")
    cfg["scanPath"] = str(cfg.get("scanPath", "")).strip()
    markers = cfg.get("markers", FLAKY_QUARANTINE_MARKERS)
    if not isinstance(markers, list) or not all(isinstance(m, str) for m in markers):
        raise SystemExit("Project adapter flakyQuarantine.markers must be a list of strings")
    cfg["markers"] = [m.strip() for m in markers if m.strip()] or list(FLAKY_QUARANTINE_MARKERS)
    return cfg


def parse_diff_added_hunks(diff_text: str) -> list[tuple[str, list[str]]]:
    """Pure: list of (file, [added line texts]) per diff hunk, so a check can require an owner/due
    annotation in the SAME hunk that introduced a marker."""
    hunks: list[tuple[str, list[str]]] = []
    current_file: str | None = None
    added: list[str] = []
    in_hunk = False

    def flush():
        if current_file is not None and added:
            hunks.append((current_file, list(added)))

    for raw in diff_text.splitlines():
        if raw.startswith("+++ "):
            flush()
            added = []
            path = raw[4:].strip()
            current_file = None if path == "/dev/null" else re.sub(r"^b/", "", path).strip().strip('"')
            in_hunk = False
        elif raw.startswith("@@"):
            flush()
            added = []
            in_hunk = True
        elif in_hunk and raw.startswith("+") and not raw.startswith("+++"):
            added.append(raw[1:])
    flush()
    return [(f, lines) for f, lines in hunks if lines]


FLAKY_OWNER_MARKERS = ("owner:", "owner=", "@owner", "assignee:", "assigned:")
FLAKY_DUE_PATTERN = re.compile(r"#\d+|\d{4}-\d{2}-\d{2}|due:|trigger:|un-pend|unpend|jira-|tracked:", re.IGNORECASE)


def flaky_quarantine_diff_issues(diff_text: str, markers: list[str]) -> list[str]:
    """A diff that ADDS a quarantine marker must record, in the same hunk, an owner AND a due/issue/
    trigger -- otherwise the flaky/skipped test is untracked debt that silently erodes the gate (FM2)."""
    issues: list[str] = []
    lowered = [m.lower() for m in markers]
    for file, added in parse_diff_added_hunks(diff_text):
        hunk_ctx = "\n".join(added).lower()
        has_owner = any(tok in hunk_ctx for tok in FLAKY_OWNER_MARKERS)
        has_due = bool(FLAKY_DUE_PATTERN.search(hunk_ctx))
        for line in added:
            low = line.lower()
            marker = next((markers[i] for i, m in enumerate(lowered) if m in low), None)
            if marker is None:
                continue
            missing = []
            if not has_owner:
                missing.append("owner")
            if not has_due:
                missing.append("due/issue/trigger")
            if missing:
                issues.append(
                    f"new quarantine marker '{marker}' in {file} missing {', '.join(missing)}: "
                    f"{line.strip()[:100]} -- a flaky/skipped test is tracked debt; record owner + "
                    "due/issue/trigger or remove the marker"
                )
    return issues


def flaky_marker_repo_count(target: Path, markers: list[str], scan_glob: str) -> int:
    """Bounded count of quarantine markers under scanPath -- the monotonic-decrease ratchet's input."""
    if not scan_glob:
        return 0
    lowered = [m.lower() for m in markers]
    count = 0
    seen = 0
    for candidate in sorted(target.glob(scan_glob)):
        paths = [candidate] if candidate.is_file() else (p for p in candidate.rglob("*") if p.is_file())
        for path in paths:
            seen += 1
            if seen > 20000:
                return count
            try:
                text = path.read_text(encoding="utf-8", errors="replace").lower()
            except OSError:
                continue
            for m in lowered:
                count += text.count(m)
    return count


def flaky_quarantine_check(args: argparse.Namespace) -> int:
    """Quarantine forcing-function (rec #14): a newly-added flaky/skip/xfail marker must carry owner +
    due/issue/trigger, and (when scanPath is set) the repo-wide marker count must not exceed maxMarkers
    -- a ratchet you lower as you fix flakies, never raise. Blocks only when enforcement=block."""
    target = args.target if args.target else Path(".")
    cfg = dict(DEFAULT_FLAKY_QUARANTINE)
    cfg["markers"] = list(FLAKY_QUARANTINE_MARKERS)
    if args.project:
        cfg = load_project(args.project)["flakyQuarantine"]
    raw_markers = cfg.get("markers")
    markers = [str(m) for m in raw_markers] if isinstance(raw_markers, list) and raw_markers else list(FLAKY_QUARANTINE_MARKERS)
    base = str(args.base or "origin/main")

    if args.diff_file:
        if not Path(args.diff_file).exists():
            print(f"flaky_quarantine: diff file {args.diff_file} not found; skipping (not a failure)")
            return 0
        diff_text = Path(args.diff_file).read_text(encoding="utf-8", errors="replace")
    else:
        code, out, _ = run_command(["git", "-C", str(target), "diff", f"{base}...HEAD"])
        if code != 0:
            code, out, _ = run_command(["git", "-C", str(target), "diff", base])
        if code != 0:
            print(f"flaky_quarantine: could not compute a diff against {base}; skipping (not a failure)")
            return 0
        diff_text = out

    issues = flaky_quarantine_diff_issues(diff_text, markers)
    scan_glob = str(cfg.get("scanPath") or "").strip()
    raw_max = cfg.get("maxMarkers", -1)
    max_markers = int(raw_max) if isinstance(raw_max, int) else -1
    ratchet_issue = None
    if scan_glob and max_markers >= 0:
        count = flaky_marker_repo_count(target, markers, scan_glob)
        if count > max_markers:
            ratchet_issue = (
                f"quarantine markers under {scan_glob} = {count} exceed the ratchet maxMarkers={max_markers}; "
                "fix a flaky/skipped test and lower the ratchet -- never raise it"
            )
    print(
        f"flaky_quarantine: new_markers={len(issues)} enforcement={cfg['enforcement']} "
        f"ratchet={'over' if ratchet_issue else 'ok'} status={'drift' if (issues or ratchet_issue) else 'ok'}"
    )
    for issue in issues:
        print(f"flaky_quarantine_issue: {issue}")
    if ratchet_issue:
        print(f"flaky_quarantine_ratchet: {ratchet_issue}")
    if (issues or ratchet_issue) and cfg["enforcement"] == "block":
        return 1
    return 0


def normalize_health_contract(data: dict) -> dict:
    raw = data.get("healthContract")
    if raw is not None and not isinstance(raw, dict):
        raise SystemExit("Project adapter healthContract must be an object")
    cfg = {**DEFAULT_HEALTH_CONTRACT, **(raw or {})}
    if cfg["enforcement"] not in {"off", "warn", "block"}:
        raise SystemExit("Project adapter healthContract.enforcement must be off, warn, or block")
    return cfg


def normalize_side_effect_proof(data: dict) -> dict:
    raw = data.get("sideEffectProof")
    if raw is not None and not isinstance(raw, dict):
        raise SystemExit("Project adapter sideEffectProof must be an object")
    cfg = {**DEFAULT_SIDE_EFFECT_PROOF, **(raw or {})}
    if cfg["enforcement"] not in {"off", "warn", "block"}:
        raise SystemExit("Project adapter sideEffectProof.enforcement must be off, warn, or block")
    return cfg


def health_signal_is_proxy(text: str) -> bool:
    """A signal that proves liveness, not that a customer can complete the journey."""
    low = text.lower()
    return any(re.search(pattern, low) for pattern in PROXY_HEALTH_PATTERNS)


def health_contract_issues(data: dict, target: Path) -> list[str]:
    """Customer-outcome health contract (rec #1): a milestone-close deploy target must declare at least
    one non-proxy customer-outcome signal, so "green" means a real customer round-trip passed -- not that
    /healthcheck returned 200 while the headline journey was 100% down (P1 provability theater)."""
    cfg = {**DEFAULT_HEALTH_CONTRACT, **(data.get("healthContract") or {})}
    if cfg.get("enforcement") == "off":
        return []
    issues: list[str] = []
    for tgt in data.get("deploymentTargets") or []:
        if not isinstance(tgt, dict) or not tgt.get("milestoneClose"):
            continue
        name = str(tgt.get("name") or "unnamed").strip() or "unnamed"
        signals = [str(s).strip() for s in tgt.get("outcomeSignals", []) if str(s).strip()]
        real = [s for s in signals if not health_signal_is_proxy(s)]
        if not real:
            issues.append(
                f"milestone-close deploy target '{name}' declares no non-proxy customer-outcome signal: "
                "green must mean a real customer round-trip passed, not a proxy (/health, 200 OK, a DB "
                "read). Add an outcomeSignals entry describing a customer journey "
                "(e.g. 'guest places an order -> 201 and the order is visible')"
            )
    return issues


def print_health_contract_status(data: dict, target: Path) -> list[str]:
    cfg = {**DEFAULT_HEALTH_CONTRACT, **(data.get("healthContract") or {})}
    issues = health_contract_issues(data, target)
    print(f"health_contract: enforcement={cfg['enforcement']} status={'drift' if issues else 'ok'}")
    for issue in issues:
        print(f"health_contract_issue: {issue}")
    return issues


SIDE_EFFECT_RECEIPT_KEYS = ("receiptProof", "receipt_proof", "proof")
SIDE_EFFECT_PROXY_PATTERN = re.compile(r"outbox|row (?:written|inserted|created)|queued|enqueued|db (?:row|insert)|saved to (?:db|database)", re.IGNORECASE)


def side_effect_proof_issues(data: dict, target: Path) -> list[str]:
    """External side-effect delivery proof (rec #15): a milestone-close target's declared outbound
    side-effect (email/sms/push/webhook/payment) must carry a provider-RECEIPT proof. An outbox row
    written is not delivery -- the RCA's record-only notifications meant every confirmation silently
    never sent (P1/FM1)."""
    cfg = {**DEFAULT_SIDE_EFFECT_PROOF, **(data.get("sideEffectProof") or {})}
    if cfg.get("enforcement") == "off":
        return []
    issues: list[str] = []
    for tgt in data.get("deploymentTargets") or []:
        if not isinstance(tgt, dict) or not tgt.get("milestoneClose"):
            continue
        name = str(tgt.get("name") or "unnamed").strip() or "unnamed"
        for side_effect in tgt.get("sideEffects", []) or []:
            if not isinstance(side_effect, dict):
                continue
            se_type = str(side_effect.get("type") or "side-effect").strip() or "side-effect"
            proof = ""
            for key in SIDE_EFFECT_RECEIPT_KEYS:
                if str(side_effect.get(key, "")).strip():
                    proof = str(side_effect[key]).strip()
                    break
            if not proof or SIDE_EFFECT_PROXY_PATTERN.search(proof):
                issues.append(
                    f"outbound side-effect '{se_type}' on milestone-close target '{name}' has no "
                    "provider-receipt proof: a written/queued outbox row is not delivery -- wire a "
                    "provider-receipt step (delivery webhook or provider API confirmation)"
                )
    return issues


def print_side_effect_proof_status(data: dict, target: Path) -> list[str]:
    cfg = {**DEFAULT_SIDE_EFFECT_PROOF, **(data.get("sideEffectProof") or {})}
    issues = side_effect_proof_issues(data, target)
    print(f"side_effect_proof: enforcement={cfg['enforcement']} status={'drift' if issues else 'ok'}")
    for issue in issues:
        print(f"side_effect_proof_issue: {issue}")
    return issues


# A "closed" remediation obligation must cite a real customer-outcome verify, not merely that a PR
# merged (#325's PRs merged; the bug recurred as #448). Matches the rank-1 customer-outcome proof.
REMEDIATION_VERIFY_PATTERN = re.compile(r"customer[- ]outcome|outcome[- ]verif|verified post[- ]merge|live[- ]smoke|round[- ]trip|deploy[- ]verif", re.IGNORECASE)
_ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")


def load_remediation_ledger(path: Path) -> list[dict]:
    """Read the remediation-obligation ledger (a top-level list or {"obligations": [...]})."""
    try:
        raw = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return []
    if isinstance(raw, dict):
        raw = raw.get("obligations", [])
    return [item for item in raw if isinstance(item, dict)] if isinstance(raw, list) else []


def remediation_obligation_issues(obligations: list[dict], today_iso: str) -> list[str]:
    """Open-remediation forcing function (rec #3, P2): a recurrence-gate cannot be deferred. An open
    obligation past its due date is drift, and an obligation marked closed without a customer-outcome
    verify is drift -- 'PR merged' is not proof the recurrence is actually fixed."""
    issues: list[str] = []
    for ob in obligations:
        oid = str(ob.get("id") or ob.get("title") or "unnamed").strip() or "unnamed"
        status = str(ob.get("status", "open")).strip().lower()
        verified = str(ob.get("verifiedBy", "")).strip()
        if status in {"closed", "resolved", "done", "fixed"}:
            if not REMEDIATION_VERIFY_PATTERN.search(verified):
                issues.append(
                    f"remediation obligation '{oid}' is marked {status} without a customer-outcome "
                    "verify: 'PR merged' is not proof the recurrence is fixed -- record the post-merge "
                    "customer-outcome verify (live-smoke/round-trip run) in verifiedBy"
                )
            continue
        due = str(ob.get("due", "")).strip()
        if _ISO_DATE.match(due) and due < today_iso:
            issues.append(
                f"remediation obligation '{oid}' is overdue (due {due}) and still {status}: a "
                "recurrence-gate cannot be deferred -- close it with a customer-outcome verify or "
                "re-justify the due date"
            )
    return issues


def remediation_issues(data: dict, target: Path) -> list[str]:
    cfg = {**DEFAULT_REMEDIATION, **(data.get("remediation") or {})}
    ledger_path = configured_path(target, str(cfg.get("ledgerPath") or DEFAULT_REMEDIATION["ledgerPath"]))
    if not ledger_path.exists():
        return []
    today_iso = datetime.now(timezone.utc).strftime("%Y-%m-%d")
    return remediation_obligation_issues(load_remediation_ledger(ledger_path), today_iso)


def print_remediation_status(data: dict, target: Path) -> list[str]:
    cfg = {**DEFAULT_REMEDIATION, **(data.get("remediation") or {})}
    ledger_path = configured_path(target, str(cfg.get("ledgerPath") or DEFAULT_REMEDIATION["ledgerPath"]))
    issues = remediation_issues(data, target)
    print(f"remediation: enforcement={cfg['enforcement']} ledger={'present' if ledger_path.exists() else 'none'} status={'drift' if issues else 'ok'}")
    for issue in issues:
        print(f"remediation_issue: {issue}")
    return issues


def generate_mutations(source: str) -> list[tuple[str, str]]:
    """Single-symbol mutations of one source file (rec #8): for each rule, mutate the FIRST occurrence on
    a non-comment line. Returns (label, mutated_source) in rule order; the orchestrator tries them in
    turn until one is killed (tests discriminate) or one survives (tests are no-ops)."""
    lines = source.splitlines(keepends=True)
    mutants: list[tuple[str, str]] = []
    for label, pattern, repl in MUTATION_RULES:
        for i, line in enumerate(lines):
            if line.lstrip().startswith("#"):
                continue
            if pattern.search(line):
                new_line = pattern.sub(repl, line, count=1)
                if new_line != line:
                    mutants.append((label, "".join(lines[:i] + [new_line] + lines[i + 1:])))
                    break
    return mutants


# A genuine test-assertion failure (a real kill), NOT a tooling/lint/type/import failure. A kill must
# show positive evidence the assertion layer ran and failed; a non-zero exit alone is not a kill (a
# linter/type-checker/missing-command rejecting the mutated token would otherwise masquerade as one).
MUTATION_ASSERTION_FAIL = re.compile(
    r"AssertionError|\bassert\b|\b\d+ failed\b|\bFAILED\b|\bexpect\(|\bto (?:equal|be|throw|contain|match|have)\b|✗|✕",
    re.IGNORECASE,
)


def mutation_outcome(exit_code: int, output: str) -> str:
    """Classify a mutated test run: 'survived' (exit 0 -> tests are a no-op), 'killed' (a genuine test
    assertion failure caught the mutation), or 'error' (non-zero with no assertion evidence -- a
    tooling/lint/type/import failure that the rec rejects as a valid kill)."""
    if exit_code == 0:
        return "survived"
    if MUTATION_ASSERTION_FAIL.search(output):
        return "killed"
    return "error"


def red_green_check(args: argparse.Namespace) -> int:
    """Red-green discrimination proof (rec #8, the true FM1 kill): mutate a single symbol of the changed
    source, run the test command, and require a GENUINE assertion failure. A mutant that survives proves
    the tests are no-ops (assert True / over-mock). Blocks on a survivor when --strict or
    redGreen.enforcement=block; an all-mutants-broke-loading run is inconclusive, not a failure."""
    target = args.target if args.target else Path(".")
    file = Path(args.file)
    if not file.exists():
        print(f"red_green: file not found: {file}", file=sys.stderr)
        return 1
    cfg = dict(DEFAULT_RED_GREEN)
    if args.project:
        cfg = {**DEFAULT_RED_GREEN, **(load_project(args.project).get("redGreen") or {})}
    blocking = bool(args.strict) or cfg.get("enforcement") == "block"
    original = file.read_text(encoding="utf-8")
    mutants = generate_mutations(original)
    if not mutants:
        print(f"red_green: no applicable single-symbol mutation in {file}; skipping (not a failure)")
        return 0
    # Baseline must be green: if the unmutated command already fails (broken env, pre-existing red, or a
    # tooling/lint error in the command), a mutant failure cannot be attributed to the tests. Skip
    # rather than miscredit -- otherwise a command that always exits non-zero would "kill" every mutant.
    base_code, _, _ = run_command(["bash", "-c", args.test_command], cwd=target)
    if base_code != 0:
        print("red_green: baseline test command is not green; skipping (cannot measure discrimination)")
        return 0
    try:
        for label, mutated in mutants:
            file.write_text(mutated, encoding="utf-8")
            code, out, err = run_command(["bash", "-c", args.test_command], cwd=target)
            outcome = mutation_outcome(code, f"{out}\n{err}")
            print(f"red_green_mutation: {label} -> {outcome}")
            if outcome == "killed":
                print(f"red_green: pass (tests discriminate; killed mutant '{label}')")
                return 0
            if outcome == "survived":
                print(
                    f"red_green_issue: mutation '{label}' in {file} SURVIVED the test command -- the "
                    "tests do not prove this code works (they would pass even if it broke). Add a "
                    "discriminating assertion.",
                    file=sys.stderr,
                )
                return 1 if blocking else 0
            # 'error': non-zero but no assertion evidence (tooling/loading error); try the next mutation.
        print("red_green: inconclusive (no mutation produced a test-assertion failure; tooling/loading errors only); skipping")
        return 0
    finally:
        file.write_text(original, encoding="utf-8")


def repo_has_tests(data: dict, target: Path) -> tuple[bool, bool, str]:
    """Does this repo have a test suite to run? Authoritative signal is the adapter declaring a real
    preflight/test command; a bounded, pruned scan for common test artifacts is the fallback.
    Returns (has_tests, conclusive, reason): conclusive is False only when the scan could not decide
    (hit its entry cap), so the caller can distinguish a legitimately test-less repo from an
    undetermined one and refuse to self-disable for a customer-facing surface (FM2 fail-open). RCA:
    a repo with tests must run them in CI."""
    if adapter_test_command_tokens(data):
        return True, True, "adapter declares a preflight/test command"
    for name in (
        "tests", "test", "e2e", "spec", "__tests__",
        "pytest.ini", "tox.ini", "jest.config.js", "jest.config.ts", "vitest.config.ts",
        "vitest.config.js", "playwright.config.ts", "playwright.config.js", "phpunit.xml",
    ):
        if (target / name).exists():
            return True, True, f"repo has test artifact `{name}`"
    # Bounded, pruned walk: skip .git/node_modules/vendor/build dirs (so dependency test files are
    # not counted as this repo's tests) and cap entries scanned so pre-push stays fast (Codex P2).
    patterns = ["test_*.py", "*_test.py", "*.test.ts", "*.test.js", "*.spec.ts", "*_test.go", "*.feature"]
    scanned = 0
    try:
        for _root, dirs, files in os.walk(target):
            dirs[:] = [d for d in dirs if d not in CI_TEST_FILE_PRUNE_DIRS and not d.startswith(".")]
            for filename in files:
                scanned += 1
                if scanned > 20000:
                    return False, False, "no adapter test command; test-file scan hit its cap without a match"
                for pattern in patterns:
                    if fnmatch.fnmatch(filename, pattern):
                        return True, True, f"repo has test files matching `{pattern}`"
    except OSError:
        pass
    return False, True, "no adapter test command and no test artifacts detected"


def _split_top_level_commas(text: str) -> list[str]:
    """Split on commas that are NOT inside quotes, so a quoted scalar containing a comma (e.g.
    `"push, pull_request"`) stays one item rather than being mis-split into two (Codex P1)."""
    items: list[str] = []
    current = ""
    quote: str | None = None
    for char in text:
        if quote is not None:
            current += char
            if char == quote:
                quote = None
        elif char in "\"'":
            quote = char
            current += char
        elif char == ",":
            items.append(current)
            current = ""
        else:
            current += char
    items.append(current)
    return [item for item in items if item.strip()]


def _flow_mapping_top_keys(flow: str) -> set[str]:
    """Top-level keys of a YAML flow mapping like `{push: ..., pull_request: {...}}` -> {push,
    pull_request}. Keys nested inside a value (depth > 1) are ignored, and quoted text is skipped, so
    a flow `workflow_dispatch` whose nested input default or quoted value contains `pull_request`
    does not leak that as a top-level trigger (Codex P1)."""
    keys: set[str] = set()
    depth = 0
    token = ""
    quote: str | None = None
    for char in flow:
        if quote is not None:
            if char == quote:
                quote = None
            continue
        if char in "\"'":
            quote = char
        elif char in "{[":
            depth += 1
        elif char in "}]":
            depth -= 1
            token = ""
        elif depth == 1 and char == ":":
            key = token.strip().lower()
            if re.fullmatch(r"[a-z_]+", key):
                keys.add(key)
            token = ""
        elif depth == 1 and char == ",":
            token = ""
        elif depth == 1:
            token += char
    return keys


def workflow_triggers_on_events(workflow_text: str, events: list[str]) -> set[str]:
    """Pure: which of `events` does a GitHub Actions workflow trigger on? Reads only the TOP-LEVEL
    `on:` key (column 0 -- ignores `jobs.<id>.on`). Handles every real `on:` shape: inline scalar
    (`on: push`), inline flow list (`on: [push, pull_request]`), inline flow mapping (top-level keys
    only), block list (`- push`), and block mapping (first-level keys); strips a leading YAML
    anchor/tag (`on: &events`). Event names appearing only as NESTED config under another trigger are
    never counted (Codex P1). Returns the subset of `events` triggered."""
    event_lookup = {ev.lower(): ev for ev in events}
    lines = workflow_text.splitlines()
    on_idx = None
    # GitHub's trigger key is literally lowercase `on` (text parsing always sees `on:`, never the
    # YAML-boolean `true:`), so match only `on:`/`"on":`/`'on':`, case-sensitive (Codex P1).
    for index, line in enumerate(lines):
        if re.match(r"""^(?:on|"on"|'on')\s*:""", line):
            on_idx = index
            break
    if on_idx is None:
        return set()
    found: set[str] = set()
    # Inline value on the `on:` line (comment stripped, leading anchor/tag like `&events`/`!x` removed).
    inline = lines[on_idx].split(":", 1)[1].split("#", 1)[0].strip()
    inline = re.sub(r"^[&!]\S+\s*", "", inline).strip().lower()
    if inline:
        if inline.startswith("{"):
            names = _flow_mapping_top_keys(inline)
        elif inline.startswith("["):
            # Flow list: split on TOP-LEVEL commas only (quote-aware), so a single quoted scalar like
            # ["push, pull_request"] is one item, not two triggers (Codex P1).
            names = {item.strip().strip("'\"") for item in _split_top_level_commas(inline[1:].rstrip("]"))}
        else:
            names = {inline.strip("'\"")}
        return {event_lookup[name] for name in names if name in event_lookup}
    # Block form: only first-level entries (minimal indent in the block) are triggers; nested config
    # is deeper-indented and ignored. Both block-mapping keys and block-list items are recognized.
    block = []
    for line in lines[on_idx + 1:]:
        if line.strip() and not line[:1].isspace():
            break
        if line.strip() and not line.lstrip().startswith("#"):
            block.append(line)
    if not block:
        return set()
    first_level = min(len(line) - len(line.lstrip(" \t")) for line in block)
    for line in block:
        if len(line) - len(line.lstrip(" \t")) != first_level:
            continue
        entry = line.strip().split("#", 1)[0].strip()
        if entry.startswith("- "):
            name = entry[2:].strip().strip("'\"").lower()
            if name in event_lookup:
                found.add(event_lookup[name])
        else:
            match = re.match(r"""["']?([A-Za-z_]+)["']?\s*:""", entry)
            if match and match.group(1).lower() in event_lookup:
                found.add(event_lookup[match.group(1).lower()])
    return found


def workflow_runs_tests(workflow_text: str, markers: list[str]) -> bool:
    """Pure: does a workflow run a recognized test command? Heuristic substring match against
    built-in test-runner markers plus adapter-declared test-RUN commands (not setup commands)."""
    low = workflow_text.lower()
    return any(marker.strip() and marker.lower() in low for marker in markers)


def ci_test_gate_issues(data: dict, target: Path) -> list[str]:
    """Fail-closed: when the repo has tests but no CI workflow runs them on the configured events
    (pull_request/push), return blocking-style issues. A test-less repo returns []. RCA: tests
    defined but never executed in CI let regressions ship silently."""
    cfg = ci_test_gate_config(data)
    if not cfg.get("enabled", True):
        return []
    has_tests, conclusive, why = repo_has_tests(data, target)
    if not has_tests:
        # FM2 fail-open closure (quality rec #6): "no tests detected" must not silently self-disable
        # the gate for a customer-facing product. expectTests defaults true when the adapter declares
        # deploymentTargets; asserting a deliberately test-less repo requires expectTests=false.
        expect_tests = cfg.get("expectTests")
        if expect_tests is None:
            expect_tests = bool(data.get("deploymentTargets"))
        if not expect_tests:
            return []
        events_label = "/".join(cfg["events"])
        if not conclusive:
            return [
                f"ci_test_gate cannot confirm this product has tests: the test-file scan was "
                f"inconclusive ({why}). Declare a test command (commands.fullPreflight) and a CI "
                f"workflow that runs it on {events_label}, or set ciTestGate.expectTests=false to "
                f"assert a deliberately test-less surface."
            ]
        return [
            f"ci_test_gate: this product declares deployment targets but no tests were detected "
            f"({why}). Add a test suite and a CI workflow that runs it on {events_label}, or set "
            f"ciTestGate.expectTests=false to assert a deliberately test-less surface."
        ]
    events = cfg["events"]
    events_label = "/".join(events)
    # Markers = built-in test runners + explicit adapter markers + the adapter's test-RUN command
    # (fullPreflight). testEnvironment is deliberately excluded (it is setup, not a test run).
    markers = CI_TEST_RUNNER_MARKERS + cfg.get("testCommandMarkers", []) + adapter_test_run_command_tokens(data)
    workflow_dir = target / ".github" / "workflows"
    workflows = []
    if workflow_dir.is_dir():
        workflows = sorted(workflow_dir.glob("*.yml")) + sorted(workflow_dir.glob("*.yaml"))
    # Track which events are covered by a test-running workflow across ALL workflows, so a push-only
    # test job does not leave pull_request ungated (Codex P1): every configured event must be covered.
    covered_events: set[str] = set()
    gate_workflows: list[str] = []
    required_runs_tests = False
    required = cfg.get("requiredWorkflow", "")
    for workflow in workflows:
        try:
            text = workflow.read_text(encoding="utf-8", errors="replace")
        except OSError:
            continue
        if not workflow_runs_tests(text, markers):
            continue
        triggered = workflow_triggers_on_events(text, events)
        if triggered:
            gate_workflows.append(workflow.name)
            covered_events |= triggered
            if required and workflow.name == required:
                required_runs_tests = True
    issues: list[str] = []
    if required:
        required_path = workflow_dir / required
        if not required_path.is_file():
            issues.append(f"required CI workflow .github/workflows/{required} is missing; it must run the test suite on {events_label}")
        elif not required_runs_tests:
            issues.append(f"required CI workflow .github/workflows/{required} does not run the test suite on {events_label}")
    uncovered = [ev for ev in events if ev not in covered_events]
    if uncovered:
        uncovered_label = "/".join(uncovered)
        if not gate_workflows:
            where = "no workflow runs them" if workflows else ".github/workflows has no workflow at all"
            issues.append(
                f"no CI test gate: the repo has tests ({why}) but {where}, so tests are not executed in CI. "
                f"Add a workflow that runs the test suite on {events_label}."
            )
        else:
            issues.append(
                f"CI test gate does not cover every event: the repo has tests ({why}) and CI runs them on "
                f"{'/'.join(sorted(covered_events))}, but not on {uncovered_label}. A test-running workflow must "
                f"trigger on {events_label} so no {uncovered_label} ships without the test suite."
            )
    return issues


def print_ci_test_gate_status(data: dict, target: Path) -> list[str]:
    cfg = ci_test_gate_config(data)
    if not cfg.get("enabled", True):
        print("ci_test_gate: disabled by adapter")
        return []
    has_tests, _conclusive, why = repo_has_tests(data, target)
    issues = ci_test_gate_issues(data, target)
    print(
        f"ci_test_gate: enforcement={cfg['enforcement']} events={'/'.join(cfg['events'])} "
        f"has_tests={str(has_tests).lower()} ({why}) status={'drift' if issues else 'ok'}"
    )
    for issue in issues:
        print(f"ci_test_gate_issue: {issue}")
    return issues


def ci_health_check(args: argparse.Namespace) -> int:
    """CI/CD pipeline health check: verifies the repo's tests are actually run by CI on PR/push.
    Blocks when enforcement is 'block' (or --strict). RCA: tests defined but never executed."""
    data, _project_path, target = lane_project(args)
    issues = print_ci_test_gate_status(data, target)
    cfg = ci_test_gate_config(data)
    blocking = cfg["enforcement"] == "block" or getattr(args, "strict", False)
    if issues and blocking:
        print(
            "ci_health_check: blocked - the repo has tests but CI does not run them on every PR/push; "
            "wire a CI workflow that runs the real test suite before shipping",
            file=sys.stderr,
        )
        return 1
    if issues:
        print("ci_health_check: warn - " + issues[0])
        return 0
    print("ci_health_check: ok")
    return 0


def backlog_provider_status(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    provider = backlog_provider_config(data)
    print(backlog_provider_status_summary(data))
    if not provider["enabled"]:
        print("backlog_provider_status: disabled")
        return 0
    issues = goal_tracker_status_issues(data, target)
    if issues:
        for issue in issues:
            print(f"backlog_provider_issue: {issue}")
        print("backlog_provider_status: blocked")
        return 1
    fields = goal_tracker_project_fields(data, target)
    print("backlog_provider_status: ok")
    print(f"backlog_provider_fields: {', '.join(str(field.get('name')) for field in fields if field.get('name'))}")
    if provider_item_completion_enabled(data):
        print("backlog_provider_next_action: run `tautline backlog-provider-next --target .` to select the next provider item; the issue/work item is the completion unit")
    else:
        print("backlog_provider_next_action: run `tautline backlog-provider-next --target .` when no active goal ledger exists")
    return 0


def backlog_provider_next(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    provider = backlog_provider_config(data)
    if not provider["enabled"]:
        print("backlog_provider_next: disabled")
        return 0
    item_type = str(getattr(args, "item_type", "") or "").strip().lower()
    issues = goal_tracker_status_issues(data, target)
    if issues:
        for issue in issues:
            print(f"backlog_provider_issue: {issue}", file=sys.stderr)
        return 1
    epics = print_board_selection_mode(data, args, "backlog_provider_next")
    item = goal_tracker_select_next_item(data, target, item_type=item_type, epics=epics)
    if item is None:
        print("backlog_provider_next: none")
        scope = f" within assigned epics: {', '.join(epics)}" if epics else ""
        print(f"backlog_provider_next_action: no ready GitHub Project item matched the adapter query/status/type mapping{scope}")
        return 1
    print("backlog_provider_next: found")
    print_goal_tracker_item(data, item, "backlog_provider_next")
    next_ref = shlex.quote(goal_tracker_item_id(item) or goal_tracker_item_url(item) or goal_tracker_item_title(item))
    next_text = (
        "to claim the board item active, then review the repo execution plan; the GitHub issue/work item is the completion unit"
        if provider_item_completion_enabled(data)
        else "to claim the board item active, then review the repo source-of-truth plan before execution"
    )
    print(
        "backlog_provider_next_action: run `tautline backlog-provider-sync --target . "
        f"--item {next_ref} --write`, "
        f"{next_text}"
    )
    return 0


def backlog_provider_sync(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    provider = backlog_provider_config(data)
    if not provider["enabled"]:
        raise SystemExit("backlogProvider is disabled in this project adapter")
    issues = goal_tracker_status_issues(data, target)
    if issues:
        raise SystemExit("; ".join(issues))
    item = resolve_goal_tracker_item(data, target, args.item)
    item_ref = goal_tracker_item_id(item) or goal_tracker_item_url(item) or goal_tracker_item_title(item)
    path = goal_tracker_plan_path(data, target, item)
    text = goal_tracker_sync_text(data, target, item, path)
    print("backlog_provider_sync: ready")
    print_goal_tracker_item(data, item, "backlog_provider_sync")
    print(f"backlog_provider_sync_plan: {path_display(target, path)}")
    if args.write:
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(text, encoding="utf-8")
        print(f"backlog_provider_sync_written: {path}")
    else:
        print("backlog_provider_sync_write: false")
    goal_tracker_sync_claim_active(data, target, item_ref, item, "backlog_provider_sync", bool(args.write))
    if provider_item_completion_enabled(data):
        print("backlog_provider_sync_next_action: run cross-model review for the repo execution plan; keep the GitHub issue/Project item current through closeout")
    else:
        print("backlog_provider_sync_next_action: run cross-model review for the repo plan before execution; do not execute directly from the GitHub Project item")
    return 0


def backlog_provider_done_evidence_text(args: argparse.Namespace, target: Path) -> str:
    parts: list[str] = []
    inline = str(getattr(args, "verification_evidence", "") or "").strip()
    if inline:
        parts.append(inline)
    evidence_file = getattr(args, "verification_evidence_file", None)
    if evidence_file:
        path = cli_path(target, str(evidence_file))
        try:
            file_text = path.read_text(encoding="utf-8").strip()
        except OSError as exc:
            raise SystemExit(f"verification evidence file unreadable: {path}: {exc}") from exc
        if file_text:
            parts.append(file_text)
    evidence_url = str(getattr(args, "verification_evidence_url", "") or "").strip()
    if evidence_url:
        if not evidence_url.startswith(("https://", "http://")):
            raise SystemExit("--verification-evidence-url must be an http(s) URL")
        parts.append(f"Verification artifact: {evidence_url}")
    return "\n\n".join(parts).strip()


def backlog_provider_done_evidence_comment(evidence: str) -> str:
    return "## Verification Evidence\n\n" + evidence.strip() + "\n"


def backlog_provider_update(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    provider = backlog_provider_config(data)
    if not provider["enabled"]:
        raise SystemExit("backlogProvider is disabled in this project adapter")
    status = str(args.status or "").strip()
    evidence = backlog_provider_done_evidence_text(args, target)
    result, comment_url = goal_tracker_update_status_with_done_evidence(
        data,
        target,
        args.item,
        status,
        evidence=evidence,
        context="backlog-provider-update",
    )
    if comment_url:
        print(f"backlog_provider_update_verification_comment: {comment_url or 'posted'}")
    print("goal_tracker_update: ok")
    print(f"goal_tracker_update_item_id: {result['item_id']}")
    print(f"goal_tracker_update_status: {result['status']}")
    if result.get("output"):
        print(f"goal_tracker_update_output: {result['output']}")
    print("backlog_provider_update: ok")
    return 0


def markdown_title_and_summary(path: Path) -> tuple[str, str]:
    text = path.read_text(encoding="utf-8", errors="replace")
    title = ""
    for line in text.splitlines():
        match = re.match(r"^#\s+(.+?)\s*$", line)
        if match:
            title = match.group(1).strip()
            break
    if not title:
        title = path.stem.replace("-", " ").replace("_", " ").strip().title()
    summary_parts = []
    in_fence = False
    for line in text.splitlines():
        stripped = line.strip()
        if stripped.startswith("```"):
            in_fence = not in_fence
            continue
        if in_fence or not stripped:
            if summary_parts:
                break
            continue
        if stripped.startswith("#") or stripped.startswith("---"):
            continue
        if stripped.startswith(("-", "*", "|", ">", "<!--")):
            continue
        summary_parts.append(stripped)
        if len(" ".join(summary_parts)) >= 320:
            break
    summary = " ".join(summary_parts).strip()
    if len(summary) > 360:
        summary = summary[:357].rstrip() + "..."
    return title, summary or "No plain-language summary found; inspect the source item before export."


def backlog_provider_candidate_roots(data: dict, target: Path) -> list[Path]:
    roots = [
        configured_path(target, data["goalArtifacts"]["sourceOfTruth"]),
        configured_path(target, data["planningArtifacts"]["sourceOfTruth"]),
    ]
    bug_source = str(data.get("bugBacklog", {}).get("sourceOfTruth", "")).strip()
    if bug_source and bug_source != "backlogAdapter" and not bug_source.startswith(BOOTSTRAP_REQUIRED_PREFIX):
        roots.append(configured_path(target, bug_source))
    unique: list[Path] = []
    seen = set()
    for root in roots:
        key = str(root.resolve(strict=False))
        if key not in seen:
            unique.append(root)
            seen.add(key)
    return unique


def backlog_provider_guess_item_type(data: dict, target: Path, path: Path, title: str, summary: str) -> str:
    goal_root = configured_path(target, data["goalArtifacts"]["sourceOfTruth"])
    if path_is_under(path, goal_root):
        return "goal"
    haystack = f"{path.name} {title} {summary}".lower()
    if "bug" in haystack or "defect" in haystack:
        return "bug"
    if "milestone" in haystack or re.search(r"\bp[0-9]+-[0-9]+", haystack):
        return "milestone"
    return "task"


def backlog_provider_candidates(data: dict, target: Path, item_type: str = "", limit: int = 120) -> list[dict]:
    allowed = set(backlog_provider_config(data).get("itemTypes", []))
    requested = item_type.strip().lower()
    if requested and requested not in allowed:
        raise SystemExit(f"backlogProvider.itemTypes does not allow item type: {requested}")
    files = markdown_files_under(data, target, backlog_provider_candidate_roots(data, target))
    candidates = []
    for path in files:
        rel = path_display(target, path)
        lower_rel = rel.lower()
        if "/.plan-reviews/" in lower_rel or lower_rel.endswith("/_index.md") or lower_rel.endswith("/goal.template.md") or lower_rel.endswith("/_template.md"):
            continue
        title, summary = markdown_title_and_summary(path)
        guessed = backlog_provider_guess_item_type(data, target, path, title, summary)
        if guessed not in allowed:
            continue
        if requested and guessed != requested:
            continue
        candidates.append({"path": rel, "type": guessed, "title": title, "summary": summary})
        if len(candidates) >= limit:
            break
    return candidates


def backlog_provider_migration_interview_text(data: dict, target: Path, candidates: list[dict]) -> str:
    provider = backlog_provider_config(data)
    lines = [
        "# Backlog Provider Migration Interview",
        "",
        f"Project: {data['project']}",
        f"Repository: {data['repo']}",
        f"Provider: {provider['provider']}",
        f"Project: {provider['owner']}/{provider['projectNumber']}",
        "",
        "Purpose: migrate existing repo backlog items into the external provider only when the human operator approves that specific item for stakeholder prioritization.",
        "",
        "For each candidate, answer exactly one action:",
        "- `export` - create/update a GitHub Project item for stakeholder prioritization.",
        "- `keep-repo-only` - keep the item only in repo planning/backlog.",
        "- `already-linked` - record the existing GitHub Project item URL/id.",
        "- `archive-ignore` - do not migrate; historical or no longer relevant.",
        "",
        "Do not export every item by default. Present the plain-language summary first, then ask the human operator for the item decision. Tactical planning and implementation prioritization stay in repo source-of-truth plans.",
        "",
    ]
    if not candidates:
        lines.extend(["## Candidates", "", "No candidate Markdown backlog items found under configured goal/planning/bug roots.", ""])
        return "\n".join(lines)
    for index, item in enumerate(candidates, start=1):
        lines.extend(
            [
                f"## Candidate {index}: {item['title']}",
                "",
                f"- Source: `{item['path']}`",
                f"- Type guess: `{item['type']}`",
                f"- Plain-language summary: {item['summary']}",
                "- Recommended operator question: Should this be sent to the GitHub Project for stakeholder prioritization?",
                "",
                "Action: BOOTSTRAP REQUIRED",
                "External item URL/id: BOOTSTRAP REQUIRED",
                "Notes: BOOTSTRAP REQUIRED",
                "",
            ]
        )
    return "\n".join(lines)


def backlog_provider_migration_interview(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    provider = backlog_provider_config(data)
    if not provider["enabled"]:
        print("backlog_provider_migration_interview: disabled")
        print("backlog_provider_migration_next_action: enable backlogProvider in the project adapter before migration")
        return 1
    item_type = str(getattr(args, "item_type", "") or "").strip().lower()
    candidates = backlog_provider_candidates(data, target, item_type=item_type, limit=int(args.limit))
    text = backlog_provider_migration_interview_text(data, target, candidates)
    if args.write:
        path = configured_path(target, provider["migrationInterviewPath"])
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(text, encoding="utf-8")
        print(f"backlog_provider_migration_interview_written: {path}")
        print(f"backlog_provider_migration_interview_sha256: {file_sha256(path)}")
    else:
        print(text, end="")
    print(f"backlog_provider_migration_candidates: {len(candidates)}")
    print("backlog_provider_migration_next_action: walk candidates with the human operator; export only items marked `export`")
    return 0


def goal_tracker_set_single_select_field(data: dict, target: Path, item_id: str, field_name: str, value: str) -> None:
    if not field_name or not value:
        return
    fields = goal_tracker_project_fields(data, target)
    field = goal_tracker_field_by_name(fields, field_name)
    if not field or not field.get("id"):
        raise SystemExit(f"GitHub Project field missing or has no id: {field_name}")
    option = goal_tracker_option_by_name(field, value)
    if not option or not option.get("id"):
        raise SystemExit(f"GitHub Project field option missing for {field_name}: {value}")
    project = goal_tracker_project_view(data, target)
    project_id = goal_tracker_value_text(project.get("id"))
    if not project_id:
        raise SystemExit("gh project view did not return a project id")
    code, out, err = run_command(
        [
            "gh",
            "project",
            "item-edit",
            "--id",
            item_id,
            "--project-id",
            project_id,
            "--field-id",
            str(field["id"]),
            "--single-select-option-id",
            str(option["id"]),
        ],
        cwd=target,
        timeout=30,
    )
    if code != 0:
        raise SystemExit(err or out or f"gh project item-edit failed for {field_name}")


def github_issue_number_from_url(issue_url: str) -> str:
    match = re.search(r"/(?:issues|pull)/([0-9]+)(?:$|[?#])", issue_url.strip())
    return match.group(1) if match else ""


MILESTONE_PROGRESS_HEADING = "## Milestone Progress"
MILESTONE_PROGRESS_START = "<!-- minervit:milestone-progress:start -->"
MILESTONE_PROGRESS_END = "<!-- minervit:milestone-progress:end -->"
_MILESTONE_PROGRESS_ANNOTATIONS = {
    "in_progress": " — _in progress_",
    "blocked": " — _blocked_",
    "deferred": " — _deferred_",
}


def milestone_progress_checkbox_line(milestone: dict) -> str:
    fallback = f"Milestone {milestone.get('index')}"
    # Strip leading markdown heading markers so a stray "## ..." title cannot masquerade as
    # structure inside the issue body or interfere with later boundary detection.
    title = compact_one_line(str(milestone.get("title") or fallback).strip().lstrip("#").strip() or fallback)
    status = str(milestone.get("status") or "").strip().lower()
    box = "x" if status == "complete" else " "
    return f"- [{box}] {title}{_MILESTONE_PROGRESS_ANNOTATIONS.get(status, '')}"


def render_milestone_progress_block(run: dict) -> str:
    """Render the framework-owned milestone checklist as a marker-delimited block.

    The block is wrapped in explicit start/end HTML-comment markers so it can be replaced in
    place without parsing the surrounding human-authored issue body: nothing outside the
    markers participates in boundary detection, and nothing outside the markers is touched.
    The whole block is re-rendered (not appended to) so it is idempotent across transitions.
    """
    milestones = sorted(run.get("milestones") or [], key=lambda m: m.get("index") or 0)
    done = sum(1 for m in milestones if str(m.get("status") or "").strip().lower() == "complete")
    updated = str(run.get("updatedAt") or "").strip()
    note = (
        f"_Auto-maintained by Minervit AI Delivery — {done}/{len(milestones)} milestones complete"
        + (f" (updated {updated})" if updated else "")
        + ". This block is regenerated on each milestone transition; edits between the markers are overwritten._"
    )
    lines = [
        MILESTONE_PROGRESS_START,
        MILESTONE_PROGRESS_HEADING,
        "",
        note,
        "",
        *[milestone_progress_checkbox_line(m) for m in milestones],
        MILESTONE_PROGRESS_END,
    ]
    return "\n".join(lines)


def upsert_marker_block(text: str, start_marker: str, end_marker: str, block: str) -> str:
    """Replace the span from start_marker through end_marker (inclusive) with block, or append
    block when the markers are absent. Everything outside the marker span is preserved, so
    milestone titles and any human edits elsewhere in the issue body can neither participate in
    boundary detection nor be clobbered.
    """
    block = block.strip()
    start = text.find(start_marker)
    end = text.find(end_marker)
    if start != -1 and end != -1 and end >= start:
        before = text[:start].rstrip()
        after = text[end + len(end_marker):].strip()
        return "\n\n".join(segment for segment in (before, block, after) if segment) + "\n"
    base = text.strip()
    return (base + "\n\n" + block if base else block) + "\n"


def sync_goal_issue_milestone_progress(data: dict, target: Path, run: dict) -> list[str]:
    """Best-effort: mirror milestone completion into the goal's GitHub issue body as a
    framework-owned `## Milestone Progress` checklist, so stakeholders see weekly progress
    without waiting on a board sync.

    Never raises and never blocks goal-advance: gh-missing, network, permission, or
    not-found failures degrade to a returned status/warning line. Silently no-ops when the
    goal plan is not linked to a GitHub issue (the issue link is the implicit opt-in).
    """
    milestones = run.get("milestones") or []
    if not milestones:
        return []
    source_goal = goal_run_source_path(data, target, run)
    if not source_goal:
        return []
    issue_url = (goal_tracker_source_values_from_plan(source_goal).get("project_item_url") or "").strip()
    issue_number = github_issue_number_from_url(issue_url)
    if not issue_number:
        return []  # goal not linked to a GitHub issue -> nothing to mirror
    repo = str(data.get("repo") or "").strip()
    if not re.fullmatch(r"[^/\s]+/[^/\s]+", repo):
        return [f"milestone_progress_skipped: adapter repo is not owner/name ({repo or 'missing'}); issue #{issue_number} checklist not updated"]
    try:
        code, body, err = run_command(
            ["gh", "issue", "view", issue_number, "--repo", repo, "--json", "body", "--jq", '.body // ""'],
            cwd=target,
            timeout=30,
        )
    except OSError as exc:
        return [f"milestone_progress_skipped: gh unavailable ({exc}); issue #{issue_number} checklist not updated"]
    if code != 0:
        return [f"milestone_progress_skipped: could not read issue #{issue_number} ({(err or body or 'gh issue view failed').strip()[:160]}); checklist not updated"]
    # Defensive: `gh ... --jq .body` emits the literal string "null" for an empty body; the
    # `// ""` coalesce above should prevent it, but normalize so a title-only issue is never
    # corrupted with a literal `null` line in the rewritten body.
    if body.strip() == "null":
        body = ""
    new_body = upsert_marker_block(body, MILESTONE_PROGRESS_START, MILESTONE_PROGRESS_END, render_milestone_progress_block(run))
    if new_body.strip() == body.strip():
        return []  # idempotent: already current
    try:
        code, out, err = run_command(
            ["gh", "issue", "edit", issue_number, "--repo", repo, "--body", new_body],
            cwd=target,
            timeout=30,
        )
    except OSError as exc:
        return [f"milestone_progress_warning: gh unavailable ({exc}); issue #{issue_number} checklist is stale"]
    if code != 0:
        return [f"milestone_progress_warning: could not update issue #{issue_number} ({(err or out or 'gh issue edit failed').strip()[:160]}); checklist is stale"]
    done = sum(1 for m in milestones if str(m.get("status") or "").strip().lower() == "complete")
    return [f"milestone_progress_synced: issue #{issue_number} checklist updated ({done}/{len(milestones)} complete)"]


def ui_evidence_manifest_path(data: dict, target: Path, path_arg: Path | None = None) -> Path | None:
    if path_arg is not None:
        return cli_path(target, str(path_arg)).resolve(strict=False)
    cfg = ui_evidence_config(data)
    evidence_dir = configured_path(target, cfg["evidenceDir"])
    if not evidence_dir.exists():
        return None
    candidates = [
        path
        for path in evidence_dir.glob(cfg["manifestGlob"])
        if path.is_file() and path.suffix.lower() == ".json"
    ]
    if not candidates:
        return None
    return sorted(candidates, key=lambda path: (path.stat().st_mtime, path.name), reverse=True)[0]


def load_ui_evidence_manifest(path: Path) -> dict:
    try:
        manifest = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        raise SystemExit(f"ui evidence manifest unreadable: {path}: {exc}") from exc
    if not isinstance(manifest, dict):
        raise SystemExit(f"ui evidence manifest must be a JSON object: {path}")
    return manifest


def ui_evidence_screenshot_entries(manifest: dict) -> list[dict]:
    raw = manifest.get("screenshots", manifest.get("images", []))
    if not isinstance(raw, list):
        return []
    return [item for item in raw if isinstance(item, dict)]


def ui_evidence_path_for_entry(target: Path, entry: dict) -> Path | None:
    value = str(entry.get("path") or entry.get("file") or "").strip()
    if not value:
        return None
    candidate = Path(value).expanduser()
    if not candidate.is_absolute():
        candidate = target / candidate
    return candidate.resolve(strict=False)


def ui_evidence_manifest_errors(manifest: dict, manifest_path: Path, target: Path) -> list[str]:
    errors: list[str] = []
    if manifest.get("schema") != UI_EVIDENCE_SCHEMA:
        errors.append(f"schema must be {UI_EVIDENCE_SCHEMA}")
    status = str(manifest.get("status") or "captured").strip()
    if status not in {"captured", "not_applicable"}:
        errors.append("status must be captured or not_applicable")
    if status == "not_applicable":
        reason = str(manifest.get("notApplicableReason") or "").strip()
        if len(reason) < 12:
            errors.append("not_applicable manifests require a concrete notApplicableReason")
        return errors

    screenshots = ui_evidence_screenshot_entries(manifest)
    if not screenshots:
        errors.append("captured manifests require at least one screenshot entry")
        return errors
    for index, screenshot in enumerate(screenshots, start=1):
        path = ui_evidence_path_for_entry(target, screenshot)
        url = str(screenshot.get("url") or screenshot.get("href") or "").strip()
        sha = str(screenshot.get("sha256") or "").strip().lower()
        if not path and not url:
            errors.append(f"screenshot {index} must include a path or hosted url")
        if not sha or not re.fullmatch(r"[0-9a-f]{64}", sha):
            errors.append(f"screenshot {index} must include a 64-character sha256")
        if path:
            if not path.exists():
                errors.append(f"screenshot {index} path is missing: {path_relative_to_target(target, path)}")
            elif sha and re.fullmatch(r"[0-9a-f]{64}", sha):
                actual = file_sha256(path)
                if actual != sha:
                    errors.append(
                        f"screenshot {index} sha256 mismatch for {path_relative_to_target(target, path)} "
                        f"({sha} != {actual})"
                    )
    return errors


def ui_evidence_completion_blocker(
    data: dict,
    target: Path,
    event: str,
    path_arg: Path | None = None,
) -> tuple[str | None, Path | None, dict | None]:
    cfg = ui_evidence_config(data)
    if not cfg["enabled"] or cfg["enforcement"] != "block" or event not in cfg["requiredEvents"]:
        return None, ui_evidence_manifest_path(data, target, path_arg), None
    manifest_path = ui_evidence_manifest_path(data, target, path_arg)
    if manifest_path is None:
        capture = cfg.get("captureCommand") or "run the project Playwright screenshot capture"
        return (
            "ui evidence required before "
            f"{event}: no manifest found under {cfg['evidenceDir']} ({cfg['manifestGlob']}). "
            f"{capture}, then pass --ui-evidence-manifest <manifest>. If UI proof is genuinely not "
            "possible, write a not_applicable manifest with a concrete reason.",
            None,
            None,
        )
    manifest = load_ui_evidence_manifest(manifest_path)
    errors = ui_evidence_manifest_errors(manifest, manifest_path, target)
    if errors:
        return (
            f"ui evidence manifest invalid for {event}: {path_relative_to_target(target, manifest_path)}: "
            + "; ".join(errors),
            manifest_path,
            manifest,
        )
    return None, manifest_path, manifest


def ui_evidence_issue_number(data: dict, target: Path, run: dict) -> str:
    source_goal = goal_run_source_path(data, target, run)
    if not source_goal:
        return ""
    issue_url = (goal_tracker_source_values_from_plan(source_goal).get("project_item_url") or "").strip()
    return github_issue_number_from_url(issue_url)


def ui_evidence_issue_number_from_ref(data: dict, target: Path, issue_ref: str) -> str:
    text = str(issue_ref or "").strip()
    if not text:
        return ""
    if re.fullmatch(r"#?[0-9]+", text):
        return text.lstrip("#")
    issue_number = github_issue_number_from_url(text)
    if issue_number:
        return issue_number
    if backlog_provider_config(data).get("enabled") or goal_tracker_config(data).get("enabled"):
        try:
            item = resolve_goal_tracker_item(data, target, text)
        except SystemExit:
            return ""
        return github_issue_number_from_url(goal_tracker_item_url(item))
    return ""


def ui_evidence_comment_lines(
    data: dict,
    target: Path,
    event: str,
    manifest_path: Path,
    manifest: dict,
    *,
    context_label: str,
    context_value: str,
) -> str:
    status = str(manifest.get("status") or "captured").strip()
    summary = compact_one_line(str(manifest.get("summary") or manifest.get("title") or "Playwright UI evidence").strip())
    command = compact_one_line(str(manifest.get("command") or manifest.get("captureCommand") or "").strip())
    target_url = compact_one_line(str(manifest.get("targetUrl") or manifest.get("baseUrl") or "").strip())
    lines = [
        "## UI Evidence Proof",
        "",
        "_Auto-submitted by Minervit AI Delivery from a Playwright UI evidence manifest._",
        "",
        f"- Event: `{event}`",
        f"- {context_label}: `{compact_one_line(context_value or 'unknown')}`",
        f"- Manifest: `{path_relative_to_target(target, manifest_path)}`",
        f"- Status: `{status}`",
        f"- Summary: {summary}",
    ]
    if command:
        lines.append(f"- Command: `{command}`")
    if target_url:
        lines.append(f"- Target: {target_url}")
    if status == "not_applicable":
        lines.append(f"- Not applicable reason: {compact_one_line(str(manifest.get('notApplicableReason') or 'not provided'))}")
        return "\n".join(lines) + "\n"

    screenshots = ui_evidence_screenshot_entries(manifest)
    lines.extend(["", "### Screenshots", ""])
    for index, screenshot in enumerate(screenshots[:20], start=1):
        path = ui_evidence_path_for_entry(target, screenshot)
        rel_path = path_relative_to_target(target, path) if path else ""
        url = str(screenshot.get("url") or screenshot.get("href") or "").strip()
        sha = str(screenshot.get("sha256") or "").strip().lower()
        label = compact_one_line(
            str(
                screenshot.get("label")
                or screenshot.get("description")
                or screenshot.get("route")
                or screenshot.get("name")
                or rel_path
                or url
                or f"screenshot {index}"
            )
        )
        meta = []
        for key in ("route", "viewport", "browser"):
            value = compact_one_line(str(screenshot.get(key) or "").strip())
            if value:
                meta.append(f"{key}={value}")
        if rel_path:
            meta.append(f"path={rel_path}")
        if sha:
            meta.append(f"sha256={sha}")
        target_ref = f"[{label}]({url})" if url else f"`{rel_path or label}`"
        lines.append(f"- {target_ref}" + (f" ({'; '.join(meta)})" if meta else ""))
        if url:
            lines.append(f"  ![{label}]({url})")
    if len(screenshots) > 20:
        lines.append(f"- {len(screenshots) - 20} additional screenshot(s) omitted from this comment; see the manifest.")
    return "\n".join(lines) + "\n"


def sync_ui_evidence_to_issue(
    data: dict,
    target: Path,
    issue_number: str,
    event: str,
    manifest_path: Path | None,
    manifest: dict | None = None,
    *,
    context_label: str,
    context_value: str,
) -> tuple[list[str], bool]:
    cfg = ui_evidence_config(data)
    comments = cfg["issueComments"]
    if not cfg["enabled"] or not comments["enabled"] or event not in comments["triggerEvents"]:
        return [], True
    issue_number = str(issue_number or "").strip().lstrip("#")
    if not issue_number:
        return ["ui_evidence_comment_skipped: no GitHub issue number resolved; no UI proof comment posted"], not comments["required"]
    repo = str(data.get("repo") or "").strip()
    if not re.fullmatch(r"[^/\s]+/[^/\s]+", repo):
        return [f"ui_evidence_comment_skipped: adapter repo is not owner/name ({repo or 'missing'}); issue #{issue_number} not updated"], not comments["required"]
    if manifest_path is None:
        manifest_path = ui_evidence_manifest_path(data, target)
    if manifest_path is None:
        return [f"ui_evidence_comment_skipped: no UI evidence manifest found; issue #{issue_number} not updated"], not comments["required"]
    if manifest is None:
        manifest = load_ui_evidence_manifest(manifest_path)
    errors = ui_evidence_manifest_errors(manifest, manifest_path, target)
    if errors:
        return [f"ui_evidence_comment_skipped: invalid UI evidence manifest ({'; '.join(errors)}); issue #{issue_number} not updated"], not comments["required"]
    body = ui_evidence_comment_lines(
        data,
        target,
        event,
        manifest_path,
        manifest,
        context_label=context_label,
        context_value=context_value,
    )
    try:
        code, out, err = run_command(
            ["gh", "issue", "comment", issue_number, "--repo", repo, "--body", body],
            cwd=target,
            timeout=30,
        )
    except OSError as exc:
        return [f"ui_evidence_comment_warning: gh unavailable ({exc}); issue #{issue_number} not updated"], False if comments["required"] else True
    if code != 0:
        return [f"ui_evidence_comment_warning: could not comment on issue #{issue_number} ({(err or out or 'gh issue comment failed').strip()[:160]})"], False if comments["required"] else True
    return [f"ui_evidence_comment_synced: issue #{issue_number} received UI proof from {path_relative_to_target(target, manifest_path)}"], True


def sync_goal_issue_ui_evidence(
    data: dict,
    target: Path,
    run: dict,
    event: str,
    manifest_path: Path | None,
    manifest: dict | None = None,
) -> tuple[list[str], bool]:
    cfg = ui_evidence_config(data)
    comments = cfg["issueComments"]
    if not cfg["enabled"] or not comments["enabled"] or event not in comments["triggerEvents"]:
        return [], True
    issue_number = ui_evidence_issue_number(data, target, run)
    if not issue_number:
        return [f"ui_evidence_comment_skipped: goal is not linked to a GitHub issue; no UI proof comment posted"], not comments["required"]
    context_label = "Work item" if provider_item_completion_enabled(data) else "Goal"
    context_value = f"#{issue_number}" if provider_item_completion_enabled(data) else str(run.get("goalId") or "unknown")
    return sync_ui_evidence_to_issue(
        data,
        target,
        issue_number,
        event,
        manifest_path,
        manifest,
        context_label=context_label,
        context_value=context_value,
    )


def ui_evidence_submit(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    cfg = ui_evidence_config(data)
    if not cfg["enabled"]:
        print("ui_evidence_submit: disabled")
        return 0
    manifest_path = ui_evidence_manifest_path(data, target, args.manifest)
    if manifest_path is None:
        raise SystemExit(
            f"ui evidence manifest missing under {cfg['evidenceDir']} ({cfg['manifestGlob']}); "
            "run Playwright capture and pass --manifest <path>"
        )
    manifest = load_ui_evidence_manifest(manifest_path)
    errors = ui_evidence_manifest_errors(manifest, manifest_path, target)
    if errors:
        raise SystemExit("ui evidence manifest invalid: " + "; ".join(errors))
    issue_number = ui_evidence_issue_number_from_ref(data, target, args.issue)
    if not issue_number:
        raise SystemExit(f"could not resolve GitHub issue from --issue {args.issue!r}")
    output, ok = sync_ui_evidence_to_issue(
        data,
        target,
        issue_number,
        args.event,
        manifest_path,
        manifest,
        context_label="Work item",
        context_value=f"#{issue_number}",
    )
    if not output:
        output = [f"ui_evidence_submit_skipped: event {args.event} is not configured for issue comments"]
    for line in output:
        print(line)
    if not ok:
        return 1
    print(f"ui_evidence_submit: ok issue=#{issue_number} manifest={path_relative_to_target(target, manifest_path)}")
    return 0


def print_ui_evidence_status(data: dict, target: Path) -> list[str]:
    cfg = ui_evidence_config(data)
    manifest_path = ui_evidence_manifest_path(data, target)
    comments = cfg["issueComments"]
    print(
        "ui_evidence: "
        f"enabled={str(cfg['enabled']).lower()} enforcement={cfg['enforcement']} tool={cfg['tool']} "
        f"dir={cfg['evidenceDir']} latest={path_relative_to_target(target, manifest_path) if manifest_path else 'none'} "
        f"issue_comments={str(comments['enabled']).lower()} required={str(comments['required']).lower()}"
    )
    if not cfg["enabled"] or manifest_path is None:
        return []
    try:
        manifest = load_ui_evidence_manifest(manifest_path)
        issues = ui_evidence_manifest_errors(manifest, manifest_path, target)
    except SystemExit as exc:
        issues = [str(exc)]
    for issue in issues:
        print(f"ui_evidence_issue: {issue}")
    return issues


def backlog_provider_create_repo_issue(data: dict, target: Path, title: str, body: str) -> tuple[str, str]:
    repo = str(data.get("repo") or "").strip()
    if not re.fullmatch(r"[^/\s]+/[^/\s]+", repo):
        raise SystemExit(f"Project adapter repo must be owner/name before exporting real issues: {repo or 'missing'}")
    code, out, err = run_command(
        [
            "gh",
            "issue",
            "create",
            "--repo",
            repo,
            "--title",
            title,
            "--body",
            body,
        ],
        cwd=target,
        timeout=30,
    )
    if code != 0:
        raise SystemExit(err or out or "gh issue create failed")
    issue_url = ""
    for line in reversed(out.splitlines()):
        candidate = line.strip()
        if candidate.startswith("http://") or candidate.startswith("https://"):
            issue_url = candidate
            break
    if not issue_url:
        raise SystemExit("gh issue create did not return an issue URL")
    return issue_url, github_issue_number_from_url(issue_url)


def backlog_provider_add_issue_to_project(data: dict, target: Path, issue_url: str) -> str:
    provider = backlog_provider_config(data)
    payload = goal_tracker_gh_json(
        [
            "project",
            "item-add",
            str(provider["projectNumber"]),
            "--owner",
            provider["owner"],
            "--url",
            issue_url,
            "--format",
            "json",
        ],
        target,
    )
    if not isinstance(payload, dict):
        raise SystemExit("gh project item-add returned unexpected JSON")
    item_id = goal_tracker_value_text(payload.get("id") or payload.get("itemId"))
    if not item_id:
        raise SystemExit("gh project item-add did not return an item id")
    return item_id


def backlog_provider_create_draft_project_item(provider: dict, target: Path, title: str, body: str) -> str:
    payload = goal_tracker_gh_json(
        [
            "project",
            "item-create",
            str(provider["projectNumber"]),
            "--owner",
            provider["owner"],
            "--title",
            title,
            "--body",
            body,
            "--format",
            "json",
        ],
        target,
    )
    if not isinstance(payload, dict):
        raise SystemExit("gh project item-create returned unexpected JSON")
    item_id = goal_tracker_value_text(payload.get("id") or payload.get("itemId"))
    if not item_id:
        raise SystemExit("gh project item-create did not return an item id")
    return item_id


BOARD_NON_CUSTOMER_FACING_MARKERS = [
    "tech debt",
    "technical debt",
    "techdebt",
    "tech-debt",
    "refactor",
    "refactoring",
    "cleanup",
    "clean up",
    "clean-up",
    "chore",
    "linting",
    "reformat",
    "dependency bump",
    "dep bump",
    "bump dependency",
    "bump deps",
    "upgrade dependency",
    "upgrade deps",
    "version bump",
    "ci pipeline",
    "build pipeline",
    "ci/cd",
    "internal tooling",
    "internal-only",
    "internal only",
    "non-customer-facing",
    "non customer facing",
    "not customer-facing",
    "not customer facing",
    "internal bug",
    "internal-only",
    "internal only",
    "admin-only",
    "admin only",
    "back-office",
    "backend-only",
    "developer experience",
    "dx improvement",
    "devx",
    "test coverage",
    "flaky test",
    "dead code",
    "remove unused",
    "code smell",
    "boilerplate",
]


# Every backlog item must LEAD with plain-language business framing before technical detail: what
# it delivers and why it matters. Accept the canonical headings plus close synonyms (the operator's
# three-question phrasing) so honest variations pass.
BACKLOG_LEAD_WHAT_HEADINGS = ["what this delivers", "what it delivers", "what is it", "what is this", "what"]
BACKLOG_LEAD_WHY_HEADINGS = ["why it matters", "why we care", "why this matters", "value", "what value", "business value"]
BACKLOG_LEAD_MIN_CHARS = 24


def _backlog_lead_section(text: str, headings: list[str]) -> tuple[int, str] | None:
    """Earliest-by-position heading matching any of `headings` (exact, case-insensitive, levels
    `#`-`######`), with its section content (to the next heading of any level). Using the EARLIEST
    occurrence keeps the content check and the ordering check on the SAME section, so a placeholder
    `## What` near the top cannot borrow content from a real `## What this delivers` lower down
    (Codex). Returns (position, content) or None."""
    names = {h.lower() for h in headings}
    best: tuple[int, str] | None = None
    for match in re.finditer(r"(?im)^#{1,6}\s*(.+?)\s*$", text):
        if match.group(1).strip().lower() in names and (best is None or match.start() < best[0]):
            rest = text[match.end():]
            next_heading = re.search(r"(?m)^#{1,6}\s+\S", rest)
            content = (rest[: next_heading.start()] if next_heading else rest).strip()
            best = (match.start(), content)
    return best


def _backlog_lead_section_content(text: str, headings: list[str]) -> str | None:
    section = _backlog_lead_section(text, headings)
    return section[1] if section is not None else None


def _backlog_lead_content_ok(content: str | None) -> bool:
    """Real plain-language content: enough characters AND several distinct words, so a placeholder
    like `xxxxxxxxxxxxxxxxxxxxxxxx` does not pass (mirrors the customer-facing justification guard)."""
    if content is None:
        return False
    if len(re.sub(r"\s+", "", content)) < BACKLOG_LEAD_MIN_CHARS:
        return False
    distinct_words = len({word for word in re.findall(r"[a-z0-9]+", content.lower()) if len(word) > 1})
    return distinct_words >= 4


def backlog_item_missing_business_lead(body: str) -> str:
    """Pure: every backlog item must LEAD with plain-language business framing -- what it delivers
    and why it matters -- before technical detail (RCA: items were rote technical readings with no
    business context). Returns a drift/refusal message naming what is missing, or '' when compliant.
    Empty/whitespace body is itself missing the lead."""
    text = body or ""
    what = _backlog_lead_section(text, BACKLOG_LEAD_WHAT_HEADINGS)
    why = _backlog_lead_section(text, BACKLOG_LEAD_WHY_HEADINGS)
    missing = []
    if what is None or not _backlog_lead_content_ok(what[1]):
        missing.append("a plain-language `## What this delivers` section")
    if why is None or not _backlog_lead_content_ok(why[1]):
        missing.append("a plain-language `## Why it matters` section")
    if missing:
        return (
            "backlog item does not lead with a business justification: it is missing "
            + " and ".join(missing)
            + ". Every item must explain in plain language what it delivers and why it matters before technical detail."
        )
    # Lead off: What and Why must be the FIRST section headings. Any other `##`+ section before
    # both of them (e.g. `## Technical detail`, `## Implementation notes`, `## Technical approach`)
    # means the item does not lead with the business justification. This catches every technical
    # heading variant without enumerating them; a leading `#` (level-1) title is ignored (Codex).
    business_names = {h.lower() for h in BACKLOG_LEAD_WHAT_HEADINGS + BACKLOG_LEAD_WHY_HEADINGS}
    lead_end = max(what[0], why[0])
    headings = list(re.finditer(r"(?im)^(#{1,6})\s*(.+?)\s*$", text))
    for idx, match in enumerate(headings):
        if match.start() >= lead_end:
            break
        if match.group(2).strip().lower() in business_names:
            continue
        # Allow a single leading level-1 document TITLE (first heading, `#`, with no substantial
        # body). A level-1 heading that carries real content before What/Why is a section, not a
        # title, and must NOT be skipped (Codex: `# Technical approach` with a paragraph).
        if idx == 0 and len(match.group(1)) == 1:
            section_end = headings[idx + 1].start() if idx + 1 < len(headings) else len(text)
            title_body = text[match.end():section_end]
            if len(re.sub(r"\s+", "", title_body)) < BACKLOG_LEAD_MIN_CHARS:
                continue
        return (
            f"backlog item does not lead with the business justification: the section `{match.group(2).strip()}` "
            "appears before `## What this delivers` / `## Why it matters`. Lead with the business framing, then technical detail."
        )
    return ""


def board_export_non_customer_facing_signal(title: str, summary: str) -> str:
    """Pure: detect technical-debt / non-customer-facing framing in a backlog item proposed for
    the stakeholder board. The GitHub Projects board is exclusively for customer-facing
    functionality and customer-impacting bugs; technical debt, cleanup, internal/non-customer-facing
    work, and CI/dependency chores belong in the repo backlog, not on the board. Returns the matched
    signal or ''."""
    haystack = f"{title}\n{summary}".lower()
    for marker in BOARD_NON_CUSTOMER_FACING_MARKERS:
        if marker in haystack:
            return marker
    return ""


def backlog_provider_export(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    provider = backlog_provider_config(data)
    if not provider["enabled"]:
        raise SystemExit("backlogProvider is disabled in this project adapter")
    item_type = args.item_type.strip().lower()
    if item_type not in provider["itemTypes"]:
        raise SystemExit(f"backlogProvider.itemTypes does not allow item type: {item_type}")
    issues = goal_tracker_status_issues(data, target)
    if issues:
        raise SystemExit("; ".join(issues))
    source_path = cli_path(target, str(args.item_path)).resolve(strict=False)
    if not source_path.is_file():
        raise SystemExit(f"repo backlog item missing: {source_path}")
    title, summary = markdown_title_and_summary(source_path)
    # The board is EXCLUSIVELY customer-facing functionality and customer-impacting bugs; it must
    # not be cluttered with technical debt, cleanup, internal/non-customer-facing work, or chores.
    customer_facing = bool(getattr(args, "customer_facing", False))
    justification = str(getattr(args, "customer_facing_justification", "") or "").strip()
    if not customer_facing:
        raise SystemExit(
            "backlog-provider-export refused: the GitHub Projects board is exclusively for customer-facing "
            "functionality and customer-impacting bugs. Pass --customer-facing only when this item is customer-facing; "
            "keep technical debt, cleanup, internal/non-customer-facing bugs, and CI/dependency chores in the repo backlog (do not export them)."
        )
    signal = board_export_non_customer_facing_signal(title, summary)
    # A justification must be a real sentence about customer impact, not a placeholder: require
    # length plus several distinct words (blocks 'x' and 'xxxxxxxxxxxxxxxxxxxxxxxx').
    distinct_words = len({w for w in re.findall(r"[a-z0-9]+", justification.lower()) if len(w) > 1})
    if signal and (len(justification) < 24 or distinct_words < 4):
        raise SystemExit(
            f"backlog-provider-export refused: this item reads as technical-debt / non-customer-facing (matched '{signal}'). "
            "The stakeholder board must not be cluttered with tech debt or cleanup — keep it in the repo backlog. If it is "
            'genuinely customer-facing, pass --customer-facing-justification "<the concrete customer impact>" as a real '
            "sentence (>= 24 chars, >= 4 distinct words) naming the customer outcome it changes; a placeholder will not pass."
        )
    rel = path_display(target, source_path)
    # Every board item must LEAD with plain-language business framing (what it delivers, why it
    # matters) before technical detail. Require the source item to carry it, and lead the exported
    # body with it (RCA: stakeholder items were rote technical readings with no business context).
    source_text = source_path.read_text(encoding="utf-8", errors="replace")
    # Validate the SOURCE item itself (not a synthetic body), so a source that buries What/Why below
    # technical detail is refused (Codex P1).
    lead_issue = backlog_item_missing_business_lead(source_text)
    what = (_backlog_lead_section_content(source_text, BACKLOG_LEAD_WHAT_HEADINGS) or "").strip()
    why = (_backlog_lead_section_content(source_text, BACKLOG_LEAD_WHY_HEADINGS) or "").strip()
    if lead_issue:
        raise SystemExit(
            "backlog-provider-export refused: " + lead_issue + " Add plain-language "
            f"`## What this delivers` and `## Why it matters` sections (>= {BACKLOG_LEAD_MIN_CHARS} chars each) to "
            f"{rel} before exporting; a stakeholder board item must lead with the business justification, not technical detail."
        )
    justification_line = f"\nCustomer-facing justification: {justification}\n" if justification else ""
    body = (
        f"## What this delivers\n{what}\n\n"
        f"## Why it matters\n{why}\n\n"
        f"## Technical detail\n"
        f"Source repo item: {rel}\n"
        f"Item type: {item_type}\n"
        f"Customer-facing: yes{justification_line}\n"
        f"Plain-language summary:\n{summary}\n\n"
        "Execution authority remains in the repo source-of-truth plan. This GitHub Project item is for stakeholder prioritization, status, links, and concise progress notes. The board is exclusively customer-facing; technical debt and cleanup stay in the repo backlog."
    )
    print("backlog_provider_export: ready")
    print(f"backlog_provider_export_source: {rel}")
    print(f"backlog_provider_export_title: {title}")
    print(f"backlog_provider_export_type: {item_type}")
    if not args.write:
        print("backlog_provider_export_write: false")
        print("backlog_provider_export_next_action: rerun with --write only after the migration interview marks this item `export`")
        return 0
    issue_url = ""
    issue_number = ""
    if args.draft:
        item_id = backlog_provider_create_draft_project_item(provider, target, title, body)
        content_type = "draft_issue"
    else:
        issue_url, issue_number = backlog_provider_create_repo_issue(data, target, title, body)
        item_id = backlog_provider_add_issue_to_project(data, target, issue_url)
        content_type = "issue"
    if provider.get("typeField"):
        goal_tracker_set_single_select_field(data, target, item_id, provider["typeField"], item_type)
    if provider.get("statusField") and provider.get("readyStatuses"):
        goal_tracker_set_single_select_field(data, target, item_id, provider["statusField"], provider["readyStatuses"][0])
    print("backlog_provider_export_written: ok")
    print(f"backlog_provider_export_content_type: {content_type}")
    print(f"backlog_provider_export_item_id: {item_id}")
    print(f"backlog_provider_export_issue_url: {issue_url or 'none'}")
    print(f"backlog_provider_export_issue_number: {issue_number or 'none'}")
    if args.draft:
        print("backlog_provider_export_warning: draft issue created only because --draft was explicit; drafts are not repo-visible tracker items")
    print("backlog_provider_export_next_action: record the issue url and project item id in the migration interview and keep repo tactical planning authoritative")
    return 0


def _print_provider_diff(proposed: dict, current: dict, prefix: str) -> None:
    """Print the proposed backlogProvider vs current diff, one line per board-derived key."""
    for key in ("statusField", "readyStatuses", "activeStatuses", "doneStatuses", "blockedStatuses", "priorityField", "orderField", "schemaHash"):
        print(f"{prefix}: {key} = {proposed.get(key)!r}  (current: {current.get(key)!r})")


def backlog_board_examine(args: argparse.Namespace) -> int:
    """Read-only board diagnostic: print schema profile, assumptions, unknowns, and proposed
    adapter diff.  This handler NEVER writes the adapter or the board — it is purely diagnostic
    and is designed to run even when the adapter is incomplete, missing, or mid-adoption."""
    target = args.target.resolve()

    # Lenient adapter read: examine is an operator diagnostic that runs precisely when the
    # adapter and board are out of sync or mid-adoption, so we cannot require a valid adapter.
    data: dict = {}
    adapter_path = adapter_marker_path(target)
    if adapter_path.exists():
        try:
            data = load_project(adapter_path)
        except SystemExit:
            # load_project validates required adapter fields; a partial/in-flight adapter fails
            # that check.  Fall back to raw JSON so examine still works in those cases.
            try:
                raw = json.loads(adapter_path.read_text(encoding="utf-8"))
                if isinstance(raw, dict):
                    data = raw
            except (json.JSONDecodeError, OSError):
                data = {}

    current: dict = data.get("backlogProvider") or {}

    # Resolve board fields: --fields-file for offline/test; live gh fetch otherwise.
    fields_file = getattr(args, "fields_file", None)
    if fields_file:
        fields: list = json.loads(Path(fields_file).read_text(encoding="utf-8"))
    else:
        fields = goal_tracker_project_fields(data, target)

    profile = board_profile(fields)
    assumptions = board_assumptions(profile)
    proposed = board_proposed_backlog_provider(profile, current)

    # --- output (read-only; no writes below this line) ---
    columns_str = ", ".join(profile.get("columns") or [])
    print(f"board_examine_status_field: {profile.get('statusField')}")
    print(f"board_examine_columns: {columns_str}")

    for stmt in assumptions.get("statements") or []:
        print(f"board_examine_statement: {stmt}")

    for question in assumptions.get("unknowns") or []:
        print(f"board_examine_unknown: {question}")

    # Proposed vs current diff for the board-derived keys an adopt would change.
    _print_provider_diff(proposed, current, "board_examine_proposed")

    return 0


def backlog_board_adopt(args: argparse.Namespace) -> int:
    """Operator-gated board-adopt: validate answers then write the aligned backlogProvider into the adapter.

    Without --apply this is a dry-run (prints the proposed diff and any unanswered unknowns; never writes).
    With --apply it refuses unless ALL board_assumptions unknowns are answered and writes the adapter.
    NEVER touches the GitHub board; NEVER calls any gh mutation command.
    """
    target = args.target.resolve()
    adapter_path = adapter_marker_path(target)

    # For adopt the adapter MUST exist — we write into it.
    if not adapter_path.exists():
        raise SystemExit(f"backlog-board-adopt: no adapter at {adapter_path}; run from a bootstrapped project")

    # Read the RAW on-disk adapter (NOT load_project): adopt must rewrite ONLY backlogProvider and
    # leave every other top-level key exactly as authored. load_project normalizes the whole adapter
    # and injects a derived goalTracker, so writing it back would silently rewrite far more than this
    # handler intends (e.g. adding keys to an older adapter).
    try:
        raw_data = json.loads(adapter_path.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError) as exc:
        raise SystemExit(f"backlog-board-adopt: cannot read adapter at {adapter_path}: {exc}")
    data: dict = raw_data if isinstance(raw_data, dict) else {}

    current: dict = data.get("backlogProvider") or {}

    # Resolve board fields: --fields-file for offline/test; live gh fetch otherwise.
    fields_file = getattr(args, "fields_file", None)
    if fields_file:
        fields: list = json.loads(Path(fields_file).read_text(encoding="utf-8"))
    else:
        # The live gh field fetch needs a derived goalTracker (owner/projectNumber); build it on a
        # COPY so the raw adapter we write back is never mutated by normalization.
        live_data = dict(data)
        live_data["goalTracker"] = goal_tracker_from_backlog_provider(
            normalize_tracker_adapter_config(data.get("backlogProvider"), DEFAULT_BACKLOG_PROVIDER, "backlogProvider")
        )
        fields = goal_tracker_project_fields(live_data, target)

    profile = board_profile(fields)
    unknowns: list[str] = board_assumptions(profile)["unknowns"]

    # Parse --answer KEY=VALUE entries.
    answers_raw: dict[str, str] = {}
    for entry in (args.answer or []):
        if "=" not in entry:
            raise SystemExit(f"backlog-board-adopt: --answer must be KEY=VALUE, got: {entry!r}")
        key, _, val = entry.partition("=")
        answers_raw[key] = val

    answers = board_adopt_answers(answers_raw)
    unanswered = [q for q in unknowns if board_answer_key(q) not in answers_raw]

    apply_mode: bool = getattr(args, "apply", False)

    if not apply_mode:
        # Dry-run: print the proposed diff.
        proposed = board_proposed_backlog_provider(profile, current, answers)
        _print_provider_diff(proposed, current, "board_adopt_proposed")
        for q in unanswered:
            print(f"board_adopt_unanswered: {q}")
        return 0

    # --apply mode: refuse if any unknown is unanswered.
    if unanswered:
        for q in unanswered:
            print(f"board_adopt_unanswered: {q}")
        raise SystemExit("backlog-board-adopt: refuse to apply with unanswered unknowns")

    # Build the aligned proposal and write it into the adapter.
    proposed = board_proposed_backlog_provider(profile, current, answers)
    proposed["boardProfile"] = profile

    # Defense-in-depth: never write an adapter that would fail to load. Validate the proposed
    # provider block exactly as load_project will on every subsequent lane command; if it would
    # raise (e.g. an empty readyStatuses), refuse to write rather than brick the project.
    try:
        normalize_tracker_adapter_config(dict(proposed), DEFAULT_BACKLOG_PROVIDER, "backlogProvider")
    except SystemExit as exc:
        print(f"board_adopt_invalid: {exc}")
        raise SystemExit("backlog-board-adopt: refusing to write an adapter that fails validation")

    # Write back ONLY backlogProvider; every other top-level key keeps its exact on-disk value.
    data["backlogProvider"] = proposed

    # Serialize exactly as render_adapters writes generated adapters (json.dumps + indent=2 + newline).
    adapter_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")

    ready = proposed.get("readyStatuses", [])
    schema_hash = proposed.get("schemaHash", "")
    print(f"board_adopt_applied: backlogProvider updated; readyStatuses={ready!r}, schemaHash={schema_hash!r}")
    return 0


STAKEHOLDER_QUESTION_MARKER = "minervit-question"
STAKEHOLDER_QUESTION_ATTR_RE = re.compile(r"([A-Za-z_][A-Za-z0-9_-]*)=\"([^\"]*)\"")


def stakeholder_questions_config(data: dict) -> dict:
    return data["stakeholderQuestions"]


def stakeholder_questions_status_summary(data: dict) -> str:
    config = stakeholder_questions_config(data)
    if not config["enabled"]:
        return "stakeholder_questions: enabled=false"
    default = f" default={config['defaultMention']}" if config.get("defaultMention") else " default=unknown"
    sync = ",".join(config.get("syncAt", [])) or "none"
    return (
        "stakeholder_questions: enabled=true provider=github-issues"
        f"{default} open_label={config['openLabel']} answered_label={config['answeredLabel']} sync_at={sync}"
    )


def stakeholder_questions_auth_issues(data: dict, target: Path) -> list[str]:
    config = stakeholder_questions_config(data)
    if not config["enabled"]:
        return []
    if shutil.which("gh") is None:
        return ["gh CLI missing; install GitHub CLI before using stakeholderQuestions provider github-issues"]
    repo = str(data.get("repo") or "").strip()
    if not re.fullmatch(r"[^/\s]+/[^/\s]+", repo):
        return [f"Project adapter repo must be owner/name before using stakeholderQuestions: {repo or 'missing'}"]
    code, out, err = run_command(["gh", "auth", "status"], cwd=target, timeout=20)
    if code != 0:
        text = "\n".join(part for part in [out, err] if part)
        return [f"gh auth status failed: {text or 'not authenticated'}"]
    return []


def stakeholder_issue_number(issue_ref: str) -> str:
    text = issue_ref.strip()
    if not text:
        return ""
    if re.fullmatch(r"[0-9]+", text):
        return text
    return github_issue_number_from_url(text)


def stakeholder_login_from_mention(mention: str) -> str:
    return mention.strip().lstrip("@").lower()


def stakeholder_question_id(issue_number: str, question: str, needed_for: str) -> str:
    seed = f"{issue_number}\n{question.strip()}\n{needed_for.strip()}"
    digest = hashlib.sha256(seed.encode("utf-8")).hexdigest()[:12]
    slug = slugify((needed_for or question)[:60], "question")
    return f"{slug}-{digest}"


def stakeholder_question_has_secret(text: str) -> bool:
    return any(re.search(pattern, text) for pattern in SESSION_JOURNAL_SECRET_PATTERNS)


def stakeholder_question_marker(attrs: dict[str, str]) -> str:
    parts = []
    for key in ["id", "status", "asked_at", "answered_at", "stakeholder", "issue"]:
        value = str(attrs.get(key, "")).strip()
        if value:
            safe = value.replace('"', "'")
            parts.append(f'{key}="{safe}"')
    return f"<!-- {STAKEHOLDER_QUESTION_MARKER} {' '.join(parts)} -->"


def stakeholder_question_parse_markers(body: str) -> list[dict[str, str]]:
    markers = []
    for match in re.finditer(r"<!--\s*minervit-question\s+([^>]*)-->", body or ""):
        attrs = {key: value for key, value in STAKEHOLDER_QUESTION_ATTR_RE.findall(match.group(1))}
        if attrs.get("id"):
            markers.append(attrs)
    return markers


def stakeholder_question_comment_author(comment: dict) -> str:
    author = comment.get("author")
    if isinstance(author, dict):
        return goal_tracker_value_text(author.get("login") or author.get("name"))
    return goal_tracker_value_text(author)


def stakeholder_question_comment_created(comment: dict) -> str:
    return goal_tracker_value_text(comment.get("createdAt") or comment.get("created_at") or comment.get("updatedAt"))


def stakeholder_question_comment_url(comment: dict) -> str:
    return goal_tracker_value_text(comment.get("url") or comment.get("htmlUrl"))


def stakeholder_question_comment_body(comment: dict) -> str:
    return goal_tracker_value_text(comment.get("body") or comment.get("bodyText"))


def stakeholder_question_answer_is_substantive(body: str) -> bool:
    text = re.sub(r"\s+", " ", body or "").strip()
    if len(text) < 24 or len(re.findall(r"[A-Za-z0-9]+", text)) < 4:
        return False
    non_answer_patterns = [
        r"(?i)^\+1$",
        r"(?i)^ok(?:ay)?[.!]?$",
        r"(?i)^thanks?[.!]?$",
        r"(?i)\b(looking into it|checking|i'?ll check|will check|get back to you|not sure yet|need to think|following up)\b",
    ]
    return not any(re.search(pattern, text) for pattern in non_answer_patterns)


def stakeholder_issue_view(data: dict, target: Path, issue_ref: str) -> dict:
    issue_number = stakeholder_issue_number(issue_ref)
    if not issue_number:
        raise SystemExit(f"GitHub issue ref must be an issue number or issue URL: {issue_ref}")
    payload = goal_tracker_gh_json(
        [
            "issue",
            "view",
            issue_number,
            "--repo",
            data["repo"],
            "--json",
            "number,title,url,comments,labels",
        ],
        target,
    )
    if not isinstance(payload, dict):
        raise SystemExit("gh issue view returned unexpected JSON")
    return payload


def stakeholder_issue_list(data: dict, target: Path, label: str, limit: int) -> list[dict]:
    command = [
        "issue",
        "list",
        "--repo",
        data["repo"],
        "--state",
        "open",
        "--limit",
        str(limit),
        "--json",
        "number,title,url,labels",
    ]
    if label:
        command.extend(["--label", label])
    payload = goal_tracker_gh_json(command, target)
    if not isinstance(payload, list):
        raise SystemExit("gh issue list returned unexpected JSON; expected an array")
    return [item for item in payload if isinstance(item, dict)]


def stakeholder_issue_ensure_label(data: dict, target: Path, label: str, color: str, description: str) -> None:
    if not label:
        return
    code, _out, _err = run_command(
        [
            "gh",
            "label",
            "create",
            label,
            "--repo",
            data["repo"],
            "--color",
            color,
            "--description",
            description,
            "--force",
        ],
        cwd=target,
        timeout=20,
    )
    if code != 0:
        # Missing label creation support should not block commenting; issue edit will surface real label failures.
        return


def stakeholder_issue_edit_labels(data: dict, target: Path, issue_number: str, add: list[str] | None = None, remove: list[str] | None = None) -> None:
    command = ["gh", "issue", "edit", issue_number, "--repo", data["repo"]]
    for label in add or []:
        if label:
            command.extend(["--add-label", label])
    for label in remove or []:
        if label:
            command.extend(["--remove-label", label])
    if len(command) <= 6:
        return
    code, out, err = run_command(command, cwd=target, timeout=20)
    if code != 0:
        raise SystemExit(err or out or f"gh issue edit failed for issue {issue_number}")


def stakeholder_issue_post_comment(data: dict, target: Path, issue_number: str, body: str) -> str:
    try:
        return github_issue_comment_rest(data["repo"], target, issue_number, body)
    except SystemExit:
        with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as tmp:
            tmp.write(body)
            tmp_path = Path(tmp.name)
        try:
            code, out, err = github_provider_client_run(
                "issue.comment",
                ["issue", "comment", issue_number, "--repo", data["repo"], "--body-file", str(tmp_path)],
                target,
                timeout=30,
                resource="core",
            )
        finally:
            try:
                tmp_path.unlink()
            except OSError:
                pass
        if code != 0:
            raise SystemExit(err or out or f"gh issue comment failed for issue {issue_number}")
        for line in reversed(out.splitlines()):
            candidate = line.strip()
            if candidate.startswith("http://") or candidate.startswith("https://"):
                return candidate
        return ""


def stakeholder_question_project_status(data: dict, target: Path, issue_url: str, status: str) -> str:
    if not status or not backlog_provider_config(data).get("enabled"):
        return "skipped"
    result = goal_tracker_update_status(data, target, issue_url, status)
    return str(result.get("item_id") or "updated")


def stakeholder_question_records(issue: dict) -> list[dict]:
    comments = issue.get("comments") if isinstance(issue.get("comments"), list) else []
    answered_ids = set()
    records = []
    for comment in comments:
        body = stakeholder_question_comment_body(comment)
        markers = stakeholder_question_parse_markers(body)
        for marker in markers:
            if marker.get("status") == "answered":
                answered_ids.add(marker["id"])
    for index, comment in enumerate(comments):
        body = stakeholder_question_comment_body(comment)
        markers = stakeholder_question_parse_markers(body)
        for marker in markers:
            if marker.get("status") != "open":
                continue
            stakeholder = marker.get("stakeholder", "")
            stakeholder_login = stakeholder_login_from_mention(stakeholder)
            candidate = None
            ignored_candidate = None
            for later in comments[index + 1 :]:
                later_body = stakeholder_question_comment_body(later)
                if stakeholder_question_parse_markers(later_body):
                    continue
                if stakeholder_login and stakeholder_question_comment_author(later).lower() != stakeholder_login:
                    continue
                if stakeholder_question_answer_is_substantive(later_body):
                    candidate = later
                    break
                ignored_candidate = later
            records.append(
                {
                    "id": marker["id"],
                    "status": "answered" if marker["id"] in answered_ids else "open",
                    "stakeholder": stakeholder,
                    "asked_at": marker.get("asked_at", ""),
                    "question_url": stakeholder_question_comment_url(comment),
                    "candidate_answer": candidate,
                    "ignored_candidate": ignored_candidate,
                }
            )
    return records


def stakeholder_question_answer_body(issue: dict, record: dict) -> str:
    answer = record["candidate_answer"]
    answer_body = stakeholder_question_comment_body(answer).strip()
    if len(answer_body) > 1800:
        answer_body = answer_body[:1797].rstrip() + "..."
    author = stakeholder_question_comment_author(answer)
    url = stakeholder_question_comment_url(answer)
    marker = stakeholder_question_marker(
        {
            "id": record["id"],
            "status": "answered",
            "answered_at": now_iso(),
            "stakeholder": record.get("stakeholder", ""),
            "issue": str(issue.get("number", "")),
        }
    )
    return (
        f"Recorded answer for `{record['id']}`.\n\n"
        f"Stakeholder: @{author}\n"
        f"Source comment: {url or 'same issue'}\n\n"
        "Answer:\n"
        f"{answer_body}\n\n"
        "I am marking this stakeholder question answered and continuing from the repo source-of-truth plan.\n\n"
        f"{marker}\n"
    )


def stakeholder_question_status_for_issue(data: dict, target: Path, issue: dict, sync: bool) -> tuple[int, int]:
    config = stakeholder_questions_config(data)
    issue_number = str(issue.get("number", "")).strip()
    issue_url = goal_tracker_value_text(issue.get("url"))
    records = stakeholder_question_records(issue)
    open_count = 0
    answered_now = 0
    unresolved_candidates = [
        record for record in records
        if record["status"] != "answered" and record.get("candidate_answer")
    ]
    auto_record_candidates = len(unresolved_candidates) <= 1
    for record in records:
        if record["status"] == "answered":
            print(f"stakeholder_question_answered: issue={issue_number} id={record['id']} stakeholder={record.get('stakeholder') or 'unknown'}")
            continue
        if record["candidate_answer"] and sync and auto_record_candidates:
            body = stakeholder_question_answer_body(issue, record)
            if stakeholder_question_has_secret(body):
                open_count += 1
                print(f"stakeholder_question_answer_blocked: issue={issue_number} id={record['id']} reason=secret-looking-value")
                continue
            comment_url = stakeholder_issue_post_comment(data, target, issue_number, body)
            print(f"stakeholder_question_recorded_answer: issue={issue_number} id={record['id']} comment={comment_url or 'posted'}")
            answered_now += 1
            continue
        open_count += 1
        if record["candidate_answer"]:
            if auto_record_candidates:
                print(f"stakeholder_question_candidate_answer: issue={issue_number} id={record['id']} stakeholder={record.get('stakeholder') or 'unknown'}")
            else:
                print(f"stakeholder_question_candidate_needs_agent_review: issue={issue_number} id={record['id']} reason=multiple-open-questions")
        elif record.get("ignored_candidate"):
            print(f"stakeholder_question_candidate_ignored: issue={issue_number} id={record['id']} reason=not-substantive-yet")
        else:
            print(f"stakeholder_question_open: issue={issue_number} id={record['id']} stakeholder={record.get('stakeholder') or 'unknown'}")
    if sync and records:
        if open_count == 0:
            stakeholder_issue_ensure_label(data, target, config["answeredLabel"], "0E8A16", "Stakeholder clarification has been answered")
            stakeholder_issue_edit_labels(data, target, issue_number, add=[config["answeredLabel"]], remove=[config["openLabel"]])
            if issue_url:
                item_id = stakeholder_question_project_status(data, target, issue_url, config.get("projectStatusOnAnswered", ""))
                print(f"stakeholder_question_project_status: issue={issue_number} status={config.get('projectStatusOnAnswered') or 'none'} item={item_id}")
        else:
            stakeholder_issue_ensure_label(data, target, config["openLabel"], "D93F0B", "Waiting for stakeholder clarification")
            stakeholder_issue_edit_labels(data, target, issue_number, add=[config["openLabel"]], remove=[])
    return open_count, answered_now


def stakeholder_question_ask(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    config = stakeholder_questions_config(data)
    if not config["enabled"]:
        raise SystemExit("stakeholderQuestions is disabled in this project adapter")
    issues = stakeholder_questions_auth_issues(data, target)
    if issues:
        raise SystemExit("; ".join(issues))
    stakeholder = (args.stakeholder or config.get("defaultMention") or "").strip()
    if stakeholder and not stakeholder.startswith("@"):
        stakeholder = f"@{stakeholder}"
    if not stakeholder:
        raise SystemExit(
            "stakeholder_question_blocker: unknown-stakeholder; ask the human operator exactly once: "
            f"Who should be tagged for stakeholder clarification on this {data['project']} issue? "
            "Then set stakeholderQuestions.defaultMention in the project adapter or rerun with --stakeholder @name."
        )
    issue = stakeholder_issue_view(data, target, args.issue)
    issue_number = str(issue.get("number", "")).strip()
    issue_url = goal_tracker_value_text(issue.get("url"))
    question = str(args.question or "").strip()
    why = str(args.why or "").strip()
    needed_for = str(args.needed_for or "").strip()
    if not question:
        raise SystemExit("--question is required")
    if not why:
        raise SystemExit("--why is required")
    if not needed_for:
        raise SystemExit("--needed-for is required")
    if stakeholder_question_has_secret("\n".join([question, why, needed_for])):
        raise SystemExit("stakeholder question contains a secret-looking value; do not publish")
    question_id = str(args.question_id or "").strip() or stakeholder_question_id(issue_number, question, needed_for)
    for record in stakeholder_question_records(issue):
        if record["id"] == question_id:
            print("stakeholder_question_ask: already-present")
            print(f"stakeholder_question_id: {question_id}")
            print(f"stakeholder_question_issue: {issue_number}")
            print(f"stakeholder_question_status: {record['status']}")
            print(f"stakeholder_question_url: {record.get('question_url') or issue_url or 'none'}")
            print("stakeholder_question_next_action: do not repost; run `tautline stakeholder-question-status --target . --sync`")
            return 0
    marker = stakeholder_question_marker(
        {
            "id": question_id,
            "status": "open",
            "asked_at": now_iso(),
            "stakeholder": stakeholder,
            "issue": issue_number,
        }
    )
    body = (
        f"{stakeholder} I need one clarification before I can finish this correctly.\n\n"
        "Question:\n"
        f"{question}\n\n"
        "Why it matters:\n"
        f"{why}\n\n"
        "Needed for:\n"
        f"{needed_for}\n\n"
        f"{marker}\n"
    )
    stakeholder_issue_ensure_label(data, target, config["openLabel"], "D93F0B", "Waiting for stakeholder clarification")
    comment_url = stakeholder_issue_post_comment(data, target, issue_number, body)
    stakeholder_issue_edit_labels(data, target, issue_number, add=[config["openLabel"]], remove=[config["answeredLabel"]])
    project_item = "skipped"
    if issue_url:
        project_item = stakeholder_question_project_status(data, target, issue_url, config.get("projectStatusOnOpen", ""))
    print("stakeholder_question_ask: posted")
    print(f"stakeholder_question_id: {question_id}")
    print(f"stakeholder_question_issue: {issue_number}")
    print(f"stakeholder_question_url: {comment_url or issue_url or 'none'}")
    print(f"stakeholder_question_stakeholder: {stakeholder}")
    print(f"stakeholder_question_project_status: {config.get('projectStatusOnOpen') or 'none'} item={project_item}")
    print("stakeholder_question_next_action: continue other unblocked work; run `tautline stakeholder-question-status --target . --sync` at startup/boundaries or when the stakeholder replies")
    return 0


def stakeholder_question_status(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    config = stakeholder_questions_config(data)
    print(stakeholder_questions_status_summary(data))
    if not config["enabled"]:
        print("stakeholder_question_status: disabled")
        return 0
    issues = stakeholder_questions_auth_issues(data, target)
    if issues:
        for issue in issues:
            print(f"stakeholder_question_issue: {issue}")
        return 1
    if args.issue:
        issue_refs = [args.issue]
    else:
        listed = stakeholder_issue_list(data, target, config["openLabel"], int(args.limit))
        issue_refs = [str(item.get("number")) for item in listed if item.get("number")]
    total_open = 0
    total_answered = 0
    for issue_ref in issue_refs:
        issue = stakeholder_issue_view(data, target, issue_ref)
        open_count, answered_now = stakeholder_question_status_for_issue(data, target, issue, bool(args.sync))
        total_open += open_count
        total_answered += answered_now
    print(f"stakeholder_question_open_count: {total_open}")
    print(f"stakeholder_question_answered_synced_count: {total_answered}")
    if total_open:
        print("stakeholder_question_status: waiting")
        return 1 if args.strict else 0
    print("stakeholder_question_status: ok")
    return 0


MILESTONE_RUN_SCHEMA = "minervit-milestone-run/v1"
MILESTONE_TERMINAL_STATUSES = {"pr_queued", "merged", "complete", "deferred"}
MILESTONE_ACTIVE_STATUSES = {"pending", "in_progress"}
MILESTONE_ADVANCE_EVENTS = {"startup", "pr-queued", "pr-merged", "pr-abandoned", "item-blocked", "item-complete"}


def milestone_run_path(data: dict, target: Path) -> Path:
    return paths_module().milestone_run_path(data, target)


def load_milestone_run(data: dict, target: Path) -> dict:
    path = milestone_run_path(data, target)
    if not path.exists():
        raise SystemExit(f"milestone run missing: {path}. Start one with milestone-start --target . --plan <source-of-truth-plan>.")
    try:
        run = json.loads(path.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        raise SystemExit(f"milestone run invalid JSON: {path}: {exc}") from exc
    if run.get("schema") != MILESTONE_RUN_SCHEMA:
        raise SystemExit(f"milestone run has unsupported schema: {run.get('schema')}")
    run["percentComplete"] = milestone_percent_complete(run)
    run["nextAction"] = milestone_next_action_record(run)
    run["status"] = milestone_status_from_action(run["nextAction"])
    return run


def milestone_status_from_action(action: dict) -> str:
    if action["type"] == "milestone_complete":
        return "complete"
    if action["type"] == "true_blocker":
        return "blocked"
    return "active"


def milestone_run_integrity_issues(run: dict) -> list[str]:
    issues: list[str] = []
    for item in run.get("items", []):
        status = item.get("status")
        if status in {"pr_queued", "merged"} and not item.get("pr"):
            issues.append(f"item {item.get('index')} is {status} but has no PR recorded")
        if status == "blocked" and not item.get("blockers"):
            issues.append(f"item {item.get('index')} is blocked but has no blocker reason")
    return issues


def save_milestone_run(data: dict, target: Path, run: dict) -> Path:
    path = milestone_run_path(data, target)
    path.parent.mkdir(parents=True, exist_ok=True)
    run["updatedAt"] = now_iso()
    run["percentComplete"] = milestone_percent_complete(run)
    run["nextAction"] = milestone_next_action_record(run)
    run["status"] = milestone_status_from_action(run["nextAction"])
    path.write_text(json.dumps(run, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    return path


def resolve_milestone_plan_path(data: dict, target: Path, plan_arg: Path) -> Path:
    plan_path = plan_arg.expanduser()
    if not plan_path.is_absolute():
        plan_path = target / plan_path
    plan_path = plan_path.resolve()
    if not plan_path.exists():
        raise SystemExit(f"source-of-truth plan missing: {plan_path}")
    source_root = planning_source_root(data, target).resolve()
    try:
        plan_path.relative_to(source_root)
    except ValueError as exc:
        raise SystemExit(f"plan is outside planning source of truth: {plan_path}") from exc
    return plan_path


def milestone_items_from_plan_or_packet(data: dict, target: Path, plan_path: Path) -> tuple[list[str], str | None]:
    packet_path = target / data["laneState"]["executionPacket"]
    if packet_path.exists():
        packet_items = execution_queue_items(packet_path.read_text(encoding="utf-8"))
        if packet_items:
            return packet_items, path_relative_to_target(target, packet_path)
    plan_items = execution_queue_items(plan_path.read_text(encoding="utf-8"))
    if not plan_items:
        raise SystemExit(
            "milestone plan/execution packet has no ordered tactical queue items; add a tactical queue before starting the milestone run"
        )
    return plan_items, path_relative_to_target(target, packet_path) if packet_path.exists() else None


def initial_milestone_run(data: dict, target: Path, plan_path: Path) -> dict:
    item_titles, packet_rel = milestone_items_from_plan_or_packet(data, target, plan_path)
    now = now_iso()
    return {
        "schema": MILESTONE_RUN_SCHEMA,
        "createdAt": now,
        "updatedAt": now,
        "project": data["project"],
        "repo": data["repo"],
        "milestoneId": slugify(plan_path.stem, "milestone"),
        "sourcePlan": path_relative_to_target(target, plan_path),
        "executionPacket": packet_rel,
        "currentIndex": 1,
        "status": "active",
        "percentComplete": 0,
        "items": [
            {
                "index": index + 1,
                "title": title,
                "status": "in_progress" if index == 0 else "pending",
                "branch": None,
                "pr": None,
                "blockers": [],
                "updatedAt": now,
            }
            for index, title in enumerate(item_titles)
        ],
        "events": [
            {"timestamp": now, "event": "milestone-start", "detail": f"started from {path_relative_to_target(target, plan_path)}"}
        ],
        "nextAction": {},
    }


def refresh_milestone_run(data: dict, target: Path, run: dict, plan_path: Path) -> dict:
    item_titles, packet_rel = milestone_items_from_plan_or_packet(data, target, plan_path)
    source_plan = path_relative_to_target(target, plan_path)
    existing_source_plan = run.get("sourcePlan")
    if existing_source_plan and existing_source_plan != source_plan:
        raise SystemExit(
            "milestone refresh would retarget an existing ledger from "
            f"{existing_source_plan} to {source_plan}. Finish/archive the current ledger or start a new milestone run intentionally."
        )
    existing = {str(item.get("title", "")).strip().lower(): item for item in run.get("items", [])}
    now = now_iso()
    refreshed_items = []
    refreshed_keys = set()
    for index, title in enumerate(item_titles, 1):
        key = title.strip().lower()
        refreshed_keys.add(key)
        previous = existing.get(key)
        if previous:
            item = {**previous, "index": index, "title": title}
        else:
            item = {
                "index": index,
                "title": title,
                "status": "pending",
                "branch": None,
                "pr": None,
                "blockers": [],
                "updatedAt": now,
            }
        refreshed_items.append(item)
    stateful_dropped = []
    for key, item in existing.items():
        if key in refreshed_keys:
            continue
        has_state = (
            item.get("status") in (MILESTONE_TERMINAL_STATUSES | {"in_progress", "blocked"})
            or bool(item.get("branch"))
            or bool(item.get("pr"))
            or bool(item.get("blockers"))
        )
        if has_state:
            stateful_dropped.append(f"{item.get('index')}: {item.get('title')} ({item.get('status')})")
    if stateful_dropped:
        raise SystemExit(
            "milestone refresh would drop existing item state: "
            + "; ".join(stateful_dropped)
            + ". Preserve the item title, finish/archive the current ledger, or start a new milestone run intentionally."
        )
    run.update(
        {
            "project": data["project"],
            "repo": data["repo"],
            "milestoneId": slugify(plan_path.stem, "milestone"),
            "sourcePlan": source_plan,
            "executionPacket": packet_rel,
            "items": refreshed_items,
        }
    )
    if not any(item.get("status") == "in_progress" for item in refreshed_items):
        for item in refreshed_items:
            if item.get("status") in MILESTONE_ACTIVE_STATUSES:
                item["status"] = "in_progress"
                item["updatedAt"] = now
                run["currentIndex"] = item["index"]
                break
    run.setdefault("events", []).append(
        {"timestamp": now, "event": "milestone-refresh", "detail": f"refreshed from {path_relative_to_target(target, plan_path)}"}
    )
    return run


def milestone_percent_complete(run: dict) -> int:
    items = run.get("items", [])
    if not items:
        return 0
    done = sum(1 for item in items if item.get("status") in MILESTONE_TERMINAL_STATUSES)
    return int(round((done / len(items)) * 100))


def milestone_next_action_record(run: dict) -> dict:
    items = run.get("items", [])
    for item in items:
        if item.get("status") == "in_progress":
            return {
                "type": "continue_item",
                "itemIndex": item["index"],
                "itemTitle": item["title"],
                "instruction": f"Continue item {item['index']}: {item['title']}",
            }
    for item in items:
        if item.get("status") == "pending":
            return {
                "type": "start_item",
                "itemIndex": item["index"],
                "itemTitle": item["title"],
                "instruction": f"Start item {item['index']}: {item['title']}",
            }
    blocked = [item for item in items if item.get("status") == "blocked"]
    if blocked:
        item = blocked[0]
        blockers = "; ".join(item.get("blockers") or ["blocked with no reason recorded"])
        return {
            "type": "true_blocker",
            "itemIndex": item["index"],
            "itemTitle": item["title"],
            "instruction": f"True blocker on item {item['index']}: {blockers}",
        }
    return {
        "type": "milestone_complete",
        "itemIndex": None,
        "itemTitle": None,
        "instruction": "Prove milestone completion from the ledger, execution packet/backlog readiness, open PR state, continuity handoff, and session journal, then start the next source-of-truth item or automatic planning flow unless a true blocker remains.",
    }


def print_milestone_run(run: dict, path: Path) -> None:
    action = milestone_next_action_record(run)
    current = next((item for item in run.get("items", []) if item.get("status") == "in_progress"), None)
    pending = next((item for item in run.get("items", []) if item.get("status") == "pending"), None)
    print(f"milestone_run: {path}")
    print(f"milestone_id: {run.get('milestoneId')}")
    print(f"source_plan: {run.get('sourcePlan')}")
    print(f"execution_packet: {run.get('executionPacket') or 'none'}")
    print(f"status: {run.get('status')}")
    print(f"percent_complete: {run.get('percentComplete', milestone_percent_complete(run))}")
    print(f"current_item: {current['index']} - {current['title']}" if current else "current_item: none")
    print(f"next_pending_item: {pending['index']} - {pending['title']}" if pending else "next_pending_item: none")
    print(f"next_action_type: {action['type']}")
    print(f"next_action: {action['instruction']}")


def milestone_start(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    ensure_lane_state(data, target)
    plan_path = resolve_milestone_plan_path(data, target, args.plan)
    path = milestone_run_path(data, target)
    if path.exists():
        run = refresh_milestone_run(data, target, load_milestone_run(data, target), plan_path)
    else:
        run = initial_milestone_run(data, target, plan_path)
    goal_tracker_claim_plan_active(data, target, plan_path, "milestone_start")
    save_milestone_run(data, target, run)
    saved = load_milestone_run(data, target)
    print_milestone_run(saved, path)
    try_write_event(
        data,
        target,
        event="milestone_run_active",
        severity="ok",
        plain=f"Milestone ledger is active for {saved.get('milestoneId') or path_display(target, plan_path)}.",
        next_action=milestone_next_action_record(saved)["instruction"],
        refs={"milestone_plan": path_display(target, plan_path), "milestone_run": path_display(target, path)},
    )
    return 0


def milestone_status(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    ensure_lane_state(data, target)
    path = milestone_run_path(data, target)
    run = load_milestone_run(data, target)
    print_milestone_run(run, path)
    return 0


def selected_milestone_item(run: dict, item_index: int | None, pr: str | None = None, require_pr_match: bool = False) -> dict:
    items = run.get("items", [])
    pr_owner = None
    if pr:
        for item in items:
            if str(item.get("pr") or "") == str(pr):
                pr_owner = item
                break
    if item_index is not None:
        for item in items:
            if item.get("index") == item_index:
                if pr_owner and pr_owner.get("index") != item_index:
                    raise SystemExit(
                        f"PR {pr} is already recorded on milestone item {pr_owner.get('index')}; "
                        f"refusing to overwrite item {item_index}"
                    )
                if pr and item.get("pr") and str(item.get("pr")) != str(pr):
                    raise SystemExit(
                        f"milestone item {item_index} already records PR {item.get('pr')}; "
                        f"refusing to overwrite it with PR {pr}"
                    )
                return item
        raise SystemExit(f"milestone item not found: {item_index}")
    if pr_owner:
        return pr_owner
    if pr and require_pr_match:
        raise SystemExit(f"milestone item with PR {pr} not found; pass --item-index to identify the item explicitly")
    current_index = run.get("currentIndex")
    for item in items:
        if item.get("index") == current_index:
            return item
    for item in items:
        if item.get("status") == "in_progress":
            return item
    for item in items:
        if item.get("status") == "pending":
            return item
    raise SystemExit("milestone run has no selectable item")


def milestone_advance(args: argparse.Namespace) -> int:
    if args.event not in MILESTONE_ADVANCE_EVENTS:
        raise SystemExit(f"unsupported milestone event: {args.event}")
    if args.event == "pr-queued" and not args.pr:
        raise SystemExit("--pr is required for pr-queued so the milestone ledger can bind the queued PR to the correct item")
    if args.event in {"pr-merged", "pr-abandoned"} and not (args.pr or args.item_index):
        raise SystemExit(f"--pr or --item-index is required for {args.event}")
    data, _, target = lane_project(args)
    ensure_lane_state(data, target)
    path = milestone_run_path(data, target)
    run = load_milestone_run(data, target)
    now = now_iso()
    event_detail = args.detail or ""
    if args.event == "startup":
        if not any(item.get("status") == "in_progress" for item in run.get("items", [])):
            for item in run.get("items", []):
                if item.get("status") == "pending":
                    item["status"] = "in_progress"
                    item["updatedAt"] = now
                    run["currentIndex"] = item["index"]
                    event_detail = event_detail or f"resumed item {item['index']}"
                    break
    else:
        item = selected_milestone_item(run, args.item_index, args.pr)
        if args.branch:
            item["branch"] = args.branch
        if args.pr:
            item["pr"] = args.pr
        if args.event == "pr-queued":
            item["status"] = "pr_queued"
            event_detail = event_detail or f"queued PR for item {item['index']}"
        elif args.event == "pr-merged":
            item["status"] = "merged"
            event_detail = event_detail or f"merged PR for item {item['index']}"
        elif args.event == "pr-abandoned":
            for candidate in run.get("items", []):
                if candidate is item:
                    continue
                if (
                    candidate.get("status") == "in_progress"
                    and not candidate.get("branch")
                    and not candidate.get("pr")
                    and not candidate.get("blockers")
                ):
                    candidate["status"] = "pending"
                    candidate["updatedAt"] = now
            item["status"] = "in_progress"
            run["currentIndex"] = item["index"]
            event_detail = event_detail or f"abandoned PR for item {item['index']}; item returned to active work"
        elif args.event == "item-complete":
            item["status"] = "complete"
            event_detail = event_detail or f"completed item {item['index']}"
        elif args.event == "item-blocked":
            if not args.reason:
                raise SystemExit("--reason is required for item-blocked")
            item["status"] = "blocked"
            item.setdefault("blockers", []).append(args.reason)
            event_detail = event_detail or args.reason
        item["updatedAt"] = now
        run["currentIndex"] = item["index"]
        if item["status"] in MILESTONE_TERMINAL_STATUSES:
            for candidate in run.get("items", []):
                if candidate.get("status") == "pending":
                    candidate["status"] = "in_progress"
                    candidate["updatedAt"] = now
                    run["currentIndex"] = candidate["index"]
                    break
            else:
                current = next((candidate for candidate in run.get("items", []) if candidate.get("status") == "in_progress"), None)
                if current:
                    run["currentIndex"] = current["index"]
    run.setdefault("events", []).append({"timestamp": now, "event": args.event, "detail": event_detail})
    save_milestone_run(data, target, run)
    saved = load_milestone_run(data, target)
    print_milestone_run(saved, path)
    next_action = milestone_next_action_record(saved)["instruction"]
    try_write_event(
        data,
        target,
        event=f"milestone_advance_{args.event.replace('-', '_')}",
        severity="block" if args.event == "item-blocked" else "ok",
        plain=f"Milestone transition recorded: {args.event}. {event_detail}",
        next_action=next_action,
        pr=args.pr,
        refs={"milestone_run": path_display(target, path)},
    )
    return 0


def milestone_next(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    ensure_lane_state(data, target)
    path = milestone_run_path(data, target)
    run = load_milestone_run(data, target)
    run["nextAction"] = milestone_next_action_record(run)
    run["percentComplete"] = milestone_percent_complete(run)
    print_milestone_run(run, path)
    return 0


def milestone_watchdog(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    ensure_lane_state(data, target)
    config = data["milestoneContinuation"]
    print(f"watchdog_enabled: {str(config['watchdogEnabled']).lower()}")
    print(f"watchdog_cadence_minutes: {config['watchdogCadenceMinutes']}")
    if not config["watchdogEnabled"] and not args.force:
        print("watchdog_action: disabled; no action taken")
        print("watchdog_role: recovery visibility only; milestone ledger remains the source of next-action truth")
        return 0
    if args.force and not config["watchdogEnabled"]:
        print("watchdog_force: diagnostic only; adapter watchdog remains disabled")
    pending_journals = pending_session_journals(data, target)
    print("watchdog_role: recovery visibility only; milestone ledger remains the source of next-action truth")
    print(f"watchdog_pending_session_journals: {compact_path_list(pending_journals, target)}")
    try:
        run = load_milestone_run(data, target)
    except SystemExit as exc:
        print(f"watchdog_milestone_state: missing - {exc}")
        print("watchdog_repair_pointer: inspect lane-start/methodology-status and run milestone-next after repairing missing milestone state")
        return 0
    current = next((item for item in run.get("items", []) if item.get("status") == "in_progress"), None)
    blocked = [item for item in run.get("items", []) if item.get("status") == "blocked"]
    pr_queued = [item for item in run.get("items", []) if item.get("status") == "pr_queued"]
    print(f"watchdog_milestone_run: {milestone_run_path(data, target)}")
    print(f"watchdog_milestone_status: {run.get('status')} percent_complete={run.get('percentComplete', milestone_percent_complete(run))}")
    print(f"watchdog_current_item: {current['index']} - {current['title']}" if current else "watchdog_current_item: none")
    print(f"watchdog_pr_queued_items: {len(pr_queued)}")
    print(f"watchdog_blocked_items: {len(blocked)}")
    print("watchdog_next_pointer: run `tautline milestone-next --target .`; watchdog output is not next-action authority")
    return 0


def work_loop(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    ensure_lane_state(data, target)
    packet_path = target / data["laneState"]["executionPacket"]

    if not args.dry_run:
        goal_action = None
        if goal_run_path(data, target).exists():
            goal_run = load_goal_run(data, target)
            goal_action = goal_next_action_record(goal_run)
        if args.plan and not milestone_run_path(data, target).exists():
            plan_path = resolve_milestone_plan_path(data, target, args.plan)
            save_milestone_run(data, target, initial_milestone_run(data, target, plan_path))
        run = load_milestone_run(data, target)
        action = milestone_next_action_record(run)
        run_dir = target / data["laneState"]["runsDir"]
        run_dir.mkdir(parents=True, exist_ok=True)
        run_file = target / data["laneState"]["runsDir"] / f"{time.strftime('%Y%m%d-%H%M%S')}-work-loop-next.json"
        run_file.write_text(
            json.dumps(
                {
                    "schema": "minervit-work-loop-run/v1",
                    "mode": "next-action",
                    "createdAt": now_iso(),
                    "goalRun": data["laneState"]["goalRun"] if goal_action else None,
                    "goalNextAction": goal_action,
                    "milestoneRun": data["laneState"]["milestoneRun"],
                    "packet": data["laneState"]["executionPacket"] if packet_path.exists() else None,
                    "nextAction": action,
                },
                indent=2,
                sort_keys=True,
            )
            + "\n",
            encoding="utf-8",
        )
        print(f"wrote {run_file}")
        if goal_action:
            print(f"goal_next_action: {goal_action['instruction']}")
        print_milestone_run(run, milestone_run_path(data, target))
        return 0

    if not packet_path.exists():
        raise SystemExit(f"execution packet missing: {packet_path}")

    packet_text = packet_path.read_text(encoding="utf-8")
    items = execution_queue_items(packet_text)
    if not items:
        raise SystemExit("execution packet has no ordered tactical queue items")

    stamp = time.strftime("%Y%m%d-%H%M%S")
    run_dir = target / data["laneState"]["runsDir"]
    run_dir.mkdir(parents=True, exist_ok=True)
    run_file = run_dir / f"{stamp}-work-loop-dry-run.json"
    record = {
        "schema": "minervit-work-loop-run/v1",
        "mode": "dry-run",
        "createdAt": now_iso(),
        "packet": data["laneState"]["executionPacket"],
        "stoppedForHuman": False,
        "items": [{"index": index + 1, "title": item, "status": "would_execute"} for index, item in enumerate(items)],
    }
    run_file.write_text(json.dumps(record, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    handoff = write_continuity_handoff(
        data,
        target,
        "## Current State\n"
        f"- Dry run consumed {len(items)} execution packet item(s) without stopping between items.\n\n"
        "## Decisions Made\n"
        "- No decisions were required during the dry run.\n\n"
        "## Files Changed\n"
        f"- `{data['laneState']['runsDir']}/` run evidence was written.\n"
        f"- `{data['continuity']['path']}` was refreshed.\n\n"
        "## Validation\n"
        "- Work-loop dry run completed.\n\n"
        "## Open Risks Or Blockers\n"
        "- None from dry run.\n\n"
        "## Next Action\n"
        "- Execute the first incomplete item in the execution packet.\n",
    )
    print(f"wrote {run_file}")
    print(f"wrote {handoff}")
    return 0


def markdown_section(text: str, heading: str) -> str:
    return paths_module().markdown_section(text, heading)


def session_journal_person_specific_tokens() -> set[str]:
    return rca_person_specific_tokens()


def validate_session_journal_file(path: Path, max_bytes: int = DEFAULT_SESSION_JOURNAL["maxBytes"]) -> list[str]:
    errors: list[str] = []
    if not path.exists():
        return [f"session journal missing: {path}"]
    try:
        size = path.stat().st_size
    except OSError as exc:
        return [f"session journal unreadable: {exc}"]
    if size > max_bytes:
        errors.append(f"session journal exceeds maxBytes {max_bytes}: {size}")
    text = path.read_text(encoding="utf-8", errors="replace")
    lower = text.lower()
    positions: list[tuple[str, int]] = []
    for heading in SESSION_JOURNAL_REQUIRED_HEADINGS:
        match = re.search(rf"^{re.escape(heading)}\s*$", text, re.MULTILINE)
        if not match:
            errors.append(f"missing required heading: {heading}")
            continue
        positions.append((heading, match.start()))
        section = markdown_section(text, heading)
        if heading != "## Methodology Improvement Signals" and len(section.strip()) < 4:
            errors.append(f"section is empty: {heading}")
    if len(positions) == len(SESSION_JOURNAL_REQUIRED_HEADINGS):
        ordered = [position for _, position in positions]
        if ordered != sorted(ordered):
            errors.append("required headings are out of order")
    runtime = markdown_section(text, "## Session Runtime")
    for field in [
        "schema",
        "generated_at",
        "project",
        "repo",
        "lane",
        "plugin_version",
        "methodology_commit",
        "adapter_drift",
        "continuity_path",
        "session_journal_archive_branch",
        "graphify_enabled",
        "graphify_freshness",
        "graphify_freshness_enforcement",
        "graphify_latest",
        "graphify_newer_files",
        "graphify_status_gate",
        "graphify_issues",
        "usage_accounting_enabled",
        "usage_jsonl",
        "usage_rollup",
        "usage_7d_total_tokens",
        "usage_7d_confidence",
    ]:
        if not re.search(rf"(?im)^\s*-?\s*`?{re.escape(field)}`?\s*:\s*\S+", runtime):
            errors.append(f"Session Runtime missing required field: {field}")
    if SESSION_JOURNAL_SCHEMA not in runtime:
        errors.append(f"Session Runtime schema must be {SESSION_JOURNAL_SCHEMA}")
    if "evidence only" not in lower:
        errors.append("session journal must state it is evidence only")
    for token in session_journal_person_specific_tokens():
        if re.search(rf"\b{re.escape(token)}\b", text, re.IGNORECASE):
            errors.append(f"person/machine-specific token present: {token}")
    for pattern in RCA_PERSON_SPECIFIC_PATH_PATTERNS:
        match = re.search(pattern, text)
        if match:
            errors.append(f"person/machine-specific path present: {match.group(0)}")
    for pattern in SESSION_JOURNAL_SECRET_PATTERNS:
        if re.search(pattern, text):
            errors.append("secret-looking value present")
            break
    for marker in SESSION_JOURNAL_RAW_LOG_MARKERS:
        if marker in text:
            errors.append(f"raw transcript/log marker present: {marker}")
    fenced_blocks = re.findall(r"```[^\n]*\n(.*?)```", text, flags=re.DOTALL)
    for block in fenced_blocks:
        lines = [line for line in block.splitlines() if line.strip()]
        if len(lines) > 20:
            errors.append("raw log dump suspected: fenced block exceeds 20 non-empty lines")
            break
        if any(line.startswith(("$ ", "> ", "ja" + "son@", "root@")) for line in lines):
            errors.append("raw shell transcript suspected in fenced block")
            break
    for pattern in SESSION_JOURNAL_PROCESS_AUTHORITY_PATTERNS:
        if re.search(pattern, text):
            errors.append("session journal claims process authority")
            break
    return errors


def validate_session_journal(args: argparse.Namespace) -> int:
    errors = validate_session_journal_file(args.file)
    if errors:
        for error in errors:
            print(error, file=sys.stderr)
        return 1
    print(f"session_journal_valid: {args.file}")
    return 0


def session_journal_archive_title(path: Path) -> str:
    text = path.read_text(encoding="utf-8", errors="replace")
    delivered = markdown_section(text, "## Work Delivered Or Advanced")
    for line in delivered.splitlines():
        cleaned = line.strip().lstrip("-* ").strip()
        if cleaned:
            return cleaned[:180]
    return path.stem


def session_journal_runtime_value(path: Path, field: str, fallback: str = "") -> str:
    runtime = markdown_section(path.read_text(encoding="utf-8", errors="replace"), "## Session Runtime")
    match = re.search(rf"(?im)^\s*-?\s*`?{re.escape(field)}`?\s*:\s*(.+?)\s*$", runtime)
    return match.group(1).strip() if match else fallback


def session_journal_archive_base_destination(source: Path, archive_dir: Path) -> Path:
    project = slugify(session_journal_runtime_value(source, "project", "project"), "project")
    lane = slugify(session_journal_runtime_value(source, "lane", "lane"), "lane")
    stamp_match = re.match(r"^(\d{8}T\d{6}Z)", source.name)
    stamp = stamp_match.group(1) if stamp_match else datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    year = stamp[:4]
    directory = archive_dir / project / year
    directory.mkdir(parents=True, exist_ok=True)
    return directory / f"{stamp}-{lane}-session-journal.md"


def session_journal_archive_destination(source: Path, archive_dir: Path) -> Path:
    base = session_journal_archive_base_destination(source, archive_dir)
    return unique_destination(base.parent, base.name)


def purge_archive(args: argparse.Namespace) -> int:
    """`purge-archive`: erase one project's/tenant's published artifacts from an archive (sec-privacy-2).

    Removes <archive-dir>/<project-slug>/ and drops its entries from _index.md -- the GDPR/CCPA
    erasure path the framework previously lacked. Refuses to operate outside the archive dir;
    --dry-run previews. Note: this clears the LOCAL archive copy; an already-pushed archive branch
    must also be rewritten/force-pushed and any CDN cache invalidated (printed as a reminder).
    """
    # The default archive dir is canonical-anchored like every other archive path: erasure must
    # operate on the checkout that actually holds the published copies, not on the read-only
    # snapshot we execute (where rmtree would only ever EACCES).
    default_dir = canonical_archive_dir(
        DEFAULT_SESSION_JOURNAL_ARCHIVE_DIR, SESSION_JOURNAL_ARCHIVE_SUBPATH
    )
    archive_dir = (args.archive_dir or default_dir).expanduser().resolve()
    if not archive_dir.is_dir():
        raise SystemExit(f"purge-archive: archive dir not found: {archive_dir}")
    project_slug = slugify(args.project, "project")
    project_dir = (archive_dir / project_slug).resolve()
    if project_dir.parent != archive_dir:
        raise SystemExit("purge-archive: refusing to operate outside the archive dir")
    dry = getattr(args, "dry_run", False)
    removed_files = [p for p in project_dir.rglob("*") if p.is_file()] if project_dir.exists() else []
    index_path = archive_dir / "_index.md"
    kept_lines: list[str] = []
    dropped = 0
    if index_path.exists():
        for line in index_path.read_text(encoding="utf-8").splitlines():
            if f"/{project_slug}/" in line or f"`{project_slug}/" in line:
                dropped += 1
                continue
            kept_lines.append(line)
    if dry:
        print(f"purge_archive_dry_run: would remove {len(removed_files)} file(s) under {project_dir}")
        print(f"purge_archive_dry_run: would drop {dropped} _index.md entr(y/ies)")
        return 0
    if project_dir.exists():
        shutil.rmtree(project_dir)
    if index_path.exists():
        index_path.write_text("\n".join(kept_lines).rstrip() + "\n", encoding="utf-8")
    print(f"purge_archive_removed: {len(removed_files)} file(s) under {project_dir}")
    print(f"purge_archive_index_dropped: {dropped} entr(y/ies) from {index_path}")
    print(
        "purge_archive_note: cleared the LOCAL archive copy; if already pushed to an archive branch, "
        "rewrite/force-push that branch and invalidate any CDN cache to complete erasure."
    )
    return 0


RELEASE_CHECKOUT_UPDATE_POLICIES = {"pinned", "signed"}


def canonical_release_checkout_status() -> tuple[bool, str]:
    """Is the canonical checkout the pinned/auto-synced RELEASE checkout sync manages?

    Two signals, both required: an update policy that actually advances the checkout
    (pinned/signed) AND a release branch. Either alone is a dev checkout -- a maintainer on a
    feature branch is not auto-synced, and an unpinned checkout is not advanced by the trust gate.

    Fails CLOSED on an unreadable/detached branch under a pinned policy: guessing "dev" there would
    dirty exactly the tree the guard exists to protect (a detached HEAD is what a rescue mid-flight
    looks like).
    """
    canonical = canonical_methodology_repo()
    policy = effective_methodology_update_policy()
    if policy not in RELEASE_CHECKOUT_UPDATE_POLICIES:
        return False, f"{canonical} under update policy {policy} (not auto-synced)"
    branch = current_branch_name(canonical)
    if not branch:
        return True, f"{canonical} on an unknown branch under update policy {policy}"
    if branch not in set(FRAMEWORK_CHANNEL_BRANCHES.values()):
        return False, f"{canonical} on non-release branch {branch}"
    return True, f"{canonical} on release branch {branch} under update policy {policy}"


def release_checkout_archive_write_allowed(args: argparse.Namespace, artifact_name: str) -> bool:
    """Archive publication must never dirty the checkout sync/rescue manages.

    A dirty canonical checkout is not a local inconvenience: the sync machinery reads `git status`
    there as a CONTROL SIGNAL, so one staged archive copy routes the next sync into the
    local-changes rescue path for EVERY lane on the machine. `--commit --push` publishes through an
    isolated temporary clone and never touches the checkout, which is why it stays the sanctioned
    path. A maintainer's dev checkout (non-release branch, or an update policy that does not
    auto-advance it) is nobody's runtime and keeps behaving like the ordinary git tree it is.
    """
    if args.commit and args.push:
        return True
    if getattr(args, "allow_release_checkout_write", False):
        return True
    managed, detail = canonical_release_checkout_status()
    if not managed:
        return True
    print(
        f"publish_error: local {artifact_name} archive writes into the active methodology release "
        f"checkout are refused: {detail}. Staging archive copies there dirties the checkout "
        "sync/rescue manages, which routes every lane on this machine into the local-changes "
        "rescue path. Use --commit --push to publish through an isolated archive-branch clone, or "
        "pass --allow-release-checkout-write for a local validation/preview write you will clean "
        "up yourself.",
        file=sys.stderr,
    )
    return False


def stage_archive_copy(canonical: Path, rel_paths: list[str], artifact_name: str) -> bool:
    """`git add` an archive copy into the canonical checkout, journaled.

    This is a deliberate, operator-authorized mutation of the checkout every lane on the machine
    shares (it only runs past release_checkout_archive_write_allowed), so it belongs in the same
    write journal as sync/rescue/snapshot writes: when a later lane finds the canonical tree dirty,
    the journal is the only thing that can say which lane staged what, and when. The head does not
    move -- staging is not a commit -- so old_head and new_head are both the head we staged onto.
    """
    code, _out, err = run_command(["git", "-C", str(canonical), "add", "--", *rel_paths])
    head = run_git(canonical, ["rev-parse", "HEAD"])
    detail = f"{artifact_name}: {' '.join(rel_paths)}"
    if code != 0:
        message = (err or "git add failed").strip()
        append_methodology_write_journal(
            "archive.git-add", head, head, "failed", detail=f"{detail} -- {message}"
        )
        print(f"git_stage_error: {message}", file=sys.stderr)
        return False
    append_methodology_write_journal("archive.git-add", head, head, "ok", detail=detail)
    return True


# 0.9.0 security behavior change (see the sanitized-instrumentation plan's narrative-journals
# section): narrative session journals describe the adopter's product work and can never be proven
# safe to publish, so NO narrative journal can leave the machine. Every publication surface refuses
# outright for every adapter and in every mode (--commit --push, bare --file, and
# --allow-release-checkout-write -- even the no-commit path writes/stages narrative into a git
# worktree and is just as much a leak vector). Journals stay LOCAL evidence via
# prepare-session-journal + validate-session-journal under the lane's gitignored .ai-runs/. The
# replacement for contributing upstream is the sanitized, zero-freeform instrumentation record.
NARRATIVE_JOURNAL_PUBLISH_REFUSAL = (
    "publish_error: narrative session-journal publication is disabled as of 0.9.0. A session "
    "journal narrates the adopter's product work, so it can never be proven safe to publish -- no "
    "narrative journal can leave the machine, in any mode (--commit --push, --file, or "
    "--allow-release-checkout-write). Journals remain LOCAL evidence: create with "
    "prepare-session-journal and inspect with validate-session-journal (both stay under the lane's "
    "gitignored .ai-runs/). To contribute sanitized signal upstream, enable instrumentation and run "
    "`publish-instrumentation-record` -- a closed-vocabulary record with zero product-information "
    "capacity. See docs/reference/session-journals.md and docs/reference/instrumentation.md."
)


def publish_session_journal(args: argparse.Namespace) -> int:
    """Refused in 0.9.0 (see NARRATIVE_JOURNAL_PUBLISH_REFUSAL). Every mode refuses -- the argparse
    flags remain parseable so an old muscle-memory invocation gets the migration message instead of
    an unknown-option error, but nothing is validated, written, staged, committed, or pushed."""
    print(NARRATIVE_JOURNAL_PUBLISH_REFUSAL, file=sys.stderr)
    return 1


def publish_pending_session_journals(args: argparse.Namespace) -> int:
    """Refused in 0.9.0 (see NARRATIVE_JOURNAL_PUBLISH_REFUSAL). Pending journals stay LOCAL; there
    is no remote publication path for narrative journals anymore, for any adapter."""
    print(NARRATIVE_JOURNAL_PUBLISH_REFUSAL, file=sys.stderr)
    return 1


def rca_next_action_is_concrete(next_action_lower: str) -> bool:
    if "true blocker" in next_action_lower:
        return True
    if re.search(r"https?://\S+", next_action_lower):
        return True
    if re.search(r"\b(?:minervit-methodology|make|gh|python3?|npm|pnpm|yarn)\s+\S+", next_action_lower):
        return True
    if re.search(r"(?:^|\s)(?:\.?/)?(?:bin|docs|methodology|plugins|scripts|adapters|\.ai-runs|\.ai-work|\.ai-continuity)/[^\s`]+", next_action_lower):
        return True
    if re.search(r"(?:^|\s)(?:readme\.md|claude\.md|agents\.md|\.minervit-ai-delivery\.json)\b", next_action_lower):
        return True
    if re.search(r"\b(?:pr|check)\s*#?\d+\b", next_action_lower):
        return True
    return False


def rca_person_specific_tokens() -> set[str]:
    candidates = {
        os.environ.get("USER", ""),
        os.environ.get("LOGNAME", ""),
        Path.home().name,
    }
    tokens: set[str] = set()
    for candidate in candidates:
        token = candidate.strip()
        if len(token) < 3:
            continue
        if token.lower() in RCA_GENERIC_USERNAME_TOKENS:
            continue
        tokens.add(token)
    return tokens


def validate_rca_artifact_file(path: Path) -> list[str]:
    errors: list[str] = []
    if not path.exists():
        return [f"RCA artifact missing: {path}"]
    text = path.read_text(encoding="utf-8")
    for token in rca_person_specific_tokens():
        if re.search(rf"\b{re.escape(token)}\b", text, re.IGNORECASE):
            errors.append(f"person/machine-specific token present: {token}")
    for pattern in RCA_PERSON_SPECIFIC_PATH_PATTERNS:
        match = re.search(pattern, text)
        if match:
            errors.append(f"person/machine-specific path present: {match.group(0)}")

    for heading in RCA_REQUIRED_HEADINGS:
        match = re.search(rf"^{re.escape(heading)}\s*$", text, re.MULTILINE)
        if not match:
            errors.append(f"missing required heading: {heading}")
            continue
        section = markdown_section(text, heading)
        if len(section) < 12:
            errors.append(f"section is empty or too vague: {heading}")

    root_cause = markdown_section(text, "## Root cause")
    root_cause_lower = root_cause.lower().replace("’", "'").replace("‘", "'")
    if any(escape.lower() in root_cause_lower for escape in RCA_DEFAULT_ESCAPE_CLASSES):
        if not any(term in root_cause_lower for term in RCA_CONTROL_GAP_TERMS):
            errors.append("default/shortcut root causes must name the control gap")
        if not any(reference in root_cause_lower for reference in RCA_CONTROL_REFERENCES):
            errors.append("default/shortcut root causes must name the methodology control")

    proposed_control = markdown_section(text, "## Proposed control")
    proposed_control_lower = proposed_control.lower()
    if any(term in proposed_control_lower for term in RCA_MEMORY_AUTHORITY_TERMS) and not any(
        marker in proposed_control_lower for marker in RCA_STRONG_PROCESS_AUTHORITY_MARKERS
    ):
        errors.append("Proposed control cannot cite memory/Open Brain as process authority")

    evidence = markdown_section(text, "## Evidence")
    evidence_lower = evidence.lower()
    if "current conversation context only" not in evidence_lower:
        evidence_lines = [line.strip() for line in evidence.splitlines() if line.strip()]
        if not evidence_lines:
            errors.append("Evidence must name current context or inspected artifacts")
        for line in evidence_lines:
            line = line.lstrip("-* ").strip()
            line_lower = line.lower()
            if "not resolved" in line_lower or not re.search(r":\s*resolved\s+\S+", line_lower):
                errors.append("Evidence lines must map each artifact as '<artifact>: resolved <stated uncertainty>'")
                break
    return errors


def validate_rca_artifact(args: argparse.Namespace) -> int:
    errors = validate_rca_artifact_file(args.file)
    if errors:
        for error in errors:
            print(error, file=sys.stderr)
        return 1
    print(f"rca_artifact_valid: {args.file}")
    return 0


# --- D4: backlog-epic grooming Definition of Ready (read-only validate-grooming) ----------------
#
# Epics are enumerated by their epicField VALUE (matching the lane's assigned epics), NOT by an
# itemType (there is no 'epic'/'feature' itemType; the itemTypes enum is untouched). Feature items
# are the epic's native sub-issues. Everything board-touching here is READ-ONLY; the gate enforces
# only via a non-zero exit code and never mutates the board.

# Sentinel returned by the sub-issue reader when GitHub's native sub_issues GraphQL is unavailable
# (older gh / connection error): degrade to a non-blocking warning, never a false DoR-fail.
GROOMING_SUBISSUES_UNAVAILABLE = "__grooming_subissues_unavailable__"


def grooming_privacy_invariants(data: dict) -> list[str]:
    """Project-declared privacy/security invariants (optional). A project that declares none must
    NOT fail the privacy-negative-test check (over-block guard)."""
    raw = data.get("privacyInvariants")
    if not isinstance(raw, list):
        return []
    return [str(item).strip() for item in raw if str(item).strip()]


def grooming_feature_dor_issues(body: str, privacy_invariants: list[str]) -> list[str]:
    """Pure DoR checks for one feature item body. Returns a list of human-readable reasons (empty
    when the item satisfies the DoR)."""
    issues: list[str] = []
    lower = body.lower()
    if "## acceptance criteria" not in lower:
        issues.append("missing `## Acceptance criteria`")
    elif _grooming_section_is_stub(body, "## Acceptance criteria"):
        issues.append("`## Acceptance criteria` is empty or a stub")
    if "## verification" not in lower:
        issues.append("missing `## Verification`")
    elif _grooming_section_is_stub(body, "## Verification"):
        issues.append("`## Verification` is empty or a stub")
    if privacy_invariants:
        # A negative/privacy test section must be present when the project declares invariants.
        if not re.search(r"(?im)^##\s+privacy\b|^##\s+.*negative tests?\b", body):
            issues.append("missing privacy/negative-test section (project declares privacy invariants)")
    return issues


def _grooming_section_is_stub(body: str, heading: str) -> bool:
    section = markdown_section(body, heading)
    stripped = section.strip()
    if len(stripped) < 12:
        return True
    return bool(re.fullmatch(r"(?is)\s*(?:todo|tbd|placeholder|n/?a|pending)\s*", stripped))


def grooming_feature_number(item: dict, body: str, feature_series: dict) -> str:
    """Extract a feature number for collision detection. Prefers a `pattern` match over the item
    title+body (the RELIABLE mechanism: native sub-issue GraphQL Issue nodes carry NO ProjectV2
    field value, so a configured `field` reads empty against the real board). Falls back to the
    board `field` value only when no pattern is configured (or the pattern does not match), which
    works for non-sub-issue item shapes that do carry field values."""
    pattern = str(feature_series.get("pattern") or "").strip()
    if pattern:
        haystack = f"{goal_tracker_value_text(item.get('title'))}\n{body}"
        try:
            match = re.search(pattern, haystack)
        except re.error:
            match = None
        if match:
            return match.group(0).strip()
    field = str(feature_series.get("field") or "").strip()
    if field:
        value = goal_tracker_item_field(item, field).strip()
        if value:
            return value
    return ""


def grooming_sub_issues(item: dict, target: Path) -> "list[dict] | str":
    """Read-only enumeration of an epic's native sub-issues via GraphQL. Returns a list of
    sub-issue nodes, or GROOMING_SUBISSUES_UNAVAILABLE when the native sub_issues connection is not
    available (older gh / API error) so the caller degrades to a warning instead of false-failing."""
    content = item.get("content") if isinstance(item.get("content"), dict) else {}
    node_id = goal_tracker_value_text(content.get("id")) or goal_tracker_value_text(item.get("id"))
    if not node_id:
        return GROOMING_SUBISSUES_UNAVAILABLE
    query = (
        "query($id:ID!){node(id:$id){... on Issue{subIssues(first:50){totalCount "
        "nodes{id number title url body}}}}}"
    )
    try:
        payload = goal_tracker_gh_json(["api", "graphql", "-f", f"query={query}", "-f", f"id={node_id}"], target)
    except SystemExit:
        return GROOMING_SUBISSUES_UNAVAILABLE
    container = payload.get("data", payload) if isinstance(payload, dict) else {}
    node = container.get("node") if isinstance(container, dict) else None
    sub = (node or {}).get("subIssues") if isinstance(node, dict) else None
    if not isinstance(sub, dict):
        return GROOMING_SUBISSUES_UNAVAILABLE
    nodes = sub.get("nodes")
    if not isinstance(nodes, list):
        return GROOMING_SUBISSUES_UNAVAILABLE
    return [n for n in nodes if isinstance(n, dict)]


def grooming_epic_items(data: dict, target: Path, assigned: list[str]) -> list[dict]:
    """Epics in scope, enumerated by epicField VALUE. With assigned epics, select items whose
    epicField equals one of them; with none assigned, select every item that carries a non-empty
    epicField value. Never selects by itemType (there is no epic itemType)."""
    epic_field = board_epic_field(data)
    if not epic_field:
        raise SystemExit("backlogProvider.epicField is not configured; grooming enumerates epics by epicField value")
    assigned_set = {name.strip().lower() for name in assigned if name.strip()}
    epics: list[dict] = []
    for item in goal_tracker_items_for_scope(data, target):
        value = goal_tracker_item_field(item, epic_field).strip()
        if not value:
            continue
        if assigned_set and value.lower() not in assigned_set:
            continue
        epics.append(item)
    return epics


def validate_grooming(args: argparse.Namespace) -> int:
    data, _, target = lane_project(args)
    tracker = goal_tracker_config(data)
    provider = backlog_provider_config(data)
    if not (tracker.get("enabled") or provider.get("enabled")):
        print("grooming_dor_ok: provider not enabled; no board to groom")
        return 0
    # Provider-unavailable is a warning by default; a failure only under --strict (operator-run).
    auth_issues = goal_tracker_auth_issues(target)
    if auth_issues:
        for issue in auth_issues:
            print(f"grooming_provider_warn: {issue}", file=sys.stderr)
        if getattr(args, "strict", False):
            print("grooming_dor_issue: provider unavailable; cannot verify grooming DoR (--strict)", file=sys.stderr)
            return 1
        print("grooming_dor_ok: provider unavailable; skipped (run with --strict to require)")
        return 0
    assigned = lane_assigned_epics(args)
    if assigned:
        print(f"grooming_assigned_epics: {', '.join(assigned)}")
    else:
        print("grooming_assigned_epics: none - no epic assigned; checking all epics on the board")
    feature_series = board_feature_series(data)
    if not feature_series.get("field") and not feature_series.get("pattern"):
        print("grooming_feature_series_unconfigured: number-collision check skipped (set backlogProvider.featureSeries.field)")
    privacy_invariants = grooming_privacy_invariants(data)
    strict = bool(getattr(args, "strict", False))
    try:
        epics = grooming_epic_items(data, target, assigned)
    except SystemExit as exc:
        print(f"grooming_dor_issue: {exc}", file=sys.stderr)
        return 1
    # An empty epic set is not automatically a pass: assigned epics that match nothing is a typo /
    # off-board reference and must FAIL; an unassigned scan of a board with no epics at all is a
    # warn-and-pass (nothing to groom yet).
    if not epics:
        if assigned:
            print(
                f"grooming_dor_issue: assigned epics {', '.join(assigned)} matched no board items "
                "(typo or not on the board)",
                file=sys.stderr,
            )
            return 1
        print("grooming_no_epics_warn: no epics carry an epicField value on the board; nothing to groom", file=sys.stderr)
        return 0
    issues: list[str] = []
    seen_numbers: dict[str, str] = {}
    # FIX-7: a configured `field` reads empty on native sub-issue Issue nodes (they carry no
    # ProjectV2 field value). Track whether the field ever produced a value; if it is configured
    # and yields nothing for every sub, warn once and rely on `pattern` for collision detection.
    field_configured = bool(str(feature_series.get("field") or "").strip())
    field_ever_read = False
    for epic in epics:
        epic_ref = goal_tracker_item_title(epic) or goal_tracker_item_id(epic) or "epic"
        subs = grooming_sub_issues(epic, target)
        if subs == GROOMING_SUBISSUES_UNAVAILABLE:
            # FIX-8: under --strict (operator requiring verification at a boundary) an unreadable
            # sub-issue connection is a hard failure, not a degrade-to-pass. Non-strict still warns.
            if strict:
                issues.append(
                    f"{epic_ref}: native sub-issue enumeration unavailable; cannot verify grooming DoR (--strict)"
                )
            else:
                print(
                    f"grooming_subissues_warn: {epic_ref}: native sub-issue enumeration unavailable; "
                    "skipped sub-issue DoR checks for this epic",
                    file=sys.stderr,
                )
            continue
        assert isinstance(subs, list)
        print(f"grooming_epic: {epic_ref} sub_issues={len(subs)}")
        if not subs:
            issues.append(f"{epic_ref}: no feature items / sub-issues; an epic is not consumable until groomed into DoR-satisfying feature items")
            continue
        for sub in subs:
            sub_ref = goal_tracker_value_text(sub.get("title")) or goal_tracker_value_text(sub.get("url")) or f"#{sub.get('number')}"
            body = goal_tracker_value_text(sub.get("body"))
            for reason in grooming_feature_dor_issues(body, privacy_invariants):
                issues.append(f"{epic_ref} / {sub_ref}: {reason}")
            if feature_series.get("field") or feature_series.get("pattern"):
                if field_configured and goal_tracker_item_field(sub, str(feature_series["field"]).strip()).strip():
                    field_ever_read = True
                number = grooming_feature_number(sub, body, feature_series)
                if number:
                    key = number.lower()
                    if key in seen_numbers:
                        issues.append(f"{epic_ref} / {sub_ref}: duplicate feature number {number} (also on {seen_numbers[key]})")
                    else:
                        seen_numbers[key] = sub_ref
    if field_configured and not field_ever_read:
        print(
            "grooming_feature_series_field_unreadable: featureSeries.field "
            f"{str(feature_series['field']).strip()!r} read empty for every sub-issue (native sub-issue "
            "nodes carry no board field value); collision detection fell back to featureSeries.pattern "
            "-- configure `pattern` for the numbering series",
            file=sys.stderr,
        )
        # No pattern fallback exists, so the configured numbering series could not be verified at
        # all. Under --strict this is a closed-gate failure (a silent grooming_dor_ok would hide
        # that collisions went unchecked); non-strict leaves the warning above as advisory.
        if not str(feature_series.get("pattern", "")).strip() and getattr(args, "strict", False):
            issues.append(
                "featureSeries.field could not be read from any sub-issue and no featureSeries.pattern "
                "is configured, so feature-number collisions could not be verified (--strict): set "
                "backlogProvider.featureSeries.pattern"
            )
    if issues:
        for issue in issues:
            print(f"grooming_dor_issue: {issue}", file=sys.stderr)
        print("grooming_dor_failed: fix the source item(s) above and re-run; validate-grooming is read-only and never edits the board", file=sys.stderr)
        return 1
    print("grooming_dor_ok")
    return 0


def feature_request_section_is_stub(section: str) -> bool:
    stripped = section.strip()
    if len(stripped) < 12:
        return True
    return bool(re.fullmatch(r"(?is)\s*(?:todo|tbd|placeholder|n/?a|pending|none)\s*", stripped))


def validate_feature_request_artifact_file(path: Path) -> list[str]:
    errors: list[str] = []
    if not path.exists():
        return [f"feature request artifact missing: {path}"]
    text = path.read_text(encoding="utf-8")
    for token in rca_person_specific_tokens():
        if re.search(rf"\b{re.escape(token)}\b", text, re.IGNORECASE):
            errors.append(f"person/machine-specific token present: {token}")
    for pattern in RCA_PERSON_SPECIFIC_PATH_PATTERNS:
        match = re.search(pattern, text)
        if match:
            errors.append(f"person/machine-specific path present: {match.group(0)}")

    positions: list[tuple[str, int]] = []
    for heading in FEATURE_REQUEST_REQUIRED_HEADINGS:
        match = re.search(rf"^{re.escape(heading)}\s*$", text, re.MULTILINE)
        if not match:
            errors.append(f"missing required heading: {heading}")
            continue
        positions.append((heading, match.start()))
    if len(positions) == len(FEATURE_REQUEST_REQUIRED_HEADINGS):
        ordered = [position for _, position in positions]
        if ordered != sorted(ordered):
            errors.append("required headings are out of order")

    lead_issue = backlog_item_missing_business_lead(text)
    if lead_issue:
        errors.append(lead_issue)

    for heading in FEATURE_REQUEST_REQUIRED_HEADINGS:
        section = markdown_section(text, heading)
        if feature_request_section_is_stub(section):
            errors.append(f"section is empty or too vague: {heading}")

    acceptance = markdown_section(text, "## Acceptance Criteria")
    if acceptance and not re.search(r"(?m)^\s*(?:[-*]|\d+\.)\s+\S", acceptance):
        errors.append("Acceptance Criteria must contain at least one bullet or numbered criterion")

    rca_heading_hits = [
        heading
        for heading in FEATURE_REQUEST_RCA_HEADINGS
        if re.search(rf"^{re.escape(heading)}\s*$", text, re.MULTILINE)
    ]
    if len(rca_heading_hits) >= 2:
        errors.append(f"feature request artifact must not use RCA incident heading set: {', '.join(rca_heading_hits)}")

    next_action = markdown_section(text, "## Immediate Next Action").strip()
    next_action_lower = re.sub(r"\s+", " ", next_action.lower().replace("`", "")).strip()
    next_action_generic = next_action_lower.strip(" .:-")
    if next_action_generic in RCA_GENERIC_NEXT_ACTIONS:
        errors.append("Immediate Next Action is empty or generic")
    elif not rca_next_action_is_concrete(next_action_lower):
        errors.append("Immediate Next Action must name a file path, validation target, command, PR/check URL, or true-blocker decision")

    return errors


def validate_feature_request_artifact(args: argparse.Namespace) -> int:
    errors = validate_feature_request_artifact_file(args.file)
    if errors:
        for error in errors:
            print(error, file=sys.stderr)
        return 1
    print(f"feature_request_artifact_valid: {args.file}")
    return 0


def feature_request_archive_title(path: Path) -> str:
    try:
        text = path.read_text(encoding="utf-8", errors="replace")
    except OSError:
        return path.stem
    what = markdown_section(text, "## What this delivers")
    for line in what.splitlines():
        cleaned = line.strip().lstrip("-* ").strip()
        if cleaned:
            return cleaned[:180]
    return path.stem


def write_feature_request_archive_copy(source: Path, archive_dir: Path, repo_root: Path) -> tuple[Path, Path]:
    archive_dir.mkdir(parents=True, exist_ok=True)
    dest = archive_dir / source.name
    if dest.exists() and dest.read_bytes() != source.read_bytes():
        dest = unique_destination(archive_dir, source.name)
    if not dest.exists():
        shutil.copyfile(source, dest)

    index_path = archive_dir / "_index.md"
    rel_dest = dest.relative_to(repo_root).as_posix() if path_is_under(dest, repo_root) else str(dest)
    title = feature_request_archive_title(dest)
    entry = f"- `{rel_dest}` - {title}"
    if index_path.exists():
        index_text = index_path.read_text(encoding="utf-8")
    else:
        index_text = (
            "# Methodology Feature Request Archive\n\n"
            "Branch-published methodology feature-request artifacts. Lane-local `.ai-runs/` files are useful run evidence, but branch copies here make enhancement requests visible across machines and future agents without adding request artifacts to `main`.\n\n"
            "## Artifacts\n\n"
        )
    if rel_dest not in index_text:
        index_text = index_text.rstrip() + "\n" + entry + "\n"
        index_path.write_text(index_text, encoding="utf-8")
    return dest, index_path


def publish_feature_request_to_branch(source: Path, archive_dir: Path, branch: str, message: str | None) -> int:
    # Canonical everywhere in this function: the origin URL, the containment root, and the path the
    # archive dir is relativized against. A snapshot exec root has no `.git` for the origin read and
    # is not where an operator's archive dir lives.
    canonical = canonical_methodology_repo()
    remote_url = run_git(canonical, ["config", "--get", "remote.origin.url"])
    if not remote_url or remote_url == "unavailable":
        print("git_remote_error: methodology repo has no remote.origin.url; cannot publish feature request branch", file=sys.stderr)
        return 1
    if not re.fullmatch(r"[A-Za-z0-9._/-]+", branch) or branch.startswith("/") or branch.endswith("/"):
        print(f"branch_error: invalid feature request branch name: {branch}", file=sys.stderr)
        return 1
    if not path_is_under(archive_dir, canonical):
        print(
            f"archive_dir_error: {archive_dir} is outside methodology repo {canonical}; feature request archive copies must use a methodology-repo path",
            file=sys.stderr,
        )
        return 1

    rel_archive_dir = archive_dir.relative_to(canonical)
    with tempfile.TemporaryDirectory(prefix="minervit-feature-request-publish-") as tmp:
        clone_dir = Path(tmp) / "repo"
        branch_exists = remote_branch_exists(remote_url, branch)
        if branch_exists:
            code, _out, err = run_command(
                ["git", "clone", "--branch", branch, "--single-branch", remote_url, str(clone_dir)]
            )
            if code != 0:
                print(f"git_clone_error: {err or 'git clone failed'}", file=sys.stderr)
                return 1
        else:
            code, _out, err = run_command(["git", "clone", remote_url, str(clone_dir)])
            if code != 0:
                print(f"git_clone_error: {err or 'git clone failed'}", file=sys.stderr)
                return 1
            code, _out, err = run_command(["git", "checkout", "--orphan", branch], cwd=clone_dir)
            if code != 0:
                print(f"git_branch_error: {err or 'git checkout --orphan failed'}", file=sys.stderr)
                return 1
            run_command(["git", "rm", "-rf", "."], cwd=clone_dir)

        branch_archive_dir = clone_dir / rel_archive_dir
        dest, index_path = write_feature_request_archive_copy(source, branch_archive_dir, clone_dir)
        rel_dest = dest.relative_to(clone_dir).as_posix()
        rel_index = index_path.relative_to(clone_dir).as_posix()
        code, _out, err = run_command(["git", "add", "--", rel_dest, rel_index], cwd=clone_dir)
        if code != 0:
            print(f"git_stage_error: {err or 'git add failed'}", file=sys.stderr)
            return 1
        code, _out, err = run_command(["git", "diff", "--cached", "--quiet", "--", rel_dest, rel_index], cwd=clone_dir)
        if code == 0:
            print("feature_request_artifact_commit: no feature request archive changes on branch")
        elif code == 1:
            commit_message = message or f"docs: publish methodology feature request {source.stem}"
            code, out, err = run_command(["git", "commit", "-m", commit_message, "--", rel_dest, rel_index], cwd=clone_dir)
            if code != 0:
                print(f"git_commit_error: {err or out or 'git commit failed'}", file=sys.stderr)
                return 1
            print(f"feature_request_artifact_commit: {out.splitlines()[0] if out else commit_message}")
        else:
            print(f"git_diff_error: {err or 'git diff --cached failed'}", file=sys.stderr)
            return 1
        code, out, err = run_command(["git", "push", "origin", f"HEAD:refs/heads/{branch}"], cwd=clone_dir)
        if code != 0:
            print(f"git_push_error: {err or out or 'git push failed'}", file=sys.stderr)
            return 1
        print(f"feature_request_artifact_branch: {branch}")
        print(f"feature_request_artifact_published: {branch}:{rel_dest}")
        print(f"feature_request_archive_index: {branch}:{rel_index}")
        print("feature_request_artifact_pushed: ok")
        return 0


def publish_feature_request_artifact(args: argparse.Namespace) -> int:
    source = args.file.resolve()
    if args.push and not args.commit:
        print("publish_error: --push requires --commit so the feature request archive copy can reach the remote", file=sys.stderr)
        return 1
    if args.commit and not args.push:
        print("publish_error: --commit requires --push; feature request commits publish to the dedicated archive branch, not local main", file=sys.stderr)
        return 1
    errors = validate_feature_request_artifact_file(source)
    if errors:
        for error in errors:
            print(error, file=sys.stderr)
        return 1
    print(f"feature_request_artifact_valid: {source}")

    canonical = canonical_methodology_repo()
    default_dir = canonical_archive_dir(
        DEFAULT_FEATURE_REQUEST_ARCHIVE_DIR, FEATURE_REQUEST_ARCHIVE_SUBPATH
    )
    archive_dir = (args.archive_dir or default_dir).expanduser().resolve()
    if not path_is_under(archive_dir, canonical):
        print(
            f"archive_dir_error: {archive_dir} is outside methodology repo {canonical}; feature request archive copies must be commit-ready in the methodology repository",
            file=sys.stderr,
        )
        return 1
    if not release_checkout_archive_write_allowed(args, "feature request"):
        return 1

    if args.commit and args.push:
        return publish_feature_request_to_branch(source, archive_dir, args.branch, args.message)

    dest, index_path = write_feature_request_archive_copy(source, archive_dir, canonical)
    rel_dest = dest.relative_to(canonical).as_posix()
    rel_index = index_path.relative_to(canonical).as_posix()
    print(f"feature_request_artifact_published: {dest}")
    print(f"feature_request_archive_index: {index_path}")

    if args.no_stage:
        print("feature_request_artifact_staged: skipped --no-stage")
    else:
        if not stage_archive_copy(canonical, [rel_dest, rel_index], "feature request"):
            return 1
        print(f"feature_request_artifact_staged: {rel_dest} {rel_index}")

    print("feature_request_artifact_commit: not requested; use --commit --push to publish to the dedicated feature request branch")
    print("feature_request_artifact_pushed: not requested; branch publish is required before treating the feature request as cross-machine durable")

    return 0


def rca_archive_title(path: Path) -> str:
    try:
        text = path.read_text(encoding="utf-8", errors="replace")
    except OSError:
        return path.stem
    for heading in ("## What happened", "## Incident Summary"):
        incident = markdown_section(text, heading)
        for line in incident.splitlines():
            cleaned = line.strip().lstrip("-* ").strip()
            if cleaned:
                return cleaned[:180]
    return path.stem


def write_rca_archive_copy(source: Path, archive_dir: Path, repo_root: Path) -> tuple[Path, Path]:
    archive_dir.mkdir(parents=True, exist_ok=True)
    dest = archive_dir / source.name
    if dest.exists() and dest.read_bytes() != source.read_bytes():
        dest = unique_destination(archive_dir, source.name)
    if not dest.exists():
        shutil.copyfile(source, dest)

    index_path = archive_dir / "_index.md"
    rel_dest = dest.relative_to(repo_root).as_posix() if path_is_under(dest, repo_root) else str(dest)
    title = rca_archive_title(dest)
    entry = f"- `{rel_dest}` - {title}"
    if index_path.exists():
        index_text = index_path.read_text(encoding="utf-8")
    else:
        index_text = (
            "# Methodology Regression RCA Archive\n\n"
            "Branch-published methodology/process RCA artifacts. Lane-local `.ai-runs/` files are useful run evidence, but branch copies here make regressions visible across machines and future agents without adding RCA Markdown to `main`.\n\n"
            "## Artifacts\n\n"
        )
    if rel_dest not in index_text:
        index_text = index_text.rstrip() + "\n" + entry + "\n"
        index_path.write_text(index_text, encoding="utf-8")
    return dest, index_path


def remote_branch_exists(remote_url: str, branch: str) -> bool:
    code, out, _err = run_command(["git", "ls-remote", "--heads", remote_url, branch])
    return code == 0 and bool(out.strip())


def publish_rca_to_branch(source: Path, archive_dir: Path, branch: str, message: str | None) -> int:
    # Canonical everywhere in this function (see publish_feature_request_to_branch).
    canonical = canonical_methodology_repo()
    remote_url = run_git(canonical, ["config", "--get", "remote.origin.url"])
    if not remote_url or remote_url == "unavailable":
        print("git_remote_error: methodology repo has no remote.origin.url; cannot publish RCA branch", file=sys.stderr)
        return 1
    if not re.fullmatch(r"[A-Za-z0-9._/-]+", branch) or branch.startswith("/") or branch.endswith("/"):
        print(f"branch_error: invalid RCA branch name: {branch}", file=sys.stderr)
        return 1
    if not path_is_under(archive_dir, canonical):
        print(
            f"archive_dir_error: {archive_dir} is outside methodology repo {canonical}; RCA archive copies must use a methodology-repo path",
            file=sys.stderr,
        )
        return 1

    rel_archive_dir = archive_dir.relative_to(canonical)
    with tempfile.TemporaryDirectory(prefix="minervit-rca-publish-") as tmp:
        clone_dir = Path(tmp) / "repo"
        branch_exists = remote_branch_exists(remote_url, branch)
        if branch_exists:
            code, _out, err = run_command(
                ["git", "clone", "--branch", branch, "--single-branch", remote_url, str(clone_dir)]
            )
            if code != 0:
                print(f"git_clone_error: {err or 'git clone failed'}", file=sys.stderr)
                return 1
        else:
            code, _out, err = run_command(["git", "clone", remote_url, str(clone_dir)])
            if code != 0:
                print(f"git_clone_error: {err or 'git clone failed'}", file=sys.stderr)
                return 1
            code, _out, err = run_command(["git", "checkout", "--orphan", branch], cwd=clone_dir)
            if code != 0:
                print(f"git_branch_error: {err or 'git checkout --orphan failed'}", file=sys.stderr)
                return 1
            run_command(["git", "rm", "-rf", "."], cwd=clone_dir)

        branch_archive_dir = clone_dir / rel_archive_dir
        dest, index_path = write_rca_archive_copy(source, branch_archive_dir, clone_dir)
        rel_dest = dest.relative_to(clone_dir).as_posix()
        rel_index = index_path.relative_to(clone_dir).as_posix()
        code, _out, err = run_command(["git", "add", "--", rel_dest, rel_index], cwd=clone_dir)
        if code != 0:
            print(f"git_stage_error: {err or 'git add failed'}", file=sys.stderr)
            return 1
        code, _out, err = run_command(["git", "diff", "--cached", "--quiet", "--", rel_dest, rel_index], cwd=clone_dir)
        if code == 0:
            print("rca_artifact_commit: no RCA archive changes on branch")
        elif code == 1:
            commit_message = message or f"docs: publish methodology regression RCA {source.stem}"
            code, out, err = run_command(["git", "commit", "-m", commit_message, "--", rel_dest, rel_index], cwd=clone_dir)
            if code != 0:
                print(f"git_commit_error: {err or out or 'git commit failed'}", file=sys.stderr)
                return 1
            print(f"rca_artifact_commit: {out.splitlines()[0] if out else commit_message}")
        else:
            print(f"git_diff_error: {err or 'git diff --cached failed'}", file=sys.stderr)
            return 1
        code, out, err = run_command(["git", "push", "origin", f"HEAD:refs/heads/{branch}"], cwd=clone_dir)
        if code != 0:
            print(f"git_push_error: {err or out or 'git push failed'}", file=sys.stderr)
            return 1
        print(f"rca_artifact_branch: {branch}")
        print(f"rca_artifact_published: {branch}:{rel_dest}")
        print(f"rca_archive_index: {branch}:{rel_index}")
        print("rca_artifact_pushed: ok")
        return 0


def publish_rca_artifact(args: argparse.Namespace) -> int:
    source = args.file.resolve()
    if args.push and not args.commit:
        print("publish_error: --push requires --commit so the RCA archive copy can reach the remote", file=sys.stderr)
        return 1
    if args.commit and not args.push:
        print("publish_error: --commit requires --push; methodology RCA commits publish to the dedicated RCA branch, not local main", file=sys.stderr)
        return 1
    errors = validate_rca_artifact_file(source)
    if errors:
        for error in errors:
            print(error, file=sys.stderr)
        return 1
    print(f"rca_artifact_valid: {source}")

    canonical = canonical_methodology_repo()
    default_dir = canonical_archive_dir(DEFAULT_RCA_ARCHIVE_DIR, RCA_ARCHIVE_SUBPATH)
    archive_dir = (args.archive_dir or default_dir).expanduser().resolve()
    if not path_is_under(archive_dir, canonical):
        print(
            f"archive_dir_error: {archive_dir} is outside methodology repo {canonical}; RCA archive copies must be commit-ready in the methodology repository",
            file=sys.stderr,
        )
        return 1
    if not release_checkout_archive_write_allowed(args, "RCA"):
        return 1

    if args.commit and args.push:
        return publish_rca_to_branch(source, archive_dir, args.branch, args.message)

    dest, index_path = write_rca_archive_copy(source, archive_dir, canonical)
    rel_dest = dest.relative_to(canonical).as_posix()
    rel_index = index_path.relative_to(canonical).as_posix()
    print(f"rca_artifact_published: {dest}")
    print(f"rca_archive_index: {index_path}")

    if args.no_stage:
        print("rca_artifact_staged: skipped --no-stage")
    else:
        if not stage_archive_copy(canonical, [rel_dest, rel_index], "RCA"):
            return 1
        print(f"rca_artifact_staged: {rel_dest} {rel_index}")

    print("rca_artifact_commit: not requested; use --commit --push to publish to the dedicated RCA branch")
    print("rca_artifact_pushed: not requested; branch publish is required before treating the RCA as cross-machine durable")

    return 0


def dispatch_command(args: argparse.Namespace) -> int:
    """One explicit hook-failure contract (arch-errors-1).

    Hooks (commands whose name ends in `-hook`) FAIL OPEN: an UNEXPECTED internal error must never
    wedge a lane, so it is logged to stderr and dispatch returns 0 (no block). Intentional block
    decisions are unaffected -- `hook_decision` prints its block to stdout (it does not raise), and a
    deliberate SystemExit is re-raised. Non-hook commands keep normal fail-loud behavior (exceptions
    propagate), since for them a surfaced error is the correct signal, not a silent pass.
    """
    cmd_name = getattr(args, "cmd", "") or ""
    warn_deprecated_command(cmd_name)
    remediation_refusal = startup_remediation_guard_refusal(cmd_name, args)
    if remediation_refusal is not None:
        print(remediation_refusal, file=sys.stderr)
        return 1
    if cmd_name.endswith("-hook"):
        try:
            return args.func(args)
        except SystemExit as exc:
            missing_framework_package = (
                "require the framework checkout's src/minervit_methodology" in str(exc)
                and isinstance(exc.__cause__, ModuleNotFoundError)
            )
            if not missing_framework_package:
                raise
            print(
                f"hook_fail_open: {cmd_name} hit a missing framework package and did NOT block: "
                f"{str(exc)}",
                file=sys.stderr,
            )
            return 0
        except Exception as exc:  # noqa: BLE001 - failing open is the explicit hook contract
            try:
                detail = redact_secrets(str(exc))
            except BaseException:
                # The last-resort fail-open handler must not depend on the lazily
                # imported util package (absent in standalone bin copies), and it
                # must never fall back to the unredacted message: degrade to the
                # exception type only.
                detail = f"{type(exc).__name__} (detail suppressed: secret redaction unavailable)"
            print(
                f"hook_fail_open: {cmd_name} hit an internal error and did NOT block: "
                f"{detail}",
                file=sys.stderr,
            )
            return 0
    return args.func(args)


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    sub = parser.add_subparsers(dest="cmd", required=True)

    version_p = sub.add_parser("version", help="Print plugin version, methodology repo commit, and local-vs-remote sync status.")
    version_p.add_argument("--no-remote", action="store_true")
    version_p.set_defaults(func=version)

    github_budget_p = sub.add_parser(
        "github-budget-status",
        help="Report GitHub REST/GraphQL rate budget and the methodology GitHub cache state.",
    )
    github_budget_p.add_argument("--target", type=Path, default=Path("."))
    github_budget_p.add_argument("--refresh", action="store_true", help="Ignore the cached rate-limit status and fetch fresh budget data.")
    github_budget_p.set_defaults(func=github_budget_status)

    release_update_p = sub.add_parser("publish-release-update", help="Post per-version release-update cards to a Google Chat webhook and record the delivery.")
    release_update_p.add_argument("--version", help="Release version to summarize; defaults to current VERSION")
    release_update_p.add_argument("--last", type=int, help="Publish the last N release summaries in chronological order")
    release_update_p.add_argument("--webhook-env", default=RELEASE_UPDATE_WEBHOOK_ENV)
    release_update_p.add_argument("--webhook-url", help="One-off Google Chat webhook URL; do not commit this")
    release_update_p.add_argument("--dry-run", action="store_true")
    release_update_p.set_defaults(func=publish_release_update)

    release_update_status_p = sub.add_parser(
        "release-update-status",
        help="Report which shipped releases still lack a Google Chat delivery marker; exit 1 if any prior release is overdue.",
    )
    release_update_status_p.add_argument("--json", action="store_true", dest="as_json")
    release_update_status_p.add_argument(
        "--require-current",
        action="store_true",
        help=argparse.SUPPRESS,
    )
    release_update_status_p.set_defaults(func=release_update_status)

    install_p = sub.add_parser("install-cli", help="Install the CLI shim + methodology.env; mutates ~/.local/bin and ~/.config/minervit.")
    install_p.add_argument("--bin-dir", type=Path, default=USER_BIN_DIR)
    install_p.add_argument("--config-env", type=Path, default=USER_CONFIG_ENV)
    install_p.add_argument("--dry-run", action="store_true", help="Print planned actions without changing anything.")
    install_p.add_argument(
        "--update-policy",
        choices=["signed", "pinned", "warn", "unverified"],
        default="pinned",
        help="Auto-update trust policy written to methodology.env. Default 'pinned' pins the install commit; advance with update-repin.",
    )
    install_p.set_defaults(func=install_cli)

    repin_p = sub.add_parser(
        "update-repin",
        help="Advance the pinned auto-update allowlist in methodology.env to current origin/<branch> HEAD after reviewing upstream.",
    )
    repin_p.add_argument("--config-env", type=Path, default=None, help="Config env file; defaults to the effective one (tautline path, else legacy).")
    # choices mirrors set-framework-channel's positional: an unknown channel must error, not
    # silently map to main and repin trust to the wrong branch (Codex R2 P2).
    repin_p.add_argument(
        "--channel",
        choices=sorted(CLIENT_FRAMEWORK_CHANNELS),
        default="stable",
        help="Framework channel whose origin branch to trust (stable|experimental).",
    )
    repin_p.set_defaults(func=update_repin)

    uninstall_p = sub.add_parser("uninstall-cli", help="Remove the installed CLI shim and methodology.env (reverses install-cli).")
    uninstall_p.add_argument("--bin-dir", type=Path, default=USER_BIN_DIR)
    uninstall_p.add_argument("--config-env", type=Path, default=None, help="Config env file; defaults to the effective one (tautline path, else legacy).")
    uninstall_p.add_argument("--keep-env", action="store_true", help="Keep methodology.env; remove only the shim.")
    uninstall_p.add_argument("--dry-run", action="store_true", help="Print planned removals without changing anything.")
    uninstall_p.set_defaults(func=uninstall_cli)

    launcher_p = sub.add_parser(
        "install-claude-launcher",
        help="Install a managed Claude launcher that syncs methodology before startup.",
    )
    launcher_p.add_argument("--bin-dir", type=Path, default=USER_BIN_DIR, help="Directory to install the launcher into.")
    launcher_p.add_argument("--name", default="minervit-claude", help="Command name to create, such as minervit-claude or yolo.")
    launcher_p.add_argument("--dangerously-skip-permissions", action="store_true", help="Start Claude with --dangerously-skip-permissions.")
    launcher_p.add_argument("--force", action="store_true", help="Replace an existing launcher even when it was not methodology-generated.")
    launcher_p.set_defaults(func=install_claude_launcher)

    sync_p = sub.add_parser("sync-methodology", help="Update the methodology repo (optional auto-rescue), install guards, and report version/remote status.")
    sync_p.add_argument("--project", type=Path, help="Project adapter used with --target for release-track/WIP checks.")
    sync_p.add_argument("--target", type=Path, help="Adopter repo whose framework pin and WIP state should gate the sync.")
    sync_p.add_argument("--skip-update", action="store_true")
    sync_p.add_argument("--no-remote", action="store_true")
    sync_p.add_argument("--allow-non-main", action="store_true", help="Allow sync from a non-main methodology checkout for intentional methodology development.")
    sync_p.add_argument(
        "--auto-rescue-local-changes",
        action="store_true",
        help="Preserve dirty methodology checkout changes on a local rescue branch, reset release main to upstream, and re-run. This is already the default when invoked from outside the methodology repo.",
    )
    sync_p.add_argument(
        "--no-auto-rescue-local-changes",
        action="store_true",
        help="Disable project-startup auto-rescue and fail closed on dirty stale methodology checkouts.",
    )
    sync_p.add_argument(
        "--launcher-gate",
        action="store_true",
        help=(
            "Serialize concurrent lane launches on a lock, and skip the sync when another lane "
            "already synced within the freshness window "
            f"({METHODOLOGY_SYNC_FRESHNESS_ENV}, default "
            f"{METHODOLOGY_SYNC_DEFAULT_FRESHNESS_MINUTES} minutes; 0 disables the skip)."
        ),
    )
    sync_p.set_defaults(func=sync_methodology)

    snapshot_status_p = sub.add_parser(
        "snapshot-status",
        help=(
            "Report the methodology snapshot store: root, current target, published "
            "snapshots, and lane pins."
        ),
    )
    snapshot_status_p.set_defaults(func=snapshot_status)

    snapshot_prune_p = sub.add_parser(
        "snapshot-prune",
        help=(
            "Delete superseded methodology snapshots, keeping the current target, live "
            "pins, recently-current snapshots, and the newest --keep."
        ),
    )
    snapshot_prune_p.add_argument(
        "--keep",
        type=int,
        default=None,
        help=(
            "How many of the newest snapshots to retain regardless of use "
            f"(default {SNAPSHOT_DEFAULT_KEEP})."
        ),
    )
    snapshot_prune_p.set_defaults(func=snapshot_prune)

    snapshot_pin_p = sub.add_parser(
        "snapshot-pin",
        help=(
            "Pin a lane to the current methodology snapshot so retention cannot delete "
            "the code it is executing."
        ),
    )
    snapshot_pin_p.add_argument(
        "--target",
        type=Path,
        default=Path("."),
        help=(
            "Lane whose session is pinned to the current snapshot; defaults to the "
            "working directory."
        ),
    )
    snapshot_pin_p.set_defaults(func=snapshot_pin)

    channel_p = sub.add_parser(
        "set-framework-channel",
        help=(
            f"Select the stable or experimental framework channel. Defaults to writing {FRAMEWORK_PIN_FILE}; "
            "use --source adapter to update _framework.channel in the repo-local adapter and re-render lane JSON."
        ),
    )
    channel_p.add_argument("--target", type=Path, default=Path("."))
    channel_p.add_argument(
        "--source",
        choices=["pin", "adapter"],
        default="pin",
        help="Write a lane-local pin (default) or update the repo-local adapter _framework.channel.",
    )
    channel_p.add_argument(
        "--adapter",
        action="store_true",
        help="Alias for --source adapter.",
    )
    channel_p.add_argument(
        "--adapter-path",
        type=Path,
        help=f"Adapter path to update with --source adapter; defaults to {REPO_LOCAL_ADAPTER_FILE}.",
    )
    channel_p.add_argument("channel", choices=sorted(CLIENT_FRAMEWORK_CHANNELS))
    channel_p.set_defaults(func=set_framework_channel)

    maintainer_mode_p = sub.add_parser(
        "maintainer-mode",
        help=(
            "Machine-scoped maintainer standdown: turn the methodology update gates off (on), "
            "restore stock behavior (off), or report the armed state (status)."
        ),
        description=(
            "Maintainer mode: update gates stand down machine-wide while armed -- no fetch, "
            "no branch check, no rescue, no repair escalation at launch; a loud banner prints "
            "on every armed launch and heal keeps republishing the canonical checkout's HEAD. "
            "`on` writes the mode key into the installed config env file (the ONLY arming "
            "state: a live-environment value is inert by design), `off` strips it from every "
            "config surface and is therefore authoritative, `status` prints the three-state "
            "maintainer_mode line that methodology-status also reports."
        ),
    )
    maintainer_mode_p.add_argument(
        "action",
        choices=["on", "off", "status"],
        help="on: arm the standdown; off: disarm and restore stock behavior; status: report.",
    )
    maintainer_mode_p.set_defaults(func=maintainer_mode_command)

    repair_main_p = sub.add_parser(
        "repair-methodology-main",
        help="Preserve accidental local methodology main commits and reset release main to origin/main.",
    )
    repair_main_p.add_argument("--no-remote", action="store_true")
    repair_main_p.set_defaults(func=repair_methodology_main)

    render = sub.add_parser("render-adapters", help="Render CLAUDE.md/AGENTS.md/lane config from a project adapter (--write/--check) with drift guards.")
    render.add_argument("--project", type=Path, required=True)
    render.add_argument("--target", type=Path, required=True)
    render.add_argument("--write", action="store_true")
    render.add_argument("--check", action="store_true")
    render.add_argument(
        "--json-only",
        action="store_true",
        help="Render/check only .minervit-ai-delivery.json; preserve CLAUDE.md and AGENTS.md.",
    )
    render.set_defaults(func=render_adapters)

    validate_adapter_p = sub.add_parser(
        "validate-adapter",
        help="Validate a project adapter JSON file against the adapter schema (reports all violations).",
    )
    validate_adapter_p.add_argument("--project", type=Path, required=True, help="Path to the adapter JSON to validate.")
    validate_adapter_p.set_defaults(func=validate_adapter)

    migrate_adapter_p = sub.add_parser(
        "migrate-adapter",
        help="Dry-run or apply deterministic adapter migrations, including framework pins and goalTracker -> backlogProvider.",
    )
    migrate_adapter_p.add_argument("adapter", type=Path, help="Adapter JSON to migrate.")
    migrate_adapter_p.add_argument("--adopter-target", type=Path, help=f"Write migrated adapter to <repo>/{REPO_LOCAL_ADAPTER_FILE}.")
    migrate_adapter_p.add_argument("--dry-run", action="store_true", help="Preview without writing (default).")
    migrate_adapter_p.add_argument("--write", action="store_true", help="Write the migrated adapter after validation.")
    migrate_adapter_p.set_defaults(func=migrate_adapter)

    migration_report_p = sub.add_parser(
        "release-migration-report",
        help="Generate/check a release migration report with migrations, deprecated surfaces, behavior changes, tested products, and rollback notes.",
    )
    migration_report_p.add_argument("--version", help="Release version; defaults to VERSION.")
    migration_report_p.add_argument("--wip-safe", action="store_true", help="Declare this release safe for patch auto-update during WIP.")
    migration_report_p.add_argument("--product-tested", action="append", help="Product/client/lane tested for this release.")
    migration_report_p.add_argument("--write", action="store_true", help="Write docs/releases/migrations/<version>.json.")
    migration_report_p.add_argument("--check", action="store_true", help="Fail if the committed report is stale.")
    migration_report_p.add_argument("--print", action="store_true", dest="print_report", help="Print the report JSON.")
    migration_report_p.add_argument("--output", type=Path, help="Write/check a path other than the default report.")
    migration_report_p.set_defaults(func=release_migration_report)

    public_contract_p = sub.add_parser(
        "public-contract",
        help="Generate/check the public compatibility contract manifest for commands, adapter keys, generated files, skills, and policy modules.",
    )
    public_contract_p.add_argument("--write", action="store_true", help="Write methodology/public-contract-manifest.json.")
    public_contract_p.add_argument("--check", action="store_true", help="Fail if the committed manifest is stale.")
    public_contract_p.add_argument("--print", action="store_true", dest="print_manifest", help="Print the manifest JSON.")
    public_contract_p.add_argument("--output", type=Path, help="Write/check a path other than the default manifest.")
    public_contract_p.set_defaults(func=public_contract)

    public_release_check_p = sub.add_parser(
        "public-release-check",
        help="Fail if the checkout still contains private adapters, private product references, account IDs, or live adapter hosts.",
    )
    public_release_check_p.add_argument("--json", action="store_true", dest="as_json", help="Emit machine-readable issue JSON.")
    public_release_check_p.add_argument(
        "--private-terms-file",
        type=Path,
        help=f"Read private product/client scan terms from an untracked local file in addition to {PUBLIC_RELEASE_PRIVATE_TERMS_ENV}.",
    )
    public_release_check_p.set_defaults(func=public_release_check)

    public_release_export_p = sub.add_parser(
        "public-release-export",
        help="Create a clean git repository candidate for public release, then run public-release-check against it.",
    )
    public_release_export_p.add_argument("--destination", type=Path, required=True, help="Directory to create as the clean public repo candidate.")
    public_release_export_p.add_argument("--write", action="store_true", help="Create the export repo. Without --write, print the plan only.")
    public_release_export_p.add_argument("--force", action="store_true", help=f"Replace an existing destination only when it contains {PUBLIC_RELEASE_EXPORT_MARKER} from a prior export.")
    public_release_export_p.add_argument(
        "--include-untracked",
        action="store_true",
        help="Include untracked, non-ignored files. Intended for in-progress validation only; public release exports should run from a committed tree.",
    )
    public_release_export_p.add_argument(
        "--include-working-tree",
        action="store_true",
        help="Include dirty tracked working-tree content. Intended for in-progress validation only; public release exports should run from a committed tree.",
    )
    public_release_export_p.add_argument(
        "--allow-empty-private-terms",
        action="store_true",
        help=f"Allow export without {PUBLIC_RELEASE_PRIVATE_TERMS_ENV} or --private-terms-file; use only for repos with no private product/client terms.",
    )
    public_release_export_p.add_argument(
        "--private-terms-file",
        type=Path,
        help=f"Read private product/client scan terms from an untracked local file in addition to {PUBLIC_RELEASE_PRIVATE_TERMS_ENV}.",
    )
    public_release_export_p.set_defaults(func=public_release_export)

    registry_package_p = sub.add_parser(
        "registry-package",
        help=(
            "Generate a registry package tree: the real PyPI package (wrapper + "
            "embedded tree + stamped snapshot manifest) or the npm namespace pointer "
            "(one source of truth for the registry copy)."
        ),
    )
    registry_package_p.add_argument(
        "--registry",
        required=True,
        choices=list(REGISTRY_PACKAGE_REGISTRIES),
        help="Which registry's package tree to generate; the registry determines the payload.",
    )
    registry_package_p.add_argument("--version", required=True, help="Package version (X.Y.Z).")
    registry_package_p.add_argument(
        "--channel",
        default="stable",
        choices=sorted(FRAMEWORK_CHANNELS),
        help=(
            "Release channel stamped into the pypi payload's snapshot manifest "
            "(ignored for npm)."
        ),
    )
    registry_package_p.add_argument(
        "--destination",
        type=Path,
        required=True,
        help="Directory to materialize the package tree into.",
    )
    registry_package_p.add_argument(
        "--write",
        action="store_true",
        help="Write the package tree. Without --write, print the plan only.",
    )
    registry_package_p.set_defaults(func=registry_package)

    release_tail_p = sub.add_parser(
        "release-tail",
        help=(
            "Run the whole release tail: public export, fast-forward mirror commit, "
            "tag, GitHub Release, registry publish, drift check."
        ),
    )
    release_tail_p.add_argument(
        "--dry-run",
        action="store_true",
        help="Print the full plan and mutate nothing. The default posture for an agent.",
    )
    release_tail_p.add_argument(
        "--skip-registry",
        action="store_true",
        help=(
            "One-time bootstrap: push the mirror, tag, and create the Release as a DRAFT "
            "(a draft fires no publish workflow). Rerun without this flag to publish it."
        ),
    )
    release_tail_p.add_argument(
        "--timeout",
        type=int,
        default=1800,
        help="Seconds to wait for the publish workflow runs to reach a terminal state.",
    )
    release_tail_p.add_argument(
        "--poll-interval",
        type=int,
        default=15,
        help="Seconds between publish-run polls.",
    )
    release_tail_p.add_argument(
        "--allow-empty-private-terms",
        action="store_true",
        help="Allow the export gate to run with no private terms configured.",
    )
    release_tail_p.add_argument(
        "--private-terms-file",
        type=Path,
        help="Read private product/client scan terms from an untracked local file.",
    )
    release_tail_p.set_defaults(func=release_tail)

    release_drift_p = sub.add_parser(
        "release-drift-check",
        help="Report the repo, npm, and PyPI versions and fail when any surface has drifted.",
    )
    release_drift_p.add_argument(
        "--json",
        action="store_true",
        dest="as_json",
        help="Emit machine-readable drift JSON.",
    )
    release_drift_p.set_defaults(func=release_drift_check)

    canonical_policy_p = sub.add_parser(
        "canonical-policy",
        help="Generate/check methodology/canonical-rules.md from modular methodology/policy sources.",
    )
    canonical_policy_p.add_argument("--write", action="store_true", help="Write methodology/canonical-rules.md from modules.")
    canonical_policy_p.add_argument("--check", action="store_true", help="Fail if canonical-rules.md is stale against modules.")
    canonical_policy_p.add_argument("--print", action="store_true", dest="print_policy", help="Print assembled canonical policy.")
    canonical_policy_p.add_argument("--output", type=Path, help="Write/check a path other than methodology/canonical-rules.md.")
    canonical_policy_p.set_defaults(func=canonical_policy)

    dump_phrases_p = sub.add_parser(
        "dump-policy-phrases",
        help="Generate methodology/policy-phrases.json (the SSOT projection of the guard phrase vocabulary) from the CLI constants.",
    )
    dump_phrases_p.add_argument("--write", action="store_true", help="Write the file in place.")
    dump_phrases_p.add_argument("--check", action="store_true", help="Fail if the committed file is stale (generated-equals-source).")
    dump_phrases_p.add_argument("--output", type=Path, help="Write to a path other than the default.")
    dump_phrases_p.set_defaults(func=dump_policy_phrases)

    dump_instrumentation_schema_p = sub.add_parser(
        "dump-instrumentation-schema",
        help="Generate methodology/instrumentation-schema.json (tautline-instrumentation/v1) from the in-code field rules.",
    )
    dump_instrumentation_schema_p.add_argument("--write", action="store_true", help="Write the file in place.")
    dump_instrumentation_schema_p.add_argument(
        "--check", action="store_true", help="Fail if the committed file is stale (generated-equals-source)."
    )
    dump_instrumentation_schema_p.add_argument("--print", action="store_true", dest="print_schema", help="Print the generated schema.")
    dump_instrumentation_schema_p.add_argument("--output", type=Path, help="Write/check a path other than the default.")
    dump_instrumentation_schema_p.set_defaults(func=dump_instrumentation_schema)

    cut_release_p = sub.add_parser(
        "cut-release",
        help="Build the release boundary for the current VERSION: checksums manifest + the signed-tag command (no git mutation by default).",
    )
    cut_release_p.add_argument("--dry-run", action="store_true", help="Print the plan without writing the checksums manifest.")
    cut_release_p.add_argument("--create-tag", action="store_true", help="Create the annotated git tag locally (not pushed).")
    cut_release_p.add_argument("--sign", action="store_true", help="Sign the tag (git tag -s) instead of -a.")
    cut_release_p.add_argument("--force", action="store_true", help="Overwrite an existing tag (with --create-tag).")
    cut_release_p.set_defaults(func=cut_release)

    purge_archive_p = sub.add_parser(
        "purge-archive",
        help="Erase one project's/tenant's published artifacts from an archive dir + _index (GDPR/CCPA erasure).",
    )
    purge_archive_p.add_argument("--project", required=True, help="Project/tenant name (slugified) whose artifacts to erase.")
    purge_archive_p.add_argument("--archive-dir", type=Path, help=f"Archive directory to purge from; default: the canonical checkout's {SESSION_JOURNAL_ARCHIVE_SUBPATH}.")
    purge_archive_p.add_argument("--dry-run", action="store_true", help="Preview removals without changing anything.")
    purge_archive_p.set_defaults(func=purge_archive)

    telemetry_p = sub.add_parser(
        "telemetry",
        help="Inspect/clear the opt-in, local-first gate telemetry log (default OFF; never sent anywhere by this CLI).",
    )
    telemetry_p.add_argument("--show", action="store_true", help="Show recorded gate events (default).")
    telemetry_p.add_argument("--clear", action="store_true", help="Delete the local telemetry log.")
    telemetry_p.set_defaults(func=telemetry_command)

    guard_report_p = sub.add_parser("guard-report", help="Summarize lane-local guard-fire telemetry.")
    guard_report_p.add_argument("--target", type=Path, default=Path("."))
    guard_report_p.add_argument("--since", help="Only include guard events at or after this ISO-8601 timestamp.")
    guard_report_p.add_argument("--json", action="store_true", help="Print machine-readable JSON.")
    guard_report_p.set_defaults(func=guard_report)

    name_check_p = sub.add_parser(
        "check-name-availability",
        help="Check npm + PyPI package-name availability for a candidate product name (trademark clearance aid).",
    )
    name_check_p.add_argument("name", help="Candidate package/product name to check.")
    name_check_p.set_defaults(func=check_name_availability)

    pilot_report_p = sub.add_parser(
        "pilot-report",
        help="Aggregate local gate-fire telemetry into the false-block proxy metrics an external-validity pilot needs.",
    )
    pilot_report_p.set_defaults(func=pilot_report)

    init_p = sub.add_parser("init", help="Onboard an unmanaged repo through the bootstrap interview, repo-local adapter, render, and lane-start flow.")
    init_p.add_argument("--target", type=Path, default=Path("."))
    init_p.add_argument("--project-name")
    init_p.add_argument("--repo")
    init_p.add_argument("--continue", dest="continue_init", action="store_true", help="Continue after answering the bootstrap interview artifact.")
    init_p.set_defaults(func=init_methodology_project)

    init_adapter = sub.add_parser("init-project-adapter", help=f"Scaffold a new repo-local project adapter at {REPO_LOCAL_ADAPTER_FILE} and print bootstrap/render/start next steps.")
    init_adapter.add_argument("--target", type=Path, default=Path("."))
    init_adapter.add_argument("--output", type=Path, help="Override the source adapter path; defaults to <target>/.minervit/adapter.json.")
    init_adapter.add_argument("--project-name")
    init_adapter.add_argument("--repo")
    init_adapter.add_argument("--overwrite", action="store_true")
    init_adapter.set_defaults(func=init_project_adapter)

    harness = sub.add_parser("scaffold-test-harness", help="Emit a day-one test harness (runner + CI workflow + smoke + gate-self-proof) so a new repo blocks on tests from commit one (rec #10).")
    harness.add_argument("--target", type=Path, default=Path("."))
    harness.add_argument("--project-slug")
    harness.add_argument("--write", action="store_true", help="Write the files into --target (default prints them).")
    harness.add_argument("--overwrite", action="store_true")
    harness.set_defaults(func=scaffold_test_harness)

    adapter_questions = sub.add_parser("adapter-bootstrap-questions", help="Generate the mandatory adapter bootstrap interview questionnaire (--write saves it + prints sha256).")
    adapter_questions.add_argument("--target", type=Path, default=Path("."))
    adapter_questions.add_argument("--project-name")
    adapter_questions.add_argument("--repo")
    adapter_questions.add_argument("--write", action="store_true")
    adapter_questions.set_defaults(func=adapter_bootstrap_questions)

    audit_p = sub.add_parser("audit", help="Scan target docs for known process-drift patterns and report findings (exit 1 if any found).")
    audit_p.add_argument("--project", type=Path, required=True)
    audit_p.add_argument("--target", type=Path, required=True)
    audit_p.set_defaults(func=audit)

    ready = sub.add_parser("readiness-review", help="Query GitHub for a repo's default branch, readiness source files, and recent workflow runs.")
    ready.add_argument("--project", type=Path)
    ready.add_argument("--repo")
    ready.add_argument("--source", action="append")
    ready.add_argument("--strict", action="store_true", help="Fail closed when the detection baseline (branch protection + canary-on-SHA + errorId alarm) is not met.")
    ready.set_defaults(func=readiness_review)

    bg = sub.add_parser("background-run", help="Launch a command detached with logging, PID/meta tracking, timeout, and a runaway-cost guard.")
    bg.add_argument("--log", type=Path, required=True)
    bg.add_argument("--timeout-seconds", type=int, default=0)
    bg.add_argument("command", nargs=argparse.REMAINDER)
    bg.set_defaults(func=background_run)

    monitor = sub.add_parser("monitor-status", help="Report liveness/staleness of a background run via its log, PID file, and meta.")
    monitor.add_argument("--target", type=Path, default=Path("."))
    monitor.add_argument("--log", type=Path, required=True)
    monitor.add_argument("--pid", type=int)
    monitor.add_argument("--pid-file", type=Path)
    monitor.add_argument("--max-stale-seconds", type=int, default=DEFAULT_MONITOR_STALE_SECONDS)
    monitor.add_argument("--success-marker")
    monitor.add_argument("--failure-marker")
    monitor.add_argument("--strict", action="store_true")
    monitor.set_defaults(func=monitor_status)

    lane = sub.add_parser("lane-start", help="Start a lane: update/lock-check methodology, render generated files, validate bootstrap, set lane state.")
    lane.add_argument("--project", type=Path)
    lane.add_argument("--target", type=Path, default=Path("."))
    lane.add_argument("--skip-update", action="store_true")
    lane.add_argument("--allow-non-main", action="store_true", help="Allow startup from a non-main methodology checkout for intentional methodology development.")
    lane.add_argument(
        "--profile",
        choices=sorted(WORK_PROFILE_CHOICES),
        help="Write an explicit local work-profile lock for this session (default behavior remains development).",
    )
    lane.add_argument(
        "--defer-debt-preflights",
        action="store_true",
        help="(internal) Passed by the generated launcher. Debt-mapped preflight failures (the "
        "latest-code baseline write, the Claude *-hook settings writes, and the autocompact "
        "settings write) degrade to `lane_start_warn:` lines and exit 0 instead of hard-refusing, "
        "deferring the pass/fail decision to methodology-status --fail-on-drift's own "
        "classification of the gate. Structural failures (adapter render, bootstrap-evidence, "
        "development-environment) still hard-refuse. Without this flag lane-start is unchanged.",
    )
    lane.set_defaults(func=lane_start)

    lock = sub.add_parser("lock-methodology", help="Pin the methodology repo at the current commit by writing a lock record (archiving any prior lock).")
    lock.add_argument("--project", type=Path)
    lock.add_argument("--target", type=Path, default=Path("."))
    lock.add_argument("--reason", default="methodology pinned by human operator request")
    lock.set_defaults(func=lock_methodology)

    unlock = sub.add_parser("unlock-methodology", help="Remove the methodology lock by archiving the existing lock file.")
    unlock.add_argument("--project", type=Path)
    unlock.add_argument("--target", type=Path, default=Path("."))
    unlock.set_defaults(func=unlock_methodology)

    status = sub.add_parser("methodology-status", help="Report plugin version, lock state, adapter/plugin-version drift, and lane state paths.")
    status.add_argument("--project", type=Path)
    status.add_argument("--target", type=Path, default=Path("."))
    status.add_argument("--no-remote", action="store_true")
    status.add_argument(
        "--fail-on-drift",
        action="store_true",
        help="Exit 0 clean, 1 integrity (adapter drift or an unsupported runtime; refuses to start), "
        "or 2 debt-only (agent-fixable lane debt). Prints 'methodology_status_blocking: <integrity|debt> "
        "- <gate names>' as the last line whenever the exit code is nonzero. See "
        "docs/reference/startup-remediation.md.",
    )
    status.add_argument(
        "--strict",
        action="store_true",
        help="Escalate every debt gate to exit 1 as well; --strict never returns 2, including combined "
        "with --fail-on-drift.",
    )
    status.add_argument(
        "--enter-remediation-on-debt",
        action="store_true",
        help="(internal) Passed by the generated launcher. On a debt-only --fail-on-drift outcome, "
        "atomically persist the startup-remediation marker before exiting 2 (exit 1 with a synthetic "
        "remediation_marker gate if the marker cannot be written).",
    )
    status.set_defaults(func=methodology_status)

    context_boot = sub.add_parser("context-bootstrap", help="Create/update the document-context index, archive dirs, classification, and headers (--write).")
    context_boot.add_argument("--project", type=Path)
    context_boot.add_argument("--target", type=Path, default=Path("."))
    context_boot.add_argument("--write", action="store_true")
    context_boot.add_argument("--classify", action="store_true")
    context_boot.add_argument("--add-archive-headers", action="store_true")
    context_boot.set_defaults(func=context_bootstrap)

    context_stat = sub.add_parser("context-status", help="Report document-context issues and adapter drift; exit 1 under --strict/--fail-on-drift.")
    context_stat.add_argument("--project", type=Path)
    context_stat.add_argument("--target", type=Path, default=Path("."))
    context_stat.add_argument("--strict", action="store_true")
    context_stat.add_argument("--fail-on-drift", action="store_true")
    context_stat.set_defaults(func=context_status)

    behavior_spec_status_p = sub.add_parser("behavior-spec-status", help="Report behavior-spec issues and effective enforcement; exit 1 if --strict and issues exist.")
    behavior_spec_status_p.add_argument("--project", type=Path)
    behavior_spec_status_p.add_argument("--target", type=Path, default=Path("."))
    behavior_spec_status_p.add_argument("--strict", action="store_true")
    behavior_spec_status_p.set_defaults(func=behavior_spec_status)

    coordination_boot = sub.add_parser("lane-coordination-bootstrap", help="Create lane-coordination contract/board/status-dir artifacts for multi-lane work (--write).")
    coordination_boot.add_argument("--project", type=Path)
    coordination_boot.add_argument("--target", type=Path, default=Path("."))
    coordination_boot.add_argument("--write", action="store_true")
    coordination_boot.set_defaults(func=lane_coordination_bootstrap)

    coordination_status = sub.add_parser("lane-coordination-status", help="Report lane-coordination status/issues; exit 1 under --strict enforcement.")
    coordination_status.add_argument("--project", type=Path)
    coordination_status.add_argument("--target", type=Path, default=Path("."))
    coordination_status.add_argument("--strict", action="store_true")
    coordination_status.set_defaults(func=lane_coordination_status)

    coordination_note = sub.add_parser("lane-coordination-note", help="Write (or dry-run) a per-lane coordination note with goal/branch/deps/blockers/PR fields.")
    coordination_note.add_argument("--project", type=Path)
    coordination_note.add_argument("--target", type=Path, default=Path("."))
    coordination_note.add_argument("--lane")
    coordination_note.add_argument("--branch")
    coordination_note.add_argument("--goal")
    coordination_note.add_argument("--current")
    coordination_note.add_argument("--depends-on", dest="depends_on")
    coordination_note.add_argument("--provides")
    coordination_note.add_argument("--blockers")
    coordination_note.add_argument("--pr")
    coordination_note.add_argument("--write", action="store_true")
    coordination_note.set_defaults(func=lane_coordination_note)

    graphify_status_p = sub.add_parser("graphify-status", help="Ensure graphify output is gitignored and report graphify status; exit 1 under --strict if issues.")
    graphify_status_p.add_argument("--project", type=Path)
    graphify_status_p.add_argument("--target", type=Path, default=Path("."))
    graphify_status_p.add_argument("--strict", action="store_true")
    graphify_status_p.set_defaults(func=graphify_status)

    graphify_install_p = sub.add_parser(
        "graphify-install",
        help="Install Graphify CLI support without letting Graphify overwrite project adapter files.",
    )
    graphify_install_p.add_argument("--project", type=Path)
    graphify_install_p.add_argument("--target", type=Path, default=Path("."))
    graphify_install_p.add_argument("--force", action="store_true", help="Run the install command even if graphify is already on PATH.")
    graphify_install_p.add_argument("--skip-setup", action="store_true", help="Skip the adapter graphify.setupCommand step.")
    graphify_install_p.add_argument("--build", action="store_true", help="Build Graphify output after installing.")
    graphify_install_p.add_argument("--update", action="store_true", help="Use the adapter update command when --build is set.")
    graphify_install_p.set_defaults(func=graphify_install)

    context_rotate = sub.add_parser("context-rotation-check", help="Decide whether to rotate context at a boundary from context percent; print action/urgency/handoff.")
    context_rotate.add_argument("--project", type=Path)
    context_rotate.add_argument("--target", type=Path, default=Path("."))
    context_rotate.add_argument("--boundary", required=True, help="Current safe-boundary candidate, such as pr-queued or milestone-complete.")
    context_rotate.add_argument("--context-percent", type=int, help="Visible host context window percentage, when available.")
    context_rotate.add_argument(
        "--context-percent-source",
        choices=["host", "estimate"],
        default=None,
        help=(
            "Provenance of --context-percent. Use 'host' only when the host literally exposes a "
            "context-window counter; that keeps the measured mandatory-at-hard-threshold behavior. "
            "Use 'estimate' when the percent is an agent or human estimate; an estimate can raise an "
            "advisory rotation but can never be mandatory. If you supply --context-percent without a "
            "source, it is treated as an estimate."
        ),
    )
    context_rotate.set_defaults(func=context_rotation_check)

    remote_main = sub.add_parser("remote-main-status", help="Fetch the remote base branch and report local-vs-remote ahead/behind, dirty state, and path presence.")
    remote_main.add_argument("--target", type=Path, default=Path("."))
    remote_main.add_argument("--remote", default="origin")
    remote_main.add_argument("--base", default="main")
    remote_main.add_argument("--path", action="append", help="Relative path to check on the fetched remote base; repeatable.")
    remote_main.set_defaults(func=remote_main_status)

    latest_code_p = sub.add_parser("latest-code-status", help="Compute/report the latest-code baseline for the target, with recovery and strict drift gating.")
    latest_code_p.add_argument("--project", type=Path)
    latest_code_p.add_argument("--target", type=Path, default=Path("."))
    latest_code_p.add_argument("--write", action="store_true")
    latest_code_p.add_argument("--strict", action="store_true")
    latest_code_p.set_defaults(func=latest_code_status)

    log_event_p = sub.add_parser("log-event", help="Write an observability boundary event (human + JSONL) for the lane, skipping if events disabled.")
    log_event_p.add_argument("--project", type=Path)
    log_event_p.add_argument("--target", type=Path, default=Path("."))
    log_event_p.add_argument("--event", required=True)
    log_event_p.add_argument("--severity", choices=sorted(EVENT_SEVERITIES), required=True)
    log_event_p.add_argument("--plain", required=True)
    log_event_p.add_argument("--next", required=True)
    log_event_p.add_argument("--goal")
    log_event_p.add_argument("--milestone")
    log_event_p.add_argument("--pr")
    log_event_p.add_argument("--ref", action="append", help="Structured reference in key=value form; repeatable.")
    log_event_p.set_defaults(func=log_event)

    event_path_p = sub.add_parser("event-log-path", help="Print the human and JSONL event log paths plus the event-tail command hint.")
    event_path_p.add_argument("--project", type=Path)
    event_path_p.add_argument("--target", type=Path, default=Path("."))
    event_path_p.set_defaults(func=event_log_path)

    event_tail_p = sub.add_parser("event-tail", help="Print the last N lines of the lane's human-readable event log.")
    event_tail_p.add_argument("--project", type=Path)
    event_tail_p.add_argument("--target", type=Path, default=Path("."))
    event_tail_p.add_argument("--lines", type=int, default=80)
    event_tail_p.set_defaults(func=event_tail)

    event_viewer_p = sub.add_parser("event-viewer", help="Serve a localhost web UI to browse and live-refresh the lane's recent events.")
    event_viewer_p.add_argument("--project", type=Path)
    event_viewer_p.add_argument("--target", type=Path, default=Path("."))
    event_viewer_p.add_argument("--host", default="127.0.0.1")
    event_viewer_p.add_argument("--port", type=int, default=EVENT_VIEWER_DEFAULT_PORT)
    event_viewer_p.add_argument("--port-search-limit", type=int, default=20)
    event_viewer_p.add_argument("--lines", type=int, default=250)
    event_viewer_p.add_argument("--since", default="7d")
    event_viewer_p.add_argument("--open", action="store_true")
    event_viewer_p.set_defaults(func=event_viewer)

    event_audit_p = sub.add_parser("event-audit", help="Audit recent events for missing required boundary events; exit non-zero on issues with --strict.")
    event_audit_p.add_argument("--project", type=Path)
    event_audit_p.add_argument("--target", type=Path, default=Path("."))
    event_audit_p.add_argument("--since", default="24h")
    event_audit_p.add_argument("--strict", action="store_true")
    event_audit_p.set_defaults(func=event_audit)

    event_rotate_p = sub.add_parser("event-rotate", help="Rotate the human and JSONL event logs when over size (or always with --force).")
    event_rotate_p.add_argument("--project", type=Path)
    event_rotate_p.add_argument("--target", type=Path, default=Path("."))
    event_rotate_p.add_argument("--force", action="store_true")
    event_rotate_p.set_defaults(func=event_rotate)

    usage_path_p = sub.add_parser("usage-log-path", help="Print the usage JSONL log and rollup paths plus the usage-report command hint.")
    usage_path_p.add_argument("--project", type=Path)
    usage_path_p.add_argument("--target", type=Path, default=Path("."))
    usage_path_p.set_defaults(func=usage_log_path)

    usage_record_p = sub.add_parser("usage-record", help="Append one token/cost usage record for a provider/model/activity to the usage log and rollup.")
    usage_record_p.add_argument("--project", type=Path)
    usage_record_p.add_argument("--target", type=Path, default=Path("."))
    usage_record_p.add_argument("--provider", required=True)
    usage_record_p.add_argument("--model", required=True)
    usage_record_p.add_argument("--activity", required=True)
    usage_record_p.add_argument("--source", default="manual")
    usage_record_p.add_argument("--confidence", choices=sorted(USAGE_CONFIDENCE_VALUES), default="unknown")
    usage_record_p.add_argument("--input-tokens", type=int, default=0)
    usage_record_p.add_argument("--output-tokens", type=int, default=0)
    usage_record_p.add_argument("--cache-creation-input-tokens", type=int, default=0)
    usage_record_p.add_argument("--cache-read-input-tokens", type=int, default=0)
    usage_record_p.add_argument("--total-tokens", type=int)
    usage_record_p.add_argument("--cost-usd")
    usage_record_p.add_argument("--goal")
    usage_record_p.add_argument("--milestone")
    usage_record_p.add_argument("--pr")
    usage_record_p.add_argument("--session-id")
    usage_record_p.add_argument("--request-id")
    usage_record_p.add_argument("--ref", action="append", help="Structured reference in key=value form; repeatable.")
    usage_record_p.set_defaults(func=usage_record)

    usage_import_claude_p = sub.add_parser("usage-import-claude", help="Import token usage from Claude JSONL transcript files, dedupe, and append new records.")
    usage_import_claude_p.add_argument("--project", type=Path)
    usage_import_claude_p.add_argument("--target", type=Path, default=Path("."))
    usage_import_claude_p.add_argument("--path", type=Path, action="append", required=True)
    usage_import_claude_p.add_argument("--since", default="7d")
    usage_import_claude_p.add_argument("--activity", default="claude-session")
    usage_import_claude_p.set_defaults(func=usage_import_claude)

    usage_report_p = sub.add_parser("usage-report", help="Summarize usage records since a window, grouped by product/day/etc., with token and cost totals.")
    usage_report_p.add_argument("--project", type=Path)
    usage_report_p.add_argument("--target", type=Path, default=Path("."))
    usage_report_p.add_argument("--since", default="7d")
    usage_report_p.add_argument(
        "--by",
        choices=["product", "lane", "provider", "model", "activity", "goal", "milestone", "pr", "day"],
        default="product",
    )
    usage_report_p.set_defaults(func=usage_report)

    usage_rotate_p = sub.add_parser("usage-rotate", help="Rotate the usage JSONL log when over size (or always with --force) and rewrite the rollup.")
    usage_rotate_p.add_argument("--project", type=Path)
    usage_rotate_p.add_argument("--target", type=Path, default=Path("."))
    usage_rotate_p.add_argument("--force", action="store_true")
    usage_rotate_p.set_defaults(func=usage_rotate)

    iteration_review_status_p = sub.add_parser("iteration-review-status", help="Print iteration-review config/readiness; block (exit 1 with --strict) on issues like unset webhook.")
    iteration_review_status_p.add_argument("--project", type=Path)
    iteration_review_status_p.add_argument("--target", type=Path, default=Path("."))
    iteration_review_status_p.add_argument("--strict", action="store_true")
    iteration_review_status_p.set_defaults(func=iteration_review_status)

    iteration_review_renderer_setup_p = sub.add_parser("iteration-review-renderer-setup", help="Sync the iteration-review renderer kit into the user-state cache and install its npm dependencies.")
    iteration_review_renderer_setup_p.add_argument("--no-install", action="store_true", help="Only sync renderer-kit source into the user-state cache; do not run npm.")
    iteration_review_renderer_setup_p.add_argument("--timeout-seconds", type=int, default=600, help="Timeout for npm dependency installation.")
    iteration_review_renderer_setup_p.set_defaults(func=setup_iteration_review_renderer)

    validate_iteration_review_p = sub.add_parser("validate-iteration-review", help="Validate an iteration-review record file against the output contract and required fields.")
    validate_iteration_review_p.add_argument("--project", type=Path)
    validate_iteration_review_p.add_argument("--target", type=Path)
    validate_iteration_review_p.add_argument("--file", type=Path, required=True)
    validate_iteration_review_p.set_defaults(func=validate_iteration_review)

    generate_iteration_review_page_p = sub.add_parser("generate-iteration-review-page", help="Render the iteration-review HTML page from a record (print or --write under recordDir).")
    generate_iteration_review_page_p.add_argument("--project", type=Path)
    generate_iteration_review_page_p.add_argument("--target", type=Path, default=Path("."))
    generate_iteration_review_page_p.add_argument("--record", type=Path, required=True)
    generate_iteration_review_page_p.add_argument("--output", type=Path)
    generate_iteration_review_page_p.add_argument("--write", action="store_true")
    generate_iteration_review_page_p.set_defaults(func=generate_iteration_review_page)

    publish_iteration_review_p = sub.add_parser("publish-iteration-review", help="Upload the iteration-review record/page to hosting and post the Google Chat delivery card.")
    publish_iteration_review_p.add_argument("--project", type=Path)
    publish_iteration_review_p.add_argument("--target", type=Path, default=Path("."))
    publish_iteration_review_p.add_argument("--record", type=Path, required=True)
    publish_iteration_review_p.add_argument("--skip-chat", action="store_true")
    publish_iteration_review_p.add_argument("--skip-chat-reason")
    publish_iteration_review_p.add_argument("--force", action="store_true")
    publish_iteration_review_p.add_argument("--repeat-chat-reason")
    publish_iteration_review_p.add_argument("--force-partial-video", action="store_true")
    publish_iteration_review_p.add_argument("--partial-video-reason")
    publish_iteration_review_p.add_argument("--operator-approval-token")
    publish_iteration_review_p.add_argument("--dry-run", action="store_true")
    publish_iteration_review_p.add_argument("--allow-unmerged", action="store_true")
    publish_iteration_review_p.set_defaults(func=publish_iteration_review)

    emit_iteration_review_workflow_p = sub.add_parser("emit-iteration-review-workflow", help="Emit a GitHub Actions workflow that publishes the iteration review after merge (print or --write).")
    emit_iteration_review_workflow_p.add_argument("--project", type=Path)
    emit_iteration_review_workflow_p.add_argument("--target", type=Path, default=Path("."))
    emit_iteration_review_workflow_p.add_argument("--write", action="store_true")
    emit_iteration_review_workflow_p.set_defaults(func=emit_iteration_review_workflow)

    iteration_review_delivery_check_p = sub.add_parser("iteration-review-delivery-check", help="Verify the iteration-review delivery marker matches and required outputs/reasons are satisfied.")
    iteration_review_delivery_check_p.add_argument("--project", type=Path)
    iteration_review_delivery_check_p.add_argument("--target", type=Path, default=Path("."))
    iteration_review_delivery_check_p.add_argument("--record", type=Path, required=True)
    iteration_review_delivery_check_p.set_defaults(func=iteration_review_delivery_check)

    milestone_update_status_p = sub.add_parser("milestone-update-status", help="Print milestone-update config/readiness; block (exit 1 with --strict) on issues.")
    milestone_update_status_p.add_argument("--project", type=Path)
    milestone_update_status_p.add_argument("--target", type=Path, default=Path("."))
    milestone_update_status_p.add_argument("--strict", action="store_true")
    milestone_update_status_p.set_defaults(func=milestone_update_status)

    publish_milestone_update_p = sub.add_parser("publish-milestone-update", help="Validate milestone-update content and post it to Google Chat, deduping via a delivery marker.")
    publish_milestone_update_p.add_argument("--project", type=Path)
    publish_milestone_update_p.add_argument("--target", type=Path, default=Path("."))
    publish_milestone_update_p.add_argument("--milestone", required=True)
    publish_milestone_update_p.add_argument("--stdin", action="store_true")
    publish_milestone_update_p.add_argument("--content-file", type=Path)
    publish_milestone_update_p.add_argument("--summary")
    publish_milestone_update_p.add_argument("--dry-run", action="store_true")
    publish_milestone_update_p.add_argument("--force", action="store_true")
    publish_milestone_update_p.set_defaults(func=publish_milestone_update)

    publish_product_note_p = sub.add_parser("publish-product-note", help="Validate a product note (title + body) and post it to the product Google Chat space.")
    publish_product_note_p.add_argument("--project", type=Path)
    publish_product_note_p.add_argument("--target", type=Path, default=Path("."))
    publish_product_note_p.add_argument("--title", required=True)
    publish_product_note_p.add_argument("--stdin", action="store_true")
    publish_product_note_p.add_argument("--content-file", type=Path)
    publish_product_note_p.add_argument("--summary")
    publish_product_note_p.add_argument("--dry-run", action="store_true")
    publish_product_note_p.set_defaults(func=publish_product_note)

    deploy_health_p = sub.add_parser("deploy-health", help="Check configured deployment workflow run history for failed or stale deploys.")
    deploy_health_p.add_argument("--project", type=Path)
    deploy_health_p.add_argument("--target", type=Path, default=Path("."))
    deploy_health_p.add_argument("--runs-file", type=Path, help="Offline JSON run history fixture for validation/tests.")
    deploy_health_p.add_argument("--strict", action="store_true", help="Compatibility flag; configured deploy issues always return non-zero.")
    deploy_health_p.set_defaults(func=deploy_health)

    deployment_notification_status_p = sub.add_parser("deployment-notification-status", help="Print deployment-notification config/readiness; block (exit 1 with --strict) on issues.")
    deployment_notification_status_p.add_argument("--project", type=Path)
    deployment_notification_status_p.add_argument("--target", type=Path, default=Path("."))
    deployment_notification_status_p.add_argument("--strict", action="store_true")
    deployment_notification_status_p.set_defaults(func=deployment_notification_status)

    deployment_notification_snippet_p = sub.add_parser("deployment-notification-pipeline-snippet", help="Print a CI deploy-pipeline snippet that calls publish-deploy-ready-update after health checks.")
    deployment_notification_snippet_p.add_argument("--project", type=Path)
    deployment_notification_snippet_p.add_argument("--target", type=Path, default=Path("."))
    deployment_notification_snippet_p.add_argument("--environment-var", default="DEPLOY_ENVIRONMENT")
    deployment_notification_snippet_p.add_argument("--url-var", default="DEPLOY_READY_URL")
    deployment_notification_snippet_p.add_argument("--iteration-review-url-var", default="ITERATION_REVIEW_URL")
    deployment_notification_snippet_p.set_defaults(func=deployment_notification_pipeline_snippet)

    deploy_ready_p = sub.add_parser("publish-deploy-ready-update", help="Validate and post a deploy-ready Google Chat update for an environment/URL, deduping via marker.")
    deploy_ready_p.add_argument("--project", type=Path)
    deploy_ready_p.add_argument("--target", type=Path, default=Path("."))
    deploy_ready_p.add_argument("--environment", required=True, help="Deployment environment name, such as dev, staging, or production.")
    deploy_ready_p.add_argument("--url", required=True, help="Live deployed URL ready for review.")
    deploy_ready_p.add_argument("--iteration-review-url", help="Optional published iteration review URL to include in the Chat card.")
    deploy_ready_p.add_argument("--commit", help="Optional deployed commit/ref; defaults to current HEAD short SHA when available.")
    deploy_ready_p.add_argument("--source", choices=["auto", "pipeline", "agent"], default="auto", help="Post provenance. auto records pipeline in common CI environments and agent otherwise.")
    deploy_ready_p.add_argument("--stdin", action="store_true")
    deploy_ready_p.add_argument("--content-file", type=Path)
    deploy_ready_p.add_argument("--summary")
    deploy_ready_p.add_argument("--dry-run", action="store_true")
    deploy_ready_p.add_argument("--force", action="store_true")
    deploy_ready_p.set_defaults(func=publish_deploy_ready_update)

    lane_run_p = sub.add_parser("lane-run", help="Run a command in the lane env (writes lane env file, applies Codex fast-mode), returning its exit code.")
    lane_run_p.add_argument("--project", type=Path)
    lane_run_p.add_argument("--target", type=Path, default=Path("."))
    lane_run_p.add_argument("command", nargs=argparse.REMAINDER)
    lane_run_p.set_defaults(func=lane_run)

    codex_run_p = sub.add_parser("codex-run", help="Run a Codex wrapper command, enforcing the implementation-review round budget for the risk tier.")
    codex_run_p.add_argument("--project", type=Path)
    codex_run_p.add_argument("--target", type=Path, default=Path("."))
    codex_run_p.add_argument(
        "--native-review-note",
        help="Required for T2/T3 or unspecified adapter implementation review: summarize the completed Stage 1 native/Superpowers review on the current assembled diff.",
    )
    codex_run_p.add_argument(
        "--stage1-sweep",
        type=Path,
        help="Required for T2/T3 or unspecified adapter implementation review: path to a record-stage1-sweep artifact for the current assembled diff.",
    )
    codex_run_p.add_argument("--risk-tier", choices=["T0", "T1", "T2", "T3"], help="Optional risk tier used to enforce adapter implementation-review round budgets.")
    codex_run_p.add_argument("--review-round", help="Optional implementation-review round marker such as R1; checked against review.roundBudgets when --risk-tier is set.")
    codex_run_p.add_argument("--allow-extra-rounds", action="store_true", help="Permit another Codex round after two finalized clean rounds (requires --extra-round-reason).")
    codex_run_p.add_argument("--extra-round-reason", help="Reason (>=12 chars) naming the genuine new defect class when using --allow-extra-rounds.")
    codex_run_p.add_argument("--scope-strict", action="store_true", help="Require the review base to be the configured remote base...HEAD (reject stale local bases).")
    codex_run_p.add_argument("command", nargs=argparse.REMAINDER)
    codex_run_p.set_defaults(func=codex_run)

    claude_review_p = sub.add_parser("claude-review", help="Run an evidence-packet Claude Stage 2 review with Claude tool use disabled.")
    claude_review_p.add_argument("--target", type=Path, default=Path("."))
    claude_review_p.add_argument("--packet", type=Path, required=True, help="Review packet containing the exact diff, tests, and context Claude should review.")
    claude_review_p.add_argument("--output", type=Path, help=f"Review artifact path; defaults to {CLAUDE_REVIEW_DIR}/<timestamp>-claude-review.json.")
    claude_review_p.add_argument("--timeout-seconds", type=int, default=180, help="Hard timeout for the Claude CLI transport.")
    claude_review_p.add_argument("--model", help="Optional Claude model override.")
    claude_review_p.add_argument("--instruction", help="Optional extra bounded review instruction appended before the packet.")
    claude_review_p.add_argument("--fail-on-blockers", dest="fail_on_blockers", action="store_true", help="Exit 2 when Claude completes and reports blockers; this is the default.")
    claude_review_p.add_argument("--no-fail-on-blockers", dest="fail_on_blockers", action="store_false", help="Exit 0 for completed blocked reviews when a caller wants evidence capture only.")
    claude_review_p.add_argument("--skip-packet-preflight", action="store_true", help="Skip local packet completeness checks; use only for emergency evidence capture.")
    claude_review_p.set_defaults(fail_on_blockers=True)
    claude_review_p.set_defaults(func=claude_review)

    claude_review_status_p = sub.add_parser("claude-review-status", help="Summarize recent Claude review artifacts and local Claude CLI availability.")
    claude_review_status_p.add_argument("--target", type=Path, default=Path("."))
    claude_review_status_p.add_argument("--review-dir", type=Path, help=f"Review artifact directory; defaults to {CLAUDE_REVIEW_DIR}.")
    claude_review_status_p.add_argument("--limit", type=int, default=10, help="Maximum number of recent review artifacts to summarize.")
    claude_review_status_p.set_defaults(func=claude_review_status)

    stage1_sweep_p = sub.add_parser("record-stage1-sweep", help="Record a Stage 1 implementation-review sweep manifest after enforcing derived-artifact freshness.")
    stage1_sweep_p.add_argument("--project", type=Path)
    stage1_sweep_p.add_argument("--target", type=Path, default=Path("."))
    stage1_sweep_p.add_argument(
        "--native-review-note",
        required=True,
        help="Summary of the completed Stage 1 native/Superpowers review on the current assembled diff.",
    )
    stage1_sweep_p.add_argument(
        "--class",
        dest="sweep_class",
        action="append",
        default=[],
        help="Defect class and members checked, formatted as 'class: members/files/sinks checked'. May be repeated.",
    )
    stage1_sweep_p.add_argument("--classes-json", type=Path, help="JSON array of {class, members_checked, status} entries.")
    stage1_sweep_p.add_argument("--base", help="Override the base ref used for outgoing diff sweep evidence.")
    stage1_sweep_p.add_argument(
        "--derived-artifact-current",
        dest="derived_artifact_current",
        action="append",
        default=[],
        help="Path of a declared derived artifact you regenerated with byte-identical output (narrow "
        "override for when a source change produced no artifact change). May be repeated.",
    )
    stage1_sweep_p.set_defaults(func=record_stage1_sweep)

    review_evidence_p = sub.add_parser("review-evidence-check", help="Verify the outgoing diff has matching tracked Stage 2 review evidence; --strict blocks on failure.")
    review_evidence_p.add_argument("--project", type=Path)
    review_evidence_p.add_argument("--target", type=Path, default=Path("."))
    review_evidence_p.add_argument("--strict", action="store_true")
    review_evidence_p.add_argument("--scope-strict", action="store_true", help="Require the review base to be the configured remote base...HEAD (reject stale local bases).")
    review_evidence_p.add_argument("--base", help="Override the base ref used for outgoing diff review evidence.")
    review_evidence_p.set_defaults(func=review_evidence_check)

    finalize_impl_review_p = sub.add_parser("finalize-implementation-review", help="Record verdict and unresolved Critical/P1 counts into the review manifest; mark push-eligible or blocked.")
    finalize_impl_review_p.add_argument("--project", type=Path)
    finalize_impl_review_p.add_argument("--target", type=Path, default=Path("."))
    finalize_impl_review_p.add_argument("--manifest", type=Path, required=True)
    finalize_impl_review_p.add_argument("--verdict", choices=IMPLEMENTATION_REVIEW_ALL_VERDICTS, required=True)
    finalize_impl_review_p.add_argument("--unresolved-critical-count", type=int, required=True)
    finalize_impl_review_p.add_argument("--unresolved-p1-count", type=int, required=True)
    finalize_impl_review_p.add_argument("--classified-findings-json", type=Path)
    finalize_impl_review_p.add_argument("--base", help="Override the base ref used for outgoing diff review evidence.")
    finalize_impl_review_p.set_defaults(func=finalize_implementation_review)

    branch_live = sub.add_parser("branch-liveness-check", help="Check the current branch's GitHub PRs; block when no active/open PR exists for a non-base branch.")
    branch_live.add_argument("--project", type=Path)
    branch_live.add_argument("--target", type=Path, default=Path("."))
    branch_live.add_argument("--strict", action="store_true", help="Fail closed when the current branch liveness cannot be verified.")
    branch_live.set_defaults(func=branch_liveness_check)

    work_profile_p = sub.add_parser("work-profile-check", help="Classify the current diff for the active work profile; non-dev profiles allow docs/assets only.")
    work_profile_p.add_argument("--project", type=Path)
    work_profile_p.add_argument("--target", type=Path, default=Path("."))
    work_profile_p.add_argument("--event", choices=["pre-commit", "pre-push", "status"], default="status")
    work_profile_p.add_argument("--base", help="Override the base ref used for pre-push diff classification.")
    work_profile_p.set_defaults(func=work_profile_check)

    diff_cov = sub.add_parser("diff-coverage-check", help="Block a change whose NEW lines are exercised below coverageFloor.newCodeMinPct (diff-scoped, rec #7 Part 2).")
    diff_cov.add_argument("--project", type=Path)
    diff_cov.add_argument("--target", type=Path, default=Path("."))
    diff_cov.add_argument("--base", help="Diff base ref (default: adapter coverageFloor.diffBase or origin/main).")
    diff_cov.add_argument("--report", type=Path, help="Coverage report path (coverage.py JSON or LCOV; default: adapter coverageFloor.coverageReport).")
    diff_cov.add_argument("--min-pct", type=int, help="Override the new-code minimum coverage percent.")
    diff_cov.add_argument("--diff-file", type=Path, help="Read the unified diff from this file instead of running git (for tests/CI).")
    diff_cov.set_defaults(func=diff_coverage_check)

    flaky = sub.add_parser("flaky-quarantine-check", help="Block a diff that adds a flaky/skip/xfail marker without owner+due, or exceeds the marker ratchet (rec #14).")
    flaky.add_argument("--project", type=Path)
    flaky.add_argument("--target", type=Path, default=Path("."))
    flaky.add_argument("--base", help="Diff base ref (default: origin/main).")
    flaky.add_argument("--diff-file", type=Path, help="Read the unified diff from this file instead of running git (for tests/CI).")
    flaky.set_defaults(func=flaky_quarantine_check)

    redgreen = sub.add_parser("red-green-check", help="Mutate one symbol of a source file and require the test command to catch it -- proves the tests discriminate (rec #8).")
    redgreen.add_argument("--project", type=Path)
    redgreen.add_argument("--target", type=Path, default=Path("."))
    redgreen.add_argument("--file", type=Path, required=True, help="Source file to mutate (a changed critical-journey source).")
    redgreen.add_argument("--test-command", required=True, help="Command (run via bash -c in --target) that should FAIL when the code breaks.")
    redgreen.add_argument("--strict", action="store_true", help="Fail closed when a mutation survives.")
    redgreen.set_defaults(func=red_green_check)

    cont = sub.add_parser("prepare-continuity", help="Write a continuity handoff file from stdin/--content-file and record a continuity_written event.")
    cont.add_argument("--project", type=Path, required=True)
    cont.add_argument("--target", type=Path, required=True)
    cont.add_argument("--stdin", action="store_true")
    cont.add_argument("--content-file", type=Path)
    cont.set_defaults(func=prepare_continuity)

    journal = sub.add_parser("prepare-session-journal", help="Write and validate a timestamped session-journal markdown file under the lane runs dir.")
    journal.add_argument("--project", type=Path)
    journal.add_argument("--target", type=Path, default=Path("."))
    journal.add_argument("--stdin", action="store_true")
    journal.add_argument("--content-file", type=Path)
    journal.set_defaults(func=prepare_session_journal)

    prepare_instrumentation = sub.add_parser(
        "prepare-instrumentation-record",
        help="Write and validate the current-window sanitized instrumentation preview (single overwritten file) under the lane's .ai-runs/instrumentation/.",
    )
    prepare_instrumentation.add_argument("--project", type=Path)
    prepare_instrumentation.add_argument("--target", type=Path, default=Path("."))
    prepare_instrumentation.set_defaults(func=prepare_instrumentation_record)

    publish_instrumentation = sub.add_parser(
        "publish-instrumentation-record",
        help="Recompute the sanitized instrumentation record from the local event log and publish it to the constant telemetry archive branch via the hardened push path (gated on instrumentation.enabled).",
    )
    publish_instrumentation.add_argument("--project", type=Path)
    publish_instrumentation.add_argument("--target", type=Path, default=Path("."))
    publish_instrumentation.set_defaults(func=publish_instrumentation_record)

    blocker_declare_p = sub.add_parser("blocker-declare", help="Declare a fresh true blocker record for the active lane/goal.")
    blocker_declare_p.add_argument("--target", type=Path, default=Path("."))
    blocker_declare_p.add_argument("--kind", required=True)
    blocker_declare_p.add_argument("--reason", required=True)
    blocker_declare_p.add_argument("--artifact", type=Path)
    blocker_declare_p.add_argument("--checked", help="Comma-separated policy checks required for no-safe-parallel-work blockers.")
    blocker_declare_p.set_defaults(func=blocker_declare)

    blocker_clear_p = sub.add_parser("blocker-clear", help="Clear the lane-local true blocker record.")
    blocker_clear_p.add_argument("--target", type=Path, default=Path("."))
    blocker_clear_p.set_defaults(func=blocker_clear)

    blocker_status_p = sub.add_parser("blocker-status", help="Report the lane-local true blocker record and freshness.")
    blocker_status_p.add_argument("--target", type=Path, default=Path("."))
    blocker_status_p.set_defaults(func=blocker_status)

    guard_check_p = sub.add_parser("guard-check", help="Run runtime-agnostic state guard checks at status, manual, or pre-push boundaries.")
    guard_check_p.add_argument("--project", type=Path)
    guard_check_p.add_argument("--target", type=Path, default=Path("."))
    guard_check_p.add_argument("--boundary", choices=["prepush", "status", "manual"], required=True)
    guard_check_p.set_defaults(func=guard_check)

    goal_start_p = sub.add_parser("goal-start", help="Initialize or refresh the goal run ledger from a goal plan and sync the tracker.")
    goal_start_p.add_argument("--project", type=Path)
    goal_start_p.add_argument("--target", type=Path, default=Path("."))
    goal_start_p.add_argument("--goal", type=Path, required=True)
    goal_start_p.set_defaults(func=goal_start)

    goal_status_p = sub.add_parser("goal-status", help="Print the goal run ledger state and report any goal-tracker drift issues.")
    goal_status_p.add_argument("--project", type=Path)
    goal_status_p.add_argument("--target", type=Path, default=Path("."))
    goal_status_p.set_defaults(func=goal_status)

    goal_advance_p = sub.add_parser("goal-advance", help="Apply a goal/milestone transition event (start/complete/defer/block/goal-complete) to the ledger.")
    goal_advance_p.add_argument("--project", type=Path)
    goal_advance_p.add_argument("--target", type=Path, default=Path("."))
    goal_advance_p.add_argument("--event", choices=sorted(GOAL_ADVANCE_EVENTS), required=True)
    goal_advance_p.add_argument("--milestone-index", type=int)
    goal_advance_p.add_argument("--milestone-plan", type=Path)
    goal_advance_p.add_argument("--milestone-run", type=Path)
    goal_advance_p.add_argument("--pr")
    goal_advance_p.add_argument("--reason")
    goal_advance_p.add_argument("--detail")
    goal_advance_p.add_argument("--iteration-review-record", type=Path)
    goal_advance_p.add_argument("--ui-evidence-manifest", type=Path, help="Playwright UI evidence manifest for configured UI-proof goal events.")
    goal_advance_p.set_defaults(func=goal_advance)

    ui_evidence_submit_p = sub.add_parser("ui-evidence-submit", help="Post a Playwright UI evidence manifest to a tracked GitHub issue/work item.")
    ui_evidence_submit_p.add_argument("--project", type=Path)
    ui_evidence_submit_p.add_argument("--target", type=Path, default=Path("."))
    ui_evidence_submit_p.add_argument("--issue", required=True, help="GitHub issue number/URL or configured Project item reference.")
    ui_evidence_submit_p.add_argument("--manifest", type=Path, help="Playwright UI evidence manifest. Defaults to latest configured manifest.")
    ui_evidence_submit_p.add_argument("--event", choices=sorted(UI_EVIDENCE_EVENTS), default="work-item-complete")
    ui_evidence_submit_p.set_defaults(func=ui_evidence_submit)

    goal_next_p = sub.add_parser("goal-next", help="Recompute and print the goal ledger's next action and percent-complete.")
    goal_next_p.add_argument("--project", type=Path)
    goal_next_p.add_argument("--target", type=Path, default=Path("."))
    goal_next_p.set_defaults(func=goal_next)

    goal_condition_p = sub.add_parser("goal-condition", help="Print the goal completion condition and proof commands for the active goal.")
    goal_condition_p.add_argument("--project", type=Path)
    goal_condition_p.add_argument("--target", type=Path, default=Path("."))
    goal_condition_p.set_defaults(func=goal_condition)

    goal_kickoff_p = sub.add_parser("goal-kickoff-prompt", help="Emit the right goal kickoff prompt (active goal / next candidate / no adapter) for session startup.")
    goal_kickoff_p.add_argument("--target", type=Path, default=Path("."))
    goal_kickoff_p.set_defaults(func=goal_kickoff_prompt)

    goal_tracker_status_p = sub.add_parser("goal-tracker-status", help="Print GitHub Projects goal-tracker config and validate auth/field mapping; block on issues.")
    goal_tracker_status_p.add_argument("--project", type=Path)
    goal_tracker_status_p.add_argument("--target", type=Path, default=Path("."))
    goal_tracker_status_p.set_defaults(func=goal_tracker_status)

    goal_tracker_next_p = sub.add_parser("goal-tracker-next", help="Select and print the next ready GitHub Project goal item within scope; suggest sync.")
    goal_tracker_next_p.add_argument("--project", type=Path)
    goal_tracker_next_p.add_argument("--target", type=Path, default=Path("."))
    goal_tracker_next_p.add_argument("--epic", action="append", help="Restrict selection to this assigned epic (repeatable). Defaults to MINERVIT_LANE_EPICS, else top of the board.")
    goal_tracker_next_p.set_defaults(func=goal_tracker_next)

    goal_tracker_sync_p = sub.add_parser("goal-tracker-sync", help="Render a repo goal plan from a GitHub Project item, optionally writing it with --write.")
    goal_tracker_sync_p.add_argument("--project", type=Path)
    goal_tracker_sync_p.add_argument("--target", type=Path, default=Path("."))
    goal_tracker_sync_p.add_argument("--item", required=True, help="GitHub Project item id, URL, or exact title from the configured project item list.")
    goal_tracker_sync_p.add_argument("--write", action="store_true")
    goal_tracker_sync_p.set_defaults(func=goal_tracker_sync)

    goal_tracker_update_p = sub.add_parser("goal-tracker-update", help="Set a GitHub Project item's status field to an adapter-approved value via gh.")
    goal_tracker_update_p.add_argument("--project", type=Path)
    goal_tracker_update_p.add_argument("--target", type=Path, default=Path("."))
    goal_tracker_update_p.add_argument("--item", required=True, help="GitHub Project item id, URL, or exact title from the configured project item list.")
    goal_tracker_update_p.add_argument("--status", required=True)
    goal_tracker_update_p.add_argument("--verification-evidence", help="Required when moving to a done status: actual verification output to post to the linked issue/PR.")
    goal_tracker_update_p.add_argument("--verification-evidence-file", type=Path, help="Required when moving to a done status unless inline/URL evidence is supplied.")
    goal_tracker_update_p.add_argument("--verification-evidence-url", help="Required when moving to a done status unless inline/file evidence is supplied.")
    goal_tracker_update_p.set_defaults(func=goal_tracker_update)

    backlog_provider_status_p = sub.add_parser("backlog-provider-status", help="Print backlog provider (GitHub Projects) config and validate auth/field mapping; block on issues.")
    backlog_provider_status_p.add_argument("--project", type=Path)
    backlog_provider_status_p.add_argument("--target", type=Path, default=Path("."))
    backlog_provider_status_p.set_defaults(func=backlog_provider_status)

    backlog_provider_board_check_p = sub.add_parser("backlog-provider-board-check", help="(internal) Board-currency gate for pre-push/boundaries; block on board drift, warn on provider-unavailable.")
    backlog_provider_board_check_p.add_argument("--project", type=Path)
    backlog_provider_board_check_p.add_argument("--target", type=Path, default=Path("."))
    backlog_provider_board_check_p.add_argument("--strict", action="store_true", help="Also fail when the board cannot be verified (provider unavailable / unauthenticated).")
    backlog_provider_board_check_p.set_defaults(func=backlog_provider_board_check)

    backlog_provider_active_check_p = sub.add_parser("backlog-provider-active-check", help="(internal) Early active-status gate for current board-backed work; block when active work is not in an active status.")
    backlog_provider_active_check_p.add_argument("--project", type=Path)
    backlog_provider_active_check_p.add_argument("--target", type=Path, default=Path("."))
    backlog_provider_active_check_p.add_argument("--strict", action="store_true", help="Also fail when the board cannot be verified (provider unavailable / unauthenticated).")
    backlog_provider_active_check_p.set_defaults(func=backlog_provider_active_check)

    ci_health_check_p = sub.add_parser("ci-health-check", help="Verify CI actually runs the repo's tests on every PR/push; block when enforcement is block or --strict.")
    ci_health_check_p.add_argument("--project", type=Path)
    ci_health_check_p.add_argument("--target", type=Path, default=Path("."))
    ci_health_check_p.add_argument("--strict", action="store_true", help="Treat a missing CI test gate as blocking even when ciTestGate.enforcement is 'warn'.")
    ci_health_check_p.set_defaults(func=ci_health_check)

    backlog_provider_next_p = sub.add_parser("backlog-provider-next", help="Select and print the next ready GitHub Project item (optionally by type) within scope; suggest sync.")
    backlog_provider_next_p.add_argument("--project", type=Path)
    backlog_provider_next_p.add_argument("--target", type=Path, default=Path("."))
    backlog_provider_next_p.add_argument("--type", dest="item_type", choices=["goal", "milestone", "bug", "task"], default="")
    backlog_provider_next_p.add_argument("--epic", action="append", help="Restrict selection to this assigned epic (repeatable). Defaults to MINERVIT_LANE_EPICS, else top of the board.")
    backlog_provider_next_p.set_defaults(func=backlog_provider_next)

    backlog_provider_sync_p = sub.add_parser("backlog-provider-sync", help="Render a repo source-of-truth plan from a GitHub Project item, optionally writing it with --write.")
    backlog_provider_sync_p.add_argument("--project", type=Path)
    backlog_provider_sync_p.add_argument("--target", type=Path, default=Path("."))
    backlog_provider_sync_p.add_argument("--item", required=True, help="GitHub Project item id, URL, or exact title from the configured project item list.")
    backlog_provider_sync_p.add_argument("--write", action="store_true")
    backlog_provider_sync_p.set_defaults(func=backlog_provider_sync)

    backlog_provider_update_p = sub.add_parser("backlog-provider-update", help="Update a GitHub Project item's status (delegates to goal-tracker-update).")
    backlog_provider_update_p.add_argument("--project", type=Path)
    backlog_provider_update_p.add_argument("--target", type=Path, default=Path("."))
    backlog_provider_update_p.add_argument("--item", required=True, help="GitHub Project item id, URL, or exact title from the configured project item list.")
    backlog_provider_update_p.add_argument("--status", required=True)
    backlog_provider_update_p.add_argument("--verification-evidence", help="Required when moving to a done status: actual verification output to post to the linked issue/PR.")
    backlog_provider_update_p.add_argument("--verification-evidence-file", type=Path, help="Required when moving to a done status unless inline/URL evidence is supplied.")
    backlog_provider_update_p.add_argument("--verification-evidence-url", help="Required when moving to a done status unless inline/file evidence is supplied.")
    backlog_provider_update_p.set_defaults(func=backlog_provider_update)

    backlog_provider_migration_p = sub.add_parser("backlog-provider-migration-interview", help="Generate a migration-interview doc of backlog candidates for human review; only export-marked items ship.")
    backlog_provider_migration_p.add_argument("--project", type=Path)
    backlog_provider_migration_p.add_argument("--target", type=Path, default=Path("."))
    backlog_provider_migration_p.add_argument("--type", dest="item_type", choices=["goal", "milestone", "bug", "task"], default="")
    backlog_provider_migration_p.add_argument("--limit", type=int, default=120)
    backlog_provider_migration_p.add_argument("--write", action="store_true")
    backlog_provider_migration_p.set_defaults(func=backlog_provider_migration_interview)

    backlog_provider_export_p = sub.add_parser("backlog-provider-export", help="Export a customer-facing repo backlog item to the GitHub Projects board, enforcing justification rules.")
    backlog_provider_export_p.add_argument("--project", type=Path)
    backlog_provider_export_p.add_argument("--target", type=Path, default=Path("."))
    backlog_provider_export_p.add_argument("--item-path", required=True, help="Repo-relative Markdown backlog item path approved for export by the migration interview.")
    backlog_provider_export_p.add_argument("--type", dest="item_type", choices=["goal", "milestone", "bug", "task"], required=True)
    backlog_provider_export_p.add_argument("--customer-facing", action="store_true", help="Affirm the item is customer-facing functionality or a customer-impacting bug. Required: the board is exclusively customer-facing — never export tech debt/cleanup/internal work.")
    backlog_provider_export_p.add_argument("--customer-facing-justification", help="Required customer-impact justification when the item trips a technical-debt / non-customer-facing signal.")
    backlog_provider_export_p.add_argument("--write", action="store_true")
    backlog_provider_export_p.add_argument("--draft", action="store_true", help="Create a board-only GitHub Project draft item instead of the default real repository issue.")
    backlog_provider_export_p.set_defaults(func=backlog_provider_export)

    backlog_board_examine_p = sub.add_parser("backlog-board-examine", help="(operator) Read board schema, print profile/assumptions/unknowns, and show proposed adapter diff; never writes.")
    backlog_board_examine_p.add_argument("--project", type=Path)
    backlog_board_examine_p.add_argument("--target", type=Path, default=Path("."))
    backlog_board_examine_p.add_argument("--fields-file", type=Path, help="(internal/offline) read board fields from a JSON file instead of querying gh.")
    backlog_board_examine_p.set_defaults(func=backlog_board_examine)

    backlog_board_adopt_p = sub.add_parser("backlog-board-adopt", help="(operator) Apply board-aligned backlogProvider to the project adapter; requires --apply and all unknowns answered.")
    backlog_board_adopt_p.add_argument("--project", type=Path)
    backlog_board_adopt_p.add_argument("--target", type=Path, default=Path("."))
    backlog_board_adopt_p.add_argument("--fields-file", type=Path, help="(internal/offline) read board fields from a JSON file instead of querying gh.")
    backlog_board_adopt_p.add_argument("--answer", action="append", default=[], metavar="KEY=VALUE", help="Operator answer to a board unknown, e.g. role:Todo=ready; repeatable.")
    backlog_board_adopt_p.add_argument("--apply", action="store_true", help="Write the aligned backlogProvider into the adapter (operator confirmation gate); dry-run without this flag.")
    backlog_board_adopt_p.set_defaults(func=backlog_board_adopt)

    stakeholder_question_ask_p = sub.add_parser("stakeholder-question-ask", help="Post a labeled clarification question to a GitHub issue, tagging the stakeholder, and mark it open.")
    stakeholder_question_ask_p.add_argument("--project", type=Path)
    stakeholder_question_ask_p.add_argument("--target", type=Path, default=Path("."))
    stakeholder_question_ask_p.add_argument("--issue", required=True, help="GitHub issue number or URL.")
    stakeholder_question_ask_p.add_argument("--stakeholder", help="GitHub handle to tag, such as @david-minervit. Defaults to adapter stakeholderQuestions.defaultMention.")
    stakeholder_question_ask_p.add_argument("--id", dest="question_id", help="Stable question id. Defaults to a generated id from issue/question/scope.")
    stakeholder_question_ask_p.add_argument("--question", required=True)
    stakeholder_question_ask_p.add_argument("--why", required=True)
    stakeholder_question_ask_p.add_argument("--needed-for", required=True)
    stakeholder_question_ask_p.set_defaults(func=stakeholder_question_ask)

    stakeholder_question_status_p = sub.add_parser("stakeholder-question-status", help="List/sync open stakeholder questions on issues, counting open vs answered; --strict blocks while waiting.")
    stakeholder_question_status_p.add_argument("--project", type=Path)
    stakeholder_question_status_p.add_argument("--target", type=Path, default=Path("."))
    stakeholder_question_status_p.add_argument("--issue", help="Optional GitHub issue number or URL. Defaults to open issues carrying the configured open label.")
    stakeholder_question_status_p.add_argument("--limit", type=int, default=50)
    stakeholder_question_status_p.add_argument("--sync", action="store_true", help="Record detected stakeholder answers, update labels, and restore Project status when configured.")
    stakeholder_question_status_p.add_argument("--strict", action="store_true", help="Exit non-zero while questions remain open.")
    stakeholder_question_status_p.set_defaults(func=stakeholder_question_status)

    milestone_start_p = sub.add_parser("milestone-start", help="Initialize or refresh the milestone ledger run from a plan and mark it active for the lane.")
    milestone_start_p.add_argument("--project", type=Path)
    milestone_start_p.add_argument("--target", type=Path, default=Path("."))
    milestone_start_p.add_argument("--plan", type=Path, required=True)
    milestone_start_p.set_defaults(func=milestone_start)

    milestone_status_p = sub.add_parser("milestone-status", help="Load and print the current milestone ledger run for the lane.")
    milestone_status_p.add_argument("--project", type=Path)
    milestone_status_p.add_argument("--target", type=Path, default=Path("."))
    milestone_status_p.set_defaults(func=milestone_status)

    milestone_advance_p = sub.add_parser("milestone-advance", help="Record a milestone transition (startup, pr-queued/merged/abandoned, item-complete/blocked) in the ledger.")
    milestone_advance_p.add_argument("--project", type=Path)
    milestone_advance_p.add_argument("--target", type=Path, default=Path("."))
    milestone_advance_p.add_argument("--event", choices=sorted(MILESTONE_ADVANCE_EVENTS), required=True)
    milestone_advance_p.add_argument("--item-index", type=int)
    milestone_advance_p.add_argument("--branch")
    milestone_advance_p.add_argument("--pr")
    milestone_advance_p.add_argument("--reason")
    milestone_advance_p.add_argument("--detail")
    milestone_advance_p.set_defaults(func=milestone_advance)

    milestone_next_p = sub.add_parser("milestone-next", help="Recompute and print the milestone ledger's next action and percent-complete.")
    milestone_next_p.add_argument("--project", type=Path)
    milestone_next_p.add_argument("--target", type=Path, default=Path("."))
    milestone_next_p.set_defaults(func=milestone_next)

    milestone_watchdog_p = sub.add_parser("milestone-watchdog", help="Print recovery-visibility milestone diagnostics (current/blocked/queued items, pending journals).")
    milestone_watchdog_p.add_argument("--project", type=Path)
    milestone_watchdog_p.add_argument("--target", type=Path, default=Path("."))
    milestone_watchdog_p.add_argument("--force", action="store_true")
    milestone_watchdog_p.set_defaults(func=milestone_watchdog)

    loop = sub.add_parser("work-loop", help="Emit the milestone next-action run record, or with --dry-run plan the execution packet's tactical queue.")
    loop.add_argument("--project", type=Path)
    loop.add_argument("--target", type=Path, default=Path("."))
    loop.add_argument("--dry-run", action="store_true")
    loop.add_argument("--plan", type=Path)
    loop.set_defaults(func=work_loop)

    plan_run = sub.add_parser("run-plan-review", help="Run the Codex plan-review wrapper against a source-of-truth plan and write the timestamped review log.")
    plan_run.add_argument("--project", type=Path)
    plan_run.add_argument("--target", type=Path, default=Path("."))
    plan_run.add_argument("--plan", type=Path, required=True)
    plan_run.add_argument("--round", dest="review_round", default="R1")
    plan_run.add_argument("--model", default="unknown")
    plan_run.add_argument("--verdict", choices=PLAN_REVIEW_ALL_VERDICTS)
    plan_run.add_argument("--unresolved-critical-count", type=int)
    plan_run.add_argument("--unresolved-p1-count", type=int)
    plan_run.add_argument("--classified-findings-json")
    plan_run.add_argument(
        "--allow-r3-structural-critical", action="store_true", help=argparse.SUPPRESS
    )
    plan_run.add_argument(
        "--structural-critical-evidence", help="Legacy alias for --exception-note."
    )
    plan_run.add_argument("--exception-note", help=PLAN_REVIEW_EXCEPTION_NOTE_HELP)
    plan_run.add_argument("--no-output-timeout-seconds", type=int, default=PLAN_REVIEW_NO_OUTPUT_TIMEOUT_SECONDS)
    plan_run.add_argument("review_args", nargs=argparse.REMAINDER)
    plan_run.set_defaults(func=run_plan_review)

    codex_plan = sub.add_parser("codex-plan-review", help="Adapter-safe wrapper that binds --plan into a native Codex review prompt.")
    codex_plan.add_argument("--target", type=Path, default=Path("."))
    codex_plan.add_argument("--plan", type=Path)
    codex_plan.add_argument("--base")
    codex_plan.add_argument("--title")
    codex_plan.add_argument("review_args", nargs=argparse.REMAINDER)
    codex_plan.set_defaults(func=codex_plan_review_command)

    plan_finalize = sub.add_parser("finalize-plan-review", help="Classify an existing review log and record the trusted plan-review verdict into the manifest.")
    plan_finalize.add_argument("--project", type=Path)
    plan_finalize.add_argument("--target", type=Path, default=Path("."))
    plan_finalize.add_argument("--plan", type=Path, required=True)
    plan_finalize.add_argument("--log", type=Path, required=True)
    plan_finalize.add_argument("--model", default="unknown")
    plan_finalize.add_argument("--round", dest="review_round", default="R1")
    plan_finalize.add_argument("--verdict", choices=PLAN_REVIEW_ALL_VERDICTS, required=True)
    plan_finalize.add_argument("--unresolved-critical-count", type=int, required=True)
    plan_finalize.add_argument("--unresolved-p1-count", type=int, required=True)
    plan_finalize.add_argument("--classified-findings-json")
    plan_finalize.add_argument(
        "--allow-r3-structural-critical", action="store_true", help=argparse.SUPPRESS
    )
    plan_finalize.add_argument(
        "--structural-critical-evidence", help="Legacy alias for --exception-note."
    )
    plan_finalize.add_argument("--exception-note", help=PLAN_REVIEW_EXCEPTION_NOTE_HELP)
    plan_finalize.set_defaults(func=finalize_plan_review)

    plan_record = sub.add_parser("record-plan-review", help="Validate a Codex review log/findings against the plan and record the cross-model review in the manifest.")
    plan_record.add_argument("--project", type=Path)
    plan_record.add_argument("--target", type=Path, default=Path("."))
    plan_record.add_argument("--plan", type=Path, required=True)
    plan_record.add_argument("--log", type=Path, required=True)
    plan_record.add_argument("--review-command")
    plan_record.add_argument("--reviewer", required=True)
    plan_record.add_argument("--model", default="unknown")
    plan_record.add_argument("--round", dest="review_round", default="R1")
    plan_record.add_argument("--wrapper-exit-code", type=int, required=True)
    plan_record.add_argument("--verdict", choices=PLAN_REVIEW_ALL_VERDICTS, required=True)
    plan_record.add_argument("--unresolved-critical-count", type=int, required=True)
    plan_record.add_argument("--unresolved-p1-count", type=int, required=True)
    plan_record.add_argument("--classified-findings-json")
    plan_record.add_argument("--exception-note", help=PLAN_REVIEW_EXCEPTION_NOTE_HELP)
    plan_record.set_defaults(func=record_plan_review)

    plan_precheck = sub.add_parser("plan-finalization-precheck", help="Check whether a plan passes finalization gates (review manifest, verdict) and print pass/repair pointer.")
    plan_precheck.add_argument("--project", type=Path)
    plan_precheck.add_argument("--target", type=Path, default=Path("."))
    plan_precheck.add_argument("--plan", type=Path, required=True)
    plan_precheck.set_defaults(func=plan_finalization_precheck)

    plan_hook = sub.add_parser("plan-finalization-hook", help="(internal) ExitPlanMode hook: block plan exit unless the source-of-truth plan passes finalization precheck.")
    plan_hook.set_defaults(func=plan_finalization_hook)

    branch_hook = sub.add_parser("branch-liveness-hook", help="(internal) Task-tool hook: block dispatching subagents unless the branch passes strict branch-liveness.")
    branch_hook.set_defaults(func=branch_liveness_hook)

    response_hook = sub.add_parser("response-guard-hook", help="(internal) Stop hook: block the stop when the final message violates response-guard rules.")
    response_hook.set_defaults(func=response_guard_hook)

    background_hook = sub.add_parser("background-command-hook", help="(internal) Bash hook: block fake shell monitor loops and point to background-run/monitor-status.")
    background_hook.set_defaults(func=background_command_hook)

    latest_code_hook_p = sub.add_parser("latest-code-hook", help="(internal) PreToolUse hook: auto-refresh or block tools until the latest-code baseline is fresh.")
    latest_code_hook_p.set_defaults(func=latest_code_hook)

    context_heartbeat_hook = sub.add_parser("context-rotation-heartbeat-hook", help="(internal) PostToolUse hook: inject a periodic context-rotation/compaction heartbeat during goal work.")
    context_heartbeat_hook.set_defaults(func=context_rotation_heartbeat_hook)

    tool_rejection = sub.add_parser("tool-rejection-hook", help="(internal) Hook: on a rejected/denied tool call, inject context forcing the next reply to acknowledge it.")
    tool_rejection.set_defaults(func=tool_rejection_hook)

    hooks = sub.add_parser("install-hooks", help="Install methodology Claude settings hooks (plan-finalization, branch-liveness, response-guard, etc.).")
    hooks.add_argument("--settings", type=Path, default=Path.home() / ".claude" / "settings.json")
    hooks.add_argument("--command", default="tautline plan-finalization-hook")
    hooks.add_argument("--branch-liveness-command", default="tautline branch-liveness-hook")
    hooks.add_argument("--response-guard-command", default="tautline response-guard-hook")
    hooks.add_argument("--tool-rejection-command", default="tautline tool-rejection-hook")
    hooks.add_argument("--background-command-command", default="tautline background-command-hook")
    hooks.add_argument("--latest-code-command", default="tautline latest-code-hook")
    hooks.add_argument("--context-rotation-heartbeat-command", default="tautline context-rotation-heartbeat-hook")
    hooks.add_argument("--target", type=Path, help="Adapter-backed lane path where Git branch-liveness hooks should be installed.")
    hooks.set_defaults(func=install_hooks)

    response = sub.add_parser("response-guard", help="(internal) Scan response text from --stdin/--content-file and exit nonzero if it violates guard rules.")
    response.add_argument("--stdin", action="store_true")
    response.add_argument("--content-file", type=Path)
    response.add_argument("--target", type=Path, default=Path("."))
    response.add_argument("--active-monitor", action="store_true")
    response.add_argument("--autonomous-yield", action="store_true")
    response.add_argument("--rca-request", action="store_true")
    response.add_argument("--after-tool-rejection", action="store_true")
    response.add_argument("--derivable-next-action-prompt", action="store_true")
    response.add_argument("--terminal-stop", action="store_true")
    response.add_argument("--boundary-summary", action="store_true")
    response.add_argument("--human-discussion-request", action="store_true")
    response.add_argument(
        "--phrase-checks",
        choices=sorted(RESPONSE_GUARD_PHRASE_CHECK_MODES),
        default=None,
        help="Override the adapter's responseGuard.phraseChecks resolution for this invocation.",
    )
    response.set_defaults(func=response_guard)

    rca = sub.add_parser("validate-rca-artifact", help="Validate an RCA artifact file and exit nonzero on schema/content errors.")
    rca.add_argument("--file", type=Path, required=True)
    rca.set_defaults(func=validate_rca_artifact)

    feature_request = sub.add_parser("validate-feature-request-artifact", help="Validate a methodology feature-request artifact file and exit nonzero on schema/content errors.")
    feature_request.add_argument("--file", type=Path, required=True)
    feature_request.set_defaults(func=validate_feature_request_artifact)

    grooming = sub.add_parser("validate-grooming", help="Check backlog epic/feature items against the grooming Definition of Ready (read-only board scan).")
    grooming.add_argument("--project", type=Path)
    grooming.add_argument("--target", type=Path, default=Path("."))
    grooming.add_argument("--epic", action="append", help="Epic to check (repeatable); matches the board epicField value. Falls back to MINERVIT_LANE_EPICS.")
    grooming.add_argument("--strict", action="store_true", help="Treat provider-unavailable as a failure (operator/boundary-run verification).")
    grooming.set_defaults(func=validate_grooming)

    validate_journal = sub.add_parser("validate-session-journal", help="Validate a session journal file and exit nonzero on schema/content errors.")
    validate_journal.add_argument("--file", type=Path, required=True)
    validate_journal.set_defaults(func=validate_session_journal)

    validate_instrumentation = sub.add_parser(
        "validate-instrumentation-record",
        help="Validate a tautline-instrumentation/v1 record file and exit nonzero on parse/schema errors.",
    )
    validate_instrumentation.add_argument("--file", type=Path, required=True)
    validate_instrumentation.set_defaults(func=validate_instrumentation_record)

    publish_rca = sub.add_parser("publish-rca-artifact", help="Validate an RCA artifact, copy it into the methodology archive, and stage or commit/push to the RCA branch.")
    publish_rca.add_argument("--file", type=Path, required=True)
    publish_rca.add_argument("--archive-dir", type=Path)
    publish_rca.add_argument("--no-stage", action="store_true", help="Skip git add only; requires --allow-release-checkout-write when not using --commit --push.")
    publish_rca.add_argument("--allow-release-checkout-write", action="store_true", help="Allow local validation/preview writes into the active methodology release checkout. Normal RCA publication must use --commit --push.")
    publish_rca.add_argument("--commit", action="store_true", help="Commit the RCA archive files to the dedicated RCA archive branch; requires --push.")
    publish_rca.add_argument("--push", action="store_true", help="Push the dedicated RCA archive branch after committing; requires --commit.")
    publish_rca.add_argument("--branch", default=DEFAULT_RCA_BRANCH, help=f"Dedicated RCA archive branch to publish to; default: {DEFAULT_RCA_BRANCH}.")
    publish_rca.add_argument("--message", help="Commit message to use with --commit.")
    publish_rca.set_defaults(func=publish_rca_artifact)

    publish_feature_request = sub.add_parser("publish-feature-request-artifact", help="Validate a methodology feature request, copy it into the archive, and stage or commit/push to the feature-request branch.")
    publish_feature_request.add_argument("--file", type=Path, required=True)
    publish_feature_request.add_argument("--archive-dir", type=Path)
    publish_feature_request.add_argument("--no-stage", action="store_true", help="Skip git add only; requires --allow-release-checkout-write when not using --commit --push.")
    publish_feature_request.add_argument("--allow-release-checkout-write", action="store_true", help="Allow local validation/preview writes into the active methodology release checkout. Normal feature-request publication must use --commit --push.")
    publish_feature_request.add_argument("--commit", action="store_true", help="Commit the feature-request archive files to the dedicated feature-request archive branch; requires --push.")
    publish_feature_request.add_argument("--push", action="store_true", help="Push the dedicated feature-request archive branch after committing; requires --commit.")
    publish_feature_request.add_argument("--branch", default=DEFAULT_FEATURE_REQUEST_BRANCH, help=f"Dedicated feature-request archive branch to publish to; default: {DEFAULT_FEATURE_REQUEST_BRANCH}.")
    publish_feature_request.add_argument("--message", help="Commit message to use with --commit.")
    publish_feature_request.set_defaults(func=publish_feature_request_artifact)

    _publish_journal_help = (
        "DEPRECATED/DISABLED (0.9.0, removal >=1.0.0): narrative session journals can no longer be "
        "published to any remote in any mode; they stay LOCAL evidence (prepare-session-journal + "
        "validate-session-journal). Run publish-instrumentation-record for sanitized upstream signal."
    )
    publish_journal = sub.add_parser(
        "publish-session-journal",
        help=_publish_journal_help,
        description=_publish_journal_help,
    )
    # Flags remain PARSEABLE so a muscle-memory invocation gets the migration message instead of an
    # unknown-option error; the command refuses in every mode (see NARRATIVE_JOURNAL_PUBLISH_REFUSAL).
    publish_journal.add_argument("--file", type=Path, required=True)
    publish_journal.add_argument("--archive-dir", type=Path)
    publish_journal.add_argument("--no-stage", action="store_true", help="No effect: publication is disabled; journals stay local.")
    publish_journal.add_argument("--allow-release-checkout-write", action="store_true", help="No effect: publication is disabled. Local preview/validation lives in prepare/validate-session-journal under the lane's gitignored .ai-runs/, never a git worktree.")
    publish_journal.add_argument("--commit", action="store_true", help="No effect: publication is disabled.")
    publish_journal.add_argument("--push", action="store_true", help="No effect: publication is disabled.")
    publish_journal.add_argument("--branch", default=DEFAULT_SESSION_JOURNAL_BRANCH, help="No effect: publication is disabled.")
    publish_journal.add_argument("--message", help="No effect: publication is disabled.")
    publish_journal.set_defaults(func=publish_session_journal)

    _publish_pending_help = (
        "DEPRECATED/DISABLED (0.9.0, removal >=1.0.0): pending narrative journals stay LOCAL; there "
        "is no remote publication path. Run publish-instrumentation-record for sanitized upstream signal."
    )
    publish_pending_journals = sub.add_parser(
        "publish-pending-session-journals",
        help=_publish_pending_help,
        description=_publish_pending_help,
    )
    publish_pending_journals.add_argument("--project", type=Path)
    publish_pending_journals.add_argument("--target", type=Path, default=Path("."))
    publish_pending_journals.add_argument("--archive-dir", type=Path)
    publish_pending_journals.add_argument("--branch")
    publish_pending_journals.set_defaults(func=publish_pending_session_journals)

    raw_argv = list(sys.argv[1:] if argv is None else argv)
    if raw_argv and raw_argv[0] == "codex-plan-review":
        args, unknown_args = parser.parse_known_args(raw_argv)
        if unknown_args:
            args.review_args = [*unknown_args, *(getattr(args, "review_args", None) or [])]
    else:
        args = parser.parse_args(raw_argv)
    if getattr(args, "write", False) and getattr(args, "check", False):
        raise SystemExit("--write and --check are mutually exclusive")
    if getattr(args, "command", None) and args.command[0] == "--":
        args.command = args.command[1:]
    return dispatch_command(args)


if __name__ == "__main__":
    raise SystemExit(main())
