Git Workflow Hooks

core no always-loaded rules -- loads on demand updated 2026-08-03

Python hooks that enforce git workflow rules: issue-first workflow, commit message format, branch protection, and auto-approval for file operations.

Tags

  • hooks
  • git
  • workflow
  • auto-approve

README

hooks

Python hooks that enforce git workflow rules: issue-first workflow, commit message format, branch protection, and auto-approval for file operations.

What It Does

This module installs fifteen Python hooks, several Python libraries, and a settings partial:

Hook Event Purpose
enforce-git-workflow.py PreToolUse (Bash) Blocks commits on protected branches and enforces #issue: description commit message format
enforce-issue-workflow.py UserPromptSubmit Injects a workflow reminder when Claude detects a work request (create issue first, create branch, then implement)
auto-approve-bash.py PreToolUse (Bash) Reads allow/deny patterns from settings.json and auto-approves matching Bash commands
auto-approve-file-ops.py PreToolUse (Read/Edit/Write) Reads path patterns from settings.json and auto-approves file operations on allowed paths
ccgm-update-check.py UserPromptSubmit Daily check for CCGM upstream updates
port-check.py PreToolUse (Bash) Warns about dev server port conflicts in multi-clone setups
agent-tracking-pre.py PreToolUse (Bash) Warns when claiming an issue already claimed by another agent
agent-tracking-post.py PostToolUse (Bash) Records issue claims and status transitions in tracking CSV
check-migration-timestamps.py PreToolUse Validates Supabase migration file timestamps for duplicates before commit
orphan-process-check.py Not a hook Detects and warns about orphaned background processes (stale dev servers, zombie workers). Registered under no event; run as a plain script by /startup via startup-dashboard's startup-gather.sh
check-careful.py PreToolUse (Bash) Prompts before destructive Bash commands (rm -rf, SQL DROP/TRUNCATE, force push, hard reset, kubectl delete, docker prune). Build-artifact directories (node_modules, dist, .next, build, pycache, .cache, .turbo, coverage) are whitelisted for rm -rf
check-freeze.py PreToolUse (Edit/Write) Denies Edit/Write outside the frozen directory when ~/.claude/freeze-dir.txt is set. Pair with /freeze, /unfreeze, /guard from commands-extra
session-start-enforce.py SessionStart (startup) Experimental. Injects an Iron-Law rule-enforcement meta-instruction at fresh session start so discipline rules activate under pressure. OFF by default; opt in via CCGM_RULE_ENFORCEMENT=true in ~/.claude/.ccgm.env
sync-ccgm-canonical.py PostToolUse (Bash) After gh pr merge succeeds in the CCGM repo, fast-forwards the canonical CCGM clone (the symlink source for ~/.claude/) so it never drifts. Default canonical dir: ~/code/ccgm; override with CCGM_CANONICAL_DIR env var. No-op if the dir doesn't exist or the merge wasn't in a CCGM clone

The settings.partial.json wires these hooks into your ~/.claude/settings.json.

PreToolUse:Bash is composed. The six PreToolUse(Bash) checks above (enforce-git-workflow, auto-approve-bash, port-check, agent-tracking-pre, check-migration-timestamps, check-careful) run through a single in-process dispatcher (hooks/pretooluse-bash-dispatch.py) rather than six separate processes. Their behavior is unchanged — the dispatcher calls the same pure functions and the decision is equivalence-proven against the legacy chain. See Hook Composition Dispatcher below.

Libraries: lib/hook_utils.py (shared hook I/O, redaction, locking, bypass-mode detection — imported by every other hook module's hooks), lib/agent_tracking.py (tracking CSV operations), lib/agent_sessions.py (live session detection), lib/sched_platform.py (launchd/cron platform abstraction for scheduled-job installation, used by autoheal and dreaming's install scripts)

Dependencies

This module depends on the settings module. The auto-approve hooks read permission patterns from settings.json, so the settings module must be installed first.

Template Variables

enforce-git-workflow.py contains one template variable:

Variable Description Example
__USERNAME__ Your GitHub username myuser

During installation, __USERNAME__/ccgm in the DIRECT_TO_MAIN_REPOS list will be replaced with your actual GitHub username. This allows the ccgm config repo itself to use direct-to-main commits.

Manual Installation

# 1. Copy hooks
mkdir -p ~/.claude/hooks
cp hooks/enforce-git-workflow.py ~/.claude/hooks/enforce-git-workflow.py
cp hooks/enforce-issue-workflow.py ~/.claude/hooks/enforce-issue-workflow.py
cp hooks/auto-approve-bash.py ~/.claude/hooks/auto-approve-bash.py
cp hooks/auto-approve-file-ops.py ~/.claude/hooks/auto-approve-file-ops.py
cp hooks/ccgm-update-check.py ~/.claude/hooks/ccgm-update-check.py
cp hooks/port-check.py ~/.claude/hooks/port-check.py
cp hooks/agent-tracking-pre.py ~/.claude/hooks/agent-tracking-pre.py
cp hooks/agent-tracking-post.py ~/.claude/hooks/agent-tracking-post.py
cp hooks/check-migration-timestamps.py ~/.claude/hooks/check-migration-timestamps.py
cp hooks/orphan-process-check.py ~/.claude/hooks/orphan-process-check.py
cp hooks/check-careful.py ~/.claude/hooks/check-careful.py
cp hooks/check-freeze.py ~/.claude/hooks/check-freeze.py
cp hooks/session-start-enforce.py ~/.claude/hooks/session-start-enforce.py
cp hooks/sync-ccgm-canonical.py ~/.claude/hooks/sync-ccgm-canonical.py
cp hooks/pretooluse-bash-dispatch.py ~/.claude/hooks/pretooluse-bash-dispatch.py

# 2. Copy libraries
mkdir -p ~/.claude/lib
cp lib/hook_utils.py ~/.claude/lib/hook_utils.py
cp lib/agent_tracking.py ~/.claude/lib/agent_tracking.py
cp lib/agent_sessions.py ~/.claude/lib/agent_sessions.py
cp lib/sched_platform.py ~/.claude/lib/sched_platform.py
cp lib/hook_dispatcher.py ~/.claude/lib/hook_dispatcher.py
cp lib/pretooluse_bash_checks.py ~/.claude/lib/pretooluse_bash_checks.py

