Skip to content

HTTP API: lifecycle

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.

Rendered from docs/generated/openapi.json, which the executable generates from its capability registry and CI refuses stale. The raw document is at /openapi.json; a running server serves the same document at its own /openapi.json and Swagger UI at /swagger.

lifecycle

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.

GET /api/v1/lifecycle/closed lifecycle.closed

The records a clone receives

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.

  • query
  • behaviorally_verified
  • benchmark required
  • cache process (max_entries=2, ttl_seconds=30)
  • MCP tool majordomus_lifecycle_closed

No parameters.

Try it

The port is whatever the server logged; 8741 is the one it asks for first. The values are the operation's first example.

curl -s "http://127.0.0.1:8741/api/v1/lifecycle/closed"

Tested by apps/majordomus-cli/tests/http_serve.rs, which replays the route with these values against a server on a real socket.

Responses

statusbodymeaning
200 ClosedSessions The records a clone receives: the result.
400 ErrorBody invalid_input: The input does not fit the schema: a parameter of the wrong type, an unknown one, or a value the capability rejects.
404 ErrorBody not_found: The input names something the repository does not hold.
500 ErrorBody internal: The capability failed for a reason of its own; the message names it.
default ErrorBody Any other failure the transport reports: 405 method_not_allowed for another method on this path, 400 invalid_input for a body that is not a JSON object.
GET /api/v1/lifecycle/episodes lifecycle.episodes

Every open episode

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.

  • query
  • behaviorally_verified
  • benchmark required
  • cache process (max_entries=2, ttl_seconds=2)
  • MCP tool majordomus_lifecycle_episodes
  • MCP resource {"name": "lifecycle-episodes", "uri": "majordomus://lifecycle/episodes"}

No parameters.

Try it

The port is whatever the server logged; 8741 is the one it asks for first. The values are the operation's first example.

curl -s "http://127.0.0.1:8741/api/v1/lifecycle/episodes"

Tested by apps/majordomus-cli/tests/http_serve.rs, which replays the route with these values against a server on a real socket.

Responses

statusbodymeaning
200 Episodes Every open episode: the result.
400 ErrorBody invalid_input: The input does not fit the schema: a parameter of the wrong type, an unknown one, or a value the capability rejects.
404 ErrorBody not_found: The input names something the repository does not hold.
500 ErrorBody internal: The capability failed for a reason of its own; the message names it.
default ErrorBody Any other failure the transport reports: 405 method_not_allowed for another method on this path, 400 invalid_input for a body that is not a JSON object.
GET /api/v1/lifecycle/providers lifecycle.providers

What each provider's adapter declares

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.

  • query
  • behaviorally_verified
  • benchmark required
  • cache process (max_entries=2)
  • MCP tool majordomus_lifecycle_providers

No parameters.

Try it

The port is whatever the server logged; 8741 is the one it asks for first. The values are the operation's first example.

curl -s "http://127.0.0.1:8741/api/v1/lifecycle/providers"

Tested by apps/majordomus-cli/tests/http_serve.rs, which replays the route with these values against a server on a real socket.

Responses

statusbodymeaning
200 ProviderLifecycles What each provider's adapter declares: the result.
400 ErrorBody invalid_input: The input does not fit the schema: a parameter of the wrong type, an unknown one, or a value the capability rejects.
404 ErrorBody not_found: The input names something the repository does not hold.
500 ErrorBody internal: The capability failed for a reason of its own; the message names it.
default ErrorBody Any other failure the transport reports: 405 method_not_allowed for another method on this path, 400 invalid_input for a body that is not a JSON object.
GET /api/v1/lifecycle/recovery lifecycle.recovery

What the store needs somebody to do

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.

  • query
  • behaviorally_verified
  • benchmark required
  • cache process (max_entries=2, ttl_seconds=2)
  • MCP tool majordomus_lifecycle_recovery
  • MCP resource {"name": "lifecycle-recovery", "uri": "majordomus://lifecycle/recovery"}

No parameters.

Try it

The port is whatever the server logged; 8741 is the one it asks for first. The values are the operation's first example.

curl -s "http://127.0.0.1:8741/api/v1/lifecycle/recovery"

