Skip to content

Commit ad21988

Browse files
barkainclaude
andcommitted
feat: block work tools without active delegation (exit 2)
Scoped to 7 WORK_TOOLS only — Agent, Skill, TaskCreate etc. always pass. Subagents and active delegations bypass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0bba8f6 commit ad21988

2 files changed

Lines changed: 31 additions & 236 deletions

File tree

hooks/PreToolUse/require_delegation.py

Lines changed: 10 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,14 @@
33
# requires-python = ">=3.12"
44
# ///
55
"""
6-
PreToolUse Hook: Adaptive Delegation Nudge (cross-platform)
6+
PreToolUse Hook: Delegation Enforcement (cross-platform)
77
8-
Soft enforcement: never blocks. Tracks per-turn direct work-tool calls from
9-
the main agent and writes an escalating reminder to stderr. Cost (and signal)
10-
scales with violation count — silent for compliant sessions, louder when the
11-
main agent is repeatedly bypassing /workflow-orchestrator:delegate.
12-
13-
Violation set is small and stable: only the 7 work-doing primitives that
14-
existed before the plugin and won't be renamed. New Claude Code tools never
15-
trigger nudges.
16-
17-
Counter is reset by UserPromptSubmit (new turn) and zeroed when
18-
/workflow-orchestrator:delegate runs (handled by remind_skill_continuation.py).
8+
Blocks the 7 work-tool primitives unless a delegation is active.
9+
Subagents and active delegations bypass.
1910
2011
EXIT CODES:
21-
- 0: always (this hook never blocks)
12+
- 0: tool allowed
13+
- 2: tool blocked (work tool without active delegation)
2214
"""
2315

2416
import io
@@ -28,7 +20,6 @@
2820
import sys
2921
from pathlib import Path
3022

31-
# Force UTF-8 output on Windows (fixes emoji encoding errors)
3223
if sys.platform == "win32":
3324
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
3425
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
@@ -39,7 +30,6 @@
3930
_handler.setFormatter(logging.Formatter("%(message)s"))
4031
logger.addHandler(_handler)
4132

42-
# Stable, work-doing primitives. New Claude Code tools never appear here.
4333
WORK_TOOLS = {
4434
"Bash",
4535
"Edit",
@@ -50,108 +40,47 @@
5040
"NotebookEdit",
5141
}
5242

43+
BLOCK_MSG = "Run /workflow-orchestrator:delegate <task> first."
44+
5345

5446
def get_state_dir() -> Path:
55-
"""Get the state directory path."""
5647
project_dir = Path(os.environ.get("CLAUDE_PROJECT_DIR", str(Path.cwd()))).resolve()
5748
state_dir = project_dir / ".claude" / "state"
5849
state_dir.mkdir(parents=True, exist_ok=True)
5950
return state_dir
6051

6152

6253
def is_subagent() -> bool:
63-
"""True if running inside a subagent (delegation in progress)."""
6454
return bool(
6555
os.environ.get("CLAUDE_PARENT_SESSION_ID") or os.environ.get("CLAUDE_AGENT_ID")
6656
)
6757

6858

69-
def load_violations(path: Path) -> dict[str, int | str]:
70-
"""Load violations counter, or return a fresh one."""
71-
if not path.exists():
72-
return {"violations": 0, "delegations": 0, "turn_id": ""}
73-
try:
74-
return json.loads(path.read_text(encoding="utf-8"))
75-
except (json.JSONDecodeError, OSError):
76-
return {"violations": 0, "delegations": 0, "turn_id": ""}
77-
78-
79-
def save_violations(path: Path, data: dict[str, int | str]) -> None:
80-
"""Persist violations counter."""
81-
try:
82-
path.write_text(json.dumps(data), encoding="utf-8")
83-
except OSError:
84-
pass
85-
86-
87-
def message_for(count: int) -> str | None:
88-
"""Pick the escalation message for a given violation count.
89-
90-
Token cost scales with severity. Compliant sessions pay 0 tokens.
91-
"""
92-
if count <= 0:
93-
return None
94-
if count == 1:
95-
return (
96-
"STOP. This tool call bypasses delegation. Abandon this step "
97-
"and run: /workflow-orchestrator:delegate <your task>"
98-
)
99-
if count == 2:
100-
return (
101-
"STOP. 2nd direct tool call this turn. The main agent does not "
102-
"execute work tools. Run: /workflow-orchestrator:delegate <your task>"
103-
)
104-
return (
105-
f"STOP. {count} direct tool calls bypassing delegation. You are "
106-
"losing planning, parallelization, and context isolation. Abandon "
107-
"the current plan and run: /workflow-orchestrator:delegate <your task>"
108-
)
109-
110-
11159
def main() -> int:
112-
"""Main entry point. Always returns 0."""
113-
# Subagents are executing a delegation — never count their tool use.
11460
if is_subagent():
11561
return 0
11662

