---
phase: 27-stathera-reporting-separation
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - scripts/heartbeat/checks/stathera.py
  - scripts/heartbeat/formatter.py
  - scripts/heartbeat/config.py
autonomous: true
requirements: [STATHERA-01, STATHERA-02]

must_haves:
  truths:
    - "Heartbeat Stathera section shows pipeline_gaps=N and exchange_gaps=M as separate counts"
    - "Only pipeline_gaps > 0 triggers CRITICAL/LOUD alerts; exchange_gaps alone do not"
    - "Per-symbol breakdown shows exchange vs pipeline gap split"
  artifacts:
    - path: "scripts/heartbeat/checks/stathera.py"
      provides: "Exchange gap count from kintsugi_log cross-referenced with Stathera gaps"
      contains: "exchange_gap"
    - path: "scripts/heartbeat/formatter.py"
      provides: "Separated pipeline_gaps and exchange_gaps display"
      contains: "pipeline_gaps"
    - path: "scripts/heartbeat/config.py"
      provides: "Updated health gating keys"
      contains: "stathera_pipeline_gaps"
  key_links:
    - from: "scripts/heartbeat/checks/stathera.py"
      to: "opendeviationbar_cache.kintsugi_log"
      via: "SQL query for result='exchange_gap'"
      pattern: "result.*exchange_gap"
    - from: "scripts/heartbeat/formatter.py"
      to: "scripts/heartbeat/checks/stathera.py"
      via: "results dict keys stathera_pipeline_gap_count and stathera_exchange_gap_count"
      pattern: "pipeline_gap_count"
---

<objective>
Separate exchange gaps from pipeline gaps in heartbeat Stathera reporting so that only actionable pipeline gaps trigger LOUD alerts, while exchange-level gaps (permanent, unhealable) are reported informatively.

Purpose: Eliminate false CRITICAL alerts from the ~96K exchange-level Stathera gaps that kintsugi correctly classified as permanent in Phase 26. Only pipeline gaps (actionable, healable) should wake the operator.

Output: Modified stathera.py, formatter.py, and config.py with exchange/pipeline gap separation.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@scripts/heartbeat/checks/stathera.py
@scripts/heartbeat/formatter.py
@scripts/heartbeat/config.py

<interfaces>
<!-- kintsugi_log schema (from clickhouse/schema.sql) -->
CREATE TABLE opendeviationbar_cache.kintsugi_log (
    timestamp DateTime64(3, 'UTC'),
    symbol LowCardinality(String),
    threshold_decimal_bps UInt32,
    gap_start_ms Int64,    -- actually microseconds despite name
    gap_end_ms Int64,
    result LowCardinality(String),  -- 'healed', 'failed', 'clean', 'exchange_gap'
    bars_written UInt32,
    ...
)

<!-- Stathera query helpers (from clickhouse/stathera_query.py) -->

FULL_TABLE = "opendeviationbar_cache.open_deviation_bars"
cross_pair_filter() -> str -- WHERE clause for enabled symbols + valid TID
cross_pair_window() -> str -- WINDOW w AS (PARTITION BY symbol, threshold_decimal_bps, ouroboros_mode ORDER BY ...)

<!-- Current results dict keys set by stathera.py -->

results["stathera_gaps"] = bool -- health gate (True=healthy)
results["stathera_gap_count"] = str -- total gap count
results["stathera_gap_delta"] = str -- delta from previous heartbeat
results["stathera_per_symbol"] = dict -- {pair_key: {gaps, overlaps, latest_open_us}}

<!-- config.py health gating -->

CRITICAL_KEYS = frozenset({"stathera_gaps", "stathera_overlaps", ...})
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Query kintsugi_log for exchange gap counts and split Stathera results</name>
  <files>scripts/heartbeat/checks/stathera.py, scripts/heartbeat/config.py</files>
  <read_first>scripts/heartbeat/checks/stathera.py, scripts/heartbeat/config.py, python/opendeviationbar/clickhouse/schema.sql</read_first>
  <action>
In `stathera.py`, after the per_symbol_row query builds `per_symbol` dict and computes `gap_count`:

1. Add a new query to count exchange gaps per (symbol, threshold) pair from kintsugi_log:

```sql
SELECT symbol, threshold_decimal_bps, count() AS exchange_gaps
FROM opendeviationbar_cache.kintsugi_log
WHERE result = 'exchange_gap'
GROUP BY symbol, threshold_decimal_bps
```

1. Cross-reference: for each pair in `per_symbol`, look up exchange_gap count from the kintsugi_log query. Cap it at the pair's total gaps (exchange_gaps cannot exceed total gaps for that pair). Compute `pipeline_gaps = gaps - exchange_gaps` per pair.

2. Compute aggregate totals: `exchange_gap_count = sum(per-pair exchange gaps)`, `pipeline_gap_count = gap_count - exchange_gap_count`.

3. Store enriched per_symbol entries: add `exchange_gaps` and `pipeline_gaps` keys to each pair's dict in `per_symbol`.

4. Set new results keys:
   - `results["stathera_pipeline_gap_count"] = str(pipeline_gap_count)`
   - `results["stathera_exchange_gap_count"] = str(exchange_gap_count)`

5. Update health gating logic: change the `stathera_gaps` boolean to use `pipeline_gap_count` instead of `gap_count` for the health gate. Exchange gaps should NOT trigger CRITICAL. The key insight: `results["stathera_gaps"]` should be True (healthy) when `pipeline_gap_count == 0`, even if `exchange_gap_count > 0`.

6. Update delta tracking: compute and store `pipeline_gap_delta` and `exchange_gap_delta` separately. Store both in `save_stathera_counts()`. Load both from `load_stathera_counts()`.

7. Update the TELEM-04 today-gap LOUD alert: today's gaps are ALWAYS pipeline gaps (exchange_gap classification only happens after kintsugi repair, which cannot touch today). No change needed to `_check_today_gaps()`.

8. Update `anomalies_growing` check: use `pipeline_gap_delta > 0` (not total gap_delta) for the growing check since exchange gaps growing is expected during retroactive classification.

In `config.py`:

- Add `stathera_pipeline_gaps` to CRITICAL_KEYS (replaces `stathera_gaps` for pipeline-specific gating)
- Keep `stathera_gaps` in CRITICAL_KEYS for backward compat during transition — but stathera.py will set it based on pipeline_gap_count, so it's equivalent

In `state.py` — update `save_stathera_counts` and `load_stathera_counts` to also persist `pipeline_gap_count` and `exchange_gap_count` alongside existing `gap_count`. Use backward-compatible defaults so first run after upgrade doesn't false-alarm.

Wrap the kintsugi_log query in try/except — if it fails (table not yet migrated, connection issue), fall back to treating all gaps as pipeline gaps. Log at DEBUG level. Heartbeat must never crash.
</action>
<verify>
<automated>cd /Users/terryli/eon/opendeviationbar-py && python -c "
import ast, sys
src = open('scripts/heartbeat/checks/stathera.py').read()
tree = ast.parse(src)

# Verify exchange_gap query exists

assert 'exchange_gap' in src, 'exchange_gap query missing'
assert 'pipeline_gap_count' in src, 'pipeline_gap_count missing'
assert 'stathera_pipeline_gap_count' in src, 'results key missing'
assert 'stathera_exchange_gap_count' in src, 'results key missing'
print('stathera.py: all keys present')

# Verify config.py

cfg = open('scripts/heartbeat/config.py').read()
print('config.py: checked')
print('PASS')
"</automated>
</verify>
<acceptance_criteria> - grep -q "exchange_gap" scripts/heartbeat/checks/stathera.py - grep -q "pipeline_gap_count" scripts/heartbeat/checks/stathera.py - grep -q "stathera_pipeline_gap_count" scripts/heartbeat/checks/stathera.py - grep -q "stathera_exchange_gap_count" scripts/heartbeat/checks/stathera.py - grep -q "kintsugi_log" scripts/heartbeat/checks/stathera.py - grep -q "result.*=.*exchange_gap" scripts/heartbeat/checks/stathera.py
</acceptance_criteria>
<done>stathera.py queries kintsugi_log for exchange gaps, splits gap counts into pipeline vs exchange, and only pipeline_gaps triggers CRITICAL health gating</done>
</task>

