# Phase 2.5: Surgical Patches Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Resolve 4 Phase 2 deferred items via minimal, targeted patches.

**Architecture:** Each patch modifies 1-2 source files + tests. No Protocol changes. os.py gets a 3-line addition. All other changes are in implementations.

**Tech Stack:** Python 3.11, pytest, threading, pathlib, json

**Baseline:** 146 tests passing (0.61s). All tests must continue to pass after each task.

**Design doc:** `docs/plans/2026-02-27-phase2.5-surgical-patches-design.md`

---

## Task 1: BufferedEmitter Thread Safety -- Failing Tests

**Files:**
- Modify: `tests/test_buffered_emitter.py:1-80`
- Read (reference): `src/veronica/buffered_emitter.py`

**Step 1: Write 5 new thread-safety tests**

Add a new test class `TestBufferedEmitterThreadSafety` at the end of the file:

```python
import threading
import time


class TestBufferedEmitterThreadSafety:
    def test_concurrent_emit(self) -> None:
        """10 threads emit simultaneously -- no data corruption."""
        emitter = BufferedEmitter()
        barrier = threading.Barrier(10)

        def worker(idx: int) -> None:
            barrier.wait()
            emitter.emit("e", {"idx": idx})

        threads = [threading.Thread(target=worker, args=(i,)) for i in range(10)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        events = emitter.snapshot()
        assert len(events) == 10
        indices = sorted(e[1]["idx"] for e in events)
        assert indices == list(range(10))

    def test_reentrant_emit_from_subscriber(self) -> None:
        """Subscriber calling emit() inside callback -- no deadlock."""
        emitter = BufferedEmitter()
        inner_called = threading.Event()

        def reentrant_callback(et: str, p: dict) -> None:
            if et == "outer":
                emitter.emit("inner", {"from": "callback"})
                inner_called.set()

        emitter.subscribe("reentrant", reentrant_callback)
        emitter.emit("outer", {})

        assert inner_called.is_set()
        events = emitter.snapshot()
        types = [e[0] for e in events]
        assert "outer" in types
        assert "inner" in types

    def test_slow_subscriber_does_not_block_others(self) -> None:
        """Slow subscriber doesn't prevent other subscribers from being called."""
        emitter = BufferedEmitter()
        fast_received = threading.Event()

        def slow_callback(et: str, p: dict) -> None:
            time.sleep(0.1)

        def fast_callback(et: str, p: dict) -> None:
            fast_received.set()

        emitter.subscribe("slow", slow_callback)
        emitter.subscribe("fast", fast_callback)
        emitter.emit("test", {})

        assert fast_received.is_set()

    def test_subscribe_during_emit(self) -> None:
        """subscribe/unsubscribe from another thread during emit -- no crash."""
        emitter = BufferedEmitter()
        results: list[bool] = []

        def slow_callback(et: str, p: dict) -> None:
            time.sleep(0.05)

        emitter.subscribe("slow", slow_callback)

        def subscribe_thread() -> None:
            time.sleep(0.01)  # mid-emit
            emitter.subscribe("late", lambda et, p: None)
            results.append(True)

        t = threading.Thread(target=subscribe_thread)
        t.start()
        emitter.emit("test", {})
        t.join()

        assert results == [True]

    def test_fatal_exception_propagates(self) -> None:
        """SystemExit/KeyboardInterrupt from subscriber propagates."""
        emitter = BufferedEmitter()
        emitter.subscribe("fatal", lambda et, p: (_ for _ in ()).throw(KeyboardInterrupt))
        with pytest.raises(KeyboardInterrupt):
            emitter.emit("test", {})
```

**Step 2: Run tests to verify they fail**

Run: `cd D:/work/Projects/veronica && uv run pytest tests/test_buffered_emitter.py::TestBufferedEmitterThreadSafety -v`

Expected: FAIL -- `test_concurrent_emit` fails with `AssertionError` (main-thread assert),
`test_fatal_exception_propagates` fails (bare `except Exception` swallows it), etc.

---

## Task 2: BufferedEmitter Thread Safety -- Implementation

**Files:**
- Modify: `src/veronica/buffered_emitter.py:1-71`

**Step 1: Implement Lock-based thread safety**

Replace the entire `BufferedEmitter` class body with:

```python
class BufferedEmitter:
    """Phase 2 event emitter with ring buffer and subscriber management.

    Thread-safe. Lock protects internal state only; subscriber callbacks
    execute outside the lock. Subscribers are assumed to be few (< 50).

    Re-entrancy: Subscribers may call emit() from within their callback
    without deadlock. Infinite recursion is the caller's responsibility.
    """

    def __init__(self, maxlen: int = 1024) -> None:
        self._buffer: deque[tuple[str, Mapping[str, Any]]] = deque(maxlen=maxlen)
        self._subscribers: dict[str, Callable[[str, Mapping[str, Any]], None]] = {}
        self._fail_counts: dict[str, int] = {}
        self._lock = threading.Lock()

    def emit(self, event_type: str, payload: Mapping[str, Any]) -> None:
        with self._lock:
            self._buffer.append((event_type, payload))
            targets = list(self._subscribers.items())

        local_resets: list[str] = []
        local_removals: list[str] = []

        for name, callback in targets:
            try:
                callback(event_type, payload)
                local_resets.append(name)
            except (SystemExit, KeyboardInterrupt):
                raise
            except Exception:
                with self._lock:
                    count = self._fail_counts.get(name, 0) + 1
                    self._fail_counts[name] = count
                    if count >= _MAX_CONSECUTIVE_FAILURES:
                        local_removals.append(name)

        if local_resets or local_removals:
            with self._lock:
                for name in local_resets:
                    if name in self._fail_counts:
                        self._fail_counts[name] = 0
                for name in local_removals:
                    self._subscribers.pop(name, None)
                    self._fail_counts.pop(name, None)
                    logger.warning(
                        "Auto-unsubscribed '%s' after %d consecutive failures",
                        name,
                        _MAX_CONSECUTIVE_FAILURES,
                    )

    def subscribe(
        self,
        name: str,
        callback: Callable[[str, Mapping[str, Any]], None],
    ) -> None:
        with self._lock:
            self._subscribers[name] = callback
            self._fail_counts[name] = 0

    def unsubscribe(self, name: str) -> None:
        with self._lock:
            self._subscribers.pop(name, None)
            self._fail_counts.pop(name, None)

    def drain(self, n: int) -> list[tuple[str, Mapping[str, Any]]]:
        with self._lock:
            result: list[tuple[str, Mapping[str, Any]]] = []
            for _ in range(min(n, len(self._buffer))):
                result.append(self._buffer.popleft())
        return result

    def snapshot(self) -> list[tuple[str, Mapping[str, Any]]]:
        with self._lock:
            return list(self._buffer)
```

Key changes from Phase 2:
- Remove `assert threading.current_thread() is threading.main_thread()`
- Add `self._lock = threading.Lock()`
- `emit()`: lock for buffer+snapshot, unlock for callbacks, lock for batch cleanup
- `except (SystemExit, KeyboardInterrupt): raise` before generic `except Exception`
- All other methods: wrap internal state access with `with self._lock:`

**Step 2: Run all tests**

Run: `cd D:/work/Projects/veronica && uv run pytest tests/test_buffered_emitter.py -v`

Expected: ALL 13 tests PASS (8 existing + 5 new).

**Step 3: Run full suite**

Run: `cd D:/work/Projects/veronica && uv run pytest tests/ -v`

Expected: 151 passed (146 + 5 new).

**Step 4: Commit**

```bash
cd D:/work/Projects/veronica
git add src/veronica/buffered_emitter.py tests/test_buffered_emitter.py
git commit -m "feat: thread-safe BufferedEmitter with fine-grained Lock

Replace main-thread assert with threading.Lock. Lock protects internal
state only; subscriber callbacks execute outside the lock.

- SystemExit/KeyboardInterrupt always re-raised (Rule A)
- Subscriber count assumed < 50 (Rule B)
- 5 new thread-safety tests"
```

---

## Task 3: budget_headroom_ratio -- Failing Tests

**Files:**
- Modify: `tests/test_file_store.py:1-198`
- Read (reference): `src/veronica/file_store.py`, `src/veronica/os.py`

**Step 1: Write 5 new headroom tests**

Add to `TestFileStore` class at the end:

```python
    def test_headroom_basic_injection(self, tmp_path) -> None:
        """set_budget_context -> commit -> build_history returns correct headroom."""
        store = FileStore(data_dir=str(tmp_path))
        store.set_budget_context(10.0, 7.0)
        store.commit(_outcome(), _analysis(), _cost_est(), _desired(), _policy(), _meta())
        hv = store.build_history("c1")
        assert hv.budget_headroom_ratio == pytest.approx(0.7)

    def test_headroom_zero_ceiling(self, tmp_path) -> None:
        """Zero ceiling does not cause division by zero."""
        store = FileStore(data_dir=str(tmp_path))
        store.set_budget_context(0.0, 0.0)
        store.commit(_outcome(), _analysis(), _cost_est(), _desired(), _policy(), _meta())
        hv = store.build_history("c1")
        assert hv.budget_headroom_ratio == 1.0  # default preserved

    def test_headroom_no_context_set(self, tmp_path) -> None:
        """Without set_budget_context, headroom stays at default 1.0."""
        store = FileStore(data_dir=str(tmp_path))
        store.commit(_outcome(), _analysis(), _cost_est(), _desired(), _policy(), _meta())
        hv = store.build_history("c1")
        assert hv.budget_headroom_ratio == 1.0

    def test_headroom_cleared_after_commit(self, tmp_path) -> None:
        """Budget context is consumed by commit -- not reused on next commit."""
        store = FileStore(data_dir=str(tmp_path))
        store.set_budget_context(10.0, 7.0)
        store.commit(_outcome(step_id="s1"), _analysis(), _cost_est(), _desired(), _policy(), _meta())
        # Second commit without set_budget_context
        store.commit(_outcome(step_id="s2"), _analysis(), _cost_est(), _desired(), _policy(), _meta())
        hv = store.build_history("c1")
        # headroom should still be 0.7 (last successful update), not re-updated
        assert hv.budget_headroom_ratio == pytest.approx(0.7)

    def test_headroom_persists_across_reload(self, tmp_path) -> None:
        """Headroom survives stats flush + reload."""
        store = FileStore(data_dir=str(tmp_path))
        store.set_budget_context(10.0, 5.0)
        store.commit(_outcome(), _analysis(), _cost_est(), _desired(), _policy(), _meta())
        store.close()
        store2 = FileStore(data_dir=str(tmp_path))
        hv = store2.build_history("c1")
        assert hv.budget_headroom_ratio == pytest.approx(0.5)
```

**Step 2: Run tests to verify they fail**

Run: `cd D:/work/Projects/veronica && uv run pytest tests/test_file_store.py::TestFileStore::test_headroom_basic_injection -v`

Expected: FAIL -- `AttributeError: 'FileStore' object has no attribute 'set_budget_context'`.

---

## Task 4: budget_headroom_ratio -- FileStore Implementation

**Files:**
- Modify: `src/veronica/file_store.py:33-50` (_ChainStats), `src/veronica/file_store.py:73-98` (FileStore.__init__), `src/veronica/file_store.py:100-158` (commit), `src/veronica/file_store.py:160-180` (build_history)

**Step 1: Add budget_headroom_ratio to _ChainStats**

In `_ChainStats.__slots__` (line 34), add `"budget_headroom_ratio"` after `"total_commits"`.

In `_ChainStats.__init__` (line 43), add:
```python
self.budget_headroom_ratio: float = 1.0
```

In `_ChainStats.to_dict` (line 51), add:
```python
"budget_headroom_ratio": self.budget_headroom_ratio,
```

In `_ChainStats.from_dict` (line 61), add:
```python
s.budget_headroom_ratio = data.get("budget_headroom_ratio", 1.0)
```

**Step 2: Add set_budget_context + commit consumption**

Add to `FileStore.__init__` after `self._chain_stats` (line 88):
```python
self._budget_ceiling: float | None = None
self._budget_remaining: float | None = None
```

Add method after `_load_existing_stats` (after line 98):
```python
def set_budget_context(self, ceiling_usd: float, remaining_usd: float) -> None:
    """Inject budget context for the next commit. Consumed on commit."""
    self._budget_ceiling = ceiling_usd
    self._budget_remaining = remaining_usd
```

In `commit()`, after `stats.total_commits += 1` (after line 154), add:
```python
# Consume budget context
ceiling = self._budget_ceiling
remaining = self._budget_remaining
self._budget_ceiling = None
self._budget_remaining = None
if ceiling is not None and remaining is not None and ceiling > _EPS:
    stats.budget_headroom_ratio = remaining / ceiling
```

