17 Commits

Author SHA1 Message Date
vh d30be12deb feat(sessions,cli,tui): issue #8 — startup agent picker (v0.3.0)
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"`.
2026-05-23 17:58:22 -07:00
vh 3b9c610587 feat(cli,tui): issue #12 — presenter contract semantics amendment (v0.2.0)
Replaces the stateless _render_event / _render_event_to_log helpers with
stateful per-turn presenters (CliPresenterState / TuiPresenterState).
Coalesces thinking-event deltas into a single growing display per run;
demotes telemetry events with editorial hierarchy; formats duration +
usage for human reading. Headline behavior change: a 50-token thinking
phase now renders as ONE coalesced growing line in CLI (or one closed
RichLog entry + per-delta live Static widget in TUI), not 50 lines of
[thinking] spam.

Editorial promotion line (issue #12 INV-002):
- Load-bearing (no demotion prefix): Text, Done, Error, Cancelled
- Demoted telemetry (`. ` ASCII prefix in CLI; dim `· ` in TUI):
  WorkerPhase, Thinking, TextBoundary, ToolStart, ToolResult

Stateful coalescing:
- Thinking deltas accumulate into thinking_buffer; first non-thinking
  event closes the run with a single \n boundary in CLI / one closed
  dim RichLog entry in TUI.
- TUI adds a dedicated Static(id="thinking-current") widget that shows
  the last ~200 chars of the active run, mirroring per-delta updates.
  Two-views-of-thinking decoupling per INV-004: chronological RichLog +
  always-visible widget.
- CLI INV-005: when stdout text was streamed mid-line,
  text_written_since_newline triggers a stdout flush + \n before the
  next stderr terminal label — guarantees [done] / [error] / [cancelled]
  land on their own line in a TTY without breaking pipe-to-file
  scripted consumers.

Formatting helpers (issue #12 INV-006 / INV-007):
- _format_duration_ms — autoscale `347ms` / `5.5s` / `1.2m`
- _format_usage — natural-language `6756 in -> 126 out (6882 total, 0
  cached)` with arrow="->" CLI / "→" TUI

Cross-frontier design pass (eitri-smithy-dev, althing
01KSBE52YZR5E3SPTKA672JE43) returned 16-of-16 confirmed decisions + 4
material divergences applied:
- ASCII `. ` prefix in CLI (`·` is U+00B7, not ASCII)
- RichLog one-closed-entry-per-run + Static per-delta updates (not
  inline-mirror as initially proposed)
- presenter-state object instead of pure-function rendering
- Framed as "contract semantics amendment", not "polish"

Volva paraphrase round (5 prose-precision fixes applied to
12.contract.md): INV-001 "growing display" semantics; single hide
mechanism for the Static widget (Textual reactive `display: bool`);
[render_error] security clause (type-only, no exception message);
text_written_since_newline `\n`-terminated text corner case;
[create_session] integration path (bypasses state.render — not an SSE
Event variant).

Volva code-review round (5 findings applied):
- F1 drift: render-exception fallback now writes BOTH a plain-label
  fallback line for the original event AND the `[render_error] <type>`
  line (was missing the fallback half).
- F2 drift: dim Rich style applied to all demoted-telemetry RichLog
  writes via `rich.text.Text(..., style="dim")` (was plain str).
- F3 drift: belt-and-braces widget clear+hide on EVERY terminal event
  (Done/Error/Cancelled), even when thinking_open was False.
- F4 precision: _format_usage gains PRE-001 assertion on the four
  expected usage keys.
- F5 precision: _run_turn signature amended in issue #3 contract to
  document the new `state: CliPresenterState | None = None` test-
  injection kwarg.

[create_session] lifecycle line demoted to `. create_session:` (written
directly by _amain; bypasses state.render since it's not a wire-level
SSE Event variant). Pre-amendment _render_event / _render_event_to_log
and their test classes removed under the no-backwards-compat rule.

Issues #3 and #4 contracts amended in-place: #3 (CliPresenterState
CLASS + FN block + helper FN blocks + _run_turn signature + _amain
create_session demotion); #4 (TuiPresenterState CLASS + FN block +
compose Static widget + _stream_turn_worker state construction).

209 tests GREEN; ruff clean. Bumps v0.1.0 → v0.2.0 (minor — output
shape change breaks pre-amendment grep patterns like `[thinking] '`;
no public API surface change beyond the rendering contract).

