#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Mechanical validator for the Agentic SDLC skill.

Commands:
  check      single closure gate: validate + stale in one command (exit 1 if either fails)
  validate   verify the structural coherence of ai_docs/ (exit 1 on errors; --strict also
             fails on warnings or on a missing ai_docs/, for CI)
  index      regenerate the generated indexes: strategic/features_history.md (from the
             frontmatter of ANALYSIS_*.md files) and ai_docs/INDEX.md (manifest of canonical docs)
  stale      list areas modified after the last analysis recorded in audit_plan.md (exit 1 if any)
  mark       record paths as ANALYZED with the current reference (git hash, else UTC timestamp)
  gate       PreToolUse hook: block writes on protected paths without an IN_PROGRESS ANALYSIS (exit 2)

Hybrid/devPNT mode: pass --hybrid explicitly on check/stale (skips audit-plan
staleness, delegated to devPNT/KL) and on gate (also unlocks when an approved
E-TDD shadow, solutions/SHADOW_*tdd*.md, exists).

Canonical language is English. Legacy Italian frontmatter keys (stato, livello,
data_inizio, data_fine) and section headings are still accepted for existing projects,
but are deprecated: new documents should use the English forms.

Standard library only (Python >= 3.8). Windows and POSIX compatible.
"""
import argparse
import hashlib
import json
import os
import re
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path

VALID_STATES = {"PLANNED", "IN_PROGRESS", "COMPLETED", "CANCELLED"}
VALID_LEVELS = {"L1", "L2", "L3", "SPIKE"}
VISION_FILES = ("project_vision.md", "roadmap.md", "principles.md")
SKIP_DIRS = {".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv",
             "dist", "build", ".idea", ".vs", "ai_docs"}
INDEX_HEADER = ("<!-- GENERATED by sdlc_check.py index - do not edit by hand. "
                "Source of truth: frontmatter of the ANALYSIS_*.md files -->")
MANIFEST_HEADER = ("<!-- GENERATED by sdlc_check.py index - do not edit by hand. "
                   "Source of truth: the headers of the canonical documents in ai_docs/. -->")
# Directories whose .md files are durable canonical documents: manifested in INDEX.md.
# audit/ and solutions/ stay discovery-by-grep (session / process artifacts), not manifested.
MANIFEST_DIRS = ("vision", "reference", "architecture", "functional", "strategic")
# Recognized states: canonical docs (CURRENT/SUPERSEDED/...), vision (DRAFT/APPROVED),
# ADR (Accepted/Proposed/Rejected). Union, to avoid false warnings on conventions in use.
CANONICAL_STATES = {"CURRENT", "SUPERSEDED", "DRAFT", "DEPRECATED",
                    "APPROVED", "ACCEPTED", "PROPOSED", "REJECTED"}
GENERATED_DOCS = {"features_history.md", "INDEX.md"}  # generated: never manifest entries
MTIME_GRACE = timedelta(seconds=2)
GUIDE_INDEX_HEADER = ("<!-- GENERATED by sdlc_check.py index - do not edit by hand. "
                      "Source of truth: the headers of the GUIDE_*.md files in ai_docs/reference/. -->")
GUIDE_PROVENANCE_KEYS = ("source", "distilled_from", "source_hash")  # source_version optional
# a guide section is "covered" when it carries a source marker or an explicit gap marker
GUIDE_MARKER_RE = re.compile(r"\[(?:source:[^\]]+|not covered by source)\]")
# Agent-global KB (Feature B unit 2): ONE client-agnostic root under home.
# AGENTIC_SDLC_KB_ROOT env var is a TEST/CI seam only (scenario battery must
# not touch the real user KB); the documented product path is fixed.
DEFAULT_KB_ROOT = Path(os.environ.get("AGENTIC_SDLC_KB_ROOT", "")) if os.environ.get("AGENTIC_SDLC_KB_ROOT") else Path.home() / ".agentic-sdlc"
# Subagent Execution (Feature A): a PLAN_[feature].md task must carry these keys,
# plus at least one of paths/produces (checked separately in cmd_plan).
PLAN_TASK_REQUIRED = ("id", "title", "verify")

# Deprecated Italian frontmatter keys, mapped to the canonical English ones.
LEGACY_KEYS = {"stato": "status", "livello": "level",
               "data_inizio": "start_date", "data_fine": "end_date"}

# ANALYSIS sections: (canonical English heading, legacy Italian heading).
SECURITY_SECTION = ("## Security", "## Sicurezza")
ANALYSIS_SECTIONS = (
    ("## Objective", "## Obiettivo"),
    ("## Feature Vision", "## Vision della Feature"),
    ("## Impact", "## Impatto"),
    ("## Action Plan", "## Piano d'Azione"),
    ("## Test Strategy", "## Strategia di Test"),
    ("## Diary", "## Diario"),
)

try:
    sys.stdout.reconfigure(encoding="utf-8", errors="replace")
    sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except Exception:
    pass


# --- orient (SessionStart hook) ---
# Fixed, hard-coded doc set (label, path-relative-to-root). No content- or
# user-derived paths -> no traversal input (P-TM T3); confine_under is
# defense-in-depth. Emitted at session start by the orient subcommand.
ORIENT_DOCS = [
    ("Reading guide (README)", "ai_docs/README.md"),
    ("Canonical manifest (INDEX)", "ai_docs/INDEX.md"),
    ("Guide router (when-to-consult)", "ai_docs/reference/INDEX.md"),
    ("Last session handoff", "ai_docs/audit/handoff.md"),
]
ORIENT_PER_DOC_CHARS = 6000     # per-doc truncation
ORIENT_MAX_TOTAL_CHARS = 16000  # total ingestion cap (P-TM T2); tunable


# ----------------------------------------------------------------- utilities

def utc_now_iso():
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def find_project_root(start=None):
    cur = Path(start or os.getcwd()).resolve()
    for p in [cur] + list(cur.parents):
        if (p / "ai_docs").is_dir():
            return p
    return cur


def require_ai_docs(root, command):
    """Fail fast when ai_docs/ is missing: prevents silently creating a second
    documentation root in the wrong working directory."""
    if not (root / "ai_docs").is_dir():
        print(f"[ERROR] {root / 'ai_docs'} not found: refusing to run '{command}' here. "
              "Run agentic-sdlc-init first, or pass --root <project_root>.")
        return False
    return True


def confine_under(base, rel):
    """Fail-closed path confinement: resolve `rel` under `base` and require the
    result to stay inside `base`. Returns None (reject) if `rel` is absolute,
    contains a '..' part, or resolves outside `base` (including an OSError
    during resolution, e.g. an unresolvable/reparse-point path on Windows).
    Single source for path confinement (T2/T3): reused by check_kb_collisions'
    `overrides:` check and cmd_validate's `distilled_from` check, and by the
    new `plan` command's paths/consumes/produces/guides confinement."""
    p = Path(rel)
    if p.is_absolute() or ".." in p.parts:
        return None
    try:
        t = (base / rel).resolve()
        t.relative_to(base.resolve())
        return t
    except (ValueError, OSError):
        return None


