"""Daily cycle: morning briefing + end-of-day report with reflection.

Runs on a 1-minute check loop. Triggers at configured times.
Results saved to journal and vault.

ARCHITECTURE INVARIANT: Every scheduled file type MUST be produced every day.
If data (journal, tasks, memory) is unavailable, the file is still created
with a note explaining what was missing. LLM failures are retried indefinitely
until content is produced. On startup, ALL missing files for today (and past
7 days) are generated. No dependency gating, no silent skips, no giving up.

SCHEDULING MODEL: The loop uses >= hour checks (not ==) so that missed
windows are caught on the next iteration. State (_last_morning, _last_evening,
_last_weekly) is persisted to a JSON file in the vault so restarts within the
same day don't re-generate files. Briefing and EOD run as independent asyncio
tasks so a stuck LLM retry on one doesn't block the other. Failed cascade
tasks are enqueued to the persistent work queue for retry.
"""

from __future__ import annotations

import asyncio
import json
import logging
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from agntspace.agent.orchestrator import Orchestrator
    from agntspace.automation.heartbeat import Heartbeat
    from agntspace.coach.service import CoachService
    from agntspace.journal.reflection import ReflectionService
    from agntspace.journal.service import JournalService
    from agntspace.kb.memory_bridge import MemoryWikiBridge
    from agntspace.llm.base import LLMProvider
    from agntspace.memory.engine import MemoryEngine
    from agntspace.skillschool.service import SkillSchoolService
    from agntspace.tasks.service import TaskService
    from agntspace.trust.service import TrustService
    from agntspace.vault.reader import VaultReader
    from agntspace.vault.search import VaultSearch
    from agntspace.vault.writer import VaultWriter

log = logging.getLogger(__name__)

# Retry delays in seconds — exponential backoff capped at 5 minutes
_RETRY_DELAYS = [10, 30, 60, 120, 300]  # then repeats 300 forever


def sanitize_llm_output(text: str) -> str:
    """Post-process LLM output to ensure clean markdown.

    Small local models sometimes output raw JSON instead of markdown.
    This detects that and converts it to readable markdown.
    Also strips common LLM preamble noise.
    """
    import json as _json

    stripped = text.strip()

    # Detect raw JSON object — convert to markdown
    if stripped.startswith("{") and stripped.endswith("}"):
        try:
            data = _json.loads(stripped)
            return _json_to_markdown(data)
        except _json.JSONDecodeError:
            pass

    # Detect JSON array
    if stripped.startswith("[") and stripped.endswith("]"):
        try:
            data = _json.loads(stripped)
            if isinstance(data, list):
                parts = []
                for item in data:
                    if isinstance(item, dict):
                        parts.append(_json_to_markdown(item))
                    else:
                        parts.append(f"- {item}")
                return "\n\n".join(parts)
        except _json.JSONDecodeError:
            pass

    # Strip tool-call-like noise at the start (model hallucinated a tool call
    # then continued with real content)
    if stripped.startswith('{"') and "\n" in stripped:
        first_newline = stripped.index("\n")
        rest = stripped[first_newline:].strip()
        if rest and not rest.startswith("{"):
            log.warning("Stripped leading JSON noise from LLM output")
            return rest

    return text


def _json_to_markdown(data: dict, depth: int = 2) -> str:
    """Convert a JSON dict to readable markdown with headers and bullets."""
    lines: list[str] = []
    hdr = "#" * depth

    for key, value in data.items():
        # Skip nested date wrappers (e.g. {"2026-04-03": {...}})
        if isinstance(value, dict) and len(data) == 1:
            return _json_to_markdown(value, depth)

        title = key.replace("_", " ").replace("-", " ").title()
        lines.append(f"{hdr} {title}")

        if isinstance(value, str):
            lines.append(value)
        elif isinstance(value, list):
            if not value:
                lines.append("*None.*")
            else:
                for item in value:
                    if isinstance(item, dict):
                        for k, v in item.items():
                            lines.append(f"- **{k}**: {v}")
                    else:
                        lines.append(f"- {item}")
        elif isinstance(value, dict):
            lines.append(_json_to_markdown(value, depth + 1))
        elif value is None:
            lines.append("*None.*")
        else:
            lines.append(str(value))

        lines.append("")  # blank line between sections

    return "\n".join(lines).strip()


def _retry_delay(attempt: int) -> int:
    """Get retry delay for the given attempt number (1-based)."""
    idx = min(attempt - 1, len(_RETRY_DELAYS) - 1)
    return _RETRY_DELAYS[idx]


# Minimum content length for generated files (chars). LLM output shorter
# than this is almost certainly truncated mid-stream or a hallucinated
# stub. The threshold is intentionally low — a real briefing is 500+
# chars, but even a terse "quiet day" summary should be at least 80.
_MIN_CONTENT_LENGTH = 80


def _is_content_complete(text: str) -> bool:
    """Heuristic check that LLM output is not truncated mid-sentence.

    Returns True if the content looks complete (ends with sentence-ending
    punctuation or a markdown structure). Returns False if it looks like
    the connection dropped mid-stream. This is a safety net, not a
    quality gate — it catches the worst truncation cases without
    rejecting valid short outputs.
    """
    if len(text) < _MIN_CONTENT_LENGTH:
        return False
    stripped = text.rstrip()
    if not stripped:
        return False
    # Content that ends with sentence-ending punctuation is likely complete
    if stripped[-1] in ".!?:)]\u2014\u2013\"'`*_":
        return True
    # Content ending with a markdown list item or heading is likely complete
    last_line = stripped.split("\n")[-1].strip()
    if last_line.startswith(("- ", "* ", "## ", "### ", "---")):
        return True
    # Content ending with a fenced block close is complete
    if last_line == "```":
        return True
    # If it has enough content (500+ chars) and multiple paragraphs,
    # it's probably complete even without sentence-ending punctuation
    if len(stripped) >= 500 and stripped.count("\n\n") >= 2:
        return True
    return False


def _get_model_name(agent: object | None, llm: object | None) -> str:
    """Extract the current model name from the agent's LLM provider."""
    try:
        if agent and hasattr(agent, "_llm") and hasattr(agent._llm, "_model"):
            return agent._llm._model
    except Exception:
        pass
    try:
        if llm and hasattr(llm, "_model"):
            return llm._model
    except Exception:
        pass
    return "unknown"


