From 85674e7a85829db14e76fd638b370e720c97b617 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 3 Jun 2026 23:49:07 +0200 Subject: [PATCH] feat(claude): proactively advise agents on protected branches before they act Every gitguardex guard is reactive: skill_guard.py (PreToolUse) and the pre-commit hook only fire once an agent ATTEMPTS a blocked action, so an agent on main stages -> commit -> blocked -> tries to branch -> blocked, bouncing wall to wall. There was no proactive signal of branch state. Add .claude/hooks/agent_branch_advisor.py, wired into SessionStart and UserPromptSubmit. On a protected branch (dev/main/master + configured extras) it injects, via hookSpecificOutput.additionalContext at exit 0, an advisory naming the branch and the sanctioned `gx branch start` command -- so the agent's first move is to open an isolated worktree. - Silent on agent/* branches and non-protected branches (zero noise). - Fail-open: bad stdin, missing/old skill_guard, git error, or GUARDEX_ON=0 -> no output, exit 0. Always exit 0 (UserPromptSubmit exit 2 rejects the prompt -- advise, never block). - Imports branch/protection predicates from the sibling skill_guard.py so the advisory can never disagree with what the guards enforce. - Added to MANAGED_HOOK_FILES so `gx claude install` distributes it (the pre-existing scripts/agent-stalled-report.sh is NOT distributed; the advisor deliberately avoids that gap). Verify: node --test test/claude-install.test.js -> 14/14 (2 new); advisor runtime silent on agent branch, correct per-event advisory JSON on main, fail-open on malformed stdin and GUARDEX_ON=0; full suite failing set unchanged vs main (33 == 33, byte-identical). --- .claude/hooks/agent_branch_advisor.py | 114 ++++++++++++++++++ .../.openspec.yaml | 2 + .../notes.md | 64 ++++++++++ src/cli/commands/claude.js | 13 +- test/claude-install.test.js | 23 ++++ 5 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 .claude/hooks/agent_branch_advisor.py create mode 100644 openspec/changes/agent-claude-proactive-protected-branch-advisory-hook-2026-06-03-23-39/.openspec.yaml create mode 100644 openspec/changes/agent-claude-proactive-protected-branch-advisory-hook-2026-06-03-23-39/notes.md diff --git a/.claude/hooks/agent_branch_advisor.py b/.claude/hooks/agent_branch_advisor.py new file mode 100644 index 00000000..5e18397e --- /dev/null +++ b/.claude/hooks/agent_branch_advisor.py @@ -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": , "additionalContext": }} +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 "" ""\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() diff --git a/openspec/changes/agent-claude-proactive-protected-branch-advisory-hook-2026-06-03-23-39/.openspec.yaml b/openspec/changes/agent-claude-proactive-protected-branch-advisory-hook-2026-06-03-23-39/.openspec.yaml new file mode 100644 index 00000000..0ba725fb --- /dev/null +++ b/openspec/changes/agent-claude-proactive-protected-branch-advisory-hook-2026-06-03-23-39/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-03 diff --git a/openspec/changes/agent-claude-proactive-protected-branch-advisory-hook-2026-06-03-23-39/notes.md b/openspec/changes/agent-claude-proactive-protected-branch-advisory-hook-2026-06-03-23-39/notes.md new file mode 100644 index 00000000..ea9c7db9 --- /dev/null +++ b/openspec/changes/agent-claude-proactive-protected-branch-advisory-hook-2026-06-03-23-39/notes.md @@ -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`). diff --git a/src/cli/commands/claude.js b/src/cli/commands/claude.js index 2b10c2cd..4ef4e0d5 100644 --- a/src/cli/commands/claude.js +++ b/src/cli/commands/claude.js @@ -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', ]; @@ -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'], }; @@ -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"', + }, ], }, ], @@ -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"', + }, ], }, ], diff --git a/test/claude-install.test.js b/test/claude-install.test.js index d32e27fb..6ae151f8 100644 --- a/test/claude-install.test.js +++ b/test/claude-install.test.js @@ -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'); +});