import json
from collections.abc import Iterator
from pathlib import Path
from unittest.mock import patch, MagicMock

import click
import pytest

from colonyos.config import (
    BudgetConfig,
    ColonyConfig,
    LearningsConfig,
    ParallelImplementConfig,
    PhasesConfig,
    RecoveryConfig,
    RetryConfig,
    save_config,
)
from colonyos.learnings import learnings_path, LearningEntry, append_learnings
from colonyos.models import BranchRestoreError, Persona, Phase, PhaseResult, PreflightResult, ProjectInfo, ResumeState, RetryInfo, RunLog, RunStatus
from colonyos.orchestrator import (
    run,
    run_thread_fix,
    prepare_resume,
    _drain_injected_context,
    _extract_pr_number_from_log,
    _fail_run_log,
    _format_personas_block,
    _build_persona_agents,
    _build_ci_fix_prompt,
    _build_preflight_recovery_prompt,
    _build_implement_prompt,
    _build_fix_prompt,
    _build_learn_prompt,
    _build_plan_prompt,
    _build_deliver_prompt,
    _build_thread_fix_prompt,
    _build_ceo_prompt,
    _build_decision_prompt,
    _build_run_id,
    _inject_repo_map,
    _invoke_ui_factory,
    _load_run_log,
    _parse_learn_output,
    _parse_parent_tasks,
    _persona_slug,
    _run_ci_fix_loop,
    run_preflight_recovery,
    reviewer_personas,
    _build_persona_review_prompt,
    extract_review_verdict,
    _collect_review_findings,
    _save_review_artifact,
    _save_run_log,
    _validate_resume_preconditions,
    _compute_next_phase,
    _SKIP_MAP,
)
from colonyos.recovery import PreservationResult


REVIEWER_PERSONA = Persona(
    role="Engineer", expertise="Backend", perspective="Scale", reviewer=True
)
NON_REVIEWER_PERSONA = Persona(
    role="Designer", expertise="UX", perspective="Usability", reviewer=False
)


@pytest.fixture
def tmp_repo(tmp_path: Path) -> Path:
    """Lightweight fixture: directory structure only, NO git init."""
    (tmp_path / "cOS_prds").mkdir()
    (tmp_path / "cOS_tasks").mkdir()
    (tmp_path / "cOS_reviews").mkdir()
    (tmp_path / ".colonyos").mkdir()
    return tmp_path


@pytest.fixture
def tmp_git_repo(tmp_path: Path) -> Iterator[Path]:
    """Directory structure with subprocess.run mocked for git calls.

    Tests that call run() / run_thread_fix() use this. No real git init —
    subprocess.run is patched to return plausible defaults for all git commands.
    """
    (tmp_path / "cOS_prds").mkdir()
    (tmp_path / "cOS_tasks").mkdir()
    (tmp_path / "cOS_reviews").mkdir()
    (tmp_path / ".colonyos").mkdir()
    with patch("colonyos.orchestrator.subprocess.run", side_effect=_mock_git):
        yield tmp_path


@pytest.fixture
def config() -> ColonyConfig:
    return ColonyConfig(
        project=ProjectInfo(name="Test", description="test", stack="Python"),
        personas=[REVIEWER_PERSONA],
        model="test-model",
        budget=BudgetConfig(per_phase=1.0, per_run=10.0),
        phases=PhasesConfig(plan=True, implement=True, review=True, deliver=True),
    )


def _mock_git(*args, **kwargs):
    """Stub for subprocess.run — returns plausible defaults for git commands."""
    cmd = args[0] if args else kwargs.get("args", [])
    m = MagicMock()
    m.returncode = 0
    m.stderr = ""
    if "rev-parse" in cmd and "--abbrev-ref" in cmd:
        m.stdout = "main"
    elif "rev-parse" in cmd and "HEAD" in cmd:
        m.stdout = "abc123"
    elif "rev-parse" in cmd and "--verify" in cmd:
        m.returncode = 1  # branch doesn't exist yet → create it
        m.stdout = ""
    elif "status" in cmd and "--porcelain" in cmd:
        m.stdout = ""
    elif "rev-list" in cmd:
        m.stdout = "0"
    elif "diff" in cmd:
        m.stdout = ""
    elif "branch" in cmd and "--list" in cmd:
        m.stdout = ""
    else:
        m.stdout = ""
    return m


def _fake_phase_result(phase: Phase, success: bool = True) -> PhaseResult:
    return PhaseResult(
        phase=phase,
        success=success,
        cost_usd=0.01,
        duration_ms=100,
        session_id="test-session",
        artifacts={"result": "done"},
    )


def _approve_review_result() -> PhaseResult:
    return PhaseResult(
        phase=Phase.REVIEW,
        success=True,
        cost_usd=0.01,
        duration_ms=100,
        session_id="test-session",
        artifacts={"result": "VERDICT: approve\n\nFINDINGS:\n- None\n\nSYNTHESIS:\nLooks good."},
    )


def _request_changes_review_result() -> PhaseResult:
    return PhaseResult(
        phase=Phase.REVIEW,
        success=True,
        cost_usd=0.01,
        duration_ms=100,
        session_id="test-session",
        artifacts={"result": "VERDICT: request-changes\n\nFINDINGS:\n- src/foo.py: Missing tests\n\nSYNTHESIS:\nNeeds work."},
    )


class TestPhaseReviewEnum:
    def test_review_exists(self):
        assert Phase.REVIEW == "review"
        assert Phase.REVIEW.value == "review"

    def test_phase_ordering(self):
        phases = list(Phase)
        assert phases == [
            Phase.CEO,
            Phase.PLAN,
            Phase.TRIAGE,
            Phase.PREFLIGHT_RECOVERY,
            Phase.AUTO_RECOVERY,
            Phase.IMPLEMENT,
            Phase.REVIEW,
            Phase.DECISION,
            Phase.FIX,
            Phase.LEARN,
            Phase.VERIFY,
            Phase.DELIVER,
            Phase.CI_FIX,
            Phase.CONFLICT_RESOLVE,
            Phase.NUKE,
            Phase.QA,
            Phase.SWEEP,
        ]

    def test_fix_phase_exists(self):
        assert Phase.FIX == "fix"
        assert Phase.FIX.value == "fix"


class TestFormatPersonasBlock:
    def test_with_personas(self):
        personas = [
            Persona(role="Engineer", expertise="APIs", perspective="Scale")
        ]
        block = _format_personas_block(personas)
        assert "engineer" in block
        assert "subagent" in block.lower()

    def test_without_personas(self):
        block = _format_personas_block([])
        assert "senior engineer" in block

    def test_lists_subagent_keys(self):
        personas = [
            Persona(role="Steve Jobs", expertise="Product", perspective="Simplify"),
            Persona(role="Linus Torvalds", expertise="Kernel", perspective="Correctness"),
        ]
        block = _format_personas_block(personas)
        assert "`steve_jobs`" in block
        assert "`linus_torvalds`" in block


class TestBuildPersonaAgents:
    def test_builds_agents_per_persona(self):
        personas = [
            Persona(role="Steve Jobs", expertise="Product vision", perspective="Simplify"),
            Persona(role="Linus Torvalds", expertise="Kernel", perspective="Correctness"),
        ]
        agents = _build_persona_agents(personas)
        assert "steve_jobs" in agents
        assert "linus_torvalds" in agents
        assert agents["steve_jobs"].description.startswith("Steve Jobs")
        assert "Read" in (agents["linus_torvalds"].tools or [])

    def test_empty_personas(self):
        agents = _build_persona_agents([])
        assert agents == {}


class TestReviewerPersonas:
    def test_filters_to_reviewers(self):
        config = ColonyConfig(
            personas=[REVIEWER_PERSONA, NON_REVIEWER_PERSONA],
        )
        reviewers = reviewer_personas(config)
        assert len(reviewers) == 1
        assert reviewers[0].role == "Engineer"

    def test_no_reviewers(self):
        config = ColonyConfig(
            personas=[NON_REVIEWER_PERSONA],
        )
        assert reviewer_personas(config) == []

    def test_all_reviewers(self):
        p1 = Persona(role="A", expertise="a", perspective="a", reviewer=True)
        p2 = Persona(role="B", expertise="b", perspective="b", reviewer=True)
        config = ColonyConfig(personas=[p1, p2])
        assert len(reviewer_personas(config)) == 2


class TestBuildPersonaReviewPrompt:
    def test_contains_persona_identity(self):
        persona = Persona(role="Security Lead", expertise="AppSec", perspective="Threat model", reviewer=True)
        config = ColonyConfig()
        system, user = _build_persona_review_prompt(persona, config, "prd.md", "feat/x")
        assert "Security Lead" in system
        assert "AppSec" in system
        assert "Threat model" in system

    def test_contains_branch_and_prd(self):
        config = ColonyConfig()
        system, user = _build_persona_review_prompt(REVIEWER_PERSONA, config, "cOS_prds/test.md", "feat/test")
        assert "cOS_prds/test.md" in system
        assert "feat/test" in system
        assert "feat/test" in user

    def test_review_tools_not_in_prompt(self):
        config = ColonyConfig()
        system, _ = _build_persona_review_prompt(REVIEWER_PERSONA, config, "prd.md", "branch")
        assert "Agent" not in system


class TestExtractReviewVerdict:
    def test_approve(self):
        assert extract_review_verdict("VERDICT: approve\nLooks good.") == "approve"

    def test_request_changes(self):
        assert extract_review_verdict("VERDICT: request-changes\nNeeds fixes.") == "request-changes"

    def test_case_insensitive(self):
        assert extract_review_verdict("Verdict: Approve") == "approve"

    def test_defaults_to_request_changes(self):
        assert extract_review_verdict("No clear verdict.") == "request-changes"


class TestCollectReviewFindings:
    def test_collects_request_changes(self):
        results = [_request_changes_review_result(), _approve_review_result()]
        reviewers = [REVIEWER_PERSONA, Persona(role="Other", expertise="X", perspective="Y", reviewer=True)]
        findings = _collect_review_findings(results, reviewers)
        assert len(findings) == 1
        assert findings[0][0] == "Engineer"

    def test_all_approve_returns_empty(self):
        results = [_approve_review_result()]
        reviewers = [REVIEWER_PERSONA]
        findings = _collect_review_findings(results, reviewers)
        assert findings == []

    def test_all_request_changes(self):
        r1 = Persona(role="A", expertise="a", perspective="a", reviewer=True)
        r2 = Persona(role="B", expertise="b", perspective="b", reviewer=True)
        results = [_request_changes_review_result(), _request_changes_review_result()]
        findings = _collect_review_findings(results, [r1, r2])
        assert len(findings) == 2


class TestParseParentTasks:
    def test_parses_unchecked_tasks(self):
        content = """## Tasks

- [ ] 1.0 Update models
  - [ ] 1.1 Add field
- [ ] 2.0 Update config
  - [ ] 2.1 Add default
"""
        tasks = _parse_parent_tasks(content)
        assert len(tasks) == 2
        assert "1.0 Update models" in tasks[0]
        assert "2.0 Update config" in tasks[1]

    def test_parses_checked_tasks(self):
        content = "- [x] 1.0 Done task\n- [ ] 2.0 Pending task\n"
        tasks = _parse_parent_tasks(content)
        assert len(tasks) == 2

    def test_ignores_subtasks(self):
        content = "- [ ] 1.0 Parent\n  - [ ] 1.1 Child\n"
        tasks = _parse_parent_tasks(content)
        assert len(tasks) == 1
        assert "1.0 Parent" in tasks[0]

    def test_empty_content(self):
        assert _parse_parent_tasks("") == []


class TestInjectRepoMap:
    """Tests for _inject_repo_map() — Task 5.1."""

    def test_injects_when_repo_map_non_empty(self):
        """Repo map block is appended with the ## Repository Structure header."""
        system = "Base system prompt."
        repo_map = "src/\n  config.py (100 lines)\n"
        result = _inject_repo_map(system, repo_map)
        assert "## Repository Structure" in result
        assert "config.py (100 lines)" in result
        # Original prompt is preserved
        assert result.startswith("Base system prompt.")

    def test_no_injection_when_empty(self):
        """Empty repo map string produces no change to the system prompt."""
        system = "Base system prompt."
        result = _inject_repo_map(system, "")
        assert result == system

    def test_no_injection_when_whitespace_only(self):
        """Whitespace-only repo map string produces no change."""
        system = "Base system prompt."
        result = _inject_repo_map(system, "   \n  ")
        assert result == system

    def test_repo_map_appears_after_base(self):
        """Repo map block is appended after the base system prompt content."""
        system = "## User Directions\n\nBe concise."
        repo_map = "src/\n  main.py (50 lines)\n"
        result = _inject_repo_map(system, repo_map)
        # Header should come after user directions
        idx_directions = result.index("User Directions")
        idx_repo_map = result.index("Repository Structure")
        assert idx_repo_map > idx_directions

    def test_repo_map_integrated_in_plan_prompt(self):
        """Repo map is injected into a plan prompt when enabled."""
        config = ColonyConfig(
            project=ProjectInfo(name="T", description="t", stack="Python"),
            personas=[REVIEWER_PERSONA],
            model="test-model",
            budget=BudgetConfig(per_phase=1.0, per_run=10.0),
        )
        system, _user = _build_plan_prompt("test", config, "prd.md", "tasks.md")
        # Inject repo map manually (as _run_pipeline would)
        repo_map = "src/\n  foo.py (10 lines)\n"
        result = _inject_repo_map(system, repo_map)
        assert "## Repository Structure" in result
        assert "foo.py (10 lines)" in result

    def test_repo_map_not_injected_when_disabled(self):
        """When config.repo_map.enabled is False, no repo map should be generated."""
        # This tests the pattern: callers check config.repo_map.enabled
        # before calling generate_repo_map, passing "" when disabled
        system = "Base prompt."
        result = _inject_repo_map(system, "")
        assert "Repository Structure" not in result


class TestInjectRepoMapDeliverPhase:
    """Tests for repo map injection into deliver phase — FR-15."""

    def test_repo_map_injected_into_deliver_prompt(self):
        """Repo map should be injectable into a deliver prompt."""
        config = ColonyConfig(
            project=ProjectInfo(name="T", description="t", stack="Python"),
            personas=[REVIEWER_PERSONA],
            model="test-model",
            budget=BudgetConfig(per_phase=1.0, per_run=10.0),
        )
        system, _user = _build_deliver_prompt(config, "prd.md", "branch")
        repo_map = "src/\n  deliver.py (30 lines)\n"
        result = _inject_repo_map(system, repo_map)
        assert "## Repository Structure" in result
        assert "deliver.py (30 lines)" in result

    def test_deliver_prompt_unchanged_when_repo_map_empty(self):
        """Deliver prompt is unchanged when repo map is empty."""
        config = ColonyConfig(
            project=ProjectInfo(name="T", description="t", stack="Python"),
            personas=[REVIEWER_PERSONA],
            model="test-model",
            budget=BudgetConfig(per_phase=1.0, per_run=10.0),
        )
        system, _user = _build_deliver_prompt(config, "prd.md", "branch")
        result = _inject_repo_map(system, "")
        assert result == system
        assert "Repository Structure" not in result


class TestInjectRepoMapCEOPhase:
    """Tests for repo map injection into CEO phase — FR-15."""

    def test_repo_map_injected_into_ceo_prompt(self):
        """Repo map should be injectable into a CEO prompt."""
        config = ColonyConfig(
            project=ProjectInfo(name="T", description="t", stack="Python"),
            personas=[REVIEWER_PERSONA],
            model="test-model",
            budget=BudgetConfig(per_phase=1.0, per_run=10.0),
        )
        system, _user = _build_ceo_prompt(config, "proposal.md", Path("/tmp/test-repo"))
        repo_map = "src/\n  ceo.py (20 lines)\n"
        result = _inject_repo_map(system, repo_map)
        assert "## Repository Structure" in result
        assert "ceo.py (20 lines)" in result

    def test_ceo_prompt_unchanged_when_repo_map_empty(self):
        """CEO prompt is unchanged when repo map is empty."""
        config = ColonyConfig(
            project=ProjectInfo(name="T", description="t", stack="Python"),
            personas=[REVIEWER_PERSONA],
            model="test-model",
            budget=BudgetConfig(per_phase=1.0, per_run=10.0),
        )
        system, _user = _build_ceo_prompt(config, "proposal.md", Path("/tmp/test-repo"))
        result = _inject_repo_map(system, "")
        assert result == system
        assert "Repository Structure" not in result


class TestInjectRepoMapDecisionGate:
    """Tests for repo map injection into Decision Gate phase — FR-15."""

    def test_repo_map_injected_into_decision_prompt(self):
        """Repo map should be injectable into a decision gate prompt."""
        config = ColonyConfig(
            project=ProjectInfo(name="T", description="t", stack="Python"),
            personas=[REVIEWER_PERSONA],
            model="test-model",
            budget=BudgetConfig(per_phase=1.0, per_run=10.0),
        )
        system, _user = _build_decision_prompt(config, "prd.md", "test-branch")
        repo_map = "src/\n  decision.py (40 lines)\n"
        result = _inject_repo_map(system, repo_map)
        assert "## Repository Structure" in result
        assert "decision.py (40 lines)" in result

    def test_decision_prompt_unchanged_when_repo_map_empty(self):
        """Decision gate prompt is unchanged when repo map is empty."""
        config = ColonyConfig(
            project=ProjectInfo(name="T", description="t", stack="Python"),
            personas=[REVIEWER_PERSONA],
            model="test-model",
            budget=BudgetConfig(per_phase=1.0, per_run=10.0),
        )
        system, _user = _build_decision_prompt(config, "prd.md", "test-branch")
        result = _inject_repo_map(system, "")
        assert result == system
        assert "Repository Structure" not in result


class TestPersonaSlug:
    def test_basic(self):
        assert _persona_slug("Steve Jobs") == "steve_jobs"

    def test_complex_role(self):
        assert _persona_slug("YC Partner (Michael Seibel)") == "yc_partner_michael_seibel"


class TestBuildRunId:
    def test_format(self):
        rid = _build_run_id("test prompt")
        assert rid.startswith("run-")
        assert len(rid) > 20


