1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
//! The git-tree view-model types (§7.1 live view, §3.5 agent-state contract):
//! the error enum and the five inert structures [`GitTree::from_repo`] answers
//! with. Pure data — no git call, no egui dep, no derivation beyond the one
//! constructor that hands off to [`ProbeStack::derive`]; every field is a fact
//! read off refs or disk on the tick that built it (§3.5 stateless re-read).
//! [`super`] holds the wiring that fills them.
use super::{AgentState, ProbeStack, Stream};
use std::path::{Path, PathBuf};
#[derive(Debug, thiserror::Error)]
pub enum GitTreeError {
#[error("git invocation failed: {0}")]
Spawn(#[from] std::io::Error),
#[error("git {command} in {repo:?} failed: {stderr}")]
Git {
command: String,
repo: PathBuf,
stderr: String,
},
#[error("malformed git log line: {0:?}")]
LogFormat(String),
/// The governing-config derivation (§5.1 #17) declined: either no
/// `config/*` lineage reaches the agent's branch, or two candidate
/// ancestors are incomparable. Both mean a defective workspace, declined
/// rather than guessed (mirrors lernie `workspace.rs`).
#[error("governing config: {0}")]
Governing(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct GitTree {
/// The config lineage (`HEAD` → `config/default`, §2.2),
/// first-parent, oldest to newest.
pub commits: Vec<CommitNode>,
/// Every agent branch (`agents/*`, §2.3), enumerated via
/// `git for-each-ref refs/heads/agents/`. A flat authoritative set;
/// the render tree is derived from the ids by [`descent_order`]
/// (§2.3 hyphenated descent) — never stored (PRINCIPLES "Single
/// source of truth").
pub agents: Vec<Agent>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommitNode {
pub oid: String,
pub short_oid: String,
pub timestamp_unix: i64,
/// Commit subject — config commits are the only trunk commits
/// (§2.2–§2.3: agents never merge anywhere), so the subject is the
/// row's label.
pub subject: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StepCommit {
pub oid: String,
pub short_oid: String,
pub timestamp_unix: i64,
/// Commit subject. Surfaces what a branch commit is — a dispatch, a
/// delivery commit, or a work-product-transfer commit (§2.11, §2.6,
/// §7.1 "delivery/result-message commits surfaced").
pub subject: String,
}
/// One agent branch (`agents/<agent-id>`, §2.3). Named `Agent` — every
/// row is an agent, not an "unmerged conversation branch"; nothing merges
/// (§2.6), so the merged/unmerged framing is gone.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Agent {
/// Branch name (`agents/<agent-id>`). Held separately so the renderer
/// can label rows without re-deriving.
pub branch_name: String,
/// The agent id (`agents/` prefix stripped) — the identity everywhere
/// (steps/, inbox/, worktree dir, descent). The descent tree keys off
/// this (§2.3).
pub agent_id: String,
pub tip_oid: String,
pub tip_short_oid: String,
pub tip_timestamp_unix: i64,
/// The agent's **last action of any kind** (§11 recency, bl-cad5): the
/// newest of the tip commit timestamp, the newest `messages/` entry mtime
/// and the latest step's `response.json` mtime (the live streaming tail).
/// Committing is only *one* way an agent acts, so
/// [`tip_timestamp_unix`](Agent::tip_timestamp_unix) alone leaves a
/// streaming or just-messaged conversation looking stale. Gathered here at
/// snapshot time (§3.5 stateless re-read) so the §11 list's sort and its
/// age label are one fact read once — never a stat from the render path.
pub last_action_unix: i64,
/// How many entries this agent's `messages/` directory holds (§5.1 #12) —
/// counted by the very readdir
/// [`last_action_unix`](Agent::last_action_unix) already performs, so the
/// two facts cost one directory walk (the §5.1 #10 discipline).
///
/// Its one consumer is §7.2's pending echo: the operator's just-sent
/// message is superseded when this count passes the baseline the echo
/// recorded. Nothing cheaper says that honestly —
/// [`last_action_unix`](Agent::last_action_unix) moves on a streaming
/// token and [`tip_oid`](Agent::tip_oid) on any step commit, so either
/// would retire the echo while the message was still missing.
pub messages: usize,
/// When the latest step's **model call began** (§5.1 #28 elapsed, bl-9dfb):
/// the mtime of that step's `request.json`. lernie writes that file exactly
/// once, immediately before handing the request to the adapter — the same
/// instant it later records as `meta.json`'s `started_at` — and never
/// appends to it, so its mtime *is* the call's start rather than its last
/// sign of life. `meta.json` itself cannot serve: it lands only after the
/// call returns, so it is absent for exactly the call the strip is timing.
/// `None` when no step has written a request yet, or its stamp is
/// unreadable — the strip then omits elapsed rather than inventing one.
/// Gathered here at snapshot time (§3.5) like every other disk fact; the
/// render path never stats.
pub call_start_unix: Option<i64>,
/// Commits on this branch past every config lineage, oldest to newest
/// (each with its subject).
pub steps: Vec<StepCommit>,
pub preview: Option<String>,
/// What the latest step's live `response.json` says (§5.1 #10, #28b): the
/// answer text so far, the reasoning text so far, and the kind of the last
/// content delta. **One value off one read** — three fields filled from
/// one pass could otherwise be filled from three, and then they would be
/// three mid-write states of one file rather than its answer.
///
/// Re-derived from `<workspace>/steps/<agent-id>/<NNN>/response.json` on
/// every `from_repo` call (§3.5: stateless re-read on each tick) — and, on
/// the **focused** conversation, superseded per frame by the §7.2 live-tail
/// follower's fresher fold of the same file (`app::live`), which is the one
/// place a rendered `Agent` carries something no derivation put there.
pub stream: Stream,
/// Tool calls under this branch's latest step's `tools/` directory
/// (ARCH §3.3), derived purely from `input.json` / `output.json`
/// presence. Re-derived on every `from_repo` call (§3.5).
pub tool_calls: Vec<ToolCall>,
/// §3.5 agent-state classification, derived from the executor lock and
/// the latest step's `response.json` terminal segment. Re-derived on
/// every `from_repo` call (§3.5).
pub state: AgentState,
/// A liveness probe could not observe (DESIGN §10): the lock probe
/// returned `Unknown`, or the writer probe did under a held lock. The
/// [`state`](Agent::state) is then the best framing-only reading, never a
/// false definite, and the renderer flags it with an uncertainty ("?")
/// suffix. Always `false` on Linux, where `/proc` is authoritative.
pub state_uncertain: bool,
/// Pending (undelivered) deposits in the agent's inbox
/// (`<workspace>/inbox/<agent-id>/*.md`, §2.11), oldest-first — the §5.1
/// #11 derivation gathered at snapshot time (§3.5 stateless re-read) so
/// its three seats (the `✉n` badge, the Inbox tab, the inbox-composer's
/// pending queue, bl-929d) read one listing and the render path never
/// touches disk. The count is this listing's length, never a second
/// stored fact.
pub pending: Vec<crate::inboxview::InboxEntry>,
/// `refs/lernie/conflicted/<agent-id>` oid, or `None` when unmarked —
/// a work-product transfer was declined (§2.6). Rendered as an
/// orthogonal mark alongside the state (§3.5, §7.1); the oid is the §6
/// attention watermark evidence (rule 4).
pub conflicted_oid: Option<String>,
/// `refs/lernie/budget-exhausted/<agent-id>` oid, or `None` — the
/// agent tree hit a spend ceiling (§6). Rendered alongside the state
/// (§3.5, §7.1); the oid is the §6 watermark evidence (rule 3).
pub budget_oid: Option<String>,
/// `refs/lernie/abandoned/<agent-id>` oid, or `None` — the policy
/// assertion that a stopped branch will not be retried (ARCH §8). Its
/// presence suppresses the stop-attention signal (§6 rule 2).
pub abandoned_oid: Option<String>,
/// `refs/lernie/notify/<agent-id>` oid, or `None` — the branch asked
/// the UI to raise a notification (ARCH §8). The oid is the §6
/// watermark evidence (rule 1: unseen = oid ≠ watermark).
pub notify_oid: Option<String>,
/// The invocation the capability control **parked** before it executed
/// (`refs/lernie/held/<agent-id>`, ARCH §3.3, DESIGN §8.6): which
/// `tool_use`, which tool, and the control's reason. `None` for every
/// branch nothing is holding, which is nearly all of them.
///
/// The **value**, not an oid, and that is §6 rule 6's shape: a park is not
/// acknowledgeable — hiding a parked drone behind a watermark would hide a
/// drone that cannot move — so there is nothing to seen-gate, and what the
/// operator needs is what the blob says.
pub held: Option<crate::control::hold::Held>,
/// The start-flow ball id stamped in this agent's `goal.md` (DESIGN §3.3),
/// parsed back by [`crate::start::parse_ball_stamp`] — the *derived*
/// conversation↔ball association, never stored (§3.2, §5.1: a fact whose
/// one home is the goal content). Only a root the yog start flow composed
/// carries one; a sub-agent or a hand-typed conversation reads `None`.
pub goal_ball: Option<String>,
/// The **lernie-stored name fact** (DESIGN §3.3 as ruled by bl-50f3): the
/// `name` blob on this agent's own branch, committed beside `goal.md` at
/// dispatch and read back `git show agents/<id>:name` — the `agents/*`
/// refs stay the only registry, so this is a query, never a stored index.
/// The one durable home of the name since lernie 0.0.4; any agent may
/// carry it — a yog-fired root (`--name` at fire) or a lernie-dispatched
/// child alike, no special case. `None` for an unnamed agent and for a
/// pre-0.0.4 branch with no blob.
pub name: Option<String>,
/// The **legacy** `You are <x>.` stamp on this agent's `goal.md` first line
/// (DESIGN §3.3), parsed back by [`crate::start::parse_identity_stamp`].
/// Demoted by bl-08f2 from the name's home to its fallback: it covers only
/// pre-0.0.4 roots (no [`name`](Agent::name) blob) until lernie's 30-day
/// retention ages them out — then the rung is deleted. Not a fact home;
/// since bl-6920 nothing composes the stamp, so new roots never carry one.
pub goal_name: Option<String>,
}
impl Agent {
/// The agent's display identity, or `None` when it has none — the top of
/// the §3.3 ladder in one fold: the lernie-stored [`name`](Agent::name)
/// fact, else the legacy [`goal_name`](Agent::goal_name) stamp parse.
/// Every seat that names an agent (the §11 row title and in-flight strip,
/// the center header, the §3.6 deletion gate, the mint's occupied set)
/// reads this one fold, so retiring the legacy rung is one deletion here.
/// Whether this row exists **only in memory** — §7.2's pending
/// conversation, the row a fired start paints before its driver has written
/// a branch. A derived agent comes off `git for-each-ref`, so it always has
/// a tip; an empty one cannot be derived, which makes this a query rather
/// than a flag (§5.1's discipline: no fact stored twice). Its one consumer
/// is the §11 tone — faded while a send is only yog's word for it,
/// brightening when the derivation makes it a statement.
pub fn in_memory(&self) -> bool {
self.tip_oid.is_empty()
}
pub fn name_fact(&self) -> Option<String> {
self.name.clone().or_else(|| self.goal_name.clone())
}
/// Whether [`name_fact`](Agent::name_fact)'s answer is the **legacy
/// display-only rung** (bl-8068): a goal-stamp parse with no lernie-stored
/// [`name`](Agent::name) blob behind it. Such a name renders as the title,
/// but lernie resolves message targets by id or *stored* name only — a
/// peer addressing this name gets `no agent "<x>" in this workspace`. The
/// seats that show the name hover this fact so an operator never reads an
/// unaddressable name as an addressable one. `false` for a fact-named
/// agent and for one with no name claim at all.
pub fn name_display_only(&self) -> bool {
self.name.is_none() && self.goal_name.is_some()
}
}
/// A single tool call surfaced to the renderer. The disk records carry
/// more metadata (timing, exit code, raw stdout) but the view-model only
/// needs identity + state to drive the indicator.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolCall {
/// `tool_use.id` from the wire (e.g. `toolu_01abc…`); also the
/// `<tool-id>/` directory name under
/// `<workspace>/steps/<agent-id>/<NNN>/tools/`.
pub tool_id: String,
/// What the tool is **called** (`Read`, `Bash`, …) — `input.json`'s `name`
/// field, read at enumerate time beside the two presence checks that
/// decide [`state`](ToolCall::state), never from a render path (§11
/// bl-cad5's rule). `None` when the record carries no parsable name; the
/// §11 in-flight strip then drops the segment rather than printing the
/// opaque `tool_id`, which names nothing to an operator.
pub name: Option<String>,
/// When this call **started** (§5.1 #28 elapsed, bl-9dfb): the mtime of the
/// `input.json` whose presence already decides
/// [`state`](ToolCall::state). The executor lands that record atomically
/// immediately before it spawns the tool — the same instant it later records
/// as `output.json`'s `started_at` — and never rewrites it, so its mtime is
/// the call's start. No commit timestamp could serve instead: step records
/// are not git-tracked (§2.3), so this file has no commit at all. `None`
/// only when the stamp is unreadable (the record vanished between the
/// presence check and the stat); the strip then omits elapsed.
pub start_unix: Option<i64>,
pub state: ToolCallState,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolCallState {
/// `input.json` has landed but `output.json` has not — the tool
/// executor is still running. Renderer pulses this node.
InFlight,
/// Both `input.json` and `output.json` are present on disk. Renders
/// statically; no repaint scheduling.
Complete,
}
impl GitTree {
/// Derive a workspace's tree with a fresh, throwaway [`ProbeStack`] — the
/// one-shot path (tests, a single read). The live UI instead holds one
/// [`ProbeStack`] across ticks (§15 Y11) so its TTL cache pays off; both
/// route through the same [`ProbeStack::derive`] derivation.
pub fn from_repo(workspace: &Path) -> Result<Self, GitTreeError> {
ProbeStack::platform().derive(workspace)
}
}