Skip to content
Cockpit0.29.0
A snapshot: read at commit 707a04c7ce8 on 2026-10-18T00:06:01Z by majordomus 0.29.0. Nothing on this page is read again. See it live

docs/CONTINUITY.md

Continuity — how work survives the session that was doing it

What it is

URI
majordomus://document/docs/CONTINUITY.md
Kind
document
Identity
docs/CONTINUITY.md
Path
docs/CONTINUITY.md
Provenance
declared
Media type
text/markdown
As a capability
document.docs/CONTINUITY.md

Front matter

{}

Content

# Continuity — how work survives the session that was doing it

A conversation with an AI worker is not a place to keep anything. It ends, it is
compacted, it is replaced by a different worker with a different context window, and
none of that is under your control. The work, meanwhile, has to continue.

Majordomus's answer is that operational state lives in the repository, in files that
outlive every conversation, and that the next worker is given a briefing assembled from
those files rather than a transcript to re-read.

```
Conversation is ephemeral.
Operational state is durable.
Context is selected.
History is reconstructable.
Handovers are explicit.
Completion is independently verified.
```

## The problem, stated precisely

A worker stops. Something must survive, or the next worker begins by reconstructing what
the last one knew — from commit messages, from a diff, from a human's memory of a
conversation they were not part of. That reconstruction is slow, and worse, it is
confidently wrong often enough to matter.

The obvious fix is to save the conversation. That fix is wrong for three reasons.

A transcript is not state. It is a record of how someone arrived at a conclusion,
including every wrong turn, in an order optimised for nothing. What the next worker needs
is the conclusion, the current facts, and the next action.

A transcript is unbounded. It grows with the length of the session rather than with the
size of the work, and the tenth session's context is filled with the first session's
false starts.

A transcript goes stale silently. It records what was true when it was written and says
nothing when the repository moves past it. A worker that trusts a stale narrative makes
decisions against a repository that no longer exists.

So Majordomus stores none of it. What it stores is small, typed, and each piece has one
home.

## The durable records, and the ones that are not task-shaped

| Record | Answers | Mutability | Where |
|---|---|---|---|
| **task** | what is being worked on, under what constraints, within which paths | mutable; one active per checkout | `state/current.yaml` |
| **checkpoint** | what was true a few minutes ago, and what comes next | append-only | `state/checkpoints/` |
| **handover** | the deliberate package for whoever continues | append-only | `state/handovers/` |
| **decision** | what was decided, why, and what was rejected | append-only | `state/decisions.md` |
| **open question** | what is unresolved, and what it blocks | mutable index; the ledger keeps the history | `state/open-questions.md` |
| **history event** | what happened, when, for which task, at which commit | append-only | `state/ledger.jsonl` |

Prompt assets in `.ai/repo/prompts/` are a seventh thing, but they are not records of
work — they are reusable framings, versioned with the repository.

## The kinds that are not task-shaped: the session, and what it learned

The records above are all task-shaped. Each one hangs off the work, which is right, and it
leaves one question with nowhere to live: *what did one worker do between sitting down and
stopping?*

That question is not the same as any of those. A task can outlive a worker; a worker can
touch three tasks in an afternoon. Nothing in those records that two decisions an hour
apart, filed under different tasks, were made by the same person in the same sitting — and
that is exactly the causal thread a later reader is trying to pick up.

| Record | Answers | Mutability | Where |
|---|---|---|---|
| **session** | what one execution episode did, between which commits, producing which records | one open per provider session, then immutable | `local/state/sessions-open/<provider session>.yaml`, pointed at by `local/state/session-current.yaml`, then the layer’s `repo/sessions/` |
| **working context** | what the worker was told when the episode opened, and what it noted while working | appended to, never rewritten | `local/session-contexts/<stamp>--<session-id>.md` |
| **knowledge record** | what the repository now knows because of the episode: each decision recorded, question resolved and task finished, as one assertion with provenance | a candidate is rewritten by the deriver until a person promotes or rejects it; verified is then immutable except by supersession | `repo/knowledge/candidates/<id>.md`, then `repo/knowledge/curated/<id>.md` |

The two are not the same record and answer opposite questions. The closed session says what
the episode produced, derived from git and the ledger, and it is shared. The working context
says what the episode was given, frozen from the builder at the open, and it is local: it
names this machine, and re-resolving it later would produce a different document, so no
surface can reproduce it and none publishes it (ADR 0015).

