#!/usr/bin/env python3
"""
product-hook — Session governance for Prawduct products.

Enforces two structural gates:
1. Reflection: files modified -> reflection must be captured before session end
2. Critic review: code built against a plan -> independent review must happen

Also triggers framework sync at session start so products pick up template
updates when a new session begins.

Usage (in .claude/settings.json):
  python3 "$CLAUDE_PROJECT_DIR/tools/product-hook" clear   (SessionStart hook)
  python3 "$CLAUDE_PROJECT_DIR/tools/product-hook" stop    (Stop hook)
"""

from __future__ import annotations

import json
import os
import re
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path


def get_project_dir() -> Path:
    """Get the project directory from CLAUDE_PROJECT_DIR or cwd."""
    return Path(os.environ.get("CLAUDE_PROJECT_DIR", ".")).resolve()


def get_prawduct_dir(project_dir: Path) -> Path:
    return project_dir / ".prawduct"


# --- Active build-plan resolution (inline mirror of tools/lib/core.py) ---
# product-hook is standalone in product repos and cannot import tools/lib, so
# the resolver is duplicated here. Kept in sync with
# core.resolve_build_plan_path / core.read_str_yaml_key by a parity test
# (same discipline as the GITIGNORE_ENTRIES / _SESSION_GITIGNORED_PATHS mirror).
_BUILD_PLAN_POINTER_KEY = "active_build_plan"
_DEFAULT_BUILD_PLAN_REL = "artifacts/build-plan.md"


def _read_str_yaml_key(state_path: Path, key: str) -> str | None:
    """Value of a top-level (column-0) ``key: value`` scalar, or None."""
    try:
        content = state_path.read_text(encoding="utf-8")
    except OSError:
        return None
    needle = f"{key}:"
    for raw in content.splitlines():
        if raw[:1] in (" ", "\t"):
            continue
        line = raw.split("#", 1)[0].rstrip()
        if not line.startswith(needle):
            continue
        value = line.split(":", 1)[1].strip().strip("\"'")
        return value or None
    return None


def _resolve_build_plan_path(prawduct_dir: Path) -> Path:
    """Resolve the active build plan via the ``active_build_plan:`` pointer
    (relative to ``.prawduct/``); fall back to ``artifacts/build-plan.md`` when
    unset, so repos without the pointer are unchanged. May not exist — callers
    treat a missing plan as no active build plan."""
    pointer = _read_str_yaml_key(prawduct_dir / "project-state.yaml", _BUILD_PLAN_POINTER_KEY)
    if pointer:
        return prawduct_dir / pointer
    return prawduct_dir / _DEFAULT_BUILD_PLAN_REL


# =============================================================================
# Sync trigger (best-effort on session start)
# =============================================================================


def try_sync(
    project_dir: Path, *, quiet: bool = False
) -> tuple[dict | None, list[dict], dict | None]:
    """Attempt to run framework sync. Silently does nothing on any failure.

    When quiet=False (default), prints sync actions for Claude's context.
    When quiet=True, runs silently.

    Returns a tuple of (upgrade_info, advisories, freshness):
    - upgrade_info: dict with 'previous_version' and 'new_version' if version changed, else None
    - advisories: list of template drift advisory dicts from sync
    - freshness: dict of framework freshness facts for the briefing, or None
      when not derivable (no manifest, etc.). See _compute_framework_freshness.
    """
    try:
        manifest_path = project_dir / ".prawduct" / "sync-manifest.json"

        if manifest_path.is_file():
            manifest = json.loads(manifest_path.read_text())
            fw_source = os.environ.get("PRAWDUCT_FRAMEWORK_DIR") or manifest.get("framework_source", "")
        elif (project_dir / ".prawduct").is_dir():
            # Prawduct repo without manifest — sync will bootstrap one
            fw_source = os.environ.get("PRAWDUCT_FRAMEWORK_DIR") or ""
        else:
            return None, [], None  # Not a prawduct repo

        # Fall back to sibling ../prawduct if configured source not found
        if not fw_source or not Path(fw_source).is_dir():
            sibling = (project_dir.parent / "prawduct").resolve()
            if sibling.is_dir():
                fw_source = str(sibling)

        if not fw_source:
            return None, [], None

        sync_script = Path(fw_source) / "tools" / "prawduct-setup.py"
        if not sync_script.is_file():
            # Backward compat: fall back to old script name
            sync_script = Path(fw_source) / "tools" / "prawduct-sync.py"
        if not sync_script.is_file():
            return None, [], None

        result = subprocess.run(
            [sys.executable, str(sync_script), "sync", str(project_dir), "--framework-dir", fw_source, "--json"],
            capture_output=True,
            text=True,
            timeout=30,
        )

        upgrade_info = None
        advisories: list[dict] = []
        if result.returncode == 0 and result.stdout.strip():
            try:
                sync_result = json.loads(result.stdout)
                actions = sync_result.get("actions", [])
                notes = sync_result.get("notes", [])
                advisories = sync_result.get("advisories", [])

                # Check for version upgrade
                version = sync_result.get("version", {})
                prev = version.get("previous_version")
                new = version.get("new_version")
                if prev and new and prev != new:
                    upgrade_info = {"previous_version": prev, "new_version": new}
                    if not quiet:
                        print(f"═══ Prawduct upgraded: v{prev} → v{new} ═══")
                        print("  (banner will show new version next session)")

                if actions and not quiet:
                    non_bootstrap = [a for a in actions if "Bootstrapped" not in a]
                    is_bootstrap = len(non_bootstrap) != len(actions)

                    # Collapse the per-file action dump to a single count line —
                    # a routine sync (or the tools/lib self-heal, which touches
                    # ~14 files) should not wallpaper the briefing with "+ New: …"
                    # lines. The manifest records exactly what changed; re-read
                    # prompts and gitignore fixes still surface as notes below.
                    if is_bootstrap:
                        print("PRAWDUCT SYNC: first framework sync for this repo")
                    if non_bootstrap:
                        n = len(non_bootstrap)
                        if upgrade_info or is_bootstrap:
                            print(f"  ({n} framework file(s) updated)")
                        else:
                            print(f"PRAWDUCT SYNC: {n} framework file(s) updated")
                    # Notes carry the genuinely actionable bits (re-read X,
                    # un-ignored a path to git add) — keep them.
                    for note in notes:
                        print(f"  * {note}")
            except json.JSONDecodeError:
                pass
        # Check if the framework is stale relative to this product's last sync
        _check_framework_version(project_dir, fw_source, quiet)
        # Compute freshness AFTER sync so the manifest reflects any just-applied updates
        freshness = _compute_framework_freshness(project_dir, fw_source)
        return upgrade_info, advisories, freshness

    except Exception as e:  # prawduct:ok-broad-except — sync must never block session start
        print(f"NOTE: Framework sync failed: {e}", file=sys.stderr)
        return None, [], None


def _compute_framework_freshness(project_dir: Path, fw_source: str) -> dict | None:
    """Compute structured framework freshness facts for the session briefing.

    Returns a dict with the fields below, or None if no sync-manifest exists.
    Individual fields may be None when their source is unavailable (framework
    dir not a git repo, manifest predates a schema addition, etc.):

    - framework_head: short SHA of fw_source HEAD, or None if not git
    - framework_head_date: YYYY-MM-DD of HEAD commit, or None
    - framework_version: contents of fw_source/VERSION, or None
    - last_sync_date: YYYY-MM-DD parsed from manifest.last_sync, or None
    - last_sync_version: manifest.framework_version, or None
    - last_sync_commit: manifest.framework_commit (recorded at sync time;
      not present in manifests written before that field was added — those
      will populate on next sync)
    - commits_behind: int — commits in fw_source ahead of last_sync_commit,
      or None when either commit is unavailable

    The three drift dimensions (version, commit, template content) are
    independent. Callers should not synthesize them into a single answer.
    """
    manifest_path = project_dir / ".prawduct" / "sync-manifest.json"
    if not manifest_path.is_file():
        return None
    try:
        manifest = json.loads(manifest_path.read_text())
    except (json.JSONDecodeError, OSError):
        return None

    out: dict[str, object] = {
        "framework_head": None,
        "framework_head_date": None,
        "framework_version": None,
        "last_sync_date": None,
        "last_sync_version": manifest.get("framework_version") or None,
        "last_sync_commit": manifest.get("framework_commit") or None,
        "commits_behind": None,
    }

    last_sync = manifest.get("last_sync", "")
    if last_sync:
        out["last_sync_date"] = last_sync.split("T", 1)[0]

    fw_dir = Path(fw_source) if fw_source else None
    if not fw_dir or not fw_dir.is_dir():
        return out

    version_file = fw_dir / "VERSION"
    if version_file.is_file():
        try:
            out["framework_version"] = version_file.read_text().strip() or None
        except OSError:
            pass

    try:
        result = subprocess.run(
            ["git", "-C", str(fw_dir), "log", "-1", "--format=%h|%ai", "HEAD"],
            capture_output=True, text=True, timeout=10,
        )
        if result.returncode == 0 and result.stdout.strip():
            parts = result.stdout.strip().split("|", 1)
            if len(parts) == 2:
                out["framework_head"] = parts[0]
                out["framework_head_date"] = parts[1].split(" ", 1)[0]
    except Exception:  # prawduct:ok-broad-except — best-effort git lookup
        pass

    if out["last_sync_commit"] and out["framework_head"]:
        try:
            result = subprocess.run(
                ["git", "-C", str(fw_dir), "rev-list", "--count",
                 f"{out['last_sync_commit']}..HEAD"],
                capture_output=True, text=True, timeout=10,
            )
            if result.returncode == 0:
                count = result.stdout.strip()
                if count.isdigit():
                    out["commits_behind"] = int(count)
        except Exception:  # prawduct:ok-broad-except — best-effort
            pass

    return out


def _check_framework_version(project_dir: Path, fw_source: str, quiet: bool) -> None:
    """Warn if the product's sync lags (or leads) the framework's VERSION.

    Two directions to catch:
    - Framework older than last sync: ../prawduct is stale (not pulled) while
      the product received updated files via its own git pull.
    - Framework newer than last sync: auto-sync at session start did not take
      effect (sync failed, framework unreachable, or manifest not updated), so
      the product's product-hook and templates are running old code despite
      the framework being ahead. This is the "stale hook" safety net.
    """
    try:
        fw_version_file = Path(fw_source) / "VERSION"
        if not fw_version_file.is_file():
            return

        manifest_path = project_dir / ".prawduct" / "sync-manifest.json"
        if not manifest_path.is_file():
            return

        manifest = json.loads(manifest_path.read_text())
        synced_version = manifest.get("framework_version", "")
        if not synced_version:
            return  # Old manifest without version tracking

        fw_version = fw_version_file.read_text().strip()
        if fw_version == synced_version:
            return  # Framework matches last sync — all good

        # Versions differ — framework may be stale or newer
        # Compare as tuples for semantic versioning
        def _parse_version(v: str) -> tuple[int, ...]:
            parts = []
            for p in v.split("."):
                try:
                    parts.append(int(p))
                except ValueError:
                    parts.append(0)
            return tuple(parts)

        fw_parsed = _parse_version(fw_version)
        synced_parsed = _parse_version(synced_version)

        if fw_parsed < synced_parsed and not quiet:
            # Framework checkout is behind its own history — git pull is the fix,
            # not sync. One line; the version pair says the rest.
            print(
                f"NOTE: framework checkout at {fw_source} is v{fw_version}, behind this "
                f"product's last sync (v{synced_version}) — git pull the framework repo."
            )
        elif fw_parsed > synced_parsed and not quiet:
            # Framework is ahead but the session-start auto-sync didn't land
            # (failed/timed out/unreachable) — otherwise versions would match.
            # One line, not a wall; this fires only on genuine sync failure now.
            print(
                f"NOTE: framework is v{fw_version} but this product synced at "
                f"v{synced_version} — session-start sync didn't apply; run prawduct-setup.py sync."
            )
    except Exception:  # prawduct:ok-broad-except — version check must never block session start
        pass


# =============================================================================
# Git helpers
# =============================================================================


def git_status_output(project_dir: Path) -> str | None:
    """Return raw `git status --porcelain` output, or None on failure."""
    try:
        result = subprocess.run(
            ["git", "status", "--porcelain"],
            capture_output=True,
            text=True,
            cwd=str(project_dir),
            timeout=10,
        )
        if result.returncode != 0:
            return None
        return result.stdout
    except Exception:  # prawduct:ok-broad-except — git failure must not crash hook
        return None


def git_has_changes(project_dir: Path) -> str:
    """Check if there are uncommitted changes. Returns first changed file or empty string."""
    output = git_status_output(project_dir)
    if output is None:
        return ""
    lines = output.strip().splitlines()
    return lines[0] if lines else ""


# Paths that are framework/session metadata — changes to these should
# never trigger reflection or Critic gates.
_METADATA_PREFIXES = (
    ".prawduct/",
    ".claude/settings.json",
    ".claude/skills/",
    "tools/product-hook",
)


def _is_metadata_path(filepath: str) -> bool:
    """Check if a file path is framework/session metadata (not user code)."""
    return any(filepath.startswith(p) for p in _METADATA_PREFIXES)


def git_has_session_changes(project_dir: Path) -> str:
    """Check if non-metadata uncommitted changes differ from session baseline.

    Compares current git status to the baseline captured at session start.
    Ignores .prawduct/ metadata and framework-managed files.
    Returns first new changed file or empty string. Falls back to
    git_has_changes if no baseline exists (backward compat).
    """
    prawduct_dir = get_prawduct_dir(project_dir)
    baseline_path = prawduct_dir / ".session-git-baseline"

    if not baseline_path.is_file():
        return git_has_changes(project_dir)

    current = git_status_output(project_dir)
    if current is None:
        return ""

    try:
        baseline = baseline_path.read_text()
    except (UnicodeDecodeError, OSError):
        return ""  # Corrupted baseline — treat as no baseline (safe: permits session end)
    baseline_lines = set(baseline.strip().splitlines())
    current_lines = current.strip().splitlines()

    for line in current_lines:
        if line not in baseline_lines:
            # Extract file path from porcelain format (e.g., " M src/app.py")
            parts = line.split()
            if parts:
                filepath = parts[-1]
                if not _is_metadata_path(filepath):
                    return line

    return ""


def _session_changes_are_doc_only(project_dir: Path) -> bool:
    """Check if all non-metadata session changes are documentation (.md) files.

    Returns True if changes exist but are all .md files — used to skip
    the reflection gate for doc-only edits.
    """
    prawduct_dir = get_prawduct_dir(project_dir)
    baseline_path = prawduct_dir / ".session-git-baseline"

    current = git_status_output(project_dir)
    if current is None:
        return False

    baseline_lines: set[str] = set()
    if baseline_path.is_file():
        try:
            baseline_lines = set(baseline_path.read_text().strip().splitlines())
        except (UnicodeDecodeError, OSError):
            pass

    has_any = False
    for line in current.strip().splitlines():
        if line in baseline_lines:
            continue
        parts = line.split()
        if not parts:
            continue
        filepath = parts[-1]
        if _is_metadata_path(filepath):
            continue
        has_any = True
        if not filepath.endswith(".md"):
            return False

    return has_any


def git_has_code_changes(project_dir: Path) -> bool:
    """Check if non-metadata files were modified since session baseline.

    Mirrors git_has_session_changes() baseline-diff logic but returns a bool.
    Skips files that match the session baseline (pre-existing dirt) and
    framework metadata (.prawduct/, .claude/settings.json, etc.).
    """
    return bool(git_has_session_changes(project_dir))


def _is_framework_tooling(f: Path, project_dir: Path) -> bool:
    """True if ``f`` is Prawduct framework infrastructure shipped into a product
    repo (``tools/product-hook`` or ``tools/lib/*``), not the product's own code.

    Used by the "has product code?" heuristic so that sync-delivered tooling
    doesn't make a freshly-initialized empty repo look like it contains source.
    Deliberately NOT used by the compliance canary's source detection: in the
    framework repo itself these paths ARE the source under test.
    """
    try:
        rel = f.relative_to(project_dir).as_posix()
    except ValueError:
        return False
    return rel == "tools/product-hook" or rel.startswith("tools/lib/")


# Canonical list of session files that should be gitignored, never tracked.
# Mirrors GITIGNORE_ENTRIES in tools/lib/core.py — kept duplicated here because
# product-hook is intentionally standalone and ships into product repos that
# do not have access to tools/lib. Keep these two lists in sync; the test
# tests/test_coverage_gaps.py::TestProductHookGitignoreMirror asserts equality.
_SESSION_GITIGNORED_PATHS = (
    ".claude/settings.local.json",
    ".prawduct/.critic-findings.json",
    ".prawduct/.test-evidence.json",
    ".prawduct/.pr-reviews",
    ".prawduct/.session-git-baseline",
    ".prawduct/.session-handoff.md",
    ".prawduct/.session-reflected",
    ".prawduct/.session-start",
    ".prawduct/.subagent-briefing.md",
    ".prawduct/.gates-waived",
    ".prawduct/.sync-pending",
    ".prawduct/.advisories.json",
    ".prawduct/reflections.md",
    ".prawduct/sync-manifest.json",
    ".prawduct/artifacts/build-plan.md",
)


def _read_advisory_store(prawduct_dir: Path) -> dict:
    """Read `.prawduct/.advisories.json` (the post-sync nag log).

    Standalone read — product-hook ships into product repos without access to
    tools/lib, so it cannot import advisory_store. Missing/unreadable/malformed
    → empty store; never raises (briefing must not break on a bad file).
    """
    path = prawduct_dir / ".advisories.json"
    if not path.is_file():
        return {"schema_version": 1, "advisories": []}
    try:
        data = json.loads(path.read_text())
    except (OSError, json.JSONDecodeError):
        return {"schema_version": 1, "advisories": []}
    if not isinstance(data, dict) or not isinstance(data.get("advisories"), list):
        return {"schema_version": 1, "advisories": []}
    return data


def _untrack_session_files(project_dir: Path) -> list[str]:
    """Defensively `git rm --cached` any session files that are currently tracked.

    Some product repos accidentally committed session files (e.g. .session-handoff.md)
    before they were added to .gitignore. Once tracked, those files cause noisy
    merge conflicts whenever any session writes new content. The framework sync
    command also runs untrack_gitignored_files(), but that only fires when the
    user runs sync. This in-process check runs on every session start so the
    cleanup happens in any product repo regardless of sync version.

    Returns a list of paths that were untracked (may be empty). Never raises.
    """
    untracked: list[str] = []
    try:
        check = subprocess.run(
            ["git", "rev-parse", "--git-dir"],
            capture_output=True,
            text=True,
            cwd=str(project_dir),
            timeout=10,
        )
        if check.returncode != 0:
            return untracked
    except Exception:  # prawduct:ok-broad-except — git failure must not crash hook
        return untracked

    for path in _SESSION_GITIGNORED_PATHS:
        try:
            ls = subprocess.run(
                ["git", "ls-files", "--error-unmatch", path],
                capture_output=True,
                text=True,
                cwd=str(project_dir),
                timeout=10,
            )
            if ls.returncode != 0:
                continue  # Not tracked — nothing to do
            rm = subprocess.run(
                ["git", "rm", "--cached", "--quiet", "-r", path],
                capture_output=True,
                text=True,
                cwd=str(project_dir),
                timeout=10,
            )
            if rm.returncode == 0:
                untracked.append(path)
        except Exception:  # prawduct:ok-broad-except — single-file failure must not abort the loop
            continue

    return untracked


def _git_head_sha(project_dir: Path) -> str:
    """Return current HEAD SHA, or empty string on failure."""
    try:
        result = subprocess.run(
            ["git", "rev-parse", "HEAD"],
            capture_output=True,
            text=True,
            cwd=str(project_dir),
            timeout=10,
        )
        if result.returncode == 0:
            return result.stdout.strip()
    except Exception:  # prawduct:ok-broad-except — git failure must not crash hook
        pass
    return ""


