Skip to content

feat(promotion): generalized DL-2 entity promotion gate#116

Closed
spuentesp wants to merge 55 commits into
masterfrom
worktree-entity-promotion
Closed

feat(promotion): generalized DL-2 entity promotion gate#116
spuentesp wants to merge 55 commits into
masterfrom
worktree-entity-promotion

Conversation

@spuentesp

Copy link
Copy Markdown
Owner

Implements the Generalized Entity Promotion Plan from docs/2_architecture/entity_promotion_architecture_plan.md.

Replaces lore-specific entity-promotion heuristics with a deterministic, system-agnostic gate:

  • Narrator prompt now enforces [Name](entity:anchor) / [Name](entity:flavor) tagging with few-shot examples.
  • ProposedChange schema gains a PromotionIntent enum, interaction_count, and is_mechanically_bound fields.
  • monitor_agents.loops.entity_promotion parses tags from narrator prose, merges intents into LLM-extracted proposals, bumps interaction counts, and detects mechanical binding from resolution / combat / inventory payloads.
  • Scene loop wires the parser into extract_new_entities and the mechanical-binding pass into persist_turn_artifacts.
  • CanonKeeper.evaluate_proposals runs a pre-LLM deterministic pass:
    1. Topology — entities named by RELATIONSHIP proposals are auto-ACCEPTed ("graph integrity laws").
    2. State-gated — entities named by STATE_CHANGE / EVENT proposals are auto-ACCEPTed ("mechanically bound").
    3. Intent-gated — (anchor) is auto-ACCEPTed.
    4. Threshold-flush — (flavor) with interaction_count > 3 graduates to anchor; (flavor) below threshold is garbage-collected.
  • Docs updated in docs/2_architecture/data_model_workflow.md (§6) and docs/4_ontology/_index.md.
  • Tests: 20 new tests in test_entity_promotion.py. Full agents suite (1130 tests) still green.

Closes the DL-2 generalized promotion work item.

Alumno and others added 30 commits June 22, 2026 20:31
Single-commit consolidation of the project: history squashed to drop the 256 MB
Neo4j backup, divergent throwaway commits, and any leaked credentials.

This tree includes the v1.1 work: multi-database ingestion pipeline (PDF/structure
extraction, embeddings, analyzer, knowledge packs), CanonKeeper canonization,
non-hardcoded game-system extraction + character creation, auto-roll resolution,
data-driven resource economy, and multi-scene solo play — validated end-to-end
against Vampire: The Masquerade (108 MB corebook) and Death in Space.

Secrets scrubbed: removed personal .continue/ IDE config, redacted hardcoded
z.ai keys (seed_zai_provider.py, env.example) to env-var lookups. test_logs,
coverage artifacts, and *.xls excluded via .gitignore. .env stays untracked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* more npc wiring

* cleanup

---------

Co-authored-by: Alumno <alumno@ejemplo.com>
- Add missing wired CLI commands: play, manage, universe
- Fix docs paths: docs/architecture/ → docs/2_architecture/, docs/ontology/ → docs/4_ontology/
- Remove reference to non-existent docs/archive/ directory
- Remove reference to non-existent AGENT_SETUP.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMzawB9BZFgQxWo3kcToWj
- Add missing wired CLI commands: play, manage, universe
- Fix docs paths: docs/architecture/ → docs/2_architecture/, docs/ontology/ → docs/4_ontology/
- Remove reference to non-existent docs/archive/ directory
- Remove reference to non-existent AGENT_SETUP.md


Claude-Session: https://claude.ai/code/session_01AMzawB9BZFgQxWo3kcToWj

Co-authored-by: Claude <noreply@anthropic.com>
…eak)

NPCVoice + ConversationLoop now thread universe_id through recall,
memory writes, working state, and staged proposals. Each character card
holds a versions[] array; every incarnation has its own entity_id +
NPCProfile + memory set + relationship deltas, partitioned by universe.

- data-layer: NPCProfile gains universe_id, relationship_states_by_universe,
  current_emotional_state_by_universe (legacy fields remain as fallback).
- agents: NPCVoice._recall_memories/_write_npc_memory/_build_*_snapshot
  read/write the per-universe partition first; add universe_id +
  include_cross_incarnation kwargs to respond_direct. ConversationLoop
  stamps universe_id on staged ProposedChanges.
- UI backend: character_storage.add_version/get_version/list_versions/
  touch_version/delete_version; character_conversation.ensure_character_backed
  is per-universe idempotent; new delete_incarnation tears down entity +
  profile. 5 new endpoints (POST/GET/DELETE /versions + universe_id +
  include_cross_incarnation flags).