<task type="auto">
  <name>Task 2: Update formatter to display pipeline_gaps and exchange_gaps separately</name>
  <files>scripts/heartbeat/formatter.py</files>
  <read_first>scripts/heartbeat/formatter.py, scripts/heartbeat/checks/stathera.py</read_first>
  <action>
In the Stathera section of `format_message()` (around line 279):

1. Replace the single gap count display with separated counts. Instead of:
   `{gap_count} gaps{_delta_str(gap_delta)}`
   Show:
   `pipeline_gaps={pipeline_gap_count}{_delta_str(pipeline_delta)}, exchange_gaps={exchange_gap_count}{_delta_str(exchange_delta)}`

   Read from results:
   - `results.get("stathera_pipeline_gap_count", "0")`
   - `results.get("stathera_exchange_gap_count", "0")`
   - `results.get("stathera_pipeline_gap_delta", "0")`
   - `results.get("stathera_exchange_gap_delta", "0")`

2. Keep backward compat: if `stathera_pipeline_gap_count` is not in results (old stathera.py), fall back to the existing single gap_count display.

3. Update `has_gaps` logic: use `pipeline_gap_count != "0"` for triggering the expanded anomaly display. Exchange-only gaps should still show in the summary line but NOT trigger the remediation hints.

4. Per-symbol breakdown: update to show `{pipeline_gaps}p + {exchange_gaps}x gaps` per pair, using the enriched per_symbol dict from Task 1. Example: `BTCUSDT@250: 0p + 47x gaps, 0 overlaps (all exchange)`.

5. When pipeline_gaps=0 and exchange_gaps>0, show a reassuring note: "all gaps are exchange-level (permanent, no action needed)".

6. Update the `_remediation` dict entry for `stathera_gaps` to mention pipeline gaps specifically: "Pipeline bar-boundary gaps detected — kintsugi day-recompute will resolve".
   </action>

  <verify>
    <automated>cd /Users/terryli/eon/opendeviationbar-py && python -c "
src = open('scripts/heartbeat/formatter.py').read()
assert 'pipeline_gap' in src, 'pipeline_gap display missing'
assert 'exchange_gap' in src, 'exchange_gap display missing'
print('formatter.py: pipeline/exchange gap display present')
print('PASS')
"</automated>
  </verify>
  <acceptance_criteria>
    - grep -q "pipeline_gap" scripts/heartbeat/formatter.py
    - grep -q "exchange_gap" scripts/heartbeat/formatter.py
    - grep -q "exchange-level" scripts/heartbeat/formatter.py
  </acceptance_criteria>
  <done>Telegram heartbeat message shows `pipeline_gaps=N, exchange_gaps=M` with per-symbol breakdown; exchange-only state shows reassuring "no action needed" note</done>
</task>

</tasks>

<verification>
1. `grep -q "exchange_gap" scripts/heartbeat/checks/stathera.py` — exchange gap query exists
2. `grep -q "pipeline_gap_count" scripts/heartbeat/checks/stathera.py` — pipeline count computed
3. `grep -q "pipeline_gap" scripts/heartbeat/formatter.py` — formatter displays split
4. `grep -q "kintsugi_log" scripts/heartbeat/checks/stathera.py` — cross-references kintsugi_log
5. `python -c "import ast; ast.parse(open('scripts/heartbeat/checks/stathera.py').read()); print('OK')"` — valid Python
6. `python -c "import ast; ast.parse(open('scripts/heartbeat/formatter.py').read()); print('OK')"` — valid Python
</verification>

<success_criteria>

1. stathera.py queries kintsugi_log for exchange_gap entries and subtracts from total to get pipeline gap count
2. results dict contains `stathera_pipeline_gap_count` and `stathera_exchange_gap_count` as separate keys
3. Health gating (`stathera_gaps` boolean) uses pipeline_gap_count — exchange gaps alone do not trigger CRITICAL
4. Formatter shows `pipeline_gaps=N, exchange_gaps=M` in the Stathera section
5. Per-symbol breakdown shows pipeline vs exchange split per pair
6. All files parse as valid Python
   </success_criteria>

<output>
After completion, create `.planning/phases/27-stathera-reporting-separation/27-01-SUMMARY.md`
</output>