class TestRun:
    @patch("colonyos.orchestrator.run_phases_parallel_sync")
    @patch("colonyos.orchestrator.run_phase_sync")
    def test_full_run_success_with_review(self, mock_run, mock_parallel, tmp_git_repo: Path, config: ColonyConfig):
        save_config(tmp_git_repo, config)

        mock_run.side_effect = [
            _fake_phase_result(Phase.PLAN),
            _fake_phase_result(Phase.IMPLEMENT),
            PhaseResult(phase=Phase.DECISION, success=True, cost_usd=0.01, duration_ms=50, session_id="s", artifacts={"result": "VERDICT: GO"}),
            _fake_phase_result(Phase.LEARN),
            PhaseResult(phase=Phase.VERIFY, success=True, cost_usd=0.01, duration_ms=50, session_id="s", artifacts={"result": "All tests passed"}),
            _fake_phase_result(Phase.DELIVER),
        ]
        mock_parallel.return_value = [_approve_review_result()]

        log = run("Add tests", repo_root=tmp_git_repo, config=config)

        assert log.status == RunStatus.COMPLETED
        assert mock_run.call_count == 6
        assert mock_parallel.call_count == 1

    @patch("colonyos.orchestrator.run_phases_parallel_sync")
    @patch("colonyos.orchestrator.run_phase_sync")
    def test_small_fix_skip_planning_still_reviews(
        self,
        mock_run,
        mock_parallel,
        tmp_git_repo: Path,
        config: ColonyConfig,
    ):
        save_config(tmp_git_repo, config)
        mock_run.side_effect = [
            _fake_phase_result(Phase.IMPLEMENT),
            PhaseResult(phase=Phase.DECISION, success=True, cost_usd=0.01, duration_ms=50, session_id="s", artifacts={"result": "VERDICT: GO"}),
            _fake_phase_result(Phase.LEARN),
            _fake_phase_result(Phase.VERIFY),
            _fake_phase_result(Phase.DELIVER),
        ]
        mock_parallel.return_value = [_approve_review_result()]

        log = run("Fix typo", repo_root=tmp_git_repo, config=config, skip_planning=True)

        phase_types = [phase.phase for phase in log.phases]
        assert Phase.PLAN not in phase_types
        assert Phase.IMPLEMENT in phase_types
        assert any(phase.phase == Phase.REVIEW for phase in log.phases)
        assert mock_parallel.call_count == 1

    @patch("colonyos.orchestrator.run_phase_sync")
    def test_review_phase_skipped_when_disabled(self, mock_run, tmp_git_repo: Path, config: ColonyConfig):
        config.phases.review = False
        save_config(tmp_git_repo, config)
        mock_run.side_effect = [
            _fake_phase_result(Phase.PLAN),
            _fake_phase_result(Phase.IMPLEMENT),
            _fake_phase_result(Phase.LEARN),
            _fake_phase_result(Phase.VERIFY),
            _fake_phase_result(Phase.DELIVER),
        ]

        log = run("Add tests", repo_root=tmp_git_repo, config=config)

        assert log.status == RunStatus.COMPLETED
        assert len(log.phases) == 5
        assert mock_run.call_count == 5
        phase_types = [p.phase for p in log.phases]
        assert Phase.REVIEW not in phase_types

    @patch("colonyos.orchestrator.run_phase_sync")
    def test_review_skipped_when_noreviewer_personas(self, mock_run, tmp_git_repo: Path):
        """No reviewer personas means review phase is skipped entirely."""
        config = ColonyConfig(
            project=ProjectInfo(name="Test", description="test", stack="Python"),
            personas=[NON_REVIEWER_PERSONA],
            model="test-model",
            budget=BudgetConfig(per_phase=1.0, per_run=10.0),
            phases=PhasesConfig(plan=True, implement=True, review=True, deliver=True),
        )
        save_config(tmp_git_repo, config)
        mock_run.side_effect = [
            _fake_phase_result(Phase.PLAN),
            _fake_phase_result(Phase.IMPLEMENT),
            _fake_phase_result(Phase.LEARN),
            _fake_phase_result(Phase.VERIFY),
            _fake_phase_result(Phase.DELIVER),
        ]

        log = run("Add tests", repo_root=tmp_git_repo, config=config)

        assert log.status == RunStatus.COMPLETED
        assert mock_run.call_count == 5

    @patch("colonyos.orchestrator.run_phases_parallel_sync")
    @patch("colonyos.orchestrator.run_phase_sync")
    def test_review_saves_artifacts(self, mock_run, mock_parallel, tmp_git_repo: Path, config: ColonyConfig):
        save_config(tmp_git_repo, config)
        mock_run.side_effect = [
            _fake_phase_result(Phase.PLAN),
            _fake_phase_result(Phase.IMPLEMENT),
            PhaseResult(phase=Phase.DECISION, success=True, cost_usd=0.01, duration_ms=50, session_id="s", artifacts={"result": "VERDICT: GO"}),
            _fake_phase_result(Phase.LEARN),
            _fake_phase_result(Phase.VERIFY),
            _fake_phase_result(Phase.DELIVER),
        ]
        mock_parallel.return_value = [_approve_review_result()]

        run("Add tests", repo_root=tmp_git_repo, config=config)

        reviews_dir = tmp_git_repo / config.reviews_dir
        assert reviews_dir.exists()
        review_files = list(reviews_dir.glob("**/*.md"))
        assert len(review_files) >= 2  # 1 persona review + 1 decision

    @patch("colonyos.orchestrator.run_phases_parallel_sync")
    @patch("colonyos.orchestrator.run_phase_sync")
    def test_review_uses_parallel_runner(self, mock_run, mock_parallel, tmp_git_repo: Path, config: ColonyConfig):
        """Reviews use run_phases_parallel_sync, not run_phase_sync."""
        save_config(tmp_git_repo, config)
        mock_run.side_effect = [
            _fake_phase_result(Phase.PLAN),
            _fake_phase_result(Phase.IMPLEMENT),
            PhaseResult(phase=Phase.DECISION, success=True, cost_usd=0.01, duration_ms=50, session_id="s", artifacts={"result": "VERDICT: GO"}),
            _fake_phase_result(Phase.LEARN),
            _fake_phase_result(Phase.VERIFY),
            _fake_phase_result(Phase.DELIVER),
        ]
        mock_parallel.return_value = [_approve_review_result()]

        run("Add tests", repo_root=tmp_git_repo, config=config)

        assert mock_parallel.call_count == 1
        calls = mock_parallel.call_args_list[0]
        review_calls = calls[0][0]
        assert len(review_calls) == 1  # 1 reviewer persona
        assert review_calls[0]["phase"] == Phase.REVIEW
        assert review_calls[0]["allowed_tools"] == ["Read", "Glob", "Grep", "Bash"]

    @patch("colonyos.orchestrator.run_phases_parallel_sync")
    @patch("colonyos.orchestrator.run_phase_sync")
    def test_multiplereviewer_personas(self, mock_run, mock_parallel, tmp_git_repo: Path):
        """All reviewer personas get their own parallel session."""
        r1 = Persona(role="Systems Eng", expertise="Distributed", perspective="Reliability", reviewer=True)
        r2 = Persona(role="Security Eng", expertise="AppSec", perspective="Threats", reviewer=True)
        config = ColonyConfig(
            project=ProjectInfo(name="Test", description="test", stack="Python"),
            personas=[r1, r2, NON_REVIEWER_PERSONA],
            model="test-model",
            budget=BudgetConfig(per_phase=1.0, per_run=10.0),
            phases=PhasesConfig(plan=True, implement=True, review=True, deliver=True),
        )
        save_config(tmp_git_repo, config)

        mock_run.side_effect = [
            _fake_phase_result(Phase.PLAN),
            _fake_phase_result(Phase.IMPLEMENT),
            PhaseResult(phase=Phase.DECISION, success=True, cost_usd=0.01, duration_ms=50, session_id="s", artifacts={"result": "VERDICT: GO"}),
            _fake_phase_result(Phase.LEARN),
            _fake_phase_result(Phase.VERIFY),
            _fake_phase_result(Phase.DELIVER),
        ]
        mock_parallel.return_value = [_approve_review_result(), _approve_review_result()]

        log = run("Add feature", repo_root=tmp_git_repo, config=config)

        assert log.status == RunStatus.COMPLETED
        review_calls = mock_parallel.call_args_list[0][0][0]
        assert len(review_calls) == 2  # Only 2 reviewer personas (not the designer)

    @patch("colonyos.orchestrator.run_phase_sync")
    def test_plan_passes_persona_agents(self, mock_run, tmp_git_repo: Path, config: ColonyConfig):
        save_config(tmp_git_repo, config)
        mock_run.return_value = _fake_phase_result(Phase.PLAN)

        run("Add tests", repo_root=tmp_git_repo, config=config, plan_only=True)

        call_kwargs = mock_run.call_args_list[0]
        assert call_kwargs.kwargs.get("agents") is not None
        assert "engineer" in call_kwargs.kwargs["agents"]

    @patch("colonyos.orchestrator.run_phase_sync")
    def test_plan_only(self, mock_run, tmp_git_repo: Path, config: ColonyConfig):
        save_config(tmp_git_repo, config)
        mock_run.return_value = _fake_phase_result(Phase.PLAN)

        log = run("Add tests", repo_root=tmp_git_repo, config=config, plan_only=True)

        assert log.status == RunStatus.COMPLETED
        assert len(log.phases) == 1
        assert mock_run.call_count == 1

    @patch("colonyos.orchestrator.run_phase_sync")
    def test_plan_failure_stops_run(self, mock_run, tmp_git_repo: Path, config: ColonyConfig):
        save_config(tmp_git_repo, config)
        mock_run.return_value = _fake_phase_result(Phase.PLAN, success=False)

        log = run("Add tests", repo_root=tmp_git_repo, config=config)

        assert log.status == RunStatus.FAILED
        assert len(log.phases) == 1
        assert mock_run.call_count == 1

    @patch("colonyos.orchestrator.run_phases_parallel_sync")
    @patch("colonyos.orchestrator.run_phase_sync")
    def test_from_prd_skips_plan(self, mock_run, mock_parallel, tmp_git_repo: Path, config: ColonyConfig):
        save_config(tmp_git_repo, config)
        prd = tmp_git_repo / "cOS_prds" / "20260316_120000_prd_test.md"
        prd.write_text("# PRD", encoding="utf-8")

        mock_run.side_effect = [
            _fake_phase_result(Phase.IMPLEMENT),
            PhaseResult(phase=Phase.DECISION, success=True, cost_usd=0.01, duration_ms=50, session_id="s", artifacts={"result": "VERDICT: GO"}),
            _fake_phase_result(Phase.LEARN),
            _fake_phase_result(Phase.VERIFY),
            _fake_phase_result(Phase.DELIVER),
        ]
        mock_parallel.return_value = [_approve_review_result()]

        log = run(
            "Implement test",
            repo_root=tmp_git_repo,
            config=config,
            from_prd=str(prd),
        )

        assert log.status == RunStatus.COMPLETED

    @patch("colonyos.orchestrator.run_phases_parallel_sync")
    @patch("colonyos.orchestrator.run_phase_sync")
    def test_run_log_saved(self, mock_run, mock_parallel, tmp_git_repo: Path, config: ColonyConfig):
        save_config(tmp_git_repo, config)
        mock_run.side_effect = [
            _fake_phase_result(Phase.PLAN),
            _fake_phase_result(Phase.IMPLEMENT),
            PhaseResult(phase=Phase.DECISION, success=True, cost_usd=0.01, duration_ms=50, session_id="s", artifacts={"result": "VERDICT: GO"}),
            _fake_phase_result(Phase.LEARN),
            _fake_phase_result(Phase.VERIFY),
            _fake_phase_result(Phase.DELIVER),
        ]
        mock_parallel.return_value = [_approve_review_result()]

        run("Add feature", repo_root=tmp_git_repo, config=config)

        runs_dir = tmp_git_repo / ".colonyos" / "runs"
        assert runs_dir.exists()
        log_files = list(runs_dir.glob("*.json"))
        assert len(log_files) == 1

    @patch("colonyos.orchestrator.run_phases_parallel_sync")
    @patch("colonyos.orchestrator.run_phase_sync")
    def test_decision_nogo_stops_pipeline(self, mock_run, mock_parallel, tmp_git_repo: Path, config: ColonyConfig):
        """Decision gate NO-GO verdict prevents delivery."""
        config.max_fix_iterations = 0
        save_config(tmp_git_repo, config)
        mock_run.side_effect = [
            _fake_phase_result(Phase.PLAN),
            _fake_phase_result(Phase.IMPLEMENT),
            PhaseResult(phase=Phase.DECISION, success=True, cost_usd=0.01, duration_ms=50, session_id="s", artifacts={"result": "VERDICT: NO-GO\n\nToo many issues."}),
            _fake_phase_result(Phase.LEARN),
        ]
        mock_parallel.return_value = [_approve_review_result()]

        log = run("Add feature", repo_root=tmp_git_repo, config=config)

        assert log.status == RunStatus.FAILED
        phase_types = [p.phase for p in log.phases]
        assert Phase.DELIVER not in phase_types
        assert Phase.DECISION in phase_types


class TestBuildFixPrompt:
    def test_returns_tuple(self):
        config = ColonyConfig(max_fix_iterations=2)
        result = _build_fix_prompt(
            config, "prd.md", "tasks.md", "feat/branch",
            "### Engineer\n\nVERDICT: request-changes\nMissing tests", 1,
        )
        assert isinstance(result, tuple)
        assert len(result) == 2

    def test_system_contains_base_and_fix(self):
        config = ColonyConfig(max_fix_iterations=2)
        system, _ = _build_fix_prompt(
            config, "prd.md", "tasks.md", "feat/branch",
            "Findings here", 1,
        )
        assert "Fix Phase" in system
        assert "fix iteration 1 of 2" in system.lower()

    def test_system_embeds_findings_text(self):
        config = ColonyConfig(max_fix_iterations=3)
        findings = "### Engineer\n\nVERDICT: request-changes\nMissing tests for auth module"
        system, _ = _build_fix_prompt(
            config, "prd.md", "tasks.md", "branch", findings, 2,
        )
        assert "Missing tests for auth module" in system

    def test_user_prompt_contains_context(self):
        config = ColonyConfig(max_fix_iterations=2)
        _, user = _build_fix_prompt(
            config, "cOS_prds/prd.md", "cOS_tasks/tasks.md",
            "feat/fix", "findings text", 1,
        )
        assert "feat/fix" in user
        assert "cOS_prds/prd.md" in user
        assert "iteration 1" in user

    def test_includes_reviews_dir(self):
        config = ColonyConfig(reviews_dir="my_reviews", max_fix_iterations=2)
        system, _ = _build_fix_prompt(
            config, "prd.md", "tasks.md", "branch", "text", 1,
        )
        assert "my_reviews" in system

    def test_fix_identity_is_staff_engineer(self):
        config = ColonyConfig(max_fix_iterations=2)
        system, _ = _build_fix_prompt(
            config, "prd.md", "tasks.md", "branch", "text", 1,
        )
        assert "Staff+" in system
        assert "Google" in system


class TestFixLoop:
    @patch("colonyos.orchestrator.run_phases_parallel_sync")
    @patch("colonyos.orchestrator.run_phase_sync")
    def test_request_changes_triggers_fix(self, mock_run, mock_parallel, tmp_git_repo: Path, config: ColonyConfig):
        """request-changes -> fix -> approve -> decision GO -> deliver."""
        save_config(tmp_git_repo, config)
        mock_run.side_effect = [
            _fake_phase_result(Phase.PLAN),
            _fake_phase_result(Phase.IMPLEMENT),
            _fake_phase_result(Phase.FIX),
            PhaseResult(phase=Phase.DECISION, success=True, cost_usd=0.01,
                        duration_ms=50, session_id="s",
                        artifacts={"result": "VERDICT: GO"}),
            _fake_phase_result(Phase.LEARN),
            _fake_phase_result(Phase.VERIFY),
            _fake_phase_result(Phase.DELIVER),
        ]
        mock_parallel.side_effect = [
            [_request_changes_review_result()],  # round 1: request changes
            [_approve_review_result()],           # round 2: approve
        ]

        log = run("Add feature", repo_root=tmp_git_repo, config=config)

        assert log.status == RunStatus.COMPLETED
        phase_types = [p.phase for p in log.phases]
        assert Phase.FIX in phase_types
        assert Phase.DELIVER in phase_types

    @patch("colonyos.orchestrator.run_phases_parallel_sync")
    @patch("colonyos.orchestrator.run_phase_sync")
    def test_max_iterations_exhausted(self, mock_run, mock_parallel, tmp_git_repo: Path, config: ColonyConfig):
        """request-changes every round -> decision NO-GO -> fail."""
        config.max_fix_iterations = 2
        save_config(tmp_git_repo, config)
        mock_run.side_effect = [
            _fake_phase_result(Phase.PLAN),
            _fake_phase_result(Phase.IMPLEMENT),
            _fake_phase_result(Phase.FIX),       # fix 1
            _fake_phase_result(Phase.FIX),       # fix 2
            PhaseResult(phase=Phase.DECISION, success=True, cost_usd=0.01,
                        duration_ms=50, session_id="s",
                        artifacts={"result": "VERDICT: NO-GO\n\nStill bad."}),
            _fake_phase_result(Phase.LEARN),
        ]
        mock_parallel.side_effect = [
            [_request_changes_review_result()],  # round 1
            [_request_changes_review_result()],  # round 2 (after fix 1)
            [_request_changes_review_result()],  # round 3 (after fix 2, last round)
        ]

        log = run("Add feature", repo_root=tmp_git_repo, config=config)

        assert log.status == RunStatus.FAILED
        phase_types = [p.phase for p in log.phases]
        assert Phase.DELIVER not in phase_types
        assert phase_types.count(Phase.FIX) == 2

    @patch("colonyos.orchestrator.run_phases_parallel_sync")
    @patch("colonyos.orchestrator.run_phase_sync")
    def test_zero_max_iterations_no_fix(self, mock_run, mock_parallel, tmp_git_repo: Path, config: ColonyConfig):
        """max_fix_iterations=0: only 1 review round, no fix, then decision."""
        config.max_fix_iterations = 0
        save_config(tmp_git_repo, config)
        mock_run.side_effect = [
            _fake_phase_result(Phase.PLAN),
            _fake_phase_result(Phase.IMPLEMENT),
            PhaseResult(phase=Phase.DECISION, success=True, cost_usd=0.01,
                        duration_ms=50, session_id="s",
                        artifacts={"result": "VERDICT: NO-GO"}),
            _fake_phase_result(Phase.LEARN),
        ]
        mock_parallel.return_value = [_request_changes_review_result()]

        log = run("Add feature", repo_root=tmp_git_repo, config=config)

        assert log.status == RunStatus.FAILED
        phase_types = [p.phase for p in log.phases]
        assert Phase.FIX not in phase_types
        assert Phase.DELIVER not in phase_types

    @patch("colonyos.orchestrator.run_phases_parallel_sync")
    @patch("colonyos.orchestrator.run_phase_sync")
    def test_fix_phases_in_runlog(self, mock_run, mock_parallel, tmp_git_repo: Path, config: ColonyConfig):
        """Fix iterations appear as Phase.FIX in the run log."""
        save_config(tmp_git_repo, config)
        mock_run.side_effect = [
            _fake_phase_result(Phase.PLAN),
            _fake_phase_result(Phase.IMPLEMENT),
            _fake_phase_result(Phase.FIX),
            PhaseResult(phase=Phase.DECISION, success=True, cost_usd=0.01,
                        duration_ms=50, session_id="s",
                        artifacts={"result": "VERDICT: GO"}),
            _fake_phase_result(Phase.LEARN),
            _fake_phase_result(Phase.VERIFY),
            _fake_phase_result(Phase.DELIVER),
        ]
        mock_parallel.side_effect = [
            [_request_changes_review_result()],
            [_approve_review_result()],
        ]

        log = run("Add feature", repo_root=tmp_git_repo, config=config)

        fix_phases = [p for p in log.phases if p.phase == Phase.FIX]
        assert len(fix_phases) == 1
        assert fix_phases[0].success is True

    @patch("colonyos.orchestrator.run_phases_parallel_sync")
    @patch("colonyos.orchestrator.run_phase_sync")
    def test_unknown_verdict_proceeds_to_deliver(self, mock_run, mock_parallel, tmp_git_repo: Path, config: ColonyConfig):
        """UNKNOWN decision verdict proceeds to deliver (with warning)."""
        save_config(tmp_git_repo, config)
        mock_run.side_effect = [
            _fake_phase_result(Phase.PLAN),
            _fake_phase_result(Phase.IMPLEMENT),
            PhaseResult(phase=Phase.DECISION, success=True, cost_usd=0.01,
                        duration_ms=50, session_id="s",
                        artifacts={"result": "No clear verdict here."}),
            _fake_phase_result(Phase.LEARN),
            _fake_phase_result(Phase.VERIFY),
            _fake_phase_result(Phase.DELIVER),
        ]
        mock_parallel.return_value = [_approve_review_result()]

        log = run("Add feature", repo_root=tmp_git_repo, config=config)

        assert log.status == RunStatus.COMPLETED
        phase_types = [p.phase for p in log.phases]
        assert Phase.FIX not in phase_types
        assert Phase.DELIVER in phase_types

    @patch("colonyos.orchestrator.run_phases_parallel_sync")
    @patch("colonyos.orchestrator.run_phase_sync")
    def test_fix_phase_failure_stops_loop(self, mock_run, mock_parallel, tmp_git_repo: Path, config: ColonyConfig):
        """Fix phase failure breaks the loop and proceeds to decision."""
        save_config(tmp_git_repo, config)
        mock_run.side_effect = [
            _fake_phase_result(Phase.PLAN),
            _fake_phase_result(Phase.IMPLEMENT),
            _fake_phase_result(Phase.FIX, success=False),
            PhaseResult(phase=Phase.DECISION, success=True, cost_usd=0.01,
                        duration_ms=50, session_id="s",
                        artifacts={"result": "VERDICT: NO-GO"}),
            _fake_phase_result(Phase.LEARN),
        ]
        mock_parallel.return_value = [_request_changes_review_result()]

        log = run("Add feature", repo_root=tmp_git_repo, config=config)

        assert log.status == RunStatus.FAILED
        phase_types = [p.phase for p in log.phases]
        assert Phase.DELIVER not in phase_types

    @patch("colonyos.orchestrator.run_phases_parallel_sync")
    @patch("colonyos.orchestrator.run_phase_sync")
    def test_budget_exhaustion_stops_review_loop(self, mock_run, mock_parallel, tmp_git_repo: Path, config: ColonyConfig):
        """Review loop stops when remaining per-run budget is insufficient."""
        config.budget = BudgetConfig(per_phase=1.0, per_run=2.5)
        config.phases.verify = False
        save_config(tmp_git_repo, config)
        mock_run.side_effect = [
            PhaseResult(phase=Phase.PLAN, success=True, cost_usd=1.0,
                        duration_ms=50, session_id="s", artifacts={"result": "done"}),
            PhaseResult(phase=Phase.IMPLEMENT, success=True, cost_usd=1.0,
                        duration_ms=50, session_id="s", artifacts={"result": "done"}),
            # Budget: 2.5 - 2.0 = 0.5 remaining < per_phase(1.0) -> skip reviews
            PhaseResult(phase=Phase.DECISION, success=True, cost_usd=0.01,
                        duration_ms=50, session_id="s",
                        artifacts={"result": "VERDICT: GO"}),
            _fake_phase_result(Phase.LEARN),
            _fake_phase_result(Phase.DELIVER),
        ]
        mock_parallel.return_value = [_approve_review_result()]

        log = run("Add feature", repo_root=tmp_git_repo, config=config)

        # Budget guard should have prevented at least some review rounds
        assert log.status == RunStatus.COMPLETED

    @patch("colonyos.orchestrator.run_phases_parallel_sync")
    @patch("colonyos.orchestrator.run_phase_sync")
    def test_review_artifacts_per_persona_per_round(self, mock_run, mock_parallel, tmp_git_repo: Path, config: ColonyConfig):
        """Each reviewer persona gets a separate artifact file per round."""
        save_config(tmp_git_repo, config)
        mock_run.side_effect = [
            _fake_phase_result(Phase.PLAN),
            _fake_phase_result(Phase.IMPLEMENT),
            PhaseResult(phase=Phase.DECISION, success=True, cost_usd=0.01,
                        duration_ms=50, session_id="s",
                        artifacts={"result": "VERDICT: GO"}),
            _fake_phase_result(Phase.LEARN),
            _fake_phase_result(Phase.VERIFY),
            _fake_phase_result(Phase.DELIVER),
        ]
        mock_parallel.return_value = [_approve_review_result()]

        run("Add feature", repo_root=tmp_git_repo, config=config)

        reviews_dir = tmp_git_repo / config.reviews_dir
        filenames = {f.name for f in reviews_dir.glob("**/*.md")}
        assert any("round1_" in f for f in filenames)
        # Verify persona subdirectory was created
        persona_dirs = [d.name for d in (reviews_dir / "reviews").iterdir() if d.is_dir()]
        assert any("engineer" in d for d in persona_dirs)