- frontend: StandaloneCharacter + ConversationStart carry versions /
  default_universe_id / version_id / universe_id. Roster shows Nx badge,
  editor has Incarnations panel (list + delete + add by universe), chat
  has 'Remember across incarnations' toggle.
- tests: 13 NPCVoice universe-scoping tests, 3 isolation tests in
  test_character_conversation, 9 frontend version-API tests; 87 agent
  tests, 39 backend tests, 38 frontend tests, type-check clean.
- docs: docs/use-cases/epic-3-identity-M/M-31/CHARACTER_VERSIONS.md
  describes the architecture, data model, endpoint catalog, migration.
  docs/USE_CASES.md adds a VERSIONS row pointing at it.

Use cases: M-31, DL-20, P-17, P-20.

Co-Authored-By: Claude <noreply@anthropic.com>
P1 — packages/data-layer/src/monitor_data/tools/neo4j_tools/core.py
  Fork/split/merge Cypher matched (u)-[:IN_MULTIVERSE]->(mv) but the
  schema (core.py:72 / :314) creates (m)-[:CONTAINS]->(u) — opposite
  direction. Queries matched zero rows, so fork/split/merge silently
  failed. Flipped all three to (mv)-[:CONTAINS]->(source/new).

P1 — packages/ui/backend/src/monitor_ui/routers/character_conversation.py
  _provision_entity_and_profile called neo4j_create_entity directly,
  bypassing the CanonKeeper authority check (INV-1: only CanonKeeper
  writes Neo4j entity nodes). Route through CanonKeeper.create_entity.

P2 — packages/agents/src/monitor_agents/loops/conversation_loop.py
  ConversationState did not declare include_cross_incarnation, so
  assignments to state.include_cross_incarnation were silently dropped.
  Added the field with a False default (preserves per-universe
  incarnation isolation).

P2 — packages/agents/src/monitor_agents/npc_voice.py
  _recall_memories filtered by universe_id even when
  include_cross_incarnation=True, making the flag a no-op. Drop the
  universe scope when cross-incarnation recall is requested.

Co-Authored-By: Claude <noreply@anthropic.com>
First Medium post draft covering origin story, Yggdrasil/DAG insight,
temporality model, and current e2e test state. Includes Mermaid source
files for both diagrams (render via mermaid.live before uploading).
…h coverage

Adds 6 live CI badges + Codecov to README, rewrites mutation.yml for 10-module
parallel matrix, adds branch coverage config, fixes 13 stale tests, and expands
test coverage for _enrichment and _game_system_persistence modules.
Full suite with --cov-branch: 6308 passed, 204 skipped, 37202 statements,
10468 branches. 67.9% combined branch+statement coverage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RC8T8VN6X4KaJmZ4fnUvkn
Phase 0 — fix 7 production-blocking bugs:
- WS URL fallback for non-localhost deploys (lib/api.ts)
- REST-path typing-indicator watchdog (PlayConsole.deliverTurn)
- User-scrolled-up scroll guard (new useStickyScroll hook)
- Per-turn streaming isolation (architect/page.tsx — keyed by message_id)
- Cleanup all streaming state on session delete (PlayConsole)
- SSE lifecycle: replace-on-reopen, retry with backoff (IngestionJobsList)
- WS connect() race guard via isConnectingRef (use-chat-websocket)

Phase 1 — extract chat core into features/chat/:
- useChatSession owns WS lifecycle, streaming state, watchdog, retry, dice
- ChatList (react-virtuoso virtualized list, followOutput auto-scroll)
- Composer (textarea + status pill + quick actions)
- 7 shared sub-components (TypingIndicator, ProseBubble, CopyButton,
  DiceResultCard, DiceRollPrompt, ConsequenceBanner, StatusPill)
- PlayConsole.tsx: 1968 → 726 lines (-63%)
- architect/page.tsx: 1599 → 603 lines (-62%)
- 4 components extracted: SessionList, SetupPanel, PlayMessageBubble,
  ArchitectMessageBubble

Phase 2 — backend emits composing/thinking/thinking_end events:
- dspy_runtime.py: stream-callback now cb(kind, data); CoT text before
  'Narrative Text:' prefix surfaces as thinking events
- chat.py: emits composing after start; dispatches thinking/thinking_end
- useChatSession handles new events; persists thinking to metadata.thinking
  on done (fixed bug surfaced by tests where trace vanished)
- New ThinkingBubble component (collapsible CoT card)
- WsServerMsg union extended with tool_call/tool_result (no-op for now,
  forward-compat for Phase 2B MCP surfacing)

Tooling + tests:
- react-virtuoso added to package.json
- vitest config: JSX automatic, happy-dom env, setupFiles for jest-dom
- 96/96 tests passing (was 28)
- 8 new test files cover Phase 0/1/2 fixes and chat primitives
- Per-turn isolation regression caught and fixed during test pass
- Thinking-trace persistence bug caught and fixed during test pass

