Session Safety
A Claude Code session does not exist in a vacuum. At any moment there may be another session active on the same machine, against the same repository, holding unstaged changes or working on a different branch. There may also be past sessions whose memory entries the current session is reading as fact β even though the world has moved on since those entries were written.
This document defines the discipline that keeps a session from harming itself or its peers. It has two axes:
| Axis | Failure mode | Mitigation |
|---|---|---|
| Write-side safety | A session mutates the developer's working tree while another session is editing it. Branches switch under foot, commits land on the wrong base, unstaged work disappears. | Operate in an isolated git worktree. Never touch the developer's primary worktree. |
| Read-side freshness | A session reads a memory entry as fact ("PR #23 is open"), but the world has changed (PR #23 is merged). Decisions cascade off stale state. | Verify before acting. Memory is a hint, not an oracle. Check gh pr view, git ls-remote, file existence β whichever is cheap and authoritative. |
Both axes apply to any artifact in the platform that mutates state or that reasons from memory. The audit table at the bottom of this page records who currently complies.
Why this existsβ
PR #23 shipped the
team-rule proposal flow. Before that PR, the natural implementation
would have been: cd into bewith-docs, create a branch, edit files,
commit, push, open a PR. That works fine when only one session is
running. It is unsafe the moment a second session is active.
Concrete scenario captured during that PR's review:
Ariel has session A open on the
attendance-appbranch with unstaged edits. He triggers/share-rulefrom session B (different terminal, same repo). The naΓ―vegit checkout proposal/team-rule/xxxin session B would switch branches under session A's feet, surfacing the unstaged edits as conflicts and risking data loss.
The fix landed in propose-team-rule step 5 β every mutation happens
inside a temporary worktree. The developer's primary worktree is never
touched. This document promotes that pattern to a platform-wide rule.
The read-side axis came up the same week, in a different shape:
A session resumed and read a memory entry stating
PR #23 is open, follow-up methodology doc is the next session's task. By then PR #23 was merged and two follow-up PRs (#24, #25) had landed. The session needed to be told to re-read the world before acting on memory.
Memory is necessarily a snapshot. Sessions must learn to verify the snapshot before relying on it.
Write-side safety β the worktree patternβ
Rule: any platform artifact (skill, agent, command, hook, script)
that mutates git state from inside a session MUST operate in a
short-lived git worktree rooted at mktemp -d. The developer's
working tree is off-limits.
The recipeβ
# 1. Locate the target repo. See "Locating the bewith-docs checkout"
# below for the canonical resolution cascade.
BEWITH_DIR=<resolved path>
# 2. Derive the branch name. It MUST be a single `<type>/<slug>` segment,
# where <type> is an ALLOWED branch type:
# feature | bug | hotfix | infra | chore | refactor | docs | proposal
# NEVER invent a prefix (`insight/`, `decision/`) and NEVER nest a second
# slash (`proposal/team-rule/x`) β git-hooks/branch-name-check.sh rejects
# both at commit time, so the skill's own commit would fail. Pre-flight:
# refuse if the branch already exists locally or on the remote. Do NOT
# auto-rename or force-delete.
BRANCH="docs/<slug>" # pick the type that fits the output; e.g. docs/ for doc-only
git -C "$BEWITH_DIR" fetch origin master --quiet
if git -C "$BEWITH_DIR" rev-parse --verify --quiet "refs/heads/${BRANCH}" >/dev/null; then
echo "ERROR: branch ${BRANCH} already exists locally. Pick a different slug." >&2
exit 1
fi
if git -C "$BEWITH_DIR" ls-remote --exit-code origin "${BRANCH}" >/dev/null 2>&1; then
echo "ERROR: branch ${BRANCH} already exists on remote β probably an in-flight proposal." >&2
exit 1
fi
# 3. Create an isolated worktree off origin/master.
WORKTREE="$(mktemp -d -t bewith-<scope>.XXXXXXXX)"
git -C "$BEWITH_DIR" worktree add -b "${BRANCH}" "${WORKTREE}" origin/master
# A linked worktree has NO node_modules, and the pre-commit validators import
# js-yaml etc. The pre-commit hook (git-hooks/pre-commit) auto-symlinks it in a
# worktree, so the commit below won't hit ERR_MODULE_NOT_FOUND β no manual step
# needed. (Symlinking explicitly is harmless if you prefer to be self-contained:
# ln -s "$BEWITH_DIR/node_modules" "${WORKTREE}/node_modules" )
# 4. Work, commit, push, open the PR β all inside ${WORKTREE}.
cd "${WORKTREE}"
# ... edits ...
git add <files>
git commit -m "..."
git push -u origin "${BRANCH}"
gh pr create --title "..." --body "..."
# 5. Cleanup. Always, even on failure paths (use a trap).
git -C "$BEWITH_DIR" worktree remove --force "${WORKTREE}"
The worktree shares .git/ with the primary worktree β branches and refs
are visible across both β but the working directories are independent.
Switching branches in one does not move HEAD in the other.
Reference implementationβ
propose-team-rule
steps 4β8 are the canonical implementation. draft-proposal
steps 5β10 are a second example with a different output target.
new-artifact
step 6 is a third, where the worktree carries platform-artifact authoring (agents, skills, hooks, etc.).
Locating the bewith-docs checkoutβ
Every state-mutating skill that targets bewith-docs needs to find the developer's local clone. The resolution cascade is the same for all three skills and should not be duplicated in each one. The canonical cascade (in preference order):
- cwd is already bewith-docs. If
git -C "$(pwd)" remote get-url origin | grep -E 'bewith-dev/bewith-docs(\.git)?$'matches, use$(pwd). Most common path β the developer is editing in the repo. - Configured path. Check
~/.claude/settings.jsonfor abewithDocsPathkey. If present and the directory has the matching git remote, use it. - Common paths. Try
~/workspace/bewith-docs/then~/code/bewith-docs/. Verify the git remote before using. - Ask the developer. If none of the above resolve, ask for the absolute path. Offer to save it under
bewithDocsPathin~/.claude/settings.jsonso the next call hits step 2.
If no clone is found after all four steps: stop. Do not invent fallbacks (clipboard dump, manual instructions, stdout). The skill's contract is "open a PR via git" β if it can't, it doesn't run. Tell the developer in their language:
"ΧΧΧ clone Χ©Χ bewith-docs ΧΧΧΧΧ Χ. ΧΧ ΧΧ€Χ©Χ¨ ΧΧ€ΧͺΧΧ PR ΧΧΧ clone. ΧΧ ΧΧ ΧΧΧ Χ β Χ¦ΧΧ¨ clone ΧΧΧ¨Χ₯ Χ©ΧΧ. ΧΧ ΧΧΧ ΧΧ ΧΧΧ©Χ Χ-org β ΧΧ ΧΧ ΧΧΧ¨ΧΧΧ ΧΧ©ΧΧΧΧ."
Set BEWITH_DIR=<resolved path> and continue with the worktree recipe.
State-mutating skills in bewith-docs reference this section instead of restating the cascade. Update the cascade here when it changes; the skills inherit.
Pre-flight invariantsβ
- Branch must not exist locally or remotely. Pre-flight refuses
silently β never auto-renames, never
branch -Ds. A name collision means a concurrent proposal is in flight; the developer must decide what to do. - Base off
origin/master. Never off the developer's localmaster(could be stale) and never off the developer's current branch (could be unrelated work). - No fallback to the primary worktree. If
git worktree addfails, fail loudly. Do not "justcd $BEWITH_DIRand proceed" β that is the exact failure mode the pattern exists to prevent.
Anti-patternsβ
cd $BEWITH_DIR && git checkout -b ...β switches the developer's branch under their feet.git stash; <mutations>; git stash popβ relies on the stash surviving across the whole operation; race conditions if another session pops first; loses unstashed-but-staged work.- Force-deleting a colliding branch.
git branch -Dof a collision is destructive of someone else's in-flight work. - Working in the primary worktree "just for read-only steps". Even an audit grep can block if another session is mid-merge with locks held. Worktrees are not just for writes; they are for any operation whose duration overlaps another session.
Why mktemp -d instead of a fixed pathβ
mktemp -d -t bewith-<scope>.XXXXXXXX guarantees no collision across
concurrent sessions. A fixed path like /tmp/bewith-team-rule-worktree
would itself be the collision point β session A's cleanup could
clobber session B's in-flight work.
Read-side freshness β verify before acting on memoryβ
Rule: when a session is about to act on a memory entry that names a checkable artifact β a PR number, a branch name, a ticket ID, a file path, a task status β verify the artifact's current state before relying on the memory. Memory is a hint; the world is the oracle.
What "checkable" meansβ
| Memory mentions | Authoritative check |
|---|---|
PR #<n> | gh pr view <n> --json state,mergedAt,headRefName |
branch <name> | git ls-remote --exit-code origin <name> |
file at <path> | test -e <path> (or read it) |
ClickUp task CU-<id> | clickup_get_task MCP, or gh issue view if cross-linked |
status: in progress on a named artifact | inspect the artifact directly |
next session's task is X | the current session may already be X, or X may be done β confirm before redoing |
Any memory entry that talks about concrete platform state falls under this rule. Memory entries that record user preferences, project goals, or non-checkable facts (e.g. "Ariel prefers terse responses") do not.
The freshness workflowβ
Pseudocode for the session at startup, after reading memory:
for each memory entry just loaded:
identify any checkable references (PR #, branch, file path, task ID)
for each reference:
perform the cheap authoritative check
if current state differs from what memory implies:
surface the drift to the user before relying on the entry
treat memory as informative ("here is the history") rather than
active ("here is the world")
The session must state the drift to the user rather than silently overriding memory. Concrete phrasing:
"Memory says PR #23 is open and a follow-up doc is pending. Actual state: PR #23 is merged; PRs #24 and #25 also landed since. I will work against current master."
When verification is too expensiveβ
For a long memory store, verifying every entry at session start would cost more than the benefit. The discipline is lazy verification: verify only when the session is about to act on the entry. A memory entry that the session merely passes over does not need verification.
A SessionStart awareness hook (see
session-state-awareness.sh)
nudges Claude with the rule itself; the runtime checks happen later,
in-context, when relevance is clear.
Anti-patternsβ
- Trusting memory without checking when the memory names a PR, branch, or task. The fastest way to a wrong decision.
- Bulk-verifying everything at startup. Wasteful, slow, and the results go stale by the time the session uses them.
- Silently overriding memory when drift is detected. The user cannot calibrate the session's mental model without knowing what shifted.
- Treating "drift detected" as cause for panic. The mitigation is a one-line statement to the user and a re-read of current state. It is not a workflow stop.
Same-machine concurrent sessions β the live registryβ
The worktree pattern protects against cross-checkout clobber; the freshness rule protects against stale memory. Neither fully covers the most common real case: two or more Claude sessions running on the same machine at the same time β two VS Code windows on the same checkout, a CLI session plus a desktop session, several panes. Two failure modes:
- Clobber β both sessions edit the same files / the same branch in the same working directory and overwrite each other.
- Staleness β the session you're typing in started a while ago; since
then
masteradvanced or another session committed, and it's acting on a world that no longer exists. (The manual symptom: telling Claude "a lot changed since the last message, refresh your context.")
Both are must-solve β parallel work is a first-class workflow, so the goal
is awareness and safety, not prevention. The mechanism is a local,
per-machine session registry (.claude/session/sessions/, gitignored),
written and read by three hooks:
session-register.sh(SessionStart) β writes this session's entry:{session_id, surface, branch, cwd, repo_root, started_at, last_heartbeat, baseline_head, baseline_origin_master, files_touched}.session-guard.sh(PreToolUse Edit/Write) β refreshes the heartbeat + records the touched file, then applies the hybrid policy: a warning when another live session has touched the same file (parallel work continues β you're just told), and a block only when another live session owns the same branch in the same working directory (the case that actually corrupts work). "Owner" is the earliest-started session, so the later arrival is the one redirected β no deadlock.session-drift-check.sh(UserPromptSubmit) β on each message, compares the live repo to this session's baseline (best-effortgit fetch, β€ every 3 min) and injects a one-shot "re-verify before acting" reminder whenorigin/masteradvanced or the branch HEAD moved. This is the automated form of the manual "refresh your context" nudge.
The registry is keyed to the main repo, shared by all of a repo's worktrees. Each hook resolves the registry directory via
git rev-parse --git-common-dir(which points at<main>/.giteven from a linked worktree), so a session running in a/tmpworktree writes its entry into the main checkout's.claude/session/sessions/rather than burying it inside the throwaway worktree where nothing can see it. Work stays isolated (the worktree pattern is unchanged) while every session is visible together in one registry β and collision detection still keys on the per-worktreerepo_root+branch, so two different worktrees never block each other, but two sessions in the same worktree+branch do.
Two invariants make this safe to run on every edit:
- The block tells you how to continue without losing WIP. Your uncommitted work lives in the git working tree and is never touched; the block only stops the write and hands you the worktree recipe (or the
ALLOW_CONCURRENT_EDIT=1bypass) so the in-progress changes are preserved, not discarded. - Reaping is non-destructive and visible. A session whose heartbeat has gone stale (default 15 min) has only its registry entry β which holds file paths, never file content β moved to
sessions/archive/(never deleted) so the claim is released without ever risking code. Every hook is fail-open: any internal error exits 0, so a registry bug can never block real work.
Scope. This solves the same-machine case. Cross-machine ownership (laptop vs a coworker's machine) is left to the actor convention + the git/Issue/ClickUp record, and a cross-machine lease remains a real deferred item (Open Questions) β likely unnecessary given the current task-distribution model.
In-session edits β the snapshot safety netβ
The registry stops one session from clobbering another; the freshness rule stops
a session from acting on a stale world. Neither covers the most ordinary loss of
all: a single session's own edit overwrites good uncommitted code β a rewrite
that drops a function, a generated file that truncates an existing one, a Write
over content that was never Read. The developer should never have to think
about this, or pre-emptively commit, to stay safe.
The mechanism is an always-on, non-destructive snapshot taken before every edit:
safety-snapshot.sh(PreToolUse Edit/Write/NotebookEdit) β before the tool runs, captures the full working tree (tracked + untracked,.gitignore-respecting) as a commit object assembled in a throwaway index (GIT_INDEX_FILE), and anchors it underrefs/bewith-safety/<session>/<epoch>. It never touches the real index or working tree, is debounced (default 20 s) and auto-pruned (default 7 days), and is fail-open β a snapshot failure can never block an edit.restore-codeskill //restorecommand β reads those snapshots back. Invoked explicitly, by free language ("I lost code" / "ΧͺΧ©ΧΧΧ¨ ΧΧͺ ΧΧ Χ©ΧΧΧ"), or proactively when Claude detects an edit dropped code. It always shows a diff and confirms before writing, scopes to the lost path(s), takes a pre-restore snapshot so the recovery is itself reversible, and falls back to git's own nets (reflog, stash, dangling blobs) when the snapshots predate the loss.
Two properties make this safe to run on every edit:
- It is invisible and cheap. The snapshot is a
git write-treein a temp index plus acommit-treeβ no working-tree mutation, no staged changes, no user-visible refs. Therefs/bewith-safety/*namespace never appears in branch listings and never pushes (no refspec matches it); refs are local-only and pruned, so the object store does not grow without bound. - The net is git, not memory. Recovery returns the actual bytes that were there, not a reconstruction β which is exactly why the snapshot is taken before the edit instead of relying on Claude to remember what it overwrote.
Scope. This protects uncommitted work inside a session. Committed work is already safe in the object store (recoverable via reflog) and pushed work is on the remote; the snapshot net closes the one remaining window β the live working tree between commits.
Enforcementβ
Session safety lives at the session boundary β neither at commit
time nor at PR time. It cannot be expressed as a git hook or a CI
workflow because by the time a commit reaches pre-commit, the
violation (if any) has already happened. The relevant tiers, mapping
to the ladder in enforcement-tiers.md:
| Tier | Where session safety fires | What it does |
|---|---|---|
| Skill / agent body | Inside the platform artifacts that mutate state | The worktree recipe is mandatory; pre-flight refuses on collision. |
| SessionStart hook | session-state-awareness.sh | Injects the freshness + worktree-awareness rules into Claude's first-message context. Detects sibling worktrees on the cwd repo and surfaces them. |
| PreToolUse hook | worktree-mutation-warn.sh | Soft warning when a git commit / push / checkout -b runs in the primary worktree while sibling worktrees exist. Warns, does not block β false positives would be more harmful than the rare miss. |
| PreToolUse hook | safety-snapshot.sh | Before every Edit/Write, snapshots the full working tree to refs/bewith-safety/ so any uncommitted code an edit clobbers is recoverable via /restore. Fail-open; never blocks. |
| PreToolUse hook | branch-intent-warn.sh | Before every Edit/Write, warns when the write lands in the primary worktree on a branch that contradicts the session's recorded task branch (active-context.json), echoing intended vs current. Silent on shared branches (block-edit-on-master.sh hard-blocks those), linked worktrees (the isolation pattern), detached HEAD, or when no task is recorded. Fail-open; never blocks. |
The body-level enforcement is the load-bearing layer; the hooks are awareness mechanisms that catch drift between authoring and execution.
Why a soft warning, not a blockβ
A hard block on git commit in the primary worktree would be unbearable
for the developer's normal day-to-day work. The signal that should
fire β "this session is mutating the primary worktree while another
session may be active" β is not derivable from the command alone. The
worktree-mutation-warn hook makes a heuristic guess (git worktree list shows >1 worktree) and emits a one-line nudge. The session and
the developer decide what to do with it.
If a hard gate is ever justified, it belongs in the skill body, not in the hook β the skill knows whether it's the kind of operation that must be isolated.
Audit β current platform complianceβ
Run on 2026-05-29; reaudited 2026-06-09 when the snapshot net landed. Reaudit when a new state-mutating artifact lands.
| Artifact | Mutates git? | Worktree-isolated? | Notes |
|---|---|---|---|
propose-team-rule skill | yes | β | Reference implementation. |
draft-proposal skill | yes | β | Second example, different target dir. |
new-artifact skill | yes | β | Updated 2026-05-29 to adopt the worktree recipe in step 6. |
code-reviewer agent | no | n/a | Read-only tool allowlist (Read, Grep, Glob, Bash). |
process-guardian agent | no | n/a | Read-only tool allowlist. |
/share-rule, /propose, /new-artifact commands | no | n/a | Thin wrappers β safety is at the underlying skill. |
scripts/install-git-hooks.sh, scripts/install-to-consumer.sh | no (creates symlinks) | n/a | Developer-run installers, not session-run. |
safety-snapshot.sh hook | writes local refs only | n/a | Builds snapshot commits in a throwaway index under refs/bewith-safety/* (never pushed, auto-pruned). No working-tree, branch, or history mutation β nothing to isolate. Fail-open. |
restore-code skill / /restore | writes working tree (by design) | exempt | Recovery tool β its purpose is to restore code into the developer's tree, on explicit request, after a diff + a reversible pre-restore snapshot. Mutates tree/index, never branches/history. |
other .claude/hooks/*.sh | no | n/a | Read state and emit text β they never git checkout / commit / push. |
Acceptance criterion for new artifactsβ
A new platform artifact that mutates git state cannot merge until it
either (a) adopts the worktree pattern with pre-flight checks, or (b)
states explicitly why it is exempt (read-only step in a larger
workflow, runs only during installation, etc.). The
new-artifact
skill enforces this in step 6 of its authoring procedure.
Cross-referencesβ
enforcement-tiers.mdβ the broader enforcement ladder. Session safety lives at a tier that ladder does not list (the session boundary itself).artifact-types.mdβ the decision tree for picking an artifact shape. Session safety is one of the authoring-time invariants every new artifact must satisfy.autonomy-model.mdβ the Hard Floor + configurable + never-ask hierarchy. Worktree isolation is implicit in the Hard Floor: any operation that can damage shared branches requires the safety this document defines..claude/skills/propose-team-rule/SKILL.mdβ reference implementation of the worktree pattern..claude/hooks/session-state-awareness.shβ the SessionStart hook that teaches the freshness rule..claude/hooks/worktree-mutation-warn.shβ the PreToolUse soft-warn hook.