11763
state_dir = get_state_dir()
11864

119-
# If a delegation is active in this session, the main agent is steering
120-
# subagents — direct tool use is fine.
12165
if (state_dir / "delegation_active").exists():
12266
return 0
12367

124-
# Read tool name from stdin
12568
try:
12669
stdin_data = sys.stdin.read()
12770
data = json.loads(stdin_data) if stdin_data else {}
12871
except (OSError, json.JSONDecodeError):
12972
return 0
13073

131-
tool_name = str(data.get("tool_name", ""))
132-
if tool_name not in WORK_TOOLS:
74+
if str(data.get("tool_name", "")) not in WORK_TOOLS:
13375
return 0
13476

135-
# Count + nudge
136-
counter_file = state_dir / "delegation_violations.json"
137-
state = load_violations(counter_file)
138-
count = int(state.get("violations", 0)) + 1
139-
state["violations"] = count
140-
save_violations(counter_file, state)
141-
142-
msg = message_for(count)
143-
if msg:
144-
logger.warning("%s", msg)
145-
146-
return 0
77+
logger.warning("%s", BLOCK_MSG)
78+
return 2
14779

14880

14981
if __name__ == "__main__":
15082
try:
15183
sys.exit(main())
15284
except Exception as e: # noqa: BLE001
153-
# Surface internal errors per hook exit-code policy (1 = hook error).
154-
# Exit 1 is a non-blocking error in Claude Code — the tool call still
155-
# proceeds, but the failure is visible in debug output.
15685
logger.error("require_delegation hook error: %s", e)
15786
sys.exit(1)

tests/test_require_delegation.py