Tested by apps/majordomus-cli/tests/http_serve.rs, which replays the route with these values against a server on a real socket.

Responses

statusbodymeaning
200 Recovery What the store needs somebody to do: the result.
400 ErrorBody invalid_input: The input does not fit the schema: a parameter of the wrong type, an unknown one, or a value the capability rejects.
404 ErrorBody not_found: The input names something the repository does not hold.
500 ErrorBody internal: The capability failed for a reason of its own; the message names it.
default ErrorBody Any other failure the transport reports: 405 method_not_allowed for another method on this path, 400 invalid_input for a body that is not a JSON object.
GET /api/v1/lifecycle/runtime lifecycle.runtime

The commit this process is answering about

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.

  • query
  • behaviorally_verified
  • benchmark required
  • cache process (max_entries=2, ttl_seconds=2)
  • MCP tool majordomus_lifecycle_runtime

No parameters.

Try it

The port is whatever the server logged; 8741 is the one it asks for first. The values are the operation's first example.

curl -s "http://127.0.0.1:8741/api/v1/lifecycle/runtime"

Tested by apps/majordomus-cli/tests/http_serve.rs, which replays the route with these values against a server on a real socket.

Responses

statusbodymeaning
200 RuntimeView The commit this process is answering about: the result.
400 ErrorBody invalid_input: The input does not fit the schema: a parameter of the wrong type, an unknown one, or a value the capability rejects.
404 ErrorBody not_found: The input names something the repository does not hold.
500 ErrorBody internal: The capability failed for a reason of its own; the message names it.
default ErrorBody Any other failure the transport reports: 405 method_not_allowed for another method on this path, 400 invalid_input for a body that is not a JSON object.

Schemas

The components only this tag's operations use. Each row is the type's own field, its doc comment the description; a type another page owns links there.

Balance

object
What the ledger says about episode lifecycle against what the store holds.

The invariant ADR 0052 asks for, stated as arithmetic a person can check: every episode that started either closed or is still open. When started exceeds closed + open, the difference is episodes whose close was never recorded — the shape of the outage that ADR describes, where events kept arriving and the records they should have produced did not.

The sign matters and the two directions mean opposite things. Positive unaccounted is the outage: episodes that started, are not open, and produced no closed record — work that happened and left nothing behind. Negative is ordinary bookkeeping: the ledger was rotated, or a record was committed from a checkout whose ledger lines are not in this one, so there are more records than started lines to account for them.

use majordomus_cli::capability::builtin::lifecycle::Balance;
use serde_json::json;

let of = |started, closed, open, unaccounted, agrees| -> Balance {
    serde_json::from_value(json!({
        "started": started, "closed": closed, "open": open,
        "unaccounted": unaccounted, "agrees": agrees, "note": "as measured",
    })).unwrap()
};

// the good case: every episode that started either closed or is still open
let sound = of(40, 37, 3, 0, true);
assert_eq!(sound.started as i64 - sound.closed as i64 - sound.open as i64, sound.unaccounted);
assert!(sound.agrees);

// the outage ADR 0052 describes: four closes that were never recorded
let lost = of(40, 33, 3, 4, false);
assert!(lost.unaccounted > 0 && !lost.agrees);

// and a rotated ledger, which is not a fault in either half
let rotated = of(2, 37, 3, -38, false);
assert!(rotated.unaccounted < 0, "more records than lines to account for them");
propertytyperequireddescription
started integer (uint) yes session.started lines in the ledger.
closed integer (uint) yes session.closed lines.
open integer (uint) yes Open records in the store.
unaccounted integer (int64) yes started - closed - open. Zero is the good case; negative means the ledger was rotated under the records, which is ordinary.
agrees boolean yes Whether the three agree.
note string yes What the numbers mean, in words.

Used by Recovery

ClosedSession

object
One closed episode of the tracked, durable projection — the half of this subsystem that survives a clone.

It carries both a path and a uri because they answer different questions: the path is where the record is in this checkout, and the majordomus://session/<identity> identifier is what every other surface addresses the same record by, so a reader can follow it to the object page without constructing one.