A session opens, may cross several tasks, and closes. `task != session` in both
directions: a task spanning two sessions is named by both, and a session spanning two
tasks names both.

### A session is an envelope, not a narrative

The closed record is identity, a temporal boundary, and references. It names the tasks,
issues, milestones, checkpoints, handovers, decisions, questions and evidence of its
episode, and it copies the body of none of them. An authored summary is allowed and is
not authority; the records it points at are.

The temptation is to write the episode down instead — one document with the decisions
quoted, the checkpoint bodies inlined, the git log pasted, the reasoning narrated. That
document is a transcript with better formatting, and it fails the same three ways any
transcript does: it is a record of how somebody arrived somewhere rather than where they
are, it grows with the length of the session rather than the size of the work, and it goes
stale silently while the repository moves past it.

So the session file holds pointers, and the pointers are checked. A reference to a
checkpoint that does not exist is a reported defect, not a broken link nobody notices.

### The envelope is derived, not accumulated

Nothing writes into an open session. `checkpoint`, `decision`, `question` and `plan` are
unchanged and know nothing about sessions. At close, the reference lists are computed from
the ledger.

The ledger is already append-only, already written only by Majordomus, already ordered by
the order the commands ran, and already validated. It knows every fact the envelope needs.
Having each command additionally append to a session file would put a write on the hot path
of commands that today append one line, and would create a second mutable account of
events the ledger already holds — which is the second source of truth this whole design
refuses. The cost is that the ledger is now load-bearing for two purposes, and that is
stated rather than hidden.

What it selects, and why it is not a time range, is worth stating because the obvious
implementation is wrong. Every ledger line now carries the session that wrote it, next to
the commit and branch it already carried. A first version selected every line between the
session's two timestamps instead, and the first time it ran for real it claimed another
worker's tasks, checkpoints and handovers: the ledger is one file per repository, two
workers were writing to it, and no timestamp can separate them. Which episode wrote an
event is something the machine knows when it writes it, so it is recorded then.

A line with no session belongs to no episode. Sessions are optional, and work done outside
one is attributed to nobody rather than to whoever had a session open nearby.

### Sessions are read with the same rules as everything else

Resolution is the two-tier rule: same repository, same worktree, same branch; then same
repository, same branch; then nothing. A session from another branch is never offered.

Divergence uses the same four labels — `exact`, `advanced`, `diverged`,
`different_context` — computed at read time from the commit the session closed at. A
session whose branch was rewritten underneath it is labelled `diverged` wherever it is
printed, and is never quietly presented as current knowledge.

Ordering comes from the recorded timestamp, with ledger line order breaking ties inside
one second. Nothing reads filesystem modification time: it does not survive a clone, and
it is not the time the record asserts.

### What a session is not

It is not a scope. A task claims paths; an episode does not, and giving one paths would
create a second, weaker claim for the coordination check to disagree with.

It is not a unit of acceptance. `finish` evaluates a task against its contract. Closing a
session asserts only that the episode ended.

It is not required. A worker that never opens a session loses the episode boundary and
nothing else; every other record is written exactly as before.

## Checkpoint and handover are different objects

They are easy to conflate and expensive to conflate.

A **checkpoint** is a progress note of an episode. It is written often, it is short by
policy (`checkpoint.max_body_lines`), and its job is to be quotable whole into the next
briefing. "Reproduced the fault with the fixture; the cause is in normalisation, not
comparison; next, write the regression test."

A **handover** is a deliberate continuation package written when a worker stops. It has
required sections, it is refused if any of them is empty, and it is the thing another
worker resumes from. It is written rarely.

**Neither of them belongs to a task.** Both name the task they were written under when
there is one, and both are written when there is not. A task is a unit of intended work
that somebody opens and closes; an episode is a provider's conversation boundary that
begins when a client attaches and ends when it detaches. Making the records depend on the
first means that the ordinary act of finishing a task disables continuity for every episode
after it — which is not a hypothetical. Between 2026-09-05 and 2026-09-11 this repository
wrote neither record, because a task had been marked `handed_over` and three separate
guards each asked, correctly against their own contract, whether a task was `active`. ADR
0052 removed the premise they shared.

A checkpoint that grows into a report is refused rather than truncated, with the
suggestion to write a handover instead. The cap is the mechanism that keeps the two
objects distinct.