Lines changed: 21 additions & 155 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Tests for hooks/PreToolUse/require_delegation.py (soft adaptive nudges)."""
1+
"""Tests for hooks/PreToolUse/require_delegation.py (blocks work tools)."""
22

33
import json
44
import os
@@ -18,10 +18,8 @@ def _run(
1818
project_dir: Path,
1919
env_extra: dict[str, str] | None = None,
2020
) -> tuple[str, str, int]:
21-
"""Run the hook in an isolated project dir, return (stdout, stderr, rc)."""
2221
env = os.environ.copy()
2322
env["CLAUDE_PROJECT_DIR"] = str(project_dir)
24-
# Strip subagent markers so we test main-agent behavior unless overridden
2523
env.pop("CLAUDE_PARENT_SESSION_ID", None)
2624
env.pop("CLAUDE_AGENT_ID", None)
2725
if env_extra:
@@ -42,193 +40,61 @@ def _input(tool_name: str) -> str:
4240
return json.dumps({"tool_name": tool_name, "tool_input": {}})
4341

4442

45-
# ---------------------------------------------------------------------------
46-
# Always returns 0
47-
# ---------------------------------------------------------------------------
48-
49-
50-
class TestNeverBlocks:
51-
def test_work_tool_returns_zero(self, tmp_path: Path) -> None:
52-
_, _, rc = _run(_input("Bash"), tmp_path)
53-
assert rc == 0 # noqa: S101
54-
55-
def test_unknown_tool_returns_zero(self, tmp_path: Path) -> None:
56-
_, _, rc = _run(_input("FooBarBaz"), tmp_path)
57-
assert rc == 0 # noqa: S101
58-
59-
def test_empty_stdin_returns_zero(self, tmp_path: Path) -> None:
60-
_, _, rc = _run("", tmp_path)
61-
assert rc == 0 # noqa: S101
62-
63-
def test_malformed_stdin_returns_zero(self, tmp_path: Path) -> None:
64-
_, _, rc = _run("not json", tmp_path)
65-
assert rc == 0 # noqa: S101
66-
67-
68-
# ---------------------------------------------------------------------------
69-
# Stable violation set: only WORK_TOOLS trigger nudges
70-
# ---------------------------------------------------------------------------
71-
72-
73-
class TestViolationSet:
43+
class TestBlocksWorkTools:
7444
@pytest.mark.parametrize(
7545
"tool",
7646
["Bash", "Edit", "Write", "Glob", "Grep", "MultiEdit", "NotebookEdit"],
7747
)
78-
def test_work_tools_count_as_violations(self, tmp_path: Path, tool: str) -> None:
79-
_, stderr, _ = _run(_input(tool), tmp_path)
48+
def test_work_tools_blocked(self, tmp_path: Path, tool: str) -> None:
49+
_, stderr, rc = _run(_input(tool), tmp_path)
50+
assert rc == 2 # noqa: S101
8051
assert "delegate" in stderr.lower() # noqa: S101
8152

82-
def test_read_is_silent(self, tmp_path: Path) -> None:
83-
"""Read is exempt from delegation nudges (not in WORK_TOOLS)."""
53+
def test_read_allowed(self, tmp_path: Path) -> None:
8454
_, stderr, rc = _run(_input("Read"), tmp_path)
8555
assert rc == 0 # noqa: S101
8656
assert stderr == "" # noqa: S101
8757

8858
@pytest.mark.parametrize(
8959
"tool",
90-
[
91-
"AskUserQuestion",
92-
"TaskCreate",
93-
"TaskUpdate",
94-
"Agent",
95-
"Task",
96-
"Skill",
97-
"SlashCommand",
98-
"EnterPlanMode",
99-
"ExitPlanMode",
100-
"ToolSearch",
101-
"TeamCreate",
102-
"SendMessage",
103-
"FooBar", # hypothetical new tool
104-
"CronCreate",
105-
],
60+
["Agent", "Skill", "SlashCommand", "TaskCreate", "TaskUpdate",
61+
"EnterPlanMode", "ExitPlanMode", "ToolSearch", "TeamCreate",
62+
"SendMessage", "AskUserQuestion", "FooBar", "CronCreate"],
10663
)
107-
def test_non_work_tools_silent(self, tmp_path: Path, tool: str) -> None:
64+
def test_non_work_tools_allowed(self, tmp_path: Path, tool: str) -> None:
10865
_, stderr, rc = _run(_input(tool), tmp_path)
10966
assert rc == 0 # noqa: S101
11067
assert stderr == "" # noqa: S101
11168

69+
def test_empty_stdin_allowed(self, tmp_path: Path) -> None:
70+
_, _, rc = _run("", tmp_path)
71+
assert rc == 0 # noqa: S101
11272

113-
# ---------------------------------------------------------------------------
114-
# Escalation ladder
115-
# ---------------------------------------------------------------------------
116-
117-
118-
class TestEscalationLadder:
119-
def test_first_violation_is_imperative(self, tmp_path: Path) -> None:
120-
_, stderr, _ = _run(_input("Bash"), tmp_path)
121-
# New imperative message must include STOP + the delegate command
122-
assert "STOP" in stderr # noqa: S101
123-
assert "/workflow-orchestrator:delegate" in stderr # noqa: S101
124-
125-
def test_second_violation_is_medium(self, tmp_path: Path) -> None:
126-
_run(_input("Bash"), tmp_path)
127-
_, stderr, _ = _run(_input("Edit"), tmp_path)
128-
assert "STOP" in stderr # noqa: S101
129-
assert "/workflow-orchestrator:delegate" in stderr # noqa: S101
130-
# Distinct 2nd-call phrasing
131-
assert "2nd direct tool call" in stderr # noqa: S101
132-
133-
def test_third_violation_is_warning(self, tmp_path: Path) -> None:
134-
for tool in ("Bash", "Edit", "Write"):
135-
_run(_input(tool), tmp_path)
136-
_, stderr, _ = _run(_input("Glob"), tmp_path)
137-
# 4th call -> "STOP. 4 direct tool calls bypassing delegation..."
138-
assert "STOP" in stderr # noqa: S101
139-
assert "4" in stderr # noqa: S101
140-
assert "/workflow-orchestrator:delegate" in stderr # noqa: S101
141-
142-
def test_fifth_plus_is_strong(self, tmp_path: Path) -> None:
143-
for _ in range(5):
144-
_run(_input("Bash"), tmp_path)
145-
_, stderr, _ = _run(_input("Bash"), tmp_path) # 6th call
146-
assert "STOP" in stderr # noqa: S101
147-
# The ≥3 message explains what's being lost
148-
assert "losing planning, parallelization, and context isolation" in stderr # noqa: S101
149-
# Long message — over ~100 chars
150-
assert len(stderr.strip()) > 100 # noqa: S101
151-
152-
def test_message_length_grows(self, tmp_path: Path) -> None:
153-
"""Verify escalation adds context (proxied by message length/content)."""
154-
lengths = []
155-
messages = []
156-
for _ in range(6):
157-
_, stderr, _ = _run(_input("Bash"), tmp_path)
158-
lengths.append(len(stderr.strip()))
159-
messages.append(stderr.strip())
160-
# Each level should be at least as long as the previous (monotonic)
161-
for i in range(1, len(lengths)):
162-
assert lengths[i] >= lengths[i - 1] # noqa: S101
163-
# The final message should be strictly longer than the first
164-
assert lengths[-1] > lengths[0] # noqa: S101
165-
# And must include the "what's being lost" context that proves escalation
166-
assert "losing planning, parallelization, and context isolation" in messages[-1] # noqa: S101
167-
168-
169-
# ---------------------------------------------------------------------------
170-
# Counter persistence
171-
# ---------------------------------------------------------------------------
172-
173-
174-
class TestCounterPersistence:
175-
def test_counter_persists_across_calls(self, tmp_path: Path) -> None:
176-
for _ in range(3):
177-
_run(_input("Bash"), tmp_path)
178-
counter = json.loads(
179-
(tmp_path / ".claude" / "state" / "delegation_violations.json").read_text()
180-
)
181-
assert counter["violations"] == 3 # noqa: S101
182-
183-
def test_fresh_project_starts_at_zero(self, tmp_path: Path) -> None:
184-
_run(_input("Bash"), tmp_path)
185-
counter = json.loads(
186-
(tmp_path / ".claude" / "state" / "delegation_violations.json").read_text()
187-
)
188-
assert counter["violations"] == 1 # noqa: S101
189-
190-
191-
# ---------------------------------------------------------------------------
192-
# Subagent immunity
193-
# ---------------------------------------------------------------------------
73+
def test_malformed_stdin_allowed(self, tmp_path: Path) -> None:
74+
_, _, rc = _run("not json", tmp_path)
75+
assert rc == 0 # noqa: S101
19476

19577

19678
class TestSubagentImmunity:
197-
def test_parent_session_id_skips(self, tmp_path: Path) -> None:
79+
def test_parent_session_id_allows(self, tmp_path: Path) -> None:
19880
_, stderr, rc = _run(
199-
_input("Bash"),
200-
tmp_path,
81+
_input("Bash"), tmp_path,
20182
env_extra={"CLAUDE_PARENT_SESSION_ID": "abc123"},
20283
)
20384
assert rc == 0 # noqa: S101
20485
assert stderr == "" # noqa: S101
20586

206-
def test_agent_id_skips(self, tmp_path: Path) -> None:
87+
def test_agent_id_allows(self, tmp_path: Path) -> None:
20788
_, stderr, rc = _run(
208-
_input("Bash"),
209-
tmp_path,
89+
_input("Bash"), tmp_path,
21090
env_extra={"CLAUDE_AGENT_ID": "xyz"},
21191
)
21292
assert rc == 0 # noqa: S101
21393
assert stderr == "" # noqa: S101
21494

215-
def test_subagent_does_not_increment_counter(self, tmp_path: Path) -> None:
216-
_run(
217-
_input("Bash"),
218-
tmp_path,
219-
env_extra={"CLAUDE_PARENT_SESSION_ID": "x"},
220-
)
221-
counter_file = tmp_path / ".claude" / "state" / "delegation_violations.json"
222-
assert not counter_file.exists() # noqa: S101
223-
224-
225-
# ---------------------------------------------------------------------------
226-
# delegation_active flag suppresses nudges
227-
# ---------------------------------------------------------------------------
228-
22995

23096
class TestDelegationActiveFlag:
231-
def test_active_flag_suppresses_nudge(self, tmp_path: Path) -> None:
97+
def test_active_flag_allows(self, tmp_path: Path) -> None:
23298
state = tmp_path / ".claude" / "state"
23399
state.mkdir(parents=True)
234100
(state / "delegation_active").touch()

0 commit comments

Comments
 (0)