Skip to main content

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}