A **knowledge record** is neither. A checkpoint says what the work looked like and a
handover says what to do next; a knowledge record says what is now true about the
repository because of the episode — the decision it recorded, the question it resolved, the
reason a task was blocked — as one assertion with a class, a status and provenance that
resolves. It is derived at the same moments the checkpoint is, from the ledger and git and
never from the conversation or from either of the other two records, and it lands in the
tracked tree as a candidate that a person promotes or rejects. It is the one record of an
episode that survives a clone before anybody has written a word by hand, and it is the one
that is never authored by the deriver: `verified` is a person's act. [`KNOWLEDGE.md`](KNOWLEDGE.md)
has the derivation and the commands.

## Identity comes from reality

`repository_id`, `worktree`, `branch`, `head`, `working_tree` and `changed_files` on every
record are computed from git at write time. A body that contains any of them is refused.

This is not fussiness. A worker asked to summarise its own state will produce a plausible
summary, and a plausible summary that includes a commit hash the worker did not verify is
worse than no summary, because it looks checkable. The fields a machine can determine are
determined by the machine; the fields only a person or a worker can supply are supplied by
them and marked as such.

## Records are read with a divergence label

Nothing is trusted because it exists. On read, the recorded `head` is compared with the
current `HEAD` through `git merge-base`, and the record is labelled:

| Label | Meaning | What to do |
|---|---|---|
| `exact` | written at this commit | trust it |
| `advanced` | git has moved forward since | trust it, expect some of it to be done |
| `diverged` | the recorded commit is not an ancestor | history was rewritten; trust git, not the record |
| `different_context` | another branch | this record is not about your work |

`context` prints the label. `check` and `watch` report it as a finding. A record is
evidence, never authority.

## Resolution is deterministic, and absence is a valid answer

`handover --resolve`, `checkpoint --show` and `context` all use the same rule:

1. Same repository, same worktree, same branch.
2. Same repository, same branch, when the branch is not detached.
3. Nothing.

There is no third tier. A record from an unrelated worktree or branch is never offered,
because a globally newer record silently becoming your context is worse than having no
context at all — you cannot tell that it is wrong until you have acted on it.

Records written inside the same second would otherwise resolve in an order decided by a
random filename suffix, so the ledger, which is append-only and written in command order,
breaks the tie.

When nothing matches, the answer is "no relevant handover", not the closest thing
available. **Absence is better than incorrect memory.**

## Context is selected, not accumulated

`majordomus context` assembles a briefing in authority order — git, then the task and its
profile, then blockers, then authored records, then event history — and prints it. Nothing
is persisted; it is recomputed each time from the records and git.

What goes in is decided by the profile's `context` block. A `routine` task does not need
the decision history; a `deep-work` task wants the repository's decisions rather than only
its own. Those toggles were configuration that nothing read until the builder existed;
now they are the thing that decides.

When the assembled text exceeds `context.builder_budget_lines`, sections are dropped in a
fixed order: history first, then touched files, then decisions, then the bodies of the
checkpoint and the handover, which degrade to a pointer rather than disappearing. Git, the
task, the profile and open blockers are never dropped.

**Every exclusion is named, with its reason**, under `EXCLUDED`. An under-filled context is
debugged by reading that list, not by guessing what the tool decided to leave out. The
reported line count is the count of the document actually printed, including its own
header and trailer.

## History is for reconstruction, not for reading back a conversation

`state/ledger.jsonl` is append-only and written only by Majordomus. One line per event:
started, checkpoint, decision recorded, question opened, question resolved, handed over,
finished, projections updated, ledger rotated. Each carries a timestamp, the task, and the
commit it happened at.

`majordomus history` reads it back with filters. It answers what happened, when, for which
task, at what git state, what verification ran and what outcome was accepted. It does not
answer what anyone said, because that is not recorded.

Retention is deliberate: the ledger has a line cap, and `history --rotate` moves the
oldest lines into a dated archive file. It never deletes, and it refuses to overwrite an
existing archive.

## Blockers are state, not prose

An unresolved question in a handover paragraph is a note. An entry in
`state/open-questions.md` **refuses `finish --outcome completed`** — any entry, not only one
the active task opened. A question is a person owing an answer, and the work it blocks
outlives the task that asked; a gate that forgot at the task boundary would be a gate a
handover walks past. The store is checkout-local, under the ignored `.ai/local/state/`,
so the questions a gate can see are the ones asked in this working copy; a question is
carried to another checkout by the handover that names it, never by git.

Only *completed* is refused. `blocked`, `partial`, `no_match` and `failed` are honest
statements that the work did not finish, and refusing them would buy a green gate by forcing
a mislabelled outcome.