def tests_are_current(project_dir: Path) -> tuple[bool, str]:
    """Decide whether saved test evidence is fresh enough to trust.

    Uses a "trust the cycle" model: evidence is current if it was written
    during this session (timestamp >= session start) and all tests passed.
    No tree-hashing or content fingerprinting (those mechanisms were
    removed pre-v1.4 after chronic false positives from metadata churn) —
    the build cycle (write code → run tests → Critic reviews) is the
    trust boundary.

    Falls back to timestamp-only comparison when no session-start marker
    exists (e.g., running outside a governed session).

    Returns (is_current, reason). reason is a short human-readable string suitable
    for printing back to the agent.
    """
    prawduct_dir = get_prawduct_dir(project_dir)
    evidence_path = prawduct_dir / ".test-evidence.json"
    if not evidence_path.is_file():
        return False, "no .test-evidence.json on disk"

    try:
        evidence = json.loads(evidence_path.read_text())
    except (json.JSONDecodeError, OSError) as exc:
        return False, f"unreadable evidence ({exc})"

    if not isinstance(evidence, dict):
        return False, "evidence is not a JSON object"

    # Schema check — catches writer typos like ``ran_at`` for ``timestamp`` or
    # ``num_passed`` for ``passed``. Without this, missing fields silently fall
    # through ``.get()`` calls and the evidence parses as "no failures, no
    # timestamp" which the freshness check below would reject for the wrong
    # reason. Loud failure makes the writer bug obvious.
    schema_ok, schema_err = _validate_evidence_schema(evidence)
    if not schema_ok:
        return False, schema_err

    # Test pass/fail check — fail counts make evidence stale regardless of timing.
    failed = evidence.get("failed")
    if isinstance(failed, int) and failed > 0:
        return False, f"{failed} test(s) failing in saved evidence"

    # Timestamp check — evidence must have been written during this session.
    evidence_ts = evidence.get("timestamp")
    if not isinstance(evidence_ts, str) or not evidence_ts:
        return False, "no timestamp in evidence"

    session_start_path = prawduct_dir / ".session-start"
    if session_start_path.is_file():
        try:
            session_start = session_start_path.read_text().strip()
        except OSError:
            session_start = ""
        if session_start and evidence_ts >= session_start:
            return True, f"evidence from this session ({evidence_ts})"
        if session_start:
            return False, f"evidence predates session ({evidence_ts} < {session_start})"

    # No session-start marker — fall back to recency check.
    # Evidence exists with passing tests and a timestamp, but we can't verify
    # it's from this session. Accept it with a note.
    return True, f"evidence has passing tests ({evidence_ts}, no session marker to verify)"


_EVIDENCE_REQUIRED_FIELDS: dict[str, tuple[type, ...]] = {
    "timestamp": (str,),
    "passed": (int,),
    "failed": (int,),
    "skipped": (int,),
    "duration_seconds": (int, float),
    "command": (str,),
}

# v1.4 F4a — coverage-evidence fields. Presence of ``verifier`` is the
# discriminator: legacy evidence without ``verifier`` validates exactly as
# before (compat). When ``verifier`` IS present, the writer has opted into
# the new schema and the other three fields become conditionally required —
# so writer typos (``coverage`` vs. ``coverage_level``, ``tests_ran`` vs.
# ``tests_executed``) fail loud instead of silently dropping coverage data.
_EVIDENCE_COVERAGE_FIELDS: dict[str, tuple[type, ...]] = {
    "verifier": (str,),
    "tests_executed": (list,),
    "changes_referenced": (list,),
    "coverage_level": (str,),
}
_EVIDENCE_COVERAGE_LEVELS = frozenset({"referenced", "executed"})


# Critic mode values persisted in .prawduct/.critic-findings.json's `mode` field.
# Verbose strings are intentional: the JSON is read by humans during session
# briefings and gate WARNINGs, so the value itself communicates the implication.
# The bare short tokens ("chunk" / "final") that callers pass via $ARGUMENTS
# are NOT accepted in the persisted form — `validate_critic_findings` rejects them
# so writer drift surfaces immediately.
_CRITIC_MODE_CHUNK = "chunk (lighter pass, not ready for push)"
_CRITIC_MODE_FINAL = "final (full review, ready for push)"
_CRITIC_MODE_CUMULATIVE = "cumulative (bundle review, ready for merge)"
# v1.5 Chunk 02 — verify-resolutions: delta review against prior findings'
# scope. Cheaper than chunk/final for the common case of "Critic flagged 1-2
# BLOCKING findings, builder fixed them, re-review pays full latency to
# confirm the fix." Scope = prior files_reviewed ∪ files changed since
# `commit_reviewed` (Chunk 01's anchor). Demotes to /critic final when scope
# widens past `len(delta) > 2 * len(prior) + 5` or when prior findings lack
# the anchor.
_CRITIC_MODE_VERIFY_RESOLUTIONS = (
    "verify-resolutions (delta review, prior findings only)"
)
_CRITIC_MODE_VALUES = frozenset({
    _CRITIC_MODE_CHUNK,
    _CRITIC_MODE_FINAL,
    _CRITIC_MODE_CUMULATIVE,
    _CRITIC_MODE_VERIFY_RESOLUTIONS,
})


def _validate_evidence_schema(evidence: dict) -> tuple[bool, str]:
    """Reject ``.test-evidence.json`` with missing or wrong-typed fields.

    Catches writer typos that would otherwise silently parse via ``.get()``.
    Examples this catches: ``ran_at`` instead of ``timestamp``; ``num_passed``
    instead of ``passed``; passed/failed/skipped emitted as strings.

    v1.4 F4a: when ``verifier`` is present, the writer has opted into the
    coverage-evidence schema and ``tests_executed`` / ``changes_referenced``
    / ``coverage_level`` are also required. Legacy evidence (pre-F4a shape:
    no ``verifier`` field — historically called "fingerprint" though the
    tree-hash mechanism it referred to was removed pre-v1.4) is accepted
    unchanged; v1.5 will drop the compat path (see Chunk 10's migration
    NOTE — `prawduct-setup migrate --enable-coverage` surfaces the
    deprecation).

    Returns ``(True, "")`` on success, ``(False, reason)`` on failure. Missing
    fields are reported before wrong-typed fields, so a single fix-it pass
    addresses the highest-priority violations first.
    """
    required = dict(_EVIDENCE_REQUIRED_FIELDS)
    if "verifier" in evidence:
        required.update(_EVIDENCE_COVERAGE_FIELDS)

    missing: list[str] = []
    wrong_type: list[str] = []
    for field, allowed_types in required.items():
        if field not in evidence:
            missing.append(field)
            continue
        if not isinstance(evidence[field], allowed_types):
            wrong_type.append(
                f"{field} must be {' | '.join(t.__name__ for t in allowed_types)}, "
                f"got {type(evidence[field]).__name__}"
            )
    if missing:
        return False, f"evidence missing required field(s): {', '.join(sorted(missing))}"
    if wrong_type:
        return False, f"evidence schema violation: {'; '.join(wrong_type)}"

    # Enum check for coverage_level — only when the coverage schema is in use
    # AND the field's type is already known good (str). Bad-type errors are
    # surfaced by the loop above; here we just refuse out-of-set values.
    if "verifier" in evidence:
        level = evidence.get("coverage_level")
        if isinstance(level, str) and level not in _EVIDENCE_COVERAGE_LEVELS:
            allowed = ", ".join(sorted(_EVIDENCE_COVERAGE_LEVELS))
            return False, (
                f"evidence schema violation: coverage_level must be one of "
                f"{{{allowed}}}, got {level!r}"
            )

    return True, ""


def _read_gates_waived(prawduct_dir: Path) -> dict[str, str]:
    """Load .gates-waived JSON. Returns {} if missing or invalid.

    Format: {"critic": "reason", "pr": "reason", "reflection": "reason"}.
    Each present key signals "this gate does not apply to the current work."
    The agent writes this file when work is genuinely N/A for that gate
    (e.g., docs-only refactor, no PR planned for this branch). The file
    is auto-deleted at session start so waivers never carry across sessions.
    """
    waiver_path = prawduct_dir / ".gates-waived"
    if not waiver_path.is_file():
        return {}
    try:
        data = json.loads(waiver_path.read_text())
    except (json.JSONDecodeError, OSError):
        return {}
    if not isinstance(data, dict):
        return {}
    # A waiver requires a non-empty *string* reason. Empty strings, missing
    # reasons, and non-string values (booleans, numbers, nested objects) are
    # all rejected. The reason is required so reviewers can audit *why* the
    # gate was bypassed; an "implicit truthy" form would be a silent escape
    # hatch and exactly the kind of pattern the project's learnings warn
    # against.
    out: dict[str, str] = {}
    for key, val in data.items():
        if not isinstance(key, str):
            continue
        if isinstance(val, str) and val.strip():
            out[key] = val.strip()
    return out


# =============================================================================
# Staleness Scan (v5: content-based artifact freshness)
# =============================================================================


def _extract_dependency_names(dep_file: Path) -> list[str]:
    """Extract package names from dependency files."""
    try:
        content = dep_file.read_text()
    except Exception:  # prawduct:ok-broad-except — dependency scanning is best-effort
        return []

    name = dep_file.name

    if name == "requirements.txt":
        deps = []
        for line in content.splitlines():
            line = line.strip()
            if not line or line.startswith("#") or line.startswith("-"):
                continue
            match = re.match(r"([a-zA-Z0-9][a-zA-Z0-9._-]*)", line)
            if match:
                deps.append(match.group(1).lower())
        return deps

    if name == "package.json":
        try:
            data = json.loads(content)
            deps = list(data.get("dependencies", {}).keys())
            deps += list(data.get("devDependencies", {}).keys())
            return deps
        except (json.JSONDecodeError, AttributeError):
            return []

    return []


def staleness_scan(project_dir: Path) -> list[str]:
    """Lightweight content-based staleness checks. Returns list of warnings."""
    prawduct_dir = get_prawduct_dir(project_dir)
    findings: list[str] = []

    state_path = prawduct_dir / "project-state.yaml"
    if not state_path.is_file():
        return findings

    try:
        state_content = state_path.read_text()
    except Exception:  # prawduct:ok-broad-except — staleness scan is best-effort
        return findings

    # 1. Architecture coverage (test_count is now computed, not tracked)
    source_root_match = re.search(r"source_root:\s*[\"']?([^\"'\n#]+)", state_content)
    source_root = None
    if source_root_match:
        val = source_root_match.group(1).strip().strip("\"'")
        if val and val != "null":
            source_root = val

    arch_path = prawduct_dir / "artifacts" / "architecture.md"
    if source_root and arch_path.is_file():
        try:
            arch_content = arch_path.read_text()
            src_dir = project_dir / source_root
            if src_dir.is_dir():
                unmentioned = []
                for d in sorted(src_dir.iterdir()):
                    if d.is_dir() and not d.name.startswith((".", "__")):
                        if d.name not in arch_content:
                            unmentioned.append(d.name)
                if unmentioned:
                    findings.append(
                        f"architecture: {', '.join(unmentioned[:3])} in {source_root}/ not in architecture.md"
                    )
        except Exception:  # prawduct:ok-broad-except — staleness scan is best-effort
            pass

    # 3. Dependency coverage
    dep_manifest = prawduct_dir / "artifacts" / "dependency-manifest.md"
    if dep_manifest.is_file():
        try:
            manifest_content = dep_manifest.read_text().lower()
            for dep_file_name in ["requirements.txt", "package.json"]:
                dep_file = project_dir / dep_file_name
                if dep_file.is_file():
                    dep_names = _extract_dependency_names(dep_file)
                    missing = [d for d in dep_names if d.lower() not in manifest_content]
                    if missing:
                        preview = ", ".join(missing[:3])
                        more = f" +{len(missing) - 3} more" if len(missing) > 3 else ""
                        findings.append(
                            f"dependencies: {len(missing)} in {dep_file_name} not in manifest ({preview}{more})"
                        )
        except Exception:  # prawduct:ok-broad-except — staleness scan is best-effort
            pass

    # 4. Stale build plan detection — check Status section first, fall back to WIP
    build_plan_path = _resolve_build_plan_path(prawduct_dir)
    build_plan_label = f".prawduct/{build_plan_path.relative_to(prawduct_dir).as_posix()}"
    if build_plan_path.is_file():
        try:
            status = _parse_build_plan_status(prawduct_dir)
            if status.get("current_chunk"):
                pass  # Active work — not stale
            elif status.get("_has_status_items"):
                # All items checked — work complete
                findings.append(
                    f"build plan: {build_plan_label} has all chunks complete — "
                    "if work is done, delete the plan"
                )
            else:
                # No Status items — check WIP as fallback for old-style repos
                current_branch = _get_current_branch(project_dir)
                wip = _parse_wip(prawduct_dir, branch=current_branch)
                if not wip.get("description"):
                    findings.append(
                        f"build plan: {build_plan_label} exists but no active work — "
                        "if work is complete, delete the plan"
                    )
        except Exception:  # prawduct:ok-broad-except — staleness scan is best-effort
            pass

    # 5. Completed but uncleaned build plan in state
    if not build_plan_path.is_file():
        try:
            if "\n  strategy:" in state_content:
                strategy_match = re.search(r"\n  strategy:\s*(.+)", state_content)
                if strategy_match:
                    strategy_val = strategy_match.group(1).strip().strip("\"'")
                    if strategy_val and strategy_val != "null":
                        if not _has_build_plan_in_state(prawduct_dir):
                            findings.append(
                                "build plan: project-state.yaml has a completed build plan "
                                "(strategy set, no active chunks) — consider resetting to defaults"
                            )
        except Exception:  # prawduct:ok-broad-except — staleness scan is best-effort
            pass

    return findings


# =============================================================================
# Session Briefing (v5: structured context for SessionStart)
# =============================================================================


def _get_product_name(prawduct_dir: Path) -> str:
    """Extract product name from project-state.yaml."""
    state_path = prawduct_dir / "project-state.yaml"
    if not state_path.is_file():
        return "Unknown"
    try:
        content = state_path.read_text()
        in_identity = False
        for line in content.splitlines():
            if "product_identity:" in line:
                in_identity = True
            elif in_identity and line.strip().startswith("name:"):
                val = line.split(":", 1)[1].strip().strip("\"'")
                if val and val != "null" and not val.startswith("{{"):
                    return val
                break
            elif in_identity and not line.startswith(" ") and line.strip():
                break
    except Exception:  # prawduct:ok-broad-except — product name extraction is best-effort
        pass
    return "Unknown"


def _get_current_branch(project_dir: Path) -> str:
    """Get current git branch name. Returns 'main' on failure."""
    try:
        result = subprocess.run(
            ["git", "branch", "--show-current"],
            capture_output=True, text=True, cwd=str(project_dir), timeout=10,
        )
        if result.returncode == 0 and result.stdout.strip():
            return result.stdout.strip()
    except Exception:  # prawduct:ok-broad-except — branch detection is best-effort
        pass
    return "main"


def _parse_wip(prawduct_dir: Path, branch: str | None = None) -> dict[str, str]:
    """Parse work_in_progress fields from project-state.yaml.

    Supports two formats:
    - Branch-scoped (v6+): fields nested under branch name key
    - Flat (legacy): fields directly under work_in_progress

    When branch is None, auto-detects from git. Returns a dict of non-null
    field values, or empty dict on any failure.
    """
    state_path = prawduct_dir / "project-state.yaml"
    if not state_path.is_file():
        return {}
    try:
        if branch is None:
            branch = _get_current_branch(prawduct_dir.parent)

        content = state_path.read_text()
        in_wip = False
        in_branch = False
        is_flat = False
        wip: dict[str, str] = {}

        for line in content.splitlines():
            stripped = line.strip()

            # Find work_in_progress section
            if stripped.startswith("work_in_progress:"):
                in_wip = True
                continue

            if not in_wip:
                continue

            # Exit WIP section on unindented non-empty line
            if line and not line[0].isspace() and stripped:
                break

            # Detect format: 2-space indent with known field = flat; otherwise branch-keyed
            if not in_branch and not is_flat and line.startswith("  ") and not line.startswith("    "):
                key_part = stripped.split(":")[0].strip()
                if key_part in ("description", "size", "type", "current_chunk", "governance_level", "context"):
                    # Flat format (legacy) — parse directly
                    is_flat = True

            if is_flat:
                # Flat format: 2-space indent = field
                if line.startswith("  ") and ":" in line:
                    key, _, val = stripped.partition(":")
                    val = val.strip().strip("\"'")
                    if val and val != "null":
                        wip[key.strip()] = val
                continue

            # Branch-keyed format: look for our branch at 2-space indent
            if line.startswith("  ") and not line.startswith("    "):
                # This is a branch key line like "  feature/foo:" or "  main:"
                branch_key = stripped.rstrip(":").strip()
                if branch_key == branch:
                    in_branch = True
                elif in_branch:
                    # We were in our branch, hit another branch — done
                    break
            elif in_branch and line.startswith("    ") and ":" in line:
                # 4-space indent = field under our branch
                key, _, val = stripped.partition(":")
                val = val.strip().strip("\"'")
                if val and val != "null":
                    wip[key.strip()] = val

        return wip
    except Exception:  # prawduct:ok-broad-except — WIP extraction is best-effort
        return {}


def _parse_all_wip_branches(prawduct_dir: Path) -> dict[str, dict[str, str]]:
    """Parse all branch WIP entries. Returns {branch: {field: value}} dict.

    For flat format, returns {"_flat": {fields}}.
    """
    state_path = prawduct_dir / "project-state.yaml"
    if not state_path.is_file():
        return {}
    try:
        content = state_path.read_text()
        in_wip = False
        branches: dict[str, dict[str, str]] = {}
        current_branch: str | None = None

        for line in content.splitlines():
            stripped = line.strip()

            if stripped.startswith("work_in_progress:"):
                in_wip = True
                continue

            if not in_wip:
                continue

            if line and not line[0].isspace() and stripped:
                break

            # 2-space indent, not 4-space = branch key (or flat field)
            if line.startswith("  ") and not line.startswith("    "):
                key_part = stripped.split(":")[0].strip()
                if key_part in ("description", "size", "type", "current_chunk", "governance_level", "context"):
                    # Flat format
                    if "_flat" not in branches:
                        branches["_flat"] = {}
                    val_part = stripped.partition(":")[2].strip().strip("\"'")
                    if val_part and val_part != "null":
                        branches["_flat"][key_part] = val_part
                else:
                    # Branch key
                    current_branch = stripped.rstrip(":").strip()
                    if current_branch not in branches:
                        branches[current_branch] = {}
            elif current_branch and line.startswith("    ") and ":" in line:
                key, _, val = stripped.partition(":")
                val = val.strip().strip("\"'")
                if val and val != "null":
                    branches[current_branch][key.strip()] = val

        return branches
    except Exception:  # prawduct:ok-broad-except — WIP extraction is best-effort
        return {}


def _parse_build_plan_status(prawduct_dir: Path) -> dict[str, str]:
    """Parse work context from build-plan.md Status section.

    Returns dict with keys matching _parse_wip output:
    description, size, type, current_chunk, context, governance_level.
    Returns empty dict if no build plan or no Status section.
    """
    plan_path = _resolve_build_plan_path(prawduct_dir)
    if not plan_path.is_file():
        return {}
    try:
        content = plan_path.read_text()
        result: dict[str, str] = {}

        # Extract title from header: "# Build Plan — Title (date)"
        for line in content.splitlines():
            if line.startswith("# Build Plan"):
                title = line.lstrip("# ").removeprefix("Build Plan").strip()
                if title.startswith("—") or title.startswith("-"):
                    title = title.lstrip("—- ").strip()
                # Remove trailing date in parens
                if title.endswith(")") and "(" in title:
                    title = title[:title.rfind("(")].strip()
                if title:
                    result["description"] = title
                break

        # Extract Size and Type from metadata line: **Size**: X | **Type**: Y
        for line in content.splitlines():
            if "**Size**:" in line:
                for segment in line.split("|"):
                    segment = segment.strip()
                    if segment.startswith("**Size**:"):
                        result["size"] = segment.removeprefix("**Size**:").strip()
                    elif segment.startswith("**Type**:"):
                        result["type"] = segment.removeprefix("**Type**:").strip()
                    elif segment.startswith("**Governance**:"):
                        result["governance_level"] = segment.removeprefix("**Governance**:").strip()
                break

        # Parse Status section for current chunk and context
        in_status = False
        in_comment = False
        has_any_items = False
        for line in content.splitlines():
            stripped = line.strip()
            if stripped == "## Status":
                in_status = True
                continue
            if not in_status:
                continue
            # Exit on next section
            if stripped.startswith("## ") and stripped != "## Status":
                break
            # Track HTML comments (multi-line)
            if "<!--" in stripped:
                in_comment = True
            if "-->" in stripped:
                in_comment = False
                continue
            if in_comment:
                continue
            # Current chunk = first unchecked item
            if stripped.startswith("- [ ]") and "current_chunk" not in result:
                has_any_items = True
                result["current_chunk"] = stripped[5:].strip()
            elif stripped.startswith("- [x]") or stripped.startswith("- [X]"):
                has_any_items = True
            # Context line
            if stripped.startswith("Context:"):
                result["context"] = stripped.removeprefix("Context:").strip()

        # Mark whether Status section had items (for staleness detection)
        if has_any_items:
            result["_has_status_items"] = "true"

        return result
    except Exception:  # prawduct:ok-broad-except — build plan parsing is best-effort
        return {}


