# Session End

> session-end

- Skill: `bryceewatson/session-end` (Agent Skill)
- Install (CLI): `npx skillmds@latest add bryceewatson/session-end`
- Raw SKILL.md: https://api.skillmd.com/api/skills/bryceewatson/session-end/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: BryceEWatson (https://skillmd.com/u/bryceewatson)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/bryceewatson/session-end

---


# session-end

Close out a session with an evidence-grounded record — and, **when work is still in flight**, hand off
so a fresh session can pick up with zero loss. Two intents, one flow: *ending* always produces the
record; *handing off* (the mid-flight mode) adds the exact resumable state + a copy-pasteable
continuation prompt **for the next session (you, with an empty context window) and for the human operator**.
Optimize for *truth and resumability*, not for sounding complete.

## Operating principle: ground in artifacts, never in memory alone

A summary written from recollection will hallucinate. **Reconstruct the session from hard evidence
first**, then narrate. This is what makes the handoff *safe*.

## Operating principle: an inference must not read as a measurement

The next session reads this doc cold and acts on it. **An untagged claim is indistinguishable from a
measurement** — a diagnosis you reasoned your way to looks exactly like a fact you checked. That is the
failure mode this rule exists to stop, and it is not hypothetical:

> A handoff stated: *"The `review-pr` skill template is the source of that divergence."*
>
> It reads as a diagnosis. It was an inference, and it was wrong — the divergence was a **deliberate
> safety separation** between a code review that runs the tests and one that doesn't. The next session
> nearly "repaired" it, which would have let an explicitly-untested review auto-merge its own changes.
> It was caught only because that session happened to open both files. The same handoff also stated a
> mechanism wrongly (a script reading the cwd's `.env`, when it actually resolves from the script's own
> location) — harmless there, same root cause.

**The rule.** Every **load-bearing** claim carries an epistemic tag, or states its evidence inline so the
reader can judge for themselves. *Load-bearing* means: **the next session is likely to act on it without
re-deriving it** — a diagnosis, a mechanism, a state-of-the-world assertion, a number, a "X is why Y".
Passing colour and narration need no tag; don't tag everything, or the tags stop carrying signal.

| Tag | Means | Requires |
|---|---|---|
| `[verified]` | You read it, ran it, or observed it. | Name *how* — the file, command, or output. |
| `[derived]` | You **inferred** it from evidence. Sound reasoning, still an inference. | Name what it's inferred *from*, so the reader can re-run the inference. |
| `[assumed]` | No source; you took it as given. | Say what would confirm or falsify it. |
| `[unverified]` | Asserted during the session, never checked. | Say what checking it would take. |

`[derived]` is the tier that was missing, and it is the one that bites: an inference is grounded enough
to *feel* verified, so it gets written in the voice of a measurement. **If you reasoned your way to it,
it is `[derived]`, however confident you are.**

Stating the evidence inline satisfies this too, and is often shorter and better:
*"`review-pr/SKILL.md` has no test step and `code-review` does"* beats a bare tag — it hands the reader
the same evidence you had.

**The gate: no claim without a source.** If you can't name what a load-bearing claim rests on, it is
`[assumed]` — write it as `[assumed]`, or leave it out. Never close the gap by writing more confidently.
This applies to *every* section below, and hardest to Steps 3 and 5, where claims travel furthest from
the evidence that produced them.

## Step 1 — Gather evidence (do this before writing anything)

- `git status --short` and `git diff --stat` (+ `git log --oneline -15`) — what actually changed / was created. Every artifact you cite must appear here or on disk.
- **Always look for work that already landed — never gate this on the working tree being clean.** Those
  two probes are structurally blind to anything that has merged: a session whose work went out through
  PRs has a clean tree *by construction*, and reporting "no artifacts" there records a productive shift
  as an empty one. A dirty tree does not clear you either. Merge a PR and leave one unrelated edit or
  stray untracked file, and the status probe is non-empty, so a check that fires only on "both empty"
  never runs and the handoff silently keeps the stray file while dropping the merged work. The session
  *window* is what decides this, not tree cleanliness, so run these every time:
  - `git log --oneline --since=<session start>` (and on the default branch) — what landed during the shift.
  - `git show --stat <sha>` per commit, or `git diff --stat <base-branch>...HEAD` — the files those commits
    touched. (Three-dot already means "since the merge base"; don't pass a merge-base into it.)
  - `gh pr list --state merged --author @me --search "merged:>=<session start date>" --limit 100` — PRs
    that closed during the shift. Bound it by **both** date and author, and set `--limit` explicitly: an
    unfiltered window sweeps in every *other* contributor's merges, and `--limit` defaults to 30 and
    truncates *silently*, which is the same under-reporting this whole bullet exists to stop. If you get
    back exactly the limit, assume you hit the ceiling and raise it rather than reporting a round number.
    Filtering is necessary but not sufficient — `--author @me` still collapses concurrent agent sessions
    running under one account, so **correlate each candidate with this session before claiming it**
    (does its branch, timing, or content actually match work you did?). Landing in the window is not
    evidence you did it, and an artifact list is exactly where a borrowed claim does damage.
  - Output that lives outside this checkout entirely: another worktree, a sibling repo, or a non-repo
    artifact (a published page, a scheduled task, a review verdict left on a PR).
  If this turns up nothing either, say so as an explicit finding — "no files changed; this shift's
  output was X" — never as a silently omitted section.
- The current **TodoWrite** list — the authoritative in-progress/next-step state.
- New/modified files of substance — Read or skim the ones central to the session (specs, code, docs).
- Skim the conversation for: explicit **decisions**, **claims/numbers** asserted, **assumptions** taken, **questions answered**, and any **reversals** (things that changed mid-session).
- If the work is a long arc, reconstruct the **chronology** from commit messages + file mtimes.

## Step 2 — Synthesize a DYNAMIC summary

Include only the sections that apply to *this* session (a design session, a debugging session, and a
research session produce different shapes). Always tag epistemics — this mirrors a
verify-first standard.

- **Session arc** — 2-4 sentences: what this session was, start to now.
- **Key decisions** — each with its one-line rationale. These are the load-bearing outcomes.
- **Claims, diagnoses & numbers** — each tagged per the provenance rule above: `[verified]` / `[derived]` / `[unverified]` / `[assumed]`. **Not just numbers** — a diagnosis ("X is the source of Y"), a mechanism ("this script reads Z"), and a state-of-the-world assertion are claims too, and are the ones that mislead. Never present an unverified figure or an inference as fact. Note *how* the verified ones were checked and *from what* the derived ones were inferred.
- **Assumptions** — load-bearing ones flagged explicitly, with what would confirm/falsify each.
- **Artifacts** — files created/modified (paths), one line each on what + why. Cite from `git status` —
  or, when the tree is clean because the work merged, from the widened evidence of Step 1 (the landed
  commits and the files they touched). Say which, so "merged" never reads as "nothing happened".
- **Reversals / corrections** — anything that changed during the session (a dropped claim, a re-decided choice), so the next session doesn't resurrect it.
- **Open threads / unresolved** — questions still pending, deferred items, known gaps.

Keep it scannable (ranked, terse). Match length to session size; don't pad.

## Step 3 — Mid-flight continuation block (only if work is in progress)

If a task was underway when handoff was invoked, capture the exact resumable state:
- **What was in progress** and **precisely where it stands** (last completed step → next step).
- **Files/functions in play** (paths, and what's half-done in each).
- **Constraints that bind the continuation** — decisions/assumptions the next session must honor.
- **What NOT to redo** — settled choices, so the new session doesn't relitigate or duplicate.
- Any **pending command/verification** that was about to run.

Everything here is load-bearing by construction — it exists precisely so the next session acts on it
without re-deriving it. So **tag it** (`[verified]` / `[derived]` / `[unverified]` / `[assumed]`) or
state the evidence inline. Be strictest about any claim that would justify *changing* something: "this
looks wrong / is the cause / should be repaired" is a `[derived]` claim until you have read the thing
and can say why it is that way. If it might be deliberate, say so — that sentence is what stops the
next session from "fixing" a safety property.

## Step 4 — Write the handoff to disk (durable + machine-readable)

Write the full summary to a file so the next session can READ it rather than trust pasted prose:
`<primary-checkout>/.claude/handoffs/<YYYYMMDDTHHMMSSZ>_<slug>_<discriminator>.md` (create the dir; it's
additive/safe). If not in a writable repo, skip and emit in-chat only.

**Every filename carries a unique discriminator from the start — not only when a clash is noticed.**
That directory is shared by every worktree (see below), so two concurrent sessions can land on one path,
and losing a handoff to a silent overwrite is no better than losing it to worktree cleanup. Checking
whether the file exists and only *then* adding a suffix does not fix this: both sessions can look, both
can see nothing, and both can write the same path. Make the name unique before you write it.

**The discriminator is a fresh random token per write** (6-8 chars is plenty), generated at the moment
you write, not derived from anything. Don't reach for the session id: it is constant *within* a session,
so invoking `session-end` twice in the same second with the same slug would rebuild the same path, and
two different sessions can share a short id prefix anyway. Nothing depends on the filename identifying
the session, because the provenance marker inside the file already does that.

Then keep the no-clobber check as a backstop: if the path somehow exists, generate a new token rather
than overwriting. The random token is what makes a collision improbable; refusing to overwrite is what
keeps an improbable one from costing a handoff. Second-resolution timestamps matter for the same reason:
coarse stamps widen the window, and similar concurrent tasks produce similar slugs.

**Resolve `<primary-checkout>` explicitly — it is NOT necessarily where you are standing.** If this
session is running in a linked git worktree, the obvious answers (`git rev-parse --show-toplevel`, or the
cwd) give the *worktree's* root — and worktrees are routinely deleted when the task that created them
ends, taking the one artifact this skill exists to produce with them. A handoff that dies with its
worktree is worse than no handoff: the session reports success and leaves nothing behind. Resolve it:

```bash
git worktree list --porcelain | head -1 | sed 's/^worktree //'
```

The first entry is always the primary checkout. In an ordinary clone it returns that same checkout, so
run it unconditionally rather than branching on whether you think you're in a worktree. (In a bare-repo
+ worktrees setup the first entry is the bare `.git` directory, marked `bare` in the output — that has
no checkout to prefer, and it is the one location there that outlives every worktree, so it is still the
right target. Don't be surprised by a path ending in `.git`.) **State the
absolute path you wrote to in your closing message** — when the destination is not the directory the
session has been working in, that is precisely what the operator needs to see, not a silent redirect.

**On committing: don't — and you don't need to.** Durability here comes from *location*, not from
committing: the primary checkout outlives worktree cleanup, so the read-only gate stands. Whether
handoffs are *tracked* is the project's call and genuinely varies — some repos commit their handoff
history, others gitignore `.claude/` wholesale, and a handoff written under an ignored path is still
durable, just untracked. Report which you observed and leave the decision to the project; a declared
close-out contract (Step 4b) is the one thing that can authorize more.

Run that check **from the checkout you wrote to**, not from where you're standing:

```bash
git -C <primary-checkout> check-ignore -v .claude/handoffs/<file>
```

Bare `git check-ignore -v <absolute path>` from inside a worktree exits 128 with *"is outside
repository"*, because the path belongs to a different checkout than the cwd. Exit 0 means ignored,
1 means not ignored, and 128 means you asked the wrong repo. (If the primary is a bare repo, there is
no worktree to test against and no tracking question to answer; skip the check and say so.)

**Stamp a provenance marker as the very FIRST line of the handoff file** (before the H1), so a later automated
review can identify the handoff THIS session wrote and never a concurrent sibling session's:
`<!-- review-loop:session:<session-id> -->`. `<session-id>` is the current Claude Code session id (the UUID for
this session — e.g. the active transcript's id for this cwd, or a `--session-id` value surfaced in this
session). If you cannot determine it with confidence, write `<!-- review-loop:session:unattributed -->` — the
review will then safely SKIP reconciling this handoff rather than risk editing the wrong one. (HTML comments
are invisible in rendered markdown.) This is the only hook the `/review-loop` "Reconcile the session's handoff"
step keys on.

**Stamp where the session ran, on the line directly after the provenance marker:**
`<!-- session-end:origin branch=<current branch> worktree=<basename of the worktree root> -->`. Writing to
the primary checkout is what makes the handoff durable, but it also means sessions running in *different*
worktrees now share one handoff directory. "The newest file" therefore stops being a safe way for a later
`session-pickup` to tell whose handoff it is holding: end two concurrent sessions minutes apart and the
newest belongs to whichever finished last, not to the branch being resumed. This line is what lets pickup
disambiguate.

**`worktree=` is the directory's BASENAME, never an absolute path.** A handoff is durable and in many
projects committed, so anything stamped here can end up in shared or public history — and an absolute
path carries the operator's home directory, username, and often a client or project name. `branch` is
what pickup actually matches on; the basename is only a tiebreak for the rare two-worktrees-one-branch
case, and it buys that without persisting a home path. If even the basename is sensitive, drop the field
and keep `branch`.

## Step 4b — Honour the project's close-out contract (only if it declares one)

Some projects have a session **acquire** state at start that must be **released** at end: a claim on a
shared desk or role, a lock, a lease, a "session N is live" marker. This skill cannot know what those
are — but a claim left held by a session that no longer exists silently blocks the next one, and the
symptom (a live-looking claim naming a dead session) points nowhere near the cause.

Look for a project-declared contract at `<primary-checkout>/.claude/session-close-out.md`. **If it is
absent, skip this step** — never invent close-out actions a project did not ask for. If present, read it
and do what it says. **The writes it authorizes are a declared, scoped exception to the read-only gate**
— that is the contract's entire purpose, and it is bounded by what that file names and nothing wider.

**If you cannot complete it, say so loudly — that is the whole job here.** A close-out that silently
half-ran is worse than one never attempted, because the project now believes it ran. Name the exact
state left held, where it lives, and the command to clear it — in the handoff's **Open threads** section
*and* in your closing message. An unreleasable claim the operator can see is a nuisance; one they
cannot is a trap for the next session.

**A close-out is a record, not a deploy — never block on CI.** Releasing a claim, writing the handoff,
and stamping what happened are all descriptions of work that already occurred, so nothing here waits on
a build, a check run, or a merge queue. If CI is red or still running when you close out, say that in
the handoff and release the claim anyway. A desk that holds its claim until a pipeline turns green
hands the next session a live-looking lock on a dead process, which is the exact failure this step
exists to prevent. The companion rule on the reviewing side is "post the signed verdict and stop"
(`docs/AUTO-MERGE-POLICY.md` in Command).

**Then go back and record what it changed.** This step runs after the handoff is written, so a contract
that succeeds mutates state the handoff has already described — the file it touched is missing from
**Artifacts**, and any line saying the claim is held is now false. That is the same failure this skill
exists to prevent, produced by the skill itself: a record that reads as current while describing a state
that no longer exists. Whatever the contract changed is an artifact of this session, so amend the
handoff to list it and to reflect the released state. Update on the success path, not only on failure.

## Step 5 — Emit the continuation prompt (ONLY if work is mid-flight)

**If the session is DONE** — nothing in flight (work shipped, parked, or merged) — skip this step.
Close with the Step 2 record plus a one-line stop marker ("Nothing in flight; clean stopping point —
no resume needed."). Don't manufacture a continuation prompt when there's nothing to continue; that's
the whole point of *ending* vs *handing off*.

**If work is mid-flight** (Step 3 applied), emit a self-sufficient Claude Code prompt to start the next
session. It MUST:
1. Orient — project, branch, and that it continues a prior session.
2. **Point to disk first** — list the authoritative artifacts to READ before acting (the handoff file + the key specs/code), so the new session rehydrates from current state, not from the prompt's prose.
3. State the **immediate next action** concretely.
4. Carry the **load-bearing constraints, assumptions, and settled decisions** to honor (and what not to redo).
5. Note the verification debts (unverified claims/assumptions to confirm).
6. **Keep the tags on.** The continuation prompt is the furthest a claim travels from its evidence — a
   pasted prompt has no `git log` behind it. Stripping `[derived]` to make the prompt read cleanly is
   exactly how an inference becomes the next session's premise. Carry the tag, or the inline evidence.

Present it in a fenced block, ready to paste. Keep it tight but complete — it is the single thing that determines whether the next session continues cleanly.

## Safety + quality gate

- **Read-only to the repo** except (a) writing the one handoff file and (b) whatever a declared close-out
  contract authorizes (Step 4b). No commits. Absent such a contract, no edits to other files.
- **The handoff went to the primary checkout**, not a worktree that is about to be deleted (Step 4), and
  its absolute path is stated in the closing message.
- Every cited artifact exists in `git status`/on disk, **in the widened evidence** of Step 1, **or is a
  change an executed close-out contract made** (Step 4b) — including a file it *deleted*. Releasing a
  claim often means removing an untracked or ignored lock file, which then exists nowhere: not on disk,
  not in `git status` (deleting an ignored file leaves no status entry), not in any commit. The executed
  contract is the evidence for those, and without this carve-out the gate would force you to drop the
  close-out from the record or fail your own rule.
- Every load-bearing claim is tagged or evidenced. A clean working tree was investigated, not assumed empty.
- **Re-read your own draft for untagged diagnoses.** Scan for the shapes that hide inferences: *"X is the
  source of Y" · "the reason is…" · "this reads the…" · "that's a bug/divergence/leftover" · "should be
  fixed"*. For each, ask: **did I check this, or did I conclude it?** Concluded → `[derived]` (or go
  check it now). This pass is cheap and catches the review-pr failure above.
- A reader with an empty context window could resume from the continuation prompt alone.
- If you're unsure whether something was decided vs. discussed, say so — don't assert it as settled.

## Complements (not duplicates)
`session-pickup` is the inverse — it rehydrates the next session from the handoff doc this writes (invoke
it at the start of a continued session; only relevant when this ran in mid-flight mode). `pattern-retrospective`
(rigorous multi-session studies) and `chat-history-search` (find past prompts) are heavier and
backward-looking. `session-end` is the fast, forward-looking close-out for *this* session — recording it
always, and handing it off when there's work to continue.

