Brings main current with the full dev line — poker ledger reliability
(idempotent/guaranteed hand logging, straddle capture, hero-stack
auto-fill, turn-id fire-and-forget), the persona 'How you talk' voice
rewrite, and the mi50 runaway guards.
Brings the 28-commit poker line onto dev — idempotent/guaranteed hand
logging, straddle capture, hero-stack auto-fill, turn-id fire-and-forget,
plus the merged persona 'How you talk' rewrite. Clean merge (disjoint
files vs dev's mi50-summary-cap-fallback tip).
Lands the persona voice rewrite onto the active line. Disjoint from the
poker ledger work (persona.py / personas/lyra.md / persona eval vs.
chat/poker + tests), so a clean no-conflict merge.
Brian fires a quick message then locks his phone to go play the hand — which drops
the SSE stream, and on wake the UI re-fires via the blocking fallback. The prior
(session, message) + 20s window caught the near-simultaneous case but not a re-fire
minutes later.
Now the UI stamps each send with a unique turnId (crypto.randomUUID) and carries the
SAME id on both the stream and the fallback; the server dedupes on it. Bulletproof
regardless of how long he's away — lock for an hour, come back, still exactly one
execution and one log — and a genuinely new send gets a fresh id so nothing legit is
swallowed. Id-keyed turns keep a long (1h) window; requests without an id keep the
short (session, msg) window for near-simultaneous dupes.
Verified end-to-end: two POSTs with the same turnId → one reply, one persisted
exchange pair (the duplicate reused the owner's result). 9 dedup tests; suite 235.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the roster→hand seat/name resolution feature (needs seat+button tracking, so
it's a real feature not a fill) and records the 2026-07-11 shipped fixes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The UI POSTs the SSE stream and, when nothing streams to the browser (iOS can't
read a fetch-stream body → the fetch throws in ~1s), falls back to the blocking
endpoint. But the server-side stream runs to completion regardless, so BOTH turns
executed — double-persisting the message and (once logging became guaranteed)
double-logging the hand.
Make a turn idempotent instead of chasing why the client bails: the first request
for a (session, message) owns it; a concurrent duplicate waits on the owner's
Event and reuses its reply rather than running a second full turn. respond and
respond_stream both claim/await; a finally always releases waiters. Short window
so a genuine later resend still runs fresh. Verified with a threaded race: two
simultaneous calls, body runs once, both get the same reply.
Also fixes the duplicate user-message persistence (the same double-execution) that
was polluting reconstructed history. 6 dedup tests + concurrency check; suite 232.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a hand doesn't state hero's stack, default it to current_stack() (his last
logged stack) — the system already knows it from the stack log even when he doesn't
restate it every hand. record_hand._fill_hero_stack sets the hero player's stack and
marks stack_inferred=True (honest about stated vs inferred); a stack given in the
hand text always wins, and observed hands get nothing. 3 tests; suite 226 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Verified the parser captures a straddle from every non-blind seat (UTG..BTN, 7/7),
so no behavior change — but the prompt only named UTG/button examples. Spell out
that a straddle is legal from ANY non-blind seat (Mississippi/any-seat straddle,
common at the Meadows) and state the open-action seat per straddle position, as
insurance against model drift.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two issues from live testing:
- Double-logged hand. The chat turn can execute TWICE — the SSE stream and the
blocking fallback both run server-side (two 'chat request' lines, 1s apart) — a
pre-existing double-execution (it also duplicated user messages) that the new
logging guarantee turned into duplicate HANDS. record_hand is now idempotent:
_recent_duplicate_hand returns an identical hand (same session, hole cards, board;
NULL-safe) recorded in the last few minutes, so the second run reuses it instead
of inserting. A system-of-record records an event once.
- Button straddle dropped. The parse prompt had no straddle logic. Added a STRADDLES
rule: record any straddle as a preflop `post` by the straddler with its amount and
respect the action order (button straddle acts last preflop, action opens in the
SB; UTG straddle opens to its left). Verified: a btn-straddle hand now parses the
straddle as {pos: BTN, action: post, amount: 6}.
Note: the underlying double turn-execution (stream + fallback) is a separate web-layer
bug worth fixing at the source — it wastes a full LLM turn and still double-persists
chat messages. Filed for a follow-up. 6 tests; suite 223 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Four tasks: demote stale 'Right now' out of _CORE, rewrite 'How you talk'
in-voice with the 4 anti-tic rules + real exemplars, trim hedgy prose
for a leaner hot path, and a replay eval on the exact safe-trigger
prompts. Runs via `uv run` from the worktree (shared venv resolves to
the wrong checkout).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G796GsLCvJQKVN7hwV2cDx
Logging a stated hand was unreliable and got worse mid-session: same model, same
hand, clean history logged 4/4 but the real session's history logged 0/4. Root
cause: memory.recent() rebuilt past turns as "hand -> narration" with the tool
calls stripped (they live in tool_events), so the model's own context became
few-shot examples training it, mid-conversation, to STOP calling tools. Even a
maximal "LOG FIRST, no exceptions" prompt scored 0/5 — it's structural, not wording.
Two-part fix (both, per the system-of-record frame):
- A (guarantee): chat._ensure_hand_logged — on a HAND turn that's Brian's OWN hand,
if the model didn't log it, force record_hand (tool_choice). Guarded to hero hands
(looks_like_hero_hand) so an observed hand is never force-logged as his. Adds
tool_choice passthrough to llm.chat_call; surfaces msg_type on TurnContext.
- B (heal forward): mind._history_with_tools makes each assistant turn's tool calls
visible in reconstructed history ("record_hand -> Hand #62"), so the demonstrated
pattern stops being "hand -> narrate". Recovers natural logging as logs accumulate.
Verified: force guard returns record_hand on the polluted context; full respond_stream
logs Hand #63 end-to-end on a clean session. 7 guard tests; suite 219 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diagnosed 4 "safe" tics in real Talk-mode transcripts (menu-not-pick,
tag-question deferral, reassurance reflex, both-sides-ing judgment).
Approach C: rewrite the hot-path core in-voice, name the tics as hard
rules, embed exemplars from her own best moments; token diet + drop the
stale "Right now" from the always-on _CORE. Files: personas/lyra.md,
persona.py.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G796GsLCvJQKVN7hwV2cDx
The HAND fragment let her narrate a finished board from memory: she called
quad kings "kings full" and never noticed Brian FLOPPED quads, then wrapped it
in variance-evens-out / resilience filler. Two fixes to _F_HAND:
- Correctness: at a showdown where both hands are known, call analyze_spot on
the full board to confirm made hands + winner BEFORE commenting; name his hand
class by the street it mattered ("flopped quads"). The eval already existed —
she just never reached for it on a resolved hand. (It also catches impossible
cards, e.g. a villain card already on the board.)
- Register: ban reflexive praise / variance-evens-out / life-lesson / cross-hand
pep talk; if there's genuinely no leak, say so instead of inventing a takeaway.
Surgical here; the full voice pass stays with the persona branch.
Verified live on the exact quad-kings hand (cloud/gpt-4o-mini): she now logs,
calls analyze_spot, reads quads-vs-quads correctly, and calls it a cooler with
no leak. Guard tests added. Full suite 212 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both are real, half-built features to revisit later (not cruft): hand-recorder
(tap-to-build V1, superseded-for-now by chat-narration record_hand) and
decision-log (Decide-mode learning layer, never merged). Both pushed to origin
so they survive dormant. Also noted the retired branches: thought-loop (shipped),
feat/prompting + feat/poker-mode-prompts (renamed to feat/poker).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Live-session bug: the chat model called record_hand but filled log_hand's
granular fields (position/hole_cards/board/streets), leaving `shorthand` empty →
the parser got nothing → "I couldn't parse that hand". The fragment/tool choice
was correct; only the argument shape was wrong.
- _record_hand now reconstructs a parseable description from the granular fields
when `shorthand` is empty (_shorthand_from_fields), so it works regardless of
which schema the model uses. Explicit `shorthand` still passes verbatim.
- Sharpened record_hand's spec: pass the ENTIRE hand as ONE `shorthand` string,
not split fields (that's log_hand); shorthand is required + non-empty.
4 tests. Full suite 210 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Nail down which backend each LLM path uses, and make the dream cycle resilient to
a backend being down (the MI50 outage left profile/era/narrative — pinned to mi50
with no fallback — aborting every dream cycle).
- Consolidation (summaries + profile/era/narrative) -> cloud via SUMMARY_BACKEND=
cloud (.env, not committed). Matches the documented lesson that the MI50 is too
slow/hot for bulk consolidation; nothing background touches the card now.
- llm.complete_with_fallback(): try the primary backend, fall back to cloud on
error (re-raise if already cloud / no key). Wired into reflect + think so the
introspection voice (3090/dolphin) survives the gaming PC being powered off.
- dream coherence stage is now fault-isolated: a rebuild failure logs + continues
instead of sinking the whole pass (reflection still runs).
- .env: removed stale INTROSPECTION_BACKEND=mi50 (live routing is the web-switchable
introspection_mode DB setting = dolphin/3090; the var only fed a dead fallback).
Verified: forced cycle runs consolidation on cloud, introspection on the 3090,
completes with zero MI50 calls. 206 pass, ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015yrEb5qpPGv2FjyxrB7LLk
Enabling MI50 function-calling is now a config flip, not a code change:
cfg.tool_backends (env TOOL_BACKENDS, default "cloud") drives which backends get
tool specs. Once the MI50 llama.cpp server runs with --jinja + a tool-capable
model, set TOOL_BACKENDS="cloud,mi50" and MI50 chat drives the same tool contract
as cloud. Default unchanged (cloud-only), so this is safe with the MI50 down/
unverified — no live flip made (server is currently offline; --jinja unconfirmed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Probed the heuristic against live-style messages and fixed 6 real gaps:
- action verbs missed -ing forms ("TAG's been limping") → no READ
- the descriptor-subject regex required words between "the" and the noun, so
"the whale called" missed → now handles bare "the <noun>"
- player departures ("TAG busted", "TAG left") weren't TABLE → added _DEPART
(gated to non-first-person so "I busted, heading home" isn't a roster op)
- the bare word "stack" made strategy questions ("should I stack off?") classify
as LOG → LOG now needs a number/result word AND excludes questions
- thin MENTAL lexicon → added coolered/sick/brutal/run bad/etc.
21-case probe (13 former misses + 8 regression guards) all green; folded into the
suite as 5 hardening tests. Full suite 201 green. classify() stays the swappable
seam for an LLM/MI50 upgrade later.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ~100-line card was superseded by the sharded poker_prompts (BASE + fragments)
and kept only as distillation reference. Fragments are proven in tests + a live
session; remove the dead source. No functional refs remained (card="").
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A single map of done/next/parked, organized by area (prompting, persona, tool
self-knowledge, pokerlog separation, logger features), framed by the
pokerlog-as-separable-system-of-record decision. Captures the two new asks:
streamline the persona's "How you talk" section (~180 tok of fat) and a broader
persona review, plus the stale "Right now" demotion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
build_messages now shards poker_cash: injects poker_prompts.BASE + exactly ONE
fragment chosen by classify(user_msg, roster_handles), replacing the ~100-line
_CASH_CARD monolith. Roster handles are fetched fail-safe from the live session so
the READ-vs-HAND split is reliable. CASH.card set to "" (monolith kept only as the
distillation source, superseded). Other modes' single-card path unchanged.
Verified end-to-end: TAG-limp→READ, his-hand→HAND, table-broke→TABLE, tilt→MENTAL,
bare-stack→LOG, all with BASE always present. 5 injection/pipeline tests. Full
suite 196 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The standalone piece, not yet wired. lyra/poker_prompts.py:
- classify(user_msg, roster_handles=()) — pure 7-type classifier
(READ|HAND|TABLE|MENTAL|STATUS|LOG|CHAT), READ ranked above HAND so a villain's
action lands on their file not Brian's. Roster-aware (seated handles passed in),
handles poker shorthand (AKs) and strategy questions (→ CHAT not a logged hand).
The swappable seam for an LLM/MI50 classifier later.
- BASE — lean always-on poker rules distilled from the monolith (log-first + tool
routing, identity rules, rituals, equity, session_state).
- FRAGMENTS + fragment_for — per-type response-shape contracts.
13 unit tests incl. the READ↔HAND boundary. Full suite 193 green. Wiring next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per docs/superpowers/specs/2026-07-01-poker-prompts-design.md, Phase A. Two
independent pipeline fixes that reduce mush at the table, ahead of the classifier:
1. _mode_menu_note is no longer injected in poker_cash — mid-session she should
not be offering to switch modes.
2. _route skips the register/mood nudge in poker_cash — the lexicon heuristic
misfired (neutral logistics like "table broke, 11:50pm" read as tilt/fatigue).
Poker register will come from the Phase B MENTAL fragment instead. Non-poker
modes keep the nudge unchanged.
2 tests. Full suite 180 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The message-type-prompts plan (sub-project 2) predated the scouting desk + roster
build on this branch. Readjust it so the prompting work starts from reality:
- Add a "Readjustment 2026-07-04" section: the real failure shifted from mush to
MISSED TOOL CALLS; two new message types (READ, TABLE); HAND gains a
hero-vs-observed split; the scouting desk is a live per-turn injection layer to
compose with (not duplicate); BASE must cover the expanded toolset + identity
rules; source card grew to modes.py:67-169; Phase A still unbuilt.
- Taxonomy → READ | HAND | TABLE | MENTAL | STATUS | LOG | CHAT, with READ above
HAND (a villain's action lands on their file, not Brian's).
- New READ + TABLE fragments; BASE routes the full current tool set; STATUS
narrowed to pure logistics; classify tests add READ/TABLE + the READ↔HAND edge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Independent Proxmox-host backstop to the in-app dream budget: a systemd timer
runs every ~2 min and stops lyra-brain if the MI50 is busy >=1hr continuously OR
junction >=97C for ~6 min, then pings Brian via ntfy. Trips on duration only
after a full hour so a legit ~40-min manual workload runs untouched. GPU temp/use
read from host rocm-smi; stop via 'pct exec 202 -- docker stop'.
Parsing + duration/temp decision logic dry-run-verified locally against real
rocm-smi output format (4 scenarios). NOT yet installed/live-verified — card is
off and Brian's away; install + trip-test per deploy/mi50-watchdog/README.md when
it's back.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015yrEb5qpPGv2FjyxrB7LLk
Belt-and-suspenders so no dream pass can run unchecked for hours:
- llm.complete() now always bounds the OpenAI/mi50 request: default 300s +
max_retries=0 instead of the SDK's 600s x2 (~30 min). One change bounds every
consolidation/introspection call (profile/era/narrative/reflect/think), not
just summaries. Live chat (chat_call*) is a separate path, unaffected.
- dream_cycle() enforces a 20-min wall-clock budget, checked between stages;
once past it, remaining stages are skipped, it logs 'stopped early (over
budget)', and notify.push() pings Brian. Paired with the host watchdog (A) as
an independent fallback.
Tests: default timeout/max_retries threaded into complete(); an over-budget pass
skips later stages + pings. 178 pass, ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015yrEb5qpPGv2FjyxrB7LLk
Observed live: an overheated MI50 returns a single char repeated ("?????") as a
successful 200, which neither the timeout nor the exception fallback catches — so
a degraded GPU would silently save capped garbage gists. Validate each summary
call's output: flag text (>=24 non-space chars) whose most-common non-whitespace
char exceeds 50%, raise DegenerateOutput, and let the existing retry->cloud
fallback handle it. Real prose (top char <20%) won't false-positive; short output
is exempt; cloud garbage raises rather than looping.
Tests: _looks_degenerate flags repeated-char / passes real prose / ignores short;
degenerate MI50 output falls back to cloud; cloud garbage raises. 177 pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015yrEb5qpPGv2FjyxrB7LLk
The dream cycle's summarize_all ran uncapped against the MI50: no max_tokens
and no timeout, so the OpenAI SDK's 600s x2-retry default meant ~30 min per
call. Combined with summary.py's own retry loop, one unsummarizable session
pegged the GPU for hours (observed 2026-07-04: stuck since 23:02, nothing saved
since 00:56, 7-8k-token runaway generations, all 4 llama.cpp slots busy). Not
context overflow (0 shifts/truncations) - purely unbounded length on a slow
backend timing out and retrying.
- llm.complete(): add optional max_tokens (caps generation; num_predict for
Ollama) and timeout (bounds the request and sets max_retries=0 so the caller
owns retry policy). Both default None -> unchanged for every existing caller.
- summary.py: cap gists at 768 tokens, 150s/call fast-fail, 2 MI50 attempts
then one cloud fallback (when primary isn't already cloud and a key exists).
Known limitation (scoped out per decision): the fallback triggers on
timeouts/exceptions, not on a degraded backend returning garbage as a 200.
Tests: fallback fires after 2 MI50 failures; no fallback when primary is cloud
or no key; cap+timeout threaded into every complete() call; llm bounds tests.
172 pass, ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015yrEb5qpPGv2FjyxrB7LLk
"table broke", "I got moved", "switched tables", "new table" etc. all mean clear
the roster — spell them out in the Cash card (esp. "table broke" jargon) so it's
reliable, not dependent on her inferring it from "changes tables".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
"Clear the table" had no tool behind it, so she claimed she did it and nothing
changed. Add clear_table (empties the roster, keeps the session/stack/reads) and
a `replace` flag on seat_players for a one-shot table swap. Cash card: on a table
change / "clear the table", call clear_table then seat the new table — never claim
it without calling the tool.
2 tests. Full suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- The roster card's empty-state string had a broken apostrophe escape
("who\\'s") that terminated the string early — a syntax error that killed the
whole session.html script, so the HUD only rendered from a stale cached shell.
Reworded to drop the apostrophe.
- Add a middleware that sets Cache-Control: no-cache on HTML/JS so a PWA can't
keep serving a stale shell after a deploy (iOS heuristically caches when no
cache header is present — the reason a hard refresh + reopen didn't update).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The missing backbone for read tracking: a place for "who's at the table" to live.
When Brian reads the table off Bravo (handles like TAG), Lyra registers them as
seated this session; reads/TAGs then attach to those players by handle instead of
spawning duplicates or getting missed.
- session_players table; seat_player/seat_players/unseat_player/session_roster;
_resolve_or_create_player (shared name/descriptor resolution, dedupe guard).
- tools seat_players (accepts objects or a plain name list) + unseat_player.
- HUD gains `roster`; Session page shows a 🪑 Table card (seat, handle, category,
read count, last read).
- Cash card: capture the roster when he names the table; a Bravo handle like TAG
is a PERSON, seated as a player — never the tight-aggressive style.
5 tests. Full suite 162 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Prior commit misread "TAG" as an imperative ("tag this on his file"); it's
actually a player's handle (his initials). Rewrite the Cash-card rule around the
real gap: any "<player> did X" (limped/called/raised/shoved) is a read →
add_read log-first, every time. Player names are often short handles/initials
(Tag, JD, Wheelz) — use whatever he calls a person as-is, never as a poker term.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brian tracks who's limping by messaging "TAG <player> limped A4o in the SB". She
was treating these as chat, not logging them — and "TAG" is ambiguous (reads as
the tight-aggressive player type). Cash card now makes TAG an explicit order to
add_read on that player, log-first, covering limps/calls/raises/sizings/showdowns;
a bare "X limped" counts too. Names given at session start are the roster.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause of "4 entries for the same person": physical descriptions were being
passed as `name`, creating a new *named* player each time the wording drifted
(exact-name match can't dedupe near-identical sentences, and the merge scan only
looks at descriptor embeddings).
- add_read: a `name` that looks like a description (comma-listed / long / has
appearance words) is rerouted to the descriptor path so it dedupes.
- descriptor reads that are ambiguously close to an existing villain now file a
merge_candidate to the review queue instead of leaving a silent duplicate.
- distinctiveness() reworked: recognizes specific content (proper nouns/brands,
feature lists) as distinctive even when a generic word like "shirt" is present —
the old list-only heuristic scored "Filipino, Fox Racing hat, DKNY shirt" as
generic and gated it out.
- Cash card: name = real handle ONLY; the look goes in descriptor as a few
distinctive tags, and use name_villain to fuse a name onto a described player.
Full suite 157 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses "no way to edit hands" and cleaning up misattributed ones:
- hand viewer (/hand/{id}) gets an "✎ Edit this hand" panel: position, cards,
board, your net, tag, lesson → Save (existing PATCH), plus Delete.
- "Not my hand" → POST /hand/{id}/disown → poker.disown_hand clears the flat hero
fields and rewrites structured with hero_involved=false, so a hand mislabeled as
Brian's becomes a clean observed hand (replay stops showing him as hero).
1 test. Full suite 156 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When Brian narrates a hand he watched between OTHER players, the parser was still
filling hero_pos/hero_cards — pinning someone else's cards, position, and result
to him. Now:
- parser prompt adds hero_involved detection: fill hero_pos/hero_cards ONLY if he
was actually in the hand; a hand he only watched has hero_involved=false and
null hero fields, with the other players recorded normally.
- normalize_structured enforces it as a safety net (hero_involved=false → null
hero_pos/cards, hero_net) even if the model slips.
- record_hand tool confirms an observed hand as "not yours" instead of implying
it was his.
2 tests. Restarting web for the live session.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phases 5 & 6, completing the scouting desk.
Phase 5 — the nightly consolidation (dream cycle, coherence block) now runs
poker.scan_merge_candidates(), filing likely same-person merges to the review
queue off the hot path. Fail-safe: a scan error never sinks the cycle.
Phase 6 — "you've hit this leak before". Scar/confidence notes are embedded on
write; recall_similar_rituals() finds past ones close to the current spot,
excluding tonight. The scouting desk adds a pattern pass that surfaces them — but
ONLY on genuine strategy/tilt talk (a length + cue gate), so routine logging like
"stack 350" never pays for an embed. Respects the per-turn latency concern.
7 tests (deterministic embed stub to keep threshold assertions stable). Full
suite 153 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>