def _has_active_build_plan_file(prawduct_dir: Path) -> bool:
    """Return True if build-plan.md has at least one incomplete chunk.

    A completed plan (all [x]) or a missing file both return False — only an
    in-progress plan with remaining work triggers governance gates.
    """
    status = _parse_build_plan_status(prawduct_dir)
    return bool(status.get("current_chunk"))


def _get_active_work(prawduct_dir: Path) -> dict[str, str]:
    """Get active work context, preferring build plan Status over project-state.yaml WIP."""
    work = _parse_build_plan_status(prawduct_dir)
    if work.get("description"):
        return work
    return _parse_wip(prawduct_dir)


def _get_work_in_progress(prawduct_dir: Path) -> str:
    """Format work in progress as a one-line summary for the session briefing."""
    wip = _get_active_work(prawduct_dir)
    if wip.get("description"):
        parts = [wip["description"]]
        qualifiers = []
        if wip.get("size"):
            qualifiers.append(wip["size"])
        if wip.get("type"):
            qualifiers.append(wip["type"])
        if qualifiers:
            parts.append(f"({', '.join(qualifiers)})")
        return " ".join(parts)
    return "none active"


def _detect_worktrees(project_dir: Path) -> list[dict[str, str]]:
    """Return a list of git worktrees attached to this repo, or [] if not in a repo
    or only one worktree exists.

    Each entry: {"path": str, "branch": str, "is_active": "true"/"false"}.
    "is_active" is "true" for the worktree at project_dir.
    """
    try:
        result = subprocess.run(
            ["git", "worktree", "list", "--porcelain"],
            capture_output=True,
            text=True,
            cwd=str(project_dir),
            timeout=10,
        )
        if result.returncode != 0:
            return []
    except Exception:  # prawduct:ok-broad-except — worktree detection is best-effort
        return []

    # Porcelain output: groups of "worktree <path>", "HEAD <sha>", "branch refs/heads/<name>"
    # (or "detached"), separated by blank lines.
    worktrees: list[dict[str, str]] = []
    current: dict[str, str] = {}
    for line in result.stdout.splitlines():
        if not line.strip():
            if current:
                worktrees.append(current)
                current = {}
            continue
        if line.startswith("worktree "):
            current["path"] = line.removeprefix("worktree ").strip()
        elif line.startswith("branch "):
            current["branch"] = line.removeprefix("branch ").removeprefix("refs/heads/").strip()
        elif line == "detached":
            current["branch"] = "(detached)"
    if current:
        worktrees.append(current)

    if len(worktrees) <= 1:
        return []  # Single worktree = no need to surface; degenerate case.

    project_resolved = str(project_dir.resolve())
    for w in worktrees:
        wpath = w.get("path", "")
        try:
            w["is_active"] = "true" if Path(wpath).resolve() == Path(project_resolved) else "false"
        except OSError:
            w["is_active"] = "false"
    return worktrees


def _get_other_branch_wip(prawduct_dir: Path, current_branch: str) -> list[str]:
    """Get one-line summaries of WIP on other branches."""
    all_wip = _parse_all_wip_branches(prawduct_dir)
    others = []
    for branch, fields in all_wip.items():
        if branch == current_branch or branch == "_flat":
            continue
        desc = fields.get("description", "")
        if desc:
            others.append(f"{branch}: {desc}")
    return others


def assemble_session_briefing(
    project_dir: Path,
    staleness: list[str],
    *,
    upgrade_info: dict | None = None,
    advisories: list[dict] | None = None,
    freshness: dict | None = None,
) -> str:
    """Assemble session briefing text. Target: <400 tokens (excluding handoff pointer)."""
    prawduct_dir = get_prawduct_dir(project_dir)
    lines = ["== SESSION BRIEFING =="]

    # Framework version upgrade notice (prominent, near top)
    if upgrade_info:
        prev = upgrade_info.get("previous_version", "?")
        new = upgrade_info.get("new_version", "?")
        lines.append(f"Framework: upgraded v{prev} → v{new} this session")

    # Project identity + work in progress (branch-scoped)
    project_name = _get_product_name(prawduct_dir)
    current_branch = _get_current_branch(project_dir)
    work_desc = _get_work_in_progress(prawduct_dir)
    lines.append(f"Project: {project_name} | Branch: {current_branch} | Work: {work_desc}")

    # Work context and current chunk (prefer build plan Status, fall back to WIP)
    wip = _get_active_work(prawduct_dir)
    if wip.get("current_chunk"):
        lines.append(f"Resume: {wip['current_chunk']}")
    if wip.get("context"):
        ctx = wip["context"]
        if len(ctx) > 200:
            ctx = ctx[:197] + "..."
        lines.append(f"Context: {ctx}")

    # Other branches with active WIP
    other_wip = _get_other_branch_wip(prawduct_dir, current_branch)
    if other_wip:
        lines.append(f"Other active branches: {len(other_wip)}")
        for owip in other_wip[:3]:  # Cap at 3 to keep briefing concise
            lines.append(f"  - {owip}")

    # Worktree awareness — surface only when more than one worktree exists.
    # Hooks operate on $CLAUDE_PROJECT_DIR; if the agent thinks they are in a
    # different worktree, gates will look at the wrong tree. Surfacing this
    # avoids silent confusion (see issue: discodon worktree gate firing on
    # main repo branch state).
    worktrees = _detect_worktrees(project_dir)
    if worktrees:
        active = next((w for w in worktrees if w.get("is_active") == "true"), None)
        active_branch = active.get("branch", "?") if active else "?"
        active_path = active.get("path", str(project_dir)) if active else str(project_dir)
        lines.append(
            f"Worktrees: {len(worktrees)} attached — hook is operating on '{active_branch}' "
            f"at {active_path}. Other worktrees are NOT visible to gates this session."
        )
        for w in worktrees:
            if w.get("is_active") == "true":
                continue
            lines.append(f"  - {w.get('branch', '?')} @ {w.get('path', '?')}")

    # Handoff from previous session
    handoff_path = prawduct_dir / ".session-handoff.md"
    if handoff_path.is_file():
        lines.append("Previous session context available: read .prawduct/.session-handoff.md")

    # Staleness warnings
    if staleness:
        for s in staleness:
            lines.append(f"Stale: {s}")

    # Framework freshness — one line, only on REAL drift, and only for the
    # commit delta. Version drift is owned by _check_framework_version (printed
    # during sync with the correct git-pull-vs-sync remedy) so the two never say
    # the same thing twice. Raw SHAs/dates are no longer dumped here — they live
    # in the sync manifest. After Chunk B an up-to-date repo keeps last_sync /
    # framework_commit current, so commits_behind is 0 and this stays silent.
    has_commit_delta = bool(freshness and freshness.get("commits_behind"))
    if has_commit_delta:
        behind = freshness.get("commits_behind")
        lines.append(f"Framework drift: {behind} commit(s) since last sync — run a framework sync")
    if advisories:
        # Template-drift advisories are fire-once (Chunk B), so this surfaces at
        # most once per template change. One line naming the files, not a table.
        names = ", ".join(adv.get("file", "?").rsplit("/", 1)[-1] for adv in advisories)
        lines.append(f"Template update(s) since setup: {names} — /janitor scope=templates to review")

    # ADVISORIES (post-sync) — v1.6.0 Phase 1. The per-clone nag log written by
    # sync's probe step (`.prawduct/.advisories.json`). Read directly here
    # because product-hook is standalone and cannot import tools/lib. Active
    # advisories are listed priority-ordered (urgent→warn→info, then newest
    # first), capped at 5. Empty active set with nothing newly resolved → omit
    # the section entirely (spec §5.2 / A5 — no "0 active" noise). Phase 1
    # ships an empty production probe roster, so this section stays silent
    # until a feature registers a probe.
    adv_store = _read_advisory_store(prawduct_dir)
    adv_all = adv_store.get("advisories", []) if isinstance(adv_store, dict) else []
    active_adv = [a for a in adv_all if isinstance(a, dict) and a.get("state") == "active"]
    # Stable two-pass sort: newest-first within each priority band.
    active_adv.sort(key=lambda a: a.get("triggered_at") or "", reverse=True)
    _adv_prio = {"urgent": 0, "warn": 1, "info": 2}
    active_adv.sort(key=lambda a: _adv_prio.get(a.get("priority", "info"), 2))
    # "Resolved/Dismissed since last session" — entries that transitioned at or
    # after this session's start stamp (the just-run sync resolves; dismissals
    # carry their own timestamp).
    resolved_since = 0
    dismissed_since = 0
    session_start_path = prawduct_dir / ".session-start"
    if session_start_path.is_file():
        try:
            session_start_ts = session_start_path.read_text().strip()
        except OSError:
            session_start_ts = ""
        if session_start_ts:
            resolved_since = sum(
                1
                for a in adv_all
                if isinstance(a, dict)
                and a.get("state") == "resolved"
                and (a.get("resolved_at") or "") >= session_start_ts
            )
            dismissed_since = sum(
                1
                for a in adv_all
                if isinstance(a, dict)
                and a.get("state") == "dismissed"
                and (a.get("dismissed_at") or "") >= session_start_ts
            )
    if active_adv or resolved_since or dismissed_since:
        if active_adv:
            lines.append(f"ADVISORIES (post-sync, {len(active_adv)} active):")
            for adv in active_adv[:5]:
                feature = adv.get("feature", "?")
                summary = adv.get("trigger_summary", "")
                lines.append(f"  • [{feature}] {summary}")
                action = adv.get("recommended_action", "")
                if action:
                    aid = adv.get("id", "")
                    lines.append(f"    → Run {action} (or /prawduct-advisory dismiss {aid})")
            if len(active_adv) > 5:
                lines.append(
                    f"  ... and {len(active_adv) - 5} more (run /prawduct-advisory list)"
                )
        else:
            lines.append("ADVISORIES (post-sync):")
        if dismissed_since:
            lines.append(
                f"  Dismissed since last session: {dismissed_since} "
                f"(run /prawduct-advisory list --state=dismissed to see)"
            )
        if resolved_since:
            lines.append(f"  Resolved since last session: {resolved_since}")

    # F5a sync-pending marker — surfaces when sync couldn't auto-commit
    # framework drift because a precondition (WIP, protected branch,
    # in-progress git op) blocked it. Surface so the user/Claude know there
    # is uncommitted framework drift that should be resolved.
    sync_pending_path = prawduct_dir / ".sync-pending"
    if sync_pending_path.is_file():
        try:
            pending = json.loads(sync_pending_path.read_text())
            reason = pending.get("reason", "unknown")
            version = pending.get("version", "")
            v_suffix = f" (v{version})" if version else ""
            lines.append(
                f"Framework sync pending{v_suffix}: {reason}. "
                f"Resolve and commit `.prawduct/`/`CLAUDE.md`/etc. drift, "
                f"or rerun sync once the blocker clears."
            )
        except (json.JSONDecodeError, OSError):
            lines.append(
                "Framework sync pending: marker file present but unreadable; "
                "inspect `.prawduct/.sync-pending`."
            )

    # CLAUDE.md size check
    claude_md_path = project_dir / "CLAUDE.md"
    if claude_md_path.is_file():
        try:
            claude_content = claude_md_path.read_text()
            claude_lines = claude_content.splitlines()
            total_lines = len(claude_lines)
            # Count project-specific lines (outside PRAWDUCT markers)
            in_prawduct = False
            prawduct_lines = 0
            for cl in claude_lines:
                if "PRAWDUCT:BEGIN" in cl:
                    in_prawduct = True
                elif "PRAWDUCT:END" in cl:
                    in_prawduct = False
                    prawduct_lines += 1  # count the END line itself
                elif in_prawduct:
                    prawduct_lines += 1
            project_lines = total_lines - prawduct_lines
            # Only surface genuine bloat. The soft ~150-line guideline is already
            # enforced by the Critic at review time (the actionable moment); a
            # 250+ line CLAUDE.md is a real problem worth a standing reminder.
            # Re-nagging every session at the soft threshold was pure tax.
            if project_lines > 250:
                lines.append(
                    f"CLAUDE.md is large ({project_lines} project lines) — move architecture "
                    f"docs, config tables, and component inventories to docs/ or .prawduct/artifacts/"
                )
        except Exception:  # prawduct:ok-broad-except — briefing must never block session start
            pass

    # (Cut: per-session "Tests: ~N" count and the "Last Critic review took …"
    # timing quip. Neither is actionable at session start — the canonical test
    # count lives in .test-evidence.json, and a past review's duration is noise.)

    # Relevant learnings — show count + pointer so Claude knows rules exist
    learnings_path = prawduct_dir / "learnings.md"
    if learnings_path.is_file():
        try:
            learnings_content = learnings_path.read_text()
            rule_count = 0
            for line in learnings_content.splitlines():
                # Count bullet-point rules
                if line.strip().startswith("- "):
                    rule_count += 1
            # Collapse to a count + pointer. The full topic index re-printed
            # unchanged every session — a static table of contents is tax; the
            # /learnings skill is the intended lookup path.
            if rule_count > 0:
                lines.append(f"Learnings ({rule_count} rules): /learnings <topic> or read .prawduct/learnings.md")
        except Exception:  # prawduct:ok-broad-except — briefing must never block session start
            pass

    # Backlog — show items inline so resolved ones are naturally noticed
    backlog_path = prawduct_dir / "backlog.md"
    if backlog_path.is_file():
        try:
            backlog_content = backlog_path.read_text()
            in_code_block = False
            in_resolved = False
            pending_items: list[str] = []
            for bl in backlog_content.splitlines():
                stripped_bl = bl.strip()
                if stripped_bl.startswith("```"):
                    in_code_block = not in_code_block
                elif not in_code_block and stripped_bl.startswith("#"):
                    # Track resolved/done sections
                    header_lower = stripped_bl.lower()
                    in_resolved = any(w in header_lower for w in ("resolved", "done", "completed", "archive"))
                elif not in_code_block and not in_resolved and bl.startswith("- "):
                    # Only count top-level items (no leading spaces) outside resolved sections
                    if "~~" not in stripped_bl:  # Skip strikethrough items
                        # Strip leading "- " for display
                        item_text = stripped_bl[2:].strip()
                        if item_text:
                            pending_items.append(item_text)
            if pending_items:
                # One count line, not a 5-item dump every session. /backlog is
                # the triage path; dumping arbitrary items here was tax.
                lines.append(f"Backlog: {len(pending_items)} pending (/backlog to triage)")
        except Exception:  # prawduct:ok-broad-except — briefing must never block session start
            pass

    return "\n".join(lines)


# =============================================================================
# Subagent Briefing (v5: governance context for delegated agents)
# =============================================================================


def _extract_critical_rules(project_dir: Path) -> list[str]:
    """Extract Critical Rules bullet points from the project's CLAUDE.md.

    Falls back to a minimal hardcoded set if extraction fails.
    """
    fallback = [
        "- Write tests alongside code, never after.",
        "- Never weaken a test to make it pass. Fix the code, not the test.",
        "- Never silently drop a requirement. If you can't implement it, say so.",
        "- Never catch broad exceptions without logging and re-raising.",
        "- Run the full test suite before finishing work.",
        "- When changes cross boundaries (API, database, IPC), verify consumers.",
    ]
    claude_md = project_dir / "CLAUDE.md"
    if not claude_md.is_file():
        return fallback
    try:
        content = claude_md.read_text()
        # Find Critical Rules section
        in_section = False
        rules: list[str] = []
        for line in content.splitlines():
            if line.strip().startswith("## Critical Rules"):
                in_section = True
                continue
            if in_section and line.strip().startswith("## "):
                break
            if in_section and line.strip().startswith("- **"):
                rules.append(line.strip())
        return rules if rules else fallback
    except Exception:  # prawduct:ok-broad-except — rule extraction is best-effort
        return fallback


def generate_subagent_briefing(project_dir: Path) -> None:
    """Generate .prawduct/.subagent-briefing.md for subagent governance."""
    prawduct_dir = get_prawduct_dir(project_dir)
    if not prawduct_dir.is_dir():
        return

    project_name = _get_product_name(prawduct_dir)
    rules = _extract_critical_rules(project_dir)

    sections = [
        f"# Subagent Briefing — {project_name}\n",
        "Read this file before starting work. It contains project-specific governance rules.\n",
        "## Governance Rules\n",
        *[r for r in rules],
        "",
    ]

    # Project preferences
    prefs_path = prawduct_dir / "artifacts" / "project-preferences.md"
    if prefs_path.is_file():
        try:
            prefs = prefs_path.read_text().strip()
            if prefs and "- **Language**:\n" not in prefs:
                sections.append("## Project Preferences\n")
                lines = prefs.splitlines()
                summary_lines = []
                past_header = False
                for line in lines:
                    if line.startswith("# "):
                        past_header = True
                        continue
                    if past_header:
                        summary_lines.append(line)
                    if len(summary_lines) > 30:
                        summary_lines.append("(see full file for details)")
                        break
                sections.append("\n".join(summary_lines).strip() + "\n")
        except Exception:  # prawduct:ok-broad-except — briefing generation is best-effort
            pass

    # Active learnings
    learnings_path = prawduct_dir / "learnings.md"
    if learnings_path.is_file():
        try:
            learnings = learnings_path.read_text().strip()
            if learnings:
                sections.append("## Active Learnings\n")
                sections.append(learnings + "\n")
        except Exception:  # prawduct:ok-broad-except — briefing generation is best-effort
            pass

    (prawduct_dir / ".subagent-briefing.md").write_text("\n".join(sections))


# =============================================================================
# Session Handoff (context transfer across /clear boundaries)
# =============================================================================


def _git_session_commits(project_dir: Path) -> list[str]:
    """Get commit subjects made during this session. Returns list of one-line summaries."""
    prawduct_dir = get_prawduct_dir(project_dir)
    session_start_path = prawduct_dir / ".session-start"
    if not session_start_path.is_file():
        return []
    try:
        since = session_start_path.read_text().strip()
        result = subprocess.run(
            ["git", "log", f"--since={since}", "--oneline", "--no-decorate"],
            capture_output=True,
            text=True,
            cwd=str(project_dir),
            timeout=10,
        )
        if result.returncode == 0 and result.stdout.strip():
            return result.stdout.strip().splitlines()
    except Exception:  # prawduct:ok-broad-except — commit listing is best-effort
        pass
    return []