**Step 3: Update build_history to use stats.budget_headroom_ratio**

In `build_history()` (line 179), change:
```python
budget_headroom_ratio=1.0,  # Requires ceiling context; default for now
```
to:
```python
budget_headroom_ratio=stats.budget_headroom_ratio,
```

**Step 4: Run all file_store tests**

Run: `cd D:/work/Projects/veronica && uv run pytest tests/test_file_store.py -v`

Expected: ALL 17 tests PASS (12 existing + 5 new).

**Step 5: Run full suite**

Run: `cd D:/work/Projects/veronica && uv run pytest tests/ -v`

Expected: 156 passed (146 + 5 emitter + 5 headroom).

**Step 6: Commit**

```bash
cd D:/work/Projects/veronica
git add src/veronica/file_store.py tests/test_file_store.py
git commit -m "feat: budget_headroom_ratio injection in FileStore

Add set_budget_context(ceiling, remaining) method. Context is consumed
on commit() and cleared. build_history() returns computed headroom.

- _ChainStats gets budget_headroom_ratio field (persisted)
- Zero ceiling treated as no-update (avoids division by zero)
- 5 new tests including reload persistence"
```

---

## Task 5: os.py budget_headroom_ratio Wiring

**Files:**
- Modify: `src/veronica/os.py:200-247`
- Test: `tests/test_phase2_integration.py`

**Step 1: Write integration test**

Add to `TestPhase2Integration` in `test_phase2_integration.py`:

```python
    def test_headroom_injected_by_os(self, tmp_path) -> None:
        """VeronicaOS injects budget context into FileStore."""
        store = FileStore(data_dir=str(tmp_path))
        vos = VeronicaOS(
            analyzer=HistoryAnalyzer(),
            cost_model=RegressionCostModel(),
            planner=AdaptivePlanner(),
            arbiter=ProportionalArbiter(),
            store=store,
            request_budget_usd=10.0,
        )

        handle = vos.before_step(_intent())
        vos.after_step(handle, _snapshot(cost=1.0))

        hv = store.build_history("c1")
        # request_budget=10.0, spent=1.0 -> remaining=9.0
        # headroom = remaining / ceiling (ceiling set by arbiter)
        assert 0.0 < hv.budget_headroom_ratio < 1.0
```

**Step 2: Run test to verify it fails**

Run: `cd D:/work/Projects/veronica && uv run pytest tests/test_phase2_integration.py::TestPhase2Integration::test_headroom_injected_by_os -v`