class TestRunLogResumeFields:
    """Task 1: RunLog can hold branch_name, prd_rel, task_rel."""

    def test_defaults_to_none(self):
        log = RunLog(run_id="r1", prompt="test", status=RunStatus.RUNNING)
        assert log.branch_name is None
        assert log.prd_rel is None
        assert log.task_rel is None

    def test_accepts_values(self):
        log = RunLog(
            run_id="r1", prompt="test", status=RunStatus.RUNNING,
            branch_name="feat/x", prd_rel="cOS_prds/prd.md", task_rel="cOS_tasks/tasks.md",
        )
        assert log.branch_name == "feat/x"
        assert log.prd_rel == "cOS_prds/prd.md"
        assert log.task_rel == "cOS_tasks/tasks.md"


class TestSaveRunLogResumeFields:
    """Task 2: save_run_log persists resume fields and last_successful_phase."""

    def test_persists_resume_fields(self, tmp_repo: Path):
        log = RunLog(
            run_id="r1", prompt="test", status=RunStatus.COMPLETED,
            branch_name="feat/x", prd_rel="cOS_prds/prd.md", task_rel="cOS_tasks/tasks.md",
        )
        path = _save_run_log(tmp_repo, log)
        data = json.loads(path.read_text(encoding="utf-8"))
        assert data["branch_name"] == "feat/x"
        assert data["prd_rel"] == "cOS_prds/prd.md"
        assert data["task_rel"] == "cOS_tasks/tasks.md"

    def test_persists_last_successful_phase(self, tmp_repo: Path):
        log = RunLog(
            run_id="r2", prompt="test", status=RunStatus.FAILED,
            phases=[
                _fake_phase_result(Phase.PLAN),
                _fake_phase_result(Phase.IMPLEMENT),
                _fake_phase_result(Phase.REVIEW, success=False),
            ],
        )
        path = _save_run_log(tmp_repo, log)
        data = json.loads(path.read_text(encoding="utf-8"))
        assert data["last_successful_phase"] == "implement"

    def test_last_successful_phase_none_when_no_success(self, tmp_repo: Path):
        log = RunLog(
            run_id="r3", prompt="test", status=RunStatus.FAILED,
            phases=[_fake_phase_result(Phase.PLAN, success=False)],
        )
        path = _save_run_log(tmp_repo, log)
        data = json.loads(path.read_text(encoding="utf-8"))
        assert data["last_successful_phase"] is None


class TestLoadRunLog:
    """Task 3.1: _load_run_log tests."""

    def test_loads_valid_json(self, tmp_repo: Path):
        log = RunLog(
            run_id="r1", prompt="test", status=RunStatus.FAILED,
            branch_name="feat/x", prd_rel="cOS_prds/prd.md", task_rel="cOS_tasks/tasks.md",
            phases=[_fake_phase_result(Phase.PLAN)],
        )
        _save_run_log(tmp_repo, log)
        loaded = _load_run_log(tmp_repo, "r1")
        assert loaded.run_id == "r1"
        assert loaded.status == RunStatus.FAILED
        assert loaded.branch_name == "feat/x"
        assert len(loaded.phases) == 1
        assert loaded.phases[0].phase == Phase.PLAN

    def test_missing_file_raises(self, tmp_repo: Path):
        with pytest.raises(click.ClickException, match="Run log not found"):
            _load_run_log(tmp_repo, "nonexistent-id")

    def test_corrupted_json_raises(self, tmp_repo: Path):
        runs_dir = tmp_repo / ".colonyos" / "runs"
        runs_dir.mkdir(parents=True, exist_ok=True)
        (runs_dir / "bad.json").write_text("{invalid json", encoding="utf-8")
        with pytest.raises(click.ClickException, match="Corrupted run log"):
            _load_run_log(tmp_repo, "bad")

    def test_old_log_without_resume_fields(self, tmp_repo: Path):
        runs_dir = tmp_repo / ".colonyos" / "runs"
        runs_dir.mkdir(parents=True, exist_ok=True)
        (runs_dir / "old.json").write_text(json.dumps({
            "run_id": "old", "prompt": "test", "status": "failed",
            "phases": [], "total_cost_usd": 0.0,
        }), encoding="utf-8")
        loaded = _load_run_log(tmp_repo, "old")
        assert loaded.branch_name is None
        assert loaded.prd_rel is None
        assert loaded.task_rel is None


class TestValidateResumePreconditions:
    """Task 3.2: _validate_resume_preconditions tests."""

    def test_fails_on_running_status(self, tmp_repo: Path):
        log = RunLog(
            run_id="r1", prompt="test", status=RunStatus.RUNNING,
            branch_name="feat/x", prd_rel="cOS_prds/prd.md", task_rel="cOS_tasks/tasks.md",
        )
        with pytest.raises(click.ClickException, match="Only failed runs"):
            _validate_resume_preconditions(tmp_repo, log)

    def test_fails_on_completed_status(self, tmp_repo: Path):
        log = RunLog(
            run_id="r1", prompt="test", status=RunStatus.COMPLETED,
            branch_name="feat/x", prd_rel="cOS_prds/prd.md", task_rel="cOS_tasks/tasks.md",
        )
        with pytest.raises(click.ClickException, match="Only failed runs"):
            _validate_resume_preconditions(tmp_repo, log)

    @patch("colonyos.orchestrator.subprocess.run")
    def test_fails_on_missing_branch(self, mock_subprocess, tmp_repo: Path):
        log = RunLog(
            run_id="r1", prompt="test", status=RunStatus.FAILED,
            branch_name="feat/gone", prd_rel="cOS_prds/prd.md", task_rel="cOS_tasks/tasks.md",
        )
        mock_subprocess.return_value = MagicMock(stdout="")
        (tmp_repo / "cOS_prds" / "prd.md").write_text("# PRD", encoding="utf-8")
        (tmp_repo / "cOS_tasks" / "tasks.md").write_text("# Tasks", encoding="utf-8")
        with pytest.raises(click.ClickException, match="not found locally"):
            _validate_resume_preconditions(tmp_repo, log)

    @patch("colonyos.orchestrator.subprocess.run")
    def test_fails_on_missing_prd(self, mock_subprocess, tmp_repo: Path):
        log = RunLog(
            run_id="r1", prompt="test", status=RunStatus.FAILED,
            branch_name="feat/x", prd_rel="cOS_prds/missing.md", task_rel="cOS_tasks/tasks.md",
        )
        mock_subprocess.return_value = MagicMock(stdout="  feat/x\n")
        with pytest.raises(click.ClickException, match="PRD file not found"):
            _validate_resume_preconditions(tmp_repo, log)

    @patch("colonyos.orchestrator.subprocess.run")
    def test_fails_on_missing_task_file(self, mock_subprocess, tmp_repo: Path):
        log = RunLog(
            run_id="r1", prompt="test", status=RunStatus.FAILED,
            branch_name="feat/x", prd_rel="cOS_prds/prd.md", task_rel="cOS_tasks/missing.md",
        )
        mock_subprocess.return_value = MagicMock(stdout="  feat/x\n")
        (tmp_repo / "cOS_prds" / "prd.md").write_text("# PRD", encoding="utf-8")
        with pytest.raises(click.ClickException, match="Task file not found"):
            _validate_resume_preconditions(tmp_repo, log)

    @patch("colonyos.orchestrator.subprocess.run")
    def test_succeeds_when_all_conditions_met(self, mock_subprocess, tmp_repo: Path):
        log = RunLog(
            run_id="r1", prompt="test", status=RunStatus.FAILED,
            branch_name="feat/x", prd_rel="cOS_prds/prd.md", task_rel="cOS_tasks/tasks.md",
        )
        mock_subprocess.return_value = MagicMock(stdout="  feat/x\n")
        (tmp_repo / "cOS_prds" / "prd.md").write_text("# PRD", encoding="utf-8")
        (tmp_repo / "cOS_tasks" / "tasks.md").write_text("# Tasks", encoding="utf-8")
        _validate_resume_preconditions(tmp_repo, log)  # Should not raise


class TestComputeNextPhase:
    def test_plan_to_implement(self):
        assert _compute_next_phase("plan") == "implement"

    def test_implement_to_review(self):
        assert _compute_next_phase("implement") == "review"

    def test_review_to_review(self):
        assert _compute_next_phase("review") == "review"

    def test_fix_to_review(self):
        assert _compute_next_phase("fix") == "review"

    def test_decision_to_verify(self):
        assert _compute_next_phase("decision") == "verify"

    def test_verify_to_deliver(self):
        assert _compute_next_phase("verify") == "deliver"

    def test_unknown_returns_none(self):
        assert _compute_next_phase("unknown") is None


class TestSkipMap:
    def test_decision_skips_plan_implement_review(self):
        assert _SKIP_MAP["decision"] == {"plan", "implement", "review"}

    def test_verify_skips_plan_implement_review_verify(self):
        assert _SKIP_MAP["verify"] == {"plan", "implement", "review", "verify"}


class TestResumeFromRun:
    """Task 4: Phase resumption and log continuity."""

    @patch("colonyos.orchestrator.run_phases_parallel_sync")
    @patch("colonyos.orchestrator.run_phase_sync")
    def test_resume_after_plan_runs_implement_review_deliver(
        self, mock_run, mock_parallel, tmp_git_repo: Path, config: ColonyConfig
    ):
        """When last_successful_phase is 'plan', skip plan and run implement+review+deliver."""
        save_config(tmp_git_repo, config)
        existing_log = RunLog(
            run_id="r-resume", prompt="Add feature", status=RunStatus.FAILED,
            branch_name="colonyos/add_feature", prd_rel="cOS_prds/prd.md",
            task_rel="cOS_tasks/tasks.md",
            phases=[_fake_phase_result(Phase.PLAN)],
        )

        mock_run.side_effect = [
            _fake_phase_result(Phase.IMPLEMENT),
            PhaseResult(phase=Phase.DECISION, success=True, cost_usd=0.01,
                        duration_ms=50, session_id="s",
                        artifacts={"result": "VERDICT: GO"}),
            _fake_phase_result(Phase.LEARN),
            _fake_phase_result(Phase.VERIFY),
            _fake_phase_result(Phase.DELIVER),
        ]
        mock_parallel.return_value = [_approve_review_result()]

        resume_state = ResumeState(
            log=existing_log,
            branch_name="colonyos/add_feature",
            prd_rel="cOS_prds/prd.md",
            task_rel="cOS_tasks/tasks.md",
            last_successful_phase="plan",
        )

        log = run(
            "Add feature", repo_root=tmp_git_repo, config=config,
            resume_from=resume_state,
        )

        assert log.status == RunStatus.COMPLETED
        # Plan was from the original run, then implement+review+decision+learn+deliver added
        phase_types = [p.phase for p in log.phases]
        assert phase_types[0] == Phase.PLAN  # Original
        assert Phase.IMPLEMENT in phase_types
        assert Phase.DELIVER in phase_types

    @patch("colonyos.orchestrator.run_phases_parallel_sync")
    @patch("colonyos.orchestrator.run_phase_sync")
    def test_resume_after_implement_skips_plan_and_implement(
        self, mock_run, mock_parallel, tmp_git_repo: Path, config: ColonyConfig
    ):
        """When last_successful_phase is 'implement', skip plan+implement, run review+deliver."""
        save_config(tmp_git_repo, config)
        existing_log = RunLog(
            run_id="r-resume2", prompt="Add feature", status=RunStatus.FAILED,
            branch_name="colonyos/add_feature", prd_rel="cOS_prds/prd.md",
            task_rel="cOS_tasks/tasks.md",
            phases=[
                _fake_phase_result(Phase.PLAN),
                _fake_phase_result(Phase.IMPLEMENT),
            ],
        )

        mock_run.side_effect = [
            PhaseResult(phase=Phase.DECISION, success=True, cost_usd=0.01,
                        duration_ms=50, session_id="s",
                        artifacts={"result": "VERDICT: GO"}),
            _fake_phase_result(Phase.LEARN),
            _fake_phase_result(Phase.VERIFY),
            _fake_phase_result(Phase.DELIVER),
        ]
        mock_parallel.return_value = [_approve_review_result()]

        resume_state = ResumeState(
            log=existing_log,
            branch_name="colonyos/add_feature",
            prd_rel="cOS_prds/prd.md",
            task_rel="cOS_tasks/tasks.md",
            last_successful_phase="implement",
        )

        log = run(
            "Add feature", repo_root=tmp_git_repo, config=config,
            resume_from=resume_state,
        )

        assert log.status == RunStatus.COMPLETED
        # Plan mock should NOT have been called for plan or implement
        # mock_run calls: decision + learn + verify + deliver = 4
        assert mock_run.call_count == 4

    @patch("colonyos.orchestrator.run_phases_parallel_sync")
    @patch("colonyos.orchestrator.run_phase_sync")
    def test_resume_after_review_failure_reruns_review_loop(
        self, mock_run, mock_parallel, tmp_git_repo: Path, config: ColonyConfig
    ):
        """When review/fix failed, re-enter the review loop from the top."""
        save_config(tmp_git_repo, config)
        existing_log = RunLog(
            run_id="r-resume3", prompt="Add feature", status=RunStatus.FAILED,
            branch_name="colonyos/add_feature", prd_rel="cOS_prds/prd.md",
            task_rel="cOS_tasks/tasks.md",
            phases=[
                _fake_phase_result(Phase.PLAN),
                _fake_phase_result(Phase.IMPLEMENT),
                _fake_phase_result(Phase.REVIEW, success=False),
            ],
        )

        mock_run.side_effect = [
            PhaseResult(phase=Phase.DECISION, success=True, cost_usd=0.01,
                        duration_ms=50, session_id="s",
                        artifacts={"result": "VERDICT: GO"}),
            _fake_phase_result(Phase.LEARN),
            _fake_phase_result(Phase.VERIFY),
            _fake_phase_result(Phase.DELIVER),
        ]
        mock_parallel.return_value = [_approve_review_result()]

        resume_state = ResumeState(
            log=existing_log,
            branch_name="colonyos/add_feature",
            prd_rel="cOS_prds/prd.md",
            task_rel="cOS_tasks/tasks.md",
            last_successful_phase="implement",
        )

        log = run(
            "Add feature", repo_root=tmp_git_repo, config=config,
            resume_from=resume_state,
        )

        assert log.status == RunStatus.COMPLETED
        assert mock_parallel.call_count == 1  # Review was re-entered

    @patch("colonyos.orchestrator.run_phases_parallel_sync")
    @patch("colonyos.orchestrator.run_phase_sync")
    def test_log_continuity_preserves_original_phases(
        self, mock_run, mock_parallel, tmp_git_repo: Path, config: ColonyConfig
    ):
        """Resumed log has both original and new phases, written to same file."""
        save_config(tmp_git_repo, config)
        original_plan = _fake_phase_result(Phase.PLAN)
        existing_log = RunLog(
            run_id="r-continuity", prompt="Add feature", status=RunStatus.FAILED,
            branch_name="colonyos/add_feature", prd_rel="cOS_prds/prd.md",
            task_rel="cOS_tasks/tasks.md",
            phases=[original_plan],
        )
        _save_run_log(tmp_git_repo, existing_log)

        mock_run.side_effect = [
            _fake_phase_result(Phase.IMPLEMENT),
            PhaseResult(phase=Phase.DECISION, success=True, cost_usd=0.01,
                        duration_ms=50, session_id="s",
                        artifacts={"result": "VERDICT: GO"}),
            _fake_phase_result(Phase.LEARN),
            _fake_phase_result(Phase.VERIFY),
            _fake_phase_result(Phase.DELIVER),
        ]
        mock_parallel.return_value = [_approve_review_result()]

        resume_state = ResumeState(
            log=existing_log,
            branch_name="colonyos/add_feature",
            prd_rel="cOS_prds/prd.md",
            task_rel="cOS_tasks/tasks.md",
            last_successful_phase="plan",
        )

        log = run(
            "Add feature", repo_root=tmp_git_repo, config=config,
            resume_from=resume_state,
        )

        assert log.phases[0] is original_plan  # Original phase preserved
        assert len(log.phases) > 1  # New phases appended

        # Verify the JSON file has all phases
        log_path = tmp_git_repo / ".colonyos" / "runs" / "r-continuity.json"
        data = json.loads(log_path.read_text(encoding="utf-8"))
        assert len(data["phases"]) == len(log.phases)
        assert data["status"] == "completed"

    @patch("colonyos.orchestrator.run_phases_parallel_sync")
    @patch("colonyos.orchestrator.run_phase_sync")
    def test_resume_after_decision_runs_only_deliver(
        self, mock_run, mock_parallel, tmp_git_repo: Path, config: ColonyConfig
    ):
        """When last_successful_phase is 'decision', only deliver runs."""
        save_config(tmp_git_repo, config)
        existing_log = RunLog(
            run_id="r-decision", prompt="Add feature", status=RunStatus.FAILED,
            branch_name="colonyos/add_feature", prd_rel="cOS_prds/prd.md",
            task_rel="cOS_tasks/tasks.md",
            phases=[
                _fake_phase_result(Phase.PLAN),
                _fake_phase_result(Phase.IMPLEMENT),
                _approve_review_result(),
                PhaseResult(phase=Phase.DECISION, success=True, cost_usd=0.01,
                            duration_ms=50, session_id="s",
                            artifacts={"result": "VERDICT: GO"}),
            ],
        )

        mock_run.side_effect = [
            _fake_phase_result(Phase.LEARN),
            _fake_phase_result(Phase.VERIFY),
            _fake_phase_result(Phase.DELIVER),
        ]

        resume_state = ResumeState(
            log=existing_log,
            branch_name="colonyos/add_feature",
            prd_rel="cOS_prds/prd.md",
            task_rel="cOS_tasks/tasks.md",
            last_successful_phase="decision",
        )

        log = run(
            "Add feature", repo_root=tmp_git_repo, config=config,
            resume_from=resume_state,
        )

        assert log.status == RunStatus.COMPLETED
        assert mock_run.call_count == 3  # Learn + verify + deliver
        assert mock_parallel.call_count == 0  # No reviews

    @patch("colonyos.orchestrator.run_phases_parallel_sync")
    @patch("colonyos.orchestrator.run_phase_sync")
    def test_run_sets_resume_fields_in_log(
        self, mock_run, mock_parallel, tmp_git_repo: Path, config: ColonyConfig
    ):
        """Normal (non-resume) runs persist branch_name, prd_rel, task_rel."""
        save_config(tmp_git_repo, config)
        mock_run.side_effect = [
            _fake_phase_result(Phase.PLAN),
            _fake_phase_result(Phase.IMPLEMENT),
            PhaseResult(phase=Phase.DECISION, success=True, cost_usd=0.01,
                        duration_ms=50, session_id="s",
                        artifacts={"result": "VERDICT: GO"}),
            _fake_phase_result(Phase.LEARN),
            _fake_phase_result(Phase.VERIFY),
            _fake_phase_result(Phase.DELIVER),
        ]
        mock_parallel.return_value = [_approve_review_result()]

        log = run("Add feature", repo_root=tmp_git_repo, config=config)

        assert log.branch_name is not None
        assert log.prd_rel is not None
        assert log.task_rel is not None

        # Verify persisted in JSON
        runs_dir = tmp_git_repo / ".colonyos" / "runs"
        log_files = list(runs_dir.glob("*.json"))
        assert len(log_files) == 1
        data = json.loads(log_files[0].read_text(encoding="utf-8"))
        assert data["branch_name"] == log.branch_name
        assert data["prd_rel"] == log.prd_rel
        assert data["task_rel"] == log.task_rel


class TestRunIdValidation:
    """Path traversal protection for run_id in _load_run_log."""

    def test_rejects_path_traversal_dotdot(self, tmp_repo: Path):
        with pytest.raises(click.ClickException, match="must not contain"):
            _load_run_log(tmp_repo, "../../etc/passwd")

    def test_rejects_forward_slash(self, tmp_repo: Path):
        with pytest.raises(click.ClickException, match="must not contain"):
            _load_run_log(tmp_repo, "foo/bar")

    def test_rejects_backslash(self, tmp_repo: Path):
        with pytest.raises(click.ClickException, match="must not contain"):
            _load_run_log(tmp_repo, "foo\\bar")

    def test_rejects_empty_run_id(self, tmp_repo: Path):
        with pytest.raises(click.ClickException, match="must not be empty"):
            _load_run_log(tmp_repo, "")

    def test_accepts_valid_run_id(self, tmp_repo: Path):
        """Valid run IDs with hyphens, underscores, dots should work."""
        # This should raise "not found" rather than "invalid"
        with pytest.raises(click.ClickException, match="Run log not found"):
            _load_run_log(tmp_repo, "run-20260101-abc123")