def read_text(path):
    # utf-8-sig: strips a leading BOM (files authored on Windows) so the
    # frontmatter '---' on line 0 stays recognizable; reads plain utf-8 otherwise.
    return path.read_text(encoding="utf-8-sig", errors="replace")


def sha256_file(path):
    # CRLF->LF before hashing: a Windows checkout with core.autocrlf=true
    # rewrites snapshot files, and a raw-byte hash would flag every guide
    # [stale] on a fresh clone. Recorded hashes are LF-based, so normalizing
    # maps CRLF copies back to the same digest.
    h = hashlib.sha256()
    h.update(path.read_bytes().replace(b"\r\n", b"\n"))
    return h.hexdigest()


def parse_iso(value):
    if not value:
        return None
    try:
        dt = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
        if dt.tzinfo is None:
            dt = dt.replace(tzinfo=timezone.utc)
        return dt
    except ValueError:
        return None


def norm_text(s):
    return "\n".join(line.rstrip() for line in s.strip().splitlines())


def load_frontmatter(lines):
    meta = {}
    if not lines or lines[0].strip() != "---":
        return meta
    for line in lines[1:60]:
        if line.strip() == "---":
            break
        m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", line)
        if m:
            meta[m.group(1).strip().lower()] = m.group(2).strip()
    # Legacy Italian keys: accepted, normalized to canonical English (deprecated).
    for legacy, canon in LEGACY_KEYS.items():
        if legacy in meta and canon not in meta:
            meta[canon] = meta[legacy]
    return meta


def is_shadow(path, first_line):
    """A shadow mirror of a devPNT-governed document, not an authoritative ANALYSIS.
    Recognized by filename (SHADOW_*) or by the marker comment on the FIRST line
    (legacy shadows saved under an ANALYSIS_* name)."""
    return path.name.startswith("SHADOW") or first_line.lstrip().startswith("<!-- SHADOW")


def list_analyses(root):
    """Returns [(path, frontmatter, text)] for the ANALYSIS_*.md files (shadows excluded)."""
    sol = root / "ai_docs" / "solutions"
    out = []
    if not sol.is_dir():
        return out
    for p in sorted(sol.glob("ANALYSIS_*.md")):
        text = read_text(p)
        first_line = text.splitlines()[0] if text else ""
        if is_shadow(p, first_line):
            continue
        out.append((p, load_frontmatter(text.splitlines()), text))
    return out


def has_etdd_shadow(root):
    """True if an E-TDD shadow exported from devPNT exists in solutions/.
    In Hybrid mode the approved E-TDD (exported BEFORE implementation) is the
    design authorization that replaces the IN_PROGRESS ANALYSIS."""
    sol = root / "ai_docs" / "solutions"
    if not sol.is_dir():
        return False
    return any("tdd" in p.name.lower() for p in sol.glob("SHADOW_*.md"))


def iter_files(target):
    if target.is_file():
        yield target
        return
    for dirpath, dirnames, filenames in os.walk(target):
        dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".")]
        for name in filenames:
            yield Path(dirpath) / name


# ---------------------------------------------------------------------- git

def git_available(root):
    try:
        r = subprocess.run(["git", "rev-parse", "--is-inside-work-tree"],
                           cwd=str(root), capture_output=True, text=True, timeout=10)
        return r.returncode == 0 and r.stdout.strip() == "true"
    except Exception:
        return False


def git_head(root):
    try:
        r = subprocess.run(["git", "rev-parse", "--short=12", "HEAD"],
                           cwd=str(root), capture_output=True, text=True, timeout=10)
        return r.stdout.strip() if r.returncode == 0 else ""
    except Exception:
        return ""


def git_has_changes(root, rel_path):
    """True if there are tracked/untracked changes under rel_path."""
    try:
        rel = rel_path.replace("\\", "/")
        r = subprocess.run(["git", "status", "--porcelain", "--", rel],
                           cwd=str(root), capture_output=True, text=True, timeout=30)
        return r.returncode == 0 and bool(r.stdout.strip())
    except Exception:
        return False


def git_changed_since(root, ref, rel_path):
    """Files changed (tracked + untracked) under rel_path since ref. None if ref unresolvable."""
    try:
        r = subprocess.run(["git", "diff", "--name-only", ref, "--", rel_path],
                           cwd=str(root), capture_output=True, text=True, timeout=30)
        if r.returncode != 0:
            return None
        changed = [l.strip() for l in r.stdout.splitlines() if l.strip()]
        r2 = subprocess.run(["git", "ls-files", "--others", "--exclude-standard", "--", rel_path],
                            cwd=str(root), capture_output=True, text=True, timeout=30)
        if r2.returncode == 0:
            changed += [l.strip() for l in r2.stdout.splitlines() if l.strip()]
        return sorted(set(changed))
    except Exception:
        return None


