Metadata-Version: 2.4
Name: kirby-ai
Version: 0.3.1
Summary: Kirby's interface to kirby-medialib generation: one client, one seat registry
Author: PeterB
License: PolyForm Noncommercial License 1.0.0
Project-URL: Repository, https://github.com/pdbethke/kirby-ai
Requires-Python: >=3.12
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx>=0.28.0
Requires-Dist: kirby-combat>=0.18.5
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
Requires-Dist: pytest-cov>=5.0; extra == "dev"
Requires-Dist: ruff>=0.6; extra == "dev"
Dynamic: license-file

# kirby-ai

Kirby's interface to kirby-medialib's generation endpoints. Published to PyPI as `kirby-ai` and to the ProductBinder index; it carries no keys — seats are named here, provider keys live in medialib.

One client, replacing four independently written medialib clients in
kirby-api.

## Seats

A call site names a **seat**, never a model:

```python
client.generate(seat="combat-pick", prompt="...")
```

A seat is a row in kirby-medialib, not a file this package reads (medialib-api
spec 2026-09-16): medialib resolves the provider and model server-side, so
the client only ever sends the seat's name. `seeds/seats.dev.json` (local stack,
Ollama-backed) and `seeds/seats.prod.json` (Hetzner, cloud providers only) are
the seeds for that table, applied by `medialib-api/scripts/seed_seats.py`; they
are not consulted at request time. The two files define the same seat names.

```json
{
  "tenant": "kirby",
  "seats": {
    "combat-pick":    {"provider": "ollama", "model": "kirby-mythos:latest"},
    "combat-review":  {"provider": "ollama", "model": "kirby-mythos:latest"},
    "campaign-prose": {"provider": "ollama", "model": "kirby:latest", "vision": true}
  }
}
```

`generate()` has **no `model` or `provider` argument**, and a test asserts their
absence. The wire body carries `seat` and nothing that names a provider.
Overriding a seat means adding a seat -- in medialib, now, not here.

**There are no defaults.** An unnamed seat raises. A tool that silently picks a
model has signed a claim on your behalf — and Kirby has the receipt: a provider
pinned inside one module sent a local-model fight to a metered API for four
months with nothing in the log to say so.

## Choosing an action

`ModelChooser` fills the seat `kirby_combat.loop.Chooser` leaves open: the
engine renders the Phase as a `Brief`, this sends it, and takes back an
`action_id`. One pass.

`DeliberatingChooser` adds a second — **propose → review → execute or
re-propose** — because "on the menu" is a legality test, not a sanity test.
The O.K. Corral keeps producing legal absurdities: a marshal who cannot see
an enemy spending his Phase shooting a boarding house, five men with
revolvers trying to level a saloon they could not dent. A reviewer holding
the proposal catches those without anyone having to anticipate them as a
rule.

```python
DeliberatingChooser(client, propose_seat="combat-pick",
                    review_seat="combat-review", rounds=1)
```

`rounds` counts re-proposals: `0` is a single pass and behaves exactly like
`ModelChooser`, so the two can be compared on one benchmark by changing one
number. The reviewer never picks — it approves, or sends the proposal back
with a stated objection, and only the objection travels back. **A reviewer
that cannot answer never vetoes:** a dead network, prose instead of JSON, or
an unrecognised verdict all resolve to execute, recorded in `notes`.

Two seats, not one, because reviewing is not the same job as proposing and
must not become the same model by accident.

A model's answer can fail to reach any chooser — a dead transport, prose
instead of JSON, an id off the menu — and by default every one of these
quietly substitutes `FirstLegalChooser` and records why in `.notes`, so a
benchmark keeps running rather than dying on one malformed reply. Pass
`fallback="raise"` to opt out of that: `ModelChooser`, `DeliberatingChooser`
and `RoleCouncil` all raise `kirby_ai.NoChoice` instead of substituting,
carrying the actor, the offered `action_ids`, and the reason the model could
not be honoured. This is for a harness that must be able to say "I don't
know" rather than act on an unreviewed silent standby — a caller reading
`.notes` after the fact is not the same as one deciding, per Phase, whether
to proceed. The default (`fallback=None`, or any chooser instance) is
unchanged.

## Intent

A seat that decides from scratch every Phase has no memory: it closes to
melee, then shoots, then closes again, and a reader watching the log cannot
tell a change of mind from a change of circumstance. `IntentChooser` wraps
another chooser and gives each actor a **standing plan** — an objective and a
target, formed from what it actually chose — kept across Phases until
something happens worth rethinking for: the target goes down or out of sight,
the actor takes a hit worth a quarter of its current STUN, its stance changes,
nothing on the menu serves the plan any more, or the commitment runs out. The
commitment is measured in **turns**, not Phases, and how many is a property of
the fighter: the role the engine classifies it as, moved by the psychological
complications the player paid points for.

```python
from kirby_ai import IntentChooser, ModelChooser

seat = ModelChooser(client, seat="combat-pick")
chooser = IntentChooser(seat, commitment_turns=2)
```

The plan **advises, it does not decide**. The inner seat still picks; the live
plan reaches that seat through the engine — `Brief.render(extra_doctrine=...)`
puts it in the engine's own doctrine section, under the engine's heading and
the engine's suppression, so `doctrine=False` and `KIRBY_BRIEF_NO_DOCTRINE`
remove the plan along with everything else advisory. Nothing here splices text
into a rendered page.

That advisory shape is the one deliberate difference from the kirby-api layer
this was ported from, where a live plan continued **without a model call at
all**: the intent layer picked the serving action itself. **The price is one
model call per Phase where the api made none while a plan held** — for a fight
of any length that is most of the calls, so `IntentChooser` today buys
coherence and costs money, where the api's bought both. Restoring the saving is
a follow-on (`continue_without_call`), not something this layer decides on its
own.

`IntentChooser` **installs itself on the inner chooser**: it sets
`inner.doctrine_lines`, chaining any source already there rather than replacing
it. That is a side effect on an object the caller owns — a wrapper is built
after the thing it wraps, so there is no other moment to do it — and two
`IntentChooser`s over one seat would stack their lines.
`chooser.renders_intent` says whether the plan is reaching the page at all.

`fallback="raise"` passes straight through: a harness that asked not to be
guessed for still hears `NoChoice`.

### What the port changed

| | |
|---|---|
| **dropped** | the intent table and its event lane (api rows), the `character_tactic_profile` base, the construct damage-overlay id translation, the per-combatant `morale_state` and with it all rage suppression, and the Tactics/INT notice roll (a `Chooser` rolls no dice — `notice` is the caller's callable and, given none, nothing is noticed) |
| **added** | `throw` and `throw_object` now imply an objective (ENGAGE_MELEE and KEEP_RANGE); the api's kind map omitted both although its preference table already served them. A **STUNNED actor** now abandons its plan: `is_significant_hit` is asked with `stunned=`, folded off the engine's event log by `statuses_for`, which the api read from its own rows |
| **rungs** | health is `kirby_combat.classify_health`, the engine's one door. The 0.25 significant-hit fraction stays here: it is planning policy and no rule fixes it |

## Doctrine

`TacticsDoctrine` puts the engine's own tactic catalogue on the page **as
offers a seat can return**. It takes a `PhaseSituation` and gives back ranked
lines shaped `"<kind> -> [<action_id>] against <name>"`; install it with `ModelChooser(..., doctrine_lines=TacticsDoctrine())`
and they reach the reader through `Brief.render(extra_doctrine=...)`, in the
engine's own doctrine section, under the engine's suppression. Nothing here
splices text into a rendered page.

```python
from kirby_ai import ModelChooser, TacticsDoctrine

seat = ModelChooser(client, seat="combat-pick", doctrine_lines=TacticsDoctrine())
```

**What it calls in the engine:** `tactics_for` (the catalogue, ranked — every
tactic, its `Plan` and its `Basis` are kirby-combat's), `PhaseSituation.
tactical_situation` and `ordered_menu`, and `Brief.render(extra_doctrine=...)`
for delivery. The matching rule is `TacticChooser`'s, step for step: steps are
tried in order, an offer of the right kind at a different man does not count,
and a tactic the menu cannot serve is skipped. Advice that contradicts the
engine's own deterministic seat would be a contradiction on one page.

**Why there is anything to add, and only that.** `Brief.doctrine` already
renders `name -> kind: summary`. A *kind* is not returnable — the seat must
answer with an `action_id` — and sixteen of the catalogue's tactics name a
`target_id` that never reaches the page, so with two enemies in front of you
the engine's line says "close and strike" and does not say who. These lines are
**continuations** of the engine's, directly below them under the same heading:
they add the offer and the man and **never restate the tactic's name, summary
or rationale**. Two tactics wanting one offer say it once.
`test_every_tactic_is_named_once_on_the_page` renders the real page through
`Brief.render(extra_doctrine=...)` and counts, and
`test_it_advises_what_the_engines_own_seat_would_take` pins the first line to
`TacticChooser().choose()` on the same Phase.

**A tactic that raises is an engine defect and arrives as one.** Nothing here
catches; a caught exception would read as "no doctrine applies", forever.

### What the port changed

| | |
|---|---|
| **not ported** | the **catalogue itself**. All twenty of the api's tactics are already in `kirby_combat/tactics/catalog/` — terrain-aware ones included, and repaired there (`smash_cover` named a man where the offer is keyed by a wall; `take_cover_when_hurt` kept its own copy of the health ladder). A second copy here would be a second answer. Covered by the api's `tests/combat/test_tactics_catalog.py` (all of it — smash_cover / fight_from_cover / keep_range / close_and_strike / exploit_hazards) and `test_tactics.py::test_bait_enraged_*`, `::test_exploit_susceptibility_*`, `::test_focus_fire_*`, `::test_presence_attack_*`, now the engine's `tests/test_tactic_catalogue.py` |
| **dropped** | `llm_selector.py` — a rules library asking a service which tactic to use and falling back to priority on any error. The engine deleted it deliberately (`select_tactic`: choosing among what applies is the consumer's job), and the consumer is the seat, which picks an offer rather than a tactic name. Covered by `test_tactics.py::test_select_tactic_with_llm_mock`, `::test_select_tactic_llm_fallback_on_error`, `::test_compare_selectors_runs_each_model`, `::test_compare_selectors_handles_per_model_failure` |
| **dropped** | the deterministic priority selector (`test_tactics.py::test_select_tactic_deterministic_priority_when_no_llm`) — that is `tactics_for`, in the engine |
| **dropped** | the **role split and its rows** — `TacticProfileRow` / `TacticDefinitionRow`, per-entry weights, offensive/defensive lists on `StepSituation`. Rows are kirby-api's and the api's tables go with the harness. Covered by `test_tactics_role_split.py` (all of it) |
| **dropped** | **seven tactic definitions that existed only as seeded rows**, with no class behind them and no engine counterpart by name: `finish_dying`, `target_stunned`, `defensive_recover`, `separate_grouped`, `silence_caster`, `lure_to_cover` (alembic `20260504_0010_tactic_catalog_expansion`) and `slam_velocity_attack` (`20260504_0011`). Advice is only worth rendering if something decides it applies, and nothing in code ever did for these. Covered by `tests/combat/test_situation_builder.py::test_build_situation_active_profile_resolves`, which names `target_stunned`, `silence_caster` and `separate_grouped` in its comment and asserts only that the *original* four profile entries still land; `finish_dying`, `defensive_recover`, `lure_to_cover` and `slam_velocity_attack` had **no api test at all**. `lure_to_cover` and `separate_grouped` are positional advice the engine's catalogue lacks — listed under engine gaps, deliberately **not** implemented here |
| **dropped** | the tactic's `Basis`, from the rendered line. The engine's own doctrine line is where a tactic explains itself; quoting its citation down here was one more restatement |

## The calculator seat

`EVChooser` is a `Chooser` that prices every offer and takes the best: **P(hit)
x the STUN the engine's resolver gets through**, averaged over every roll the
dice can produce. It is kirby-api's `sim_ai.py` expected-value pick, ported.

```python
from kirby_ai import EVChooser

seat = EVChooser()          # .picks records the number behind every decision
```

**No fallback.** A Phase where nothing can be priced raises `NoChoice` rather
than taking the first legal offer. A calculator that quietly stops calculating
is worse than one that stops, because the fight goes on looking decided.

**The to-hit number is the engine's.** `chance_to_hit` asks `resolve_to_hit`
(or `resolve_mental_to_hit` for a mental attack) **once per 3d6 total, 3
through 18**, and weights each answer by how often that total comes up. Both
functions are public and pure — they read their inputs, spend no END, emit no
event and touch no session — and both settle hit-or-miss independently of the
dice handed in. So Combat Skill Levels, the No Range Modifier Advantage (6E1
p.346) and mental combat's range-free roll (6E1 p.105) are the engine's
answers. The seat hands over the *distance*; what it costs is not its to say.

**What it calls in the engine:** `resolve_to_hit` / `resolve_mental_to_hit`,
`compute_defense` (which also knows Armor Piercing, defense items and AVAD),
`compute_damage`, `killing_damage` / `normal_damage` with the neutral body —
the same four damage primitives `kirby_combat.critique` asks, for the same
reason — and `PhaseSituation.ordered_menu` so scenery sorts last.

**The probabilities are counted, not typed.** `chance_of_rolling(n)` is
P(3d6 <= n) enumerated off the die: 0.5 at 10, because half of 216 outcomes are
10 or under. That distribution is the only thing here the engine does not own.
The killing ½d6 STUN multiplier is rolled through the engine the same way, once
per face.

**Ties and futility.** Equal values keep the first offer in `ordered_menu` — no
die is rolled to separate them — and a menu where every attack prices at zero
raises `NoChoice` rather than returning one: `critique` would file every one of
those picks as futile.

### What the port changed

| | |
|---|---|
| **dropped** | the 17-entry 3d6 table — **derived** from the die instead (api `test_sim_ai.py::test_hit_probability_known_values`) |
| **dropped** | the to-hit target number itself. The api computed `ocv + 11 - dcv` and so did this module's first draft, which omitted CSLs, charged a No Range Modifier power the full range penalty and charged mental attacks a penalty they never pay. `resolve_to_hit` / `resolve_mental_to_hit` answer all three (`::test_expected_damage_no_abort_basic` covered the api's version) |
| **dropped** | `_avg_damage`'s 3.5-a-die and its 1.83 killing multiplier, and `_defense_for`'s pick-a-pool-by-type — the engine's resolver answers both (`::test_expected_damage_no_abort_basic`, `::test_md_reduces_mental_blast_damage`) |
| **dropped** | the `SPD_PHASES` chart — `kirby_combat.tables.segments_for_spd` (`::test_abort_at_segment_12_wraps_to_next_turn`) |
| **dropped** | `DODGE_DCV_BONUS` / `SET_OCV_BONUS` — `Dodge.dcv_bonus` and `actions/set_action.py` (`::test_expected_damage_with_dodge_lower_than_no_abort`) |
| **dropped** | `CoverFeature` / `cover_at` / `cover_ocv_penalty` — `scene/cover.py` computes cover from the real map and `cover_ocv_modifier` prices it (`::test_cover_at_picks_best_within_adjacency`, `::test_cover_at_returns_empty_when_no_adjacent_cover`, `::test_cover_ocv_penalty_thresholds`) |
| **dropped** | `distance` / `step_toward` / `step_away` — `scene/placement.move_toward` and `scene/geometry.py`, which also know what is in the way (`::test_distance_3d`, `::test_step_toward_clamps_at_destination`, `::test_step_away_moves_directly_away`) |
| **dropped** | `int_roll` and its MASTERFUL/SOLID/MUDDLED/BAD-CALL tiers — a `Chooser` rolls no dice, and the roll target is the engine's (`hero_view`'s skill roll target, `kirby_cost.engine.rolls`). Same call `IntentChooser` made about the notice roll (`::test_int_roll_target_calculation`, `::test_int_roll_tier_thresholds`) |
| **dropped** | `maybe_set`'s 0.30 hit-probability threshold — an invented number with no page behind it, and a Set is an offer on the menu like any other (`::test_maybe_set_triggers_at_low_hit_probability`, `::test_maybe_set_skipped_when_hit_chance_already_high`, `::test_maybe_set_skipped_if_already_setting`) |
| **dropped** | the whole **abort seat** — `pick_best_defense`, `maybe_abort_to_defense`, `AbortDecision`, the owned-martial-maneuver reading, and with it Block's full negation on a successful block. An abort is reactive and the `Chooser` protocol decides a Phase; there is nowhere to seat it yet (see the gap below). Covered by `::test_pick_best_defense_*`, `::test_abort_*`, `::test_expected_damage_with_block_full_negation_on_success`, `::test_dodge_does_nothing_against_mental_attack`, `::test_block_returns_inf_against_mental_attack` |
| **dropped** | `_DISABLING_THREAT_STUN` — hand-set "threat-equivalent STUN" for Entangle, Drain, Flash, Mind Control and the rest, with no page behind any of them. Nothing here invents a number, so **a 0-STUN disabling attack prices at 0 and this seat will never pick one**. That is a real behaviour change and the gap below is where it belongs (`::test_is_disabling_attack_recognises_common_xmlids`, `::test_disabling_threat_stun_values`, `::test_expected_damage_for_entangle_returns_threat_stun_not_zero`, `::test_pick_best_defense_aborts_against_entangle`, `::test_flashdef_reduces_flash_threat`) |
| **dropped** | the six-xmlid "is this mental" list — the engine puts `damage_type` / `defense_type` on the power (`::test_pick_best_defense_returns_none_for_mental_attack`) |
| **added** | the **distance**. The api's EV took an `ocv_modifier` from its caller and never measured; the Phase knows how far everyone is, so `distances_m` is handed to `resolve_to_hit`, which decides what it costs. A shot priced without it is priced at point blank from across the street. Pinned by `test_the_engines_range_rule_is_honoured` |

### Engine gaps this found

* **No probability primitive.** kirby-combat resolves a roll, but nothing there
  can say what a roll is *worth* before it is made, and nothing can say what an
  attack is expected to do — `critique` prices only the luckiest roll.
  `chance_to_hit` and `expected_stun` are priced-before-rolled and belong with
  the rules; asking the engine 16 times per offer is what a consumer has to do
  without them.
* **No mental / physical to-hit dispatch.** `actions/base.py` calls
  `resolve_to_hit`; nothing routes a mental power to `resolve_mental_to_hit`.
  So a consumer picks the door off `damage_type` / `defense_type`, which is a
  rule-shaped question it should not be answering.
* **No public tactic-matching door.** `TacticChooser.choose` is the only place
  that turns a `Plan` into an offer on the menu; anything else that wants the
  same answer copies it, as `TacticsDoctrine._offer_for` does (pinned by a
  parity test). The engine has corrected that logic twice already.
* **No public "who does this action_id mean" door.** `critique._target_of` is
  private, so `EVChooser._target` is a second copy that will not follow the
  engine when it widens the lookup.
* **Two positional tactics the catalogue lacks.** kirby-api seeded
  `lure_to_cover` ("if cover is within ½ Move, take it before attacking") and
  `separate_grouped` ("when enemies cluster, scatter them with high-knockback
  attacks") as rows, with no code behind them. Neither has an engine
  counterpart; both are advice about ground the engine already models
  (`scene/cover.py`, knockback), and both belong in `kirby_combat/tactics/
  catalog/` if they are wanted at all — never here.
* **No threat equivalence for status attacks.** An Entangle, a Flash or a Mind
  Control does 0 STUN and ends fights. Until the engine can price one, any
  expected-value seat will ignore them.
* **No reactive seat.** Aborting to a Dodge or a Block is a decision, made
  between Phases, and the `Chooser` protocol has no room for it. The api's
  defender-side EV picker has nowhere to go until there is one.

## Narrating a fight

`Narrator` speaks one line in a fighter's voice over the events of a Phase —
or says nothing, which is most Phases. The events are the engine's own; the
policy is kirby-api's, ported.

```python
from kirby_ai import Narrator

narrator = Narrator(client, seat="combat-narrate", significance=0.5)
line = narrator.narrate(events, situation)   # str | None
```

**Nothing here stores anything.** `narrate` returns the text and the harness
writes it — in kirby-api, to its own `combat_narration` table keyed by
`(session_id, sequence)`. There is no narration event: the engine's
`CombatEvent` union is closed and narration is not a state change.

**One `Narrator` per fight.** The throttle (one line per fighter per Turn) is
state on the narrator. A harness that builds a fresh `Narrator` per request
loses that history and every significant moment speaks again; keep one per
session for the fight's life, as with `IntentChooser`. Also note the `PASS`
door is ported verbatim: a reply that is literally "Pass!" is read as the
character choosing silence.

**The gate is pure and runs first.** Each event is priced on a 0–1 dial and the
Phase is silent unless something clears `significance`. The moments, most
dramatic first: the fight ending (1.0), a man going down (0.9), a man left at
death's door (0.8), a barrier destroyed (0.7), the scene turning on somebody
(0.6), a man visibly badly hurt (0.6), a barrier going up (0.4). A wound is
also priced as the fraction of the man it was taken from, so twelve STUN off a
thug is a moment and twelve off a brick is not. **None of these numbers is a
rule** — no page of 6E says when a man speaks — so they are policy here, and
`significance` overrides all of them at once.

Vitals are read from `VitalsChanged`, which is where they now are: the engine's
A7 work made the change itself an event, so a fight replayed from its log is
narrated the same way as the fight that ran. Where the man stands *after* the
blow comes from `classify_health`, the engine's one door, rather than from a
before/after pair this package snapshotted.

**One line per man per turn**, kept on the narrator rather than queried back
out of rows, with the api's single exception: the fight-ending line is never
eaten because the winner spoke earlier in the turn.

**`None` is a decision, never a failure.** It means exactly three things: below
significance, already spoke this turn, or the character chose silence (the
seat's `PASS`). A seat that errors, or answers with nothing at all, **raises** —
this returns no placeholder text.

### What the port changed

| | |
|---|---|
| **dropped** | the `combat_event` row it wrote, `EventRepository`, the sequence bump and the savepoint around it, and the `SOLILOQUY_EVENT_TYPE` lane on the log. The harness stores the text (`test_soliloquy.py::test_maybe_narrate_emits_event_row`, `::test_rewind_survives_interleaved_soliloquy_rows`) |
| **dropped** | the throttle's **query** — `_turn_boundary_seq` walking `SegmentAdvanced` rows backwards for the last `from_segment == 12`, and `_spoke_this_turn` over soliloquy rows. `PhaseSituation.turn` is the boundary and the narrator remembers who spoke (`::test_throttle_one_soliloquy_per_actor_per_turn`) |
| **dropped** | the five **before/after snapshot arguments** the driver took around `_resolve` (`combat_started_before`, `target_stun_before`, `target_health_before/after`, `construct_effects_fired`) — every one of them existed because the state change was not in the log. It is now (`::test_ko_when_stun_crosses_zero`, `::test_low_health_crossing_when_class_worsens`) |
| **dropped** | `first_blood`. It asks whether this fight has drawn blood before, which one Phase's events cannot answer and which no engine event records (`::test_first_blood_on_first_damaging_hit`, `::test_no_first_blood_when_combat_already_started`, `::test_no_first_blood_without_damage`) |
| **dropped** | `drowning_start` and `drowning_already_narrated` — the api's per-segment construct effects with `outcome == "suffocating"`, and the once-per-dunk memory read off its own rows. The engine's nearest event is `EnvironmentalTriggered`, which is scored as a hazard without knowing that drowning is one long moment (`::test_drowning_start_first_suffocating_tick`, `::test_no_drowning_when_already_suffocating`, `::test_no_drowning_without_suffocating_outcome`) |
| **dropped** | the three payload shapes `_construct_destroyed` had to know — top-level, nested under `construct_damage`, and the AoE list under `construct_hits`. `ConstructDamaged.destroyed` is one field (`::test_construct_destroyed_top_level`, `::test_construct_destroyed_nested_in_construct_damage`, `::test_construct_destroyed_in_construct_hits_list`) |
| **dropped** | `victory_after` as a caller's flag, and with it the driver's `_actor_won` side arithmetic. `SessionEnded` is in the log (`::test_victory_trigger_when_victory_after_true`, `::test_actor_won_loser_acting_and_mutual_ko`, `::test_driver_step_victory_narrated_by_winner`) |
| **dropped** | `OllamaConfig`, the bearer token as an argument and the `settings.kirby_medialib_api_url` read — the client is constructed by the caller, which is why this package is testable without a web application (`::test_maybe_narrate_no_bearer_skips_the_seam`) |
| **dropped** | `profile_name` — the api's `character_tactic_profile` row in the voice context. The complications stay (`::test_driver_step_wall_destruction_emits_soliloquy`) |
| **reversed** | **failure is raised, not swallowed.** kirby-api wrapped every touch and returned `None` on any exception, because narration was decoration the combat loop must not care about. A harness that STORES the line cannot tell "he said nothing" from "medialib is down" that way (`::test_maybe_narrate_seam_failure_never_raises`) |
| **kept** | the system prompt and the `PASS` door verbatim, the priority order of the moments, and the one-line-per-man-per-turn throttle with its victory exemption |
| **rungs** | health is `classify_health` and the sheet is `complications_of` — the engine's doors, where the api had its own `_classify_health` snapshots and a `complications_for(db, character_id)` query |

### Engine gaps this found

* **A wound has no "worse than before" in the log.** `VitalsChanged` carries the
  delta and `classify_health` reads where a man stands now, but nothing says he
  crossed a rung *on this blow* — the api knew because it snapshotted the class
  either side of `_resolve`. A consumer that wants the crossing re-derives it by
  keeping its own before-picture, which is the thing the event was meant to end.
* **Drowning is not a state.** `EnvironmentalTriggered` fires every segment a
  man is under water and carries a free-form `effect` dict; there is no event
  for *starting* to suffocate and no status for being in it, so "he just began
  to drown" cannot be told from "he is still drowning" without a consumer's own
  memory.
* **No first blow.** Nothing records that a fight has gone live. `SessionStarted`
  is setup and the first damaging `VitalsChanged` is only recognisable as the
  first if you have read the whole log.

## Layering

| layer | owns |
|---|---|
| kirby-medialib | providers, credentials, execution, and the seat rows themselves — shared with ProductBinder |
| **kirby-ai** | the client, parsing, provenance |
| kirby-api | HTTP serving, persistence. Names a seat, never a model. |
| kirby-combat | rules. Knows nothing about any of this. |

`combat-pick` and `campaign-prose` are still Kirby's vocabulary — they are
just rows scoped to the `kirby` tenant in medialib now, not a file this
package loads.

## What it owns, and what it does not

Rules and rows belong to their own layers, and nothing here reads either.

**Prompts live here**, which this file used to deny. The claim was that "a
prompt is domain knowledge — callers hand this package finished strings", and
there is no caller that can. kirby-combat forbids the word: `prompt` is in the
banned-term list its vocabulary guard fails the build on, alongside `llm` and
`ollama`, because the engine models *choosing* and stays neutral about what
implements a choice — which is what lets a fight run with no dependencies at
all. And the only callers that would hand the strings over are the scripts in
this repository.

So a prompt for the `combat-pick` seat has nowhere else legal to be, and no
consumer outside this package: the seat is only ever sent by the client here.
The test is the same one that keeps the deliberation harness here rather than
in a package of its own — **if nothing outside kirby-ai can ever use it, it
belongs to kirby-ai.** A charter the code has broken since before it was
written, in the one place the code has no alternative, is a charter that is
wrong.

What that costs is real and worth stating: someone tuning a combat prompt is
editing a transport package, and a HERO rules change can want a prompt change
in a repo that knows nothing about HERO. The seat names are the seam that
keeps this survivable — `combat-pick`, `combat-review`, `combat-atmosphere`
say who a prompt is for without this package having to understand it.

## Dependencies

`httpx`, and nothing else. `tests/test_leaf.py` enforces that, and enforces that
nothing here imports `kirby.settings` — the base URL and bearer token are passed
in, which is what makes the package usable from a script and testable without a
web application.
