Files
project-lyra/docs/superpowers/plans/2026-06-28-poker-logging-service.md
T
serversdown a2835500bc docs: implementation plan for poker logging service (sub-project 1)
Seven tasks: contract module + conformance test, direct capture
endpoints (stack/buyin/start), hands API, reads/players API + route
conformance, chat-page stack quick-capture box, HUD quick inputs +
villain rename, and the iOS-PWA bottom safe-area fix. TDD with real
test/impl code grounded in the actual poker.py signatures and FastAPI
route patterns.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G796GsLCvJQKVN7hwV2cDx
2026-06-28 23:27:58 +00:00

31 KiB
Raw Blame History

Poker Logging Service Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Turn lyra/poker.py into a standalone logging system-of-record with a complete REST API, a single source-of-truth tool/API contract, and a human UI to log and correct everything — usable by Brian with zero LLM dependency.

Architecture: Thin FastAPI routes wrap the existing (already-working) poker.py store functions; a declarative poker_contract.py pins operation names + required args so the REST API and Lyra's LLM tool specs can't drift; the web UI gets dumb capture inputs (2nd stack box on chat, quick inputs on the HUD) and correction controls. This is sub-project 1 of 2; Lyra's classifier/prompts (sub-project 2) are parked.

Tech Stack: Python 3.11+ (venv runs 3.14), FastAPI + uvicorn, SQLite (WAL), pytest, vanilla HTML/JS/CSS.

Global Constraints

  • Python files: start with from __future__ import annotations; 4-space indent; ruff line-length = 100, target-version = "py311".
  • lyra/web/static/index.html uses CRLF (\r\n) line endings and mixed tabs/spaces. Every other static file (session.html, style.css, nav.js) and all Python use LF + spaces. Match the file you edit or you produce a noisy diff.
  • Pure data capture (stack / buy-in / cash-out / hand / read) must reach the store via the REST endpoints, never through the chat/LLM path.
  • poker_contract.py is the single source of truth: REST routes and tools.py specs must agree with it (enforced by a conformance test).
  • Web app runs via lyra-web (uvicorn) on 0.0.0.0:7078. DB path from LYRA_DB_PATH (default data/lyra.db, WAL).
  • Test idiom: fixture sets LYRA_DB_PATH to a tmp_path file, stubs llm.embed (and llm.complete where needed), then importlib.reload(memory) then importlib.reload(poker) (order matters), then importlib.reload(server) for endpoint tests. Run with .venv/bin/pytest (or uv run pytest).
  • Existing store facts to respect: start_session(...) uses fmt= (column is format); add_buyin returns a float total; log_stack returns the stack_state dict {current, buy_in, net}; end_session(cash_out, ...) takes cash_out first; hud() returns None when no session; _HAND_FIELDS = ("position","hole_cards","board","preflop","flop","turn","river","showdown","pot","result","stack_after","tag","lesson"); upsert_player(name, **fields) returns an int player id; tools.dispatch(name, args, ctx)ctx is a plain dict.

Task 1: Contract module + tool-spec conformance test

Files:

  • Create: lyra/poker_contract.py
  • Create: tests/test_poker_contract.py

Interfaces:

  • Produces: lyra.poker_contract.OPERATIONS: dict[str, dict] and CONTRACT_VERSION: int. Each op value: {"required": tuple[str,...], "llm_tool": str | None, "rest": tuple[str, str] | None} where rest is (METHOD, PATH) with PATH exactly matching the FastAPI route template.

  • Step 1: Write the contract module

lyra/poker_contract.py:

from __future__ import annotations

# Single source of truth for poker logging operations. The REST API, Lyra's LLM
# tool specs, the human UI, and (later) an MCP wrapper all derive from this.
# `required` MUST match the `required` list in the matching tools.py spec.
# `rest` PATH MUST match the FastAPI route template verbatim.
CONTRACT_VERSION = 1

