Resolved conflict in types.ts: dropped the duplicate EntityTraits
definition (D and E both added it identically) and combined both
workstreams' new ServerFrame variants (tell/ritual_complete from D,
judgment_result from E). 223/223 frontend tests pass, tsc clean.
Third sub-project: physical hardware (ESP32-P4, presence + BME280 env
sensors, experimental RTL-SDR USB-host module) pairs with a user's account
and streams telemetry that feeds the SAME anomaly/summon pipeline the
browser-based modes already use — not a passive dashboard, the actual
business model (selling devices that summon spirits). Defines device
pairing/auth (reusing the existing session-token hash convention),
a generic/extensible sensor-reading shape, and the live-broadcast +
anomaly-detection contract split across 5 workstreams (G/K backend,
H frontend, I/J firmware).
App-shell HUD (mounted in App.tsx alongside HauntingLayer, visible on
every screen) that shows ambient idle status until a séance is active,
then streams `tell` WS frames as terminal-style log lines with a
"hacker witch" crescent-arc evil-meter gauge (occult sigils/runes fused
with Transcript.tsx's monospace log vocabulary).
- lib/evilMeter.ts: pure function computing a malevolent<->benevolent
belief from the accumulated tell history — starts wide/uncertain,
narrows geometrically and shifts per tell (deterministic hash of the
tell text, since tells never leak ground truth), and snaps to
definitive certainty on a successful ritual_complete's
revealed.alignment. Fully unit tested (narrowing, ordering,
determinism, ritual override, idle/empty history).
- lib/ghostLogBus.ts: tiny typed event bus (mirrors lib/haunting.ts's
HauntBus) so the app-shell-level GhostLog can react to live séance
frames — SeanceProvider is only mounted inside the séance route, so a
shell-level sibling can't read its context directly.
- state/seance.tsx: publish entity/tell/ritual_complete onto the bus,
and session_end on provider teardown so the HUD falls back to idle
when the seeker leaves the séance page.
- lib/types.ts: add the `tell` and `ritual_complete` server frames from
the Character Depth spec's Contract section (only what this
workstream consumes).
- components/GhostLog.tsx/.css: the HUD itself, plus i18n keys in
en.json/es.json.
168/168 frontend tests pass (137 pre-existing + 20 evilMeter + 11
GhostLog); tsc -b and the i18n coverage pretest are clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Workstream F of the character-depth/ghost-log spec.
- InventoryPanel.tsx: essence balance, owned unlocks/items (terminal
listing + Codex-style rarity-glow borders on items), and a buy flow
for a small fixed unlock catalog (currently just "listening_tool",
the only key the contract names) with afford/can't-afford button
states. Prices are fetched from a best-guess /api/inventory/catalog
endpoint and fall back to a flagged "(est.)" price sourced from the
spec's own worked example when that endpoint isn't available yet —
the contract doesn't define a price-list REST shape.
- SigilDesigner.tsx + lib/sigil.ts: constrained geometric builder —
points snap to 24 fixed clock-face slots around a circle, capped at
12 to match the backend's payload limit, connected in placement
order. Five hand-drawn stroke-only rune glyphs (eye/crescent/key/
spiral/thorn) overlay the center. Point-cap enforcement, rune
selection and payload-shape building are pure functions in
lib/sigil.ts, unit-tested directly. Saves via POST
/api/inventory/sigils (path inferred; payload shape matches the
contract exactly: {"points": [[x,y],...], "rune": str}). Art
direction: the builder itself reads like a plotting/debug tool
(crosshair cursor, monospace coordinate HUD) while the rendered
lines + rune glow violet, consistent with GhostGlyph's conventions.
- lib/evp.ts: new evpThresholdDb(hasListeningTool) pure function and
EvpListener.start() now accepts { hasListeningTool } to lower the
EVP anomaly threshold (8dB -> 4dB) when the unlock is owned — wired
from useAuth().user.unlocks in SeancePage's EvpPanel, a real
gameplay effect on which faint signals register as anomalies.
- api.ts User type gains unlocks/essence per the contract's /auth/me
extension; new InventoryPage.tsx mounts both components behind auth
at /inventory, linked from the séance nav.
166/166 tests pass (137 pre-existing + 29 new), i18n coverage check
clean, tsc -b clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Implements the Character Depth / Ghost Log spec's Workstream E:
- RitualPanel.tsx: a 4-step "focus the channel" hold-to-charge sequence
(align/breathe/trace/lock), sending ritual_step frames as the seeker
progresses. Success reveals the entity's true hidden traits; failure
reveals nothing. Sequencing/timing logic lives in the pure, unit-tested
lib/ritual.ts rather than inline in the component.
- JudgmentPanel.tsx: Trust/Banish/Cross Over/Test verdict buttons with
rune-style SVG icons, sending the judgment frame and rendering a
distinct treatment per judgment_result consequence — reward,
escalation (also spikes the ambient haunting), withdrawal, resisted,
neutral, and a calm glyph-fade farewell for crossed_over (deliberately
not the reward treatment, since it's a goodbye).
- lib/haunting.ts: IdleEscalator gains forceEscalate()/forcedUntil so a
judgment's "escalation" consequence can spike the ambient haunting
immediately instead of waiting on the 90s idle clock; exports a
sharedIdleEscalator singleton and a forceEscalate() free function.
HauntingLayer now paces itself off that shared instance instead of a
private one, so the forced spike actually reaches the running layer.
- state/seance.tsx: new ritual/judgmentResult state, a local_ritual_start
action, and reducer cases for the ritual_complete/judgment_result server
frames; SeanceContext exported for component testing; startRitual/
sendRitualStep/sendJudgment added to the provider API.
- lib/types.ts: EntityTraits/JudgmentVerdict/JudgmentConsequence types and
the new client/server WS frames, per the spec's Contract section.
- i18n: seance.ritual.* / seance.judgment.* keys in en.json and es.json.
Fast-forwarded this worktree's branch onto master first — it had been
created from a stale ancestor commit predating the frontend scaffold
entirely, with zero commits of its own ahead of that point.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Entity.traits (alignment/power/volatility/deceptiveness, 0.0-1.0 each) is
rolled once at mint time in entities.py, seeded from the entity's
signature via random.Random(f"traits:{signature}") — a separate rng
namespace from normalize_profile's existing "norm:" rng, and never fed
into mint_prompt, so persona text stays fully decoupled from ground
truth. normalize_profile now includes "traits" in its returned dict;
fallback_profile inherits it for free since it already delegates to
normalize_profile.
Adds the new JSONB column to the Entity model (default {}) and the
idempotent `ALTER TABLE entities ADD COLUMN IF NOT EXISTS traits ...`
migration line to main.py's lifespan, per the live-Postgres migration
convention this spec introduces (no Alembic in this repo).
Tests cover trait value ranges, signature-determinism, and
persona/trait independence (same persona template pairs with a wide
spread of alignment rolls across signatures), plus a regression check
that mint_prompt's signature never grows a traits parameter.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Renaming: "Armory" read too militaristic for a séance app. Landed on "The
Reliquary" (not "The Threshold" — that name was already taken by the
landing-page back-link).
Spec addendum: added a visible essence currency (earned per summon, spent
on unlocks — distinct from the hidden favor score) and a fourth judgment
verdict, cross_over, for compassionately helping a genuinely benevolent
"stuck" spirit move on rather than just trusting or banishing it. Updates
workstreams B/C/E/F accordingly before any of them are dispatched.
First sub-project of the "make contact feel real" arc (spec:
docs/superpowers/specs/2026-07-23-possession-presentation-design.md).
Direct Contact replies now feel like a spirit fighting through static to
hold the channel rather than a plain chat bubble:
- backend/app/possession.py: compute_stability(rarity, magnitude, rng) —
a 0.05-0.98 score per reply (rarer entity + stronger triggering anomaly =
cleaner signal), rng injectable for a later quantum-RNG source.
- ws.py sends stability on reply_start; audio synthesis for that reply gets
noise/bitcrush scaled by instability (1 - stability) via a new
instability param on synthesize_spirit_voice — effects.py itself is
untouched, only the params fed into it.
- frontend/src/lib/possession.ts: renderPossessedText — pure, deterministic
(tick-seeded, no Math.random) text corruption with self-correcting
glitch bursts, wired into Transcript.tsx's streaming reply display.
Stored transcript/reply text is unaffected — this is presentation only.
78/78 backend, 137/137 frontend tests passing.
First sub-project of the "make contact feel real" arc (candle rituals,
quantum RNG, tuning, progression, escalation to follow as separate specs).
Defines the stability-score contract shared between the backend audio
degradation and frontend text-glitch halves so they can build in parallel.
The gap-g-readme merge commit (57a8914) staged this fix but never
re-staged it after editing, so the merge landed with the pre-fix content —
the working tree had the correction but git didn't. No functional change,
just closing the gap between what was intended and what was committed.
Took Gap G's version (comprehensive rewrite) over Gap A's smaller README
edit, then manually stripped the 4 remaining SESSION_SECRET mentions Gap G
didn't know about (Gap A dropped that config field entirely) — README now
correctly lists only DATABASE_URL and OLLAMA_BASE_URL as required.
websocket.client.host is always the Cloudflare Tunnel machine's LAN IP for
every internet-facing connection (the tunnel runs on a separate machine and
terminates TLS there), which collapsed per-IP rate limiting into a single
shared bucket for all remote visitors — the exact gap flagged in review.
Cloudflare's edge sets CF-Connecting-IP itself, overwriting any
client-supplied value, so it's safe to trust when present. Falls back to
the raw socket peer for direct LAN/local access.
auth_sessions rows were never deleted after expiry, only rejected
on read. Adds a background sweep (every 30 min) in the app lifespan,
plus a tested pure delete_expired_sessions() function.
Session security already comes from a cryptographically random
256-bit token (secrets.token_urlsafe) hashed before storage —
SESSION_SECRET was required config that nothing ever read.
StaticFiles defaults to check_dir=True, which raises at import time if
frontend/dist/assets is missing on restart — taking down /healthz and
/auth/* along with the frontend. Pass check_dir=False so the mount never
crashes the app, and make the SPA fallback return a clear 503 instead of
an unhandled 500 when index.html is absent.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013PphXq1s43DNRj1uWKGXof
Reordered to put a real browsable site first (React frontend served
by FastAPI, Tasks 1-2) ahead of backend-only plumbing (Ollama queue,
Piper TTS, WebSocket session channel, Tasks 3-5) that Plans 3-6 will
build spirit-mode logic on top of.
Added test_hits_expire_after_window_elapses() which uses unittest.mock.patch
to deterministically advance time and verify that expired hits are evicted from
the rolling window. This exercises the while loop in RateLimiter.allow() that
was previously untested.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013PphXq1s43DNRj1uWKGXof
httpx's cookie jar only auto-attaches Secure cookies to https:// requests.
Switching the ASGITransport client fixture's base_url from http://test to
https://test (no real socket is opened either way) makes it behave like a
browser talking to the Cloudflare-Tunnel-terminated HTTPS edge in
production, eliminating the need for manual client.cookies.set(...)
re-injection workarounds in test_auth.py.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013PphXq1s43DNRj1uWKGXof
Addresses three Important-severity review findings inherited from Task 4's
plan reference code:
- login() now sets secure=True on the session cookie (safe behind the
Cloudflare Tunnel, which terminates TLS at the edge).
- logout() looks up and deletes the matching AuthSession row before
clearing the cookie, so a leaked raw token can no longer be replayed
after logout.
- login() always performs exactly one verify_password call regardless of
whether the username exists (against a module-level dummy hash for
nonexistent users), removing the timing oracle that let unauthenticated
requests distinguish registered from unregistered usernames.
Adds two tests: nonexistent-username login rejection, and logout revoking
the session server-side. Also adjusts two cookie-propagation touch points
in test_auth.py to manually re-inject the qm_session cookie, since
httpx's cookie jar won't auto-attach a Secure cookie to the test
transport's plain http://test base_url (a real browser talking to the
HTTPS tunnel edge wouldn't have this problem).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013PphXq1s43DNRj1uWKGXof
Production code shouldn't branch on `"pytest" in sys.modules` — it's
fragile and couples db.py to test tooling. Instead, conftest.py now
builds its own dedicated NullPool engine directly from settings, used
only for the drop_all/create_all reset and the get_db override. The
production engine in app/db.py is untouched and never exercised during
tests, so this fully preserves the event-loop fix while keeping prod
code test-agnostic.
Task 3's conftest.py (per plan) reuses the module-level app.db.engine
singleton across every test. pytest-asyncio 0.24 gives each test function
its own event loop by default, and asyncpg connections are bound to the
loop they were opened on. Pooling a connection from a prior test's loop
made subsequent tests fail with "got Future attached to a different loop"
as soon as more than one DB-touching test ran in the same session.
NullPool is applied only when running under pytest (detected via
sys.modules), so production keeps normal connection pooling and only the
test suite pays the cost of a fresh connection per checkout.