diff --git a/engine/hooks/split-scope/README.md b/engine/hooks/split-scope/README.md new file mode 100644 index 0000000..d10f524 --- /dev/null +++ b/engine/hooks/split-scope/README.md @@ -0,0 +1,47 @@ +# split-scope + +Inject the existing `split-scope` skill reminder when a prompt plans +multi-slice or multi-PR work. The skill is descriptive, so a hook supplies +the prompt-time nudge before a plan or PR stack is written. + +Fail-open. Inject-only. Never blocks tools. Stays silent on single-file +edits, typo fixes, and questions that only mention split-scope by name. + +## Fires On + +- `pr stack`, `stack of prs`, `stacked prs` +- `multiple prs`, `several prs`, `multi-pr` +- `split this into`, `break this into prs`, `into slices` +- `migration plan`, `plan a migration`, `plan the migration` + +## Silent On + +- one-file edits and typo fixes +- split-scope meta questions such as `what does split-scope do?` +- malformed hook input, which logs to stderr and allows the prompt or tool + +## Reminder + +`split-scope: this prompt plans multi-slice work. Before writing the plan or PR stack, read the split-scope skill (product/skills/split-scope/SKILL.md, or the installed split-scope skill) and give each slice one review claim with a user-confirmed safety invariant.` + +## Files + +- `detect.py` — prompt regexes and shared reminder text +- `state.py` — Cursor-only pending reminder state under `~/.cache/catstack-split-scope` +- `claude_prompt_submit.py` — Claude inject +- `cursor_before_submit.py` / `cursor_post_tool_use.py` — Cursor parity + (`beforeSubmitPrompt` cannot inject; reminder arrives on first `postToolUse`) +- `codex_prompt_submit.py` — Codex inject +- `install_claude_hook.py` / `install_cursor_hook.py` / `install_codex_hook.py` + +## Install + +`./install.sh` from the catstack repo root, then restart Claude Code, Cursor, +and Codex (Codex also needs `/hooks` trust). + +## Tests + +```sh +python3 -m unittest discover -s engine/hooks/split-scope/tests -v +python3 scripts/check_hook_test_coverage.py engine/hooks/split-scope +``` diff --git a/engine/hooks/split-scope/claude.prompt.hook.json b/engine/hooks/split-scope/claude.prompt.hook.json new file mode 100644 index 0000000..37cdb7e --- /dev/null +++ b/engine/hooks/split-scope/claude.prompt.hook.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 $HOME/.claude/hooks/split-scope/claude_prompt_submit.py", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/engine/hooks/split-scope/claude_prompt_submit.py b/engine/hooks/split-scope/claude_prompt_submit.py new file mode 100755 index 0000000..988b603 --- /dev/null +++ b/engine/hooks/split-scope/claude_prompt_submit.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Claude Code UserPromptSubmit entrypoint for split-scope reminders.""" +from __future__ import annotations + +import json +import sys +import traceback + +from detect import extract_prompt_text, plans_multi_slice_work, reminder_text + + +def _fail_open(context: str) -> None: + print(f"split-scope claude_prompt_submit fail-open during {context}", file=sys.stderr) + traceback.print_exc(file=sys.stderr) + + +def main() -> None: + try: + payload = json.load(sys.stdin) + if not isinstance(payload, dict): + return + if not plans_multi_slice_work(extract_prompt_text(payload)): + return + print( + json.dumps( + { + "hookSpecificOutput": { + "hookEventName": "UserPromptSubmit", + "additionalContext": reminder_text(), + } + } + ) + ) + except Exception: + _fail_open("prompt detection") + + +if __name__ == "__main__": + main() diff --git a/engine/hooks/split-scope/codex.hook.json b/engine/hooks/split-scope/codex.hook.json new file mode 100644 index 0000000..84d8f31 --- /dev/null +++ b/engine/hooks/split-scope/codex.hook.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 $HOME/.codex/hooks/split-scope/codex_prompt_submit.py", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/engine/hooks/split-scope/codex_prompt_submit.py b/engine/hooks/split-scope/codex_prompt_submit.py new file mode 100755 index 0000000..cbd7e96 --- /dev/null +++ b/engine/hooks/split-scope/codex_prompt_submit.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Codex UserPromptSubmit entrypoint for split-scope reminders.""" +from __future__ import annotations + +import json +import sys +import traceback + +from detect import extract_prompt_text, plans_multi_slice_work, reminder_text + + +def _fail_open(context: str) -> None: + print(f"split-scope codex_prompt_submit fail-open during {context}", file=sys.stderr) + traceback.print_exc(file=sys.stderr) + + +def main() -> None: + try: + payload = json.load(sys.stdin) + if not isinstance(payload, dict): + return + if not plans_multi_slice_work(extract_prompt_text(payload)): + return + print( + json.dumps( + { + "hookSpecificOutput": { + "hookEventName": "UserPromptSubmit", + "additionalContext": reminder_text(), + } + } + ) + ) + except Exception: + _fail_open("prompt detection") + + +if __name__ == "__main__": + main() diff --git a/engine/hooks/split-scope/cursor_before_submit.py b/engine/hooks/split-scope/cursor_before_submit.py new file mode 100755 index 0000000..622244f --- /dev/null +++ b/engine/hooks/split-scope/cursor_before_submit.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Cursor beforeSubmitPrompt entrypoint for split-scope reminders.""" +from __future__ import annotations + +import json +import sys +import traceback + +from detect import extract_prompt_text, plans_multi_slice_work, remember_cursor_prompt + + +def _fail_open(context: str) -> None: + print(f"split-scope cursor_before_submit fail-open during {context}", file=sys.stderr) + traceback.print_exc(file=sys.stderr) + + +def main() -> None: + try: + payload = json.load(sys.stdin) + if isinstance(payload, dict) and plans_multi_slice_work(extract_prompt_text(payload)): + remember_cursor_prompt(payload) + except Exception: + _fail_open("prompt detection") + + +if __name__ == "__main__": + main() diff --git a/engine/hooks/split-scope/cursor_post_tool_use.py b/engine/hooks/split-scope/cursor_post_tool_use.py new file mode 100755 index 0000000..25b6ae4 --- /dev/null +++ b/engine/hooks/split-scope/cursor_post_tool_use.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Cursor postToolUse entrypoint for split-scope reminders.""" +from __future__ import annotations + +import json +import sys +import traceback + +from detect import consume_cursor_prompt, reminder_text + + +def _fail_open(context: str) -> None: + print(f"split-scope cursor_post_tool_use fail-open during {context}", file=sys.stderr) + traceback.print_exc(file=sys.stderr) + + +def main() -> None: + try: + payload = json.load(sys.stdin) + if not isinstance(payload, dict): + return + if consume_cursor_prompt(payload): + print(json.dumps({"additional_context": reminder_text()})) + except Exception: + _fail_open("pending reminder delivery") + + +if __name__ == "__main__": + main() diff --git a/engine/hooks/split-scope/detect.py b/engine/hooks/split-scope/detect.py new file mode 100644 index 0000000..d872cbd --- /dev/null +++ b/engine/hooks/split-scope/detect.py @@ -0,0 +1,67 @@ +"""Shared detection for split-scope prompt inject hooks.""" +from __future__ import annotations + +import re + +from state import consume_pending, remember_pending + +SKILL_PATH = "/".join(("product", "skills", "split-scope", "SKILL.md")) +REMINDER = ( + "split-scope: this prompt plans multi-slice work. Before writing the plan " + f"or PR stack, read the split-scope skill ({SKILL_PATH}, or the installed " + "split-scope skill) and give each slice one review claim with a " + "user-confirmed safety invariant." +) + +TRIGGERS = ( + re.compile(r"\bpr\s+stack\b", re.I), + re.compile(r"\bstack\s+of\s+prs\b", re.I), + re.compile(r"\bstacked\s+prs\b", re.I), + re.compile(r"\bmultiple\s+prs\b", re.I), + re.compile(r"\bseveral\s+prs\b", re.I), + re.compile(r"\bmulti[-\s]?pr\b", re.I), + re.compile(r"\bsplit\s+this\s+into\b", re.I), + re.compile(r"\bbreak\s+this\s+into\s+prs\b", re.I), + re.compile(r"\binto\s+slices\b", re.I), + re.compile(r"\bmigration\s+plan\b", re.I), + re.compile(r"\bplan\s+a\s+migration\b", re.I), + re.compile(r"\bplan\s+the\s+migration\b", re.I), +) + + +def reminder_text() -> str: + return REMINDER + + +def extract_prompt_text(payload: dict) -> str: + for key in ("prompt", "user_prompt", "userPrompt", "message", "text"): + value = payload.get(key) + if isinstance(value, str) and value.strip(): + return value + content = payload.get("content") + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for item in content: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict) and item.get("type") == "text": + parts.append(str(item.get("text") or "")) + return "\n".join(parts) + return "" + + +def plans_multi_slice_work(prompt: str) -> bool: + text = (prompt or "").strip() + if not text: + return False + return any(pattern.search(text) for pattern in TRIGGERS) + + +def remember_cursor_prompt(payload: dict) -> None: + remember_pending(payload) + + +def consume_cursor_prompt(payload: dict) -> bool: + return consume_pending(payload) diff --git a/engine/hooks/split-scope/install_claude_hook.py b/engine/hooks/split-scope/install_claude_hook.py new file mode 100755 index 0000000..880bb2b --- /dev/null +++ b/engine/hooks/split-scope/install_claude_hook.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Merge split-scope Claude hooks into ~/.claude/settings.json without wiping others.""" +from __future__ import annotations + +import json +import os + +HERE = os.path.dirname(os.path.abspath(__file__)) +SETTINGS_PATH = os.path.expanduser("~/.claude/settings.json") +HOOK_SPECS = [ + ("UserPromptSubmit", "split-scope/claude_prompt_submit.py", os.path.join(HERE, "claude.prompt.hook.json")), +] + + +def _is_ours(entry: dict, marker: str) -> bool: + return any(marker in h.get("command", "") for h in entry.get("hooks", [])) + + +def merge_hook_type(settings: dict, hook_type: str, marker: str, fragment: dict) -> bool: + entry_list = settings.setdefault("hooks", {}).setdefault(hook_type, []) + new_entries = fragment.get("hooks", {}).get(hook_type, []) + before = json.dumps(entry_list, sort_keys=True) + kept = [e for e in entry_list if not _is_ours(e, marker)] + entry_list[:] = kept + new_entries + return json.dumps(entry_list, sort_keys=True) != before + + +def main() -> None: + settings: dict = {} + if os.path.exists(SETTINGS_PATH): + with open(SETTINGS_PATH, encoding="utf-8") as handle: + settings = json.load(handle) + + any_changed = False + loaded: dict[str, dict] = {} + for hook_type, marker, fragment_path in HOOK_SPECS: + if fragment_path not in loaded: + with open(fragment_path, encoding="utf-8") as handle: + loaded[fragment_path] = json.load(handle) + fragment = loaded[fragment_path] + if merge_hook_type(settings, hook_type, marker, fragment): + any_changed = True + print(f"link claude {hook_type} split-scope merged") + else: + print(f"ok claude {hook_type} split-scope already up to date") + + if not any_changed: + return + + os.makedirs(os.path.dirname(SETTINGS_PATH), exist_ok=True) + with open(SETTINGS_PATH, "w", encoding="utf-8") as handle: + json.dump(settings, handle, indent=2) + handle.write("\n") + print(" (restart Claude Code to pick up the change)") + + +if __name__ == "__main__": + main() diff --git a/engine/hooks/split-scope/install_codex_hook.py b/engine/hooks/split-scope/install_codex_hook.py new file mode 100755 index 0000000..87a1c38 --- /dev/null +++ b/engine/hooks/split-scope/install_codex_hook.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Idempotently merge split-scope into Codex's native lifecycle hooks.""" +from __future__ import annotations + +import copy +import json +import os + +HERE = os.path.dirname(os.path.abspath(__file__)) +HOOKS_PATH = os.path.expanduser("~/.codex/hooks.json") +FRAGMENT_PATH = os.path.join(HERE, "codex.hook.json") +MARKERS = { + "UserPromptSubmit": "split-scope/codex_prompt_submit.py", +} +LEGACY_EVENTS = { + "user_prompt_submit": "UserPromptSubmit", +} + + +def _is_ours(entry: dict, marker: str) -> bool: + return any(marker in str(hook.get("command", "")) for hook in entry.get("hooks", [])) + + +def merge_hooks(settings: dict) -> dict: + result = copy.deepcopy(settings) + hooks = result.get("hooks") + if not isinstance(hooks, dict): + hooks = {} + result["hooks"] = hooks + + for legacy_name, event in LEGACY_EVENTS.items(): + entries = result.pop(legacy_name, []) + if isinstance(entries, list): + hooks.setdefault(event, []).extend( + entry for entry in entries if isinstance(entry, dict) + ) + + with open(FRAGMENT_PATH, encoding="utf-8") as handle: + fragment = json.load(handle)["hooks"] + for event, marker in MARKERS.items(): + existing = hooks.get(event, []) + kept = [entry for entry in existing if not _is_ours(entry, marker)] + hooks[event] = kept + fragment[event] + return result + + +def main() -> None: + settings: dict = {} + if os.path.exists(HOOKS_PATH): + with open(HOOKS_PATH, encoding="utf-8") as handle: + settings = json.load(handle) + merged = merge_hooks(settings) + if merged == settings: + print("ok codex split-scope hooks already up to date") + return + os.makedirs(os.path.dirname(HOOKS_PATH), exist_ok=True) + with open(HOOKS_PATH, "w", encoding="utf-8") as handle: + json.dump(merged, handle, indent=2) + handle.write("\n") + print("link codex UserPromptSubmit split-scope merged") + print(" (review with /hooks, trust the definitions, then restart Codex)") + + +if __name__ == "__main__": + main() diff --git a/engine/hooks/split-scope/install_cursor_hook.py b/engine/hooks/split-scope/install_cursor_hook.py new file mode 100755 index 0000000..1678e93 --- /dev/null +++ b/engine/hooks/split-scope/install_cursor_hook.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Merge split-scope Cursor hooks into ~/.cursor/hooks.json without wiping others.""" +from __future__ import annotations + +import json +import os + +HOOKS_PATH = os.path.expanduser("~/.cursor/hooks.json") +FRAGMENT = { + "beforeSubmitPrompt": [ + { + "command": "python3 $HOME/.cursor/hooks/split-scope/cursor_before_submit.py", + "timeout": 10, + } + ], + "postToolUse": [ + { + "command": "python3 $HOME/.cursor/hooks/split-scope/cursor_post_tool_use.py", + "timeout": 5, + } + ], +} +MARKERS = { + "beforeSubmitPrompt": "split-scope/cursor_before_submit.py", + "postToolUse": "split-scope/cursor_post_tool_use.py", +} +DIU_STOP = { + "type": "prompt", + "prompt": ( + "Find the assistant's last response in this conversation and check it against this rule: " + "it should read under ~150 words and be free of unexplained jargon, UNLESS the user's last " + "message explicitly asked for full technical detail, a specific long format (a PR summary, " + "a written plan, a file list), or the response already applies an explicit ELI5 word cap " + "the user gave. Output ONLY a single JSON object and nothing else -- no explanation, no " + "analysis, no markdown fences, before or after it. If it violates the rule and none of " + "those exceptions apply, output exactly: {\"followup_message\": \"Apply diu: rewrite under " + "40 words, plain language.\"}. Otherwise output exactly: {\"followup_message\": \"\"}." + ), + "timeout": 30, +} + + +def _is_ours(entry: dict, marker: str) -> bool: + return marker in str(entry.get("command", "")) + + +def merge_list(existing: list, incoming: list, marker: str) -> list: + kept = [e for e in existing if not _is_ours(e, marker)] + return kept + incoming + + +def load_hooks() -> dict: + if not os.path.exists(HOOKS_PATH): + return {"version": 1, "hooks": {"stop": [DIU_STOP]}} + with open(HOOKS_PATH, encoding="utf-8") as handle: + data = json.load(handle) + if not isinstance(data, dict): + return {"version": 1, "hooks": {"stop": [DIU_STOP]}} + data.setdefault("version", 1) + data.setdefault("hooks", {}) + data["hooks"].setdefault("stop", [DIU_STOP]) + return data + + +def materialize_real_file_if_symlink() -> bool: + if not os.path.islink(HOOKS_PATH): + return False + data = load_hooks() + os.unlink(HOOKS_PATH) + os.makedirs(os.path.dirname(HOOKS_PATH), exist_ok=True) + with open(HOOKS_PATH, "w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2) + handle.write("\n") + print("fix cursor hooks.json was a symlink; replaced with a real merged file") + return True + + +def main() -> None: + materialize_real_file_if_symlink() + data = load_hooks() + hooks = data.setdefault("hooks", {}) + + changed = False + for key, incoming in FRAGMENT.items(): + before = json.dumps(hooks.get(key, []), sort_keys=True) + hooks[key] = merge_list(list(hooks.get(key, [])), incoming, MARKERS[key]) + after = json.dumps(hooks[key], sort_keys=True) + if before != after: + changed = True + print(f"link cursor {key} split-scope merged") + else: + print(f"ok cursor {key} split-scope already up to date") + + stop = list(hooks.get("stop") or []) + if not any("Apply diu" in str(e.get("prompt", "")) for e in stop): + hooks["stop"] = [DIU_STOP] + stop + changed = True + print("link cursor stop diu entry restored") + + if not changed and not os.path.islink(HOOKS_PATH): + return + + os.makedirs(os.path.dirname(HOOKS_PATH), exist_ok=True) + with open(HOOKS_PATH, "w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2) + handle.write("\n") + print(" (restart Cursor to pick up the change)") + + +if __name__ == "__main__": + main() diff --git a/engine/hooks/split-scope/state.py b/engine/hooks/split-scope/state.py new file mode 100644 index 0000000..998eed0 --- /dev/null +++ b/engine/hooks/split-scope/state.py @@ -0,0 +1,68 @@ +"""Session-local state for split-scope Cursor prompt handoff.""" +from __future__ import annotations + +import json +import os +import time +from typing import Any + +STATE_DIR = os.environ.get( + "CATSTACK_SPLIT_SCOPE_STATE_DIR", + os.path.join(os.path.expanduser("~"), ".cache", "catstack-split-scope"), +) +TTL_SECONDS = 2 * 60 * 60 + + +def _session_key(payload: dict) -> str: + for key in ("session_id", "sessionId", "conversation_id", "conversationId", "transcript_path"): + value = payload.get(key) + if isinstance(value, str) and value.strip(): + return value.strip().replace("/", "_")[-80:] + cwd = payload.get("cwd") or payload.get("workspace_roots") or "default" + if isinstance(cwd, list): + cwd = cwd[0] if cwd else "default" + return str(cwd).replace("/", "_")[-80:] + + +def state_path(payload: dict) -> str: + os.makedirs(STATE_DIR, exist_ok=True) + return os.path.join(STATE_DIR, f"{_session_key(payload)}.json") + + +def load_state(payload: dict) -> dict[str, Any]: + path = state_path(payload) + try: + with open(path, encoding="utf-8") as handle: + data = json.load(handle) + if not isinstance(data, dict): + return {} + created_at = data.get("created_at") + if not isinstance(created_at, (int, float)): + return {} + if time.time() - created_at > TTL_SECONDS: + return {} + return data + except (OSError, json.JSONDecodeError, TypeError): + return {} + + +def save_state(payload: dict, state: dict[str, Any]) -> None: + path = state_path(payload) + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as handle: + json.dump(state, handle) + except OSError: + pass + + +def remember_pending(payload: dict) -> None: + save_state(payload, {"pending": True, "created_at": time.time()}) + + +def consume_pending(payload: dict) -> bool: + state = load_state(payload) + if not state.get("pending"): + return False + save_state(payload, {"pending": False, "created_at": time.time()}) + return True diff --git a/engine/hooks/split-scope/tests/test_hooks.py b/engine/hooks/split-scope/tests/test_hooks.py new file mode 100755 index 0000000..d2739d9 --- /dev/null +++ b/engine/hooks/split-scope/tests/test_hooks.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Unit tests for split-scope inject hooks.""" +from __future__ import annotations + +import io +import json +import os +import sys +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from unittest.mock import patch + +HOOKS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, HOOKS_DIR) + +import claude_prompt_submit # noqa: E402 +import codex_prompt_submit # noqa: E402 +import cursor_before_submit # noqa: E402 +import cursor_post_tool_use # noqa: E402 +import detect # noqa: E402 +import state # noqa: E402 + +SKILL_PATH = "/".join(("product", "skills", "split-scope", "SKILL.md")) +REMINDER = ( + "split-scope: this prompt plans multi-slice work. Before writing the plan " + f"or PR stack, read the split-scope skill ({SKILL_PATH}, or the installed " + "split-scope skill) and give each slice one review claim with a " + "user-confirmed safety invariant." +) + + +def run_main(main, stdin_text: str) -> tuple[str, str, int]: + out = io.StringIO() + err = io.StringIO() + code = 0 + with patch.object(sys, "stdin", io.StringIO(stdin_text)): + with redirect_stdout(out), redirect_stderr(err): + try: + main() + except SystemExit as exc: + code = int(exc.code or 0) + return out.getvalue(), err.getvalue(), code + + +def run_json(main, payload: dict) -> tuple[str, str, int]: + return run_main(main, json.dumps(payload)) + + +class SplitScopeCase(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + state.STATE_DIR = self.tmp.name + + def tearDown(self) -> None: + self.tmp.cleanup() + + +class TestDetect(SplitScopeCase): + def test_pr_stack_phrase_fires(self) -> None: + self.assertTrue(detect.plans_multi_slice_work("Plan a PR stack for this change.")) + + def test_split_this_into_phrase_fires(self) -> None: + self.assertTrue(detect.plans_multi_slice_work("Split this into reviewable pieces.")) + + def test_migration_plan_phrase_fires(self) -> None: + self.assertTrue(detect.plans_multi_slice_work("Write a migration plan for the API move.")) + + def test_single_file_edit_stays_silent(self) -> None: + self.assertFalse(detect.plans_multi_slice_work("Edit this one file to add the import.")) + + def test_typo_fix_stays_silent(self) -> None: + self.assertFalse(detect.plans_multi_slice_work("Fix the typo in README.md.")) + + def test_split_scope_name_question_stays_silent(self) -> None: + self.assertFalse(detect.plans_multi_slice_work("What does split-scope do?")) + + +class TestClaudePromptEntrypoint(SplitScopeCase): + def assert_claude_fires(self, prompt: str) -> None: + out, err, code = run_json( + claude_prompt_submit.main, + {"prompt": prompt, "session_id": "claude-positive"}, + ) + self.assertEqual(code, 0, err) + data = json.loads(out) + self.assertEqual( + data["hookSpecificOutput"]["additionalContext"], + REMINDER, + ) + self.assertEqual(err, "") + + def assert_claude_silent(self, prompt: str) -> None: + out, err, code = run_json( + claude_prompt_submit.main, + {"prompt": prompt, "session_id": "claude-negative"}, + ) + self.assertEqual(code, 0, err) + self.assertEqual(out, "") + self.assertEqual(err, "") + + def test_real_claude_entrypoint_fires_on_multiple_prs(self) -> None: + self.assert_claude_fires("Please plan multiple PRs for this refactor.") + + def test_real_claude_entrypoint_fires_on_stacked_prs(self) -> None: + self.assert_claude_fires("Create stacked PRs for the API migration.") + + def test_real_claude_entrypoint_fires_on_plan_the_migration(self) -> None: + self.assert_claude_fires("Plan the migration before changing code.") + + def test_real_claude_entrypoint_prints_nothing_on_single_file_edit(self) -> None: + self.assert_claude_silent("Change the label in this single file.") + + def test_real_claude_entrypoint_prints_nothing_on_typo_fix(self) -> None: + self.assert_claude_silent("Fix the recieve typo in README.md.") + + def test_real_claude_entrypoint_prints_nothing_on_split_scope_question(self) -> None: + self.assert_claude_silent("What does split-scope do?") + + def test_malformed_json_fails_open_with_empty_stdout(self) -> None: + cases = ( + (claude_prompt_submit.main, "split-scope claude_prompt_submit fail-open"), + (codex_prompt_submit.main, "split-scope codex_prompt_submit fail-open"), + (cursor_before_submit.main, "split-scope cursor_before_submit fail-open"), + (cursor_post_tool_use.main, "split-scope cursor_post_tool_use fail-open"), + ) + for main, marker in cases: + with self.subTest(marker=marker): + out, err, code = run_main(main, "not-json") + self.assertEqual(code, 0) + self.assertEqual(out, "") + self.assertIn(marker, err) + + +class TestCursorHandoff(SplitScopeCase): + def test_cursor_pending_prompt_injects_once_on_first_post_tool_use(self) -> None: + out, err, code = run_json( + cursor_before_submit.main, + {"prompt": "Break this into PRs.", "session_id": "cursor-1"}, + ) + self.assertEqual(code, 0, err) + self.assertEqual(out, "") + self.assertEqual(err, "") + + out, err, code = run_json( + cursor_post_tool_use.main, + {"session_id": "cursor-1", "tool_name": "Edit"}, + ) + self.assertEqual(code, 0, err) + self.assertEqual(json.loads(out)["additional_context"], REMINDER) + self.assertEqual(err, "") + + out, err, code = run_json( + cursor_post_tool_use.main, + {"session_id": "cursor-1", "tool_name": "Edit"}, + ) + self.assertEqual(code, 0, err) + self.assertEqual(out, "") + self.assertEqual(err, "") + + def test_cursor_before_submit_prints_nothing_on_nonmatch(self) -> None: + out, err, code = run_json( + cursor_before_submit.main, + {"prompt": "Fix this one typo.", "session_id": "cursor-quiet"}, + ) + self.assertEqual(code, 0, err) + self.assertEqual(out, "") + self.assertEqual(err, "") + + +if __name__ == "__main__": + unittest.main() diff --git a/install.sh b/install.sh index 6feac88..146dddc 100755 --- a/install.sh +++ b/install.sh @@ -232,6 +232,7 @@ link_item "external-claim-gate" "$REPO_DIR/engine/hooks/external-claim-gate" "$H link_item "wrong-check-reflect" "$REPO_DIR/engine/hooks/wrong-check-reflect" "$HOME/.claude/hooks/wrong-check-reflect" link_item "llm-judge" "$REPO_DIR/engine/hooks/llm-judge" "$HOME/.claude/hooks/llm-judge" link_item "build-the-lever" "$REPO_DIR/engine/hooks/build-the-lever" "$HOME/.claude/hooks/build-the-lever" +link_item "split-scope" "$REPO_DIR/engine/hooks/split-scope" "$HOME/.claude/hooks/split-scope" link_item "no-comments" "$REPO_DIR/engine/hooks/no-comments" "$HOME/.claude/hooks/no-comments" link_item "explicit-failures" "$REPO_DIR/engine/hooks/explicit-failures" "$HOME/.claude/hooks/explicit-failures" link_item "repeat-error-stop" "$REPO_DIR/engine/hooks/repeat-error-stop" "$HOME/.claude/hooks/repeat-error-stop" @@ -272,6 +273,7 @@ link_item "pr-schema-gate" "$REPO_DIR/engine/hooks/pr-schema-gate" "$HOME/.curso link_item "wrong-check-reflect" "$REPO_DIR/engine/hooks/wrong-check-reflect" "$HOME/.cursor/hooks/wrong-check-reflect" link_item "llm-judge" "$REPO_DIR/engine/hooks/llm-judge" "$HOME/.cursor/hooks/llm-judge" link_item "build-the-lever" "$REPO_DIR/engine/hooks/build-the-lever" "$HOME/.cursor/hooks/build-the-lever" +link_item "split-scope" "$REPO_DIR/engine/hooks/split-scope" "$HOME/.cursor/hooks/split-scope" link_item "repeat-error-stop" "$REPO_DIR/engine/hooks/repeat-error-stop" "$HOME/.cursor/hooks/repeat-error-stop" link_item "ui-input-guard" "$REPO_DIR/engine/hooks/ui-input-guard" "$HOME/.cursor/hooks/ui-input-guard" @@ -284,6 +286,7 @@ link_item "pr-schema-gate" "$REPO_DIR/engine/hooks/pr-schema-gate" "$HOME/.codex link_item "wrong-check-reflect" "$REPO_DIR/engine/hooks/wrong-check-reflect" "$HOME/.codex/hooks/wrong-check-reflect" link_item "llm-judge" "$REPO_DIR/engine/hooks/llm-judge" "$HOME/.codex/hooks/llm-judge" link_item "build-the-lever" "$REPO_DIR/engine/hooks/build-the-lever" "$HOME/.codex/hooks/build-the-lever" +link_item "split-scope" "$REPO_DIR/engine/hooks/split-scope" "$HOME/.codex/hooks/split-scope" link_item "repeat-error-stop" "$REPO_DIR/engine/hooks/repeat-error-stop" "$HOME/.codex/hooks/repeat-error-stop" link_item "ui-input-guard" "$REPO_DIR/engine/hooks/ui-input-guard" "$HOME/.codex/hooks/ui-input-guard" @@ -342,6 +345,7 @@ python3 "$REPO_DIR/engine/hooks/external-claim-gate/install_claude_hook.py" python3 "$REPO_DIR/engine/hooks/wrong-check-reflect/install_claude_hook.py" python3 "$REPO_DIR/engine/hooks/llm-judge/install_claude_hook.py" python3 "$REPO_DIR/engine/hooks/build-the-lever/install_claude_hook.py" +python3 "$REPO_DIR/engine/hooks/split-scope/install_claude_hook.py" python3 "$REPO_DIR/engine/hooks/no-comments/install_claude_hook.py" python3 "$REPO_DIR/engine/hooks/explicit-failures/install_claude_hook.py" python3 "$REPO_DIR/engine/hooks/repeat-error-stop/install_claude_hook.py" @@ -385,6 +389,7 @@ python3 "$REPO_DIR/engine/hooks/pr-schema-gate/install_cursor_hook.py" python3 "$REPO_DIR/engine/hooks/wrong-check-reflect/install_cursor_hook.py" python3 "$REPO_DIR/engine/hooks/llm-judge/install_cursor_hook.py" python3 "$REPO_DIR/engine/hooks/build-the-lever/install_cursor_hook.py" +python3 "$REPO_DIR/engine/hooks/split-scope/install_cursor_hook.py" python3 "$REPO_DIR/engine/hooks/repeat-error-stop/install_cursor_hook.py" echo "--- codex notify (\$HOME/.codex/config.toml) ---" @@ -400,6 +405,7 @@ python3 "$REPO_DIR/engine/hooks/pr-schema-gate/install_codex_hook.py" echo "--- codex native scope-lock hooks (\$HOME/.codex/hooks.json) ---" python3 "$REPO_DIR/engine/hooks/scope-lock/install_codex_hook.py" python3 "$REPO_DIR/engine/hooks/build-the-lever/install_codex_hook.py" +python3 "$REPO_DIR/engine/hooks/split-scope/install_codex_hook.py" python3 "$REPO_DIR/engine/hooks/repeat-error-stop/install_codex_hook.py" # CLAUDE.md is a dedicated file with no other unrelated config mixed into it diff --git a/tests/test_install.py b/tests/test_install.py index 23274d6..279ae20 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -437,6 +437,41 @@ def test_scope_lock_wired_for_claude_cursor_and_codex(self): self.assertTrue(any("codex_prompt_scope.py" in command for command in codex_prompt_commands)) self.assertTrue(any("codex_pretool_scope.py" in command for command in codex_pretool_commands)) + def test_split_scope_wired_for_claude_cursor_and_codex(self): + for agent_dir in (".claude", ".cursor", ".codex"): + target = os.path.join(self.fake_home, agent_dir, "hooks", "split-scope") + self.assertTrue(os.path.islink(target), target) + self.assertEqual(os.readlink(target), hook_src("split-scope")) + + with open(os.path.join(self.fake_home, ".claude", "settings.json")) as handle: + settings = json.load(handle) + claude_prompt_commands = [ + hook["command"] + for entry in settings["hooks"]["UserPromptSubmit"] + for hook in entry["hooks"] + ] + self.assertTrue(any("split-scope/claude_prompt_submit.py" in command for command in claude_prompt_commands)) + + with open(os.path.join(self.fake_home, ".cursor", "hooks.json")) as handle: + cursor_hooks = json.load(handle)["hooks"] + self.assertTrue(any( + "split-scope/cursor_before_submit.py" in str(entry.get("command", "")) + for entry in cursor_hooks["beforeSubmitPrompt"] + )) + self.assertTrue(any( + "split-scope/cursor_post_tool_use.py" in str(entry.get("command", "")) + for entry in cursor_hooks["postToolUse"] + )) + + with open(os.path.join(self.fake_home, ".codex", "hooks.json")) as handle: + codex_hooks = json.load(handle)["hooks"] + codex_prompt_commands = [ + hook["command"] + for entry in codex_hooks["UserPromptSubmit"] + for hook in entry["hooks"] + ] + self.assertTrue(any("split-scope/codex_prompt_submit.py" in command for command in codex_prompt_commands)) + def test_llm_judge_inbox_wired_for_claude_cursor_and_codex(self): for agent_dir in (".claude", ".cursor", ".codex"): target = os.path.join(self.fake_home, agent_dir, "hooks", "llm-judge") @@ -762,6 +797,7 @@ def test_rerun_reports_already_done_and_produces_no_duplicates(self): for hook_type, marker in ( ("UserPromptSubmit", "build-the-lever/claude_prompt_submit.py"), + ("UserPromptSubmit", "split-scope/claude_prompt_submit.py"), ("PostToolUse", "build-the-lever/claude_posttooluse.py"), ): matching = [ @@ -1146,7 +1182,7 @@ def test_agent_pretooluse_hook_wired_for_claude(self): class TestCursorHooksDanglingLink(unittest.TestCase): - INSTALLERS = ("bug-complaint-leak", "build-the-lever", "pr-schema-gate") + INSTALLERS = ("bug-complaint-leak", "build-the-lever", "split-scope", "pr-schema-gate") DIU_PROMPT_START = "Find the assistant's last response in this conversation" def _seed_link(self, fake_home, target):