# -------------------------------------------------------------------- index

def build_index(root):
    rows = []
    for p, meta, _ in list_analyses(root):
        rows.append((
            meta.get("id", "?"),
            meta.get("feature", p.stem.replace("ANALYSIS_", "")),
            meta.get("level", ""),
            meta.get("status", "?"),
            meta.get("start_date", ""),
            meta.get("end_date", ""),
            "solutions/" + p.name,
        ))
    rows.sort(key=lambda r: r[0])
    lines = [INDEX_HEADER,
             "# Feature History (generated)",
             "",
             "| ID | Feature | Level | Status | Started | Finished | Doc |",
             "|---|---|---|---|---|---|---|"]
    for r in rows:
        lines.append("| " + " | ".join(r) + " |")
    return "\n".join(lines) + "\n"


# "Status:"/"Stato:" line in the body (with or without ** **), prefix before the description
_STATUS_LINE = re.compile(r"^\**\s*(?:status|stato)\s*\**\s*:\s*\**\s*([A-Za-z][\w-]*)", re.I)
# pure metadata lines to skip when picking the fallback description
_META_LINE = re.compile(r"^\**\s*(date|data|task ref|version|versione|owner|autore|branch|agente|agent|created|creato|updated|aggiornato)\b", re.I)


def extract_doc_meta(path):
    """(title, description, status, supersedes) of a canonical doc.

    Recognizes TWO header conventions: the YAML-lite frontmatter
    (description/status/supersedes/title) and the in-body `**Status:** X`
    line (used by ADRs and legacy docs). As a fallback it derives the title
    from the first '# H1' and the description from the first prose line,
    skipping metadata lines.
    """
    text = read_text(path)
    lines = text.splitlines()
    meta = load_frontmatter(lines)
    body = lines
    if lines and lines[0].strip() == "---":
        for i in range(1, min(len(lines), 60)):
            if lines[i].strip() == "---":
                body = lines[i + 1:]
                break

    title = meta.get("title", "")
    if not title:
        for line in body:
            m = re.match(r"^#\s+(.*)$", line)
            if m:
                title = m.group(1).strip()
                break
    title = title or path.stem

    status = meta.get("status", "").upper()
    if not status:
        for line in body[:25]:
            m = _STATUS_LINE.match(line.strip())
            if m:
                status = m.group(1).upper()
                break

    desc = meta.get("description", "")
    if not desc:
        for line in body:
            s = line.strip()
            if not s or s.startswith("#") or s.startswith("<!--") or _META_LINE.match(s):
                continue
            if s.startswith(">"):
                s = s.lstrip(">").strip()
            m = _STATUS_LINE.match(s)
            if m:
                # "Status: X — description": keep the part after the status; if empty, skip
                rest = s[m.end():].strip(" *—–-:.")
                if not rest:
                    continue
                s = rest
            if s:
                desc = s
                break
    desc = re.sub(r"\s+", " ", desc).strip()
    if len(desc) > 160:
        desc = desc[:157].rstrip() + "..."
    return title, desc, status, meta.get("supersedes", "").strip()


def list_canonical_docs(root):
    """[(rel_to_ai_docs, path, (title, desc, status, supersedes))] for canonical docs."""
    ai = root / "ai_docs"
    out = []
    for d in MANIFEST_DIRS:
        base = ai / d
        if not base.is_dir():
            continue
        for p in sorted(base.rglob("*.md")):
            rel_parts = p.relative_to(base).parts
            if any(part.startswith(".") for part in rel_parts[:-1]):
                continue  # dot-subdirs (e.g. reference/.sources/) are never canonical
            if p.name in GENERATED_DOCS or p.name == "README.md":
                continue
            out.append((p.relative_to(ai).as_posix(), p, extract_doc_meta(p)))
    return out


def build_manifest(root):
    docs = list_canonical_docs(root)
    lines = [MANIFEST_HEADER,
             "# `ai_docs/` document index (generated)",
             "",
             "Complete manifest of the canonical documents. For the reading priority",
             "(must-reads) see the hand-curated `README.md`. The ANALYSIS history is in",
             "`strategic/features_history.md`. `audit/` and `solutions/` are discovery-by-grep,",
             "not manifested here."]
    by_dir = {}
    for rel, _, meta in docs:
        by_dir.setdefault(rel.split("/", 1)[0], []).append((rel, meta))
    for top in MANIFEST_DIRS:
        rows = by_dir.get(top)
        if not rows:
            continue
        lines += ["", f"## {top}/", "",
                  "| Document | Status | Description |", "|---|---|---|"]
        for rel, (title, desc, status, _sup) in rows:
            d = (desc or title).replace("|", "\\|")
            lines.append(f"| `{rel}` | {status or '-'} | {d} |")
    return "\n".join(lines).rstrip() + "\n"


def list_guides(root):
    """[(rel_to_ai_docs, path, meta, text)] for ai_docs/reference/GUIDE_*.md."""
    ref = root / "ai_docs" / "reference"
    out = []
    if not ref.is_dir():
        return out
    for p in sorted(ref.glob("GUIDE_*.md")):
        text = read_text(p)
        out.append((p.relative_to(root / "ai_docs").as_posix(), p,
                    load_frontmatter(text.splitlines()), text))
    return out


def check_kb_collisions(root, project_guides, errors, warnings):
    """Cross-root awareness (unit 2): project-wins precedence, declared via 'overrides:'."""
    kb_root = DEFAULT_KB_ROOT
    kb_ref = (kb_root / "ai_docs" / "reference")
    try:
        if root.resolve() == kb_root.resolve():
            return  # validating the KB itself: no self-comparison
    except OSError:
        return
    if not kb_ref.is_dir():
        return  # no KB on this machine: zero behavior change
    kb_names = {p.name for _, p, _, _ in list_guides(kb_root)}
    for rel, p, meta, _ in project_guides:
        ov = (meta.get("overrides") or "").strip()
        if ov:
            # T6: untrusted cross-root pointer — distilled_from parity, fail closed
            target = confine_under(kb_ref, ov)
            if target is None:
                errors.append(f"{rel}: overrides '{ov}' is absolute, contains '..', or escapes the KB "
                              "reference dir — rejected (fail closed)")
                continue
            if not target.is_file():
                warnings.append(f"{rel}: overrides target '{ov}' not found in KB ({kb_ref})")
        if p.name in kb_names and ov != p.name:
            warnings.append(f"{rel}: undeclared collision with KB guide '{p.name}' (project wins) — declare overrides: {p.name}")


def build_guide_index(root):
    lines = [GUIDE_INDEX_HEADER,
             "# Operative guides (generated router)",
             "",
             "One row per guide. `description` is the when-to-consult line; provenance",
             "shows what the guide was distilled from. Freshness: run `sdlc_check.py stale`.",
             "",
             "| Guide | Status | When to consult | Source | Source version |",
             "|---|---|---|---|---|"]
    for rel, p, meta, _ in list_guides(root):
        lines.append("| `{}` | {} | {} | {} | {} |".format(
            p.name, meta.get("status", "-") or "-",
            (meta.get("description", "") or "-").replace("|", "\\|"),
            (meta.get("source", "") or "-").replace("|", "\\|"),
            meta.get("source_version", "") or "-"))
    return "\n".join(lines) + "\n"


def cmd_index(root):
    if not require_ai_docs(root, "index"):
        return 1
    hist = root / "ai_docs" / "strategic" / "features_history.md"
    hist.parent.mkdir(parents=True, exist_ok=True)
    hist.write_text(build_index(root), encoding="utf-8")
    print(f"[ok] ANALYSIS index regenerated: {hist}")
    # INDEX.md only if canonical docs exist: no empty manifest on minimal projects
    if list_canonical_docs(root):
        manifest = root / "ai_docs" / "INDEX.md"
        manifest.write_text(build_manifest(root), encoding="utf-8")
        print(f"[ok] document manifest regenerated: {manifest}")
    else:
        print("[info] no canonical documents: INDEX.md not generated")
    guides = list_guides(root)
    gidx = root / "ai_docs" / "reference" / "INDEX.md"
    if guides:
        gidx.write_text(build_guide_index(root), encoding="utf-8")
        print(f"[ok] guide router regenerated: {gidx}")
    elif gidx.is_file():
        print(f"[warn]  {gidx} exists but no GUIDE_*.md found: stale router, remove or add guides")
    return 0


# ----------------------------------------------------------------- validate

def has_section(text, aliases):
    return any(a in text for a in aliases)


