Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions bridge/frontier.py
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +18 to +20
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The imports Optional and Session are unused in this file. Removing them keeps the code clean and adheres to standard Python practices.

Suggested change
from typing import Optional
from bridge.session_state import Session, SessionState, SessionStatus
from bridge.session_state import 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"
Comment on lines +23 to +89
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There is significant logic duplication between next_frontier and frontier_priority_kind, particularly in the session filtering and the momentum tier calculation for active sessions. This increases maintenance overhead and the risk of logic drift if the priority rules or tier thresholds change.

Consider refactoring the shared logic into a private helper function (e.g., _get_frontier_info) that returns the relevant session and a category identifier. This would also allow for a more efficient implementation that avoids multiple list comprehensions over the sessions list by using a single-pass search.

127 changes: 127 additions & 0 deletions bridge/tests/test_frontier.py
Original file line number Diff line number Diff line change
@@ -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)
12 changes: 9 additions & 3 deletions docs/audit-2026-05-08.md
Original file line number Diff line number Diff line change
@@ -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.
Comment on lines +8 to +12
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The 'Closure update' block states that every P0 and P1 item is now closed, but it does not include a reference to the current PR or explicitly mention the closure of acceptance criterion #19 (which this PR addresses). Adding this reference would ensure the audit documentation accurately reflects the current state of the project and provides a complete audit trail.


This is a hard-eyed alignment review against `ClaudeKingdomsPRD.md` v1.5.
It calls out what's truly shipped, where the implementation diverges
Expand Down
5 changes: 5 additions & 0 deletions tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)


Expand Down
Loading