---
phase: 07-heartbeat-unified-per-symbol-view
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - scripts/heartbeat/checks/junction.py
  - scripts/heartbeat/formatter.py
autonomous: true
requirements: [HB-VIEW-01, HB-VIEW-02]

must_haves:
  truths:
    - "When all symbols are clean (yesterday=no gaps, junction delta=0), the Telegram message shows one summary line instead of expanding every symbol"
    - "When issues exist, only problematic symbols are expanded with detail; clean symbols are not listed individually"
    - "Junction section shows TID delta per symbol, not raw TID dumps"
    - "Yesterday + Junction are merged into a single unified section in the formatter"
  artifacts:
    - path: "scripts/heartbeat/checks/junction.py"
      provides: "Per-symbol TID delta computation"
      contains: "junction_delta_"
    - path: "scripts/heartbeat/formatter.py"
      provides: "Unified per-symbol health section"
      contains: "Per-Symbol Health"
  key_links:
    - from: "scripts/heartbeat/checks/junction.py"
      to: "scripts/heartbeat/formatter.py"
      via: "results dict keys junction_delta_{symbol}"
      pattern: "junction_delta_"
    - from: "scripts/heartbeat/checks/yesterday.py"
      to: "scripts/heartbeat/formatter.py"
      via: "results dict keys yesterday_{symbol}"
      pattern: "yesterday_"
---

<objective>
Merge the Yesterday and Junction sections of the heartbeat Telegram message into a single exception-only "Per-Symbol Health" display. Junction switches from raw TID dumps to per-symbol delta computation (delta=0 means clean handoff). When all symbols are clean, show one summary line. Only expand problematic symbols.

Purpose: Reduce Telegram message noise from ~40 lines of raw TIDs to 1 summary line when healthy, while making issues immediately visible.
Output: Updated junction.py (delta computation) and formatter.py (unified section).
</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/junction.py
@scripts/heartbeat/checks/yesterday.py
@scripts/heartbeat/formatter.py
@scripts/heartbeat/config.py

<interfaces>
<!-- Key types and contracts the executor needs -->

From scripts/heartbeat/config.py:

```python
HEARTBEAT_PRIORITY_SYMBOLS = ("BTCUSDT", "ETHUSDT")  # DEPRECATED

@lru_cache(maxsize=1)
def get_heartbeat_symbols() -> tuple[str, ...]:
    """Returns sorted tuple of all enabled Binance spot symbols."""
```

From scripts/heartbeat/checks/yesterday.py (results dict keys):

```python
# Per-symbol keys already set by yesterday.py:
results[f"yesterday_{symbol}"]         # True | False | "no_orphan" | "query_failed"
results[f"yesterday_{symbol}_detail"]  # "clean" | "expected X, got Y"
results["yesterday_completion"]        # True if no False values among checked symbols
```

From scripts/heartbeat/checks/junction.py (current results dict keys — will change):

```python
# Current keys (to be replaced with per-symbol deltas):
results["junction_telemetry"]          # True | False
results["junction_rest_last_tid"]      # str: "SYM=TID, ..." or "unavailable"
results["junction_ws_first_tid"]       # str: "SYM=TID, ..." or "unavailable"
results["junction_forming_bar"]        # str
results["junction_committed_floors"]   # str
```

From scripts/heartbeat/formatter.py:

```python
def format_message(results: dict[str, bool | str], healthy: bool) -> str:
    """Format health check results as a sectioned Telegram message."""
```

</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Refactor junction.py to compute per-symbol TID deltas</name>
  <files>scripts/heartbeat/checks/junction.py</files>
  <read_first>
    scripts/heartbeat/checks/junction.py
    scripts/heartbeat/config.py
  </read_first>
  <action>
Refactor `collect_junction_telemetry()` to store per-symbol delta results instead of aggregated raw TID strings. Per HB-VIEW-02, compute `delta = ws_first_tid - rest_last_tid - 1` for each symbol.

Changes to `collect_junction_telemetry()`:

1. Keep the existing HTTP fetch logic for `/health` and `/status` endpoints (lines 36-98 stay mostly intact).

