majordomus_cli/capability/builtin/lifecycle.rs
1//! The `lifecycle` module: the episode lifecycle as something a person can watch.
2//!
3//! The module is named for the lifecycle rather than for the session because `session` is
4//! already a kind of the layer — the closed records under the sessions section — and the
5//! registry composes one module per declarative kind. A capability module that took the
6//! same namespace would be composed twice and the registry refuses to build, which is the
7//! right answer: two different things called `session` in one namespace is exactly the
8//! ambiguity that refusal exists to catch.
9//!
10//! [`super::continuity`] answers one question well — *what would a worker resuming in this
11//! checkout be handed?* — and it answers it about **one** episode, the one
12//! `state/session-current.yaml` points at. That is the right answer for a briefing and the
13//! wrong answer for an operator, because the pointer is a symlink that the most recent
14//! `session start` re-aims: on 2026-09-11 this repository held five open episodes in one
15//! checkout and `continuity.state` could name exactly one of them. Four workers were
16//! invisible to every surface the tool has, and the episode that never closes is the one
17//! nobody can see.
18//!
19//! So this module reads the store rather than the pointer, and it reports the things a
20//! subsystem needs somebody to look at:
21//!
22//! - **`lifecycle.episodes`** — every open episode in the local store, which of them this checkout
23//! points at, which belong to another worktree, and what the ledger last saw each do.
24//! - **`lifecycle.recovery`** — the episodes that can no longer close themselves, the temporary
25//! files a killed close left in the tracked directory, whether the pointer has been
26//! migrated to the per-provider layout, and the arithmetic ADR 0052 asks for: how many
27//! `session.started` events the ledger holds against how many `session.closed`.
28//! - **`lifecycle.runtime`** — the commit this process is serving answers about, against the commit
29//! the repository is on right now. A long-lived server that built its index once and held
30//! it served a six-hour-old HEAD from the API, from MCP and from this Cockpit with nothing
31//! saying the picture was old (`fix/server-sees-the-current-tree`). Whether that is still
32//! possible is a fact this page should state rather than a fact a reader should assume.
33//! - **`lifecycle.providers`** — which lifecycle events each provider's adapter declares, whether it
34//! can capture prompts, and which of this repository's enforcement entries are actually
35//! wired to its hook. Declared, never inferred from the presence of a file.
36//! - **`lifecycle.closed`** — the tracked durable projection under the layer's sessions section: the
37//! only half of this subsystem that survives a clone.
38//!
39//! **Read, never written.** Like `continuity`, this module is one of the lifecycle's
40//! readers; the writer is the shell tool. A second writer would be a second account of
41//! events the ledger already holds, which is what that design refuses.
42//!
43//! **Served, never published.** Everything under `.ai/local/` names this machine, so none of
44//! it is projected into `docs/generated/` or `site/` — the claim `local-state-ignored` owns
45//! that, over the whole local half (ADR 0014). The half that belongs to these five is that
46//! none of them declares a command line, because a command line is how a value reaches a
47//! script, a log and eventually a commit; `test/cases/171` asserts exactly that.
48//!
49//! **No thresholds and no clock.** Nothing here decides that a record is old. Age is
50//! `session.freshness` in the policy and it is [`super::continuity`]'s to judge; a second
51//! engine for it in this file would be the second source of truth ADR 0052 names. What this
52//! module reports is what it can observe without an opinion: a timestamp as recorded, a
53//! count, a path that exists or does not.
54//!
55//! # The shape of the module, and the constraint it is under
56//!
57//! Two of the paragraphs above are properties of the declaration rather than intentions,
58//! so they can be read off it. Every one of the five capabilities is served — over HTTP and
59//! over MCP — and **none of them declares a command line**, which is what "served, never
60//! published" amounts to mechanically: a command line is how a value reaches a script, a
61//! log and eventually a commit, and nothing under `.ai/local/` may take that path.
62//!
63//! ```
64//! use majordomus_cli::capability::builtin::lifecycle;
65//!
66//! let m = lifecycle::module();
67//! assert_eq!(m.id.as_str(), "lifecycle");
68//!
69//! // declaration order, which is the order the five are read in
70//! let ids: Vec<&str> = m.capabilities.iter().map(|e| e.capability.id.as_str()).collect();
71//! assert_eq!(ids, [
72//! "lifecycle.episodes",
73//! "lifecycle.recovery",
74//! "lifecycle.runtime",
75//! "lifecycle.providers",
76//! "lifecycle.closed",
77//! ]);
78//!
79//! // served everywhere a worker in front of this checkout can reach, and nowhere a
80//! // script can: the local half of the layer never becomes a committed value
81//! for e in &m.capabilities {
82//! let id = e.capability.id.as_str();
83//! assert!(e.capability.exposure.http.is_some(), "{id} is not served over HTTP");
84//! assert!(e.capability.exposure.mcp.is_some(), "{id} is not served over MCP");
85//! assert!(e.capability.exposure.cli.is_none(), "{id} declares a command line");
86//! }
87//! ```
88
89use std::collections::BTreeMap;
90use std::path::{Path, PathBuf};
91
92use schemars::JsonSchema;
93use serde::{Deserialize, Serialize};
94
95use crate::capability::handler::{CapabilityError, Context};
96use crate::capability::model::{Exposure, McpExposure, McpResource, Stability};
97use crate::capability::module::ModuleDescriptor;
98use crate::capability::CachePolicy;
99use crate::git::{self, GitState};
100use crate::{capability, module};
101
102use super::continuity::document;
103use super::{get, Empty};
104
105/// The URI under which `lifecycle.episodes` is read as an MCP resource.
106pub const EPISODES_URI: &str = "majordomus://lifecycle/episodes";
107/// The URI under which `lifecycle.recovery` is read as an MCP resource.
108pub const RECOVERY_URI: &str = "majordomus://lifecycle/recovery";
109
110/// The local half of the layer, relative to the repository root. The same constant
111/// `continuity` carries, and for the same reason: the shell tool decides where its state
112/// lives and a second opinion about the path would be a second source of truth for the one
113/// thing both halves must agree on.
114const STATE_DIR: &str = ".ai/local/state";
115/// The open-episode store, one file per provider session (`lib/session.sh`).
116const OPEN_DIR: &str = "sessions-open";
117/// The pointer this checkout follows, a symlink into [`OPEN_DIR`] since the per-provider
118/// layout landed.
119const POINTER: &str = "session-current.yaml";
120/// The append-only ledger, the only account of events either half keeps.
121const LEDGER: &str = "ledger.jsonl";
122/// How many closed records [`closed`] carries in full. The total is always exact; the
123/// window exists because the tracked directory grows without bound and a Cockpit card is
124/// not a listing page.
125const CLOSED_WINDOW: usize = 20;
126
127// --------------------------------------------------------------------- open episodes
128
129/// Where an open episode stands, as far as the repository can observe it.
130///
131/// It is worth being precise about what is *not* here. Whether the provider that opened an
132/// episode is still attached to its conversation is not a fact of this repository: the
133/// episode file records no pid, the peer board records no episode id, and a client that
134/// exits without firing its end event leaves a file identical to one a live worker is using.
135/// Guessing would produce exactly the confident-and-wrong answer this subsystem exists to
136/// avoid, so the vocabulary below is the observable one — where the file is, whose worktree
137/// it names, and whether the pointer follows it — and `note` always says which.
138///
139/// Read as a scale, the four run from "this checkout's own work" to "nobody's work any
140/// more", and only the last is a fault: `current` and `open` are both this worktree's live
141/// episodes and differ only in which one the pointer happens to aim at, `foreign` belongs to
142/// another worktree and is reported rather than adopted, and `stranded` is the one that
143/// needs a person, because nothing can close it.
144///
145/// ```
146/// use majordomus_cli::capability::builtin::lifecycle::EpisodeStanding;
147///
148/// // the standings that are somebody's live work, against the one that is nobody's
149/// let live = [EpisodeStanding::Current, EpisodeStanding::Open, EpisodeStanding::Foreign];
150/// assert!(live.iter().all(|s| *s != EpisodeStanding::Stranded));
151///
152/// // serialised as the word `as_str` gives, so the two renderings cannot drift apart
153/// for s in [EpisodeStanding::Current, EpisodeStanding::Foreign, EpisodeStanding::Stranded] {
154/// assert_eq!(serde_json::to_value(s).unwrap(), serde_json::json!(s.as_str()));
155/// }
156/// ```
157#[derive(
158 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
159)]
160#[serde(rename_all = "snake_case")]
161pub enum EpisodeStanding {
162 /// This checkout's pointer resolves to it. `continuity.state` is about this one.
163 Current,
164 /// Open, and it names this worktree, but the pointer names a different episode. Two
165 /// windows of one provider on one worktree are two workers, and only one is pointed at.
166 Open,
167 /// It names another worktree of this repository. Reported, never treated as this
168 /// checkout's — a record about somebody else's work quietly becoming yours is the
169 /// failure the two-tier resolution rule exists to prevent.
170 Foreign,
171 /// The worktree it names is gone from disk. Nothing can close it: the close path
172 /// composes the record from git in the worktree the episode opened in.
173 Stranded,
174}
175
176impl EpisodeStanding {
177 /// The word this standing is reported and serialised under. One spelling for the
178 /// value, so that the Cockpit, the API and MCP name a stranded episode identically
179 /// and a reader who learned the vocabulary once has learned it everywhere.
180 ///
181 /// ```
182 /// use majordomus_cli::capability::builtin::lifecycle::EpisodeStanding;
183 /// assert_eq!(EpisodeStanding::Stranded.as_str(), "stranded");
184 /// // the serialised form is this word, not a second rendering of the same value
185 /// assert_eq!(
186 /// serde_json::to_value(EpisodeStanding::Stranded).unwrap(),
187 /// serde_json::json!(EpisodeStanding::Stranded.as_str()),
188 /// );
189 /// ```
190 pub fn as_str(self) -> &'static str {
191 match self {
192 EpisodeStanding::Current => "current",
193 EpisodeStanding::Open => "open",
194 EpisodeStanding::Foreign => "foreign",
195 EpisodeStanding::Stranded => "stranded",
196 }
197 }
198}
199
200/// One open episode, as its file records it and as the ledger has seen it.
201///
202/// Everything here is recorded rather than derived, and almost everything is optional,
203/// which is the point: an episode opened by hand has no provider, a worker that supplied no
204/// identity has none, and an episode that opened and did nothing has no ledger line and so
205/// no `last_activity`. Each of those is ordinary and none is a fault, so each is carried as
206/// absence — the empty fields are skipped on the wire — and a reader must handle their
207/// absence rather than expect a placeholder.
208///
209/// The two fields that are always there are `standing` and `note`: where the episode stands,
210/// and why it stands there in words. A standing a reader cannot act on is the failure this
211/// subsystem was corrected for, so `note` is never empty.
212///
213/// ```
214/// use majordomus_cli::capability::builtin::lifecycle::{Episode, EpisodeStanding};
215/// use serde_json::json;
216///
217/// // the smallest true record: an episode opened by hand that has written nothing yet
218/// let e: Episode = serde_json::from_value(json!({
219/// "session_id": "s-0001",
220/// "path": ".ai/local/state/sessions-open/by-hand.yaml",
221/// "standing": "open",
222/// "events": 0,
223/// "note": "open in this worktree; the pointer names another episode",
224/// })).unwrap();
225///
226/// assert_eq!(e.standing, EpisodeStanding::Open);
227/// assert!(e.provider.is_empty(), "no provider session owns a hand-opened episode");
228/// assert!(e.last_activity.is_empty(), "it has written no ledger line");
229/// assert!(e.tasks.is_empty(), "work outside a task is permitted");
230/// assert!(!e.note.is_empty(), "a standing always says why");
231///
232/// // and absence stays absence: the empty fields are not serialised back as ""
233/// let wire = serde_json::to_value(&e).unwrap();
234/// assert!(wire.get("provider").is_none());
235/// assert_eq!(wire["events"], 0, "a count of zero is a measurement, so it is kept");
236/// ```
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
238pub struct Episode {
239 /// The episode id the ledger stamps on every line this worker wrote.
240 pub session_id: String,
241 /// Repository-relative path of the open record. The body is at the path.
242 pub path: String,
243 /// The provider whose event opened it, when one did. Empty for an episode opened by
244 /// hand, which is the one episode no provider session owns.
245 #[serde(default, skip_serializing_if = "String::is_empty")]
246 pub provider: String,
247 /// That provider's own session identity: the file's stem, and the string the prompt
248 /// archive stamps on the same worker's records.
249 #[serde(default, skip_serializing_if = "String::is_empty")]
250 pub provider_session: String,
251 /// Who opened it.
252 #[serde(default, skip_serializing_if = "String::is_empty")]
253 pub owner: String,
254 /// The worker identity, when one was supplied. Never inferred.
255 #[serde(default, skip_serializing_if = "String::is_empty")]
256 pub worker: String,
257 /// The worktree it opened in, absolute, as recorded.
258 #[serde(default, skip_serializing_if = "String::is_empty")]
259 pub worktree: String,
260 /// The branch it opened on.
261 #[serde(default, skip_serializing_if = "String::is_empty")]
262 pub branch: String,
263 /// The commit it opened at.
264 #[serde(default, skip_serializing_if = "String::is_empty")]
265 pub start_head: String,
266 /// When it opened, as recorded.
267 #[serde(default, skip_serializing_if = "String::is_empty")]
268 pub started_at: String,
269 /// Where it stands.
270 pub standing: EpisodeStanding,
271 /// How many ledger lines carry this episode id. Zero for an episode that opened and did
272 /// nothing, which is ordinary and not a fault.
273 pub events: usize,
274 /// The timestamp of the newest ledger line carrying this episode id, as recorded.
275 /// Empty when it has written none.
276 #[serde(default, skip_serializing_if = "String::is_empty")]
277 pub last_activity: String,
278 /// That line's event name.
279 #[serde(default, skip_serializing_if = "String::is_empty")]
280 pub last_event: String,
281 /// The tasks this episode's ledger lines name, in the order it first touched each.
282 ///
283 /// A relation, not a container. An episode may touch three tasks or none, and a task may
284 /// span three episodes; the two are joined here by what the ledger recorded rather than
285 /// by either one owning the other. An episode with no task is ordinary — work outside a
286 /// task is permitted — and it is emphatically not a reason for the episode's own records
287 /// to go unwritten, which is the premise ADR 0052 removes.
288 #[serde(default, skip_serializing_if = "Vec::is_empty")]
289 pub tasks: Vec<String>,
290 /// Why it stands where it does, in words. Never empty: a standing a reader cannot act
291 /// on is the failure this subsystem is being corrected for.
292 pub note: String,
293}
294
295/// Every open episode of this checkout's store — the answer `continuity.state` cannot give,
296/// because it reads the pointer and this reads the store.
297///
298/// `current` is the one episode the pointer resolves to, and it is one row of `episodes`
299/// rather than a separate thing: the whole correction this type carries is that the pointed-at
300/// episode is *a* member of the set and not the set. On the day this was written the store
301/// held five open episodes in one checkout and every surface could name one of them.
302///
303/// `present: false` is a fresh clone, which is not a fault — nobody has opened an episode
304/// here yet — and is reported as itself rather than as an error.
305///
306/// ```
307/// use majordomus_cli::capability::builtin::lifecycle::Episodes;
308/// use serde_json::json;
309///
310/// // a clone in which no episode has ever been opened: absence, answered as absence
311/// let fresh: Episodes = serde_json::from_value(json!({
312/// "present": false,
313/// "store": ".ai/local/state/sessions-open",
314/// "worktree": "example/a-fresh-clone",
315/// })).unwrap();
316/// assert!(!fresh.present);
317/// assert!(fresh.episodes.is_empty() && fresh.current.is_empty());
318/// assert!(fresh.findings.is_empty(), "nothing to warn about; it is simply new");
319///
320/// // and a store with work in it: `current` names one of the rows, never a sixth thing
321/// let busy: Episodes = serde_json::from_value(json!({
322/// "present": true,
323/// "store": ".ai/local/state/sessions-open",
324/// "worktree": "example/wt",
325/// "current": "s-0002",
326/// "episodes": [
327/// {"session_id": "s-0001", "path": "a.yaml", "standing": "open",
328/// "events": 3, "note": "this worktree, not pointed at"},
329/// {"session_id": "s-0002", "path": "b.yaml", "standing": "current",
330/// "events": 9, "note": "the pointer resolves to it"},
331/// ],
332/// })).unwrap();
333/// assert_eq!(busy.episodes.len(), 2, "the pointer is blind to the first of these");
334/// assert!(busy.episodes.iter().any(|e| e.session_id == busy.current));
335/// ```
336#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
337pub struct Episodes {
338 /// Whether the open-episode store exists. False in a fresh clone, which is not a fault.
339 pub present: bool,
340 /// The store, repository-relative.
341 pub store: String,
342 /// This worktree, absolute. Every standing above is decided against it.
343 pub worktree: String,
344 /// The episode id the pointer resolves to, when it resolves to one.
345 #[serde(default, skip_serializing_if = "String::is_empty")]
346 pub current: String,
347 /// Every open episode, newest first by recorded start.
348 #[serde(default, skip_serializing_if = "Vec::is_empty")]
349 pub episodes: Vec<Episode>,
350 /// What a reader should know before trusting the above: a file that does not parse, a
351 /// pointer that resolves to nothing. Empty is the good case.
352 #[serde(default, skip_serializing_if = "Vec::is_empty")]
353 pub findings: Vec<String>,
354}
355
356// --------------------------------------------------------------------- recovery
357
358/// The layout of the pointer, which is how far the per-provider migration has got here.
359///
360/// Before the episode was keyed by its provider session, `session-current.yaml` *was* the
361/// open record: one regular file, one episode per checkout. Seven concurrent sessions on
362/// 2026-09-09 produced one record between them under that layout. A regular file here today
363/// is a checkout that has not run the migration, and its episodes are being overwritten.
364///
365/// Only one of the three is a finding, and it is the middle one. `Pointer` is the current
366/// layout and `Absent` means no episode is open here — a fresh clone, or a worker between
367/// episodes — while `Inline` is a checkout still on the pre-migration layout, where a
368/// second concurrent session silently overwrites the first one's record. So the question a
369/// reader asks of this value is not "which layout" but "is it `Inline`".
370///
371/// ```
372/// use majordomus_cli::capability::builtin::lifecycle::PointerLayout;
373///
374/// // the two that are fine, and the one that loses records
375/// let loses_records = |l: PointerLayout| l == PointerLayout::Inline;
376/// assert!(!loses_records(PointerLayout::Pointer));
377/// assert!(!loses_records(PointerLayout::Absent), "no episode open is not a fault");
378/// assert!(loses_records(PointerLayout::Inline));
379///
380/// // serialised as the word `as_str` gives
381/// assert_eq!(
382/// serde_json::to_value(PointerLayout::Absent).unwrap(),
383/// serde_json::json!(PointerLayout::Absent.as_str()),
384/// );
385/// ```
386#[derive(
387 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
388)]
389#[serde(rename_all = "snake_case")]
390pub enum PointerLayout {
391 /// A symlink into `sessions-open/`: the per-provider layout.
392 Pointer,
393 /// A regular file: the pre-migration layout, one episode per checkout.
394 Inline,
395 /// Nothing is there. No episode is open here, which is not a fault.
396 Absent,
397}
398
399impl PointerLayout {
400 /// The word this layout is reported and serialised under. Named for what the pointer
401 /// *is* rather than for the migration that produced it, so that the value still reads
402 /// correctly once nobody remembers there was a migration.
403 ///
404 /// ```
405 /// use majordomus_cli::capability::builtin::lifecycle::PointerLayout;
406 /// assert_eq!(PointerLayout::Inline.as_str(), "inline");
407 /// // the serialised form is this word, not a second rendering of the same value
408 /// assert_eq!(
409 /// serde_json::to_value(PointerLayout::Inline).unwrap(),
410 /// serde_json::json!(PointerLayout::Inline.as_str()),
411 /// );
412 /// ```
413 pub fn as_str(self) -> &'static str {
414 match self {
415 PointerLayout::Pointer => "pointer",
416 PointerLayout::Inline => "inline",
417 PointerLayout::Absent => "absent",
418 }
419 }
420}
421
422/// What the pointer is and whether it leads anywhere.
423///
424/// `layout` and `resolves` are independent, and the interesting case is the pair
425/// `Pointer` + `resolves: false`: a symlink that survived the episode it named, which is
426/// what a killed close or a hand-removed record leaves behind. The layout is right, the
427/// destination is gone, and `continuity.state` has nothing to report while the store may
428/// still hold open episodes — so this is a dangling pointer, not an empty store.
429///
430/// ```
431/// use majordomus_cli::capability::builtin::lifecycle::{Pointer, PointerLayout};
432/// use serde_json::json;
433///
434/// // the healthy case: the current layout, and it leads to an episode
435/// let live: Pointer = serde_json::from_value(json!({
436/// "layout": "pointer",
437/// "target": "sessions-open/claude-abc.yaml",
438/// "resolves": true,
439/// "session_id": "s-0002",
440/// "note": "the pointer resolves to an open episode",
441/// })).unwrap();
442/// assert_eq!(live.layout, PointerLayout::Pointer);
443/// assert!(live.resolves && !live.session_id.is_empty());
444///
445/// // and the dangling one: the right layout, aimed at a record that is no longer there
446/// let dangling: Pointer = serde_json::from_value(json!({
447/// "layout": "pointer",
448/// "target": "sessions-open/claude-gone.yaml",
449/// "resolves": false,
450/// "note": "the symlink outlived the episode it named",
451/// })).unwrap();
452/// assert_eq!(dangling.layout, PointerLayout::Pointer, "the layout is not the fault");
453/// assert!(!dangling.resolves);
454/// assert!(dangling.session_id.is_empty(), "there is no episode to name");
455/// ```
456#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
457pub struct Pointer {
458 /// The layout.
459 pub layout: PointerLayout,
460 /// What the symlink names, as written, when it is one.
461 #[serde(default, skip_serializing_if = "String::is_empty")]
462 pub target: String,
463 /// Whether reading through it yields an episode.
464 pub resolves: bool,
465 /// The episode it resolves to.
466 #[serde(default, skip_serializing_if = "String::is_empty")]
467 pub session_id: String,
468 /// What that means, in words.
469 pub note: String,
470}
471
472/// An open episode that cannot close itself, and what to do about it.
473///
474/// The remedy here is the mechanical one — remove the file — because it is correct in every
475/// checkout and needs nothing that is not already installed. `lib/recover.sh` on
476/// `feature/session-store-recovery` is the same three degradations seen from the writing
477/// side: it classifies each candidate by reading it, prints the evidence before acting, and
478/// closes a stranded episode into a real record rather than deleting it. When that lands,
479/// the remedy a finding carries should become `majordomus recover episodes` and the two
480/// detections should become one — a reader and a writer that each decide for themselves
481/// what "stranded" means are two definitions, and this repository has been bitten by that
482/// shape often enough to name it.
483///
484/// ```
485/// use majordomus_cli::capability::builtin::lifecycle::Stranded;
486/// use serde_json::json;
487///
488/// let s: Stranded = serde_json::from_value(json!({
489/// "session_id": "s-0007",
490/// "path": ".ai/local/state/sessions-open/claude-7.yaml",
491/// "reason": "the worktree it opened in is gone from disk",
492/// "remedy": "rm .ai/local/state/sessions-open/claude-7.yaml",
493/// })).unwrap();
494///
495/// // the reason is about the world and the remedy is a command: a finding with no
496/// // remedy is a complaint, so both are required fields rather than optional ones
497/// assert!(!s.reason.is_empty() && !s.remedy.is_empty());
498/// assert!(s.remedy.contains(&s.path), "the remedy acts on the file it names");
499/// ```
500#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
501pub struct Stranded {
502 /// The episode id.
503 pub session_id: String,
504 /// The open record, repository-relative.
505 pub path: String,
506 /// Why it is stranded.
507 pub reason: String,
508 /// The command that clears it. A finding with no remedy is a complaint.
509 pub remedy: String,
510}
511
512/// A temporary file left in a record directory by a close that did not finish.
513///
514/// The close path writes the durable record to `.tmp.XXXX` in the destination directory and
515/// renames it, so that no reader ever sees half a record. A process killed between the two
516/// leaves the temporary file behind, where it is untracked, invisible to every reader of the
517/// section, and — because the directory is tracked — offered to the next `git add .`.
518///
519/// `bytes` is carried because it is the one thing that decides what to do with the file: a
520/// zero-length temporary file is a close that died before it wrote anything and can simply
521/// go, while a large one is a record that was written and never renamed, and is worth
522/// reading before it is deleted.
523///
524/// ```
525/// use majordomus_cli::capability::builtin::lifecycle::Orphan;
526/// use majordomus_cli::order::canonical;
527///
528/// let mut orphans = vec![
529/// Orphan { path: ".ai/repo/project/sessions/.tmp.b2".into(), bytes: 4_812 },
530/// Orphan { path: ".ai/repo/project/sessions/.tmp.a1".into(), bytes: 0 },
531/// ];
532/// canonical(&mut orphans);
533///
534/// // filed under the path, which names one file and so makes the list total
535/// let paths: Vec<&str> = orphans.iter().map(|o| o.path.as_str()).collect();
536/// assert_eq!(paths, [
537/// ".ai/repo/project/sessions/.tmp.a1",
538/// ".ai/repo/project/sessions/.tmp.b2",
539/// ]);
540///
541/// // and the size is what tells a dead stub from a record that was written
542/// assert_eq!(orphans[0].bytes, 0, "nothing was written before the close died");
543/// assert!(orphans[1].bytes > 0, "read this one before removing it");
544/// ```
545#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
546pub struct Orphan {
547 /// Repository-relative path.
548 pub path: String,
549 /// Its size in bytes, which is how a reader tells an empty stub from a lost record.
550 pub bytes: u64,
551}
552
553/// A path names one file, so it is both what a reader looks for and the identity that makes
554/// the orphan list total.
555impl crate::order::Ordered for Orphan {
556 fn order_key(&self) -> crate::order::OrderKey<'_> {
557 crate::order::OrderKey::plain(&self.path, &self.path)
558 }
559}
560
561/// What the ledger says about episode lifecycle against what the store holds.
562///
563/// The invariant ADR 0052 asks for, stated as arithmetic a person can check: every episode
564/// that started either closed or is still open. When `started` exceeds `closed + open`, the
565/// difference is episodes whose close was never recorded — the shape of the outage that ADR
566/// describes, where events kept arriving and the records they should have produced did not.
567///
568/// The sign matters and the two directions mean opposite things. Positive `unaccounted` is
569/// the outage: episodes that started, are not open, and produced no closed record — work
570/// that happened and left nothing behind. Negative is ordinary bookkeeping: the ledger was
571/// rotated, or a record was committed from a checkout whose ledger lines are not in this
572/// one, so there are more records than started lines to account for them.
573///
574/// ```
575/// use majordomus_cli::capability::builtin::lifecycle::Balance;
576/// use serde_json::json;
577///
578/// let of = |started, closed, open, unaccounted, agrees| -> Balance {
579/// serde_json::from_value(json!({
580/// "started": started, "closed": closed, "open": open,
581/// "unaccounted": unaccounted, "agrees": agrees, "note": "as measured",
582/// })).unwrap()
583/// };
584///
585/// // the good case: every episode that started either closed or is still open
586/// let sound = of(40, 37, 3, 0, true);
587/// assert_eq!(sound.started as i64 - sound.closed as i64 - sound.open as i64, sound.unaccounted);
588/// assert!(sound.agrees);
589///
590/// // the outage ADR 0052 describes: four closes that were never recorded
591/// let lost = of(40, 33, 3, 4, false);
592/// assert!(lost.unaccounted > 0 && !lost.agrees);
593///
594/// // and a rotated ledger, which is not a fault in either half
595/// let rotated = of(2, 37, 3, -38, false);
596/// assert!(rotated.unaccounted < 0, "more records than lines to account for them");
597/// ```
598#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
599pub struct Balance {
600 /// `session.started` lines in the ledger.
601 pub started: usize,
602 /// `session.closed` lines.
603 pub closed: usize,
604 /// Open records in the store.
605 pub open: usize,
606 /// `started - closed - open`. Zero is the good case; negative means the ledger was
607 /// rotated under the records, which is ordinary.
608 pub unaccounted: i64,
609 /// Whether the three agree.
610 pub agrees: bool,
611 /// What the numbers mean, in words.
612 pub note: String,
613}
614
615/// What this checkout's session store needs somebody to do.
616///
617/// `findings` is the whole report said once, and an empty `findings` is the verdict: there
618/// is nothing to recover. That is the field to branch on — the three detailed lists and the
619/// arithmetic are what a reader consults *after* deciding there is something to look at,
620/// and each of them can be empty for a good reason while another is not.
621///
622/// ```
623/// use majordomus_cli::capability::builtin::lifecycle::Recovery;
624/// use serde_json::json;
625///
626/// // a healthy checkout: the current layout, nothing stranded, the arithmetic agreeing
627/// let clean: Recovery = serde_json::from_value(json!({
628/// "present": true,
629/// "pointer": {"layout": "pointer", "target": "sessions-open/c.yaml",
630/// "resolves": true, "session_id": "s-1", "note": "resolves"},
631/// "balance": {"started": 8, "closed": 7, "open": 1,
632/// "unaccounted": 0, "agrees": true, "note": "they agree"},
633/// })).unwrap();
634///
635/// assert!(clean.findings.is_empty(), "an empty findings list is the good verdict");
636/// assert!(clean.stranded.is_empty() && clean.orphans.is_empty());
637/// assert!(clean.balance.agrees);
638///
639/// // a fresh clone has no local half at all, which is also nothing to recover
640/// let fresh: Recovery = serde_json::from_value(json!({
641/// "present": false,
642/// "pointer": {"layout": "absent", "resolves": false, "note": "no episode is open here"},
643/// "balance": {"started": 0, "closed": 0, "open": 0,
644/// "unaccounted": 0, "agrees": true, "note": "nothing has happened yet"},
645/// })).unwrap();
646/// assert!(!fresh.present && fresh.findings.is_empty());
647/// ```
648#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
649pub struct Recovery {
650 /// Whether the local half exists at all.
651 pub present: bool,
652 /// The pointer and its layout.
653 pub pointer: Pointer,
654 /// Episodes that can no longer close themselves.
655 #[serde(default, skip_serializing_if = "Vec::is_empty")]
656 pub stranded: Vec<Stranded>,
657 /// Temporary files left in the tracked sessions section.
658 #[serde(default, skip_serializing_if = "Vec::is_empty")]
659 pub orphans: Vec<Orphan>,
660 /// The started/closed/open arithmetic.
661 pub balance: Balance,
662 /// Everything above, said once, for a reader that wants the summary. Empty is the good
663 /// case and means there is nothing to recover.
664 #[serde(default, skip_serializing_if = "Vec::is_empty")]
665 pub findings: Vec<String>,
666}
667
668// --------------------------------------------------------------------- runtime
669
670/// The commit this process answers about, against the commit the repository is on.
671///
672/// Both halves are read here and neither is assumed. `served` is what the index this
673/// process is holding was built from; `repository` is what `git` says now, on this call. A
674/// server that follows the repository reports them equal and pays one `git` invocation for
675/// the proof; a server that froze its index at start-up reports them different, which is
676/// the only way a reader could ever have found that out.
677///
678/// `agree` is the field with the operational meaning, and what it says is *not* "the
679/// repository is fine" but "the answers you are reading are about the tree you are looking
680/// at". When it is false every other capability this process serves is answering about a
681/// commit that is no longer checked out, and nothing else in the API would tell you.
682///
683/// ```
684/// use majordomus_cli::capability::builtin::lifecycle::RuntimeView;
685/// use serde_json::json;
686///
687/// let view = |served: &str, repo: &str, agree: bool| -> RuntimeView {
688/// serde_json::from_value(json!({
689/// "root": "example/repo",
690/// "served_head": served, "served_branch": "master", "served_working_tree": "clean",
691/// "repository_head": repo, "repository_branch": "master",
692/// "repository_working_tree": "clean",
693/// "agree": agree, "objects": 1319, "index_state": "ok",
694/// "note": "as measured on this call",
695/// })).unwrap()
696/// };
697///
698/// // a server following the repository: one `git` invocation buys the proof
699/// let current = view("a1b2c3d", "a1b2c3d", true);
700/// assert!(current.agree);
701/// assert_eq!(current.served_head, current.repository_head);
702/// assert_eq!(current.index_state, "ok");
703///
704/// // and one that froze its index at start-up: every answer it serves is about the old commit
705/// let frozen = view("a1b2c3d", "9f8e7d6", false);
706/// assert!(!frozen.agree);
707/// assert_ne!(frozen.served_head, frozen.repository_head);
708/// ```
709#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
710pub struct RuntimeView {
711 /// The repository root this process is serving.
712 pub root: String,
713 /// The commit the served index was built at.
714 #[serde(default, skip_serializing_if = "String::is_empty")]
715 pub served_head: String,
716 /// Its branch, or `DETACHED`.
717 #[serde(default, skip_serializing_if = "String::is_empty")]
718 pub served_branch: String,
719 /// Whether the working tree was clean when the index was built.
720 #[serde(default, skip_serializing_if = "String::is_empty")]
721 pub served_working_tree: String,
722 /// The commit the repository is on, read on this call.
723 #[serde(default, skip_serializing_if = "String::is_empty")]
724 pub repository_head: String,
725 /// Its branch now.
726 #[serde(default, skip_serializing_if = "String::is_empty")]
727 pub repository_branch: String,
728 /// The working tree now.
729 #[serde(default, skip_serializing_if = "String::is_empty")]
730 pub repository_working_tree: String,
731 /// Whether the two agree on the commit and the branch.
732 pub agree: bool,
733 /// How many objects the served index holds, and whether it was degraded.
734 pub objects: usize,
735 /// `ok` or `degraded`.
736 pub index_state: String,
737 /// What the comparison means, and what to do when it disagrees.
738 pub note: String,
739}
740
741// --------------------------------------------------------------------- providers
742
743/// One provider's lifecycle surface: what its adapter declares it can do, and what this
744/// repository has wired to it.
745///
746/// The two halves come from different places and must not be confused. `lifecycle` and
747/// `prompt_capture` are the *distribution's* declaration — what the shipped adapter can do
748/// for this provider at all — while `wired` is *this repository's* policy naming that
749/// provider's hook in `wired_by`. Either can be empty with the other full, and that pairing
750/// is the interesting finding: a provider this repository wires but the tool ships no
751/// lifecycle for is a hook that will never fire.
752///
753/// Neither half is inferred from a file on disk. Whether a shim is actually installed is
754/// `majordomus capture status`'s question, and this type deliberately does not answer it.
755///
756/// ```
757/// use majordomus_cli::capability::builtin::lifecycle::ProviderLifecycle;
758/// use serde_json::json;
759///
760/// // a provider the tool adapts and this repository wires: both halves present
761/// let full: ProviderLifecycle = serde_json::from_value(json!({
762/// "id": "claude",
763/// "title": "Claude Code",
764/// "lifecycle": ["SessionStart", "SessionEnd", "PreCompact"],
765/// "prompt_capture": true,
766/// "client_config": ".mcp.json",
767/// "wired": ["session.start", "session.close"],
768/// "note": "adapted and wired",
769/// })).unwrap();
770/// assert!(!full.lifecycle.is_empty() && !full.wired.is_empty());
771/// assert!(full.prompt_capture);
772///
773/// // and one the tool ships no adapter for: it loses the automation and none of the model,
774/// // because every command remains the same
775/// let unadapted: ProviderLifecycle = serde_json::from_value(json!({
776/// "id": "some-editor",
777/// "title": "Some Editor",
778/// "prompt_capture": false,
779/// "note": "no lifecycle adapter ships for it",
780/// })).unwrap();
781/// assert!(unadapted.lifecycle.is_empty(), "nothing will fire for it");
782/// assert!(unadapted.client_config.is_empty(), "and it reads no project-scoped config");
783/// ```
784#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
785pub struct ProviderLifecycle {
786 /// The provider id: the bootstrap template's file stem.
787 pub id: String,
788 /// The name a person knows it by.
789 pub title: String,
790 /// The lifecycle events its adapter declares, in the provider's own vocabulary
791 /// (`SessionStart`, `SessionEnd`, `PreCompact`). Empty means the tool ships no
792 /// lifecycle adapter for it: such a provider loses the automation and none of the
793 /// model, because every command remains the same.
794 #[serde(default, skip_serializing_if = "Vec::is_empty")]
795 pub lifecycle: Vec<String>,
796 /// Whether its adapter can archive the worker's prompts.
797 pub prompt_capture: bool,
798 /// The file it reads a project-scoped MCP client configuration from, when it has one.
799 #[serde(default, skip_serializing_if = "String::is_empty")]
800 pub client_config: String,
801 /// The enforcement entries of *this repository's* policy that name this provider's hook
802 /// in `wired_by`. Declared wiring, not a file on disk: whether a shim is installed is a
803 /// question for `majordomus capture status`, which is the command that owns it.
804 #[serde(default, skip_serializing_if = "Vec::is_empty")]
805 pub wired: Vec<String>,
806 /// What the two together mean.
807 pub note: String,
808}
809
810/// Every provider the distribution ships a bootstrap for, with the lifecycle half of each.
811///
812/// `with_lifecycle` is a count over `providers` rather than a second list, so the two
813/// cannot disagree; it is there because "how many of the providers we support actually get
814/// the automation" is the question this capability exists to answer, and a reader should not
815/// have to fold the list to find out.
816///
817/// ```
818/// use majordomus_cli::capability::builtin::lifecycle::ProviderLifecycles;
819/// use serde_json::json;
820///
821/// let p: ProviderLifecycles = serde_json::from_value(json!({
822/// "providers": [
823/// {"id": "claude", "title": "Claude Code", "lifecycle": ["SessionStart"],
824/// "prompt_capture": true, "note": "adapted"},
825/// {"id": "plain", "title": "Plain", "prompt_capture": false, "note": "not adapted"},
826/// ],
827/// "with_lifecycle": 1,
828/// })).unwrap();
829///
830/// // the count is over the list, so it is checkable against it
831/// let counted = p.providers.iter().filter(|x| !x.lifecycle.is_empty()).count();
832/// assert_eq!(p.with_lifecycle, counted);
833/// assert_eq!(p.providers.len(), 2, "a provider with no adapter is still reported");
834/// assert!(p.findings.is_empty());
835/// ```
836#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
837pub struct ProviderLifecycles {
838 /// The providers, in declaration order.
839 pub providers: Vec<ProviderLifecycle>,
840 /// How many of them declare a lifecycle adapter.
841 pub with_lifecycle: usize,
842 /// What a reader should know: a provider this repository wires but the distribution
843 /// declares no lifecycle for, and the like.
844 #[serde(default, skip_serializing_if = "Vec::is_empty")]
845 pub findings: Vec<String>,
846}
847
848// --------------------------------------------------------------------- closed
849
850/// One closed episode of the tracked, durable projection — the half of this subsystem that
851/// survives a clone.
852///
853/// It carries both a `path` and a `uri` because they answer different questions: the path
854/// is where the record is in this checkout, and the `majordomus://session/<identity>`
855/// identifier is what every other surface addresses the same record by, so a reader can
856/// follow it to the object page without constructing one.
857///
858/// Everything but the identity, the identifier and the path is optional, because a record
859/// written by an older close carries fewer fields and is still a record.
860///
861/// ```
862/// use majordomus_cli::capability::builtin::lifecycle::ClosedSession;
863/// use serde_json::json;
864///
865/// let s: ClosedSession = serde_json::from_value(json!({
866/// "session_id": "s-0042",
867/// "uri": "majordomus://session/2026-09-12-s-0042",
868/// "path": ".ai/repo/project/sessions/2026-09-12-s-0042.md",
869/// "created_at": "2026-09-12T15:44:00Z",
870/// "branch": "master",
871/// "head": "cd04012f1",
872/// "outcome": "landed",
873/// })).unwrap();
874///
875/// assert!(s.uri.starts_with("majordomus://session/"), "addressable as an object");
876/// assert_eq!(s.outcome, "landed");
877///
878/// // an older record with less in it is still one, and says so by absence
879/// let sparse: ClosedSession = serde_json::from_value(json!({
880/// "session_id": "s-0001",
881/// "uri": "majordomus://session/2026-08-01-s-0001",
882/// "path": ".ai/repo/project/sessions/2026-08-01-s-0001.md",
883/// })).unwrap();
884/// assert!(sparse.outcome.is_empty() && sparse.title.is_empty());
885/// ```
886#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
887pub struct ClosedSession {
888 /// The episode id.
889 pub session_id: String,
890 /// `majordomus://session/<identity>`, for the object page.
891 pub uri: String,
892 /// Repository-relative path.
893 pub path: String,
894 /// When it closed, as recorded.
895 #[serde(default, skip_serializing_if = "String::is_empty")]
896 pub created_at: String,
897 /// The branch it closed on.
898 #[serde(default, skip_serializing_if = "String::is_empty")]
899 pub branch: String,
900 /// The commit it closed at.
901 #[serde(default, skip_serializing_if = "String::is_empty")]
902 pub head: String,
903 /// The typed outcome the close recorded.
904 #[serde(default, skip_serializing_if = "String::is_empty")]
905 pub outcome: String,
906 /// Its title, when the record carries one.
907 #[serde(default, skip_serializing_if = "String::is_empty")]
908 pub title: String,
909}
910
911/// The tracked half of the subsystem: the records a clone receives.
912///
913/// `total` is exact and `newest` is a window, and keeping them as two numbers is the whole
914/// design of this type. The tracked directory grows without bound and a Cockpit card is not
915/// a listing page, so the rows are capped — but a capped list that did not carry the true
916/// total would quietly understate the repository, which is the shape of defect this
917/// repository has been bitten by often enough to name. `window` says how many rows came
918/// back, so a reader can see the cap rather than infer it.
919///
920/// ```
921/// use majordomus_cli::capability::builtin::lifecycle::ClosedSessions;
922/// use serde_json::json;
923///
924/// let row = |id: &str| json!({
925/// "session_id": id,
926/// "uri": format!("majordomus://session/{id}"),
927/// "path": format!(".ai/repo/project/sessions/{id}.md"),
928/// "branch": "master",
929/// });
930/// let c: ClosedSessions = serde_json::from_value(json!({
931/// "total": 137,
932/// "on_this_branch": 41,
933/// "newest": [row("s-0137"), row("s-0136")],
934/// "window": 2,
935/// })).unwrap();
936///
937/// // the window is what came back; the total is what exists
938/// assert_eq!(c.window, c.newest.len());
939/// assert!(c.total > c.window, "the rows are capped and the total is not");
940/// assert!(c.on_this_branch <= c.total);
941/// ```
942#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
943pub struct ClosedSessions {
944 /// How many closed records the index holds. Exact.
945 pub total: usize,
946 /// How many of them closed on the branch this checkout is on.
947 pub on_this_branch: usize,
948 /// The newest `CLOSED_WINDOW`, newest first.
949 #[serde(default, skip_serializing_if = "Vec::is_empty")]
950 pub newest: Vec<ClosedSession>,
951 /// How many are carried in `newest`, against the total.
952 pub window: usize,
953}
954
955// --------------------------------------------------------------------- reading
956
957/// One ledger line, reduced to the four fields this module reads.
958#[derive(Debug, Clone, Default)]
959struct Line {
960 /// The episode that wrote it, empty when the line belongs to none.
961 session: String,
962 /// The event name.
963 event: String,
964 /// When it was written, as recorded.
965 ts: String,
966 /// The task it belonged to, when it belonged to one. This is the relation ADR 0052
967 /// makes optional in the other direction: an episode names the tasks it touched, and
968 /// no task decides whether the episode's own records are written.
969 task_id: String,
970}
971
972/// Every ledger line, oldest first.
973///
974/// One pass over the file, and a line that does not parse is skipped rather than failing the
975/// call: the ledger is append-only and written by another process, and a reader that refused
976/// the whole history because one line was truncated by a killed write would take the
977/// subsystem's only account of itself away at exactly the moment it is needed.
978fn ledger_lines(path: &Path) -> Vec<Line> {
979 let Ok(text) = std::fs::read_to_string(path) else {
980 return Vec::new();
981 };
982 let field = |v: &serde_json::Value, k: &str| -> String {
983 v.get(k).and_then(|s| s.as_str()).unwrap_or("").to_string()
984 };
985 text.lines()
986 .filter_map(|l| {
987 let v: serde_json::Value = serde_json::from_str(l).ok()?;
988 Some(Line {
989 session: field(&v, "session"),
990 event: field(&v, "event"),
991 ts: field(&v, "ts"),
992 task_id: field(&v, "task_id"),
993 })
994 })
995 .collect()
996}
997
998/// The repository-relative form of a path under `root`, or the path as given.
999fn relative(root: &Path, p: &Path) -> String {
1000 p.strip_prefix(root)
1001 .unwrap_or(p)
1002 .to_string_lossy()
1003 .into_owned()
1004}
1005
1006/// The episode id the pointer resolves to, and how it is laid out.
1007fn pointer(dir: &Path) -> (Pointer, String) {
1008 let path = dir.join(POINTER);
1009 let meta = std::fs::symlink_metadata(&path).ok();
1010 let layout = match &meta {
1011 None => PointerLayout::Absent,
1012 Some(m) if m.file_type().is_symlink() => PointerLayout::Pointer,
1013 Some(_) => PointerLayout::Inline,
1014 };
1015 let target = std::fs::read_link(&path)
1016 .map(|t| t.to_string_lossy().into_owned())
1017 .unwrap_or_default();
1018 let session_id = document(&path)
1019 .and_then(|f| f.get("session_id").cloned())
1020 .unwrap_or_default();
1021 let resolves = !session_id.is_empty();
1022 let note = match layout {
1023 PointerLayout::Absent => {
1024 "No episode is open in this checkout. The provider's start event opens one."
1025 .to_string()
1026 }
1027 PointerLayout::Inline => format!(
1028 "`{POINTER}` is a regular file, which is the layout that existed before an episode was keyed by its provider session. Under it a second start overwrites the first, and two windows of one provider share one record. `majordomus session start` rewrites it as a pointer."
1029 ),
1030 PointerLayout::Pointer if resolves => format!(
1031 "`{POINTER}` points into `{OPEN_DIR}/`, which is the per-provider layout: each window's start event keeps its own episode."
1032 ),
1033 PointerLayout::Pointer => format!(
1034 "`{POINTER}` points at `{target}`, and reading through it yields no episode. The episode it named was removed without the pointer being re-aimed; `majordomus session status` reports what is actually open."
1035 ),
1036 };
1037 (
1038 Pointer {
1039 layout,
1040 target,
1041 resolves,
1042 session_id: session_id.clone(),
1043 note,
1044 },
1045 session_id,
1046 )
1047}
1048
1049/// Every open record in the store, as `(path, fields)`, sorted by file name so that the
1050/// answer does not depend on the order the filesystem happened to hand them back.
1051type OpenRecord = (PathBuf, BTreeMap<String, String>);
1052
1053fn open_records(dir: &Path) -> (Vec<OpenRecord>, Vec<String>) {
1054 let mut out = Vec::new();
1055 let mut findings = Vec::new();
1056 let Ok(entries) = std::fs::read_dir(dir) else {
1057 return (out, findings);
1058 };
1059 let mut paths: Vec<PathBuf> = entries
1060 .flatten()
1061 .map(|e| e.path())
1062 .filter(|p| p.extension().is_some_and(|e| e == "yaml"))
1063 .collect();
1064 paths.sort();
1065 for p in paths {
1066 match document(&p) {
1067 Some(f) if f.contains_key("session_id") => out.push((p, f)),
1068 _ => findings.push(format!(
1069 "the open record {} does not parse as an episode and was skipped; nothing about it is counted below",
1070 p.file_name().unwrap_or_default().to_string_lossy()
1071 )),
1072 }
1073 }
1074 (out, findings)
1075}
1076
1077// --------------------------------------------------------------------- handlers
1078
1079fn episodes(ctx: &Context, _: Empty) -> Result<Episodes, CapabilityError> {
1080 let root = PathBuf::from(&ctx.index.repository.root);
1081 let dir = root.join(STATE_DIR);
1082 let store = dir.join(OPEN_DIR);
1083
1084 let (p, current) = pointer(&dir);
1085 let (records, mut findings) = open_records(&store);
1086 if !p.resolves && p.layout != PointerLayout::Absent {
1087 findings.push(p.note.clone());
1088 }
1089 let ledger = ledger_lines(&dir.join(LEDGER));
1090
1091 let here = root.to_string_lossy().into_owned();
1092 let mut episodes: Vec<Episode> = records
1093 .into_iter()
1094 .map(|(path, f)| {
1095 let get = |k: &str| f.get(k).cloned().unwrap_or_default();
1096 let session_id = get("session_id");
1097 let worktree = get("worktree");
1098 let mine = worktree.is_empty() || worktree == here;
1099 let exists = worktree.is_empty() || Path::new(&worktree).is_dir();
1100 let standing = if !exists {
1101 EpisodeStanding::Stranded
1102 } else if !mine {
1103 EpisodeStanding::Foreign
1104 } else if session_id == current {
1105 EpisodeStanding::Current
1106 } else {
1107 EpisodeStanding::Open
1108 };
1109 let note = match standing {
1110 EpisodeStanding::Current => format!(
1111 "This checkout's `{POINTER}` resolves to this episode, so it is the one `continuity.state` and the briefing are about."
1112 ),
1113 EpisodeStanding::Open => "Open in this worktree, and the pointer names a different episode. Its ledger lines are stamped with its own id; only the briefing follows the pointer.".to_string(),
1114 EpisodeStanding::Foreign => format!(
1115 "Opened in {worktree}, not here. It is listed because it shares this repository's store, and nothing about it is about the work in this checkout."
1116 ),
1117 EpisodeStanding::Stranded => format!(
1118 "The worktree it opened in, {worktree}, is gone from disk. The close path composes its record from git there, so nothing can close it in place."
1119 ),
1120 };
1121 let mut events = 0usize;
1122 let mut last_activity = String::new();
1123 let mut last_event = String::new();
1124 let mut tasks: Vec<String> = Vec::new();
1125 for l in &ledger {
1126 if l.session == session_id {
1127 events += 1;
1128 if l.ts.as_str() >= last_activity.as_str() {
1129 last_activity.clone_from(&l.ts);
1130 last_event.clone_from(&l.event);
1131 }
1132 if !l.task_id.is_empty() && !tasks.contains(&l.task_id) {
1133 tasks.push(l.task_id.clone());
1134 }
1135 }
1136 }
1137 Episode {
1138 session_id,
1139 path: relative(&root, &path),
1140 provider: get("provider"),
1141 provider_session: get("provider_session"),
1142 owner: get("owner"),
1143 worker: get("worker"),
1144 worktree,
1145 branch: get("branch"),
1146 start_head: get("start_head"),
1147 started_at: get("started_at"),
1148 standing,
1149 events,
1150 last_activity,
1151 last_event,
1152 tasks,
1153 note,
1154 }
1155 })
1156 .collect();
1157 // Newest first by what the record asserts, never by file modification time: mtime does
1158 // not survive a clone and is not the time the record claims.
1159 episodes.sort_by(|a, b| {
1160 b.started_at
1161 .cmp(&a.started_at)
1162 .then_with(|| a.session_id.cmp(&b.session_id))
1163 });
1164
1165 if episodes.len() > 1 {
1166 findings.push(format!(
1167 "{} episodes are open in this store. That is ordinary — one per provider window — and it is also the state in which `continuity.state` can name only the one the pointer follows.",
1168 episodes.len()
1169 ));
1170 }
1171
1172 Ok(Episodes {
1173 present: store.is_dir(),
1174 store: relative(&root, &store),
1175 worktree: here,
1176 current,
1177 episodes,
1178 findings,
1179 })
1180}
1181
1182fn recovery(ctx: &Context, _: Empty) -> Result<Recovery, CapabilityError> {
1183 let root = PathBuf::from(&ctx.index.repository.root);
1184 let dir = root.join(STATE_DIR);
1185 let store = dir.join(OPEN_DIR);
1186
1187 let (p, _) = pointer(&dir);
1188 let (records, mut findings) = open_records(&store);
1189 let ledger = ledger_lines(&dir.join(LEDGER));
1190
1191 let started = ledger
1192 .iter()
1193 .filter(|l| l.event == "session.started")
1194 .count();
1195 let closed_events: Vec<&str> = ledger
1196 .iter()
1197 .filter(|l| l.event == "session.closed")
1198 .map(|l| l.session.as_str())
1199 .collect();
1200
1201 let here = root.to_string_lossy().into_owned();
1202 let mut stranded = Vec::new();
1203 for (path, f) in &records {
1204 let session_id = f.get("session_id").cloned().unwrap_or_default();
1205 let worktree = f.get("worktree").cloned().unwrap_or_default();
1206 let rel = relative(&root, path);
1207 if !worktree.is_empty() && !Path::new(&worktree).is_dir() {
1208 stranded.push(Stranded {
1209 session_id: session_id.clone(),
1210 path: rel.clone(),
1211 reason: format!(
1212 "the worktree it opened in, {worktree}, is gone from disk, and the close path composes its record from git there"
1213 ),
1214 remedy: format!("rm {rel}"),
1215 });
1216 continue;
1217 }
1218 if closed_events.contains(&session_id.as_str()) {
1219 stranded.push(Stranded {
1220 session_id: session_id.clone(),
1221 path: rel.clone(),
1222 reason: "the ledger records this episode as closed, and its open record is still here; the close wrote the durable record and did not remove the file it was composed from".into(),
1223 remedy: format!("rm {rel}"),
1224 });
1225 continue;
1226 }
1227 if !worktree.is_empty() && worktree != here && !Path::new(&worktree).join(".git").exists() {
1228 stranded.push(Stranded {
1229 session_id,
1230 path: rel.clone(),
1231 reason: format!("it names {worktree}, which is not a checkout of this repository"),
1232 remedy: format!("rm {rel}"),
1233 });
1234 }
1235 }
1236
1237 // The temporary files of the tracked sessions section. The section's path is the
1238 // manifest's, never a constant here: a repository that files its sessions elsewhere is
1239 // still checked, and one that declares no sessions section has nothing to check. The
1240 // value is already repository-relative and already carries `.ai/` — `Repository::
1241 // section_path` puts it there — so joining `.ai` again would look under `.ai/.ai/`,
1242 // find nothing, and report a clean store in a checkout that has orphans.
1243 let mut orphans = Vec::new();
1244 if let Some(section) = ctx.index.repository.sections.get("sessions") {
1245 let sessions = root.join(section);
1246 if let Ok(entries) = std::fs::read_dir(&sessions) {
1247 let mut found: Vec<Orphan> = entries
1248 .flatten()
1249 .filter(|e| e.file_name().to_string_lossy().starts_with(".tmp."))
1250 .map(|e| Orphan {
1251 path: relative(&root, &e.path()),
1252 bytes: e.metadata().map(|m| m.len()).unwrap_or(0),
1253 })
1254 .collect();
1255 crate::order::canonical(&mut found);
1256 orphans.extend(found);
1257 }
1258 }
1259
1260 let open = records.len();
1261 let closed = closed_events.len();
1262 let unaccounted = started as i64 - closed as i64 - open as i64;
1263 let agrees = unaccounted == 0;
1264 let balance = Balance {
1265 started,
1266 closed,
1267 open,
1268 unaccounted,
1269 agrees,
1270 note: if agrees {
1271 format!("{started} started, {closed} closed, {open} still open: every episode the ledger has seen is accounted for.")
1272 } else if unaccounted > 0 {
1273 format!(
1274 "{started} started, {closed} closed, {open} still open: {unaccounted} episode(s) started and neither closed nor left a record here. An end event that reached the tool and produced nothing looks exactly like this (ADR 0052)."
1275 )
1276 } else {
1277 format!(
1278 "{started} started, {closed} closed, {open} still open: more closes and open records than starts, which is what a rotated ledger looks like — `history --rotate` moves the oldest lines into a dated archive and the starts went with them."
1279 )
1280 },
1281 };
1282
1283 if !stranded.is_empty() {
1284 findings.push(format!(
1285 "{} open record(s) cannot be closed by the worker that opened them.",
1286 stranded.len()
1287 ));
1288 }
1289 if !orphans.is_empty() {
1290 findings.push(format!(
1291 "{} temporary file(s) are sitting in the tracked sessions section. They are untracked, no reader of the section sees them, and the next `git add .` would commit them.",
1292 orphans.len()
1293 ));
1294 }
1295 if p.layout == PointerLayout::Inline {
1296 findings.push(p.note.clone());
1297 }
1298 if !agrees && unaccounted > 0 {
1299 findings.push(balance.note.clone());
1300 }
1301
1302 Ok(Recovery {
1303 present: dir.is_dir(),
1304 pointer: p,
1305 stranded,
1306 orphans,
1307 balance,
1308 findings,
1309 })
1310}
1311
1312fn runtime(ctx: &Context, _: Empty) -> Result<RuntimeView, CapabilityError> {
1313 let root = PathBuf::from(&ctx.index.repository.root);
1314 let (served_branch, served_head, served_working_tree) = match &ctx.index.repository.git {
1315 GitState::Available(i) => (
1316 i.branch.clone().unwrap_or_else(|| "DETACHED".into()),
1317 i.head.clone().unwrap_or_default(),
1318 i.working_tree.clone(),
1319 ),
1320 GitState::Unavailable { .. } => (String::new(), String::new(), String::new()),
1321 };
1322 let (repository_branch, repository_head, repository_working_tree) = match git::inspect(&root) {
1323 GitState::Available(i) => (
1324 i.branch.clone().unwrap_or_else(|| "DETACHED".into()),
1325 i.head.clone().unwrap_or_default(),
1326 i.working_tree.clone(),
1327 ),
1328 GitState::Unavailable { .. } => (String::new(), String::new(), String::new()),
1329 };
1330 let agree = served_head == repository_head && served_branch == repository_branch;
1331 let note = if served_head.is_empty() || repository_head.is_empty() {
1332 "Git could not be asked on one side of this comparison, so this process says so rather than reporting agreement it did not establish.".to_string()
1333 } else if agree {
1334 format!(
1335 "This process is answering about {}, which is the commit the repository is on. A long-lived server that built its index once and held it would report a commit from whenever it started, from here, from the HTTP API and from MCP alike, with nothing saying the picture was old.",
1336 &repository_head[..7.min(repository_head.len())]
1337 )
1338 } else {
1339 format!(
1340 "This process is answering about {} while the repository is on {}. Everything it reports — the index, the objects, this page — is a picture of the older commit. Restart the shared server, or run the command against a fresh process.",
1341 &served_head[..7.min(served_head.len())],
1342 &repository_head[..7.min(repository_head.len())]
1343 )
1344 };
1345 Ok(RuntimeView {
1346 root: root.to_string_lossy().into_owned(),
1347 served_head,
1348 served_branch,
1349 served_working_tree,
1350 repository_head,
1351 repository_branch,
1352 repository_working_tree,
1353 agree,
1354 objects: ctx.index.objects.len(),
1355 index_state: match ctx.index.state {
1356 crate::index::State::Ok => "ok".into(),
1357 crate::index::State::Degraded => "degraded".into(),
1358 },
1359 note,
1360 })
1361}
1362
1363fn providers(ctx: &Context, _: Empty) -> Result<ProviderLifecycles, CapabilityError> {
1364 // Which enforcement entries this repository wires to a provider hook. The policy is
1365 // read as the index holds it — an object of kind `policy` — because that is the reading
1366 // every other surface uses, and a second parse of the same file is a second answer.
1367 let policy = ctx.index.objects.iter().find(|o| o.kind == "policy");
1368 let mut wired: BTreeMap<String, Vec<String>> = BTreeMap::new();
1369 if let Some(entries) = policy
1370 .and_then(|p| p.metadata.get("enforcement"))
1371 .and_then(|v| v.as_array())
1372 {
1373 for e in entries {
1374 let Some(w) = e.get("wired_by").and_then(|v| v.as_str()) else {
1375 continue;
1376 };
1377 let Some(rest) = w.strip_prefix("provider-hook:") else {
1378 continue;
1379 };
1380 let id = rest.split(':').next().unwrap_or(rest);
1381 if let Some(name) = e.get("name").and_then(|v| v.as_str()) {
1382 wired.entry(id.to_string()).or_default().push(name.into());
1383 }
1384 }
1385 }
1386
1387 let mut findings = Vec::new();
1388 let providers: Vec<ProviderLifecycle> = ctx
1389 .index
1390 .providers
1391 .providers
1392 .iter()
1393 .map(|d| {
1394 let w = wired.get(&d.id).cloned().unwrap_or_default();
1395 let note = match (d.lifecycle.is_empty(), w.is_empty()) {
1396 (true, true) => format!(
1397 "The distribution ships no lifecycle adapter for {}. A worker using it loses the automation and none of the model: every command is the same, and running them is again a matter of remembering.",
1398 d.title
1399 ),
1400 (true, false) => format!(
1401 "This repository wires {} to {}'s hook, and the distribution declares no lifecycle events for it. One of the two is wrong.",
1402 w.join(", "),
1403 d.title
1404 ),
1405 (false, true) => format!(
1406 "{} declares {}, and this repository's policy wires none of it. The adapter exists; nothing here asks it to run.",
1407 d.title,
1408 d.lifecycle.join(", ")
1409 ),
1410 (false, false) => format!(
1411 "{} declares {}, and this repository wires {} to its hook.",
1412 d.title,
1413 d.lifecycle.join(", "),
1414 w.join(", ")
1415 ),
1416 };
1417 if d.lifecycle.is_empty() && !w.is_empty() {
1418 findings.push(note.clone());
1419 }
1420 ProviderLifecycle {
1421 id: d.id.clone(),
1422 title: d.title.clone(),
1423 lifecycle: d.lifecycle.clone(),
1424 prompt_capture: d.prompt_capture,
1425 client_config: d.client_config.clone().unwrap_or_default(),
1426 wired: w,
1427 note,
1428 }
1429 })
1430 .collect();
1431
1432 let with_lifecycle = providers.iter().filter(|p| !p.lifecycle.is_empty()).count();
1433 Ok(ProviderLifecycles {
1434 providers,
1435 with_lifecycle,
1436 findings,
1437 })
1438}
1439
1440fn closed(ctx: &Context, _: Empty) -> Result<ClosedSessions, CapabilityError> {
1441 let branch = match &ctx.index.repository.git {
1442 GitState::Available(i) => i.branch.clone().unwrap_or_default(),
1443 GitState::Unavailable { .. } => String::new(),
1444 };
1445 let field = |o: &crate::model::Object, k: &str| {
1446 o.metadata
1447 .get(k)
1448 .and_then(|v| v.as_str())
1449 .unwrap_or_default()
1450 .to_string()
1451 };
1452 let mut all: Vec<ClosedSession> = ctx
1453 .index
1454 .objects
1455 .iter()
1456 .filter(|o| o.kind == "session")
1457 .map(|o| ClosedSession {
1458 session_id: field(o, "session_id"),
1459 uri: o.uri.clone(),
1460 path: o.provenance.path.clone(),
1461 created_at: field(o, "created_at"),
1462 branch: field(o, "branch"),
1463 head: field(o, "head"),
1464 outcome: field(o, "outcome"),
1465 title: o.title.clone().unwrap_or_default(),
1466 })
1467 .collect();
1468 all.sort_by(|a, b| {
1469 b.created_at
1470 .cmp(&a.created_at)
1471 .then_with(|| a.session_id.cmp(&b.session_id))
1472 });
1473 let total = all.len();
1474 let on_this_branch = if branch.is_empty() {
1475 0
1476 } else {
1477 all.iter().filter(|s| s.branch == branch).count()
1478 };
1479 all.truncate(CLOSED_WINDOW);
1480 Ok(ClosedSessions {
1481 total,
1482 on_this_branch,
1483 window: all.len(),
1484 newest: all,
1485 })
1486}
1487
1488/// The module's one declaration: the five capabilities, their schemas and every projection
1489/// of them.
1490///
1491/// This function is the canonical definition and not a registration of one made elsewhere.
1492/// The HTTP routes, the MCP tools and resources, the OpenAPI components and the Cockpit
1493/// cards are all derived from what is declared here, which is why none of them is editable
1494/// on its own — a semantic definition repeated across projections is the design defect
1495/// ADR 0004 names.
1496///
1497/// ```
1498/// use majordomus_cli::capability::builtin::lifecycle::{self, EPISODES_URI, RECOVERY_URI};
1499///
1500/// let m = lifecycle::module();
1501/// assert_eq!(m.id.as_str(), "lifecycle");
1502/// assert_eq!(m.capabilities.len(), 5);
1503///
1504/// // the routes are derived from the declaration and written down nowhere else
1505/// let routes: Vec<&str> = m
1506/// .capabilities
1507/// .iter()
1508/// .filter_map(|e| e.capability.exposure.http.as_ref().map(|h| h.path.as_str()))
1509/// .collect();
1510/// assert_eq!(routes, [
1511/// "/api/v1/lifecycle/episodes",
1512/// "/api/v1/lifecycle/recovery",
1513/// "/api/v1/lifecycle/runtime",
1514/// "/api/v1/lifecycle/providers",
1515/// "/api/v1/lifecycle/closed",
1516/// ]);
1517///
1518/// // and the two readable resources are addressed by the constants this module exports,
1519/// // so a caller never spells a URI that the declaration does not carry
1520/// let resources: Vec<&str> = m
1521/// .capabilities
1522/// .iter()
1523/// .filter_map(|e| e.capability.exposure.mcp.as_ref())
1524/// .filter_map(|x| x.resource.as_ref())
1525/// .map(|r| r.uri.as_str())
1526/// .collect();
1527/// assert!(resources.contains(&EPISODES_URI));
1528/// assert!(resources.contains(&RECOVERY_URI));
1529/// ```
1530pub fn module() -> ModuleDescriptor {
1531 module! {
1532 id: "lifecycle",
1533 title: "Session lifecycle",
1534 description: "The episode lifecycle as an operator sees it: every open episode of this checkout's store rather than only the one the pointer follows, the episodes that can no longer close themselves, the commit this process is answering about against the commit the repository is on, what each provider's adapter declares it can do, and the tracked records a clone receives. Read from the local half of the layer and from the index; written by nothing here.",
1535 stability: Stability::BehaviorallyVerified,
1536 capabilities: [
1537 capability! {
1538 id: "lifecycle.episodes",
1539 title: "Every open episode",
1540 description: "Every open episode in this checkout's store, with the provider session that owns it, the worktree and branch it opened on, where it stands, and what the ledger last saw it do. `continuity.state` reports the one episode the pointer resolves to, which is right for a briefing and blind to every other window open on the same worktree.",
1541 input: Empty,
1542 output: Episodes,
1543 stability: Stability::BehaviorallyVerified,
1544 exposure: Exposure {
1545 mcp: Some(McpExposure {
1546 tool: Some("majordomus_lifecycle_episodes".into()),
1547 resource: Some(McpResource { uri: EPISODES_URI.into(), name: "lifecycle-episodes".into() }),
1548 }),
1549 http: get("/api/v1/lifecycle/episodes"),
1550 cli: None,
1551 },
1552 tags: ["continuity", "session", "episode"],
1553 // Short-lived for the same reason continuity.state is: another process owns
1554 // these files, and a reader that cached them for a minute would answer with
1555 // an episode that had already closed.
1556 cache: CachePolicy::Process { max_entries: 2, ttl_seconds: Some(2) },
1557 handler: episodes,
1558 },
1559 capability! {
1560 id: "lifecycle.recovery",
1561 title: "What the store needs somebody to do",
1562 description: "Open records that can no longer close themselves, temporary files a killed close left in the tracked sessions section, whether the pointer has been migrated to the per-provider layout, and the arithmetic that says whether every episode the ledger has seen start is accounted for. Every finding carries the command that clears it.",
1563 input: Empty,
1564 output: Recovery,
1565 stability: Stability::BehaviorallyVerified,
1566 exposure: Exposure {
1567 mcp: Some(McpExposure {
1568 tool: Some("majordomus_lifecycle_recovery".into()),
1569 resource: Some(McpResource { uri: RECOVERY_URI.into(), name: "lifecycle-recovery".into() }),
1570 }),
1571 http: get("/api/v1/lifecycle/recovery"),
1572 cli: None,
1573 },
1574 tags: ["continuity", "session", "recovery"],
1575 cache: CachePolicy::Process { max_entries: 2, ttl_seconds: Some(2) },
1576 handler: recovery,
1577 },
1578 capability! {
1579 id: "lifecycle.runtime",
1580 title: "The commit this process is answering about",
1581 description: "The commit the served index was built at, against the commit the repository is on, read on this call. A server that froze its index at start-up answers every question about a commit from hours ago with nothing in the answer saying so; this is the reading that can tell.",
1582 input: Empty,
1583 output: RuntimeView,
1584 stability: Stability::BehaviorallyVerified,
1585 exposure: Exposure {
1586 mcp: Some(McpExposure { tool: Some("majordomus_lifecycle_runtime".into()), resource: None }),
1587 http: get("/api/v1/lifecycle/runtime"),
1588 cli: None,
1589 },
1590 tags: ["continuity", "session", "server"],
1591 cache: CachePolicy::Process { max_entries: 2, ttl_seconds: Some(2) },
1592 handler: runtime,
1593 },
1594 capability! {
1595 id: "lifecycle.providers",
1596 title: "What each provider's adapter declares",
1597 description: "Per provider: the lifecycle events its adapter declares in the provider's own vocabulary, whether it can archive prompts, and which of this repository's enforcement entries name its hook. Declared in share/providers.yaml and in the policy; never inferred from whether a shim happens to be on disk.",
1598 input: Empty,
1599 output: ProviderLifecycles,
1600 stability: Stability::BehaviorallyVerified,
1601 exposure: Exposure {
1602 mcp: Some(McpExposure { tool: Some("majordomus_lifecycle_providers".into()), resource: None }),
1603 http: get("/api/v1/lifecycle/providers"),
1604 cli: None,
1605 },
1606 tags: ["continuity", "session", "provider"],
1607 cache: CachePolicy::Process { max_entries: 2, ttl_seconds: None },
1608 handler: providers,
1609 },
1610 capability! {
1611 id: "lifecycle.closed",
1612 title: "The records a clone receives",
1613 description: "The tracked, durable projection of closed episodes: how many there are, how many closed on this branch, and the newest twenty. This is the only half of the subsystem that survives a clone; everything under .ai/local/ names this machine and travels nowhere.",
1614 input: Empty,
1615 output: ClosedSessions,
1616 stability: Stability::BehaviorallyVerified,
1617 exposure: Exposure {
1618 mcp: Some(McpExposure { tool: Some("majordomus_lifecycle_closed".into()), resource: None }),
1619 http: get("/api/v1/lifecycle/closed"),
1620 cli: None,
1621 },
1622 tags: ["continuity", "session", "record"],
1623 cache: CachePolicy::Process { max_entries: 2, ttl_seconds: Some(30) },
1624 handler: closed,
1625 },
1626 ],
1627 }
1628}
1629
1630#[cfg(test)]
1631mod tests {
1632 use super::*;
1633
1634 /// The declaration is the only place the ids, the tool names, the resource URIs and the
1635 /// routes exist. A refactor that dropped one of them would still compile and every test
1636 /// of the reports themselves would still pass; this is the assertion that would not.
1637 #[test]
1638 fn the_declaration_yields_the_identities_and_the_projections_it_claims() {
1639 let m = module();
1640 assert_eq!(m.id.as_str(), "lifecycle");
1641 let ids: Vec<&str> = m
1642 .capabilities
1643 .iter()
1644 .map(|e| e.capability.id.as_str())
1645 .collect();
1646 assert_eq!(
1647 ids,
1648 [
1649 "lifecycle.episodes",
1650 "lifecycle.recovery",
1651 "lifecycle.runtime",
1652 "lifecycle.providers",
1653 "lifecycle.closed"
1654 ]
1655 );
1656 // every one of them is read-only, and the registry refuses an executable that is not
1657 for e in &m.capabilities {
1658 assert!(e.capability.kind.is_read_only() && e.capability.kind.is_executable());
1659 assert!(e.capability.id.as_str().starts_with("lifecycle."));
1660 }
1661 let episodes = &m.capabilities[0].capability;
1662 assert_eq!(
1663 episodes
1664 .exposure
1665 .mcp
1666 .as_ref()
1667 .and_then(|m| m.tool.as_deref()),
1668 Some("majordomus_lifecycle_episodes")
1669 );
1670 assert_eq!(
1671 episodes.exposure.http.as_ref().map(|h| h.path.as_str()),
1672 Some("/api/v1/lifecycle/episodes")
1673 );
1674 }
1675
1676 /// A standing is a word a person reads, and the four are the whole vocabulary. A fifth
1677 /// added without a word would serialise as something no surface has a sentence for.
1678 #[test]
1679 fn every_standing_has_a_word() {
1680 for s in [
1681 EpisodeStanding::Current,
1682 EpisodeStanding::Open,
1683 EpisodeStanding::Foreign,
1684 EpisodeStanding::Stranded,
1685 ] {
1686 assert!(!s.as_str().is_empty());
1687 }
1688 assert_eq!(EpisodeStanding::Foreign.as_str(), "foreign");
1689 assert_eq!(PointerLayout::Pointer.as_str(), "pointer");
1690 }
1691}