def _summarize_critic_findings(prawduct_dir: Path) -> str | None:
    """Extract a brief summary from .critic-findings.json. Returns None if unavailable."""
    findings_path = prawduct_dir / ".critic-findings.json"
    if not findings_path.is_file():
        return None
    try:
        data = json.loads(findings_path.read_text())
        summary = data.get("summary", "")
        findings = data.get("findings", [])
        if not summary and not findings:
            return None
        parts = []
        if summary:
            parts.append(summary)
        if findings:
            blocking = [f for f in findings if f.get("severity") == "blocking"]
            warnings = [f for f in findings if f.get("severity") == "warning"]
            notes = [f for f in findings if f.get("severity") == "note"]
            counts = []
            if blocking:
                counts.append(f"{len(blocking)} blocking")
            if warnings:
                counts.append(f"{len(warnings)} warning")
            if notes:
                counts.append(f"{len(notes)} note")
            if counts:
                parts.append(f"Findings: {', '.join(counts)}")
            for f in blocking:
                parts.append(f"  BLOCKING: {f.get('summary', 'no summary')}")
            for f in warnings[:3]:
                parts.append(f"  WARNING: {f.get('summary', 'no summary')}")
        return "\n".join(parts)
    except Exception:  # prawduct:ok-broad-except — findings summarization is best-effort
        return None


def generate_session_handoff(project_dir: Path) -> None:
    """Generate .prawduct/.session-handoff.md with context for the next session.

    Called during /clear BEFORE session files are deleted. Assembles handoff
    from: WIP context, session reflection, critic findings, files changed,
    and commits made during the session.
    """
    prawduct_dir = get_prawduct_dir(project_dir)
    if not prawduct_dir.is_dir():
        return

    sections: list[str] = ["# Session Handoff", ""]

    # 1. Work context (prefer build plan Status, fall back to project-state.yaml WIP)
    wip = _get_active_work(prawduct_dir)
    if wip.get("description"):
        sections.append("## Work In Progress")
        sections.append(f"**Task**: {wip['description']}")
        qualifiers = []
        if wip.get("size"):
            qualifiers.append(f"size={wip['size']}")
        if wip.get("type"):
            qualifiers.append(f"type={wip['type']}")
        if wip.get("governance_level"):
            qualifiers.append(f"governance={wip['governance_level']}")
        if qualifiers:
            sections.append(f"**Classification**: {', '.join(qualifiers)}")
        if wip.get("context"):
            sections.append(f"**Context**: {wip['context']}")
        if wip.get("current_chunk"):
            sections.append(f"**Current chunk**: {wip['current_chunk']}")
        sections.append("")

    # 2. Session reflection (from .session-reflected, before it gets archived)
    reflected_path = prawduct_dir / ".session-reflected"
    if reflected_path.is_file():
        try:
            reflection = reflected_path.read_text().strip()
            if reflection:
                sections.append("## Previous Session Reflection")
                sections.append(reflection)
                sections.append("")
        except Exception:  # prawduct:ok-broad-except — handoff generation is best-effort
            pass

    # 3. Critic findings summary
    critic_summary = _summarize_critic_findings(prawduct_dir)
    if critic_summary:
        sections.append("## Critic Findings")
        sections.append(critic_summary)
        sections.append("")

    # 4. Files changed during session
    changed = _get_session_changed_files(project_dir)
    if changed:
        sections.append("## Files Changed This Session")
        for f in changed[:20]:  # Cap at 20 to keep handoff manageable
            sections.append(f"- {f}")
        if len(changed) > 20:
            sections.append(f"- ... and {len(changed) - 20} more")
        sections.append("")

    # 5. Commits made during session
    commits = _git_session_commits(project_dir)
    if commits:
        sections.append("## Commits This Session")
        for c in commits[:10]:
            sections.append(f"- {c}")
        if len(commits) > 10:
            sections.append(f"- ... and {len(commits) - 10} more")
        sections.append("")

    # Only write if there's actual content beyond the header
    if len(sections) > 2:
        try:
            (prawduct_dir / ".session-handoff.md").write_text("\n".join(sections) + "\n")
        except Exception:  # prawduct:ok-broad-except — handoff write must never block clear
            pass


# =============================================================================
# Compliance Canary (v5: lightweight failure detection at session end)
# =============================================================================


def _get_session_changed_files(project_dir: Path) -> list[str]:
    """Get files changed since session start. Returns list of file paths."""
    prawduct_dir = get_prawduct_dir(project_dir)
    current = git_status_output(project_dir)
    if current is None:
        return []

    baseline_path = prawduct_dir / ".session-git-baseline"
    baseline_lines: set[str] = set()
    if baseline_path.is_file():
        baseline_lines = set(baseline_path.read_text().strip().splitlines())

    changed = []
    for line in current.strip().splitlines():
        if line and line not in baseline_lines:
            parts = line.split()
            if parts:
                filepath = parts[-1]
                changed.append(filepath)
    return changed


def _is_source_file(filepath: str) -> bool:
    """Check if a filepath looks like a source code file (not test, not config)."""
    p = Path(filepath)
    suffix = p.suffix

    if suffix not in (".py", ".js", ".ts", ".jsx", ".tsx", ".go", ".rs", ".java", ".rb", ".swift", ".kt", ".c", ".cpp", ".h"):
        return False

    name = p.name
    if name.startswith("test_") or name.endswith(("_test.py", ".test.js", ".test.ts", ".test.jsx", ".test.tsx", ".spec.js", ".spec.ts")):
        return False

    if filepath.startswith(".prawduct/"):
        return False

    return True


def _is_test_file(filepath: str) -> bool:
    """Check if a filepath looks like a test file."""
    name = Path(filepath).name
    return (
        (name.startswith("test_") and name.endswith(".py"))
        or name.endswith(("_test.py", ".test.js", ".test.ts", ".test.jsx", ".test.tsx", ".spec.js", ".spec.ts"))
    )


def _is_dependency_file(filepath: str) -> bool:
    """Check if a filepath is a dependency declaration file."""
    name = Path(filepath).name
    return name in ("requirements.txt", "package.json", "Pipfile", "pyproject.toml", "Cargo.toml", "go.mod", "Gemfile")


def _check_broad_exceptions(project_dir: Path, source_files: list[str]) -> list[str]:
    """Check if changed source files contain broad exception patterns.

    Skips lines marked with 'prawduct:ok-broad-except' (intentional, reviewed).
    For Python, also skips broad catches that re-raise or log within 3 lines.
    """
    # Language-specific patterns for overly broad exception handling
    patterns = {
        ".py": r"except\s+(?:Exception|BaseException)\s*(?::|,|as\b)",
        ".js": r"catch\s*\([^)]*\)\s*\{\s*\}",
        ".jsx": r"catch\s*\([^)]*\)\s*\{\s*\}",
        ".ts": r"catch\s*\([^)]*\)\s*\{\s*\}",
        ".tsx": r"catch\s*\([^)]*\)\s*\{\s*\}",
        ".go": r"_\s*=\s*\w+\.(?:\w+)\(",  # _ = foo.Bar() — ignored errors
    }
    pragma = "prawduct:ok-broad-except"
    flagged = []
    for filepath in source_files:
        full_path = project_dir / filepath
        if not full_path.is_file():
            continue
        suffix = Path(filepath).suffix
        pattern = patterns.get(suffix)
        if not pattern:
            continue
        try:
            content = full_path.read_text()
            lines = content.splitlines()
            has_unflagged = False
            for i, line in enumerate(lines):
                if not re.search(pattern, line):
                    continue
                # Skip if line (or line above) has the pragma marker
                if pragma in line:
                    continue
                if i > 0 and pragma in lines[i - 1]:
                    continue
                # For Python: skip if the except block re-raises or logs
                if suffix == ".py":
                    # Check next 3 lines for raise or log/logger calls
                    following = "\n".join(lines[i + 1 : i + 4])
                    if re.search(r"\braise\b", following) or re.search(r"\blog(?:ger|ging)?\b", following, re.IGNORECASE):
                        continue
                has_unflagged = True
                break
            if has_unflagged:
                flagged.append(filepath)
        except Exception:  # prawduct:ok-broad-except — canary must never crash
            pass
    return flagged


def compliance_canary(project_dir: Path) -> list[str]:
    """Lightweight compliance checks at session end. Returns informational findings."""
    findings: list[str] = []
    changed = _get_session_changed_files(project_dir)

    if not changed:
        return findings

    source_changed = [f for f in changed if _is_source_file(f)]
    test_changed = [f for f in changed if _is_test_file(f)]
    dep_changed = [f for f in changed if _is_dependency_file(f)]

    # 1. Code changed but no tests
    if source_changed and not test_changed:
        preview = ", ".join(source_changed[:3])
        findings.append(
            f"CANARY: {len(source_changed)} source file(s) changed but no test files modified. Changed: {preview}"
        )

    # 2. Dependency file changed without manifest update
    if dep_changed:
        prawduct_dir = get_prawduct_dir(project_dir)
        manifest_path = prawduct_dir / "artifacts" / "dependency-manifest.md"
        dep_manifest_in_changes = any(f.endswith("dependency-manifest.md") for f in changed)
        if manifest_path.is_file() and not dep_manifest_in_changes:
            findings.append(
                f"CANARY: Dependency file(s) changed ({', '.join(dep_changed)}) "
                f"but dependency-manifest.md was not updated."
            )

    # 3. Broad exception handling in changed source files
    broad_except_files = _check_broad_exceptions(project_dir, source_changed)
    if broad_except_files:
        findings.append(
            f"CANARY: Broad exception handling detected in: {', '.join(broad_except_files)}. "
            f"Verify exceptions are specific and include logging/re-raising."
        )

    return findings


# =============================================================================
# clear command
# =============================================================================


def _check_previous_session_gates(project_dir: Path) -> list[str]:
    """Check if the previous session had unmet governance gates.

    Returns list of warning messages. Used by cmd_clear to warn (not block)
    when starting a new session without completing the previous one's governance.
    """
    prawduct_dir = get_prawduct_dir(project_dir)
    warnings: list[str] = []

    # Was there a previous session with changes?
    had_changes = git_has_session_changes(project_dir)
    if not had_changes:
        return warnings

    # Honor any waivers the previous session declared (file is deleted right
    # after this check runs, so waivers never carry past the gate they covered).
    waivers = _read_gates_waived(prawduct_dir)

    # Gate 1: Reflection (skipped for doc-only changes or when waived)
    doc_only = _session_changes_are_doc_only(project_dir)
    if not doc_only and "reflection" not in waivers:
        reflected_file = prawduct_dir / ".session-reflected"
        try:
            if not reflected_file.is_file() or len(reflected_file.read_text().strip()) < 50:
                warnings.append("reflection not captured")
        except (UnicodeDecodeError, OSError):
            warnings.append("reflection not captured")

    # Gate 2: Critic review (only when building against an active plan)
    has_build_plan = _has_active_build_plan_file(prawduct_dir) or _has_build_plan_in_state(prawduct_dir)
    if has_build_plan and not doc_only and "critic" not in waivers and git_has_code_changes(project_dir):
        critic_findings = prawduct_dir / ".critic-findings.json"
        session_start_file = prawduct_dir / ".session-start"
        needs_review = True
        if critic_findings.is_file() and session_start_file.is_file():
            try:
                session_start = session_start_file.read_text().strip()
                findings_mtime = datetime.fromtimestamp(
                    critic_findings.stat().st_mtime, tz=timezone.utc
                ).strftime("%Y-%m-%dT%H:%M:%SZ")
                if findings_mtime > session_start and validate_critic_findings(critic_findings):
                    needs_review = False
            except Exception:  # prawduct:ok-broad-except — gate check must not crash clear
                pass
        if needs_review:
            warnings.append("Critic review not recorded")

    return warnings


def cmd_clear(project_dir: Path) -> int:
    """Reset session state and trigger sync."""
    prawduct_dir = get_prawduct_dir(project_dir)

    # Check previous session's governance gates (warn, never block)
    try:
        gate_warnings = _check_previous_session_gates(project_dir)
        if gate_warnings:
            print(
                f"WARNING: Previous session had unmet governance: {', '.join(gate_warnings)}. "
                f"Consider addressing before continuing."
            )
    except Exception:  # prawduct:ok-broad-except — gate check must never block clear
        pass

    # Defensively untrack any session files that were accidentally committed.
    # Runs every session-start so even product repos that have not yet synced
    # to a Prawduct version with `untrack_gitignored_files()` get the cleanup.
    try:
        untracked = _untrack_session_files(project_dir)
        if untracked:
            print(
                "PRAWDUCT: untracked previously-committed session files (kept locally): "
                + ", ".join(untracked)
            )
    except Exception:  # prawduct:ok-broad-except — defensive cleanup must never block clear
        pass

    # Generate handoff BEFORE archiving/deleting session files
    try:
        generate_session_handoff(project_dir)
    except Exception:  # prawduct:ok-broad-except — handoff must never block clear
        pass

    # Preserve previous session's reflection before clearing
    reflected = prawduct_dir / ".session-reflected"
    if reflected.is_file():
        try:
            content = reflected.read_text().strip()
            if content:
                log = prawduct_dir / "reflections.md"
                separator = "\n\n---\n\n" if log.is_file() and log.read_text().strip() else ""
                with open(log, "a") as f:
                    f.write(f"{separator}{content}\n")
        except (UnicodeDecodeError, OSError):
            pass  # Corrupted reflection — skip archival, safe to proceed

    # Remove session files
    for name in (".session-reflected", ".session-start", ".session-git-baseline", ".gates-waived"):
        f = prawduct_dir / name
        if f.is_file():
            f.unlink()

    # Create session start timestamp
    if prawduct_dir.is_dir():
        stamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
        (prawduct_dir / ".session-start").write_text(stamp)

    # Agent-facing messages go to stdout (SessionStart hooks show stdout to the model).
    # stderr is only visible to the user in the terminal, not to the agent.

    # Warn if project-state.yaml is oversized
    state_path = prawduct_dir / "project-state.yaml"
    if state_path.is_file():
        try:
            size = state_path.stat().st_size
            if size > 40000:  # ~10K tokens ≈ ~40KB
                print(
                    f"NOTE: .prawduct/project-state.yaml is {size // 1024}KB — recommend compacting.\n"
                    f"  Completed build plan chunks: keep only id, name, status (remove deliverables/acceptance_criteria).\n"
                    f"  Test history: keep only the current count, remove per-chunk history.\n"
                    f"  Change log: keep the last ~10 entries, remove older ones (git has the history)."
                )
        except Exception:  # prawduct:ok-broad-except — size check must not block session start
            pass

    # Check for missing or unfilled project-preferences.md
    prefs_path = prawduct_dir / "artifacts" / "project-preferences.md"
    prefs_missing = not prefs_path.is_file()
    prefs_unfilled = False
    if not prefs_missing:
        try:
            content = prefs_path.read_text()
            # Template has empty fields like "- **Language**:\n" — filled has content after colon
            prefs_unfilled = "- **Language**:\n" in content
        except Exception:  # prawduct:ok-broad-except — prefs check must not block session start
            pass

    if prefs_missing or prefs_unfilled:
        # Only warn if there's source code (not a brand-new project). Framework
        # tooling (tools/product-hook, tools/lib/*.py) is infrastructure shipped
        # by sync, NOT the product's own code — counting it would fire this
        # CRITICAL on a freshly-initialized empty repo.
        has_code = any(
            f.suffix in (".py", ".js", ".ts", ".go", ".rs", ".java", ".rb", ".swift", ".kt", ".c", ".cpp", ".h")
            for f in project_dir.rglob("*")
            if f.is_file()
            and ".prawduct" not in f.parts
            and "node_modules" not in f.parts
            and f.name != "conftest.py"
            and not _is_framework_tooling(f, project_dir)
        )
        if has_code:
            print(
                "CRITICAL: You MUST create .prawduct/artifacts/project-preferences.md before doing anything else.\n"
                "  Infer preferences from the codebase (config files, test patterns, code style, existing conventions)\n"
                "  and create it with sections: Language & Runtime, Code Style, Testing, Architecture Patterns, Tooling.\n"
                "  This file is read before writing any code — it tells every session how this project's code should be written.\n"
                "  Do this NOW, before responding to the user."
            )

    # Trigger framework sync (best-effort)
    upgrade_info, sync_advisories, freshness = try_sync(project_dir)

    # Capture git status baseline AFTER sync so sync-modified files
    # (settings.json, product-hook, etc.) don't trigger spurious stop-hook gates
    if prawduct_dir.is_dir():
        baseline = git_status_output(project_dir)
        if baseline is not None:
            (prawduct_dir / ".session-git-baseline").write_text(baseline)

    # v5: Staleness scan + session briefing + subagent briefing
    if prawduct_dir.is_dir():
        try:
            staleness = staleness_scan(project_dir)
            briefing = assemble_session_briefing(
                project_dir,
                staleness,
                upgrade_info=upgrade_info,
                advisories=sync_advisories,
                freshness=freshness,
            )
            print(briefing)
        except Exception as e:  # prawduct:ok-broad-except — briefing must never block session start
            print(f"NOTE: Session briefing failed: {e}", file=sys.stderr)

        try:
            generate_subagent_briefing(project_dir)
        except Exception as e:  # prawduct:ok-broad-except — briefing must never block session start
            print(f"NOTE: Subagent briefing generation failed: {e}", file=sys.stderr)

    return 0


# =============================================================================
# stop command
# =============================================================================


def validate_critic_findings(findings_path: Path) -> bool:
    """Validate that critic findings JSON has required structure.

    Requires: non-empty files_reviewed list, findings list where each entry
    has goal/severity/summary, and a non-empty summary string. The `mode`
    field is optional (legacy hooks pre-v1.3.13 omit it) but, when present,
    must be one of the verbose strings in ``_CRITIC_MODE_VALUES``. The bare
    short tokens (``"chunk"`` / ``"final"``) are rejected — those are the
    caller-side input form, not the persistence form.
    """
    try:
        data = json.loads(findings_path.read_text())
        # Must have a findings list
        findings = data.get("findings")
        if not isinstance(findings, list):
            return False
        # Must have non-empty files_reviewed
        files_reviewed = data.get("files_reviewed")
        if not isinstance(files_reviewed, list) or not files_reviewed:
            return False
        # Each finding must have goal, severity, and summary
        for finding in findings:
            if not isinstance(finding, dict):
                return False
            for field in ("goal", "severity", "summary"):
                val = finding.get(field)
                if not isinstance(val, str) or not val.strip():
                    return False
        # Must have a non-empty summary
        summary = data.get("summary")
        if not isinstance(summary, str) or not summary.strip():
            return False
        # Mode field is optional (back-compat). If present, must be exactly one
        # of the verbose strings — bare tokens, unknown strings, and non-string
        # values are all rejected so writer drift surfaces here, not later.
        if "mode" in data:
            mode = data["mode"]
            if not isinstance(mode, str) or mode not in _CRITIC_MODE_VALUES:
                return False
        # v1.5 Chunk 01 — commit_reviewed / base_reviewed anchor the delta
        # computation for the verify-resolutions mode (Chunk 02). Optional
        # for back-compat with pre-v1.5 findings files (which simply cannot
        # serve as a verify-resolutions baseline). When present, must be a
        # NON-EMPTY string SHA or None — wrong types and empty strings are
        # writer drift (empty string would silently anchor at no commit,
        # breaking Chunk 02's delta computation), surfaced here.
        for sha_field in ("commit_reviewed", "base_reviewed"):
            if sha_field in data:
                val = data[sha_field]
                if val is None:
                    continue
                if not isinstance(val, str) or not val.strip():
                    return False
        # v1.5 Chunk 03 — mode_chosen_by records which rule fired in the
        # inference helper, or the literal "explicit-args" when the
        # builder overrode inference. Optional for back-compat. When
        # present, must be a non-empty string — empty strings and wrong
        # types are writer drift (the field's whole purpose is post-hoc
        # introspection of mode selection; empty defeats that).
        if "mode_chosen_by" in data:
            val = data["mode_chosen_by"]
            if not isinstance(val, str) or not val.strip():
                return False
        return True
    except Exception:  # prawduct:ok-broad-except — validation must not crash gate check
        return False