2. After extracting `symbols_data` from health/status response, iterate per-symbol and compute delta:

   ```python
   for sym, sym_data in symbols_data.items():
       if not isinstance(sym_data, dict):
           continue
       r_tid = sym_data.get("rest_last_tid")
       w_tid = sym_data.get("ws_first_tid")
       if r_tid is not None and w_tid is not None:
           delta = int(w_tid) - int(r_tid) - 1
           results[f"junction_delta_{sym}"] = delta
           if delta != 0:
               results[f"junction_detail_{sym}"] = f"rest={r_tid}, ws={w_tid}, delta={delta}"
       elif r_tid is not None or w_tid is not None:
           results[f"junction_delta_{sym}"] = "partial"
           results[f"junction_detail_{sym}"] = f"rest={r_tid}, ws={w_tid}"
   ```

3. Set `results["junction_available"] = True` when sidecar responds (replaces `junction_telemetry`). Set `results["junction_available"] = False` on ConnectionError/Timeout.

4. Remove the old aggregated string keys (`junction_rest_last_tid`, `junction_ws_first_tid`, `junction_forming_bar`, `junction_committed_floors`). These are no longer needed — the formatter will read per-symbol delta keys.

5. Keep the `_try_journal_fallback()` function but simplify: if journal provides rest/ws TIDs for individual symbols, compute deltas the same way. If journal only provides aggregate data, set `results["junction_available"] = "journal_only"` (formatter will show a note).

6. Import `get_heartbeat_symbols` from `heartbeat.config` for use in the journal fallback path if needed.
   </action>

  <verify>
    <automated>cd /Users/terryli/eon/opendeviationbar-py && python -c "import ast; ast.parse(open('scripts/heartbeat/checks/junction.py').read()); print('syntax ok')" && grep -c "junction_delta_" scripts/heartbeat/checks/junction.py</automated>
  </verify>
  <acceptance_criteria>
    - `junction_delta_{sym}` keys are computed per symbol (grep: `junction_delta_`)
    - Delta formula is `ws_first_tid - rest_last_tid - 1` (grep: `- 1`)
    - Old aggregated string keys removed (no `junction_rest_last_tid` assignment outside of journal fallback)
    - `junction_available` replaces `junction_telemetry` (grep: `junction_available`)
  </acceptance_criteria>
  <done>junction.py computes per-symbol TID deltas and stores them as `junction_delta_{symbol}` in results dict. Raw TID dump keys removed.</done>
</task>

<task type="auto">
  <name>Task 2: Merge Yesterday + Junction into unified exception-only section in formatter</name>
  <files>scripts/heartbeat/formatter.py</files>
  <read_first>
    scripts/heartbeat/formatter.py
    scripts/heartbeat/checks/yesterday.py
    scripts/heartbeat/checks/junction.py
    scripts/heartbeat/config.py
  </read_first>
  <action>
Replace the separate "Yesterday" and "Junction" sections in `format_message()` with a single unified "Per-Symbol Health" section. Per HB-VIEW-01, use exception-only display.

1. Remove the "Yesterday" section (lines ~372-398 in current formatter.py) that iterates `HEARTBEAT_PRIORITY_SYMBOLS` and shows per-symbol yesterday results.

2. Remove the "Junction" section (lines ~400-436) that shows raw TID fields.

3. Replace both with a new unified section. Import `get_heartbeat_symbols` from `heartbeat.config`. Build the section as follows:

   ```python
   # -- Per-Symbol Health (unified Yesterday + Junction) --
   symbols = get_heartbeat_symbols()
   issues = []  # list of (symbol, issue_description) tuples
   symbols_checked = 0

   for symbol in symbols:
       symbols_checked += 1
       # Yesterday check
       yest = results.get(f"yesterday_{symbol}")
       if yest is False:
           detail = results.get(f"yesterday_{symbol}_detail", "unknown")
           issues.append((symbol, f"yest mismatch: {detail}"))
       elif yest == "no_orphan":
           issues.append((symbol, "no orphan bar (fresh deploy or gap)"))
       elif yest == "query_failed":
           issues.append((symbol, "crossover query failed"))

       # Junction check
       jdelta = results.get(f"junction_delta_{symbol}")
       if jdelta is not None and jdelta != 0 and jdelta != "partial":
           detail = results.get(f"junction_detail_{symbol}", f"delta={jdelta}")
           gap_or_overlap = "gap" if jdelta > 0 else "overlap"
           issues.append((symbol, f"junction {gap_or_overlap}={abs(jdelta)} trades ({detail})"))
       elif jdelta == "partial":
           detail = results.get(f"junction_detail_{symbol}", "")
           issues.append((symbol, f"junction partial: {detail}"))
   ```