OPERATIONS: dict[str, dict] = {
    "start_session":  {"required": (),            "llm_tool": "start_session",  "rest": ("POST", "/session")},
    "update_session": {"required": (),            "llm_tool": "update_session", "rest": ("PATCH", "/session/{session_id}")},
    "end_session":    {"required": ("cash_out",), "llm_tool": "end_session",    "rest": None},
    "log_stack":      {"required": ("amount",),   "llm_tool": "log_stack",      "rest": ("POST", "/session/stack")},
    "add_buyin":      {"required": ("amount",),   "llm_tool": "add_buyin",      "rest": ("POST", "/session/buyin")},
    "log_hand":       {"required": (),            "llm_tool": "log_hand",       "rest": ("POST", "/session/hand")},
    "update_hand":    {"required": ("id",),       "llm_tool": None,             "rest": ("PATCH", "/hand/{hand_id}")},
    "add_read":       {"required": ("note",),     "llm_tool": "add_read",       "rest": ("POST", "/session/read")},
    "update_player":  {"required": ("id",),       "llm_tool": None,             "rest": ("PATCH", "/player/{player_id}")},
}
  • Step 2: Write the failing conformance test

tests/test_poker_contract.py:

from __future__ import annotations

from lyra import tools
from lyra.poker_contract import OPERATIONS


def test_llm_tool_required_args_match_contract():
    for op, decl in OPERATIONS.items():
        name = decl["llm_tool"]
        if not name:
            continue
        spec = tools.TOOLS[name]["spec"]
        required = set(spec["function"]["parameters"]["required"])
        assert required == set(decl["required"]), (
            f"{op}: tools spec required {required} != contract {set(decl['required'])}"
        )
  • Step 3: Run the test

