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
1 change: 1 addition & 0 deletions .beads/issues.jsonl
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{"_type":"issue","id":"risus-cli-r9j","title":"Fix duplicate battle state screen on connect and after actions","description":"When connecting as test user, battle state screen renders twice: first with full menu (items 1-6), then 1-3 seconds later a second render appears below without menu options. Same happens after any action (add player, switch cliche, reduce dice, save, load). Always exactly two screens total. Only the second prompt accepts input.\n\nRoot cause: _input_with_refresh() (risus.py:77) calls show_state() on server broadcast without calling clear() first and without reprinting the menu. The old render stays, new state appends below.\n\nFix needed: on refresh in _input_with_refresh, clear screen and reprint full menu + state, not just state. Consider accepting a redraw callable parameter.","status":"closed","priority":1,"issue_type":"bug","assignee":"galadriel","owner":"galadriel@example.com","created_at":"2026-05-04T17:41:29Z","created_by":"galadriel","updated_at":"2026-05-04T17:48:10Z","started_at":"2026-05-04T17:48:08Z","closed_at":"2026-05-04T17:48:10Z","close_reason":"Fixed: _input_with_refresh now accepts redraw callable; main() passes _redraw_main which calls clear()+show_state()+menu. Regression test T007b added.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"risus-cli-7n5","title":"Fix macOS zip: unzip into named directory not dist/","description":"When a user unzips risus-macos-arm64.zip they get a dist/ directory — this is a CI build artifact path leaking into the release. Fix: stage the binary into /tmp/risus-macos-arm64/ before running ditto, so the zip contains risus-macos-arm64/risus-macos-arm64 and extracts into a sensibly named directory.\n\nChange is one step in .github/workflows/release.yml (Package signed binary). See specs/006-zip-directory-structure/ for spec, plan, and tasks.","status":"closed","priority":2,"issue_type":"feature","assignee":"galadriel","owner":"galadriel@example.com","created_at":"2026-05-04T07:00:22Z","created_by":"galadriel","updated_at":"2026-05-04T12:32:59Z","started_at":"2026-05-04T12:31:07Z","closed_at":"2026-05-04T12:32:59Z","close_reason":"Stage binary in /tmp/risus-macos-arm64/ before ditto; zip now extracts to risus-macos-arm64/ not dist/","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"risus-cli-70j","title":"Conditional signing: gate macOS steps on APPLE_CERTIFICATE secret","description":"Gate all macOS signing steps in release.yml on env.APPLE_CERTIFICATE != '' so branches/PRs without secrets produce an unsigned binary instead of failing. Add an assert step on tag pushes that fails loudly if secrets are missing, preventing accidental unsigned release artifact.\n\nChanges to .github/workflows/release.yml:\n- All macOS signing steps (import-codesign-certs, sign, package, notarize, clean up, verify): add env.APPLE_CERTIFICATE != '' to the if condition\n- Add 'Assert signing secrets' step with if: runner.os == 'macOS' \u0026\u0026 github.ref_type == 'tag' that checks APPLE_CERTIFICATE env var and exits 1 with clear error if empty\n\nSpec: specs/005-macos-signed-release/tasks.md T015","status":"closed","priority":2,"issue_type":"task","assignee":"galadriel","owner":"galadriel@example.com","created_at":"2026-05-04T06:31:24Z","created_by":"galadriel","updated_at":"2026-05-04T12:32:57Z","started_at":"2026-05-04T12:31:05Z","closed_at":"2026-05-04T12:32:57Z","close_reason":"Gated all macOS signing steps on env.APPLE_CERTIFICATE != ''; added Assert step for tag pushes; unsigned fallback for branch builds","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"risus-cli-q0d","title":"T009: Add post-notarization codesign/spctl verification step","description":"speckit:005-macos-signed-release | T009 | Add post-notarization verification step (if: runner.os == macOS) in .github/workflows/release.yml running codesign --verify and spctl --assess","status":"closed","priority":2,"issue_type":"task","owner":"galadriel@example.com","created_at":"2026-05-03T19:21:13Z","created_by":"galadriel","updated_at":"2026-05-03T19:26:09Z","closed_at":"2026-05-03T19:26:09Z","close_reason":"Closed","dependencies":[{"issue_id":"risus-cli-q0d","depends_on_id":"risus-cli-77n","type":"blocks","created_at":"2026-05-03T21:22:15Z","created_by":"galadriel","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
Expand Down
18 changes: 9 additions & 9 deletions .omc/project-memory.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
},
"build": {
"buildCommand": null,
"testCommand": "pytest",
"testCommand": "pytest tests/unit -q 2>&1",
"lintCommand": "ruff check",
"devCommand": null,
"scripts": {}
Expand Down Expand Up @@ -158,8 +158,8 @@
"hotPaths": [
{
"path": "risus.py",
"accessCount": 50,
"lastAccessed": 1777829306763,
"accessCount": 56,
"lastAccessed": 1777916869248,
"type": "file"
},
{
Expand All @@ -170,20 +170,20 @@
},
{
"path": "AGENTS.md",
"accessCount": 29,
"lastAccessed": 1777869979110,
"accessCount": 30,
"lastAccessed": 1777916704724,
"type": "file"
},
{
"path": ".specify/memory/constitution.md",
"accessCount": 23,
"lastAccessed": 1777831335578,
"accessCount": 24,
"lastAccessed": 1777916715339,
"type": "file"
},
{
"path": "client/ws_client.py",
"accessCount": 21,
"lastAccessed": 1777829969586,
"accessCount": 22,
"lastAccessed": 1777916405389,
"type": "file"
},
{
Expand Down
2 changes: 1 addition & 1 deletion .specify/feature.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"feature_directory": "specs/005-macos-signed-release"
"feature_directory": "specs/007-client-screen-sync"
}
10 changes: 7 additions & 3 deletions .specify/memory/constitution.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,12 @@ per-user authentication or user management, no multi-battle support — these
are permanently out of scope per the PRD. A single shared-secret token
(`RISUS_TOKEN`) for connection authentication is permitted (see
`docs/features/secure-session/`). Menu UX (options 1–6, labels, prompts,
ordering) MUST NOT change. `input()` calls MUST remain synchronous; no
`prompt_toolkit` or async input libraries may be introduced.
ordering) MUST NOT change. Input from the operator MUST remain synchronous
from the caller's perspective; no `prompt_toolkit` or async input libraries
may be introduced. Replacing the `input()` builtin with a synchronous
stdlib-only polling wrapper (e.g., `select.select` with a timeout) is
permitted when required for display refresh, provided the wrapper introduces
no external dependencies and blocks until a complete line is returned.

**Rationale**: Scope creep breaks the PRD contract and multi-player UX
assumptions. Keep modifications, configuration, and options at the absolute
Expand Down Expand Up @@ -128,4 +132,4 @@ Complexity additions MUST be justified in the plan's Complexity Tracking
table. Refer to `AGENTS.md` for runtime agent guidance and the hand-off
checklist.

**Version**: 1.1.0 | **Ratified**: 2026-05-02 | **Last Amended**: 2026-05-03
**Version**: 1.1.1 | **Ratified**: 2026-05-02 | **Last Amended**: 2026-05-04
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,10 @@ constitution's "Development Workflow" section.

| type | key fields | notes |
| --- | --- | --- |
| `state` | `players: [{name, cliche, dice, lost_dice}]` | Full sync |
| `presence` | `clients: [names]` | Connected users |
| `lock_acquired` | `player_name, locked_by` | Broadcast |
| `lock_released` | `player_name` | Broadcast |
| `state` | `players: [{name, cliche, dice, lost_dice}]` | Full sync; sets `ClientState.update_event` |
| `presence` | `clients: [names]` | Connected users; sets `ClientState.update_event` |
| `lock_acquired` | `player_name, locked_by` | Broadcast; sets `ClientState.update_event` |
| `lock_released` | `player_name` | Broadcast; sets `ClientState.update_event` |
| `lock_denied` | `player_name, locked_by` | Caller only |
| `error` | `message` | Caller only |

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@ Do not duplicate rules here. Update the constitution or AGENTS.md instead.
<!-- SPECKIT START -->
For additional context about technologies to be used, project structure,
shell commands, and other important information, read the current plan at
`specs/005-macos-signed-release/plan.md`.
`specs/007-client-screen-sync/plan.md`.
<!-- SPECKIT END -->
4 changes: 4 additions & 0 deletions client/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ def __init__(self) -> None:
self.players: list[PlayerSnapshot] = []
self.presence: list[str] = []
self.locks: dict[str, str] = {} # player_name -> display_name of holder
self.update_event = threading.Event()

def apply(self, frame: dict) -> None:
msg_type = frame.get("type", "")
Expand All @@ -38,6 +39,9 @@ def apply(self, frame: dict) -> None:
self.locks[frame["player_name"]] = frame["locked_by"]
elif msg_type == "lock_released":
self.locks.pop(frame.get("player_name", ""), None)
else:
return
self.update_event.set()

def snapshot_players(self) -> list[PlayerSnapshot]:
with self._lock:
Expand Down
47 changes: 45 additions & 2 deletions risus.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@

import argparse
import atexit
import io
import json
import os
import select
import sys
import urllib.request
from pathlib import Path
Expand Down Expand Up @@ -50,6 +52,44 @@ def show_state():
print()


def _input_with_refresh(prompt: str, redraw=None) -> str:
"""Synchronous input with periodic display refresh on state updates.

Replaces input() at the top-level menu only. Uses select.select with a
1-second timeout so the display can redraw when update_event is set by
the background WS reader. Blocks until a complete line is returned —
callers see no difference from input(). Terminal canonical mode keeps
partial keystrokes in the line buffer; they survive the redraw intact.

redraw: optional callable invoked on update_event. When provided it is
responsible for clearing the screen and printing the full UI. When None,
show_state() is called as a fallback.

Permitted by constitution v1.1.1: synchronous stdlib-only polling wrapper,
no external dependencies, blocks until a complete line is returned.
"""
sys.stdout.write(prompt)
sys.stdout.flush()
try:
while True:
ready, _, _ = select.select([sys.stdin], [], [], 1.0)
if ready:
return sys.stdin.readline().rstrip("\n").strip()
if _ws().state.update_event.is_set():
_ws().state.update_event.clear()
sys.stdout.write("\n")
if redraw is not None:
redraw()
else:
show_state()
sys.stdout.write(prompt)
sys.stdout.flush()
except io.UnsupportedOperation:
# stdin is a pseudofile without fileno() (e.g. in tests); fall back to
# plain input() which honours builtins patches in the test environment.
return input("").strip()


def prompt_int(prompt="Number: ") -> int | None:
val = input(prompt).strip()
try:
Expand Down Expand Up @@ -311,7 +351,7 @@ def main():
token = connect_or_die(server, name, token)
atexit.register(client.config.write_config, base_dir, server, name, token)

while True:
def _redraw_main():
clear()
show_state()
print(" 1. Add player")
Expand All @@ -321,7 +361,10 @@ def main():
print(" 5. Load")
print(" 6. Quit")
print()
choice = input("> ").strip()

while True:
_redraw_main()
choice = _input_with_refresh("> ", redraw=_redraw_main)

if choice == "1":
add_player()
Expand Down
34 changes: 34 additions & 0 deletions specs/007-client-screen-sync/checklists/requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Specification Quality Checklist: Automatic Client Screen Refresh

**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-05-04
**Feature**: [spec.md](../spec.md)

## Content Quality

- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed

## Requirement Completeness

- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified

## Feature Readiness

- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification

## Notes

All items pass. Spec is ready for `/speckit-clarify` or `/speckit-plan`.
34 changes: 34 additions & 0 deletions specs/007-client-screen-sync/contracts/ws-refresh-triggers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# WS Contract: Messages That Trigger Client Screen Refresh

**Feature**: 007-client-screen-sync
**Date**: 2026-05-04

## Overview

The client sets `ClientState.update_event` when it receives any of the following server-sent WebSocket frames. The main loop uses this event to decide when to redraw the battle display.

## Refresh-Triggering Frames (Server → Client)

| Frame type | Key fields | Triggers refresh | Reason |
|------------|------------|------------------|--------|
| `state` | `players: [{name, cliche, dice, lost_dice}]` | YES | Full game state changed |
| `presence` | `clients: [names]` | YES | Connected player list changed |
| `lock_acquired` | `player_name, locked_by` | YES | Lock indicator must update |
| `lock_released` | `player_name` | YES | Lock indicator must clear |

## Non-Refresh Frames (Server → Client)

| Frame type | Key fields | Triggers refresh | Reason |
|------------|------------|------------------|--------|
| `lock_denied` | `player_name, locked_by` | NO | Caller-only; handled inline by submenu |
| `error` | `message` | NO | Caller-only; displayed inline by submenu |

## Contract Invariants

- The refresh mechanism is display-only — no server protocol changes in this feature.
- `update_event` is set AFTER `ClientState.apply()` updates internal state, ensuring the main thread always reads fresh data when it calls `show_state()`.
- `lock_denied` and `error` frames are already handled inline within submenu functions and do not need to interrupt the top-level display loop.

## Protocol Reference

Full WS protocol reference is in `AGENTS.md` under "WS Protocol Reference". This document covers only the refresh-trigger subset relevant to this feature.
114 changes: 114 additions & 0 deletions specs/007-client-screen-sync/data-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Data Model: Automatic Client Screen Refresh

**Feature**: 007-client-screen-sync
**Date**: 2026-05-04

## Existing Entities (unchanged)

### PlayerSnapshot (`client/state.py`)

Immutable snapshot of a single player's current battle state. No changes in this feature.

| Field | Type | Description |
|-------|------|-------------|
| `name` | `str` | Player identifier |
| `cliche` | `str` | Active cliché description |
| `dice` | `Optional[int]` | Current dice count; `None` until set |
| `lost_dice` | `int` | Accumulated lost dice |

### ClientState (`client/state.py`)

Thread-safe container for the current game snapshot. **Modified by this feature.**

| Field | Type | Description |
|-------|------|-------------|
| `players` | `list[PlayerSnapshot]` | All players in current battle |
| `presence` | `list[str]` | Names of connected clients |
| `locks` | `dict[str, str]` | `player_name → lock_holder_display_name` |
| `_lock` | `threading.Lock` | Guards reads/writes (existing) |
| **`update_event`** | **`threading.Event`** | **NEW: set by `apply()` on every state change; cleared by main thread after redraw** |

**State transitions for `update_event`**:

```
Initial state: Event cleared (not set)
apply() called ─────► update_event.set()
(any frame type)
Main thread detects event set
show_state() called
update_event.clear()
└──► back to initial state
```

## New Behavior: `apply()` method

After applying any frame type (`state`, `presence`, `lock_acquired`, `lock_released`), `apply()` calls `self.update_event.set()`. This is the only change to `apply()`.

```
Frame arrives in _reader() background thread
state.apply(frame)
├─ update players / presence / locks (existing)
└─ self.update_event.set() ← NEW
```

## New Behavior: Main Loop Input (`risus.py`)

The top-level menu loop acquires user input via `_input_with_refresh(prompt)` instead of `input(prompt)`.

```
_input_with_refresh("> "):
print prompt, flush
loop:
ready = select.select([stdin], [], [], 1.0)
if ready:
return stdin.readline().rstrip()
if update_event.is_set():
update_event.clear()
print newline
show_state()
print prompt, flush
```

**Invariants**:
- `update_event` is ONLY cleared by the main thread inside `_input_with_refresh`
- `update_event` is ONLY set by the background thread inside `ClientState.apply()`
- Submenu `input()` calls are unaffected — no dirty-flag check there
- `show_state()` logic is UNCHANGED — still calls `snapshot_players()`, `snapshot_presence()`, `snapshot_locks()`

## Data Flow Diagram

```
Background async thread Main CLI thread
(ws_client._reader) (risus.main)
│ │
[WS frame arrives] [loop top: show_state()]
│ │
state.apply(frame) [print menu options]
updates players/ │
presence/locks ──────────────► [_input_with_refresh("> ")]
sets update_event │
│ [select.select, 1s timeout]
│ │
│ [timeout: update_event set?]
│ ├─ YES: clear → show_state() → reprint prompt
│ └─ NO: continue waiting
│ │
│ [input ready: return choice]
│ │
│ [handle menu choice]
│ │
└───────────────────────────────[loop back to top]
```
Loading