Skip to content

Instantly share code, notes, and snippets.

@tonydzi
Last active August 24, 2026 23:12
Show Gist options
  • Select an option

  • Save tonydzi/10efd6aa6e1ce206a049a4a1e58a4030 to your computer and use it in GitHub Desktop.

Select an option

Save tonydzi/10efd6aa6e1ce206a049a4a1e58a4030 to your computer and use it in GitHub Desktop.
Claude Code workdir sentry: SessionStart hook that warns when a session starts outside the machine's canonical project dir (project memory/CLAUDE.md/history are keyed to cwd)

Claude Code workdir sentry

One tiny SessionStart hook that tells Claude (and you) when a session started in the wrong folder — before you lose a day of context.

The problem this fixes

Claude Code keys three things to the directory a session starts in:

  1. Project memory (auto-memory) — lives in ~/.claude/projects/<encoded-cwd>/memory/
  2. Session history — same per-directory bucket
  3. Your project CLAUDE.md — loaded from the cwd upward

Start a session in the wrong folder and none of that loads. No error, no warning — Claude just quietly behaves like it has amnesia. You've probably seen the symptoms without knowing the cause:

  • "Claude ignores my CLAUDE.md rules" — sometimes the file was never in context at all
  • "My chat history / memory disappeared" — it's sitting in a different project bucket
  • "Claude feels dumber on this machine" — it's running without your project context

How common is this really?

We audited one long-running workstation (a machine in a 6-node Claude Code fleet):

Where sessions actually started Count
C:\Users\<user> (terminal's default tab dir) 4 370
C:\Windows\System32 (elevated shells, scheduled tasks) 2 420
The actual project folder 1 921

More than half of all sessions ran without their project context — and nobody noticed for months, because nothing ever complains.

Read the denominator before you quote this number. ~/.claude/projects/<slug>/ also contains subagents/*.jsonl — subagent transcripts, marked "isSidechain": true. They never open a memory store and never fire SessionStart, so counting **/*.jsonl measures the wrong population (thanks to @JhouCode for catching this). Count <slug>/*.jsonl only.

The bias does not have a fixed direction. Re-measuring one node of our fleet with the filter named: 1797 real sessions vs 613 subagent transcripts; 63.1% of real sessions keyed to an empty store, but only 47.3% if you count recursively — because 606 of those 613 subagent transcripts sit under the one slug that does have a store. A subagent inherits its parent's project, and parents are the healthy projects, so the passenger is systematically healthy and drags the rate down. Whether it inflates or deflates depends on which sessions spawn subagents on your box. Name your filter; don't assume its sign.

Quick self-diagnosis (30 seconds)

Look at ~/.claude/projects/ — every directory name encodes a start-cwd. If you see a fat C--Users-<you> or C--Windows-System32 next to your real project dir, you have this problem.

The fix (two halves)

Half 1 — the alarm. workdir_sentry.py runs at every session start (SessionStart hook). It compares the session's cwd against your machine's canonical project directory and, when they differ, prints one warning line that lands directly in the model's context:

[workdir-sentry] WARNING: this session started in 'C:\Users\me', but this machine's canonical Claude project dir is 'D:\projects\main'. Project memory, session history and CLAUDE.md are keyed to the working directory... Restart from 'D:\projects\main' for real work here.

So the model itself knows it's homeless and tells you on turn one — instead of you discovering it a week later.

Design choices, so you can trust it in your hook chain:

  • Silent when healthy. Exact canonical folder, allowed prefix, or unlisted machine → prints nothing. A subdirectory of the canonical dir gets a softer NOTE, not silence: CLAUDE.md still loads (it is searched cwd-upward) but memory and history are keyed to the exact start dir, so a subdir has its own empty bucket. Earlier versions stayed silent there — and the selftest asserted that silence, which is how the bug survived. Both this and the "/"-as-canonical-dir disarm were reported by @JhouCode.
  • Fail-open. Any error (missing config, broken JSON, weird stdin) → silence and exit 0. A watchdog must never block a session start.
  • Zero dependencies. Stdlib Python 3, one file, ~40 effective lines.
  • BOM-hardened. PowerShell 5.1 pipes prepend a UTF-8 BOM to stdin; the hook strips it before parsing (this exact BOM broke two of our own tools before we learned).

Half 2 — the entry. The alarm tells you you're lost; fixing the door stops you getting lost. Point your terminal's default start directory at the project:

  • Windows Terminal: settings.json"profiles": { "defaults": { "startingDirectory": "D:\\projects\\main" } }
  • macOS Terminal: Settings → Profiles → your profile → "Working directory: Other"
  • iTerm2: Profiles → General → Working Directory → "Directory"

In our audit, the 4 370 home-dir sessions existed purely because that's where a fresh terminal tab opens.

Install (2 minutes)

  1. Save workdir_sentry.py to ~/.claude/hooks/workdir_sentry.py.
  2. Copy workdir_homes.example.json to ~/.claude/workdir_homes.json and edit it: your machine name(s) → canonical project dir, plus always_ok_prefixes for places where non-canonical cwds are intentional (agent worktrees, scratch dirs, a notes vault).
  3. Register the hook in ~/.claude/settings.json:
{
  "hooks": {
    "SessionStart": [
      { "hooks": [ { "type": "command",
          "command": "python3 \"$HOME/.claude/hooks/workdir_sentry.py\"",
          "timeout": 10 } ] }
    ]
  }
}

On Windows use python "%USERPROFILE%\\.claude\\hooks\\workdir_sentry.py".

  1. Sanity-check without restarting anything:
python3 ~/.claude/hooks/workdir_sentry.py --selftest

--selftest runs the built-in cases (wrong dir warns, right dir is silent, allowed prefix is silent, unlisted machine is silent, broken config never crashes) and prints PASS or what failed. You can also try a single path by hand: --check /some/path.

FAQ

Why a per-machine config file instead of hardcoding a path? The config syncs fine across machines (each machine only reads its own entry), while the hook registration lives in per-machine settings.json. If you run one machine, one entry is all you need.

Why warn instead of auto-cd or blocking? A session's cwd can't be changed retroactively by a hook, and blocking would break intentional off-project sessions (quick questions, scratch work). One honest line in the model's context is the right amount of force — the model relays it and you decide.

Does this work in the Desktop app? Yes — SessionStart hooks fire there too, so a session opened in the wrong folder via the folder picker gets the same first-turn warning.

What about agent worktrees / scratchpads? That's what always_ok_prefixes is for — subagents legitimately start in isolated worktrees; you don't want a false alarm per agent.


Built after the fleet audit above, by Mycroft (synthetic cofounder) & Tony, Palo Alto AI Research Lab. MIT — take it, ship it, adapt it.

{
"_readme": "Machine name (COMPUTERNAME / hostname, upper-cased on lookup) -> canonical Claude Code project dir. Machines not listed = sentry stays silent. always_ok_prefixes = intentional non-canonical cwds that must NOT warn (agent worktrees, scratchpads, a notes vault).",
"nodes": {
"MY-DESKTOP": "D:\\projects\\main-workspace",
"MY-MACBOOK": "/Users/me/projects/main-workspace"
},
"always_ok_prefixes": [
"C:\\Users\\me\\.claude\\worktrees",
"C:\\Users\\me\\AppData\\Local\\Temp\\claude"
]
}
#!/usr/bin/env python3
"""workdir_sentry.py — Claude Code SessionStart hook: one warning line when a
session starts outside this machine's canonical project directory.
WHY THIS EXISTS
Claude Code keys project memory, session history and CLAUDE.md to the
directory a session STARTS in. Start in the wrong folder (your home dir,
System32 via an elevated shell, a synced copy of another machine's
project) and the session silently loads the wrong — usually empty —
context. Nothing errors. Nothing warns. Claude just gets amnesia.
Measured on one long-running workstation: 4370 sessions had started in
$HOME and 2420 in System32, vs 1921 in the actual project folder. More
than half of all sessions ran context-less, unnoticed, for months.
WHAT IT DOES
At session start it compares the session's cwd with the canonical
project dir you declared for this machine in ~/.claude/workdir_homes.json.
Mismatch -> it prints ONE line, which Claude Code feeds into the model's
context, so the model itself knows it's homeless and says so on turn one.
Match / allowed prefix / unlisted machine -> silence (a healthy watchdog
is a quiet watchdog).
SAFETY CONTRACT (read this before adding to your hook chain)
* fail-open: ANY error -> print nothing, exit 0. A sentry must never
block or slow a session start.
* zero dependencies: stdlib only, one file.
* BOM-hardened: a PowerShell 5.1 pipe prepends U+FEFF to stdin, which
makes json.loads throw. We strip it. (This exact byte silently broke
two of our own tools before we learned. Keep the strip if you edit.)
USAGE
as a hook : register in settings.json (see README.md)
--check P : dry-run one path, see what the hook would say
(no config yet -> a worded hint, never a traceback)
--selftest : run the built-in cases, print PASS/FAIL (exit 0/1)
CONFIG (~/.claude/workdir_homes.json)
{
"nodes": { "<MACHINE-NAME>": "<canonical project dir>" },
"always_ok_prefixes": [ "<dirs where odd cwds are intentional>" ]
}
Machine names are matched against COMPUTERNAME / hostname, upper-cased.
always_ok_prefixes = agent worktrees, scratch dirs, a notes vault —
places where a non-canonical cwd is on purpose and must not alarm.
"""
import json
import os
import platform
import sys
def machine_key() -> str:
"""This machine's name, upper-cased: COMPUTERNAME on Windows, hostname elsewhere."""
return (os.environ.get("COMPUTERNAME") or platform.node().split(".")[0] or "").upper()
def norm(p: str) -> str:
"""Normalize a path for comparison: expanduser, collapse separators,
case-fold on Windows (its filesystems are case-insensitive; comparing raw
strings would miss D:\\Proj vs d:\\proj).
NOTE: we deliberately do NOT rstrip separators here. normpath already
removes trailing ones, and stripping again turns the filesystem root "/"
into "" -- which used to make `startswith(nhome + os.sep)` true for EVERY
absolute path and silently disarm the whole sentry. Reported by @JhouCode
in anthropics/claude-code#82056.
"""
p = os.path.normpath(os.path.expanduser(p))
return p.lower() if os.name == "nt" else p
def under(child: str, parent: str) -> bool:
"""True if `child` is `parent` or lives inside it. Component-wise, so
"/proj2" is not "inside" "/proj", and a parent of "/" behaves sanely."""
if child == parent:
return True
if not parent.endswith(os.sep):
parent += os.sep
return child.startswith(parent)
def check(cwd: str, cfg: dict, key: str) -> str:
"""Core rule. Returns the warning text, or '' when everything is fine.
Pure function on purpose: no I/O, no env — so it's trivially testable
(see selftest below) and you can lift it into your own tooling.
"""
nodes = {k.upper(): v for k, v in cfg.get("nodes", {}).items()}
home = nodes.get(key)
if not home:
return "" # machine not registered -> not our business, stay silent
ncwd, nhome = norm(cwd), norm(home)
# Exact match is the ONLY fully healthy case: project memory and session
# history are keyed to the EXACT start dir, so `<home>/docs` gets its own
# (empty) bucket even though it is "inside the project".
if ncwd == nhome:
return ""
for pref in cfg.get("always_ok_prefixes", []):
np = norm(pref)
if not np or np == os.sep:
continue # a degenerate prefix ("/", "") would silence everything
if under(ncwd, np):
return "" # intentional off-project place (worktree, scratch) -> silent
# A subdir of the canonical dir is a PARTIAL miss, and saying so precisely
# matters: CLAUDE.md still loads (it is searched cwd-upward), memory and
# history do not (they are keyed to the exact path).
if under(ncwd, nhome):
return (f"[workdir-sentry] NOTE: this session started in '{cwd}', a subdirectory "
f"of this machine's canonical Claude project dir '{home}'. CLAUDE.md still "
f"loads (it is searched from the cwd upward), but project memory and session "
f"history are keyed to the EXACT start directory, so this session has its own "
f"— usually empty — bucket. Restart from '{home}' to reuse the project's memory.")
return (f"[workdir-sentry] WARNING: this session started in '{cwd}', but this "
f"machine's canonical Claude project dir is '{home}'. Project memory, "
f"session history and CLAUDE.md are keyed to the working directory, so "
f"you are running with the WRONG (or no) project context. Restart the "
f"session from '{home}' for real work here.")
def _load_cfg() -> dict:
cfg_path = os.path.join(os.path.expanduser("~"), ".claude", "workdir_homes.json")
# utf-8-sig: tolerate a BOM here too (editors on Windows love adding one)
with open(cfg_path, "r", encoding="utf-8-sig") as f:
return json.load(f)
def selftest() -> int:
"""Built-in cases so you can trust the sentry before wiring it in."""
cfg = {"nodes": {"BOX": "D:/proj"}, "always_ok_prefixes": ["D:/ok"]}
cases = [
("canonical dir is silent", check("D:/proj", cfg, "BOX") == ""),
# A subdir is NOT silent: memory/history are keyed to the exact start
# dir, so it gets its own empty bucket. The old selftest asserted the
# opposite and locked the bug in for a month.
("subdir of canonical is flagged", "NOTE" in check("D:/proj/sub/deep", cfg, "BOX")),
("subdir note says CLAUDE.md still loads",
"CLAUDE.md still" in check("D:/proj/sub/deep", cfg, "BOX")),
("sibling dir is not 'inside'", "WARNING" in check("D:/proj2", cfg, "BOX")),
("allowed prefix is silent", check("D:/ok/x", cfg, "BOX") == ""),
# root-as-home must NOT swallow every absolute path
("root home still flags a foreign dir",
check("/etc", {"nodes": {"BOX": "/"}}, "BOX") != ""),
("root home is silent at root itself",
check("/", {"nodes": {"BOX": "/"}}, "BOX") == ""),
("a '/' always_ok_prefix does not disarm",
check("/etc", {"nodes": {"BOX": "/proj"}, "always_ok_prefixes": ["/"]}, "BOX") != ""),
("foreign dir warns", "WARNING" in check("C:/Users/me", cfg, "BOX")),
# the warning must NAME the canonical dir — an alarm that doesn't say
# where to go just adds anxiety, not a fix
("warning names the home dir", "D:/proj" in check("C:/Users/me", cfg, "BOX")),
("unlisted machine is silent", check("C:/Users/me", cfg, "GHOST") == ""),
("empty config never crashes", check("C:/anything", {}, "BOX") == ""),
]
failed = [name for name, ok in cases if not ok]
print("FAIL: " + ", ".join(failed) if failed else "PASS (%d cases)" % len(cases))
return 1 if failed else 0
def main() -> None:
if "--selftest" in sys.argv:
sys.exit(selftest())
if "--check" in sys.argv:
# dry-run one path with the real config: what would the hook say?
# Unlike hook mode this talks to a HUMAN, so errors are worded, not silent.
idx = sys.argv.index("--check") + 1
if idx >= len(sys.argv):
sys.exit("usage: workdir_sentry.py --check <path>")
path = sys.argv[idx]
try:
cfg = _load_cfg()
except FileNotFoundError:
sys.exit("no config yet — create ~/.claude/workdir_homes.json first "
"(copy workdir_homes.example.json from this gist and edit it)")
except Exception as e:
sys.exit("config exists but can't be read (%s: %s) — "
"fix ~/.claude/workdir_homes.json" % (type(e).__name__, e))
msg = check(path, cfg, machine_key())
print(msg or "(silent — '%s' is fine on %s)" % (path, machine_key()))
sys.exit(0)
# ---- hook mode: everything below is fail-open by contract ----
try:
try:
# lstrip BOM: PowerShell 5.1 pipes prepend U+FEFF, json.loads rejects it
payload = json.loads(sys.stdin.read().lstrip("\ufeff"))
except Exception:
payload = {}
cwd = payload.get("cwd") or os.getcwd()
msg = check(cwd, _load_cfg(), machine_key())
if msg:
print(msg)
except BaseException:
pass # a sentry must never break a session start
sys.exit(0)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment