"""Casual-pace polling scheduler.

Poll-based, not event-loop. Agents invoked for focused bursts,
state fully persisted between bursts. Fundamentally different from
swarm's synchronous pipeline.

Properties:
- Agents invoked for focused bursts, not left running
- State fully persisted between bursts (.pact/state.json)
- Humans can inspect state at any time
- Work can pause/resume across days
- Token efficient — agents only invoked when work exists
"""

from __future__ import annotations

import asyncio
import logging
from dataclasses import dataclass, field
from datetime import datetime

from pact.agents.base import AgentBase
from pact.budget import BudgetExceeded, BudgetTracker
from pact.config import (
    BuildMode,
    GlobalConfig,
    ProjectConfig,
    resolve_backend,
    resolve_build_mode,
    resolve_model,
    resolve_parallel_config,
)
from pact.decomposer import decompose_and_contract, run_interview
from pact.diagnoser import determine_recovery_action, diagnose_failure
from pact.events import EventBus, PactEvent
from pact.implementer import (
    _check_absolute_paths,
    detect_stubs,
    implement_all,
    implement_all_iterative,
    implement_component_iterative,
    validate_and_fix_exports,
)
from pact.test_harness import run_contract_tests
from pact.integrator import integrate_all, integrate_all_iterative
from pact.lifecycle import advance_phase, format_run_summary
from pact.project import ProjectManager
from pact.schemas import ComponentTask, DecompositionTree, RunState, TestResults

logger = logging.getLogger(__name__)

# ── Phase Classification ──────────────────────────────────────────

PLANNING_PHASES = {"interview", "shape", "decompose", "diagnose"}
GENERATION_PHASES = {"implement", "integrate"}
# Phases where no artifacts are expected — health checks for output ratios
# and budget velocity should not fire here.  Interview and shape are pure
# planning; artifacts (contracts, tests) first appear in decompose.
PRE_ARTIFACT_PHASES = {"interview", "shape"}

# Maps tree node implementation_status -> ComponentTask status
_IMPL_STATUS_TO_TASK: dict[str, str] = {
    "pending": "pending",
    "contracted": "contracting",
    "implemented": "implementing",
    "tested": "completed",
    "failed": "failed",
}


def detect_cascade(tree: "DecompositionTree", failed_set: set[str]) -> int:
    """Detect cascade events from the tree structure.

    A cascade event is a unique pair where:
    - A failed component's parent also failed (propagation up)
    - Two failed siblings share a parent (lateral spread)

    Uses frozenset pairs to avoid double-counting (a->b and b->a
    are the same cascade event).

    Returns count of unique cascade events detected.
    """
    seen_pairs: set[frozenset[str]] = set()

    for cid in failed_set:
        # Check parent propagation
        parent = tree.parent_of(cid)
        if parent and parent.component_id in failed_set:
            pair = frozenset({cid, parent.component_id})
            seen_pairs.add(pair)

        # Check sibling spread
        if parent:
            siblings = tree.children_of(parent.component_id)
            for sib in siblings:
                if sib.component_id != cid and sib.component_id in failed_set:
                    pair = frozenset({cid, sib.component_id})
                    seen_pairs.add(pair)

    return len(seen_pairs)


@dataclass
class SystemicPattern:
    """Detected pattern of identical failures across components."""
    pattern_type: str          # "zero_tests", "import_error", "timeout", "identical_failure"
    affected_components: list[str] = field(default_factory=list)
    sample_error: str = ""
    recommendation: str = ""


def detect_systemic_failure(
    results: dict[str, TestResults],
    threshold: int = 3,
) -> SystemicPattern | None:
    """Detect when multiple components fail with the same root cause.

    Args:
        results: Map of component_id to TestResults
        threshold: Minimum components with same failure to trigger detection

    Returns:
        SystemicPattern if detected, None if failures are heterogeneous.

    Patterns detected:
    - All 0/0 (total=0, passed=0) -> "zero_tests" (environment/PATH issue)
    - All same error message in failure_details -> "identical_failure"
    - All have errors but no passed tests -> "import_error" (likely missing dependency)
    """
    if len(results) < threshold:
        return None

    # Pattern 1: All zero-zero (no tests collected)
    zero_zero = [
        cid for cid, r in results.items()
        if r.total == 0 and r.passed == 0
    ]
    if len(zero_zero) >= threshold:
        sample = ""
        for cid in zero_zero:
            r = results[cid]
            if r.failure_details:
                sample = r.failure_details[0].error_message
                break
        return SystemicPattern(
            pattern_type="zero_tests",
            affected_components=zero_zero,
            sample_error=sample or "No tests collected (0 total, 0 passed)",
            recommendation="Check PATH and PYTHONPATH in test environment. Likely pytest not found or test collection failed globally.",
        )

    # Pattern 2: All have errors, no passes (likely import/collection error)
    all_error_no_pass = [
        cid for cid, r in results.items()
        if r.errors > 0 and r.passed == 0
    ]
    if len(all_error_no_pass) >= threshold:
        # Check if errors share a common message
        error_msgs = []
        for cid in all_error_no_pass:
            r = results[cid]
            for fd in r.failure_details:
                if fd.error_message:
                    error_msgs.append(fd.error_message)
                    break

        sample = error_msgs[0] if error_msgs else "Collection/import error"
        return SystemicPattern(
            pattern_type="import_error",
            affected_components=all_error_no_pass,
            sample_error=sample,
            recommendation="Check for missing dependencies or import errors affecting all components.",
        )

    # Pattern 3: Identical failure messages across components
    failed = {
        cid: r for cid, r in results.items()
        if not r.all_passed and r.failure_details
    }
    if len(failed) >= threshold:
        # Group by first failure message
        msg_groups: dict[str, list[str]] = {}
        for cid, r in failed.items():
            msg = r.failure_details[0].error_message if r.failure_details else ""
            if msg:
                msg_groups.setdefault(msg, []).append(cid)

        for msg, cids in msg_groups.items():
            if len(cids) >= threshold:
                return SystemicPattern(
                    pattern_type="identical_failure",
                    affected_components=cids,
                    sample_error=msg,
                    recommendation=f"All {len(cids)} components failed with identical error. Fix the root cause rather than individual components.",
                )

    return None


def _build_goodhart_hint(
    attempt: int,
    results: TestResults,
    goodhart_suite: "ContractTestSuite",
) -> str:
    """Build graduated behavioral hint from Goodhart test failures.

    Level 1 (attempt 1): Vague behavioral hint from test descriptions only.
    Level 2 (attempt 2+): Add specific invariant/postcondition from the contract.

    Never shares actual test code, assertions, or specific failing inputs.
    """
    from pact.schemas import ContractTestSuite  # noqa: F811

    # Map test IDs from failures to their descriptions in the suite
    failing_test_ids = set()
    for fd in results.failure_details:
        failing_test_ids.add(fd.test_id)

    # Build description map from goodhart suite
    desc_map: dict[str, str] = {}
    for tc in goodhart_suite.test_cases:
        desc_map[tc.id] = tc.description

    # Collect descriptions for failing tests
    failing_descriptions = []
    for tid in failing_test_ids:
        desc = desc_map.get(tid, "")
        if desc:
            failing_descriptions.append(desc)

    # If no descriptions matched (test runner uses different IDs), use all
    if not failing_descriptions and goodhart_suite.test_cases:
        failing_descriptions = [tc.description for tc in goodhart_suite.test_cases if tc.description]

    if not failing_descriptions:
        return "A code reviewer noted potential issues with your implementation's generality."

    if attempt == 1:
        # Level 1: Vague behavioral hints
        hints = "\n".join(
            f"- A code reviewer noted: your implementation may not correctly handle "
            f"{desc}"
            for desc in failing_descriptions
        )
        return (
            "BEHAVIORAL REVIEW FEEDBACK (from an independent code review):\n"
            f"{hints}\n\n"
            "Please review your implementation for these potential issues. "
            "The feedback suggests your code may work for specific inputs "
            "but not generalize correctly."
        )
    else:
        # Level 2+: More specific invariant/postcondition hints
        hints = "\n".join(
            f"- The contract requires: {desc}. "
            f"Your implementation appears to violate this for some inputs "
            f"not in the visible test suite."
            for desc in failing_descriptions
        )
        return (
            "SPECIFIC CONTRACT COMPLIANCE ISSUES:\n"
            f"{hints}\n\n"
            "Your implementation passes all visible tests but appears to "
            "have gaps in contract compliance. These are not edge cases — "
            "they are core behavioral properties from the contract."
        )