def _compute_verify_resolutions_scope(
    prawduct_dir: Path, project_dir: Path
) -> tuple[list[str], str]:
    """Compute the file scope a ``/critic verify-resolutions`` pass should review.

    Reads the *prior* ``.prawduct/.critic-findings.json`` (the record the
    builder is about to re-verify) and returns the union of:

      1. ``files_reviewed`` from the prior record — files the Critic already
         examined — but only when actionable findings (BLOCKING or WARNING)
         exist. NOTE-only and clean records have nothing to verify.
      2. Files changed since ``commit_reviewed`` — anchor recorded in Chunk
         01 — computed as ``git diff --name-only <commit_reviewed>`` plus
         untracked files. Mirrors ``_coverage_changed_files``'s diff +
         ls-files-others union so the verify pass sees the same shape the
         cumulative-Critic flow does.

    Returns ``(scope, reason)``. ``scope`` is sorted; ``reason`` is human-
    readable. Successful computations return a non-empty scope and a reason
    prefixed ``ok:``. All other cases return an empty scope and a categorized
    reason so the caller (Critic agent or stop-hook gate) can fall safely
    through to ``/critic chunk`` or ``/critic final``:

      - ``no-findings:`` — prior findings file missing.
      - ``unreadable-findings:`` — JSON parse or I/O failure.
      - ``no-commit-reviewed:`` — anchor absent / null / empty. Pre-v1.5
        records are valid for the schema but cannot serve as a verify
        baseline; the helper fails closed.
      - ``no-actionable-findings:`` — only NOTEs (or empty findings). Verify-
        resolutions has nothing to re-check.
      - ``invalid-files-reviewed:`` — schema-permitted but unusable
        (non-list or empty list).
      - ``unresolved-commit:`` — ``commit_reviewed`` does not resolve in
        the current repo (rebase, force-push, or simply never on this
        branch). Cannot compute a delta.
      - ``git-diff-failed:`` — the diff invocation itself failed.
      - ``scope-widened:`` — demotion criterion ``len(delta) > 2 * len(prior)
        + 5`` tripped. The prior surface no longer covers what changed; a
        partial review would mislead. Fall through to ``/critic final``.

    Fail-closed throughout — when the helper cannot anchor a delta it
    refuses to compute one rather than silently shrinking the review.
    """
    findings_path = prawduct_dir / ".critic-findings.json"
    if not findings_path.is_file():
        return [], (
            "no-findings: .prawduct/.critic-findings.json is missing — "
            "run /critic chunk or /critic final first"
        )
    try:
        data = json.loads(findings_path.read_text())
    except (json.JSONDecodeError, OSError) as exc:
        return [], f"unreadable-findings: {exc}"

    commit_reviewed = data.get("commit_reviewed")
    if not isinstance(commit_reviewed, str) or not commit_reviewed.strip():
        return [], (
            "no-commit-reviewed: prior findings lack commit_reviewed — "
            "cannot anchor delta. Run /critic chunk or /critic final."
        )

    findings = data.get("findings")
    if not isinstance(findings, list):
        return [], "invalid-findings: prior findings.findings is not a list"
    actionable = [
        f for f in findings
        if isinstance(f, dict) and f.get("severity") in ("blocking", "warning")
    ]
    if not actionable:
        return [], (
            "no-actionable-findings: prior review had no blocking/warning "
            "findings — verify-resolutions has nothing to verify. "
            "Run /critic chunk or /critic final."
        )

    prior_files = data.get("files_reviewed")
    if not isinstance(prior_files, list) or not prior_files:
        return [], (
            "invalid-files-reviewed: prior findings.files_reviewed is "
            "missing or empty"
        )
    prior_files_set = {f for f in prior_files if isinstance(f, str) and f.strip()}

    # rev-parse with the `^{commit}` peel rejects non-commit refs and SHAs
    # that don't resolve. fail-closed: any non-0 → no delta computable.
    proc = subprocess.run(
        ["git", "rev-parse", "--verify", f"{commit_reviewed}^{{commit}}"],
        cwd=str(project_dir),
        capture_output=True,
        text=True,
        timeout=10,
    )
    if proc.returncode != 0:
        return [], (
            f"unresolved-commit: commit_reviewed {commit_reviewed[:12]} "
            "does not resolve in the current repo — cannot compute delta. "
            "Run /critic chunk or /critic final."
        )

    diff_proc = subprocess.run(
        ["git", "diff", "--name-only", commit_reviewed],
        cwd=str(project_dir),
        capture_output=True,
        text=True,
        timeout=30,
    )
    if diff_proc.returncode != 0:
        return [], f"git-diff-failed: {diff_proc.stderr.strip()}"
    delta_files = {
        line.strip() for line in diff_proc.stdout.splitlines() if line.strip()
    }

    ls_proc = subprocess.run(
        ["git", "ls-files", "--others", "--exclude-standard"],
        cwd=str(project_dir),
        capture_output=True,
        text=True,
        timeout=30,
    )
    if ls_proc.returncode == 0:
        delta_files.update(
            line.strip() for line in ls_proc.stdout.splitlines() if line.strip()
        )

    # Filter metadata before threshold and scope union. The session-end
    # gate already ignores ``_is_metadata_path`` files (``.prawduct/``,
    # ``.claude/settings.json``, etc.) when computing the chunk diff;
    # counting them here would inflate ``delta_files`` against the
    # widening threshold and falsely demote a legitimate fix flow whose
    # only delta beyond the prior surface is incidental state churn
    # (the very ``.critic-findings.json`` the prior review wrote,
    # ``.session-reflected``, ``.session-git-baseline``, etc.). Symmetric
    # with ``_verify_resolutions_gate_check`` — both sides of "what
    # counts as a chunk file" agree.
    delta_files = {f for f in delta_files if not _is_metadata_path(f)}

    # Demotion: when the delta has grown well past the prior review's
    # surface, a verify pass would mislead — most of what changed wasn't
    # part of the prior review's scope at all. Threshold mirrors the build
    # plan (Chunk 02): linear factor 2 plus floor 5 so small priors don't
    # demote on a single unrelated edit.
    if len(delta_files) > 2 * len(prior_files_set) + 5:
        return [], (
            f"scope-widened: {len(delta_files)} files changed since "
            f"commit_reviewed (prior surface {len(prior_files_set)} files; "
            f"demotion threshold len(delta) > 2 * prior + 5). Fall through "
            "to /critic chunk or /critic final."
        )

    scope = sorted(prior_files_set | delta_files)
    return scope, (
        f"ok: scope = {len(scope)} files "
        f"(prior surface {len(prior_files_set)} + delta {len(delta_files)} "
        f"since {commit_reviewed[:12]})"
    )


def _verify_resolutions_gate_check(
    prawduct_dir: Path, project_dir: Path, findings_path: Path
) -> tuple[bool, str]:
    """Stop-hook gate helper: when the Critic findings file is in
    ``verify-resolutions`` mode, accept only when the current chunk diff is
    a subset of the verify pass's declared scope.

    Returns ``(True, "")`` for any other mode — the standard gate logic
    applies. For ``verify-resolutions``:

      - ``(True, "")`` — current session-changed files (excluding metadata
        paths the regular Critic gate also ignores) are all within the
        findings' ``files_reviewed`` set.
      - ``(False, reason)`` — at least one chunk-diff file is outside the
        declared scope. The verify pass is stale relative to the current
        diff; the gate keeps the block in place and surfaces the reason so
        the builder runs ``/critic chunk`` or ``/critic final`` instead.

    Fail-closed: a JSON read failure or schema gap returns ``(False, reason)``
    rather than silently clearing the gate (see learnings: "Escape hatches
    in classification create silent failures").
    """
    try:
        data = json.loads(findings_path.read_text())
    except (json.JSONDecodeError, OSError) as exc:
        return False, (
            f"verify-resolutions findings unreadable ({exc}). Run /critic "
            "chunk or /critic final."
        )

    if data.get("mode") != _CRITIC_MODE_VERIFY_RESOLUTIONS:
        return True, ""

    files_reviewed = data.get("files_reviewed")
    if not isinstance(files_reviewed, list):
        return False, (
            "verify-resolutions findings have no files_reviewed list. "
            "Run /critic chunk or /critic final."
        )
    scope = {f for f in files_reviewed if isinstance(f, str) and f.strip()}

    session_changed = {
        f for f in _get_session_changed_files(project_dir)
        if not _is_metadata_path(f)
    }

    out_of_scope = sorted(session_changed - scope)
    if not out_of_scope:
        return True, ""

    sample = ", ".join(out_of_scope[:3])
    more = f" (+{len(out_of_scope) - 3} more)" if len(out_of_scope) > 3 else ""
    return False, (
        f"verify-resolutions findings declare {len(scope)} file(s) in scope "
        f"but the current chunk diff includes {len(out_of_scope)} file(s) "
        f"outside scope ({sample}{more}). Run /critic chunk or /critic final."
    )


def _count_build_plan_chunks(prawduct_dir: Path) -> tuple[int, int]:
    """Count chunks in build-plan.md's Status section.

    Returns ``(total, complete)``: total chunks declared, and how many are
    marked ``[x]``. Returns ``(0, 0)`` if the plan is missing, has no Status
    section, or has no chunk items. Mirrors the parsing rules in
    ``_parse_build_plan_status`` (skip HTML comments; exit on next ``## ``).
    """
    plan_path = _resolve_build_plan_path(prawduct_dir)
    if not plan_path.is_file():
        return 0, 0
    try:
        content = plan_path.read_text()
        in_status = False
        in_comment = False
        total = 0
        complete = 0
        for line in content.splitlines():
            stripped = line.strip()
            if stripped == "## Status":
                in_status = True
                continue
            if not in_status:
                continue
            if stripped.startswith("## ") and stripped != "## Status":
                break
            if "<!--" in stripped:
                in_comment = True
            if "-->" in stripped:
                in_comment = False
                continue
            if in_comment:
                continue
            if stripped.startswith("- [ ]"):
                total += 1
            elif stripped.startswith("- [x]") or stripped.startswith("- [X]"):
                total += 1
                complete += 1
        return total, complete
    except Exception:  # prawduct:ok-broad-except — gate check must not crash session end
        return 0, 0


_CRITIC_MODE_GOALS_1_3_ONLY = frozenset({
    _CRITIC_MODE_CHUNK,
    _CRITIC_MODE_VERIFY_RESOLUTIONS,
})


def _critic_session_satisfies_gate(prawduct_dir: Path) -> tuple[bool, str]:
    """Check whether the latest Critic findings satisfy end-of-cycle synthesis.

    Returns ``(True, "")`` when the gate is satisfied, ``(False, reason)``
    otherwise. The gate is advisory: it fires when a multi-chunk build plan
    has all chunks marked ``[x]`` but the most recent Critic review ran
    Goals 1-3 only — ``chunk`` and ``verify-resolutions`` modes both skip
    end-of-cycle goals (Coherence, Design, Learnings Cross-Check, Backlog
    Reconciliation, Framework-Specific Checks). v1.5 Chunk 02 extended the
    case-4 trigger to verify-resolutions so a plan that closes with a delta
    re-review doesn't silently bypass final-mode synthesis.

    Specific cases (in order):
    1. No build plan, or no chunks declared → satisfied (non-chunked work).
    2. Single-chunk plan → satisfied (the one Critic run is the final).
    3. Multi-chunk plan with incomplete chunks → satisfied (mid-cycle).
    4. Multi-chunk plan, all chunks ``[x]``, latest mode is in
       ``_CRITIC_MODE_GOALS_1_3_ONLY`` (chunk or verify-resolutions)
       → unsatisfied. Run ``/critic final`` for end-of-cycle synthesis.
    5. Multi-chunk plan, all chunks ``[x]``, latest mode is ``_CRITIC_MODE_FINAL``
       (or absent — legacy records default to final), or ``_CRITIC_MODE_CUMULATIVE``
       → satisfied (full goal set ran).

    Caller is expected to invoke this only after the existing critic-required
    blocker has cleared (i.e. findings exist and ``validate_critic_findings``
    returned True). Defensive checks here keep the helper standalone-safe.
    """
    total, complete = _count_build_plan_chunks(prawduct_dir)
    if total == 0:
        return True, ""
    if total == 1:
        return True, ""
    if complete < total:
        return True, ""

    findings_path = prawduct_dir / ".critic-findings.json"
    if not findings_path.is_file():
        return True, ""
    try:
        data = json.loads(findings_path.read_text())
    except (json.JSONDecodeError, OSError):
        return True, ""
    mode = data.get("mode", _CRITIC_MODE_FINAL)
    if mode in _CRITIC_MODE_GOALS_1_3_ONLY:
        return False, (
            f"All {total} chunks complete; last Critic review was "
            f"'{mode}'. Run /critic final for end-of-cycle "
            "synthesis (Coherence, Design, Learnings Cross-Check, Backlog "
            "Reconciliation) before pushing."
        )
    return True, ""


def _has_build_plan_in_state(prawduct_dir: Path) -> bool:
    """Check if project-state.yaml contains an ACTIVE build plan.

    Uses string matching rather than YAML parsing for speed and zero dependencies.
    Returns True only when chunks exist with non-complete status. Returns False for:
    - chunks: [] (empty plan, template default)
    - All chunks with status: complete (finished plan)
    - No build_plan section at all
    """
    state_path = prawduct_dir / "project-state.yaml"
    if not state_path.is_file():
        return False
    try:
        content = state_path.read_text()
        if not (content.startswith("build_plan:") or "\nbuild_plan:" in content):
            return False
        if not ("\n  chunks:" in content or content.startswith("  chunks:")):
            return False

        # Scan chunks section for non-complete status entries
        in_chunks = False
        for line in content.splitlines():
            stripped = line.strip()
            if stripped.startswith("chunks:"):
                in_chunks = True
                if "[]" in stripped:
                    return False  # Empty array
                continue
            if in_chunks:
                # Exit chunks on line at same or lesser indent (not blank/comment)
                if line and not line.startswith("    ") and stripped and not stripped.startswith("#"):
                    break
                if stripped.startswith("status:"):
                    status_val = stripped.split(":", 1)[1].strip().strip("\"'")
                    if status_val != "complete":
                        return True
        return False
    except Exception:  # prawduct:ok-broad-except — build plan check must not crash gate check
        return False