That gate is why the file has a machine-written line format, and why `check`, `doctor` and
`watch` fail on an entry that does not parse: a gate that cannot read an entry is a gate
that can be bypassed by mistyping one.

## Nothing here calls a model

Majordomus stores, validates, resolves, projects and verifies. It does not summarise, it
does not decide what a checkpoint should say, and it makes no network call. If you want a
model to write your checkpoint, have the worker write it and pipe it in. The tool is
deterministic infrastructure and stays that way.

## Who runs the lifecycle

The start event also converges on the shared server. With `session.ensure_server_on_start`
(on by default) it runs `majordomus serve ensure`: a server is started as a process of its
own when none answers this checkout, never built, and the briefing carries one line naming
where it stands — `Shared server: ready http://127.0.0.1:8741 pid 123`, or `not ensured:
the executable is not built (run \`just build\`)`. Discovery, loading and now the server are
three things a worker no longer has to remember; `docs/MCP.md` has the lifecycle.

Nothing above requires a worker to remember any of it.

Where a provider announces the boundaries of a sitting, its own hooks run the lifecycle.
The start event opens the episode, freezes the context the builder resolved at that moment,
and writes a briefing to standard output — which the provider adds to the context it is
about to build. That is the one moment at which a continuation record reaches a worker
without the worker asking for it, and it is the difference between a record that is written
automatically and one that is also read.

```mermaid
flowchart TD
  ss["provider fires SessionStart"]
  opens["session opens (or the open one is kept)"]
  frozen["the working context is frozen"]
  brief["the briefing goes to stdout: the episode, the resolved<br>handover with its divergence label AND its freshness,<br>the blockers, and — only when the record is still<br>current — the one section to act on"]
  work["work happens"]
  pc["provider fires PreCompact"]
  cp1["a derived checkpoint, because the conversation<br>is about to stop holding it"]
  se["provider fires SessionEnd"]
  cp2["a derived checkpoint, for the same reason: the close<br>is the other moment at which what the episode knows<br>stops being reachable"]
  kn1["derived knowledge candidates, from the ledger and git:<br>what the episode decided, resolved and finished"]
  kn2["derived knowledge candidates, on the close path,<br>before the envelope is sealed"]
  ho["a derived handover"]
  env["the episode closes into its envelope of references"]

  ss --> opens
  ss --> frozen
  ss --> brief
  ss --> work
  work --> pc
  pc --> cp1
  pc --> kn1
  pc --> se
  se --> cp2
  se --> kn2
  se --> ho
  se --> env
```

Every one of those arrows fires whether or not a task is open and whatever outcome the last
task reached, and each event writes a `provider.event.received` line to the ledger *before*
any of them is decided. A hook must return success to its provider — blocking somebody's
work is the worse failure — but returning success is not the same as claiming the work
happened, and without the receipt an event that never fired and an event that fired and did
nothing are the same observation afterwards.

## Divergence is not freshness

Every record carries two independent judgements and they answer different questions.

**Divergence** — `exact`, `advanced`, `diverged`, `different_context` — says where the
record's commit sits relative to this checkout's HEAD. **Freshness** — `fresh`, `aging`,
`stale`, `unknown`, `invalid` — says how old it is, against `session.freshness` in the
policy, which is the only place either threshold is written.

A record is `advanced` on the day it is written and still `advanced` a month later:
`advanced` means its commit is an ancestor of HEAD, which sounds like agreement and says
nothing whatever about time. A record that is both `advanced` and `stale` is the exact
shape of the 2026-09-05 outage, and it is the reason the two are reported side by side.

A stale record is history. The briefing still shows it — knowing what the last worker was
doing is worth having — but it is labelled as historical, its age and the reason are
stated, and its `Next Action` is deliberately **not** quoted as the thing to do now. An
instruction that was correct six days ago, handed over without qualification, is worse than
no instruction: the worker cannot tell it is wrong until after acting on it.

The episode belongs to the provider session that opened it, and not to the checkout. Two
windows of the same provider open on one worktree are two workers: each start event opens
or keeps *its own* episode, each end event closes its own and no other, and each worker's
ledger lines are stamped with its own episode id. An open episode is
`local/state/sessions-open/<provider session>.yaml`; `local/state/session-current.yaml`
points at the episode of this checkout, and a worker that can name its provider session
resolves its own instead of following the pointer. Before this, `--if-open keep` returned
the already-open episode whatever the event said: seven concurrent sessions here on
2026-09-09 produced one record between them, stamped with whichever id happened to be first,
and closed by whichever window was shut first.