# 3. Make hooks executable
chmod +x ~/.claude/hooks/*.py

# 4. Replace template variable in enforce-git-workflow.py
# Edit the DIRECT_TO_MAIN_REPOS list to use your GitHub username

# 5. Merge settings.partial.json into ~/.claude/settings.json
# Add the "hooks" section from settings.partial.json

Configuration

You can add additional protected branches by creating ~/.claude/git-flow-protected-branches.json:

["staging", "develop", "release"]

The default protected branches are: main, master, production, prod, staging, stag, develop, dev, release, trunk.

Experimental: rule-enforcement meta-instruction

session-start-enforce.py is OFF by default. To pilot it, add this to ~/.claude/.ccgm.env:

CCGM_RULE_ENFORCEMENT=true

On fresh session start, the hook injects a short reminder that routes tasks through loaded Iron-Law rules (TDD, systematic-debugging, verification, subagent-patterns, confusion-protocol). Remove or set to false to disable.

Hook Composition Dispatcher (default)

The PreToolUse:Bash event is handled by a single-process composition dispatcher (hooks/pretooluse-bash-dispatch.py). It replaces what used to be six separate Python processes (enforce-git-workflowauto-approve-bashport-checkagent-tracking-precheck-migration-timestampscheck-careful), each re-importing hook_utils and re-parsing stdin, where precedence was an emergent property of array order across several modules that did not know about each other.

The dispatcher runs the same checks via a declarative manifest (priority + tool-matcher + handler) with an explicit precedence contract:

hard_block (exit 2)  >  deny  >  allow  >  ask  >  advisory / pass
  • The first hard_block wins and is emitted via hook_utils.hard_block() (exit 2) — the only signal that survives bypass mode (GitHub #39344).
  • deny beats any allow; allow beats ask.
  • The curated destructive set and the git-reset smart-rule are short_circuit checks: they emit the instant they fire, so nothing can soften them.
  • Bypass-suppressible checks (pattern matching, the destructive-prompt ask, advisory warnings) declare runs_in_bypass=False and are skipped in bypass mode — exactly as the standalone hooks exit early when is_bypass_mode() is true.

The handlers (lib/pretooluse_bash_checks.py) call the same pure functions the legacy hooks use, so the dispatched path and the legacy path share one source of truth — the six standalone hook scripts are still installed and remain individually runnable. modules/hooks/tests/test-dispatcher.sh proves the dispatcher produces an identical final decision to the six-process chain across an adversarial command battery × all four permission modes (equivalence_harness.py): 224/224 (command × mode) pairs match, with a non-trivial outcome distribution spanning hard_block, deny, allow, ask, and pass.

This is the default. settings.partial.json wires the single dispatcher entry for PreToolUse:Bash:

{
  "matcher": "Bash",
  "hooks": [
    { "type": "command",
      "command": "python3 $HOME/.claude/hooks/pretooluse-bash-dispatch.py",
      "timeout": 5000 }
  ]
}

To revert to the legacy per-process chain, replace that single entry with the six standalone hook entries (enforce-git-workflow, auto-approve-bash, port-check, agent-tracking-pre, check-migration-timestamps, check-careful) in settings.json. Both mechanisms remain supported.

Files

File Description
hooks/enforce-git-workflow.py Branch protection and commit message format enforcement (template)
hooks/enforce-issue-workflow.py Issue-first workflow reminder injection
hooks/auto-approve-bash.py Bash command auto-approval based on settings.json patterns
hooks/auto-approve-file-ops.py File operation auto-approval based on settings.json path patterns
hooks/ccgm-update-check.py Daily CCGM update check
hooks/port-check.py Dev server port conflict detection
hooks/agent-tracking-pre.py Pre-execution issue claim warning
hooks/agent-tracking-post.py Post-execution tracking CSV updates
hooks/check-migration-timestamps.py Supabase migration timestamp validation
hooks/orphan-process-check.py Orphaned background process detection before conflicting Bash commands
hooks/check-careful.py Destructive-command warning (careful safety hook)
hooks/check-freeze.py Scope-lock Edit/Write to ~/.claude/freeze-dir.txt (freeze safety hook)
hooks/session-start-enforce.py Experimental Iron-Law rule-enforcement meta-instruction at session start (opt in via CCGM_RULE_ENFORCEMENT=true)
hooks/sync-ccgm-canonical.py Auto-pull ~/code/ccgm after CCGM PR merges so symlinked runtime never drifts (override path via CCGM_CANONICAL_DIR)
hooks/pretooluse-bash-dispatch.py Default single-process composition dispatcher for the PreToolUse:Bash chain (declarative precedence; equivalence-proven against the six-process chain)
lib/hook_dispatcher.py Composition engine: declarative Manifest/Check/Result model + dispatch() precedence resolution (hard_block > deny > allow > ask)
lib/pretooluse_bash_checks.py Dispatcher handlers wrapping the legacy PreToolUse:Bash hooks' pure functions into the Result contract
lib/hook_utils.py Shared hook I/O, redaction, locking, and bypass-mode detection — imported by every other hook module's hooks
lib/agent_tracking.py Python library for tracking CSV operations
lib/agent_sessions.py Python library for live session detection
lib/sched_platform.py Platform abstraction for scheduled-job (launchd/cron) installation, used by autoheal and dreaming's install scripts
settings.partial.json Hook wiring configuration to merge into settings.json

Will install

Path Action Target Type
hooks/enforce-git-workflow.py hooks/enforce-git-workflow.py hook
hooks/enforce-issue-workflow.py hooks/enforce-issue-workflow.py hook
hooks/auto-approve-bash.py hooks/auto-approve-bash.py hook
hooks/auto-approve-file-ops.py hooks/auto-approve-file-ops.py hook
hooks/ccgm-update-check.py hooks/ccgm-update-check.py hook
hooks/port-check.py hooks/port-check.py hook
hooks/agent-tracking-pre.py hooks/agent-tracking-pre.py hook
hooks/agent-tracking-post.py hooks/agent-tracking-post.py hook
lib/agent_tracking.py lib/agent_tracking.py lib
lib/agent_sessions.py lib/agent_sessions.py lib
lib/hook_utils.py lib/hook_utils.py lib
lib/hook_dispatcher.py lib/hook_dispatcher.py lib
lib/pretooluse_bash_checks.py lib/pretooluse_bash_checks.py lib
hooks/pretooluse-bash-dispatch.py hooks/pretooluse-bash-dispatch.py hook
lib/sched_platform.py lib/sched_platform.py lib
hooks/check-migration-timestamps.py hooks/check-migration-timestamps.py hook
hooks/orphan-process-check.py hooks/orphan-process-check.py hook
hooks/check-careful.py hooks/check-careful.py hook
hooks/check-freeze.py hooks/check-freeze.py hook
hooks/session-start-enforce.py hooks/session-start-enforce.py hook
hooks/sync-ccgm-canonical.py hooks/sync-ccgm-canonical.py hook
settings.partial.json merge settings.json config

Dependencies

Required by

Asks during install

  • Any additional protected branches? (comma-separated, leave empty if none)

    Default:

  • Check for CCGM updates daily? (notifies you when new updates are available)

    Default: yes

    Options: yesno

Included in presets

Install this module

Agent prompt

Recommended for agent users -- hands the whole install off to your assistant.

Fetch https://cd23a9be.ccgm-site.pages.dev/modules/hooks.md and install this module into my Claude Code setup.

Native plugin marketplace

One command via the native plugin marketplace -- additive, does not merge settings.json.

claude plugin install hooks@ccgm

The marketplace path is additive, not a replacement: it installs commands, agents, and skills as native plugin components, but it does not perform the bash installer's deep settings.json merge, and it does not write the always-loaded global CLAUDE.md context. Rules are only injected via an opt-in SessionStart hook rather than being auto-loaded. Use the bash installer when those pieces matter to you.

Manual, per file

Full control -- copy exactly the files you want from the sections below.

Files

Files

hook (15)

hooks/enforce-git-workflow.py

#!/usr/bin/env python3
"""
PreToolUse:Bash hook to enforce GitHub Issues workflow.

Classification (plan.md §5 Epic 1): bypass-retained. Protected-branch
protections are NOT permission noise — they are the workflow contract.
`deny()` migrated to `hook_utils.hard_block()` (exit 2) so the rules
survive bypass mode. `ALLOW_MAIN_COMMIT=1` is the explicit escape hatch.

BLOCKS (via hard_block, bypass-proof):
1. Commits on protected branches (must use feature branch)
2. Commit messages without issue number prefix (^#\\d+:)
3. Direct pushes to protected branches (must use PR workflow)

ALLOWS:
- sync: prefix for log/coordination commits (no issue number needed)
- Merge commits and --amend without -m skip validation
- ALLOW_MAIN_COMMIT=1 env var for emergencies
- Not in a git repo → allow all
"""

from __future__ import annotations

import json
import os
import re
import subprocess
import sys

sys.path.insert(0, os.path.expanduser("~/.claude/lib"))
import hook_utils  # noqa: E402

# ─── Protected branches ──────────────────────────────────────────────
# Any branch matching one of these names (case-insensitive) is blocked
# from direct commits and pushes. Use feature branches + PRs instead.
#
# To add custom branches, append to this list or use the setup script
# which writes them to a config file.
PROTECTED_BRANCHES = [
    "dev",
    "develop",
    "main",
    "master",
    "prod",
    "production",
    "release",
    "stag",
    "staging",
    "trunk",
]

# Load user-defined protected branches from config file (written by setup.sh)
_CUSTOM_BRANCHES_FILE = os.path.expanduser(
    "~/.claude/git-flow-protected-branches.json"
)
try:
    with open(_CUSTOM_BRANCHES_FILE) as f:
        _custom = json.load(f)
        if isinstance(_custom, list):
            PROTECTED_BRANCHES.extend(_custom)
except (FileNotFoundError, json.JSONDecodeError, TypeError):
    pass

# Normalize to lowercase set for O(1) lookup
PROTECTED_BRANCHES_SET = {b.lower() for b in PROTECTED_BRANCHES}

# ─── Direct-to-main repos ────────────────────────────────────────────
# Repos that use direct-to-main workflow (no feature branches, no issue numbers).
# Matched as substrings against the git remote URL. Populate via
# ~/.claude/git-flow-direct-to-main-repos.json: e.g. ["myorg/dotfiles"].
DIRECT_TO_MAIN_REPOS: list[str] = []

_DIRECT_TO_MAIN_FILE = os.path.expanduser(
    "~/.claude/git-flow-direct-to-main-repos.json"
)
try:
    with open(_DIRECT_TO_MAIN_FILE) as f:
        _d2m = json.load(f)
        if isinstance(_d2m, list):
            DIRECT_TO_MAIN_REPOS.extend(str(x) for x in _d2m)
except (FileNotFoundError, json.JSONDecodeError, TypeError):
    pass


def is_direct_to_main_repo() -> bool:
    """Check if the current repo is allowlisted for direct-to-main workflow."""
    try:
        result = subprocess.run(
            ["git", "remote", "get-url", "origin"],
            capture_output=True, text=True, timeout=2,
        )
        if result.returncode == 0:
            remote = result.stdout.strip()
            return any(repo in remote for repo in DIRECT_TO_MAIN_REPOS)
    except Exception:
        pass
    return False


def is_protected_branch(branch: str) -> bool:
    """Check if a branch name is in the protected list (case-insensitive)."""
    return branch.lower() in PROTECTED_BRANCHES_SET


def get_current_branch() -> str | None:
    """Get current git branch. Returns None if not in a git repo."""
    try:
        result = subprocess.run(
            ["git", "branch", "--show-current"],
            capture_output=True,
            text=True,
            timeout=2,
        )
        if result.returncode == 0:
            return result.stdout.strip()
        return None
    except Exception:
        return None


def extract_commit_message(command: str) -> str | None:
    """Extract commit message from git commit -m '...' or --message '...'."""
    # Heredoc patterns (e.g. -m "$(cat <<'EOF' ... EOF)") can't be reliably
    # parsed from the raw command string — skip validation and let git handle it.
    # This check MUST come before the regex patterns, which would otherwise
    # match the outer quotes and capture the raw heredoc syntax as the "message".
    if "-m" in command and "<<" in command:
        return None

    # Try quoted patterns first (single or double quotes)
    patterns = [
        r"""-m\s+"([^"]+)" """,
        r"""-m\s+'([^']+)' """,
        r'''--message\s+"([^"]+)"''',
        r"""--message\s+'([^']+)'""",
        # Unquoted (grab until next flag or end)
        r"""-m\s+(\S+)""",
    ]
    # Append a space to command to make patterns match at end of string
    cmd = command + " "
    for pattern in patterns:
        match = re.search(pattern, cmd)
        if match:
            return match.group(1)

    return None


def is_commit_command(command: str) -> bool:
    """Check if this is a git commit command."""
    # Match: git commit, git commit -m, git commit --amend, etc.
    # But NOT: git commit-graph, git commit-tree
    return bool(re.match(r"git\s+commit(\s|$)", command))


def is_push_command(command: str) -> bool:
    """Check if this is a git push command."""
    return bool(re.match(r"(SKIP_CHECKS=\S+\s+)?git\s+push(\s|$)", command))


def is_merge_commit(command: str) -> bool:
    """Check if this is a merge commit (auto-generated message)."""
    return "Merge branch" in command or "Merge pull request" in command


def is_amend_without_message(command: str) -> bool:
    """Check if this is --amend that preserves existing message."""
    return "--amend" in command and "-m" not in command and "--message" not in command


def is_sync_commit(message: str | None) -> bool:
    """Check if this is a sync/coordination commit (no issue number needed)."""
    if not message:
        return False
    return bool(re.match(r"^sync:", message, re.IGNORECASE))


def is_auto_commit(message: str | None) -> bool:
    """Check if this is an autoheal apply-path commit (`#auto:` prefix).

    autoheal's `lib/apply-proposal.py` (and the future /autoheal-apply
    command) write commits with `#auto: apply autoheal proposal {id}`
    when a proposal is mechanically applied to a feature branch. These
    are not issue commits and never run on main; the format is
    distinguishable enough to allow without weakening the issue-number
    rule for human commits.
    """
    if not message:
        return False
    return bool(re.match(r"^#auto:", message))


def validate_commit_message_format(message: str | None) -> bool:
    """Check if commit message starts with issue number: '#42: description'."""
    if not message:
        return True  # No message to validate (e.g., heredoc), skip
    return bool(re.match(r"^#\d+:", message))


def deny(reason: str) -> None:
    """Bypass-proof hard block. Exits 2 with reason on stderr.

    `permissionDecision: deny` from a PreToolUse hook is silently
    overridden by any later `ask` decision (GitHub issue #39344). The
    only signal Claude Code honors regardless of `permission_mode` is
    `exit 2`. `hook_utils.hard_block()` wraps that primitive.
    """
    hook_utils.hard_block(reason)


def check_commit(command: str, branch: str) -> None:
    """Validate git commit commands against workflow rules."""
    # Direct-to-main repos skip all checks
    if is_direct_to_main_repo():
        return

    # Emergency bypass
    if os.environ.get("ALLOW_MAIN_COMMIT") == "1":
        print(
            "WARNING: ALLOW_MAIN_COMMIT bypass active - skipping all branch protections",
            file=sys.stderr,
        )
        return

    # Rule 1: No commits on protected branches
    if is_protected_branch(branch):
        deny(
            f"Cannot commit on protected branch '{branch}'. "
            "Create a feature branch first:\n"
            "  1. gh issue create  (if no issue exists)\n"
            "  2. git checkout -b {issue#}-{description} origin/main\n"
            "  3. Then commit with '#<issue>: description'\n\n"
            "Emergency bypass: ALLOW_MAIN_COMMIT=1 git commit ..."
        )

    # Skip message validation for merge commits and amends without new message
    if is_merge_commit(command) or is_amend_without_message(command):
        return

    # Rule 2: Commit message must have issue number prefix (or sync: / #auto: prefix)
    message = extract_commit_message(command)
    if (
        message
        and not is_sync_commit(message)
        and not is_auto_commit(message)
        and not validate_commit_message_format(message)
    ):
        deny(
            f'Commit message must start with issue number.\n'
            f'  Required format: "#{{issue_number}}: {{description}}"\n'
            f'  Example: "#42: Fix the login bug"\n'
            f'  Also allowed: "sync: update session log"\n'
            f'  Also allowed (autoheal apply path): "#auto: apply autoheal proposal {{id}}"\n'
            f'  Your message: "{message}"'
        )


def check_push(command: str, branch: str) -> None:
    """Validate git push commands against workflow rules."""
    # Direct-to-main repos skip all checks
    if is_direct_to_main_repo():
        return

    # Emergency bypass
    if os.environ.get("ALLOW_MAIN_COMMIT") == "1":
        print(
            "WARNING: ALLOW_MAIN_COMMIT bypass active - skipping all branch protections",
            file=sys.stderr,
        )
        return

    # Only block pushes when on a protected branch
    if not is_protected_branch(branch):
        return

    # Parse what's being pushed to determine if it's actually pushing
    # the protected branch or an explicit different branch.
    # Allow: git push origin feature-branch (explicitly pushing a different branch)
    parts = command.split()

    # Find the refspec (what's being pushed)
    # git push origin branch-name -> pushing branch-name
    # git push -u origin HEAD -> pushing current branch
    # git push -> pushing current branch
    push_target = None
    skip_next = False
    found_remote = False

    for i, part in enumerate(parts):
        if skip_next:
            skip_next = False
            continue
        if part in ("-u", "--set-upstream", "--force", "-f", "--no-verify",
                     "--force-with-lease", "--delete", "-d"):
            if part in ("-u", "--set-upstream"):
                skip_next = False  # -u takes no argument by itself
            continue
        if part.startswith("-"):
            continue
        if part in ("git", "push"):
            continue
        # Skip env var prefix like SKIP_CHECKS=1
        if "=" in part:
            continue
        if not found_remote:
            found_remote = True  # First non-flag arg is the remote
            continue
        # Second non-flag arg is the refspec
        push_target = part
        break

    # HEAD means current branch, explicit branch name could differ
    if push_target == "HEAD":
        push_target = branch  # Resolves to current (protected) branch
    elif push_target is None:
        # No explicit target -> defaults to current branch
        push_target = branch
    elif not is_protected_branch(push_target):
        return  # Pushing a specific non-protected branch, allow

    deny(
        f"Cannot push to protected branch '{branch}'. "
        "Use the feature branch + PR workflow:\n"
        "  1. git checkout -b {issue-number}-{description}\n"
        "  2. Make changes and commit\n"
        "  3. git push -u origin HEAD\n"
        "  4. gh pr create\n"
        "  5. gh pr merge --squash --delete-branch\n\n"
        "Emergency bypass: ALLOW_MAIN_COMMIT=1 git push ..."
    )


def main() -> None:
    input_data = hook_utils.read_hook_input()

    tool_name = input_data.get("tool_name", "")
    tool_input = input_data.get("tool_input", {})

    if tool_name != "Bash":
        sys.exit(0)

    command = tool_input.get("command", "").strip()
    if not command:
        sys.exit(0)

    # Only check git commit and git push commands
    if not (is_commit_command(command) or is_push_command(command)):
        sys.exit(0)

    # Get current branch (None if not in git repo)
    branch = get_current_branch()
    if not branch:
        sys.exit(0)  # Not in git repo, allow

    if is_commit_command(command):
        check_commit(command, branch)
    elif is_push_command(command):
        check_push(command, branch)

    # No deny → exit silently, let other hooks handle allow/deny
    sys.exit(0)


if __name__ == "__main__":
    main()

hooks/enforce-issue-workflow.py

#!/usr/bin/env python3
"""
UserPromptSubmit hook to enforce issue-first workflow.

This hook detects work requests and injects a reminder into Claude's context
to ensure the issue-first workflow is followed before making changes.

Scoped to ~/code/ — the reminder only fires when cwd is under that directory.
Outside that scope (e.g., a note-taking vault or other non-code working
directory), the hook stays silent. Coordination injection is additionally
conditional on .claude/logs/ existing in the current working directory.
"""

from __future__ import annotations

import json
import os
import re
import sys


def is_work_request(prompt: str) -> bool:
    """Detect if the prompt is a work request vs a question or research task."""
    prompt_lower = prompt.lower()

    # Work action verbs that indicate implementation tasks
    work_patterns = [
        r"\b(update|fix|add|create|implement|build|change|modify|refactor)\b",
        r"\b(write|make|set up|setup|configure|migrate|convert|move)\b",
        r"\b(delete|remove|rename|replace|upgrade|downgrade)\b",
        r"\b(enable|disable|install|uninstall)\b",
    ]

    # Patterns that indicate it's NOT a work request (questions, research)
    question_patterns = [
        r"^(what|why|how|where|when|which|who|can you explain|tell me)\b",
        r"\?$",  # Ends with question mark
        r"\b(explain|describe|show me|list|find|search|look for|check)\b",
    ]

    # Check if it looks like a question first
    for pattern in question_patterns:
        if re.search(pattern, prompt_lower):
            return False

    # Check if it matches work patterns
    for pattern in work_patterns:
        if re.search(pattern, prompt_lower):
            return True

    return False


def has_logs_directory() -> bool:
    """Check if .claude/logs/ exists in the current working directory."""
    return os.path.isdir(os.path.join(os.getcwd(), ".claude", "logs"))


def is_in_code_dir() -> bool:
    """Scope check: only fire the reminder when cwd is under ~/code/."""
    code_root = os.path.realpath(os.path.expanduser("~/code"))
    cwd = os.path.realpath(os.getcwd())
    return cwd == code_root or cwd.startswith(code_root + os.sep)


def build_reminder() -> str:
    """Build the workflow reminder, with optional coordination section."""
    if has_logs_directory():
        return """
<workflow-reminder>
STOP - Before making ANY code or file changes, you MUST:

1. CHECK: Does a GitHub issue exist for this work?
   - If NO: Create one first with `gh issue create`
   - If YES: Note the issue number

2. CREATE BRANCH: `git checkout -b {issue#}-{description} origin/main`

3. CHECK COORDINATION: Read `.claude/logs/` for other active sessions
   - Look for today's date directory: `.claude/logs/YYYYMMDD/`
   - Read other agents' logs to check for file conflicts
   - If overlap detected, note it but proceed (advisory, not blocking)

4. LOG YOUR SESSION: Create/update `.claude/logs/YYYYMMDD/agent-N.md`

5. IMPLEMENT: Make your changes

6. COMMIT & PR:
   - `git commit -m "#{issue_number}: {description}"`
   - `gh pr create --title "#{issue_number}: ..." --body "Closes #{issue_number}"`

This applies to ALL changes including documentation, config, and "trivial" fixes.
Do NOT skip this workflow. The user has explicitly requested strict adherence.
</workflow-reminder>
"""
    else:
        return """
<workflow-reminder>
STOP - Before making ANY code or file changes, you MUST:

1. CHECK: Does a GitHub issue exist for this work?
   - If NO: Create one first with `gh issue create`
   - If YES: Note the issue number

2. CREATE BRANCH: `git checkout -b {issue#}-{description} origin/main`

3. IMPLEMENT: Make your changes

4. COMMIT & PR:
   - `git commit -m "#{issue_number}: {description}"`
   - `gh pr create --title "#{issue_number}: ..." --body "Closes #{issue_number}"`

This applies to ALL changes including documentation, config, and "trivial" fixes.
Do NOT skip this workflow. The user has explicitly requested strict adherence.
</workflow-reminder>
"""


def main() -> None:
    try:
        input_data = json.load(sys.stdin)
    except json.JSONDecodeError:
        sys.exit(0)  # Silent failure, don't block

    prompt = input_data.get("prompt", "")

    if is_work_request(prompt) and is_in_code_dir():
        print(build_reminder())

    sys.exit(0)


if __name__ == "__main__":
    main()

hooks/auto-approve-bash.py

#!/usr/bin/env python3
"""
PreToolUse hook that enforces Bash permissions from settings.json.

Classification (plan.md §5 Epic 1): bypass-suppressible for allow/deny pattern
matching; the curated destructive set and smart-rules run OUTSIDE the bypass
short-circuit so they survive bypass mode via `hook_utils.hard_block()`.

Execution order in main():
  1. curated destructive set (always run; hard_block, bypass-proof)
  2. force branch delete (always run; hard_block, bypass-proof)
  3. smart-rules (always run; destructive-reset → hard_block, bypass-proof)
  4. bypass-mode short-circuit (exit 0)
  5. settings.json allow/deny pattern matching (per-segment)

Deny/allow matching is PER SEGMENT: the command is decomposed on &&, ||, ;,
|, newlines, and command substitution before matching, so a chained command
like "echo hi && rm -rf /" cannot smuggle a denied/dangerous segment past
the list (GitHub issue #660).

This hook exists because Claude Code's built-in permission system has bugs:
- Issue #15921: VSCode extension ignores Bash permissions
- Issue #13340: Piped commands bypass permission allowlist

This hook properly implements the allow/deny logic that settings.json SHOULD provide.
"""
from __future__ import annotations

import json
import os
import re
import sys
from pathlib import Path

sys.path.insert(0, os.path.expanduser("~/.claude/lib"))
import hook_utils  # noqa: E402

# Settings file locations in precedence order (highest first)
SETTINGS_FILES = [
    Path.home() / ".claude" / "settings.json",
    # Add project-level settings if needed
]

def load_settings() -> tuple[list[str], list[str]]:
    """Load and merge settings from all settings files."""
    allow_patterns: list[str] = []
    deny_patterns: list[str] = []

    for settings_file in SETTINGS_FILES:
        if settings_file.exists():
            try:
                with open(settings_file, 'r') as f:
                    settings = json.load(f)
                    permissions = settings.get("permissions", {})

                    # Extract Bash patterns from allow list
                    for rule in permissions.get("allow", []):
                        if rule.startswith("Bash(") and rule.endswith(")"):
                            pattern = rule[5:-1]  # Extract pattern from Bash(...)
                            allow_patterns.append(pattern)

                    # Extract Bash patterns from deny list
                    for rule in permissions.get("deny", []):
                        if rule.startswith("Bash(") and rule.endswith(")"):
                            pattern = rule[5:-1]  # Extract pattern from Bash(...)
                            deny_patterns.append(pattern)
            except (json.JSONDecodeError, IOError):
                continue

    return allow_patterns, deny_patterns

def pattern_matches_command(pattern: str, command: str) -> bool:
    """
    Check if a permission pattern matches a command.

    Patterns use prefix matching with :* as wildcard suffix.
    Examples:
        - "mkdir:*" matches "mkdir -p /foo/bar"
        - "git status:*" matches "git status"
        - "npm run lint" matches exactly "npm run lint" or "npm run lint ..."

    NOTE: This matches a SINGLE command segment. Whole-command matching is
    done by check_pattern_decision(), which first decomposes a chained
    command into segments via split_command_segments() and applies this
    matcher to each. A bare prefix match against a whole chained string
    (e.g. "echo hi && rm -rf /") would let a benign leading command smuggle
    a dangerous trailing one past the deny list — see GitHub issue #660.
    """
    command = command.strip()

    # Handle :* wildcard suffix (matches anything after the prefix)
    if pattern.endswith(":*"):
        prefix = pattern[:-2]  # Remove :*
        return command.startswith(prefix)

    # Handle patterns with space before * (e.g., "mkdir *")
    if pattern.endswith(" *"):
        prefix = pattern[:-2]  # Remove " *"
        return command.startswith(prefix)

    # Exact match or prefix match
    return command.startswith(pattern)


# Shell tokens that separate one command from the next. A deny pattern must
# be evaluated against every segment, not just the head of the whole string,
# or chaining slips dangerous commands past the deny list (issue #660).
_SEGMENT_SEPARATORS = re.compile(r"&&|\|\||[;|\n]")

# Command-substitution forms: $( ... ) and `...`. Their inner command runs,
# so it must be decomposed and checked just like a top-level segment.
_SUBSTITUTION = re.compile(r"\$\(([^()]*)\)|`([^`]*)`")


def split_command_segments(command: str) -> list[str]:
    """
    Decompose a shell command string into independently-runnable segments.

    Splits on the operators that chain or pipe commands (`&&`, `||`, `;`,
    `|`, newlines) and lifts out command substitutions (`$(...)`, backticks)
    so their inner commands are evaluated too. Every returned segment is a
    candidate command that the shell would actually execute; the deny list
    must be checked against each one.

    This is a best-effort lexical split, not a full shell parser. It does not
    track quoting, so a separator inside a quoted string is still treated as a
    separator. For a SECURITY deny check that bias is correct: over-splitting
    only produces extra segments to inspect, never fewer.

    Returns a flat list of non-empty, stripped segments. Always returns at
    least one element for a non-empty command.
    """
    work = [command]
    out: list[str] = []
    while work:
        current = work.pop()
        # Lift any command substitutions into their own segments, replacing
        # the substitution text with a space so the surrounding command is
        # still inspected as its own segment.
        subs = list(_SUBSTITUTION.finditer(current))
        if subs:
            for m in subs:
                inner = m.group(1) if m.group(1) is not None else m.group(2)
                if inner and inner.strip():
                    work.append(inner)
            current = _SUBSTITUTION.sub(" ", current)
        for part in _SEGMENT_SEPARATORS.split(current):
            part = part.strip()
            if part:
                out.append(part)
    return out or ([command.strip()] if command.strip() else [])


# Curated set of destructive command shapes that must be hard-blocked even in
# bypass-permission mode. These mirror the destructive-git-reset smart-rule:
# each is checked ABOVE the bypass short-circuit and promoted to
# hook_utils.hard_block() (exit 2), the only mechanism that survives bypass
# mode (GitHub issue #39344). Patterns are deliberately narrow — they target
# whole-disk / root destruction, not ordinary cleanup like `rm -rf ./build`.
_DESTRUCTIVE_PATTERNS: list[tuple[str, "re.Pattern[str]"]] = [
    # rm with recursive+force flags targeting the filesystem root or a
    # top-level system dir. Matches combined (-rf/-fr) and split (-r ... -f)
    # flag forms; the flag run must contain both r and f.
    (
        "recursive root delete",
        re.compile(
            r"\brm\b(?=(?:\s+-[a-zA-Z]*)*\s+-[a-zA-Z]*r[a-zA-Z]*\b)"
            r"(?=(?:\s+-[a-zA-Z]*)*\s+-[a-zA-Z]*f[a-zA-Z]*\b)"
            r".*?\s(?:/|/\*|~|\$HOME|/etc|/usr|/bin|/var|/boot|/lib|/sys|/dev)"
            r"(?:/\S*)?\s*$"
        ),
    ),
    # Filesystem creation over a block device (mkfs, mkfs.ext4, etc.).
    ("filesystem format (mkfs)", re.compile(r"\bmkfs(?:\.[a-z0-9]+)?\b")),
    # Raw disk write via dd to a device node.
    ("raw disk write (dd of=/dev)", re.compile(r"\bdd\b[^\n]*\bof=/dev/")),
    # Overwriting a partition / whole disk device directly.
    ("device overwrite", re.compile(r"\bof=/dev/(?:sd|hd|nvme|disk|mapper)\S*")),
    # Forking bomb.
    ("fork bomb", re.compile(r":\s*\(\s*\)\s*\{\s*:\s*\|\s*:")),
    # Overwriting a whole disk via shred.
    ("disk shred", re.compile(r"\bshred\b[^\n]*\s/dev/")),
]


def check_destructive(command: str) -> tuple[str | None, str | None]:
    """
    Detect curated destructive command shapes in any segment of `command`.

    Run ABOVE the bypass short-circuit in main(); a hit is promoted to
    hook_utils.hard_block() (exit 2) so it cannot be bypassed by
    --dangerously-skip-permissions. Decomposes the command first so a
    destructive segment hidden behind a benign one (e.g.
    "echo go && rm -rf /") is still caught.

    Returns (label, reason) on the first destructive segment, else (None, None).
    """
    for segment in split_command_segments(command):
        for label, regex in _DESTRUCTIVE_PATTERNS:
            if regex.search(segment):
                return (
                    label,
                    f"Refusing destructive command ({label}): {segment!r}. "
                    "This is hard-blocked even in bypass-permission mode. "
                    "If this is intentional, run it manually outside Claude Code.",
                )
    return (None, None)


# Escape hatch for a genuinely-intended force delete of an unmerged branch.
# Honored from the environment or inline on the command, matching the
# ALLOW_MAIN_COMMIT convention in branch-guard.py / enforce-git-workflow.py.
_FORCE_DELETE_HATCH = "ALLOW_BRANCH_FORCE_DELETE"
_TRUTHY = frozenset({"1", "true", "yes"})


def _git_branch_args(segment: str) -> list[str] | None:
    """Return the argument tokens of a `git branch` segment, else None.

    Skips the global options that may sit between `git` and the subcommand
    (`-C <path>`, `-c key=val`, `--git-dir=...`), so `git -C /repo branch -D x`
    is recognized. Anything that is not a `git branch` invocation — including a
    segment that merely mentions the string in a grep pattern or an echo —
    returns None.
    """
    tokens = segment.split()
    if not tokens or tokens[0] != "git":
        return None
    i = 1
    while i < len(tokens):
        tok = tokens[i]
        if tok in ("-C", "-c"):
            i += 2  # consumes its value
            continue
        if tok.startswith("--") or tok.startswith("-"):
            i += 1
            continue
        break
    if i >= len(tokens) or tokens[i] != "branch":
        return None
    return tokens[i + 1:]


def is_force_branch_delete(segment: str) -> bool:
    """True if `segment` is a `git branch` invocation that force-deletes.

    Covers every spelling git accepts, not just the literal `-D` prefix the
    settings.json deny pattern matches: `-D`, `-d -f`, `--delete --force`,
    combined short flags (`-Df`, `-fd`), and any order.
    """
    args = _git_branch_args(segment)
    if args is None:
        return False
    has_delete = has_force = False
    for tok in args:
        if tok == "--":
            break
        if tok.startswith("--"):
            if tok == "--delete":
                has_delete = True
            elif tok == "--force":
                has_force = True
        elif tok.startswith("-") and len(tok) > 1:
            letters = tok[1:]
            if "D" in letters:
                has_delete = has_force = True
            if "d" in letters:
                has_delete = True
            if "f" in letters:
                has_force = True
    return has_delete and has_force


def check_force_branch_delete(command: str) -> tuple[str | None, str | None]:
    """Block `git branch` force-deletes, naming the segment and the way out.

    Runs ABOVE the bypass short-circuit in main() so the reason reaches the
    agent in bypassPermissions sessions. There, the settings.json pattern
    check never runs, and Claude Code's own deny enforcement emits a generic
    "Permission to use Bash with command <whole chain> has been denied" — which
    reads as if worktree removal were blocked and makes agents abandon teardown
    (GitHub issue #907).

    Returns (segment, reason) on the first force-delete segment, else
    (None, None).
    """
    if os.environ.get(_FORCE_DELETE_HATCH, "").strip().lower() in _TRUTHY:
        return (None, None)
    if f"{_FORCE_DELETE_HATCH}=1" in command:
        return (None, None)

    for segment in split_command_segments(command):
        if not is_force_branch_delete(segment):
            continue
        return (
            segment,
            f"Force-deleting a branch is blocked. ONLY this segment is at fault: {segment!r}\n"
            "Every other segment of your command is permitted — `git worktree remove` and "
            "`git worktree prune` are both on the allow list. Re-run them without this segment "
            "and they will succeed.\n"
            "\n"
            "`git branch -D` can discard commits that exist nowhere else, so it is never "
            "permitted ad hoc. Use the janitor, which deletes a branch only after verifying "
            "the default branch already contains its work (merged OR squash-merged):\n"
            "\n"
            "  # tear down one worktree: remove it, prune, delete its branch if absorbed\n"
            "  bash ~/.claude/lib/worktree-sweep.sh --worktree <path-to-worktree>\n"
            "\n"
            "  # worktree already gone? sweep leftover absorbed branches\n"
            "  bash ~/.claude/lib/worktree-sweep.sh --merged-branches\n"
            "\n"
            "(That script ships with the git-worktrees module. If it is not installed, "
            "`git branch -d` still deletes a normally-merged branch; a squash-merged one "
            "needs the hatch below.)\n"
            "\n"
            "If the branch is genuinely unmerged and you intend to discard its commits, say so "
            f"explicitly: `{_FORCE_DELETE_HATCH}=1 git branch -D <branch>`.",
        )
    return (None, None)


def check_smart_rules(command: str) -> tuple[str | None, str | None]:
    """
    Context-aware rules for commands that are safe in some forms but dangerous in others.
    These run BEFORE settings.json patterns AND before the bypass-mode short-circuit.

    Returns: (decision, reason) where decision is:
      - "allow": auto-approve (e.g. reset to a remote ref)
      - "hard_block": destroy-loca-history pattern; main() promotes to hard_block()
      - None: fall through to bypass / pattern matching
    """
    # git reset --hard: allow when targeting a remote ref, hard-block otherwise.
    # Safe: git reset --hard origin/main, git reset --hard origin/development
    # Dangerous: git reset --hard (bare), git reset --hard HEAD~3, git reset --hard <local-ref>
    reset_match = re.search(r'\bgit\s+reset\s+--hard\b', command)
    if reset_match:
        if re.search(r'\bgit\s+reset\s+--hard\s+origin/', command):
            return ("allow", "git reset --hard to remote ref (safe)")
        # Also allow git -C <path> reset --hard origin/
        if re.search(r'\bgit\s+-C\s+\S+\s+reset\s+--hard\s+origin/', command):
            return ("allow", "git reset --hard to remote ref in subdir (safe)")
        return (
            "hard_block",
            "git reset --hard without remote ref is blocked. "
            "Use 'git reset --hard origin/<branch>' to reset to a remote ref, "
            "or 'git pull --ff-only' to sync.",
        )

    return (None, None)


def check_pattern_decision(command: str, allow_patterns: list[str], deny_patterns: list[str]) -> tuple[str | None, str | None]:
    """
    Allow/deny decision from settings.json patterns, evaluated PER SEGMENT.

    The command is decomposed into segments (split_command_segments) so that
    chaining cannot smuggle a denied command past the list. Rules:

      - DENY if ANY segment matches ANY deny pattern. A chain is only as safe
        as its most dangerous link.
      - ALLOW only if EVERY segment matches some allow pattern. One
        un-allowed segment means the whole chain falls through (returns None),
        so the caller's normal permission flow still applies to it.

    Smart-rules and the destructive set are NOT consulted here — main() runs
    those first so their hard_block fires bypass-proof.

    Returns: (decision, reason) where decision is "allow", "deny", or None.
    """
    segments = split_command_segments(command)

    # DENY wins: any denied segment blocks the whole command.
    for segment in segments:
        for pattern in deny_patterns:
            if pattern_matches_command(pattern, segment):
                return (
                    "deny",
                    f"Command segment {segment!r} matches deny pattern: {pattern}",
                )

    # ALLOW only when every segment is explicitly allowed.
    if allow_patterns and segments:
        all_allowed = all(
            any(pattern_matches_command(p, seg) for p in allow_patterns)
            for seg in segments
        )
        if all_allowed:
            return ("allow", "All command segments match allow patterns")

    return (None, None)


def main() -> None:
    input_data = hook_utils.read_hook_input()

    tool_name = input_data.get("tool_name", "")
    tool_input = input_data.get("tool_input", {})

    # Only handle Bash commands
    if tool_name != "Bash":
        sys.exit(0)

    command = tool_input.get("command", "")
    if not command:
        sys.exit(0)

    # 1. Curated destructive set runs first and OUTSIDE the bypass
    #    short-circuit so whole-disk / root destruction hard-blocks even in
    #    bypass mode. Checked per-segment so chaining cannot hide it.
    destructive_label, destructive_reason = check_destructive(command)
    if destructive_label:
        hook_utils.hard_block(destructive_reason or "destructive command blocked")

    # 2. Force branch-delete runs OUTSIDE the bypass short-circuit too. In
    #    bypass mode the pattern check below never runs, so this is the only
    #    place an actionable reason can reach the agent (issue #907).
    fbd_segment, fbd_reason = check_force_branch_delete(command)
    if fbd_segment:
        hook_utils.hard_block(fbd_reason or "force branch delete blocked")

    # 3. Smart-rules run next, also OUTSIDE the bypass short-circuit so the
    #    destructive-reset hard-blocks even in bypass mode.
    smart_decision, smart_reason = check_smart_rules(command)
    if smart_decision == "hard_block":
        hook_utils.hard_block(smart_reason or "destructive smart-rule matched")
    if smart_decision == "allow":
        hook_utils.emit_decision("allow", smart_reason or "smart-rule allow")
    # "deny" via smart-rule is unused now (legacy path).

    # 4. Bypass mode: skip pattern matching. The session has opted out
    #    of permission noise, and smart-rule hard-blocks have already
    #    fired for the genuinely dangerous cases.
    if hook_utils.is_bypass_mode(input_data):
        sys.exit(0)

    # 5. Pattern matching against settings.json allow/deny lists.
    allow_patterns, deny_patterns = load_settings()
    decision, reason = check_pattern_decision(command, allow_patterns, deny_patterns)

    if decision:
        hook_utils.emit_decision(decision, reason or "")

    sys.exit(0)

if __name__ == "__main__":
    main()

hooks/auto-approve-file-ops.py

#!/usr/bin/env python3
"""
PreToolUse hook that auto-approves Read, Edit, and Write operations for allowed paths.

This hook exists because Claude Code's built-in permission system has bugs:
- Issue #15921: VSCode extension ignores Edit/Write permissions
- The permission patterns in settings.json are ignored

This hook enforces path-based permissions for file operations.
"""
from __future__ import annotations

import json
import os
import sys
from fnmatch import fnmatch
from pathlib import Path

# Settings file location
SETTINGS_FILE = Path.home() / ".claude" / "settings.json"

def load_path_patterns() -> tuple[list[str], list[str], list[str]]:
    """Load Read/Edit/Write path patterns from settings."""
    read_patterns: list[str] = []
    edit_patterns: list[str] = []
    write_patterns: list[str] = []

    if SETTINGS_FILE.exists():
        try:
            with open(SETTINGS_FILE, 'r') as f:
                settings = json.load(f)
                permissions = settings.get("permissions", {})

                for rule in permissions.get("allow", []):
                    if rule.startswith("Read(") and rule.endswith(")"):
                        pattern = rule[5:-1]  # Extract path from Read(...)
                        read_patterns.append(pattern)
                    elif rule.startswith("Edit(") and rule.endswith(")"):
                        pattern = rule[5:-1]  # Extract path from Edit(...)
                        edit_patterns.append(pattern)
                    elif rule.startswith("Write(") and rule.endswith(")"):
                        pattern = rule[6:-1]  # Extract path from Write(...)
                        write_patterns.append(pattern)
        except (json.JSONDecodeError, IOError):
            pass

    return read_patterns, edit_patterns, write_patterns

def path_matches_pattern(file_path: str, pattern: str) -> bool:
    """
    Check if a file path matches a glob pattern.

    Patterns use glob syntax:
        - "$HOME/code/**" matches anything under $HOME/code/
        - "/tmp/**" matches anything under /tmp/
    """
    # Normalize paths
    file_path = os.path.normpath(file_path)

    # Handle ** glob pattern (match any depth)
    if "**" in pattern:
        # Convert $HOME/code/** to $HOME/code/ prefix match
        base_path = pattern.replace("**", "").rstrip("/")
        return file_path.startswith(base_path)

    # Standard glob matching
    return fnmatch(file_path, pattern)

def check_file_path(file_path: str, patterns: list[str]) -> tuple[str | None, str | None]:
    """
    Check if a file path matches any allowed pattern.

    Returns: (decision, reason)
        decision: "allow" or None (let default system handle)
    """
    for pattern in patterns:
        if path_matches_pattern(file_path, pattern):
            return ("allow", f"Path matches allowed pattern: {pattern}")

    # No match - let the default permission system handle it
    return (None, None)

def main() -> None:
    try:
        input_data = json.load(sys.stdin)
    except json.JSONDecodeError:
        sys.exit(0)  # Non-blocking exit on invalid input

    tool_name = input_data.get("tool_name", "")
    tool_input = input_data.get("tool_input", {})

    # Load patterns from settings
    read_patterns, edit_patterns, write_patterns = load_path_patterns()

    # Handle Read tool
    if tool_name == "Read":
        file_path = tool_input.get("file_path", "")
        if file_path:
            decision, reason = check_file_path(file_path, read_patterns)
            if decision:
                output = {
                    "hookSpecificOutput": {
                        "hookEventName": "PreToolUse",
                        "permissionDecision": decision,
                        "permissionDecisionReason": reason
                    }
                }
                print(json.dumps(output))
        sys.exit(0)

    # Handle Edit tool
    if tool_name == "Edit":
        file_path = tool_input.get("file_path", "")
        if file_path:
            decision, reason = check_file_path(file_path, edit_patterns)
            if decision:
                output = {
                    "hookSpecificOutput": {
                        "hookEventName": "PreToolUse",
                        "permissionDecision": decision,
                        "permissionDecisionReason": reason
                    }
                }
                print(json.dumps(output))
        sys.exit(0)

    # Handle Write tool
    if tool_name == "Write":
        file_path = tool_input.get("file_path", "")
        if file_path:
            decision, reason = check_file_path(file_path, write_patterns)
            if decision:
                output = {
                    "hookSpecificOutput": {
                        "hookEventName": "PreToolUse",
                        "permissionDecision": decision,
                        "permissionDecisionReason": reason
                    }
                }
                print(json.dumps(output))
        sys.exit(0)

    sys.exit(0)

if __name__ == "__main__":
    main()

hooks/ccgm-update-check.py

#!/usr/bin/env python3
"""
UserPromptSubmit hook: daily CCGM health check (upstream + install drift).

On the first prompt of each day:
  1. Check the CCGM remote for new commits and warn if updates are available.
  2. Audit install drift: verify every file the manifest claims is installed
     actually exists on disk (and symlinks resolve).

Both checks print warnings to stderr. A single daily flag file gates both so
subsequent prompts skip the work entirely.

Disable via CCGM_AUTO_UPDATE_CHECK=false in ~/.claude/.ccgm.env.
"""
from __future__ import annotations

import json
import os
import subprocess
import sys
import tempfile
from datetime import date
from pathlib import Path

MANIFEST_FILE = Path.home() / ".claude" / ".ccgm-manifest.json"
ENV_FILE = Path.home() / ".claude" / ".ccgm.env"
FLAG_DIR = Path(tempfile.gettempdir())


def is_enabled() -> bool:
    """Check if auto-update check is enabled in .ccgm.env."""
    if not ENV_FILE.exists():
        return False
    try:
        with open(ENV_FILE) as f:
            for line in f:
                line = line.strip()
                if line.startswith("CCGM_AUTO_UPDATE_CHECK="):
                    value = line.split("=", 1)[1].strip().lower()
                    return value in ("true", "1", "yes")
    except (OSError, IOError):
        pass
    return False


def already_checked_today() -> bool:
    """Check if we already ran the update check today."""
    flag_file = FLAG_DIR / f".ccgm-update-check-{date.today().isoformat()}"
    if flag_file.exists():
        return True
    # Create flag file for today, clean up old ones
    for old_flag in FLAG_DIR.glob(".ccgm-update-check-*"):
        try:
            old_flag.unlink()
        except OSError:
            pass
    try:
        flag_file.touch()
    except OSError:
        pass
    return False


def get_ccgm_root() -> str | None:
    """Read the CCGM clone path from the manifest."""
    if not MANIFEST_FILE.exists():
        return None
    try:
        with open(MANIFEST_FILE) as f:
            manifest = json.load(f)
            return manifest.get("ccgmRoot")
    except (json.JSONDecodeError, OSError):
        return None


def check_install_drift() -> list[str]:
    """Return a list of manifest-claimed files that are missing from disk.

    A file is "missing" if its path does not exist. For symlinks, the
    symlink itself must resolve to an existing target. Returns file paths
    (max 10) so output stays readable.
    """
    if not MANIFEST_FILE.exists():
        return []
    try:
        with open(MANIFEST_FILE) as f:
            manifest = json.load(f)
    except (json.JSONDecodeError, OSError):
        return []

    missing: list[str] = []
    for entry in manifest.get("files", []):
        p = Path(entry)
        # Path.exists() follows symlinks, so a dangling symlink shows as missing.
        if not p.exists():
            missing.append(entry)
            if len(missing) >= 10:
                break
    return missing


def check_for_updates(ccgm_root: str) -> int:
    """Fetch remote and count new commits on main."""
    try:
        # Fetch latest (quiet, fast)
        subprocess.run(
            ["git", "fetch", "origin", "--quiet"],
            cwd=ccgm_root,
            capture_output=True,
            timeout=10,
        )
        # Count commits ahead on remote
        result = subprocess.run(
            ["git", "rev-list", "HEAD..origin/main", "--count"],
            cwd=ccgm_root,
            capture_output=True,
            text=True,
            timeout=5,
        )
        if result.returncode == 0:
            count = int(result.stdout.strip())
            return count
    except (subprocess.TimeoutExpired, subprocess.SubprocessError, ValueError):
        pass
    return 0


def main() -> None:
    # Read stdin (required by hook contract) but we don't use it
    try:
        json.load(sys.stdin)
    except (json.JSONDecodeError, EOFError):
        pass

    # Quick exit paths
    if not is_enabled():
        return

    if already_checked_today():
        return

    ccgm_root = get_ccgm_root()
    if not ccgm_root or not Path(ccgm_root).is_dir():
        return

    count = check_for_updates(ccgm_root)
    if count > 0:
        s = "s" if count != 1 else ""
        print(
            f"\n  CCGM: {count} update{s} available. "
            f"Run: cd {ccgm_root} && ./update.sh\n",
            file=sys.stderr,
        )

    drift = check_install_drift()
    if drift:
        more = ""
        if len(drift) >= 10:
            more = " (more truncated)"
        print(
            f"\n  CCGM install drift: {len(drift)} manifest-claimed "
            f"file(s) missing from disk{more}:",
            file=sys.stderr,
        )
        for path in drift:
            print(f"    - {path}", file=sys.stderr)
        print(
            "  Resolve by re-running the relevant module install, or "
            "prune the manifest via /ccgm-sync.\n",
            file=sys.stderr,
        )


if __name__ == "__main__":
    main()

hooks/port-check.py

#!/usr/bin/env python3
"""
PreToolUse:Bash hook that intercepts dev server commands and ensures correct
port allocation based on the port registry and .env.clone identity.

Classification (plan.md §5 Epic 1): bypass-suppressible. Warnings are advisory
only — they never block. In bypass-mode sessions the hook short-circuits to
keep dev-server launches quiet. Outside bypass mode it prints to stderr (not
as a permission decision) so the agent can see the warning without blocking.

DETECTS: Commands that launch dev servers (vite, wrangler dev, npm run dev,
pnpm dev, next dev, browser-sync, etc.)

CHECKS:
1. Resolves the correct port from port-registry.json + .env.clone
2. Checks if that port is already in use by another process
3. If a collision is found, warns with the PID and suggests action
4. If the command uses a wrong port (hardcoded or default), warns

OUTPUT: Prints a status message to stderr for the agent to see.
Does NOT block - only warns. The agent should act on the warning.
"""

from __future__ import annotations

import json
import os
import re
import subprocess
import sys
from pathlib import Path

sys.path.insert(0, os.path.expanduser("~/.claude/lib"))
import hook_utils  # noqa: E402

REGISTRY_PATH = Path.home() / ".claude" / "port-registry.json"

# Patterns that indicate a dev server is being launched
DEV_SERVER_PATTERNS = [
    r'\bvite\b',
    r'\bwrangler\s+dev\b',
    r'\bnpm\s+run\s+dev\b',
    r'\bpnpm\s+(run\s+)?dev\b',
    r'\bnext\s+dev\b',
    r'\bbrowser-sync\s+start\b',
    r'\bastro\s+dev\b',
    r'\btsx\s+watch\b',
    r'\bnode\s+.*server',
    r'\bconcurrently\b.*\bdev\b',
]

# Patterns to extract --port from command
PORT_FLAG_PATTERN = re.compile(r'--port[=\s]+(\d+)')
PORT_EXPR_PATTERN = re.compile(r'--port\s+\$\(\(([^)]+)\)\)')


def load_registry() -> dict | None:
    """Load port registry."""
    try:
        with open(REGISTRY_PATH) as f:
            return json.load(f)
    except (FileNotFoundError, json.JSONDecodeError):
        return None


def get_repo_name() -> str | None:
    """Derive repo name from git remote."""
    try:
        result = subprocess.run(
            ["git", "remote", "get-url", "origin"],
            capture_output=True, text=True, timeout=2,
        )
        if result.returncode == 0:
            url = result.stdout.strip()
            name = url.rstrip("/").split("/")[-1]
            if name.endswith(".git"):
                name = name[:-4]
            return name
    except Exception:
        pass
    return None


def get_env_clone() -> dict[str, str]:
    """Read .env.clone from current directory."""
    env_clone: dict[str, str] = {}
    env_path = Path.cwd() / ".env.clone"
    if env_path.exists():
        try:
            with open(env_path) as f:
                for line in f:
                    line = line.strip()
                    if line and not line.startswith("#") and "=" in line:
                        key, val = line.split("=", 1)
                        env_clone[key.strip()] = val.strip()
        except Exception:
            pass
    return env_clone


def get_port_offset(env_clone: dict[str, str]) -> int:
    """Get port offset from .env.clone."""
    if "PORT_OFFSET" in env_clone:
        try:
            return int(env_clone["PORT_OFFSET"])
        except ValueError:
            pass
    if "CLONE_NUMBER" in env_clone:
        try:
            return int(env_clone["CLONE_NUMBER"])
        except ValueError:
            pass
    # Try deriving from directory name
    dirname = Path.cwd().name
    # Workspace model: extract w and c numbers
    wc_match = re.search(r'w(\d+)-c(\d+)$', dirname)
    if wc_match:
        w, c = int(wc_match.group(1)), int(wc_match.group(2))
        return w * 4 + c  # Assumes 4 clones per workspace
    # Flat clone model: extract trailing number
    num_match = re.search(r'(\d+)$', dirname)
    if num_match:
        return int(num_match.group(1))
    return 0


def check_port_in_use(port: int) -> tuple[str, str] | None:
    """Check if a port is in use. Returns (pid, process_name) or None."""
    try:
        result = subprocess.run(
            ["lsof", "-iTCP:" + str(port), "-sTCP:LISTEN", "-P", "-n", "-t"],
            capture_output=True, text=True, timeout=3,
        )
        if result.returncode == 0 and result.stdout.strip():
            pid = result.stdout.strip().split("\n")[0]
            # Get process name
            ps_result = subprocess.run(
                ["ps", "-p", pid, "-o", "comm="],
                capture_output=True, text=True, timeout=2,
            )
            proc_name = ps_result.stdout.strip() if ps_result.returncode == 0 else "unknown"
            return (pid, proc_name)
    except Exception:
        pass
    return None


def is_dev_server_command(command: str) -> bool:
    """Check if command launches a dev server."""
    for pattern in DEV_SERVER_PATTERNS:
        if re.search(pattern, command, re.IGNORECASE):
            return True
    return False


def extract_port_from_command(command: str) -> int | None:
    """Try to extract a hardcoded port from the command."""
    match = PORT_FLAG_PATTERN.search(command)
    if match:
        return int(match.group(1))
    return None


def determine_service_type(command: str) -> str:
    """Determine if this is a frontend or backend service."""
    cmd_lower = command.lower()
    if "wrangler" in cmd_lower:
        return "backend"
    if "tsx" in cmd_lower and ("server" in cmd_lower or "watch" in cmd_lower):
        return "backend"
    if "api" in cmd_lower:
        return "backend"
    # Default to frontend
    return "frontend"


def main() -> None:
    # The earlier shape passed only `tool_input` here; the modern Claude
    # Code shape passes the full hook envelope (tool_name + tool_input +
    # permission_mode). Read both for compatibility.
    data = hook_utils.read_hook_input()

    # Accept either shape: the modern envelope (with tool_input nested)
    # or the older direct-tool-input payload some tests may still send.
    if "tool_input" in data:
        tool_input = data.get("tool_input", {})
    else:
        tool_input = data

    # Bypass mode: stay completely silent. The user has opted out of
    # permission noise, and port checks are advisory not safety.
    if hook_utils.is_bypass_mode(data):
        return

    command = tool_input.get("command", "")

    # Only care about dev server commands
    if not is_dev_server_command(command):
        return

    registry = load_registry()
    if not registry:
        print("WARNING: Port registry (~/.claude/port-registry.json) not found. "
              "Cannot validate port allocation.", file=sys.stderr)
        return

    repo_name = get_repo_name()
    if not repo_name or repo_name not in registry.get("repos", {}):
        # Not a registered repo, skip
        return

    repo_config = registry["repos"][repo_name]
    env_clone = get_env_clone()
    port_offset = get_port_offset(env_clone)
    service_type = determine_service_type(command)
    base_port = repo_config.get(service_type)

    if base_port is None:
        return

    expected_port = base_port + port_offset
    agent_id = env_clone.get("AGENT_ID", f"offset-{port_offset}")

    # Check if the command specifies a different port
    cmd_port = extract_port_from_command(command)

    messages = []

    if cmd_port is not None and cmd_port != expected_port:
        messages.append(
            f"PORT MISMATCH: Command uses port {cmd_port} but registry assigns "
            f"port {expected_port} for {repo_name} {service_type} ({agent_id}). "
            f"Use --port {expected_port} instead."
        )

    # Check if expected port is already in use
    in_use = check_port_in_use(expected_port)
    if in_use:
        pid, proc_name = in_use
        messages.append(
            f"PORT CONFLICT: Port {expected_port} ({repo_name} {service_type}, "
            f"{agent_id}) is already in use by {proc_name} (PID {pid}). "
            f"Kill it with: kill {pid}"
        )

    # Also check if the command port is in use (if different from expected)
    if cmd_port is not None and cmd_port != expected_port:
        in_use_cmd = check_port_in_use(cmd_port)
        if in_use_cmd:
            pid, proc_name = in_use_cmd
            messages.append(
                f"PORT CONFLICT: Command port {cmd_port} is already in use by "
                f"{proc_name} (PID {pid})."
            )

    # If no explicit port in command and not using expected, suggest it
    if cmd_port is None and port_offset > 0:
        messages.append(
            f"PORT INFO: {repo_name} {service_type} ({agent_id}) should use "
            f"port {expected_port} (base {base_port} + offset {port_offset}). "
            f"Ensure --port {expected_port} is passed or .env.clone is read by the dev config."
        )

    if messages:
        # Warnings go to stderr so the agent sees them in tool output;
        # this hook never blocks via a permission decision (advisory only).
        print("PORT-CHECK: " + " | ".join(messages), file=sys.stderr)
    # If no messages, silently allow


if __name__ == "__main__":
    main()

hooks/agent-tracking-pre.py

#!/usr/bin/env python3
"""
PreToolUse:Bash hook for multi-agent issue tracking.

ADVISORY ONLY - this hook NEVER writes to tracking CSV.
It only emits warnings when an agent is about to work on an
issue that's already claimed by another agent.

Intercepts:
- git checkout -b {N}-* : Warn if issue N is already claimed
"""

from __future__ import annotations

import json
import os
import re
import sys

# Import tracking module
sys.path.insert(0, os.path.expanduser("~/.claude/lib"))


def is_multi_clone_repo() -> bool:
    """Check if current directory has .env.clone (multi-clone repo)."""
    return os.path.isfile(os.path.join(os.getcwd(), ".env.clone"))


def get_repo_name() -> str | None:
    """Get repo name from git remote."""
    import subprocess
    try:
        result = subprocess.run(
            ["git", "remote", "get-url", "origin"],
            capture_output=True, text=True, timeout=5,
        )
        if result.returncode == 0:
            name = os.path.basename(result.stdout.strip())
            return name[:-4] if name.endswith(".git") else name
    except Exception:
        pass
    return None


def extract_issue_from_branch(command: str) -> str | None:
    """Extract issue number from git checkout -b {N}-* command."""
    match = re.search(r"git\s+checkout\s+-b\s+(\d+)-", command)
    if match:
        return match.group(1)
    return None


def warn(message: str) -> None:
    """Emit a warning (advisory, does not block)."""
    output = {
        "hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "permissionDecisionReason": f"WARNING: {message}",
        }
    }
    print(json.dumps(output))


def check_live_session_in_cwd() -> dict | None:
    """Check if another live Claude session is running in this working directory."""
    try:
        from agent_sessions import get_active_sessions
        my_cwd = os.getcwd()
        sessions = get_active_sessions(exclude_cwd=my_cwd)
        # Check if any session is in the same directory
        for s in sessions:
            if s.get("cwd") and os.path.realpath(s["cwd"]) == os.path.realpath(my_cwd):
                return s
    except Exception:
        pass
    return None


def main() -> None:
    try:
        data = json.load(sys.stdin)
    except json.JSONDecodeError:
        sys.exit(0)

    tool_name = data.get("tool_name", "")
    tool_input = data.get("tool_input", {})

    if tool_name != "Bash":
        sys.exit(0)

    command = tool_input.get("command", "").strip()
    if not command:
        sys.exit(0)

    # Early exit: not a multi-clone repo
    if not is_multi_clone_repo():
        sys.exit(0)

    # Only intercept git checkout -b
    if not re.match(r"git\s+checkout\s+-b\s+", command):
        sys.exit(0)

    # Check for live session in current directory (highest priority warning)
    live_session = check_live_session_in_cwd()
    if live_session:
        pid = live_session.get("pid", "?")
        uptime = live_session.get("uptime", "?")
        branch = live_session.get("branch") or "unknown branch"
        warn(
            f"A live Claude session (PID {pid}, up {uptime}) is already running in this directory "
            f"on branch '{branch}'. Creating a new branch here may conflict with that session's work. "
            f"Consider using a different clone directory."
        )

    issue_num = extract_issue_from_branch(command)
    if not issue_num:
        sys.exit(0)

    repo = get_repo_name()
    if not repo:
        sys.exit(0)

    # Check if issue is already claimed
    try:
        from agent_tracking import check_claim, get_agent_id
        agent, status = check_claim(repo, issue_num)
        my_id = get_agent_id()

        if agent and agent != my_id:
            warn(f"Issue #{issue_num} is already claimed by {agent} (status: {status})")
    except Exception:
        pass  # Never block on tracking errors

    sys.exit(0)


if __name__ == "__main__":
    main()

hooks/agent-tracking-post.py

#!/usr/bin/env python3
"""
PostToolUse:Bash hook for multi-agent issue tracking.

All tracking CSV mutations happen here, AFTER the command succeeds.
This prevents orphaned claims from failed commands.

Intercepts:
- git checkout -b {N}-*     : Register claim for issue N
- git commit -m "#N: ..."   : Update heartbeat (throttled to 30 min)
- gh pr create              : Update status to pr-created
- gh pr merge               : Update status to merged
- gh issue close            : Update status to closed

PostToolUse input schema (verified empirically):
{
    "tool_name": "Bash",
    "tool_input": {"command": "...", "description": "..."},
    "tool_response": {"stdout": "...", "stderr": "...", "interrupted": false},
    "cwd": "/path/to/working/dir",
    ...
}
"""

from __future__ import annotations

import json
import os
import re
import subprocess
import sys

# Import tracking module
sys.path.insert(0, os.path.expanduser("~/.claude/lib"))


def _find_log_repo() -> str:
    """Find the agent log repo directory."""
    env = os.environ.get("CLAUDE_LOG_REPO")
    if env and os.path.isdir(env):
        return env
    code_dir = os.path.expanduser("~/code")
    if os.path.isdir(code_dir):
        for entry in sorted(os.listdir(code_dir)):
            if entry.endswith("agent-logs") and os.path.isdir(os.path.join(code_dir, entry)):
                return os.path.join(code_dir, entry)
    return os.path.expanduser("~/code/agent-logs")


LOG_REPO_DIR = _find_log_repo()


def is_multi_clone_repo(cwd: str | None = None) -> bool:
    """Check if directory has .env.clone (multi-clone repo)."""
    wd = cwd or os.getcwd()
    return os.path.isfile(os.path.join(wd, ".env.clone"))


def is_log_repo(cwd: str | None = None) -> bool:
    """Check if we're in the log repo (skip heartbeats for log commits)."""
    wd = cwd or os.getcwd()
    try:
        return os.path.realpath(wd).startswith(os.path.realpath(LOG_REPO_DIR))
    except Exception:
        return False


def get_repo_name(cwd: str | None = None) -> str | None:
    """Get repo name from git remote."""
    wd = cwd or os.getcwd()
    try:
        result = subprocess.run(
            ["git", "remote", "get-url", "origin"],
            capture_output=True, text=True, timeout=5, cwd=wd,
        )
        if result.returncode == 0:
            name = os.path.basename(result.stdout.strip())
            return name[:-4] if name.endswith(".git") else name
    except Exception:
        pass
    return None


def extract_issue_from_branch_cmd(command: str) -> str | None:
    """Extract issue number from git checkout -b {N}-* command."""
    match = re.search(r"git\s+checkout\s+-b\s+(\d+)-", command)
    if match:
        return match.group(1)
    return None


def extract_issue_from_commit_msg(command: str) -> str | None:
    """Extract issue number from git commit -m '#N: ...' command."""
    match = re.search(r'-m\s+["\']?#(\d+):', command)
    if match:
        return match.group(1)
    return None


def extract_branch_name(command: str) -> str | None:
    """Extract branch name from git checkout -b command."""
    match = re.search(r"git\s+checkout\s+-b\s+(\S+)", command)
    if match:
        return match.group(1)
    return None


def extract_pr_number(stdout: str) -> str | None:
    """Extract PR number from gh pr create output."""
    # gh pr create outputs a URL like https://github.com/user/repo/pull/123
    match = re.search(r"/pull/(\d+)", stdout)
    if match:
        return match.group(1)
    return None


def get_issue_title(issue_num: str, cwd: str | None = None) -> str:
    """Fetch issue title from GitHub (best-effort)."""
    try:
        result = subprocess.run(
            ["gh", "issue", "view", str(issue_num), "--json", "title", "--jq", ".title"],
            capture_output=True, text=True, timeout=10,
            cwd=cwd,
        )
        if result.returncode == 0:
            return result.stdout.strip()
    except Exception:
        pass
    return ""


def get_current_branch(cwd: str | None = None) -> str | None:
    """Get current git branch name."""
    try:
        result = subprocess.run(
            ["git", "branch", "--show-current"],
            capture_output=True, text=True, timeout=5,
            cwd=cwd,
        )
        if result.returncode == 0:
            return result.stdout.strip()
    except Exception:
        pass
    return None


def extract_issue_from_branch_name(branch: str | None) -> str | None:
    """Extract issue number from branch name like 42-fix-auth."""
    if branch:
        match = re.match(r"^(\d+)-", branch)
        if match:
            return match.group(1)
    return None


def main() -> None:
    try:
        data = json.load(sys.stdin)
    except json.JSONDecodeError:
        sys.exit(0)

    tool_name = data.get("tool_name", "")
    tool_input = data.get("tool_input", {})
    tool_response = data.get("tool_response", {})
    cwd = data.get("cwd", os.getcwd())

    if tool_name != "Bash":
        sys.exit(0)

    command = tool_input.get("command", "").strip()
    stdout = tool_response.get("stdout", "")
    stderr = tool_response.get("stderr", "")
    interrupted = tool_response.get("interrupted", False)

    if not command or interrupted:
        sys.exit(0)

    # Early exit: not a multi-clone repo
    if not is_multi_clone_repo(cwd):
        sys.exit(0)

    try:
        from agent_tracking import (
            claim_issue, update_status, update_heartbeat,
            check_claim, get_agent_id, get_repo_name as at_get_repo_name,
        )

        agent_id = get_agent_id(cwd)
        repo = get_repo_name(cwd)
        if not repo:
            sys.exit(0)

        # --- git checkout -b {N}-* : Register claim ---
        if re.match(r"git\s+checkout\s+-b\s+\d+-", command):
            issue_num = extract_issue_from_branch_cmd(command)
            branch = extract_branch_name(command)
            if issue_num:
                # Check if we already have this claim (idempotent)
                existing_agent, _ = check_claim(repo, issue_num)
                if existing_agent == agent_id:
                    sys.exit(0)  # Already claimed by us

                title = get_issue_title(issue_num, cwd)
                claim_issue(
                    repo, issue_num,
                    agent_id=agent_id,
                    title=title,
                    branch=branch or "",
                )

        # --- git commit -m "#N: ..." : Heartbeat ---
        elif re.match(r"git\s+commit(\s|$)", command) and not is_log_repo(cwd):
            issue_num = extract_issue_from_commit_msg(command)
            if not issue_num:
                # Try to get issue from branch name
                branch = get_current_branch(cwd)
                issue_num = extract_issue_from_branch_name(branch)

            if issue_num:
                # Also transition from claimed -> in-progress on first commit
                existing_agent, status = check_claim(repo, issue_num)
                if existing_agent == agent_id and status == "claimed":
                    update_status(repo, issue_num, "in-progress", agent_id=agent_id)
                else:
                    update_heartbeat(repo, issue_num, agent_id=agent_id)

        # --- gh pr create : Update to pr-created ---
        elif re.match(r"gh\s+pr\s+create", command):
            branch = get_current_branch(cwd)
            issue_num = extract_issue_from_branch_name(branch)
            pr_num = extract_pr_number(stdout)
            if issue_num:
                update_status(
                    repo, issue_num, "pr-created",
                    agent_id=agent_id, pr=pr_num,
                )

        # --- gh pr merge : Update to merged ---
        elif re.match(r"gh\s+pr\s+merge", command):
            branch = get_current_branch(cwd)
            issue_num = extract_issue_from_branch_name(branch)
            if issue_num:
                update_status(repo, issue_num, "merged", agent_id=agent_id)

        # --- gh issue close : Update to closed ---
        elif re.match(r"gh\s+issue\s+close\s+(\d+)", command):
            match = re.search(r"gh\s+issue\s+close\s+(\d+)", command)
            if match:
                issue_num = match.group(1)
                update_status(repo, issue_num, "closed", agent_id=agent_id)

    except Exception as e:
        # Never block on tracking errors - write warning to stderr
        sys.stderr.write(f"agent-tracking-post: {e}\n")

    sys.exit(0)


if __name__ == "__main__":
    main()

hooks/pretooluse-bash-dispatch.py

#!/usr/bin/env python3
"""Single-process PreToolUse:Bash dispatcher (composition entry point).

This is the BACKWARD-COMPATIBLE composition layer for the PreToolUse:Bash
event. It replaces the six-process legacy chain
(enforce-git-workflow → auto-approve-bash → port-check → agent-tracking-pre
→ check-migration-timestamps → check-careful) with ONE process that runs the
same checks in-process, by priority, through hook_dispatcher.

It is the DEFAULT PreToolUse:Bash handler: settings.partial.json wires this
single entry in place of the six legacy per-process entries. The decisions are
identical because the handlers (lib/pretooluse_bash_checks.py) call the legacy
hooks' own pure functions; the dispatcher only resolves precedence. The six
standalone hook scripts remain installed and individually runnable, so a
deployment can revert by restoring the six PreToolUse:Bash entries.

DECLARATIVE MANIFEST (priority order mirrors the legacy registration order).
Precedence among the decisions they return is governed by hook_dispatcher's
DECISION_RANK: hard_block > deny > allow > ask. The curated destructive set is
short_circuit so it is emitted the instant it fires, nothing can soften it.

  priority  check                       decision kinds       bypass-safe  short
  --------  --------------------------  -------------------  -----------  -----
  10        git_workflow_check          hard_block / adv     yes          no
  20        destructive_check           hard_block           yes          YES
  25        force_branch_delete_check   hard_block           yes          YES
  30        smart_rules_check           hard_block / allow   yes          YES
  40        port_advisory_check         advisory             no           no
  50        agent_tracking_check        advisory             no           no
  60        migration_timestamp_check   hard_block           yes          no
  70        force_push_main_check       hard_block           yes          no
  80        careful_check               ask                  no           no
  90        pattern_check               deny / allow         no           no

bypass-safe=no means the dispatcher skips it in bypass mode, exactly as the
legacy hook exits 0 before its suppressible logic when is_bypass_mode() is
true. bypass-safe=yes means it runs even in bypass mode (the only path that
can produce a bypass-proof exit-2 block).
"""
from __future__ import annotations

import os
import sys

sys.path.insert(0, os.path.expanduser("~/.claude/lib"))
import hook_dispatcher as hd  # noqa: E402
import pretooluse_bash_checks as checks  # noqa: E402


def build_manifest() -> "hd.Manifest":
    """Construct the declarative PreToolUse:Bash manifest."""
    bash_only = hd.tool_matcher("Bash")
    m = hd.Manifest(event="PreToolUse")
    m.add(hd.Check(10, "git_workflow", bash_only, checks.git_workflow_check,
                   runs_in_bypass=True, short_circuit=False))
    m.add(hd.Check(20, "destructive", bash_only, checks.destructive_check,
                   runs_in_bypass=True, short_circuit=True))
    m.add(hd.Check(25, "force_branch_delete", bash_only, checks.force_branch_delete_check,
                   runs_in_bypass=True, short_circuit=True))
    m.add(hd.Check(30, "smart_rules", bash_only, checks.smart_rules_check,
                   runs_in_bypass=True, short_circuit=True))
    m.add(hd.Check(40, "port_advisory", bash_only, checks.port_advisory_check,
                   runs_in_bypass=False, short_circuit=False))
    m.add(hd.Check(50, "agent_tracking", bash_only, checks.agent_tracking_check,
                   runs_in_bypass=False, short_circuit=False))
    m.add(hd.Check(60, "migration_timestamp", bash_only, checks.migration_timestamp_check,
                   runs_in_bypass=True, short_circuit=False))
    m.add(hd.Check(70, "force_push_main", bash_only, checks.force_push_main_check,
                   runs_in_bypass=True, short_circuit=False))
    m.add(hd.Check(80, "careful", bash_only, checks.careful_check,
                   runs_in_bypass=False, short_circuit=False))
    m.add(hd.Check(90, "pattern", bash_only, checks.pattern_check,
                   runs_in_bypass=False, short_circuit=False))
    return m


def main() -> None:
    hd.dispatch(build_manifest())


if __name__ == "__main__":
    main()

hooks/check-migration-timestamps.py

#!/usr/bin/env python3
"""
PreToolUse:Bash hook to prevent duplicate Supabase migration timestamps.

Classification (plan.md §5 Epic 1): bypass-retained. This is a data-integrity
check, not permission noise — a duplicate timestamp leaves a migration
permanently stuck as "local only" in Supabase, which is hard to recover from.
Block uses `hook_utils.hard_block()` so it survives bypass mode.

BLOCKS git commit (via hard_block, bypass-proof) when migration files have
duplicate numeric prefixes. Duplicate timestamps break `supabase db push`
because the CLI cannot distinguish files that share the same timestamp.

Only runs when:
1. The command is a git commit
2. A supabase/migrations/ directory exists in the working directory
"""

from __future__ import annotations

import os
import re
import sys
from collections import Counter

sys.path.insert(0, os.path.expanduser("~/.claude/lib"))
import hook_utils  # noqa: E402


def main() -> None:
    data = hook_utils.read_hook_input()

    # Only handle Bash commands
    if data.get("tool_name", "") != "Bash":
        return

    command = data.get("tool_input", {}).get("command", "")

    # Only check git commit commands
    if not re.search(r"\bgit\s+commit\b", command):
        return

    # Check if we're in a git repo with supabase/migrations/
    migrations_dir = None
    for candidate in ["supabase/migrations", "supabase/migrations/"]:
        if os.path.isdir(candidate):
            migrations_dir = candidate
            break

    if not migrations_dir:
        return

    # Check ALL migration files for duplicate timestamps
    try:
        all_files = sorted(os.listdir(migrations_dir))
    except OSError:
        return

    sql_files = [f for f in all_files if f.endswith(".sql")]
    timestamps = []
    for f in sql_files:
        match = re.match(r"^(\d+)", f)
        if match:
            timestamps.append(match.group(1))

    # Find duplicates
    counts = Counter(timestamps)
    duplicates = {ts: count for ts, count in counts.items() if count > 1}

    if not duplicates:
        return

    # Build error message
    dup_details = []
    for ts in sorted(duplicates.keys()):
        files = [f for f in sql_files if f.startswith(ts)]
        dup_details.append(f"  Timestamp {ts} used by {duplicates[ts]} files:")
        for f in files:
            dup_details.append(f"    - {f}")

    error_msg = (
        "BLOCKED: Duplicate Supabase migration timestamps detected.\n"
        "Duplicate timestamps break `supabase db push` - the CLI can't distinguish files\n"
        "that share the same numeric prefix. Rename one file to a unique timestamp.\n\n"
        + "\n".join(dup_details)
        + "\n\nFix: rename one file in each group to increment the timestamp by 1 "
        "(e.g., 20260325900000 -> 20260325900001)."
    )

    hook_utils.hard_block(error_msg)


if __name__ == "__main__":
    main()

hooks/orphan-process-check.py

#!/usr/bin/env python3
"""
Check for orphaned test worker processes (vitest, jest) at session start.

Orphaned workers occur when a Claude Code session exits mid-test-run. The forked
worker processes get re-parented to PID 1 (launchd) and run indefinitely, consuming
RAM and CPU.

This hook runs during startup and warns if orphans are found.
"""

import json
import os
import subprocess
import sys


def find_orphaned_test_workers():
    """Find node processes with PPID 1 that look like test workers."""
    try:
        result = subprocess.run(
            ["ps", "-eo", "pid,ppid,rss,command"],
            capture_output=True, text=True, timeout=5
        )
    except (subprocess.TimeoutExpired, FileNotFoundError):
        return []

    orphans = []
    for line in result.stdout.strip().split("\n")[1:]:  # skip header
        parts = line.split(None, 3)
        if len(parts) < 4:
            continue

        pid, ppid, rss_kb, command = parts[0], parts[1], parts[2], parts[3]

        # Only orphaned processes (PPID 1)
        if ppid != "1":
            continue

        # Only node processes that look like test workers
        test_patterns = ["vitest", "jest-worker", "jest_worker", "test-worker"]
        if not any(p in command.lower() for p in test_patterns):
            continue

        try:
            orphans.append({
                "pid": int(pid),
                "rss_mb": int(rss_kb) / 1024,
                "command": command[:80]
            })
        except ValueError:
            continue

    return orphans


def main():
    orphans = find_orphaned_test_workers()

    if not orphans:
        sys.exit(0)

    total_mb = sum(o["rss_mb"] for o in orphans)
    pids = [str(o["pid"]) for o in orphans]

    # Output warning via hook result
    msg = (
        f"WARNING: {len(orphans)} orphaned test worker(s) found "
        f"({total_mb:.0f} MB RAM). "
        f"PIDs: {', '.join(pids[:10])}. "
        f"Run: kill {' '.join(pids[:10])}"
    )

    print(json.dumps({
        "decision": "approve",
        "reason": msg
    }))


if __name__ == "__main__":
    main()

hooks/check-careful.py

#!/usr/bin/env python3
"""
PreToolUse hook that pauses on destructive Bash commands.

Classification (plan.md §5 Epic 1): bypass-suppressible. In bypass-mode
sessions (bypassPermissions / dontAsk / auto) the routine "ask" decisions
short-circuit to exit 0 — the user already opted out of permission noise.

The exception is force-push-to-`main`: that case is migrated to
`hook_utils.hard_block()` so it survives bypass mode. Pushing over a
protected branch's history is the kind of destructive op `ALLOW_MAIN_COMMIT`
exists to gate, not a permission-noise prompt.

Inspects Bash commands for destructive patterns:
  - rm -rf (with smart whitelist for build artifacts)
  - SQL DROP / TRUNCATE
  - git push --force (and --force-with-lease) — escalates to hard_block on `main`
  - git reset --hard (history destroying)
  - git checkout . (dirty discard)
  - kubectl delete
  - docker rm -f / docker system prune

Build-artifact directories are whitelisted so `rm -rf node_modules` does not
trigger a prompt. Whitelist: node_modules, dist, .next, build, __pycache__,
.cache, .turbo, coverage.

Ported to Python from gstack `careful/bin/check-careful.sh` to match CCGM's
hook style (JSON stdin/stdout, consistent with auto-approve-bash.py).
"""
from __future__ import annotations

import os
import re
import sys

sys.path.insert(0, os.path.expanduser("~/.claude/lib"))
import hook_utils  # noqa: E402

# Build-artifact directory names that are safe to `rm -rf` without asking.
# If every path on an rm -rf line matches one of these (exactly or as a
# trailing component), we let it pass without warning.
BUILD_ARTIFACT_WHITELIST = {
    "node_modules",
    "dist",
    ".next",
    "build",
    "__pycache__",
    ".cache",
    ".turbo",
    "coverage",
}


def _is_whitelisted_rm_rf(command: str) -> bool:
    """
    Return True if every target of `rm -rf` in `command` is a build artifact.

    Walks tokens after `rm` (in any order of flags) and verifies each
    non-flag argument is a recognised build-artifact directory name.
    Conservative: any unknown target disables the whitelist.
    """
    # Extract the argument list after `rm`. We only check the first rm
    # occurrence; chained commands are handled separately by the caller.
    m = re.search(r"\brm\s+([^\|&;]+)", command)
    if not m:
        return False

    tokens = m.group(1).strip().split()
    targets: list[str] = []
    for tok in tokens:
        if tok.startswith("-"):
            # Flag (e.g. -rf, -r, -f, --force, -R)
            continue
        targets.append(tok)

    if not targets:
        return False

    for target in targets:
        # Strip trailing slash, quotes
        t = target.strip().strip("'\"").rstrip("/")
        # Extract the last path component (e.g. "apps/web/dist" -> "dist")
        last = t.rsplit("/", 1)[-1]
        if last not in BUILD_ARTIFACT_WHITELIST:
            return False

    return True


def check_careful(command: str) -> tuple[bool, str]:
    """
    Determine whether the command is destructive and should prompt the user.

    Returns (is_destructive, warning_reason).
    """
    # Normalize whitespace for easier matching
    cmd = command

    # rm -rf  (any order of -r/-R/-f/--recursive/--force)
    # Matches: rm -rf, rm -fr, rm -r -f, rm --recursive --force, rm -Rf
    rm_rf_patterns = [
        r"\brm\s+[^\|&;]*-[rRf]*r[rRf]*f",  # -rf, -fr, -Rf, -fR (r and f together)
        r"\brm\s+[^\|&;]*-[rRf]*f[rRf]*r",  # -fr ordering
        r"\brm\s+[^\|&;]*--recursive[^\|&;]*--force",
        r"\brm\s+[^\|&;]*--force[^\|&;]*--recursive",
        r"\brm\s+[^\|&;]*-r\b[^\|&;]*-f\b",
        r"\brm\s+[^\|&;]*-f\b[^\|&;]*-r\b",
    ]
    for pattern in rm_rf_patterns:
        if re.search(pattern, cmd):
            if _is_whitelisted_rm_rf(cmd):
                return (False, "")
            return (True, "Destructive: `rm -rf` deletes recursively. Confirm targets are correct.")

    # SQL DROP statements (DROP TABLE, DROP DATABASE, DROP SCHEMA, etc.)
    if re.search(r"\bDROP\s+(TABLE|DATABASE|SCHEMA|INDEX|VIEW|FUNCTION|TRIGGER|ROLE|USER)\b", cmd, re.IGNORECASE):
        return (True, "Destructive SQL: DROP removes objects permanently. Confirm target and environment.")

    # SQL TRUNCATE
    if re.search(r"\bTRUNCATE\s+(TABLE\s+)?\w+", cmd, re.IGNORECASE):
        return (True, "Destructive SQL: TRUNCATE empties a table. Confirm target and environment.")

    # git push --force / -f. The safer --force-with-lease only rewrites the
    # remote when our local ref still matches it, which is the recommended
    # flow in git-workflow.md when rebasing a feature branch onto main.
    # Don't prompt on --force-with-lease.
    if re.search(r"\bgit\s+push\s+[^\|&;]*(--force\b(?!-with-lease)|-f\b)", cmd):
        return (True, "History-rewriting: `git push --force` overwrites remote commits. Confirm the branch is yours.")

    # git reset --hard (any target). Even `origin/main` can discard uncommitted work.
    if re.search(r"\bgit\s+(-C\s+\S+\s+)?reset\s+--hard\b", cmd):
        return (True, "History-destroying: `git reset --hard` discards local changes and commits. Confirm no work is lost.")

    # git checkout . (discard working tree changes)
    if re.search(r"\bgit\s+checkout\s+\.(\s|$)", cmd):
        return (True, "Destructive: `git checkout .` discards all uncommitted changes. Confirm before proceeding.")

    # git restore . / git restore --staged .
    if re.search(r"\bgit\s+restore\s+(--staged\s+)?\.(\s|$)", cmd):
        return (True, "Destructive: `git restore .` discards uncommitted changes. Confirm before proceeding.")

    # git clean -f (removes untracked files)
    if re.search(r"\bgit\s+clean\s+[^\|&;]*-[fdx]*f", cmd):
        return (True, "Destructive: `git clean -f` removes untracked files. Confirm before proceeding.")

    # kubectl delete
    if re.search(r"\bkubectl\s+delete\b", cmd):
        return (True, "Destructive: `kubectl delete` removes cluster resources. Confirm target and namespace.")

    # docker rm -f / docker rmi -f
    if re.search(r"\bdocker\s+rmi?\s+[^\|&;]*-f", cmd):
        return (True, "Destructive: `docker rm/rmi -f` force-removes containers/images. Confirm before proceeding.")

    # docker system prune / docker volume prune
    if re.search(r"\bdocker\s+(system|volume|image|container|network)\s+prune\b", cmd):
        return (True, "Destructive: `docker ... prune` removes unused resources. Confirm before proceeding.")

    return (False, "")


def _is_force_push_to_main(command: str) -> bool:
    """Specifically: `git push --force[-f|--force-with-lease] ... main` (and refs that resolve to it).

    Distinct from `_is_force_push_to_branch` because main is the one branch
    a bypass-mode session should NOT be able to overwrite without an explicit
    `ALLOW_MAIN_COMMIT=1` escape hatch.
    """
    if not re.search(r"\bgit\s+push\b", command):
        return False
    if not re.search(r"(--force\b|--force-with-lease\b|\s-f\b)", command):
        return False
    # Match: git push --force origin main, git push -f origin main,
    # git push --force-with-lease origin main, git push origin +main,
    # git push origin HEAD:main, git push origin main:main, etc.
    if re.search(r"\b(origin\s+\+?main\b|main:main\b|HEAD:main\b|\s\+main\b)", command):
        return True
    # `git push --force` with no remote/refspec defaults to current branch.
    # Catch that when the current branch is main via env var, but the
    # hook can't reliably know the branch — leave that case to
    # enforce-git-workflow.py which DOES read the branch.
    return False


def main() -> None:
    data = hook_utils.read_hook_input()

    tool_name = data.get("tool_name", "")
    tool_input = data.get("tool_input", {})

    if tool_name != "Bash":
        sys.exit(0)

    command = tool_input.get("command", "")
    if not command:
        sys.exit(0)

    # Bypass-proof: force-push to main is a hard block regardless of mode.
    # `ALLOW_MAIN_COMMIT=1` opens the explicit emergency channel.
    if _is_force_push_to_main(command) and os.environ.get("ALLOW_MAIN_COMMIT") != "1":
        hook_utils.hard_block(
            "BLOCKED: force-pushing to `main` overwrites shared history. "
            "If this is truly intended (recovering from a bad merge, etc.), "
            "re-run with `ALLOW_MAIN_COMMIT=1` set."
        )

    is_destructive, reason = check_careful(command)
    if not is_destructive:
        sys.exit(0)

    # In bypass mode the user has explicitly opted out of permission noise.
    # Routine `ask` decisions are suppressed; the hard-block path above
    # still fires for genuinely dangerous ops.
    if hook_utils.is_bypass_mode(data):
        sys.exit(0)

    hook_utils.emit_decision("ask", reason)


if __name__ == "__main__":
    main()

hooks/check-freeze.py

#!/usr/bin/env python3
"""
PreToolUse hook that scope-locks Edit/Write to a frozen directory.

When a freeze is active (state file `~/.claude/freeze-dir.txt` exists and
contains a directory path), Edit and Write operations outside that directory
are denied. Use `/freeze <dir>` to set the scope and `/unfreeze` to clear it.

Paths are normalised (symlinks resolved, `..` collapsed) before the
containment check so trivial escape attempts (`../foo`, symlinked parent) are
caught POSIX-portably.

Ported to Python from gstack `freeze/bin/check-freeze.sh` to match CCGM's
hook style (JSON stdin/stdout, consistent with auto-approve-file-ops.py).
"""
from __future__ import annotations

import json
import os
import sys
from pathlib import Path

FREEZE_FILE = Path.home() / ".claude" / "freeze-dir.txt"


def read_freeze_dir() -> str | None:
    """Return the current freeze directory (absolute, resolved) or None."""
    if not FREEZE_FILE.exists():
        return None
    try:
        raw = FREEZE_FILE.read_text().strip()
    except OSError:
        return None
    if not raw:
        return None
    # Expand ~ and environment variables, then resolve.
    expanded = os.path.expandvars(os.path.expanduser(raw))
    try:
        return str(Path(expanded).resolve())
    except OSError:
        return None


def resolve_path(path: str) -> str | None:
    """
    Resolve `path` to an absolute canonical form.

    Uses Path.resolve(strict=False) so new files (Write) that do not yet
    exist on disk still get their parent hierarchy resolved and `..` collapsed.
    """
    if not path:
        return None
    expanded = os.path.expandvars(os.path.expanduser(path))
    try:
        return str(Path(expanded).resolve())
    except OSError:
        return None


def is_within(target: str, parent: str) -> bool:
    """Return True if `target` is the same path as or nested under `parent`."""
    # Ensure trailing slash semantics so "/a/b" does not match "/a/bcd".
    parent_norm = parent.rstrip(os.sep)
    target_norm = target.rstrip(os.sep)
    if target_norm == parent_norm:
        return True
    return target_norm.startswith(parent_norm + os.sep)


def main() -> None:
    try:
        input_data = json.load(sys.stdin)
    except json.JSONDecodeError:
        sys.exit(0)

    tool_name = input_data.get("tool_name", "")
    if tool_name not in ("Edit", "Write"):
        sys.exit(0)

    freeze_dir = read_freeze_dir()
    if not freeze_dir:
        # No freeze active. Fall through to default permission handling.
        sys.exit(0)

    tool_input = input_data.get("tool_input", {})
    file_path = tool_input.get("file_path", "")
    resolved = resolve_path(file_path)
    if resolved is None:
        # Could not resolve; fall through rather than block on malformed input.
        sys.exit(0)

    if is_within(resolved, freeze_dir):
        # In scope - let other hooks / default system decide.
        sys.exit(0)

    reason = (
        f"Freeze active: writes are scoped to {freeze_dir}. "
        f"{resolved} is outside the frozen directory. "
        f"Run `/unfreeze` to clear the scope, or stay within the frozen path."
    )
    output = {
        "hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "permissionDecision": "deny",
            "permissionDecisionReason": reason,
        }
    }
    print(json.dumps(output))
    sys.exit(0)


if __name__ == "__main__":
    main()

hooks/session-start-enforce.py

#!/usr/bin/env python3
"""
SessionStart hook that injects a rule-enforcement meta-instruction.

Adapted from obra/superpowers `hooks/session-start`. The idea: CCGM installs
discipline rules (TDD, systematic-debugging, verification, confusion-protocol,
etc.) to `~/.claude/rules/` where Claude Code auto-loads them, but there is no
*meta-instruction* that forces the agent to route through those rules under
pressure. This hook injects a short reminder at fresh session start so the
agent treats the Iron Laws as real gates, not background reading.

Experimental: OFF by default. Opt in by setting CCGM_RULE_ENFORCEMENT=true in
`~/.claude/.ccgm.env`. Fires only on source == "startup" so it does not fire
on resume or compaction.
"""
from __future__ import annotations

import json
import sys
from pathlib import Path

ENV_FILE = Path.home() / ".claude" / ".ccgm.env"

# The meta-instruction injected into the session. Kept short on purpose:
# long context at session start competes with the user's first prompt for
# attention. The goal is to bias routing toward loaded rules, not to restate
# them.
META_INSTRUCTION = """\
<ccgm-rule-enforcement>
Before your first response in this session, scan the loaded rules in
~/.claude/rules/ and in any CLAUDE.md files for Iron Laws (all-caps "NO X
WITHOUT Y" declarations). For any task with a plausible match, route through
the relevant rule before acting:

- Writing or modifying code -> test-driven-development (failing test first).
- Fixing a bug or unexpected behavior -> systematic-debugging (root cause before fix).
- Claiming a task is done, tests pass, or a build works -> verification (fresh evidence).
- Dispatching work to subagents -> subagent-patterns (spec + status protocol).
- Unclear requirements, contradictions, or missing context -> confusion-protocol (stop and ask).

"Violating the letter of a rule is violating the spirit." Do not negotiate
Iron Laws under time pressure, user pressure, or sunk-cost pressure. If a rule
seems to block the task, surface the conflict instead of routing around it.
</ccgm-rule-enforcement>
"""


def is_enabled() -> bool:
    """Check if rule-enforcement injection is enabled in .ccgm.env."""
    if not ENV_FILE.exists():
        return False
    try:
        with open(ENV_FILE) as f:
            for line in f:
                line = line.strip()
                if line.startswith("CCGM_RULE_ENFORCEMENT="):
                    value = line.split("=", 1)[1].strip().lower()
                    return value in ("true", "1", "yes")
    except (OSError, IOError):
        pass
    return False


def main() -> None:
    # Read stdin (required by hook contract)
    try:
        hook_input = json.load(sys.stdin)
    except (json.JSONDecodeError, EOFError):
        hook_input = {}

    # Only fire on fresh sessions, not resume or compact. Resume already has
    # the rules in-context from the prior session; compact has its own
    # (different) context-preservation mechanism.
    source = hook_input.get("source", "")
    if source != "startup":
        return

    if not is_enabled():
        return

    # Print to stdout - Claude Code injects this as additionalContext for the
    # SessionStart event.
    sys.stdout.write(META_INSTRUCTION)


if __name__ == "__main__":
    main()

hooks/sync-ccgm-canonical.py

#!/usr/bin/env python3
"""
PostToolUse:Bash hook — sync the canonical CCGM clone after a CCGM PR merges.

Why: ~/.claude/ symlinks point at one canonical CCGM checkout. When PRs merge
in workspace clones, that canonical checkout drifts unless something pulls it.
This hook removes the manual sync step.

Triggers when:
- The Bash command invokes `gh pr merge ...` in any segment (it is usually
  `cd <repo>` first, or chained with && / piped to tail -- not the first token)
- The cwd's git remote points at a repo named "ccgm" (any owner)
- The canonical clone exists at $CCGM_CANONICAL_DIR (default ~/code/ccgm)

Behavior:
- Runs `git fetch origin main && git pull --ff-only origin main` in the
  canonical clone
- Logs success/failure to stderr
- Never blocks on errors (always exit 0)
"""

from __future__ import annotations

import json
import os
import re
import subprocess
import sys


CANONICAL_DIR_ENV = "CCGM_CANONICAL_DIR"
DEFAULT_CANONICAL_DIR = os.path.expanduser("~/code/ccgm")
CCGM_REPO_NAME = "ccgm"


def get_origin_url(cwd: str) -> str | None:
    try:
        result = subprocess.run(
            ["git", "-C", cwd, "remote", "get-url", "origin"],
            capture_output=True, text=True, timeout=3, check=False,
        )
        if result.returncode == 0:
            return result.stdout.strip()
    except (subprocess.SubprocessError, OSError):
        pass
    return None


def command_triggers_merge(command: str) -> bool:
    """True if any segment of `command` invokes `gh pr merge`.

    `gh pr merge` is almost never the first token of the command string -- it is
    typically `cd <repo>` first (often on its own line) or chained with && / piped
    to tail. A start-anchored match therefore misses real merges and leaves the
    canonical clone stale (#728). Split on shell separators (newline ; | &) and
    check each segment. A false positive only costs one harmless, idempotent
    ff-only pull, so erring toward matching is safe.
    """
    for segment in re.split(r"[\n;|&]+", command):
        if re.match(r"\s*gh\s+pr\s+merge(\s|$)", segment):
            return True
    return False


def is_ccgm_repo(cwd: str) -> bool:
    url = get_origin_url(cwd)
    if not url:
        return False
    # Extract repo name from URL (last path segment, strip .git)
    repo_name = re.sub(r"\.git$", "", url.rstrip("/").rsplit("/", 1)[-1])
    return repo_name == CCGM_REPO_NAME


def sync_canonical(canonical_dir: str) -> tuple[bool, str]:
    """Pull origin/main into canonical_dir. Returns (success, message)."""
    if not os.path.isdir(os.path.join(canonical_dir, ".git")):
        return False, f"canonical dir not a git repo: {canonical_dir}"

    try:
        fetch = subprocess.run(
            ["git", "-C", canonical_dir, "fetch", "origin", "main"],
            capture_output=True, text=True, timeout=30, check=False,
        )
        if fetch.returncode != 0:
            return False, f"fetch failed: {fetch.stderr.strip()}"

        pull = subprocess.run(
            ["git", "-C", canonical_dir, "pull", "--ff-only", "origin", "main"],
            capture_output=True, text=True, timeout=30, check=False,
        )
        if pull.returncode != 0:
            return False, f"pull failed (not fast-forward?): {pull.stderr.strip()}"

        return True, pull.stdout.strip().splitlines()[-1] if pull.stdout.strip() else "up to date"
    except subprocess.TimeoutExpired:
        return False, "timeout"
    except (subprocess.SubprocessError, OSError) as e:
        return False, str(e)


def main() -> None:
    try:
        payload = json.load(sys.stdin)
    except (json.JSONDecodeError, ValueError):
        sys.exit(0)

    if payload.get("tool_name") != "Bash":
        sys.exit(0)

    command = (payload.get("tool_input") or {}).get("command", "")
    if not command_triggers_merge(command):
        sys.exit(0)

    cwd = payload.get("cwd") or os.getcwd()
    if not is_ccgm_repo(cwd):
        sys.exit(0)

    canonical_dir = os.environ.get(CANONICAL_DIR_ENV, DEFAULT_CANONICAL_DIR)
    if not os.path.isdir(canonical_dir):
        sys.stderr.write(
            f"sync-ccgm-canonical: skipped — {canonical_dir} does not exist "
            f"(set {CANONICAL_DIR_ENV} or create the dir)\n"
        )
        sys.exit(0)

    if os.path.realpath(cwd) == os.path.realpath(canonical_dir):
        sys.exit(0)

    ok, msg = sync_canonical(canonical_dir)
    prefix = "sync-ccgm-canonical"
    if ok:
        sys.stderr.write(f"{prefix}: {canonical_dir} → {msg}\n")
    else:
        sys.stderr.write(f"{prefix}: FAILED — {msg}\n")

    sys.exit(0)


if __name__ == "__main__":
    main()
lib (6)

lib/agent_tracking.py

#!/usr/bin/env python3
"""
Multi-agent issue tracking system.

Provides structured issue lifecycle tracking via a single CSV file per repo
in the agent log repository. Replaces the label-based claiming system.

Storage: ~/code/{log-repo}/{repo}/tracking.csv
Format: CSV with fields: issue,agent,status,branch,pr,epic,title,claimed_at,updated_at
Concurrency: Standard git flow (commit, pull --rebase, push). Different-row
edits auto-resolve via rebase.

Usage as CLI:
    python agent_tracking.py claim <repo> <issue> [--title "..."] [--epic N]
    python agent_tracking.py check <repo> <issue>
    python agent_tracking.py update <repo> <issue> --status <status> [--pr N]
    python agent_tracking.py release <repo> <issue>
    python agent_tracking.py list [--repo <repo>] [--status <status>] [--agent <id>]
    python agent_tracking.py gc [--days N]
    python agent_tracking.py import <repo>
    python agent_tracking.py init <repo>

Usage as import:
    from agent_tracking import claim_issue, check_claim, update_status
"""

from __future__ import annotations

import argparse
import csv
import io
import os
import subprocess
import sys
from datetime import datetime, timedelta
from pathlib import Path

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

CSV_FIELDS = [
    "issue", "agent", "status", "branch", "pr",
    "epic", "title", "claimed_at", "updated_at",
]

ACTIVE_STATUSES = {"claimed", "in-progress", "pr-created", "blocked"}
TERMINAL_STATUSES = {"merged", "closed", "released"}
ALL_STATUSES = ACTIVE_STATUSES | TERMINAL_STATUSES


def _find_log_repo() -> str:
    """Find the agent log repo directory."""
    env = os.environ.get("CLAUDE_LOG_REPO")
    if env and os.path.isdir(env):
        return env
    code_dir = os.path.expanduser("~/code")
    if os.path.isdir(code_dir):
        for entry in sorted(os.listdir(code_dir)):
            if entry.endswith("agent-logs") and os.path.isdir(os.path.join(code_dir, entry)):
                return os.path.join(code_dir, entry)
    return os.path.expanduser("~/code/agent-logs")


LOG_REPO_DIR = _find_log_repo()

# ---------------------------------------------------------------------------
# Agent identity helpers
# ---------------------------------------------------------------------------

def get_agent_id(working_dir: str | None = None) -> str:
    """Derive agent ID from the working directory or .env.clone."""
    wd = working_dir or os.getcwd()

    # Try .env.clone first
    env_clone = os.path.join(wd, ".env.clone")
    if os.path.isfile(env_clone):
        with open(env_clone) as f:
            for line in f:
                if line.startswith("AGENT_ID="):
                    return line.strip().split("=", 1)[1]

    # Workspace model: directory name ends with w{N}-c{M}
    import re
    basename = os.path.basename(wd)
    wc = re.search(r"w(\d+)-c(\d+)$", basename)
    if wc:
        return f"agent-w{wc.group(1)}-c{wc.group(2)}"

    # Flat clone model: directory name ends with -{N}
    num = re.search(r"-(\d+)$", basename)
    if num:
        return f"agent-{num.group(1)}"

    return "agent-0"


def get_repo_name(working_dir: str | None = None) -> str | None:
    """Derive repo name from git remote origin URL."""
    wd = working_dir or os.getcwd()
    try:
        result = subprocess.run(
            ["git", "remote", "get-url", "origin"],
            capture_output=True, text=True, timeout=5, cwd=wd,
        )
        if result.returncode == 0:
            url = result.stdout.strip()
            name = os.path.basename(url)
            if name.endswith(".git"):
                name = name[:-4]
            return name
    except Exception:
        pass
    return None


def is_multi_clone_repo(working_dir: str | None = None) -> bool:
    """Check if the current directory is a multi-clone repo (has .env.clone)."""
    wd = working_dir or os.getcwd()
    return os.path.isfile(os.path.join(wd, ".env.clone"))


# ---------------------------------------------------------------------------
# CSV helpers
# ---------------------------------------------------------------------------

def get_tracking_path(repo: str) -> str:
    """Return the path to tracking.csv for a repo."""
    return os.path.join(LOG_REPO_DIR, repo, "tracking.csv")


def read_tracking(repo: str) -> list[dict[str, str]]:
    """Read tracking.csv and return a list of dicts."""
    path = get_tracking_path(repo)
    if not os.path.isfile(path):
        return []
    rows = []
    with open(path, newline="") as f:
        reader = csv.DictReader(f)
        for row in reader:
            rows.append(row)
    return rows


def write_tracking(repo: str, rows: list[dict[str, str]]) -> None:
    """Write a list of dicts to tracking.csv."""
    path = get_tracking_path(repo)
    os.makedirs(os.path.dirname(path), exist_ok=True)
    with open(path, "w", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=CSV_FIELDS, quoting=csv.QUOTE_MINIMAL)
        writer.writeheader()
        for row in rows:
            writer.writerow(row)


def now_iso() -> str:
    """Return current local time as ISO 8601 string (minute precision)."""
    return datetime.now().strftime("%Y-%m-%dT%H:%M")


# ---------------------------------------------------------------------------
# Git operations on the log repo
# ---------------------------------------------------------------------------

def commit_and_push(agent_id: str, message: str) -> bool:
    """Commit tracking changes to the log repo and push.

    Uses standard git flow: add, commit, pull --rebase, push.
    Returns True on success, False on failure (non-blocking).
    """
    try:
        cmds = [
            ["git", "add", "-A"],
            ["git", "commit", "-m", f"{agent_id}: {message}"],
            ["git", "pull", "--rebase"],
            ["git", "push"],
        ]
        for cmd in cmds:
            result = subprocess.run(
                cmd, capture_output=True, text=True, timeout=30,
                cwd=LOG_REPO_DIR,
            )
            # git commit returns 1 if nothing to commit - that's ok
            if result.returncode != 0 and cmd[1] != "commit":
                sys.stderr.write(
                    f"WARNING: tracking git {cmd[1]} failed: {result.stderr.strip()}\n"
                )
                return False
        return True
    except Exception as e:
        sys.stderr.write(f"WARNING: tracking commit/push failed: {e}\n")
        return False


# ---------------------------------------------------------------------------
# Core operations
# ---------------------------------------------------------------------------

def claim_issue(repo: str, issue: int | str, agent_id: str | None = None, title: str = "", epic: int | str = "", branch: str = "") -> tuple[bool, str]:
    """Claim an issue. Returns (success: bool, message: str)."""
    agent_id = agent_id or get_agent_id()
    issue = str(issue)

    rows = read_tracking(repo)

    # Check for existing active claim on this issue
    for row in rows:
        if row["issue"] == issue and row["status"] in ACTIVE_STATUSES:
            if row["agent"] == agent_id:
                return False, f"You already have issue #{issue} claimed"
            return False, f"Issue #{issue} is already claimed by {row['agent']}"

    # Add claim
    rows.append({
        "issue": issue,
        "agent": agent_id,
        "status": "claimed",
        "branch": branch,
        "pr": "",
        "epic": str(epic) if epic else "",
        "title": title,
        "claimed_at": now_iso(),
        "updated_at": now_iso(),
    })

    write_tracking(repo, rows)
    commit_and_push(agent_id, f"claim #{issue}")
    return True, f"Claimed issue #{issue}"


def update_status(repo: str, issue: int | str, status: str, agent_id: str | None = None, pr: int | str | None = None, branch: str | None = None) -> tuple[bool, str]:
    """Update the status of a claimed issue. Returns (success, message)."""
    agent_id = agent_id or get_agent_id()
    issue = str(issue)

    if status not in ALL_STATUSES:
        return False, f"Invalid status: {status}. Valid: {', '.join(sorted(ALL_STATUSES))}"

    rows = read_tracking(repo)
    updated = False

    for row in rows:
        if row["issue"] == issue and row["agent"] == agent_id and row["status"] in ACTIVE_STATUSES:
            row["status"] = status
            row["updated_at"] = now_iso()
            if pr is not None:
                row["pr"] = str(pr)
            if branch is not None:
                row["branch"] = branch
            updated = True
            break

    if not updated:
        return False, f"No active claim found for issue #{issue} by {agent_id}"

    write_tracking(repo, rows)
    commit_and_push(agent_id, f"update #{issue} -> {status}")
    return True, f"Updated issue #{issue} to {status}"


def update_heartbeat(repo: str, issue: int | str, agent_id: str | None = None, throttle_minutes: int = 30) -> tuple[bool, str]:
    """Update the heartbeat (updated_at) for an issue, throttled.

    Only updates if the current updated_at is older than throttle_minutes.
    Returns (updated: bool, message: str).
    """
    agent_id = agent_id or get_agent_id()
    issue = str(issue)

    rows = read_tracking(repo)
    threshold = datetime.now() - timedelta(minutes=throttle_minutes)

    for row in rows:
        if row["issue"] == issue and row["agent"] == agent_id and row["status"] in ACTIVE_STATUSES:
            try:
                last_updated = datetime.strptime(row["updated_at"], "%Y-%m-%dT%H:%M")
                if last_updated > threshold:
                    return False, "Heartbeat throttled (too recent)"
            except ValueError:
                pass  # Can't parse, just update

            row["updated_at"] = now_iso()
            write_tracking(repo, rows)
            # Don't commit/push for heartbeats - they'll be included in the next
            # regular log repo commit to avoid excessive pushes
            return True, f"Heartbeat updated for #{issue}"

    return False, f"No active claim found for issue #{issue}"


def release_issue(repo: str, issue: int | str, agent_id: str | None = None) -> tuple[bool, str]:
    """Release a claimed issue. Returns (success, message)."""
    return update_status(repo, issue, "released", agent_id)


def check_claim(repo: str, issue: int | str) -> tuple[str | None, str | None]:
    """Check if an issue is claimed. Returns (agent_id, status) or (None, None)."""
    issue = str(issue)
    rows = read_tracking(repo)
    for row in rows:
        if row["issue"] == issue and row["status"] in ACTIVE_STATUSES:
            return row["agent"], row["status"]
    return None, None


def list_claims(repo: str | None = None, status: str | None = None, agent: str | None = None) -> list[dict[str, str]]:
    """List claims, optionally filtered. Returns list of dicts."""
    results: list[dict[str, str]] = []

    if repo:
        repos = [repo]
    else:
        # Find all repos with tracking.csv
        repos = []
        if os.path.isdir(LOG_REPO_DIR):
            for entry in sorted(os.listdir(LOG_REPO_DIR)):
                tracking = os.path.join(LOG_REPO_DIR, entry, "tracking.csv")
                if os.path.isfile(tracking):
                    repos.append(entry)

    for r in repos:
        rows = read_tracking(r)
        for row in rows:
            if status and row["status"] != status:
                continue
            if agent and row["agent"] != agent:
                continue
            row["_repo"] = r
            results.append(row)

    return results


def gc_stale(repo: str | None = None, days: int = 1) -> list[dict]:
    """Find stale claims (active status + old updated_at). Returns list of stale rows."""
    threshold = datetime.now() - timedelta(days=days)
    stale = []

    claims = list_claims(repo=repo)
    for row in claims:
        if row["status"] not in ACTIVE_STATUSES:
            continue
        try:
            updated = datetime.strptime(row["updated_at"], "%Y-%m-%dT%H:%M")
            if updated < threshold:
                row["_stale_hours"] = int((datetime.now() - updated).total_seconds() / 3600)
                stale.append(row)
        except ValueError:
            # Can't parse date - consider it stale
            row["_stale_hours"] = "unknown"
            stale.append(row)

    return stale


def import_from_labels(repo: str, agent_id: str | None = None) -> tuple[int, str]:
    """Import existing GitHub issues with agent-* labels into tracking.csv.

    Scans for open issues with in-progress or agent-* labels and creates
    tracking entries for them.
    """
    agent_id = agent_id or get_agent_id()
    imported = 0

    try:
        # Get all open issues with labels
        result = subprocess.run(
            ["gh", "issue", "list", "--state", "open", "--limit", "100",
             "--json", "number,title,labels"],
            capture_output=True, text=True, timeout=30,
        )
        if result.returncode != 0:
            return 0, f"Failed to list issues: {result.stderr.strip()}"

        import json
        issues = json.loads(result.stdout)
        rows = read_tracking(repo)
        existing_issues = {row["issue"] for row in rows if row["status"] in ACTIVE_STATUSES}

        for issue in issues:
            issue_num = str(issue["number"])
            if issue_num in existing_issues:
                continue

            labels = [l["name"] for l in issue.get("labels", [])]
            # Find agent label
            agent_label = None
            for label in labels:
                if label.startswith("agent-"):
                    agent_label = label
                    break

            if agent_label or "in-progress" in labels:
                status = "in-progress" if "in-progress" in labels else "claimed"
                rows.append({
                    "issue": issue_num,
                    "agent": agent_label or agent_id,
                    "status": status,
                    "branch": "",
                    "pr": "",
                    "epic": "",
                    "title": issue["title"],
                    "claimed_at": now_iso(),
                    "updated_at": now_iso(),
                })
                imported += 1

        if imported > 0:
            write_tracking(repo, rows)
            commit_and_push(agent_id, f"import {imported} issues from labels")

        return imported, f"Imported {imported} issues"

    except Exception as e:
        return 0, f"Import failed: {e}"


def init_tracking(repo: str) -> tuple[bool, str]:
    """Create tracking.csv with header row for a repo."""
    path = get_tracking_path(repo)
    if os.path.isfile(path):
        return False, f"tracking.csv already exists for {repo}"

    os.makedirs(os.path.dirname(path), exist_ok=True)
    with open(path, "w", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=CSV_FIELDS, quoting=csv.QUOTE_MINIMAL)
        writer.writeheader()

    return True, f"Created tracking.csv for {repo}"


# ---------------------------------------------------------------------------
# Display helpers
# ---------------------------------------------------------------------------

def format_claims_table(claims: list[dict[str, str]], show_repo: bool = False) -> str:
    """Format claims as a readable table string."""
    if not claims:
        return "  (no claims)"

    lines = []
    if show_repo:
        header = f"  {'Repo':<20} {'Issue':>6} {'Agent':<16} {'Status':<14} {'Branch':<30} {'PR':>4} {'Updated'}"
        lines.append(header)
        lines.append("  " + "-" * (len(header) - 2))
        for c in claims:
            repo_name = c.get("_repo", "")
            lines.append(
                f"  {repo_name:<20} #{c['issue']:>5} {c['agent']:<16} {c['status']:<14} "
                f"{c.get('branch', ''):<30} {c.get('pr', ''):>4} {c.get('updated_at', '')}"
            )
    else:
        header = f"  {'Issue':>6} {'Agent':<16} {'Status':<14} {'Branch':<30} {'PR':>4} {'Updated'}"
        lines.append(header)
        lines.append("  " + "-" * (len(header) - 2))
        for c in claims:
            lines.append(
                f"  #{c['issue']:>5} {c['agent']:<16} {c['status']:<14} "
                f"{c.get('branch', ''):<30} {c.get('pr', ''):>4} {c.get('updated_at', '')}"
            )

    return "\n".join(lines)


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------

def main() -> None:
    parser = argparse.ArgumentParser(
        description="Multi-agent issue tracking",
        prog="agent-tracking",
    )
    sub = parser.add_subparsers(dest="command", required=True)

    # claim
    p = sub.add_parser("claim", help="Claim an issue")
    p.add_argument("repo", help="Repo name (e.g., darkly-suite)")
    p.add_argument("issue", type=int, help="Issue number")
    p.add_argument("--title", default="", help="Issue title")
    p.add_argument("--epic", type=int, default=None, help="Parent epic number")
    p.add_argument("--branch", default="", help="Branch name")

    # update
    p = sub.add_parser("update", help="Update issue status")
    p.add_argument("repo", help="Repo name")
    p.add_argument("issue", type=int, help="Issue number")
    p.add_argument("--status", required=True, help="New status")
    p.add_argument("--pr", type=int, default=None, help="PR number")
    p.add_argument("--branch", default=None, help="Branch name")

    # release
    p = sub.add_parser("release", help="Release an issue")
    p.add_argument("repo", help="Repo name")
    p.add_argument("issue", type=int, help="Issue number")

    # check
    p = sub.add_parser("check", help="Check if an issue is claimed")
    p.add_argument("repo", help="Repo name")
    p.add_argument("issue", type=int, help="Issue number")

    # list
    p = sub.add_parser("list", help="List claims")
    p.add_argument("--repo", default=None, help="Filter by repo")
    p.add_argument("--status", default=None, help="Filter by status")
    p.add_argument("--agent", default=None, help="Filter by agent")

    # gc
    p = sub.add_parser("gc", help="Find stale claims")
    p.add_argument("--repo", default=None, help="Filter by repo")
    p.add_argument("--days", type=int, default=1, help="Stale threshold in days")

    # import
    p = sub.add_parser("import", help="Import issues from GitHub labels")
    p.add_argument("repo", help="Repo name")

    # init
    p = sub.add_parser("init", help="Initialize tracking.csv for a repo")
    p.add_argument("repo", help="Repo name")

    args = parser.parse_args()

    # Defensive: empty-string --repo is almost always a caller bug (e.g., REPO_NAME
    # failed to derive in a workspace coordinator dir). Without this guard, list/gc
    # would silently fall through to scanning ALL repos and dump unrelated noise.
    if getattr(args, "repo", None) == "":
        print("Error: --repo cannot be empty (omit --repo to scan all repos intentionally)",
              file=sys.stderr)
        sys.exit(2)

    if args.command == "claim":
        ok, msg = claim_issue(args.repo, args.issue, title=args.title,
                              epic=args.epic or "", branch=args.branch)
        print(msg)
        sys.exit(0 if ok else 1)

    elif args.command == "update":
        ok, msg = update_status(args.repo, args.issue, args.status,
                                pr=args.pr, branch=args.branch)
        print(msg)
        sys.exit(0 if ok else 1)

    elif args.command == "release":
        ok, msg = release_issue(args.repo, args.issue)
        print(msg)
        sys.exit(0 if ok else 1)

    elif args.command == "check":
        agent, status = check_claim(args.repo, args.issue)
        if agent:
            print(f"Issue #{args.issue} is claimed by {agent} (status: {status})")
        else:
            print(f"Issue #{args.issue} is unclaimed")

    elif args.command == "list":
        claims = list_claims(repo=args.repo, status=args.status, agent=args.agent)
        show_repo = args.repo is None
        print(format_claims_table(claims, show_repo=show_repo))

    elif args.command == "gc":
        stale = gc_stale(repo=args.repo, days=args.days)
        if stale:
            print(f"Found {len(stale)} stale claims:")
            for s in stale:
                repo_name = s.get("_repo", "")
                hours = s.get("_stale_hours", "?")
                print(f"  WARNING: {s['agent']} has stale claim on #{s['issue']} "
                      f"in {repo_name} (status: {s['status']}, stale for {hours}h)")
        else:
            print("No stale claims found")

    elif args.command == "import":
        count, msg = import_from_labels(args.repo)
        print(msg)
        sys.exit(0 if count >= 0 else 1)

    elif args.command == "init":
        ok, msg = init_tracking(args.repo)
        print(msg)
        sys.exit(0 if ok else 1)


if __name__ == "__main__":
    main()

lib/agent_sessions.py

#!/usr/bin/env python3
"""
Discover active Claude Code CLI sessions on this machine.

Uses ps + lsof + git to find all running claude CLI sessions with their
working directory, repo, and branch context. No files, no daemons - just
reads live OS state. Process exit = session gone, no stale data possible.

Usage as library:
    from agent_sessions import get_active_sessions
    sessions = get_active_sessions()
    # [{pid, tty, uptime, cwd, repo, branch, agent_id}, ...]

Usage as CLI:
    python3 agent_sessions.py          # JSON output
    python3 agent_sessions.py --text   # Human-readable table
    python3 agent_sessions.py --repo habitpro-ai  # Filter by repo
"""

from __future__ import annotations

import json
import os
import re
import subprocess
import sys
from pathlib import Path


# ---------------------------------------------------------------------------
# Core discovery
# ---------------------------------------------------------------------------

def _run(cmd: list[str], cwd: str | None = None, timeout: int = 5) -> str:
    """Run a command, return stdout or empty string on failure."""
    try:
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=timeout,
            cwd=cwd,
        )
        return result.stdout.strip() if result.returncode == 0 else ""
    except Exception:
        return ""


def _get_claude_pids() -> list[tuple[int, str, str]]:
    """
    Return list of (pid, tty, etime) for running claude CLI processes.

    Filters out Claude Desktop (Electron app) and helper processes.
    Matches only the bare 'claude' command or 'claude /startup' etc.
    """
    ps_out = _run(["ps", "-eo", "pid,tty,etime,command"])
    pids: list[tuple[int, str, str]] = []
    for line in ps_out.splitlines():
        # Match lines where the command is exactly 'claude' (with optional args)
        # Exclude: Claude.app, Claude Helper, grep, python running this script
        m = re.search(r"^\s*(\d+)\s+(\S+)\s+(\S+)\s+claude(\s|$)", line)
        if not m:
            continue
        full_line = line.strip()
        # Exclude Electron app and helpers
        if any(skip in full_line for skip in ["Claude.app", "Claude Helper", "Claude.framework"]):
            continue
        pid, tty, etime = m.group(1), m.group(2), m.group(3)
        pids.append((int(pid), tty, etime))
    return pids


def _get_cwd(pid: int) -> str | None:
    """Get the current working directory of a process via lsof."""
    out = _run(["lsof", "-a", "-p", str(pid), "-d", "cwd", "-Fn"], timeout=5)
    for line in out.splitlines():
        if line.startswith("n"):
            path = line[1:]
            if os.path.isdir(path):
                return path
    return None


def _get_git_context(cwd: str) -> tuple[str | None, str | None]:
    """Return (repo_name, branch) for a directory, or (None, None)."""
    branch = _run(["git", "-C", cwd, "branch", "--show-current"])
    if not branch:
        return None, None
    remote_url = _run(["git", "-C", cwd, "remote", "get-url", "origin"])
    repo_name = os.path.basename(remote_url) if remote_url else None
    if repo_name and repo_name.endswith(".git"):
        repo_name = repo_name[:-4]
    repo = repo_name
    return repo, branch


def _get_tmux_pane_pids() -> dict[int, str]:
    """Return {pane_pid: session_name} for all tmux panes, or {} if tmux unavailable."""
    out = _run(["tmux", "list-panes", "-a", "-F", "#{pane_pid} #{session_name}"])
    panes: dict[int, str] = {}
    for line in out.splitlines():
        parts = line.split(None, 1)
        if len(parts) == 2 and parts[0].isdigit():
            panes[int(parts[0])] = parts[1]
    return panes


def _get_tmux_attached_sessions() -> set[str]:
    """Return set of tmux session names with at least one attached client."""
    out = _run(["tmux", "list-clients", "-F", "#{session_name}"])
    return {line.strip() for line in out.splitlines() if line.strip()}


def _get_ppid_map() -> dict[int, int]:
    """Return {pid: ppid} for all running processes."""
    out = _run(["ps", "-eo", "pid,ppid"])
    ppid_map: dict[int, int] = {}
    for line in out.splitlines()[1:]:
        parts = line.split()
        if len(parts) >= 2 and parts[0].isdigit() and parts[1].isdigit():
            ppid_map[int(parts[0])] = int(parts[1])
    return ppid_map


def _find_tmux_session(
    pid: int,
    ppid_map: dict[int, int],
    pane_pids: dict[int, str],
) -> str | None:
    """Walk parent chain from pid; return tmux session name if an ancestor is a tmux pane."""
    cur = pid
    for _ in range(32):
        if cur in pane_pids:
            return pane_pids[cur]
        parent = ppid_map.get(cur)
        if parent is None or parent <= 1 or parent == cur:
            return None
        cur = parent
    return None


def _get_agent_id(cwd: str) -> str | None:
    """
    Derive agent identity from .env.clone or directory name pattern.

    Workspace model: habitpro-ai-w1-c2 -> agent-w1-c2
    Flat clone model: habitpro-ai-3 -> agent-3
    """
    env_clone = os.path.join(cwd, ".env.clone")
    if os.path.isfile(env_clone):
        try:
            with open(env_clone) as f:
                for line in f:
                    if line.startswith("AGENT_ID="):
                        return line.strip().split("=", 1)[1]
        except Exception:
            pass

    dirname = os.path.basename(cwd)
    # Workspace model pattern: *-wN-cN
    m = re.search(r"w\d+-c\d+$", dirname)
    if m:
        return f"agent-{m.group()}"
    # Flat clone model pattern: *-N (trailing number)
    m = re.search(r"-(\d+)$", dirname)
    if m:
        return f"agent-{m.group(1)}"
    return None


def get_active_sessions(repo_filter: str | None = None, exclude_cwd: str | None = None) -> list[dict]:
    """
    Return list of active Claude Code CLI sessions on this machine.

    Args:
        repo_filter: If set, only return sessions for this repo name.
        exclude_cwd: If set, exclude the session at this working directory
                     (used to exclude the current session from sibling lists).

    Returns:
        List of dicts:
        {
            "pid":      int,    # Process ID
            "tty":      str,    # Terminal (e.g. ttys003, ?? for background)
            "uptime":   str,    # Elapsed time (e.g. "01-02:30:45", "15:30")
            "cwd":      str,    # Working directory
            "repo":       str,    # Git repo name (or None)
            "branch":     str,    # Current git branch (or None)
            "agent_id":   str,    # Derived agent ID (or None)
            "tmux_state": str,    # "attached", "detached", or None (not in tmux)
        }
    """
    sessions: list[dict] = []
    my_cwd = os.path.realpath(exclude_cwd) if exclude_cwd else None

    pane_pids = _get_tmux_pane_pids()
    attached_sessions = _get_tmux_attached_sessions() if pane_pids else set()
    ppid_map = _get_ppid_map() if pane_pids else {}

    for pid, tty, etime in _get_claude_pids():
        cwd = _get_cwd(pid)
        if not cwd:
            continue

        # Normalize for comparison
        real_cwd = os.path.realpath(cwd)
        if my_cwd and real_cwd == my_cwd:
            continue  # Skip current session

        repo, branch = _get_git_context(cwd)

        if repo_filter and repo != repo_filter:
            continue

        tmux_session = _find_tmux_session(pid, ppid_map, pane_pids) if pane_pids else None
        if tmux_session is None:
            tmux_state: str | None = None
        elif tmux_session in attached_sessions:
            tmux_state = "attached"
        else:
            tmux_state = "detached"

        sessions.append({
            "pid":        pid,
            "tty":        tty,
            "uptime":     etime,
            "cwd":        cwd,
            "repo":       repo,
            "branch":     branch,
            "agent_id":   _get_agent_id(cwd),
            "tmux_state": tmux_state,
        })

    return sessions


# ---------------------------------------------------------------------------
# Formatting helpers
# ---------------------------------------------------------------------------

def format_sessions_text(sessions: list[dict], header: bool = True) -> str:
    """Format sessions as a human-readable table for dashboard display."""
    if not sessions:
        return "  (none)"

    lines = []
    for s in sessions:
        pid_str = str(s["pid"])
        repo = s["repo"] or "(no repo)"
        branch = s["branch"] or "(no branch)"
        agent = s["agent_id"] or "(unknown)"
        uptime = s["uptime"]
        tty = s["tty"]
        cwd = s["cwd"]

        tmux_state = s.get("tmux_state")
        suffix = f"  [tmux:{tmux_state}]" if tmux_state else ""

        if s["repo"]:
            lines.append(
                f"  PID {pid_str:6} | {repo:25} | branch: {branch:30} | "
                f"up: {uptime:12} | {tty}{suffix}"
            )
        else:
            lines.append(
                f"  PID {pid_str:6} | (no repo) {cwd:40} | up: {uptime:12} | {tty}{suffix}"
            )
    return "\n".join(lines)


def sessions_by_repo(sessions: list[dict]) -> dict[str, list[dict]]:
    """Group sessions by repo name. Returns {repo: [sessions]}."""
    grouped = {}
    for s in sessions:
        key = s["repo"] or "(no repo)"
        grouped.setdefault(key, []).append(s)
    return grouped


# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------

def main() -> None:
    import argparse

    parser = argparse.ArgumentParser(
        description="List active Claude Code CLI sessions on this machine."
    )
    parser.add_argument("--text", action="store_true", help="Human-readable output")
    parser.add_argument("--repo", help="Filter by repo name")
    parser.add_argument("--exclude-cwd", help="Exclude session at this CWD")
    args = parser.parse_args()

    sessions = get_active_sessions(
        repo_filter=args.repo,
        exclude_cwd=args.exclude_cwd,
    )

    if args.text:
        print(format_sessions_text(sessions))
    else:
        print(json.dumps(sessions, indent=2))


if __name__ == "__main__":
    main()

lib/hook_utils.py

"""Shared utilities for CCGM Claude Code hooks.

Locked API (referenced from plan.md §5 Epic 1 and §3 architecture):

    read_hook_input() -> dict
    permission_mode(data: dict) -> str
    is_bypass_mode(data: dict) -> bool
    emit_decision(decision: str, reason: str) -> None
    hard_block(reason: str) -> NoReturn
    redact_secrets(text: str) -> str
    file_locked_append(path: str, data: str) -> None
    load_repo_config(cwd: str) -> dict

The module is installed at ~/.claude/lib/hook_utils.py by CCGM's installer.
Hooks import it via `sys.path.insert(0, os.path.expanduser("~/.claude/lib"))`
followed by `import hook_utils`.
"""
from __future__ import annotations

import fcntl
import json
import os
import re
import sys
from typing import NoReturn

__all__ = [
    "read_hook_input",
    "permission_mode",
    "is_bypass_mode",
    "emit_decision",
    "hard_block",
    "redact_secrets",
    "file_locked_append",
    "load_repo_config",
    "BYPASS_MODES",
    "SECRET_PATTERNS",
]


BYPASS_MODES = frozenset({"bypassPermissions", "dontAsk", "auto"})


def read_hook_input() -> dict:
    """Read and parse JSON from stdin. Returns {} on parse failure.

    Hooks that need stdin in a strict mode should check the return value
    themselves; this helper never raises so that a malformed payload
    cannot wedge an enforcement hook into an error state.
    """
    try:
        return json.load(sys.stdin)
    except (json.JSONDecodeError, ValueError, EOFError):
        return {}


def permission_mode(data: dict) -> str:
    """Return the permission_mode field from hook stdin, or 'default'.

    Claude Code passes one of: 'default', 'acceptEdits', 'plan',
    'bypassPermissions', 'dontAsk', 'auto'. Older clients may omit it.
    """
    mode = data.get("permission_mode")
    if isinstance(mode, str) and mode:
        return mode
    return "default"


def is_bypass_mode(data: dict) -> bool:
    """True iff the session is in a bypass-permission mode.

    Treats bypassPermissions, dontAsk, and auto as bypass. Conservative
    default: any unknown / missing mode is treated as NON-bypass so a
    suppressible safety check still fires when in doubt.
    """
    return permission_mode(data) in BYPASS_MODES


def emit_decision(decision: str, reason: str) -> None:
    """Emit a PreToolUse JSON decision and exit 0.

    `decision` must be one of 'allow', 'deny', 'ask'. The hook process
    terminates after this call — callers should not assume control
    returns.
    """
    payload = {
        "hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "permissionDecision": decision,
            "permissionDecisionReason": reason,
        }
    }
    json.dump(payload, sys.stdout)
    sys.stdout.write("\n")
    sys.stdout.flush()
    sys.exit(0)


def hard_block(reason: str) -> NoReturn:
    """Bypass-proof hard block. Writes reason to stderr and exits 2.

    GitHub issue #39344: `permissionDecision: 'ask'` from any PreToolUse
    hook silently overrides declarative deny rules. The only mechanism
    that survives bypass mode is `exit 2`, which Claude Code treats as a
    hard block regardless of permission_mode.

    Use this for data-integrity invariants and protected-branch
    enforcement that MUST hold even in bypass sessions.
    """
    sys.stderr.write(reason.rstrip() + "\n")
    sys.stderr.flush()
    sys.exit(2)


# 17 secret patterns. Order matters only insofar as longer / more specific
# patterns come first so they win when a substring would match multiple.
# Each entry is (name, compiled regex). The replacement is always
# "[REDACTED:{name}]" so downstream readers can identify the kind without
# re-scanning. Patterns target prefix shapes published by each vendor.
_SECRET_PATTERN_SOURCES: list[tuple[str, str]] = [
    # Anthropic API keys (legacy and api03-prefixed).
    ("anthropic", r"sk-ant-(?:api03-)?[A-Za-z0-9_\-]{32,}"),
    # Stripe live/test secret keys.
    ("stripe_live", r"sk_live_[A-Za-z0-9]{16,}"),
    ("stripe_test", r"sk_test_[A-Za-z0-9]{16,}"),
    # GitHub token families (PAT, OAuth, user-to-server, server-to-server, refresh).
    ("github_pat", r"ghp_[A-Za-z0-9]{30,}"),
    ("github_oauth", r"gho_[A-Za-z0-9]{30,}"),
    ("github_u2s", r"ghu_[A-Za-z0-9]{30,}"),
    ("github_s2s", r"ghs_[A-Za-z0-9]{30,}"),
    ("github_refresh", r"ghr_[A-Za-z0-9]{30,}"),
    # AWS access key.
    ("aws_access_key", r"AKIA[0-9A-Z]{16}"),
    # Google API key.
    ("google_api", r"AIza[0-9A-Za-z_\-]{35}"),
    # Slack tokens.
    ("slack", r"xox[abprs]-[0-9A-Za-z\-]{10,}"),
    # Resend.
    ("resend", r"re_[A-Za-z0-9]{8,}_[A-Za-z0-9]{16,}"),
    # Supabase service-role and publishable keys (modern prefixed shape).
    ("supabase", r"sb_(?:secret|publishable)_[A-Za-z0-9]{20,}"),
    # OpenAI (generic sk- form, after Anthropic and Stripe have eaten theirs).
    ("openai", r"sk-(?!ant-)(?!live_)(?!test_)[A-Za-z0-9]{32,}"),
    # Authorization: Bearer ... header (covers most generic bearer tokens).
    ("authorization_bearer", r"(?i)authorization\s*:\s*bearer\s+[A-Za-z0-9._\-]+"),
    # env-var style KV assignments naming common secret keys.
    (
        "env_var_kv",
        r"(?i)\b(?:api[_-]?key|api[_-]?secret|access[_-]?token|auth[_-]?token|"
        r"client[_-]?secret|secret[_-]?key|password|passwd)\s*[=:]\s*['\"]?[A-Za-z0-9_\-/+=.]{12,}",
    ),
    # CLI password flag (--password / -p with a value following).
    ("password_flag", r"(?<!\S)(?:--password|--passwd|-p)[=\s]+\S{6,}"),
]

# Compile once at import.
SECRET_PATTERNS: list[tuple[str, "re.Pattern[str]"]] = [
    (name, re.compile(pat)) for name, pat in _SECRET_PATTERN_SOURCES
]


def redact_secrets(text: str) -> str:
    """Replace any secret-shaped token in `text` with [REDACTED:{kind}].

    Applied to event log entries BEFORE truncation so the truncation
    point can never lop a redaction marker in half. Conservative: a
    pattern with broad-ish shape (env_var_kv) is fine to over-fire —
    false positives in logs cost much less than leaked secrets.
    """
    if not text:
        return text
    out = text
    for name, regex in SECRET_PATTERNS:
        out = regex.sub(f"[REDACTED:{name}]", out)
    return out


def file_locked_append(path: str, data: str) -> None:
    """Append `data` (with trailing newline) to `path`, fcntl-locked.

    Cross-clone-safe: multiple Claude Code agents in different repo
    clones may race on the same `~/.claude/autoheal/events/*.jsonl`
    path. fcntl.flock(LOCK_EX) on the open file descriptor serializes
    appends so the JSONL never truncates mid-record.

    POSIX-portable. macOS + Linux supported. Creates parent dir if
    missing. Never raises on lock contention — blocks until acquired.
    """
    payload = data if data.endswith("\n") else data + "\n"
    parent = os.path.dirname(path)
    if parent:
        os.makedirs(parent, exist_ok=True)
    # Open with O_APPEND so writes always land at end-of-file even
    # under contention. Combined with flock(LOCK_EX), this is the
    # standard POSIX recipe for atomic concurrent append.
    fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
    try:
        fcntl.flock(fd, fcntl.LOCK_EX)
        try:
            os.write(fd, payload.encode("utf-8"))
        finally:
            fcntl.flock(fd, fcntl.LOCK_UN)
    finally:
        os.close(fd)


def load_repo_config(cwd: str | None = None) -> dict:
    """Walk up from cwd looking for `.autoheal/config.json`. Return merged config.

    The walk stops at the first repo root that contains the file or at
    the filesystem root. Returns {} when no config is found.

    Schema (validated lightly here; full schema in
    `modules/autoheal/lib/repo-config-schema.json` for Epic 12):

        {
          "additional_allow_patterns": [str, ...],
          "calibration_days": int,
          "thresholds": {"confidence_min": int, "occurrence_min": int},
          "kind_filters": [str, ...]
        }

    Unknown top-level keys are preserved (forward-compat) but only the
    documented fields are consumed by downstream code.
    """
    here = os.path.abspath(cwd or os.getcwd())
    seen: set[str] = set()
    while here and here not in seen:
        seen.add(here)
        candidate = os.path.join(here, ".autoheal", "config.json")
        if os.path.isfile(candidate):
            try:
                with open(candidate, "r", encoding="utf-8") as fh:
                    cfg = json.load(fh)
                if isinstance(cfg, dict):
                    return cfg
            except (OSError, json.JSONDecodeError):
                # Treat a malformed repo config as if it did not exist;
                # a hook is not the right place to fail loudly on user
                # JSON typos.
                return {}
        parent = os.path.dirname(here)
        if parent == here:
            break
        here = parent
    return {}

lib/hook_dispatcher.py

"""In-process hook composition dispatcher with declarative precedence.

Problem this solves
-------------------
Today every Claude Code event fans out into one OS process per registered
hook script (see modules/*/settings.partial.json). A single PreToolUse:Bash
tool call spawns six python interpreters in sequence, each re-importing
hook_utils, each re-parsing stdin. There is also no explicit, testable
ordering contract: precedence is an emergent property of the array order in
settings.json across several modules that do not know about each other.

