Skip to content

Commit 6cd3ad7

Browse files
Merge pull request #28 from navapbc/jeffhorn/27-reinforce-durable-artifact-gates
Add PreToolUse reminder hooks for artifact skills
2 parents 52a9004 + 4d14ca5 commit 6cd3ad7

11 files changed

Lines changed: 298 additions & 0 deletions

File tree

.claude/settings.json

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

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
__pycache__/
44
*.pyc
55
.pytest_cache/
6+
.claude/settings.local.json

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,8 @@ directly when working the paths noted below.
118118
repo loads every skill without copying: Claude Code reads `.claude/skills`, and Cursor, Codex, and
119119
Copilot read `.agents/skills`. Edit skills under `skills/`; adding one needs no symlink change, since
120120
both links point at the whole directory.
121+
- `.claude/settings.json` (committed) registers the local reminder hooks in `scripts/hooks/`;
122+
`.claude/settings.local.json` is per-user and not committed. See `rules/architecture.md`.
121123
- `docs/INDEX.md` and `docs/graph.json` are generated by `build_graph`; never hand-edit them.
122124
- `docs/.verification/` and `docs/.curation/` are the audit trail; never delete them.
123125
- Other pipeline-internal conventions (the shared `scripts/frontmatter.py` parser) live in

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ python -m scripts.lint_manifest
3838
python -m scripts.lint_docs
3939
```
4040

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

4348
See [CONTRIBUTING.md](CONTRIBUTING.md) for the contribution workflow, branch naming, commit

rules/architecture.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,21 @@ editing the graph builder, linter, or delta classifier, emit a visible record ra
6666
- Source removed from `sources.md` with docs still present: `source_delta` reports **orphaned**.
6767
- Drifted source documented under a week ago: `source_delta` reports **throttled**.
6868

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

7186
- `docs/.verification/` and `docs/.curation/` are audit trail. Keep them.

scripts/hooks/__init__.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Shared plumbing for the PreToolUse Bash reminder hooks.
2+
3+
Each sibling module is one check: a pure `reminder(command, cwd) -> str | None`. `__main__.py` is
4+
the single entrypoint `.claude/settings.json` invokes; it reads the hook payload once and runs every
5+
check. This module owns the I/O contract with Claude Code so the checks stay pure and self-evident:
6+
read the Bash command (and the tool's working directory) from the payload on stdin, and, when a check
7+
returns text, emit it as non-blocking `additionalContext` that the harness injects into the model's
8+
context. Nothing here blocks the tool.
9+
"""
10+
import json
11+
import re
12+
import sys
13+
14+
15+
def payload_from_stdin():
16+
"""The parsed hook payload on stdin, or {} if unavailable."""
17+
try:
18+
return json.load(sys.stdin)
19+
except Exception:
20+
return {}
21+
22+
23+
def command_of(payload):
24+
"""The Bash command string from a hook payload, or ''."""
25+
return (payload.get("tool_input") or {}).get("command") or ""
26+
27+
28+
def cwd_of(payload):
29+
"""The working directory Claude Code reports for the tool call, or None."""
30+
return payload.get("cwd") or None
31+
32+
33+
def matches(command, *words):
34+
"""True if `command` runs `words` as a whitespace-separated token sequence.
35+
36+
The hook payload only carries the raw command string (there is no structured "which binary
37+
ran" signal), and fully parsing the shell (pipes, quoting, `$()`, aliases) is impractical and
38+
still defeatable. A word-boundary regex is the right cost/benefit for an advisory, non-blocking
39+
nudge: it tolerates extra whitespace and, unlike a plain substring test, does not fire on
40+
hyphenated look-alikes like `gh-pr-create-helper`. A false positive only adds a harmless
41+
reminder, so we do not chase quoted-string or comment edge cases.
42+
"""
43+
pattern = r"\b" + r"\s+".join(map(re.escape, words)) + r"\b"
44+
return re.search(pattern, command) is not None
45+
46+
47+
def emit(message):
48+
"""Inject `message` into the model's context (non-blocking)."""
49+
json.dump(
50+
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": message}},
51+
sys.stdout,
52+
)
53+
54+
55+
def run(reminder):
56+
"""Wire a single check's `reminder` to the I/O contract for direct invocation. Returns 0."""
57+
payload = payload_from_stdin()
58+
message = reminder(command_of(payload), cwd_of(payload))
59+
if message:
60+
emit(message)
61+
return 0

scripts/hooks/__main__.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""The single entrypoint `.claude/settings.json` invokes (`python3 -m scripts.hooks`).
2+
3+
Reads the PreToolUse payload once, runs every registered check against the Bash command, and emits
4+
the combined reminder as one non-blocking `additionalContext` block. Register a check by adding its
5+
`reminder` to CHECKS below: the list of active checks lives in tested Python, so settings.json needs
6+
one entry and never changes when a check is added or removed.
7+
"""
8+
import sys
9+
10+
from scripts.hooks import command_of, cwd_of, emit, payload_from_stdin
11+
from scripts.hooks import git_commit, issue_create, pr_create
12+
13+
CHECKS = (pr_create.reminder, issue_create.reminder, git_commit.reminder)
14+
15+
16+
def main():
17+
payload = payload_from_stdin()
18+
command, cwd = command_of(payload), cwd_of(payload)
19+
messages = [m for check in CHECKS if (m := check(command, cwd))]
20+
if messages:
21+
emit("\n\n".join(messages))
22+
return 0
23+
24+
25+
if __name__ == "__main__":
26+
sys.exit(main())

scripts/hooks/git_commit.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
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 matches, run
11+
12+
13+
def _git(cwd, *args):
14+
"""Trimmed stdout of a git command run in `cwd`, or None on failure (including timeouts)."""
15+
prefix = ["-C", cwd] if cwd else []
16+
try:
17+
result = subprocess.run(["git", *prefix, *args], capture_output=True, text=True, timeout=5)
18+
except subprocess.TimeoutExpired:
19+
return None
20+
except Exception:
21+
return None
22+
if result.returncode != 0:
23+
return None
24+
return result.stdout.strip()
25+
26+
27+
def reminder(command, cwd=None):
28+
if not matches(command, "git", "commit"):
29+
return None
30+
# cwd is the tool's reported working directory, so the lists match the repo the commit runs in
31+
# rather than the hook's own process cwd. A `cd` buried inside the command still isn't reflected.
32+
staged = _git(cwd, "diff", "--cached", "--name-only")
33+
unstaged = _git(cwd, "diff", "--name-only")
34+
untracked = _git(cwd, "ls-files", "--others", "--exclude-standard")
35+
staged = "(unavailable)" if staged is None else (staged or "(none)")
36+
unstaged = "(unavailable)" if unstaged is None else (unstaged or "(none)")
37+
untracked = "(unavailable)" if untracked is None else (untracked or "(none)")
38+
return (
39+
"git commit reminder. Confirm the staged set matches your intended change set "
40+
"before committing.\n\n"
41+
f"Staged (will be committed):\n{staged}\n\n"
42+
f"Modified but NOT staged:\n{unstaged}\n\n"
43+
f"Untracked:\n{untracked}"
44+
)
45+
46+
47+
if __name__ == "__main__":
48+
sys.exit(run(reminder))

scripts/hooks/issue_create.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""Nudge `gh issue create` toward the create-issue skill."""
2+
import sys
3+
4+
from scripts.hooks import matches, 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+
"If you are already inside create-issue, proceed."
10+
)
11+
12+
13+
def reminder(command, cwd=None):
14+
return REMINDER if matches(command, "gh", "issue", "create") else None
15+
16+
17+
if __name__ == "__main__":
18+
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 matches, 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, cwd=None):
14+
return REMINDER if matches(command, "gh", "pr", "create") else None
15+
16+
17+
if __name__ == "__main__":
18+
sys.exit(run(reminder))

0 commit comments

Comments
 (0)