Expected: FAIL -- headroom is still 1.0 (os.py doesn't call `set_budget_context` yet).

**Step 3: Wire set_budget_context in os.py**

In `os.py:after_step()`, before the `self._store.commit(...)` call (line 244), add:

```python
        # Inject budget context for headroom computation
        if hasattr(self._store, "set_budget_context"):
            remaining = self._request_budget_usd - self._total_spent_usd
            self._store.set_budget_context(
                ceiling_usd=handle.policy.ceiling_usd,
                remaining_usd=remaining,
            )
```

Note: `self._total_spent_usd` is updated at line 241 (`+= outcome.cost_usd`) BEFORE this code runs, so `remaining` already accounts for the current step's cost.

**Step 4: Run all tests**

Run: `cd D:/work/Projects/veronica && uv run pytest tests/ -v`

Expected: 157 passed (156 + 1 new integration test).

**Step 5: Commit**

```bash
cd D:/work/Projects/veronica
git add src/veronica/os.py tests/test_phase2_integration.py
git commit -m "feat: wire budget_headroom_ratio in VeronicaOS.after_step

Inject budget context into FileStore before commit via hasattr check.
MemoryStore and other StoreProtocol implementations are unaffected."
```

---

## Task 6: Provider Separation Convention Doc

**Files:**
- Create: `docs/conventions.md`

**Step 1: Create conventions doc**

```markdown
# VERONICA Conventions

## Model String Format

`StepIntent.model` uses the format `"{provider}/{model}"`.

### Examples

- `openai/gpt-4`
- `anthropic/claude-sonnet-4-20250514`
- `google/gemini-2.0-flash`
- `azure-openai/gpt-4`
- `bedrock/claude-sonnet-4-20250514`

Provider names: lowercase alphanumeric + hyphens.

### Backward Compatibility

Bare model names (e.g., `"gpt-4"`) are accepted but discouraged.

**Warning:** The model string is the Store's aggregation key for EMA
computation. Mixing formats (e.g., `"gpt-4"` and `"openai/gpt-4"`)
contaminates per-model EMA values. Choose one format and use it
consistently within a deployment.
```

**Step 2: Commit**

```bash
cd D:/work/Projects/veronica
git add docs/conventions.md
git commit -m "docs: add provider/model naming convention

StepIntent.model should use '{provider}/{model}' format.
Mixing formats contaminates EMA aggregation (warning included)."
```

---

## Task 7: JSONL Rotation -- Failing Tests

**Files:**
- Modify: `tests/test_file_store.py`

**Step 1: Write 5 new rotation tests**

Add a new test class `TestFileStoreRotation` at the end of the file:

```python
class TestFileStoreRotation:
    def test_rotation_trigger(self, tmp_path) -> None:
        """Rotation occurs when commits_since_rotate >= max_lines."""
        store = FileStore(data_dir=str(tmp_path), max_lines=5)
        for i in range(6):
            store.commit(
                _outcome(step_id=f"s{i}"), _analysis(), _cost_est(),
                _desired(), _policy(), _meta(),
            )
        active = tmp_path / "c1.jsonl"
        rotated = tmp_path / "c1.1.jsonl"
        assert active.exists()
        assert rotated.exists()
        # Active has 1 line (post-rotation commit)
        assert len(active.read_text().strip().split("\n")) == 1
        # Rotated has 5 lines
        assert len(rotated.read_text().strip().split("\n")) == 5

    def test_post_rotation_history_no_coldstart(self, tmp_path) -> None:
        """build_history reads both active and rotated files."""
        store = FileStore(data_dir=str(tmp_path), max_lines=5)
        for i in range(6):
            store.commit(
                _outcome(step_id=f"s{i}"), _analysis(), _cost_est(),
                _desired(), _policy(), _meta(),
            )
        hv = store.build_history("c1")
        assert hv.depth == 6  # all 6 entries visible

    def test_double_rotation(self, tmp_path) -> None:
        """Second rotation overwrites .1 file (1 generation only)."""
        store = FileStore(data_dir=str(tmp_path), max_lines=5)
        for i in range(12):
            store.commit(
                _outcome(step_id=f"s{i}"), _analysis(), _cost_est(),
                _desired(), _policy(), _meta(),
            )
        active = tmp_path / "c1.jsonl"
        rotated = tmp_path / "c1.1.jsonl"
        # Active: 2 lines (10 rotated, 11th + 12th in active)
        assert len(active.read_text().strip().split("\n")) == 2
        # Rotated: 5 lines (second generation, overwrote first)
        assert len(rotated.read_text().strip().split("\n")) == 5
        # Total visible via history
        hv = store.build_history("c1")
        assert hv.depth == 7  # 5 from .1 + 2 from active

    def test_rotation_failure_resilience(self, tmp_path, monkeypatch) -> None:
        """Rename failure logs warning and continues -- next commit retries."""
        store = FileStore(data_dir=str(tmp_path), max_lines=5)
        for i in range(4):
            store.commit(
                _outcome(step_id=f"s{i}"), _analysis(), _cost_est(),
                _desired(), _policy(), _meta(),
            )

        # Make 5th commit trigger rotation, but sabotage rename
        from pathlib import Path
        original_rename = Path.rename
        def failing_rename(self_path, target):
            raise OSError("simulated Windows lock")
        monkeypatch.setattr(Path, "rename", failing_rename)

        # Should not raise -- rotation failure is non-fatal
        store.commit(
            _outcome(step_id="s4"), _analysis(), _cost_est(),
            _desired(), _policy(), _meta(),
        )

        monkeypatch.setattr(Path, "rename", original_rename)
        # Next commit retries rotation
        store.commit(
            _outcome(step_id="s5"), _analysis(), _cost_est(),
            _desired(), _policy(), _meta(),
        )
        rotated = tmp_path / "c1.1.jsonl"
        assert rotated.exists()

    def test_rotation_stats_persist(self, tmp_path) -> None:
        """commits_since_rotate persists through close + reload."""
        store = FileStore(data_dir=str(tmp_path), max_lines=10)
        for i in range(3):
            store.commit(
                _outcome(step_id=f"s{i}"), _analysis(), _cost_est(),
                _desired(), _policy(), _meta(),
            )
        store.close()
        store2 = FileStore(data_dir=str(tmp_path), max_lines=10)
        # Continue committing -- rotation should happen at commit 10, not 13
        for i in range(3, 10):
            store2.commit(
                _outcome(step_id=f"s{i}"), _analysis(), _cost_est(),
                _desired(), _policy(), _meta(),
            )
        rotated = tmp_path / "c1.1.jsonl"
        assert rotated.exists()
```

**Step 2: Run tests to verify they fail**

Run: `cd D:/work/Projects/veronica && uv run pytest tests/test_file_store.py::TestFileStoreRotation -v`

Expected: FAIL -- `TypeError: FileStore.__init__() got an unexpected keyword argument 'max_lines'`.

---

## Task 8: JSONL Rotation -- Implementation

**Files:**
- Modify: `src/veronica/file_store.py:33-50` (_ChainStats), `src/veronica/file_store.py:80-98` (FileStore.__init__), `src/veronica/file_store.py:100-158` (commit), `src/veronica/file_store.py:193-225` (_load_recent_outcomes)

**Step 1: Add commits_since_rotate to _ChainStats**

In `_ChainStats.__slots__`, add `"commits_since_rotate"`.

In `_ChainStats.__init__`, add:
```python
self.commits_since_rotate: int = 0
```

In `_ChainStats.to_dict`, add:
```python
"commits_since_rotate": self.commits_since_rotate,
```

In `_ChainStats.from_dict`, add:
```python
s.commits_since_rotate = data.get("commits_since_rotate", 0)
```

**Step 2: Add max_lines parameter to FileStore.__init__**

Change `__init__` signature:
```python
def __init__(
    self,
    data_dir: str,
    flush_interval: int = 10,
    max_lines: int = 10_000,
) -> None:
```

Add after `self._flush_interval`:
```python
self._max_lines = max_lines
```

**Step 3: Add rotation logic to commit()**

After the periodic flush block (after `self._flush_stats(chain_id)`), add:

```python
        # 5. Rotation check (Rule C: only after successful commit)
        stats.commits_since_rotate += 1
        if stats.commits_since_rotate >= self._max_lines:
            if self._rotate(chain_id):
                stats.commits_since_rotate = 0
            # else: rotation failed (Rule D), will retry next commit
```

**Step 4: Implement _rotate()**

Add after `_flush_stats`:

```python
    def _rotate(self, chain_id: str) -> bool:
        """Rotate JSONL: active -> .1 (single generation).

        Returns True on success. On failure (e.g., Windows file lock),
        logs warning and returns False -- next commit will retry (Rule D).
        """
        active = self._data_dir / f"{chain_id}.jsonl"
        rotated = self._data_dir / f"{chain_id}.1.jsonl"
        try:
            if rotated.exists():
                rotated.unlink()  # Windows: must delete before rename
            if active.exists():
                active.rename(rotated)
            return True
        except OSError:
            logger.warning("JSONL rotation failed for %s, will retry", chain_id)
            return False
```

**Step 5: Update _load_recent_outcomes to read both files**

Replace the method:

```python
    def _load_recent_outcomes(self, chain_id: str, limit: int) -> list[StepOutcome]:
        active_path = self._data_dir / f"{chain_id}.jsonl"
        rotated_path = self._data_dir / f"{chain_id}.1.jsonl"

        outcomes = self._read_jsonl_outcomes(active_path)
        if len(outcomes) < limit and rotated_path.exists():
            older = self._read_jsonl_outcomes(rotated_path)
            outcomes = older + outcomes  # older first, then current

        return outcomes[-limit:]

    def _read_jsonl_outcomes(self, path: Path) -> list[StepOutcome]:
        if not path.exists():
            return []
        records: list[StepOutcome] = []
        for line in open(path, encoding="utf-8"):
            line = line.strip()
            if not line:
                continue
            try:
                rec = json.loads(line)
            except json.JSONDecodeError:
                logger.warning("Corrupt JSONL line in %s, skipping", path)
                continue
            o = rec["outcome"]
            records.append(StepOutcome(
                step_id=o["step_id"],
                request_id=o["request_id"],
                chain_id=o["chain_id"],
                kind=o["kind"],
                status=o["status"],
                cost_usd=o["cost_usd"],
                tokens_in=o["tokens_in"],
                tokens_out=o["tokens_out"],
                elapsed_ms=o["elapsed_ms"],
                model=o.get("model"),
                events=(),
                timestamp_ms=o["timestamp_ms"],
            ))
        return records
```

**Step 6: Run all file_store tests**

Run: `cd D:/work/Projects/veronica && uv run pytest tests/test_file_store.py -v`

Expected: ALL 22 tests PASS (12 existing + 5 headroom + 5 rotation).

**Step 7: Run full suite**

Run: `cd D:/work/Projects/veronica && uv run pytest tests/ -v`

Expected: 162 passed.

**Step 8: Commit**

```bash
cd D:/work/Projects/veronica
git add src/veronica/file_store.py tests/test_file_store.py
git commit -m "feat: JSONL rotation with dual-generation read (4-B)

Single-generation rotation triggered by in-memory commit count.
build_history reads both active and .1 files -- no cold-start gap.

- Rule C: rotate only after successful commit
- Rule D: rename failure is non-fatal on Windows (retry next commit)
- .1 deleted before rename (Windows compatibility)
- 5 new rotation tests"
```

---

## Task 9: Version Bump + Final Verification

**Files:**
- Modify: `src/veronica/__init__.py:25`
- Modify: `pyproject.toml`

**Step 1: Bump version**

In `src/veronica/__init__.py`, change:
```python
__version__ = "0.2.0"
```
to:
```python
__version__ = "0.2.1"
```

In `pyproject.toml`, update version to `"0.2.1"`.

**Step 2: Run full test suite**

Run: `cd D:/work/Projects/veronica && uv run pytest tests/ -v --tb=short`

Expected: 162 passed (146 baseline + 5 emitter thread + 5 headroom + 5 rotation + 1 integration).

**Step 3: Verify no regressions**

Run: `cd D:/work/Projects/veronica && uv run pytest tests/test_phase2_integration.py -v`

Expected: ALL 6 integration tests PASS (5 existing + 1 new headroom test).

**Step 4: Commit + Tag**

```bash
cd D:/work/Projects/veronica
git add src/veronica/__init__.py pyproject.toml
git commit -m "chore: bump version to 0.2.1 (Phase 2.5 surgical patches)"
git tag -a v0.2.1 -m "Phase 2.5: thread safety, headroom injection, JSONL rotation"
```

---

## Dependency Graph

```
Task 1 (emitter tests) ──► Task 2 (emitter impl) ──┐
                                                     │
Task 3 (headroom tests) ──► Task 4 (headroom impl) ──► Task 5 (os.py wiring)
                                                     │
Task 6 (conventions doc) ────────────────────────────┤
                                                     │
Task 7 (rotation tests) ──► Task 8 (rotation impl) ──┤
                                                     │
                                                     └──► Task 9 (version + final)
```

Tasks 1+3+6+7 can run in parallel (all write tests / docs, no source conflicts).
Tasks 2, 4, 8 depend on their respective test tasks.
Task 5 depends on Task 4.
Task 9 depends on all others.

---

## Parallel Execution Strategy (J.A.R.V.I.S.)

| Mark | Tasks | Files |
|------|-------|-------|
| Mark-1 | 1 -> 2 | buffered_emitter.py, test_buffered_emitter.py |
| Mark-2 | 3 -> 4 -> 5, 6 | file_store.py, os.py, test_file_store.py, test_phase2_integration.py, docs/conventions.md |
| Mark-3 | 7 -> 8 | file_store.py (rotation only), test_file_store.py |

**Conflict note:** Mark-2 and Mark-3 both modify `file_store.py` and `test_file_store.py`. Options:
- Sequential: Mark-2 finishes Tasks 3-5, then Mark-3 does 7-8 on the updated file
- Worktree: Mark-3 works in isolated worktree, merge after
- Single Mark: Give Tasks 3-8 to one Mark (recommended for Phase 2.5 -- only 4 files)

**Recommended:** Mark-1 handles Tasks 1-2 (emitter). Mark-2 handles Tasks 3-8 + 6 (all FileStore + os.py + docs). Task 9 by J.A.R.V.I.S. after both complete.
