d30be12deb
Adds GET /agents fetch + ListView picker for bare `--new` (TUI mode without --agent). Three in-place amendments: - ratatoskr.sessions: new `list_agents()` + `AgentInfo` frozen dataclass with omit-when-null/empty defaults mirroring SessionInfo's INV-001/INV-002 origin-conditional pattern. Non-200 responses raise the existing SessionApiFailed (no new exception). - ratatoskr.cli: `_parse_args` softens `--agent` from absolute to mode-conditional — required for `--send --new`, optional for bare `--new`, forbidden with `--session` (unchanged INV-004). - ratatoskr.tui: new `AgentPickerApp(App[str | None])` — separate Textual App (not Screen-within-RatatoskrApp) so list_agents errors land on real stderr before any alt-screen opens (preserves issue #6's INV-001). `_resolve_then_run` gains a pre-create branch: fetch agents → empty list → exit 13; non-200 → exit 20; network error → exit 21; picker dismissed → exit 0; otherwise thread chosen agent_id into create_session. Contract: docs/contracts/issues/8.contract.md (drift-check clean). Tests: +18 (227 total, was 209). Live smoke against personal Worldtree (:8081) returned 12 agents; programmatic picker drive auto-picked lofn and created a real session with `end_user_id="ratatoskr-tui"`.
324 lines
21 KiB
Markdown
324 lines
21 KiB
Markdown
---
|
|
contract_version: "2.1"
|
|
target_module: "ratatoskr.sessions"
|
|
scope: "Startup agent picker for TUI mode when `--new` is passed without `--agent`. Small surface change distributed across three existing modules via in-place contract amendments: `ratatoskr.sessions` gains `list_agents()` (GET /agents) returning `list[AgentInfo]` (new frozen dataclass with omit-when-null defaults mirroring SessionInfo's INV-001/INV-002 pattern); `ratatoskr.cli` softens `--agent` requirement from absolute to mode-conditional (`--send --new` still requires it; bare `--new` accepts None; `--session` still forbids it); `ratatoskr.tui._resolve_then_run` gains a pre-create branch that, when `args.new and args.agent_id is None`, calls `list_agents(client)` then runs a dedicated tiny `AgentPickerApp` (separate Textual `App` instance, opens before the main `RatatoskrApp`) whose `run_async()` returns the chosen `agent_id` (or `None` on Esc/Ctrl-D for clean exit). No new files; no wire-level surface change beyond the new endpoint hit. Composes naturally with #5 (`--end-user-id`): both thread through `ParsedArgs` before any App opens."
|
|
depends_on:
|
|
- "httpx"
|
|
- "textual"
|
|
used_by:
|
|
- "ratatoskr.cli"
|
|
- "ratatoskr.tui"
|
|
language: "python"
|
|
complexity: "low"
|
|
estimated_loc: 200
|
|
confidence: 0.85
|
|
assumptions:
|
|
- "Worldtree spec pin (`docs/conversation-api-spec.md` v0.19.0, repo SHA `55101e909abcd2219833266b6f905c5bc956e0f0`) declares `GET /agents` at §832: returns 200 with a JSON array of agent objects. Three fields always present (`agent_id`, `name`, `description`); five optional with omit-when-null/omit-when-empty semantics (`version`, `capabilities`, `supported_models`, `persona_traits`, `ui_hints`). No pagination, no filters, no auth-scope requirement beyond bearer-authenticated (worldtree-dev confirmed 2026-05-23: Tier 1 `agent.list` baseline scope covers it; same auth posture as POST /sessions)."
|
|
- "`AgentInfo` is a frozen dataclass on `ratatoskr.sessions` (alongside `SessionInfo` / `SessionPage`) carrying all 8 fields. Origin-conditional defaults mirror INV-001/INV-002 from issue #2: required fields (`agent_id`, `name`, `description`) take the response value verbatim; optional fields default to `None` (scalar `version`) or empty container (`capabilities=[]`, `supported_models=[]`, `persona_traits={}`, `ui_hints={}`) when omitted from the response. Empty containers (NOT `None`) for collection-shaped optionals so caller code can branch on truthiness without `is None` ceremony."
|
|
- "`list_agents` uses the same caller-owned-client posture as `create_session` / `list_sessions`: takes `httpx.AsyncClient`, returns `list[AgentInfo]`, raises `SessionApiFailed(status, body)` on any non-200 response. No new exception type — list_agents' failure modes (auth, transport, server-side 5xx) all map cleanly to the existing `SessionApiFailed` shape. The module's posture against shared types with sse_client (`ratatoskr.sessions` issue #1 convention dependency) is preserved."
|
|
- "**CLI surface change is mode-conditional, not flag-removal**: `--agent` becomes optional ONLY when `--new` is passed AND `--send` is NOT passed (i.e., bare TUI-mode create). `--send --new` still raises `UsageError('--agent is required when --new is passed in --send mode')` because non-interactive --send mode has no way to prompt. `--session <id>` still forbids `--agent` (preserves issue #3 INV-004 mutual-exclusion). The single existing line `if ns.new and not ns.agent: raise UsageError(...)` in `_parse_args` STEP 3 splits into two conditionals that distinguish on `ns.send`."
|
|
- "**Picker is its own App, not a Screen within RatatoskrApp** (deliberate divergence from the issue body's 'pushed onto the App's screen stack' phrasing, which predated issue #6's refactor). Reason: issue #6's load-bearing invariant is that session-resolution + startup errors land on the operator's REAL stderr before any alt-screen opens. `list_agents` failures (network, auth, server error) need that same property. Doing it via a Screen inside RatatoskrApp re-introduces the alt-screen-eats-stderr problem #6 fixed. Doing it via a separate `AgentPickerApp` opened in `_resolve_then_run` (before `RatatoskrApp`) preserves #6's invariant: `list_agents` errors print to stderr and short-circuit BEFORE the picker's alt-screen opens; picker errors (which don't really exist — it's pure UI navigation) are bounded; chosen `agent_id` flows back through `app.run_async()`'s return value."
|
|
- "**Two alt-screen cycles is acceptable** (picker opens + closes; main RatatoskrApp opens). Textual's `App.run_async()` handles alt-screen entry + restoration cleanly per-instance. The visible-flicker cost is one quick alt-screen flash between picker dismissal and main App mount; the architectural cost of avoiding this (Screen-within-App, breaking #6) is higher than the cosmetic cost. If empirical operator feedback indicates the flicker is jarring, follow-up issue collapses to one App with two Screens AFTER re-engineering the stderr-error path."
|
|
- "**Picker Esc/Ctrl-D returns exit 0, NOT a `UsageError`**: when the operator dismisses the picker without choosing, the intent is 'never mind, exit cleanly' — same as Ctrl-D from the main chat pane in INV-002 of issue #4. `_resolve_then_run` returns 0 without calling `create_session` or `App.run_async()` on RatatoskrApp. No session is created server-side; no `agent_id` is required to satisfy this exit path."
|
|
- "**Picker layout uses ListView, not DataTable**: design-brief §5 mentions `DataTable` for the session picker but ListView is the right primitive for agent picking — single-column, keyboard-navigable, one row per agent rendered as `agent_id · name — description`. v1 picker is a flat list per the issue's out-of-scope clause (search/filter/sort/ui_hints rendering all deferred). DataTable's column-header + sortable-column ergonomics are wasted on this surface."
|
|
- "**Empty agent list is a clean exit, not an error**: if `GET /agents` returns `[]`, the picker writes `[no_agents] server returned empty agent list\\n` to stderr and `_resolve_then_run` returns exit code 13 (new — see ERROR_ROUTING below). The picker UI never opens in this case; no point showing an empty list with no actionable rows."
|
|
- "**Single agent does NOT auto-select**: if `GET /agents` returns one agent, the picker still opens with one row. Auto-select would hide the choice (and the agent's description) from the operator. The cost is one keystroke; the benefit is transparency about what's about to happen."
|
|
- "**`AgentInfo` field order in the dataclass matches the spec's column order** (`agent_id`, `name`, `description`, `version`, `capabilities`, `supported_models`, `persona_traits`, `ui_hints`). Mirrors how readers scanning the dataclass map mental model from spec → code."
|
|
- "**`AgentInfo.persona_traits` / `ui_hints` are typed as `dict[str, Any]` not nested dataclasses**: v1 picker just displays `agent_id · name — description`; the inner shape (ocean object, icon, color_hint, vibe) is opaque to ratatoskr. Future polish that renders icon/color_hint would either parse on-demand or introduce nested dataclasses then. Keeping them as dict[str, Any] avoids paying a typing tax now for a display surface that's deferred."
|
|
- "**`list_agents` does NOT pass query params**: spec §832 declares no pagination, no filters. The request is a bare `GET /agents` with the bearer header from the caller-owned client. If Worldtree later adds filters (e.g., `?capability=foo`), `list_agents` gains them via amendment then."
|
|
open_questions:
|
|
- "Should the picker display `version` when available (e.g., `mimir v0.2.0 — Keeper of the Well of Knowledge`)? Draft: no for v1 — the issue body specifies `agent_id · name — description` exactly. Add in a follow-up if operators report ambiguity (two `mimir` rows from different deployments). Drift-check evidence first."
|
|
- "Should `AgentPickerApp.run_async()` return the chosen `AgentInfo` or just the `agent_id` string? Draft: just the `agent_id` string for v1 — that's all `create_session` needs. Returning the full `AgentInfo` would let `RatatoskrApp` show name/description in the identity widget without re-fetching, but the existing identity widget format is `<agent_id> · …<session_id>` so the extra metadata has no consumer yet. Defer until §5 side-panes work needs it."
|
|
- "Should the picker show a loading spinner while `list_agents` is in flight? Draft: no for v1 — list_agents runs BEFORE the picker App opens (per the architectural decision above), so there's no in-app loading state to show. Operator sees stderr label on failure; on success the picker opens with the list already populated. If the request latency turns out to be noticeable (e.g., >300ms), reconsider."
|
|
prd:
|
|
issue: 8
|
|
issue_url: "https://gitea.phasefinal.com/vh/ratatoskr/issues/8"
|
|
body_sha256_16: "c34f4878936a4edc"
|
|
lock_in_comment_id: null
|
|
lock_in_sha256_16: null
|
|
lock_in_at: null
|
|
pinned_at: "2026-05-24T00:49:03+00:00"
|
|
dependencies:
|
|
- issue: 2
|
|
path: "src/ratatoskr/sessions.py"
|
|
reason: "In-place contract amendment: new `list_agents()` function + new `AgentInfo` frozen dataclass added to the module's public surface. POST /sessions paths unchanged; no shared types with the new code beyond the module's existing `SessionApiFailed` exception (reused for non-200 responses)."
|
|
- issue: 3
|
|
path: "src/ratatoskr/cli.py"
|
|
reason: "In-place contract amendment: `_parse_args` STEP 3 splits the single `if ns.new and not ns.agent` check into two conditionals — `--send --new` keeps the strict requirement; bare `--new` accepts `agent_id=None` for the TUI-picker case."
|
|
- issue: 4
|
|
path: "src/ratatoskr/tui.py"
|
|
reason: "In-place contract amendment: new `AgentPickerApp(App[str | None])` class with ListView + Enter/Esc bindings; `_resolve_then_run` gains a pre-create branch that runs the picker when `args.new and args.agent_id is None`; on chosen `agent_id`, threads it into `create_session(client, chosen, end_user_id=args.end_user_id)`. RatatoskrApp itself is unchanged."
|
|
---
|
|
|
|
# Startup agent picker — GET /agents when --new without --agent (TUI)
|
|
|
|
## Context
|
|
|
|
Today, `ratatoskr --new` requires `--agent <id>`. If omitted, `_parse_args`
|
|
raises `UsageError("--agent is required when --new is passed")`. That's
|
|
correct for `--send --new` (non-interactive — can't prompt) but wrong
|
|
for the TUI (operator may not know which agents are available, would
|
|
prefer to pick from a list at startup).
|
|
|
|
Worldtree's spec §832 exposes `GET /agents`. The endpoint returns a flat
|
|
JSON array; required fields are `agent_id`, `name`, `description`;
|
|
optional fields (`version`, `capabilities`, `supported_models`,
|
|
`persona_traits`, `ui_hints`) follow omit-when-null/empty rules. No
|
|
pagination, no filters, no special scope. Worldtree-dev confirmed
|
|
2026-05-23: Tier 1 baseline auth covers it.
|
|
|
|
This issue threads a small surface change through three existing modules
|
|
in-place — no new files apart from this contract.
|
|
|
|
## Data flow
|
|
|
|
**Input:**
|
|
- `httpx.AsyncClient` (caller-owned, base_url + bearer auth on the client).
|
|
- No request body, no query params.
|
|
|
|
**Output (`list_agents`):**
|
|
- `list[AgentInfo]` — one entry per available agent, in server-declared order.
|
|
|
|
**Output (`AgentPickerApp.run_async()`):**
|
|
- `str | None` — chosen `agent_id`, or `None` on Esc/Ctrl-D dismissal.
|
|
|
|
## Public surface (ratatoskr.sessions amendment)
|
|
|
|
```python
|
|
@dataclass(frozen=True)
|
|
class AgentInfo:
|
|
"""One agent's metadata from GET /agents.
|
|
|
|
INV-005: Required fields (`agent_id`, `name`, `description`) take the
|
|
response value verbatim. Optional fields default to None (`version`)
|
|
or an empty container (`capabilities`, `supported_models`,
|
|
`persona_traits`, `ui_hints`) when omitted from the server response,
|
|
mirroring SessionInfo's INV-001/INV-002 origin-conditional pattern.
|
|
"""
|
|
|
|
agent_id: str
|
|
name: str
|
|
description: str
|
|
version: str | None
|
|
capabilities: list[str]
|
|
supported_models: list[str]
|
|
persona_traits: dict[str, Any]
|
|
ui_hints: dict[str, Any]
|
|
|
|
|
|
async def list_agents(client: httpx.AsyncClient) -> list[AgentInfo]:
|
|
"""GET /agents → list of available agents. See contract FN list_agents."""
|
|
```
|
|
|
|
## Functions
|
|
|
|
### FN list_agents
|
|
|
|
```
|
|
FN list_agents(client: httpx.AsyncClient) -> list[AgentInfo]
|
|
BRIEF: GET /agents → list of available agents. No pagination, no filters.
|
|
|
|
PRE-001: client is not None.
|
|
|
|
STEPS:
|
|
1. resp = await client.get("/agents")
|
|
2. IF resp.status_code != 200:
|
|
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
|
3. body = resp.json() # expected: list[dict]
|
|
4. items = [
|
|
AgentInfo(
|
|
agent_id = item["agent_id"],
|
|
name = item["name"],
|
|
description = item["description"],
|
|
version = item.get("version"),
|
|
capabilities = item.get("capabilities") or [],
|
|
supported_models = item.get("supported_models") or [],
|
|
persona_traits = item.get("persona_traits") or {},
|
|
ui_hints = item.get("ui_hints") or {},
|
|
)
|
|
for item in body
|
|
]
|
|
5. RETURN items
|
|
|
|
POST-001: every item in the return list has required fields populated.
|
|
POST-002: optional fields default to None / [] / {} when absent from the response.
|
|
|
|
ERROR_ROUTING:
|
|
- 200 with non-list body → KeyError / TypeError propagates (server bug; not handled here).
|
|
- non-200 → SessionApiFailed(status=N, body=resp.content)
|
|
- httpx.RequestError → propagates (network failure; caller handles).
|
|
|
|
TESTS:
|
|
- test_happy_full_shape: 200 + spec's full-shape mimir example → AgentInfo with all fields populated.
|
|
- test_happy_minimum_shape: 200 + spec's minimum-shape "minimal" example → AgentInfo with required + defaulted optional.
|
|
- test_happy_multi_agent: 200 + array of 3 agents → list of 3 AgentInfo in order.
|
|
- test_happy_empty: 200 + [] → empty list (no error).
|
|
- test_omit_capabilities: 200 + agent missing capabilities → AgentInfo.capabilities == [].
|
|
- test_omit_persona_traits: 200 + agent missing persona_traits → AgentInfo.persona_traits == {}.
|
|
- test_omit_ui_hints: 200 + agent missing ui_hints → AgentInfo.ui_hints == {}.
|
|
- test_500_raises: 500 → SessionApiFailed with status=500.
|
|
- test_401_raises: 401 → SessionApiFailed with status=401.
|
|
```
|
|
|
|
## CLI surface change (ratatoskr.cli amendment)
|
|
|
|
`_parse_args` STEP 3, currently:
|
|
|
|
```python
|
|
if ns.new and not ns.agent:
|
|
raise UsageError("--agent is required when --new is passed")
|
|
```
|
|
|
|
becomes:
|
|
|
|
```python
|
|
if ns.new and not ns.agent:
|
|
if ns.send is not None:
|
|
# --send --new mode: non-interactive, cannot prompt for choice.
|
|
raise UsageError("--agent is required with --new in --send mode")
|
|
# else: bare --new (TUI mode) — agent_id stays None, TUI runs picker.
|
|
```
|
|
|
|
INV-004 (`--session` + `--agent` mutual exclusion) is unchanged. Existing
|
|
`--send --new` smoke flows that pass `--agent` continue to work unchanged.
|
|
|
|
ERROR_ROUTING (cli):
|
|
- `--send --new` without `--agent` → `UsageError` → exit 10 (unchanged from today).
|
|
- bare `--new` without `--agent` → `ParsedArgs.agent_id=None`, TUI handles.
|
|
|
|
TESTS (additions to test_cli.py):
|
|
- test_parse_send_new_without_agent_raises: `--send "hi" --new` (no --agent) → UsageError.
|
|
- test_parse_bare_new_without_agent_accepted: `--new` (no --agent, no --send) → ParsedArgs with agent_id=None.
|
|
- test_parse_bare_new_with_agent_accepted: `--new --agent mimir` → unchanged behavior, agent_id="mimir".
|
|
|
|
## TUI surface change (ratatoskr.tui amendment)
|
|
|
|
### New: AgentPickerApp
|
|
|
|
```python
|
|
class AgentPickerApp(App[str | None]):
|
|
"""Single-purpose picker App. Opens before RatatoskrApp.
|
|
|
|
`run_async()` returns the chosen agent_id (str) or None on Esc/Ctrl-D.
|
|
"""
|
|
|
|
BINDINGS: ClassVar[list[Binding]] = [
|
|
Binding("enter", "pick", "Pick", priority=True),
|
|
Binding("escape", "dismiss", "Cancel", priority=True),
|
|
Binding("ctrl+d", "dismiss", "Cancel", priority=True),
|
|
Binding("ctrl+c", "dismiss", "Cancel", priority=True),
|
|
]
|
|
|
|
def __init__(self, agents: list[AgentInfo]) -> None:
|
|
super().__init__()
|
|
assert agents # PRE-002 — caller guarantees non-empty
|
|
self.agents = agents
|
|
|
|
def compose(self) -> ComposeResult:
|
|
yield Header()
|
|
yield Static("Pick an agent for the new session:", id="picker-prompt")
|
|
yield ListView(
|
|
*[
|
|
ListItem(Label(f"{a.agent_id} · {a.name} — {a.description}"))
|
|
for a in self.agents
|
|
],
|
|
id="agent-list",
|
|
)
|
|
yield Footer()
|
|
|
|
async def on_mount(self) -> None:
|
|
self.query_one("#agent-list", ListView).focus()
|
|
|
|
def action_pick(self) -> None:
|
|
lv = self.query_one("#agent-list", ListView)
|
|
idx = lv.index
|
|
if idx is None:
|
|
return # no row highlighted; ignore
|
|
self.exit(self.agents[idx].agent_id)
|
|
|
|
def action_dismiss(self) -> None:
|
|
self.exit(None)
|
|
```
|
|
|
|
### Modified: _resolve_then_run
|
|
|
|
Insert a pre-create branch between the `async with httpx.AsyncClient(...)`
|
|
and the existing `if args.new:` block:
|
|
|
|
```python
|
|
async def _resolve_then_run(args: ParsedArgs) -> int:
|
|
assert isinstance(args, ParsedArgs) and args.send_content is None
|
|
async with httpx.AsyncClient(...) as client:
|
|
# NEW (issue #8): startup agent picker when --new without --agent.
|
|
chosen_agent_id: str | None = args.agent_id
|
|
if args.new and args.agent_id is None:
|
|
try:
|
|
agents = await list_agents(client)
|
|
except SessionApiFailed as exc:
|
|
sys.stderr.write(
|
|
f"[session_api_failed] status={exc.status} body={exc.body!r}\n"
|
|
)
|
|
return 20
|
|
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
|
|
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
|
|
return 21
|
|
if not agents:
|
|
sys.stderr.write("[no_agents] server returned empty agent list\n")
|
|
return 13
|
|
picker = AgentPickerApp(agents)
|
|
chosen_agent_id = await picker.run_async()
|
|
if chosen_agent_id is None:
|
|
return 0 # Esc/Ctrl-D — clean exit, no session created
|
|
# EXISTING: create_session OR re-use --session id
|
|
if args.new:
|
|
assert chosen_agent_id is not None
|
|
try:
|
|
info = await create_session(
|
|
client, chosen_agent_id, end_user_id=args.end_user_id
|
|
)
|
|
...
|
|
else:
|
|
...
|
|
app = RatatoskrApp(args, session_id=session_id, agent_id=agent_id, client=client)
|
|
...
|
|
```
|
|
|
|
### ERROR_ROUTING (tui startup)
|
|
|
|
| Failure | Stderr label | Exit |
|
|
|---------|--------------|------|
|
|
| `list_agents` → `SessionApiFailed` | `[session_api_failed] status=N body=B` | 20 |
|
|
| `list_agents` → network error | `[network_error] T: M` | 21 |
|
|
| empty agent list (`GET /agents` returns `[]`) | `[no_agents] server returned empty agent list` | 13 (new) |
|
|
| picker Esc/Ctrl-D dismissal | (none — clean exit) | 0 |
|
|
| `create_session` post-pick → `AgentNotFound` | `[agent_not_found] agent_id=X` | 12 (unchanged from #4) |
|
|
|
|
INV: exit code 13 is new (no-agents). Previously unused — does not collide
|
|
with any existing exit code in `_resolve_then_run` or `_amain`.
|
|
|
|
### TESTS (additions to test_tui.py)
|
|
|
|
```
|
|
- test_picker_happy_path: list_agents returns 2 agents; AgentPickerApp opens; user picks index 0; chosen agent_id flows into create_session; main App opens.
|
|
- test_picker_esc_clean_exit: list_agents returns 2 agents; picker opens; user presses Esc; _resolve_then_run returns 0; create_session NOT called; RatatoskrApp NOT opened.
|
|
- test_picker_skipped_when_agent_id_provided: bare --new --agent mimir → list_agents NOT called; picker NOT opened; create_session called with "mimir".
|
|
- test_picker_skipped_when_session_mode: --session s-1 → list_agents NOT called; picker NOT opened; no create_session.
|
|
- test_picker_list_agents_session_api_failed: list_agents raises SessionApiFailed → stderr [session_api_failed]; exit 20; picker NOT opened; create_session NOT called.
|
|
- test_picker_list_agents_network_error: list_agents raises ConnectError → stderr [network_error]; exit 21.
|
|
- test_picker_empty_list: list_agents returns [] → stderr [no_agents]; exit 13; picker NOT opened; create_session NOT called.
|
|
- test_agent_picker_app_renders_rows: AgentPickerApp with 3 agents → ListView has 3 ListItem children with expected text.
|
|
- test_agent_picker_app_pick_returns_agent_id: simulate Enter on highlighted row → exit value == agents[idx].agent_id.
|
|
- test_agent_picker_app_dismiss_returns_none: simulate Esc → exit value is None.
|
|
```
|
|
|
|
## Invariants
|
|
|
|
- **INV-005**: `AgentInfo` field defaults are origin-conditional (mirrors INV-001/002 from #2). Required → verbatim; optional → None / [] / {}.
|
|
- **INV-006**: `list_agents` failure modes route through `SessionApiFailed` only — no new exception type introduced.
|
|
- **INV-007**: Picker is a separate App, opened by `_resolve_then_run` BEFORE `RatatoskrApp`. Preserves issue #6's stderr-error invariant for `list_agents` failures.
|
|
- **INV-008**: `--agent` CLI requirement is mode-conditional: required only when `--send --new`; bare `--new` accepts None; `--session` always forbids it.
|
|
- **INV-009**: Picker dismissal (Esc/Ctrl-D) returns clean exit 0; no session created server-side.
|
|
- **INV-010**: Empty agent list is a clean stderr exit (code 13), not an open picker.
|
|
- **INV-011**: Single-agent response still opens the picker — no auto-select.
|
|
- **INV-012**: Two alt-screen cycles (picker + main App) is the explicit architectural tradeoff for preserving INV-007.
|