def cmd_validate(root, strict=False):
    errors, warnings = [], []
    ai = root / "ai_docs"
    if not ai.is_dir():
        if strict:
            print(f"[ERROR] {ai} does not exist: nothing to validate. In --strict mode this "
                  "fails so a wrong working directory cannot produce a green pipeline.")
            return 1
        print(f"[info] {ai} does not exist: nothing to validate (project without SDLC docs).")
        return 0

    # Vision: presence and declared state
    for name in VISION_FILES:
        f = ai / "vision" / name
        if not f.is_file():
            warnings.append(f"vision/{name} missing")
            continue
        head = "\n".join(read_text(f).splitlines()[:12])
        m = re.search(r"(?:Status|Stato):\s*(DRAFT|APPROVED)", head)
        if not m:
            errors.append(f"vision/{name}: missing 'Status: DRAFT|APPROVED' in the first lines")
        elif m.group(1) == "DRAFT":
            warnings.append(f"vision/{name} is DRAFT: not a gating authority, have the user validate it")

    # ANALYSIS: frontmatter and mandatory sections
    seen_ids = {}
    analyses = list_analyses(root)
    for p, meta, text in analyses:
        rel = "solutions/" + p.name
        if not meta:
            errors.append(f"{rel}: frontmatter missing")
            continue
        fid = meta.get("id")
        if not fid:
            errors.append(f"{rel}: 'id' field missing")
        elif fid in seen_ids:
            errors.append(f"{rel}: id '{fid}' duplicated (already used in {seen_ids[fid]})")
        else:
            seen_ids[fid] = rel
        status = meta.get("status", "")
        if status not in VALID_STATES:
            errors.append(f"{rel}: status '{status}' not valid ({'/'.join(sorted(VALID_STATES))})")
        if not meta.get("start_date"):
            errors.append(f"{rel}: 'start_date' missing")
        if status == "COMPLETED" and not meta.get("end_date"):
            errors.append(f"{rel}: COMPLETED without 'end_date'")
        level = meta.get("level")
        if level and level.upper() not in VALID_LEVELS:
            warnings.append(f"{rel}: level '{level}' not recognized ({'/'.join(sorted(VALID_LEVELS))})")
        if not has_section(text, SECURITY_SECTION):
            errors.append(f"{rel}: section '## Security and Threat Model' missing (mandatory)")
        for en, it in ANALYSIS_SECTIONS:
            if not has_section(text, (en, it)):
                warnings.append(f"{rel}: section '{en}' missing")

    # Generated index aligned
    hist = ai / "strategic" / "features_history.md"
    if analyses:
        if not hist.is_file():
            errors.append("strategic/features_history.md missing: run 'sdlc_check.py index'")
        elif norm_text(read_text(hist)) != norm_text(build_index(root)):
            errors.append("strategic/features_history.md not aligned with the ANALYSIS files: run 'sdlc_check.py index'")

    # Canonical document manifest aligned (Poka-Yoke: unindexed file = dirty closure)
    docs = list_canonical_docs(root)
    manifest = ai / "INDEX.md"
    if docs:
        if not manifest.is_file():
            errors.append("ai_docs/INDEX.md missing: run 'sdlc_check.py index'")
        elif norm_text(read_text(manifest)) != norm_text(build_manifest(root)):
            errors.append("ai_docs/INDEX.md not aligned with the canonical documents: run 'sdlc_check.py index'")

    # Canonical document lifecycle: declared status + supersedes coherence
    canon_status = {rel: meta[2] for rel, _, meta in docs}
    for rel, _, (title, desc, status, supersedes) in docs:
        if not status:
            warnings.append(f"{rel}: missing 'status:' in the header (CURRENT/SUPERSEDED/DRAFT/DEPRECATED)")
        elif status not in CANONICAL_STATES:
            warnings.append(f"{rel}: status '{status}' not recognized ({'/'.join(sorted(CANONICAL_STATES))})")
        if supersedes:
            base = os.path.basename(supersedes)
            for other, ost in canon_status.items():
                if (other == supersedes or other.endswith("/" + supersedes)
                        or os.path.basename(other) == base) and ost == "CURRENT":
                    warnings.append(f"{other}: still CURRENT but superseded by {rel} (set status: SUPERSEDED)")

    # Guide checks (ai_docs/reference/GUIDE_*.md): structure only — freshness is stale's job
    guides = list_guides(root)
    for rel, p, meta, text in guides:
        missing = [k for k in GUIDE_PROVENANCE_KEYS if not meta.get(k)]
        if missing:
            warnings.append(f"{rel}: guide missing provenance key(s): {', '.join(missing)}")
        # (b) per-section fidelity markers: every '## ' section body must carry a marker
        body = text.split("---", 2)[-1]
        sections = re.split(r"^##\s+", body, flags=re.M)[1:]
        unmarked = [s.splitlines()[0].strip() for s in sections if not GUIDE_MARKER_RE.search(s)]
        if unmarked:
            warnings.append(f"{rel}: section(s) without [source: ...] / [not covered by source] marker: "
                            + "; ".join(unmarked[:5]))
        # (c) distilled_from confinement — fail closed (P-TM T6, distilled_from vector)
        df = meta.get("distilled_from", "")
        if df and confine_under(root, df) is None:
            errors.append(f"{rel}: distilled_from '{df}' is absolute, contains '..', or resolves "
                          "outside the project root: rejected")
    check_kb_collisions(root, guides, errors, warnings)
    # guide-router alignment (mirror of the root-manifest check)
    gidx = root / "ai_docs" / "reference" / "INDEX.md"
    if guides:
        if not gidx.is_file():
            errors.append("ai_docs/reference/INDEX.md missing: run 'sdlc_check.py index'")
        elif norm_text(read_text(gidx)) != norm_text(build_guide_index(root)):
            errors.append("ai_docs/reference/INDEX.md not aligned with the guides: run 'sdlc_check.py index'")

    # Handoff: header and freshness
    hand = ai / "audit" / "handoff.md"
    if hand.is_file():
        m = re.search(r"(?:Date|Data):\s*(\d{4}-\d{2}-\d{2})", read_text(hand))
        if not m:
            warnings.append("audit/handoff.md without a 'Date: YYYY-MM-DD' header")
        else:
            try:
                stamp = datetime.strptime(m.group(1), "%Y-%m-%d").replace(tzinfo=timezone.utc)
                age = (datetime.now(timezone.utc) - stamp).days
                if age > 14:
                    warnings.append(f"audit/handoff.md is {age} days old: treat it as history, not current state")
            except ValueError:
                warnings.append("audit/handoff.md: date not parseable")

    for w in warnings:
        print(f"[warn]  {w}")
    for e in errors:
        print(f"[ERROR] {e}")
    print(f"\nValidation: {len(errors)} errors, {len(warnings)} warnings.")
    if strict and warnings and not errors:
        print("[strict] warnings are failures in --strict mode.")
    return 1 if errors or (strict and warnings) else 0


# ------------------------------------------------------------- audit_plan

def parse_audit_plan(root):
    f = root / "ai_docs" / "audit" / "audit_plan.md"
    rows, lines = [], []
    if f.is_file():
        lines = read_text(f).splitlines()
        for i, line in enumerate(lines):
            if not line.strip().startswith("|"):
                continue
            cells = [c.strip() for c in line.strip().strip("|").split("|")]
            if len(cells) < 2:
                continue
            if cells[0].lower() in ("path", "percorso") or set(cells[0]) <= set("-: "):
                continue
            rows.append({
                "line": i,
                "path": cells[0],
                "status": cells[1].upper(),
                "ref": cells[2] if len(cells) > 2 else "",
                "note": cells[3] if len(cells) > 3 else "",
            })
    return f, lines, rows


