"""Coach service: goal tracking, stall detection, nudges."""

from __future__ import annotations

import logging
from datetime import UTC, datetime, timedelta
from pathlib import Path

from agntspace.vault.reader import VaultReader
from agntspace.vault.writer import VaultWriter

log = logging.getLogger(__name__)

_GOAL_TEMPLATE = """---
title: "{title}"
entity_type: goal
type: {goal_type}
status: active
target_date: "{target_date}"
created: "{created}"
domain: "{domain}"
success_metric: "{success_metric}"
budget: {budget}
budget_consumed: 0.0
dispatches: 0
max_attempts: {max_attempts}
attempt_failures: 0
pursuit_status: idle
trust_override: false
last_block_reason: ""
last_block_at: ""
last_block_details: ""
---

## What
{description}

## Why


## Success Criteria


## Milestones

"""


class CoachService:
    """Tracks goals, detects stalls, and generates nudges."""

    def __init__(
        self,
        vault_reader: VaultReader,
        vault_writer: VaultWriter,
        knowledge_graph: object | None = None,
    ) -> None:
        self._reader = vault_reader
        self._writer = vault_writer
        self._graph = knowledge_graph
        self._trust: object | None = None  # set post-init by main.py
        self._contract: object | None = None  # set post-init by main.py
        self._llm_mode: str | None = None  # cached, set post-init

    def _refresh_graph(self) -> None:
        """Refresh structural triples if the graph is wired."""
        if self._graph is not None:
            try:
                self._graph.refresh_structural_triples()  # type: ignore[attr-defined]
            except Exception as exc:  # pragma: no cover
                log.debug("Graph structural refresh failed: %s", exc)

    def list_goals(self, status_filter: str | None = None) -> list[dict]:
        """List all goals from vault/goals/."""
        files = self._reader.list_files("goals", "*.md")
        goals = []
        for f in files:
            try:
                doc = self._reader.read(f"goals/{f.name}")
                meta = doc.metadata
                goal_status = meta.get("status", "active")
                if status_filter and goal_status != status_filter:
                    continue
                try:
                    updated_at = datetime.fromtimestamp(
                        f.stat().st_mtime, UTC
                    ).isoformat()
                except OSError:
                    updated_at = ""
                goals.append({
                    "slug": f.stem,
                    "title": meta.get("title", f.stem),
                    "type": meta.get("type", "goal"),
                    "status": goal_status,
                    "target_date": meta.get("target_date", ""),
                    "created": meta.get("created", ""),
                    "updated_at": updated_at,
                })
            except Exception:
                continue
        return goals

    def get_goal(self, slug: str) -> dict | None:
        """Get a goal by slug."""
        path = f"goals/{slug}.md"
        if not self._reader.exists(path):
            return None
        doc = self._reader.read(path)
        return {
            "slug": slug,
            "content": doc.content,
            "metadata": doc.metadata,
        }

    def create_goal(
        self,
        title: str,
        description: str,
        goal_type: str = "goal",
        target_date: str = "",
        success_metric: str = "",
        budget: float = 0.0,
        max_attempts: int = 20,
        domain: str = "general",
    ) -> str:
        """Create a new goal file. Returns the vault path."""
        slug = title.lower().strip()
        slug = "".join(c if c.isalnum() or c == " " else "" for c in slug)
        slug = slug.replace(" ", "-")[:60].strip("-")
        path = f"goals/{slug}.md"

        now = datetime.now(UTC).strftime("%Y-%m-%d")
        content = _GOAL_TEMPLATE.format(
            title=title,
            goal_type=goal_type,
            target_date=target_date or "",
            created=now,
            description=description,
            success_metric=success_metric,
            budget=budget,
            max_attempts=max_attempts,
            domain=domain,
        )
        self._writer.write(path, content, contract_action="create_task")
        self._refresh_graph()
        return path

    def rename_goal(self, slug: str, new_title: str) -> dict:
        """Rename a goal file and rewrite references in PROJECT.md files.

        Returns {"slug": new_slug, "projects_updated": n}.
        """
        import re

        old_path = f"goals/{slug}.md"
        if not self._reader.exists(old_path):
            raise FileNotFoundError(f"Goal not found: {slug}")

        new_slug = "".join(c if c.isalnum() or c == " " else "" for c in new_title.lower().strip())
        new_slug = new_slug.replace(" ", "-")[:60].strip("-")
        if not new_slug:
            raise ValueError("New title produces an empty slug")

        doc = self._reader.read(old_path)
        content = re.sub(
            r'^title:\s*".*"',
            f'title: "{new_title}"',
            doc.raw,
            count=1,
            flags=re.MULTILINE,
        )

        if new_slug == slug:
            self._writer.write(old_path, content, contract_action="create_task")
            self._refresh_graph()
            return {"slug": slug, "projects_updated": 0}

        new_path = f"goals/{new_slug}.md"
        if self._reader.exists(new_path):
            raise ValueError(f"Goal already exists: {new_slug}")

        self._writer.write(new_path, content, contract_action="create_task")

        try:
            if self._reader.paths:
                old_physical = self._reader.paths.resolve(old_path)
            else:
                # Defensive fallback for tests / mocks that pass a bare
                # VaultReader. Try the test-convention layout
                # (``vault_dir/goals/<slug>.md``) first, then fall back
                # to the shipped sibling layout
                # (``<parent>/agntspace-goals/<slug>.md``). Production
                # always hits the ``paths.resolve`` branch above.
                test_layout = self._reader._vault_dir / old_path  # noqa: SLF001
                shipped_layout = self._reader._vault_dir.parent / "agntspace-goals" / Path(old_path).name  # noqa: SLF001
                old_physical = test_layout if test_layout.is_file() else shipped_layout
            if old_physical.is_file():
                old_physical.unlink()
        except Exception:
            pass

        projects_updated = 0
        if self._reader.paths:
            projects_dir = self._reader.paths.resolve("projects")
        else:
            # Defensive fallback for tests / mocks that pass a bare
            # VaultReader without a paths resolver. Try the
            # test-convention layout (``vault_dir/projects/``) first,
            # then fall back to the shipped layout (sibling
            # ``agntspace-projects/``). Production always hits the
            # ``paths.resolve`` branch above — this code path only
            # matters for unit tests.
            test_layout = self._reader._vault_dir / "projects"  # noqa: SLF001
            shipped_layout = self._reader._vault_dir.parent / "agntspace-projects"  # noqa: SLF001
            projects_dir = test_layout if test_layout.is_dir() else shipped_layout
        if projects_dir.is_dir():
            for project_dir in projects_dir.iterdir():
                if not project_dir.is_dir():
                    continue
                project_file = project_dir / "PROJECT.md"
                if not project_file.is_file():
                    continue
                try:
                    pdoc = self._reader.read(f"projects/{project_dir.name}/PROJECT.md")
                    goals_list = pdoc.metadata.get("related_goals", []) or []
                    if slug in goals_list:
                        new_goals = [new_slug if g == slug else g for g in goals_list]
                        yaml_list = (
                            "[" + ", ".join(f'"{g}"' for g in new_goals) + "]" if new_goals else "[]"
                        )
                        new_content = re.sub(
                            r"^related_goals:\s*.*$",
                            f"related_goals: {yaml_list}",
                            pdoc.raw,
                            count=1,
                            flags=re.MULTILINE,
                        )
                        self._writer.write(
                            f"projects/{project_dir.name}/PROJECT.md", new_content,
                            contract_action="create_task",
                        )
                        projects_updated += 1
                except Exception:
                    continue

        self._refresh_graph()
        return {"slug": new_slug, "projects_updated": projects_updated}

    def update_goal(
        self,
        slug: str,
        description: str | None = None,
        target_date: str | None = None,
    ) -> None:
        """Update a goal's editable body fields (description, target_date)."""
        import re

        path = f"goals/{slug}.md"
        if not self._reader.exists(path):
            raise FileNotFoundError(f"Goal not found: {slug}")
        doc = self._reader.read(path)
        content = doc.raw

        if target_date is not None:
            content = re.sub(
                r'^target_date:\s*".*"',
                f'target_date: "{target_date}"',
                content,
                count=1,
                flags=re.MULTILINE,
            )

        if description is not None:
            pattern = r"(## What\n)(.*?)(\n## |\Z)"
            replacement = rf"\g<1>{description}\g<3>"
            content = re.sub(pattern, replacement, content, count=1, flags=re.DOTALL)

        self._writer.write(path, content, contract_action="create_task")

    _VALID_GOAL_STATUSES = frozenset({"active", "paused", "achieved", "abandoned"})  # arch:deterministic — closed enum of goal lifecycle states; downstream filters (list_goals, pursuit checks) depend on exact membership

    def update_goal_status(self, slug: str, status: str) -> None:
        """Update a goal's status. Valid: active/paused/achieved/abandoned.

        Gap 30 fix: previously accepted any string, which broke downstream
        filters (list_goals(status_filter=X), pursuit checks assuming
        status==active). Now raises ValueError on invalid input.
        """
        import re

        if status not in self._VALID_GOAL_STATUSES:
            raise ValueError(
                f"Invalid goal status {status!r}; must be one of "
                f"{sorted(self._VALID_GOAL_STATUSES)}"
            )

        path = f"goals/{slug}.md"
        doc = self._reader.read(path)
        content = re.sub(
            r"^status:\s*\S+",
            f"status: {status}",
            doc.raw,
            count=1,
            flags=re.MULTILINE,
        )
        self._writer.write(path, content, contract_action="create_task")
        if self._graph:
            try:
                self._graph.build()
            except Exception:
                log.debug("Graph rebuild after goal status change failed", exc_info=True)

    def delete_goal(self, slug: str) -> dict:
        """Delete a goal file and remove references from all PROJECT.md files.

        Returns {"slug": slug, "projects_cleaned": n}.
        """
        import re as _re

        path = f"goals/{slug}.md"
        if not self._reader.exists(path):
            raise FileNotFoundError(f"Goal not found: {slug}")

        # Delete the file
        physical = (
            self._reader.paths.resolve(path)
            if self._reader.paths
            else self._reader._vault_dir / path  # noqa: SLF001
        )
        if physical.is_file():
            physical.unlink()

        # Clean up project references (same pattern as rename_goal)
        projects_cleaned = 0
        projects_dir = (
            self._reader.paths.resolve("projects")
            if self._reader.paths
            else self._reader._vault_dir / "projects"  # noqa: SLF001
        )
        if projects_dir.is_dir():
            for project_dir in projects_dir.iterdir():
                if not project_dir.is_dir():
                    continue
                project_file = project_dir / "PROJECT.md"
                if not project_file.is_file():
                    continue
                try:
                    pdoc = self._reader.read(f"projects/{project_dir.name}/PROJECT.md")
                    goals_list = pdoc.metadata.get("related_goals", []) or []
                    if slug in goals_list:
                        new_goals = [g for g in goals_list if g != slug]
                        yaml_list = (
                            "[" + ", ".join(f'"{g}"' for g in new_goals) + "]"
                            if new_goals
                            else "[]"
                        )
                        new_content = _re.sub(
                            r"^related_goals:\s*.*$",
                            f"related_goals: {yaml_list}",
                            pdoc.raw,
                            count=1,
                            flags=_re.MULTILINE,
                        )
                        self._writer.write(
                            f"projects/{project_dir.name}/PROJECT.md",
                            new_content,
                            contract_action="create_task",
                        )
                        projects_cleaned += 1
                except Exception:
                    continue

        self._refresh_graph()
        return {"slug": slug, "projects_cleaned": projects_cleaned}

    # ── Pursuit tracking ──

    def get_pursuable_goals(self) -> list[dict]:
        """Return active goals that have budget > 0 and are eligible for pursuit."""
        goals = self.list_goals(status_filter="active")
        pursuable = []
        for g in goals:
            path = f"goals/{g['slug']}.md"
            try:
                doc = self._reader.read(path)
            except (FileNotFoundError, OSError):
                continue
            meta = doc.metadata
            budget = float(meta.get("budget", 0))
            if budget <= 0:
                continue
            consumed = float(meta.get("budget_consumed", 0))
            pursuit_status = meta.get("pursuit_status", "idle")
            attempt_failures = int(meta.get("attempt_failures", 0))
            max_attempts = int(meta.get("max_attempts", 20))
            if pursuit_status not in ("idle", "pursuing"):
                continue
            if attempt_failures >= max_attempts:
                continue
            pursuable.append({
                **g,
                "success_metric": meta.get("success_metric", ""),
                "budget": budget,
                "budget_consumed": consumed,
                "dispatches": int(meta.get("dispatches", 0)),
                "max_dispatches": int(meta.get("max_dispatches", 50)),
                "attempt_failures": attempt_failures,
                "max_attempts": max_attempts,
                "pursuit_status": pursuit_status,
                "domain": meta.get("domain", "general"),
                "trust_override": bool(meta.get("trust_override", False)),
            })
        return pursuable

    def get_pursuit_status(self, slug: str) -> dict:
        """Return pursuit-related fields for a goal.

        Includes ``live_block`` — a dict describing the gate that would
        currently block this goal, computed from live data (trust, dispatch
        cap, budget). Set to ``None`` when the goal can be dispatched right
        now. Lets the UI explain the halt immediately, without waiting for
        the heartbeat to record ``last_block_reason`` (up to 15min lag).
        """
        path = f"goals/{slug}.md"
        if not self._reader.exists(path):
            raise FileNotFoundError(f"Goal not found: {slug}")
        doc = self._reader.read(path)
        meta = doc.metadata
        status = {
            "slug": slug,
            "success_metric": meta.get("success_metric", ""),
            "budget": float(meta.get("budget", 0)),
            "budget_consumed": float(meta.get("budget_consumed", 0)),
            "dispatches": int(meta.get("dispatches", 0)),
            "max_dispatches": int(meta.get("max_dispatches", 50)),
            "max_attempts": int(meta.get("max_attempts", 20)),
            "attempt_failures": int(meta.get("attempt_failures", 0)),
            "pursuit_status": meta.get("pursuit_status", "idle"),
            "domain": meta.get("domain", "general"),
            "trust_override": bool(meta.get("trust_override", False)),
            "last_block_reason": meta.get("last_block_reason", "") or "",
            "last_block_at": meta.get("last_block_at", "") or "",
            "last_block_details": meta.get("last_block_details", "") or "",
        }
        status["live_block"] = self._compute_live_block(status)
        return status

    def _compute_live_block(self, status: dict) -> dict | None:
        """Compute the gate that would currently block this goal.

        Mirrors the gate order in :meth:`heartbeat._try_pursue_goal` so the
        UI and the heartbeat agree on why a goal is halted. Returns None
        when the goal is not currently pursuing, or when no gate would
        block the next dispatch.
        """
        if status.get("pursuit_status") != "pursuing":
            return None

        # Gate: dispatch cap (universal, works in all modes)
        dispatches = status.get("dispatches", 0)
        max_dispatches = status.get("max_dispatches", 50)
        if dispatches >= max_dispatches:
            return {
                "reason": "dispatch_limit",
                "details": {
                    "dispatches": dispatches,
                    "max_dispatches": max_dispatches,
                },
            }

        # Gate: budget (cloud mode only; local LLMs are free)
        if self._llm_mode and self._llm_mode != "local":
            budget = status.get("budget", 0.0)
            consumed = status.get("budget_consumed", 0.0)
            if budget > 0 and consumed >= budget:
                return {
                    "reason": "budget_exhausted",
                    "details": {"spent": consumed, "budget": budget},
                }

        # Gate: trust (bypassable per goal via trust_override).
        # Mirrors _try_pursue_goal's check_goal_trust call — same signature,
        # same domain source, same goal_key — so the UI's "why halted" answer
        # agrees with the heartbeat's dispatch decision.
        if status.get("trust_override"):
            return None
        trust = self._trust
        contract = self._contract
        if not trust:
            return None
        try:
            proactivity = (
                contract.rules.proactivity if contract else "advisor"  # type: ignore[attr-defined]
            )
            goal_domain = status.get("domain", "general") or "general"
            goal_slug = status.get("slug", "")
            check = trust.check_goal_trust(  # type: ignore[attr-defined]
                goal_domain, proactivity,
                is_autonomous=True,
                goal_key=goal_slug,
            )
            from agntspace.trust.service import _TRUST_LEVEL_INDEX
            effective_idx = _TRUST_LEVEL_INDEX.get(check.effective_level, 0)
            if effective_idx < 2:  # Below Assistant
                return {
                    "reason": "trust_too_low",
                    "details": {
                        "domain": status.get("domain", "general"),
                        "effective_level": check.effective_level,
                        "domain_trust": check.domain_trust,
                        "proactivity": proactivity,
                        "effective_score": getattr(check, "effective_score", None),
                    },
                }
        except Exception:
            log.debug("live_block trust check failed for %s", status.get("slug"), exc_info=True)
            return None
        return None

    # ── Outcome taxonomy (ternary + "why" fields) ──
    # keep    = this dispatch moved us toward the success metric
    #           (the agent keeps the work and future dispatches build on it).
    # discard = this dispatch produced work but didn't move the metric; we
    #           throw it away (the agent identifies this itself).
    # crash   = this dispatch errored out or produced unusable output.
    # ""      = legacy / unstructured — the caller didn't specify, so we fall
    #           back to ``succeeded`` semantics (True → keep, False → crash).
    _OUTCOMES: tuple[str, ...] = ("keep", "discard", "crash")  # arch:deterministic — outcome enum mirrored in coach/dispatch_outcome.py

    def record_pursuit_attempt(
        self, slug: str, succeeded: bool, cost: float, note: str = "",
        *,
        outcome: str = "",
        hypothesis: str = "",
        action: str = "",
        observation: str = "",
        metric_delta: str = "",
        reason: str = "",
    ) -> dict:
        """Update pursuit tracking fields after an agent dispatch.

        Writes a structured dispatch block to the ``## Pursuit Log`` section
        of the goal file — per-experiment record with a ternary outcome
        (keep / discard / crash) plus an explicit "why" trail
        (hypothesis, action, observation, metric_delta, reason). Fields:

        - ``outcome``: ``keep`` | ``discard`` | ``crash`` | ``""`` (legacy).
          If empty, we fall back to ``succeeded`` (``True`` → keep,
          ``False`` → crash).
        - ``hypothesis``: why the agent picked this step (prior reasoning).
        - ``action``: what was actually attempted this dispatch.
        - ``observation``: raw result / what happened.
        - ``metric_delta``: delta vs. ``success_metric`` (string, since
          goals aren't necessarily scalar).
        - ``reason``: why the agent classified this as keep/discard/crash.
        - ``note``: free-text fallback — used when no structured fields
          are present (preserves back-compat with older callers).

        When structured fields are set, a multi-line YAML-style block is
        written:

            - 2026-04-19 14:30 — #7 keep ($0.03)
              hypothesis: ...
              action: ...
              observation: ...
              metric_delta: ...
              reason: ...

        When only ``note`` is set (or nothing), the old one-liner format
        is preserved — existing tests and callers continue to work
        unchanged.

        Returns a dict with the resolved fields so the caller can feed
        ``outcome`` into the Bayesian trust signal pipeline
        (``keep`` → ``good``, ``discard`` → ``needs_improvement``,
        ``crash`` → ``redo``).
        """
        import re as _re

        path = f"goals/{slug}.md"
        if not self._reader.exists(path):
            raise FileNotFoundError(f"Goal not found: {slug}")
        doc = self._reader.read(path)
        meta = doc.metadata
        content = doc.raw

        # Normalize outcome: explicit wins; otherwise derive from succeeded.
        normalized_outcome = (outcome or "").strip().lower()
        if normalized_outcome not in self._OUTCOMES:
            normalized_outcome = "keep" if succeeded else "crash"

        # A discard outcome means the agent PRODUCED work but threw it away
        # (a deliberate retreat). That's not the same as a crash, and it
        # shouldn't count against ``attempt_failures`` (which gates the
        # futility pause). Only ``crash`` or legacy ``succeeded=False``
        # increments the failure counter.
        counted_as_failure = normalized_outcome == "crash"

        dispatches = int(meta.get("dispatches", 0)) + 1
        consumed = float(meta.get("budget_consumed", 0)) + cost
        failures = (
            0 if not counted_as_failure
            else int(meta.get("attempt_failures", 0)) + 1
        )

        for field, val in [
            ("dispatches", str(dispatches)),
            ("budget_consumed", f"{consumed:.2f}"),
            ("attempt_failures", str(failures)),
        ]:
            content = _re.sub(
                rf"^{field}:\s*.*$", f"{field}: {val}",
                content, count=1, flags=_re.MULTILINE,
            )

        # Pass the ORIGINAL outcome string (not the normalized fallback)
        # to the log helper so legacy callers that didn't specify keep
        # getting the old ✓/✗ markers in the log. The normalized outcome
        # is still what we return to the caller for Bayesian trust feeding.
        content = self._append_pursuit_log_entry(
            content, dispatches=dispatches, succeeded=succeeded,
            cost=cost, note=note,
            outcome=(outcome or "").strip().lower(),
            hypothesis=hypothesis, action=action,
            observation=observation, metric_delta=metric_delta,
            reason=reason,
        )

        self._writer.write(path, content, contract_action="create_task")

        return {
            "dispatches": dispatches,
            "succeeded": succeeded,
            "cost": cost,
            "outcome": normalized_outcome,
            "counted_as_failure": counted_as_failure,
        }

    def _append_pursuit_log_entry(
        self, content: str, *, dispatches: int, succeeded: bool,
        cost: float, note: str,
        outcome: str = "",
        hypothesis: str = "",
        action: str = "",
        observation: str = "",
        metric_delta: str = "",
        reason: str = "",
    ) -> str:
        """Append a dispatch record to the ``## Pursuit Log`` section.

        Writes one of two shapes depending on whether structured fields were
        provided:

        - **Structured** (any of hypothesis/action/observation/metric_delta/
          reason is non-empty): header line + indented ``key: value`` lines,
          each on its own line. Readable by a human *and* parseable by the
          unit tests.
        - **Legacy one-liner**: ``- YYYY-MM-DD HH:MM — #N marker ($X.XX) — note``
          preserved for back-compat with older callers that only pass ``note``.

        The section is created at the end of the file if missing.
        """
        from datetime import datetime as _dt
        ts = _dt.now().strftime("%Y-%m-%d %H:%M")

        # Header marker — ternary outcome if set explicitly,
        # legacy "progress / no progress" otherwise.
        if outcome in self._OUTCOMES:
            marker = outcome  # keep | discard | crash
        else:
            marker = "✓ progress" if succeeded else "✗ no progress"
        cost_str = f" (${cost:.2f})" if cost > 0 else ""

        has_structured = any([hypothesis, action, observation, metric_delta, reason])

        if has_structured:
            # Multi-line structured block. Sanitize each value so a rogue
            # newline or ``---`` fence from LLM output can't split the file.
            def _clean(v: str) -> str:
                return (v or "").replace("\n", " ").replace("\r", " ").strip()

            header = f"- {ts} — #{dispatches} {marker}{cost_str}"
            parts: list[str] = [header]
            for field, val in (
                ("hypothesis", hypothesis),
                ("action", action),
                ("observation", observation),
                ("metric_delta", metric_delta),
                ("reason", reason),
            ):
                cleaned = _clean(val)
                if cleaned:
                    parts.append(f"  {field}: {cleaned}")
            # Append ``note`` at the end of the block when provided
            # alongside structured fields (so the raw LLM snippet is still
            # recoverable for debugging).
            if note:
                parts.append(f"  note: {_clean(note)}")
            entry = "\n".join(parts)
        else:
            note_str = f" — {note}" if note else ""
            entry = f"- {ts} — #{dispatches} {marker}{cost_str}{note_str}"
            # Sanitize the note so it can't inject a new YAML block or heading
            entry = entry.replace("\n", " ").replace("\r", " ")

        if "## Pursuit Log" in content:
            # Append to existing section — insert before any trailing section
            # or at the very end of the file
            lines = content.rstrip().split("\n")
            # Find the section and append a new entry right after existing entries
            section_idx = None
            for i, line in enumerate(lines):
                if line.strip() == "## Pursuit Log":
                    section_idx = i
                    break
            if section_idx is not None:
                # Walk forward past existing entries and blank lines to find
                # either the next `## ` heading or end of file
                insert_at = len(lines)
                for j in range(section_idx + 1, len(lines)):
                    if lines[j].startswith("## "):
                        insert_at = j
                        break
                # Trim trailing blank lines in the section before inserting
                while insert_at > section_idx + 1 and not lines[insert_at - 1].strip():
                    insert_at -= 1
                lines.insert(insert_at, entry)
                return "\n".join(lines) + "\n"
        # Section doesn't exist — create it at end of file
        return content.rstrip() + f"\n\n## Pursuit Log\n\n{entry}\n"

    # ── Block tracking, trust override, domain ──

    def _upsert_frontmatter_field(
        self, content: str, field: str, value: str,
        *, anchor: str = "pursuit_status",
    ) -> str:
        """Set a frontmatter field's value, inserting it after ``anchor`` if missing.

        Quotes the value when it contains characters YAML would misparse
        (colons, leading whitespace, empty string handled as ``""``).
        Idempotent — running twice with the same value is a no-op.
        """
        import re as _re

        # Normalize: always quote strings so YAML stays unambiguous
        if isinstance(value, bool):
            rendered = "true" if value else "false"
        elif isinstance(value, (int, float)):
            rendered = str(value)
        else:
            escaped = str(value).replace('"', '\\"')
            rendered = f'"{escaped}"'

        pattern = rf"^{_re.escape(field)}:\s*.*$"
        if _re.search(pattern, content, flags=_re.MULTILINE):
            return _re.sub(
                pattern, f"{field}: {rendered}",
                content, count=1, flags=_re.MULTILINE,
            )

        # Field missing — insert after the anchor (or before the closing ---)
        anchor_pat = rf"^{_re.escape(anchor)}:\s*.*$"
        anchor_match = _re.search(anchor_pat, content, flags=_re.MULTILINE)
        if anchor_match:
            end = anchor_match.end()
            return content[:end] + f"\n{field}: {rendered}" + content[end:]

        # Anchor missing too — insert before closing --- of frontmatter
        matches = list(_re.finditer(r"^---\s*$", content, flags=_re.MULTILINE))
        if len(matches) >= 2:
            insert_at = matches[1].start()
            return content[:insert_at] + f"{field}: {rendered}\n" + content[insert_at:]
        return content

    def record_block(self, slug: str, reason: str, details: dict | str = "") -> None:
        """Record why the heartbeat couldn't dispatch this goal this tick.

        ``reason`` is a short machine token (``trust_too_low``, ``dispatch_limit``,
        ``budget_exhausted``). ``details`` is a human- or machine-readable payload
        serialized as JSON on disk for the UI to parse.
        """
        import json

        path = f"goals/{slug}.md"
        if not self._reader.exists(path):
            raise FileNotFoundError(f"Goal not found: {slug}")
        doc = self._reader.read(path)
        content = doc.raw

        now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
        if isinstance(details, dict):
            try:
                details_str = json.dumps(details, sort_keys=True, default=str)
            except Exception:
                details_str = str(details)
        else:
            details_str = str(details)

        content = self._upsert_frontmatter_field(content, "last_block_reason", reason)
        content = self._upsert_frontmatter_field(content, "last_block_at", now)
        content = self._upsert_frontmatter_field(content, "last_block_details", details_str)
        self._writer.write(path, content, contract_action="create_task")

    def clear_block(self, slug: str) -> None:
        """Clear the halt reason. Called when a dispatch actually goes through."""
        path = f"goals/{slug}.md"
        if not self._reader.exists(path):
            return
        doc = self._reader.read(path)
        content = doc.raw
        meta = doc.metadata
        # Only write if something needs clearing (avoid version-history churn)
        if not (meta.get("last_block_reason") or meta.get("last_block_at") or meta.get("last_block_details")):
            return
        content = self._upsert_frontmatter_field(content, "last_block_reason", "")
        content = self._upsert_frontmatter_field(content, "last_block_at", "")
        content = self._upsert_frontmatter_field(content, "last_block_details", "")
        self._writer.write(path, content, contract_action="create_task")

    # ─────────────────────────────────────────────────────────────
    # Pending questions — Ship 3
    # ─────────────────────────────────────────────────────────────
    #
    # The main agent's autonomous goal pursuit needs a channel to ask
    # the owner for input when it hits ambiguity — a tasteful human-
    # in-the-loop beat instead of barreling forward on a bad assumption.
    # Questions live in the goal's frontmatter under ``pending_questions``.
    # When the owner answers, the question moves to ``answered_questions``
    # (frozen history the agent can reference in future dispatches).
    #
    # Data shape (YAML list of dicts):
    #   pending_questions:
    #     - id: "q-<iso>-<short>"
    #       question: "..."
    #       asked_at: "<iso>"
    #       importance: low | medium | high
    #       context: "short note on what the agent was trying to decide"
    #   answered_questions:
    #     - (same fields as above) + answer, answered_at

    def _load_goal_doc(self, slug: str):
        """Load a goal markdown file via python-frontmatter. Raises
        FileNotFoundError if the goal doesn't exist."""
        import frontmatter as _fm

        path = f"goals/{slug}.md"
        if not self._reader.exists(path):
            raise FileNotFoundError(f"Goal not found: {slug}")
        raw = self._reader.read(path).raw
        return path, _fm.loads(raw)

    def _save_goal_doc(self, rel_path: str, doc) -> None:
        """Serialize a frontmatter.Post back to disk via the vault writer
        so the contract gate + version history stay intact."""
        import frontmatter as _fm

        serialized = _fm.dumps(doc)
        # python-frontmatter omits the trailing newline; writers everywhere
        # else in this codebase expect one, so be explicit.
        if not serialized.endswith("\n"):
            serialized += "\n"
        self._writer.write(rel_path, serialized, contract_action="create_task")

    def add_pending_question(
        self,
        slug: str,
        question: str,
        *,
        importance: str = "medium",
        context: str = "",
    ) -> dict:
        """Agent queues a clarifying question for the owner.

        Returns the full question dict including its generated ``id``.
        Importance must be one of {low, medium, high}; bad values are
        coerced to ``medium`` instead of raising so a badly-calibrated
        subagent can't crash the pursuit loop.
        """
        if not question or not isinstance(question, str):
            raise ValueError("question text is required")
        if importance not in ("low", "medium", "high"):
            importance = "medium"

        rel_path, doc = self._load_goal_doc(slug)
        pending = list(doc.metadata.get("pending_questions") or [])

        now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
        import uuid as _uuid
        # Gap 29: uuid4 prevents same-second ID collisions.
        q_id = f"q-{now[:10]}-{_uuid.uuid4().hex[:6]}"

        entry = {
            "id": q_id,
            "question": question.strip()[:500],
            "asked_at": now,
            "importance": importance,
            "context": (context or "").strip()[:300],
        }
        pending.append(entry)
        doc["pending_questions"] = pending
        self._save_goal_doc(rel_path, doc)
        return entry

    def list_pending_questions(self, slug: str) -> list[dict]:
        """Return unanswered questions for this goal, oldest first."""
        try:
            _, doc = self._load_goal_doc(slug)
        except FileNotFoundError:
            return []
        items = list(doc.metadata.get("pending_questions") or [])
        # Safety: skip malformed entries so a hand-edit can't crash the UI
        return [q for q in items if isinstance(q, dict) and q.get("question")]

    def list_answered_questions(self, slug: str, limit: int = 20) -> list[dict]:
        """Return the most recent answered questions (newest first)."""
        try:
            _, doc = self._load_goal_doc(slug)
        except FileNotFoundError:
            return []
        items = list(doc.metadata.get("answered_questions") or [])
        items = [q for q in items if isinstance(q, dict) and q.get("question")]
        # Newest first — they're appended in time order so reverse.
        return list(reversed(items))[:limit]

    def answer_pending_question(
        self, slug: str, question_id: str, answer: str,
    ) -> dict:
        """Owner answers a pending question. The question moves from
        ``pending_questions`` to ``answered_questions`` with the answer
        + answered_at timestamp attached. Returns the answered entry.
        Raises ``FileNotFoundError`` if goal missing, ``KeyError`` if the
        question id isn't in the pending list.
        """
        if not answer or not isinstance(answer, str) or not answer.strip():
            raise ValueError("answer text is required")

        rel_path, doc = self._load_goal_doc(slug)
        pending = list(doc.metadata.get("pending_questions") or [])
        answered = list(doc.metadata.get("answered_questions") or [])

        found_idx = None
        for i, q in enumerate(pending):
            if isinstance(q, dict) and q.get("id") == question_id:
                found_idx = i
                break
        if found_idx is None:
            raise KeyError(f"Question not pending on goal '{slug}': {question_id}")

        entry = dict(pending.pop(found_idx))
        entry["answer"] = answer.strip()[:2000]
        entry["answered_at"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
        answered.append(entry)

        doc["pending_questions"] = pending
        doc["answered_questions"] = answered
        self._save_goal_doc(rel_path, doc)
        return entry

    def list_all_pending_questions(self) -> list[dict]:
        """Across all active goals — powers the dashboard alert card.

        Each entry is enriched with ``goal_slug`` and ``goal_title`` so the
        UI can link back without a second round trip. Questions sorted by
        importance (high first) then asked_at (oldest first).
        """
        importance_rank = {"high": 0, "medium": 1, "low": 2}
        results: list[dict] = []
        for g in self.list_goals(status_filter="active"):
            slug = g.get("slug")
            if not slug:
                continue
            for q in self.list_pending_questions(slug):
                enriched = dict(q)
                enriched["goal_slug"] = slug
                enriched["goal_title"] = g.get("title", slug)
                results.append(enriched)
        results.sort(
            key=lambda q: (
                importance_rank.get(q.get("importance", "medium"), 1),
                q.get("asked_at", ""),
            )
        )
        return results

    # ─────────────────────────────────────────────────────────────
    # Metric observations — Ship 4A (Check step in PDCA)
    # ─────────────────────────────────────────────────────────────
    #
    # The agent or owner records observations of the goal's success
    # metric over time (e.g. "GitHub stars = 347 on 2026-05-01"). The
    # latest observation is compared against the target parsed from the
    # success_metric text to drive the progress bar in the UI and the
    # retrospective's "on track / off track / stuck" verdict.
    #
    # Data shape:
    #   metric_target_numeric: 1000       # parsed once from success_metric
    #   metric_unit: "stars"               # best-effort extract
    #   metric_observations:
    #     - id: "obs-<iso>-<short>"
    #       value: "347 stars"             # human-readable
    #       numeric_value: 347              # parsed for trend math (None ok)
    #       observed_at: "<iso>"
    #       source: "github_api" | "manual" | "delegated_task" | "agent_estimate"
    #       note: "..."

    _METRIC_NUMBER_RE = r"(-?\d{1,3}(?:[,]\d{3})+(?:\.\d+)?|-?\d+(?:\.\d+)?)"

    def _parse_success_metric(self, metric_text: str) -> tuple[float | None, str]:
        """Best-effort extract a numeric target + unit from the success_metric
        text. Returns (target_numeric, unit). Either may be None/empty.

        Handles common shapes:
          "1000 GitHub stars by ..."           → (1000, "GitHub stars")
          "publish 5 articles"                 → (5, "articles")
          "complete a plan with 3+ segments"   → (3, "segments")
          "Complete X by 2026-04-30"           → (None, "") — date-only, non-numeric
        """
        import re as _re

        if not metric_text:
            return None, ""
        # Pre-strip ISO dates (YYYY-MM-DD) and standalone years so
        # "By 2026-04-30, do X" doesn't pick up 2026, 04, or 30 as targets.
        cleaned = _re.sub(r"\d{4}-\d{1,2}-\d{1,2}", "", metric_text)
        cleaned = _re.sub(r"(?:20|21)\d{2}", "", cleaned)
        # Gap 18: if the metric uses "N of M X" or "N/M X" shape, prefer
        # M (the target) over N (current state). The agent uses this
        # format in its observations, and goal-shape may echo it.
        of_match = _re.search(
            r"(\d{1,3}(?:,\d{3})*(?:\.\d+)?|\d+(?:\.\d+)?)\s*(?:of|/)\s*"
            r"(\d{1,3}(?:,\d{3})*(?:\.\d+)?|\d+(?:\.\d+)?)\s+([A-Za-z][\w-]*(?:\s+[A-Za-z][\w-]*){0,2})",
            cleaned,
        )
        if of_match:
            try:
                target_val = float(of_match.group(2).replace(",", ""))
                return target_val, of_match.group(3).strip()
            except ValueError:
                pass
        for m in _re.finditer(self._METRIC_NUMBER_RE, cleaned):
            raw = m.group(1).replace(",", "")
            try:
                val = float(raw)
            except ValueError:
                continue
            # Extra safety: skip anything still shaped like a year
            if 2020 <= val <= 2099 and "." not in raw:
                continue
            # Unit: the next 1-3 words after the number
            tail = cleaned[m.end():].strip()
            unit_match = _re.match(r"([A-Za-z][\w-]*(?:\s+[A-Za-z][\w-]*){0,2})", tail)
            unit = unit_match.group(1) if unit_match else ""
            return val, unit
        return None, ""

    def record_metric_observation(
        self,
        slug: str,
        value: str,
        *,
        numeric_value: float | None = None,
        source: str = "manual",
        note: str = "",
    ) -> dict:
        """Record one observation of the goal's success metric.

        ``value`` is the human-readable text ("347 stars"). ``numeric_value``
        is the parsed number used for trend math and progress-bar display —
        callers may omit it; the method attempts to extract from ``value``.
        """
        import re as _re

        if not value:
            raise ValueError("value is required")

        rel_path, doc = self._load_goal_doc(slug)

        # Auto-populate the parsed target + unit on first observation so the
        # UI doesn't need to re-parse on every render.
        if not doc.metadata.get("metric_target_numeric"):
            target, unit = self._parse_success_metric(doc.metadata.get("success_metric", ""))
            if target is not None:
                doc["metric_target_numeric"] = target
            if unit:
                doc["metric_unit"] = unit

        # Auto-extract numeric_value if not given
        if numeric_value is None:
            m = _re.search(self._METRIC_NUMBER_RE, value)
            if m:
                try:
                    numeric_value = float(m.group(1).replace(",", ""))
                except ValueError:
                    numeric_value = None

        now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
        import uuid as _uuid
        # Gap 29: uuid4 prevents same-second ID collisions.
        entry = {
            "id": f"obs-{now[:10]}-{_uuid.uuid4().hex[:6]}",
            "value": str(value)[:500],
            "numeric_value": numeric_value,
            "observed_at": now,
            "source": str(source)[:50],
            "note": str(note)[:500],
        }
        observations = list(doc.metadata.get("metric_observations") or [])
        observations.append(entry)
        doc["metric_observations"] = observations
        self._save_goal_doc(rel_path, doc)
        return entry

    def list_metric_observations(self, slug: str) -> list[dict]:
        """Return observations oldest-first so the UI renders a time series."""
        try:
            _, doc = self._load_goal_doc(slug)
        except FileNotFoundError:
            return []
        items = list(doc.metadata.get("metric_observations") or [])
        return [o for o in items if isinstance(o, dict) and o.get("value")]

    def metric_progress(self, slug: str) -> dict:
        """Return progress summary for the UI progress bar.

        Shape: ``{target, unit, latest, latest_at, percent, observations_count,
        trend}`` — ``trend`` is ``"up" | "down" | "flat" | "unknown"`` based
        on the last 3 observations.
        """
        try:
            _, doc = self._load_goal_doc(slug)
        except FileNotFoundError:
            return {}

        meta = doc.metadata
        target = meta.get("metric_target_numeric")
        if target is None:
            t, unit = self._parse_success_metric(meta.get("success_metric", ""))
            target = t
        else:
            unit = meta.get("metric_unit", "")

        observations = self.list_metric_observations(slug)
        if not observations:
            return {
                "target": target,
                "unit": unit,
                "latest": None,
                "latest_at": None,
                "percent": None,
                "observations_count": 0,
                "trend": "unknown",
            }

        latest = observations[-1]
        latest_numeric = latest.get("numeric_value")
        percent = None
        if latest_numeric is not None and target:
            try:
                percent = round((float(latest_numeric) / float(target)) * 100, 1)
            except (TypeError, ValueError, ZeroDivisionError):
                percent = None

        # Trend from last 3 numeric observations
        trend = "unknown"
        numeric_tail = [
            o.get("numeric_value") for o in observations[-3:]
            if o.get("numeric_value") is not None
        ]
        if len(numeric_tail) >= 2:
            first, last = numeric_tail[0], numeric_tail[-1]
            if last > first:
                trend = "up"
            elif last < first:
                trend = "down"
            else:
                trend = "flat"

        return {
            "target": target,
            "unit": unit,
            "latest": latest.get("value"),
            "latest_numeric": latest_numeric,
            "latest_at": latest.get("observed_at"),
            "percent": percent,
            "observations_count": len(observations),
            "trend": trend,
        }

    # ─────────────────────────────────────────────────────────────
    # Retrospectives — Ship 4B (Act step in PDCA)
    # ─────────────────────────────────────────────────────────────
    #
    # Every N dispatches (configurable per-skill), the agent runs the
    # goal-retrospective skill: reads pursuit log + metric observations
    # + answered questions + task outcomes, emits a structured verdict
    # and a recommended next action. The owner accepts or rejects the
    # recommendation; accepted recommendations update the goal's plan.
    #
    # Data shape:
    #   retrospectives:
    #     - id: "retro-<iso>-<short>"
    #       at: "<iso>"
    #       dispatch_count: 5
    #       verdict: "on_track" | "off_track" | "stuck" | "done"
    #       insights: "short synthesis of what was tried and what happened"
    #       recommended_action: "continue" | "pivot" | "ask_owner" | "pause" | "mark_achieved"
    #       recommendation_note: "concrete next step"
    #       applied: "accepted" | "rejected" | null

    def add_retrospective(
        self,
        slug: str,
        *,
        verdict: str,
        insights: str,
        recommended_action: str,
        recommendation_note: str = "",
        dispatch_count: int = 0,
    ) -> dict:
        """Agent submits a retrospective verdict + recommendation. The
        owner reviews and decides whether to apply it.
        """
        VERDICTS = {"on_track", "off_track", "stuck", "done"}
        ACTIONS = {"continue", "pivot", "ask_owner", "pause", "mark_achieved"}
        if verdict not in VERDICTS:
            verdict = "on_track"
        if recommended_action not in ACTIONS:
            recommended_action = "continue"

        rel_path, doc = self._load_goal_doc(slug)
        retros = list(doc.metadata.get("retrospectives") or [])

        now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
        import uuid as _uuid
        # Gap 29: uuid4 prevents same-second ID collisions.
        entry = {
            "id": f"retro-{now[:10]}-{_uuid.uuid4().hex[:6]}",
            "at": now,
            "dispatch_count": int(dispatch_count),
            "verdict": verdict,
            "insights": str(insights or "").strip()[:1500],
            "recommended_action": recommended_action,
            "recommendation_note": str(recommendation_note or "").strip()[:500],
            "applied": None,
        }
        # Gap 23: supersede older pending retrospectives — when a new
        # retrospective is submitted, any prior retrospective still in
        # pending state (applied=None) is auto-marked superseded so the
        # UI's "first pending" selector always shows the newest view,
        # and older stale snapshots don't linger across dispatches.
        for r in retros:
            if isinstance(r, dict) and r.get("applied") is None:
                r["applied"] = "superseded"
                r["applied_at"] = now
                r["superseded_by"] = entry["id"]
        retros.append(entry)
        doc["retrospectives"] = retros
        doc["last_retrospective_at"] = now
        doc["last_retrospective_dispatch"] = int(dispatch_count)

        # Gap 4 cascade: when the retrospective recommends ask_owner, also
        # queue a pending_question so the owner sees a concrete actionable
        # card (not just "retrospective says ask_owner" in isolation). The
        # question text IS the recommendation_note, which the skill requires
        # to be specific. Saves one dispatch round-trip and makes the ask
        # land on the dashboard alert card immediately.
        if recommended_action == "ask_owner" and entry["recommendation_note"]:
            pending = list(doc.metadata.get("pending_questions") or [])
            # Dedup: don't create a duplicate question from a retrospective
            # re-run with the same text.
            already_asked = any(
                isinstance(q, dict) and q.get("question") == entry["recommendation_note"]
                for q in pending
            )
            if not already_asked:
                import hashlib as _h

                q_id = (
                    "q-" + now[:10] + "-"
                    + _h.sha1(f"{slug}|retro|{entry['id']}".encode()).hexdigest()[:6]
                )
                pending.append({
                    "id": q_id,
                    "question": entry["recommendation_note"][:500],
                    "asked_at": now,
                    "importance": "high",
                    "context": f"From retrospective {entry['id']} (verdict: {entry['verdict']})",
                })
                doc["pending_questions"] = pending

        self._save_goal_doc(rel_path, doc)
        return entry

    def list_retrospectives(self, slug: str, *, limit: int = 20) -> list[dict]:
        """Return retrospectives newest-first."""
        try:
            _, doc = self._load_goal_doc(slug)
        except FileNotFoundError:
            return []
        items = list(doc.metadata.get("retrospectives") or [])
        items = [r for r in items if isinstance(r, dict) and r.get("id")]
        return list(reversed(items))[:limit]

    def latest_retrospective(self, slug: str) -> dict | None:
        retros = self.list_retrospectives(slug, limit=1)
        return retros[0] if retros else None

    def apply_retrospective(self, slug: str, retro_id: str, *, accept: bool, note: str = "") -> dict:
        """Owner accepts or rejects an agent-proposed retrospective action.

        Accepting may trigger a side effect depending on ``recommended_action``:
          - ``pause``: goal pursuit is paused
          - ``mark_achieved``: goal status moves to ``achieved``
          - ``pivot``: marks applied; the agent's next dispatch will read
            the accepted retrospective as context and adjust
          - ``continue`` / ``ask_owner``: purely informational — just mark applied

        Rejection preserves the retro but records the owner's disagreement.
        """
        rel_path, doc = self._load_goal_doc(slug)
        retros = list(doc.metadata.get("retrospectives") or [])

        found_idx = None
        for i, r in enumerate(retros):
            if isinstance(r, dict) and r.get("id") == retro_id:
                found_idx = i
                break
        if found_idx is None:
            raise KeyError(f"Retrospective not found: {retro_id}")

        entry = dict(retros[found_idx])
        entry["applied"] = "accepted" if accept else "rejected"
        entry["applied_at"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
        if note:
            entry["owner_note"] = str(note)[:500]
        retros[found_idx] = entry
        doc["retrospectives"] = retros
        self._save_goal_doc(rel_path, doc)

        # Side effects for accepted recommendations
        if accept:
            action = entry.get("recommended_action", "")
            if action == "pause":
                try:
                    self.set_pursuit_status(slug, "paused", reason="owner_accepted_retrospective")
                except Exception:
                    pass
            elif action == "mark_achieved":
                # Gap 22: integrity check — if the metric doesn't support
                # "achieved", stamp a mismatch flag on the entry so the
                # audit trail makes the decision visible. Never blocks —
                # owner might have evidence outside the observation channel.
                try:
                    progress = self.metric_progress(slug)
                    latest = progress.get("latest_numeric")
                    target = progress.get("target")
                    if target is not None and (latest is None or float(latest) < float(target)):
                        # Re-read + write with mismatch flag
                        rp2, d2 = self._load_goal_doc(slug)
                        retros2 = list(d2.metadata.get("retrospectives") or [])
                        for r in retros2:
                            if isinstance(r, dict) and r.get("id") == retro_id:
                                r["metric_mismatch"] = {
                                    "latest_numeric": latest,
                                    "target": target,
                                    "note": "mark_achieved accepted despite metric below target",
                                }
                                break
                        d2["retrospectives"] = retros2
                        self._save_goal_doc(rp2, d2)
                except Exception:
                    pass
                try:
                    self.update_goal_status(slug, "achieved")
                except Exception:
                    pass

        return entry

    # ─────────────────────────────────────────────────────────────
    # Milestones — Ship 4C (structural progress markers)
    # ─────────────────────────────────────────────────────────────
    #
    # Human-readable checkpoints that break a goal's success metric
    # into tangible waypoints. The agent or owner can add/complete.
    # Completion % is surfaced in the UI alongside the metric progress bar.
    #
    # Data shape:
    #   milestones:
    #     - id: "m-<iso>-<short>"
    #       text: "Draft first chapter"
    #       done: false
    #       created_at: "<iso>"
    #       completed_at: null | "<iso>"
    #       source: "owner" | "agent" | "goal_shape"

    def add_milestone(self, slug: str, text: str, *, source: str = "owner") -> dict:
        if not text or not str(text).strip():
            raise ValueError("milestone text is required")

        rel_path, doc = self._load_goal_doc(slug)
        milestones = list(doc.metadata.get("milestones") or [])

        now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
        import uuid as _uuid
        # Gap 29: uuid4 prevents same-second ID collisions.
        entry = {
            "id": f"m-{now[:10]}-{_uuid.uuid4().hex[:6]}",
            "text": str(text).strip()[:300],
            "done": False,
            "created_at": now,
            "completed_at": None,
            "source": str(source)[:30],
        }
        milestones.append(entry)
        doc["milestones"] = milestones
        self._save_goal_doc(rel_path, doc)
        return entry

    def list_milestones(self, slug: str) -> list[dict]:
        try:
            _, doc = self._load_goal_doc(slug)
        except FileNotFoundError:
            return []
        items = list(doc.metadata.get("milestones") or [])
        return [m for m in items if isinstance(m, dict) and m.get("text")]

    def complete_milestone(self, slug: str, milestone_id: str, *, done: bool = True) -> dict:
        rel_path, doc = self._load_goal_doc(slug)
        milestones = list(doc.metadata.get("milestones") or [])

        found_idx = None
        for i, m in enumerate(milestones):
            if isinstance(m, dict) and m.get("id") == milestone_id:
                found_idx = i
                break
        if found_idx is None:
            raise KeyError(f"Milestone not found: {milestone_id}")

        entry = dict(milestones[found_idx])
        entry["done"] = bool(done)
        entry["completed_at"] = (
            datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") if done else None
        )
        milestones[found_idx] = entry
        doc["milestones"] = milestones
        self._save_goal_doc(rel_path, doc)
        return entry

    def delete_milestone(self, slug: str, milestone_id: str) -> None:
        rel_path, doc = self._load_goal_doc(slug)
        milestones = list(doc.metadata.get("milestones") or [])
        before = len(milestones)
        milestones = [m for m in milestones if not (isinstance(m, dict) and m.get("id") == milestone_id)]
        if len(milestones) == before:
            raise KeyError(f"Milestone not found: {milestone_id}")
        doc["milestones"] = milestones
        self._save_goal_doc(rel_path, doc)

    def milestones_progress(self, slug: str) -> dict:
        milestones = self.list_milestones(slug)
        total = len(milestones)
        done = sum(1 for m in milestones if m.get("done"))
        percent = round((done / total) * 100, 1) if total else None
        return {"total": total, "done": done, "percent": percent}

    def set_trust_override(self, slug: str, enabled: bool) -> bool:
        """Toggle the per-goal trust-gate bypass. Returns the new value."""
        path = f"goals/{slug}.md"
        if not self._reader.exists(path):
            raise FileNotFoundError(f"Goal not found: {slug}")
        doc = self._reader.read(path)
        content = self._upsert_frontmatter_field(doc.raw, "trust_override", bool(enabled))
        self._writer.write(path, content, contract_action="create_task")
        return bool(enabled)

    def set_domain(self, slug: str, domain: str) -> str:
        """Set the goal's trust domain. Returns the new domain."""
        if not domain or not isinstance(domain, str):
            raise ValueError("Domain must be a non-empty string")
        path = f"goals/{slug}.md"
        if not self._reader.exists(path):
            raise FileNotFoundError(f"Goal not found: {slug}")
        doc = self._reader.read(path)
        content = self._upsert_frontmatter_field(doc.raw, "domain", domain)
        self._writer.write(path, content, contract_action="create_task")
        return domain

    def list_halted_goals(self, max_age_hours: int = 6) -> list[dict]:
        """Return goals in ``pursuing`` status that are currently halted.

        A goal is halted when EITHER the heartbeat recorded a block within
        ``max_age_hours`` (``last_block_at``) OR the live gate computation
        says it would be blocked right now (``live_block``). The live path
        closes the "invisible window" between the user clicking Start
        pursuit and the next heartbeat tick (up to 15min).
        """
        cutoff = datetime.now(UTC) - timedelta(hours=max_age_hours)
        out = []
        for g in self.list_goals(status_filter="active"):
            try:
                ps = self.get_pursuit_status(g["slug"])
            except (FileNotFoundError, OSError):
                continue
            if ps.get("pursuit_status") != "pursuing":
                continue

            live = ps.get("live_block")
            block_at = ps.get("last_block_at") or ""
            recorded_reason = ps.get("last_block_reason") or ""

            ts_ok = False
            if block_at:
                try:
                    ts = datetime.strptime(block_at, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC)
                    ts_ok = ts >= cutoff
                except (ValueError, TypeError):
                    ts_ok = False

            if not live and not ts_ok:
                continue

            # Prefer live reason if set — it reflects the current state,
            # not a stale recorded one that might already be resolved.
            if live:
                reason = live["reason"]
                import json as _json
                details_json = _json.dumps(live["details"])
                at = block_at if ts_ok else ""
            else:
                reason = recorded_reason
                details_json = ps.get("last_block_details", "") or ""
                at = block_at

            out.append({
                "slug": g["slug"],
                "title": g["title"],
                "domain": ps.get("domain", "general"),
                "last_block_reason": reason,
                "last_block_at": at,
                "last_block_details": details_json,
                "trust_override": ps.get("trust_override", False),
                "live": bool(live),
            })
        # Recorded-with-timestamp first (most recent first), then live-only
        out.sort(key=lambda r: (r["last_block_at"] or "", r["slug"]), reverse=True)
        return out

    def set_pursuit_status(self, slug: str, status: str, reason: str = "") -> None:
        """Set pursuit_status (idle, pursuing, paused, completed, failed)."""
        import re as _re

        valid = ("idle", "pursuing", "paused", "completed", "failed")
        if status not in valid:
            raise ValueError(f"Invalid pursuit_status: {status}")

        path = f"goals/{slug}.md"
        if not self._reader.exists(path):
            raise FileNotFoundError(f"Goal not found: {slug}")
        doc = self._reader.read(path)
        content = _re.sub(
            r"^pursuit_status:\s*.*$", f"pursuit_status: {status}",
            doc.raw, count=1, flags=_re.MULTILINE,
        )
        self._writer.write(path, content, contract_action="create_task")

        if status == "completed":
            self.update_goal_status(slug, "achieved")
        if status == "paused" and reason:
            log.info("Goal '%s' pursuit paused: %s", slug, reason)

    def detect_stalls(self, stall_days: int = 7) -> list[dict]:
        """Detect goals that haven't had activity in stall_days.

        Checks if goals have related tasks or journal mentions recently.
        """
        stalls = []
        cutoff = (datetime.now(UTC) - timedelta(days=stall_days)).strftime("%Y-%m-%d")

        for goal in self.list_goals(status_filter="active"):
            # Check if any recent journal entries mention this goal
            title_lower = goal["title"].lower()
            has_recent_activity = False

            # Check recent journal files (user + agent + legacy)
            journal_files = []
            for d in ("journal/user", "journal/agnt", "journal"):
                for jf in self._reader.list_files(d, "*.md"):
                    journal_files.append((d, jf))
            for d, jf in sorted(journal_files, key=lambda x: x[1].name, reverse=True)[:stall_days * 2]:
                stem = jf.stem.replace("user-journal_", "").replace("agent-observations_", "")
                if stem < cutoff:
                    break
                try:
                    doc = self._reader.read(f"{d}/{jf.name}")
                    if title_lower in doc.content.lower():
                        has_recent_activity = True
                        break
                except Exception:
                    continue

            if not has_recent_activity:
                stalls.append({
                    "goal": goal["title"],
                    "slug": goal["slug"],
                    "days_inactive": stall_days,
                    "nudge": (
                        f"Your goal '{goal['title']}' hasn't had any activity "
                        f"in the last {stall_days} days. Want to plan the next step?"
                    ),
                })

        return stalls

    def generate_nudges(self) -> list[str]:
        """Generate coaching nudges for stalled goals and approaching deadlines."""
        nudges = []
        today = datetime.now(UTC).strftime("%Y-%m-%d")

        # Stall nudges
        for stall in self.detect_stalls():
            nudges.append(stall["nudge"])

        # Deadline nudges
        for goal in self.list_goals(status_filter="active"):
            target = goal.get("target_date", "")
            if not target:
                continue
            try:
                target_date = datetime.strptime(target, "%Y-%m-%d")
                days_left = (target_date - datetime.strptime(today, "%Y-%m-%d")).days
                if 0 < days_left <= 7:
                    nudges.append(
                        f"Goal '{goal['title']}' is due in {days_left} day(s) ({target})."
                    )
                elif days_left <= 0:
                    nudges.append(
                        f"Goal '{goal['title']}' was due on {target}. "
                        f"Want to update the deadline or mark it?"
                    )
            except ValueError:
                continue

        return nudges