class DailyCycle:
    """Manages morning briefing and end-of-day report generation."""

    def __init__(
        self,
        llm: LLMProvider,
        journal: JournalService,
        task_service: TaskService,
        coach: CoachService,
        trust_service: TrustService,
        reflection_service: ReflectionService,
        vault_reader: VaultReader,
        vault_writer: VaultWriter,
        memory_engine: MemoryEngine | None = None,
        heartbeat: Heartbeat | None = None,
        skillschool: SkillSchoolService | None = None,
        vault_search: VaultSearch | None = None,
        orchestrator: Orchestrator | None = None,
        memory_bridge: MemoryWikiBridge | None = None,
        morning_hour: int = 7,
        evening_hour: int = 21,
        weekly_compaction_day: int = 6,  # 0=Mon, 6=Sun
        skill_loader: object | None = None,
        model_router: object | None = None,
    ) -> None:
        self._llm = llm
        self._journal = journal
        self._tasks = task_service
        self._coach = coach
        self._trust = trust_service
        self._reflection = reflection_service
        self._reader = vault_reader
        self._writer = vault_writer
        self._memory_engine = memory_engine
        self._heartbeat = heartbeat
        self._skillschool = skillschool
        self._vault_search = vault_search
        self._orchestrator = orchestrator
        self._memory_bridge = memory_bridge
        self._skill_loader = skill_loader
        self._model_router = model_router
        self._agent: object | None = None  # injected post-hoc from main.py
        self._contract: object | None = None  # injected post-hoc from main.py
        self._activity: object | None = None  # injected post-hoc from main.py
        self._decisions: object | None = None  # injected post-hoc from main.py
        self._work_queue: object | None = None  # injected post-hoc from main.py
        self._fill_lock = asyncio.Lock()  # prevent concurrent fill workflows
        self._last_editor: str = ""
        self._last_fact_check: str = ""
        self._morning_hour = morning_hour
        self._evening_hour = evening_hour
        self._weekly_day = weekly_compaction_day
        self._task: asyncio.Task | None = None  # type: ignore[type-arg]
        self._running = False
        self._latest_briefing: str = ""
        self._latest_eod: str = ""
        # Active generation tasks — so stuck LLM retries don't block the loop
        self._briefing_task: asyncio.Task | None = None  # type: ignore[type-arg]
        self._eod_task: asyncio.Task | None = None  # type: ignore[type-arg]

        # Load persisted state (survives restarts within the same day)
        state = self._load_cycle_state()
        self._last_morning: str = state.get("last_morning", "")
        self._last_evening: str = state.get("last_evening", "")
        self._last_weekly: str = state.get("last_weekly", "")
        self._last_editor: str = state.get("last_editor", "")
        self._last_fact_check: str = state.get("last_fact_check", "")

    # ── State persistence ──────────────────────────────────────────

    @property
    def _state_path(self) -> Path:
        return Path(self._reader.vault_dir) / ".daily-cycle-state.json"

    def _load_cycle_state(self) -> dict:
        """Load persisted cycle state from disk."""
        try:
            if self._state_path.exists():
                return json.loads(self._state_path.read_text(encoding="utf-8"))
        except Exception:
            log.debug("Could not load daily cycle state, starting fresh")
        return {}

    def _save_cycle_state(self) -> None:
        """Persist cycle state so restarts don't re-generate today's files."""
        try:
            self._state_path.write_text(
                json.dumps({
                    "last_morning": self._last_morning,
                    "last_evening": self._last_evening,
                    "last_weekly": self._last_weekly,
                    "last_editor": self._last_editor,
                    "last_fact_check": self._last_fact_check,
                }),
                encoding="utf-8",
            )
        except Exception:
            log.debug("Could not save daily cycle state", exc_info=True)

    def start(self) -> None:
        if self._running:
            return
        self._running = True
        self._task = asyncio.create_task(self._run())
        log.info(
            "Daily cycle started (morning: %02d:00 UTC, evening: %02d:00 UTC, weekly: day %d)",
            self._morning_hour,
            self._evening_hour,
            self._weekly_day,
        )

    async def _run(self) -> None:
        """Run catch-up first, then enter the regular loop."""
        await self._catch_up()
        await self._loop()

    def stop(self) -> None:
        self._running = False
        if self._task:
            self._task.cancel()
            self._task = None

    @property
    def latest_briefing(self) -> str:
        return self._latest_briefing

    @property
    def latest_eod(self) -> str:
        return self._latest_eod

    def _check_contract(self, action: str) -> bool:
        """Check contract permission. Returns True if allowed, False if blocked."""
        if not self._contract or not hasattr(self._contract, "check"):
            return True  # no contract = allowed
        result = self._contract.check(action)
        if not result.allowed:
            log.warning("Contract blocked '%s': %s", action, result.reason)
            return False
        return True

    # ── LLM call with infinite retry ───────────────────────────────

    async def _call_agent(
        self, task: str, context: str, label: str,
        model_tier: str = "capable", agent_key: str = "main",
    ) -> str:
        """Call the agent, retrying indefinitely until content is produced.

        Never returns None — keeps trying until the LLM responds.
        Logs every failure at WARNING level. Post-processes output to
        ensure clean markdown (catches JSON hallucinations from small models).
        Runs fabrication detection to strip invented proper nouns (Rule #16).

        Args:
            model_tier: LLM tier to use. Defaults to "capable" — briefings,
                        EOD reports, and reflections are user-facing summaries
                        that need quality reasoning, not just extraction.
            agent_key: Agent key for activity tracking in constellation view.
        """
        import time as _time

        from agntspace.automation.fabrication_detector import check_fabrication

        attempt = 0
        started = datetime.now(UTC).isoformat()
        t0 = _time.monotonic()
        while True:
            attempt += 1

            if not self._agent:
                delay = _retry_delay(attempt)
                log.warning("[%s] Agent not available — waiting %ds before retry (attempt %d)", label, delay, attempt)
                await asyncio.sleep(delay)
                continue

            try:
                # Track activity so constellation shows this agent working
                if self._orchestrator:
                    self._orchestrator._active_tasks[agent_key] = label
                result = await self._agent.process_task(task=task, context=context, model_tier=model_tier)
                if result and result.strip():
                    clean = sanitize_llm_output(result)
                    if clean and clean.strip():
                        if not _is_content_complete(clean):
                            log.warning(
                                "[%s] LLM output appears truncated (%d chars, no sentence ending) — retrying (attempt %d)",
                                label, len(clean), attempt,
                            )
                        else:
                            # ── Fabrication detection (Rule #16) ──────
                            # Cross-check LLM output against input context
                            # to catch invented proper nouns. Strip them
                            # and emit activity event if found.
                            try:
                                fab_report = check_fabrication(clean, context, label=label, vault_reader=self._reader)
                                if fab_report.was_modified:
                                    clean = fab_report.clean_output
                                    # Emit activity event so user sees what was stripped
                                    activity = getattr(self, "_activity", None)
                                    if activity:
                                        try:
                                            activity.log(
                                                category="system",
                                                action="fabrication_stripped",
                                                summary=f"Fabricated names removed from {label}: {', '.join(fab_report.fabricated_names)}",
                                                details={
                                                    "label": label,
                                                    "fabricated_names": fab_report.fabricated_names,
                                                    "flagged_lines": fab_report.flagged_lines[:10],
                                                },
                                                importance="high",
                                            )
                                        except Exception:
                                            pass
                            except Exception as fab_exc:
                                # Fabrication detector must never block output
                                log.debug("[%s] Fabrication check failed: %s", label, fab_exc)

                            if attempt > 1:
                                log.warning("[%s] Succeeded on attempt %d", label, attempt)
                            # Log to dispatch timeline
                            if self._orchestrator:
                                duration_ms = int((_time.monotonic() - t0) * 1000)
                                try:
                                    await self._orchestrator.log_background_task(
                                        agent_key=agent_key, task=label,
                                        content=clean[:500], started_at=started,
                                        duration_ms=duration_ms,
                                    )
                                except Exception:
                                    pass
                            return clean
                log.warning("[%s] Agent returned empty response (attempt %d)", label, attempt)
            except Exception as exc:
                log.warning("[%s] LLM call failed (attempt %d): %s", label, attempt, exc)
                if self._heartbeat:
                    try:
                        await self._heartbeat.record_pulse("error", f"{label} attempt {attempt} failed: {exc}")
                    except Exception:
                        pass
            finally:
                # Untrack activity
                if self._orchestrator:
                    self._orchestrator._active_tasks.pop(agent_key, None)

            delay = _retry_delay(attempt)
            log.warning("[%s] Retrying in %ds...", label, delay)
            await asyncio.sleep(delay)

    # ── Context gathering (never raises) ───────────────────────────

    def _gather_briefing_context(self) -> str:
        """Gather all available context for morning briefing. Missing data noted inline."""
        parts: list[str] = []

        # User's focus
        focus_text = "Not set — suggest based on tasks and goals."
        try:
            focus_content = self._journal.get_section("Focus")
            if focus_content:
                focus_text = focus_content
        except Exception as exc:
            log.warning("[Briefing context] Failed to read focus: %s", exc)
        parts.append(f"USER'S FOCUS FOR TODAY:\n{focus_text}")

        # Active tasks
        try:
            tasks = self._tasks.list_tasks()
            active_tasks = [t for t in tasks if t.get("status") not in ("done", "cancelled")]
            task_text = "\n".join(
                f"- {t['title']} [{t['status']}] (mode: {t['mode']}, domain: {t['domain']})"
                for t in active_tasks
            ) or "No active tasks."
        except Exception as exc:
            task_text = f"(Task data unavailable: {exc})"
            log.warning("[Briefing context] Failed to read tasks: %s", exc)
        parts.append(f"ACTIVE TASKS:\n{task_text}")

        # Goals
        try:
            goals = self._coach.list_goals(status_filter="active")
            goals_text = "\n".join(f"- {g['title']} (due: {g.get('target_date', 'no date')})" for g in goals) or "No active goals."
        except Exception as exc:
            goals_text = f"(Goals data unavailable: {exc})"
            log.warning("[Briefing context] Failed to read goals: %s", exc)
        parts.append(f"GOALS:\n{goals_text}")

        # Goal pursuit status (autonomous execution)
        try:
            pursuit_lines: list[str] = []
            for g in goals if "goals" in dir() else self._coach.list_goals(status_filter="active"):
                try:
                    ps = self._coach.get_pursuit_status(g["slug"])
                    if ps.get("budget", 0) > 0:
                        pstatus = ps.get("pursuit_status", "idle")
                        consumed = ps.get("budget_consumed", 0)
                        budget = ps.get("budget", 0)
                        dispatches = ps.get("dispatches", 0)
                        failures = ps.get("attempt_failures", 0)
                        metric = ps.get("success_metric", "")
                        pursuit_lines.append(
                            f"- {g['title']}: {pstatus} | ${consumed:.2f}/${budget:.2f} spent | "
                            f"{dispatches} dispatches, {failures} failures | metric: {metric}"
                        )
                except Exception:
                    pass
            pursuit_text = "\n".join(pursuit_lines) or "No goals with autonomous pursuit enabled."
        except Exception as exc:
            pursuit_text = f"(Pursuit data unavailable: {exc})"
        parts.append(f"GOAL PURSUIT STATUS:\n{pursuit_text}")

        # Coaching nudges
        try:
            nudges = self._coach.generate_nudges()
            nudges_text = "\n".join(f"- {n}" for n in nudges) or "No nudges."
        except Exception as exc:
            nudges_text = f"(Nudges unavailable: {exc})"
            log.warning("[Briefing context] Failed to generate nudges: %s", exc)
        parts.append(f"COACHING NUDGES:\n{nudges_text}")

        # Yesterday's journal
        from datetime import timedelta

        from agntspace.infra.timezone import now_local as _now_local
        yesterday = (_now_local() - timedelta(days=1)).strftime("%Y-%m-%d")
        try:
            yesterday_doc = self._journal.get_entry(yesterday)
            yesterday_text = yesterday_doc.content[:1500] if yesterday_doc else "No journal entry yesterday."
        except Exception as exc:
            yesterday_text = f"(Yesterday's journal unavailable: {exc})"
            log.warning("[Briefing context] Failed to read yesterday's journal: %s", exc)
        parts.append(f"YESTERDAY'S JOURNAL:\n{yesterday_text}")

        # Memory
        memory_text = "(No memory file found.)"
        try:
            memory_path = "memory/MEMORY.md"
            if self._reader.exists(memory_path):
                memory_text = self._reader.read(memory_path).content[:1000]
        except Exception as exc:
            memory_text = f"(Memory unavailable: {exc})"
            log.warning("[Briefing context] Failed to read memory: %s", exc)
        parts.append(f"MEMORY:\n{memory_text}")

        # Relationship context — so briefing can reference user's patterns
        relationship_text = "(No relationship data yet.)"
        try:
            rel_doc = self._reader.read("system/identity/RELATIONSHIP.md")
            if rel_doc and rel_doc.content and len(rel_doc.content.strip()) > 100:
                relationship_text = rel_doc.content[:2000]
        except Exception:
            pass
        parts.append(f"RELATIONSHIP (agent's understanding of user):\n{relationship_text}")

        # Evolution context
        evolution_parts: list[str] = []
        try:
            trust_changes = self._trust.get_recent_changes(since_date=yesterday)
            if trust_changes:
                evolution_parts.append("Trust changes:\n" + "\n".join(trust_changes))
        except Exception:
            pass
        if self._skillschool:
            try:
                proposals = self._skillschool.list_proposals()
                pending = [p for p in proposals if p.get("status") == "pending"]
                if pending:
                    evolution_parts.append(f"{len(pending)} pending skill proposal(s): " + ", ".join(p.get("title", p.get("name", "?")) for p in pending))
            except Exception:
                pass
        try:
            stalled = self._coach.detect_stalls()
            if stalled:
                evolution_parts.append(f"{len(stalled)} stalled goal(s): " + ", ".join(s.get("title", "?") for s in stalled))
        except Exception:
            pass
        evolution_text = "\n".join(evolution_parts) or "No evolution updates."
        parts.append(f"EVOLUTION:\n{evolution_text}")

        # Activity summary from logger (what actually happened today so far)
        activity_text = "(No activity data available.)"
        activity_logger = getattr(self, "_activity", None)
        if activity_logger:
            try:
                from agntspace.infra.timezone import now_local as _now_local2
                yesterday_date = (_now_local2() - timedelta(days=1)).strftime("%Y-%m-%d")
                summary = activity_logger.get_daily_summary(yesterday_date)
                if summary:
                    activity_text = summary[:1500]
                else:
                    activity_text = "No recorded activity yesterday."
            except Exception as exc:
                activity_text = f"(Activity data unavailable: {exc})"
                log.warning("[Briefing context] Failed to read activity: %s", exc)
        parts.append(f"YESTERDAY'S ACTIVITY:\n{activity_text}")

        # Upcoming — placeholder, filled by async caller
        parts.append("UPCOMING SCHEDULES & DEADLINES:\nNone found.")

        return "\n\n".join(parts)

    def _gather_eod_context(self, date: str) -> str:
        """Gather all available context for EOD report. Missing data noted inline."""
        parts: list[str] = []

        # Activity summary from logger — the richest signal about what happened
        activity_text = "(No activity data available.)"
        activity_logger = getattr(self, "_activity", None)
        if activity_logger:
            try:
                summary = activity_logger.get_daily_summary(date)
                if summary:
                    activity_text = summary[:2000]
                else:
                    activity_text = "No recorded activity events for this day."
            except Exception as exc:
                activity_text = f"(Activity data unavailable: {exc})"
                log.warning("[EOD context] Failed to read activity for %s: %s", date, exc)
        parts.append(f"ACTIVITY SUMMARY FOR {date}:\n{activity_text}")

        # Agent observations for the date (timestamped events written throughout the day)
        obs_text = "No agent observations for this day."
        try:
            obs_doc = self._journal.get_agent_observations(date)
            if obs_doc:
                obs_text = obs_doc.content[:2000]
        except Exception as exc:
            obs_text = f"(Observations unavailable: {exc})"
            log.warning("[EOD context] Failed to read observations for %s: %s", date, exc)
        parts.append(f"AGENT OBSERVATIONS FOR {date}:\n{obs_text}")

        # Journal for the date
        try:
            journal_doc = self._journal.get_entry(date)
            journal_text = journal_doc.content[:2000] if journal_doc else "No journal entries for this day."
        except Exception as exc:
            journal_text = f"(Journal data unavailable: {exc})"
            log.warning("[EOD context] Failed to read journal for %s: %s", date, exc)
        parts.append(f"JOURNAL FOR {date}:\n{journal_text}")

        # Tasks
        try:
            tasks = self._tasks.list_tasks()
            task_text = "\n".join(f"- {t['title']} [{t['status']}]" for t in tasks) or "No tasks."
        except Exception as exc:
            task_text = f"(Task data unavailable: {exc})"
            log.warning("[EOD context] Failed to read tasks: %s", exc)
        parts.append(f"ACTIVE TASKS:\n{task_text}")

        # Goals
        try:
            goals = self._coach.list_goals(status_filter="active")
            goals_text = "\n".join(f"- {g['title']}" for g in goals) or "No active goals."
        except Exception as exc:
            goals_text = f"(Goals data unavailable: {exc})"
            log.warning("[EOD context] Failed to read goals: %s", exc)
        parts.append(f"GOALS:\n{goals_text}")

        # Goal pursuit progress (what agents accomplished today)
        try:
            pursuit_lines: list[str] = []
            for g in goals if "goals" in dir() else self._coach.list_goals(status_filter="active"):
                try:
                    ps = self._coach.get_pursuit_status(g["slug"])
                    if ps.get("budget", 0) > 0:
                        pursuit_lines.append(
                            f"- {g['title']}: {ps.get('pursuit_status', 'idle')} | "
                            f"${ps.get('budget_consumed', 0):.2f}/${ps.get('budget', 0):.2f} spent | "
                            f"{ps.get('dispatches', 0)} dispatches, {ps.get('attempt_failures', 0)} failures"
                        )
                except Exception:
                    pass
            if pursuit_lines:
                parts.append("GOAL PURSUIT PROGRESS TODAY:\n" + "\n".join(pursuit_lines))
        except Exception:
            pass

        return "\n\n".join(parts)

    async def _gather_upcoming_text(self) -> str:
        """Search vault for upcoming schedules (async)."""
        if not self._vault_search:
            return "None found."
        try:
            searches = ["scheduled activities", "maintenance due", "deadline upcoming", "recurring"]
            all_results: list[str] = []
            seen_paths: set[str] = set()
            for query in searches:
                results = await self._vault_search.search(query, limit=3)
                for r in results:
                    path = r.get("path", "")
                    if path not in seen_paths:
                        seen_paths.add(path)
                        snippet = r.get("snippet", "")[:300]
                        all_results.append(f"[{path}] {snippet}")
            if all_results:
                return "\n".join(all_results[:8])
        except Exception as exc:
            log.warning("[Briefing context] Vault search failed: %s", exc)
        return "None found."

    # ── File builders (frontmatter with model attribution) ─────────

    def _build_frontmatter(
        self,
        title: str,
        date: str,
        file_type: str,
        agent_name: str,
        description: str,
        extra: dict | None = None,
        model_override: str | None = None,
    ) -> str:
        """Build YAML frontmatter with model attribution and generation timestamp.

        Args:
            model_override: If provided, use this model name instead of querying
                the current LLM provider. Callers should capture the model name
                AFTER the LLM call returns, not before, so the frontmatter
                reflects the model that actually generated the content.
        """
        model = model_override or _get_model_name(self._agent, self._llm)
        from agntspace.infra.timezone import get_tz_name as _tz_name_fm
        from agntspace.infra.timezone import now_local as _now_local_fm
        now = _now_local_fm()
        generated_at = f"{now.strftime('%H:%M')} {_tz_name_fm()}"
        lines = [
            "---",
            f'title: "{title}"',
            f'date: "{date}"',
            f'generated_at: "{generated_at}"',
            f"type: {file_type}",
            "author: agent",
            f"agent: {agent_name}",
            f'model: "{model}"',
            f'description: "{description}"',
        ]
        if extra:
            for k, v in extra.items():
                if isinstance(v, bool):
                    lines.append(f"{k}: {'true' if v else 'false'}")
                elif isinstance(v, str):
                    lines.append(f"{k}: {v}")
                else:
                    lines.append(f"{k}: {v}")
        lines.append("---")
        return "\n".join(lines)

    def _is_fallback_file(self, path: str) -> bool:
        """Check if a file is a fallback placeholder (should be regenerated)."""
        try:
            if self._reader.exists(path):
                doc = self._reader.read(path)
                return doc.metadata.get("generation_failed", False) is True
        except Exception:
            pass
        return False

    def _needs_generation(self, path: str) -> bool:
        """Check if a file needs to be (re)generated."""
        if not self._reader.exists(path):
            return True
        return self._is_fallback_file(path)

    # ── Morning briefing ───────────────────────────────────────────

    async def generate_morning_briefing(self) -> str:
        """Generate the morning briefing via the main agent.

        Retries indefinitely until content is produced. Always writes a file.
        Contract-gated: requires `generate_briefing` action.
        """
        if not self._check_contract("generate_briefing"):
            return ""

        from agntspace.infra.timezone import local_today as _local_today_b
        today = _local_today_b()
        path = f"journal/agnt/morning-briefing_{today}.md"
        label = "Morning briefing"

        # Gather context (never raises)
        dynamic_ctx = self._gather_briefing_context()

        # Async search for upcoming items
        upcoming = await self._gather_upcoming_text()
        dynamic_ctx = dynamic_ctx.replace(
            "UPCOMING SCHEDULES & DEADLINES:\nNone found.",
            f"UPCOMING SCHEDULES & DEADLINES:\n{upcoming}",
        )

        from agntspace.journal.format_loader import build_task_instruction

        _FALLBACK_BRIEFING = (
            "Generate my morning briefing for today using EXACTLY this markdown format:\n\n"
            "## Good Morning\nA brief personalized greeting (1-2 sentences).\n\n"
            "## Today's Focus\nHonor the user's focus if set, otherwise suggest top priorities.\n\n"
            "## Active Tasks\n- Task name — status and brief note\n\n"
            "## Upcoming\nScheduled items and deadlines. Write *Nothing scheduled.* if empty.\n\n"
            "## Coaching Nudges\nAny nudges from the coaching system. Write *None today.* if empty.\n\n"
            "## Yesterday's Takeaway\nOne key thing from yesterday worth carrying forward.\n\n"
            "## Evolution\nTrust changes, skill proposals, stalled goals. Write *No updates.* if empty.\n\n"
            "---\nRULES: Output ONLY valid markdown. Use ## headers exactly as shown. "
            "Use bullet lists (- item) for lists. Keep it under 300 words. "
            "Be warm but efficient. Never output JSON."
        )
        task_instruction = build_task_instruction(
            self._skill_loader, "daily-briefing",
            preamble="Generate my morning briefing for today using EXACTLY this markdown format:",
            fallback=_FALLBACK_BRIEFING,
        )

        # Wrap the briefing generation in a scoped_operation so every
        # LLM call + vault write inside shares one correlation id,
        # produces activity events, and lands a decision record. This
        # is Rule #17's "every mutation visible" applied to daily cycle.
        from agntspace.activity.scoped import scoped_operation

        async with scoped_operation(
            writer=self._writer,
            contract=self._contract,
            decisions=self._decisions,
            activity=self._activity,
            actor="ops-supervisor",
            action_type="daily_briefing",
            action_target=path,
            reason="daily_cycle",
            scope_prefix="daily-briefing",
            rationale=f"Morning briefing for {today}",
            caller="daily_cycle",
            skill_context=["daily-briefing"],
        ) as _scope:
            briefing = await self._call_agent(task_instruction, dynamic_ctx, label, agent_key="ops-supervisor")
            # Capture model name AFTER the LLM call so frontmatter reflects
            # the model that actually generated the content, not the one
            # configured at prompt-build time (which may have hot-swapped).
            actual_model = _get_model_name(self._agent, self._llm)

            fm = self._build_frontmatter(
                f"Morning Briefing — {today}", today, "briefing", "ops-supervisor",
                "Daily morning briefing — today's priorities, active tasks, upcoming deadlines, coaching nudges, and yesterday's takeaway.",
                model_override=actual_model,
            )
            self._writer.write(_scope.target, f"{fm}\n\n{briefing}")
            _scope.extra_details["chars"] = len(briefing)
            self._latest_briefing = briefing
            self._journal.append_action("Morning briefing generated")
            log.warning("[%s] Generated successfully (%d chars)", label, len(briefing))
            if self._heartbeat:
                await self._heartbeat.record_pulse("automation", "Morning briefing generated")
            return briefing

    # ── End-of-day report ──────────────────────────────────────────

    async def generate_eod_report(self) -> str:
        """Generate end-of-day report via the main agent + trigger cascade."""
        from agntspace.infra.timezone import local_today as _local_today_e
        today = _local_today_e()
        return await self._generate_eod_for_date(today, catch_up=False)

    async def _generate_eod_for_date(self, date: str, catch_up: bool = False) -> str:
        """Generate EOD report for a specific date. Contract-gated: `generate_eod`."""
        if not self._check_contract("generate_eod"):
            return ""

        path = f"journal/agnt/eod-report_{date}.md"
        label = f"EOD report ({date})"

        eod_ctx = self._gather_eod_context(date)

        retro = " (retroactive catch-up)" if catch_up else ""

        from agntspace.journal.format_loader import build_task_instruction

        _FALLBACK_EOD = (
            f"Generate an end-of-day report for {date}{retro} using EXACTLY this markdown format:\n\n"
            "## Day Summary\n2-3 sentences on what happened today.\n\n"
            "## Completed\n- Task or achievement\n\n"
            "## Still Active\n- Task that carries over to tomorrow\n\n"
            "## Decisions Made\n- Decision and brief reasoning\n\n"
            "## Learnings\n- Thing worth remembering\n\n"
            "## Memory Updates\n- Add: specific fact to remember\n- Update: correction to existing memory\n\n"
            "---\nRULES: Output ONLY valid markdown. Use ## headers exactly as shown. "
            "Use bullet lists (- item) for lists. Keep it concise and actionable. Never output JSON."
        )
        task_instruction = build_task_instruction(
            self._skill_loader, "eod-report",
            preamble=f"Generate an end-of-day report for {date}{retro} using EXACTLY this markdown format:",
            fallback=_FALLBACK_EOD,
        )

        from agntspace.activity.scoped import scoped_operation

        async with scoped_operation(
            writer=self._writer,
            contract=self._contract,
            decisions=self._decisions,
            activity=self._activity,
            actor="ops-supervisor",
            action_type="daily_eod",
            action_target=path,
            reason="daily_cycle",
            scope_prefix="daily-eod",
            rationale=f"End-of-day report for {date}{' (catch-up)' if catch_up else ''}",
            caller="daily_cycle",
            skill_context=["daily-eod"],
        ) as _scope:
            eod = await self._call_agent(task_instruction, eod_ctx, label, agent_key="ops-supervisor")
            actual_model = _get_model_name(self._agent, self._llm)

            extra = {"catch_up": True} if catch_up else None
            fm = self._build_frontmatter(
                f"EOD Report — {date}", date, "eod_report", "ops-supervisor",
                f"End-of-day summary — completed tasks, active items, decisions made, learnings, and memory updates{' (retroactive catch-up)' if catch_up else ''}.",
                extra=extra,
                model_override=actual_model,
            )
            self._writer.write(_scope.target, f"{fm}\n\n{eod}")
            _scope.extra_details["chars"] = len(eod)
            _scope.extra_details["catch_up"] = catch_up
            self._latest_eod = eod
            self._journal.append_action(f"EOD report generated for {date}")
            log.warning("[%s] Generated successfully (%d chars)", label, len(eod))
            if self._heartbeat:
                await self._heartbeat.record_pulse("automation", f"EOD report generated for {date}")

            # Post-EOD cascade runs in background
            asyncio.create_task(self._post_eod_cascade(date))

            return eod

    async def _post_eod_cascade(self, today: str) -> None:
        """Run reflection, memory generation, and bridges after EOD.

        Each step is independent — failures don't block the next step.
        Failed steps are enqueued to the persistent work queue for retry.
        """
        # Trigger daily reflection (it has its own infinite retry)
        try:
            await self._reflection.run_daily_reflection(today)
            log.warning("Daily reflection triggered for %s", today)
        except Exception as exc:
            log.warning("Daily reflection failed for %s: %s", today, exc)
            await self._enqueue_cascade_retry("daily_reflection", {"date": today})

        # Generate daily memory file
        if self._memory_engine:
            try:
                result = await self._memory_engine.generate_daily_memory(today)
                log.warning("Daily memory generated: %s", result.get("path", "?"))
            except Exception as exc:
                log.warning("Daily memory generation failed for %s: %s", today, exc)
                await self._enqueue_cascade_retry("daily_memory", {"date": today})

        # Memory ↔ Wiki bridge
        if self._memory_bridge:
            try:
                m2w = await self._memory_bridge.memory_to_wiki(today)
                log.warning("Memory->Wiki: %d updates filed", m2w.get("updates_filed", 0))
            except Exception as exc:
                log.warning("Memory->Wiki bridge failed: %s", exc)
                await self._enqueue_cascade_retry("memory_to_wiki", {"date": today})
            try:
                w2m = await self._memory_bridge.wiki_to_memory()
                log.warning("Wiki->Memory: %d pending facts", w2m.get("count", 0))
            except Exception as exc:
                log.warning("Wiki->Memory bridge failed: %s", exc)
                await self._enqueue_cascade_retry("wiki_to_memory", {"date": today})

        # System bridge
        if hasattr(self, "_system_bridge") and self._system_bridge:
            try:
                sb_result = await self._system_bridge.sync_all(today)
                log.warning("SystemBridge: %d triples synced", sb_result.get("total", 0))
            except Exception as exc:
                log.warning("SystemBridge sync failed: %s", exc)
                await self._enqueue_cascade_retry("system_bridge", {"date": today})

        log.warning("Post-EOD cascade complete for %s", today)

    async def _enqueue_cascade_retry(self, job_type: str, payload: dict) -> None:
        """Enqueue a failed cascade step to the persistent work queue."""
        wq = getattr(self, "_work_queue", None)
        if not wq:
            log.debug("No work queue available — cannot retry %s", job_type)
            return
        try:
            await wq.enqueue(job_type, payload, deduplicate=True)
            log.info("Enqueued %s retry to work queue", job_type)
        except Exception as exc:
            log.warning("Failed to enqueue %s retry: %s", job_type, exc)

    async def run_weekly_compaction(self) -> None:
        """Run weekly reflection + memory compaction cascade.

        After weekly reflection, automatically compacts higher memory layers
        on their natural cadence:
        - month:    every Sunday (after weekly compaction)
        - quarter:  1st week of Jan, Apr, Jul, Oct
        - year:     1st week of January
        - longterm: 1st week of January (after year)
        """
        from agntspace.infra.timezone import now_local as _now_local_w
        now = _now_local_w()
        today = now.strftime("%Y-%m-%d")
        try:
            result = await self._reflection.run_weekly_reflection(today)
            log.warning("Weekly reflection completed: %s", result.get("week", "?"))
        except Exception as exc:
            log.warning("Weekly reflection/compaction failed: %s", exc)
            await self._enqueue_cascade_retry("weekly_reflection", {"date": today})

        # Higher-layer compaction cascade (each layer only reads from layer below)
        if not self._memory_engine:
            return

        # Month: compact every week (month layer reads from week + older dailies)
        try:
            await self._memory_engine._compact_layer("month")
            log.warning("Monthly memory layer compacted")
        except Exception as exc:
            log.warning("Monthly compaction failed: %s", exc)
            await self._enqueue_cascade_retry("memory_compact", {"layer": "month"})

        # Quarter: compact in the 1st week of quarter months (Jan, Apr, Jul, Oct)
        if now.month in (1, 4, 7, 10) and now.day <= 7:
            try:
                await self._memory_engine._compact_layer("quarter")
                log.warning("Quarterly memory layer compacted")
            except Exception as exc:
                log.warning("Quarterly compaction failed: %s", exc)
                await self._enqueue_cascade_retry("memory_compact", {"layer": "quarter"})

        # Year + longterm: compact in the 1st week of January
        if now.month == 1 and now.day <= 7:
            try:
                await self._memory_engine._compact_layer("year")
                log.warning("Yearly memory layer compacted")
            except Exception as exc:
                log.warning("Yearly compaction failed: %s", exc)
                await self._enqueue_cascade_retry("memory_compact", {"layer": "year"})

            try:
                await self._memory_engine._compact_layer("longterm")
                log.warning("Long-term memory layer compacted")
            except Exception as exc:
                log.warning("Long-term compaction failed: %s", exc)
                await self._enqueue_cascade_retry("memory_compact", {"layer": "longterm"})

        # Reassemble MEMORY.md from all layers
        try:
            self._memory_engine._assemble_memory_md()
        except Exception:
            log.warning("MEMORY.md assembly failed after compaction")

        # Relationship evolution: synthesize the week's behavioral signals
        # into RELATIONSHIP.md — the agent's deepest understanding of the user.
        await self._evolve_relationship()

    async def _evolve_relationship(self) -> None:
        """Evolve RELATIONSHIP.md from this week's activity signals.

        Reads: weekly activity summaries, feedback signals, corrections,
        current RELATIONSHIP.md. Calls the relationship-evolution skill via
        the agent. Writes back atomically. Contract-gated via
        ``store_user_insights``.
        """
        if not self._check_contract("store_user_insights"):
            log.warning("Relationship evolution blocked by contract (store_user_insights)")
            return

        # Gather context: this week's behavioral signals
        context_parts: list[str] = []

        # 1. Current RELATIONSHIP.md
        try:
            rel_doc = self._reader.read("system/identity/RELATIONSHIP.md")
            if rel_doc and rel_doc.content:
                context_parts.append(f"CURRENT RELATIONSHIP.MD:\n{rel_doc.content}")
        except Exception:
            context_parts.append("CURRENT RELATIONSHIP.MD:\n(empty or unreadable)")

        # 2. Activity summary for the past 7 days
        activity = getattr(self, "_activity", None)
        if activity:
            try:
                from datetime import timedelta

                from agntspace.infra.timezone import now_local as _now_local_rel
                today = _now_local_rel()
                for day_offset in range(7):
                    d = (today - timedelta(days=day_offset)).strftime("%Y-%m-%d")
                    summary = activity.get_daily_summary(d)
                    if summary and summary.strip():
                        context_parts.append(f"ACTIVITY {d}:\n{summary[:1500]}")
            except Exception as exc:
                log.warning("Relationship evolution: activity summary failed: %s", exc)

        # 3. Feedback signals
        if activity:
            try:
                ctx = activity.get_relationship_context()
                if ctx:
                    context_parts.append(f"RELATIONSHIP CONTEXT (live counters):\n{ctx}")
            except Exception:
                pass

        # 4. Recent corrections from observations files
        try:
            from datetime import timedelta

            from agntspace.infra.timezone import now_local as _now_local_corr
            today = _now_local_corr()
            corrections: list[str] = []
            for day_offset in range(7):
                d = (today - timedelta(days=day_offset)).strftime("%Y-%m-%d")
                try:
                    obs = self._reader.read(f"journal/agnt/agent-observations_{d}.md")
                    if obs and obs.content:
                        # Extract corrections section if present
                        for line in obs.content.split("\n"):
                            if "correct" in line.lower() or "reject" in line.lower() or "override" in line.lower():
                                corrections.append(f"[{d}] {line.strip()}")
                except Exception:
                    pass
            if corrections:
                context_parts.append("USER CORRECTIONS THIS WEEK:\n" + "\n".join(corrections[:20]))
        except Exception:
            pass

        # 5. Prediction engine stats (explicit data for Predictions & Accuracy section)
        prediction_engine = getattr(self, "_agent", None)
        if prediction_engine:
            prediction_engine = getattr(prediction_engine, "_prediction_engine", None)
        if prediction_engine:
            try:
                ps = prediction_engine.stats()
                if ps.get("total_resolved", 0) > 0:
                    accuracy_pct = round((ps.get("accuracy") or 0) * 100)
                    pred_context = (
                        f"PREDICTION ACCURACY STATS:\n"
                        f"- Confirmed predictions: {ps.get('confirmed', 0)}\n"
                        f"- Surprising (wrong) predictions: {ps.get('surprising', 0)}\n"
                        f"- Still pending: {ps.get('pending', 0)}\n"
                        f"- Overall accuracy: {accuracy_pct}%\n"
                    )
                    context_parts.append(pred_context)
            except Exception:
                pass

        if len(context_parts) < 2:
            log.info("Relationship evolution: insufficient context, skipping")
            return

        context = "\n\n---\n\n".join(context_parts)

        # Load skill instructions
        task_instruction = (
            "You are updating RELATIONSHIP.md based on this week's activity signals.\n\n"
            "Rules:\n"
            "- Accumulate insights, don't replace existing ones\n"
            "- Date every new insight with [YYYY-MM-DD]\n"
            "- Mark uncertain insights as (tentative)\n"
            "- User corrections override everything — update immediately\n"
            "- Boundaries are sacred — never remove\n"
            "- Write helpful notes, not surveillance data\n"
            "- Keep the file under 150 lines total\n"
            "- Merge tentative entries confirmed 3+ times\n"
            "- Remove unconfirmed tentative entries older than 30 days\n"
            "- Cap Corrections Log at 10, summarize older ones\n"
            "- Cap Predictions at 10 active, merge resolved into accuracy stats\n"
            "- IMPORTANT: Update the 'Predictions & Accuracy' section with the prediction stats provided\n\n"
            "Return the COMPLETE updated RELATIONSHIP.md content (with frontmatter).\n"
            "If there are no meaningful new insights this week, return the file unchanged."
        )
        if self._skill_loader and hasattr(self._skill_loader, "get_system_prompt"):
            try:
                skill_prompt = self._skill_loader.get_system_prompt("relationship-evolution")
                if skill_prompt:
                    task_instruction = skill_prompt + "\n\n" + task_instruction
            except Exception:
                pass

        try:
            result = await self._call_agent(
                task=task_instruction,
                context=context,
                label="relationship-evolution",
                model_tier="capable",
                agent_key="main",
            )
            if result and result.strip():
                # Write to pending file for user approval — never overwrite
                # RELATIONSHIP.md directly from automation.
                pending_path = "memory/pending/relationship-evolution.md"
                from agntspace.activity.decisions import current_correlation_id as _cid_rel
                from agntspace.infra.timezone import local_today as _local_today_rel
                today = _local_today_rel()
                _rel_corr = _cid_rel() or ""
                _rel_corr_line = f"correlation_id: \"{_rel_corr}\"\n" if _rel_corr else ""
                pending_frontmatter = (
                    "---\n"
                    "title: Pending Relationship Evolution\n"
                    "type: relationship-pending\n"
                    "author: agent\n"
                    "agent: main\n"
                    f"date: {today}\n"
                    "status: pending\n"
                    "description: Weekly relationship evolution — awaiting user review\n"
                    f"{_rel_corr_line}"
                    "---\n\n"
                )
                # The result is the proposed full RELATIONSHIP.md content.
                # Wrap it with pending metadata.
                self._writer.write(
                    pending_path,
                    pending_frontmatter + result,
                    trust_reason="relationship_evolution_pending",
                )
                log.warning("Relationship evolution written to pending — awaiting approval")

                # Log activity event (high importance so user sees it)
                if activity:
                    try:
                        activity.log(
                            category="memory",
                            action="relationship_evolution_pending",
                            summary="Weekly relationship evolution ready for review",
                            details={"lines": len(result.split("\n")), "path": pending_path},
                            importance="high",
                        )
                    except Exception:
                        pass
        except Exception as exc:
            log.warning("Relationship evolution failed: %s", exc)

    def status(self) -> dict:
        return {
            "running": self._running,
            "morning_hour": self._morning_hour,
            "evening_hour": self._evening_hour,
            "weekly_compaction_day": self._weekly_day,
            "last_morning": self._last_morning,
            "last_evening": self._last_evening,
            "last_weekly": self._last_weekly,
            "has_briefing": bool(self._latest_briefing),
            "has_eod": bool(self._latest_eod),
            "agent_available": self._agent is not None,
            "model": _get_model_name(self._agent, self._llm),
        }

    # ── Startup catch-up ───────────────────────────────────────────

    async def _catch_up(self) -> None:
        """Generate ALL missing daily cycle files on startup.

        Respects time-of-day for today's files (don't generate
        tonight's EOD in the morning). Past days always filled.
        """
        async with self._fill_lock:
            await self._run_fill_workflow(force=False)

    def _vault_creation_date(self) -> str | None:
        """Return the date the vault was initialized (YYYY-MM-DD), or None.

        Uses SOUL.md creation/modification time as the anchor — it's always
        copied by run_init() on first setup. This prevents the catch-up
        workflow from backfilling journal entries for dates before the vault
        existed (fabricating history for a fresh vault).
        """
        from pathlib import Path

        try:
            vault_dir = Path(self._reader.vault_dir)
            soul = vault_dir / "system" / "identity" / "SOUL.md"
            if soul.exists():
                # Use the earlier of ctime and mtime — on Windows ctime is
                # true creation time; on Unix it's inode change time.
                ts = min(soul.stat().st_ctime, soul.stat().st_mtime)
                return datetime.fromtimestamp(ts, tz=UTC).strftime("%Y-%m-%d")
        except Exception:
            log.debug("Could not determine vault creation date", exc_info=True)
        return None

    async def _run_fill_workflow(self, *, force: bool = False) -> dict:
        """Fill all missing journal files for the last 7 days.

        Args:
            force: When True (manual trigger), ignores time-of-day checks
                   and generates ALL file types for today. When False
                   (startup catch-up), respects schedule hours.

        Never backfills dates before the vault was created — a fresh vault
        should not get fabricated history for days it didn't exist.
        """
        from datetime import timedelta

        from agntspace.infra.timezone import now_local as _now_local_fill

        now = _now_local_fill()
        today = now.strftime("%Y-%m-%d")
        generated: list[str] = []

        # Determine the earliest date we're allowed to backfill.
        # Never generate entries for dates before the vault existed.
        vault_created = self._vault_creation_date()
        if vault_created:
            log.debug("Vault creation date: %s", vault_created)

        log.info(
            "Journal fill workflow starting (force=%s, agent: %s, model: %s)",
            force,
            "available" if self._agent else "NOT AVAILABLE",
            _get_model_name(self._agent, self._llm),
        )

        # Today's morning briefing — always when forced, otherwise respect schedule
        if force or now.hour >= self._morning_hour:
            briefing_path = f"journal/agnt/morning-briefing_{today}.md"
            if self._needs_generation(briefing_path):
                log.info("Fill: generating morning briefing for %s", today)
                await self.generate_morning_briefing()
                generated.append(f"morning briefing ({today})")

        # EOD reports + reflections for today and past 7 days
        for days_ago in range(0, 8):
            date = (now - timedelta(days=days_ago)).strftime("%Y-%m-%d")

            # Never backfill dates before the vault was created.
            if vault_created and date < vault_created:
                log.debug("Fill: skipping %s — before vault creation (%s)", date, vault_created)
                continue

            # Skip today's EOD and reflection on auto catch-up before evening hour.
            # Generating a reflection at 06:00 for a day that hasn't happened yet
            # produces useless "today was quiet" output — defer until EOD cascade.
            if days_ago == 0 and not force and now.hour < self._evening_hour:
                continue

            # EOD report
            eod_path = f"journal/agnt/eod-report_{date}.md"
            if self._needs_generation(eod_path):
                log.info("Fill: generating EOD report for %s", date)
                await self._generate_eod_for_date(date, catch_up=(days_ago > 0))
                generated.append(f"EOD ({date})")

            # Daily reflection — check independently (post-EOD cascade may have failed)
            reflection_path = f"journal/agnt/daily-reflection_{date}.md"
            if self._needs_generation(reflection_path):
                log.info("Fill: generating daily reflection for %s", date)
                try:
                    await self._reflection.run_daily_reflection(date)
                    generated.append(f"reflection ({date})")
                except Exception as exc:
                    log.info("Fill: daily reflection for %s failed: %s", date, exc)

        # Weekly reflection — only if the week's start date is on or after vault creation
        days_since_weekly = (now.weekday() - self._weekly_day) % 7
        if days_since_weekly > 0 or force:
            if days_since_weekly == 0:
                days_since_weekly = 7  # force: check last week
            last_weekly_date = (now - timedelta(days=days_since_weekly)).strftime("%Y-%m-%d")

            # Skip weekly reflection if its anchor date predates the vault
            if vault_created and last_weekly_date < vault_created:
                log.debug("Fill: skipping weekly for %s — before vault creation", last_weekly_date)
            else:
                last_weekly_dt = datetime.strptime(last_weekly_date, "%Y-%m-%d")
                week_num = last_weekly_dt.isocalendar()[1]
                week_label = f"{last_weekly_dt.year}-W{week_num:02d}"
                weekly_path = f"journal/agnt/weekly-reflection_{week_label}.md"

                if self._needs_generation(weekly_path):
                    log.info("Fill: generating weekly reflection for %s", week_label)
                    try:
                        await self._reflection.run_weekly_reflection(last_weekly_date)
                        generated.append(f"weekly reflection ({week_label})")
                    except Exception as exc:
                        log.info("Fill: weekly reflection for %s failed: %s", week_label, exc)

        # Summary
        if generated:
            summary = f"Journal fill workflow completed: {', '.join(generated)}"
        else:
            summary = "Journal fill workflow: all files up to date"
        log.info(summary)
        self._journal.append_observation(summary)

        return {"generated": generated, "summary": summary}

    async def run_fill_workflow(self) -> dict:
        """Public method for manual triggering via API.

        Force-fills all missing journal files — ignores time-of-day checks.
        Waits for any running fill workflow to finish first.
        """
        async with self._fill_lock:
            return await self._run_fill_workflow(force=True)

    # ── Editor pass ────────────────────────────────────────────────

    async def _run_editor_pass(self) -> None:
        """Delegate daily wiki quality pass to the Editor subagent."""
        if not self._orchestrator:
            log.warning("Editor pass skipped: no orchestrator available")
            return

        try:
            result = await self._orchestrator.dispatch(
                agent_name="editor",
                task=(
                    "Daily wiki quality and reflection pass:\n\n"
                    "## Phase 1: Lint\n"
                    "Find broken links, orphans, stale articles, missing frontmatter across all KBs.\n\n"
                    "## Phase 2: Taxonomy\n"
                    "Reclassify misplaced articles. Note clusters needing new categories.\n\n"
                    "## Phase 3: Reflect\n"
                    "Review what changed since yesterday. For any significant structural changes "
                    "(new categories, merges, reframings, articles that became hubs):\n"
                    "- What decision was made?\n"
                    "- What alternatives existed?\n"
                    "- What bias might this introduce?\n"
                    "Create decision records in wiki/decisions/ for non-trivial changes.\n\n"
                    "## Phase 4: Curate\n"
                    "Update featured article. Check index stats.\n\n"
                    "Report findings concisely."
                ),
                context="Scheduled daily editor review with structural reflection.",
            )
            log.warning("Editor pass completed: %s", result.content[:100])
            self._journal.append_observation(f"Daily editor pass completed: {result.content[:200]}")
            if self._heartbeat:
                await self._heartbeat.record_pulse("automation", "Daily editor pass completed")
        except Exception as exc:
            log.warning("Daily editor pass failed: %s", exc)
            if self._heartbeat:
                await self._heartbeat.record_pulse("error", f"Editor pass failed: {exc}")
            from agntspace.infra.timezone import local_today as _local_today_ed
            await self._enqueue_cascade_retry("editor_pass", {"date": _local_today_ed()})

    # ── Fact-checker pass ────────────────────────────────────────────

    async def _run_fact_check_pass(self) -> None:
        """Delegate daily truth audit to the Fact Checker subagent.

        Runs after briefing + editor pass. Reviews all agent-generated
        content from the last 24 hours for fabricated names, ungrounded
        claims, and compaction drift. Read-only — flags issues but
        never modifies files.
        """
        if not self._orchestrator:
            log.warning("Fact-check pass skipped: no orchestrator available")
            return

        # Gather the list of recently generated files for context
        from datetime import timedelta

        from agntspace.infra.timezone import now_local as _now_local_fc
        _fc_now = _now_local_fc()
        today = _fc_now.strftime("%Y-%m-%d")
        yesterday = (_fc_now - timedelta(days=1)).strftime("%Y-%m-%d")

        # Load identity names so the fact-checker knows what's always trusted
        identity_note = ""
        try:
            user_doc = self._reader.read("system/identity/USER.md")
            user_text = user_doc.content if hasattr(user_doc, "content") else str(user_doc)
            import re as _re
            names = []
            for m in _re.finditer(r"\*\*(?:Name|Companies?|Location)\*\*:\s*(.+)", user_text):
                val = m.group(1).strip()
                if val and not val.startswith("["):
                    names.append(val)
            if names:
                identity_note = f"\n\nALWAYS TRUSTED (user identity — never flag): {', '.join(names)}"
        except Exception:
            pass

        try:
            result = await self._orchestrator.dispatch(
                agent_name="fact-checker",
                task=(
                    f"Daily truth audit for {today}.\n\n"
                    "## Phase 1: Automated Cross-Reference\n"
                    f"Review agent-generated files from {yesterday} and {today}:\n"
                    f"- journal/agnt/morning-briefing_{today}.md\n"
                    f"- journal/agnt/eod-report_{yesterday}.md\n"
                    f"- journal/agnt/daily-reflection_{yesterday}.md\n"
                    f"- memory/*/memory_{yesterday}.md\n"
                    f"- memory/summaries/week.md\n\n"
                    "For each file, extract every proper noun (person, project, company, task) "
                    "and verify it exists in the entity registry, active tasks, active projects, "
                    "active goals, or the user's identity.\n\n"
                    "## Phase 2: Semantic Verification\n"
                    "For each stated event or relationship, check: is there evidence in the vault "
                    "(journal entries, conversations, activity events) that this actually happened? "
                    "Flag claims that cannot be traced to any source.\n\n"
                    "## Phase 3: Memory Integrity\n"
                    "Compare memory/summaries/week.md against the daily memory files it was built from. "
                    "Flag any facts in the summary that don't appear in any daily file (compaction drift).\n\n"
                    "Report findings using the format from your fact-checking skill."
                    f"{identity_note}"
                ),
                context=f"Scheduled daily fact-check audit for {today}.",
            )
            report = result.content if hasattr(result, "content") else str(result)
            log.warning("Fact-check pass completed: %s", report[:100])
            self._journal.append_observation(f"Daily fact-check audit completed: {report[:300]}")
            if self._heartbeat:
                await self._heartbeat.record_pulse("automation", "Fact-check pass completed")

            # Emit activity event if issues were found
            if self._activity and "issues found" in report.lower() and "0 issues" not in report.lower():
                try:
                    self._activity.log(
                        category="system",
                        action="fact_check_issues",
                        summary=f"Fact-check audit found issues: {report[:200]}",
                        details={"date": today, "report_preview": report[:500]},
                        importance="high",
                    )
                except Exception:
                    pass
        except Exception as exc:
            log.warning("Fact-check pass failed: %s", exc)
            if self._heartbeat:
                await self._heartbeat.record_pulse("error", f"Fact-check pass failed: {exc}")
            await self._enqueue_cascade_retry("fact_check_pass", {"date": today})

    # ── Main loop ──────────────────────────────────────────────────

    def _is_task_active(self, task: asyncio.Task | None) -> bool:
        """Check if an asyncio task is still running."""
        return task is not None and not task.done()

    async def _loop(self) -> None:
        while self._running:
            try:
                # Schedule checks use LOCAL time — morning_hour/evening_hour
                # are in the user's timezone, not UTC.
                from agntspace.infra.timezone import now_local
                local_now = now_local()
                today = local_now.strftime("%Y-%m-%d")
                hour = local_now.hour

                # Morning briefing — uses >= so missed hours are caught later
                # Runs as independent task so a stuck LLM doesn't block EOD
                if (
                    hour >= self._morning_hour
                    and self._last_morning != today
                    and not self._is_task_active(self._briefing_task)
                ):
                    self._last_morning = today
                    self._save_cycle_state()
                    self._briefing_task = asyncio.create_task(
                        self._safe_generate("briefing", self.generate_morning_briefing)
                    )

                # Daily Editor pass — lint, taxonomy, curation (runs after briefing)
                editor_hour = self._morning_hour + 1  # 1 hour after briefing
                if hour >= editor_hour and self._last_editor != today:
                    self._last_editor = today
                    self._save_cycle_state()
                    asyncio.create_task(
                        self._safe_generate("editor", self._run_editor_pass)
                    )

                # Daily Fact-check pass — truth audit (runs after editor pass)
                fact_check_hour = self._morning_hour + 2  # 2 hours after briefing
                if hour >= fact_check_hour and self._last_fact_check != today:
                    self._last_fact_check = today
                    self._save_cycle_state()
                    asyncio.create_task(
                        self._safe_generate("fact-checker", self._run_fact_check_pass)
                    )

                # End-of-day report — independent task, >= hour check
                if (
                    hour >= self._evening_hour
                    and self._last_evening != today
                    and not self._is_task_active(self._eod_task)
                ):
                    self._last_evening = today
                    self._save_cycle_state()
                    self._eod_task = asyncio.create_task(
                        self._safe_generate("eod", self.generate_eod_report)
                    )

                    # Weekly compaction — runs after EOD on the configured day
                    if local_now.weekday() == self._weekly_day and self._last_weekly != today:
                        self._last_weekly = today
                        self._save_cycle_state()

                        async def _weekly_after_eod() -> None:
                            # Wait for EOD to finish before running weekly
                            if self._eod_task:
                                try:
                                    await asyncio.wait_for(self._eod_task, timeout=600)
                                except (TimeoutError, Exception):
                                    pass
                            await self._safe_generate("weekly", self.run_weekly_compaction)
                            # Weekly skill analysis
                            if self._skillschool:
                                try:
                                    proposals = await self._skillschool.analyze_patterns()
                                    if proposals:
                                        log.warning("Weekly skill analysis: %d proposals generated", len(proposals))
                                        self._journal.append_observation(
                                            f"Skill analysis: {len(proposals)} new skill proposal(s) from task patterns"
                                        )
                                except Exception as exc:
                                    log.warning("Weekly skill analysis failed: %s", exc)

                        asyncio.create_task(_weekly_after_eod())

            except asyncio.CancelledError:
                break
            except Exception as exc:
                log.warning("Daily cycle loop error: %s", exc, exc_info=True)
                if self._heartbeat:
                    try:
                        await self._heartbeat.record_pulse("error", f"Daily cycle: {exc}")
                    except Exception:
                        pass
            await asyncio.sleep(60)  # Check every minute

    async def _safe_generate(self, label: str, coro_fn: object) -> None:
        """Run a generation function, catching errors so the task doesn't crash."""
        try:
            await coro_fn()  # type: ignore[misc]
        except Exception as exc:
            log.warning("Daily cycle %s generation failed: %s", label, exc, exc_info=True)
            if self._heartbeat:
                try:
                    await self._heartbeat.record_pulse("error", f"Daily cycle {label} failed: {exc}")
                except Exception:
                    pass
