From 379054755a616b893860799912986ceb3101a0e4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 16:55:50 -0400 Subject: [PATCH] docs: add Phase 3 implementation plan with parallel-safe waves 19 tasks across 8 waves covering events with lifecycles, time skips (elision + jump), active threads, significance/retrieval refinements, and meanwhile scenes (host+guest with no 'you'). Mirrors the Phase 2 plan structure: pre-flight, parallel-execution strategy with worktree isolation, file-disjointness analysis per wave, and per-task TDD spec with commit messages. Phase 3 schema: adds 0009_events.sql, 0010_threads.sql, 0011_meanwhile_scenes.sql (final version 11). Builds on Phase 2's 3-entity scene support and event-sourced architecture. --- .../2026-04-26-v3-phase3-implementation.md | 891 ++++++++++++++++++ ...-26-v3-phase3-implementation.md.tasks.json | 26 + 2 files changed, 917 insertions(+) create mode 100644 docs/plans/2026-04-26-v3-phase3-implementation.md create mode 100644 docs/plans/2026-04-26-v3-phase3-implementation.md.tasks.json diff --git a/docs/plans/2026-04-26-v3-phase3-implementation.md b/docs/plans/2026-04-26-v3-phase3-implementation.md new file mode 100644 index 0000000..14a09a7 --- /dev/null +++ b/docs/plans/2026-04-26-v3-phase3-implementation.md @@ -0,0 +1,891 @@ +# Roleplay Engine — Phase 3 Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use `superpowers-extended-cc:executing-plans` to implement this plan task-by-task. Use the parallel-dispatch pattern documented under "Parallel-Execution Strategy" for waves that fan out to multiple subagents. + +**Goal:** Add events with lifecycles, time skips (elision + jump), active threads, significance/retrieval refinements, and "Meanwhile…" scenes (host+guest with no "you" present). All scoped to a single chat; the cross-chat surface remains unchanged. + +**Architecture:** Builds on Phase 2's event-sourced architecture and 3-entity scene support. New event kinds (`event_planned`, `event_started`, `event_completed`, `event_cancelled`, `event_expired`, `time_skip_elision`, `time_skip_jump`, `thread_opened`, `thread_updated`, `thread_closed`, `meanwhile_scene_started`, `meanwhile_scene_closed`, `synthesized_memories`) carry the new state changes. Two new tables (`events`, `threads`) hold lifecycle state. Existing handlers (`memory_written`, `edge_update`) gain new payload sources without changes — promotion logic lives in services, not in projector handlers. + +**Tech Stack:** Same as Phase 2 (Python 3.11+, FastAPI, HTMX, SQLite, Featherless). No new dependencies. + +**Source-of-truth references:** + +- Phase 3 scope: requirements doc §13 "Phase 3 — events, skips, threads" +- Behavioral details: §4 (per-chat clocks), §6.3 (prompt assembly), §6.4 (drawer), §8.1 (retrieved-memory inputs), §9 ("Time, Skips, Events — Phase 3 surface"), §11 (significance & compression) +- Conventions: [../../CLAUDE.md](../../CLAUDE.md) §"Behavioral defaults" + §"Phase 2 status" +- Phase 2 plan (style, TDD pattern, parallel-dispatch mechanics): [2026-04-26-v2-phase2-implementation.md](2026-04-26-v2-phase2-implementation.md) + +When a task says "see §X", that's the requirements doc unless stated otherwise. + +--- + +## Pre-flight + +**Branch:** create `phase-3` from the latest `main` after Phase 2 has merged. If Phase 2 is still in PR review, branch off `phase-2` directly: + +```bash +# Option A: after main has phase-2 merged +git checkout main && git pull && git checkout -b phase-3 + +# Option B: continue from phase-2 directly +git checkout phase-2 && git pull && git checkout -b phase-3 +``` + +**Schema baseline:** Phase 2 leaves the DB at version 8. Phase 3 adds two migrations: `0009_events.sql` and `0010_threads.sql`. No other migrations expected. + +**Phase 2.5 backlog:** the items in CLAUDE.md §"Phase 2.5 / 3 backlog" are NOT scoped here — they should be cleaned up in a separate branch off `main` (suggested name `phase-2.5`) before or in parallel with Phase 3. None of them blocks Phase 3. + +**Pinned non-negotiables (carried forward):** + +- State changes go through the event log. Use `append_and_apply(conn, kind, payload)` for the live path; `apply_event` only after a fresh `append_event` returning the new id. +- Witness filter every memory read at SQL level (hard `WHERE` constraint; never a soft signal). +- Edges are directed; `botA → botB` and `botB → botA` are independent records. +- Per-POV scene summaries — never write omniscient narration. (Meanwhile scenes write per-POV summaries for both present bots; you receive a digest later, not during the scene.) +- TDD: every task starts with a failing test. +- One commit per task minimum, more if it splits naturally. + +**Verification before claiming done:** Use `superpowers-extended-cc:verification-before-completion` — run the test command, paste actual output. Don't assume green. + +--- + +## Parallel-Execution Strategy + +Same pattern as Phase 2. Eight waves: parallel within each wave (file-disjoint), serial across waves. The controller (you, the controlling Claude session) merges each subagent's commits and verifies the suite stays green before dispatching the next wave. + +### How to dispatch a wave in parallel + +Use the **Agent tool with `isolation: "worktree"`** so each subagent gets its own git worktree. The runtime cleans up the worktree automatically if no changes are made; otherwise it returns the path + branch for the controller to merge. (If the controlling session's working directory is **not** the chat repo, create worktrees manually with `git worktree add .worktrees/- -b / phase-3` from inside the chat repo and pass the worktree path explicitly into each subagent prompt — that is the pattern Phase 2 used.) + +In a single message, dispatch all tasks in the wave: + +``` +Agent({ + description: "Wave 1 — T49 events table + handlers", + subagent_type: "general-purpose", + isolation: "worktree", + prompt: "", +}) +Agent({ + description: "Wave 1 — T50 time_skip handlers", + subagent_type: "general-purpose", + isolation: "worktree", + prompt: "", +}) +Agent({ + description: "Wave 1 — T51 threads table + handlers", + subagent_type: "general-purpose", + isolation: "worktree", + prompt: "", +}) +``` + +All subagents start simultaneously, each working on a private worktree branched off `phase-3`. They cannot see each other's changes (no shared filesystem state) — that's the safety guarantee. + +### After a wave completes + +1. Each subagent returns its worktree path and commit SHA. +2. **Run a spec + code-quality reviewer subagent on each completed task** (combined review is acceptable for purely mechanical schema/handler tasks; large or integration tasks like T62, T63 deserve separate spec + quality reviewers). +3. **Merge the wave into `phase-3`** in any order (file-disjointness guarantees no conflict). Use `--no-ff` so each task's history stays grouped: + + ```bash + git checkout phase-3 + for branch in ; do + git merge --no-ff "$branch" -m "merge: " + done + ``` + +4. **Run the full test suite** on the merged `phase-3`. If it's red, the wave's mutual-independence assumption was violated — bisect to find the offending pair, fix in a follow-up commit, re-merge. +5. **Push `phase-3`** to gitea so the work is durable before the next wave starts. +6. Optionally clean up worktrees: `git worktree remove .worktrees/` and `git branch -D `. + +### Conflict prevention checklist (apply before dispatch) + +For each parallel wave, verify the **Files** sections of all tasks have **no overlapping paths**. The waves below are designed to satisfy this; if you decide to add or merge tasks, re-check. + +If a hot file (`chat/web/turns.py`, `chat/services/prompt.py`, `chat/web/drawer.py`, `chat/templates/_drawer.html`, `chat/services/regenerate.py`) needs changes from multiple tasks, do **not** parallelize them — serialize within the wave or split into separate waves. + +### Failure recovery + +If one subagent fails (test failures, blocked, infinite loop): + +- **Do not block the wave on a failure.** Cancel the failed subagent, merge the others' successful work, and re-dispatch the failed task as a single follow-up. +- If a failure exposes a bad assumption shared by multiple tasks (e.g. an event-payload schema mismatch), pause the wave and revisit the plan. + +### Why each wave is parallel-safe + +| Wave | Tasks | Hot files touched | Disjoint? | +|------|-------|-------------------|-----------| +| 1 | T49, T50, T51 | new SQL migrations + new state modules; T50 also extends `chat/state/world.py` (additive) | ✅ | +| 2 | T52, T53, T54, T55 | new service modules only | ✅ | +| 3 | T56, T57, T58 | new service module (T56) + `chat/state/memory.py` retrieval extension (T57) + `chat/services/scene_summarize.py` (T58) | ✅ | +| 4 | T59 | `chat/web/drawer.py`, `chat/templates/_drawer.html` | (single task) | +| 5a | T60, T61 | `chat/services/prompt.py` (T60), `chat/web/turns.py` (T61) | ✅ | +| 5b | T62 | `chat/web/turns.py`, plus a new skip route module | (single task; depends on 5a) | +| 6 | T63, T64, T65 | meanwhile is tightly coupled — see Wave 6 sub-structure below | ⚠️ partial | +| 7 | T66, T67 | new test file + docs only | ✅ | + +**Wave 6 sub-structure:** T63 is schema/state (new files); T64 is service + extends `chat/web/turns.py`; T65 is service + extends `chat/services/prompt.py`. T64 and T65 are file-disjoint relative to each other but both depend on T63's schema landing first. Dispatch as: T63 alone → merge → T64+T65 in parallel → merge. + +--- + +## Task overview + +``` +Wave 1 ─┬─ T49: events table + lifecycle handlers + ├─ T50: time_skip event kinds + handlers (advance chat clock) + └─ T51: threads table + open/update/close handlers + +Wave 2 ─┬─ T52: event-lifecycle detection service (narrative → state changes) + ├─ T53: skip narration service (elision + jump prose) + ├─ T54: synthesized-memories service (jump skip "anything notable?") + └─ T55: thread-detection service (on scene close, identify open threads) + +Wave 3 ─┬─ T56: event-completion promotion (inventory / edges / memories) + ├─ T57: significance retrieval ranking refinements + └─ T58: scene compression keeps key quotes when significance ≥ 2 + +Wave 4 ─── T59: drawer additions — events panel, threads panel, skip controls + +Wave 5a ─┬─ T60: prompt assembly includes active events + active threads + └─ T61: turn flow invokes event-detection + thread-update per turn + +Wave 5b ─── T62: skip command surface (parse + route + jump UI prompt) + +Wave 6 ─┬─ T63: meanwhile scene config — schema + state + scene-config-4 marker + └─ (after T63 merges) + ├─ T64: meanwhile turn flow (host+guest, no "you") + └─ T65: meanwhile summary digest (briefs you on next active scene) + +Wave 7 ─┬─ T66: cross-feature integration tests (events × skips × threads × meanwhile) + └─ T67: Phase 3 documentation update +``` + +Critical path: 8 sequential merge points (Waves 1, 2, 3, 4, 5a, 5b, 6a, 6b, 7). Total tasks: 19. Wall-clock parallelism advantage depends on subagent dispatch overhead, but in principle each wave's tasks can run concurrently in ~the time of one task. + +--- + +## Wave 1 — Schema & state foundation + +These three tasks are **fully independent**: each adds a new SQL migration + new state module. T50 also adds two handlers to `chat/state/world.py` (additive, alongside Phase 2's `_apply_guest_added`). + +### Task 49: Events table + lifecycle handlers + +**Files:** + +- Create: `chat/db/migrations/0009_events.sql` +- Create: `chat/state/events.py` +- Create: `tests/test_events_state.py` + +**Spec:** Adds the `events` table and projector handlers for the lifecycle: `event_planned`, `event_started`, `event_completed`, `event_cancelled`, `event_expired`. Each event row carries `chat_id`, `kind` (free-form domain-event tag like `"date_at_park"`), `status` (`planned|active|completed|cancelled|expired`), `props_json` (arbitrary blob), `planned_for` (ISO-8601 chat-clock string, optional), `started_at` / `completed_at` (chat-clock strings). + +**Step 1: failing test** — see pattern in `tests/test_group_node.py` (Phase 2 T36). Three tests minimum: + +1. `test_event_planned_creates_row`: append `event_planned` with `kind`, `props_json`, `planned_for`; project; assert `get_event(conn, event_id)` returns the row with `status="planned"`. +2. `test_event_started_then_completed_updates_status`: append `event_planned` → `event_started` → `event_completed`; assert `status` transitions and `completed_at` populated. +3. `test_event_cancelled_terminal`: append `event_planned` → `event_cancelled`; assert `status="cancelled"`. A subsequent `event_started` is ignored (handler no-op when status is terminal). + +**Step 3: implementation** — `0009_events.sql`: + +```sql +CREATE TABLE events ( + id INTEGER PRIMARY KEY, + chat_id TEXT NOT NULL, + kind TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'planned', + props_json TEXT NOT NULL DEFAULT '{}', + planned_for TEXT, + started_at TEXT, + completed_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX events_chat_idx ON events(chat_id, status); +``` + +`chat/state/events.py`: + +- `@on("event_planned")` inserts a new row with status `planned`. Payload provides a stable `event_id` (caller-allocated UUID) so the projector is idempotent. +- `@on("event_started")` updates status to `active` and sets `started_at` from payload (or current chat clock). +- `@on("event_completed")`, `@on("event_cancelled")`, `@on("event_expired")` each move to the named terminal state and stamp `completed_at` (the column doubles as "ended at"). +- `get_event(conn, event_id)`, `list_active_events(conn, chat_id)`, `list_events_in_status(conn, chat_id, status)` readers. +- All handlers no-op when the row is already in a terminal state (idempotent re-projection safety). + +**Step 5: commit** — `feat: events table + lifecycle handlers (T49)`. + +**Notes for the implementer:** + +- Use UUID-style ids (e.g., `f"evt_{uuid.uuid4().hex[:12]}"`) created by the caller; pass as `event_id` in payload. Don't auto-generate inside the projector. +- Schema version after this migration alone: 9. The full Phase 3 baseline is 10 (T51 adds 0010_threads.sql). +- `tests/test_world.py::test_schema_version_after_migration_is_8` will need to bump after Wave 1 merges — handle in the wave-merge step (mirrors Phase 2 T36's pattern). + +--- + +### Task 50: Time-skip event kinds + chat-clock handlers + +**Files:** + +- Modify: `chat/state/world.py` (add `_apply_time_skip_elision`, `_apply_time_skip_jump`; both update `chats.time` and may reset `activity` rows) +- Create: `tests/test_time_skip_handlers.py` + +**Spec:** Two new event kinds. + +- `time_skip_elision` payload: `{chat_id, new_time}`. Handler updates `chats.time = ?`. Activity rows are NOT reset (the activity that was elided to its end-state is the resolution itself; the caller passes a follow-up `activity_changed` event when needed). +- `time_skip_jump` payload: `{chat_id, new_time, reset_activity: bool}`. Handler updates `chats.time = ?`; if `reset_activity` is true, deletes per-chat `activity` rows for the participants in that chat (a fresh landing state will be set by a follow-up `activity_changed` event from the skip service). + +These are pure state mutations. T54 and T62 fire them via `append_and_apply`. + +**Tests:** 3 minimum. + +1. `test_elision_advances_chat_clock_only`: seed chat at time T0; append `time_skip_elision` with `new_time=T1`; project; assert `get_chat(...)["time"] == T1` and activity unchanged. +2. `test_jump_with_reset_clears_activity`: seed chat with one activity row; append `time_skip_jump` with `reset_activity=True`; assert chat clock advanced AND activity table empty for that chat. +3. `test_jump_without_reset_preserves_activity`: same seed; `reset_activity=False`; assert activity row still present and clock advanced. + +**Implementation:** new handlers next to `_apply_chat_created` in `chat/state/world.py`. Use the same parameterized SQL patterns. Do NOT add UI here — T62 wires the skip command flow. + +**Commit:** `feat: time_skip event handlers (T50)`. + +--- + +### Task 51: Threads table + open/update/close handlers + +**Files:** + +- Create: `chat/db/migrations/0010_threads.sql` +- Create: `chat/state/threads.py` +- Create: `tests/test_threads_state.py` + +**Spec:** Adds the `threads` table and projector handlers for `thread_opened`, `thread_updated`, `thread_closed`. A thread is a per-chat narrative continuity tag — open during scenes, surfaced to prompt assembly so successor scenes can reference unresolved arcs. + +`0010_threads.sql`: + +```sql +CREATE TABLE threads ( + id INTEGER PRIMARY KEY, + chat_id TEXT NOT NULL, + title TEXT NOT NULL, + summary TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'open', -- open | closed + opened_at TEXT NOT NULL DEFAULT (datetime('now')), + closed_at TEXT, + last_referenced_scene_id INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX threads_chat_status_idx ON threads(chat_id, status); +``` + +`chat/state/threads.py`: + +- `@on("thread_opened")` payload: `{thread_id, chat_id, title, summary?}`. Inserts a new row with `status='open'`. +- `@on("thread_updated")` payload: `{thread_id, summary, last_referenced_scene_id?}`. Updates summary + optional last-referenced-scene pointer. +- `@on("thread_closed")` payload: `{thread_id, closed_at?}`. Sets status='closed', stamps `closed_at`. +- Readers: `get_thread(conn, thread_id)`, `list_open_threads(conn, chat_id)`, `list_threads(conn, chat_id, status=None)`. + +**Tests:** 3 minimum. + +1. `test_thread_opened_creates_row`. +2. `test_thread_updated_changes_summary_and_last_referenced`. +3. `test_thread_closed_terminal`: subsequent `thread_updated` is ignored (matches the design's "closed threads are kept for replay but don't surface in prompt"). + +**Note:** the Phase 2 `group_node.threads_json` column was a Phase-3 placeholder and is NOT used as authoritative storage now — `threads` table is the source of truth. The drawer can choose to render either, but Phase 3 onward should treat the table as canonical and treat `group_node.threads_json` as a deprecated cache that we leave alone (or clear in the next migration). + +**Commit:** `feat: threads table + projector handlers (T51)`. + +--- + +## Wave 2 — Classifier services (parallel) + +Four tasks, all new service modules — fully file-disjoint. + +### Task 52: Event-lifecycle detection service + +**Files:** + +- Create: `chat/services/event_lifecycle.py` +- Create: `tests/test_event_lifecycle.py` + +**Spec:** A classifier-wrapped service that inspects a freshly-narrated turn and decides whether any active events transitioned this turn (started, completed, cancelled). Returns a structured `EventLifecycleDecision` with one or more `EventTransition(event_id, new_status, reason)` items, or empty when nothing changed. + +Schema: + +```python +class EventTransition(BaseModel): + event_id: str + new_status: str # "active" | "completed" | "cancelled" + reason: str = "" + +class EventLifecycleDecision(BaseModel): + transitions: list[EventTransition] = Field(default_factory=list) +``` + +Public API: + +```python +async def detect_event_transitions( + client: LLMClient, + *, + classifier_model: str, + narrative_text: str, + active_events: list[dict], # [{id, kind, status, props}, ...] from list_active_events + timeout_s: float = 30.0, +) -> EventLifecycleDecision: + """Decide whether any active events transitioned this turn. Conservative + bias — most turns return empty transitions. Trigger only when the + narrative text clearly resolves or starts a known active event. + """ +``` + +Caller (T61 turn flow) appends one `event_started` / `event_completed` / `event_cancelled` event per transition via `append_and_apply`. + +**Tests:** 3 minimum — happy path with one transition, empty active_events short-circuits without classifier call, classifier failure returns empty default. + +**Commit:** `feat: event-lifecycle detection service (T52)`. + +--- + +### Task 53: Skip narration service + +**Files:** + +- Create: `chat/services/skip_narration.py` +- Create: `tests/test_skip_narration.py` + +**Spec:** Generates the brief transition narration that bridges a time skip. Two flavors mirroring §9: + +- **Elision:** "skip to when we arrive". Input: current activity ("walking to park"), expected end-state ("at the park, sitting on a bench"). Output: 1-2 sentence transition prose narrated from the host bot's POV. New chat-clock value is provided by the caller. +- **Jump:** "next morning". Input: time delta + landing-state hint (optional). Output: 2-3 sentences setting the scene at the new time. + +Public API: + +```python +async def narrate_skip( + client: LLMClient, + *, + narrative_model: str, + skip_kind: str, # "elision" | "jump" + speaker_bot: dict, # {id, name, persona} + you_name: str, + current_time: str, + new_time: str, + current_activity: str, + landing_state_hint: str = "", + timeout_s: float = 60.0, +) -> str: + """Generate brief transition prose. Returns plain text, not JSON.""" +``` + +Uses `client.generate(...)` (not `classify`) since output is free-form prose. Falls back to a deterministic template string on failure (e.g., `f"({new_time}: {landing_state_hint or current_activity}.)"`). The fallback ensures the skip flow never blocks even when the LLM is down. + +**Tests:** 3 minimum — happy elision, happy jump, generation failure returns fallback string with the new time visible. + +**Commit:** `feat: skip narration service (T53)`. + +--- + +### Task 54: Synthesized-memories service + +**Files:** + +- Create: `chat/services/synthesized_memories.py` +- Create: `tests/test_synthesized_memories.py` + +**Spec:** When the user does a jump skip ("a week later") they're prompted "anything notable happen?" If they answer with prose, this service parses that prose into 1-N synthesized memories per present bot. Each memory carries `source="synthesized"`, `reliability=0.7`, witness mask `[1, 1, 0]` or `[1, 1, 1]` per present set, and a one-sentence text body. + +Schema: + +```python +class SynthesizedMemory(BaseModel): + text: str + significance: int = 1 # 0..3, default 1 + affinity_delta: int = 0 + trust_delta: int = 0 + +class SynthesizedDigest(BaseModel): + memories: list[SynthesizedMemory] = Field(default_factory=list) +``` + +Public API: + +```python +async def synthesize_memories( + client: LLMClient, + *, + classifier_model: str, + prose: str, + bot_name: str, # which witness's POV + bot_persona: str, + you_name: str, + timeout_s: float = 30.0, +) -> SynthesizedDigest: + """Parse 'anything notable happen?' prose into structured memories + from a single bot's POV. Empty/whitespace prose short-circuits.""" +``` + +Caller (T62 skip flow) calls this once per present bot (host always; guest if present), then writes via `record_turn_memory_for_present` with `source="synthesized"` and the synthesized text in place of narrative_text. + +**Tests:** 3 minimum — happy path returns parseable memories, empty prose short-circuits, classifier failure returns empty digest. + +**Commit:** `feat: synthesized-memories service for jump skips (T54)`. + +--- + +### Task 55: Thread-detection service + +**Files:** + +- Create: `chat/services/thread_detection.py` +- Create: `tests/test_thread_detection.py` + +**Spec:** On scene close, classify the scene transcript to detect open threads (unresolved arcs, dangling questions, promises made). Returns a list of `ThreadCandidate(title, summary, action: "open"|"update"|"close", existing_thread_id?)`. + +The service receives the current set of open threads so it can decide to **update** an existing thread rather than open a duplicate. It can also signal **close** when the transcript clearly resolves an open thread. + +Schema: + +```python +class ThreadCandidate(BaseModel): + action: str # "open" | "update" | "close" + title: str = "" # required for "open"; ignored otherwise + summary: str = "" + existing_thread_id: str | None = None # required for "update"/"close" + +class ThreadDetectionResult(BaseModel): + candidates: list[ThreadCandidate] = Field(default_factory=list) +``` + +Public API: + +```python +async def detect_threads( + client: LLMClient, + *, + classifier_model: str, + scene_transcript: list[dict], # [{speaker, text}, ...] + open_threads: list[dict], # [{id, title, summary}, ...] + timeout_s: float = 30.0, +) -> ThreadDetectionResult: + """Classify scene close into thread open/update/close candidates.""" +``` + +Caller (T58 scene compression — added in Wave 3) loops over candidates and emits one `thread_opened`, `thread_updated`, or `thread_closed` event per candidate. + +**Tests:** 3 minimum — opens a new thread, updates an existing thread (test asserts `existing_thread_id` is honored), classifier failure returns empty. + +**Commit:** `feat: thread-detection service (T55)`. + +--- + +## Wave 3 — Promotion & retrieval refinements + +Three tasks. T56 is a new service module (event-completion promotion). T57 modifies `chat/state/memory.py` to add a significance-aware retrieval rank. T58 modifies `chat/services/scene_summarize.py` to integrate compression hints + the thread-detection service from T55. File-disjoint. + +### Task 56: Event-completion promotion + +**Files:** + +- Create: `chat/services/event_promotion.py` +- Create: `tests/test_event_promotion.py` + +**Spec:** When an event reaches `completed` (the only terminal state that promotes; cancelled/expired do NOT promote per §9 last paragraph), the orchestrator promotes any structured artifacts the event carried into the appropriate target store: + +- `event.props.acquired_objects: list[str]` → append `inventory_added` events (Phase 4 schema; Phase 3 stub: just append a `manual_edit` with `target_kind="memory_pov_summary"` describing the acquisition into the host's memory). +- `event.props.knowledge_facts: list[{owner_id, target_id, fact}]` → append `edge_update` events with the facts on the named directed edge. +- `event.props.relationship_change: {summary, source_id, target_id}` → append `manual_edit` with `target_kind="edge_summary"` for that pair. +- Everything else stays in the closed event record (the projector kept the row; no further promotion). + +Public API: + +```python +def promote_completed_event( + conn, + *, + event_id: str, + chat_id: str, + chat_clock_at: str | None, +) -> dict: + """Read the completed event's props_json and emit promotion events. + Returns a summary dict {inventory: int, knowledge: int, relationship: int} + of how many promotion events fired. No classifier calls — purely + structural. Skips if event status isn't 'completed'.""" +``` + +This is **synchronous** (no async, no LLM). It reads a row, parses JSON, emits events via `append_and_apply`. + +**Tests:** 4 minimum — empty props no-op, knowledge_facts produces edge_update events, relationship_change produces manual_edit, cancelled-event-doesn't-promote. + +**Commit:** `feat: event-completion promotion service (T56)`. + +--- + +### Task 57: Significance-aware retrieval ranking + +**Files:** + +- Modify: `chat/state/memory.py` (extend `search_memories(conn, owner_id, witness_role, query, k)` to add a significance bias to the rank ordering) +- Modify: `tests/test_memory_search.py` (or wherever the existing search tests live; add 2 tests) + +**Spec:** Currently `search_memories` orders by FTS rank only. §11.1 says "Retrieval ranking: significance multiplier applied as `score × constant` to FTS / vector rank." Phase 3 implements this for FTS only (vector retrieval is Phase 4). + +Change the SQL `ORDER BY` from `ORDER BY rank` to `ORDER BY (rank + significance * 0.5) DESC` (or whatever scaling produces sane results — this is a tuning knob, document the choice in a comment). The constant may need adjustment after manual play; surface it as a module-level constant `SIGNIFICANCE_RANK_BIAS`. + +**Tests:** 2 added. + +1. `test_higher_significance_outranks_equal_rank`: seed two memories with identical FTS-matching text but different significance scores; assert the higher-significance row appears first in results. +2. `test_significance_bias_is_constant_module_level`: verify the constant is accessible as `chat.state.memory.SIGNIFICANCE_RANK_BIAS` (so it's tunable without a code change in calling sites). + +**Commit:** `feat: significance-aware retrieval ranking (T57)`. + +--- + +### Task 58: Scene compression keeps key quotes when significance ≥ 2 + +**Files:** + +- Modify: `chat/services/scene_summarize.py` (extend `apply_scene_close_summary` to also call `detect_threads` from T55 and emit thread events; extend the per-POV summary to include up to 3 verbatim "key quotes" from the closing scene when scene-max-significance ≥ 2) +- Modify: `tests/test_per_pov_summary.py` (add 3 tests for the new behavior) + +**Spec:** §11.1 specifies "Compression: scenes with max-turn-significance ≥ 2 retain key quotes; ≤ 1 collapse fully into the per-POV summary." Implement this: + +- Compute scene max significance from `memories.significance` rows in this scene. +- When max < 2: existing behavior unchanged (per-POV summary, no extra quotes). +- When max ≥ 2: include up to 3 verbatim quote spans (each ≤ 200 chars) in the per-POV summary text. Format: append `\n\nKey quotes:\n- "..."\n- "..."` to the summary. The `summarize_scene` classifier already produces the prose; the quote-selection step is a deterministic post-process that picks the top-3 highest-significance turn texts from the scene transcript (truncated). + +Additionally, after writing per-POV summaries (existing behavior), call `detect_threads` (from T55) once per close. For each candidate emit the matching `thread_opened` / `thread_updated` / `thread_closed` event via `append_and_apply`. Failures fall back to no thread changes (existing memory + edge updates still land). + +**Tests:** 3 added. + +1. `test_low_significance_scene_omits_quotes`: max significance = 1; assert summary text contains no "Key quotes:" header. +2. `test_high_significance_scene_includes_top_3_quotes`: seed 4 memories with significance 3, 2, 1, 2; assert summary contains the top-3 (by significance) verbatim turn texts. +3. `test_thread_detection_emits_events`: stub `detect_threads` to return one `ThreadCandidate(action="open", ...)`; assert a `thread_opened` event landed. + +**Commit:** `feat: significance-driven quote retention + thread emission on close (T58)`. + +--- + +## Wave 4 — Drawer additions (single task) + +This wave is one task because all Phase 3 drawer additions touch `chat/web/drawer.py` and `chat/templates/_drawer.html` together — splitting would force serial execution with conflicts. + +### Task 59: Drawer events / threads / skip controls + +**Files:** + +- Modify: `chat/web/drawer.py` (extend `GET /chats/{chat_id}/drawer`; add `POST /chats/{chat_id}/drawer/event/plan`, `/drawer/event/cancel/{event_id}`, `/drawer/skip/elision`, `/drawer/skip/jump`, `/drawer/thread/close/{thread_id}`) +- Modify: `chat/templates/_drawer.html` (3 new sections: Events, Threads, Skip controls) +- Create: `tests/test_drawer_events_threads_skip.py` + +**Spec:** + +**GET extension:** + +- `list_active_events(conn, chat_id)` → render in a new "Events" section. +- `list_open_threads(conn, chat_id)` → render in a new "Threads" section. +- A "Skip" subsection with two buttons: "Elision skip" (opens an inline form taking a `landing_state_hint`) and "Jump skip" (opens an inline form taking `target_time` ISO + optional `notable_prose` for the synthesized-memories prompt). + +**POST routes:** + +1. `POST /drawer/event/plan` — form `{kind, planned_for, props_json}` → 400-validates JSON, appends `event_planned`, returns refreshed drawer. +2. `POST /drawer/event/cancel/{event_id}` — appends `event_cancelled`, returns refreshed drawer. +3. `POST /drawer/skip/elision` — form `{landing_state_hint, new_time}` → calls `narrate_skip` (T53), appends `time_skip_elision` + an `assistant_turn` carrying the narration, returns refreshed drawer + chat partial. +4. `POST /drawer/skip/jump` — form `{new_time, notable_prose, reset_activity}` → calls `narrate_skip` for transition prose, calls `synthesize_memories` (T54) for each present bot, appends `time_skip_jump` + memories + transition turn, returns refreshed drawer + chat partial. +5. `POST /drawer/thread/close/{thread_id}` — appends `thread_closed`, returns refreshed drawer. + +**Template additions:** + +- "Events" section listing each active event by kind + planned_for + props. +- "Threads" section listing each open thread title + summary + a Close button. +- "Skip" controls under existing Activity section. +- Forms use HTMX (`hx-post`, `hx-target="#drawer"`, `hx-swap="innerHTML"`) consistent with Phase 2 drawer patterns. + +**Tests (`tests/test_drawer_events_threads_skip.py`):** 6 minimum. + +1. GET drawer with no events/threads → no Events/Threads sections rendered. +2. POST event/plan with valid form → event_planned event appended; drawer body now contains the event title. +3. POST event/cancel → event_cancelled appended; drawer no longer lists the event under "Active". +4. POST skip/elision → time_skip_elision appended, chat clock advanced, narration assistant_turn present in chat history. +5. POST skip/jump with notable_prose → time_skip_jump + N synthesized memory_written events; assert reliability=0.7 on those rows. +6. POST thread/close → thread_closed appended; thread no longer in open list. + +**Commit:** `feat: drawer events / threads / skip controls (T59)`. + +**Notes for implementer:** + +- The existing `available_guests` dropdown helper from T42 is the reference for form-population patterns. +- For the Jump skip's `notable_prose` field, treat empty as "no synthesized memories" (just advance the clock) — the spec allows this. +- Validate `target_time` ISO format; 400 on parse failure. Do not allow target_time earlier than current chat clock. + +--- + +## Wave 5a — Prompt + turn-flow integration (parallel) + +T60 modifies `chat/services/prompt.py`. T61 modifies `chat/web/turns.py`. File-disjoint. + +### Task 60: Prompt assembly includes active events + active threads + +**Files:** + +- Modify: `chat/services/prompt.py` (extend `assemble_narrative_prompt`) +- Modify: `tests/test_prompt.py` (add 3 tests) + +**Spec:** Two new SHOULD-tier blocks added between the existing scene-context block and retrieved-memories block: + +1. **Active events** — title `Active events:`. Lists each active event in this chat: `- {kind} (planned for {planned_for})` plus a one-line props excerpt (truncate to ~80 chars). Trim-tier SHOULD; drops before retrieved memories under tight budget. +2. **Active threads** — title `Open threads:`. Lists each open thread: `- {title}: {summary}` (summary truncated to ~120 chars). SHOULD-tier. + +Both blocks are omitted entirely when their lists are empty (no header rendered). + +Per Phase 2 T43's auto-detection precedent, the function reads `list_active_events(conn, chat_id)` and `list_open_threads(conn, chat_id)` itself; no new parameters. + +**Tests:** 3 added. + +1. `test_assemble_with_no_events_or_threads_omits_blocks` — regression; no events/threads → assembled prompt has neither block. +2. `test_assemble_with_active_events_renders_block` — seed one event_planned + event_started; assert "Active events:" header and event kind appear in prompt. +3. `test_assemble_with_open_thread_renders_block` — seed one thread_opened; assert "Open threads:" header and thread title appear. + +**Commit:** `feat: prompt assembly renders active events + open threads (T60)`. + +--- + +### Task 61: Turn flow invokes event-detection + thread-update per turn + +**Files:** + +- Modify: `chat/web/turns.py` (after the primary narrative + memory + state-update block, call `detect_event_transitions` from T52; emit `event_started`/`event_completed`/`event_cancelled` events accordingly) +- Modify: `chat/services/regenerate.py` (mirror — regenerate also re-detects event transitions for the regenerated turn) +- Modify: `tests/test_turn_flow.py` (add 3 tests) + +**Spec:** After the existing post-turn classifier passes (memory write, state update, interjection check) and BEFORE scene-close detection, call `detect_event_transitions` with `narrative_text=primary_text` and `active_events=list_active_events(conn, chat_id)`. + +For each `EventTransition` returned: + +- `new_status="active"` → append `event_started` payload `{event_id, started_at: chat.time}`. +- `new_status="completed"` → append `event_completed` payload `{event_id, completed_at: chat.time}` AND THEN call `promote_completed_event` (T56) inline so promotion events emit synchronously after completion. +- `new_status="cancelled"` → append `event_cancelled`. Promotion is skipped. + +Empty transitions list = no-op (most turns; no extra events written). + +`regenerate.py` mirrors the same logic for the regenerated turn (existing event transitions from the superseded turn are NOT undone — that's a Phase 3.5 follow-up; document the limitation). + +**Tests:** 3 added to `tests/test_turn_flow.py`. + +1. `test_turn_with_event_transition_appends_started_event`: mock `detect_event_transitions` to return one transition; assert `event_started` lands in event log; canned-response queue matches. +2. `test_turn_with_event_completion_runs_promotion`: same mock returning `new_status="completed"`; seed a planned event with knowledge_facts in props; assert `event_completed` + `edge_update` (from promotion) both land. +3. `test_turn_with_no_active_events_skips_classifier`: no active events; assert `detect_event_transitions` is never called (its canned response slot would still be in the queue at end of test). + +**Commit:** `feat: per-turn event-lifecycle detection + completion promotion (T61)`. + +--- + +## Wave 5b — Skip command flow (single task) + +Single task because it modifies `chat/web/turns.py` (which Wave 5a also touched). Run after Wave 5a is merged so the file's recent additions are stable. + +### Task 62: Skip command surface + +**Files:** + +- Modify: `chat/web/turns.py` (extend `parse_turn` to detect natural-language skip commands like "skip to the park", "next morning", "a week later" and route to a skip-handling branch BEFORE the normal narrative flow) +- Create: `chat/web/skip.py` (new module hosting `process_elision_skip(...)` and `process_jump_skip(...)` controllers; called by both turns.py and the drawer skip routes from T59) +- Modify: `tests/test_turn_flow.py` (add 3 tests) + +**Spec:** Currently `parse_turn` extracts the user's prose into structured fields (addressee inferred, etc.). Phase 3 adds detection of skip commands as a separate intent. + +The classifier-based parse already produces an `intent` field (or similar — verify in code). Extend the schema with `intent="skip_elision"` and `intent="skip_jump"`. When intent is one of these, the turn flow short-circuits the normal narrative path and routes to: + +- `process_elision_skip(conn, client, settings, *, chat_id, landing_state_hint=parsed.landing_state)` — calls `narrate_skip(skip_kind="elision")`, appends `time_skip_elision`, `assistant_turn` carrying narration, returns 204. +- `process_jump_skip(conn, client, settings, *, chat_id, target_time=parsed.target_time, notable_prose=parsed.notable_prose)` — appends `time_skip_jump`, calls `synthesize_memories` per present bot, appends synthesized `memory_written` events, calls `narrate_skip(skip_kind="jump")`, appends `assistant_turn` carrying transition prose, returns 204. + +The drawer routes from T59 share these functions (don't duplicate the logic across drawer.py and turns.py). + +For Phase 3's first cut, JUMP skip's `notable_prose` is NOT collected from natural-language ("a week later, anything notable?" requires a UI prompt). Two options: + +- **(simpler)** Drawer-only entry for jump skip; natural-language jump short-circuits to drawer prompt. +- **(better UX)** Natural-language jump returns a 422 with an HTMX-swap that injects the "anything notable?" textarea into the chat surface; user submits prose to a follow-up `/chats/{chat_id}/skip/jump/confirm` endpoint. + +Pick the simpler path for Phase 3 (drawer-only jump). Document the second option as a Phase 3.5 polish. + +**Tests:** 3 added. + +1. `test_elision_skip_via_natural_language` — user prose "skip to when we arrive at the park"; assert `time_skip_elision` event landed and chat clock advanced; an `assistant_turn` carrying transition prose was appended. +2. `test_jump_skip_via_natural_language_redirects_to_drawer` — user prose "next morning"; assert response is 422 with an HTMX swap pointing at the drawer's jump form (or whatever the chosen Phase 3 fallback is). +3. `test_skip_command_does_not_run_narrative_classifier` — same user prose as test 1; assert `assemble_narrative_prompt` was NOT called for a regular bot turn (the skip path bypasses it). + +**Commit:** `feat: natural-language skip detection + skip command flow (T62)`. + +--- + +## Wave 6 — Meanwhile scenes + +Phase 3's capstone feature. Most ambitious: scene config 4 (host + guest, no "you"). Per §13 the cap stays at 2 bots in any scene; meanwhile is two-bot bot↔bot. "You" receives a digest later, not during. + +Decomposed into 3 tasks. T63 lands first (schema + state); then T64 + T65 in parallel. + +### Task 63: Meanwhile scene config — schema + state + +**Files:** + +- Create: `chat/db/migrations/0011_meanwhile_scenes.sql` +- Create: `chat/state/meanwhile.py` +- Create: `tests/test_meanwhile_state.py` + +**Spec:** A meanwhile scene is a special kind of scene where `present_set = {host_bot_id, guest_bot_id}` (no "you"). The existing `scenes` table can carry it via a new `present_set_kind` column distinguishing `you_host`, `you_host_guest`, `host_guest`. Alternatively, `meanwhile_scenes` is a sidecar table — pick the lower-disruption option. + +**Recommended:** add a `present_set_kind` column to `scenes` (default `'you_host'` for back-compat) via migration `0011_meanwhile_scenes.sql`: + +```sql +ALTER TABLE scenes ADD COLUMN present_set_kind TEXT NOT NULL DEFAULT 'you_host'; +ALTER TABLE scenes ADD COLUMN parent_scene_id INTEGER; -- the active you-scene this meanwhile branched off from +CREATE INDEX scenes_present_set_idx ON scenes(chat_id, present_set_kind, status); +``` + +New event kinds with `chat/state/meanwhile.py` handlers: + +- `@on("meanwhile_scene_started")` payload: `{chat_id, scene_id, host_bot_id, guest_bot_id, parent_scene_id, started_at}`. Inserts a new scene row with `present_set_kind="host_guest"`, links to parent. +- `@on("meanwhile_scene_closed")` payload: `{scene_id, closed_at}`. Updates status to `closed`; subsequent per-POV summary writes for both bots happen via existing scene-close path (host + guest are the "present witnesses"; "you" is excluded). + +Readers: `list_meanwhile_scenes(conn, chat_id, status='active')`, `get_parent_scene(conn, scene_id)`. + +**Tests:** 3 minimum. + +1. `test_meanwhile_started_creates_scene_with_correct_present_set_kind`. +2. `test_meanwhile_closed_marks_scene_closed`. +3. `test_active_you_scene_can_coexist_with_active_meanwhile_scene` (one chat, two active scenes — meanwhile + the main you-scene that spawned it). + +**Commit:** `feat: meanwhile scene schema + state (T63)`. + +--- + +### Task 64: Meanwhile turn flow + +**Files:** + +- Modify: `chat/web/turns.py` (add meanwhile-mode detection at the start of `post_turn`; if active meanwhile scene exists for this chat, route to `process_meanwhile_turn`) +- Create: `chat/web/meanwhile.py` (new module hosting `process_meanwhile_turn(...)` controller; mirrors post_turn but with no "you" in present_set) +- Modify: `chat/services/prompt.py` (small addition: when `present_set_kind="host_guest"`, exclude "you" from edges + activity blocks; addressee is always the other bot) +- Create: `tests/test_meanwhile_turn_flow.py` + +**Spec:** A meanwhile scene runs entirely between two bots. The user can advance it manually via a meanwhile-mode chat surface (T65 wires the UI), but turn-flow logic is: + +1. Read active meanwhile scene; identify `speaker_bot_id` (alternates each turn — start with host, then guest, etc.) and `addressee_bot_id` (the other one). +2. Assemble narrative prompt with `speaker_bot_id`, `addressee=addressee_bot.name`, `present_set_kind="host_guest"` (so "you" is omitted from edges/activities). +3. Stream narrative; commit `assistant_turn` event with `present_set_kind="host_guest"` and `meanwhile_scene_id` populated. +4. Memory writes: BOTH host and guest get a memory_written with witness `[0, 1, 1]` (you=0; you wasn't present). Use `record_turn_memory_for_present` adapted to the no-you case (or extend it with a `you_present: bool = True` parameter). +5. State updates: 2 directed pairs (host↔guest only). Skip you-related pairs. +6. Scene close detection: same path as regular scenes; on close, per-POV summaries fire for both bots; group_node updates if applicable. + +Addressee-alternation: simple — each turn alternates speaker. (Phase 3.5 may add classifier-driven turn-taking with refusals.) + +**Tests:** 4 minimum. + +1. `test_meanwhile_turn_writes_memories_with_witness_0_1_1`. +2. `test_meanwhile_turn_emits_2_edge_updates_only` (host→guest, guest→host). +3. `test_meanwhile_turn_alternates_speaker` (turn 1: host speaks; turn 2: guest speaks). +4. `test_meanwhile_scene_close_writes_per_pov_for_both_bots_only` (no "you" memory; existing T45 path is hit but with `you_present=False`). + +**Commit:** `feat: meanwhile turn flow (host+guest, no you) (T64)`. + +--- + +### Task 65: Meanwhile summary digest + +**Files:** + +- Modify: `chat/services/scene_summarize.py` (when a meanwhile scene closes, generate ALSO a "you-facing digest" — a brief narrated summary that will surface to "you" the next time the main you-scene resumes) +- Modify: `chat/services/prompt.py` (when assembling for a regular you-scene and any closed-but-not-yet-surfaced meanwhile digests exist, include them as a SHOULD-tier block titled "Meanwhile while you were away:") +- Create: `chat/state/meanwhile_digest.py` (a small state module: `meanwhile_digest_pending` table; handlers for `meanwhile_digest_created` / `meanwhile_digest_consumed`) +- Modify: `tests/test_per_pov_summary.py` and `tests/test_prompt.py` (add tests) + +**Spec:** When a meanwhile scene closes (T64's path), also append `meanwhile_digest_created` with `{chat_id, scene_id, summary}`. The summary is generated via a fresh `summarize_scene` call with `bot_persona="omniscient narrator briefing the absent player"`; output is a 2-3 sentence neutral summary of what happened. + +When the next you-scene starts (or the prompt is assembled for the next active you-scene's turn), `assemble_narrative_prompt` queries `list_pending_meanwhile_digests(conn, chat_id)` and: + +- Includes them as a SHOULD-tier block: `"Meanwhile while you were away:\n- {summary}\n- {summary}"`. +- After they're surfaced once, the caller (T64 in the post-meanwhile turn or the first you-turn after meanwhile-close) appends `meanwhile_digest_consumed` per digest, marking them as surfaced. + +Migration `0011_meanwhile_scenes.sql` (T63) can include the `meanwhile_digest_pending` table OR T65 adds a thin `0012_meanwhile_digest.sql`. Pick lower-disruption — likely add to T63's migration for simplicity. Document the choice. + +(If you choose to add the table in T65 via a new migration, add `0012_meanwhile_digest.sql`. The schema-version assertion bump in `tests/test_world.py` happens once after Wave 6 merges.) + +**Tests:** 3 added. + +1. `test_meanwhile_close_creates_digest`: close a meanwhile scene; assert `meanwhile_digest_pending` row exists with non-empty summary. +2. `test_pending_digest_renders_in_you_scene_prompt`: seed a pending digest; assemble prompt for a you-host scene; assert the "Meanwhile while you were away:" header and summary appear. +3. `test_consumed_digest_does_not_render_again`: append `meanwhile_digest_consumed`; reassemble prompt; digest no longer appears. + +**Commit:** `feat: meanwhile summary digest surfaces to next you-scene (T65)`. + +--- + +## Wave 7 — Polish (parallel) + +Two independent tasks. New test file (T66) + docs only (T67). Dispatch in parallel after Wave 6 merges. + +### Task 66: Cross-feature integration tests + +**Files:** + +- Create: `tests/test_phase3_integration.py` + +**Spec:** Phase 3 introduces a lot of cross-feature interaction surfaces. This task adds tests that exercise multi-feature flows end-to-end: + +1. Plan an event → play turns → event_started detected → event_completed detected → promotion fires → memory + edge updates land. +2. Open a thread on close → next scene's prompt includes the open thread → close thread via drawer → next scene's prompt no longer includes it. +3. Jump skip → synthesized memories land per present bot → next turn's prompt retrieves them via search. +4. Meanwhile scene → close → digest pending → first you-turn prompt includes digest → after that turn, digest is consumed. +5. Meanwhile while a regular you-scene is active → both scenes have memories; querying memories for either bot at the post-meanwhile main scene correctly returns both sets witness-filtered. + +5 tests minimum. + +**Commit:** `test: phase 3 cross-feature integration coverage (T66)`. + +--- + +### Task 67: Phase 3 documentation update + +**Files:** + +- Modify: `CLAUDE.md` (add "Phase 3 status" section; update "Behavioral defaults"; add "Phase 3.5 / 4 backlog" with carry-overs from review feedback during execution) +- Modify: `docs/plans/2026-04-26-v1-requirements-design.md` (annotate §13 "Phase 3 — events, skips, threads" as **Status: shipped **) + +**Spec:** Documentation-only. Run last so it captures any deviations and review-noted follow-ups discovered during execution. Reflect: + +- Events with full lifecycle (planned → active → completed/cancelled/expired). +- Time skips: elision (immediate end-state) + jump (synthesized memories from "anything notable?"). +- Threads opened/updated/closed; surfaced in prompt assembly + drawer. +- Significance retrieval bias + key-quote retention at significance ≥ 2. +- Meanwhile scenes: bot+bot without "you"; per-POV summaries for both bots; you-facing digest on next you-scene. +- Phase 3 known limitations / 3.5 backlog candidates: + - Natural-language jump skip falls back to drawer form (no inline "anything notable?" prompt). + - Regenerate doesn't undo prior event transitions from the superseded turn. + - Meanwhile turn-taking is alternation (no classifier-driven refusals or initiative). + - Vector retrieval is still Phase 4. + +**Commit:** `docs: phase 3 status, behavioral defaults, deferred items (T67)`. + +--- + +## Wrap-up + +After Wave 7 lands: + +1. **Run full suite** on `phase-3`: should be ~260+ tests passing (212 from Phase 2 + ~50 new). +2. **Manual smoke** (recommended before opening the PR): + - Plan an event from the drawer; play turns until it completes; verify promotion landed (drawer shows updated edges / memories). + - Use elision and jump skips both via natural language and the drawer. + - Close a scene that opened a thread; verify the thread renders in the next scene's prompt. + - Trigger a meanwhile scene from the drawer; play 2 turns; close it; resume the main you-scene; verify the digest renders once and not again. +3. **Push `phase-3`** to gitea. +4. **Open PR** `phase-3 → main`. +5. **Phase 3.5 backlog candidates** (track in CLAUDE.md): inline natural-language jump prompt UI, regenerate-aware event-transition undo, classifier-driven meanwhile turn-taking, drawer surface for closed-event browsing, event template library (kind presets with default props). + +--- + +## Notes for the controller running this plan + +- **Don't dispatch Wave 5b until Wave 5a is merged AND green on `phase-3`.** Wave 5b's `turns.py` modifications layer on top of T61's recent additions; missing that produces merge conflicts or import-time failures. +- **Don't dispatch T64+T65 until T63 merges.** Both depend on the new `present_set_kind` column and the meanwhile event kinds. +- **After each parallel wave**, run a code-review subagent (`subagent-driven-development` skill's two-stage review pattern) on each task before merging to `phase-3`. For purely mechanical tasks (schema migrations, projector handlers), a combined spec+quality review is acceptable. For T62, T64, T65 (large or integration tasks), use separate spec + quality reviewers. +- **If a parallel wave's merge produces a conflict**, the wave's file-disjointness assumption was violated. Bisect the affected pair, fix the offending task in a follow-up commit on `phase-3`, and proceed. +- **Schema-version test bumps** happen at Wave 1 merge (8 → 10) and Wave 6 merge (10 → 11 or 12 depending on T65's migration choice). Update `tests/test_world.py` once per affected merge — same pattern as Phase 2 T36. +- **Token-spend rough estimate**: Phase 3 should be larger than Phase 2 (~1.5×) — events / skips / meanwhile each carry their own state + service + UI surfaces. Per-task token spend similar to Phase 2's larger tasks (T42, T44). +- **DO NOT modify Phase 1 / 2 code paths** unless explicitly required (e.g., T58 modifies `scene_summarize.py` because the new behavior is genuinely additive). Existing 1- and 2-entity flows must continue to work end-to-end after each wave. diff --git a/docs/plans/2026-04-26-v3-phase3-implementation.md.tasks.json b/docs/plans/2026-04-26-v3-phase3-implementation.md.tasks.json new file mode 100644 index 0000000..1f430e8 --- /dev/null +++ b/docs/plans/2026-04-26-v3-phase3-implementation.md.tasks.json @@ -0,0 +1,26 @@ +{ + "planPath": "docs/plans/2026-04-26-v3-phase3-implementation.md", + "tasks": [ + {"id": 49, "subject": "T49: events table + lifecycle handlers", "status": "pending", "wave": 1, "parallelGroup": "wave-1"}, + {"id": 50, "subject": "T50: time_skip event kinds + chat-clock handlers", "status": "pending", "wave": 1, "parallelGroup": "wave-1"}, + {"id": 51, "subject": "T51: threads table + open/update/close handlers", "status": "pending", "wave": 1, "parallelGroup": "wave-1"}, + {"id": 52, "subject": "T52: event-lifecycle detection service", "status": "pending", "wave": 2, "parallelGroup": "wave-2", "blockedBy": [49]}, + {"id": 53, "subject": "T53: skip narration service (elision + jump)", "status": "pending", "wave": 2, "parallelGroup": "wave-2", "blockedBy": [50]}, + {"id": 54, "subject": "T54: synthesized-memories service for jump skips", "status": "pending", "wave": 2, "parallelGroup": "wave-2", "blockedBy": [50]}, + {"id": 55, "subject": "T55: thread-detection service", "status": "pending", "wave": 2, "parallelGroup": "wave-2", "blockedBy": [51]}, + {"id": 56, "subject": "T56: event-completion promotion service", "status": "pending", "wave": 3, "parallelGroup": "wave-3", "blockedBy": [49, 52]}, + {"id": 57, "subject": "T57: significance-aware retrieval ranking", "status": "pending", "wave": 3, "parallelGroup": "wave-3"}, + {"id": 58, "subject": "T58: scene compression keeps key quotes + emits thread events", "status": "pending", "wave": 3, "parallelGroup": "wave-3", "blockedBy": [55]}, + {"id": 59, "subject": "T59: drawer events / threads / skip controls", "status": "pending", "wave": 4, "parallelGroup": null, "blockedBy": [49, 50, 51, 53, 54]}, + {"id": 60, "subject": "T60: prompt assembly includes active events + open threads", "status": "pending", "wave": 5, "parallelGroup": "wave-5a", "blockedBy": [49, 51]}, + {"id": 61, "subject": "T61: turn flow invokes event-detection + completion promotion", "status": "pending", "wave": 5, "parallelGroup": "wave-5a", "blockedBy": [52, 56]}, + {"id": 62, "subject": "T62: skip command surface (parse + route + jump UI)", "status": "pending", "wave": 5, "parallelGroup": null, "blockedBy": [50, 53, 54, 60, 61]}, + {"id": 63, "subject": "T63: meanwhile scene config — schema + state", "status": "pending", "wave": 6, "parallelGroup": null}, + {"id": 64, "subject": "T64: meanwhile turn flow (host+guest, no you)", "status": "pending", "wave": 6, "parallelGroup": "wave-6b", "blockedBy": [63]}, + {"id": 65, "subject": "T65: meanwhile summary digest surfaces to next you-scene", "status": "pending", "wave": 6, "parallelGroup": "wave-6b", "blockedBy": [63]}, + {"id": 66, "subject": "T66: cross-feature integration tests", "status": "pending", "wave": 7, "parallelGroup": "wave-7", "blockedBy": [62, 64, 65]}, + {"id": 67, "subject": "T67: Phase 3 documentation update", "status": "pending", "wave": 7, "parallelGroup": "wave-7", "blockedBy": [62, 64, 65]} + ], + "lastUpdated": "2026-04-26T00:00:00Z", + "notes": "19 tasks across 8 waves (1, 2, 3, 4, 5a, 5b, 6a, 6b, 7). Waves 1, 2, 3, 5a, and 7 are fully parallel-safe (file-disjoint within each). Waves 4, 5b, and 6a are single-task. Wave 6b is parallel after 6a (T63) merges. Use Agent tool with isolation: 'worktree' to dispatch parallel tasks. Merge each wave's worktrees back into phase-3 before dispatching the next wave. See plan §Parallel-Execution Strategy for full guidance. Schema baseline: Phase 2 ends at version 8; Phase 3 adds 0009_events.sql, 0010_threads.sql, 0011_meanwhile_scenes.sql (final version 11)." +}