lernie 0.1.70

lernie: the operator seat — the window and wire client for a yog server
docs.rs failed to build lernie-0.1.70
Please check the build logs for more information.
See Builds for ideas on how to fix a failed build, or Metadata for how to configure docs.rs builds.
If you believe this is docs.rs' fault, open an issue.
Visit the last successful build: lernie-0.1.60

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> [children]       # kill the driver held on it;
                                                 # `children` takes the subtree too
lernie retarget <workspace> <agent>              # settle it onto its lineage's head
lernie workflow <workspace> <agent> <config>     # read workflow.yaml from that lineage's head
lernie clear-workflow <workspace> <agent>        # drop that mark; the tip's governs again
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 prices                                    # every engine's price table, bound and spend
lernie price <provider> <model> <rates…> | off [--on <channel>]   # one engine's row
lernie ceiling <usd> | off [--on <channel>]      # one engine's bound; `--on` names which
                                                 # where this box holds more than one

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: two columns. On the left an accordion of the engines this seat reaches, the open one's workspaces under it and the aimed workspace's conversations under their own row, with a + on each engine that begins a conversation on it; on the right the chat pane with the composer under it. Both are painted from a snapshot and fire 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 one list the window has — engine rows, the open engine's workspaces, and the aimed one's conversations, in the order the glass paints them — and Escape puts a notice down. Enter or Space on an engine's row opens it; left and right step between the columns where the window is too narrow to stand them side by side. 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/unpainted/ 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 — and, for an engine that roves behind a NAT with no dialable listener, the pair rendezvous.pub and pairing.salt, with which a dial finds the engine over the mainline DHT and punches a connection to it (docs/DESIGN.md §4.40; yog's docs/REMOTE.md §13). An entry without the pair is dialled at its address and nowhere else. 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 number in a repo-root PROTOCOL file — one line, compiled into the constant by build.rs (bl-55b1), at the one address a module split cannot move, which is what both gates read; 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. A hold clears when the engine publishes, which is an event this repository never sees, so merge-release-pr runs on a workflow_dispatch of Release-plz as well as on a push (bl-614a): that dispatch is the door for re-judging a held release, and without it the release waited for an unrelated landing on main.

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.
  • docs/STYLE.md — what the window looks like: the phone seat's visual language adopted verbatim, the desktop's information architecture, and its deltas. Normative for every pane; src/ui/theme.rs is its one module.
  • 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.