{
  "schema": "https://ai-atoms.com/schemas/hook-v1.json",
  "type": "hook",
  "id": "hook/worktree-guard",
  "version": "1.3.0",
  "name": "Worktree Guard",
  "description": "Enforces worktree-based feature development. Rule 1: blocks ‘git checkout -b’ and ‘git switch -c’ in the primary worktree — feature work must live in a linked worktree so the primary repo stays on main. Rule 2: blocks ‘git worktree add’ to non-canonical paths. Canonical paths: <repo>/.worktrees/<name>/ for single-repo work, <repo>/.claude/worktrees/<name>/ for Claude Code's native EnterWorktree tool, ~/.ai/worktrees/<name>/ for cross-repo or persistent worktrees.",
  "event": "PreToolUse",
  "language": "python",
  "trigger": {
    "type": "tool-name",
    "pattern": "Bash"
  },
  "blocking": true,
  "side_effects": [
    "blocks new-branch creation (checkout -b / switch -c) in the primary worktree with worktree-add guidance",
    "blocks non-canonical worktree placement with path guidance"
  ],
  "authored_by": "convergent-systems-key",
  "tags": [
    "git",
    "worktree",
    "governance",
    "claude-code"
  ],
  "lifecycle": "stable",
  "platforms": [
    "linux",
    "macos",
    "windows"
  ],
  "platform_notes": "Logic is cross-platform. Wiring: use ‘ai hooks run worktree-guard’ in settings.json — the ai binary discovers Python on each OS. Inspects git command strings via Python. Path separators handled by pathlib. Works on all platforms.",
  "script": "#!/usr/bin/env python3\n\"\"\"hooks/worktree-guard.py — enforce worktree-based feature development.\n\nTwo rules, both implementing Common.md §U17:\n\n1. BRANCH-IN-PRIMARY GUARD\n   Block `git checkout -b <branch>` and `git switch -c <branch>` when\n   running in the primary worktree. Feature work must live in a linked\n   worktree so the primary repo stays on main. Redirect to\n   `git worktree add`.\n\n2. PLACEMENT GUARD\n   When `git worktree add <path>` IS used, enforce canonical placement:\n     <repo>/.worktrees/<name>/         (single-repo, dies with the repo)\n     <repo>/.claude/worktrees/<name>/  (Claude Code native EnterWorktree)\n     ~/.ai/worktrees/<name>/           (cross-repo, persistent)\n   Ad-hoc placement (../<branch>/, /tmp/worktree-X/, etc.) is forbidden.\n\nKnown limitations (PreToolUse static parser): a `git checkout -b` or\n`git worktree add` hidden inside a subshell (`(...)`), command\nsubstitution (`$(...)` / backticks), or a delegated interpreter\n(`bash -c \"...\"`) is not caught by static tokenization. The\nclient-agnostic wrapper mode (`ai wrap git`) is the backstop for those\n— it inspects the real git argv.\n\nTwo invocation modes:\n  PreToolUse  — reads a JSON event from stdin; on violation emits a\n                JSON permissionDecision deny on stdout and exits 0.\n                (Claude Code path via hooks.PreToolUse in settings.json)\n  wrapper     — invoked as `python <hook> --mode=wrapper` by `ai wrap git`;\n                reads git argv from $WRAPPED_ARGV; on violation writes to\n                stderr and exits non-zero to block the real git binary.\n\nSelf-check:\n  --self-check\n\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport os\nimport shlex\nimport subprocess\nimport sys\nfrom pathlib import Path\nfrom typing import List, Optional\n\nsys.path.insert(0, str(Path(__file__).resolve().parent))\nimport _lib  # noqa: E402\n\n# Flags on `git worktree add` that consume the next token as a value.\nVALUE_FLAGS = {\"-b\", \"-B\", \"--reason\"}\n\n# Shell operators that separate one command from the next.\nSHELL_SEPARATORS = {\";\", \"&&\", \"||\", \"|\", \"&\"}\n\n# `git` global options (before the subcommand) that consume the next token.\nGIT_GLOBAL_VALUE_OPTS = {\n    \"-C\", \"-c\", \"--git-dir\", \"--work-tree\",\n    \"--namespace\", \"--exec-path\", \"--super-prefix\",\n}\n\n\ndef deny_pretooluse(reason: str) -> None:\n    \"\"\"Emit a PreToolUse permission-deny decision (stdout) and exit 0.\"\"\"\n    print(json.dumps({\n        \"hookSpecificOutput\": {\n            \"hookEventName\": \"PreToolUse\",\n            \"permissionDecision\": \"deny\",\n            \"permissionDecisionReason\": reason,\n        }\n    }))\n    sys.exit(0)\n\n\ndef ai_worktrees_root() -> Path:\n    \"\"\"Return the cross-repo canonical worktree root.\"\"\"\n    ai_root = os.environ.get(\"AI_ROOT\", str(Path.home() / \".ai\"))\n    return Path(ai_root) / \"worktrees\"\n\n\ndef _clean_path_env() -> dict:\n    \"\"\"Return os.environ with AI shim bin dirs stripped from PATH.\n\n    resolve_repo_root and is_primary_worktree must reach the real git,\n    not the ~/.ai/bin/git governance shim, to avoid re-entering the\n    `ai wrap` pipeline which can deadlock or timeout.\n    \"\"\"\n    canonical_ai_bin = Path.home() / \".ai\" / \"bin\"\n    env_ai_root = os.environ.get(\"AI_ROOT\", \"\")\n    override_ai_bin = Path(env_ai_root) / \"bin\" if env_ai_root else None\n\n    strip = {str(canonical_ai_bin.resolve())}\n    if override_ai_bin is not None:\n        try:\n            strip.add(str(override_ai_bin.resolve()))\n        except Exception:\n            strip.add(str(override_ai_bin))\n\n    raw_path = os.environ.get(\"PATH\", \"\")\n    cleaned_parts = []\n    for p in raw_path.split(os.pathsep):\n        if not p:\n            continue\n        try:\n            resolved = str(Path(p).resolve())\n        except Exception:\n            resolved = p\n        if resolved not in strip and p not in strip:\n            cleaned_parts.append(p)\n    env = dict(os.environ)\n    if cleaned_parts:\n        env[\"PATH\"] = os.pathsep.join(cleaned_parts)\n    return env\n\n\ndef resolve_repo_root(cwd: str) -> Optional[Path]:\n    \"\"\"Return the repo root for the given working directory, or None.\"\"\"\n    try:\n        out = subprocess.run(\n            [\"git\", \"-C\", cwd, \"rev-parse\", \"--show-toplevel\"],\n            capture_output=True, text=True, timeout=5,\n            env=_clean_path_env(),\n        )\n        if out.returncode == 0 and out.stdout.strip():\n            return Path(out.stdout.strip()).resolve()\n    except Exception:\n        pass\n    return None\n\n\ndef is_primary_worktree(cwd: str) -> bool:\n    \"\"\"Return True if cwd is inside the primary (main) worktree.\n\n    In a linked worktree, --git-dir and --git-common-dir differ. In the\n    primary worktree they resolve to the same path.\n    \"\"\"\n    try:\n        git_dir = subprocess.run(\n            [\"git\", \"-C\", cwd, \"rev-parse\", \"--git-dir\"],\n            capture_output=True, text=True, timeout=5,\n            env=_clean_path_env(),\n        )\n        common_dir = subprocess.run(\n            [\"git\", \"-C\", cwd, \"rev-parse\", \"--git-common-dir\"],\n            capture_output=True, text=True, timeout=5,\n            env=_clean_path_env(),\n        )\n        if git_dir.returncode != 0 or common_dir.returncode != 0:\n            return False\n        gd = Path(git_dir.stdout.strip()).resolve()\n        cd = Path(common_dir.stdout.strip()).resolve()\n        return gd == cd\n    except Exception:\n        return False\n\n\ndef _normalize_tokens(tokens: List[str]) -> List[str]:\n    \"\"\"Expand tokens that have trailing semicolons glued on.\"\"\"\n    result: List[str] = []\n    for tok in tokens:\n        if tok.endswith(\";\") and tok != \";\":\n            result.append(tok[:-1])\n            result.append(\";\")\n        else:\n            result.append(tok)\n    return result\n\n\ndef _split_segments(tokens: List[str]) -> List[List[str]]:\n    \"\"\"Split a flat token list into command segments on shell operators.\"\"\"\n    segments: List[List[str]] = []\n    current: List[str] = []\n    for tok in _normalize_tokens(tokens):\n        if tok in SHELL_SEPARATORS:\n            if current:\n                segments.append(current)\n                current = []\n        else:\n            current.append(tok)\n    if current:\n        segments.append(current)\n    return segments\n\n\ndef _is_env_assignment(tok: str) -> bool:\n    \"\"\"True for a leading `VAR=value` shell environment assignment.\"\"\"\n    if tok.startswith(\"-\") or \"=\" not in tok:\n        return False\n    return tok.split(\"=\", 1)[0].isidentifier()\n\n\ndef _skip_git_preamble(seg: List[str], start: int) -> int:\n    \"\"\"Skip env assignments and git global options; return index of subcommand.\"\"\"\n    i, n = start, len(seg)\n    while i < n and _is_env_assignment(seg[i]):\n        i += 1\n    if i >= n or seg[i] != \"git\":\n        return n  # not a git command\n    i += 1\n    while i < n and seg[i].startswith(\"-\"):\n        opt = seg[i]\n        if \"=\" in opt:\n            i += 1\n        elif opt in GIT_GLOBAL_VALUE_OPTS and i + 1 < n:\n            i += 2\n        else:\n            i += 1\n    return i\n\n\n# ── Rule 1: new-branch-in-primary detection ──────────────────────────────────\n\ndef _is_new_branch_in_segment(seg: List[str]) -> bool:\n    \"\"\"True if this segment creates a new git branch.\"\"\"\n    i = _skip_git_preamble(seg, 0)\n    if i >= len(seg):\n        return False\n    subcmd = seg[i]\n    flags = set(seg[i + 1:])\n    if subcmd == \"checkout\" and (flags & {\"-b\", \"-B\"}):\n        return True\n    if subcmd == \"switch\" and (flags & {\"-c\", \"-C\"}):\n        return True\n    return False\n\n\ndef _has_new_branch_command(tokens: List[str]) -> bool:\n    \"\"\"True if any segment in tokens creates a new git branch.\"\"\"\n    return any(_is_new_branch_in_segment(seg) for seg in _split_segments(tokens))\n\n\ndef new_branch_deny_message(cwd: str) -> str:\n    \"\"\"Build the Rule 1 deny message with worktree-add guidance.\"\"\"\n    repo_root = resolve_repo_root(cwd)\n    ai_root = ai_worktrees_root()\n    hints = []\n    if repo_root:\n        hints.append(\n            f\"  git worktree add {repo_root}/.worktrees/<name> -b <branch>\"\n            \"  (single-repo)\"\n        )\n    hints.append(\n        f\"  git worktree add {ai_root}/<name> -b <branch>\"\n        \"  (cross-repo / persistent)\"\n    )\n    return (\n        \"Creating a branch in the primary worktree violates Common.md §U17.\\n\"\n        \"Feature work must live in a linked worktree so the primary repo \"\n        \"stays on main.\\n\"\n        \"\\nUse a worktree instead:\\n\"\n        + \"\\n\".join(hints) + \"\\n\"\n        \"\\nOr: `ai worktree add <name>` (with `--global` for cross-repo).\"\n    )\n\n\n# ── Rule 2: placement detection ───────────────────────────────────────────────\n\ndef _path_after_add(seg: List[str], i: int) -> Optional[str]:\n    \"\"\"Return the positional path argument following `worktree add`.\"\"\"\n    n = len(seg)\n    while i < n:\n        tok = seg[i]\n        if tok == \"--\":\n            i += 1\n            break\n        if not tok.startswith(\"-\"):\n            return tok\n        if tok in VALUE_FLAGS and i + 1 < n:\n            i += 2\n            continue\n        i += 1\n    if i < n:\n        return seg[i]\n    return None\n\n\ndef _worktree_add_path_in_segment(seg: List[str]) -> Optional[str]:\n    \"\"\"Return the target path if this segment is `git worktree add`.\"\"\"\n    i = _skip_git_preamble(seg, 0)\n    n = len(seg)\n    if i + 1 >= n or seg[i] != \"worktree\" or seg[i + 1] != \"add\":\n        return None\n    return _path_after_add(seg, i + 2)\n\n\ndef _worktree_add_paths(tokens: List[str]) -> List[str]:\n    \"\"\"Every `git worktree add` target path across the token segments.\"\"\"\n    paths: List[str] = []\n    for seg in _split_segments(tokens):\n        path = _worktree_add_path_in_segment(seg)\n        if path is not None:\n            paths.append(path)\n    return paths\n\n\ndef _worktree_add_paths_in_command(command: str) -> List[str]:\n    \"\"\"All `git worktree add` paths in a raw shell command string.\"\"\"\n    paths: List[str] = []\n    for line in command.splitlines():\n        line_tokens = shlex.split(line, comments=False, posix=True)\n        paths.extend(_worktree_add_paths(line_tokens))\n    return paths\n\n\ndef is_canonical(raw_path: str, cwd: str) -> bool:\n    \"\"\"Return True if raw_path resolves to a canonical worktree location.\"\"\"\n    target = Path(raw_path)\n    if not target.is_absolute():\n        target = Path(cwd) / target\n    try:\n        resolved = target.resolve()\n    except Exception:\n        resolved = Path(os.path.normpath(str(target)))\n\n    ai_root = ai_worktrees_root()\n    try:\n        resolved.relative_to(ai_root.resolve())\n        return True\n    except (ValueError, FileNotFoundError):\n        pass\n\n    repo_root = resolve_repo_root(cwd)\n    if repo_root is not None:\n        try:\n            resolved.relative_to(repo_root / \".worktrees\")\n            return True\n        except ValueError:\n            pass\n        try:\n            # Claude Code's native EnterWorktree tool places worktrees here;\n            # it can't be redirected to `ai worktree add`, so this harness-\n            # managed root is canonical too.\n            resolved.relative_to(repo_root / \".claude\" / \"worktrees\")\n            return True\n        except ValueError:\n            pass\n\n    return False\n\n\ndef placement_deny_message(raw_path: str, cwd: str) -> str:\n    \"\"\"Build the Rule 2 deny message.\"\"\"\n    p = Path(raw_path)\n    if not p.is_absolute():\n        p = Path(cwd) / p\n    try:\n        shown = p.resolve()\n    except Exception:\n        shown = p\n\n    repo_root = resolve_repo_root(cwd)\n    ai_root = ai_worktrees_root()\n    hints = []\n    if repo_root:\n        hints.append(f\"  {repo_root}/.worktrees/<name>/  — single-repo\")\n        hints.append(f\"  {repo_root}/.claude/worktrees/<name>/  — Claude Code native (EnterWorktree)\")\n    hints.append(f\"  {ai_root}/<name>/  — cross-repo or persistent\")\n    return (\n        \"Worktree placement violates Common.md §U17.\\n\"\n        f\"  Target: {shown}\\n\"\n        \"Canonical roots:\\n\"\n        + \"\\n\".join(hints) + \"\\n\"\n        \"Choose by lifecycle (§U17.1) and re-run.\\n\"\n        \"Preferred surface: `ai worktree add <name>` \"\n        \"(with `--global` for cross-repo).\"\n    )\n\n\n# ── Mode runners ──────────────────────────────────────────────────────────────\n\ndef run_pretooluse_mode() -> int:\n    \"\"\"Claude Code PreToolUse path: parse a JSON event from stdin.\"\"\"\n    raw = sys.stdin.read()\n    if not raw.strip():\n        return 0\n    try:\n        event = json.loads(raw)\n    except json.JSONDecodeError:\n        return 0\n\n    hook_event = event.get(\"hookEventName\") or event.get(\"hook_event_name\") or \"\"\n    if hook_event != \"PreToolUse\":\n        return 0\n    tool_name = event.get(\"tool_name\") or event.get(\"toolName\") or \"\"\n    if tool_name not in (\"Bash\", \"shell\", \"execute\"):\n        return 0\n    tool_input = event.get(\"tool_input\") or event.get(\"toolInput\") or {}\n    if not isinstance(tool_input, dict):\n        return 0\n    command = tool_input.get(\"command\") or \"\"\n    if not isinstance(command, str):\n        return 0\n\n    has_branch_op = \"checkout\" in command or \"switch\" in command\n    has_worktree = \"worktree\" in command\n    if not has_branch_op and not has_worktree:\n        return 0\n\n    cwd = event.get(\"cwd\") or os.getcwd()\n\n    # Rule 1: block new-branch creation in the primary worktree.\n    if has_branch_op and is_primary_worktree(cwd):\n        for line in command.splitlines():\n            try:\n                line_tokens = shlex.split(line, comments=False, posix=True)\n            except ValueError:\n                continue\n            if _has_new_branch_command(line_tokens):\n                deny_pretooluse(new_branch_deny_message(cwd))\n\n    # Rule 2: enforce canonical placement when `git worktree add` is used.\n    if has_worktree:\n        try:\n            targets = _worktree_add_paths_in_command(command)\n        except ValueError:\n            deny_pretooluse(\n                \"worktree-guard: could not parse a command containing \"\n                \"'worktree'; denying per fail-closed policy (Common.md §U17).\"\n            )\n            return 0\n        for target in targets:\n            if not is_canonical(target, cwd):\n                deny_pretooluse(placement_deny_message(target, cwd))\n\n    return 0\n\n\ndef run_wrapper_mode() -> int:\n    \"\"\"Client-agnostic `ai wrap git` path: git argv arrives in $WRAPPED_ARGV.\"\"\"\n    argv_json = os.environ.get(\"WRAPPED_ARGV\", \"\")\n    if not argv_json:\n        return 0\n    try:\n        argv = json.loads(argv_json)\n    except json.JSONDecodeError:\n        return 0\n    if not isinstance(argv, list):\n        return 0\n\n    tokens = [\"git\"] + [str(a) for a in argv]\n    cwd = os.getcwd()\n\n    # Rule 1: block new-branch creation in the primary worktree.\n    if (\"checkout\" in tokens or \"switch\" in tokens):\n        if _has_new_branch_command(tokens) and is_primary_worktree(cwd):\n            sys.stderr.write(new_branch_deny_message(cwd) + \"\\n\")\n            return 1\n\n    # Rule 2: enforce canonical placement.\n    if \"worktree\" in tokens:\n        for target in _worktree_add_paths(tokens):\n            if not is_canonical(target, cwd):\n                sys.stderr.write(placement_deny_message(target, cwd) + \"\\n\")\n                return 1\n\n    return 0\n\n\ndef main() -> int:\n    if \"--self-check\" in sys.argv:\n        return _lib.self_check_ok()\n    if \"--mode=wrapper\" in sys.argv or os.environ.get(\"WRAPPED_CMD\"):\n        return run_wrapper_mode()\n    return run_pretooluse_mode()\n\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n",
  "depends_on": [
    "hook/lib"
  ],
  "category": "governance"
}