lernie 0.1.19

lernie: the operator seat — the window and wire client for a yog server
Documentation

lernie

The seat. The operator's face on a 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. 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>                  # kill the driver held on it
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>         # begin a conversation — two acts, one word

lernie                  # open the window
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 each reply frame on its own line. It exits 0 when the last reply says ok.

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.

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.

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.

The macOS artifact is built natively on an Apple runner in the same workflow and then READ rather than trusted — 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. The signature the linker applies is ad-hoc and is not notarization — a downloaded copy carries a quarantine attribute that only somebody on a mac can clear.

Continuous deployment for a seat box

A seat box tracks released versions unattended:

make deploy-seat HOST=<ssh-host>

HOST 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.