diff --git a/engine/hooks/_flags/tests/test_installed_layout.py b/engine/hooks/_flags/tests/test_installed_layout.py index 58c48396..be43f419 100644 --- a/engine/hooks/_flags/tests/test_installed_layout.py +++ b/engine/hooks/_flags/tests/test_installed_layout.py @@ -17,7 +17,6 @@ Run: python3 -m unittest discover -s engine/hooks/_flags/tests -v """ import os -import re import shutil import subprocess import sys @@ -26,8 +25,12 @@ FLAGS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) HOOKS_DIR = os.path.dirname(FLAGS_DIR) +SDK_DIR = os.path.join(HOOKS_DIR, "_sdk") REPO_DIR = os.path.dirname(os.path.dirname(HOOKS_DIR)) INSTALL_SH = os.path.join(REPO_DIR, "install.sh") +sys.path.insert(0, SDK_DIR) + +from install_from_registry import harness_hook_names # noqa: E402 CONSUMER = ( "import os, sys\n" @@ -135,13 +138,7 @@ def test_every_hook_importing_flags_is_linked_where_it_is_installed(self): for hook in sorted(importers): for harness, relative in HARNESS_DIRS: folder = relative.replace(os.sep, "/") - installed = re.search( - r'link_item "{}" "\$HOOKS_SNAPSHOT_DIR/{}" "\$HOME/{}/{}"'.format( - re.escape(hook), re.escape(hook), re.escape(folder), re.escape(hook) - ), - self.body, - ) - if not installed: + if hook not in harness_hook_names(harness): continue checked += 1 with self.subTest(hook=hook, harness=harness): @@ -152,8 +149,8 @@ def test_every_hook_importing_flags_is_linked_where_it_is_installed(self): ) self.assertTrue( checked, - "matched no link_item line for any flags-importing hook -- the regex " - "no longer matches install.sh, so this test checked nothing", + "matched no registry-installed harness for any flags-importing hook, " + "so this test checked nothing", ) diff --git a/engine/hooks/_sdk/install_from_registry.py b/engine/hooks/_sdk/install_from_registry.py index bdca053b..e9fe37df 100644 --- a/engine/hooks/_sdk/install_from_registry.py +++ b/engine/hooks/_sdk/install_from_registry.py @@ -72,6 +72,16 @@ def _fragment_paths(hook: str, harness: str) -> list[Path]: return sorted(paths) +def harness_hook_names(harness: str, registry_path: Path | None = None) -> list[str]: + registry, _thresholds = load_registry(registry_path) + return [ + hook + for hook in registry + if (HOOKS_DIR / hook).is_dir() + if harness == "claude" or hook != "cat-mode-default" + ] + + def _load_fragment(path: Path) -> dict: data = json.loads(path.read_text(encoding="utf-8")) if not isinstance(data, dict) or not isinstance(data.get("hooks"), dict): @@ -142,7 +152,11 @@ def install(home: Path | None = None, registry_path: Path | None = None) -> int: home = home or Path.home() registry, _thresholds = load_registry(registry_path) registry_hooks = set(registry) - active_hooks = list(registry) + active_hooks = [ + name + for name, record in registry.items() + if record.mode != "off" + ] python, warning = _pick_install_python() if warning: sys.stderr.write(warning) @@ -175,6 +189,17 @@ def install(home: Path | None = None, registry_path: Path | None = None) -> int: def main() -> int: try: + if len(sys.argv) == 3 and sys.argv[1] == "--list-harness-hooks": + harness = sys.argv[2] + if harness not in CONFIGS: + print(f"error unknown harness: {harness}", file=sys.stderr) + return 2 + for hook in harness_hook_names(harness): + print(hook) + return 0 + if len(sys.argv) != 1: + print("usage install_from_registry.py [--list-harness-hooks claude|cursor|codex]", file=sys.stderr) + return 2 return install() except Exception as exc: print(f"error install_from_registry: {exc}", file=sys.stderr) diff --git a/engine/hooks/agent-launch-guard/tests/test_hooks.py b/engine/hooks/agent-launch-guard/tests/test_hooks.py index 080e9227..ab81e560 100644 --- a/engine/hooks/agent-launch-guard/tests/test_hooks.py +++ b/engine/hooks/agent-launch-guard/tests/test_hooks.py @@ -15,6 +15,7 @@ sys.path.insert(0, SDK_DIR) import detect # noqa: E402 +from install_from_registry import harness_hook_names # noqa: E402 from finding import Finding # noqa: E402 from render import render # noqa: E402 @@ -134,10 +135,10 @@ def test_install_links_the_dir_the_manifest_points_at(self): with open(os.path.join(REPO_ROOT, "install.sh"), encoding="utf-8") as handle: install_sh = handle.read() self.assertIn( - 'link_item "agent-launch-guard" "$HOOKS_SNAPSHOT_DIR/agent-launch-guard" ' - '"$HOME/.claude/hooks/agent-launch-guard"', + 'install_from_registry.py" --list-harness-hooks claude', install_sh, ) + self.assertIn("agent-launch-guard", harness_hook_names("claude")) def test_manifest_targets_installed_claude_hook(self): with open(os.path.join(HOOKS_DIR, "claude.hook.json"), encoding="utf-8") as handle: diff --git a/install.sh b/install.sh index 55bf2aff..dcdba86d 100755 --- a/install.sh +++ b/install.sh @@ -355,59 +355,9 @@ link_item "_markers" "$HOOKS_SNAPSHOT_DIR/_markers" "$HOME/.claude/hooks/_marker install_local_runner "$REPO_DIR/engine/hooks/_runner" "$HOME/.claude/hooks/_runner" link_item "_flags" "$HOOKS_SNAPSHOT_DIR/_flags" "$HOME/.claude/hooks/_flags" link_item "_sdk" "$HOOKS_SNAPSHOT_DIR/_sdk" "$HOME/.claude/hooks/_sdk" -link_item "diu-stop" "$HOOKS_SNAPSHOT_DIR/diu-stop" "$HOME/.claude/hooks/diu-stop" -link_item "bug-complaint-leak" "$HOOKS_SNAPSHOT_DIR/bug-complaint-leak" "$HOME/.claude/hooks/bug-complaint-leak" -link_item "demo-freeze" "$HOOKS_SNAPSHOT_DIR/demo-freeze" "$HOME/.claude/hooks/demo-freeze" -link_item "frustration-watchdog" "$HOOKS_SNAPSHOT_DIR/frustration-watchdog" "$HOME/.claude/hooks/frustration-watchdog" -link_item "skill-usage-log" "$HOOKS_SNAPSHOT_DIR/skill-usage-log" "$HOME/.claude/hooks/skill-usage-log" -link_item "reflect-on-thrash" "$HOOKS_SNAPSHOT_DIR/reflect-on-thrash" "$HOME/.claude/hooks/reflect-on-thrash" -link_item "scope-lock" "$HOOKS_SNAPSHOT_DIR/scope-lock" "$HOME/.claude/hooks/scope-lock" -link_item "restart-risk-check" "$HOOKS_SNAPSHOT_DIR/restart-risk-check" "$HOME/.claude/hooks/restart-risk-check" -link_item "auto-pr" "$HOOKS_SNAPSHOT_DIR/auto-pr" "$HOME/.claude/hooks/auto-pr" -link_item "pr-schema-gate" "$HOOKS_SNAPSHOT_DIR/pr-schema-gate" "$HOME/.claude/hooks/pr-schema-gate" -link_item "external-claim-gate" "$HOOKS_SNAPSHOT_DIR/external-claim-gate" "$HOME/.claude/hooks/external-claim-gate" -link_item "wrong-check-reflect" "$HOOKS_SNAPSHOT_DIR/wrong-check-reflect" "$HOME/.claude/hooks/wrong-check-reflect" -link_item "handback-needs-attempt" "$HOOKS_SNAPSHOT_DIR/handback-needs-attempt" "$HOME/.claude/hooks/handback-needs-attempt" -link_item "llm-judge" "$HOOKS_SNAPSHOT_DIR/llm-judge" "$HOME/.claude/hooks/llm-judge" -link_item "hook-health" "$HOOKS_SNAPSHOT_DIR/hook-health" "$HOME/.claude/hooks/hook-health" -link_item "build-the-lever" "$HOOKS_SNAPSHOT_DIR/build-the-lever" "$HOME/.claude/hooks/build-the-lever" -link_item "split-scope" "$HOOKS_SNAPSHOT_DIR/split-scope" "$HOME/.claude/hooks/split-scope" -link_item "no-comments" "$HOOKS_SNAPSHOT_DIR/no-comments" "$HOME/.claude/hooks/no-comments" -link_item "explicit-failures" "$HOOKS_SNAPSHOT_DIR/explicit-failures" "$HOME/.claude/hooks/explicit-failures" -link_item "text-match-decision-warn" "$HOOKS_SNAPSHOT_DIR/text-match-decision-warn" "$HOME/.claude/hooks/text-match-decision-warn" -link_item "bound-tool-result" "$HOOKS_SNAPSHOT_DIR/bound-tool-result" "$HOME/.claude/hooks/bound-tool-result" -link_item "repeat-error-stop" "$HOOKS_SNAPSHOT_DIR/repeat-error-stop" "$HOME/.claude/hooks/repeat-error-stop" -link_item "repeat-deny-stop" "$HOOKS_SNAPSHOT_DIR/repeat-deny-stop" "$HOME/.claude/hooks/repeat-deny-stop" -link_item "prove-it-ship-gate" "$HOOKS_SNAPSHOT_DIR/prove-it-ship-gate" "$HOME/.claude/hooks/prove-it-ship-gate" -link_item "narrow-the-scope" "$HOOKS_SNAPSHOT_DIR/narrow-the-scope" "$HOME/.claude/hooks/narrow-the-scope" -link_item "answer-overrides-menu" "$HOOKS_SNAPSHOT_DIR/answer-overrides-menu" "$HOME/.claude/hooks/answer-overrides-menu" -link_item "serial-option-guard" "$HOOKS_SNAPSHOT_DIR/serial-option-guard" "$HOME/.claude/hooks/serial-option-guard" -link_item "cat-mode-default" "$HOOKS_SNAPSHOT_DIR/cat-mode-default" "$HOME/.claude/hooks/cat-mode-default" -link_item "fanout-routing-guard" "$HOOKS_SNAPSHOT_DIR/fanout-routing-guard" "$HOME/.claude/hooks/fanout-routing-guard" -link_item "playbook-router" "$HOOKS_SNAPSHOT_DIR/playbook-router" "$HOME/.claude/hooks/playbook-router" -link_item "restated-constraint" "$HOOKS_SNAPSHOT_DIR/restated-constraint" "$HOME/.claude/hooks/restated-constraint" -link_item "named-verb-guard" "$HOOKS_SNAPSHOT_DIR/named-verb-guard" "$HOME/.claude/hooks/named-verb-guard" -link_item "user-did-it" "$HOOKS_SNAPSHOT_DIR/user-did-it" "$HOME/.claude/hooks/user-did-it" -echo "--- claude hooks: wait / hedge / callout stack ---" -link_item "wait-needs-wakeup" "$HOOKS_SNAPSHOT_DIR/wait-needs-wakeup" "$HOME/.claude/hooks/wait-needs-wakeup" -link_item "hedge-runs-prove-it" "$HOOKS_SNAPSHOT_DIR/hedge-runs-prove-it" "$HOME/.claude/hooks/hedge-runs-prove-it" -link_item "gate-blame-needs-evidence" "$HOOKS_SNAPSHOT_DIR/gate-blame-needs-evidence" "$HOME/.claude/hooks/gate-blame-needs-evidence" -link_item "unverified-tag-ledger" "$HOOKS_SNAPSHOT_DIR/unverified-tag-ledger" "$HOME/.claude/hooks/unverified-tag-ledger" -link_item "unverified-tag-check" "$HOOKS_SNAPSHOT_DIR/unverified-tag-check" "$HOME/.claude/hooks/unverified-tag-check" -link_item "incidence-needs-repetition" "$HOOKS_SNAPSHOT_DIR/incidence-needs-repetition" "$HOME/.claude/hooks/incidence-needs-repetition" -link_item "verdict-flip-watch" "$HOOKS_SNAPSHOT_DIR/verdict-flip-watch" "$HOME/.claude/hooks/verdict-flip-watch" -link_item "new-file-callout" "$HOOKS_SNAPSHOT_DIR/new-file-callout" "$HOME/.claude/hooks/new-file-callout" -link_item "agent-relay-attribution" "$HOOKS_SNAPSHOT_DIR/agent-relay-attribution" "$HOME/.claude/hooks/agent-relay-attribution" -link_item "scratchpad-collision" "$HOOKS_SNAPSHOT_DIR/scratchpad-collision" "$HOME/.claude/hooks/scratchpad-collision" -link_item "ui-input-guard" "$HOOKS_SNAPSHOT_DIR/ui-input-guard" "$HOME/.claude/hooks/ui-input-guard" -link_item "handoff-needs-smoke-test" "$HOOKS_SNAPSHOT_DIR/handoff-needs-smoke-test" "$HOME/.claude/hooks/handoff-needs-smoke-test" -link_item "hook-freshness" "$HOOKS_SNAPSHOT_DIR/hook-freshness" "$HOME/.claude/hooks/hook-freshness" -link_item "gh-write-verification" "$HOOKS_SNAPSHOT_DIR/gh-write-verification" "$HOME/.claude/hooks/gh-write-verification" -link_item "history-before-reversal" "$HOOKS_SNAPSHOT_DIR/history-before-reversal" "$HOME/.claude/hooks/history-before-reversal" -link_item "publish-act-guard" "$HOOKS_SNAPSHOT_DIR/publish-act-guard" "$HOME/.claude/hooks/publish-act-guard" -link_item "categorical-scope-guard" "$HOOKS_SNAPSHOT_DIR/categorical-scope-guard" "$HOME/.claude/hooks/categorical-scope-guard" -link_item "claimed-search-not-run" "$HOOKS_SNAPSHOT_DIR/claimed-search-not-run" "$HOME/.claude/hooks/claimed-search-not-run" -link_item "agent-launch-guard" "$HOOKS_SNAPSHOT_DIR/agent-launch-guard" "$HOME/.claude/hooks/agent-launch-guard" +while IFS= read -r hook_name; do + link_item "$hook_name" "$HOOKS_SNAPSHOT_DIR/$hook_name" "$HOME/.claude/hooks/$hook_name" +done < <(python3 "$REPO_DIR/engine/hooks/_sdk/install_from_registry.py" --list-harness-hooks claude) echo "--- git pre-push hooks (init.templateDir and this clone) ---" bash "$REPO_DIR/scripts/install/install-git-template.sh" @@ -419,22 +369,9 @@ install_local_runner "$REPO_DIR/engine/hooks/_runner" "$HOME/.cursor/hooks/_runn link_item "_flags" "$HOOKS_SNAPSHOT_DIR/_flags" "$HOME/.cursor/hooks/_flags" link_item "_sdk" "$HOOKS_SNAPSHOT_DIR/_sdk" "$HOME/.cursor/hooks/_sdk" link_item "_markers" "$HOOKS_SNAPSHOT_DIR/_markers" "$HOME/.cursor/hooks/_markers" -link_item "bug-complaint-leak" "$HOOKS_SNAPSHOT_DIR/bug-complaint-leak" "$HOME/.cursor/hooks/bug-complaint-leak" -link_item "reflect-on-thrash" "$HOOKS_SNAPSHOT_DIR/reflect-on-thrash" "$HOME/.cursor/hooks/reflect-on-thrash" -link_item "scope-lock" "$HOOKS_SNAPSHOT_DIR/scope-lock" "$HOME/.cursor/hooks/scope-lock" -link_item "auto-pr" "$HOOKS_SNAPSHOT_DIR/auto-pr" "$HOME/.cursor/hooks/auto-pr" -link_item "pr-schema-gate" "$HOOKS_SNAPSHOT_DIR/pr-schema-gate" "$HOME/.cursor/hooks/pr-schema-gate" -link_item "wrong-check-reflect" "$HOOKS_SNAPSHOT_DIR/wrong-check-reflect" "$HOME/.cursor/hooks/wrong-check-reflect" -link_item "llm-judge" "$HOOKS_SNAPSHOT_DIR/llm-judge" "$HOME/.cursor/hooks/llm-judge" -link_item "hook-health" "$HOOKS_SNAPSHOT_DIR/hook-health" "$HOME/.cursor/hooks/hook-health" -link_item "skill-usage-log" "$HOOKS_SNAPSHOT_DIR/skill-usage-log" "$HOME/.cursor/hooks/skill-usage-log" -link_item "build-the-lever" "$HOOKS_SNAPSHOT_DIR/build-the-lever" "$HOME/.cursor/hooks/build-the-lever" -link_item "split-scope" "$HOOKS_SNAPSHOT_DIR/split-scope" "$HOME/.cursor/hooks/split-scope" -link_item "repeat-error-stop" "$HOOKS_SNAPSHOT_DIR/repeat-error-stop" "$HOME/.cursor/hooks/repeat-error-stop" -link_item "ui-input-guard" "$HOOKS_SNAPSHOT_DIR/ui-input-guard" "$HOME/.cursor/hooks/ui-input-guard" -link_item "text-match-decision-warn" "$HOOKS_SNAPSHOT_DIR/text-match-decision-warn" "$HOME/.cursor/hooks/text-match-decision-warn" -link_item "bound-tool-result" "$HOOKS_SNAPSHOT_DIR/bound-tool-result" "$HOME/.cursor/hooks/bound-tool-result" -link_item "unverified-tag-check" "$HOOKS_SNAPSHOT_DIR/unverified-tag-check" "$HOME/.cursor/hooks/unverified-tag-check" +while IFS= read -r hook_name; do + link_item "$hook_name" "$HOOKS_SNAPSHOT_DIR/$hook_name" "$HOME/.cursor/hooks/$hook_name" +done < <(python3 "$REPO_DIR/engine/hooks/_sdk/install_from_registry.py" --list-harness-hooks cursor) echo "--- codex hooks (\$HOME/.codex/hooks) ---" mkdir -p "$HOME/.codex/hooks" @@ -442,21 +379,9 @@ install_local_runner "$REPO_DIR/engine/hooks/_runner" "$HOME/.codex/hooks/_runne link_item "_flags" "$HOOKS_SNAPSHOT_DIR/_flags" "$HOME/.codex/hooks/_flags" link_item "_sdk" "$HOOKS_SNAPSHOT_DIR/_sdk" "$HOME/.codex/hooks/_sdk" link_item "_markers" "$HOOKS_SNAPSHOT_DIR/_markers" "$HOME/.codex/hooks/_markers" -link_item "diu-stop" "$HOOKS_SNAPSHOT_DIR/diu-stop" "$HOME/.codex/hooks/diu-stop" -link_item "scope-lock" "$HOOKS_SNAPSHOT_DIR/scope-lock" "$HOME/.codex/hooks/scope-lock" -link_item "auto-pr" "$HOOKS_SNAPSHOT_DIR/auto-pr" "$HOME/.codex/hooks/auto-pr" -link_item "pr-schema-gate" "$HOOKS_SNAPSHOT_DIR/pr-schema-gate" "$HOME/.codex/hooks/pr-schema-gate" -link_item "wrong-check-reflect" "$HOOKS_SNAPSHOT_DIR/wrong-check-reflect" "$HOME/.codex/hooks/wrong-check-reflect" -link_item "llm-judge" "$HOOKS_SNAPSHOT_DIR/llm-judge" "$HOME/.codex/hooks/llm-judge" -link_item "hook-health" "$HOOKS_SNAPSHOT_DIR/hook-health" "$HOME/.codex/hooks/hook-health" -link_item "skill-usage-log" "$HOOKS_SNAPSHOT_DIR/skill-usage-log" "$HOME/.codex/hooks/skill-usage-log" -link_item "build-the-lever" "$HOOKS_SNAPSHOT_DIR/build-the-lever" "$HOME/.codex/hooks/build-the-lever" -link_item "split-scope" "$HOOKS_SNAPSHOT_DIR/split-scope" "$HOME/.codex/hooks/split-scope" -link_item "repeat-error-stop" "$HOOKS_SNAPSHOT_DIR/repeat-error-stop" "$HOME/.codex/hooks/repeat-error-stop" -link_item "ui-input-guard" "$HOOKS_SNAPSHOT_DIR/ui-input-guard" "$HOME/.codex/hooks/ui-input-guard" -link_item "text-match-decision-warn" "$HOOKS_SNAPSHOT_DIR/text-match-decision-warn" "$HOME/.codex/hooks/text-match-decision-warn" -link_item "bound-tool-result" "$HOOKS_SNAPSHOT_DIR/bound-tool-result" "$HOME/.codex/hooks/bound-tool-result" -link_item "unverified-tag-check" "$HOOKS_SNAPSHOT_DIR/unverified-tag-check" "$HOME/.codex/hooks/unverified-tag-check" +while IFS= read -r hook_name; do + link_item "$hook_name" "$HOOKS_SNAPSHOT_DIR/$hook_name" "$HOME/.codex/hooks/$hook_name" +done < <(python3 "$REPO_DIR/engine/hooks/_sdk/install_from_registry.py" --list-harness-hooks codex) echo "--- cursor hooks.json (\$HOME/.cursor/hooks.json) ---" mkdir -p "$HOME/.cursor" diff --git a/scripts/ci/check_install_effective.py b/scripts/ci/check_install_effective.py index 6b60ffe2..d46bee3e 100755 --- a/scripts/ci/check_install_effective.py +++ b/scripts/ci/check_install_effective.py @@ -270,7 +270,11 @@ def registry_hook_names() -> set[str]: def active_registry_hook_names() -> set[str]: registry, _thresholds = load_registry(REPO / "engine/hooks/hooks.toml") - return set(registry) + return { + name + for name, record in registry.items() + if record.mode != "off" + } def check_hooks_registered() -> list[str]: diff --git a/scripts/install/mirror_stop_hooks_to_subagent_stop.py b/scripts/install/mirror_stop_hooks_to_subagent_stop.py index e980c19b..132a5392 100644 --- a/scripts/install/mirror_stop_hooks_to_subagent_stop.py +++ b/scripts/install/mirror_stop_hooks_to_subagent_stop.py @@ -58,7 +58,11 @@ def command_prefix(self) -> str: def active_registry_hooks() -> set[str]: registry, _thresholds = load_registry() - return set(registry) + return { + name + for name, record in registry.items() + if record.mode != "off" + } def load_manifests( diff --git a/tests/test_install.py b/tests/test_install.py index e160852e..0c155107 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -117,7 +117,8 @@ def registry_records(): def active_registry_hooks(): return { name - for name in registry_records() + for name, record in registry_records().items() + if record["mode"] != "off" if glob.glob(os.path.join(REPO_ROOT, "engine", "hooks", name, "*.hook*.json")) } @@ -737,10 +738,7 @@ def test_skill_usage_log_is_linked_but_not_wired_while_registry_mode_is_off(self for hooks, event, marker in expected: with self.subTest(event=event, marker=marker): matching = [entry for entry in hooks[event] if marker in json.dumps(entry)] - self.assertEqual(len(matching), 1, matching) - self.assertIn("_runner/run.py", json.dumps(matching[0])) - claude_pre = [entry for entry in claude_hooks["PreToolUse"] if "skill-usage-log/" in json.dumps(entry)] - self.assertEqual(claude_pre[0]["matcher"], "Skill|Read|Bash") + self.assertEqual(matching, []) def test_llm_judge_inbox_wired_for_claude_cursor_and_codex(self): for agent_dir in (".claude", ".cursor", ".codex"): @@ -773,10 +771,10 @@ def test_unverified_tag_check_is_linked_but_not_wired_while_registry_mode_is_off self.assertTrue(os.path.islink(target), target) self.assertEqual(os.readlink(target), hook_src(self.fake_home, "unverified-tag-check")) claude_stop = self._claude_hook_commands("Stop") - self.assertEqual(sum("unverified-tag-check/claude_stop_check.py" in command for command in claude_stop), 1, claude_stop) + self.assertEqual(sum("unverified-tag-check/claude_stop_check.py" in command for command in claude_stop), 0, claude_stop) with open(os.path.join(self.fake_home, ".cursor", "hooks.json")) as handle: cursor_stop = json.load(handle)["hooks"]["stop"] - self.assertEqual(sum("unverified-tag-check/cursor_session.py" in str(entry.get("command", "")) for entry in cursor_stop), 1, cursor_stop) + self.assertEqual(sum("unverified-tag-check/cursor_session.py" in str(entry.get("command", "")) for entry in cursor_stop), 0, cursor_stop) with open(config_path) as handle: text = handle.read() self.assertIn('model = "gpt-5"', text)