def cmd_stale(root, hybrid=False):
    rc = 0
    # --- guide freshness (source_hash vs snapshot) — runs in EVERY mode
    drifted = []
    for rel, p, meta, _ in list_guides(root):
        df, rec = meta.get("distilled_from", ""), meta.get("source_hash", "")
        if not df or not rec:
            continue  # structure problems are validate's job
        src = root / df
        if not src.is_file():
            print(f"[warn]  {rel}: distilled_from '{df}' not found — snapshot missing")
            rc = 1
            continue
        if sha256_file(src) != rec:
            drifted.append((rel, df))
    for rel, df in drifted:
        print(f"[stale] {rel}: source snapshot '{df}' changed since distillation — regenerate the guide")
    if drifted:
        rc = 1
    # --- audit-plan staleness — delegated to devPNT/KL in hybrid
    if hybrid:
        print("[info] hybrid mode: audit-plan staleness is delegated to devPNT/KL, skipping.")
        return rc                                  # was: implicit skip-all; guide rc survives
    f, _, rows = parse_audit_plan(root)
    if not rows:
        print(f"[info] no rows in {f}: nothing to check "
              "(audit not initialized, or Hybrid mode where mapping is delegated to devPNT).")
        return rc                                  # was: return 0 — MUST carry guide rc
    use_git = git_available(root)
    stale = []
    for row in rows:
        if row["status"] != "ANALYZED":
            continue
        rel, ref = row["path"], row["ref"]
        target = root / rel
        if not target.exists():
            print(f"[warn]  {rel}: path does not exist")
            continue
        changed = []
        if use_git and re.fullmatch(r"[0-9a-fA-F]{7,40}", ref or ""):
            res = git_changed_since(root, ref, rel.replace("\\", "/"))
            if res is None:
                print(f"[warn]  {rel}: git ref '{ref}' unresolvable, cannot evaluate")
                continue
            changed = res
        else:
            ts = parse_iso(ref)
            if ts is None:
                print(f"[warn]  {rel}: reference '{ref}' not parseable (neither git hash nor ISO UTC)")
                continue
            for fp in iter_files(target):
                mtime = datetime.fromtimestamp(fp.stat().st_mtime, tz=timezone.utc)
                if mtime > ts + MTIME_GRACE:
                    changed.append(str(fp.relative_to(root)).replace("\\", "/"))
        if changed:
            stale.append((rel, changed))

    if not stale:
        print("[ok] no analyzed area was modified after its last recorded analysis.")
        return rc                                  # was: return 0 — MUST carry guide rc
    print("Areas modified after the last recorded analysis:")
    for rel, changed in stale:
        print(f"  {rel}  ({len(changed)} files)")
        for c in changed[:10]:
            print(f"    - {c}")
        if len(changed) > 10:
            print(f"    ... and {len(changed) - 10} more")
    print("\nAfter re-analyzing, record it with: sdlc_check.py mark <path>")
    return 1                                       # stale areas dominate: rc already implied


def cmd_mark(root, paths):
    if not require_ai_docs(root, "mark"):
        return 1
    f, lines, rows = parse_audit_plan(root)
    use_git_ref = git_available(root) and not any(
        git_has_changes(root, raw.replace("\\", "/").rstrip("/")) for raw in paths
    )
    ref = git_head(root) if use_git_ref else utc_now_iso()
    by_path = {r["path"].replace("\\", "/").rstrip("/"): r for r in rows}

    if not lines:
        lines = ["# Audit Plan", "",
                 "| Path | Status | Reference | Notes |",
                 "|---|---|---|---|"]
        rows = []

    def row_text(path, note):
        return f"| {path} | ANALYZED | {ref} | {note} |"

    appended = []
    for raw in paths:
        key = raw.replace("\\", "/").rstrip("/")
        display = key + ("/" if (root / key).is_dir() else "")
        existing = by_path.get(key)
        if existing:
            lines[existing["line"]] = row_text(existing["path"], existing["note"])
            print(f"[ok] {existing['path']} -> ANALYZED ({ref})")
        else:
            appended.append(row_text(display, ""))
            print(f"[ok] {display} added as ANALYZED ({ref})")

    if appended:
        insert_at = (max(r["line"] for r in rows) + 1) if rows else len(lines)
        lines[insert_at:insert_at] = appended

    f.parent.mkdir(parents=True, exist_ok=True)
    f.write_text("\n".join(lines) + "\n", encoding="utf-8")
    return 0


def cmd_check(root, strict=False, hybrid=False):
    print("===== validate =====")
    rc_v = cmd_validate(root, strict=strict)
    print("\n===== stale =====")
    rc_s = cmd_stale(root, hybrid=hybrid)
    print(f"\ncheck: {'CLEAN' if not (rc_v or rc_s) else 'NOT CLEAN'} "
          f"(validate rc={rc_v}, stale rc={rc_s})")
    return 1 if (rc_v or rc_s) else 0


# --------------------------------------------------------------------- gate

def cmd_gate(args):
    file_path = args.file or ""
    if args.hook:
        try:
            # bytes -> utf-8-sig: the hook payload is UTF-8 JSON regardless of the
            # console code page; '-sig' strips the BOM (PowerShell pipes)
            raw = sys.stdin.buffer.read().decode("utf-8-sig", errors="replace")
            payload = json.loads(raw)
            file_path = (payload.get("tool_input") or {}).get("file_path") or ""
        except Exception:
            return 0  # unparseable input: do not block
    if not file_path:
        return 0
    root = Path(args.root).resolve() if args.root else find_project_root()
    try:
        rel = str(Path(file_path).resolve().relative_to(root)).replace("\\", "/")
    except ValueError:
        return 0  # outside the project: not this gate's concern
    if rel.startswith(("ai_docs/", "tests/", "test/")):
        return 0
    protected = [p.strip().replace("\\", "/").rstrip("/")
                 for p in (args.protected or "").split(";") if p.strip()]
    if not protected:
        return 0
    if not any(rel == p or rel.startswith(p + "/") for p in protected):
        return 0
    for _, meta, _ in list_analyses(root):
        if meta.get("status") == "IN_PROGRESS":
            return 0
    if args.hybrid and has_etdd_shadow(root):
        return 0  # Hybrid design gate: an approved E-TDD shadow authorizes the change
    if args.hybrid:
        sys.stderr.write(
            f"[sdlc gate] '{rel}' is on a protected path but no E-TDD shadow "
            "(solutions/SHADOW_*tdd*.md) exists and no ANALYSIS_*.md is IN_PROGRESS. "
            "In Hybrid mode, export the approved E-TDD shadow from devPNT before implementing.\n")
        return 2
    sys.stderr.write(
        f"[sdlc gate] '{rel}' is on a protected path but no ANALYSIS_*.md is IN_PROGRESS. "
        "Create or reactivate the analysis (agentic-sdlc Phase 3) before modifying this file.\n")
    return 2


# --------------------------------------------------------------------- plan
# Subagent Execution (Feature A). Zero-execution surface: this section and
# everything it calls MUST NOT spawn a process (no subprocess/os.system/eval/
# exec, no git_* helper). It validates a PLAN_[feature].md and prints a task
# brief as text; the orchestrator (dispatch.md) is the sole executor.