Everything but the identity, the identifier and the path is optional, because a record written by an older close carries fewer fields and is still a record.

use majordomus_cli::capability::builtin::lifecycle::ClosedSession;
use serde_json::json;

let s: ClosedSession = serde_json::from_value(json!({
    "session_id": "s-0042",
    "uri": "majordomus://session/2026-09-12-s-0042",
    "path": ".ai/repo/project/sessions/2026-09-12-s-0042.md",
    "created_at": "2026-09-12T15:44:00Z",
    "branch": "master",
    "head": "cd04012f1",
    "outcome": "landed",
})).unwrap();

assert!(s.uri.starts_with("majordomus://session/"), "addressable as an object");
assert_eq!(s.outcome, "landed");

// an older record with less in it is still one, and says so by absence
let sparse: ClosedSession = serde_json::from_value(json!({
    "session_id": "s-0001",
    "uri": "majordomus://session/2026-08-01-s-0001",
    "path": ".ai/repo/project/sessions/2026-08-01-s-0001.md",
})).unwrap();
assert!(sparse.outcome.is_empty() && sparse.title.is_empty());
propertytyperequireddescription
session_id string yes The episode id.
uri string yes majordomus://session/<identity>, for the object page.
path string yes Repository-relative path.
created_at string no When it closed, as recorded.
branch string no The branch it closed on.
head string no The commit it closed at.
outcome string no The typed outcome the close recorded.
title string no Its title, when the record carries one.

Used by ClosedSessions

ClosedSessions

object
The tracked half of the subsystem: the records a clone receives.

total is exact and newest is a window, and keeping them as two numbers is the whole design of this type. The tracked directory grows without bound and a Cockpit card is not a listing page, so the rows are capped — but a capped list that did not carry the true total would quietly understate the repository, which is the shape of defect this repository has been bitten by often enough to name. window says how many rows came back, so a reader can see the cap rather than infer it.

use majordomus_cli::capability::builtin::lifecycle::ClosedSessions;
use serde_json::json;

let row = |id: &str| json!({
    "session_id": id,
    "uri": format!("majordomus://session/{id}"),
    "path": format!(".ai/repo/project/sessions/{id}.md"),
    "branch": "master",
});
let c: ClosedSessions = serde_json::from_value(json!({
    "total": 137,
    "on_this_branch": 41,
    "newest": [row("s-0137"), row("s-0136")],
    "window": 2,
})).unwrap();

// the window is what came back; the total is what exists
assert_eq!(c.window, c.newest.len());
assert!(c.total > c.window, "the rows are capped and the total is not");
assert!(c.on_this_branch <= c.total);
propertytyperequireddescription
total integer (uint) yes How many closed records the index holds. Exact.
on_this_branch integer (uint) yes How many of them closed on the branch this checkout is on.
newest array of ClosedSession → ClosedSession no The newest CLOSED_WINDOW, newest first.
window integer (uint) yes How many are carried in newest, against the total.

Used by lifecycle.closed

Episode

object
One open episode, as its file records it and as the ledger has seen it.

Everything here is recorded rather than derived, and almost everything is optional, which is the point: an episode opened by hand has no provider, a worker that supplied no identity has none, and an episode that opened and did nothing has no ledger line and so no last_activity. Each of those is ordinary and none is a fault, so each is carried as absence — the empty fields are skipped on the wire — and a reader must handle their absence rather than expect a placeholder.

The two fields that are always there are standing and note: where the episode stands, and why it stands there in words. A standing a reader cannot act on is the failure this subsystem was corrected for, so note is never empty.

use majordomus_cli::capability::builtin::lifecycle::{Episode, EpisodeStanding};
use serde_json::json;

// the smallest true record: an episode opened by hand that has written nothing yet
let e: Episode = serde_json::from_value(json!({
    "session_id": "s-0001",
    "path": ".ai/local/state/sessions-open/by-hand.yaml",
    "standing": "open",
    "events": 0,
    "note": "open in this worktree; the pointer names another episode",
})).unwrap();