The briefing is bounded by `session.briefing_budget_lines` and carries references, labels,
one section and the ids of the knowledge candidates awaiting review on the branch — never a
conversation. Each of the three behaviours is a switch in the
policy, and `session.briefing_on_start: false` restores the older silence.

This is the one route by which anything under `local/` reaches a model's context without
being asked for. It is narrow on purpose, and the other half of that rule is unconditional:
nothing under `local/` is ever published by a generator or served on a public surface.
`.ai/repo/adrs/0017-an-episode-that-opens-is-handed-what-the-last-one-left.md` records why.

A worker with no such provider loses none of the model and all of the automation: every
command below is the same, and running them is again a matter of remembering.

## Reading it back from somewhere other than a terminal

`continuity.state` in the Rust executable reads this same local half and reports it over
MCP (`majordomus_continuity`), over HTTP (`GET /api/v1/continuity`) and in the Cockpit's
Continuity page: the open episode, the active task, the handover and checkpoint that
resolve here with their divergence labels, and the blockers. It uses the same two tiers and
the same four labels, and it never writes — the lifecycle has one writer, and a second
account of events the ledger already holds is what this design refuses.

It is served and never published. Those records name this machine, so no generated document
and no site page carries one, and a test proves it.

### One episode, and then the rest of them

`continuity.state` answers about **one** episode: the one `state/session-current.yaml`
resolves to. That is the right answer for a briefing — a worker wants its own episode, not a
census — and it is the wrong answer for anybody asking whether the subsystem is working,
because the pointer is a symlink that the most recent start event re-aims. On 2026-09-11
this repository held five open episodes in one checkout and no surface the tool has could
name more than one of them. An episode nobody can see is an episode that never closes.

So there is a second reading, `lifecycle.*`, which reads the **store** rather than the
pointer and answers the operator's questions instead of the worker's:

| Capability | Answers | Where the answer comes from |
|---|---|---|
| `lifecycle.episodes` | every open episode, its provider session, where it stands (`current`, `open`, `foreign`, `stranded`), the tasks it touched, and what the ledger last saw it do | `state/sessions-open/*.yaml`, `state/session-current.yaml`, `state/ledger.jsonl` |
| `lifecycle.recovery` | episodes that can no longer close themselves, temporary files a killed close left in the tracked section, whether the pointer has been migrated, and whether every episode the ledger saw start is accounted for | the same store, plus the sessions section the manifest names |
| `lifecycle.runtime` | the commit this process is answering about, against the commit the repository is on right now | the served index's git state, and `git` read on the call |
| `lifecycle.providers` | which lifecycle events each provider's adapter declares, whether it can archive prompts, and which of this repository's enforcement entries are wired to its hook | `share/providers.yaml` and the policy's `enforcement` |
| `lifecycle.closed` | the tracked records a clone receives: how many, how many on this branch, and the newest twenty | the object index, kind `session` |

Each is exposed over MCP and over `GET /api/v1/lifecycle/<name>`, and the Cockpit's
Continuity page is their projection. None of them writes: the lifecycle has one writer, and
a second account of events the ledger already holds is what this design refuses.

Two things are deliberately **not** among them.

**No clock and no thresholds.** Nothing in `lifecycle.*` decides that a record is old. Age
is `session.freshness` in the policy and `continuity.state`'s to judge; a second engine for
it here would be the second source of truth that makes the numbers disagree. (`session.freshness`
and the `fresh | aging | stale | unknown | invalid` labels are ADR 0052's and arrive with it;
until they do, a record carries its divergence label and its recorded timestamp, and a reader
does the arithmetic. The whole point of ADR 0052 is that a reader should not have to.)

**No guess about attachment.** 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 process,
the peer board records no episode, and a client that exits without firing its end event
leaves a file identical to one a live worker is using. What `lifecycle.episodes` reports is
what it can observe — where the file is, whose worktree it names, whether the pointer
follows it, and when the ledger last saw it write — and `stranded` is reserved for the one
case the repository can actually establish: the worktree the episode opened in is gone from
disk, so nothing can compose its record.

### The whole path, once

Everything above is one path, and it is worth seeing whole. Every box is a thing that
exists; the labels on the arrows are what has to be true for the next box to be reached.

```mermaid
flowchart TD
  cmd["a person runs a command<br>or opens an editor"]
  conv["a provider starts a conversation<br>(Claude Code: SessionStart)"]
  entry["ENTRY<br>bin/majordomus, .envrc, or the hook shim"]
  runtime["RUNTIME ENSURED<br>serve ensure: one shared server per checkout,<br>started if none answers, never built"]
  client["CLIENT ATTACHED<br>MCP over stdio or HTTP; the peer board is what<br>the other workers can see"]
  episode["EPISODE OPENED<br>keyed by the provider session, not by the checkout:<br>state/sessions-open/&lt;provider session&gt;.yaml and the pointer<br>aimed at it. --if-open keep returns this provider<br>session's episode and no other."]
  fresh["FRESHNESS VALIDATED<br>the resolved handover and checkpoint carry two independent<br>labels: divergence (where their commit sits) and freshness<br>(how old they are). A stale record is shown as history;<br>its Next Action is not quoted as the thing to do now.<br>[ADR 0052]"]
  context["CONTEXT PROJECTED<br>the briefing, within session.briefing_budget_lines, frozen<br>into local/session-contexts/ as the working context —<br>what this episode was told, never rewritten"]
  events["WORK AND EVENTS<br>every command appends one ledger line, stamped with the<br>episode that wrote it. A line with no episode belongs to<br>none: work outside a session is attributed to nobody<br>rather than to whoever was open nearby."]
  checkpoints["CHECKPOINTS<br>written by hand, and derived on PreCompact — the moment<br>the conversation stops being a place anything is kept.<br>An artefact of the episode: no task required."]
  knowledge["KNOWLEDGE DERIVED<br>on PreCompact and on the close path: the episode's decisions,<br>resolved questions and finished tasks become candidate<br>records from the ledger and git, never from the conversation.<br>Idempotent; the ledger says it ran. [ADR 0091]"]
  closing["CLOSE AND HANDOVER<br>SessionEnd closes the episode and, when work is still open,<br>writes the continuation record. The envelope's reference<br>lists are computed from the ledger at close, never<br>accumulated during the episode."]
  record[".ai/repo/sessions/&lt;stamp&gt;--&lt;episode&gt;--&lt;branch&gt;--&lt;head&gt;--&lt;digest&gt;.md<br>and .ai/repo/knowledge/candidates/&lt;episode&gt;-&lt;digest&gt;.md<br>TRACKED. What survives a clone: everything under .ai/local/<br>names this machine and travels nowhere."]
  resume["A FUTURE RESUME<br>locally, the next episode's briefing resolves the records of<br>this worktree and branch; elsewhere, a clone receives the<br>closed records and reads them as history, because a record<br>is evidence and never authority."]
  watching["and, beside the path, watching it:<br>lifecycle.episodes · lifecycle.recovery · lifecycle.runtime<br>lifecycle.providers · lifecycle.closed · continuity.state<br>projected together on the Cockpit's /cockpit/continuity"]

  cmd & conv --> entry
  entry --> runtime --> client --> episode --> fresh --> context
  context --> events --> checkpoints --> knowledge --> closing --> record --> resume
```

Two boxes in that path are marked. **Knowledge derived** is the newest: until ADR 0091 an
episode's decisions, resolved questions and finished tasks were evidence on one machine, under
a retention cap, and nothing wrote them where a clone could read them. **Freshness validated**
is the one this repository most recently did without: it is a step, not a property of the record.
Without it a handover that was `advanced` — a true statement about git topology, and
identically true on the day it was written and a month later — was quoted into every new
episode for six days after the work it described was finished. The decision, the thresholds
and the labels are ADR 0052's, "The
session lifecycle is the episode's, not the task's"; every other box in the path is behaviour this tree already has. And the **tracked** box is where the local
half stops: a clone receives the closed records and nothing else, which is why no generated
document and no site page may carry a briefing, a working context or an open episode.

## Where the lifecycle puts each piece

```mermaid
flowchart TD
  st["majordomus start #quot;&lt;task&gt;#quot; --scope &lt;paths&gt; [--profile &lt;name&gt;]<br>names any prior record for this branch"]
  ctx["majordomus context<br>the briefing, within budget"]
  work["work happens (Majordomus is not involved)"]
  cp["majordomus checkpoint<br>progress, often, short"]
  dec["majordomus decision add<br>what was decided and why"]
  qq["majordomus question add<br>what is unresolved, and it now blocks"]
  ck["majordomus check<br>scope, state, blockers, store integrity"]
  ho["majordomus handover"]
  nxt["the next session runs context"]
  fin["majordomus finish --outcome &lt;...&gt; --verify-command #quot;&lt;cmd&gt;#quot;"]
  hist["majordomus history<br>the lifecycle, reconstructable"]
  kd["majordomus knowledge derive<br>at the close and on compaction, or by hand:<br>the decisions, answers and outcomes as candidate records"]
  kp["majordomus knowledge promote &lt;id&gt; &lt; evidence<br>a person makes a candidate verified"]

  st --> ctx --> work
  work --> cp
  work --> dec
  work --> qq
  work --> ck
  ck -->|"not finished"| ho --> nxt
  ck --> fin --> hist
  dec --> kd
  fin --> kd
  kd --> kp
```

## Across machines

Everything above lives in one checkout's `.ai/local/state/`, which is never committed and
names the machine it is on. Stopping on a laptop and continuing on a desktop therefore needs
one more step: the handover is **published** — projected into a portable, signed record — and
the other machine **resumes** it after a plan has said it can. The design is
[ADR 0105](../.ai/repo/adrs/0105-a-handover-moves-between-machines-as-a-signed-record-in-a-ref-of-its-own.md);
the rule is `project.session-continuity-is-portable`.

```mermaid
flowchart TD
  ho["machine A: majordomus handover"]
  pub["machine A: majordomus-cli continuity publish<br>refused if it carries a secret or a machine path"]
  ref["refs/majordomus/continuity<br>records/&lt;id&gt;.json, signed by A's device key"]
  syncA["machine A: continuity sync<br>fetch, union, push (never forced)"]
  remote["git remote"]
  syncB["machine B: continuity sync"]
  plan["machine B: continuity plan<br>trust, lineage, source compatibility"]
  resume["machine B: continuity resume<br>handover + decisions written into B's state"]
  ctx["machine B: handover --resolve, context, the session briefing"]

  ho --> pub --> ref --> syncA --> remote --> syncB --> plan --> resume --> ctx
```

### From a MacBook to a Mac mini

<!-- majordomus:unrun it is one sequence across two machines that share a remote, which no single checkout can be; case 821 runs the same commands in that order between two clones of one bare remote -->
```sh
# MacBook, once: name the device (the key is the mesh's node key, created on first use)
majordomus-cli continuity device --label macbook-pro

# MacBook, when stopping
majordomus handover < note.md            # # Objective / # Current State / # Next Action
majordomus-cli continuity publish --issue '#184'
git push                                  # the source: continuity never moves it
majordomus-cli continuity sync            # the record: fetch, merge, push refs/majordomus/continuity

# Mac mini
git pull
majordomus-cli continuity sync            # fetches what other devices published
majordomus-cli continuity status          # this device, the store, what is resumable
majordomus-cli continuity plan            # writes nothing; says what a resume would do
majordomus-cli continuity resume          # only on a ready plan
majordomus handover --resolve             # the MacBook's handover, as this checkout's record
```

On entry, the preflight's `session.continuity` check (and the banner's last line) says a
resumable handover is waiting — read from `.ai/local/state/continuity.json`, which the last
`sync` wrote, never from a remote.

### What moves, and what does not

| State | Moves | How |
|---|---|---|
| source, committed | yes | git, by the person: `git push` / `git pull` |
| source, uncommitted | **no** | the record lists the changed paths and a fingerprint; the plan says `source_incomplete` |
| handover (objective, state, next action) | yes | the record's `handover`, the mesh's `HandoverBody` |
| intent: the task's title, scope, profile | yes | the record's `task`; the plan recommends the `majordomus start` to run |
| issue, milestone | yes | carried along the line until a publication names others |
| decisions the task recorded | yes | appended to the receiving decision log once each, marked `Carried:` |
| lineage and provenance | yes | `parent`, `line`, device, episode, commit, `published_at`, producer version |
| worktree path, repository id the shell compares | no | recomputed by the receiving checkout |
| the episode, the working context | no | opened and compiled on the receiving machine |
| PIDs, sockets, ports, the lease, build output | no | the record has no field for them |
| credentials, secret environment values, home paths | no | a publication carrying one is refused |

### Lineage, and what a conflict is

A record names the record it continues — the last one this checkout published or resumed on
the same branch — and the first record's id is its line. Two records are:

| Relation | Meaning | What happens |
|---|---|---|
| `equal` | the same record | a second resume rewrites nothing |
| `newer` / `older` | one descends from the other | the newer is what is offered |
| `diverged` | same line, neither descends from the other: the same work continued twice | a plan that would pick one is a `conflict`; a person names one with `--record` |
| `independent` | different lines: separate work | ordinary; never a conflict |

