From aaa8ad995e6136e82465c4edd7659d74ad5a666b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 24 Oct 2026 21:14:06 -0400 Subject: [PATCH] Install adopts untagged copies of its own Claude Code hooks owns_entry recognized an entry only by its writer tag, so every entry an install wrote before the tag existed looked foreign forever: install appended its tagged entry beside it and each hook ran twice. An untagged entry whose every hook is one this package installs is now owned too, so the next install leaves one entry per hook. Foreign hooks stay. The hook dict has one owner, hook(), used by entry() and by the ownership test. Co-Authored-By: Claude Opus 5.5 --- src/agent_comms_hooks/__init__.py | 24 +++++++++++++++++--- tests/test_claude_hook_install.py | 37 +++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 tests/test_claude_hook_install.py diff --git a/src/agent_comms_hooks/__init__.py b/src/agent_comms_hooks/__init__.py index 0efb22ae2c..7ee7a49d3e 100644 --- a/src/agent_comms_hooks/__init__.py +++ b/src/agent_comms_hooks/__init__.py @@ -15,6 +15,7 @@ from abc import ABC, abstractmethod from collections.abc import Mapping from dataclasses import dataclass, fields +from functools import cache from importlib import import_module from importlib.metadata import entry_points from pathlib import Path @@ -127,13 +128,30 @@ def console_script(target: str) -> str: @classmethod def owns_entry(cls, entry: Mapping[str, object]) -> bool: - """Ownership survives deletion of a program and its console script.""" - return entry.get("writer") == cls.writer + """Ownership survives deletion of a program and its console script. An untagged + entry running only this package's hooks was written before the tag existed; + install adopts it, so each hook keeps one entry.""" + match entry: + case {"writer": writer} if writer == cls.writer: + return True + case {"hooks": [_, *_] as hooks}: + return all(hook in cls.hooks() for hook in hooks) + return False + + @classmethod + @cache + def hooks(cls) -> tuple[dict[str, str], ...]: + """The hook every declared program installs.""" + return tuple(program.hook() for program in cls.members()) + + @classmethod + def hook(cls) -> dict[str, str]: + return {"type": "command", "command": cls.command()} @classmethod def entry(cls) -> dict[str, object]: """This program's entry, marked so install can replace what it wrote.""" - return {"writer": cls.writer, "hooks": [{"type": "command", "command": cls.command()}]} + return {"writer": cls.writer, "hooks": [cls.hook()]} class ClaudeToolHookProgram(ClaudeHookProgram, ABC): diff --git a/tests/test_claude_hook_install.py b/tests/test_claude_hook_install.py new file mode 100644 index 0000000000..9d5639c732 --- /dev/null +++ b/tests/test_claude_hook_install.py @@ -0,0 +1,37 @@ +"""Install owns every Claude Code hook entry that runs its programs, tagged or not.""" + +import json + +from agent_comms.harness_install import ClaudeCodeHarness +from agent_comms_hooks import ClaudeHookProgram + +FOREIGN = {"matcher": "Bash", "hooks": [{"type": "command", "command": "python3 -I guard.py"}]} + + +def commands(settings) -> dict[str, list[str]]: + hooks = json.loads(settings.read_text())["hooks"] + return { + event: sorted(hook["command"] for entry in entries for hook in entry["hooks"]) + for event, entries in hooks.items() + } + + +def test_install_adopts_untagged_copies_of_its_hooks_and_keeps_foreign_ones(tmp_path): + settings = tmp_path / ".claude/settings.json" + untagged: dict[str, list[dict[str, object]]] = {"PreToolUse": [FOREIGN]} + for program in ClaudeHookProgram.members(): + entry = {key: value for key, value in program.entry().items() if key != "writer"} + for event in program.events: + untagged.setdefault(event, []).append(entry) + settings.parent.mkdir() + settings.write_text(json.dumps({"hooks": untagged, "theme": "dark"})) + + ClaudeCodeHarness(tmp_path).configure() + ClaudeCodeHarness(tmp_path).configure() + + expected: dict[str, list[str]] = {"PreToolUse": ["python3 -I guard.py"]} + for program in ClaudeHookProgram.members(): + for event in program.events: + expected.setdefault(event, []).append(program.command()) + assert commands(settings) == {event: sorted(found) for event, found in expected.items()} + assert json.loads(settings.read_text())["theme"] == "dark"