Run: .venv/bin/pytest tests/test_poker_contract.py -v Expected: PASS (the contract's required tuples were copied from the live specs).

  • Step 4: Commit
git add lyra/poker_contract.py tests/test_poker_contract.py
git commit -m "feat: poker operation contract + tool-spec conformance test"

Task 2: Direct capture endpoints (stack / buy-in / start)

Files:

  • Modify: lyra/web/server.py (add three routes inside create_app, near the existing PATCH /session/{session_id} at server.py:116)
  • Create: tests/test_poker_api.py

Interfaces:

  • Consumes: poker.log_stack(amount, note=None), poker.add_buyin(amount), poker.start_session(venue=, stakes=, game=, fmt=, buy_in=, mantra=), poker.live_session().

  • Produces: POST /session/stack{ok, stack} or {ok:false, error}; POST /session/buyin{ok, buy_in_total}; POST /session{ok, id}.

  • Step 1: Write the failing endpoint tests

tests/test_poker_api.py:

from __future__ import annotations

import importlib

import pytest


@pytest.fixture
def client(tmp_path, monkeypatch):
    monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
    from lyra import llm
    monkeypatch.setattr(llm, "embed", lambda texts: [[0.1, 0.2, 0.3] for _ in texts])
    import lyra.memory as memory
    importlib.reload(memory)
    import lyra.poker as poker
    importlib.reload(poker)
    import lyra.web.server as server
    importlib.reload(server)
    from fastapi.testclient import TestClient
    return TestClient(server.app), poker


def test_post_stack_logs_and_returns_state(client):
    c, poker = client
    poker.start_session(venue="Meadows", stakes="1/3", buy_in=400)
    r = c.post("/session/stack", json={"amount": 373})
    assert r.status_code == 200
    body = r.json()
    assert body["ok"] is True
    assert body["stack"]["current"] == 373
    assert body["stack"]["net"] == pytest.approx(-27)


def test_post_stack_without_session_errors(client):
    c, _ = client
    r = c.post("/session/stack", json={"amount": 373})
    assert r.json()["ok"] is False
    assert "error" in r.json()


def test_post_buyin_increments_total(client):
    c, poker = client
    poker.start_session(buy_in=400)
    r = c.post("/session/buyin", json={"amount": 200})
    assert r.json()["buy_in_total"] == pytest.approx(600)


def test_post_session_starts_live(client):
    c, poker = client
    r = c.post("/session", json={"venue": "Wheeling", "stakes": "1/3", "buy_in": 400})
    sid = r.json()["id"]
    assert poker.live_session()["id"] == sid
  • Step 2: Run to verify it fails

Run: .venv/bin/pytest tests/test_poker_api.py -v Expected: FAIL with 404s (routes not defined). If it errors with "No module named 'httpx'", run .venv/bin/pip install httpx (TestClient needs it).

  • Step 3: Add the three routes

In lyra/web/server.py, immediately after the PATCH /session/{session_id} handler (server.py:122), add:

    @app.post("/session/stack")
    async def session_log_stack(request: Request) -> dict:
        """Log Brian's current stack directly (no LLM). Server-stamps the time."""
        body = await request.json()
        try:
            amount = float(body.get("amount"))
        except (TypeError, ValueError):
            return {"ok": False, "error": "amount must be a number"}
        note = (body.get("note") or "").strip() or None
        try:
            state = await asyncio.to_thread(poker.log_stack, amount, note)
        except ValueError as exc:
            return {"ok": False, "error": str(exc)}
        logbus.log("info", "stack logged (direct)", amount=amount)
        return {"ok": True, "stack": state}

    @app.post("/session/buyin")
    async def session_add_buyin(request: Request) -> dict:
        """Add a buy-in/rebuy directly (no LLM)."""
        body = await request.json()
        try:
            amount = float(body.get("amount"))
        except (TypeError, ValueError):
            return {"ok": False, "error": "amount must be a number"}
        try:
            total = await asyncio.to_thread(poker.add_buyin, amount)
        except ValueError as exc:
            return {"ok": False, "error": str(exc)}
        logbus.log("info", "buyin added (direct)", amount=amount)
        return {"ok": True, "buy_in_total": total}

    @app.post("/session")
    async def session_start(request: Request) -> dict:
        """Open a new live session directly (no LLM)."""
        body = await request.json()
        sid = await asyncio.to_thread(lambda: poker.start_session(
            venue=body.get("venue"), stakes=body.get("stakes"),
            game=body.get("game") or "NLH", fmt=body.get("format") or "cash",
            buy_in=body.get("buy_in") or 0, mantra=body.get("mantra"),
        ))
        logbus.log("info", "poker session started (direct)", id=sid)
        return {"ok": True, "id": sid}
  • Step 4: Run to verify it passes

Run: .venv/bin/pytest tests/test_poker_api.py -v Expected: PASS (4 tests).

  • Step 5: Commit
git add lyra/web/server.py tests/test_poker_api.py
git commit -m "feat: direct REST endpoints for stack/buyin/start-session (no LLM)"

Task 3: Hands API (log / edit / delete)

Files:

  • Modify: lyra/poker.py (add update_hand near log_hand at poker.py:558)
  • Modify: lyra/web/server.py (add routes after the Task 2 routes)
  • Modify: tests/test_poker_api.py (add tests)

Interfaces:

  • Consumes: poker.log_hand(**fields), poker.get_hand(id), poker.delete_entry("hand", id), _HAND_FIELDS.

  • Produces: poker.update_hand(hand_id, **fields) -> dict | None; POST /session/hand{ok, id}; PATCH /hand/{hand_id}{ok, hand}; DELETE /hand/{hand_id}{ok}.

  • Step 1: Write the failing tests

Append to tests/test_poker_api.py:

def test_post_hand_edit_and_delete(client):
    c, poker = client
    poker.start_session(buy_in=400)
    r = c.post("/session/hand", json={"position": "BTN", "hole_cards": "22", "result": 120})
    assert r.json()["ok"] is True
    hid = r.json()["id"]
    r2 = c.patch(f"/hand/{hid}", json={"hole_cards": "2c2d"})
    assert r2.json()["ok"] is True
    assert r2.json()["hand"]["hole_cards"] == "2c2d"
    r3 = c.delete(f"/hand/{hid}")
    assert r3.json()["ok"] is True
    assert poker.get_hand(hid) is None
  • Step 2: Run to verify it fails

Run: .venv/bin/pytest tests/test_poker_api.py::test_post_hand_edit_and_delete -v Expected: FAIL (404 on /session/hand).

  • Step 3: Add update_hand to the store

In lyra/poker.py, immediately after log_hand (poker.py:558), add:

def update_hand(hand_id: int, **fields) -> dict | None:
    """Edit a logged hand's flat fields (fix a mislabeled board, result, villain).
    Only known columns are touched. Returns the updated hand row or None."""
    sets, vals = [], []
    for k, v in fields.items():
        if k in _HAND_FIELDS and v is not None:
            sets.append(f"{k} = ?")
            vals.append(v)
    if sets:
        conn = _c()
        with conn:
            conn.execute(f"UPDATE poker_hands SET {', '.join(sets)} WHERE id = ?",
                         (*vals, hand_id))
    return get_hand(hand_id)
  • Step 4: Add the three routes

In lyra/web/server.py, after the Task 2 routes, add:

    @app.post("/session/hand")
    async def session_log_hand(request: Request) -> dict:
        """Log a hand directly with flat fields (no LLM parse)."""
        body = await request.json()
        try:
            hid = await asyncio.to_thread(lambda: poker.log_hand(**body))
        except ValueError as exc:
            return {"ok": False, "error": str(exc)}
        logbus.log("info", "hand logged (direct)", id=hid)
        return {"ok": True, "id": hid}

    @app.patch("/hand/{hand_id}")
    async def hand_update(hand_id: int, request: Request) -> dict:
        """Edit a logged hand's flat fields."""
        body = await request.json()
        h = await asyncio.to_thread(lambda: poker.update_hand(hand_id, **body))
        logbus.log("info", "hand edited", id=hand_id, fields=list(body))
        return {"ok": h is not None, "hand": h}

    @app.delete("/hand/{hand_id}")
    async def hand_delete(hand_id: int) -> dict:
        """Delete a logged hand."""
        ok = await asyncio.to_thread(poker.delete_entry, "hand", hand_id)
        return {"ok": ok}
  • Step 5: Run to verify it passes

Run: .venv/bin/pytest tests/test_poker_api.py -v Expected: PASS (all tests, including the new hand test).

  • Step 6: Commit
git add lyra/poker.py lyra/web/server.py tests/test_poker_api.py
git commit -m "feat: hands API — log_hand endpoint, update_hand store fn, edit/delete routes"

Task 4: Reads/players API + route conformance

Files:

  • Modify: lyra/poker.py (add update_player near upsert_player)
  • Modify: lyra/web/server.py (add routes)
  • Modify: tests/test_poker_api.py (add tests)
  • Modify: tests/test_poker_contract.py (add route-coverage test)

Interfaces:

  • Consumes: poker.add_read(note=, name=, ...), poker.upsert_player(name, **fields).

  • Produces: poker.update_player(player_id, **fields) -> dict | None; POST /session/read{ok, id}; PATCH /player/{player_id}{ok, player}.

  • Step 1: Write the failing tests

Append to tests/test_poker_api.py:

def test_post_read(client):
    c, poker = client
    poker.start_session(buy_in=400)
    r = c.post("/session/read", json={"note": "3-bets light", "name": "James K"})
    assert r.json()["ok"] is True
    assert isinstance(r.json()["id"], int)


def test_rename_player_fixes_mislabel(client):
    c, poker = client
    pid = poker.upsert_player("Dave the rock", category="reg")
    r = c.patch(f"/player/{pid}", json={"name": "Dave the mechanic"})
    assert r.json()["ok"] is True
    assert r.json()["player"]["name"] == "Dave the mechanic"

Append to tests/test_poker_contract.py:

def test_rest_routes_registered():
    import lyra.web.server as server
    registered = set()
    for route in server.app.routes:
        methods = getattr(route, "methods", None)
        path = getattr(route, "path", None)
        if not methods or not path:
            continue
        for m in methods:
            registered.add((m, path))
    for op, decl in OPERATIONS.items():
        if not decl["rest"]:
            continue
        method, path = decl["rest"]
        assert (method, path) in registered, f"{op}: {method} {path} not registered"
  • Step 2: Run to verify it fails

Run: .venv/bin/pytest tests/test_poker_api.py::test_rename_player_fixes_mislabel tests/test_poker_contract.py::test_rest_routes_registered -v Expected: FAIL (404 on /player/...; route-coverage missing several POST/PATCH paths).

  • Step 3: Add update_player to the store

In lyra/poker.py, immediately after upsert_player (find it near poker.py:1010), add:

_PLAYER_FIELDS = ("name", "venue", "description", "tendencies", "adjustment", "category")


def update_player(player_id: int, **fields) -> dict | None:
    """Edit a player's dossier (rename, fix tendencies/category). Returns the row or None."""
    sets, vals = [], []
    for k, v in fields.items():
        if k in _PLAYER_FIELDS and v is not None:
            sets.append(f"{k} = ?")
            vals.append(v)
    if sets:
        conn = _c()
        with conn:
            conn.execute(f"UPDATE poker_players SET {', '.join(sets)} WHERE id = ?",
                         (*vals, player_id))
    row = _c().execute("SELECT * FROM poker_players WHERE id = ?", (player_id,)).fetchone()
    return dict(row) if row else None
  • Step 4: Add the two routes

In lyra/web/server.py, after the Task 3 routes, add:

    @app.post("/session/read")
    async def session_add_read(request: Request) -> dict:
        """Log a read directly (no LLM); upserts the villain file when name is given."""
        body = await request.json()
        rid = await asyncio.to_thread(lambda: poker.add_read(
            note=body.get("note") or "", seat=body.get("seat"), name=body.get("name"),
            tendencies=body.get("tendencies"), adjustment=body.get("adjustment"),
            description=body.get("description"), category=body.get("category"),
            venue=body.get("venue"),
        ))
        return {"ok": True, "id": rid}

    @app.patch("/player/{player_id}")
    async def player_update(player_id: int, request: Request) -> dict:
        """Edit a player's dossier (rename, fix tendencies)."""
        body = await request.json()
        p = await asyncio.to_thread(lambda: poker.update_player(player_id, **body))
        logbus.log("info", "player edited", id=player_id, fields=list(body))
        return {"ok": p is not None, "player": p}
  • Step 5: Run to verify it passes

Run: .venv/bin/pytest tests/test_poker_api.py tests/test_poker_contract.py -v Expected: PASS (all API tests + both conformance tests).

  • Step 6: Run the full suite (no regressions)

Run: .venv/bin/pytest -q Expected: PASS (existing poker/tools/chat tests still green).

  • Step 7: Commit
git add lyra/poker.py lyra/web/server.py tests/test_poker_api.py tests/test_poker_contract.py
git commit -m "feat: reads/players API + REST route conformance test"

Task 5: Chat-page stack quick-capture (2nd input box)

Files:

  • Modify: lyra/web/static/index.html (CRLF + tabs — add markup + JS)
  • Modify: lyra/web/static/style.css (LF + spaces — add styling)

Interfaces:

  • Consumes: POST /session/stack (Task 2). Reads currentSession and the Live Log DOM (#thinkingContent, #thinkingEmpty) already present in index.html.

  • Produces: a stack-only input that logs without any chat/LLM call.

  • Step 1: Add the input row markup

In lyra/web/static/index.html, insert between the <div id="input">…</div> block (ends ~index.html:125) and <nav id="tabbar"> (index.html:128). Use CRLF + tab indentation to match the file.

    <!-- Stack quick-capture (no LLM): type a number -> logs current stack -->
    <div id="stackQuick">
      <input id="stackQuickInput" type="number" inputmode="decimal" placeholder="Stack $" aria-label="Log current stack">
      <button id="stackQuickBtn" type="button" title="Log stack (no chat)">Log</button>
    </div>
  • Step 2: Add the JS

In the <script> of index.html, near sendMessage (index.html:299), add (CRLF + tabs):

    function liveLogLine(text) {
      const content = document.getElementById("thinkingContent");
      const empty = document.getElementById("thinkingEmpty");
      if (empty) empty.style.display = "none";
      const div = document.createElement("div");
      div.className = "thinking-event";
      div.textContent = text;
      content.appendChild(div);
      content.scrollTop = content.scrollHeight;
    }

    async function logStackQuick() {
      const el = document.getElementById("stackQuickInput");
      const raw = (el.value || "").replace(/[^0-9.]/g, "");
      if (!raw) return;
      const amount = Number(raw);
      try {
        const r = await fetch("/session/stack", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ amount })
        });
        const data = await r.json();
        if (!data.ok) { liveLogLine("⚠ " + (data.error || "stack not logged")); return; }
        const t = new Date().toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
        const net = (data.stack && data.stack.net != null)
          ? ` (net ${data.stack.net >= 0 ? "+" : ""}${data.stack.net})` : "";
        liveLogLine(`💰 $${amount} logged · ${t}${net}`);
        el.value = "";
      } catch (e) {
        liveLogLine("⚠ stack log failed: " + e.message);
      }
    }
    document.getElementById("stackQuickBtn").addEventListener("click", logStackQuick);
    document.getElementById("stackQuickInput").addEventListener("keydown", (e) => {
      if (e.key === "Enter") { e.preventDefault(); logStackQuick(); }
    });
  • Step 3: Add styling

