# yog — Architecture & Design
Status: normative. This is the repo's living architecture document, tracked and
amended like code. It synthesizes three competing designs and three judge
verdicts (unanimous convergence); the decisions here are settled unless amended
here. Deliberate interpretations of the requirement that the user may veto are
collected in §13; rejected alternatives in §14.
---
## 0. What yog is
yog is a desktop manager for lernie loops organized around **named
workspaces**; balls are the work items workspaces pick up. One yog window
shows every project on the machine, every ball in each project, every lernie
workspace, every agent and subagent in each workspace, and every byte of
diagnostic data lernie produces — and lets the operator start work from
nothing, a path, or a ball (§3.4),
drive agents, and edit brazen and lernie configuration, all through the
substrates' own write paths (`bl`, `lernie`, `bz`-validated file writes).
**yog is an application that owns a nested world, not a layer draped over the
user's.** The naïve reading — yog as a thin viewer over the ambient
`bl`/`lernie`/`bz` state a human already runs — inverts: yog composes its own
nested environment (§16.2) for itself and every child it spawns, so the
substrate state yog drives is *yog's*, under yog's data root. Playing on top of
the user's *direct* tool usage stays possible — the balls store branch is
shared by default (§16.3) and redirection knobs tune the overlap — so
**compatibility with an ambient workflow is the user's decision, not a
structural given.** The world is the subject of §16.
**Governing invariant (I0): two yog instances running side-by-side faithfully
replicate the same data, with nothing RAM-only except unsubmitted input text.**
yog is a pure renderer of disk plus a small set of user-action dispatchers. The
only durable state yog itself owns is one UI-state document (`ui.json`) and one
action-outcome log (`ops.jsonl`). Everything else already has an authoritative
home in balls, lernie, brazen, or git, and yog derives it.
Crate `yog`, bin `yog`, repo `github.com/mudbungie/yog`, version 0.0.1 with
crates.io metadata wired; publication is a separate deliberate act.
**New runtime dependencies: zero in phase 1.** (clap, eframe/egui 0.29, libc,
notify, serde_json, thiserror — unchanged; tempfile dev-only.) Phase 2 (§16.5)
embeds `balls`, `brazen`, and `lernie` as **exact-pinned** crates — the sole new
dependencies, and the version mechanism; the crate direction is the end state,
gated per substrate on upstream lib-readiness.
---
## 1. Taxonomy
lernie bans the term "session" (TAXONOMY §3: "underdefined, per-framework
overloaded, and colliding with the transport/connection sense"). yog does not
re-mint it — **the concept dissolves into existing nouns**: "start a session"
is *prompt into a workspace (created if none exists — §3.4; claim optional)*;
"the session" is *the
workspace*;
"the session list" is *workspace enumeration*. The word "session" appears
nowhere in yog code or UI. yog's vocabulary, exhaustively:
| Noun | Definition | Authority |
|---|---|---|
| **project** | A repo path with a balls clone: one entry under `$XDG_STATE_HOME/balls/clones/<pct-enc-path>/`, percent-decoded | balls |
| **ball** | A task: `tasks/<id>.md` in a project's store, read via `bl list --json` / `bl show <id> --json` | balls |
| **workspace** | A lernie workspace: a directory containing `repo.git`; yog-started workspaces live at `$XDG_DATA_HOME/yog/workspaces/<name>/` (§3.1) | lernie (contents), yog (location) |
| **name** | A yog-minted two-word identity (embedded wordlist, hyphenated): the workspace's dir leaf **and** the `--as` identity of every ball claim the workspace makes (§3.1) | yog (minted once; thereafter the dir leaf) |
| **binding** | The derived association between a ball and a workspace: ball claimant = workspace name (§3.2). Balls-owned metadata, explicitly late-mutable via `bl claim`/`bl unclaim` — never a yog-stored fact | balls (claimant field); yog joins |
| **agent** | `agents/<id>` branch + descent arithmetic on the hyphenated id | lernie |
| **exchange** | lernie's presentational span (ARCH §2.4: root agent's history between a user message and the terminal response) | lernie |
| **attention** | A derived per-agent predicate (§6): unacked notify / stop / budget / conflict, or pending mail with no driver | yog (pure function) |
| **seen / pin / collapse** | The operator's durable, converging UI facts (§4.1) | yog (`ui.json`) |
| **draft** | Text typed but not sent | RAM (the requirement's carve-out) |
| **world** | The nested substrate environment yog composes under its data root — the `LERNIE_HOME` / `XDG_STATE_HOME` override fold that redirects `lernie` and `bl` state into yog-owned roots (§16.2; brazen's config stays ambient) | yog (composed) |
**Rejected:** a first-class session/loop record (registry file, ball field, or
git ref mapping ball↔workspace) — a second name for a workspace path, i.e. a
stored duplicate of a derivable fact, which drifts. balls' unknown-key
writeback seam was considered for storing the workspace path in the ball and
rejected: machine-local paths in a shared store are the same mistake balls
itself refuses for worktree paths (balls arch §11). The claimant field is
neither: it is balls' own first-class metadata under balls' own merge
discipline, and a *name* is a machine-neutral identity, not a path — which is
why binding lives there (§3.2).
---
## 2. Invariants (the durability skeleton)
- **I1 — Disk is the app.** Every rendered fact is a pure function of (files
on disk, probe observations). Restart is equivalent to re-read. This already
holds for the read path (`tests/pluggability.rs` proves N concurrent
`GitTree::from_repo` calls converge); yog extends it to all state. The files
are the *nested world's* files: every path derivation resolves through the
composed world env (§16.2), so "disk" means yog's world, not the ambient one.
- **I2 — Two durable yog artifacts, no more.** yog owns exactly
`$XDG_STATE_HOME/yog/ui.json` (§4.1) and `$XDG_STATE_HOME/yog/ops.jsonl`
(§4.2) — these `$XDG_STATE_HOME` paths resolve through the composed world env
(§16.2), i.e. nested inside yog's data root, which the world leaves anchored
to the ambient `$XDG_DATA_HOME`. Every other write goes through the owning
substrate's contract: task
state via `bl` verbs; workspace state via `lernie` verbs only (never a direct
write inside a workspace — ARCH §3.5); brazen config as a
`bz --dump-config`-validated atomic file replace; lernie global config as an
atomic file replace (lernie declares these hand-edited).
- **I3 — All yog file writes are temp-in-destination-directory + `rename`.**
Never in-place truncation, never a temp on another filesystem (EXDEV). Temp
names are dotfiles (`.<name>.yog-tmp-<pid>`) so no substrate reads them;
leftovers older than 24 h are swept at startup. `ops.jsonl` is the one
exception: O_APPEND lines ≤ 4096 bytes (PIPE_BUF), atomic per line.
- **I4 — Watches are latency, polls are correctness.** fs-watch (notify:
inotify/FSEvents) triggers re-derivation fast; a periodic sweep re-derives
regardless (§7.2: 2 s cheap sweep, 15 s full sweep, clock-injected). A
dropped event, a stale watch, an overflowed inotify queue never causes
divergence — only delay bounded by the sweep interval.
- **I5 — Convergence discipline by state class.** `ui.json`: last-writer-wins
whole-file with echo suppression. `ops.jsonl`: append-only, order-free
union. Config files: optimistic hash guard (refuse to overwrite a file
changed since load, §9). `bl` operations: converge-on-retry (balls' own
recovery rule, arch §13). lernie repo state: lernie's writer/driver
discipline (ARCH §2.11); yog is a pure reader.
- **I6 — The RAM whitelist is closed.** Only the items in §5.3 may exist
without a disk home. Every addition requires amending this document.
- **I7 — yog never mutates any substrate except on an explicit user action.**
No auto-prime, no auto-scan, no auto-push, no background repair. Two
instances can never race a spontaneous mutation because neither has any.
*Composing* the world env (§16.2) is pure — no mutation, always safe;
*materializing* the world (creating the subtree, seeding `LERNIE_HOME` via
lernie's own bootstrap verb, priming the nested balls clone, setting the
no-marks knob) is a mutation and so happens only on an explicit action — the
first Start seeds a missing world exactly as it seeds a missing workspace
(§3.4), never at idle.
- **I8 — A probe never perturbs the observed.** Liveness probing is read-only
observation (lsof / procfs scans), never lock acquisition (§10, §14).
- **I9 — Determinism substitutes for persistence.** All ordering is derived:
projects sort by path, workspaces by ball id, agents by descent order. Two
instances render identical order without sharing any ordering state.
---
## 3. The organizing unit: the named workspace
Both roots below — `yog_data_root`, `lernie_data_root` — resolve through the
composed world env (§16.2), so the paths name locations *inside yog's nested
world*. The balls state root enters only through `bl` verbs (claims and
listings), never through path arithmetic: **the workspace tree encodes no
project paths and no ball ids** (§3.2 supersedes the original path-convention
binding).
### 3.1 Names: a minted identity, a flat root
**A workspace is long-lived and low-volume** — a sphere of work (personal,
corporate, a client) whose wall is lernie's isolation boundary; conversations
are root agents *inside* it (lernie §7.3), and balls flow through it (§3.2).
A new user never meets the concept: the first start bootstraps one (§3.4),
and further workspaces are minted only to wall spheres off from each other.
Every yog-started workspace is created at:
```
$XDG_DATA_HOME/yog/workspaces/<name>/
e.g. ~/.local/share/yog/workspaces/cobalt-gecko/
```
- **The name** is minted at creation: two words from an embedded wordlist,
hyphenated. The mint is a pure function over an injected RNG and the
occupied set — existing dirs under the root plus every claimant visible in
`bl list --json` across enumerated projects — so a fresh name collides with
neither a live workspace nor a live claim. Every wordlist entry matches
`^[a-z]{3,9}$` and is neither a given name, a surname, nor `unknown` (bl's
`--as` fallbacks are `$USER` then the literal `unknown`, and bl validates
nothing), so a minted name is path-safe by construction and can never be
mistaken for a human claimant (curation + provenance: bl-ccf7,
`src/names/words.txt` header). **The dir's existence is the
registration**: the name namespace *is* the readdir; no registry file.
- **Enumeration / reverse derivation:** readdir the root for directories
containing `repo.git`; the leaf is the name. Workspaces under
`<lernie-data-root>/workspaces/` are **foreign** (lernie's auto-id
territory — rendered, unnamed, never created by yog);
`<lernie-data-root>/replays/*` render as read-only replay workspaces. Three
roots, one shape, classification by path alone.
- **Severability:** the root is yog's own territory, *not* lernie's
machine-populated `workspaces/` tree. Deleting `$XDG_DATA_HOME/yog` erases
yog's entire workspace footprint and leaves lernie and balls untouched —
the same choice balls made in placing delivery worktrees under its own
plugin territory (arch §1). **With the nested world (§16.2) this widens:** the
nested `LERNIE_HOME`, the nested balls state root, and yog's own artifacts
all live under `$XDG_DATA_HOME/yog`, so one `rm` erases the whole world and
leaves the *ambient* lernie/balls/brazen untouched.
Rejected: `<lernie-data-root>/workspaces/yog/…`
— squats in lernie's retention-governed, auto-id-populated territory.
- lernie accepts any path for `lernie new [path]` (CLI §1); yog `mkdir -p`s
the root (outside any workspace — the ban is on writing *inside* one) and
passes `<root>/<name>`.
**Superseded (was §3.1):** the path-convention binding
`$XDG_DATA_HOME/yog/balls/<mirrored-project-path>/<ball-id>/`. It encoded a
**congenital, immutable, 1:1** ball↔workspace association — fixed at `mkdir`,
unchangeable thereafter — and the actual workflow is the opposite: **agents
author balls mid-flight and pick up several**, and assignment must be
**late-mutable**. A location cannot be reassigned; a claim can. "Id is the
path" survives where it belongs — the *name* is the path leaf — while the
*assignment* moves to metadata balls already owns (§3.2). Y7/Y11/Y14/Y17
landed against the superseded convention; the Z-wave (§15 M6) reworks them.
### 3.2 Binding is the ball's claimant
The ball↔workspace association is **metadata on the ball**: a ball is bound
to a workspace iff its claimant equals the workspace's name.
- **Forward derivation:** ball → workspace = the dir named by the claimant.
**Reverse:** workspace → balls = `bl list --json` filtered on claimant =
name. Both are joins over facts balls already owns, mutates, and syncs —
yog stores nothing (I2 intact).
- **Every claim a workspace makes is stamped with its name:** yog's own start
flow claims `bl claim <id> --as <name>`, and the composed preamble (§3.3)
tells the agent its name, so the balls an agent authors and picks up
register the same way — **"agents write the balls" is the normal case, not
a deviation.** The ball-pickup event *is* the assignment record.
- **Explicitly late-mutable, by design:** assign = `bl claim <id> --as
<name>`; move = `bl unclaim <id>` + `bl claim <id> --as <other-name>`;
release = `bl unclaim <id>`. All are first-class UI verbs (§8.2), legal
whenever balls allows them, at any point in a workspace's life.
- **N balls per workspace** over its life; a ball has one claimant, so at
most one workspace at a time. Stop-scope, budget-scope, and retention-scope
ride the workspace — the conversation — not the ball.
- **Cross-machine caveat (accepted):** the store branch is shared (§16.3), so
claimants minted by another machine's yog are visible here; a name
collision would false-join. The mint's occupied-set check plus the
wordlist's combinatorics make this negligible, and a false join only
renders — it never mutates (I7).
**Justification:** single source of truth — the assignment's one
authoritative home is the ball's claimant field, owned by balls, mutated only
through `bl` verbs, synced by the store branch, and visible to the ambient
`bl list`. **Rejected:** (a) a yog-side registry file — drifts, needs its own
merge discipline (the claimant is not a registry: it is balls' first-class
metadata under balls' discipline); (b) storing the workspace *path* in the
ball — machine-local paths in a shared store (balls arch §11); a name is a
machine-neutral identity, not a path; (c) binding in `goal.md` content only —
per-root prose, unusable as an enumeration source; (d) workspace inside the
delivery worktree (`<worktree>/.lernie/`) — captured by close's squash into
the project repo, disqualifying.
Parallel attempts, reprompts, and follow-ups remain *multiple root agents in
the one workspace* — exactly lernie §7.3's concurrent-exchange model ("new
question → `lernie prompt` forks new root"); the workspace stays lernie's
isolation boundary (§2.2).
**Two altitudes of ball attribution, both derived, honestly scoped.** The
claimant equality above binds a ball to a *workspace* — the enumeration source
for the workspace's bound balls (the roster/header). A *conversation* (a root
agent, §1) is finer than a workspace, and its association is derived from a
different fact, with a different reach:
- **Conversation → ball (start-flow only):** the start flow composes the ball
header into the conversation root's `goal.md` (`Ball <id>:`, §3.3); parsing
that stamp back is the conversation↔ball join. It is the **inverse of the
compose**, so one module owns both (compose and parse live together) and the
format has a single home. A start-flow conversation stamps **exactly one**
ball, so a conversation carries **at most one** derived ball — never a set.
- **Agent-picked balls have no conversation-level record.** When an agent runs
`bl claim` mid-conversation (the normal case, §3.2 above), the claim stamps
the *workspace* name — there is no fact recording *which conversation* picked
it up. Such balls therefore bind at the workspace altitude only. **This is a
real limit, not a rendering choice:** per-conversation badges come *only* from
the goal stamp; every other bound ball renders in the workspace header. A
conversation-level pickup record does not exist yet, and until a fact carries
it, none is invented (single source of truth — no yog-side registry, §3.2).
Both joins are pure reads over facts already owned elsewhere (the claimant on
the ball; the `Ball <id>:` line in `goal.md`), stored nowhere by yog (I2).
### 3.3 Work-target and identity ride in the goal — through an editable composer
lernie has no target-repo concept: tools inherit the driver's cwd, which is
unreliable across detached revivals (CLI ground truth: message→advance via
setsid inherits lernie's own spawner cwd). The durable facts are therefore
**content**: the composed goal embeds the workspace's name and, when a work
target exists, its absolute path.
The start flow (§3.4) opens a **prompt composer prefilled** per payload rung.
**Identity is stamped by the harness, not the model:** every workspace-scoped
spawn carries `YOG_NAME=<name>` in its env (§8), and the world's bl agent
tool injects `--as $YOG_NAME` whenever the caller omits `--as` (W9, §16.4) —
the agent cannot forget what it never had to remember. The preamble still
opens by telling the agent who it is, for self-reference:
```
You are <name>.
```
*Phase-1 interim (host `bl`, no shim yet):* the preamble instead carries
`You are <name>. Stamp every bl verb you run with --as <name>.` —
load-bearing until W9 lands and deleted with it. A forgotten stamp falls back
to bl's `$USER` default and renders claimed-elsewhere: visible as a stray,
never corrupting.
The path rung appends a target preamble naming the given directory verbatim;
the ball rung composes ball title, ball body verbatim, and the worktree
preamble:
```
Ball <id>: <title>
<ball body verbatim>
The project repository checkout for this work is the git worktree at:
<abs worktree path> (branch work/<id> of <project-path>)
Do all repository work there, by absolute path. Do not rely on the current directory.
```
**Transparency, scoped by ownership** *(amended with the STORIES ladder)*:
the operator sees and edits exactly the **payload** that is sent — their own
text and the target/ball prefills — before `lernie prompt` fires; the
**identity preamble is the harness's fact, stamped at fire time** (the same
ownership line as `YOG_NAME`/W9: the harness stamps identity, the model and
the operator never carry it). The composer *previews* the identity line
greyed above the box — the mint is a pure read (names-root readdir +
already-fetched claimants), so the predicted name renders before submit with
nothing spawned (I7 intact) — and on the rare lost race (another instance
registered the name between preview and Enter) the mint re-derives and stamps
the fresh name; the preview is a prediction, the stamp is the truth. The
binding mechanic stays transparent, and no goal-template config file exists
(the visible editable prefill is the severable version; deleting nothing
changes no code path). Belt-and-suspenders: yog also sets `current_dir` per
the §3.4 cwd column on every spawn.
The worktree path is never stored (balls arch §11: "computed, never stored");
it is recomputed by the bl-delivery formula
(`$XDG_STATE_HOME/balls/plugins/bl-delivery/<mirrored-project-path>/<id>/`)
and cross-checked against `bl claim` stdout at claim time; `git worktree list`
in the project repo is the standing ground truth. Because the path is a pure
function of (project, id), an unclaim/re-claim re-materializes at the same
path — the preamble never goes stale.
### 3.4 Lifecycle: the start flow
**Two orthogonal axes, one composer.** *Where* a prompt goes: the focused
workspace — and a world with zero workspaces mints one first, so **bootstrap
is the empty case of the general path**, never a wizard or a concept the new
user meets. *What* it carries: the payload ladder, each rung the one below
plus inputs.
| Payload rung | Input | Extra steps | Composer prefill (the identity line is *not* prefill — it is previewed grey and harness-stamped at fire, §3.3) | Driver cwd |
|---|---|---|---|---|
| **bare** | — | (none) | (none — the empty composer) | `~` |
| **path** | a directory | (none) | target preamble, path verbatim | the path |
| **ball** | a ball, picked or freshly created | `bl claim <id> --as <name>` | target preamble + ball title + body + worktree preamble | the work worktree |
- **Creating a workspace is the rare, deliberate verb** (+ New workspace,
§11): raising a sphere wall — a client, corporate vs. personal — not a
per-conversation act. The everyday gesture is the composer: a new prompt is
a new root in the focused workspace (lernie §7.3), and a ball pickup claims
`--as` that workspace's name (§3.2).
- The path rung's directory need **not** be a bl-primed project — it is
simply where the agent works.
- Re-opening is the same path as opening: an existing dir skips `lernie new`,
dissolving any "resume" special case. During work, everything is lernie's
normal surface; yog renders and dispatches verbs (§8.2), including
late assignment of further balls (§3.2).
- **`bl close`:** the ball file is deleted; the closed listing's claimant
still names the workspace, so *that query is the "delivered" status* —
delivered balls group under their claimant workspace on demand
(`bl list -s closed --json`; obituary via `bl show <id> --json`;
delivered-in commit via `git log --grep "[<id>]"`). yog deletes nothing:
workspace retention is lernie's (§9.2, 30-day default); `lernie bundle` is
the archival verb (deferred, §8.3).
### 3.5 Join states (the edge-case-dissolving enumeration)
The join is the claimant equality (§3.2), enumerated once, as a table; every
combination renders as a row state, never an ad-hoc branch. All derived, none
stored:
| Ball (derived status) | Claimant | Rendered as |
|---|---|---|
| ready | — | ready ball: ▶ Start (ball rung) or Assign to an existing workspace |
| blocked | — | blocked ball (blocker edges shown from bedrock JSON) |
| claimed | = a local workspace name | **bound** — the normal working row, grouped under its workspace |
| claimed | ≠ every local workspace name | **claimed-elsewhere** — badge shows the claimant verbatim (a human, another machine, or a deleted workspace) |
| closed (absent from live set) | (from the closed listing) | **delivered** — grouped under the claimant workspace when one matches, else visible in the on-demand closed listing |
| (none claim it) | workspace exists, zero bound balls | **unassigned workspace** — the bare/path-rung general case: full rendering, no ball column |
| any | project clone gone | **orphaned-project** — the project's balls unlistable, marked missing; workspaces unaffected (they encode no project path) |
**Conversation-level rendering is an overlay on this same table, keyed by the
goal stamp (§3.2).** A conversation row's ball badge is the ball its `goal.md`
stamps (`Ball <id>:`, §3.3), coloured by *that ball's* row-state above — bound
green, delivered ash, blocked/claimed-elsewhere brazen, orphaned ichor. The
join table is unchanged: the overlay looks the stamped id up in it. Two honest
gaps follow from §3.2's scoping, each a plain `None`, never a fabricated row:
(a) a conversation with no start-flow stamp (bare/path, or a hand-typed root)
shows no badge; (b) a stamped id the join does not know here (its project
unfetched, or the ball since deleted) still shows the id from the stamp, but
uncoloured — the badge is source-1 truth, the colour is the join's when it has
one. The **grouped-by-ball** organizing view is this overlay inverted: each
stamped ball heads its conversations, the stamp-less ones trailing in one
unassociated group — a pure, stable partition of the recency-sorted list.
---
## 4. Durable yog state
### 4.1 `$XDG_STATE_HOME/yog/ui.json`
`ui.json` holds **only genuinely-converging data** — user assertions with no
other authoritative home, where both instances *should* agree. Live focus,
selection, and scroll are deliberately **not** here: they are per-instance
viewport ephemera (§5.3, reasoning in §13.1).
```json
{
"v": 1,
"seen": {
"/abs/ws/path": {
"a-b": { "notify": "<ref-oid>", "stopped": "<tip-oid>",
"budget": "<ref-oid>", "conflicted": "<ref-oid>" }
}
},
"pinned": ["/abs/ws/path", "..."],
"collapsed": ["proj:/home/mark/dev/brazen", "ws:/abs/ws/path"],
"show_internal": false,
"identity_last_used": "orionriver@gmail.com"
}
```
- **`seen`** — the attention-acknowledgement watermarks (§6), one per signal
kind, keyed to ref oids (notify/budget/conflicted = the `refs/lernie/*` ref
target; stopped = the branch tip oid at acknowledgement). lernie's marks are
level-triggered and yog may not delete refs ("the UI is a pure reader"; no
ack verb exists) — so "the user has seen this" is a yog fact: **the mark is
lernie's, the acknowledgement is yog's.** A moved ref re-notifies.
- **`pinned`** — ordered float list of workspaces (a user assertion, no other
home).
- **`collapsed`** — explicit user expansion overrides only; default expansion
is derived from attention, so the file stays tiny. A persisted *view* (§13.0),
not durable data.
- **`show_internal`** — the global nested-delivery ("internal") clone view
filter (§5.1 #1): a boolean, `false`/absent ⇒ hidden. A persisted view
(§13.0), not durable data.
- **`identity_last_used`** — prefills `--as` for verbs dispatched *outside*
any workspace (manual `bl create`/`bl update` from a project row; default
`$USER` when absent). Workspace-scoped verbs never consult it: they stamp
the workspace's name (§3.2). The severability showcase: no yog config file exists;
deleting `ui.json` restores defaults and deletes no code path.
**Write discipline:** debounced (≤1 write 250 ms after last change; flushed on
any dispatched action and window close), temp-in-dir + rename (I3).
**Convergence:** both instances watch `$XDG_STATE_HOME/yog/`; on an external
change, read; if the content hash equals our own last write it is an echo —
ignore; otherwise adopt wholesale (LWW at file granularity). A missing/corrupt
`ui.json` is the fold identity — all defaults, never an error (brazen's
forgiving-read stance for its model cache). Unknown keys are preserved on
writeback (additive schema, balls' discipline).
**Startup focus derivation:** with focus out of `ui.json`, each instance
derives its initial focus deterministically — the next-attention workspace
(§6), else the first workspace in derived order (I9). Nothing is lost on
crash; focus re-derives.
### 4.2 `$XDG_STATE_HOME/yog/ops.jsonl`
One JSON line per **attempted** yog-initiated action *(amended from
"completed": a spawn failure — missing binary, exec error — appends a
synthetic line with the intended argv and the failure in `stderr`, extending
the precedent the detached prompt's spawn-failure line already set in §8.1;
a non-spawn step failure — mint pool exhaustion, `mkdir`, worktree
cross-check drift — appends a line whose `argv` is the logical step name,
e.g. `["yog-step","mint"]`, with a sentinel `exit`. **The sentinels are the
`src/opslog` consts (the authority): `-1` a piped verb whose status was
unobservable, `-2` a detached spawn (`lernie prompt` — clean when `stderr` is
empty, a spawn failure or a post-launch death when not), `-3` a synthetic
failure line (a piped spawn that never launched, or a non-spawn
`["yog-step",…]` step).** An error class with no ops row is an error the UI
cannot render — the §7.3 failed-action row depends on this)*:
```json
{"ts":"2026-07-17T12:00:00Z","argv":["bl","close","bl-4db6"],"cwd":"/home/mark/dev/brazen","exit":0,"stdout":"…","stderr":"…"}
```
- Written with O_APPEND; **each line is capped at 4096 bytes (PIPE_BUF)** so
concurrent appends from two instances are atomic and never interleave;
`stdout`/`stderr` are truncated to fit with an explicit
`"truncated":true` marker. A pathological `argv` element is the one field the
capper cannot shrink, so the caller pre-clips the sole large one — the detached
`lernie prompt`'s composed goal — to a bounded head with an explicit
`… [+N bytes elided]` marker before logging (the *spawned* goal is unclipped;
full fidelity is never the log's job, the goal being derivable from the
workspace).
- Both instances append; both tail it (fs-watched). The ops pane renders the
shared history.
- This closes the one durability leak of a pure derive-everything stance:
gate/close output, `lernie scan` summaries, and error text are *not* on disk
anywhere else — without this log they would be RAM-only and non-convergent
(and the current shell's "stderr printed and dropped" hole would persist).
- No rotation in v1 (documented; balls' own multi-MB per-clone `log` sets the
precedent). Detached long-lived drivers (§8.1) do **not** stream here —
their outcomes are derived from disk, not parsed from pipes.
- **One field is not stored here: a detached spawn's `stderr`.** The `-2` line
is written at fire, when the child has said nothing yet. Its stderr is
captured to the per-spawn sink `detached/<ts>-<workspace leaf>.err` (§8.1,
§5.2) and folded into the row **at read time**, on the tail the ops sweep
re-reads. The sink is the authority and the row a projection, so the text is
never stored twice and no line is ever rewritten; the sink's name derives from
the `ts` and workspace the line already carries, so the schema gains no field
to join them. A row whose folded `stderr` is non-empty is a rendered failure by
the `-2` rule above — that is how a driver that died *after* launching stops
being invisible.
---
## 5. The complete state inventory (normative)
Every piece of state in the application, classified. **This table is
normative: code review rejects any state not placeable in it.**
### 5.1 Derived-from-disk (fact → home → derivation; never stored by yog)
Every path fold below resolves through the composed world env (§16.2):
`LERNIE_HOME` and `$XDG_STATE_HOME` name nested locations, while
`$XDG_DATA_HOME` / `$XDG_CACHE_HOME` / `$BRAZEN_CONFIG` stay ambient — so
brazen config (#19), credentials (#22), and model cache (#23) all read the
*shared* ambient world, with no change to the fold expressions themselves.
| # | Fact | Source of truth | Derivation |
|---|---|---|---|
| 1 | Project list | `$XDG_STATE_HOME/balls/clones/*` | readdir + percent-decode basename; decoded paths under `plugins/bl-delivery/` are nested-delivery clones, hidden behind an "internal" toggle |
| 2 | Balls per project | project store | `bl list --json` with cwd = project path (the bedrock projection; serde_json). **Process-failure ≠ empty:** a non-zero/failed `bl` (clone gone, unlistable) leaves the project *unkeyed* in the cache → the §3.5 orphaned-project row; a clean exit with `[]` keys it to an empty vec → a listable project with no balls (the two are distinct states, not both "no balls") |
| 3 | Ball status | ball frontmatter | balls §3 ladder: claimant ⇒ claimed; else unresolved claim-blocker ⇒ blocked; else ready. Closed = absent from live set |
| 4 | Delivered/closed balls | store history | `bl list -s closed --json` / `bl show <id> --json` on demand |
| 5 | Work-worktree path | formula + git | bl-delivery formula recompute; `git worktree list` ground truth; `bl claim` stdout cross-check |
| 6 | Workspace list + names + foreign + replays | `$XDG_DATA_HOME/yog/workspaces/*`, `<lernie-data>/workspaces/*`, `replays/*` | readdir for `repo.git`; leaf = name per §3.1 |
| 7 | Join state per (ball, workspace) | #2–#6 | the §3.5 claimant join, a pure function |
| 8 | Agent set, descent, tips | `repo.git` refs | `git for-each-ref agents/*` + hyphen-prefix arithmetic (existing `git_tree`) |
| 9 | Agent state {Live, InFlight, Quiescent, Stopped} | inbox flock + response.json framing | LockProbe + WriterProbe (tri-state, §10) + `last_segment_complete` (ARCH §3.5/§4.4) |
| 10 | Streaming text, tool calls | `steps/<id>/NNN/` | existing `streaming.rs` / `tools.rs` |
| 11 | Pending messages, inbox contents | `inbox/<id>/*.md` | count + parse `---from/deposited_at/epitaph---` frontmatter |
| 12 | Transcript | `agents/<id>/messages/NNN-<origin>.*` | readdir + sort; origin from filename; "tool in progress" = tool_use with no tool_result |
| 13 | Step diagnostics | `steps/<id>/NNN/{meta,request,response,staging}.json`, `tools/` | full-file reads, jsonview rendering — every byte inspectable. The §7.3 **no-response wound** derives from the same bytes: an empty-or-absent `response.json` with no `meta.json`, on an agent nobody is driving (#9) |
| 14 | Marks ×4 | `refs/lernie/{conflicted,budget-exhausted,abandoned,notify}/*` | `for-each-ref` (marks.rs extended from 2 to 4 namespaces) |
| 15 | Attention (per agent / rollups / totals) | #9, #11, #14 + `ui.json.seen` | §6 predicate — pure |
| 16 | Budget *spent* | Usage events across `steps/<root>*/` | fold; limits displayed only as raw `workflow.yaml` text (no YAML dep) |
| 17 | Governing config per agent | git ancestry | nearest ancestor of agent tip reachable from any `config/*` ref (merge-base over config refs, ARCH §2.2) |
| 18 | Config branches + contents | `repo.git` `config/*` refs | `for-each-ref` + `git show <ref>:<path>` |
| 19 | brazen file config | brazen's exact path fold: `$BRAZEN_CONFIG` > `$XDG_CONFIG_HOME/brazen/config.toml` > `~/.config/…` (pure XDG on all platforms) | raw text |
| 20 | brazen effective config | `bz` itself | `bz --dump-config` stdout verbatim (bz is the authority on the value fold; yog never re-implements TOML semantics) |
| 21 | brazen built-in rows | compiled into bz | static read-only hint labeled "compiled into bz <`bz --version`>" |
| 22 | Credential presence | `credentials/<provider>.json` existence (per-OS dir) | existence only; contents never read, never written |
| 23 | Model cache | `$XDG_CACHE_HOME/brazen/models/*.json` (per-OS) | read-only display; refresh = `bz --list-models` |
| 24 | lernie global config | `<config-root>/models.yaml`, `workflows/*.yaml` | raw text |
| 25 | Action history | `ops.jsonl` | tail + parse (§4.2); ambient error prominence is the §6 retirement projection over that tail — never a stored flag |
### 5.2 Durable-on-disk, yog-owned
Exactly `ui.json` (§4.1), `ops.jsonl` (§4.2), and the detached-spawn stderr
sinks `$XDG_STATE_HOME/yog/detached/<ts>-<workspace leaf>.err` (§8.1, §13.3) —
each written *by the detached child itself*, not by yog, and each the sole
authority for what that driver said, projected into its `-2` ops row at read
time. Like `ops.jsonl` they are not rotated in v1.
Plus two *transient scratch* artifacts that exist only inside an operation and
are swept: config staging temps (`.<name>.yog-tmp-<pid>` in the destination dir)
and the scripted-editor staging directory `$XDG_STATE_HOME/yog/stage/<nonce>/`
(§9.3). Neither is an authority; leftovers >24 h old are swept at startup.
### 5.3 Legitimately RAM (the closed whitelist, I6)
| Item | Why RAM is legitimate |
|---|---|
| Unsubmitted input text (prompt/message bars, claim-dialog fields, the goal composer, config editor buffers before Apply) | the requirement's explicit carve-out: "text typed in a box can live in RAM until sent" |
| **Live focus/selection and scroll position** | per-instance viewport ephemera — *which data you look at*, not data; loses nothing on crash; re-derives at startup (§4.1). Deliberate interpretation, §13.1. Scroll is *represented* as content anchors (topmost visible message ordinal / step number), never pixels, so the viewport stays stable across live re-derivation of the tree beneath it |
| Subprocess handles, drain threads, `Stream`s | a process is not data; the fact each represents lives on disk (driver running = flock held; op outcome = `ops.jsonl` line + substrate state). Long-lived drivers are spawned fully detached (§8.1) so yog's death cannot kill or starve them |
| Watcher registry, notify channels, dirty flags | reconstructible plumbing; the sweep (I4) makes their loss harmless |
| Memoized derived snapshots (`HashMap<PathBuf, GitTree>`, ball lists, parsed transcripts) | caches of §5.1 facts; discarded and rebuilt at will |
| Live window geometry, egui layout/font caches, GPU state | instance-physical, not data (§13.0: window arrangement is a view, not data) |
| Probe result TTL cache on macOS (§10) | a cache of an observation with a 2 s bound |
| Live streamed-verb output (§8's streamed-piped class: the `bz --login` device code/URL lines) and the last failure outcome held at its originating surface | instance-local by nature (a device code is for the human at *this* keyboard); both converge to their `ops.jsonl` line — the stream to its outcome line at exit, the failure to the entry it already appended — so the other instance renders the durable fact from the pane, never diverges |
**The left panel's `collapsed` overrides (§4.1) are the deliberate
counter-example:** a persisted *view* — which sections (the balls section)
you keep folded — that converges benignly for convenience, intentionally
*unlike* this RAM-only jsonview collapse set and the RAM-only activity
accessory (§13.0). All are "collapse" state; only the section override is
worth persisting. Don't "fix" the asymmetry.
---
## 6. The attention model
`attention(agent)` is a derived predicate, true when any of:
1. `refs/lernie/notify/<id>` exists and its oid ≠ `seen[ws][agent].notify`.
2. State = Stopped, no `refs/lernie/abandoned/<id>`, and tip oid ≠
`seen[ws][agent].stopped`.
3. `refs/lernie/budget-exhausted/<branch>` oid ≠ `seen[ws][agent].budget`.
4. `refs/lernie/conflicted/<id>` oid ≠ `seen[ws][agent].conflicted`.
5. Pending inbox > 0 **and** lock Free — mail nobody is driving (the
writer/driver stall case). **Not seen-gated**: it is actionable (flush via
`lernie scan`), self-clears when a driver picks it up, and hiding it would
hide a stall.
Signals 1–4 are seen-gated on the `ui.json` watermarks (§4.1); focusing an
agent records the current evidence oids as seen. Because `seen` converges,
**acknowledging in one instance acknowledges in both — attention is data, and
it converges.** Live focus does not.
Rollups: workspace attention = max over its agents; the top strip shows totals
across all workspaces with a **jump-to-next-attention** control (also the
startup focus derivation, §4.1). Sort within each group (conversation list,
keyboard order): **attention > running > idle**, then derived order (I9) —
the conversation list refines "idle" to recency (§11).
**Ops error prominence retires the same way — derived, never stored.** The
activity surface (§4.2's tail, §11's chip) is the ops-side analogue of this
model. `ops.jsonl` is append-only and keeps every failure forever, so the
*ambient* ⚠ count is a **projection over the tail at read time, never a stored
flag**: walking newest-first, a failed line is a **live failure** unless a later
line with the same (`cwd`, verb) did not fail. The verb is the leading two argv
tokens — binary plus subcommand (`bl close`, `lernie prime`, `yog-step mint`) —
because the argv tail carries per-run operands (a ball id, a composed goal) that
never repeat, so keying on the whole argv would retire nothing; `cwd` scopes it,
so a clean `bl close` in one project leaves a failed one in another alone.
Success is the pane's own failure classifier negated (`OpRow::failed`), so no
second definition of success can drift from the one it paints. A retired failure
keeps its row and its ⚠ in the expanded accessory — it loses only ichor and the
chip's count. **Absence of a live failure is the record; the log is the
history.** The wound this closes: a three-day-old `lernie prime` failure, since
fixed and re-run green, read as THE error when an unrelated action failed,
sending diagnosis down a false trail.
A conversation whose latest step **failed** stirs the strip through rule 2: a
failed or killed latest `response.json` classifies the agent Stopped
(§4.4/§3.5) — an auth-failed step included — so an unseen dead conversation is
never "nothing stirs". Acknowledging it clears the *signal*, not the fact: the
conversation list's state badge and the §11 Login affordance keep rendering
the settled failure (the badge is state, not attention).
**The prompt that never became a conversation (bl-a649).** Rules 1–5 are all
per-*agent*, so they can only stir once a conversation root exists. A detached
`lernie prompt` that dies before writing one — a tool version-skew refusal at
startup — has no agent to attach a signal to, and used to stir nothing at all.
It is not a sixth rule: that failure is an **action** outcome, not an agent
state, and it surfaces on the action path it already belongs to — the child's
stderr sink folded into its `-2` ops row (§4.2, §8.1), which makes the row
`failed()` and therefore lights the §11 activity chip's ⚠ count and the §7.3
ichor-red banner at the firing surface. The two paths stay disjoint on purpose:
attention is about agents that exist; the ops surface is about actions that were
attempted. A prompt whose driver dies mid-life crosses over — it *has* an agent
by then, and rule 2 fires. Rule 2 only says *something is wrong here*, though:
the **cause** is not attention's job. What that conversation shows once focused
is the §7.3 no-response wound (§11), and what the driver actually said is its
stderr sink on the ops surface.
---
## 7. Watch / re-render architecture
### 7.1 Watch roots
A `WatchSet` (new module `src/watch/`) owns one `fs_watcher::Watcher` per
root, with a per-root-kind allowlist (generalizing the existing hardcoded
single allowlist):
| Root kind | Path | Allowlist |
|---|---|---|
| Workspace (×N) | each workspace dir | existing: `steps/`, `inbox/`, `agents/<id>/{goal.md,soul.md,summary,messages,descriptions,skills}`, `repo.git/HEAD`, `repo.git/refs` |
| NamesRoot | `$XDG_DATA_HOME/yog/workspaces/` (top-level — flat by construction) | dir create/remove (new/removed named workspaces) |
| WorkspacesRoot | `<lernie-data>/workspaces/`, `replays/` (top) | dir create/remove |
| BallsClones | `$XDG_STATE_HOME/balls/clones/` | clone dir create/remove; per-clone `tasks/tasks/*.md` and `config/config/**`; the per-clone `log` (multi-MB, no rotation) is **filtered out** to avoid event storms |
| BrazenConfig | dir of the resolved config.toml (the ambient file, §16.2/§9.1) | the file name only (atomic rename = Remove+Create on the dir watch) |
| LernieConfig | `<config-root>/` | `models.yaml`, `workflows/` |
| YogState | `$XDG_STATE_HOME/yog/` | `ui.json`, `ops.jsonl` (the `detached/` sinks are **not** watched: a chattering driver would storm the watch, and the 15 s sweep's re-read of the ops tail folds them in anyway, §8.1) |
Rejected: one recursive watcher over the whole lernie data root — an agent
building a large tree in its worktree inflates the inotify watch count and
fires on every git object write; per-workspace scoped watchers with allowlists
are the existing, tested shape.
### 7.2 Repaint and re-derivation
eframe is repaint-on-interaction; today nothing re-reads disk after startup
(fs_watcher is built, tested, and unwired). The wiring:
- A single **bridge thread** blocks on the aggregated notify channels; on an
allowlisted event it records the dirty root in a `Mutex<DirtySet>` and calls
`egui::Context::request_repaint()`. This is the only cross-thread signal.
- `App::update` each frame: drain `DirtySet` → re-derive only dirty roots →
render. Re-derivation granularity v1 is **whole-root rebuild with a 100 ms
coalescing debounce** (a streaming `response.json` append storm collapses to
≤10 rebuilds/s of one workspace). Rebuild is always correct; incremental
streaming-only refresh is a listed optimization task, not a correctness
feature. `GitTree: PartialEq` suppresses no-op replacements.
- **Poll floor (I4):** every frame schedules `request_repaint_after(2 s)`.
The **2 s cheap sweep** re-runs the cheap enumerations (readdir of clones/,
workspace roots, config dirs), reconciles the WatchSet (a watcher whose
directory was deleted/recreated — e.g. a re-primed clone — is rebuilt), and
performs the **targeted liveness re-probe: only agents currently
Live/InFlight are re-probed** — a released flock emits *no* fs event, so
silent driver death is only observable by polling, and probing only the
agents that could have died bounds the cost. Every **15 s** the sweep marks
*everything* dirty. Correctness never depends on an event arriving; a stale
watch costs ≤15 s of latency, never divergence. `bl list --json` per
project runs on its root's dirtiness or the 15 s sweep, never per frame.
- **All sweep/debounce/heartbeat timing is clock-injected** (a `Clock` trait,
same injection pattern as LockProbe/WriterProbe) so every time-gated branch
is testable to 100% without sleeps.
- The existing ~30 fps tool-pulse repaint is unchanged; effective repaint
delay each frame is `min(pulse, sweep)`.
Rejected: a dedicated derivation thread pushing snapshots — an extra
concurrent stateful component; deriving on the frame thread with dirty-set
coalescing keeps "pure function of disk at this tick" inspectable.
### 7.3 Failure modes (enumerated)
| Failure | Handling |
|---|---|
| inotify queue overflow / dropped events | 15 s full sweep re-derives; bounded staleness |
| Watched dir replaced (clone re-primed, workspace deleted) | 2 s reconcile rebuilds the watcher from the enumerated root list |
| Editor atomic-rename inode swap on config files | dir-level watch sees Remove+Create; allowlist matches by name |
| Event storm from streaming response.json | 100 ms coalescing debounce per root; balls `log` files excluded from allowlists |
| Concurrent `ui.json` writers | LWW + echo-hash (§4.1) |
| Concurrent `ops.jsonl` appenders | O_APPEND + ≤PIPE_BUF lines — kernel-atomic, no interleave |
| Concurrent config-file editors (two instances, or instance + vi) | optimistic hash guard: Apply refuses if on-disk content ≠ content loaded into the buffer; user reloads and re-applies (§9) |
| Crashed `bl` op | converge-on-retry: re-run the verb (balls arch §13); stderr in `ops.jsonl` |
| Crashed/killed driver | agent classifies Stopped from framing; attention rule 2 fires; `lernie scan` deposits died epitaphs and flushes inboxes |
| yog crash mid-write | rename atomicity: whole-old or whole-new; dotfile temp debris swept at startup |
| yog crash with drivers running | drivers are detached into their own process group, holding no yog-owned pipe (stdin/stdout null, stderr on a file) — unaffected; next launch re-derives their state from locks/refs |
| Detached driver dies right after launch (tool version skew, missing model config) | its stderr sink (§8.1) is non-empty; the ops sweep folds the tail into the `-2` row, which becomes a rendered failure — banner + ⚠ chip. Without the sink this was invisible: exit `-2`, empty stderr, a prompt that "does nothing" (bl-a649) |
| Probe backend unavailable (lsof missing) | tri-state `Unknown` → uncertainty badge, never a false definite state (§10) |
| Driver dies leaving an empty step (version skew, OOM, kill before the first event) | the step is a **no-response wound**, not a quiet one: an empty-or-absent `response.json` **and** no `meta.json` **and** no driver on the agent (§3.5) renders "driver produced no response" in ichor beside the step and banners it at Altitude 1 (§11). Framing alone reads this `Killed` — the ash "stopped" badge over a `0 attempts · 0 tok` row, which is how it read as a quiet step (bl-7f2e). The *cause* lives on the ops surface: the driver's stderr sink, folded into its `-2` row (§8.1) |
| Failed action (short verb or start step) | **a rendered fact, never stderr-only**: the full `ops.jsonl` entry (argv, cwd, exit, stderr) is expandable at the ops pane, *and* the originating surface (start pane, input bar) renders the failure in ichor red with argv + stderr tail. No `eprintln!`-only error path may exist in `src/shell/` (STORIES INV-2) |
---
## 8. The action surface (v1) and exact argv
All spawns go through `cli_outbound` (generalized: binary resolution
parametric over env var — `LERNIE_BINARY`, `BL_BINARY`, `BZ_BINARY`, default
PATH names — plus `current_dir` support and a detached-spawn mode). **Every
spawn carries the composed world env (§16.2)** — the overrides layer over the
inherited environment via the existing `run_env` seam — so every child, the
detached driver included, runs *inside* the nested world; an agent's own tool
processes inherit the driver's nested `$XDG_STATE_HOME` and so a host `bl` they
invoke computes the right nested paths (§16.4, phase-1 correctness).
Workspace-scoped spawns additionally carry `YOG_NAME=<name>` (§3.3) — the
identity the W9 tool shim stamps onto unstamped bl verbs. Binary
resolution is unchanged. Every attempted action appends its line to
`ops.jsonl` (§4.2). Spawns come in exactly three classes: **short-piped**
(run to completion, outcome logged), **detached** (own process group,
stdin/stdout→null, stderr→a per-spawn sink file, spawn logged with the `-2`
sentinel), and **streamed-piped** *(added with the §8.3
login amendment: line-buffered stdout rendered live at the invoking surface —
a §5.3-whitelisted instance-local stream — with the outcome line appended at
exit; sole v1 member: `bz --login`)*.
### 8.1 Start (the composite verb — §3.4's axes, as argv)
1. Resolve the target workspace: the focused one. **Zero workspaces in the
world → mint a name (§3.1), `mkdir -p` the names root, `lernie new
<root>/<name>`** — the bootstrap is this empty case, not a separate flow.
The explicit **+ New workspace** verb (§11) runs the same mint + `lernie
new`, deliberately.
2. Open the **editable goal composer** (§3.3), prefilled per payload rung. On
confirm: `lernie prompt <root>/<name> <composed-goal>` — **spawned
detached** (own process group, stdin/stdout→null, **stderr→the per-spawn
sink file**, `YOG_NAME=<name>` layered per §8), cwd per
the §3.4 column. The agent id on stdout is
**deliberately not read**: the new root materializes in the watched repo
within a tick — *derive, don't parse*. Detachment also removes the failure
mode where yog holding a driver's stdout pipe ties the driver's lifetime
to yog's (`Stream`'s Drop SIGTERMs; a SIGPIPE after yog death would kill
the loop).
The ball rung inserts, between 1 and 2: (optional) `bl create <title> …`
(cwd = project; stdout = id), then `bl claim <id> --as <name>` (cwd =
project; stdout = worktree path). **The order is load-bearing:** every
substrate step (the seed, `lernie new`) precedes every `bl` mutation, so a
failed or missing substrate aborts before anything half-commits — *the start
flow* can never mint an orphaned claim. (A claimed ball whose workspace was
later deleted remains a legal state; §3.5 renders it claimed-elsewhere.)
The planner (`start::plan`) is a pure function returning the command sequence;
the executor runs it step-by-step with per-step outcomes in `ops.jsonl`.
Steps are individually idempotent-or-convergent: re-running after a crash at
any step converges (double-claim refuses benignly; `lernie new` skipped when
the dir exists; prompt just adds a root; **a ball already claimed by a local
workspace name re-plans as a prompt into that workspace — resume, not a
second mint**). A **new** ball defers the id: the plan is a single
`bl create`, and the freshly-minted (ready, unclaimed) ball is re-planned as
an existing one — the new→existing transition *is* the convergence, not a
special case. The claim's stdout worktree path is cross-checked against the
bl-delivery formula (both the `<id>` and `<id>-<claimant>` variants match;
anything else is a convention drift surfaced loudly, never silently
accepted). The short steps (`lernie prime`, `bl create`, `bl claim`,
`lernie new`) log their piped outcome; the detached `lernie prompt` logs only its **spawn** — argv,
cwd, and a `-2` sentinel exit, since a detached child in its own process group
has no waitable status — with a spawn failure riding the same line in `stderr`.
**The detached child's stderr sink (bl-a649, amending §13.3).** A spawn failure
is not the only way a prompt fails: the child can launch cleanly and *then* die
(a tool version-skew refusal, a missing model config), which under the original
stdio→null left `-2` + empty `stderr` — indistinguishable from a healthy launch,
and the operator saw a prompt that did nothing. So the detached child's stderr
is bound to a **per-spawn sink file**,
`$XDG_STATE_HOME/yog/detached/<ts>-<workspace leaf>.err`, and the ops row's
`stderr` is **derived from that file at read time** (§4.2, §7.2) instead of
being copied into `ops.jsonl`. Consequences, each load-bearing:
- **The sink is the authority; the row is a projection.** The fact lives once.
`ops.jsonl` records the *launch* and is never rewritten; a driver that keeps
writing surfaces more on each sweep, with no second durable copy to diverge.
- **The join key is computed, not stored.** The sink's name derives from the ts
and workspace the ops line already carries, so no field is added to the §4.2
schema to point a row at its file — the path *is* the id.
- **A file, not a pipe.** yog holds no descriptor on it, so §8.1's whole reason
for detaching is untouched: the child outlives yog and keeps writing.
- **Only the tail is folded**, bounded, from a line boundary — a long-running
driver's sink is unbounded and this read runs every sweep.
- **Nothing new stirs.** A non-empty capture makes the row `failed()` by the
rule already written for `-2` (§4.2), which is what the §7.3 banner and the
§11 activity chip's ⚠ count read. No new signal, no new surface.
- **An unopenable sink degrades to `/dev/null`** and the launch proceeds: the
driver is the point, the capture is the diagnosis.
### 8.2 Per-workspace / per-agent verbs
| UI action | argv (cwd) | Spawn mode |
|---|---|---|
| New prompt (new root) | `lernie prompt <ws> <text>` | detached |
| Message agent (also the resume gesture — no resume verb exists, ARCH §2.9) | `lernie message <ws> <agent> <text>` | short, piped (it self-detaches its driver) |
| Stop | `lernie stop <ws> <agent>` (+ `--stop-children` toggle) | short, piped |
| Scan / flush | `lernie scan <ws>` | short, piped; summary line surfaced |
| Close ball | `bl close <id>` (project) | short, piped; capture/fold/gate/squash output in `ops.jsonl`, gate failures verbatim (claim+worktree stay up, bl's own semantics) |
| Assign ball → workspace (§3.2) | `bl claim <id> --as <name>` (project) | short, piped |
| Move ball → other workspace | `bl unclaim <id>` then `bl claim <id> --as <other-name>` (project) | short, piped ×2, both logged |
| Release ball | `bl unclaim <id>` (project) | short, piped |
| New ball | `bl create "<title>" [--body B] [flags]` (project) | short, piped; new id captured on stdout |
| Update ball | `bl update <id> …` (project) | short, piped |
| Refresh models | `bz --list-models --provider <row> --json` | short, piped |
| Login provider | `bz --login --provider <row>` (toolchain pane; also offered beside an auth-failed step) | streamed-piped (§8.3) |
Short verbs show a busy indicator (RAM — the underlying fact is the
`ops.jsonl` line plus substrate state both instances see).
**Identity rider (Z4).** Every `bl` claim/close/unclaim yog issues is stamped
`--as <workspace name>`, **not** the operator's `$USER` — the claimant delivers
its own ball (§3.2's ownership line, the same fact as the start flow's `bl claim
--as <name>` and W9's `YOG_NAME`). Concretely: **close** and **release** stamp
the ball's *bound* workspace name (its claimant); **assign** and a **move**'s
claim stamp the *target* workspace name; a **move**'s unclaim stamps the ball's
current (source) workspace name. The operator identity survives only as the
*author* of a standalone `bl create`/`bl update` (§8.2 New ball / Update ball),
where a workspace is not the reporter. `lernie message` (the resume gesture)
additionally layers `YOG_NAME=<ws leaf>` on the revived driver, so its agents'
own tool subprocesses stamp `--as` the same name — the detached `lernie prompt`
already does (§8.1); message is a workspace-scoped spawn too (§8).
### 8.3 Deliberately not in v1 (with reasons)
- **Manual `lernie dispatch`** — workflow-driven dispatch is the designed
path; a manual role-dispatch button invites mis-goaled children. If ever
surfaced: `lernie dispatch <role> <ws> <branch> --goal <text>`, role list
derived from governing `providers.yaml` roles that carry souls.
- **Fork-from-history** — no lernie CLI verb exists; yog may not write refs
(ARCH §3.5). Upstream gap, tracked as a lernie ball, not worked around.
- **`bundle` / `replay` actions** — v1.1; replay *results* (`replays/*`)
already render read-only in v1 since they are just workspaces.
- **`bl conf` editing, `bl prime` of new projects** — v1.1; v1 scope is
projects already primed (present in `clones/`).
- **brazen `[ingress]`/`--serve`, credentials editing** — out of scope;
credentials are constitutionally untouchable. **Amended (STORIES S0):**
`bz --login --provider <row>` *is* v1 — it is bz's one interactive surface
and its headless device flow needs no TTY input, so yog runs it as a
**streamed-piped** verb (§8's third spawn class) from the toolchain pane,
its stdout lines (device code, URL, poll status) rendered live in the pane
verbatim. yog renders the flow;
credentials remain bz-stored, never read or written by yog. Showing the
exact command stays as the fallback when the piped flow exits non-zero.
### 8.4 World escape hatches (`yog env`, `yog exec`)
Two subcommands of the yog binary — the multi-call pattern beside
`--editor-apply` (§9.3) — expose the composed world to a human at a shell:
- `yog env` prints the world's `export` lines (`LERNIE_HOME`,
`XDG_STATE_HOME`); `eval "$(yog env)"` drops the current shell *into* the
world, where the ambient `bl`/`lernie`/`bz` then operate on yog's nested
state.
- `yog exec <cmd…>` runs one command inside the world (world env layered,
optional cwd) without touching the caller's shell.
Both are pure entrypoints of the yog binary, not substrate spawns — the
operator's hand-hold on an otherwise-encapsulated world (§16.2), and the human
counterpart to the embedded-crate agent tools (§16.4). **They stay hatches:**
yog never fires `yog exec` at itself as a reproduction affordance beside a
failure — the driver's stderr sink already carries the cause (§8.1), so a
re-run button would re-create a held fact and start a second driver (§14).
---
## 9. Config editing write paths
One shared discipline for all three editors: **load → edit in RAM buffer
(carve-out) → Apply = stage → validate (where a validator exists) → hash-guard
→ atomic rename → watcher propagates to the other instance.** The hash guard
is the concurrent-edit discipline: Apply refuses if the on-disk content no
longer matches what was loaded into the buffer (another instance, or vi, wrote
meanwhile); the user reloads, re-diffs, re-applies. Rejected: blind LWW on
operator-authored config — silently discarding a concurrent edit.
### 9.1 brazen `config.toml`
- Path: brazen's exact fold, reproduced — `$BRAZEN_CONFIG` else
`$XDG_CONFIG_HOME/brazen/config.toml` else `~/.config/brazen/config.toml`
(pure XDG on all platforms, per brazen `env.rs`). This is the **ambient**
file — the world leaves `BRAZEN_CONFIG` unoverridden (§16.2), so the file
yog edits, validates, and watches is the same one the user's own `bz` reads.
That is the intent: one `bz`, one config.
- Editor: **raw TOML text**, not form fields. Apply: write buffer to
`.config.toml.yog-tmp-<pid>` in the same dir → run
`bz --config <temp> --dump-config`; non-zero exit (MalformedFile/BadValue/
IncompleteProvider… all exit 78) rejects with stderr shown, draft kept in
RAM → hash-guard → rename into place. A malformed config can therefore
never land, so `bz` (and every lernie loop calling it) never breaks.
- Alongside the editor: a read-only "effective config" pane =
`bz --dump-config` stdout verbatim (the merged, redacted, authoritative
view including env-layer effects yog could never compute from the file),
plus the built-in-rows hint (§5.1 #21).
- **Structured view = `bz --dump-config`; no TOML dependency.** brazen's
schema is versionless, forward-additive, and full of open valves (top-level
passthrough, `body_defaults`) that a form would corrupt or reject; any
yog-side parse is a second authority that drifts. bz *is* the parser, kept
in lockstep with brazen by being brazen. This deliberately contradicts the
brief's "toml parsing is probably justified" leaning — all three judges
concurred it is not.
### 9.2 lernie global config (`models.yaml`, `workflows/*.yaml`)
Text editor per file; Apply = hash-guard + temp-in-dir + rename (lernie
declares these hand-edited; yog is the hand, minus torn writes). No validator
exists and yog adds no YAML dep — the operator's risk is identical to `vi`.
Documented gap; a future `lernie config --check` slots into the same pipe.
New workflow = same path, new name; templates copyable.
### 9.3 Per-workspace config branches (the scripted `$EDITOR`)
Browsing: `for-each-ref refs/heads/config/` + `git show <ref>:<path>`
(read-only, via the existing env-scrubbed `git_tree::cmd`), including each
agent's derived governing config (§5.1 #17, "policy frozen at `<short-oid>`").
Editing — `lernie config <ws> [name]` is the only lawful writer of `config/*`
and is $EDITOR-interactive, so yog drives it:
1. User edits the branch's files in RAM buffers; Apply writes the full
drafted file set to `$XDG_STATE_HOME/yog/stage/<nonce>/`.
2. Spawn `lernie config <ws> <name>` (plus `--from <src>` / `--orphan` when
forking) with `EDITOR="<yog-binary> --editor-apply"` and
**`YOG_EDIT_SRC=<staging-dir>` in the environment** — the staging dir
rides in env, the checkout path arrives as the shim's argv, because lernie
composes the editor line through `sh -c` and its exact arg-passing shape
is a flagged open question. The shim tolerates both `$EDITOR <dir>` and
per-file invocation.
3. `yog --editor-apply` (a tiny non-GUI mode of the yog binary; its copy
logic is a pure, fully-tested lib function) copies **only the drafted
files** over the materialized checkout — **never a full-tree sync**:
`lernie config` has just refreshed `descriptions/**` from the data-root
pools at commit time and the shim must not clobber that. Exits 0; lernie
commits and tears down. Empty diff is declined by lernie; surfaced as "no
change".
4. Staging dir deleted on completion; leftovers swept (§5.2).
**Diligence task 0 for this feature: source-read lernie's exact `$EDITOR`
invocation shape** (how the checkout path is passed through `sh -c`) before
building the shim, and record the finding in the task.
This is the only path that advances a config branch, honoring "never write
inside a lernie workspace except via the lernie CLI" — yog writes only its own
staging dir; lernie performs the commit.
---
## 10. Portability (Linux + macOS/aarch64)
- **Already portable:** notify (inotify/FSEvents), eframe/glow, libc::kill,
all git/CLI spawning, the XDG folds (balls/lernie/yog paths are pure-XDG on
both platforms, matching those tools; brazen's *per-OS* credential/cache
dirs are reproduced for the read-only displays).
- **The gap:** both probes scan `/proc/<pid>/fd` (Linux-only). The fix:
- Probe traits return a **tri-state `Probe::{Held, Free, Unknown}`**
(replacing bool).
- Linux impls: existing procfs scans, behavior unchanged.
- macOS impls: parse `lsof -F` output over the inbox dir / response.json
(writer filter from the fd access-mode field). The **parser is a pure,
platform-independent function compiled and tested everywhere**
(recorder-fixture inputs, 100% covered on Linux CI); only the ~20-line
spawn shim is `#[cfg(target_os = "macos")]`. lsof is slow, so macOS probe
results carry a 2 s TTL cache (RAM, §5.3), refreshed eagerly on watcher
events touching the agent, and re-probed only for Live/InFlight agents on
the sweep (§7.2).
- `lsof` missing/failing ⇒ `Unknown` ⇒ classification degrades to
framing-only: closed-with-`end` = quiescent, closed-without = stopped,
open-file undetectable ⇒ rendered with an explicit **uncertainty badge
("live?")**, never a false definite state.
- **Rejected: flock-acquire probing** (`flock(LOCK_SH|LOCK_NB)` then
release) — portable and dependency-free but **perturbs the substrate**:
during yog's transient hold, a `lernie message` writer's probe sees the
lock taken, concludes a driver exists, and strands the deposit until the
next scan (writer/driver totality, ARCH §2.11). A probe must never affect
the observed (I8). Also rejected: the libproc crate (a dependency for one
probe) and hand-rolled FFI (unsafe, untestable on Linux).
- **Coverage mechanics:** brazen's per-OS creds/cache path folds take
**`target_os` as a runtime-injected parameter**, so the macOS branch is
exercised by Linux tarpaulin — no cfg-gated coverage hole. The same rule
applies to any future per-OS branch.
- **Known upstream limit, documented, not worked around:** `lernie stop` is
itself /proc-based (Linux-only). On macOS yog surfaces the Stop failure
verbatim in `ops.jsonl`; fixing stop portability is lernie's ball.
- **CI:** Linux runs the full gate (fmt, clippy -D warnings, tarpaulin 100%
pinned 0.35.2). macOS (aarch64) job: `cargo build` + `cargo test`, no
tarpaulin (Linux sees every line because nothing but the lsof spawn shim is
cfg'd out). Known macOS test issues — `/tmp` vs `/private/tmp`
canonicalization in probe fixtures, FSEvents timing in fs_watcher tests —
are already filed as **bl-592b**.
---
## 11. UI structure — three altitudes
Single window; the organizing frame is three information altitudes, each one
click apart. The organizing *unit* on screen is the **conversation** — a root
agent in the focused workspace (§1, STORIES) — and the workspace is a regime
wall (personal / work / client): totally separate blast radius, almost
invisible — nothing in the UI but a small tab bar under the top right.
**Altitude 0 — glance (always visible).**
`TopBottomPanel::top`, left: the wordmark and the **attention strip** — totals
per signal kind across all workspaces, jump-to-next-attention. Right: the
**workspace tab bar** — one tab per named workspace (pinned first, in pin
order, then name order), each badged with its attention count; a slim **+**
tab (the deliberate sphere-wall mint, §3.4 — minting is not the everyday
gesture; the composer is); and an overflow menu (⋯, shown only when needed)
holding foreign and replay workspaces — real but not regimes, so they never
widen the wall row; pinning hoists one into the tabs, and the menu button
carries their aggregate attention.
`SidePanel::left`: the focused workspace's **conversation list** — headed by a
**+ conversation** affordance that clears the agent selection and focuses the
composer, then one row per root agent: state badge (aggregated over the
conversation's subtree — InFlight > Live > the root's settled state, with the
§10 "?" uncertainty suffix), first-line preview, age, and the shared
streaming pulse while any member is in flight. Sort: **attention > running >
recency** (§6). Below the list: a minimal collapsible **balls** section — the
start affordances (▶ Start / ▶ Continue / Assign per §3.5, the new-ball
forms, the empty-project hint, the nested-delivery "internal" toggle) and the
focused workspace's bound-ball rows with join badges; the full per-project
ball views return in the ball-views wave — then the Config entry and the
toolchain pane. Answers "does anything need me?" and "what's running?"
without interaction.
**Altitude 1 — the selected conversation (center).**
The center renders the selected conversation, **transcript first**: a header
(conversation id, aggregated state badge, age, whole-tree budget-spent
figures), then — **only when the conversation has children** — the compact
descent tree (one selectable row per member, state badge + `✉n` pending;
selecting a member is the §6 acknowledgement gesture and retargets the
inspector), then the Altitude-2 inspector for the selected member, Transcript
tab by default with the live streaming tail appended and visually distinct
(§5.1 #10/#12). A conversation whose **latest step is an auth-shaped failure**
(the §8.3/Z8 detection over the derived step facts) banners in ichor red and
renders the **Login** affordance inline — the same streamed `bz --login`
machinery as the toolchain pane, one click away where the wound is. A
conversation whose latest step is the §7.3 **no-response wound** — its driver
died before the model said anything — banners in ichor red the same way,
naming where the cause lives (the driver's own stderr, in the activity
accessory below) rather than offering to re-run the prompt (§14). Both banners
sit above the inspector, so the cause is on the conversation surface whichever
tab is open — including the default Transcript, which for a step that produced
nothing has by construction nothing to show. yog's own plumbing never reads as
conversation content: the ops trail lives in the bottom activity accessory
(below), never as inline rows between conversation content.
**Altitude 2 — the inspector (per selected agent, tabbed; every tab has a Raw
toggle showing verbatim bytes).**
- **Transcript** — `messages/NNN-*` in filename order; `.md` deliveries with
origin header; model `.json` as content blocks (text/thinking/tool_use);
`NNN-tool.json` results; committed tool_use without tool_result = "tool in
progress"; live tail from the open response.json appended, visually
distinct.
- **Steps** — `steps/NNN` table: framing status (in-flight/complete/failed/
stopped, plus the §7.3 no-response wound, which outranks the framing read and
paints the ichor ✗ with its sentence beside the row), commit, started/ended,
attempts (segment count), tokens. Drill-in:
meta.json, request.json, response.json event list, staging.json, per-tool
input/output — all rendered through **jsonview**, a small hand-rolled pure
collapsible `serde_json::Value → row tree` widget (zero-dep, uniformly used,
fully testable): every byte inspectable.
- **Inbox** — deposits with from/deposited_at/epitaph; explains `✉n`; Flush =
`lernie scan`.
- **Files** — the agent worktree read-only, bounded previews: goal.md,
soul.md, summary/, skills/, descriptions/, work products.
- **Config** — governing config files with "policy frozen at `<short-oid>`";
links to the workspace config editor (§9.3).
**Bottom accessories (`TopBottomPanel::bottom`, stacked):**
- The **composer**, docked bottom whenever a non-replay workspace is focused:
one text box (RAM until sent). The target follows the selection — a selected
agent ⇒ **message** (the resume gesture); none ⇒ **new conversation** (a
detached prompt into the focused workspace; §3.4's rungs, minting only when
no workspace exists). **Enter fires the targeted verb** — the S0/S1 gesture,
one box, one Enter — with the greyed identity preview above the box (§3.3).
The dir (path-rung) field, Stop (+children checkbox), Scan, and the ball
actions (Close/Release/Move, §8.2) ride beside it unchanged.
- The **activity accessory** — the demoted ops pane: one collapsed chip
(`activity · N ops · M ⚠`, the **live**-failure count in ichor when M > 0 —
§6's retirement rule, so a failure a later clean run of the same verb
superseded is not counted) expanding on demand to the `ops.jsonl` tail; a row
expands to the full entry — argv, cwd, exit, stderr — because a trail that
hides *why* is not a trail (§7.3 failed-action row); a retired failure still
renders its ⚠ row, weak instead of ichor. Default collapsed; per-instance
viewport state (§13.0).
- Config mode (left-panel entry) swaps the center to the brazen /
lernie-global / config-branch editors (§9).
**Keyboard navigation.** ↑/↓ step the focus through the flattened
conversation order (workspace path order across, §6 sort within), landing via
the seen-acknowledgement path (§6); the
digit keys 1–5 select an Altitude-2 inspector tab (RAM, §5.3). The bindings are
a pure key → intent table (`src/keymap`, tested); the `egui::Key` lift and
dispatch are thin shell glue, excluded like the rest of the tree.
Widget split discipline (unchanged religion): pure view-model modules (no
egui) + pure render functions (headless shape-walk-tested) + interaction glue
(`.clicked()` branches) confined to `src/shell/*`, coverage-excluded alongside
`main.rs`: shell-level clicks are unreachable in the headless harness, so the
split keeps everything a click *calls* covered. The exception, proven by Y13
(jsonview's tested collapse toggle): a self-contained widget whose interaction
is *intrinsic* may own a tested click, exercised under a simulated-pointer
render test — the exclusion is for the shell tree, not a claim that no click
can be driven headlessly.
**Visual identity — the congeries palette (`src/theme`).** Yog-Sothoth
manifests as *a congeries of iridescent globes*; the UI's identity is exactly
that — luminous sphere-hues against a violet-black void. `src/theme` is the
**single colour authority**: every hue the UI paints is a lore-named constant
there (hydra green = liveness/ok, spectral blue = in-flight/streaming, brazen
bronze = pending/warn, ichor red = error, ash = stopped, sigil magenta = the
uncertainty "?", gate violet = yog's own selection/wordmark hue), renderers
import the name and never restate an RGB triple. The module also derives the
whole-app `egui::Visuals` (installed once at eframe bring-up), owns the one
shared in-flight pulse (every pulsing indicator beats in step), maps each
driven integration to its hue (`lernie`→hydra, `bz`→brazen, `bl`→gate — used
by the toolchain rows and config-editor headings), and renders the wordmark
(three iridescent spheres + "yog") seated in the attention strip and the
empty-workspace placeholder.
---
## 12. Module map and line budgets
New dependencies: **none in phase 1.** Percent-decoding, XDG folds, jsonview,
lsof parsing are small pure functions; serde_json covers every machine contract
(`bl --json`, step files, model cache, `ui.json`, `ops.jsonl`). The existing
trait-injection pattern (LockProbe/WriterProbe) is **the template for every
new effect**: lsof runner, bl runner, bz runner, editor shim, clock. Phase 2
(§16.5) embeds `balls`/`brazen`/`lernie` as exact-pinned crates — the only new
dependencies, and the version mechanism. The world epic adds `src/world/*`
(§16.6); its line budgets live in those task specs.
tarpaulin excludes: `src/main.rs`, `src/shell/*`. Budgets include inline
tests; anything projected ≥250 is pre-split at design time, not at the cap.
| Module | Est. lines | Responsibility |
|---|---|---|
| `src/main.rs` (excl.) | 140 | entry, `--editor-apply` dispatch, eframe boot |
| `src/lib.rs` | 160 | module decls, Args (`--workspace` optional initial focus; `--repo` deleted), test_support |
| `src/shell/{mod,navigator,workspace,conv_ball,activity,config_edit,input_bar,inspector,start_pane}.rs` (excl.) | 230+290+215+60+60+290+230+160+140 | interaction glue only (navigator = top bar tabs + conversation panel with the grouping toggle; workspace = conversation center + ball header; conv_ball = the shared ball-badge painters, §3.5 hue; activity = the demoted ops accessory; inspector = tab-strip/controls + VM build; config_edit = config-mode editors; acceptance.rs is the excluded full-window smoke test) |
| `src/app/mod.rs` | 230 | AppModel: snapshots map, ui-state integration, tick |
| `src/app/dirty.rs` | 150 | Change→dirty-root mapping, debounce/sweep scheduling (clock-injected) |
| `src/xdg/mod.rs` | 150 | env folds: balls state root, lernie roots (`LERNIE_HOME` collapse), brazen paths (per-OS via runtime `target_os` param), yog roots; percent-decode |
| `src/projects/mod.rs` | 160 | clone enumeration, nested-delivery detection |
| `src/projects/balls.rs` | 240 | `bl list/show --json` parse, status ladder, groupings, join-state table |
| `src/binding/mod.rs` | 150 | names-root enumeration (§3.1), claimant join (§3.2), worktree formula, workspace classification |
| `src/names/mod.rs` (+ `words.txt` data) | 100 | two-word mint: pure over injected RNG + occupied set; wordlist embedded via `include_str!` |
| `src/ui_state/mod.rs` | 230 | ui.json schema, forgiving load, atomic save, echo-hash, adopt, seen API |
| `src/opslog/{mod,line,rows,live,detached}.rs` | 190+125+180+160+110 | mod = O_APPEND append + tail parse + the sentinels; line = the pure ≤4096 capper (truncation marker, argv clip); rows = OpRow/SurfaceFailure; live = §6's retirement projection (live-failure flags per row) + the §11 activity summary built on it (op + live-error counts, chip label); detached = the per-spawn stderr sink's computed name and its read-time fold into the `-2` row (§8.1, §13.3) |
| `src/attention/mod.rs` | 230 | predicates, seen-gating, rollups, sort ranks, next-attention |
| `src/nav/{mod,tabs,convs,convs/group}.rs` | 40+170+220+125 | §11 altitude-0 view-models: `ws_key`/BoundBall; the workspace tab bar (pin hoist, named tabs, foreign/replay overflow); the conversation list (per-root subtree aggregation, sort, age labels, conversation-root lookup, the derived goal-stamp ball overlay §3.5); `convs/group` = the grouped-by-ball partition (§3.5, stable, unassociated-last) |
| `src/watch/mod.rs` | 210 | WatchSet reconcile, bridge thread, repaint hook |
| `src/fs_watcher/roots.rs` | 150 | per-root-kind allowlists (existing mod.rs unchanged) |
| `src/cli_outbound/{mod,detach}.rs` | 265+80 | mod = parametric binary resolution, current_dir, the piped `run` family; detach = the fire-and-forget spawn and its stderr-sink opening (degrading to null) |
| `src/cli_outbound/tests/{run,stream,spawn}.rs` | ≤250 each | split of the current 274-line tests.rs (pre-graft hygiene) |
| `src/theme/{mod,tests}.rs` | 185+135 | the congeries palette — single colour/visuals authority (§11): lore-named hues, whole-app egui Visuals, shared in-flight pulse, integration-hue map, the §3.5 ball-status hue, wordmark |
| `src/transcript/{mod,render}.rs` | 240+180 | messages enumeration/order/parse; render |
| `src/steps_view/{mod,render,wound}.rs` | 230+195+60 | step inspector VM; render; the §7.3 no-response wound |
| `src/jsonview/mod.rs` | 190 | pure collapsible JSON row tree + render fn |
| `src/inboxview/{mod,render}.rs` | 230+150 | deposit parsing; render |
| `src/budgets/{mod,render}.rs` | 230+65 | Usage fold across subtree steps; spend render |
| `src/files_view/{mod,render,tests}.rs` | 190+180+185 | agent-worktree bounded walk + file preview VM (depth/entry/byte caps, absent-worktree state); read-only tree + selected-file preview render (§11 Files tab) |
| `src/inspector/{mod,tests}.rs` | 100+300 | tested per-agent tab-content dispatch (§11) over the landed render fns (incl. the Files walk/preview) + governing-config view |
| `src/git_tree/probe.rs` | 110 | tri-state traits (moved from fd/lock_probe headers) |
| `src/git_tree/lsof.rs` | 210 | lsof -F parser (pure, tested everywhere) + cfg(macos) shim |
| `src/git_tree/marks.rs` | +50 | add abandoned + notify namespaces |
| `src/actions/verbs.rs` | 180 | message/stop-children/scan/close/unclaim/create/update dispatchers + opslog wiring |
| `src/start/mod.rs` | 210 | plan (pure) + goal composition + step executor |
| `src/config_edit/brazen.rs` | 230 | path fold use, staged bz validation, hash-guard, rename |
| `src/config_edit/lernie_global.rs` | 160 | file enumeration, hash-guard atomic write |
| `src/config_edit/branch.rs` | 240 | config-ref browse, governing-config derivation, edit plan (EDITOR env + argv) |
| `src/config_edit/apply.rs` | 100 | `--editor-apply` copy logic (pure: only drafted files) |
Testing per the house pattern: real-git tempdir fixtures (extended with a
balls-clone-layout and yog-ball-root fixture builder), argv-recorder scripts
under the binary-wide SPAWN_LOCK, fake `/proc` and fake `lsof` output
injection, injected clocks for every debounce/sweep branch, headless
shape-walk for every render fn, forgiving-read cases for every file parser,
`--test-threads=1`, tarpaulin 0.35.2 pinned, 100%.
### 12.1 Code style is governed by Rust Bootstrap v3 (see AGENTS.md)
DESIGN is the *architecture* authority; **code style is governed by the "Rust
Bootstrap v3" standard, whose yog-adapted, flat-numbered rules live in
`AGENTS.md`** at the repo root, machine-enforced by `rules/*.yml` (pinned
ast-grep 0.44.1), the clippy manifest (`Cargo.toml [lints]`, pedantic=deny), and
`cargo-deny` (`deny.toml`). `make check` runs the whole gate; the pre-commit
hook and CI mirror it. Read AGENTS.md before writing code — this note only
records that the standard exists and which parts of it yog deliberately skips.
**Surfaced skips (deliberate, not oversights — reasons in AGENTS.md/rules):**
- **No workspace/crates split** — yog is a single published binary crate; the
module tree (§12) plus the 300-line cap already contain complexity, and a
split would fight the 100% coverage floor (Bootstrap rule 11 adapted).
- **No musl static target** — yog is a native GL desktop app needing dynamic
platform libs; `rust-toolchain.toml` carries no `targets`. The TLS bans stay
in `deny.toml` regardless.
- **`unsafe` confined, not `forbid`** — one irreducible SIGTERM syscall lives in
`src/cli_outbound/sys.rs`, pinned there by `rules/unsafe-outside-sys.yml`;
`forbid` is unoverridable and reaches tests (Bootstrap rule 3 adapted).
- **No `anyhow`** — `main.rs` is a thin entry with no error-plumbing layer;
`thiserror` already carries the error enums (rule 10 adapted).
- **Async rules vacuous** — no async/tokio today; the async rule (8) is installed
but matches nothing.
- **No pre-commit framework / nextest / bacon / sccache / mold** — one hook
system (`.githooks`) carries the same checks; `cargo test` + pinned tarpaulin
remain the runners.
---
## 13. Deliberate interpretations (user-vetoable)
These are deliberate readings of the requirement, flagged for veto rather than
silently assumed.
### 13.0 The rule is no *state* in RAM, not nothing in RAM
The user clarified the durability requirement: **"no STATE in RAM" — not
"nothing in RAM"; views are fine in RAM.** This is the primary rule the rest
of §13 applies. A *view* is which data you look at and how the window is
arranged: focus, selection, scroll position, which nodes or panel sections you
have folded, live window geometry. *State* (durable data) is a user assertion
with no other authoritative home: the seen watermarks, pins, the last-used
identity. State must replicate so two instances agree (§4.1); a view may live
in RAM and be lost on crash. §13.1's focus/scroll ruling was the first
statement of this rule and is now just its leading instance. The persisted-view
keys (`collapsed`, `show_internal` — §4.1) are the allowed converse: a view is
*permitted* to be kept durable for convenience, but is never *required* to
replicate the way state is.
### 13.1 Live focus/selection and scroll are per-instance viewport ephemera
The requirement: "effectively nothing is allowed to exist in only-RAM… two
yog instances side-by-side faithfully replicate the same data, excepting
unsubmitted user inputs." **This design interprets the requirement's motive as
data durability and replication: no *data* is RAM-only, and both instances
replicate the same *data*. Live focus/selection and scroll are *which data
you look at*, not data:** they lose nothing on crash (focus re-derives
deterministically at startup — next-attention, else first; scroll re-anchors),
and mirroring them makes side-by-side instances actively hostile — every
click in one yanks the other's view, which two of three judges found defeats
the point of running two instances. `ui.json` therefore keeps the
genuinely-converging *state* — the four seen watermarks, pins,
`identity_last_used` — plus the persisted *views* kept for convenience
(`collapsed`, `show_internal`; §13.0).
Everything the operator would call data — including acknowledgements, which a
zero-durable-state stance structurally cannot represent — is durable and
converges. **Veto path:** if the strict-literal reading is wanted (mirrored
focus/scroll), add `focus`/`scroll` keys back into `ui.json` with content-
anchor scroll representation; the write/adopt machinery already supports it.
### 13.3 Detached prompt defers error immediacy to disk
`lernie prompt` is spawned detached (§8.1), so a prompt-time failure surfaces
from disk rather than from a pipe yog holds. This is the
robustness-over-immediacy trade: yog's death must never kill a running loop,
and the short piped steps (`bl claim`, `lernie new`) still surface their errors
directly in `ops.jsonl`.
**Amended (bl-a649): deferred to disk, not discarded.** The original
"stdio→null" cost more than immediacy — a driver that *launched* and then died
(the brazen 0.0.2/0.0.3 version-skew refusal, 2026-07-22) left exit `-2` and an
empty `stderr`, byte-identical to a clean launch, so the operator saw a prompt
that "does nothing" and yog had nothing to render. **stdin and stdout stay
null; stderr goes to a per-spawn sink file** (§4.2 / §5.2:
`$XDG_STATE_HOME/yog/detached/<ts>-<workspace>.err`). The sink is a *file*, not
a pipe: yog holds no fd, so the §8.1 lifetime guarantee is untouched — the child
outlives yog and keeps writing to an inode nobody must be alive to drain. The
ops row's `stderr` is folded in from that file **at read time**, on the ops
sweep (§7.2), and a non-empty capture makes the row a rendered failure — the
existing §7.3 machinery, fed a fact it was previously denied. Only the
*immediacy* is still deferred: the death is visible on the next sweep, not at
fire.
**The conversation surface owes its own half (bl-7f2e).** The sink answers
*why* on the ops surface; it cannot answer *where*, because a driver that dies
**mid-life** has already written a step, and that step — `response.json` at zero
bytes, no `meta.json` — read as a quiet one (§4.4 framing has no vocabulary for
"produced nothing", so it says `Killed`, the same ash badge a mid-stream kill
gets). The **no-response wound** (§7.3, §11) is that vocabulary: derived at read
time from the two files plus the agent's §3.5 liveness, stored nowhere, rendered
in ichor beside the step and bannered at Altitude 1. The division is the one §6
already draws — the conversation surface says *this died*, the ops surface says
*what it said on the way out* — and the wound's own text points across it.
---
## 14. Rejections
Recorded so they are not relitigated:
- **`toml_edit` or any TOML/YAML dependency** — the brazen structured view is
`bz --dump-config`; balls data is `bl list --json`; a yog-side parse is a
second authority that drifts.
- **flock-acquire liveness probing** — perturbs the substrate: a transient
yog hold makes a `lernie message` writer skip launching a driver and strand
the deposit (I8).
- **Direct `tasks/*.md` parsing** — re-implements bl's frontmatter parser and
status/admits ladder; `bl list --json` is the sanctioned bedrock contract.
- **Any yog-side ball↔workspace registry file** — drifts and needs its own
merge discipline. The claimant field is not a registry: it is balls' own
first-class metadata under balls' own merge discipline, which is why binding
lives there (§3.2).
- **Path-convention binding**
(`yog/balls/<mirrored-project-path>/<ball-id>/`, the original §3.1, with its
verbatim-vs-percent-encoding debate) — *superseded by the claimant join
(§3.2)*: a location is congenital and immutable where the requirement is
late-mutable, and it hard-coded 1:1 where agents author and pick up several
balls per workspace.
- **Zero-durable-state stance** — structurally cannot represent notification
acknowledgement (no lernie ack verb exists; marks are level-triggered) and
violates the governing requirement.
- **Ops-log-free design** — gate/close/scan output would be RAM-only and
non-convergent; the current stderr-and-drop hole would persist.
- **The word "session" in code or UI** — lernie bans it for cause; the
concept dissolves (start = claim+new+prompt, the unit is the workspace, the
list is workspace enumeration).
- **Daemon/socket/IPC between instances** — disk is the bus (every
substrate's religion).
- **Linking lernie/brazen crates *as a blanket rule*** — *superseded by
§16.5.* The original stance (CLI + disk is the whole contract; brazen's
library API is explicitly unstable) is now the *phase-1* posture only; the
phase-2 end state embeds `balls`/`brazen`/`lernie` as **exact-pinned** crates.
Instability is answered by exact-pinning (brazen's own README posture), and
process semantics stay non-negotiable regardless of linking — drivers are
processes holding flocks; plugin dispatch stays subprocess (§16.5).
- **A reproduction hatch at the wound** (a "re-run this prompt with stderr
attached" button beside a §7.3 no-response step, running `yog exec lernie
prompt …`) — *rejected on the evidence, not the ergonomics*. `yog exec` is how
the bl-8e07 skew was diagnosed, but that was **before** the detached child's
stderr sink (§8.1/§13.3): the driver's own words are now captured and rendered
on the ops surface, so the button would ask the operator to re-create a fact
yog already holds — and would fire a *second* driver at a conversation that
already has one, with a goal yog would have to re-compose. The wound instead
names where the cause lives, and the two surfaces stay disjoint the way §6
keeps them: the conversation says *this died*, the ops trail says *why*.
`yog exec` stays exactly what §8.4 makes it — a hatch for a human at a shell,
not a verb yog fires at itself.
- **Async runtime** — the eframe loop + notify threads + repaint scheduling
is the entire concurrency story.
- **yog aping lernie's seeding** — the nested `LERNIE_HOME` is seeded by
lernie's own bootstrap verb (**`lernie prime`**, landed upstream as
bl-6d83: `LERNIE_HOME=<dir> lernie prime`, seed-if-absent, idempotent,
silent on success; `models.yaml` at the home root is the seeded marker),
never by yog reproducing lernie's seed logic; a second seeder drifts from
the first (§16.2).
- **Overriding `XDG_DATA_HOME` / `XDG_CACHE_HOME` in the world env** —
`$XDG_DATA_HOME` is the world's *anchor* (the world lives under
`$XDG_DATA_HOME/yog`; overriding it recurses), and leaving both ambient is
*exactly* what shares brazen's credentials and model cache with the ambient
world — creds are secrets not schema-fragile state, and the cache is
regenerable and forgiving (§16.2).
- **yog shipping or installing tool binaries as the end state** — a yog-owned
pinned bin dir with `cli_outbound` preferring it is phase-1 scaffolding,
retired wholesale by phase 2 (§16.4). In the end state the version mechanism
is the exact-pinned crate, and the only binaries are the user's ambient CLIs
(orthogonal to yog) and the embedded-crate agent-tool shims (§16.4).
---
## 15. Implementation epic
Ordered, bl-sized tasks; each lands independently through the pre-commit gate
(fmt, clippy -D warnings, 300-line cap incl. inline tests, tarpaulin 100%
pinned 0.35.2) via the worktree/merge/no-ff flow. Cross-cutting rules baked
in: `cli_outbound/tests.rs` splits **before** cli_outbound grows (Y1);
`target_os` is a runtime parameter in per-OS path folds (Y2); all timing is
clock-injected (Y6); every new effect is trait-injected on the
LockProbe/WriterProbe template.
Already landed (do not re-file): **bl-a86c** (ETXTBSY SPAWN_LOCK fix),
**bl-fed1** (rebrand lernie-ui-egui → yog), **bl-d5f3** (delivery wiring, CI
linux + macos-arm64, crates.io publish wiring). Already filed and open:
**bl-592b** (macOS test portability: /tmp canonicalization + FSEvents timing —
the macOS CI job) — slots into M5 beside Y21.
### M1 — the existing single-workspace view goes live
**Y1 — Split cli_outbound/tests.rs into submodule files.**
Scope: `src/cli_outbound/tests.rs` is at 274/300 and every subsequent
cli_outbound extension adds tests there. Split it into
`src/cli_outbound/tests/{run,stream,spawn}.rs` (+ tiny `mod.rs`), preserving
every test verbatim and the SPAWN_LOCK/ENV_LOCK usage. Pure refactor, zero
behavior change, green gate.
Deps: none. Files: `src/cli_outbound/tests/*` (≤250 each).
**Y2 — xdg module: env-snapshot path folds with runtime-injected target_os.**
Scope: one module owning every path derivation from an injected env snapshot:
balls state root, lernie config/data roots including the `LERNIE_HOME`
collapse, brazen config path fold (`$BRAZEN_CONFIG` > XDG > `~/.config`),
brazen per-OS credentials/cache dirs with **`target_os` passed as a runtime
parameter** (so the macOS branch is covered by Linux tarpaulin), yog data/state
roots, and percent-decode (hand-rolled, ~25 lines). Table-driven tests for
every fold and both OS branches. No env reads anywhere else in the crate.
Deps: none. Files: `src/xdg/mod.rs` (~150).
**Y3 — cli_outbound generalization: parametric binaries, current_dir, detached spawn.**
Scope: binary resolution parametric over env var (`LERNIE_BINARY`,
`BL_BINARY`, `BZ_BINARY`, PATH-name defaults), `current_dir` support on
`run`, and a detached-spawn mode (setsid via libc, stdio→null, no retained
`Stream` — the caller gets only a spawn result). Recorder-script tests for
cwd/env propagation and a detach test proving the child survives the parent.
Deps: Y1. Files: `src/cli_outbound/mod.rs` (→ ~240), tests in the Y1 split.
**Y4 — Probe tri-state: {Held, Free, Unknown}.**
Scope: change `LockProbe`/`WriterProbe` to return a tri-state
`Probe::{Held, Free, Unknown}`; Linux procfs impls keep behavior identical
(never Unknown); `classify` maps Unknown to an explicit uncertain state that
renders as a "live?" badge instead of a false definite. Move the trait
declarations to `src/git_tree/probe.rs`.
Deps: none. Files: `src/git_tree/probe.rs` (~110), `state.rs`, `render.rs`
touch-ups.
**Y5 — fs_watcher root-kind allowlists.**
Scope: parameterize the hardcoded single allowlist into per-root-kind
allowlists (Workspace, BallRoot, WorkspacesRoot, BallsClones, BrazenConfig,
LernieConfig, YogState) per DESIGN §7.1, with the balls per-clone `log` file
explicitly filtered. Existing `fs_watcher/mod.rs` behavior unchanged for the
Workspace kind.
Deps: none. Files: `src/fs_watcher/roots.rs` (~150).
**Y6 — Watch registry + repaint bridge + clock-injected sweeps; wire the single-workspace view live.**
Scope: `WatchSet` owning one Watcher per root with reconcile (desired vs live
watchers); a bridge thread draining notify channels into a `Mutex<DirtySet>`
and calling `request_repaint()`; frame-side drain → re-derive dirty roots with
a 100 ms coalescing debounce; the 2 s cheap sweep (enumerations + WatchSet
reconcile + **targeted liveness re-probe of only Live/InFlight agents**) and
15 s full sweep — all timing through an injected `Clock` trait. Wire the
existing single-workspace view to re-render on disk change. **Milestone M1:
the current UI finally re-renders live.**
Deps: Y4, Y5. Files: `src/watch/mod.rs` (~210), `src/app/dirty.rs` (~150),
shell wiring.
### M2 — browse everything
**Y7 — binding module: yog ball root arithmetic + workspace enumeration.**
Scope: `workspace_path(yog_data_root, project, ball_id)` and its inverse
(walk `$XDG_DATA_HOME/yog/balls/**` for `repo.git` dirs; last segment = ball
id, rest = project path); enumeration/classification over the three roots
(bound / ad-hoc / replay); the bl-delivery work-worktree formula. Pure
functions over injected paths; fixture builder for the yog ball root.
Deps: Y2. Files: `src/binding/mod.rs` (~150).
**Y8 — ui_state document: ui.json load/save/echo/adopt + seen API.**
Scope: the §4.1 schema (seen watermarks ×4 kinds, pinned, collapsed,
show_internal, identity_last_used), forgiving load (missing/corrupt = default,
unknown keys preserved), debounced atomic save (temp-in-dir + rename),
echo-hash suppression, wholesale adopt on external change, and the
seen-watermark API (record evidence oids on focus). Startup focus derivation
(next-attention else first) as a pure function.
Deps: Y2. Files: `src/ui_state/mod.rs` (~230).
**Y9 — marks completion + inbox view + budgets fold.**
Scope: extend `git_tree/marks.rs` to all four `refs/lernie/*` namespaces
(add abandoned + notify, with oids exposed for watermark comparison); inbox
deposit parsing (`---from/deposited_at/epitaph/terminal_ref---` frontmatter)
as a view-model; budget-spent fold over Usage events across
`steps/<root>/` + `steps/<root>-*/` response.json files.
Deps: Y6. Files: `src/git_tree/marks.rs` (+50), `src/inboxview/mod.rs`
(~130), `src/budgets/mod.rs` (~170).
**Y10 — attention module: predicates, rollups, roster sort.**
Scope: `attention(agent)` per DESIGN §6 (unacked notify / unacked stop
without abandoned / unacked budget / unacked conflicted, each oid-gated on
ui.json seen; plus pending-mail-with-lock-Free, not seen-gated); workspace
rollups; strip totals; jump-to-next-attention; roster sort
attention > running > idle then derived order. Pure over injected snapshots.
Deps: Y8, Y9. Files: `src/attention/mod.rs` (~230).
**Y11 — multi-workspace AppModel + navigator + attention strip.**
Scope: AppModel holding the snapshot map (`HashMap<PathBuf, GitTree>` +
workspace classification), fed by the WatchSet; the roster panel (pinned →
projects → ad-hoc → replays, ball ids from path arithmetic only — no bl calls
yet), the attention strip with totals and jump; per-instance focus with
startup derivation; pins/collapsed durable via ui_state. `--repo` deleted;
optional `--workspace` initial-focus flag. **Milestone M2 shell: browse every
workspace, attention-sorted, two instances converging on seen/pins.**
Deps: Y6, Y7, Y8, Y10. Files: `src/app/mod.rs` (~230),
`src/shell/{navigator,workspace}.rs` (excl.), `src/lib.rs`.
**Y12 — transcript view-model + render.**
Scope: `messages/NNN-<origin>.*` enumeration and ordering (order lives in the
filename), origin classification (`.md` delivery / `NNN-<model>.json` content
blocks / `NNN-tool.json` tool_result), tool-in-progress derivation (committed
tool_use without tool_result), live streaming tail merged from the open
response.json, Raw toggle. Headless shape-walk tests.
Deps: Y6. Files: `src/transcript/{mod,render}.rs` (~240+180).
**Y13 — jsonview + steps inspector.**
Scope: `jsonview` — a pure hand-rolled collapsible `serde_json::Value → row
tree` with a thin render fn (the uniform "every byte inspectable" widget);
the steps inspector VM: per-step framing status, meta/request/response/
staging drill-in, per-tool input/output, attempts and token counts.
**Milestone M2 complete: browse everything.**
Deps: Y12. Files: `src/jsonview/mod.rs` (~190),
`src/steps_view/{mod,render,wound}.rs` (~230+195+60).
### M3 — ball-to-loop lifecycle
**Y14 — projects + balls view-models + join-state table.**
Scope: clone enumeration with nested-delivery detection (decoded-path prefix
match, "internal" toggle); `bl list --json` / `bl show <id> --json` invocation
(cwd = project) and parse; the derived status ladder; the §3.5 join-state
classification joining balls to enumerated workspaces (detached,
claimed-elsewhere, delivered, orphaned-project); ball detail (full bedrock
frontmatter + body); closed listing on demand. Roster gains ball rows and
join badges.
Deps: Y3, Y7, Y11. Files: `src/projects/mod.rs` (~160),
`src/projects/balls.rs` (~240).
**Y15 — ops.jsonl: durable action-outcome log.**
Scope: O_APPEND writer producing one JSON line per completed CLI action
`{ts, argv, cwd, exit, stdout, stderr}`, hard-capped at 4096 bytes
(PIPE_BUF) with stdout/stderr truncation and a `"truncated":true` marker;
tail parser (forgiving per line); fs-watched so both instances render the
shared history; ops pane VM.
Deps: Y2. Files: `src/opslog/mod.rs` (~150).
**Y16 — action verbs: message/stop/scan/close/unclaim/create/update + opslog wiring.**
Scope: dispatchers for `lernie message/stop/scan` and
`bl close/unclaim/create/update` (exact argv per DESIGN §8.2, cwd = project
for bl), each short-piped with its outcome appended to ops.jsonl; enablement
predicates extending the existing `actions` pattern (Stop iff Live/InFlight;
Close iff claimed ∧ bound; Message always — it is the resume gesture).
Replaces the stderr-and-drop `spawn_detached` for short verbs.
Deps: Y3, Y14, Y15. Files: `src/actions/verbs.rs` (~180),
`src/actions/mod.rs` growth, `src/shell/input_bar.rs` (excl.).
**Y17 — start flow: claim + new + editable goal composer + detached prompt.**
Scope: the composite verb as a pure planner (`start::plan`) + step executor:
optional `bl create`, `bl claim <id> --as <identity>` (prefilled from
identity_last_used, default `$USER`; stdout cross-checked against the
worktree formula), `mkdir -p` + `lernie new <bound-path>` (skipped if
existing), then the **editable goal composer** prefilled with ball title +
body + absolute work-worktree preamble — operator edits before
`lernie prompt` fires **detached** (setsid, stdio→null, stdout deliberately
unread — the new root materializes in the watched repo). Per-step outcomes in
ops.jsonl; every step idempotent-or-convergent. **Milestone M3: ball to
running loop from the UI.**
Deps: Y14, Y16. Files: `src/start/mod.rs` (~210), shell wiring.
### M4 — config editing
**Y18 — brazen config editor: raw text + bz validation + hash guard.**
Scope: locate via the Y2 fold; raw-TOML text editor; Apply = stage to
`.config.toml.yog-tmp-<pid>` in the destination dir →
`bz --config <temp> --dump-config` gate (non-zero exit blocks with stderr,
draft kept) → content-hash guard against the loaded snapshot → atomic rename.
Read-only effective pane (`bz --dump-config` verbatim) + built-in-rows hint +
credential-presence booleans + model-cache display with
`bz --list-models --provider <row> --json` refresh.
Deps: Y2, Y3, Y15. Files: `src/config_edit/brazen.rs` (~230),
`src/shell/config.rs` (excl.).
**Y19 — lernie global config editor.**
Scope: enumerate and edit `<config-root>/models.yaml` and
`workflows/*.yaml` as raw text with the same hash-guard + temp-in-dir +
rename pipe (no validator exists; no YAML dep); new-workflow = new file name;
copy-from-existing affordance.
Deps: Y18 (shared editor widget). Files:
`src/config_edit/lernie_global.rs` (~160).
**Y20 — config-branch browse + governing-config derivation.**
Scope: read-only config surface: branch list via
`for-each-ref refs/heads/config/`, file trees and contents via
`git show <ref>:<path>` through the env-scrubbed cmd wrapper; per-agent
governing config = nearest ancestor of the agent tip reachable from any
`config/*` ref (merge-base fold), rendered as "policy frozen at
`<short-oid>`" in the inspector Config tab.
Deps: Y13. Files: `src/config_edit/branch.rs` (browse half, ~140 of 240).
**Y21 — config-branch edit via the editor shim.**
Scope: **task 0: source-read lernie's exact `$EDITOR` invocation shape**
(how the checkout path passes through `sh -c`) and record the finding in this
task before building. Then: staging dir `$XDG_STATE_HOME/yog/stage/<nonce>/`;
drive `lernie config <ws> <name>` (with `--from`/`--orphan` options) with
`EDITOR="<yog-binary> --editor-apply"` and `YOG_EDIT_SRC=<staging>` in env;
the shim reads the checkout from argv and copies **only the drafted files**
(never a full-tree sync — lernie refreshes `descriptions/**` at commit and
must not be clobbered); pure, fully-covered copy fn; sweep of stale staging
dirs. **Milestone M4: all three config surfaces editable.**
Deps: Y3, Y20. Files: `src/config_edit/branch.rs` (edit half),
`src/config_edit/apply.rs` (~100), `src/main.rs` dispatch.
### M5 — portability + polish
**Y22 — macOS lsof probe: pure parser + cfg shim + TTL cache.**
Scope: `lsof -F` output parser as a pure, platform-independent function
implementing both probe traits' evidence extraction (writer filter from the
fd access-mode field), 100% covered on Linux via recorder-fixture inputs and
a fake `lsof` on PATH; the ~20-line `#[cfg(target_os = "macos")]` spawn shim
selects it at construction; `Unknown` on lsof absence/failure (renders the
Y4 uncertainty badge); 2 s TTL cache refreshed eagerly on agent-touching
events and swept only for Live/InFlight agents. Note: macOS *test* failures
(path canonicalization, FSEvents timing) are bl-592b, a sibling task.
Deps: Y4. Files: `src/git_tree/lsof.rs` (~210).
**Y23 — polish wave: replays, keyboard nav, docs.**
Scope: replay workspaces section verified end-to-end (`replays/*` render
read-only); nested-clone "internal" toggle polish; keyboard navigation
(roster ↑/↓, digit tabs); README architecture section rewritten to point at
docs/DESIGN.md; `make run` without REPO.
Deps: Y17, Y21. Files: shell + docs touches.
**Y24 — (optional, only if measured) incremental streaming refresh.**
Scope: replace whole-root rebuild with a streaming-text-only re-read on
`steps/**` events for the focused workspace, if profiling shows the 100 ms
debounced rebuild costing frames. Correctness is already owned by Y6; this
is purely an optimization and lands only with a measurement in the task.
Deps: Y6. Files: `src/app/dirty.rs`, `src/git_tree/streaming.rs`.
### M6 — conversation-first rework (the §3 claimant-join amendment)
Y7/Y11/Y14/Y17 landed against the superseded path-convention §3.1; this wave
reworks them in place. Same gate, same worktree/merge flow. **The acceptance
ladder for this wave is `docs/STORIES.md`** (paved-path stories S0–S9, each
with its integration tests); a rung is done when its story tests pass against
the fake substrate and the flow works against the real one.
**Z1 — names module: embedded wordlist + mint.**
Scope: `src/names/` — an embedded wordlist (`words.txt` data file via
`include_str!`; source and license recorded in the task), the two-word
hyphenated mint as a pure function over an injected RNG and an occupied-name
set, and the occupied-set assembly (readdir of the names root + claimants
from `bl list --json` across enumerated projects). Table-driven tests incl.
collision retry and pool-exhaustion error.
Deps: none. Files: `src/names/mod.rs` (~100), `src/names/words.txt` (data).
**Z2 — binding rework: names root + claimant join.**
Scope: replace `src/binding`'s path arithmetic with §3.1 enumeration (flat
readdir of `$XDG_DATA_HOME/yog/workspaces/`, leaf = name; foreign + replay
classification unchanged) and the §3.2 claimant join; rework the §3.5
join-state table and the roster grouping (balls group under their bound
workspace); migrate the watch root (BallRoot → NamesRoot, flat, §7.1).
Delete the mirrored-path code.
Deps: Z1. Files: `src/binding/mod.rs`, `src/projects/balls.rs`,
`src/watch/mod.rs`, `src/app/mod.rs` touches.
**Z3 — start flow: generalize `start::plan` to §3.4's axes.**
Scope: target-workspace resolution (the focused workspace; zero workspaces →
mint + `lernie new <root>/<name>` — the bootstrap as the empty case; the
explicit + New workspace verb runs the same pair deliberately);
`YOG_NAME=<name>` layered on workspace-scoped spawns (§8); the ball rung
inserting create/claim `--as <name>` with the resume-on-already-claimed
convergence (§8.1); per-rung composer prefills (harness-stamped identity
preamble with pre-mint preview, §3.3 as amended — phase-1 interim stamp
instruction included; target preamble for path; ball + worktree for ball);
per-rung driver cwd (`~` / the path / the worktree). **Two standing defects
die here:** `prepare()` re-implements the sequence while `start::plan` sits
dead — §8.1 mandates the planner as the one source and the executor runs its
output; and the shipped step order ran `bl create`/`claim` *before* the seed
(the §8.1 amendment's load-bearing order is seed → `new` → bl mutations —
the orphaned-claim wound).
Deps: Z2. Files: `src/start/mod.rs`, `src/start/exec.rs`, shell start-pane
wiring.
**Z4 — assign / move / release verbs + + New affordances.**
Scope: the §8.2 additions (assign = `bl claim <id> --as <name>`, move =
unclaim + claim, release = unclaim) with enablement predicates from the join
state; the roster's + New workspace verb (the explicit mint, §11) and the
composer's payload affordances (bare / path / ball, §3.4).
Deps: Z2, Z3. Files: `src/actions/verbs.rs`, shell roster wiring.
**Z5 — failed actions are rendered facts.**
Scope: the §7.3 failed-action row over the amended §4.2 (*attempted*, not
completed: synthetic lines for spawn failures — deleting `verbs.rs`'s
missing-binary un-logged carve-out — and `["yog-step",<name>]` lines for
non-spawn step failures). Ops-pane rows expand to the full entry (argv, cwd,
exit, stderr; today `OpRow` drops everything but ts/argv/exit); start and
short-verb failures render at their originating surface in ichor red with
argv + stderr tail (the surface holds its last failure as the §5.3
whitelisted RAM item; the durable fact is the ops line); every `eprintln!`
in `src/shell/` is deleted (STORIES INV-2's mechanized form: the grep count
is zero). The proven wound: a failing seed step printed to stderr and the
composer silently never opened.
Deps: none (parallel to Z2). Files: `src/opslog/mod.rs`,
`src/actions/verbs.rs`, `src/shell/*` touches, view-model modules for the
failure rows. Tracked: **bl-7687**.
**Z6 — capability gate (W5 as amended).**
Scope: §16.6 W5's capability probe — the normative driven-verb list probed
per verb via `--help`, verdicts naming the missing verb + remediation
command, `pub` verdict types, and gate consultation **in the dispatch
layer**: a `start::prepare` precondition and an `actions::verbs` check, so
every mutating path refuses identically (today only `input_bar` checks
`permits()`, and shell-glue checks are untestable — tarpaulin excludes
`src/shell/`). Pre-split: `src/world/toolgate/probe.rs`.
Deps: Z3 (the precondition lands in the reworked `prepare()`; both tasks
edit `src/start/run.rs`). Files: `src/world/toolgate.rs`,
`src/world/toolgate/probe.rs`, `src/start/run.rs`, `src/actions/verbs.rs`,
`src/shell/toolchain.rs` touch. Tracked: **bl-e324**.
**Z7 — story-test fixture (the fake substrate).**
Scope: a `tests/` recorder fixture — fake `lernie`/`bl`/`bz` script binaries
recording argv+env+cwd with canned stdout/exit per verb, injected as
`Cli::new(path)` / `Deps{…}` at the dispatch API (the actual
`editor_roundtrip.rs` idiom; the `*_BINARY` env vars stay production wiring,
covered by the existing `resolve_with` unit tests — no process-global env
mutation under the parallel test runner) — and the story tests green-able
against the **current** dispatch API: S1-T2, S1-T3, INV-3, and S0-T2's
seed-skip half via `seed::ensure_seeded`. Every other story test lands
red-first *inside the worktree of the task that turns it green* (written
first, red in-worktree, green at close — the precommit gate forbids landing
red on main); **the test→enabling-task map lives in STORIES.md alone**
(single source — do not restate it here).
Deps: none. Files: `tests/support/*`, `tests/stories_*.rs`. Tracked:
**bl-412d**.
**Z8 — the login flow (§8.3 as amended).**
Scope: the streamed-piped `bz --login --provider <row>` runner (provider
rows from the §5.1 #20/#21 config derivations), its live-line view-model
(covered; shell paints it), the exit-non-zero fallback (show the exact
command), and the **detection affordance**: an auth-failed step in the
already-derived steps/response.json facts (§5.1 #10/#13, per §13.3 a
prompt-time failure surfaces as derived agent state) renders Login one
click away, beside the failed step and in the toolchain pane.
Deps: Z5 (failure rows), Z6 (pane verdicts). Files: `src/cli_outbound/`
streamed-spawn addition, `src/world/toolgate.rs` or a small
`src/login/mod.rs`, view-model module. Tracked: **bl-02bf**.
**Z9 — top-level rework: workspace tabs + conversation-first center.**
Scope: §11 as rewritten — the workspace tab bar under the top right (named
tabs + + mint + foreign/replay overflow), the conversation list replacing the
workspace roster (one row per root agent; attention > running > recency), the
conversation-centred center (transcript first, descent tree only with
children, inline Login on an auth-failed latest step), the ops pane demoted to
the collapsed activity accessory, and the composer targeting selection
(message) or new conversation (prompt) on Enter. New VMs: `nav::tabs`,
`nav::convs`, the `opslog` activity summary, `login::latest_step_auth_failed`.
S0/S1 gestures unchanged. Deps: Z1–Z8 (landed). Tracked: **bl-abd9**.
---
## 16. The yog world
yog owns a **world** — a nested substrate environment it composes under its own
data root and hands to every child it spawns. This section is the world's
normative home; §0, §1, §2 (I1/I2/I7), §3, §5.1, §8, §12, and §14 are amended
to point here.
### 16.1 Application, not a layer
The naïve framing has yog as a thin viewer over the user's ambient
`bl`/`lernie`/`bz` state. It inverts: yog composes its own nested world
(§16.2), and the substrate state yog drives is *yog's*, under yog's data root.
Playing on top of the user's *direct* tool usage stays possible — the balls
store branch is shared by default (§16.3) and redirection knobs tune the
overlap — so **compatibility with an ambient workflow is the user's decision,
not a structural given.** The world encapsulates lernie, balls, and brazen
state so completely that yog and the human's own shell never collide unless the
user chooses overlap.
**Rejected:** yog as a pure ambient overlay (no nested state) — the coordination
point would be the user's live working tree and clones, so every yog action
would perturb the user's own `bl`/`lernie` work; encapsulation is what makes yog
safe to run beside a working human.
### 16.2 The composed world environment
yog reads the ambient environment once (`xdg::Env::from_env`), computes its
data-root anchor `$XDG_DATA_HOME/yog`, and composes **one** world `Env` — the
ambient snapshot plus a fixed override set — used both to derive every substrate
path yog reads *and* to spawn every child. Overridden, to nest:
| Var | World value | Nests |
|---|---|---|
| `LERNIE_HOME` | `<yog-data-root>/world/lernie` | lernie config **and** data (the `Env::lernie_home` collapse) |
| `XDG_STATE_HOME` | `<yog-data-root>/world/state` | balls clones/worktrees/op-logs **and** yog's own `ui.json`/`ops.jsonl` |
Left ambient, deliberately:
| Var | Consequence |
|---|---|
| `XDG_DATA_HOME` | the **anchor** — the world lives under `$XDG_DATA_HOME/yog`; overriding it would recurse. Brazen credentials (`$XDG_DATA_HOME/brazen/credentials`) stay **shared** with the ambient world |
| `XDG_CACHE_HOME` | brazen's model cache (`$XDG_CACHE_HOME/brazen/models`) stays **shared** |
| `BRAZEN_CONFIG` | brazen's config (`$BRAZEN_CONFIG` else `$XDG_CONFIG_HOME/brazen/config.toml`) stays **shared** — in phase 1 yog spawns the one host `bz` binary, so there is no version skew for a nested config to protect against, and the provider rows are credential-adjacent (oauth endpoints/client ids for the credentials already deliberately shared). *Amended after live proof (2026-07-21):* the nested path named a file nothing ever creates, so `bz` fell back to built-in defaults and every in-app conversation died at auth while the same prompt against the ambient config went green. **Phase-2 revisit:** re-nest only if the embedded-crate phase (§16.5) reintroduces config-schema skew |
The sharing is the decision, not an accident of which var each fold reads:
**credentials are secrets, not schema-fragile state; the model cache is
regenerable and forgiving** (brazen's own read stance); **and the config is
read by the one shared host `bz`, so nesting it would only orphan the
credentials it points at** — all three are reused from the ambient world;
everything version-fragile (lernie's home, balls' store layout) is nested.
Because `$XDG_DATA_HOME` is *not* overridden, re-deriving the anchor through
the world `Env` yields the same path — the composition is self-consistent, one
lens, no bootstrap special case.
**I1/I2 hold against the nested disk:** every §5.1 derivation resolves through
the world `Env`, so "disk is the app" means yog's world; two instances compose
the identical world from the identical ambient env and converge exactly as
before. **Severability widens (§3.1):** one `rm -rf $XDG_DATA_HOME/yog` erases
the entire world — nested lernie home, nested balls state, and yog's own
artifacts — and leaves the *ambient* substrates untouched.
The `LERNIE_HOME` seed is written by lernie's own bootstrap verb (upstream
lernie **bl-6d83**, in flight), never by yog — yog composes the env and calls
the verb; it never apes lernie's seeding (§14). **Diligence (task 0, W1/W3):**
confirm bl-delivery derives its worktree territory from *its own*
`$XDG_STATE_HOME`, so a nested child `bl` lands clones and worktrees in the
nested state root; record the finding in the task.
### 16.3 The balls store branch: shared by default, with a no-marks knob
The nested balls clone tracks the project's **shared `balls/tasks` store branch
by default**. The store branch is the stable contract, and sharing it is the
coordination point with the user's own `bl`: a ball yog claims or closes is
visible to the ambient `bl list`, and vice versa. Single source of truth — the
branch schema — with two clones (nested and ambient) reading it.
A per-project **no-marks knob** serves operators who want yog without leaving
marks in the shared store: **stealth** (`bl conf task-remote none` — a
local-only store) and/or a **custom task-branch**, both wired through **bl's own
config surface** and exposed in yog's UI as a project setting that *drives
`bl conf`* — never a yog config file (severability: the policy lives in balls'
capability, not yog's core). The trade is rendered at the knob: **stealth makes
yog's claims invisible to the ambient `bl list`** — the user trades coordination
for invisibility.
### 16.4 Binaries are agent tools, not yog's payload
**yog ships and installs no tool binaries in the end state.** Binaries matter
only as the plane on which lernie's worker agents drive tools by bash — an
essential surface — and there they split cleanly:
- **Ambient-world work** uses the user's own installed CLIs. It is orthogonal
to yog — not yog's concern, not yog's to pin.
- **Embedded-world work** — an agent operating on yog's nested state, mostly
balls (e.g. closing the ball yog claimed in the nested state root) — needs a
tool that (a) resolves the **nested** clone/worktree paths and (b) shares
yog's **exact** balls implementation and state view. An ambient `bl` fails
(a): it reads a *different* `$XDG_STATE_HOME` and computes the wrong
clone/worktree paths. No host binary guarantees (b): none is built from yog's
pinned crate.
The correctness argument, not mere convenience, drives the mechanism. In
**phase 1**, (a) is met by env inheritance — yog spawns the driver in the world
env (§8), so a host `bl` an agent runs inherits the nested `$XDG_STATE_HOME` and
computes the right paths — while (b) is only softly met and is what the phase-1
**capability gate** guards (W5 as amended: per-driven-verb `--help` probes —
presence gating shipped first and was proven blind): surface the toolchain
state as a read-only UI pane, and **mutating verbs refuse on a detected
mismatch while rendering continues** (the read path is never gated). In **phase 2** both are
met structurally: yog seeds the nested world's `<yog-data-root>/world/tools/`
with **`lernie-tool-bl`** — a re-exec shim of the yog binary (the
`--editor-apply` multi-call pattern) speaking bl's argv contract and dispatching
to the **embedded balls crate against the nested roots**, discovered by lernie's
external-tool convention, and stamping `--as $YOG_NAME` onto any bl verb the
caller left unstamped (§3.3). The tool *is* yog: no version and no path is left to
drift, and the phase-1 version gate is deleted (the exact-pinned crate is the
version, §16.5).
The human counterpart to these agent tools is `yog env` / `yog exec` (§8.4).
### 16.5 Crate adoption, phased by upstream readiness
The end-state direction embeds all three substrates as **exact-pinned** crates —
the pin *is* the version mechanism (brazen's own pin-exact README posture,
generalized) — retiring the phase-1 version gate. Phasing is gated per upstream:
- **balls** — a `[lib]` target ships (balls **0.5.7**); adopt for typed store
reads where the lib serves them (W8), then expose it as the `lernie-tool-bl`
agent tool (W9).
- **brazen** — a lib exists (pin-exact per its README); adopt for canonical
Event types and config validation (W10). Where brazen declares its API
unstable, the `bz --dump-config` subprocess (§9.1) stays the authority.
- **lernie** — gated on upstream lernie **bl-231c** (library port +
driver/successor exec parametrization). If linked, yog re-execs **itself** as
the driver binary — exactly bl-231c's exec parametrization (W11).
**Process semantics are non-negotiable regardless of linking:** drivers are
processes holding flocks, and plugin dispatch stays subprocess. Linking changes
what code yog *calls*, never the concurrency model — a linked lernie still runs
drivers as flock-holding processes, and yog re-execing itself as the driver is
exactly that process, not an in-process task.
### 16.6 Phase 1 — binaries (implement now)
Ordered like §15; each lands through the same gate (fmt, clippy -D warnings,
300-line cap incl. inline tests, tarpaulin 100% pinned 0.35.2) via the
worktree/merge/no-ff flow. yog shells to host binaries throughout phase 1
(lernie is not lib-ready); the version gate and any install convenience live
and die with this phase.
**W1 — world-env module: compose the nested `Env` and the world layout.**
Scope: a new module that, from the ambient `xdg::Env`, computes yog's data-root
anchor and derives the world subtree
(`<yog-data-root>/world/{lernie,state,tools}`), then composes
the world `Env` = ambient + `{LERNIE_HOME, XDG_STATE_HOME}`
overrides (`XDG_DATA_HOME`/`XDG_CACHE_HOME`/`BRAZEN_CONFIG` left ambient — the
anchor and the brazen config/creds/cache share, §16.2). Pure over an injected ambient `Env`; every
override and both the nested and shared derivations table-tested; yog re-derives
all substrate roots and its own two artifacts through the world `Env`. Task 0:
confirm bl-delivery derives worktree territory from its own `$XDG_STATE_HOME`
and record it (§16.2).
Deps: none (extends `xdg`). Files: `src/world/mod.rs` (~150).
**W2 — world-env injection at every spawn.**
Scope: the composed world overrides (§16.2 — `world::overrides`, the single
source shared with `compose`, so the dir yog watches and the dir a spawned `bl`
writes are one fact) stand on every child. **The seam is the `Cli` itself, at
construction** (`Cli::resolve_in_world`): a world `Cli` carries the overrides and
layers them under any per-call `env` on every
`run`/`run_in`/`run_env`/`spawn_detached`, so nesting is impossible to forget at
a new call site (a new call reuses an existing world `Cli`) and `cli_outbound`
stays generic — opaque pairs, no world knowledge. `main.rs` composes the world
once and derives **both** sides through it: every read (`Roots`, `ShellState`)
via the world `Env` and every spawn (the `bl`/`lernie`/`bz` runners — incl.
`BlRunner`, `BlConf`, `RealBzRunner`, and the detached `lernie prompt`) via the
standing overrides, so an agent's own tool processes inherit the nested
`$XDG_STATE_HOME` and reads/spawns land on the same paths (§16.4 phase-1
correctness; agreement regression-tested). W3's explicit `LERNIE_HOME` collapses
into the standing env. No scrub — overrides layer over the inherited
environment. (`cli_outbound/mod.rs` split its `Stream` half to `stream.rs` to
stay under the 300-line cap.)
Deps: W1. Files: `src/cli_outbound/{mod,stream}.rs`, `src/world/mod.rs`
(`overrides`), `src/main.rs`, the config-pane resolvers.
**W3 — lernie home seeding via the upstream bootstrap verb.**
Scope: on the first Start against an unseeded world, yog invokes lernie's own
bootstrap verb to populate `LERNIE_HOME`; yog never reproduces lernie's seed
logic (§14). Skipped when the world is already seeded — the general path with
the seed present, not a bootstrap special case (§3.4). **The contract landed
(upstream bl-6d83, 2026-07-18): the verb is `lernie prime`** —
`LERNIE_HOME=<dir> lernie prime`, seed-if-absent, idempotent, silent on
success, `models.yaml` at the home root as the marker — exactly what
`src/world/seed.rs` drives and `seeded()` probes. *(History: yog shipped
against this contract before it landed; a stale installed lernie then failed
every start at the seed step — the incident behind the W5 capability
amendment.)*
Deps: W1. Files: `src/world/seed.rs` (~90), start-flow wiring.
**W4 — the no-marks knob (shared store default + stealth / custom branch).**
Scope: the nested balls clone tracks the project's shared `balls/tasks` store
branch by default (the coordination point with ambient `bl`); a per-project knob
repoints it through bl's own config surface — stealth (`bl conf task-remote
none`) and/or a custom task-branch — surfaced in yog's UI as a project setting
that *drives `bl conf`*, never a yog config file (§16.3). Renders the stealth
trade (claims invisible to ambient `bl list`).
Deps: W1. Files: `src/world/marks.rs` (~120), a config-pane shell touch.
**W5 — phase-1 capability gate + toolchain pane.** *(Amended: presence
gating shipped and was proven blind — a stale lernie predating `prime` passed
the gate, then every Start died at `lernie prime` exit 2 with the composer
never opening. A binary's existence says nothing about its verbs.)*
Scope: at startup (before first render; the gate is a read-only probe, the
class I1 already admits — see STORIES INV-1) and on toolchain-pane refresh,
probe each host tool by **capability** against the normative driven-verb
list, which is exactly this: lernie `prime new prompt message stop scan
config`, bl `create claim unclaim close update list show conf`, bz
`--version`. For
`bl`/`lernie` (no `--version`) each verb must answer `<tool> <verb> --help`
with exit 0 — one short spawn per verb. The read-only premise is per-tool:
lernie is clap, where `--help` is contractual; bl's convention (`<verb>
--help` ⇒ exit 0, unknown verb ⇒ exit 2) is pinned empirically on the tested
tuple — and a future bl whose probe mutates or exits non-zero classifies
Mismatch, which is the gate *working*, not a false negative. Any missing verb
classifies the tool Mismatch **with the verb named in the verdict and the
remediation beside it** (the §8.3 show-the-exact-command pattern: the
phase-1 install/upgrade command per tool; phase 2 dissolves the install
story entirely, §16.4); **mutating dispatches refuse on Mismatch while
read derivation continues — the gate is consulted inside the dispatch layer
(`start::prepare` precondition, `actions::verbs`), never only in shell
glue** (a gate only some verbs honor is not a gate). Verdict types are `pub`
so `tests/` asserts refusals directly. Effect- and clock-injected per the
house pattern. Pre-split for the verb-probe growth: `src/world/toolgate.rs`
(classification) + `src/world/toolgate/probe.rs` (per-verb capability
probe), each under the cap. **Explicitly phase-1-scoped:** phase 2's
exact-pinned crates make the version definitional and delete this gate
(§16.4).
Deps: W1. Files: `src/world/toolgate.rs` + `src/world/toolgate/probe.rs`
(the pre-split above; each under the cap), a shell pane (excl.).
**W6 — `yog env` / `yog exec` world escape hatches.**
Scope: two multi-call subcommands of the yog binary (beside `--editor-apply`):
`yog env` prints the world's `export` lines; `yog exec <cmd…>` runs a command
inside the world (world env layered, optional cwd), §8.4. Pure argv → plan; the
exec spawn reuses `cli_outbound`.
Deps: W1, W2. Files: `src/world/hatch.rs` (~100), `src/main.rs` dispatch.
**W7 — (optional, doomed) `make install-tools` convenience.**
Scope: a Makefile target pinning the tested tool tuple into
`<yog-data-root>/world/tools/bin` via `cargo install --locked --root`, with
`cli_outbound` optionally preferring that dir when present — a convenience so
the W5 gate passes on a fresh machine. **Retired wholesale by phase 2** (§16.4):
the target, the bin dir, and the preference all delete when the embedded crates
land. File only if the manual-install friction is felt.
Deps: W1. Files: `Makefile` target, small `src/cli_outbound` resolution touch.
### 16.7 Phase 2 — crates (gated placeholders, per upstream readiness)
Each names its upstream dependency and lands only when that dependency is ready;
together they retire W5 and W7 and establish the crate end state (§16.5).
**W8 — embed `balls` as an exact-pinned crate (typed store reads).**
Gated on: balls **0.5.7** `[lib]` (available). Scope: adopt the balls lib for
typed store reads where the lib serves them, exact-pinned in `Cargo.lock` (the
pin is the version mechanism; retires W5 for balls); replaces `bl list/show
--json` parsing where the lib exposes the same projection.
Deps: W1; balls 0.5.7 lib.
**W9 — expose the embedded balls crate as an agent tool (`lernie-tool-bl`).**
Gated on: W8. Scope: seed `<yog-data-root>/world/tools/lernie-tool-bl` — a
re-exec shim of the yog binary (the `--editor-apply` multi-call pattern)
speaking bl's argv contract and dispatching to the **embedded** balls crate
against the **nested** roots, so an agent operating on yog's world gets a `bl`
that shares yog's implementation and state view (the correctness argument,
§16.4); lernie's external-tool convention discovers it. The shim also
**injects `--as $YOG_NAME`** (the per-workspace spawn env, §3.3/§8) whenever
the caller omits `--as` — identity is the harness's job, not the model's;
landing this deletes the phase-1 preamble instruction (§3.3).
Deps: W8; lernie external-tool convention.
**W10 — embed `brazen` as an exact-pinned crate (Event types + config validation).**
Gated on: brazen lib (exists; pin-exact per its README). Scope: adopt brazen's
canonical Event types and config validation from the linked crate, exact-pinned;
retire the `bz`-subprocess validation where the lib serves it — the subprocess
stays wherever brazen declares its API unstable (§9.1).
Deps: W1; brazen lib.
**W11 — embed `lernie` as an exact-pinned crate + driver re-exec.**
Gated on: upstream lernie **bl-231c** (library port + driver/successor exec
parametrization). Scope: link lernie as an exact-pinned crate; if linked, yog
re-execs **itself** as the driver binary (bl-231c's exec parametrization).
**Process semantics stay non-negotiable regardless of linking:** drivers are
processes holding flocks; plugin dispatch stays subprocess (§16.5).
Deps: W1; lernie **bl-231c**.