The clock decides nothing: `published_at` is shown and used for an age. `sync` reports each
line as `equal`, `remote_newer`, `local_newer`, `diverged`, `local_only` or `remote_only`.

### The plan

`continuity plan` (the same value over MCP as `majordomus_continuity_plan` and over HTTP as
`GET /api/v1/continuity/plan`) answers `ready`, `ready_with_warnings`,
`requires_source_update`, `conflict`, `choose_record`, `nothing_to_resume` or `refused`, with:

- **trust** — the signer's key against the mesh declaration's `trust.allow`; an untrusted
  signer is `refused`, and with no declaration at all the plan warns `origin_undeclared`;
- **lineage** — `line_diverged`, `would_diverge` (this checkout continued the same work
  elsewhere), `already_resumed`;
- **source** — `exact`, `local_ahead` (a warning), `local_behind`, `head_missing`,
  `branch_differs` (each `requires_source_update`, with the git command to run), `diverged`
  (a conflict: reconcile with git first);
- **the origin's tree** — `source_incomplete` when it was dirty, unless this checkout holds
  the very same uncommitted change (the fingerprints agree);
- **activity** — `origin_may_be_active` when the record came from a still-open episode less
  than an hour ago. Advisory: nothing locks, and a crashed laptop locks nothing.

The commands a plan lists are recommendations. Nothing a record says is executed.

### Offline

`publish` writes a local ref and never touches the network, so it works on a plane. `sync`
against an unreachable remote exits 10, changes nothing, and records `unreachable` in
`continuity.json`; `status` reports the store `pending` (or `never_synced`) until a sync
reaches the remote. Nothing is lost on the way.

### Security

- A record is signed by the device's mesh key and identified by its content digest; an
  altered record no longer matches its id or its signature and is refused on every read.
- A record from another repository — a fork shares a name, not a root history — is refused,
  and a sync with such a remote leaves none of its records in this store's tree.
- A record of a later schema is kept and reported (`schema_too_new`, `upgrade_required`),
  so an older executable never drops a newer one's records.
- The continuity ref is pushed to whatever remote is synced: in a public repository its
  records are public, like the tracked session envelopes. Sync with a private remote
  (`--remote`) where that is not acceptable.

### Troubleshooting

| Symptom | Ask | Usually |
|---|---|---|
| nothing resumable on the other machine | `continuity status` | not synced on one side; `store.sync` says `pending` or `never_synced` |
| `head_missing` | `continuity plan` | the source was not pushed, or not fetched |
| `origin_untrusted` | `continuity device` on the origin | its `public_key` is not in `trust.allow` |
| `conflict` with `line_diverged` | `continuity records` | the same work was continued on two machines; resume one with `--record` |
| a publication refused | the refusal's field | a token or a home path in the handover body or the task title |
| `continuity.dangling_parent` | `continuity sync` | a record continues one this store has not fetched yet |

## What this does not do

It does not know who wrote which commit. A task records the commit it started at, and
`check` treats every file changed since as the task's work. When another session commits
to the same branch in the same checkout, that session's files are reported outside your
scope. The report is correct about the files and wrong about the author, and Majordomus
has no way to tell the difference. Close the task with a handover and start a new one at
the current commit.

**A question blocks its whole branch, not the task that asked.** That is deliberate, and it
is wrong in one case: a question about work that was abandoned, left unanswered, refuses
completion of every later task on that branch until somebody writes an answer. The direction
was chosen because it fails loudly — `question list` names it and `question resolve` clears
it in one command — where the alternatives fail silently. See `M001` in
`.ai/repo/project/` for the alternatives that were rejected and the case each gets wrong.

It does not measure tokens, context savings, or cost. The budget is lines, because lines
are what it can count. See [`ECONOMICS.md`](ECONOMICS.md).

It does not rank search results, embed anything, or maintain an index. `search` is a
literal grep across the record kinds in authority order. The corpus is a handful of files;
an index would be a second source of truth that has to be kept in step with the first.

It does not resolve across worktrees or branches, and it never will silently. A handover
from another machine reaches this checkout only through `continuity resume`, after a plan.

## Related

[`CONCEPTS.md`](CONCEPTS.md) for the vocabulary · [`CLI.md`](CLI.md) for every command ·
[`SCHEMAS.md`](SCHEMAS.md) for the file formats · [`KNOWLEDGE.md`](KNOWLEDGE.md) for what an
episode learned and how it is derived · [`DESIGN.md`](DESIGN.md) for why the models are
shaped this way.