_PLAN_JSON_RE = re.compile(r"```json\s*\n(.*?)```", re.DOTALL)


def extract_plan_json(text):
    """Extract the first fenced ```json block from a PLAN_[feature].md body.
    Returns (data, "") on success, or (None, reason) on any failure. Never
    raises: a malformed or missing block is a validation failure, not a crash."""
    m = _PLAN_JSON_RE.search(text or "")
    if not m:
        return None, "no fenced ```json block found in the plan file"
    try:
        data = json.loads(m.group(1))
    except (ValueError, TypeError) as e:
        return None, f"malformed JSON in the plan block: {e}"
    if not isinstance(data, dict):
        return None, "plan JSON block must be a JSON object"
    return data, ""


def load_ledger(path):
    """Read the sidecar ledger {"<task_id>": {"status", "verify_result",
    "timestamp"}}. Absent file -> ({}, ""). Malformed/unreadable -> ({}, reason).
    Never raises, never hangs: the ledger is untrusted state read on every call."""
    if not path.is_file():
        return {}, ""
    try:
        raw = read_text(path)
        data = json.loads(raw)
    except (ValueError, TypeError, OSError) as e:
        return {}, f"ledger '{path}' unreadable/malformed, treating as empty: {e}"
    if not isinstance(data, dict):
        return {}, f"ledger '{path}' is not a JSON object, treating as empty"
    return data, ""


def _confine_or_reject(base, rel, label, rel_label, errors):
    t = confine_under(base, rel)
    if t is None:
        errors.append(f"{rel_label}: {label} '{rel}' is absolute, contains '..', or escapes "
                      f"'{base}' — rejected (fail closed)")
    return t


def _validate_plan_tasks(root, data, rel_label, errors, warnings):
    """Shared core of `plan validate`/`plan brief`: schema + confinement checks.
    Returns the task list (possibly empty) on success; errors/warnings are
    appended in place. Callers decide the exit code."""
    tasks = data.get("tasks")
    if not isinstance(tasks, list) or not tasks:
        errors.append(f"{rel_label}: 'tasks' must be a non-empty JSON array")
        return []
    ref_dir = root / "ai_docs" / "reference"
    kb_ref = DEFAULT_KB_ROOT / "ai_docs" / "reference"
    seen_ids = set()
    for i, task in enumerate(tasks):
        loc = f"{rel_label}: task[{i}]"
        if not isinstance(task, dict):
            errors.append(f"{loc}: not a JSON object")
            continue
        missing = [k for k in PLAN_TASK_REQUIRED if not task.get(k)]
        if missing:
            errors.append(f"{loc}: missing required field(s): {', '.join(missing)}")
        if not task.get("paths") and not task.get("produces"):
            errors.append(f"{loc}: must declare at least one of 'paths'/'produces'")
        tid = task.get("id")
        if tid:
            if tid in seen_ids:
                errors.append(f"{loc}: duplicate task id '{tid}'")
            seen_ids.add(tid)
        for key in ("paths", "consumes", "produces"):
            for p in (task.get(key) or []):
                _confine_or_reject(root, p, key, loc, errors)
        for g in (task.get("guides") or []):
            in_project = confine_under(ref_dir, g)
            in_kb = confine_under(kb_ref, g)
            if in_project is None and in_kb is None:
                errors.append(f"{loc}: guide '{g}' is not confined under the project reference "
                              f"dir ({ref_dir}) or the agent KB reference dir ({kb_ref}) — rejected")
    return tasks


def cmd_plan(root, args):
    """Zero-execution: validates/briefs a PLAN_[feature].md. Never spawns a
    process, never calls a git_* helper, never runs the opaque `verify` text —
    it is printed, not executed."""
    plan_path = Path(args.file)
    if not plan_path.is_absolute():
        plan_path = root / plan_path
    if not plan_path.is_file():
        sys.stderr.write(f"[plan] plan file not found: {plan_path}\n")
        return 2
    rel_label = str(plan_path)
    data, reason = extract_plan_json(read_text(plan_path))
    if data is None:
        sys.stderr.write(f"[plan] {rel_label}: {reason}\n")
        return 2

    errors, warnings = [], []
    tasks = _validate_plan_tasks(root, data, rel_label, errors, warnings)

    ledger_path = plan_path.with_name(plan_path.stem + ".ledger.json")
    ledger, ledger_reason = load_ledger(ledger_path)
    if ledger_reason:
        warnings.append(ledger_reason)
    if not errors:
        task_ids = {t.get("id") for t in tasks if isinstance(t, dict)}
        for lid in ledger:
            if lid not in task_ids:
                warnings.append(f"ledger id '{lid}' not found in {rel_label}: orphaned entry (not fatal)")

    for w in warnings:
        sys.stderr.write(f"[warn] {w}\n")
    for e in errors:
        sys.stderr.write(f"[ERROR] {e}\n")

    if args.plan_cmd == "validate":
        if errors:
            sys.stderr.write(f"\n[plan] validate: {len(errors)} errors, {len(warnings)} warnings.\n")
            return 2
        print(f"[ok] {rel_label}: plan valid ({len(tasks)} task(s), {len(warnings)} warning(s)).")
        return 0

    # brief
    if errors:
        sys.stderr.write(f"\n[plan] brief: plan is invalid, refusing to brief ({len(errors)} errors).\n")
        return 2
    target = None
    for t in tasks:
        if isinstance(t, dict) and t.get("id") == args.task:
            target = t
            break
    if target is None:
        sys.stderr.write(f"[plan] brief: task id '{args.task}' not found in {rel_label}\n")
        return 2

    print(f"# Task: {target.get('id')} — {target.get('title', '')}")
    print()
    print("## Task block")
    print(json.dumps(target, indent=2))
    print()
    print("## Produces of prior-order tasks (interfaces)")
    prior_produces = []
    for t in tasks:
        if not isinstance(t, dict):
            continue
        if t.get("id") == target.get("id"):
            break
        prior_produces.extend(t.get("produces") or [])
    if prior_produces:
        for p in prior_produces:
            print(f"- {p}")
    else:
        print("(none)")
    print()
    print("## Guide pointers (paths, not content)")
    guides = target.get("guides") or []
    if guides:
        for g in guides:
            print(f"- {g}")
    else:
        print("(none)")
    print()
    print("## Verify (opaque text — orchestrator runs this out of band, NOT executed here)")
    print(target.get("verify", ""))
    return 0