In lyra/web/static/style.css (LF + spaces), add:

#stackQuick {
  display: flex;
  gap: 8px;
  align-items: center;
  padding: 6px 12px;
  border-top: 1px solid var(--border, #222);
}
#stackQuick input {
  flex: 1;
  min-width: 0;
  padding: 8px 10px;
  background: var(--panel, #111);
  color: inherit;
  border: 1px solid var(--border, #333);
  border-radius: 8px;
}
#stackQuick button {
  padding: 8px 14px;
  background: var(--accent, #ff7a18);
  color: #000;
  border: none;
  border-radius: 8px;
  font-weight: 600;
}
  • Step 4: Verify manually

Start the app: .venv/bin/python -m lyra.web.server (serves on :7078). With a live session (start one via the HUD or curl -XPOST localhost:7078/session -d '{"buy_in":400}' -H 'Content-Type: application/json'):

  • The stack box appears below the message input, above the nav icons.

  • Type 350, press Enter → a 💰 $350 logged · … line appears in the Live Log, the box clears, and no chat bubble is added.

  • Confirm persisted: curl -s localhost:7078/session/data | python -m json.tool shows stack.current == 350.

  • Step 5: Commit

git add lyra/web/static/index.html lyra/web/static/style.css
git commit -m "feat: stack quick-capture box on chat page (no LLM)"

Task 6: HUD quick-capture + correction controls

Files:

  • Modify: lyra/web/static/session.html (LF + spaces — Stack card markup, villain rename control, JS functions)

Interfaces:

  • Consumes: POST /session/stack, POST /session/buyin (Task 2), PATCH /session/{id} (existing), PATCH /player/{id} (Task 4). Reads existing globals curSession, refresh(), and the villain render block.

  • Step 1: Add quick inputs to the Stack card

In lyra/web/static/session.html, replace the Stack card block (session.html:280-289) with the same block plus a quick row before its closing </div>:

        <div class="card">
          <p class="label">Stack</p>
          <div class="stack-row">
            <span class="stack-now">${stack.current == null ? '—' : money(stack.current)}</span>
            <span class="net ${netClass(stack.net)}">${stack.net == null ? '' : signed(stack.net)}</span>
            <span class="stack-meta">bought in ${money(stack.buy_in)}<br>${(stack.log||[]).length} update(s)</span>
          </div>
          ${sparkline(stack.log || [])}
          <div class="quick">
            <input id="qStack" type="number" inputmode="decimal" placeholder="Stack $" onkeydown="if(event.key==='Enter')postStack()">
            <button onclick="postStack()">Log stack</button>
            <input id="qBuyin" type="number" inputmode="decimal" placeholder="Buy-in $" onkeydown="if(event.key==='Enter')postBuyin()">
            <button onclick="postBuyin()">Add buy-in</button>
            <input id="qCashout" type="number" inputmode="decimal" placeholder="Cash out $" onkeydown="if(event.key==='Enter')postCashout()">
            <button onclick="postCashout()">Cash out</button>
          </div>
        </div>
  • Step 2: Add the quick-capture + rename JS functions

In the <script> of session.html, near saveEdit() (session.html:192), add:

    async function postQuick(url, amount, body){
      const r = await fetch(url, { method: 'POST', headers: {'Content-Type':'application/json'},
        body: JSON.stringify(body || { amount }) });
      const d = await r.json();
      if(!d.ok){ alert(d.error || 'failed'); return false; }
      refresh(); return true;
    }
    function numVal(id){ const el = document.getElementById(id); return Number((el.value||'').replace(/[^0-9.]/g,'')); }
    async function postStack(){ const v = numVal('qStack'); if(v) { if(await postQuick('/session/stack', v)) document.getElementById('qStack').value=''; } }
    async function postBuyin(){ const v = numVal('qBuyin'); if(v) { if(await postQuick('/session/buyin', v)) document.getElementById('qBuyin').value=''; } }
    async function postCashout(){
      if(!curSession) return;
      const v = numVal('qCashout'); if(!v) return;
      const r = await fetch('/session/'+curSession.id, { method:'PATCH', headers:{'Content-Type':'application/json'},
        body: JSON.stringify({ cash_out: v }) });
      if(!(await r.json()).ok){ alert('failed'); return; }
      document.getElementById('qCashout').value=''; refresh();
    }
    async function renamePlayer(id, current){
      const name = prompt('Rename player', current || ''); if(!name) return;
      const r = await fetch('/player/'+id, { method:'PATCH', headers:{'Content-Type':'application/json'},
        body: JSON.stringify({ name }) });
      if(!(await r.json()).ok){ alert('failed'); return; }
      refresh();
    }
  • Step 3: Add the rename control to the villains list

In session.html, find the villains render block in render(data) (it maps over data.villains / the villains array). For each villain item, add a rename affordance next to the name, using the player id field present on the villain row (commonly v.id or v.player_id — use whichever the bundle provides):

              <button class="mini" title="Rename / fix" onclick="renamePlayer(${v.id}, '${esc(v.name||'')}')"></button>

Read the existing villain block first to splice this in cleanly and confirm the id field name.

  • Step 4: Add minimal styling

In the inline <style> of session.html, add:

    .quick { display:flex; flex-wrap:wrap; gap:6px; margin-top:12px; }
    .quick input { width:96px; padding:7px 9px; background:#111; color:inherit; border:1px solid #333; border-radius:8px; }
    .quick button { padding:7px 11px; background:var(--accent,#ff7a18); color:#000; border:none; border-radius:8px; font-weight:600; }
    button.mini { background:transparent; border:none; color:#888; cursor:pointer; padding:0 4px; }
  • Step 5: Verify manually

With the app running and a live session, open /session:

  • Log a stack via qStack → sparkline + net update without a chat call.

  • Add a buy-in via qBuyin → "bought in" total rises.

  • Enter a cash-out via qCashout → session net updates.

  • Click ✎ on a villain, rename it → name changes after refresh. Confirm via curl -s localhost:7078/session/data.

  • Step 6: Commit

git add lyra/web/static/session.html
git commit -m "feat: HUD quick-capture (stack/buyin/cashout) + villain rename"

Task 7: iOS-PWA bottom safe-area gap fix

Files:

  • Modify: lyra/web/static/style.css (bottom nav / container safe-area)

Interfaces: none (visual fix). The empty band below the nav icons is the home-indicator inset not being consumed by #tabbar.

  • Step 1: Load the iOS-PWA skill

Invoke the building-ios-pwas skill and follow its guidance for safe-area / 100dvh handling before editing. The current #tabbar (style.css:921-952) applies env(safe-area-inset-left/right) and padding-bottom: 6px, but does not add env(safe-area-inset-bottom) — the likely cause.

  • Step 2: Apply the safe-area fix

In lyra/web/static/style.css, in the mobile #tabbar rule (style.css:921-929), change the bottom padding to consume the inset, and ensure the bar is pinned:

#tabbar {
  /* …existing flex/border rules… */
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  padding-bottom: calc(6px + env(safe-area-inset-bottom));
}

And ensure the chat scroll container reserves space for the bar so content isn't hidden behind it (match the container selector used at style.css:836-852):

@media (max-width: 768px) {
  #messages {
    padding-bottom: calc(64px + env(safe-area-inset-bottom));
  }
}
  • Step 3: Verify on device

Open the PWA (Add to Home Screen) on iPhone:

  • The empty band below the icons is gone; the nav sits flush above the home indicator.

  • The stack quick-capture box (Task 5) sits directly above the nav.

  • Open the keyboard: body.kb still hides the tabbar (style.css:952) and the input pins to the keyboard — confirm no regression.

  • If the gap persists or content clips, follow the building-ios-pwas skill's 100dvh/visualViewport guidance and iterate.

  • Step 4: Commit

git add lyra/web/static/style.css
git commit -m "fix: consume iOS home-indicator safe-area inset under bottom nav"

Self-Review

Spec coverage:

  • Complete API surface (create/update/delete per entity) → Tasks 2 (stack/buyin/start), 3 (hands), 4 (reads/players); existing PATCH/DELETE session + entry routes retained.
  • Single documented/versioned tool-API contract → Task 1 (poker_contract.py, CONTRACT_VERSION) + conformance tests (Tasks 1, 4).
  • Human UI to log + edit/correct → Tasks 5 (chat 2nd box), 6 (HUD quick inputs + villain rename + existing edit form/delete).
  • Pure capture never touches LLM → all capture goes through REST endpoints (Tasks 26); verified in manual steps (no chat bubble).
  • 2nd input box + PWA fix → Tasks 5, 7.
  • Non-goals respected: no classifier/prompts, no MI50 tool enablement, no MCP, buy-in stays scalar (add_buyin increments buy_in_total).

Placeholder scan: All code steps contain complete code. The one "locate the block" instruction (Task 6 Step 3, villain rename) provides the exact button snippet and names the id-field ambiguity to resolve by reading the file — not a placeholder, a grounded splice.

Type consistency: poker_contract.OPERATIONS shape is consistent across Tasks 1 and 4; REST paths in the contract (/session/stack, /session/buyin, /session, /session/hand, /hand/{hand_id}, /session/read, /player/{player_id}, /session/{session_id}) match the routes added in Tasks 24 exactly; update_hand/update_player signatures match their callers; response shapes ({ok, stack}, {ok, buy_in_total}, {ok, id}, {ok, hand}, {ok, player}) are used consistently in tests and routes.

Open implementation note: Task 6 Step 3 requires reading session.html's villain render to confirm the player id field name (v.id vs v.player_id) before splicing the rename button.