def cmd_stop(project_dir: Path) -> int:
    """Check governance gates before session end."""
    prawduct_dir = get_prawduct_dir(project_dir)

    # Skip if no .prawduct directory
    if not prawduct_dir.is_dir():
        return 0

    blockers: list[str] = []
    canary_findings: list[str] = []

    # v5: Compliance canary (informational)
    try:
        canary_findings = compliance_canary(project_dir)
    except Exception:  # prawduct:ok-broad-except — canary must never block session end
        pass

    # Check for changes (session-scoped: only new changes since session start)
    has_changes = git_has_session_changes(project_dir)
    doc_only = _session_changes_are_doc_only(project_dir) if has_changes else False

    # Load agent-declared gate waivers (auto-cleared at session start).
    # The convention: a waiver is only NOTED to the user when it actually
    # suppressed a blocker. Notes for gates that would have been silent anyway
    # are noise — they make every session look like it bypassed governance.
    waivers = _read_gates_waived(prawduct_dir)
    waiver_notes: list[str] = []
    KNOWN_WAIVER_KEYS = {"reflection", "critic", "pr"}
    unknown_waiver_keys = sorted(k for k in waivers if k not in KNOWN_WAIVER_KEYS)
    if unknown_waiver_keys:
        # Stderr-only warning so the agent learns about typos. Doesn't affect gate
        # behavior — unknown keys simply have no effect.
        print(
            f"\nNOTE: .gates-waived has unknown keys (no effect): {', '.join(unknown_waiver_keys)}. "
            f"Valid keys: {', '.join(sorted(KNOWN_WAIVER_KEYS))}.",
            file=sys.stderr,
        )

    # Determine if there's an active build plan (used by both gates).
    # "Active" means incomplete chunks remain — a completed plan (all [x]) does not count.
    has_build_plan = _has_active_build_plan_file(prawduct_dir) or _has_build_plan_in_state(prawduct_dir)

    # Gate 1: Reflection
    # - With active build plan: BLOCKING (structured work deserves reflection)
    # - Without build plan: ADVISORY (exploratory/Q&A — reflection optional)
    # - Doc-only changes: skipped entirely
    reflected_file = prawduct_dir / ".session-reflected"
    reflection_sufficient = False
    if reflected_file.is_file():
        try:
            content = reflected_file.read_text().strip()
            # Require at least ~50 chars to avoid empty/token reflections
            reflection_sufficient = len(content) >= 50
        except Exception:  # prawduct:ok-broad-except — gate check must not crash session end
            pass

    # Reflection blocker fires when there are real changes, the reflection is
    # missing or too short, the changes are not all docs, AND a build plan is
    # active. (Without a build plan, the gate is advisory — see below.)
    reflection_would_block = (
        has_changes and not reflection_sufficient and not doc_only and has_build_plan
    )
    if reflection_would_block:
        if "reflection" in waivers:
            waiver_notes.append(f"reflection: waived ({waivers['reflection']})")
        else:
            blockers.append(
                "REFLECTION: Files were modified but no session reflection was captured.\n"
                "  Append your reflection to .prawduct/.session-reflected. This is a narrative of what\n"
                "  happened: what changed, what surprised you, what the methodology helped or hindered.\n"
                "  It gets archived to reflections.md automatically on next session start.\n"
                "\n"
                "  Going forward, reflect at WORK BOUNDARIES — right after Critic passes, after a bug\n"
                "  fix, after error recovery — not at session end. Writing reflections when the user is\n"
                "  waiting on /clear creates friction and hurts quality. Append to .session-reflected\n"
                "  as you go; the file accumulates across the session. Then /clear is instant.\n"
                "\n"
                "  If this session produced a durable lesson (a rule future sessions should follow),\n"
                "  also add it to .prawduct/learnings.md. Learnings are concise standing rules;\n"
                "  the session reflection is the story behind them.\n"
                "\n"
                "  Escape hatch: if this gate cannot be satisfied this session (rare for reflection —\n"
                "  it is meant to be cheap), declare a waiver so this gate stops re-firing:\n"
                "    echo '{\"reflection\": \"reason — be specific\"}' > .prawduct/.gates-waived\n"
                "  Empty reasons are rejected; the file auto-clears next session. See\n"
                "  .prawduct/build-governance.md \"Gate Waivers\" for when this is appropriate.\n"
            )
    elif (
        has_changes
        and not reflection_sufficient
        and not doc_only
        and not has_build_plan
        and "reflection" not in waivers
    ):
        # Advisory: print to stderr but don't block
        print(
            "\nNOTE: No session reflection captured. Reflection is not required for exploratory work,\n"
            "  but if this session produced useful insights, consider writing them to\n"
            "  .prawduct/.session-reflected (archived automatically on next session start).\n",
            file=sys.stderr,
        )

    # Gate 2: Critic review (only when building against a plan)
    # Skipped for doc-only changes (no code to review). The waiver is only
    # noted when the gate would have actually blocked (valid findings absent).
    # `has_changes` already reflects baseline-aware diff via git_has_session_changes,
    # so no extra "code changes" check is needed beyond `not doc_only`.
    #
    # v1.4 F6 — Type: designer-handoff also skips the gate (formalizes the
    # prior user-memory carveout into a framework rule). Other declared types
    # (code, doc-only, cleanup, cumulative-final) leave the gate behavior
    # unchanged — only Critic's *protocol* adjusts inside the agent.
    designer_handoff_skip = False
    # v1.5 Chunk 04 — Type: trivial fileset + rationale enforcement. The
    # checks fire BEFORE the Critic gate and emit BLOCKING for any
    # violation, but the chunk is still treated as `code` for gate
    # purposes (so the Critic gate still applies on top). Fail-closed:
    # over-declaring `trivial` produces a named blocker pointing at the
    # specific bound violated, never a silent carveout. The trivial
    # check does NOT honor the doc-only skip — bounds like
    # "no edits under agents/" matter even when the diff is empirically
    # all-.md (the agents/ tree itself is pure prose). Doc-only only
    # carves out the Critic protocol (which has no code to review); the
    # trivial-declaration contract is independent.
    trivial_block_reason = ""
    if has_changes and has_build_plan:
        current_chunk_id = _current_chunk_id_from_status(prawduct_dir)
        if current_chunk_id is not None:
            chunk_type, type_error = _parse_build_plan_chunk_type(prawduct_dir, current_chunk_id)
            # v1.5.1 Chunk 04(a): surface typo'd Type values. Pre-fix, the
            # parser's "unknown type: ..." message was discarded so the author
            # never saw it. Surfacing in waiver_notes makes the typo visible
            # at session-end and in the next stop-hook briefing without
            # changing fail-closed semantics (chunk still runs as `code` per
            # parser default). Restricted to "unknown type:" errors only —
            # "chunk not found" / "missing build-plan" errors indicate a
            # structural fixture issue, not actionable user feedback.
            if type_error and type_error.startswith("unknown type:"):
                waiver_notes.append(f"chunk-type-parse-error: {type_error}")
            if chunk_type == "designer-handoff" and not doc_only:
                designer_handoff_skip = True
                waiver_notes.append(
                    f"critic: skipped (Chunk {current_chunk_id} declares Type: designer-handoff — v1.4 F6 carveout)"
                )
            elif chunk_type == "trivial":
                fileset_ok, fileset_reason = _is_trivial_fileset_eligible(project_dir)
                if not fileset_ok:
                    trivial_block_reason = fileset_reason
                else:
                    _, rationale_error = _parse_build_plan_chunk_trivial_rationale(
                        prawduct_dir, current_chunk_id
                    )
                    if rationale_error:
                        trivial_block_reason = rationale_error

    critic_would_block = False
    # v1.5 Chunk 02: when the fresh, schema-valid findings file is in
    # verify-resolutions mode, the gate must also confirm that the current
    # chunk diff is a subset of the verify pass's declared scope. Out-of-
    # scope means the builder added work after the verify pass — the
    # findings are stale relative to the current diff. The reason string is
    # surfaced verbatim in the blocker so the builder knows which files
    # widened the scope.
    verify_resolutions_block_reason = ""
    if has_changes and has_build_plan and not doc_only and not designer_handoff_skip:
        critic_would_block = True
        critic_findings = prawduct_dir / ".critic-findings.json"
        session_start_file = prawduct_dir / ".session-start"
        if critic_findings.is_file() and session_start_file.is_file():
            try:
                session_start = session_start_file.read_text().strip()
                findings_mtime = datetime.fromtimestamp(
                    critic_findings.stat().st_mtime, tz=timezone.utc
                ).strftime("%Y-%m-%dT%H:%M:%SZ")
                if findings_mtime > session_start and validate_critic_findings(critic_findings):
                    in_scope, scope_reason = _verify_resolutions_gate_check(
                        prawduct_dir, project_dir, critic_findings
                    )
                    if in_scope:
                        critic_would_block = False
                    else:
                        verify_resolutions_block_reason = scope_reason
            except Exception:  # prawduct:ok-broad-except — gate check must not crash session end
                pass

    if critic_would_block:
        if "critic" in waivers:
            waiver_notes.append(f"critic: waived ({waivers['critic']})")
        elif verify_resolutions_block_reason:
            blockers.append(
                "CRITIC REVIEW (verify-resolutions stale): "
                f"{verify_resolutions_block_reason}\n"
                "  Verify-resolutions findings clear the gate only when the current chunk\n"
                "  diff is within the verify pass's declared scope. The chunk has grown\n"
                "  beyond that surface, so the prior review's conclusions no longer cover\n"
                "  what changed. Re-run with a wider mode before ending the session.\n"
                "\n"
                "  Escape hatch: if Critic cannot run this session (e.g., wider mode is\n"
                "  blocked by an external dependency), declare a waiver so this gate stops\n"
                "  re-firing every turn:\n"
                "    echo '{\"critic\": \"reason — be specific\"}' > .prawduct/.gates-waived\n"
                "  Empty reasons are rejected; the file auto-clears next session. See\n"
                "  .prawduct/build-governance.md \"Gate Waivers\" for when this is appropriate.\n"
            )
        else:
            blockers.append(
                "CRITIC REVIEW: The build plan includes Critic review in each chunk's \"Done when\" steps,\n"
                "  but no Critic findings were recorded this session. If you have completed a chunk,\n"
                "  run `/critic` now. The Critic reads .prawduct/critic-review.md for instructions\n"
                "  and records findings to .prawduct/.critic-findings.json\n"
                "\n"
                "  Escape hatch: if Critic legitimately cannot run this session — e.g., the chunk\n"
                "  is in a designer-handoff phase (mark `Type: designer-handoff` in the build plan\n"
                "  to skip this gate automatically), or the GO/NO-GO is not yet verifiable because\n"
                "  it depends on a human authoring step or external system — declare a waiver so\n"
                "  this gate stops re-firing every turn:\n"
                "    echo '{\"critic\": \"reason — be specific\"}' > .prawduct/.gates-waived\n"
                "  Empty reasons are rejected; the file auto-clears next session. See\n"
                "  .prawduct/build-governance.md \"Gate Waivers\" for when this is appropriate.\n"
            )

    # v1.5 Chunk 04: Type: trivial enforcement runs alongside the Critic
    # gate (not in place of it). The chunk is still treated as `code`
    # for gate purposes; this block adds a separate BLOCKING if either
    # the file-set bound or rationale presence failed.
    if trivial_block_reason:
        blockers.append(
            f"TYPE: TRIVIAL — declared but {trivial_block_reason}.\n"
            "  Either fix the violation or change Type to `code`.\n"
        )

    # Gate 2.5: Advisory mode gate — fires only when Gate 2 passed, which
    # already enforces this-session freshness on the findings file. So if we
    # reach this branch, the findings represent a real review the agent ran
    # this session; the gate doesn't need to re-check freshness. When all
    # chunks of a multi-chunk plan are complete but the latest review was
    # chunk-mode, end-of-cycle synthesis (Coherence, Design, Learnings
    # Cross-Check, Backlog Reconciliation) hasn't run. Advisory NOT blocking —
    # chunk-mode reviews are valid mid-cycle, and the builder may legitimately
    # defer the final review (e.g., next session).
    if has_changes and not doc_only and not critic_would_block:
        mode_satisfied, mode_reason = _critic_session_satisfies_gate(prawduct_dir)
        if not mode_satisfied:
            print("", file=sys.stderr)
            print(f"NOTE: {mode_reason}", file=sys.stderr)

    # Gate 3: PR review evidence (when a PR exists for the current branch).
    # Skipped for doc-only changes. The waiver is only noted when the gate
    # would have actually blocked (real PR exists with no valid evidence).
    pr_reviews_dir = prawduct_dir / ".pr-reviews"
    pr_would_block = False
    pr_block_branch = ""
    if not doc_only:
        try:
            branch_result = subprocess.run(
                ["git", "branch", "--show-current"],
                capture_output=True, text=True, cwd=str(project_dir), timeout=10,
            )
            current_branch = branch_result.stdout.strip() if branch_result.returncode == 0 else ""
            if current_branch and current_branch not in ("main", "master", "develop"):
                # Check if there's a PR for this branch
                pr_check = subprocess.run(
                    ["gh", "pr", "list", "--head", current_branch, "--json", "number", "--limit", "1"],
                    capture_output=True, text=True, cwd=str(project_dir), timeout=15,
                )
                has_pr = False
                if pr_check.returncode == 0 and pr_check.stdout.strip():
                    try:
                        prs = json.loads(pr_check.stdout)
                        has_pr = len(prs) > 0
                    except json.JSONDecodeError:
                        pass

                if has_pr:
                    # Symmetry with `/pr create` Steps 1b/1c: when the PR diff
                    # is doc-only (all .md) or trivial (every commit fileset-
                    # eligible per Chunk 04 bounds), the cumulative-Critic and
                    # PR-reviewer gates were legitimately skipped at create-
                    # time and no evidence file was written. Gate 3 would
                    # otherwise block session end with a misleading "no review
                    # evidence" error for a PR that doesn't need one. If a
                    # later session adds non-.md or fileset-violating commits,
                    # both checks return False and the gate fires as intended
                    # — matching the create-flow contract.
                    pr_is_doc_only, _ = _pr_diff_is_doc_only(project_dir)
                    pr_is_trivial, _ = (
                        (False, "") if pr_is_doc_only else _pr_diff_is_trivial(project_dir)
                    )
                    if pr_is_doc_only or pr_is_trivial:
                        pass  # Fast-path eligible — no evidence required
                    else:
                        # Check for review evidence with content validation
                        branch_file = current_branch.replace("/", "--") + ".json"
                        evidence_path = pr_reviews_dir / branch_file
                        pr_review_valid = False
                        if evidence_path.is_file():
                            try:
                                data = json.loads(evidence_path.read_text())
                                if isinstance(data.get("findings"), list) and data.get("summary"):
                                    pr_review_valid = True
                            except (json.JSONDecodeError, OSError):
                                pass
                        if not pr_review_valid:
                            pr_would_block = True
                            pr_block_branch = current_branch
        except Exception:  # prawduct:ok-broad-except — gate check must not crash session end
            pass

    if pr_would_block:
        if "pr" in waivers:
            waiver_notes.append(f"pr: waived ({waivers['pr']})")
        else:
            blockers.append(
                f"PR REVIEW: PR exists for branch '{pr_block_branch}' but no valid review evidence found.\n"
                "  Run /pr to invoke the PR reviewer before merging.\n"
                "  The reviewer writes evidence to .prawduct/.pr-reviews/<branch>.json\n"
                "\n"
                "  Escape hatch: if PR review cannot run this session (e.g., reviewer agent is\n"
                "  unavailable and merge is not imminent), declare a waiver so this gate stops\n"
                "  re-firing every turn:\n"
                "    echo '{\"pr\": \"reason — be specific\"}' > .prawduct/.gates-waived\n"
                "  Empty reasons are rejected; the file auto-clears next session. PR review is\n"
                "  the release-readiness gate — waive only when merge will not happen this\n"
                "  session. See .prawduct/build-governance.md \"Gate Waivers\".\n"
            )

    # Surface any waivers so the agent (and reviewer) can see what was skipped.
    # Waivers are auto-cleared at next session start so they never carry forward.
    if waiver_notes:
        print("", file=sys.stderr)
        print("GATE WAIVERS (this session, .gates-waived):", file=sys.stderr)
        for note in waiver_notes:
            print(f"  {note}", file=sys.stderr)

    # Report blockers or exit clean
    if blockers:
        print("", file=sys.stderr)
        print("BLOCKED — resolve before ending session:", file=sys.stderr)
        print("", file=sys.stderr)
        for blocker in blockers:
            print(blocker, file=sys.stderr)
        # Include canary findings in stderr so model sees them alongside blockers
        if canary_findings:
            print("COMPLIANCE CANARY (address if possible):", file=sys.stderr)
            for f in canary_findings:
                print(f"  {f}", file=sys.stderr)
            print("", file=sys.stderr)
        return 2

    # No blockers — print canary to stderr (visible in verbose mode only)
    if canary_findings:
        print("", file=sys.stderr)
        print("COMPLIANCE CANARY (informational):", file=sys.stderr)
        for f in canary_findings:
            print(f"  {f}", file=sys.stderr)

    return 0


# =============================================================================
# test-status command
# =============================================================================


def cmd_test_status(project_dir: Path) -> int:
    """Print whether saved test evidence is fresh enough to trust.

    Used by builders, the Critic, and the PR reviewer to decide whether to
    re-run the test suite.

    stdout: one line — `current: <reason>` or `stale: <reason>`

    Exit codes:
      0  - tests are current; safe to skip re-running
      1  - tests are stale or no evidence
    """
    is_current, reason = tests_are_current(project_dir)
    if is_current:
        print(f"current: {reason}")
    else:
        print(f"stale: {reason}")
    return 0 if is_current else 1


# =============================================================================
# validate-evidence command
# =============================================================================


def cmd_validate_evidence(project_dir: Path) -> int:
    """Schema-only check on ``.test-evidence.json``.

    Useful for CI / pre-commit hooks that want a fast schema sanity check
    without the freshness comparison ``test-status`` performs. Returns
    exit 0 only when the file exists, parses, and matches the required
    schema; exit 1 otherwise.
    """
    evidence_path = get_prawduct_dir(project_dir) / ".test-evidence.json"
    if not evidence_path.is_file():
        print(f"missing: {evidence_path}", file=sys.stderr)
        return 1
    try:
        evidence = json.loads(evidence_path.read_text())
    except (json.JSONDecodeError, OSError) as exc:
        print(f"unreadable: {exc}", file=sys.stderr)
        return 1
    if not isinstance(evidence, dict):
        print("evidence is not a JSON object", file=sys.stderr)
        return 1
    ok, err = _validate_evidence_schema(evidence)
    if not ok:
        print(f"invalid: {err}", file=sys.stderr)
        return 1
    print("valid")
    return 0


# =============================================================================
# check-cumulative-critic command (v1.4 F2 — PR gate)
# =============================================================================


_BUILD_PLAN_PATH_RE = re.compile(r"`([^`\s]+)`")
_BUILD_PLAN_NEW_QUALIFIER_RE = re.compile(r"\bnew\s+`([^`\s]+)`")
# Per-chunk Type declaration (v1.4 F6 — proportional Critic via chunk type).
# Matches `- **Type:** <token>` or `**Type:** <token>` as a list item; trailing
# parenthetical prose is allowed and ignored.
_BUILD_PLAN_TYPE_RE = re.compile(r"^[\s\-\*]*\*\*Type:\*\*\s*([A-Za-z][\w\-]*)")
# v1.5 Chunk 04 — `trivial` joins the allowed set. File-set bounds (no
# edits under agents/, methodology/, templates/; no CLAUDE.md edit; no
# test deletion; no new files) + required `**Trivial because:**`
# rationale are enforced structurally; semantic-fit is Critic Goal 3 in
# Chunk 05. Size is unbounded — trivial is a semantic claim, not a LOC
# metric.
_BUILD_PLAN_ALLOWED_TYPES = frozenset(
    {"code", "doc-only", "cleanup", "designer-handoff", "cumulative-final", "trivial"}
)
# `**Trivial because:** <rationale>` — first line; continuation lines (no
# list-item / heading prefix) are joined onto the rationale until the next
# field. Empty after the colon → missing-rationale.
_BUILD_PLAN_TRIVIAL_RATIONALE_RE = re.compile(
    r"^[\s\-\*]*\*\*Trivial because:\*\*\s*(.*)$"
)


def _looks_like_file_path(token: str) -> bool:
    """A backticked token is a precise file-path reference only when it
    contains ``/`` — i.e. the chunk author wrote a specific relative path.
    Bare filenames in prose (e.g. ``backlog.md``, ``SKILL.md``) are usually
    conceptual references whose actual location varies, so they're not
    verifiable in a useful way.

    Slash-commands (``/pr``, ``/learnings``, ``/critic``) also contain
    ``/`` but are not file paths. Exclude tokens that start with ``/``,
    have no further ``/``, and contain no ``.`` — that shape is a single
    slash-command identifier, not a path."""
    if "/" not in token:
        return False
    if token.startswith("/") and "/" not in token[1:] and "." not in token:
        return False
    return True


def _parse_build_plan_chunk_refs(prawduct_dir: Path, chunk_id: str) -> dict:
    """Extract backticked file-path references from a single chunk's section
    in ``.prawduct/artifacts/build-plan.md``.

    The section is located by name (``### Chunk <chunk_id>:`` prefix, leading
    zeros tolerant), and parsing stops at the next ``### `` or ``## `` heading
    — sibling chunks' refs are NOT returned. Fenced code blocks (```...```)
    are skipped because project-structure diagrams aren't load-bearing prose.
    Paths preceded by the word ``new`` on the same line are skipped as
    intra-chunk forward references (files the chunk creates rather than
    modifies).

    Returns ``{"file_paths": [{"line_num": int, "ref": str}, ...],
    "error": str | None}``. Symbol and backlog-ID extraction are deferred —
    see Chunk 02 plan-vs-execution divergence (commit message).
    """
    result: dict = {"file_paths": [], "error": None}
    plan_path = _resolve_build_plan_path(prawduct_dir)
    if not plan_path.is_file():
        result["error"] = f"missing build-plan: {plan_path}"
        return result
    try:
        content = plan_path.read_text()
    except OSError as exc:
        result["error"] = f"unreadable build-plan: {exc}"
        return result

    # Normalize chunk_id: strip leading zeros for matching ("02" matches
    # "### Chunk 2:" and vice versa) but report the original in errors.
    target = chunk_id.lstrip("0") or "0"

    in_section = False
    in_fence = False
    section_lines: list[tuple[int, str]] = []
    for line_num, line in enumerate(content.splitlines(), start=1):
        stripped = line.strip()
        if stripped.startswith("### Chunk "):
            # Heading format: "### Chunk NN: Name"
            rest = stripped[len("### Chunk "):]
            head = rest.split(":", 1)[0].strip()
            head_norm = head.lstrip("0") or "0"
            if in_section:
                # Entered a sibling chunk; stop accumulating.
                break
            if head_norm == target:
                in_section = True
            continue
        if not in_section:
            continue
        if stripped.startswith("## "):
            # Left the Build Chunks section entirely.
            break
        if stripped.startswith("```"):
            in_fence = not in_fence
            continue
        if in_fence:
            continue
        section_lines.append((line_num, line))

    if not in_section and not section_lines:
        result["error"] = f"chunk {chunk_id!r} not found in build-plan"
        return result

    seen: set[tuple[str, int]] = set()
    for line_num, line in section_lines:
        # Collect spans preceded by "new " — these get filtered out.
        excluded_spans: list[tuple[int, int]] = [
            m.span(1) for m in _BUILD_PLAN_NEW_QUALIFIER_RE.finditer(line)
        ]
        for match in _BUILD_PLAN_PATH_RE.finditer(line):
            token = match.group(1)
            if not _looks_like_file_path(token):
                continue
            if any(start == match.start(1) for start, _ in excluded_spans):
                continue
            key = (token, line_num)
            if key in seen:
                continue
            seen.add(key)
            result["file_paths"].append({"line_num": line_num, "ref": token})
    return result


def _parse_build_plan_chunk_type(
    prawduct_dir: Path, chunk_id: str
) -> tuple[str | None, str | None]:
    """Extract the `Type:` declaration from a chunk's build-plan section.

    Returns ``(chunk_type, error)``. ``chunk_type`` is one of
    ``code | doc-only | cleanup | designer-handoff | cumulative-final | trivial``.
    Default (field absent) is ``code`` — fail-closed so a missing declaration
    runs the full Critic protocol rather than silently triggering a carveout
    (learnings: "escape hatches in classification create silent failures").
    Unknown values surface as ``(None, "unknown type: <value>")`` so the
    author fixes the typo instead of getting silent fall-through.

    Section discovery mirrors ``_parse_build_plan_chunk_refs`` — name-anchored
    on ``### Chunk <chunk_id>:`` with leading-zero tolerance; fenced code
    blocks are skipped.
    """
    plan_path = _resolve_build_plan_path(prawduct_dir)
    if not plan_path.is_file():
        return None, f"missing build-plan: {plan_path}"
    try:
        content = plan_path.read_text()
    except OSError as exc:
        return None, f"unreadable build-plan: {exc}"

    target = chunk_id.lstrip("0") or "0"

    in_section = False
    in_fence = False
    section_found = False
    declared: str | None = None
    for line in content.splitlines():
        stripped = line.strip()
        if stripped.startswith("### Chunk "):
            rest = stripped[len("### Chunk "):]
            head = rest.split(":", 1)[0].strip()
            head_norm = head.lstrip("0") or "0"
            if in_section:
                break  # hit sibling chunk
            if head_norm == target:
                in_section = True
                section_found = True
            continue
        if not in_section:
            continue
        if stripped.startswith("## "):
            break
        if stripped.startswith("```"):
            in_fence = not in_fence
            continue
        if in_fence:
            continue
        m = _BUILD_PLAN_TYPE_RE.match(line)
        if m:
            declared = m.group(1)
            break

    if not section_found:
        return None, f"chunk {chunk_id!r} not found in build-plan"

    if declared is None:
        return "code", None  # fail-closed default
    if declared not in _BUILD_PLAN_ALLOWED_TYPES:
        allowed = ", ".join(sorted(_BUILD_PLAN_ALLOWED_TYPES))
        return None, f"unknown type: {declared!r} (allowed: {allowed})"
    return declared, None


