Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions .claude/hooks/agent_branch_advisor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""SessionStart + UserPromptSubmit hook — proactively tell the agent it is on a
protected branch BEFORE it tries to edit or commit.

Every other gitguardex guard is reactive: skill_guard.py (PreToolUse) and the
pre-commit git hook only fire once the agent *attempts* a blocked action, so the
agent learns the rule by smacking into a wall, recovering, and smacking into the
next one. This advisor surfaces the branch state and the sanctioned
`gx branch start` command up front, so the agent's first move on a protected
branch is to open an isolated worktree instead of bouncing off the commit guard.

Behaviour:
- Silent on agent/* (and other recognized agent) branches — the sanctioned
state, zero noise.
- Silent on non-agent, non-protected branches (e.g. feature/*) — Claude may
edit there and the guards allow it.
- On a protected branch (dev/main/master + any GUARDEX_PROTECTED_BRANCHES /
multiagent.protectedBranches additions) it injects an advisory.
- Fail-open: any error → no output, exit 0. Never blocks a session or a prompt.

Wired into BOTH events (see EXPECTED_HOOK_MATCHERS in src/cli/commands/claude.js):
- SessionStart announce once at the top of the session (incl. resume/clear)
- UserPromptSubmit re-check each turn; catches drift back onto a protected
branch mid-session (e.g. right after `gx branch finish`)

Output is the documented context-injecting shape for both events:
{"hookSpecificOutput": {"hookEventName": <event>, "additionalContext": <text>}}
emitted on exit 0. NOTE: UserPromptSubmit treats exit 2 as "reject the prompt",
so this hook must always exit 0 — advise, never block.

The branch/protection predicates are imported from the sibling skill_guard.py so
the advisory's notion of "protected" / "agent branch" is byte-identical to what
the guards actually enforce. `gx claude install` copies both files together, so
they stay version-matched; the import is still wrapped to fail open if a target
repo somehow carries an older skill_guard.py without these helpers.
"""

import json
import os
import sys
from pathlib import Path

try:
from skill_guard import (
current_branch,
find_repo_root,
guardex_repo_is_enabled,
is_agent_branch,
resolve_protected_branches,
)
except Exception: # noqa: BLE001 - fail open if sibling hook is missing/older
sys.exit(0)


SUPPORTED_EVENTS = ("SessionStart", "UserPromptSubmit")


def resolve_repo_root(cwd: str) -> Path:
"""Resolve the guarded repo from the session cwd (falls back to process cwd)."""
if cwd:
return find_repo_root(cwd)
return find_repo_root(os.getcwd())


def advisory_text(branch: str) -> str:
return (
f"⚠ GUARDEX: this session is on protected branch '{branch}'. "
"Agent edits and commits are BLOCKED here by gitguardex.\n"
"Before editing any file in this repo, open an isolated agent worktree "
"first (it carries any uncommitted changes with you):\n"
' gx branch start "<task>" "<agent-name>"\n'
"Then `cd` into the printed worktree path and do all work from there. "
"Finish completed work with:\n"
" gx branch finish --via-pr --wait-for-merge --cleanup\n"
"(On an agent/* branch you will not see this notice.)"
)


def main() -> None:
try:
raw = sys.stdin.read()
input_data = json.loads(raw) if raw.strip() else {}
except (json.JSONDecodeError, EOFError, ValueError):
sys.exit(0) # fail-open

event = input_data.get("hook_event_name", "")
cwd = input_data.get("cwd", "") or ""

try:
repo_root = resolve_repo_root(cwd)
if not guardex_repo_is_enabled(repo_root):
sys.exit(0)
branch = current_branch(repo_root)
except Exception: # noqa: BLE001 - never let a git/env hiccup block the agent
sys.exit(0)

if not branch or is_agent_branch(branch):
sys.exit(0)
if branch not in resolve_protected_branches(repo_root):
sys.exit(0)

hook_event = event if event in SUPPORTED_EVENTS else "SessionStart"
payload = {
"hookSpecificOutput": {
"hookEventName": hook_event,
"additionalContext": advisory_text(branch),
}
}
print(json.dumps(payload))
sys.exit(0)


if __name__ == "__main__":
main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-03
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# agent-claude-proactive-protected-branch-advisory-hook-2026-06-03-23-39 (minimal / T1)

Branch: `agent/claude/proactive-protected-branch-advisory-hook-2026-06-03-23-39`

## What & why

Make the agent **auto-know** it is on a protected branch *before* it edits or
commits, instead of only learning *after* a guard blocks it.

Every existing gitguardex guard is reactive — `skill_guard.py` (PreToolUse) and
`templates/githooks/pre-commit` only fire once the agent *attempts* a blocked
action. So an agent on `main` stages → `git commit` → blocked → tries to branch →
blocked → recovers, bouncing wall to wall. There was no proactive signal of the
current branch state.

This adds one proactive advisory hook, `.claude/hooks/agent_branch_advisor.py`,
wired into **SessionStart** and **UserPromptSubmit**. On a protected branch it
injects (via `hookSpecificOutput.additionalContext`, exit 0) a notice naming the
branch and the sanctioned `gx branch start` command, so the agent's first move
is to open an isolated worktree.

## Scope

- New `.claude/hooks/agent_branch_advisor.py` (added to `MANAGED_HOOK_FILES` so
`gx claude install` distributes it; imports branch/protection predicates from
the sibling `skill_guard.py` so the advisory can never disagree with the guard).
- `src/cli/commands/claude.js`: register advisor in `MANAGED_HOOK_FILES`,
`EXPECTED_HOOK_MATCHERS` (SessionStart + UserPromptSubmit), and
`TEMPLATE_DEFAULT_SETTINGS` (added to both event groups).
- `test/claude-install.test.js`: 2 tests (managed-file membership + both-event wiring).

## Behaviour

- Silent on `agent/*` (and other recognized agent) branches — zero noise.
- Silent on non-agent, non-protected branches (e.g. `feature/*`).
- Advisory only on protected branches (`dev`/`main`/`master` +
`GUARDEX_PROTECTED_BRANCHES` / `multiagent.protectedBranches`).
- Fail-open: any error, malformed stdin, or `GUARDEX_ON=0` → no output, exit 0.
Always exit 0 (UserPromptSubmit exit 2 would *reject the prompt* — advise, never block).

## Verification

- `node --test test/claude-install.test.js` → 14/14 pass (incl. 2 new).
- `node -c src/cli/commands/claude.js` → OK.
- Advisor runtime: silent on agent branch; correct per-event advisory JSON on
`main` (SessionStart + UserPromptSubmit); fail-open on malformed stdin; silent
under `GUARDEX_ON=0`.
- Hook contract confirmed against Claude Code hooks docs: both events inject via
`hookSpecificOutput.additionalContext` at exit 0; SessionStart fires on
startup/resume/clear/compact.

## Note (out of scope)

`scripts/agent-stalled-report.sh` (the existing SessionStart hook) is referenced
by the settings template but is NOT in `MANAGED_HOOK_FILES`, so `gx claude
install` does not copy it to target repos (latent gap). The advisor deliberately
lives in `.claude/hooks/` + `MANAGED_HOOK_FILES` to avoid that gap. Fixing the
stalled-report distribution is a separate follow-up.

## Cleanup

- [ ] Run: `gx branch finish --branch agent/claude/proactive-protected-branch-advisory-hook-2026-06-03-23-39 --base main --via-pr --wait-for-merge --cleanup`
- [ ] Record PR URL + `MERGED` state in the completion handoff.
- [ ] Confirm sandbox worktree is gone (`git worktree list`, `git branch -a`).
13 changes: 11 additions & 2 deletions src/cli/commands/claude.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const SKILLS_REL = '.claude/skills';
const MANAGED_HOOK_FILES = [
'skill_guard.py',
'skill_activation.py',
'agent_branch_advisor.py',
'post_edit_tracker.py',
'skill_tracker.py',
];
Expand All @@ -39,8 +40,8 @@ const MANAGED_SLASH_COMMANDS = [
];

const EXPECTED_HOOK_MATCHERS = {
SessionStart: ['agent-stalled-report.sh'],
UserPromptSubmit: ['skill_activation.py'],
SessionStart: ['agent-stalled-report.sh', 'agent_branch_advisor.py'],
UserPromptSubmit: ['skill_activation.py', 'agent_branch_advisor.py'],
PreToolUse: ['skill_guard.py'],
PostToolUse: ['post_edit_tracker.py', 'skill_tracker.py'],
};
Expand All @@ -54,6 +55,10 @@ const TEMPLATE_DEFAULT_SETTINGS = {
type: 'command',
command: 'bash "${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}/scripts/agent-stalled-report.sh"',
},
{
type: 'command',
command: 'python3 "${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}/.claude/hooks/agent_branch_advisor.py"',
},
],
},
],
Expand All @@ -64,6 +69,10 @@ const TEMPLATE_DEFAULT_SETTINGS = {
type: 'command',
command: 'python3 "${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}/.claude/hooks/skill_activation.py"',
},
{
type: 'command',
command: 'python3 "${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}/.claude/hooks/agent_branch_advisor.py"',
},
],
},
],
Expand Down
23 changes: 23 additions & 0 deletions test/claude-install.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,26 @@ test('mergeSettings --force ignores existing settings', () => {
const mergedNonForce = claudeModule.mergeSettings(existing, claudeModule.TEMPLATE_DEFAULT_SETTINGS);
assert.ok(mergedNonForce.hooks.PreToolUse.some((g) => g.matcher === 'Other'));
});

test('agent_branch_advisor.py is a managed (distributed) hook file', () => {
assert.ok(
claudeModule.MANAGED_HOOK_FILES.includes('agent_branch_advisor.py'),
'advisor must be in MANAGED_HOOK_FILES so gx claude install copies it to target repos',
);
});

test('branch advisor is wired into SessionStart and UserPromptSubmit', () => {
const merged = claudeModule.mergeSettings(null, claudeModule.TEMPLATE_DEFAULT_SETTINGS);
for (const event of ['SessionStart', 'UserPromptSubmit']) {
const groups = merged.hooks[event] || [];
const commands = groups.flatMap((g) => (g.hooks || []).map((h) => h.command || ''));
assert.ok(
commands.some((cmd) => cmd.includes('.claude/hooks/agent_branch_advisor.py')),
`${event} should invoke agent_branch_advisor.py`,
);
}
// Pre-existing advisory hooks must survive alongside the new one.
const sessionCmds = (merged.hooks.SessionStart || []).flatMap((g) =>
(g.hooks || []).map((h) => h.command || ''));
assert.ok(sessionCmds.some((cmd) => cmd.includes('agent-stalled-report.sh')), 'stalled report preserved');
});
Loading