assert_eq!(e.standing, EpisodeStanding::Open);
assert!(e.provider.is_empty(), "no provider session owns a hand-opened episode");
assert!(e.last_activity.is_empty(), "it has written no ledger line");
assert!(e.tasks.is_empty(), "work outside a task is permitted");
assert!(!e.note.is_empty(), "a standing always says why");

// and absence stays absence: the empty fields are not serialised back as ""
let wire = serde_json::to_value(&e).unwrap();
assert!(wire.get("provider").is_none());
assert_eq!(wire["events"], 0, "a count of zero is a measurement, so it is kept");
propertytyperequireddescription
session_id string yes The episode id the ledger stamps on every line this worker wrote.
path string yes Repository-relative path of the open record. The body is at the path.
provider string no The provider whose event opened it, when one did. Empty for an episode opened by hand, which is the one episode no provider session owns.
provider_session string no That provider's own session identity: the file's stem, and the string the prompt archive stamps on the same worker's records.
owner string no Who opened it.
worker string no The worker identity, when one was supplied. Never inferred.
worktree string no The worktree it opened in, absolute, as recorded.
branch string no The branch it opened on.
start_head string no The commit it opened at.
started_at string no When it opened, as recorded.
standing EpisodeStanding → EpisodeStanding yes Where it stands.
events integer (uint) yes How many ledger lines carry this episode id. Zero for an episode that opened and did nothing, which is ordinary and not a fault.
last_activity string no The timestamp of the newest ledger line carrying this episode id, as recorded. Empty when it has written none.
last_event string no That line's event name.
tasks array of string no The tasks this episode's ledger lines name, in the order it first touched each.

A relation, not a container. An episode may touch three tasks or none, and a task may span three episodes; the two are joined here by what the ledger recorded rather than by either one owning the other. An episode with no task is ordinary — work outside a task is permitted — and it is emphatically not a reason for the episode's own records to go unwritten, which is the premise ADR 0052 removes.

note string yes Why it stands where it does, in words. Never empty: a standing a reader cannot act on is the failure this subsystem is being corrected for.

Used by Episodes

EpisodeStanding

"current" | "open" | "foreign" | "stranded"
Where an open episode stands, as far as the repository can observe it.

It is worth being precise about what is not here. Whether the provider that opened an episode is still attached to its conversation is not a fact of this repository: the episode file records no pid, the peer board records no episode id, and a client that exits without firing its end event leaves a file identical to one a live worker is using. Guessing would produce exactly the confident-and-wrong answer this subsystem exists to avoid, so the vocabulary below is the observable one — where the file is, whose worktree it names, and whether the pointer follows it — and note always says which.

Read as a scale, the four run from "this checkout's own work" to "nobody's work any more", and only the last is a fault: current and open are both this worktree's live episodes and differ only in which one the pointer happens to aim at, foreign belongs to another worktree and is reported rather than adopted, and stranded is the one that needs a person, because nothing can close it.

use majordomus_cli::capability::builtin::lifecycle::EpisodeStanding;

// the standings that are somebody's live work, against the one that is nobody's
let live = [EpisodeStanding::Current, EpisodeStanding::Open, EpisodeStanding::Foreign];
assert!(live.iter().all(|s| *s != EpisodeStanding::Stranded));

// serialised as the word `as_str` gives, so the two renderings cannot drift apart
for s in [EpisodeStanding::Current, EpisodeStanding::Foreign, EpisodeStanding::Stranded] {
    assert_eq!(serde_json::to_value(s).unwrap(), serde_json::json!(s.as_str()));
}
  • currentThis checkout's pointer resolves to it. continuity.state is about this one.
  • openOpen, and it names this worktree, but the pointer names a different episode. Two windows of one provider on one worktree are two workers, and only one is pointed at.
  • foreignIt names another worktree of this repository. Reported, never treated as this checkout's — a record about somebody else's work quietly becoming yours is the failure the two-tier resolution rule exists to prevent.
  • strandedThe worktree it names is gone from disk. Nothing can close it: the close path composes the record from git in the worktree the episode opened in.

Used by Episode

Episodes

object
Every open episode of this checkout's store — the answer continuity.state cannot give, because it reads the pointer and this reads the store.

