What it means
Where a provider fires session events, the episode boundary is drawn by that provider's own hook rather than by the model working inside it. SessionStart opens the episode and hands the worker the briefing the policy declares, PreCompact records what the conversation is about to stop holding, SessionEnd writes a continuation record and closes the episode into the shared record under .ai/repo/sessions/, and none of it depends on a worker remembering to run a command. At the open, the context the builder resolved is written to .ai/local/session-contexts/ and kept for the episode, so what the worker was told is evidence rather than recollection.
How it works
capture install writes three more shims beside the prompt one — .claude/hooks/majordomus-session-start, .claude/hooks/majordomus-session-end and .claude/hooks/majordomus-session-compact — and the matching entries in .claude/settings.json, refusing to rewrite a configuration it did not write and naming each entry that is missing. Each shim hands its payload to majordomus capture session --provider <name> --event start|end|compact, which reads the provider's session identity and the event's source or reason with lib/json_scan.awk and calls session start, session close or checkpoint --derive. Which events an adapter has, and which column of its line holds each one, is a table: a fourth event is a row and two columns rather than another branch in each reader.
Every direction is idempotent, because the events are. SessionStart fires again on a resume, so the start passes --if-open keep and the already-open episode is kept rather than replaced. SessionEnd fires whether or not anything was opened, so the close passes --if-none ignore and writes nothing when there is nothing to close. PreCompact and SessionEnd record what they record whether or not a task is open and whatever outcome the last one reached, because a checkpoint and a continuation record are the episode's and a task is an optional relation they name when they have one. Each event writes a provider.event.received line to the ledger before any of that is decided, and a provider.event.failed line with a reason when the work does not complete: a hook must return success to its provider, and returning success is not the same as claiming the work happened. Until ADR 0052 all three asked for an active task first, and a repository whose last task had been handed over stopped writing both records — for six days, in this one, while every health check passed. The event's reason decides the outcome: a reason the adapter lists as deliberate closes the episode as closed, and anything else — a crash, a name the table has not seen — closes it as interrupted, because calling a cut-short episode complete is the worse of the two mistakes.
The start hook writes a briefing to standard output and the other two write none. Claude Code adds a SessionStart hook's output to the context it is about to build, which is the one moment at which a continuation record reaches a worker without the worker remembering to ask — and a record nothing loads is a record nobody reads. What the briefing may carry is bounded by session.briefing_budget_lines and limited to references, divergence labels, the freshness the record is judged at against session.freshness, the blockers that refuse acceptance, and the one section of a handover a resuming worker acts on — that last only while the record is still current, because a record past the stale threshold is shown as history with its age and is deliberately not quoted as an instruction; it never carries a conversation, and session.briefing_on_start: false restores silence. The end and compaction events fire inside a turn already under way, where output would alter what somebody is doing rather than furnish it, so they say everything on stderr. No hook exits 2, for the same reason capture prompt never does.
An episode leaves a continuation record before the envelope closes, and a final checkpoint before that. The two documents answer different questions — the session record indexes what the episode produced, a handover is what the next worker resumes from — and until the end event wrote one, the next worker inherited the first and not the second. Its body is derived by handover --derive from the task record, the ledger, git and the open questions, all of which are already written and already validated; nothing calls a model.
Those derived records are written when the worker can no longer be asked. A fourth thing capture install writes asks the worker earlier. It is a provider mod, .claude/skills/majordomus-handover, copied from share/mods/claude-code/, and Claude Code loads it by itself from that folder. The provider measures the context window on every response and fires no event as it fills, so the request cannot be a shim (ADR 0123). Once the window nears its fill line, a fixed headroom short of full or a share of it, whichever comes first (HEADROOM_TOKENS and MAX_FILL in the mod's hooks module), the mod queues one prompt. The prompt asks the worker to read the live state and pipe # Objective, # Current State and # Next Action to majordomus handover. It asks once per window, and not at all when the worker already wrote a handover in it. The folder is owned by its marker, as a shim is: drift is rewritten and a folder without the marker is left alone. Only session.handover_on_fill: true installs it: the skeleton declares it, an older policy without the key gets nothing, and any other value removes a folder the tool wrote. It draws no boundary and replaces no derived record; it adds the one record only the worker could write.
session start freezes the working context: front matter carrying schema: session-context/v1, the episode's identity, the provider and the provider's own session id — the same string the prompt records carry, which is what ties the two stores together — and then the context builder's output, verbatim, under a heading. A ## Notes section follows for the worker's own account of the work. session close appends a ## Close section naming the outcome and the shared record; the document is appended to and never rewritten, so what a worker typed into it survives the close.
wired_by: provider-hook:<provider>:session is what makes a repository answerable for the wiring. doctor decides it by driving a synthetic payload through the end shim with the mutation disabled: the shim resolving the repository, finding the executable, the adapter matching and the payload parsing all run, and only the close is left out — closing somebody's open episode is not a price a diagnostic may charge. The five states are the prompt aspect's five states, because they are the same five facts.
How to see it
majordomus capture install
majordomus capture status # claude-code:session verified
claude plugin validate .claude/skills/majordomus-handover
# the mod that asks before the window fills
printf '{"session_id":"cc-1","source":"startup"}' | .claude/hooks/majordomus-session-start
# stdout is the briefing the next worker is handed
majordomus session status # an open episode nobody typed a command for
printf '{"session_id":"cc-1"}' | .claude/hooks/majordomus-session-compact
majordomus checkpoint --show # what the compaction recorded
majordomus session context # the working context, composed on this read
majordomus session context --path # .ai/local/session-contexts/<stamp>--<id>.md
printf '{"session_id":"cc-1","reason":"prompt_input_exit"}' | .claude/hooks/majordomus-session-end
majordomus session latest # the closed record, with its commits and referencesWhat it does not cover
Only Claude Code has a lifecycle adapter, and that is now the narrower of two facts. Codex CLI and Gemini CLI both document SessionStart and SessionEnd; this distribution ships no adapter for either, so they are reported unadapted rather than unsupported, which is a word reserved for a provider that cannot do the thing at all (ADR 0103, share/providers.yaml carries the citations). A client with no hooks whatever now gets its episode from the MCP connection instead — see connection-episode — so a worker is no longer without a boundary for want of a vendor. A session opened on the web, in another application or on another machine remains unobservable from a hook that was never run and from a connection that was never made.
The opening snapshot is a snapshot, not a live view: it is what the builder resolved at the open, and the repository moves underneath it. session context composes the live working context from the episode, git and the ledger on every read, and --path names the snapshot. It is also local, and stays local — it names this machine and it cannot be reproduced by any surface, so it is never published, never indexed, and never loaded into a context on its own.
It is not a transcript and cannot become one. The derived half is the builder's output and the authored half is a summary of the work; a front-matter key naming a message list, a completion or a model's reply is a blocking failure, which is how project.never-store-transcripts is kept mechanically rather than by memory.
Why it exists
.ai/local/session-contexts/ was named by the layer from the beginning and had no producer: init created the directory, one sentence of prose described it, and nothing ever wrote a file into it. A directory the skeleton creates and nothing fills is a promise the layer does not keep, and a reader cannot tell an empty store from an unimplemented one. Giving it a producer meant answering where the episode's boundary comes from, and the answer was the one ADR 0009 had already given for prompts: below the model, in the provider's hook, where running it is the proof that it works. .ai/repo/adrs/0015-the-episode-boundary-is-drawn-by-the-provider-not-by-the-mod.md records that decision, and .ai/repo/adrs/0017-an-episode-that-opens-is-handed-what-the-last-one-left.md records the briefing, the compaction event and the continuation record that follow from it.