orchestrata_ultimate.session.context

Reference › orchestrata_ultimate › session › context

ContextFileManager owns the project's context.md and context.json pair, the durable record of every session (spec 006). It is the only class in the session package that reads and writes those files: the SessionController, the CLI commands (through _manager), the carryover, handover, archive and recovery managers and the hooks all go through it. The data types it reads and writes are in models.

The JSON file is the source of truth and keeps every session; the markdown file is rendered from it with Jinja templates and shows only the last SESSION_LIMIT sessions. If only the markdown exists, read parses it as a migration fallback, and ensure_initialized creates both files when they are missing and a virtual environment is present. write renders both under the write lock and then calls archive_old_sessions, which appends the sessions that fell out of the display window to context.history.md and never touches the JSON.

Several independent processes write these files, so durability is handled in three ways. _transaction takes an exclusive fcntl.flock on a sidecar lock file, re-entrant within a process, so read-modify-write cycles serialize across processes. _atomic_write_text writes a temporary sibling file, flushes and fsyncs it, then replaces the target, so a crash leaves the previous file intact. _recover_json repairs a corrupt context.json on read: it first salvages the leading valid JSON value (and validates it as ContextData), then falls back to the backup copy, and raises ValueError if neither works.

The accessors cover state (read_state, update_state), sessions (add_session, update_session, read_latest_session) and deferred work items (add_work_item, complete_work_item, and index-based complete and reopen). next_session_num takes the highest number from the JSON, the history file and the session-* git tags, so the counter cannot drift. verify_history_integrity checks gaps, duplicates and consistency between JSON and history, and recover_from_git rebuilds lost sessions from commit history, up to max_commits commits.

At a glance

Source src/orchestrata_ultimate/session/context.py
Lines of code 1058
Classes 1
Functions 0
Imports (internal) orchestrata_ultimate.core.base.traces, orchestrata_ultimate.session.models
Imported by orchestrata_ultimate.session, orchestrata_ultimate.session.carryover, orchestrata_ultimate.session.cli._manager, orchestrata_ultimate.session.controller._core, orchestrata_ultimate.session.controller._goals, orchestrata_ultimate.session.facts, orchestrata_ultimate.session.handover
Tests that import it tests/agents/volks/pm/test_goal_registry.py, tests/agents/volks/pm/test_goal_wiring.py, tests/session/cli/test_overview.py, tests/session/controller/test_git.py, tests/session/hooks/test_session_auto_start_hook.py, tests/session/test_carryover_coverage.py, tests/session/test_context_coverage.py, tests/session/test_context_durability.py, tests/session/test_context_extended.py, tests/session/test_event_emitting_session_controller.py, tests/session/test_facts_coverage.py, tests/session/test_goal_evaluate.py, tests/session/test_goal_stop_gate.py, tests/session/test_handover_coverage.py, tests/session/test_session_carryover.py, tests/session/test_session_cli.py, tests/session/test_session_cli_coverage.py, tests/session/test_session_cli_extended.py, tests/session/test_session_cli_subcommands.py, tests/session/test_session_context.py, tests/session/test_session_context_more_coverage.py, tests/session/test_session_controller_lifecycle.py, tests/session/test_session_goal_coverage.py, tests/session/test_session_handover.py, tests/session/test_session_hooks.py … and 6 more

Class diagram

Class diagram orchestrata_ultimate.session.context

Dependency diagram

Dependency diagram orchestrata_ultimate.session.context

API

Classes

ContextFileManager

Line 62

Manages context.md file operations.