class TestRelPathValidation:
    """Path containment validation for prd_rel and task_rel."""

    def test_rejects_prd_rel_escaping_repo(self, tmp_repo: Path):
        runs_dir = tmp_repo / ".colonyos" / "runs"
        runs_dir.mkdir(parents=True, exist_ok=True)
        (runs_dir / "escape.json").write_text(json.dumps({
            "run_id": "escape", "prompt": "test", "status": "failed",
            "phases": [], "total_cost_usd": 0.0,
            "prd_rel": "../../etc/passwd",
            "task_rel": "cOS_tasks/tasks.md",
        }), encoding="utf-8")
        with pytest.raises(click.ClickException, match="escapes repository root"):
            _load_run_log(tmp_repo, "escape")

    def test_rejects_task_rel_escaping_repo(self, tmp_repo: Path):
        runs_dir = tmp_repo / ".colonyos" / "runs"
        runs_dir.mkdir(parents=True, exist_ok=True)
        (runs_dir / "escape2.json").write_text(json.dumps({
            "run_id": "escape2", "prompt": "test", "status": "failed",
            "phases": [], "total_cost_usd": 0.0,
            "prd_rel": "cOS_prds/prd.md",
            "task_rel": "../../../etc/shadow",
        }), encoding="utf-8")
        with pytest.raises(click.ClickException, match="escapes repository root"):
            _load_run_log(tmp_repo, "escape2")


class TestSchemaValidation:
    """Schema validation for corrupted run log JSON."""

    def test_missing_required_field_raises(self, tmp_repo: Path):
        runs_dir = tmp_repo / ".colonyos" / "runs"
        runs_dir.mkdir(parents=True, exist_ok=True)
        # Missing 'prompt' field
        (runs_dir / "bad-schema.json").write_text(json.dumps({
            "run_id": "bad-schema", "status": "failed",
            "phases": [],
        }), encoding="utf-8")
        with pytest.raises(click.ClickException, match="Invalid run log schema"):
            _load_run_log(tmp_repo, "bad-schema")

    def test_invalid_phase_value_raises(self, tmp_repo: Path):
        runs_dir = tmp_repo / ".colonyos" / "runs"
        runs_dir.mkdir(parents=True, exist_ok=True)
        (runs_dir / "bad-phase.json").write_text(json.dumps({
            "run_id": "bad-phase", "prompt": "test", "status": "failed",
            "phases": [{"phase": "nonexistent", "success": True}],
        }), encoding="utf-8")
        with pytest.raises(click.ClickException, match="Invalid run log schema"):
            _load_run_log(tmp_repo, "bad-phase")

    def test_invalid_status_value_raises(self, tmp_repo: Path):
        runs_dir = tmp_repo / ".colonyos" / "runs"
        runs_dir.mkdir(parents=True, exist_ok=True)
        (runs_dir / "bad-status.json").write_text(json.dumps({
            "run_id": "bad-status", "prompt": "test", "status": "invalid_status",
            "phases": [],
        }), encoding="utf-8")
        with pytest.raises(click.ClickException, match="Invalid run log schema"):
            _load_run_log(tmp_repo, "bad-status")


class TestGitBranchArgTermination:
    """Verify -- argument termination in git branch --list call."""

    @patch("colonyos.orchestrator.subprocess.run")
    def test_git_branch_uses_double_dash(self, mock_subprocess, tmp_repo: Path):
        log = RunLog(
            run_id="r1", prompt="test", status=RunStatus.FAILED,
            branch_name="--delete", prd_rel="cOS_prds/prd.md", task_rel="cOS_tasks/tasks.md",
        )
        mock_subprocess.return_value = MagicMock(stdout="  --delete\n")
        (tmp_repo / "cOS_prds" / "prd.md").write_text("# PRD", encoding="utf-8")
        (tmp_repo / "cOS_tasks" / "tasks.md").write_text("# Tasks", encoding="utf-8")
        _validate_resume_preconditions(tmp_repo, log)
        # Verify the subprocess call includes -- before branch name
        call_args = mock_subprocess.call_args[0][0]
        assert call_args == ["git", "branch", "--list", "--", "--delete"]


class TestResumeAuditTrail:
    """Resume events are recorded in the run log JSON."""

    def test_resume_events_recorded(self, tmp_repo: Path):
        log = RunLog(
            run_id="r-audit", prompt="test", status=RunStatus.FAILED,
            branch_name="feat/x", prd_rel="cOS_prds/prd.md", task_rel="cOS_tasks/tasks.md",
        )
        # First save (no resume)
        _save_run_log(tmp_repo, log)
        data = json.loads((tmp_repo / ".colonyos" / "runs" / "r-audit.json").read_text())
        assert data.get("resume_events", []) == []

        # Save with resumed=True
        _save_run_log(tmp_repo, log, resumed=True)
        data = json.loads((tmp_repo / ".colonyos" / "runs" / "r-audit.json").read_text())
        assert len(data["resume_events"]) == 1
        assert isinstance(data["resume_events"][0], str)  # ISO timestamp

    def test_multiple_resume_events_accumulate(self, tmp_repo: Path):
        log = RunLog(
            run_id="r-multi", prompt="test", status=RunStatus.FAILED,
            branch_name="feat/x", prd_rel="cOS_prds/prd.md", task_rel="cOS_tasks/tasks.md",
        )
        _save_run_log(tmp_repo, log)
        _save_run_log(tmp_repo, log, resumed=True)
        _save_run_log(tmp_repo, log, resumed=True)
        data = json.loads((tmp_repo / ".colonyos" / "runs" / "r-multi.json").read_text())
        assert len(data["resume_events"]) == 2


class TestPrepareResume:
    """Tests for the public prepare_resume() function."""

    def test_returns_resume_state(self, tmp_repo: Path):
        log = RunLog(
            run_id="r-prep", prompt="test", status=RunStatus.FAILED,
            branch_name="feat/x", prd_rel="cOS_prds/prd.md", task_rel="cOS_tasks/tasks.md",
            phases=[_fake_phase_result(Phase.PLAN)],
        )
        _save_run_log(tmp_repo, log)
        (tmp_repo / "cOS_prds" / "prd.md").write_text("# PRD", encoding="utf-8")
        (tmp_repo / "cOS_tasks" / "tasks.md").write_text("# Tasks", encoding="utf-8")

        with patch("colonyos.orchestrator.subprocess.run") as mock_sub:
            mock_sub.return_value = MagicMock(stdout="  feat/x\n")
            state = prepare_resume(tmp_repo, "r-prep")

        assert isinstance(state, ResumeState)
        assert state.branch_name == "feat/x"
        assert state.prd_rel == "cOS_prds/prd.md"
        assert state.task_rel == "cOS_tasks/tasks.md"
        assert state.last_successful_phase == "plan"

    def test_raises_on_no_successful_phases(self, tmp_repo: Path):
        log = RunLog(
            run_id="r-nosuccess", prompt="test", status=RunStatus.FAILED,
            branch_name="feat/x", prd_rel="cOS_prds/prd.md", task_rel="cOS_tasks/tasks.md",
            phases=[_fake_phase_result(Phase.PLAN, success=False)],
        )
        _save_run_log(tmp_repo, log)
        (tmp_repo / "cOS_prds" / "prd.md").write_text("# PRD", encoding="utf-8")
        (tmp_repo / "cOS_tasks" / "tasks.md").write_text("# Tasks", encoding="utf-8")

        with patch("colonyos.orchestrator.subprocess.run") as mock_sub:
            mock_sub.return_value = MagicMock(stdout="  feat/x\n")
            with pytest.raises(click.ClickException, match="No successful phases"):
                prepare_resume(tmp_repo, "r-nosuccess")

    def test_rejects_path_traversal(self, tmp_repo: Path):
        with pytest.raises(click.ClickException, match="must not contain"):
            prepare_resume(tmp_repo, "../../etc/passwd")


class TestHeartbeat:
    """Task 6.1: Tests for heartbeat file."""

    def test_heartbeat_created_at_phase_start(self, tmp_repo: Path):
        from colonyos.orchestrator import _touch_heartbeat as touch_heartbeat
        heartbeat_path = tmp_repo / ".colonyos" / "runs" / "heartbeat"
        assert not heartbeat_path.exists()
        touch_heartbeat(tmp_repo)
        assert heartbeat_path.exists()

    def test_heartbeat_path_is_correct(self, tmp_repo: Path):
        from colonyos.orchestrator import _touch_heartbeat as touch_heartbeat
        touch_heartbeat(tmp_repo)
        heartbeat_path = tmp_repo / ".colonyos" / "runs" / "heartbeat"
        assert heartbeat_path.exists()

    def test_heartbeat_touch_updates_mtime(self, tmp_repo: Path):
        import time
        from colonyos.orchestrator import _touch_heartbeat as touch_heartbeat
        touch_heartbeat(tmp_repo)
        heartbeat_path = tmp_repo / ".colonyos" / "runs" / "heartbeat"
        mtime1 = heartbeat_path.stat().st_mtime
        time.sleep(0.05)
        touch_heartbeat(tmp_repo)
        mtime2 = heartbeat_path.stat().st_mtime
        assert mtime2 >= mtime1


class TestLearningsInjection:
    """Tests for learnings injection into implement and fix prompts."""

    def test_implement_prompt_includes_learnings(self, tmp_repo: Path, config: ColonyConfig):
        lpath = learnings_path(tmp_repo)
        lpath.parent.mkdir(parents=True, exist_ok=True)
        entries = [LearningEntry("code-quality", "Always add docstrings")]
        append_learnings(tmp_repo, "run-001", "2026-03-17", "test", entries)

        system, _ = _build_implement_prompt(config, "prd.md", "tasks.md", "branch", repo_root=tmp_repo)
        assert "## Learnings from Past Runs" in system
        assert "Always add docstrings" in system

    def test_implement_prompt_no_crash_when_no_ledger(self, tmp_repo: Path, config: ColonyConfig):
        system, _ = _build_implement_prompt(config, "prd.md", "tasks.md", "branch", repo_root=tmp_repo)
        assert "Learnings" not in system

    def test_implement_prompt_no_learnings_without_repo_root(self, config: ColonyConfig):
        system, _ = _build_implement_prompt(config, "prd.md", "tasks.md", "branch")
        assert "Learnings" not in system

    def test_fix_prompt_includes_learnings(self, tmp_repo: Path, config: ColonyConfig):
        lpath = learnings_path(tmp_repo)
        lpath.parent.mkdir(parents=True, exist_ok=True)
        entries = [LearningEntry("testing", "Write tests first")]
        append_learnings(tmp_repo, "run-001", "2026-03-17", "test", entries)

        system, _ = _build_fix_prompt(
            config, "prd.md", "tasks.md", "branch", "fix this", 1, repo_root=tmp_repo
        )
        assert "## Learnings from Past Runs" in system
        assert "Write tests first" in system

    def test_fix_prompt_no_crash_when_no_ledger(self, tmp_repo: Path, config: ColonyConfig):
        system, _ = _build_fix_prompt(
            config, "prd.md", "tasks.md", "branch", "fix this", 1, repo_root=tmp_repo
        )
        assert "Learnings" not in system


class TestParseLearnOutput:
    def test_parses_valid_entries(self):
        text = (
            "- **[code-quality]** Always add docstrings\n"
            "- **[testing]** Run pytest before committing\n"
        )
        entries = _parse_learn_output(text)
        assert len(entries) == 2
        assert entries[0].category == "code-quality"
        assert entries[1].category == "testing"

    def test_ignores_invalid_categories(self):
        text = "- **[invalid-cat]** Something\n"
        entries = _parse_learn_output(text)
        assert len(entries) == 0

    def test_truncates_long_entries(self):
        long_text = "x" * 200
        text = f"- **[code-quality]** {long_text}\n"
        entries = _parse_learn_output(text)
        assert len(entries[0].text) <= 150


class TestBuildLearnPrompt:
    def test_returns_system_and_user(self, tmp_repo: Path, config: ColonyConfig):
        system, user = _build_learn_prompt(config, tmp_repo)
        assert "review artifacts" in system.lower() or "review" in system.lower()
        assert config.reviews_dir in user

    def test_learn_prompt_contains_tool_constraint_language(self, tmp_repo: Path, config: ColonyConfig):
        """Regression: learn.md must explicitly state available tools so the agent
        does not reach for disallowed tools (Bash, Agent) and crash."""
        system, _user = _build_learn_prompt(config, tmp_repo)
        system_lower = system.lower()
        # Must mention the three allowed tools
        assert "read" in system_lower, "learn prompt must mention Read tool"
        assert "glob" in system_lower, "learn prompt must mention Glob tool"
        assert "grep" in system_lower, "learn prompt must mention Grep tool"
        # Must contain a negative constraint about disallowed tools
        assert "do not" in system_lower or "must not" in system_lower or "never" in system_lower, (
            "learn prompt must contain a negative constraint about disallowed tools"
        )
        assert "bash" in system_lower, (
            "learn prompt must explicitly warn against using Bash"
        )

    def test_learn_prompt_contains_glob_pattern_for_reviews(self, tmp_repo: Path, config: ColonyConfig):
        """Regression: learn.md must provide concrete Glob patterns instead of
        vague 'read all recursively' instructions that lead the agent to use Bash find."""
        system, _user = _build_learn_prompt(config, tmp_repo)
        assert "**/*.md" in system, (
            "learn prompt must include a concrete Glob pattern like **/*.md for file discovery"
        )


@patch("colonyos.orchestrator.run_phases_parallel_sync")
@patch("colonyos.orchestrator.run_phase_sync")
class TestLearnPhaseWiring:
    """Tests for learn phase wiring in the orchestrator pipeline."""

    def test_learn_phase_produces_phase_result(self, mock_run, mock_parallel, tmp_git_repo: Path, config: ColonyConfig):
        """Learn phase runs and produces a PhaseResult with Phase.LEARN."""
        save_config(tmp_git_repo, config)
        mock_run.side_effect = [
            _fake_phase_result(Phase.PLAN),
            _fake_phase_result(Phase.IMPLEMENT),
            PhaseResult(phase=Phase.DECISION, success=True, cost_usd=0.01,
                        duration_ms=50, session_id="s",
                        artifacts={"result": "VERDICT: GO"}),
            _fake_phase_result(Phase.LEARN),
            _fake_phase_result(Phase.VERIFY),
            _fake_phase_result(Phase.DELIVER),
        ]
        mock_parallel.return_value = [_approve_review_result()]

        log = run("Add feature", repo_root=tmp_git_repo, config=config)

        phase_types = [p.phase for p in log.phases]
        assert Phase.LEARN in phase_types

    def test_learn_phase_runs_on_nogo(self, mock_run, mock_parallel, tmp_git_repo: Path, config: ColonyConfig):
        """Learn phase runs even on NO-GO verdict."""
        config.max_fix_iterations = 0
        save_config(tmp_git_repo, config)
        mock_run.side_effect = [
            _fake_phase_result(Phase.PLAN),
            _fake_phase_result(Phase.IMPLEMENT),
            PhaseResult(phase=Phase.DECISION, success=True, cost_usd=0.01,
                        duration_ms=50, session_id="s",
                        artifacts={"result": "VERDICT: NO-GO"}),
            _fake_phase_result(Phase.LEARN),
        ]
        mock_parallel.return_value = [_approve_review_result()]

        log = run("Add feature", repo_root=tmp_git_repo, config=config)

        assert log.status == RunStatus.FAILED
        phase_types = [p.phase for p in log.phases]
        assert Phase.LEARN in phase_types

    def test_learn_phase_skipped_when_disabled(self, mock_run, mock_parallel, tmp_git_repo: Path, config: ColonyConfig):
        """Learn phase is skipped when config.learnings.enabled is False."""
        config.learnings = LearningsConfig(enabled=False)
        save_config(tmp_git_repo, config)
        mock_run.side_effect = [
            _fake_phase_result(Phase.PLAN),
            _fake_phase_result(Phase.IMPLEMENT),
            PhaseResult(phase=Phase.DECISION, success=True, cost_usd=0.01,
                        duration_ms=50, session_id="s",
                        artifacts={"result": "VERDICT: GO"}),
            _fake_phase_result(Phase.VERIFY),
            _fake_phase_result(Phase.DELIVER),
        ]
        mock_parallel.return_value = [_approve_review_result()]

        log = run("Add feature", repo_root=tmp_git_repo, config=config)

        phase_types = [p.phase for p in log.phases]
        assert Phase.LEARN not in phase_types
        assert log.status == RunStatus.COMPLETED

    def test_learn_phase_failure_does_not_block_deliver(self, mock_run, mock_parallel, tmp_git_repo: Path, config: ColonyConfig):
        """Learn phase failure does not prevent deliver from running."""
        save_config(tmp_git_repo, config)

        _learn_phase_results = [
            _fake_phase_result(Phase.PLAN),
            _fake_phase_result(Phase.IMPLEMENT),
            PhaseResult(phase=Phase.DECISION, success=True, cost_usd=0.01,
                        duration_ms=50, session_id="s",
                        artifacts={"result": "VERDICT: GO"}),
            PhaseResult(phase=Phase.LEARN, success=False, error="Budget exceeded"),
            _fake_phase_result(Phase.VERIFY),
            _fake_phase_result(Phase.DELIVER),
        ]

        def learn_side_effect(*args: object, **kwargs: object) -> PhaseResult:
            return _learn_phase_results.pop(0)

        mock_run.side_effect = learn_side_effect
        mock_parallel.return_value = [_approve_review_result()]

        log = run("Add feature", repo_root=tmp_git_repo, config=config)

        assert log.status == RunStatus.COMPLETED
        phase_types = [p.phase for p in log.phases]
        assert Phase.LEARN in phase_types
        assert Phase.DELIVER in phase_types

    def test_learn_phase_exception_does_not_block_deliver(self, mock_run, mock_parallel, tmp_git_repo: Path, config: ColonyConfig):
        """Exception in learn phase does not prevent deliver from running."""
        save_config(tmp_git_repo, config)

        call_count = [0]
        results = [
            _fake_phase_result(Phase.PLAN),
            _fake_phase_result(Phase.IMPLEMENT),
            PhaseResult(phase=Phase.DECISION, success=True, cost_usd=0.01,
                        duration_ms=50, session_id="s",
                        artifacts={"result": "VERDICT: GO"}),
            RuntimeError("Agent crashed"),  # learn phase raises
            _fake_phase_result(Phase.VERIFY),
            _fake_phase_result(Phase.DELIVER),
        ]

        def side_effect(*args, **kwargs):
            r = results[call_count[0]]
            call_count[0] += 1
            if isinstance(r, Exception):
                raise r
            return r

        mock_run.side_effect = side_effect
        mock_parallel.return_value = [_approve_review_result()]

        log = run("Add feature", repo_root=tmp_git_repo, config=config)

        assert log.status == RunStatus.COMPLETED
        phase_types = [p.phase for p in log.phases]
        assert Phase.LEARN in phase_types
        assert Phase.DELIVER in phase_types
        learn_result = [p for p in log.phases if p.phase == Phase.LEARN][0]
        assert learn_result.success is False

    def test_learn_phase_budget(self, mock_run, mock_parallel, tmp_git_repo: Path, config: ColonyConfig):
        """Learn phase budget is min(0.50, config.budget.per_phase / 2)."""
        config.budget.per_phase = 1.0
        save_config(tmp_git_repo, config)
        mock_run.side_effect = [
            _fake_phase_result(Phase.PLAN),
            _fake_phase_result(Phase.IMPLEMENT),
            PhaseResult(phase=Phase.DECISION, success=True, cost_usd=0.01,
                        duration_ms=50, session_id="s",
                        artifacts={"result": "VERDICT: GO"}),
            _fake_phase_result(Phase.LEARN),
            _fake_phase_result(Phase.VERIFY),
            _fake_phase_result(Phase.DELIVER),
        ]
        mock_parallel.return_value = [_approve_review_result()]

        run("Add feature", repo_root=tmp_git_repo, config=config)

        # Find the learn phase call
        for call in mock_run.call_args_list:
            if call[0][0] == Phase.LEARN:
                assert call.kwargs["budget_usd"] == 0.50
                assert call.kwargs["allowed_tools"] == ["Read", "Glob", "Grep"]
                break

    def test_learn_phase_uses_read_only_tools(self, mock_run, mock_parallel, tmp_git_repo: Path, config: ColonyConfig):
        """Learn phase uses read-only tools."""
        save_config(tmp_git_repo, config)
        mock_run.side_effect = [
            _fake_phase_result(Phase.PLAN),
            _fake_phase_result(Phase.IMPLEMENT),
            PhaseResult(phase=Phase.DECISION, success=True, cost_usd=0.01,
                        duration_ms=50, session_id="s",
                        artifacts={"result": "VERDICT: GO"}),
            _fake_phase_result(Phase.LEARN),
            _fake_phase_result(Phase.VERIFY),
            _fake_phase_result(Phase.DELIVER),
        ]
        mock_parallel.return_value = [_approve_review_result()]

        run("Add feature", repo_root=tmp_git_repo, config=config)

        for call in mock_run.call_args_list:
            if call[0][0] == Phase.LEARN:
                tools = call.kwargs["allowed_tools"]
                assert tools == ["Read", "Glob", "Grep"]
                assert "Write" not in tools
                assert "Bash" not in tools
                break


