Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
13 changes: 13 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "python3 -m scripts.hooks" }
]
}
]
}
}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
__pycache__/
*.pyc
.pytest_cache/
.claude/settings.local.json
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ directly when working the paths noted below.
repo loads every skill without copying: Claude Code reads `.claude/skills`, and Cursor, Codex, and
Copilot read `.agents/skills`. Edit skills under `skills/`; adding one needs no symlink change, since
both links point at the whole directory.
- `.claude/settings.json` (committed) registers the local reminder hooks in `scripts/hooks/`;
`.claude/settings.local.json` is per-user and not committed. See `rules/architecture.md`.
- `docs/INDEX.md` and `docs/graph.json` are generated by `build_graph`; never hand-edit them.
- `docs/.verification/` and `docs/.curation/` are the audit trail; never delete them.
- Other pipeline-internal conventions (the shared `scripts/frontmatter.py` parser, the
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ python -m scripts.lint_manifest
python -m scripts.lint_docs
```

Opening the repo in Claude Code loads local `PreToolUse` reminder hooks (`scripts/hooks/`, registered
in `.claude/settings.json`) that nudge `gh pr create` / `gh issue create` toward the create-pr /
create-issue skills and list the staged set before `git commit`. They only remind, never block; see
`rules/architecture.md`.

See `docs/superpowers/specs/2026-06-18-strata-documentation-engine-design.md` for the design.

## Contributing
Expand Down
15 changes: 15 additions & 0 deletions rules/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,21 @@ editing the graph builder, linter, or delta classifier, emit a visible record ra
- Source removed from `sources.md` with docs still present: `source_delta` reports **orphaned**.
- Drifted source documented under a week ago: `source_delta` reports **throttled**.

## Local reminder hooks

`.claude/settings.json` registers one non-blocking `PreToolUse` hook under the `Bash` matcher,
`python3 -m scripts.hooks`, that keeps durable-artifact conventions in front of an agent as it acts:
route `gh pr create` / `gh issue create` through the create-pr / create-issue skills, and print the
staged-vs-unstaged file lists before `git commit`. Each check is a pure
`reminder(command, cwd) -> str | None` in its own `scripts/hooks/` module, over the single I/O
contract in `scripts/hooks/__init__.py`; the module docstrings are the contract, so read those before
changing a check. `scripts/hooks/__main__.py` is the dispatcher: it reads the payload once and runs
every check listed in its `CHECKS` tuple, so adding a check means writing a module and appending to
`CHECKS` (tested Python), never editing `settings.json`. Reminders only guide, never block (they emit
`additionalContext`, never a `permissionDecision`), so create-pr's own `gh pr create` proceeds.
`.claude/settings.json` is committed and shared; `.claude/settings.local.json` is per-user and not
committed.

## Conventions

- `docs/.verification/` and `docs/.curation/` are audit trail. Keep them.
Expand Down
65 changes: 65 additions & 0 deletions scripts/hooks/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Shared plumbing for the PreToolUse Bash reminder hooks.

Each sibling module is one check: a pure `reminder(command, cwd) -> str | None`. `__main__.py` is
the single entrypoint `.claude/settings.json` invokes; it reads the hook payload once and runs every
check. This module owns the I/O contract with Claude Code so the checks stay pure and self-evident:
read the Bash command (and the tool's working directory) from the payload on stdin, and, when a check
returns text, emit it as non-blocking `additionalContext` that the harness injects into the model's
context. Nothing here blocks the tool.
"""
import json
import re
import sys


def payload_from_stdin():
"""The parsed hook payload on stdin, or {} if unavailable."""
try:
return json.load(sys.stdin)
except Exception:
return {}


def command_of(payload):
"""The Bash command string from a hook payload, or ''."""
return (payload.get("tool_input") or {}).get("command") or ""


def cwd_of(payload):
"""The working directory Claude Code reports for the tool call, or None."""
return payload.get("cwd") or None


def command_from_stdin():
"""The Bash command string from the payload on stdin, or '' (single-check `run` path)."""
return command_of(payload_from_stdin())


def matches(command, *words):
"""True if `command` runs `words` as a whitespace-separated token sequence.

The hook payload only carries the raw command string (there is no structured "which binary
ran" signal), and fully parsing the shell (pipes, quoting, `$()`, aliases) is impractical and
still defeatable. A word-boundary regex is the right cost/benefit for an advisory, non-blocking
nudge: it tolerates extra whitespace and, unlike a plain substring test, does not fire on
hyphenated look-alikes like `gh-pr-create-helper`. A false positive only adds a harmless
reminder, so we do not chase quoted-string or comment edge cases.
"""
pattern = r"\b" + r"\s+".join(map(re.escape, words)) + r"\b"
return re.search(pattern, command) is not None
Comment thread
jeffhorn-nava marked this conversation as resolved.


def emit(message):
"""Inject `message` into the model's context (non-blocking)."""
json.dump(
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": message}},
sys.stdout,
)


def run(reminder):
"""Wire a single check's `reminder` to the I/O contract for direct invocation. Returns 0."""
message = reminder(command_from_stdin())
if message:
emit(message)
return 0
Comment thread
jeffhorn-nava marked this conversation as resolved.
26 changes: 26 additions & 0 deletions scripts/hooks/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""The single entrypoint `.claude/settings.json` invokes (`python3 -m scripts.hooks`).

Reads the PreToolUse payload once, runs every registered check against the Bash command, and emits
the combined reminder as one non-blocking `additionalContext` block. Register a check by adding its
`reminder` to CHECKS below: the list of active checks lives in tested Python, so settings.json needs
one entry and never changes when a check is added or removed.
"""
import sys

from scripts.hooks import command_of, cwd_of, emit, payload_from_stdin
from scripts.hooks import git_commit, issue_create, pr_create

CHECKS = (pr_create.reminder, issue_create.reminder, git_commit.reminder)


def main():
payload = payload_from_stdin()
command, cwd = command_of(payload), cwd_of(payload)
messages = [m for check in CHECKS if (m := check(command, cwd))]
if messages:
emit("\n\n".join(messages))
return 0


if __name__ == "__main__":
sys.exit(main())
41 changes: 41 additions & 0 deletions scripts/hooks/git_commit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Before `git commit`, surface the staged vs unstaged file lists.

A commit records only what is staged. Printing the three lists at commit time makes a missing
`git add` visible in context, so the intended change set and the staged set can be reconciled
before the commit lands.
"""
import subprocess
import sys

from scripts.hooks import matches, run


def _git(cwd, *args):
"""Trimmed stdout of a git command run in `cwd`, or '' on any failure."""
prefix = ["-C", cwd] if cwd else []
try:
result = subprocess.run(["git", *prefix, *args], capture_output=True, text=True, timeout=5)
return result.stdout.strip()
except Exception:
return ""


def reminder(command, cwd=None):
if not matches(command, "git", "commit"):
return None
# cwd is the tool's reported working directory, so the lists match the repo the commit runs in
# rather than the hook's own process cwd. A `cd` buried inside the command still isn't reflected.
staged = _git(cwd, "diff", "--cached", "--name-only") or "(none)"
unstaged = _git(cwd, "diff", "--name-only") or "(none)"
untracked = _git(cwd, "ls-files", "--others", "--exclude-standard") or "(none)"
Comment thread
jeffhorn-nava marked this conversation as resolved.
Outdated
return (
"git commit reminder. Confirm the staged set matches your intended change set "
"before committing.\n\n"
f"Staged (will be committed):\n{staged}\n\n"
f"Modified but NOT staged:\n{unstaged}\n\n"
f"Untracked:\n{untracked}"
)


if __name__ == "__main__":
sys.exit(run(reminder))
18 changes: 18 additions & 0 deletions scripts/hooks/issue_create.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""Nudge `gh issue create` toward the create-issue skill."""
import sys

from scripts.hooks import matches, run

REMINDER = (
"Reminder: open issues through the create-issue skill. It picks the matching template, "
"runs review-draft, and files with the right label. "
"If you are already inside create-issue, proceed."
)


def reminder(command, cwd=None):
return REMINDER if matches(command, "gh", "issue", "create") else None


if __name__ == "__main__":
sys.exit(run(reminder))
18 changes: 18 additions & 0 deletions scripts/hooks/pr_create.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""Nudge `gh pr create` toward the create-pr skill."""
import sys

from scripts.hooks import matches, run

REMINDER = (
"Reminder: open PRs through the create-pr skill. It fills "
".github/PULL_REQUEST_TEMPLATE.md, runs review-draft, and opens the PR as a draft. "
"If you are already inside create-pr, proceed."
)


def reminder(command, cwd=None):
return REMINDER if matches(command, "gh", "pr", "create") else None


if __name__ == "__main__":
sys.exit(run(reminder))
91 changes: 91 additions & 0 deletions tests/test_hook_reminders.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import io
import json

from scripts.hooks import command_from_stdin, matches, run
from scripts.hooks import __main__ as dispatcher
from scripts.hooks import git_commit, issue_create, pr_create


def test_pr_create_matches_even_in_compound_command():
assert pr_create.reminder("cd repo && gh pr create --draft") == pr_create.REMINDER


def test_issue_create_matches():
assert issue_create.reminder("gh issue create --label bug") == issue_create.REMINDER


def test_issue_create_has_inside_skill_caveat():
assert "already inside create-issue" in issue_create.REMINDER


def test_checks_ignore_unrelated_commands():
assert pr_create.reminder("ls -la") is None
assert issue_create.reminder("git status") is None
assert git_commit.reminder("echo hi") is None


def test_matches_tolerates_whitespace_but_not_lookalikes():
assert matches("gh pr create", "gh", "pr", "create")
assert not matches("gh-pr-create-helper run", "gh", "pr", "create")


def test_git_commit_lists_staged_and_unstaged(monkeypatch):
outputs = {
("diff", "--cached", "--name-only"): "a.py",
("diff", "--name-only"): "b.py",
("ls-files", "--others", "--exclude-standard"): "c.py",
}
monkeypatch.setattr(git_commit, "_git", lambda cwd, *args: outputs[args])
message = git_commit.reminder("git commit -m x")
assert "a.py" in message and "b.py" in message and "c.py" in message
assert "Staged (will be committed)" in message


def test_git_commit_threads_cwd_into_git(monkeypatch):
seen = []
monkeypatch.setattr(git_commit, "_git", lambda cwd, *args: seen.append(cwd) or "f.py")
git_commit.reminder("git commit -m x", cwd="/repo/sub")
assert seen == ["/repo/sub", "/repo/sub", "/repo/sub"]


def test_command_from_stdin_handles_bad_input(monkeypatch):
monkeypatch.setattr("sys.stdin", io.StringIO("not json"))
assert command_from_stdin() == ""


def test_run_emits_valid_hook_json(monkeypatch):
out = io.StringIO()
monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps({"tool_input": {"command": "x"}})))
monkeypatch.setattr("sys.stdout", out)
assert run(lambda command: "hello") == 0
assert json.loads(out.getvalue())["hookSpecificOutput"]["additionalContext"] == "hello"
Comment thread
jeffhorn-nava marked this conversation as resolved.


def test_run_is_silent_when_check_returns_none(monkeypatch):
out = io.StringIO()
monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps({"tool_input": {"command": "x"}})))
monkeypatch.setattr("sys.stdout", out)
assert run(lambda command: None) == 0
assert out.getvalue() == ""
Comment thread
jeffhorn-nava marked this conversation as resolved.


def test_dispatch_combines_reminders_and_threads_cwd(monkeypatch):
seen = []
monkeypatch.setattr(git_commit, "_git", lambda cwd, *args: seen.append(cwd) or "f.py")
payload = {"tool_input": {"command": "gh pr create && git commit -m x"}, "cwd": "/repo"}
out = io.StringIO()
monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps(payload)))
monkeypatch.setattr("sys.stdout", out)
assert dispatcher.main() == 0
context = json.loads(out.getvalue())["hookSpecificOutput"]["additionalContext"]
assert pr_create.REMINDER in context
assert "git commit reminder" in context
assert seen == ["/repo", "/repo", "/repo"]


def test_dispatch_is_silent_on_unrelated_command(monkeypatch):
out = io.StringIO()
monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps({"tool_input": {"command": "ls -la"}})))
monkeypatch.setattr("sys.stdout", out)
assert dispatcher.main() == 0
assert out.getvalue() == ""