Persistent-memory commit-along: captures the issue #12 decision,
forward direction (require end_user_id for every access — declined
worldtree-dev's requires_end_user_id offer because we'll send it
universally), and the Heimdall scope-model foot-gun note (the
"per-Tier-1-agent scope add" diagnosis was a phantom ask resolved by
worldtree-dev's correction; agent.call:* baseline covers all Tier 1).
2026-05-23 16:13:55 -07:00
vh 804c2df6eb feat(sessions,cli,tui): issues #5 + #6 + worldtree-dev consumer-API follow-up
Issue #6 (TUI startup error visibility): restructure run_tui lifecycle so
pre-App.run() failures land on real stderr instead of getting eaten by
the alt-screen teardown. New _resolve_then_run async helper opens the
AsyncClient via async-with, does pre-flight session resolution, routes
AgentNotFound / SessionApiFailed / network errors to sys.stderr (verbatim
same labels + exit codes as cli._amain), then constructs RatatoskrApp
with pre-resolved state and awaits app.run_async(). RatatoskrApp.__init__
signature widens to (args, *, session_id, agent_id, client) — all three
required. on_mount narrows to identity-widget population; on_unmount
becomes a no-op (client lifetime owned by run_tui's async-with).

Issue #5 (--end-user-id for per-end-user agents): sessions.create_session
gains keyword-only end_user_id kwarg with PRE-003 non-empty assertion;
ParsedArgs.end_user_id field added (default None); --end-user-id flag
with non-empty validation; _amain + _resolve_then_run thread it to their
create_session calls. RATATOSKR_END_USER_ID env-var fallback
(flag > env > None) per the post-2026-05-23 amendment; env.sh (gitignored)
ships "ratatoskr-tui" as project-stable partition default.

Worldtree-dev consumer-API follow-up (althing 01KSBARG2B8M): User-Agent
header added (ratatoskr/<version> (vh@phasefinal.com), version pulled via
importlib.metadata) to both AsyncClient constructions so server logs can
distinguish ratatoskr traffic from other consumers.

Volva code-review (2 rounds on #6) found 8 test-precision gaps + 1 PRE
assertion drift, all Category 1 fixed: missing PRE-001 at
_resolve_then_run entry; Rule separator assertions on markdown render;
RichLog-write spy on empty submit; input-cleared + no-new-worker on
cancelling busy; worker.cancel observation on three force-exit paths;
on_unmount-no-close focused test (the prior client-lifetime test patched
run_async so on_unmount was never exercised); happy --new resolve test
verifying POST count + identity propagation.

Issues #2/#3/#4/#5 contracts amended in-place to reflect:
- create_session widened (PRE-003, body construction step, body shape POST)
- ParsedArgs description + _parse_args STEPS + _amain create_session call
  + new TESTS for end_user_id + env-var fallback
- _resolve_then_run STEPS + new TEST entries; on_mount narrowed;
  INV-007 amended for new client ownership
- Post-#6 adjustment note on issue #5 (_resolve_then_run replaces
  on_mount as the threading site since #6 moved session resolution out
  of the alt-screen)

188 tests GREEN; ruff clean. Bumps to v0.1.0 — first minor release, the
load-bearing reason is RatatoskrApp.__init__'s breaking signature change
(additive end_user_id alone wouldn't have triggered a minor pre-v1.x).

Files Gitea issues #9 (spec-pin refresh v0.19.0 → v0.22.1), #10 (track
Worldtree #196 subject:{type,id} migration), #11 (AdminEvents pane auth
prerequisite admin.events.read). Infra-ops pinged via althing for
agents.call:lofn scope add (broker pattern; they forwarded to
worldtree-dev because personal Worldtree exposes no public
scope-mutation endpoint).
2026-05-23 14:34:53 -07:00
vh 7028c5bc11 contract(issue#7): author + amend #1/#3/#4 for empty-skip + MalformedSseData
Issue #7: mid-stream robustness fix discovered via 2026-05-22 crash. Long
mimir TUI conversation (turn 93, 1077 events consumed) crashed on event
1078 with JSONDecodeError("Expecting value: line 1 column 1 (char 0)")
from json.loads('') on an empty-data SSE frame. _iter_events
unconditionally called json.loads on every dispatched event; when
httpx_sse surfaces a frame with id: present but data: empty/missing
(a known library-vs-spec divergence), parsing fails and propagates.

Two-rule fix in _iter_events:
- Empty sse.data (exact `== ''`): SKIP silently per SSE spec (keepalive
  semantics). Don't yield, don't advance last_sse_id, don't set
  terminal_seen. ORDERING: skip fires BEFORE _parse_sse_id, so a
  keepalive with a malformed id is still a keepalive (intentional).
- Non-empty sse.data that fails json.loads: raise new MalformedSseData
  (sibling to MalformedSseId, mirrors raw[:200] truncation pattern).
  Wire-level protocol error; presenters route to [malformed_sse_data]
  + exit 22 in cli, transcript label + state→idle in tui (INV-008).

Volva paraphrase round: 4 ambiguities, all amended:
1. INV-001 prose tightened — exact `sse.data == ''` rule made
   prominent; "keepalive" framing demoted to intent-not-rule;
   whitespace-only data explicitly listed as malformed (not skipped);
   specific state names (last_sse_id, terminal_seen) instead of vague
   "any counter".
2. STEPS pseudocode spells out the ordering — empty-skip happens
   BEFORE _parse_sse_id; empty-data with bad id is silently swallowed.
3. empty_data_skipped test description fixed (had off-by-one count +
   wrong wording around last_sse_id intermediate state).
4. (paired with #1 above).

Volva code-review post-implementation: 3 findings, all addressed:
F1 (test-gap): empty_data_skipped proves yielded events but not
   internal last_sse_id non-advancement. New
   empty_data_skip_preserves_last_seen_sse_id test probes via
   SseConnectionDropped.last_seen_sse_id after a drop following the
   skipped frame — if the skip had transiently advanced last_sse_id,
   the exception payload would carry the wrong value.
F2 (precision, contract amend): MalformedSseData ERROR_ROUTING said
   "log truncated raw" but stream_turn doesn't log — sse_client is a
   library, presenters own observability. Amended to "propagate to
   caller (no logging at sse_client layer); presenters log exc.raw."
F3 (test-gap): cli malformed_sse_data test asserted label but not
   `raw='X'` shape and not truncation. Tightened existing test +
   added malformed_sse_data_truncation with 5000-char payload —
   verifies MalformedSseData.raw truncation carries through the
   presenter's repr() rendering.

**Calibration milestone**: issue #7 is the first issue with ZERO drift
findings from Volva code-review. TDD caught all runtime behavior
cleanly. The 3 findings were assertion-precision and
architectural-correctness-of-wording, not behavioral. Hypothesis:
tighter contract spec + smaller code surface shifts Volva's role
from "catch behavioral drift" to "tighten observability + wording".

Cumulative calibration table: #1 (4 findings, 3 drift + 1 test-gap),
#2 (3, 1+1+1 precision), #3 (5, 3+1+1), #4 (8, 5+2+1), #7 (3, 0 drift
+ 2 test-gap + 1 precision).

Contracts touched (all drift-check clean):
- docs/contracts/issues/7.contract.md (new): the coordinating record.
- docs/contracts/issues/1.contract.md: _iter_events STEP 3.0
  empty-skip + ordering note; STEP 3.c JSONDecodeError → MalformedSseData;
  new MalformedSseData ERROR_ROUTING (propagate-to-caller wording per
  F2); 4 new TESTS entries including F1's last-seen probe.
- docs/contracts/issues/3.contract.md: _run_turn ERROR_ROUTING +
  malformed_sse_data tests (incl. F3 truncation).
- docs/contracts/issues/4.contract.md: INV-008 mentions MalformedSseData;
  _stream_turn_worker ERROR_ROUTING + new TEST.
2026-05-22 16:41:16 -07:00
vh 61c3941ec3 fix(client): disable SSE read timeout — caught by personal Worldtree smoke
First manual smoke against personal Worldtree (10.250.50.152:8081)
produced httpx.ReadTimeout mid-stream after the worker_phase
BuildingPrompt event. Root cause: httpx's default 5s read timeout
killed the connection during mimir's thinking phase (LLM streaming
has multi-second idle gaps between SSE events).

Fix at the caller layer (where the AsyncClient is owned):
- cli._amain and tui.on_mount now construct AsyncClient with
  timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0).
  read=None disables the SSE-killing timeout; connect/write/pool
  keep modest timeouts so true network failures still surface
  promptly.

Defense in depth in sse_client.stream_turn:
- ERROR_ROUTING now also catches httpx.ReadTimeout (was just
  ReadError | RemoteProtocolError) and surfaces it as
  SseConnectionDropped, so if a caller misconfigures their client
  the failure is at least a named exception the presenters handle.

Contract amendments (in-place):
- Issue #1: new [compatibility] constraint documents the read=None
  recommendation; ERROR_ROUTING for stream_turn lists ReadTimeout
  alongside ReadError/RemoteProtocolError.
- Issues #3 + #4: AsyncClient construction step now spells out the
  timeout shape explicitly.

Smoke after fix: SSE stream consumed cleanly, agent responded,
[done] turn_id=88 model=qwen3.6-35-a3b duration_ms=2351. Stdout-only
(2>/dev/null) returned clean agent text + exit 0 — INV-002
stdout/stderr split holds end-to-end against real wire. Wire-compat
envelope (personal v0.16.2 vs ratatoskr's v0.19.0 pin) confirmed.

164/164 tests GREEN; ruff clean; all three drift checks clean.

Note: TUI mode not smoke-tested from this CC session (needs a TTY;
operator-side check via `source env.sh && uv run ratatoskr --new
--agent mimir`).
2026-05-21 00:58:42 -07:00
vh 942e33898c fix(tui): address Volva code-vs-contract drift (issue #4)
Volva code-review surfaced 8 findings against the TDD-passing
TUI shell. All 8 addressed.

Drift fixes (code):
- Primary: INV-002 + INV-003 require visible Footer-area rendering
  of session-identity + Ctrl-C state hint. Implementation stored
  the strings in `self.sub_title` (which lands in the Header, not
  Footer) and `self.hint` (a plain attribute, never rendered). Fixed
  by adding two `Static` widgets (id="identity" and id="hint") in
  compose; the `_set_hint()` helper mirrors state into the widget on
  every state transition. Same-model TDD missed this because tests
  asserted internal state, not visible widget content.
- Reverted `_stream_turn_worker(content, log)` to single-param
  `(content)` per the contract FN signature. The widened signature
  was a TDD-time workaround for a NoMatches-during-worker
  execution; root cause was test timing (added `await pilot.pause()`
  before the polling loop in `_submit_and_wait`).
- Restored `exclusive=True` on `self.run_worker(...)` per the
  contract STEP 6 spec.
- Added missing `isinstance(args, ParsedArgs)` PRE assertion to
  `run_tui`. Required hoisting `from ratatoskr.cli import
  ParsedArgs` out of TYPE_CHECKING — runtime import is fine (no
  circular dependency: cli lazy-imports tui inside main; tui
  imports cli unconditionally at module load).
- Added missing union-type PRE assertion to `_render_event_to_log`.

Contract amendments (precision):
- COMPOSE shape: RichLog `markup=False, highlight=False` (was True,
  True). Explanatory comment in-line: bracketed labels like
  [cancel_failed] would otherwise be interpreted+stripped as Rich
  style spans; the post-Done Markdown rendering still works via
  Markdown() Renderable.
- INV-002 reworded: identity rendered via dedicated
  Static(id="identity") widget composed adjacent to Footer (Textual's
  built-in Footer renders BINDINGS descriptions; a sibling Static
  carries custom content in the same visual region).
- on_mount POST-003 amended to allow `agent_id is None` when
  --session is used without --agent (matches INV-002 carve-out;
  GET /sessions/{id} agent lookup is out of scope for this shell).
- run_tui happy_returns_zero_on_quit test description clarified:
  App.run() is sync and can't be driven by Pilot, so run_tui's
  wrapping behavior is tested via monkeypatch; the piloted Ctrl-D
  exit path is covered separately by TestActionQuit.

Test fixes:
- footer_identity_visible_first_frame, footer_hint_flips_to_cancel,
  streaming_first_ctrl_c_cancels: now query the Static(#identity) /
  Static(#hint) widgets via `widget.render()` instead of asserting
  on `app.sub_title` / `app.hint` internal state. The internal
  state still exists (mirror), but the load-bearing assertion is
  on visible widget content.

Meta-note from Volva: "TDD pass caught most stream/session/error
mechanics, but tested internal state where the contract required
visible Footer behavior, so same-model TDD would plausibly miss the
primary drift." Calibration shape continues across all four issues:
the post-TDD cross-model review consistently catches assert-boundary
+ observability-shape gaps the test-author's hypotheses don't cover
(#1: 4 findings, #2: 3, #3: 5, #4: 8).

164/164 tests GREEN; ruff clean; both contract drift checks clean.
2026-05-21 00:46:06 -07:00
vh 9d469d5c67 contract(issue#4): author ratatoskr.tui shell + amend issue #3 cli
Issue #4: Textual TUI shell — the interactive primary presenter
(design-brief §1, §5). Single chat-pane App[int] subclass + sync
run_tui(args) entry. Composes existing sessions + sse_client modules
(no forked API-consumption code, per design-brief §8b).

Six FN blocks: run_tui, RatatoskrApp class + on_mount + on_unmount,
on_input_submitted, _stream_turn_worker, _render_event_to_log,
action_interrupt, action_quit, _cancel_via_sse.

Nine hard invariants codifying:
- INV-001: lazy-import boundary so cli.py STILL doesn't import textual
  at module scope (issue #3's INV-001 carried forward)
- INV-002: session-identity-always-visible footer (`<agent> · …<tail8>`)
  with explicit `<unknown>` carve-out for --session without --agent
- INV-003: two-stage Ctrl-C state machine (idle/streaming/cancelling)
  per design-brief §8c
- INV-005: markdown default-on with --raw opt-out; deliberately produces
  streaming-deltas + post-Done markdown re-render (accepted v1 trade-off,
  Static-then-commit refactor deferred)
- INV-007: one AsyncClient per app lifetime
- INV-008: mid-session errors → idle (don't exit); only initial
  session-create errors exit

Concurrent in-place amendment of issue #3's contract:
- --send becomes optional; when omitted, send_content=None is the
  TUI-mode marker
- --raw flag added to ParsedArgs
- main dispatches via lazy `from ratatoskr.tui import run_tui` when
  send_content is None
- _parse_args + main TESTS sections updated (no_send_marks_tui_mode
  replaces usage_no_send; new raw_flag_default_false / raw_flag_set /
  no_send_dispatches_to_tui)

Volva paraphrase round on issue #4: 5 findings, all amended.
(1) INV-002 `<unknown>` carve-out wording.
(2) Idle "Ctrl-C twice to exit" hint kept per design-brief §8c's
    conservative-by-design rationale; INV-003 spells out the
    intentional one-press-from-idle discrepancy.
(3) Markdown double-render trade-off made explicit in INV-005.
(4) Submit-during-streaming now writes `[busy] turn in flight; input
    ignored` (visible notice, not silent swallow).
(5) `{!r:.200}` format spec kept with explanatory inline comment.

Both contracts drift-check clean. prd: pinned to issue #4 body SHA
b1e73e7d2e3dd453 at 2026-05-21T06:21:37+00:00.
2026-05-21 00:31:15 -07:00
vh 9717fb80e2 fix(cli): address Volva code-vs-contract drift (issue #3)
Volva code-review surfaced 5 findings against the TDD-passing
implementation; all 5 addressed.

Drift fixes (code):
- Add `assert argv is None or all(isinstance(a, str) for a in argv)`
  at both `main` and `_parse_args` entry points (PRE-001 was unenforced).
- `main` now catches `SystemExit` and returns `exc.code` verbatim —
  argparse's --help (SystemExit(0)) was escaping through main as an
  unhandled exception. Contract amended in-place to spell out the
  SystemExit-from-argparse-clean-exits passthrough in both
  `main` and `_parse_args` ERROR_ROUTING. New `help_exits_cleanly`
  test added per the contract amendment.
- Add the PRE-001 union-type assert at `_render_event` entry —
  unmatched Event variants would have silently no-op'd.
- `_run_turn` now awaits `cancel_task` in the `finally` block before
  returning. Under fast-stream + slow-cancel scenarios the
  `[cancel_failed]` line could miss being written before _run_turn
  returns, AND _amain could close the AsyncClient while the cancel
  POST was still in flight. `_cancel_and_log` swallows all errors
  per INV-009 so the await is safe.

Test gap fix:
- New `_FlushCountingIO` subclass counts flush() calls;
  `test_text_to_stdout_only` and `test_done_writes_newline_and_label`
  now assert `flush_count == 1` to verify INV-010 (per-chunk flush).
  Previously the tests would have passed even with flush removed.

Meta-note carried in persistent-memory: TDD caught central behavior
(stdout/stderr routing, exit-code mapping, create-session ordering,
SIGINT idempotence); the cross-model code review consistently catches
assert-boundary + observability-shape gaps across all three issues
(#1: 4 findings, #2: 3 findings, #3: 5 findings).

118/118 tests GREEN; ruff clean; drift check clean.
2026-05-20 22:59:26 -07:00
vh 69fdfaa104 contract(issue#3): author ratatoskr.cli --send — scaffold + body + Volva amend
Issue #3: non-interactive `--send` stdout presenter (design-brief §8b).
Composes sessions.create_session (when --new) with sse_client.stream_turn
+ cancel_turn. First contract in the repo with a code-level dependencies:
block (issues #1 and #2).

Six FN blocks (main, _parse_args, _amain, _render_event, _run_turn,
_cancel_and_log). 10 hard invariants codifying: no textual/rich imports;
Text deltas + post-Done newline are the only stdout writes; --session xor
--new + agent/api-key resolution chains; SIGINT semantics (cancel-with-
known-turn-id; early-exit code 3 without; swallow cancel failures during
drain). 9-bucket exit code table.

Volva paraphrase round flagged 5 ambiguities; all 5 amended:
- INV-001 wording: "no in-repo modules other than X/Y" (was strict-only)
- INV-002 + _render_event POST-002/003: Done's stdout newline is part
  of the contract, restructured to avoid the "no other event writes
  stdout" contradiction
- Data flow stderr list: added the pre-event [cancelled] (before any
  event arrived) label
- _run_turn STEPS 3 race-loop: gate sigint_task creation behind
  `if not cancelling` to avoid busy-wake once event is set; new
  no_busy_loop_after_cancel trace test
- assumptions list: added CancelTurnNotFound + CancelAlreadyCompleted

prd: block pinned to issue #3 body SHA 206ef51709d43b2c at
2026-05-21T05:13:29+00:00. Drift check clean.
2026-05-20 22:50:59 -07:00
vh d6f9327ec1 fix(sessions): address Volva code-vs-contract drift (issue #2)
Volva's code-spec review (thread 01KS4EKVKKGF) surfaced three findings
on the TDD-passing sessions module. All three addressed; one carries
a collateral contract amendment to keep INV-002 truthful.

1) drift: archived=item.get("archived", False) returned None for an
explicit "archived": null in the response. dict.get(k, default) only
fires the default when the key is absent — it does NOT default for
explicit-null values. The dataclass type is `bool` (not `bool | None`)
and INV-002 says explicit-null → False; the .get() form silently
violated both. Fixed: archived=item.get("archived") or False
(handles absent, null, false, and true cleanly).

INV-002 wording was the source of the bug — I introduced the
mis-spelled form during the Volva amendment round. Updated to spell
out the .get(default) foot-gun explicitly so future readers (and
future paraphrase rounds) don't fall back to the broken pattern.

2) test-gap: no test exercised explicit-null archived/tags. The
_list_item() helper had its own defaulting layer (tags=None →
["work"]) so a happy path test couldn't catch the underlying drift.
Added test_explicit_null_list_defaults using a raw dict to bypass
the helper. Catches the drift directly.

3) precision: message_count=body.get("message_count") could silently
default to None while POST-003 required it non-None. INV-001 prose
literally said "body['message_count']" (bracket access) so the
STEP 5 .get() was the contract's own internal inconsistency.
Aligned the code to bracket access (matches sibling required
fields like session_id) and amended STEP 5 + INV-001 to spell out
the strict semantics explicitly.

Volva's meta-note: "modest weight" — TDD caught the main surface;
this round caught a narrow Python .get() semantics edge that no
human reading would have spotted without explicit-null priors.
Still pulls real weight: that's the kind of bug that ships and
shows up months later when a server starts emitting null where
it used to omit a field.

63 tests GREEN (42 sse_client + 20 sessions + 1 boundary).
Ruff clean. Drift check still GREEN against the pinned issue body.
2026-05-20 22:06:37 -07:00
vh 4ba143c563 feat(sessions): implement issue #2 contract via TDD
Implements docs/contracts/issues/2.contract.md. Two functions
(create_session, list_sessions), two frozen dataclasses (SessionInfo,
SessionPage), three exception types (AgentNotFound, InvalidCursor,
SessionApiFailed). 19 contract-listed tests cover every TESTS:
entry verbatim per the tracer-bullet vertical-slice ordering.

SessionInfo uses one shape across both endpoints with origin-
conditional defaults per INV-001 (create) and INV-002 (list). create-
origin always sets list-only fields to (name=None, archived=False,
tags=[]); list-origin reads them from the response item with
absent/null treated as those same defaults — keeps the dataclass
uniform without forcing callers to handle two types.

Spotted an internal-inconsistency in the contract at TDD start —
POST-003 and happy_create's test description still said "archived
is None, tags is None" while the freshly-applied Volva amendment
had moved INV-001 to (archived=False, tags=[]). Fixed in-place
before writing any tests so the spec stayed coherent.

SessionApiFailed.body truncates to <= 1024 bytes at construction,
matching the SseConnectFailed / CancelFailed precedent from issue #1.

No code shared with sse_client.py (convention-dependency only per
issue #2's dependencies: block). 62 tests GREEN total (42 sse_client
+ 19 sessions + 1 boundary smoke). Ruff clean.

No refactor pass — the two functions are ~25 LOC each with distinct
error-routing branches that don't naturally share more than they
already do.
2026-05-20 22:01:05 -07:00
vh a6e6c1bbd8 contract(issue#2): amend per Volva paraphrase — defaults, query, metadata
Volva's contract paraphrase round (thread 01KS4DTCW8CV) surfaced five
ambiguities; three are real contract-text gaps and addressed here.

1) tags/archived/name defaulting was inconsistent across prose, INV-002,
and STEP 6. open_questions said "defaulting to sensible None/empty",
INV-002 said "populated from the response item shape", STEP 6 said
`item.get("tags", []) if "tags" in item else None` (which collapses
absent and explicit-null into the same None branch while letting an
explicit [] pass through). Tightened to: tags is always list[str]
defaulting to [] for absent/null/empty in list items; archived is
always bool defaulting to False; name remains str | None (the only
field where None is a meaningful value). create_session always sets
list-only fields to their fixed defaults (name=None, archived=False,
tags=[]) instead of None to keep the dataclass shape uniform.

2) The include_archived_query test said "URL has no include_archived
param OR explicit false". STEP 2 prescribes "ADD include_archived='true'
iff include_archived" — the OR-clause weakened the test against the
prescribed behavior. Tightened to: default (include_archived=False)
asserts NO include_archived param at all, not an explicit false.

5) metadata's populated semantics: INV-001 said "populated from the
201 response", STEP 5 said body.get("metadata", {}) — two valid
readings (trust the spec vs defensive default). Aligned to the
defensive shape: INV-001 + INV-002 now explicitly state "defaults
to {} when absent" as spec-drift tolerance.

Volva flags #3 (exception .body sensitivity — truncation reduces
size not sensitivity) and #4 (assert for runtime validation — Python
-O disables) reviewed and kept as-is. Both are intentional carryovers
from issue #1's precedent: exception .body is for caller debugging
bound to 1024 bytes (caller's responsibility to not log raw); assert
chosen for fast-path validation, trading -O robustness for normal-mode
speed.

Drift check still clean — amendments don't touch the pinned issue
body, so prd: hashes remain valid.
2026-05-20 21:52:32 -07:00
vh 9df9bb8757 contract(issue#2): scaffold ratatoskr.sessions — create + list
Issue #2: ratatoskr.sessions covers the session-lifecycle endpoints
needed by --send --new (POST /sessions) and the eventual TUI startup
picker (GET /sessions). Two FN blocks (create_session, list_sessions)
plus two shared frozen dataclasses (SessionInfo, SessionPage).
complexity=low; estimated 150 LOC.

Bundles both endpoints in one contract because they share the response-
envelope shape — SessionInfo carries the union of POST-response fields
(message_count) and list-item fields (name, archived, tags), with
the origin-conditional fields defaulting to None. INV-001/002 spell
out which fields come from which source so callers can rely on the
discriminator.

INV-006 refuses out-of-range limit (< 1 or > 200) client-side: spec
§GET /sessions says the server returns 422; the client checks first
so a 422 from this endpoint indicates server-side spec drift, not a
client bug.

INV-003 codifies the opaque-cursor discipline (spec §Pagination:
"Cursors are opaque to clients — do not parse or construct them.").
list_sessions threads next_cursor verbatim; never base64-decodes.

Exception .body truncation to [:1024] inherited from issue #1's
SseConnectFailed/CancelFailed precedent.

First contract in this repo to carry a ## Out of scope H2. Future
/volva-code-review consults will auto-resolve that section instead
of needing --out-of-scope overrides. Six explicit exclusions:
Bifrost binding (Worldtree #160), ephemeral/Saga sessions, single-
session fetch, PATCH/DELETE mutation, history pagination, transparent
multi-page iteration. Server retry/backoff is caller's policy.

dependencies: lists issue #1 as a convention-dependency only — no
code import; same API-consumption posture (caller-owned httpx
client, async-native, frozen dataclasses, no Worldtree-source
imports).

prd: pinned to issue #2 body SHA-256 01fbbd52b6d90eb0 at
2026-05-21T04:45:06+00:00; scripts/contract_drift_check.py returns
clean.
2026-05-20 21:47:49 -07:00
vh 1526f0bc8e contract(issue#1): amend per Volva paraphrase — body, id range, INV-003
Volva's contract paraphrase round (thread 01KS4B3B0Y62) surfaced three
real contract-time ambiguities — addressing each here before applying
ready-for-agent.

1) reconnect_turn body was a punt. STEP 2 literally said "json={'content':
''} OR with no body (TBD per spec — confirm during implementation)".
The spec §Reconnect flow example shows POST with Content-Type:
application/json and a body shaped {"content": "..."} — the wire schema
requires content; the server identifies the resume target via the
Last-Event-ID header and does NOT re-process content (spec line 732:
"agent's tools and LLM call run exactly once regardless of disconnects/
reconnects"). reconnect_turn now takes content: str explicitly; STEP 2
sends json={"content": content}. Caller convention: pass the original
content sent to stream_turn. POST-002 added to assert byte-for-byte
body shape; new test body_threads_content covers it.

2) _parse_sse_id allowed turn_id and seq ≥ 0 — too loose. Spec §SSE id
format line 705 says seq starts at 1 (resets per turn); turn_id is
from SQLite turns.id (autoincrement, ≥1). Tightened POST-001 to require
both ≥1; STEP 5 raises ValueError on either < 1. Test happy_zero_seq
flipped to adversarial zero_seq; added zero_turn_id and negative_seq.
INV-002 tightened to reflect the same range.

3) INV-003 (TurnIdFlip) had a subtle wording gap between stream_turn
(first event ESTABLISHES turn_id; cannot be a flip) and reconnect_turn
(expected turn_id parsed FROM last_event_id BEFORE connect; first event
is already a flip-candidate). Volva noticed the reconnect test said
"first event was not yielded" while stream_turn semantics depend on
the first event being yielded unconditionally. Spelled out both entry
points in INV-003 as a numbered sub-list. reconnect_turn STEP 4 and
test turn_id_flip_on_first_event reworded to match.

Volva flags #3 (MalformedSseId-vs-ValueError split) and #5 (exactly-
one-terminal as server-assumed, not client-verified) reviewed and kept
as-is — both intentional. Drift check unchanged: amending the contract
does not touch the pinned issue body, so prd: hashes remain valid.
2026-05-20 21:08:19 -07:00
vh 999b0b4765 contract(issue#1): pin sse_client to gitea issue + seed default labels
Convert the sse_client contract into an issue-scoped contract bound to
the freshly-filed gitea issue #1. Frontmatter migrates from module-shape
(module:/purpose:) to issue-shape (target_module:/scope:/prd:) per
CONTRACT-FORMAT §2.1.I. The prd: block pins to issue #1's body SHA-256
(abcbc49467e86f1d at 2026-05-21T03:57:37+00:00); drift check verifies
the pin matches the live issue body.

scripts/contract_drift_check.py needs pyyaml; added to [dev] in
pyproject.toml. Without it the drift check (and the contract parser)
fail with ModuleNotFoundError — that's a scaffold hole I'd hit again
on a fresh checkout.

Also seed 17 default labels on gitea via tea so issue tracking has a
working vocabulary out of the gate. Five buckets: Sleipnir gating
(ready-for-agent, blocked-needs-contract, blocked-needs-dependency),
triage (needs-triage, needs-architect-decision, needs-info), type
(bug, enhancement, task, documentation), resolution (duplicate,
wontfix, invalid), Ratatoskr-specific area (sse-client, tui, cli,
observability). Labels are gitea-side state — not in this commit.

Known: contract_parser.py --validate ERRORs on the issue-scoped
frontmatter because the parser is v2.0-shape. CONTRACT-FORMAT §2.1.L
H10 explicitly marks parser kind-aware validation as a Brokkr-side
follow-up. Parser is a canonical-synced file so we do NOT patch it
locally (would drift from corviduo-project-template).
2026-05-20 20:59:46 -07:00
vh 72d477f516 contract(sse_client): first contract — SSE consumer, reconnect, cancel
The natural smallest unit to TDD against per design-brief §3. Bundles
stream_turn + reconnect_turn + cancel_turn + private _parse_sse_id into
one module because the SSE-resume flow is structurally coupled — cancel
needs the turn_id parsed from the SSE wire id:, reconnect re-uses the
same parsed SseId, and stream_turn is what produces them.

Hard invariant INV-002 forces every yielded Event to carry a parsed
SseId(turn_id, seq) lifted from the composite {turn_id}:{seq} id:
wire field. This closes the foot-gun design-brief §3 explicitly names:
hand-rolled data:-only parsing silently drops the id: line and breaks
SSE-resume invisibly.

v2.1 format used; test categories adversarial/scenario/trace flagged
warn-only by the v2.0 parser (CONTRACT-FORMAT §2.1.L H10 is a known
Brokkr-side parser follow-up). FN block list parses cleanly.

Scaffold also verified at this commit: uv pip install -e ".[dev]"
resolves clean against the lockfile (now committed), and the boundary
smoke test (tests/test_no_worldtree_imports.py) passes.
2026-05-20 20:50:26 -07:00
vh 9703eb2b6b init: seed Ratatoskr from corviduo-project-template + ship v0 scaffold
Worldtree Conversation API debug TUI. Multi-pane observability dashboard:
chat transcript + persona/Vili affect log + tool events + admin events +
Bifrost state + tool inventory + (opt-in) raw server log.

Design locked at docs/design-brief.md (originated as
brokkr-smithy/docs/ratatoskr-design-brief.md). Operator-locked decisions:

- Textual application-shell framework (multi-pane dashboard, not REPL).
- Separate repo + separate dev team (no Worldtree-source imports).
- httpx-sse for SSE consumption (reference Python SSE-resume impl).
- Triple version-skew mitigation: spec-pin in pyproject.toml + recorded
  SSE snapshot tests + conformance smoke. Initial pin: Worldtree v0.19.0
  at 55101e909abcd2219833266b6f905c5bc956e0f0.
- Persona pane: label-don't-refuse PII posture.
- Server-log pane: opt-in via --server-log <path>.
- Two-stage Ctrl-C (cancel then exit).
- Markdown rendering default-on; --raw opt-out.

In the box:

- docs/design-brief.md — the locked design with full rationale.
- docs/SPEC-PIN.md — Worldtree spec pin + bump procedure.
- docs/conversation-api-spec.md + docs/conversation_api.contract.md —
  vendored Worldtree spec snapshots at the pinned SHA.
- pyproject.toml — Python 3.12, hatchling, uv-managed, deps locked.
- src/ratatoskr/ — stub package (cli.py raises NotImplementedError).
- tests/test_no_worldtree_imports.py — boundary smoke test PASSING.
- tests/snapshots/README.md — recording convention for SSE snapshot tests.

Not in the box yet:

- Gitea remote (operator/infra-ops to register at vh/ratatoskr).
- Implementation — the dev team owns this; design brief is the spec.

Origin: althing thread 01KS3R34XD3N6HMK91VXESHGW7 (worldtree-dev →
brokkr-smithy-dev, 2026-05-20). Volva consulted via thread
01KS3VF6W33N3V5FNMGQ91YNVD.
2026-05-20 20:38:22 -07:00