class Scheduler:
    """Casual-pace scheduler — poll, burst, persist, sleep."""

    def __init__(
        self,
        project: ProjectManager,
        global_config: GlobalConfig,
        project_config: ProjectConfig,
        budget: BudgetTracker,
        event_bus: EventBus | None = None,
    ) -> None:
        self.project = project
        self.global_config = global_config
        self.project_config = project_config
        self.budget = budget
        self.event_bus = event_bus or EventBus(
            project.project_dir, global_config, project_config,
        )
        self.check_interval = (
            project_config.check_interval
            or global_config.check_interval
        )
        self._standards_brief: str = ""

    @property
    def build_mode(self) -> BuildMode:
        """Resolve the effective build mode."""
        return resolve_build_mode(self.project_config, self.global_config)

    def _make_agent(self, role: str) -> AgentBase:
        """Create an agent configured for a specific role."""
        model = resolve_model(role, self.project_config, self.global_config)
        backend = resolve_backend(role, self.project_config, self.global_config)
        self.budget.set_model_pricing(model)
        return AgentBase(budget=self.budget, model=model, backend=backend)

    async def run_once(self) -> RunState:
        """Run a single burst of work. Returns updated state."""
        state = self.project.load_state()

        if state.status in ("completed", "failed", "budget_exceeded"):
            return state

        try:
            state = await self._do_burst(state)
        except BudgetExceeded:
            state.status = "budget_exceeded"
            state.pause_reason = "Budget cap reached"
            state.completed_at = datetime.now().isoformat()
            logger.warning("Budget exceeded for %s", state.id)
        except Exception as e:
            state.fail(f"Unexpected error: {e}")
            logger.exception("Scheduler error for %s", state.id)

        # Sync budget tracker totals to persistent state
        in_tok, out_tok = self.budget.project_tokens
        state.total_tokens = in_tok + out_tok
        state.total_cost_usd = self.budget.project_spend

        state.last_check_in = datetime.now().isoformat()
        self.project.save_state(state)
        return state

    async def run_forever(self) -> RunState:
        """Run the scheduler loop until completion or failure."""
        while True:
            state = await self.run_once()
            if state.status in ("completed", "failed", "budget_exceeded"):
                logger.info("Run complete: %s", format_run_summary(state))
                return state
            if state.status == "paused":
                logger.info("Run paused: %s", state.pause_reason)
                return state
            await asyncio.sleep(self.check_interval)

    # Phase ownership in two-repo mode
    _AUDIT_PHASES = {"interview", "shape", "decompose", "contract", "test", "validate"}
    _CODE_PHASES = {"implement", "integrate"}

    async def _do_burst(self, state: RunState) -> RunState:
        """Execute one phase of work."""
        # Enforce phase ownership in audit-separated mode
        audit_mode = self.project_config.audit_mode
        if audit_mode:
            phase = state.phase
            if audit_mode == "code" and phase in self._AUDIT_PHASES:
                state.pause(f"Phase '{phase}' is owned by the audit agent")
                return state
            if audit_mode == "audit" and phase in self._CODE_PHASES:
                state.pause(f"Phase '{phase}' is owned by the coding agent")
                return state

        sops = self.project.load_sops()
        phase = state.phase

        # Snapshot token counts before phase dispatch
        pre_in, pre_out = self.budget.project_tokens

        # Resolve context_max_chars from config
        context_max_chars = (
            self.project_config.context_max_chars
            if self.project_config.context_max_chars is not None
            else self.global_config.context_max_chars
        )

        # Gather external context and learnings for agent phases
        external_context = ""
        learnings_str = ""
        if phase in ("implement", "integrate", "diagnose"):
            try:
                from pact.human.context import gather_context
                ctx = await gather_context(self.event_bus, phase=phase)
                external_context = ctx.format_for_prompt(max_chars=context_max_chars)
            except Exception:
                pass

            try:
                raw_learnings = self.project.load_learnings()
                if raw_learnings:
                    from pact.agents.base import AgentBase
                    learnings_str = AgentBase.with_learnings(None, raw_learnings)
            except Exception:
                pass

        await self.event_bus.emit(PactEvent(
            kind="phase_start",
            project_name=self.project.project_dir.name,
            detail=phase,
        ))

        if phase == "interview":
            state = await self._phase_interview(state, sops)
        elif phase == "shape":
            state = await self._phase_shape(state, sops)
        elif phase == "decompose":
            state = await self._phase_decompose(state, sops)
        elif phase == "contract":
            # Contract phase is part of decompose
            advance_phase(state)
        elif phase == "preflight":
            state = await self._phase_preflight(state)
        elif phase == "implement":
            state = await self._phase_implement(
                state, sops,
                external_context=external_context,
                learnings=learnings_str,
            )
        elif phase == "integrate":
            state = await self._phase_integrate(
                state, sops,
                external_context=external_context,
                learnings=learnings_str,
            )
        elif phase == "arbiter":
            state = await self._phase_arbiter(state)
        elif phase == "polish":
            state = await self._phase_polish(state)
        elif phase == "diagnose":
            state = await self._phase_diagnose(state, sops)
        elif phase == "retrospective":
            state = self._phase_retrospective(state)
        elif phase == "complete":
            state.complete()
            await self.event_bus.emit(PactEvent(
                kind="run_complete",
                project_name=self.project.project_dir.name,
                detail="completed",
            ))

        # Sync component_tasks status from tree node implementation_status.
        # Tree nodes are the source of truth; component_tasks mirrors them
        # for summary display.
        self._sync_component_tasks(state)

        # Emit phase_complete if we advanced
        if state.phase != phase and state.status == "active":
            await self.event_bus.emit(PactEvent(
                kind="phase_complete",
                project_name=self.project.project_dir.name,
                detail=phase,
                component_id=str(len(self.project.load_tree().nodes)) if phase == "decompose" and self.project.load_tree() else "",
            ))

        # ── Health instrumentation (never blocks the pipeline) ──
        try:
            from pact.health import HealthMetrics

            post_in, post_out = self.budget.project_tokens
            delta_in = post_in - pre_in
            delta_out = post_out - pre_out

            metrics = HealthMetrics.from_dict(state.health_snapshot)

            # Record per-phase tokens
            if delta_in > 0 or delta_out > 0:
                metrics.record_phase_tokens(phase, delta_in, delta_out)

            # Categorize as planning or generation
            if phase in PLANNING_PHASES and (delta_in > 0 or delta_out > 0):
                metrics.record_planning(delta_in, delta_out)
            elif phase in GENERATION_PHASES and (delta_in > 0 or delta_out > 0):
                metrics.record_generation(delta_in, delta_out)

            # Sync total spend from budget tracker
            metrics.total_spend = self.budget.project_spend
            metrics.budget_cap = self.budget.per_project_cap

            state.health_snapshot = metrics.to_dict()

            # Register drift checking (probabilistic, after generation phases)
            if (
                phase in GENERATION_PHASES
                and state.processing_register
                and state.status == "active"
            ):
                try:
                    await self._check_register_drift(state, metrics)
                    state.health_snapshot = metrics.to_dict()
                except Exception:
                    pass  # Drift checking never blocks the pipeline

            # Run health check and apply remedies
            thresholds = getattr(self.project_config, "health_thresholds", {}) or {}
            if thresholds.get("output_planning_ratio_critical") == 0.0:
                pass  # Health checks effectively disabled via config
            else:
                state = self._check_health_and_remediate(state, phase=phase)
        except Exception:
            pass  # Health instrumentation never blocks the pipeline

        # Budget warning at 80%
        if self.budget.spend_percentage >= 80.0 and state.status == "active":
            await self.event_bus.emit(PactEvent(
                kind="budget_warning",
                project_name=self.project.project_dir.name,
                detail=f"{self.budget.spend_percentage:.0f}% spent (${self.budget.project_spend:.2f} of ${self.budget.per_project_cap:.2f})",
            ))

        # Emit human_needed when paused
        if state.status == "paused":
            await self.event_bus.emit(PactEvent(
                kind="human_needed",
                project_name=self.project.project_dir.name,
                detail=state.pause_reason,
            ))

        return state

    async def _phase_interview(self, state: RunState, sops: str) -> RunState:
        """Run interview phase.

        Establishes processing register before domain analysis.
        Register comes from: config override > existing interview > LLM establishment.
        """
        existing = self.project.load_interview()
        if existing and existing.approved:
            # Carry register forward from existing interview
            if existing.processing_register:
                state.processing_register = existing.processing_register
            advance_phase(state)
            return state

        # Config override for register (user can set in pact.yaml)
        config_register = self.project_config.processing_register

        agent = self._make_agent("decomposer")
        try:
            task = self.project.load_task()
            result = await run_interview(
                agent, task, sops,
                processing_register=config_register,
            )
            state.processing_register = result.processing_register
            self.project.save_interview(result)
            self.project.append_audit(
                "interview",
                f"{len(result.questions)} questions, register={result.processing_register}",
            )

            # Count interview questions as artifacts so health checks
            # see real output, not zero.  Interview questions are a genuine
            # work product — they shape downstream decomposition quality.
            if result.questions:
                try:
                    from pact.health import HealthMetrics
                    metrics = HealthMetrics.from_dict(state.health_snapshot)
                    metrics.contracts_produced += 1  # interview itself is an artifact
                    state.health_snapshot = metrics.to_dict()
                except Exception:
                    pass

            if not result.questions:
                result.approved = True
                self.project.save_interview(result)
                advance_phase(state)
            else:
                state.interview_result = result
                state.pause("Interview questions pending — waiting for user answers")
        finally:
            await agent.close()

        return state

    async def _phase_shape(self, state: RunState, sops: str) -> RunState:
        """Run optional shaping phase (Shape Up methodology).

        Skips immediately if shaping is disabled in config.
        """
        shaping_enabled = (
            self.project_config.shaping
            if self.project_config.shaping is not None
            else self.global_config.shaping
        )
        if not shaping_enabled:
            advance_phase(state)
            return state

        # Already have a pitch? Skip.
        existing_pitch = self.project.load_pitch()
        if existing_pitch is not None:
            advance_phase(state)
            return state

        from pact.agents.shaper import Shaper

        agent = self._make_agent("decomposer")
        try:
            depth = (
                self.project_config.shaping_depth
                or self.global_config.shaping_depth
            )
            rigor = (
                self.project_config.shaping_rigor
                or self.global_config.shaping_rigor
            )
            budget_pct = (
                self.project_config.shaping_budget_pct
                if self.project_config.shaping_budget_pct is not None
                else self.global_config.shaping_budget_pct
            )

            shaper = Shaper(
                agent=agent,
                shaping_depth=depth,
                shaping_rigor=rigor,
                shaping_budget_pct=budget_pct,
            )

            task = self.project.load_task()
            interview = self.project.load_interview()
            interview_context = ""
            if interview:
                answers = "\n".join(
                    f"  Q: {q}\n  A: {interview.user_answers.get(q, 'No answer')}"
                    for q in interview.questions
                )
                interview_context = f"Interview:\n{answers}"

            pitch = await shaper.shape(
                task=task,
                sops=sops,
                interview_context=interview_context,
                budget_used=self.budget.project_spend,
                budget_total=self.budget.per_project_cap,
            )
            self.project.save_pitch(pitch)
            self.project.append_audit("shape", f"depth={depth}, appetite={pitch.appetite}")

            # Count shape pitch as an artifact so health checks see
            # real output from this phase.
            try:
                from pact.health import HealthMetrics
                metrics = HealthMetrics.from_dict(state.health_snapshot)
                metrics.contracts_produced += 1  # pitch is an artifact
                state.health_snapshot = metrics.to_dict()
            except Exception:
                pass

            advance_phase(state)
        except Exception as e:
            logger.error("Shaping failed: %s", e)
            self.project.append_audit("shape_error", str(e))
            # On failure, skip shaping and proceed to decompose
            advance_phase(state)
        finally:
            await agent.close()

        return state

    async def _phase_decompose(self, state: RunState, sops: str) -> RunState:
        """Run decomposition + contract + test generation."""
        agent = self._make_agent("decomposer")
        try:
            gate = await decompose_and_contract(
                agent, self.project, sops=sops,
                max_plan_revisions=self.global_config.max_plan_revisions,
                build_mode=self.build_mode.value,
                processing_register=state.processing_register,
                package_namespace=self.project_config.package_namespace,
            )

            if gate.passed:
                # Record decompose-phase artifact counts for health.
                # Use delta against previously-counted artifacts to avoid
                # inflation when diagnose loops back to decompose.
                try:
                    from pact.health import HealthMetrics
                    metrics = HealthMetrics.from_dict(state.health_snapshot)
                    contracts = self.project.load_all_contracts()
                    test_suites = self.project.load_all_test_suites()
                    new_contracts = max(0, len(contracts) - metrics.contracts_produced)
                    new_tests = max(0, len(test_suites) - metrics.tests_produced)
                    metrics.contracts_produced += new_contracts
                    metrics.tests_produced += new_tests
                    state.health_snapshot = metrics.to_dict()
                except Exception:
                    pass  # Health recording never blocks

                # Set up component tasks (preserve existing status on resume)
                tree = self.project.load_tree()
                if tree:
                    existing = {t.component_id: t for t in state.component_tasks}
                    state.component_tasks = [
                        existing.get(cid, ComponentTask(component_id=cid))
                        for cid in tree.topological_order()
                    ]
                    # Persist immediately so component tasks survive crashes/failures
                    self.project.save_state(state)

                    # Auto-generate task list
                    try:
                        from pact.task_list import generate_task_list
                        contracts = self.project.load_all_contracts()
                        test_suites = self.project.load_all_test_suites()
                        task_list = generate_task_list(
                            tree, contracts, test_suites,
                            self.project.project_dir.name,
                        )
                        self.project.save_task_list(task_list)
                        self.project.append_audit(
                            "tasks_generated", f"{task_list.total} tasks",
                        )
                    except Exception as e:
                        logger.debug("Task list generation failed: %s", e)

                    # Collect and persist global standards
                    try:
                        from pact.standards import collect_standards, render_standards_brief
                        standards = collect_standards(
                            contracts, sops,
                            config_env=self.project_config.environment or self.global_config.environment,
                        )
                        self._standards_brief = render_standards_brief(standards)
                        # Persist for inspection
                        import json as _json
                        standards_path = self.project.standards_path
                        standards_path.write_text(_json.dumps(standards.to_dict(), indent=2))
                    except Exception as e:
                        logger.debug("Standards collection failed: %s", e)

                pcfg = resolve_parallel_config(self.project_config, self.global_config)
                if pcfg.plan_only:
                    # Plan-only mode: stop after contracts + tests are generated
                    state.pause(
                        "Plan-only mode: decomposition and contracts complete. "
                        "Use 'pact build <project> <component_id>' to implement "
                        "specific components, or disable plan_only to implement all."
                    )
                else:
                    advance_phase(state)  # -> contract
                    advance_phase(state)  # -> implement
            else:
                state.fail(f"Contract validation failed: {gate.reason}")
        finally:
            await agent.close()

        return state

    # ── Component task sync ────────────────────────────────────────────

    def _sync_component_tasks(self, state: RunState) -> None:
        """Sync component_tasks status from tree node implementation_status.

        Tree nodes are the authoritative source for component progress.
        This keeps the summary-level component_tasks in sync so that
        format_run_summary() reports accurate counts.
        """
        if not state.component_tasks:
            return
        tree = self.project.load_tree()
        if not tree:
            return
        for task in state.component_tasks:
            node = tree.nodes.get(task.component_id)
            if node:
                mapped = _IMPL_STATUS_TO_TASK.get(
                    node.implementation_status, task.status,
                )
                task.status = mapped

    def _make_agent_factory(self, role: str):
        """Create a factory that produces fresh agents for parallel/competitive modes."""
        def factory() -> AgentBase:
            return self._make_agent(role)
        return factory

    async def _phase_preflight(self, state: RunState) -> RunState:
        """Preflight phase — establish red lines and contingencies before implementation.

        Only activates when the backend is claude_code or claude_code_team.
        Direct API backends (anthropic, openai, gemini) skip this phase because
        signet-eval only runs as a Claude Code hook — there's nothing to enforce
        the plan against when calling the API directly.

        For each component about to be implemented:
        1. Read the contract (what we're building)
        2. Query Kindex for lessons from previous runs (if available)
        3. Run environment checks (can we write to src dir? do deps exist?)
        4. Establish red lines (inviolable constraints)
        5. Establish contingencies (plan Bs for known failure modes)
        6. Submit the PreflightPlan via signet_preflight_submit MCP tool

        Compliance mechanisms (enforced by signet-eval, not by Pact):
        - Timed lockout: plan is immutable for a configurable duration after submission
        - HMAC signing: plan signed with vault session key, verified on every read
        - Escalation: 5+ violations triggers ASK-everything mode
        - Human override: preflight-override CLI requires vault passphrase

        MCP tools (signet-eval):
        - signet_preflight_submit: submit the plan (starts lockout)
        - signet_preflight_active: read the active plan
        - signet_preflight_history: past preflights for this component
        - signet_preflight_violations: violations during this run
        - signet_preflight_test: dry-run a tool call against the plan

        This is the "on the ground, before departure" planning step.
        """
        from pact.schemas import (
            Contingency,
            EnvironmentCheck,
            PreflightPlan,
            RedLine,
        )

        # Only activate for claude_code backends
        backend = self.project_config.backend
        role_backends = self.project_config.role_backends
        implementer_backend = role_backends.get("implementer", backend)

        if implementer_backend not in ("claude_code", "claude_code_team"):
            logger.info(
                "Preflight skipped — backend '%s' is not claude_code",
                implementer_backend,
            )
            advance_phase(state)
            return state

        tree = self.project.load_tree()
        if not tree:
            advance_phase(state)
            return state

        contracts = self.project.load_all_contracts()
        plans: list[PreflightPlan] = []

        for cid in tree.topological_order():
            node = tree.nodes.get(cid)
            if not node or node.children:
                continue  # Skip non-leaf / missing

            contract = contracts.get(cid)
            if not contract:
                continue

            # ── 1. Query Kindex for lessons ──
            kindex_lessons: list[str] = []
            try:
                import subprocess
                result = subprocess.run(
                    ["kin", "search", f"preflight {cid} failure lesson"],
                    capture_output=True, text=True, timeout=5,
                )
                if result.returncode == 0 and result.stdout.strip():
                    for line in result.stdout.strip().splitlines()[:5]:
                        line = line.strip()
                        if line and not line.startswith("Found"):
                            kindex_lessons.append(line)
            except Exception:
                pass  # Kindex not available — that's fine

            # ── 2. Environment checks ──
            env_checks: list[EnvironmentCheck] = []

            # Can we write to the component's src dir?
            src_dir = self.project.impl_src_dir(cid)
            try:
                src_dir.mkdir(parents=True, exist_ok=True)
                env_checks.append(EnvironmentCheck(
                    check=f"src dir writable: {src_dir}",
                    passed=True,
                ))
            except Exception as e:
                env_checks.append(EnvironmentCheck(
                    check=f"src dir writable: {src_dir}",
                    passed=False,
                    detail=str(e),
                ))

            # Does the test file exist?
            test_path = self.project.test_code_path(cid)
            env_checks.append(EnvironmentCheck(
                check=f"test file exists: {test_path}",
                passed=test_path.exists(),
            ))

            # ── 3. Red lines (universal + contract-derived) ──
            red_lines = [
                RedLine(
                    rule="Do not delete test files",
                    rationale="Tests are the contract — they define correctness",
                    action_on_violation="stop_and_report",
                ),
                RedLine(
                    rule="Do not modify contract files (interface.json)",
                    rationale="Contracts are the spec — implementation adapts to them, not the reverse",
                    action_on_violation="stop_and_report",
                ),
                RedLine(
                    rule="Do not use try/except to silence import errors",
                    rationale="Import errors indicate missing exports — fix the export, don't hide the error",
                    action_on_violation="stop_and_report",
                ),
                RedLine(
                    rule="Do not use eval(), exec(), or __import__() for dynamic dispatch",
                    rationale="Use registry dicts or match statements instead",
                    action_on_violation="use_registry_pattern",
                ),
            ]

            # ── 4. Contingencies ──
            contingencies = [
                Contingency(
                    trigger="Tests fail after 3 implementation iterations",
                    response="Stop and report the failure pattern instead of continuing to modify",
                ),
                Contingency(
                    trigger="Cannot resolve an import from another component",
                    response="Check conftest.py path wiring; do not stub the import",
                ),
                Contingency(
                    trigger="Environment check failed (src dir not writable)",
                    response="Report the environment issue; do not attempt workarounds",
                ),
            ]

            # Add lessons-derived contingencies
            for lesson in kindex_lessons:
                contingencies.append(Contingency(
                    trigger=f"Similar pattern to previous failure",
                    response=f"Apply lesson: {lesson}",
                    learned_from="kindex",
                ))

            plan = PreflightPlan(
                component_id=cid,
                red_lines=red_lines,
                contingencies=contingencies,
                environment_checks=env_checks,
                kindex_lessons=kindex_lessons,
                created_at=datetime.now().isoformat(),
            )
            plans.append(plan)

            # Submit plan via signet_preflight_submit if available,
            # fall back to local file storage.  The MCP submission
            # starts the timed lockout and HMAC-signs the plan.
            preflight_dir = self.project.project_dir / ".pact" / "preflight"
            preflight_dir.mkdir(parents=True, exist_ok=True)
            plan_path = preflight_dir / f"{cid}.json"
            plan_path.write_text(plan.model_dump_json(indent=2))

            try:
                import subprocess as _sp
                _sp.run(
                    ["claude", "mcp", "call", "signet_preflight_submit",
                     "--", plan.model_dump_json()],
                    capture_output=True, text=True, timeout=10,
                )
                logger.info("Preflight submitted via signet for %s", cid)
            except Exception:
                logger.debug(
                    "signet_preflight_submit not available — plan saved locally for %s",
                    cid,
                )

        # Check for any failed environment checks
        failed_checks = [
            (p.component_id, c)
            for p in plans
            for c in p.environment_checks
            if not c.passed
        ]
        if failed_checks:
            details = "; ".join(
                f"{cid}: {c.check} — {c.detail}" for cid, c in failed_checks
            )
            logger.warning("Preflight: %d environment checks failed: %s", len(failed_checks), details)

        self.project.append_audit(
            "preflight",
            f"{len(plans)} plans, {sum(len(p.red_lines) for p in plans)} red lines, "
            f"{sum(len(p.contingencies) for p in plans)} contingencies, "
            f"{sum(len(p.kindex_lessons) for p in plans)} kindex lessons",
        )

        logger.info(
            "Preflight complete: %d component plans established",
            len(plans),
        )

        advance_phase(state)
        return state

    async def _phase_implement(
        self, state: RunState, sops: str,
        target_components: set[str] | None = None,
        external_context: str = "",
        learnings: str = "",
    ) -> RunState:
        """Implement all leaf components."""
        tree = self.project.load_tree()
        if not tree:
            state.fail("No decomposition tree found")
            return state

        # Inject standards into external context
        if self._standards_brief:
            external_context = self._standards_brief + "\n\n" + external_context if external_context else self._standards_brief

        max_attempts = (
            self.project_config.max_implementation_attempts
            or self.global_config.max_implementation_attempts
        )

        pcfg = resolve_parallel_config(self.project_config, self.global_config)

        # Detect if code_author backend supports iterative implementation
        code_author_backend = resolve_backend(
            "code_author", self.project_config, self.global_config,
        )
        code_author_model = resolve_model(
            "code_author", self.project_config, self.global_config,
        )

        if code_author_backend in ("claude_code", "claude_code_team"):
            # Iterative path: Claude Code writes, tests, fixes in a loop
            logger.info(
                "Using iterative Claude Code implementation (%s, %s)",
                code_author_backend, code_author_model,
            )
            results = await implement_all_iterative(
                project=self.project,
                tree=tree,
                budget=self.budget,
                model=code_author_model,
                sops=sops,
                parallel=pcfg.parallel,
                max_concurrent=pcfg.max_concurrent,
                target_components=target_components,
                external_context=external_context,
                learnings=learnings,
                timeout=self.global_config.autonomous_timeout or 1800,
            )
        else:
            # API-based path: structured extraction with blind retries
            agent = self._make_agent("code_author")
            try:
                results = await implement_all(
                    agent, self.project, tree,
                    max_attempts=max_attempts,
                    sops=sops,
                    max_plan_revisions=self.global_config.max_plan_revisions,
                    parallel=pcfg.parallel,
                    competitive=pcfg.competitive,
                    competitive_agents=pcfg.agent_count,
                    max_concurrent=pcfg.max_concurrent,
                    agent_factory=self._make_agent_factory("code_author") if (pcfg.parallel or pcfg.competitive) else None,
                    target_components=target_components,
                    external_context=external_context,
                    learnings=learnings,
                )
            finally:
                await agent.close()

        # --- Common post-implementation logic (both paths) ---

        # Record health metrics from implementation results
        try:
            from pact.health import HealthMetrics
            metrics = HealthMetrics.from_dict(state.health_snapshot)

            for cid, r in results.items():
                if r.all_passed:
                    metrics.record_attempt(success=True)
                    metrics.implementations_produced += 1
                    # tests_produced already counted in _phase_decompose
                else:
                    metrics.record_attempt(success=False)
                    metrics.record_component_failure(cid)
                if r.passed > 0 or r.failed > 0:
                    metrics.record_test_run(r.passed, r.failed)

            # Detect cascades — include previously-failed tree nodes
            # so cross-phase cascades (implement → integrate) are visible.
            # Use max() not accumulation: detect_cascade returns the total
            # cascade picture at this point in time. Accumulating would
            # double-count persistent cascades across bursts.
            failed_set = {cid for cid, r in results.items() if not r.all_passed}
            if tree:
                for nid, node in tree.nodes.items():
                    if node.implementation_status == "failed":
                        failed_set.add(nid)
            if failed_set and tree:
                cascades = detect_cascade(tree, failed_set)
                metrics.cascade_events = max(metrics.cascade_events, cascades)

            state.health_snapshot = metrics.to_dict()
        except Exception:
            pass  # Health recording never blocks

        # Update task list statuses
        try:
            from pact.task_list import update_task_status
            task_list = self.project.load_task_list()
            if task_list:
                tree = self.project.load_tree()
                if tree:
                    for cid, r in results.items():
                        node = tree.nodes.get(cid)
                        impl_status = "tested" if r.all_passed else "failed"
                        update_task_status(task_list, cid, impl_status)
                    self.project.save_task_list(task_list)
        except Exception as e:
            logger.debug("Task list update failed: %s", e)

        # Check for systemic failure pattern
        systemic = detect_systemic_failure(results)
        if systemic:
            logger.warning(
                "Systemic failure detected: %s (%d components). %s",
                systemic.pattern_type,
                len(systemic.affected_components),
                systemic.recommendation,
            )
            state.pause(
                f"Systemic failure: {systemic.pattern_type} "
                f"({len(systemic.affected_components)} components). "
                f"{systemic.recommendation}"
            )
            self.project.save_state(state)
            self.project.append_audit(
                "systemic_failure",
                f"{systemic.pattern_type}: {systemic.sample_error[:200]}",
            )
            return state

        # Emit per-component events
        for cid, r in results.items():
            if r.all_passed:
                await self.event_bus.emit(PactEvent(
                    kind="component_complete",
                    project_name=self.project.project_dir.name,
                    component_id=cid,
                    test_results=r,
                ))
            else:
                await self.event_bus.emit(PactEvent(
                    kind="component_failed",
                    project_name=self.project.project_dir.name,
                    component_id=cid,
                    detail=f"{r.failed}/{r.total} tests failed",
                    test_results=r,
                ))

        # Check for failures
        failed = [cid for cid, r in results.items() if not r.all_passed]
        if failed:
            state.phase = "diagnose"
            state.pause_reason = f"Components failed: {', '.join(failed)}"
        else:
            advance_phase(state)  # -> integrate

        return state

    async def _phase_integrate(
        self, state: RunState, sops: str,
        external_context: str = "",
        learnings: str = "",
    ) -> RunState:
        """Integrate all non-leaf components."""
        tree = self.project.load_tree()
        if not tree:
            state.fail("No decomposition tree found")
            return state

        # Inject standards into external context
        if self._standards_brief:
            external_context = self._standards_brief + "\n\n" + external_context if external_context else self._standards_brief

        # Check if there are any non-leaf components
        non_leaves = [n for n in tree.nodes.values() if n.children]
        if not non_leaves:
            # No integration needed — single component or all leaves
            advance_phase(state)  # -> polish
            return state

        pcfg = resolve_parallel_config(self.project_config, self.global_config)

        code_author_backend = resolve_backend(
            "code_author", self.project_config, self.global_config,
        )
        code_author_model = resolve_model(
            "code_author", self.project_config, self.global_config,
        )

        if code_author_backend in ("claude_code", "claude_code_team"):
            results = await integrate_all_iterative(
                project=self.project,
                tree=tree,
                budget=self.budget,
                model=code_author_model,
                sops=sops,
                parallel=pcfg.parallel,
                max_concurrent=pcfg.max_concurrent,
                external_context=external_context,
                learnings=learnings,
            )
        else:
            agent = self._make_agent("code_author")
            try:
                results = await integrate_all(
                    agent, self.project, tree,
                    max_attempts=self.global_config.max_implementation_attempts,
                    sops=sops,
                    parallel=pcfg.parallel,
                    max_concurrent=pcfg.max_concurrent,
                    agent_factory=self._make_agent_factory("code_author") if pcfg.parallel else None,
                )
            finally:
                await agent.close()

        # Record health metrics from integration results
        try:
            from pact.health import HealthMetrics
            metrics = HealthMetrics.from_dict(state.health_snapshot)

            for cid, r in results.items():
                if r.all_passed:
                    metrics.record_attempt(success=True)
                    metrics.implementations_produced += 1
                else:
                    metrics.record_attempt(success=False)
                    metrics.record_component_failure(cid)
                if r.passed > 0 or r.failed > 0:
                    metrics.record_test_run(r.passed, r.failed)

            # Detect cascades — include previously-failed tree nodes
            # so implement→integrate cascades are visible.
            # Use max() not accumulation to avoid double-counting
            # persistent cascades across bursts.
            failed_set = {cid for cid, r in results.items() if not r.all_passed}
            if tree:
                for nid, node in tree.nodes.items():
                    if node.implementation_status == "failed":
                        failed_set.add(nid)
            if failed_set and tree:
                cascades = detect_cascade(tree, failed_set)
                metrics.cascade_events = max(metrics.cascade_events, cascades)

            state.health_snapshot = metrics.to_dict()
        except Exception:
            pass  # Health recording never blocks

        # Update task list statuses for integration results
        try:
            from pact.task_list import update_task_status
            task_list = self.project.load_task_list()
            if task_list:
                for cid, r in results.items():
                    impl_status = "tested" if r.all_passed else "failed"
                    update_task_status(task_list, cid, impl_status)
                self.project.save_task_list(task_list)
        except Exception as e:
            logger.debug("Task list update failed: %s", e)

        failed = [cid for cid, r in results.items() if not r.all_passed]
        if failed:
            state.phase = "diagnose"
            state.pause_reason = f"Integration failed: {', '.join(failed)}"
        else:
            advance_phase(state)  # -> polish

        return state

    async def _phase_arbiter(self, state: RunState) -> RunState:
        """Arbiter gate phase — generate access_graph.json and register with Arbiter.

        1. Generate access_graph.json from contracts
        2. If Arbiter configured: POST to /register, handle HUMAN_GATE
        3. If not configured or --skip-arbiter: skip with warning
        4. If Ledger configured: validate contracts against ledger assertions
        """
        from pact.access_graph import generate_access_graph, save_access_graph
        from pact.arbiter import ArbiterResponse, register_with_arbiter, resolve_arbiter_endpoint
        from pact.lifecycle import advance_phase

        # Generate access_graph.json
        trust_policy = state.constrain_context.get("trust_policy") if state.constrain_context else None
        graph = generate_access_graph(self.project, trust_policy=trust_policy)
        save_access_graph(self.project, graph)

        self.project.append_audit(
            "access_graph_generated",
            f"{len(graph.get('components', []))} components, {len(graph.get('edges', []))} edges",
        )

        # Ledger validation (if configured)
        ledger_dir = self.project_config.ledger_dir
        if ledger_dir:
            from pact.ledger import load_all_ledger_assertions, validate_contract_against_ledger

            all_assertions = load_all_ledger_assertions(ledger_dir)
            contracts = self.project.load_all_contracts()
            all_violations: list[str] = []

            for cid, assertions in all_assertions.items():
                if cid in contracts:
                    violations = validate_contract_against_ledger(contracts[cid], assertions)
                    all_violations.extend(violations)

            if all_violations:
                state.pause(
                    f"Ledger violations: {'; '.join(all_violations[:3])}"
                    + (f" (+{len(all_violations) - 3} more)" if len(all_violations) > 3 else "")
                )
                return state

        # Arbiter registration
        endpoint = resolve_arbiter_endpoint(self.project_config.arbiter_endpoint)

        if self.project_config.skip_arbiter or not endpoint:
            if not endpoint:
                logger.warning("Arbiter not configured — skipping phase 8.5")
            else:
                logger.info("Arbiter gate skipped (--skip-arbiter)")
            advance_phase(state)
            return state

        response = await register_with_arbiter(endpoint, graph)
        state.arbiter_response = response.raw

        if response.human_gate_required:
            # Write gate file and pause
            gate_path = self.project.project_dir / "arbiter_gate.json"
            import json
            gate_path.write_text(json.dumps(response.raw, indent=2))
            state.pause("Arbiter requires human review — see arbiter_gate.json")
            return state

        if response.soak_requirements:
            state.arbiter_response["soak"] = response.soak_requirements

        advance_phase(state)
        return state

    async def _phase_polish(self, state: RunState) -> RunState:
        """Polish phase — cross-component regression check + quality validation.

        1. Re-run all contract tests across all components (catch regressions)
        2. Detect stub/placeholder code
        3. Check for absolute path leakage
        4. Validate exports
        5. If regressions: transition to diagnose
        6. If only warnings: log and advance to complete
        7. If clean: advance to complete
        """
        tree = self.project.load_tree()
        if not tree:
            advance_phase(state)
            return state

        contracts = self.project.load_all_contracts()
        test_suites = self.project.load_all_test_suites()
        language = self.project.language
        project_dir_str = str(self.project.project_dir)

        regression_failures: list[str] = []
        warnings: list[str] = []

        # 1. Re-run all contract tests
        for cid in tree.topological_order():
            node = tree.nodes.get(cid)
            if not node:
                continue

            test_suite = test_suites.get(cid)
            if not test_suite or not test_suite.generated_code:
                continue

            test_file = self.project.test_code_path(cid)
            if not test_file.exists():
                continue

            # Determine src_dir: leaf -> impl_src_dir, non-leaf -> composition_dir
            if node.children:
                src_dir = self.project.composition_dir(cid)
                extra_paths = [self.project.impl_src_dir(child) for child in node.children]
            else:
                src_dir = self.project.impl_src_dir(cid)
                extra_paths = []

            try:
                results = await run_contract_tests(
                    test_file, src_dir, extra_paths=extra_paths,
                    language=language,
                    project_dir=self.project.project_dir,
                )
                if not results.all_passed:
                    regression_failures.append(
                        f"{cid}: {results.failed + results.errors}/{results.total} failed"
                    )
            except Exception as e:
                regression_failures.append(f"{cid}: test error: {e}")

        # 2. Detect stubs in each component's src dir
        for cid in tree.topological_order():
            node = tree.nodes.get(cid)
            if not node:
                continue
            if node.children:
                src_dir = self.project.composition_dir(cid)
            else:
                src_dir = self.project.impl_src_dir(cid)
            stubs = detect_stubs(src_dir, language)
            for w in stubs:
                warnings.append(f"[stub] {cid}: {w}")

        # 3. Check for absolute path leakage
        for cid in tree.topological_order():
            node = tree.nodes.get(cid)
            if not node:
                continue
            if node.children:
                src_dir = self.project.composition_dir(cid)
            else:
                src_dir = self.project.impl_src_dir(cid)
            path_warnings = _check_absolute_paths(src_dir, project_dir_str, language)
            for w in path_warnings:
                warnings.append(f"[path] {cid}: {w}")

        # 4. Validate exports
        for cid, contract in contracts.items():
            node = tree.nodes.get(cid)
            if not node:
                continue
            if node.children:
                src_dir = self.project.composition_dir(cid)
            else:
                src_dir = self.project.impl_src_dir(cid)
            missing = validate_and_fix_exports(src_dir, contract)
            for name in missing:
                warnings.append(f"[export] {cid}: missing export '{name}'")

        # 5. Decide outcome
        if regression_failures:
            logger.warning(
                "Polish found %d test regressions: %s",
                len(regression_failures), regression_failures,
            )
            state.phase = "diagnose"
            state.pause_reason = f"Polish regressions: {', '.join(regression_failures)}"
            self.project.append_audit(
                "polish",
                f"regressions={len(regression_failures)} warnings={len(warnings)}",
            )
            return state

        # 6. North-star validation: do contracts fulfill the original task?
        try:
            task_text = self.project.load_task()
            if task_text:
                from pact.contracts import validate_north_star
                interview = self.project.load_interview()
                ac = interview.acceptance_criteria if interview else None
                ns_warnings = validate_north_star(task_text, tree, contracts, acceptance_criteria=ac)
                for w in ns_warnings:
                    logger.warning("North-star: %s", w)
                    warnings.append(f"[north-star] {w}")
                if ns_warnings:
                    self.project.append_audit(
                        "north_star_validation",
                        f"{len(ns_warnings)} warnings",
                    )
        except Exception as e:
            logger.debug("North-star validation skipped: %s", e)

        # 7. Run Goodhart (hidden) acceptance tests
        goodhart_failures = await self._run_goodhart_tests(tree, language)

        if goodhart_failures:
            logger.info(
                "Goodhart failures in %d components — entering remediation",
                len(goodhart_failures),
            )
            state = await self._goodhart_remediate(
                state, tree, contracts, test_suites, goodhart_failures, language,
            )

        if warnings:
            logger.warning(
                "Polish found %d warnings (non-blocking): %s",
                len(warnings), warnings[:5],
            )

        # If remediation didn't push to diagnose, advance to complete
        if state.phase == "polish":
            advance_phase(state)  # -> complete

        self.project.append_audit(
            "polish",
            f"regressions={len(regression_failures)} warnings={len(warnings)} "
            f"goodhart_failures={len(goodhart_failures)}",
        )

        return state

    def _phase_retrospective(self, state: RunState) -> RunState:
        """Retrospective phase — analyze the completed run and capture lessons.

        Generates a RunRetrospective with cost/duration analysis, failure
        patterns, and inferred lessons.  Saves to .pact/retrospectives/.
        Feeds lessons into the project's learnings for future runs.

        This is a mechanical phase — no LLM calls, no cost.
        """
        try:
            from pact.retrospective import generate_retrospective

            retro = generate_retrospective(self.project.project_dir)

            # Feed lessons into learnings file for future runs
            if retro.lessons:
                self.project.append_learning({
                    "source": "retrospective",
                    "run_id": retro.run_id,
                    "lessons": retro.lessons,
                    "failure_patterns": retro.failure_patterns,
                })

            summary_parts = [
                f"cost=${retro.total_cost:.4f}",
                f"components={retro.components_count}",
                f"lessons={len(retro.lessons)}",
            ]
            if retro.failure_patterns:
                summary_parts.append(f"failure_patterns={len(retro.failure_patterns)}")

            self.project.append_audit(
                "retrospective",
                " ".join(summary_parts),
            )

            logger.info(
                "Retrospective: %d lessons, %d failure patterns",
                len(retro.lessons), len(retro.failure_patterns),
            )

        except Exception as e:
            logger.debug("Retrospective generation failed (non-blocking): %s", e)
            self.project.append_audit("retrospective", f"failed: {e}")

        advance_phase(state)  # -> complete
        return state

    async def _run_goodhart_tests(
        self,
        tree: DecompositionTree,
        language: str,
    ) -> dict[str, TestResults]:
        """Run Goodhart tests for all components. Returns map of failures only."""
        goodhart_failures: dict[str, TestResults] = {}

        for cid in tree.topological_order():
            node = tree.nodes.get(cid)
            if not node:
                continue

            goodhart_suite = self.project.load_goodhart_suite(cid)
            if not goodhart_suite or not goodhart_suite.generated_code:
                continue

            test_file = self.project.goodhart_test_code_path(cid)
            if not test_file.exists():
                continue

            # Determine src_dir
            if node.children:
                src_dir = self.project.composition_dir(cid)
                extra_paths = [self.project.impl_src_dir(child) for child in node.children]
            else:
                src_dir = self.project.impl_src_dir(cid)
                extra_paths = []

            try:
                results = await run_contract_tests(
                    test_file, src_dir, extra_paths=extra_paths,
                    language=language,
                    project_dir=self.project.project_dir,
                )
                if not results.all_passed:
                    goodhart_failures[cid] = results
                    logger.info(
                        "Goodhart test failures for %s: %d/%d failed",
                        cid, results.failed + results.errors, results.total,
                    )
            except Exception as e:
                logger.warning("Goodhart test error for %s: %s", cid, e)

        return goodhart_failures

    async def _goodhart_remediate(
        self,
        state: RunState,
        tree: DecompositionTree,
        contracts: dict,
        test_suites: dict,
        goodhart_failures: dict[str, TestResults],
        language: str,
    ) -> RunState:
        """Graduated-disclosure remediation for Goodhart test failures.

        Re-implements failing components with behavioral hints that get
        progressively more specific. Never shares actual test code.
        """
        max_goodhart_attempts = getattr(
            self.project_config, "max_goodhart_attempts", None,
        ) or 2

        sops = self.project.load_sops()

        for attempt in range(1, max_goodhart_attempts + 1):
            logger.info(
                "Goodhart remediation attempt %d/%d for %d components",
                attempt, max_goodhart_attempts, len(goodhart_failures),
            )

            for cid, results in list(goodhart_failures.items()):
                contract = contracts.get(cid)
                test_suite = test_suites.get(cid)
                if not contract or not test_suite:
                    continue

                goodhart_suite = self.project.load_goodhart_suite(cid)
                if not goodhart_suite:
                    continue

                hint = _build_goodhart_hint(attempt, results, goodhart_suite)

                # Re-implement with hint injected as learnings
                dep_contracts = {
                    dep_id: contracts[dep_id]
                    for dep_id in contract.dependencies
                    if dep_id in contracts
                }

                code_author_backend = resolve_backend(
                    "code_author", self.project_config, self.global_config,
                )
                code_author_model = resolve_model(
                    "code_author", self.project_config, self.global_config,
                )

                try:
                    if code_author_backend in ("claude_code", "claude_code_team"):
                        await implement_component_iterative(
                            project=self.project,
                            component_id=cid,
                            contract=contract,
                            test_suite=test_suite,
                            budget=self.budget,
                            model=code_author_model,
                            dependency_contracts=dep_contracts or None,
                            sops=sops,
                            learnings=hint,
                        )
                    else:
                        from pact.implementer import implement_component
                        agent = self._make_agent("code_author")
                        try:
                            await implement_component(
                                agent, self.project, cid, contract,
                                test_suite,
                                dependency_contracts=dep_contracts or None,
                                sops=sops,
                                learnings=hint,
                            )
                        finally:
                            await agent.close()
                except Exception as e:
                    logger.warning("Goodhart remediation failed for %s: %s", cid, e)

            # Re-run ALL Goodhart tests (cross-component regression check)
            goodhart_failures = await self._run_goodhart_tests(tree, language)

            if not goodhart_failures:
                logger.info("All Goodhart tests pass after attempt %d", attempt)
                self.project.append_audit(
                    "goodhart_remediation",
                    f"All passed after {attempt} attempt(s)",
                )
                return state

        # Still failing after max attempts — log and proceed
        remaining = list(goodhart_failures.keys())
        logger.warning(
            "Goodhart tests still failing after %d attempts for: %s",
            max_goodhart_attempts, remaining,
        )
        self.project.append_audit(
            "goodhart_remediation",
            f"Max attempts ({max_goodhart_attempts}) reached, "
            f"still failing: {', '.join(remaining)}",
        )

        return state

    async def build_component(
        self, component_id: str,
        competitive: bool = False,
        num_agents: int = 2,
    ) -> RunState:
        """Build (or rebuild) a specific component.

        Archives any existing implementation as informational context,
        then implements the component against its contract.
        """
        state = self.project.load_state()
        # Snapshot tokens before build for delta measurement
        pre_in, pre_out = self.budget.project_tokens

        tree = self.project.load_tree()
        if not tree:
            state.fail("No decomposition tree found")
            self.project.save_state(state)
            return state

        node = tree.nodes.get(component_id)
        if not node:
            state.fail(f"Component not found: {component_id}")
            self.project.save_state(state)
            return state

        sops = self.project.load_sops()
        contracts = self.project.load_all_contracts()
        test_suites = self.project.load_all_test_suites()

        if component_id not in contracts:
            state.fail(f"No contract for component: {component_id}")
            self.project.save_state(state)
            return state
        if component_id not in test_suites:
            state.fail(f"No test suite for component: {component_id}")
            self.project.save_state(state)
            return state

        # Archive current implementation as context for new agent
        archive_id = self.project.archive_current_impl(
            component_id, reason="Rebuilt via cf build",
        )
        if archive_id:
            self.project.append_audit(
                "archive",
                f"Archived {component_id} as {archive_id} for rebuild",
            )
            logger.info("Archived existing impl as %s", archive_id)

        max_attempts = (
            self.project_config.max_implementation_attempts
            or self.global_config.max_implementation_attempts
        )

        contract = contracts[component_id]
        dep_contracts = {
            dep_id: contracts[dep_id]
            for dep_id in contract.dependencies
            if dep_id in contracts
        }

        # Detect backend for routing
        code_author_backend = resolve_backend(
            "code_author", self.project_config, self.global_config,
        )
        code_author_model = resolve_model(
            "code_author", self.project_config, self.global_config,
        )

        if code_author_backend in ("claude_code", "claude_code_team") and not competitive:
            # Iterative path: Claude Code writes, tests, fixes in a loop
            logger.info(
                "Building %s iteratively via Claude Code (%s)",
                component_id, code_author_model,
            )
            test_results = await implement_component_iterative(
                project=self.project,
                component_id=component_id,
                contract=contract,
                test_suite=test_suites[component_id],
                budget=self.budget,
                model=code_author_model,
                dependency_contracts=dep_contracts or None,
                sops=sops,
            )
        elif competitive:
            from pact.implementer import implement_component_competitive
            agent_factory = self._make_agent_factory("code_author")
            test_results = await implement_component_competitive(
                agent_factory,
                self.project, component_id, contract,
                test_suites[component_id],
                dependency_contracts=dep_contracts or None,
                max_attempts=max_attempts,
                num_agents=num_agents,
                sops=sops,
                max_plan_revisions=self.global_config.max_plan_revisions,
            )
        else:
            from pact.implementer import implement_component
            agent = self._make_agent("code_author")
            try:
                test_results = await implement_component(
                    agent, self.project, component_id, contract,
                    test_suites[component_id],
                    dependency_contracts=dep_contracts or None,
                    max_attempts=max_attempts,
                    sops=sops,
                    max_plan_revisions=self.global_config.max_plan_revisions,
                )
            finally:
                await agent.close()

        # Update tree status
        node.implementation_status = (
            "tested" if test_results.all_passed else "failed"
        )
        node.test_results = test_results
        self.project.save_tree(tree)

        # Update task list statuses
        try:
            from pact.task_list import update_task_status
            task_list = self.project.load_task_list()
            if task_list:
                update_task_status(
                    task_list, component_id,
                    node.implementation_status,
                )
                self.project.save_task_list(task_list)
        except Exception as e:
            logger.debug("Task list update failed: %s", e)

        self.project.append_audit(
            "build",
            f"{component_id}: {test_results.passed}/{test_results.total} passed"
            + (f" (competitive, {num_agents} agents)" if competitive else ""),
        )

        # Record health metrics for build_component
        try:
            from pact.health import HealthMetrics
            metrics = HealthMetrics.from_dict(state.health_snapshot)

            # Token delta for this build
            post_in, post_out = self.budget.project_tokens
            delta_in = post_in - pre_in
            delta_out = post_out - pre_out
            if delta_in > 0 or delta_out > 0:
                metrics.record_phase_tokens("implement", delta_in, delta_out)
                metrics.record_generation(delta_in, delta_out)

            if test_results.all_passed:
                metrics.record_attempt(success=True)
                metrics.implementations_produced += 1
            else:
                metrics.record_attempt(success=False)
                metrics.record_component_failure(component_id)
            if test_results.passed > 0 or test_results.failed > 0:
                metrics.record_test_run(test_results.passed, test_results.failed)

            metrics.total_spend = self.budget.project_spend
            metrics.budget_cap = self.budget.per_project_cap
            state.health_snapshot = metrics.to_dict()
        except Exception:
            pass  # Health recording never blocks

        # Sync budget tracker totals to persistent state
        in_tok, out_tok = self.budget.project_tokens
        state.total_tokens = in_tok + out_tok
        state.total_cost_usd = self.budget.project_spend

        state.last_check_in = datetime.now().isoformat()
        self.project.save_state(state)
        return state

    async def _check_register_drift(
        self, state: RunState, metrics: "HealthMetrics",
    ) -> None:
        """Probabilistically check recent artifacts for register drift.

        Uses fast-tier model for minimal cost. Check rate is tunable
        via register_check_rate in pact.yaml (default 0.1 = 10%).
        """
        from pact.register import check_artifacts_for_drift

        check_rate = self.project_config.register_check_rate

        # Collect component IDs with implementations
        implemented = [
            t.component_id for t in state.component_tasks
            if t.status in ("completed", "implementing", "failed")
            and t.attempts > 0
        ]
        if not implemented:
            return

        # Use fast-tier model for cost efficiency
        agent = self._make_agent("decomposer")
        try:
            # Temporarily switch to fast model
            from pact.config import resolve_model_tiers
            tiers = resolve_model_tiers(self.global_config, self.project_config)
            original_model = agent._model
            agent.set_model(tiers.fast)

            results = await check_artifacts_for_drift(
                agent=agent,
                project_dir=self.project.project_dir,
                expected_register=state.processing_register,
                component_ids=implemented,
                check_rate=check_rate,
            )

            # Record results in health metrics
            for cid, consistent, confidence in results:
                metrics.record_register_check(drifted=not consistent)
                if not consistent:
                    logger.warning(
                        "Register drift detected in %s (confidence=%.2f)",
                        cid, confidence,
                    )

            agent.set_model(original_model)
        finally:
            await agent.close()

    def _check_health_and_remediate(
        self, state: RunState, *, phase: str = "",
    ) -> RunState:
        """Check health and apply automated remedies if needed.

        Auto-safe remedies (skip_cascaded, informational) are applied
        immediately. Config-changing remedies (max_plan_revisions, shaping)
        are surfaced as proposals in the pause message — the user accepts
        them via FIFO directive.

        Also persists the overall_status and critical findings into
        health_snapshot so that format_run_summary can read them
        without re-running check_health (which has logging side effects).

        The ``phase`` parameter tells check_health which checks are
        appropriate.  Pre-artifact phases (interview, shape) skip
        artifact-production metrics since those phases produce planning
        outputs, not contracts or code.
        """
        from pact.health import HealthMetrics, check_health, should_abort, suggest_remedies

        metrics = HealthMetrics.from_dict(state.health_snapshot)
        thresholds = getattr(self.project_config, "health_thresholds", {}) or {}
        report = check_health(metrics, thresholds=thresholds, phase=phase)

        # Persist report summary into snapshot for side-effect-free reads
        snapshot = state.health_snapshot
        snapshot["_overall_status"] = report.overall_status.value
        snapshot["_critical_findings"] = [
            f"[{f.condition}] {f.message[:80]}"
            for f in report.critical_findings[:3]
        ]
        state.health_snapshot = snapshot

        if report.overall_status.value != "healthy":
            all_remedies = suggest_remedies(report, metrics)
            auto_applied = self._apply_auto_remedies(
                [r for r in all_remedies if r.auto], state,
            )
            proposed = [r for r in all_remedies if not r.auto]

            if auto_applied:
                self.project.append_audit(
                    "health_remedy",
                    "; ".join(auto_applied),
                )

            if should_abort(report):
                parts = []
                if auto_applied:
                    parts.append(f"Applied: {'; '.join(auto_applied)}")
                if proposed:
                    proposals = "; ".join(r.description for r in proposed)
                    parts.append(f"Proposed: {proposals}")
                    # Store proposals in snapshot for CLI display
                    snapshot["_proposed_remedies"] = [
                        {"kind": r.kind, "description": r.description, "fifo_hint": r.fifo_hint}
                        for r in proposed
                    ]
                    state.health_snapshot = snapshot

                summary = ". ".join(parts) if parts else "no remedies available"
                state.pause(
                    f"Health check: dysmemic pressure detected. {summary}. "
                    f"Review with 'pact health'."
                )

        return state

    def _apply_auto_remedies(self, remedies: list, state: RunState) -> list[str]:
        """Apply auto-safe remedies only. Returns descriptions of what was applied.

        Only informational remedies are auto-safe. Everything that modifies
        state or config is a proposal for the user — the system does not
        unilaterally reduce its own degrees of freedom.
        """
        applied: list[str] = []

        for remedy in remedies:
            try:
                if remedy.kind == "informational":
                    applied.append(remedy.description)

            except Exception as e:
                logger.debug("Auto-remedy '%s' failed: %s", remedy.kind, e)

        return applied

    def apply_remedy(self, kind: str, value: str | int | None = None) -> str:
        """Apply a user-approved remedy by kind. Called from daemon on FIFO directive.

        Returns a description of what was applied, or empty string if nothing changed.
        """
        if kind == "max_plan_revisions":
            target = int(value) if value is not None else 1
            old_val = self.global_config.max_plan_revisions
            if old_val != target:
                self.global_config.max_plan_revisions = max(1, target)
                msg = f"Reduced max_plan_revisions {old_val} -> {self.global_config.max_plan_revisions}"
                self.project.append_audit("remedy_applied", msg)
                return msg

        elif kind == "shaping":
            if self.global_config.shaping:
                self.global_config.shaping = False
                msg = "Disabled shaping"
                self.project.append_audit("remedy_applied", msg)
                return msg

        elif kind == "skip_cascaded":
            tree = self.project.load_tree()
            if tree:
                currently_failed = {
                    nid for nid, n in tree.nodes.items()
                    if n.implementation_status == "failed"
                }
                skipped = []
                for cid in currently_failed:
                    for sub_id in tree.subtree(cid):
                        if sub_id == cid:
                            continue
                        node = tree.nodes.get(sub_id)
                        if node and node.implementation_status == "pending":
                            node.implementation_status = "failed"
                            skipped.append(sub_id)
                if skipped:
                    self.project.save_tree(tree)
                    msg = f"Skipped cascaded: {', '.join(skipped)}"
                    self.project.append_audit("remedy_applied", msg)
                    return msg

        return ""

    async def _phase_diagnose(self, state: RunState, sops: str) -> RunState:
        """Diagnose failures and determine recovery action.

        Increments phase_cycles each time we enter diagnose. If the cycle
        count exceeds max_phase_cycles, pauses for human review instead of
        looping back to implement/integrate indefinitely.
        """
        state.phase_cycles += 1
        max_cycles = self.global_config.max_phase_cycles

        if state.phase_cycles > max_cycles:
            state.pause(
                f"Phase cycle limit reached ({state.phase_cycles} diagnose cycles, "
                f"max={max_cycles}). Human review required."
            )
            logger.warning(
                "Phase cycle limit reached (%d > %d) — pausing for human review",
                state.phase_cycles, max_cycles,
            )
            return state

        tree = self.project.load_tree()
        if not tree:
            state.fail("No tree for diagnosis")
            return state

        # Detect systemic failure before spending API calls on diagnosis
        failed_nodes = [
            n for n in tree.nodes.values()
            if n.implementation_status == "failed" and n.test_results
        ]
        failed_results = {
            n.component_id: n.test_results for n in failed_nodes
        }

        if len(failed_results) >= 3:
            pattern = detect_systemic_failure(failed_results)
            if pattern:
                state.pause(
                    f"Systemic failure in diagnose: {pattern.pattern_type} "
                    f"across {len(pattern.affected_components)} components. "
                    f"{pattern.recommendation}"
                )
                logger.warning(
                    "Systemic failure detected in diagnose: %s (%d components)",
                    pattern.pattern_type, len(pattern.affected_components),
                )
                return state

        agent = self._make_agent("trace_analyst")
        try:
            for node in failed_nodes:
                diagnosis = await diagnose_failure(
                    agent, self.project,
                    node.component_id,
                    node.test_results,
                    sops=sops,
                )

                if diagnosis:
                    action = determine_recovery_action(diagnosis)
                    if action == "reimplement":
                        node.implementation_status = "pending"
                        state.phase = "implement"
                    elif action == "reglue":
                        state.phase = "integrate"
                    elif action == "update_contract":
                        state.phase = "decompose"
                    elif action == "redesign":
                        # Andon cord: route design bugs back to decompose
                        # instead of failing. The phase_cycles counter
                        # prevents infinite loops (max_phase_cycles limit).
                        state.phase = "decompose"
                        logger.warning(
                            "Design bug in %s — andon cord: routing back to "
                            "decompose for redesign (cycle %d/%d)",
                            node.component_id,
                            state.phase_cycles,
                            self.global_config.max_phase_cycles,
                        )

            project_tree = self.project.load_tree()
            if project_tree:
                self.project.save_tree(tree)

        finally:
            await agent.close()

        return state
