Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions engine/hooks/split-scope/README.md
Original file line number Diff line number Diff line change
@@ -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
```
15 changes: 15 additions & 0 deletions engine/hooks/split-scope/claude.prompt.hook.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "python3 $HOME/.claude/hooks/split-scope/claude_prompt_submit.py",
"timeout": 10
}
]
}
]
}
}
39 changes: 39 additions & 0 deletions engine/hooks/split-scope/claude_prompt_submit.py
Original file line number Diff line number Diff line change
@@ -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()
15 changes: 15 additions & 0 deletions engine/hooks/split-scope/codex.hook.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "python3 $HOME/.codex/hooks/split-scope/codex_prompt_submit.py",
"timeout": 10
}
]
}
]
}
}
39 changes: 39 additions & 0 deletions engine/hooks/split-scope/codex_prompt_submit.py
Original file line number Diff line number Diff line change
@@ -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()
27 changes: 27 additions & 0 deletions engine/hooks/split-scope/cursor_before_submit.py
Original file line number Diff line number Diff line change
@@ -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()
29 changes: 29 additions & 0 deletions engine/hooks/split-scope/cursor_post_tool_use.py
Original file line number Diff line number Diff line change
@@ -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()
67 changes: 67 additions & 0 deletions engine/hooks/split-scope/detect.py
Original file line number Diff line number Diff line change
@@ -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)
58 changes: 58 additions & 0 deletions engine/hooks/split-scope/install_claude_hook.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading