# yog
yog is the desktop manager for
[lernie](https://github.com/mudbungie/lernie) loops: an egui/eframe desktop
window that browses every lernie workspace, surfaces what needs attention, and
drives the loop lifecycle — start work from nothing, a path, or a
ball, message or stop agents, assign and close balls — over the lernie and
balls substrates it embeds.
It is its own package ([mudbungie/yog](https://github.com/mudbungie/yog)),
deliberately outside the lernie and balls workspaces: both ship as composable
components and yog composes on top of them. The batteries-included direction is
**landed**: balls, brazen and lernie are all linked crates, exact-pinned in
`Cargo.toml` — which is the pin authority, so no version is restated here or in
any doc. Ball reads run in-process, and every substrate spawn targets yog's own
executable under a verb namespace — `yog bl <verb…>`, `yog bz <args…>`, `yog
lernie <argv…>` — dispatched to the embedded crate exactly as each upstream's
own thin binary does (DESIGN §16.7). Nothing is installed alongside: there is no
host `bl`, `bz` or `lernie` in the chain, which is what `make drive-cleanroom`
proves by putting only `yog` and `git` on `PATH`. lernie, balls and brazen take
no dependency back.
## Architecture
**`docs/DESIGN.md` is the authority** — the state inventory, the attention
model, the write paths and the module map live there. It is
the *architecture* authority, not a generated mirror: when code and DESIGN
disagree, one of them is a bug, and the discipline is to fix the doc rather than
code around it (AGENTS.md). Two guards hold it to the tree: its `§`-citations
(`tests/design_citations.rs` proves every cited section resolves to a real
heading) and its module map (`tests/design_module_map.rs` proves every source
file has a row, every row names a live path, and every rule §12 states about
its own table). The 300-line cap is `make line-cap`'s, and lives nowhere else.
This section is only the map.
yog is a stateless renderer: every frame is a pure function of on-disk state at
that tick, so two instances against the same repos converge with no
coordination. The durable, yog-owned state is a closed list — **DESIGN §5.2 is
the normative one** — and it is short: `ui.json` (pins, collapse overrides,
attention watermarks — §4.1), the `ops.jsonl` action log (§4.2), `cadence.yaml`
(the clock's periods and the arming entries for the monitor and the loop, §7.2 —
present only once you tune or arm something, and deleting it *is* the reset),
the alignment monitor's policy file that a `cadence.yaml` entry names
(`monitor.md` by default, present only while armed), and one stderr sink per
detached spawn (`<yog-state>/detached/<ts>-<workspace leaf>.err`, §8.1/§13.3),
each written by the detached child itself and projected into its ops row at read
time. Everything else is derived from disk. The code is split the house way:
**pure view-model modules with no egui** — the per-tick `git_tree`, the `nav`
roster, `attention`, `projects`/`binding`, `start`, `ui_state`, the inspector
VMs (`transcript`, `steps_view`, `jsonview`, `inboxview`, `budgets`) composed by
the tested `inspector` tab dispatch, and `config_edit` — fronted by **thin egui
glue in `shell`** (coverage-excluded), with the keyboard bindings a pure
`keymap` table.
Visually, the window wears the **congeries palette** (`src/theme`, DESIGN §11):
Yog-Sothoth is *a congeries of iridescent globes*, and the UI is exactly that —
lore-named sphere-hues against a violet-black void (hydra green for liveness,
spectral blue for in-flight, brazen bronze for pending/warn, ichor for errors,
gate violet for yog itself), with every colour authored once in the theme
module and each driven tool keyed to its own hue. The application icon is the
same lore rendered as a mark: a **circuit triskele** — three circles tangent to
a central slit-pupilled eye, 120° apart, each launching an arm of arc to a
further circle, the circles between riding that arc on a trace of dim casing
under a bright phosphor conductor. It is all compass work: an arm is named by
its two pinned endpoints and one sagitta, so the equal legs and the seats on
the curve fall out of the construction rather than being tuned. Every shape is
a flat fill — which is what lets the window icon, the checked-in SVG and the
on-screen mark be one picture rather than three approximations of it — and the
hues are the palette's own, driven saturated rather than restated
(`assets/yog.svg`, generated by `make icon`; DESIGN §11).
On an open conversation's headline row that mark is **live**: one circle per agent, coloured by
what that agent is doing right now — green nothing, orange waiting on the API,
magenta thinking, blue answering, red running a tool. The eye is the conversation
you have open, the nine outer circles its subagents; hover it for the roster in
words. With nothing running, every circle rests at the mark's own green, so the
telemetry's empty reading *is* the logo.
The window is three information altitudes (DESIGN §11): the attention strip and
roster (does anything need me? what's running?), the focused workspace (its
agent descent tree with per-agent state badges, the four `refs/lernie/*` marks
— conflicted, budget-exhausted, abandoned, notify — budgets, streaming tails,
and tool pulses), and a per-agent tabbed inspector. Two composite flows sit on
top: the **start flow** (DESIGN §3.4) — prompts land as new roots in the
focused workspace (a fresh world bootstraps one automatically; creating more
is a deliberate act, walling off spheres like clients or corporate vs.
personal) and may carry a path or a ball payload (`bl create` + `bl claim
--as <workspace-name>`), through an editable goal composer whose
Send fires `lernie prompt` detached. A start that binds a work target also
**freezes that project's instruction files** into the agent's first commit
(DESIGN §3.7): yog walks from the target's git root down to the target, pins
each `AGENTS.md` it finds as exact bytes before the first inference, and
authors the workspace's `config/default` so the worker composes them — the
filename set is `AGENTS.md` unless a workspace's `instructions.yaml` says
otherwise, and the goal you typed stays your payload, never a concatenation.
Then the **config editors** stage-and-validate
brazen `config.toml`, lernie global config, and per-workspace config branches
(DESIGN §9). Replays (`<lernie-data>/replays/*`) render through the same view,
read-only.
## Running
```
yog # browse every workspace, attention-sorted
yog --workspace /path/to/workspace # start focused on one workspace
```
yog enumerates every lernie workspace (named workspaces under
`<yog-data-root>/workspaces/` — the resolved root is spelled out under "The
world" below — foreign workspaces and replays under the
lernie data root) and opens on the first one that needs attention. A workspace
name is **chosen** — typed at the New-workspace affordance — or the fixed
`home` the empty-world bootstrap uses without asking; it is never minted
(DESIGN §3.1). Minting is what names a *conversation*, from an embedded
wordlist (§3.3). Balls bind to workspaces by claimant: every claim a workspace
makes is stamped `--as` that chosen name, so assignment is late-mutable
metadata, not a location (DESIGN §3.2). The optional
`--workspace <path>` flag overrides that startup focus. There is no `--repo`
flag — the whole roster is the view.
From a source checkout, `make run` does the same **on main's tip**, and keeps it
there: it launches the installed binary and, when a merge lands on
`refs/heads/main`, rebuilds, closes the window and reopens it on the new tip —
so a long-lived `make run` is always the latest landed yog, with no restart verb
in between. It builds nothing from your working tree; pass `WS=<path>` to preset
the initial focus, and Ctrl-C to end the session.
To look at work you have **not** landed, use `make ux`: it rebuilds and installs
the **release** binary from your working tree, kills any running
instance, and relaunches it on your live state (`$XDG_DATA_HOME/yog`, real
conversations, no env overrides). It **blocks the terminal** rather than
detaching, so yog's stderr stays in front of you while you poke at the window;
Ctrl-C ends the session and re-running iterates. A failed build aborts before
the kill, so your running instance survives it, and running it with nothing up
is a no-op kill — hammer it as often as you like.
egui is winit-based; its only runtime deps are the X11/Wayland libs
already present on any Linux desktop. No `apt install` step is required.
**No substrate needs installing.** lernie, balls and brazen are compiled in, and
every `lernie` / `bl` / `bz` yog runs is yog re-execing itself under that verb
namespace (DESIGN §16.7). The `LERNIE_BINARY` / `BL_BINARY` / `BZ_BINARY` env
vars still override the physical target, as test seams and escape hatches back
to a host binary.
## The world
yog composes its own **nested world** — a substrate environment under
`<yog-data-root>/world` that overrides `LERNIE_HOME`, `XDG_STATE_HOME`, and
`PATH` and hands it to every child it spawns, so yog's `bl`/`lernie` state never
collides with your ambient tools. The world is the authority in **DESIGN §16**.
**The reset is one `rm`, and the path it takes is resolved, not spelled.**
`<yog-data-root>` is `$XDG_DATA_HOME/yog` when `XDG_DATA_HOME` is set and
non-empty, else `$HOME/.local/share/yog` — the XDG fallback yog's own fold
applies (`src/xdg`), and the reason a literal `rm -rf $XDG_DATA_HOME/yog` is
wrong: with the optional variable unset that command names `/yog`, and unquoted
it splits on any space in the value. Ask yog for the resolved value rather than
re-deriving it — `yog env` prints the world's roots shell-quoted, and
`LERNIE_HOME` is `<yog-data-root>/world/lernie`:
```
rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/yog"
```
**Scope of that deletion, exactly:** everything yog owns and nothing else — the
world subtree (the nested lernie home, the nested `XDG_STATE_HOME` holding
balls' state and yog's own durables, and the generated `world/tools/` shims),
every workspace yog created under `<yog-data-root>/workspaces/`, and every
per-workspace wall with its brazen config, credentials and model cache. Your
**ambient** lernie, balls and brazen state is untouched — it lives under your
real `$XDG_*` roots, which the world never writes to. Foreign workspaces and
replays are untouched too: they live under the lernie data root, outside this
path. There is no undo and no reset verb; the deletion is the reset, which is
the severability the nesting was chosen for.
**Nothing brazen-shaped is shared** (DESIGN §16.2). A workspace is an app-wide blast radius, and provider rows are
credential-adjacent workspace settings, so brazen's config, the credentials it
points at and the model cache beside them all resolve inside the focused
workspace's **wall**:
```
<yog-data-root>/world/walls/<workspace>/brazen/config.toml
<yog-data-root>/world/walls/<workspace>/brazen/credentials/<provider>.json
<yog-data-root>/world/walls/<workspace>/brazen/models/<provider>.json
```
Your machine's own brazen state is never read. A wall is **born empty** — it is
keyed by the name the workspace is created under, so nothing can seed it
earlier —
so a newborn workspace answers brazen's shipped provider rows and nothing else;
custom rows and sign-ins are per-workspace acts you perform after birth, which
is what keeps a corporate sphere's logins from shining into a personal one. A
seat inside *no* workspace has no wall, so `yog bz …` there refuses (exit 64)
rather than reaching for machine state — including a shell the escape hatches
below dropped into, since they hand out the world, and the world names no
sphere.
The `PATH` override fronts `world/tools/`, where yog seeds a **shim per
namespace** — `bl`, `lernie`, `bz`, balls' two plugin siblings `bl-delivery` and
`bl-tracker`, and the capability control — each a one-line re-exec of yog itself
(DESIGN §16.4, §16.7). So an agent yog starts gets yog's own compiled-in balls
when it types `bl` — same implementation, same nested state — and its claims are
stamped `--as $YOG_NAME` without the prompt telling it to. **Every verb works,
`bl prime` included**: the plugin-sibling shims are exactly what lets a checkout
primed by the embedded `bl` run a plugin chain that is yog at every hop, so the
old "these verbs are refused, run a host `bl` by absolute path" carve-out is
gone. The shims are generated artifacts — yog rewrites any that drift, and
deleting the directory loses nothing.
Two escape hatches (DESIGN §8.4) let a human — or a foreign frontend — join that
world from a shell with one prefix:
```
eval "$(yog env)" # drop THIS shell into the world; `bl`/`lernie`/`bz`
# now resolve to the world's shims — yog itself,
# against yog's nested state
yog exec bl list # run one command inside the world (its exit is yog's)
yog exec --cwd /path bl close bl-1234 --as me
# …and inside ONE workspace's wall, which is what anything brazen-shaped needs:
yog exec --ws /path/to/ws bz --login --provider openai --browser # sign in
eval "$(yog env --ws /path/to/ws)" # a whole shell
```
`yog env` prints the world's shell-quoted `export` lines; `yog exec [--cwd DIR]
[--ws WORKSPACE] <cmd…>` layers the world env over your inherited environment,
inherits stdio, and propagates the child's exit code (a terminating signal maps
to `128 + signum`).
**`--ws` names a workspace's wall.** Providers, sign-ins and the model cache
belong to a workspace, not to the machine — so `bz` outside one refuses rather
than falling back to anything shared, and `--ws` is how a headless seat says
which one. It is the same flag, spelled the same way, as `yog gesture --ws`. Both work headless — no display required.
Beyond the hatches, **every operator gesture is drivable headlessly** through
the control boundary (DESIGN §8.5, VISION §4.8). A gesture is a JSON envelope
deposited create-only into `<yog-state>/gestures/`; any running yog — the GUI
window, or `yog serve`, the same engine with no window — consumes it and
writes `gestures/replies/<id>.json`:
```
yog serve & # the windowless engine (worker + watcher + inbox + wire)
yog gesture '{"op":"scan","workspace":"/path/to/ws"}' # deposit-and-wait sugar
yog gesture '{"op":"conversations","workspace":"/path"}' # queries answer typed JSON
```
**Or type it.** The same gestures have a slash spelling — the line a human uses
at a terminal, a TUI, or a chat window, and the one the composer reads when a
draft starts with `/` (type `/` alone for the roster, `//` to say a literal
slash). A line takes its unspoken targets from the seat it is typed at: the
window's focus, or these flags:
```
yog gesture --ws /path/to/ws '/scan'
yog gesture --ws /path/to/ws --agent c-1 '/message ship it'
yog gesture --project /path/to/repo --as cobalt '/close bl-1f2a'
yog gesture '/balls'
```
**What needs you is a queue, not a badge.** `/attention` is the window's
attention strip as rows — every conversation waiting on somebody, anywhere,
with why it is asking and what it last said. `/seen` answers one: it writes the
same watermarks the window writes when you open that conversation, and hands
back the queue that remains, so an agent driving yog closes one decision per
gesture.
```
yog gesture '/attention'
yog gesture --ws /path/to/ws --agent c-1 '/message go with the second option'
yog gesture --ws /path/to/ws --agent c-1 '/seen'
```
**Every command answers `--help`**, and help is itself a gesture — a query,
asked *about* a command, so it is the same question wherever you type it:
```
yog --help # the whole surface: window, headless, hatches, namespaces
yog help exec # one yog command's page
yog exec --help # the same page, asked at the command
yog gesture --help # every gesture, one line each
yog gesture --help close # one command's page
yog gesture '/close --help' # the same page, said as a line
```
In the window, a draft of `/help`, `/help close`, `/close --help` or a bare `/`
answers identically. Help reads the interface rather than the world, so the
terminal answers it in place — no running yog required, and it exits 0. That
holds for every command, not just the gestures: `yog env --help` prints its
page instead of the export lines, `yog serve --help` prints instead of
booting, and `yog bl --help` / `yog bz --help` are balls' and brazen's own
pages, reached without founding a world or needing a workspace.
Actions run the same §8 executors the GUI's buttons do and log the same
`ops.jsonl` rows; queries return the same typed data the GUI renders. The roster
is deliberately not restated here — `yog gesture --help` prints every command,
one line each, read off the interface itself. Exit: `0` ok, `1` refused/failed,
`2` never deposited, `124` no consumer answered (the deposit remains and
converges later).
**Each agent tracks on a balls space of its own** (DESIGN §16.3). A workspace's space is its own clone bundle and its own balls
config home, under its wall, so two agents' task churn never collides; the
project's shared `balls/tasks` board is a destination an agent is *pointed at*,
not the water every agent swims in. An agent raised on a ball is launched onto
that project's board from birth, subagents inherit their parent's space, and
`/marks [<branch>]` reads or amends the branch a space rides. There is no yog
config file — the value written is balls' own `tasks_branch` key in balls' own
config, so removing the space deletes config, not code.
## Building and contributing
| `make build` / `make release` | Debug / release build |
| `make test` | Run the test suite (parallel; see the Makefile note on spawn discipline) |
| `make coverage` | Enforce the 100% coverage floor via pinned tarpaulin (see `tarpaulin.toml`) |
| `make lint` / `make fmt` | Clippy `-D warnings` + ast-grep rules audit + `cargo deny check` / rustfmt |
| `make rules-audit` | Run the pinned ast-grep rules (`rules/`) over `src` and verify the `rules/fixtures` negatives still fire |
| `make check` | fmt-check + lint + coverage — the complete gate CI and the pre-commit hook mirror |
| `make run` [`WS=<path>`] | Launch **main's tip** and keep it there: builds/installs `refs/heads/main` if it isn't what's installed, then relaunches the window on every landed merge (optionally focused on one workspace) |
| `make ux` | The UX loop: reinstall the **release** binary from your **working tree** and relaunch it on live state, blocking on yog's stderr |
| `make reload` | Relaunch an **already-running** yog on the currently installed binary, detached (no-op if yog isn't running) — the CICD half of `make ux`'s kill+relaunch, used by `scripts/install-main` |
| `make drive` [`DRIVE_RUNS="run …"`] | Drive the real-substrate ladder (`docs/STORIES.md`): release build, host preflight, one **scratch** world per run verb on an isolated Xvfb seat, a PASS/FAIL line and a `verdicts.jsonl` row per beat, and a pre-filled drive-log skeleton. Never the live world — an overlap with `$XDG_DATA_HOME` is refused |
| `make drive-cleanroom` [`DRIVE_VERB=<verb>`] | The same ladder with only `yog` and `git` on `PATH` — the standing batteries-included done-bar |
| `make drive-preflight` | Name every missing host prerequisite at once (Xvfb, xdotool, ffmpeg, python3, git, the `yog` under drive, the world seed, and the workspace **wall** — whether a scratch world can birth a workspace at all, plus the brazen fixtures seeded into it) |
| `make drive-seat` / `make drive-unseat` | Claim / drop an isolated Xvfb display: `export YOG_SEAT=$(make -s drive-seat)` |
| `make drive-seed` | Lay a scratch world and print its path — the starting point for a hand-steered capture pass (`docs/QUALITY.md` §3) |
| `make drive-log` [`DRIVE_LOG_DIR=<d>`] | Re-emit a run's drive-log skeleton (sha, host tuple, load, beat table) from its verdict rows |
| `make install` [`INSTALL_PREFIX=<p>`] | Release-build and drop `yog` into `$INSTALL_PREFIX/bin` (default `~/.local/bin`) |
| `make install-hooks` | Seat every `.githooks/` hook as a symlink in the repo's own hooks directory — do this once per clone, in the main checkout |
| `make icon` | Re-emit every icon artifact — the scalable SVG and the PNG set — from the generator (DESIGN §11); they are derived, never hand-edited |
Code style is governed by the Rust Bootstrap v3 standard — the flat-numbered,
yog-adapted rules in [`AGENTS.md`](AGENTS.md), machine-enforced by `rules/*.yml`
(pinned ast-grep), the clippy manifest, and `cargo-deny`; read it before writing
code.
Task tracking uses [balls](https://github.com/mudbungie/balls) (`bl`);
never commit directly on `main` — all changes land via a claimed worktree
and are delivered by `bl close`. The pre-commit hook enforces this, a
300-line ceiling on source files, and the 100% coverage floor.
## Delivery
Delivery to the upstream ([mudbungie/yog](https://github.com/mudbungie/yog))
is automatic. `bl close` squash-merges the worktree onto `main`, but it seals
that commit itself — outside `git commit` — so git's `post-commit` hook never
fires on a close. Delivery instead rides balls' own extension seam: the
`scripts/bl-push-main` plugin, wired into `close.post`, pushes `main` to
`origin` right after the close lands it. A push failure only warns — it never
fails the close, so a landed change is never lost to a transient network error;
re-push manually with `git push origin main`. Balls task state travels its own
path, pushed by the `bl-tracker` plugin; the two are independent.
Wiring the plugin into a checkout is checkout-local, not a repo edit: symlink
`scripts/bl-push-main` into the landing's `config/plugins/bin/`, then
`bl conf append close.post bl-push-main`. The `.githooks/post-commit` hook is
retained for the rare legitimate manual commit made directly on `main`; run
`make install-hooks` once per clone to arm the git hooks.
### Local install (CICD)
The *local* half of delivery is `scripts/install-main`: it recompiles `main`'s
tip, `make install`s `yog` into `$PATH`, then `make reload`s it — killing and
relaunching a currently-running yog on the fresh binary (a no-op if yog wasn't
running), so a landed merge takes effect without a manual restart. It builds
from an **ephemeral `git worktree --detach main`**, never the root checkout —
a plumbing delivery leaves the root working tree stale and possibly holding
uncommitted work, so the `main` ref is the only trustworthy source of "what
landed". The build shares the repo's `target/` (`CARGO_TARGET_DIR`) to stay
incremental, runs **detached** (`setsid`) so whatever moved `main` returns at
once, and logs to `target/cicd-install.log` (the relaunched yog's own stderr
goes to `target/yog.log`, kept separate so it doesn't grow the CICD log
forever). Like `bl-push-main` it **always exits 0**.
**Its trigger is the fact, not a verb.** The job is "`main`'s tip is not what
is installed", so the trigger is `refs/heads/main` *moving*, however it moved:
`.githooks/reference-transaction` fires on the transaction's `committed` state,
and dispatches when a line names `refs/heads/main` with a changed oid. That
covers a `bl close`'s plumbing update, a `git pull`/`git merge` on `main`, a
push received into this repo, and a hand repair (`git reset --hard`,
`git revert`) with one mechanism. It was previously a `close.post` balls plugin
named `bl-install-main`, which saw only the close — every other route left
`~/.local/bin/yog` on an older tip. That registration is retired (`bl conf
remove close.post bl-install-main`) and the script is no longer a plugin; two
paths to one outcome is a double build.
Two properties make a hook on so hot a ref safe. It is **idempotent**:
`make install` stamps the commit it built beside the binary
(`$(INSTALL_BIN)/.yog.commit`, named by `make print-install-stamp`), and
install-main compares `main`'s tip to it before building, so a ref write that
installs nothing new costs one `rev-parse` and a string compare. And it
**cannot recurse**: the ephemeral worktree's own ref writes re-enter the hook
as `HEAD`/`ORIG_HEAD`, never `refs/heads/main` (`worktree remove` and
`worktree prune` write no refs at all), so the ref-name test is the loop guard
and the stamp compare is the second one behind it.
Arming it is `make install-hooks`, once per clone, in the main checkout.
On every push to `main`, GitHub Actions runs `make ci` on Linux
(`.github/workflows/ci.yml` — fmt-check, clippy `-D warnings`, tarpaulin 100%
floor). **Linux is the gate**: a release publishes only on a green run of that
workflow.
macOS (Apple silicon) builds and runs `make test` in a workflow of its own
(`.github/workflows/macos.yml`), on the same triggers, and is **reported but
not gating**: it passes, and a release still waits only on Linux. The
difference between the two is coverage — tarpaulin's 100% floor runs on Linux
alone, which is where every line is compiled (nothing but the `lsof` spawn shim
is `cfg`'d out).
## Publishing
The crate reaches [crates.io](https://crates.io/crates/yog) two ways, and both
put a human at the trigger.
By hand: `make publish` runs `cargo publish --dry-run` unconditionally, and the
real upload only when you opt in with `make publish CONFIRM=yes`.
In CI: `.github/workflows/release-plz.yml` keeps ONE open "release PR" that
bumps the version and stages the changelog entry. **Merging that PR is the
human decision**; nothing publishes until you do. The merge lands on `main`,
CI runs, and only a CI run that concludes `success` releases — tag, GitHub
Release, crates.io upload, then the linux-gnu binary archive.
That release job is the only thing in the repo holding publish authority, so
the boundary around it is written out rule by rule in the header of
`release-plz.yml` (bl-5ae6). In short: no other job is handed the registry
token, the job runs in the `publish` environment so the token can sit behind
branch and reviewer rules, every action is pinned to a full commit SHA, each
job declares its own token scopes under a repo-wide `permissions: {}`, the
release is cut from the exact commit CI proved green
(`workflow_run.head_sha`, not the branch tip that may have moved), a manual
dispatch can only backfill binaries and can never publish, and the one operator
input crosses into shell through an environment variable rather than `${{ }}`
interpolation.