-
Notifications
You must be signed in to change notification settings - Fork 0
feat(p1): visible next-frontier per session (acceptance #19) + audit refresh #31
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
|
||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is significant logic duplication between Consider refactoring the shared logic into a private helper function (e.g., |
||
| 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) |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The imports
OptionalandSessionare unused in this file. Removing them keeps the code clean and adheres to standard Python practices.