# ---------------------------------------------------------------------------
# GitHub issue integration tests
# ---------------------------------------------------------------------------


class TestBuildPlanPromptWithIssue:
    def test_without_issue(self, config: ColonyConfig) -> None:
        system, user = _build_plan_prompt("test prompt", config, "prd.md", "tasks.md")
        assert "GitHub issue" not in system

    def test_with_issue(self, config: ColonyConfig) -> None:
        system, user = _build_plan_prompt(
            "test prompt", config, "prd.md", "tasks.md",
            source_issue=42,
            source_issue_url="https://github.com/org/repo/issues/42",
        )
        assert "#42" in system
        assert "https://github.com/org/repo/issues/42" in system
        assert "Source Issue" in system

    def test_user_prompt_unchanged(self, config: ColonyConfig) -> None:
        _, user = _build_plan_prompt(
            "my feature", config, "prd.md", "tasks.md",
            source_issue=1,
        )
        assert "my feature" in user


class TestBuildDeliverPromptWithIssue:
    def test_without_issue(self, config: ColonyConfig) -> None:
        system, _ = _build_deliver_prompt(config, "prd.md", "branch")
        assert "Closes" not in system

    def test_with_issue(self, config: ColonyConfig) -> None:
        system, _ = _build_deliver_prompt(config, "prd.md", "branch", source_issue=42)
        assert "Closes #42" in system
        assert "#42" in system


class TestSaveRunLogSourceIssue:
    def test_persists_source_issue_fields(self, tmp_repo: Path) -> None:
        log = RunLog(
            run_id="r-issue", prompt="test", status=RunStatus.COMPLETED,
            source_issue=42,
            source_issue_url="https://github.com/org/repo/issues/42",
        )
        path = _save_run_log(tmp_repo, log)
        data = json.loads(path.read_text(encoding="utf-8"))
        assert data["source_issue"] == 42
        assert data["source_issue_url"] == "https://github.com/org/repo/issues/42"

    def test_persists_none_when_no_issue(self, tmp_repo: Path) -> None:
        log = RunLog(run_id="r-no-issue", prompt="test", status=RunStatus.COMPLETED)
        path = _save_run_log(tmp_repo, log)
        data = json.loads(path.read_text(encoding="utf-8"))
        assert data["source_issue"] is None
        assert data["source_issue_url"] is None


class TestLoadRunLogSourceIssue:
    def test_loads_source_issue(self, tmp_repo: Path) -> None:
        log = RunLog(
            run_id="r-load", prompt="test", status=RunStatus.FAILED,
            source_issue=7,
            source_issue_url="https://github.com/org/repo/issues/7",
        )
        _save_run_log(tmp_repo, log)
        loaded = _load_run_log(tmp_repo, "r-load")
        assert loaded.source_issue == 7
        assert loaded.source_issue_url == "https://github.com/org/repo/issues/7"

    def test_backward_compat_missing_fields(self, tmp_repo: Path) -> None:
        runs_dir = tmp_repo / ".colonyos" / "runs"
        runs_dir.mkdir(parents=True, exist_ok=True)
        (runs_dir / "old.json").write_text(json.dumps({
            "run_id": "old", "prompt": "test", "status": "failed",
            "phases": [], "total_cost_usd": 0.0,
        }), encoding="utf-8")
        loaded = _load_run_log(tmp_repo, "old")
        assert loaded.source_issue is None
        assert loaded.source_issue_url is None


class TestSaveRunLogParallelFields:
    """Round-trip for pr_url, post_fix_head_sha, parallel_tasks, wall/agent time."""

    def test_round_trips_parallel_metadata(self, tmp_repo: Path) -> None:
        log = RunLog(
            run_id="r-parallel", prompt="test", status=RunStatus.COMPLETED,
            pr_url="https://github.com/org/repo/pull/99",
            post_fix_head_sha="abc123",
            parallel_tasks=5,
            wall_time_ms=30000,
            agent_time_ms=90000,
        )
        path = _save_run_log(tmp_repo, log)
        data = json.loads(path.read_text(encoding="utf-8"))
        assert data["pr_url"] == "https://github.com/org/repo/pull/99"
        assert data["post_fix_head_sha"] == "abc123"
        assert data["parallel_tasks"] == 5
        assert data["wall_time_ms"] == 30000
        assert data["agent_time_ms"] == 90000

    def test_loads_parallel_metadata(self, tmp_repo: Path) -> None:
        log = RunLog(
            run_id="r-par-load", prompt="test", status=RunStatus.FAILED,
            pr_url="https://github.com/org/repo/pull/7",
            post_fix_head_sha="def456",
            parallel_tasks=3,
            wall_time_ms=10000,
            agent_time_ms=25000,
        )
        _save_run_log(tmp_repo, log)
        loaded = _load_run_log(tmp_repo, "r-par-load")
        assert loaded.pr_url == "https://github.com/org/repo/pull/7"
        assert loaded.post_fix_head_sha == "def456"
        assert loaded.parallel_tasks == 3
        assert loaded.wall_time_ms == 10000
        assert loaded.agent_time_ms == 25000

    def test_backward_compat_missing_parallel_fields(self, tmp_repo: Path) -> None:
        runs_dir = tmp_repo / ".colonyos" / "runs"
        runs_dir.mkdir(parents=True, exist_ok=True)
        (runs_dir / "old-par.json").write_text(json.dumps({
            "run_id": "old-par", "prompt": "test", "status": "failed",
            "phases": [], "total_cost_usd": 0.0,
        }), encoding="utf-8")
        loaded = _load_run_log(tmp_repo, "old-par")
        assert loaded.pr_url is None
        assert loaded.post_fix_head_sha is None
        assert loaded.parallel_tasks is None
        assert loaded.wall_time_ms is None
        assert loaded.agent_time_ms is None


class TestInvokeUIFactory:
    """Regression tests for _invoke_ui_factory with extended and legacy signatures."""

    def test_legacy_prefix_only_factory(self) -> None:
        called_with: list[str] = []

        def legacy_factory(prefix: str) -> str:
            called_with.append(prefix)
            return f"ui-{prefix}"

        result = _invoke_ui_factory(legacy_factory, prefix="[R1] ")
        assert result == "ui-[R1] "
        assert called_with == ["[R1] "]

    def test_extended_factory_with_task_id(self) -> None:
        captured: dict[str, object] = {}

        def extended_factory(*, prefix: str = "", task_id: str | None = None, badge: object = None) -> str:
            captured["prefix"] = prefix
            captured["task_id"] = task_id
            return f"ui-task-{task_id}"

        result = _invoke_ui_factory(extended_factory, prefix="", task_id="3.0")
        assert result == "ui-task-3.0"
        assert captured["task_id"] == "3.0"

    def test_legacy_factory_with_badge_gets_markup_prefix(self) -> None:
        from colonyos.ui import StreamBadge
        badge = StreamBadge(text="R1", style="cyan")

        called_with: list[str] = []

        def legacy_factory(prefix: str) -> str:
            called_with.append(prefix)
            return "ui"

        _invoke_ui_factory(legacy_factory, badge=badge)
        assert called_with[0] == f"{badge.markup} "

    def test_kwargs_factory(self) -> None:
        captured: dict[str, object] = {}

        def kwargs_factory(**kw: object) -> str:
            captured.update(kw)
            return "ui"

        _invoke_ui_factory(kwargs_factory, prefix="x", task_id="1.0")
        assert captured["task_id"] == "1.0"


class TestBuildCeoPromptWithIssues:
    @patch("colonyos.github.fetch_open_issues")
    def test_injects_open_issues(self, mock_fetch, tmp_repo: Path, config: ColonyConfig) -> None:
        from colonyos.github import GitHubIssue
        mock_fetch.return_value = [
            GitHubIssue(number=1, title="Bug fix", body="", labels=["bug"]),
            GitHubIssue(number=2, title="New feature", body="", labels=[]),
        ]
        _, user = _build_ceo_prompt(config, "proposal.md", tmp_repo)
        assert "## Open Issues" in user
        assert "#1: Bug fix" in user
        assert "#2: New feature" in user
        assert "Issue: #N" in user

    @patch("colonyos.github.fetch_open_issues")
    def test_no_issues_no_section(self, mock_fetch, tmp_repo: Path, config: ColonyConfig) -> None:
        mock_fetch.return_value = []
        _, user = _build_ceo_prompt(config, "proposal.md", tmp_repo)
        assert "## Open Issues" not in user

    @patch("colonyos.github.fetch_open_issues", side_effect=Exception("fail"))
    def test_exception_non_blocking(self, mock_fetch, tmp_repo: Path, config: ColonyConfig) -> None:
        _, user = _build_ceo_prompt(config, "proposal.md", tmp_repo)
        # Should not raise — just proceed without issues
        assert "Analyze this project" in user


class TestPerPhaseModelRouting:
    """Verify that orchestrator passes config.get_model(Phase.XXX) to run_phase_sync for each phase."""

    @patch("colonyos.orchestrator.run_phases_parallel_sync")
    @patch("colonyos.orchestrator.run_phase_sync")
    def test_each_phase_receives_correct_model(self, mock_run, mock_parallel, tmp_git_repo: Path):
        """With per-phase overrides, each run_phase_sync call gets the right model string."""
        config = ColonyConfig(
            project=ProjectInfo(name="Test", description="test", stack="Python"),
            personas=[REVIEWER_PERSONA],
            model="sonnet",
            phase_models={"plan": "haiku", "implement": "opus", "deliver": "haiku"},
            budget=BudgetConfig(per_phase=1.0, per_run=10.0),
            phases=PhasesConfig(plan=True, implement=True, review=True, deliver=True),
        )
        save_config(tmp_git_repo, config)

        mock_run.side_effect = [
            _fake_phase_result(Phase.PLAN),
            _fake_phase_result(Phase.IMPLEMENT),
            PhaseResult(phase=Phase.DECISION, success=True, cost_usd=0.01,
                        duration_ms=50, session_id="s",
                        artifacts={"result": "VERDICT: GO"}),
            _fake_phase_result(Phase.LEARN),
            _fake_phase_result(Phase.VERIFY),
            _fake_phase_result(Phase.DELIVER),
        ]
        mock_parallel.return_value = [_approve_review_result()]

        run("Test model routing", repo_root=tmp_git_repo, config=config)

        # Collect model= kwarg from each run_phase_sync call
        calls = mock_run.call_args_list
        model_by_phase = {}
        for call in calls:
            phase_arg = call.args[0] if call.args else call.kwargs.get("phase")
            model_kwarg = call.kwargs.get("model")
            model_by_phase[phase_arg] = model_kwarg

        # Overridden phases get their override
        assert model_by_phase[Phase.PLAN] == "haiku"
        assert model_by_phase[Phase.IMPLEMENT] == "opus"
        assert model_by_phase[Phase.DELIVER] == "haiku"
        # Non-overridden phases fall back to global model
        assert model_by_phase[Phase.DECISION] == "sonnet"
        assert model_by_phase[Phase.LEARN] == "sonnet"

    @patch("colonyos.orchestrator.run_phases_parallel_sync")
    @patch("colonyos.orchestrator.run_phase_sync")
    def test_review_parallel_receives_correct_model(self, mock_run, mock_parallel, tmp_git_repo: Path):
        """Review phase (via parallel runner) also receives the per-phase model."""
        config = ColonyConfig(
            project=ProjectInfo(name="Test", description="test", stack="Python"),
            personas=[REVIEWER_PERSONA],
            model="sonnet",
            phase_models={"review": "opus"},
            budget=BudgetConfig(per_phase=1.0, per_run=10.0),
            phases=PhasesConfig(plan=True, implement=True, review=True, deliver=True),
        )
        save_config(tmp_git_repo, config)

        mock_run.side_effect = [
            _fake_phase_result(Phase.PLAN),
            _fake_phase_result(Phase.IMPLEMENT),
            PhaseResult(phase=Phase.DECISION, success=True, cost_usd=0.01,
                        duration_ms=50, session_id="s",
                        artifacts={"result": "VERDICT: GO"}),
            _fake_phase_result(Phase.LEARN),
            _fake_phase_result(Phase.VERIFY),
            _fake_phase_result(Phase.DELIVER),
        ]
        mock_parallel.return_value = [_approve_review_result()]

        run("Test review model", repo_root=tmp_git_repo, config=config)

        # The parallel call for review passes a list of dicts, each with model=
        parallel_call = mock_parallel.call_args
        call_dicts = parallel_call.args[0]
        for call_dict in call_dicts:
            assert call_dict["model"] == "opus"


class TestSaveReviewArtifact:
    """Tests for _save_review_artifact subdirectory support."""

    def test_saves_to_root_when_no_subdirectory(self, tmp_path: Path):
        path = _save_review_artifact(
            tmp_path, "cOS_reviews", "test.md", "content"
        )
        assert path == tmp_path / "cOS_reviews" / "test.md"
        assert path.read_text(encoding="utf-8") == "content"

    def test_saves_to_subdirectory(self, tmp_path: Path):
        path = _save_review_artifact(
            tmp_path, "cOS_reviews", "decision.md", "verdict",
            subdirectory="decisions",
        )
        assert path == tmp_path / "cOS_reviews" / "decisions" / "decision.md"
        assert path.read_text(encoding="utf-8") == "verdict"

    def test_creates_nested_subdirectories(self, tmp_path: Path):
        path = _save_review_artifact(
            tmp_path, "cOS_reviews", "round1.md", "review",
            subdirectory="reviews/staff_engineer",
        )
        expected = tmp_path / "cOS_reviews" / "reviews" / "staff_engineer" / "round1.md"
        assert path == expected
        assert path.exists()

    def test_rejects_path_traversal(self, tmp_path: Path):
        with pytest.raises(ValueError, match="escapes"):
            _save_review_artifact(
                tmp_path, "cOS_reviews", "bad.md", "evil",
                subdirectory="../../etc",
            )

    def test_rejects_path_traversal_in_filename(self, tmp_path: Path):
        with pytest.raises(ValueError, match="escapes"):
            _save_review_artifact(
                tmp_path, "cOS_reviews", "../../etc/passwd", "evil",
            )


class TestExtractPrNumberFromLog:
    """Tests for _extract_pr_number_from_log helper."""

    def test_extracts_from_deliver_artifacts(self) -> None:
        log = RunLog(run_id="test", prompt="test", status=RunStatus.RUNNING)
        log.phases.append(PhaseResult(
            phase=Phase.DELIVER,
            success=True,
            artifacts={"pr_url": "https://github.com/org/repo/pull/42"},
        ))
        assert _extract_pr_number_from_log(log) == 42

    def test_returns_none_when_no_deliver_phase(self) -> None:
        log = RunLog(run_id="test", prompt="test", status=RunStatus.RUNNING)
        log.phases.append(PhaseResult(phase=Phase.IMPLEMENT, success=True))
        assert _extract_pr_number_from_log(log) is None

    def test_returns_none_when_no_pr_url(self) -> None:
        log = RunLog(run_id="test", prompt="test", status=RunStatus.RUNNING)
        log.phases.append(PhaseResult(
            phase=Phase.DELIVER, success=True, artifacts={},
        ))
        assert _extract_pr_number_from_log(log) is None


class TestBuildCiFixPrompt:
    """Tests for _build_ci_fix_prompt."""

    def test_returns_system_and_user(self, tmp_path: Path) -> None:
        config = ColonyConfig()
        system, user = _build_ci_fix_prompt(
            config, "my-branch", "CI failure context here", 1, 3,
        )
        assert "my-branch" in system
        assert "CI failure context here" in system
        assert "attempt 1 of 3" in user

    def test_includes_branch_name(self, tmp_path: Path) -> None:
        config = ColonyConfig()
        system, user = _build_ci_fix_prompt(
            config, "feature/fix-tests", "", 2, 5,
        )
        assert "feature/fix-tests" in user


class TestPreflightRecovery:
    def test_build_prompt_includes_dirty_context(self) -> None:
        config = ColonyConfig()
        system, user = _build_preflight_recovery_prompt(
            config,
            "feature/recovery",
            "ship this prompt",
            "M src/app.py",
        )
        assert "feature/recovery" in system
        assert "M src/app.py" in system
        assert "ship this prompt" in user

    def test_run_preflight_recovery_rejects_secret_like_paths(self, tmp_path: Path) -> None:
        result = run_preflight_recovery(
            tmp_path,
            ColonyConfig(),
            blocked_prompt="saved prompt",
            dirty_output="M .env\nM src/app.py",
            branch_name="feature/recovery",
        )

        assert result.success is False
        assert result.error is not None
        assert "sensitive-looking files" in result.error

    @patch("colonyos.orchestrator._check_working_tree_clean", return_value=(True, ""))
    @patch("colonyos.orchestrator._get_head_sha", side_effect=["before", "after"])
    @patch("colonyos.orchestrator._changed_paths_between", return_value={"src/app.py"})
    @patch("colonyos.orchestrator.run_phase_sync")
    def test_run_preflight_recovery_requires_new_commit(
        self,
        mock_run_phase_sync,
        mock_changed_paths,
        mock_head_sha,
        mock_check_clean,
        tmp_path: Path,
    ) -> None:
        mock_run_phase_sync.return_value = PhaseResult(
            phase=Phase.PREFLIGHT_RECOVERY,
            success=True,
            cost_usd=0.01,
            duration_ms=100,
            session_id="recover",
        )

        result = run_preflight_recovery(
            tmp_path,
            ColonyConfig(),
            blocked_prompt="saved prompt",
            dirty_output="M src/app.py",
            branch_name="feature/recovery",
        )

        assert result.success is True

    @patch("colonyos.orchestrator._check_working_tree_clean", return_value=(True, ""))
    @patch("colonyos.orchestrator._get_head_sha", side_effect=["before", "after"])
    @patch("colonyos.orchestrator._changed_paths_between", return_value={"tests/test_app.py"})
    @patch("colonyos.orchestrator.run_phase_sync")
    def test_run_preflight_recovery_fails_when_blocked_files_not_committed(
        self,
        mock_run_phase_sync,
        mock_changed_paths,
        mock_head_sha,
        mock_check_clean,
        tmp_path: Path,
    ) -> None:
        mock_run_phase_sync.return_value = PhaseResult(
            phase=Phase.PREFLIGHT_RECOVERY,
            success=True,
            cost_usd=0.01,
            duration_ms=100,
            session_id="recover",
        )

        result = run_preflight_recovery(
            tmp_path,
            ColonyConfig(),
            blocked_prompt="saved prompt",
            dirty_output="M src/app.py",
            branch_name="feature/recovery",
        )

        assert result.success is False
        assert result.error is not None
        assert "did not cover all blocked files" in result.error

    @patch("colonyos.orchestrator._check_working_tree_clean", return_value=(True, ""))
    @patch("colonyos.orchestrator._get_head_sha", side_effect=["before", "after"])
    @patch(
        "colonyos.orchestrator._changed_paths_between",
        return_value={"src/app.py", "docs/notes.md"},
    )
    @patch("colonyos.orchestrator.run_phase_sync")
    def test_run_preflight_recovery_fails_on_scope_expansion(
        self,
        mock_run_phase_sync,
        mock_changed_paths,
        mock_head_sha,
        mock_check_clean,
        tmp_path: Path,
    ) -> None:
        mock_run_phase_sync.return_value = PhaseResult(
            phase=Phase.PREFLIGHT_RECOVERY,
            success=True,
            cost_usd=0.01,
            duration_ms=100,
            session_id="recover",
        )

        result = run_preflight_recovery(
            tmp_path,
            ColonyConfig(),
            blocked_prompt="saved prompt",
            dirty_output="M src/app.py",
            branch_name="feature/recovery",
        )

        assert result.success is False
        assert result.error is not None
        assert "expanded scope" in result.error

    @patch("colonyos.orchestrator._check_working_tree_clean", return_value=(True, ""))
    @patch("colonyos.orchestrator._get_head_sha", side_effect=["before", "after"])
    @patch("colonyos.orchestrator._changed_paths_between", return_value={"src/app.py"})
    @patch("colonyos.orchestrator.run_phase_sync")
    def test_run_preflight_recovery_restricts_allowed_tools(
        self,
        mock_run_phase_sync,
        mock_changed_paths,
        mock_head_sha,
        mock_check_clean,
        tmp_path: Path,
    ) -> None:
        """Preflight recovery agent should have explicitly scoped tools."""
        mock_run_phase_sync.return_value = PhaseResult(
            phase=Phase.PREFLIGHT_RECOVERY,
            success=True,
            cost_usd=0.01,
            duration_ms=100,
            session_id="recover",
        )

        run_preflight_recovery(
            tmp_path,
            ColonyConfig(),
            blocked_prompt="saved prompt",
            dirty_output="M src/app.py",
            branch_name="feature/recovery",
        )

        call_kwargs = mock_run_phase_sync.call_args[1]
        assert "allowed_tools" in call_kwargs
        allowed = call_kwargs["allowed_tools"]
        assert set(allowed) == {"Read", "Glob", "Grep", "Bash", "Write", "Edit"}


class TestRunCiFixLoop:
    """Integration tests for _run_ci_fix_loop."""

    def _make_log_with_deliver(self, pr_url: str = "https://github.com/o/r/pull/42") -> RunLog:
        log = RunLog(run_id="test", prompt="test", status=RunStatus.RUNNING)
        log.phases.append(PhaseResult(
            phase=Phase.DELIVER,
            success=True,
            artifacts={"pr_url": pr_url},
        ))
        return log

    def test_skips_when_no_pr_number(self, tmp_path: Path) -> None:
        config = ColonyConfig()
        log = RunLog(run_id="test", prompt="test", status=RunStatus.RUNNING)
        # No deliver phase → should return immediately
        _run_ci_fix_loop(config, tmp_path, log, "main")
        # No CI_FIX phases should be added
        assert not any(p.phase == Phase.CI_FIX for p in log.phases)

    def test_skips_when_all_checks_pass(self, tmp_path: Path) -> None:
        from colonyos.ci import CheckResult
        config = ColonyConfig()
        log = self._make_log_with_deliver()
        checks = [CheckResult(name="test", state="completed", conclusion="success")]
        with patch("colonyos.ci.poll_pr_checks", return_value=checks):
            _run_ci_fix_loop(config, tmp_path, log, "main")
        assert not any(p.phase == Phase.CI_FIX for p in log.phases)

    def test_runs_fix_cycle_on_failure(self, tmp_path: Path) -> None:
        from colonyos.ci import CheckResult
        config = ColonyConfig()
        log = self._make_log_with_deliver()
        failed_checks = [
            CheckResult(name="test", state="completed", conclusion="failure",
                        details_url="https://github.com/o/r/actions/runs/1/jobs/1"),
        ]
        pass_checks = [CheckResult(name="test", state="completed", conclusion="success")]
        mock_phase = PhaseResult(phase=Phase.CI_FIX, success=True, cost_usd=0.01)

        with patch("colonyos.ci.poll_pr_checks", side_effect=[failed_checks, pass_checks]), \
             patch("colonyos.ci.fetch_check_logs", return_value={"step": "error"}), \
             patch("colonyos.orchestrator.run_phase_sync", return_value=mock_phase), \
             patch("colonyos.orchestrator.subprocess.run") as mock_sp:
            mock_sp.return_value = MagicMock(returncode=0)  # git push
            _run_ci_fix_loop(config, tmp_path, log, "main")

        ci_fix_phases = [p for p in log.phases if p.phase == Phase.CI_FIX]
        assert len(ci_fix_phases) == 1
        assert ci_fix_phases[0].success

    def test_push_failure_breaks_loop(self, tmp_path: Path) -> None:
        from colonyos.ci import CheckResult
        config = ColonyConfig()
        config.ci_fix.max_retries = 3
        log = self._make_log_with_deliver()
        failed_checks = [
            CheckResult(name="test", state="completed", conclusion="failure",
                        details_url="https://github.com/o/r/actions/runs/1/jobs/1"),
        ]
        mock_phase = PhaseResult(phase=Phase.CI_FIX, success=True, cost_usd=0.01)

        with patch("colonyos.ci.poll_pr_checks", return_value=failed_checks), \
             patch("colonyos.ci.fetch_check_logs", return_value={"step": "error"}), \
             patch("colonyos.orchestrator.run_phase_sync", return_value=mock_phase), \
             patch("colonyos.orchestrator.subprocess.run") as mock_sp:
            mock_sp.return_value = MagicMock(returncode=1, stderr="push rejected")  # git push fails
            _run_ci_fix_loop(config, tmp_path, log, "main")

        # Only one attempt should have been made despite max_retries=3
        ci_fix_phases = [p for p in log.phases if p.phase == Phase.CI_FIX]
        assert len(ci_fix_phases) == 1

    def test_agent_failure_continues_retries(self, tmp_path: Path) -> None:
        from colonyos.ci import CheckResult
        config = ColonyConfig()
        config.ci_fix.max_retries = 2
        log = self._make_log_with_deliver()
        failed_checks = [
            CheckResult(name="test", state="completed", conclusion="failure",
                        details_url="https://github.com/o/r/actions/runs/1/jobs/1"),
        ]
        fail_phase = PhaseResult(phase=Phase.CI_FIX, success=False, error="agent crash")

        with patch("colonyos.ci.poll_pr_checks", return_value=failed_checks), \
             patch("colonyos.ci.fetch_check_logs", return_value={"step": "error"}), \
             patch("colonyos.orchestrator.run_phase_sync", return_value=fail_phase):
            _run_ci_fix_loop(config, tmp_path, log, "main")

        # Both attempts should have been recorded
        ci_fix_phases = [p for p in log.phases if p.phase == Phase.CI_FIX]
        assert len(ci_fix_phases) == 2
        assert all(not p.success for p in ci_fix_phases)

    def test_budget_guard_stops_loop(self, tmp_path: Path) -> None:
        """CI fix loop must stop when per-run budget is exhausted (FR21)."""
        from colonyos.ci import CheckResult
        config = ColonyConfig()
        config.ci_fix.max_retries = 3
        # per_run=0.20, per_phase=0.10, prior cost=0.15 → remaining=0.05
        # which is < per_phase → first attempt should already be blocked.
        config.budget.per_run = 0.20
        config.budget.per_phase = 0.10

        log = self._make_log_with_deliver()
        log.phases.append(PhaseResult(phase=Phase.IMPLEMENT, success=True, cost_usd=0.15))

        failed_checks = [
            CheckResult(name="test", state="completed", conclusion="failure",
                        details_url="https://github.com/o/r/actions/runs/1/jobs/1"),
        ]

        with patch("colonyos.ci.poll_pr_checks", return_value=failed_checks), \
             patch("colonyos.ci.fetch_check_logs", return_value={"step": "error"}):
            _run_ci_fix_loop(config, tmp_path, log, "main")

        # No CI fix attempts should have been made — budget exhausted
        ci_fix_phases = [p for p in log.phases if p.phase == Phase.CI_FIX]
        assert len(ci_fix_phases) == 0

    def test_budget_guard_allows_then_stops(self, tmp_path: Path) -> None:
        """Budget guard allows first attempt but blocks second when exhausted."""
        from colonyos.ci import CheckResult
        config = ColonyConfig()
        config.ci_fix.max_retries = 3
        # per_run=1.00, per_phase=0.50, prior cost=0.20 → remaining=0.80
        # Attempt 1 ok. After attempt 1 cost = 0.20+0.50=0.70, remaining=0.30
        # which is < per_phase(0.50) → attempt 2 blocked.
        config.budget.per_run = 1.00
        config.budget.per_phase = 0.50

        log = self._make_log_with_deliver()
        log.phases.append(PhaseResult(phase=Phase.IMPLEMENT, success=True, cost_usd=0.20))

        failed_checks = [
            CheckResult(name="test", state="completed", conclusion="failure",
                        details_url="https://github.com/o/r/actions/runs/1/jobs/1"),
        ]
        mock_phase = PhaseResult(phase=Phase.CI_FIX, success=True, cost_usd=0.50)

        with patch("colonyos.ci.poll_pr_checks", return_value=failed_checks), \
             patch("colonyos.ci.fetch_check_logs", return_value={"step": "error"}), \
             patch("colonyos.orchestrator.run_phase_sync", return_value=mock_phase), \
             patch("colonyos.orchestrator.subprocess.run") as mock_sp:
            mock_sp.return_value = MagicMock(returncode=0)
            _run_ci_fix_loop(config, tmp_path, log, "main")

        # Only 1 attempt ran; second blocked by budget
        ci_fix_phases = [p for p in log.phases if p.phase == Phase.CI_FIX]
        assert len(ci_fix_phases) == 1

    def test_budget_guard_caps_phase_budget(self, tmp_path: Path) -> None:
        """run_phase_sync budget should be min(per_phase, remaining)."""
        from colonyos.ci import CheckResult
        config = ColonyConfig()
        config.ci_fix.max_retries = 1
        config.budget.per_run = 1.00
        config.budget.per_phase = 0.50

        log = self._make_log_with_deliver()
        # Already spent 0.60 → remaining is 0.40 which is ≥ per_phase? No,
        # 0.40 < 0.50 so the guard would block.  Use 0.30 instead →
        # remaining = 0.70 > per_phase so attempt runs, but budget_usd
        # should be min(0.50, 0.70) = 0.50.  That doesn't test the cap.
        # Use per_run=0.80, per_phase=0.50, spent=0.40 → remaining=0.40
        # which is < per_phase → blocked.  We need remaining ≥ per_phase
        # but < per_phase too, which is impossible.  The cap only matters
        # when remaining ≥ per_phase (otherwise we break out).
        # So test: per_run=1.00, per_phase=0.80, spent=0.60 →
        # remaining=0.40 < 0.80 → blocked.
        # Ok — we can only observe the cap when remaining ≥ per_phase AND
        # remaining < per_phase.  That's contradictory for a single call.
        # The cap is: min(per_phase, remaining). When remaining ≥ per_phase,
        # cap = per_phase.  When remaining < per_phase, we break.
        # So the min() only produces a different value if remaining < per_phase,
        # but in that case the guard blocks.  The min() is thus a
        # belt-and-suspenders safety; we can still verify it's passed.
        # Use: per_run=1.00, per_phase=0.50, spent=0.20 → remaining=0.80
        # budget_usd = min(0.50, 0.80) = 0.50.
        log.phases.append(PhaseResult(phase=Phase.IMPLEMENT, success=True, cost_usd=0.20))

        failed_checks = [
            CheckResult(name="test", state="completed", conclusion="failure",
                        details_url="https://github.com/o/r/actions/runs/1/jobs/1"),
        ]
        pass_checks = [CheckResult(name="test", state="completed", conclusion="success")]
        mock_phase = PhaseResult(phase=Phase.CI_FIX, success=True, cost_usd=0.10)

        with patch("colonyos.ci.poll_pr_checks", side_effect=[failed_checks, pass_checks]), \
             patch("colonyos.ci.fetch_check_logs", return_value={"step": "error"}), \
             patch("colonyos.orchestrator.run_phase_sync", return_value=mock_phase) as mock_rps, \
             patch("colonyos.orchestrator.subprocess.run") as mock_sp:
            mock_sp.return_value = MagicMock(returncode=0)
            _run_ci_fix_loop(config, tmp_path, log, "main")

        # budget_usd = min(per_phase=0.50, remaining=0.80) = 0.50
        call_kwargs = mock_rps.call_args[1]
        assert call_kwargs["budget_usd"] == pytest.approx(0.50, abs=0.01)


# ---------------------------------------------------------------------------
# Base branch targeting tests
# ---------------------------------------------------------------------------


class TestBaseBranchDeliverPrompt:
    """Tests for base_branch parameter in _build_deliver_prompt."""

    def test_no_base_branch(self) -> None:
        from colonyos.orchestrator import _build_deliver_prompt
        config = ColonyConfig()
        system, user = _build_deliver_prompt(
            config, "cOS_prds/test.md", "colonyos/test",
        )
        assert "--base" not in system
        assert "Target branch" not in user

    def test_with_base_branch(self) -> None:
        from colonyos.orchestrator import _build_deliver_prompt
        config = ColonyConfig()
        system, user = _build_deliver_prompt(
            config, "cOS_prds/test.md", "colonyos/test",
            base_branch="colonyos/auth-middleware",
        )
        assert "colonyos/auth-middleware" in system
        assert "--base" in system
        assert "colonyos/auth-middleware" in user

    def test_base_branch_with_source_issue(self) -> None:
        from colonyos.orchestrator import _build_deliver_prompt
        config = ColonyConfig()
        system, user = _build_deliver_prompt(
            config, "cOS_prds/test.md", "colonyos/test",
            source_issue=42,
            base_branch="colonyos/feat",
        )
        assert "Closes #42" in system
        assert "colonyos/feat" in system


class TestSkipPrCreationDeliverPrompt:
    """Tests for skip_pr_creation parameter in _build_deliver_prompt."""

    def test_skip_pr_creation_system_prompt(self) -> None:
        from colonyos.orchestrator import _build_deliver_prompt
        config = ColonyConfig()
        system, user = _build_deliver_prompt(
            config, "cOS_prds/test.md", "colonyos/test",
            skip_pr_creation=True,
        )
        assert "Do NOT create a new PR" in system
        assert "already exists" in system

    def test_skip_pr_creation_user_prompt(self) -> None:
        from colonyos.orchestrator import _build_deliver_prompt
        config = ColonyConfig()
        _, user = _build_deliver_prompt(
            config, "cOS_prds/test.md", "colonyos/test",
            skip_pr_creation=True,
        )
        assert "Do NOT create a new PR" in user

    def test_no_skip_pr_creation_default(self) -> None:
        from colonyos.orchestrator import _build_deliver_prompt
        config = ColonyConfig()
        system, user = _build_deliver_prompt(
            config, "cOS_prds/test.md", "colonyos/test",
        )
        assert "Do NOT create a new PR" not in system
        assert "open a pull request" in user


class TestBaseBranchValidation:
    """Tests for base_branch validation in orchestrator.run()."""

    def test_invalid_base_branch_raises(self, tmp_path: Path) -> None:
        from colonyos.orchestrator import run as run_orchestrator
        from colonyos.models import PreflightError

        config = ColonyConfig(
            project=ProjectInfo(name="test", description="test", stack="python"),
        )

        # Initialize a git repo for pre-flight to work
        import subprocess
        subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True)
        subprocess.run(["git", "commit", "--allow-empty", "-m", "init"], cwd=tmp_path, capture_output=True)

        with pytest.raises(PreflightError, match="does not exist"):
            run_orchestrator(
                "test prompt",
                repo_root=tmp_path,
                config=config,
                base_branch="nonexistent/branch",
                offline=True,
            )

    def test_base_branch_with_invalid_chars_rejected(self, tmp_path: Path) -> None:
        """Defense-in-depth: base_branch with shell metacharacters is rejected at orchestrator level."""
        from colonyos.orchestrator import run as run_orchestrator
        from colonyos.models import PreflightError

        config = ColonyConfig(
            project=ProjectInfo(name="test", description="test", stack="python"),
        )

        import subprocess
        subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True)
        subprocess.run(["git", "commit", "--allow-empty", "-m", "init"], cwd=tmp_path, capture_output=True)

        with pytest.raises(PreflightError, match="invalid characters"):
            run_orchestrator(
                "test prompt",
                repo_root=tmp_path,
                config=config,
                base_branch="main; rm -rf /",
                offline=True,
            )

    def test_base_branch_with_dotdot_rejected(self, tmp_path: Path) -> None:
        """Paths with .. traversal are rejected."""
        from colonyos.orchestrator import run as run_orchestrator
        from colonyos.models import PreflightError

        config = ColonyConfig(
            project=ProjectInfo(name="test", description="test", stack="python"),
        )

        import subprocess
        subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True)
        subprocess.run(["git", "commit", "--allow-empty", "-m", "init"], cwd=tmp_path, capture_output=True)

        with pytest.raises(PreflightError, match="invalid characters"):
            run_orchestrator(
                "test prompt",
                repo_root=tmp_path,
                config=config,
                base_branch="../etc/passwd",
                offline=True,
            )


class TestNamedStashOnBranchRollback:
    """The finally block in run() should use a named stash with 'colonyos-{branch}' message."""

    def test_stash_command_includes_branch_name(self) -> None:
        """Verify the stash push command uses -m with branch-identifying message.

        The stash intentionally omits --include-untracked to avoid capturing
        sensitive untracked files (.env.local, credential files).
        """
        import inspect
        from colonyos.orchestrator import run as run_fn

        source = inspect.getsource(run_fn)
        assert "git\", \"stash\", \"push\", \"-m\"" in source
        assert "--include-untracked" not in source
        assert "colonyos-{branch_name}" in source


class TestBuildThreadFixPrompt:
    """Tests for _build_thread_fix_prompt."""

    def test_includes_fix_request(self, tmp_path: Path) -> None:
        config = ColonyConfig()
        system, user = _build_thread_fix_prompt(
            config,
            branch_name="colonyos/fix-auth",
            prd_rel="cOS_prds/test_prd.md",
            task_rel="cOS_tasks/test_tasks.md",
            fix_request="Fix the failing test in test_auth.py",
            original_prompt="Add auth feature",
        )
        assert "Fix the failing test in test_auth.py" in system
        assert "colonyos/fix-auth" in system
        assert "cOS_prds/test_prd.md" in system
        assert "Add auth feature" in system

    def test_user_prompt_contains_fix(self) -> None:
        config = ColonyConfig()
        system, user = _build_thread_fix_prompt(
            config,
            branch_name="colonyos/fix",
            prd_rel="prd.md",
            task_rel="tasks.md",
            fix_request="Fix the bug",
            original_prompt="original",
        )
        assert "Fix the bug" in user
        assert "colonyos/fix" in user

    def test_sanitizes_fix_request_at_point_of_use(self) -> None:
        """Defense-in-depth: XML tags in fix_request are stripped."""
        config = ColonyConfig()
        system, user = _build_thread_fix_prompt(
            config,
            branch_name="colonyos/fix",
            prd_rel="prd.md",
            task_rel="tasks.md",
            fix_request="Fix <system>injected</system> the bug",
            original_prompt="original",
        )
        assert "<system>" not in system
        assert "<system>" not in user
        assert "Fix  the bug" in system or "Fix injected the bug" in system

    def test_sanitizes_original_prompt_at_point_of_use(self) -> None:
        """Defense-in-depth: XML tags in original_prompt are stripped."""
        config = ColonyConfig()
        system, user = _build_thread_fix_prompt(
            config,
            branch_name="colonyos/fix",
            prd_rel="prd.md",
            task_rel="tasks.md",
            fix_request="Fix the bug",
            original_prompt="Add <assistant>malicious</assistant> feature",
        )
        assert "<assistant>" not in system


class TestRunThreadFix:
    """Tests for run_thread_fix orchestrator function."""

    @patch("colonyos.orchestrator.validate_branch_exists")
    def test_branch_not_found(self, mock_validate: MagicMock, tmp_git_repo: Path) -> None:
        mock_validate.return_value = (False, "Branch 'colonyos/gone' not found")
        config = ColonyConfig()
        save_config(tmp_git_repo, config)

        log = run_thread_fix(
            "fix the test",
            branch_name="colonyos/gone",
            pr_url="https://github.com/org/repo/pull/1",
            original_prompt="original",
            prd_rel="prd.md",
            task_rel="tasks.md",
            repo_root=tmp_git_repo,
            config=config,
            quiet=True,
        )
        assert log.status == RunStatus.FAILED

    @patch("colonyos.orchestrator.check_open_pr")
    @patch("colonyos.orchestrator.validate_branch_exists")
    def test_pr_merged_or_closed(
        self, mock_validate: MagicMock, mock_check_pr: MagicMock, tmp_git_repo: Path,
    ) -> None:
        mock_validate.return_value = (True, "")
        mock_check_pr.return_value = (None, None)  # No open PR
        config = ColonyConfig()
        save_config(tmp_git_repo, config)

        log = run_thread_fix(
            "fix the test",
            branch_name="colonyos/merged",
            pr_url="https://github.com/org/repo/pull/1",
            original_prompt="original",
            prd_rel="prd.md",
            task_rel="tasks.md",
            repo_root=tmp_git_repo,
            config=config,
            quiet=True,
        )
        assert log.status == RunStatus.FAILED

    @patch("colonyos.orchestrator.subprocess.run")
    @patch("colonyos.orchestrator.run_phase_sync")
    @patch("colonyos.orchestrator.check_open_pr")
    @patch("colonyos.orchestrator.validate_branch_exists")
    def test_success_path(
        self,
        mock_validate: MagicMock,
        mock_check_pr: MagicMock,
        mock_run_phase: MagicMock,
        mock_run: MagicMock,
        tmp_git_repo: Path,
    ) -> None:
        mock_validate.return_value = (True, "")
        mock_check_pr.return_value = (42, "https://github.com/org/repo/pull/42")
        mock_run.return_value = MagicMock(returncode=0, stdout="main", stderr="")

        def _phase_result(phase, *args, **kwargs):
            return PhaseResult(phase=phase, success=True, cost_usd=1.0)
        mock_run_phase.side_effect = _phase_result

        config = ColonyConfig()
        save_config(tmp_git_repo, config)

        log = run_thread_fix(
            "fix the test",
            branch_name="colonyos/feature",
            pr_url="https://github.com/org/repo/pull/42",
            original_prompt="original",
            prd_rel="prd.md",
            task_rel="tasks.md",
            repo_root=tmp_git_repo,
            config=config,
            quiet=True,
        )
        assert log.status == RunStatus.COMPLETED
        # Implement + Verify + Deliver = 3 phases
        assert len(log.phases) >= 2

    @patch("colonyos.orchestrator.subprocess.run")
    @patch("colonyos.orchestrator.validate_branch_exists")
    def test_checkout_failure(
        self, mock_validate: MagicMock, mock_run: MagicMock, tmp_git_repo: Path,
    ) -> None:
        mock_validate.return_value = (True, "")
        # First call: _check_working_tree_clean (clean tree)
        # Second call: git checkout (fails)
        mock_run.side_effect = [
            MagicMock(returncode=0, stdout="", stderr=""),  # working tree clean
            MagicMock(returncode=1, stderr="error: checkout failed"),  # checkout fails
        ]

        config = ColonyConfig()
        save_config(tmp_git_repo, config)

        log = run_thread_fix(
            "fix the test",
            branch_name="colonyos/feature",
            pr_url=None,  # No PR URL, skip PR check
            original_prompt="original",
            prd_rel="prd.md",
            task_rel="tasks.md",
            repo_root=tmp_git_repo,
            config=config,
            quiet=True,
        )
        assert log.status == RunStatus.FAILED

    def test_invalid_branch_name_rejected(self, tmp_git_repo: Path) -> None:
        """Defense-in-depth: run_thread_fix rejects invalid git refs."""
        config = ColonyConfig()
        save_config(tmp_git_repo, config)

        log = run_thread_fix(
            "fix the test",
            branch_name="../../../etc/passwd",
            pr_url=None,
            original_prompt="original",
            prd_rel="prd.md",
            task_rel="tasks.md",
            repo_root=tmp_git_repo,
            config=config,
            quiet=True,
        )
        assert log.status == RunStatus.FAILED

    def test_empty_branch_name_rejected(self, tmp_git_repo: Path) -> None:
        """Defense-in-depth: run_thread_fix rejects empty branch name."""
        config = ColonyConfig()
        save_config(tmp_git_repo, config)

        log = run_thread_fix(
            "fix the test",
            branch_name="",
            pr_url=None,
            original_prompt="original",
            prd_rel="prd.md",
            task_rel="tasks.md",
            repo_root=tmp_git_repo,
            config=config,
            quiet=True,
        )
        assert log.status == RunStatus.FAILED

    @patch("colonyos.orchestrator._get_head_sha")
    @patch("colonyos.orchestrator.subprocess.run")
    @patch("colonyos.orchestrator.validate_branch_exists")
    def test_head_sha_mismatch_fails(
        self,
        mock_validate: MagicMock,
        mock_run: MagicMock,
        mock_get_sha: MagicMock,
        tmp_git_repo: Path,
    ) -> None:
        """FR-7: HEAD SHA verification detects force-push tampering."""
        mock_validate.return_value = (True, "")
        mock_run.return_value = MagicMock(returncode=0, stdout="main", stderr="")
        mock_get_sha.return_value = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"

        config = ColonyConfig()
        save_config(tmp_git_repo, config)

        log = run_thread_fix(
            "fix the test",
            branch_name="colonyos/feature",
            pr_url=None,
            original_prompt="original",
            prd_rel="prd.md",
            task_rel="tasks.md",
            repo_root=tmp_git_repo,
            config=config,
            quiet=True,
            expected_head_sha="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
        )
        assert log.status == RunStatus.FAILED

    @patch("colonyos.orchestrator._get_head_sha")
    @patch("colonyos.orchestrator.subprocess.run")
    @patch("colonyos.orchestrator.run_phase_sync")
    @patch("colonyos.orchestrator.check_open_pr")
    @patch("colonyos.orchestrator.validate_branch_exists")
    def test_head_sha_match_proceeds(
        self,
        mock_validate: MagicMock,
        mock_check_pr: MagicMock,
        mock_run_phase: MagicMock,
        mock_run: MagicMock,
        mock_get_sha: MagicMock,
        tmp_git_repo: Path,
    ) -> None:
        """FR-7: Matching HEAD SHA allows the fix to proceed."""
        mock_validate.return_value = (True, "")
        mock_check_pr.return_value = (42, "https://github.com/org/repo/pull/42")
        mock_run.return_value = MagicMock(returncode=0, stdout="main", stderr="")

        def _phase_result(phase, *args, **kwargs):
            return PhaseResult(phase=phase, success=True, cost_usd=1.0)
        mock_run_phase.side_effect = _phase_result
        expected_sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
        mock_get_sha.return_value = expected_sha

        config = ColonyConfig()
        save_config(tmp_git_repo, config)

        log = run_thread_fix(
            "fix the test",
            branch_name="colonyos/feature",
            pr_url="https://github.com/org/repo/pull/42",
            original_prompt="original",
            prd_rel="prd.md",
            task_rel="tasks.md",
            repo_root=tmp_git_repo,
            config=config,
            quiet=True,
            expected_head_sha=expected_sha,
        )
        assert log.status == RunStatus.COMPLETED

    @patch("colonyos.orchestrator.subprocess.run")
    @patch("colonyos.orchestrator.run_phase_sync")
    @patch("colonyos.orchestrator.check_open_pr")
    @patch("colonyos.orchestrator.validate_branch_exists")
    def test_verify_phase_runs(
        self,
        mock_validate: MagicMock,
        mock_check_pr: MagicMock,
        mock_run_phase: MagicMock,
        mock_run: MagicMock,
        tmp_git_repo: Path,
    ) -> None:
        """FR-7: Verify phase runs between Implement and Deliver."""
        mock_validate.return_value = (True, "")
        mock_check_pr.return_value = (42, "https://github.com/org/repo/pull/42")
        mock_run.return_value = MagicMock(returncode=0, stdout="main", stderr="")

        def _phase_result(phase, *args, **kwargs):
            return PhaseResult(phase=phase, success=True, cost_usd=1.0)
        mock_run_phase.side_effect = _phase_result

        config = ColonyConfig()
        save_config(tmp_git_repo, config)

        log = run_thread_fix(
            "fix the test",
            branch_name="colonyos/feature",
            pr_url="https://github.com/org/repo/pull/42",
            original_prompt="original",
            prd_rel="prd.md",
            task_rel="tasks.md",
            repo_root=tmp_git_repo,
            config=config,
            quiet=True,
        )
        assert log.status == RunStatus.COMPLETED
        # Should have Implement, Verify, and Deliver phases
        phase_names = [p.phase for p in log.phases]
        assert Phase.VERIFY in phase_names
        # Verify must come after Implement
        impl_idx = phase_names.index(Phase.IMPLEMENT)
        verify_idx = phase_names.index(Phase.VERIFY)
        assert verify_idx > impl_idx

    @patch("colonyos.orchestrator.subprocess.run")
    @patch("colonyos.orchestrator.run_phase_sync")
    @patch("colonyos.orchestrator.check_open_pr")
    @patch("colonyos.orchestrator.validate_branch_exists")
    def test_verify_phase_failure_stops_pipeline(
        self,
        mock_validate: MagicMock,
        mock_check_pr: MagicMock,
        mock_run_phase: MagicMock,
        mock_run: MagicMock,
        tmp_git_repo: Path,
    ) -> None:
        """Verify phase failure prevents Deliver from running."""
        mock_validate.return_value = (True, "")
        mock_check_pr.return_value = (42, "https://github.com/org/repo/pull/42")
        mock_run.return_value = MagicMock(returncode=0, stdout="main", stderr="")

        def _phase_result(phase, *args, **kwargs):
            if phase == Phase.IMPLEMENT:
                return PhaseResult(phase=phase, success=True, cost_usd=1.0)
            return PhaseResult(phase=phase, success=False, cost_usd=0.5, error="Tests failed")
        mock_run_phase.side_effect = _phase_result

        config = ColonyConfig()
        save_config(tmp_git_repo, config)

        log = run_thread_fix(
            "fix the test",
            branch_name="colonyos/feature",
            pr_url="https://github.com/org/repo/pull/42",
            original_prompt="original",
            prd_rel="prd.md",
            task_rel="tasks.md",
            repo_root=tmp_git_repo,
            config=config,
            quiet=True,
        )
        assert log.status == RunStatus.FAILED
        phase_names = [p.phase for p in log.phases]
        assert Phase.VERIFY in phase_names
        # Deliver should NOT have run
        assert Phase.DELIVER not in phase_names

    @patch("colonyos.orchestrator.subprocess.run")
    @patch("colonyos.orchestrator.run_phase_sync")
    @patch("colonyos.orchestrator.check_open_pr")
    @patch("colonyos.orchestrator.validate_branch_exists")
    def test_verify_phase_uses_verify_model(
        self,
        mock_validate: MagicMock,
        mock_check_pr: MagicMock,
        mock_run_phase: MagicMock,
        mock_run: MagicMock,
        tmp_git_repo: Path,
    ) -> None:
        """Verify phase must use Phase.VERIFY model, not Phase.IMPLEMENT."""
        mock_validate.return_value = (True, "")
        mock_check_pr.return_value = (42, "https://github.com/org/repo/pull/42")
        mock_run.return_value = MagicMock(returncode=0, stdout="main", stderr="")

        models_used: list[tuple[Phase, str]] = []

        def _phase_result(phase, *args, **kwargs):
            model = kwargs.get("model", "")
            models_used.append((phase, model))
            return PhaseResult(phase=phase, success=True, cost_usd=1.0)
        mock_run_phase.side_effect = _phase_result

        config = ColonyConfig()
        # Set distinct models so we can distinguish them
        config.phase_models = {
            "implement": "model-implement",
            "verify": "model-verify",
            "deliver": "model-deliver",
        }
        save_config(tmp_git_repo, config)

        log = run_thread_fix(
            "fix the test",
            branch_name="colonyos/feature",
            pr_url="https://github.com/org/repo/pull/42",
            original_prompt="original",
            prd_rel="prd.md",
            task_rel="tasks.md",
            repo_root=tmp_git_repo,
            config=config,
            quiet=True,
        )
        assert log.status == RunStatus.COMPLETED
        # Find the Verify phase call and confirm it used the VERIFY model
        verify_calls = [(p, m) for p, m in models_used if p == Phase.VERIFY]
        assert len(verify_calls) == 1
        assert verify_calls[0][1] == "model-verify"

    @patch("colonyos.orchestrator.subprocess.run")
    @patch("colonyos.orchestrator.run_phase_sync")
    @patch("colonyos.orchestrator.check_open_pr")
    @patch("colonyos.orchestrator.validate_branch_exists")
    def test_verify_phase_uses_read_only_tools(
        self,
        mock_validate: MagicMock,
        mock_check_pr: MagicMock,
        mock_run_phase: MagicMock,
        mock_run: MagicMock,
        tmp_git_repo: Path,
    ) -> None:
        """Verify phase must use read-only tools (no Write/Edit)."""
        mock_validate.return_value = (True, "")
        mock_check_pr.return_value = (42, "https://github.com/org/repo/pull/42")
        mock_run.return_value = MagicMock(returncode=0, stdout="main", stderr="")

        tools_used: dict[Phase, list[str] | None] = {}

        def _phase_result(phase, *args, **kwargs):
            tools_used[phase] = kwargs.get("allowed_tools")
            return PhaseResult(phase=phase, success=True, cost_usd=1.0)
        mock_run_phase.side_effect = _phase_result

        config = ColonyConfig()
        save_config(tmp_git_repo, config)

        run_thread_fix(
            "fix the test",
            branch_name="colonyos/feature",
            pr_url="https://github.com/org/repo/pull/42",
            original_prompt="original",
            prd_rel="prd.md",
            task_rel="tasks.md",
            repo_root=tmp_git_repo,
            config=config,
            quiet=True,
        )
        # Verify phase must be restricted to read-only tools
        verify_tools = tools_used.get(Phase.VERIFY)
        assert verify_tools is not None, "Verify phase should specify allowed_tools"
        assert "Write" not in verify_tools
        assert "Edit" not in verify_tools
        assert "Read" in verify_tools
        assert "Bash" in verify_tools

    @patch("colonyos.orchestrator.subprocess.run")
    @patch("colonyos.orchestrator.validate_branch_exists")
    def test_dirty_working_tree_stashes(
        self,
        mock_validate: MagicMock,
        mock_run: MagicMock,
        tmp_git_repo: Path,
    ) -> None:
        """Dirty working tree triggers stash before checkout."""
        mock_validate.return_value = (True, "")
        # First call: _check_working_tree_clean (dirty)
        # Second call: git stash
        # Third call: git checkout (fails, to stop early)
        mock_run.side_effect = [
            MagicMock(returncode=0, stdout="M  dirty_file.py", stderr=""),  # dirty tree
            MagicMock(returncode=0, stdout="", stderr=""),  # stash ok
            MagicMock(returncode=1, stderr="checkout failed"),  # checkout fails
        ]

        config = ColonyConfig()
        save_config(tmp_git_repo, config)

        log = run_thread_fix(
            "fix the test",
            branch_name="colonyos/feature",
            pr_url=None,
            original_prompt="original",
            prd_rel="prd.md",
            task_rel="tasks.md",
            repo_root=tmp_git_repo,
            config=config,
            quiet=True,
        )
        assert log.status == RunStatus.FAILED
        # Verify stash was called (second subprocess.run call)
        stash_call = mock_run.call_args_list[1]
        assert "stash" in stash_call[0][0]


class TestFailRunLogHelper:
    """Tests for the _fail_run_log deduplication helper."""

    def test_marks_failed_and_persists(self, tmp_path: Path) -> None:
        """_fail_run_log should set FAILED status, mark finished, and save."""
        log = RunLog(
            run_id="test-fail-helper",
            prompt="test",
            status=RunStatus.RUNNING,
        )
        result = _fail_run_log(tmp_path, log, "something went wrong")
        assert result is log
        assert result.status == RunStatus.FAILED
        assert result.finished_at is not None

    def test_returns_same_log_object(self, tmp_path: Path) -> None:
        log = RunLog(
            run_id="test-identity",
            prompt="test",
            status=RunStatus.RUNNING,
        )
        result = _fail_run_log(tmp_path, log, "reason")
        assert result is log


class TestBranchRestoreError:
    """BranchRestoreError should be a RuntimeError subclass."""

    def test_is_runtime_error(self) -> None:
        err = BranchRestoreError("checkout failed")
        assert isinstance(err, RuntimeError)
        assert str(err) == "checkout failed"

    def test_raised_on_checkout_failure_in_run_thread_fix(self, tmp_path: Path) -> None:
        """run_thread_fix raises BranchRestoreError when branch restore fails."""
        config = ColonyConfig()

        # Track git checkout calls to make first succeed and restore fail
        checkout_calls = []

        def _mock_subprocess_run(cmd, *args, **kwargs):
            result = MagicMock()
            result.returncode = 0
            result.stdout = ""
            result.stderr = ""
            if cmd[0] == "git" and cmd[1] == "checkout":
                checkout_calls.append(cmd)
                if len(checkout_calls) >= 2:
                    # Second checkout = restore → fail
                    result.returncode = 1
                    result.stderr = "error: pathspec 'main' did not match"
            return result

        with (
            patch("colonyos.orchestrator.subprocess.run", side_effect=_mock_subprocess_run),
            patch("colonyos.orchestrator._get_current_branch", return_value="main"),
            patch("colonyos.orchestrator.validate_branch_exists", return_value=(True, "")),
            patch("colonyos.orchestrator.is_valid_git_ref", return_value=True),
            patch("colonyos.orchestrator._check_working_tree_clean", return_value=(True, "")),
            patch("colonyos.orchestrator._get_head_sha", return_value="abc123"),
            patch("colonyos.orchestrator.run_phase_sync", return_value=PhaseResult(
                phase=Phase.IMPLEMENT, success=False, cost_usd=0.01,
                duration_ms=100,
            )),
            patch("colonyos.orchestrator._touch_heartbeat"),
        ):
            with pytest.raises(BranchRestoreError, match="pathspec"):
                run_thread_fix(
                    "fix the bug",
                    branch_name="colonyos/feature",
                    pr_url=None,
                    original_prompt="original",
                    prd_rel="prd.md",
                    task_rel="tasks.md",
                    repo_root=tmp_path,
                    config=config,
                    quiet=True,
                )


class TestStashOmitsIncludeUntracked:
    """Stash commands should NOT include --include-untracked to avoid capturing secrets."""

    def test_thread_fix_stash_no_include_untracked(self) -> None:
        """run_thread_fix stash command should not use --include-untracked."""
        import inspect
        from colonyos.orchestrator import run_thread_fix as fn

        source = inspect.getsource(fn)
        # The stash push command should be present but without --include-untracked
        assert '"git", "stash", "push", "-m"' in source
        assert "--include-untracked" not in source


class TestParallelImplementIntegration:
    """Integration tests for parallel implement mode (Task 13.1-13.3)."""

    def test_parallel_implement_function_exists(self) -> None:
        """_run_parallel_implement should be importable from orchestrator."""
        from colonyos.orchestrator import _run_parallel_implement
        assert callable(_run_parallel_implement)

    def test_parallel_implement_prompt_builder_exists(self) -> None:
        """_build_parallel_implement_prompt should be importable."""
        from colonyos.orchestrator import _build_parallel_implement_prompt
        assert callable(_build_parallel_implement_prompt)

    def test_conflict_resolve_prompt_builder_exists(self) -> None:
        """_build_conflict_resolve_prompt should be importable."""
        from colonyos.orchestrator import _build_conflict_resolve_prompt
        assert callable(_build_conflict_resolve_prompt)

    def test_build_parallel_implement_prompt(self, tmp_path: Path) -> None:
        """Test building a parallel implement prompt."""
        from colonyos.orchestrator import _build_parallel_implement_prompt

        config = ColonyConfig()
        system, user = _build_parallel_implement_prompt(
            config,
            task_id="1.0",
            task_description="Add user model",
            worktree_path=tmp_path / "worktree",
            prd_path="cOS_prds/test_prd.md",
            task_file_path="cOS_tasks/test_tasks.md",
            base_branch="main",
        )

        assert "1.0" in system
        assert "Add user model" in user
        assert "worktree" in system
        assert "cOS_prds/test_prd.md" in user

    def test_build_conflict_resolve_prompt(self, tmp_path: Path) -> None:
        """Test building a conflict resolution prompt."""
        from colonyos.orchestrator import _build_conflict_resolve_prompt

        config = ColonyConfig()
        system, user = _build_conflict_resolve_prompt(
            config,
            conflict_files=["src/file.py", "src/other.py"],
            task_id="2.0",
            target_branch="feature/branch",
            working_dir=tmp_path,
        )

        assert "2.0" in user
        assert "src/file.py" in user or "src/file.py" in system
        assert "Resolve" in user

    def test_prepare_resume_includes_parallel_task_info(self, tmp_path: Path) -> None:
        """prepare_resume should include failed/blocked task IDs for parallel runs."""
        # Create a run log with parallel task results
        log = RunLog(
            run_id="test-parallel-resume",
            prompt="test parallel resume",
            status=RunStatus.FAILED,
            branch_name="colonyos/test",
            prd_rel="prd.md",
            task_rel="tasks.md",
            phases=[
                PhaseResult(
                    phase=Phase.PLAN,
                    success=True,
                    artifacts={},
                ),
                PhaseResult(
                    phase=Phase.IMPLEMENT,
                    success=True,
                    artifacts={"task_id": "1.0"},
                ),
                PhaseResult(
                    phase=Phase.IMPLEMENT,
                    success=False,
                    error="Task failed",
                    artifacts={"task_id": "2.0"},
                ),
                PhaseResult(
                    phase=Phase.IMPLEMENT,
                    success=False,
                    error="Blocked by failed dependencies",
                    artifacts={"task_id": "3.0"},
                ),
            ],
        )

        # Save the log
        _save_run_log(tmp_path, log)

        # Mock branch validation to skip actual git checks
        with patch("colonyos.orchestrator._validate_resume_preconditions"):
            # Prepare resume (need to load log manually since we skipped validation)
            from colonyos.orchestrator import _load_run_log
            _load_run_log(tmp_path, log.run_id)

            # Now call the actual function with mocked validation
            resume_state = prepare_resume(tmp_path, log.run_id)

            # Check parallel task info
            assert "1.0" in resume_state.completed_task_ids
            assert "2.0" in resume_state.failed_task_ids
            assert "3.0" in resume_state.blocked_task_ids

    def test_run_parallel_implement_returns_none_when_single_task(self, tmp_path: Path) -> None:
        """_run_parallel_implement should return None when only one task."""
        from colonyos.orchestrator import _run_parallel_implement

        config = ColonyConfig()
        log = RunLog(
            run_id="test-single-task",
            prompt="test",
            status=RunStatus.RUNNING,
        )

        # Create task file with only one task
        task_file = tmp_path / "cOS_tasks" / "test_tasks.md"
        task_file.parent.mkdir(parents=True, exist_ok=True)
        task_file.write_text("""
- [ ] 1.0 Single task
  depends_on: []
""")

        result = _run_parallel_implement(
            log=log,
            repo_root=tmp_path,
            config=config,
            branch_name="test-branch",
            prd_rel="cOS_prds/test_prd.md",
            task_rel="cOS_tasks/test_tasks.md",
            _make_ui=lambda **kwargs: None,
        )

        # Should return None for single task (falls back to sequential)
        assert result is None

    def test_run_parallel_implement_returns_none_when_disabled(self, tmp_path: Path) -> None:
        """_run_parallel_implement should return None when parallel is disabled."""
        from colonyos.orchestrator import _run_parallel_implement
        from colonyos.config import ParallelImplementConfig

        config = ColonyConfig(
            parallel_implement=ParallelImplementConfig(enabled=False)
        )
        log = RunLog(
            run_id="test-disabled",
            prompt="test",
            status=RunStatus.RUNNING,
        )

        # Create task file with multiple tasks
        task_file = tmp_path / "cOS_tasks" / "test_tasks.md"
        task_file.parent.mkdir(parents=True, exist_ok=True)
        task_file.write_text("""
- [ ] 1.0 Task A
  depends_on: []
- [ ] 2.0 Task B
  depends_on: []
""")

        result = _run_parallel_implement(
            log=log,
            repo_root=tmp_path,
            config=config,
            branch_name="test-branch",
            prd_rel="cOS_prds/test_prd.md",
            task_rel="cOS_tasks/test_tasks.md",
            _make_ui=lambda **kwargs: None,
        )

        # Should return None when disabled
        assert result is None

    @patch("colonyos.orchestrator._run_parallel_implement")
    @patch("colonyos.orchestrator._preflight_check")
    @patch("colonyos.orchestrator.run_phase_sync")
    def test_parallel_implement_failure_surfaces_to_ui(
        self,
        mock_run_phase_sync,
        mock_preflight,
        mock_parallel_implement,
        tmp_git_repo: Path,
        config: ColonyConfig,
    ) -> None:
        class FakeUI:
            def __init__(self) -> None:
                self.errors: list[str] = []

            def phase_header(self, *args, **kwargs) -> None:
                pass

            def phase_complete(self, *args, **kwargs) -> None:
                pass

            def phase_error(self, error: str) -> None:
                self.errors.append(error)

            def slack_note(self, text: str) -> None:
                pass

        ui = FakeUI()
        mock_preflight.return_value = PreflightResult(
            current_branch="main",
            is_clean=True,
            branch_exists=False,
            action_taken="proceed",
        )
        config.recovery.enabled = False
        config.parallel_implement.enabled = True
        mock_run_phase_sync.return_value = _fake_phase_result(Phase.PLAN)
        mock_parallel_implement.return_value = PhaseResult(
            phase=Phase.IMPLEMENT,
            success=False,
            error="parallel worktree creation failed",
        )

        log = run(
            "Build cleanup agent",
            repo_root=tmp_git_repo,
            config=config,
            ui_factory=lambda prefix="": ui,
        )

        assert log.status == RunStatus.FAILED
        assert ui.errors == ["parallel worktree creation failed"]


