Why a boundary needs a ritual
A session that ends without a record is a session the next one cannot resume. The next session starts from the repository and whatever prose was left behind: it does not know what was decided an hour before it began, what was deliberately deferred, which branch the work was on, or that a sibling session is open on another machine. That is the drift a fresh session sees, and it is why we treat both ends of a session as ledger events rather than moments — a start that reads before it acts, an end that writes before it stops.
The two objects the ritual reads and writes are the ones the rest of this site is about: a build session — the same one back for the same project, computer, branch and actor, so a start resumes rather than restarts — and an append-only decision log, one entry per material decision, linked to the session it was made in. Everything below is the recipe we run around those two: how the start reads them, how the end writes them, and what the reading and the writing buy. It is how we run it — the server does not enforce the order, and any MCP-capable client can make the same calls.
| object | what /start-session reads | what /end-session writes |
|---|---|---|
| The session | Reads the active sessions — the resume target, each with its current-state lead — then resumes one or starts one. | Writes the close: the history entry, the pull-request URLs and the commits the session produced. |
| Decisions | Reads the last ten entries in the log. | Writes one entry per material decision not yet logged. |
| Backlog | Reads the counts by status. | Writes one row per deferral; resolves what the session finished, with closing notes or a closing PR. |
| The repository | Reads the age of the main branch, the active worktrees and the open pull requests — the hook prints them — and picks a worktree and a branch. | Writes the local merge from main, the PR body the advisory check reads, and the commit trailer that names the session. |
| The plan and the status file | Nothing. | Regenerates the launch plan (which fails on an untiered item) and refreshes the status file — both best-effort. |
| Usage | Writes the pointer file that names the session. | The SessionEnd hook records the transcript’s tokens and turns against that session. |
/start-session, step by step
The skill file has five numbered steps. Here they are in its order, with the tool each calls where it calls one. Two of the steps make no call at all — they are the agent reading and choosing before it writes anything.
1. Read the LAWs. Two files at the repository root — instructions.md and
CLAUDE.md — hold the conventions that override default behaviour. They change between
sessions, so the skill re-reads them rather than trusting memory. Nothing is called yet.
2. Query for current state. Four reads against the ledger before a file is touched:
list_build_sessions filtered to the active ones — the resume target, each
carrying its current-state lead; list_decision_log for the last ten
decisions; count_backlog_items_by_status for the open and resolved counts;
and the caller's own unchecked feedback and bug reports, listed with a small page size and counted by the
reader — two inbox reads our copy of the skill makes against its own project ledger, for which PostMQ has
no tool. There is no count-by-status variant for either family, and no alerts family at all, so the count
is the reader's job rather than the server's. "Reads first" is a fact about the
reader, not the server: the server returns the state, and this recipe reads it before anything else.
3. Surface drift. Compare the age of origin/main's last commit with the
timestamps of the most recent decision-log entry and session close. If anything is more than about 24
hours old and the work is material, list the gap before starting new work — the gap is the record the last
session did not leave. The SessionStart hook has already printed its
part by now: the protocol reminder, origin/main last commit with its age and short hash, the
active worktrees — each with its uncommitted-file count, the id and age of the most recent
transcript filed for it, and a resume marker plus the literal resume command where those two
together say a session was left stranded mid-change — and each open pull request with a
pass/fail flag and its merge state — read from the checkout and gh, failing open
when either is missing. When the checkout's own
main trails the remote it says so, and points at git show origin/main:<path>:
nothing in the protocol advances local main, because both skills branch from and merge
origin/main instead, so the files sitting in the primary checkout can be days old while the
ref the protocol reads has moved on. It says it only when main is the branch you are actually
standing on — in a worktree the files are the branch's own and the warning would be false. It also prints a release notes
section, but only when there is something to say — a version series that rolled without being written up,
or a cursor it could not read. A signal that fires every session is one nobody reads. That is
all it prints today. The last decision-log entry, the last session close and unchecked-queue counts were
designed and are not yet built; until they are, this step asks the ledger itself. The per-worktree
counts and the resume marker were in that same list until 2026-08-31, when they were built — the marker
finds a session started inside the worktree, so one launched from the repository root files its
transcript elsewhere and that worktree reports none, which is a limit of attribution rather than of
resuming.
3b. Check the release-notes cursor. One command asks whether the published release notes still cover what has shipped: up to date, behind, or a cursor that cannot be read at all — three answers, three exit codes, never folded together. If a version series has rolled and nobody wrote it up, this step says so before new work starts and offers the drafting recipe. It is the safety net rather than the prompt: the close asks at the moment of the roll, and this catches the one that was missed because a session ended badly. Plain distance behind is not worth interrupting for — a scheduled check owns that.
4. Pick a worktree and a branch. Material work gets its own worktree on its own branch
off origin/main — git worktree add, never stash-and-switch, because a stash puts
the current branch in a half-state another session may be reading. A small item with its files already
open may ship on the current branch. Not a ledger call; the worktree path and the branch are what the next
step records.
5. Resume or mint the session. If an active session matches the work, resume it and
ping_build_session it. Otherwise
start_build_session with the computer, the branch, the worktree path and
the intent — resume-or-create on the server, so a second start for the same project, computer, branch and
actor returns the same session rather than a second one. Then write the returned id to
.claude/state/active-build-session-<claude_session_id> — the pointer file the usage
reporter reads when the client exits.
During the work
Three things happen between the two skills. A material decision is appended when it is made — one
append_decision_log with a title, the rationale in markdown and the
decision's date; because a session is open, the entry links to it and takes the project from it without
being told to — and step 1 of /end-session sweeps for any that were not. Deferrals become
backlog rows as they arise, one file_backlog_item each.
Here our repository adds a rule of its own: the launch plan we work from is
generated by a script that joins the live backlog against a hand-authored tier file — which tier, and
why, the one judgement the ledger cannot express. An item filed with no tier is rendered in an
untriaged section at the top of the plan rather than quietly omitted, and the script fails, exit 1,
once such an item is more than a day old, naming the item and its age. So filing a backlog item still
forces a tier decision within a day, and an untiered item cannot drift into a queue nobody reads —
but on the day it is filed it no longer blocks every other session from regenerating the plan. Status in that plan is read live and never hand-typed.
And the commit that ships the work carries the session id as a trailer, so a reader can go from a commit to
its session and to the decisions it logged.
/end-session, step by step
Seven numbered steps, two of them with a "b" half; the order is the skill file's, and the middle of it is the part most recipes skip — the verification that what was pushed actually shipped.
1. Log decisions. One append_decision_log for every
material decision not yet logged — title, body, entry date, whether it changes a public contract, the files
touched, the source PR. No silent decisions.
2. File deferrals. Every deferred item gets a
file_backlog_item — with why it was deferred, what done looks like, and
what would make it recur — and every item the session finished is resolved with
triage_backlog_item, which needs closing notes or a closing PR. "I'll do
it later" without a row is the failure this replaces.
3. Update project status. Best-effort: refresh the status file at the repository root
from the decisions just written, the task plan, the backlog counts and the state of
origin/main, and stage it so it rides out with the session's last commit. If it cannot, note
that in the history entry and continue — status maintenance never blocks a close.
3b. Regenerate the launch plan. Best-effort too: run the generator so the plan reflects the backlog rows steps 1 and 2 just moved, and stage the result. Exit 1 means an item has been untiered for more than a day — the generator names it, lists every untiered item under an untriaged heading in the plan, and assigning a tier and staging that file clears it. Exit 2 means the ledger could not be reached; note it and continue. Neither blocks the close.
3c. Check the merge gate has not drifted. Best-effort as well. The file recording which checks a merge requires is a RECORD, not something the repository applies: editing it changes the documentation of the gate and not the gate, and changing the gate in the hosting provider's UI leaves the record stale so the next apply silently reverts someone's deliberate change. Both directions are quiet, so the check compares them and names the difference either way. "Could not establish it" is reported as exactly that, never as a clean result. Noted and continued past — gate maintenance never blocks a close.
3d. Check the sign-in consent screen has not drifted. Best-effort, and the same shape as the gate above. The first screen a stranger reads is the identity provider's permission prompt — its title, and whether it links a privacy policy and terms — and those values live only in the identity provider, not in the repository. They were wrong for the whole time sign-in had been open to people outside our own directory, and nothing noticed. So the check reads them and names any difference from the record we keep. Two of them it reports and deliberately does not fail on, because neither can be set from a terminal: a permanently red check is an ignored one. Noted and continued past.
4. Pre-merge guard. For each work branch, git fetch origin and
git merge origin/main — locally, because a local merge honours the repository's merge
attributes and a server-side branch update does not. Conflicts are resolved now, at close, in seconds,
rather than after CI has started.
4b. Write the PR body. A required check on every pull request reads the PR body — not
the commit message — and greps it, case-insensitively, for three things: a line naming the session (a
SpecStep session: or build-session: label, hyphen optional, followed by the
session's full id — a prefix is rejected, because a prefix cannot be resolved); a decisions heading at
any level and however it is qualified, so ## Decisions and ## Material decisions
both count, or a decision: label, or the words "no material decisions"; and a mention of
backlog, follow-up, deferred, not in scope, or none deferred. These are literal-word greps, so a
well-written body can still miss them — "decided rather than left implicit" does not match
decision. The check re-runs when the body is edited, so a body corrected after opening turns it green. It was
advisory until 2026-08-31; it is now required, and a miss blocks the merge.
4c. Resolve the trailer. A full id is a shape, not an identity: the check runs on a runner with no credential for our session store, so it cannot ask what kind of record the id names. Four pull requests merged green naming a backlog item as their build session. So the id is resolved one step earlier instead, locally, where the credential already exists — before the pull request is opened. A trailer that names a backlog item, or a session too stale to be closed, fails there. When the store cannot be reached the answer is "cannot determine", never "clean".
5. Mandatory CI verification. Enumerate every pull request the session touched and
classify each: merged — and race-checked, because a push exiting 0 is not shipped until
origin/main actually contains the commit; in flight with the required checks green and
auto-merge armed; or failed and fixed in the session, re-run green. A failing required check or a dirty
merge state blocks the close: fix it first. The verdict goes into the history entry as a "PRs verified at
close" line.
5b. Release-notes roll prompt. After the merge has landed — so a roll caused by this session's own work is visible — check whether a version series rolled without being written up. If one did, ask whether to draft the notes now, and say plainly what is uncovered if the answer is no. It asks rather than runs because the public notes are a sanitized derivation of the internal record, and the sanitizing is the step that most needs a person: an automated pass that misses something publishes it. Nothing here ever commits release notes.
6. Close the session. end_build_session with the
session id, the PR URLs and the history entry carrying that line and a one-paragraph summary — the record
the next start reads; optionally update_build_session_state first, so the
current-state lead is what a resumer sees. Re-ending a closed session returns it unchanged.
7. Clean up. Remove the worktree, delete the merged branch, prune. The pointer file
deliberately survives: the SessionEnd hook below runs after this step and needs it, and it
is keyed per session, so leaving it cannot mis-attribute a later one.
Then, when the client exits, the SessionEnd hook fires: it reads the just-finished
transcript, sums five token classes and the turns, and records them with
record_build_session_usage against the session named in the pointer file
— self-reported by the hook, token counts, not dollars, idempotent per transcript. No pointer, no
attribution: it says so and exits 0. What the counts are, and what they are not, is on
the usage page.
What this buys, and what it does not
Three things, and they are ordinary. Attribution. Every session names its computer, its branch, its worktree and the credential that opened it, and every decision names the session it was made in — so "who decided this, on which machine, on which branch" is a query, not an archaeology. Resumability. The next start finds the active session with its current-state lead and its closing history entry — not a summary someone remembered to paste. A record a reviewer can read. The session, its decisions and its pull requests cross-link: the commit trailer to the session, the session to the decisions and the PRs it produced; and the dashboard shows the session's overview, current state, history and what it produced to a reviewer who was never in the session.
What it does not buy — or at least what we cannot show: fewer bugs, faster delivery, better decisions. We do not have numbers for those and we are not going to invent them. The numbers we do have are below, and they are counts of what was written, not measurements of what it was worth.
The numbers
Dogfooded. This repository is built this way, and the numbers below are its own — generated from the project's ledger and git history at build, never typed; the dogfood check fails the build when the file goes stale.
As of 2026-09-02 — the project's own ledger and git history, read at build: decisions counted by paging the list to a short final page, never by the period counter; backlog counted by status on the server; commits and merged pull requests from the main branch's history.
Steal this
The skills and hooks are ours — two skill files, a shell script, a Python file — and they live in our
repository, which is private. The files themselves are not: every one is served at
postmq.com/kit/. They are also described here
closely enough to reproduce, and the tool calls are the public part: PostMQ speaks standard MCP, so any
MCP-capable client can make them. The wiring — the .mcp.json entry, where the skills and the
hooks sit, the one restart — is on the Claude Code
page. Reproduce the order, not the files: read before acting, write as you go, verify before you stop.
When not to
A one-off script — one prompt, one file, done — needs no session; nothing will resume it. A solo scratch project on one machine and one branch loses little to a state file's blind spots and gains the ritual's value only when a second machine, a second branch, a second agent or a reviewer shows up. And the ritual is only as good as the client that runs it: a session that never appends a decision leaves an empty log, not a bad one — which is why ours runs at every boundary and not on request.
Next
Session state — the objects the ritual reads and writes,
the guarantees table and the API surface. The decision log —
the trigger, and corrections as new entries. Claude
Code — the setup we run: the .mcp.json entry, the two skills, the two hooks.
Multi-agent code review — the hand-off that
leaves the session, run as a full loop: one agent builds, another reviews from its own inbox.
The context-loss guide — why a session
needs a state layer at all. Rules — the protocol invites
query_applicable_rules before acting, with the file paths and operations of
the change; our start-session skill does not yet call it, a gap in our recipe rather than in the tool.
The MCP server — every tool named here, in the
catalogue. The docs — connect an agent.