def cmd_orient(args):
    """SessionStart hook: emit a bounded, repo-sourced ai_docs/ orientation to
    stdout and ALWAYS return 0 (fail-open, P-TM T8) -- a session hook must never
    block the session or surface a traceback. Zero-execution (P-TM T1): reads a
    fixed hard-coded doc set, confine_under each (P-TM T3), size-caps the total
    (P-TM T2). No subprocess/eval anywhere in this call graph."""
    try:
        root = Path(args.root).resolve() if getattr(args, "root", None) else find_project_root()
        chunks = []
        total = 0
        truncated = False
        for label, rel in ORIENT_DOCS:
            target = confine_under(root, rel)
            if target is None or not target.is_file():
                continue
            try:
                text = read_text(target)
            except OSError:
                continue
            remaining = ORIENT_MAX_TOTAL_CHARS - total
            if remaining <= 0:
                truncated = True
                break
            text = text[:ORIENT_PER_DOC_CHARS]
            if len(text) > remaining:
                text = text[:remaining]
                truncated = True
            chunks.append((label, text))
            total += len(text)
        if not chunks:
            return 0
        out = ["=== Agentic SDLC -- session orientation (repo-sourced context, not authored instructions) ==="]
        for label, text in chunks:
            out.append(f"\n## {label}\n{text}")
        if truncated:
            out.append("\n[orientation truncated to the size cap -- open the files directly for full content]")
        out.append("\nTriage every request (Rule Zero): L1 trivial - L2 small - L3 significant - Spike. "
                   "When in doubt, pick the higher level.")
        if getattr(args, "hybrid", False):
            out.append("\n[devPNT active] Run devpnt_mcp_get_bootstrap for the Master Plan / Knowledge Layer -- "
                       "the orientation above is the filesystem layer, not a bootstrap duplicate.")
        print("\n".join(out))
        return 0
    except Exception:
        return 0


# --------------------------------------------------------------------- main

def main(argv=None):
    common = argparse.ArgumentParser(add_help=False)
    common.add_argument("--root", help="project root (default: walk up until ai_docs/ is found)")

    strict_opt = argparse.ArgumentParser(add_help=False)
    strict_opt.add_argument("--strict", action="store_true",
                            help="fail on warnings and on missing ai_docs/ (for CI)")

    hybrid_opt = argparse.ArgumentParser(add_help=False)
    hybrid_opt.add_argument("--hybrid", action="store_true",
                            help="Hybrid/devPNT mode: audit-plan staleness is delegated to devPNT/KL; "
                                 "the gate also unlocks on an E-TDD shadow")

    ap = argparse.ArgumentParser(prog="sdlc_check.py",
                                 description="Mechanical validator for Agentic SDLC")
    sub = ap.add_subparsers(dest="cmd", required=True)
    sub.add_parser("check", parents=[common, strict_opt, hybrid_opt],
                   help="closure gate: validate + stale in one command")
    sub.add_parser("validate", parents=[common, strict_opt], help="verify ai_docs/ coherence")
    sub.add_parser("index", parents=[common], help="regenerate features_history.md + ai_docs/INDEX.md")
    sub.add_parser("stale", parents=[common, hybrid_opt], help="areas modified after the last analysis")
    mp = sub.add_parser("mark", parents=[common], help="record paths as ANALYZED")
    mp.add_argument("paths", nargs="+", help="paths relative to the project root")
    gp = sub.add_parser("gate", parents=[common, hybrid_opt], help="PreToolUse hook (exit 2 = block)")
    gp.add_argument("--hook", action="store_true", help="read the hook JSON payload from stdin")
    gp.add_argument("--file", help="file path to evaluate (alternative to --hook)")
    gp.add_argument("--protected", default="", help="protected prefixes separated by ';' (e.g. \"src/auth;src/crypto\")")

    sub.add_parser("orient", parents=[common, hybrid_opt],
                   help="SessionStart hook: emit ai_docs/ orientation to stdout (fail-open, zero-execution)")

    pp = sub.add_parser("plan", parents=[common],
                        help="Subagent Execution: validate/brief a PLAN_[feature].md (zero-execution)")
    pp_sub = pp.add_subparsers(dest="plan_cmd", required=True)
    pv = pp_sub.add_parser("validate", help="schema + confinement + ledger cross-check (exit 2 on error)")
    pv.add_argument("file", help="path to the PLAN_[feature].md file")
    pb = pp_sub.add_parser("brief", help="print a task's brief to stdout (verify text is NOT executed)")
    pb.add_argument("file", help="path to the PLAN_[feature].md file")
    pb.add_argument("--task", required=True, help="task id to brief")

    args = ap.parse_args(argv)
    if args.cmd == "gate":
        return cmd_gate(args)
    if args.cmd == "orient":
        return cmd_orient(args)

    root = Path(args.root).resolve() if args.root else find_project_root()
    if args.cmd == "check":
        return cmd_check(root, strict=args.strict, hybrid=args.hybrid)
    if args.cmd == "validate":
        return cmd_validate(root, strict=args.strict)
    if args.cmd == "index":
        return cmd_index(root)
    if args.cmd == "stale":
        return cmd_stale(root, hybrid=args.hybrid)
    if args.cmd == "mark":
        return cmd_mark(root, args.paths)
    if args.cmd == "plan":
        return cmd_plan(root, args)
    return 0


if __name__ == "__main__":
    sys.exit(main())