def _parse_build_plan_chunk_trivial_rationale(
    prawduct_dir: Path, chunk_id: str
) -> tuple[str | None, str | None]:
    """Extract the ``**Trivial because:**`` rationale from a chunk's section.

    Returns ``(rationale, error)``. Empty or absent field → ``(None,
    "missing-rationale: Type: trivial requires non-empty **Trivial
    because:** field")``. Multi-line rationale (continuation lines without
    a list-item / heading prefix) is joined into a single string until the
    next field.

    Section discovery mirrors ``_parse_build_plan_chunk_type`` —
    name-anchored on ``### Chunk <chunk_id>:`` with leading-zero
    tolerance; fenced code blocks are skipped.
    """
    plan_path = _resolve_build_plan_path(prawduct_dir)
    if not plan_path.is_file():
        return None, f"missing build-plan: {plan_path}"
    try:
        content = plan_path.read_text()
    except OSError as exc:
        return None, f"unreadable build-plan: {exc}"

    target = chunk_id.lstrip("0") or "0"

    in_section = False
    in_fence = False
    section_found = False
    capturing = False
    rationale_lines: list[str] = []
    for line in content.splitlines():
        stripped = line.strip()
        if stripped.startswith("### Chunk "):
            rest = stripped[len("### Chunk "):]
            head = rest.split(":", 1)[0].strip()
            head_norm = head.lstrip("0") or "0"
            if in_section:
                break  # hit sibling chunk
            if head_norm == target:
                in_section = True
                section_found = True
            continue
        if not in_section:
            continue
        if stripped.startswith("## "):
            break
        if stripped.startswith("```"):
            in_fence = not in_fence
            continue
        if in_fence:
            continue
        m = _BUILD_PLAN_TRIVIAL_RATIONALE_RE.match(line)
        if m:
            capturing = True
            first = m.group(1).strip()
            if first:
                rationale_lines.append(first)
            continue
        if capturing:
            # Stop at the next list-item field, bolded label, or sub-heading.
            if (
                stripped.startswith("- **")
                or stripped.startswith("* **")
                or stripped.startswith("**")
                or stripped.startswith("#")
            ):
                break
            if stripped:
                rationale_lines.append(stripped)

    if not section_found:
        return None, f"chunk {chunk_id!r} not found in build-plan"
    if not capturing:
        return None, (
            "missing-rationale: Type: trivial requires non-empty "
            "**Trivial because:** field"
        )
    rationale = " ".join(rationale_lines).strip()
    if not rationale:
        return None, (
            "missing-rationale: Type: trivial requires non-empty "
            "**Trivial because:** field"
        )
    return rationale, None


def _classify_trivial_change(
    *,
    path: str,
    src_path: str | None,
    is_addition: bool,
    is_deletion: bool,
) -> str | None:
    """Single-file path-rule check for the ``Type: trivial`` file-set
    bounds. Returns the violation reason or ``None`` when the change
    is eligible. Shared by ``_is_trivial_fileset_eligible`` (Chunk 04 —
    working-tree porcelain) and ``_pr_diff_is_trivial`` (Chunk 05 —
    per-commit name-status diff). Centralizing the rule set prevents
    the stop-hook gate and the PR-boundary gate from drifting apart
    silently.

    Metadata paths (``.prawduct/``, ``.claude/settings.json``, etc. —
    see ``_is_metadata_path``) are treated as out-of-scope and return
    ``None``. The check applies to BOTH ``path`` (rename dst / single-
    file change) AND ``src_path`` (rename src) — without that
    symmetry, a rename FROM a metadata path that landed somewhere
    interesting would silently be classified, and a rename TO a
    metadata path would be classified differently depending on which
    gate ran (the callers previously did dst-only filtering before
    calling this helper — v1.5.1 Chunk 04(b) consolidates).

    The caller translates its status format into the boolean inputs
    (porcelain handles 2-char codes; name-status handles single-char).
    Path bounds are catastrophic-blast-radius classes regardless of
    size — size is intentionally not a bound.
    """
    if _is_metadata_path(path):
        return None
    if src_path is not None and _is_metadata_path(src_path):
        return None
    if path == "CLAUDE.md":
        return f"claude-md-edited: {path}"
    if path.startswith("agents/"):
        return f"agent-file-edited: {path}"
    if path.startswith("methodology/"):
        return f"methodology-edited: {path}"
    if path.startswith("templates/"):
        return f"template-edited: {path}"
    if is_deletion and path.startswith("tests/"):
        return f"test-file-deleted: {path}"
    if src_path is not None and src_path.startswith("tests/"):
        return f"test-file-deleted: {src_path} (renamed out)"
    if is_addition:
        return f"new-file: {path}"
    return None


def _is_trivial_fileset_eligible(project_dir: Path) -> tuple[bool, str]:
    """Check the current session's diff against the ``Type: trivial``
    file-set bounds. Returns ``(eligible, reason)``.

    Bounds (catastrophic-blast-radius classes, regardless of size):
    no edits under ``agents/``, ``methodology/``, or ``templates/``; no
    edits to ``CLAUDE.md``; no test-file removals (porcelain ``D`` under
    ``tests/`` OR rename whose source path is under ``tests/`` — a
    ``git mv`` out of the test directory is semantically a deletion);
    no newly-tracked files (porcelain ``A`` or ``??``).

    Size is intentionally NOT a bound — trivial is a semantic judgment.
    An 80-LOC project-wide rename can qualify; a 5-line state-machine
    change cannot. The semantic claim lives in
    ``**Trivial because:**``; Critic Goal 3 validates rationale-vs-diff
    fit (Chunk 05).

    Reason strings name the specific bound that failed for actionable
    stop-hook messaging — e.g. ``"agent-file-edited: agents/critic/SKILL.md"``.
    The first violating file wins (deterministic order: porcelain
    output ordering); the user fixes one violation at a time.

    Uses session-baseline filtering so pre-session dirt doesn't count
    against the chunk (mirrors ``git_has_session_changes``).

    Path-bound rules are delegated to ``_classify_trivial_change`` so
    the PR-boundary helper (``_pr_diff_is_trivial``) applies an
    identical rule set — same bounds checked at session-end and at
    PR creation.
    """
    output = git_status_output(project_dir)
    if output is None:
        # No git or git error — defer to other gates; treat as eligible
        # so the trivial check doesn't fail the build when git itself is
        # the problem.
        return True, ""

    prawduct_dir = get_prawduct_dir(project_dir)
    baseline_path = prawduct_dir / ".session-git-baseline"
    baseline_lines: set[str] = set()
    if baseline_path.is_file():
        try:
            baseline_lines = set(baseline_path.read_text().splitlines())
        except (UnicodeDecodeError, OSError):
            pass

    for line in output.splitlines():
        if not line or line in baseline_lines:
            continue
        if len(line) < 4:
            continue
        status = line[:2]
        raw = line[3:].strip()
        src_path: str | None = None
        if " -> " in raw:
            src_raw, dst_raw = raw.split(" -> ", 1)
            src_path = src_raw.strip()
            if src_path.startswith('"') and src_path.endswith('"'):
                src_path = src_path[1:-1]
            path = dst_raw.strip()
        else:
            path = raw
        if path.startswith('"') and path.endswith('"'):
            path = path[1:-1]
        if not path:
            continue

        # v1.5.1 Chunk 04(b): metadata-path filtering lives inside
        # `_classify_trivial_change` (returns None for both src and dst
        # metadata paths). Single check site = no drift between this gate
        # and `_pr_diff_is_trivial`.
        is_addition = status[0] == "A" or status == "??"
        is_deletion = "D" in status
        violation = _classify_trivial_change(
            path=path,
            src_path=src_path,
            is_addition=is_addition,
            is_deletion=is_deletion,
        )
        if violation is not None:
            return False, violation

    return True, ""


def _current_chunk_id_from_status(prawduct_dir: Path) -> str | None:
    """Extract the chunk id of the first `- [ ]` item in the build-plan Status
    section, e.g. ``"03"`` for ``- [ ] Chunk 03: Foo``. Returns ``None`` if
    Status is missing, has no current chunk (all complete), or the chunk text
    isn't in the expected ``Chunk NN:`` form.

    Mirrors the resolution logic in ``cmd_verify_chunk_refs`` so the stop
    hook and the Critic helper agree on "which chunk is current."
    """
    status = _parse_build_plan_status(prawduct_dir)
    current = status.get("current_chunk", "")
    if not current.startswith("Chunk "):
        return None
    rest = current[len("Chunk "):]
    chunk_id = rest.split(":", 1)[0].strip()
    return chunk_id or None


def _verify_chunk_refs(project_dir: Path, refs: dict) -> list[dict]:
    """Verify each file-path ref exists relative to ``project_dir``.
    Returns a list of ``{"kind", "ref", "line_num", "reason"}`` for missing
    entries. Empty list = all refs resolved.
    """
    missing: list[dict] = []
    for entry in refs.get("file_paths", []):
        ref = entry["ref"]
        target = project_dir / ref
        if not target.exists():
            missing.append({
                "kind": "file_path",
                "ref": ref,
                "line_num": entry["line_num"],
                "reason": "file does not exist",
            })
    return missing


def cmd_verify_chunk_refs(project_dir: Path, chunk_id: str | None) -> int:
    """Critic Goal-2 helper: verify symbols/paths referenced in the current
    chunk's build-plan section actually exist on the filesystem.

    Exit 0 = all refs resolved (or nothing to check). Exit 1 = at least one
    ref is missing, or the gate cannot be evaluated. stderr lists each
    missing ref so the Critic can quote it in findings.

    With no ``chunk_id``, reads the current chunk from Status (first unchecked
    ``- [ ]`` item). If Status has no current chunk (all complete or no
    Status section), exits 0 — there is nothing to verify.
    """
    prawduct_dir = get_prawduct_dir(project_dir)
    if chunk_id is None:
        chunk_id = os.environ.get("PRAWDUCT_VERIFY_CHUNK_ID")
    if chunk_id is None:
        status = _parse_build_plan_status(prawduct_dir)
        current = status.get("current_chunk", "")
        # current is e.g. "Chunk 02: F3 — …"
        if not current.startswith("Chunk "):
            print("ok: no current chunk in Status; nothing to verify")
            return 0
        rest = current[len("Chunk "):]
        chunk_id = rest.split(":", 1)[0].strip()

    refs = _parse_build_plan_chunk_refs(prawduct_dir, chunk_id)
    if refs["error"]:
        print(f"error: {refs['error']}", file=sys.stderr)
        return 1
    missing = _verify_chunk_refs(project_dir, refs)
    if missing:
        for m in missing:
            print(
                f"missing-ref: {m['ref']} (line {m['line_num']}, {m['kind']}): {m['reason']}",
                file=sys.stderr,
            )
        return 1
    count = len(refs["file_paths"])
    print(f"ok: chunk {chunk_id} — {count} file ref(s) verified")
    return 0


# v1.4 F4b — coverage-required gate inputs.
# Helper kept parallel to ``is_views_enabled`` in tools/lib/views.py: column-0
# key scan, fail-closed default of False. Inline rather than imported to keep
# the product-hook surface flat (no new lib dependency just for a 10-line
# scanner). Same idiom = same drift discipline as views_enabled.
def _read_bool_yaml_key(state_path: Path, key: str) -> bool:
    if not state_path.exists():
        return False
    try:
        content = state_path.read_text(encoding="utf-8")
    except OSError:
        return False
    needle = f"{key}:"
    for raw in content.splitlines():
        if raw[:1] in (" ", "\t"):
            continue
        line = raw.split("#", 1)[0].rstrip()
        if not line.startswith(needle):
            continue
        value = line.split(":", 1)[1].strip().lower()
        return value == "true"
    return False


def _coverage_resolve_base(project_dir: Path) -> tuple[str | None, str]:
    """Pick the git diff base for coverage verification. Mirrors
    ``_resolve_base`` in ``tools/test-reference-verify`` so writer (verifier)
    and reader (verify-coverage) examine the same set of changes. If the
    bases diverge, every chunk's verify-coverage would emit spurious
    missing-coverage findings on files outside the verifier's base.
    """
    for candidate in ("origin/main", "main", "HEAD~1"):
        proc = subprocess.run(
            ["git", "rev-parse", "--verify", candidate],
            cwd=str(project_dir),
            capture_output=True,
            text=True,
            timeout=30,
        )
        if proc.returncode == 0:
            return candidate, candidate
    return None, "no base candidate resolved (origin/main, main, HEAD~1 all absent)"


def _coverage_changed_files(project_dir: Path, base: str) -> list[str]:
    """Files changed between ``base`` and the working tree, union untracked.
    Mirrors ``_changed_files`` in ``tools/test-reference-verify`` — same
    union over ``git diff`` + ``git ls-files --others --exclude-standard``
    so verify-coverage sees exactly the file set the verifier scored.
    """
    proc = subprocess.run(
        ["git", "diff", "--name-only", base],
        cwd=str(project_dir),
        capture_output=True,
        text=True,
        timeout=30,
    )
    if proc.returncode != 0:
        raise RuntimeError(f"git diff failed: {proc.stderr.strip()}")
    files = {line.strip() for line in proc.stdout.splitlines() if line.strip()}

    proc2 = subprocess.run(
        ["git", "ls-files", "--others", "--exclude-standard"],
        cwd=str(project_dir),
        capture_output=True,
        text=True,
        timeout=30,
    )
    if proc2.returncode == 0:
        files.update(line.strip() for line in proc2.stdout.splitlines() if line.strip())
    return sorted(files)


def cmd_verify_coverage(project_dir: Path) -> int:
    """Critic Goal-1 helper (v1.4 F4b): when ``coverage_required: true`` in
    project-state.yaml, verify every changed file in the diff appears in
    ``.test-evidence.json``'s ``changes_referenced`` list.

    Exit codes:
      0 — check passed, or skipped (``coverage_required: false`` — the v1.4
          default; opt-in by design).
      1 — at least one changed file is missing from ``changes_referenced``,
          or a precondition failed (evidence missing/invalid, schema lacks
          F4a fields, git base unresolved).

    stderr lists each missing file with language scaled to the declared
    ``coverage_level`` — ``referenced`` (floor) vs ``executed`` (real
    coverage tool) — so the Critic can quote it directly in BLOCKING
    findings without re-deriving the wording.
    """
    prawduct_dir = get_prawduct_dir(project_dir)
    state_path = prawduct_dir / "project-state.yaml"

    if not _read_bool_yaml_key(state_path, "coverage_required"):
        print("skipped: coverage_required is false (default in v1.4)")
        return 0

    evidence_path = prawduct_dir / ".test-evidence.json"
    if not evidence_path.is_file():
        print(
            f"error: coverage_required=true but {evidence_path} is missing — "
            "run the test suite + verifier first.",
            file=sys.stderr,
        )
        return 1

    try:
        evidence = json.loads(evidence_path.read_text())
    except (json.JSONDecodeError, OSError) as exc:
        print(f"error: cannot read evidence: {exc}", file=sys.stderr)
        return 1

    if "verifier" not in evidence:
        print(
            "error: coverage_required=true but evidence lacks F4a schema "
            "(no `verifier` field) — emit coverage fields with "
            "`tools/test-reference-verify` (or a stronger product-specific "
            "verifier) before re-running.",
            file=sys.stderr,
        )
        return 1

    ok, reason = _validate_evidence_schema(evidence)
    if not ok:
        print(f"error: {reason}", file=sys.stderr)
        return 1

    coverage_level = evidence["coverage_level"]
    referenced = set(evidence.get("changes_referenced", []))

    base, base_reason = _coverage_resolve_base(project_dir)
    if base is None:
        print(f"error: cannot resolve diff base — {base_reason}", file=sys.stderr)
        return 1

    try:
        changed = _coverage_changed_files(project_dir, base)
    except RuntimeError as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 1

    missing = [f for f in changed if f not in referenced]
    if not missing:
        print(
            f"ok: {len(changed)} changed file(s) covered "
            f"(level: {coverage_level})"
        )
        return 0

    # Severity wording is per the F4a spec — the floor (``referenced``)
    # explicitly disclaims execution, the real-coverage level
    # (``executed``) asserts it. Critic quotes these lines verbatim.
    if coverage_level == "executed":
        suffix = "has no executing test."
    else:
        suffix = (
            "is not referenced by any executed test "
            "(floor check — does not prove execution)."
        )

    for m in missing:
        print(
            f"missing-coverage: {m} (coverage_level: {coverage_level}) — {suffix}",
            file=sys.stderr,
        )
    return 1


def cmd_check_cumulative_critic(project_dir: Path) -> int:
    """Structural gate for `/pr create`: require a fresh cumulative-Critic record.

    Exit 0 only when the Critic findings file:
      - exists and parses,
      - is schema-valid (``validate_critic_findings``),
      - has ``mode == _CRITIC_MODE_CUMULATIVE`` (chunk/final do not satisfy
        this gate — cumulative is specifically the ``merge-base...HEAD``
        bundle review),
      - is fresh (mtime later than the current session's ``.session-start``
        marker; stale evidence from a prior session is rejected),
      - contains no unresolved BLOCKING severity findings (WARNING and NOTE
        are advisory at the PR gate, matching the PR reviewer's semantics).

    Exit 1 otherwise. stderr names the specific check that failed so the
    caller (`/pr` skill) can present an actionable message to the user.
    """
    prawduct_dir = get_prawduct_dir(project_dir)
    findings_path = prawduct_dir / ".critic-findings.json"

    if not findings_path.is_file():
        print(
            "missing: no cumulative-Critic findings at "
            f"{findings_path}. Run /critic cumulative before opening a PR.",
            file=sys.stderr,
        )
        return 1

    if not validate_critic_findings(findings_path):
        print(
            f"invalid: {findings_path} did not pass schema validation",
            file=sys.stderr,
        )
        return 1

    try:
        data = json.loads(findings_path.read_text())
    except (json.JSONDecodeError, OSError) as exc:
        print(f"unreadable: {exc}", file=sys.stderr)
        return 1

    mode = data.get("mode")
    if mode != _CRITIC_MODE_CUMULATIVE:
        print(
            f"wrong-mode: findings mode is {mode!r}, expected cumulative "
            f"({_CRITIC_MODE_CUMULATIVE!r}). The cumulative review covers "
            "`merge-base...HEAD` — re-run /critic cumulative.",
            file=sys.stderr,
        )
        return 1

    # Freshness check — fail closed if the marker is missing or the check
    # itself crashes. Mirrors the `needs_review = True` default in
    # `_check_previous_session_gates` around line 1872; the cumulative gate
    # cannot vouch for freshness it can't verify. (See "Escape hatches in
    # classification create silent failures" in learnings.md.)
    session_start_path = prawduct_dir / ".session-start"
    if not session_start_path.is_file():
        print(
            "no-session-start: missing .prawduct/.session-start — cannot "
            "validate cumulative-Critic freshness. Run /clear to start a "
            "session, then re-run /critic cumulative.",
            file=sys.stderr,
        )
        return 1
    try:
        session_start = session_start_path.read_text().strip()
        findings_mtime = datetime.fromtimestamp(
            findings_path.stat().st_mtime, tz=timezone.utc
        ).strftime("%Y-%m-%dT%H:%M:%SZ")
    except Exception as exc:  # prawduct:ok-broad-except — fail closed if freshness inputs are unreadable
        print(
            f"freshness-check-failed: could not compare cumulative-Critic "
            f"findings vs session-start ({exc!r}). Re-run /critic cumulative.",
            file=sys.stderr,
        )
        return 1
    # Lexicographic compare on whole-second %Y-%m-%dT%H:%M:%SZ strings is
    # order-preserving — same convention as cmd_clear's gate check around
    # line 1874. Adding fractional seconds to the format would break both
    # sides; keep them in lockstep.
    if findings_mtime <= session_start:
        print(
            f"stale: cumulative findings ({findings_mtime}) "
            f"predate session-start ({session_start}). Re-run "
            "/critic cumulative in the current session before opening a PR.",
            file=sys.stderr,
        )
        return 1

    findings = data.get("findings", [])
    blocking = [f for f in findings if isinstance(f, dict) and f.get("severity") == "blocking"]
    if blocking:
        first = blocking[0].get("summary", "<no summary>")
        print(
            f"blocking: cumulative-Critic recorded {len(blocking)} BLOCKING "
            f"finding(s). First: {first}. Resolve before opening a PR.",
            file=sys.stderr,
        )
        return 1

    print(f"satisfied: cumulative-Critic record is fresh and clean ({findings_path})")
    return 0