Co-Authored-By: Claude <noreply@anthropic.com>
Backend:
- BaseAgent.call_tool now emits tool_call before and tool_result after
  every MCP server invocation, correlated by a per-call uuid. Errors are
  caught and surfaced as a tool_result with an error payload so the UI
  can render the failure inline instead of crashing the turn. Result
  preview is truncated to 2000 chars.
- chat.py on_event dispatcher forwards tool_call and tool_result as
  typed WS payloads (matching the protocol types added in Phase 2).
- Backend test: test_tool_call_emit covers happy path + error path.

Frontend:
- New ToolCall interface + toolCalls? field on StreamingMessage.
- useChatSession: tool_call appends pending entry, tool_result resolves
  by id, done handler captures toolCalls via setter-callback and
  persists them as metadata.tool_calls on the cached message.
- New ToolCallCard component (121 lines): collapsible inline card with
  status pill (running / done / error) and expandable args / result /
  error panels. data-testid for testability.
- PlayMessageBubble renders one ToolCallCard per msg.toolCalls below the
  prose bubble. ChatList threads toolCalls through the streaming bubble
  synthesis so live tool cards appear during streaming, not just after.
- Tests: use-chat-session-toolcalls (3 tests), PlayMessageBubble render
  test (1 new). 108/108 frontend tests passing.

Cleanup:
- IngestionJobsList: re-add useCallback import I removed during Phase 0.
- ChatList: fix streamingMsg null narrowing with local aliases.
- tsconfig: exclude test files from tsc (they use dynamic FakeSocket
  typing for mocks; runtime is correct).
- esbuild removed dead _preview line in base.py success emit.
- Add minimal eslint.config.mjs so 'next lint' won't prompt interactively.

Verified:
- tsc --noEmit: 0 errors in production code
- next build: all 19 routes compile, /play 19.1kB
- Frontend tests: 108/108 passing
- Backend tests: 183/183 passing on the touched subset

Co-Authored-By: Claude <noreply@anthropic.com>
…t coverage

Type safety:
- New lib/errors.ts errorMessage(err: unknown) helper handles Error,
  DOMException-shaped { message }, string, null/undefined, and circular
  objects. Replaces 13 onError: (e: any) and 2 catch (e: any) sites
  across characters, PackLibrary, and CharacterChat with proper unknown
  + the helper.
- New features/graph/adapters.ts bridges WorldGraph (GraphNode,
  GraphEdge) to xyflow Node/Edge via toReactFlowNode/toReactFlowEdge/
  toReactFlowGraph. Replaces 4 'as unknown as Node[]' / 'as unknown as
  Edge[]' casts in architect/page.tsx and explorer/page.tsx.
- FORGE_KEYS.assets opts typed as { source_id?, universe_id?, asset_type?,
  limit?, offset? } instead of (opts?: any).
- Two 'as any' casts in forge/page.tsx patchArtifactPack removed.

Test coverage (108 → 120):
- errorMessage helper: 6 tests covering all shapes + circular safety
- SessionList (Phase 1 extraction): 4 tests for render/empty/loading
- RecapModal (Phase 1 extraction): 2 tests for mount + close handler

Verified:
- tsc --noEmit: 0 errors
- next build: all 19 routes compile
- vitest: 120/120 passing
- Backend tests still passing (not re-run, no backend changes)

Co-Authored-By: Claude <noreply@anthropic.com>
The ingest mutation was posting ALL chunks in a single bulkCreate
request. A 50-page document at chunk_size=200 produces ~300 chunks —
that single POST exceeds the backend's default 30s timeout and silently
loses everything.

Fix:
- Extract lorebookChunksForText + chunkLorebookEntries to a pure
  helpers module (src/components/play/lorebookChunking.ts). The
  chunking also clamps the size to a safe minimum of 200 chars
  (matching the original inline behavior).
- The mutationFn now batches bulkCreate calls in groups of 20. Batches
  run sequentially so a failure on batch 7 still saves 1-6 (vs. the
  all-or-nothing single-POST).
- Add ingestProgress state + 'Ingesting N / total…' UI label so the
  player sees progress on a long ingest.

Tests:
- lorebookChunking.test.ts: 7 tests covering empty input, size
  clamping, exact chunking, and batch splitting (7 new).

Verified: tsc clean, vitest 127/127 passing.

Co-Authored-By: Claude <noreply@anthropic.com>
chunk_size and priority_hint inputs were storing as numbers and
snapping to the default (1000 / 50) whenever the field was cleared,
making the silent-default bug a data-loss risk: a player who wanted
to set chunk_size=0 (one chunk = whole doc) or priority=0 (deprioritize
everything) couldn't — clearing the field re-set it to the default.

Fix: store as strings, parse on submit with safe fallback. Empty fields
now show empty (and submit falls back to default at the submit boundary).

Co-Authored-By: Claude <noreply@anthropic.com>
Clicking a pack from the Sources panel used to call setForgeMode('packs')
which collapsed the sources view and switched to the sidebar+detail
layout used for pack editing — a jarring layout jump in the middle of
the player's flow.

Fix: route to /forge/editor?pack=<id> instead. The editor route is the
canonical place to edit a pack, has its own focused layout, and keeps
the back/forward navigation consistent.

Co-Authored-By: Claude <noreply@anthropic.com>
… differs

The sidebar's Active World selector reads from useWorldContext (a
persisted preference). When playing a session that uses a different
universe, the sidebar displayed the saved preference — making it look
like the wrong world was active.

Fix: when on a session route (?universe=…), compare the session's
universe to the persisted preference and show an amber 'session ≠'
badge so the player knows the sidebar reflects their saved preference
rather than the active session.

Tests: 3 cases covering no-drift, matching, and drift states.

Co-Authored-By: Claude <noreply@anthropic.com>
The web surface had only one root error.tsx and zero loading.tsx files.
Every route rendered directly with no skeleton during data fetches and
no per-route fallback for caught errors. Now:

- src/components/PageSkeleton.tsx — reusable loading skeleton with
  default / two-column / compact variants.
- src/components/PageError.tsx — reusable error fallback with friendly
  title, error message, and reload action.
- 17 per-route loading.tsx files (play, gm, worlds, settings, forge,
  forge/editor, forge/review, forge/apply, characters, architect,
  explorer, systems, snapshots, search, prompts, history, universes)
  using the shared skeleton.
- 18 per-route error.tsx files surfacing route-specific titles via
  the shared PageError.
- src/app/not-found.tsx — top-level 404 fallback with link back to
  Gate / Worlds.
- Tests: PageSkeleton (2), PageError (3) covering ARIA roles,
  reload button, default title.

Verified:
- tsc --noEmit: 0 errors
- vitest: 135/135 passing (was 130)

Note: 'next build' fails on master's /history page prerender — this is
pre-existing on master and unrelated to this change.

Co-Authored-By: Claude <noreply@anthropic.com>
Next 15 requires useSearchParams() to be inside a Suspense boundary
during prerender. WorldPicker (rendered inside the layout's Sidebar)
calls useSearchParams to detect drift between the persisted 'active
world' and the session's world. Without Suspense, every page failed
to prerender with:

  useSearchParams() should be wrapped in a suspense boundary at page
  "/history"

Fix: wrap <Sidebar /> in <Suspense> at the layout level with a minimal
fallback (a 240px-wide placeholder div). The page shell renders as
static HTML; the search-params-dependent sidebar content swaps in
during hydration.

Verified:
- next build: all 21 routes prerender as ○ static
- tsc --noEmit: 0 errors
- vitest: 135/135 passing

Co-Authored-By: Claude <noreply@anthropic.com>
The fallback was 240px but Sidebar's default state is collapsed=false
(width=224px). Matching the fallback width avoids a 16px visual jank
when Sidebar hydrates and the animated width kicks in.

Co-Authored-By: Claude <noreply@anthropic.com>
Audit found 20 buttons whose only child was a lucide icon (Close,
Plus, Send, ChevronDown, Loader2) with no text and no aria-label /
title attribute. Screen readers announce these as 'button' with no
name, making them ungraspable for non-visual users.

Fix: contextual aria-labels per button:
- All <X> close buttons → aria-label="Close"
- Plus icons in architect/worlds → "Create multiverse/universe",
  "Add tag"
- SessionRecorder Send → "Log entry"
- Demo button → "Start demo"
- Snapshot confirm → "Confirm"
- PlayConsole tone chip → "Change session tone"

Verified: tsc clean, vitest 135/135 passing.

Co-Authored-By: Claude <noreply@anthropic.com>
6 hidden file inputs (character card upload, asset upload, document
upload, pack import, etc.) had no accessible name. Screen readers
announced them as 'file upload' with no description. Search inputs
on forge/worlds pages had placeholders but no aria-label, so screen
readers also couldn't identify them as search fields.

Fix: contextual aria-labels for each (e.g. 'Choose pack file to import',
'Search library', 'Choose document to upload', etc.).

Verified: tsc clean, vitest 135/135 passing.

Co-Authored-By: Claude <noreply@anthropic.com>
Settings was the last remaining god-component (2334 lines). Two of
its 6 tabs are well-isolated and extracted:

- components/settings/ToneTab.tsx (153 lines): tone profile CRUD +
  library list. Used by settings/page.tsx via import.
- components/settings/PerformanceTab.tsx (106 lines): live snapshot of
  slow queries + alerts + health check.

The other 4 tabs (LLM, Agents, Testbed, Databases) share too much
state (PROMPTS, agent nodes, provider metadata, db types) to
extract atomically without a larger refactor; left in place for now.

Verified: tsc clean, vitest 135/135 passing.

Co-Authored-By: Claude <noreply@anthropic.com>
Sidebar nav links now declare aria-current="page" when active so
screen readers announce the current page on focus. The root layout
adds a visually-hidden skip-to-content link that becomes visible on
keyboard focus, allowing keyboard users to bypass the sidebar and
jump straight to <main id="main-content" tabIndex={-1}>.

Verified: tsc clean, vitest 135/135 passing.

Co-Authored-By: Claude <noreply@anthropic.com>
Alumno and others added 23 commits June 27, 2026 12:14
Added 13 new tests across the extracted components:
- ToneTab (4): section header, disabled-until-filled, enabled-when-filled,
  library list rendering.
- PerformanceTab (3): header render, four metric cards, empty state.
- NPCCard (6): name, state_tags, empty state_tags, description,
  onClick, role=button.

Verified: tsc clean, vitest 144/144 passing, next build green.

Co-Authored-By: Claude <noreply@anthropic.com>
Worlds page further decomposed. Two more self-contained pieces:

- app/worlds/GraphLegend.tsx (21 lines): the colored swatch legend
  shown on the graph tab.
- app/worlds/InspectorPanel.tsx (214 lines): the right-side detail
  panel that shows node attributes / properties and exposes edit
  controls. Pure presentational, takes a Node<GraphNodeData>.

Worlds page now 1162 lines (-219).

Verified: tsc clean, vitest 148/148 passing.

Co-Authored-By: Claude <noreply@anthropic.com>
GraphLegend previously referenced KIND_CONFIG from settings/page.tsx
— which broke once it was extracted. Replaced with a self-contained
LEGEND array so the component is standalone. Tests verify all 9 kind
labels render (Multiverse, Universe, Character, Location, Faction,
Concept, Axiom, Lore, Rule).

Verified: tsc clean, vitest 150/150 passing.

Co-Authored-By: Claude <noreply@anthropic.com>
InspectorPanel was extracted in 717d0c8 but referenced KIND_CONFIG
from settings/page.tsx, which is no longer in scope. The component
compiled (TS was permissive because the GraphNodeKind cast hid the
undefined) but would crash at runtime on the first node selection.

Fix: inline a minimal KIND_CONFIG inside InspectorPanel.tsx. The
mapping is the same data as the GraphLegend inlined array (8 + 2
extra kinds) and the canonical worldGraph KIND_CONFIG that lives in
settings/page.tsx. Keeping it self-contained here avoids the same
cross-file import fragility that bit GraphLegend.

Verified: tsc clean, vitest 150/150 passing.

Co-Authored-By: Claude <noreply@anthropic.com>
GraphTab was the biggest remaining function in the worlds page. It
holds the ReactFlow graph view, filter side-panel, search bar, and
related-entity selection — all the state and render code for the
graph visualization tab.

Extracted to app/worlds/GraphTab.tsx (416 lines, includes inlined
GraphCanvas for self-containment). The worlds page is now 763 lines
(down from 1599 at the start of this audit, -52%).

Verified: tsc clean, vitest 150/150 passing, next build green.

Co-Authored-By: Claude <noreply@anthropic.com>
… extraction

GraphCanvas (WorldNode, KIND_CONFIG, nodeTypes) were still in page.tsx even
after GraphTab was extracted to its own file in 4562a48. GraphTab.tsx already
imported from "./GraphCanvas" but the file didn't exist, and several names
(AnimatePresence, X, User, MapPin, Shield, Sparkles, WorldGraphFilter) were
missing from GraphTab.tsx's import list.

Also fixes InspectorPanel.tsx: Edit2 → Edit3 (icon rename in lucide-react) and
adds missing Loader2 + Link2 imports that were never brought over from page.tsx.

After this commit page.tsx sheds 248 lines and all xyflow/react imports.
Verified: tsc clean, vitest 150/150 passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- base.py: max_tokens default changed from hardcoded 2048 to None;
  fallback chain is now: caller override → provider config → env default
  (MONITOR_LLM_DEFAULT_MAX_TOKENS). Old hardcoded 2048 silently truncated
  extraction-heavy calls.
- dspy_runtime.py: node→role mapping and intensity keywords now override-
  able via MONITOR_NODE_ROLES / MONITOR_HIGH_INTENSITY_KEYWORDS env vars,
  without code changes.
- llm_registry.py + schemas/llm_config.py: prompt_version field on
  LLMNodeAssignment threaded through to LLMClient, enabling A/B testing
  prompt variants with correlated metrics.
- schemas/llm_config.py: ModelParams gains stop, seed, top_k, and
  response_format fields (Anthropic + OpenAI compatible).
- config.py: new Settings fields for LLM timeout, streamable nodes,
  and DSPy optimizer toggle.
- env.example: documents all new MONITOR_* knobs.
- test_llm_routing.py: adds params={} to FakeClient (required by new
  max_tokens resolution logic).

Verified: 16 LLM routing tests passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
LorebookKeywordExtractor and LorebookIngestionModule now use explicit
DSPy Signature classes (LorebookKeywordExtractionSignature,
LorebookIngestionSignature) instead of inline docstring definitions.
Adds JSON parsing + validation helpers so callers get proper
LorebookEntryDraft objects back rather than raw strings.

No functional change to existing behavior.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- conftest.py: adds FakeDSPyPrediction and FakeDSPyLM fakes for testing
  DSPy modules without real LLM calls, plus fake_dspy_lm fixture.
- tests/property/test_agent_loop_properties.py: Hypothesis tests for
  resolve_dynamic_role, normalize_node_name, and default_role_for_node.
  131 property tests across agent loop invariants.
- tests/property/test_llm_registry_properties.py: Hypothesis tests for
  EffectiveLLMConfig construction, ModelParams.to_dict() idempotency,
  PROVIDER_BASE_URLS consistency, and param merging invariants.
- pyproject.toml: replaces deal (removed) with hypothesis, adds
  pytest-cov, pytest-timeout, pytest-socket, and cosmic-ray to dev deps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds narrator, context-assembly, and simulacrum to the cosmic-ray
mutation test matrix (was 10 modules, now 13). Each new config uses
curated mutation operators tuned to the module's logic type.

Also adds a quality gate step that emits a CI warning when a module's
kill rate drops below 60%, making regressions visible without blocking
CI on a hard threshold.

Updates docs/contributing/BADGES.md to reflect the new module count
and document the operator curation approach.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
test_adv.py and test_adv2.py were one-off manual scripts for checking
AdvancementSystem schema fields. Behavior covered by proper tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…el testAll

- query-keys.ts: add WORLDS_KEYS (worldGraph base + filter), UNIVERSE_KEYS;
  remove dead ENTITY_KEYS.graph which used "world-graph" instead of the live
  "worldGraph" key (would have silently prevented cache invalidation)
- worlds/GraphTab: use UNIVERSE_KEYS + WORLDS_KEYS constants throughout
- worlds/InspectorPanel: WORLDS_KEYS for invalidation; aria-label on tag delete
  buttons; fix Edit2→Edit3 lucide rename; add missing Loader2/Link2 imports
- worlds/NPCDetailPanel: composite stable key for relationships list
  (was index-as-key, broke reconciliation on reorder)
- worlds/page.tsx: UNIVERSE_KEYS constants in CreateMultiverseForm +
  CreateUniverseForm; error display on mutation failure; aria-label="Close"
  on modal close button
- settings/page.tsx: testAll() sequential for..of → Promise.allSettled
  (parallel provider tests, ~N× faster)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ria-label on close

- gm/page.tsx: SessionNotebook used useState(() => ...) to load localStorage —
  invalid pattern (the return value is ignored, setNotes() fired as a side
  effect inside state init). Replace with useEffect + [STORAGE_KEY] dep so
  notes reload correctly when the active universe changes.
- ThinkingBubble: add aria-expanded + aria-label to the reasoning toggle button
- ToolCallCard: add aria-expanded + aria-label to the tool detail toggle button
- NPCDetailPanel: add aria-label="Close" to the panel dismiss button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…o side-effects, timeout cleanup

- explorer/page.tsx: move setState-during-render into useEffect; replace
  useMemo-as-side-effect (setNodes/setEdges inside useMemo) with useEffect
- snapshots/page.tsx: same setState-during-render fix
- Sidebar.tsx: startsWith(href) → startsWith(href + '/') to prevent false
  active matches on sibling routes
- NotificationProvider.tsx: store setTimeout IDs in ref, cancel on removal
  and unmount; use crypto.randomUUID() for collision-safe notification IDs
- forge/page.tsx: Discard handler was only resetting dirtyPack, not dirtySystem

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lity

- TypingIndicator: role=status + aria-label so screen readers announce
  GM typing state
- ChatModeToggle: aria-pressed on IC/OOC buttons to expose active state
- QueryBoundary: role=status on loading div, role=alert on error div
- CopyButton: aria-label with copied/copy states (title alone not reliable)
- SessionList: aria-label on new/rename/delete icon-only buttons
- ForgeRows: aria-label + aria-expanded on kebab button; aria-label on
  InlineEdit inputs
- UploadCard: role=button + tabIndex + onKeyDown for keyboard drop-zone access
- search/page.tsx: aria-label on search input; aria-pressed on collection
  filter toggles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- LorebookEditor: sort toggle fixed (active ? !ascending : true) — was
  stuck returning true when clicking active-ascending column; inline
  lorebook query keys → LOREBOOK_KEYS constants
- MemoryInspector: add minImportance to query key so slider changes bust
  cache (was serving stale results on filter change)
- ProseBubble: hoist markdownComponents to module scope — was recreated
  every render, remounting all block elements during token streaming
- use-llm: refetchOnWindowFocus: false on providers + assignments — was
  triggering connectivity checks on every browser tab focus
- ChatList: wrap itemContent in useCallback, components in useMemo so
  Virtuoso does not remount visible rows/footer on every parent render
- gm/page.tsx: rename local saved → stored to remove shadow over
  const [saved, setSaved] state variable
- query-keys.ts: add LOREBOOK_KEYS, STORY_KEYS.threads, FORGE_KEYS.pack,
  ENTITY_KEYS.characterVersions; fix any param types on npcs/standaloneCharacters

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Critical: several components used inline key strings that hit different cache
slots than the constants, silently breaking invalidation:
- SessionRecorder: [sessions] → PLAY_KEYS.sessions and [chat,id,messages] →
  PLAY_KEYS.messages — was writing optimistic updates to a key no other
  consumer read; play console invalidations never reached session list
- AssetsPanel: [assets,filter] → FORGE_KEYS.assets (was [forge-assets,*])
- forge/review: [proposals,id] → FORGE_KEYS.proposals; [forge-packs] → FORGE_KEYS.packs
- WorldPicker: [universes,"all"] → UNIVERSE_KEYS.universes() (arity fix)
- app/page.tsx: [sessions] → PLAY_KEYS.sessions; [universes] → UNIVERSE_KEYS.universes()
New constants: HEALTH_KEYS, MODE_KEYS, CHANGE_LOG_KEYS, CANON_KEYS
used in ConnectionStatus, ModeSwitcher, HistoryTab, CanonReviewPanel
CanonReview bug: hide Accept All button in scene mode where storyId is
always null and the handler was silently no-oping

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ogShell a11y

- gm/page.tsx: extract AccordionRule component — useState was called inside
  system.rules.map(), violating Rules of Hooks (can corrupt hook order when
  rules array length changes)
- settings/page.tsx: delete local ToneTab/PerformanceTab duplicates (2×140
  lines of dead copies); import from components/settings/ instead; remove
  non-existent Tailwind bg-network-500/10 tokens; PLAY_KEYS.sessions constant
- forge/apply/page.tsx: auto-commit toggle div had onClick that double-fired
  with the label's native input activation — net result was no toggle
- DialogShell.tsx: add role=dialog, aria-modal, aria-labelledby, Escape key
  handler — all modals in the app share this shell so fix benefits everywhere

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
KIND_CONFIG was defined 3× with silently divergent values — the
inspector showed Shield for rule while the graph showed Zap, and the
GraphLegend had its own hardcoded LEGEND array (7/9 icons wrong, 6/9
colors wrong, pack kind missing). Universe kind was showing MapPin
(the location icon) — actively misleading.

Fixes:
- New lib/kind-config.ts — single source of truth matching the values
  GraphCanvas actually renders
- GraphCanvas, InspectorPanel, architect/page all import from it
- GraphLegend derives directly from KIND_CONFIG (no more LEGEND array)
- Add ARCHITECT_KEYS, BENCHMARK_KEYS, STORY_KEYS.stories/turns/threads,
  ENTITY_KEYS.systemDetail/entities to query-keys.ts; add PLAY_KEYS.recap
- Replace inline keys in architect/page.tsx (had no query-keys import),
  UniverseTree.tsx (9 keys), worlds/page.tsx (4 keys), settings/page.tsx
  (5 benchmark keys)
- Fix architect stale closure: useEffect was missing multiverseId dep
  (eslint-disabled); guard already prevents overwriting user's selection,
  so adding the dep makes re-evaluation safe

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…i types

- lib/play-constants.ts: extract TONES, TONE_DESCRIPTIONS, MODE_LABEL,
  PHASE_STYLE — was duplicated verbatim across PlayConsole and SetupPanel
- PlayConsole: wrap onTurnSettled in useCallback (was new ref every
  render → useChatSession WebSocket reconnect storm); memoize quickActions;
  useCallback for renderBubble; replace inline "play-sessions" key
- RecapModal: delete local RecapCopyButton (exact dup of features/chat
  CopyButton); import shared CopyButton; use PLAY_KEYS.recap constant
- api.ts: export ApiError so callers can instanceof-check and read .status;
  type all 5 lorebookApi methods with LorebookEntry/LorebookStats; add
  LorebookStats to types.ts; move character_id out of inline path query
  string into query: option for consistency; LorebookEditor drops its
  old `as Promise<Record<string, number>>` cast (now correctly typed)
- hooks/use-ingest-jobs.ts: extract polling query duplicated verbatim in
  IngestionDashboard, IngestionJobsList, SourceLibrary into one hook

Tests: 150/150 pass. tsc clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GMAwareness (DSPy-based causality engine):
- New gm_awareness.py: single LLM call replaces 5 regex heuristics
  (_infer_intent_type, _infer_roll_type, _classify_roll_necessity,
  _extract_target, _detect_forced_narrative)
- GMAwarenessSignature(dspy.Signature) — prompt IS the test surface
- GMAwarenessModule(dspy.Module) — wraps dspy.Predict for optimization
- prediction_to_verdict() — lenient parser with alias normalization
- Mode flags skip LLM: narrative→NARRATIVE_OVERRIDE, auto-roll→ACCEPT
- LLM failure → fallback to propose_roll (safe default)
- Resolver Branch 2 now uses check_gm_awareness (no regex)

Narrative coherence (scene_loop + narrator):
- SceneState: context_summary, turn_context, pending_roll, established_facts
- New nodes: extract_facts, check_consistency
- Narrator: setting_anchor, context_summary, turn_context injection
- Prior turns expanded from [-3:] to [-8:]
- Pending roll state machine: pushback when player skips a roll

Tests (306 agent tests pass):
- test_gm_awareness.py: 36 routing tests (trivial/propose/contested/narrative/auto)
- test_gm_awareness_prompts.py: 16 gold-set prompt tests + parser mutations + metric
- test_turn_context_coherence.py: 30 coherence feature tests
- test_session0_and_forced_narrative.py: 13 session0 + causality tests
- All existing resolver/narrator/scene_loop tests updated to mock check_gm_awareness

UI worktree merge (from 5 claude agent worktrees):
- agent-a95505: UI logic bugs + streaming perf (69 files)
- agent-adc7a1: query-key constants + cache desync fixes (73 files)
- agent-aa31942d: uncommitted UI refactors (5 files)
- ui-fixes: shared play constants, RecapModal DRY, api types (33 files)
- restore-deps: package.json deps update

Coherence playtest: 10 turns, 9 pushbacks, 0 violations, PASS
Layer dependencies: all pass
Implements the Generalized Entity Promotion Plan: replace lore-specific
heuristics with a deterministic, system-agnostic promotion gate.

* Narrator prompt enforces [Name](entity:anchor|flavor) tagging with
  few-shot examples.
* ProposedChange schema gains PromotionIntent enum + interaction_count
  + is_mechanically_bound fields.
* New loops/entity_promotion.py parses tags, merges intents into LLM-
  extracted proposals, bumps interaction counts, and detects mechanical
  binding from resolution/combat/inventory payloads.
* Scene loop wires the parser into extract_new_entities and the
  mechanical-binding pass into persist_turn_artifacts.
* CanonKeeper.evaluate_proposals runs a pre-LLM deterministic pass that
  promotes entities via topology (RELATIONSHIP references) and state-
  gating (STATE_CHANGE/EVENT references), then accepts anchors, accepts
  flavor entities that exceed the interaction threshold or are
  mechanically bound, and rejects low-traffic flavor entities.
* Docs updated in data_model_workflow.md and ontology _index.md.
* 20 new tests cover the full promotion lifecycle.

DL-2 / 1130 existing tests still pass.
@codecov-commenter

Copy link
Copy Markdown

Welcome to Codecov 🎉

Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests.

ℹ️ You can also turn on project coverage checks and project coverage reporting on Pull Request comment

Thanks for integrating Codecov - We've got you covered ☂️

@spuentesp spuentesp closed this Jul 24, 2026
@spuentesp
spuentesp deleted the worktree-entity-promotion branch July 25, 2026 00:09
@spuentesp
spuentesp restored the worktree-entity-promotion branch July 25, 2026 00:13
@spuentesp
spuentesp deleted the worktree-entity-promotion branch July 25, 2026 00:14
@spuentesp
spuentesp restored the worktree-entity-promotion branch July 25, 2026 00:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants