Skip to content

Commit 047462e

Browse files
committed
Add PreToolUse reminder hooks for artifact skills
A retrospective on PR #25 traced repeated rework to hand-rolling a PR instead of invoking the create-pr skill. Add non-blocking PreToolUse hooks that route gh pr create / gh issue create toward their skills and print the staged-vs-unstaged file lists before git commit, so the conventions stay in front of the agent at the moment it acts. Each check is its own small module over a shared stdin/stdout contract in scripts/hooks/, registered per-check in a committed .claude/settings .json and covered by unit tests. Closes #27
1 parent 4334280 commit 047462e

6 files changed

Lines changed: 175 additions & 0 deletions

File tree

.claude/settings.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"hooks": {
4+
"PreToolUse": [
5+
{
6+
"matcher": "Bash",
7+
"hooks": [
8+
{ "type": "command", "command": "python3 -m scripts.hooks.pr_create" },
9+
{ "type": "command", "command": "python3 -m scripts.hooks.issue_create" },
10+
{ "type": "command", "command": "python3 -m scripts.hooks.git_commit" }
11+
]
12+
}
13+
]
14+
}
15+
}

scripts/hooks/__init__.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Shared plumbing for the PreToolUse Bash reminder hooks.
2+
3+
Each sibling module is one check. A check defines a pure `reminder(command) -> str | None`
4+
and, under `__main__`, calls `run(reminder)`. This module owns the single I/O contract with
5+
Claude Code so the checks stay pure and self-evident: read the Bash command from the hook
6+
payload on stdin, and, when a check returns text, emit it as non-blocking `additionalContext`
7+
that the harness injects into the model's context. Nothing here blocks the tool.
8+
"""
9+
import json
10+
import sys
11+
12+
13+
def command_from_stdin():
14+
"""The Bash command string from the hook payload on stdin, or '' if unavailable."""
15+
try:
16+
payload = json.load(sys.stdin)
17+
except Exception:
18+
return ""
19+
return (payload.get("tool_input") or {}).get("command") or ""
20+
21+
22+
def emit(message):
23+
"""Inject `message` into the model's context (non-blocking)."""
24+
json.dump(
25+
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": message}},
26+
sys.stdout,
27+
)
28+
29+
30+
def run(reminder):
31+
"""Wire a check's `reminder(command)` to the hook I/O contract. Always returns 0."""
32+
message = reminder(command_from_stdin())
33+
if message:
34+
emit(message)
35+
return 0

scripts/hooks/git_commit.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""Before `git commit`, surface the staged vs unstaged file lists.
2+
3+
A commit records only what is staged. Printing the three lists at commit time makes a missing
4+
`git add` visible in context, so the intended change set and the staged set can be reconciled
5+
before the commit lands.
6+
"""
7+
import subprocess
8+
import sys
9+
10+
from scripts.hooks import run
11+
12+
13+
def _git(*args):
14+
"""Trimmed stdout of a git command, or '' on any failure."""
15+
try:
16+
result = subprocess.run(["git", *args], capture_output=True, text=True, timeout=5)
17+
return result.stdout.strip()
18+
except Exception:
19+
return ""
20+
21+
22+
def reminder(command):
23+
if "git commit" not in command:
24+
return None
25+
staged = _git("diff", "--cached", "--name-only") or "(none)"
26+
unstaged = _git("diff", "--name-only") or "(none)"
27+
untracked = _git("ls-files", "--others", "--exclude-standard") or "(none)"
28+
return (
29+
"git commit reminder. Confirm the staged set matches your intended change set "
30+
"before committing.\n\n"
31+
f"Staged (will be committed):\n{staged}\n\n"
32+
f"Modified but NOT staged:\n{unstaged}\n\n"
33+
f"Untracked:\n{untracked}"
34+
)
35+
36+
37+
if __name__ == "__main__":
38+
sys.exit(run(reminder))

scripts/hooks/issue_create.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""Nudge `gh issue create` toward the create-issue skill."""
2+
import sys
3+
4+
from scripts.hooks import run
5+
6+
REMINDER = (
7+
"Reminder: open issues through the create-issue skill. It picks the matching template, "
8+
"runs review-draft, and files with the right label."
9+
)
10+
11+
12+
def reminder(command):
13+
return REMINDER if "gh issue create" in command else None
14+
15+
16+
if __name__ == "__main__":
17+
sys.exit(run(reminder))

scripts/hooks/pr_create.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""Nudge `gh pr create` toward the create-pr skill."""
2+
import sys
3+
4+
from scripts.hooks import run
5+
6+
REMINDER = (
7+
"Reminder: open PRs through the create-pr skill. It fills "
8+
".github/PULL_REQUEST_TEMPLATE.md, runs review-draft, and opens the PR as a draft. "
9+
"If you are already inside create-pr, proceed."
10+
)
11+
12+
13+
def reminder(command):
14+
return REMINDER if "gh pr create" in command else None
15+
16+
17+
if __name__ == "__main__":
18+
sys.exit(run(reminder))

tests/test_hook_reminders.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import io
2+
import json
3+
4+
from scripts.hooks import command_from_stdin, run
5+
from scripts.hooks import git_commit, issue_create, pr_create
6+
7+
8+
def test_pr_create_matches_even_in_compound_command():
9+
assert pr_create.reminder("cd repo && gh pr create --draft") == pr_create.REMINDER
10+
11+
12+
def test_issue_create_matches():
13+
assert issue_create.reminder("gh issue create --label bug") == issue_create.REMINDER
14+
15+
16+
def test_checks_ignore_unrelated_commands():
17+
assert pr_create.reminder("ls -la") is None
18+
assert issue_create.reminder("git status") is None
19+
assert git_commit.reminder("echo hi") is None
20+
21+
22+
def test_git_commit_lists_staged_and_unstaged(monkeypatch):
23+
outputs = {
24+
("diff", "--cached", "--name-only"): "a.py",
25+
("diff", "--name-only"): "b.py",
26+
("ls-files", "--others", "--exclude-standard"): "c.py",
27+
}
28+
monkeypatch.setattr(git_commit, "_git", lambda *args: outputs[args])
29+
message = git_commit.reminder("git commit -m x")
30+
assert "a.py" in message and "b.py" in message and "c.py" in message
31+
assert "Staged (will be committed)" in message
32+
33+
34+
def test_command_from_stdin_handles_bad_input(monkeypatch):
35+
monkeypatch.setattr("sys.stdin", io.StringIO("not json"))
36+
assert command_from_stdin() == ""
37+
38+
39+
def test_run_emits_valid_hook_json(monkeypatch):
40+
out = io.StringIO()
41+
monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps({"tool_input": {"command": "x"}})))
42+
monkeypatch.setattr("sys.stdout", out)
43+
assert run(lambda command: "hello") == 0
44+
assert json.loads(out.getvalue())["hookSpecificOutput"]["additionalContext"] == "hello"
45+
46+
47+
def test_run_is_silent_when_check_returns_none(monkeypatch):
48+
out = io.StringIO()
49+
monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps({"tool_input": {"command": "x"}})))
50+
monkeypatch.setattr("sys.stdout", out)
51+
assert run(lambda command: None) == 0
52+
assert out.getvalue() == ""

0 commit comments

Comments
 (0)