Skip to main content

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:

AxisFailure modeMitigation
Write-side safetyA 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 freshnessA 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-app branch with unstaged edits. He triggers /share-rule from session B (different terminal, same repo). The naΓ―ve git checkout proposal/team-rule/xxx in 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):

  1. 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.
  2. Configured path. Check ~/.claude/settings.json for a bewithDocsPath key. If present and the directory has the matching git remote, use it.
  3. Common paths. Try ~/workspace/bewith-docs/ then ~/code/bewith-docs/. Verify the git remote before using.
  4. Ask the developer. If none of the above resolve, ask for the absolute path. Offer to save it under bewithDocsPath in ~/.claude/settings.json so 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 local master (could be stale) and never off the developer's current branch (could be unrelated work).
  • No fallback to the primary worktree. If git worktree add fails, fail loudly. Do not "just cd $BEWITH_DIR and 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 -D of 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 mentionsAuthoritative 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 artifactinspect the artifact directly
next session's task is Xthe 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:

  1. Clobber β€” both sessions edit the same files / the same branch in the same working directory and overwrite each other.
  2. Staleness β€” the session you're typing in started a while ago; since then master advanced 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-effort git fetch, ≀ every 3 min) and injects a one-shot "re-verify before acting" reminder when origin/master advanced 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>/.git even from a linked worktree), so a session running in a /tmp worktree 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-worktree repo_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=1 bypass) 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 under refs/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-code skill / /restore command β€” 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-tree in a temp index plus a commit-tree β€” no working-tree mutation, no staged changes, no user-visible refs. The refs/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:

TierWhere session safety firesWhat it does
Skill / agent bodyInside the platform artifacts that mutate stateThe worktree recipe is mandatory; pre-flight refuses on collision.
SessionStart hooksession-state-awareness.shInjects the freshness + worktree-awareness rules into Claude's first-message context. Detects sibling worktrees on the cwd repo and surfaces them.
PreToolUse hookworktree-mutation-warn.shSoft 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 hooksafety-snapshot.shBefore 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 hookbranch-intent-warn.shBefore 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.

ArtifactMutates git?Worktree-isolated?Notes
propose-team-rule skillyesβœ“Reference implementation.
draft-proposal skillyesβœ“Second example, different target dir.
new-artifact skillyesβœ“Updated 2026-05-29 to adopt the worktree recipe in step 6.
code-reviewer agentnon/aRead-only tool allowlist (Read, Grep, Glob, Bash).
process-guardian agentnon/aRead-only tool allowlist.
/share-rule, /propose, /new-artifact commandsnon/aThin wrappers β€” safety is at the underlying skill.
scripts/install-git-hooks.sh, scripts/install-to-consumer.shno (creates symlinks)n/aDeveloper-run installers, not session-run.
safety-snapshot.sh hookwrites local refs onlyn/aBuilds 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 / /restorewrites working tree (by design)exemptRecovery 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/*.shnon/aRead 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​