current is the one episode the pointer resolves to, and it is one row of episodes rather than a separate thing: the whole correction this type carries is that the pointed-at episode is a member of the set and not the set. On the day this was written the store held five open episodes in one checkout and every surface could name one of them.

present: false is a fresh clone, which is not a fault — nobody has opened an episode here yet — and is reported as itself rather than as an error.

use majordomus_cli::capability::builtin::lifecycle::Episodes;
use serde_json::json;

// a clone in which no episode has ever been opened: absence, answered as absence
let fresh: Episodes = serde_json::from_value(json!({
    "present": false,
    "store": ".ai/local/state/sessions-open",
    "worktree": "example/a-fresh-clone",
})).unwrap();
assert!(!fresh.present);
assert!(fresh.episodes.is_empty() && fresh.current.is_empty());
assert!(fresh.findings.is_empty(), "nothing to warn about; it is simply new");

// and a store with work in it: `current` names one of the rows, never a sixth thing
let busy: Episodes = serde_json::from_value(json!({
    "present": true,
    "store": ".ai/local/state/sessions-open",
    "worktree": "example/wt",
    "current": "s-0002",
    "episodes": [
        {"session_id": "s-0001", "path": "a.yaml", "standing": "open",
         "events": 3, "note": "this worktree, not pointed at"},
        {"session_id": "s-0002", "path": "b.yaml", "standing": "current",
         "events": 9, "note": "the pointer resolves to it"},
    ],
})).unwrap();
assert_eq!(busy.episodes.len(), 2, "the pointer is blind to the first of these");
assert!(busy.episodes.iter().any(|e| e.session_id == busy.current));
propertytyperequireddescription
present boolean yes Whether the open-episode store exists. False in a fresh clone, which is not a fault.
store string yes The store, repository-relative.
worktree string yes This worktree, absolute. Every standing above is decided against it.
current string no The episode id the pointer resolves to, when it resolves to one.
episodes array of Episode → Episode no Every open episode, newest first by recorded start.
findings array of string no What a reader should know before trusting the above: a file that does not parse, a pointer that resolves to nothing. Empty is the good case.

Used by lifecycle.episodes

Orphan

object
A temporary file left in a record directory by a close that did not finish.

The close path writes the durable record to .tmp.XXXX in the destination directory and renames it, so that no reader ever sees half a record. A process killed between the two leaves the temporary file behind, where it is untracked, invisible to every reader of the section, and — because the directory is tracked — offered to the next git add ..

bytes is carried because it is the one thing that decides what to do with the file: a zero-length temporary file is a close that died before it wrote anything and can simply go, while a large one is a record that was written and never renamed, and is worth reading before it is deleted.

use majordomus_cli::capability::builtin::lifecycle::Orphan;
use majordomus_cli::order::canonical;

let mut orphans = vec![
    Orphan { path: ".ai/repo/project/sessions/.tmp.b2".into(), bytes: 4_812 },
    Orphan { path: ".ai/repo/project/sessions/.tmp.a1".into(), bytes: 0 },
];
canonical(&mut orphans);

// filed under the path, which names one file and so makes the list total
let paths: Vec<&str> = orphans.iter().map(|o| o.path.as_str()).collect();
assert_eq!(paths, [
    ".ai/repo/project/sessions/.tmp.a1",
    ".ai/repo/project/sessions/.tmp.b2",
]);

// and the size is what tells a dead stub from a record that was written
assert_eq!(orphans[0].bytes, 0, "nothing was written before the close died");
assert!(orphans[1].bytes > 0, "read this one before removing it");
propertytyperequireddescription
path string yes Repository-relative path.
bytes integer (uint64) yes Its size in bytes, which is how a reader tells an empty stub from a lost record.

Used by Recovery

Pointer

object
What the pointer is and whether it leads anywhere.

layout and resolves are independent, and the interesting case is the pair Pointer + resolves: false: a symlink that survived the episode it named, which is what a killed close or a hand-removed record leaves behind. The layout is right, the destination is gone, and continuity.state has nothing to report while the store may still hold open episodes — so this is a dangling pointer, not an empty store.