4. Format the section:

   ```python
   lines.append("")
   lines.append("<b>Per-Symbol Health</b>")

   junction_available = results.get("junction_available", False)

   if not issues:
       # All clean — one summary line
       junc_note = "" if junction_available else " (junction: sidecar unavailable)"
       lines.append(f"  \u2705 {symbols_checked}/{symbols_checked} symbols clean{junc_note}")
   else:
       clean_count = symbols_checked - len(set(sym for sym, _ in issues))
       lines.append(f"  {clean_count}/{symbols_checked} clean, {len(issues)} issue(s):")
       for sym, desc in issues:
           lines.append(f"    \u274c {sym}: {desc}")
       if not junction_available:
           lines.append(f"  \u2139\ufe0f junction: sidecar unavailable")
   ```

5. Update the import at the top of formatter.py: add `get_heartbeat_symbols` from `heartbeat.config`. Remove the `HEARTBEAT_PRIORITY_SYMBOLS` import if it is no longer used elsewhere in the file. Check if `HEARTBEAT_PRIORITY_SYMBOLS` is still used in the freshness section — if yes, keep the import.

6. Update the `_remediation` dict: change `"yesterday_completion"` hint to reference the new unified section. No other remediation changes needed since junction was never health-gating.
   </action>

  <verify>
    <automated>cd /Users/terryli/eon/opendeviationbar-py && python -c "import ast; ast.parse(open('scripts/heartbeat/formatter.py').read()); print('syntax ok')" && grep -c "Per-Symbol Health" scripts/heartbeat/formatter.py && python -c "
# Verify old sections are removed
content = open('scripts/heartbeat/formatter.py').read()
assert 'Per-Symbol Health' in content, 'Missing unified section'
# The old Junction header as a standalone section should be gone
# (it may appear in remediation text, that is fine)
lines = content.split('\n')
junction_headers = [l for l in lines if 'Junction</b>' in l and 'append' in l]
assert len(junction_headers) == 0, f'Old Junction section still present: {junction_headers}'
print('all checks passed')
"</automated>
  </verify>
  <acceptance_criteria>
    - Unified "Per-Symbol Health" section exists (grep: `Per-Symbol Health`)
    - Old separate "Yesterday" section header removed (no `Yesterday</b>` in append calls)
    - Old separate "Junction" section header removed (no `Junction</b>` in append calls)
    - Exception-only: summary line when all clean (grep: `symbols clean`)
    - Issues expanded only for problematic symbols (grep: `issue(s)`)
    - `get_heartbeat_symbols` imported from config (grep: `get_heartbeat_symbols`)
  </acceptance_criteria>
  <done>Formatter shows unified "Per-Symbol Health" section with exception-only display. All clean = one summary line. Issues = expanded per-symbol detail. Old Yesterday and Junction sections removed.</done>
</task>

</tasks>

<verification>
1. Both files parse without syntax errors: `python -c "import ast; ast.parse(open(f).read())"` for each
2. `grep "Per-Symbol Health" scripts/heartbeat/formatter.py` finds the unified section
3. `grep "junction_delta_" scripts/heartbeat/checks/junction.py` confirms per-symbol delta keys
4. No remaining `Yesterday</b>` or `Junction</b>` section headers in formatter append calls
5. `get_heartbeat_symbols` used in formatter (not `HEARTBEAT_PRIORITY_SYMBOLS` for this section)
</verification>

<success_criteria>

- Heartbeat Telegram message shows a single "Per-Symbol Health" section instead of separate Yesterday + Junction
- When all symbols are clean: one line like "17/17 symbols clean"
- When issues exist: summary count + only problematic symbols expanded
- Junction uses TID delta (0=clean) instead of raw TID dumps
- No regression in yesterday completion checking (still uses get_heartbeat_symbols())
  </success_criteria>

<output>
After completion, create `.planning/phases/07-heartbeat-unified-per-symbol-view/07-01-SUMMARY.md`
</output>