class TestAutomaticRecovery:
    @patch("colonyos.orchestrator.run_phases_parallel_sync", return_value=[])
    @patch("colonyos.orchestrator.run_phase_sync")
    def test_implement_failure_triggers_auto_recovery_and_retry(
        self,
        mock_run_phase_sync,
        _mock_parallel,
        tmp_git_repo: Path,
    ) -> None:
        config = ColonyConfig(
            project=ProjectInfo(name="Test", description="test", stack="Python"),
            personas=[],
            budget=BudgetConfig(per_phase=1.0, per_run=10.0),
            phases=PhasesConfig(plan=True, implement=True, review=False, deliver=False),
            parallel_implement=ParallelImplementConfig(enabled=False),
            recovery=RecoveryConfig(enabled=True, max_phase_retries=1, allow_nuke=False),
        )
        save_config(tmp_git_repo, config)

        mock_run_phase_sync.side_effect = [
            _fake_phase_result(Phase.PLAN),
            PhaseResult(phase=Phase.IMPLEMENT, success=False, error="merge conflict"),
            PhaseResult(phase=Phase.AUTO_RECOVERY, success=True, artifacts={"result": "fixed"}),
            _fake_phase_result(Phase.IMPLEMENT),
            _fake_phase_result(Phase.LEARN),
            _fake_phase_result(Phase.VERIFY),
        ]

        log = run("Fix the broken branch", repo_root=tmp_git_repo, config=config)

        phases = [p.phase for p in log.phases]
        assert Phase.AUTO_RECOVERY in phases
        assert phases.count(Phase.IMPLEMENT) == 2
        assert log.status == RunStatus.COMPLETED
        assert any(event["kind"] == "auto_recovery" for event in log.recovery_events)

    @patch("colonyos.orchestrator.create_branch")
    @patch("colonyos.orchestrator.checkout_branch")
    @patch("colonyos.orchestrator.preserve_and_reset_worktree")
    @patch("colonyos.orchestrator.run_phases_parallel_sync", return_value=[])
    @patch("colonyos.orchestrator.run_phase_sync")
    def test_failed_recovery_escalates_to_nuke(
        self,
        mock_run_phase_sync,
        _mock_parallel,
        mock_preserve,
        mock_checkout,
        mock_create_branch,
        tmp_git_repo: Path,
    ) -> None:
        config = ColonyConfig(
            project=ProjectInfo(name="Test", description="test", stack="Python"),
            personas=[],
            budget=BudgetConfig(per_phase=1.0, per_run=10.0),
            phases=PhasesConfig(plan=True, implement=True, review=False, deliver=False),
            parallel_implement=ParallelImplementConfig(enabled=False),
            recovery=RecoveryConfig(enabled=True, max_phase_retries=1, allow_nuke=True, max_nuke_attempts=1),
        )
        save_config(tmp_git_repo, config)

        snapshot_dir = tmp_git_repo / ".colonyos" / "recovery" / "snapshot"
        snapshot_dir.mkdir(parents=True, exist_ok=True)
        mock_preserve.return_value = PreservationResult(
            snapshot_dir=snapshot_dir,
            preservation_mode="snapshot",
            stash_message=None,
        )
        mock_run_phase_sync.side_effect = [
            _fake_phase_result(Phase.PLAN),
            PhaseResult(phase=Phase.IMPLEMENT, success=False, error="unmerged index"),
            PhaseResult(phase=Phase.AUTO_RECOVERY, success=False, error="still broken"),
            PhaseResult(phase=Phase.NUKE, success=True, artifacts={"result": "start clean from main"}),
            _fake_phase_result(Phase.PLAN),
            _fake_phase_result(Phase.IMPLEMENT),
            _fake_phase_result(Phase.LEARN),
            _fake_phase_result(Phase.VERIFY),
        ]

        log = run("Rescue this run", repo_root=tmp_git_repo, config=config)

        assert log.status == RunStatus.COMPLETED
        mock_preserve.assert_called_once()
        mock_checkout.assert_called_once_with(tmp_git_repo, "main")
        mock_create_branch.assert_called_once()
        assert log.prompt.startswith("Rescue this run")
        assert "Recovery context:" in log.prompt


# ---------------------------------------------------------------------------
# _drain_injected_context tests — destructive drain semantics
# ---------------------------------------------------------------------------


class TestDrainInjectedContext:
    """Verify _drain_injected_context drains destructively and formats correctly."""

    def test_none_provider_returns_empty(self) -> None:
        assert _drain_injected_context(None) == ""

    def test_empty_queue_returns_empty(self) -> None:
        assert _drain_injected_context(lambda: []) == ""

    def test_single_message_formatted(self) -> None:
        result = _drain_injected_context(lambda: ["Please use pytest"])
        assert "## Additional User Context" in result
        assert "- Please use pytest" in result

    def test_multiple_messages_all_included(self) -> None:
        result = _drain_injected_context(lambda: ["note one", "note two"])
        assert "- note one" in result
        assert "- note two" in result

    def test_whitespace_only_messages_stripped(self) -> None:
        result = _drain_injected_context(lambda: ["  ", "\n", "real note"])
        assert "- real note" in result
        assert result.count("- ") == 1

    def test_destructive_drain_semantics(self) -> None:
        """Second call returns empty because the first call consumed the queue."""
        queue: list[str] = ["first", "second"]

        def provider() -> list[str]:
            msgs = list(queue)
            queue.clear()
            return msgs

        first = _drain_injected_context(provider)
        assert "- first" in first
        assert "- second" in first

        second = _drain_injected_context(provider)
        assert second == ""

    def test_messages_injected_between_drains_are_visible(self) -> None:
        """Messages added after a drain are visible on the next drain."""
        queue: list[str] = ["phase-1 note"]

        def provider() -> list[str]:
            msgs = list(queue)
            queue.clear()
            return msgs

        first = _drain_injected_context(provider)
        assert "- phase-1 note" in first

        queue.append("phase-2 note")
        second = _drain_injected_context(provider)
        assert "- phase-2 note" in second
        assert "phase-1" not in second


class TestRetryConfigWiring:
    """Integration tests verifying retry_config flows from ColonyConfig to run_phase_sync."""

    @patch("colonyos.orchestrator.run_phases_parallel_sync")
    @patch("colonyos.orchestrator.run_phase_sync")
    def test_retry_config_passed_to_run_phase_sync(
        self, mock_run, mock_parallel, tmp_git_repo: Path, config: ColonyConfig
    ):
        """All run_phase_sync calls receive retry_config from ColonyConfig."""
        config.retry = RetryConfig(max_attempts=5, base_delay_seconds=20.0)
        save_config(tmp_git_repo, config)

        mock_run.side_effect = [
            _fake_phase_result(Phase.PLAN),
            _fake_phase_result(Phase.IMPLEMENT),
            PhaseResult(
                phase=Phase.DECISION, success=True, cost_usd=0.01,
                duration_ms=50, session_id="s",
                artifacts={"result": "VERDICT: GO"},
            ),
            _fake_phase_result(Phase.LEARN),
            _fake_phase_result(Phase.VERIFY),
            _fake_phase_result(Phase.DELIVER),
        ]
        mock_parallel.return_value = [_approve_review_result()]

        log = run("Add tests", repo_root=tmp_git_repo, config=config)
        assert log.status == RunStatus.COMPLETED

        # Every run_phase_sync call must include retry_config
        for call in mock_run.call_args_list:
            assert "retry_config" in call.kwargs, (
                f"retry_config missing from call: {call}"
            )
            assert call.kwargs["retry_config"] is config.retry

    @patch("colonyos.orchestrator.run_phases_parallel_sync")
    @patch("colonyos.orchestrator.run_phase_sync")
    def test_retry_config_passed_to_parallel_review_calls(
        self, mock_run, mock_parallel, tmp_git_repo: Path, config: ColonyConfig
    ):
        """Parallel review calls include retry_config in their kwargs dicts."""
        config.retry = RetryConfig(max_attempts=2)
        save_config(tmp_git_repo, config)

        mock_run.side_effect = [
            _fake_phase_result(Phase.PLAN),
            _fake_phase_result(Phase.IMPLEMENT),
            PhaseResult(
                phase=Phase.DECISION, success=True, cost_usd=0.01,
                duration_ms=50, session_id="s",
                artifacts={"result": "VERDICT: GO"},
            ),
            _fake_phase_result(Phase.LEARN),
            _fake_phase_result(Phase.VERIFY),
            _fake_phase_result(Phase.DELIVER),
        ]
        mock_parallel.return_value = [_approve_review_result()]

        run("Add tests", repo_root=tmp_git_repo, config=config)

        # Check each parallel review call dict has retry_config
        assert mock_parallel.call_count >= 1
        review_calls = mock_parallel.call_args_list[0][0][0]
        for call_kwargs in review_calls:
            assert "retry_config" in call_kwargs, (
                f"retry_config missing from parallel call: {call_kwargs}"
            )
            assert call_kwargs["retry_config"] is config.retry

    @patch("colonyos.orchestrator.run_phases_parallel_sync")
    @patch("colonyos.orchestrator.run_phase_sync")
    def test_retry_info_populated_in_run_log(
        self, mock_run, mock_parallel, tmp_git_repo: Path, config: ColonyConfig
    ):
        """PhaseResult.retry_info flows through to RunLog when populated."""
        save_config(tmp_git_repo, config)

        retry_info = RetryInfo(
            attempts=2,
            transient_errors=1,
            fallback_model_used=None,
            total_retry_delay_seconds=12.5,
        )
        mock_run.side_effect = [
            _fake_phase_result(Phase.PLAN),
            PhaseResult(
                phase=Phase.IMPLEMENT, success=True, cost_usd=0.05,
                duration_ms=500, session_id="impl-session",
                artifacts={"result": "done"},
                retry_info=retry_info,
            ),
            PhaseResult(
                phase=Phase.DECISION, success=True, cost_usd=0.01,
                duration_ms=50, session_id="s",
                artifacts={"result": "VERDICT: GO"},
            ),
            _fake_phase_result(Phase.LEARN),
            _fake_phase_result(Phase.VERIFY),
            _fake_phase_result(Phase.DELIVER),
        ]
        mock_parallel.return_value = [_approve_review_result()]

        log = run("Add tests", repo_root=tmp_git_repo, config=config)
        assert log.status == RunStatus.COMPLETED

        # Find the implement phase and verify retry_info is present
        impl_phases = [p for p in log.phases if p.phase == Phase.IMPLEMENT]
        assert len(impl_phases) >= 1
        assert impl_phases[0].retry_info == retry_info
        ri = impl_phases[0].retry_info
        assert ri is not None
        assert ri.attempts == 2

    @patch("colonyos.orchestrator.run_phases_parallel_sync")
    @patch("colonyos.orchestrator.run_phase_sync")
    def test_recovery_not_triggered_when_retry_succeeds(
        self, mock_run, mock_parallel, tmp_git_repo: Path,
    ):
        """When run_phase_sync returns success (even after internal retries),
        the orchestrator's _attempt_phase_recovery is NOT invoked."""
        config = ColonyConfig(
            project=ProjectInfo(name="Test", description="test", stack="Python"),
            personas=[REVIEWER_PERSONA],
            model="test-model",
            budget=BudgetConfig(per_phase=1.0, per_run=10.0),
            phases=PhasesConfig(plan=True, implement=True, review=True, deliver=True),
            recovery=RecoveryConfig(enabled=True, max_phase_retries=2),
            retry=RetryConfig(max_attempts=3),
        )
        save_config(tmp_git_repo, config)

        # Simulate: implement succeeded after internal retries (retry_info shows 2 attempts)
        mock_run.side_effect = [
            _fake_phase_result(Phase.PLAN),
            PhaseResult(
                phase=Phase.IMPLEMENT, success=True, cost_usd=0.05,
                duration_ms=500, session_id="impl",
                artifacts={"result": "done"},
                retry_info=RetryInfo(
                    attempts=2, transient_errors=1,
                    fallback_model_used=None,
                    total_retry_delay_seconds=15.0,
                ),
            ),
            PhaseResult(
                phase=Phase.DECISION, success=True, cost_usd=0.01,
                duration_ms=50, session_id="s",
                artifacts={"result": "VERDICT: GO"},
            ),
            _fake_phase_result(Phase.LEARN),
            _fake_phase_result(Phase.VERIFY),
            _fake_phase_result(Phase.DELIVER),
        ]
        mock_parallel.return_value = [_approve_review_result()]

        log = run("Add tests", repo_root=tmp_git_repo, config=config)

        # Run completed successfully — recovery was never needed
        assert log.status == RunStatus.COMPLETED
        assert len(log.recovery_events) == 0


# ---------------------------------------------------------------------------
# Task 3.0 — Auto-pull at orchestrator entry points
# ---------------------------------------------------------------------------


class TestPreflightCheckPull:
    """_preflight_check() should pull the current branch instead of fetch+warn."""

    def _make_config(self) -> "ColonyConfig":
        return ColonyConfig(
            project=ProjectInfo(name="t", description="t", stack="python"),
        )

    @patch("colonyos.orchestrator.pull_branch", return_value=(True, None))
    @patch("colonyos.orchestrator._get_head_sha", return_value="abc123")
    @patch("colonyos.orchestrator.check_open_pr", return_value=(None, None))
    @patch("colonyos.orchestrator.validate_branch_exists", return_value=(False, ""))
    @patch("colonyos.orchestrator._check_working_tree_clean", return_value=(True, ""))
    @patch("colonyos.orchestrator._get_current_branch", return_value="main")
    def test_pull_success_clears_behind_count(
        self, _br, _clean, _exists, _pr, _sha, mock_pull,
    ) -> None:
        from colonyos.orchestrator import _preflight_check

        result = _preflight_check(Path("/tmp/r"), "feat", self._make_config())
        mock_pull.assert_called_once_with(Path("/tmp/r"))
        assert result.main_behind_count == 0
        assert not result.warnings

    @patch("colonyos.orchestrator.pull_branch", return_value=(False, "network error"))
    @patch("colonyos.orchestrator._get_head_sha", return_value="abc123")
    @patch("colonyos.orchestrator.check_open_pr", return_value=(None, None))
    @patch("colonyos.orchestrator.validate_branch_exists", return_value=(False, ""))
    @patch("colonyos.orchestrator._check_working_tree_clean", return_value=(True, ""))
    @patch("colonyos.orchestrator._get_current_branch", return_value="main")
    def test_pull_failure_adds_warning(
        self, _br, _clean, _exists, _pr, _sha, mock_pull,
    ) -> None:
        from colonyos.orchestrator import _preflight_check

        result = _preflight_check(Path("/tmp/r"), "feat", self._make_config())
        mock_pull.assert_called_once()
        assert any("Failed to pull" in w for w in result.warnings)

    @patch("colonyos.orchestrator.pull_branch")
    @patch("colonyos.orchestrator._get_head_sha", return_value="abc123")
    @patch("colonyos.orchestrator.check_open_pr", return_value=(None, None))
    @patch("colonyos.orchestrator.validate_branch_exists", return_value=(False, ""))
    @patch("colonyos.orchestrator._check_working_tree_clean", return_value=(True, ""))
    @patch("colonyos.orchestrator._get_current_branch", return_value="main")
    def test_offline_skips_pull(
        self, _br, _clean, _exists, _pr, _sha, mock_pull,
    ) -> None:
        from colonyos.orchestrator import _preflight_check

        result = _preflight_check(
            Path("/tmp/r"), "feat", self._make_config(), offline=True,
        )
        mock_pull.assert_not_called()
        assert result.main_behind_count is None

    @patch("colonyos.orchestrator.pull_branch", return_value=(False, None))
    @patch("colonyos.orchestrator._get_head_sha", return_value="abc123")
    @patch("colonyos.orchestrator.check_open_pr", return_value=(None, None))
    @patch("colonyos.orchestrator.validate_branch_exists", return_value=(False, ""))
    @patch("colonyos.orchestrator._check_working_tree_clean", return_value=(True, ""))
    @patch("colonyos.orchestrator._get_current_branch", return_value="main")
    def test_no_upstream_no_warning(
        self, _br, _clean, _exists, _pr, _sha, mock_pull,
    ) -> None:
        """When pull_branch returns (False, None) — no upstream — no warning added."""
        from colonyos.orchestrator import _preflight_check

        result = _preflight_check(Path("/tmp/r"), "feat", self._make_config())
        mock_pull.assert_called_once()
        assert not result.warnings


class TestBaseBranchCheckoutPull:
    """Base-branch checkout in run() should pull after checkout."""

    @staticmethod
    def _init_repo_with_main(tmp_path: Path) -> None:
        """Create a git repo with a 'main' branch."""
        import subprocess as sp
        sp.run(["git", "init", "-b", "main"], cwd=tmp_path, capture_output=True)
        sp.run(
            ["git", "commit", "--allow-empty", "-m", "init"],
            cwd=tmp_path, capture_output=True,
            env={**__import__("os").environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
                 "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"},
        )

    def test_base_branch_pull_fails_raises_preflight_error(self, tmp_path: Path) -> None:
        """When pull fails after base-branch checkout, PreflightError is raised."""
        from colonyos.orchestrator import run as run_orchestrator
        from colonyos.models import PreflightError as PFE

        config = ColonyConfig(
            project=ProjectInfo(name="test", description="test", stack="python"),
        )

        self._init_repo_with_main(tmp_path)

        with patch("colonyos.orchestrator.pull_branch", return_value=(False, "timeout")) as mock_pull:
            with pytest.raises(PFE, match="Failed to pull latest"):
                run_orchestrator(
                    "test prompt",
                    repo_root=tmp_path,
                    config=config,
                    base_branch="main",
                )
            mock_pull.assert_called_once()

    def test_base_branch_pull_skipped_when_offline(self) -> None:
        """Verify that the base-branch checkout code gates pull behind 'not offline'."""
        import inspect
        from colonyos.orchestrator import run as run_fn

        source = inspect.getsource(run_fn)
        # The pull_branch call in the base-branch section must be inside
        # an `if not offline` guard.
        assert "if not offline:" in source
        assert "pull_branch(repo_root)" in source

    def test_base_branch_pull_succeeds_does_not_raise(self) -> None:
        """When pull succeeds (True, None), no PreflightError about pull is raised.

        Verified via source inspection: the raise only fires when
        ``not pull_ok and pull_err is not None``.
        """
        import inspect
        from colonyos.orchestrator import run as run_fn

        source = inspect.getsource(run_fn)
        # pull_branch is called and its result is checked
        assert "pull_ok, pull_err = pull_branch(repo_root)" in source
        # Only raises when pull_ok is False AND pull_err is not None
        assert "if not pull_ok and pull_err is not None:" in source


class TestThreadFixNoPull:
    """Thread-fix checkout must NOT pull — SHA integrity check must remain intact."""

    def test_thread_fix_does_not_call_pull_branch(self) -> None:
        """Verify that run_thread_fix source code does not call pull_branch."""
        import inspect
        from colonyos.orchestrator import run_thread_fix

        source = inspect.getsource(run_thread_fix)
        assert "pull_branch" not in source, (
            "run_thread_fix must NOT call pull_branch — "
            "pulling would break the HEAD SHA integrity check"
        )