use majordomus_cli::capability::builtin::lifecycle::{Pointer, PointerLayout};
use serde_json::json;

// the healthy case: the current layout, and it leads to an episode
let live: Pointer = serde_json::from_value(json!({
    "layout": "pointer",
    "target": "sessions-open/claude-abc.yaml",
    "resolves": true,
    "session_id": "s-0002",
    "note": "the pointer resolves to an open episode",
})).unwrap();
assert_eq!(live.layout, PointerLayout::Pointer);
assert!(live.resolves && !live.session_id.is_empty());

// and the dangling one: the right layout, aimed at a record that is no longer there
let dangling: Pointer = serde_json::from_value(json!({
    "layout": "pointer",
    "target": "sessions-open/claude-gone.yaml",
    "resolves": false,
    "note": "the symlink outlived the episode it named",
})).unwrap();
assert_eq!(dangling.layout, PointerLayout::Pointer, "the layout is not the fault");
assert!(!dangling.resolves);
assert!(dangling.session_id.is_empty(), "there is no episode to name");
propertytyperequireddescription
layout PointerLayout → PointerLayout yes The layout.
target string no What the symlink names, as written, when it is one.
resolves boolean yes Whether reading through it yields an episode.
session_id string no The episode it resolves to.
note string yes What that means, in words.

Used by Recovery

PointerLayout

"pointer" | "inline" | "absent"
The layout of the pointer, which is how far the per-provider migration has got here.

Before the episode was keyed by its provider session, session-current.yaml was the open record: one regular file, one episode per checkout. Seven concurrent sessions on 2026-09-09 produced one record between them under that layout. A regular file here today is a checkout that has not run the migration, and its episodes are being overwritten.

Only one of the three is a finding, and it is the middle one. Pointer is the current layout and Absent means no episode is open here — a fresh clone, or a worker between episodes — while Inline is a checkout still on the pre-migration layout, where a second concurrent session silently overwrites the first one's record. So the question a reader asks of this value is not "which layout" but "is it Inline".

use majordomus_cli::capability::builtin::lifecycle::PointerLayout;

// the two that are fine, and the one that loses records
let loses_records = |l: PointerLayout| l == PointerLayout::Inline;
assert!(!loses_records(PointerLayout::Pointer));
assert!(!loses_records(PointerLayout::Absent), "no episode open is not a fault");
assert!(loses_records(PointerLayout::Inline));

// serialised as the word `as_str` gives
assert_eq!(
    serde_json::to_value(PointerLayout::Absent).unwrap(),
    serde_json::json!(PointerLayout::Absent.as_str()),
);
  • pointerA symlink into sessions-open/: the per-provider layout.
  • inlineA regular file: the pre-migration layout, one episode per checkout.
  • absentNothing is there. No episode is open here, which is not a fault.

Used by Pointer

ProviderLifecycle

object
One provider's lifecycle surface: what its adapter declares it can do, and what this repository has wired to it.

The two halves come from different places and must not be confused. lifecycle and prompt_capture are the distribution's declaration — what the shipped adapter can do for this provider at all — while wired is this repository's policy naming that provider's hook in wired_by. Either can be empty with the other full, and that pairing is the interesting finding: a provider this repository wires but the tool ships no lifecycle for is a hook that will never fire.

Neither half is inferred from a file on disk. Whether a shim is actually installed is majordomus capture status's question, and this type deliberately does not answer it.

use majordomus_cli::capability::builtin::lifecycle::ProviderLifecycle;
use serde_json::json;

// a provider the tool adapts and this repository wires: both halves present
let full: ProviderLifecycle = serde_json::from_value(json!({
    "id": "claude",
    "title": "Claude Code",
    "lifecycle": ["SessionStart", "SessionEnd", "PreCompact"],
    "prompt_capture": true,
    "client_config": ".mcp.json",
    "wired": ["session.start", "session.close"],
    "note": "adapted and wired",
})).unwrap();
assert!(!full.lifecycle.is_empty() && !full.wired.is_empty());
assert!(full.prompt_capture);