Method Signature Summary
__init__ __init__(root: str | Path, env: Mapping[str, str] | None = None) -> None Initialize manager for project.
_transaction _transaction() Hold an exclusive cross-process lock for a read-modify-write.
_atomic_write_text staticmethod _atomic_write_text(path: Path, content: str) -> None Write content to path atomically via temp file + os.replace.
file_exists file_exists() -> bool Check if context files exist (JSON or markdown).
ensure_initialized ensure_initialized() -> ContextData Auto-create context files if .venv exists and files are missing.
initialize initialize(state: ProjectState | None = None) -> ContextData Create new context.md.
read read() -> ContextData Read context data.
_read_json _read_json() -> ContextData Read context from JSON, self-healing past residual corruption.
_recover_json _recover_json(raw: str) -> dict Best-effort recovery of a corrupt context.json payload.
_write_json _write_json(ctx: ContextData) -> None Write context to JSON: validate, back up, then write atomically.
write write(ctx: ContextData) -> None Save context to both JSON and markdown under the write lock.
read_state read_state() -> ProjectState Get current state.
update_state update_state(****kwargs: object) -> ProjectState Update state fields.
read_sessions read_sessions() -> list[SessionRecord] Get all sessions.
read_latest_session read_latest_session() -> SessionRecord | None Get most recent session.
add_session add_session(session: SessionRecord) -> None Add new session.
update_session update_session(seq: int, ****kwargs: object) -> SessionRecord | None Update existing session.
read_work_items read_work_items() -> list[WorkItem] Get all deferred work items.
add_work_item add_work_item(item: WorkItem) -> None Add deferred work item.
reopen_work_item_at reopen_work_item_at(index: int) -> WorkItem | None Return the work item at index to TODO.
complete_work_item_at complete_work_item_at(index: int) -> WorkItem | None Mark the work item at index as done.
complete_work_item complete_work_item(text: str) -> bool Mark work item as done.
archive_old_sessions archive_old_sessions() -> None Write sessions older than the last SESSION_LIMIT to context.history.md.
_get_max_session_from_history _get_max_session_from_history() -> int Get highest session number from history file.
_get_max_session_from_tags _get_max_session_from_tags() -> int Get highest session number from git tags.
next_session_num next_session_num() -> int Get next session number, checking context.md, history, and git tags.
verify_history_integrity verify_history_integrity() -> dict Verify integrity of session history across context.json and context.history.md.
_collect_json_session_nums _collect_json_session_nums(result: dict) -> list[int] Return sorted session seqs from context.json (records errors on failure).
_collect_history_session_nums _collect_history_session_nums() -> list[int] Return sorted session seqs parsed from context.history.md.
_check_gaps staticmethod _check_gaps(history_nums: list[int], json_nums: list[int], result: dict) -> None Flag missing session numbers in the combined range.
_check_duplicates staticmethod _check_duplicates(history_nums: list[int], json_nums: list[int], result: dict) -> None Flag session numbers that appear more than once across both files.
_check_sync _check_sync(result: dict) -> None Verify context.md matches the rendered context.json.
_check_continuity staticmethod _check_continuity(history_nums: list[int], json_nums: list[int], result: dict) -> None Verify history sessions all precede context.json sessions.
recover_from_git recover_from_git(max_commits: int = 200) -> dict Recover lost sessions from git history.
_collect_known_session_nums _collect_known_session_nums(result: dict) -> set[int] Gather session seqs already present in context.json and history.md.
_git_context_commits _git_context_commits(max_commits: int, result: dict) -> list[str] | None Return commit hashes that touched context.json, or None to abort recovery.
_scan_commits_for_sessions _scan_commits_for_sessions(commits: list[str], known_nums: set[int], result: dict) -> list[SessionRecord] Extract sessions missing from the current history out of the given commits.
_append_recovered_sessions _append_recovered_sessions(recovered_sessions: list[SessionRecord]) -> None Render recovered sessions and append them to context.history.md.
_parse_current_state _parse_current_state(content: str, ctx: ContextData) -> None Parse the ## Current State block and populate ctx.state.
_parse_carryover_tasks _parse_carryover_tasks(content: str, ctx: ContextData) -> None Parse the ## Carryover Tasks block and populate ctx.deferred_work.
_parse_session_record _parse_session_record(match: re.Match, content: str) -> SessionRecord Build a SessionRecord from a session header regex match.
_parse_session_records _parse_session_records(content: str, ctx: ContextData) -> None Parse all ### Session NNN header blocks and populate ctx.session_history.
_parse _parse(content: str) -> ContextData Parse context.md content.

Source

Permalink: https://gitlab.com/MSamy/orchestrata-ultimate-architecture/-/blob/a4cbc2bdf/src/orchestrata_ultimate/session/context.py

# mypy: ignore-errors
"""Fresh implementation of context management.

Handles context.md file operations with Jinja2 templates.
Uses JSON for data persistence, markdown for display.

Durability contract
-------------------
``context.json`` is written by several independent OS processes (the
``session_cli.py`` invocations the agent makes, the SessionStart hook that
shells out to that CLI, and the SessionEnd hook that drives this
``SessionController`` in-process — see ``claude/hooks/``). A plain
``Path.write_text`` does ``open('w')`` (truncate) then ``write``, which is
neither atomic nor mutually exclusive: two concurrent writers interleave and
the longer write's tail survives past the shorter one, leaving a file that
decodes as a valid object *followed by* leftover ``}``/``]`` garbage ("Extra
data" JSONDecodeError) — the recurring corruption this module guards against.

Defenses (the lock-file NAME and flock protocol below must stay compatible
with the standalone session-CLI implementation at
``~/.claude/tools/orchestrata/context.py`` — the module ``session_cli.py``
actually imports — so both writers mutually exclude on the same
``.context.lock``. The old ``~/.claude/tools/session/context.py`` copy is a
dead legacy artifact: it imports the retired ``orchestrata.models`` monorepo
package and is never imported by the CLI.):

1. **Atomic writes** — serialize to a sibling temp file, ``fsync``, then
   ``os.replace`` (atomic on POSIX); readers see only whole files.
2. **Cross-process locking** — every read-modify-write holds an exclusive
   ``fcntl.flock`` on a sidecar ``.context.lock``. Both context-manager
   implementations lock the SAME file, so they mutually exclude.
3. **Validated, self-healing reads** — writes round-trip through
   ``json.loads`` before commit, a ``.bak`` of the last good file is kept, and
   a corrupt read salvages the valid JSON prefix or restores the backup.
"""

import fcntl
import json
import logging
import os
import re
import subprocess
from collections.abc import Mapping
from contextlib import contextmanager
from datetime import datetime
from pathlib import Path

from jinja2 import Environment, FileSystemLoader

from orchestrata_ultimate.core.base.traces import traces
from orchestrata_ultimate.session.models import (
    ContextData,
    ProjectState,
    SessionRecord,
    TaskState,
    WorkItem,
)

logger = logging.getLogger(__name__)


