lernie 0.1.39

lernie: the operator seat — the window and wire client for a yog server
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
# lernie

**The seat.** The operator's face on a [yog](https://github.com/mudbungie/yog)
server.

> ## ⚠ The name has two eras, and the version is the fence
>
> **`lernie` through 0.0.x was the agent-loop engine.** That program did not
> retire — it was renamed, and it continues as
> **[`litany`]https://github.com/mudbungie/litany**. If you are upgrading
> from `lernie` 0.0.x, `litany` is what you want, and its README carries the
> migration: `LERNIE_HOME` becomes `LITANY_HOME`, the XDG harness roots move
> from `.../lernie` to `.../litany`, and the in-workspace mark namespace moves
> from `refs/lernie/*` to `refs/litany/*`.
>
> **`lernie` 0.1.0 and above is this crate**: the seat, severed from yog by the
> four-component split adopted 2026-08-28 (yog's `docs/REMOTE.md` §12).
>
> **The version is the only rule that separates them.** A published record
> cannot be corrected in place, so both READMEs state the fence and both name
> the other era. Read every `lernie` you meet against it: a bare one names the
> seat, and one bound to a `0.0.x` version names the engine at that release.

A seat holds an operator-issued certificate for the machine it runs on and dials
in to an engine somewhere else. It asks the boundary's queries, dispatches its
actions, and paints what comes back.

It holds **no world**, runs **no agent** and executes **nothing**. Every durable
fact of a workspace is the server's; every execution is a foot's. A seat is the
part you look at.

## Why it is a separate program

lernie is one of four components that meet only at the wire — the server
(**yog**), the seat (**lernie**), the agent-loop engine (**litany**, beneath the
server) and the tool-execution foot (**thrall**).

The seat was severed so the machine holding the conversations is not the machine
an operator sits at. A phone, a laptop and a desk can all be seats on one world
without any of them holding it; agents run on the server, in the background,
independent of any seat being attached.

The extraction moved code, not architecture: yog's window had already been a
pure wire client of localhost — real socket, real handshake, real certificate,
everything through the front door.

## Status

**A working seat client, and no window yet.**

```
lernie workspaces                       # every workspace an engine holds
lernie conversations <workspace>        # one workspace's conversations
lernie transcript <workspace> <agent>   # one conversation, entries and tail
lernie follow <workspace> <agent>       # hold the line on the live tail
lernie message <workspace> <agent> <content>
lernie interrupt <workspace> <agent> <content>   # cut it off and say this instead
lernie nudge <workspace> <agent>
lernie stop <workspace> <agent> [children]       # kill the driver held on it;
                                                 # `children` takes the subtree too
lernie retarget <workspace> <agent>              # settle it onto its lineage's head
lernie delete-agent <workspace> <agent> <typed>  # empty <typed>; its name takes the children
lernie delete-workspace <workspace> <typed>      # <typed> must be the workspace's own name

lernie start <workspace> <goal> [<dir>]  # begin a conversation — two acts, one word;
                                         # a directory aims the driver there

lernie                  # open the window
lernie --json <verb>…   # the reply frames as they crossed, instead of rendered
lernie entries          # every channel this box holds, without dialling any
lernie ask <envelope>   # the same gestures, written out as JSON
lernie help [<verb>]    # what a verb takes — answered with no engine up
```

A verb opens the channel its workspace names — this box's own engine, or one of
the workspaces it participates in elsewhere — over a real mTLS handshake with a
real version preface, carries the envelope across, and **prints the reply
rendered**: the rows of a listing, the entries of a transcript, the sentence a
receipt is. It exits 0 when the last reply says ok.

The boundary stays JSON and `--json` prints it byte for byte, one envelope a
line, which is what a script wants (`docs/DESIGN.md` §4.37). The rendering is
designed once per reply **kind** rather than per verb, because several verbs
answer with one kind and four spellings of one rendering would disagree within a
week. The flag goes before the word: everything after the word is the gesture,
verbatim, including a message whose text begins with a dash.

The verbs are a **serialization** of the envelope, never a second spelling of a
gesture: each builds the object `ask` would have taken and hands it to the same
router. `ask` is the escape hatch for every op the table does not name —
including one this build has never heard of, which the protocol says is not a
version bump.

`start` is a serialization of **two** gestures rather than one, and the only
one: starting is two acts — a `prepare` that stages it and answers the fire's
parameters, then a `prompt` that hands that body straight back with the goal —
so the thing between them is a local, and one word is what holds it. Both reply
streams print; the exit code is the fire's. Name a **directory** after the goal
and the start is staged on the path rung instead of the bare one: the
conversation's driver runs there, and the engine's own target preamble is fired
ahead of the goal. A directory named in the goal's prose is a request the
agent's tools are free to ignore; the rung is not.

**Bare `lernie` opens the window**: the roster grouped by channel, the
conversation list, the chat pane and the composer, painted from a snapshot and
firing gestures through the same doors the command line spends. The composer
speaks to the conversation that is selected and **begins one** where none is,
holding the staged body between the start's two acts — so the window can start a
conversation and not only continue one. Behind it are three threads — the asker
over the standing question set, the poster
draining what a click composed, and the follow lane holding one connection open
on the focused conversation.

**Everything it does is reachable from the keyboard.** Most of that is egui's —
Tab moves focus between the controls and Space fires the focused one — and what
Tab cannot make *usable* is a list, so the arrows walk the roster and the
conversation list, left and right say which of the two they belong to, and
Escape puts a notice down. Moving in a list **selects**, so the cursor and the
selection are one thing and the highlight the pointer paints is where the
keyboard is. Every binding calls the same door the click beneath it calls.

**The frame never dials.** Every read and every act happens off it, and the
frame's whole side is one `settle` at the top of an update: file what landed,
hand over what was composed, publish what to ask next. What to ask is derived
from the window's own state rather than stored, so a click changes the model and
the next question follows from it.

What it paints *from* is the typed reply vocabulary (`src/reply/`,
`docs/DESIGN.md` §4.9), reimplemented off yog's REMOTE rather than shared
through a crate, decoding only what a window renders — eight kinds today. It is
judged by **yog's own generated conformance corpus**, vendored under `corpus/`
by `scripts/refresh-corpus.sh` and replayed both directions, with
`corpus/unreadable/` standing as the ledger of what is not painted yet.

Every assertion about the window reads the **glyphs that reached the glass**
(`src/paint_probe.rs`, `rules/no-hand-rolled-paint-walk.yml`). A galley reports
the string that went in, so a label the toolkit elided to `…` reads back whole
and every assertion against it is blind to truncation. Upstream found that three
times before it became a rule; it arrived here as a rule.

What it reads, all of it put there by the operator's hand and none of it ever
written by lernie:

```
<data root>/wire/                     this box's own channel
<data root>/wire/workspaces/<leaf>/   one channel per workspace held elsewhere
```

where the data root is `$XDG_DATA_HOME/lernie` or `$HOME/.local/share/lernie`.
Each directory holds `ca.pem`, `client.pem`, `client.key` and `address`, plus an
optional `workspace` file naming what that workspace is called on its host.
Certificates arrive out of channel, by the operator's hand; **lernie mints
nothing**, and there is no bootstrap flow and must never be one.

**0.1.0 is published**, and it is the version that fixes the fence in the public
record — the coordinated cutover moment for the whole four-component split.
`cargo publish` is irreversible, so what shipped at 0.1.0 shipped: this
paragraph still claimed the crate carried `publish = false` when that version
went out, and no edit can reach the copy on the registry. See *Releases* below
for the path a version takes now, and `AGENTS.md`'s *Before a publish* for the
half of it no workflow can hold.

## Build

`make` is the build authority. `make check` is the whole gate and nothing runs a
step it does not:

```
make check     # fmt-check -> lint -> coverage
make build     # debug build
make test      # cargo test
make install   # release build, then the binary and its icon seats into ~/.local
make icon      # re-emit assets/lernie.svg from the generator in src/mark.rs
```

`install` lays down a desktop entry and a scalable icon beside the binary, and
it is not decoration: a Wayland compositor has no protocol for a client to set
its own window icon, so it matches the window's application id against the
installed entry and reads the mark off that. Without the seats the window wears
whatever the desktop invents for an unnamed client.

The entry it installs names the binary by absolute path, resolved at seat time
— a desktop environment reads `Exec=` out of the session's environment, not a
login shell's, so a bare name launches nothing wherever `~/.local/bin` reached
`PATH` through a shell profile. `make icon-seats` refuses rather than seat an
entry whose `Exec` resolves nowhere; the tracked asset stays generic.

`make lint` is `line-cap`, then `leak-scan`, then `cargo clippy --all-targets --
-D warnings`, then `rules-audit`, then `cargo deny check`.

### Looking at the window without a compositor

```
make snapshots  # render the seat off-screen; PNGs into target/snapshots/
```

Wayland has no protocol for capturing another client's window, so for a long
time nobody working on this seat by agent could SEE it — the suite could say
which words reached the glass and nothing could say what it looked like.
`src/snapshot` closes that: it runs the real `ui::render` on an off-screen
context and rasterizes the frame, touching no compositor and opening no window.

**No compositor is not no renderer**, and that distinction costs a box that has
never been told it. `egui_kittest` asks wgpu to enumerate adapters and takes the
first; where there is none it panics `No adapter found`, and these two tests are
the only ones in the suite that can fail that way. A desktop with a working GPU
driver has one already. A headless box — a container, or a CI runner — usually
has neither a GPU nor a Vulkan ICD, and the fix is to give it a software one
rather than to stand the tests down: `mesa-vulkan-drivers` (Mesa's lavapipe,
which rasterizes on the CPU) plus the `libvulkan1` loader that finds it. That is
the line `.github/workflows/ci.yml` installs before the gate, and it is enough
because nothing here compares pixels to a pinned image — see below.

It writes one PNG per (world, size) into **`target/snapshots/`**, named
`<world>--<size>.png` — four named world states (`unprovisioned`, `seated`,
`beginning`, `enrolling`) at three viewport sizes (`phone` 400x800, `narrow`
900x700, `desk` 1400x900). They are untracked on purpose: an image is a derivation, re-made by
every run of the suite, and the disclosure gate refuses every tracked binary.

**Nothing compares those images to anything.** A pinned golden image reddens on
every font and layout tweak and gets rebaselined without being looked at, which
is a gate that has stopped reading. The PNGs are for eyes. What gates is three
properties that hold whatever the pixels are:

- the seat's one covered pane is **one gesture from the main screen** and one
  gesture back, at every size — asked of the accessibility tree, so the subject
  is what an operator can act on rather than what was laid out;
- where the layout claimed content, **the glass is not blank** — every leaf
  carrying text is read off the rendered image, and one whose every pixel is
  identical is a word that did not arrive;
- **no control is laid out wholly off the window**, and none is offered without
  a rectangle to aim at.

A fourth reads the same tree for a different question: **interface parity**
(yog's `docs/PARITY.md`, DESIGN §4.16). Every control that fires a boundary op
carries the machine token `act:<op>` on its accessibility node — the visible
label stays a human word — and the gate holds that every op yog's help table
classes a `control` either carries such a tag here or has a line in
**`parity.toml`** citing the ball that will build its surface. The roster is
the `surface` field on the vendored corpus's `reply/help` rows, so what this
seat owes is upstream's fact and not a list kept here; the ledger is printed in
full on every run, and a line goes red when the op is surfaced after all or
when upstream stops classing it a control.

The last two are geometry, and they are judged at the widths this seat's own
layout policy still promises a shape (`ui::shell::widths` — the width at which
the conversation pane still gets its floor). Narrower than that the layout says
in its own words that it has run out of answers, so the frame is rendered and
photographed but not judged. The first is not geometry and holds everywhere.

Every tool is pinned, or the gate is not reproducible: rustc 1.95.0
(`rust-toolchain.toml`), ast-grep 0.44.1 (`sgconfig.yml`), cargo-deny 0.20.2
(`deny.toml`), cargo-tarpaulin 0.35.2 (`tarpaulin.toml`).

`.github/workflows/ci.yml` runs `make ci` — the same target, the same pins — on
every push to `main` and every pull request, so the gate is not a thing somebody
remembered to run. On a push it runs as a job of `release-plz.yml` rather than
on its own trigger, because a release has to be gated by a CALLED workflow (see
below) and running it twice per push would buy nothing. `store-scan.yml` scans
the published task-store ref with the same disclosure scanner, daily and on
dispatch: the local gate prevents and is bypassable, that one detects and is
not.

Run `make install-hooks` once per clone to seat the pre-commit hook.

## Releases

`.github/workflows/release-plz.yml` is the release path. A push to `main` runs
the gate, keeps one version-bump pull request fresh, and publishes any manifest
version crates.io does not already serve — tagged `v<version>`, with a GitHub
Release beside it. `release-plz.toml` holds the four policy decisions it reads
and the reason for each.

**A protocol bump waits for the engine** (bl-52b5). yog mints the wire protocol
version and this crate *vendors* a copy of the constant; the wire is
fail-closed on a mismatch and does not negotiate (yog `docs/REMOTE.md` §3). So
`merge-release-pr` **holds every release while this repository's `PROTOCOL`
exceeds the newest published yog's** — thrall took that road first, shipping 16
while the published engine spoke 15, which composes with nothing. Strictly
greater, not different: a seat *behind* the engine is the mirror-image defect
and its release is the fix. Between the two gates — yog holds a bump until the
consumers' mains carry it, each consumer holds a release until yog has
published it — the ordering a bump requires is: **the consumers' mains first,
then yog publishes, then the consumers publish.** The decision is
`scripts/protocol-gate.sh`, proved both ways by `make protocol-gate`.

Publishing is by **trusted publishing**: crates.io records that this one
workflow file in this one repository may publish this one crate, and GitHub
mints a short-lived signed token asserting exactly that at run time. There is no
registry credential in this repository and there should never be one. The
workflow's filename is matched literally against that claim, so renaming it
breaks publishing until the registry entry is updated.

What no workflow can promise is the other half: `Cargo.toml` declares an
`include` ALLOWLIST rather than an exclude list, and `tests/packaged_files.rs`
holds it over the real `cargo package --list` in both directions — but a guard
judges file classes and never content. That half is `AGENTS.md`'s *Before a
publish*, run by a person.

**On a mac, the artifact is the crate:**

    cargo install lernie --locked

No release carries a binary, for any platform, and that is a ruling rather
than a gap (`docs/DESIGN.md` §6.3). The seat's unit of install is the published
version — the Linux seat box below installs the same way, on a timer — and a
downloaded mac binary would be the worse product: the linker's signature is
ad-hoc, not notarization, so a copy that arrives over a network carries a
quarantine attribute the mac refuses to start it under until somebody clears
it by hand, and clearing that for everyone means a credential this repository
does not hold. A binary compiled on the box carries no such attribute and
runs. What you get is a window launched from a terminal — no `.app` bundle
and no dock mark; that is a separate question.

**There is no container image either** (`docs/DESIGN.md` §6.4). The other three
components ship one because an image is the unit of install for a box that
takes images; a seat's box is a desktop, and every layer a seat image could
carry — the GL stack, the display client libraries, the fonts — is something
that box has by being one, while the display socket, the GPU and the wire
material would all have to be mounted back in from it. The crate is the
artifact on every platform, and the seat box below installs it that way.

What the release workflow does on a mac is PROVE that install: a job on an
Apple runner checks out the released tag, builds it natively, and READS the
result rather than trusting it — `scripts/mac-verify.sh` reports its
architecture, its filetype, the OS it targets, every dynamic library it will ask
macOS for, and whether it is signed at all. It cannot be cross-built from Linux:
the window links Apple frameworks, the frameworks ship only in Apple's SDK, and
the SDK agreement refuses both hosting that SDK and running any part of it on
non-Apple hardware. Nothing here acquires one.

## Continuous deployment for a seat box

A seat box tracks released versions unattended:

    make deploy-seat                    # THIS box
    make deploy-seat HOST=<ssh-host>    # that one

**With no HOST it seats the box you are on, and that is the common case rather
than a fallback** (bl-bae7). A seat is a *window*, the box most likely to run a
window is the workstation somebody is sitting at, and that is the box least
likely to be running an sshd — it cannot ssh to itself. The remote form was the
only door until bl-bae7, so the seat most likely to exist was the one that could
not arm its own timer, and its lernie was whatever the last hand install left.

**Two carriers, one payload.** The three files and the four arming commands are
spelled once in `scripts/deploy/seat.sh` and do not know which way they arrived;
only its `put` and `run` differ. A local seating is therefore not a second
recipe that can drift from the remote one — the same idiom yog's
`scripts/deploy/verify.sh` states for its own `--local`.

`HOST`, when given, is an ssh destination and the only parameter — no address,
account or machine name is committed anywhere in this tree. That is the
disclosure gate's rule, and the severability one from the other side: a second
seat is a second argument rather than an edit, and a box that should stop
tracking releases is one `systemctl --user disable lernie-update.timer` away
from stopping, with nothing to change here.

It seats three text files — `scripts/deploy/lernie-update` and its `.timer` and
`.service` — and carries no build. The engine's deployment moves an image
because an image is its unit of install; a seat's unit of install is a
published version, and the registry already serves it. From then on the box
reads the crates.io sparse index hourly, compares the newest **live** version
against what its own binary reports, and on a difference runs `cargo install
lernie --root ~/.local --locked --version <v> --force`. It installs to the same
root `make install` uses, because the desktop entry that launches the window
names an absolute path and an install anywhere else would update a binary the
launcher never runs.

**Nothing is restarted, because there is nothing to restart.** A seat is a
window somebody launched. An install replaces the binary by rename, so an open
window finishes its session on the build it started under and the next launch
is the new one.

**A yank is the rollback lever.** Yanked releases are filtered here rather than
left to cargo, so yanking a bad release makes the previous one newest-live; the
next tick sees it differ from what is installed and puts it back, on every
seat, with nobody logging in. That is what the explicit `--version` and
`--force` are for — cargo will not move backwards without them.

**A seat may run ahead of its engine, and the refusal is designed.** Engine
boxes reconcile on their own hourly schedule, so a seat that updates first may
speak a newer `PROTOCOL` and be refused at the hello — fail-closed, in band,
naming both versions (yog's `docs/REMOTE.md` §9.5). Wait; the skew closes
itself within the hour. `scripts/deploy/lernie-update`'s header says why there
is deliberately no downgrade, no capability probe and no compat shim.

`make deploy-selftest` is the regression half and a step of `make lint`: it
drives the real reconciler under fake `curl` and `cargo` shims in a scratch
`HOME` — no network, no registry, no toolchain, no machine touched — asserting
in one direction that an install happened with exactly which arguments, and in
the other that `cargo` was never invoked at all.

## The rules

Two are hard and machine-enforced:

- **300 lines** on every source file, inline tests included. Docs and config are
  exempt. `make line-cap` is the one definition of both. 300 is a wall, not a
  target; `make line-cap LINE_CAP=199` lists the pre-split band.
- **100% test coverage.** If it can't be tested, it mustn't be built.

A third is hard and machine-enforced but is not about the code:

- **Nothing discloses.** `make leak-scan` reads the index this commit would
  publish — not the worktree — for credentials, routable addresses, home paths,
  pasted dialogue, session artifacts and content no rule can read.
  `scripts/leak-rules.sh` is the one definition of what counts, and
  `--self-test` proves every rule still bites in both directions before the tree
  is scanned at all. `.githooks/commit-msg` runs it over the commit message,
  which no pre-commit step can see. It scans one tree and promises nothing about
  anything already published — that half is filed, not built.

Beyond those, lernie follows the house **contained Rust** standard from birth:
complexity lives in function bodies, where the compiler catches it, not in type
signatures, where it is viral. No named lifetimes; a `pub fn` returns an owned
concrete type; no generic bounds on a `pub` item; no panic paths outside tests;
no `#[allow]` in prod — policy lives in `Cargo.toml [lints]`, justified in one
place; no `Rc`/`RefCell`. Three more rules **confine** a kind of code to one
file: `unsafe` to `src/sys.rs`, `Mutex`/`RwLock` to `src/state.rs`, and both
building and forking a child process to `src/test_support/mint.rs`. The first
two files do not exist, and that is the discipline working — a confinement rule
names its location before the first site is written, or the first site picks the
location by being written. The third names test scaffolding because the seat
forks nothing in production, and the crate's one child is the suite performing
the operator's own out-of-channel certificate mint.

The `rules/` directory enforces what it can, and `make rules-audit` checks both
directions: the tree clean **and** every rule, run alone by its own id, still
flagging its deliberate violation in `rules/fixtures`. Per rule rather than per
directory, because nine live rules would answer for a tenth dead one forever —
and because the confinement rules have little or nothing in `src` to match, so
their fixture is the only thing proving they work at all.

## Authorities

- **`docs/DESIGN.md`** — lernie's own architecture: the fence, the role, the
  inherited invariants, the module map, and what is deferred.
- **yog's `docs/REMOTE.md`** — the **protocol authority**. It is versioned, and
  every component implements against it. Where this crate and that document
  disagree, one of them is a bug; never invent a third answer.

Tasks are tracked with `bl`. Run `bl --skill` before using it.

## License

MIT.