// and one the tool ships no adapter for: it loses the automation and none of the model,
// because every command remains the same
let unadapted: ProviderLifecycle = serde_json::from_value(json!({
    "id": "some-editor",
    "title": "Some Editor",
    "prompt_capture": false,
    "note": "no lifecycle adapter ships for it",
})).unwrap();
assert!(unadapted.lifecycle.is_empty(), "nothing will fire for it");
assert!(unadapted.client_config.is_empty(), "and it reads no project-scoped config");
propertytyperequireddescription
id string yes The provider id: the bootstrap template's file stem.
title string yes The name a person knows it by.
lifecycle array of string no The lifecycle events its adapter declares, in the provider's own vocabulary (SessionStart, SessionEnd, PreCompact). Empty means the tool ships no lifecycle adapter for it: such a provider loses the automation and none of the model, because every command remains the same.
prompt_capture boolean yes Whether its adapter can archive the worker's prompts.
client_config string no The file it reads a project-scoped MCP client configuration from, when it has one.
wired array of string no The enforcement entries of this repository's policy that name this provider's hook in wired_by. Declared wiring, not a file on disk: whether a shim is installed is a question for majordomus capture status, which is the command that owns it.
note string yes What the two together mean.

Used by ProviderLifecycles

ProviderLifecycles

object
Every provider the distribution ships a bootstrap for, with the lifecycle half of each.

with_lifecycle is a count over providers rather than a second list, so the two cannot disagree; it is there because "how many of the providers we support actually get the automation" is the question this capability exists to answer, and a reader should not have to fold the list to find out.

use majordomus_cli::capability::builtin::lifecycle::ProviderLifecycles;
use serde_json::json;

let p: ProviderLifecycles = serde_json::from_value(json!({
    "providers": [
        {"id": "claude", "title": "Claude Code", "lifecycle": ["SessionStart"],
         "prompt_capture": true, "note": "adapted"},
        {"id": "plain", "title": "Plain", "prompt_capture": false, "note": "not adapted"},
    ],
    "with_lifecycle": 1,
})).unwrap();

// the count is over the list, so it is checkable against it
let counted = p.providers.iter().filter(|x| !x.lifecycle.is_empty()).count();
assert_eq!(p.with_lifecycle, counted);
assert_eq!(p.providers.len(), 2, "a provider with no adapter is still reported");
assert!(p.findings.is_empty());
propertytyperequireddescription
providers array of ProviderLifecycle → ProviderLifecycle yes The providers, in declaration order.
with_lifecycle integer (uint) yes How many of them declare a lifecycle adapter.
findings array of string no What a reader should know: a provider this repository wires but the distribution declares no lifecycle for, and the like.

Used by lifecycle.providers

Recovery

object
What this checkout's session store needs somebody to do.

findings is the whole report said once, and an empty findings is the verdict: there is nothing to recover. That is the field to branch on — the three detailed lists and the arithmetic are what a reader consults after deciding there is something to look at, and each of them can be empty for a good reason while another is not.

use majordomus_cli::capability::builtin::lifecycle::Recovery;
use serde_json::json;

// a healthy checkout: the current layout, nothing stranded, the arithmetic agreeing
let clean: Recovery = serde_json::from_value(json!({
    "present": true,
    "pointer": {"layout": "pointer", "target": "sessions-open/c.yaml",
                "resolves": true, "session_id": "s-1", "note": "resolves"},
    "balance": {"started": 8, "closed": 7, "open": 1,
                "unaccounted": 0, "agrees": true, "note": "they agree"},
})).unwrap();

assert!(clean.findings.is_empty(), "an empty findings list is the good verdict");
assert!(clean.stranded.is_empty() && clean.orphans.is_empty());
assert!(clean.balance.agrees);

// a fresh clone has no local half at all, which is also nothing to recover
let fresh: Recovery = serde_json::from_value(json!({
    "present": false,
    "pointer": {"layout": "absent", "resolves": false, "note": "no episode is open here"},
    "balance": {"started": 0, "closed": 0, "open": 0,
                "unaccounted": 0, "agrees": true, "note": "nothing has happened yet"},
})).unwrap();
assert!(!fresh.present && fresh.findings.is_empty());
propertytyperequireddescription
present boolean yes Whether the local half exists at all.
pointer Pointer → Pointer yes The pointer and its layout.
stranded array of Stranded → Stranded no Episodes that can no longer close themselves.
orphans array of Orphan → Orphan no Temporary files left in the tracked sessions section.
balance Balance → Balance yes The started/closed/open arithmetic.
findings array of string no Everything above, said once, for a reader that wants the summary. Empty is the good case and means there is nothing to recover.