class ContextFileManager:
    """Manages context.md file operations.

    Uses JSON for data persistence, markdown for human-readable display.
    """

    CTX_FILE = "context.md"
    JSON_FILE = "context.json"
    HISTORY_FILE = "context.history.md"
    LOCK_FILE = ".context.lock"
    BACKUP_SUFFIX = ".bak"
    LINE_LIMIT = 500
    SESSION_LIMIT = 3

    @traces(spec="006", story="FR-014", reqs=["REQ-006-FR14-001"])
    def __init__(self, root: str | Path, env: Mapping[str, str] | None = None) -> None:
        """Initialize manager for project.

        Args:
            root: Project root directory.
            env: Optional explicit environment mapping passed to the ``git``
                subprocess calls used for tag/history recovery (for testing —
                e.g. simulating a missing ``git`` executable via a stripped
                ``PATH``); when omitted, those subprocesses inherit the real
                process environment exactly as before.
        """
        self.root = Path(root)
        self._env = env
        self.ctx_path = self.root / self.CTX_FILE
        self.json_path = self.root / self.JSON_FILE
        self.history_path = self.root / self.HISTORY_FILE
        self.lock_path = self.root / self.LOCK_FILE
        self.json_backup_path = self.root / (self.JSON_FILE + self.BACKUP_SUFFIX)

        # Re-entrancy state for the cross-process write lock. ``_lock_depth``
        # lets nested ``_transaction()`` blocks (e.g. add_session -> write ->
        # archive_old_sessions) share one held flock instead of self-deadlocking
        # on a second descriptor.
        self._lock_depth = 0
        self._lock_fp = None

        templates_dir = Path(__file__).parent / "templates"
        self.jinja = Environment(
            loader=FileSystemLoader(templates_dir),
            trim_blocks=True,
            lstrip_blocks=True,
        )

    @contextmanager
    def _transaction(self):
        """Hold an exclusive cross-process lock for a read-modify-write.

        Uses ``fcntl.flock`` on a dedicated sidecar lock file (never the data
        file, whose inode is swapped by every atomic write). Advisory but
        honored by every writer of this ``context.json`` — including the
        standalone ``session_cli.py`` copy, which locks the same path — so
        concurrent processes serialize instead of interleaving. Re-entrant
        within a process via ``_lock_depth``.
        """
        outermost = self._lock_depth == 0
        if outermost:
            # 'a' (not 'w') so we never truncate a lock file a peer may hold.
            self._lock_fp = open(self.lock_path, "a")
            fcntl.flock(self._lock_fp.fileno(), fcntl.LOCK_EX)
        self._lock_depth += 1
        try:
            yield
        finally:
            self._lock_depth -= 1
            if self._lock_depth == 0 and self._lock_fp is not None:
                try:
                    fcntl.flock(self._lock_fp.fileno(), fcntl.LOCK_UN)
                finally:
                    self._lock_fp.close()
                    self._lock_fp = None

    @staticmethod
    def _atomic_write_text(path: Path, content: str) -> None:
        """Write ``content`` to ``path`` atomically via temp file + os.replace.

        The temp file is a sibling (same filesystem, so the rename is atomic),
        flushed and ``fsync``ed before the rename. A crash leaves either the
        intact previous file or a leftover ``*.tmp`` — never a torn target.
        """
        tmp = path.with_name(f"{path.name}.tmp.{os.getpid()}")
        try:
            with open(tmp, "w", encoding="utf-8") as f:
                f.write(content)
                f.flush()
                os.fsync(f.fileno())
            os.replace(tmp, path)
        finally:
            try:
                if tmp.exists():
                    tmp.unlink()
            except OSError:
                pass

    @traces(spec="006", story="FR-014", reqs=["REQ-006-FR14-001"])
    def file_exists(self) -> bool:
        """Check if context files exist (JSON or markdown)."""
        return self.json_path.exists() or self.ctx_path.exists()

    @traces(spec="006", story="FR-014", reqs=["REQ-006-FR14-001", "REQ-006-FR14-004"])
    def ensure_initialized(self) -> ContextData:
        """Auto-create context files if ``.venv`` exists and files are missing.

        Detects the project root by the presence of ``.venv`` (or the ``root``
        passed to the constructor if it already contains ``context.json``).
        If context files don't exist, initializes them with a fresh
        ``ProjectState``. Also creates an empty ``context.history.md`` and
        ``CHANGELOG.md`` if they don't exist.

        Returns:
            Context data (new or existing).
        """
        if self.file_exists():
            return self.read()

        # Determine if this looks like a project root
        is_project = (self.root / ".venv").is_dir()
        if not is_project:
            # Maybe we're already at a known project root
            is_project = (self.root / "pyproject.toml").exists()

        if not is_project:
            raise FileNotFoundError(
                f"No context files and no .venv/pyproject.toml at {self.root}. "
                f"Run from a project root or create context files manually."
            )

        # Create required files
        logger.info("Auto-initializing session files at %s", self.root)
        ctx = self.initialize()

        # Ensure history file exists
        if not self.history_path.exists():
            self._atomic_write_text(
                self.history_path,
                "# Session History\n\n"
                "*Sessions are automatically archived here after "
                f"{self.SESSION_LIMIT} active sessions.*\n",
            )

        # Ensure CHANGELOG.md exists
        changelog = self.root / "CHANGELOG.md"
        if not changelog.exists():
            self._atomic_write_text(
                changelog,
                "# Changelog\n\n"
                "All notable changes to this project will be documented in this file.\n\n"
                "## [Unreleased]\n\n",
            )

        return ctx

    @traces(spec="006", story="FR-014", reqs=["REQ-006-FR14-001", "REQ-006-FR14-003"])
    def initialize(self, state: ProjectState | None = None) -> ContextData:
        """Create new context.md.

        Args:
            state: Optional initial state.

        Returns:
            Created context data.
        """
        if state is None:
            state = ProjectState()

        ctx = ContextData(state=state)
        self.write(ctx)
        return ctx

    @traces(spec="006", story="FR-016", reqs=["REQ-006-FR16-003"])
    def read(self) -> ContextData:
        """Read context data.

        Loads from JSON (source of truth) if available,
        falls back to markdown parsing for migration.

        Returns:
            Parsed context data.

        Raises:
            FileNotFoundError: If no context files exist.
        """
        if not self.file_exists():
            raise FileNotFoundError(f"context.md not found at {self.ctx_path}")

        # JSON is the source of truth
        if self.json_path.exists():
            return self._read_json()

        # Fallback: parse markdown (for migration)
        content = self.ctx_path.read_text()
        return self._parse(content)

    @traces(spec="006", story="FR-014", reqs=["REQ-006-FR14-001"])
    def _read_json(self) -> ContextData:
        """Read context from JSON, self-healing past residual corruption.

        Happy path is a plain ``json.loads``. On a JSONDecodeError (historically:
        a valid object plus leftover ``}``/``]`` from an interleaved write) it
        salvages the leading valid object, else restores the ``.bak`` copy, and
        rewrites the file clean so the corruption does not recur.
        """
        raw = self.json_path.read_text()
        try:
            data = json.loads(raw)
        except json.JSONDecodeError:
            data = self._recover_json(raw)
        return ContextData.model_validate(data)

    def _recover_json(self, raw: str) -> dict:
        """Best-effort recovery of a corrupt context.json payload."""
        # 1) Salvage the leading valid JSON value (handles trailing garbage).
        try:
            data, _ = json.JSONDecoder().raw_decode(raw.lstrip())
            if isinstance(data, dict):
                ContextData.model_validate(data)  # must be a real ContextData
                self._atomic_write_text(
                    self.json_path,
                    ContextData.model_validate(data).model_dump_json(indent=2),
                )
                return data
        except json.JSONDecodeError, ValueError:
            pass

        # 2) Fall back to the last-known-good backup.
        if self.json_backup_path.exists():
            backup_raw = self.json_backup_path.read_text()
            data = json.loads(backup_raw)  # propagate if the backup is also bad
            self._atomic_write_text(self.json_path, backup_raw)
            return data

        raise ValueError(
            f"context.json at {self.json_path} is corrupt and unrecoverable "
            "(no salvageable prefix, no usable backup)"
        )

    @traces(spec="006", story="FR-014", reqs=["REQ-006-FR14-001"])
    def _write_json(self, ctx: ContextData) -> None:
        """Write context to JSON: validate, back up, then write atomically."""
        payload = ctx.model_dump_json(indent=2)
        json.loads(payload)  # guard: never persist anything unparseable

        if self.json_path.exists():
            try:
                current = self.json_path.read_text()
                json.loads(current)  # only back up a *valid* current file
                self._atomic_write_text(self.json_backup_path, current)
            except OSError, json.JSONDecodeError:
                pass  # current missing/corrupt -> nothing worth backing up

        self._atomic_write_text(self.json_path, payload)

    @traces(
        spec="006",
        story="FR-002",
        reqs=["REQ-006-FR14-002", "REQ-006-FR14-003", "REQ-006-FR2-004"],
    )
    def write(self, ctx: ContextData) -> None:
        """Save context to both JSON and markdown under the write lock.

        JSON keeps ALL sessions (never trimmed).
        Markdown shows only the last SESSION_LIMIT sessions.
        """
        with self._transaction():
            self._write_json(ctx)  # JSON keeps ALL sessions

            sorted_sessions = sorted(ctx.session_history, key=lambda s: s.seq)
            display_sessions = sorted_sessions[-self.SESSION_LIMIT :]

            tpl = self.jinja.get_template("context.md.j2")
            content = tpl.render(
                current_state=ctx.state,
                current_date=datetime.now().strftime("%Y-%m-%d"),
                carryover_tasks=ctx.deferred_work,
                sessions=display_sessions,
            )
            self._atomic_write_text(self.ctx_path, content)

            self.archive_old_sessions()

    @traces(spec="020", story="US-006", reqs=["REQ-020-173"])
    def read_state(self) -> ProjectState:
        """Get current state."""
        ctx = self.read()
        return ctx.state

    @traces(spec="020", story="US-006", reqs=["REQ-020-173"])
    def update_state(self, **kwargs: object) -> ProjectState:
        """Update state fields.

        Args:
            **kwargs: Fields to update.

        Returns:
            Updated state.
        """
        with self._transaction():
            ctx = self.read()
            for k, v in kwargs.items():
                if hasattr(ctx.state, k):
                    setattr(ctx.state, k, v)
            self.write(ctx)
            return ctx.state

    @traces(spec="020", story="US-006", reqs=["REQ-020-172"])
    def read_sessions(self) -> list[SessionRecord]:
        """Get all sessions."""
        ctx = self.read()
        return ctx.session_history

    @traces(spec="006", story="FR-010", reqs=["REQ-006-FR10-004"])
    def read_latest_session(self) -> SessionRecord | None:
        """Get most recent session."""
        ctx = self.read()
        return ctx.latest_session()

    @traces(spec="020", story="US-006", reqs=["REQ-020-172"])
    def add_session(self, session: SessionRecord) -> None:
        """Add new session.

        Args:
            session: Session to add.
        """
        with self._transaction():
            ctx = self.read()
            ctx.append_session(session)
            self.write(ctx)
            self.archive_old_sessions()

    @traces(spec="020", story="US-006", reqs=["REQ-020-172"])
    def update_session(self, seq: int, **kwargs: object) -> SessionRecord | None:
        """Update existing session.

        Args:
            seq: Session number.
            **kwargs: Fields to update.

        Returns:
            Updated session or None.
        """
        with self._transaction():
            ctx = self.read()
            for session in ctx.session_history:
                if session.seq == seq:
                    for k, v in kwargs.items():
                        if hasattr(session, k):
                            setattr(session, k, v)
                    self.write(ctx)
                    return session
            return None

    @traces(spec="020", story="US-006", reqs=["REQ-020-174"])
    def read_work_items(self) -> list[WorkItem]:
        """Get all deferred work items."""
        ctx = self.read()
        return ctx.deferred_work

    @traces(spec="020", story="US-006", reqs=["REQ-020-174"])
    def add_work_item(self, item: WorkItem) -> None:
        """Add deferred work item.

        Args:
            item: Work item to add.
        """
        with self._transaction():
            ctx = self.read()
            ctx.deferred_work.append(item)
            self.write(ctx)

    @traces(spec="020", story="US-006", reqs=["REQ-020-174"])
    def reopen_work_item_at(self, index: int) -> WorkItem | None:
        """Return the work item at *index* to TODO.

        The undo for :meth:`complete_work_item_at`. ``task done`` takes a bare
        index with no confirmation, and hand-editing context.json is
        forbidden, so without this a mistyped index is an unrecoverable state
        change.

        Args:
            index: Position in ``deferred_work``.

        Returns:
            The reopened item, or None when *index* is out of range.
        """
        with self._transaction():
            ctx = self.read()
            if index < 0 or index >= len(ctx.deferred_work):
                return None
            item = ctx.deferred_work[index]
            item.mark_todo()
            self.write(ctx)
            return item

    @traces(spec="020", story="US-006", reqs=["REQ-020-174"])
    def complete_work_item_at(self, index: int) -> WorkItem | None:
        """Mark the work item at *index* as done.

        The index-based twin of :meth:`complete_work_item`. Both read, mutate
        and write ONE context inside a single transaction — mutating an item
        obtained from a separate read and then writing a freshly-read context
        discards the change silently, which is exactly what the CLI used to do.

        Args:
            index: Position in ``deferred_work``.

        Returns:
            The completed item, or None when *index* is out of range.
        """
        with self._transaction():
            ctx = self.read()
            if index < 0 or index >= len(ctx.deferred_work):
                return None
            item = ctx.deferred_work[index]
            item.mark_done()
            self.write(ctx)
            return item

    @traces(spec="020", story="US-006", reqs=["REQ-020-174"])
    def complete_work_item(self, text: str) -> bool:
        """Mark work item as done.

        Args:
            text: Item text to match.

        Returns:
            True if found and completed.
        """
        with self._transaction():
            ctx = self.read()
            for item in ctx.deferred_work:
                if item.text == text:
                    item.mark_done()
                    self.write(ctx)
                    return True
            return False

    @traces(spec="006", story="FR-014", reqs=["REQ-006-FR14-005", "REQ-006-FR15-001"])
    def archive_old_sessions(self) -> None:
        """Write sessions older than the last SESSION_LIMIT to context.history.md.

        Appends only sessions not already archived. Never modifies context.json.
        """
        with self._transaction():
            ctx = self.read()
            if len(ctx.session_history) <= self.SESSION_LIMIT:
                return

            sorted_sessions = sorted(ctx.session_history, key=lambda s: s.seq)
            older_sessions = sorted_sessions[: -self.SESSION_LIMIT]

            already_archived: set[int] = set()
            if self.history_path.exists():
                already_archived.update(
                    int(match.group(1))
                    for match in re.finditer(
                        r"### Session (\d+)", self.history_path.read_text()
                    )
                )

            new_to_archive = [
                s for s in older_sessions if s.seq not in already_archived
            ]
            if not new_to_archive:
                return

            tpl = self.jinja.get_template("session.md.j2")
            archive_text = "\n\n".join(tpl.render(session=s) for s in new_to_archive)

            if self.history_path.exists():
                existing = self.history_path.read_text()
                self._atomic_write_text(
                    self.history_path, existing + "\n\n" + archive_text
                )
            else:
                self._atomic_write_text(self.history_path, archive_text)

    @traces(spec="006", story="FR-014", reqs=["REQ-006-FR14-007"])
    def _get_max_session_from_history(self) -> int:
        """Get highest session number from history file.

        Returns:
            Max session number found, or -1 if no history.
        """
        if not self.history_path.exists():
            return -1

        content = self.history_path.read_text()
        session_nums = re.findall(r"### Session (\d+)", content)
        if not session_nums:
            return -1

        return max(int(n) for n in session_nums)

    @traces(spec="006", story="FR-014", reqs=["REQ-006-FR14-007"])
    def _get_max_session_from_tags(self) -> int:
        """Get highest session number from git tags.

        Parses tags matching 'session-*' pattern and extracts
        session numbers to prevent counter drift when sessions
        happen outside the hook system.

        Returns:
            Max session number found in tags, or -1 if none.
        """
        try:
            result = subprocess.run(
                ["git", "tag", "-l", "session-*"],
                cwd=self.root,
                capture_output=True,
                text=True,
                timeout=10,
                env=self._env,
            )
            if result.returncode != 0 or not result.stdout.strip():
                return -1

            max_num = -1
            for tag in result.stdout.strip().splitlines():
                match = re.match(r"session-(\d+)", tag.strip())
                if match:
                    num = int(match.group(1))
                    if num > max_num:
                        max_num = num
            return max_num
        except subprocess.TimeoutExpired, FileNotFoundError, OSError:
            return -1

    @traces(spec="006", story="FR-014", reqs=["REQ-006-FR14-007"])
    def next_session_num(self) -> int:
        """Get next session number, checking context.md, history, and git tags.

        Considers all three sources to prevent counter drift:
        - context.json session_history
        - context.history.md archived sessions
        - git tags matching session-*

        Returns:
            Next session number.
        """
        max_from_history = self._get_max_session_from_history()
        max_from_tags = self._get_max_session_from_tags()

        max_from_context = -1
        if self.file_exists():
            ctx = self.read()
            if ctx.session_history:
                max_from_context = max(s.seq for s in ctx.session_history)

        return max(max_from_history, max_from_context, max_from_tags) + 1

    @traces(
        spec="006",
        story="FR-014",
        reqs=[
            "REQ-006-FR14-007",
            "REQ-006-FR16-001",
            "REQ-006-FR16-002",
            "REQ-006-FR16-003",
            "REQ-006-FR16-004",
            "REQ-006-FR18-002",
            "REQ-006-NFR9-002",
        ],
    )
    def verify_history_integrity(self) -> dict:
        """Verify integrity of session history across context.json and context.history.md.

        Checks:
        - Gap detection in session numbering
        - Duplicate detection across files
        - Sync validation (context.md matches context.json render)
        - History continuity (max history < min context)

        Returns:
            Dict with keys: valid, gaps, duplicates, sync_ok, continuity_ok, errors.
        """
        result = {
            "valid": True,
            "gaps": [],
            "duplicates": [],
            "sync_ok": True,
            "continuity_ok": True,
            "errors": [],
        }

        json_nums = self._collect_json_session_nums(result)
        history_nums = self._collect_history_session_nums()

        self._check_gaps(history_nums, json_nums, result)
        self._check_duplicates(history_nums, json_nums, result)
        self._check_sync(result)
        self._check_continuity(history_nums, json_nums, result)

        return result

    @traces(spec="006", story="FR-014", reqs=["REQ-006-FR14-007"])
    def _collect_json_session_nums(self, result: dict) -> list[int]:
        """Return sorted session seqs from context.json (records errors on failure)."""
        if not self.json_path.exists():
            return []
        try:
            ctx = self._read_json()
            return sorted(s.seq for s in ctx.session_history)
        except Exception as e:
            result["errors"].append(f"Failed to read context.json: {e}")
            result["valid"] = False
            return []

    @traces(spec="006", story="FR-014", reqs=["REQ-006-FR14-007"])
    def _collect_history_session_nums(self) -> list[int]:
        """Return sorted session seqs parsed from context.history.md."""
        if not self.history_path.exists():
            return []
        content = self.history_path.read_text()
        matches = re.findall(r"### Session (\d+)", content)
        return sorted(int(m) for m in matches)

    @staticmethod
    @traces(spec="006", story="FR-016", reqs=["REQ-006-FR16-001"])
    def _check_gaps(
        history_nums: list[int], json_nums: list[int], result: dict
    ) -> None:
        """Flag missing session numbers in the combined range."""
        all_nums = sorted(set(history_nums + json_nums))
        if not all_nums:
            return
        expected = range(min(all_nums), max(all_nums) + 1)
        gaps = [n for n in expected if n not in all_nums]
        if gaps:
            result["gaps"] = gaps
            result["valid"] = False

    @staticmethod
    @traces(spec="006", story="FR-016", reqs=["REQ-006-FR16-002"])
    def _check_duplicates(
        history_nums: list[int], json_nums: list[int], result: dict
    ) -> None:
        """Flag session numbers that appear more than once across both files."""
        seen: set[int] = set()
        dupes: list[int] = []
        for n in history_nums + json_nums:
            if n in seen:
                dupes.append(n)
            seen.add(n)
        if dupes:
            result["duplicates"] = sorted(set(dupes))
            result["valid"] = False

    @traces(spec="006", story="FR-016", reqs=["REQ-006-FR16-003"])
    def _check_sync(self, result: dict) -> None:
        """Verify context.md matches the rendered context.json."""
        if not (self.json_path.exists() and self.ctx_path.exists()):
            return
        try:
            ctx = self._read_json()
            tpl = self.jinja.get_template("context.md.j2")
            from datetime import datetime as dt

            sorted_sessions = sorted(ctx.session_history, key=lambda s: s.seq)
            display_sessions = sorted_sessions[-self.SESSION_LIMIT :]
            rendered = tpl.render(
                current_state=ctx.state,
                current_date=dt.now().strftime("%Y-%m-%d"),
                carryover_tasks=ctx.deferred_work,
                sessions=display_sessions,
            )
            actual = self.ctx_path.read_text()
            if rendered.strip() != actual.strip():
                result["sync_ok"] = False
                result["valid"] = False
        except Exception as e:
            result["errors"].append(f"Sync check failed: {e}")
            result["sync_ok"] = False
            result["valid"] = False

    @staticmethod
    @traces(spec="006", story="FR-016", reqs=["REQ-006-FR16-004"])
    def _check_continuity(
        history_nums: list[int], json_nums: list[int], result: dict
    ) -> None:
        """Verify history sessions all precede context.json sessions."""
        if history_nums and json_nums and max(history_nums) >= min(json_nums):
            result["continuity_ok"] = False
            result["valid"] = False

    @traces(
        spec="006",
        story="FR-017",
        reqs=[
            "REQ-006-FR17-001",
            "REQ-006-FR17-002",
            "REQ-006-FR17-003",
            "REQ-006-FR17-004",
            "REQ-006-FR18-003",
            "REQ-006-NFR9-003",
        ],
    )
    def recover_from_git(self, max_commits: int = 200) -> dict:
        """Recover lost sessions from git history.

        Scans git log for historical context.json commits, extracts session
        data, and appends any missing sessions to context.history.md with
        a [RECOVERED] annotation.

        Non-destructive: never overwrites existing session data.

        Args:
            max_commits: Maximum number of commits to scan.

        Returns:
            Dict with keys: recovered, already_present, commits_scanned, errors.
        """
        result = {
            "recovered": [],
            "already_present": [],
            "commits_scanned": 0,
            "errors": [],
        }

        known_nums = self._collect_known_session_nums(result)

        commits = self._git_context_commits(max_commits, result)
        if commits is None:
            return result
        result["commits_scanned"] = len(commits)

        recovered_sessions = self._scan_commits_for_sessions(
            commits, known_nums, result
        )
        self._append_recovered_sessions(recovered_sessions)

        result["already_present"] = sorted(set(result["already_present"]))
        result["recovered"] = sorted(set(result["recovered"]))
        return result

    @traces(spec="006", story="FR-017", reqs=["REQ-006-FR17-004"])
    def _collect_known_session_nums(self, result: dict) -> set[int]:
        """Gather session seqs already present in context.json and history.md."""
        known_nums: set[int] = set()
        if self.json_path.exists():
            try:
                ctx = self._read_json()
                known_nums.update(s.seq for s in ctx.session_history)
            except Exception as e:
                result["errors"].append(f"Failed to read context.json: {e}")

        if self.history_path.exists():
            content = self.history_path.read_text()
            matches = re.findall(r"### Session (\d+)", content)
            known_nums.update(int(m) for m in matches)
        return known_nums

    @traces(spec="006", story="FR-017", reqs=["REQ-006-FR17-001", "REQ-006-NFR9-003"])
    def _git_context_commits(self, max_commits: int, result: dict) -> list[str] | None:
        """Return commit hashes that touched context.json, or None to abort recovery."""
        try:
            log_result = subprocess.run(
                [
                    "git",
                    "log",
                    f"-n{max_commits}",
                    "--format=%H",
                    "--",
                    "context.json",
                ],
                cwd=self.root,
                capture_output=True,
                text=True,
                timeout=30,
                env=self._env,
            )
        except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
            result["errors"].append(f"Git log failed: {e}")
            return None

        if log_result.returncode != 0 or not log_result.stdout.strip():
            return None
        return log_result.stdout.strip().splitlines()

    @traces(spec="006", story="FR-017", reqs=["REQ-006-FR17-002"])
    def _scan_commits_for_sessions(
        self, commits: list[str], known_nums: set[int], result: dict
    ) -> list[SessionRecord]:
        """Extract sessions missing from the current history out of the given commits."""
        recovered_sessions: list[SessionRecord] = []
        for commit_hash in commits:
            try:
                show_result = subprocess.run(
                    ["git", "show", f"{commit_hash}:context.json"],
                    cwd=self.root,
                    capture_output=True,
                    text=True,
                    timeout=10,
                    env=self._env,
                )
                if show_result.returncode != 0:
                    continue

                data = json.loads(show_result.stdout)
                ctx_data = ContextData.model_validate(data)

                for session in ctx_data.session_history:
                    if session.seq in known_nums:
                        if session.seq not in [s.seq for s in recovered_sessions]:
                            result["already_present"].append(session.seq)
                        continue
                    known_nums.add(session.seq)
                    recovered_sessions.append(session)
                    result["recovered"].append(session.seq)

            except (json.JSONDecodeError, Exception) as e:
                result["errors"].append(
                    f"Failed to parse commit {commit_hash[:8]}: {e}"
                )
                continue
        return recovered_sessions

    @traces(spec="006", story="FR-017", reqs=["REQ-006-FR17-003"])
    def _append_recovered_sessions(
        self, recovered_sessions: list[SessionRecord]
    ) -> None:
        """Render recovered sessions and append them to context.history.md."""
        if not recovered_sessions:
            return
        recovered_sessions.sort(key=lambda s: s.seq)
        tpl = self.jinja.get_template("session.md.j2")
        new_content = ""
        for session in recovered_sessions:
            header_annotation = "\n> [RECOVERED] from git history\n\n"
            new_content += tpl.render(session=session) + header_annotation

        if self.history_path.exists():
            existing = self.history_path.read_text()
            self._atomic_write_text(self.history_path, existing + "\n" + new_content)
        else:
            header = "# Session History Archive\n\n"
            self._atomic_write_text(self.history_path, header + new_content)

    @traces(spec="006", story="FR-014", reqs=["REQ-006-FR14-002"])
    def _parse_current_state(self, content: str, ctx: ContextData) -> None:
        """Parse the ## Current State block and populate ctx.state.

        Args:
            content: Raw markdown content.
            ctx: ContextData instance to mutate.
        """
        state_match = re.search(
            r"## Current State\n(.*?)(?=\n## |\Z)", content, re.DOTALL
        )
        if not state_match:
            return

        state_text = state_match.group(1)

        session_match = re.search(r"Session (\d+)", state_text)
        if session_match:
            ctx.state.active_session = int(session_match.group(1))

        version_match = re.search(r"Current Version: (.+)", state_text)
        if version_match:
            ctx.state.version = version_match.group(1).strip()

        branch_match = re.search(r"Branch: (.+)", state_text)
        if branch_match:
            ctx.state.git_branch = branch_match.group(1).strip()

        test_match = re.search(r"Test Status: (.+)", state_text)
        if test_match:
            ctx.state.test_state = test_match.group(1).strip()

        status_match = re.search(r"Status: (.+)", state_text)
        if status_match:
            ctx.state.status_line = status_match.group(1).strip()

    @traces(spec="020", story="US-006", reqs=["REQ-020-174"])
    def _parse_carryover_tasks(self, content: str, ctx: ContextData) -> None:
        """Parse the ## Carryover Tasks block and populate ctx.deferred_work.

        Args:
            content: Raw markdown content.
            ctx: ContextData instance to mutate.
        """
        carryover_match = re.search(
            r"## Carryover Tasks.*?\n(.*?)(?=\n## |\Z)", content, re.DOTALL
        )
        if not carryover_match:
            return

        tasks_text = carryover_match.group(1)
        for line in tasks_text.strip().splitlines():
            line = line.strip()
            if not line.startswith("- ") or "{none" in line.lower():
                continue

            task_desc = line[2:].strip()

            # Parse checkbox: [x] or [ ]
            state = TaskState.DEFERRED
            checkbox_match = re.match(r"\[([ x])\]\s*(.+)", task_desc)
            if checkbox_match:
                if checkbox_match.group(1) == "x":
                    state = TaskState.DONE
                task_desc = checkbox_match.group(2)

            # Parse priority: [P0], [P1], etc.
            priority = None
            priority_match = re.match(r"\[([^\]]+)\]\s*(.+)", task_desc)
            if priority_match:
                priority = priority_match.group(1)
                task_desc = priority_match.group(2)

            # Parse notes: (notes text)
            notes = None
            notes_match = re.match(r"(.+?)\s*\(([^)]+)\)\s*$", task_desc)
            if notes_match:
                task_desc = notes_match.group(1)
                notes = notes_match.group(2)

            ctx.deferred_work.append(
                WorkItem(text=task_desc, priority=priority, state=state, notes=notes)
            )

    @traces(spec="020", story="US-006", reqs=["REQ-020-172"])
    def _parse_session_record(self, match: re.Match, content: str) -> SessionRecord:
        """Build a SessionRecord from a session header regex match.

        Args:
            match: Regex match object for a ``### Session NNN`` header line.
            content: Full raw markdown content (used for sub-searches).

        Returns:
            Populated SessionRecord.
        """
        seq = int(match.group(1))
        seq_str = match.group(1)  # Preserve the original string (e.g., "001")
        date_str = match.group(2)
        model = match.group(3).strip()

        try:
            when = datetime.strptime(date_str, "%Y-%m-%d %H:%M")
        except ValueError:
            when = datetime.now()

        # Use the original seq_str to match the zero-padded format from template
        focus_match = re.search(
            rf"### Session {seq_str}.*?Focus: (.+?)(?:\n|$)", content, re.DOTALL
        )
        purpose = focus_match.group(1).strip() if focus_match else "Unknown"

        # Parse Next Steps section for upcoming items
        upcoming: list[str] = []
        next_steps_match = re.search(
            rf"### Session {seq_str}.*?#### Next Steps\n(.*?)(?=\n####|\n### Session|\Z)",
            content,
            re.DOTALL,
        )
        if next_steps_match:
            steps_text = next_steps_match.group(1)
            for line in steps_text.strip().splitlines():
                line = line.strip()
                if line.startswith("- "):
                    upcoming.append(line[2:].strip())

        return SessionRecord(
            seq=seq, when=when, ai_model=model, purpose=purpose, upcoming=upcoming
        )

    @traces(spec="020", story="US-006", reqs=["REQ-020-172"])
    def _parse_session_records(self, content: str, ctx: ContextData) -> None:
        """Parse all ### Session NNN header blocks and populate ctx.session_history.

        Args:
            content: Raw markdown content.
            ctx: ContextData instance to mutate.
        """
        session_pattern = r"### Session (\d+) - ([\d-]+ [\d:]+) - (.+?)\n"
        for match in re.finditer(session_pattern, content):
            ctx.session_history.append(self._parse_session_record(match, content))

    @traces(spec="006", story="FR-014", reqs=["REQ-006-FR14-002"])
    def _parse(self, content: str) -> ContextData:
        """Parse context.md content.

        Args:
            content: Raw markdown content.

        Returns:
            Parsed context data.
        """
        ctx = ContextData()
        self._parse_current_state(content, ctx)
        self._parse_carryover_tasks(content, ctx)
        self._parse_session_records(content, ctx)
        return ctx