"""Claude Code hooks that hand out and take back the working copies managed by Scripts/scratch.py. One entry point serves three events, chosen by `hook_event_name` in the JSON on stdin: WorktreeCreate Locks a free workspace, updates it and prints its path as the session's working directory. WorktreeRemove Unlocks the workspace, refusing while it holds uncommitted work. The directory is kept, since workspaces are reused and Claude Code counts a zero exit as the worktree being gone. SessionStart In the main working copy only, reports locks older than ten days. """ import datetime import json import os import sys MAIN_ROOT = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")) sys.path.insert(0, os.path.join(MAIN_ROOT, "Scripts")) import scratch # noqa: E402 def account_name(environment): """Returns 'account2' when the session runs from the second config directory, and 'account1' otherwise.""" config_directory = environment.get("CLAUDE_CONFIG_DIR", "") return "account2" if "account2" in config_directory else "account1" def create_worktree(payload, scratch_root, environment): """Locks and updates a workspace, returning its path. A workspace that can't be updated goes back to the pool.""" workspace_path = scratch.take(scratch_root, session_id=payload["session_id"], account=account_name(environment), task=payload.get("name", "")) try: scratch.refresh(workspace_path) except Exception: scratch.release(scratch_root, workspace_path, has_changes=lambda path: []) raise return workspace_path def remove_worktree(payload, scratch_root): """Unlocks the workspace at `worktree_path`, raising WorkspaceHasChanges while it holds work.""" scratch.release(scratch_root, payload["worktree_path"]) def stale_lock_report(payload, scratch_root, main_root=MAIN_ROOT): """Returns a message naming old locks, or None when there are none or this isn't the main working copy.""" if os.path.normcase(os.path.normpath(payload.get("cwd", ""))) != os.path.normcase(os.path.normpath(main_root)): return None stale = scratch.stale_locks(scratch_root) if not stale: return None lines = [scratch.describe_holder(name, holder) for name, holder in stale] return ("Workspaces held for more than {} days. Check whether these sessions are still alive, then release the " "workspaces with Scripts/scratch.py:\n{}".format(scratch.STALE_DAYS, "\n".join(lines))) def session_start_output(message): """Returns the JSON a SessionStart hook prints to show `message` to the user and to Claude.""" return json.dumps({"systemMessage": message, "hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": message}}) def log_event(scratch_root, payload, outcome, now=None): """Appends one line to the hook log, the only record of what Claude Code asked of the hooks.""" now = now or datetime.datetime.now() os.makedirs(os.path.join(scratch_root, "locks"), exist_ok=True) with open(os.path.join(scratch_root, "locks", "hooks.log"), "a", encoding="utf-8") as log: log.write("{} {} session={} outcome={}\n".format(now.strftime(scratch.TIME_FORMAT), payload.get("hook_event_name"), payload.get("session_id"), outcome)) def main(): payload = json.load(sys.stdin) event = payload.get("hook_event_name") try: if event == "WorktreeCreate": print(create_worktree(payload, scratch.SCRATCH_ROOT, os.environ)) elif event == "WorktreeRemove": remove_worktree(payload, scratch.SCRATCH_ROOT) elif event == "SessionStart": message = stale_lock_report(payload, scratch.SCRATCH_ROOT) if message: print(session_start_output(message)) else: print("scratch_hooks.py doesn't handle the event {!r}".format(event), file=sys.stderr) return 1 except (scratch.NoFreeWorkspace, scratch.WorkspaceHasChanges, RuntimeError, ValueError) as error: print(error, file=sys.stderr) if event != "SessionStart": log_event(scratch.SCRATCH_ROOT, payload, "refused: {}".format(error).splitlines()[0]) return 1 if event != "SessionStart": log_event(scratch.SCRATCH_ROOT, payload, "ok") return 0 if __name__ == "__main__": sys.exit(main())