Used by lifecycle.recovery

RuntimeView

object
The commit this process answers about, against the commit the repository is on.

Both halves are read here and neither is assumed. served is what the index this process is holding was built from; repository is what git says now, on this call. A server that follows the repository reports them equal and pays one git invocation for the proof; a server that froze its index at start-up reports them different, which is the only way a reader could ever have found that out.

agree is the field with the operational meaning, and what it says is not "the repository is fine" but "the answers you are reading are about the tree you are looking at". When it is false every other capability this process serves is answering about a commit that is no longer checked out, and nothing else in the API would tell you.

use majordomus_cli::capability::builtin::lifecycle::RuntimeView;
use serde_json::json;

let view = |served: &str, repo: &str, agree: bool| -> RuntimeView {
    serde_json::from_value(json!({
        "root": "example/repo",
        "served_head": served, "served_branch": "master", "served_working_tree": "clean",
        "repository_head": repo, "repository_branch": "master",
        "repository_working_tree": "clean",
        "agree": agree, "objects": 1319, "index_state": "ok",
        "note": "as measured on this call",
    })).unwrap()
};

// a server following the repository: one `git` invocation buys the proof
let current = view("a1b2c3d", "a1b2c3d", true);
assert!(current.agree);
assert_eq!(current.served_head, current.repository_head);
assert_eq!(current.index_state, "ok");

// and one that froze its index at start-up: every answer it serves is about the old commit
let frozen = view("a1b2c3d", "9f8e7d6", false);
assert!(!frozen.agree);
assert_ne!(frozen.served_head, frozen.repository_head);
propertytyperequireddescription
root string yes The repository root this process is serving.
served_head string no The commit the served index was built at.
served_branch string no Its branch, or DETACHED.
served_working_tree string no Whether the working tree was clean when the index was built.
repository_head string no The commit the repository is on, read on this call.
repository_branch string no Its branch now.
repository_working_tree string no The working tree now.
agree boolean yes Whether the two agree on the commit and the branch.
objects integer (uint) yes How many objects the served index holds, and whether it was degraded.
index_state string yes ok or degraded.
note string yes What the comparison means, and what to do when it disagrees.

Used by lifecycle.runtime

Stranded

object
An open episode that cannot close itself, and what to do about it.

The remedy here is the mechanical one — remove the file — because it is correct in every checkout and needs nothing that is not already installed. lib/recover.sh on feature/session-store-recovery is the same three degradations seen from the writing side: it classifies each candidate by reading it, prints the evidence before acting, and closes a stranded episode into a real record rather than deleting it. When that lands, the remedy a finding carries should become majordomus recover episodes and the two detections should become one — a reader and a writer that each decide for themselves what "stranded" means are two definitions, and this repository has been bitten by that shape often enough to name it.

use majordomus_cli::capability::builtin::lifecycle::Stranded;
use serde_json::json;

let s: Stranded = serde_json::from_value(json!({
    "session_id": "s-0007",
    "path": ".ai/local/state/sessions-open/claude-7.yaml",
    "reason": "the worktree it opened in is gone from disk",
    "remedy": "rm .ai/local/state/sessions-open/claude-7.yaml",
})).unwrap();

// the reason is about the world and the remedy is a command: a finding with no
// remedy is a complaint, so both are required fields rather than optional ones
assert!(!s.reason.is_empty() && !s.remedy.is_empty());
assert!(s.remedy.contains(&s.path), "the remedy acts on the file it names");
propertytyperequireddescription
session_id string yes The episode id.
path string yes The open record, repository-relative.
reason string yes Why it is stranded.
remedy string yes The command that clears it. A finding with no remedy is a complaint.

Used by Recovery

Searches the titles and descriptions of every page this site publishes.