# =============================================================================
# main
# =============================================================================


def cmd_regen_views(project_dir: Path) -> int:
    """Regenerate derived views from the change-log canonical store.

    Three views are produced in one pass when `views_enabled: true`:

      1. Build-plan `## Status` block — checkbox flips from `status=shipped` tags
      2. Release-notes view — `.prawduct/release-notes.md` from `release=` tags
      3. Scope-rollup view — `scope_rollups:` block in `project-state.yaml`
         from `scope=` tags

    No-op when `views_enabled: true` is absent (enabled by default in v1.4;
    sync auto-enables for existing repos and `false` is the explicit opt-out).

    Exit codes:
      0 - success (views disabled, no changes needed, or regen applied cleanly)
      2 - I/O or schema error (missing change-log or build-plan)
    """
    # Ensure tools/ is on sys.path so `from lib import views` resolves both
    # when invoked as a script and when imported via SourceFileLoader in tests.
    tools_dir = str(Path(__file__).resolve().parent)
    if tools_dir not in sys.path:
        sys.path.insert(0, tools_dir)
    try:
        from lib import views  # noqa: PLC0415 — lazy import keeps top-level cheap
    except ImportError:
        # Old repo that predates tools/lib propagation — degrade gracefully
        # rather than crash. Views derivation is a convenience; Status can be
        # updated by hand until the next framework sync ships tools/lib.
        print(
            "NOTE: regen-views needs tools/lib (not present in this repo). "
            "Run a framework sync to install it; Status not auto-regenerated.",
            file=sys.stderr,
        )
        return 0

    prawduct_dir = get_prawduct_dir(project_dir)
    try:
        enabled, results = views.plan_regen(prawduct_dir)
    except FileNotFoundError as e:
        msg = str(e)
        if "change-log" in msg:
            print(f"ERROR: {msg}", file=sys.stderr)
        elif "build-plan" in msg:
            print(f"ERROR: {msg}", file=sys.stderr)
        else:
            print(f"ERROR: {msg}", file=sys.stderr)
        return 2
    except OSError as e:
        print(f"ERROR reading view inputs: {e}", file=sys.stderr)
        return 2

    if not enabled:
        print("Views disabled (set views_enabled: true in project-state.yaml).")
        return 0

    try:
        views.apply_regen(prawduct_dir, results)
    except OSError as e:
        print(f"ERROR writing view output: {e}", file=sys.stderr)
        return 2

    for r in results:
        print(r.summary)
    return 0


def cmd_check_operator_verification(project_dir: Path) -> int:
    """Structural gate for `/pr create`: refuse open when queue has pending entries.

    Mirrors :func:`cmd_check_cumulative_critic` semantics — exit 0 only
    when the gate is satisfied, exit 1 with stderr describing the
    blocker. The gate is only active when ``operator_verification_required:
    true`` is set in ``project-state.yaml``; otherwise the command is a
    no-op exit 0.
    """
    tools_dir = str(Path(__file__).resolve().parent)
    if tools_dir not in sys.path:
        sys.path.insert(0, tools_dir)
    try:
        from lib import operator_verification as ov  # noqa: PLC0415 — lazy import
    except ImportError:
        # Fail OPEN: this gate cannot evaluate a queue it can't load. In a repo
        # that predates tools/lib, operator-verification isn't active anyway, so
        # blocking /pr here would be a false positive. Surface a note, allow.
        print(
            "NOTE: check-operator-verification needs tools/lib (not present); "
            "gate skipped. Run a framework sync to install it.",
            file=sys.stderr,
        )
        return 0

    result = ov.run_check_operator_verification(project_dir)
    if not result["required"]:
        return 0
    if result["pending"] == 0:
        print(result["message"])
        return 0
    print(result["message"], file=sys.stderr)
    return 1


def cmd_accept_operator_verification(
    project_dir: Path, rationale: str | None
) -> int:
    """Flip all pending entries to ``accepted`` with the supplied rationale.

    Invoked by ``/pr create --accept-pending-verification "rationale"``.
    Refuses an empty or missing rationale — the work-log capture is the
    whole point of the override.
    """
    tools_dir = str(Path(__file__).resolve().parent)
    if tools_dir not in sys.path:
        sys.path.insert(0, tools_dir)
    try:
        from lib import operator_verification as ov  # noqa: PLC0415 — lazy import
    except ImportError:
        # Honest failure: this command mutates the queue, so it must not claim
        # success when its machinery is absent. Tell the user to sync.
        print(
            "error: accept-operator-verification needs tools/lib (not present "
            "in this repo). Run a framework sync to install it.",
            file=sys.stderr,
        )
        return 1

    if rationale is None or not rationale.strip():
        print(
            "error: accept-operator-verification requires a non-empty "
            "rationale argument — `/pr create --accept-pending-verification "
            '"rationale"`. The override is recorded in operator-verification.md '
            "as the work-log entry.",
            file=sys.stderr,
        )
        return 1

    result = ov.run_accept_pending(project_dir, rationale)
    if "error" in result:
        print(f"error: {result['error']}", file=sys.stderr)
        return 1

    for action in result["actions"]:
        print(action)
    for note in result["notes"]:
        print(f"  * {note}")
    return 0


def _pr_diff_is_doc_only(project_dir: Path) -> tuple[bool, str]:
    """Shared helper: is the PR diff (``merge-base...HEAD``) all ``.md``?

    Returns ``(is_doc_only, status_message)``. ``is_doc_only`` is True only
    when the diff is non-empty AND every file ends in ``.md``. The status
    message names the specific reason for False (``no-base``, ``git-failed``,
    ``empty-diff``, ``not-doc-only: <files>``) so both the CLI gate and the
    stop-hook Gate 3 can surface actionable detail without re-implementing
    the diff inspection. Base resolution mirrors ``_coverage_resolve_base``
    so the helper sees the same diff surface as the cumulative-Critic flow.
    """
    base, base_note = _coverage_resolve_base(project_dir)
    if base is None:
        return False, f"no-base: {base_note}"

    proc = subprocess.run(
        ["git", "diff", "--name-only", f"{base}...HEAD"],
        cwd=str(project_dir),
        capture_output=True,
        text=True,
        timeout=30,
    )
    if proc.returncode != 0:
        return False, f"git-failed: git diff {base}...HEAD failed: {proc.stderr.strip()}"

    files = [line.strip() for line in proc.stdout.splitlines() if line.strip()]
    if not files:
        return False, f"empty-diff: no files changed in {base}...HEAD"

    non_md = [f for f in files if not f.endswith(".md")]
    if non_md:
        sample = ", ".join(non_md[:3])
        more = f" (+{len(non_md) - 3} more)" if len(non_md) > 3 else ""
        return False, f"not-doc-only: PR includes non-.md files: {sample}{more}"

    return True, f"doc-only: {len(files)} file(s) in {base}...HEAD all .md"


def cmd_check_pr_doc_only(project_dir: Path) -> int:
    """Fast-path gate for `/pr create`: report whether the PR diff is doc-only.

    Exit 0 when every file in ``merge-base...HEAD`` ends in ``.md`` and the
    diff is non-empty — the `/pr` skill uses this to skip the cumulative-
    Critic and PR-reviewer gates, mirroring the session-end stop-hook
    behavior (`_session_changes_are_doc_only`) at the PR boundary. The
    stop hook's PR-review evidence gate (Gate 3) consults the same helper
    so a doc-only PR doesn't get blocked at session end for missing
    evidence — symmetric behavior across both gates.

    Exit 1 otherwise (any non-``.md`` file, empty diff, no resolvable base
    branch, or git failure). Fails closed: when the gate cannot be evaluated,
    fall through to the full review path rather than silently skipping it.
    """
    is_doc_only, status = _pr_diff_is_doc_only(project_dir)
    if is_doc_only:
        print(
            f"{status} — cumulative-Critic and PR-reviewer gates may be skipped."
        )
        return 0
    suffix = (
        ". Doc-only fast-path is not applicable."
        if status.startswith("empty-diff")
        else ". Falling back to full review path."
        if status.startswith(("no-base", "git-failed"))
        else ". Full review required."
    )
    print(f"{status}{suffix}", file=sys.stderr)
    return 1


def _pr_diff_is_trivial(project_dir: Path) -> tuple[bool, str]:
    """Shared helper: is every commit on ``merge-base...HEAD`` fileset-
    eligible per Chunk 04's ``Type: trivial`` path bounds?

    Returns ``(is_trivial, status_message)``. ``is_trivial`` is True only
    when at least one commit exists ahead of base AND every commit's
    file changes satisfy ``_classify_trivial_change``. The first
    violating commit short-circuits with a reason naming the SHA and
    the specific bound (e.g.
    ``"not-trivial: commit a1b2c3d agent-file-edited: agents/foo.md"``).

    Mirrors ``_pr_diff_is_doc_only`` at the PR boundary — same base
    resolution via ``_coverage_resolve_base``, same fail-closed posture
    on missing base / git failure / empty diff. Does NOT re-validate
    rationale fit; that's the per-chunk Critic's job at chunk-mode
    review time. This helper trusts that every chunk's rationale was
    Critic-passed and only checks the structural file-set bounds.

    Per-commit (not cumulative) walk: a PR that adds an ``agents/``
    file in commit 1 and removes it in commit 2 has an empty cumulative
    diff but is NOT fast-path eligible — the commit 1 violation is
    real signal that the work crossed a catastrophic-blast-radius
    boundary at least once during the build.
    """
    base, base_note = _coverage_resolve_base(project_dir)
    if base is None:
        return False, f"no-base: {base_note}"

    # `git log --name-status --format=%H` emits one commit SHA per line
    # followed by one tab-separated <status>\t<path> (or
    # <status>\t<src>\t<dst> for renames) line per changed file, with
    # a blank line between commits. Reverse order is irrelevant — we
    # short-circuit on first violation regardless.
    proc = subprocess.run(
        [
            "git",
            "log",
            "--name-status",
            "-M",  # enable rename detection so test-renamed-out-of-tests/ trips
            "--format=%H",
            f"{base}..HEAD",
        ],
        cwd=str(project_dir),
        capture_output=True,
        text=True,
        timeout=30,
    )
    if proc.returncode != 0:
        return False, f"git-failed: git log {base}..HEAD failed: {proc.stderr.strip()}"

    lines = proc.stdout.splitlines()
    if not any(line.strip() for line in lines):
        return False, f"empty-diff: no commits ahead of {base}"

    current_sha: str | None = None
    commits_seen = 0
    for raw in lines:
        line = raw.rstrip()
        if not line:
            continue
        # A bare 40-hex line is a commit header.
        if len(line) == 40 and all(c in "0123456789abcdef" for c in line):
            current_sha = line
            commits_seen += 1
            continue
        # Otherwise it's a name-status entry for the current commit.
        if current_sha is None:
            continue
        parts = line.split("\t")
        if len(parts) < 2:
            continue
        status = parts[0].strip()
        if not status:
            continue
        # R<score> / C<score> rows are: <status>\t<src>\t<dst>
        src_path: str | None = None
        if status[0] in ("R", "C") and len(parts) >= 3:
            src_path = parts[1].strip()
            path = parts[2].strip()
        else:
            path = parts[-1].strip()
        if not path:
            continue
        # v1.5.1 Chunk 04(b): metadata-path filtering lives inside
        # `_classify_trivial_change` (handles both src and dst). Single
        # check site = no drift between this PR-boundary gate and the
        # stop-hook gate in `_is_trivial_fileset_eligible`.
        is_addition = status == "A"
        is_deletion = status == "D"
        violation = _classify_trivial_change(
            path=path,
            src_path=src_path,
            is_addition=is_addition,
            is_deletion=is_deletion,
        )
        if violation is not None:
            return False, f"not-trivial: commit {current_sha[:7]} {violation}"

    return True, (
        f"trivial: {commits_seen} commit(s) ahead of {base} all fileset-eligible"
    )


def cmd_check_pr_trivial(project_dir: Path) -> int:
    """Fast-path gate for `/pr create`: report whether every commit in
    ``merge-base...HEAD`` is fileset-eligible per Chunk 04's
    ``Type: trivial`` path bounds.

    Exit 0 when at least one commit exists ahead of base AND every
    commit clears the bounds — the `/pr` skill skips the cumulative-
    Critic and PR-reviewer gates in this case. This is the PR-boundary
    parallel to the doc-only fast-path: same fail-closed shape, same
    Gate-3 alignment at session end.

    Exit 1 otherwise (any commit touches ``agents/``/``methodology/``/
    ``templates/``/``CLAUDE.md``, adds a new file, removes a test
    file, empty diff, no resolvable base, or git failure). Fails
    closed: when the gate cannot be evaluated, fall through to the
    full review path rather than silently skipping it.
    """
    is_trivial, status = _pr_diff_is_trivial(project_dir)
    if is_trivial:
        print(
            f"{status} — cumulative-Critic and PR-reviewer gates may be skipped."
        )
        return 0
    suffix = (
        ". Trivial fast-path is not applicable."
        if status.startswith("empty-diff")
        else ". Falling back to full review path."
        if status.startswith(("no-base", "git-failed"))
        else ". Full review required."
    )
    print(f"{status}{suffix}", file=sys.stderr)
    return 1


def cmd_compute_verify_resolutions_scope(project_dir: Path) -> int:
    """Print the file scope a ``/critic verify-resolutions`` pass should review.

    Thin CLI wrapper around ``_compute_verify_resolutions_scope`` (v1.5
    Chunk 02) so the Critic SKILL can call the canonical helper instead
    of reimplementing the widening-threshold prose. Defense-in-depth: the
    Critic's verify-resolutions invocation now computes the same scope
    the stop-hook gate enforces; drift between the two is no longer
    possible (v1.5.1 Chunk 03 — backlog 2026-05-21).

    Output format:
      stdout: one file path per line (the union of prior ``files_reviewed``
              and files-changed-since-``commit_reviewed``, sorted).
              Empty stdout when the helper returns no scope.
      stderr: one line with the helper's reason string.

    Exit codes:
      0 - scope computed; stdout lists files; stderr reason starts ``ok:``.
      1 - cannot compute (no prior findings, no ``commit_reviewed``,
          unreadable findings, no actionable findings, unresolved commit,
          git failure, invalid files_reviewed). The Critic should fall
          back to ``/critic chunk`` or ``/critic final`` and record
          ``mode_chosen_by: "fallback-no-prior-findings"`` (or similar).
      2 - scope widened past the demotion threshold
          (``len(delta) > 2 * len(prior) + 5``). The Critic should fall
          back to ``/critic final`` and record
          ``mode_chosen_by: "fallback-scope-widened"``.
    """
    prawduct_dir = get_prawduct_dir(project_dir)
    scope, reason = _compute_verify_resolutions_scope(prawduct_dir, project_dir)
    if scope:
        for f in scope:
            print(f)
    print(reason, file=sys.stderr)
    if reason.startswith("ok:"):
        return 0
    if reason.startswith("scope-widened:"):
        return 2
    return 1


def cmd_infer_critic_mode(project_dir: Path, args: str | None) -> int:
    """Print ``<mode>|<rationale>`` for the inferred ``/critic`` mode.

    Thin wrapper around ``tools.lib.critic_mode.infer_mode`` (v1.5
    Chunk 03). The subcommand exists so the Critic SKILL can call
    inference without expanding its ``allowed-tools`` to ``Bash(python3
    -c *)`` — keeping the structural "no arbitrary code execution"
    constraint enforceable.

    Output format: a single stdout line ``<mode>|<rationale>`` where
    ``<mode>`` is one of the short tokens (``chunk`` / ``final`` /
    ``cumulative`` / ``verify-resolutions``) and ``<rationale>`` is the
    human-readable explanation suitable for both stdout reporting and
    the ``mode_chosen_by`` field in ``.critic-findings.json``.

    Fail-safe: when ``tools/lib`` is absent (product repos that
    haven't yet received the lib propagation), returns ``final|
    fallback-no-tools-lib`` so the Critic falls through to thorough
    review rather than crashing. Same fail-safe direction as the
    SKILL's "missing/unrecognized mode → final" rule.
    """
    tools_dir = str(Path(__file__).resolve().parent)
    if tools_dir not in sys.path:
        sys.path.insert(0, tools_dir)
    try:
        from lib import infer_mode  # noqa: PLC0415 — lazy keeps top-level cheap
    except ImportError:
        print("final|fallback-no-tools-lib")
        return 0
    mode, rationale = infer_mode(project_dir, args)
    print(f"{mode}|{rationale}")
    return 0


def cmd_advisory(project_dir: Path, argv: list[str]) -> int:
    """Dispatch the ``/prawduct-advisory`` management CLI (spec §6, Chunk 05).

    Thin wrapper around ``tools.lib.advisory_cmd.run`` — the subcommand exists so
    the skill's ``allowed-tools`` can be scoped to ``Bash(python3 tools/product-hook
    advisory*)`` instead of arbitrary code execution, mirroring the
    ``infer-critic-mode`` pattern. ``argv`` is ``sys.argv[2:]`` (everything after
    ``advisory``).

    Fail-safe: when ``tools/lib`` is absent (a product repo that has not yet
    received the lib propagation), print a clear message and return 1 rather than
    crashing — the advisory CLI is informational, never a gate.
    """
    tools_dir = str(Path(__file__).resolve().parent)
    if tools_dir not in sys.path:
        sys.path.insert(0, tools_dir)
    try:
        from lib import advisory_cmd  # noqa: PLC0415 — lazy keeps top-level cheap
    except ImportError:
        print("advisory CLI unavailable (tools/lib not present in this repo)", file=sys.stderr)
        return 1
    return advisory_cmd.run(project_dir, argv)


_USAGE = (
    "Usage: product-hook "
    "{clear|stop|test-status|validate-evidence|check-cumulative-critic|"
    "verify-chunk-refs [chunk_id]|verify-coverage|"
    "check-operator-verification|accept-operator-verification <rationale>|"
    "check-pr-doc-only|check-pr-trivial|regen-views|infer-critic-mode [args]|"
    "compute-verify-resolutions-scope|advisory <subcmd> [args]}"
)


def main() -> int:
    if len(sys.argv) < 2:
        print(_USAGE, file=sys.stderr)
        return 1

    command = sys.argv[1]
    project_dir = get_project_dir()

    if command == "clear":
        return cmd_clear(project_dir)
    elif command == "stop":
        return cmd_stop(project_dir)
    elif command == "test-status":
        return cmd_test_status(project_dir)
    elif command == "validate-evidence":
        return cmd_validate_evidence(project_dir)
    elif command == "check-cumulative-critic":
        return cmd_check_cumulative_critic(project_dir)
    elif command == "verify-chunk-refs":
        chunk_id = sys.argv[2] if len(sys.argv) > 2 else None
        return cmd_verify_chunk_refs(project_dir, chunk_id)
    elif command == "verify-coverage":
        return cmd_verify_coverage(project_dir)
    elif command == "check-operator-verification":
        return cmd_check_operator_verification(project_dir)
    elif command == "accept-operator-verification":
        rationale = sys.argv[2] if len(sys.argv) > 2 else None
        return cmd_accept_operator_verification(project_dir, rationale)
    elif command == "check-pr-doc-only":
        return cmd_check_pr_doc_only(project_dir)
    elif command == "check-pr-trivial":
        return cmd_check_pr_trivial(project_dir)
    elif command == "regen-views":
        return cmd_regen_views(project_dir)
    elif command == "infer-critic-mode":
        args = " ".join(sys.argv[2:]) if len(sys.argv) > 2 else None
        return cmd_infer_critic_mode(project_dir, args)
    elif command == "compute-verify-resolutions-scope":
        return cmd_compute_verify_resolutions_scope(project_dir)
    elif command == "advisory":
        return cmd_advisory(project_dir, sys.argv[2:])
    else:
        print(_USAGE, file=sys.stderr)
        return 1


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