This module provides a *backward-compatible* alternative: a single dispatcher
per event that runs a DECLARATIVE manifest of checks in-process, by priority,
with an explicit precedence resolution that exactly mirrors the behavior the
separate-process chain produces today:

    hard_block (exit 2)  >  deny  >  allow  >  ask  >  (advisory / pass)

Precedence guarantees (the safety contract — preserved bit-for-bit):

  * The FIRST hard_block wins and is emitted via hook_utils.hard_block()
    (exit 2). This is the only signal Claude Code honors regardless of
    permission_mode (GitHub issue #39344), so it survives bypass mode.
  * A `deny` beats any `allow`. If any check denies and none hard-block,
    the dispatcher emits deny.
  * An `allow` is emitted only if some check allows and nothing denies or
    hard-blocks.
  * `ask` is the weakest decision; it is emitted only if nothing above it
    fired.
  * Advisory output (stderr warnings) NEVER affects the decision and is
    flushed for every matching check whose result carries it, regardless
    of which decision ultimately wins.
  * Bypass-mode short-circuit: a check may declare `runs_in_bypass=False`.
    Such checks are skipped when hook_utils.is_bypass_mode() is true. Checks
    that must survive bypass (the curated destructive set, data-integrity
    hard blocks, protected-branch enforcement) declare `runs_in_bypass=True`
    and run ABOVE the short-circuit — exactly the current arrangement in
    auto-approve-bash.py and check-careful.py.

The dispatcher does NOT replace hook_utils; it composes its primitives.
redact_secrets-before-truncation, fcntl file locking, and bypass detection
all continue to live in hook_utils and are reused unchanged.

Coexistence
-----------
This is additive. Installing the dispatcher does not remove or rewrite any
existing hook. A module may migrate onto the dispatcher by registering a
single entry hook that calls dispatch(); modules not yet migrated keep their
own settings.partial.json entries and run on the legacy per-process path.
The two paths produce identical decisions because the dispatcher's precedence
rules are derived from the legacy chain's observed behavior.
"""
from __future__ import annotations

import os
import sys
from dataclasses import dataclass, field
from typing import Callable, Optional

sys.path.insert(0, os.path.expanduser("~/.claude/lib"))
import hook_utils  # noqa: E402

__all__ = [
    "ALLOW",
    "DENY",
    "ASK",
    "HARD_BLOCK",
    "PASS",
    "DECISION_RANK",
    "Result",
    "Check",
    "Manifest",
    "dispatch",
    "tool_matcher",
]

# ─── Decision vocabulary ─────────────────────────────────────────────
# Ranked weakest → strongest. A larger rank wins precedence.
PASS = "pass"            # check did not fire; no opinion
ASK = "ask"              # request a permission prompt (suppressible in bypass)
ALLOW = "allow"          # auto-approve
DENY = "deny"            # block (overridable by hard_block, beats allow)
HARD_BLOCK = "hard_block"  # bypass-proof block via exit 2

DECISION_RANK = {
    PASS: 0,
    ASK: 1,
    ALLOW: 2,
    DENY: 3,
    HARD_BLOCK: 4,
}


@dataclass
class Result:
    """What a single check decided.

    `decision` is one of the decision-vocabulary constants. `reason` is the
    human-readable explanation surfaced to Claude / the user. `advisory` is
    optional stderr text that is ALWAYS printed (it never participates in the
    decision) — this is how port-check / agent-tracking-style warnings are
    represented without giving them blocking power.
    """

    decision: str = PASS
    reason: str = ""
    advisory: str = ""

    def __post_init__(self) -> None:
        if self.decision not in DECISION_RANK:
            raise ValueError(f"unknown decision: {self.decision!r}")


# A handler receives the full hook envelope (the parsed stdin dict) and
# returns a Result. It must be pure with respect to the decision (no
# sys.exit, no printing of permission JSON) — the dispatcher owns emission.
Handler = Callable[[dict], "Result"]


def tool_matcher(*tool_names: str) -> Callable[[dict], bool]:
    """Build a matcher that fires only for the named tools.

    Mirrors settings.json `matcher` semantics: a check registered for "Bash"
    only runs when tool_name == "Bash". An empty matcher (no names) matches
    every tool, mirroring a settings.json block with no `matcher` key.
    """
    wanted = frozenset(tool_names)

    def _match(data: dict) -> bool:
        if not wanted:
            return True
        return data.get("tool_name", "") in wanted

    return _match


@dataclass
class Check:
    """A declarative entry in a dispatcher manifest.

    Fields:
      priority      Lower runs first. Hard-block-capable checks that must beat
                    a downstream allow are given a smaller number so they are
                    evaluated before it. (Precedence resolution does NOT rely
                    on priority alone — DECISION_RANK is authoritative — but
                    priority fixes a deterministic, documented run order and
                    decides ties between same-rank decisions: first wins.)
      name          Stable identifier (used in tests + audit).
      matches       Predicate over the hook envelope; True ⇒ run the handler.
      handler       Callable returning a Result.
      runs_in_bypass  If False, the check is SKIPPED in bypass mode. Safety
                    checks that must survive bypass set this True.
      short_circuit If True, ANY decisive (non-PASS) result from this check is
                    emitted immediately without consulting later checks. This
                    reproduces a legacy hook that calls emit_decision()/
                    hard_block() and exits the instant it fires — e.g. the
                    curated destructive set (hard_block) and the
                    git-reset-to-remote smart-rule (allow), both of which the
                    standalone auto-approve-bash.py emits before any later
                    check runs. A PASS never short-circuits regardless of this
                    flag (a check with no opinion yields to the rest).
    """

    priority: int
    name: str
    matches: Callable[[dict], bool]
    handler: Handler
    runs_in_bypass: bool = True
    short_circuit: bool = False


@dataclass
class Manifest:
    """An ordered set of checks for one event (optionally one tool)."""

    event: str
    checks: list = field(default_factory=list)

    def add(self, check: "Check") -> "Manifest":
        self.checks.append(check)
        return self

    def ordered(self) -> list:
        # Stable sort by priority; registration order breaks ties.
        return sorted(self.checks, key=lambda c: c.priority)


def _emit_and_exit(decision: str, reason: str) -> None:
    """Emit a decision through the hook_utils primitives and terminate.

    hard_block → exit 2 (bypass-proof). deny/allow/ask → JSON on stdout,
    exit 0. The dispatcher routes EVERY winning decision through these same
    primitives so behavior is identical to a standalone hook calling them.
    """
    if decision == HARD_BLOCK:
        hook_utils.hard_block(reason or "hard-blocked")
    if decision in (ALLOW, DENY, ASK):
        hook_utils.emit_decision(decision, reason)
    # PASS: no output, exit 0.
    sys.exit(0)


def dispatch(manifest: "Manifest", data: Optional[dict] = None) -> None:
    """Run the manifest's checks in-process and emit the winning decision.

    This function does not return on a decisive outcome — it calls
    hook_utils.hard_block() (exit 2) or hook_utils.emit_decision() (exit 0),
    exactly as a standalone hook would. On PASS it exits 0.

    Execution model (mirrors the legacy per-process chain):

      1. Read the envelope once (data is read here if not supplied) — replaces
         N separate stdin reads with one.
      2. Determine bypass mode once.
      3. For each check in priority order:
           a. Skip if its matcher does not match the tool.
           b. Skip if bypass mode is active AND the check is not bypass-safe.
           c. Run the handler.
           d. Always flush any advisory text to stderr (never blocks).
           e. If the result is a hard_block AND the check is short_circuit,
              emit it immediately (nothing can override the curated
              destructive set).
           f. Otherwise track the strongest decision seen so far (ties keep
              the earlier check, matching first-wins chain order).
      4. After all checks, emit the strongest tracked decision.

    Precedence is governed by DECISION_RANK, so the final outcome is
    independent of how many checks fired: one hard_block beats any number of
    allows; one deny beats any number of allows; etc.
    """
    if data is None:
        data = hook_utils.read_hook_input()

    bypass = hook_utils.is_bypass_mode(data)

    best_decision = PASS
    best_reason = ""

    for check in manifest.ordered():
        if not check.matches(data):
            continue
        if bypass and not check.runs_in_bypass:
            continue

        result = check.handler(data)

        # Advisory output is decision-independent and always surfaced.
        if result.advisory:
            sys.stderr.write(result.advisory.rstrip("\n") + "\n")
            sys.stderr.flush()

        if result.decision == PASS:
            continue

        # A short-circuit check emits its decisive result immediately — this
        # reproduces a legacy hook that calls emit_decision()/hard_block() and
        # exits the instant it fires (the curated destructive set's hard_block
        # and the smart-rule's allow both behave this way standalone). No later
        # check can override a short-circuited decision.
        if check.short_circuit:
            _emit_and_exit(result.decision, result.reason)

        # Otherwise, keep the strongest decision. First-wins on ties keeps
        # the priority-ordered chain's behavior (an earlier deny's reason is
        # the one surfaced).
        if DECISION_RANK[result.decision] > DECISION_RANK[best_decision]:
            best_decision = result.decision
            best_reason = result.reason

    _emit_and_exit(best_decision, best_reason)

lib/pretooluse_bash_checks.py

"""Dispatcher handlers for the PreToolUse:Bash check chain.

These wrap the ALREADY-TESTED pure functions of the legacy per-process hooks
into the dispatcher's Result contract. They deliberately do NOT re-implement
any regex or branch-protection logic — each handler imports the legacy hook
module and calls its existing functions, so the dispatched path and the
legacy path share one source of truth and cannot drift.

Mapping (legacy hook → dispatcher check), in the order the legacy
settings.partial.json registers them for PreToolUse:Bash:

  enforce-git-workflow.py        → git_workflow_check        (hard_block, bypass-safe)
  auto-approve-bash.py           → destructive_check         (hard_block, bypass-safe, short-circuit)
                                   smart_rules_check          (hard_block/allow, bypass-safe)
                                   pattern_check              (deny/allow, NOT bypass-safe)
  port-check.py                  → port_advisory_check        (advisory only)
  agent-tracking-pre.py          → agent_tracking_check       (advisory only)
  check-migration-timestamps.py  → migration_timestamp_check  (hard_block, bypass-safe)
  check-careful.py               → force_push_main_check      (hard_block, bypass-safe)
                                   careful_check              (ask, NOT bypass-safe)

Precedence inside the dispatcher (DECISION_RANK) reproduces the legacy chain:
any hard_block beats deny beats allow beats ask. The destructive set is
marked short_circuit so it is emitted the instant it fires — identical to
auto-approve-bash.py running it ABOVE everything else.
"""
from __future__ import annotations

import importlib.util
import io
import os
import sys
from contextlib import redirect_stderr

sys.path.insert(0, os.path.expanduser("~/.claude/lib"))
import hook_dispatcher as hd  # noqa: E402

# Where the legacy hook scripts live. Defaults to the installed location but
# is overridable (CCGM_HOOKS_DIR) so the test suite can point at the in-repo
# modules/hooks/hooks directory without installing.
_HOOKS_DIR = os.environ.get(
    "CCGM_HOOKS_DIR", os.path.expanduser("~/.claude/hooks")
)


def _load_hook(module_name: str, filename: str):
    """Import a legacy hook script by path under ~/.claude/hooks.

    The hooks are not on the import path (they are executable scripts), so we
    load them explicitly. Each is side-effect-free at import time (logic lives
    under `def main()` guarded by `if __name__ == '__main__'`).
    """
    path = os.path.join(_HOOKS_DIR, filename)
    spec = importlib.util.spec_from_file_location(module_name, path)
    if spec is None or spec.loader is None:
        raise ImportError(f"cannot load {filename} from {path}")
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


def _command(data: dict) -> str:
    return data.get("tool_input", {}).get("command", "") or ""


# ─── enforce-git-workflow.py ─────────────────────────────────────────
def git_workflow_check(data: dict) -> "hd.Result":
    """Protected-branch + issue-number enforcement (bypass-proof).

    The legacy hook calls hook_utils.hard_block() directly inside
    check_commit/check_push. We capture that exit-2 by running its main() in a
    controlled way: instead, call the underlying validators and translate a
    hard_block into a HARD_BLOCK Result. To avoid re-implementing the parsing,
    we reuse the module's own predicates and let it raise SystemExit(2), which
    we convert to a Result so the dispatcher owns emission.
    """
    egw = _load_hook("egw", "enforce-git-workflow.py")
    command = _command(data).strip()
    if data.get("tool_name", "") != "Bash" or not command:
        return hd.Result()
    if not (egw.is_commit_command(command) or egw.is_push_command(command)):
        return hd.Result()
    branch = egw.get_current_branch()
    if not branch:
        return hd.Result()

    # check_commit / check_push call hook_utils.hard_block() (→ SystemExit 2)
    # on a violation, writing the reason to stderr. Run them with stderr
    # captured so we can lift the reason into a Result rather than letting the
    # process die here — the dispatcher decides when to exit.
    buf = io.StringIO()
    try:
        with redirect_stderr(buf):
            if egw.is_commit_command(command):
                egw.check_commit(command, branch)
            elif egw.is_push_command(command):
                egw.check_push(command, branch)
    except SystemExit as exc:
        if exc.code == 2:
            return hd.Result(hd.HARD_BLOCK, buf.getvalue().rstrip("\n"))
        # Any non-2 exit from the validator is treated as no-decision.
        return hd.Result()
    # The validators may print a WARNING (ALLOW_MAIN_COMMIT bypass) to stderr
    # without blocking; surface it as advisory.
    warned = buf.getvalue()
    if warned.strip():
        return hd.Result(advisory=warned.rstrip("\n"))
    return hd.Result()


# ─── auto-approve-bash.py ────────────────────────────────────────────
def destructive_check(data: dict) -> "hd.Result":
    """Curated destructive set (whole-disk/root destruction). Bypass-proof."""
    aab = _load_hook("aab", "auto-approve-bash.py")
    command = _command(data)
    if data.get("tool_name", "") != "Bash" or not command:
        return hd.Result()
    label, reason = aab.check_destructive(command)
    if label:
        return hd.Result(hd.HARD_BLOCK, reason or "destructive command blocked")
    return hd.Result()


def force_branch_delete_check(data: dict) -> "hd.Result":
    """`git branch` force-delete: hard-block naming the segment and the way out.

    Bypass-safe on purpose. In bypass mode the pattern check never runs, so
    without this the only message an agent sees is Claude Code's own generic
    "Permission to use Bash with command <whole chain> has been denied" —
    which reads as if worktree removal were blocked (issue #907).
    """
    aab = _load_hook("aab", "auto-approve-bash.py")
    command = _command(data)
    if data.get("tool_name", "") != "Bash" or not command:
        return hd.Result()
    segment, reason = aab.check_force_branch_delete(command)
    if segment:
        return hd.Result(hd.HARD_BLOCK, reason or "force branch delete blocked")
    return hd.Result()


def smart_rules_check(data: dict) -> "hd.Result":
    """git reset --hard smart-rule: allow remote-ref resets, hard-block others."""
    aab = _load_hook("aab", "auto-approve-bash.py")
    command = _command(data)
    if data.get("tool_name", "") != "Bash" or not command:
        return hd.Result()
    decision, reason = aab.check_smart_rules(command)
    if decision == "hard_block":
        return hd.Result(hd.HARD_BLOCK, reason or "destructive smart-rule matched")
    if decision == "allow":
        return hd.Result(hd.ALLOW, reason or "smart-rule allow")
    return hd.Result()


def pattern_check(data: dict) -> "hd.Result":
    """settings.json allow/deny pattern matching, per-segment (#660 fix).

    NOT bypass-safe: in bypass mode the dispatcher skips this check, exactly
    as auto-approve-bash.py exits 0 before pattern matching when bypass is on.
    """
    aab = _load_hook("aab", "auto-approve-bash.py")
    command = _command(data)
    if data.get("tool_name", "") != "Bash" or not command:
        return hd.Result()
    allow_patterns, deny_patterns = aab.load_settings()
    decision, reason = aab.check_pattern_decision(command, allow_patterns, deny_patterns)
    if decision == "deny":
        return hd.Result(hd.DENY, reason or "")
    if decision == "allow":
        return hd.Result(hd.ALLOW, reason or "")
    return hd.Result()


# ─── port-check.py ───────────────────────────────────────────────────
def port_advisory_check(data: dict) -> "hd.Result":
    """Dev-server port-allocation warnings. Advisory only, never blocks.

    NOT bypass-safe: port-check.py returns silently in bypass mode.
    """
    pc = _load_hook("pc", "port-check.py")
    if data.get("tool_name", "") != "Bash":
        return hd.Result()
    command = _command(data)
    if not pc.is_dev_server_command(command):
        return hd.Result()
    # port-check.py writes warnings to stderr inside main(). Run main() with
    # stderr captured and surface the text as advisory. main() never emits a
    # permission decision, so nothing else leaks.
    buf = io.StringIO()
    try:
        with redirect_stderr(buf):
            pc.main_with_data(data) if hasattr(pc, "main_with_data") else _run_port_main(pc, data)
    except SystemExit:
        pass
    text = buf.getvalue()
    if text.strip():
        return hd.Result(advisory=text.rstrip("\n"))
    return hd.Result()


def _run_port_main(pc, data: dict) -> None:
    """port-check.py.main() reads its own stdin; feed it our envelope."""
    import json
    saved = sys.stdin
    sys.stdin = io.StringIO(json.dumps(data))
    try:
        pc.main()
    finally:
        sys.stdin = saved


# ─── agent-tracking-pre.py ───────────────────────────────────────────
def agent_tracking_check(data: dict) -> "hd.Result":
    """Multi-agent issue-claim warnings. Advisory only, never blocks."""
    at = _load_hook("atp", "agent-tracking-pre.py")
    if data.get("tool_name", "") != "Bash":
        return hd.Result()
    import json
    buf_out = io.StringIO()
    saved_in, saved_out = sys.stdin, sys.stdout
    sys.stdin = io.StringIO(json.dumps(data))
    sys.stdout = buf_out
    try:
        at.main()
    except SystemExit:
        pass
    finally:
        sys.stdin, sys.stdout = saved_in, saved_out
    # agent-tracking-pre emits its warnings as JSON with only a
    # permissionDecisionReason (no permissionDecision) — that is advisory by
    # Claude Code's contract. Surface it as advisory text.
    text = buf_out.getvalue()
    if text.strip():
        return hd.Result(advisory=text.rstrip("\n"))
    return hd.Result()


# ─── check-migration-timestamps.py ───────────────────────────────────
def migration_timestamp_check(data: dict) -> "hd.Result":
    """Duplicate Supabase migration timestamps. Data-integrity hard_block."""
    cmt = _load_hook("cmt", "check-migration-timestamps.py")
    import json
    buf = io.StringIO()
    saved_in = sys.stdin
    sys.stdin = io.StringIO(json.dumps(data))
    try:
        with redirect_stderr(buf):
            cmt.main()
    except SystemExit as exc:
        if exc.code == 2:
            return hd.Result(hd.HARD_BLOCK, buf.getvalue().rstrip("\n"))
    finally:
        sys.stdin = saved_in
    return hd.Result()


# ─── check-careful.py ────────────────────────────────────────────────
def force_push_main_check(data: dict) -> "hd.Result":
    """Force-push to main. Bypass-proof hard_block (gated by ALLOW_MAIN_COMMIT)."""
    cc = _load_hook("cc", "check-careful.py")
    if data.get("tool_name", "") != "Bash":
        return hd.Result()
    command = _command(data)
    if not command:
        return hd.Result()
    if cc._is_force_push_to_main(command) and os.environ.get("ALLOW_MAIN_COMMIT") != "1":
        return hd.Result(
            hd.HARD_BLOCK,
            "BLOCKED: force-pushing to `main` overwrites shared history. "
            "If this is truly intended (recovering from a bad merge, etc.), "
            "re-run with `ALLOW_MAIN_COMMIT=1` set.",
        )
    return hd.Result()


def careful_check(data: dict) -> "hd.Result":
    """Destructive-command prompt (ask). NOT bypass-safe."""
    cc = _load_hook("cc", "check-careful.py")
    if data.get("tool_name", "") != "Bash":
        return hd.Result()
    command = _command(data)
    if not command:
        return hd.Result()
    is_destructive, reason = cc.check_careful(command)
    if is_destructive:
        return hd.Result(hd.ASK, reason)
    return hd.Result()

lib/sched_platform.py

"""Platform abstraction for scheduled-job installation.

Locked API (referenced from plan.md §5 Epic 1, §3.11 Linux portability):

    install_scheduled_job(label: str, command: str, hour: int, minute: int) -> None
    uninstall_scheduled_job(label: str) -> None
    list_scheduled_jobs() -> list[str]

macOS uses launchd LaunchAgents under ~/Library/LaunchAgents/.
Linux is a documented v2 plug-in seam — the implementation raises
NotImplementedError with a clear message pointing at the planned cron
template.

Note: the plan spells this `lib/platform.py`, but that name shadows the
Python stdlib `platform` module for any hook that puts `~/.claude/lib`
on `sys.path`. Renamed to `sched_platform.py` to keep the stdlib
reachable. The locked API (install_scheduled_job, uninstall_scheduled_job,
list_scheduled_jobs) is unchanged.
"""
from __future__ import annotations

import os
import platform as _platform
import plistlib
import shutil
import subprocess
from typing import NoReturn

__all__ = [
    "install_scheduled_job",
    "uninstall_scheduled_job",
    "list_scheduled_jobs",
    "LAUNCH_AGENTS_DIR",
]


LAUNCH_AGENTS_DIR = os.path.expanduser("~/Library/LaunchAgents")


def _linux_v2(_op: str) -> NoReturn:
    raise NotImplementedError(
        "Linux scheduling is a v2 plug-in point. "
        "See modules/autoheal/lib/autoheal.cron.template for the planned "
        "cron implementation; the macOS launchd path is in lib/sched_platform.py."
    )


def _unsupported(op: str) -> NoReturn:
    raise NotImplementedError(
        f"Platform not supported for {op}: {_platform.system()!r}. "
        "macOS (Darwin) is the only v1 target; Linux is a v2 plug-in seam."
    )


def install_scheduled_job(label: str, command: str, hour: int, minute: int) -> None:
    """Install a daily scheduled job to fire at HH:MM local time.

    macOS: writes a launchd plist at ~/Library/LaunchAgents/{label}.plist
    and bootstraps it via `launchctl bootstrap gui/$UID`. Idempotent — an
    existing job with the same label is bootout'd first.

    Linux: raises NotImplementedError (v2 seam).
    """
    if not (0 <= hour <= 23 and 0 <= minute <= 59):
        raise ValueError(f"Bad schedule: hour={hour}, minute={minute}")

    sysname = _platform.system()
    if sysname == "Darwin":
        _install_launchd(label, command, hour, minute)
        return
    if sysname == "Linux":
        _linux_v2("install_scheduled_job")
    _unsupported("install_scheduled_job")


def uninstall_scheduled_job(label: str) -> None:
    """Remove a previously-installed scheduled job.

    macOS: bootout the launchd job and delete its plist. Tolerates a
    missing plist (treats it as already-uninstalled).

    Linux: raises NotImplementedError.
    """
    sysname = _platform.system()
    if sysname == "Darwin":
        _uninstall_launchd(label)
        return
    if sysname == "Linux":
        _linux_v2("uninstall_scheduled_job")
    _unsupported("uninstall_scheduled_job")


def list_scheduled_jobs() -> list[str]:
    """Return labels of currently-installed scheduled jobs.

    macOS: reads ~/Library/LaunchAgents and returns the basename
    (without .plist) of every plist whose Label matches the file name.
    Cheap and doesn't shell out.

    Linux: raises NotImplementedError.
    """
    sysname = _platform.system()
    if sysname == "Darwin":
        return _list_launchd()
    if sysname == "Linux":
        _linux_v2("list_scheduled_jobs")
    _unsupported("list_scheduled_jobs")


def _plist_path(label: str) -> str:
    return os.path.join(LAUNCH_AGENTS_DIR, f"{label}.plist")


def _gui_target() -> str:
    uid = os.getuid()
    return f"gui/{uid}"


def _install_launchd(label: str, command: str, hour: int, minute: int) -> None:
    os.makedirs(LAUNCH_AGENTS_DIR, exist_ok=True)
    path = _plist_path(label)

    # Best-effort uninstall first so a config change takes effect.
    if os.path.exists(path):
        _uninstall_launchd(label)

    plist = {
        "Label": label,
        "ProgramArguments": ["/bin/sh", "-c", command],
        "StartCalendarInterval": {"Hour": hour, "Minute": minute},
        "RunAtLoad": False,
        "StandardOutPath": os.path.expanduser(f"~/.claude/logs/{label}.out.log"),
        "StandardErrorPath": os.path.expanduser(f"~/.claude/logs/{label}.err.log"),
        "EnvironmentVariables": {
            "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin",
        },
    }
    os.makedirs(os.path.expanduser("~/.claude/logs"), exist_ok=True)
    with open(path, "wb") as fh:
        plistlib.dump(plist, fh)

    if shutil.which("launchctl"):
        # bootstrap is the modern (Big Sur+) way; ignore non-zero on
        # already-loaded (launchctl is grumpy about idempotency).
        subprocess.run(
            ["launchctl", "bootstrap", _gui_target(), path],
            check=False,
            capture_output=True,
        )


def _uninstall_launchd(label: str) -> None:
    path = _plist_path(label)
    if shutil.which("launchctl") and os.path.exists(path):
        subprocess.run(
            ["launchctl", "bootout", _gui_target(), path],
            check=False,
            capture_output=True,
        )
    if os.path.exists(path):
        try:
            os.remove(path)
        except OSError:
            pass


def _list_launchd() -> list[str]:
    if not os.path.isdir(LAUNCH_AGENTS_DIR):
        return []
    labels: list[str] = []
    for entry in os.listdir(LAUNCH_AGENTS_DIR):
        if entry.endswith(".plist"):
            labels.append(entry[: -len(".plist")])
    labels.sort()
    return labels
config (1)

settings.partial.json

Merged into ~/.claude/settings.json -- a fragment, not a replacement.

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup",
        "hooks": [
          {
            "type": "command",
            "command": "python3 $HOME/.claude/hooks/session-start-enforce.py",
            "timeout": 5000
          }
        ]
      }
    ],
    "UserPromptSubmit": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "python3 $HOME/.claude/hooks/enforce-issue-workflow.py",
            "timeout": 5000
          },
          {
            "type": "command",
            "command": "python3 $HOME/.claude/hooks/ccgm-update-check.py",
            "timeout": 15000
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "python3 $HOME/.claude/hooks/agent-tracking-post.py",
            "timeout": 15000
          },
          {
            "type": "command",
            "command": "python3 $HOME/.claude/hooks/sync-ccgm-canonical.py",
            "timeout": 35000
          }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "python3 $HOME/.claude/hooks/pretooluse-bash-dispatch.py",
            "timeout": 5000
          }
        ]
      },
      {
        "matcher": "Read",
        "hooks": [
          {
            "type": "command",
            "command": "$HOME/.claude/hooks/auto-approve-file-ops.py",
            "timeout": 5000
          }
        ]
      },
      {
        "matcher": "Edit",
        "hooks": [
          {
            "type": "command",
            "command": "$HOME/.claude/hooks/auto-approve-file-ops.py",
            "timeout": 5000
          },
          {
            "type": "command",
            "command": "python3 $HOME/.claude/hooks/check-freeze.py",
            "timeout": 5000
          }
        ]
      },
      {
        "matcher": "Write",
        "hooks": [
          {
            "type": "command",
            "command": "$HOME/.claude/hooks/auto-approve-file-ops.py",
            "timeout": 5000
          },
          {
            "type": "command",
            "command": "python3 $HOME/.claude/hooks/check-freeze.py",
            "timeout": 5000
          }
        ]
      }
    ]
  }
}