diff --git a/bridge/frontier.py b/bridge/frontier.py new file mode 100644 index 0000000..2be7c4c --- /dev/null +++ b/bridge/frontier.py @@ -0,0 +1,89 @@ +"""Next-frontier suggestions — PRD acceptance criterion #19. + +'Every play session ends with at least one visible unresolved next-step +frontier.' + +next_frontier(state) inspects the current SessionState and returns a +single short, in-character medieval-voice string that tells the player +what's worth returning for. The function is pure: same state → same +suggestion. Order of evaluation prefers the most actionable signal +(an active session needing attention beats a generic 'come back later'). + +Returned string is always non-empty — even an empty kingdom gets a +'found your first city' onboarding nudge so the player has a frontier. +""" + +from __future__ import annotations + +from typing import Optional + +from bridge.session_state import Session, SessionState, SessionStatus + + +def next_frontier(state: SessionState) -> str: + """Return one short medieval-voice 'come back to do X' string.""" + sessions = state.sessions + + if not sessions: + return "Found your first city — open a Claude Code session and the realm begins." + + # Priority order: redirected (acute, transient) > waiting (input expected) + # > inactive (registered but quiet) > momentum-buildup > momentum-tier-up + # > goal-pending > generic come-back. + + # 1) Any redirected session — surface that first; momentum is on the line. + redirected = [s for s in sessions if s.status == SessionStatus.REDIRECTED] + if redirected: + s = redirected[0] + return f"'{s.name}' is redirected — return to rebuild momentum." + + # 2) Waiting sessions — the agent expects input, this is the warmest re-entry. + waiting = [s for s in sessions if s.status == SessionStatus.WAITING] + if waiting: + s = waiting[0] + return f"'{s.name}' awaits your council. The next charter starts with you." + + # 3) Inactive sessions — registered but never engaged. + inactive = [s for s in sessions if s.status == SessionStatus.INACTIVE] + if inactive: + s = inactive[0] + return f"'{s.name}' is registered but quiet — open the session to begin." + + # 4) Active session about to tier up its momentum (next +1 culture). + actives = [s for s in sessions if s.status == SessionStatus.ACTIVE] + if actives: + s = max(actives, key=lambda x: x.momentum) + next_tier = ((s.momentum // 10) + 1) * 10 + gap = next_tier - s.momentum + if 0 < gap <= 3: + return f"'{s.name}' is {gap} from the next culture tier." + # 5) Generic come-back for active sessions. + return f"'{s.name}' is at work; return to claim the next charter." + + # 6) All suspended (game-closed) — give a return invite. + return "The chronicles preserve your work. Return when you're ready." + + +def frontier_priority_kind(state: SessionState) -> str: + """Return a stable identifier for which frontier kind fired. + + Useful for testing + telemetry. Mirrors next_frontier's branches. + """ + sessions = state.sessions + if not sessions: + return "first_city" + if any(s.status == SessionStatus.REDIRECTED for s in sessions): + return "redirected" + if any(s.status == SessionStatus.WAITING for s in sessions): + return "waiting" + if any(s.status == SessionStatus.INACTIVE for s in sessions): + return "inactive" + actives = [s for s in sessions if s.status == SessionStatus.ACTIVE] + if actives: + s = max(actives, key=lambda x: x.momentum) + next_tier = ((s.momentum // 10) + 1) * 10 + gap = next_tier - s.momentum + if 0 < gap <= 3: + return "momentum_tier_up_imminent" + return "active_general" + return "all_suspended" diff --git a/bridge/tests/test_frontier.py b/bridge/tests/test_frontier.py new file mode 100644 index 0000000..4b0d963 --- /dev/null +++ b/bridge/tests/test_frontier.py @@ -0,0 +1,127 @@ +"""Tests for the next-frontier helper (PRD acceptance #19).""" + +import pytest + +from bridge.frontier import frontier_priority_kind, next_frontier +from bridge.session_state import SessionState, SessionStatus + + +def _state_with_session(name: str, status: SessionStatus, **kwargs) -> SessionState: + state = SessionState(kingdom_name="Camelot") + s = state.add_session(name) + s.status = status + for k, v in kwargs.items(): + setattr(s, k, v) + return state + + +# ──────────────────────────────────────────────────────────────────────── +# Always non-empty — acceptance criterion guarantee +# ──────────────────────────────────────────────────────────────────────── + +def test_empty_state_yields_first_city_frontier(): + state = SessionState() + out = next_frontier(state) + assert out # non-empty + assert "first city" in out.lower() + assert frontier_priority_kind(state) == "first_city" + + +def test_all_suspended_state_yields_chronicles_invite(): + state = SessionState() + s1 = state.add_session("X") + s1.status = SessionStatus.SUSPENDED + s2 = state.add_session("Y") + s2.status = SessionStatus.SUSPENDED + out = next_frontier(state) + assert "chronicles" in out.lower() or "return" in out.lower() + assert frontier_priority_kind(state) == "all_suspended" + + +# ──────────────────────────────────────────────────────────────────────── +# Priority ordering +# ──────────────────────────────────────────────────────────────────────── + +def test_redirected_takes_priority_over_active(): + state = SessionState() + a = state.add_session("Active") + a.status = SessionStatus.ACTIVE + a.momentum = 8 + r = state.add_session("Redirected") + r.status = SessionStatus.REDIRECTED + assert frontier_priority_kind(state) == "redirected" + assert "Redirected" in next_frontier(state) + + +def test_waiting_beats_inactive_and_active_idle(): + state = SessionState() + state.add_session("Inactive").status = SessionStatus.INACTIVE + state.add_session("Waiting").status = SessionStatus.WAITING + state.add_session("Active").status = SessionStatus.ACTIVE + assert frontier_priority_kind(state) == "waiting" + assert "Waiting" in next_frontier(state) + + +def test_inactive_beats_active_when_no_redirect_or_waiting(): + state = SessionState() + state.add_session("Inactive").status = SessionStatus.INACTIVE + a = state.add_session("Active") + a.status = SessionStatus.ACTIVE + a.momentum = 4 + assert frontier_priority_kind(state) == "inactive" + + +# ──────────────────────────────────────────────────────────────────────── +# Active-session frontiers +# ──────────────────────────────────────────────────────────────────────── + +def test_active_session_near_culture_tier_calls_out_the_gap(): + state = _state_with_session("Court", SessionStatus.ACTIVE, momentum=8) + out = next_frontier(state) + assert frontier_priority_kind(state) == "momentum_tier_up_imminent" + assert "2 from" in out # 10 - 8 = 2 + assert "culture" in out + + +def test_active_session_far_from_tier_uses_generic_message(): + state = _state_with_session("Court", SessionStatus.ACTIVE, momentum=4) + out = next_frontier(state) + assert frontier_priority_kind(state) == "active_general" + assert "claim" in out.lower() or "charter" in out.lower() + + +def test_active_at_exact_tier_uses_generic(): + """At momentum=10 the gap is 10 (next tier 20-10), so generic message.""" + state = _state_with_session("Court", SessionStatus.ACTIVE, momentum=10) + assert frontier_priority_kind(state) == "active_general" + + +def test_active_with_highest_momentum_chosen(): + """If multiple actives, the one with highest momentum is the frontier.""" + state = SessionState() + a = state.add_session("Low") + a.status = SessionStatus.ACTIVE + a.momentum = 1 + b = state.add_session("High") + b.status = SessionStatus.ACTIVE + b.momentum = 9 + out = next_frontier(state) + assert "High" in out + assert "1 from" in out # 10 - 9 = 1 + + +# ──────────────────────────────────────────────────────────────────────── +# String quality — always non-empty, never crashes +# ──────────────────────────────────────────────────────────────────────── + +@pytest.mark.parametrize("status", list(SessionStatus)) +def test_every_status_yields_nonempty_frontier(status): + state = _state_with_session("X", status, momentum=5) + out = next_frontier(state) + assert out # always non-empty + assert isinstance(out, str) + + +def test_session_name_in_output_when_session_exists(): + state = _state_with_session("Funky Royal Court", SessionStatus.WAITING) + assert "Funky Royal Court" in next_frontier(state) diff --git a/docs/audit-2026-05-08.md b/docs/audit-2026-05-08.md index 0472a42..27e835b 100644 --- a/docs/audit-2026-05-08.md +++ b/docs/audit-2026-05-08.md @@ -1,9 +1,15 @@ # Claude Kingdoms — CTO-Style Audit Against PRD v1.5 -**Date:** 2026-05-08 +**Date:** 2026-05-08 (updated 14:00 PDT after the 5-hour supervision sprint) **Reviewer:** claude-code -**Repo HEAD:** `4b4b119` (PR #21 merged) -**Test count on `main`:** 149 passing +**Repo HEAD:** `ea739ba` (PR #30 merged) → see closure status below for live state +**Test count on `main`:** 234+ passing (was 149 at audit time) + +> **Closure update — every P0 and P1 line item identified in this audit +> is now closed.** PR refs: #23 (P0 #1 enum), #24 (P0 #3 Stop→COMPLETED), +> #25 (P0 #2 schema), #26 (BR-9), #27 (#11 + #12), #28 (GM-8), #29 +> (GM-5), #30 (#9 + #20). The original audit grid below is preserved +> as the historical baseline. This is a hard-eyed alignment review against `ClaudeKingdomsPRD.md` v1.5. It calls out what's truly shipped, where the implementation diverges diff --git a/tui/app.py b/tui/app.py index 61a5f7c..2b10801 100644 --- a/tui/app.py +++ b/tui/app.py @@ -27,6 +27,7 @@ from bridge.session_state import SessionState, Session, SessionStatus from bridge.scoring import ScoringEngine, SimpleScoringStrategy +from bridge.frontier import next_frontier from tui.medieval_voice import MedievalVoice, voice_for_change # Forward-only import for type hints; BridgeLoop is optional at runtime. @@ -49,6 +50,10 @@ def render_state(self, state: SessionState) -> str: f"goals={s.goals_completed} momentum={s.momentum} " f"tokens={s.tokens} plans={len(s.plans)}" ) + # PRD acceptance #19: every play session ends with a visible + # unresolved frontier — so always show one. + lines.append("") + lines.append(f"Next: {next_frontier(state)}") return "\n".join(lines)